@es3d/es3d 1.1.1 → 1.2.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.
@@ -0,0 +1,527 @@
1
+ // Fragments: complete ES3D documents shown inside a stage's document.
2
+ //
3
+ // const tile = stage.add(source, { id: '16/32748/21789' });
4
+ // await tile.ready; // compiled and placed
5
+ // tile.visible = false; // kept, not drawn
6
+ // tile.replace(newSource); // only this fragment is rebuilt
7
+ // tile.remove();
8
+ //
9
+ // Every fragment is a conforming version-1 document, so any reader can open
10
+ // it on its own; the stage only draws several of them together. Each keeps
11
+ // its own compile result: its own ids (fragment.setTransforms, <use>), its
12
+ // own scene graph and animation. Drawing goes through a SceneSet
13
+ // (scene-set.js): materials with identical parameters are one material for
14
+ // the whole stage, and one GeometryCache serves the host and every fragment,
15
+ // each building under its own owner (geometry-cache.js), so a shape repeated
16
+ // across tiles is generated and uploaded once.
17
+ //
18
+ // Changes are queued: applyQueued(budget) compiles and places them in the
19
+ // stage's frame loop until the budget (ms) is spent, and the rest waits for
20
+ // the next frame. A change already started finishes over budget. Removing and
21
+ // hiding are applied at once: they cost what the fragment's own geometry and
22
+ // material lists do, and a page expects a removed tile gone in the next frame.
23
+ //
24
+ // This module has no DOM: the stage (render/stage.js) connects it to the
25
+ // renderer and the frame loop, and the tests drive it directly.
26
+
27
+ import { compile, now as clockNow } from '../pipeline.js';
28
+ import { parse, formatDiagnostics } from '../format/parse.js';
29
+ import { GeometryCache, DOCUMENT } from '../geometry/geometry-cache.js';
30
+ import { applyLocalMatrices, updateDynamic } from './flatten.js';
31
+ import { ancestorNames } from './picking.js';
32
+ import { SceneSet, ScenePart } from './scene-set.js';
33
+ import { Prepared, hostOnlyWarnings, sceneFromPayload } from './prepared.js';
34
+
35
+ const isThenable = (v) => !!v && typeof v.then === 'function';
36
+
37
+ /** One document shown inside the stage's document. */
38
+ export class Fragment {
39
+ constructor(owner, id) {
40
+ this.stage = owner;
41
+ this.id = id;
42
+ this.part = new ScenePart(id, 'fragment');
43
+ this.result = null; // the placed compile result (a prepared one compiles its graph on demand)
44
+ this.owner = null; // its cache owner
45
+ this.source = null;
46
+ this.warnings = [];
47
+ this.tasks = []; // queued changes, oldest first
48
+ this.removed = false;
49
+ this.time = NaN; // clock its animated rows were last evaluated at
50
+ this.progress = NaN;
51
+ /** @type {Promise<{ok: boolean, diagnostics: object[], fragment: string}>} */
52
+ this.ready = null;
53
+ }
54
+
55
+ /** Compiled and drawn (or hidden) in the stage. */
56
+ get placed() {
57
+ return !!this.part.scene;
58
+ }
59
+
60
+ get visible() {
61
+ return this.part.visible;
62
+ }
63
+
64
+ /** Hide or show it. Nothing is recompiled; a hidden fragment draws no rows at all. */
65
+ set visible(v) {
66
+ if (this.removed) return;
67
+ this.stage.set.setVisible(this.part, v);
68
+ this.stage.changed = true;
69
+ }
70
+
71
+ /** Diagnostics of the content on screen (compile's and the stage's warnings). */
72
+ get diagnostics() {
73
+ return this.result ? [...this.result.diagnostics, ...this.warnings] : [];
74
+ }
75
+
76
+ /**
77
+ * Show other content in this fragment's place, in its turn. Bad input keeps
78
+ * the content on screen. Resolves when the new content is placed or failed.
79
+ * @param {string|ArrayBuffer|Uint8Array|Prepared|Promise<Prepared>} source
80
+ * @param {{immediate?: boolean}} [opts]
81
+ */
82
+ replace(source, opts = {}) {
83
+ if (this.removed) return Promise.resolve({ ok: false, diagnostics: [], fragment: this.id, removed: true });
84
+ this.ready = this.stage.enqueue(this, source, !!opts.immediate);
85
+ return this.ready;
86
+ }
87
+
88
+ /**
89
+ * Replace the local matrix of elements of this fragment, by their ids in
90
+ * this fragment's document. Same entries, rules and return values as
91
+ * stage.setTransforms: false for an id that is missing or drawn in more
92
+ * than one place.
93
+ * @param {Array<[string, ArrayLike<number>]|{id: string, matrix: ArrayLike<number>}>} entries
94
+ */
95
+ setTransforms(entries) {
96
+ const list = Array.from(entries || []);
97
+ if (this.removed || !this.result) return list.map(() => false);
98
+ const res = this.stage.materialize(this);
99
+ const byId = res.graph.byId;
100
+ const nodes = list.map((entry) => byId.get(Array.isArray(entry) ? entry[0] : entry && entry.id));
101
+ const matrices = list.map((entry) => (Array.isArray(entry) ? entry[1] : entry && entry.matrix));
102
+ const { time, progress } = this.stage.clock();
103
+ const applied = applyLocalMatrices(res.scene, res.graph, nodes, matrices, time, progress);
104
+ if (applied.some(Boolean)) {
105
+ this.stage.set.invalidateBounds();
106
+ this.stage.changed = true;
107
+ }
108
+ return applied;
109
+ }
110
+
111
+ /** setTransforms for one id; true when it was applied. */
112
+ setTransform(id, matrix) {
113
+ return this.setTransforms([[id, matrix]])[0] === true;
114
+ }
115
+
116
+ /** Take it out of the stage. Its rows and the geometry only it used are freed. */
117
+ remove() {
118
+ this.stage.removeFragment(this);
119
+ }
120
+ }
121
+
122
+ /**
123
+ * The fragments of one stage and the scene set that draws them with the host.
124
+ */
125
+ export class FragmentStage {
126
+ /**
127
+ * @param {object} [o]
128
+ * @param {GeometryCache} [o.cache] shared with the host document
129
+ * @param {() => {time: number, progress: number}} [o.clock]
130
+ * @param {(message: string, result: object) => void} [o.report] errors and stage warnings
131
+ * @param {{compile: Function}|null} [o.compiler] a CompilerClient (compile-worker.js); null compiles here
132
+ * @param {(owner: *) => void} [o.forget] an owner's geometry is gone (stop storing it)
133
+ * @param {(cache: GeometryCache, owner: *) => Promise|null} [o.restore] read an owner's stored
134
+ * meshes before it is placed (the stage's geometry store); null: place at once
135
+ * @param {() => number} [o.now]
136
+ */
137
+ constructor(o = {}) {
138
+ this.cache = o.cache || new GeometryCache();
139
+ this.clock = o.clock || (() => ({ time: 0, progress: 0 }));
140
+ this.report = o.report || null;
141
+ this.compiler = o.compiler || null;
142
+ this.forget = o.forget || null;
143
+ this.restore = o.restore || null;
144
+ this.now = o.now || clockNow;
145
+ this.set = new SceneSet();
146
+ this.host = this.set.insert(new ScenePart(null, 'host'));
147
+ this.hostResult = null;
148
+ this.hostOwner = DOCUMENT;
149
+ this.byId = new Map();
150
+ this.queue = [];
151
+ this.waiting = 0; // changes waiting for a worker's result
152
+ this.nextId = 1;
153
+ this.changed = false; // something to draw since the stage last looked (takeChanged)
154
+ }
155
+
156
+ /** Fragments in the order added. */
157
+ get list() {
158
+ return [...this.byId.values()];
159
+ }
160
+
161
+ /** Changes not yet placed: queued ones and those waiting for a worker. */
162
+ get queued() {
163
+ return this.queue.length;
164
+ }
165
+
166
+ get pending() {
167
+ return this.queue.length > 0;
168
+ }
169
+
170
+ /** True once if anything changed since the last call. */
171
+ takeChanged() {
172
+ const c = this.changed;
173
+ this.changed = false;
174
+ return c;
175
+ }
176
+
177
+ newOwner(kind) {
178
+ return { owner: kind };
179
+ }
180
+
181
+ // --- the host ---------------------------------------------------------------
182
+
183
+ /**
184
+ * Draw a compiled document as the host. `owner` holds its geometry; the
185
+ * previous host's owner is released.
186
+ */
187
+ setHost(result, owner) {
188
+ this.set.place(this.host, result.scene);
189
+ this.adoptHost(result, owner);
190
+ }
191
+
192
+ /** Record the host without drawing it (a stage that has no fragment yet). */
193
+ adoptHost(result, owner) {
194
+ if (this.hostOwner !== owner) this.release(this.hostOwner);
195
+ this.hostResult = result;
196
+ this.hostOwner = owner;
197
+ this.changed = true;
198
+ }
199
+
200
+ release(owner) {
201
+ if (owner == null) return;
202
+ this.cache.release(owner);
203
+ if (this.forget && owner !== DOCUMENT) this.forget(owner);
204
+ }
205
+
206
+ /**
207
+ * The compile result a prepared document stands for, with its geometry in
208
+ * this stage's cache. A prepared document is placed once.
209
+ * @param {Prepared} prepared
210
+ * @returns {{result: object, owner: *}}
211
+ */
212
+ fromPrepared(prepared, kind = 'fragment') {
213
+ if (prepared.used) throw new Error('A prepared document can be shown or added once; prepare it again.');
214
+ prepared.used = true;
215
+ prepared.claimed = true;
216
+ if (!prepared.ok) {
217
+ return { result: { ok: false, doc: null, graph: null, scene: null, cache: this.cache, diagnostics: prepared.documentDiagnostics, timings: prepared.timings, lineIndex: prepared.lineIndex }, owner: null };
218
+ }
219
+ if (prepared.result) return { result: prepared.result, owner: prepared.owner };
220
+ const owner = this.newOwner(kind);
221
+ if (prepared.payload.main) {
222
+ // the worker could not carry it (animation, textures): compile it here
223
+ const { time, progress } = this.clock();
224
+ return { result: compile(prepared.source, { cache: this.cache, owner, time, progress }), owner };
225
+ }
226
+ const source = prepared.source;
227
+ const cache = this.cache;
228
+ let local = null;
229
+ // a level the worker did not send (its key was sent for another document
230
+ // and evicted here since) is generated from this document, compiled here once
231
+ const real = (key) => {
232
+ if (!local) {
233
+ local = new GeometryCache();
234
+ local.quality = cache.quality;
235
+ compile(source, { cache: local });
236
+ }
237
+ const e = local.entries.get(key);
238
+ if (!e) throw new Error(`The document has no geometry ${key}.`);
239
+ return e.desc;
240
+ };
241
+ const scene = sceneFromPayload(prepared.payload, cache, owner, real);
242
+ prepared.payload = null; // its buffers are the scene's rows now
243
+ let doc = null;
244
+ const result = {
245
+ ok: true,
246
+ scene,
247
+ cache,
248
+ graph: null, // compiled on demand (FragmentStage.materialize)
249
+ diagnostics: prepared.documentDiagnostics,
250
+ timings: prepared.timings,
251
+ prepared: true,
252
+ source,
253
+ // parsed on first use, for the names of a picked element
254
+ get doc() {
255
+ if (!doc) doc = parse(source);
256
+ return doc;
257
+ },
258
+ };
259
+ return { result, owner };
260
+ }
261
+
262
+ // --- fragments --------------------------------------------------------------
263
+
264
+ /**
265
+ * Add a fragment. Its content is compiled and placed in its turn (or at
266
+ * once with immediate: true); until then it draws nothing.
267
+ * @param {string|ArrayBuffer|Uint8Array|Prepared|Promise<Prepared>} source
268
+ * @param {{id?: string, immediate?: boolean}} [opts]
269
+ */
270
+ add(source, opts = {}) {
271
+ let id = opts.id != null ? String(opts.id) : null;
272
+ if (id === null) {
273
+ while (this.byId.has(`fragment-${this.nextId}`)) this.nextId++;
274
+ id = `fragment-${this.nextId++}`;
275
+ }
276
+ if (this.byId.has(id)) throw new Error(`The stage has a fragment "${id}" already. Replace it, or remove it first.`);
277
+ const frag = new Fragment(this, id);
278
+ this.byId.set(id, frag);
279
+ this.set.insert(frag.part);
280
+ frag.ready = this.enqueue(frag, source, !!opts.immediate);
281
+ return frag;
282
+ }
283
+
284
+ /** Queue new content for a fragment. A change not yet started gives way to it. */
285
+ enqueue(frag, input, immediate) {
286
+ this.claim(input);
287
+ let settle;
288
+ const promise = new Promise((r) => (settle = r));
289
+ const task = { frag, input, ready: !isThenable(input), cancelled: false, finished: false, immediate, placing: null, resolve: null };
290
+ task.resolve = (v) => {
291
+ task.finished = true;
292
+ settle(v);
293
+ };
294
+ // older changes of this fragment that have not started resolve with this one
295
+ for (const old of frag.tasks) {
296
+ this.cancel(old);
297
+ promise.then(old.resolve);
298
+ }
299
+ frag.tasks = [task];
300
+ if (isThenable(input)) {
301
+ this.waiting++;
302
+ input.then(
303
+ (v) => {
304
+ task.input = v;
305
+ },
306
+ (err) => {
307
+ task.input = null;
308
+ task.error = err;
309
+ },
310
+ ).then(() => {
311
+ this.waiting--;
312
+ task.ready = true;
313
+ if (task.cancelled) this.discard(task.input);
314
+ });
315
+ }
316
+ if (immediate && task.ready) this.run(task);
317
+ else this.queue.push(task);
318
+ return promise;
319
+ }
320
+
321
+ // a prepared document becomes one fragment's rows: using it twice would share them
322
+ claim(input) {
323
+ if (!(input instanceof Prepared)) return;
324
+ if (input.claimed) throw new Error('A prepared document can be shown or added once; prepare it again.');
325
+ input.claimed = true;
326
+ }
327
+
328
+ cancel(task) {
329
+ task.cancelled = true;
330
+ task.finished = true;
331
+ if (task.placing) this.release(task.placing.owner);
332
+ const k = this.queue.indexOf(task);
333
+ if (k >= 0) this.queue.splice(k, 1);
334
+ if (task.ready) this.discard(task.input);
335
+ }
336
+
337
+ // a prepared document compiled here holds geometry until it is used
338
+ discard(input) {
339
+ if (input instanceof Prepared && !input.used && input.owner) {
340
+ input.used = true;
341
+ this.release(input.owner);
342
+ }
343
+ }
344
+
345
+ /**
346
+ * Apply queued changes until `budget` ms are spent (at least one per call).
347
+ * @returns {{applied: number, changeMs: number, compileMs: number}}
348
+ */
349
+ applyQueued(budget = Infinity) {
350
+ const t0 = this.now();
351
+ let applied = 0;
352
+ let compileMs = 0;
353
+ while (this.queue.length) {
354
+ if (applied && this.now() - t0 >= budget) break;
355
+ const k = this.queue.findIndex((t) => t.ready);
356
+ if (k < 0) break;
357
+ const task = this.queue.splice(k, 1)[0];
358
+ compileMs += this.run(task);
359
+ applied++;
360
+ }
361
+ return { applied, changeMs: applied ? this.now() - t0 : 0, compileMs };
362
+ }
363
+
364
+ /** Compile and place one change. Returns the time spent compiling (ms). */
365
+ run(task) {
366
+ const frag = task.frag;
367
+ frag.tasks = frag.tasks.filter((t) => t !== task);
368
+ if (task.cancelled || frag.removed) return 0;
369
+ if (task.placing) {
370
+ // compiled earlier; its stored meshes have been read
371
+ const p = task.placing;
372
+ task.placing = null;
373
+ this.place(frag, p.result, p.owner, p.warnings, p.input);
374
+ task.resolve({ ok: true, diagnostics: frag.diagnostics, fragment: frag.id });
375
+ return 0;
376
+ }
377
+ const t0 = this.now();
378
+ let result, owner, warnings, lineIndex;
379
+ const input = task.input;
380
+ try {
381
+ if (input instanceof Prepared) {
382
+ ({ result, owner } = this.fromPrepared(input, 'fragment'));
383
+ warnings = result.ok ? input.hostOnly : [];
384
+ lineIndex = input.lineIndex;
385
+ } else if (input == null) {
386
+ result = { ok: false, diagnostics: [], error: task.error };
387
+ } else {
388
+ owner = this.newOwner('fragment');
389
+ const { time, progress } = this.clock();
390
+ result = compile(input, { cache: this.cache, owner, time, progress });
391
+ warnings = result.ok ? hostOnlyWarnings(result) : [];
392
+ }
393
+ } catch (err) {
394
+ result = { ok: false, diagnostics: [], error: err };
395
+ }
396
+ const ms = this.now() - t0;
397
+ if (!result.ok) {
398
+ if (owner) this.release(owner);
399
+ const msg = result.error ? String(result.error && result.error.message ? result.error.message : result.error) : formatDiagnostics({ diagnostics: result.diagnostics, lineIndex: lineIndex || (result.doc && result.doc.lineIndex) });
400
+ if (this.report) this.report(msg, { ...result, fragment: frag.id });
401
+ task.resolve({ ok: false, diagnostics: result.diagnostics, fragment: frag.id });
402
+ return ms;
403
+ }
404
+ // meshes made here may be stored from an earlier visit: read them before
405
+ // the first frame draws this fragment (a prepared one brought its meshes)
406
+ const wait = this.restore && !task.immediate && !result.prepared ? this.restore(this.cache, owner) : null;
407
+ if (wait) {
408
+ task.placing = { result, owner, warnings, input };
409
+ task.ready = false;
410
+ frag.tasks.push(task);
411
+ this.queue.push(task);
412
+ this.waiting++;
413
+ Promise.resolve(wait).catch(() => {}).then(() => {
414
+ this.waiting--;
415
+ task.ready = true;
416
+ });
417
+ return ms;
418
+ }
419
+ this.place(frag, result, owner, warnings, input);
420
+ task.resolve({ ok: true, diagnostics: frag.diagnostics, fragment: frag.id });
421
+ return ms;
422
+ }
423
+
424
+ place(frag, result, owner, warnings, source, quiet = false) {
425
+ const before = frag.owner;
426
+ this.set.place(frag.part, result.scene);
427
+ frag.result = result;
428
+ frag.owner = owner;
429
+ frag.source = source instanceof Prepared ? source.source : source;
430
+ frag.warnings = warnings || [];
431
+ const { time, progress } = this.clock();
432
+ frag.time = time;
433
+ frag.progress = progress;
434
+ if (before && before !== owner) this.release(before);
435
+ this.changed = true;
436
+ if (!quiet && frag.warnings.length && this.report) {
437
+ this.report(formatDiagnostics({ diagnostics: frag.warnings, lineIndex: result.doc && !result.prepared ? result.doc.lineIndex : null }), { ok: true, diagnostics: frag.warnings, fragment: frag.id });
438
+ }
439
+ }
440
+
441
+ /**
442
+ * A fragment's full compile result, with its scene graph. A prepared
443
+ * fragment has none yet: its document is compiled here now and drawn in
444
+ * its place (the same rows), once.
445
+ */
446
+ materialize(frag) {
447
+ const res = frag.result;
448
+ if (res.graph) return res;
449
+ const owner = this.newOwner('fragment');
450
+ const { time, progress } = this.clock();
451
+ const full = compile(frag.source, { cache: this.cache, owner, time, progress });
452
+ this.place(frag, full, owner, frag.warnings, frag.source, true);
453
+ return full;
454
+ }
455
+
456
+ removeFragment(frag) {
457
+ if (frag.removed) return;
458
+ frag.removed = true;
459
+ for (const t of frag.tasks) {
460
+ this.cancel(t);
461
+ t.resolve({ ok: false, diagnostics: [], fragment: frag.id, removed: true });
462
+ }
463
+ frag.tasks = [];
464
+ this.set.remove(frag.part);
465
+ this.release(frag.owner);
466
+ frag.owner = null;
467
+ this.byId.delete(frag.id);
468
+ this.changed = true;
469
+ }
470
+
471
+ /** Remove every fragment. */
472
+ clear() {
473
+ for (const f of this.list) this.removeFragment(f);
474
+ }
475
+
476
+ /**
477
+ * Compile a document for add() or show(), in the worker where there is one.
478
+ * @param {string|ArrayBuffer|Uint8Array} source
479
+ * @param {{as?: 'fragment'|'document'}} [opts]
480
+ * @returns {Promise<Prepared>}
481
+ */
482
+ prepare(source, opts = {}) {
483
+ const as = opts.as === 'document' ? 'document' : 'fragment';
484
+ const { time, progress } = this.clock();
485
+ const here = () => {
486
+ const owner = this.newOwner(as);
487
+ const result = compile(source, { cache: this.cache, owner, time, progress });
488
+ if (!result.ok) this.release(owner);
489
+ return new Prepared({ as, source, result, owner: result.ok ? owner : null, hostOnly: result.ok ? hostOnlyWarnings(result) : [] });
490
+ };
491
+ if (!this.compiler) return Promise.resolve(here());
492
+ return this.compiler.compile(source, { time, progress, quality: this.cache.quality }).then((payload) => (payload ? new Prepared({ as, source, payload }) : here()));
493
+ }
494
+
495
+ // --- per frame ----------------------------------------------------------------
496
+
497
+ /**
498
+ * Evaluate the animated rows of the drawn fragments at this clock (the
499
+ * stage animates the host as it always has). True when any row moved.
500
+ */
501
+ updateDynamic(time, progress) {
502
+ let any = false;
503
+ for (const f of this.byId.values()) {
504
+ const r = f.result;
505
+ if (!r || !f.part.drawn || !r.scene.dynamic.length || !r.graph) continue;
506
+ if (f.time === time && f.progress === progress) continue;
507
+ updateDynamic(r.scene, r.graph, time, progress);
508
+ f.time = time;
509
+ f.progress = progress;
510
+ any = true;
511
+ }
512
+ if (any) this.set.invalidateBounds();
513
+ return any;
514
+ }
515
+
516
+ /** Names (name or id) of a picked element and its ancestors, from its own document. */
517
+ names(part, uid) {
518
+ if (!part || part === this.host) return ancestorNames(this.hostResult && this.hostResult.doc, uid);
519
+ const f = this.byId.get(part.id);
520
+ return f && f.result ? ancestorNames(f.result.doc, uid) : [];
521
+ }
522
+
523
+ dispose() {
524
+ this.clear();
525
+ if (this.compiler && this.compiler.dispose) this.compiler.dispose();
526
+ }
527
+ }
@@ -84,10 +84,13 @@ function rayTriangles(o, d, mesh, best, facing = 0) {
84
84
  * @param {{isVisible?: (i:number)=>boolean, maxDistance?: number, skipFlags?: number, facing?: boolean}} [opts]
85
85
  * skipFlags: instances with any of these flag bits are ignored. facing: only
86
86
  * surfaces whose front side faces the origin (double-sided geometry always counts).
87
- * @returns {{index:number, uid:number, distance:number, point:number[], normal:number[]}|null}
88
- * normal is the world-space surface normal turned against the ray.
87
+ * @returns {{index:number, uid:number, distance:number, point:number[], normal:number[], part?: object}|null}
88
+ * normal is the world-space surface normal turned against the ray. A SceneSet
89
+ * (scene-set.js) is searched part by part: isVisible(i, partScene) gets the
90
+ * part's own scene, and the hit names the part it is in (index is that part's row).
89
91
  */
90
92
  export function raycast(scene, meshOf, origin, dir, opts = {}) {
93
+ if (scene && scene.parts) return raycastParts(scene, meshOf, origin, dir, opts);
91
94
  const { isVisible = null, maxDistance = Infinity, skipFlags = 0, facing = false } = opts;
92
95
  const cands = [];
93
96
  const S = scene.spheres;
@@ -147,6 +150,20 @@ export function raycast(scene, meshOf, origin, dir, opts = {}) {
147
150
  };
148
151
  }
149
152
 
153
+ /** Nearest hit over the drawn parts of a scene set. */
154
+ function raycastParts(set, meshOf, origin, dir, opts) {
155
+ let best = null;
156
+ for (const part of set.parts) {
157
+ if (!part.scene || !part.visible) continue;
158
+ const P = part.scene;
159
+ const isVisible = opts.isVisible ? (i) => opts.isVisible(i, P) : null;
160
+ const maxDistance = best ? best.distance : opts.maxDistance ?? Infinity;
161
+ const hit = raycast(P, meshOf, origin, dir, { ...opts, isVisible, maxDistance });
162
+ if (hit && (!best || hit.distance < best.distance)) best = { ...hit, part };
163
+ }
164
+ return best;
165
+ }
166
+
150
167
  /**
151
168
  * Element under a ray (for selection).
152
169
  * @param {import('./flatten.js').RenderScene} scene
@@ -159,3 +176,15 @@ export function raycast(scene, meshOf, origin, dir, opts = {}) {
159
176
  export function pick(scene, meshOf, origin, dir, isVisible = null) {
160
177
  return raycast(scene, meshOf, origin, dir, { isVisible });
161
178
  }
179
+
180
+ /** Names (name or id) on an element and its ancestors, nearest first. */
181
+ export function ancestorNames(doc, uid) {
182
+ const names = [];
183
+ let el = doc && doc.byUid.get(uid);
184
+ while (el) {
185
+ const n = el.values && (el.values.name || el.values.id);
186
+ if (n) names.push(n);
187
+ el = el.parent;
188
+ }
189
+ return names;
190
+ }