@displayxr/inline3d 1.36.0 → 1.37.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/CHANGELOG.md CHANGED
@@ -5,6 +5,38 @@ entry points (`.`, `./three`) are frozen for 1.x, while the **scene subpaths** (
5
5
  `./splat`, `./model`) are a preview tier whose options may change in any release. Entries below say
6
6
  which tier they touch, because that is what tells you whether an upgrade can move your pixels.
7
7
 
8
+ ## 1.37.0 — 2026-10-07
9
+
10
+ Driven by a tester's run on a head-tracked panel (the depth cursor shipped in 1.36.0). Additive
11
+ API, plus two deliberate default changes to the cursor, which is opt-in, so nothing changes for a
12
+ page that does not use it.
13
+
14
+ ### Depth cursor: parallax, margin, overlays, visibility
15
+
16
+ - **The cursor now parallaxes like the content (new default `anchor: 'hybrid'`).** 1.36.0 kept
17
+ the sprite on the line of sight through the pointer. Its image on the glass then never moved
18
+ with the head: disparity without motion parallax. On a head-tracked display that reads as "at
19
+ the glass", behind the content it should sit on.
20
+ - **Hybrid:** follows the pointer exactly while it moves, and stays world-fixed while the
21
+ pointer is still, so it parallaxes with the content as you look around. It is continuous at
22
+ the moment the pointer stops.
23
+ - **`anchor: 'screen'`:** the 1.36 behaviour.
24
+ - **`anchor: 'world'`:** always straight in front of the pointer's canvas point.
25
+ - **Default margin 0.03 → 0.005** (eye-baseline units, about 1.5 mm at 60 cm). The old default
26
+ floated 1–2 cm off shallow content; the cursor now rests on what it hovers. There's also a
27
+ `margin` option.
28
+ - **`pointerScope: 'window'`:** the cursor stays active over DOM layered on the canvas (overlay
29
+ buttons), and the CSS cursor is hidden page-wide while it shows.
30
+ - **Filled, outlined crosshair** instead of 1 px lines, which read too thin through the lens.
31
+ Both engines draw it the same way.
32
+ - **`./model` / `./splat`:** `cursor` also takes an object, `{ margin, anchor, height,
33
+ pointerScope }`, validated at the call.
34
+ - **`./cursor-depth`:** `CURSOR_ANCHOR_MODES`, `CursorOptions`, `resolveCursorOption`,
35
+ `cursorCrosshairMesh`, `cursorModelMatrix`; `CursorDepthPlacer` takes `{ anchor }`.
36
+ - **Docs:** `hitTest` must be cheap and cover everything that sticks out.
37
+ - **e2e:** `test:e2e:cursor` adds a head-move check on the real viewers. Hybrid and world stay
38
+ world-fixed and screen slides; this holds on both engines, for models and splats.
39
+
8
40
  ## 1.36.0 — 2026-10-07
9
41
 
10
42
  Additive everywhere it touches: the **core** `./three` (new helpers, minor per
package/cursor-depth.d.ts CHANGED
@@ -18,7 +18,7 @@ export interface CursorTuning {
18
18
  stale: number;
19
19
  }
20
20
 
21
- /** The runtime's defaults: margin 0.03, clamp ±0.6, rise 30 ms, sink 250 ms, stale 0.5 s. */
21
+ /** The runtime's defaults: margin 0.005 (~1.5 mm at 60 cm), clamp ±0.6, rise 30 ms, sink 250 ms, stale 0.5 s. */
22
22
  export const CURSOR_DEFAULT_TUNING: Readonly<CursorTuning>;
23
23
  /** Default sprite height, as a fraction of the canvas height (0.03). */
24
24
  export const CURSOR_DEFAULT_HEIGHT: number;
@@ -47,6 +47,41 @@ export function cursorFilterStep(filter: object, tuning: CursorTuning, target: n
47
47
  /** Where to draw a sprite at `disparity`, and how tall. */
48
48
  export function placeCursor(g: CursorGeometry, disparity: number, heightFraction: number): { position: number[]; height: number };
49
49
 
50
+ /**
51
+ * Where along the cursor's depth the sprite goes.
52
+ * - `'hybrid'` (default): on the line of sight while the pointer MOVES (exactly over it), and
53
+ * world-FIXED while it is still, so it parallaxes with the content as the head moves.
54
+ * - `'screen'`: always on the line of sight. Never parallaxes; on a head-tracked display it can
55
+ * read as "at the glass".
56
+ * - `'world'`: always straight in front of the pointer's canvas point. Parallaxes; drifts ~mm off
57
+ * the pointer from an off-axis viewer.
58
+ */
59
+ export type CursorAnchorMode = 'hybrid' | 'screen' | 'world';
60
+ export const CURSOR_ANCHOR_MODES: readonly CursorAnchorMode[];
61
+
62
+ /** `cursor: { … }` on ./model and ./splat (an object also means `'depth'`). */
63
+ export interface CursorOptions {
64
+ /** Sprite height as a fraction of the canvas height (default 0.03). */
65
+ height?: number;
66
+ /** How far in front of the content it floats, in eye-baseline units (default 0.005). */
67
+ margin?: number;
68
+ /** Default `'hybrid'`. */
69
+ anchor?: CursorAnchorMode;
70
+ /** `'window'`: keep the cursor over DOM layered on the canvas, and hide the CSS cursor page-wide meanwhile. Default `'canvas'`. */
71
+ pointerScope?: 'canvas' | 'window';
72
+ }
73
+ export const CURSOR_OPTION_KEYS: readonly (keyof CursorOptions)[];
74
+ /** `cursor` as ./model and ./splat accept it: null = off; throws on a bad value. */
75
+ export function resolveCursorOption(opt: unknown, who?: string): CursorOptions | null;
76
+
77
+ /** The crosshair as filled, outlined strokes in its own XY plane (unit = sprite height), non-indexed triangles. */
78
+ export function cursorCrosshairMesh(
79
+ fill?: readonly number[],
80
+ outline?: readonly number[],
81
+ ): { positions: Float32Array; colors: Float32Array; count: number };
82
+ /** Column-major model matrix placing the unit crosshair for a placement. */
83
+ export function cursorModelMatrix(placement: CursorPlacement, out?: number[]): number[];
84
+
50
85
  /** One frame's placement. `position`/`basis` are in the views' space; null when inactive. */
51
86
  export interface CursorPlacement {
52
87
  active: boolean;
@@ -55,11 +90,13 @@ export interface CursorPlacement {
55
90
  height: number;
56
91
  disparity: number;
57
92
  targetDisparity: number;
93
+ /** Hybrid: true while the pointer is still and the sprite is held world-fixed. */
94
+ anchored?: boolean;
58
95
  }
59
96
 
60
97
  /** The whole pipeline with its filter state: one per cursor. Inactive with fewer than two views. */
61
98
  export class CursorDepthPlacer {
62
- constructor(tuning?: CursorTuning);
99
+ constructor(tuning?: CursorTuning, opts?: { anchor?: CursorAnchorMode });
63
100
  update(
64
101
  views: ArrayLike<CursorView | XRView>,
65
102
  hint: {
package/dist/call.js CHANGED
@@ -1,4 +1,4 @@
1
- /* @displayxr/inline3d 1.36.0 — dist/call.js — Apache-2.0 — https://github.com/DisplayXR/displayxr-web */
1
+ /* @displayxr/inline3d 1.37.0 — dist/call.js — Apache-2.0 — https://github.com/DisplayXR/displayxr-web */
2
2
  var _e=Object.freeze({speaker:"high",other:"normal",hidden:"paused"}),Cn=["high","normal","low","paused"],Rn=.02,Dn=1e3/20,Ln=3e3,An=1e3/28;function ye(n){return typeof n=="function"?n:n===!1||n==="off"?"off":"auto"}function it(n=import.meta.url){return ge?typeof ge=="function"?ge():ge:new URL("../lift/index.js",n).href}var ge=null;function ii(n){ge=n||null}function On(){return import(it())}async function nt(n,{importer:e=On,nav:t=globalThis.navigator,log:i=null,capsOpts:r={webFallback:!0}}={}){let s=(h,d)=>i&&i(h,d),o=(h,d={})=>(s("lift-unavailable",{reason:h,...d}),{lift:null,capabilities:null,caps:null,source:"none",reason:h}),a=ye(n);if(a==="off")return{lift:null,capabilities:null,caps:null,source:"none",reason:"off"};if(typeof a=="function"){let h=typeof a.liftCapabilities=="function"?a.liftCapabilities:null;return{lift:a,capabilities:h,caps:null,source:"injected",reason:null}}let l;try{l=await e()}catch(h){return o("import-failed",{message:String(h&&h.message||h).slice(0,200)})}let c=l&&typeof l.lift=="function"?l.lift:null;if(!c)return o("no-lift-export");let p=typeof l.liftCapabilities=="function"?l.liftCapabilities:null,f=null;if(p){try{f=await p(r)}catch{f=null}let h=!!(f&&f.webFallback&&f.webFallback.webgpu);if(f&&!f.native&&!h)return o("no-provider",{native:!1,webgpu:!1})}else if(!(t&&t.gpu))return o("no-webgpu");return s("lift-ready",{native:!!(f&&f.native),provider:f&&f.provider||null}),{lift:c,capabilities:p,caps:f,source:"module",reason:null}}function ve(n){let e=Number.isFinite(+n)&&n!==null?Math.max(-1,Math.min(1,+n)):0;return Math.abs(e)<Rn?"auto":Math.round((.5+.5*e)*1e3)/1e3}function Le({id:n,speakerId:e,visible:t=!0}){return t?n!=null&&n===e?_e.speaker:_e.other:_e.hidden}function rt(n,e){if(!n||!Cn.includes(e)||typeof n.setPriority!="function")return!1;try{return n.setPriority(e)!==!1}catch{return!1}}function st({degradedMs:n=Dn,recoveredMs:e=An,holdMs:t=Ln,alpha:i=.1}={}){let r=null,s=0,o=!1;return{feed(a){if(!(a>0)||a>1e3)return null;if(r=r===null?a:r+i*(a-r),o){if(r<e)return o=!1,s=0,"recovered"}else if(s=r>n?s+a:0,s>=t)return o=!0,"degraded";return null},reset(){r=null,s=0,o=!1},get frameMs(){return r},get degraded(){return o}}}function ot({lift:n,max:e=4,log:t=null,options:i=null}={}){let r=i&&typeof i=="object"?i:{},s=new Map,o=null,a=0,l=0,c=(h,d)=>t&&t(h,d),p=(h,d)=>{if(!d.handle)return;let m=Le({id:h,speakerId:o,visible:d.visible});if(m===d.priority)return;d.priority=m;let u=rt(d.handle,m);c("lift-priority",{peer:h,priority:m,applied:u})},f={get size(){return s.size},get max(){return e},set max(h){e=Math.max(0,h|0)},has:h=>s.has(h),handle:h=>s.get(h)&&s.get(h).handle||null,priority:h=>s.get(h)&&s.get(h).priority||null,canAcquire:h=>s.has(h)||s.size<e,acquire(h,d,{wall:m,visible:u=!0,onError:v}={}){let y=s.get(h);if(y)return y.pending||Promise.resolve(y.handle);if(s.size>=e)return Promise.resolve(null);let b=++l,C=Le({id:h,speakerId:o,visible:u}),x={gen:b,handle:null,visible:u,priority:C,pending:null};s.set(h,x);let I={quality:"auto",...r,mode:"live",wall:m,ui:"none",convergence:ve(a),priority:C};return c("lift-acquire",{peer:h,priority:C,convergence:I.convergence}),x.pending=Promise.resolve().then(()=>n(d,I)).then(T=>{if(s.get(h)!==x||x.gen!==b){try{T&&T.remove&&T.remove()}catch{}return null}return x.pending=null,x.handle=T||null,x.priority=null,p(h,x),T&&typeof T.setConvergence=="function"&&T.setConvergence(ve(a)),x.handle},T=>(s.get(h)===x&&s.delete(h),c("lift-failed",{peer:h,message:String(T&&T.message||T).slice(0,200)}),v&&v(T),null)),x.pending},release(h){let d=s.get(h);if(d){if(s.delete(h),d.handle)try{d.handle.remove()}catch{}c("lift-release",{peer:h})}},releaseAll(){for(let h of[...s.keys()])f.release(h)},setSpeaker(h){o=h===void 0?null:h;for(let[d,m]of s)p(d,m)},setVisible(h,d){let m=s.get(h);!m||m.visible===!!d||(m.visible=!!d,p(h,m))},setDepth(h){a=h;let d=ve(h);for(let m of s.values())m.handle&&typeof m.handle.setConvergence=="function"&&m.handle.setConvergence(d);return d},get anyWeb(){for(let h of s.values())if(h.handle&&!h.handle.native)return!0;return!1},list(){return[...s].map(([h,d])=>({id:h,priority:d.priority,pending:!!d.pending,native:!!(d.handle&&d.handle.native)}))}};return f}var Pn=null;function ni(n){Pn=typeof n=="function"?n:null}var Fn=new WeakMap;function ri(n,e){n&&(typeof n=="object"||typeof n=="function")&&Fn.set(n,e)}var jn=Object.freeze({displaySizeM:Object.freeze([.3456,.1944]),displayPixels:Object.freeze([1920,1080]),nominalViewerM:.65,eyeSeparationM:.063});function si(n,e,t=1){let i=jn,r=!!n&&n.displayWidthMeters>0&&n.displayHeightMeters>0&&n.displayPixelWidth>0&&n.displayPixelHeight>0,s=r?[n.displayWidthMeters,n.displayHeightMeters]:[...i.displaySizeM],o=r?[n.displayPixelWidth,n.displayPixelHeight]:[...i.displayPixels],a=(s[0]/o[0]+s[1]/o[1])/2,l=t>0?t:1,c=r?a*l:a*(o[1]/Math.max(1,globalThis.innerHeight||o[1])),p=n?.nominalViewerPosition?.z,f=Number.isFinite(p)&&p>.1;return{canvasSizeM:[(e?.width||0)*c,(e?.height||0)*c],metersPerCssPx:c,displaySizeM:s,nominalViewerM:f?p:i.nominalViewerM,eyeSeparationM:i.eyeSeparationM,source:{size:r?"display":"default",viewer:f?"display":"default",eyeSeparation:"default"}}}var oi=new WeakMap,J;function ai(n){let e=null;for(let[t,i]of n)i>0&&(!e||i<e[1])&&(e=[t,i]);return e}function Wn(n){if(!n||typeof n.getParameter!="function")return null;let e=oi.get(n);if(e)return e;let t,i,r;try{t=n.getParameter(n.MAX_TEXTURE_SIZE??3379),i=n.getParameter(n.MAX_RENDERBUFFER_SIZE??34024),r=n.getParameter(n.MAX_VIEWPORT_DIMS??3386)}catch{return null}let s=r&&r.length>=2?r[0]:0,o=r&&r.length>=2?r[1]:0,a=ai([["MAX_TEXTURE_SIZE",t],["MAX_RENDERBUFFER_SIZE",i],["MAX_VIEWPORT_DIMS",s]]),l=ai([["MAX_TEXTURE_SIZE",t],["MAX_RENDERBUFFER_SIZE",i],["MAX_VIEWPORT_DIMS",o]]);if(!a||!l)return null;let c={maxW:a[1],maxH:l[1],nameW:a[0],nameH:l[0],valueW:a[1],valueH:l[1]};return oi.set(n,c),c}function li(){if(J!==void 0)return J;J=null;try{if(typeof document>"u"||!document||typeof document.createElement!="function")return J;let n=document.createElement("canvas"),e=n.getContext("webgl2")||n.getContext("webgl");if(!e)return J;J=Wn(e),e.getExtension?.("WEBGL_lose_context")?.loseContext?.()}catch{J=null}return J}function ci(n,e,t,{cols:i=2,rows:r=1}={}){let s=n*i,o=e*r,a={eyeW:n,eyeH:e,cols:i,scale:1,clamped:!1,reqW:s,reqH:o,bufW:s,bufH:o,limitName:"",limitValue:0};if(!t||!(n>0)||!(e>0))return a;let l=t.maxW/s,c=t.maxH/o,p=Math.min(l,c);if(!(p<1))return a;let f=l<=c,h=Math.max(1,Math.min(Math.floor(n*p+1e-9),Math.floor(t.maxW/i))),d=Math.max(1,Math.min(Math.floor(e*p+1e-9),Math.floor(t.maxH/r)));return{eyeW:h,eyeH:d,cols:i,scale:p,clamped:!0,reqW:s,reqH:o,bufW:h*i,bufH:d*r,limitName:f?t.nameW:t.nameH,limitValue:f?t.valueW:t.valueH}}function at(n){return Math.round(n*1e3)/1e3}function di(n,e,t){let i=typeof t=="number"?` (renderScale ${at(t)} \u2192 ${at(t*e.scale)})`:` (scale ${at(e.scale)})`;return`${n} ${e.cols===1?"buffer":"SBS buffer"} ${e.reqW}\xD7${e.reqH} exceeds ${e.limitName} ${e.limitValue} on this device; rendering at ${e.bufW}\xD7${e.bufH}${i}.`}function hi(n,e){let t=n?.width||0,i=n?.height||0,r=e&&e.drawingBufferWidth>0?e.drawingBufferWidth:t,s=e&&e.drawingBufferHeight>0?e.drawingBufferHeight:i,o=t>0?r/t:1,a=i>0?s/i:1;return{sx:o,sy:a,w:r,h:s,mismatch:o!==1||a!==1}}function fi(n,e,t,i="laying the eyes out from the drawing buffer. Lower renderScale to stay inside the device limit."){return`${n} the browser clamped this canvas's drawing buffer to ${t.w}\xD7${t.h} (canvas.width/height say ${e.width}\xD7${e.height}); ${i}`}var ne="smoothstep",ct=["linear","smoothstep","easeoutcubic"];function dt(n){if(typeof n!="string")return null;let e=n.toLowerCase().replace(/[-_\s]/g,"");return ct.includes(e)?e:null}function Nn(n,e){if(e<=0)return 0;if(e>=1)return 1;if(n==="linear")return e;if(n==="easeoutcubic"){let t=1-e;return 1-t*t*t}return e*e*(3-2*e)}var ie="idle",ui="rampDownThenFire",lt="fireThenRampUp",Ae=class{constructor(e,t){this._phase=ie,this._targetMode=null,this._firePending=!1,this._fireAtEnd=!1,this._from=0,this._to=0,this._cur=0,this._t=1,this._dur=180/1e3,this._easing=ne,(e!==void 0||t!==void 0)&&this.configure(e,t)}configure(e,t){if(e!==void 0){let i=Number(e);this._dur=Number.isFinite(i)&&i>0?i:0}return t!==void 0&&(this._easing=dt(t)||ne),this}get durationS(){return this._dur}get easing(){return this._easing}request({targetMode:e=null,targetViewCount:t,currentMode:i=null,currentViewCount:r,current:s,steady:o=1}={}){let a=!(t>1),l=!(r>1),c=Number.isFinite(o)?o:1,p=Number.isFinite(s)?s:c;return this._targetMode=e,this._from=p,a&&!l?(this._phase=ui,this._to=0,this._fireAtEnd=!0,this._firePending=!1):!a&&l?(this._phase=lt,this._from=0,this._to=c,this._firePending=!0,this._fireAtEnd=!1):(this._phase=lt,this._to=a?p:c,this._firePending=!(e!==null&&e===i),this._fireAtEnd=!1),this._t=this._dur>0?0:1,this._cur=this._from,this}update(e){let t=!1;if(this._phase!==ie){let i=Number.isFinite(e)&&e>0?e:0;this._t<1&&this._dur>0?(this._t+=i/this._dur,this._t>1&&(this._t=1)):this._t=1,this._cur=this._from+(this._to-this._from)*Nn(this._easing,this._t),this._phase===lt?(this._firePending&&(this._firePending=!1,t=!0),this._t>=1&&(this._phase=ie)):this._t>=1&&(this._fireAtEnd&&(this._fireAtEnd=!1,t=!0),this._phase=ie)}return{factor:this._cur,fire:t,mode:this._targetMode}}active(){return this._phase!==ie}value(){return this._cur}firePending(){return this._phase===ui?this._fireAtEnd:this._firePending}cancel(){return this._phase=ie,this._firePending=!1,this._fireAtEnd=!1,this._t=1,this}};var U=null;ni(n=>{let e=U;if(!e||!e._running||!n)return null;let t=i=>({layer:i.layer,session:e.session});for(let i of e._windows.values())if(i.canvas===n&&i.layer)return t(i);for(let i of e._windows.values())if(i.layer&&typeof n.contains=="function"&&n.contains(i.canvas))return t(i);for(let i of e._windows.values()){let r=i.canvas.parentElement;if(i.layer&&r&&typeof r.contains=="function"&&r.contains(n))return t(i)}return null});var zn=()=>typeof navigator<"u"&&!!navigator.xr,Q=()=>typeof window<"u"&&typeof window.XRDisplayLayer=="function",Oe=()=>Q()&&"excludeElement"in window.XRDisplayLayer.prototype,ht=()=>{if(!Q())return!1;try{return"setViewRig"in window.XRDisplayLayer.prototype}catch{return!1}},Hn=["getDisplayInfo","getRenderingModes","requestRenderingMode"],Si=["renderingmodechange","hardwaredisplaystatechange"],gt="trackingstatechange",Mi=["unknown","tracking","searching"],mi=[...Si,gt];function Ie(n){try{let e=n?n.trackingState:void 0;return Mi.includes(e)?e:null}catch{return null}}var ft=()=>{if(!Q())return!1;try{let n=window.XRDisplayLayer.prototype;return Hn.every(e=>typeof n[e]=="function")}catch{return!1}},ut=()=>{if(!Q())return!1;try{return typeof window.XRDisplayLayer.prototype.undock=="function"}catch{return!1}},se=null;function re(){if(se!==null)return se;if(!Q())return!1;try{let n=window.XRDisplayLayer.occlusionByDrawOrder;if(typeof n=="boolean")return se=n}catch{}return!1}function Vn(n){if(se!==null)return se;if(!n||!Q()||!("occlusionByDrawOrder"in window.XRDisplayLayer.prototype))return null;try{return se=!!n.occlusionByDrawOrder}catch{return null}}var gi=!1;function pt(){gi||(gi=!0,console.info("[inline3d] This browser composites 2D over woven 3D automatically, per-pixel by draw order \u2014 overlay exclusion is obsolete here, so exclude()/addGlobalOverlay()/data-inline3d-overlay/autoChrome are accepted and ignored. Your 2D chrome already occludes the tiles correctly. The calls are harmless (keep them if you also ship to older DisplayXR Browsers); gate on inline3dOcclusionByDrawOrder() to drop them."))}function Ei(){return zn()&&Q()}function mt(n){return{type:"display",position:{x:0,y:0,z:0},orientation:{x:0,y:0,z:0,w:1},virtualDisplayHeight:n.virtualDisplayHeight>0?n.virtualDisplayHeight:.24,ipdFactor:1,parallaxFactor:1,perspectiveFactor:1}}function $n(n,e){let t=Number.isFinite(n.ipdFactor)?n.ipdFactor:1,i=Number.isFinite(n.parallaxFactor)?n.parallaxFactor:1;return{...n,ipdFactor:t*e,parallaxFactor:i*e}}var Un=16;function V(){return typeof performance<"u"&&performance&&typeof performance.now=="function"?performance.now():Date.now()}var Bn=1200,_i=!1;function qn(n){if(n==null)return ne;let e=dt(n);return e||(_i||(_i=!0,console.warn(`[inline3d] createInline3D({ modeSwitch: { easing: ${JSON.stringify(n)} } }) is not a curve this SDK knows (${ct.join(" / ")}); using '${ne}'.`)),ne)}var vi=!1;function Xn(){vi||(vi=!0,console.info("[inline3d] The active rendering mode is 1-view, so this SDK has zeroed every window rig (ipdFactor/parallaxFactor -> 0) \u2014 both eyes now render from one place. The runtime keeps weaving the same two-view atlas whatever the page submits, so leaving stereo in it would put two slightly different images on a flat panel, i.e. a blurry double image instead of 2D. Your rendering is unchanged; the flattening is a copy applied on the way to the layer and it is undone the moment a 2-view mode goes active again."))}function _t(n){return n&&n.detail!==void 0&&n.detail!==null?n.detail:void 0}function ki(n){let e=_t(n);return e!==void 0?[e,n]:[n]}function Gn(n){for(let e of ki(n)){if(typeof e=="number"&&Number.isFinite(e))return e;if(e&&typeof e=="object"){let t=e.modeIndex!==void 0?e.modeIndex:e.mode;if(typeof t=="number"&&Number.isFinite(t))return t}}return-1}function Jn(n){for(let e of ki(n)){if(e==="2d"||e==="3d")return e;if(e&&typeof e=="object"){let t=e.state!==void 0?e.state:e.hardwareDisplayState;if(t==="2d"||t==="3d")return t}}return null}var yi=!1;function Kn(){yi||(yi=!0,console.warn("[inline3d] This browser has no XRDisplayLayer.setViewRig, so the view rig was ignored (the window still weaves \u2014 the runtime keeps the default display rig, scaled by virtualDisplayHeight). Gate on inline3dViewRigSupported() if your page's framing depends on the rig; further calls are silent."))}var wi=!1;function Yn(){wi||(wi=!0,console.warn("[inline3d] addScene got BOTH viewRig and virtualDisplayHeight; on a browser with rig support the rig wins and the height is dropped. They describe the same slot \u2014 virtualDisplayHeight is shorthand for exactly one rig (display, identity pose, all factors 1) \u2014 so there is no merge. This is only worth passing as a PAIR deliberately: the height is what a browser without setViewRig will use, so a camera-rig scene can name its own fallback framing. Otherwise say it once, inside the rig."))}async function Ti(n={}){let{referenceSpace:e="viewer",lazy:t=!0,rootMargin:i="50% 0px",autoChrome:r=!0,modeSwitch:s=null,untrackedFallback:o="none",viewerEase:a}=n;if(!Ei())return{supported:!1,trackingState:"unknown"};let l;try{l=await navigator.xr.requestSession("inline-3d")}catch(p){return{supported:!1,trackingState:"unknown",error:p}}a!==void 0&&ri(l,a);let c=null;try{c=await l.requestReferenceSpace(e)}catch{}return new vt(l,c,{lazy:t,rootMargin:i,autoChrome:r,modeSwitch:s,untrackedFallback:o})}var we=null;function Fe(n={}){return U&&U._running?Promise.resolve(U):we||(we=Ti(n).finally(()=>{we=null}),we)}var bi=.7,Zn=new Set(["IMG","SVG","VIDEO","CANVAS","BUTTON","INPUT","SELECT","TEXTAREA"]);function Qn(n){let e=[],t=document.createTreeWalker(n,NodeFilter.SHOW_ELEMENT);for(let i=t.nextNode();i;i=t.nextNode())if(!i.closest("[data-inline3d-no-overlay]")){if(Zn.has(i.tagName.toUpperCase())){e.push(i);continue}for(let r of i.childNodes)if(r.nodeType===Node.TEXT_NODE&&r.nodeValue.trim()){e.push(i);break}}return e}var er=4;function xi(n){let e=typeof window<"u"&&window.devicePixelRatio||1;return`${n.clientWidth}x${n.clientHeight}@${e}`}var vt=class{constructor(e,t,{lazy:i,rootMargin:r,autoChrome:s=!0,modeSwitch:o=null,untrackedFallback:a="none"}){this.supported=!0,this.session=e,this.refSpace=t,this._windows=new Map,this._globalOverlays=new Set,this._isolatedBy=new WeakMap,this._autoChrome=s,this._autoChromeEls=new Set,this._lastChromeScan=0,this._fullTileWarned=new WeakSet,this._stoodDown=!1,this._activeModeIndex=-1,this._activeViewCount=0,this._hardwareDisplayState=null,this._modes=null,this._stereoCollapsed=!1,this._displayListeners=new Map,this._primedDisplayState=!1;let l=o&&typeof o=="object"?o:{};this._msEnabled=l.enabled!==!1,this._modeSwitch=new Ae((Number.isFinite(l.durationMs)?Math.max(0,l.durationMs):180)/1e3,qn(l.easing)),this._stereoFactor=1,this._msFire=null,this._msArmedUp=!1,this._msLastMs=null,this._msTick=null,this._trackingSupported=Ie(e)!==null,this._trackingState=this._trackingSupported?Ie(e):"unknown",this._untrackedFallback=a==="mono"?"mono":"none",this._bakedStereo=1,this._bakedTarget=1,this._bakedAt=null,this._bakedEaseS=(Number.isFinite(l.durationMs)?Math.max(0,l.durationMs):180)/1e3,this._applyUntrackedTarget(),this.undock=ut()?{model:!1,splat:!1}:null,this._undockRead=!1,this._running=!0,this._lazy=i,this._observer=i&&typeof IntersectionObserver=="function"?new IntersectionObserver(c=>this._onIntersect(c),{rootMargin:r}):null,this._loopId=0,this._framePending=!1,this._frameCount=0,this._frameWatchdog=null,this._suspended=null,U&&U._running&&U!==this&&console.warn("[inline3d] A second inline-3D session is live in this document. The browser's element-rect channel is a whole-widget setter, so both managers clobber each other's rect list every frame \u2014 tiles may flicker, ghost, or weave at a stale rect. Use ONE createInline3D() per document and add every window to it; if you are switching views, close() the previous manager first."),U=this,e.addEventListener("end",()=>this._teardown()),this._bindDisplayEvents(),this._bindTrackingEvents(),this._scanChrome(),this._bindLifecycle(),this._armDprWatch(),this._requestFrame()}get liveCount(){let e=0;for(let t of this._windows.values())t.layer&&e++;return e}addGlobalOverlay(e){if(!(!e||this._globalOverlays.has(e))){re()&&pt(),this._globalOverlays.add(e);for(let t of this._windows.values())t.layer&&this._applyExclusion(t,e)}}removeGlobalOverlay(e){if(!(!e||!this._globalOverlays.delete(e)))for(let t of this._windows.values())t.layer&&this._dropExclusion(t,e)}_scanChrome(){if(re()||!this._autoChrome||!Oe()||typeof document>"u")return;let e=Date.now();if(e-this._lastChromeScan<1e3)return;this._lastChromeScan=e;let t=document.body;if(!t)return;let i=new Set,r=t.querySelectorAll(":scope > *, :scope > * > *, :scope > * > * > *");for(let s of r){if(s.closest("[data-inline3d-no-overlay]"))continue;let o=getComputedStyle(s).position;if(o!=="fixed"&&o!=="sticky")continue;let a=!1;for(let l of this._windows.keys())if(s===l||s.contains(l)){a=!0;break}if(!a){i.add(s);for(let l of Qn(s))i.add(l)}}for(let s of this._autoChromeEls){let o=!1;if(s.isConnected){for(let a of this._windows.keys())if(s===a||s.contains(a)){o=!0;break}}(!s.isConnected||o)&&(this._autoChromeEls.delete(s),this.removeGlobalOverlay(s))}for(let s of i)this._globalOverlays.has(s)||(this._autoChromeEls.add(s),this.addGlobalOverlay(s))}addImage(e,t,i={}){let r=this._register(e,"image",i);return r.ready=ir(t).then(s=>{r.img=s,r.repaint()}),this._handle(e,r)}addVideo(e,t,i={}){let r=this._register(e,"video",i);return r.video=t,this._handle(e,r)}addScene(e,t,i={}){i.viewRig&&i.virtualDisplayHeight!==void 0&&Yn();let r=this._register(e,"scene",{virtualDisplayHeight:.24,...i});return r.onFrame=t,r.ownsBuffer=!1,i.bufferClamped===!0&&(r.warnedBufMismatch=!0),this._handle(e,r)}_handle(e,t){let i=this;return{remove:()=>this._remove(e),exclude:r=>{r&&(re()&&pt(),t.excluded.add(r),this._applyExclusion(t,r))},unexclude:r=>{!r||!t.excluded.delete(r)||this._dropExclusion(t,r)},get trackingState(){return i._trackingState},setViewRig:r=>(t.viewRig=r||null,this._pushViewRig(t)),getDisplayInfo:()=>this._layerCall(t,"getDisplayInfo","getDisplayInfo()"),displayMetrics:async()=>{let r=null;try{r=await this._layerCall(t,"getDisplayInfo","getDisplayInfo()")}catch{}return si(r,e.getBoundingClientRect(),globalThis.devicePixelRatio||1)},getRenderingModes:()=>this._getRenderingModes(t),requestRenderingMode:r=>this._requestRenderingModeEased(r,t),setStereoEnabled:r=>this._setStereoEnabled(r,t),on:(r,s)=>this.on(r,s),off:(r,s)=>this.off(r,s),onDisplayModeChange:r=>this._onDisplayModeChange(r),stats:()=>({frames:t.frames,monoFrames:t.monoFrames}),get firstWoven(){return t.fwPromise},onFirstWoven:r=>{if(typeof r!="function")throw new TypeError("[inline3d] onFirstWoven() takes a function.");let s=!0;return t.fwPromise.then(o=>{if(s)try{r(o)}catch(a){console.error("[inline3d] onFirstWoven callback threw",a)}}),()=>{s=!1}},rewoven:()=>this._rewoven(t)}}getDisplayInfo(){return this._layerCall(this._liveWindow(null),"getDisplayInfo","getDisplayInfo()")}getRenderingModes(){return this._getRenderingModes(null)}requestRenderingMode(e){return this._requestRenderingModeEased(e,null)}setStereoEnabled(e){return this._setStereoEnabled(e,null)}get trackingState(){return this._trackingState}get hardwareDisplayState(){return this._hardwareDisplayState}get activeMode(){return{modeIndex:this._activeModeIndex,viewCount:this._activeViewCount}}get stereoCollapsed(){return this._stereoCollapsed}get modeSwitch(){return{active:this._msTransitionActive(),factor:this._stereoFactor}}close(){try{this.session.end()}catch{}this._teardown()}_effectiveViewRig(e){return this._stereoFactor>=1?e.viewRig||(e.stereoSynthRig?mt(e):null):(e.viewRig||(e.stereoSynthRig=!0),$n(e.viewRig||mt(e),this._stereoFactor))}_setStereoCollapsed(e){let t=!!e;if(this._stereoCollapsed!==t){this._stereoCollapsed=t,t&&Xn(),!t&&this._msArmedUp?(this._msArmedUp=!1,this._startUpRamp()):this._modeSwitch.active()||(this._stereoFactor=t?0:1);for(let i of this._windows.values()){if(t){let r=i.viewRig||mt(i);i.stereoSaved={ipdFactor:r.ipdFactor,parallaxFactor:r.parallaxFactor}}else i.stereoSaved=null;this._pushViewRig(i)}}}_pushViewRig(e){if(!ht())return Kn(),!1;if(!e.layer)return!1;let t=this._effectiveViewRig(e);try{return e.layer.setViewRig(t),!0}catch{return!1}}_layerCall(e,t,i,r=[]){if(!ft())return Promise.reject(new Error(`[inline3d] ${i} needs a DisplayXR Browser with the display-mode API (XRDisplayLayer.getDisplayInfo/getRenderingModes/requestRenderingMode). Gate on inline3dDisplayModesSupported().`));if(!e||!e.layer)return Promise.reject(new Error(`[inline3d] ${i} needs a live weave layer, and this window has none right now (lazy mode closes the layer while the tile is off screen). Call it once the tile is visible, or create the manager with { lazy: false }.`));try{return Promise.resolve(e.layer[t](...r))}catch(s){return Promise.reject(s)}}_liveWindow(e){if(e&&e.layer)return e;for(let t of this._windows.values())if(t.layer)return t;return e||null}async _getRenderingModes(e){let t=await this._layerCall(this._liveWindow(e),"getRenderingModes","getRenderingModes()"),i=Array.isArray(t)?t:[];this._modes=i;let r=i.find(s=>s.isActive);return r&&(this._activeModeIndex=r.modeIndex,this._activeViewCount=r.viewCount,(r.viewCount===1||r.viewCount===2)&&this._setStereoCollapsed(r.viewCount===1)),this._primedDisplayState=!0,t}_requestRenderingMode(e,t){return this._layerCall(this._liveWindow(t),"requestRenderingMode","requestRenderingMode()",[e])}_msTransitionActive(){return this._modeSwitch.active()||this._msArmedUp||this._msFire!==null}async _requestRenderingModeEased(e,t){if(!this._msEnabled||!ht())return this._requestRenderingMode(e,t);let i=this._modes;if(!Array.isArray(i)||i.length===0)try{i=await this._getRenderingModes(t)}catch{i=null}let r=(Array.isArray(i)?i:[]).find(a=>a&&a.modeIndex===e),s=r&&Number.isFinite(r.viewCount)?r.viewCount:0,o=this._activeViewCount;return s<1||s>2||o<1?this._requestRenderingMode(e,t):s===1?o===1?this._requestRenderingMode(e,t):this._rampDownThenRequest(e,t):this._requestThenRampUp(e,t)}_rampDownThenRequest(e,t){if(this._msFire&&this._msFire.modeIndex===e)return this._msFire.promise;this._settlePendingDown("superseded",`a request for mode ${e} replaced it`);let i={modeIndex:e,win:t,resolve:null,reject:null,promise:null};return i.promise=new Promise((r,s)=>{i.resolve=r,i.reject=s}),this._msFire=i,this._modeSwitch.request({targetMode:e,targetViewCount:1,currentMode:this._activeModeIndex,currentViewCount:this._activeViewCount,current:this._stereoFactor,steady:1}),this._msLastMs=V(),this._armModeSwitchTick(),i.promise}_requestThenRampUp(e,t){let r=this._msFire!==null&&e===this._activeModeIndex;this._settlePendingDown("superseded",`a request for mode ${e} reversed it`);let s=r?Promise.resolve(void 0):this._requestRenderingMode(e,t);return this._stereoFactor<1&&(this._stereoCollapsed?(this._msArmedUp=!0,this._modeSwitch.cancel(),this._stereoFactor=0):this._startUpRamp()),s.catch(o=>{throw this._msArmedUp&&(this._msArmedUp=!1,this._modeSwitch.active()||(this._stereoFactor=this._stereoCollapsed?0:1)),o})}_startUpRamp(){this._settlePendingDown("superseded","the display returned to 3D"),this._modeSwitch.request({targetMode:this._activeModeIndex,targetViewCount:2,currentMode:this._activeModeIndex,currentViewCount:2,current:this._stereoFactor,steady:1}),this._msLastMs=V(),this._armModeSwitchTick()}_settlePendingDown(e,t){let i=this._msFire;if(!i)return;this._msFire=null;let r=new Error(`[inline3d] the request for rendering mode ${i.modeIndex} was never forwarded: ${t}. A ->2D switch is held until the disparity has ramped out, so a request that is reversed or replaced in that window is dropped rather than fired late.`);r.name=e,i.reject(r)}_advanceModeSwitch(){if(!this._modeSwitch.active()){this._msLastMs=null,this._disarmModeSwitchTick();return}let e=V(),t=typeof this._msLastMs=="number"?Math.max(0,(e-this._msLastMs)/1e3):0;this._msLastMs=e;let i=this._modeSwitch.update(t);if(i.factor!==this._stereoFactor){this._stereoFactor=i.factor;for(let r of this._windows.values())this._pushViewRig(r)}if(i.fire&&this._msFire){let r=this._msFire;this._msFire=null,this._requestRenderingMode(r.modeIndex,r.win).then(s=>r.resolve(s),s=>{this._startUpRamp(),r.reject(s)})}this._modeSwitch.active()||(this._msLastMs=null,this._disarmModeSwitchTick())}_armModeSwitchTick(){this._msTick!==null||typeof setTimeout!="function"||(this._msTick=setTimeout(()=>{this._msTick=null,this._running&&(this._advanceModeSwitch(),this._modeSwitch.active()&&this._armModeSwitchTick())},Un))}_disarmModeSwitchTick(){this._msTick!==null&&(typeof clearTimeout=="function"&&clearTimeout(this._msTick),this._msTick=null)}async _setStereoEnabled(e,t){let i=e?2:1,r=await this._getRenderingModes(t),s=Array.isArray(r)?r:[],o=s.find(a=>a.viewCount===i&&a.isRequestable);if(!o)throw new Error(`[inline3d] setStereoEnabled(${!!e}) found no requestable ${i}-view mode on this display (${s.length} mode(s) listed). It is sugar over requestRenderingMode() and cannot invent one \u2014 read getRenderingModes() and drive the list yourself.`);return o.isActive&&!this._msTransitionActive()||await this._requestRenderingModeEased(o.modeIndex,t),!!e}_bindDisplayEvents(){let e=this.session;!e||typeof e.addEventListener!="function"||ft()&&(e.addEventListener("renderingmodechange",t=>{this._onRenderingModeChange(t).catch(()=>{})}),e.addEventListener("hardwaredisplaystatechange",t=>{this._onHardwareDisplayStateChange(t).catch(()=>{})}))}_onHardwareDisplayStateChange(e){let t=Jn(e),i=_t(e),r=a=>{a&&(this._hardwareDisplayState=a),this._emitDisplay({type:"hardwaredisplaystatechange",state:a||this._hardwareDisplayState,detail:i})};t&&r(t);let s=this._activeModeIndex,o=this._activeViewCount;return this._getRenderingModes(null).then(a=>{let l=(Array.isArray(a)?a:[]).find(c=>c.isActive)||null;if(l&&(l.modeIndex!==s||l.viewCount!==o)&&this._emitDisplay({type:"renderingmodechange",modeIndex:this._activeModeIndex,viewCount:this._activeViewCount||null,mode:l,detail:null}),!t){let c=l&&typeof l.hardwareDisplay3D=="boolean"?l.hardwareDisplay3D?"3d":"2d":null;r(c)}}).catch(()=>{t||r(null)})}async _onRenderingModeChange(e){let t=_t(e),i=Gn(e),r=null;try{let s=await this._getRenderingModes(null),o=Array.isArray(s)?s:[];r=(i>=0?o.find(a=>a.modeIndex===i):null)||o.find(a=>a.isActive)||null}catch{}r?(this._activeModeIndex=r.modeIndex,this._activeViewCount=r.viewCount):i>=0&&(this._activeModeIndex=i),(this._activeViewCount===1||this._activeViewCount===2)&&this._setStereoCollapsed(this._activeViewCount===1),this._emitDisplay({type:"renderingmodechange",modeIndex:this._activeModeIndex,viewCount:this._activeViewCount||null,mode:r,detail:t})}_emitDisplay(e,t){let i=this._displayListeners.get(e.type);if(!i)return;let r=t||[e];for(let s of[...i])try{s(...r)}catch(o){console.error(`[inline3d] ${e.type} listener threw`,o)}}on(e,t){if(typeof t!="function")throw new TypeError("[inline3d] on() takes (type, function).");if(!mi.includes(e))throw new TypeError(`[inline3d] on() knows ${mi.join(" / ")}, got ${JSON.stringify(e)}.`);let i=this._displayListeners.get(e);return i||this._displayListeners.set(e,i=new Set),i.add(t),()=>this.off(e,t)}off(e,t){let i=this._displayListeners.get(e);i&&i.delete(t)}_onDisplayModeChange(e){if(typeof e!="function")throw new TypeError("[inline3d] onDisplayModeChange() takes a function.");let t=Si.map(i=>this.on(i,e));return()=>{for(let i of t)i()}}async refreshUndock(){if(!ut())return this.undock=null;let e=this._liveWindow(null);if(!e||!e.layer||typeof e.layer.getUndockCapabilities!="function")return this.undock;try{let t=await e.layer.getUndockCapabilities();this.undock={model:!!(t&&t.model),splat:!!(t&&t.splat)}}catch{}return this.undock}_register(e,t,i){this._windows.has(e)&&this._remove(e),e.style.willChange="transform",e.style.transform="translateZ(0)";let r={canvas:e,kind:t,layer:null,img:null,video:null,onFrame:null,onLayerLost:typeof i.onLayerLost=="function"?i.onLayerLost:null,layerLostSent:!1,ready:null,ownsBuffer:t!=="scene",cornerRadius:i.cornerRadius||0,feather:i.feather||0,reqW:i.width||0,reqH:i.height||0,virtualDisplayHeight:i.virtualDisplayHeight||0,viewRig:i.viewRig||null,stereoSaved:null,stereoSynthRig:!1,observeEl:i.observe||e,ctx:t==="scene"?null:e.getContext("2d"),repaint:()=>this._paint(r,null),excluded:new Set,autoExcluded:new Set,overlayObserver:null,sizeObserver:null,resizePending:!1,frames:0,monoFrames:0,fwResult:null,fwResolve:null,fwPromise:null,fwHoldMs:tr(i.firstWovenHoldMs),fwRegAt:V(),fwLayerAt:null,fwStereo:!1,rw:null,rwGone:null};return r.fwPromise=new Promise(s=>{r.fwResolve=s}),this._windows.set(e,r),this._lazy&&this._observer?this._observer.observe(r.observeEl):this._activate(r),r}_remove(e){let t=this._windows.get(e);t&&(this._observer&&this._observer.unobserve(t.observeEl),this._deactivate(t),this._windows.delete(e),this._settleFirstWoven(t,!1,"removed"))}_onIntersect(e){for(let t of e){let i=null;for(let r of this._windows.values())if(r.observeEl===t.target){i=r;break}i&&(t.isIntersecting?this._activate(i):this._deactivate(i))}}_activate(e){if(!e.layer){this._scanChrome();try{let t=ht()?this._effectiveViewRig(e):null,i=t?{viewRig:t}:e.virtualDisplayHeight>0?{virtualDisplayHeight:e.virtualDisplayHeight}:{};e.layer=new XRDisplayLayer(this.session,e.canvas,i)}catch(t){e.layer=null,e.layerFailWarned||(e.layerFailWarned=!0,console.warn("[inline3d] new XRDisplayLayer() failed for this window \u2014 it will show FLAT 2D instead of woven 3D, and the SDK will not retry. The canvas has been repainted mono so it cannot be left holding a raw side-by-side pair.",t)),this._paintMono(e),this._notifyLayerLost(e),this._settleFirstWoven(e,!1,"layer-failed");return}e.fwResult||(e.fwLayerAt=V(),e.fwStereo=!1),this._restartRewoven(e),e.layerLostSent=!1,!this._primedDisplayState&&ft()&&(this._primedDisplayState=!0,this._getRenderingModes(e).catch(()=>{this._primedDisplayState=!1})),ut()&&this.undock&&!this._undockRead&&(this._undockRead=!0,this.refreshUndock().catch(()=>{})),Vn(e.layer)===!0&&this._standDownLegacyOcclusion();for(let t of e.excluded)this._applyExclusion(e,t);for(let t of this._globalOverlays)this._applyExclusion(e,t);this._startOverlayScan(e),e.ownsBuffer&&(this._sizeBuffer(e,!0),this._paint(e,null)),this._startSizeWatch(e)}}_deactivate(e){if(this._stopOverlayScan(e),this._stopSizeWatch(e),e.layer){try{e.layer.close()}catch{}e.layer=null}this._paintMono(e)}_paintMono(e){!e.ownsBuffer||e.kind==="scene"||(this._sizeBuffer(e,!1),this._paint(e,null))}_notifyLayerLost(e){if(!(e.kind!=="scene"||typeof e.onLayerLost!="function"||e.layerLostSent)){e.layerLostSent=!0;try{e.onLayerLost()}catch(t){console.warn("[inline3d] a scene window's onLayerLost callback threw",t)}}}_isFullTileOverlay(e,t){if(!t||typeof t.getBoundingClientRect!="function")return!1;let i=t.getBoundingClientRect(),r=e.canvas.getBoundingClientRect(),s=i.width*i.height,o=r.width*r.height;if(s<=0||o<=0)return!1;let a=Math.min(i.right,r.right)-Math.max(i.left,r.left),l=Math.min(i.bottom,r.bottom)-Math.max(i.top,r.top);if(a<=0||l<=0)return!1;let c=a*l;return c/s<bi||c/o<bi?!1:(this._fullTileWarned.has(t)||(this._fullTileWarned.add(t),console.warn("[inline3d] Refusing a full-tile overlay: this element covers its own woven canvas, and the browser's geometric matcher cannot tell the two apart \u2014 it would stage the CANVAS as the overlay and the tile would show its raw side-by-side buffer instead of 3D. Make the overlay a PARTIAL region of the tile (a caption band, a badge, a corner plate), or move it outside the tile and register it with addGlobalOverlay().",t)),!0)}_applyExclusion(e,t){if(re()||!e.layer||!Oe()||this._isFullTileOverlay(e,t))return;let i=this._isolatedBy.get(t);if(i||(i=new Set,this._isolatedBy.set(t,i)),i.add(e),!t.dataset.inline3dIsolated){t.dataset.inline3dPriorWillChange=t.style.willChange||"";let r=t.style.willChange&&t.style.willChange!=="auto"?t.style.willChange+", transform":"transform";t.style.willChange=r,t.dataset.inline3dIsolated="1"}try{e.layer.excludeElement(t)}catch{}}_dropExclusion(e,t){if(re())return;let i=this._isolatedBy.get(t);if(i&&i.delete(e),(!i||i.size===0)&&this._unpromote(t),!(!e.layer||!Oe()))try{e.layer.unexcludeElement(t)}catch{}}_unpromote(e){return!e||!e.dataset||!e.dataset.inline3dIsolated?!1:(e.style.willChange=e.dataset.inline3dPriorWillChange||"",delete e.dataset.inline3dPriorWillChange,delete e.dataset.inline3dIsolated,!0)}_standDownLegacyOcclusion(){if(this._stoodDown)return;this._stoodDown=!0;let e=!1;for(let t of this._windows.values()){t.overlayObserver&&(this._stopOverlayScan(t),e=!0);for(let i of t.excluded)this._unpromote(i)&&(e=!0)}for(let t of this._autoChromeEls)this._globalOverlays.delete(t),this._unpromote(t),e=!0;this._autoChromeEls.clear();for(let t of this._globalOverlays)this._unpromote(t)&&(e=!0);this._isolatedBy=new WeakMap,e&&pt()}_startOverlayScan(e){if(re()||!Oe()||typeof MutationObserver!="function")return;let t=e.canvas.parentElement;if(!t)return;let i=()=>{let r=new Set(t.querySelectorAll("[data-inline3d-overlay]"));for(let s of e.autoExcluded)r.has(s)||(e.autoExcluded.delete(s),this._dropExclusion(e,s));for(let s of r)e.autoExcluded.has(s)||(e.autoExcluded.add(s),this._applyExclusion(e,s))};i(),e.overlayObserver=new MutationObserver(i),e.overlayObserver.observe(t,{childList:!0,subtree:!0,attributes:!0,attributeFilter:["data-inline3d-overlay"]})}_stopOverlayScan(e){e.overlayObserver&&(e.overlayObserver.disconnect(),e.overlayObserver=null),e.autoExcluded.clear()}_eyeSize(e,t=!0){let i=Math.min(window.devicePixelRatio||1,2),r=e.reqW||Math.round((e.canvas.clientWidth||256)*i),s=e.reqH||Math.round((e.canvas.clientHeight||256)*i),o=1,a=!e.reqW&&!e.reqH&&this._sourceEyeSize(e);if(a){let c=Math.max(a.w/r,a.h/s);c<1&&(o=c,r=Math.max(1,Math.round(r*c)),s=Math.max(1,Math.round(s*c)))}let l=ci(r,s,li(),{cols:t?2:1});return l.clamped&&(o*=l.scale,r=l.eyeW,s=l.eyeH,e.warnedBufClamp||(e.warnedBufClamp=!0,console.warn(di("[inline3d]",l)))),e.bufScale=o,{w:r,h:s}}_sourceEyeSize(e){if(e.kind!=="video"&&e.kind!=="image")return null;let t=e.kind==="video"?e.video:e.img;if(!t)return null;let i=t.videoWidth||t.naturalWidth||t.width,r=t.videoHeight||t.naturalHeight||t.height;return!(i>1)||!(r>0)?null:{w:i/2,h:r}}_checkSceneBuffer(e){let t=e.canvas;if(!t||typeof t.getContext!="function")return;if(e.sceneGl===void 0){let r=null;try{r=t.getContext("webgl2")||t.getContext("webgl")}catch{r=null}e.sceneGl=r&&typeof r.drawingBufferWidth=="number"?r:null}if(!e.sceneGl)return;let i=hi(t,e.sceneGl);i.mismatch&&(e.warnedBufMismatch=!0,console.warn(fi("[inline3d]",t,i,"getViewport() splits canvas.width, so the eyes are off-centre in the woven buffer. This scene canvas is sized by the page: keep 2 \xD7 eye width and the height within MAX_TEXTURE_SIZE / MAX_RENDERBUFFER_SIZE / MAX_VIEWPORT_DIMS, or map getViewport() onto gl.drawingBufferWidth/Height.")))}_sizeBuffer(e,t){let{w:i,h:r}=this._eyeSize(e,t);e.eyeW=i,e.eyeH=r,e.canvas.width=t?i*2:i,e.canvas.height=r,e.sbs=t}_startSizeWatch(e){typeof ResizeObserver=="function"&&(e.sizeObserver||!e.ownsBuffer||e.reqW&&e.reqH||(e.sizeObserver=new ResizeObserver(()=>this._onBoxChange(e)),e.sizeObserver.observe(e.canvas)))}_stopSizeWatch(e){e.sizeObserver&&(e.sizeObserver.disconnect(),e.sizeObserver=null),e.resizePending=!1}_onBoxChange(e){if(!e.layer||!e.ownsBuffer||e.resizePending)return;e.resizePending=!0;let t=()=>{if(!e.resizePending||(e.resizePending=!1,!e.layer||!e.ownsBuffer))return;this._noteRewovenBox(e);let{w:i,h:r}=this._eyeSize(e);i===e.eyeW&&r===e.eyeH||(this._sizeBuffer(e,!0),this._paint(e,null))};typeof requestAnimationFrame=="function"?requestAnimationFrame(t):t()}_armDprWatch(){if(typeof window>"u"||typeof window.matchMedia!="function")return;this._disarmDprWatch();let e;try{e=window.matchMedia(`(resolution: ${window.devicePixelRatio||1}dppx)`)}catch{return}let t=()=>{if(this._running){this._armDprWatch();for(let i of this._windows.values())i.layer&&this._onBoxChange(i)}};try{e.addEventListener("change",t)}catch{return}this._dprWatch={q:e,onChange:t}}_disarmDprWatch(){if(this._dprWatch){try{this._dprWatch.q.removeEventListener("change",this._dprWatch.onChange)}catch{}this._dprWatch=null}}_paint(e,t){if(e.kind==="scene"||!e.ctx)return;let i=this._running&&!!e.layer;!i&&e.sbs&&this._sizeBuffer(e,!1);let r=e.kind==="video"?e.video:e.img;if(!r)return;if(e.kind==="video"&&(r.readyState||0)<2){i&&this._recommitLastFrame(e);return}let s=e.canvas,o=e.ctx,a=r.videoWidth||r.naturalWidth||r.width,l=r.videoHeight||r.naturalHeight||r.height;if(!a||!l)return;let c=a+"x"+l;if(e.srcKey!==c&&(e.srcKey=c,e.ownsBuffer&&!(e.reqW&&e.reqH))){let{w:u,h:v}=this._eyeSize(e,e.sbs);(u!==e.eyeW||v!==e.eyeH)&&this._sizeBuffer(e,e.sbs)}let p=e.bufScale||1,f=e.cornerRadius*p,h=e.feather*p;if(o.clearRect(0,0,s.width,s.height),!e.sbs){Pe(o,r,0,0,a/2,l,0,0,s.width,s.height,f,h);return}let d=s.width/2,m=Math.min(this._stereoFactor,this._bakedStereo);if(m<1){Pe(o,r,0,0,a/2,l,0,0,d,s.height,f,h),nr(o,r,a/2,l,d,s.height,m,f,h);return}f>0||h>0?(Pe(o,r,0,0,a/2,l,0,0,d,s.height,f,h),Pe(o,r,a/2,0,a/2,l,d,0,d,s.height,f,h)):o.drawImage(r,0,0,a,l,0,0,s.width,s.height)}_bindTrackingEvents(){let e=this.session;!e||typeof e.addEventListener!="function"||this._trackingSupported&&e.addEventListener(gt,()=>{this._setTrackingState(Ie(e)||"unknown")})}_setTrackingState(e){let t=Mi.includes(e)?e:"unknown";if(t===this._trackingState)return;this._trackingState=t,this._applyUntrackedTarget();let i={type:gt,state:t};this._emitDisplay(i,[t,i])}_applyUntrackedTarget(){if(this._untrackedFallback!=="mono"){this._bakedTarget=1;return}this._trackingState==="searching"?this._bakedTarget=0:this._trackingState==="tracking"&&(this._bakedTarget=1)}_trackBakedStereo(e){this._trackingSupported&&this._setTrackingState(Ie(this.session)||"unknown");let t=this._bakedAt===null||!Number.isFinite(e)?0:Math.max(0,(e-this._bakedAt)/1e3);this._bakedAt=Number.isFinite(e)?e:this._bakedAt;let i=this._bakedStereo,r=this._bakedTarget;if(i===r)return;let s=this._bakedEaseS>0?t/this._bakedEaseS:1;this._bakedStereo=r>i?Math.min(r,i+s):Math.max(r,i-s)}_recommitLastFrame(e){let t=e.canvas;if(!t.width||!t.height)return;let i=e.ctx;try{i.save(),i.globalCompositeOperation="copy",i.drawImage(t,0,0),i.restore()}catch{}}_requestFrame(e){if(!this._running||this._framePending&&!e)return;let t=e?++this._loopId:this._loopId;this._framePending=!0;try{this.session.requestAnimationFrame((i,r)=>{t===this._loopId&&(this._framePending=!1,this._frameCount++,this._frame(i,r))})}catch{this._framePending=!1}}_frame(e,t){if(!this._running)return;this._requestFrame(),this._advanceModeSwitch();let i=this.refSpace?t.getViewerPose(this.refSpace):null,r=i?i.views:null;this._trackBakedStereo(e);for(let s of this._windows.values())if(s.layer){if(s.kind==="scene"){if(r&&s.onFrame){if(s.frames++,r.length<2&&(s.monoFrames++,s.monoFrames%300===1&&typeof console<"u"&&console.debug)){let o=(100*s.monoFrames/Math.max(1,s.frames)).toFixed(1);console.debug(`[inline3d] scene window: ${s.monoFrames} non-stereo view lists in ${s.frames} frames (${o}%). The viewer replays its last good stereo frame for these; a rising rate means the session is falling back under load.`)}try{s.onFrame(r,s.layer,t),r.length>=2&&(s.fwStereo=!0,s.warnedBufMismatch||this._checkSceneBuffer(s),s.rw&&(s.rw.stereo=!0))}catch(o){s.frameThrewWarned||(s.frameThrewWarned=!0,console.warn("[inline3d] a scene window's onFrame threw; this window will keep whatever it last drew, and the other windows carry on. Further throws from it are silent.",o))}}}else this._paint(s,r),s.sbs&&(s.kind==="video"?(s.video&&s.video.readyState||0)>=2:s.img)&&(s.fwStereo=!0,s.rw&&(s.rw.stereo=!0));this._tickFirstWoven(s)}}_tickFirstWoven(e){if(e.rw){this._noteRewovenBox(e);let t=e.rw,i=V();t.stereo&&i-t.at>=e.fwHoldMs?this._settleRewoven(e,!0,"hold-elapsed"):t.at>t.calledAt&&i-t.calledAt>=er*e.fwHoldMs&&this._settleRewoven(e,!0,"hold-capped")}e.fwResult||!e.fwStereo||e.fwLayerAt===null||V()-e.fwLayerAt<e.fwHoldMs||this._settleFirstWoven(e,!0,"hold-elapsed")}_settleFirstWoven(e,t,i){t||(e.rwGone||(e.rwGone=i),this._settleRewoven(e,!1,i)),!e.fwResult&&(e.fwResult=Object.freeze({woven:t,confirmed:!1,reason:i,ms:Math.round(V()-e.fwRegAt)}),e.fwResolve(e.fwResult),e.fwResolve=null)}_rewoven(e){if(!e.fwResult)return e.fwPromise;if(!e.fwResult.woven)return Promise.resolve(e.fwResult);if(e.rwGone)return Promise.resolve(Object.freeze({woven:!1,confirmed:!1,reason:e.rwGone,ms:0}));if(e.rw)return this._restartRewoven(e),e.rw.promise;let t,i=new Promise(s=>{t=s}),r=V();return e.rw={promise:i,resolve:t,calledAt:r,at:r,stereo:!1,box:xi(e.canvas)},i}_noteRewovenBox(e){let t=e.rw;if(!t)return;let i=xi(e.canvas);i!==t.box&&(t.box=i,this._restartRewoven(e))}_restartRewoven(e){e.rw&&(e.rw.at=V(),e.rw.stereo=!1)}_settleRewoven(e,t,i){let r=e.rw;r&&(e.rw=null,r.resolve(Object.freeze({woven:t,confirmed:!1,reason:i,ms:Math.round(V()-r.calledAt)})))}_bindLifecycle(){typeof window>"u"||(this._onPageHide=()=>this._suspend(),this._onPageShow=e=>this._resume(!!(e&&e.persisted)),window.addEventListener("pagehide",this._onPageHide),window.addEventListener("pageshow",this._onPageShow),typeof document<"u"&&"onfreeze"in document&&(this._onFreeze=()=>this._suspend(),this._onResume=()=>this._resume(!0),document.addEventListener("freeze",this._onFreeze),document.addEventListener("resume",this._onResume)))}_unbindLifecycle(){typeof window>"u"||(this._onPageHide&&window.removeEventListener("pagehide",this._onPageHide),this._onPageShow&&window.removeEventListener("pageshow",this._onPageShow),this._onFreeze&&typeof document<"u"&&(document.removeEventListener("freeze",this._onFreeze),document.removeEventListener("resume",this._onResume)),this._onPageHide=this._onPageShow=this._onFreeze=this._onResume=null,this._frameWatchdog&&(clearTimeout(this._frameWatchdog),this._frameWatchdog=null))}_suspend(){if(!this._running||this._suspended)return;let e=[];for(let t of this._windows.values())t.layer&&(e.push(t),this._deactivate(t));this._suspended=e}_resume(e){if(!this._running)return;let t=this._suspended;if(this._suspended=null,t)for(let i of t)this._windows.has(i.canvas)&&(this._lazy&&this._observer?(this._observer.unobserve(i.observeEl),this._observer.observe(i.observeEl)):this._activate(i));this._lastChromeScan=0,this._scanChrome(),this._armDprWatch(),e&&this._watchForStalledFrames()}_watchForStalledFrames(){if(this._frameWatchdog||typeof setTimeout!="function")return;let e=this._frameCount;this._frameWatchdog=setTimeout(()=>{this._frameWatchdog=null,!(!this._running||this._frameCount!==e)&&this._requestFrame(!0)},1e3)}_teardown(){if(this._running){this._running=!1,U===this&&(U=null),this._unbindLifecycle(),this._disarmDprWatch(),this._disarmModeSwitchTick(),this._modeSwitch.cancel(),this._msArmedUp=!1,this._settlePendingDown("closed","the inline-3D session closed first"),this._observer&&this._observer.disconnect();for(let e of this._windows.values()){if(this._stopOverlayScan(e),this._stopSizeWatch(e),e.layer){try{e.layer.close()}catch{}e.layer=null}this._paintMono(e),this._notifyLayerLost(e),this._settleFirstWoven(e,!1,"session-ended")}this._windows.clear(),this._setTrackingState("unknown"),this._displayListeners.clear()}}};function tr(n){return typeof n=="number"&&Number.isFinite(n)&&n>=0?n:Bn}function ir(n){return typeof n!="string"?Promise.resolve(n):new Promise((e,t)=>{let i=new Image;i.decoding="async",i.onload=()=>e(i),i.onerror=t,i.src=n})}function Pe(n,e,t,i,r,s,o,a,l,c,p,f){p>0&&n.roundRect?(n.save(),n.beginPath(),n.roundRect(o,a,l,c,p),n.clip(),n.drawImage(e,t,i,r,s,o,a,l,c),n.restore()):n.drawImage(e,t,i,r,s,o,a,l,c),f>0&&Ci(n,o,a,l,c,f)}function nr(n,e,t,i,r,s,o,a,l){n.save(),a>0&&n.roundRect&&(n.beginPath(),n.roundRect(r,0,r,s,a),n.clip()),n.drawImage(e,0,0,t,i,r,0,r,s),o>0&&(n.globalAlpha=o,n.drawImage(e,t,0,t,i,r,0,r,s)),n.restore(),l>0&&Ci(n,r,0,r,s,l)}function Ci(n,e,t,i,r,s){let o=Math.min(s,Math.floor(Math.min(i,r)/2));if(o<=0)return;n.save(),n.globalCompositeOperation="destination-out";let a=[[e,t,i,o,[e,t],[e,t+o]],[e,t+r-o,i,o,[e,t+r],[e,t+r-o]],[e,t,o,r,[e,t],[e+o,t]],[e+i-o,t,o,r,[e+i,t],[e+i-o,t]]];for(let[l,c,p,f,h,d]of a){let m=n.createLinearGradient(h[0],h[1],d[0],d[1]);m.addColorStop(0,"rgba(0,0,0,1)"),m.addColorStop(1,"rgba(0,0,0,0)"),n.fillStyle=m,n.fillRect(l,c,p,f)}n.restore()}function oe(n,e){return n>0&&e>0&&n/e>2.5}function X(n,e,t,i,r){let s=Math.abs(i||0),o=Math.min(n-2*s,e*t);o=Math.max(1,o);let a=Math.min(e,o/t);o=a*t;let l=(n-o)/2+(r===0?i:-i);return{sx:Math.max(0,Math.min(n-o,l)),sy:(e-a)/2,sw:o,sh:a}}function be(n,e,t){let i=Math.min(n,e*t),r=i/t;return{w:Math.max(2,Math.round(i/2)*2),h:Math.max(2,Math.round(r/2)*2)}}function ae(n,e){let t=n/2;return[{src:"R",sx:t,sw:t,sy:0,sh:e,dx:0,dw:t,mirror:!0},{src:"L",sx:0,sw:t,sy:0,sh:e,dx:t,dw:t,mirror:!0}]}function yt(n,e,t){let i=new n.constructor(n.length);for(let r of ae(e,t))for(let s=0;s<t;s++)for(let o=0;o<r.dw;o++){let a=r.sx+(r.sw-1-o);i[s*e+r.dx+o]=n[s*e+a]}return i}function Ri(n,e){return!(n>0)||!(e>0)||e>=180?null:n/2/Math.tan(e/2*Math.PI/180)}function je({eyeWidthPx:n,hfovDeg:e,baselineMm:t,subjectZmm:i}){let r=Ri(n,e);return r===null||!(t>0)||!(i>0)?0:r*t/(2*i)}function We(n,e,t=.12){let i=Math.max(0,e*t);return Math.max(-i,Math.min(i,n||0))}function Ne(n,e,t=.2){let i=n+t*(e-n);return Math.abs(e-i)<.05?e:i}function xe(){let n={hello:null,subjectZmm:null,measuredPx:null,depth:0,current:0,target(e){let t=n.measuredPx!==null?n.measuredPx/2:n.hello&&n.subjectZmm?je({eyeWidthPx:e,hfovDeg:n.hello.hfovDeg,baselineMm:n.hello.baselineMm,subjectZmm:n.subjectZmm}):0;return We(t+n.depth*.05*e,e)},step(e){return n.current=Ne(n.current,n.target(e)),n.current}};return n}var ze=1,wt="inline3d-call/1",Di=Object.freeze(["sbs","mono"]),le=4,Li=4,bt=5,rr=150,sr=5e3,or=/^[A-Za-z0-9_-]{16,64}$/,ar=16;function xt(n){let e=new Uint8Array(ar);return(n||(i=>globalThis.crypto.getRandomValues(i)))(e),Oi(e)}function Ai(n){let e=new Uint8Array(8);return(n||(i=>globalThis.crypto.getRandomValues(i)))(e),Oi(e)}function Oi(n){let e="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_",t="",i=0;for(;i+2<n.length;i+=3){let s=n[i]<<16|n[i+1]<<8|n[i+2];t+=e[s>>18&63]+e[s>>12&63]+e[s>>6&63]+e[s&63]}let r=n.length-i;if(r===1){let s=n[i]<<16;t+=e[s>>18&63]+e[s>>12&63]}else if(r===2){let s=n[i]<<16|n[i+1]<<8;t+=e[s>>18&63]+e[s>>12&63]+e[s>>6&63]}return t}function ce(n){return typeof n=="string"&&or.test(n)}function Se(n){if(!n)return null;let e="";if(typeof n=="object")e=n.hash||n.href&&String(n.href).split("#")[1]||"";else{let t=String(n),i=t.indexOf("#");e=i>=0?t.slice(i+1):t.includes("=")&&!t.includes("://")?t:""}e=e.replace(/^#/,"");for(let t of e.split("&")){let i=t.indexOf("=");if(i<0||decodeURIComponent(t.slice(0,i))!=="room")continue;let r=decodeURIComponent(t.slice(i+1));return ce(r)?r:null}return null}function He(n,e){if(!ce(e))throw new Error(`@displayxr/inline3d/call: invalid room id "${e}"`);return`${String(n||"").split("#")[0]}#room=${e}`}function St(n){if(!n||typeof n!="object"||n.type!=="hello")return null;let e=(t,i,r)=>typeof t=="number"&&Number.isFinite(t)&&t>=i&&t<=r?t:null;return{type:"hello",v:typeof n.v=="number"?n.v:ze,format:Di.includes(n.format)?n.format:"mono",width:e(n.width,1,16384),height:e(n.height,1,16384),baselineMm:e(n.baselineMm,1,1e3),hfovDeg:e(n.hfovDeg,5,170),rectified:n.rectified===!0,sdk:typeof n.sdk=="string"?n.sdk.slice(0,64):null}}function Mt({format:n,width:e,height:t,baselineMm:i,hfovDeg:r,rectified:s}={}){let o={type:"hello",v:ze,format:Di.includes(n)?n:"mono",width:e||0,height:t||0,rectified:!!s,sdk:wt};return Number.isFinite(i)&&(o.baselineMm=i),Number.isFinite(r)&&(o.hfovDeg=r),o}function Et(n){if(!n||n.type!=="hint")return null;let e=n.subjectZmm;return typeof e=="number"&&Number.isFinite(e)&&e>=rr&&e<=sr?{type:"hint",subjectZmm:e}:null}function Ii(n){return!n||n.type!=="state"?null:{type:"state",muted:n.muted===!0,cameraOff:n.cameraOff===!0,speaking:n.speaking===!0}}function kt(n){let e=1e3/n,t=-1/0;return i=>i-t<e?!1:(t=i,!0)}function Tt({format:n,woven:e,mono3D:t="auto",lift:i=!1,overBudget:r=!1,failed:s=!1}){return(n==="sbs"?"sbs":"mono")==="sbs"?{route:e?"woven-sbs":"flat-left"}:e?t==="off"||t===!1?{route:"flat",mono3d:"off"}:i?s?{route:"flat",mono3d:"failed"}:r?{route:"flat",mono3d:"budget"}:{route:"lifted",mono3d:"lifted"}:{route:"flat",mono3d:"unavailable"}:{route:"flat"}}function Ct(n){return n==="woven-sbs"?"3D":n==="lifted"?"2D\u21923D":"2D"}function Rt({need:n=10,needNoPose:e=30}={}){let t=0,i=0,r=!1;return{feed(s){return r?!0:(i++,s===null?r=i>=e:(t=s>=1?t+1:0,r=t>=n),r)},get live(){return r}}}function Ve(n,e){let t=Math.max(1,e|0),r=n==="sbs"?[6e3,4e3,3e3]:[2500,1800,1400];return r[Math.min(t,r.length)-1]}function B(n,{baseMs:e=1e3,maxMs:t=3e4,rand:i=Math.random}={}){let r=Math.min(t,e*Math.pow(2,Math.max(0,n)));return Math.round(r*(.8+.4*i()))}var Me=Object.freeze(["VP9","VP8","AV1"]);function cr(n,e=Me){let t=e.indexOf(String(n||"").toUpperCase());return t<0?e.length:t}function $e(n,e=Me){let t=i=>cr(String(i.mimeType||"").split("/")[1],e);return n.map((i,r)=>({c:i,i:r,r:t(i)})).sort((i,r)=>i.r-r.r||i.i-r.i).map(i=>i.c)}function Ee(n,e=Me){if(!n)return n;let t=n.includes(`\r
3
3
  `)?`\r
4
4
  `:`
@@ -15,14 +15,16 @@
15
15
  //
16
16
  // Placement: the same maths as everywhere else (./inline3d-cursor-depth.js, a port of the
17
17
  // runtime's u_cursor_depth, ADR-046), solved in WORLD space from this frame's views carried
18
- // through the rig node. Drawn as immediate-mode lines with depth test off, so every eye camera
19
- // draws it and nothing occludes it.
18
+ // through the rig node. Drawn as a filled mesh (strokes + a dark outline) in the UI layer with
19
+ // depth test off, so every eye camera draws it last and nothing occludes it; the picker skips the
20
+ // UI layer, so the cursor never picks itself.
20
21
 
21
22
  import {
22
23
  CursorDepthPlacer,
23
24
  CursorPointer,
24
25
  CURSOR_DEFAULT_HEIGHT,
25
- cursorCrosshairPoints,
26
+ CURSOR_DEFAULT_TUNING,
27
+ cursorCrosshairMesh,
26
28
  cursorFootprint,
27
29
  cursorViewRay,
28
30
  } from './inline3d-cursor-depth.js';
@@ -38,15 +40,15 @@ export class PlayCanvasDepthCursor {
38
40
  /**
39
41
  * @param {object} pc the PlayCanvas namespace the adapter renders with.
40
42
  * @param {object} viewer the PlayCanvasSplatViewer (app, rigNode, eye are read lazily).
41
- * @param {{canvas: HTMLElement, height?: number, color?: number[]}} opts
43
+ * @param {{canvas: HTMLElement, height?: number, margin?: number, anchor?: string, pointerScope?: string}} opts
42
44
  */
43
- constructor(pc, viewer, { canvas, height = CURSOR_DEFAULT_HEIGHT, color = [1, 0.83, 0.1, 1] }) {
45
+ constructor(pc, viewer, { canvas, height = CURSOR_DEFAULT_HEIGHT, margin, anchor = 'hybrid', pointerScope = 'canvas' }) {
44
46
  this.pc = pc;
45
47
  this.viewer = viewer;
46
- this.height = height;
47
- this.color = color;
48
- this.pointer = new CursorPointer(canvas);
49
- this.placer = new CursorDepthPlacer();
48
+ this.height = height > 0 && Number.isFinite(height) ? height : CURSOR_DEFAULT_HEIGHT;
49
+ this.pointer = new CursorPointer(canvas, { scope: pointerScope });
50
+ const tuning = margin > 0 && Number.isFinite(margin) ? { ...CURSOR_DEFAULT_TUNING, margin } : CURSOR_DEFAULT_TUNING;
51
+ this.placer = new CursorDepthPlacer(tuning, { anchor });
50
52
  /** The last placement (diagnostics). */
51
53
  this.placement = { active: false };
52
54
  /** The last picked world point under the footprint, or null (nothing under it). */
@@ -58,8 +60,7 @@ export class PlayCanvasDepthCursor {
58
60
  this._pickStart = 0;
59
61
  this._frame = 0;
60
62
  this._disposed = false;
61
- this._points = [];
62
- this._colors = [];
63
+ this._sprite = null; // { mi, node, mat } — built on first use
63
64
  }
64
65
 
65
66
  /**
@@ -93,26 +94,81 @@ export class PlayCanvasDepthCursor {
93
94
  const p = this.placer.update(views, { u: uv[0], v: uv[1], nearestPoint: this.hit, cursorHeight: this.height }, now);
94
95
  this.placement = p;
95
96
  this.pointer.hideCss(!!p.active);
97
+ const sprite = this._sprite || (p.active ? this._makeSprite(app) : null);
98
+ if (!sprite) return;
99
+ sprite.mi.visible = !!p.active;
96
100
  if (!p.active) return;
97
- const pts = cursorCrosshairPoints(p, this._points);
98
- const cols = this._colors;
99
- if (cols.length !== (pts.length / 3) * 4) {
100
- cols.length = 0;
101
- for (let i = 0; i < pts.length / 3; i++) cols.push(...this.color);
102
- }
103
- app.drawLineArrays(pts, cols, false);
101
+ // The unit crosshair in the sprite's XY plane: basis (rotation), height (scale), position.
102
+ const { x, y, z } = p.basis;
103
+ 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]);
104
+ sprite.node.setLocalPosition(p.position[0], p.position[1], p.position[2]);
105
+ sprite.node.setLocalRotation(q[0], q[1], q[2], q[3]);
106
+ sprite.node.setLocalScale(p.height, p.height, p.height);
104
107
  }
105
108
 
106
109
  /** Mono / no pointer / no views: no sprite, the CSS cursor back. */
107
110
  inactive() {
108
111
  if (this.placement.active) this.placement = { active: false };
112
+ this.placer.update(null); // forget the hybrid anchor
113
+ if (this._sprite) this._sprite.mi.visible = false;
109
114
  this.pointer.hideCss(false);
110
115
  }
111
116
 
117
+ // The filled crosshair: one mesh (outline triangles first, fill after — the order a
118
+ // depth-test-off draw needs), vertex-coloured, alpha-blended, in the UI layer.
119
+ _makeSprite(app) {
120
+ const pc = this.pc;
121
+ const m = cursorCrosshairMesh();
122
+ const mesh = new pc.Mesh(app.graphicsDevice);
123
+ mesh.setPositions(m.positions);
124
+ mesh.setColors(m.colors);
125
+ mesh.update();
126
+ const mat = new pc.ShaderMaterial({
127
+ uniqueName: 'inline3dDepthCursor',
128
+ attributes: { vertex_position: pc.SEMANTIC_POSITION, vertex_color: pc.SEMANTIC_COLOR },
129
+ vertexGLSL: `
130
+ attribute vec3 vertex_position;
131
+ attribute vec4 vertex_color;
132
+ uniform mat4 matrix_model;
133
+ uniform mat4 matrix_viewProjection;
134
+ varying vec4 vColor;
135
+ void main() { vColor = vertex_color; gl_Position = matrix_viewProjection * matrix_model * vec4(vertex_position, 1.0); }`,
136
+ fragmentGLSL: `
137
+ varying vec4 vColor;
138
+ void main() { gl_FragColor = vColor; }`,
139
+ });
140
+ mat.blendState = new pc.BlendState(
141
+ true,
142
+ pc.BLENDEQUATION_ADD,
143
+ pc.BLENDMODE_SRC_ALPHA,
144
+ pc.BLENDMODE_ONE_MINUS_SRC_ALPHA,
145
+ pc.BLENDEQUATION_ADD,
146
+ pc.BLENDMODE_ONE,
147
+ pc.BLENDMODE_ONE_MINUS_SRC_ALPHA,
148
+ );
149
+ mat.depthTest = false;
150
+ mat.depthWrite = false;
151
+ mat.cull = pc.CULLFACE_NONE;
152
+ mat.update();
153
+ const node = new pc.GraphNode('inline3d-cursor');
154
+ app.root.addChild(node); // world space = the views' (rig-carried) space
155
+ const mi = new pc.MeshInstance(mesh, mat, node);
156
+ mi.cull = false;
157
+ mi.pick = false;
158
+ mi.drawOrder = 1e9;
159
+ app.scene.layers.getLayerById(pc.LAYERID_UI).addMeshInstances([mi]);
160
+ this._sprite = { mi, node, mat };
161
+ return this._sprite;
162
+ }
163
+
112
164
  dispose() {
113
165
  this._disposed = true;
114
166
  this.pointer.dispose();
115
167
  try {
168
+ if (this._sprite) {
169
+ this.viewer.app?.scene?.layers?.getLayerById(this.pc.LAYERID_UI)?.removeMeshInstances?.([this._sprite.mi]);
170
+ this._sprite.node.destroy?.();
171
+ }
116
172
  this._picker?.destroy?.();
117
173
  this._pickCam?.destroy?.();
118
174
  } catch {
@@ -27,8 +27,20 @@
27
27
  // - the policy works in DISPARITY d = 1 - 1/t, in eye-baseline units (on-screen disparity =
28
28
  // baseline × d): 0 on the canvas, < 0 in front. The eye compares disparities, so the margin,
29
29
  // the clamp and the slew rates live there;
30
- // - the sprite goes at C = E + t (S - E) — on the cyclopean ray, so it never slides sideways
31
- // as it rises — with its height scaled by t so its apparent size is constant.
30
+ // - WHERE along that depth the sprite goes is the anchor mode (CURSOR_ANCHOR_MODES):
31
+ // 'screen' — C = E + t (S - E), on the cyclopean ray: exactly over the pointer from where the
32
+ // viewer is, but head-motion invariant — disparity WITHOUT motion parallax, which
33
+ // on a head-tracked display reads as "at the glass";
34
+ // 'world' — C = S - f (1 - t) eyeToCanvas, straight in front of the pointer's canvas point:
35
+ // parallaxes like content, drifts ~mm off the pointer from an off-axis viewer;
36
+ // 'hybrid' (default) — 'screen' while the pointer moves (exact aim), and world-FIXED while it
37
+ // is still (correct parallax): the foot F of the last moving placement on the glass
38
+ // is kept, and C = F - f (1 - t) eyeToCanvas. Continuous at the stop frame.
39
+ // The height is scaled by t either way, so the apparent size is constant.
40
+
41
+ import { CURSOR_ANCHOR_MODES } from './inline3d-cursor-option.js';
42
+
43
+ export { CURSOR_ANCHOR_MODES, CURSOR_OPTION_KEYS, resolveCursorOption } from './inline3d-cursor-option.js';
32
44
 
33
45
  /** Default sprite height, as a fraction of the canvas height. */
34
46
  export const CURSOR_DEFAULT_HEIGHT = 0.03;
@@ -38,8 +50,12 @@ export const CURSOR_DEFAULT_HEIGHT = 0.03;
38
50
  * Disparities are in eye-baseline units; times in seconds.
39
51
  */
40
52
  export const CURSOR_DEFAULT_TUNING = Object.freeze({
41
- /** How far in front of the content the cursor floats (~2 mm on screen at 65 mm IPD). */
42
- margin: 0.03,
53
+ /**
54
+ * How far in front of the content the cursor floats. 0.005 baseline is ~1.5 mm at a 60 cm
55
+ * viewing distance: it RESTS on what it hovers. (1.36.0 shipped 0.03, ~1–2 cm, which a tester
56
+ * on a panel found floated visibly off shallow content.)
57
+ */
58
+ margin: 0.005,
43
59
  /** Comfort clamp: t >= 0.625, never more than ~3/8 of the way to the eye. */
44
60
  minDisparity: -0.6,
45
61
  maxDisparity: 0.6,
@@ -176,6 +192,10 @@ export function placeCursor(g, disparity, heightFraction) {
176
192
  return { position: addScaled(g.eye, sub(g.canvasPoint, g.eye), t), height: hf * g.canvasHeight * t };
177
193
  }
178
194
 
195
+
196
+ /** A pointer moving less than this (canvas-normalised) between frames counts as still. */
197
+ const POINTER_STILL_EPS = 1e-4;
198
+
179
199
  /**
180
200
  * The whole pipeline with its state: one per cursor.
181
201
  *
@@ -189,23 +209,63 @@ export function placeCursor(g, disparity, heightFraction) {
189
209
  * FOOTPRINT nearest the viewer, in the views' space, or null for "nothing under it".
190
210
  */
191
211
  export class CursorDepthPlacer {
192
- constructor(tuning = CURSOR_DEFAULT_TUNING) {
212
+ /**
213
+ * @param {object} [tuning=CURSOR_DEFAULT_TUNING] placement policy (see CursorTuning).
214
+ * @param {{anchor?: 'hybrid'|'screen'|'world'}} [opts]
215
+ */
216
+ constructor(tuning = CURSOR_DEFAULT_TUNING, { anchor = 'hybrid' } = {}) {
217
+ if (!CURSOR_ANCHOR_MODES.includes(anchor)) {
218
+ throw new Error(`@displayxr/inline3d: cursor anchor "${anchor}" — expected ${CURSOR_ANCHOR_MODES.join(', ')}.`);
219
+ }
193
220
  this.tuning = tuning;
221
+ this.anchor = anchor;
194
222
  this.filter = {};
223
+ this._foot = null; // hybrid: the world-fixed foot on the glass while the pointer is still
224
+ this._last = null; // [u, v] of the previous active frame
195
225
  }
196
226
 
197
227
  update(views, hint, nowSec) {
198
228
  const inactive = { active: false, position: null, basis: null, height: 0, disparity: 0, targetDisparity: 0 };
199
- if (!views || views.length < 2 || !hint) return inactive;
229
+ if (!views || views.length < 2 || !hint) {
230
+ this._foot = null;
231
+ this._last = null;
232
+ return inactive;
233
+ }
200
234
  const a = asView(views[0]);
201
235
  const b = asView(views[views.length - 1]);
202
236
  const g = solveCursorGeometry(a, b, hint.u, hint.v);
203
- if (!g) return inactive;
237
+ if (!g) {
238
+ this._foot = null;
239
+ this._last = null;
240
+ return inactive;
241
+ }
204
242
  const content = hint.nearestPoint ? cursorPointDisparity(g, hint.nearestPoint) : null;
205
243
  const target = cursorTarget(this.tuning, content !== null, content);
206
- const disparity = cursorFilterStep(this.filter, this.tuning, target, nowSec);
207
- const { position, height } = placeCursor(g, disparity, hint.cursorHeight);
208
- return { active: true, position, basis: g.basis, height, disparity, targetDisparity: target, geometry: g };
244
+ const f = this.filter;
245
+ const reprimed = !f.primed || nowSec < f.last || nowSec - f.last > this.tuning.stale;
246
+ const disparity = cursorFilterStep(f, this.tuning, target, nowSec);
247
+ const { position: onRay, height } = placeCursor(g, disparity, hint.cursorHeight);
248
+ const t = 1 / (1 - disparity);
249
+ const out = (-(1 - t) * g.eyeToCanvas); // signed offset along the normal (toward the viewer)
250
+ const fw = g.forward;
251
+ let position = onRay;
252
+ let anchored = false;
253
+ if (this.anchor === 'world') {
254
+ position = addScaled(g.canvasPoint, fw, out);
255
+ } else if (this.anchor === 'hybrid') {
256
+ const last = this._last;
257
+ const moved =
258
+ reprimed || !this._foot || !last || Math.abs(hint.u - last[0]) > POINTER_STILL_EPS || Math.abs(hint.v - last[1]) > POINTER_STILL_EPS;
259
+ if (moved) {
260
+ // The foot of the on-ray placement on the glass, along the normal: C = F - f·(1-t)·eyeToCanvas.
261
+ this._foot = addScaled(onRay, fw, -out);
262
+ } else {
263
+ position = addScaled(this._foot, fw, out);
264
+ anchored = true;
265
+ }
266
+ }
267
+ this._last = [hint.u, hint.v];
268
+ return { active: true, position, basis: g.basis, height, disparity, targetDisparity: target, anchored, geometry: g };
209
269
  }
210
270
  }
211
271
 
@@ -219,22 +279,45 @@ function asView(v) {
219
279
  * Constructing one adds two listeners; nothing else runs until a backend asks for `uv`.
220
280
  */
221
281
  export class CursorPointer {
222
- /** @param {HTMLElement} canvas */
223
- constructor(canvas) {
282
+ /**
283
+ * @param {HTMLElement} canvas
284
+ * @param {{scope?: 'canvas'|'window'}} [opts] 'canvas' (default): the pointer counts only while
285
+ * it is over the canvas element itself. 'window': while it is inside the canvas's BOX,
286
+ * even over DOM layered on top of it (overlay buttons, labels) — and the CSS cursor is then
287
+ * hidden page-wide while the sprite shows, since the overlay has its own cursor.
288
+ */
289
+ constructor(canvas, { scope = 'canvas' } = {}) {
290
+ if (scope !== 'canvas' && scope !== 'window') {
291
+ throw new Error(`@displayxr/inline3d: cursor pointerScope "${scope}" — expected 'canvas' or 'window'.`);
292
+ }
224
293
  this.canvas = canvas;
294
+ this.scope = scope;
225
295
  /** `[u, v]` while the pointer is over the canvas, else null. */
226
296
  this.uv = null;
227
297
  this._hidden = false;
228
298
  this._prev = '';
229
299
  this._onMove = (e) => {
230
300
  const r = canvas.getBoundingClientRect();
231
- this.uv = r.width > 0 && r.height > 0 ? [(e.clientX - r.left) / r.width, (e.clientY - r.top) / r.height] : null;
301
+ if (!(r.width > 0 && r.height > 0)) return void (this.uv = null);
302
+ const u = (e.clientX - r.left) / r.width;
303
+ const v = (e.clientY - r.top) / r.height;
304
+ this.uv = u >= 0 && u <= 1 && v >= 0 && v <= 1 ? [u, v] : null;
232
305
  };
233
306
  this._onLeave = () => {
234
307
  this.uv = null;
235
308
  };
236
- canvas.addEventListener('pointermove', this._onMove);
237
- canvas.addEventListener('pointerleave', this._onLeave);
309
+ this._target = scope === 'window' ? globalThis : canvas;
310
+ this._target.addEventListener('pointermove', this._onMove);
311
+ if (scope === 'window') {
312
+ // Leaving the window (relatedTarget null) or losing focus mid-hover: nothing to hover.
313
+ this._onOut = (e) => {
314
+ if (!e.relatedTarget) this.uv = null;
315
+ };
316
+ globalThis.addEventListener('pointerout', this._onOut);
317
+ globalThis.addEventListener('blur', this._onLeave);
318
+ } else {
319
+ canvas.addEventListener('pointerleave', this._onLeave);
320
+ }
238
321
  }
239
322
 
240
323
  /** Set the pointer directly (canvas-normalised, v down), or null — for scripted input. */
@@ -251,7 +334,9 @@ export class CursorPointer {
251
334
  /** Hide (true) or restore (false) the CSS cursor; idempotent. */
252
335
  hideCss(hide) {
253
336
  if (hide === this._hidden) return;
254
- if (hide) {
337
+ if (this.scope === 'window') {
338
+ pageCursorHidden(hide);
339
+ } else if (hide) {
255
340
  this._prev = this.canvas.style.cursor || '';
256
341
  this.canvas.style.cursor = 'none';
257
342
  } else {
@@ -261,12 +346,34 @@ export class CursorPointer {
261
346
  }
262
347
 
263
348
  dispose() {
264
- this.canvas.removeEventListener('pointermove', this._onMove);
265
- this.canvas.removeEventListener('pointerleave', this._onLeave);
349
+ this._target.removeEventListener('pointermove', this._onMove);
350
+ if (this.scope === 'window') {
351
+ globalThis.removeEventListener('pointerout', this._onOut);
352
+ globalThis.removeEventListener('blur', this._onLeave);
353
+ } else {
354
+ this.canvas.removeEventListener('pointerleave', this._onLeave);
355
+ }
266
356
  this.hideCss(false);
267
357
  }
268
358
  }
269
359
 
360
+ // pointerScope 'window': the overlay DOM has its own cursor, so hiding it on the canvas alone is
361
+ // not enough. A class on <html> plus one injected rule hides it everywhere while any depth cursor
362
+ // shows; a count keeps two tiles from un-hiding each other.
363
+ let _pageHides = 0;
364
+ function pageCursorHidden(hide) {
365
+ const doc = globalThis.document;
366
+ if (!doc || !doc.documentElement) return;
367
+ if (hide && !doc.getElementById('inline3d-cursor-style')) {
368
+ const st = doc.createElement('style');
369
+ st.id = 'inline3d-cursor-style';
370
+ st.textContent = 'html.inline3d-cursor-hidden, html.inline3d-cursor-hidden * { cursor: none !important; }';
371
+ (doc.head || doc.documentElement).appendChild(st);
372
+ }
373
+ _pageHides = Math.max(0, _pageHides + (hide ? 1 : -1));
374
+ doc.documentElement.classList.toggle('inline3d-cursor-hidden', _pageHides > 0);
375
+ }
376
+
270
377
  /**
271
378
  * The footprint ring every backend samples: the hotspot plus 8 points on a circle of radius
272
379
  * 0.75 × the sprite height (canvas heights), so the sprite and a margin are covered.
@@ -291,6 +398,53 @@ export const CURSOR_CROSSHAIR = Object.freeze([
291
398
  [-0.15, -0.15, 0.15, -0.15], [0.15, -0.15, 0.15, 0.15], [0.15, 0.15, -0.15, 0.15], [-0.15, 0.15, -0.15, -0.15],
292
399
  ]);
293
400
 
401
+ /** Fill and outline stroke widths, in sprite heights; and their colours (RGBA, 0..1). */
402
+ const STROKE_FILL = 0.07;
403
+ const STROKE_OUTLINE = 0.15;
404
+ export const CURSOR_FILL_COLOR = Object.freeze([1, 0.84, 0.1, 1]);
405
+ export const CURSOR_OUTLINE_COLOR = Object.freeze([0.04, 0.04, 0.06, 0.85]);
406
+
407
+ /**
408
+ * The crosshair as FILLED strokes in its own XY plane (unit = sprite height): a dark outline
409
+ * pass first, the coloured fill over it — the order a depth-test-off draw needs. A 1 px line
410
+ * reads too thin through a lenticular panel. Triangles, non-indexed.
411
+ * @returns {{positions: Float32Array, colors: Float32Array, count: number}}
412
+ */
413
+ export function cursorCrosshairMesh(fill = CURSOR_FILL_COLOR, outline = CURSOR_OUTLINE_COLOR) {
414
+ const pos = [];
415
+ const col = [];
416
+ const quad = ([x0, y0, x1, y1], w, ext, c) => {
417
+ const dx = x1 - x0, dy = y1 - y0;
418
+ const l = Math.hypot(dx, dy) || 1;
419
+ const ux = dx / l, uy = dy / l; // along
420
+ const nx = -uy * (w / 2), ny = ux * (w / 2); // across
421
+ const ax = x0 - ux * ext, ay = y0 - uy * ext, bx = x1 + ux * ext, by = y1 + uy * ext;
422
+ const v = [[ax - nx, ay - ny], [bx - nx, by - ny], [bx + nx, by + ny], [ax - nx, ay - ny], [bx + nx, by + ny], [ax + nx, ay + ny]];
423
+ for (const [x, y] of v) {
424
+ pos.push(x, y, 0);
425
+ col.push(c[0], c[1], c[2], c[3]);
426
+ }
427
+ };
428
+ for (const seg of CURSOR_CROSSHAIR) quad(seg, STROKE_OUTLINE, (STROKE_OUTLINE - STROKE_FILL) / 2, outline);
429
+ for (const seg of CURSOR_CROSSHAIR) quad(seg, STROKE_FILL, 0, fill);
430
+ return { positions: new Float32Array(pos), colors: new Float32Array(col), count: pos.length / 3 };
431
+ }
432
+
433
+ /**
434
+ * A column-major model matrix placing the unit crosshair for a placement: the sprite's basis
435
+ * scaled by its height, at its position.
436
+ */
437
+ export function cursorModelMatrix(placement, out = new Array(16)) {
438
+ const { x, y, z } = placement.basis;
439
+ const h = placement.height;
440
+ const e = placement.position;
441
+ out[0] = x[0] * h; out[1] = x[1] * h; out[2] = x[2] * h; out[3] = 0;
442
+ out[4] = y[0] * h; out[5] = y[1] * h; out[6] = y[2] * h; out[7] = 0;
443
+ out[8] = z[0] * h; out[9] = z[1] * h; out[10] = z[2] * h; out[11] = 0;
444
+ out[12] = e[0]; out[13] = e[1]; out[14] = e[2]; out[15] = 1;
445
+ return out;
446
+ }
447
+
294
448
  /**
295
449
  * The crosshair's segment endpoints in the views' space for a placement, as a flat
296
450
  * [x, y, z, x, y, z, …] array (16 points) — what a line renderer takes.
@@ -309,3 +463,4 @@ export function cursorCrosshairPoints(placement, out = []) {
309
463
  }
310
464
  return out;
311
465
  }
466
+
@@ -0,0 +1,38 @@
1
+ // inline3d-cursor-option.js — `cursor: 'depth' | { … }` as ./model and ./splat accept it.
2
+ //
3
+ // Its own tiny, dependency-free module on purpose: the PlayCanvas adapter validates the option at
4
+ // the call (statically), but must not pull the cursor itself into a page that never asks for it
5
+ // (the cursor modules load only on opt-in). ./cursor-depth re-exports everything here.
6
+
7
+ /** Anchor modes — see ./inline3d-cursor-depth.js. */
8
+ export const CURSOR_ANCHOR_MODES = Object.freeze(['hybrid', 'screen', 'world']);
9
+
10
+ /** The keys `cursor: { … }` accepts on ./model and ./splat. */
11
+ export const CURSOR_OPTION_KEYS = Object.freeze(['height', 'margin', 'anchor', 'pointerScope']);
12
+
13
+ /**
14
+ * `cursor` as ./model and ./splat accept it: `'depth'`, or an options object (which also means
15
+ * depth). Anything falsy is OFF — the zero-cost default. Validated here, at the call.
16
+ * @returns {null | {height?: number, margin?: number, anchor?: string, pointerScope?: string}}
17
+ */
18
+ export function resolveCursorOption(opt, who = '@displayxr/inline3d') {
19
+ if (opt === undefined || opt === null || opt === false) return null;
20
+ if (opt === 'depth' || opt === true) return {};
21
+ if (typeof opt !== 'object') {
22
+ throw new Error(`${who}: cursor ${JSON.stringify(opt)} — expected 'depth' or { height, margin, anchor, pointerScope }.`);
23
+ }
24
+ const unknown = Object.keys(opt).filter((k) => !CURSOR_OPTION_KEYS.includes(k));
25
+ if (unknown.length) throw new Error(`${who}: cursor — unknown option(s) ${unknown.join(', ')}; expected ${CURSOR_OPTION_KEYS.join(', ')}.`);
26
+ if (opt.anchor !== undefined && !CURSOR_ANCHOR_MODES.includes(opt.anchor)) {
27
+ throw new Error(`${who}: cursor.anchor "${opt.anchor}" — expected ${CURSOR_ANCHOR_MODES.join(', ')}.`);
28
+ }
29
+ if (opt.pointerScope !== undefined && opt.pointerScope !== 'canvas' && opt.pointerScope !== 'window') {
30
+ throw new Error(`${who}: cursor.pointerScope "${opt.pointerScope}" — expected 'canvas' or 'window'.`);
31
+ }
32
+ for (const k of ['height', 'margin']) {
33
+ if (opt[k] !== undefined && !(typeof opt[k] === 'number' && opt[k] > 0 && Number.isFinite(opt[k]))) {
34
+ throw new Error(`${who}: cursor.${k} must be a positive number.`);
35
+ }
36
+ }
37
+ return { ...opt };
38
+ }
@@ -40,6 +40,7 @@
40
40
 
41
41
  import * as THREE from 'three';
42
42
  import { EyeCamera, EdgeFeather, DepthCursor, raycastHitTest } from './inline3d-three.js';
43
+ import { resolveCursorOption } from './inline3d-cursor-depth.js';
43
44
  import { SceneViewer } from './inline3d-viewer.js';
44
45
  // Procedural — built in memory, no asset to serve. Imported eagerly rather than lazily because it
45
46
  // is the default lighting path, so deferring it would only add a frame of unlit content.
@@ -382,7 +383,8 @@ export function addModel(wall, canvas, src, opts = {}) {
382
383
  logTag: '[inline3d/model]',
383
384
  viewerEase: opts.viewerEase,
384
385
  }).useEyeCamera(EyeCamera, EdgeFeather);
385
- if (opts.cursor === 'depth') viewer.useDepthCursor(DepthCursor, (content) => raycastHitTest(THREE, [content]));
386
+ const cursorOpts = resolveCursorOption(opts.cursor, '@displayxr/inline3d/model');
387
+ if (cursorOpts) viewer.useDepthCursor(DepthCursor, (content) => raycastHitTest(THREE, [content]), cursorOpts);
386
388
 
387
389
  if (envMap) viewer.scene.environment = envMap;
388
390
  else if (environment === 'room') addRoomEnvironment(viewer);
@@ -54,6 +54,7 @@ export {
54
54
  CULLFACE_NONE,
55
55
  SEMANTIC_POSITION,
56
56
  SEMANTIC_TEXCOORD0,
57
+ SEMANTIC_COLOR,
57
58
  ShaderChunks,
58
59
  SHADERLANGUAGE_GLSL,
59
60
  CHUNKAPI_2_8,
@@ -120,6 +120,7 @@ import {
120
120
  coverageExponent,
121
121
  declareViewRig,
122
122
  } from './inline3d-splat-shared.js';
123
+ import { resolveCursorOption } from './inline3d-cursor-option.js';
123
124
 
124
125
  /**
125
126
  * The component systems the tile's `AppBase` registers. Camera + GSplat draw the splat; Render,
@@ -829,7 +830,7 @@ export class PlayCanvasSplatViewer {
829
830
  // `cursor: 'depth'` (ADR-046): the depth-aware cursor. OPT-IN AND FREE OTHERWISE — its module
830
831
  // is imported only when asked for (attachEngine), and every per-frame hook is an optional call
831
832
  // on `cursorDepth`, which stays null without it.
832
- this._cursorOpt = cursor;
833
+ this._cursorOpt = resolveCursorOption(cursor, opts.logTag || '[inline3d/splat]'); // null = off
833
834
  this.cursorDepth = null;
834
835
  // The tracking-acquisition ease (./inline3d-viewer-ease.js): undefined = the session's
835
836
  // createInline3D({ viewerEase }) default. Built on the first 3D frame, which knows the session.
@@ -1414,10 +1415,11 @@ export class PlayCanvasSplatViewer {
1414
1415
  if (this.featherPx > 0) this._makeFeather();
1415
1416
  this._applyTransform();
1416
1417
  app.start();
1417
- if (this._cursorOpt === 'depth') {
1418
+ if (this._cursorOpt) {
1419
+ const cursorOpts = this._cursorOpt;
1418
1420
  import('./inline3d-cursor-depth-playcanvas.js')
1419
1421
  .then(({ PlayCanvasDepthCursor }) => {
1420
- if (!this._disposed) this.cursorDepth = new PlayCanvasDepthCursor(pc, this, { canvas: this.canvas });
1422
+ if (!this._disposed) this.cursorDepth = new PlayCanvasDepthCursor(pc, this, { ...cursorOpts, canvas: this.canvas });
1421
1423
  })
1422
1424
  .catch((err) => console.warn(`${this.logTag || '[inline3d/splat]'} cursor: 'depth' could not load; the normal cursor stays.`, err));
1423
1425
  }
@@ -21,6 +21,7 @@
21
21
  import * as THREE from 'three';
22
22
  import { SparkRenderer, SplatMesh } from '@sparkjsdev/spark';
23
23
  import { EyeCamera, EdgeFeather, cameraRigFromCamera, DepthCursor, raycastHitTest } from './inline3d-three.js';
24
+ import { resolveCursorOption } from './inline3d-cursor-depth.js';
24
25
  import { SceneViewer, boundsFromPositions } from './inline3d-viewer.js';
25
26
  import { readSogCamera } from './inline3d-sog.js';
26
27
  import { resolveRevealOption, effectsNotOnSpark } from './inline3d-splat-effects.js';
@@ -250,7 +251,8 @@ export function addSplat(wall, canvas, src, opts = {}) {
250
251
  }).useEyeCamera(EyeCamera, EdgeFeather);
251
252
  // A gaussian-splat raycast is ~8 ms a ray on Spark: one ray a frame, cycling the footprint.
252
253
  // (engine:'playcanvas' hit-tests with the engine's depth picker instead, which is far cheaper.)
253
- if (opts.cursor === 'depth') viewer.useDepthCursor(DepthCursor, (content) => raycastHitTest(THREE, [content]), { raysPerFrame: 1 });
254
+ const cursorOpts = resolveCursorOption(opts.cursor, '@displayxr/inline3d/splat');
255
+ if (cursorOpts) viewer.useDepthCursor(DepthCursor, (content) => raycastHitTest(THREE, [content]), { ...cursorOpts, raysPerFrame: 1 });
254
256
 
255
257
  // Spark renders through the ordinary three.js pipeline, so splats and meshes co-exist and
256
258
  // sort against each other — which is what lets a product page mix a captured hero with a
@@ -52,7 +52,16 @@
52
52
  // descriptor and nothing else: no Kooima, no off-axis math, no scale, here or anywhere in this
53
53
  // SDK. That stays in the runtime, which is the point of the extension.
54
54
 
55
- import { CursorDepthPlacer, CursorPointer, cursorFootprint, cursorViewRay, CURSOR_CROSSHAIR, CURSOR_DEFAULT_HEIGHT } from './inline3d-cursor-depth.js';
55
+ import {
56
+ CursorDepthPlacer,
57
+ CursorPointer,
58
+ cursorCrosshairMesh,
59
+ cursorFootprint,
60
+ cursorModelMatrix,
61
+ cursorViewRay,
62
+ CURSOR_DEFAULT_HEIGHT,
63
+ CURSOR_DEFAULT_TUNING,
64
+ } from './inline3d-cursor-depth.js';
56
65
 
57
66
  /**
58
67
  * A reusable three.js camera driven directly by an XRView's matrices. Construct once with
@@ -473,14 +482,24 @@ export class DepthCursor {
473
482
  * content point along a ray in WORLD space, or null. {@link raycastHitTest} for meshes;
474
483
  * for splats return the renderer's expected depth along the ray (never raycast splats).
475
484
  * @param {number} [opts.height=0.03] sprite height as a fraction of the canvas height.
476
- * @param {number} [opts.color=0xffd419]
485
+ * @param {number} [opts.color=0xffd61a] fill colour (the outline is always dark).
486
+ * @param {number} [opts.margin=0.005] how far in front of the content it floats, in eye-baseline
487
+ * units (0.005 ≈ 1.5 mm at 60 cm: it rests on the content).
488
+ * @param {'hybrid'|'screen'|'world'} [opts.anchor='hybrid'] where along that depth: 'hybrid'
489
+ * follows the pointer exactly while it moves and stays world-fixed (parallaxes with the
490
+ * content) while it is still; 'screen' never parallaxes; 'world' always does.
491
+ * @param {'canvas'|'window'} [opts.pointerScope='canvas'] 'window' keeps the cursor over DOM
492
+ * layered on the canvas (overlay buttons) and hides the CSS cursor page-wide meanwhile.
477
493
  * @param {object} [opts.viewSpace] Object3D whose world matrix maps view transforms to world.
478
494
  * @param {number} [opts.raysPerFrame=0] 0 = the whole footprint every frame (two eyes × 9
479
495
  * points). N > 0 = an EXPENSIVE hit test (e.g. a gaussian-splat raycast, ~8 ms a ray):
480
496
  * cast N rays a frame from the first eye, cycling through the footprint, and take the
481
497
  * nearest of the most recent full cycle.
482
498
  */
483
- constructor(THREE, { canvas, hitTest, height = CURSOR_DEFAULT_HEIGHT, color = 0xffd419, viewSpace = null, raysPerFrame = 0 }) {
499
+ constructor(
500
+ THREE,
501
+ { canvas, hitTest, height = CURSOR_DEFAULT_HEIGHT, color = 0xffd61a, viewSpace = null, raysPerFrame = 0, margin, anchor = 'hybrid', pointerScope = 'canvas' },
502
+ ) {
484
503
  this._THREE = THREE;
485
504
  this.canvas = canvas;
486
505
  this.hitTest = hitTest;
@@ -489,17 +508,28 @@ export class DepthCursor {
489
508
  this.raysPerFrame = raysPerFrame;
490
509
  this._ring = []; // amortised mode: the last hit per footprint point (null = a miss)
491
510
  this._next = 0;
492
- this.placer = new CursorDepthPlacer();
493
- this.pointer = new CursorPointer(canvas);
494
- /** The last placement (diagnostics): `{active, position, height, disparity, targetDisparity}`. */
511
+ const tuning = margin > 0 && Number.isFinite(margin) ? { ...CURSOR_DEFAULT_TUNING, margin } : CURSOR_DEFAULT_TUNING;
512
+ this.placer = new CursorDepthPlacer(tuning, { anchor });
513
+ this.pointer = new CursorPointer(canvas, { scope: pointerScope });
514
+ /** The last placement (diagnostics): `{active, position, height, disparity, targetDisparity, anchored}`. */
495
515
  this.placement = { active: false };
496
516
 
497
- const pos = [];
498
- for (const [x0, y0, x1, y1] of CURSOR_CROSSHAIR) pos.push(x0, y0, 0, x1, y1, 0);
517
+ // Filled strokes with a dark outline (a 1 px line reads too thin through the lens).
518
+ const fill = [((color >> 16) & 255) / 255, ((color >> 8) & 255) / 255, (color & 255) / 255, 1];
519
+ const m = cursorCrosshairMesh(fill);
499
520
  const geom = new THREE.BufferGeometry();
500
- geom.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
501
- const mat = new THREE.LineBasicMaterial({ color, depthTest: false, depthWrite: false, transparent: true });
502
- this.object = new THREE.LineSegments(geom, mat);
521
+ geom.setAttribute('position', new THREE.Float32BufferAttribute(m.positions, 3));
522
+ geom.setAttribute('color', new THREE.Float32BufferAttribute(m.colors, 4));
523
+ const mat = new THREE.MeshBasicMaterial({
524
+ vertexColors: true,
525
+ transparent: true,
526
+ depthTest: false,
527
+ depthWrite: false,
528
+ side: THREE.DoubleSide,
529
+ toneMapped: false,
530
+ });
531
+ this.object = new THREE.Mesh(geom, mat);
532
+ this.object.name = 'inline3d-cursor';
503
533
  this.object.matrixAutoUpdate = false;
504
534
  this.object.frustumCulled = false;
505
535
  this.object.renderOrder = 1e9; // last: never occluded, it is in front by construction
@@ -528,15 +558,7 @@ export class DepthCursor {
528
558
  this.placement = p;
529
559
  this.object.visible = !!p.active;
530
560
  if (p.active) {
531
- const { x, y, z } = p.basis;
532
- const h = p.height;
533
- const e = p.position;
534
- this.object.matrix.fromArray([
535
- x[0] * h, x[1] * h, x[2] * h, 0,
536
- y[0] * h, y[1] * h, y[2] * h, 0,
537
- z[0] * h, z[1] * h, z[2] * h, 0,
538
- e[0], e[1], e[2], 1,
539
- ]);
561
+ this.object.matrix.fromArray(cursorModelMatrix(p));
540
562
  this.object.matrixWorldNeedsUpdate = true;
541
563
  }
542
564
  this.pointer.hideCss(!!p.active);
package/model.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // Type definitions for @displayxr/inline3d/model (and ./model/three).
2
2
  // EXPERIMENTAL — not covered by the 1.x semver promise. See docs/sdk-stability.md.
3
3
 
4
+ import type { CursorOptions } from './cursor-depth.js';
4
5
  import type { SceneViewer, SubjectBounds, OrbitPose } from './viewer.js';
5
6
  import type { FirstWovenResult, ViewerEaseOption } from './index.js';
6
7
  import type { PlayCanvasSplatViewer, SplatCameraPose, SplatFrameInfo } from './splat.js';
@@ -55,9 +56,10 @@ export interface ModelOptions {
55
56
  * is hidden while it shows and comes back in 2D and off the canvas. OPT-IN: without it nothing
56
57
  * is loaded or run. PlayCanvas: hit-tested with the engine's depth picker (meshes and splats,
57
58
  * one low-res pass every other frame while the pointer is over the tile). three: a raycast
58
- * against the subject.
59
+ * against the subject. Pass an object to tune it: `{ margin, anchor, height, pointerScope }`
60
+ * (any object also means depth).
59
61
  */
60
- cursor?: 'depth';
62
+ cursor?: 'depth' | CursorOptions;
61
63
  /**
62
64
  * How the mesh is lit.
63
65
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@displayxr/inline3d",
3
- "version": "1.36.0",
3
+ "version": "1.37.0",
4
4
  "description": "Turn any HTML <canvas> into a 3D window on a spatial display, inside an ordinary web page, for the DisplayXR Browser. Dependency-free; progressive enhancement (falls back to plain 2D everywhere else).",
5
5
  "type": "module",
6
6
  "types": "./index.d.ts",
@@ -74,6 +74,7 @@
74
74
  "js/inline3d-buffer-limit.js",
75
75
  "js/inline3d-viewer-ease.js",
76
76
  "js/inline3d-three.js",
77
+ "js/inline3d-cursor-option.js",
77
78
  "js/inline3d-cursor-depth.js",
78
79
  "js/inline3d-cursor-depth-playcanvas.js",
79
80
  "js/inline3d-viewer.js",
package/splat.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // Type definitions for @displayxr/inline3d/splat.
2
2
  // EXPERIMENTAL — not covered by the 1.x semver promise. See docs/sdk-stability.md.
3
3
 
4
+ import type { CursorOptions } from './cursor-depth.js';
4
5
  import type { SceneViewer, SubjectBounds, OrbitPose } from './viewer.js';
5
6
  import type { FirstWovenResult, ViewerEaseOption, DisplayMetrics } from './index.js';
6
7
 
@@ -479,9 +480,10 @@ export interface SplatOptions {
479
480
  * is hidden while it shows and comes back in 2D and off the canvas. OPT-IN: without it nothing
480
481
  * is loaded or run. PlayCanvas: hit-tested with the engine's depth picker (meshes and splats,
481
482
  * one low-res pass every other frame while the pointer is over the tile). three: a raycast
482
- * against the subject.
483
+ * against the subject. Pass an object to tune it: `{ margin, anchor, height, pointerScope }`
484
+ * (any object also means depth).
483
485
  */
484
- cursor?: 'depth';
486
+ cursor?: 'depth' | CursorOptions;
485
487
  /** Camera rig only: the distance in world metres that sits ON the glass. */
486
488
  convergence?: number;
487
489
  /**
package/three.d.ts CHANGED
@@ -143,7 +143,7 @@ export class EdgeFeather {
143
143
  ): void;
144
144
  }
145
145
 
146
- import type { CursorPlacement, CursorView } from './cursor-depth.js';
146
+ import type { CursorAnchorMode, CursorPlacement, CursorView } from './cursor-depth.js';
147
147
 
148
148
  /**
149
149
  * A hit test for {@link DepthCursor} that raycasts three.js objects (recursively): the first hit
@@ -174,13 +174,21 @@ export class DepthCursor {
174
174
  hitTest: (origin: number[], direction: number[]) => number[] | null;
175
175
  /** Sprite height as a fraction of the canvas height (default 0.03). */
176
176
  height?: number;
177
- /** Crosshair colour (default 0xffd419). */
177
+ /** Fill colour of the crosshair (default 0xffd61a; the outline is always dark). */
178
178
  color?: number;
179
+ /** How far in front of the content it floats, in eye-baseline units (default 0.005 ≈ 1.5 mm at 60 cm). */
180
+ margin?: number;
181
+ /** Default `'hybrid'`: exact while the pointer moves, world-fixed (parallaxing) while it is still. */
182
+ anchor?: CursorAnchorMode;
183
+ /** `'window'`: keep the cursor over DOM layered on the canvas; hides the CSS cursor page-wide meanwhile. */
184
+ pointerScope?: 'canvas' | 'window';
185
+ /** An EXPENSIVE hit test: N rays a frame from the first eye, cycling the footprint (0 = all). */
186
+ raysPerFrame?: number;
179
187
  /** Attach pattern: the Object3D the eye cameras hang off; its world matrix carries the views to world. */
180
188
  viewSpace?: unknown;
181
189
  },
182
190
  );
183
- /** The crosshair (a `THREE.LineSegments`, depth test off, drawn last). Add it to the scene root. */
191
+ /** The crosshair (a `THREE.Mesh` of filled, outlined strokes; depth test off, drawn last). Add it to the scene root. */
184
192
  readonly object: unknown;
185
193
  /** The last placement, for diagnostics. */
186
194
  readonly placement: CursorPlacement | { active: false };