@displayxr/inline3d 1.35.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 +68 -0
- package/cursor-depth.d.ts +113 -0
- package/dist/call.js +1 -1
- package/js/inline3d-cursor-depth-playcanvas.js +337 -0
- package/js/inline3d-cursor-depth.js +466 -0
- package/js/inline3d-cursor-option.js +38 -0
- package/js/inline3d-model-playcanvas.js +1 -0
- package/js/inline3d-model.js +4 -1
- package/js/inline3d-playcanvas-engine.js +4 -0
- package/js/inline3d-splat-playcanvas.js +21 -0
- package/js/inline3d-splat.js +6 -1
- package/js/inline3d-three.js +223 -0
- package/js/inline3d-viewer.js +24 -0
- package/model.d.ts +12 -0
- package/package.json +10 -1
- package/splat.d.ts +12 -0
- package/three.d.ts +57 -0
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
// inline3d-cursor-depth.js — the depth-aware cursor's placement maths, dependency-free.
|
|
2
|
+
//
|
|
3
|
+
// The OS cursor is flat: it is drawn at zero disparity, on the glass. Hover it over content that
|
|
4
|
+
// pops OUT of the glass and it is drawn on top of pixels whose disparity says they are in front
|
|
5
|
+
// of it — a depth violation, and it reads as a broken image. The fix is a cursor SPRITE drawn at
|
|
6
|
+
// the depth of the nearest content under it.
|
|
7
|
+
//
|
|
8
|
+
// This is a line-for-line port of the runtime's `u_cursor_depth` (displayxr-runtime ADR-046,
|
|
9
|
+
// extension XR_DXR_cursor_depth), so a web page and a native app place their cursors
|
|
10
|
+
// identically. The test file pins the same numbers as the runtime's C tests.
|
|
11
|
+
//
|
|
12
|
+
// Division of labour, as in the runtime: the PAGE knows its content, so it supplies the nearest
|
|
13
|
+
// content point under the cursor (a raycast, a depth readback, a depth map); this module decides
|
|
14
|
+
// where the sprite goes, how big it is, and how it moves over time. ./three's `DepthCursor` wires
|
|
15
|
+
// both for a three.js scene. Nothing here runs unless a page calls it — opt-in, zero cost
|
|
16
|
+
// otherwise.
|
|
17
|
+
//
|
|
18
|
+
// The geometry is solved from the VIEWS the page is about to render (projection + transform
|
|
19
|
+
// matrices, column-major, as an XRView carries them), never from rig internals, so it is
|
|
20
|
+
// correct for display rigs, camera rigs, any scale, and any view count >= 2:
|
|
21
|
+
//
|
|
22
|
+
// - S, the canvas point under the cursor, is where the two outermost view rays through (u, v)
|
|
23
|
+
// meet (every off-axis frustum frames the same canvas);
|
|
24
|
+
// - E, the cyclopean eye, is the midpoint of those two views;
|
|
25
|
+
// - a point's depth is t = its distance in front of E along the display normal over S's
|
|
26
|
+
// (t = 1 on the canvas, t < 1 in front of it);
|
|
27
|
+
// - the policy works in DISPARITY d = 1 - 1/t, in eye-baseline units (on-screen disparity =
|
|
28
|
+
// baseline × d): 0 on the canvas, < 0 in front. The eye compares disparities, so the margin,
|
|
29
|
+
// the clamp and the slew rates live there;
|
|
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';
|
|
44
|
+
|
|
45
|
+
/** Default sprite height, as a fraction of the canvas height. */
|
|
46
|
+
export const CURSOR_DEFAULT_HEIGHT = 0.03;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The runtime's defaults (u_cursor_depth_tuning_defaults), in the same units.
|
|
50
|
+
* Disparities are in eye-baseline units; times in seconds.
|
|
51
|
+
*/
|
|
52
|
+
export const CURSOR_DEFAULT_TUNING = Object.freeze({
|
|
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,
|
|
59
|
+
/** Comfort clamp: t >= 0.625, never more than ~3/8 of the way to the eye. */
|
|
60
|
+
minDisparity: -0.6,
|
|
61
|
+
maxDisparity: 0.6,
|
|
62
|
+
/** Rising toward the viewer is fast — never lag behind content that comes forward. */
|
|
63
|
+
riseTau: 0.03,
|
|
64
|
+
/** Sinking away is slow — no flicker as the footprint crosses an edge. */
|
|
65
|
+
sinkTau: 0.25,
|
|
66
|
+
/** A gap longer than this re-primes (snaps) the filter. */
|
|
67
|
+
stale: 0.5,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
|
71
|
+
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
|
72
|
+
const addScaled = (a, b, s) => [a[0] + b[0] * s, a[1] + b[1] * s, a[2] + b[2] * s];
|
|
73
|
+
const finite3 = (a) => Number.isFinite(a[0]) && Number.isFinite(a[1]) && Number.isFinite(a[2]);
|
|
74
|
+
const rotate = (m, v) => [
|
|
75
|
+
m[0] * v[0] + m[4] * v[1] + m[8] * v[2],
|
|
76
|
+
m[1] * v[0] + m[5] * v[1] + m[9] * v[2],
|
|
77
|
+
m[2] * v[0] + m[6] * v[1] + m[10] * v[2],
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The ray from a view through canvas point (u, v) — origin and (unnormalised) direction in the
|
|
82
|
+
* space of the view's transform. (u, v) is canvas-normalised: [0,1]², origin top-left, v down.
|
|
83
|
+
*
|
|
84
|
+
* Use this for the page's own hit test, so the rays it casts are exactly the rays the placement
|
|
85
|
+
* is solved from.
|
|
86
|
+
*
|
|
87
|
+
* @param {{projectionMatrix: ArrayLike<number>, transformMatrix: ArrayLike<number>}} view
|
|
88
|
+
* @param {number} u
|
|
89
|
+
* @param {number} v
|
|
90
|
+
* @returns {{origin: number[], direction: number[]}}
|
|
91
|
+
*/
|
|
92
|
+
export function cursorViewRay(view, u, v) {
|
|
93
|
+
const p = view.projectionMatrix;
|
|
94
|
+
const m = view.transformMatrix;
|
|
95
|
+
// Any perspective projection maps view-space (X, Y, Z) to NDC x = (a X + c Z) / -Z, so the
|
|
96
|
+
// view-space direction through NDC (x, y) is ((x + c) / a, (y + d) / b, -1).
|
|
97
|
+
const x = 2 * u - 1;
|
|
98
|
+
const y = 1 - 2 * v;
|
|
99
|
+
const d = [(x + p[8]) / p[0], (y + p[9]) / p[5], -1];
|
|
100
|
+
return { origin: [m[12], m[13], m[14]], direction: rotate(m, d) };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Solve the cursor's line of sight from two views (normally the first and the last).
|
|
105
|
+
* @returns {object|null} the geometry, or null when degenerate — coincident views (2D), parallel
|
|
106
|
+
* rays, (u, v) off the canvas, non-finite input.
|
|
107
|
+
*/
|
|
108
|
+
export function solveCursorGeometry(viewA, viewB, u, v) {
|
|
109
|
+
if (!(u >= 0 && u <= 1 && v >= 0 && v <= 1)) return null;
|
|
110
|
+
const ra = cursorViewRay(viewA, u, v);
|
|
111
|
+
const rb = cursorViewRay(viewB, u, v);
|
|
112
|
+
const p1 = ra.origin, d1 = ra.direction, p2 = rb.origin, d2 = rb.direction;
|
|
113
|
+
if (!finite3(p1) || !finite3(p2) || !finite3(d1) || !finite3(d2)) return null;
|
|
114
|
+
|
|
115
|
+
// Closest points of p1 + s d1 and p2 + r d2; the midpoint absorbs float noise.
|
|
116
|
+
const w0 = sub(p1, p2);
|
|
117
|
+
const aa = dot(d1, d1), bb = dot(d1, d2), cc = dot(d2, d2);
|
|
118
|
+
const dd = dot(d1, w0), ee = dot(d2, w0);
|
|
119
|
+
const den = aa * cc - bb * bb;
|
|
120
|
+
if (dot(w0, w0) <= 0 || !(den > 1e-12 * aa * cc)) return null;
|
|
121
|
+
const s = (bb * ee - cc * dd) / den;
|
|
122
|
+
const r = (aa * ee - bb * dd) / den;
|
|
123
|
+
if (!(s > 0 && r > 0)) return null;
|
|
124
|
+
const q1 = addScaled(p1, d1, s), q2 = addScaled(p2, d2, r);
|
|
125
|
+
const canvasPoint = [(q1[0] + q2[0]) / 2, (q1[1] + q2[1]) / 2, (q1[2] + q2[2]) / 2];
|
|
126
|
+
const eye = [(p1[0] + p2[0]) / 2, (p1[1] + p2[1]) / 2, (p1[2] + p2[2]) / 2];
|
|
127
|
+
|
|
128
|
+
const ma = viewA.transformMatrix;
|
|
129
|
+
const fwd = rotate(ma, [0, 0, -1]);
|
|
130
|
+
const fl = Math.hypot(fwd[0], fwd[1], fwd[2]);
|
|
131
|
+
const forward = [fwd[0] / fl, fwd[1] / fl, fwd[2] / fl];
|
|
132
|
+
const eyeToCanvas = dot(sub(canvasPoint, eye), forward);
|
|
133
|
+
// Canvas height from view A's frustum: tan(up) - tan(down) = 2 / b.
|
|
134
|
+
const canvasHeight = dot(sub(canvasPoint, p1), forward) * (2 / viewA.projectionMatrix[5]);
|
|
135
|
+
if (!(eyeToCanvas > 0) || !(canvasHeight > 0) || !Number.isFinite(canvasHeight)) return null;
|
|
136
|
+
|
|
137
|
+
return { eye, canvasPoint, forward, eyeToCanvas, canvasHeight, basis: orthonormalBasis(ma) };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The view's rotation as unit X/Y/Z columns (a transform with parent scale still works). */
|
|
141
|
+
function orthonormalBasis(m) {
|
|
142
|
+
const col = (i) => {
|
|
143
|
+
const c = [m[i], m[i + 1], m[i + 2]];
|
|
144
|
+
const l = Math.hypot(c[0], c[1], c[2]) || 1;
|
|
145
|
+
return [c[0] / l, c[1] / l, c[2] / l];
|
|
146
|
+
};
|
|
147
|
+
return { x: col(0), y: col(4), z: col(8) };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Disparity of a point (eye-baseline units; 0 on the canvas, < 0 in front).
|
|
152
|
+
* @returns {number|null} null when the point is not in front of the eye.
|
|
153
|
+
*/
|
|
154
|
+
export function cursorPointDisparity(g, point) {
|
|
155
|
+
if (!finite3(point)) return null;
|
|
156
|
+
const t = dot(sub(point, g.eye), g.forward) / g.eyeToCanvas;
|
|
157
|
+
if (!(t > 0) || !Number.isFinite(t)) return null;
|
|
158
|
+
return 1 - 1 / t;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Target disparity: content minus the margin, clamped; 0 (the canvas) with nothing under it. */
|
|
162
|
+
export function cursorTarget(tuning, hasContent, contentDisparity) {
|
|
163
|
+
if (!hasContent || !Number.isFinite(contentDisparity)) return 0;
|
|
164
|
+
return Math.min(tuning.maxDisparity, Math.max(tuning.minDisparity, contentDisparity - tuning.margin));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Advance the filter toward `target`. `filter` is a plain object, `{}` to start (the first step
|
|
169
|
+
* snaps). A call at a time not after the previous one does not advance it.
|
|
170
|
+
* @param {number} nowSec seconds (e.g. rAF timestamp / 1000).
|
|
171
|
+
*/
|
|
172
|
+
export function cursorFilterStep(filter, tuning, target, nowSec) {
|
|
173
|
+
if (!filter.primed || nowSec < filter.last || nowSec - filter.last > tuning.stale) {
|
|
174
|
+
filter.primed = true;
|
|
175
|
+
filter.disparity = target;
|
|
176
|
+
filter.last = nowSec;
|
|
177
|
+
return target;
|
|
178
|
+
}
|
|
179
|
+
if (nowSec === filter.last) return filter.disparity;
|
|
180
|
+
const dt = nowSec - filter.last;
|
|
181
|
+
const tau = target < filter.disparity ? tuning.riseTau : tuning.sinkTau;
|
|
182
|
+
const alpha = tau > 0 ? 1 - Math.exp(-dt / tau) : 1;
|
|
183
|
+
filter.disparity += alpha * (target - filter.disparity);
|
|
184
|
+
filter.last = nowSec;
|
|
185
|
+
return filter.disparity;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Where to draw a sprite at `disparity`, and how tall, for `heightFraction` of the canvas. */
|
|
189
|
+
export function placeCursor(g, disparity, heightFraction) {
|
|
190
|
+
const t = 1 / (1 - disparity);
|
|
191
|
+
const hf = heightFraction > 0 && Number.isFinite(heightFraction) ? heightFraction : CURSOR_DEFAULT_HEIGHT;
|
|
192
|
+
return { position: addScaled(g.eye, sub(g.canvasPoint, g.eye), t), height: hf * g.canvasHeight * t };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
/** A pointer moving less than this (canvas-normalised) between frames counts as still. */
|
|
197
|
+
const POINTER_STILL_EPS = 1e-4;
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The whole pipeline with its state: one per cursor.
|
|
201
|
+
*
|
|
202
|
+
* const placer = new CursorDepthPlacer();
|
|
203
|
+
* // per frame, with the views you are about to render:
|
|
204
|
+
* const p = placer.update(views, { u, v, nearestPoint, cursorHeight: 0.03 }, now / 1000);
|
|
205
|
+
* if (p.active) drawSprite(p.position, p.basis, p.height);
|
|
206
|
+
*
|
|
207
|
+
* `views` are `{projectionMatrix, transformMatrix}` (or XRViews — `transform.matrix` is read
|
|
208
|
+
* when `transformMatrix` is absent). `nearestPoint` is the content point under the cursor
|
|
209
|
+
* FOOTPRINT nearest the viewer, in the views' space, or null for "nothing under it".
|
|
210
|
+
*/
|
|
211
|
+
export class CursorDepthPlacer {
|
|
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
|
+
}
|
|
220
|
+
this.tuning = tuning;
|
|
221
|
+
this.anchor = anchor;
|
|
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
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
update(views, hint, nowSec) {
|
|
228
|
+
const inactive = { active: false, position: null, basis: null, height: 0, disparity: 0, targetDisparity: 0 };
|
|
229
|
+
if (!views || views.length < 2 || !hint) {
|
|
230
|
+
this._foot = null;
|
|
231
|
+
this._last = null;
|
|
232
|
+
return inactive;
|
|
233
|
+
}
|
|
234
|
+
const a = asView(views[0]);
|
|
235
|
+
const b = asView(views[views.length - 1]);
|
|
236
|
+
const g = solveCursorGeometry(a, b, hint.u, hint.v);
|
|
237
|
+
if (!g) {
|
|
238
|
+
this._foot = null;
|
|
239
|
+
this._last = null;
|
|
240
|
+
return inactive;
|
|
241
|
+
}
|
|
242
|
+
const content = hint.nearestPoint ? cursorPointDisparity(g, hint.nearestPoint) : null;
|
|
243
|
+
const target = cursorTarget(this.tuning, content !== null, content);
|
|
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 };
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function asView(v) {
|
|
273
|
+
return v.transformMatrix ? v : { projectionMatrix: v.projectionMatrix, transformMatrix: v.transform.matrix };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The pointer half of a depth cursor, shared by every backend: tracks the pointer over the
|
|
278
|
+
* canvas (canvas-normalised, v down) and hides the CSS cursor exactly while a sprite replaces it.
|
|
279
|
+
* Constructing one adds two listeners; nothing else runs until a backend asks for `uv`.
|
|
280
|
+
*/
|
|
281
|
+
export class CursorPointer {
|
|
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
|
+
}
|
|
293
|
+
this.canvas = canvas;
|
|
294
|
+
this.scope = scope;
|
|
295
|
+
/** `[u, v]` while the pointer is over the canvas, else null. */
|
|
296
|
+
this.uv = null;
|
|
297
|
+
this._hidden = false;
|
|
298
|
+
this._prev = '';
|
|
299
|
+
this._onMove = (e) => {
|
|
300
|
+
const r = canvas.getBoundingClientRect();
|
|
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;
|
|
305
|
+
};
|
|
306
|
+
this._onLeave = () => {
|
|
307
|
+
this.uv = null;
|
|
308
|
+
};
|
|
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
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Set the pointer directly (canvas-normalised, v down), or null — for scripted input. */
|
|
324
|
+
set(u, v) {
|
|
325
|
+
this.uv = u === null || u === undefined ? null : [u, v];
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** The canvas aspect (width / height), for a round footprint. */
|
|
329
|
+
aspect() {
|
|
330
|
+
const r = this.canvas.getBoundingClientRect();
|
|
331
|
+
return r.height > 0 ? r.width / r.height : 1;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Hide (true) or restore (false) the CSS cursor; idempotent. */
|
|
335
|
+
hideCss(hide) {
|
|
336
|
+
if (hide === this._hidden) return;
|
|
337
|
+
if (this.scope === 'window') {
|
|
338
|
+
pageCursorHidden(hide);
|
|
339
|
+
} else if (hide) {
|
|
340
|
+
this._prev = this.canvas.style.cursor || '';
|
|
341
|
+
this.canvas.style.cursor = 'none';
|
|
342
|
+
} else {
|
|
343
|
+
this.canvas.style.cursor = this._prev;
|
|
344
|
+
}
|
|
345
|
+
this._hidden = hide;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
dispose() {
|
|
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
|
+
}
|
|
356
|
+
this.hideCss(false);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
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
|
+
|
|
377
|
+
/**
|
|
378
|
+
* The footprint ring every backend samples: the hotspot plus 8 points on a circle of radius
|
|
379
|
+
* 0.75 × the sprite height (canvas heights), so the sprite and a margin are covered.
|
|
380
|
+
* @returns {Array<[number, number]>} canvas-normalised points, the hotspot first.
|
|
381
|
+
*/
|
|
382
|
+
export function cursorFootprint(u, v, height, aspect) {
|
|
383
|
+
const r = 0.75 * height;
|
|
384
|
+
const out = [[u, v]];
|
|
385
|
+
for (let k = 0; k < 8; k++) {
|
|
386
|
+
const a = (k * Math.PI) / 4;
|
|
387
|
+
out.push([u + (r * Math.cos(a)) / aspect, v + r * Math.sin(a)]);
|
|
388
|
+
}
|
|
389
|
+
return out;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* The crosshair every backend draws: 8 segments in the sprite's own XY plane, unit = sprite
|
|
394
|
+
* height (a "+" with a gap and a small square at the hotspot), as [x0, y0, x1, y1] tuples.
|
|
395
|
+
*/
|
|
396
|
+
export const CURSOR_CROSSHAIR = Object.freeze([
|
|
397
|
+
[-0.5, 0, -0.15, 0], [0.15, 0, 0.5, 0], [0, -0.5, 0, -0.15], [0, 0.15, 0, 0.5],
|
|
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],
|
|
399
|
+
]);
|
|
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
|
+
|
|
448
|
+
/**
|
|
449
|
+
* The crosshair's segment endpoints in the views' space for a placement, as a flat
|
|
450
|
+
* [x, y, z, x, y, z, …] array (16 points) — what a line renderer takes.
|
|
451
|
+
*/
|
|
452
|
+
export function cursorCrosshairPoints(placement, out = []) {
|
|
453
|
+
out.length = 0;
|
|
454
|
+
const { position: p, basis: b, height: h } = placement;
|
|
455
|
+
for (const [x0, y0, x1, y1] of CURSOR_CROSSHAIR) {
|
|
456
|
+
for (const [x, y] of [[x0, y0], [x1, y1]]) {
|
|
457
|
+
out.push(
|
|
458
|
+
p[0] + (b.x[0] * x + b.y[0] * y) * h,
|
|
459
|
+
p[1] + (b.x[1] * x + b.y[1] * y) * h,
|
|
460
|
+
p[2] + (b.x[2] * x + b.y[2] * y) * h,
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return out;
|
|
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
|
+
}
|
|
@@ -467,6 +467,7 @@ export function attachPlayCanvasModel(out, wall, canvas, src, opts, pending = []
|
|
|
467
467
|
farClip: opts.farClip,
|
|
468
468
|
sky: false,
|
|
469
469
|
viewerEase: opts.viewerEase,
|
|
470
|
+
cursor: opts.cursor,
|
|
470
471
|
// Each environment's own look: 'room' is three's (untonemapped), the rest the Sample Viewer's.
|
|
471
472
|
toneMapping: envMap ? 'neutral' : ENVIRONMENT_TONE_MAPPING[environment] || 'neutral',
|
|
472
473
|
});
|
package/js/inline3d-model.js
CHANGED
|
@@ -39,7 +39,8 @@
|
|
|
39
39
|
// docs/authoring-inline-3d.md#compressed-gltf.
|
|
40
40
|
|
|
41
41
|
import * as THREE from 'three';
|
|
42
|
-
import { EyeCamera, EdgeFeather } from './inline3d-three.js';
|
|
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,6 +383,8 @@ export function addModel(wall, canvas, src, opts = {}) {
|
|
|
382
383
|
logTag: '[inline3d/model]',
|
|
383
384
|
viewerEase: opts.viewerEase,
|
|
384
385
|
}).useEyeCamera(EyeCamera, EdgeFeather);
|
|
386
|
+
const cursorOpts = resolveCursorOption(opts.cursor, '@displayxr/inline3d/model');
|
|
387
|
+
if (cursorOpts) viewer.useDepthCursor(DepthCursor, (content) => raycastHitTest(THREE, [content]), cursorOpts);
|
|
385
388
|
|
|
386
389
|
if (envMap) viewer.scene.environment = envMap;
|
|
387
390
|
else if (environment === 'room') addRoomEnvironment(viewer);
|
|
@@ -38,6 +38,9 @@ export {
|
|
|
38
38
|
Quat,
|
|
39
39
|
LAYERID_SKYBOX,
|
|
40
40
|
LAYERID_UI,
|
|
41
|
+
LAYERID_IMMEDIATE,
|
|
42
|
+
// cursor: 'depth' (./inline3d-cursor-depth-playcanvas.js, loaded only on opt-in)
|
|
43
|
+
Picker,
|
|
41
44
|
// materials / meshes (the edge feather)
|
|
42
45
|
Mesh,
|
|
43
46
|
MeshInstance,
|
|
@@ -51,6 +54,7 @@ export {
|
|
|
51
54
|
CULLFACE_NONE,
|
|
52
55
|
SEMANTIC_POSITION,
|
|
53
56
|
SEMANTIC_TEXCOORD0,
|
|
57
|
+
SEMANTIC_COLOR,
|
|
54
58
|
ShaderChunks,
|
|
55
59
|
SHADERLANGUAGE_GLSL,
|
|
56
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,
|
|
@@ -823,8 +824,14 @@ export class PlayCanvasSplatViewer {
|
|
|
823
824
|
pageCamera = false,
|
|
824
825
|
toneMapping = 'none',
|
|
825
826
|
viewerEase,
|
|
827
|
+
cursor = null,
|
|
826
828
|
} = opts;
|
|
827
829
|
this.canvas = canvas;
|
|
830
|
+
// `cursor: 'depth'` (ADR-046): the depth-aware cursor. OPT-IN AND FREE OTHERWISE — its module
|
|
831
|
+
// is imported only when asked for (attachEngine), and every per-frame hook is an optional call
|
|
832
|
+
// on `cursorDepth`, which stays null without it.
|
|
833
|
+
this._cursorOpt = resolveCursorOption(cursor, opts.logTag || '[inline3d/splat]'); // null = off
|
|
834
|
+
this.cursorDepth = null;
|
|
828
835
|
// The tracking-acquisition ease (./inline3d-viewer-ease.js): undefined = the session's
|
|
829
836
|
// createInline3D({ viewerEase }) default. Built on the first 3D frame, which knows the session.
|
|
830
837
|
this._viewerEaseOpt = viewerEase;
|
|
@@ -1253,6 +1260,7 @@ export class PlayCanvasSplatViewer {
|
|
|
1253
1260
|
this._monoRaf = requestAnimationFrame(loop);
|
|
1254
1261
|
this._beforeFrame?.(null);
|
|
1255
1262
|
if (this._disposed || this._mode !== 'mono') return; // the callback removed or re-wove us
|
|
1263
|
+
this.cursorDepth?.inactive(); // 2D: the normal cursor
|
|
1256
1264
|
this._tick();
|
|
1257
1265
|
this._drawMono();
|
|
1258
1266
|
};
|
|
@@ -1283,6 +1291,8 @@ export class PlayCanvasSplatViewer {
|
|
|
1283
1291
|
this._live = null;
|
|
1284
1292
|
this._videoPlane?.destroy();
|
|
1285
1293
|
this._videoPlane = null;
|
|
1294
|
+
this.cursorDepth?.dispose();
|
|
1295
|
+
this.cursorDepth = null;
|
|
1286
1296
|
this.layerRigs = null; // its cameras go with the app
|
|
1287
1297
|
try {
|
|
1288
1298
|
this.app?.destroy();
|
|
@@ -1405,6 +1415,14 @@ export class PlayCanvasSplatViewer {
|
|
|
1405
1415
|
if (this.featherPx > 0) this._makeFeather();
|
|
1406
1416
|
this._applyTransform();
|
|
1407
1417
|
app.start();
|
|
1418
|
+
if (this._cursorOpt) {
|
|
1419
|
+
const cursorOpts = this._cursorOpt;
|
|
1420
|
+
import('./inline3d-cursor-depth-playcanvas.js')
|
|
1421
|
+
.then(({ PlayCanvasDepthCursor }) => {
|
|
1422
|
+
if (!this._disposed) this.cursorDepth = new PlayCanvasDepthCursor(pc, this, { ...cursorOpts, canvas: this.canvas });
|
|
1423
|
+
})
|
|
1424
|
+
.catch((err) => console.warn(`${this.logTag || '[inline3d/splat]'} cursor: 'depth' could not load; the normal cursor stays.`, err));
|
|
1425
|
+
}
|
|
1408
1426
|
return app;
|
|
1409
1427
|
}
|
|
1410
1428
|
|
|
@@ -2103,6 +2121,8 @@ export class PlayCanvasSplatViewer {
|
|
|
2103
2121
|
}
|
|
2104
2122
|
// handle.setVideo's plane: size, eye split, and a new frame's upload (./inline3d-splat-video.js).
|
|
2105
2123
|
this._videoPlane?.beforeDraw(entries, rect);
|
|
2124
|
+
// cursor: 'depth' — place the sprite and queue its lines for the tick below (no-op unless opted in).
|
|
2125
|
+
this.cursorDepth?.frame(entries);
|
|
2106
2126
|
app.tick(now());
|
|
2107
2127
|
this._afterTick();
|
|
2108
2128
|
return true;
|
|
@@ -3216,6 +3236,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3216
3236
|
farClip: opts.farClip,
|
|
3217
3237
|
sky: opts.sky,
|
|
3218
3238
|
viewerEase: opts.viewerEase,
|
|
3239
|
+
cursor: opts.cursor,
|
|
3219
3240
|
});
|
|
3220
3241
|
|
|
3221
3242
|
let handle = null;
|
package/js/inline3d-splat.js
CHANGED
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
|
|
21
21
|
import * as THREE from 'three';
|
|
22
22
|
import { SparkRenderer, SplatMesh } from '@sparkjsdev/spark';
|
|
23
|
-
import { EyeCamera, EdgeFeather, cameraRigFromCamera } from './inline3d-three.js';
|
|
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';
|
|
@@ -248,6 +249,10 @@ export function addSplat(wall, canvas, src, opts = {}) {
|
|
|
248
249
|
logTag: '[inline3d/splat]',
|
|
249
250
|
viewerEase: opts.viewerEase,
|
|
250
251
|
}).useEyeCamera(EyeCamera, EdgeFeather);
|
|
252
|
+
// A gaussian-splat raycast is ~8 ms a ray on Spark: one ray a frame, cycling the footprint.
|
|
253
|
+
// (engine:'playcanvas' hit-tests with the engine's depth picker instead, which is far cheaper.)
|
|
254
|
+
const cursorOpts = resolveCursorOption(opts.cursor, '@displayxr/inline3d/splat');
|
|
255
|
+
if (cursorOpts) viewer.useDepthCursor(DepthCursor, (content) => raycastHitTest(THREE, [content]), { ...cursorOpts, raysPerFrame: 1 });
|
|
251
256
|
|
|
252
257
|
// Spark renders through the ordinary three.js pipeline, so splats and meshes co-exist and
|
|
253
258
|
// sort against each other — which is what lets a product page mix a captured hero with a
|