Transform: scale, rotation and mirror as properties, world transform as a matrix chain, oriented colliders (#76)
game-maker / suite (push) Successful in 9m5s

Four new rows on sprite, text, collider and container (block TRANSFORM):
scale, rotation in degrees clockwise, mirrorX, mirrorY. As properties they
get override/reset/apply, loading of old projects, write-back after play and
the Properties node for free; camera and behavior have none.

The hierarchy composes T*R*S per level (composeAlongChain replaces
sumAlongChain), so children rotate and scale with their parent. Reparenting
and grouping keep the look (carriedTransform). Drawing goes through
placeNode, the gizmo is a polygon, cull and objectRect use the AABB, drags
are computed in the parent's system.

Colliders are oriented boxes: SAT for box/box, box-circle in the box frame;
exitDistance replaces axisSeparation/axisCorrection, moveAndSlide moves along
the object's own axes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-13 17:25:06 +02:00
1 parent f85606427b
commit 016e83c9b5
33 files changed
+1346 -274

No files matched your search

+184
View File
@@ -0,0 +1,184 @@
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 {
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);
});
});