@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,876 @@
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
+ * HeroScene — the hero’s living building. Real WebGL via Three.js.
7
+ *
8
+ * Twelve agent steps now, picked to span every visible primitive on the MCP
9
+ * surface (Discovery, Query, Validation, Mutation, BCF, bSDD, Viewer):
10
+ *
11
+ * 00 viewer_open neutral framing
12
+ * 01 model_audit audit badge appears
13
+ * 02 count_entities(group_by="type") element-count panel slides in
14
+ * 03 viewer_color_by_storey storey-0 cool blue, storey-1 warm orange
15
+ * 04 viewer_color_by_property(IsExt.) outer walls vs inner walls split colour
16
+ * 05 viewer_isolate(IfcWall) non-walls fade out
17
+ * 06 viewer_colorize(IfcWall, "#d6ff3f") chartreuse paint
18
+ * 07 bsdd_property_sets(IfcWall) Pset list overlay
19
+ * 08 entity_create(IfcDoor) new door slides into the south wall
20
+ * 09 viewer_set_section(z=2.2) section plane clips top storey progressively
21
+ * 10 bcf_topic_create("missing rating") red pin appears beside a wall
22
+ * 11 viewer_describe_selection info card overlays the canvas
23
+ *
24
+ * Steps own three things in parallel:
25
+ *
26
+ * • visual scene state (driven inside this file via tweened materials,
27
+ * positions, and three.js clipping planes),
28
+ * • a transcript line (printed under the canvas by the parent),
29
+ * • optional UI overlays (badges, pins, panels) the parent renders on
30
+ * top of the canvas via the exported `HERO_STEPS` data.
31
+ */
32
+
33
+ import { useEffect, useRef } from 'react';
34
+ import * as THREE from 'three';
35
+ import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
36
+
37
+ const NIGHT = 0x0a0a0c;
38
+ const PAPER = 0xede4d3;
39
+ const PAPER_DIM = 0x6f6657;
40
+ const ACCENT = 0xd6ff3f; // chartreuse
41
+ const ACCENT_2 = 0xff5cdc; // magenta
42
+ const TEAL = 0x73daca;
43
+ const STOREY_HUE_LO = 0x4a6fa5; // cool blue
44
+ const STOREY_HUE_HI = 0xff9e64; // warm orange
45
+ const PROP_TRUE = 0xd6ff3f; // outer (IsExternal=true)
46
+ const PROP_FALSE = 0x7c7cd2; // inner (IsExternal=false)
47
+ const SLAB_DIM = 0x222226;
48
+ const NEW_DOOR_HUE = 0xff5cdc; // freshly-created door pulses magenta
49
+
50
+ /** Step descriptor — `verb` carries the story-arc headline (1-2 words),
51
+ * `line` is the technical tool call shown beneath it. Overlays are kept
52
+ * intentionally sparse: each one shows the smallest piece of evidence
53
+ * that proves the agent's action landed. */
54
+ export interface HeroStep {
55
+ /** One- or two-word verb shown big in display serif. The story arc. */
56
+ verb: string;
57
+ /** Tool call line shown under the verb in mono. The detail. */
58
+ line: string;
59
+ /** Tool category badge ("Validation", "Viewer", …). */
60
+ family: string;
61
+ /** Optional overlay UI key the parent renders inside the canvas frame. */
62
+ overlay?:
63
+ | { kind: 'audit'; score: number; note: string }
64
+ | { kind: 'counts'; rows: Array<{ type: string; n: number }> }
65
+ | { kind: 'psets'; psets: string[] }
66
+ | { kind: 'pin'; ref: string }
67
+ | { kind: 'card'; ref: string; lines: string[] };
68
+ }
69
+
70
+ export const HERO_STEPS: HeroStep[] = [
71
+ { verb: 'Open', line: 'viewer_open()', family: 'Viewer' },
72
+ { verb: 'Audit', line: 'model_audit()', family: 'Validation', overlay: { kind: 'audit', score: 74, note: '1 issue' } },
73
+ { verb: 'Survey', line: 'count_entities(group_by: "type")', family: 'Query', overlay: { kind: 'counts', rows: [
74
+ { type: 'Wall', n: 8 },
75
+ { type: 'Window', n: 12 },
76
+ { type: 'Slab', n: 3 },
77
+ ] } },
78
+ { verb: 'Layer', line: 'viewer_color_by_storey()', family: 'Viewer' },
79
+ { verb: 'Classify', line: 'viewer_color_by_property("IsExternal")', family: 'Viewer' },
80
+ { verb: 'Focus', line: 'viewer_isolate(IfcWall)', family: 'Viewer' },
81
+ { verb: 'Paint', line: 'viewer_colorize(IfcWall, "#d6ff3f")', family: 'Viewer' },
82
+ { verb: 'Standardize', line: 'bsdd_property_sets("IfcWall")', family: 'bSDD', overlay: { kind: 'psets', psets: ['Pset_WallCommon', 'Qto_WallBaseQuantities', 'Pset_ConcreteElementGeneral'] } },
83
+ { verb: 'Add', line: 'entity_create(IfcDoor)', family: 'Mutation' },
84
+ { verb: 'Section', line: 'viewer_set_section(z = 2.2)', family: 'Viewer' },
85
+ { verb: 'Issue', line: 'bcf_topic_create("missing fire rating")', family: 'BCF', overlay: { kind: 'pin', ref: 'BCF #04' } },
86
+ { verb: 'Inspect', line: 'viewer_describe_selection()', family: 'Viewer', overlay: { kind: 'card', ref: 'IfcWall #262', lines: ['Pset_WallCommon · IsExternal=true', 'FireRating=EI60 · 240 mm concrete'] } },
87
+ ];
88
+
89
+ /** ms each step gets before advancing. */
90
+ export const HERO_STEP_MS = 2000;
91
+
92
+ interface Element {
93
+ mesh: THREE.Mesh;
94
+ baseColor: THREE.Color;
95
+ targetColor: THREE.Color;
96
+ baseOpacity: number;
97
+ targetOpacity: number;
98
+ /** Visible flag — used for the "new door" reveal. */
99
+ hidden?: boolean;
100
+ /** Multiplier for the mesh's stored Y so we can animate the new door sliding in. */
101
+ yOffset?: number;
102
+ baseY?: number;
103
+ }
104
+
105
+ export interface HeroSceneProps {
106
+ /** 0..HERO_STEPS.length-1 — drives material/camera/section animation. */
107
+ step: number;
108
+ className?: string;
109
+ /**
110
+ * Optional callback fired every animation frame with the current
111
+ * screen-space position of the BCF pin (relative to this element's
112
+ * top-left). Used by HeroOverlay to anchor the pin caption.
113
+ */
114
+ onPinFrame?: (frame: { x: number; y: number; visible: boolean } | null) => void;
115
+ }
116
+
117
+ export function HeroScene({ step, className, onPinFrame }: HeroSceneProps) {
118
+ const containerRef = useRef<HTMLDivElement>(null);
119
+ const sceneRef = useRef<SceneHandle | null>(null);
120
+ const onPinRef = useRef(onPinFrame);
121
+ onPinRef.current = onPinFrame;
122
+
123
+ useEffect(() => {
124
+ const container = containerRef.current;
125
+ if (!container) return;
126
+ const handle = createScene(container);
127
+ sceneRef.current = handle;
128
+
129
+ let raf = 0;
130
+ const tick = () => {
131
+ raf = requestAnimationFrame(tick);
132
+ onPinRef.current?.(handle.projectPin());
133
+ };
134
+ tick();
135
+ return () => {
136
+ cancelAnimationFrame(raf);
137
+ handle.dispose();
138
+ sceneRef.current = null;
139
+ };
140
+ }, []);
141
+
142
+ useEffect(() => {
143
+ sceneRef.current?.update(step);
144
+ }, [step]);
145
+
146
+ return (
147
+ <div
148
+ ref={containerRef}
149
+ className={className ?? 'relative aspect-[4/5] w-full overflow-hidden rounded-lg'}
150
+ style={{ background: '#0a0a0c' }}
151
+ />
152
+ );
153
+ }
154
+
155
+ // ── scene factory ──────────────────────────────────────────────────────────
156
+
157
+ interface SceneHandle {
158
+ update(step: number): void;
159
+ dispose(): void;
160
+ /**
161
+ * Project the BCF pin's world position into the host element's local
162
+ * coordinate space so a sibling HTML overlay can track it through orbit
163
+ * and camera transitions. Returns null when the pin is behind the camera
164
+ * or the host has no size yet.
165
+ */
166
+ projectPin(): { x: number; y: number; visible: boolean } | null;
167
+ }
168
+
169
+ function createScene(container: HTMLElement): SceneHandle {
170
+ const allElements: Element[] = [];
171
+
172
+ // ── Renderer ─────────────────────────────────────────────────────────────
173
+ const renderer = new THREE.WebGLRenderer({
174
+ antialias: true,
175
+ alpha: true,
176
+ powerPreference: 'high-performance',
177
+ });
178
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
179
+ renderer.setSize(container.clientWidth, container.clientHeight, false);
180
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
181
+ renderer.setClearColor(NIGHT, 0);
182
+ renderer.localClippingEnabled = true;
183
+ container.appendChild(renderer.domElement);
184
+ renderer.domElement.style.display = 'block';
185
+ renderer.domElement.style.width = '100%';
186
+ renderer.domElement.style.height = '100%';
187
+
188
+ // ── Scene + camera ───────────────────────────────────────────────────────
189
+ const scene = new THREE.Scene();
190
+ scene.fog = new THREE.Fog(NIGHT, 18, 42);
191
+
192
+ const camera = new THREE.PerspectiveCamera(35, container.clientWidth / container.clientHeight, 0.1, 100);
193
+ camera.position.set(11, 8, 13);
194
+ camera.lookAt(0, 2.5, 0);
195
+
196
+ // ── Lighting ─────────────────────────────────────────────────────────────
197
+ scene.add(new THREE.HemisphereLight(0xb6c8ff, 0x1a1a22, 0.6));
198
+
199
+ const key = new THREE.DirectionalLight(0xffffff, 1.4);
200
+ key.position.set(8, 14, 6);
201
+ scene.add(key);
202
+
203
+ const rim = new THREE.DirectionalLight(0xff5cdc, 0.35);
204
+ rim.position.set(-10, 4, -8);
205
+ scene.add(rim);
206
+
207
+ const fill = new THREE.DirectionalLight(0xd6ff3f, 0.18);
208
+ fill.position.set(-4, 2, 8);
209
+ scene.add(fill);
210
+
211
+ // ── Ground ───────────────────────────────────────────────────────────────
212
+ const grid = new THREE.GridHelper(40, 40, 0x2a2a32, 0x16161c);
213
+ (grid.material as THREE.Material).transparent = true;
214
+ (grid.material as THREE.Material).opacity = 0.5;
215
+ grid.position.y = -0.02;
216
+ scene.add(grid);
217
+ const ground = new THREE.Mesh(
218
+ new THREE.CircleGeometry(20, 48),
219
+ new THREE.MeshStandardMaterial({ color: 0x0e0e12, roughness: 1 }),
220
+ );
221
+ ground.rotation.x = -Math.PI / 2;
222
+ ground.position.y = -0.03;
223
+ scene.add(ground);
224
+
225
+ // ── Building geometry ────────────────────────────────────────────────────
226
+ //
227
+ // Spatial conventions (model space, no parent rotation):
228
+ // • +Z is the FRONT face — the one the default camera (in the +X/+Z
229
+ // corner) looks at directly. Door + primary window wall live there.
230
+ // • +X is the RIGHT face — the destination of the agent-created door
231
+ // in step 8 (so the new entity is unmistakable on a wall that started
232
+ // out blank).
233
+ // • Camera auto-rotates around Y, so the back / left faces eventually
234
+ // come around — we still populate them so the building looks right
235
+ // from every angle.
236
+ const root = new THREE.Group();
237
+ scene.add(root);
238
+
239
+ const W = 8; // building width (X)
240
+ const D = 5; // building depth (Z)
241
+ const STOREY = 3; // storey height
242
+ const WALL_THK = 0.18;
243
+ const FRONT_Z = D / 2 + 0.001;
244
+ const BACK_Z = -D / 2 - 0.001;
245
+ const RIGHT_X = W / 2 + 0.001;
246
+ const LEFT_X = -W / 2 - 0.001;
247
+
248
+ // Section plane (used by viewer_set_section step). Negative Y axis means
249
+ // we clip everything ABOVE the plane.
250
+ const sectionPlane = new THREE.Plane(new THREE.Vector3(0, -1, 0), 100); // disabled by default (constant pushed far away)
251
+
252
+ function makeMesh(geom: THREE.BufferGeometry, hex: number): THREE.Mesh {
253
+ const mat = new THREE.MeshStandardMaterial({
254
+ color: new THREE.Color(hex),
255
+ metalness: 0.05,
256
+ roughness: 0.74,
257
+ clippingPlanes: [sectionPlane],
258
+ clipShadows: true,
259
+ });
260
+ return new THREE.Mesh(geom, mat);
261
+ }
262
+ function registerElement(mesh: THREE.Mesh, baseHex: number, baseOpacity = 1): Element {
263
+ const baseColor = new THREE.Color(baseHex);
264
+ const el: Element = {
265
+ mesh,
266
+ baseColor,
267
+ targetColor: baseColor.clone(),
268
+ baseOpacity,
269
+ targetOpacity: baseOpacity,
270
+ baseY: mesh.position.y,
271
+ yOffset: 0,
272
+ };
273
+ allElements.push(el);
274
+ return el;
275
+ }
276
+
277
+ // Slab
278
+ const slab = makeMesh(new THREE.BoxGeometry(W + 0.4, 0.18, D + 0.4), PAPER_DIM);
279
+ slab.position.y = -0.09;
280
+ root.add(slab);
281
+ const slabEl = registerElement(slab, PAPER_DIM);
282
+
283
+ // Walls per storey. The two camera-facing walls (FRONT + RIGHT) are
284
+ // tagged "outer" so the color_by_property step splits visibly; BACK +
285
+ // LEFT play "inner". Each face is one solid box — windows + doors sit
286
+ // *on top of* the wall as separate meshes (we’re showing IFC entities,
287
+ // not boolean cuts, so the layered geometry reads better).
288
+ const wallsByStorey: Element[][] = [[], []];
289
+ const externalByStorey: { outer: Element[]; inner: Element[] }[] = [
290
+ { outer: [], inner: [] },
291
+ { outer: [], inner: [] },
292
+ ];
293
+ const windowsByStorey: Element[][] = [[], []];
294
+ const doorEls: Element[] = [];
295
+
296
+ for (let storey = 0; storey < 2; storey++) {
297
+ const yMid = storey * STOREY + (STOREY - 0.05) / 2 + 0.05;
298
+ const wallFront = makeMesh(new THREE.BoxGeometry(W, STOREY - 0.05, WALL_THK), 0x6c6c75);
299
+ wallFront.position.set(0, yMid, D / 2);
300
+ const wallBack = makeMesh(new THREE.BoxGeometry(W, STOREY - 0.05, WALL_THK), 0x6c6c75);
301
+ wallBack.position.set(0, yMid, -D / 2);
302
+ const wallRight = makeMesh(new THREE.BoxGeometry(WALL_THK, STOREY - 0.05, D), 0x6c6c75);
303
+ wallRight.position.set(W / 2, yMid, 0);
304
+ const wallLeft = makeMesh(new THREE.BoxGeometry(WALL_THK, STOREY - 0.05, D), 0x6c6c75);
305
+ wallLeft.position.set(-W / 2, yMid, 0);
306
+ [wallFront, wallBack, wallRight, wallLeft].forEach((m) => root.add(m));
307
+
308
+ const elFront = registerElement(wallFront, 0x6c6c75);
309
+ const elBack = registerElement(wallBack, 0x6c6c75);
310
+ const elRight = registerElement(wallRight, 0x6c6c75);
311
+ const elLeft = registerElement(wallLeft, 0x6c6c75);
312
+ wallsByStorey[storey].push(elFront, elBack, elRight, elLeft);
313
+ externalByStorey[storey].outer.push(elFront, elRight);
314
+ externalByStorey[storey].inner.push(elBack, elLeft);
315
+
316
+ // FRONT-face windows. Storey 0 has 2 windows flanking the centre door;
317
+ // storey 1 has 3 evenly spaced (no door above to dodge). Generous
318
+ // spacing so nothing overlaps even at WALL_THK + offsets.
319
+ const winY = yMid + 0.55; // sill ~yMid+0.05, head ~yMid+1.05
320
+ const frontXs = storey === 0 ? [-3.2, +3.2] : [-3.2, 0, +3.2];
321
+ for (const x of frontXs) {
322
+ const win = makeMesh(new THREE.BoxGeometry(1.0, 1.1, WALL_THK + 0.02), 0x2c3a52);
323
+ win.position.set(x, winY, FRONT_Z);
324
+ root.add(win);
325
+ windowsByStorey[storey].push(registerElement(win, 0x2c3a52));
326
+ }
327
+
328
+ // BACK-face windows: 3 per storey, evenly spaced (visible while the
329
+ // camera auto-orbits past the rear).
330
+ for (const x of [-3.2, 0, +3.2]) {
331
+ const win = makeMesh(new THREE.BoxGeometry(1.0, 1.1, WALL_THK + 0.02), 0x2c3a52);
332
+ win.position.set(x, winY, BACK_Z);
333
+ root.add(win);
334
+ windowsByStorey[storey].push(registerElement(win, 0x2c3a52));
335
+ }
336
+
337
+ // SIDE-face windows: 1 per storey on the LEFT face only — the RIGHT
338
+ // face is reserved for the agent-created side door (step 8).
339
+ const winSide = makeMesh(new THREE.BoxGeometry(WALL_THK + 0.02, 1.1, 1.0), 0x2c3a52);
340
+ winSide.position.set(LEFT_X, winY, 0);
341
+ root.add(winSide);
342
+ windowsByStorey[storey].push(registerElement(winSide, 0x2c3a52));
343
+ }
344
+
345
+ // ORIGINAL door — front face, ground floor, dead-centre on the wall. The
346
+ // door is taller than the windows above it, so the silhouette reads as
347
+ // a real entrance rather than another opening.
348
+ const door = makeMesh(new THREE.BoxGeometry(1.05, 2.2, WALL_THK + 0.04), 0x2a2a30);
349
+ door.position.set(0, 1.1, FRONT_Z);
350
+ root.add(door);
351
+ doorEls.push(registerElement(door, 0x2a2a30));
352
+
353
+ // Tiny "step" / threshold under the door so it visibly sits on the slab.
354
+ const threshold = makeMesh(new THREE.BoxGeometry(1.4, 0.05, 0.5), 0x55554f);
355
+ threshold.position.set(0, 0.025, FRONT_Z + 0.18);
356
+ root.add(threshold);
357
+ registerElement(threshold, 0x55554f);
358
+
359
+ // NEW door — created by entity_create step. Lives on the RIGHT face (a
360
+ // wall that started out blank), centred on Z. Hidden + lifted high by
361
+ // default; slides down + fades in when the agent fires entity_create so
362
+ // the addition is unmistakable.
363
+ const newDoor = makeMesh(new THREE.BoxGeometry(WALL_THK + 0.04, 2.2, 1.05), NEW_DOOR_HUE);
364
+ newDoor.position.set(RIGHT_X, 4.6, 0);
365
+ root.add(newDoor);
366
+ const newDoorEl = registerElement(newDoor, NEW_DOOR_HUE, 0);
367
+ newDoorEl.hidden = true;
368
+ // Threshold for the new door — also hidden until the agent acts.
369
+ const newThreshold = makeMesh(new THREE.BoxGeometry(0.5, 0.05, 1.4), 0x55554f);
370
+ newThreshold.position.set(RIGHT_X + 0.18, 0.025, 0);
371
+ root.add(newThreshold);
372
+ const newThresholdEl = registerElement(newThreshold, 0x55554f, 0);
373
+ newThresholdEl.hidden = true;
374
+
375
+ // Storey-2 floor (so the silhouette reads as two-storey).
376
+ const floor2 = makeMesh(new THREE.BoxGeometry(W + 0.05, 0.08, D + 0.05), PAPER_DIM);
377
+ floor2.position.y = STOREY;
378
+ root.add(floor2);
379
+ const floor2El = registerElement(floor2, PAPER_DIM);
380
+
381
+ // Roof — true hip roof matching the 8 × 5 footprint with a small eave
382
+ // overhang. Built as a closed mesh of two trapezoids (front/back) + two
383
+ // triangles (left/right) meeting at a ridge along the long X axis.
384
+ const roof = makeMesh(makeHipRoof(W, D, 1.5, 0.5), 0x4a4a52);
385
+ roof.position.y = 2 * STOREY + 0.05;
386
+ root.add(roof);
387
+ const roofEl = registerElement(roof, 0x4a4a52);
388
+
389
+ // Eave board — a thin lip along the top edge of the upper walls so the
390
+ // roof meets the walls cleanly instead of floating.
391
+ const eave = makeMesh(new THREE.BoxGeometry(W + 1.0, 0.08, D + 1.0), 0x3e3e44);
392
+ eave.position.y = 2 * STOREY + 0.04;
393
+ root.add(eave);
394
+ registerElement(eave, 0x3e3e44);
395
+
396
+ // ── Section plane visualisation ─────────────────────────────────────────
397
+ // A faint chartreuse rectangle that snaps in only when the section step
398
+ // fires, so the user sees WHERE the cut is happening.
399
+ const sectionVis = new THREE.Mesh(
400
+ new THREE.PlaneGeometry(W + 1.2, D + 1.2),
401
+ new THREE.MeshBasicMaterial({
402
+ color: ACCENT,
403
+ side: THREE.DoubleSide,
404
+ transparent: true,
405
+ opacity: 0,
406
+ depthWrite: false,
407
+ }),
408
+ );
409
+ sectionVis.rotation.x = -Math.PI / 2;
410
+ sectionVis.position.y = 2.2;
411
+ scene.add(sectionVis);
412
+
413
+ // ── BCF pin (3D Sprite) ─────────────────────────────────────────────────
414
+ // A small canvas-baked red disc that lives in world space on the front
415
+ // wall, top-right area. Sprites always face the camera, so this stays
416
+ // legible through auto-rotate. The "BCF #04" caption next to it is still
417
+ // an HTML overlay (handled by McpLanding) but it now anchors to the
418
+ // sprite’s projected screen position via getProjectedPin().
419
+ const pinCanvas = document.createElement('canvas');
420
+ pinCanvas.width = 96;
421
+ pinCanvas.height = 96;
422
+ const pinCtx = pinCanvas.getContext('2d');
423
+ if (pinCtx) {
424
+ // soft glow
425
+ const grad = pinCtx.createRadialGradient(48, 48, 6, 48, 48, 48);
426
+ grad.addColorStop(0, 'rgba(255, 58, 58, 0.55)');
427
+ grad.addColorStop(0.55, 'rgba(255, 58, 58, 0.18)');
428
+ grad.addColorStop(1, 'rgba(255, 58, 58, 0)');
429
+ pinCtx.fillStyle = grad;
430
+ pinCtx.fillRect(0, 0, 96, 96);
431
+ // solid disc
432
+ pinCtx.beginPath();
433
+ pinCtx.arc(48, 48, 22, 0, Math.PI * 2);
434
+ pinCtx.fillStyle = '#ff3a3a';
435
+ pinCtx.fill();
436
+ pinCtx.lineWidth = 2;
437
+ pinCtx.strokeStyle = '#fff';
438
+ pinCtx.stroke();
439
+ // "!" glyph
440
+ pinCtx.fillStyle = '#fff';
441
+ pinCtx.font = 'bold 30px ui-monospace, "JetBrains Mono", Menlo, monospace';
442
+ pinCtx.textAlign = 'center';
443
+ pinCtx.textBaseline = 'middle';
444
+ pinCtx.fillText('!', 48, 49);
445
+ }
446
+ const pinTex = new THREE.CanvasTexture(pinCanvas);
447
+ pinTex.colorSpace = THREE.SRGBColorSpace;
448
+ const pinMat = new THREE.SpriteMaterial({
449
+ map: pinTex,
450
+ transparent: true,
451
+ opacity: 0,
452
+ depthTest: false,
453
+ });
454
+ const pin = new THREE.Sprite(pinMat);
455
+ pin.scale.set(0.9, 0.9, 1);
456
+ // Anchor on the front wall, towards the right side (away from the door).
457
+ // World coords map directly to model since root has no rotation.
458
+ pin.position.set(2.6, 1.9, FRONT_Z + 0.1);
459
+ scene.add(pin);
460
+ let pinOpacityTarget = 0;
461
+
462
+ // ── Controls ─────────────────────────────────────────────────────────────
463
+ const controls = new OrbitControls(camera, renderer.domElement);
464
+ controls.target.set(0, STOREY, 0);
465
+ controls.enableDamping = true;
466
+ controls.dampingFactor = 0.08;
467
+ controls.enablePan = false;
468
+ controls.enableZoom = false;
469
+ controls.minPolarAngle = Math.PI / 4;
470
+ controls.maxPolarAngle = Math.PI / 2.05;
471
+ controls.autoRotate = true;
472
+ controls.autoRotateSpeed = 0.45;
473
+
474
+ let resumeTimer: ReturnType<typeof setTimeout> | null = null;
475
+ controls.addEventListener('start', () => {
476
+ controls.autoRotate = false;
477
+ if (resumeTimer) clearTimeout(resumeTimer);
478
+ });
479
+ controls.addEventListener('end', () => {
480
+ if (resumeTimer) clearTimeout(resumeTimer);
481
+ resumeTimer = setTimeout(() => { controls.autoRotate = true; }, 2200);
482
+ });
483
+
484
+ // ── Resize ───────────────────────────────────────────────────────────────
485
+ const ro = new ResizeObserver(() => {
486
+ const w = container.clientWidth;
487
+ const h = container.clientHeight;
488
+ if (w === 0 || h === 0) return;
489
+ camera.aspect = w / h;
490
+ camera.updateProjectionMatrix();
491
+ renderer.setSize(w, h, false);
492
+ });
493
+ ro.observe(container);
494
+
495
+ // ── Camera tween targets ─────────────────────────────────────────────────
496
+ // No parent rotation any more, so all targets read in plain world coords.
497
+ // Default: front-right corner at slight elevation.
498
+ // Close: nudged in toward the front face for the describe-selection step.
499
+ // RightAngle: looks at the +X (RIGHT) face where the new door appears,
500
+ // rotated camera around to the side so it’s visible.
501
+ const cameraDefault = new THREE.Vector3(12, 8, 13);
502
+ const cameraClose = new THREE.Vector3(8, 5.5, 10);
503
+ const cameraRightAngle = new THREE.Vector3(14, 6, 6);
504
+ let cameraTarget = cameraDefault.clone();
505
+
506
+ // Section plane state — animates between "off" (constant 100) and "on"
507
+ // (constant 2.2 → clipping above y=2.2).
508
+ let sectionConstantTarget = 100;
509
+ let sectionVisOpacityTarget = 0;
510
+
511
+ // ── Animation loop ───────────────────────────────────────────────────────
512
+ let raf = 0;
513
+ let disposed = false;
514
+ function tick() {
515
+ if (disposed) return;
516
+ raf = requestAnimationFrame(tick);
517
+ controls.update();
518
+
519
+ for (const el of allElements) {
520
+ const mat = el.mesh.material as THREE.MeshStandardMaterial;
521
+ mat.color.lerp(el.targetColor, 0.07);
522
+ const targetOpacity = el.hidden ? 0 : el.targetOpacity;
523
+ mat.opacity = THREE.MathUtils.lerp(mat.opacity, targetOpacity, 0.07);
524
+ mat.transparent = mat.opacity < 0.999;
525
+ // Optional Y slide (used for the new-door reveal)
526
+ if (el.baseY !== undefined && el.yOffset !== undefined) {
527
+ const targetY = el.baseY + el.yOffset;
528
+ el.mesh.position.y = THREE.MathUtils.lerp(el.mesh.position.y, targetY, 0.08);
529
+ }
530
+ }
531
+
532
+ // Section plane tween
533
+ sectionPlane.constant = THREE.MathUtils.lerp(sectionPlane.constant, sectionConstantTarget, 0.08);
534
+ const sectMat = sectionVis.material as THREE.MeshBasicMaterial;
535
+ sectMat.opacity = THREE.MathUtils.lerp(sectMat.opacity, sectionVisOpacityTarget, 0.1);
536
+
537
+ // BCF pin sprite tween
538
+ pinMat.opacity = THREE.MathUtils.lerp(pinMat.opacity, pinOpacityTarget, 0.12);
539
+
540
+ camera.position.lerp(cameraTarget, 0.04);
541
+ renderer.render(scene, camera);
542
+ }
543
+ tick();
544
+
545
+ // ── State controller ────────────────────────────────────────────────────
546
+ // Every step is its own switch case so each transition is a *complete*
547
+ // scene description, not a cumulative diff. That's what makes the visual
548
+ // story arc legible — Survey paints types, Layer overrides with storeys,
549
+ // Standardize pulls everything into bSDD blue, etc. The few things that
550
+ // genuinely persist across steps (the agent-created door once it lands;
551
+ // the section plane while it’s active) are restored explicitly inside
552
+ // each case that needs them.
553
+ function setTarget(el: Element, color: number, opacity = 1) {
554
+ el.targetColor.setHex(color);
555
+ el.targetOpacity = opacity;
556
+ el.hidden = false;
557
+ }
558
+ function dim(el: Element, opacity = 0.16) {
559
+ el.targetOpacity = opacity;
560
+ }
561
+ function reset() {
562
+ for (const el of allElements) {
563
+ el.targetColor.copy(el.baseColor);
564
+ el.targetOpacity = el.baseOpacity;
565
+ if (el.baseY !== undefined) el.yOffset = 0;
566
+ }
567
+ cameraTarget = cameraDefault.clone();
568
+ sectionConstantTarget = 100;
569
+ sectionVisOpacityTarget = 0;
570
+ newDoorEl.hidden = true;
571
+ newThresholdEl.hidden = true;
572
+ pinOpacityTarget = 0;
573
+ }
574
+
575
+ // After step 8, the new door / threshold persist into later steps. This
576
+ // helper re-applies that state inside cases that come after entity_create.
577
+ function keepNewDoor() {
578
+ newDoorEl.hidden = false;
579
+ newDoorEl.targetOpacity = 1;
580
+ newDoorEl.targetColor.setHex(NEW_DOOR_HUE);
581
+ newDoorEl.yOffset = -3.5;
582
+ newThresholdEl.hidden = false;
583
+ newThresholdEl.targetOpacity = 1;
584
+ }
585
+
586
+ // Type aliases for legibility inside the switch
587
+ const allWalls = () => [...wallsByStorey[0], ...wallsByStorey[1]];
588
+ const allWindows = () => [...windowsByStorey[0], ...windowsByStorey[1]];
589
+
590
+ function update(step: number) {
591
+ reset();
592
+ switch (step) {
593
+ // ── 00 OPEN ───────────────────────────────────────────────────────
594
+ // Establish neutral framing — camera sits in the front-right corner
595
+ // and slowly orbits.
596
+ case 0: {
597
+ cameraTarget = cameraDefault.clone();
598
+ break;
599
+ }
600
+
601
+ // ── 01 AUDIT ──────────────────────────────────────────────────────
602
+ // model_audit reports a single offending wall (missing FireRating).
603
+ // Visual: the offending wall flashes a saturated red while the rest
604
+ // of the building stays neutral.
605
+ case 1: {
606
+ const issueWall = externalByStorey[0].outer[0]; // front-face ground wall
607
+ setTarget(issueWall, 0xff3a3a);
608
+ cameraTarget = new THREE.Vector3(13, 8.5, 14);
609
+ break;
610
+ }
611
+
612
+ // ── 02 SURVEY ─────────────────────────────────────────────────────
613
+ // count_entities groups by type. Visual: each type takes its own
614
+ // distinct hue at the same time so the histogram in the overlay
615
+ // maps onto the building. Pulls camera up so slabs + roof read.
616
+ case 2: {
617
+ for (const el of allWalls()) setTarget(el, 0x73daca); // walls — teal
618
+ for (const el of allWindows()) setTarget(el, 0x7aa2f7); // windows — blue
619
+ for (const el of doorEls) setTarget(el, 0xff9e64); // doors — orange
620
+ setTarget(slabEl, 0xbb9af7); // slabs — purple
621
+ setTarget(floor2El, 0xbb9af7);
622
+ setTarget(roofEl, 0xc8c8d0); // roof — pale
623
+ cameraTarget = new THREE.Vector3(11, 11, 13);
624
+ break;
625
+ }
626
+
627
+ // ── 03 LAYER ──────────────────────────────────────────────────────
628
+ // viewer_color_by_storey — ground floor cool blue, upper warm orange.
629
+ case 3: {
630
+ for (const el of wallsByStorey[0]) setTarget(el, STOREY_HUE_LO);
631
+ for (const el of wallsByStorey[1]) setTarget(el, STOREY_HUE_HI);
632
+ cameraTarget = cameraDefault.clone();
633
+ break;
634
+ }
635
+
636
+ // ── 04 CLASSIFY ───────────────────────────────────────────────────
637
+ // viewer_color_by_property("IsExternal") — outer (front + right)
638
+ // walls go chartreuse, inner walls go cool lavender.
639
+ case 4: {
640
+ for (const s of [0, 1]) {
641
+ for (const el of externalByStorey[s].outer) setTarget(el, PROP_TRUE);
642
+ for (const el of externalByStorey[s].inner) setTarget(el, PROP_FALSE);
643
+ }
644
+ cameraTarget = new THREE.Vector3(13, 7, 11);
645
+ break;
646
+ }
647
+
648
+ // ── 05 FOCUS ──────────────────────────────────────────────────────
649
+ // viewer_isolate — pick ONE specific wall (front face, ground storey
650
+ // — the wall the door sits on) and dim absolutely everything else
651
+ // to ~2 %. Camera dollies in close to a near-elevation view so the
652
+ // single wall reads at full size.
653
+ case 5: {
654
+ const pickedWall = wallsByStorey[0][0]; // FRONT, storey 0
655
+ for (const el of allElements) {
656
+ if (el === pickedWall) continue;
657
+ el.targetOpacity = 0.02;
658
+ }
659
+ setTarget(pickedWall, 0x6c6c75); // neutral grey — Paint step pops next
660
+ cameraTarget = new THREE.Vector3(4, 3.5, 10);
661
+ break;
662
+ }
663
+
664
+ // ── 06 PAINT ──────────────────────────────────────────────────────
665
+ // viewer_colorize per-entity — every visible IFC element gets a
666
+ // distinct hue from the palette. The agent fanning out colours per
667
+ // entity makes the "we touched everything" point loud + clear.
668
+ case 6: {
669
+ // Bring everything back from Focus first.
670
+ for (const el of allElements) {
671
+ el.targetOpacity = el.baseOpacity;
672
+ }
673
+ // Newly-created door doesn’t exist yet — keep it hidden until step 8.
674
+ newDoorEl.hidden = true;
675
+ newThresholdEl.hidden = true;
676
+
677
+ // Group + walk the palette. Each group cycles independently so
678
+ // we don’t end up with two adjacent walls in the same colour.
679
+ const RAINBOW = [
680
+ 0xff3a3a, 0xff9e64, 0xe0af68, 0xd6ff3f,
681
+ 0x9ece6a, 0x73daca, 0x7aa2f7, 0xbb9af7, 0xff5cdc,
682
+ ];
683
+ let i = 0;
684
+ for (const el of allWalls()) setTarget(el, RAINBOW[i++ % RAINBOW.length]);
685
+ for (const el of allWindows()) setTarget(el, RAINBOW[i++ % RAINBOW.length]);
686
+ for (const el of doorEls) setTarget(el, RAINBOW[i++ % RAINBOW.length]);
687
+ setTarget(slabEl, RAINBOW[i++ % RAINBOW.length]);
688
+ setTarget(floor2El, RAINBOW[i++ % RAINBOW.length]);
689
+ setTarget(roofEl, RAINBOW[i++ % RAINBOW.length]);
690
+ cameraTarget = new THREE.Vector3(11, 7, 12);
691
+ break;
692
+ }
693
+
694
+ // ── 07 STANDARDIZE (bSDD) ────────────────────────────────────────
695
+ // bsdd_property_sets — walls take the deep "schema blue" cue, still
696
+ // isolated, with the data sheet overlay showing the canonical Pset.
697
+ case 7: {
698
+ dim(roofEl, 0.0);
699
+ dim(slabEl, 0.04);
700
+ dim(floor2El, 0.04);
701
+ for (const el of allWindows()) dim(el, 0.02);
702
+ for (const el of doorEls) dim(el, 0.02);
703
+ for (const el of allWalls()) setTarget(el, 0x2e5fc7);
704
+ cameraTarget = new THREE.Vector3(9, 5, 10);
705
+ break;
706
+ }
707
+
708
+ // ── 08 ADD ────────────────────────────────────────────────────────
709
+ // entity_create(IfcDoor) — reveal the new door on the +X face,
710
+ // restore non-wall opacities so the building reads in context, swing
711
+ // the camera over so the addition is unmistakable.
712
+ case 8: {
713
+ for (const el of allWindows()) {
714
+ el.targetOpacity = 1;
715
+ el.targetColor.setHex(0x2c3a52);
716
+ }
717
+ for (const el of doorEls) {
718
+ el.targetOpacity = 1;
719
+ el.targetColor.setHex(0x2a2a30);
720
+ }
721
+ slabEl.targetOpacity = 1;
722
+ floor2El.targetOpacity = 1;
723
+ roofEl.targetOpacity = 1;
724
+ for (const el of allWalls()) {
725
+ el.targetOpacity = 1;
726
+ el.targetColor.setHex(0x6c6c75);
727
+ }
728
+ keepNewDoor();
729
+ cameraTarget = cameraRightAngle.clone();
730
+ break;
731
+ }
732
+
733
+ // ── 09 SECTION ────────────────────────────────────────────────────
734
+ // viewer_set_section(z=2.2) — clip the upper storey progressively;
735
+ // a chartreuse plane rectangle marks where the cut is. Camera drops
736
+ // low so the section reads as a horizontal slice.
737
+ case 9: {
738
+ keepNewDoor();
739
+ sectionConstantTarget = 2.2;
740
+ sectionVisOpacityTarget = 0.22;
741
+ cameraTarget = new THREE.Vector3(10, 4, 12);
742
+ break;
743
+ }
744
+
745
+ // ── 10 ISSUE (BCF) ──────────────────────────────────────────────
746
+ // bcf_topic_create — the 3D pin sprite (anchored to the front wall
747
+ // top-right) fades in. The wall the pin sits on flashes red so the
748
+ // anchor is unambiguous. Section stays clipped to keep the lower
749
+ // storey reading.
750
+ case 10: {
751
+ keepNewDoor();
752
+ sectionConstantTarget = 2.2;
753
+ sectionVisOpacityTarget = 0.14;
754
+ const issueWall = externalByStorey[0].outer[0]; // FRONT wall
755
+ for (const el of allWalls()) dim(el, 0.45);
756
+ setTarget(issueWall, 0xff3a3a, 1);
757
+ pinOpacityTarget = 1;
758
+ cameraTarget = new THREE.Vector3(9.5, 4, 10);
759
+ break;
760
+ }
761
+
762
+ // ── 11 INSPECT ────────────────────────────────────────────────────
763
+ // viewer_describe_selection — clear section, dim the building down,
764
+ // light up the picked wall in magenta. The describe-card overlay
765
+ // does the rest.
766
+ case 11: {
767
+ keepNewDoor();
768
+ sectionConstantTarget = 100;
769
+ sectionVisOpacityTarget = 0;
770
+ const pickedEl = externalByStorey[0].outer[0];
771
+ for (const el of allWalls()) dim(el, 0.18);
772
+ for (const el of allWindows()) dim(el, 0.5);
773
+ setTarget(pickedEl, ACCENT_2, 1);
774
+ cameraTarget = cameraClose.clone();
775
+ break;
776
+ }
777
+
778
+ default: {
779
+ cameraTarget = cameraDefault.clone();
780
+ }
781
+ }
782
+ }
783
+
784
+ update(0);
785
+
786
+ // Re-usable scratch vector to avoid alloc churn in projectPin().
787
+ const projScratch = new THREE.Vector3();
788
+
789
+ return {
790
+ update,
791
+ projectPin() {
792
+ const w = container.clientWidth;
793
+ const h = container.clientHeight;
794
+ if (w === 0 || h === 0) return null;
795
+ projScratch.copy(pin.position).project(camera);
796
+ const visible = projScratch.z >= -1 && projScratch.z <= 1;
797
+ return {
798
+ x: ((projScratch.x + 1) / 2) * w,
799
+ y: ((-projScratch.y + 1) / 2) * h,
800
+ visible,
801
+ };
802
+ },
803
+ dispose() {
804
+ disposed = true;
805
+ cancelAnimationFrame(raf);
806
+ ro.disconnect();
807
+ controls.dispose();
808
+ if (resumeTimer) clearTimeout(resumeTimer);
809
+ scene.traverse((obj) => {
810
+ const mesh = obj as THREE.Mesh;
811
+ if (mesh.geometry) mesh.geometry.dispose();
812
+ const mat = mesh.material as THREE.Material | THREE.Material[] | undefined;
813
+ if (Array.isArray(mat)) mat.forEach((m) => m.dispose());
814
+ else if (mat) mat.dispose();
815
+ });
816
+ // The pin sprite uses a CanvasTexture allocated outside the
817
+ // material-walk above (the sprite material's `map` is set, but
818
+ // scene.traverse only disposes materials & geometries). Drop it
819
+ // explicitly so the canvas-backed GPU texture doesn't leak across
820
+ // mount/unmount cycles.
821
+ pinTex.dispose();
822
+ renderer.dispose();
823
+ if (renderer.domElement.parentNode === container) {
824
+ container.removeChild(renderer.domElement);
825
+ }
826
+ },
827
+ };
828
+ }
829
+
830
+ // ── geometry helpers ──────────────────────────────────────────────────────
831
+
832
+ /**
833
+ * Build a hip-roof BufferGeometry for a rectangular footprint W × D with a
834
+ * given peak `height` and `overhang` past the eaves. The ridge runs along
835
+ * the long axis (X). All faces are non-indexed so per-face normals shade
836
+ * cleanly without averaging across the ridge.
837
+ */
838
+ function makeHipRoof(W: number, D: number, height: number, overhang: number): THREE.BufferGeometry {
839
+ const hx = W / 2 + overhang;
840
+ const hz = D / 2 + overhang;
841
+ // 45° hips on the short ends → the ridge is hz inset from each X edge.
842
+ const ridgeX = Math.max(0, hx - hz);
843
+
844
+ // Eave corners (y = 0) and the two ridge endpoints (y = height).
845
+ const FL: [number, number, number] = [-hx, 0, hz]; // front-left
846
+ const FR: [number, number, number] = [hx, 0, hz]; // front-right
847
+ const BR: [number, number, number] = [hx, 0, -hz]; // back-right
848
+ const BL: [number, number, number] = [-hx, 0, -hz]; // back-left
849
+ const RL: [number, number, number] = [-ridgeX, height, 0]; // ridge-left
850
+ const RR: [number, number, number] = [ridgeX, height, 0]; // ridge-right
851
+
852
+ const positions: number[] = [];
853
+ function tri(a: [number, number, number], b: [number, number, number], c: [number, number, number]) {
854
+ positions.push(...a, ...b, ...c);
855
+ }
856
+ function quad(a: [number, number, number], b: [number, number, number], c: [number, number, number], d: [number, number, number]) {
857
+ tri(a, b, c);
858
+ tri(a, c, d);
859
+ }
860
+
861
+ // Vertex order is CCW when viewed from outside the roof. Three.js will
862
+ // recompute normals after we set positions.
863
+ // Front (looking from +Z): FL → FR → RR → RL
864
+ quad(FL, FR, RR, RL);
865
+ // Right end (looking from +X): FR → BR → RR (triangle)
866
+ tri(FR, BR, RR);
867
+ // Back (looking from -Z): BR → BL → RL → RR
868
+ quad(BR, BL, RL, RR);
869
+ // Left end (looking from -X): BL → FL → RL (triangle)
870
+ tri(BL, FL, RL);
871
+
872
+ const geom = new THREE.BufferGeometry();
873
+ geom.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
874
+ geom.computeVertexNormals();
875
+ return geom;
876
+ }