@thatopen/services 0.3.14 → 0.3.16

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.
Files changed (26) hide show
  1. package/dist/built-in/index.d.ts +165 -2
  2. package/dist/cli/commands/revit.d.ts +1 -1
  3. package/dist/cli/lib/revit-addin.d.ts +1 -1
  4. package/dist/cli.js +24 -13
  5. package/dist/index.cjs.js +2 -2
  6. package/dist/index.es.js +46 -36
  7. package/docs/ai-quickstart.md +130 -130
  8. package/docs/builtin/paths.json +94 -86
  9. package/docs/cloud/GaussianSplatConverter.md +43 -43
  10. package/docs/cloud/PointCloudConverter.md +53 -53
  11. package/docs/revit-collab-quickstart.md +256 -205
  12. package/package.json +1 -1
  13. package/src/built-in/UIManager/src/app/examples/ProjectFileStorage/example.ts +150 -0
  14. package/src/built-in/UIManager/src/clashes-panel/example.ts +183 -160
  15. package/src/built-in/UIManager/src/models-list/example.ts +122 -122
  16. package/src/built-in/UIManager/src/topics-panel/example.ts +170 -0
  17. package/src/built-in/index.ts +1048 -881
  18. package/src/cli/templates/app/README.md +217 -217
  19. package/src/cli/templates/app/src/app.ts +37 -37
  20. package/src/cli/templates/app/src/assets/tool-placeholder.ts +50 -50
  21. package/src/cli/templates/app/src/main.ts +199 -199
  22. package/src/cli/templates/app/src/setups/reality-capture/lib/hidden-tiles-plugin.ts +88 -88
  23. package/src/cli/templates/app/src/setups/reality-capture/lib/point-tile-plugin.ts +166 -166
  24. package/src/cli/templates/app/src/setups/reality-capture/lib/render-frame.ts +31 -31
  25. package/src/cli/templates/app/src/setups/reality-capture/lib/splat-tile-plugin.ts +51 -51
  26. package/src/cli/templates/app/src/setups/reality-capture-viewer.ts +1383 -1383
@@ -1,166 +1,166 @@
1
- import * as THREE from "three";
2
- import { frame, placeAtAnchor } from "./render-frame";
3
-
4
- // ---- decode worker pool -----------------------------------------------------
5
- // Dequantizes node blobs off the main thread (grimoire: worker-zero-copy decode).
6
- //
7
- // INLINE worker: the decode logic is bundled as a source STRING -> Blob -> object
8
- // URL classic worker. NO `new URL("./worker.ts", import.meta.url)` — that breaks
9
- // under esbuild bundling + the platform's sandboxed iframe (Invalid URL). This
10
- // blob-URL form works in every bundler/sandbox (same approach Spark uses).
11
- // Blob: f64 anchor[3] | f32 scale[3] | u32 count | (u16 x,y,z + u8 r,g,b)*count.
12
- const WORKER_SRC = `
13
- self.onmessage = function (e) {
14
- var id = e.data.id, buffer = e.data.buffer;
15
- var dv = new DataView(buffer);
16
- var ax = dv.getFloat64(0, true), ay = dv.getFloat64(8, true), az = dv.getFloat64(16, true);
17
- var sx = dv.getFloat32(24, true), sy = dv.getFloat32(28, true), sz = dv.getFloat32(32, true);
18
- var count = dv.getUint32(36, true);
19
- var positions = new Float32Array(count * 3);
20
- var colors = new Uint8Array(count * 3);
21
- var o = 40;
22
- for (var k = 0; k < count; k++) {
23
- positions[k * 3] = dv.getUint16(o, true) * sx;
24
- positions[k * 3 + 1] = dv.getUint16(o + 2, true) * sy;
25
- positions[k * 3 + 2] = dv.getUint16(o + 4, true) * sz;
26
- colors[k * 3] = dv.getUint8(o + 6);
27
- colors[k * 3 + 1] = dv.getUint8(o + 7);
28
- colors[k * 3 + 2] = dv.getUint8(o + 8);
29
- o += 9;
30
- }
31
- self.postMessage({ id: id, positions: positions, colors: colors, count: count, anchor: [ax, ay, az] }, [positions.buffer, colors.buffer]);
32
- };
33
- `;
34
- let _blobURL: string | null = null;
35
- function workerURL(): string {
36
- return (_blobURL ??= URL.createObjectURL(new Blob([WORKER_SRC], { type: "text/javascript" })));
37
- }
38
-
39
- type Decoded = { positions: Float32Array; colors: Uint8Array; count: number; anchor: [number, number, number] };
40
- class DecodePool {
41
- private workers: Worker[] = [];
42
- private idle: Worker[] = [];
43
- private queue: { id: number; buffer: ArrayBuffer; resolve: (d: Decoded) => void }[] = [];
44
- private pending = new Map<number, (d: Decoded) => void>();
45
- private nextId = 0;
46
- constructor(n: number) {
47
- for (let i = 0; i < n; i++) {
48
- const w = new Worker(workerURL()); // classic blob worker (no import.meta.url, no module type)
49
- w.onmessage = (e: MessageEvent<Decoded & { id: number }>) => {
50
- const r = this.pending.get(e.data.id);
51
- this.pending.delete(e.data.id);
52
- this.idle.push(w);
53
- r?.(e.data);
54
- this.drain();
55
- };
56
- this.workers.push(w); this.idle.push(w);
57
- }
58
- }
59
- decode(buffer: ArrayBuffer): Promise<Decoded> {
60
- return new Promise((resolve) => { this.queue.push({ id: this.nextId++, buffer, resolve }); this.drain(); });
61
- }
62
- private drain() {
63
- while (this.idle.length && this.queue.length) {
64
- const w = this.idle.pop()!; const job = this.queue.shift()!;
65
- this.pending.set(job.id, job.resolve);
66
- w.postMessage({ id: job.id, buffer: job.buffer }, [job.buffer]); // transfer in (zero-copy)
67
- }
68
- }
69
- }
70
- // LAZY: workers are created on the FIRST decode (a points tile), never at module
71
- // load — so merely importing this module (e.g. for the splat path) spins up nothing.
72
- let _pool: DecodePool | null = null;
73
- function getPool(): DecodePool {
74
- return (_pool ??= new DecodePool(Math.min(16, Math.max(3, (navigator.hardwareConcurrency || 4) - 1))));
75
- }
76
- export function decodeStats() {
77
- const p = _pool as any;
78
- return p ? { workers: p.workers.length, idle: p.idle.length, queued: p.queue.length, pending: p.pending.size }
79
- : { workers: 0, idle: 0, queued: 0, pending: 0 };
80
- }
81
-
82
- // 3DTilesRendererJS plugin: decodes our quantized point-node format into a
83
- // node-local THREE.Points, tagged with its float64 world anchor for RTC.
84
- //
85
- // Node blob (sliced out of cloud.bin by byte range):
86
- // header: f64 anchor[3] | f32 scale[3] | u32 count (40 B)
87
- // body: per point u16 x,y,z + u8 r,g,b (9 B)
88
- // Decoded position = stored * scale (node-local, small, float32-safe).
89
- //
90
- // Same `parseToMesh` seam the experiment proved with Spark: return a
91
- // THREE.Object3D and the renderer owns transform/cull/cache/eviction. We must
92
- // NOT be async at the top level (invokeOnePlugin treats a Promise as truthy and
93
- // stops iterating), so non-matching content returns null synchronously.
94
-
95
- // Shared material for all point nodes. Writes rgb + view-space log-depth (alpha)
96
- // so the EDL post pass gets its depth field for free (no second target).
97
- const pointUniforms = { uPointSize: { value: frame.pointSize } };
98
-
99
- const pointMaterial = new THREE.ShaderMaterial({
100
- uniforms: pointUniforms,
101
- vertexShader: /* glsl */ `
102
- attribute vec3 color;
103
- uniform float uPointSize;
104
- varying vec3 vColor;
105
- varying float vLogZ;
106
- void main() {
107
- vColor = color;
108
- vec4 mv = modelViewMatrix * vec4(position, 1.0);
109
- vLogZ = log2(max(1e-6, -mv.z));
110
- gl_Position = projectionMatrix * mv;
111
- gl_PointSize = uPointSize;
112
- }
113
- `,
114
- fragmentShader: /* glsl */ `
115
- varying vec3 vColor;
116
- varying float vLogZ;
117
- void main() {
118
- gl_FragColor = vec4(vColor, vLogZ);
119
- }
120
- `,
121
- });
122
-
123
- export function setPointSize(px: number) {
124
- pointUniforms.uPointSize.value = px;
125
- }
126
-
127
- export class PointTilePlugin {
128
- name = "POINT_TILE_PLUGIN";
129
- tiles: any = null;
130
-
131
- init(tiles: any) {
132
- this.tiles = tiles;
133
- }
134
-
135
- // parseToMesh: hand the blob to the decode WORKER POOL (off-thread dequant +
136
- // zero-copy transfer back) so tile loads never hitch the main thread. Must
137
- // return null synchronously for non-matching content (a Promise is truthy and
138
- // would stop plugin iteration); matching content returns a Promise<Points>.
139
- parseToMesh(
140
- buffer: ArrayBuffer,
141
- _tile: any,
142
- _extension: string,
143
- uri: string,
144
- signal: AbortSignal
145
- ) {
146
- if (!uri.includes(".pnt")) return null;
147
- if (signal.aborted) return null;
148
- return getPool().decode(buffer).then((d) => {
149
- if (signal.aborted) return null;
150
- const geom = new THREE.BufferGeometry();
151
- geom.setAttribute("position", new THREE.BufferAttribute(d.positions, 3));
152
- geom.setAttribute("color", new THREE.BufferAttribute(d.colors, 3, true));
153
- const points = new THREE.Points(geom, pointMaterial);
154
- points.matrixAutoUpdate = false;
155
- points.frustumCulled = false; // renderer culls per tile already
156
- points.userData.worldAnchor = d.anchor; // float64 world anchor for RTC
157
- placeAtAnchor(points, d.anchor);
158
- return points;
159
- });
160
- }
161
-
162
- disposeTile(tile: any) {
163
- const scene = tile.engineData?.scene;
164
- if (scene && scene.geometry) scene.geometry.dispose();
165
- }
166
- }
1
+ import * as THREE from "three";
2
+ import { frame, placeAtAnchor } from "./render-frame";
3
+
4
+ // ---- decode worker pool -----------------------------------------------------
5
+ // Dequantizes node blobs off the main thread (grimoire: worker-zero-copy decode).
6
+ //
7
+ // INLINE worker: the decode logic is bundled as a source STRING -> Blob -> object
8
+ // URL classic worker. NO `new URL("./worker.ts", import.meta.url)` — that breaks
9
+ // under esbuild bundling + the platform's sandboxed iframe (Invalid URL). This
10
+ // blob-URL form works in every bundler/sandbox (same approach Spark uses).
11
+ // Blob: f64 anchor[3] | f32 scale[3] | u32 count | (u16 x,y,z + u8 r,g,b)*count.
12
+ const WORKER_SRC = `
13
+ self.onmessage = function (e) {
14
+ var id = e.data.id, buffer = e.data.buffer;
15
+ var dv = new DataView(buffer);
16
+ var ax = dv.getFloat64(0, true), ay = dv.getFloat64(8, true), az = dv.getFloat64(16, true);
17
+ var sx = dv.getFloat32(24, true), sy = dv.getFloat32(28, true), sz = dv.getFloat32(32, true);
18
+ var count = dv.getUint32(36, true);
19
+ var positions = new Float32Array(count * 3);
20
+ var colors = new Uint8Array(count * 3);
21
+ var o = 40;
22
+ for (var k = 0; k < count; k++) {
23
+ positions[k * 3] = dv.getUint16(o, true) * sx;
24
+ positions[k * 3 + 1] = dv.getUint16(o + 2, true) * sy;
25
+ positions[k * 3 + 2] = dv.getUint16(o + 4, true) * sz;
26
+ colors[k * 3] = dv.getUint8(o + 6);
27
+ colors[k * 3 + 1] = dv.getUint8(o + 7);
28
+ colors[k * 3 + 2] = dv.getUint8(o + 8);
29
+ o += 9;
30
+ }
31
+ self.postMessage({ id: id, positions: positions, colors: colors, count: count, anchor: [ax, ay, az] }, [positions.buffer, colors.buffer]);
32
+ };
33
+ `;
34
+ let _blobURL: string | null = null;
35
+ function workerURL(): string {
36
+ return (_blobURL ??= URL.createObjectURL(new Blob([WORKER_SRC], { type: "text/javascript" })));
37
+ }
38
+
39
+ type Decoded = { positions: Float32Array; colors: Uint8Array; count: number; anchor: [number, number, number] };
40
+ class DecodePool {
41
+ private workers: Worker[] = [];
42
+ private idle: Worker[] = [];
43
+ private queue: { id: number; buffer: ArrayBuffer; resolve: (d: Decoded) => void }[] = [];
44
+ private pending = new Map<number, (d: Decoded) => void>();
45
+ private nextId = 0;
46
+ constructor(n: number) {
47
+ for (let i = 0; i < n; i++) {
48
+ const w = new Worker(workerURL()); // classic blob worker (no import.meta.url, no module type)
49
+ w.onmessage = (e: MessageEvent<Decoded & { id: number }>) => {
50
+ const r = this.pending.get(e.data.id);
51
+ this.pending.delete(e.data.id);
52
+ this.idle.push(w);
53
+ r?.(e.data);
54
+ this.drain();
55
+ };
56
+ this.workers.push(w); this.idle.push(w);
57
+ }
58
+ }
59
+ decode(buffer: ArrayBuffer): Promise<Decoded> {
60
+ return new Promise((resolve) => { this.queue.push({ id: this.nextId++, buffer, resolve }); this.drain(); });
61
+ }
62
+ private drain() {
63
+ while (this.idle.length && this.queue.length) {
64
+ const w = this.idle.pop()!; const job = this.queue.shift()!;
65
+ this.pending.set(job.id, job.resolve);
66
+ w.postMessage({ id: job.id, buffer: job.buffer }, [job.buffer]); // transfer in (zero-copy)
67
+ }
68
+ }
69
+ }
70
+ // LAZY: workers are created on the FIRST decode (a points tile), never at module
71
+ // load — so merely importing this module (e.g. for the splat path) spins up nothing.
72
+ let _pool: DecodePool | null = null;
73
+ function getPool(): DecodePool {
74
+ return (_pool ??= new DecodePool(Math.min(16, Math.max(3, (navigator.hardwareConcurrency || 4) - 1))));
75
+ }
76
+ export function decodeStats() {
77
+ const p = _pool as any;
78
+ return p ? { workers: p.workers.length, idle: p.idle.length, queued: p.queue.length, pending: p.pending.size }
79
+ : { workers: 0, idle: 0, queued: 0, pending: 0 };
80
+ }
81
+
82
+ // 3DTilesRendererJS plugin: decodes our quantized point-node format into a
83
+ // node-local THREE.Points, tagged with its float64 world anchor for RTC.
84
+ //
85
+ // Node blob (sliced out of cloud.bin by byte range):
86
+ // header: f64 anchor[3] | f32 scale[3] | u32 count (40 B)
87
+ // body: per point u16 x,y,z + u8 r,g,b (9 B)
88
+ // Decoded position = stored * scale (node-local, small, float32-safe).
89
+ //
90
+ // Same `parseToMesh` seam the experiment proved with Spark: return a
91
+ // THREE.Object3D and the renderer owns transform/cull/cache/eviction. We must
92
+ // NOT be async at the top level (invokeOnePlugin treats a Promise as truthy and
93
+ // stops iterating), so non-matching content returns null synchronously.
94
+
95
+ // Shared material for all point nodes. Writes rgb + view-space log-depth (alpha)
96
+ // so the EDL post pass gets its depth field for free (no second target).
97
+ const pointUniforms = { uPointSize: { value: frame.pointSize } };
98
+
99
+ const pointMaterial = new THREE.ShaderMaterial({
100
+ uniforms: pointUniforms,
101
+ vertexShader: /* glsl */ `
102
+ attribute vec3 color;
103
+ uniform float uPointSize;
104
+ varying vec3 vColor;
105
+ varying float vLogZ;
106
+ void main() {
107
+ vColor = color;
108
+ vec4 mv = modelViewMatrix * vec4(position, 1.0);
109
+ vLogZ = log2(max(1e-6, -mv.z));
110
+ gl_Position = projectionMatrix * mv;
111
+ gl_PointSize = uPointSize;
112
+ }
113
+ `,
114
+ fragmentShader: /* glsl */ `
115
+ varying vec3 vColor;
116
+ varying float vLogZ;
117
+ void main() {
118
+ gl_FragColor = vec4(vColor, vLogZ);
119
+ }
120
+ `,
121
+ });
122
+
123
+ export function setPointSize(px: number) {
124
+ pointUniforms.uPointSize.value = px;
125
+ }
126
+
127
+ export class PointTilePlugin {
128
+ name = "POINT_TILE_PLUGIN";
129
+ tiles: any = null;
130
+
131
+ init(tiles: any) {
132
+ this.tiles = tiles;
133
+ }
134
+
135
+ // parseToMesh: hand the blob to the decode WORKER POOL (off-thread dequant +
136
+ // zero-copy transfer back) so tile loads never hitch the main thread. Must
137
+ // return null synchronously for non-matching content (a Promise is truthy and
138
+ // would stop plugin iteration); matching content returns a Promise<Points>.
139
+ parseToMesh(
140
+ buffer: ArrayBuffer,
141
+ _tile: any,
142
+ _extension: string,
143
+ uri: string,
144
+ signal: AbortSignal
145
+ ) {
146
+ if (!uri.includes(".pnt")) return null;
147
+ if (signal.aborted) return null;
148
+ return getPool().decode(buffer).then((d) => {
149
+ if (signal.aborted) return null;
150
+ const geom = new THREE.BufferGeometry();
151
+ geom.setAttribute("position", new THREE.BufferAttribute(d.positions, 3));
152
+ geom.setAttribute("color", new THREE.BufferAttribute(d.colors, 3, true));
153
+ const points = new THREE.Points(geom, pointMaterial);
154
+ points.matrixAutoUpdate = false;
155
+ points.frustumCulled = false; // renderer culls per tile already
156
+ points.userData.worldAnchor = d.anchor; // float64 world anchor for RTC
157
+ placeAtAnchor(points, d.anchor);
158
+ return points;
159
+ });
160
+ }
161
+
162
+ disposeTile(tile: any) {
163
+ const scene = tile.engineData?.scene;
164
+ if (scene && scene.geometry) scene.geometry.dispose();
165
+ }
166
+ }
@@ -1,31 +1,31 @@
1
- import * as THREE from "three";
2
-
3
- // Shared floating-origin (RTC) state for the whole viewer.
4
- //
5
- // The precision stack: authoritative coordinates live in float64 (plain JS
6
- // numbers). `origin` is a float64 world point near the camera. Every renderable
7
- // (point node, splat) is placed at worldAnchor - origin computed in float64,
8
- // then assigned to a float32 Object3D.position — which is small and safe. When
9
- // the camera travels far, we rebase `origin` and shift everything, so GPU
10
- // coordinates never grow large no matter the absolute world position.
11
-
12
- export const POINT_LAYER = 1;
13
- export const SPLAT_LAYER = 2;
14
-
15
- export const frame = {
16
- // float64 render origin in world space
17
- origin: [0, 0, 0] as [number, number, number],
18
- pointSize: 3.0,
19
- edlStrength: 0.35,
20
- edlRadius: 1.4,
21
- edlOn: true,
22
- };
23
-
24
- // Place a point node at its FULL float64 world anchor. The recentering (origin
25
- // subtraction) is done ONCE by pointContainer.position = -origin each frame — so
26
- // world = -origin + anchor + localOffset = (worldPoint - origin), small + precise.
27
- // (Do NOT subtract origin here too, or it gets subtracted twice.)
28
- export function placeAtAnchor(obj: THREE.Object3D, anchor: [number, number, number]) {
29
- obj.position.set(anchor[0], anchor[1], anchor[2]);
30
- obj.updateMatrix();
31
- }
1
+ import * as THREE from "three";
2
+
3
+ // Shared floating-origin (RTC) state for the whole viewer.
4
+ //
5
+ // The precision stack: authoritative coordinates live in float64 (plain JS
6
+ // numbers). `origin` is a float64 world point near the camera. Every renderable
7
+ // (point node, splat) is placed at worldAnchor - origin computed in float64,
8
+ // then assigned to a float32 Object3D.position — which is small and safe. When
9
+ // the camera travels far, we rebase `origin` and shift everything, so GPU
10
+ // coordinates never grow large no matter the absolute world position.
11
+
12
+ export const POINT_LAYER = 1;
13
+ export const SPLAT_LAYER = 2;
14
+
15
+ export const frame = {
16
+ // float64 render origin in world space
17
+ origin: [0, 0, 0] as [number, number, number],
18
+ pointSize: 3.0,
19
+ edlStrength: 0.35,
20
+ edlRadius: 1.4,
21
+ edlOn: true,
22
+ };
23
+
24
+ // Place a point node at its FULL float64 world anchor. The recentering (origin
25
+ // subtraction) is done ONCE by pointContainer.position = -origin each frame — so
26
+ // world = -origin + anchor + localOffset = (worldPoint - origin), small + precise.
27
+ // (Do NOT subtract origin here too, or it gets subtracted twice.)
28
+ export function placeAtAnchor(obj: THREE.Object3D, anchor: [number, number, number]) {
29
+ obj.position.set(anchor[0], anchor[1], anchor[2]);
30
+ obj.updateMatrix();
31
+ }
@@ -1,51 +1,51 @@
1
- import * as THREE from "three";
2
- import { SplatMesh } from "@sparkjsdev/spark";
3
-
4
- // 3DTilesRendererJS plugin that renders .ply Gaussian-splat tiles via Spark —
5
- // the proven seam from Antonio's 3d-tiles experiment, kept intact. The point of
6
- // the slice: the SAME streaming foundation (3DTilesRendererJS) drives this splat
7
- // branch and the point-cloud branch as peers, differing only at parseToMesh.
8
- //
9
- // parseToMesh must not be async at the top level (a returned Promise is treated
10
- // as truthy and stops plugin iteration) — return null synchronously for non-ply.
11
- export class SplatTilePlugin {
12
- name = "SPLAT_TILE_PLUGIN";
13
- tiles: any = null;
14
-
15
- init(tiles: any) {
16
- this.tiles = tiles;
17
- }
18
-
19
- parseToMesh(
20
- buffer: ArrayBuffer,
21
- _tile: any,
22
- extension: string,
23
- _uri: string,
24
- signal: AbortSignal
25
- ) {
26
- if (extension !== "ply" && extension !== "spz") return null;
27
- // Spark's SplatMesh sniffs the actual file type (PLY/SPZ/SPLAT/…) from the
28
- // bytes, so both load the same way.
29
- return (async () => {
30
- if (signal.aborted) return null;
31
- const splatMesh = new SplatMesh({ fileBytes: new Uint8Array(buffer) });
32
- await splatMesh.initialized;
33
- if (signal.aborted) {
34
- splatMesh.dispose();
35
- return null;
36
- }
37
- // Real splat count from the LOADED data — format-agnostic. (A byte probe of
38
- // the header is fragile: SPZ is gzipped, so reading bytes off the top gives
39
- // garbage ~2.6e9, which poisons the motion-budget governor's count and makes
40
- // the Motion-detail slider a no-op.) packedSplats.numSplats is correct for
41
- // every format.
42
- splatMesh.userData.splatCount = (splatMesh as any).packedSplats?.numSplats ?? 0;
43
- return splatMesh as unknown as THREE.Object3D;
44
- })();
45
- }
46
-
47
- disposeTile(tile: any) {
48
- const scene = tile.engineData?.scene;
49
- if (scene && typeof scene.dispose === "function") scene.dispose();
50
- }
51
- }
1
+ import * as THREE from "three";
2
+ import { SplatMesh } from "@sparkjsdev/spark";
3
+
4
+ // 3DTilesRendererJS plugin that renders .ply Gaussian-splat tiles via Spark —
5
+ // the proven seam from Antonio's 3d-tiles experiment, kept intact. The point of
6
+ // the slice: the SAME streaming foundation (3DTilesRendererJS) drives this splat
7
+ // branch and the point-cloud branch as peers, differing only at parseToMesh.
8
+ //
9
+ // parseToMesh must not be async at the top level (a returned Promise is treated
10
+ // as truthy and stops plugin iteration) — return null synchronously for non-ply.
11
+ export class SplatTilePlugin {
12
+ name = "SPLAT_TILE_PLUGIN";
13
+ tiles: any = null;
14
+
15
+ init(tiles: any) {
16
+ this.tiles = tiles;
17
+ }
18
+
19
+ parseToMesh(
20
+ buffer: ArrayBuffer,
21
+ _tile: any,
22
+ extension: string,
23
+ _uri: string,
24
+ signal: AbortSignal
25
+ ) {
26
+ if (extension !== "ply" && extension !== "spz") return null;
27
+ // Spark's SplatMesh sniffs the actual file type (PLY/SPZ/SPLAT/…) from the
28
+ // bytes, so both load the same way.
29
+ return (async () => {
30
+ if (signal.aborted) return null;
31
+ const splatMesh = new SplatMesh({ fileBytes: new Uint8Array(buffer) });
32
+ await splatMesh.initialized;
33
+ if (signal.aborted) {
34
+ splatMesh.dispose();
35
+ return null;
36
+ }
37
+ // Real splat count from the LOADED data — format-agnostic. (A byte probe of
38
+ // the header is fragile: SPZ is gzipped, so reading bytes off the top gives
39
+ // garbage ~2.6e9, which poisons the motion-budget governor's count and makes
40
+ // the Motion-detail slider a no-op.) packedSplats.numSplats is correct for
41
+ // every format.
42
+ splatMesh.userData.splatCount = (splatMesh as any).packedSplats?.numSplats ?? 0;
43
+ return splatMesh as unknown as THREE.Object3D;
44
+ })();
45
+ }
46
+
47
+ disposeTile(tile: any) {
48
+ const scene = tile.engineData?.scene;
49
+ if (scene && typeof scene.dispose === "function") scene.dispose();
50
+ }
51
+ }