@displayxr/inline3d 1.46.0 → 1.47.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 +75 -0
- package/README.md +3 -0
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/depth.d.ts +184 -0
- package/dist/call.js +6 -6
- package/docs/making-3d-look-right.md +49 -0
- package/docs/woven-canvas-rules.md +378 -0
- package/index.d.ts +22 -3
- package/js/inline3d-depth-budget.js +342 -0
- package/js/inline3d-model-entry.js +15 -1
- package/js/inline3d-model-playcanvas.js +27 -2
- package/js/inline3d-model.js +22 -2
- package/js/inline3d-splat-deferred.js +2 -0
- package/js/inline3d-splat-pc-entry.js +2 -1
- package/js/inline3d-splat-playcanvas.js +42 -42
- package/js/inline3d-splat-shared.js +311 -0
- package/js/inline3d-splat.js +31 -3
- package/js/inline3d-three.js +30 -10
- package/js/inline3d-viewer.js +13 -0
- package/js/inline3d.js +35 -0
- package/llms.txt +21 -0
- package/model.d.ts +53 -0
- package/package.json +10 -1
- package/splat.d.ts +33 -0
- package/three.d.ts +7 -2
- package/viewer.d.ts +11 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
// inline3d-depth-budget.js — how deep is this scene, in the units a 3D display cares about.
|
|
2
|
+
//
|
|
3
|
+
// Pure functions, dependency-free, re-exported by ./inline3d.js (the root), ./three and the
|
|
4
|
+
// `./depth` subpath. NOTHING HERE CHANGES A PIXEL: these are measurements, plus one-time warnings.
|
|
5
|
+
// The SDK never clamps, refuses or overrides a depth a page asked for. The one consumer that acts
|
|
6
|
+
// on a number from here is the OPT-IN `depth` option of ./model and ./splat, which feeds
|
|
7
|
+
// comfortDepthFactor's result into the display rig it declares (only ever lowering depth).
|
|
8
|
+
//
|
|
9
|
+
// The units are relative, which is what makes them portable across panels: disparity as a
|
|
10
|
+
// percentage of the TILE's width, or depth in TILE HEIGHTS on a display rig (where the canvas is a
|
|
11
|
+
// portal and world depth / virtualDisplayHeight equals physical depth / the tile's physical
|
|
12
|
+
// height). Depth ranges measured on a panel are in docs/authoring-inline-3d.md; they are reference
|
|
13
|
+
// points, not limits this module applies.
|
|
14
|
+
//
|
|
15
|
+
// The conversion on a display rig: physical depth z = depthH · h (h = the tile's physical height),
|
|
16
|
+
// on-screen disparity p = e · z / (V ∓ z) (e = 0.063 m, V = the nominal viewer distance, − in
|
|
17
|
+
// front, + behind), as a percentage of the tile width w = aspect · h. When h is unknown the
|
|
18
|
+
// small-depth limit p ≈ e · z / V is used, in which h cancels: pct ≈ 100 · e · depthH / (aspect · V).
|
|
19
|
+
//
|
|
20
|
+
// Comfort zones (vergence–accommodation conflict) are in DIOPTERS of vergence away from the glass:
|
|
21
|
+
// ΔD = 1/V − 1/z (> 0 behind, < 0 in front), and the on-screen disparity is p = e · V · ΔD. A zone
|
|
22
|
+
// is a max |ΔD| on each side; comfortZone() states it, comfortDepthFactor() says how much a display
|
|
23
|
+
// rig's depth would have to shrink for a subject to sit inside it (never more than 1: no zone here
|
|
24
|
+
// ever ADDS depth).
|
|
25
|
+
|
|
26
|
+
/** The eye separation every estimate here assumes (no browser surface reports a real one). */
|
|
27
|
+
export const DEPTH_EYE_SEPARATION_M = 0.063;
|
|
28
|
+
|
|
29
|
+
/** The viewer distance used when the display does not report its nominal viewer (desktop panel). */
|
|
30
|
+
export const DEPTH_DEFAULT_VIEWER_M = 0.6;
|
|
31
|
+
|
|
32
|
+
const num = (v) => typeof v === 'number' && Number.isFinite(v);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Disparity, % of the tile width, of a point `depthH` tile heights out of the glass on a display
|
|
36
|
+
* rig. SIGNED like display space: `depthH > 0` is IN FRONT (toward the viewer) and gives a NEGATIVE
|
|
37
|
+
* (crossed) disparity; `depthH < 0` is behind and gives a positive one — the same sign as
|
|
38
|
+
* {@link cameraRigDisparityPct}.
|
|
39
|
+
*
|
|
40
|
+
* @param {object} o
|
|
41
|
+
* @param {number} o.depthH tile heights out of the glass (+ in front, − behind).
|
|
42
|
+
* @param {number} o.aspect the tile's width / height.
|
|
43
|
+
* @param {number} [o.viewerM=0.6] nominal viewer distance, metres.
|
|
44
|
+
* @param {number} [o.heightM] the tile's physical height, metres. Unset: the small-depth limit.
|
|
45
|
+
* @param {number} [o.ipdFactor=1] a display rig's ipdFactor (disparity scales with it).
|
|
46
|
+
* @param {number} [o.eyeSeparationM=0.063]
|
|
47
|
+
* @returns {number} %, −Infinity at or past the viewer, NaN on bad input.
|
|
48
|
+
*/
|
|
49
|
+
export function disparityPct({ depthH, aspect, viewerM = DEPTH_DEFAULT_VIEWER_M, heightM, ipdFactor = 1, eyeSeparationM = DEPTH_EYE_SEPARATION_M } = {}) {
|
|
50
|
+
if (!num(depthH) || !num(aspect) || !(aspect > 0) || !num(viewerM) || !(viewerM > 0) || !num(ipdFactor)) return NaN;
|
|
51
|
+
const h = num(heightM) && heightM > 0 ? heightM : 0;
|
|
52
|
+
const denom = viewerM - depthH * h;
|
|
53
|
+
if (!(denom > 0)) return -Infinity;
|
|
54
|
+
if (depthH === 0) return 0;
|
|
55
|
+
return (-100 * eyeSeparationM * ipdFactor * depthH) / (aspect * denom);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Disparity, % of the tile width, of a point at distance `z` on a CAMERA rig:
|
|
60
|
+
* `100 · b · (1/D − 1/z) / (2 · tan(verticalFov/2) · aspect)`, `b = 0.063 · ipdFactor · metersToVirtual`,
|
|
61
|
+
* D the convergence distance. `+` = behind the glass, `−` = in front. Convergence 0 / unset is
|
|
62
|
+
* infinity (1/D = 0): everything is in front.
|
|
63
|
+
*
|
|
64
|
+
* @param {object} o
|
|
65
|
+
* @param {number} o.z distance of the point along the camera's view axis, world units.
|
|
66
|
+
* @param {number} [o.convergence] zero-disparity distance, world units (0 = infinity).
|
|
67
|
+
* @param {number} [o.convergenceDiopters] or 1/distance, as the descriptor carries it.
|
|
68
|
+
* @param {number} [o.ipdFactor=1] [o.metersToVirtual=1]
|
|
69
|
+
* @param {number} o.verticalFov FULL vertical angle, RADIANS (the descriptor's unit).
|
|
70
|
+
* @param {number} o.aspect the tile's width / height.
|
|
71
|
+
* @returns {number} %, NaN on bad input.
|
|
72
|
+
*/
|
|
73
|
+
export function cameraRigDisparityPct({ z, convergence, convergenceDiopters, ipdFactor = 1, metersToVirtual = 1, verticalFov, aspect } = {}) {
|
|
74
|
+
if (!num(z) || !(z > 0) || !num(verticalFov) || !(verticalFov > 0) || !num(aspect) || !(aspect > 0)) return NaN;
|
|
75
|
+
if (!num(ipdFactor) || !num(metersToVirtual)) return NaN;
|
|
76
|
+
const invD = num(convergenceDiopters) ? Math.max(0, convergenceDiopters) : num(convergence) && convergence > 0 ? 1 / convergence : 0;
|
|
77
|
+
const b = DEPTH_EYE_SEPARATION_M * ipdFactor * metersToVirtual;
|
|
78
|
+
return (100 * b * (invD - 1 / z)) / (2 * Math.tan(verticalFov / 2) * aspect);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A depth report from a display-rig subject box (SceneViewer.getSubjectBounds' `front` / `back`,
|
|
83
|
+
* display space: +z toward the viewer, glass at 0, units of the virtual display).
|
|
84
|
+
*
|
|
85
|
+
* `frontH` / `backH` are MAGNITUDES in tile heights: how far the nearest point sits in front of the
|
|
86
|
+
* glass and the farthest behind it (0 when nothing is on that side). `frontPct` / `backPct` are the
|
|
87
|
+
* matching disparity magnitudes, % of the tile width.
|
|
88
|
+
*
|
|
89
|
+
* @param {object} o
|
|
90
|
+
* @param {number} o.front z of the nearest surface.
|
|
91
|
+
* @param {number} o.back z of the farthest surface.
|
|
92
|
+
* @param {number} o.virtualDisplayHeight what one tile height is in those units.
|
|
93
|
+
* @param {number} o.aspect the tile's width / height.
|
|
94
|
+
* @param {number} [o.viewerM=0.6] [o.heightM] [o.ipdFactor=1] as {@link disparityPct}.
|
|
95
|
+
*/
|
|
96
|
+
export function depthBudgetFromBounds({ front, back, virtualDisplayHeight, aspect, viewerM = DEPTH_DEFAULT_VIEWER_M, heightM, ipdFactor = 1 } = {}) {
|
|
97
|
+
const vH = virtualDisplayHeight;
|
|
98
|
+
const ok = num(front) && num(back) && num(vH) && vH > 0;
|
|
99
|
+
const frontH = ok ? Math.max(0, front / vH) : NaN;
|
|
100
|
+
const backH = ok ? Math.max(0, -back / vH) : NaN;
|
|
101
|
+
const common = { aspect, viewerM, heightM, ipdFactor };
|
|
102
|
+
const frontPct = Math.abs(disparityPct({ depthH: frontH, ...common }));
|
|
103
|
+
const backPct = Math.abs(disparityPct({ depthH: -backH, ...common }));
|
|
104
|
+
return {
|
|
105
|
+
frontH,
|
|
106
|
+
backH,
|
|
107
|
+
frontPct,
|
|
108
|
+
backPct,
|
|
109
|
+
basis: {
|
|
110
|
+
rig: 'display',
|
|
111
|
+
aspect,
|
|
112
|
+
viewerM,
|
|
113
|
+
heightM: num(heightM) && heightM > 0 ? heightM : null,
|
|
114
|
+
ipdFactor,
|
|
115
|
+
eyeSeparationM: DEPTH_EYE_SEPARATION_M,
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── comfort zones ─────────────────────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
/** The comfort zones comfortZone() knows by name; a positive number is a symmetric |ΔD| bound. */
|
|
123
|
+
const COMFORT_ZONES = Object.freeze(['shibata', 'percival']);
|
|
124
|
+
|
|
125
|
+
/** A `zone` comfortZone() accepts: a known name, or a finite number of diopters > 0. */
|
|
126
|
+
export function isComfortZone(zone) {
|
|
127
|
+
return COMFORT_ZONES.includes(zone) || (num(zone) && zone > 0);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The comfort zone for a viewer at `viewerM`: the largest vergence change, in diopters, that is
|
|
132
|
+
* comfortable in front of the glass (`frontD`) and behind it (`behindD`), and the matching
|
|
133
|
+
* on-screen disparity magnitudes in mm (`e · V · ΔD`).
|
|
134
|
+
*
|
|
135
|
+
* - `'shibata'` (default): Shibata et al. 2011, "The zone of comfort" (J. Vision 11(8):11). The
|
|
136
|
+
* near vergence limit is `Dv/1.035 + 0.626`, the far `Dv/1.129 − 0.442` (Dv = 1/V), so
|
|
137
|
+
* `frontD = (Dv/1.035 + 0.626) − Dv` and `behindD = Dv − (Dv/1.129 − 0.442)`. A far limit at or
|
|
138
|
+
* below 0 D (a distant viewer) leaves behind unbounded: `Infinity`. At 0.6 m: 0.57 D / 0.63 D.
|
|
139
|
+
* - `'percival'`: Percival's zone, ±1/3 D on both sides.
|
|
140
|
+
* - a number: a symmetric |ΔD| bound, diopters.
|
|
141
|
+
*
|
|
142
|
+
* @param {object} [o]
|
|
143
|
+
* @param {number} [o.viewerM=0.6] viewing distance, metres.
|
|
144
|
+
* @param {'shibata'|'percival'|number} [o.zone='shibata']
|
|
145
|
+
* @returns {{zone:'shibata'|'percival'|number, viewerM:number, frontD:number, behindD:number,
|
|
146
|
+
* frontMm:number, behindMm:number}} NaN fields on bad input.
|
|
147
|
+
*/
|
|
148
|
+
export function comfortZone({ viewerM = DEPTH_DEFAULT_VIEWER_M, zone = 'shibata' } = {}) {
|
|
149
|
+
let frontD = NaN;
|
|
150
|
+
let behindD = NaN;
|
|
151
|
+
if (num(viewerM) && viewerM > 0 && isComfortZone(zone)) {
|
|
152
|
+
const Dv = 1 / viewerM;
|
|
153
|
+
if (zone === 'shibata') {
|
|
154
|
+
frontD = Dv / 1.035 + 0.626 - Dv;
|
|
155
|
+
const far = Dv / 1.129 - 0.442;
|
|
156
|
+
behindD = far > 0 ? Dv - far : Infinity;
|
|
157
|
+
} else if (zone === 'percival') {
|
|
158
|
+
frontD = behindD = 1 / 3;
|
|
159
|
+
} else {
|
|
160
|
+
frontD = behindD = zone;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const mm = (d) => DEPTH_EYE_SEPARATION_M * viewerM * d * 1000;
|
|
164
|
+
return { zone, viewerM, frontD, behindD, frontMm: mm(frontD), behindMm: mm(behindD) };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* How much a DISPLAY rig's depth would have to shrink for a subject to sit inside a comfort zone:
|
|
169
|
+
* the largest `k ≤ 1` such that the subject's disparities scaled by `k` (a display rig's
|
|
170
|
+
* ipdFactor, with parallaxFactor moved with it) stay within {@link comfortZone}. 1 = it already
|
|
171
|
+
* fits; never above 1 — this only ever says "less".
|
|
172
|
+
*
|
|
173
|
+
* Exact geometry, at real proportions: the nearest point is `frontH` tile heights in front of the
|
|
174
|
+
* glass, physically `frontH · tileHeightM`, so at `z = V − frontH · tileHeightM` from the viewer;
|
|
175
|
+
* the farthest at `z = V + backH · tileHeightM`. Each side's `|ΔD| = |1/V − 1/z|` against the
|
|
176
|
+
* zone's bound on that side gives `k = min(1, frontD/|ΔD_front|, behindD/ΔD_behind)` (a side with
|
|
177
|
+
* no depth does not count). The SAME subject in tile heights asks more of a small, close screen —
|
|
178
|
+
* a tablet at 0.4 m carries more vergence per tile height than a laptop at 0.6 m — which is the
|
|
179
|
+
* whole reason this depends on the panel and not just on the scene.
|
|
180
|
+
*
|
|
181
|
+
* Returns 1 (no change) for any missing or invalid input — a non-finite depth, a height or
|
|
182
|
+
* viewer distance ≤ 0, an unknown zone — and when the front reaches the viewer (no finite
|
|
183
|
+
* disparity to scale): a measurement that cannot be made must never be read as "flatten it".
|
|
184
|
+
*
|
|
185
|
+
* @param {object} o
|
|
186
|
+
* @param {number} o.frontH MAGNITUDE, tile heights in front of the glass (0 = nothing in front).
|
|
187
|
+
* @param {number} o.backH MAGNITUDE, tile heights behind it.
|
|
188
|
+
* @param {number} o.tileHeightM the tile's (canvas's) physical height, metres.
|
|
189
|
+
* @param {number} [o.viewerM=0.6] NOMINAL viewer distance, metres.
|
|
190
|
+
* @param {'shibata'|'percival'|number} [o.zone='shibata']
|
|
191
|
+
* @returns {number} in (0, 1].
|
|
192
|
+
*/
|
|
193
|
+
export function comfortDepthFactor({ frontH, backH, tileHeightM, viewerM = DEPTH_DEFAULT_VIEWER_M, zone = 'shibata' } = {}) {
|
|
194
|
+
if (!num(frontH) || !num(backH) || !num(tileHeightM) || !(tileHeightM > 0)) return 1;
|
|
195
|
+
const z = comfortZone({ viewerM, zone });
|
|
196
|
+
if (!num(z.frontD) || Number.isNaN(z.behindD)) return 1;
|
|
197
|
+
const V = viewerM;
|
|
198
|
+
const zf = V - Math.max(0, frontH) * tileHeightM;
|
|
199
|
+
const zb = V + Math.max(0, backH) * tileHeightM;
|
|
200
|
+
if (!(zf > 0)) return 1;
|
|
201
|
+
const dFront = 1 / zf - 1 / V;
|
|
202
|
+
const dBehind = 1 / V - 1 / zb;
|
|
203
|
+
let k = 1;
|
|
204
|
+
if (dFront > 0) k = Math.min(k, z.frontD / dFront);
|
|
205
|
+
if (dBehind > 0) k = Math.min(k, z.behindD / dBehind);
|
|
206
|
+
return k > 0 ? k : 1;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The CAMERA-rig counterpart of {@link comfortDepthFactor}: the multiplier `m` on the eye
|
|
211
|
+
* separation (the rig's ipdFactor) that puts the scene's nearest point (`zNear`) and farthest
|
|
212
|
+
* (`zFar`, may be Infinity) at the edge of the comfort zone, at the given viewing distance.
|
|
213
|
+
*
|
|
214
|
+
* Measurement only; the page decides whether to apply it. Unlike the display-rig factor it can
|
|
215
|
+
* exceed 1, because a camera rig's 1 is only physical in metre-scale worlds: `ipdFactor` 1 is
|
|
216
|
+
* 63 mm × metersToVirtual in scene units, which is right for a real-sized room and far too little
|
|
217
|
+
* for a scene authored in large units (it renders nearly flat).
|
|
218
|
+
*
|
|
219
|
+
* Method: the on-screen disparity as a fraction of the tile width is
|
|
220
|
+
* `f = b · (1/D − 1/z) / (2 · tan(verticalFov/2) · aspect)` (b = 0.063 · ipdFactor ·
|
|
221
|
+
* metersToVirtual; {@link cameraRigDisparityPct} / 100), physically `p = f · aspect · tileHeightM`
|
|
222
|
+
* on the glass, which is `ΔD = p / (0.063 · V)` diopters of vergence. `m` = the smallest of
|
|
223
|
+
* zone side / |ΔD side| over the sides with content (in front: z < D, behind: z > D).
|
|
224
|
+
* Convergence 0 / unset is infinity: everything is in front.
|
|
225
|
+
*
|
|
226
|
+
* @param {object} o
|
|
227
|
+
* @param {number} o.zNear nearest scene point along the view axis, world units (> 0).
|
|
228
|
+
* @param {number} o.zFar farthest, world units (≥ zNear; Infinity allowed).
|
|
229
|
+
* @param {number} [o.convergence] zero-disparity distance, world units (0 = infinity).
|
|
230
|
+
* @param {number} [o.convergenceDiopters] or 1/distance; wins over `convergence`.
|
|
231
|
+
* @param {number} o.verticalFov FULL vertical angle, RADIANS.
|
|
232
|
+
* @param {number} o.aspect the tile's width / height.
|
|
233
|
+
* @param {number} o.tileHeightM the tile's physical height, metres.
|
|
234
|
+
* @param {number} [o.viewerM=0.6] [o.zone='shibata'] [o.ipdFactor=1] [o.metersToVirtual=1]
|
|
235
|
+
* @returns {number} > 0, no upper bound; Infinity when nothing is off the plane; NaN on bad input.
|
|
236
|
+
*/
|
|
237
|
+
export function cameraRigComfortFactor({
|
|
238
|
+
zNear,
|
|
239
|
+
zFar,
|
|
240
|
+
convergence,
|
|
241
|
+
convergenceDiopters,
|
|
242
|
+
verticalFov,
|
|
243
|
+
aspect,
|
|
244
|
+
tileHeightM,
|
|
245
|
+
viewerM = DEPTH_DEFAULT_VIEWER_M,
|
|
246
|
+
zone = 'shibata',
|
|
247
|
+
ipdFactor = 1,
|
|
248
|
+
metersToVirtual = 1,
|
|
249
|
+
} = {}) {
|
|
250
|
+
const farOk = num(zFar) || zFar === Infinity;
|
|
251
|
+
if (!num(zNear) || !(zNear > 0) || !farOk || !(zFar >= zNear)) return NaN;
|
|
252
|
+
if (!num(verticalFov) || !(verticalFov > 0) || !num(aspect) || !(aspect > 0) || !num(tileHeightM) || !(tileHeightM > 0)) return NaN;
|
|
253
|
+
if (!num(ipdFactor) || !(ipdFactor > 0) || !num(metersToVirtual) || !(metersToVirtual > 0)) return NaN;
|
|
254
|
+
const cz = comfortZone({ viewerM, zone });
|
|
255
|
+
if (!num(cz.frontD)) return NaN;
|
|
256
|
+
const invD = num(convergenceDiopters) ? Math.max(0, convergenceDiopters) : num(convergence) && convergence > 0 ? 1 / convergence : 0;
|
|
257
|
+
const b = DEPTH_EYE_SEPARATION_M * ipdFactor * metersToVirtual;
|
|
258
|
+
// ΔD of a point at z (+ behind, − in front): f · aspect · H / (e · V), the aspect cancelling.
|
|
259
|
+
const dD = (z) => (b * (invD - 1 / z) * tileHeightM) / (2 * Math.tan(verticalFov / 2) * DEPTH_EYE_SEPARATION_M * viewerM);
|
|
260
|
+
let m = Infinity;
|
|
261
|
+
const front = -dD(zNear);
|
|
262
|
+
const behind = dD(zFar);
|
|
263
|
+
if (front > 0) m = Math.min(m, cz.frontD / front);
|
|
264
|
+
if (behind > 0) m = Math.min(m, cz.behindD / behind);
|
|
265
|
+
return m;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ── one-time warnings (never a behaviour change) ──────────────────────────────────────────────
|
|
269
|
+
|
|
270
|
+
const CONVERGENCE_MSG =
|
|
271
|
+
'[inline3d] camera rig with convergenceDiopters 0: convergence at infinity puts the whole scene in front of the glass; ' +
|
|
272
|
+
'set convergence to the subject\'s distance.';
|
|
273
|
+
|
|
274
|
+
/** Descriptors a rig builder already warned about, so setViewRig does not say it twice. */
|
|
275
|
+
const builderWarned = new WeakSet();
|
|
276
|
+
let builderWarnedOnce = false;
|
|
277
|
+
|
|
278
|
+
/** cameraRigFromCamera / cameraRigFromPose: warn once per page when convergence is 0 / unset. */
|
|
279
|
+
export function noteBuiltCameraRig(rig) {
|
|
280
|
+
if (rig.convergenceDiopters > 0) return;
|
|
281
|
+
if (!builderWarnedOnce) {
|
|
282
|
+
builderWarnedOnce = true;
|
|
283
|
+
console.warn(CONVERGENCE_MSG);
|
|
284
|
+
}
|
|
285
|
+
builderWarned.add(rig);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* What looks wrong about a view-rig descriptor: `[key, message]` pairs (key = 'convergence' |
|
|
290
|
+
* 'factor' | 'fov'). Read only; the descriptor goes to the runtime exactly as given.
|
|
291
|
+
*/
|
|
292
|
+
export function viewRigWarnings(rig) {
|
|
293
|
+
const out = [];
|
|
294
|
+
if (!rig || typeof rig !== 'object') return out;
|
|
295
|
+
if (rig.type === 'camera') {
|
|
296
|
+
if (!(rig.convergenceDiopters > 0)) out.push(['convergence', CONVERGENCE_MSG]);
|
|
297
|
+
if (rig.verticalFov > Math.PI) {
|
|
298
|
+
out.push([
|
|
299
|
+
'fov',
|
|
300
|
+
`[inline3d] camera rig verticalFov ${rig.verticalFov} is more than π: it looks like DEGREES — the descriptor takes RADIANS ` +
|
|
301
|
+
'(cameraRigFromCamera / cameraRigFromPose convert three\'s degrees for you).',
|
|
302
|
+
]);
|
|
303
|
+
}
|
|
304
|
+
} else if (rig.ipdFactor > 1 || rig.parallaxFactor > 1) {
|
|
305
|
+
out.push([
|
|
306
|
+
'factor',
|
|
307
|
+
`[inline3d] display rig ipdFactor ${rig.ipdFactor ?? 1} / parallaxFactor ${rig.parallaxFactor ?? 1}: above 1 exaggerates ` +
|
|
308
|
+
'depth past what the display shows by construction (1 = correct; below 1 = a comfort dial). Sent as given.',
|
|
309
|
+
]);
|
|
310
|
+
}
|
|
311
|
+
return out;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Warn each of viewRigWarnings(rig) once per `seen` set (one per handle). */
|
|
315
|
+
export function warnViewRigOnce(seen, rig) {
|
|
316
|
+
if (!rig || typeof rig !== 'object') return;
|
|
317
|
+
// The cheap common case first: called per frame by pages that drive a rig.
|
|
318
|
+
if (rig.type === 'camera' ? rig.convergenceDiopters > 0 && !(rig.verticalFov > Math.PI) : !(rig.ipdFactor > 1 || rig.parallaxFactor > 1)) return;
|
|
319
|
+
for (const [key, msg] of viewRigWarnings(rig)) {
|
|
320
|
+
if (seen.has(key)) continue;
|
|
321
|
+
seen.add(key);
|
|
322
|
+
if (key === 'convergence' && builderWarned.has(rig)) continue; // the builder said it already
|
|
323
|
+
console.warn(msg);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* The hint for a `wovenState === 'withheld'` reason token (docs/woven-canvas-rules.md §3).
|
|
329
|
+
*/
|
|
330
|
+
export function withheldReasonHint(reason) {
|
|
331
|
+
const r = String(reason || '');
|
|
332
|
+
if (r.startsWith('cross-pass')) {
|
|
333
|
+
return 'the canvas is inside its own render surface: a CSS effect on it or an ancestor (filter, opacity < 1, mask, clip-path, mix-blend-mode, backdrop-filter). Remove it (woven-canvas rule 7)';
|
|
334
|
+
}
|
|
335
|
+
if (r === 'no-quad') {
|
|
336
|
+
return 'the canvas drew nothing the compositor can see: it is not drawing, is occluded, or a CSS effect on it or an ancestor (filter, opacity < 1, mask, clip-path, mix-blend-mode, backdrop-filter) hides it (woven-canvas rule 7)';
|
|
337
|
+
}
|
|
338
|
+
if (r === 'no-identity') return 'the canvas reached the browser without its identity: register it once, keep it mounted, cover it until firstWoven (woven-canvas rules 3-5)';
|
|
339
|
+
if (r === 'no-join') return 'nothing joined this rect; harmless off screen, otherwise check the canvas is drawing and on screen';
|
|
340
|
+
if (r === 'resolve-dropped') return "the browser could not resolve the canvas's GPU resource (a browser-side issue)";
|
|
341
|
+
return 'see docs/woven-canvas-rules.md §3';
|
|
342
|
+
}
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// engine import fails, one warning names what to install, and the tile renders on three. With
|
|
22
22
|
// neither, `ready` rejects naming both. An explicit `engine` never falls back.
|
|
23
23
|
|
|
24
|
-
import { guardedAttach } from './inline3d-splat-shared.js';
|
|
24
|
+
import { guardedAttach, validateSetStereo, resolveDepthOption } from './inline3d-splat-shared.js';
|
|
25
25
|
/** The engines `addModel` renders with; the first is the default. */
|
|
26
26
|
export const MODEL_ENGINES = Object.freeze(['playcanvas', 'three']);
|
|
27
27
|
|
|
@@ -71,6 +71,7 @@ export function validateModelCall(canvas, src, opts, route) {
|
|
|
71
71
|
if (opts.orbitStyle !== undefined && opts.orbitStyle !== 'turntable' && opts.orbitStyle !== 'tilt') {
|
|
72
72
|
throw new Error(`@displayxr/inline3d/model: orbitStyle must be 'turntable' or 'tilt' (got ${String(opts.orbitStyle)}).`);
|
|
73
73
|
}
|
|
74
|
+
resolveDepthOption(opts.depth, '@displayxr/inline3d/model');
|
|
74
75
|
if (route.engine === 'three' && route.explicit) {
|
|
75
76
|
const bad = PLAYCANVAS_ONLY.filter((k) => opts[k] !== undefined && !(k === 'controls' && opts[k] === 'viewer'));
|
|
76
77
|
if (bad.length) {
|
|
@@ -141,6 +142,15 @@ function addModelUnguarded(wall, canvas, src, opts = {}) {
|
|
|
141
142
|
remove: queue('remove'),
|
|
142
143
|
exclude: queue('exclude'),
|
|
143
144
|
unexclude: queue('unexclude'),
|
|
145
|
+
// Validated now (a bad argument throws at the call, as once the backend is in), replayed then.
|
|
146
|
+
setStereo(o) {
|
|
147
|
+
validateSetStereo(o, null, '@displayxr/inline3d/model');
|
|
148
|
+
pending.push(['setStereo', [o]]);
|
|
149
|
+
return out;
|
|
150
|
+
},
|
|
151
|
+
rig: null,
|
|
152
|
+
// The `depth` option's comfort factor in force (1 until measured, and always without it).
|
|
153
|
+
depthFactor: 1,
|
|
144
154
|
// Before the backend lands: once it has, the backend's own (PlayCanvas); three has none (0).
|
|
145
155
|
warmEntity: (entity, o) => out.ready.then(() => (out.warmEntity !== stubWarm && typeof out.warmEntity === 'function' ? out.warmEntity(entity, o) : 0)),
|
|
146
156
|
};
|
|
@@ -233,6 +243,10 @@ async function attachThree(out, wall, canvas, src, opts, pending, mod) {
|
|
|
233
243
|
}
|
|
234
244
|
out.backend = 'three';
|
|
235
245
|
out.engine = null;
|
|
246
|
+
for (const k of ['rig', 'viewRig', 'depthFactor']) {
|
|
247
|
+
Object.defineProperty(out, k, { get: () => inner[k], enumerable: true, configurable: true });
|
|
248
|
+
}
|
|
249
|
+
out.setStereo = (o) => (inner.setStereo(o), out);
|
|
236
250
|
out.setPose = inner.setPose;
|
|
237
251
|
out.resetPose = inner.resetPose;
|
|
238
252
|
out.remove = inner.remove;
|
|
@@ -38,7 +38,8 @@
|
|
|
38
38
|
// └── eye camera, N RenderViews, TONEMAP_NEUTRAL, no sky layer
|
|
39
39
|
|
|
40
40
|
import { PlayCanvasSplatViewer, pageRigTRS, pageViewAxis, pageViewRig } from './inline3d-splat-playcanvas.js';
|
|
41
|
-
import { resolveControls, normalizeCameraPose, now, MAX_DT_S, declareViewRig } from './inline3d-splat-shared.js';
|
|
41
|
+
import { resolveControls, normalizeCameraPose, now, MAX_DT_S, declareViewRig, attachModelStereo, attachComfortDepth, resolveDepthOption, framedSubjectBounds } from './inline3d-splat-shared.js';
|
|
42
|
+
import { displayRig } from './inline3d-three.js';
|
|
42
43
|
import {
|
|
43
44
|
NEUTRAL_STUDIO,
|
|
44
45
|
neutralStudioRadiance,
|
|
@@ -440,6 +441,7 @@ export function attachPlayCanvasModel(out, wall, canvas, src, opts, pending = []
|
|
|
440
441
|
} = opts;
|
|
441
442
|
const ctl = validatePlayCanvasModelOptions(opts);
|
|
442
443
|
const pageMode = ctl.page;
|
|
444
|
+
const depthOption = resolveDepthOption(opts.depth, '@displayxr/inline3d/model');
|
|
443
445
|
if (pageMode && ctl.ignored.length) {
|
|
444
446
|
console.info(`${TAG} controls:'page' — the page owns the camera, so ${ctl.ignored.join(', ')} ${ctl.ignored.length === 1 ? 'is' : 'are'} ignored.`);
|
|
445
447
|
}
|
|
@@ -468,6 +470,8 @@ export function attachPlayCanvasModel(out, wall, canvas, src, opts, pending = []
|
|
|
468
470
|
sky: false,
|
|
469
471
|
viewerEase: opts.viewerEase,
|
|
470
472
|
cursor: opts.cursor,
|
|
473
|
+
// Opt-in; undefined = the viewer's own default (a transparent clear).
|
|
474
|
+
clearColor: opts.clearColor,
|
|
471
475
|
// Each environment's own look: 'room' is three's (untonemapped), the rest the Sample Viewer's.
|
|
472
476
|
toneMapping: envMap ? 'neutral' : ENVIRONMENT_TONE_MAPPING[environment] || 'neutral',
|
|
473
477
|
});
|
|
@@ -510,6 +514,7 @@ export function attachPlayCanvasModel(out, wall, canvas, src, opts, pending = []
|
|
|
510
514
|
},
|
|
511
515
|
remove() {
|
|
512
516
|
removed = true;
|
|
517
|
+
depth.dispose();
|
|
513
518
|
handle?.remove();
|
|
514
519
|
viewer.dispose(); // app.destroy(): the model, its textures and any page entities go with it
|
|
515
520
|
},
|
|
@@ -530,6 +535,21 @@ export function attachPlayCanvasModel(out, wall, canvas, src, opts, pending = []
|
|
|
530
535
|
Object.defineProperty(out, 'renderScale', { get: () => viewer.effectiveRenderScale, enumerable: true, configurable: true });
|
|
531
536
|
Object.defineProperty(out, 'renderScaleRequested', { get: () => viewer.renderScale, enumerable: true, configurable: true });
|
|
532
537
|
|
|
538
|
+
// handle.setStereo / handle.rig (a depth slider; nothing is declared until it is called).
|
|
539
|
+
const stereo = attachModelStereo(out, { getTile: () => handle, vH: virtualDisplayHeight, makeDisplayRig: displayRig, page: pageMode });
|
|
540
|
+
// `depth: 'auto'` (opt-in): the comfort factor under setStereo's, on the same display rig.
|
|
541
|
+
// Without it, inert: `depthFactor` reads 1 and nothing is measured or declared.
|
|
542
|
+
const depth = attachComfortDepth(out, {
|
|
543
|
+
option: depthOption,
|
|
544
|
+
canvas,
|
|
545
|
+
getTile: () => handle,
|
|
546
|
+
getBounds: () => (out.frame ? framedSubjectBounds(viewer) : null),
|
|
547
|
+
getVH: () => virtualDisplayHeight,
|
|
548
|
+
apply: (k) => stereo.setBase(k),
|
|
549
|
+
tag: TAG,
|
|
550
|
+
});
|
|
551
|
+
if (pageMode) depth.ignore("controls:'page' (the page's camera rig)");
|
|
552
|
+
|
|
533
553
|
if (wall && wall.supported) {
|
|
534
554
|
handle = wall.addScene(canvas, viewer.onFrame, {
|
|
535
555
|
...(pageMode
|
|
@@ -574,7 +594,11 @@ export function attachPlayCanvasModel(out, wall, canvas, src, opts, pending = []
|
|
|
574
594
|
const dm = (c[0] - ax.origin[0]) * ax.forward[0] + (c[1] - ax.origin[1]) * ax.forward[1] + (c[2] - ax.origin[2]) * ax.forward[2];
|
|
575
595
|
if (dm > 0) d = dm / (ax.scale || 1);
|
|
576
596
|
}
|
|
577
|
-
|
|
597
|
+
// setStereo's values, ABSOLUTE on this camera rig (1 and 1 until it is called: as before).
|
|
598
|
+
out.viewRig = pageViewRig(
|
|
599
|
+
{ verticalFovDeg: viewer.page.fov, convergence: d ?? 2, comfortDepth: ctl.comfortDepth, ipdFactor: out.rig.ipdFactor, parallaxFactor: out.rig.parallaxFactor },
|
|
600
|
+
out.viewRig || {},
|
|
601
|
+
);
|
|
578
602
|
declareViewRig(handle, out.viewRig);
|
|
579
603
|
};
|
|
580
604
|
if (typeof opts.onBeforeFrame === 'function') {
|
|
@@ -666,6 +690,7 @@ export function attachPlayCanvasModel(out, wall, canvas, src, opts, pending = []
|
|
|
666
690
|
if (bounds) {
|
|
667
691
|
out.frame = bounds;
|
|
668
692
|
viewer.fitTo(bounds.center, bounds.extent);
|
|
693
|
+
depth.refresh();
|
|
669
694
|
}
|
|
670
695
|
return out;
|
|
671
696
|
})();
|
package/js/inline3d-model.js
CHANGED
|
@@ -38,9 +38,9 @@
|
|
|
38
38
|
// your own origin (`/draco/`, `/basis/`) and `opts.decoderPath` moves that. See
|
|
39
39
|
// docs/authoring-inline-3d.md#compressed-gltf.
|
|
40
40
|
|
|
41
|
-
import { guardedAttach } from './inline3d-splat-shared.js';
|
|
41
|
+
import { guardedAttach, attachModelStereo, attachComfortDepth, resolveDepthOption, framedSubjectBounds } from './inline3d-splat-shared.js';
|
|
42
42
|
import * as THREE from 'three';
|
|
43
|
-
import { EyeCamera, EdgeFeather, DepthCursor, raycastHitTest } from './inline3d-three.js';
|
|
43
|
+
import { EyeCamera, EdgeFeather, DepthCursor, raycastHitTest, displayRig } from './inline3d-three.js';
|
|
44
44
|
import { resolveCursorOption } from './inline3d-cursor-depth.js';
|
|
45
45
|
import { CURSOR_MODEL_RAYS_PER_FRAME } from './inline3d-cursor-perf.js';
|
|
46
46
|
import { SceneViewer } from './inline3d-viewer.js';
|
|
@@ -403,6 +403,8 @@ function addModelUnguarded(wall, canvas, src, opts = {}) {
|
|
|
403
403
|
|
|
404
404
|
const paths = normalizeDecoderPath(decoderPath);
|
|
405
405
|
const injected = { draco: injectedDraco, ktx2: injectedKtx2, meshopt: injectedMeshopt };
|
|
406
|
+
// Opt-in comfort depth; a bad value throws here, at the call.
|
|
407
|
+
const depthOption = resolveDepthOption(opts.depth, '@displayxr/inline3d/model');
|
|
406
408
|
|
|
407
409
|
const viewer = new SceneViewer(THREE, canvas, {
|
|
408
410
|
virtualDisplayHeight,
|
|
@@ -416,6 +418,8 @@ function addModelUnguarded(wall, canvas, src, opts = {}) {
|
|
|
416
418
|
feather,
|
|
417
419
|
logTag: '[inline3d/model]',
|
|
418
420
|
viewerEase: opts.viewerEase,
|
|
421
|
+
// Opt-in; undefined = the viewer's own default (a transparent clear).
|
|
422
|
+
clearColor: opts.clearColor,
|
|
419
423
|
}).useEyeCamera(EyeCamera, EdgeFeather);
|
|
420
424
|
const cursorOpts = resolveCursorOption(opts.cursor, '@displayxr/inline3d/model');
|
|
421
425
|
// Amortised pick (a few rays a frame): the sprite follows the newest pointer position every
|
|
@@ -436,6 +440,7 @@ function addModelUnguarded(wall, canvas, src, opts = {}) {
|
|
|
436
440
|
setPose: (p) => viewer.setPose(p),
|
|
437
441
|
resetPose: () => viewer.resetPose(),
|
|
438
442
|
remove() {
|
|
443
|
+
depth.dispose();
|
|
439
444
|
handle?.remove();
|
|
440
445
|
viewer.dispose();
|
|
441
446
|
if (out.model) disposeTree(out.model);
|
|
@@ -450,6 +455,20 @@ function addModelUnguarded(wall, canvas, src, opts = {}) {
|
|
|
450
455
|
Object.defineProperty(out, 'renderScale', { get: () => viewer.effectiveRenderScale, enumerable: true, configurable: true });
|
|
451
456
|
Object.defineProperty(out, 'renderScaleRequested', { get: () => viewer.renderScale, enumerable: true, configurable: true });
|
|
452
457
|
|
|
458
|
+
// handle.setStereo / handle.rig (a depth slider; nothing is declared until it is called).
|
|
459
|
+
const stereo = attachModelStereo(out, { getTile: () => handle, vH: virtualDisplayHeight, makeDisplayRig: displayRig });
|
|
460
|
+
// `depth: 'auto'` (opt-in): the comfort factor under setStereo's, on the same display rig.
|
|
461
|
+
// Without it, inert: `depthFactor` reads 1 and nothing is measured or declared.
|
|
462
|
+
const depth = attachComfortDepth(out, {
|
|
463
|
+
option: depthOption,
|
|
464
|
+
canvas,
|
|
465
|
+
getTile: () => handle,
|
|
466
|
+
getBounds: () => (out.frame ? framedSubjectBounds(viewer) : null),
|
|
467
|
+
getVH: () => virtualDisplayHeight,
|
|
468
|
+
apply: (k) => stereo.setBase(k),
|
|
469
|
+
tag: '[inline3d/model]',
|
|
470
|
+
});
|
|
471
|
+
|
|
453
472
|
// Window first, content when it lands — same reasoning as ./splat: a grid should not appear
|
|
454
473
|
// one tile at a time in download order.
|
|
455
474
|
let handle = null;
|
|
@@ -528,6 +547,7 @@ function addModelUnguarded(wall, canvas, src, opts = {}) {
|
|
|
528
547
|
if (bounds) {
|
|
529
548
|
out.frame = bounds;
|
|
530
549
|
viewer.fitTo(bounds.center, bounds.extent);
|
|
550
|
+
depth.refresh();
|
|
531
551
|
}
|
|
532
552
|
return out;
|
|
533
553
|
})().catch((err) => {
|
|
@@ -53,6 +53,8 @@ export function addSplatDeferred(wall, canvas, src, opts) {
|
|
|
53
53
|
frame: null,
|
|
54
54
|
camera: null,
|
|
55
55
|
rig: null,
|
|
56
|
+
// The `depth` option's comfort factor in force (1 until measured, and always without it).
|
|
57
|
+
depthFactor: 1,
|
|
56
58
|
perf: null,
|
|
57
59
|
setPose: page ? pageOnly('setPose') : queue('setPose'),
|
|
58
60
|
resetPose: page ? pageOnly('resetPose') : queue('resetPose'),
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// Not here: measureSplatBounds (it measures a Spark mesh — import it from ./splat).
|
|
17
17
|
|
|
18
18
|
import { resolveRevealOption } from './inline3d-splat-effects.js';
|
|
19
|
-
import { CAPTURE_FITS, guardedAttach, playcanvasCannotRead, resolveControls } from './inline3d-splat-shared.js';
|
|
19
|
+
import { CAPTURE_FITS, guardedAttach, playcanvasCannotRead, resolveControls, resolveDepthOption } from './inline3d-splat-shared.js';
|
|
20
20
|
import { addSplatDeferred } from './inline3d-splat-deferred.js';
|
|
21
21
|
|
|
22
22
|
export { applySplatPerf, SPLAT_PERF_PRESETS } from './inline3d-splat-perf.js';
|
|
@@ -44,6 +44,7 @@ function addSplatUnguarded(wall, canvas, src, opts = {}) {
|
|
|
44
44
|
);
|
|
45
45
|
}
|
|
46
46
|
resolveControls(opts);
|
|
47
|
+
resolveDepthOption(opts.depth);
|
|
47
48
|
resolveRevealOption(opts.reveal);
|
|
48
49
|
const why = playcanvasCannotRead(src, opts);
|
|
49
50
|
if (why) throw new Error(`@displayxr/inline3d/splat: ${why}`);
|