- Graph: nodes.ts split into nodes/<category>/<Type>.ts (62 files) with shared.ts factories and index.ts registry; NodeContext/values/NodeRegistry separated, writeTyped() replaces duplicated typed writes - Comments: one style (JSDoc on non-obvious exports, // for decisions and traps), wrong/stale comments fixed or removed, ids.ts now real 10 base-36 chars - Core: chainOf, shared worldPosition/editorPosition core, commands/require.ts, commands/naming.ts, util/list.ts, setBagValue/toPosition/propDef; dead code removed (isBehaviorAsset, SCENE_OBJECT_SCHEMA, CommandStack.subscribe, Input.axis, setMode, titleText, World.worldPosition, unused ctor params) - Editor: Inspector split into inspector/ sections, App.ts into Editor.ts + debugHook.ts + shortcuts.ts, Viewport into items.ts + symbols.ts, shared "Add collider" action, unified type labels/swatches - UI toolkit: Widget.trackHover/onPointerRelease, drawControlBox, math helpers, Popup.openBelow, Menu/Dialog/ColorPicker/ListRow in own files, named Theme sizes - Tools: seed-demo/smoke-demo and demo-check project removed, tools/lib (harness, projects, builders), e2e.mjs as runner over tools/e2e/*.mjs sections - Tests: tests/helpers.ts replaces copied fixtures and NodeContext doubles Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
175 lines
6.9 KiB
TypeScript
175 lines
6.9 KiB
TypeScript
import { type Removed, removeWhere } from '../util/list';
|
||
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.
|
||
*
|
||
* The helpers work on anything that holds a flat object list – a scene, or the template
|
||
* inside a container asset.
|
||
*/
|
||
export interface ObjectTree {
|
||
objects: SceneObject[];
|
||
}
|
||
|
||
export function findObject(tree: ObjectTree, id: string): SceneObject | undefined {
|
||
return tree.objects.find((o) => o.id === id);
|
||
}
|
||
|
||
export function childrenOf(tree: ObjectTree, parentId: string): SceneObject[] {
|
||
return tree.objects.filter((o) => o.parentId === parentId);
|
||
}
|
||
|
||
/** 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;
|
||
}
|
||
|
||
/** The ids of `rootIds` and of everything below them – what group operations act on. */
|
||
export function subtreeIds(tree: ObjectTree, rootIds: Iterable<string>): Set<string> {
|
||
const ids = new Set<string>();
|
||
for (const rootId of rootIds) {
|
||
ids.add(rootId);
|
||
for (const child of descendantsOf(tree, rootId)) ids.add(child.id);
|
||
}
|
||
return ids;
|
||
}
|
||
|
||
/** Takes the subtrees of `rootIds` out of the tree; returns them with their former indices (see `restoreAt`). */
|
||
export function removeSubtrees(tree: ObjectTree, rootIds: readonly string[]): Removed<SceneObject>[] {
|
||
const ids = subtreeIds(tree, rootIds);
|
||
return removeWhere(tree.objects, (o) => ids.has(o.id));
|
||
}
|
||
|
||
/** 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;
|
||
}
|
||
|
||
/** Ids of the ancestors of `id`, nearest parent first. Empty for a top-level object. */
|
||
export function ancestorIdsOf(tree: ObjectTree, id: string): string[] {
|
||
const object = findObject(tree, id);
|
||
return object ? chainOf(tree, object).slice(1).map((o) => o.id) : [];
|
||
}
|
||
|
||
/** Whether `ancestorId` is `objectId` itself or one of its ancestors. */
|
||
export function isSelfOrAncestor(tree: ObjectTree, ancestorId: string, objectId: string): boolean {
|
||
const object = findObject(tree, objectId);
|
||
return object !== undefined && chainOf(tree, object).some((o) => o.id === ancestorId);
|
||
}
|
||
|
||
/**
|
||
* Of the given ids, those that have no ancestor among the ids themselves – the tops of the
|
||
* selected subtrees. Whole subtrees are what group operations (move, remove, group) act on.
|
||
*/
|
||
export function subtreeRoots(tree: ObjectTree, ids: readonly string[]): string[] {
|
||
const set = new Set(ids);
|
||
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;
|
||
}
|
||
|
||
/** 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);
|
||
}
|
||
|
||
/**
|
||
* Where the scene view draws an object: its world position plus the editor offsets along the
|
||
* chain, so shifting a parent shifts everything below it. This is a view aid only – nothing
|
||
* 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);
|
||
}
|
||
|
||
/** The local position that puts an object at `world` under the given parent ('' = top level). */
|
||
export function localPositionFor(tree: ObjectTree, parentId: string, world: Position): Position {
|
||
const parent = parentId ? findObject(tree, parentId) : undefined;
|
||
if (!parent) return { ...world };
|
||
const origin = worldPosition(tree, 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(tree: ObjectTree): TreeEntry[] {
|
||
const result: TreeEntry[] = [];
|
||
const ids = new Set(tree.objects.map((o) => o.id));
|
||
const visit = (parentId: string, depth: number) => {
|
||
for (const object of tree.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 tree.objects) {
|
||
if (object.parentId && !ids.has(object.parentId) && !result.some((e) => e.object === object)) result.push({ object, depth: 0 });
|
||
}
|
||
return result;
|
||
}
|
||
|
||
export interface VisibleEntry extends TreeEntry {
|
||
/** Whether the object has children at all – only those rows offer an expand/collapse chevron. */
|
||
hasChildren: boolean;
|
||
}
|
||
|
||
/**
|
||
* Tree order with the subtrees of collapsed objects left out: a row is shown when *every*
|
||
* ancestor is expanded. Expansion is a view state (which ids are open), never stored in the
|
||
* project – anything not listed counts as collapsed, so a freshly loaded scene shows only its
|
||
* top level.
|
||
*/
|
||
export function visibleTreeOrder(tree: ObjectTree, expanded: ReadonlySet<string>): VisibleEntry[] {
|
||
const parents = new Set(tree.objects.map((o) => o.parentId).filter(Boolean));
|
||
const result: VisibleEntry[] = [];
|
||
// Depth of the collapsed row whose subtree is being skipped (-1 = showing rows).
|
||
let collapsedAt = -1;
|
||
for (const entry of treeOrder(tree)) {
|
||
if (collapsedAt >= 0) {
|
||
if (entry.depth > collapsedAt) continue;
|
||
collapsedAt = -1;
|
||
}
|
||
const hasChildren = parents.has(entry.object.id);
|
||
result.push({ ...entry, hasChildren });
|
||
if (hasChildren && !expanded.has(entry.object.id)) collapsedAt = entry.depth;
|
||
}
|
||
return result;
|
||
}
|