@displayxr/inline3d 1.6.1 → 1.7.1

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.
@@ -52,6 +52,15 @@ const DEFAULT_DEPTH_LIMIT = 4.0;
52
52
  /** Milliseconds of no interaction before the idle turntable starts. */
53
53
  const IDLE_DELAY_MS = 2500;
54
54
 
55
+ /**
56
+ * Per-frame easing factor for a focus change, matching the gallery's `EASE`.
57
+ *
58
+ * Deliberately per FRAME and not per second, because that is what the reference implementation
59
+ * does and a focus change is a one-off gesture response rather than a continuous motion — the
60
+ * difference between 60 and 120 Hz here is a settle that takes half as long, not a bug.
61
+ */
62
+ const FOCUS_EASE = 0.18;
63
+
55
64
  const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
56
65
  // NaN/Infinity into a transform silently blanks the tile — three propagates it into the
57
66
  // matrix and every vertex lands undefined. Reject at the setter instead.
@@ -226,6 +235,23 @@ export class SceneViewer {
226
235
 
227
236
  this._fitScale = 1;
228
237
  this._zoom = 1;
238
+ // FOCUS — the point everything turns about, eased. `_focus` is where it is now, `_target`
239
+ // where it is going; `_orbitCentre` is where the pivot sits afterwards, and it is what
240
+ // separates the two rigs: a DISPLAY rig brings the focused point to the middle of the tile
241
+ // (centre 0), a CAMERA rig leaves the capture exactly where it was placed and only moves
242
+ // what the rotation turns about (centre = the focus point). See setFocus().
243
+ // Plain triples, not THREE.Vector3: this module takes its THREE by injection and is tested
244
+ // against a stub, so every three.js type it reaches for is one more thing a consumer has to
245
+ // supply. Three numbers need no library.
246
+ this._focus = { x: 0, y: 0, z: 0 };
247
+ this._targetFocus = { x: 0, y: 0, z: 0 };
248
+ this._orbitCentre = { x: 0, y: 0, z: 0 };
249
+ this._focusRecentres = true;
250
+ this._focusSettled = true;
251
+ /** Called after every focus ease step, with the live focus. Set by ./splat. */
252
+ this.onFocusChange = null;
253
+ /** Called at the end of every _tick, after the transform is applied. */
254
+ this.onTick = null;
229
255
  this._targetZoom = 1;
230
256
  // Author-driven slide along the depth axis, display metres, +z toward the viewer. Applied
231
257
  // by _applyTransform, PRESERVED by fitTo, cleared by resetPose. Default 0 means every page
@@ -307,7 +333,10 @@ export class SceneViewer {
307
333
  const c = Array.isArray(center) ? center : [center.x, center.y, center.z];
308
334
  const e = Array.isArray(extent) ? extent : [extent.x, extent.y, extent.z];
309
335
 
310
- this._centering.position.set(-c[0], -c[1], -c[2]);
336
+ // Through the focus, not around it: framing a subject IS pointing the viewer at its centre,
337
+ // and keeping the two in one place is what stops an orbit turning about somewhere the fit
338
+ // has since moved away from. Snapped — a refit is not a gesture.
339
+ this.setFocus(c, { snap: true });
311
340
  // Recorded for getSubjectBounds(). Model units; the fit scale is applied at read time so a
312
341
  // later zoom or orbit needs no re-measure.
313
342
  this._subjectHalf = [Math.abs(e[0]) / 2, Math.abs(e[1]) / 2, Math.abs(e[2]) / 2];
@@ -480,6 +509,57 @@ export class SceneViewer {
480
509
  this._applyTransform();
481
510
  }
482
511
 
512
+ /**
513
+ * Point the viewer at something — the one point that is simultaneously the orbit centre, the
514
+ * pivot plane and (on a camera rig) the convergence distance.
515
+ *
516
+ * Those three are the same thing and saying so is the point of this method. A viewer that lets
517
+ * them drift apart orbits about one place, converges at another and rotates the picture around
518
+ * a third, which is how "the scene swings away when I turn it" happens.
519
+ *
520
+ * The two rigs differ in what MOVES, and only in that:
521
+ *
522
+ * - **`recentre: true`** (a display rig, the default) — the focused point is brought to the
523
+ * middle of the tile and onto the zero-disparity plane. That is what a portal does: you
524
+ * chose a subject, so the subject is what the window shows.
525
+ * - **`recentre: false`** (a camera rig) — the capture stays exactly where it was placed and
526
+ * only the rotation centre moves. Translating a camera-rig scene would move the viewpoint,
527
+ * and the neutral view IS the photograph; nothing may move it.
528
+ *
529
+ * Eased at {@link FOCUS_EASE} per frame unless `snap`.
530
+ *
531
+ * @param {{x:number,y:number,z:number}|number[]|null} point in CONTENT space (the space your
532
+ * object sits in, i.e. `viewer.content`'s local space). Null resets to the origin.
533
+ * @param {object} [opts]
534
+ * @param {boolean} [opts.snap=false] arrive immediately.
535
+ * @param {boolean} [opts.recentre] see above. Sticky: set once when the rig is chosen.
536
+ */
537
+ setFocus(point, { snap = false, recentre } = {}) {
538
+ if (recentre !== undefined) this._focusRecentres = !!recentre;
539
+ const p = point == null ? [0, 0, 0] : Array.isArray(point) ? point : [point.x, point.y, point.z];
540
+ this._targetFocus = { x: finite(p[0], 0), y: finite(p[1], 0), z: finite(p[2], 0) };
541
+ this._focusSettled = false;
542
+ if (snap) {
543
+ this._focus = { ...this._targetFocus };
544
+ this._focusSettled = true;
545
+ this._applyFocus();
546
+ this._applyTransform();
547
+ this.onFocusChange?.(this._focus);
548
+ }
549
+ return this;
550
+ }
551
+
552
+ /**
553
+ * Where the viewer is pointed, in content space.
554
+ *
555
+ * @param {object} [opts]
556
+ * @param {boolean} [opts.target=false] the value being eased TOWARD, as with getPose().
557
+ */
558
+ getFocus({ target = false } = {}) {
559
+ const v = target ? this._targetFocus : this._focus;
560
+ return { x: v.x, y: v.y, z: v.z };
561
+ }
562
+
483
563
  /** Return to the framed default pose, depth slide included. */
484
564
  resetPose() {
485
565
  this.setPose({ yaw: 0, pitch: 0, zoom: 1, depthOffset: 0 });
@@ -757,7 +837,13 @@ export class SceneViewer {
757
837
  // The depth slide lives here, not in fitTo, so it survives a refit and cannot be left
758
838
  // stale by a code path that forgets it. x/y are never written: the fit centres the subject
759
839
  // on the tile and sliding it sideways is a scene concern, not a viewer one.
760
- this._pivot.position.z = this._depthOffset;
840
+ // The orbit centre is where the pivot SITS; the depth slide rides on top of it. Both are
841
+ // zero for the ordinary framed subject, so this is identity for every existing page.
842
+ this._pivot.position.set(
843
+ this._orbitCentre.x,
844
+ this._orbitCentre.y,
845
+ this._orbitCentre.z + this._depthOffset,
846
+ );
761
847
  // Order 'XYZ' == R = Rx(pitch) · Ry(yaw), and the order is the whole point.
762
848
  //
763
849
  // Yaw must act in the subject's OWN frame (spin it on its axis); pitch must act in the
@@ -824,7 +910,44 @@ export class SceneViewer {
824
910
  } else {
825
911
  this._zoom = this._targetZoom;
826
912
  }
913
+ this._easeFocus();
827
914
  this._applyTransform();
915
+ this.onTick?.();
916
+ }
917
+
918
+ /**
919
+ * Walk the live focus toward its target. A no-op — not even a vector compare — for every
920
+ * viewer that never sets one.
921
+ */
922
+ _easeFocus() {
923
+ if (this._focusSettled) return;
924
+ const f = this._focus;
925
+ const t = this._targetFocus;
926
+ const dx = t.x - f.x;
927
+ const dy = t.y - f.y;
928
+ const dz = t.z - f.z;
929
+ if (dx * dx + dy * dy + dz * dz < 1e-10) {
930
+ f.x = t.x;
931
+ f.y = t.y;
932
+ f.z = t.z;
933
+ this._focusSettled = true;
934
+ } else {
935
+ f.x += dx * FOCUS_EASE;
936
+ f.y += dy * FOCUS_EASE;
937
+ f.z += dz * FOCUS_EASE;
938
+ }
939
+ this._applyFocus();
940
+ this.onFocusChange?.(f);
941
+ }
942
+
943
+ /** Write the current focus into the scene graph. */
944
+ _applyFocus() {
945
+ const f = this._focus;
946
+ this._centering.position.set(-f.x, -f.y, -f.z);
947
+ const c = this._orbitCentre;
948
+ c.x = this._focusRecentres ? 0 : f.x;
949
+ c.y = this._focusRecentres ? 0 : f.y;
950
+ c.z = this._focusRecentres ? 0 : f.z;
828
951
  }
829
952
 
830
953
  _bindOrbit() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@displayxr/inline3d",
3
- "version": "1.6.1",
3
+ "version": "1.7.1",
4
4
  "description": "Turn any HTML <canvas> into a glasses-free-3D window on a DisplayXR display, inside an ordinary web page. Dependency-free; progressive enhancement (falls back to plain 2D everywhere else).",
5
5
  "type": "module",
6
6
  "types": "./index.d.ts",
@@ -40,6 +40,9 @@
40
40
  "js/inline3d-three.js",
41
41
  "js/inline3d-viewer.js",
42
42
  "js/inline3d-splat.js",
43
+ "js/inline3d-sog.js",
44
+ "js/inline3d-splat-perf.js",
45
+ "js/inline3d-splat-rig.js",
43
46
  "js/inline3d-model.js",
44
47
  "index.d.ts",
45
48
  "three.d.ts",
package/splat.d.ts CHANGED
@@ -3,6 +3,114 @@
3
3
 
4
4
  import type { SceneViewer, SubjectBounds, OrbitPose } from './viewer.js';
5
5
 
6
+ /**
7
+ * The knobs behind `SplatOptions.perf`. Every one is a Spark 2.1.0 setting except `alphaRadius`,
8
+ * which is a patch to Spark's own vertex shader (there is no option for it). Bit-exact vs lossy,
9
+ * and the defaults each one overrides, are tabled in `js/inline3d-splat-perf.js`.
10
+ */
11
+ export interface SplatPerfOptions {
12
+ /** Shrink each quad to the radius where its alpha reaches `alphaFloor`. Bit-exact by default. */
13
+ alphaRadius?: boolean;
14
+ /**
15
+ * The alpha each splat's tail may be cut at, PER SPLAT. Defaults to `minAlpha`, which is the
16
+ * bit-exact cut; above it this is a lossy crop that scales with each splat's own opacity.
17
+ */
18
+ alphaFloor?: number;
19
+ /** Drop splats and fragments under this alpha. Spark's default is `0.5/255`. */
20
+ minAlpha?: number;
21
+ /** Quad extent in σ, globally. Spark's default is `Math.sqrt(8)`. */
22
+ maxStdDev?: number;
23
+ /** Drop splats smaller than this, in pixels. Spark's default is 0. */
24
+ minPixelRadius?: number;
25
+ /** Clamp on quad size in pixels — note it SQUASHES rather than crops. Default 512. */
26
+ maxPixelRadius?: number;
27
+ /** 1 = Gaussian falloff, 0 = flat. Not a perf knob; 0 costs MORE. */
28
+ falloff?: number;
29
+ /** Build LOD data at load, so Spark can substitute merged splats against a budget. */
30
+ lod?: boolean | 'quality';
31
+ /** LOD budget multiplier (needs `lod`). */
32
+ lodSplatScale?: number;
33
+ /** Absolute LOD budget in splats (needs `lod`). */
34
+ lodSplatCount?: number;
35
+ /** Minimum on-screen splat size multiplier (needs `lod`); up to ~5 is often invisible. */
36
+ lodRenderScale?: number;
37
+ }
38
+
39
+ /** Camera intrinsics for ONE eye, in pixels, OpenCV convention. */
40
+ export interface SogIntrinsics {
41
+ fx: number;
42
+ fy: number;
43
+ cx: number;
44
+ cy: number;
45
+ width: number;
46
+ height: number;
47
+ }
48
+
49
+ /**
50
+ * The `camera` block of a `.sog`'s `meta.json` (v2), plus the fields this SDK derives from it.
51
+ * v2 is a superset of v1: everything but `convention` is optional, `intrinsics` included.
52
+ */
53
+ export interface SogCamera {
54
+ convention: 'opencv';
55
+ /** Which rig the asset asks for. Null when the block does not say. */
56
+ rig: 'camera' | 'display' | null;
57
+ rest: { position: number[]; rotation: number[] };
58
+ /** Null when the block carried none, or none that were usable — estimate one instead. */
59
+ intrinsics: SogIntrinsics | null;
60
+ stereo: { baseline_m: number } | null;
61
+ /**
62
+ * The point that is simultaneously the orbit centre, the pivot plane and the convergence
63
+ * distance. In the splat's own space. The three distances are advisory.
64
+ */
65
+ focus: {
66
+ point: number[];
67
+ subject_m: number | null;
68
+ near_m: number | null;
69
+ far_m: number | null;
70
+ source: string | null;
71
+ } | null;
72
+ /** The camera rig's ABSOLUTE scalars. Null when unstated. */
73
+ dxr: { ipdFactor: number | null; parallaxFactor: number | null };
74
+ /** Full vertical angle of the capture in RADIANS; null without intrinsics. */
75
+ verticalFov: number | null;
76
+ /** Principal point off the frame centre, fraction of the frame, y UP; null without intrinsics. */
77
+ principalOffset: { x: number; y: number } | null;
78
+ }
79
+
80
+ /**
81
+ * What the waterfall resolved, with the step that produced each value beside it — which is the
82
+ * point of it. `intrinsicsSource: 'fallback-28mm'` on an asset that looks zoomed out says more
83
+ * than any amount of staring at the picture.
84
+ */
85
+ export interface ResolvedRig {
86
+ type: 'camera' | 'display';
87
+ typeSource: 'caller' | 'block' | 'block-present' | 'default';
88
+ rest: { position: number[]; rotation: number[] };
89
+ intrinsics: SogIntrinsics;
90
+ intrinsicsSource: 'block' | 'caller' | 'estimated' | 'fallback-28mm';
91
+ /** 35 mm-equivalent focal of whatever lens was resolved. */
92
+ focalEqMm: number;
93
+ /** The live focus, in the splat's own space. */
94
+ focus: number[];
95
+ focusSource:
96
+ | 'caller'
97
+ | 'caller-convergence'
98
+ | 'block'
99
+ | 'median-disparity'
100
+ | 'default'
101
+ | 'picked'
102
+ | 'set';
103
+ /** What Space returns to. */
104
+ focusDefault: number[];
105
+ focusDefaultSource: string;
106
+ /** The block's advisory distances, when it carried any. */
107
+ focusDistances: { subject_m: number | null; near_m: number | null; far_m: number | null } | null;
108
+ /** Focus distance along the rest camera's view axis — the zero-disparity PLANE. */
109
+ convergence: number;
110
+ ipdFactor: number;
111
+ parallaxFactor: number;
112
+ }
113
+
6
114
  export interface SplatOptions {
7
115
  /** Metres of world the tile's height spans (default 0.24). */
8
116
  virtualDisplayHeight?: number;
@@ -32,6 +140,41 @@ export interface SplatOptions {
32
140
  feather?: number;
33
141
  /** Minimum ms between splat sorts. Defaults to 16 so both eyes share one sort per frame. */
34
142
  sortIntervalMs?: number;
143
+ /**
144
+ * Cut overdraw. UNSET changes nothing — every Spark default stays where Spark put it, so an
145
+ * existing page's pixels do not move.
146
+ *
147
+ * `'exact'` is the bit-exact pair — each quad shrunk to where its own alpha reaches 1/255
148
+ * (those fragments were already being discarded), plus the 1/255 peak-opacity cull. It buys
149
+ * little on a mostly-opaque capture, which is what a lifted photograph is. `'balanced'` (or
150
+ * `true`, −5…−20 % measured) and `'aggressive'` (−22 %) tighten the quad extent instead, which
151
+ * is the axis that actually pays on the web; both move pixels.
152
+ */
153
+ perf?: true | 'exact' | 'balanced' | 'aggressive' | SplatPerfOptions;
154
+ /**
155
+ * Which view rig. `'auto'` (the default) reads it off the ASSET — a `.sog` carrying a `camera`
156
+ * block was lifted from a photograph and gets a camera rig that conserves the recording
157
+ * camera; anything else is an object and gets the display rig with the auto-frame. Only
158
+ * detectable when `src` is BYTES.
159
+ */
160
+ rig?: 'auto' | 'display' | 'camera';
161
+ /** Camera rig only: the distance in world metres that sits ON the glass. */
162
+ convergence?: number;
163
+ /**
164
+ * The point to converge on and orbit about, in the splat's own space — the highest step of the
165
+ * focus waterfall. Wins over `convergence`, which is the straight-ahead shorthand for it.
166
+ */
167
+ focus?: number[];
168
+ /** Override the lens, when the asset carries none and the estimate is wrong. */
169
+ intrinsics?: SogIntrinsics;
170
+ /** Camera rig scalars. ABSOLUTE, never normalised against the convergence. */
171
+ ipdFactor?: number;
172
+ parallaxFactor?: number;
173
+ /**
174
+ * Bind double-click (focus what was clicked) and Space (back to the resolved focus). Default
175
+ * true; pass false when the page owns those gestures itself.
176
+ */
177
+ focusInput?: boolean;
35
178
  /**
36
179
  * Disambiguates .splat from .ksplat when passing BYTES — content-sniffing cannot separate
37
180
  * those two. Unnecessary for .sog/.ply/.spz, which are identifiable by magic number.
@@ -55,11 +198,32 @@ export interface SplatHandle {
55
198
  readonly spark: object;
56
199
  /** Bounds actually used for framing; null until `ready` resolves. */
57
200
  frame: SubjectBounds | null;
201
+ /**
202
+ * The `.sog`'s `camera` block — the recording camera, when the asset carries one. Null for a
203
+ * URL source, a non-`.sog`, or an object splat (which is most of them).
204
+ */
205
+ camera: SogCamera | null;
206
+ /** What the waterfall resolved, sources included. Null until `ready` resolves. */
207
+ rig: ResolvedRig | null;
208
+ /** The view-rig descriptor sent to the runtime, on the camera path. */
209
+ viewRig?: object;
210
+ /** What `perf` actually applied, or null. */
211
+ perf: object | null;
58
212
  /** Resolves once the asset has loaded and been framed; rejects if the load failed. */
59
213
  readonly ready: Promise<SplatHandle>;
60
214
 
61
215
  setPose(pose?: OrbitPose): void;
62
216
  resetPose(): void;
217
+ /**
218
+ * Point the window at something, in the SPLAT's own space (the space the `camera` block's
219
+ * `focus.point` is in). Null returns to whatever the waterfall resolved. Eased unless `snap`.
220
+ */
221
+ setFocus(
222
+ point: number[] | { x: number; y: number; z: number } | null,
223
+ opts?: { snap?: boolean },
224
+ ): SplatHandle;
225
+ /** What is under a point on the canvas, in the splat's own space — the double-click's raycast. */
226
+ pick(clientX: number, clientY: number): number[] | null;
63
227
 
64
228
  /** Close this window and release its GPU resources. */
65
229
  remove(): void;
package/viewer.d.ts CHANGED
@@ -105,6 +105,22 @@ export declare class SceneViewer {
105
105
 
106
106
  /** Snap the pose. Writes the eased value and its target together. */
107
107
  setPose(pose?: OrbitPose): void;
108
+ /**
109
+ * Point the viewer at something: the orbit centre, the pivot plane and (on a camera rig) the
110
+ * convergence distance are ONE point, and this is it. `recentre: true` (a display rig) brings
111
+ * that point to the middle of the tile; `recentre: false` (a camera rig) leaves the capture
112
+ * where it was placed and moves only the rotation centre. Eased 0.18/frame unless `snap`.
113
+ */
114
+ setFocus(
115
+ point: { x: number; y: number; z: number } | number[] | null,
116
+ opts?: { snap?: boolean; recentre?: boolean },
117
+ ): SceneViewer;
118
+ /** Where the viewer is pointed, in content space. */
119
+ getFocus(opts?: { target?: boolean }): { x: number; y: number; z: number };
120
+ /** Called after every focus ease step, with the live focus. */
121
+ onFocusChange: ((focus: { x: number; y: number; z: number }) => void) | null;
122
+ /** Called at the end of every tick, after the transform is applied. */
123
+ onTick: (() => void) | null;
108
124
 
109
125
  /**
110
126
  * The pose as it is right now. `target: true` reports what it is easing TOWARD, which differs