Transform: scale, rotation and mirror as properties, world transform as a matrix chain, oriented colliders (#76)
game-maker / suite (push) Successful in 9m5s
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:
1 parent
f85606427b
commit
016e83c9b5
33 files changed
+1346
-274
No files matched your search
+67
-21
@@ -3,16 +3,26 @@ import { SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
||||
import { AddColliderCommand } from '../../src/core/commands/colliderCommands';
|
||||
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||||
import { findAsset } from '../../src/core/model/Project';
|
||||
import { transformMatrix, translation } from '../../src/core/model/matrix';
|
||||
import {
|
||||
axisCorrection,
|
||||
axisSeparation,
|
||||
colliderShape,
|
||||
exitDistance,
|
||||
shapeContains,
|
||||
shapesOverlap,
|
||||
} from '../../src/core/properties/collider';
|
||||
import { graphEditor, makeBehavior, makeSpriteScene, place, roundTrip, startWorld } from '../helpers';
|
||||
|
||||
const box = (x: number, y: number, w: number, h: number) => ({ kind: 'box' as const, x, y, w, h });
|
||||
const box = (x: number, y: number, w: number, h: number, degrees = 0) => ({
|
||||
kind: 'box' as const,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
angle: (degrees * Math.PI) / 180,
|
||||
});
|
||||
const circle = (x: number, y: number, r: number) => ({ kind: 'circle' as const, x, y, r });
|
||||
const LEFT = { x: -1, y: 0 };
|
||||
const UP = { x: 0, y: -1 };
|
||||
|
||||
describe('collider geometry', () => {
|
||||
it('box vs box: overlapping yes, touching edges no', () => {
|
||||
@@ -21,6 +31,19 @@ describe('collider geometry', () => {
|
||||
expect(shapesOverlap(box(0, 0, 100, 100), box(0, 150, 100, 100))).toBe(false);
|
||||
});
|
||||
|
||||
it('a turned box reaches where its corners are, not where its upright frame would be', () => {
|
||||
// A 100×20 bar turned by 45° reaches ≈ 42.4 along x; upright it would reach 50.
|
||||
expect(shapesOverlap(box(0, 0, 100, 20, 45), box(95, 0, 100, 100))).toBe(false);
|
||||
expect(shapesOverlap(box(0, 0, 100, 20), box(95, 0, 100, 100))).toBe(true);
|
||||
// A diamond (square turned by 45°, corners at ±70.7) and a square at (110, 110): their upright
|
||||
// frames overlap (120.7 > 110 on both axes) but the square's corner (60, 60) lies outside the
|
||||
// diamond's edge x + y = 70.7.
|
||||
expect(shapesOverlap(box(0, 0, 100, 100, 45), box(110, 110, 100, 100))).toBe(false);
|
||||
expect(shapesOverlap(box(0, 0, 100, 100, 45), box(95, 0, 100, 100))).toBe(true);
|
||||
expect(shapeContains(box(0, 0, 100, 20, 90), { x: 0, y: 45 })).toBe(true);
|
||||
expect(shapeContains(box(0, 0, 100, 20, 90), { x: 45, y: 0 })).toBe(false);
|
||||
});
|
||||
|
||||
it('circle vs circle and box vs circle (corner case: the closest point is a box corner)', () => {
|
||||
expect(shapesOverlap(circle(0, 0, 10), circle(15, 0, 10))).toBe(true);
|
||||
expect(shapesOverlap(circle(0, 0, 10), circle(20, 0, 10))).toBe(false);
|
||||
@@ -29,28 +52,51 @@ describe('collider geometry', () => {
|
||||
// Corner at (50,50): a circle at (60,60) with r=10 has distance √200 ≈ 14.1 → no overlap; r=15 → overlap.
|
||||
expect(shapesOverlap(circle(60, 60, 10), box(0, 0, 100, 100))).toBe(false);
|
||||
expect(shapesOverlap(circle(60, 60, 15), box(0, 0, 100, 100))).toBe(true);
|
||||
// The same corner turned to (0, 70.7): the circle at (0, 80) with r=10 just misses it.
|
||||
expect(shapesOverlap(circle(0, 80, 10), box(0, 0, 100, 100, 45))).toBe(true);
|
||||
expect(shapesOverlap(circle(0, 81, 10), box(0, 0, 100, 100, 45))).toBe(false);
|
||||
});
|
||||
|
||||
it('builds shapes from properties with defaults', () => {
|
||||
expect(colliderShape({}, { x: 1, y: 2 })).toEqual(box(1, 2, 100, 100));
|
||||
expect(colliderShape({ shape: 'circle', radius: 7 }, { x: 0, y: 0 })).toEqual(circle(0, 0, 7));
|
||||
it('builds shapes from properties with defaults, sized and turned by the transform', () => {
|
||||
expect(colliderShape({}, translation(1, 2))).toEqual(box(1, 2, 100, 100));
|
||||
expect(colliderShape({ shape: 'circle', radius: 7 }, translation(0, 0))).toEqual(circle(0, 0, 7));
|
||||
const turned = colliderShape({ width: 40, height: 20 }, transformMatrix(5, 6, 30, 2, false, false));
|
||||
expect(turned).toMatchObject({ kind: 'box', x: 5, y: 6, w: 80, h: 40 });
|
||||
expect(turned.kind === 'box' && (turned.angle * 180) / Math.PI).toBeCloseTo(30);
|
||||
// A mirror keeps a box a box: the same shape, whichever way its x axis points.
|
||||
const mirrored = colliderShape({ width: 40, height: 20 }, transformMatrix(0, 0, 0, 1, true, false));
|
||||
expect(shapeContains(mirrored, { x: 19, y: 9 })).toBe(true);
|
||||
expect(shapeContains(mirrored, { x: 21, y: 9 })).toBe(false);
|
||||
expect(colliderShape({ shape: 'circle', radius: 7 }, transformMatrix(0, 0, 0, 3, false, false))).toEqual(
|
||||
circle(0, 0, 21),
|
||||
);
|
||||
});
|
||||
|
||||
it('axis separation: the centre distance at which shapes touch along one axis', () => {
|
||||
expect(axisSeparation(box(0, 0, 100, 60), box(0, 10, 40, 60), 'x')).toBe(70);
|
||||
expect(axisSeparation(box(0, 0, 100, 60), box(0, 100, 40, 60), 'x')).toBe(0); // apart on y
|
||||
expect(axisSeparation(circle(0, 0, 10), circle(0, 12, 10), 'x')).toBeCloseTo(16);
|
||||
expect(axisSeparation(box(0, 0, 100, 100), circle(0, 20, 10), 'x')).toBe(60); // flat side
|
||||
expect(axisSeparation(box(0, 0, 100, 100), circle(0, 56, 10), 'x')).toBeCloseTo(50 + 8); // corner: dy=6 → √(100−36)
|
||||
expect(axisSeparation(box(0, 0, 100, 100), circle(0, 70, 10), 'x')).toBe(0);
|
||||
});
|
||||
|
||||
it('axis correction: how far a mover must back off to just touch', () => {
|
||||
// Mover moved right into a box: centres 80 apart, need 100 → push back 20.
|
||||
expect(axisCorrection(box(0, 0, 100, 100), box(80, 0, 100, 100), 'x', 1)).toBe(20);
|
||||
expect(axisCorrection(box(80, 0, 100, 100), box(0, 0, 100, 100), 'x', -1)).toBe(20);
|
||||
expect(axisCorrection(box(0, 0, 100, 100), box(120, 0, 100, 100), 'x', 1)).toBe(0);
|
||||
expect(axisCorrection(box(0, 0, 100, 100), box(0, 90, 100, 100), 'y', 1)).toBe(10);
|
||||
it('exit distance: how far a mover must travel to just clear an obstacle', () => {
|
||||
// Mover moved right into a box: centres 80 apart, need 100 → 20 back to the left.
|
||||
expect(exitDistance(box(80, 0, 100, 100), box(0, 0, 100, 100), { x: 1, y: 0 })).toBe(20);
|
||||
expect(exitDistance(box(0, 0, 100, 100), box(80, 0, 100, 100), LEFT)).toBe(20);
|
||||
expect(exitDistance(box(0, 0, 100, 100), box(120, 0, 100, 100), LEFT)).toBe(0);
|
||||
expect(exitDistance(box(0, 0, 100, 100), box(0, 90, 100, 100), UP)).toBe(10);
|
||||
expect(exitDistance(box(0, 10, 40, 60), box(0, 0, 100, 60), LEFT)).toBe(70);
|
||||
expect(exitDistance(box(0, 100, 40, 60), box(0, 0, 100, 60), LEFT)).toBe(0); // apart on y
|
||||
expect(exitDistance(circle(0, 12, 10), circle(0, 0, 10), LEFT)).toBeCloseTo(16);
|
||||
expect(exitDistance(circle(0, 20, 10), box(0, 0, 100, 100), LEFT)).toBeCloseTo(60); // flat side
|
||||
expect(exitDistance(circle(0, 56, 10), box(0, 0, 100, 100), LEFT)).toBeCloseTo(50 + 8); // corner: dy=6 → √(100−36)
|
||||
expect(exitDistance(circle(0, 70, 10), box(0, 0, 100, 100), LEFT)).toBe(0);
|
||||
// Moving away diagonally clears a corner sooner than along one axis would.
|
||||
expect(exitDistance(box(80, 80, 100, 100), box(0, 0, 100, 100), { x: 1, y: 1 })).toBeCloseTo(
|
||||
20 * Math.SQRT2,
|
||||
);
|
||||
// A wall turned by 45°, a box moving left into its slanted face: level with the corner it must
|
||||
// back off to the corner (70.7 + 10 − 60), higher up the face is only half a unit deep along
|
||||
// its normal – an upright frame would still demand the 20.7.
|
||||
expect(exitDistance(box(-60, 0, 20, 20), box(0, 0, 100, 100, 45), LEFT)).toBeCloseTo(
|
||||
50 * Math.SQRT2 + 10 - 60,
|
||||
);
|
||||
expect(exitDistance(box(-60, 30, 20, 20), box(0, 0, 100, 100, 45), LEFT)).toBeCloseTo(
|
||||
(50 + 10 * Math.SQRT2 - 90 * Math.SQRT1_2) * Math.SQRT2,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -24,6 +24,10 @@ describe('asset commands', () => {
|
||||
expect(project.activeSceneId).toBe(sceneId);
|
||||
expect(findAsset(project, spriteId)?.props).toEqual({
|
||||
enabled: true,
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
mirrorX: false,
|
||||
mirrorY: false,
|
||||
width: 100,
|
||||
height: 100,
|
||||
color: 0xffffff,
|
||||
|
||||
@@ -11,12 +11,12 @@ import {
|
||||
ancestorIdsOf,
|
||||
descendantsOf,
|
||||
findObject,
|
||||
localPositionFor,
|
||||
type ObjectTree,
|
||||
treeOrder,
|
||||
visibleTreeOrder,
|
||||
} from '../../src/core/model/hierarchy';
|
||||
import { findScene } from '../../src/core/model/Project';
|
||||
import { localPositionFor } from '../../src/core/properties/transform';
|
||||
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
|
||||
import {
|
||||
fakeContext,
|
||||
@@ -39,7 +39,7 @@ function threeBoxes() {
|
||||
|
||||
describe('scene hierarchy', () => {
|
||||
it('re-parenting keeps the world position and moves children along with the parent', () => {
|
||||
const { stack, sceneId, a, b, c, sceneOf, object, world } = threeBoxes();
|
||||
const { project, stack, sceneId, a, b, c, sceneOf, object, world } = threeBoxes();
|
||||
stack.execute(new SetParentCommand(sceneId, b, a));
|
||||
// b stays at world (10,0): local becomes (-90,-100,-1).
|
||||
expect(object(b).parentId).toBe(a);
|
||||
@@ -63,7 +63,7 @@ describe('scene hierarchy', () => {
|
||||
expect(object(b).parentId).toBe('');
|
||||
expect(object(b).position).toEqual({ x: 10, y: 0, z: 0 });
|
||||
|
||||
expect(localPositionFor(sceneOf(), a, { x: 100, y: 100, z: 1 })).toEqual({ x: 0, y: 0, z: 0 });
|
||||
expect(localPositionFor(project, sceneOf(), a, { x: 100, y: 100, z: 1 })).toEqual({ x: 0, y: 0, z: 0 });
|
||||
});
|
||||
|
||||
it('refuses cycles and self-parenting', () => {
|
||||
|
||||
@@ -25,6 +25,10 @@ describe('PropertySchema', () => {
|
||||
it('provides defaults for every key', () => {
|
||||
expect(defaultsFor('sprite')).toEqual({
|
||||
enabled: true,
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
mirrorX: false,
|
||||
mirrorY: false,
|
||||
width: 100,
|
||||
height: 100,
|
||||
color: 0xffffff,
|
||||
@@ -51,6 +55,10 @@ describe('resolve', () => {
|
||||
sprite.props = { width: 50, bogus: 1 };
|
||||
expect(resolveAssetProps(project, sprite)).toEqual({
|
||||
enabled: true,
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
mirrorX: false,
|
||||
mirrorY: false,
|
||||
width: 50,
|
||||
height: 100,
|
||||
color: 0xffffff,
|
||||
@@ -65,6 +73,10 @@ describe('resolve', () => {
|
||||
const object = createSceneObject('o1', sprite.id, { x: 10, y: 20 });
|
||||
expect(resolveSceneObjectProps(project, sprite, object)).toEqual({
|
||||
enabled: true,
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
mirrorX: false,
|
||||
mirrorY: false,
|
||||
width: 100,
|
||||
height: 100,
|
||||
color: 0x00ff00,
|
||||
|
||||
@@ -61,6 +61,10 @@ describe('serialize', () => {
|
||||
expect(project.assets[1]!.name).toBe('sprite');
|
||||
expect(project.assets[1]!.props).toEqual({
|
||||
enabled: true,
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
mirrorX: false,
|
||||
mirrorY: false,
|
||||
width: 10,
|
||||
height: 100,
|
||||
color: 0xffffff,
|
||||
|
||||
@@ -458,7 +458,18 @@ describe('a sprite wearing a shader', () => {
|
||||
it('has the shader picker and, once one is picked, that shader’s rows', () => {
|
||||
const f = wearing();
|
||||
const keys = () => schemaForAsset(f.project, f.sprite()).map((d) => d.key);
|
||||
expect(keys()).toEqual(['enabled', 'width', 'height', 'color', 'texture', 'shader']);
|
||||
expect(keys()).toEqual([
|
||||
'enabled',
|
||||
'scale',
|
||||
'rotation',
|
||||
'mirrorX',
|
||||
'mirrorY',
|
||||
'width',
|
||||
'height',
|
||||
'color',
|
||||
'texture',
|
||||
'shader',
|
||||
]);
|
||||
expect(schemaFor('sprite').find((d) => d.key === SHADER_KEY)).toMatchObject({
|
||||
kind: 'asset',
|
||||
assetTypes: ['shader'],
|
||||
@@ -467,6 +478,10 @@ describe('a sprite wearing a shader', () => {
|
||||
f.stack.execute(new SetAssetPropertyCommand(f.spriteId, SHADER_KEY, f.s.id));
|
||||
expect(keys()).toEqual([
|
||||
'enabled',
|
||||
'scale',
|
||||
'rotation',
|
||||
'mirrorX',
|
||||
'mirrorY',
|
||||
'width',
|
||||
'height',
|
||||
'color',
|
||||
@@ -479,14 +494,14 @@ describe('a sprite wearing a shader', () => {
|
||||
expect(assetDeviates(f.project, f.sprite())).toBe(true);
|
||||
// A dangling id adds no rows and draws nothing – the picker still shows it.
|
||||
f.sprite().props[SHADER_KEY] = 'gone';
|
||||
expect(keys()).toHaveLength(6);
|
||||
expect(keys()).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('gives a placed object the rows of the shader it overrides, filled with the node defaults', () => {
|
||||
const f = wearing();
|
||||
const id = place(f, f.spriteId, { x: 0, y: 0 });
|
||||
const object = () => f.object(id);
|
||||
expect(schemaForObject(f.project, f.sprite(), object())).toHaveLength(6);
|
||||
expect(schemaForObject(f.project, f.sprite(), object())).toHaveLength(10);
|
||||
f.stack.execute(new SetOverrideCommand(f.sceneId, id, SHADER_KEY, f.s.id));
|
||||
expect(schemaForObject(f.project, f.sprite(), object()).map((d) => d.key)).toContain('saturation');
|
||||
expect(resolveSceneObjectProps(f.project, f.sprite(), object())).toMatchObject({
|
||||
|
||||
@@ -50,6 +50,10 @@ describe('CreateAssetCommand with starting props', () => {
|
||||
|
||||
expect(findAsset(project, sprite.asset.id)?.props).toEqual({
|
||||
enabled: true,
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
mirrorX: false,
|
||||
mirrorY: false,
|
||||
width: 64,
|
||||
height: 32,
|
||||
color: 0xffffff,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user