@displayxr/inline3d 1.44.0 → 1.45.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.
@@ -27,12 +27,17 @@ import {
27
27
  CursorDepthPlacer,
28
28
  CursorPointer,
29
29
  CURSOR_DEFAULT_TUNING,
30
- cursorArrowMesh,
31
30
  cursorArrowFootprint,
32
31
  cursorDefaultHeight,
33
32
  cursorPlacementSoften,
33
+ cursorArrowMesh,
34
34
  cursorSoftArrowBox,
35
35
  cursorSoftArrowGLSL,
36
+ CursorShapeSelector,
37
+ cursorShapeTexture,
38
+ cursorSpriteBox,
39
+ cursorSpriteGLSL,
40
+ cursorSpriteRect,
36
41
  cursorViewRay,
37
42
  resolveCursorSoften,
38
43
  CURSOR_FILL_COLOR,
@@ -50,9 +55,9 @@ export class PlayCanvasDepthCursor {
50
55
  /**
51
56
  * @param {object} pc the PlayCanvas namespace the adapter renders with.
52
57
  * @param {object} viewer the PlayCanvasSplatViewer (app, rigNode, eye are read lazily).
53
- * @param {{canvas: HTMLElement, height?: number, margin?: number, anchor?: string, pointerScope?: string, soften?: string|object}} opts
58
+ * @param {{canvas: HTMLElement, height?: number, margin?: number, anchor?: string, pointerScope?: string, soften?: string|object, shape?: string, shapeFor?: Function}} opts
54
59
  */
55
- constructor(pc, viewer, { canvas, height, margin, anchor = 'hybrid', pointerScope = 'canvas', soften }) {
60
+ constructor(pc, viewer, { canvas, height, margin, anchor = 'hybrid', pointerScope = 'canvas', soften, shape = 'auto', shapeFor = null }) {
56
61
  this.pc = pc;
57
62
  this.viewer = viewer;
58
63
  /** Sprite height (canvas heights) when set; null = the OS arrow's size, from the canvas's on-page height. */
@@ -70,12 +75,21 @@ export class PlayCanvasDepthCursor {
70
75
  this.hit = null;
71
76
  this._picker = null;
72
77
  this._pickCam = null;
78
+ this._picker2 = null; // the display-rig pass (handle.setLayerRig), made on first use
79
+ this._pickCam2 = null;
73
80
  this._inFlight = false;
74
81
  this._pickId = 0;
75
82
  this._pickStart = 0;
76
83
  this._frame = 0;
77
84
  this._disposed = false;
78
85
  this._sprite = null; // { mi, node, mat } — built on first use
86
+ /** Which sprite (CSS mirror / fixed / shapeFor); `.current` = the one drawn last. */
87
+ this.shapes = new CursorShapeSelector(this.pointer, { shape, shapeFor });
88
+ }
89
+
90
+ /** The sprite drawn last: a CURSOR_SHAPES name, 'none' or 'image'. */
91
+ get shape() {
92
+ return this.shapes.current;
79
93
  }
80
94
 
81
95
  /**
@@ -116,7 +130,11 @@ export class PlayCanvasDepthCursor {
116
130
  sprite.mi.visible = !!p.active;
117
131
  if (!p.active) return;
118
132
  const [pw, ph] = this.pointer.panelSize();
119
- this._useSoft(app, sprite, (this.softening = cursorPlacementSoften(p, views, pw, ph, h, this.soften)));
133
+ const s = (this.softening = cursorPlacementSoften(p, views, pw, ph, h, this.soften));
134
+ const chosen = this.shapes.pick({ u: uv[0], v: uv[1], point: this.hit }, t);
135
+ if (chosen.kind === 'none') sprite.mi.visible = false; // the page hides its cursor: so do we (the OS one stays hidden)
136
+ else if (chosen.kind === 'shape' && chosen.name === 'arrow') this._useSoft(app, sprite, s);
137
+ else this._useSprite(app, sprite, s, chosen);
120
138
  // The unit arrow in the sprite's XY plane, tip at its origin: basis (rotation), height (scale), position = the hotspot.
121
139
  const { x, y, z } = p.basis;
122
140
  const q = quatFromMatrix([x[0], x[1], x[2], 0, y[0], y[1], y[2], 0, z[0], z[1], z[2], 0, 0, 0, 0, 1]);
@@ -177,7 +195,7 @@ export class PlayCanvasDepthCursor {
177
195
  mi.pick = false;
178
196
  mi.drawOrder = 1e9;
179
197
  app.scene.layers.getLayerById(pc.LAYERID_UI).addMeshInstances([mi]);
180
- this._sprite = { mi, node, mat, mesh, soft: null };
198
+ this._sprite = { mi, node, mat, mesh, soft: null, tex: null, textures: new Map() };
181
199
  return this._sprite;
182
200
  }
183
201
 
@@ -245,26 +263,130 @@ export class PlayCanvasDepthCursor {
245
263
  return { mesh, mat };
246
264
  }
247
265
 
266
+ // Every sprite but the arrow: a texture (a keyword shape rasterised once, or a url() image) on
267
+ // one soft quad — the same 13-tap blur and opacity, crisp at radius 0. One program for all.
268
+ _useSprite(app, sprite, s, chosen) {
269
+ const mi = sprite.mi;
270
+ const q = sprite.tex || (sprite.tex = this._makeTex(app));
271
+ const radius = s.soft ? s.radius : 0;
272
+ q.mat.setParameter('inline3dCursorTex', this._texture(app, sprite, chosen));
273
+ q.mat.setParameter('inline3dCursorBox', cursorSpriteBox(chosen, radius, s.aa));
274
+ q.mat.setParameter('inline3dCursorRect', cursorSpriteRect(chosen));
275
+ q.mat.setParameter('inline3dCursorSoft', [radius, s.soft ? s.alpha : 1, 0, 0]);
276
+ if (mi.mesh !== q.mesh) mi.mesh = q.mesh;
277
+ if (mi.material !== q.mat) mi.material = q.mat;
278
+ }
279
+
280
+ _texture(app, sprite, chosen) {
281
+ let t = sprite.textures.get(chosen.key);
282
+ if (t) return t;
283
+ const pc = this.pc;
284
+ const opts = {
285
+ name: `inline3d-cursor-${chosen.kind}`,
286
+ format: pc.PIXELFORMAT_RGBA8,
287
+ mipmaps: false,
288
+ minFilter: pc.FILTER_LINEAR,
289
+ magFilter: pc.FILTER_LINEAR,
290
+ addressU: pc.ADDRESS_CLAMP_TO_EDGE,
291
+ addressV: pc.ADDRESS_CLAMP_TO_EDGE,
292
+ flipY: false, // row 0 = the sprite's top, as the shader reads it
293
+ };
294
+ if (chosen.kind === 'image') {
295
+ const img = chosen.entry.image;
296
+ t = new pc.Texture(app.graphicsDevice, { ...opts, width: img.naturalWidth || img.width, height: img.naturalHeight || img.height });
297
+ t.setSource(img);
298
+ } else {
299
+ const d = cursorShapeTexture(chosen.name);
300
+ t = new pc.Texture(app.graphicsDevice, { ...opts, width: d.width, height: d.height });
301
+ t.lock().set(d.data);
302
+ t.unlock();
303
+ }
304
+ if (sprite.textures.size > 32) {
305
+ for (const old of sprite.textures.values()) old.destroy?.();
306
+ sprite.textures.clear();
307
+ }
308
+ sprite.textures.set(chosen.key, t);
309
+ return t;
310
+ }
311
+
312
+ _makeTex(app) {
313
+ const pc = this.pc;
314
+ const mesh = new pc.Mesh(app.graphicsDevice);
315
+ mesh.setPositions(new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0]));
316
+ mesh.update();
317
+ const mat = new pc.ShaderMaterial({
318
+ uniqueName: 'inline3dDepthCursorSprite',
319
+ attributes: { vertex_position: pc.SEMANTIC_POSITION },
320
+ vertexGLSL: `
321
+ attribute vec3 vertex_position;
322
+ uniform mat4 matrix_model;
323
+ uniform mat4 matrix_viewProjection;
324
+ uniform vec4 inline3dCursorBox;
325
+ varying vec2 vSprite;
326
+ void main() {
327
+ vSprite = vec2(mix(inline3dCursorBox.x, inline3dCursorBox.z, vertex_position.x), mix(inline3dCursorBox.y, inline3dCursorBox.w, vertex_position.y));
328
+ gl_Position = matrix_viewProjection * matrix_model * vec4(vSprite.x, -vSprite.y, 0.0, 1.0);
329
+ }`,
330
+ fragmentGLSL: `
331
+ uniform vec4 inline3dCursorRect; // the area the texture covers (sprite units, y down)
332
+ uniform vec4 inline3dCursorSoft; // radius (sprite units), opacity
333
+ uniform sampler2D inline3dCursorTex;
334
+ varying vec2 vSprite;
335
+ ${cursorSpriteGLSL()}
336
+ void main() {
337
+ vec4 c = inline3dSoftSprite(inline3dCursorTex, vSprite, inline3dCursorRect, inline3dCursorSoft.x);
338
+ gl_FragColor = vec4(c.rgb, c.a * inline3dCursorSoft.y);
339
+ }`,
340
+ });
341
+ mat.blendState = new pc.BlendState(
342
+ true,
343
+ pc.BLENDEQUATION_ADD,
344
+ pc.BLENDMODE_SRC_ALPHA,
345
+ pc.BLENDMODE_ONE_MINUS_SRC_ALPHA,
346
+ pc.BLENDEQUATION_ADD,
347
+ pc.BLENDMODE_ONE,
348
+ pc.BLENDMODE_ONE_MINUS_SRC_ALPHA,
349
+ );
350
+ mat.depthTest = false;
351
+ mat.depthWrite = false;
352
+ mat.cull = pc.CULLFACE_NONE;
353
+ mat.update();
354
+ return { mesh, mat };
355
+ }
356
+
248
357
  dispose() {
249
358
  this._disposed = true;
250
359
  this.pointer.dispose();
360
+ this.shapes.dispose();
251
361
  try {
252
362
  if (this._sprite) {
253
363
  this.viewer.app?.scene?.layers?.getLayerById(this.pc.LAYERID_UI)?.removeMeshInstances?.([this._sprite.mi]);
254
364
  this._sprite.node.destroy?.();
255
365
  this._sprite.soft?.mat.destroy?.();
256
366
  this._sprite.soft?.mesh.destroy?.();
367
+ this._sprite.tex?.mat.destroy?.();
368
+ this._sprite.tex?.mesh.destroy?.();
369
+ for (const t of this._sprite.textures.values()) t.destroy?.();
257
370
  }
258
371
  this._picker?.destroy?.();
259
372
  this._pickCam?.destroy?.();
373
+ this._picker2?.destroy?.();
374
+ this._pickCam2?.destroy?.();
260
375
  } catch {
261
376
  // the app may already be gone with its device
262
377
  }
263
378
  this._picker = null;
264
379
  this._pickCam = null;
380
+ this._picker2 = null;
381
+ this._pickCam2 = null;
265
382
  }
266
383
 
267
- // Render the pick buffer from the first eye and read the footprint's world points.
384
+ // Render the pick buffer from the first eye and read the footprint's world points. Layers moved
385
+ // to the display rig (handle.setLayerRig) are drawn by the layer rig's own cameras, not the eye:
386
+ // with the rig ROUNDED (a camera rig, gain != 1) they take a second pass through the round
387
+ // views, and each of their hits becomes the world point the PHOTO views see at the same pixels
388
+ // in both eyes, so the placer (which solves against the photo views) puts the arrow where the
389
+ // stage appears. Otherwise they draw through the photo views and simply join the eye pass.
268
390
  _pick(app, rig, e, uv, height) {
269
391
  const pc = this.pc;
270
392
  const aspect = e.width > 0 && e.height > 0 ? e.width / e.height : 1;
@@ -272,59 +394,27 @@ export class PlayCanvasDepthCursor {
272
394
  const H = Math.max(1, Math.round(W / aspect));
273
395
  if (!this._picker) {
274
396
  this._picker = new pc.Picker(app, W, H, true);
275
- const cam = new pc.Entity('inline3d-cursor-pick', app);
276
- // frustumCulling off: this camera never renders on its own (disabled — the picker drives
277
- // it), so nothing keeps its culling frustum current, and a stale one culls everything.
278
- cam.addComponent('camera', { clearColor: new pc.Color(0, 0, 0, 0), frustumCulling: false });
279
- cam.camera.enabled = false;
280
- cam._dxrProj = new Float64Array(16);
281
- cam.camera.calculateProjection = (out) => out.set(cam._dxrProj);
282
- rig.addChild(cam);
283
- this._pickCam = cam;
397
+ this._pickCam = this._makePickCam(app, rig, 'inline3d-cursor-pick');
284
398
  }
285
399
  const cam = this._pickCam;
286
- cam._dxrProj.set(e.proj);
287
400
  // The picker linearises depth with the COMPONENT's near/far, and reconstructs through the
288
401
  // projection: keep the two in agreement (GL-style projection: m10 = -(f+n)/(f-n), m14 = -2fn/(f-n)).
289
402
  const m10 = e.proj[10], m14 = e.proj[14];
290
403
  const near = m14 / (m10 - 1);
291
404
  const far = m14 / (m10 + 1);
292
- if (near > 0 && far > near) {
293
- cam.camera.nearClip = near;
294
- cam.camera.farClip = far;
295
- }
296
- if (cam.camera.camera) cam.camera.camera._projMatDirty = true;
297
- const q = quatFromMatrix(e.pose);
298
- cam.setLocalPosition(e.pose[12], e.pose[13], e.pose[14]);
299
- cam.setLocalRotation(q[0], q[1], q[2], q[3]);
405
+ placePickCam(cam, e.proj, e.pose, near, far);
300
406
  const eye = this.viewer.eye?.camera;
301
- const skip = new Set([pc.LAYERID_UI, pc.LAYERID_IMMEDIATE, pc.LAYERID_SKYBOX]);
302
- const ids = (eye && Array.isArray(eye.layers) ? eye.layers : cam.camera.layers).filter((id) => !skip.has(id));
303
- const layers = ids.map((id) => app.scene.layers.getLayerById(id)).filter(Boolean);
407
+ const plan = cursorPickPlan(
408
+ eye && Array.isArray(eye.layers) ? eye.layers : cam.camera.layers,
409
+ this.viewer.layerRigs,
410
+ [pc.LAYERID_UI, pc.LAYERID_IMMEDIATE, pc.LAYERID_SKYBOX],
411
+ );
412
+ const layers = plan.eye.map((id) => app.scene.layers.getLayerById(id)).filter(Boolean);
413
+ setCamLayers(cam, plan.eye);
304
414
 
305
415
  const picker = this._picker;
306
416
  picker.resize(W, H);
307
- // The depth picker draws into TWO colour targets (pick id + pick depth). The engine's lit and
308
- // gsplat shaders write both under DEPTH_PICK_PASS; an app's own pc.ShaderMaterial usually
309
- // writes only the first, and WebGL rejects every such draw with GL_INVALID_OPERATION ("Active
310
- // draw buffers with missing fragment shader outputs") — one per mesh per pick, so a page with
311
- // a custom-shader backdrop logged hundreds a second. Those draws never reached the pick buffer
312
- // anyway: leave them out of the pass, exactly as if they were not pickable.
313
- const muted = muteDepthlessPicks(pc, layers, app.graphicsDevice);
314
- try {
315
- // Enabled only for the synchronous pick pass: a disabled camera is not in its layers'
316
- // camera lists, so the picker would render nothing; enabled outside it, the camera would
317
- // render a frame of its own on every app.tick.
318
- cam.camera.enabled = true;
319
- picker.prepare(cam.camera, app.scene, layers);
320
- } catch (err) {
321
- console.warn('[inline3d] cursor: depth: the PlayCanvas picker failed; cursor stays on the glass.', err);
322
- this.hit = null;
323
- return;
324
- } finally {
325
- cam.camera.enabled = false;
326
- for (const mi of muted) mi.pick = true;
327
- }
417
+ if (!this._prepare(picker, cam, app, layers)) return;
328
418
  // The picker stores LINEAR depth: (viewDist - near) / (far - near), viewDist = 1/gl_FragCoord.w.
329
419
  // Rebuild the point ourselves along the exact off-axis ray (getWorldPointAsync would unproject
330
420
  // with the component's symmetric projection, not this one). PlayCanvas's view matrix is built
@@ -334,33 +424,31 @@ export class PlayCanvasDepthCursor {
334
424
  const Mw = view.transformMatrix;
335
425
  const f = [-Mw[8], -Mw[9], -Mw[10]];
336
426
  const zNear = cam.camera.nearClip, zFar = cam.camera.farClip;
337
- const samples = cursorArrowFootprint(uv[0], uv[1], height, this.pointer.aspect())
338
- .filter(([u, v]) => u >= 0 && u <= 1 && v >= 0 && v <= 1)
339
- .map(([u, v]) =>
340
- picker.getPointDepthAsync(Math.min(W - 1, Math.floor(u * W)), Math.min(H - 1, Math.floor(v * H))).then((lin) => {
341
- if (lin === null || !(lin >= 0 && lin < 1)) return null;
342
- const z = zNear + lin * (zFar - zNear);
343
- const ray = cursorViewRay(view, u, v);
344
- return { x: ray.origin[0] + ray.direction[0] * z, y: ray.origin[1] + ray.direction[1] * z, z: ray.origin[2] + ray.direction[2] * z };
345
- }),
346
- );
427
+ const footprint = cursorArrowFootprint(uv[0], uv[1], height, this.pointer.aspect()).filter(([u, v]) => u >= 0 && u <= 1 && v >= 0 && v <= 1);
428
+ const read = (pk, u, v, toWorld) =>
429
+ pk.getPointDepthAsync(Math.min(W - 1, Math.floor(u * W)), Math.min(H - 1, Math.floor(v * H))).then((lin) => {
430
+ if (lin === null || !(lin >= 0 && lin < 1)) return null;
431
+ return toWorld(u, v, zNear + lin * (zFar - zNear));
432
+ });
433
+ const samples = footprint.map(([u, v]) =>
434
+ read(picker, u, v, (uu, vv, z) => {
435
+ const ray = cursorViewRay(view, uu, vv);
436
+ return { x: ray.origin[0] + ray.direction[0] * z, y: ray.origin[1] + ray.direction[1] * z, z: ray.origin[2] + ray.direction[2] * z };
437
+ }),
438
+ );
439
+ // The display run, rounded: a second pass, only while such a layer exists (zero cost otherwise).
440
+ if (plan.display.length) {
441
+ const lr = this.viewer.layerRigs;
442
+ const pass = this._pickDisplay(app, rig, e, lr, plan.display, near, far, W, H);
443
+ if (pass) for (const [u, v] of footprint) samples.push(read(pass.picker, u, v, pass.toWorld));
444
+ }
347
445
  this._inFlight = true;
348
446
  const id = ++this._pickId;
349
447
  this._pickStart = globalThis.performance ? globalThis.performance.now() : Date.now();
350
448
  Promise.all(samples)
351
449
  .then((pts) => {
352
450
  if (this._disposed || id !== this._pickId) return;
353
- let best = Infinity;
354
- let nearest = null;
355
- for (const p of pts) {
356
- if (!p) continue;
357
- const d = p.x * f[0] + p.y * f[1] + p.z * f[2];
358
- if (d < best) {
359
- best = d;
360
- nearest = [p.x, p.y, p.z];
361
- }
362
- }
363
- this.hit = nearest;
451
+ this.hit = nearestPickPoint(pts, f);
364
452
  })
365
453
  .catch(() => {
366
454
  if (id === this._pickId) this.hit = null;
@@ -369,6 +457,218 @@ export class PlayCanvasDepthCursor {
369
457
  if (id === this._pickId) this._inFlight = false;
370
458
  });
371
459
  }
460
+
461
+ // The pick camera: a child of the rig node, DISABLED except around prepare() (a disabled camera
462
+ // is in no layer's camera list, so the picker would render nothing; an enabled one would render
463
+ // a frame of its own on every app.tick), with a projection override.
464
+ _makePickCam(app, rig, name) {
465
+ const pc = this.pc;
466
+ const cam = new pc.Entity(name, app);
467
+ // frustumCulling off: this camera never renders on its own, so nothing keeps its culling
468
+ // frustum current, and a stale one culls everything.
469
+ cam.addComponent('camera', { clearColor: new pc.Color(0, 0, 0, 0), frustumCulling: false });
470
+ cam.camera.enabled = false;
471
+ cam._dxrProj = new Float64Array(16);
472
+ cam.camera.calculateProjection = (out) => out.set(cam._dxrProj);
473
+ rig.addChild(cam);
474
+ return cam;
475
+ }
476
+
477
+ // One synchronous pick pass. The depth picker draws into TWO colour targets (pick id + pick
478
+ // depth). The engine's lit and gsplat shaders write both under DEPTH_PICK_PASS; an app's own
479
+ // pc.ShaderMaterial usually writes only the first, and WebGL rejects every such draw with
480
+ // GL_INVALID_OPERATION ("Active draw buffers with missing fragment shader outputs") — one per
481
+ // mesh per pick, so a page with a custom-shader backdrop logged hundreds a second. Those draws
482
+ // never reached the pick buffer anyway: leave them out of the pass, exactly as if they were not
483
+ // pickable. (A ShaderMaterial opts in with CURSOR_DEPTH_PICK_GLSL / _WGSL.)
484
+ _prepare(picker, cam, app, layers) {
485
+ const muted = muteDepthlessPicks(this.pc, layers, app.graphicsDevice);
486
+ try {
487
+ cam.camera.enabled = true;
488
+ picker.prepare(cam.camera, app.scene, layers);
489
+ return true;
490
+ } catch (err) {
491
+ console.warn('[inline3d] cursor: depth: the PlayCanvas picker failed; cursor stays on the glass.', err);
492
+ this.hit = null;
493
+ return false;
494
+ } finally {
495
+ cam.camera.enabled = false;
496
+ for (const mi of muted) mi.pick = true;
497
+ }
498
+ }
499
+
500
+ // The display-rig pass: its own picker + camera, drawn with exactly what the layer rig's display
501
+ // camera draws, proj · view_round · rig⁻¹ (PlayCanvas composes RenderViews with the rig node's
502
+ // world transform, scale included). The camera's own view is undone in the projection override,
503
+ // as the layer rig's N-camera path does. Null (no pass) when the rig has no round views this frame.
504
+ _pickDisplay(app, rig, e, lr, ids, near, far, W, H) {
505
+ const round = lr?.lastRound;
506
+ const poses = lr?.lastEntries;
507
+ if (!round || !poses || round.length < 2 || poses.length !== round.length) return null;
508
+ const pc = this.pc;
509
+ if (!this._picker2) {
510
+ this._picker2 = new pc.Picker(app, W, H, true);
511
+ this._pickCam2 = this._makePickCam(app, rig, 'inline3d-cursor-pick-display');
512
+ }
513
+ const cam = this._pickCam2;
514
+ placePickCam(cam, e.proj, e.pose, near, far);
515
+ const R = rig.getWorldTransform().data;
516
+ const Rinv = invert4(R);
517
+ if (!Rinv) return null;
518
+ cam._dxrProj.set(mul4(e.proj, mul4(round[0].view, mul4(Rinv, unscaled(cam.getWorldTransform().data)))));
519
+ if (cam.camera.camera) cam.camera.camera._projMatDirty = true;
520
+ setCamLayers(cam, ids);
521
+ const layers = ids.map((id) => app.scene.layers.getLayerById(id)).filter(Boolean);
522
+ const picker = this._picker2;
523
+ picker.resize(W, H);
524
+ if (!this._prepare(picker, cam, app, layers)) return null;
525
+ const solve = displayRigPickSolver(e.proj, round, poses.map((p) => p.pose), R);
526
+ if (!solve) return null;
527
+ return { picker, toWorld: (u, v, dist) => solve(u, v, dist) };
528
+ }
529
+ }
530
+
531
+ /**
532
+ * Which layers each pick pass draws. `eyeLayers` the eye camera's list; `layerRigs` the viewer's
533
+ * LayerRigCameras (or null); `skip` the ids never picked (UI, immediate, skybox).
534
+ * eye — the eye's layers, plus the layer rig's post run and (when the rig is not rounded this
535
+ * frame) its display run: all drawn through the photo views.
536
+ * display — the display run, when it is rounded: picked through the round views.
537
+ * @returns {{ eye: number[], display: number[] }}
538
+ */
539
+ export function cursorPickPlan(eyeLayers, layerRigs, skip = []) {
540
+ const no = new Set(skip);
541
+ const eye = (eyeLayers || []).filter((id) => !no.has(id));
542
+ const moved = layerRigs && layerRigs.active !== false ? layerRigs.moved : null;
543
+ if (!moved || (!moved.display?.length && !moved.post?.length)) return { eye, display: [] };
544
+ const rounded = !!(layerRigs.lastRound && layerRigs.lastRound.length >= 2);
545
+ const add = (list, ids) => {
546
+ for (const id of ids || []) if (!no.has(id) && !list.includes(id)) list.push(id);
547
+ };
548
+ const display = [];
549
+ if (rounded) add(display, moved.display);
550
+ else add(eye, moved.display);
551
+ add(eye, moved.post);
552
+ return { eye, display: display.filter((id) => !eye.includes(id)) };
553
+ }
554
+
555
+ /**
556
+ * The picked point nearest the viewer: the smallest distance along the eye's forward `f` (world),
557
+ * i.e. the most crossed disparity. `pts` are {x, y, z} or [x, y, z] (null = nothing there).
558
+ * @returns {number[]|null} [x, y, z], or null when nothing was hit.
559
+ */
560
+ export function nearestPickPoint(pts, f) {
561
+ let best = Infinity;
562
+ let nearest = null;
563
+ for (const p of pts || []) {
564
+ if (!p) continue;
565
+ const x = Array.isArray(p) ? p[0] : p.x, y = Array.isArray(p) ? p[1] : p.y, z = Array.isArray(p) ? p[2] : p.z;
566
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) continue;
567
+ const d = x * f[0] + y * f[1] + z * f[2];
568
+ if (d < best) {
569
+ best = d;
570
+ nearest = [x, y, z];
571
+ }
572
+ }
573
+ return nearest;
574
+ }
575
+
576
+ /**
577
+ * Turn a display-rig pick into the WORLD point the PHOTO views show at the same place.
578
+ *
579
+ * The pick was drawn from round eye 0 (`rounds[i].view` maps rig space to round eye i); a sample at
580
+ * canvas (u, v) with view distance `dist` is the round-space point X. Each round eye i shows X at
581
+ * the pixel where the PHOTO eye i (pose `poses[i]`, rig space) sees P_i · view_round_i · X, so the
582
+ * point the photo views put at both eyes' pixels is where the photo rays of the first and last eye
583
+ * through those points meet. Taken ON eye 0's ray (the pixel that was sampled stays exact) at its
584
+ * closest approach to the last eye's (exact whenever the rays meet: eyes level with each other).
585
+ * Mapped to world by the rig node's transform `R`. With no rounding (round view = photo view) it
586
+ * is X itself.
587
+ * @returns {((u: number, v: number, dist: number) => number[]|null)|null}
588
+ */
589
+ export function displayRigPickSolver(proj, rounds, poses, R) {
590
+ const L = rounds.length - 1;
591
+ if (L < 1 || poses.length <= L) return null;
592
+ const T0 = mul4(poses[0], mul4(rounds[0].view, rounds[0].viewInv));
593
+ const TL = mul4(poses[L], mul4(rounds[L].view, rounds[0].viewInv));
594
+ const E0 = [poses[0][12], poses[0][13], poses[0][14]];
595
+ const EL = [poses[L][12], poses[L][13], poses[L][14]];
596
+ return (u, v, dist) => {
597
+ if (!(dist > 0)) return null;
598
+ // Round eye 0's view space: the direction through NDC (x, y) is ((x + c)/a, (y + d)/b, -1).
599
+ const x = 2 * u - 1, y = 1 - 2 * v;
600
+ const Xe = [((x + proj[8]) / proj[0]) * dist, ((y + proj[9]) / proj[5]) * dist, -dist];
601
+ const Q0 = apply(T0, Xe), QL = apply(TL, Xe);
602
+ const P = closestApproach(E0, sub3(Q0, E0), EL, sub3(QL, EL)) || Q0;
603
+ const w = apply(R, P);
604
+ return Number.isFinite(w[0]) && Number.isFinite(w[1]) && Number.isFinite(w[2]) ? w : null;
605
+ };
606
+ }
607
+
608
+ function sub3(a, b) {
609
+ return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
610
+ }
611
+
612
+ /** Column-major 4x4 × point (w = 1, affine). */
613
+ function apply(m, p) {
614
+ return [0, 1, 2].map((r) => m[r] * p[0] + m[4 + r] * p[1] + m[8 + r] * p[2] + m[12 + r]);
615
+ }
616
+
617
+ /** The point of p1 + s·d1 closest to the line p2 + r·d2; null when (near) parallel. */
618
+ function closestApproach(p1, d1, p2, d2) {
619
+ const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
620
+ const w0 = sub3(p1, p2);
621
+ const aa = dot(d1, d1), bb = dot(d1, d2), cc = dot(d2, d2), dd = dot(d1, w0), ee = dot(d2, w0);
622
+ const den = aa * cc - bb * bb;
623
+ if (!(den > 1e-12 * aa * cc)) return null;
624
+ const s = (bb * ee - cc * dd) / den;
625
+ return [0, 1, 2].map((i) => p1[i] + s * d1[i]);
626
+ }
627
+
628
+ /** Point a pick camera along a view: its projection override, near/far, and rig-space pose. */
629
+ function placePickCam(cam, proj, pose, near, far) {
630
+ cam._dxrProj.set(proj);
631
+ if (near > 0 && far > near) {
632
+ cam.camera.nearClip = near;
633
+ cam.camera.farClip = far;
634
+ }
635
+ if (cam.camera.camera) cam.camera.camera._projMatDirty = true;
636
+ const q = quatFromMatrix(pose);
637
+ cam.setLocalPosition(pose[12], pose[13], pose[14]);
638
+ cam.setLocalRotation(q[0], q[1], q[2], q[3]);
639
+ }
640
+
641
+ /** The picker draws only sublayers whose camera list holds the camera: give it these layers (on change only). */
642
+ function setCamLayers(cam, ids) {
643
+ const key = ids.join(',');
644
+ if (cam._dxrLayersKey === key) return;
645
+ cam._dxrLayersKey = key;
646
+ cam.camera.layers = ids.slice();
647
+ }
648
+
649
+ /** General column-major 4x4 inverse (cofactors); null when singular. */
650
+ function invert4(m) {
651
+ const a = m, o = new Array(16);
652
+ o[0] = a[5]*a[10]*a[15]-a[5]*a[11]*a[14]-a[9]*a[6]*a[15]+a[9]*a[7]*a[14]+a[13]*a[6]*a[11]-a[13]*a[7]*a[10];
653
+ o[4] = -a[4]*a[10]*a[15]+a[4]*a[11]*a[14]+a[8]*a[6]*a[15]-a[8]*a[7]*a[14]-a[12]*a[6]*a[11]+a[12]*a[7]*a[10];
654
+ o[8] = a[4]*a[9]*a[15]-a[4]*a[11]*a[13]-a[8]*a[5]*a[15]+a[8]*a[7]*a[13]+a[12]*a[5]*a[11]-a[12]*a[7]*a[9];
655
+ o[12] = -a[4]*a[9]*a[14]+a[4]*a[10]*a[13]+a[8]*a[5]*a[14]-a[8]*a[6]*a[13]-a[12]*a[5]*a[10]+a[12]*a[6]*a[9];
656
+ o[1] = -a[1]*a[10]*a[15]+a[1]*a[11]*a[14]+a[9]*a[2]*a[15]-a[9]*a[3]*a[14]-a[13]*a[2]*a[11]+a[13]*a[3]*a[10];
657
+ o[5] = a[0]*a[10]*a[15]-a[0]*a[11]*a[14]-a[8]*a[2]*a[15]+a[8]*a[3]*a[14]+a[12]*a[2]*a[11]-a[12]*a[3]*a[10];
658
+ o[9] = -a[0]*a[9]*a[15]+a[0]*a[11]*a[13]+a[8]*a[1]*a[15]-a[8]*a[3]*a[13]-a[12]*a[1]*a[11]+a[12]*a[3]*a[9];
659
+ o[13] = a[0]*a[9]*a[14]-a[0]*a[10]*a[13]-a[8]*a[1]*a[14]+a[8]*a[2]*a[13]+a[12]*a[1]*a[10]-a[12]*a[2]*a[9];
660
+ o[2] = a[1]*a[6]*a[15]-a[1]*a[7]*a[14]-a[5]*a[2]*a[15]+a[5]*a[3]*a[14]+a[13]*a[2]*a[7]-a[13]*a[3]*a[6];
661
+ o[6] = -a[0]*a[6]*a[15]+a[0]*a[7]*a[14]+a[4]*a[2]*a[15]-a[4]*a[3]*a[14]-a[12]*a[2]*a[7]+a[12]*a[3]*a[6];
662
+ o[10] = a[0]*a[5]*a[15]-a[0]*a[7]*a[13]-a[4]*a[1]*a[15]+a[4]*a[3]*a[13]+a[12]*a[1]*a[7]-a[12]*a[3]*a[5];
663
+ o[14] = -a[0]*a[5]*a[14]+a[0]*a[6]*a[13]+a[4]*a[1]*a[14]-a[4]*a[2]*a[13]-a[12]*a[1]*a[6]+a[12]*a[2]*a[5];
664
+ o[3] = -a[1]*a[6]*a[11]+a[1]*a[7]*a[10]+a[5]*a[2]*a[11]-a[5]*a[3]*a[10]-a[9]*a[2]*a[7]+a[9]*a[3]*a[6];
665
+ o[7] = a[0]*a[6]*a[11]-a[0]*a[7]*a[10]-a[4]*a[2]*a[11]+a[4]*a[3]*a[10]+a[8]*a[2]*a[7]-a[8]*a[3]*a[6];
666
+ o[11] = -a[0]*a[5]*a[11]+a[0]*a[7]*a[9]+a[4]*a[1]*a[11]-a[4]*a[3]*a[9]-a[8]*a[1]*a[7]+a[8]*a[3]*a[5];
667
+ o[15] = a[0]*a[5]*a[10]-a[0]*a[6]*a[9]-a[4]*a[1]*a[10]+a[4]*a[2]*a[9]+a[8]*a[1]*a[6]-a[8]*a[2]*a[5];
668
+ const det = a[0]*o[0]+a[1]*o[4]+a[2]*o[8]+a[3]*o[12];
669
+ if (!det || !Number.isFinite(det)) return null;
670
+ for (let i = 0; i < 16; i++) o[i] /= det;
671
+ return o;
372
672
  }
373
673
 
374
674
  /** A copy of a column-major transform with unit rotation columns (scale removed). */