Fix findUsages/undo/node-lookup bugs, split Viewport and Editor, share command and inspector helpers

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-08 09:22:49 +02:00
1 parent 1eb28314cf
commit f140c2fd77
58 files changed
+1969 -1171

No files matched your search

+19 -8
View File
@@ -1,4 +1,4 @@
import type { Asset, SceneAsset } from './Asset';
import type { Asset, ContainerAsset, SceneAsset } from './Asset';
import { isSceneAsset } from './Asset';
import type { SceneObject } from './SceneObject';
@@ -32,21 +32,32 @@ export function listScenes(project: Project): SceneAsset[] {
return project.assets.filter(isSceneAsset);
}
/**
* Everything that holds placed objects. Container assets do so just like scenes: their
* `objects` are the template a placed container is instantiated from. Anything that walks
* "all placed objects" has to cover both, or it silently skips the templates.
*/
export type ObjectHolder = SceneAsset | ContainerAsset;
export function objectHolders(project: Project): ObjectHolder[] {
return project.assets.filter((a): a is ObjectHolder => a.type === 'scene' || a.type === 'container');
}
export interface SceneObjectRef {
scene: SceneAsset;
holder: ObjectHolder;
object: SceneObject;
}
/** Objects of all scenes that satisfy `predicate`, each with the scene it belongs to. */
export function sceneObjectsWhere(project: Project, predicate: (object: SceneObject) => boolean): SceneObjectRef[] {
/** Objects of all scenes and container templates that satisfy `predicate`, each with its holder. */
export function objectsWhere(project: Project, predicate: (object: SceneObject) => boolean): SceneObjectRef[] {
const result: SceneObjectRef[] = [];
for (const scene of listScenes(project)) {
for (const object of scene.objects) if (predicate(object)) result.push({ scene, object });
for (const holder of objectHolders(project)) {
for (const object of holder.objects) if (predicate(object)) result.push({ holder, object });
}
return result;
}
/** Every placed instance of an asset, across all scenes. */
/** Every placed instance of an asset, in scenes and in container templates alike. */
export function findUsages(project: Project, assetId: string): SceneObjectRef[] {
return sceneObjectsWhere(project, (o) => o.assetId === assetId);
return objectsWhere(project, (o) => o.assetId === assetId);
}
+7 -3
View File
@@ -1,7 +1,7 @@
import { newId } from '../ids';
import type { ContainerAsset } from './Asset';
import { chainOf, type ObjectTree } from './hierarchy';
import { findAsset, type Project, sceneObjectsWhere, type SceneObjectRef } from './Project';
import { findAsset, objectsWhere, type Project, type SceneObjectRef } from './Project';
import type { SceneObject } from './SceneObject';
/** What a placed container's content object is linked to: the container and its template object. */
@@ -27,9 +27,13 @@ export function templateLinkOf(project: Project, tree: ObjectTree, object: Scene
return undefined;
}
/** Every placed copy of a template object, across all scenes (template ids are unique per project). */
/**
* Every placed copy of a template object (template ids are unique per project). Template
* objects themselves never carry a template id, so a template is never mistaken for a copy
* of itself even though the search covers container assets too.
*/
export function instancesOf(project: Project, templateId: string): SceneObjectRef[] {
return sceneObjectsWhere(project, (o) => o.templateId === templateId);
return objectsWhere(project, (o) => o.templateId === templateId);
}
/** Nesting depth at which a container inside a container inside … stops being expanded. */
+17 -29
View File
@@ -1,6 +1,7 @@
import { type Removed, removeWhere } from '../util/list';
import type { Position } from './Properties';
import type { SceneObject } from './SceneObject';
import { descendantsBelow, parentChain, sumAlongChain } from './walk';
/**
* Parent/child structure of a scene. `scene.objects` stays a flat list (storage order =
@@ -19,21 +20,18 @@ export function findObject(tree: ObjectTree, id: string): SceneObject | undefine
return tree.objects.find((o) => o.id === id);
}
export function childrenOf(tree: ObjectTree, parentId: string): SceneObject[] {
function childrenOf(tree: ObjectTree, parentId: string): SceneObject[] {
return tree.objects.filter((o) => o.parentId === parentId);
}
/** The parent of an object, or undefined for a top-level one (and for a dangling link). */
function parentOf(tree: ObjectTree, object: SceneObject): SceneObject | undefined {
return object.parentId ? findObject(tree, object.parentId) : undefined;
}
/** All objects below `id`, depth-first, parents before children. */
export function descendantsOf(tree: ObjectTree, id: string): SceneObject[] {
const result: SceneObject[] = [];
const visit = (parentId: string) => {
for (const child of childrenOf(tree, parentId)) {
result.push(child);
visit(child.id);
}
};
visit(id);
return result;
return descendantsBelow(id, (parentId) => childrenOf(tree, parentId), (o) => o.id);
}
/** The ids of `rootIds` and of everything below them – what group operations act on. */
@@ -54,15 +52,7 @@ export function removeSubtrees(tree: ObjectTree, rootIds: readonly string[]): Re
/** An object and every parent above it, nearest first. Stops at a cycle (defensive). */
export function chainOf(tree: ObjectTree, object: SceneObject): SceneObject[] {
const chain = [object];
const seen = new Set<string>([object.id]);
let parent = object.parentId ? findObject(tree, object.parentId) : undefined;
while (parent && !seen.has(parent.id)) {
chain.push(parent);
seen.add(parent.id);
parent = parent.parentId ? findObject(tree, parent.parentId) : undefined;
}
return chain;
return [...parentChain(object, (o) => parentOf(tree, o))];
}
/** Ids of the ancestors of `id`, nearest parent first. Empty for a top-level object. */
@@ -86,19 +76,17 @@ export function subtreeRoots(tree: ObjectTree, ids: readonly string[]): string[]
return ids.filter((id) => !ancestorIdsOf(tree, id).some((ancestorId) => set.has(ancestorId)));
}
function sumAlongChain(tree: ObjectTree, object: SceneObject, withEditorOffset: boolean): Position {
const result: Position = { x: 0, y: 0, z: 0 };
for (const o of chainOf(tree, object)) {
result.x += o.position.x + (withEditorOffset ? o.editorOffset.x : 0);
result.y += o.position.y + (withEditorOffset ? o.editorOffset.y : 0);
result.z += o.position.z;
}
return result;
function positionAlongChain(tree: ObjectTree, object: SceneObject, withEditorOffset: boolean): Position {
return sumAlongChain(
object,
(o) => parentOf(tree, o),
(o) => (withEditorOffset ? { x: o.position.x + o.editorOffset.x, y: o.position.y + o.editorOffset.y, z: o.position.z } : o.position),
);
}
/** Position of an object in world units (all local positions along the chain summed up). */
export function worldPosition(tree: ObjectTree, object: SceneObject): Position {
return sumAlongChain(tree, object, false);
return positionAlongChain(tree, object, false);
}
/**
@@ -107,7 +95,7 @@ export function worldPosition(tree: ObjectTree, object: SceneObject): Position {
* in the game, in collisions or in the graph ever sees it (they all use `worldPosition`).
*/
export function editorPosition(tree: ObjectTree, object: SceneObject): Position {
return sumAlongChain(tree, object, true);
return positionAlongChain(tree, object, true);
}
/** The local position that puts an object at `world` under the given parent ('' = top level). */
+60
View File
@@ -0,0 +1,60 @@
/**
* Walking a parent/child structure, generic over the shape. Two hierarchies exist side by
* side – scene objects (`parentId` + lookup in a flat list) and runtime entities (direct
* `parent` pointer) – and both walk the same way. Callers pass in how to get from an item to
* its parent (or children); everything else lives here, so the cycle guard exists once.
*/
import type { Position } from './Properties';
/**
* `start` and every ancestor above it, nearest first. An item that was already visited ends
* the walk instead of looping forever: a cyclic parent chain is always a bug (loading repairs
* one, see `store/serialize`), but no walk at runtime may hang because of it.
*/
export function* parentChain<T>(start: T, parentOf: (item: T) => T | null | undefined): Generator<T> {
const seen = new Set<T>();
for (let current: T | null | undefined = start; current && !seen.has(current); current = parentOf(current)) {
seen.add(current);
yield current;
}
}
/** Whether the parents of `start` lead into a cycle instead of up to a root. */
export function hasParentCycle<T>(start: T, parentOf: (item: T) => T | null | undefined): boolean {
let last = start;
for (const item of parentChain(start, parentOf)) last = item;
// The walk stops either above a root or at an already visited item – only the latter still has a parent.
return Boolean(parentOf(last));
}
/**
* The local positions of `start` and all its ancestors summed up – the world position, as
* long as the hierarchy has no rotation or scale (it has none).
*/
export function sumAlongChain<T>(start: T, parentOf: (item: T) => T | null | undefined, positionOf: (item: T) => Position): Position {
const total: Position = { x: 0, y: 0, z: 0 };
for (const item of parentChain(start, parentOf)) {
const position = positionOf(item);
total.x += position.x;
total.y += position.y;
total.z += position.z;
}
return total;
}
/**
* Everything below `rootKey`, depth-first, parents before children. `keyOf` turns a found item
* back into the key its own children are looked up by, which is what lets ids (scene objects)
* and object identity (entities) use the same walk.
*/
export function descendantsBelow<T, K>(rootKey: K, childrenOf: (key: K) => readonly T[], keyOf: (item: T) => K): T[] {
const result: T[] = [];
const visit = (key: K) => {
for (const child of childrenOf(key)) {
result.push(child);
visit(keyOf(child));
}
};
visit(rootKey);
return result;
}