Fit sprite size to the texture aspect ratio

Add ↔ next to Width and ↕ next to Height in the inspector. Each sets its
field so the sprite matches the ratio of its assigned texture or frame,
reading the frame's own rect rather than the sheet's.

The buttons reuse the existing FieldActions convention, so they simply
do not appear when no texture is assigned, when the sprite is already
proportional, or on assets whose schema has no texture property.

Comparison is a cross product with a relative tolerance: 3135 of the
40000 size pairs up to 200x200 do not survive a round trip through a
division, so an exact check would leave the button standing after its
own click.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-08 16:27:38 +02:00
1 parent 4d84231b9f
commit 0b34bdd990
9 files changed
+362 -15

No files matched your search

+13 -3
View File
@@ -223,6 +223,15 @@ undo step. Deleting a frame leaves the sprites pointing at it: a dangling refere
plain colour and comes back by itself on undo. Deleting the whole texture counts the sprites
wearing *any* of its frames, not just the ones naming the texture directly.
**Fitting the sprite to the picture.** The *Width* and *Height* rows of a sprite carry a ↔ / ↕
button that sets that one side from the other and the picture's proportions — the frame's
rectangle when it wears a frame, the whole image otherwise. Like ↺ and ↑ the buttons exist only
when they would do something: no picture, an image not yet measured, or a sprite already in
proportion means no button at all. Fitting is an ordinary property write, so on an object it
becomes an override and it is one undo step. The comparison has a relative tolerance rather than
being exact — of the 40 000 picture sizes up to 200 × 200, 3135 do not survive the round trip
through a division, and an exact test would leave the button standing after its own click.
Rendering a frame is a texture matrix on the fill, not a Pixi sub-texture — a Graphics fill
ignores a `Texture`'s `frame` (measured, see `viewport/textureFill.ts`). One consequence is
worth having: a sheet stays **one** GPU upload however many frames of it are on screen.
@@ -344,7 +353,7 @@ rewritten by every e2e run and the **jump-run** project by its seeder. The **pla
project is the author's own and is maintained in the editor – every tool refuses to touch it.
The e2e run is a list of steps in `tools/e2e/` (hierarchy, inspector, play, assets, drawing,
graph, parenting, colliders, containers, text/enabled, menus, textures, frames, overflow) that build
graph, parenting, colliders, containers, text/enabled, menus, textures, frames, overflow, aspect) that build
on each other's scene state
and share the CDP driver in `tools/lib/harness.mjs` – `press`, `undo`, `play`, `object(id)`,
`world()`, `mutate(js)` and friends, so a check reads like the interaction it exercises.
@@ -400,6 +409,7 @@ Editing a container's template directly (today changes reach it only via **Apply
container** from an instance), reordering the hierarchy, several scenes in one run (a
`Load scene` node – screens are containers today), sound, auth, touch-optimised layout,
graph comments/groups. A frame is always stretched to the sprite's `width` × `height`: no
animation (nothing steps through a sheet's frames yet), no nine-slice, and no "size from
frame" button. Slicing cuts a regular grid only — no detection of where the sprites actually
animation (nothing steps through a sheet's frames yet) and no nine-slice. ↔ / ↕ fit the
proportions, but nothing sets a sprite to the picture's pixel size outright. Slicing cuts a
regular grid only — no detection of where the sprites actually
sit on the sheet. Unreferenced image files are never collected.
+47
View File
@@ -0,0 +1,47 @@
import type { PropertyBag } from '../model/Properties';
import type { Project } from '../model/Project';
import { resolveTexture } from '../model/texture';
export type SizeAxis = 'width' | 'height';
export interface PixelSize {
w: number;
h: number;
}
/**
* Pixel size of the picture a sprite wears: its frame's rectangle, else the whole image.
* Null while that is not known – an upload still running has no measured size yet, and a
* ratio taken from 0 × 0 would be invented.
*/
export function texturePixelSize(project: Project, props: PropertyBag): PixelSize | null {
const ref = resolveTexture(project, props);
if (!ref) return null;
const w = ref.rect ? ref.rect.w : ref.sourceWidth;
const h = ref.rect ? ref.rect.h : ref.sourceHeight;
return w > 0 && h > 0 ? { w, h } : null;
}
/**
* What `axis` has to become for the sprite to carry the picture's proportions, leaving the
* other side where it is. Null when the other side is not a usable number.
*/
export function fitAxisToAspect(size: PixelSize, props: PropertyBag, axis: SizeAxis): number | null {
const other = Number(props[axis === 'width' ? 'height' : 'width']);
if (!Number.isFinite(other) || other <= 0) return null;
const fitted = axis === 'width' ? (other * size.w) / size.h : (other * size.h) / size.w;
return Number.isFinite(fitted) && fitted > 0 ? fitted : null;
}
/**
* Whether the sprite already has the picture's proportions. Compared as a cross product with a
* relative tolerance, because fitting one side and then asking about the other must not answer
* "no" over a rounding error: 3135 of the 40 000 sizes up to 200 × 200 fail an exact test after
* their own fit (1 × 11 at height 100 gives 9.090909090909092, whose cross product is 100.000…1).
*/
export function matchesAspect(size: PixelSize, props: PropertyBag): boolean {
const w = Number(props.width);
const h = Number(props.height);
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return false;
return Math.abs(w * size.h - h * size.w) <= 1e-9 * w * size.h;
}
+49
View File
@@ -0,0 +1,49 @@
import type { Asset } from '../../core/model/Asset';
import type { PropertyBag } from '../../core/model/Properties';
import {
fitAxisToAspect,
matchesAspect,
type SizeAxis,
texturePixelSize,
} from '../../core/properties/aspect';
import { coerceValue, type PropertyDef, schemaForAsset } from '../../core/properties/PropertySchema';
import type { FieldActionGroup } from './fieldFactory';
import type { InspectorHost } from './host';
/** ↔ sets the width, ↕ the height – the side the button changes, not the one it reads. */
const AXIS_GLYPH: Record<SizeAxis, string> = { width: '↔', height: '↕' };
/**
* "Fit this side to the picture's proportions" for the Width/Height rows of an asset that also
* wears a texture. Empty for every other row, and – through an action list that comes back
* empty – while no texture or frame is assigned, so the button is not drawn at all.
*/
export function aspectActions(
host: InspectorHost,
asset: Asset,
def: PropertyDef,
props: () => PropertyBag | null,
commit: (value: number) => void,
): FieldActionGroup[] {
if (def.kind !== 'number' || (def.key !== 'width' && def.key !== 'height')) return [];
// A size without a picture (collider, camera) has no proportions to fit to.
if (!schemaForAsset(asset).some((d) => d.kind === 'texture')) return [];
const axis: SizeAxis = def.key;
return [
{
glyph: AXIS_GLYPH[axis],
available: () => {
const bag = props();
const size = bag && texturePixelSize(host.project(), bag);
if (!bag || !size || matchesAspect(size, bag)) return [];
const fitted = fitAxisToAspect(size, bag, axis);
if (fitted === null) return [];
// Offered only when the schema would store it unchanged: a value its min/max clamps
// cannot make the sprite proportional, so the button would never go away again.
const next = Number(coerceValue(def, fitted));
if (next !== fitted) return [];
return [{ label: `Fit to ${size.w} × ${size.h}`, onSelect: () => commit(next) }];
},
},
];
}
+13
View File
@@ -20,6 +20,7 @@ import {
} from '../../core/properties/PropertySchema';
import { isModified, resolveAssetProps } from '../../core/properties/resolve';
import { Button, Dropdown, HStack, Label, Menu, Spacer, TextField, Theme } from '../../ui';
import { aspectActions } from './aspectAction';
import { createField, createFieldRow, type FieldHandlers, labelledRow } from './fieldFactory';
import { addNote, addTitle, type InspectorHost } from './host';
import { previewingHandlers } from './previewHandlers';
@@ -91,7 +92,18 @@ function buildProps(host: InspectorHost, asset: Asset, schema: readonly Property
def,
values[def.key] ?? def.default,
assetHandlers(host, asset.id, def),
{
extra: aspectActions(
host,
asset,
def,
() => {
const target = findAsset(host.state.project, asset.id);
return target ? resolveAssetProps(target) : null;
},
(value) => host.state.execute(new SetAssetPropertyCommand(asset.id, def.key, value)),
),
reset: () => {
const target = findAsset(host.state.project, asset.id);
if (!target || !isModified(target, def.key)) return [];
return [
@@ -101,6 +113,7 @@ function buildProps(host: InspectorHost, asset: Asset, schema: readonly Property
},
];
},
},
);
row.setModified(isModified(asset, def.key));
host.fields.set(def.key, row);
+18 -5
View File
@@ -67,6 +67,19 @@ interface FieldAction {
*/
export type FieldActions = () => FieldAction[];
/** One trailing button of a row: its glyph, and what it currently offers. */
export interface FieldActionGroup {
glyph: string;
available: FieldActions;
}
export interface RowActions {
reset?: FieldActions;
apply?: FieldActions;
/** Row-specific buttons, drawn left of ↑/↺ – they act on the value rather than on its origin. */
extra?: readonly FieldActionGroup[];
}
/** A row starting with the fixed-width, muted label column. */
export function labelledRow(text: string): { row: HStack; label: Label } {
const row = new HStack({ gap: Theme.gap, align: 'center' });
@@ -83,7 +96,7 @@ export function labelledRow(text: string): { row: HStack; label: Label } {
* kind means adding a case here, adding a property means adding a schema line.
*
* A row shows its actions at the right: ↺ resets to the base value, ↑ applies the value to
* its source. Which of them exist is asked from `reset`/`apply` on every refresh: no entry
* its source, `extra` adds row-specific ones. Each group is asked on every refresh: no entry
* means no button, one entry means a direct button, several mean a menu (↺ ▾ / ↑ ▾).
*/
export function createFieldRow(
@@ -91,8 +104,7 @@ export function createFieldRow(
def: PropertyDef,
value: PropertyValue,
handlers: FieldHandlers,
reset?: FieldActions,
apply?: FieldActions,
rowActions: RowActions = {},
): FieldRow {
const { row, label } = labelledRow(def.label);
const field = createField(ctx, def, value, handlers);
@@ -115,8 +127,9 @@ export function createFieldRow(
actions.push({ button, glyph, available });
row.add(button);
};
if (apply) addActions('↑', apply);
if (reset) addActions('↺', reset);
for (const group of rowActions.extra ?? []) addActions(group.glyph, group.available);
if (rowActions.apply) addActions('↑', rowActions.apply);
if (rowActions.reset) addActions('↺', rowActions.reset);
const refreshActions = (): void => {
let changed = false;
+24 -7
View File
@@ -17,7 +17,7 @@ import {
import type { Asset, SceneAsset } from '../../core/model/Asset';
import { type TemplateLink, templateLinkOf } from '../../core/model/container';
import { findObject, isSelfOrAncestor, treeOrder } from '../../core/model/hierarchy';
import type { Axis, PropertyValue } from '../../core/model/Properties';
import type { Axis, PropertyBag, PropertyValue } from '../../core/model/Properties';
import { findAsset, findScene, type Project } from '../../core/model/Project';
import type { SceneObject } from '../../core/model/SceneObject';
import {
@@ -33,6 +33,7 @@ import {
} from '../../core/properties/resolve';
import { Button, Dropdown, Menu, type MenuItem, TextField } from '../../ui';
import { addCollider, createContainer, removeObjects } from '../actions';
import { aspectActions } from './aspectAction';
import { createFieldRow, type FieldActions, type FieldRow, labelledRow } from './fieldFactory';
import { addGap, addNote, addTitle, type InspectorHost } from './host';
import { previewingHandlers } from './previewHandlers';
@@ -134,7 +135,7 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
// The master switch, right where it is looked for: above everything type-specific.
for (const def of COMMON_PROPS)
propertyRow(host, sceneId, objectId, def, resolveSceneObjectProps(asset, object));
propertyRow(host, sceneId, objectId, asset, def, resolveSceneObjectProps(asset, object));
addTitle(host, object.parentId ? 'POSITION (LOCAL TO PARENT)' : 'POSITION');
for (const axis of POSITION_AXES) {
@@ -203,7 +204,7 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
if (own.length > 0 || asset.type === 'behavior')
addTitle(host, asset.type === 'behavior' ? 'VARIABLES' : 'PROPERTIES');
if (own.length === 0 && asset.type === 'behavior') addNote(host, 'The behavior declares no variables.');
for (const def of own) propertyRow(host, sceneId, objectId, def, values);
for (const def of own) propertyRow(host, sceneId, objectId, asset, def, values);
markObjectRows(host, object, asset, link);
if (asset.type === 'container') {
@@ -275,8 +276,7 @@ function componentRow<V extends object>(
toModel: (next, object) => withAxis(spec.read(object), next),
commit: spec.command,
}),
spec.reset,
spec.apply,
{ reset: spec.reset, apply: spec.apply },
);
host.fields.set(spec.key, row);
host.body.add(row.row);
@@ -295,6 +295,7 @@ function propertyRow(
host: InspectorHost,
sceneId: string,
objectId: string,
owner: Asset,
def: PropertyDef,
values: Record<string, PropertyValue>,
): FieldRow {
@@ -310,7 +311,15 @@ function propertyRow(
remove: (object) => delete object.overrides[def.key],
commit: (value) => new SetOverrideCommand(sceneId, objectId, def.key, value),
}),
() => {
{
extra: aspectActions(
host,
owner,
def,
() => currentProps(host.state, sceneId, objectId),
(value) => host.state.execute(new SetOverrideCommand(sceneId, objectId, def.key, value)),
),
reset: () => {
const { template, asset } = bases();
return [
...(template
@@ -332,7 +341,7 @@ function propertyRow(
: []),
];
},
() => {
apply: () => {
const { template, asset } = bases();
return [
...(template
@@ -355,6 +364,7 @@ function propertyRow(
: []),
];
},
},
);
host.fields.set(def.key, row);
host.body.add(row.row);
@@ -457,3 +467,10 @@ function currentObject(
const scene = findScene(state.project, sceneId);
return scene && findObject(scene, objectId);
}
/** The values the object shows right now, asset defaults and overrides merged. */
function currentProps(state: InspectorHost['state'], sceneId: string, objectId: string): PropertyBag | null {
const object = currentObject(state, sceneId, objectId);
const asset = object && findAsset(state.project, object.assetId);
return object && asset ? resolveSceneObjectProps(asset, object) : null;
}
+83
View File
@@ -0,0 +1,83 @@
import { describe, expect, it } from 'vitest';
import { CreateTextureCommand } from '../../src/core/commands/assetCommands';
import { AddFrameCommand } from '../../src/core/commands/frameCommands';
import type { PropertyBag } from '../../src/core/model/Properties';
import { fitAxisToAspect, matchesAspect, texturePixelSize } from '../../src/core/properties/aspect';
import { makeSpriteScene } from '../helpers';
const FILE = `${'a1b2c3d4'.repeat(8)}.png`;
/** A project with a 64 × 48 sheet carrying one 30 × 10 frame. */
function makeSheet(width = 64, height = 48) {
const fixture = makeSpriteScene();
const texture = new CreateTextureCommand('Sheet', { file: FILE, width, height });
fixture.stack.execute(texture);
const frame = new AddFrameCommand(texture.asset.id, { x: 2, y: 4, w: 30, h: 10 });
fixture.stack.execute(frame);
return { ...fixture, textureId: texture.asset.id, frameId: frame.frameId };
}
const props = (bag: PropertyBag): PropertyBag => ({ width: 100, height: 100, ...bag });
describe('texturePixelSize', () => {
it('is the whole image for a texture id and the rectangle for a frame id', () => {
const sheet = makeSheet();
expect(texturePixelSize(sheet.project, props({ texture: sheet.textureId }))).toEqual({ w: 64, h: 48 });
expect(texturePixelSize(sheet.project, props({ texture: sheet.frameId }))).toEqual({ w: 30, h: 10 });
});
it('is null when the sprite wears nothing, or an id that no longer resolves', () => {
const sheet = makeSheet();
expect(texturePixelSize(sheet.project, props({ texture: '' }))).toBeNull();
expect(texturePixelSize(sheet.project, props({}))).toBeNull();
expect(texturePixelSize(sheet.project, props({ texture: 'gone' }))).toBeNull();
});
it('is null while the image has not been measured, so no ratio is invented', () => {
const sheet = makeSheet(0, 0);
expect(texturePixelSize(sheet.project, props({ texture: sheet.textureId }))).toBeNull();
});
});
describe('fitAxisToAspect', () => {
const size = { w: 64, h: 48 };
it('takes the other side as given and returns the proportional one', () => {
expect(fitAxisToAspect(size, props({ height: 60 }), 'width')).toBe(80);
expect(fitAxisToAspect(size, props({ width: 80 }), 'height')).toBe(60);
});
it('returns null when the side it would read from is unusable', () => {
expect(fitAxisToAspect(size, props({ height: 0 }), 'width')).toBeNull();
expect(fitAxisToAspect(size, props({ height: -5 }), 'width')).toBeNull();
expect(fitAxisToAspect(size, props({ width: Number.NaN }), 'height')).toBeNull();
});
});
describe('matchesAspect', () => {
it('compares proportions, not sizes', () => {
const size = { w: 64, h: 48 };
expect(matchesAspect(size, props({ width: 64, height: 48 }))).toBe(true);
expect(matchesAspect(size, props({ width: 8, height: 6 }))).toBe(true);
expect(matchesAspect(size, props({ width: 100, height: 100 }))).toBe(false);
});
it('holds after fitting one side, whatever the division left behind', () => {
// Measured, not assumed: of the 40 000 size pairs up to 200 × 200, 3135 do not survive the
// round trip through a division. 1 × 11 is one of them, and an exact comparison would call
// the sprite this very button just fitted not proportional – leaving the button standing.
const size = { w: 1, h: 11 };
const width = fitAxisToAspect(size, props({ height: 100 }), 'width')!;
expect(width * size.h).not.toBe(100 * size.w);
expect(matchesAspect(size, props({ width, height: 100 }))).toBe(true);
const height = fitAxisToAspect(size, props({ width }), 'height')!;
expect(height).not.toBe(100);
expect(matchesAspect(size, props({ width, height }))).toBe(true);
});
it('is false for a degenerate sprite rather than dividing by zero', () => {
const size = { w: 64, h: 48 };
expect(matchesAspect(size, props({ width: 0, height: 0 }))).toBe(false);
expect(matchesAspect(size, props({ width: Number.NaN, height: 10 }))).toBe(false);
});
});
+113
View File
@@ -0,0 +1,113 @@
/**
* The ↔ / ↕ buttons beside a sprite's Width and Height: fit that side to the proportions of the
* picture the sprite wears, and not exist at all while it wears none.
*
* Runs on the sheet the texture steps imported (64 × 48) and adds a frame with a deliberately
* different shape, which is what makes "sheet or frame?" a question the numbers can answer.
*/
/** The rows are read as numbers, so a float from a division needs a tolerance. */
const near = (value, expected) => Math.abs(value - expected) < 0.01;
const spriteProp = (key) => `__gm.state.project.assets.find((a) => a.id === 'tex-sprite').props.${key}`;
export async function aspect(t) {
const { check, sleep, json, evaluate, shot, clickText, press, undo, mutate, select } = t;
await press('Escape');
await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()');
const sheet = await json("(__gm.state.project.assets.find((a) => a.type === 'texture') ?? {}).id ?? ''");
check('the imported sheet is still there', sheet !== '', sheet);
// 26. A sprite wearing nothing has nothing to be proportional to, so the buttons are absent.
// Edited as an asset: one base value per row instead of an override, so the numbers below
// say what they mean.
await mutate(`
const props = __gm.state.project.assets.find((a) => a.id === 'tex-sprite').props;
props.texture = '';
props.width = 100;
props.height = 100`);
await select({ kind: 'asset', assetId: 'tex-sprite' });
await sleep(600);
check('the sprite asset is in the inspector', Boolean(await json("__gm.locate('Width')")));
check(
'no texture, no aspect buttons',
(await json("__gm.locate('↔')")) === null && (await json("__gm.locate('↕')")) === null,
);
await shot('40-aspect-without-a-texture');
// 27. Wearing the 64 x 48 sheet, a 100 x 100 sprite is out of proportion – both appear.
await mutate(`${spriteProp('texture')} = ${JSON.stringify(sheet)}`);
await sleep(500);
check(
'assigning a texture brings both buttons out',
Boolean(await json("__gm.locate('↔')")) && Boolean(await json("__gm.locate('↕')")),
);
await shot('41-aspect-buttons');
// 28. ↔ moves the width only, to the sheet's ratio: 100 x 64/48.
await clickText('↔');
await sleep(500);
const fitted = { w: await json(spriteProp('width')), h: await json(spriteProp('height')) };
check(
'↔ sets the width from the height and the sheet ratio',
near(fitted.w, (100 * 64) / 48) && fitted.h === 100,
JSON.stringify(fitted),
);
check(
'and both buttons go once the sprite is proportional',
(await json("__gm.locate('↔')")) === null && (await json("__gm.locate('↕')")) === null,
);
await shot('42-aspect-fitted');
// 28a. One command, so it is one undo step – and the buttons come back with the old shape.
await undo();
await sleep(500);
check('undo takes the width back', (await json(spriteProp('width'))) === 100);
check('and the buttons return with it', Boolean(await json("__gm.locate('↔')")));
await t.redo();
await sleep(500);
// 29. A frame is the picture the sprite wears, not the sheet it was cut from: 40 x 10 against
// the sheet's 64 x 48, so the two ratios cannot be confused for one another.
await mutate(`
const asset = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(sheet)});
asset.frames.push({ id: 'aspect-frame', name: 'Wide', x: 0, y: 0, w: 40, h: 10 });
${spriteProp('texture')} = 'aspect-frame'`);
await sleep(500);
check('a frame of another shape brings the buttons back', Boolean(await json("__gm.locate('↕')")));
const before = await json(spriteProp('width'));
await clickText('↕');
await sleep(500);
const framed = { w: await json(spriteProp('width')), h: await json(spriteProp('height')) };
check(
'↕ sets the height from the frame ratio, not the sheet it belongs to',
near(framed.h, (before * 10) / 40) && framed.w === before,
JSON.stringify({ framed, sheetWouldGive: (before * 48) / 64 }),
);
check(
'and the buttons go again',
(await json("__gm.locate('↔')")) === null && (await json("__gm.locate('↕')")) === null,
);
// 30. The same rows on a placed object write an override rather than the asset value.
await mutate(`${spriteProp('texture')} = ${JSON.stringify(sheet)}`);
await select({ kind: 'object', sceneId: 'scene1', objectIds: ['otex'], objectId: 'otex' });
await sleep(600);
await mutate("__gm.state.activeScene.objects.find((o) => o.id === 'otex').overrides.height = 90");
await sleep(400);
check('the object row offers the fit as well', Boolean(await json("__gm.locate('↔')")));
// The crowded case: an overridden, out-of-proportion row carries ↕ next to ↑ and ↺.
check('and shows it beside the override buttons', (await json('__gm.textOverflows()')).length === 0);
await shot('43-aspect-beside-the-override-buttons');
await clickText('↔');
await sleep(500);
const override = await json("__gm.state.activeScene.objects.find((o) => o.id === 'otex').overrides");
check(
'fitting a scene object writes an override, leaving the asset alone',
near(override.width, (90 * 64) / 48) && near(await json(spriteProp('width')), framed.w),
JSON.stringify({ override, asset: await json(spriteProp('width')) }),
);
await shot('44-aspect-on-an-object');
}
+2
View File
@@ -17,6 +17,7 @@ import { menus } from './11-menus.mjs';
import { textures } from './12-textures.mjs';
import { frames } from './13-frames.mjs';
import { overflow } from './14-overflow.mjs';
import { aspect } from './15-aspect.mjs';
export const STEPS = [
hierarchy,
@@ -33,4 +34,5 @@ export const STEPS = [
textures,
frames,
overflow,
aspect,
];