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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@ 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.

## Unreleased

Touches the **core** `./three` (`DepthCursor`) and the **preview tier** (`./model`, `./splat`,
`./call`, `./camera`: `cursor: 'depth'`), plus `./cursor-depth` (`CURSOR_DEFAULT_TUNING`). **One
deliberate default change.**

- **Depth-cursor margin 0.03 → 0.015 eye baselines (default change),** about 1 mm of crossed
on-screen disparity at a 63–65 mm IPD instead of about 2 mm. Runtime ADR-046 Amendment 2 A.2 was
halved after a Windows panel check, because 0.03 floated visibly off the content. The runtime
makes the same change (`U_CURSOR_DEPTH_DEFAULT_MARGIN`), so web and native cursors still agree.
Every depth cursor takes the default from `CURSOR_DEFAULT_TUNING`: `DepthCursor`,
`cursor: 'depth'` on both engines, and the stereo-tile pair cursor. `margin` still overrides it:
pass `margin: 0.03` for the 1.41.0 look.

## 1.41.0 — 2026-10-11

Touches the **core** `./three` (`DepthCursor`'s look and two defaults) and the **preview tier**
Expand Down
6 changes: 3 additions & 3 deletions cursor-depth.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// same geometry, same defaults, same numbers. For three.js, ./three's `DepthCursor` wires it up.
//
// The policy (ADR-046 Amendment 2), the same for every DisplayXR depth cursor: never behind the
// glass (`cursorTarget`), margin 0.03 eye baselines, drawn only past the draw threshold
// glass (`cursorTarget`), margin 0.015 eye baselines, drawn only past the draw threshold
// (`cursorShouldDraw`; otherwise the OS cursor is the cursor), the classic arrow at the OS cursor's
// size (`CURSOR_ARROW`, `cursorArrowMesh`, `cursorDefaultHeight`).

Expand All @@ -24,7 +24,7 @@ export interface CursorTuning {
stale: number;
}

/** The defaults: margin 0.03 (~2 mm at a 65 mm IPD), clamp [−0.6, 0], rise 30 ms, sink 250 ms, stale 0.5 s. */
/** The defaults: margin 0.015 (~1 mm at a 63–65 mm IPD), clamp [−0.6, 0], rise 30 ms, sink 250 ms, stale 0.5 s. */
export const CURSOR_DEFAULT_TUNING: Readonly<CursorTuning>;
/** Fallback sprite height, as a fraction of the canvas height (0.03), when the canvas's on-page size is unknown. */
export const CURSOR_DEFAULT_HEIGHT: number;
Expand Down Expand Up @@ -79,7 +79,7 @@ export const CURSOR_ANCHOR_MODES: readonly CursorAnchorMode[];
export interface CursorOptions {
/** Sprite height as a fraction of the canvas height (default: the OS arrow's 19 CSS px of the canvas). */
height?: number;
/** How far in front of out-of-screen content it floats, in eye-baseline units (default 0.03). */
/** How far in front of out-of-screen content it floats, in eye-baseline units (default 0.015). */
margin?: number;
/** Default `'hybrid'`. */
anchor?: CursorAnchorMode;
Expand Down
4 changes: 2 additions & 2 deletions docs/authoring-inline-3d.md
Original file line number Diff line number Diff line change
Expand Up @@ -1073,8 +1073,8 @@ wall.addScene(canvas, (views, layer) => {
- *On the plane it IS the OS cursor.* Nothing is drawn unless the cursor is at least about
0.3 mm (0.005 eye baselines, and half a panel pixel) in front of the glass. Otherwise the CSS
cursor is the cursor, untouched, including any `cursor:` your page set on the canvas.
- *Margin.* `margin` (default 0.03 eye baselines, about 2 mm of on-screen disparity at a 65 mm
IPD) is how far in front of out-of-screen content it floats.
- *Margin.* `margin` (default 0.015 eye baselines, about 1 mm of on-screen disparity at a
63–65 mm IPD) is how far in front of out-of-screen content it floats.
- **What it draws.** The classic arrow (white, dark outline), at the OS cursor's size (19 CSS px of
the canvas unless you pass `height`), with its tip on the click point. It hit-tests the arrow's
footprint, not just the tip, so a nearer edge beside the hotspot can't cut through the arrow, and
Expand Down
4 changes: 2 additions & 2 deletions js/camera/pair-cursor.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
// The policy is the one every DisplayXR depth cursor follows (runtime ADR-046 Amendment 2), taken
// from the shared core (../inline3d-cursor-depth.js), not re-implemented here:
// - never behind the glass: only content that comes OUT of the screen moves it, to the margin
// (0.03 eye baselines, ~2 mm) in front of that content (cursorTarget);
// (0.015 eye baselines, ~1 mm) in front of that content (cursorTarget);
// - on the plane it IS the OS cursor: the arrow is drawn into both halves only while the filtered
// disparity is past the draw threshold (-0.005 baselines, and >= 0.5 px; cursorShouldDraw), the
// OS cursor hidden exactly then;
Expand Down Expand Up @@ -109,7 +109,7 @@ export function pairPxPerBaseline(outW, tileCssWidth, metersPerCssPx, eyeSeparat
/**
* The arrow's target disparity in output px (+ = in front): the core's policy (cursorTarget) on
* the content's disparity, converted through `pxPerBaseline`. In front of content that comes OUT
* of the screen by the margin (0.03 baselines), else the glass — never behind it.
* of the screen by the margin (0.015 baselines), else the glass — never behind it.
*/
export function pairCursorTarget(contentPx, pxPerBaseline, tuning = CURSOR_DEFAULT_TUNING) {
if (contentPx === null || !Number.isFinite(contentPx) || !(pxPerBaseline > 0)) return 0;
Expand Down
9 changes: 5 additions & 4 deletions js/inline3d-cursor-depth.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
// ./camera, which calls the pieces exported here):
// - never behind the glass: target = d < 0 ? max(d - margin, dMin) : 0 — it only comes forward,
// to stay ahead of content that comes OUT of the screen (cursorTarget);
// - margin 0.03 eye baselines (~2 mm of crossed on-screen disparity at a 65 mm IPD);
// - margin 0.015 eye baselines (~1 mm of crossed on-screen disparity at a 63-65 mm IPD);
// - on the plane it IS the OS cursor: nothing is drawn while the filtered disparity is above
// CURSOR_DRAW_THRESHOLD (-0.005, ~0.3 mm) or under CURSOR_DRAW_MIN_PX on the panel
// (cursorShouldDraw), and the CSS cursor is left alone;
Expand Down Expand Up @@ -99,10 +99,11 @@ export function cursorShouldDraw(disparity, pxPerBaseline) {
*/
export const CURSOR_DEFAULT_TUNING = Object.freeze({
/**
* How far in front of out-of-screen content the cursor floats: 0.03 eye baselines, ~2 mm of
* crossed on-screen disparity at a 65 mm IPD (ADR-046 Amendment 2; the reference app's value).
* How far in front of out-of-screen content the cursor floats: 0.015 eye baselines, ~1 mm of
* crossed on-screen disparity at a 63-65 mm IPD (ADR-046 Amendment 2 A.2; halved from 0.03 on
* 2026-10-11 after a Windows panel check: 0.03 floated visibly off the content).
*/
margin: 0.03,
margin: 0.015,
/** Comfort clamp: t >= 0.625, never more than ~3/8 of the way to the eye. */
minDisparity: -0.6,
/** Never behind the glass: 0, and cursorTarget never goes above 0 whatever this says. */
Expand Down
4 changes: 2 additions & 2 deletions js/inline3d-three.js
Original file line number Diff line number Diff line change
Expand Up @@ -489,8 +489,8 @@ export class DepthCursor {
* @param {number} [opts.height] sprite height as a fraction of the canvas height. Default: the
* OS arrow's size, 19 CSS px of the canvas's on-page height (re-read every frame).
* @param {number} [opts.color=0xffffff] the arrow's fill colour (the outline is always dark).
* @param {number} [opts.margin=0.03] how far in front of out-of-screen content it floats, in
* eye-baseline units (0.03 ≈ 2 mm of on-screen disparity at a 65 mm IPD).
* @param {number} [opts.margin=0.015] how far in front of out-of-screen content it floats, in
* eye-baseline units (0.015 ≈ 1 mm of on-screen disparity at a 63-65 mm IPD).
* @param {'hybrid'|'screen'|'world'} [opts.anchor='hybrid'] where along that depth: 'hybrid'
* follows the pointer exactly while it moves and stays world-fixed (parallaxes with the
* content) while it is still; 'screen' never parallaxes; 'world' always does.
Expand Down
2 changes: 1 addition & 1 deletion model.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export interface ModelOptions {
* `'depth'`: a cursor that is never drawn on the glass behind content that pops out (ADR-046 +
* Amendment 2, the same policy as native `XR_DXR_cursor_depth`). While the subject under the
* pointer's footprint comes OUT of the screen, the classic arrow (the OS cursor's size, tip on
* the hotspot) is drawn the margin (0.03 eye baselines, ~2 mm) in front of it and the CSS cursor
* the hotspot) is drawn the margin (0.015 eye baselines, ~1 mm) in front of it and the CSS cursor
* is hidden; over anything on or behind the glass nothing is drawn and the CSS cursor is the
* cursor, untouched. Never behind the glass. OPT-IN: without it nothing
* is loaded or run. PlayCanvas: hit-tested with the engine's depth picker (meshes and splats,
Expand Down
2 changes: 1 addition & 1 deletion splat.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -477,7 +477,7 @@ export interface SplatOptions {
* `'depth'`: a cursor that is never drawn on the glass behind content that pops out (ADR-046 +
* Amendment 2, the same policy as native `XR_DXR_cursor_depth`). While the subject under the
* pointer's footprint comes OUT of the screen, the classic arrow (the OS cursor's size, tip on
* the hotspot) is drawn the margin (0.03 eye baselines, ~2 mm) in front of it and the CSS cursor
* the hotspot) is drawn the margin (0.015 eye baselines, ~1 mm) in front of it and the CSS cursor
* is hidden; over anything on or behind the glass nothing is drawn and the CSS cursor is the
* cursor, untouched. Never behind the glass. OPT-IN: without it nothing
* is loaded or run. PlayCanvas: hit-tested with the engine's depth picker (meshes and splats,
Expand Down
7 changes: 5 additions & 2 deletions test/cursor-depth.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -394,8 +394,11 @@ import { CURSOR_ANCHOR_MODES, CursorPointer, resolveCursorOption } from '../js/i

const sub3 = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];

test('cursor-depth: the default margin is 0.03 baseline (~2 mm at 65 mm IPD), clamp [−0.6, 0]', () => {
assert.equal(T.margin, 0.03);
test('cursor-depth: the default margin is 0.015 baseline (~1 mm at 63–65 mm IPD), clamp [−0.6, 0]', () => {
// ADR-046 Amendment 2 A.2, halved from 0.03 on 2026-10-11 after a Windows panel check.
assert.equal(T.margin, 0.015);
// The behaviour, not just the field: content at d = −0.2 puts the cursor at −0.215.
assert.ok(Math.abs(cursorTarget(T, true, -0.2) - -0.215) < 1e-12);
assert.equal(T.minDisparity, -0.6);
assert.equal(T.maxDisparity, 0);
});
Expand Down
2 changes: 1 addition & 1 deletion test/e2e/cursor-depth.e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
// viewer two hand-built off-axis views (what the DisplayXR Browser hands addScene) and a scripted
// pointer. Pass =
// model (a unit cube): over the cube the arrow's disparity (its tip) is the cube front's minus the
// margin (0.03 baselines — in front of it, by exactly the ADR-046 Amendment 2 policy), and off
// margin (0.015 baselines — in front of it, by exactly the ADR-046 Amendment 2 policy), and off
// it NOTHING is drawn (the OS cursor is the cursor on the plane);
// splat (butterfly.sog; 'three' = the Spark engine): over the subject the arrow is in front of the
// glass, off it nothing is drawn;
Expand Down
2 changes: 1 addition & 1 deletion test/e2e/cursor-depth/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
DisplayXR Browser would hand addScene) and a scripted pointer, then checks the depth cursor:

over the cube → the arrow's disparity ≈ (cube front disparity − margin), i.e. in front of it
(margin 0.03 baselines, ADR-046 Amendment 2), its TIP on the placement;
(margin 0.015 baselines, ADR-046 Amendment 2 A.2), its TIP on the placement;
off the cube → NOTHING drawn (the sprite hidden) and the CSS cursor back: on the plane the
OS cursor is the cursor (the policy's draw threshold).

Expand Down
12 changes: 6 additions & 6 deletions test/pair-cursor.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
// - a mirrored view flips the pointer, never the disparity;
// - the policy is the shared core's (ADR-046 Amendment 2), not a copy: the target is cursorTarget
// on the content's disparity in eye baselines, converted through the panel's pixel pitch
// (displayMetrics) × the eye separation; margin 0.03 baselines; the draw threshold
// (displayMetrics) × the eye separation; margin 0.015 baselines; the draw threshold
// (cursorShouldDraw: −0.005 baselines and ≥ 0.5 px) decides when the arrow is drawn;
// - the cursor never goes INSIDE the display: content on or behind the glass → the plane; only
// content that comes out moves it;
Expand Down Expand Up @@ -123,9 +123,9 @@ test('footprint disparity: nothing to match (flat frame, no copy) → null; targ
assert.equal(pairCursorTarget(null, 100), 0);
});

test('target: the core policy in px — content that comes out + 0.03 baselines (clamped); never behind the glass', () => {
test('target: the core policy in px — content that comes out + 0.015 baselines (clamped); never behind the glass', () => {
const k = 100; // output px of on-screen disparity per eye baseline
assert.ok(Math.abs(pairCursorTarget(10, k) - (10 + 0.03 * k)) < 1e-9, 'the margin is 0.03 baselines');
assert.ok(Math.abs(pairCursorTarget(10, k) - (10 + 0.015 * k)) < 1e-9, 'the margin is 0.015 baselines');
assert.ok(Math.abs(pairCursorTarget(500, k) - -T.minDisparity * k) < 1e-9, 'clamped at the core minDisparity');
assert.equal(pairCursorTarget(0, k), 0, 'content on the glass: the plane');
assert.equal(pairCursorTarget(-3, k), 0, 'content behind the glass: the cursor stays on the plane, never inside');
Expand All @@ -150,10 +150,10 @@ test('units: baseline ↔ px through the panel pixel pitch (displayMetrics) and
const s = pairCursorScale(640, 320, 0.000225);
assert.ok(Math.abs(0.002 * s - (0.002 / 0.000225) * 2) < 1e-9);
assert.equal(pairCursorScale(640, 320, null), 2 / PAIR_CURSOR_CSS_PX_M, 'unknown pitch → the CSS reference px');
// The margin, 0.03 of a 63 mm eye separation = 1.89 mm on the glass.
// The margin, 0.015 of a 63 mm eye separation = 0.945 mm on the glass.
const k = pairPxPerBaseline(640, 320, 0.000225);
assert.ok(Math.abs(k - s * DISPLAY_METRICS_DEFAULTS.eyeSeparationM) < 1e-9);
assert.ok(Math.abs(pairCursorTarget(1e-9, k) / s - 0.03 * 0.063) < 1e-9, 'margin ≈ 1.9 mm on the glass');
assert.ok(Math.abs(pairCursorTarget(1e-9, k) / s - 0.015 * 0.063) < 1e-9, 'margin ≈ 0.95 mm on the glass');
});

/** A 2D context that records each filled path (the arrow's outline, then its fill). */
Expand Down Expand Up @@ -214,7 +214,7 @@ test('PairCursor: the OS arrow on the plane; the drawn arrow only over content t
const k = pairPxPerBaseline(E, 384, null);
assert.ok(Math.abs(p0b.pxPerBaseline - k) < 1e-9, 'px ↔ baselines through the tile scale (CSS reference px before displayMetrics answers)');
assert.ok(Math.abs(p0b.disparityPx - pairCursorTarget(12, k)) < 0.6, `caught up within ~100 ms (${p0.disparityPx} → ${p0b.disparityPx})`);
assert.ok(Math.abs(p0b.targetPx - (12 + 0.03 * k)) < 0.6, 'content + 0.03 baselines');
assert.ok(Math.abs(p0b.targetPx - (12 + 0.015 * k)) < 0.6, 'content + 0.015 baselines');
const tipX = g.ops.at(-4).pts[0][0];
assert.ok(Math.abs(tipX - (0.5 * E + p0b.disparityPx / 2)) < 1e-9, 'left copy +d/2 about the hotspot');
assert.equal(g.ops.at(-4).pts[0][1], 0.5 * H, 'the TIP is on the hotspot row');
Expand Down
Loading