react-babylon-map 0.0.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 (211) hide show
  1. package/.claude/settings.local.json +78 -0
  2. package/demo.html +161 -0
  3. package/dist/cjs/main.js +520 -0
  4. package/dist/es/main.mjs +20 -0
  5. package/dist/es/main.mjs.map +1 -0
  6. package/dist/es/main10.mjs +33 -0
  7. package/dist/es/main10.mjs.map +1 -0
  8. package/dist/es/main11.mjs +12 -0
  9. package/dist/es/main11.mjs.map +1 -0
  10. package/dist/es/main12.mjs +14 -0
  11. package/dist/es/main12.mjs.map +1 -0
  12. package/dist/es/main13.mjs +12 -0
  13. package/dist/es/main13.mjs.map +1 -0
  14. package/dist/es/main14.mjs +5 -0
  15. package/dist/es/main14.mjs.map +1 -0
  16. package/dist/es/main15.mjs +12 -0
  17. package/dist/es/main15.mjs.map +1 -0
  18. package/dist/es/main16.mjs +25 -0
  19. package/dist/es/main16.mjs.map +1 -0
  20. package/dist/es/main17.mjs +54 -0
  21. package/dist/es/main17.mjs.map +1 -0
  22. package/dist/es/main18.mjs +88 -0
  23. package/dist/es/main18.mjs.map +1 -0
  24. package/dist/es/main19.mjs +18 -0
  25. package/dist/es/main19.mjs.map +1 -0
  26. package/dist/es/main2.mjs +9 -0
  27. package/dist/es/main2.mjs.map +1 -0
  28. package/dist/es/main20.mjs +21 -0
  29. package/dist/es/main20.mjs.map +1 -0
  30. package/dist/es/main21.mjs +61 -0
  31. package/dist/es/main21.mjs.map +1 -0
  32. package/dist/es/main3.mjs +46 -0
  33. package/dist/es/main3.mjs.map +1 -0
  34. package/dist/es/main4.mjs +23 -0
  35. package/dist/es/main4.mjs.map +1 -0
  36. package/dist/es/main5.mjs +69 -0
  37. package/dist/es/main5.mjs.map +1 -0
  38. package/dist/es/main6.mjs +35 -0
  39. package/dist/es/main6.mjs.map +1 -0
  40. package/dist/es/main7.mjs +65 -0
  41. package/dist/es/main7.mjs.map +1 -0
  42. package/dist/es/main8.mjs +14 -0
  43. package/dist/es/main8.mjs.map +1 -0
  44. package/dist/es/main9.mjs +26 -0
  45. package/dist/es/main9.mjs.map +1 -0
  46. package/dist/maplibre/cjs/main.js +520 -0
  47. package/dist/maplibre/es/main.mjs +20 -0
  48. package/dist/maplibre/es/main.mjs.map +1 -0
  49. package/dist/maplibre/es/main10.mjs +33 -0
  50. package/dist/maplibre/es/main10.mjs.map +1 -0
  51. package/dist/maplibre/es/main11.mjs +12 -0
  52. package/dist/maplibre/es/main11.mjs.map +1 -0
  53. package/dist/maplibre/es/main12.mjs +14 -0
  54. package/dist/maplibre/es/main12.mjs.map +1 -0
  55. package/dist/maplibre/es/main13.mjs +12 -0
  56. package/dist/maplibre/es/main13.mjs.map +1 -0
  57. package/dist/maplibre/es/main14.mjs +5 -0
  58. package/dist/maplibre/es/main14.mjs.map +1 -0
  59. package/dist/maplibre/es/main15.mjs +12 -0
  60. package/dist/maplibre/es/main15.mjs.map +1 -0
  61. package/dist/maplibre/es/main16.mjs +25 -0
  62. package/dist/maplibre/es/main16.mjs.map +1 -0
  63. package/dist/maplibre/es/main17.mjs +54 -0
  64. package/dist/maplibre/es/main17.mjs.map +1 -0
  65. package/dist/maplibre/es/main18.mjs +88 -0
  66. package/dist/maplibre/es/main18.mjs.map +1 -0
  67. package/dist/maplibre/es/main19.mjs +18 -0
  68. package/dist/maplibre/es/main19.mjs.map +1 -0
  69. package/dist/maplibre/es/main2.mjs +9 -0
  70. package/dist/maplibre/es/main2.mjs.map +1 -0
  71. package/dist/maplibre/es/main20.mjs +61 -0
  72. package/dist/maplibre/es/main20.mjs.map +1 -0
  73. package/dist/maplibre/es/main21.mjs +21 -0
  74. package/dist/maplibre/es/main21.mjs.map +1 -0
  75. package/dist/maplibre/es/main3.mjs +46 -0
  76. package/dist/maplibre/es/main3.mjs.map +1 -0
  77. package/dist/maplibre/es/main4.mjs +23 -0
  78. package/dist/maplibre/es/main4.mjs.map +1 -0
  79. package/dist/maplibre/es/main5.mjs +69 -0
  80. package/dist/maplibre/es/main5.mjs.map +1 -0
  81. package/dist/maplibre/es/main6.mjs +35 -0
  82. package/dist/maplibre/es/main6.mjs.map +1 -0
  83. package/dist/maplibre/es/main7.mjs +65 -0
  84. package/dist/maplibre/es/main7.mjs.map +1 -0
  85. package/dist/maplibre/es/main8.mjs +14 -0
  86. package/dist/maplibre/es/main8.mjs.map +1 -0
  87. package/dist/maplibre/es/main9.mjs +26 -0
  88. package/dist/maplibre/es/main9.mjs.map +1 -0
  89. package/dist/maplibre/types/api/canvas-props.d.ts +9 -0
  90. package/dist/maplibre/types/api/coordinates.d.ts +13 -0
  91. package/dist/maplibre/types/api/coords-to-vector-3.d.ts +3 -0
  92. package/dist/maplibre/types/api/coords.d.ts +5 -0
  93. package/dist/maplibre/types/api/index.d.ts +7 -0
  94. package/dist/maplibre/types/api/near-coordinates.d.ts +13 -0
  95. package/dist/maplibre/types/api/use-map.d.ts +3 -0
  96. package/dist/maplibre/types/api/vector-3-to-coords.d.ts +2 -0
  97. package/dist/maplibre/types/core/canvas-in-layer/use-canvas-in-layer.d.ts +15 -0
  98. package/dist/maplibre/types/core/canvas-in-layer/use-render.d.ts +15 -0
  99. package/dist/maplibre/types/core/canvas-in-layer/use-root.d.ts +11 -0
  100. package/dist/maplibre/types/core/canvas-overlay/canvas-portal.d.ts +10 -0
  101. package/dist/maplibre/types/core/canvas-overlay/init-canvas-fc.d.ts +11 -0
  102. package/dist/maplibre/types/core/canvas-overlay/render.d.ts +1 -0
  103. package/dist/maplibre/types/core/canvas-overlay/sync-camera-fc.d.ts +12 -0
  104. package/dist/maplibre/types/core/coords-to-matrix.d.ts +9 -0
  105. package/dist/maplibre/types/core/earth-radius.d.ts +1 -0
  106. package/dist/maplibre/types/core/generic-map.d.ts +49 -0
  107. package/dist/maplibre/types/core/matrix-utils.d.ts +7 -0
  108. package/dist/maplibre/types/core/sync-camera.d.ts +7 -0
  109. package/dist/maplibre/types/core/use-babylon-map.d.ts +32 -0
  110. package/dist/maplibre/types/core/use-coords-to-matrix.d.ts +6 -0
  111. package/dist/maplibre/types/core/use-coords.d.ts +5 -0
  112. package/dist/maplibre/types/core/use-function.d.ts +1 -0
  113. package/dist/maplibre/types/maplibre/canvas.d.ts +4 -0
  114. package/dist/maplibre/types/maplibre.index.d.ts +4 -0
  115. package/dist/types/api/canvas-props.d.ts +9 -0
  116. package/dist/types/api/coordinates.d.ts +13 -0
  117. package/dist/types/api/coords-to-vector-3.d.ts +3 -0
  118. package/dist/types/api/coords.d.ts +5 -0
  119. package/dist/types/api/index.d.ts +7 -0
  120. package/dist/types/api/near-coordinates.d.ts +13 -0
  121. package/dist/types/api/use-map.d.ts +3 -0
  122. package/dist/types/api/vector-3-to-coords.d.ts +2 -0
  123. package/dist/types/core/canvas-in-layer/use-canvas-in-layer.d.ts +15 -0
  124. package/dist/types/core/canvas-in-layer/use-render.d.ts +15 -0
  125. package/dist/types/core/canvas-in-layer/use-root.d.ts +11 -0
  126. package/dist/types/core/canvas-overlay/canvas-portal.d.ts +10 -0
  127. package/dist/types/core/canvas-overlay/init-canvas-fc.d.ts +11 -0
  128. package/dist/types/core/canvas-overlay/render.d.ts +1 -0
  129. package/dist/types/core/canvas-overlay/sync-camera-fc.d.ts +12 -0
  130. package/dist/types/core/coords-to-matrix.d.ts +9 -0
  131. package/dist/types/core/earth-radius.d.ts +1 -0
  132. package/dist/types/core/generic-map.d.ts +49 -0
  133. package/dist/types/core/matrix-utils.d.ts +7 -0
  134. package/dist/types/core/sync-camera.d.ts +7 -0
  135. package/dist/types/core/use-babylon-map.d.ts +32 -0
  136. package/dist/types/core/use-coords-to-matrix.d.ts +6 -0
  137. package/dist/types/core/use-coords.d.ts +5 -0
  138. package/dist/types/core/use-function.d.ts +1 -0
  139. package/dist/types/mapbox/canvas.d.ts +4 -0
  140. package/dist/types/mapbox.index.d.ts +4 -0
  141. package/package.json +58 -0
  142. package/plan.md +719 -0
  143. package/src/api/canvas-props.ts +10 -0
  144. package/src/api/coordinates.tsx +83 -0
  145. package/src/api/coords-to-vector-3.ts +39 -0
  146. package/src/api/coords.tsx +6 -0
  147. package/src/api/index.ts +7 -0
  148. package/src/api/near-coordinates.tsx +87 -0
  149. package/src/api/use-map.ts +8 -0
  150. package/src/api/vector-3-to-coords.ts +13 -0
  151. package/src/core/canvas-in-layer/use-canvas-in-layer.tsx +27 -0
  152. package/src/core/canvas-in-layer/use-render.ts +43 -0
  153. package/src/core/canvas-in-layer/use-root.tsx +82 -0
  154. package/src/core/canvas-overlay/canvas-portal.tsx +98 -0
  155. package/src/core/canvas-overlay/init-canvas-fc.tsx +45 -0
  156. package/src/core/canvas-overlay/render.tsx +1 -0
  157. package/src/core/canvas-overlay/sync-camera-fc.tsx +83 -0
  158. package/src/core/coords-to-matrix.ts +21 -0
  159. package/src/core/earth-radius.ts +1 -0
  160. package/src/core/events.ts +55 -0
  161. package/src/core/generic-map.ts +59 -0
  162. package/src/core/map-engine.tsx +70 -0
  163. package/src/core/matrix-utils.ts +22 -0
  164. package/src/core/sync-camera.ts +29 -0
  165. package/src/core/use-babylon-map.ts +46 -0
  166. package/src/core/use-coords-to-matrix.ts +13 -0
  167. package/src/core/use-coords.tsx +22 -0
  168. package/src/core/use-function.ts +10 -0
  169. package/src/mapbox/canvas.tsx +59 -0
  170. package/src/mapbox.index.ts +7 -0
  171. package/src/maplibre/canvas.tsx +59 -0
  172. package/src/maplibre.index.ts +7 -0
  173. package/src/vite-env.d.ts +1 -0
  174. package/stories/.ladle/components.tsx +50 -0
  175. package/stories/.ladle/style.css +63 -0
  176. package/stories/package.json +31 -0
  177. package/stories/pnpm-lock.yaml +5450 -0
  178. package/stories/sandbox.config.json +3 -0
  179. package/stories/src/adaptive-dpr.tsx +34 -0
  180. package/stories/src/billboard.stories.tsx +111 -0
  181. package/stories/src/buildings-3d.stories.tsx +280 -0
  182. package/stories/src/canvas/mapbox.stories.tsx +113 -0
  183. package/stories/src/canvas/maplibre.stories.tsx +93 -0
  184. package/stories/src/comparison.stories.tsx +161 -0
  185. package/stories/src/extrude/chaillot.ts +8 -0
  186. package/stories/src/exude-coordinates.stories.tsx +139 -0
  187. package/stories/src/free-3d-buildings/get-buildings-data.ts +49 -0
  188. package/stories/src/html-on-top.stories.tsx +156 -0
  189. package/stories/src/ifc/ifc-to-babylon.ts +97 -0
  190. package/stories/src/ifc/ifc.main.ts +904 -0
  191. package/stories/src/ifc/ifc2bb.ts +343 -0
  192. package/stories/src/ifc/model.ifc +14155 -0
  193. package/stories/src/ifc.stories.tsx +276 -0
  194. package/stories/src/mapbox/story-mapbox.tsx +97 -0
  195. package/stories/src/maplibre/story-maplibre.tsx +36 -0
  196. package/stories/src/multi-coordinates.stories.tsx +115 -0
  197. package/stories/src/pivot-controls.stories.tsx +148 -0
  198. package/stories/src/postprocessing.stories.tsx +125 -0
  199. package/stories/src/render-on-demand.stories.tsx +76 -0
  200. package/stories/src/story-map.tsx +44 -0
  201. package/stories/src/sunlight.stories.tsx +215 -0
  202. package/stories/src/vite-env.d.ts +1 -0
  203. package/stories/tsconfig.json +32 -0
  204. package/stories/tsconfig.node.json +10 -0
  205. package/stories/vite.config.ts +27 -0
  206. package/tsconfig.json +31 -0
  207. package/tsconfig.mapbox.json +7 -0
  208. package/tsconfig.maplibre.json +7 -0
  209. package/tsconfig.node.json +10 -0
  210. package/tsconfig.types.json +25 -0
  211. package/vite.config.ts +65 -0
@@ -0,0 +1,904 @@
1
+
2
+ /* This Source Code Form is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
5
+
6
+ /**
7
+ * IFC viewer: @ifc-lite/geometry + Babylon.js
8
+ *
9
+ * Loading strategy:
10
+ * 1. Geometry streams progressively via @ifc-lite/geometry WASM.
11
+ * Each batch is vertex-color-batched and added to the scene immediately.
12
+ * 2. On 'complete', the whole model is rebuilt as a single optimised mesh.
13
+ * Temporary batch groups are disposed one frame later (no visual pop).
14
+ * 3. In parallel, @ifc-lite/parser builds a columnar data store for
15
+ * entity attributes, property sets, and the spatial hierarchy tree.
16
+ *
17
+ * Interaction model:
18
+ * • Hover → scene.pick updates cursor (crosshair → pointer, frame-throttled)
19
+ * • Orbit → grabbing cursor via pointer event tracking
20
+ * • Click → pick entity → highlight + open properties panel + reveal in tree
21
+ * • Escape → clear selection
22
+ * • Tree → click spatial node to select / two-way sync with 3D selection
23
+ */
24
+
25
+ import {
26
+ Engine,
27
+ Scene,
28
+ ArcRotateCamera,
29
+ HemisphericLight,
30
+ DirectionalLight,
31
+ Vector3,
32
+ Color3,
33
+ Color4,
34
+ Mesh,
35
+ VertexData,
36
+ StandardMaterial,
37
+ TransformNode,
38
+ } from '@babylonjs/core';
39
+ import { GeometryProcessor, type MeshData } from '@ifc-lite/geometry';
40
+ import {
41
+ batchWithVertexColors,
42
+ findEntityByFace,
43
+ disposeNode,
44
+ computeBounds,
45
+ type ExpressIdMap,
46
+ type TriangleMaps,
47
+ } from './ifc-to-babylon.js';
48
+ import {
49
+ buildDataStore,
50
+ buildSpatialTreeFromStore,
51
+ getEntityData,
52
+ IfcTypeEnum,
53
+ type IfcDataStore,
54
+ type EntityData,
55
+ type SpatialTreeNode,
56
+ } from './ifc-data.js';
57
+
58
+ // ── DOM refs ──────────────────────────────────────────────────────────
59
+ const canvas = document.getElementById('viewer') as HTMLCanvasElement;
60
+ const fileInput = document.getElementById('file-input') as HTMLInputElement;
61
+ const status = document.getElementById('status') as HTMLElement;
62
+ const selectionPanel = document.getElementById('selection-panel') as HTMLElement;
63
+ const entityTypeBadge = document.getElementById('entity-type-badge');
64
+ const entityIdEl = document.getElementById('entity-id');
65
+ const panelBody = document.getElementById('panel-body') as HTMLElement;
66
+ const panelClose = document.getElementById('panel-close') as HTMLButtonElement;
67
+ const spatialTree = document.getElementById('spatial-tree') as HTMLElement;
68
+ const spatialSearch = document.getElementById('spatial-search') as HTMLInputElement;
69
+ const spatialCount = document.getElementById('spatial-entity-count') as HTMLElement;
70
+
71
+ if (!canvas || !fileInput || !status || !selectionPanel || !panelBody || !panelClose) {
72
+ throw new Error('Required DOM elements missing — check index.html');
73
+ }
74
+
75
+ // ── Babylon.js setup ──────────────────────────────────────────────────
76
+ // preserveDrawingBuffer: false — no GPU→CPU readback every frame (major perf win)
77
+ // stencil: false — stencil buffer unused, saves memory bandwidth
78
+ const engine = new Engine(canvas, true, { preserveDrawingBuffer: false, stencil: false });
79
+ const scene = new Scene(engine);
80
+ scene.clearColor = new Color4(0.051, 0.106, 0.165, 1); // #0d1b2a
81
+ // Skip Babylon's own pointer-move picking — we do our own rAF-throttled hover
82
+ scene.skipPointerMovePicking = true;
83
+
84
+ const camera = new ArcRotateCamera(
85
+ 'camera',
86
+ -Math.PI / 4, // alpha (horizontal rotation) - south-west home view
87
+ (65 * Math.PI) / 180, // beta (vertical) -> ~25deg elevation above horizon
88
+ 50, // radius
89
+ Vector3.Zero(), // target
90
+ scene,
91
+ );
92
+ camera.attachControl(canvas, true);
93
+ camera.inertia = 0; // sharp stop on pointer release
94
+ camera.minZ = 0.1;
95
+ camera.maxZ = 10000;
96
+ // Match Three.js OrbitControls feel — lower = more sensitive
97
+ camera.wheelPrecision = 3; // zoom: default 3, was 10 (too sluggish)
98
+ camera.panningSensibility = 100; // pan: lower = more pan per drag
99
+ camera.angularSensibilityX = 300; // orbit: lower = more rotation per drag
100
+ camera.angularSensibilityY = 300;
101
+ const HOME_ALPHA = -Math.PI / 4;
102
+ const HOME_BETA = (65 * Math.PI) / 180;
103
+
104
+ // ── Lighting — hemispheric + one directional is plenty ────────────────
105
+ const hemiLight = new HemisphericLight('hemiLight', new Vector3(0, 1, 0), scene);
106
+ hemiLight.intensity = 0.7;
107
+ hemiLight.diffuse = new Color3(1, 1, 1);
108
+ hemiLight.groundColor = new Color3(0.3, 0.3, 0.35);
109
+
110
+ const dirLight = new DirectionalLight('dirLight', new Vector3(-1, -2, -1).normalize(), scene);
111
+ dirLight.intensity = 0.9;
112
+ dirLight.position = new Vector3(50, 80, 50);
113
+
114
+ // ── State ─────────────────────────────────────────────────────────────
115
+ const geometryProc = new GeometryProcessor();
116
+ const expressIdMap: ExpressIdMap = new Map();
117
+ const meshDataByExpressId = new Map<number, MeshData>();
118
+ let triangleMaps: TriangleMaps = new Map();
119
+ let dataStore: IfcDataStore | null = null;
120
+ let spatialRoot: SpatialTreeNode | null = null;
121
+
122
+ /** Root TransformNode for all final geometry — quick clearScene disposal */
123
+ let modelRoot: TransformNode | null = null;
124
+ /** Root TransformNode for progressively streamed batch geometry. */
125
+ let streamRoot: TransformNode | null = null;
126
+
127
+ let selectedExpressId: number | null = null;
128
+ let hoveredId: number | null = null;
129
+ let selectionHighlight: Mesh | null = null;
130
+
131
+ // ── Resize ────────────────────────────────────────────────────────────
132
+ window.addEventListener('resize', () => engine.resize());
133
+
134
+ // ── Render loop (continuous, like Three.js) ────────────────────────────
135
+ engine.runRenderLoop(() => scene.render());
136
+
137
+ // ── Picking helper ───────────────────────────────────────────────────
138
+ function pickAt(clientX: number, clientY: number): number | null {
139
+ if (triangleMaps.size === 0) return null;
140
+ const rect = canvas.getBoundingClientRect();
141
+ const x = clientX - rect.left;
142
+ const y = clientY - rect.top;
143
+ const pickResult = scene.pick(x, y);
144
+ if (!pickResult.hit || !pickResult.pickedMesh || pickResult.faceId < 0) return null;
145
+ const ranges = triangleMaps.get(pickResult.pickedMesh as Mesh);
146
+ return ranges ? findEntityByFace(ranges, pickResult.faceId) : null;
147
+ }
148
+
149
+ // ── Pointer / cursor management ───────────────────────────────────────
150
+ //
151
+ // Strategy: track drag-vs-click ourselves so we can:
152
+ // • only show `grabbing` cursor when the pointer actually moves (not on a mere click)
153
+ // • skip the selection handler after a genuine drag/orbit
154
+ //
155
+ // ArcRotateCamera handles orbit/pan/zoom internally; our listeners are
156
+ // purely for cursor control and click-to-select.
157
+
158
+ const DRAG_THRESHOLD_PX = 4;
159
+ let pointerDownX = 0;
160
+ let pointerDownY = 0;
161
+ let didDrag = false;
162
+
163
+ canvas.addEventListener('pointerdown', (e) => {
164
+ pointerDownX = e.clientX;
165
+ pointerDownY = e.clientY;
166
+ didDrag = false;
167
+ });
168
+
169
+ // Hover cursor: rAF-throttled, skipped while a button is held.
170
+ let hoverRafPending = false;
171
+ canvas.addEventListener('pointermove', (e) => {
172
+ if (e.buttons !== 0) {
173
+ // Pointer button held — detect drag and switch to grabbing
174
+ if (!didDrag) {
175
+ const dist = Math.hypot(e.clientX - pointerDownX, e.clientY - pointerDownY);
176
+ if (dist > DRAG_THRESHOLD_PX) {
177
+ didDrag = true;
178
+ canvas.classList.add('dragging');
179
+ canvas.classList.remove('hovering');
180
+ }
181
+ }
182
+ return; // no hover detection while dragging
183
+ }
184
+
185
+ // No button held — update hover cursor once per frame
186
+ if (hoverRafPending) return;
187
+ hoverRafPending = true;
188
+ const cx = e.clientX;
189
+ const cy = e.clientY;
190
+ requestAnimationFrame(() => {
191
+ hoverRafPending = false;
192
+ const id = pickAt(cx, cy);
193
+ hoveredId = id;
194
+ canvas.classList.toggle('hovering', id != null);
195
+ });
196
+ });
197
+
198
+ canvas.addEventListener('pointerup', () => {
199
+ canvas.classList.remove('dragging');
200
+ });
201
+
202
+ canvas.addEventListener('mouseleave', () => {
203
+ hoveredId = null;
204
+ canvas.classList.remove('hovering', 'dragging');
205
+ });
206
+
207
+
208
+ // ── Click → pick (only on genuine clicks, not after a drag) ───────────
209
+ canvas.addEventListener('click', (e) => {
210
+ if (didDrag) return; // orbit/pan completed — not a selection click
211
+
212
+ const expressId = pickAt(e.clientX, e.clientY);
213
+ if (expressId == null) {
214
+ clearSelection();
215
+ closePanel();
216
+ } else {
217
+ selectEntity(expressId);
218
+ }
219
+ });
220
+
221
+ // ── Keyboard ──────────────────────────────────────────────────────────
222
+ window.addEventListener('keydown', (e) => {
223
+ if (e.key === 'Escape') { clearSelection(); closePanel(); }
224
+ });
225
+
226
+ // ── File loading ──────────────────────────────────────────────────────
227
+ fileInput.addEventListener('change', async () => {
228
+ const file = fileInput.files?.[0];
229
+ if (!file) return;
230
+
231
+ status.textContent = `Loading ${file.name}…`;
232
+ clearSelection();
233
+ closePanel();
234
+ resetSpatialPanel();
235
+
236
+ // ── Timing ── start the clock before EVERYTHING (WASM init + file read + streaming + parser)
237
+ const totalStartTime = performance.now();
238
+
239
+ try {
240
+ await geometryProc.init();
241
+
242
+ // ── File read ─────────────────────────────────────────────────
243
+ const fileReadStart = performance.now();
244
+ const rawBuffer = await file.arrayBuffer();
245
+ const fileReadMs = performance.now() - fileReadStart;
246
+ const fileSizeMB = rawBuffer.byteLength / (1024 * 1024);
247
+ console.log(`[BabylonJS] File: ${file.name}, size: ${fileSizeMB.toFixed(2)} MB, read in ${fileReadMs.toFixed(0)} ms`);
248
+
249
+ const buffer = new Uint8Array(rawBuffer);
250
+
251
+ clearScene();
252
+
253
+ const allMeshes: MeshData[] = [];
254
+ const batchRoots: TransformNode[] = [];
255
+
256
+ // Per-batch timing
257
+ let batchCount = 0;
258
+ let firstBatchMs = 0;
259
+ let geometryMs = 0; // set at 'complete'
260
+ let finalMeshCount = 0;
261
+
262
+ // Low-overhead stream fit: running bounds from raw mesh data + sparse refits.
263
+ const STREAM_FIT_BATCH_INTERVAL = 26;
264
+ let streamMinX = Infinity, streamMinY = Infinity, streamMinZ = Infinity;
265
+ let streamMaxX = -Infinity, streamMaxY = -Infinity, streamMaxZ = -Infinity;
266
+ let hasStreamBounds = false;
267
+
268
+ function expandStreamBoundsFromMeshes(meshes: MeshData[]) {
269
+ for (const m of meshes) {
270
+ const p = m.positions;
271
+ for (let i = 0; i < p.length; i += 3) {
272
+ const x = p[i];
273
+ const y = p[i + 1];
274
+ const z = p[i + 2];
275
+ if (x < streamMinX) streamMinX = x;
276
+ if (y < streamMinY) streamMinY = y;
277
+ if (z < streamMinZ) streamMinZ = z;
278
+ if (x > streamMaxX) streamMaxX = x;
279
+ if (y > streamMaxY) streamMaxY = y;
280
+ if (z > streamMaxZ) streamMaxZ = z;
281
+ }
282
+ }
283
+ hasStreamBounds = true;
284
+ }
285
+
286
+ function getStreamBounds(): { center: Vector3; maxDim: number } | null {
287
+ if (!hasStreamBounds) return null;
288
+ const center = new Vector3(
289
+ (streamMinX + streamMaxX) / 2,
290
+ (streamMinY + streamMaxY) / 2,
291
+ (streamMinZ + streamMaxZ) / 2,
292
+ );
293
+ const sizeX = streamMaxX - streamMinX;
294
+ const sizeY = streamMaxY - streamMinY;
295
+ const sizeZ = streamMaxZ - streamMinZ;
296
+ const maxDim = Math.max(sizeX, sizeY, sizeZ);
297
+ if (maxDim <= 0 || !isFinite(maxDim)) return null;
298
+ return { center, maxDim };
299
+ }
300
+
301
+ function fitIfDue() {
302
+ const bounds = getStreamBounds();
303
+ if (!bounds) return;
304
+ applyCameraFit(bounds.center, bounds.maxDim, false);
305
+ }
306
+
307
+ streamRoot = new TransformNode('stream-root', scene);
308
+
309
+ // The WASM `parseMeshesAsync` calls onBatch synchronously, so the entire
310
+ // for-await drains as microtasks — requestAnimationFrame never fires between
311
+ // batches. We fix this by explicitly yielding to the next animation frame
312
+ // inside each batch case. The main render loop then fires once per frame,
313
+ // painting accumulated geometry before we process the next batch.
314
+ for await (const event of geometryProc.processStreaming(buffer)) {
315
+ switch (event.type) {
316
+ case 'batch': {
317
+ allMeshes.push(...event.meshes);
318
+ expandStreamBoundsFromMeshes(event.meshes);
319
+ const { root } = batchWithVertexColors(event.meshes, scene);
320
+ root.parent = streamRoot;
321
+ batchRoots.push(root);
322
+
323
+ batchCount++;
324
+ if (batchCount === 1) {
325
+ firstBatchMs = performance.now() - totalStartTime;
326
+ console.log(`[BabylonJS] Batch #1: ${event.meshes.length} meshes, wait: ${firstBatchMs.toFixed(0)} ms`);
327
+ }
328
+
329
+ status.textContent = `Streaming… ${allMeshes.length} meshes`;
330
+ if (batchCount === 1 || batchCount % STREAM_FIT_BATCH_INTERVAL === 0) {
331
+ fitIfDue();
332
+ }
333
+
334
+ // Yield to the next animation frame — breaks the microtask chain so
335
+ // the render loop above can fire and paint this batch before we
336
+ // continue processing more geometry.
337
+ await new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
338
+ break;
339
+ }
340
+
341
+ case 'complete': {
342
+ geometryMs = performance.now() - totalStartTime;
343
+ finalMeshCount = event.totalMeshes;
344
+
345
+ const totalVertices = allMeshes.reduce((sum, m) => sum + m.positions.length / 3, 0);
346
+ console.log(
347
+ `[BabylonJS] Geometry streaming complete: ${batchCount} batches, ` +
348
+ `${finalMeshCount} meshes, ${(totalVertices / 1000).toFixed(0)}k vertices in ${geometryMs.toFixed(0)} ms`
349
+ );
350
+
351
+ const { root: finalRoot, expressIdMap: newMap, triangleMaps: newMaps } =
352
+ batchWithVertexColors(allMeshes, scene);
353
+
354
+ modelRoot = finalRoot;
355
+ for (const [id, mesh] of newMap) expressIdMap.set(id, mesh);
356
+ triangleMaps = newMaps;
357
+
358
+ meshDataByExpressId.clear();
359
+ for (const m of allMeshes) meshDataByExpressId.set(m.expressId, m);
360
+
361
+ fitCameraToScene();
362
+
363
+ // Dispose batch groups one frame later so there's no visual pop,
364
+ // then freeze world matrices and materials for orbit performance.
365
+ requestAnimationFrame(() => {
366
+ for (const root of batchRoots) disposeNode(root);
367
+ batchRoots.length = 0;
368
+ if (streamRoot) {
369
+ streamRoot.dispose();
370
+ streamRoot = null;
371
+ }
372
+
373
+ // Model is static — skip world matrix recalculation every frame
374
+ for (const mesh of scene.meshes) {
375
+ mesh.freezeWorldMatrix();
376
+ }
377
+ // Static materials — skip shader dirty checks every frame
378
+ for (const mat of scene.materials) {
379
+ mat.freeze();
380
+ }
381
+
382
+ const activeMeshes = scene.meshes.filter((m) => m.isEnabled());
383
+ status.textContent = `${file.name} — ${finalMeshCount} meshes · ${activeMeshes.length} draw calls`;
384
+ });
385
+
386
+ // Build property + spatial data store (last async phase — defines TOTAL end time)
387
+ dataStore = null;
388
+ spatialRoot = null;
389
+ buildDataStore(rawBuffer)
390
+ .then((store) => {
391
+ const totalMs = performance.now() - totalStartTime;
392
+ const parserMs = totalMs - geometryMs;
393
+ const totalVerts = allMeshes.reduce((sum, m) => sum + m.positions.length / 3, 0);
394
+
395
+ dataStore = store;
396
+ spatialRoot = buildSpatialTreeFromStore(store);
397
+ renderSpatialPanel(spatialRoot, store);
398
+
399
+ // Refresh panel if something is already selected
400
+ if (selectedExpressId !== null) {
401
+ const md = meshDataByExpressId.get(selectedExpressId);
402
+ if (md) renderPanel(getEntityData(store, selectedExpressId, md.ifcType ?? 'IfcProduct'));
403
+ }
404
+
405
+ // ── Final summary — matches main viewer style ──────────────
406
+ console.log(
407
+ `[BabylonJS] Done ${file.name} (${fileSizeMB.toFixed(1)} MB) -> ` +
408
+ `${finalMeshCount} meshes, ${(totalVerts / 1000).toFixed(0)}k vertices | ` +
409
+ `file: ${fileReadMs.toFixed(0)} ms, ` +
410
+ `first batch: ${firstBatchMs.toFixed(0)} ms, ` +
411
+ `geometry: ${geometryMs.toFixed(0)} ms, ` +
412
+ `parser: ${parserMs.toFixed(0)} ms`
413
+ );
414
+ console.log(`[BabylonJS] TOTAL LOAD TIME: ${totalMs.toFixed(0)} ms (${(totalMs / 1000).toFixed(1)} s)`);
415
+ })
416
+ .catch((err) => console.warn('[BabylonJS] buildDataStore failed:', err));
417
+
418
+ break;
419
+ }
420
+ }
421
+ }
422
+ } catch (err) {
423
+ console.error(err);
424
+ status.textContent = `Error: ${(err as Error).message}`;
425
+ }
426
+ });
427
+
428
+ // ── Selection ─────────────────────────────────────────────────────────
429
+ function selectEntity(expressId: number) {
430
+ selectedExpressId = expressId;
431
+
432
+ const md = meshDataByExpressId.get(expressId);
433
+ if (!md) return;
434
+
435
+ const ifcType = md.ifcType ?? 'IfcProduct';
436
+ openPanel(ifcType, expressId);
437
+ applyHighlight(md);
438
+
439
+ if (dataStore) {
440
+ renderPanel(getEntityData(dataStore, expressId, ifcType));
441
+ } else {
442
+ panelBody.innerHTML = `<p class="loading-data">Loading property data…</p>`;
443
+ }
444
+
445
+ revealInTree(expressId);
446
+ }
447
+
448
+ function clearSelection() {
449
+ selectedExpressId = null;
450
+ removeHighlight();
451
+ // De-highlight tree row
452
+ for (const row of spatialTree.querySelectorAll('.tree-row.selected')) {
453
+ row.classList.remove('selected');
454
+ }
455
+ }
456
+
457
+ // ── Selection highlight ───────────────────────────────────────────────
458
+ function applyHighlight(md: MeshData) {
459
+ removeHighlight();
460
+
461
+ const highlightMesh = new Mesh('selection-highlight', scene);
462
+ const vertexData = new VertexData();
463
+ vertexData.positions = md.positions;
464
+ vertexData.normals = md.normals;
465
+ vertexData.indices = md.indices;
466
+ vertexData.applyToMesh(highlightMesh);
467
+
468
+ const mat = new StandardMaterial('highlight-mat', scene);
469
+ mat.diffuseColor = new Color3(0.31, 0.27, 0.90); // #4f46e5
470
+ mat.emissiveColor = new Color3(0.14, 0.12, 0.40); // emissive tint
471
+ mat.alpha = 0.72;
472
+ mat.backFaceCulling = false;
473
+
474
+ highlightMesh.material = mat;
475
+ highlightMesh.renderingGroupId = 1; // render after main geometry
476
+ highlightMesh.isPickable = false; // don't interfere with entity picking
477
+ highlightMesh.freezeWorldMatrix(); // static once placed
478
+
479
+ selectionHighlight = highlightMesh;
480
+ }
481
+
482
+ function removeHighlight() {
483
+ if (!selectionHighlight) return;
484
+ if (selectionHighlight.material) selectionHighlight.material.dispose();
485
+ selectionHighlight.dispose();
486
+ selectionHighlight = null;
487
+ }
488
+
489
+ // ── Properties panel ──────────────────────────────────────────────────
490
+ function openPanel(ifcType: string, expressId: number) {
491
+ if (entityTypeBadge) entityTypeBadge.textContent = ifcType;
492
+ if (entityIdEl) entityIdEl.textContent = `#${expressId}`;
493
+ selectionPanel.classList.add('open');
494
+ }
495
+
496
+ function closePanel() {
497
+ selectionPanel.classList.remove('open');
498
+ clearSelection();
499
+ }
500
+
501
+ panelClose.addEventListener('click', closePanel);
502
+
503
+ function renderPanel(data: EntityData) {
504
+ const attrs: Array<[string, string]> = [
505
+ ['GlobalId', data.globalId || '—'],
506
+ ['Name', data.name || '—'],
507
+ ['Description', data.description || '—'],
508
+ ['ObjectType', data.objectType || '—'],
509
+ ['Tag', data.tag || '—'],
510
+ ];
511
+
512
+ const attrsHtml = attrs.map(([label, value]) => {
513
+ const empty = value === '—';
514
+ return `<div class="attr-row">
515
+ <span class="attr-label">${esc(label)}</span>
516
+ <span class="attr-value${empty ? ' empty' : ''}">${esc(value)}</span>
517
+ </div>`;
518
+ }).join('');
519
+
520
+ panelBody.innerHTML = `
521
+ <div class="attr-section">
522
+ <h3>Attributes</h3>${attrsHtml}
523
+ </div>
524
+ <div class="attr-section">
525
+ <h3>Property Sets</h3>
526
+ ${renderSets(data.propertySets.map(ps => ({
527
+ name: ps.name,
528
+ rows: ps.properties.map(p => [p.name, p.value] as [string, string]),
529
+ })), 'No property sets')}
530
+ </div>
531
+ <div class="attr-section">
532
+ <h3>Quantity Sets</h3>
533
+ ${renderSets(data.quantitySets.map(qs => ({
534
+ name: qs.name,
535
+ rows: qs.quantities.map(q => [q.name, q.value] as [string, string]),
536
+ })), 'No quantity sets')}
537
+ </div>`;
538
+
539
+ for (const btn of panelBody.querySelectorAll('.pset-toggle')) {
540
+ btn.addEventListener('click', () => {
541
+ btn.classList.toggle('open');
542
+ btn.nextElementSibling?.classList.toggle('open');
543
+ });
544
+ }
545
+ }
546
+
547
+ function renderSets(sets: Array<{ name: string; rows: Array<[string, string]> }>, emptyMsg: string) {
548
+ if (!sets.length) return `<p class="no-data">${emptyMsg}</p>`;
549
+ return sets.map(({ name, rows }) => `
550
+ <div class="pset-section">
551
+ <button class="pset-toggle" type="button">
552
+ <span>${esc(name)}</span><span class="pset-chevron">▶</span>
553
+ </button>
554
+ <div class="pset-body">
555
+ ${rows.map(([n, v]) => `<div class="pset-prop">
556
+ <span class="pset-prop-name">${esc(n)}</span>
557
+ <span class="pset-prop-value">${esc(v)}</span>
558
+ </div>`).join('')}
559
+ </div>
560
+ </div>`).join('');
561
+ }
562
+
563
+ // ── Spatial panel ─────────────────────────────────────────────────────
564
+
565
+ /** Render the full spatial tree into #spatial-tree */
566
+ function renderSpatialPanel(root: SpatialTreeNode | null, store: IfcDataStore) {
567
+ if (!root) {
568
+ spatialTree.innerHTML = `<p class="spatial-placeholder">No spatial structure found.</p>`;
569
+ return;
570
+ }
571
+
572
+ const total = root.totalElements;
573
+ spatialCount.textContent = `${total}`;
574
+ spatialCount.style.display = '';
575
+
576
+ spatialTree.innerHTML = buildNodeHtml(root, 0, store);
577
+
578
+ // Event delegation — one listener handles all tree clicks
579
+ spatialTree.onclick = handleTreeClick;
580
+
581
+ // Wire up search
582
+ spatialSearch.oninput = () => filterTree(spatialSearch.value.trim().toLowerCase(), store);
583
+ }
584
+
585
+ /** Reset panel to placeholder state */
586
+ function resetSpatialPanel() {
587
+ spatialTree.innerHTML = `<p class="spatial-placeholder">Open an IFC file to explore its structure.</p>`;
588
+ spatialTree.onclick = null;
589
+ spatialSearch.value = '';
590
+ spatialSearch.oninput = null;
591
+ spatialCount.style.display = 'none';
592
+ }
593
+
594
+ /** Build the HTML for a spatial node and all its descendants. */
595
+ function buildNodeHtml(node: SpatialTreeNode, depth: number, store: IfcDataStore): string {
596
+ const hasChildren = node.children.length > 0 || node.elementGroups.length > 0;
597
+ const { icon, abbr } = spatialNodeMeta(node.type);
598
+ const nameText = node.name || store.entities.getName(node.expressId) || `#${node.expressId}`;
599
+ const subLabel = node.elevation != null ? ` ${node.elevation.toFixed(1)}m` : '';
600
+
601
+ let childrenHtml = '';
602
+ for (const child of node.children) {
603
+ childrenHtml += buildNodeHtml(child, depth + 1, store);
604
+ }
605
+ for (const { typeName, ids } of node.elementGroups) {
606
+ childrenHtml += buildTypeGroupHtml(typeName, ids, depth + 1);
607
+ }
608
+
609
+ // Auto-expand top 2 levels (Project, Site)
610
+ const autoOpen = depth < 2;
611
+
612
+ return `
613
+ <div class="tree-node" id="sn-${node.expressId}">
614
+ <div class="tree-row"
615
+ data-express-id="${node.expressId}"
616
+ data-spatial="1"
617
+ style="--tree-depth:${depth}">
618
+ <span class="tree-toggle${hasChildren ? (autoOpen ? ' expanded' : '') : ' leaf'}"
619
+ data-toggle-id="${node.expressId}">▶</span>
620
+ <span class="tree-icon ${icon}">${abbr}</span>
621
+ <span class="tree-label">${esc(nameText)}</span>
622
+ ${subLabel ? `<span class="tree-sublabel">${esc(subLabel)}</span>` : ''}
623
+ ${node.totalElements > 0 ? `<span class="tree-count">${node.totalElements}</span>` : ''}
624
+ </div>
625
+ <div class="tree-children${autoOpen ? ' open' : ''}" id="sc-${node.expressId}">
626
+ ${childrenHtml}
627
+ </div>
628
+ </div>`;
629
+ }
630
+
631
+ /** Build a type-group row (e.g. "IfcWall ×12") and its element children. */
632
+ function buildTypeGroupHtml(typeName: string, ids: number[], depth: number): string {
633
+ const color = typeColor(typeName);
634
+ let elemRows = ids.map((id) => `
635
+ <div class="tree-row"
636
+ data-express-id="${id}"
637
+ data-element="1"
638
+ id="en-${id}"
639
+ style="--tree-depth:${depth + 1}">
640
+ <span class="tree-toggle leaf">▶</span>
641
+ <span class="tree-icon icon-element" style="background:${color}"></span>
642
+ <span class="tree-label dim">#${id}</span>
643
+ </div>`).join('');
644
+
645
+ return `
646
+ <div class="tree-node">
647
+ <div class="tree-row"
648
+ data-type-group="${esc(typeName)}"
649
+ style="--tree-depth:${depth}">
650
+ <span class="tree-toggle${ids.length ? '' : ' leaf'}" data-toggle-type="${esc(typeName)}-${depth}">▶</span>
651
+ <span class="tree-icon icon-type" style="font-size:8px">${esc(typeName.replace('Ifc', '').substring(0,3).toUpperCase())}</span>
652
+ <span class="tree-label">${esc(typeName.replace('Ifc', ''))}</span>
653
+ <span class="tree-count">${ids.length}</span>
654
+ </div>
655
+ <div class="tree-children" id="tg-${esc(typeName)}-${depth}">
656
+ ${elemRows}
657
+ </div>
658
+ </div>`;
659
+ }
660
+
661
+ /** Click delegation handler for the spatial tree. */
662
+ function handleTreeClick(e: MouseEvent) {
663
+ const target = e.target as HTMLElement;
664
+ const row = target.closest<HTMLElement>('.tree-row');
665
+ if (!row) return;
666
+
667
+ // Expand/collapse toggle
668
+ const toggleId = (target.closest('[data-toggle-id]') as HTMLElement | null)?.dataset.toggleId;
669
+ const toggleType = (target.closest('[data-toggle-type]') as HTMLElement | null)?.dataset.toggleType;
670
+ const toggleTarget = toggleId ?? toggleType;
671
+ if (toggleTarget) {
672
+ const toggle = row.querySelector('.tree-toggle') ?? target.closest('.tree-toggle');
673
+ const childrenId = toggleId ? `sc-${toggleId}` : `tg-${toggleType}`;
674
+ const children = document.getElementById(childrenId);
675
+ if (children) {
676
+ const nowOpen = children.classList.toggle('open');
677
+ toggle?.classList.toggle('expanded', nowOpen);
678
+ }
679
+ e.stopPropagation();
680
+ return;
681
+ }
682
+
683
+ // Spatial container row click — just expand/collapse
684
+ if (row.dataset.spatial) {
685
+ const id = row.dataset.expressId;
686
+ if (!id) return;
687
+ const children = document.getElementById(`sc-${id}`);
688
+ const toggle = row.querySelector<HTMLElement>('.tree-toggle');
689
+ if (children && toggle && !toggle.classList.contains('leaf')) {
690
+ const nowOpen = children.classList.toggle('open');
691
+ toggle.classList.toggle('expanded', nowOpen);
692
+ }
693
+ return;
694
+ }
695
+
696
+ // Element row click — select entity
697
+ if (row.dataset.element) {
698
+ const id = parseInt(row.dataset.expressId ?? '', 10);
699
+ if (!isNaN(id)) selectEntity(id);
700
+ return;
701
+ }
702
+ }
703
+
704
+ /** Reveal and highlight the tree row for the given expressId. */
705
+ function revealInTree(expressId: number) {
706
+ // Clear previous selection in tree
707
+ for (const row of spatialTree.querySelectorAll('.tree-row.selected')) {
708
+ row.classList.remove('selected');
709
+ }
710
+
711
+ const row = document.getElementById(`en-${expressId}`)?.querySelector('.tree-row') ??
712
+ document.getElementById(`en-${expressId}`);
713
+ if (!row) {
714
+ // Try to expand the containing storey so the row appears
715
+ if (dataStore?.spatialHierarchy) {
716
+ const storeyId = dataStore.spatialHierarchy.elementToStorey.get(expressId);
717
+ if (storeyId != null) {
718
+ expandSpatialNode(storeyId);
719
+ // Try again after DOM update
720
+ requestAnimationFrame(() => revealInTree(expressId));
721
+ }
722
+ }
723
+ return;
724
+ }
725
+
726
+ row.classList.add('selected');
727
+ row.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
728
+ }
729
+
730
+ /** Expand the tree path down to the node with the given expressId. */
731
+ function expandSpatialNode(expressId: number) {
732
+ const children = document.getElementById(`sc-${expressId}`);
733
+ if (!children) return;
734
+ if (!children.classList.contains('open')) {
735
+ children.classList.add('open');
736
+ const toggle = document.querySelector(`[data-toggle-id="${expressId}"]`);
737
+ toggle?.classList.add('expanded');
738
+ }
739
+ }
740
+
741
+ /** Filter tree to show only nodes/types matching the query. */
742
+ function filterTree(query: string, store: IfcDataStore) {
743
+ if (!spatialRoot) return;
744
+ if (!query) {
745
+ // Restore full tree
746
+ spatialTree.innerHTML = buildNodeHtml(spatialRoot, 0, store);
747
+ spatialTree.onclick = handleTreeClick;
748
+ return;
749
+ }
750
+
751
+ // Collect matching element rows only
752
+ const matches: Array<{ id: number; typeName: string; name: string }> = [];
753
+ collectMatchingElements(spatialRoot, query, store, matches);
754
+
755
+ if (!matches.length) {
756
+ spatialTree.innerHTML = `<p class="spatial-placeholder">No results for "${esc(query)}"</p>`;
757
+ spatialTree.onclick = null;
758
+ return;
759
+ }
760
+
761
+ const rows = matches.map(({ id, typeName, name }) => {
762
+ const color = typeColor(typeName);
763
+ return `<div class="tree-row" data-express-id="${id}" data-element="1"
764
+ id="en-${id}" style="--tree-depth:0">
765
+ <span class="tree-toggle leaf">▶</span>
766
+ <span class="tree-icon icon-element" style="background:${color}"></span>
767
+ <span class="tree-label">${esc(name || `#${id}`)}</span>
768
+ <span class="tree-sublabel">${esc(typeName.replace('Ifc', ''))}</span>
769
+ </div>`;
770
+ }).join('');
771
+
772
+ spatialTree.innerHTML = `
773
+ <div class="tree-node">
774
+ <div class="tree-children open">${rows}</div>
775
+ </div>`;
776
+ spatialTree.onclick = handleTreeClick;
777
+ }
778
+
779
+ function collectMatchingElements(
780
+ node: SpatialTreeNode,
781
+ query: string,
782
+ store: IfcDataStore,
783
+ out: Array<{ id: number; typeName: string; name: string }>,
784
+ ) {
785
+ for (const { typeName, ids } of node.elementGroups) {
786
+ for (const id of ids) {
787
+ const name = store.entities.getName(id) || '';
788
+ const typeMatch = typeName.toLowerCase().includes(query);
789
+ const nameMatch = name.toLowerCase().includes(query);
790
+ const idMatch = String(id).includes(query);
791
+ if (typeMatch || nameMatch || idMatch) {
792
+ out.push({ id, typeName, name });
793
+ }
794
+ }
795
+ }
796
+ for (const child of node.children) {
797
+ collectMatchingElements(child, query, store, out);
798
+ }
799
+ }
800
+
801
+ // ── Spatial node metadata ─────────────────────────────────────────────
802
+ function spatialNodeMeta(type: IfcTypeEnum): { icon: string; abbr: string } {
803
+ switch (type) {
804
+ case IfcTypeEnum.IfcProject: return { icon: 'icon-project', abbr: 'PRJ' };
805
+ case IfcTypeEnum.IfcSite: return { icon: 'icon-site', abbr: 'SIT' };
806
+ case IfcTypeEnum.IfcBuilding: return { icon: 'icon-building', abbr: 'BLD' };
807
+ case IfcTypeEnum.IfcFacility:
808
+ case IfcTypeEnum.IfcBridge:
809
+ case IfcTypeEnum.IfcRoad:
810
+ case IfcTypeEnum.IfcRailway:
811
+ case IfcTypeEnum.IfcMarineFacility: return { icon: 'icon-building', abbr: 'FAC' };
812
+ case IfcTypeEnum.IfcBuildingStorey: return { icon: 'icon-storey', abbr: 'STR' };
813
+ case IfcTypeEnum.IfcFacilityPart:
814
+ case IfcTypeEnum.IfcBridgePart:
815
+ case IfcTypeEnum.IfcRoadPart:
816
+ case IfcTypeEnum.IfcRailwayPart: return { icon: 'icon-storey', abbr: 'PRT' };
817
+ case IfcTypeEnum.IfcSpace: return { icon: 'icon-space', abbr: 'SPC' };
818
+ default: return { icon: 'icon-type', abbr: '?' };
819
+ }
820
+ }
821
+
822
+ /** Deterministic hue from a type name string. */
823
+ function typeColor(typeName: string): string {
824
+ let h = 0;
825
+ for (let i = 0; i < typeName.length; i++) h = (h * 31 + typeName.charCodeAt(i)) & 0xffff;
826
+ return `hsl(${h % 360},55%,52%)`;
827
+ }
828
+
829
+ // ── Scene helpers ─────────────────────────────────────────────────────
830
+ function clearScene() {
831
+ clearSelection();
832
+ triangleMaps.clear();
833
+ expressIdMap.clear();
834
+ meshDataByExpressId.clear();
835
+ dataStore = null;
836
+ spatialRoot = null;
837
+
838
+ if (modelRoot) {
839
+ disposeNode(modelRoot);
840
+ modelRoot = null;
841
+ }
842
+ if (streamRoot) {
843
+ streamRoot.dispose();
844
+ streamRoot = null;
845
+ }
846
+
847
+ // Dispose any remaining non-camera, non-light meshes
848
+ const toDispose = scene.meshes.slice();
849
+ for (const mesh of toDispose) {
850
+ if (mesh.material) mesh.material.dispose();
851
+ mesh.dispose();
852
+ }
853
+ }
854
+
855
+ function fitCameraToScene() {
856
+ fitCameraToRoot(modelRoot, true);
857
+ }
858
+
859
+ function getBoundsForRoot(root: TransformNode | null): { center: Vector3; maxDim: number } | null {
860
+ if (!root) return null;
861
+
862
+ const childMeshes = root.getChildMeshes();
863
+ if (childMeshes.length === 0) return null;
864
+
865
+ const { center, maxDim } = computeBounds(root);
866
+ if (maxDim === 0 || !isFinite(maxDim)) return null;
867
+
868
+ return { center, maxDim };
869
+ }
870
+
871
+ function applyCameraFit(center: Vector3, maxDim: number, immediate: boolean) {
872
+ const radius = maxDim * 1.5;
873
+ const minZ = maxDim * 0.001;
874
+ const maxZ = maxDim * 100;
875
+
876
+ if (immediate) {
877
+ // Always snap final post-stream fit to the canonical home orientation.
878
+ camera.alpha = HOME_ALPHA;
879
+ camera.beta = HOME_BETA;
880
+ camera.target = center;
881
+ camera.radius = radius;
882
+ camera.minZ = minZ;
883
+ camera.maxZ = maxZ;
884
+ return;
885
+ }
886
+
887
+ // Stream updates: preserve current orbit orientation and just reframe.
888
+ camera.target = center;
889
+ camera.radius = radius;
890
+ camera.minZ = minZ;
891
+ camera.maxZ = maxZ;
892
+ }
893
+
894
+ function fitCameraToRoot(root: TransformNode | null, immediate = false) {
895
+ const bounds = getBoundsForRoot(root);
896
+ if (!bounds) return;
897
+
898
+ applyCameraFit(bounds.center, bounds.maxDim, immediate);
899
+ }
900
+
901
+ // ── Misc ──────────────────────────────────────────────────────────────
902
+ function esc(s: string): string {
903
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
904
+ }