@triscope/core 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +39 -0
  3. package/dist/compose.d.ts +11 -0
  4. package/dist/compose.d.ts.map +1 -0
  5. package/dist/compose.js +152 -0
  6. package/dist/compose.js.map +1 -0
  7. package/dist/editor.d.ts +14 -0
  8. package/dist/editor.d.ts.map +1 -0
  9. package/dist/editor.js +131 -0
  10. package/dist/editor.js.map +1 -0
  11. package/dist/harness.d.ts +199 -0
  12. package/dist/harness.d.ts.map +1 -0
  13. package/dist/harness.js +1027 -0
  14. package/dist/harness.js.map +1 -0
  15. package/dist/index.d.ts +32 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +20 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/inspect.d.ts +94 -0
  20. package/dist/inspect.d.ts.map +1 -0
  21. package/dist/inspect.js +434 -0
  22. package/dist/inspect.js.map +1 -0
  23. package/dist/knob-utils.d.ts +22 -0
  24. package/dist/knob-utils.d.ts.map +1 -0
  25. package/dist/knob-utils.js +51 -0
  26. package/dist/knob-utils.js.map +1 -0
  27. package/dist/lab/css.d.ts +11 -0
  28. package/dist/lab/css.d.ts.map +1 -0
  29. package/dist/lab/css.js +57 -0
  30. package/dist/lab/css.js.map +1 -0
  31. package/dist/lab/dom.d.ts +13 -0
  32. package/dist/lab/dom.d.ts.map +1 -0
  33. package/dist/lab/dom.js +76 -0
  34. package/dist/lab/dom.js.map +1 -0
  35. package/dist/motion-probe.d.ts +47 -0
  36. package/dist/motion-probe.d.ts.map +1 -0
  37. package/dist/motion-probe.js +122 -0
  38. package/dist/motion-probe.js.map +1 -0
  39. package/dist/probe-utils.d.ts +14 -0
  40. package/dist/probe-utils.d.ts.map +1 -0
  41. package/dist/probe-utils.js +18 -0
  42. package/dist/probe-utils.js.map +1 -0
  43. package/dist/scene-cameras.d.ts +6 -0
  44. package/dist/scene-cameras.d.ts.map +1 -0
  45. package/dist/scene-cameras.js +20 -0
  46. package/dist/scene-cameras.js.map +1 -0
  47. package/dist/scene-delta.d.ts +21 -0
  48. package/dist/scene-delta.d.ts.map +1 -0
  49. package/dist/scene-delta.js +57 -0
  50. package/dist/scene-delta.js.map +1 -0
  51. package/dist/scene-introspect.d.ts +78 -0
  52. package/dist/scene-introspect.d.ts.map +1 -0
  53. package/dist/scene-introspect.js +164 -0
  54. package/dist/scene-introspect.js.map +1 -0
  55. package/dist/scene-registry.d.ts +36 -0
  56. package/dist/scene-registry.d.ts.map +1 -0
  57. package/dist/scene-registry.js +64 -0
  58. package/dist/scene-registry.js.map +1 -0
  59. package/dist/scene-view.d.ts +52 -0
  60. package/dist/scene-view.d.ts.map +1 -0
  61. package/dist/scene-view.js +171 -0
  62. package/dist/scene-view.js.map +1 -0
  63. package/dist/source-tag.d.ts +34 -0
  64. package/dist/source-tag.d.ts.map +1 -0
  65. package/dist/source-tag.js +120 -0
  66. package/dist/source-tag.js.map +1 -0
  67. package/dist/telemetry.d.ts +53 -0
  68. package/dist/telemetry.d.ts.map +1 -0
  69. package/dist/telemetry.js +302 -0
  70. package/dist/telemetry.js.map +1 -0
  71. package/dist/types.d.ts +142 -0
  72. package/dist/types.d.ts.map +1 -0
  73. package/dist/types.js +9 -0
  74. package/dist/types.js.map +1 -0
  75. package/dist/uniform-access.d.ts +32 -0
  76. package/dist/uniform-access.d.ts.map +1 -0
  77. package/dist/uniform-access.js +144 -0
  78. package/dist/uniform-access.js.map +1 -0
  79. package/dist/validate.d.ts +2 -0
  80. package/dist/validate.d.ts.map +1 -0
  81. package/dist/validate.js +81 -0
  82. package/dist/validate.js.map +1 -0
  83. package/dist/vite.d.ts +3 -0
  84. package/dist/vite.d.ts.map +1 -0
  85. package/dist/vite.js +4 -0
  86. package/dist/vite.js.map +1 -0
  87. package/dist/warnings.d.ts +24 -0
  88. package/dist/warnings.d.ts.map +1 -0
  89. package/dist/warnings.js +26 -0
  90. package/dist/warnings.js.map +1 -0
  91. package/package.json +60 -0
  92. package/src/compose.ts +164 -0
  93. package/src/editor.ts +138 -0
  94. package/src/harness.ts +1263 -0
  95. package/src/index.ts +58 -0
  96. package/src/inspect.ts +498 -0
  97. package/src/knob-utils.ts +60 -0
  98. package/src/lab/css.ts +56 -0
  99. package/src/lab/dom.ts +88 -0
  100. package/src/motion-probe.ts +135 -0
  101. package/src/probe-utils.ts +17 -0
  102. package/src/scene-cameras.ts +33 -0
  103. package/src/scene-delta.ts +69 -0
  104. package/src/scene-introspect.ts +230 -0
  105. package/src/scene-registry.ts +103 -0
  106. package/src/scene-view.ts +204 -0
  107. package/src/source-tag.ts +139 -0
  108. package/src/telemetry.ts +337 -0
  109. package/src/three-webgpu-shim.d.ts +130 -0
  110. package/src/types.ts +121 -0
  111. package/src/uniform-access.ts +152 -0
  112. package/src/validate.ts +82 -0
  113. package/src/vite.ts +5 -0
  114. package/src/warnings.ts +41 -0
package/src/lab/dom.ts ADDED
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Lab DOM scaffolding helper.
3
+ *
4
+ * Eliminates per-project boilerplate: instead of every lab HTML page
5
+ * declaring its own <canvas>, <div id="boot">, <div id="hud">, knob
6
+ * editor pane + the supporting CSS, consumers call
7
+ *
8
+ * const dom = mountLabDom();
9
+ * runLab({ element, ...dom });
10
+ *
11
+ * The helper:
12
+ * - Injects `LAB_CSS` once into `<head>` (no-op on second call).
13
+ * - Creates the standard ids `runLab()` / `mountEditor()` expect:
14
+ * `boot`, `app`, `canvas`, `hud`, `lab-controls`.
15
+ * - Returns the ref bundle in the shape `runLab()` accepts.
16
+ *
17
+ * Existing DOM nodes with matching ids are reused (no duplication).
18
+ * This means a project that wants custom CSS for one element can still
19
+ * hand-write the HTML and skip this helper — the contract is unchanged.
20
+ */
21
+ import { LAB_CSS } from './css.js';
22
+
23
+ export interface LabDomRefs {
24
+ canvas: HTMLCanvasElement;
25
+ hud: HTMLElement;
26
+ boot: HTMLElement;
27
+ labelContainer: HTMLElement;
28
+ editorContainer: HTMLElement;
29
+ }
30
+
31
+ const STYLE_ID = 'triscope-lab-css';
32
+
33
+ function ensureStyleInjected(): void {
34
+ if (typeof document === 'undefined') return;
35
+ if (document.getElementById(STYLE_ID)) return;
36
+ const style = document.createElement('style');
37
+ style.id = STYLE_ID;
38
+ style.textContent = LAB_CSS;
39
+ document.head.appendChild(style);
40
+ }
41
+
42
+ function getOrCreate<T extends HTMLElement>(id: string, tag: string, parent: HTMLElement): T {
43
+ const existing = document.getElementById(id) as T | null;
44
+ if (existing) return existing;
45
+ const el = document.createElement(tag) as T;
46
+ el.id = id;
47
+ parent.appendChild(el);
48
+ return el;
49
+ }
50
+
51
+ /**
52
+ * Build (or reuse) the standard lab DOM and return the refs `runLab()`
53
+ * needs. Idempotent: calling twice does not duplicate nodes.
54
+ */
55
+ export function mountLabDom(): LabDomRefs {
56
+ if (typeof document === 'undefined') {
57
+ throw new Error('mountLabDom() requires a browser DOM (document).');
58
+ }
59
+ ensureStyleInjected();
60
+
61
+ const body = document.body;
62
+ const boot = getOrCreate<HTMLDivElement>('boot', 'div', body);
63
+ if (!boot.textContent) boot.textContent = 'Initialising Triscope · WebGPU...';
64
+
65
+ const app = getOrCreate<HTMLDivElement>('app', 'div', body);
66
+
67
+ // Canvas lives inside `#app` so the label-overlay positioning math in
68
+ // `runLab` (which is relative to `#app`) lines up with the canvas pixels.
69
+ let canvas = document.getElementById('canvas') as HTMLCanvasElement | null;
70
+ if (!canvas) {
71
+ canvas = document.createElement('canvas');
72
+ canvas.id = 'canvas';
73
+ app.appendChild(canvas);
74
+ }
75
+
76
+ const hud = getOrCreate<HTMLDivElement>('hud', 'div', body);
77
+ if (!hud.textContent) hud.textContent = '- fps · WebGPU';
78
+
79
+ const editorContainer = getOrCreate<HTMLDivElement>('lab-controls', 'div', body);
80
+
81
+ return {
82
+ canvas,
83
+ hud,
84
+ boot,
85
+ labelContainer: app,
86
+ editorContainer,
87
+ };
88
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Ring-buffered numeric probe used by the lab harness to summarize animated
3
+ * Element state without ever leaving the CPU (no GPU readback). The buffer
4
+ * holds the last `capacity` (sample, time) pairs; `stats()` returns aggregates
5
+ * including a zero-crossing-based estimate of dominant frequency.
6
+ *
7
+ * Extracted from `runLab` so the math is unit-testable in isolation.
8
+ */
9
+
10
+ export interface ProbeStats {
11
+ latest: number;
12
+ mean: number;
13
+ min: number;
14
+ max: number;
15
+ peakToPeak: number;
16
+ zeroCrossingsPerSec: number;
17
+ dominantFreqHz: number;
18
+ /** Last up-to-32 samples, in temporal order. */
19
+ samples: number[];
20
+ }
21
+
22
+ export class MotionProbeBuffer {
23
+ private readonly cap: number;
24
+ private readonly buf: Float32Array;
25
+ private readonly times: Float32Array;
26
+ private writeIdx = 0;
27
+ private count = 0;
28
+
29
+ constructor(capacity = 120) {
30
+ if (capacity <= 0) throw new Error('MotionProbeBuffer capacity must be > 0');
31
+ this.cap = capacity;
32
+ this.buf = new Float32Array(capacity);
33
+ this.times = new Float32Array(capacity);
34
+ }
35
+
36
+ push(value: number, time: number): void {
37
+ this.buf[this.writeIdx] = value;
38
+ this.times[this.writeIdx] = time;
39
+ this.writeIdx = (this.writeIdx + 1) % this.cap;
40
+ if (this.count < this.cap) this.count += 1;
41
+ }
42
+
43
+ get size(): number {
44
+ return this.count;
45
+ }
46
+
47
+ get capacity(): number {
48
+ return this.cap;
49
+ }
50
+
51
+ /** Returns (samples, times) in temporal order: oldest first, newest last. */
52
+ ordered(): { samples: number[]; times: number[] } {
53
+ const n = this.count;
54
+ const samples: number[] = new Array(n);
55
+ const times: number[] = new Array(n);
56
+ if (n < this.cap) {
57
+ for (let i = 0; i < n; i++) {
58
+ samples[i] = this.buf[i];
59
+ times[i] = this.times[i];
60
+ }
61
+ } else {
62
+ for (let i = 0; i < this.cap; i++) {
63
+ const j = (this.writeIdx + i) % this.cap;
64
+ samples[i] = this.buf[j];
65
+ times[i] = this.times[j];
66
+ }
67
+ }
68
+ return { samples, times };
69
+ }
70
+
71
+ stats(): ProbeStats | null {
72
+ if (this.count === 0) return null;
73
+ const { samples, times } = this.ordered();
74
+ return computeProbeStats(samples, times);
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Pure stats kernel. `samples` and `times` must be the same length and in
80
+ * temporal order (oldest first). `times` is in seconds.
81
+ *
82
+ * Frequency estimate: count sign changes of (sample - mean); each full cycle
83
+ * has two zero-crossings, so divide by 2 to get cycles per duration, then by
84
+ * duration to get Hz. Robust for clean sinusoids; not a substitute for FFT on
85
+ * noisy / multi-frequency signals.
86
+ */
87
+ export function computeProbeStats(samples: number[], times: number[]): ProbeStats | null {
88
+ const n = samples.length;
89
+ if (n === 0) return null;
90
+ if (times.length !== n) {
91
+ throw new Error(
92
+ `computeProbeStats: samples (${n}) and times (${times.length}) length mismatch`,
93
+ );
94
+ }
95
+ let min = samples[0];
96
+ let max = samples[0];
97
+ let sum = 0;
98
+ for (let i = 0; i < n; i++) {
99
+ const v = samples[i];
100
+ if (v < min) min = v;
101
+ if (v > max) max = v;
102
+ sum += v;
103
+ }
104
+ const mean = sum / n;
105
+ // Sign-based crossing counter. Samples sitting exactly on the mean (within
106
+ // ZERO_EPS) carry the previous sign forward instead of triggering a false
107
+ // crossing — important because sinusoids sampled at frame-aligned times
108
+ // routinely produce values like sin(π) ≈ 1.2e-16 that confuse a strict
109
+ // <=0 / >=0 comparator.
110
+ const ZERO_EPS = 1e-9;
111
+ let crossings = 0;
112
+ let prevSign = 0;
113
+ for (let i = 0; i < n; i++) {
114
+ const v = samples[i] - mean;
115
+ const s = Math.abs(v) < ZERO_EPS ? 0 : v > 0 ? 1 : -1;
116
+ if (s !== 0) {
117
+ if (prevSign !== 0 && s !== prevSign) crossings += 1;
118
+ prevSign = s;
119
+ }
120
+ }
121
+ const duration = Math.max(times[n - 1] - times[0], 1e-6);
122
+ const dominantFreqHz = crossings / 2 / duration;
123
+ const zeroCrossingsPerSec = crossings / duration;
124
+ const tail = samples.slice(Math.max(0, n - 32));
125
+ return {
126
+ latest: samples[n - 1],
127
+ mean: +mean.toFixed(4),
128
+ min: +min.toFixed(4),
129
+ max: +max.toFixed(4),
130
+ peakToPeak: +(max - min).toFixed(4),
131
+ zeroCrossingsPerSec: +zeroCrossingsPerSec.toFixed(2),
132
+ dominantFreqHz: +dominantFreqHz.toFixed(2),
133
+ samples: tail,
134
+ };
135
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Mean Rec.709 luminance below which a rendered pane is considered "black" —
3
+ * i.e. the GPU drew nothing. Matches the literal the ocean-galleon smoke has
4
+ * used (smoke.mjs ~line 367); kept here as the single source of truth so the
5
+ * harness, the MCP server fallback, and the smoke all agree.
6
+ */
7
+ export const BLACK_FRAME_LUMINANCE = 0.004;
8
+
9
+ /**
10
+ * True when a camera's mean luminance indicates it rendered black. A non-finite
11
+ * luminance (probe unavailable) returns false — we only assert "black" on real
12
+ * evidence, never on a missing measurement, so it can't manufacture CI failures.
13
+ */
14
+ export function isBlackFrame(luminance: number, threshold = BLACK_FRAME_LUMINANCE): boolean {
15
+ if (!Number.isFinite(luminance)) return false;
16
+ return luminance < threshold;
17
+ }
@@ -0,0 +1,33 @@
1
+ // Pure camera-fitting: synthesize 4 fitted CameraSpecs around a bounding box.
2
+ // No `three`, no harness import — so BOTH the harness (for `cameras: 'auto'`)
3
+ // and scene-view (runSceneView) can use it without a circular dependency. The
4
+ // presets mirror the gltf scaffolder's `camerasFor` (front/side/top/3-quarter,
5
+ // 1.8× size standoff, fit:true so the harness frames them to the bounds).
6
+ import type { CameraSpec, Element } from './types.js';
7
+
8
+ export type Bounds = NonNullable<Element['bounds']>;
9
+
10
+ export const UNIT_BOUNDS: Bounds = { min: [-1, -1, -1], max: [1, 1, 1] };
11
+
12
+ /** 4 fitted CameraSpecs (front/side/top/three-quarter) around `bounds`. */
13
+ export function autoCameras(bounds?: Bounds): Record<string, CameraSpec> {
14
+ const b = bounds ?? UNIT_BOUNDS;
15
+ const c: [number, number, number] = [0, 1, 2].map((k) => (b.min[k] + b.max[k]) / 2) as [
16
+ number,
17
+ number,
18
+ number,
19
+ ];
20
+ const size = Math.max(b.max[0] - b.min[0], b.max[1] - b.min[1], b.max[2] - b.min[2], 1);
21
+ const d = size * 1.8;
22
+ const target: [number, number, number] = [c[0], c[1], c[2]];
23
+ return {
24
+ front: { position: [c[0], c[1], c[2] + d], target, fit: true },
25
+ side: { position: [c[0] + d, c[1], c[2]], target, fit: true },
26
+ top: { position: [c[0], c[1] + d, c[2]], target, fit: true },
27
+ 'three-quarter': {
28
+ position: [c[0] + d * 0.7, c[1] + d * 0.5, c[2] + d * 0.7],
29
+ target,
30
+ fit: true,
31
+ },
32
+ };
33
+ }
@@ -0,0 +1,69 @@
1
+ // Pure Scene-Description-Layer (SDL) camera mutation — the data behind
2
+ // set_scene_param. Lets an agent repoint a camera (position/target/fov) live,
3
+ // without a .ts edit + reload. Duck-typed (no three import) so it's
4
+ // unit-testable; the harness passes the live PerspectiveCamera.
5
+ //
6
+ // CRITICAL: a target change must also update `userData.target`, which the
7
+ // harness's targetOf() reads for telemetry — otherwise the camera moves but
8
+ // `read_telemetry .cameras.<name>.target` keeps reporting the stale value.
9
+
10
+ export interface CameraDelta {
11
+ position?: [number, number, number];
12
+ target?: [number, number, number];
13
+ fov?: number;
14
+ }
15
+
16
+ function isVec3(v: unknown): v is [number, number, number] {
17
+ return (
18
+ Array.isArray(v) &&
19
+ v.length === 3 &&
20
+ v.every((n) => typeof n === 'number' && Number.isFinite(n))
21
+ );
22
+ }
23
+
24
+ /** Apply a camera delta in place. Returns false only if `cam` is missing. */
25
+ export function applyCameraDelta(cam: any, delta: CameraDelta): boolean {
26
+ if (!cam) return false;
27
+ let changed = false;
28
+ // Position first, so the subsequent lookAt() computes orientation from the
29
+ // new position.
30
+ if (isVec3(delta.position)) {
31
+ cam.position.set(delta.position[0], delta.position[1], delta.position[2]);
32
+ changed = true;
33
+ }
34
+ if (isVec3(delta.target)) {
35
+ cam.lookAt(delta.target[0], delta.target[1], delta.target[2]);
36
+ cam.userData = cam.userData ?? {};
37
+ cam.userData.target = [delta.target[0], delta.target[1], delta.target[2]];
38
+ changed = true;
39
+ }
40
+ if (typeof delta.fov === 'number' && Number.isFinite(delta.fov)) {
41
+ cam.fov = delta.fov;
42
+ changed = true;
43
+ }
44
+ if (changed && typeof cam.updateProjectionMatrix === 'function') cam.updateProjectionMatrix();
45
+ return true;
46
+ }
47
+
48
+ /**
49
+ * Show/hide an element in the live scene by toggling its root's `visible` flag
50
+ * — the design-doc "solo a track" model of runtime add/remove, with zero
51
+ * dispose/remount risk. For a composed scene the children are looked up by name
52
+ * via `handle.userData.childrenByName` (populated by composeElements); for a
53
+ * single-element lab the mounted element itself matches. Returns false if the
54
+ * name doesn't resolve. NOTE: only the element's `root` subtree toggles — an
55
+ * element that adds lights directly to the scene (not under root) should parent
56
+ * them under root to hide cleanly.
57
+ */
58
+ export function applyElementToggle(
59
+ handle: any,
60
+ mountedElement: { name?: string } | undefined,
61
+ name: string,
62
+ enabled: boolean,
63
+ ): boolean {
64
+ const child = handle?.userData?.childrenByName?.[name];
65
+ const target = child ?? (mountedElement?.name === name ? handle : null);
66
+ if (!target?.root) return false;
67
+ target.root.visible = enabled;
68
+ return true;
69
+ }
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Pure scene-graph serializer — the data behind `inspect_scene`.
3
+ *
4
+ * Gives an AI agent a single machine-readable view of "what is in this scene"
5
+ * (meshes/lights/groups, triangle counts, world positions, materials, and the
6
+ * file:line source tag) so it can diagnose a render bug in one call instead of
7
+ * issuing 10-30 screenshot probes. Operates on duck-typed Object3D-like nodes
8
+ * so it has no `three` dependency and is unit-testable in node; the harness
9
+ * passes a `three`-based `getWorldPosition`.
10
+ */
11
+ import type { SourceTag } from './source-tag.js';
12
+
13
+ export interface SceneNode {
14
+ uuid: string;
15
+ name: string;
16
+ type: string;
17
+ visible: boolean;
18
+ /** World-space position, rounded to 3 decimals. */
19
+ worldPosition: [number, number, number];
20
+ triangleCount: number;
21
+ materialKind?: string;
22
+ materialColor?: string;
23
+ uniformNames?: string[];
24
+ /** file:line where this object was added to the scene (from the source-tag patch). */
25
+ source?: SourceTag['source'];
26
+ /** Ancestor names, root-first (root itself excluded). */
27
+ parentChain: string[];
28
+ }
29
+
30
+ export interface LightNode {
31
+ uuid: string;
32
+ name: string;
33
+ /**
34
+ * Concrete light class, e.g. "DirectionalLight" / "HemisphereLight". Exposed
35
+ * as both `type` (consistent with SceneNode.type) and `lightType` (explicit).
36
+ */
37
+ type: string;
38
+ lightType: string;
39
+ visible: boolean;
40
+ worldPosition: [number, number, number];
41
+ intensity?: number;
42
+ /** Light color as #rrggbb. */
43
+ color?: string;
44
+ /** Hemisphere ground color, when applicable. */
45
+ groundColor?: string;
46
+ /** Point/spot falloff distance + decay; spot cone angle (radians). */
47
+ distance?: number;
48
+ decay?: number;
49
+ angle?: number;
50
+ source?: SourceTag['source'];
51
+ parentChain: string[];
52
+ }
53
+
54
+ export interface SerializeSceneResult {
55
+ nodes: SceneNode[];
56
+ /**
57
+ * Every light in the scene, ALWAYS included regardless of the `nodes` cap.
58
+ * Lights have 0 triangles, so the triangle-count sort + maxNodes truncation
59
+ * would otherwise drop them — yet they're a prime tuning target (a named
60
+ * light is read/writable via read_uniform/set_uniform "name.intensity").
61
+ */
62
+ lights: LightNode[];
63
+ /** Total nodes found (before maxNodes truncation). */
64
+ total: number;
65
+ truncated: boolean;
66
+ }
67
+
68
+ export interface SerializeSceneOptions {
69
+ /**
70
+ * Cap on returned nodes (default 120) to bound the payload — nodes are sorted
71
+ * by triangle count, so the default surfaces the heaviest/most-significant
72
+ * geometry first; `truncated`+`total` flag the rest (raise maxNodes to see it).
73
+ * A full 500-node dump of a busy scene was ~160 KB and blew the agent's token
74
+ * budget; 120 keeps the common "what's in this scene" answer compact.
75
+ */
76
+ maxNodes?: number;
77
+ /** Harness supplies a three-based world-position getter; defaults to local position. */
78
+ getWorldPosition?: (o: any) => [number, number, number];
79
+ }
80
+
81
+ export function serializeScene(root: any, opts: SerializeSceneOptions = {}): SerializeSceneResult {
82
+ const maxNodes = opts.maxNodes ?? 120;
83
+ const getWP = opts.getWorldPosition ?? localPosition;
84
+ const all: any[] = [];
85
+ collectObjects(root, all);
86
+ const nodes: SceneNode[] = [];
87
+ const lights: LightNode[] = [];
88
+ const MAX_LIGHTS = 64;
89
+ for (const o of all) {
90
+ if (o === root) continue; // the scene root itself isn't interesting
91
+ // Lights go into their own array BEFORE the triangle sort/truncate so they
92
+ // are never dropped (they have 0 triangles). Duck-typed `isLight` flag —
93
+ // three sets it on every Light subclass; fakes can set it in tests.
94
+ if (o?.isLight === true && lights.length < MAX_LIGHTS) {
95
+ lights.push(describeLight(o, getWP, root));
96
+ }
97
+ nodes.push(describeNode(o, getWP, root));
98
+ }
99
+ nodes.sort((a, b) => b.triangleCount - a.triangleCount);
100
+ const total = nodes.length;
101
+ const truncated = total > maxNodes;
102
+ return { nodes: truncated ? nodes.slice(0, maxNodes) : nodes, lights, total, truncated };
103
+ }
104
+
105
+ function collectObjects(root: any, out: any[]): void {
106
+ if (!root) return;
107
+ // Real three exposes traverse(self + descendants); fakes use children DFS.
108
+ if (typeof root.traverse === 'function') {
109
+ root.traverse((o: any) => out.push(o));
110
+ return;
111
+ }
112
+ const stack = [root];
113
+ let guard = 0;
114
+ while (stack.length && guard++ < 100000) {
115
+ const o = stack.pop();
116
+ out.push(o);
117
+ const kids = o?.children;
118
+ if (Array.isArray(kids)) for (let i = kids.length - 1; i >= 0; i--) stack.push(kids[i]);
119
+ }
120
+ }
121
+
122
+ function describeNode(o: any, getWP: (o: any) => [number, number, number], root: any): SceneNode {
123
+ const tag = o?.userData?.__tris as SourceTag | undefined;
124
+ const node: SceneNode = {
125
+ uuid: String(o?.uuid ?? ''),
126
+ name: o?.name ?? '',
127
+ type: o?.type ?? o?.constructor?.name ?? 'Object3D',
128
+ visible: o?.visible !== false,
129
+ worldPosition: round3(getWP(o)),
130
+ triangleCount: triangleCount(o),
131
+ parentChain: parentChain(o, root),
132
+ };
133
+ const mat = Array.isArray(o?.material) ? o.material[0] : o?.material;
134
+ if (mat) {
135
+ node.materialKind = mat.type ?? mat.constructor?.name;
136
+ const color = materialColor(mat);
137
+ if (color) node.materialColor = color;
138
+ const names = uniformNames(mat);
139
+ if (names.length) node.uniformNames = names;
140
+ }
141
+ if (tag?.source) node.source = tag.source;
142
+ return node;
143
+ }
144
+
145
+ function describeLight(o: any, getWP: (o: any) => [number, number, number], root: any): LightNode {
146
+ const tag = o?.userData?.__tris as SourceTag | undefined;
147
+ const klass = o?.type ?? o?.constructor?.name ?? 'Light';
148
+ const light: LightNode = {
149
+ uuid: String(o?.uuid ?? ''),
150
+ name: o?.name ?? '',
151
+ type: klass,
152
+ lightType: klass,
153
+ visible: o?.visible !== false,
154
+ worldPosition: round3(getWP(o)),
155
+ parentChain: parentChain(o, root),
156
+ };
157
+ if (typeof o?.intensity === 'number') light.intensity = +o.intensity.toFixed(3);
158
+ const color = hexColor(o?.color);
159
+ if (color) light.color = color;
160
+ const ground = hexColor(o?.groundColor); // HemisphereLight
161
+ if (ground) light.groundColor = ground;
162
+ if (typeof o?.distance === 'number') light.distance = o.distance;
163
+ if (typeof o?.decay === 'number') light.decay = o.decay;
164
+ if (typeof o?.angle === 'number') light.angle = +o.angle.toFixed(4); // SpotLight cone
165
+ if (tag?.source) light.source = tag.source;
166
+ return light;
167
+ }
168
+
169
+ export function triangleCount(o: any): number {
170
+ const geo = o?.geometry;
171
+ if (!geo) return 0;
172
+ try {
173
+ if (geo.index?.count) return Math.floor(geo.index.count / 3);
174
+ const pos = geo.attributes?.position;
175
+ if (pos?.count) return Math.floor(pos.count / 3);
176
+ } catch {
177
+ /* exotic geometry — report 0 rather than throw */
178
+ }
179
+ return 0;
180
+ }
181
+
182
+ function materialColor(mat: any): string | undefined {
183
+ return hexColor(mat?.color);
184
+ }
185
+
186
+ /** A THREE.Color → "#rrggbb", guarded (color access can throw on node materials). */
187
+ function hexColor(c: any): string | undefined {
188
+ try {
189
+ if (typeof c?.getHexString === 'function') return `#${c.getHexString()}`;
190
+ } catch {
191
+ /* never throw out of introspection */
192
+ }
193
+ return undefined;
194
+ }
195
+
196
+ function uniformNames(mat: any): string[] {
197
+ // Legacy materials expose `.uniforms`; TSL node materials keep params in a
198
+ // private node graph (no stable public surface) — return nothing rather than
199
+ // reach into internals that change across three versions. Skip textures.
200
+ try {
201
+ if (mat?.uniforms && typeof mat.uniforms === 'object') {
202
+ return Object.keys(mat.uniforms).filter((k) => !mat.uniforms[k]?.value?.isTexture);
203
+ }
204
+ } catch {
205
+ /* never throw out of introspection */
206
+ }
207
+ return [];
208
+ }
209
+
210
+ function parentChain(o: any, root: any): string[] {
211
+ const chain: string[] = [];
212
+ let p = o?.parent;
213
+ let guard = 0;
214
+ while (p && p !== root && guard++ < 64) {
215
+ chain.push(p.name || p.type || p.constructor?.name || 'Object3D');
216
+ p = p.parent;
217
+ }
218
+ return chain.reverse();
219
+ }
220
+
221
+ function localPosition(o: any): [number, number, number] {
222
+ const p = o?.position;
223
+ if (Array.isArray(p)) return [p[0] ?? 0, p[1] ?? 0, p[2] ?? 0];
224
+ if (p && typeof p === 'object') return [+(p.x ?? 0), +(p.y ?? 0), +(p.z ?? 0)];
225
+ return [0, 0, 0];
226
+ }
227
+
228
+ function round3(v: [number, number, number]): [number, number, number] {
229
+ return [+v[0].toFixed(3), +v[1].toFixed(3), +v[2].toFixed(3)];
230
+ }
@@ -0,0 +1,103 @@
1
+ // Pure bookkeeping for a multi-element scene with runtime mount/unmount — the
2
+ // data layer behind runSceneLab + add_element/remove_element. No `three` or DOM
3
+ // here; the harness drives the actual mount/dispose and grid rebuild. Camera +
4
+ // knob keys are namespaced as `<element>.<key>` for multi-element scenes (so two
5
+ // elements declaring `top` don't collide) and left bare for a single
6
+ // non-namespaced lab (backward-compatible with runLab).
7
+ import { autoCameras } from './scene-cameras.js';
8
+ import type { CameraSpec, Element, Knob } from './types.js';
9
+
10
+ export interface RegistryEntry {
11
+ name: string;
12
+ element: Element;
13
+ }
14
+
15
+ export function nsKey(elementName: string, key: string, namespaced: boolean): string {
16
+ return namespaced ? `${elementName}.${key}` : key;
17
+ }
18
+
19
+ export function splitNs(
20
+ key: string,
21
+ namespaced: boolean,
22
+ ): { element: string | null; local: string } {
23
+ if (!namespaced) return { element: null, local: key };
24
+ const i = key.indexOf('.');
25
+ if (i < 0) return { element: null, local: key };
26
+ return { element: key.slice(0, i), local: key.slice(i + 1) };
27
+ }
28
+
29
+ export interface CameraBinding {
30
+ spec: CameraSpec;
31
+ element: string;
32
+ local: string;
33
+ }
34
+ export interface KnobBinding {
35
+ spec: Knob;
36
+ element: string;
37
+ local: string;
38
+ }
39
+
40
+ export function buildCameraMap(
41
+ active: RegistryEntry[],
42
+ namespaced: boolean,
43
+ ): Record<string, CameraBinding> {
44
+ const out: Record<string, CameraBinding> = {};
45
+ for (const { name, element } of active) {
46
+ const specs =
47
+ element.cameras === 'auto' ? autoCameras(element.bounds) : (element.cameras ?? {});
48
+ for (const [local, spec] of Object.entries(specs)) {
49
+ out[nsKey(name, local, namespaced)] = { spec, element: name, local };
50
+ }
51
+ }
52
+ return out;
53
+ }
54
+
55
+ export function buildKnobMap(
56
+ active: RegistryEntry[],
57
+ namespaced: boolean,
58
+ ): Record<string, KnobBinding> {
59
+ const out: Record<string, KnobBinding> = {};
60
+ for (const { name, element } of active) {
61
+ for (const [local, spec] of Object.entries(element.knobs ?? {})) {
62
+ out[nsKey(name, local, namespaced)] = { spec, element: name, local };
63
+ }
64
+ }
65
+ return out;
66
+ }
67
+
68
+ export interface SceneRegistry {
69
+ available: () => string[];
70
+ mounted: () => string[];
71
+ isMounted: (name: string) => boolean;
72
+ /** Mark a registered element mounted; returns its entry, or null if unknown/already mounted. */
73
+ mount: (name: string) => RegistryEntry | null;
74
+ /** Mark a mounted element unmounted; returns false if it wasn't mounted. */
75
+ unmount: (name: string) => boolean;
76
+ /** Mounted entries, in registry declaration order. */
77
+ activeEntries: () => RegistryEntry[];
78
+ entry: (name: string) => RegistryEntry | undefined;
79
+ }
80
+
81
+ export function createSceneRegistry(entries: RegistryEntry[], initial?: string[]): SceneRegistry {
82
+ const byName = new Map<string, RegistryEntry>();
83
+ for (const e of entries) byName.set(e.name, e);
84
+ const order = entries.map((e) => e.name);
85
+ const mountedSet = new Set<string>((initial ?? order).filter((n) => byName.has(n)));
86
+ return {
87
+ available: () => [...byName.keys()],
88
+ mounted: () => order.filter((n) => mountedSet.has(n)),
89
+ isMounted: (name) => mountedSet.has(name),
90
+ mount: (name) => {
91
+ if (!byName.has(name) || mountedSet.has(name)) return null;
92
+ mountedSet.add(name);
93
+ return byName.get(name) ?? null;
94
+ },
95
+ unmount: (name) => {
96
+ if (!mountedSet.has(name)) return false;
97
+ mountedSet.delete(name);
98
+ return true;
99
+ },
100
+ activeEntries: () => order.filter((n) => mountedSet.has(n)).map((n) => byName.get(n)!),
101
+ entry: (name) => byName.get(name),
102
+ };
103
+ }