@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/index.d.ts
CHANGED
|
@@ -24,27 +24,93 @@ export interface TileOptions {
|
|
|
24
24
|
* you know is not fresh; 0 means "the first stereo frame".
|
|
25
25
|
*/
|
|
26
26
|
firstWovenHoldMs?: number;
|
|
27
|
+
/**
|
|
28
|
+
* Cover this canvas across a rect change, owned by the SDK. Default `'off'` on `addImage` /
|
|
29
|
+
* `addVideo` / `addScene` (frozen defaults); `'auto'` on `./splat` and `./model`.
|
|
30
|
+
*
|
|
31
|
+
* Once the window has woven, a change of the canvas's CSS size, devicePixelRatio or PAGE position
|
|
32
|
+
* (scrolling the document is not a move) raises a sibling element over the canvas on the frame
|
|
33
|
+
* the change is seen, and cuts it when {@link TileHandle.rewoven} settles; a further change
|
|
34
|
+
* while it is up restarts the wait. The cover is a 2D canvas, never a second woven canvas,
|
|
35
|
+
* placed as the canvas's next sibling (`pointer-events: none`, `data-inline3d-cover`). It is
|
|
36
|
+
* filled with `color` and, with `snapshot`, the last frame: the left eye, cover-fit to the new
|
|
37
|
+
* box, captured once at the change with one `drawImage` (never `toDataURL`).
|
|
38
|
+
*
|
|
39
|
+
* - `'auto'` — `{ color: '#000', snapshot: true }`.
|
|
40
|
+
* - `'off'` — nothing; a page that runs its own cover passes this.
|
|
41
|
+
* - `{ color?, snapshot? }` — `color` any CSS color (default `'#000'`); `snapshot` default true.
|
|
42
|
+
*
|
|
43
|
+
* Before the first join nothing is raised: cover a fresh canvas yourself until `firstWoven`
|
|
44
|
+
* (rule 5). A pure move is seen at the next session frame, so a move made after that frame's
|
|
45
|
+
* callback can show for one frame.
|
|
46
|
+
*/
|
|
47
|
+
rectCover?: RectCoverOption;
|
|
27
48
|
}
|
|
28
49
|
|
|
50
|
+
/** See {@link TileOptions.rectCover}. */
|
|
51
|
+
export type RectCoverOption = 'auto' | 'off' | { color?: string; snapshot?: boolean };
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The browser's per-frame report for one layer (`XRDisplayLayer.wovenState`, DisplayXR Browser
|
|
55
|
+
* builds that carry it; absent elsewhere). A level, never latched.
|
|
56
|
+
*
|
|
57
|
+
* - `'pending'` — no presented frame has reported this layer yet (from construction, again after
|
|
58
|
+
* close(), and whenever the frame's reply does not list it).
|
|
59
|
+
* - `'woven'` — the latest swapped frame put this layer's rect into the weave.
|
|
60
|
+
* - `'withheld'` — it did not; {@link WovenWithheldReason} says why.
|
|
61
|
+
*/
|
|
62
|
+
export type WovenState = 'pending' | 'woven' | 'withheld';
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Why a layer reads `'withheld'` (`XRDisplayLayer.withheldReason`). The same tokens the browser
|
|
66
|
+
* logs; what each means and whose fix it is: `docs/woven-canvas-rules.md` §3. A future browser may
|
|
67
|
+
* add tokens, so treat an unknown string as "withheld, reason not listed".
|
|
68
|
+
*/
|
|
69
|
+
export type WovenWithheldReason =
|
|
70
|
+
| 'no-quad'
|
|
71
|
+
| 'no-identity'
|
|
72
|
+
| 'no-join'
|
|
73
|
+
| 'cross-pass:mono'
|
|
74
|
+
| 'cross-pass:mono(cover,2:1)'
|
|
75
|
+
| 'resolve-dropped'
|
|
76
|
+
| (string & {});
|
|
77
|
+
|
|
29
78
|
/**
|
|
30
79
|
* What {@link TileHandle.firstWoven} resolves to. Settles once and never rejects.
|
|
31
80
|
*
|
|
32
|
-
* - `woven: true, reason: '
|
|
33
|
-
* `
|
|
34
|
-
* - `woven: true, reason: 'hold-
|
|
35
|
-
*
|
|
36
|
-
* - `woven:
|
|
37
|
-
*
|
|
38
|
-
*
|
|
81
|
+
* - `woven: true, reason: 'woven'` (`confirmed: true`) — the BROWSER reported the layer woven
|
|
82
|
+
* (`XRDisplayLayer.wovenState`), after a stereo frame was drawn. No hold. Drop the poster.
|
|
83
|
+
* - `woven: true, reason: 'hold-elapsed'` (`confirmed: false`) — a browser without that report:
|
|
84
|
+
* a stereo frame is on a layer that has existed for `firstWovenHoldMs`. Drop the poster.
|
|
85
|
+
* - `woven: true, reason: 'hold-capped'` — `rewoven()`: the canvas kept resizing or moving, so
|
|
86
|
+
* it stopped waiting four holds after the call; with the report, also a 'woven' read that never
|
|
87
|
+
* qualified (see {@link TileHandle.rewoven}). Drop the cover; the rect may still be settling.
|
|
88
|
+
* - `woven: false, confirmed: true`, `reason` a {@link WovenWithheldReason} or `'pending'` — the
|
|
89
|
+
* browser kept reporting the layer withheld (or never listed it) for the whole cap: four holds,
|
|
90
|
+
* at least 4.8 s. A safety release, not a loss: the canvas is NOT taken flat and `rewoven()`
|
|
91
|
+
* keeps working. Drop the cover; what shows is what the browser draws for that reason
|
|
92
|
+
* (`cross-pass:mono` is flat in place; `no-identity` can be the raw pair).
|
|
93
|
+
* - `woven: false, confirmed: false` — the window will not weave (`'layer-failed'`,
|
|
94
|
+
* `'session-ended'`, `'removed'`; the subpaths add `'unsupported'`). The canvas is already flat
|
|
95
|
+
* (image/video) or its `onLayerLost` has run (scene). Drop the poster onto the 2D fallback.
|
|
39
96
|
*/
|
|
40
97
|
export interface FirstWovenResult {
|
|
41
98
|
readonly woven: boolean;
|
|
42
99
|
/**
|
|
43
|
-
* `true` only when the BROWSER reported the
|
|
44
|
-
* that
|
|
100
|
+
* `true` only when the BROWSER reported the state (`XRDisplayLayer.wovenState`). `false` on a
|
|
101
|
+
* browser without that report: the result is then the SDK's worst-case hold.
|
|
45
102
|
*/
|
|
46
103
|
readonly confirmed: boolean;
|
|
47
|
-
readonly reason:
|
|
104
|
+
readonly reason:
|
|
105
|
+
| 'woven'
|
|
106
|
+
| 'hold-elapsed'
|
|
107
|
+
| 'hold-capped'
|
|
108
|
+
| 'layer-failed'
|
|
109
|
+
| 'session-ended'
|
|
110
|
+
| 'removed'
|
|
111
|
+
| 'unsupported'
|
|
112
|
+
| 'pending'
|
|
113
|
+
| WovenWithheldReason;
|
|
48
114
|
/** Milliseconds from the add*() call to settling. */
|
|
49
115
|
readonly ms: number;
|
|
50
116
|
}
|
|
@@ -289,6 +355,15 @@ export interface SceneOptions extends TileOptions {
|
|
|
289
355
|
*/
|
|
290
356
|
bufferClamped?: boolean;
|
|
291
357
|
onLayerLost?: () => void;
|
|
358
|
+
/**
|
|
359
|
+
* Called INSIDE the ResizeObserver callback when the canvas's CSS box or devicePixelRatio
|
|
360
|
+
* changes — after the frame's animation callbacks, before its paint. Resize your backing store
|
|
361
|
+
* and draw one frame here (replay your last views) and the old store is never shown stretched
|
|
362
|
+
* onto the new box, which is what waiting for the next frame shows. Called only on a real
|
|
363
|
+
* change, never re-entrantly; a throw is caught and warned about once. Default: none, and then
|
|
364
|
+
* no observer is attached to a scene canvas (unchanged).
|
|
365
|
+
*/
|
|
366
|
+
onResize?: (box: { width: number; height: number; dpr: number }) => void;
|
|
292
367
|
}
|
|
293
368
|
|
|
294
369
|
/** The per-frame render callback passed to {@link Inline3D.addScene}. */
|
|
@@ -298,7 +373,14 @@ export type SceneFrameCallback = (
|
|
|
298
373
|
frame: XRFrame,
|
|
299
374
|
) => void;
|
|
300
375
|
|
|
301
|
-
/**
|
|
376
|
+
/**
|
|
377
|
+
* The handle returned by every add*() call.
|
|
378
|
+
*
|
|
379
|
+
* An add*() on a canvas that is still registered (not `remove()`d) on the same manager warns once
|
|
380
|
+
* per canvas (1.38) and then proceeds as it always has: the layer is closed and rebuilt (a fresh
|
|
381
|
+
* identity gap), or a subpath puts a second renderer on the context, and the call returns its own
|
|
382
|
+
* new handle. Change what is in the canvas instead, or `remove()` first.
|
|
383
|
+
*/
|
|
302
384
|
export interface TileHandle {
|
|
303
385
|
/** Remove this window: close its weave layer and stop driving it. */
|
|
304
386
|
remove(): void;
|
|
@@ -342,6 +424,19 @@ export interface TileHandle {
|
|
|
342
424
|
/**
|
|
343
425
|
* The panel this window weaves on, or `null` where there is no glasses-free display.
|
|
344
426
|
*
|
|
427
|
+
* `null` is returned only once the layer has answered for real or the cap has passed, never for
|
|
428
|
+
* "not ready yet". Called before this window's layer has delivered its first frame (right after
|
|
429
|
+
* `add*()`, the natural place), the call waits for the release signal — where the browser
|
|
430
|
+
* reports {@link TileHandle.wovenState}, the first `'woven'` read (a `'withheld'` read right
|
|
431
|
+
* after the layer is built does not count: it comes before the weave session can answer);
|
|
432
|
+
* elsewhere, the layer's first stereo frame; no first-woven hold — and asks then. If the layer
|
|
433
|
+
* still answers `null` before the cap, that answer is not returned: the call is asked again on
|
|
434
|
+
* each later release signal (the next `'woven'` read, or the next session frame once a stereo
|
|
435
|
+
* frame has been drawn) and resolves with the first non-`null` answer. Once a read on this layer
|
|
436
|
+
* has answered for real, later calls go straight to the layer. The cap is the first-woven cap
|
|
437
|
+
* (four holds from the layer's construction, never under 4.8 s): past it the layer's `null` is
|
|
438
|
+
* returned as a real absence. Without a live layer it settles at once, as below.
|
|
439
|
+
*
|
|
345
440
|
* Rejects with an `Error` on a browser without the display-mode API
|
|
346
441
|
* ({@link inline3dDisplayModesSupported}) or while this window has no live layer (lazy mode,
|
|
347
442
|
* tile off screen).
|
|
@@ -354,7 +449,14 @@ export interface TileHandle {
|
|
|
354
449
|
* The canvas size is read at the call: call again after a resize.
|
|
355
450
|
*/
|
|
356
451
|
displayMetrics(): Promise<DisplayMetrics>;
|
|
357
|
-
/**
|
|
452
|
+
/**
|
|
453
|
+
* Every rendering mode the display can be put in. See {@link XRDisplayRenderingMode}.
|
|
454
|
+
*
|
|
455
|
+
* An empty list is returned only once the cap has passed, never for "not ready yet": like
|
|
456
|
+
* {@link getDisplayInfo}, a call made before this window's layer has delivered its first frame
|
|
457
|
+
* waits for the same release signal, an empty answer before the cap is asked again on the next
|
|
458
|
+
* one, and the cap is the same. Rejects as {@link getDisplayInfo} does.
|
|
459
|
+
*/
|
|
358
460
|
getRenderingModes(): Promise<ReadonlyArray<XRDisplayRenderingMode>>;
|
|
359
461
|
/**
|
|
360
462
|
* Ask the runtime to switch the display to `modeIndex`. A thin pass-through — it resolves and
|
|
@@ -368,6 +470,10 @@ export interface TileHandle {
|
|
|
368
470
|
*
|
|
369
471
|
* A `viewCount === 1` mode is requestable and is how a page goes flat.
|
|
370
472
|
*
|
|
473
|
+
* Made before this window's layer has delivered its first frame, the request waits for it (the
|
|
474
|
+
* same release signal and cap as {@link getDisplayInfo}; forwarded once, not retried): before
|
|
475
|
+
* then the browser has no weave session to forward it to.
|
|
476
|
+
*
|
|
371
477
|
* With the eased transition on (the default — see {@link ModeSwitchOptions}) a going-flat
|
|
372
478
|
* request is HELD while the disparity ramps out, so the promise resolves when the request has
|
|
373
479
|
* been forwarded rather than on the call; a request dropped by a reversal in that window
|
|
@@ -422,22 +528,45 @@ export interface TileHandle {
|
|
|
422
528
|
* Scene windows only — image/video windows always report `{ frames: 0, monoFrames: 0 }`.
|
|
423
529
|
*/
|
|
424
530
|
stats(): { frames: number; monoFrames: number };
|
|
531
|
+
/**
|
|
532
|
+
* The browser's report for this window's layer, read live ({@link WovenState}); `'pending'`
|
|
533
|
+
* while the window has no live layer; `null` on a browser that does not report it (the
|
|
534
|
+
* attribute is absent on mac, Linux, Android and Windows builds without it). Diagnostics:
|
|
535
|
+
* `firstWoven` / `rewoven()` already settle on it.
|
|
536
|
+
*/
|
|
537
|
+
readonly wovenState: WovenState | null;
|
|
538
|
+
/** The why-token while {@link TileHandle.wovenState} is `'withheld'`, else `null`. */
|
|
539
|
+
readonly withheldReason: WovenWithheldReason | null;
|
|
425
540
|
/**
|
|
426
541
|
* Resolves once, when it is safe to reveal this canvas: see {@link FirstWovenResult}. THE way to
|
|
427
542
|
* release a poster held over a woven canvas — `await Promise.all([ready, handle.firstWoven])`
|
|
428
|
-
* and cut, never fade.
|
|
543
|
+
* and cut, never fade. On a browser that reports `wovenState` it settles on that report
|
|
544
|
+
* (`confirmed: true`, no hold); elsewhere it is the worst-case hold (`confirmed: false`).
|
|
429
545
|
*/
|
|
430
546
|
readonly firstWoven: Promise<FirstWovenResult>;
|
|
431
547
|
/** Callback form of {@link TileHandle.firstWoven}: called once, asynchronously. Returns an unsubscribe. */
|
|
432
548
|
onFirstWoven(cb: (result: FirstWovenResult) => void): () => void;
|
|
433
549
|
/**
|
|
434
550
|
* {@link TileHandle.firstWoven}, measured from NOW. Cover an already-woven canvas across a rect
|
|
435
|
-
* change (fullscreen, a layout resize), then release on this
|
|
436
|
-
*
|
|
437
|
-
*
|
|
551
|
+
* change (fullscreen, a layout resize, a move), then release on this ({@link TileOptions.rectCover}
|
|
552
|
+
* does both for you). A change of the canvas's CSS size, devicePixelRatio or page position (since
|
|
553
|
+
* 1.38; document scroll excluded) while pending restarts the hold (checked every frame, any
|
|
554
|
+
* window kind). It settles anyway, `woven: true, reason:
|
|
438
555
|
* 'hold-capped'`, four holds after the call, so a size that never stops animating cannot hold a
|
|
439
556
|
* cover up for good. A second call while pending returns the same promise, restarted. Before the
|
|
440
557
|
* first join it is `firstWoven`; on a window that will not weave it is that `woven: false` result.
|
|
558
|
+
*
|
|
559
|
+
* On a browser that reports `wovenState` it settles `confirmed: true, reason: 'woven'` on the
|
|
560
|
+
* first 'woven' read (with a stereo frame drawn since the call) that either follows a
|
|
561
|
+
* 'withheld'/'pending' read seen after the call, or comes three session frames after the last
|
|
562
|
+
* box change seen after the call, or — when no box change is seen after the call, the usual
|
|
563
|
+
* case of a page that moves the canvas and THEN calls this — comes three session frames after
|
|
564
|
+
* the call. The report is a live per-frame level trailing the join by 1–3 frames, so a read
|
|
565
|
+
* three frames on reflects the rect as it was at the call, and a 'woven' from the old rect
|
|
566
|
+
* never settles it; a read on the frame of the call never does. Expect ~50 ms instead of the
|
|
567
|
+
* 1.2 s hold.
|
|
568
|
+
* The cap stays: still 'withheld' four holds after the call (at least 4.8 s), it settles `woven:
|
|
569
|
+
* false, confirmed: true, reason: <withheldReason>`.
|
|
441
570
|
*/
|
|
442
571
|
rewoven(): Promise<FirstWovenResult>;
|
|
443
572
|
}
|
|
@@ -455,9 +584,17 @@ export interface Inline3D {
|
|
|
455
584
|
// The panel is the DOCUMENT's, not a tile's, so the display API lives here; the same names are
|
|
456
585
|
// on every tile handle, routed to whichever window currently holds a live layer.
|
|
457
586
|
|
|
458
|
-
/**
|
|
587
|
+
/**
|
|
588
|
+
* The panel, or `null` where there is no glasses-free display — `null` only once the cap has
|
|
589
|
+
* passed, never for "not ready yet" (a call before the live window has answered waits for its
|
|
590
|
+
* release signal and is asked again while it answers `null`, capped). See
|
|
591
|
+
* {@link TileHandle.getDisplayInfo}.
|
|
592
|
+
*/
|
|
459
593
|
getDisplayInfo(): Promise<XRDisplayInfo | null>;
|
|
460
|
-
/**
|
|
594
|
+
/**
|
|
595
|
+
* Every rendering mode the display can be put in; an empty list only once the cap has passed,
|
|
596
|
+
* never for "not ready yet". See {@link TileHandle.getRenderingModes}.
|
|
597
|
+
*/
|
|
461
598
|
getRenderingModes(): Promise<ReadonlyArray<XRDisplayRenderingMode>>;
|
|
462
599
|
/** Switch the display to `modeIndex`. See {@link TileHandle.requestRenderingMode}. */
|
|
463
600
|
requestRenderingMode(modeIndex: number): Promise<void>;
|
package/js/call/element.js
CHANGED
|
@@ -71,6 +71,7 @@ const str = (v) => (v === null || v === undefined || String(v).trim() === '' ? u
|
|
|
71
71
|
* | `no-audio` | `audio: false` |
|
|
72
72
|
* | `no-self-view` | `selfView: false` |
|
|
73
73
|
* | `no-auto-converge` | `autoConverge: false` |
|
|
74
|
+
* | `cursor="depth"` | `cursor: 'depth'` |
|
|
74
75
|
* | `tile-aspect` | `theme.tileAspect` (a number, or `w/h`) |
|
|
75
76
|
* | `invite-base` | `invite.base` |
|
|
76
77
|
* | `browser-url` | `landing.browserUrl` |
|
|
@@ -101,6 +102,7 @@ export function attrsToOpts(source) {
|
|
|
101
102
|
if (isOn(get('no-audio'))) o.audio = false;
|
|
102
103
|
if (isOn(get('no-self-view'))) o.selfView = false;
|
|
103
104
|
if (isOn(get('no-auto-converge'))) o.autoConverge = false;
|
|
105
|
+
if (str(get('cursor')) === 'depth') o.cursor = 'depth';
|
|
104
106
|
// The C2 groups (RFC 0003 §2): the attribute names are the C1 ones, the option shapes are new.
|
|
105
107
|
const theme = { accent: str(get('accent')), tileAspect: num(get('tile-aspect')) };
|
|
106
108
|
if (theme.accent !== undefined || theme.tileAspect !== undefined) o.theme = Object.fromEntries(Object.entries(theme).filter(([, v]) => v !== undefined));
|
package/js/call/options.js
CHANGED
|
@@ -8,6 +8,7 @@ import { clampMaxPeers } from './transport.js';
|
|
|
8
8
|
import { normalizeMono3D } from './lift.js';
|
|
9
9
|
import { dxrSignaling } from './signaling.js';
|
|
10
10
|
import { resolveCallAccent, THEME_VARS } from './ui.js';
|
|
11
|
+
import { resolvePairCursorOption } from '../inline3d-cursor-option.js';
|
|
11
12
|
|
|
12
13
|
const TAG = '[inline3d/call]';
|
|
13
14
|
const DEFAULT_BROWSER_URL = 'https://github.com/DisplayXR/displayxr-browser';
|
|
@@ -89,6 +90,9 @@ export function normalizeCallOptions(opts = {}) {
|
|
|
89
90
|
// peer's eyes and shift the eyes so it sits at the display plane. The depth slider stays an
|
|
90
91
|
// offset on top. Off = the pair as sent (plus any `hint`).
|
|
91
92
|
autoConverge: opts.autoConverge === undefined ? true : !!opts.autoConverge,
|
|
93
|
+
// `cursor: 'depth'` (runtime ADR-046 on a pair): a crosshair just in front of the person under
|
|
94
|
+
// the pointer, measured off each stereo tile's two halves. Off by default (zero cost).
|
|
95
|
+
cursor: resolvePairCursorOption(opts.cursor, '@displayxr/inline3d/call'),
|
|
92
96
|
mono3D: normalizeMono3D(opts.mono3D),
|
|
93
97
|
maxPeers: clampMaxPeers(opts.maxPeers === undefined ? DEFAULT_MAX_PEERS : opts.maxPeers),
|
|
94
98
|
// Extra lift() options for lifted tiles (models, ort, quality, providers). The call's own
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// camera/pair-cursor.js — the depth-aware cursor on a STEREO PAIR: a call tile or a camera view.
|
|
2
|
+
//
|
|
3
|
+
// The OS cursor is drawn at zero disparity, on the glass. Over a person who sits in front of it
|
|
4
|
+
// (a converged stereo call puts the face AT the glass, so the shoulders, a raised hand, a cup held
|
|
5
|
+
// up are all in front), it is drawn on top of pixels that say they are nearer — the depth
|
|
6
|
+
// violation runtime ADR-046 is about. A pair has no scene to raycast and no depth buffer, but its
|
|
7
|
+
// two halves ARE the depth: this is ADR-046 Phase 3b done in the page. Block-match a cursor-sized
|
|
8
|
+
// footprint between the eyes, take the nearest confident match, and draw a crosshair into both
|
|
9
|
+
// halves with that disparity plus a small margin. The browser weaves the pair as usual, so the
|
|
10
|
+
// sprite lands just in front of the content under it.
|
|
11
|
+
//
|
|
12
|
+
// Same policy as the scene cursor (./inline3d-cursor-depth.js): the footprint ring, the fast-rise /
|
|
13
|
+
// slow-sink filter, the display plane when nothing matches. Disparities here are in OUTPUT pixels
|
|
14
|
+
// of one eye of the woven buffer, + = crossed (in front of the glass), the sign the rest of
|
|
15
|
+
// camera/ uses. Loaded only when a page asks for `cursor: 'depth'` — zero cost otherwise.
|
|
16
|
+
|
|
17
|
+
import { CursorPointer, cursorFootprint, cursorFilterStep, CURSOR_DEFAULT_TUNING, CURSOR_CROSSHAIR, CURSOR_FILL_COLOR, CURSOR_OUTLINE_COLOR } from '../inline3d-cursor-depth.js';
|
|
18
|
+
import { matchBlock, MIN_NCC } from './disparity.js';
|
|
19
|
+
|
|
20
|
+
/** Sprite height, as a fraction of the tile height (a call tile is small; 3% would be a speck). */
|
|
21
|
+
export const PAIR_CURSOR_HEIGHT = 0.06;
|
|
22
|
+
/** How far in front of the content the sprite floats, as a fraction of the eye width (crossed). */
|
|
23
|
+
export const PAIR_CURSOR_MARGIN = 0.002;
|
|
24
|
+
/** Comfort clamp on the sprite's disparity, as a fraction of the eye width, either way. */
|
|
25
|
+
export const PAIR_CURSOR_CLAMP = 0.08;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The displayed disparity of the nearest content under the cursor's footprint, or null when no
|
|
29
|
+
* point of the footprint matched confidently (flat, ambiguous, off the frame).
|
|
30
|
+
*
|
|
31
|
+
* @param {{img: ArrayLike<number>, w: number, h: number, scale: number}} luma a grayscale copy
|
|
32
|
+
* of the WHOLE side-by-side source frame; `scale` = copy px per source px.
|
|
33
|
+
* @param {{rL: {sx:number, sy:number, sw:number, sh:number}, rR: {sx:number}, outW: number, mirror?: boolean}} geo
|
|
34
|
+
* each eye's source crop (relative to its half, eyeCropRect) and the output eye width; a
|
|
35
|
+
* mirrored view (the self view) flips the pointer, never the disparity.
|
|
36
|
+
* @param {number} u pointer, tile-normalised (0..1, left → right as the viewer sees it)
|
|
37
|
+
* @param {number} v pointer, tile-normalised (0..1, top → bottom)
|
|
38
|
+
* @param {number} height sprite height (fraction of the tile height)
|
|
39
|
+
* @param {number} aspect the tile's width / height, for a round footprint
|
|
40
|
+
* @returns {number|null} output px, + = in front of the glass
|
|
41
|
+
*/
|
|
42
|
+
export function pairFootprintDisparity(luma, geo, u, v, height, aspect) {
|
|
43
|
+
if (!luma || !luma.img || !geo || !geo.rL || !geo.rR || !(geo.outW > 0)) return null;
|
|
44
|
+
const { img, w, h, scale } = luma;
|
|
45
|
+
const E = w / 2;
|
|
46
|
+
const b = Math.max(8, Math.round(E / 20));
|
|
47
|
+
const dMax = Math.round(E * 0.3);
|
|
48
|
+
const dMin = -Math.round(E * 0.05);
|
|
49
|
+
const { rL, rR } = geo;
|
|
50
|
+
const k = geo.outW / rL.sw; // output px per source px
|
|
51
|
+
let best = null;
|
|
52
|
+
for (const [pu, pv] of cursorFootprint(u, v, height, aspect)) {
|
|
53
|
+
if (!(pu >= 0 && pu <= 1 && pv >= 0 && pv <= 1)) continue;
|
|
54
|
+
const su = geo.mirror ? 1 - pu : pu;
|
|
55
|
+
const x = Math.round((rL.sx + su * rL.sw) * scale - b / 2);
|
|
56
|
+
const y = Math.round((rL.sy + pv * rL.sh) * scale - b / 2);
|
|
57
|
+
if (x < 0 || x + b > E || y < 0 || y + b > h) continue;
|
|
58
|
+
const m = matchBlock(img, w, h, x, y, b, b, { dMin, dMax, dyMax: 2 });
|
|
59
|
+
if (!m || m.c < MIN_NCC) continue;
|
|
60
|
+
// Source disparity → displayed: each eye's crop moved its half by its own offset.
|
|
61
|
+
const d = (m.d / scale - (rL.sx - rR.sx)) * k;
|
|
62
|
+
if (best === null || d > best) best = d;
|
|
63
|
+
}
|
|
64
|
+
return best;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The sprite's target disparity: the content plus the margin, clamped; the glass with nothing under it. */
|
|
68
|
+
export function pairCursorTarget(contentPx, outW) {
|
|
69
|
+
if (contentPx === null || !Number.isFinite(contentPx)) return 0;
|
|
70
|
+
const lim = PAIR_CURSOR_CLAMP * outW;
|
|
71
|
+
return Math.max(-lim, Math.min(lim, contentPx + PAIR_CURSOR_MARGIN * outW));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Draw the crosshair into both halves of a woven buffer: the left eye's copy `disparityPx / 2`
|
|
76
|
+
* right of the pointer, the right eye's as far left (crossed = in front). Each copy is clipped to
|
|
77
|
+
* its own half, so a sprite at the edge never bleeds into the other eye.
|
|
78
|
+
*/
|
|
79
|
+
export function drawPairCrosshair(g, outW, outH, u, v, disparityPx, height) {
|
|
80
|
+
const size = height * outH;
|
|
81
|
+
const cy = v * outH;
|
|
82
|
+
const rgba = (c) => `rgba(${Math.round(c[0] * 255)},${Math.round(c[1] * 255)},${Math.round(c[2] * 255)},${c[3]})`;
|
|
83
|
+
for (const eye of [0, 1]) {
|
|
84
|
+
const cx = eye * outW + u * outW + (eye === 0 ? disparityPx / 2 : -disparityPx / 2);
|
|
85
|
+
g.save();
|
|
86
|
+
g.beginPath();
|
|
87
|
+
g.rect(eye * outW, 0, outW, outH);
|
|
88
|
+
g.clip();
|
|
89
|
+
for (const [width, color, cap] of [
|
|
90
|
+
[0.15, CURSOR_OUTLINE_COLOR, 'square'],
|
|
91
|
+
[0.07, CURSOR_FILL_COLOR, 'butt'],
|
|
92
|
+
]) {
|
|
93
|
+
g.lineWidth = width * size;
|
|
94
|
+
g.lineCap = cap;
|
|
95
|
+
g.strokeStyle = rgba(color);
|
|
96
|
+
g.beginPath();
|
|
97
|
+
for (const [x0, y0, x1, y1] of CURSOR_CROSSHAIR) {
|
|
98
|
+
g.moveTo(cx + x0 * size, cy + y0 * size);
|
|
99
|
+
g.lineTo(cx + x1 * size, cy + y1 * size);
|
|
100
|
+
}
|
|
101
|
+
g.stroke();
|
|
102
|
+
}
|
|
103
|
+
g.restore();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* One cursor over one woven pair. The owner calls `draw()` after painting the pair each frame
|
|
109
|
+
* (`idle()` on any frame it does not paint a woven pair), and hands it the latest grayscale copy
|
|
110
|
+
* of the source when it has one; `hovering` says when that copy is worth grabbing more often.
|
|
111
|
+
*/
|
|
112
|
+
export class PairCursor {
|
|
113
|
+
/** @param {HTMLElement} canvas the tile — the pointer counts over its box */
|
|
114
|
+
constructor(canvas, { height, pointerScope = 'window' } = {}) {
|
|
115
|
+
this.pointer = new CursorPointer(canvas, { scope: pointerScope });
|
|
116
|
+
this.height = height > 0 ? height : PAIR_CURSOR_HEIGHT;
|
|
117
|
+
this.filter = {};
|
|
118
|
+
this._luma = null;
|
|
119
|
+
this._uv = null;
|
|
120
|
+
this._content = null;
|
|
121
|
+
/** The last frame's placement, for diagnostics: `{ contentPx, targetPx, disparityPx }` or null. */
|
|
122
|
+
this.last = null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
get hovering() {
|
|
126
|
+
return !!this.pointer.uv;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* @param {CanvasRenderingContext2D} g the woven buffer (2·outW × outH), already painted
|
|
131
|
+
* @param {object|null} luma the latest grayscale source copy (pairFootprintDisparity)
|
|
132
|
+
* @param {{rL: object, rR: object, outW: number, outH: number, mirror?: boolean}} geo
|
|
133
|
+
* @param {number} nowSec
|
|
134
|
+
*/
|
|
135
|
+
draw(g, luma, geo, nowSec) {
|
|
136
|
+
const uv = this.pointer.uv;
|
|
137
|
+
if (!uv) return this.idle();
|
|
138
|
+
const [u, v] = uv;
|
|
139
|
+
// Match only when there is something new to match: a new frame copy or a moved pointer.
|
|
140
|
+
if (luma !== this._luma || !this._uv || this._uv[0] !== u || this._uv[1] !== v) {
|
|
141
|
+
this._content = pairFootprintDisparity(luma, geo, u, v, this.height, geo.outW / geo.outH);
|
|
142
|
+
this._luma = luma;
|
|
143
|
+
this._uv = uv;
|
|
144
|
+
}
|
|
145
|
+
const target = pairCursorTarget(this._content, geo.outW);
|
|
146
|
+
// The shared filter rises fast toward SMALLER values (nearer, in its convention): negate.
|
|
147
|
+
const disparity = -cursorFilterStep(this.filter, CURSOR_DEFAULT_TUNING, -target, nowSec);
|
|
148
|
+
drawPairCrosshair(g, geo.outW, geo.outH, u, v, disparity, this.height);
|
|
149
|
+
this.pointer.hideCss(true);
|
|
150
|
+
this.last = { contentPx: this._content, targetPx: target, disparityPx: disparity };
|
|
151
|
+
return this.last;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Not drawing this frame (pointer away, tile not woven): the CSS cursor comes back. */
|
|
155
|
+
idle() {
|
|
156
|
+
this.pointer.hideCss(false);
|
|
157
|
+
this.filter = {};
|
|
158
|
+
this._uv = null;
|
|
159
|
+
this.last = null;
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
dispose() {
|
|
164
|
+
this.pointer.dispose();
|
|
165
|
+
}
|
|
166
|
+
}
|
package/js/inline3d-call.js
CHANGED
|
@@ -117,6 +117,8 @@ const LIFT_RETRIES = 2;
|
|
|
117
117
|
// disparity on a face; small enough to stay a few ms per measurement).
|
|
118
118
|
const AUTO_CONV_INTERVAL_MS = 200;
|
|
119
119
|
const AUTO_CONV_EYE_WIDTH = 240;
|
|
120
|
+
// While a depth cursor hovers a stereo tile, its source is copied this often (the cursor measures it).
|
|
121
|
+
const CURSOR_SAMPLE_MS = 66;
|
|
120
122
|
// WebCodecs pixel formats whose plane 0 is luma (Y): read directly, no colour conversion.
|
|
121
123
|
const Y_PLANE_FORMATS = new Set(['I420', 'I420A', 'I422', 'I444', 'NV12']);
|
|
122
124
|
/**
|
|
@@ -1306,6 +1308,7 @@ class Tile {
|
|
|
1306
1308
|
// Bumped on every stream change and on destroy(): an async copyTo() that resolves after either
|
|
1307
1309
|
// belongs to a stream this tile no longer shows, and is dropped (review of #92).
|
|
1308
1310
|
this.autoGen = 0;
|
|
1311
|
+
this.luma = null; // the latest grayscale source copy: auto-convergence and the depth cursor read it
|
|
1309
1312
|
this.destroyed = false;
|
|
1310
1313
|
this.conv.depth = call.depth;
|
|
1311
1314
|
this.hintGate = rateGate(HINT_MAX_HZ);
|
|
@@ -1337,6 +1340,14 @@ class Tile {
|
|
|
1337
1340
|
}
|
|
1338
1341
|
call.ui.grid.appendChild(this.el);
|
|
1339
1342
|
this._renderBadge();
|
|
1343
|
+
/** The depth cursor (`cursor: 'depth'`) once its module has loaded; null otherwise. */
|
|
1344
|
+
this.cursor = null;
|
|
1345
|
+
if (call.o.cursor) {
|
|
1346
|
+
call._pairCursorMod = call._pairCursorMod || import('./camera/pair-cursor.js');
|
|
1347
|
+
call._pairCursorMod.then((m) => {
|
|
1348
|
+
if (!this.destroyed) this.cursor = new m.PairCursor(this.canvas, call.o.cursor);
|
|
1349
|
+
});
|
|
1350
|
+
}
|
|
1340
1351
|
}
|
|
1341
1352
|
|
|
1342
1353
|
/** How this side shows the participant: `3D` (woven pair) | `2D→3D` (lifted) | `2D` (flat). */
|
|
@@ -1366,6 +1377,8 @@ class Tile {
|
|
|
1366
1377
|
hello: this.hello,
|
|
1367
1378
|
quality: this.quality,
|
|
1368
1379
|
convergencePx: this.conv.current,
|
|
1380
|
+
// The depth cursor's last placement (output px, + = in front of the glass), null when not drawn.
|
|
1381
|
+
cursor: this.cursor ? this.cursor.last : null,
|
|
1369
1382
|
// Auto-convergence: the measured disparity (source px, null until a lock) and the mean cost of
|
|
1370
1383
|
// one measurement including the frame readback.
|
|
1371
1384
|
autoConverge: {
|
|
@@ -1672,6 +1685,7 @@ class Tile {
|
|
|
1672
1685
|
|
|
1673
1686
|
paint() {
|
|
1674
1687
|
if (!this.route) return;
|
|
1688
|
+
if (this.route !== 'woven-sbs') this.cursor?.idle(); // only a woven pair has depth to measure
|
|
1675
1689
|
if (this.route === 'woven-sbs') this._paintConv();
|
|
1676
1690
|
else if (this.route === 'lifted') {
|
|
1677
1691
|
if (!this.liftLive) paintFlat(this.canvas, this.video); // flat until lift is showing
|
|
@@ -1692,13 +1706,16 @@ class Tile {
|
|
|
1692
1706
|
c.width = 2 * outW;
|
|
1693
1707
|
c.height = outH;
|
|
1694
1708
|
}
|
|
1695
|
-
|
|
1709
|
+
const hover = !!(this.cursor && this.cursor.hovering);
|
|
1710
|
+
if (this.call.o.autoConverge || hover) this._sampleDisparity(v, W, H, hover);
|
|
1696
1711
|
const shift = this.conv.step(eyeW);
|
|
1697
1712
|
const g = c.getContext('2d');
|
|
1713
|
+
const rects = [];
|
|
1698
1714
|
for (const eye of [0, 1]) {
|
|
1699
|
-
const r = wire.eyeCropRect(eyeW, H, A, shift, eye);
|
|
1715
|
+
const r = (rects[eye] = wire.eyeCropRect(eyeW, H, A, shift, eye));
|
|
1700
1716
|
g.drawImage(v, eye * eyeW + r.sx, r.sy, r.sw, r.sh, eye * outW, 0, outW, outH);
|
|
1701
1717
|
}
|
|
1718
|
+
if (this.cursor) this.cursor.draw(g, this.luma, { rL: rects[0], rR: rects[1], outW, outH }, performance.now() / 1000);
|
|
1702
1719
|
}
|
|
1703
1720
|
|
|
1704
1721
|
/**
|
|
@@ -1707,9 +1724,10 @@ class Tile {
|
|
|
1707
1724
|
* feedback loop) and hand it to the convergence state, which halves it per eye and low-passes it.
|
|
1708
1725
|
* A failed measurement holds the last good value.
|
|
1709
1726
|
*/
|
|
1710
|
-
_sampleDisparity(v, W, H) {
|
|
1727
|
+
_sampleDisparity(v, W, H, hover = false) {
|
|
1711
1728
|
const now = performance.now();
|
|
1712
|
-
|
|
1729
|
+
// ~15 Hz while a depth cursor hovers: it measures the same copy, under the pointer.
|
|
1730
|
+
if (this.autoPending || now - this.autoAt < (hover ? CURSOR_SAMPLE_MS : AUTO_CONV_INTERVAL_MS)) return;
|
|
1713
1731
|
this.autoAt = now;
|
|
1714
1732
|
// Preferred: WebCodecs. `new VideoFrame(video)` + an ASYNC copyTo() of the decoded frame, reading
|
|
1715
1733
|
// the luma (Y) plane directly: no synchronous GPU->CPU canvas readback on the paint path (that
|
|
@@ -1775,6 +1793,8 @@ class Tile {
|
|
|
1775
1793
|
*/
|
|
1776
1794
|
_applyMeasurement(img, w, h, s, at, readMs, via) {
|
|
1777
1795
|
if (this.destroyed) return;
|
|
1796
|
+
if (img) this.luma = { img, w, h, scale: s };
|
|
1797
|
+
if (!this.call.o.autoConverge) return; // the copy was for the depth cursor only
|
|
1778
1798
|
const t = performance.now();
|
|
1779
1799
|
const m = img ? this.autoFocus.measure(img, w, h, at) : null;
|
|
1780
1800
|
const d = this.autoTrack.push(m ? m.d / s : null);
|
|
@@ -1800,6 +1820,7 @@ class Tile {
|
|
|
1800
1820
|
|
|
1801
1821
|
_resetAutoConverge() {
|
|
1802
1822
|
this.autoGen++;
|
|
1823
|
+
this.luma = null;
|
|
1803
1824
|
this.autoTrack.reset();
|
|
1804
1825
|
this.autoFocus.reset();
|
|
1805
1826
|
this.conv.measuredPx = null;
|
|
@@ -1809,6 +1830,8 @@ class Tile {
|
|
|
1809
1830
|
destroy() {
|
|
1810
1831
|
this.destroyed = true;
|
|
1811
1832
|
this.autoGen++;
|
|
1833
|
+
this.cursor?.dispose();
|
|
1834
|
+
this.cursor = null;
|
|
1812
1835
|
clearTimeout(this.helloTimer);
|
|
1813
1836
|
clearTimeout(this.layerTimer);
|
|
1814
1837
|
clearTimeout(this.leaveTimer);
|
|
@@ -1874,6 +1897,7 @@ class SelfTile {
|
|
|
1874
1897
|
this.view = addCameraView(wall, this.canvas, cam, {
|
|
1875
1898
|
mirror: true,
|
|
1876
1899
|
autoConverge: !!this.call.o.autoConverge, // the call option (default on) governs the self view too
|
|
1900
|
+
cursor: this.call.o.cursor || undefined, // and so does `cursor: 'depth'`
|
|
1877
1901
|
aspect: this.call.o.tileAspect,
|
|
1878
1902
|
onRouteChange: (route, st) => {
|
|
1879
1903
|
if (this.cam !== cam) return; // a view this tile already replaced
|
package/js/inline3d-camera.js
CHANGED
|
@@ -39,6 +39,7 @@ import { openCamera as openDevice } from './camera/capture.js';
|
|
|
39
39
|
import { eyeCropRect, eyeOutputSize, mirrorSwapOps } from './camera/geometry.js';
|
|
40
40
|
import { createConvergence } from './camera/converge.js';
|
|
41
41
|
import { lumaFromRgba, createDisparityTrack, createFocusTracker } from './camera/disparity.js';
|
|
42
|
+
import { resolvePairCursorOption } from './inline3d-cursor-option.js';
|
|
42
43
|
import { buildStereoXmp, jpegWithXmp, webmWithTags, stereoTagEntries, readJpegStereoMeta, readWebmStereoMeta, parseStereoXmp, readJpegXmp } from './camera/metadata.js';
|
|
43
44
|
|
|
44
45
|
export { readJpegStereoMeta, readWebmStereoMeta, parseStereoXmp, readJpegXmp };
|
|
@@ -50,6 +51,8 @@ export const CAMERA_SDK = 'inline3d-camera/1';
|
|
|
50
51
|
// on a face, a few ms per measurement — the same numbers as the call's remote tiles).
|
|
51
52
|
const AUTO_CONV_INTERVAL_MS = 200;
|
|
52
53
|
const AUTO_CONV_EYE_WIDTH = 240;
|
|
54
|
+
/** While a depth cursor hovers the view, the source is copied this often (it is what the cursor measures). */
|
|
55
|
+
const CURSOR_SAMPLE_MS = 66;
|
|
53
56
|
const FRAME_WAIT_MS = 4000;
|
|
54
57
|
// A self view whose layer the wall could not build (`firstWoven` → `woven:false, 'layer-failed'`)
|
|
55
58
|
// goes flat and re-registers a few times with backoff — the remote tile's rule (web#131).
|
|
@@ -521,7 +524,17 @@ class CameraView {
|
|
|
521
524
|
this._track = createDisparityTrack();
|
|
522
525
|
this._focus = createFocusTracker();
|
|
523
526
|
this._scratch = null;
|
|
527
|
+
this._luma = null; // the latest grayscale source copy (auto-convergence and the depth cursor read it)
|
|
524
528
|
this.disparityPx = null;
|
|
529
|
+
// `cursor: 'depth'` (ADR-046 on a pair): validated now, its module loaded only on opt-in.
|
|
530
|
+
this._cursorOpt = resolvePairCursorOption(o.cursor, '@displayxr/inline3d/camera');
|
|
531
|
+
/** The depth cursor once its module has loaded; null without `cursor: 'depth'`. */
|
|
532
|
+
this.cursor = null;
|
|
533
|
+
if (this._cursorOpt) {
|
|
534
|
+
import('./camera/pair-cursor.js').then((m) => {
|
|
535
|
+
if (!this.removed) this.cursor = new m.PairCursor(this.canvas, this._cursorOpt);
|
|
536
|
+
});
|
|
537
|
+
}
|
|
525
538
|
this._reroute(false);
|
|
526
539
|
if (this._loop) this._start();
|
|
527
540
|
}
|
|
@@ -691,6 +704,7 @@ class CameraView {
|
|
|
691
704
|
}
|
|
692
705
|
|
|
693
706
|
_paintFlat(v) {
|
|
707
|
+
this.cursor?.idle(); // a flat picture has no depth to put a cursor at
|
|
694
708
|
if (!sizeFlat(this.canvas)) return;
|
|
695
709
|
const W = v.videoWidth;
|
|
696
710
|
const H = v.videoHeight;
|
|
@@ -726,7 +740,8 @@ class CameraView {
|
|
|
726
740
|
c.width = 2 * outW;
|
|
727
741
|
c.height = outH;
|
|
728
742
|
}
|
|
729
|
-
|
|
743
|
+
const hover = !!(this.cursor && this.cursor.hovering);
|
|
744
|
+
if (this.autoConverge || hover) this._sample(v, hover);
|
|
730
745
|
const shift = this.conv.step(eyeW);
|
|
731
746
|
const g = c.getContext('2d');
|
|
732
747
|
if (this.mirror) {
|
|
@@ -746,15 +761,24 @@ class CameraView {
|
|
|
746
761
|
g.drawImage(v, eye * eyeW + r.sx, r.sy, r.sw, r.sh, eye * outW, 0, outW, outH);
|
|
747
762
|
}
|
|
748
763
|
}
|
|
764
|
+
if (this.cursor) {
|
|
765
|
+
const geo = { rL: eyeCropRect(eyeW, H, A, shift, 0), rR: eyeCropRect(eyeW, H, A, shift, 1), outW, outH, mirror: this.mirror };
|
|
766
|
+
this.cursor.draw(g, this._luma, geo, performance.now() / 1000);
|
|
767
|
+
}
|
|
749
768
|
}
|
|
750
769
|
|
|
751
|
-
/**
|
|
752
|
-
|
|
770
|
+
/**
|
|
771
|
+
* A small grayscale copy of the SOURCE frame (before our shift): ~5 Hz for auto-convergence,
|
|
772
|
+
* ~15 Hz while a depth cursor hovers (it measures the same copy under the pointer).
|
|
773
|
+
*/
|
|
774
|
+
_sample(v, hover = false) {
|
|
753
775
|
const now = performance.now();
|
|
754
|
-
if (now - this._autoAt < AUTO_CONV_INTERVAL_MS) return;
|
|
776
|
+
if (now - this._autoAt < (hover ? CURSOR_SAMPLE_MS : AUTO_CONV_INTERVAL_MS)) return;
|
|
755
777
|
this._autoAt = now;
|
|
756
778
|
this._scratch = this._scratch || document.createElement('canvas');
|
|
757
779
|
const g = grabLuma(v, this._scratch);
|
|
780
|
+
if (g) this._luma = g;
|
|
781
|
+
if (!this.autoConverge) return;
|
|
758
782
|
const m = g ? this._focus.measure(g.img, g.w, g.h, now) : null;
|
|
759
783
|
const d = this._track.push(m ? m.d / g.scale : null);
|
|
760
784
|
this.conv.measuredPx = d;
|
|
@@ -770,6 +794,8 @@ class CameraView {
|
|
|
770
794
|
clearTimeout(this._layerTimer);
|
|
771
795
|
this._layerTimer = 0;
|
|
772
796
|
this._unregister();
|
|
797
|
+
this.cursor?.dispose();
|
|
798
|
+
this.cursor = null;
|
|
773
799
|
this.cam._views?.delete(this);
|
|
774
800
|
}
|
|
775
801
|
}
|