@needle-tools/addressing 0.3.4 → 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 (44) hide show
  1. package/Readme.md +62 -0
  2. package/dist/address.d.ts +5 -0
  3. package/dist/address.js +25 -3
  4. package/dist/document/assets.d.ts +27 -0
  5. package/dist/document/assets.js +75 -0
  6. package/dist/document/collectionSelector.d.ts +9 -0
  7. package/dist/document/collectionSelector.js +29 -0
  8. package/dist/document/evaluate.d.ts +72 -0
  9. package/dist/document/evaluate.js +971 -0
  10. package/dist/document/index.d.ts +8 -0
  11. package/dist/document/index.js +8 -0
  12. package/dist/document/inspection.d.ts +30 -0
  13. package/dist/document/inspection.js +130 -0
  14. package/dist/document/runtime.d.ts +71 -0
  15. package/dist/document/runtime.js +1 -0
  16. package/dist/document/types.d.ts +225 -0
  17. package/dist/document/types.js +43 -0
  18. package/dist/document/validation.d.ts +36 -0
  19. package/dist/document/validation.js +219 -0
  20. package/dist/document/write.d.ts +31 -0
  21. package/dist/document/write.js +106 -0
  22. package/dist/id.d.ts +2 -0
  23. package/dist/id.js +4 -0
  24. package/dist/index.d.ts +2 -1
  25. package/dist/index.js +2 -1
  26. package/dist/loadedGltf.d.ts +32 -0
  27. package/dist/loadedGltf.js +62 -0
  28. package/dist/resolve.js +8 -6
  29. package/package.json +9 -1
  30. package/src/address.ts +28 -3
  31. package/src/document/assets.ts +77 -0
  32. package/src/document/collectionSelector.ts +37 -0
  33. package/src/document/evaluate.ts +998 -0
  34. package/src/document/index.ts +8 -0
  35. package/src/document/inspection.ts +123 -0
  36. package/src/document/runtime.ts +66 -0
  37. package/src/document/schema.json +263 -0
  38. package/src/document/types.ts +249 -0
  39. package/src/document/validation.ts +222 -0
  40. package/src/document/write.ts +112 -0
  41. package/src/id.ts +5 -0
  42. package/src/index.ts +3 -0
  43. package/src/loadedGltf.ts +93 -0
  44. package/src/resolve.ts +9 -7
@@ -0,0 +1,998 @@
1
+ import { resolveDocumentCollectionElement } from "./collectionSelector.js";
2
+ import { attachNeedleIdsToLoadedGltf, resolveInCollection, resolveInGraph, type AddressableElement, type GraphNode, type NeedleAddress } from "../index.js";
3
+ import { PROJECT_DOCUMENT_KEY, isProjectDocument, resolveDocumentRemoteSrc, type ProjectDocument, type ProjectDocumentReference, type ProjectDocumentTarget, type ProjectDocumentOver, type ProjectEvaluationIssue, type ProjectEvaluationResult, type EvaluateProjectDocumentOptions } from "./types.js";
4
+ import type { DocumentNode as Object3D, DocumentModel as Model, DocumentRuntime as Context, DocumentDefContext as SerializationContext } from "./runtime.js";
5
+ import { createProjectDocumentInspection, registerProjectDocumentInspection } from "./inspection.js";
6
+
7
+ /** Shared asset/entity preparation; does not place nodes into the host scene. */
8
+ export function createDocumentAssetLoader(
9
+ context: Pick<Context, "parse" | "parkTemplate" | "loadEnvironment">,
10
+ document: ProjectDocument,
11
+ options: EvaluateProjectDocumentOptions | undefined,
12
+ issue: (arc: ProjectEvaluationIssue["arc"], message: string) => void,
13
+ ) {
14
+ // Per-asset parse options: threads the host's resolveAssetUrl (asset-service resolveUrl)
15
+ // to parseSync so an asset's resources resolve at load time. Undefined when no rewrite is
16
+ // provided, so parseSync keeps its plain relative resolution.
17
+ const parseOptionsFor = (assetId: string) =>
18
+ options?.resolveAssetUrl ? { resolveUrl: (url: string) => options.resolveAssetUrl!(assetId, url) } : undefined;
19
+
20
+ // ---- references: load assets (cached per asset id), place roots ----
21
+ // aggregate download progress across all asset fetches: every chunk updates the
22
+ // asset's {loaded, total} slot and reports the SUMS — one bar over the whole set
23
+ const progressByAsset = new Map<string, { loaded: number; total: number }>();
24
+ const reportProgress = () => {
25
+ if (!options?.onProgress) return;
26
+ let loaded = 0, total = 0;
27
+ for (const entry of progressByAsset.values()) {
28
+ loaded += entry.loaded;
29
+ total += entry.total;
30
+ }
31
+ options.onProgress(loaded, total);
32
+ };
33
+
34
+ const assetData = new Map<string, Promise<ArrayBuffer | string | null>>();
35
+ const loadAssetData = (assetId: string): Promise<ArrayBuffer | string | null> => {
36
+ let pending = assetData.get(assetId);
37
+ if (!pending) {
38
+ const src = document.assets?.[assetId]?.src;
39
+ if (typeof src !== "string") {
40
+ pending = Promise.resolve(null);
41
+ }
42
+ else if (options?.resolveAsset) {
43
+ pending = options.resolveAsset(assetId, src).catch(err => {
44
+ issue("reference", `asset "${assetId}" failed to resolve: ${err}`);
45
+ return null;
46
+ });
47
+ }
48
+ else if (resolveDocumentRemoteSrc(src, document.remotes, options?.remotes) === null) {
49
+ // deployed-viewer incident 2026-08-17: the raw scheme reached
50
+ // fetch() and every over inside the reference cascade-failed
51
+ issue("reference", `asset "${assetId}" src "${src}" names a remote that is not in the document's remotes table — the asset is skipped`);
52
+ pending = Promise.resolve(null);
53
+ }
54
+ else {
55
+ const resolvedSrc = resolveDocumentRemoteSrc(src, document.remotes, options?.remotes)!;
56
+ const url = options?.baseUrl ? new URL(resolvedSrc, options.baseUrl).href : resolvedSrc;
57
+ pending = fetch(url).then(res => {
58
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
59
+ // SPA/dev servers answer MISSING files with 200 + their index.html —
60
+ // without this check that surfaces later as a cryptic gltf parse
61
+ // error ("Unexpected token '<' … is not valid JSON")
62
+ const contentType = res.headers.get("Content-Type") ?? "";
63
+ if (contentType.includes("text/html")) {
64
+ throw new Error(`the server answered with an HTML page (Content-Type: ${contentType}) — the file probably does not exist there. Documents record srcs AS SAVED: a dev-server url (e.g. localhost) does not exist on a deployment.`);
65
+ }
66
+ // stream so the download reports byte progress; fall back to a
67
+ // plain buffer read when the body is not streamable
68
+ if (!res.body || !options?.onProgress) return res.arrayBuffer();
69
+ const slot = { loaded: 0, total: Number(res.headers.get("Content-Length")) || 0 };
70
+ progressByAsset.set(assetId, slot);
71
+ reportProgress();
72
+ return readBodyWithProgress(res.body, received => {
73
+ slot.loaded = received;
74
+ // servers without Content-Length: keep total >= loaded so the
75
+ // bar never exceeds 100% (it jumps to done at the end)
76
+ if (slot.total < received) slot.total = received;
77
+ reportProgress();
78
+ });
79
+ }).catch(err => {
80
+ issue("reference", `asset "${assetId}" failed to load from ${url}: ${err}`);
81
+ return null;
82
+ });
83
+ }
84
+ assetData.set(assetId, pending);
85
+ }
86
+ return pending;
87
+ };
88
+
89
+ // parse-once cache: EVERY consumer — placed references, entity references, assets
90
+ // loaded on demand by overs — shares one parsed TEMPLATE per asset id. Placed
91
+ // references then INSTANTIATE per-reference copies from it (shared geometry/
92
+ // materials/textures, per-copy stable guids).
93
+ const parsedAssets = new Map<string, Promise<Model | null>>();
94
+ const loadParsedAsset = (assetId: string, arc: ProjectEvaluationIssue["arc"] = "over"): Promise<Model | null> => {
95
+ let pending = parsedAssets.get(assetId);
96
+ if (!pending) {
97
+ pending = (async () => {
98
+ // three DISTINGUISHABLE failure modes — an opaque "failed to load" cost a
99
+ // debugging session (missing assets-map entry read as a network problem)
100
+ if (!document.assets?.[assetId]) {
101
+ issue(arc, `asset "${assetId}" has no entry in the document's assets map`);
102
+ return null;
103
+ }
104
+ const data = await loadAssetData(assetId);
105
+ if (data === null) return null; // fetch failure already reported by loadAssetData
106
+ // parse with the RESOLVED src, so a remote asset's own relative
107
+ // resources (textures, external buffers) resolve at its location
108
+ const rawSrc = document.assets[assetId].src ?? assetId;
109
+ const src = resolveDocumentRemoteSrc(rawSrc, document.remotes, options?.remotes) ?? rawSrc;
110
+ const model = (await context.parse(data, src, parseOptionsFor(assetId))) ?? null;
111
+ if (!model) issue(arc, `asset "${assetId}" data loaded but PARSING failed (see console for the parser error)`);
112
+ if (model) {
113
+ if (model.parser?.json) attachNeedleIdsToLoadedGltf(model.parser.json, model.parser.associations, model.scenes);
114
+ // Park the template: a DETACHED scene still counts as active hierarchy
115
+ // (engine-test-pinned), so its queued components would AWAKE on the next
116
+ // pump in a running context. Deactivate in the same synchronous turn as
117
+ // the parse resolution — before any frame can run them. Copies are
118
+ // instantiated INTO the parked template (no awake) and re-activated
119
+ // after reparenting; the template itself is destroyed once all
120
+ // references are instantiated. Material-only glbs parse WITHOUT a
121
+ // scene — nothing to park.
122
+ if (model.scene) context.parkTemplate(model.scene);
123
+ }
124
+ return model;
125
+ })();
126
+ parsedAssets.set(assetId, pending);
127
+ }
128
+ return pending;
129
+ };
130
+
131
+ // ---- ENTITY references (with an address): instantiate into the entity pool -----
132
+ // Not placed in the graph. One promise per id — every consumer (assign values,
133
+ // entity-targeted overs) chains on the same instantiation, so N users share ONE
134
+ // runtime instance by construction and assign/patch ordering stops being temporal.
135
+ const entities = new Map<string, Promise<object | null>>();
136
+ for (const ref of document.references ?? []) {
137
+ if (!ref || typeof ref.id !== "string" || typeof ref.asset !== "string" || !ref.address) continue;
138
+ if (entities.has(ref.id)) {
139
+ issue("reference", `duplicate reference id "${ref.id}" — entry skipped`);
140
+ continue;
141
+ }
142
+ const address = ref.address;
143
+ entities.set(ref.id, (async (): Promise<object | null> => {
144
+ if (address.type === "material") {
145
+ const model = await loadParsedAsset(ref.asset);
146
+ if (!model) return null; // load/parse failure already reported
147
+ const material = await resolveMaterialEntity(model, address);
148
+ if (!material) issue("reference", `entity "${ref.id}": material did not resolve in asset "${ref.asset}" (${JSON.stringify(address)})`);
149
+ return material;
150
+ }
151
+ if (address.type === "hdr") {
152
+ // WHOLE-ASSET entity (address is only a type): the asset itself,
153
+ // instantiated as an environment texture (PMREM — hdr/exr/ktx2).
154
+ // The src rides the SAME resolution ladder as placed assets
155
+ // (editor hook, then remotes + document base) — the raw src
156
+ // reaching loadPMREM meant every relative/virtual environment
157
+ // silently failed to instantiate on reload (2026-08-18).
158
+ const src = document.assets?.[ref.asset]?.src;
159
+ if (typeof src !== "string") {
160
+ issue("reference", `entity "${ref.id}": unknown asset "${ref.asset}"`);
161
+ return null;
162
+ }
163
+ let url: string;
164
+ let revoke: (() => void) | undefined;
165
+ if (options?.resolveAsset) {
166
+ const data = await options.resolveAsset(ref.asset, src).catch(err => {
167
+ issue("reference", `entity "${ref.id}": asset "${ref.asset}" failed to resolve: ${err}`);
168
+ return null;
169
+ });
170
+ if (data === null) return null;
171
+ if (typeof data === "string") url = data;
172
+ else {
173
+ // loadPMREM sniffs content type on extensionless urls,
174
+ // so a bare blob of hdr/exr/ktx2 bytes routes correctly
175
+ const blobUrl = URL.createObjectURL(new Blob([data]));
176
+ url = blobUrl;
177
+ revoke = () => URL.revokeObjectURL(blobUrl);
178
+ }
179
+ }
180
+ else {
181
+ const resolvedSrc = resolveDocumentRemoteSrc(src, document.remotes, options?.remotes);
182
+ if (resolvedSrc === null) {
183
+ issue("reference", `entity "${ref.id}": src "${src}" names a remote that is not in the document's remotes table — the entity is skipped`);
184
+ return null;
185
+ }
186
+ url = options?.baseUrl ? new URL(resolvedSrc, options.baseUrl).href : resolvedSrc;
187
+ }
188
+ // the DOCUMENT src is the identity that still carries meaning —
189
+ // the resolved url may be a blob: of raw bytes, where loadPMREM's
190
+ // url sniffing can neither see the ".pmrem." marker (a prefiltered
191
+ // atlas would come back equirect-mapped) nor a usable filename
192
+ // (the texture would be labeled by a uuid). Pass both explicitly.
193
+ const srcPath = src.split(/[?#]/)[0];
194
+ const srcName = srcPath.substring(srcPath.lastIndexOf("/") + 1) || undefined;
195
+ const format = /pmrem/i.test(src) ? "pmrem" as const : undefined;
196
+ const texture = await context.loadEnvironment(url, { format, name: srcName }).finally(() => revoke?.());
197
+ if (!texture) issue("reference", `entity "${ref.id}": environment texture failed to load from ${src}`);
198
+ return texture;
199
+ }
200
+ issue("reference", `entity "${ref.id}": element type "${address.type ?? "?"}" is not instantiable yet`);
201
+ return null;
202
+ })());
203
+ }
204
+
205
+ return { loadParsedAsset, entities };
206
+ }
207
+
208
+ /**
209
+ * Evaluate a project document into the given context. One-shot; never throws for
210
+ * content issues (they are collected in the result), only for a non-document input.
211
+ *
212
+ * Order (plans/project-format-v0.md): load + place references (document order) →
213
+ * mounts → [pre-setup phase:] asset component deserialization (the engine's own
214
+ * queue, FIFO) → defs → overs. Resolution is scoped to the target reference's
215
+ * subtree; misses are reported and skipped (exact resolution only in v0).
216
+ */
217
+ export async function evaluateProjectDocument(context: Context, document: ProjectDocument, options?: EvaluateProjectDocumentOptions): Promise<ProjectEvaluationResult> {
218
+ if (!isProjectDocument(document)) {
219
+ throw new TypeError(`evaluateProjectDocument: not a project document (missing "${PROJECT_DOCUMENT_KEY}")`);
220
+ }
221
+
222
+ const root = options?.parent ?? (context.scene as unknown as Object3D);
223
+ const inspection = createProjectDocumentInspection(context.scene as unknown as Object3D);
224
+ const references = new Map<string, Object3D>(options?.referenceBindings as ReadonlyMap<string, Object3D> | undefined);
225
+ const models = new Map<string, Model>();
226
+ const issues: ProjectEvaluationIssue[] = [];
227
+ const issue = (arc: ProjectEvaluationIssue["arc"], message: string) => { issues.push({ arc, message }); };
228
+
229
+ const { loadParsedAsset, entities } = createDocumentAssetLoader(context, document, options, issue);
230
+
231
+ // Placed references: parse ONCE per asset (template, via loadParsedAsset), then
232
+ // INSTANTIATE per reference — deferred to the pre-setup phase so clones copy FULLY
233
+ // DESERIALIZED template components (the loader's queued deserialization runs first,
234
+ // FIFO). Each copy gets STABLE, ORDER-INDEPENDENT guids derived from the reference
235
+ // id carried in the document: deriveStableId(ref.id, originalFileGuid) — same
236
+ // guids on every client and load (engine-global lookups, networking ownership).
237
+ const pendingPlacements: Array<{ ref: ProjectDocumentReference; model: Model }> = [];
238
+ const pendingIds = new Set<string>();
239
+ for (const ref of document.references ?? []) {
240
+ if (!ref || typeof ref.id !== "string" || typeof ref.asset !== "string") {
241
+ issue("reference", `invalid reference entry: ${JSON.stringify(ref)}`);
242
+ continue;
243
+ }
244
+ if (ref.address) continue; // entity reference — pooled above, never placed
245
+ if (pendingIds.has(ref.id)) {
246
+ issue("reference", `duplicate reference id "${ref.id}" — entry skipped`);
247
+ continue;
248
+ }
249
+ if (options?.referenceBindings) {
250
+ const bound = options.referenceBindings.get(ref.id);
251
+ if (!bound) { issue("reference", `reference "${ref.id}": no existing model binding supplied`); continue; }
252
+ if (!document.assets?.[ref.asset]) { issue("reference", `reference "${ref.id}": unknown asset "${ref.asset}"`); continue; }
253
+ pendingIds.add(ref.id);
254
+ references.set(ref.id, bound);
255
+ models.set(ref.id, { scene: bound });
256
+ continue;
257
+ }
258
+ const model = await loadParsedAsset(ref.asset, "reference");
259
+ if (!model) {
260
+ // load/parse failure reported by loadParsedAsset (unknown asset, fetch, parser)
261
+ continue;
262
+ }
263
+ if (!model.scene) {
264
+ issue("reference", `reference "${ref.id}": asset "${ref.asset}" did not produce a scene`);
265
+ continue;
266
+ }
267
+ pendingIds.add(ref.id);
268
+ pendingPlacements.push({ ref, model });
269
+ }
270
+
271
+ // ---- instantiate + mounts + defs + overs: the engine's pre-setup phase ---
272
+ // The templates' own components deserialize in pre-setup callbacks the loader
273
+ // queued during parseSync (FIFO) — queuing AFTER the loads guarantees this
274
+ // callback sees fully deserialized templates, and still runs strictly before awake.
275
+ const completed = new Promise<void>((resolve) => {
276
+ context.schedule(() => {
277
+ // instantiate ALL copies FIRST, attach + activate afterwards: each
278
+ // instantiate() pumps processNewScripts, which awakens ACTIVE components in
279
+ // a running context — an already-activated copy A would awake mid-callback
280
+ // (before defs/overs) while copy B instantiates. All copies stay parked
281
+ // (inherited from the deactivated template) until the last pump inside this
282
+ // callback has happened; awake then follows at the engine's next natural
283
+ // pump, strictly after the document opinions below.
284
+ const placedCopies: Object3D[] = [];
285
+ for (const { ref, model } of pendingPlacements) {
286
+ const template = model.scene as unknown as Object3D;
287
+ const placed = context.instantiate(template, ref.id);
288
+ if (!placed) {
289
+ issue("reference", `reference "${ref.id}": instantiating asset "${ref.asset}" failed`);
290
+ continue;
291
+ }
292
+ references.set(ref.id, placed);
293
+ // per-reference Model view: parser/animations from the shared template,
294
+ // scene = THIS reference's instance (what editor open-file tracking needs)
295
+ models.set(ref.id, { ...model, scene: placed } as unknown as Model);
296
+ // Keep parser.associations valid for the copy: the loader keyed the
297
+ // TEMPLATE's objects, but tooling (editor source-asset attribution)
298
+ // looks up the PLACED instances. Materials/meshes/textures are shared
299
+ // with the template, so their keys stay valid — only Object3D keys
300
+ // need mirroring. instantiate preserves child order, so a parallel
301
+ // walk pairs template nodes with their copies.
302
+ const associations = (model as unknown as { parser?: { associations?: Map<object, unknown> } }).parser?.associations;
303
+ if (associations) {
304
+ const mirror = (a: Object3D, b: Object3D) => {
305
+ const record = associations.get(a);
306
+ if (record !== undefined) associations.set(b, record);
307
+ const count = Math.min(a.children.length, b.children.length);
308
+ for (let i = 0; i < count; i++) mirror(a.children[i], b.children[i]);
309
+ };
310
+ mirror(template, placed);
311
+ }
312
+ placedCopies.push(placed);
313
+ }
314
+ for (const placed of placedCopies) {
315
+ root.add(placed);
316
+ // undo only the park flag inherited from the template root — authored
317
+ // visibility lives on deeper nodes and is preserved by the clone
318
+ context.activateInstance(placed);
319
+ }
320
+
321
+ // mounts (after all placements so forward references work)
322
+ // placement TRS is ordinary overs on the placed root — no transform pass here
323
+ // DEPRECATED READ (2026-08-03, remove soon): `reference.parent` was replaced
324
+ // by parent OVERS below days after shipping; only unreleased documents from
325
+ // that week carry it. The writer no longer emits it.
326
+ for (const ref of document.references ?? []) {
327
+ const placed = ref?.id ? references.get(ref.id) : undefined;
328
+ if (!placed) continue;
329
+ if (ref.parent) {
330
+ const host = references.get(ref.parent.ref);
331
+ if (!host) {
332
+ issue("reference", `reference "${ref.id}": parent ref "${ref.parent.ref}" not placed — staying at the composition root`);
333
+ }
334
+ else {
335
+ // no address = mount at the host reference's ROOT (resolveInGraph
336
+ // returns null for an empty address — it never means "the root")
337
+ const mount = ref.parent.address
338
+ ? resolveInGraph(ref.parent.address, host as unknown as GraphNode) as unknown as Object3D | null
339
+ : host as unknown as Object3D;
340
+ if (!mount) {
341
+ issue("reference", `reference "${ref.id}": parent address did not resolve in "${ref.parent.ref}" — staying at the composition root`);
342
+ }
343
+ else {
344
+ mount.add(placed);
345
+ }
346
+ }
347
+ }
348
+ }
349
+
350
+ // the placement templates served their purpose — destroy them so their
351
+ // dormant registered components are cleanly unregistered. dispose=false:
352
+ // geometries/materials/textures are SHARED with the instantiated copies.
353
+ const destroyedTemplates = new Set<object>();
354
+ for (const { model } of pendingPlacements) {
355
+ if (model.scene && !destroyedTemplates.has(model.scene)) {
356
+ destroyedTemplates.add(model.scene);
357
+ context.disposeTemplate(model.scene);
358
+ }
359
+ }
360
+
361
+ // sync opinions (defs, property/active overs) apply strictly before awake;
362
+ // async ones (reference-valued material overs → load the material asset) run
363
+ // after and settle `completed`. applyOvers does its sync pass before the
364
+ // first await, so ordering for non-async overs is preserved.
365
+ applyDefs(context, document, root, references, issue, options?.baseUrl);
366
+ // Bind before mutations: original-scene paths can change during this batch.
367
+ const sceneTargets = new Map<ProjectDocumentTarget, Object3D | null>();
368
+ for (const over of document.overs ?? []) {
369
+ for (const target of [over?.target, over?.value as ProjectDocumentTarget | null]) {
370
+ if (target?.address && (target.ref === "$scene" || (target.ref !== undefined && options?.referenceBindings?.has(target.ref)))) {
371
+ const scope = over?.target?.path === "parent" ? root : context.scene;
372
+ sceneTargets.set(target, resolveTargetObject(target, root, references, scope));
373
+ }
374
+ }
375
+ }
376
+ const oversApplied = applyOvers(context, document, root, references, entities, issue, sceneTargets);
377
+ // PARENT OVERS run LAST: defs and property overs resolve their targets
378
+ // WITHIN reference scopes, so nodes must still sit inside their containers
379
+ // when those resolve (an async fn runs synchronously until its first await,
380
+ // so applyOvers' sync pass has completed here; async overs are
381
+ // entity/material work and never touch node parents). Flat-model
382
+ // consequence: content children are pulled out to the composition root
383
+ // only after every scoped target found them.
384
+ if (inspection) {
385
+ const referenceRoots = new Set(references.values());
386
+ for (const [ref, placed] of references) {
387
+ const model = models.get(ref) as unknown as { parser?: { associations?: Map<object, unknown> }; animations?: object[] } | undefined;
388
+ inspection.registerReference(ref, placed, [
389
+ ...(model?.parser?.associations?.keys() ?? []),
390
+ ...(model?.animations ?? []),
391
+ ], referenceRoots);
392
+ }
393
+ }
394
+ applyParentOvers(document, root, references, issue, sceneTargets);
395
+ oversApplied.finally(() => resolve());
396
+ });
397
+ });
398
+
399
+ if (inspection) {
400
+ inspection.ready = completed.then(async () => {
401
+ for (const [ref, entity] of entities) {
402
+ const resolved = await entity;
403
+ if (resolved) inspection.registerReference(ref, resolved);
404
+ }
405
+ });
406
+ registerProjectDocumentInspection(context.inspectionOwner ?? context, inspection);
407
+ }
408
+ return { root, references, models, issues, completed };
409
+ }
410
+
411
+ // ---------------------------------------------------------------------------
412
+ // target resolution
413
+ // ---------------------------------------------------------------------------
414
+
415
+ /**
416
+ * PARENT OVERS — the one structural rail (needle-editor plans/flat-scene-model.md):
417
+ * `{ target: { ref, address?, path: "parent" }, value: { ref | "$scene", address?,
418
+ * index? } }` reparents the target node under the value's node. Runs AFTER defs and
419
+ * the sync over pass (scoped targets must resolve while nodes still sit inside their
420
+ * containers), in ascending-index order: document nodes end up in the recorded
421
+ * RELATIVE order; foreign nodes (editor helpers) keep their positions and are not
422
+ * counted. Plain `add` (not attach): local TRS is authored by ordinary overs,
423
+ * parent-relative. An unresolvable side parks the node where it is + reports —
424
+ * never a load failure.
425
+ */
426
+ function applyParentOvers(document: ProjectDocument, root: Object3D, references: Map<string, Object3D>, issue: (arc: "over", message: string) => void, sceneTargets: ReadonlyMap<ProjectDocumentTarget, Object3D | null>): void {
427
+ const parentOvers = (document.overs ?? []).filter(over => over?.target?.path === "parent");
428
+ const overIndex = (over: ProjectDocumentOver): number => {
429
+ const index = (over.value as { index?: unknown } | null | undefined)?.index;
430
+ return typeof index === "number" ? index : Number.MAX_SAFE_INTEGER;
431
+ };
432
+ parentOvers.sort((a, b) => overIndex(a) - overIndex(b));
433
+ for (const over of parentOvers) {
434
+ const node = sceneTargets.has(over.target) ? sceneTargets.get(over.target) : resolveTargetObject(over.target, root, references);
435
+ if (!node) {
436
+ issue("over", `parent over target did not resolve: ${JSON.stringify(over.target)}`);
437
+ continue;
438
+ }
439
+ const value = over.value as { ref?: string; address?: NeedleAddress } | null | undefined;
440
+ if (!value || typeof value !== "object") {
441
+ issue("over", `parent over value must be a node reference: ${JSON.stringify(over.value)}`);
442
+ continue;
443
+ }
444
+ let parent: Object3D | null = null;
445
+ if (value.ref === undefined || value.ref === "$scene") {
446
+ // the composition root — where placements land (NOT context.scene:
447
+ // an embedded evaluation with options.parent must stay contained)
448
+ parent = sceneTargets.has(value) ? sceneTargets.get(value) ?? null : value.address
449
+ ? resolveInGraph(value.address, root as unknown as GraphNode) as unknown as Object3D | null
450
+ : root;
451
+ }
452
+ else {
453
+ const host = references.get(value.ref);
454
+ if (host) {
455
+ parent = value.address
456
+ ? resolveInGraph(value.address, host as unknown as GraphNode) as unknown as Object3D | null
457
+ : host as unknown as Object3D;
458
+ }
459
+ }
460
+ if (!parent) {
461
+ issue("over", `parent over did not resolve its new parent — staying in place: ${JSON.stringify(over.value)}`);
462
+ continue;
463
+ }
464
+ if (parent === node || isAncestorOf(node, parent)) {
465
+ issue("over", `parent over would create a cycle — ignored: ${JSON.stringify(over.target)}`);
466
+ continue;
467
+ }
468
+ parent.add(node);
469
+ }
470
+ }
471
+
472
+ /** is `node` an ancestor of `candidate` (used to refuse cycle-creating parent overs) */
473
+ function isAncestorOf(node: Object3D, candidate: Object3D): boolean {
474
+ for (let current: Object3D | null = candidate.parent; current; current = current.parent) {
475
+ if (current === node) return true;
476
+ }
477
+ return false;
478
+ }
479
+
480
+ function resolveTargetObject(target: ProjectDocumentTarget | undefined, root: Object3D, references: Map<string, Object3D>, scene?: Object3D | null): Object3D | null {
481
+ // reserved ref: the CONTEXT SCENE — same semantics as the "$scene" over
482
+ // branch (scene-level defs, e.g. components on the composition scene root)
483
+ const scope = target?.ref === "$scene" ? (scene ?? root)
484
+ : target?.ref !== undefined ? references.get(target.ref)
485
+ : root;
486
+ if (!scope) return null;
487
+ if (!target?.address) return scope;
488
+ return resolveInGraph(target.address, scope as unknown as GraphNode) as unknown as Object3D | null;
489
+ }
490
+
491
+ // ---------------------------------------------------------------------------
492
+ // property paths
493
+ // ---------------------------------------------------------------------------
494
+
495
+ /** `<field>:<value>` — matches an element of a collection by one of its fields */
496
+ const QUALIFIER = /^([A-Za-z_][A-Za-z0-9_-]*):([\s\S]*)$/;
497
+
498
+ export function splitPath(path: string): string[] {
499
+ // "~1" → "/" and "~0" → "~" (RFC 6901 escaping, unescaped in that order)
500
+ return path.split("/").filter(s => s.length > 0).map(s => s.replace(/~1/g, "/").replace(/~0/g, "~"));
501
+ }
502
+
503
+ /**
504
+ * Advance one path segment. Objects take the segment as a literal key; COLLECTIONS take
505
+ * either a numeric index or a `<field>:<value>` qualifier. Qualifier parsing is confined
506
+ * to collections, so a plain object property whose name contains ":" is never mistaken
507
+ * for one.
508
+ */
509
+ function stepPath(context: Context, current: unknown, segment: string, refScope: string | undefined, report: (message: string) => void): unknown {
510
+ // synthesized: a node's components, without exposing the userData layout
511
+ if ((current as Object3D | undefined)?.isObject3D === true && segment === "components") {
512
+ return context.getComponents?.(current as Object3D) ?? [];
513
+ }
514
+ if (!Array.isArray(current)) {
515
+ return (current as Record<string, unknown> | undefined)?.[segment];
516
+ }
517
+ return resolveDocumentCollectionElement(current, segment, {
518
+ matchesComponentType: context.matchesComponentType?.bind(context),
519
+ matchesComponentGuid: context.matchesComponentGuid?.bind(context),
520
+ deriveComponentGuid: context.deriveComponentGuid?.bind(context),
521
+ referenceId: refScope,
522
+ report,
523
+ });
524
+ }
525
+
526
+ /** walk every segment but the last; returns the container holding the final key */
527
+ function walkPath(context: Context, element: object, segments: string[], refScope: string | undefined, report: (message: string) => void): { container: object; chain: object[] } | null {
528
+ const chain: object[] = [element];
529
+ let current: unknown = element;
530
+ for (let i = 0; i < segments.length - 1; i++) {
531
+ const next = stepPath(context, current, segments[i], refScope, message => report(`at "${segments[i]}": ${message}`));
532
+ if (!next || typeof next !== "object") {
533
+ report(`does not resolve at "${segments[i]}"`);
534
+ return null;
535
+ }
536
+ current = next;
537
+ chain.push(next as object);
538
+ }
539
+ return { container: current as object, chain };
540
+ }
541
+
542
+ /** a live three material, seen through the addressing package's duck type */
543
+ type MaterialElement = AddressableElement & Record<string, unknown>;
544
+
545
+ /** every distinct material used anywhere under `root`, in traversal order */
546
+ function collectMaterials(root: Object3D): MaterialElement[] {
547
+ const materials: MaterialElement[] = [];
548
+ root.traverse(obj => {
549
+ const material = (obj as unknown as { material?: unknown }).material;
550
+ for (const m of Array.isArray(material) ? material : material ? [material] : []) {
551
+ if (m && typeof m === "object" && !materials.includes(m as MaterialElement)) {
552
+ materials.push(m as MaterialElement);
553
+ }
554
+ }
555
+ });
556
+ return materials;
557
+ }
558
+
559
+ /** materials are not scene-graph nodes — resolve them against the meshes of the scope
560
+ * (plus any subtrees deactivation detached from it: the material resource outlives
561
+ * its consumer's presence in the composed result) */
562
+ function resolveTargetMaterial(scope: Object3D, address: NeedleAddress, detachedRoots?: readonly Object3D[]): { material: Record<string, unknown> } | null {
563
+ // ONE ladder for every element type (anchor → id → name), from the addressing
564
+ // package — materials used to carry their own, which is why anchoring a material
565
+ // had no effect and why the two material resolvers here disagreed about whether a
566
+ // name match had to be unique.
567
+ const materials = collectMaterials(scope);
568
+ for (const detached of detachedRoots ?? []) {
569
+ for (const material of collectMaterials(detached)) {
570
+ if (!materials.includes(material)) materials.push(material);
571
+ }
572
+ }
573
+ const material = resolveInCollection(address, materials);
574
+ return material ? { material: material as Record<string, unknown> } : null;
575
+ }
576
+
577
+ // ---------------------------------------------------------------------------
578
+ // defs
579
+ // ---------------------------------------------------------------------------
580
+
581
+ function applyDefs(context: Context, document: ProjectDocument, root: Object3D, references: Map<string, Object3D>, issue: (arc: "def", message: string) => void, baseUrl?: string) {
582
+ const defs = document.defs ?? [];
583
+ if (!defs.length) return;
584
+ const serializationContext = createDefSerializationContext(context, root, baseUrl);
585
+ for (const def of defs) {
586
+ applyDef(def, root, references, issue, serializationContext);
587
+ }
588
+ }
589
+
590
+ /**
591
+ * The serialization context ONE def application (or a whole document's worth)
592
+ * deserializes through: rooted at the COMPOSITION root so `{guid}` references in
593
+ * def data resolve across the whole composed result (cross-reference wiring).
594
+ * `baseUrl`: URL-string component fields (e.g. a tracked image's remote object) go
595
+ * through the EXISTING AssetReference.getOrCreate deserialization — it needs a
596
+ * source id to resolve document-relative urls against (no new loading path).
597
+ */
598
+ export function createDefSerializationContext(context: Context, root: Object3D, baseUrl?: string): SerializationContext {
599
+ return context.createDefContext?.(root, baseUrl) ?? {
600
+ scene: context.scene,
601
+ createNode: () => context.createNode(),
602
+ };
603
+ }
604
+
605
+ /**
606
+ * Apply ONE def to the live composition — create/adopt the node, then
607
+ * instantiate/adopt its components. Exported as the single-arc primitive:
608
+ * document evaluation loops over it, and the op-bridge APPLICATOR applies a
609
+ * creation op's carried def through exactly this function (one implementation,
610
+ * plans/operation-bridge.md fixture 2).
611
+ */
612
+ export function applyDef(def: NonNullable<ProjectDocument["defs"]>[number], root: Object3D, references: Map<string, Object3D>, issue: (arc: "def", message: string) => void, serializationContext: SerializationContext): void {
613
+ {
614
+ let obj = resolveTargetObject(def?.target, root, references, serializationContext.scene);
615
+ if (!obj) {
616
+ issue("def", `def target did not resolve: ${JSON.stringify(def?.target)}`);
617
+ return;
618
+ }
619
+ if (def.create) {
620
+ if (def.create.copy) {
621
+ // Reproducing a duplicate needs the host's clone command: it owns
622
+ // component/runtime identity and batch reference retargeting.
623
+ issue("def", "copy definitions require a host duplication command");
624
+ return;
625
+ }
626
+ // the def DEFINES a node under the resolved parent (USD-proper def). When a
627
+ // child with the same name ALREADY exists it is ADOPTED (transform applied,
628
+ // components deserialize onto matching existing instances below) — a def
629
+ // over an existing prim composes onto it. This is how the start-camera def
630
+ // takes over the engine's own camera instead of creating a twin.
631
+ const children = obj.children as Object3D[] | undefined;
632
+ const existing = (typeof def.create.id === "string"
633
+ ? children?.find(c => c?.userData?.["needle-id"] === def.create!.id)
634
+ : undefined) ?? (typeof def.create.name === "string"
635
+ ? children?.find(c => c?.name === def.create!.name)
636
+ : undefined);
637
+ const node = existing ?? serializationContext.createNode();
638
+ if (!existing) {
639
+ if (typeof def.create.name === "string") node.name = def.create.name;
640
+ obj.add(node);
641
+ }
642
+ if (typeof def.create.id === "string") node.userData["needle-id"] = def.create.id;
643
+ if (Array.isArray(def.create.position) && def.create.position.length >= 3) node.position.fromArray(def.create.position);
644
+ if (Array.isArray(def.create.quaternion) && def.create.quaternion.length >= 4) node.quaternion.fromArray(def.create.quaternion);
645
+ if (Array.isArray(def.create.scale) && def.create.scale.length >= 3) node.scale.fromArray(def.create.scale);
646
+ obj = node;
647
+ }
648
+ for (const compData of def.components ?? []) {
649
+ if (!compData || typeof compData.name !== "string") {
650
+ issue("def", `invalid component entry: ${JSON.stringify(compData)}`);
651
+ continue;
652
+ }
653
+ // NODE REFERENCES ({ref: <reference id>, address?}) resolve to LIVE
654
+ // objects at ANY depth (top-level Object3D fields AND nested model
655
+ // arrays like trackedImages[i].object) BEFORE deserialization — the
656
+ // typed serializers then do the wrapping (an AssetReference field
657
+ // wraps the node, an Object3D field takes it as is). The references
658
+ // map is complete here, so cross-reference wiring works regardless
659
+ // of load order. Copy-on-write: the document is never mutated.
660
+ const cleanData = resolveNodeReferencesDeep(compData, root, references, issue) as { name: string; guid?: string } & Record<string, unknown>;
661
+ if (serializationContext.applyComponent) serializationContext.applyComponent(obj, cleanData, issue);
662
+ else issue("def", `unsupported component type "${compData.name}"`);
663
+ }
664
+ }
665
+ }
666
+
667
+ /**
668
+ * Deep-resolve NODE-REFERENCE values (`{ref: <known reference id>, address?}`) in def
669
+ * component data to the LIVE scene objects. Values whose `ref` is not a known
670
+ * reference id stay untouched for the regular serializers (entity refs, arbitrary
671
+ * objects). Unresolvable references become undefined (reported as an issue) — never
672
+ * a raw `{ref}` object leaking into a component field.
673
+ */
674
+ function resolveNodeReferencesDeep(value: unknown, root: Object3D, references: Map<string, Object3D>, issue: (arc: "def", message: string) => void): unknown {
675
+ if (!value || typeof value !== "object") return value;
676
+ if (Array.isArray(value)) {
677
+ let copy: unknown[] | null = null;
678
+ for (let i = 0; i < value.length; i++) {
679
+ const resolved = resolveNodeReferencesDeep(value[i], root, references, issue);
680
+ if (resolved !== value[i]) {
681
+ if (!copy) copy = [...value];
682
+ copy[i] = resolved;
683
+ }
684
+ }
685
+ return copy ?? value;
686
+ }
687
+ const candidate = value as { ref?: unknown };
688
+ if (typeof candidate.ref === "string" && references.has(candidate.ref)) {
689
+ const resolved = resolveTargetObject(value as ProjectDocumentTarget, root, references);
690
+ if (!resolved) {
691
+ issue("def", `node reference did not resolve: ${JSON.stringify(value)}`);
692
+ return undefined;
693
+ }
694
+ return resolved;
695
+ }
696
+ let copy: Record<string, unknown> | null = null;
697
+ for (const [key, entry] of Object.entries(value)) {
698
+ const resolved = resolveNodeReferencesDeep(entry, root, references, issue);
699
+ if (resolved !== entry) {
700
+ if (!copy) copy = { ...(value as Record<string, unknown>) };
701
+ copy[key] = resolved;
702
+ }
703
+ }
704
+ return copy ?? value;
705
+ }
706
+
707
+ // ---------------------------------------------------------------------------
708
+ // overs
709
+ // ---------------------------------------------------------------------------
710
+
711
+ async function applyOvers(context: Context, document: ProjectDocument, root: Object3D, references: Map<string, Object3D>, entities: Map<string, Promise<object | null>>, issue: (arc: "over", message: string) => void, sceneTargets: ReadonlyMap<ProjectDocumentTarget, Object3D | null>): Promise<void> {
712
+ const state = createOverApplyState(entities);
713
+ state.sceneTargets = sceneTargets;
714
+ for (const over of document.overs ?? []) {
715
+ applyOver(context, over, root, references, state, issue);
716
+ }
717
+ await state.settled();
718
+ }
719
+
720
+ /**
721
+ * The shared state a sequence of over applications rides — constructed once per
722
+ * document evaluation AND once per op-bridge apply session (the applicator,
723
+ * plans/operation-bridge.md fixture 2):
724
+ * - async entity chaining: entity-targeted overs and entity-ref values await the
725
+ * entity's instantiation; overs touching the same entity keep their order among
726
+ * themselves by chaining on the entity's tail promise. Non-async overs apply
727
+ * synchronously (pre-awake); `settled()` awaits the async remainder.
728
+ * - deactivated subtrees ("active": false → removeFromParent), per scope ref:
729
+ * materials are RESOURCES, not graph nodes — a material used only by a
730
+ * deactivated mesh must stay addressable (doc order legally deactivates first
731
+ * and patches after), so material resolution searches these detached roots too.
732
+ */
733
+ export type OverApplyState = {
734
+ /** Bind original-scene addresses before any rename, removal or reparent changes their paths. */
735
+ sceneTargets?: ReadonlyMap<ProjectDocumentTarget, Object3D | null>;
736
+ readonly entities: Map<string, Promise<object | null>>;
737
+ readonly deactivatedRoots: Map<string | undefined, Object3D[]>;
738
+ chainOnEntity(refId: string, work: (entity: object | null) => void): void;
739
+ settled(): Promise<void>;
740
+ };
741
+
742
+ export function createOverApplyState(entities: Map<string, Promise<object | null>>): OverApplyState {
743
+ const pending: Promise<void>[] = [];
744
+ const entityTails = new Map<string, Promise<void>>();
745
+ const deactivatedRoots = new Map<string | undefined, Object3D[]>();
746
+ return {
747
+ entities,
748
+ deactivatedRoots,
749
+ chainOnEntity(refId: string, work: (entity: object | null) => void): void {
750
+ const instantiation = entities.get(refId)!;
751
+ const prev = entityTails.get(refId) ?? Promise.resolve();
752
+ const tail = Promise.all([instantiation, prev]).then(([entity]) => work(entity));
753
+ entityTails.set(refId, tail);
754
+ pending.push(tail);
755
+ },
756
+ settled: () => Promise.all(pending).then(() => undefined),
757
+ };
758
+ }
759
+
760
+ /**
761
+ * Apply ONE over to the live composition — the single-arc primitive: document
762
+ * evaluation loops over it, and the op-bridge APPLICATOR applies a remote
763
+ * property/assign/deactivation op through exactly this function (one
764
+ * implementation, plans/operation-bridge.md fixture 2).
765
+ */
766
+ export function applyOver(context: Context, over: NonNullable<ProjectDocument["overs"]>[number], root: Object3D, references: Map<string, Object3D>, state: OverApplyState, issue: (arc: "over", message: string) => void): void {
767
+ const { entities, deactivatedRoots, chainOnEntity } = state;
768
+ {
769
+ const target = over?.target;
770
+ if (!target || typeof target.path !== "string" || target.path.length === 0) {
771
+ issue("over", `over without a target path: ${JSON.stringify(over)}`);
772
+ return;
773
+ }
774
+ // "parent" is a reserved STRUCTURAL path — applied in the placement phase
775
+ // (after all placements, index-ordered), never as a property write
776
+ if (target.path === "parent") return;
777
+
778
+ // ---- ENTITY-targeted over: patch the pooled entity itself ----------------
779
+ if (target.ref !== undefined && entities.has(target.ref)) {
780
+ const refId = target.ref, path = target.path, value = over.value, tgt = target;
781
+ chainOnEntity(refId, entity => {
782
+ if (!entity) return; // instantiation failure already reported
783
+ applyValueAtPath(context, entity, path, value, issue, tgt);
784
+ });
785
+ return;
786
+ }
787
+
788
+ // materials resolve against the scope's meshes, everything else via the graph
789
+ let element: object | null = null;
790
+ let owner: Object3D | null = null;
791
+ if (target.ref === "$scene") {
792
+ // reserved ref: the CONTEXT SCENE — scene-level opinions (background,
793
+ // environment, fog) apply to the scene itself, NOT the composition parent
794
+ // (which is a plain container when the evaluator runs with a `parent`).
795
+ // With an ADDRESS, the target is a node INSIDE the scene (e.g. the engine's
796
+ // own camera — the start-camera pose overs). `$`-prefix is reserved.
797
+ element = state.sceneTargets?.has(target) ? state.sceneTargets.get(target) ?? null : target.address
798
+ ? resolveInGraph(target.address, context.scene as unknown as GraphNode) as unknown as object | null
799
+ : context.scene as unknown as object;
800
+ }
801
+ else if (target.ref === "$renderer") {
802
+ // reserved ref: the CONTEXT RENDERER — renderer-level opinions
803
+ // (toneMapping, toneMappingExposure). Addresses make no sense here.
804
+ if (target.address) {
805
+ issue("over", `"$renderer" targets take no address: ${JSON.stringify(target)}`);
806
+ return;
807
+ }
808
+ element = context.renderer as unknown as object;
809
+ }
810
+ else if (target.address?.type === "material") {
811
+ const scope = target.ref !== undefined ? references.get(target.ref) : root;
812
+ element = scope ? resolveTargetMaterial(scope, target.address, deactivatedRoots.get(target.ref))?.material ?? null : null;
813
+ }
814
+ else {
815
+ owner = state.sceneTargets?.has(target) ? state.sceneTargets.get(target) ?? null : resolveTargetObject(target, root, references);
816
+ element = owner;
817
+ }
818
+ if (!element) {
819
+ issue("over", `over target did not resolve: ${JSON.stringify(target)}`);
820
+ return;
821
+ }
822
+
823
+ // reserved LAST segment: "active" — deactivation (USD semantics: opinions on
824
+ // content the document does not own; false removes it from the composed result).
825
+ // Whatever the rest of the path resolved to is what gets deactivated, so a node
826
+ // ("active") and a component ("components/guid:…/active") share one mechanism.
827
+ const segments = splitPath(target.path);
828
+ if (segments[segments.length - 1] === "active") {
829
+ if (over.value !== false) return;
830
+ const holder = segments.length === 1
831
+ ? element
832
+ : walkPath(context, element, segments, target.ref, message => issue("over", `path "${target.path}" ${message}: ${JSON.stringify(target)}`))?.container;
833
+ if (holder) {
834
+ // remember detached subtrees so their material RESOURCES stay addressable
835
+ if ((holder as Object3D).isObject3D) {
836
+ const detached = deactivatedRoots.get(target.ref) ?? [];
837
+ detached.push(holder as Object3D);
838
+ deactivatedRoots.set(target.ref, detached);
839
+ }
840
+ deactivate(context, holder, owner, issue);
841
+ }
842
+ return;
843
+ }
844
+
845
+ // ---- NODE-ref value ({ref: <reference id>, address?}): an Object3D field
846
+ // assigned BY REFERENCE to a node of the composition (the wire form the
847
+ // editor writes for object slots). Checked before the entity branch —
848
+ // reference ids and entity ids are distinct pools.
849
+ if (isEntityRefValue(over.value) && (over.value.ref === "$scene" || references.has(over.value.ref))) {
850
+ const node = state.sceneTargets?.has(over.value) ? state.sceneTargets.get(over.value)
851
+ : resolveTargetObject(over.value as ProjectDocumentTarget, root, references, context.scene);
852
+ if (!node) {
853
+ issue("over", `node-reference value did not resolve: ${JSON.stringify(over.value)} for ${JSON.stringify(target)}`);
854
+ return;
855
+ }
856
+ applyValueAtPath(context, element, target.path, node, issue, target);
857
+ return;
858
+ }
859
+
860
+ // ---- ENTITY-ref value: assign the pooled entity (USD material:binding) ----
861
+ // NEVER raw-assign a reference object (that breaks the property).
862
+ if (isEntityRefValue(over.value)) {
863
+ const refId = over.value.ref;
864
+ const instantiation = entities.get(refId);
865
+ if (!instantiation) {
866
+ issue("over", `value references unknown entity "${refId}": ${JSON.stringify(target)}`);
867
+ return;
868
+ }
869
+ const el = element, path = target.path, tgt = target;
870
+ chainOnEntity(refId, entity => {
871
+ if (!entity) {
872
+ issue("over", `entity "${refId}" was not instantiated — assignment skipped: ${JSON.stringify(tgt)}`);
873
+ return;
874
+ }
875
+ applyValueAtPath(context, el, path, entity, issue, tgt);
876
+ });
877
+ return;
878
+ }
879
+ if (isReferenceValued(over.value)) {
880
+ issue("over", `reference-valued over not supported (skipped, not raw-assigned): ${JSON.stringify(target)}`);
881
+ return;
882
+ }
883
+
884
+ applyValueAtPath(context, element, target.path, over.value, issue, target);
885
+ }
886
+ }
887
+
888
+ /** an over VALUE pointing at a pooled entity reference: `{ ref: "<reference id>" }` */
889
+ function isEntityRefValue(v: unknown): v is { ref: string } {
890
+ return !!v && typeof v === "object" && typeof (v as { ref?: unknown }).ref === "string";
891
+ }
892
+
893
+ /** an over VALUE that is a reference to another element (never raw-assign these) */
894
+ function isReferenceValued(v: unknown): boolean {
895
+ if (!v || typeof v !== "object") return false;
896
+ const o = v as Record<string, unknown>;
897
+ return typeof o.asset === "string" || o.node !== undefined || typeof o.guid === "string";
898
+ }
899
+
900
+ /**
901
+ * Resolve one of an asset's materials by address, through the shared ladder.
902
+ *
903
+ * The single-material fallback is CALLER policy, not part of the ladder, and it is a
904
+ * HEURISTIC rather than a contract: library material assets happen to be glbs carrying
905
+ * one material today, but nothing stops one gaining more, so this must never be the
906
+ * primary path. It exists only so an entity reference still resolves when the recorded
907
+ * address has drifted. When the asset does carry several materials the fallback simply
908
+ * does not apply and the caller reports an unresolved entity — a visible miss, never a
909
+ * silent guess at which material was meant.
910
+ */
911
+ function findMaterialInModel(model: Model, address: NeedleAddress): object | null {
912
+ const scene = (model as unknown as { scene?: Object3D }).scene;
913
+ if (!scene) return null;
914
+ const materials = collectMaterials(scene);
915
+ return resolveInCollection(address, materials) ?? (materials.length === 1 ? materials[0] : null);
916
+ }
917
+
918
+ /**
919
+ * Material entity resolution incl. MATERIAL-ONLY glbs (a promoted material
920
+ * library entry: materials but no scene/meshes). The loader never
921
+ * instantiates an unreferenced material, so a scene traversal finds nothing —
922
+ * pull the materials from the parser instead. Exported for tests.
923
+ */
924
+ export async function resolveMaterialEntity(model: Model, address: NeedleAddress): Promise<object | null> {
925
+ const found = findMaterialInModel(model, address);
926
+ if (found) return found;
927
+ const parser = (model as unknown as { parser?: { json?: { materials?: unknown[] }; getDependency?(type: string, index: number): Promise<unknown> } }).parser;
928
+ const count = parser?.json?.materials?.length ?? 0;
929
+ if (!count || !parser?.getDependency) return null;
930
+ const loaded: MaterialElement[] = [];
931
+ for (let i = 0; i < count; i++) {
932
+ const material = await parser.getDependency("material", i).catch(() => null);
933
+ if (material && typeof material === "object") loaded.push(material as MaterialElement);
934
+ }
935
+ return resolveInCollection(address, loaded) ?? (loaded.length === 1 ? loaded[0] : null);
936
+ }
937
+
938
+ /** drain a response body into one buffer, reporting cumulative received bytes per chunk */
939
+ async function readBodyWithProgress(body: ReadableStream<Uint8Array>, onChunk: (receivedBytes: number) => void): Promise<ArrayBuffer> {
940
+ const reader = body.getReader();
941
+ const chunks: Uint8Array[] = [];
942
+ let received = 0;
943
+ for (; ;) {
944
+ const { done, value } = await reader.read();
945
+ if (done) break;
946
+ if (value) {
947
+ chunks.push(value);
948
+ received += value.byteLength;
949
+ onChunk(received);
950
+ }
951
+ }
952
+ const result = new Uint8Array(received);
953
+ let offset = 0;
954
+ for (const chunk of chunks) {
955
+ result.set(chunk, offset);
956
+ offset += chunk.byteLength;
957
+ }
958
+ return result.buffer;
959
+ }
960
+
961
+ function deactivate(context: Context, element: object, owner: Object3D | null, issue: (arc: "over", message: string) => void) {
962
+ if ((element as Object3D).isObject3D) {
963
+ (element as Object3D).removeFromParent();
964
+ }
965
+ else if (context.removeComponent) context.removeComponent(element, owner);
966
+ else issue("over", "component deactivation is not supported by this host");
967
+ }
968
+
969
+ function applyValueAtPath(context: Context, element: object, path: string, value: unknown, issue: (arc: "over", message: string) => void, target: ProjectDocumentTarget) {
970
+ const segments = splitPath(path);
971
+ // the walked chain — materials/textures along it need `needsUpdate` after a set
972
+ // (deep overs like "material/map/repeat" patch texture state that three only
973
+ // uploads/recompiles when flagged)
974
+ const walked = walkPath(context, element, segments, target.ref, message => issue("over", `path "${path}" ${message}: ${JSON.stringify(target)}`));
975
+ if (!walked) return;
976
+ const { container, chain } = walked;
977
+ const current = container as Record<string, unknown>;
978
+ const key = segments[segments.length - 1];
979
+ const existing = current[key];
980
+ // three.js value types (Vector3, Color, Quaternion, …) are patched in place from
981
+ // arrays; primitives and everything else assign directly
982
+ if (Array.isArray(value) && existing && typeof existing === "object" && typeof (existing as { fromArray?: unknown }).fromArray === "function") {
983
+ (existing as { fromArray(v: unknown[]): void }).fromArray(value);
984
+ }
985
+ else if (Array.isArray(value) && value.length === 3 && key === "background" && (current as { isScene?: boolean }).isScene === true) {
986
+ // scene.background is null OR a texture (environment) by default — a color over
987
+ // must CONSTRUCT/REPLACE with a Color (raw-assigning the array breaks rendering;
988
+ // the generic fromArray branch above already handled an existing Color)
989
+ current[key] = context.createColor(value as number[]);
990
+ }
991
+ else {
992
+ current[key] = value;
993
+ }
994
+ for (const touched of chain) {
995
+ const o = touched as { isMaterial?: boolean; isTexture?: boolean; needsUpdate?: boolean };
996
+ if (o.isMaterial === true || o.isTexture === true) o.needsUpdate = true;
997
+ }
998
+ }