Multiselection and container assets: create container from selection, template instances
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
91ee2feadd
commit
0b78382bcc
26 files changed
+897
-114
No files matched your search
@@ -0,0 +1,195 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CreateAssetCommand, DeleteAssetCommand } from '../../src/core/commands/assetCommands';
|
||||
import { CommandStack } from '../../src/core/commands/CommandStack';
|
||||
import { CompoundCommand } from '../../src/core/commands/CompoundCommand';
|
||||
import { CreateContainerCommand } from '../../src/core/commands/containerCommands';
|
||||
import { MoveSceneObjectCommand, PlaceAssetCommand, RemoveSceneObjectsCommand, SetParentCommand } from '../../src/core/commands/sceneObjectCommands';
|
||||
import type { ContainerAsset } from '../../src/core/model/Asset';
|
||||
import { subtreeRoots, treeOrder, worldPosition } from '../../src/core/model/hierarchy';
|
||||
import { createEmptyProject, findScene } from '../../src/core/model/Project';
|
||||
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
|
||||
import { World } from '../../src/runtime/World';
|
||||
|
||||
/**
|
||||
* Scene with a sprite asset and three boxes: a at (100,100,z1), b at (10,0) and c at (0,5)
|
||||
* parented under b (local (-10,5)).
|
||||
*/
|
||||
function setup() {
|
||||
const project = createEmptyProject('p', 'Containers');
|
||||
const stack = new CommandStack(project);
|
||||
const scene = new CreateAssetCommand('scene', 'Level');
|
||||
const sprite = new CreateAssetCommand('sprite', 'Box');
|
||||
stack.execute(scene);
|
||||
stack.execute(sprite);
|
||||
const a = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 100, y: 100, z: 1 });
|
||||
const b = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 10, y: 0 });
|
||||
const c = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 0, y: 5 });
|
||||
for (const cmd of [a, b, c]) stack.execute(cmd);
|
||||
stack.execute(new SetParentCommand(scene.asset.id, c.objectId, b.objectId));
|
||||
const sceneOf = () => findScene(project, scene.asset.id)!;
|
||||
const object = (id: string) => sceneOf().objects.find((o) => o.id === id)!;
|
||||
const world = (id: string) => worldPosition(sceneOf(), object(id));
|
||||
return { project, stack, sceneId: scene.asset.id, spriteId: sprite.asset.id, a: a.objectId, b: b.objectId, c: c.objectId, sceneOf, object, world };
|
||||
}
|
||||
|
||||
describe('subtree roots', () => {
|
||||
it('drops ids that have a selected ancestor', () => {
|
||||
const { sceneOf, a, b, c } = setup();
|
||||
expect(subtreeRoots(sceneOf(), [a, b, c])).toEqual([a, b]);
|
||||
expect(subtreeRoots(sceneOf(), [c, a])).toEqual([c, a]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CreateContainerCommand', () => {
|
||||
it('centres the template on the selection and re-parents the roots, keeping world positions', () => {
|
||||
const { project, stack, sceneId, spriteId, a, b, c, sceneOf, object, world } = setup();
|
||||
const before = { a: world(a), b: world(b), c: world(c) };
|
||||
const command = new CreateContainerCommand(sceneId, [a, b]);
|
||||
stack.execute(command);
|
||||
|
||||
// Bounding box of (100,100) and (10,0) → centre (55,50); z is always 0.
|
||||
const container = object(command.objectId);
|
||||
expect(container.assetId).toBe(command.assetId);
|
||||
expect(container.parentId).toBe('');
|
||||
expect(container.position).toEqual({ x: 55, y: 50, z: 0 });
|
||||
expect(object(a).parentId).toBe(command.objectId);
|
||||
expect(object(a).position).toEqual({ x: 45, y: 50, z: 1 });
|
||||
expect(object(b).position).toEqual({ x: -45, y: -50, z: 0 });
|
||||
expect(object(c).parentId).toBe(b); // descendants keep their local frame
|
||||
expect(object(c).position).toEqual({ x: -10, y: 5, z: 0 });
|
||||
expect({ a: world(a), b: world(b), c: world(c) }).toEqual(before);
|
||||
|
||||
// The asset holds the same structure with private ids; the members point at it.
|
||||
const asset = project.assets.find((x) => x.id === command.assetId) as ContainerAsset;
|
||||
expect(asset.type).toBe('container');
|
||||
expect(asset.objects).toHaveLength(3);
|
||||
const template = (id: string) => asset.objects.find((t) => t.id === object(id).templateId)!;
|
||||
expect(template(a).position).toEqual({ x: 45, y: 50, z: 1 });
|
||||
expect(template(a).parentId).toBe('');
|
||||
expect(template(c).parentId).toBe(template(b).id);
|
||||
expect(template(c).assetId).toBe(spriteId);
|
||||
expect(asset.objects.map((t) => t.id)).not.toContain(a);
|
||||
expect(treeOrder(sceneOf()).map((e) => e.object.id)).toEqual([command.objectId, a, b, c]);
|
||||
|
||||
stack.undo();
|
||||
expect(sceneOf().objects.map((o) => o.id)).toEqual([a, b, c]);
|
||||
expect(object(a).parentId).toBe('');
|
||||
expect(object(a).position).toEqual({ x: 100, y: 100, z: 1 });
|
||||
expect(object(a).templateId).toBe('');
|
||||
expect(project.assets.some((x) => x.id === command.assetId)).toBe(false);
|
||||
stack.redo();
|
||||
expect(object(a).parentId).toBe(command.objectId);
|
||||
expect(world(a)).toEqual(before.a);
|
||||
});
|
||||
|
||||
it('places the container below the parent all roots share', () => {
|
||||
const { stack, sceneId, spriteId, b, c, object, world } = setup();
|
||||
const d = new PlaceAssetCommand(sceneId, spriteId, { x: 30, y: 25 }, b);
|
||||
stack.execute(d);
|
||||
const command = new CreateContainerCommand(sceneId, [c, d.objectId]);
|
||||
stack.execute(command);
|
||||
const container = object(command.objectId);
|
||||
expect(container.parentId).toBe(b);
|
||||
// World centre of c (0,5) and d (30,25) is (15,15); b is at (10,0) → local (5,15).
|
||||
expect(container.position).toEqual({ x: 5, y: 15, z: 0 });
|
||||
expect(world(c)).toEqual({ x: 0, y: 5, z: 0 });
|
||||
expect(world(d.objectId)).toEqual({ x: 30, y: 25, z: 0 });
|
||||
});
|
||||
|
||||
it('rejects an empty or unknown selection', () => {
|
||||
const { stack, sceneId } = setup();
|
||||
expect(() => stack.execute(new CreateContainerCommand(sceneId, []))).toThrow(/at least one/);
|
||||
expect(() => stack.execute(new CreateContainerCommand(sceneId, ['nope']))).toThrow(/not found/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('placing a container', () => {
|
||||
it('instantiates the template with fresh ids below the placed object', () => {
|
||||
const { project, stack, sceneId, a, b, c, sceneOf, object, world } = setup();
|
||||
const create = new CreateContainerCommand(sceneId, [a, b]);
|
||||
stack.execute(create);
|
||||
const place = new PlaceAssetCommand(sceneId, create.assetId, { x: 1000, y: 0 });
|
||||
stack.execute(place);
|
||||
|
||||
const copies = sceneOf().objects.filter((o) => ![a, b, c, create.objectId].includes(o.id));
|
||||
expect(copies).toHaveLength(4); // container + 3 template objects
|
||||
const root = object(place.objectId);
|
||||
expect(root.assetId).toBe(create.assetId);
|
||||
const asset = project.assets.find((x) => x.id === create.assetId) as ContainerAsset;
|
||||
const copyOf = (id: string) => copies.find((o) => o.templateId === object(id).templateId)!;
|
||||
expect(copyOf(a).parentId).toBe(place.objectId);
|
||||
expect(world(copyOf(a).id)).toEqual({ x: 1045, y: 50, z: 1 });
|
||||
expect(copyOf(c).parentId).toBe(copyOf(b).id);
|
||||
expect(world(copyOf(c).id)).toEqual({ x: 945, y: -45, z: 0 });
|
||||
expect(new Set(copies.map((o) => o.id)).size).toBe(4);
|
||||
expect(asset.objects.map((t) => t.id)).not.toContain(copyOf(a).id);
|
||||
|
||||
stack.undo();
|
||||
expect(sceneOf().objects).toHaveLength(4);
|
||||
expect(sceneOf().objects.some((o) => o.id === place.objectId)).toBe(false);
|
||||
});
|
||||
|
||||
it('survives a save/load round trip', () => {
|
||||
const { project, stack, sceneId, a, b } = setup();
|
||||
const create = new CreateContainerCommand(sceneId, [a, b]);
|
||||
stack.execute(create);
|
||||
const loaded = deserializeProject(serializeProject(project));
|
||||
const asset = loaded.assets.find((x) => x.id === create.assetId) as ContainerAsset;
|
||||
expect(asset.type).toBe('container');
|
||||
expect(asset.objects).toHaveLength(3);
|
||||
const scene = findScene(loaded, sceneId)!;
|
||||
expect(scene.objects.find((o) => o.id === a)?.templateId).toBe(asset.objects[0]!.id);
|
||||
expect(scene.objects.find((o) => o.id === create.objectId)?.assetId).toBe(create.assetId);
|
||||
});
|
||||
|
||||
it('deleting an asset removes its template objects from containers too', () => {
|
||||
const { project, stack, sceneId, spriteId, a, b } = setup();
|
||||
const create = new CreateContainerCommand(sceneId, [a, b]);
|
||||
stack.execute(create);
|
||||
const asset = () => project.assets.find((x) => x.id === create.assetId) as ContainerAsset;
|
||||
stack.execute(new DeleteAssetCommand(spriteId));
|
||||
expect(asset().objects).toHaveLength(0);
|
||||
expect(findScene(project, sceneId)!.objects.map((o) => o.id)).toEqual([create.objectId]);
|
||||
stack.undo();
|
||||
expect(asset().objects).toHaveLength(3);
|
||||
expect(findScene(project, sceneId)!.objects).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('spawns a container with its content at runtime', () => {
|
||||
const { project, stack, sceneId, a, b } = setup();
|
||||
const create = new CreateContainerCommand(sceneId, [a, b]);
|
||||
stack.execute(create);
|
||||
const world = World.instantiate(project, sceneId);
|
||||
const spawned = world.spawn(create.assetId, { x: 500, y: 500, z: 0 }, null)!;
|
||||
expect(spawned.type).toBe('container');
|
||||
const children = world.entities.filter((e) => e.parent === spawned);
|
||||
expect(children).toHaveLength(2);
|
||||
const grandchild = world.entities.find((e) => e.parent && children.includes(e.parent))!;
|
||||
expect(world.worldPosition(children.find((e) => e.z === 1)!)).toEqual({ x: 545, y: 550, z: 1 });
|
||||
expect(world.worldPosition(grandchild)).toEqual({ x: 445, y: 455, z: 0 });
|
||||
expect(world.syncToModel()).toBe(true);
|
||||
expect(findScene(project, sceneId)!.objects).toHaveLength(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multi-selection commands', () => {
|
||||
it('removes several subtrees as one step, covering nested ids', () => {
|
||||
const { stack, sceneId, a, b, c, sceneOf } = setup();
|
||||
stack.execute(new RemoveSceneObjectsCommand(sceneId, [b, c, a]));
|
||||
expect(sceneOf().objects).toHaveLength(0);
|
||||
stack.undo();
|
||||
expect(sceneOf().objects.map((o) => o.id)).toEqual([a, b, c]);
|
||||
expect(sceneOf().objects.find((o) => o.id === c)?.parentId).toBe(b);
|
||||
});
|
||||
|
||||
it('a compound move rolls back entirely when one part fails', () => {
|
||||
const { stack, sceneId, a, object } = setup();
|
||||
const compound = new CompoundCommand('Move objects', [
|
||||
new MoveSceneObjectCommand(sceneId, a, { x: 1, y: 2, z: 3 }),
|
||||
new MoveSceneObjectCommand(sceneId, 'missing', { x: 0, y: 0, z: 0 }),
|
||||
]);
|
||||
expect(() => stack.execute(compound)).toThrow(/not found/);
|
||||
expect(object(a).position).toEqual({ x: 100, y: 100, z: 1 });
|
||||
expect(stack.canUndo).toBe(true); // the earlier commands of setup()
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user