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

+70 -38
View File
@@ -1,6 +1,8 @@
import { isPlaceableType } from '../model/AssetTypes';
import { descendantsOf, isSelfOrAncestor, localPositionFor, worldPosition } from '../model/hierarchy';
import type { Position, PropertyValue, Vec2 } from '../model/Properties';
import { findScene, findSceneObject, type Project } from '../model/Project';
import type { SceneAsset } from '../model/Asset';
import { createSceneObject, type SceneObject } from '../model/SceneObject';
import { coerceValue, schemaForAsset } from '../properties/PropertySchema';
import { newId } from '../ids';
@@ -19,18 +21,57 @@ export function requireObject(project: Project, sceneId: string, objectId: strin
return object;
}
/** Places an asset into a scene, creating a scene object. Scenes cannot be placed. */
/** An object taken out of a scene together with the index it had, so undo can put it back. */
export interface RemovedObject {
object: SceneObject;
index: number;
}
/**
* Removes the given objects and all their descendants from the scene (a subtree goes as a
* whole). Returns what was removed in ascending index order.
*/
export function removeSubtrees(scene: SceneAsset, rootIds: string[]): RemovedObject[] {
const ids = new Set<string>();
for (const rootId of rootIds) {
ids.add(rootId);
for (const child of descendantsOf(scene, rootId)) ids.add(child.id);
}
const removed: RemovedObject[] = [];
scene.objects.forEach((object, index) => {
if (ids.has(object.id)) removed.push({ object, index });
});
scene.objects = scene.objects.filter((o) => !ids.has(o.id));
return removed;
}
/** Puts removed objects back at their former indices (ascending order restores them exactly). */
export function restoreObjects(scene: SceneAsset, removed: RemovedObject[]): void {
for (const entry of [...removed].sort((a, b) => a.index - b.index)) scene.objects.splice(entry.index, 0, entry.object);
}
/**
* Places an asset into a scene, creating a scene object. Scenes cannot be placed. The
* position is a world position; with a parent it is converted to the parent's local space.
*/
export class PlaceAssetCommand implements Command {
readonly label = 'Place asset';
readonly objectId = newId();
constructor(private readonly sceneId: string, private readonly assetId: string, private readonly position: Vec2 | Position) {}
constructor(
private readonly sceneId: string,
private readonly assetId: string,
private readonly position: Vec2 | Position,
private readonly parentId = '',
) {}
execute(project: Project): void {
const scene = requireScene(project, this.sceneId);
const asset = requireAsset(project, this.assetId);
if (!isPlaceableType(asset.type)) throw new CommandError('A scene cannot be placed into a scene');
scene.objects.push(createSceneObject(this.objectId, this.assetId, this.position));
if (this.parentId) requireObject(project, this.sceneId, this.parentId);
const world = { x: this.position.x, y: this.position.y, z: 'z' in this.position ? this.position.z : 0 };
scene.objects.push(createSceneObject(this.objectId, this.assetId, localPositionFor(scene, this.parentId, world), this.parentId));
}
undo(project: Project): void {
@@ -39,42 +80,24 @@ export class PlaceAssetCommand implements Command {
}
}
/** Removes an object and everything parented to it. */
export class RemoveSceneObjectCommand implements Command {
readonly label = 'Remove object';
private removed?: { object: SceneObject; index: number };
private clearedOwners: string[] = [];
private removed: RemovedObject[] = [];
constructor(private readonly sceneId: string, private readonly objectId: string) {}
execute(project: Project): void {
const scene = requireScene(project, this.sceneId);
const index = scene.objects.findIndex((o) => o.id === this.objectId);
const object = scene.objects[index];
if (!object) throw new CommandError(`Scene object ${this.objectId} not found`);
this.removed = { object, index };
scene.objects.splice(index, 1);
// Objects owned by the removed one lose their owner reference.
this.clearedOwners = [];
for (const other of scene.objects) {
if (other.ownerId === this.objectId) {
other.ownerId = '';
this.clearedOwners.push(other.id);
}
}
requireObject(project, this.sceneId, this.objectId);
this.removed = removeSubtrees(requireScene(project, this.sceneId), [this.objectId]);
}
undo(project: Project): void {
if (!this.removed) return;
const scene = requireScene(project, this.sceneId);
scene.objects.splice(this.removed.index, 0, this.removed.object);
for (const id of this.clearedOwners) {
const other = findSceneObject(scene, id);
if (other) other.ownerId = this.objectId;
}
restoreObjects(requireScene(project, this.sceneId), this.removed);
}
}
/** Sets the full position (x, y and draw order z) of a scene object. */
/** Sets the full local position (x, y and draw order z) of a scene object. */
export class MoveSceneObjectCommand implements Command {
readonly label = 'Move object';
private previous: Position = { x: 0, y: 0, z: 0 };
@@ -167,24 +190,33 @@ export class ClearOverrideCommand implements Command {
}
}
/** Points an object (typically a behavior) at another object of the same scene, or at nothing (''). */
export class SetOwnerCommand implements Command {
readonly label = 'Set owner';
private previous = '';
/**
* Re-parents an object ('' = top level). The object keeps its world position: its local
* position is recomputed for the new parent. Cycles (an object below itself) are rejected.
*/
export class SetParentCommand implements Command {
readonly label = 'Set parent';
private previous: { parentId: string; position: Position } = { parentId: '', position: { x: 0, y: 0, z: 0 } };
constructor(private readonly sceneId: string, private readonly objectId: string, private readonly ownerId: string) {}
constructor(private readonly sceneId: string, private readonly objectId: string, private readonly parentId: string) {}
execute(project: Project): void {
const scene = requireScene(project, this.sceneId);
const object = requireObject(project, this.sceneId, this.objectId);
if (this.ownerId) {
if (this.ownerId === this.objectId) throw new CommandError('An object cannot own itself');
requireObject(project, this.sceneId, this.ownerId);
if (this.parentId) {
if (this.parentId === this.objectId) throw new CommandError('An object cannot be its own parent');
requireObject(project, this.sceneId, this.parentId);
if (isSelfOrAncestor(scene, this.objectId, this.parentId)) throw new CommandError('An object cannot be parented to one of its own children');
}
this.previous = object.ownerId;
object.ownerId = this.ownerId;
this.previous = { parentId: object.parentId, position: { ...object.position } };
const world = worldPosition(scene, object);
object.parentId = this.parentId;
object.position = localPositionFor(scene, this.parentId, world);
}
undo(project: Project): void {
requireObject(project, this.sceneId, this.objectId).ownerId = this.previous;
const object = requireObject(project, this.sceneId, this.objectId);
object.parentId = this.previous.parentId;
object.position = { ...this.previous.position };
}
}