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

+10 -6
View File
@@ -10,24 +10,28 @@ export interface SceneObject {
assetId: string;
/** Optional display name; falls back to the asset name when empty. */
name: string;
/** World position plus draw order (`z`). */
/**
* Position **relative to the parent** (world position when there is no parent) plus the
* draw order `z`, which is relative as well. See `hierarchy.ts` for world coordinates.
*/
position: Position;
overrides: PropertyBag;
/**
* Another object of the same scene this one acts on (used by behaviors: "the owner").
* Empty string = none. Any object may carry one, only behaviors interpret it.
* Parent object of the same scene, empty string = top level. Children move with their
* parent and are removed with it. Behaviors treat their parent as the "owner" the graph
* acts on.
*/
ownerId: string;
parentId: string;
}
/** Creates a scene object; `z` defaults to 0 when the caller only has a 2D point. */
export function createSceneObject(id: string, assetId: string, position: Vec2 | Position): SceneObject {
export function createSceneObject(id: string, assetId: string, position: Vec2 | Position, parentId = ''): SceneObject {
return {
id,
assetId,
name: '',
position: { x: position.x, y: position.y, z: 'z' in position ? position.z : 0 },
overrides: {},
ownerId: '',
parentId,
};
}
+93
View File
@@ -0,0 +1,93 @@
import type { SceneAsset } from './Asset';
import type { Position } from './Properties';
import type { SceneObject } from './SceneObject';
/**
* Parent/child structure of a scene. `scene.objects` stays a flat list (storage order =
* creation order); the tree is implied by `SceneObject.parentId`. Positions are local to
* the parent, so the world position is the sum along the chain – there is no rotation or
* scale yet, which keeps this trivially invertible.
*/
export function findObject(scene: SceneAsset, id: string): SceneObject | undefined {
return scene.objects.find((o) => o.id === id);
}
export function childrenOf(scene: SceneAsset, parentId: string): SceneObject[] {
return scene.objects.filter((o) => o.parentId === parentId);
}
/** All objects below `id`, depth-first, parents before children. */
export function descendantsOf(scene: SceneAsset, id: string): SceneObject[] {
const result: SceneObject[] = [];
const visit = (parentId: string) => {
for (const child of childrenOf(scene, parentId)) {
result.push(child);
visit(child.id);
}
};
visit(id);
return result;
}
/** Whether `ancestorId` is `objectId` itself or one of its ancestors (guards against cycles). */
export function isSelfOrAncestor(scene: SceneAsset, ancestorId: string, objectId: string): boolean {
let current: SceneObject | undefined = findObject(scene, objectId);
const seen = new Set<string>();
while (current && !seen.has(current.id)) {
if (current.id === ancestorId) return true;
seen.add(current.id);
current = current.parentId ? findObject(scene, current.parentId) : undefined;
}
return false;
}
/** Position of an object in world units (all local positions along the chain summed up). */
export function worldPosition(scene: SceneAsset, object: SceneObject): Position {
const result = { ...object.position };
let parent = object.parentId ? findObject(scene, object.parentId) : undefined;
const seen = new Set<string>([object.id]);
while (parent && !seen.has(parent.id)) {
result.x += parent.position.x;
result.y += parent.position.y;
result.z += parent.position.z;
seen.add(parent.id);
parent = parent.parentId ? findObject(scene, parent.parentId) : undefined;
}
return result;
}
/** The local position that puts an object at `world` under the given parent ('' = top level). */
export function localPositionFor(scene: SceneAsset, parentId: string, world: Position): Position {
const parent = parentId ? findObject(scene, parentId) : undefined;
if (!parent) return { ...world };
const origin = worldPosition(scene, parent);
return { x: world.x - origin.x, y: world.y - origin.y, z: world.z - origin.z };
}
export interface TreeEntry {
object: SceneObject;
depth: number;
}
/**
* Objects in tree order: top-level objects in storage order, each followed by its subtree.
* This is the order the hierarchy panel shows and the tie-break order for equal `z`.
*/
export function treeOrder(scene: SceneAsset): TreeEntry[] {
const result: TreeEntry[] = [];
const ids = new Set(scene.objects.map((o) => o.id));
const visit = (parentId: string, depth: number) => {
for (const object of scene.objects) {
if (object.parentId !== parentId) continue;
result.push({ object, depth });
visit(object.id, depth + 1);
}
};
visit('', 0);
// Objects whose parent is missing (should not happen after loading) are listed at the top level.
for (const object of scene.objects) {
if (object.parentId && !ids.has(object.parentId) && !result.some((e) => e.object === object)) result.push({ object, depth: 0 });
}
return result;
}