Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 14 additions & 9 deletions js/inline3d-cursor-depth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -220,22 +223,22 @@ 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;
}
const a = asView(views[0]);
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;
}
Expand All @@ -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;
}
}
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@displayxr/inline3d",
"version": "1.37.0",
"version": "1.37.1",
"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).",
"type": "module",
"types": "./index.d.ts",
Expand Down
45 changes: 45 additions & 0 deletions test/cursor-depth.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Loading