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:
1 parent
551a50ffc9
commit
26d5edc496
22 files changed
+729
-166
No files matched your search
@@ -2,11 +2,11 @@ import { createEmptyGraph } from '../graph/Graph';
|
||||
import type { Asset } from '../model/Asset';
|
||||
import type { AssetType } from '../model/AssetTypes';
|
||||
import type { PropertyValue } from '../model/Properties';
|
||||
import { findAsset, findUsages, type Project } from '../model/Project';
|
||||
import type { SceneObject } from '../model/SceneObject';
|
||||
import { findAsset, findScene, listScenes, type Project } from '../model/Project';
|
||||
import { coerceValue, defaultsFor, schemaForAsset } from '../properties/PropertySchema';
|
||||
import { newId } from '../ids';
|
||||
import { type Command, CommandError } from './Command';
|
||||
import { type RemovedObject, removeSubtrees, restoreObjects } from './sceneObjectCommands';
|
||||
|
||||
export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
|
||||
const base = { id, name, props: defaultsFor(type) };
|
||||
@@ -45,12 +45,14 @@ export class CreateAssetCommand implements Command {
|
||||
}
|
||||
}
|
||||
|
||||
/** Deletes an asset and, as a cascade, every scene object referencing it. */
|
||||
/**
|
||||
* Deletes an asset and, as a cascade, every scene object referencing it – together with the
|
||||
* objects parented below those (a subtree is removed as a whole).
|
||||
*/
|
||||
export class DeleteAssetCommand implements Command {
|
||||
readonly label: string;
|
||||
private removed?: { asset: Asset; index: number };
|
||||
private removedUsages: { sceneId: string; object: SceneObject; index: number }[] = [];
|
||||
private clearedOwners: { sceneId: string; objectId: string; ownerId: string }[] = [];
|
||||
private removedObjects: { sceneId: string; removed: RemovedObject[] }[] = [];
|
||||
private previousActiveScene = '';
|
||||
|
||||
constructor(private readonly assetId: string) {
|
||||
@@ -61,23 +63,11 @@ export class DeleteAssetCommand implements Command {
|
||||
const index = project.assets.findIndex((a) => a.id === this.assetId);
|
||||
const asset = project.assets[index];
|
||||
if (!asset) throw new CommandError(`Asset ${this.assetId} not found`);
|
||||
this.removedUsages = findUsages(project, this.assetId).map(({ scene, object }) => ({
|
||||
sceneId: scene.id,
|
||||
object,
|
||||
index: scene.objects.indexOf(object),
|
||||
}));
|
||||
this.clearedOwners = [];
|
||||
for (const usage of this.removedUsages) {
|
||||
const scene = findAsset(project, usage.sceneId);
|
||||
if (!scene || scene.type !== 'scene') continue;
|
||||
scene.objects = scene.objects.filter((o) => o.id !== usage.object.id);
|
||||
// Objects that pointed at a removed object as their owner lose that reference.
|
||||
for (const other of scene.objects) {
|
||||
if (other.ownerId === usage.object.id) {
|
||||
this.clearedOwners.push({ sceneId: scene.id, objectId: other.id, ownerId: other.ownerId });
|
||||
other.ownerId = '';
|
||||
}
|
||||
}
|
||||
this.removedObjects = [];
|
||||
for (const scene of listScenes(project)) {
|
||||
const roots = scene.objects.filter((o) => o.assetId === this.assetId).map((o) => o.id);
|
||||
if (roots.length === 0) continue;
|
||||
this.removedObjects.push({ sceneId: scene.id, removed: removeSubtrees(scene, roots) });
|
||||
}
|
||||
this.removed = { asset, index };
|
||||
project.assets.splice(index, 1);
|
||||
@@ -91,15 +81,9 @@ export class DeleteAssetCommand implements Command {
|
||||
undo(project: Project): void {
|
||||
if (!this.removed) return;
|
||||
project.assets.splice(this.removed.index, 0, this.removed.asset);
|
||||
// Restore usages in ascending index order so the original positions come back.
|
||||
for (const usage of [...this.removedUsages].sort((a, b) => a.index - b.index)) {
|
||||
const scene = findAsset(project, usage.sceneId);
|
||||
if (scene && scene.type === 'scene') scene.objects.splice(usage.index, 0, usage.object);
|
||||
}
|
||||
for (const cleared of this.clearedOwners) {
|
||||
const scene = findAsset(project, cleared.sceneId);
|
||||
const object = scene && scene.type === 'scene' ? scene.objects.find((o) => o.id === cleared.objectId) : undefined;
|
||||
if (object) object.ownerId = cleared.ownerId;
|
||||
for (const entry of this.removedObjects) {
|
||||
const scene = findScene(project, entry.sceneId);
|
||||
if (scene) restoreObjects(scene, entry.removed);
|
||||
}
|
||||
project.activeSceneId = this.previousActiveScene;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -12,10 +12,26 @@ export type Value = PropertyValue | EntityHandle | null;
|
||||
export interface EntityHandle {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Position local to `parent` (world position for top-level entities). */
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
props: PropertyBag;
|
||||
/** The entity this one is parented to; children move with their parent. */
|
||||
parent: EntityHandle | null;
|
||||
}
|
||||
|
||||
/** World position of a handle: its local position plus those of all ancestors. */
|
||||
export function worldPositionOf(handle: EntityHandle): { x: number; y: number; z: number } {
|
||||
const result = { x: handle.x, y: handle.y, z: handle.z };
|
||||
const seen = new Set<EntityHandle>([handle]);
|
||||
for (let p = handle.parent; p && !seen.has(p); p = p.parent) {
|
||||
result.x += p.x;
|
||||
result.y += p.y;
|
||||
result.z += p.z;
|
||||
seen.add(p);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,7 +42,7 @@ export interface EntityHandle {
|
||||
export interface NodeContext {
|
||||
/** The behavior entity itself. */
|
||||
self: EntityHandle;
|
||||
/** The scene object the behavior was attached to, if any. */
|
||||
/** The behavior's parent object (the one the graph acts on), if any. */
|
||||
owner: EntityHandle | null;
|
||||
/** Mutable variable state of this behavior instance. */
|
||||
variables: PropertyBag;
|
||||
|
||||
+16
-5
@@ -1,5 +1,5 @@
|
||||
import type { PropertyDef } from '../properties/PropertySchema';
|
||||
import { asBoolean, asEntity, asNumber, asString, type PortDef, registerNode } from './NodeRegistry';
|
||||
import { asBoolean, asEntity, asNumber, asString, type PortDef, registerNode, worldPositionOf } from './NodeRegistry';
|
||||
|
||||
/**
|
||||
* The built-in node set. Each entry is data + a `run` function; everything else (palette,
|
||||
@@ -59,7 +59,17 @@ registerNode({
|
||||
// --- data: references --------------------------------------------------------------------
|
||||
|
||||
registerNode({ type: 'Self', label: 'Self', category: 'data', description: 'This behavior object.', inputs: [], outputs: [obj('object')], params: [], run: (ctx) => ({ outputs: { object: ctx.self } }) });
|
||||
registerNode({ type: 'Owner', label: 'Owner', category: 'data', description: 'The object this behavior is attached to.', inputs: [], outputs: [obj('object')], params: [], run: (ctx) => ({ outputs: { object: ctx.owner } }) });
|
||||
registerNode({ type: 'Owner', label: 'Owner', category: 'data', description: 'The object this behavior is parented to.', inputs: [], outputs: [obj('object')], params: [], run: (ctx) => ({ outputs: { object: ctx.owner } }) });
|
||||
registerNode({
|
||||
type: 'Parent',
|
||||
label: 'Parent',
|
||||
category: 'data',
|
||||
description: 'The parent of an object (none for top-level objects).',
|
||||
inputs: [obj('object')],
|
||||
outputs: [obj('parent')],
|
||||
params: [],
|
||||
run: (_ctx, inputs) => ({ outputs: { parent: asEntity(inputs.object)?.parent ?? null } }),
|
||||
});
|
||||
registerNode({
|
||||
type: 'ObjectByName',
|
||||
label: 'Object by name',
|
||||
@@ -141,13 +151,14 @@ registerNode({
|
||||
type: 'GetPosition',
|
||||
label: 'Get position',
|
||||
category: 'data',
|
||||
description: 'The x/y position of an object.',
|
||||
description: 'The position of an object: local to its parent (x/y) and in world units (world x/y).',
|
||||
inputs: [obj('object')],
|
||||
outputs: [num('x'), num('y')],
|
||||
outputs: [num('x'), num('y'), num('worldX', 'world x'), num('worldY', 'world y')],
|
||||
params: [],
|
||||
run: (_ctx, inputs) => {
|
||||
const target = asEntity(inputs.object);
|
||||
return { outputs: { x: target?.x ?? 0, y: target?.y ?? 0 } };
|
||||
const world = target ? worldPositionOf(target) : { x: 0, y: 0 };
|
||||
return { outputs: { x: target?.x ?? 0, y: target?.y ?? 0, worldX: world.x, worldY: world.y } };
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -83,12 +83,25 @@ function normalizeObjects(raw: unknown, schemas: Map<string, PropertySchema>): S
|
||||
name: typeof o.name === 'string' ? o.name : '',
|
||||
position: { x: toNumber(p.x), y: toNumber(p.y), z: toNumber(p.z) },
|
||||
overrides: normalizeBag(o.overrides, schema, true),
|
||||
ownerId: typeof o.ownerId === 'string' ? o.ownerId : '',
|
||||
// `ownerId` is the pre-hierarchy name of the same link (documents saved before parenting).
|
||||
parentId: typeof o.parentId === 'string' ? o.parentId : typeof o.ownerId === 'string' ? o.ownerId : '',
|
||||
});
|
||||
}
|
||||
// Owners must exist in the same scene and differ from the object itself.
|
||||
const ids = new Set(objects.map((o) => o.id));
|
||||
for (const o of objects) if (o.ownerId && (o.ownerId === o.id || !ids.has(o.ownerId))) o.ownerId = '';
|
||||
// Parents must exist in the same scene, differ from the object itself and not form cycles.
|
||||
const byId = new Map(objects.map((o) => [o.id, o]));
|
||||
for (const o of objects) if (o.parentId && (o.parentId === o.id || !byId.has(o.parentId))) o.parentId = '';
|
||||
for (const o of objects) {
|
||||
const seen = new Set<string>([o.id]);
|
||||
let current = o.parentId ? byId.get(o.parentId) : undefined;
|
||||
while (current) {
|
||||
if (seen.has(current.id)) {
|
||||
o.parentId = '';
|
||||
break;
|
||||
}
|
||||
seen.add(current.id);
|
||||
current = current.parentId ? byId.get(current.parentId) : undefined;
|
||||
}
|
||||
}
|
||||
return objects;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user