@ifc-lite/viewer 1.19.0 → 1.19.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +15 -14
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +8 -0
- package/dist/assets/basketViewActivator-CA2CTcVo.js +71 -0
- package/dist/assets/{bcf-DOG9_WPX.js → bcf-4K724hw0.js} +18 -18
- package/dist/assets/{exporters-BraHBeoi.js → exporters-xbXqEDlO.js} +53 -46
- package/dist/assets/ids-2WdONLlu.js +2033 -0
- package/dist/assets/index-BXeEKqJG.css +1 -0
- package/dist/assets/{index-BOi3BuUI.js → index-D8Epw-e7.js} +48072 -30928
- package/dist/assets/{native-bridge-CpBeOPQa.js → native-bridge-DKmx1z95.js} +2 -2
- package/dist/assets/{sandbox-Baez7n-t.js → sandbox-tccwm5Bo.js} +547 -529
- package/dist/assets/{server-client-BB6cMAXE.js → server-client-LoWPK1N2.js} +1 -1
- package/dist/assets/three-CDRZThFA.js +4057 -0
- package/dist/assets/{wasm-bridge-CAYCUHbE.js → wasm-bridge-BsJGgPMs.js} +1 -1
- package/dist/index.html +8 -7
- package/dist/samples/building-architecture.ifc +453 -0
- package/dist/samples/hello-wall.ifc +1054 -0
- package/dist/samples/infra-bridge.ifc +962 -0
- package/package.json +7 -2
- package/public/samples/building-architecture.ifc +453 -0
- package/public/samples/hello-wall.ifc +1054 -0
- package/public/samples/infra-bridge.ifc +962 -0
- package/src/App.tsx +37 -3
- package/src/components/mcp/HeroScene.tsx +876 -0
- package/src/components/mcp/McpLanding.tsx +1318 -0
- package/src/components/mcp/McpPlayground.tsx +524 -0
- package/src/components/mcp/PlaygroundChat.tsx +1097 -0
- package/src/components/mcp/PlaygroundViewer.tsx +815 -0
- package/src/components/mcp/README.md +171 -0
- package/src/components/mcp/data.ts +659 -0
- package/src/components/mcp/playground-dispatcher.ts +1649 -0
- package/src/components/mcp/playground-files.ts +107 -0
- package/src/components/mcp/playground-uploads.ts +122 -0
- package/src/components/mcp/types.ts +65 -0
- package/src/components/mcp/use-mcp-page.ts +109 -0
- package/src/components/viewer/MainToolbar.tsx +19 -0
- package/src/components/viewer/ViewportContainer.tsx +35 -4
- package/src/generated/mcp-catalog.json +82 -0
- package/vite.config.ts +6 -0
- package/dist/assets/basketViewActivator-RZy5c3Td.js +0 -1
- package/dist/assets/ids-DQ5jY0E8.js +0 -1
- package/dist/assets/index-0XpVr_S5.css +0 -1
|
@@ -0,0 +1,815 @@
|
|
|
1
|
+
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* PlaygroundViewer — collapsible inline 3D viewer for /mcp/playground.
|
|
7
|
+
*
|
|
8
|
+
* Loads geometry from a parsed `IfcDataStore` via @ifc-lite/geometry's WASM
|
|
9
|
+
* processor (`GeometryProcessor.process(buffer)`), renders one Three.js
|
|
10
|
+
* mesh per IFC entity so each can be coloured / hidden / picked
|
|
11
|
+
* individually, and exposes an imperative `ViewerController` that the
|
|
12
|
+
* agent's tool dispatcher drives.
|
|
13
|
+
*
|
|
14
|
+
* Why per-entity meshes (not a merged mesh): the agent loop calls things
|
|
15
|
+
* like `viewer_colorize({ global_ids: [...] })` and we need to flip just
|
|
16
|
+
* those entities. Sharing one BufferGeometry would force per-vertex colour
|
|
17
|
+
* attributes + a custom shader pass, which is overkill for the playground
|
|
18
|
+
* scale (≤ ~1k visible entities for the bundled samples).
|
|
19
|
+
*
|
|
20
|
+
* Geometry processing is async + heavy → only fired the first time the
|
|
21
|
+
* panel is opened, then the result is cached.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
forwardRef,
|
|
26
|
+
useEffect,
|
|
27
|
+
useImperativeHandle,
|
|
28
|
+
useRef,
|
|
29
|
+
useState,
|
|
30
|
+
} from 'react';
|
|
31
|
+
import * as THREE from 'three';
|
|
32
|
+
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
|
33
|
+
import { GeometryProcessor, type MeshData } from '@ifc-lite/geometry';
|
|
34
|
+
import { EntityNode } from '@ifc-lite/query';
|
|
35
|
+
import { cn } from '@/lib/utils';
|
|
36
|
+
import type { LoadedPlaygroundModel } from './playground-dispatcher';
|
|
37
|
+
|
|
38
|
+
const NIGHT = 0x0a0a0c;
|
|
39
|
+
const ACCENT = 0xd6ff3f;
|
|
40
|
+
const BG_COLOR = '#0e0e12';
|
|
41
|
+
|
|
42
|
+
// ── controller surface used by the dispatcher ──────────────────────────────
|
|
43
|
+
|
|
44
|
+
export interface SelectionHit {
|
|
45
|
+
expressId: number;
|
|
46
|
+
globalId?: string;
|
|
47
|
+
ifcType?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ViewerStatus {
|
|
51
|
+
loaded: boolean;
|
|
52
|
+
meshCount: number;
|
|
53
|
+
selection: SelectionHit[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type ColorTuple = [number, number, number, number];
|
|
57
|
+
|
|
58
|
+
export interface ViewerController {
|
|
59
|
+
isLoaded(): boolean;
|
|
60
|
+
status(): ViewerStatus;
|
|
61
|
+
/** Colour the selected entities. Pass null/undefined to default to all. */
|
|
62
|
+
colorize(args: { globalIds?: string[]; expressIds?: number[]; type?: string; color: ColorTuple }): { count: number };
|
|
63
|
+
isolate(args: { globalIds?: string[]; expressIds?: number[]; type?: string }): { count: number };
|
|
64
|
+
hide(args: { globalIds?: string[]; expressIds?: number[]; type?: string }): { count: number };
|
|
65
|
+
show(args: { globalIds?: string[]; expressIds?: number[]; type?: string }): { count: number };
|
|
66
|
+
reset(): void;
|
|
67
|
+
flyTo(args: { globalIds?: string[]; expressIds?: number[] }): { count: number };
|
|
68
|
+
setSection(args: { axis: 'x' | 'y' | 'z'; position: number }): void;
|
|
69
|
+
clearSection(): void;
|
|
70
|
+
colorByStorey(): { groups: number };
|
|
71
|
+
colorByProperty(args: {
|
|
72
|
+
type: string;
|
|
73
|
+
pset: string;
|
|
74
|
+
property: string;
|
|
75
|
+
sample: (expressId: number) => string | number | boolean | null;
|
|
76
|
+
}): { legend: Array<{ value: string; count: number; color: ColorTuple }> };
|
|
77
|
+
getSelection(): SelectionHit[];
|
|
78
|
+
setOnSelectionChange(handler: ((hits: SelectionHit[]) => void) | null): void;
|
|
79
|
+
/** Multi-subscriber. Returns an unsubscribe — safe to call from tools
|
|
80
|
+
* that need a temporary listener without clobbering the panel's. */
|
|
81
|
+
subscribeSelection(handler: (hits: SelectionHit[]) => void): () => void;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── component ──────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
export interface PlaygroundViewerProps {
|
|
87
|
+
/** Currently loaded model (or null). When this changes, the viewer reloads. */
|
|
88
|
+
model: LoadedPlaygroundModel | null;
|
|
89
|
+
/** Notified once geometry has been processed. */
|
|
90
|
+
onReady?: () => void;
|
|
91
|
+
/** Optional className to control sizing. */
|
|
92
|
+
className?: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The viewer is mounted/unmounted by the parent. Geometry processing is
|
|
97
|
+
* triggered the first time `model` becomes non-null AND the parent shows
|
|
98
|
+
* the panel — driven by the parent unmounting the component when the
|
|
99
|
+
* panel collapses (saves GPU memory on long sessions).
|
|
100
|
+
*/
|
|
101
|
+
export const PlaygroundViewer = forwardRef<ViewerController, PlaygroundViewerProps>(function PlaygroundViewer(
|
|
102
|
+
{ model, onReady, className },
|
|
103
|
+
ref,
|
|
104
|
+
) {
|
|
105
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
106
|
+
const sceneHandleRef = useRef<SceneHandle | null>(null);
|
|
107
|
+
const [phase, setPhase] = useState<'idle' | 'processing' | 'ready' | 'error'>('idle');
|
|
108
|
+
const [phaseMsg, setPhaseMsg] = useState<string>('');
|
|
109
|
+
const [meshCount, setMeshCount] = useState(0);
|
|
110
|
+
|
|
111
|
+
useImperativeHandle(
|
|
112
|
+
ref,
|
|
113
|
+
(): ViewerController => ({
|
|
114
|
+
isLoaded: () => sceneHandleRef.current !== null,
|
|
115
|
+
status: () => ({
|
|
116
|
+
loaded: sceneHandleRef.current !== null,
|
|
117
|
+
meshCount,
|
|
118
|
+
selection: sceneHandleRef.current?.getSelection() ?? [],
|
|
119
|
+
}),
|
|
120
|
+
colorize: (args) => sceneHandleRef.current?.colorize(args) ?? { count: 0 },
|
|
121
|
+
isolate: (args) => sceneHandleRef.current?.isolate(args) ?? { count: 0 },
|
|
122
|
+
hide: (args) => sceneHandleRef.current?.hide(args) ?? { count: 0 },
|
|
123
|
+
show: (args) => sceneHandleRef.current?.show(args) ?? { count: 0 },
|
|
124
|
+
reset: () => sceneHandleRef.current?.reset(),
|
|
125
|
+
flyTo: (args) => sceneHandleRef.current?.flyTo(args) ?? { count: 0 },
|
|
126
|
+
setSection: (args) => sceneHandleRef.current?.setSection(args),
|
|
127
|
+
clearSection: () => sceneHandleRef.current?.clearSection(),
|
|
128
|
+
colorByStorey: () => sceneHandleRef.current?.colorByStorey() ?? { groups: 0 },
|
|
129
|
+
colorByProperty: (args) => sceneHandleRef.current?.colorByProperty(args) ?? { legend: [] },
|
|
130
|
+
getSelection: () => sceneHandleRef.current?.getSelection() ?? [],
|
|
131
|
+
setOnSelectionChange: (h) => sceneHandleRef.current?.setOnSelectionChange(h),
|
|
132
|
+
subscribeSelection: (h) => sceneHandleRef.current?.subscribeSelection(h) ?? (() => undefined),
|
|
133
|
+
}),
|
|
134
|
+
[meshCount],
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
// Mount Three.js once.
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
const container = containerRef.current;
|
|
140
|
+
if (!container) return;
|
|
141
|
+
const handle = createScene(container);
|
|
142
|
+
sceneHandleRef.current = handle;
|
|
143
|
+
return () => {
|
|
144
|
+
handle.dispose();
|
|
145
|
+
sceneHandleRef.current = null;
|
|
146
|
+
};
|
|
147
|
+
}, []);
|
|
148
|
+
|
|
149
|
+
// Load geometry whenever the model changes (and the component is mounted —
|
|
150
|
+
// the parent decides when to mount us).
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
let cancelled = false;
|
|
153
|
+
if (!model) {
|
|
154
|
+
sceneHandleRef.current?.unloadModel();
|
|
155
|
+
setPhase('idle');
|
|
156
|
+
setMeshCount(0);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
void (async () => {
|
|
160
|
+
setPhase('processing');
|
|
161
|
+
setPhaseMsg('booting geometry pipeline…');
|
|
162
|
+
try {
|
|
163
|
+
const processor = new GeometryProcessor({ preferNative: false });
|
|
164
|
+
await processor.init();
|
|
165
|
+
setPhaseMsg('extracting geometry…');
|
|
166
|
+
// Use our owning byte snapshot — store.source can be a sub-view that
|
|
167
|
+
// the parser detached internally on big files.
|
|
168
|
+
const result = await processor.process(
|
|
169
|
+
model.bytes,
|
|
170
|
+
model.store.entityIndex.byId as unknown as Map<number, unknown>,
|
|
171
|
+
);
|
|
172
|
+
if (cancelled) return;
|
|
173
|
+
const meshes = result.meshes ?? [];
|
|
174
|
+
// eslint-disable-next-line no-console
|
|
175
|
+
console.log('[playground-viewer] geometry result:', {
|
|
176
|
+
meshCount: meshes.length,
|
|
177
|
+
firstMeshVerts: meshes[0]?.positions?.length,
|
|
178
|
+
coordinateInfo: result.coordinateInfo,
|
|
179
|
+
});
|
|
180
|
+
if (meshes.length === 0) {
|
|
181
|
+
setPhase('error');
|
|
182
|
+
setPhaseMsg('No drawable geometry — model may be schema-only.');
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
sceneHandleRef.current?.loadMeshes(meshes, model);
|
|
186
|
+
setMeshCount(meshes.length);
|
|
187
|
+
setPhase('ready');
|
|
188
|
+
onReady?.();
|
|
189
|
+
} catch (err) {
|
|
190
|
+
if (cancelled) return;
|
|
191
|
+
// eslint-disable-next-line no-console
|
|
192
|
+
console.error('[playground-viewer] geometry processing failed', err);
|
|
193
|
+
setPhase('error');
|
|
194
|
+
setPhaseMsg(err instanceof Error ? err.message : String(err));
|
|
195
|
+
}
|
|
196
|
+
})();
|
|
197
|
+
return () => { cancelled = true; };
|
|
198
|
+
}, [model, onReady]);
|
|
199
|
+
|
|
200
|
+
return (
|
|
201
|
+
// The outer wrapper must be a positioning context for the absolute
|
|
202
|
+
// canvas container below. We use a Tailwind class for `relative` so it
|
|
203
|
+
// doesn't fight the parent-supplied `className` (the parent typically
|
|
204
|
+
// passes `absolute inset-0` to drop us into a sized box).
|
|
205
|
+
<div
|
|
206
|
+
className={cn('relative', className ?? 'h-full w-full')}
|
|
207
|
+
style={{ background: BG_COLOR }}
|
|
208
|
+
>
|
|
209
|
+
<div ref={containerRef} style={{ position: 'absolute', inset: 0 }} />
|
|
210
|
+
{/* phase HUD — small hairline tag so the user can see whether
|
|
211
|
+
geometry processing actually landed even when the canvas is dark */}
|
|
212
|
+
{phase === 'ready' && (
|
|
213
|
+
<div
|
|
214
|
+
style={{
|
|
215
|
+
position: 'absolute',
|
|
216
|
+
top: 8,
|
|
217
|
+
left: 10,
|
|
218
|
+
color: '#d6ff3f',
|
|
219
|
+
fontFamily: '"JetBrains Mono", monospace',
|
|
220
|
+
fontSize: 10,
|
|
221
|
+
letterSpacing: '0.18em',
|
|
222
|
+
textTransform: 'uppercase',
|
|
223
|
+
pointerEvents: 'none',
|
|
224
|
+
opacity: 0.7,
|
|
225
|
+
}}
|
|
226
|
+
>
|
|
227
|
+
● {meshCount} meshes
|
|
228
|
+
</div>
|
|
229
|
+
)}
|
|
230
|
+
{phase !== 'ready' && (
|
|
231
|
+
<div
|
|
232
|
+
style={{
|
|
233
|
+
position: 'absolute',
|
|
234
|
+
inset: 0,
|
|
235
|
+
display: 'flex',
|
|
236
|
+
alignItems: 'center',
|
|
237
|
+
justifyContent: 'center',
|
|
238
|
+
color: phase === 'error' ? '#ff8d8d' : 'rgba(237,228,211,0.55)',
|
|
239
|
+
fontFamily: '"JetBrains Mono", monospace',
|
|
240
|
+
fontSize: 11,
|
|
241
|
+
background: BG_COLOR,
|
|
242
|
+
padding: 16,
|
|
243
|
+
textAlign: 'center',
|
|
244
|
+
}}
|
|
245
|
+
>
|
|
246
|
+
{phase === 'processing' && (
|
|
247
|
+
<span>
|
|
248
|
+
<span className="inline-block animate-pulse">●</span> {phaseMsg || 'preparing…'}
|
|
249
|
+
</span>
|
|
250
|
+
)}
|
|
251
|
+
{phase === 'error' && <span>⚠ {phaseMsg}</span>}
|
|
252
|
+
{phase === 'idle' && <span>load a model first</span>}
|
|
253
|
+
</div>
|
|
254
|
+
)}
|
|
255
|
+
</div>
|
|
256
|
+
);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// ── scene factory + per-entity book-keeping ────────────────────────────────
|
|
260
|
+
|
|
261
|
+
interface SceneHandle {
|
|
262
|
+
loadMeshes(meshes: MeshData[], model: LoadedPlaygroundModel): void;
|
|
263
|
+
unloadModel(): void;
|
|
264
|
+
colorize(args: { globalIds?: string[]; expressIds?: number[]; type?: string; color: ColorTuple }): { count: number };
|
|
265
|
+
isolate(args: { globalIds?: string[]; expressIds?: number[]; type?: string }): { count: number };
|
|
266
|
+
hide(args: { globalIds?: string[]; expressIds?: number[]; type?: string }): { count: number };
|
|
267
|
+
show(args: { globalIds?: string[]; expressIds?: number[]; type?: string }): { count: number };
|
|
268
|
+
reset(): void;
|
|
269
|
+
flyTo(args: { globalIds?: string[]; expressIds?: number[] }): { count: number };
|
|
270
|
+
setSection(args: { axis: 'x' | 'y' | 'z'; position: number }): void;
|
|
271
|
+
clearSection(): void;
|
|
272
|
+
colorByStorey(): { groups: number };
|
|
273
|
+
colorByProperty(args: {
|
|
274
|
+
type: string;
|
|
275
|
+
pset: string;
|
|
276
|
+
property: string;
|
|
277
|
+
sample: (expressId: number) => string | number | boolean | null;
|
|
278
|
+
}): { legend: Array<{ value: string; count: number; color: ColorTuple }> };
|
|
279
|
+
getSelection(): SelectionHit[];
|
|
280
|
+
setOnSelectionChange(handler: ((hits: SelectionHit[]) => void) | null): void;
|
|
281
|
+
subscribeSelection(handler: (hits: SelectionHit[]) => void): () => void;
|
|
282
|
+
dispose(): void;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
interface EntityRecord {
|
|
286
|
+
expressId: number;
|
|
287
|
+
globalId?: string;
|
|
288
|
+
ifcType?: string;
|
|
289
|
+
storeyName?: string;
|
|
290
|
+
mesh: THREE.Mesh;
|
|
291
|
+
baseColor: THREE.Color;
|
|
292
|
+
baseOpacity: number;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function createScene(container: HTMLElement): SceneHandle {
|
|
296
|
+
// ── Renderer ─────────────────────────────────────────────────────────────
|
|
297
|
+
// Mirrors examples/threejs-viewer EXACTLY (renderer setup, lighting,
|
|
298
|
+
// material settings, camera). The only difference is that we render to
|
|
299
|
+
// a divs-attached canvas (not document.getElementById('viewer')).
|
|
300
|
+
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, powerPreference: 'high-performance' });
|
|
301
|
+
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
302
|
+
renderer.setSize(container.clientWidth, container.clientHeight, false);
|
|
303
|
+
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
304
|
+
renderer.toneMappingExposure = 1.0;
|
|
305
|
+
renderer.setClearColor(NIGHT, 1);
|
|
306
|
+
renderer.localClippingEnabled = true;
|
|
307
|
+
renderer.domElement.style.display = 'block';
|
|
308
|
+
renderer.domElement.style.width = '100%';
|
|
309
|
+
renderer.domElement.style.height = '100%';
|
|
310
|
+
container.appendChild(renderer.domElement);
|
|
311
|
+
|
|
312
|
+
const scene = new THREE.Scene();
|
|
313
|
+
scene.background = new THREE.Color(NIGHT);
|
|
314
|
+
|
|
315
|
+
const camera = new THREE.PerspectiveCamera(50, container.clientWidth / Math.max(1, container.clientHeight), 0.1, 10000);
|
|
316
|
+
camera.position.set(20, 15, 20);
|
|
317
|
+
camera.lookAt(0, 0, 0);
|
|
318
|
+
|
|
319
|
+
// Lighting (parity with the threejs-viewer example).
|
|
320
|
+
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
|
|
321
|
+
const key = new THREE.DirectionalLight(0xffffff, 0.8);
|
|
322
|
+
key.position.set(50, 80, 50);
|
|
323
|
+
scene.add(key);
|
|
324
|
+
const fill = new THREE.DirectionalLight(0xb0c4de, 0.3);
|
|
325
|
+
fill.position.set(-30, 10, -20);
|
|
326
|
+
scene.add(fill);
|
|
327
|
+
|
|
328
|
+
// Reusable group so loadMeshes can clear without affecting lights.
|
|
329
|
+
// No rotation here: @ifc-lite/geometry already converts IFC Z-up to
|
|
330
|
+
// Three.js Y-up at the vertex level (swap Y/Z + negate new Z to keep
|
|
331
|
+
// right-handedness). Adding a second rotation here was tipping the
|
|
332
|
+
// whole building on its side.
|
|
333
|
+
const modelGroup = new THREE.Group();
|
|
334
|
+
scene.add(modelGroup);
|
|
335
|
+
|
|
336
|
+
// Section plane (Y-axis in three space ↔ Z in IFC after rotation).
|
|
337
|
+
const sectionPlanes: THREE.Plane[] = [];
|
|
338
|
+
let activeSectionPlane: THREE.Plane | null = null;
|
|
339
|
+
|
|
340
|
+
// Controls.
|
|
341
|
+
const controls = new OrbitControls(camera, renderer.domElement);
|
|
342
|
+
controls.enableDamping = true;
|
|
343
|
+
controls.dampingFactor = 0.08;
|
|
344
|
+
|
|
345
|
+
// ── per-entity registry ─────────────────────────────────────────────────
|
|
346
|
+
const records: EntityRecord[] = [];
|
|
347
|
+
const byExpressId = new Map<number, EntityRecord>();
|
|
348
|
+
const byGlobalId = new Map<string, EntityRecord>();
|
|
349
|
+
const byType = new Map<string, EntityRecord[]>();
|
|
350
|
+
const byStorey = new Map<string, EntityRecord[]>();
|
|
351
|
+
let modelRef: LoadedPlaygroundModel | null = null;
|
|
352
|
+
let selection: SelectionHit[] = [];
|
|
353
|
+
// Multi-subscriber so a temporary listener (e.g. viewer_wait_for_selection)
|
|
354
|
+
// doesn't displace the panel's permanent one. Anything calling
|
|
355
|
+
// `setOnSelectionChange` keeps that single-handler convenience but
|
|
356
|
+
// routes through this set.
|
|
357
|
+
const selectionListeners = new Set<(hits: SelectionHit[]) => void>();
|
|
358
|
+
let convenienceListener: ((hits: SelectionHit[]) => void) | null = null;
|
|
359
|
+
function notifySelection(hits: SelectionHit[]) {
|
|
360
|
+
convenienceListener?.(hits);
|
|
361
|
+
for (const l of selectionListeners) l(hits);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ── Picking ─────────────────────────────────────────────────────────────
|
|
365
|
+
const raycaster = new THREE.Raycaster();
|
|
366
|
+
const pointer = new THREE.Vector2();
|
|
367
|
+
const SELECTION_COLOR = new THREE.Color(0xff5cdc);
|
|
368
|
+
|
|
369
|
+
function onPointerUp(e: PointerEvent) {
|
|
370
|
+
const rect = renderer.domElement.getBoundingClientRect();
|
|
371
|
+
pointer.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
|
372
|
+
pointer.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
|
373
|
+
raycaster.setFromCamera(pointer, camera);
|
|
374
|
+
const visibleMeshes = records.filter((r) => r.mesh.visible).map((r) => r.mesh);
|
|
375
|
+
const hits = raycaster.intersectObjects(visibleMeshes, false);
|
|
376
|
+
if (hits.length === 0) {
|
|
377
|
+
// Empty pick — clear selection.
|
|
378
|
+
clearSelectionHighlight();
|
|
379
|
+
selection = [];
|
|
380
|
+
notifySelection(selection);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
const hit = hits[0].object as THREE.Mesh;
|
|
384
|
+
const rec = records.find((r) => r.mesh === hit);
|
|
385
|
+
if (!rec) return;
|
|
386
|
+
clearSelectionHighlight();
|
|
387
|
+
(rec.mesh.material as THREE.MeshStandardMaterial).color.copy(SELECTION_COLOR);
|
|
388
|
+
selection = [{ expressId: rec.expressId, globalId: rec.globalId, ifcType: rec.ifcType }];
|
|
389
|
+
notifySelection(selection);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function clearSelectionHighlight() {
|
|
393
|
+
for (const r of records) {
|
|
394
|
+
(r.mesh.material as THREE.MeshStandardMaterial).color.copy(r.baseColor);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Drag-vs-click discrimination: only treat as click if the pointer didn't
|
|
399
|
+
// move more than 4 px between down + up.
|
|
400
|
+
let downX = 0, downY = 0;
|
|
401
|
+
renderer.domElement.addEventListener('pointerdown', (e) => { downX = e.clientX; downY = e.clientY; });
|
|
402
|
+
renderer.domElement.addEventListener('pointerup', (e) => {
|
|
403
|
+
if (Math.hypot(e.clientX - downX, e.clientY - downY) < 4) onPointerUp(e);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
// ── animation loop ──────────────────────────────────────────────────────
|
|
407
|
+
let raf = 0;
|
|
408
|
+
let disposed = false;
|
|
409
|
+
function tick() {
|
|
410
|
+
if (disposed) return;
|
|
411
|
+
raf = requestAnimationFrame(tick);
|
|
412
|
+
controls.update();
|
|
413
|
+
renderer.render(scene, camera);
|
|
414
|
+
}
|
|
415
|
+
tick();
|
|
416
|
+
|
|
417
|
+
// Resize.
|
|
418
|
+
const ro = new ResizeObserver(() => {
|
|
419
|
+
const w = container.clientWidth;
|
|
420
|
+
const h = container.clientHeight;
|
|
421
|
+
if (w === 0 || h === 0) return;
|
|
422
|
+
camera.aspect = w / Math.max(1, h);
|
|
423
|
+
camera.updateProjectionMatrix();
|
|
424
|
+
renderer.setSize(w, h, false);
|
|
425
|
+
});
|
|
426
|
+
ro.observe(container);
|
|
427
|
+
|
|
428
|
+
// ── helpers ─────────────────────────────────────────────────────────────
|
|
429
|
+
function clearModel() {
|
|
430
|
+
for (const r of records) {
|
|
431
|
+
r.mesh.geometry.dispose();
|
|
432
|
+
const mat = r.mesh.material as THREE.Material;
|
|
433
|
+
mat.dispose();
|
|
434
|
+
modelGroup.remove(r.mesh);
|
|
435
|
+
}
|
|
436
|
+
records.length = 0;
|
|
437
|
+
byExpressId.clear();
|
|
438
|
+
byGlobalId.clear();
|
|
439
|
+
byType.clear();
|
|
440
|
+
byStorey.clear();
|
|
441
|
+
selection = [];
|
|
442
|
+
modelRef = null;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function selectTargets(args: { globalIds?: string[]; expressIds?: number[]; type?: string }): EntityRecord[] {
|
|
446
|
+
const out = new Set<EntityRecord>();
|
|
447
|
+
if (args.expressIds) for (const id of args.expressIds) {
|
|
448
|
+
const r = byExpressId.get(id); if (r) out.add(r);
|
|
449
|
+
}
|
|
450
|
+
if (args.globalIds) for (const gid of args.globalIds) {
|
|
451
|
+
const r = byGlobalId.get(gid); if (r) out.add(r);
|
|
452
|
+
}
|
|
453
|
+
if (args.type) {
|
|
454
|
+
// Match by leading IfcType (case-insensitive). The geometry pipeline
|
|
455
|
+
// strips the "Ifc" prefix or upper-cases freely depending on schema,
|
|
456
|
+
// so we tolerate either form.
|
|
457
|
+
const want = args.type.toLowerCase();
|
|
458
|
+
for (const [t, list] of byType) {
|
|
459
|
+
if (t.toLowerCase() === want) for (const r of list) out.add(r);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
if (out.size === 0 && !args.expressIds && !args.globalIds && !args.type) {
|
|
463
|
+
// No targets specified → all
|
|
464
|
+
for (const r of records) out.add(r);
|
|
465
|
+
}
|
|
466
|
+
return Array.from(out);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** Compute the world-space bounding box of a set of records. Robust to
|
|
470
|
+
* the modelGroup's Y-up rotation: forces matrixWorld update first, then
|
|
471
|
+
* expands the box by each geometry's local bbox transformed into world. */
|
|
472
|
+
function worldBox(records: EntityRecord[]): THREE.Box3 {
|
|
473
|
+
modelGroup.updateMatrixWorld(true);
|
|
474
|
+
const box = new THREE.Box3();
|
|
475
|
+
const tmp = new THREE.Box3();
|
|
476
|
+
for (const r of records) {
|
|
477
|
+
r.mesh.geometry.computeBoundingBox();
|
|
478
|
+
const local = r.mesh.geometry.boundingBox;
|
|
479
|
+
if (!local || !isFinite(local.min.x) || !isFinite(local.max.x)) continue;
|
|
480
|
+
tmp.copy(local).applyMatrix4(r.mesh.matrixWorld);
|
|
481
|
+
box.union(tmp);
|
|
482
|
+
}
|
|
483
|
+
return box;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** Fit the camera + orbit target to a record set. If `instant` is true the
|
|
487
|
+
* camera snaps; otherwise it tweens (used by viewer_fly_to). */
|
|
488
|
+
function frameOn(records: EntityRecord[], instant = false) {
|
|
489
|
+
if (records.length === 0) return;
|
|
490
|
+
const box = worldBox(records);
|
|
491
|
+
if (box.isEmpty()) return;
|
|
492
|
+
const size = box.getSize(new THREE.Vector3());
|
|
493
|
+
const center = box.getCenter(new THREE.Vector3());
|
|
494
|
+
const maxDim = Math.max(size.x, size.y, size.z);
|
|
495
|
+
const radius = (maxDim || 1) * 0.6 + 1;
|
|
496
|
+
// Place camera diagonally above + offset so the building reads in
|
|
497
|
+
// perspective. Distance scales with the model size so a 5 m hut and a
|
|
498
|
+
// 200 m bridge both frame nicely.
|
|
499
|
+
const dir = new THREE.Vector3(0.55, 0.55, 0.62).normalize();
|
|
500
|
+
const distance = Math.max(radius * 2.6, maxDim * 1.4 + 4);
|
|
501
|
+
const target = center.clone().add(dir.multiplyScalar(distance));
|
|
502
|
+
|
|
503
|
+
// Tighten the camera near/far plane so big georeferenced bboxes don’t
|
|
504
|
+
// crush precision into one z-buffer slab.
|
|
505
|
+
camera.near = Math.max(0.05, distance / 5000);
|
|
506
|
+
camera.far = Math.max(500, distance * 20);
|
|
507
|
+
camera.updateProjectionMatrix();
|
|
508
|
+
|
|
509
|
+
if (instant) {
|
|
510
|
+
camera.position.copy(target);
|
|
511
|
+
controls.target.copy(center);
|
|
512
|
+
controls.update();
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
// Animate camera/target — single tween via lerp on rAF.
|
|
516
|
+
const startPos = camera.position.clone();
|
|
517
|
+
const startTarget = controls.target.clone();
|
|
518
|
+
const startedAt = performance.now();
|
|
519
|
+
const dur = 600;
|
|
520
|
+
function tween() {
|
|
521
|
+
if (disposed) return;
|
|
522
|
+
const t = Math.min(1, (performance.now() - startedAt) / dur);
|
|
523
|
+
const e = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
|
|
524
|
+
camera.position.lerpVectors(startPos, target, e);
|
|
525
|
+
controls.target.lerpVectors(startTarget, center, e);
|
|
526
|
+
controls.update();
|
|
527
|
+
if (t < 1) requestAnimationFrame(tween);
|
|
528
|
+
}
|
|
529
|
+
tween();
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
return {
|
|
533
|
+
loadMeshes(meshes, model) {
|
|
534
|
+
clearModel();
|
|
535
|
+
modelRef = model;
|
|
536
|
+
|
|
537
|
+
// Build per-entity records.
|
|
538
|
+
//
|
|
539
|
+
// CRITICAL: side = THREE.DoubleSide for every material, regardless
|
|
540
|
+
// of opacity. The IFC geometry pipeline produces meshes whose
|
|
541
|
+
// triangle winding is INCONSISTENT — some triangles are CCW, some
|
|
542
|
+
// are CW. The native @ifc-lite/renderer pipeline turns culling
|
|
543
|
+
// off everywhere for the same reason (see
|
|
544
|
+
// packages/renderer/src/pipeline.ts:141 — "Disable culling to debug
|
|
545
|
+
// - IFC winding order varies"). Using FrontSide here culls roughly
|
|
546
|
+
// half the triangles per element, which is exactly the
|
|
547
|
+
// "see-through, back faces visible" symptom we hit. DoubleSide
|
|
548
|
+
// costs us a few percent fillrate but renders correctly.
|
|
549
|
+
//
|
|
550
|
+
// We also call computeVertexNormals() defensively in case any
|
|
551
|
+
// mesh's normal buffer is stale or zeroed out — the geometry
|
|
552
|
+
// pipeline writes them but we want to be sure shading reads right.
|
|
553
|
+
let opaqueCount = 0;
|
|
554
|
+
let transparentCount = 0;
|
|
555
|
+
for (const md of meshes) {
|
|
556
|
+
const geom = new THREE.BufferGeometry();
|
|
557
|
+
geom.setAttribute('position', new THREE.BufferAttribute(md.positions, 3));
|
|
558
|
+
geom.setAttribute('normal', new THREE.BufferAttribute(md.normals, 3));
|
|
559
|
+
geom.setIndex(new THREE.BufferAttribute(md.indices, 1));
|
|
560
|
+
// If the supplied normals are degenerate (all zeros), regenerate
|
|
561
|
+
// from the indexed triangles. Cheap if normals were already good.
|
|
562
|
+
const n = md.normals;
|
|
563
|
+
if (n.length === 0 || (Math.abs(n[0]) + Math.abs(n[1]) + Math.abs(n[2])) < 1e-6) {
|
|
564
|
+
geom.computeVertexNormals();
|
|
565
|
+
}
|
|
566
|
+
geom.computeBoundingSphere();
|
|
567
|
+
const [r, g, b, a] = md.color;
|
|
568
|
+
const baseColor = new THREE.Color(r, g, b);
|
|
569
|
+
const isTransparent = a < 1;
|
|
570
|
+
if (isTransparent) transparentCount++; else opaqueCount++;
|
|
571
|
+
const mat = new THREE.MeshStandardMaterial({
|
|
572
|
+
color: baseColor,
|
|
573
|
+
transparent: isTransparent,
|
|
574
|
+
opacity: a,
|
|
575
|
+
side: THREE.DoubleSide, // see comment above
|
|
576
|
+
depthWrite: !isTransparent,
|
|
577
|
+
clippingPlanes: sectionPlanes,
|
|
578
|
+
});
|
|
579
|
+
const mesh = new THREE.Mesh(geom, mat);
|
|
580
|
+
mesh.userData.expressId = md.expressId;
|
|
581
|
+
mesh.userData.ifcType = md.ifcType;
|
|
582
|
+
modelGroup.add(mesh);
|
|
583
|
+
|
|
584
|
+
let globalId: string | undefined;
|
|
585
|
+
let ifcType: string | undefined = md.ifcType;
|
|
586
|
+
let storeyName: string | undefined;
|
|
587
|
+
// Resolve more accurate IFC metadata from the parsed store.
|
|
588
|
+
if (model.store.entityIndex.byId.has(md.expressId)) {
|
|
589
|
+
try {
|
|
590
|
+
const node = new EntityNode(model.store, md.expressId);
|
|
591
|
+
globalId = node.globalId || undefined;
|
|
592
|
+
if (!ifcType || ifcType === 'IfcProduct') ifcType = node.type;
|
|
593
|
+
const storey = node.storey();
|
|
594
|
+
if (storey) storeyName = storey.name;
|
|
595
|
+
} catch (err) {
|
|
596
|
+
// Optional metadata enrichment — if EntityNode regresses, fall
|
|
597
|
+
// back to the geometry-derived fields (already populated).
|
|
598
|
+
// Surface at debug level so a real parser issue isn't silent.
|
|
599
|
+
// eslint-disable-next-line no-console
|
|
600
|
+
console.debug('[playground-viewer] EntityNode metadata lookup failed', { expressId: md.expressId, err });
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const rec: EntityRecord = {
|
|
605
|
+
expressId: md.expressId,
|
|
606
|
+
globalId,
|
|
607
|
+
ifcType,
|
|
608
|
+
storeyName,
|
|
609
|
+
mesh,
|
|
610
|
+
baseColor,
|
|
611
|
+
baseOpacity: md.color[3],
|
|
612
|
+
};
|
|
613
|
+
records.push(rec);
|
|
614
|
+
byExpressId.set(md.expressId, rec);
|
|
615
|
+
if (globalId) byGlobalId.set(globalId, rec);
|
|
616
|
+
if (ifcType) {
|
|
617
|
+
const list = byType.get(ifcType) ?? [];
|
|
618
|
+
list.push(rec);
|
|
619
|
+
byType.set(ifcType, list);
|
|
620
|
+
}
|
|
621
|
+
if (storeyName) {
|
|
622
|
+
const list = byStorey.get(storeyName) ?? [];
|
|
623
|
+
list.push(rec);
|
|
624
|
+
byStorey.set(storeyName, list);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Snap the camera to the loaded model immediately (no tween — there’s
|
|
629
|
+
// nothing to tween from on first load).
|
|
630
|
+
frameOn(records, true);
|
|
631
|
+
// eslint-disable-next-line no-console
|
|
632
|
+
console.log('[playground-viewer] mounted meshes:', {
|
|
633
|
+
count: records.length,
|
|
634
|
+
opaque: opaqueCount,
|
|
635
|
+
transparent: transparentCount,
|
|
636
|
+
bbox: (() => {
|
|
637
|
+
const b = worldBox(records);
|
|
638
|
+
return b.isEmpty() ? null : { min: b.min.toArray(), max: b.max.toArray() };
|
|
639
|
+
})(),
|
|
640
|
+
camera: camera.position.toArray(),
|
|
641
|
+
target: controls.target.toArray(),
|
|
642
|
+
firstColors: meshes.slice(0, 3).map((m) => m.color),
|
|
643
|
+
});
|
|
644
|
+
},
|
|
645
|
+
|
|
646
|
+
unloadModel() {
|
|
647
|
+
clearModel();
|
|
648
|
+
},
|
|
649
|
+
|
|
650
|
+
colorize(args) {
|
|
651
|
+
const targets = selectTargets(args);
|
|
652
|
+
const c = new THREE.Color(args.color[0], args.color[1], args.color[2]);
|
|
653
|
+
// Always set transparency state, even when the new alpha is opaque —
|
|
654
|
+
// otherwise a previous translucent colorize leaves the entity
|
|
655
|
+
// permanently see-through until reset(). Treat alpha as part of the
|
|
656
|
+
// base colour so subsequent reset() / clear-selection paths put it
|
|
657
|
+
// back, not pick a stale opacity from before this call.
|
|
658
|
+
const alpha = args.color[3] ?? 1;
|
|
659
|
+
for (const r of targets) {
|
|
660
|
+
const mat = r.mesh.material as THREE.MeshStandardMaterial;
|
|
661
|
+
mat.color.copy(c);
|
|
662
|
+
r.baseColor.copy(c);
|
|
663
|
+
mat.transparent = alpha < 0.999;
|
|
664
|
+
mat.opacity = alpha;
|
|
665
|
+
r.baseOpacity = alpha;
|
|
666
|
+
}
|
|
667
|
+
return { count: targets.length };
|
|
668
|
+
},
|
|
669
|
+
|
|
670
|
+
isolate(args) {
|
|
671
|
+
const targets = new Set(selectTargets(args));
|
|
672
|
+
for (const r of records) {
|
|
673
|
+
r.mesh.visible = targets.has(r);
|
|
674
|
+
}
|
|
675
|
+
return { count: targets.size };
|
|
676
|
+
},
|
|
677
|
+
|
|
678
|
+
hide(args) {
|
|
679
|
+
const targets = selectTargets(args);
|
|
680
|
+
for (const r of targets) r.mesh.visible = false;
|
|
681
|
+
return { count: targets.length };
|
|
682
|
+
},
|
|
683
|
+
|
|
684
|
+
show(args) {
|
|
685
|
+
const targets = selectTargets(args);
|
|
686
|
+
for (const r of targets) r.mesh.visible = true;
|
|
687
|
+
return { count: targets.length };
|
|
688
|
+
},
|
|
689
|
+
|
|
690
|
+
reset() {
|
|
691
|
+
for (const r of records) {
|
|
692
|
+
r.mesh.visible = true;
|
|
693
|
+
const mat = r.mesh.material as THREE.MeshStandardMaterial;
|
|
694
|
+
mat.color.copy(r.baseColor);
|
|
695
|
+
mat.opacity = r.baseOpacity;
|
|
696
|
+
mat.transparent = r.baseOpacity < 0.999;
|
|
697
|
+
}
|
|
698
|
+
activeSectionPlane = null;
|
|
699
|
+
sectionPlanes.length = 0;
|
|
700
|
+
},
|
|
701
|
+
|
|
702
|
+
flyTo(args) {
|
|
703
|
+
const targets = selectTargets(args);
|
|
704
|
+
if (targets.length === 0) return { count: 0 };
|
|
705
|
+
frameOn(targets);
|
|
706
|
+
return { count: targets.length };
|
|
707
|
+
},
|
|
708
|
+
|
|
709
|
+
setSection({ axis, position }) {
|
|
710
|
+
sectionPlanes.length = 0;
|
|
711
|
+
// Geometry is in Three.js coordinates (Y is up after the geometry
|
|
712
|
+
// pipeline's Z-up→Y-up conversion). The agent's `axis` arg is read
|
|
713
|
+
// in the same convention: 'y' is the horizontal "cut the top off"
|
|
714
|
+
// plane, 'x' / 'z' are vertical slabs perpendicular to those world
|
|
715
|
+
// axes. Three.js clipping plane keeps points where n·x + d > 0.
|
|
716
|
+
const normal = new THREE.Vector3(
|
|
717
|
+
axis === 'x' ? -1 : 0,
|
|
718
|
+
axis === 'y' ? -1 : 0,
|
|
719
|
+
axis === 'z' ? -1 : 0,
|
|
720
|
+
);
|
|
721
|
+
activeSectionPlane = new THREE.Plane(normal, position);
|
|
722
|
+
sectionPlanes.push(activeSectionPlane);
|
|
723
|
+
for (const r of records) {
|
|
724
|
+
const mat = r.mesh.material as THREE.MeshStandardMaterial;
|
|
725
|
+
mat.clippingPlanes = sectionPlanes;
|
|
726
|
+
mat.needsUpdate = true;
|
|
727
|
+
}
|
|
728
|
+
},
|
|
729
|
+
|
|
730
|
+
clearSection() {
|
|
731
|
+
sectionPlanes.length = 0;
|
|
732
|
+
activeSectionPlane = null;
|
|
733
|
+
for (const r of records) {
|
|
734
|
+
const mat = r.mesh.material as THREE.MeshStandardMaterial;
|
|
735
|
+
mat.clippingPlanes = [];
|
|
736
|
+
mat.needsUpdate = true;
|
|
737
|
+
}
|
|
738
|
+
},
|
|
739
|
+
|
|
740
|
+
colorByStorey() {
|
|
741
|
+
// Distinct hue per storey. HSV evenly spaced.
|
|
742
|
+
const storeyNames = Array.from(byStorey.keys());
|
|
743
|
+
storeyNames.forEach((name, i) => {
|
|
744
|
+
const h = (i / Math.max(1, storeyNames.length)) * 0.85;
|
|
745
|
+
const c = new THREE.Color().setHSL(h, 0.6, 0.55);
|
|
746
|
+
for (const r of byStorey.get(name) ?? []) {
|
|
747
|
+
(r.mesh.material as THREE.MeshStandardMaterial).color.copy(c);
|
|
748
|
+
r.baseColor.copy(c);
|
|
749
|
+
}
|
|
750
|
+
});
|
|
751
|
+
return { groups: storeyNames.length };
|
|
752
|
+
},
|
|
753
|
+
|
|
754
|
+
colorByProperty({ type, sample }) {
|
|
755
|
+
const records = byType.get(type) ?? [];
|
|
756
|
+
const buckets = new Map<string, EntityRecord[]>();
|
|
757
|
+
for (const r of records) {
|
|
758
|
+
const v = sample(r.expressId);
|
|
759
|
+
const key = v == null ? '(missing)' : String(v);
|
|
760
|
+
const list = buckets.get(key) ?? [];
|
|
761
|
+
list.push(r);
|
|
762
|
+
buckets.set(key, list);
|
|
763
|
+
}
|
|
764
|
+
const PALETTE: ColorTuple[] = [
|
|
765
|
+
[0.84, 1.0, 0.25, 1],
|
|
766
|
+
[0.48, 0.45, 0.95, 1],
|
|
767
|
+
[1.0, 0.36, 0.86, 1],
|
|
768
|
+
[0.45, 0.85, 0.79, 1],
|
|
769
|
+
[1.0, 0.62, 0.39, 1],
|
|
770
|
+
[0.62, 0.81, 0.42, 1],
|
|
771
|
+
[0.50, 0.50, 0.55, 1],
|
|
772
|
+
];
|
|
773
|
+
const legend: Array<{ value: string; count: number; color: ColorTuple }> = [];
|
|
774
|
+
let i = 0;
|
|
775
|
+
for (const [value, list] of buckets) {
|
|
776
|
+
const color = value === '(missing)' ? [0.4, 0.4, 0.45, 1] as ColorTuple : PALETTE[i++ % PALETTE.length];
|
|
777
|
+
const c = new THREE.Color(color[0], color[1], color[2]);
|
|
778
|
+
for (const r of list) {
|
|
779
|
+
(r.mesh.material as THREE.MeshStandardMaterial).color.copy(c);
|
|
780
|
+
r.baseColor.copy(c);
|
|
781
|
+
}
|
|
782
|
+
legend.push({ value, count: list.length, color });
|
|
783
|
+
}
|
|
784
|
+
return { legend };
|
|
785
|
+
},
|
|
786
|
+
|
|
787
|
+
getSelection() {
|
|
788
|
+
return selection;
|
|
789
|
+
},
|
|
790
|
+
|
|
791
|
+
setOnSelectionChange(h) {
|
|
792
|
+
convenienceListener = h;
|
|
793
|
+
},
|
|
794
|
+
|
|
795
|
+
subscribeSelection(h) {
|
|
796
|
+
selectionListeners.add(h);
|
|
797
|
+
return () => selectionListeners.delete(h);
|
|
798
|
+
},
|
|
799
|
+
|
|
800
|
+
dispose() {
|
|
801
|
+
disposed = true;
|
|
802
|
+
cancelAnimationFrame(raf);
|
|
803
|
+
ro.disconnect();
|
|
804
|
+
controls.dispose();
|
|
805
|
+
clearModel();
|
|
806
|
+
renderer.dispose();
|
|
807
|
+
if (renderer.domElement.parentNode === container) {
|
|
808
|
+
container.removeChild(renderer.domElement);
|
|
809
|
+
}
|
|
810
|
+
},
|
|
811
|
+
};
|
|
812
|
+
|
|
813
|
+
// Avoid "unused" lint flag on the modelRef helper.
|
|
814
|
+
void modelRef;
|
|
815
|
+
}
|