@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.
Files changed (42) hide show
  1. package/.turbo/turbo-build.log +15 -14
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +8 -0
  4. package/dist/assets/basketViewActivator-CA2CTcVo.js +71 -0
  5. package/dist/assets/{bcf-DOG9_WPX.js → bcf-4K724hw0.js} +18 -18
  6. package/dist/assets/{exporters-BraHBeoi.js → exporters-xbXqEDlO.js} +53 -46
  7. package/dist/assets/ids-2WdONLlu.js +2033 -0
  8. package/dist/assets/index-BXeEKqJG.css +1 -0
  9. package/dist/assets/{index-BOi3BuUI.js → index-D8Epw-e7.js} +48072 -30928
  10. package/dist/assets/{native-bridge-CpBeOPQa.js → native-bridge-DKmx1z95.js} +2 -2
  11. package/dist/assets/{sandbox-Baez7n-t.js → sandbox-tccwm5Bo.js} +547 -529
  12. package/dist/assets/{server-client-BB6cMAXE.js → server-client-LoWPK1N2.js} +1 -1
  13. package/dist/assets/three-CDRZThFA.js +4057 -0
  14. package/dist/assets/{wasm-bridge-CAYCUHbE.js → wasm-bridge-BsJGgPMs.js} +1 -1
  15. package/dist/index.html +8 -7
  16. package/dist/samples/building-architecture.ifc +453 -0
  17. package/dist/samples/hello-wall.ifc +1054 -0
  18. package/dist/samples/infra-bridge.ifc +962 -0
  19. package/package.json +7 -2
  20. package/public/samples/building-architecture.ifc +453 -0
  21. package/public/samples/hello-wall.ifc +1054 -0
  22. package/public/samples/infra-bridge.ifc +962 -0
  23. package/src/App.tsx +37 -3
  24. package/src/components/mcp/HeroScene.tsx +876 -0
  25. package/src/components/mcp/McpLanding.tsx +1318 -0
  26. package/src/components/mcp/McpPlayground.tsx +524 -0
  27. package/src/components/mcp/PlaygroundChat.tsx +1097 -0
  28. package/src/components/mcp/PlaygroundViewer.tsx +815 -0
  29. package/src/components/mcp/README.md +171 -0
  30. package/src/components/mcp/data.ts +659 -0
  31. package/src/components/mcp/playground-dispatcher.ts +1649 -0
  32. package/src/components/mcp/playground-files.ts +107 -0
  33. package/src/components/mcp/playground-uploads.ts +122 -0
  34. package/src/components/mcp/types.ts +65 -0
  35. package/src/components/mcp/use-mcp-page.ts +109 -0
  36. package/src/components/viewer/MainToolbar.tsx +19 -0
  37. package/src/components/viewer/ViewportContainer.tsx +35 -4
  38. package/src/generated/mcp-catalog.json +82 -0
  39. package/vite.config.ts +6 -0
  40. package/dist/assets/basketViewActivator-RZy5c3Td.js +0 -1
  41. package/dist/assets/ids-DQ5jY0E8.js +0 -1
  42. 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
+ }