Scene hierarchy: parentId with local positions, tree panel, reparenting and Parent node

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-07 15:04:56 +02:00
1 parent 551a50ffc9
commit 26d5edc496
22 files changed
+729 -166

No files matched your search

+12 -13
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
import { buildAsset } from '../../src/core/commands/assetCommands';
import { CommandStack } from '../../src/core/commands/CommandStack';
import { AddNodeCommand, ConnectCommand, DisconnectCommand, RemoveNodeCommand, SetNodeParamCommand } from '../../src/core/commands/graphCommands';
import { PlaceAssetCommand, SetOwnerCommand } from '../../src/core/commands/sceneObjectCommands';
import { PlaceAssetCommand, SetParentCommand } from '../../src/core/commands/sceneObjectCommands';
import { AddVariableCommand, RemoveVariableCommand, RenameVariableCommand } from '../../src/core/commands/variableCommands';
import { type EntityHandle, GraphInterpreter, type NodeContext, allNodeDefs, portsCompatible } from '../../src/core/graph';
import type { BehaviorAsset } from '../../src/core/model/Asset';
@@ -10,7 +10,7 @@ import { createEmptyProject } from '../../src/core/model/Project';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
function entity(name: string, x = 0): EntityHandle {
return { id: name, name, x, y: 0, z: 0, props: {} };
return { id: name, name, x, y: 0, z: 0, props: {}, parent: null };
}
function context(overrides: Partial<NodeContext> = {}): NodeContext & { logs: string[]; stopped: boolean } {
@@ -337,7 +337,7 @@ describe('variables', () => {
expect(object.overrides).toEqual({ speed: 5 });
});
it('owner references are validated and cleared when the owner disappears', () => {
it('the owner is the parent: validated, kept across a round trip, dropped when dangling', () => {
const project = createEmptyProject('p', 'P');
const scene = buildAsset('scene', 'S', 's');
const sprite = buildAsset('sprite', 'Box', 'box');
@@ -348,16 +348,15 @@ describe('variables', () => {
const placeB = new PlaceAssetCommand('s', 'b', { x: 0, y: 0 });
stack.execute(placeBox);
stack.execute(placeB);
expect(() => stack.execute(new SetOwnerCommand('s', placeB.objectId, placeB.objectId))).toThrow(/itself/);
stack.execute(new SetOwnerCommand('s', placeB.objectId, placeBox.objectId));
const objects = () => (project.assets[0] as { objects: { id: string; ownerId: string }[] }).objects;
expect(objects()[1]!.ownerId).toBe(placeBox.objectId);
// Round trip keeps the owner; a dangling owner is dropped by the loader.
const loaded = deserializeProject(serializeProject(project));
expect((loaded.assets[0] as { objects: { ownerId: string }[] }).objects[1]!.ownerId).toBe(placeBox.objectId);
const raw = serializeProject(project) as { assets: { objects?: { ownerId: string }[] }[] };
raw.assets[0]!.objects![1]!.ownerId = 'ghost';
expect((deserializeProject(raw).assets[0] as { objects: { ownerId: string }[] }).objects[1]!.ownerId).toBe('');
expect(() => stack.execute(new SetParentCommand('s', placeB.objectId, placeB.objectId))).toThrow(/own parent/);
stack.execute(new SetParentCommand('s', placeB.objectId, placeBox.objectId));
const objectsOf = (p: unknown) => ((p as { assets: { objects: { id: string; parentId: string }[] }[] }).assets[0]!).objects;
expect(objectsOf(project)[1]!.parentId).toBe(placeBox.objectId);
// Round trip keeps the parent; a dangling parent is dropped by the loader.
expect(objectsOf(deserializeProject(serializeProject(project)))[1]!.parentId).toBe(placeBox.objectId);
const raw = serializeProject(project) as { assets: { objects?: { parentId: string }[] }[] };
raw.assets[0]!.objects![1]!.parentId = 'ghost';
expect(objectsOf(deserializeProject(raw))[1]!.parentId).toBe('');
});
});