@displayxr/inline3d 1.45.1 → 1.47.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.
@@ -38,7 +38,8 @@
38
38
  // └── eye camera, N RenderViews, TONEMAP_NEUTRAL, no sky layer
39
39
 
40
40
  import { PlayCanvasSplatViewer, pageRigTRS, pageViewAxis, pageViewRig } from './inline3d-splat-playcanvas.js';
41
- import { resolveControls, normalizeCameraPose, now, MAX_DT_S, declareViewRig } from './inline3d-splat-shared.js';
41
+ import { resolveControls, normalizeCameraPose, now, MAX_DT_S, declareViewRig, attachModelStereo, attachComfortDepth, resolveDepthOption, framedSubjectBounds } from './inline3d-splat-shared.js';
42
+ import { displayRig } from './inline3d-three.js';
42
43
  import {
43
44
  NEUTRAL_STUDIO,
44
45
  neutralStudioRadiance,
@@ -440,6 +441,7 @@ export function attachPlayCanvasModel(out, wall, canvas, src, opts, pending = []
440
441
  } = opts;
441
442
  const ctl = validatePlayCanvasModelOptions(opts);
442
443
  const pageMode = ctl.page;
444
+ const depthOption = resolveDepthOption(opts.depth, '@displayxr/inline3d/model');
443
445
  if (pageMode && ctl.ignored.length) {
444
446
  console.info(`${TAG} controls:'page' — the page owns the camera, so ${ctl.ignored.join(', ')} ${ctl.ignored.length === 1 ? 'is' : 'are'} ignored.`);
445
447
  }
@@ -468,6 +470,8 @@ export function attachPlayCanvasModel(out, wall, canvas, src, opts, pending = []
468
470
  sky: false,
469
471
  viewerEase: opts.viewerEase,
470
472
  cursor: opts.cursor,
473
+ // Opt-in; undefined = the viewer's own default (a transparent clear).
474
+ clearColor: opts.clearColor,
471
475
  // Each environment's own look: 'room' is three's (untonemapped), the rest the Sample Viewer's.
472
476
  toneMapping: envMap ? 'neutral' : ENVIRONMENT_TONE_MAPPING[environment] || 'neutral',
473
477
  });
@@ -510,6 +514,7 @@ export function attachPlayCanvasModel(out, wall, canvas, src, opts, pending = []
510
514
  },
511
515
  remove() {
512
516
  removed = true;
517
+ depth.dispose();
513
518
  handle?.remove();
514
519
  viewer.dispose(); // app.destroy(): the model, its textures and any page entities go with it
515
520
  },
@@ -530,6 +535,21 @@ export function attachPlayCanvasModel(out, wall, canvas, src, opts, pending = []
530
535
  Object.defineProperty(out, 'renderScale', { get: () => viewer.effectiveRenderScale, enumerable: true, configurable: true });
531
536
  Object.defineProperty(out, 'renderScaleRequested', { get: () => viewer.renderScale, enumerable: true, configurable: true });
532
537
 
538
+ // handle.setStereo / handle.rig (a depth slider; nothing is declared until it is called).
539
+ const stereo = attachModelStereo(out, { getTile: () => handle, vH: virtualDisplayHeight, makeDisplayRig: displayRig, page: pageMode });
540
+ // `depth: 'auto'` (opt-in): the comfort factor under setStereo's, on the same display rig.
541
+ // Without it, inert: `depthFactor` reads 1 and nothing is measured or declared.
542
+ const depth = attachComfortDepth(out, {
543
+ option: depthOption,
544
+ canvas,
545
+ getTile: () => handle,
546
+ getBounds: () => (out.frame ? framedSubjectBounds(viewer) : null),
547
+ getVH: () => virtualDisplayHeight,
548
+ apply: (k) => stereo.setBase(k),
549
+ tag: TAG,
550
+ });
551
+ if (pageMode) depth.ignore("controls:'page' (the page's camera rig)");
552
+
533
553
  if (wall && wall.supported) {
534
554
  handle = wall.addScene(canvas, viewer.onFrame, {
535
555
  ...(pageMode
@@ -574,7 +594,11 @@ export function attachPlayCanvasModel(out, wall, canvas, src, opts, pending = []
574
594
  const dm = (c[0] - ax.origin[0]) * ax.forward[0] + (c[1] - ax.origin[1]) * ax.forward[1] + (c[2] - ax.origin[2]) * ax.forward[2];
575
595
  if (dm > 0) d = dm / (ax.scale || 1);
576
596
  }
577
- out.viewRig = pageViewRig({ verticalFovDeg: viewer.page.fov, convergence: d ?? 2, comfortDepth: ctl.comfortDepth }, out.viewRig || {});
597
+ // setStereo's values, ABSOLUTE on this camera rig (1 and 1 until it is called: as before).
598
+ out.viewRig = pageViewRig(
599
+ { verticalFovDeg: viewer.page.fov, convergence: d ?? 2, comfortDepth: ctl.comfortDepth, ipdFactor: out.rig.ipdFactor, parallaxFactor: out.rig.parallaxFactor },
600
+ out.viewRig || {},
601
+ );
578
602
  declareViewRig(handle, out.viewRig);
579
603
  };
580
604
  if (typeof opts.onBeforeFrame === 'function') {
@@ -666,6 +690,7 @@ export function attachPlayCanvasModel(out, wall, canvas, src, opts, pending = []
666
690
  if (bounds) {
667
691
  out.frame = bounds;
668
692
  viewer.fitTo(bounds.center, bounds.extent);
693
+ depth.refresh();
669
694
  }
670
695
  return out;
671
696
  })();
@@ -38,9 +38,9 @@
38
38
  // your own origin (`/draco/`, `/basis/`) and `opts.decoderPath` moves that. See
39
39
  // docs/authoring-inline-3d.md#compressed-gltf.
40
40
 
41
- import { guardedAttach } from './inline3d-splat-shared.js';
41
+ import { guardedAttach, attachModelStereo, attachComfortDepth, resolveDepthOption, framedSubjectBounds } from './inline3d-splat-shared.js';
42
42
  import * as THREE from 'three';
43
- import { EyeCamera, EdgeFeather, DepthCursor, raycastHitTest } from './inline3d-three.js';
43
+ import { EyeCamera, EdgeFeather, DepthCursor, raycastHitTest, displayRig } from './inline3d-three.js';
44
44
  import { resolveCursorOption } from './inline3d-cursor-depth.js';
45
45
  import { CURSOR_MODEL_RAYS_PER_FRAME } from './inline3d-cursor-perf.js';
46
46
  import { SceneViewer } from './inline3d-viewer.js';
@@ -403,6 +403,8 @@ function addModelUnguarded(wall, canvas, src, opts = {}) {
403
403
 
404
404
  const paths = normalizeDecoderPath(decoderPath);
405
405
  const injected = { draco: injectedDraco, ktx2: injectedKtx2, meshopt: injectedMeshopt };
406
+ // Opt-in comfort depth; a bad value throws here, at the call.
407
+ const depthOption = resolveDepthOption(opts.depth, '@displayxr/inline3d/model');
406
408
 
407
409
  const viewer = new SceneViewer(THREE, canvas, {
408
410
  virtualDisplayHeight,
@@ -416,6 +418,8 @@ function addModelUnguarded(wall, canvas, src, opts = {}) {
416
418
  feather,
417
419
  logTag: '[inline3d/model]',
418
420
  viewerEase: opts.viewerEase,
421
+ // Opt-in; undefined = the viewer's own default (a transparent clear).
422
+ clearColor: opts.clearColor,
419
423
  }).useEyeCamera(EyeCamera, EdgeFeather);
420
424
  const cursorOpts = resolveCursorOption(opts.cursor, '@displayxr/inline3d/model');
421
425
  // Amortised pick (a few rays a frame): the sprite follows the newest pointer position every
@@ -436,6 +440,7 @@ function addModelUnguarded(wall, canvas, src, opts = {}) {
436
440
  setPose: (p) => viewer.setPose(p),
437
441
  resetPose: () => viewer.resetPose(),
438
442
  remove() {
443
+ depth.dispose();
439
444
  handle?.remove();
440
445
  viewer.dispose();
441
446
  if (out.model) disposeTree(out.model);
@@ -450,6 +455,20 @@ function addModelUnguarded(wall, canvas, src, opts = {}) {
450
455
  Object.defineProperty(out, 'renderScale', { get: () => viewer.effectiveRenderScale, enumerable: true, configurable: true });
451
456
  Object.defineProperty(out, 'renderScaleRequested', { get: () => viewer.renderScale, enumerable: true, configurable: true });
452
457
 
458
+ // handle.setStereo / handle.rig (a depth slider; nothing is declared until it is called).
459
+ const stereo = attachModelStereo(out, { getTile: () => handle, vH: virtualDisplayHeight, makeDisplayRig: displayRig });
460
+ // `depth: 'auto'` (opt-in): the comfort factor under setStereo's, on the same display rig.
461
+ // Without it, inert: `depthFactor` reads 1 and nothing is measured or declared.
462
+ const depth = attachComfortDepth(out, {
463
+ option: depthOption,
464
+ canvas,
465
+ getTile: () => handle,
466
+ getBounds: () => (out.frame ? framedSubjectBounds(viewer) : null),
467
+ getVH: () => virtualDisplayHeight,
468
+ apply: (k) => stereo.setBase(k),
469
+ tag: '[inline3d/model]',
470
+ });
471
+
453
472
  // Window first, content when it lands — same reasoning as ./splat: a grid should not appear
454
473
  // one tile at a time in download order.
455
474
  let handle = null;
@@ -528,6 +547,7 @@ function addModelUnguarded(wall, canvas, src, opts = {}) {
528
547
  if (bounds) {
529
548
  out.frame = bounds;
530
549
  viewer.fitTo(bounds.center, bounds.extent);
550
+ depth.refresh();
531
551
  }
532
552
  return out;
533
553
  })().catch((err) => {
@@ -53,6 +53,8 @@ export function addSplatDeferred(wall, canvas, src, opts) {
53
53
  frame: null,
54
54
  camera: null,
55
55
  rig: null,
56
+ // The `depth` option's comfort factor in force (1 until measured, and always without it).
57
+ depthFactor: 1,
56
58
  perf: null,
57
59
  setPose: page ? pageOnly('setPose') : queue('setPose'),
58
60
  resetPose: page ? pageOnly('resetPose') : queue('resetPose'),
@@ -16,7 +16,7 @@
16
16
  // Not here: measureSplatBounds (it measures a Spark mesh — import it from ./splat).
17
17
 
18
18
  import { resolveRevealOption } from './inline3d-splat-effects.js';
19
- import { CAPTURE_FITS, guardedAttach, playcanvasCannotRead, resolveControls } from './inline3d-splat-shared.js';
19
+ import { CAPTURE_FITS, guardedAttach, playcanvasCannotRead, resolveControls, resolveDepthOption } from './inline3d-splat-shared.js';
20
20
  import { addSplatDeferred } from './inline3d-splat-deferred.js';
21
21
 
22
22
  export { applySplatPerf, SPLAT_PERF_PRESETS } from './inline3d-splat-perf.js';
@@ -44,6 +44,7 @@ function addSplatUnguarded(wall, canvas, src, opts = {}) {
44
44
  );
45
45
  }
46
46
  resolveControls(opts);
47
+ resolveDepthOption(opts.depth);
47
48
  resolveRevealOption(opts.reveal);
48
49
  const why = playcanvasCannotRead(src, opts);
49
50
  if (why) throw new Error(`@displayxr/inline3d/splat: ${why}`);
@@ -119,6 +119,12 @@ import {
119
119
  normalizeCameraPose,
120
120
  coverageExponent,
121
121
  declareViewRig,
122
+ parseClearColor,
123
+ validateSetStereo,
124
+ stereoScalarOf,
125
+ attachComfortDepth,
126
+ resolveDepthOption,
127
+ framedSubjectBounds,
122
128
  } from './inline3d-splat-shared.js';
123
129
  import { resolveCursorOption } from './inline3d-cursor-option.js';
124
130
 
@@ -827,6 +833,10 @@ export class PlayCanvasSplatViewer {
827
833
  cursor = null,
828
834
  } = opts;
829
835
  this.canvas = canvas;
836
+ // `clearColor` (opt-in): the eye cameras' clear, [r,g,b,a] in 0..1, or null = the transparent
837
+ // clear every tile had before it existed. An OPAQUE clear avoids stale / black pixels where the
838
+ // weave reads an alpha-0 hole (web#198).
839
+ this.clearColor = parseClearColor(opts.clearColor, opts.logTag || '[inline3d/splat]');
830
840
  // `cursor: 'depth'` (ADR-046): the depth-aware cursor. OPT-IN AND FREE OTHERWISE — its module
831
841
  // is imported only when asked for (attachEngine), and every per-frame hook is an optional call
832
842
  // on `cursorDepth`, which stays null without it.
@@ -1306,6 +1316,8 @@ export class PlayCanvasSplatViewer {
1306
1316
  onFrame(views, layer, frame) {
1307
1317
  if (this._disposed) return;
1308
1318
  if (this._mode !== '3d') this.stopMono();
1319
+ // cursor: 'depth' hands its depth to this layer where the runtime draws the web cursor (ADR-046 Amendment 4).
1320
+ this._frameLayer = layer || null;
1309
1321
  // The rig these views were located with: Blink chained the rig declared BEFORE this callback,
1310
1322
  // and the tick below may declare a new one (a focus ease) for the NEXT locate.
1311
1323
  if (this.layerRigs?.active) {
@@ -1348,6 +1360,7 @@ export class PlayCanvasSplatViewer {
1348
1360
  this._monoRaf = requestAnimationFrame(loop);
1349
1361
  this._beforeFrame?.(null);
1350
1362
  if (this._disposed || this._mode !== 'mono') return; // the callback removed or re-wove us
1363
+ this._frameLayer = null;
1351
1364
  this.cursorDepth?.inactive(); // 2D: the normal cursor
1352
1365
  this._tick();
1353
1366
  this._drawMono();
@@ -1981,7 +1994,7 @@ export class PlayCanvasSplatViewer {
1981
1994
  const pc = this.pc;
1982
1995
  const e = new pc.Entity(name, this.app);
1983
1996
  e.addComponent('camera', {
1984
- clearColor: new pc.Color(0, 0, 0, 0),
1997
+ clearColor: this.clearColor ? new pc.Color(this.clearColor[0], this.clearColor[1], this.clearColor[2], this.clearColor[3]) : new pc.Color(0, 0, 0, 0),
1985
1998
  nearClip: MONO_NEAR,
1986
1999
  farClip: MONO_FAR,
1987
2000
  fov: MONO_FOV,
@@ -2221,7 +2234,7 @@ export class PlayCanvasSplatViewer {
2221
2234
  // handle.setVideo's plane: size, eye split, and a new frame's upload (./inline3d-splat-video.js).
2222
2235
  this._videoPlane?.beforeDraw(entries, rect);
2223
2236
  // cursor: 'depth' — place the sprite and queue its lines for the tick below (no-op unless opted in).
2224
- this.cursorDepth?.frame(entries);
2237
+ this.cursorDepth?.frame(entries, this._frameLayer);
2225
2238
  app.tick(now());
2226
2239
  this._afterTick();
2227
2240
  return true;
@@ -3122,41 +3135,9 @@ export function validateWarmEffects(list) {
3122
3135
  });
3123
3136
  }
3124
3137
 
3125
- /**
3126
- * handle.setStereo's argument, validated and merged onto the current override (throws at the
3127
- * call). Returns the new override, or null when nothing is overridden.
3128
- */
3129
- export function validateSetStereo(o, prev) {
3130
- if (o === null) return null;
3131
- if (typeof o !== 'object') throw new TypeError('@displayxr/inline3d/splat: setStereo takes an object or null.');
3132
- // Absolute (…Factor) or RELATIVE to the asset's own (…Scale); per scalar, the last one given wins.
3133
- const pairs = [
3134
- ['ipdFactor', 'ipdScale'],
3135
- ['parallaxFactor', 'parallaxScale'],
3136
- ];
3137
- const keys = pairs.flat();
3138
- const unknown = Object.keys(o).filter((k) => !keys.includes(k));
3139
- if (unknown.length) throw new Error(`@displayxr/inline3d/splat: setStereo — unknown option(s) ${unknown.join(', ')}.`);
3140
- for (const [a, b] of pairs) {
3141
- if (o[a] != null && o[b] != null) throw new Error(`@displayxr/inline3d/splat: setStereo — ${a} and ${b} are exclusive.`);
3142
- }
3143
- const next = { ...(prev || {}) };
3144
- for (const [a, b] of pairs) {
3145
- for (const k of [a, b]) {
3146
- if (!(k in o) || o[k] === undefined) continue;
3147
- if (o[k] === null) {
3148
- delete next[k];
3149
- continue;
3150
- }
3151
- if (typeof o[k] !== 'number' || !Number.isFinite(o[k]) || o[k] < 0) {
3152
- throw new RangeError(`@displayxr/inline3d/splat: setStereo — ${k} must be a finite number >= 0 (got ${o[k]}).`);
3153
- }
3154
- next[k] = o[k];
3155
- delete next[k === a ? b : a];
3156
- }
3157
- }
3158
- return Object.keys(next).length ? next : null;
3159
- }
3138
+ // validateSetStereo lives in ./inline3d-splat-shared.js now (./model's setStereo shares it); it is
3139
+ // re-exported from here so every name this module exported before still resolves.
3140
+ export { validateSetStereo };
3160
3141
 
3161
3142
  /**
3162
3143
  * handle.setRig's arguments, validated and resolved (throws at the call, before anything runs).
@@ -3400,6 +3381,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3400
3381
  // an advanced caller may reach this function directly.
3401
3382
  const ctl = resolveControls(opts);
3402
3383
  const pageMode = ctl.page;
3384
+ const depthOption = resolveDepthOption(opts.depth);
3403
3385
  if (pageMode && ctl.ignored.length) {
3404
3386
  console.info(
3405
3387
  `[inline3d/splat] controls:'page' — the page owns the camera, so ${ctl.ignored.join(', ')} ` +
@@ -3431,10 +3413,27 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3431
3413
  sky: opts.sky,
3432
3414
  viewerEase: opts.viewerEase,
3433
3415
  cursor: opts.cursor,
3416
+ // Opt-in; undefined = the viewer's own default (a transparent clear).
3417
+ clearColor: opts.clearColor,
3434
3418
  });
3435
3419
 
3436
3420
  let handle = null;
3437
3421
  let unbindFocusInput = null;
3422
+ // `depth: 'auto'` (opt-in): the comfort factor, multiplied under setStereo's on the DISPLAY rig
3423
+ // declareDisplay sends (camera rigs and setVideo's own rig never carry it). Without the option:
3424
+ // inert, `depthFactor` 1, nothing measured, nothing declared.
3425
+ const depth = attachComfortDepth(out, {
3426
+ option: depthOption,
3427
+ canvas,
3428
+ getTile: () => handle,
3429
+ getBounds: () => (out.rig && out.rig.type !== 'camera' && !vid?.on ? framedSubjectBounds(viewer) : null),
3430
+ getVH: () => viewer.vH,
3431
+ apply: () => {
3432
+ if (!pageMode && !vid?.on && out.rig?.type !== 'camera' && lastDisplayBase) declareDisplay(lastDisplayBase);
3433
+ },
3434
+ tag: '[inline3d/splat]',
3435
+ });
3436
+ if (pageMode) depth.ignore("controls:'page' (the page's camera rig)");
3438
3437
 
3439
3438
  // ── diagnostics (diag / ?dxrdiag — ./inline3d-splat-diag.js) ──
3440
3439
  const diagCfg = resolveDiag(opts.diag);
@@ -3702,6 +3701,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
3702
3701
  effects: () => (fx ? fx.list() : []),
3703
3702
  remove() {
3704
3703
  removed = true;
3704
+ depth.dispose();
3705
3705
  dropVideo(); // before the app goes: the plane's rVFC, and a <video> this handle made
3706
3706
  current = null;
3707
3707
  for (const p of [...livePrepared]) p.dispose();
@@ -4366,9 +4366,11 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
4366
4366
  // raw: setVideo's own display rig — not the page's rig, so neither setStereo's factors nor
4367
4367
  // the base setStereo re-declares from.
4368
4368
  if (!raw) lastDisplayBase = base;
4369
+ // The `depth` option's comfort factor (1 without it), × setStereo's: never on setVideo's rig.
4370
+ const kd = raw ? 1 : depth.factor;
4369
4371
  const f =
4370
- stereoOverride && !raw
4371
- ? { ...base, ipdFactor: stereoScalar(base.ipdFactor ?? 1, 'ipd'), parallaxFactor: stereoScalar(base.parallaxFactor ?? 1, 'parallax') }
4372
+ (stereoOverride || kd !== 1) && !raw
4373
+ ? { ...base, ipdFactor: stereoScalar(base.ipdFactor ?? 1, 'ipd') * kd, parallaxFactor: stereoScalar(base.parallaxFactor ?? 1, 'parallax') * kd }
4372
4374
  : base;
4373
4375
  const key = `${f.vH}|${f.ipdFactor ?? 1}|${f.parallaxFactor ?? 1}|${f.perspectiveFactor ?? 1}`;
4374
4376
  if (declaredDisplay === key) return;
@@ -4404,6 +4406,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
4404
4406
  viewer.setFocus(modelToContent(resolved.focus), { snap: true, recentre: false });
4405
4407
  lastConvergence = Number.NaN;
4406
4408
  pushViewRig(true);
4409
+ depth.ignore('a camera rig');
4407
4410
  return;
4408
4411
  }
4409
4412
  if (viewer.mono.capture) viewer.useDisplayCamera();
@@ -4422,6 +4425,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
4422
4425
  resolved.focusDefaultSource = 'frame';
4423
4426
  resolved.frame = target;
4424
4427
  declareDisplay(disp);
4428
+ depth.resume();
4425
4429
  return;
4426
4430
  }
4427
4431
  // The splat itself, as a display-rig addSplat frames it (the load-time path).
@@ -4434,6 +4438,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
4434
4438
  }
4435
4439
  if (disp) resolved.frame = bounds ? { center: bounds.center.slice(), extent: bounds.extent.slice(), source: 'splat' } : null;
4436
4440
  declareDisplay(disp || { vH: bootFraming.vH });
4441
+ if (bounds) depth.resume();
4437
4442
  }
4438
4443
 
4439
4444
  // ── handle.setStereo: live stereo strength on whatever rig is declared ──
@@ -4449,10 +4454,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
4449
4454
  }
4450
4455
  /** One scalar under setStereo: its absolute value, else `base` × its scale, else `base`. */
4451
4456
  function stereoScalar(base, k) {
4452
- const o = stereoOverride;
4453
- if (o?.[`${k}Factor`] != null) return o[`${k}Factor`];
4454
- if (o?.[`${k}Scale`] != null) return base * o[`${k}Scale`];
4455
- return base;
4457
+ return stereoScalarOf(stereoOverride, base, k);
4456
4458
  }
4457
4459
 
4458
4460
  /**
@@ -4940,6 +4942,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
4940
4942
  lastConvergence = s.lastConvergence;
4941
4943
  state.on = false;
4942
4944
  if (stereoOverride !== state.stereoAtEnter) redeclareStereo(); // a setStereo made while the video held the rig
4945
+ depth.refresh(); // the `depth` option holds still under a video; measure again (no-op without it)
4943
4946
 
4944
4947
  }
4945
4948