@displayxr/inline3d 1.31.0 → 1.32.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 +49 -0
- package/dist/call.js +4 -4
- package/index.d.ts +36 -0
- package/js/inline3d-display-metrics.js +51 -0
- package/js/inline3d-splat-deferred.js +12 -0
- package/js/inline3d-splat-effects.js +92 -2
- package/js/inline3d-splat-playcanvas.js +270 -3
- package/js/inline3d-splat.js +14 -0
- package/js/inline3d.js +21 -0
- package/package.json +2 -1
- package/splat.d.ts +69 -1
package/index.d.ts
CHANGED
|
@@ -123,6 +123,35 @@ export interface XRDisplayInfo {
|
|
|
123
123
|
nominalViewerPosition?: DOMPointReadOnly;
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
/** {@link TileHandle.displayMetrics}: a window in physical units. Metres throughout. */
|
|
127
|
+
export interface DisplayMetrics {
|
|
128
|
+
/** The canvas's CSS box on the panel, [width, height]. */
|
|
129
|
+
canvasSizeM: [number, number];
|
|
130
|
+
/** One CSS px on the panel (physical pixel pitch × devicePixelRatio). */
|
|
131
|
+
metersPerCssPx: number;
|
|
132
|
+
displaySizeM: [number, number];
|
|
133
|
+
/** `nominalViewerPosition.z`, else the default. */
|
|
134
|
+
nominalViewerM: number;
|
|
135
|
+
/** Always the default today: no browser or runtime surface reports one. */
|
|
136
|
+
eyeSeparationM: number;
|
|
137
|
+
source: { size: 'display' | 'default'; viewer: 'display' | 'default'; eyeSeparation: 'default' };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** What {@link TileHandle.displayMetrics} fills in when the display cannot say. */
|
|
141
|
+
export const DISPLAY_METRICS_DEFAULTS: Readonly<{
|
|
142
|
+
displaySizeM: readonly [number, number];
|
|
143
|
+
displayPixels: readonly [number, number];
|
|
144
|
+
nominalViewerM: number;
|
|
145
|
+
eyeSeparationM: number;
|
|
146
|
+
}>;
|
|
147
|
+
|
|
148
|
+
/** {@link TileHandle.displayMetrics} as a pure function of an XRDisplayInfo, a client rect and devicePixelRatio. */
|
|
149
|
+
export function displayMetricsFrom(
|
|
150
|
+
info: XRDisplayInfo | null,
|
|
151
|
+
rect: { width: number; height: number } | null,
|
|
152
|
+
dpr?: number,
|
|
153
|
+
): DisplayMetrics;
|
|
154
|
+
|
|
126
155
|
/**
|
|
127
156
|
* One rendering mode the DISPLAY can be put in, as reported by the runtime.
|
|
128
157
|
*
|
|
@@ -310,6 +339,13 @@ export interface TileHandle {
|
|
|
310
339
|
* tile off screen).
|
|
311
340
|
*/
|
|
312
341
|
getDisplayInfo(): Promise<XRDisplayInfo | null>;
|
|
342
|
+
/**
|
|
343
|
+
* This window in physical units — canvas size in metres, metres per CSS px, the panel, the
|
|
344
|
+
* nominal viewer distance, the eye separation — from {@link getDisplayInfo}, with anything not
|
|
345
|
+
* measurable filled from {@link DISPLAY_METRICS_DEFAULTS} and flagged in `source`. Never rejects.
|
|
346
|
+
* The canvas size is read at the call: call again after a resize.
|
|
347
|
+
*/
|
|
348
|
+
displayMetrics(): Promise<DisplayMetrics>;
|
|
313
349
|
/** Every rendering mode the display can be put in. See {@link XRDisplayRenderingMode}. */
|
|
314
350
|
getRenderingModes(): Promise<ReadonlyArray<XRDisplayRenderingMode>>;
|
|
315
351
|
/**
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// inline3d-display-metrics.js — a tile in PHYSICAL units, from XRDisplayInfo.
|
|
2
|
+
//
|
|
3
|
+
// What TileHandle.displayMetrics() resolves, as a pure function, so ./splat's depth envelope (and
|
|
4
|
+
// a page) can share it without pulling in the core. Dependency-free; re-exported by ./inline3d.js.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The defaults displayMetrics() fills in what the display cannot report: a 15.6" 16:9 panel at
|
|
8
|
+
* 1920x1080 CSS px, the viewer at 650 mm, eyes 63 mm apart. EYE SEPARATION IS ALWAYS A DEFAULT —
|
|
9
|
+
* no browser or runtime surface reports one (the runtime tracks eye POSITIONS; a page cannot read
|
|
10
|
+
* them) — so `source.eyeSeparation` is always 'default' today.
|
|
11
|
+
*/
|
|
12
|
+
export const DISPLAY_METRICS_DEFAULTS = Object.freeze({
|
|
13
|
+
displaySizeM: Object.freeze([0.3456, 0.1944]),
|
|
14
|
+
displayPixels: Object.freeze([1920, 1080]),
|
|
15
|
+
nominalViewerM: 0.65,
|
|
16
|
+
eyeSeparationM: 0.063,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* displayMetrics() as a pure function: `info` an XRDisplayInfo (or null), `rect` the canvas's
|
|
21
|
+
* client rect (CSS px), `dpr` window.devicePixelRatio. Physical px pitch = displayWidthMeters /
|
|
22
|
+
* displayPixelWidth (the same per axis), and one CSS px is `dpr` physical px — exact when the
|
|
23
|
+
* browser window is on the panel at its native resolution, which is the only place a woven tile
|
|
24
|
+
* draws in 3D.
|
|
25
|
+
*/
|
|
26
|
+
export function displayMetricsFrom(info, rect, dpr = 1) {
|
|
27
|
+
const D = DISPLAY_METRICS_DEFAULTS;
|
|
28
|
+
const ok =
|
|
29
|
+
!!info &&
|
|
30
|
+
info.displayWidthMeters > 0 &&
|
|
31
|
+
info.displayHeightMeters > 0 &&
|
|
32
|
+
info.displayPixelWidth > 0 &&
|
|
33
|
+
info.displayPixelHeight > 0;
|
|
34
|
+
const displaySizeM = ok ? [info.displayWidthMeters, info.displayHeightMeters] : [...D.displaySizeM];
|
|
35
|
+
const px = ok ? [info.displayPixelWidth, info.displayPixelHeight] : [...D.displayPixels];
|
|
36
|
+
// Per axis, then their mean: a panel's pixels are square to well under a percent, and one
|
|
37
|
+
// number per CSS px is what a page converting a DOM rect wants.
|
|
38
|
+
const pitch = ((displaySizeM[0] / px[0]) + (displaySizeM[1] / px[1])) / 2;
|
|
39
|
+
const d = dpr > 0 ? dpr : 1;
|
|
40
|
+
const metersPerCssPx = ok ? pitch * d : pitch * (px[1] / Math.max(1, globalThis.innerHeight || px[1]));
|
|
41
|
+
const z = info?.nominalViewerPosition?.z;
|
|
42
|
+
const viewerOk = Number.isFinite(z) && z > 0.1;
|
|
43
|
+
return {
|
|
44
|
+
canvasSizeM: [(rect?.width || 0) * metersPerCssPx, (rect?.height || 0) * metersPerCssPx],
|
|
45
|
+
metersPerCssPx,
|
|
46
|
+
displaySizeM,
|
|
47
|
+
nominalViewerM: viewerOk ? z : D.nominalViewerM,
|
|
48
|
+
eyeSeparationM: D.eyeSeparationM,
|
|
49
|
+
source: { size: ok ? 'display' : 'default', viewer: viewerOk ? 'display' : 'default', eyeSeparation: 'default' },
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -91,6 +91,18 @@ export function addSplatDeferred(wall, canvas, src, opts) {
|
|
|
91
91
|
validateSetRigArgs(type, o);
|
|
92
92
|
return out.ready.then(() => out.setRig(type, o));
|
|
93
93
|
},
|
|
94
|
+
// Stereo strength / the depth envelope before the adapter has loaded: queued, replayed in
|
|
95
|
+
// order once it has (both are sticky handle state, applied to the first asset from its first
|
|
96
|
+
// frame). Validated by the adapter at replay.
|
|
97
|
+
setStereo: (...args) => {
|
|
98
|
+
pending.push(['setStereo', args]);
|
|
99
|
+
return out;
|
|
100
|
+
},
|
|
101
|
+
setDepthEnvelope: (...args) => {
|
|
102
|
+
pending.push(['setDepthEnvelope', args]);
|
|
103
|
+
return out;
|
|
104
|
+
},
|
|
105
|
+
displayMetrics: () => out.ready.then(() => out.displayMetrics()),
|
|
94
106
|
// A video before the first asset has landed: controls:'page' throws at the call's own line,
|
|
95
107
|
// the rest is validated by the adapter's setVideo, which runs once the asset is on screen.
|
|
96
108
|
setVideo: page ? pageOnly('setVideo') : (src, o) => out.ready.then(() => out.setVideo(src, o)),
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
//
|
|
24
24
|
// The SDK owns the chunk: every active effect of a scope is ONE function body with its own
|
|
25
25
|
// uniform prefix, and the chunk calls them in a FIXED order, STAGE_ORDER: grade → clip → reveal →
|
|
26
|
-
// pulse → custom. Removing the last effect deletes the chunk (tile) or the modifier (entity),
|
|
26
|
+
// pulse → custom → envelope (→ cull). Removing the last effect deletes the chunk (tile) or the modifier (entity),
|
|
27
27
|
// which restores the engine's own default — the exact baseline, not a no-op look-alike.
|
|
28
28
|
//
|
|
29
29
|
// Adding an effect = one GLSL body + one registry entry in EFFECTS below: `glsl(P, opts)` defines
|
|
@@ -47,7 +47,16 @@
|
|
|
47
47
|
import { coverageExponent, FADE_TRANSMITTANCE_FLOOR } from './inline3d-splat-shared.js';
|
|
48
48
|
|
|
49
49
|
/** The fixed composition order of a generated chunk. */
|
|
50
|
-
export const STAGE_ORDER = Object.freeze(['grade', 'clip', 'reveal', 'pulse', 'custom', 'cull']);
|
|
50
|
+
export const STAGE_ORDER = Object.freeze(['grade', 'clip', 'reveal', 'pulse', 'custom', 'envelope', 'cull']);
|
|
51
|
+
|
|
52
|
+
/** handle.setDepthEnvelope's flat rects the shader takes (the largest-weight first beyond it). */
|
|
53
|
+
export const ENVELOPE_MAX_RECTS = 8;
|
|
54
|
+
const ENVELOPE_NO_RECT = Object.freeze([1, -1, 1, -1]);
|
|
55
|
+
const ENVELOPE_OFF = (() => {
|
|
56
|
+
const o = { on: 0, O: [0, 0, 0], A: [0, 0, -1], R: [1, 0, 0], U: [0, 1, 0], T: [1, 1, 1, 1], K: [0, 1, 1, 0], S: [-1, 1, -1, 1], E: [1, 0, 0, 0], W0: [0, 0, 0, 0], W1: [0, 0, 0, 0] };
|
|
57
|
+
for (let i = 0; i < ENVELOPE_MAX_RECTS; i++) o['F' + i] = ENVELOPE_NO_RECT;
|
|
58
|
+
return Object.freeze(o);
|
|
59
|
+
})();
|
|
51
60
|
|
|
52
61
|
/** The most eye views setSource's wavefront culls for (more: every gaussian is drawn). */
|
|
53
62
|
export const WIPE_CULL_MAX_VIEWS = 4;
|
|
@@ -603,6 +612,87 @@ void ${P}color(vec3 c, inout vec4 col) { if (${P}cut) col.a = 0.0; }
|
|
|
603
612
|
},
|
|
604
613
|
},
|
|
605
614
|
|
|
615
|
+
// Internal: handle.setDepthEnvelope — THE FRAME ENVELOPE. Content may come OUT of the glass only
|
|
616
|
+
// well inside the frame: a gaussian whose panel depth would exceed the cap at its screen spot
|
|
617
|
+
// F(x, y) = edge + (maxFront − edge) · e, e = smoothstep(0, band, distance to the stage edge or
|
|
618
|
+
// to a flat rect (weighted)), in metres on the panel,
|
|
619
|
+
// slides back ALONG ITS OWN RAY from the declared camera rig's centre (centre and scale × λ), so
|
|
620
|
+
// the rig's mono picture is unchanged and the engine's sort order stays valid. On a camera rig a
|
|
621
|
+
// point at depth d has panel disparity k·e·f_p·(1/D − 1/d) (k = ipdFactor·metersToVirtual per
|
|
622
|
+
// world unit, f_p = canvas height / (2·tan(vfov/2)), D the convergence), and F metres out needs
|
|
623
|
+
// −e·F/(n − F) — so the deepest-out allowed depth is 1/d_env = 1/D + F / (k·f_p·(n − F)); the
|
|
624
|
+
// viewer's eye separation e cancels. Behind the glass is never touched. Keyed on world position
|
|
625
|
+
// and the RIG (not the tracked eyes), so both eyes and every head position agree. The adapter
|
|
626
|
+
// supplies everything per tick through opts.state() (null = off); world space throughout.
|
|
627
|
+
envelope: {
|
|
628
|
+
stage: 'envelope',
|
|
629
|
+
kind: 'persistent',
|
|
630
|
+
internal: true,
|
|
631
|
+
defaults: {},
|
|
632
|
+
glsl: (P) => `
|
|
633
|
+
uniform float ${P}on;
|
|
634
|
+
uniform vec3 ${P}O;
|
|
635
|
+
uniform vec3 ${P}A;
|
|
636
|
+
uniform vec3 ${P}R;
|
|
637
|
+
uniform vec3 ${P}U;
|
|
638
|
+
uniform vec4 ${P}T; // tan half-fov x, y; canvas half-size x, y (m)
|
|
639
|
+
uniform vec4 ${P}K; // 1/D (world), k·f_p (world·m), viewer n (m), maxFront (m)
|
|
640
|
+
uniform vec4 ${P}S; // stage l, r, b, t (canvas-normalised, y up)
|
|
641
|
+
uniform vec4 ${P}E; // band (m), edge (m), rect count, -
|
|
642
|
+
${[0, 1, 2, 3, 4, 5, 6, 7].map((i) => `uniform vec4 ${P}F${i};`).join('\n')}
|
|
643
|
+
uniform vec4 ${P}W0;
|
|
644
|
+
uniform vec4 ${P}W1;
|
|
645
|
+
float ${P}lambda;
|
|
646
|
+
float ${P}flat(vec2 f, vec4 C, float w) {
|
|
647
|
+
if (w <= 0.0 || C.x >= C.y || C.z >= C.w) return 1.0;
|
|
648
|
+
vec2 o = max(vec2(max(C.x - f.x, f.x - C.y) * ${P}T.z, max(C.z - f.y, f.y - C.w) * ${P}T.w), vec2(0.0));
|
|
649
|
+
return mix(1.0, smoothstep(0.0, ${P}E.x, length(o)), w);
|
|
650
|
+
}
|
|
651
|
+
void ${P}center(inout vec3 c) {
|
|
652
|
+
${P}lambda = 1.0;
|
|
653
|
+
if (${P}on < 0.5) return;
|
|
654
|
+
vec3 v = c - ${P}O;
|
|
655
|
+
float d = dot(v, ${P}A);
|
|
656
|
+
if (d <= 1e-4) return;
|
|
657
|
+
vec2 f = vec2(dot(v, ${P}R) / (d * ${P}T.x), dot(v, ${P}U) / (d * ${P}T.y));
|
|
658
|
+
vec4 S = ${P}S;
|
|
659
|
+
float inside = min(min(f.x - S.x, S.y - f.x) * ${P}T.z, min(f.y - S.z, S.w - f.y) * ${P}T.w);
|
|
660
|
+
float e = smoothstep(0.0, ${P}E.x, inside);
|
|
661
|
+
${[0, 1, 2, 3, 4, 5, 6, 7].map((i) => ` e = min(e, ${P}flat(f, ${P}F${i}, ${P}W${i >> 2}.${'xyzw'[i & 3]}));`).join('\n')}
|
|
662
|
+
float F = ${P}E.y + max(${P}K.w - ${P}E.y, 0.0) * e;
|
|
663
|
+
float invEnv = ${P}K.x + F / (${P}K.y * max(${P}K.z - F, 1e-3));
|
|
664
|
+
if (1.0 / d <= invEnv) return;
|
|
665
|
+
${P}lambda = 1.0 / (invEnv * d);
|
|
666
|
+
c = ${P}O + v * ${P}lambda;
|
|
667
|
+
}
|
|
668
|
+
void ${P}rs(vec3 oc, vec3 mc, inout vec4 r, inout vec3 sc) { sc *= ${P}lambda; }
|
|
669
|
+
void ${P}color(vec3 c, inout vec4 col) {}
|
|
670
|
+
`,
|
|
671
|
+
uniforms: (ctx, inst) => {
|
|
672
|
+
const st = typeof inst.opts.state === 'function' ? inst.opts.state() : null;
|
|
673
|
+
if (!st) return ENVELOPE_OFF;
|
|
674
|
+
const out = {
|
|
675
|
+
on: 1,
|
|
676
|
+
O: st.origin,
|
|
677
|
+
A: st.axis,
|
|
678
|
+
R: st.right,
|
|
679
|
+
U: st.up,
|
|
680
|
+
T: [st.tanX, st.tanY, st.halfW, st.halfH],
|
|
681
|
+
K: [st.invD, st.kfp, st.viewer, st.maxFront],
|
|
682
|
+
S: st.stage,
|
|
683
|
+
E: [st.band, st.edge, st.rects.length, 0],
|
|
684
|
+
W0: [0, 0, 0, 0],
|
|
685
|
+
W1: [0, 0, 0, 0],
|
|
686
|
+
};
|
|
687
|
+
for (let i = 0; i < ENVELOPE_MAX_RECTS; i++) {
|
|
688
|
+
const r = st.rects[i];
|
|
689
|
+
out['F' + i] = r ? r.f : ENVELOPE_NO_RECT;
|
|
690
|
+
if (r) out[i < 4 ? 'W0' : 'W1'][i & 3] = r.weight;
|
|
691
|
+
}
|
|
692
|
+
return out;
|
|
693
|
+
},
|
|
694
|
+
},
|
|
695
|
+
|
|
606
696
|
// Internal: setSource's crossfade FALLBACK (no frame snapshot) — the coverage remap on one
|
|
607
697
|
// entity, driven by the adapter.
|
|
608
698
|
xfade: {
|
|
@@ -51,8 +51,10 @@ import {
|
|
|
51
51
|
} from './inline3d-splat-perf.js';
|
|
52
52
|
import { boundsFromPositions, boundsFromPositionsAsync } from './inline3d-viewer.js';
|
|
53
53
|
import { cameraRigFromPose, displayRig } from './inline3d-three.js';
|
|
54
|
+
import { displayMetricsFrom } from './inline3d-display-metrics.js';
|
|
54
55
|
import {
|
|
55
56
|
SplatEffects,
|
|
57
|
+
ENVELOPE_MAX_RECTS,
|
|
56
58
|
EFFECTS,
|
|
57
59
|
EASINGS,
|
|
58
60
|
resolveRevealOption,
|
|
@@ -2312,6 +2314,26 @@ export class PlayCanvasSplatViewer {
|
|
|
2312
2314
|
};
|
|
2313
2315
|
}
|
|
2314
2316
|
|
|
2317
|
+
/**
|
|
2318
|
+
* The MONO camera's frame in WORLD (content) space — the rig the page declared, not the tracked
|
|
2319
|
+
* eyes: `origin`, unit `axis`/`right`/`up`, and `scale` = world units per rig unit (the rig
|
|
2320
|
+
* node's uniform scale). The depth envelope keys on it, so it holds still under head motion.
|
|
2321
|
+
*/
|
|
2322
|
+
monoFrame() {
|
|
2323
|
+
const M = mat4Mul(this.rigMatrix(), this.mono.pose);
|
|
2324
|
+
const len = (x, y, z) => Math.hypot(x, y, z) || 1;
|
|
2325
|
+
const sx = len(M[0], M[1], M[2]);
|
|
2326
|
+
const sy = len(M[4], M[5], M[6]);
|
|
2327
|
+
const sz = len(M[8], M[9], M[10]);
|
|
2328
|
+
return {
|
|
2329
|
+
origin: [M[12], M[13], M[14]],
|
|
2330
|
+
axis: [-M[8] / sz, -M[9] / sz, -M[10] / sz],
|
|
2331
|
+
right: [M[0] / sx, M[1] / sx, M[2] / sx],
|
|
2332
|
+
up: [M[4] / sy, M[5] / sy, M[6] / sy],
|
|
2333
|
+
scale: Math.cbrt(sx * sy * sz),
|
|
2334
|
+
};
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2315
2337
|
_easeFocus() {
|
|
2316
2338
|
if (this._focusSettled) return;
|
|
2317
2339
|
const f = this._focus;
|
|
@@ -2811,6 +2833,95 @@ const PAGE_SETRIG_ERROR =
|
|
|
2811
2833
|
"@displayxr/inline3d/splat: setRig() is not available with controls:'page' — the page owns the " +
|
|
2812
2834
|
'camera (its camera IS the rig). Drive it with handle.setCameraPose(matrixWorld, { verticalFovDeg, near, far }).';
|
|
2813
2835
|
|
|
2836
|
+
/** handle.setDepthEnvelope's defaults, metres on the panel (Ride Spatial's depth rules v2). */
|
|
2837
|
+
export const DEPTH_ENVELOPE_DEFAULTS = Object.freeze({ maxFrontM: 0.02, edgeM: 0.001, bandM: 0.016 });
|
|
2838
|
+
|
|
2839
|
+
/**
|
|
2840
|
+
* handle.setDepthEnvelope's argument, validated and merged onto the current one (throws at the
|
|
2841
|
+
* call). A key given replaces; `null` (the whole argument) turns the envelope off. Rects are kept
|
|
2842
|
+
* as given (canvas fractions, y DOWN) plus `f` — [l, r, b, t] canvas-normalised, y UP — for the
|
|
2843
|
+
* shader; more than ENVELOPE_MAX_RECTS keeps the heaviest (then largest) ones.
|
|
2844
|
+
*/
|
|
2845
|
+
export function validateDepthEnvelope(o, prev) {
|
|
2846
|
+
if (o === null) return null;
|
|
2847
|
+
const E = '@displayxr/inline3d/splat: setDepthEnvelope';
|
|
2848
|
+
if (typeof o !== 'object' || Array.isArray(o)) throw new TypeError(`${E} takes an object or null.`);
|
|
2849
|
+
const keys = ['maxFrontM', 'edgeM', 'bandM', 'stage', 'rects', 'viewerM', 'canvasSizeM'];
|
|
2850
|
+
const unknown = Object.keys(o).filter((k) => !keys.includes(k));
|
|
2851
|
+
if (unknown.length) throw new Error(`${E} — unknown option(s) ${unknown.join(', ')}.`);
|
|
2852
|
+
const next = { ...DEPTH_ENVELOPE_DEFAULTS, stage: { x: 0, y: 0, w: 1, h: 1 }, rects: [], viewerM: null, canvasSizeM: null, ...(prev || {}) };
|
|
2853
|
+
const num = (k, v, min, max = Infinity) => {
|
|
2854
|
+
if (typeof v !== 'number' || !Number.isFinite(v) || v < min || v > max) throw new RangeError(`${E} — ${k} must be a finite number in [${min}, ${max}] (got ${v}).`);
|
|
2855
|
+
return v;
|
|
2856
|
+
};
|
|
2857
|
+
const rect = (r, what) => {
|
|
2858
|
+
if (!r || typeof r !== 'object') throw new TypeError(`${E} — ${what} must be { x, y, w, h }.`);
|
|
2859
|
+
const x = num(`${what}.x`, r.x, -10, 10);
|
|
2860
|
+
const y = num(`${what}.y`, r.y, -10, 10);
|
|
2861
|
+
const w = num(`${what}.w`, r.w, 0, 20);
|
|
2862
|
+
const h = num(`${what}.h`, r.h, 0, 20);
|
|
2863
|
+
return { x, y, w, h, f: [2 * x - 1, 2 * (x + w) - 1, 1 - 2 * (y + h), 1 - 2 * y] };
|
|
2864
|
+
};
|
|
2865
|
+
for (const k of ['maxFrontM', 'edgeM', 'bandM']) if (k in o && o[k] !== undefined) next[k] = num(k, o[k], k === 'bandM' ? 1e-5 : 0, 1);
|
|
2866
|
+
if ('viewerM' in o && o.viewerM !== undefined) next.viewerM = o.viewerM === null ? null : num('viewerM', o.viewerM, 0.05, 10);
|
|
2867
|
+
if ('canvasSizeM' in o && o.canvasSizeM !== undefined) {
|
|
2868
|
+
const c = o.canvasSizeM;
|
|
2869
|
+
if (c === null) next.canvasSizeM = null;
|
|
2870
|
+
else {
|
|
2871
|
+
if (!Array.isArray(c) || c.length !== 2) throw new TypeError(`${E} — canvasSizeM must be [width, height] in metres, or null.`);
|
|
2872
|
+
next.canvasSizeM = [num('canvasSizeM[0]', c[0], 1e-4, 10), num('canvasSizeM[1]', c[1], 1e-4, 10)];
|
|
2873
|
+
}
|
|
2874
|
+
}
|
|
2875
|
+
if ('stage' in o && o.stage !== undefined) next.stage = o.stage === null ? { x: 0, y: 0, w: 1, h: 1 } : rect(o.stage, 'stage');
|
|
2876
|
+
next.stage = next.stage.f ? next.stage : rect(next.stage, 'stage');
|
|
2877
|
+
if ('rects' in o && o.rects !== undefined) {
|
|
2878
|
+
if (o.rects !== null && !Array.isArray(o.rects)) throw new TypeError(`${E} — rects must be an array.`);
|
|
2879
|
+
next.rects = (o.rects || [])
|
|
2880
|
+
.map((r, i) => ({ ...rect(r, `rects[${i}]`), weight: r.weight === undefined ? 1 : num(`rects[${i}].weight`, r.weight, 0, 1) }))
|
|
2881
|
+
.filter((r) => r.weight > 0 && r.w > 0 && r.h > 0)
|
|
2882
|
+
.sort((a, b) => b.weight - a.weight || b.w * b.h - a.w * a.h)
|
|
2883
|
+
.slice(0, ENVELOPE_MAX_RECTS);
|
|
2884
|
+
}
|
|
2885
|
+
if (next.edgeM > next.maxFrontM) next.edgeM = next.maxFrontM;
|
|
2886
|
+
return next;
|
|
2887
|
+
}
|
|
2888
|
+
|
|
2889
|
+
/**
|
|
2890
|
+
* handle.setStereo's argument, validated and merged onto the current override (throws at the
|
|
2891
|
+
* call). Returns the new override, or null when nothing is overridden.
|
|
2892
|
+
*/
|
|
2893
|
+
export function validateSetStereo(o, prev) {
|
|
2894
|
+
if (o === null) return null;
|
|
2895
|
+
if (typeof o !== 'object') throw new TypeError('@displayxr/inline3d/splat: setStereo takes an object or null.');
|
|
2896
|
+
// Absolute (…Factor) or RELATIVE to the asset's own (…Scale); per scalar, the last one given wins.
|
|
2897
|
+
const pairs = [
|
|
2898
|
+
['ipdFactor', 'ipdScale'],
|
|
2899
|
+
['parallaxFactor', 'parallaxScale'],
|
|
2900
|
+
];
|
|
2901
|
+
const keys = pairs.flat();
|
|
2902
|
+
const unknown = Object.keys(o).filter((k) => !keys.includes(k));
|
|
2903
|
+
if (unknown.length) throw new Error(`@displayxr/inline3d/splat: setStereo — unknown option(s) ${unknown.join(', ')}.`);
|
|
2904
|
+
for (const [a, b] of pairs) {
|
|
2905
|
+
if (o[a] != null && o[b] != null) throw new Error(`@displayxr/inline3d/splat: setStereo — ${a} and ${b} are exclusive.`);
|
|
2906
|
+
}
|
|
2907
|
+
const next = { ...(prev || {}) };
|
|
2908
|
+
for (const [a, b] of pairs) {
|
|
2909
|
+
for (const k of [a, b]) {
|
|
2910
|
+
if (!(k in o) || o[k] === undefined) continue;
|
|
2911
|
+
if (o[k] === null) {
|
|
2912
|
+
delete next[k];
|
|
2913
|
+
continue;
|
|
2914
|
+
}
|
|
2915
|
+
if (typeof o[k] !== 'number' || !Number.isFinite(o[k]) || o[k] < 0) {
|
|
2916
|
+
throw new RangeError(`@displayxr/inline3d/splat: setStereo — ${k} must be a finite number >= 0 (got ${o[k]}).`);
|
|
2917
|
+
}
|
|
2918
|
+
next[k] = o[k];
|
|
2919
|
+
delete next[k === a ? b : a];
|
|
2920
|
+
}
|
|
2921
|
+
}
|
|
2922
|
+
return Object.keys(next).length ? next : null;
|
|
2923
|
+
}
|
|
2924
|
+
|
|
2814
2925
|
/**
|
|
2815
2926
|
* handle.setRig's arguments, validated and resolved (throws at the call, before anything runs).
|
|
2816
2927
|
* @returns {{type:'display'|'camera'|'auto', o?:object}}
|
|
@@ -3218,6 +3329,21 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3218
3329
|
setSource,
|
|
3219
3330
|
prepareSource,
|
|
3220
3331
|
setRig,
|
|
3332
|
+
setStereo,
|
|
3333
|
+
setDepthEnvelope,
|
|
3334
|
+
/**
|
|
3335
|
+
* This tile in physical units (core TileHandle.displayMetrics, from getDisplayInfo()). Never
|
|
3336
|
+
* rejects: defaults, flagged in `source`, where the display cannot say.
|
|
3337
|
+
*/
|
|
3338
|
+
async displayMetrics() {
|
|
3339
|
+
let info = null;
|
|
3340
|
+
try {
|
|
3341
|
+
info = (await handle?.getDisplayInfo?.()) ?? null;
|
|
3342
|
+
} catch {
|
|
3343
|
+
/* no display API / no live layer: the defaults */
|
|
3344
|
+
}
|
|
3345
|
+
return displayMetricsFrom(info, canvas.getBoundingClientRect(), globalThis.devicePixelRatio || 1);
|
|
3346
|
+
},
|
|
3221
3347
|
setVideo,
|
|
3222
3348
|
/**
|
|
3223
3349
|
* Draw a layer of this tile's engine through the DISPLAY rig (round, physical-depth stage
|
|
@@ -3950,6 +4076,9 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3950
4076
|
canvasAspect: box.height > 0 ? box.width / box.height : 4 / 3,
|
|
3951
4077
|
});
|
|
3952
4078
|
if (type !== 'auto') resolved.typeSource = 'setRig';
|
|
4079
|
+
// What the asset (or addSplat's options) asked for, kept so setStereo(null) can go back to it.
|
|
4080
|
+
resolved.stereoDefault = { ipdFactor: resolved.ipdFactor, parallaxFactor: resolved.parallaxFactor };
|
|
4081
|
+
applyStereoTo(resolved);
|
|
3953
4082
|
resolved.focusDefault = resolved.focus.slice();
|
|
3954
4083
|
resolved.focusDefaultSource = resolved.focusSource;
|
|
3955
4084
|
return resolved;
|
|
@@ -3966,7 +4095,14 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
3966
4095
|
}
|
|
3967
4096
|
|
|
3968
4097
|
/** Declare a DISPLAY rig to the runtime, unless that exact one is already declared. */
|
|
3969
|
-
function declareDisplay(
|
|
4098
|
+
function declareDisplay(base, { raw = false } = {}) {
|
|
4099
|
+
// raw: setVideo's own display rig — not the page's rig, so neither setStereo's factors nor
|
|
4100
|
+
// the base setStereo re-declares from.
|
|
4101
|
+
if (!raw) lastDisplayBase = base;
|
|
4102
|
+
const f =
|
|
4103
|
+
stereoOverride && !raw
|
|
4104
|
+
? { ...base, ipdFactor: stereoScalar(base.ipdFactor ?? 1, 'ipd'), parallaxFactor: stereoScalar(base.parallaxFactor ?? 1, 'parallax') }
|
|
4105
|
+
: base;
|
|
3970
4106
|
const key = `${f.vH}|${f.ipdFactor ?? 1}|${f.parallaxFactor ?? 1}|${f.perspectiveFactor ?? 1}`;
|
|
3971
4107
|
if (declaredDisplay === key) return;
|
|
3972
4108
|
// Back at the boot rig from the shorthand it was built with: nothing to say.
|
|
@@ -4033,6 +4169,132 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
4033
4169
|
declareDisplay(disp || { vH: bootFraming.vH });
|
|
4034
4170
|
}
|
|
4035
4171
|
|
|
4172
|
+
// ── handle.setStereo: live stereo strength on whatever rig is declared ──
|
|
4173
|
+
/** { ipdFactor?, parallaxFactor? } set by handle.setStereo — sticky across setSource; null = none. */
|
|
4174
|
+
let stereoOverride = null;
|
|
4175
|
+
/** The display rig declareDisplay was last asked for, BEFORE setStereo's factors (to re-declare). */
|
|
4176
|
+
let lastDisplayBase = null;
|
|
4177
|
+
/** setStereo's factors onto a resolved rig, over the asset's own (stereoDefault). */
|
|
4178
|
+
function applyStereoTo(r) {
|
|
4179
|
+
const d = r.stereoDefault;
|
|
4180
|
+
r.ipdFactor = stereoScalar(d.ipdFactor, 'ipd');
|
|
4181
|
+
r.parallaxFactor = stereoScalar(d.parallaxFactor, 'parallax');
|
|
4182
|
+
}
|
|
4183
|
+
/** One scalar under setStereo: its absolute value, else `base` × its scale, else `base`. */
|
|
4184
|
+
function stereoScalar(base, k) {
|
|
4185
|
+
const o = stereoOverride;
|
|
4186
|
+
if (o?.[`${k}Factor`] != null) return o[`${k}Factor`];
|
|
4187
|
+
if (o?.[`${k}Scale`] != null) return base * o[`${k}Scale`];
|
|
4188
|
+
return base;
|
|
4189
|
+
}
|
|
4190
|
+
|
|
4191
|
+
/**
|
|
4192
|
+
* handle.setStereo({ ipdFactor, parallaxFactor }) — re-declare the rig in force with new stereo
|
|
4193
|
+
* scalars, next frame, with no cut: the pose, focus and framing stay. A key given replaces, a key
|
|
4194
|
+
* set to null goes back to the asset's own value, `null` clears both. Sticky across setSource and
|
|
4195
|
+
* setRig. On a camera rig the values are ABSOLUTE (like addSplat's options); on a display rig
|
|
4196
|
+
* they replace the declared display rig's factors.
|
|
4197
|
+
*/
|
|
4198
|
+
function setStereo(o) {
|
|
4199
|
+
const next = validateSetStereo(o, stereoOverride);
|
|
4200
|
+
stereoOverride = next;
|
|
4201
|
+
if (out.rig?.stereoDefault) applyStereoTo(out.rig);
|
|
4202
|
+
redeclareStereo();
|
|
4203
|
+
return out;
|
|
4204
|
+
}
|
|
4205
|
+
/** Re-declare the rig in force with setStereo's factors (deduplicated on a display rig). */
|
|
4206
|
+
function redeclareStereo() {
|
|
4207
|
+
if (pageMode || vid?.on || !out.rig) return; // pageTick reads out.rig; video exit calls this
|
|
4208
|
+
if (out.rig.type === 'camera') pushViewRig(true);
|
|
4209
|
+
else if (lastDisplayBase) declareDisplay(lastDisplayBase);
|
|
4210
|
+
}
|
|
4211
|
+
|
|
4212
|
+
// ── handle.setDepthEnvelope: the frame envelope (./inline3d-splat-effects.js EFFECTS.envelope) ──
|
|
4213
|
+
/** validateDepthEnvelope's result, or null (off). */
|
|
4214
|
+
let envelope = null;
|
|
4215
|
+
/** displayMetricsFrom(getDisplayInfo()) — metres per CSS px and the viewer; defaults until it lands. */
|
|
4216
|
+
let envMetrics = null;
|
|
4217
|
+
let envMetricsSeq = 0;
|
|
4218
|
+
function refreshEnvelopeMetrics() {
|
|
4219
|
+
const seq = ++envMetricsSeq;
|
|
4220
|
+
const done = (info) => {
|
|
4221
|
+
if (seq === envMetricsSeq) envMetrics = displayMetricsFrom(info ?? null, null, globalThis.devicePixelRatio || 1);
|
|
4222
|
+
};
|
|
4223
|
+
let p = null;
|
|
4224
|
+
try {
|
|
4225
|
+
p = handle?.getDisplayInfo?.();
|
|
4226
|
+
} catch {
|
|
4227
|
+
/* no display API: the defaults */
|
|
4228
|
+
}
|
|
4229
|
+
if (p && typeof p.then === 'function') p.then(done, () => done(null));
|
|
4230
|
+
else done(null);
|
|
4231
|
+
}
|
|
4232
|
+
/**
|
|
4233
|
+
* The envelope's inputs for this frame, all from the DECLARED rig (never the tracked eyes): null
|
|
4234
|
+
* = off — no envelope, not a camera rig (a display rig is a portal; its depth is the page's), a
|
|
4235
|
+
* stereo video holding the rig, no disparity to cap (ipdFactor 0), or no canvas box.
|
|
4236
|
+
*/
|
|
4237
|
+
function envelopeState() {
|
|
4238
|
+
const E = envelope;
|
|
4239
|
+
const r = out.viewRig;
|
|
4240
|
+
if (!E || !r || r.type !== 'camera' || vid?.on) return null;
|
|
4241
|
+
const k = (r.ipdFactor ?? 1) * (r.metersToVirtual ?? 1);
|
|
4242
|
+
if (!(k > 0) || !(r.verticalFov > 0)) return null;
|
|
4243
|
+
// clientWidth/Height: no layout flush on a clean frame (a real canvas always has them)
|
|
4244
|
+
const box = canvas.clientWidth === undefined ? canvas.getBoundingClientRect() : null;
|
|
4245
|
+
const cw = box ? box.width : canvas.clientWidth;
|
|
4246
|
+
const ch = box ? box.height : canvas.clientHeight;
|
|
4247
|
+
if (!(cw > 0 && ch > 0)) return null;
|
|
4248
|
+
const m = envMetrics || displayMetricsFrom(null, null, 1);
|
|
4249
|
+
const W = E.canvasSizeM ? E.canvasSizeM[0] : cw * m.metersPerCssPx;
|
|
4250
|
+
const H = E.canvasSizeM ? E.canvasSizeM[1] : ch * m.metersPerCssPx;
|
|
4251
|
+
const tanY = Math.tan(r.verticalFov / 2);
|
|
4252
|
+
const fr = viewer.monoFrame();
|
|
4253
|
+
return {
|
|
4254
|
+
origin: fr.origin,
|
|
4255
|
+
axis: fr.axis,
|
|
4256
|
+
right: fr.right,
|
|
4257
|
+
up: fr.up,
|
|
4258
|
+
tanX: tanY * (cw / ch),
|
|
4259
|
+
tanY,
|
|
4260
|
+
halfW: W / 2,
|
|
4261
|
+
halfH: H / 2,
|
|
4262
|
+
// world units = rig units × fr.scale: the convergence and the eye baseline both scale with it
|
|
4263
|
+
invD: (r.convergenceDiopters || 0) / fr.scale,
|
|
4264
|
+
kfp: fr.scale * k * (H / (2 * tanY)),
|
|
4265
|
+
viewer: E.viewerM ?? m.nominalViewerM,
|
|
4266
|
+
maxFront: E.maxFrontM,
|
|
4267
|
+
stage: E.stage.f,
|
|
4268
|
+
band: E.bandM,
|
|
4269
|
+
edge: E.edgeM,
|
|
4270
|
+
rects: E.rects,
|
|
4271
|
+
};
|
|
4272
|
+
}
|
|
4273
|
+
/** Install / update / remove the tile-scope envelope effect (no-op until the effects exist). */
|
|
4274
|
+
function syncEnvelope() {
|
|
4275
|
+
if (!fx) return;
|
|
4276
|
+
fx.setInternal('tile', 'envelope', envelope ? { state: envelopeState } : null);
|
|
4277
|
+
const inst = fx.scopes.get('tile')?.get('envelope');
|
|
4278
|
+
if (inst) inst.hidden = true; // the SDK's, not the page's: never in effects(), never stopEffect()ed
|
|
4279
|
+
}
|
|
4280
|
+
/**
|
|
4281
|
+
* handle.setDepthEnvelope(opts | null) — cap how far the splat may come OUT of the glass by where
|
|
4282
|
+
* it lands on the canvas (docs/splat-effects.md §Depth envelope). Tile-wide and LIVE: every asset
|
|
4283
|
+
* shown — an incoming one included, from its first frame, and both sides of a crossfade — is
|
|
4284
|
+
* judged through the rig declared right now. A key given replaces; null turns it off.
|
|
4285
|
+
*/
|
|
4286
|
+
function setDepthEnvelope(o) {
|
|
4287
|
+
const next = validateDepthEnvelope(o, envelope);
|
|
4288
|
+
if (next && !envelope) {
|
|
4289
|
+
refreshEnvelopeMetrics();
|
|
4290
|
+
// a lazy layer may not answer getDisplayInfo yet: ask again once the tile is woven
|
|
4291
|
+
Promise.resolve(out.firstWoven).then(() => envelope && refreshEnvelopeMetrics());
|
|
4292
|
+
}
|
|
4293
|
+
envelope = next;
|
|
4294
|
+
syncEnvelope();
|
|
4295
|
+
return out;
|
|
4296
|
+
}
|
|
4297
|
+
|
|
4036
4298
|
// ── handle.setRig: a live, reversible rig switch (docs/playcanvas-adapter.md §setRig) ──
|
|
4037
4299
|
/** null = 'auto' (the per-asset waterfall); else { type: 'camera' } | { type: 'display', o }. */
|
|
4038
4300
|
let rigOverride = null;
|
|
@@ -4241,6 +4503,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
4241
4503
|
const kept = applyLoaded(loaded);
|
|
4242
4504
|
current = { asset: loaded.asset, entity, res: loaded.res, kind: loaded.desc.kind, ...kept };
|
|
4243
4505
|
fx = makeEffects(pcModule);
|
|
4506
|
+
syncEnvelope(); // a setDepthEnvelope before the first asset
|
|
4244
4507
|
// `reveal`: installed at its START state before the asset's first frame, played once the
|
|
4245
4508
|
// tile is woven (handle.firstWoven — which is immediate in 2D) and the first frames are built.
|
|
4246
4509
|
if (revealSpec) {
|
|
@@ -4374,7 +4637,8 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
4374
4637
|
v._placeMonoForFit();
|
|
4375
4638
|
v._updateMonoProjection();
|
|
4376
4639
|
}
|
|
4377
|
-
declareDisplay({ vH: state.vH });
|
|
4640
|
+
declareDisplay({ vH: state.vH }, { raw: true });
|
|
4641
|
+
state.stereoAtEnter = stereoOverride;
|
|
4378
4642
|
state.on = true;
|
|
4379
4643
|
}
|
|
4380
4644
|
|
|
@@ -4408,6 +4672,8 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
4408
4672
|
} else out.viewRig = s.viewRig;
|
|
4409
4673
|
lastConvergence = s.lastConvergence;
|
|
4410
4674
|
state.on = false;
|
|
4675
|
+
if (stereoOverride !== state.stereoAtEnter) redeclareStereo(); // a setStereo made while the video held the rig
|
|
4676
|
+
|
|
4411
4677
|
}
|
|
4412
4678
|
|
|
4413
4679
|
/** Tear the video down now (setVideo(null) / remove()). */
|
|
@@ -4466,6 +4732,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
4466
4732
|
// Another video on screen: keep ITS snapshot (the pre-video state), swap the source.
|
|
4467
4733
|
state.saved = live.saved;
|
|
4468
4734
|
state.on = true;
|
|
4735
|
+
state.stereoAtEnter = live.stereoAtEnter;
|
|
4469
4736
|
// S2: the plane outlives this swap; the replaced video's rect subscriptions go with it.
|
|
4470
4737
|
for (const off of live.rectOffs) off();
|
|
4471
4738
|
live.rectOffs.clear();
|
|
@@ -4474,7 +4741,7 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
|
|
|
4474
4741
|
viewer.vH = state.vH;
|
|
4475
4742
|
viewer._placeMonoForFit();
|
|
4476
4743
|
viewer._updateMonoProjection();
|
|
4477
|
-
declareDisplay({ vH: state.vH });
|
|
4744
|
+
declareDisplay({ vH: state.vH }, { raw: true });
|
|
4478
4745
|
}
|
|
4479
4746
|
} else enterVideo(state);
|
|
4480
4747
|
vid = state;
|
package/js/inline3d-splat.js
CHANGED
|
@@ -401,6 +401,20 @@ export function addSplat(wall, canvas, src, opts = {}) {
|
|
|
401
401
|
"(addSplat(…, { engine:'playcanvas' })).",
|
|
402
402
|
);
|
|
403
403
|
},
|
|
404
|
+
/** Not on this backend: the depth envelope is a PlayCanvas-backend feature. */
|
|
405
|
+
setDepthEnvelope() {
|
|
406
|
+
throw new Error(
|
|
407
|
+
"@displayxr/inline3d/splat: setDepthEnvelope() is implemented on the PlayCanvas backend only " +
|
|
408
|
+
"(addSplat(…, { engine:'playcanvas' })).",
|
|
409
|
+
);
|
|
410
|
+
},
|
|
411
|
+
/** Not on this backend: live stereo strength is a PlayCanvas-backend feature. */
|
|
412
|
+
setStereo() {
|
|
413
|
+
throw new Error(
|
|
414
|
+
"@displayxr/inline3d/splat: setStereo() is implemented on the PlayCanvas backend only " +
|
|
415
|
+
"(addSplat(…, { engine:'playcanvas' })).",
|
|
416
|
+
);
|
|
417
|
+
},
|
|
404
418
|
/** Not on this backend (splat effects are PlayCanvas-only in this version). */
|
|
405
419
|
playEffect() {
|
|
406
420
|
throw effectsNotOnSpark('playEffect()');
|
package/js/inline3d.js
CHANGED
|
@@ -34,6 +34,8 @@ export { undock, undockAvailable, undockUrl, tileScreenRect };
|
|
|
34
34
|
import { setSessionViewerEase } from './inline3d-viewer-ease.js';
|
|
35
35
|
// The tracking-acquisition ease, for pages that render their own addScene from the views.
|
|
36
36
|
export { ViewerEase, resolveViewerEaseOption, frameTrackingState, VIEWER_EASE_DEFAULT_MS } from './inline3d-viewer-ease.js';
|
|
37
|
+
export { DISPLAY_METRICS_DEFAULTS, displayMetricsFrom } from './inline3d-display-metrics.js';
|
|
38
|
+
import { displayMetricsFrom } from './inline3d-display-metrics.js';
|
|
37
39
|
|
|
38
40
|
// The eased 2D<->3D transition. A pure state machine (no DOM, no WebXR) ported from the native
|
|
39
41
|
// `dxr::ModeSwitch`, so the browser eases the disparity around a mode switch the same way — and in
|
|
@@ -133,6 +135,7 @@ const hasViewRig = () => {
|
|
|
133
135
|
// required — a browser with a partial set is a browser mid-implementation, and treating it as
|
|
134
136
|
// supported would hand a page a `requestRenderingMode is not a function` at the worst moment.
|
|
135
137
|
const DISPLAY_MODE_METHODS = ['getDisplayInfo', 'getRenderingModes', 'requestRenderingMode'];
|
|
138
|
+
|
|
136
139
|
// The two display events, fired on the XRSession rather than the layer — so a page hears about a
|
|
137
140
|
// mode or hardware-state change even while its tile's layer is closed (lazy mode), and one
|
|
138
141
|
// subscription covers every window in the document.
|
|
@@ -1088,6 +1091,24 @@ class Inline3D {
|
|
|
1088
1091
|
* @returns {Promise<object|null>}
|
|
1089
1092
|
*/
|
|
1090
1093
|
getDisplayInfo: () => this._layerCall(win, 'getDisplayInfo', 'getDisplayInfo()'),
|
|
1094
|
+
/**
|
|
1095
|
+
* This window in PHYSICAL units: the canvas's size in metres, metres per CSS px, the panel,
|
|
1096
|
+
* the nominal viewer distance and the eye separation — from getDisplayInfo(), with every
|
|
1097
|
+
* value that could not be measured filled from a stated default (see `source`). Never
|
|
1098
|
+
* rejects: no display API, no live layer, no glasses-free display all resolve the defaults.
|
|
1099
|
+
* The canvas size is re-read at each call (call it again after a resize).
|
|
1100
|
+
*
|
|
1101
|
+
* @returns {Promise<object>}
|
|
1102
|
+
*/
|
|
1103
|
+
displayMetrics: async () => {
|
|
1104
|
+
let info = null;
|
|
1105
|
+
try {
|
|
1106
|
+
info = await this._layerCall(win, 'getDisplayInfo', 'getDisplayInfo()');
|
|
1107
|
+
} catch {
|
|
1108
|
+
/* no display API / no live layer: the defaults */
|
|
1109
|
+
}
|
|
1110
|
+
return displayMetricsFrom(info, canvas.getBoundingClientRect(), globalThis.devicePixelRatio || 1);
|
|
1111
|
+
},
|
|
1091
1112
|
/**
|
|
1092
1113
|
* Every rendering mode the runtime can put this display in, as reported by the runtime:
|
|
1093
1114
|
* `{modeIndex, modeName, viewCount, viewScaleX, viewScaleY, tileColumns, tileRows,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@displayxr/inline3d",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.32.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",
|
|
@@ -65,6 +65,7 @@
|
|
|
65
65
|
"files": [
|
|
66
66
|
"js/inline3d.js",
|
|
67
67
|
"js/inline3d-undock.js",
|
|
68
|
+
"js/inline3d-display-metrics.js",
|
|
68
69
|
"js/inline3d-mode-switch.js",
|
|
69
70
|
"js/inline3d-buffer-limit.js",
|
|
70
71
|
"js/inline3d-viewer-ease.js",
|