@displayxr/inline3d 1.34.0 → 1.36.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.
@@ -1127,6 +1127,9 @@ class Call {
1127
1127
  hello: this.hello(),
1128
1128
  transport: this.transport,
1129
1129
  peers: [...this.tiles.values()].map((tile) => tile.diag()),
1130
+ // The self view's ACTUAL weave state (web#131): route, woven, the fallback reason and the
1131
+ // tile's firstWoven result — null without a self view.
1132
+ self: this.self ? this.self.diag() : null,
1130
1133
  mono3D: { ...this._mono3DInfo(), lift: this.liftApi, pool: this.liftPool ? this.liftPool.list() : [] },
1131
1134
  speaker: this.speakerId,
1132
1135
  depth: this.depth,
@@ -1866,14 +1869,34 @@ class SelfTile {
1866
1869
  else {
1867
1870
  this.detach();
1868
1871
  this.cam = cam;
1869
- this.view = addCameraView(wall, this.canvas, cam, { mirror: true, autoConverge: false, aspect: this.call.o.tileAspect });
1872
+ // onRouteChange: the view drops to its flat left eye by itself when the wall says the tile
1873
+ // will not weave (firstWoven → woven:false, web#131), and the badge must follow it.
1874
+ this.view = addCameraView(wall, this.canvas, cam, {
1875
+ mirror: true,
1876
+ autoConverge: false,
1877
+ aspect: this.call.o.tileAspect,
1878
+ onRouteChange: (route, st) => {
1879
+ if (this.cam !== cam) return; // a view this tile already replaced
1880
+ this.call.log('self-route', { route, woven: st.woven, reason: st.reason });
1881
+ this._badge();
1882
+ },
1883
+ });
1870
1884
  }
1871
1885
  this._badge();
1872
1886
  }
1873
1887
 
1888
+ /** 3D only while the view is on the woven route — a layer the wall failed has already taken it flat. */
1874
1889
  _badge() {
1875
1890
  const t = this.call.t;
1876
- this.badge.replaceChildren(el('b', { text: `${t('you')} · ${this.route === 'woven-sbs' ? t('badge3D') : t('badge2D')}` }));
1891
+ const woven = !!(this.view && this.view.woven);
1892
+ this.badge.replaceChildren(el('b', { text: `${t('you')} · ${woven ? t('badge3D') : t('badge2D')}` }));
1893
+ this.badge.dataset.route = this.route || '';
1894
+ }
1895
+
1896
+ /** For `call.diagnostics().self`: what the self view actually shows. */
1897
+ diag() {
1898
+ if (!this.view) return { route: null, woven: false, reason: 'no-camera', firstWoven: null, layerRetries: 0 };
1899
+ return this.view.weaveState();
1877
1900
  }
1878
1901
 
1879
1902
  onWallLost() {
@@ -51,6 +51,11 @@ export const CAMERA_SDK = 'inline3d-camera/1';
51
51
  const AUTO_CONV_INTERVAL_MS = 200;
52
52
  const AUTO_CONV_EYE_WIDTH = 240;
53
53
  const FRAME_WAIT_MS = 4000;
54
+ // A self view whose layer the wall could not build (`firstWoven` → `woven:false, 'layer-failed'`)
55
+ // goes flat and re-registers a few times with backoff — the remote tile's rule (web#131).
56
+ const LAYER_RETRIES = 4;
57
+ const LAYER_RETRY_BASE_MS = 1500;
58
+ const LAYER_RETRY_MAX_MS = 20000;
54
59
  const RECORD_MIME_CANDIDATES = ['video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm', 'video/mp4'];
55
60
 
56
61
  const hasDoc = () => typeof document !== 'undefined' && document && typeof document.createElement === 'function';
@@ -471,7 +476,12 @@ function sizeFlat(canvas) {
471
476
  * @param {object|null} wall a `createInline3D()` / `sharedInline3D()` result (may be unsupported) or null
472
477
  * @param {HTMLCanvasElement} canvas the tile — its CSS box is the shape the viewer sees
473
478
  * @param {StereoCamera} cam
474
- * @param {{ mirror?: boolean, autoConverge?: boolean, depth?: number, aspect?: number }} [opts]
479
+ * On a 3D wall the view follows its tile's `firstWoven`: a window the wall will not weave
480
+ * (`'layer-failed'`, `'session-ended'`) drops the view to the flat left eye — never the packed
481
+ * pair — with `fallbackReason` saying why, and `onRouteChange(route, state)` tells the page (a
482
+ * badge reads `route`). A refused layer is retried a few times; `_reroute(true, wall)` recovers.
483
+ *
484
+ * @param {{ mirror?: boolean, autoConverge?: boolean, depth?: number, aspect?: number, onRouteChange?: Function }} [opts]
475
485
  */
476
486
  export function addCameraView(wall, canvas, cam, opts = {}) {
477
487
  if (!canvas || typeof canvas.getContext !== 'function') throw new TypeError(`${TAG} addCameraView(wall, canvas, cam, opts) needs a canvas`);
@@ -494,6 +504,15 @@ class CameraView {
494
504
  this.setDepth(o.depth);
495
505
  this.route = null;
496
506
  this.handle = null;
507
+ // Why a pair on a 3D-capable wall is shown FLAT (web#131): the wall said this tile's window
508
+ // will not weave (`firstWoven` → woven:false — 'layer-failed', 'session-ended'). Null while
509
+ // nothing has failed. A packed pair is never the visible fallback: the view paints one eye.
510
+ this.fallbackReason = null;
511
+ // The current registration's settled `firstWoven`, null while pending / not registered.
512
+ this._fw = null;
513
+ this._onRouteChange = typeof o.onRouteChange === 'function' ? o.onRouteChange : null;
514
+ this._layerFails = 0;
515
+ this._layerTimer = 0;
497
516
  this.buffer = null; // the SBS canvas the wall repaints the tile from (woven route)
498
517
  this.removed = false;
499
518
  this._raf = 0;
@@ -507,11 +526,30 @@ class CameraView {
507
526
  if (this._loop) this._start();
508
527
  }
509
528
 
510
- /** `'woven-sbs'` (3D on the panel) | `'flat-left'` (a pair, shown flat) | `'flat'` (mono). */
529
+ /**
530
+ * True while the view is on the woven route: registered on a 3D wall, and the wall has not said
531
+ * the tile will not weave (a failed layer drops the view to `'flat-left'`). Route is
532
+ * `'woven-sbs'` (3D on the panel) | `'flat-left'` (a pair, shown flat) | `'flat'` (mono).
533
+ */
511
534
  get woven() {
512
535
  return this.route === 'woven-sbs';
513
536
  }
514
537
 
538
+ /**
539
+ * What is actually on the panel, for diagnostics: `{ route, woven, reason, firstWoven,
540
+ * layerRetries }`. `firstWoven` is the current registration's settled result, `'pending'`
541
+ * before it settles, null off the woven route.
542
+ */
543
+ weaveState() {
544
+ return {
545
+ route: this.route,
546
+ woven: this.woven,
547
+ reason: this.fallbackReason,
548
+ firstWoven: this.route === 'woven-sbs' ? (this._fw ? { ...this._fw } : 'pending') : null,
549
+ layerRetries: this._layerFails,
550
+ };
551
+ }
552
+
515
553
  /** The per-eye convergence shift currently painted, source px. */
516
554
  get convergencePx() {
517
555
  return this.conv.current;
@@ -543,20 +581,77 @@ class CameraView {
543
581
  return this.autoConverge;
544
582
  }
545
583
 
546
- /** Re-register on the wall (the call does this when the weave goes live, #172, or a session is recovered). */
584
+ /**
585
+ * Re-register on the wall (the call does this when the weave goes live, #172, or a session is
586
+ * recovered). A FORCED reroute also clears a layer fallback (web#131): a wall that came back
587
+ * gets a fresh registration. An unforced one keeps the view flat while a fallback stands.
588
+ */
547
589
  _reroute(force, wall) {
548
590
  if (wall !== undefined) this.wall = wall;
549
591
  if (this.removed) return;
592
+ if (force && this.fallbackReason) {
593
+ this.fallbackReason = null;
594
+ clearTimeout(this._layerTimer);
595
+ this._layerTimer = 0;
596
+ }
550
597
  const cam = this.cam;
551
598
  const live = cam.state === 'live' && cam.video;
552
- const route = cam.format === 'sbs' && live && this.wall && this.wall.supported ? 'woven-sbs' : cam.format === 'sbs' ? 'flat-left' : 'flat';
599
+ const wovenOk = cam.format === 'sbs' && live && this.wall && this.wall.supported && !this.fallbackReason;
600
+ const route = wovenOk ? 'woven-sbs' : cam.format === 'sbs' ? 'flat-left' : 'flat';
553
601
  if (!force && route === this.route) return;
602
+ const prev = this.route;
554
603
  this._unregister();
555
604
  this.route = route;
556
- if (route === 'woven-sbs') {
557
- if (!this.buffer) this.buffer = document.createElement('canvas');
558
- this._paintWoven(); // the first frame exists before the layer does
559
- this.handle = this.wall.addImage(this.canvas, this.buffer);
605
+ if (route === 'woven-sbs') this._registerWoven();
606
+ if (route !== prev) this._routeChanged();
607
+ }
608
+
609
+ _registerWoven() {
610
+ if (!this.buffer) this.buffer = document.createElement('canvas');
611
+ this._paintWoven(); // the first frame exists before the layer does
612
+ const handle = this.wall.addImage(this.canvas, this.buffer);
613
+ this.handle = handle;
614
+ this._fw = null;
615
+ const fw = handle && handle.firstWoven;
616
+ if (!fw || typeof fw.then !== 'function') return;
617
+ fw.then((res) => {
618
+ // A result for a registration we already left (removed, re-registered) says nothing now.
619
+ if (this.handle !== handle || this.removed || !res) return;
620
+ this._fw = { woven: !!res.woven, confirmed: !!res.confirmed, reason: res.reason || null, ms: res.ms };
621
+ this.cam.log?.('view-first-woven', { woven: !!res.woven, reason: res.reason || null });
622
+ if (res.woven) {
623
+ this._layerFails = 0;
624
+ this._routeChanged(); // the state settled; the route did not move
625
+ } else if (res.reason !== 'removed') this._layerLost(res.reason || 'layer-failed');
626
+ });
627
+ }
628
+
629
+ /**
630
+ * The wall said this window will not weave (web#131): the canvas must never be left showing the
631
+ * packed pair under a 3D badge. Go flat (one eye, mirrored) and say why. A layer the browser
632
+ * refused is retried a few times with backoff while the wall stays up (the remote tile's rule);
633
+ * a session that ended waits for `_reroute(true, wall)` with the new wall.
634
+ */
635
+ _layerLost(reason) {
636
+ this.fallbackReason = reason;
637
+ this._reroute(false);
638
+ if (reason !== 'layer-failed' || this._layerFails >= LAYER_RETRIES) return;
639
+ const delay = Math.min(LAYER_RETRY_MAX_MS, LAYER_RETRY_BASE_MS * Math.pow(2, this._layerFails++));
640
+ this.cam.log?.('view-layer-retry', { attempt: this._layerFails, inMs: delay });
641
+ clearTimeout(this._layerTimer);
642
+ this._layerTimer = setTimeout(() => {
643
+ this._layerTimer = 0;
644
+ if (this.removed || this.fallbackReason !== reason) return;
645
+ this._reroute(true);
646
+ }, delay);
647
+ }
648
+
649
+ _routeChanged() {
650
+ if (!this._onRouteChange) return;
651
+ try {
652
+ this._onRouteChange(this.route, this.weaveState());
653
+ } catch (err) {
654
+ console.error(`${TAG} onRouteChange threw`, err);
560
655
  }
561
656
  }
562
657
 
@@ -672,6 +767,8 @@ class CameraView {
672
767
  this.removed = true;
673
768
  if (this._raf) cancelAnimationFrame(this._raf);
674
769
  this._raf = 0;
770
+ clearTimeout(this._layerTimer);
771
+ this._layerTimer = 0;
675
772
  this._unregister();
676
773
  this.cam._views?.delete(this);
677
774
  }
@@ -0,0 +1,281 @@
1
+ // inline3d-cursor-depth-playcanvas.js — the depth-aware cursor on the PlayCanvas backend (./model and
2
+ // ./splat, `cursor: 'depth'`).
3
+ //
4
+ // Internal, and LOADED ONLY ON OPT-IN: the adapters import this module dynamically when a page
5
+ // passes `cursor: 'depth'`, so a page that does not ask pays nothing — not even the download.
6
+ // The adapter's per-frame hook is an optional call (`viewer.cursorDepth?.frame(…)`) that is a
7
+ // no-op without it.
8
+ //
9
+ // Hit test: PlayCanvas's own depth PICKER, from the first eye, at low resolution. One path for
10
+ // meshes AND gaussian splats (the picker renders both into its depth target), and it reads what
11
+ // is actually drawn — no splat raycasting, no CPU triangle walk. It costs one extra low-res
12
+ // scene pass, run every other frame and only while the pointer is over the canvas. Results come
13
+ // back asynchronously (a frame or two late); the placement filter absorbs that, exactly like the
14
+ // runtime's one-frame-late app hint.
15
+ //
16
+ // Placement: the same maths as everywhere else (./inline3d-cursor-depth.js, a port of the
17
+ // runtime's u_cursor_depth, ADR-046), solved in WORLD space from this frame's views carried
18
+ // through the rig node. Drawn as immediate-mode lines with depth test off, so every eye camera
19
+ // draws it and nothing occludes it.
20
+
21
+ import {
22
+ CursorDepthPlacer,
23
+ CursorPointer,
24
+ CURSOR_DEFAULT_HEIGHT,
25
+ cursorCrosshairPoints,
26
+ cursorFootprint,
27
+ cursorViewRay,
28
+ } from './inline3d-cursor-depth.js';
29
+
30
+ /** Pick-buffer width in pixels (height follows the eye's aspect). */
31
+ const PICK_WIDTH = 160;
32
+ /** Pick every Nth frame. */
33
+ const PICK_EVERY = 2;
34
+ /** A pick that has not answered in this long is abandoned (and the cursor falls to the glass). */
35
+ const PICK_TIMEOUT_MS = 500;
36
+
37
+ export class PlayCanvasDepthCursor {
38
+ /**
39
+ * @param {object} pc the PlayCanvas namespace the adapter renders with.
40
+ * @param {object} viewer the PlayCanvasSplatViewer (app, rigNode, eye are read lazily).
41
+ * @param {{canvas: HTMLElement, height?: number, color?: number[]}} opts
42
+ */
43
+ constructor(pc, viewer, { canvas, height = CURSOR_DEFAULT_HEIGHT, color = [1, 0.83, 0.1, 1] }) {
44
+ this.pc = pc;
45
+ this.viewer = viewer;
46
+ this.height = height;
47
+ this.color = color;
48
+ this.pointer = new CursorPointer(canvas);
49
+ this.placer = new CursorDepthPlacer();
50
+ /** The last placement (diagnostics). */
51
+ this.placement = { active: false };
52
+ /** The last picked world point under the footprint, or null (nothing under it). */
53
+ this.hit = null;
54
+ this._picker = null;
55
+ this._pickCam = null;
56
+ this._inFlight = false;
57
+ this._pickId = 0;
58
+ this._pickStart = 0;
59
+ this._frame = 0;
60
+ this._disposed = false;
61
+ this._points = [];
62
+ this._colors = [];
63
+ }
64
+
65
+ /**
66
+ * One 3D frame, before app.tick: schedule a pick, place the sprite, queue its lines.
67
+ * @param {Array<{proj: ArrayLike<number>, pose: ArrayLike<number>, width: number, height: number}>} entries
68
+ * this frame's views, poses in RIG space (the eye cameras' parent).
69
+ */
70
+ frame(entries) {
71
+ const app = this.viewer.app;
72
+ const rig = this.viewer.rigNode;
73
+ const uv = this.pointer.uv;
74
+ if (this._disposed || !app || !rig || !uv || !entries || entries.length < 2) {
75
+ this.inactive();
76
+ return;
77
+ }
78
+ const R = rig.getWorldTransform().data;
79
+ const views = entries.map((e) => ({ projectionMatrix: e.proj, transformMatrix: mul4(R, e.pose) }));
80
+ const t = globalThis.performance ? globalThis.performance.now() : Date.now();
81
+ if (this._inFlight && t - this._pickStart > PICK_TIMEOUT_MS) {
82
+ // A read that never settles must not freeze the cursor on a stale hit.
83
+ this._inFlight = false;
84
+ this._pickId++;
85
+ this.hit = null;
86
+ if (!this._warnedTimeout) {
87
+ this._warnedTimeout = true;
88
+ console.warn('[inline3d] cursor: depth: a PlayCanvas pick did not answer in time; retrying (cursor on the glass meanwhile).');
89
+ }
90
+ }
91
+ if (this._frame++ % PICK_EVERY === 0 && !this._inFlight) this._pick(app, rig, entries[0], uv);
92
+ const now = (globalThis.performance ? globalThis.performance.now() : Date.now()) / 1000;
93
+ const p = this.placer.update(views, { u: uv[0], v: uv[1], nearestPoint: this.hit, cursorHeight: this.height }, now);
94
+ this.placement = p;
95
+ this.pointer.hideCss(!!p.active);
96
+ if (!p.active) return;
97
+ const pts = cursorCrosshairPoints(p, this._points);
98
+ const cols = this._colors;
99
+ if (cols.length !== (pts.length / 3) * 4) {
100
+ cols.length = 0;
101
+ for (let i = 0; i < pts.length / 3; i++) cols.push(...this.color);
102
+ }
103
+ app.drawLineArrays(pts, cols, false);
104
+ }
105
+
106
+ /** Mono / no pointer / no views: no sprite, the CSS cursor back. */
107
+ inactive() {
108
+ if (this.placement.active) this.placement = { active: false };
109
+ this.pointer.hideCss(false);
110
+ }
111
+
112
+ dispose() {
113
+ this._disposed = true;
114
+ this.pointer.dispose();
115
+ try {
116
+ this._picker?.destroy?.();
117
+ this._pickCam?.destroy?.();
118
+ } catch {
119
+ // the app may already be gone with its device
120
+ }
121
+ this._picker = null;
122
+ this._pickCam = null;
123
+ }
124
+
125
+ // Render the pick buffer from the first eye and read the footprint's world points.
126
+ _pick(app, rig, e, uv) {
127
+ const pc = this.pc;
128
+ const aspect = e.width > 0 && e.height > 0 ? e.width / e.height : 1;
129
+ const W = PICK_WIDTH;
130
+ const H = Math.max(1, Math.round(W / aspect));
131
+ if (!this._picker) {
132
+ this._picker = new pc.Picker(app, W, H, true);
133
+ const cam = new pc.Entity('inline3d-cursor-pick', app);
134
+ // frustumCulling off: this camera never renders on its own (disabled — the picker drives
135
+ // it), so nothing keeps its culling frustum current, and a stale one culls everything.
136
+ cam.addComponent('camera', { clearColor: new pc.Color(0, 0, 0, 0), frustumCulling: false });
137
+ cam.camera.enabled = false;
138
+ cam._dxrProj = new Float64Array(16);
139
+ cam.camera.calculateProjection = (out) => out.set(cam._dxrProj);
140
+ rig.addChild(cam);
141
+ this._pickCam = cam;
142
+ }
143
+ const cam = this._pickCam;
144
+ cam._dxrProj.set(e.proj);
145
+ // The picker linearises depth with the COMPONENT's near/far, and reconstructs through the
146
+ // projection: keep the two in agreement (GL-style projection: m10 = -(f+n)/(f-n), m14 = -2fn/(f-n)).
147
+ const m10 = e.proj[10], m14 = e.proj[14];
148
+ const near = m14 / (m10 - 1);
149
+ const far = m14 / (m10 + 1);
150
+ if (near > 0 && far > near) {
151
+ cam.camera.nearClip = near;
152
+ cam.camera.farClip = far;
153
+ }
154
+ if (cam.camera.camera) cam.camera.camera._projMatDirty = true;
155
+ const q = quatFromMatrix(e.pose);
156
+ cam.setLocalPosition(e.pose[12], e.pose[13], e.pose[14]);
157
+ cam.setLocalRotation(q[0], q[1], q[2], q[3]);
158
+ const eye = this.viewer.eye?.camera;
159
+ const skip = new Set([pc.LAYERID_UI, pc.LAYERID_IMMEDIATE, pc.LAYERID_SKYBOX]);
160
+ const ids = (eye && Array.isArray(eye.layers) ? eye.layers : cam.camera.layers).filter((id) => !skip.has(id));
161
+ const layers = ids.map((id) => app.scene.layers.getLayerById(id)).filter(Boolean);
162
+
163
+ const picker = this._picker;
164
+ picker.resize(W, H);
165
+ try {
166
+ // Enabled only for the synchronous pick pass: a disabled camera is not in its layers'
167
+ // camera lists, so the picker would render nothing; enabled outside it, the camera would
168
+ // render a frame of its own on every app.tick.
169
+ cam.camera.enabled = true;
170
+ picker.prepare(cam.camera, app.scene, layers);
171
+ } catch (err) {
172
+ console.warn('[inline3d] cursor: depth: the PlayCanvas picker failed; cursor stays on the glass.', err);
173
+ this.hit = null;
174
+ return;
175
+ } finally {
176
+ cam.camera.enabled = false;
177
+ }
178
+ // The picker stores LINEAR depth: (viewDist - near) / (far - near), viewDist = 1/gl_FragCoord.w.
179
+ // Rebuild the point ourselves along the exact off-axis ray (getWorldPointAsync would unproject
180
+ // with the component's symmetric projection, not this one). PlayCanvas's view matrix is built
181
+ // from the camera's world POSITION and ROTATION only — the rig node's scale is not in it — so
182
+ // view distances are world units and the ray must use the UNSCALED world rotation.
183
+ const view = { projectionMatrix: e.proj, transformMatrix: unscaled(cam.getWorldTransform().data) };
184
+ const Mw = view.transformMatrix;
185
+ const f = [-Mw[8], -Mw[9], -Mw[10]];
186
+ const zNear = cam.camera.nearClip, zFar = cam.camera.farClip;
187
+ const samples = cursorFootprint(uv[0], uv[1], this.height, this.pointer.aspect())
188
+ .filter(([u, v]) => u >= 0 && u <= 1 && v >= 0 && v <= 1)
189
+ .map(([u, v]) =>
190
+ picker.getPointDepthAsync(Math.min(W - 1, Math.floor(u * W)), Math.min(H - 1, Math.floor(v * H))).then((lin) => {
191
+ if (lin === null || !(lin >= 0 && lin < 1)) return null;
192
+ const z = zNear + lin * (zFar - zNear);
193
+ const ray = cursorViewRay(view, u, v);
194
+ return { x: ray.origin[0] + ray.direction[0] * z, y: ray.origin[1] + ray.direction[1] * z, z: ray.origin[2] + ray.direction[2] * z };
195
+ }),
196
+ );
197
+ this._inFlight = true;
198
+ const id = ++this._pickId;
199
+ this._pickStart = globalThis.performance ? globalThis.performance.now() : Date.now();
200
+ Promise.all(samples)
201
+ .then((pts) => {
202
+ if (this._disposed || id !== this._pickId) return;
203
+ let best = Infinity;
204
+ let nearest = null;
205
+ for (const p of pts) {
206
+ if (!p) continue;
207
+ const d = p.x * f[0] + p.y * f[1] + p.z * f[2];
208
+ if (d < best) {
209
+ best = d;
210
+ nearest = [p.x, p.y, p.z];
211
+ }
212
+ }
213
+ this.hit = nearest;
214
+ })
215
+ .catch(() => {
216
+ if (id === this._pickId) this.hit = null;
217
+ })
218
+ .finally(() => {
219
+ if (id === this._pickId) this._inFlight = false;
220
+ });
221
+ }
222
+ }
223
+
224
+ /** A copy of a column-major transform with unit rotation columns (scale removed). */
225
+ function unscaled(m) {
226
+ const o = Array.from(m);
227
+ for (const c of [0, 4, 8]) {
228
+ const l = Math.hypot(o[c], o[c + 1], o[c + 2]) || 1;
229
+ o[c] /= l;
230
+ o[c + 1] /= l;
231
+ o[c + 2] /= l;
232
+ }
233
+ return o;
234
+ }
235
+
236
+ /** Column-major 4x4 multiply, a × b. */
237
+ function mul4(a, b) {
238
+ const o = new Array(16);
239
+ for (let c = 0; c < 4; c++) {
240
+ for (let r = 0; r < 4; r++) {
241
+ let s = 0;
242
+ for (let k = 0; k < 4; k++) s += a[k * 4 + r] * b[c * 4 + k];
243
+ o[c * 4 + r] = s;
244
+ }
245
+ }
246
+ return o;
247
+ }
248
+
249
+ /** Unit quaternion [x, y, z, w] from a column-major rigid transform's rotation. */
250
+ function quatFromMatrix(m) {
251
+ const m00 = m[0], m11 = m[5], m22 = m[10];
252
+ const tr = m00 + m11 + m22;
253
+ let x, y, z, w;
254
+ if (tr > 0) {
255
+ const s = Math.sqrt(tr + 1) * 2;
256
+ w = 0.25 * s;
257
+ x = (m[6] - m[9]) / s;
258
+ y = (m[8] - m[2]) / s;
259
+ z = (m[1] - m[4]) / s;
260
+ } else if (m00 > m11 && m00 > m22) {
261
+ const s = Math.sqrt(1 + m00 - m11 - m22) * 2;
262
+ w = (m[6] - m[9]) / s;
263
+ x = 0.25 * s;
264
+ y = (m[4] + m[1]) / s;
265
+ z = (m[8] + m[2]) / s;
266
+ } else if (m11 > m22) {
267
+ const s = Math.sqrt(1 + m11 - m00 - m22) * 2;
268
+ w = (m[8] - m[2]) / s;
269
+ x = (m[4] + m[1]) / s;
270
+ y = 0.25 * s;
271
+ z = (m[9] + m[6]) / s;
272
+ } else {
273
+ const s = Math.sqrt(1 + m22 - m00 - m11) * 2;
274
+ w = (m[1] - m[4]) / s;
275
+ x = (m[8] + m[2]) / s;
276
+ y = (m[9] + m[6]) / s;
277
+ z = 0.25 * s;
278
+ }
279
+ const l = Math.hypot(x, y, z, w) || 1;
280
+ return [x / l, y / l, z / l, w / l];
281
+ }