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