From 0b34bdd990813e7f390b75960e136e857af09857 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 8 Sep 2026 16:27:38 +0200 Subject: [PATCH] Fit sprite size to the texture aspect ratio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 16 +++- src/core/properties/aspect.ts | 47 +++++++++++ src/editor/inspector/aspectAction.ts | 49 +++++++++++ src/editor/inspector/assetSection.ts | 29 +++++-- src/editor/inspector/fieldFactory.ts | 23 ++++-- src/editor/inspector/objectSection.ts | 115 +++++++++++++++----------- tests/core/aspect.test.ts | 83 +++++++++++++++++++ tools/e2e/15-aspect.mjs | 113 +++++++++++++++++++++++++ tools/e2e/index.mjs | 2 + 9 files changed, 412 insertions(+), 65 deletions(-) create mode 100644 src/core/properties/aspect.ts create mode 100644 src/editor/inspector/aspectAction.ts create mode 100644 tests/core/aspect.test.ts create mode 100644 tools/e2e/15-aspect.mjs diff --git a/README.md b/README.md index 26002ede..47dac9d4 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/core/properties/aspect.ts b/src/core/properties/aspect.ts new file mode 100644 index 00000000..22382767 --- /dev/null +++ b/src/core/properties/aspect.ts @@ -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; +} diff --git a/src/editor/inspector/aspectAction.ts b/src/editor/inspector/aspectAction.ts new file mode 100644 index 00000000..4c382fd8 --- /dev/null +++ b/src/editor/inspector/aspectAction.ts @@ -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 = { 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) }]; + }, + }, + ]; +} diff --git a/src/editor/inspector/assetSection.ts b/src/editor/inspector/assetSection.ts index 998d08cc..e9de7306 100644 --- a/src/editor/inspector/assetSection.ts +++ b/src/editor/inspector/assetSection.ts @@ -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,15 +92,27 @@ function buildProps(host: InspectorHost, asset: Asset, schema: readonly Property def, values[def.key] ?? def.default, assetHandlers(host, asset.id, def), - () => { - const target = findAsset(host.state.project, asset.id); - if (!target || !isModified(target, def.key)) return []; - return [ - { - label: 'Reset to default', - onSelect: () => host.state.execute(new ResetAssetPropertyCommand(asset.id, def.key)), + { + 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 [ + { + label: 'Reset to default', + onSelect: () => host.state.execute(new ResetAssetPropertyCommand(asset.id, def.key)), + }, + ]; + }, }, ); row.setModified(isModified(asset, def.key)); diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts index f05d5646..83ccc0be 100644 --- a/src/editor/inspector/fieldFactory.ts +++ b/src/editor/inspector/fieldFactory.ts @@ -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; diff --git a/src/editor/inspector/objectSection.ts b/src/editor/inspector/objectSection.ts index 20809214..661eaa9e 100644 --- a/src/editor/inspector/objectSection.ts +++ b/src/editor/inspector/objectSection.ts @@ -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( 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, ): FieldRow { @@ -310,50 +311,59 @@ function propertyRow( remove: (object) => delete object.overrides[def.key], commit: (value) => new SetOverrideCommand(sceneId, objectId, def.key, value), }), - () => { - const { template, asset } = bases(); - return [ - ...(template - ? [ - { - label: 'Reset to container', - onSelect: () => - host.state.execute(new RevertOverrideToTemplateCommand(sceneId, objectId, def.key)), - }, - ] - : []), - ...(asset - ? [ - { - label: 'Reset to asset', - onSelect: () => host.state.execute(new ClearOverrideCommand(sceneId, objectId, def.key)), - }, - ] - : []), - ]; - }, - () => { - const { template, asset } = bases(); - return [ - ...(template - ? [ - { - label: 'Apply to container', - onSelect: () => - host.state.execute(new ApplyOverrideToContainerCommand(sceneId, objectId, def.key)), - }, - ] - : []), - ...(asset - ? [ - { - label: 'Apply to asset', - onSelect: () => - host.state.execute(new ApplyOverrideToAssetCommand(sceneId, objectId, def.key)), - }, - ] - : []), - ]; + { + 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 + ? [ + { + label: 'Reset to container', + onSelect: () => + host.state.execute(new RevertOverrideToTemplateCommand(sceneId, objectId, def.key)), + }, + ] + : []), + ...(asset + ? [ + { + label: 'Reset to asset', + onSelect: () => host.state.execute(new ClearOverrideCommand(sceneId, objectId, def.key)), + }, + ] + : []), + ]; + }, + apply: () => { + const { template, asset } = bases(); + return [ + ...(template + ? [ + { + label: 'Apply to container', + onSelect: () => + host.state.execute(new ApplyOverrideToContainerCommand(sceneId, objectId, def.key)), + }, + ] + : []), + ...(asset + ? [ + { + label: 'Apply to asset', + onSelect: () => + host.state.execute(new ApplyOverrideToAssetCommand(sceneId, objectId, def.key)), + }, + ] + : []), + ]; + }, }, ); host.fields.set(def.key, 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; +} diff --git a/tests/core/aspect.test.ts b/tests/core/aspect.test.ts new file mode 100644 index 00000000..339d5fc9 --- /dev/null +++ b/tests/core/aspect.test.ts @@ -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); + }); +}); diff --git a/tools/e2e/15-aspect.mjs b/tools/e2e/15-aspect.mjs new file mode 100644 index 00000000..a4a07d1f --- /dev/null +++ b/tools/e2e/15-aspect.mjs @@ -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'); +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index e58562da..789c33b4 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -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, ];