Refactoring: one file per node, consistent comments, shared helpers, split large modules

- 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>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-08 08:37:22 +02:00
1 parent 3234eb480f
commit 1eb28314cf
189 files changed
+6231 -5543

No files matched your search

+31 -33
View File
@@ -3,9 +3,10 @@ import { edgesInto, type Graph, type GraphNode, inlineInputs, inlineValue, type
import type { BehaviorAsset } from '../../core/model/Asset';
import { findAsset, type Project } from '../../core/model/Project';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { Theme, textStyle, Widget } from '../../ui';
import { Theme, textStyle, type TextStyleToken, Widget } from '../../ui';
import { keyLabel } from '../inspector/fieldFactory';
/** Node geometry in graph units; the editor relies on it for edge anchors and hit tests. */
export const NODE_WIDTH = 176;
export const TITLE_HEIGHT = 24;
export const ROW_HEIGHT = 20;
@@ -54,7 +55,7 @@ export class NodeView extends Widget {
private paramLines: string[] = [];
selected = false;
hot = false;
/** Whether a data input currently has a connection (inline value hidden). */
/** Data inputs that currently have a connection (their inline value is hidden). */
private connected = new Set<string>();
constructor(readonly node: GraphNode, readonly def: NodeDef) {
@@ -64,22 +65,17 @@ export class NodeView extends Widget {
this.sync(node, { nodes: [node], edges: [] }, null, null);
}
get titleText(): string {
return this.def.label;
}
/** Ports sit on the left/right edges; widen the hit area so they can be grabbed. */
override resize(w: number, h: number): void {
super.resize(w, h);
this.hitArea = new Rectangle(-PORT_HIT_RADIUS, 0, w + PORT_HIT_RADIUS * 2, h);
}
/** Re-reads params and connections; rebuilds the text lines. `project` resolves asset references (Spawn). */
/** Re-reads params and connections; rebuilds the text lines. `project` resolves asset references (Spawn, Is asset). */
sync(node: GraphNode, graph: Graph, asset: BehaviorAsset | null, project: Project | null): void {
this.paramLines = this.def.params.map((p) => `${p.label}: ${formatParam(p, node.params[p.key] ?? p.default, asset, project)}`);
this.connected = new Set(inlineInputs(this.def).filter((p) => edgesInto(graph, node.id, p.key).length > 0).map((p) => p.key));
const rows = this.rowCount();
const height = TITLE_HEIGHT + PAD + this.paramLines.length * ROW_HEIGHT + rows * ROW_HEIGHT + PAD;
const height = TITLE_HEIGHT + PAD + this.paramLines.length * ROW_HEIGHT + this.rowCount() * ROW_HEIGHT + PAD;
this.rebuildTexts();
this.resize(NODE_WIDTH, height);
this.position.set(node.x, node.y);
@@ -101,13 +97,17 @@ export class NodeView extends Widget {
return TITLE_HEIGHT + PAD + this.paramLines.length * ROW_HEIGHT + index * ROW_HEIGHT + ROW_HEIGHT / 2;
}
/** Every port with its local centre – inputs on the left edge, outputs on the right. */
private *portCentres(): Iterable<{ port: PortDef; isOutput: boolean; x: number; y: number }> {
for (const [list, isOutput] of [[this.def.inputs, false], [this.def.outputs, true]] as const) {
for (let i = 0; i < list.length; i++) yield { port: list[i]!, isOutput, x: isOutput ? this.w : 0, y: this.rowY(i) };
}
}
/** Which port (if any) sits under a local point. */
portAt(localX: number, localY: number): PortHit | null {
for (const [list, isOutput] of [[this.def.inputs, false], [this.def.outputs, true]] as const) {
for (let i = 0; i < list.length; i++) {
const px = isOutput ? this.w : 0;
if (Math.hypot(localX - px, localY - this.rowY(i)) <= PORT_HIT_RADIUS) return { port: list[i]!, isOutput };
}
for (const { port, isOutput, x, y } of this.portCentres()) {
if (Math.hypot(localX - x, localY - y) <= PORT_HIT_RADIUS) return { port, isOutput };
}
return null;
}
@@ -115,12 +115,11 @@ export class NodeView extends Widget {
private rebuildTexts(): void {
for (const t of this.texts) t.destroy();
this.texts.length = 0;
const add = (text: string, style: Parameters<typeof textStyle>[0], x: number, y: number, align: 'left' | 'right' = 'left', maxWidth = NODE_WIDTH - PAD * 2) => {
const add = (text: string, style: TextStyleToken, x: number, y: number, align: 'left' | 'right' = 'left', maxWidth = NODE_WIDTH - PAD * 2) => {
const node = new Text({ text: fit(text, style, maxWidth), style: textStyle(style) });
node.position.set(align === 'left' ? x : x - node.width, y);
this.addChild(node);
this.texts.push(node);
return node;
};
add(this.def.label, 'accent', PAD + 2, (TITLE_HEIGHT - 16) / 2);
let y = TITLE_HEIGHT + PAD;
@@ -128,8 +127,7 @@ export class NodeView extends Widget {
add(line, 'small', PAD + 2, y + 2);
y += ROW_HEIGHT;
}
const rows = this.rowCount();
for (let i = 0; i < rows; i++) {
for (let i = 0; i < this.rowCount(); i++) {
const input = this.def.inputs[i];
const output = this.def.outputs[i];
const rowTop = y + i * ROW_HEIGHT + 2;
@@ -139,9 +137,9 @@ export class NodeView extends Widget {
const value = inlineValue(this.node, input);
if (value !== null && value !== '') label += ` ${formatValue(value)}`;
}
add(label || '', input.type === 'exec' ? 'body' : 'muted', PAD + PORT_RADIUS + 4, rowTop, 'left', NODE_WIDTH / 2 + 20);
add(label, input.type === 'exec' ? 'body' : 'muted', PAD + PORT_RADIUS + 4, rowTop, 'left', NODE_WIDTH / 2 + 20);
}
if (output) add(output.label || '', output.type === 'exec' ? 'body' : 'muted', this.w - PAD - PORT_RADIUS - 4, rowTop, 'right', NODE_WIDTH / 2 - 10);
if (output) add(output.label, output.type === 'exec' ? 'body' : 'muted', this.w - PAD - PORT_RADIUS - 4, rowTop, 'right', NODE_WIDTH / 2 - 10);
}
}
@@ -156,21 +154,20 @@ export class NodeView extends Widget {
this.box.roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius).stroke({ width: this.selected || this.hot ? 2 : 1, color: border });
this.ports.clear();
for (const [list, isOutput] of [[this.def.inputs, false], [this.def.outputs, true]] as const) {
list.forEach((port, i) => {
const x = isOutput ? this.w : 0;
const y = this.rowY(i);
if (port.type === 'exec') {
this.ports.moveTo(x - 5, y - 5).lineTo(x + 1, y - 5).lineTo(x + 6, y).lineTo(x + 1, y + 5).lineTo(x - 5, y + 5).closePath().fill(portColor('exec'));
} else {
this.ports.circle(x, y, PORT_RADIUS).fill(portColor(port.type));
this.ports.circle(x, y, PORT_RADIUS).stroke({ width: 1, color: c.node });
}
});
const r = PORT_RADIUS;
for (const { port, x, y } of this.portCentres()) {
if (port.type === 'exec') {
// A pentagon pointing right: the Blueprint-style exec pin.
this.ports.moveTo(x - r, y - r).lineTo(x + 1, y - r).lineTo(x + r + 1, y).lineTo(x + 1, y + r).lineTo(x - r, y + r).closePath().fill(portColor('exec'));
} else {
this.ports.circle(x, y, r).fill(portColor(port.type));
this.ports.circle(x, y, r).stroke({ width: 1, color: c.node });
}
}
}
}
/** The value of a setting as shown on the node; references that no longer resolve get a "?". */
function formatParam(param: PropertyDef, value: unknown, asset: BehaviorAsset | null, project: Project | null): string {
if (value === undefined || value === '') return '—';
if (param.kind === 'key') return keyLabel(String(value));
@@ -178,7 +175,7 @@ function formatParam(param: PropertyDef, value: unknown, asset: BehaviorAsset |
const target = project ? findAsset(project, String(value)) : undefined;
return target ? target.name : `${value}?`;
}
if (param.kind === 'enum' && param.key === 'variable' && asset && !asset.variables.some((v) => v.key === value) && asset.variables.length > 0 && String(value).length > 3) return `${value}?`;
if (param.kind === 'enum' && param.key === 'variable' && asset && !asset.variables.some((v) => v.key === value)) return `${value}?`;
return formatValue(value);
}
@@ -188,7 +185,8 @@ function formatValue(value: unknown): string {
return String(value);
}
function fit(text: string, style: Parameters<typeof textStyle>[0], maxWidth: number): string {
/** Truncates with an ellipsis so the text fits `maxWidth`. */
function fit(text: string, style: TextStyleToken, maxWidth: number): string {
const s = textStyle(style);
if (CanvasTextMetrics.measureText(text, s).width <= maxWidth) return text;
let cut = text;