diff --git a/CHANGELOG.md b/CHANGELOG.md index c2cae65..c0b5ce1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ entry points (`.`, `./three`) are frozen for 1.x, while the **scene subpaths** ( `./splat`, `./model`) are a preview tier whose options may change in any release. Entries below say which tier they touch, because that is what tells you whether an upgrade can move your pixels. +## 1.37.1 — 2026-10-07 + +### Depth cursor: hybrid anchor keeps the line of sight, not the foot + +- 1.37.0's `'hybrid'` held the sprite's foot on the glass while the pointer was still. When the + depth under a still pointer changed (content animating under a resting mouse, or the first + frames after load), the sprite then rose along the display NORMAL. For an off-axis viewer that + pulls it off the pointer (27 px in the runtime's off-axis capture). +- It now keeps the line of sight from the last pointer move, and sits on that line at the current + depth: + - **head moves:** the sprite stays put (it parallaxes, as in 1.37.0); + - **depth changes:** the sprite slides along that line, so it stays on the click point for the + viewer who placed it; + - **stop frame:** still continuous. +- Matches the runtime's `XR_DXR_cursor_depth` v3 rule. Tests cover it, including an off-axis + depth-change case and a head moving nearer; each mutant is caught. + ## 1.37.0 — 2026-10-07 Driven by a tester's run on a head-tracked panel (the depth cursor shipped in 1.36.0). Additive diff --git a/js/inline3d-cursor-depth.js b/js/inline3d-cursor-depth.js index 3e9267c..638220f 100644 --- a/js/inline3d-cursor-depth.js +++ b/js/inline3d-cursor-depth.js @@ -34,8 +34,11 @@ // 'world' — C = S - f (1 - t) eyeToCanvas, straight in front of the pointer's canvas point: // parallaxes like content, drifts ~mm off the pointer from an off-axis viewer; // 'hybrid' (default) — 'screen' while the pointer moves (exact aim), and world-FIXED while it -// is still (correct parallax): the foot F of the last moving placement on the glass -// is kept, and C = F - f (1 - t) eyeToCanvas. Continuous at the stop frame. +// is still (correct parallax): the line of sight at the last pointer move (E0 → S0) +// is kept, and the sprite sits on it at the current depth — the point whose +// distance in front of the glass is (1 - t) eyeToCanvas. A head move leaves it +// put; a depth change slides it along that line, so it stays on the click point +// for the viewer who placed it. Continuous at the stop frame. // The height is scaled by t either way, so the apparent size is constant. import { CURSOR_ANCHOR_MODES } from './inline3d-cursor-option.js'; @@ -220,14 +223,14 @@ export class CursorDepthPlacer { this.tuning = tuning; this.anchor = anchor; this.filter = {}; - this._foot = null; // hybrid: the world-fixed foot on the glass while the pointer is still + this._line = null; // hybrid: the line of sight at the last pointer move { E, S, D } this._last = null; // [u, v] of the previous active frame } update(views, hint, nowSec) { const inactive = { active: false, position: null, basis: null, height: 0, disparity: 0, targetDisparity: 0 }; if (!views || views.length < 2 || !hint) { - this._foot = null; + this._line = null; this._last = null; return inactive; } @@ -235,7 +238,7 @@ export class CursorDepthPlacer { const b = asView(views[views.length - 1]); const g = solveCursorGeometry(a, b, hint.u, hint.v); if (!g) { - this._foot = null; + this._line = null; this._last = null; return inactive; } @@ -255,12 +258,14 @@ export class CursorDepthPlacer { } else if (this.anchor === 'hybrid') { const last = this._last; const moved = - reprimed || !this._foot || !last || Math.abs(hint.u - last[0]) > POINTER_STILL_EPS || Math.abs(hint.v - last[1]) > POINTER_STILL_EPS; + reprimed || !this._line || !last || Math.abs(hint.u - last[0]) > POINTER_STILL_EPS || Math.abs(hint.v - last[1]) > POINTER_STILL_EPS; if (moved) { - // The foot of the on-ray placement on the glass, along the normal: C = F - f·(1-t)·eyeToCanvas. - this._foot = addScaled(onRay, fw, -out); + this._line = { E: g.eye, S: g.canvasPoint, D: g.eyeToCanvas }; } else { - position = addScaled(this._foot, fw, out); + // On the kept line E0 → S0, the point (1 - t)·eyeToCanvas in front of the glass. + const L = this._line; + const k = 1 - ((1 - t) * g.eyeToCanvas) / L.D; + position = addScaled(L.E, sub(L.S, L.E), k); anchored = true; } } diff --git a/package.json b/package.json index 4811046..4d525ab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@displayxr/inline3d", - "version": "1.37.0", + "version": "1.37.1", "description": "Turn any HTML 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).", "type": "module", "types": "./index.d.ts", diff --git a/test/cursor-depth.test.mjs b/test/cursor-depth.test.mjs index dfa27f0..88e01b9 100644 --- a/test/cursor-depth.test.mjs +++ b/test/cursor-depth.test.mjs @@ -373,3 +373,48 @@ test('DepthCursor: margin option + pointerScope window (over overlay DOM, page-w delete globalThis.document; } }); + +test('anchor hybrid: depth changing under a STILL pointer slides along the kept line of sight (no drift off the click point)', () => { + // Off-axis viewer (10 cm up, 6 cm right): the case where lifting along the display normal + // would pull the sprite visibly off the pointer. + const views = [kooima([0.06 - 0.032, 0.1, 0.6]), kooima([0.06 + 0.032, 0.1, 0.6])]; + const hybrid = new CursorDepthPlacer(); + const world = new CursorDepthPlacer(T, { anchor: 'world' }); + // Frame 1: nothing under the pointer yet — anchored on the glass. + const a0 = hybrid.update(views, { u: 0.4, v: 0.6, nearestPoint: null }, 1.0); + world.update(views, { u: 0.4, v: 0.6, nearestPoint: null }, 1.0); + const E0 = a0.geometry.eye, S0 = a0.geometry.canvasPoint; + // Content appears under the still pointer; let the filter rise. + let h, w; + for (let i = 1; i <= 40; i++) { + h = hybrid.update(views, { u: 0.4, v: 0.6, nearestPoint: [0, 0, 0.12] }, 1.0 + i * 0.016); + w = world.update(views, { u: 0.4, v: 0.6, nearestPoint: [0, 0, 0.12] }, 1.0 + i * 0.016); + } + assert.equal(h.anchored, true); + assert.ok(h.position[2] > 0.1, 'it rose'); + // Projected from E0 onto the glass, the hybrid sprite is still exactly on the click point S0 … + const onGlass = (e, p) => { const k = e[2] / (e[2] - p[2]); return [e[0] + (p[0] - e[0]) * k, e[1] + (p[1] - e[1]) * k]; }; + near3(onGlass(E0, h.position), [S0[0], S0[1]], 1e-6); + // … while lifting along the normal (world) lands visibly off it for this viewer. + const off = onGlass(E0, w.position); + assert.ok(Math.hypot(off[0] - S0[0], off[1] - S0[1]) > 0.005, 'world drifts > 5 mm here'); + // And it is at the same depth as the world placement (only the line differs). + near(h.position[2], w.position[2], 1e-6); +}); + +test('anchor hybrid: a still pointer + the head moving NEARER keeps the sprite at the depth the content needs', () => { + const hint = { u: 0.45, v: 0.5, nearestPoint: [0, 0, 0.1] }; + const at = (z) => [kooima([-0.032, 0, z]), kooima([0.032, 0, z])]; + const hybrid = new CursorDepthPlacer(); + const world = new CursorDepthPlacer(T, { anchor: 'world' }); + hybrid.update(at(0.6), hint, 1.0); + world.update(at(0.6), hint, 1.0); + let h, w; + for (let i = 1; i <= 60; i++) { + h = hybrid.update(at(0.5), hint, 1.0 + i * 0.016); // 10 cm nearer, pointer still + w = world.update(at(0.5), hint, 1.0 + i * 0.016); + } + assert.equal(h.anchored, true); + near(h.position[2], w.position[2], 1e-6); // same distance in front of the glass as the world placement + assert.ok(h.position[2] > 0.1, 'still in front of the content'); +});