@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,1383 +1,1383 @@
1
- import * as OBC from "@thatopen/components";
2
- import * as THREE from "three";
3
- import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
4
- import { TransformControls } from "three/examples/jsm/controls/TransformControls.js";
5
- import { TilesRenderer } from "3d-tiles-renderer";
6
- import { SparkRenderer } from "@sparkjsdev/spark";
7
-
8
- import { HiddenTilesPlugin } from "./reality-capture/lib/hidden-tiles-plugin";
9
- import { PointTilePlugin, setPointSize as setPointSizePx } from "./reality-capture/lib/point-tile-plugin";
10
- import { SplatTilePlugin } from "./reality-capture/lib/splat-tile-plugin";
11
- import { frame } from "./reality-capture/lib/render-frame";
12
-
13
- // Reality-capture (standard loose 3D Tiles) viewer for bim-viewer.
14
- //
15
- // SELF-CONTAINED + ISOLATED. This renders a standard 3D Tiles dataset (point
16
- // clouds or Gaussian splats) in its OWN three.js WebGLRenderer + Scene + camera
17
- // + orbit loop, mounted in an overlay over the app — it deliberately does NOT
18
- // touch bim-viewer's OBC world. That world runs a deferred PEN/MRT
19
- // postproduction pipeline that points/splats can't render through, so they get
20
- // an isolated forward renderer instead.
21
- //
22
- // STORAGE MODEL: the main visible file is a standard `tileset.json`; each tile's
23
- // `content.uri` is a relative path (`tiles/n…_d….spz` for splats, `…_d….pnt`
24
- // for points), and the tileset carries a top-level `hiddenFiles` map of
25
- // `content.uri -> hiddenFileId`. Tiles live as platform HIDDEN files and are
26
- // streamed ONE FILE PER TILE via `client.downloadHiddenFile(id)` through the
27
- // HiddenTilesPlugin's fetchData hook. The Q1 LRU/count-budget governor bounds
28
- // how many tiles are resident/loading at once.
29
- //
30
- // The render core (Point/Splat plugins, decode worker, render-frame) is the
31
- // platform_cde tiles-viewer prototype copied into ./reality-capture/lib. Only
32
- // this outer controller is new: it mounts an overlay (or a caller-supplied
33
- // container) instead of a CDEManager dialog, and takes the file bytes from the
34
- // platform client.
35
- //
36
- // CLIENT WIRING: bim-viewer creates its PlatformClient in main.ts via
37
- // `PlatformClient.fromPlatformContext()` and hands it to the panels (e.g.
38
- // `filesPanel(components, client)`). There is no clean singleton/OBC component
39
- // holding it, so this factory takes the client too — pass it in, or call
40
- // `controller.setClient(client)` before `loadThreeTZ`. See the report for the
41
- // exact wiring point the files-panel owner must hook.
42
-
43
- const BG = new THREE.Color(0x11151a);
44
-
45
- type Format = "points" | "splats";
46
-
47
- // ---------------------------------------------------------------------------
48
- // NAV-PERF tuning (ported from the reality-capture-viewer prototype's main.ts).
49
- // Spark re-sorts EVERY resident splat each frame, so smoothness is bounded by
50
- // the splat COUNT actually drawn — not resolution. Three levers, in order of
51
- // impact:
52
- // 1. LRU cache so non-visible tiles get evicted -> Spark's global buffer only
53
- // holds the visible cut (the 4.6 -> 70 fps win in the prototype).
54
- // 2. A count-budget governor: while MOVING, raise errorTarget to cap the
55
- // visible primitive count to a motion budget; while PARKED, step
56
- // errorTarget DOWN toward a floor to refine coarse -> fine.
57
- // 3. An idle snapshot: once parked + refined + tile queues empty, render the
58
- // heavy scene ONCE into a render target and just BLIT that texture each
59
- // frame (cheap), capping the live set back to "light" so the next motion
60
- // is instant. On camera move, drop the frozen texture and render live.
61
- // ---------------------------------------------------------------------------
62
-
63
- // 100% slider reference unused here; budgets are fixed to the prototype values.
64
- const SPLAT_MOTION_BUDGET = 200_000; // splat-count cap while moving
65
- const POINT_MOTION_BUDGET = 1_500_000; // point-count cap while moving (points cheaper than splats)
66
- const IDLE_POINT_BUDGET = 25_000_000; // cap when parked (bounds the snapshot capture)
67
-
68
- // errorTarget clamps (mirror the prototype's scaleET clamp + refine floors).
69
- const ET_MIN = 8;
70
- const ET_MAX = 8192;
71
- const SPLAT_REFINE_FLOOR = 8;
72
- const POINT_REFINE_FLOOR = 14;
73
- const SPLAT_REFINE_STEP = 0.78; // gentle (Spark rebuilds its global sort on each add)
74
- const POINT_REFINE_STEP = 0.55; // bigger jumps -> reads as "all at once"
75
-
76
- // errorTarget the governor settles on while moving — remembered so we can snap
77
- // the live set back to it after taking the idle snapshot.
78
- const SPLAT_MOTION_ET = 48;
79
- const POINT_MOTION_ET = 16;
80
-
81
- // PHASE 1 occlusion pass depth convention. W3's deferred depth is REVERSED-Z
82
- // (DEPTH_COMPONENT32F, far=0/near=1). Per W3's converged hook (d2528f06): Spark's
83
- // draw doesn't ride three's auto depthFunc inversion, so we set GreaterEqualDepth
84
- // on the splat materials OURSELVES to match reversed-Z. Set this false ONLY if a
85
- // live test shows occlusion INVERTED (splats hidden where they should show), i.e.
86
- // three did auto-invert after all → default LessEqualDepth would be correct.
87
- const OCCLUSION_USE_GEQUAL = true;
88
-
89
- // Apply the reversed-Z depth state to every resident SplatMesh in a scene so a
90
- // separate splat render call (the deferred occlusion pass OR the postpro-off
91
- // fallback) occludes correctly. depthWrite stays off (never touch the borrowed
92
- // BIM depth). Cheap per-call property assigns.
93
- function applySplatDepthState(scene: THREE.Object3D) {
94
- if (!OCCLUSION_USE_GEQUAL) return;
95
- scene.traverse((o: any) => {
96
- const m = o.material;
97
- if (m && typeof o.opacity === "number" && o.userData?.splatCount) {
98
- m.depthFunc = THREE.GreaterEqualDepth;
99
- m.depthWrite = false;
100
- }
101
- });
102
- }
103
-
104
- // Set tiles.lruCache min/max sizes so non-visible tiles are evicted and Spark's
105
- // global buffer only holds the visible cut. Mirrors prototype configureCache().
106
- function configureCache(t: TilesRenderer, maxItems: number, maxMB: number) {
107
- const lru = (t as any).lruCache;
108
- if (!lru) return;
109
- lru.maxSize = maxItems;
110
- lru.minSize = Math.floor(maxItems * 0.66);
111
- lru.maxBytesSize = maxMB * 1024 * 1024;
112
- lru.minBytesSize = Math.floor(maxMB * 0.66) * 1024 * 1024;
113
- }
114
-
115
- // Tile streaming concurrency: 1 while moving (spread the per-tile rebuild spikes
116
- // for smoothness), higher while parked (burst-load the refine in one go). Points
117
- // have no Spark global rebuild, so they can stream more aggressively.
118
- function setSplatJobs(t: TilesRenderer, n: number) {
119
- try {
120
- (t as any).parseQueue.maxJobs = n;
121
- (t as any).downloadQueue.maxJobs = Math.max(n, 4);
122
- } catch {
123
- /* queues not present yet */
124
- }
125
- }
126
-
127
- // Are both tile queues drained? (snapshot gate)
128
- function queuesEmpty(t: TilesRenderer): boolean {
129
- const dq = (t as any).downloadQueue?.items?.length || 0;
130
- const pq = (t as any).parseQueue?.items?.length || 0;
131
- return dq + pq === 0;
132
- }
133
-
134
- // Visible-primitive counters (mirror the prototype). Splats sum userData.splatCount;
135
- // points sum the position-attribute vertex count over visible THREE.Points.
136
- function countSplats(group: THREE.Object3D): number {
137
- let splats = 0;
138
- group.traverse((o: any) => {
139
- if (o.visible && o.userData?.splatCount) splats += o.userData.splatCount;
140
- });
141
- return splats;
142
- }
143
- function countPoints(group: THREE.Object3D): number {
144
- let pts = 0;
145
- group.traverse((o: any) => {
146
- if (o.visible && o instanceof THREE.Points) {
147
- pts += (o.geometry.getAttribute("position") as THREE.BufferAttribute).count;
148
- }
149
- });
150
- return pts;
151
- }
152
-
153
- // Minimal structural type for the platform client: download the tileset main
154
- // file by id (downloadFile) + each tile blob by hidden-file id
155
- // (downloadHiddenFile). PlatformClient / EngineServicesClient from
156
- // "@thatopen/services" both satisfy this.
157
- export interface ThreeTZClient {
158
- downloadFile(fileId: string, params?: any): Promise<Response>;
159
- downloadHiddenFile(hiddenId: string): Promise<Response>;
160
- }
161
-
162
- export interface RealityCaptureController {
163
- /** Download a tileset.json by file id and render it in an isolated overlay (or `container`). */
164
- loadThreeTZ(fileId: string, container?: HTMLElement): Promise<void>;
165
- /**
166
- * PHASE 0 co-located mode: download a tileset.json by id and render it INSIDE
167
- * the bim-viewer OBC world scene, driven by the world camera, so the splats /
168
- * point cloud sit in the SAME 3D space as the BIM model. Returns when the
169
- * tileset has begun streaming. See `loadIntoWorld` for the occlusion model.
170
- */
171
- loadIntoWorld(fileId: string, opts?: LoadIntoWorldOpts): Promise<void>;
172
- /**
173
- * Set the alignment transform of a co-located dataset (manual registration).
174
- * `matrix` maps dataset-local space → BIM world space. `fileId` selects the
175
- * dataset (default: the active one). Persist/restore to remember an alignment.
176
- */
177
- setTransform(matrix: THREE.Matrix4, fileId?: string): void;
178
- /** Current alignment transform of a dataset (default active), or null. */
179
- getTransform(fileId?: string): THREE.Matrix4 | null;
180
- /** Show/hide the interactive align gizmo (attached to the active dataset). */
181
- showGizmo(on: boolean): void;
182
- /** Set the align gizmo mode. */
183
- setGizmoMode(mode: "translate" | "rotate" | "scale"): void;
184
- /** Make a loaded dataset the active one (gizmo target). */
185
- setActiveDataset(fileId: string): void;
186
- /** Unload one co-located dataset (tears down the world view if it was last). */
187
- removeDataset(fileId: string): void;
188
- /** Ids of all currently co-located datasets. */
189
- listDatasets(): string[];
190
- /** Show/hide a co-located dataset without unloading it (default active). */
191
- setDatasetVisible(on: boolean, fileId?: string): void;
192
- /** Is a co-located dataset visible? (default active; false if none). */
193
- isDatasetVisible(fileId?: string): boolean;
194
- /** Point size in px (point clouds; global to all point datasets). */
195
- setPointSize(px: number): void;
196
- /** Global splat opacity 0..1 for a dataset (default active). */
197
- setSplatOpacity(opacity: number, fileId?: string): void;
198
- /** Tune the governor's while-moving primitive budget for a dataset (default active). */
199
- setMotionBudget(count: number, fileId?: string): void;
200
- /** Provide the platform client if it wasn't passed to the factory. */
201
- setClient(client: ThreeTZClient): void;
202
- /** Stop the loop, dispose everything, remove the overlay / co-located group. Idempotent. */
203
- clear(): void;
204
- }
205
-
206
- export interface LoadIntoWorldOpts {
207
- /**
208
- * Initial alignment transform (e.g. restored from app-data). If omitted, an
209
- * auto-fit is applied on first load: the dataset is centred at the BIM world
210
- * origin so it lands roughly where the model is, ready for manual nudging.
211
- */
212
- transform?: THREE.Matrix4;
213
- /**
214
- * Keep the deferred postproduction pipeline ON. Default false → we switch the
215
- * world to a forward render while the dataset is shown, which gives correct
216
- * splat-vs-BIM occlusion FOR FREE (three draws opaque BIM first writing depth,
217
- * then Spark's splats with depthTest:true are clipped behind it). With
218
- * postproduction ON, splats can't go through the capture pass, so they'd draw
219
- * over the BIM (no occlusion) until the deferred depth target is exposed (W1/W3).
220
- */
221
- keepPostproduction?: boolean;
222
- /**
223
- * Show the interactive align gizmo on load (default true). Drag to register the
224
- * dataset against the BIM model; W/E/R switch translate/rotate/scale.
225
- */
226
- gizmo?: boolean;
227
- /**
228
- * Called whenever the user finishes moving the align gizmo, with the new
229
- * dataset-local → world transform. Persist it (e.g. into app-data) and feed it
230
- * back as `opts.transform` next time to remember the alignment.
231
- */
232
- onTransformChange?: (matrix: THREE.Matrix4) => void;
233
- }
234
-
235
- // Recursively collect every content URI referenced by a tileset (root + children).
236
- function collectContentURIs(node: any, out: string[]) {
237
- if (!node || typeof node !== "object") return;
238
- const c = node.content;
239
- if (c) {
240
- const uri = c.uri ?? c.url;
241
- if (typeof uri === "string") out.push(uri);
242
- }
243
- if (Array.isArray(node.children)) {
244
- for (const child of node.children) collectContentURIs(child, out);
245
- }
246
- }
247
-
248
- function detectFormat(uris: string[]): Format {
249
- for (const uri of uris) {
250
- const u = uri.toLowerCase();
251
- if (u.includes(".spz")) return "splats";
252
- if (u.includes(".pnt")) return "points";
253
- }
254
- return "points";
255
- }
256
-
257
- /**
258
- * Build an isolated 3D Tiles viewer controller for bim-viewer.
259
- *
260
- * @param _components the engine components (kept for parity/future wiring; the
261
- * overlay is intentionally independent of the OBC world's renderer).
262
- * @param client OPTIONAL platform client (PlatformClient/EngineServicesClient).
263
- * If omitted, call `controller.setClient(client)` before `loadThreeTZ`.
264
- */
265
- export function realityCaptureViewer(
266
- _components: OBC.Components,
267
- client?: ThreeTZClient,
268
- ): RealityCaptureController {
269
- let activeClient: ThreeTZClient | undefined = client;
270
-
271
- // Per-session disposables (one tileset at a time). clear() tears these down.
272
- interface Session {
273
- overlay: HTMLElement;
274
- ownsOverlay: boolean; // true => we created the fullscreen overlay and must remove it
275
- renderer: THREE.WebGLRenderer;
276
- controls: OrbitControls;
277
- tiles: TilesRenderer | null;
278
- sparkRenderer: any;
279
- onResize: () => void;
280
- disposed: boolean;
281
- // idle-snapshot disposables (created lazily on first load)
282
- snapRT: THREE.WebGLRenderTarget | null;
283
- blitScene: THREE.Scene | null;
284
- blitQuad: THREE.Mesh | null;
285
- blitMat: THREE.ShaderMaterial | null;
286
- blitGeom: THREE.BufferGeometry | null;
287
- }
288
- let session: Session | null = null;
289
-
290
- // PHASE 0 co-located mode (it borrows the OBC world's renderer/scene/camera
291
- // instead of owning them). MULTIPLE datasets can be loaded into the BIM world
292
- // at once — each is a `WorldDataset` in `worldDatasets`. A single shared
293
- // `WorldCtx` holds the world handles, the ONE align gizmo (attached to the
294
- // ACTIVE dataset), the camera-controls listeners, and the shared rAF tick that
295
- // updates every dataset. The ctx is created lazily on the first load and torn
296
- // down (restoring postproduction) when the last dataset is removed.
297
- interface WorldDataset {
298
- id: string;
299
- root: THREE.Group; // alignment-transform target (dataset-local → BIM world)
300
- splatContainer: THREE.Group;
301
- pointContainer: THREE.Group;
302
- tiles: TilesRenderer | null;
303
- spark: any;
304
- format: Format;
305
- splatBudget: number;
306
- pointBudget: number;
307
- splatOpacity: number;
308
- visible: boolean;
309
- originFitted: boolean;
310
- hasExplicitTransform: boolean;
311
- parentScene: THREE.Scene; // scene the root/spark were added to (world or splat)
312
- onTransformChange?: (m: THREE.Matrix4) => void;
313
- emitTransform: () => void;
314
- dispose: () => void;
315
- }
316
- interface WorldCtx {
317
- world: any;
318
- scene3: THREE.Scene;
319
- renderer3: THREE.WebGLRenderer;
320
- getCam: () => THREE.Camera;
321
- controls: any;
322
- gizmo: TransformControls;
323
- gizmoHelper: THREE.Object3D;
324
- gizmoEnabled: boolean;
325
- postpro: any;
326
- prevPostpro: boolean;
327
- disabledPostpro: boolean; // did WE turn postproduction off? (restore on teardown)
328
- keepPostproduction: boolean;
329
- // PHASE 1 — "both" PEN + occluded splats. When on, postproduction stays
330
- // enabled; splats live in `splatScene` and are drawn by the deferred
331
- // pipeline's `splatOcclusionPass` hook (after composite, BIM depth borrowed),
332
- // so they're occluded by BIM with the PEN look kept. Off → forward path
333
- // (postproduction disabled, splats in world.scene, free occlusion, no PEN).
334
- deferredOcclusion: boolean;
335
- splatScene: THREE.Scene | null; // holds dataset roots + Spark when deferredOcclusion
336
- lastMoveT: number;
337
- frameNo: number;
338
- raf: number;
339
- disposed: boolean;
340
- overlay: { el: HTMLElement; refresh: () => void; dispose: () => void } | null;
341
- onCtrl: () => void;
342
- onRest: () => void;
343
- onKey: (e: KeyboardEvent) => void;
344
- }
345
- const worldDatasets = new Map<string, WorldDataset>();
346
- let worldCtx: WorldCtx | null = null;
347
- let activeDatasetId: string | null = null;
348
- // Assigned to the returned controller so the in-viewport control overlay can
349
- // drive the public API. Non-null by the time any overlay handler fires.
350
- let rcApi: RealityCaptureController | null = null;
351
-
352
- // Tear down the fullscreen-overlay session (loadThreeTZ), if any.
353
- function clearOverlay() {
354
- const s = session;
355
- if (!s) return;
356
- session = null;
357
- s.disposed = true;
358
- window.removeEventListener("resize", s.onResize);
359
- try { s.tiles?.dispose(); } catch { /* noop */ }
360
- try { s.controls.dispose(); } catch { /* noop */ }
361
- try { s.sparkRenderer?.dispose?.(); } catch { /* noop */ }
362
- try {
363
- s.snapRT?.dispose();
364
- s.blitGeom?.dispose();
365
- s.blitMat?.dispose();
366
- } catch { /* noop */ }
367
- try {
368
- s.renderer.dispose();
369
- s.renderer.forceContextLoss();
370
- } catch { /* noop */ }
371
- try {
372
- if (s.ownsOverlay) s.overlay.remove();
373
- else s.overlay.replaceChildren(); // caller-owned container: just empty it
374
- } catch { /* noop */ }
375
- }
376
-
377
- const activeDataset = (): WorldDataset | null =>
378
- (activeDatasetId && worldDatasets.get(activeDatasetId)) || null;
379
- const targetDataset = (id?: string): WorldDataset | null =>
380
- id ? worldDatasets.get(id) ?? null : activeDataset();
381
-
382
- // Attach the shared gizmo to a dataset's alignment root + make it the gizmo's
383
- // persistence target. No-op if the id isn't loaded.
384
- function setActiveDataset(id: string) {
385
- const ds = worldDatasets.get(id);
386
- const ctx = worldCtx;
387
- if (!ds || !ctx) return;
388
- activeDatasetId = id;
389
- ctx.gizmo.attach(ds.root);
390
- ctx.gizmoHelper.visible = ctx.gizmoEnabled && ds.visible;
391
- ctx.overlay?.refresh();
392
- }
393
-
394
- // Remove ONE co-located dataset; tear the shared ctx down when the last goes.
395
- function removeDataset(id: string) {
396
- const ds = worldDatasets.get(id);
397
- if (!ds) return;
398
- worldDatasets.delete(id);
399
- try { ds.dispose(); } catch { /* noop */ }
400
- if (activeDatasetId === id) {
401
- const next = worldDatasets.keys().next();
402
- activeDatasetId = next.done ? null : next.value;
403
- if (activeDatasetId) setActiveDataset(activeDatasetId);
404
- }
405
- if (worldDatasets.size === 0) teardownWorldCtx();
406
- else { worldCtx?.overlay?.refresh(); worldCtx?.world?.renderer?.update?.(); }
407
- }
408
-
409
- function teardownWorldCtx() {
410
- const ctx = worldCtx;
411
- if (!ctx) return;
412
- worldCtx = null;
413
- activeDatasetId = null;
414
- ctx.disposed = true;
415
- cancelAnimationFrame(ctx.raf);
416
- ctx.controls?.removeEventListener?.("control", ctx.onCtrl);
417
- ctx.controls?.removeEventListener?.("controlstart", ctx.onCtrl);
418
- ctx.controls?.removeEventListener?.("rest", ctx.onRest);
419
- ctx.controls?.removeEventListener?.("sleep", ctx.onRest);
420
- window.removeEventListener("keydown", ctx.onKey);
421
- if (ctx.controls) ctx.controls.enabled = true; // in case teardown lands mid-drag
422
- // Unregister the Phase-1 occlusion hook so the pipeline stops calling us.
423
- if (ctx.deferredOcclusion && ctx.postpro && "splatOcclusionPass" in ctx.postpro) {
424
- ctx.postpro.splatOcclusionPass = undefined;
425
- }
426
- try {
427
- ctx.gizmo.detach();
428
- (ctx.splatScene ?? ctx.scene3).remove(ctx.gizmoHelper);
429
- ctx.gizmo.dispose();
430
- } catch { /* noop */ }
431
- if (ctx.postpro && ctx.disabledPostpro) ctx.postpro.enabled = ctx.prevPostpro;
432
- ctx.overlay?.dispose();
433
- try { ctx.world.renderer.update(); } catch { /* noop */ }
434
- }
435
-
436
- // Remove ALL co-located datasets (and the shared ctx).
437
- function clearWorldAll() {
438
- for (const id of [...worldDatasets.keys()]) removeDataset(id);
439
- }
440
-
441
- // Public clear(): tear down everything (overlay + all co-located datasets).
442
- function clear() {
443
- clearWorldAll();
444
- clearOverlay();
445
- }
446
-
447
- async function loadThreeTZ(fileId: string, container?: HTMLElement) {
448
- if (!activeClient) {
449
- throw new Error(
450
- "realityCaptureViewer: no platform client — pass it to the factory or call setClient() first",
451
- );
452
- }
453
- // Only one session at a time.
454
- clear();
455
-
456
- // --- overlay / mount --------------------------------------------------
457
- let overlay: HTMLElement;
458
- let mount: HTMLElement;
459
- const ownsOverlay = !container;
460
- let status: HTMLElement | null = null;
461
-
462
- if (container) {
463
- overlay = container;
464
- overlay.replaceChildren();
465
- mount = container;
466
- } else {
467
- overlay = document.createElement("div");
468
- Object.assign(overlay.style, {
469
- position: "absolute",
470
- inset: "0",
471
- zIndex: "1000",
472
- background: BG.getStyle(),
473
- display: "flex",
474
- flexDirection: "column",
475
- });
476
-
477
- const header = document.createElement("div");
478
- Object.assign(header.style, {
479
- display: "flex",
480
- alignItems: "center",
481
- justifyContent: "space-between",
482
- padding: "0.75rem 1rem",
483
- flexShrink: "0",
484
- borderBottom: "1px solid var(--bim-ui_bg-contrast-20)",
485
- color: "var(--bim-ui_main-base, #fff)",
486
- });
487
-
488
- status = document.createElement("div");
489
- status.style.opacity = "0.7";
490
- status.textContent = "Loading…";
491
-
492
- const closeBtn = document.createElement("button");
493
- closeBtn.textContent = "Close";
494
- Object.assign(closeBtn.style, {
495
- cursor: "pointer",
496
- padding: "0.4rem 0.9rem",
497
- borderRadius: "0.375rem",
498
- border: "1px solid var(--bim-ui_bg-contrast-40, #555)",
499
- background: "var(--bim-ui_bg-contrast-20, #333)",
500
- color: "var(--bim-ui_main-base, #fff)",
501
- });
502
- closeBtn.addEventListener("click", () => clear());
503
-
504
- header.appendChild(status);
505
- header.appendChild(closeBtn);
506
-
507
- mount = document.createElement("div");
508
- Object.assign(mount.style, {
509
- flex: "1",
510
- overflow: "hidden",
511
- position: "relative",
512
- minHeight: "0",
513
- });
514
-
515
- overlay.appendChild(header);
516
- overlay.appendChild(mount);
517
- document.body.appendChild(overlay);
518
- }
519
-
520
- // --- three.js core (own renderer; NOT the OBC world) ------------------
521
- const renderer = new THREE.WebGLRenderer({ antialias: true });
522
- renderer.setPixelRatio(window.devicePixelRatio);
523
- renderer.setClearColor(BG, 1);
524
- Object.assign(renderer.domElement.style, {
525
- width: "100%",
526
- height: "100%",
527
- display: "block",
528
- });
529
- mount.appendChild(renderer.domElement);
530
-
531
- const scene = new THREE.Scene();
532
- const camera = new THREE.PerspectiveCamera(60, 1, 0.1, 50000);
533
- camera.position.set(15, 12, 15);
534
-
535
- const controls = new OrbitControls(camera, renderer.domElement);
536
- controls.enableDamping = true;
537
- controls.dampingFactor = 0.14;
538
- controls.target.set(0, 0, 0);
539
-
540
- const pointContainer = new THREE.Group();
541
- pointContainer.matrixAutoUpdate = false;
542
- scene.add(pointContainer);
543
- const splatContainer = new THREE.Group();
544
- splatContainer.rotation.x = Math.PI; // splats are Y-up
545
- scene.add(splatContainer);
546
-
547
- const s: Session = {
548
- overlay,
549
- ownsOverlay,
550
- renderer,
551
- controls,
552
- tiles: null,
553
- sparkRenderer: null,
554
- onResize: () => {},
555
- disposed: false,
556
- snapRT: null,
557
- blitScene: null,
558
- blitQuad: null,
559
- blitMat: null,
560
- blitGeom: null,
561
- };
562
- session = s;
563
-
564
- let format: Format = "points";
565
- let originSet = false;
566
-
567
- // --- idle-snapshot machinery (full-screen blit of a frozen RT) ---------
568
- // Allocate the RT at the drawing-buffer size; a tiny ortho quad samples it.
569
- const blitMat = new THREE.ShaderMaterial({
570
- uniforms: { uTex: { value: null as THREE.Texture | null } },
571
- vertexShader:
572
- "varying vec2 vUv; void main(){ vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }",
573
- fragmentShader:
574
- "precision highp float; varying vec2 vUv; uniform sampler2D uTex; void main(){ gl_FragColor = texture2D(uTex, vUv); }",
575
- depthTest: false,
576
- depthWrite: false,
577
- });
578
- const blitGeom = new THREE.PlaneGeometry(2, 2);
579
- const blitQuad = new THREE.Mesh(blitGeom, blitMat);
580
- blitQuad.frustumCulled = false;
581
- const blitScene = new THREE.Scene();
582
- blitScene.add(blitQuad);
583
- const blitCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
584
- s.blitScene = blitScene;
585
- s.blitQuad = blitQuad;
586
- s.blitMat = blitMat;
587
- s.blitGeom = blitGeom;
588
-
589
- // Snapshot state machine flags.
590
- let showFrozen = false; // currently blitting the frozen texture
591
- let snapshotDone = false; // a valid snapshot has been captured at this pose
592
- let lastMoveT = performance.now();
593
-
594
- function ensureSnapRT(w: number, h: number) {
595
- if (!s.snapRT) {
596
- s.snapRT = new THREE.WebGLRenderTarget(w, h, { depthBuffer: true });
597
- blitMat.uniforms.uTex.value = s.snapRT.texture;
598
- } else if (s.snapRT.width !== w || s.snapRT.height !== h) {
599
- s.snapRT.setSize(w, h);
600
- }
601
- }
602
-
603
- function resize() {
604
- const w = Math.max(1, mount.clientWidth);
605
- const h = Math.max(1, mount.clientHeight);
606
- renderer.setSize(w, h, false);
607
- camera.aspect = w / h;
608
- camera.updateProjectionMatrix();
609
- if (s.tiles) s.tiles.setResolutionFromRenderer(camera, renderer);
610
- // snapshot is resolution-specific: resize the RT and invalidate it.
611
- const db = new THREE.Vector2();
612
- renderer.getDrawingBufferSize(db);
613
- ensureSnapRT(Math.max(1, Math.floor(db.x)), Math.max(1, Math.floor(db.y)));
614
- showFrozen = false;
615
- snapshotDone = false;
616
- }
617
- s.onResize = () => resize();
618
- window.addEventListener("resize", s.onResize);
619
-
620
- // Any user input invalidates the frozen snapshot and snaps the live set
621
- // back to "light" so the resulting motion is immediately smooth.
622
- const onInteractStart = () => {
623
- showFrozen = false;
624
- snapshotDone = false;
625
- lastMoveT = performance.now();
626
- if (s.tiles) {
627
- const floorET = format === "splats" ? SPLAT_MOTION_ET : POINT_MOTION_ET;
628
- s.tiles.errorTarget = Math.max(s.tiles.errorTarget, floorET);
629
- setSplatJobs(s.tiles, 1); // spread spikes while moving
630
- }
631
- };
632
- const onInteractEnd = () => {
633
- if (s.tiles) setSplatJobs(s.tiles, 16); // burst-load the refine
634
- };
635
- controls.addEventListener("start", onInteractStart);
636
- controls.addEventListener("end", onInteractEnd);
637
-
638
- function frameCamera(center: THREE.Vector3, radius: number) {
639
- const r = Math.max(2, radius);
640
- controls.target.copy(center);
641
- camera.position.set(center.x + r * 0.9, center.y + r * 0.7, center.z + r * 0.9);
642
- camera.near = r / 500;
643
- camera.far = r * 200;
644
- camera.updateProjectionMatrix();
645
- controls.update();
646
- }
647
-
648
- // --- motion detection: compare the camera world transform frame-to-frame.
649
- // controls.update() under-reports wheel-zoom, so also diff position+quaternion
650
- // (position changes on orbit/pan/zoom; target via controls covers pan).
651
- const _prevPos = new THREE.Vector3(Infinity, Infinity, Infinity);
652
- const _prevQuat = new THREE.Quaternion(2, 2, 2, 2);
653
- const _prevTarget = new THREE.Vector3(Infinity, Infinity, Infinity);
654
- let frameNo = 0;
655
-
656
- function animate() {
657
- if (s.disposed) return;
658
- requestAnimationFrame(animate);
659
-
660
- const now = performance.now();
661
- const fromControls = controls.update();
662
- const moved =
663
- _prevPos.distanceToSquared(camera.position) > 1e-10 ||
664
- _prevTarget.distanceToSquared(controls.target) > 1e-10 ||
665
- Math.abs(_prevQuat.dot(camera.quaternion)) < 0.9999999;
666
- _prevPos.copy(camera.position);
667
- _prevTarget.copy(controls.target);
668
- _prevQuat.copy(camera.quaternion);
669
- const ctrlChanged = fromControls || moved;
670
- if (ctrlChanged) lastMoveT = now;
671
- // ~130ms tail so the damping glide still counts as "moving".
672
- const cameraMoving = now - lastMoveT < 130;
673
-
674
- const tiles = s.tiles;
675
- if (tiles) {
676
- tiles.setCamera(camera);
677
- tiles.update();
678
-
679
- const isSplat = format === "splats";
680
- const group = isSplat ? splatContainer : pointContainer;
681
- const budget = isSplat ? SPLAT_MOTION_BUDGET : POINT_MOTION_BUDGET;
682
- const motionET = isSplat ? SPLAT_MOTION_ET : POINT_MOTION_ET;
683
- const refineFloor = isSplat ? SPLAT_REFINE_FLOOR : POINT_REFINE_FLOOR;
684
- const refineStep = isSplat ? SPLAT_REFINE_STEP : POINT_REFINE_STEP;
685
-
686
- // --- count-budget governor (every ~6 frames) -----------------------
687
- if (frameNo % 6 === 0) {
688
- const vis = isSplat ? countSplats(group) : countPoints(group);
689
- const scaleET = (f: number) => {
690
- tiles.errorTarget = THREE.MathUtils.clamp(tiles.errorTarget * f, ET_MIN, ET_MAX);
691
- };
692
- if (cameraMoving) {
693
- // MOVING: hard-cap the primitive COUNT to the motion budget by
694
- // raising errorTarget proportionally; ease back down if well under.
695
- const ratio = vis / budget;
696
- if (ratio > 1.1) scaleET(Math.min(2.5, ratio));
697
- else if (ratio < 0.6) scaleET(0.9);
698
- } else if (!snapshotDone) {
699
- // PARKED + not yet snapshotted: refine coarse -> fine toward the floor,
700
- // bounded by the idle budget so a wide view doesn't request the world.
701
- const underIdleCap = isSplat || vis < IDLE_POINT_BUDGET;
702
- if (underIdleCap && tiles.errorTarget > refineFloor) {
703
- tiles.errorTarget = Math.max(tiles.errorTarget * refineStep, refineFloor);
704
- }
705
- }
706
- }
707
-
708
- // --- idle-snapshot state machine -----------------------------------
709
- if (cameraMoving) {
710
- showFrozen = false;
711
- snapshotDone = false;
712
- } else if (!snapshotDone) {
713
- // parked: capture once refined (near the floor) AND queues drained,
714
- // OR after a 2.5s safety dwell if streaming never fully settles.
715
- const refined = tiles.errorTarget <= refineFloor * 2.5 && queuesEmpty(tiles);
716
- const stable = refined || now - lastMoveT > 2500;
717
- if (stable) {
718
- const db = new THREE.Vector2();
719
- renderer.getDrawingBufferSize(db);
720
- ensureSnapRT(Math.max(1, Math.floor(db.x)), Math.max(1, Math.floor(db.y)));
721
- renderer.setRenderTarget(s.snapRT);
722
- renderer.clear(true, true, true);
723
- renderer.render(scene, camera);
724
- renderer.setRenderTarget(null);
725
- showFrozen = true;
726
- snapshotDone = true;
727
- // cap the live set back to "light" so the next motion is instant.
728
- tiles.errorTarget = motionET;
729
- }
730
- }
731
-
732
- if (showFrozen && s.snapRT) {
733
- // BLIT the frozen full-detail texture (cheap; no re-sort).
734
- renderer.setRenderTarget(null);
735
- renderer.render(blitScene, blitCam);
736
- } else {
737
- renderer.render(scene, camera);
738
- }
739
- } else {
740
- renderer.render(scene, camera);
741
- }
742
- frameNo++;
743
- }
744
-
745
- resize();
746
- animate();
747
-
748
- // --- load -------------------------------------------------------------
749
- try {
750
- if (status) status.textContent = "Downloading…";
751
- // The main visible file is a standard tileset.json. Download its bytes,
752
- // parse the JSON for the content URIs (format detection) + the hiddenFiles
753
- // map (content.uri -> hidden file id) used to stream tiles per-file.
754
- const res = await activeClient.downloadFile(fileId);
755
- const tilesetBytes = new Uint8Array(await res.arrayBuffer());
756
- if (s.disposed) return;
757
-
758
- if (status) status.textContent = "Inspecting…";
759
- const ts = JSON.parse(new TextDecoder().decode(tilesetBytes));
760
- const hiddenFiles: Record<string, string> = ts.hiddenFiles || {};
761
- const uris: string[] = [];
762
- collectContentURIs(ts.root, uris);
763
- format = detectFormat(uris);
764
- if (s.disposed) return;
765
-
766
- if (status) status.textContent = `Streaming (${format})…`;
767
-
768
- // HiddenTilesPlugin serves the root tileset from the bytes we already have,
769
- // and each tile content fetch by downloading the matching platform HIDDEN
770
- // file (one downloadHiddenFile per visible tile). The synthetic "mem://t"
771
- // base lets relative content URIs resolve to dataset-relative paths the
772
- // plugin strips back to look up in the hiddenFiles map.
773
- const tiles = new TilesRenderer("mem://t/tileset.json");
774
- s.tiles = tiles;
775
- tiles.registerPlugin(
776
- new HiddenTilesPlugin({
777
- baseUrl: "mem://t",
778
- tilesetBytes,
779
- hiddenFiles,
780
- client: activeClient,
781
- }),
782
- );
783
-
784
- if (format === "splats") {
785
- // clipXY 1.0 frustum-culls splat centers (default 1.4 keeps a 40%
786
- // off-screen overdraw margin we don't need here).
787
- s.sparkRenderer = new SparkRenderer({ renderer, clipXY: 1.0 } as any);
788
- scene.add(s.sparkRenderer);
789
- tiles.registerPlugin(new SplatTilePlugin());
790
- tiles.errorTarget = SPLAT_MOTION_ET;
791
- // LRU sized to hold a full small dataset once (no reload churn); the
792
- // count-budget governor still bounds what's actually DRAWN each frame.
793
- // This is THE big win — Spark only re-sorts the visible cut.
794
- configureCache(tiles, 280, 420);
795
- // Spark rebuilds a merged buffer per tile: cap concurrent parses to
796
- // avoid bunching spikes (raised to 16 on interaction-end to burst-refine).
797
- setSplatJobs(tiles, 1);
798
- splatContainer.add(tiles.group);
799
- } else {
800
- tiles.registerPlugin(new PointTilePlugin());
801
- tiles.errorTarget = POINT_MOTION_ET;
802
- // Bigger cache for dense point clouds so revisits don't reload.
803
- configureCache(tiles, 1500, 768);
804
- // Points load fast (no Spark rebuild to spread); match the decode pool.
805
- try {
806
- (tiles as any).parseQueue.maxJobs = 16;
807
- } catch {
808
- /* queue not present */
809
- }
810
- pointContainer.add(tiles.group);
811
- }
812
-
813
- tiles.setCamera(camera);
814
- tiles.setResolutionFromRenderer(camera, renderer);
815
-
816
- tiles.addEventListener("load-tileset", () => {
817
- if (originSet || s.disposed) return;
818
- originSet = true;
819
- const sphere = new THREE.Sphere();
820
- tiles.getBoundingSphere(sphere);
821
-
822
- if (format === "points") {
823
- // RTC: recenter the float64 origin on the tileset centre so the point
824
- // nodes (placed at float64 world anchors) render jitter-free. The
825
- // pointContainer shifts by -origin; framing is around the origin.
826
- const c = sphere.center;
827
- frame.origin = [c.x, c.y, c.z];
828
- pointContainer.position.set(-c.x, -c.y, -c.z);
829
- pointContainer.updateMatrix();
830
- frameCamera(new THREE.Vector3(0, 0, 0), sphere.radius);
831
- } else {
832
- // Splats: the container applies the Y-up flip (rotation.x = PI). Frame
833
- // on the flipped centre so the camera looks at the visible geometry.
834
- const c = sphere.center;
835
- const flipped = new THREE.Vector3(c.x, -c.y, -c.z);
836
- frameCamera(flipped, sphere.radius);
837
- }
838
- if (status) status.textContent = "";
839
- });
840
- } catch (e: any) {
841
- if (!s.disposed && status) status.textContent = `Error: ${e?.message ?? e}`;
842
- console.error("[reality-capture-viewer]", e);
843
- }
844
- }
845
-
846
- // ───────────────────────────────────────────────────────────────────────
847
- // PHASE 0 — CO-LOCATED MODE: splats/points in the BIM world scene.
848
- //
849
- // Borrows the OBC world's renderer + scene + camera instead of owning them, so
850
- // the dataset sits in the same 3D space as the .frag model under one camera.
851
- // Occlusion: with postproduction OFF (default), three renders opaque BIM first
852
- // (writes depth) then Spark's transparent splats (depthTest:true) — splats
853
- // behind walls are clipped FOR FREE, no custom depth pass. The governor (LRU +
854
- // count budget + park-refine) is re-driven off the OBC camera-controls events;
855
- // the overlay's idle-snapshot is dropped (it would freeze the whole BIM scene).
856
- // ───────────────────────────────────────────────────────────────────────
857
- // Self-contained in-viewport control strip for the co-located experience.
858
- // Plain DOM themed with the --bim-ui_* vars (matches BUI) — deliberately does
859
- // NOT touch files-panel or the app panel system (avoids collision; W2 owns
860
- // panels). Drives the public controller API (rcApi) and always reflects the
861
- // ACTIVE dataset. Owned by the WorldCtx: appears on first load, torn down with
862
- // the ctx. Bottom-centre of the viewport.
863
- function buildControlOverlay(ctx: WorldCtx): {
864
- el: HTMLElement;
865
- refresh: () => void;
866
- dispose: () => void;
867
- } {
868
- const mount = ctx.renderer3.domElement.parentElement ?? document.body;
869
- const bar = document.createElement("div");
870
- Object.assign(bar.style, {
871
- position: "absolute", bottom: "12px", left: "50%", transform: "translateX(-50%)",
872
- zIndex: "20", display: "flex", alignItems: "center", gap: "0.5rem",
873
- padding: "0.4rem 0.6rem", borderRadius: "0.5rem",
874
- background: "var(--bim-ui_bg-base, #1a1f26)",
875
- border: "1px solid var(--bim-ui_bg-contrast-20, #333)",
876
- color: "var(--bim-ui_main-base, #e6e6e6)",
877
- font: "12px/1.2 system-ui, sans-serif",
878
- boxShadow: "0 2px 12px rgba(0,0,0,0.4)", userSelect: "none",
879
- });
880
- const mkBtn = (text: string, title: string) => {
881
- const b = document.createElement("button");
882
- b.textContent = text; b.title = title;
883
- Object.assign(b.style, {
884
- cursor: "pointer", padding: "0.3rem 0.55rem", borderRadius: "0.35rem",
885
- border: "1px solid var(--bim-ui_bg-contrast-40, #555)",
886
- background: "var(--bim-ui_bg-contrast-20, #2a2f37)", color: "inherit", font: "inherit",
887
- });
888
- return b;
889
- };
890
- const hl = (b: HTMLButtonElement, on: boolean) => {
891
- b.style.background = on ? "var(--bim-ui_accent-base, #4b7bec)" : "var(--bim-ui_bg-contrast-20, #2a2f37)";
892
- b.style.borderColor = on ? "var(--bim-ui_accent-base, #4b7bec)" : "var(--bim-ui_bg-contrast-40, #555)";
893
- };
894
- const sep = () => { const s = document.createElement("span"); s.textContent = "|"; s.style.opacity = "0.3"; return s; };
895
-
896
- const label = document.createElement("span");
897
- Object.assign(label.style, { opacity: "0.85", maxWidth: "160px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" });
898
- const prev = mkBtn("‹", "Previous dataset");
899
- const next = mkBtn("›", "Next dataset");
900
- const cycle = (dir: number) => {
901
- const ids = rcApi?.listDatasets() ?? [];
902
- if (ids.length < 2) return;
903
- const i = Math.max(0, ids.indexOf(activeDatasetId ?? ""));
904
- rcApi?.setActiveDataset(ids[(i + dir + ids.length) % ids.length]);
905
- };
906
- prev.onclick = () => cycle(-1);
907
- next.onclick = () => cycle(1);
908
-
909
- const eye = mkBtn("Hide", "Show/hide this dataset");
910
- eye.onclick = () => { rcApi?.setDatasetVisible(!(rcApi?.isDatasetVisible() ?? true)); refresh(); };
911
-
912
- const align = mkBtn("Align", "Toggle the align gizmo (W/E/R = move/rotate/scale)");
913
- align.onclick = () => { ctx.gizmoEnabled = !ctx.gizmoEnabled; rcApi?.showGizmo(ctx.gizmoEnabled); refresh(); };
914
- const mMove = mkBtn("Move", "Translate (W)");
915
- const mRot = mkBtn("Rotate", "Rotate (E)");
916
- const mScale = mkBtn("Scale", "Scale (R)");
917
- mMove.onclick = () => { rcApi?.setGizmoMode("translate"); refresh(); };
918
- mRot.onclick = () => { rcApi?.setGizmoMode("rotate"); refresh(); };
919
- mScale.onclick = () => { rcApi?.setGizmoMode("scale"); refresh(); };
920
-
921
- const opWrap = document.createElement("label");
922
- Object.assign(opWrap.style, { display: "flex", alignItems: "center", gap: "0.3rem" });
923
- opWrap.title = "Splat opacity";
924
- const opTxt = document.createElement("span"); opTxt.textContent = "Opacity";
925
- const op = document.createElement("input");
926
- op.type = "range"; op.min = "0"; op.max = "1"; op.step = "0.05"; op.value = "1"; op.style.width = "80px";
927
- op.oninput = () => rcApi?.setSplatOpacity(parseFloat(op.value));
928
- opWrap.append(opTxt, op);
929
-
930
- const unload = mkBtn("Unload", "Remove this dataset from the model");
931
- unload.style.borderColor = "var(--bim-ui_color-error, #c0392b)";
932
- unload.onclick = () => { if (activeDatasetId) rcApi?.removeDataset(activeDatasetId); };
933
-
934
- bar.append(prev, label, next, sep(), eye, sep(), align, mMove, mRot, mScale, sep(), opWrap, sep(), unload);
935
- mount.appendChild(bar);
936
-
937
- function refresh() {
938
- const ids = rcApi?.listDatasets() ?? [];
939
- const multi = ids.length > 1;
940
- prev.style.display = next.style.display = label.style.display = multi ? "" : "none";
941
- if (multi && activeDatasetId) label.textContent = `${ids.indexOf(activeDatasetId) + 1}/${ids.length} · …${activeDatasetId.slice(-6)}`;
942
- eye.textContent = (rcApi?.isDatasetVisible() ?? true) ? "Hide" : "Show";
943
- hl(align, ctx.gizmoEnabled);
944
- const mode = (ctx.gizmo as any).mode ?? "translate";
945
- for (const [b, m] of [[mMove, "translate"], [mRot, "rotate"], [mScale, "scale"]] as [HTMLButtonElement, string][]) {
946
- b.disabled = !ctx.gizmoEnabled;
947
- b.style.opacity = ctx.gizmoEnabled ? "1" : "0.4";
948
- hl(b, ctx.gizmoEnabled && mode === m);
949
- }
950
- const ds = activeDataset();
951
- const isSplat = ds?.format === "splats";
952
- opWrap.style.display = isSplat ? "flex" : "none";
953
- if (isSplat && ds) op.value = String(ds.splatOpacity);
954
- }
955
- refresh();
956
-
957
- return { el: bar, refresh, dispose() { try { bar.remove(); } catch { /* noop */ } } };
958
- }
959
-
960
- // Lazily create the shared world context: OBC-world handles + the single align
961
- // gizmo (re-attached to the active dataset) + camera-controls listeners + the
962
- // rAF tick that updates EVERY loaded dataset. Created on first load; torn down
963
- // (restoring postproduction) when the last dataset is removed.
964
- function ensureWorldCtx(keepPostproduction: boolean): WorldCtx {
965
- if (worldCtx) return worldCtx;
966
- const world = [...(_components.get(OBC.Worlds).list.values() as any)][0] as any;
967
- if (!world?.scene?.three || !world?.renderer?.three || !world?.camera) {
968
- throw new Error("loadIntoWorld: no OBC world available");
969
- }
970
- const scene3: THREE.Scene = world.scene.three;
971
- const renderer3: THREE.WebGLRenderer = world.renderer.three;
972
- const getCam = (): THREE.Camera => world.camera.three ?? world.camera.threePersp;
973
- const controls: any = world.camera.controls;
974
-
975
- // Mode: PHASE-1 deferred-occlusion ("both") requires keepPostproduction AND
976
- // the deferred pipeline's splatOcclusionPass hook (W3). Otherwise → forward
977
- // path (disable postproduction; splats in world.scene; free occlusion).
978
- const postpro = world.renderer.postproduction;
979
- const prevPostpro = !!postpro?.enabled;
980
- const deferredOcclusion =
981
- keepPostproduction && !!postpro && "splatOcclusionPass" in postpro;
982
- // Disable postproduction for the forward path (free occlusion). Also covers
983
- // the fallback where "both" was requested but the deferred hook is missing
984
- // (older dist) — better splats-with-free-occlusion than splats hidden by the
985
- // capture pass. Only deferred-occlusion mode keeps postproduction on.
986
- const disabledPostpro = !!postpro && !deferredOcclusion;
987
- if (disabledPostpro) postpro.enabled = false;
988
-
989
- // In deferred-occlusion mode the splats live in a PRIVATE scene drawn by the
990
- // splatOcclusionPass (after the PEN composite, BIM depth borrowed); the
991
- // deferred capture would otherwise hide them. In forward mode they live in
992
- // world.scene and render with the BIM in one pass.
993
- const splatScene: THREE.Scene | null = deferredOcclusion ? new THREE.Scene() : null;
994
- const contentScene = splatScene ?? scene3;
995
-
996
- // ONE align gizmo, re-attached to the active dataset's root on selection.
997
- // Put its helper in the same scene as the dataset content so it stays visible
998
- // (in deferred mode the PEN capture would hide a helper added to world.scene).
999
- const gizmo = new TransformControls(getCam(), renderer3.domElement);
1000
- const gizmoHelper = gizmo.getHelper();
1001
- contentScene.add(gizmoHelper);
1002
- gizmo.addEventListener("dragging-changed", (e: any) => {
1003
- if (controls) controls.enabled = !e.value;
1004
- });
1005
- gizmo.addEventListener("mouseUp", () => activeDataset()?.emitTransform());
1006
-
1007
- const onCtrl = () => {
1008
- if (worldCtx) worldCtx.lastMoveT = performance.now();
1009
- for (const ds of worldDatasets.values()) {
1010
- if (!ds.tiles) continue;
1011
- const floorET = ds.format === "splats" ? SPLAT_MOTION_ET : POINT_MOTION_ET;
1012
- ds.tiles.errorTarget = Math.max(ds.tiles.errorTarget, floorET);
1013
- setSplatJobs(ds.tiles, 1); // spread spikes while moving
1014
- }
1015
- };
1016
- const onRest = () => {
1017
- for (const ds of worldDatasets.values()) if (ds.tiles) setSplatJobs(ds.tiles, 16);
1018
- };
1019
- const onKey = (e: KeyboardEvent) => {
1020
- if (!gizmoHelper.visible) return;
1021
- if (e.key === "w" || e.key === "W") gizmo.setMode("translate");
1022
- else if (e.key === "e" || e.key === "E") gizmo.setMode("rotate");
1023
- else if (e.key === "r" || e.key === "R") gizmo.setMode("scale");
1024
- };
1025
- controls?.addEventListener?.("control", onCtrl);
1026
- controls?.addEventListener?.("controlstart", onCtrl);
1027
- controls?.addEventListener?.("rest", onRest);
1028
- controls?.addEventListener?.("sleep", onRest);
1029
- window.addEventListener("keydown", onKey);
1030
-
1031
- const ctx: WorldCtx = {
1032
- world, scene3, renderer3, getCam, controls,
1033
- gizmo, gizmoHelper, gizmoEnabled: true,
1034
- postpro, prevPostpro, disabledPostpro, keepPostproduction,
1035
- deferredOcclusion, splatScene,
1036
- lastMoveT: performance.now(), frameNo: 0, raf: 0, disposed: false,
1037
- overlay: null,
1038
- onCtrl, onRest, onKey,
1039
- };
1040
- worldCtx = ctx;
1041
- ctx.overlay = buildControlOverlay(ctx); // self-contained in-viewport controls
1042
-
1043
- // PHASE 1 — register the deferred occlusion draw (W3 converged hook d2528f06).
1044
- // Fires AFTER composite/FXAA/overlays (PEN frame done), into `ctx.target` — a
1045
- // SEPARATE borrowed-depth colour LAYER the pipeline has already bound + cleared
1046
- // transparent with the BIM DepthTexture attached; the pipeline then composites
1047
- // that layer premultiplied OVER the PEN frame. We just draw the private
1048
- // splatScene into the (already-bound) target: Spark's premultiplied,
1049
- // depth-tested splats are occluded by the real BIM depth. We NEVER clear the
1050
- // depth and DON'T cache the layer FBO across frames (depth detached per-frame).
1051
- if (deferredOcclusion) {
1052
- postpro.splatOcclusionPass = (c: {
1053
- renderer: THREE.WebGLRenderer;
1054
- camera: THREE.Camera;
1055
- target: THREE.WebGLRenderTarget;
1056
- depth: THREE.DepthTexture;
1057
- width: number;
1058
- height: number;
1059
- }) => {
1060
- if (ctx.disposed || !splatScene || !c?.depth || !c?.target) return; // guard until pipeline up
1061
- // Reversed-Z: Spark's draw manages its own GL depth state (doesn't ride
1062
- // three's auto depthFunc inversion), so set GreaterEqualDepth ourselves.
1063
- applySplatDepthState(splatScene);
1064
- // The pipeline has already bound c.target (borrowed BIM depth, cleared
1065
- // transparent, autoClear off); just draw — renderer.render keeps the
1066
- // current target. The pipeline composites it premultiplied over PEN.
1067
- c.renderer.render(splatScene, c.camera);
1068
- };
1069
- }
1070
-
1071
- // Motion detection for the idle gate: diff the camera transform frame-to-
1072
- // frame (covers user orbit AND programmatic moves, not just controls events).
1073
- const _prevPos = new THREE.Vector3(Infinity, Infinity, Infinity);
1074
- const _prevQuat = new THREE.Quaternion(2, 2, 2, 2);
1075
- // Frames still rendered after activity stops, so the settled/refined frame is
1076
- // actually presented before we go idle.
1077
- let trailing = 0;
1078
-
1079
- // Shared tick: govern EVERY loaded dataset, then re-composite ONLY while the
1080
- // camera is moving or tiles are streaming (+ a short trailing tail). When idle
1081
- // and drained we stop forcing renderer.update() — the deferred PEN pipeline +
1082
- // splat sort no longer run every frame, restoring idle fps. A camera move (or
1083
- // a streaming tile) flips us back on within a frame, so the next move always
1084
- // re-sorts; the on-demand render path keeps the canvas correct meanwhile.
1085
- function tick() {
1086
- if (ctx.disposed) return;
1087
- ctx.raf = requestAnimationFrame(tick);
1088
- const cam = ctx.getCam();
1089
- if (ctx.gizmo.camera !== cam) ctx.gizmo.camera = cam; // follow persp/ortho
1090
- const now = performance.now();
1091
- // Camera moved since last frame? (transform diff + the controls events that
1092
- // also set lastMoveT). Damping glide keeps changing the transform → caught.
1093
- const moved =
1094
- _prevPos.distanceToSquared(cam.position) > 1e-12 ||
1095
- Math.abs(_prevQuat.dot(cam.quaternion)) < 0.9999999;
1096
- _prevPos.copy(cam.position);
1097
- _prevQuat.copy(cam.quaternion);
1098
- if (moved) ctx.lastMoveT = now;
1099
- const cameraMoving = now - ctx.lastMoveT < 180;
1100
- const runGovernor = ctx.frameNo % 6 === 0;
1101
- let anyStreaming = false;
1102
- for (const ds of worldDatasets.values()) {
1103
- const tiles = ds.tiles;
1104
- if (!tiles || !ds.visible) continue; // hidden datasets pause streaming
1105
- tiles.setCamera(cam);
1106
- tiles.setResolutionFromRenderer(cam, ctx.renderer3);
1107
- tiles.update();
1108
- if (!queuesEmpty(tiles)) anyStreaming = true; // a tile in flight → keep rendering
1109
- if (!runGovernor) continue;
1110
- const isSplat = ds.format === "splats";
1111
- const group = isSplat ? ds.splatContainer : ds.pointContainer;
1112
- const budget = isSplat ? ds.splatBudget : ds.pointBudget;
1113
- const refineFloor = isSplat ? SPLAT_REFINE_FLOOR : POINT_REFINE_FLOOR;
1114
- const refineStep = isSplat ? SPLAT_REFINE_STEP : POINT_REFINE_STEP;
1115
- const vis = isSplat ? countSplats(group) : countPoints(group);
1116
- const scaleET = (f: number) => {
1117
- tiles.errorTarget = THREE.MathUtils.clamp(tiles.errorTarget * f, ET_MIN, ET_MAX);
1118
- };
1119
- if (cameraMoving) {
1120
- const ratio = vis / budget;
1121
- if (ratio > 1.1) scaleET(Math.min(2.5, ratio));
1122
- else if (ratio < 0.6) scaleET(0.9);
1123
- } else if (tiles.errorTarget > refineFloor) {
1124
- tiles.errorTarget = Math.max(tiles.errorTarget * refineStep, refineFloor);
1125
- }
1126
- if (isSplat && ds.splatOpacity !== 1) {
1127
- group.traverse((o: any) => {
1128
- if (o.userData?.splatCount && typeof o.opacity === "number") o.opacity = ds.splatOpacity;
1129
- });
1130
- }
1131
- }
1132
- ctx.frameNo++;
1133
-
1134
- // IDLE GATE: re-composite only when something changed. Refinement counts as
1135
- // streaming (park-refine lowers errorTarget → loads finer tiles → keeps
1136
- // rendering until refined + drained, then we idle). `trailing` presents the
1137
- // final settled frame before stopping.
1138
- const needsRender = cameraMoving || anyStreaming;
1139
- if (needsRender) trailing = 3;
1140
- else if (trailing > 0) trailing--;
1141
- if (!needsRender && trailing <= 0) return; // idle → let the world render on-demand
1142
-
1143
- // Drive the OBC render so new tiles appear + Spark re-sorts for this camera.
1144
- ctx.world.renderer.update();
1145
-
1146
- // POSTPRO-OFF FALLBACK: in deferred-occlusion mode the splats live in the
1147
- // private splatScene and only draw via the splatOcclusionPass — which the
1148
- // pipeline fires only while postproduction is ON. If the user turns
1149
- // postproduction OFF at runtime (graphics panel), draw splatScene ourselves
1150
- // in a forward pass over the canvas (autoClear off so we keep the BIM the
1151
- // world just rendered; depth-tested against the BIM depth it wrote).
1152
- if (
1153
- ctx.deferredOcclusion &&
1154
- ctx.splatScene &&
1155
- ctx.postpro &&
1156
- ctx.postpro.enabled === false
1157
- ) {
1158
- applySplatDepthState(ctx.splatScene);
1159
- const r = ctx.renderer3;
1160
- const prevAutoClear = r.autoClear;
1161
- r.autoClear = false;
1162
- r.setRenderTarget(null);
1163
- r.render(ctx.splatScene, cam);
1164
- r.autoClear = prevAutoClear;
1165
- }
1166
- }
1167
- tick();
1168
- return ctx;
1169
- }
1170
-
1171
- // ───────────────────────────────────────────────────────────────────────
1172
- // PHASE 0 — CO-LOCATED MODE: splats/points in the BIM world scene.
1173
- //
1174
- // Borrows the OBC world's renderer + scene + camera instead of owning them, so
1175
- // the dataset sits in the same 3D space as the .frag model under one camera.
1176
- // MULTIPLE datasets can be loaded at once (each a WorldDataset under the shared
1177
- // WorldCtx). Occlusion: with postproduction OFF (default), three renders opaque
1178
- // BIM first (writes depth) then Spark's transparent splats (depthTest:true) —
1179
- // splats behind walls are clipped FOR FREE. Governor re-driven off the OBC
1180
- // camera-controls events; the overlay's idle-snapshot is dropped.
1181
- // ───────────────────────────────────────────────────────────────────────
1182
- async function loadIntoWorld(fileId: string, opts: LoadIntoWorldOpts = {}) {
1183
- if (!activeClient) {
1184
- throw new Error(
1185
- "realityCaptureViewer: no platform client — pass it to the factory or call setClient() first",
1186
- );
1187
- }
1188
- clearOverlay(); // overlay and co-located are alternative screens
1189
- if (worldDatasets.has(fileId)) removeDataset(fileId); // reload replaces in place
1190
-
1191
- const ctx = ensureWorldCtx(opts.keepPostproduction === true);
1192
-
1193
- // Root group carries the user alignment transform (dataset-local → world).
1194
- // matrixAutoUpdate stays ON so TransformControls can drive it via TRS; we set
1195
- // the transform by DECOMPOSING a matrix. Splats are Y-up → flip the inner
1196
- // container so the alignment transform on `root` stays in BIM world space.
1197
- // In deferred-occlusion mode the dataset lives in the private splatScene
1198
- // (drawn by the occlusion pass); otherwise in world.scene (forward path).
1199
- const contentScene = ctx.splatScene ?? ctx.scene3;
1200
- const root = new THREE.Group();
1201
- if (opts.transform) opts.transform.decompose(root.position, root.quaternion, root.scale);
1202
- contentScene.add(root);
1203
- const splatContainer = new THREE.Group();
1204
- splatContainer.rotation.x = Math.PI;
1205
- const pointContainer = new THREE.Group();
1206
- root.add(splatContainer, pointContainer);
1207
-
1208
- const ds: WorldDataset = {
1209
- id: fileId, root, splatContainer, pointContainer,
1210
- tiles: null, spark: null, format: "points",
1211
- splatBudget: SPLAT_MOTION_BUDGET, pointBudget: POINT_MOTION_BUDGET, splatOpacity: 1,
1212
- visible: true, originFitted: false, hasExplicitTransform: !!opts.transform,
1213
- parentScene: contentScene,
1214
- onTransformChange: opts.onTransformChange,
1215
- emitTransform() {
1216
- root.updateMatrix();
1217
- this.onTransformChange?.(root.matrix.clone());
1218
- },
1219
- dispose() {
1220
- try { this.tiles?.dispose(); } catch { /* noop */ }
1221
- try { this.spark?.dispose?.(); } catch { /* noop */ }
1222
- try {
1223
- if (this.spark) this.parentScene.remove(this.spark);
1224
- this.parentScene.remove(root);
1225
- } catch { /* noop */ }
1226
- },
1227
- };
1228
- worldDatasets.set(fileId, ds);
1229
- setActiveDataset(fileId); // attach the gizmo to the newest dataset
1230
- if (opts.gizmo === false) {
1231
- ctx.gizmoEnabled = false;
1232
- ctx.gizmoHelper.visible = false;
1233
- } else {
1234
- ctx.gizmoHelper.visible = ctx.gizmoEnabled;
1235
- }
1236
-
1237
- // --- download + parse the tileset ------------------------------------
1238
- // Guarded: a missing/forbidden fileId, a network failure, or a malformed
1239
- // tileset.json must not throw unhandled or leave a half-set-up dataset
1240
- // (ctx/gizmo/occlusion-pass registered but no tiles). On any failure we tear
1241
- // the partial dataset down (removeDataset → also drops the ctx if it was the
1242
- // only one) and rethrow so the caller (files-panel) surfaces it.
1243
- let tilesetBytes: Uint8Array;
1244
- let tsj: any;
1245
- try {
1246
- const res = await activeClient.downloadFile(fileId);
1247
- tilesetBytes = new Uint8Array(await res.arrayBuffer());
1248
- if (!worldDatasets.has(fileId)) return; // removed while downloading
1249
- tsj = JSON.parse(new TextDecoder().decode(tilesetBytes));
1250
- if (!tsj?.root) throw new Error("tileset.json has no root");
1251
- } catch (e) {
1252
- removeDataset(fileId);
1253
- throw new Error(
1254
- `reality-capture: failed to load tileset ${fileId} into the world: ${
1255
- e instanceof Error ? e.message : String(e)
1256
- }`,
1257
- );
1258
- }
1259
- const hiddenFiles: Record<string, string> = tsj.hiddenFiles || {};
1260
- const uris: string[] = [];
1261
- collectContentURIs(tsj.root, uris);
1262
- ds.format = detectFormat(uris);
1263
-
1264
- const tiles = new TilesRenderer("mem://t/tileset.json");
1265
- ds.tiles = tiles;
1266
- tiles.registerPlugin(
1267
- new HiddenTilesPlugin({ baseUrl: "mem://t", tilesetBytes, hiddenFiles, client: activeClient }),
1268
- );
1269
- // A tile that fails to download/parse mid-stream should degrade (that tile is
1270
- // skipped) rather than spam — log once per tile via the renderer's own error.
1271
- tiles.addEventListener("load-error", (e: any) => {
1272
- console.warn("[reality-capture] tile load error:", e?.url ?? e?.error ?? e);
1273
- });
1274
-
1275
- if (ds.format === "splats") {
1276
- ds.spark = new SparkRenderer({ renderer: ctx.renderer3, clipXY: 1.0 } as any);
1277
- ds.parentScene.add(ds.spark); // world.scene (forward) or splatScene (deferred)
1278
- tiles.registerPlugin(new SplatTilePlugin());
1279
- tiles.errorTarget = SPLAT_MOTION_ET;
1280
- configureCache(tiles, 280, 420);
1281
- setSplatJobs(tiles, 1);
1282
- splatContainer.add(tiles.group);
1283
- } else {
1284
- tiles.registerPlugin(new PointTilePlugin());
1285
- tiles.errorTarget = POINT_MOTION_ET;
1286
- configureCache(tiles, 1500, 768);
1287
- try { (tiles as any).parseQueue.maxJobs = 16; } catch { /* noop */ }
1288
- pointContainer.add(tiles.group);
1289
- }
1290
- ctx.overlay?.refresh(); // format now known → show splat-only controls (opacity)
1291
-
1292
- // AUTO-FIT (only when no explicit transform): centre the dataset at the BIM
1293
- // world origin so it lands near the model, ready to be nudged.
1294
- tiles.addEventListener("load-tileset", () => {
1295
- if (ds.originFitted || !worldDatasets.has(fileId)) return;
1296
- ds.originFitted = true;
1297
- if (!ds.hasExplicitTransform) {
1298
- const sphere = new THREE.Sphere();
1299
- tiles.getBoundingSphere(sphere);
1300
- const c = sphere.center; // splat container flips Y/Z; account for it
1301
- const worldCenter =
1302
- ds.format === "splats" ? new THREE.Vector3(c.x, -c.y, -c.z) : c.clone();
1303
- root.position.set(-worldCenter.x, -worldCenter.y, -worldCenter.z);
1304
- ds.emitTransform(); // persist the auto-fit as the initial alignment
1305
- }
1306
- });
1307
- }
1308
-
1309
- rcApi = {
1310
- loadThreeTZ,
1311
- loadIntoWorld,
1312
- // All co-located controls target a specific dataset by fileId, or the ACTIVE
1313
- // one (the most recently loaded / last selected) when fileId is omitted.
1314
- setTransform(matrix: THREE.Matrix4, fileId?: string) {
1315
- const ds = targetDataset(fileId);
1316
- if (ds) {
1317
- // root.matrixAutoUpdate is on (for the gizmo) → drive TRS, not .matrix.
1318
- matrix.decompose(ds.root.position, ds.root.quaternion, ds.root.scale);
1319
- ds.hasExplicitTransform = true;
1320
- }
1321
- },
1322
- getTransform(fileId?: string) {
1323
- const ds = targetDataset(fileId);
1324
- if (!ds) return null;
1325
- ds.root.updateMatrix();
1326
- return ds.root.matrix.clone();
1327
- },
1328
- showGizmo(on: boolean) {
1329
- if (!worldCtx) return;
1330
- worldCtx.gizmoEnabled = on;
1331
- worldCtx.gizmoHelper.visible = on && (activeDataset()?.visible ?? false);
1332
- },
1333
- setGizmoMode(mode: "translate" | "rotate" | "scale") {
1334
- worldCtx?.gizmo.setMode(mode);
1335
- },
1336
- setActiveDataset(fileId: string) {
1337
- setActiveDataset(fileId);
1338
- },
1339
- removeDataset(fileId: string) {
1340
- removeDataset(fileId);
1341
- },
1342
- listDatasets() {
1343
- return [...worldDatasets.keys()];
1344
- },
1345
- setDatasetVisible(on: boolean, fileId?: string) {
1346
- const ds = targetDataset(fileId);
1347
- if (!ds || !worldCtx) return;
1348
- ds.visible = on;
1349
- ds.root.visible = on;
1350
- if (ds.spark) ds.spark.visible = on;
1351
- if (ds === activeDataset()) {
1352
- worldCtx.gizmoHelper.visible = on && worldCtx.gizmoEnabled;
1353
- }
1354
- try { worldCtx.world.renderer.update(); } catch { /* noop */ }
1355
- },
1356
- isDatasetVisible(fileId?: string) {
1357
- return targetDataset(fileId)?.visible ?? false;
1358
- },
1359
- setPointSize(px: number) {
1360
- setPointSizePx(px);
1361
- },
1362
- setSplatOpacity(opacity: number, fileId?: string) {
1363
- const ds = targetDataset(fileId);
1364
- if (!ds) return;
1365
- ds.splatOpacity = THREE.MathUtils.clamp(opacity, 0, 1);
1366
- ds.splatContainer.traverse((o: any) => {
1367
- if (o.userData?.splatCount && typeof o.opacity === "number") o.opacity = ds.splatOpacity;
1368
- });
1369
- },
1370
- setMotionBudget(count: number, fileId?: string) {
1371
- const ds = targetDataset(fileId);
1372
- if (!ds) return;
1373
- const v = Math.max(1000, Math.floor(count));
1374
- if (ds.format === "splats") ds.splatBudget = v;
1375
- else ds.pointBudget = v;
1376
- },
1377
- setClient(c: ThreeTZClient) {
1378
- activeClient = c;
1379
- },
1380
- clear,
1381
- };
1382
- return rcApi;
1383
- }
1
+ import * as OBC from "@thatopen/components";
2
+ import * as THREE from "three";
3
+ import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
4
+ import { TransformControls } from "three/examples/jsm/controls/TransformControls.js";
5
+ import { TilesRenderer } from "3d-tiles-renderer";
6
+ import { SparkRenderer } from "@sparkjsdev/spark";
7
+
8
+ import { HiddenTilesPlugin } from "./reality-capture/lib/hidden-tiles-plugin";
9
+ import { PointTilePlugin, setPointSize as setPointSizePx } from "./reality-capture/lib/point-tile-plugin";
10
+ import { SplatTilePlugin } from "./reality-capture/lib/splat-tile-plugin";
11
+ import { frame } from "./reality-capture/lib/render-frame";
12
+
13
+ // Reality-capture (standard loose 3D Tiles) viewer for bim-viewer.
14
+ //
15
+ // SELF-CONTAINED + ISOLATED. This renders a standard 3D Tiles dataset (point
16
+ // clouds or Gaussian splats) in its OWN three.js WebGLRenderer + Scene + camera
17
+ // + orbit loop, mounted in an overlay over the app — it deliberately does NOT
18
+ // touch bim-viewer's OBC world. That world runs a deferred PEN/MRT
19
+ // postproduction pipeline that points/splats can't render through, so they get
20
+ // an isolated forward renderer instead.
21
+ //
22
+ // STORAGE MODEL: the main visible file is a standard `tileset.json`; each tile's
23
+ // `content.uri` is a relative path (`tiles/n…_d….spz` for splats, `…_d….pnt`
24
+ // for points), and the tileset carries a top-level `hiddenFiles` map of
25
+ // `content.uri -> hiddenFileId`. Tiles live as platform HIDDEN files and are
26
+ // streamed ONE FILE PER TILE via `client.downloadHiddenFile(id)` through the
27
+ // HiddenTilesPlugin's fetchData hook. The Q1 LRU/count-budget governor bounds
28
+ // how many tiles are resident/loading at once.
29
+ //
30
+ // The render core (Point/Splat plugins, decode worker, render-frame) is the
31
+ // platform_cde tiles-viewer prototype copied into ./reality-capture/lib. Only
32
+ // this outer controller is new: it mounts an overlay (or a caller-supplied
33
+ // container) instead of a CDEManager dialog, and takes the file bytes from the
34
+ // platform client.
35
+ //
36
+ // CLIENT WIRING: bim-viewer creates its PlatformClient in main.ts via
37
+ // `PlatformClient.fromPlatformContext()` and hands it to the panels (e.g.
38
+ // `filesPanel(components, client)`). There is no clean singleton/OBC component
39
+ // holding it, so this factory takes the client too — pass it in, or call
40
+ // `controller.setClient(client)` before `loadThreeTZ`. See the report for the
41
+ // exact wiring point the files-panel owner must hook.
42
+
43
+ const BG = new THREE.Color(0x11151a);
44
+
45
+ type Format = "points" | "splats";
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // NAV-PERF tuning (ported from the reality-capture-viewer prototype's main.ts).
49
+ // Spark re-sorts EVERY resident splat each frame, so smoothness is bounded by
50
+ // the splat COUNT actually drawn — not resolution. Three levers, in order of
51
+ // impact:
52
+ // 1. LRU cache so non-visible tiles get evicted -> Spark's global buffer only
53
+ // holds the visible cut (the 4.6 -> 70 fps win in the prototype).
54
+ // 2. A count-budget governor: while MOVING, raise errorTarget to cap the
55
+ // visible primitive count to a motion budget; while PARKED, step
56
+ // errorTarget DOWN toward a floor to refine coarse -> fine.
57
+ // 3. An idle snapshot: once parked + refined + tile queues empty, render the
58
+ // heavy scene ONCE into a render target and just BLIT that texture each
59
+ // frame (cheap), capping the live set back to "light" so the next motion
60
+ // is instant. On camera move, drop the frozen texture and render live.
61
+ // ---------------------------------------------------------------------------
62
+
63
+ // 100% slider reference unused here; budgets are fixed to the prototype values.
64
+ const SPLAT_MOTION_BUDGET = 200_000; // splat-count cap while moving
65
+ const POINT_MOTION_BUDGET = 1_500_000; // point-count cap while moving (points cheaper than splats)
66
+ const IDLE_POINT_BUDGET = 25_000_000; // cap when parked (bounds the snapshot capture)
67
+
68
+ // errorTarget clamps (mirror the prototype's scaleET clamp + refine floors).
69
+ const ET_MIN = 8;
70
+ const ET_MAX = 8192;
71
+ const SPLAT_REFINE_FLOOR = 8;
72
+ const POINT_REFINE_FLOOR = 14;
73
+ const SPLAT_REFINE_STEP = 0.78; // gentle (Spark rebuilds its global sort on each add)
74
+ const POINT_REFINE_STEP = 0.55; // bigger jumps -> reads as "all at once"
75
+
76
+ // errorTarget the governor settles on while moving — remembered so we can snap
77
+ // the live set back to it after taking the idle snapshot.
78
+ const SPLAT_MOTION_ET = 48;
79
+ const POINT_MOTION_ET = 16;
80
+
81
+ // PHASE 1 occlusion pass depth convention. W3's deferred depth is REVERSED-Z
82
+ // (DEPTH_COMPONENT32F, far=0/near=1). Per W3's converged hook (d2528f06): Spark's
83
+ // draw doesn't ride three's auto depthFunc inversion, so we set GreaterEqualDepth
84
+ // on the splat materials OURSELVES to match reversed-Z. Set this false ONLY if a
85
+ // live test shows occlusion INVERTED (splats hidden where they should show), i.e.
86
+ // three did auto-invert after all → default LessEqualDepth would be correct.
87
+ const OCCLUSION_USE_GEQUAL = true;
88
+
89
+ // Apply the reversed-Z depth state to every resident SplatMesh in a scene so a
90
+ // separate splat render call (the deferred occlusion pass OR the postpro-off
91
+ // fallback) occludes correctly. depthWrite stays off (never touch the borrowed
92
+ // BIM depth). Cheap per-call property assigns.
93
+ function applySplatDepthState(scene: THREE.Object3D) {
94
+ if (!OCCLUSION_USE_GEQUAL) return;
95
+ scene.traverse((o: any) => {
96
+ const m = o.material;
97
+ if (m && typeof o.opacity === "number" && o.userData?.splatCount) {
98
+ m.depthFunc = THREE.GreaterEqualDepth;
99
+ m.depthWrite = false;
100
+ }
101
+ });
102
+ }
103
+
104
+ // Set tiles.lruCache min/max sizes so non-visible tiles are evicted and Spark's
105
+ // global buffer only holds the visible cut. Mirrors prototype configureCache().
106
+ function configureCache(t: TilesRenderer, maxItems: number, maxMB: number) {
107
+ const lru = (t as any).lruCache;
108
+ if (!lru) return;
109
+ lru.maxSize = maxItems;
110
+ lru.minSize = Math.floor(maxItems * 0.66);
111
+ lru.maxBytesSize = maxMB * 1024 * 1024;
112
+ lru.minBytesSize = Math.floor(maxMB * 0.66) * 1024 * 1024;
113
+ }
114
+
115
+ // Tile streaming concurrency: 1 while moving (spread the per-tile rebuild spikes
116
+ // for smoothness), higher while parked (burst-load the refine in one go). Points
117
+ // have no Spark global rebuild, so they can stream more aggressively.
118
+ function setSplatJobs(t: TilesRenderer, n: number) {
119
+ try {
120
+ (t as any).parseQueue.maxJobs = n;
121
+ (t as any).downloadQueue.maxJobs = Math.max(n, 4);
122
+ } catch {
123
+ /* queues not present yet */
124
+ }
125
+ }
126
+
127
+ // Are both tile queues drained? (snapshot gate)
128
+ function queuesEmpty(t: TilesRenderer): boolean {
129
+ const dq = (t as any).downloadQueue?.items?.length || 0;
130
+ const pq = (t as any).parseQueue?.items?.length || 0;
131
+ return dq + pq === 0;
132
+ }
133
+
134
+ // Visible-primitive counters (mirror the prototype). Splats sum userData.splatCount;
135
+ // points sum the position-attribute vertex count over visible THREE.Points.
136
+ function countSplats(group: THREE.Object3D): number {
137
+ let splats = 0;
138
+ group.traverse((o: any) => {
139
+ if (o.visible && o.userData?.splatCount) splats += o.userData.splatCount;
140
+ });
141
+ return splats;
142
+ }
143
+ function countPoints(group: THREE.Object3D): number {
144
+ let pts = 0;
145
+ group.traverse((o: any) => {
146
+ if (o.visible && o instanceof THREE.Points) {
147
+ pts += (o.geometry.getAttribute("position") as THREE.BufferAttribute).count;
148
+ }
149
+ });
150
+ return pts;
151
+ }
152
+
153
+ // Minimal structural type for the platform client: download the tileset main
154
+ // file by id (downloadFile) + each tile blob by hidden-file id
155
+ // (downloadHiddenFile). PlatformClient / EngineServicesClient from
156
+ // "@thatopen/services" both satisfy this.
157
+ export interface ThreeTZClient {
158
+ downloadFile(fileId: string, params?: any): Promise<Response>;
159
+ downloadHiddenFile(hiddenId: string): Promise<Response>;
160
+ }
161
+
162
+ export interface RealityCaptureController {
163
+ /** Download a tileset.json by file id and render it in an isolated overlay (or `container`). */
164
+ loadThreeTZ(fileId: string, container?: HTMLElement): Promise<void>;
165
+ /**
166
+ * PHASE 0 co-located mode: download a tileset.json by id and render it INSIDE
167
+ * the bim-viewer OBC world scene, driven by the world camera, so the splats /
168
+ * point cloud sit in the SAME 3D space as the BIM model. Returns when the
169
+ * tileset has begun streaming. See `loadIntoWorld` for the occlusion model.
170
+ */
171
+ loadIntoWorld(fileId: string, opts?: LoadIntoWorldOpts): Promise<void>;
172
+ /**
173
+ * Set the alignment transform of a co-located dataset (manual registration).
174
+ * `matrix` maps dataset-local space → BIM world space. `fileId` selects the
175
+ * dataset (default: the active one). Persist/restore to remember an alignment.
176
+ */
177
+ setTransform(matrix: THREE.Matrix4, fileId?: string): void;
178
+ /** Current alignment transform of a dataset (default active), or null. */
179
+ getTransform(fileId?: string): THREE.Matrix4 | null;
180
+ /** Show/hide the interactive align gizmo (attached to the active dataset). */
181
+ showGizmo(on: boolean): void;
182
+ /** Set the align gizmo mode. */
183
+ setGizmoMode(mode: "translate" | "rotate" | "scale"): void;
184
+ /** Make a loaded dataset the active one (gizmo target). */
185
+ setActiveDataset(fileId: string): void;
186
+ /** Unload one co-located dataset (tears down the world view if it was last). */
187
+ removeDataset(fileId: string): void;
188
+ /** Ids of all currently co-located datasets. */
189
+ listDatasets(): string[];
190
+ /** Show/hide a co-located dataset without unloading it (default active). */
191
+ setDatasetVisible(on: boolean, fileId?: string): void;
192
+ /** Is a co-located dataset visible? (default active; false if none). */
193
+ isDatasetVisible(fileId?: string): boolean;
194
+ /** Point size in px (point clouds; global to all point datasets). */
195
+ setPointSize(px: number): void;
196
+ /** Global splat opacity 0..1 for a dataset (default active). */
197
+ setSplatOpacity(opacity: number, fileId?: string): void;
198
+ /** Tune the governor's while-moving primitive budget for a dataset (default active). */
199
+ setMotionBudget(count: number, fileId?: string): void;
200
+ /** Provide the platform client if it wasn't passed to the factory. */
201
+ setClient(client: ThreeTZClient): void;
202
+ /** Stop the loop, dispose everything, remove the overlay / co-located group. Idempotent. */
203
+ clear(): void;
204
+ }
205
+
206
+ export interface LoadIntoWorldOpts {
207
+ /**
208
+ * Initial alignment transform (e.g. restored from app-data). If omitted, an
209
+ * auto-fit is applied on first load: the dataset is centred at the BIM world
210
+ * origin so it lands roughly where the model is, ready for manual nudging.
211
+ */
212
+ transform?: THREE.Matrix4;
213
+ /**
214
+ * Keep the deferred postproduction pipeline ON. Default false → we switch the
215
+ * world to a forward render while the dataset is shown, which gives correct
216
+ * splat-vs-BIM occlusion FOR FREE (three draws opaque BIM first writing depth,
217
+ * then Spark's splats with depthTest:true are clipped behind it). With
218
+ * postproduction ON, splats can't go through the capture pass, so they'd draw
219
+ * over the BIM (no occlusion) until the deferred depth target is exposed (W1/W3).
220
+ */
221
+ keepPostproduction?: boolean;
222
+ /**
223
+ * Show the interactive align gizmo on load (default true). Drag to register the
224
+ * dataset against the BIM model; W/E/R switch translate/rotate/scale.
225
+ */
226
+ gizmo?: boolean;
227
+ /**
228
+ * Called whenever the user finishes moving the align gizmo, with the new
229
+ * dataset-local → world transform. Persist it (e.g. into app-data) and feed it
230
+ * back as `opts.transform` next time to remember the alignment.
231
+ */
232
+ onTransformChange?: (matrix: THREE.Matrix4) => void;
233
+ }
234
+
235
+ // Recursively collect every content URI referenced by a tileset (root + children).
236
+ function collectContentURIs(node: any, out: string[]) {
237
+ if (!node || typeof node !== "object") return;
238
+ const c = node.content;
239
+ if (c) {
240
+ const uri = c.uri ?? c.url;
241
+ if (typeof uri === "string") out.push(uri);
242
+ }
243
+ if (Array.isArray(node.children)) {
244
+ for (const child of node.children) collectContentURIs(child, out);
245
+ }
246
+ }
247
+
248
+ function detectFormat(uris: string[]): Format {
249
+ for (const uri of uris) {
250
+ const u = uri.toLowerCase();
251
+ if (u.includes(".spz")) return "splats";
252
+ if (u.includes(".pnt")) return "points";
253
+ }
254
+ return "points";
255
+ }
256
+
257
+ /**
258
+ * Build an isolated 3D Tiles viewer controller for bim-viewer.
259
+ *
260
+ * @param _components the engine components (kept for parity/future wiring; the
261
+ * overlay is intentionally independent of the OBC world's renderer).
262
+ * @param client OPTIONAL platform client (PlatformClient/EngineServicesClient).
263
+ * If omitted, call `controller.setClient(client)` before `loadThreeTZ`.
264
+ */
265
+ export function realityCaptureViewer(
266
+ _components: OBC.Components,
267
+ client?: ThreeTZClient,
268
+ ): RealityCaptureController {
269
+ let activeClient: ThreeTZClient | undefined = client;
270
+
271
+ // Per-session disposables (one tileset at a time). clear() tears these down.
272
+ interface Session {
273
+ overlay: HTMLElement;
274
+ ownsOverlay: boolean; // true => we created the fullscreen overlay and must remove it
275
+ renderer: THREE.WebGLRenderer;
276
+ controls: OrbitControls;
277
+ tiles: TilesRenderer | null;
278
+ sparkRenderer: any;
279
+ onResize: () => void;
280
+ disposed: boolean;
281
+ // idle-snapshot disposables (created lazily on first load)
282
+ snapRT: THREE.WebGLRenderTarget | null;
283
+ blitScene: THREE.Scene | null;
284
+ blitQuad: THREE.Mesh | null;
285
+ blitMat: THREE.ShaderMaterial | null;
286
+ blitGeom: THREE.BufferGeometry | null;
287
+ }
288
+ let session: Session | null = null;
289
+
290
+ // PHASE 0 co-located mode (it borrows the OBC world's renderer/scene/camera
291
+ // instead of owning them). MULTIPLE datasets can be loaded into the BIM world
292
+ // at once — each is a `WorldDataset` in `worldDatasets`. A single shared
293
+ // `WorldCtx` holds the world handles, the ONE align gizmo (attached to the
294
+ // ACTIVE dataset), the camera-controls listeners, and the shared rAF tick that
295
+ // updates every dataset. The ctx is created lazily on the first load and torn
296
+ // down (restoring postproduction) when the last dataset is removed.
297
+ interface WorldDataset {
298
+ id: string;
299
+ root: THREE.Group; // alignment-transform target (dataset-local → BIM world)
300
+ splatContainer: THREE.Group;
301
+ pointContainer: THREE.Group;
302
+ tiles: TilesRenderer | null;
303
+ spark: any;
304
+ format: Format;
305
+ splatBudget: number;
306
+ pointBudget: number;
307
+ splatOpacity: number;
308
+ visible: boolean;
309
+ originFitted: boolean;
310
+ hasExplicitTransform: boolean;
311
+ parentScene: THREE.Scene; // scene the root/spark were added to (world or splat)
312
+ onTransformChange?: (m: THREE.Matrix4) => void;
313
+ emitTransform: () => void;
314
+ dispose: () => void;
315
+ }
316
+ interface WorldCtx {
317
+ world: any;
318
+ scene3: THREE.Scene;
319
+ renderer3: THREE.WebGLRenderer;
320
+ getCam: () => THREE.Camera;
321
+ controls: any;
322
+ gizmo: TransformControls;
323
+ gizmoHelper: THREE.Object3D;
324
+ gizmoEnabled: boolean;
325
+ postpro: any;
326
+ prevPostpro: boolean;
327
+ disabledPostpro: boolean; // did WE turn postproduction off? (restore on teardown)
328
+ keepPostproduction: boolean;
329
+ // PHASE 1 — "both" PEN + occluded splats. When on, postproduction stays
330
+ // enabled; splats live in `splatScene` and are drawn by the deferred
331
+ // pipeline's `splatOcclusionPass` hook (after composite, BIM depth borrowed),
332
+ // so they're occluded by BIM with the PEN look kept. Off → forward path
333
+ // (postproduction disabled, splats in world.scene, free occlusion, no PEN).
334
+ deferredOcclusion: boolean;
335
+ splatScene: THREE.Scene | null; // holds dataset roots + Spark when deferredOcclusion
336
+ lastMoveT: number;
337
+ frameNo: number;
338
+ raf: number;
339
+ disposed: boolean;
340
+ overlay: { el: HTMLElement; refresh: () => void; dispose: () => void } | null;
341
+ onCtrl: () => void;
342
+ onRest: () => void;
343
+ onKey: (e: KeyboardEvent) => void;
344
+ }
345
+ const worldDatasets = new Map<string, WorldDataset>();
346
+ let worldCtx: WorldCtx | null = null;
347
+ let activeDatasetId: string | null = null;
348
+ // Assigned to the returned controller so the in-viewport control overlay can
349
+ // drive the public API. Non-null by the time any overlay handler fires.
350
+ let rcApi: RealityCaptureController | null = null;
351
+
352
+ // Tear down the fullscreen-overlay session (loadThreeTZ), if any.
353
+ function clearOverlay() {
354
+ const s = session;
355
+ if (!s) return;
356
+ session = null;
357
+ s.disposed = true;
358
+ window.removeEventListener("resize", s.onResize);
359
+ try { s.tiles?.dispose(); } catch { /* noop */ }
360
+ try { s.controls.dispose(); } catch { /* noop */ }
361
+ try { s.sparkRenderer?.dispose?.(); } catch { /* noop */ }
362
+ try {
363
+ s.snapRT?.dispose();
364
+ s.blitGeom?.dispose();
365
+ s.blitMat?.dispose();
366
+ } catch { /* noop */ }
367
+ try {
368
+ s.renderer.dispose();
369
+ s.renderer.forceContextLoss();
370
+ } catch { /* noop */ }
371
+ try {
372
+ if (s.ownsOverlay) s.overlay.remove();
373
+ else s.overlay.replaceChildren(); // caller-owned container: just empty it
374
+ } catch { /* noop */ }
375
+ }
376
+
377
+ const activeDataset = (): WorldDataset | null =>
378
+ (activeDatasetId && worldDatasets.get(activeDatasetId)) || null;
379
+ const targetDataset = (id?: string): WorldDataset | null =>
380
+ id ? worldDatasets.get(id) ?? null : activeDataset();
381
+
382
+ // Attach the shared gizmo to a dataset's alignment root + make it the gizmo's
383
+ // persistence target. No-op if the id isn't loaded.
384
+ function setActiveDataset(id: string) {
385
+ const ds = worldDatasets.get(id);
386
+ const ctx = worldCtx;
387
+ if (!ds || !ctx) return;
388
+ activeDatasetId = id;
389
+ ctx.gizmo.attach(ds.root);
390
+ ctx.gizmoHelper.visible = ctx.gizmoEnabled && ds.visible;
391
+ ctx.overlay?.refresh();
392
+ }
393
+
394
+ // Remove ONE co-located dataset; tear the shared ctx down when the last goes.
395
+ function removeDataset(id: string) {
396
+ const ds = worldDatasets.get(id);
397
+ if (!ds) return;
398
+ worldDatasets.delete(id);
399
+ try { ds.dispose(); } catch { /* noop */ }
400
+ if (activeDatasetId === id) {
401
+ const next = worldDatasets.keys().next();
402
+ activeDatasetId = next.done ? null : next.value;
403
+ if (activeDatasetId) setActiveDataset(activeDatasetId);
404
+ }
405
+ if (worldDatasets.size === 0) teardownWorldCtx();
406
+ else { worldCtx?.overlay?.refresh(); worldCtx?.world?.renderer?.update?.(); }
407
+ }
408
+
409
+ function teardownWorldCtx() {
410
+ const ctx = worldCtx;
411
+ if (!ctx) return;
412
+ worldCtx = null;
413
+ activeDatasetId = null;
414
+ ctx.disposed = true;
415
+ cancelAnimationFrame(ctx.raf);
416
+ ctx.controls?.removeEventListener?.("control", ctx.onCtrl);
417
+ ctx.controls?.removeEventListener?.("controlstart", ctx.onCtrl);
418
+ ctx.controls?.removeEventListener?.("rest", ctx.onRest);
419
+ ctx.controls?.removeEventListener?.("sleep", ctx.onRest);
420
+ window.removeEventListener("keydown", ctx.onKey);
421
+ if (ctx.controls) ctx.controls.enabled = true; // in case teardown lands mid-drag
422
+ // Unregister the Phase-1 occlusion hook so the pipeline stops calling us.
423
+ if (ctx.deferredOcclusion && ctx.postpro && "splatOcclusionPass" in ctx.postpro) {
424
+ ctx.postpro.splatOcclusionPass = undefined;
425
+ }
426
+ try {
427
+ ctx.gizmo.detach();
428
+ (ctx.splatScene ?? ctx.scene3).remove(ctx.gizmoHelper);
429
+ ctx.gizmo.dispose();
430
+ } catch { /* noop */ }
431
+ if (ctx.postpro && ctx.disabledPostpro) ctx.postpro.enabled = ctx.prevPostpro;
432
+ ctx.overlay?.dispose();
433
+ try { ctx.world.renderer.update(); } catch { /* noop */ }
434
+ }
435
+
436
+ // Remove ALL co-located datasets (and the shared ctx).
437
+ function clearWorldAll() {
438
+ for (const id of [...worldDatasets.keys()]) removeDataset(id);
439
+ }
440
+
441
+ // Public clear(): tear down everything (overlay + all co-located datasets).
442
+ function clear() {
443
+ clearWorldAll();
444
+ clearOverlay();
445
+ }
446
+
447
+ async function loadThreeTZ(fileId: string, container?: HTMLElement) {
448
+ if (!activeClient) {
449
+ throw new Error(
450
+ "realityCaptureViewer: no platform client — pass it to the factory or call setClient() first",
451
+ );
452
+ }
453
+ // Only one session at a time.
454
+ clear();
455
+
456
+ // --- overlay / mount --------------------------------------------------
457
+ let overlay: HTMLElement;
458
+ let mount: HTMLElement;
459
+ const ownsOverlay = !container;
460
+ let status: HTMLElement | null = null;
461
+
462
+ if (container) {
463
+ overlay = container;
464
+ overlay.replaceChildren();
465
+ mount = container;
466
+ } else {
467
+ overlay = document.createElement("div");
468
+ Object.assign(overlay.style, {
469
+ position: "absolute",
470
+ inset: "0",
471
+ zIndex: "1000",
472
+ background: BG.getStyle(),
473
+ display: "flex",
474
+ flexDirection: "column",
475
+ });
476
+
477
+ const header = document.createElement("div");
478
+ Object.assign(header.style, {
479
+ display: "flex",
480
+ alignItems: "center",
481
+ justifyContent: "space-between",
482
+ padding: "0.75rem 1rem",
483
+ flexShrink: "0",
484
+ borderBottom: "1px solid var(--bim-ui_bg-contrast-20)",
485
+ color: "var(--bim-ui_main-base, #fff)",
486
+ });
487
+
488
+ status = document.createElement("div");
489
+ status.style.opacity = "0.7";
490
+ status.textContent = "Loading…";
491
+
492
+ const closeBtn = document.createElement("button");
493
+ closeBtn.textContent = "Close";
494
+ Object.assign(closeBtn.style, {
495
+ cursor: "pointer",
496
+ padding: "0.4rem 0.9rem",
497
+ borderRadius: "0.375rem",
498
+ border: "1px solid var(--bim-ui_bg-contrast-40, #555)",
499
+ background: "var(--bim-ui_bg-contrast-20, #333)",
500
+ color: "var(--bim-ui_main-base, #fff)",
501
+ });
502
+ closeBtn.addEventListener("click", () => clear());
503
+
504
+ header.appendChild(status);
505
+ header.appendChild(closeBtn);
506
+
507
+ mount = document.createElement("div");
508
+ Object.assign(mount.style, {
509
+ flex: "1",
510
+ overflow: "hidden",
511
+ position: "relative",
512
+ minHeight: "0",
513
+ });
514
+
515
+ overlay.appendChild(header);
516
+ overlay.appendChild(mount);
517
+ document.body.appendChild(overlay);
518
+ }
519
+
520
+ // --- three.js core (own renderer; NOT the OBC world) ------------------
521
+ const renderer = new THREE.WebGLRenderer({ antialias: true });
522
+ renderer.setPixelRatio(window.devicePixelRatio);
523
+ renderer.setClearColor(BG, 1);
524
+ Object.assign(renderer.domElement.style, {
525
+ width: "100%",
526
+ height: "100%",
527
+ display: "block",
528
+ });
529
+ mount.appendChild(renderer.domElement);
530
+
531
+ const scene = new THREE.Scene();
532
+ const camera = new THREE.PerspectiveCamera(60, 1, 0.1, 50000);
533
+ camera.position.set(15, 12, 15);
534
+
535
+ const controls = new OrbitControls(camera, renderer.domElement);
536
+ controls.enableDamping = true;
537
+ controls.dampingFactor = 0.14;
538
+ controls.target.set(0, 0, 0);
539
+
540
+ const pointContainer = new THREE.Group();
541
+ pointContainer.matrixAutoUpdate = false;
542
+ scene.add(pointContainer);
543
+ const splatContainer = new THREE.Group();
544
+ splatContainer.rotation.x = Math.PI; // splats are Y-up
545
+ scene.add(splatContainer);
546
+
547
+ const s: Session = {
548
+ overlay,
549
+ ownsOverlay,
550
+ renderer,
551
+ controls,
552
+ tiles: null,
553
+ sparkRenderer: null,
554
+ onResize: () => {},
555
+ disposed: false,
556
+ snapRT: null,
557
+ blitScene: null,
558
+ blitQuad: null,
559
+ blitMat: null,
560
+ blitGeom: null,
561
+ };
562
+ session = s;
563
+
564
+ let format: Format = "points";
565
+ let originSet = false;
566
+
567
+ // --- idle-snapshot machinery (full-screen blit of a frozen RT) ---------
568
+ // Allocate the RT at the drawing-buffer size; a tiny ortho quad samples it.
569
+ const blitMat = new THREE.ShaderMaterial({
570
+ uniforms: { uTex: { value: null as THREE.Texture | null } },
571
+ vertexShader:
572
+ "varying vec2 vUv; void main(){ vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }",
573
+ fragmentShader:
574
+ "precision highp float; varying vec2 vUv; uniform sampler2D uTex; void main(){ gl_FragColor = texture2D(uTex, vUv); }",
575
+ depthTest: false,
576
+ depthWrite: false,
577
+ });
578
+ const blitGeom = new THREE.PlaneGeometry(2, 2);
579
+ const blitQuad = new THREE.Mesh(blitGeom, blitMat);
580
+ blitQuad.frustumCulled = false;
581
+ const blitScene = new THREE.Scene();
582
+ blitScene.add(blitQuad);
583
+ const blitCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
584
+ s.blitScene = blitScene;
585
+ s.blitQuad = blitQuad;
586
+ s.blitMat = blitMat;
587
+ s.blitGeom = blitGeom;
588
+
589
+ // Snapshot state machine flags.
590
+ let showFrozen = false; // currently blitting the frozen texture
591
+ let snapshotDone = false; // a valid snapshot has been captured at this pose
592
+ let lastMoveT = performance.now();
593
+
594
+ function ensureSnapRT(w: number, h: number) {
595
+ if (!s.snapRT) {
596
+ s.snapRT = new THREE.WebGLRenderTarget(w, h, { depthBuffer: true });
597
+ blitMat.uniforms.uTex.value = s.snapRT.texture;
598
+ } else if (s.snapRT.width !== w || s.snapRT.height !== h) {
599
+ s.snapRT.setSize(w, h);
600
+ }
601
+ }
602
+
603
+ function resize() {
604
+ const w = Math.max(1, mount.clientWidth);
605
+ const h = Math.max(1, mount.clientHeight);
606
+ renderer.setSize(w, h, false);
607
+ camera.aspect = w / h;
608
+ camera.updateProjectionMatrix();
609
+ if (s.tiles) s.tiles.setResolutionFromRenderer(camera, renderer);
610
+ // snapshot is resolution-specific: resize the RT and invalidate it.
611
+ const db = new THREE.Vector2();
612
+ renderer.getDrawingBufferSize(db);
613
+ ensureSnapRT(Math.max(1, Math.floor(db.x)), Math.max(1, Math.floor(db.y)));
614
+ showFrozen = false;
615
+ snapshotDone = false;
616
+ }
617
+ s.onResize = () => resize();
618
+ window.addEventListener("resize", s.onResize);
619
+
620
+ // Any user input invalidates the frozen snapshot and snaps the live set
621
+ // back to "light" so the resulting motion is immediately smooth.
622
+ const onInteractStart = () => {
623
+ showFrozen = false;
624
+ snapshotDone = false;
625
+ lastMoveT = performance.now();
626
+ if (s.tiles) {
627
+ const floorET = format === "splats" ? SPLAT_MOTION_ET : POINT_MOTION_ET;
628
+ s.tiles.errorTarget = Math.max(s.tiles.errorTarget, floorET);
629
+ setSplatJobs(s.tiles, 1); // spread spikes while moving
630
+ }
631
+ };
632
+ const onInteractEnd = () => {
633
+ if (s.tiles) setSplatJobs(s.tiles, 16); // burst-load the refine
634
+ };
635
+ controls.addEventListener("start", onInteractStart);
636
+ controls.addEventListener("end", onInteractEnd);
637
+
638
+ function frameCamera(center: THREE.Vector3, radius: number) {
639
+ const r = Math.max(2, radius);
640
+ controls.target.copy(center);
641
+ camera.position.set(center.x + r * 0.9, center.y + r * 0.7, center.z + r * 0.9);
642
+ camera.near = r / 500;
643
+ camera.far = r * 200;
644
+ camera.updateProjectionMatrix();
645
+ controls.update();
646
+ }
647
+
648
+ // --- motion detection: compare the camera world transform frame-to-frame.
649
+ // controls.update() under-reports wheel-zoom, so also diff position+quaternion
650
+ // (position changes on orbit/pan/zoom; target via controls covers pan).
651
+ const _prevPos = new THREE.Vector3(Infinity, Infinity, Infinity);
652
+ const _prevQuat = new THREE.Quaternion(2, 2, 2, 2);
653
+ const _prevTarget = new THREE.Vector3(Infinity, Infinity, Infinity);
654
+ let frameNo = 0;
655
+
656
+ function animate() {
657
+ if (s.disposed) return;
658
+ requestAnimationFrame(animate);
659
+
660
+ const now = performance.now();
661
+ const fromControls = controls.update();
662
+ const moved =
663
+ _prevPos.distanceToSquared(camera.position) > 1e-10 ||
664
+ _prevTarget.distanceToSquared(controls.target) > 1e-10 ||
665
+ Math.abs(_prevQuat.dot(camera.quaternion)) < 0.9999999;
666
+ _prevPos.copy(camera.position);
667
+ _prevTarget.copy(controls.target);
668
+ _prevQuat.copy(camera.quaternion);
669
+ const ctrlChanged = fromControls || moved;
670
+ if (ctrlChanged) lastMoveT = now;
671
+ // ~130ms tail so the damping glide still counts as "moving".
672
+ const cameraMoving = now - lastMoveT < 130;
673
+
674
+ const tiles = s.tiles;
675
+ if (tiles) {
676
+ tiles.setCamera(camera);
677
+ tiles.update();
678
+
679
+ const isSplat = format === "splats";
680
+ const group = isSplat ? splatContainer : pointContainer;
681
+ const budget = isSplat ? SPLAT_MOTION_BUDGET : POINT_MOTION_BUDGET;
682
+ const motionET = isSplat ? SPLAT_MOTION_ET : POINT_MOTION_ET;
683
+ const refineFloor = isSplat ? SPLAT_REFINE_FLOOR : POINT_REFINE_FLOOR;
684
+ const refineStep = isSplat ? SPLAT_REFINE_STEP : POINT_REFINE_STEP;
685
+
686
+ // --- count-budget governor (every ~6 frames) -----------------------
687
+ if (frameNo % 6 === 0) {
688
+ const vis = isSplat ? countSplats(group) : countPoints(group);
689
+ const scaleET = (f: number) => {
690
+ tiles.errorTarget = THREE.MathUtils.clamp(tiles.errorTarget * f, ET_MIN, ET_MAX);
691
+ };
692
+ if (cameraMoving) {
693
+ // MOVING: hard-cap the primitive COUNT to the motion budget by
694
+ // raising errorTarget proportionally; ease back down if well under.
695
+ const ratio = vis / budget;
696
+ if (ratio > 1.1) scaleET(Math.min(2.5, ratio));
697
+ else if (ratio < 0.6) scaleET(0.9);
698
+ } else if (!snapshotDone) {
699
+ // PARKED + not yet snapshotted: refine coarse -> fine toward the floor,
700
+ // bounded by the idle budget so a wide view doesn't request the world.
701
+ const underIdleCap = isSplat || vis < IDLE_POINT_BUDGET;
702
+ if (underIdleCap && tiles.errorTarget > refineFloor) {
703
+ tiles.errorTarget = Math.max(tiles.errorTarget * refineStep, refineFloor);
704
+ }
705
+ }
706
+ }
707
+
708
+ // --- idle-snapshot state machine -----------------------------------
709
+ if (cameraMoving) {
710
+ showFrozen = false;
711
+ snapshotDone = false;
712
+ } else if (!snapshotDone) {
713
+ // parked: capture once refined (near the floor) AND queues drained,
714
+ // OR after a 2.5s safety dwell if streaming never fully settles.
715
+ const refined = tiles.errorTarget <= refineFloor * 2.5 && queuesEmpty(tiles);
716
+ const stable = refined || now - lastMoveT > 2500;
717
+ if (stable) {
718
+ const db = new THREE.Vector2();
719
+ renderer.getDrawingBufferSize(db);
720
+ ensureSnapRT(Math.max(1, Math.floor(db.x)), Math.max(1, Math.floor(db.y)));
721
+ renderer.setRenderTarget(s.snapRT);
722
+ renderer.clear(true, true, true);
723
+ renderer.render(scene, camera);
724
+ renderer.setRenderTarget(null);
725
+ showFrozen = true;
726
+ snapshotDone = true;
727
+ // cap the live set back to "light" so the next motion is instant.
728
+ tiles.errorTarget = motionET;
729
+ }
730
+ }
731
+
732
+ if (showFrozen && s.snapRT) {
733
+ // BLIT the frozen full-detail texture (cheap; no re-sort).
734
+ renderer.setRenderTarget(null);
735
+ renderer.render(blitScene, blitCam);
736
+ } else {
737
+ renderer.render(scene, camera);
738
+ }
739
+ } else {
740
+ renderer.render(scene, camera);
741
+ }
742
+ frameNo++;
743
+ }
744
+
745
+ resize();
746
+ animate();
747
+
748
+ // --- load -------------------------------------------------------------
749
+ try {
750
+ if (status) status.textContent = "Downloading…";
751
+ // The main visible file is a standard tileset.json. Download its bytes,
752
+ // parse the JSON for the content URIs (format detection) + the hiddenFiles
753
+ // map (content.uri -> hidden file id) used to stream tiles per-file.
754
+ const res = await activeClient.downloadFile(fileId);
755
+ const tilesetBytes = new Uint8Array(await res.arrayBuffer());
756
+ if (s.disposed) return;
757
+
758
+ if (status) status.textContent = "Inspecting…";
759
+ const ts = JSON.parse(new TextDecoder().decode(tilesetBytes));
760
+ const hiddenFiles: Record<string, string> = ts.hiddenFiles || {};
761
+ const uris: string[] = [];
762
+ collectContentURIs(ts.root, uris);
763
+ format = detectFormat(uris);
764
+ if (s.disposed) return;
765
+
766
+ if (status) status.textContent = `Streaming (${format})…`;
767
+
768
+ // HiddenTilesPlugin serves the root tileset from the bytes we already have,
769
+ // and each tile content fetch by downloading the matching platform HIDDEN
770
+ // file (one downloadHiddenFile per visible tile). The synthetic "mem://t"
771
+ // base lets relative content URIs resolve to dataset-relative paths the
772
+ // plugin strips back to look up in the hiddenFiles map.
773
+ const tiles = new TilesRenderer("mem://t/tileset.json");
774
+ s.tiles = tiles;
775
+ tiles.registerPlugin(
776
+ new HiddenTilesPlugin({
777
+ baseUrl: "mem://t",
778
+ tilesetBytes,
779
+ hiddenFiles,
780
+ client: activeClient,
781
+ }),
782
+ );
783
+
784
+ if (format === "splats") {
785
+ // clipXY 1.0 frustum-culls splat centers (default 1.4 keeps a 40%
786
+ // off-screen overdraw margin we don't need here).
787
+ s.sparkRenderer = new SparkRenderer({ renderer, clipXY: 1.0 } as any);
788
+ scene.add(s.sparkRenderer);
789
+ tiles.registerPlugin(new SplatTilePlugin());
790
+ tiles.errorTarget = SPLAT_MOTION_ET;
791
+ // LRU sized to hold a full small dataset once (no reload churn); the
792
+ // count-budget governor still bounds what's actually DRAWN each frame.
793
+ // This is THE big win — Spark only re-sorts the visible cut.
794
+ configureCache(tiles, 280, 420);
795
+ // Spark rebuilds a merged buffer per tile: cap concurrent parses to
796
+ // avoid bunching spikes (raised to 16 on interaction-end to burst-refine).
797
+ setSplatJobs(tiles, 1);
798
+ splatContainer.add(tiles.group);
799
+ } else {
800
+ tiles.registerPlugin(new PointTilePlugin());
801
+ tiles.errorTarget = POINT_MOTION_ET;
802
+ // Bigger cache for dense point clouds so revisits don't reload.
803
+ configureCache(tiles, 1500, 768);
804
+ // Points load fast (no Spark rebuild to spread); match the decode pool.
805
+ try {
806
+ (tiles as any).parseQueue.maxJobs = 16;
807
+ } catch {
808
+ /* queue not present */
809
+ }
810
+ pointContainer.add(tiles.group);
811
+ }
812
+
813
+ tiles.setCamera(camera);
814
+ tiles.setResolutionFromRenderer(camera, renderer);
815
+
816
+ tiles.addEventListener("load-tileset", () => {
817
+ if (originSet || s.disposed) return;
818
+ originSet = true;
819
+ const sphere = new THREE.Sphere();
820
+ tiles.getBoundingSphere(sphere);
821
+
822
+ if (format === "points") {
823
+ // RTC: recenter the float64 origin on the tileset centre so the point
824
+ // nodes (placed at float64 world anchors) render jitter-free. The
825
+ // pointContainer shifts by -origin; framing is around the origin.
826
+ const c = sphere.center;
827
+ frame.origin = [c.x, c.y, c.z];
828
+ pointContainer.position.set(-c.x, -c.y, -c.z);
829
+ pointContainer.updateMatrix();
830
+ frameCamera(new THREE.Vector3(0, 0, 0), sphere.radius);
831
+ } else {
832
+ // Splats: the container applies the Y-up flip (rotation.x = PI). Frame
833
+ // on the flipped centre so the camera looks at the visible geometry.
834
+ const c = sphere.center;
835
+ const flipped = new THREE.Vector3(c.x, -c.y, -c.z);
836
+ frameCamera(flipped, sphere.radius);
837
+ }
838
+ if (status) status.textContent = "";
839
+ });
840
+ } catch (e: any) {
841
+ if (!s.disposed && status) status.textContent = `Error: ${e?.message ?? e}`;
842
+ console.error("[reality-capture-viewer]", e);
843
+ }
844
+ }
845
+
846
+ // ───────────────────────────────────────────────────────────────────────
847
+ // PHASE 0 — CO-LOCATED MODE: splats/points in the BIM world scene.
848
+ //
849
+ // Borrows the OBC world's renderer + scene + camera instead of owning them, so
850
+ // the dataset sits in the same 3D space as the .frag model under one camera.
851
+ // Occlusion: with postproduction OFF (default), three renders opaque BIM first
852
+ // (writes depth) then Spark's transparent splats (depthTest:true) — splats
853
+ // behind walls are clipped FOR FREE, no custom depth pass. The governor (LRU +
854
+ // count budget + park-refine) is re-driven off the OBC camera-controls events;
855
+ // the overlay's idle-snapshot is dropped (it would freeze the whole BIM scene).
856
+ // ───────────────────────────────────────────────────────────────────────
857
+ // Self-contained in-viewport control strip for the co-located experience.
858
+ // Plain DOM themed with the --bim-ui_* vars (matches BUI) — deliberately does
859
+ // NOT touch files-panel or the app panel system (avoids collision; W2 owns
860
+ // panels). Drives the public controller API (rcApi) and always reflects the
861
+ // ACTIVE dataset. Owned by the WorldCtx: appears on first load, torn down with
862
+ // the ctx. Bottom-centre of the viewport.
863
+ function buildControlOverlay(ctx: WorldCtx): {
864
+ el: HTMLElement;
865
+ refresh: () => void;
866
+ dispose: () => void;
867
+ } {
868
+ const mount = ctx.renderer3.domElement.parentElement ?? document.body;
869
+ const bar = document.createElement("div");
870
+ Object.assign(bar.style, {
871
+ position: "absolute", bottom: "12px", left: "50%", transform: "translateX(-50%)",
872
+ zIndex: "20", display: "flex", alignItems: "center", gap: "0.5rem",
873
+ padding: "0.4rem 0.6rem", borderRadius: "0.5rem",
874
+ background: "var(--bim-ui_bg-base, #1a1f26)",
875
+ border: "1px solid var(--bim-ui_bg-contrast-20, #333)",
876
+ color: "var(--bim-ui_main-base, #e6e6e6)",
877
+ font: "12px/1.2 system-ui, sans-serif",
878
+ boxShadow: "0 2px 12px rgba(0,0,0,0.4)", userSelect: "none",
879
+ });
880
+ const mkBtn = (text: string, title: string) => {
881
+ const b = document.createElement("button");
882
+ b.textContent = text; b.title = title;
883
+ Object.assign(b.style, {
884
+ cursor: "pointer", padding: "0.3rem 0.55rem", borderRadius: "0.35rem",
885
+ border: "1px solid var(--bim-ui_bg-contrast-40, #555)",
886
+ background: "var(--bim-ui_bg-contrast-20, #2a2f37)", color: "inherit", font: "inherit",
887
+ });
888
+ return b;
889
+ };
890
+ const hl = (b: HTMLButtonElement, on: boolean) => {
891
+ b.style.background = on ? "var(--bim-ui_accent-base, #4b7bec)" : "var(--bim-ui_bg-contrast-20, #2a2f37)";
892
+ b.style.borderColor = on ? "var(--bim-ui_accent-base, #4b7bec)" : "var(--bim-ui_bg-contrast-40, #555)";
893
+ };
894
+ const sep = () => { const s = document.createElement("span"); s.textContent = "|"; s.style.opacity = "0.3"; return s; };
895
+
896
+ const label = document.createElement("span");
897
+ Object.assign(label.style, { opacity: "0.85", maxWidth: "160px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" });
898
+ const prev = mkBtn("‹", "Previous dataset");
899
+ const next = mkBtn("›", "Next dataset");
900
+ const cycle = (dir: number) => {
901
+ const ids = rcApi?.listDatasets() ?? [];
902
+ if (ids.length < 2) return;
903
+ const i = Math.max(0, ids.indexOf(activeDatasetId ?? ""));
904
+ rcApi?.setActiveDataset(ids[(i + dir + ids.length) % ids.length]);
905
+ };
906
+ prev.onclick = () => cycle(-1);
907
+ next.onclick = () => cycle(1);
908
+
909
+ const eye = mkBtn("Hide", "Show/hide this dataset");
910
+ eye.onclick = () => { rcApi?.setDatasetVisible(!(rcApi?.isDatasetVisible() ?? true)); refresh(); };
911
+
912
+ const align = mkBtn("Align", "Toggle the align gizmo (W/E/R = move/rotate/scale)");
913
+ align.onclick = () => { ctx.gizmoEnabled = !ctx.gizmoEnabled; rcApi?.showGizmo(ctx.gizmoEnabled); refresh(); };
914
+ const mMove = mkBtn("Move", "Translate (W)");
915
+ const mRot = mkBtn("Rotate", "Rotate (E)");
916
+ const mScale = mkBtn("Scale", "Scale (R)");
917
+ mMove.onclick = () => { rcApi?.setGizmoMode("translate"); refresh(); };
918
+ mRot.onclick = () => { rcApi?.setGizmoMode("rotate"); refresh(); };
919
+ mScale.onclick = () => { rcApi?.setGizmoMode("scale"); refresh(); };
920
+
921
+ const opWrap = document.createElement("label");
922
+ Object.assign(opWrap.style, { display: "flex", alignItems: "center", gap: "0.3rem" });
923
+ opWrap.title = "Splat opacity";
924
+ const opTxt = document.createElement("span"); opTxt.textContent = "Opacity";
925
+ const op = document.createElement("input");
926
+ op.type = "range"; op.min = "0"; op.max = "1"; op.step = "0.05"; op.value = "1"; op.style.width = "80px";
927
+ op.oninput = () => rcApi?.setSplatOpacity(parseFloat(op.value));
928
+ opWrap.append(opTxt, op);
929
+
930
+ const unload = mkBtn("Unload", "Remove this dataset from the model");
931
+ unload.style.borderColor = "var(--bim-ui_color-error, #c0392b)";
932
+ unload.onclick = () => { if (activeDatasetId) rcApi?.removeDataset(activeDatasetId); };
933
+
934
+ bar.append(prev, label, next, sep(), eye, sep(), align, mMove, mRot, mScale, sep(), opWrap, sep(), unload);
935
+ mount.appendChild(bar);
936
+
937
+ function refresh() {
938
+ const ids = rcApi?.listDatasets() ?? [];
939
+ const multi = ids.length > 1;
940
+ prev.style.display = next.style.display = label.style.display = multi ? "" : "none";
941
+ if (multi && activeDatasetId) label.textContent = `${ids.indexOf(activeDatasetId) + 1}/${ids.length} · …${activeDatasetId.slice(-6)}`;
942
+ eye.textContent = (rcApi?.isDatasetVisible() ?? true) ? "Hide" : "Show";
943
+ hl(align, ctx.gizmoEnabled);
944
+ const mode = (ctx.gizmo as any).mode ?? "translate";
945
+ for (const [b, m] of [[mMove, "translate"], [mRot, "rotate"], [mScale, "scale"]] as [HTMLButtonElement, string][]) {
946
+ b.disabled = !ctx.gizmoEnabled;
947
+ b.style.opacity = ctx.gizmoEnabled ? "1" : "0.4";
948
+ hl(b, ctx.gizmoEnabled && mode === m);
949
+ }
950
+ const ds = activeDataset();
951
+ const isSplat = ds?.format === "splats";
952
+ opWrap.style.display = isSplat ? "flex" : "none";
953
+ if (isSplat && ds) op.value = String(ds.splatOpacity);
954
+ }
955
+ refresh();
956
+
957
+ return { el: bar, refresh, dispose() { try { bar.remove(); } catch { /* noop */ } } };
958
+ }
959
+
960
+ // Lazily create the shared world context: OBC-world handles + the single align
961
+ // gizmo (re-attached to the active dataset) + camera-controls listeners + the
962
+ // rAF tick that updates EVERY loaded dataset. Created on first load; torn down
963
+ // (restoring postproduction) when the last dataset is removed.
964
+ function ensureWorldCtx(keepPostproduction: boolean): WorldCtx {
965
+ if (worldCtx) return worldCtx;
966
+ const world = [...(_components.get(OBC.Worlds).list.values() as any)][0] as any;
967
+ if (!world?.scene?.three || !world?.renderer?.three || !world?.camera) {
968
+ throw new Error("loadIntoWorld: no OBC world available");
969
+ }
970
+ const scene3: THREE.Scene = world.scene.three;
971
+ const renderer3: THREE.WebGLRenderer = world.renderer.three;
972
+ const getCam = (): THREE.Camera => world.camera.three ?? world.camera.threePersp;
973
+ const controls: any = world.camera.controls;
974
+
975
+ // Mode: PHASE-1 deferred-occlusion ("both") requires keepPostproduction AND
976
+ // the deferred pipeline's splatOcclusionPass hook (W3). Otherwise → forward
977
+ // path (disable postproduction; splats in world.scene; free occlusion).
978
+ const postpro = world.renderer.postproduction;
979
+ const prevPostpro = !!postpro?.enabled;
980
+ const deferredOcclusion =
981
+ keepPostproduction && !!postpro && "splatOcclusionPass" in postpro;
982
+ // Disable postproduction for the forward path (free occlusion). Also covers
983
+ // the fallback where "both" was requested but the deferred hook is missing
984
+ // (older dist) — better splats-with-free-occlusion than splats hidden by the
985
+ // capture pass. Only deferred-occlusion mode keeps postproduction on.
986
+ const disabledPostpro = !!postpro && !deferredOcclusion;
987
+ if (disabledPostpro) postpro.enabled = false;
988
+
989
+ // In deferred-occlusion mode the splats live in a PRIVATE scene drawn by the
990
+ // splatOcclusionPass (after the PEN composite, BIM depth borrowed); the
991
+ // deferred capture would otherwise hide them. In forward mode they live in
992
+ // world.scene and render with the BIM in one pass.
993
+ const splatScene: THREE.Scene | null = deferredOcclusion ? new THREE.Scene() : null;
994
+ const contentScene = splatScene ?? scene3;
995
+
996
+ // ONE align gizmo, re-attached to the active dataset's root on selection.
997
+ // Put its helper in the same scene as the dataset content so it stays visible
998
+ // (in deferred mode the PEN capture would hide a helper added to world.scene).
999
+ const gizmo = new TransformControls(getCam(), renderer3.domElement);
1000
+ const gizmoHelper = gizmo.getHelper();
1001
+ contentScene.add(gizmoHelper);
1002
+ gizmo.addEventListener("dragging-changed", (e: any) => {
1003
+ if (controls) controls.enabled = !e.value;
1004
+ });
1005
+ gizmo.addEventListener("mouseUp", () => activeDataset()?.emitTransform());
1006
+
1007
+ const onCtrl = () => {
1008
+ if (worldCtx) worldCtx.lastMoveT = performance.now();
1009
+ for (const ds of worldDatasets.values()) {
1010
+ if (!ds.tiles) continue;
1011
+ const floorET = ds.format === "splats" ? SPLAT_MOTION_ET : POINT_MOTION_ET;
1012
+ ds.tiles.errorTarget = Math.max(ds.tiles.errorTarget, floorET);
1013
+ setSplatJobs(ds.tiles, 1); // spread spikes while moving
1014
+ }
1015
+ };
1016
+ const onRest = () => {
1017
+ for (const ds of worldDatasets.values()) if (ds.tiles) setSplatJobs(ds.tiles, 16);
1018
+ };
1019
+ const onKey = (e: KeyboardEvent) => {
1020
+ if (!gizmoHelper.visible) return;
1021
+ if (e.key === "w" || e.key === "W") gizmo.setMode("translate");
1022
+ else if (e.key === "e" || e.key === "E") gizmo.setMode("rotate");
1023
+ else if (e.key === "r" || e.key === "R") gizmo.setMode("scale");
1024
+ };
1025
+ controls?.addEventListener?.("control", onCtrl);
1026
+ controls?.addEventListener?.("controlstart", onCtrl);
1027
+ controls?.addEventListener?.("rest", onRest);
1028
+ controls?.addEventListener?.("sleep", onRest);
1029
+ window.addEventListener("keydown", onKey);
1030
+
1031
+ const ctx: WorldCtx = {
1032
+ world, scene3, renderer3, getCam, controls,
1033
+ gizmo, gizmoHelper, gizmoEnabled: true,
1034
+ postpro, prevPostpro, disabledPostpro, keepPostproduction,
1035
+ deferredOcclusion, splatScene,
1036
+ lastMoveT: performance.now(), frameNo: 0, raf: 0, disposed: false,
1037
+ overlay: null,
1038
+ onCtrl, onRest, onKey,
1039
+ };
1040
+ worldCtx = ctx;
1041
+ ctx.overlay = buildControlOverlay(ctx); // self-contained in-viewport controls
1042
+
1043
+ // PHASE 1 — register the deferred occlusion draw (W3 converged hook d2528f06).
1044
+ // Fires AFTER composite/FXAA/overlays (PEN frame done), into `ctx.target` — a
1045
+ // SEPARATE borrowed-depth colour LAYER the pipeline has already bound + cleared
1046
+ // transparent with the BIM DepthTexture attached; the pipeline then composites
1047
+ // that layer premultiplied OVER the PEN frame. We just draw the private
1048
+ // splatScene into the (already-bound) target: Spark's premultiplied,
1049
+ // depth-tested splats are occluded by the real BIM depth. We NEVER clear the
1050
+ // depth and DON'T cache the layer FBO across frames (depth detached per-frame).
1051
+ if (deferredOcclusion) {
1052
+ postpro.splatOcclusionPass = (c: {
1053
+ renderer: THREE.WebGLRenderer;
1054
+ camera: THREE.Camera;
1055
+ target: THREE.WebGLRenderTarget;
1056
+ depth: THREE.DepthTexture;
1057
+ width: number;
1058
+ height: number;
1059
+ }) => {
1060
+ if (ctx.disposed || !splatScene || !c?.depth || !c?.target) return; // guard until pipeline up
1061
+ // Reversed-Z: Spark's draw manages its own GL depth state (doesn't ride
1062
+ // three's auto depthFunc inversion), so set GreaterEqualDepth ourselves.
1063
+ applySplatDepthState(splatScene);
1064
+ // The pipeline has already bound c.target (borrowed BIM depth, cleared
1065
+ // transparent, autoClear off); just draw — renderer.render keeps the
1066
+ // current target. The pipeline composites it premultiplied over PEN.
1067
+ c.renderer.render(splatScene, c.camera);
1068
+ };
1069
+ }
1070
+
1071
+ // Motion detection for the idle gate: diff the camera transform frame-to-
1072
+ // frame (covers user orbit AND programmatic moves, not just controls events).
1073
+ const _prevPos = new THREE.Vector3(Infinity, Infinity, Infinity);
1074
+ const _prevQuat = new THREE.Quaternion(2, 2, 2, 2);
1075
+ // Frames still rendered after activity stops, so the settled/refined frame is
1076
+ // actually presented before we go idle.
1077
+ let trailing = 0;
1078
+
1079
+ // Shared tick: govern EVERY loaded dataset, then re-composite ONLY while the
1080
+ // camera is moving or tiles are streaming (+ a short trailing tail). When idle
1081
+ // and drained we stop forcing renderer.update() — the deferred PEN pipeline +
1082
+ // splat sort no longer run every frame, restoring idle fps. A camera move (or
1083
+ // a streaming tile) flips us back on within a frame, so the next move always
1084
+ // re-sorts; the on-demand render path keeps the canvas correct meanwhile.
1085
+ function tick() {
1086
+ if (ctx.disposed) return;
1087
+ ctx.raf = requestAnimationFrame(tick);
1088
+ const cam = ctx.getCam();
1089
+ if (ctx.gizmo.camera !== cam) ctx.gizmo.camera = cam; // follow persp/ortho
1090
+ const now = performance.now();
1091
+ // Camera moved since last frame? (transform diff + the controls events that
1092
+ // also set lastMoveT). Damping glide keeps changing the transform → caught.
1093
+ const moved =
1094
+ _prevPos.distanceToSquared(cam.position) > 1e-12 ||
1095
+ Math.abs(_prevQuat.dot(cam.quaternion)) < 0.9999999;
1096
+ _prevPos.copy(cam.position);
1097
+ _prevQuat.copy(cam.quaternion);
1098
+ if (moved) ctx.lastMoveT = now;
1099
+ const cameraMoving = now - ctx.lastMoveT < 180;
1100
+ const runGovernor = ctx.frameNo % 6 === 0;
1101
+ let anyStreaming = false;
1102
+ for (const ds of worldDatasets.values()) {
1103
+ const tiles = ds.tiles;
1104
+ if (!tiles || !ds.visible) continue; // hidden datasets pause streaming
1105
+ tiles.setCamera(cam);
1106
+ tiles.setResolutionFromRenderer(cam, ctx.renderer3);
1107
+ tiles.update();
1108
+ if (!queuesEmpty(tiles)) anyStreaming = true; // a tile in flight → keep rendering
1109
+ if (!runGovernor) continue;
1110
+ const isSplat = ds.format === "splats";
1111
+ const group = isSplat ? ds.splatContainer : ds.pointContainer;
1112
+ const budget = isSplat ? ds.splatBudget : ds.pointBudget;
1113
+ const refineFloor = isSplat ? SPLAT_REFINE_FLOOR : POINT_REFINE_FLOOR;
1114
+ const refineStep = isSplat ? SPLAT_REFINE_STEP : POINT_REFINE_STEP;
1115
+ const vis = isSplat ? countSplats(group) : countPoints(group);
1116
+ const scaleET = (f: number) => {
1117
+ tiles.errorTarget = THREE.MathUtils.clamp(tiles.errorTarget * f, ET_MIN, ET_MAX);
1118
+ };
1119
+ if (cameraMoving) {
1120
+ const ratio = vis / budget;
1121
+ if (ratio > 1.1) scaleET(Math.min(2.5, ratio));
1122
+ else if (ratio < 0.6) scaleET(0.9);
1123
+ } else if (tiles.errorTarget > refineFloor) {
1124
+ tiles.errorTarget = Math.max(tiles.errorTarget * refineStep, refineFloor);
1125
+ }
1126
+ if (isSplat && ds.splatOpacity !== 1) {
1127
+ group.traverse((o: any) => {
1128
+ if (o.userData?.splatCount && typeof o.opacity === "number") o.opacity = ds.splatOpacity;
1129
+ });
1130
+ }
1131
+ }
1132
+ ctx.frameNo++;
1133
+
1134
+ // IDLE GATE: re-composite only when something changed. Refinement counts as
1135
+ // streaming (park-refine lowers errorTarget → loads finer tiles → keeps
1136
+ // rendering until refined + drained, then we idle). `trailing` presents the
1137
+ // final settled frame before stopping.
1138
+ const needsRender = cameraMoving || anyStreaming;
1139
+ if (needsRender) trailing = 3;
1140
+ else if (trailing > 0) trailing--;
1141
+ if (!needsRender && trailing <= 0) return; // idle → let the world render on-demand
1142
+
1143
+ // Drive the OBC render so new tiles appear + Spark re-sorts for this camera.
1144
+ ctx.world.renderer.update();
1145
+
1146
+ // POSTPRO-OFF FALLBACK: in deferred-occlusion mode the splats live in the
1147
+ // private splatScene and only draw via the splatOcclusionPass — which the
1148
+ // pipeline fires only while postproduction is ON. If the user turns
1149
+ // postproduction OFF at runtime (graphics panel), draw splatScene ourselves
1150
+ // in a forward pass over the canvas (autoClear off so we keep the BIM the
1151
+ // world just rendered; depth-tested against the BIM depth it wrote).
1152
+ if (
1153
+ ctx.deferredOcclusion &&
1154
+ ctx.splatScene &&
1155
+ ctx.postpro &&
1156
+ ctx.postpro.enabled === false
1157
+ ) {
1158
+ applySplatDepthState(ctx.splatScene);
1159
+ const r = ctx.renderer3;
1160
+ const prevAutoClear = r.autoClear;
1161
+ r.autoClear = false;
1162
+ r.setRenderTarget(null);
1163
+ r.render(ctx.splatScene, cam);
1164
+ r.autoClear = prevAutoClear;
1165
+ }
1166
+ }
1167
+ tick();
1168
+ return ctx;
1169
+ }
1170
+
1171
+ // ───────────────────────────────────────────────────────────────────────
1172
+ // PHASE 0 — CO-LOCATED MODE: splats/points in the BIM world scene.
1173
+ //
1174
+ // Borrows the OBC world's renderer + scene + camera instead of owning them, so
1175
+ // the dataset sits in the same 3D space as the .frag model under one camera.
1176
+ // MULTIPLE datasets can be loaded at once (each a WorldDataset under the shared
1177
+ // WorldCtx). Occlusion: with postproduction OFF (default), three renders opaque
1178
+ // BIM first (writes depth) then Spark's transparent splats (depthTest:true) —
1179
+ // splats behind walls are clipped FOR FREE. Governor re-driven off the OBC
1180
+ // camera-controls events; the overlay's idle-snapshot is dropped.
1181
+ // ───────────────────────────────────────────────────────────────────────
1182
+ async function loadIntoWorld(fileId: string, opts: LoadIntoWorldOpts = {}) {
1183
+ if (!activeClient) {
1184
+ throw new Error(
1185
+ "realityCaptureViewer: no platform client — pass it to the factory or call setClient() first",
1186
+ );
1187
+ }
1188
+ clearOverlay(); // overlay and co-located are alternative screens
1189
+ if (worldDatasets.has(fileId)) removeDataset(fileId); // reload replaces in place
1190
+
1191
+ const ctx = ensureWorldCtx(opts.keepPostproduction === true);
1192
+
1193
+ // Root group carries the user alignment transform (dataset-local → world).
1194
+ // matrixAutoUpdate stays ON so TransformControls can drive it via TRS; we set
1195
+ // the transform by DECOMPOSING a matrix. Splats are Y-up → flip the inner
1196
+ // container so the alignment transform on `root` stays in BIM world space.
1197
+ // In deferred-occlusion mode the dataset lives in the private splatScene
1198
+ // (drawn by the occlusion pass); otherwise in world.scene (forward path).
1199
+ const contentScene = ctx.splatScene ?? ctx.scene3;
1200
+ const root = new THREE.Group();
1201
+ if (opts.transform) opts.transform.decompose(root.position, root.quaternion, root.scale);
1202
+ contentScene.add(root);
1203
+ const splatContainer = new THREE.Group();
1204
+ splatContainer.rotation.x = Math.PI;
1205
+ const pointContainer = new THREE.Group();
1206
+ root.add(splatContainer, pointContainer);
1207
+
1208
+ const ds: WorldDataset = {
1209
+ id: fileId, root, splatContainer, pointContainer,
1210
+ tiles: null, spark: null, format: "points",
1211
+ splatBudget: SPLAT_MOTION_BUDGET, pointBudget: POINT_MOTION_BUDGET, splatOpacity: 1,
1212
+ visible: true, originFitted: false, hasExplicitTransform: !!opts.transform,
1213
+ parentScene: contentScene,
1214
+ onTransformChange: opts.onTransformChange,
1215
+ emitTransform() {
1216
+ root.updateMatrix();
1217
+ this.onTransformChange?.(root.matrix.clone());
1218
+ },
1219
+ dispose() {
1220
+ try { this.tiles?.dispose(); } catch { /* noop */ }
1221
+ try { this.spark?.dispose?.(); } catch { /* noop */ }
1222
+ try {
1223
+ if (this.spark) this.parentScene.remove(this.spark);
1224
+ this.parentScene.remove(root);
1225
+ } catch { /* noop */ }
1226
+ },
1227
+ };
1228
+ worldDatasets.set(fileId, ds);
1229
+ setActiveDataset(fileId); // attach the gizmo to the newest dataset
1230
+ if (opts.gizmo === false) {
1231
+ ctx.gizmoEnabled = false;
1232
+ ctx.gizmoHelper.visible = false;
1233
+ } else {
1234
+ ctx.gizmoHelper.visible = ctx.gizmoEnabled;
1235
+ }
1236
+
1237
+ // --- download + parse the tileset ------------------------------------
1238
+ // Guarded: a missing/forbidden fileId, a network failure, or a malformed
1239
+ // tileset.json must not throw unhandled or leave a half-set-up dataset
1240
+ // (ctx/gizmo/occlusion-pass registered but no tiles). On any failure we tear
1241
+ // the partial dataset down (removeDataset → also drops the ctx if it was the
1242
+ // only one) and rethrow so the caller (files-panel) surfaces it.
1243
+ let tilesetBytes: Uint8Array;
1244
+ let tsj: any;
1245
+ try {
1246
+ const res = await activeClient.downloadFile(fileId);
1247
+ tilesetBytes = new Uint8Array(await res.arrayBuffer());
1248
+ if (!worldDatasets.has(fileId)) return; // removed while downloading
1249
+ tsj = JSON.parse(new TextDecoder().decode(tilesetBytes));
1250
+ if (!tsj?.root) throw new Error("tileset.json has no root");
1251
+ } catch (e) {
1252
+ removeDataset(fileId);
1253
+ throw new Error(
1254
+ `reality-capture: failed to load tileset ${fileId} into the world: ${
1255
+ e instanceof Error ? e.message : String(e)
1256
+ }`,
1257
+ );
1258
+ }
1259
+ const hiddenFiles: Record<string, string> = tsj.hiddenFiles || {};
1260
+ const uris: string[] = [];
1261
+ collectContentURIs(tsj.root, uris);
1262
+ ds.format = detectFormat(uris);
1263
+
1264
+ const tiles = new TilesRenderer("mem://t/tileset.json");
1265
+ ds.tiles = tiles;
1266
+ tiles.registerPlugin(
1267
+ new HiddenTilesPlugin({ baseUrl: "mem://t", tilesetBytes, hiddenFiles, client: activeClient }),
1268
+ );
1269
+ // A tile that fails to download/parse mid-stream should degrade (that tile is
1270
+ // skipped) rather than spam — log once per tile via the renderer's own error.
1271
+ tiles.addEventListener("load-error", (e: any) => {
1272
+ console.warn("[reality-capture] tile load error:", e?.url ?? e?.error ?? e);
1273
+ });
1274
+
1275
+ if (ds.format === "splats") {
1276
+ ds.spark = new SparkRenderer({ renderer: ctx.renderer3, clipXY: 1.0 } as any);
1277
+ ds.parentScene.add(ds.spark); // world.scene (forward) or splatScene (deferred)
1278
+ tiles.registerPlugin(new SplatTilePlugin());
1279
+ tiles.errorTarget = SPLAT_MOTION_ET;
1280
+ configureCache(tiles, 280, 420);
1281
+ setSplatJobs(tiles, 1);
1282
+ splatContainer.add(tiles.group);
1283
+ } else {
1284
+ tiles.registerPlugin(new PointTilePlugin());
1285
+ tiles.errorTarget = POINT_MOTION_ET;
1286
+ configureCache(tiles, 1500, 768);
1287
+ try { (tiles as any).parseQueue.maxJobs = 16; } catch { /* noop */ }
1288
+ pointContainer.add(tiles.group);
1289
+ }
1290
+ ctx.overlay?.refresh(); // format now known → show splat-only controls (opacity)
1291
+
1292
+ // AUTO-FIT (only when no explicit transform): centre the dataset at the BIM
1293
+ // world origin so it lands near the model, ready to be nudged.
1294
+ tiles.addEventListener("load-tileset", () => {
1295
+ if (ds.originFitted || !worldDatasets.has(fileId)) return;
1296
+ ds.originFitted = true;
1297
+ if (!ds.hasExplicitTransform) {
1298
+ const sphere = new THREE.Sphere();
1299
+ tiles.getBoundingSphere(sphere);
1300
+ const c = sphere.center; // splat container flips Y/Z; account for it
1301
+ const worldCenter =
1302
+ ds.format === "splats" ? new THREE.Vector3(c.x, -c.y, -c.z) : c.clone();
1303
+ root.position.set(-worldCenter.x, -worldCenter.y, -worldCenter.z);
1304
+ ds.emitTransform(); // persist the auto-fit as the initial alignment
1305
+ }
1306
+ });
1307
+ }
1308
+
1309
+ rcApi = {
1310
+ loadThreeTZ,
1311
+ loadIntoWorld,
1312
+ // All co-located controls target a specific dataset by fileId, or the ACTIVE
1313
+ // one (the most recently loaded / last selected) when fileId is omitted.
1314
+ setTransform(matrix: THREE.Matrix4, fileId?: string) {
1315
+ const ds = targetDataset(fileId);
1316
+ if (ds) {
1317
+ // root.matrixAutoUpdate is on (for the gizmo) → drive TRS, not .matrix.
1318
+ matrix.decompose(ds.root.position, ds.root.quaternion, ds.root.scale);
1319
+ ds.hasExplicitTransform = true;
1320
+ }
1321
+ },
1322
+ getTransform(fileId?: string) {
1323
+ const ds = targetDataset(fileId);
1324
+ if (!ds) return null;
1325
+ ds.root.updateMatrix();
1326
+ return ds.root.matrix.clone();
1327
+ },
1328
+ showGizmo(on: boolean) {
1329
+ if (!worldCtx) return;
1330
+ worldCtx.gizmoEnabled = on;
1331
+ worldCtx.gizmoHelper.visible = on && (activeDataset()?.visible ?? false);
1332
+ },
1333
+ setGizmoMode(mode: "translate" | "rotate" | "scale") {
1334
+ worldCtx?.gizmo.setMode(mode);
1335
+ },
1336
+ setActiveDataset(fileId: string) {
1337
+ setActiveDataset(fileId);
1338
+ },
1339
+ removeDataset(fileId: string) {
1340
+ removeDataset(fileId);
1341
+ },
1342
+ listDatasets() {
1343
+ return [...worldDatasets.keys()];
1344
+ },
1345
+ setDatasetVisible(on: boolean, fileId?: string) {
1346
+ const ds = targetDataset(fileId);
1347
+ if (!ds || !worldCtx) return;
1348
+ ds.visible = on;
1349
+ ds.root.visible = on;
1350
+ if (ds.spark) ds.spark.visible = on;
1351
+ if (ds === activeDataset()) {
1352
+ worldCtx.gizmoHelper.visible = on && worldCtx.gizmoEnabled;
1353
+ }
1354
+ try { worldCtx.world.renderer.update(); } catch { /* noop */ }
1355
+ },
1356
+ isDatasetVisible(fileId?: string) {
1357
+ return targetDataset(fileId)?.visible ?? false;
1358
+ },
1359
+ setPointSize(px: number) {
1360
+ setPointSizePx(px);
1361
+ },
1362
+ setSplatOpacity(opacity: number, fileId?: string) {
1363
+ const ds = targetDataset(fileId);
1364
+ if (!ds) return;
1365
+ ds.splatOpacity = THREE.MathUtils.clamp(opacity, 0, 1);
1366
+ ds.splatContainer.traverse((o: any) => {
1367
+ if (o.userData?.splatCount && typeof o.opacity === "number") o.opacity = ds.splatOpacity;
1368
+ });
1369
+ },
1370
+ setMotionBudget(count: number, fileId?: string) {
1371
+ const ds = targetDataset(fileId);
1372
+ if (!ds) return;
1373
+ const v = Math.max(1000, Math.floor(count));
1374
+ if (ds.format === "splats") ds.splatBudget = v;
1375
+ else ds.pointBudget = v;
1376
+ },
1377
+ setClient(c: ThreeTZClient) {
1378
+ activeClient = c;
1379
+ },
1380
+ clear,
1381
+ };
1382
+ return rcApi;
1383
+ }