@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.
- package/Readme.md +62 -0
- package/dist/address.d.ts +5 -0
- package/dist/address.js +25 -3
- package/dist/document/assets.d.ts +27 -0
- package/dist/document/assets.js +75 -0
- package/dist/document/collectionSelector.d.ts +9 -0
- package/dist/document/collectionSelector.js +29 -0
- package/dist/document/evaluate.d.ts +72 -0
- package/dist/document/evaluate.js +971 -0
- package/dist/document/index.d.ts +8 -0
- package/dist/document/index.js +8 -0
- package/dist/document/inspection.d.ts +30 -0
- package/dist/document/inspection.js +130 -0
- package/dist/document/runtime.d.ts +71 -0
- package/dist/document/runtime.js +1 -0
- package/dist/document/types.d.ts +225 -0
- package/dist/document/types.js +43 -0
- package/dist/document/validation.d.ts +36 -0
- package/dist/document/validation.js +219 -0
- package/dist/document/write.d.ts +31 -0
- package/dist/document/write.js +106 -0
- package/dist/id.d.ts +2 -0
- package/dist/id.js +4 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/loadedGltf.d.ts +32 -0
- package/dist/loadedGltf.js +62 -0
- package/dist/resolve.js +8 -6
- package/package.json +9 -1
- package/src/address.ts +28 -3
- package/src/document/assets.ts +77 -0
- package/src/document/collectionSelector.ts +37 -0
- package/src/document/evaluate.ts +998 -0
- package/src/document/index.ts +8 -0
- package/src/document/inspection.ts +123 -0
- package/src/document/runtime.ts +66 -0
- package/src/document/schema.json +263 -0
- package/src/document/types.ts +249 -0
- package/src/document/validation.ts +222 -0
- package/src/document/write.ts +112 -0
- package/src/id.ts +5 -0
- package/src/index.ts +3 -0
- package/src/loadedGltf.ts +93 -0
- 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
|
+
}
|