@displayxr/inline3d 1.9.0 → 1.9.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,38 @@ entry points (`.`, `./three`) are frozen for 1.x, while the **scene subpaths** (
5
5
  `./splat`, `./model`) are a preview tier whose options may change in any release. Entries below say
6
6
  which tier they touch, because that is what tells you whether an upgrade can move your pixels.
7
7
 
8
+ ## 1.9.1 — 2026-09-23
9
+
10
+ Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only), plus the package manifest.
11
+ Nothing changes for Spark callers or for splat-only pages. The fixes affect only what a page can
12
+ do with `handle.engine`.
13
+
14
+ ### Fixed
15
+
16
+ - **`handle.engine.root` is usable as documented.** The tile's `AppBase` now registers `Render`,
17
+ `Light` and `Anim` component systems and a `Container` resource handler, next to `Camera`,
18
+ `GSplat` and `Texture`/`GSplat`. That is exactly what a glTF needs, skinned and animated
19
+ included. Before, a page had to register them itself or `instantiateRenderEntity()` produced
20
+ nothing.
21
+ - A page that still registers them now gets the existing system back instead of an engine
22
+ "already registered" throw.
23
+ - Cost: +0.1 ms boot (`AppBase.init` 0.4 → 0.5 ms). +41.8 KB gzip only for a hand-tree-shaken
24
+ engine build; nothing for the SDK as shipped.
25
+ - Verified with a skinned, animated `.glb` over a splat: composited, depth-tested against nearer
26
+ splats, animation advancing.
27
+ - **`"./package.json"` is exported**, so `import pkg from '@displayxr/inline3d/package.json'` and
28
+ `require.resolve('@displayxr/inline3d/package.json')` work instead of
29
+ `ERR_PACKAGE_PATH_NOT_EXPORTED`.
30
+
31
+ ### Added
32
+
33
+ - **`nearClip` / `farClip`** (PlayCanvas): a floor on the projection's near plane and a cap on its
34
+ far plane, for depth precision in a mixed mesh + splat scene. The adapter still owns the
35
+ projections, and unset leaves them untouched.
36
+ - Docs: `handle.engine` in [`docs/playcanvas-adapter.md`](docs/playcanvas-adapter.md). Covers what
37
+ is registered, the cost, and two engine gotchas (lights shine along local −Y; use `AnimTrack.name`
38
+ with `assignAnimation`).
39
+
8
40
  ## 1.9.0 — 2026-09-23
9
41
 
10
42
  Touches the **preview tier** (`./splat`, `engine: 'playcanvas'` only). Additive: nothing changes for
@@ -84,6 +84,54 @@ import {
84
84
  streamedBytesError,
85
85
  } from './inline3d-splat-shared.js';
86
86
 
87
+ /**
88
+ * The component systems the tile's `AppBase` registers. Camera + GSplat draw the splat; Render,
89
+ * Light and Anim are what a page needs to put a glTF — skinned and animated included — and its
90
+ * lights under `handle.engine.root` (1.9.1; before that the page had to register them itself).
91
+ * Nothing else from the engine's full `Application` list (physics, UI, audio, particles, scripts
92
+ * and so on are the page's own business if it wants them).
93
+ */
94
+ export const PLAYCANVAS_SYSTEMS = Object.freeze([
95
+ 'CameraComponentSystem',
96
+ 'GSplatComponentSystem',
97
+ 'RenderComponentSystem',
98
+ 'LightComponentSystem',
99
+ 'AnimComponentSystem',
100
+ ]);
101
+
102
+ /**
103
+ * The resource handlers the tile's loader registers. Texture + GSplat load a splat (a bundled
104
+ * .sog is a zip of webp planes the loader registers as textures); Container loads a .glb/.gltf.
105
+ * The container's sub-assets (render, material, animation) arrive already loaded, so they need no
106
+ * handler of their own.
107
+ */
108
+ export const PLAYCANVAS_HANDLERS = Object.freeze(['TextureHandler', 'GSplatHandler', 'ContainerHandler']);
109
+
110
+ /**
111
+ * Registering a component system the app already has THROWS in the engine ("already
112
+ * registered"). Pages written before 1.9.1 add Render/Light/Anim themselves, so a second `add` of
113
+ * an id that exists is made a no-op that returns the registered system (the duplicate the page
114
+ * constructed is destroyed, so it leaves no listeners behind).
115
+ */
116
+ function guardDuplicateSystems(app) {
117
+ const reg = app.systems;
118
+ if (!reg || typeof reg.add !== 'function' || reg._dxrGuarded) return;
119
+ const add = reg.add.bind(reg);
120
+ reg.add = (system) => {
121
+ const existing = system?.id ? reg[system.id] : null;
122
+ if (existing && existing !== system) {
123
+ try {
124
+ system.destroy?.();
125
+ } catch {
126
+ /* a half-built duplicate: nothing to release */
127
+ }
128
+ return existing;
129
+ }
130
+ return add(system);
131
+ };
132
+ reg._dxrGuarded = true;
133
+ }
134
+
87
135
  /** The engine release this adapter was built and measured against (npm peer floor). */
88
136
  export const PLAYCANVAS_TESTED = '2.22.3';
89
137
 
@@ -138,6 +186,24 @@ export function frustumFromProjection(P) {
138
186
  };
139
187
  }
140
188
 
189
+ /**
190
+ * Raise a perspective projection's near plane to at least `nearFloor` and lower its far plane to
191
+ * at most `farCap`, IN PLACE, leaving the frustum's shape (fov, skew, principal point) untouched —
192
+ * only the depth mapping (P[10], P[14]) is rewritten. Idempotent. A null bound is left alone.
193
+ */
194
+ export function clampProjectionDepth(P, nearFloor, farCap) {
195
+ const f0 = frustumFromProjection(P);
196
+ let n = f0.nearClip;
197
+ let f = f0.farClip;
198
+ if (nearFloor !== null && nearFloor !== undefined && nearFloor > n) n = nearFloor;
199
+ if (farCap !== null && farCap !== undefined && farCap < f) f = farCap;
200
+ if (!(f > n)) f = n * 1.0001 + 1e-6;
201
+ if (n === f0.nearClip && f === f0.farClip) return P;
202
+ P[10] = -(f + n) / (f - n);
203
+ P[14] = (-2 * f * n) / (f - n);
204
+ return P;
205
+ }
206
+
141
207
  /** Rigid pose (position + unit quaternion xyzw) as a column-major 4×4. */
142
208
  export function poseMatrix(p, q, out = new Float64Array(16)) {
143
209
  const [x, y, z, w] = q;
@@ -467,8 +533,15 @@ export class PlayCanvasSplatViewer {
467
533
  orbitEase = {},
468
534
  feather = 0,
469
535
  captureFit = 'height',
536
+ nearClip,
537
+ farClip,
470
538
  } = opts;
471
539
  this.canvas = canvas;
540
+ // Depth range for a MIXED scene (meshes under handle.engine.root depth-test against each
541
+ // other; splats only test against them). The projections' own near/far stay the adapter's —
542
+ // these only raise the near (floor) and lower the far (cap). Unset: untouched.
543
+ this.nearClip = Number.isFinite(nearClip) && nearClip > 0 ? nearClip : null;
544
+ this.farClip = Number.isFinite(farClip) && farClip > 0 ? farClip : null;
472
545
  // The tilt-and-relax orbit (./inline3d-splat-shared.js §ORBIT): drag tilts up to ±orbitMaxDeg
473
546
  // from where the press started, easing with τ = orbitEase.drag; release relaxes back with
474
547
  // τ = orbitEase.rest. `_orbitMode` is 'drag' | 'rest' | null (null = ordinary damping).
@@ -784,12 +857,14 @@ export class PlayCanvasSplatViewer {
784
857
  const opts = new pc.AppOptions();
785
858
  opts.graphicsDevice = device;
786
859
  // No xr (AppBase constructs XrManager only when asked, and XrManager is what probes and
787
- // can request immersive sessions), no mouse/keyboard/touch: the SDK owns input.
788
- opts.componentSystems = [pc.CameraComponentSystem, pc.GSplatComponentSystem];
789
- // TextureHandler: a bundled .sog is a zip of webp planes the loader registers as textures.
790
- opts.resourceHandlers = [pc.TextureHandler, pc.GSplatHandler];
860
+ // can request immersive sessions), no mouse/keyboard/touch: the SDK owns input. Beyond the
861
+ // splat itself, exactly what a glTF-with-animation under `handle.engine.root` needs — see
862
+ // PLAYCANVAS_SYSTEMS / PLAYCANVAS_HANDLERS.
863
+ opts.componentSystems = PLAYCANVAS_SYSTEMS.map((n) => pc[n]).filter(Boolean);
864
+ opts.resourceHandlers = PLAYCANVAS_HANDLERS.map((n) => pc[n]).filter(Boolean);
791
865
  const app = new pc.AppBase(this.canvas);
792
866
  app.init(opts);
867
+ guardDuplicateSystems(app);
793
868
  // The SDK sizes the buffer (double-width in 3D, 1:1 in mono), so the engine must never
794
869
  // resize it. RESOLUTION_FIXED is AppBase's DEFAULT, and with it `updateCanvasSize()` is a
795
870
  // no-op. Deliberately NOT calling setCanvasResolution/setCanvasFillMode: without explicit
@@ -1047,6 +1122,9 @@ export class PlayCanvasSplatViewer {
1047
1122
  const el = this.canvas;
1048
1123
  const sx = cache && cache.bufW > 0 && el.width ? el.width / cache.bufW : 1;
1049
1124
  const sy = cache && cache.bufH > 0 && el.height ? el.height / cache.bufH : 1;
1125
+ if (this.nearClip !== null || this.farClip !== null) {
1126
+ for (const e of entries) clampProjectionDepth(e.proj, this.nearClip, this.farClip);
1127
+ }
1050
1128
  const rect = (e) =>
1051
1129
  sx !== 1 || sy !== 1
1052
1130
  ? [Math.round(e.x * sx), Math.round(e.y * sy), Math.max(1, Math.round(e.width * sx)), Math.max(1, Math.round(e.height * sy))]
@@ -1627,6 +1705,8 @@ export function attachPlayCanvasSplat(out, wall, canvas, src, opts, pending = []
1627
1705
  orbitEase: opts.orbitEase,
1628
1706
  feather,
1629
1707
  captureFit,
1708
+ nearClip: opts.nearClip,
1709
+ farClip: opts.farClip,
1630
1710
  });
1631
1711
 
1632
1712
  let handle = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@displayxr/inline3d",
3
- "version": "1.9.0",
3
+ "version": "1.9.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",
@@ -27,6 +27,7 @@
27
27
  "types": "./model.d.ts",
28
28
  "import": "./js/inline3d-model.js"
29
29
  },
30
+ "./package.json": "./package.json",
30
31
  "./undock": {
31
32
  "types": "./index.d.ts",
32
33
  "import": "./js/inline3d-undock.js"
package/splat.d.ts CHANGED
@@ -259,6 +259,14 @@ export interface SplatOptions {
259
259
  * `'height'`. Both backends; the 3D rig's vertical FOV follows the crop. Anything else throws.
260
260
  */
261
261
  captureFit?: 'height' | 'cover';
262
+ /**
263
+ * PlayCanvas: a FLOOR on the projection's near plane, in world units (the adapter owns the
264
+ * projections; this only raises near, for depth precision when meshes share the scene under
265
+ * `handle.engine.root`). Anything nearer is clipped, splats included. Unset: untouched.
266
+ */
267
+ nearClip?: number;
268
+ /** PlayCanvas: a CAP on the projection's far plane (only ever lowers it). Unset: untouched. */
269
+ farClip?: number;
262
270
  /** Camera rig only: the distance in world metres that sits ON the glass. */
263
271
  convergence?: number;
264
272
  /**