@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,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
|
+
}
|