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, ];