import { describe, expect, it } from 'vitest'; import { CreateContainerCommand } from '../../src/core/commands/containerCommands'; import { MoveSceneObjectCommand, SetOverrideCommand, SetParentCommand, } from '../../src/core/commands/sceneObjectCommands'; import { worldPositionOf, worldTransformOf } from '../../src/core/graph'; import type { ContainerAsset } from '../../src/core/model/Asset'; import { hasSceneIcon, isSymbolType } from '../../src/core/model/AssetTypes'; import { apply, decompose, invert, multiply, scaleOf, transformMatrix, translation, } from '../../src/core/model/matrix'; import { findAsset } from '../../src/core/model/Project'; import { footprintContains, footprintCorners, footprintExtent } from '../../src/core/properties/bounds'; import { hasTransformProps, isCommonProp, isValidVariableName, schemaFor, } from '../../src/core/properties/PropertySchema'; import { carriedTransform, localPositionFor, worldTransform } from '../../src/core/properties/transform'; import { fakeHandle, makeSpriteScene, place, startWorld } from '../helpers'; const close = (p: { x: number; y: number }, x: number, y: number) => { expect(p.x).toBeCloseTo(x, 9); expect(p.y).toBeCloseTo(y, 9); }; describe('matrix', () => { it('turns clockwise on screen, scales about the origin, mirrors before turning', () => { close(apply(transformMatrix(0, 0, 90, 1, false, false), { x: 1, y: 0 }), 0, 1); close(apply(transformMatrix(10, 20, 0, 2, false, false), { x: 1, y: 1 }), 12, 22); close(apply(transformMatrix(0, 0, 0, 1, true, false), { x: 1, y: 2 }), -1, 2); close(apply(transformMatrix(0, 0, 90, 1, true, false), { x: 1, y: 0 }), 0, -1); }); it('a product undoes through its inverse, and a similarity decomposes back into its rows', () => { const m = multiply( transformMatrix(5, 6, 30, 2, false, false), transformMatrix(1, 2, 15, 0.5, true, false), ); close(apply(invert(m), apply(m, { x: 3, y: 4 })), 3, 4); expect(scaleOf(m)).toBeCloseTo(1); expect(decompose(m)).toMatchObject({ mirrorX: true }); expect(decompose(m).rotation).toBeCloseTo(45); const plain = decompose(transformMatrix(0, 0, 30, 2, false, false)); expect(plain.rotation).toBeCloseTo(30, 9); expect(plain.scale).toBeCloseTo(2, 9); expect(plain.mirrorX).toBe(false); }); }); describe('transform chain', () => { it('a child sits where its turned, sized parent puts it, in the model and in the world', () => { const f = makeSpriteScene(); const parent = place(f, f.spriteId, { x: 100, y: 100 }); // `place` takes a world position; the local one is set outright. const child = place(f, f.spriteId, { x: 0, y: 0 }, parent); f.stack.execute(new MoveSceneObjectCommand(f.sceneId, child, { x: 50, y: 0, z: 0 })); f.stack.execute(new SetOverrideCommand(f.sceneId, parent, 'rotation', 90)); f.stack.execute(new SetOverrideCommand(f.sceneId, parent, 'scale', 2)); // Local (50, 0) → scaled (100, 0) → turned a quarter clockwise (0, 100) → at (100, 200). close(f.world(child), 100, 200); const chain = worldTransform(f.project, f.sceneOf(), f.object(child)); expect(scaleOf(chain.m)).toBeCloseTo(2); expect(decompose(chain.m).rotation).toBeCloseTo(90); const { world } = startWorld(f); close(worldPositionOf(world.findByObjectId(child)!), 100, 200); // Behaviors write the rows like any property: the next read of the chain sees it. world.findByObjectId(parent)!.props.rotation = 0; close(worldPositionOf(world.findByObjectId(child)!), 200, 100); }); it('the rows are reserved names shared by drawn types, absent from cameras and behaviors', () => { expect(schemaFor('sprite').map((d) => d.key)).toEqual( expect.arrayContaining(['scale', 'rotation', 'mirrorX', 'mirrorY']), ); expect(hasTransformProps('camera')).toBe(false); expect(schemaFor('camera').some((d) => d.key === 'rotation')).toBe(false); expect(isCommonProp('rotation')).toBe(true); expect(isValidVariableName('scale')).toBe(false); // A camera under a turned parent is still placed by the chain; its own props carry no rows. const parent = fakeHandle({ rotation: 90 }); const camera = { ...fakeHandle({}, parent), x: 10, y: 0 }; close(worldPositionOf(camera), 0, 10); expect(scaleOf(worldTransformOf(camera).m)).toBeCloseTo(1); }); it('the local position for a world point goes back through the parent transform', () => { const f = makeSpriteScene(); const parent = place(f, f.spriteId, { x: 100, y: 100 }); f.stack.execute(new SetOverrideCommand(f.sceneId, parent, 'rotation', 90)); const local = localPositionFor(f.project, f.sceneOf(), parent, { x: 100, y: 150, z: 0 }); close(local, 50, 0); const child = place(f, f.spriteId, { x: 100, y: 150 }, parent); close(f.world(child), 100, 150); }); }); describe('carried transform', () => { it('re-parenting keeps the look: rows absorb the parent chain, nothing is written between upright parents', () => { const f = makeSpriteScene(); const turned = place(f, f.spriteId, { x: 0, y: 0 }); const upright = place(f, f.spriteId, { x: 300, y: 0 }); const box = place(f, f.spriteId, { x: 100, y: 0 }); f.stack.execute(new SetOverrideCommand(f.sceneId, turned, 'rotation', 90)); f.stack.execute(new SetOverrideCommand(f.sceneId, turned, 'scale', 2)); f.stack.execute(new SetOverrideCommand(f.sceneId, box, 'mirrorY', true)); f.stack.execute(new SetParentCommand(f.sceneId, box, upright)); expect(f.object(box).overrides).toEqual({ mirrorY: true }); close(f.world(box), 100, 0); f.stack.execute(new SetParentCommand(f.sceneId, box, turned)); close(f.world(box), 100, 0); const world = worldTransform(f.project, f.sceneOf(), f.object(box)).m; expect(decompose(world)).toMatchObject({ mirrorX: true }); expect(scaleOf(world)).toBeCloseTo(1); expect(f.object(box).overrides.rotation).toBeCloseTo(-90); expect(f.object(box).overrides.scale).toBeCloseTo(0.5); expect(f.object(box).overrides.mirrorY).toBe(true); f.stack.undo(); expect(f.object(box).overrides).toEqual({ mirrorY: true }); expect(f.object(box).parentId).toBe(upright); }); it('a mirror in the chain is absorbed as a mirror, whichever way it was written', () => { const own = { rotation: 0, scale: 1, mirrorX: false, mirrorY: false }; const before = transformMatrix(0, 0, 0, 1, true, false); const carried = carriedTransform(own, before, translation(0, 0)); expect(carried).toMatchObject({ mirrorX: true, mirrorY: false }); expect(carried.scale).toBeCloseTo(1); expect(carried.rotation).toBeCloseTo(0); }); it('grouping under a turned parent keeps every root where it was, rows included', () => { const f = makeSpriteScene(); const parent = place(f, f.spriteId, { x: 0, y: 0 }); const a = place(f, f.spriteId, { x: 100, y: 0 }, parent); const b = place(f, f.spriteId, { x: 100, y: 50 }, parent); f.stack.execute(new SetOverrideCommand(f.sceneId, parent, 'rotation', 90)); const worldsBefore = [f.world(a), f.world(b)]; const create = new CreateContainerCommand(f.sceneId, [a, b]); f.stack.execute(create); const asset = findAsset(f.project, create.assetId) as ContainerAsset; // Same parent for both: positions relative to the container in the parent's frame, no rows. expect(asset.objects.map((o) => o.overrides)).toEqual([{}, {}]); const contents = f.viewOf().objects.filter((o) => o.parentId === create.objectId); expect(contents).toHaveLength(2); const worldsAfter = contents.map((o) => f.world(o.id)); for (const before of worldsBefore) expect( worldsAfter.some((w) => Math.abs(w.x - before.x) < 1e-9 && Math.abs(w.y - before.y) < 1e-9), ).toBe(true); }); }); describe('footprint under a transform', () => { it('corners, extent and hit test follow the turn, symbols stay upright', () => { const m = transformMatrix(100, 100, 90, 2, false, false); const props = { width: 50, height: 10 }; const extent = footprintExtent('sprite', props, m); expect(extent.left).toBeCloseTo(90); expect(extent.right).toBeCloseTo(110); expect(extent.top).toBeCloseTo(50); expect(extent.bottom).toBeCloseTo(150); expect(footprintContains('sprite', props, m, { x: 100, y: 145 })).toBe(true); expect(footprintContains('sprite', props, m, { x: 145, y: 100 })).toBe(false); // Two world units of padding around a box drawn at scale 2 is one local unit on every side. const padded = footprintCorners('sprite', props, m, 2); expect(padded[0]!.y).toBeCloseTo(48); // A behavior's diamond keeps its size and stays upright: the frame does not scale by 2. const symbol = footprintExtent('behavior', {}, m); expect(symbol.right - symbol.left).toBe(28); // Since a source wears an icon of its own (#89), the square that can be grabbed covers it // instead of growing with the source's scale. const source = footprintExtent('particles', {}, m); expect(source.right - source.left).toBe(24); expect(footprintContains('particles', {}, m, { x: 111, y: 100 })).toBe(true); expect(footprintContains('particles', {}, m, { x: 121, y: 100 })).toBe(false); }); it('a source wears an icon without becoming a symbol', () => { expect(hasSceneIcon('particles')).toBe(true); // The decisive one: `isSymbolType` does not mean "has an icon", it means "editor decoration". // Were a source one, `collectWorldItems` and the Game tab would both drop it and the effect // would never be seen outside the Scene tab. expect(isSymbolType('particles')).toBe(false); for (const type of ['camera', 'behavior', 'collider', 'container'] as const) expect(hasSceneIcon(type)).toBe(true); expect(hasSceneIcon('sprite')).toBe(false); expect(hasSceneIcon('text')).toBe(false); }); });