@displayxr/inline3d 1.38.0 → 1.40.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 +162 -0
- package/call.d.ts +9 -1
- package/camera.d.ts +17 -0
- package/dist/call.js +4 -4
- package/index.d.ts +155 -18
- package/js/call/element.js +2 -0
- package/js/call/options.js +4 -0
- package/js/camera/pair-cursor.js +166 -0
- package/js/inline3d-call.js +28 -4
- package/js/inline3d-camera.js +30 -4
- package/js/inline3d-cursor-option.js +18 -0
- package/js/inline3d-model-entry.js +7 -0
- package/js/inline3d-model-playcanvas.js +5 -0
- package/js/inline3d-model.js +12 -0
- package/js/inline3d-splat-pc-entry.js +7 -1
- package/js/inline3d-splat-playcanvas.js +105 -4
- package/js/inline3d-splat-shared.js +17 -0
- package/js/inline3d-splat.js +12 -0
- package/js/inline3d-viewer.js +35 -5
- package/js/inline3d.js +667 -17
- package/model.d.ts +8 -1
- package/package.json +1 -1
- package/splat.d.ts +8 -1
package/js/inline3d.js
CHANGED
|
@@ -422,6 +422,70 @@ function nowMs() {
|
|
|
422
422
|
// report. Per-window override: `firstWovenHoldMs` on any add*() call.
|
|
423
423
|
const FIRST_WOVEN_HOLD_MS = 1200;
|
|
424
424
|
|
|
425
|
+
// The browser's own join report (DisplayXR Browser PR #258, Windows, behind a feature flag):
|
|
426
|
+
// `XRDisplayLayer.wovenState` — 'pending' (no presented frame has reported this layer yet: from
|
|
427
|
+
// construction, again after close(), and whenever the reply does not list it), 'woven' (the
|
|
428
|
+
// latest swapped frame put this layer's rect into the weave) or 'withheld' (with a why-token in
|
|
429
|
+
// `withheldReason`: no-quad / no-identity / no-join / cross-pass:mono / cross-pass:mono(cover,2:1)
|
|
430
|
+
// / resolve-dropped). It is a per-frame LEVEL, nothing latched, read off the GetLatestViews reply,
|
|
431
|
+
// and the layer is not an EventTarget — so polling it once per session frame is the whole API.
|
|
432
|
+
//
|
|
433
|
+
// Where it exists, firstWoven/rewoven settle on it (`confirmed: true`) instead of on the hold.
|
|
434
|
+
// Where it does not (mac, Linux, Android, and every Windows build without the patch: the
|
|
435
|
+
// attribute is deliberately absent there, so `in` on the prototype is an honest feature test)
|
|
436
|
+
// the hold below is used exactly as before.
|
|
437
|
+
const WOVEN_STATES = new Set(['pending', 'woven', 'withheld']);
|
|
438
|
+
// The report trails the join by 1-3 renderer frames. After a rect change that never reads
|
|
439
|
+
// anything but 'woven' (a move the browser rejoins without a gap), a 'woven' read only means the
|
|
440
|
+
// NEW rect once this many session frames have passed since the change was seen; before that it
|
|
441
|
+
// can still be the old rect's.
|
|
442
|
+
const WOVEN_STATE_LAG_FRAMES = 3;
|
|
443
|
+
|
|
444
|
+
// The display calls that wait for a layer's first frame (see Inline3D._layerCall). All three
|
|
445
|
+
// answer from the browser's weave session, which only exists once a layer has delivered a frame:
|
|
446
|
+
// before that getDisplayInfo() is null, getRenderingModes() is [] and requestRenderingMode() is
|
|
447
|
+
// `false` ("not forwarded").
|
|
448
|
+
const LAYER_CALLS_AFTER_FIRST_FRAME = new Set(['getDisplayInfo', 'getRenderingModes', 'requestRenderingMode']);
|
|
449
|
+
// The two READS among them. A read that comes back null / empty before the cap is not an answer
|
|
450
|
+
// yet (the weave session can lag the release signal by a few frames): it is asked again on the
|
|
451
|
+
// next release signal. requestRenderingMode resolves void, so it is forwarded once, not retried.
|
|
452
|
+
const LAYER_READS_RETRIED_WHEN_EMPTY = new Set(['getDisplayInfo', 'getRenderingModes']);
|
|
453
|
+
|
|
454
|
+
/** A display read's "nothing yet" shape: null / undefined, or an empty list. */
|
|
455
|
+
function isEmptyLayerAnswer(r) {
|
|
456
|
+
return r === null || r === undefined || (Array.isArray(r) && r.length === 0);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Does this browser build report `XRDisplayLayer.wovenState`? Feature-tested on the prototype. */
|
|
460
|
+
function hasWovenState() {
|
|
461
|
+
try {
|
|
462
|
+
const L = typeof XRDisplayLayer === 'function' ? XRDisplayLayer : null;
|
|
463
|
+
return !!(L && L.prototype && 'wovenState' in L.prototype);
|
|
464
|
+
} catch {
|
|
465
|
+
return false;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** This layer's woven state, or null when the read fails or returns something not in the contract. */
|
|
470
|
+
function readWovenState(layer) {
|
|
471
|
+
try {
|
|
472
|
+
const s = layer ? layer.wovenState : null;
|
|
473
|
+
return WOVEN_STATES.has(s) ? s : null;
|
|
474
|
+
} catch {
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** The why-token while 'withheld', else null. */
|
|
480
|
+
function readWithheldReason(layer) {
|
|
481
|
+
try {
|
|
482
|
+
const r = layer ? layer.withheldReason : null;
|
|
483
|
+
return typeof r === 'string' && r ? r : null;
|
|
484
|
+
} catch {
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
425
489
|
// The easing option, validated here rather than in the state machine: the sequencer falls back
|
|
426
490
|
// silently (it has no opinion about a caller's config), but a typo in `createInline3D` is worth
|
|
427
491
|
// exactly one warning — a page that asked for 'ease-in-out' and got smoothstep should know.
|
|
@@ -693,10 +757,51 @@ function chromeTextPlates(root) {
|
|
|
693
757
|
/** A pending rewoven() settles anyway ('hold-capped') after this many holds from the call. */
|
|
694
758
|
const REWOVEN_MAX_HOLDS = 4;
|
|
695
759
|
|
|
696
|
-
/**
|
|
760
|
+
/**
|
|
761
|
+
* The CSS size + dpr + PAGE position of a window's canvas: what a pending rewoven() restarts on,
|
|
762
|
+
* and what a rect cover goes up on.
|
|
763
|
+
*
|
|
764
|
+
* Position joined the key in 1.38: a canvas moved to another place on the page, at the same size,
|
|
765
|
+
* goes through the same identity gap as a resized one (a kiosk demo moving ONE woven canvas between
|
|
766
|
+
* screens measured 0.4-1.2 s of black at the new rect). It is measured in PAGE coordinates
|
|
767
|
+
* (viewport rect + document scroll) so that scrolling the document is not a move: a scrolled
|
|
768
|
+
* canvas keeps its identity (rule 11). Rounded to whole CSS px so sub-pixel layout jitter is not
|
|
769
|
+
* a move either. A scroll inside a nested scroller still reads as one; that is the price of one
|
|
770
|
+
* getBoundingClientRect per frame instead of walking the scroll ancestors.
|
|
771
|
+
*/
|
|
697
772
|
function boxKeyOf(canvas) {
|
|
698
773
|
const dpr = (typeof window !== 'undefined' && window.devicePixelRatio) || 1;
|
|
699
|
-
|
|
774
|
+
let pos = '';
|
|
775
|
+
if (typeof canvas.getBoundingClientRect === 'function') {
|
|
776
|
+
const r = canvas.getBoundingClientRect() || {};
|
|
777
|
+
const sx = (typeof window !== 'undefined' && window.scrollX) || 0;
|
|
778
|
+
const sy = (typeof window !== 'undefined' && window.scrollY) || 0;
|
|
779
|
+
pos = `+${Math.round((r.left ?? r.x ?? 0) + sx)},${Math.round((r.top ?? r.y ?? 0) + sy)}`;
|
|
780
|
+
}
|
|
781
|
+
return `${canvas.clientWidth}x${canvas.clientHeight}@${dpr}${pos}`;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/** A scene canvas's CSS box and dpr, as addScene's `onResize` receives it. */
|
|
785
|
+
function sceneBoxOf(canvas) {
|
|
786
|
+
const dpr = (typeof window !== 'undefined' && window.devicePixelRatio) || 1;
|
|
787
|
+
return { width: canvas.clientWidth || 0, height: canvas.clientHeight || 0, dpr };
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* The `rectCover` option of every add*(): null (off) or `{ color, snapshot }`. Off by default in
|
|
792
|
+
* the core (frozen defaults); `./splat` and `./model` pass 'auto'. A bad value throws at the call:
|
|
793
|
+
* a typo here would otherwise silently leave a page without the cover it asked for.
|
|
794
|
+
*/
|
|
795
|
+
function resolveRectCover(v) {
|
|
796
|
+
if (v === undefined || v === null || v === false || v === 'off') return null;
|
|
797
|
+
if (v === 'auto' || v === true) return { color: '#000', snapshot: true };
|
|
798
|
+
if (typeof v === 'object') {
|
|
799
|
+
if (v.color !== undefined && typeof v.color !== 'string') {
|
|
800
|
+
throw new TypeError('[inline3d] rectCover.color must be a CSS color string.');
|
|
801
|
+
}
|
|
802
|
+
return { color: v.color || '#000', snapshot: v.snapshot !== false };
|
|
803
|
+
}
|
|
804
|
+
throw new TypeError(`[inline3d] rectCover: expected 'auto', 'off' or { color?, snapshot? }; got ${JSON.stringify(v)}.`);
|
|
700
805
|
}
|
|
701
806
|
|
|
702
807
|
class Inline3D {
|
|
@@ -709,6 +814,11 @@ class Inline3D {
|
|
|
709
814
|
this.session = session;
|
|
710
815
|
this.refSpace = refSpace;
|
|
711
816
|
this._windows = new Map(); // canvas -> window record
|
|
817
|
+
// Double-attach detection. `_claims`: canvases a scene subpath entry (addSplat / addModel) has
|
|
818
|
+
// taken before its core window exists (canvas -> { token, method }); `_doubleWarned`:
|
|
819
|
+
// canvases already warned about, so a page calling add*() per render warns once.
|
|
820
|
+
this._claims = new Map();
|
|
821
|
+
this._doubleWarned = new WeakSet();
|
|
712
822
|
this._globalOverlays = new Set(); // page-global overlays excluded from EVERY window
|
|
713
823
|
// el -> Set(window) currently excluding it. Isolation (will-change) is a GLOBAL
|
|
714
824
|
// property of the element while exclusion is PER-WINDOW, so the promotion has to
|
|
@@ -946,6 +1056,8 @@ class Inline3D {
|
|
|
946
1056
|
* @param {number} [opts.cornerRadius=0] round each eye's corners in buffer px (CSS
|
|
947
1057
|
* border-radius can't: it would round the packed SBS square's outer corners and
|
|
948
1058
|
* come out lopsided after the eye-split).
|
|
1059
|
+
* @param {'auto'|'off'|{color?:string, snapshot?:boolean}} [opts.rectCover='off'] (every add*())
|
|
1060
|
+
* cover the canvas across a later move/resize until rewoven() settles; see _rectCoverCheck.
|
|
949
1061
|
* @param {number} [opts.feather=0] fade each eye's outer edges to transparent over this
|
|
950
1062
|
* many buffer px, so the 3D window dissolves into the page instead of ending at a
|
|
951
1063
|
* hard rectangle. Same reason CSS can't do it: a mask/filter on the canvas applies
|
|
@@ -954,7 +1066,9 @@ class Inline3D {
|
|
|
954
1066
|
* @returns {{remove():void}}
|
|
955
1067
|
*/
|
|
956
1068
|
addImage(canvas, source, opts = {}) {
|
|
1069
|
+
this._warnDoubleAttach(canvas, 'addImage', opts._dxrClaim);
|
|
957
1070
|
const win = this._register(canvas, 'image', opts);
|
|
1071
|
+
win.method = 'addImage';
|
|
958
1072
|
win.ready = loadImage(source).then((img) => {
|
|
959
1073
|
win.img = img;
|
|
960
1074
|
win.repaint();
|
|
@@ -970,7 +1084,9 @@ class Inline3D {
|
|
|
970
1084
|
* @returns {{remove():void}}
|
|
971
1085
|
*/
|
|
972
1086
|
addVideo(canvas, video, opts = {}) {
|
|
1087
|
+
this._warnDoubleAttach(canvas, 'addVideo', opts._dxrClaim);
|
|
973
1088
|
const win = this._register(canvas, 'video', opts);
|
|
1089
|
+
win.method = 'addVideo';
|
|
974
1090
|
win.video = video;
|
|
975
1091
|
return this._handle(canvas, win);
|
|
976
1092
|
}
|
|
@@ -999,6 +1115,10 @@ class Inline3D {
|
|
|
999
1115
|
* falls back to `virtualDisplayHeight` if one was given (that pair is the one reason
|
|
1000
1116
|
* to pass both) — either way the window still weaves.
|
|
1001
1117
|
* @param {Element} [opts.observe=canvas] element whose visibility gates lazy create/close.
|
|
1118
|
+
* @param {(box:{width:number, height:number, dpr:number}) => void} [opts.onResize] called
|
|
1119
|
+
* inside the ResizeObserver callback when the canvas's CSS box or dpr changes, i.e.
|
|
1120
|
+
* before the paint: resize your backing store and draw one frame here and the old store
|
|
1121
|
+
* is never shown stretched onto the new box. Only on a real change; errors are caught.
|
|
1002
1122
|
* @param {() => void} [opts.onLayerLost] called once when this window's weave layer goes away
|
|
1003
1123
|
* for good — the session ended, or the layer could not be created. YOU own a scene
|
|
1004
1124
|
* canvas's pixels, so this is the SDK's only way to tell you that the side-by-side pair
|
|
@@ -1011,8 +1131,10 @@ class Inline3D {
|
|
|
1011
1131
|
// The rig and the height describe the same one slot in the layer init, so warn where a
|
|
1012
1132
|
// caller has said it twice — silently dropping one of two things the page explicitly asked
|
|
1013
1133
|
// for is how a scene ends up framed at a scale nobody chose.
|
|
1134
|
+
this._warnDoubleAttach(canvas, 'addScene', opts._dxrClaim);
|
|
1014
1135
|
if (opts.viewRig && opts.virtualDisplayHeight !== undefined) noteRigWinsOverHeight();
|
|
1015
1136
|
const win = this._register(canvas, 'scene', { virtualDisplayHeight: 0.24, ...opts });
|
|
1137
|
+
win.method = 'addScene';
|
|
1016
1138
|
win.onFrame = onFrame;
|
|
1017
1139
|
win.ownsBuffer = false; // the app sizes a scene canvas; we never touch canvas.width/height
|
|
1018
1140
|
// The SDK's own renderers (./viewer, ./splat, ./model) clamp their buffers before sizing and
|
|
@@ -1021,6 +1143,76 @@ class Inline3D {
|
|
|
1021
1143
|
return this._handle(canvas, win);
|
|
1022
1144
|
}
|
|
1023
1145
|
|
|
1146
|
+
// ── double-attach detection ───────────────────────────────────────────────────────────
|
|
1147
|
+
//
|
|
1148
|
+
// A second add*() on a canvas that is still registered closes its layer and builds a new one: a
|
|
1149
|
+
// fresh identity gap (rule 2), and for ./splat and ./model a second renderer on the same
|
|
1150
|
+
// context. A kiosk demo attached its stage twice per screen visit this way without noticing.
|
|
1151
|
+
// The SDK says so, once per canvas, and then does exactly what it always did: a WARNING, so no
|
|
1152
|
+
// page changes behavior (a page that re-adds to swap pictures keeps working).
|
|
1153
|
+
|
|
1154
|
+
/** The live registration on `canvas` other than the one `token` belongs to: its method, or null. */
|
|
1155
|
+
_liveRegistration(canvas, token) {
|
|
1156
|
+
const claim = this._claims.get(canvas);
|
|
1157
|
+
if (claim && claim.token !== token) return claim.method;
|
|
1158
|
+
const win = this._windows.get(canvas);
|
|
1159
|
+
return win ? win.method || 'add*' : null;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
_warnDoubleAttach(canvas, method, token) {
|
|
1163
|
+
const live = this._liveRegistration(canvas, token);
|
|
1164
|
+
if (!live || this._doubleWarned.has(canvas)) return;
|
|
1165
|
+
this._doubleWarned.add(canvas);
|
|
1166
|
+
console.warn(
|
|
1167
|
+
`[inline3d] ${method}() on a canvas that is already registered on this inline-3D session ` +
|
|
1168
|
+
`(by ${live}(), not yet removed). This call is rebuilding the canvas's layer (a fresh ` +
|
|
1169
|
+
'0.4-1.2 s identity gap) or putting a second renderer on its context. Change what is IN ' +
|
|
1170
|
+
'the canvas instead (setSource, your onFrame, a redrawn source canvas), or call ' +
|
|
1171
|
+
'handle.remove() on the first registration first. (Warned once per canvas.)',
|
|
1172
|
+
canvas
|
|
1173
|
+
);
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
/**
|
|
1177
|
+
* For the scene subpath entries (via guardedAttach in ./inline3d-splat-shared.js), which build
|
|
1178
|
+
* their renderer BEFORE the core window exists, so the core's own check would come too late.
|
|
1179
|
+
* Warns on a live registration, then claims the canvas for this call either way: returns
|
|
1180
|
+
* `{ opts, own }` — attach with `opts` (it carries the claim token down to this entry's own
|
|
1181
|
+
* addScene, which then does not warn a second time), then `own(handle)`. A nested entry
|
|
1182
|
+
* (./model's engine:'three' route calls ./model/three's addModel) inherits the claim.
|
|
1183
|
+
*/
|
|
1184
|
+
_attachGuard(canvas, method, opts = {}) {
|
|
1185
|
+
const claim = this._claims.get(canvas);
|
|
1186
|
+
if (opts && opts._dxrClaim && claim && claim.token === opts._dxrClaim) return { opts, own: () => {} };
|
|
1187
|
+
this._warnDoubleAttach(canvas, method, null);
|
|
1188
|
+
const token = {};
|
|
1189
|
+
return { opts: { ...opts, _dxrClaim: token }, own: (out) => this._claim(canvas, method, out, token) };
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/**
|
|
1193
|
+
* Remember that a subpath handle holds `canvas` until that handle's remove() (the latest call
|
|
1194
|
+
* wins). The adapters REASSIGN `out.remove` when their module loads, so the release rides an
|
|
1195
|
+
* accessor that wraps whatever is assigned.
|
|
1196
|
+
*/
|
|
1197
|
+
_claim(canvas, method, out, token) {
|
|
1198
|
+
if (!this._running || !out || typeof out !== 'object') return;
|
|
1199
|
+
const claim = { token, method };
|
|
1200
|
+
this._claims.set(canvas, claim);
|
|
1201
|
+
let impl = out.remove;
|
|
1202
|
+
const remove = (...args) => {
|
|
1203
|
+
if (this._claims.get(canvas) === claim) this._claims.delete(canvas);
|
|
1204
|
+
return typeof impl === 'function' ? impl.apply(out, args) : undefined;
|
|
1205
|
+
};
|
|
1206
|
+
Object.defineProperty(out, 'remove', {
|
|
1207
|
+
configurable: true,
|
|
1208
|
+
enumerable: true,
|
|
1209
|
+
get: () => remove,
|
|
1210
|
+
set: (fn) => {
|
|
1211
|
+
impl = fn;
|
|
1212
|
+
},
|
|
1213
|
+
});
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1024
1216
|
/**
|
|
1025
1217
|
* The handle every add*() returns. `exclude(el)` marks 2D DOM painted over this window
|
|
1026
1218
|
* (a hover plate, a play badge) so the weave leaves it crisp 2D instead of garbling it
|
|
@@ -1220,15 +1412,36 @@ class Inline3D {
|
|
|
1220
1412
|
// Read-only counters, for pages that want to see the load-induced mono fallback rather
|
|
1221
1413
|
// than wait for a bug report about "blinking". Scene windows only; 0/0 elsewhere.
|
|
1222
1414
|
stats: () => ({ frames: win.frames, monoFrames: win.monoFrames }),
|
|
1415
|
+
/**
|
|
1416
|
+
* The browser's own report for this window's layer, read live: 'pending' | 'woven' |
|
|
1417
|
+
* 'withheld', or null on a browser that does not report it (the attribute is absent: mac,
|
|
1418
|
+
* Linux, Android, Windows builds without it). 'pending' too while the window has no live
|
|
1419
|
+
* layer (lazy, scrolled away), matching what the browser says of a closed layer. For
|
|
1420
|
+
* diagnostics and pages; firstWoven/rewoven already settle on it.
|
|
1421
|
+
*/
|
|
1422
|
+
get wovenState() {
|
|
1423
|
+
if (!hasWovenState()) return null;
|
|
1424
|
+
if (!win.layer) return 'pending';
|
|
1425
|
+
return readWovenState(win.layer);
|
|
1426
|
+
},
|
|
1427
|
+
/** The browser's why-token while `wovenState` is 'withheld' (e.g. 'no-identity'), else null. */
|
|
1428
|
+
get withheldReason() {
|
|
1429
|
+
if (!hasWovenState() || !win.layer) return null;
|
|
1430
|
+
return readWovenState(win.layer) === 'withheld' ? readWithheldReason(win.layer) : null;
|
|
1431
|
+
},
|
|
1223
1432
|
/**
|
|
1224
1433
|
* Resolves ONCE, never rejects: `{ woven, confirmed, reason, ms }`.
|
|
1225
1434
|
*
|
|
1226
1435
|
* `woven: true` — the window has drawn a stereo frame on a layer that has existed for
|
|
1227
1436
|
* `firstWovenHoldMs` (default 1200). That is the moment to drop a poster covering the
|
|
1228
|
-
* canvas.
|
|
1229
|
-
*
|
|
1230
|
-
*
|
|
1231
|
-
*
|
|
1437
|
+
* canvas. On a browser without `XRDisplayLayer.wovenState` this is the browser's worst
|
|
1438
|
+
* case, measured by the SDK (`confirmed: false`, reason 'hold-elapsed'). On one that has it,
|
|
1439
|
+
* it is the browser's report: `confirmed: true`, reason 'woven', on the first 'woven' read
|
|
1440
|
+
* once a stereo frame is drawn, with no hold. Same page code on both.
|
|
1441
|
+
*
|
|
1442
|
+
* On the report, a layer that stays 'withheld' still settles after a cap (four holds, at
|
|
1443
|
+
* least 4.8 s): `woven: false, confirmed: true, reason: <withheldReason>` (or 'pending').
|
|
1444
|
+
* That is a level, not a loss: the canvas is NOT taken flat, and `rewoven()` keeps working.
|
|
1232
1445
|
*
|
|
1233
1446
|
* `woven: false` — this window will not weave: `reason` is `'layer-failed'`,
|
|
1234
1447
|
* `'session-ended'` or `'removed'`. The SDK has already taken an image/video canvas flat,
|
|
@@ -1264,12 +1477,19 @@ class Inline3D {
|
|
|
1264
1477
|
* already woven but whose rect is about to change (fullscreen, a layout resize): the
|
|
1265
1478
|
* browser re-registers the moved rect and the same identity gap as a fresh canvas
|
|
1266
1479
|
* applies, so cover the canvas across the change and release on this. A change of the
|
|
1267
|
-
* canvas's CSS size
|
|
1268
|
-
*
|
|
1480
|
+
* canvas's CSS size, devicePixelRatio or page position (since 1.38: a moved canvas goes
|
|
1481
|
+
* through the same gap; document scroll is not a move) while it is pending restarts the
|
|
1482
|
+
* hold (checked every frame, for every window kind), and it settles
|
|
1269
1483
|
* anyway with `reason: 'hold-capped'` four holds after the call, so a size that never stops
|
|
1270
1484
|
* animating cannot keep a cover up. Calling it again while pending returns the same
|
|
1271
1485
|
* promise, restarted. Before the first join it IS `firstWoven`; on a window that will not
|
|
1272
1486
|
* weave it resolves that `woven: false` result. Same shape, never rejects.
|
|
1487
|
+
*
|
|
1488
|
+
* On a browser with `XRDisplayLayer.wovenState` it settles on the report (`confirmed: true`,
|
|
1489
|
+
* reason 'woven'): on a 'woven' read that follows a 'withheld'/'pending' read seen after the
|
|
1490
|
+
* call, or three session frames after the last box change, so a stale 'woven' from the old
|
|
1491
|
+
* rect never settles it. The cap stays: a layer that never leaves 'withheld' settles `woven:
|
|
1492
|
+
* false, confirmed: true, reason: <withheldReason>` four holds after the call.
|
|
1273
1493
|
*/
|
|
1274
1494
|
rewoven: () => this._rewoven(win),
|
|
1275
1495
|
};
|
|
@@ -1482,13 +1702,104 @@ class Inline3D {
|
|
|
1482
1702
|
)
|
|
1483
1703
|
);
|
|
1484
1704
|
}
|
|
1705
|
+
// A layer that has not delivered a frame yet answers getDisplayInfo() with null and
|
|
1706
|
+
// getRenderingModes() with an empty list, and null is documented as "no glasses-free
|
|
1707
|
+
// display". A page that asks right after add*() (the natural place) would be told there is
|
|
1708
|
+
// no display. So wait for this layer's release signal (see _tickLayerFramed) and ask then.
|
|
1709
|
+
// Capped at the first-woven cap, so a layer that never frames still answers (null/empty, as
|
|
1710
|
+
// a real absence). Re-entered after the wait: the layer may have closed (-> the rejection
|
|
1711
|
+
// above) or been rebuilt (-> wait for the new one) meanwhile.
|
|
1712
|
+
const deferred = LAYER_CALLS_AFTER_FIRST_FRAME.has(method);
|
|
1713
|
+
if (deferred && !win.layerFramed && this._layerReadPending(win)) {
|
|
1714
|
+
return this._whenLayerFramed(win).then(() => this._layerCall(win, method, label, args));
|
|
1715
|
+
}
|
|
1485
1716
|
// Wrapped so a SYNCHRONOUS throw from the browser (requestRenderingMode raises TypeError
|
|
1486
1717
|
// that way for a non-2-view mode) arrives as a rejection like every other failure.
|
|
1718
|
+
let p;
|
|
1487
1719
|
try {
|
|
1488
|
-
|
|
1720
|
+
p = Promise.resolve(win.layer[method](...args));
|
|
1489
1721
|
} catch (e) {
|
|
1490
1722
|
return Promise.reject(e);
|
|
1491
1723
|
}
|
|
1724
|
+
if (!LAYER_READS_RETRIED_WHEN_EMPTY.has(method) || !this._layerReadPending(win)) return p;
|
|
1725
|
+
// Released, but not answered for real yet on this layer. The weave session can trail the
|
|
1726
|
+
// release signal, so a null / empty answer before the cap is "not ready", never returned:
|
|
1727
|
+
// wait for the next release signal (the next 'woven' read, or the next stereo frame) and ask
|
|
1728
|
+
// again. The first real answer marks the layer answered, and later calls go straight through.
|
|
1729
|
+
const layer = win.layer;
|
|
1730
|
+
return p.then((r) => {
|
|
1731
|
+
if (win.layer !== layer) return r; // that layer's own answer; the window moved on
|
|
1732
|
+
if (!isEmptyLayerAnswer(r)) {
|
|
1733
|
+
win.lfAnswered = true;
|
|
1734
|
+
return r;
|
|
1735
|
+
}
|
|
1736
|
+
if (!this._layerReadPending(win)) return r; // at the cap: null / [] is a real absence
|
|
1737
|
+
return this._whenLayerFramed(win).then(() => this._layerCall(win, method, label, args));
|
|
1738
|
+
});
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
/** The first-frame wait cap: the first-woven cap (four holds, never under four default holds). */
|
|
1742
|
+
_layerFramedCap(win) {
|
|
1743
|
+
return REWOVEN_MAX_HOLDS * Math.max(win.fwHoldMs, FIRST_WOVEN_HOLD_MS);
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
/**
|
|
1747
|
+
* Is a display read on `win` still owed a real answer by its current layer — not yet answered
|
|
1748
|
+
* non-empty, and the cap not passed? (Whether it waits for the FIRST release is `layerFramed`.)
|
|
1749
|
+
*/
|
|
1750
|
+
_layerReadPending(win) {
|
|
1751
|
+
if (!win.layer || win.lfAnswered || win.lfCapped) return false;
|
|
1752
|
+
if (nowMs() - win.lfAt >= this._layerFramedCap(win)) {
|
|
1753
|
+
win.lfCapped = true;
|
|
1754
|
+
return false;
|
|
1755
|
+
}
|
|
1756
|
+
return true;
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
/**
|
|
1760
|
+
* Resolves once `win`'s current layer has delivered its first frame, when the layer goes away
|
|
1761
|
+
* or is rebuilt, or at the cap (measured from the layer's construction, as firstWoven's is).
|
|
1762
|
+
* Never rejects. The cap is checked per session frame on the same clock as firstWoven, and
|
|
1763
|
+
* backed by a real timer for a session whose frames have stopped altogether.
|
|
1764
|
+
*/
|
|
1765
|
+
_whenLayerFramed(win) {
|
|
1766
|
+
return new Promise((resolve) => {
|
|
1767
|
+
const layer = win.layer;
|
|
1768
|
+
const waiter = { resolve, timer: null };
|
|
1769
|
+
const left = Math.max(0, this._layerFramedCap(win) - (nowMs() - win.lfAt));
|
|
1770
|
+
waiter.timer = setTimeout(() => {
|
|
1771
|
+
const i = win.framedWaiters.indexOf(waiter);
|
|
1772
|
+
if (i >= 0) win.framedWaiters.splice(i, 1);
|
|
1773
|
+
if (win.layer === layer) win.lfCapped = true;
|
|
1774
|
+
resolve();
|
|
1775
|
+
}, left);
|
|
1776
|
+
if (waiter.timer && typeof waiter.timer.unref === 'function') waiter.timer.unref();
|
|
1777
|
+
win.framedWaiters.push(waiter);
|
|
1778
|
+
});
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
/**
|
|
1782
|
+
* Per session frame, after the draw, until the current layer has framed and while reads wait.
|
|
1783
|
+
* The release signal: where the browser reports `wovenState`, a 'woven' read (a 'withheld'
|
|
1784
|
+
* read right after construction comes before the weave session can answer, so it does not
|
|
1785
|
+
* count); elsewhere, a frame once this layer has carried a stereo frame (the firstWoven signal,
|
|
1786
|
+
* with no hold). Every such frame releases the waiting reads, so a read that came back empty
|
|
1787
|
+
* is asked again on the next one. Capped at _layerFramedCap from the layer's construction.
|
|
1788
|
+
*/
|
|
1789
|
+
_tickLayerFramed(win) {
|
|
1790
|
+
const ws = win.wsLive ? readWovenState(win.layer) : null;
|
|
1791
|
+
if (ws ? ws === 'woven' : win.lfStereo) this._noteLayerFramed(win, true);
|
|
1792
|
+
else if (win.framedWaiters.length && !this._layerReadPending(win)) this._noteLayerFramed(win, false);
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
/** The layer framed (mark = true), or went away / capped (false): release every waiting read. */
|
|
1796
|
+
_noteLayerFramed(win, mark = true) {
|
|
1797
|
+
if (mark) win.layerFramed = true;
|
|
1798
|
+
const ws = win.framedWaiters.splice(0);
|
|
1799
|
+
for (const w of ws) {
|
|
1800
|
+
clearTimeout(w.timer);
|
|
1801
|
+
w.resolve();
|
|
1802
|
+
}
|
|
1492
1803
|
}
|
|
1493
1804
|
|
|
1494
1805
|
/** The first window currently holding a live layer, or null. Every display call needs one. */
|
|
@@ -2002,6 +2313,12 @@ class Inline3D {
|
|
|
2002
2313
|
// therefore already fail to build its layer) inside this call.
|
|
2003
2314
|
onLayerLost: typeof opts.onLayerLost === 'function' ? opts.onLayerLost : null,
|
|
2004
2315
|
layerLostSent: false,
|
|
2316
|
+
// Scene windows only (addScene's `onResize`): called inside the ResizeObserver callback, so
|
|
2317
|
+
// a page-drawn window can resize its store and draw before the paint. Read here for the same
|
|
2318
|
+
// reason as onLayerLost: a non-lazy window starts its size watch inside this call.
|
|
2319
|
+
onResize: kind === 'scene' && typeof opts.onResize === 'function' ? opts.onResize : null,
|
|
2320
|
+
resizeBox: null,
|
|
2321
|
+
inResize: false,
|
|
2005
2322
|
ready: null,
|
|
2006
2323
|
ownsBuffer: kind !== 'scene',
|
|
2007
2324
|
cornerRadius: opts.cornerRadius || 0,
|
|
@@ -2054,7 +2371,29 @@ class Inline3D {
|
|
|
2054
2371
|
// firstWoven's two halves again, counted from the call (and from each later box change).
|
|
2055
2372
|
rw: null,
|
|
2056
2373
|
rwGone: null, // the reason this window will never weave again, once one arrived
|
|
2374
|
+
// Whether the CURRENT layer is on a browser that reports `wovenState` (set per layer in
|
|
2375
|
+
// _activate). False keeps firstWoven/rewoven on the hold, exactly as before the report.
|
|
2376
|
+
wsLive: false,
|
|
2377
|
+
// The rect cover (opts.rectCover; see _rectCoverCheck), or null when off.
|
|
2378
|
+
rc: null,
|
|
2379
|
+
// Display reads wait for the current layer's first frame (see _layerCall). `layerFramed`:
|
|
2380
|
+
// its release signal has been seen; `lfAnswered`: a read got a real (non-empty) answer from
|
|
2381
|
+
// it; `lfStereo`: it has carried a stereo frame; `lfAt`: when it was built; `lfCapped`: the
|
|
2382
|
+
// wait cap ran out for it; `framedWaiters`: reads queued meanwhile.
|
|
2383
|
+
layerFramed: false,
|
|
2384
|
+
lfAnswered: false,
|
|
2385
|
+
lfStereo: false,
|
|
2386
|
+
lfAt: 0,
|
|
2387
|
+
lfCapped: false,
|
|
2388
|
+
framedWaiters: [],
|
|
2057
2389
|
};
|
|
2390
|
+
const rcCfg = resolveRectCover(opts.rectCover);
|
|
2391
|
+
if (rcCfg) {
|
|
2392
|
+
// box: the last key seen (null = not looked yet); el: the cover element, made on first use;
|
|
2393
|
+
// up: showing; snapped/snapWanted: this cycle's one snapshot; waiting: the rewoven promise
|
|
2394
|
+
// that takes it down; w/h: the CSS box at the last look (the snapshot's source aspect).
|
|
2395
|
+
win.rc = { cfg: rcCfg, box: null, el: null, up: false, snapped: false, snapWanted: false, waiting: null, w: 0, h: 0 };
|
|
2396
|
+
}
|
|
2058
2397
|
win.fwPromise = new Promise((resolve) => {
|
|
2059
2398
|
win.fwResolve = resolve;
|
|
2060
2399
|
});
|
|
@@ -2074,6 +2413,7 @@ class Inline3D {
|
|
|
2074
2413
|
this._deactivate(win);
|
|
2075
2414
|
this._windows.delete(canvas);
|
|
2076
2415
|
this._settleFirstWoven(win, false, 'removed');
|
|
2416
|
+
this._dropCover(win);
|
|
2077
2417
|
}
|
|
2078
2418
|
|
|
2079
2419
|
_onIntersect(entries) {
|
|
@@ -2148,6 +2488,14 @@ class Inline3D {
|
|
|
2148
2488
|
win.fwLayerAt = nowMs();
|
|
2149
2489
|
win.fwStereo = false;
|
|
2150
2490
|
}
|
|
2491
|
+
win.wsLive = hasWovenState();
|
|
2492
|
+
// A new layer has delivered nothing yet (see _layerCall). Reads queued on the previous one
|
|
2493
|
+
// were released by _deactivate and re-enter against this one.
|
|
2494
|
+
win.layerFramed = false;
|
|
2495
|
+
win.lfAnswered = false;
|
|
2496
|
+
win.lfStereo = false;
|
|
2497
|
+
win.lfCapped = false;
|
|
2498
|
+
win.lfAt = nowMs();
|
|
2151
2499
|
this._restartRewoven(win);
|
|
2152
2500
|
win.layerLostSent = false; // a live layer again: a future loss is worth reporting again
|
|
2153
2501
|
// Nothing about the hardware state is re-asserted here, and that is the point: the panel's
|
|
@@ -2189,6 +2537,7 @@ class Inline3D {
|
|
|
2189
2537
|
}
|
|
2190
2538
|
|
|
2191
2539
|
_deactivate(win) {
|
|
2540
|
+
this._noteLayerFramed(win, false); // queued display reads re-check and fail as before
|
|
2192
2541
|
this._stopOverlayScan(win);
|
|
2193
2542
|
this._stopSizeWatch(win);
|
|
2194
2543
|
if (win.layer) {
|
|
@@ -2570,6 +2919,14 @@ class Inline3D {
|
|
|
2570
2919
|
_startSizeWatch(win) {
|
|
2571
2920
|
if (typeof ResizeObserver !== 'function') return;
|
|
2572
2921
|
if (win.sizeObserver) return;
|
|
2922
|
+
if (win.kind === 'scene') {
|
|
2923
|
+
// The SDK never sizes a scene canvas; it only hands the page the moment to (opts.onResize).
|
|
2924
|
+
if (!win.onResize) return;
|
|
2925
|
+
win.resizeBox = sceneBoxOf(win.canvas);
|
|
2926
|
+
win.sizeObserver = new ResizeObserver(() => this._onSceneResize(win));
|
|
2927
|
+
win.sizeObserver.observe(win.canvas);
|
|
2928
|
+
return;
|
|
2929
|
+
}
|
|
2573
2930
|
// Scene canvases are the app's (ownsBuffer false) — never touch their width/height.
|
|
2574
2931
|
// An explicit {width, height} is box-independent by definition, so nothing to watch.
|
|
2575
2932
|
if (!win.ownsBuffer || (win.reqW && win.reqH)) return;
|
|
@@ -2577,6 +2934,33 @@ class Inline3D {
|
|
|
2577
2934
|
win.sizeObserver.observe(win.canvas);
|
|
2578
2935
|
}
|
|
2579
2936
|
|
|
2937
|
+
/**
|
|
2938
|
+
* addScene's `onResize(box)`, run INSIDE the ResizeObserver callback. That callback runs after
|
|
2939
|
+
* the frame's animation callbacks and before its paint, so a page that resizes its store and
|
|
2940
|
+
* draws here never shows the old backing store stretched onto the new box for a frame — which
|
|
2941
|
+
* is what deferring to the next animation frame shows. Only on a real change of the CSS box or
|
|
2942
|
+
* dpr (an observer fires on plenty that moves neither), never re-entrantly, and a throw is
|
|
2943
|
+
* contained and warned about once: this is a browser callback and must not take the page down.
|
|
2944
|
+
*/
|
|
2945
|
+
_onSceneResize(win) {
|
|
2946
|
+
if (!win.layer || !win.onResize || win.inResize) return;
|
|
2947
|
+
const box = sceneBoxOf(win.canvas);
|
|
2948
|
+
const last = win.resizeBox;
|
|
2949
|
+
if (last && last.width === box.width && last.height === box.height && last.dpr === box.dpr) return;
|
|
2950
|
+
win.resizeBox = box;
|
|
2951
|
+
win.inResize = true;
|
|
2952
|
+
try {
|
|
2953
|
+
win.onResize(box);
|
|
2954
|
+
} catch (err) {
|
|
2955
|
+
if (!win.resizeThrewWarned) {
|
|
2956
|
+
win.resizeThrewWarned = true;
|
|
2957
|
+
console.warn("[inline3d] a scene window's onResize threw; further throws from it are silent.", err);
|
|
2958
|
+
}
|
|
2959
|
+
} finally {
|
|
2960
|
+
win.inResize = false;
|
|
2961
|
+
}
|
|
2962
|
+
}
|
|
2963
|
+
|
|
2580
2964
|
_stopSizeWatch(win) {
|
|
2581
2965
|
if (win.sizeObserver) {
|
|
2582
2966
|
win.sizeObserver.disconnect();
|
|
@@ -2842,6 +3226,8 @@ class Inline3D {
|
|
|
2842
3226
|
this._trackBakedStereo(t);
|
|
2843
3227
|
for (const win of this._windows.values()) {
|
|
2844
3228
|
if (!win.layer) continue;
|
|
3229
|
+
// Before the draw: a moved/resized canvas is covered before anything else happens to it.
|
|
3230
|
+
if (win.rc) this._rectCoverCheck(win);
|
|
2845
3231
|
if (win.kind === 'scene') {
|
|
2846
3232
|
if (views && win.onFrame) {
|
|
2847
3233
|
// Count the SHORT view lists and hand them over unchanged. Under GPU load the session
|
|
@@ -2877,6 +3263,7 @@ class Inline3D {
|
|
|
2877
3263
|
// fallback (a mono frame), which is not what a poster is waiting for.
|
|
2878
3264
|
if (views.length >= 2) {
|
|
2879
3265
|
win.fwStereo = true;
|
|
3266
|
+
win.lfStereo = true;
|
|
2880
3267
|
if (!win.warnedBufMismatch) this._checkSceneBuffer(win);
|
|
2881
3268
|
if (win.rw) win.rw.stereo = true;
|
|
2882
3269
|
}
|
|
@@ -2902,9 +3289,15 @@ class Inline3D {
|
|
|
2902
3289
|
// video has never had a frame) the tile holds nothing worth revealing yet.
|
|
2903
3290
|
if (win.sbs && (win.kind === 'video' ? ((win.video && win.video.readyState) || 0) >= 2 : !!win.img)) {
|
|
2904
3291
|
win.fwStereo = true;
|
|
3292
|
+
win.lfStereo = true;
|
|
2905
3293
|
if (win.rw) win.rw.stereo = true;
|
|
2906
3294
|
}
|
|
2907
3295
|
}
|
|
3296
|
+
// After the draw: the one snapshot of this cover cycle reads the frame just committed to
|
|
3297
|
+
// the backing store, in the same task, which is the one moment a WebGL drawing buffer is
|
|
3298
|
+
// guaranteed readable without preserveDrawingBuffer.
|
|
3299
|
+
if (win.rc && win.rc.snapWanted) this._rectCoverSnap(win);
|
|
3300
|
+
if (!win.layerFramed || win.framedWaiters.length) this._tickLayerFramed(win);
|
|
2908
3301
|
this._tickFirstWoven(win);
|
|
2909
3302
|
}
|
|
2910
3303
|
}
|
|
@@ -2920,6 +3313,13 @@ class Inline3D {
|
|
|
2920
3313
|
* which knows nothing about whether the compositor has matched this canvas yet.
|
|
2921
3314
|
*/
|
|
2922
3315
|
_tickFirstWoven(win) {
|
|
3316
|
+
// The browser's report, where it has one. An unreadable or out-of-contract value falls back
|
|
3317
|
+
// to the hold for this frame rather than guessing.
|
|
3318
|
+
const ws = win.wsLive && win.layer ? readWovenState(win.layer) : null;
|
|
3319
|
+
if (ws) {
|
|
3320
|
+
this._tickWovenState(win, ws);
|
|
3321
|
+
return;
|
|
3322
|
+
}
|
|
2923
3323
|
if (win.rw) {
|
|
2924
3324
|
// Every frame, for every window kind: the ResizeObserver only watches SDK-sized buffers, and
|
|
2925
3325
|
// no observer sees a dpr-only change.
|
|
@@ -2939,16 +3339,70 @@ class Inline3D {
|
|
|
2939
3339
|
this._settleFirstWoven(win, true, 'hold-elapsed');
|
|
2940
3340
|
}
|
|
2941
3341
|
|
|
2942
|
-
/**
|
|
2943
|
-
|
|
3342
|
+
/**
|
|
3343
|
+
* firstWoven / rewoven on the browser's report (`wovenState`, see WOVEN_STATES). Per session
|
|
3344
|
+
* frame, per live window, one attribute read.
|
|
3345
|
+
*
|
|
3346
|
+
* firstWoven: the first 'woven' read once the layer has carried a stereo frame — no hold.
|
|
3347
|
+
*
|
|
3348
|
+
* rewoven(): a 'woven' read is only trusted as the NEW rect's once it follows a 'withheld' or
|
|
3349
|
+
* 'pending' read seen after the call (the rect left the weave and came back), or once
|
|
3350
|
+
* WOVEN_STATE_LAG_FRAMES frames have passed since the last box change seen after the call (a
|
|
3351
|
+
* move rejoined without a gap). A call that sees no box change and no gap settles on a 'woven'
|
|
3352
|
+
* read WOVEN_STATE_LAG_FRAMES session frames after the call: the report is a live per-frame
|
|
3353
|
+
* level trailing the join by at most that many frames, so such a read reflects the rect as it
|
|
3354
|
+
* was at the call (the usual case: the page moved the canvas, THEN called rewoven()). Never on
|
|
3355
|
+
* the frame of the call itself. Either way it needs a stereo frame since the last restart, as
|
|
3356
|
+
* on the hold path.
|
|
3357
|
+
*
|
|
3358
|
+
* The cap stays, as a safety: the report is a level, and a layer that never leaves 'withheld'
|
|
3359
|
+
* (a CSS effect on an ancestor, rule 7) must not hold a cover up for good. Four holds (never
|
|
3360
|
+
* fewer than four default holds, so `firstWovenHoldMs: 0` does not mean "give up at once")
|
|
3361
|
+
* after the call, it settles anyway: `woven: false` with the browser's reason while withheld,
|
|
3362
|
+
* or `'hold-capped'` if the last read was 'woven' but never qualified.
|
|
3363
|
+
*/
|
|
3364
|
+
_tickWovenState(win, state) {
|
|
3365
|
+
const t = nowMs();
|
|
3366
|
+
const cap = REWOVEN_MAX_HOLDS * Math.max(win.fwHoldMs, FIRST_WOVEN_HOLD_MS);
|
|
3367
|
+
if (win.rw) {
|
|
3368
|
+
this._noteRewovenBox(win); // first, so a change seen this frame is not credited to this read
|
|
3369
|
+
const rw = win.rw;
|
|
3370
|
+
if (state !== 'woven') rw.gap = true;
|
|
3371
|
+
else if (
|
|
3372
|
+
rw.stereo &&
|
|
3373
|
+
(rw.gap ||
|
|
3374
|
+
(rw.boxFrame !== null && this._frameCount - rw.boxFrame >= WOVEN_STATE_LAG_FRAMES) ||
|
|
3375
|
+
(rw.boxFrame === null && this._frameCount - rw.callFrame >= WOVEN_STATE_LAG_FRAMES))
|
|
3376
|
+
) {
|
|
3377
|
+
this._settleRewoven(win, true, 'woven', true);
|
|
3378
|
+
}
|
|
3379
|
+
if (win.rw && t - rw.calledAt >= cap) {
|
|
3380
|
+
if (state === 'woven') this._settleRewoven(win, true, 'hold-capped', true);
|
|
3381
|
+
else this._settleRewoven(win, false, readWithheldReason(win.layer) || state, true);
|
|
3382
|
+
}
|
|
3383
|
+
}
|
|
3384
|
+
if (win.fwResult || win.fwLayerAt === null) return;
|
|
3385
|
+
if (win.fwStereo && state === 'woven') this._settleFirstWoven(win, true, 'woven', true);
|
|
3386
|
+
else if (t - win.fwLayerAt >= cap) {
|
|
3387
|
+
if (state === 'woven') this._settleFirstWoven(win, true, 'hold-capped', true);
|
|
3388
|
+
else this._settleFirstWoven(win, false, readWithheldReason(win.layer) || state, true);
|
|
3389
|
+
}
|
|
3390
|
+
}
|
|
3391
|
+
|
|
3392
|
+
/**
|
|
3393
|
+
* One-shot: the first call wins, later ones are ignored. `confirmed` marks a result read off
|
|
3394
|
+
* the browser's report; a confirmed `woven: false` is a capped 'withheld' level, which can still
|
|
3395
|
+
* recover, so it is NOT a terminal "will not weave" and leaves rewoven() usable.
|
|
3396
|
+
*/
|
|
3397
|
+
_settleFirstWoven(win, woven, reason, confirmed = false) {
|
|
2944
3398
|
// Every "will not weave" path lands here, so a pending rewoven() is released with it, and a
|
|
2945
3399
|
// later one answers at once (firstWoven may have settled woven:true long before).
|
|
2946
|
-
if (!woven) {
|
|
3400
|
+
if (!woven && !confirmed) {
|
|
2947
3401
|
if (!win.rwGone) win.rwGone = reason;
|
|
2948
3402
|
this._settleRewoven(win, false, reason);
|
|
2949
3403
|
}
|
|
2950
3404
|
if (win.fwResult) return;
|
|
2951
|
-
win.fwResult = Object.freeze({ woven, confirmed
|
|
3405
|
+
win.fwResult = Object.freeze({ woven, confirmed, reason, ms: Math.round(nowMs() - win.fwRegAt) });
|
|
2952
3406
|
win.fwResolve(win.fwResult);
|
|
2953
3407
|
win.fwResolve = null;
|
|
2954
3408
|
}
|
|
@@ -2956,7 +3410,9 @@ class Inline3D {
|
|
|
2956
3410
|
/** handle.rewoven(): see the handle's doc comment. */
|
|
2957
3411
|
_rewoven(win) {
|
|
2958
3412
|
if (!win.fwResult) return win.fwPromise; // still on the first join: the same question
|
|
2959
|
-
|
|
3413
|
+
// A terminal no-weave answers at once. A CONFIRMED woven:false (the first join capped while
|
|
3414
|
+
// the browser reported 'withheld') is a level that can recover, so it is measured again.
|
|
3415
|
+
if (!win.fwResult.woven && !win.fwResult.confirmed) return Promise.resolve(win.fwResult);
|
|
2960
3416
|
if (win.rwGone) return Promise.resolve(Object.freeze({ woven: false, confirmed: false, reason: win.rwGone, ms: 0 }));
|
|
2961
3417
|
if (win.rw) {
|
|
2962
3418
|
this._restartRewoven(win);
|
|
@@ -2967,7 +3423,20 @@ class Inline3D {
|
|
|
2967
3423
|
resolve = r;
|
|
2968
3424
|
});
|
|
2969
3425
|
const t = nowMs();
|
|
2970
|
-
|
|
3426
|
+
// `gap` / `boxFrame` / `callFrame` are the wovenState path's (see _tickWovenState): a
|
|
3427
|
+
// 'withheld'/'pending' read since the last restart, the session frame of the last box change
|
|
3428
|
+
// (null = none), and the session frame the call was made in.
|
|
3429
|
+
win.rw = {
|
|
3430
|
+
promise,
|
|
3431
|
+
resolve,
|
|
3432
|
+
calledAt: t,
|
|
3433
|
+
at: t,
|
|
3434
|
+
stereo: false,
|
|
3435
|
+
box: boxKeyOf(win.canvas),
|
|
3436
|
+
gap: false,
|
|
3437
|
+
boxFrame: null,
|
|
3438
|
+
callFrame: this._frameCount,
|
|
3439
|
+
};
|
|
2971
3440
|
return promise;
|
|
2972
3441
|
}
|
|
2973
3442
|
|
|
@@ -2986,13 +3455,191 @@ class Inline3D {
|
|
|
2986
3455
|
if (!win.rw) return;
|
|
2987
3456
|
win.rw.at = nowMs();
|
|
2988
3457
|
win.rw.stereo = false;
|
|
3458
|
+
win.rw.gap = false;
|
|
3459
|
+
win.rw.boxFrame = this._frameCount;
|
|
2989
3460
|
}
|
|
2990
3461
|
|
|
2991
|
-
_settleRewoven(win, woven, reason) {
|
|
3462
|
+
_settleRewoven(win, woven, reason, confirmed = false) {
|
|
2992
3463
|
const rw = win.rw;
|
|
2993
3464
|
if (!rw) return;
|
|
2994
3465
|
win.rw = null;
|
|
2995
|
-
rw.resolve(Object.freeze({ woven, confirmed
|
|
3466
|
+
rw.resolve(Object.freeze({ woven, confirmed, reason, ms: Math.round(nowMs() - rw.calledAt) }));
|
|
3467
|
+
}
|
|
3468
|
+
|
|
3469
|
+
// ── rect cover (opts.rectCover) ──────────────────────────────────────────────────────
|
|
3470
|
+
//
|
|
3471
|
+
// A canvas that is already woven and then moves or resizes goes back through the identity gap
|
|
3472
|
+
// (rule 11): until the browser rejoins it at the new rect, the screen shows the page's own
|
|
3473
|
+
// raster of it — black, or the squeezed pair. A kiosk demo that moves ONE woven canvas between
|
|
3474
|
+
// screens measured 0.4-1.2 s of that, and every page that hit it hand-rolled the same cover.
|
|
3475
|
+
// This is that cover, owned by the SDK: a sibling element over the canvas (never a second woven
|
|
3476
|
+
// canvas: two 2:1 candidates in one region are refused as ambiguous, rule 6), raised on the
|
|
3477
|
+
// frame the change is seen and cut when rewoven() settles.
|
|
3478
|
+
|
|
3479
|
+
/** Per frame, before the draw: has the box (size, dpr, page position) changed since the last look? */
|
|
3480
|
+
_rectCoverCheck(win) {
|
|
3481
|
+
const rc = win.rc;
|
|
3482
|
+
const key = boxKeyOf(win.canvas);
|
|
3483
|
+
if (key === rc.box) return;
|
|
3484
|
+
const first = rc.box === null;
|
|
3485
|
+
const prevW = rc.w;
|
|
3486
|
+
const prevH = rc.h;
|
|
3487
|
+
rc.box = key;
|
|
3488
|
+
rc.w = win.canvas.clientWidth || 0;
|
|
3489
|
+
rc.h = win.canvas.clientHeight || 0;
|
|
3490
|
+
if (first) return; // the first look is the baseline, not a change
|
|
3491
|
+
if (rc.up) this._placeCover(win); // already up: follow the canvas
|
|
3492
|
+
// Before the first join the page's own poster covers the canvas (rule 5), and a window that
|
|
3493
|
+
// will not weave has nothing to wait for. A confirmed withheld first result still covers.
|
|
3494
|
+
const fw = win.fwResult;
|
|
3495
|
+
if (!fw || win.rwGone || !(fw.woven || fw.confirmed)) return;
|
|
3496
|
+
if (!rc.up) {
|
|
3497
|
+
if (!rc.snapped) rc.aspect = prevH > 0 ? prevW / prevH : 0; // what the eye looked like on screen
|
|
3498
|
+
this._raiseCover(win);
|
|
3499
|
+
}
|
|
3500
|
+
if (rc.cfg.snapshot && !rc.snapped && rc.w > 0 && rc.h > 0) rc.snapWanted = true;
|
|
3501
|
+
let p;
|
|
3502
|
+
if (win.rw) {
|
|
3503
|
+
this._noteRewovenBox(win); // a second change while pending restarts it
|
|
3504
|
+
p = win.rw.promise;
|
|
3505
|
+
} else {
|
|
3506
|
+
p = this._rewoven(win);
|
|
3507
|
+
if (win.rw) win.rw.boxFrame = this._frameCount; // the change was seen THIS frame
|
|
3508
|
+
}
|
|
3509
|
+
if (rc.waiting !== p) {
|
|
3510
|
+
rc.waiting = p;
|
|
3511
|
+
p.then(() => {
|
|
3512
|
+
if (rc.waiting !== p) return;
|
|
3513
|
+
rc.waiting = null;
|
|
3514
|
+
this._lowerCover(win);
|
|
3515
|
+
});
|
|
3516
|
+
}
|
|
3517
|
+
}
|
|
3518
|
+
|
|
3519
|
+
_raiseCover(win) {
|
|
3520
|
+
const rc = win.rc;
|
|
3521
|
+
if (!rc.el) {
|
|
3522
|
+
if (typeof document === 'undefined' || !document || typeof document.createElement !== 'function') return;
|
|
3523
|
+
if (!win.canvas.parentElement) return;
|
|
3524
|
+
const el = document.createElement('canvas');
|
|
3525
|
+
el.setAttribute?.('aria-hidden', 'true');
|
|
3526
|
+
if (el.dataset) el.dataset.inline3dCover = '';
|
|
3527
|
+
const st = el.style;
|
|
3528
|
+
st.position = 'absolute';
|
|
3529
|
+
st.pointerEvents = 'none'; // the canvas under it keeps its input (orbit, clicks)
|
|
3530
|
+
st.margin = '0';
|
|
3531
|
+
st.border = '0';
|
|
3532
|
+
st.padding = '0';
|
|
3533
|
+
st.background = rc.cfg.color;
|
|
3534
|
+
st.display = 'none';
|
|
3535
|
+
// Stack with the canvas, not under it: a canvas the page raised gets the same z-index.
|
|
3536
|
+
try {
|
|
3537
|
+
const z = typeof getComputedStyle === 'function' ? getComputedStyle(win.canvas).zIndex : 'auto';
|
|
3538
|
+
if (z && z !== 'auto') st.zIndex = z;
|
|
3539
|
+
} catch {
|
|
3540
|
+
/* no computed style: DOM order alone puts it on top */
|
|
3541
|
+
}
|
|
3542
|
+
rc.el = el;
|
|
3543
|
+
}
|
|
3544
|
+
this._placeCover(win);
|
|
3545
|
+
rc.el.style.display = 'block';
|
|
3546
|
+
rc.up = true;
|
|
3547
|
+
// Declared through the existing exclusion path. On a draw-order browser that is a no-op and
|
|
3548
|
+
// the cover is crisp 2D anyway (it sits over the tile in draw order). On an older one the
|
|
3549
|
+
// full-tile guard refuses it, on purpose: a congruent plate would stage the CANVAS as the
|
|
3550
|
+
// overlay. Pre-marked as warned so the SDK's own cover does not trip the page-facing warning.
|
|
3551
|
+
this._fullTileWarned.add(rc.el);
|
|
3552
|
+
this._applyExclusion(win, rc.el);
|
|
3553
|
+
}
|
|
3554
|
+
|
|
3555
|
+
/** Put the cover exactly over the canvas, as its next sibling (the canvas may have been re-parented). */
|
|
3556
|
+
_placeCover(win) {
|
|
3557
|
+
const rc = win.rc;
|
|
3558
|
+
const el = rc.el;
|
|
3559
|
+
const c = win.canvas;
|
|
3560
|
+
if (!el) return;
|
|
3561
|
+
const parent = c.parentElement;
|
|
3562
|
+
if (parent && (el.parentElement !== parent || el.previousSibling !== c)) {
|
|
3563
|
+
parent.insertBefore(el, c.nextSibling);
|
|
3564
|
+
}
|
|
3565
|
+
const st = el.style;
|
|
3566
|
+
if (c.offsetParent) {
|
|
3567
|
+
// Same containing block as the canvas (its offsetParent), so offsets are directly usable.
|
|
3568
|
+
st.position = 'absolute';
|
|
3569
|
+
st.left = `${c.offsetLeft}px`;
|
|
3570
|
+
st.top = `${c.offsetTop}px`;
|
|
3571
|
+
st.width = `${c.offsetWidth}px`;
|
|
3572
|
+
st.height = `${c.offsetHeight}px`;
|
|
3573
|
+
} else {
|
|
3574
|
+
// A fixed canvas (no offsetParent): cover its viewport rect.
|
|
3575
|
+
const r = c.getBoundingClientRect();
|
|
3576
|
+
st.position = 'fixed';
|
|
3577
|
+
st.left = `${r.left ?? r.x ?? 0}px`;
|
|
3578
|
+
st.top = `${r.top ?? r.y ?? 0}px`;
|
|
3579
|
+
st.width = `${r.width}px`;
|
|
3580
|
+
st.height = `${r.height}px`;
|
|
3581
|
+
}
|
|
3582
|
+
}
|
|
3583
|
+
|
|
3584
|
+
/**
|
|
3585
|
+
* The last frame, once per cover cycle: the LEFT eye of the side-by-side buffer, cover-fit into a
|
|
3586
|
+
* 2D canvas the size of the new CSS box (CSS px, not device px: it is on screen for about a second
|
|
3587
|
+
* and must not cost a full-resolution copy). The eye's on-screen aspect was the OLD box's (each
|
|
3588
|
+
* eye is stretched to the box by the weave), so the crop is computed from that aspect, not from
|
|
3589
|
+
* the buffer's. Never toDataURL: one drawImage, at this moment only. Any failure (a lost
|
|
3590
|
+
* context, a tainted source) leaves the solid color.
|
|
3591
|
+
*/
|
|
3592
|
+
_rectCoverSnap(win) {
|
|
3593
|
+
const rc = win.rc;
|
|
3594
|
+
rc.snapWanted = false;
|
|
3595
|
+
const el = rc.el;
|
|
3596
|
+
const src = win.canvas;
|
|
3597
|
+
if (!el || !rc.up || !(src.width > 1) || !(src.height > 0)) return;
|
|
3598
|
+
const bw = Math.max(1, Math.round(rc.w));
|
|
3599
|
+
const bh = Math.max(1, Math.round(rc.h));
|
|
3600
|
+
const target = bw / bh;
|
|
3601
|
+
// A canvas that was hidden (0x0) before the change has no old box to read: the eye half of the
|
|
3602
|
+
// buffer, still at its old size at this point, has the same aspect.
|
|
3603
|
+
const aspect = rc.aspect > 0 ? rc.aspect : src.width / 2 / src.height;
|
|
3604
|
+
let fw = 1;
|
|
3605
|
+
let fh = 1;
|
|
3606
|
+
if (target > aspect) fh = aspect / target; // wider box: keep the width, crop height
|
|
3607
|
+
else fw = target / aspect; // taller box: keep the height, crop width
|
|
3608
|
+
const sw = (src.width / 2) * fw;
|
|
3609
|
+
const sh = src.height * fh;
|
|
3610
|
+
const sx = (src.width / 2 - sw) / 2;
|
|
3611
|
+
const sy = (src.height - sh) / 2;
|
|
3612
|
+
try {
|
|
3613
|
+
el.width = bw;
|
|
3614
|
+
el.height = bh;
|
|
3615
|
+
const ctx = el.getContext('2d');
|
|
3616
|
+
if (!ctx) return;
|
|
3617
|
+
ctx.drawImage(src, sx, sy, sw, sh, 0, 0, bw, bh);
|
|
3618
|
+
rc.snapped = true;
|
|
3619
|
+
} catch {
|
|
3620
|
+
/* the solid color stays */
|
|
3621
|
+
}
|
|
3622
|
+
}
|
|
3623
|
+
|
|
3624
|
+
_lowerCover(win) {
|
|
3625
|
+
const rc = win.rc;
|
|
3626
|
+
if (!rc) return;
|
|
3627
|
+
rc.up = false;
|
|
3628
|
+
rc.snapped = false; // the next cycle takes its own snapshot
|
|
3629
|
+
rc.snapWanted = false;
|
|
3630
|
+
if (!rc.el) return;
|
|
3631
|
+
rc.el.style.display = 'none';
|
|
3632
|
+
this._dropExclusion(win, rc.el);
|
|
3633
|
+
}
|
|
3634
|
+
|
|
3635
|
+
/** remove() / session end: the cover leaves the DOM with its window. */
|
|
3636
|
+
_dropCover(win) {
|
|
3637
|
+
const rc = win.rc;
|
|
3638
|
+
if (!rc) return;
|
|
3639
|
+
rc.waiting = null;
|
|
3640
|
+
this._lowerCover(win);
|
|
3641
|
+
if (rc.el && rc.el.parentElement) rc.el.parentElement.removeChild(rc.el);
|
|
3642
|
+
rc.el = null;
|
|
2996
3643
|
}
|
|
2997
3644
|
|
|
2998
3645
|
// ── page lifecycle: bfcache, freeze, restore (browser#87) ───────────────────────────
|
|
@@ -3127,8 +3774,11 @@ class Inline3D {
|
|
|
3127
3774
|
this._paintMono(win);
|
|
3128
3775
|
this._notifyLayerLost(win);
|
|
3129
3776
|
this._settleFirstWoven(win, false, 'session-ended');
|
|
3777
|
+
this._dropCover(win);
|
|
3778
|
+
this._noteLayerFramed(win, false);
|
|
3130
3779
|
}
|
|
3131
3780
|
this._windows.clear();
|
|
3781
|
+
this._claims.clear();
|
|
3132
3782
|
// Nobody is tracked through a session that has ended: say so ONCE, before the listeners are
|
|
3133
3783
|
// dropped, so a page that gated its UI on 'tracking' is released rather than left latched. A
|
|
3134
3784
|
// browser that never reported one is already 'unknown' and this is silent.
|