@thatopen/services 0.3.16 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -62
- package/dist/cli/commands/rhino.d.ts +11 -0
- package/dist/cli/lib/plugin-install.d.ts +24 -0
- package/dist/cli.js +124 -1
- package/dist/core/client.d.ts +1 -0
- package/dist/core/platform-client.d.ts +7 -0
- package/dist/index.cjs.js +2 -2
- package/dist/index.es.js +382 -370
- package/dist/vite-env.d.ts +1 -1
- package/docs/ai-quickstart.md +130 -130
- package/docs/cli/create.md +29 -29
- package/docs/cli/local-server.md +38 -38
- package/docs/cli/login.md +32 -32
- package/docs/cli/publish.md +31 -31
- package/docs/cli/run.md +26 -26
- package/docs/cli/serve.md +43 -43
- package/docs/cli/swap.md +30 -30
- package/docs/cloud/GaussianSplatConverter.md +43 -43
- package/docs/cloud/PointCloudConverter.md +53 -53
- package/docs/revit-collab-quickstart.md +256 -256
- package/package.json +95 -95
- package/resources/AGENTS.md +40 -40
- package/src/built-in/CDEManager/example.ts +214 -214
- package/src/built-in/CDEManager/examples/FileEditing/example.ts +226 -226
- package/src/built-in/CDEManager/examples/FileMoves/example.ts +186 -186
- package/src/built-in/CDEManager/examples/FileOperations/example.ts +295 -295
- package/src/built-in/CDEManager/examples/FileTransfers/example.ts +210 -210
- package/src/built-in/CDEManager/examples/FolderManagement/example.ts +230 -230
- package/src/built-in/CDEManager/examples/MetadataSchema/example.ts +230 -230
- package/src/built-in/CDEManager/examples/MetadataViews/example.ts +166 -166
- package/src/built-in/CDEManager/examples/ProjectConfig/example.ts +177 -177
- package/src/built-in/ClashesManager/example.ts +283 -283
- package/src/built-in/UIManager/src/app/example.ts +97 -97
- package/src/built-in/UIManager/src/app/examples/AppState/example.ts +140 -140
- package/src/built-in/UIManager/src/app/examples/Banner/example.ts +159 -159
- package/src/built-in/UIManager/src/app/examples/Contexts/example.ts +123 -123
- package/src/built-in/UIManager/src/app/examples/FileDrop/example.ts +124 -124
- package/src/built-in/UIManager/src/app/examples/FloatingLayout/example.ts +90 -90
- package/src/built-in/UIManager/src/app/examples/Modal/example.ts +163 -163
- package/src/built-in/UIManager/src/app/examples/Notifications/example.ts +133 -133
- package/src/built-in/UIManager/src/app/examples/Sidebar/example.ts +102 -102
- package/src/cli/templates/app/README.md +217 -217
- package/src/cli/templates/app/index.html +35 -35
- package/src/cli/templates/app/package.json +32 -32
- package/src/cli/templates/app/src/app.ts +37 -37
- package/src/cli/templates/app/src/assets/tool-placeholder.ts +50 -50
- package/src/cli/templates/app/src/bim-components/CloudRunner/index.ts +95 -95
- package/src/cli/templates/app/src/bim-components/CloudRunner/src/index.ts +1 -1
- package/src/cli/templates/app/src/bim-components/CloudRunner/src/types.ts +5 -5
- package/src/cli/templates/app/src/bim-components/index.ts +1 -1
- package/src/cli/templates/app/src/globals.ts +1 -1
- package/src/cli/templates/app/src/main.ts +199 -199
- package/src/cli/templates/app/src/setups/reality-capture/lib/hidden-tiles-plugin.ts +88 -88
- package/src/cli/templates/app/src/setups/reality-capture/lib/point-tile-plugin.ts +166 -166
- package/src/cli/templates/app/src/setups/reality-capture/lib/render-frame.ts +31 -31
- package/src/cli/templates/app/src/setups/reality-capture/lib/splat-tile-plugin.ts +51 -51
- package/src/cli/templates/app/src/setups/reality-capture-viewer.ts +1383 -1383
- package/src/cli/templates/app/src/ui-components/app-info-section/index.ts +26 -26
- package/src/cli/templates/app/src/ui-components/app-info-section/src/index.ts +1 -1
- package/src/cli/templates/app/src/ui-components/app-info-section/src/types.ts +15 -15
- package/src/cli/templates/app/src/ui-components/cloud-runner-section/index.ts +37 -37
- package/src/cli/templates/app/src/ui-components/cloud-runner-section/src/index.ts +1 -1
- package/src/cli/templates/app/src/ui-components/cloud-runner-section/src/types.ts +14 -14
- package/src/cli/templates/app/src/ui-components/index.ts +2 -2
- package/src/cli/templates/app/tsconfig.json +16 -16
- package/src/cli/templates/app/vite.config.js +57 -57
- package/src/cli/templates/cloud-component/README.md +205 -205
- package/src/cli/templates/cloud-component/_thatopen +5 -5
- package/src/cli/templates/cloud-component/declarations.json +4 -4
- package/src/cli/templates/cloud-component/package.json +21 -21
- package/src/cli/templates/cloud-component/src/main.ts +70 -70
- package/src/cli/templates/cloud-component/tsconfig.json +16 -16
- package/src/cli/templates/cloud-component/vite.config.js +46 -46
- package/src/cli/templates/shared/AGENTS.md +8 -8
- package/src/cli/templates/shared/CLAUDE.md +1 -1
- package/src/cli/templates/shared/_gitignore +5 -5
|
@@ -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
|
+
}
|