@ifc-lite/viewer-core 0.2.0

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.
@@ -0,0 +1,1328 @@
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
+ * Self-contained WebGL 2 viewer HTML template for the CLI `view` command.
6
+ *
7
+ * Features:
8
+ * - Progressive geometry streaming via @ifc-lite/wasm
9
+ * - Edge-enhanced rendering (dFdx/dFdy normal discontinuity detection)
10
+ * - Ground grid with distance fade
11
+ * - Section plane clipping
12
+ * - Orbit camera with smooth inertia
13
+ * - Entity picking (GPU color-ID pass)
14
+ * - Live geometry addition (addGeometry command)
15
+ * - Full command API: colorize, isolate, xray, highlight, section, etc.
16
+ *
17
+ * Communication:
18
+ * CLI → Browser: Server-Sent Events on /events
19
+ * Browser → CLI: POST /api/command
20
+ */
21
+ export function getViewerHtml(modelName) {
22
+ // Escape HTML special characters to prevent injection via crafted filenames
23
+ const safe = modelName
24
+ .replace(/&/g, '&')
25
+ .replace(/</g, '&lt;')
26
+ .replace(/>/g, '&gt;')
27
+ .replace(/"/g, '&quot;')
28
+ .replace(/'/g, '&#39;');
29
+ return `<!DOCTYPE html>
30
+ <html lang="en">
31
+ <head>
32
+ <meta charset="utf-8"/>
33
+ <meta name="viewport" content="width=device-width, initial-scale=1"/>
34
+ <title>${safe} — ifc-lite 3D</title>
35
+ <style>
36
+ *{margin:0;padding:0;box-sizing:border-box}
37
+ html,body{width:100%;height:100%;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#1a1a2e}
38
+ canvas{display:block;width:100%;height:100%;cursor:grab}
39
+ canvas:active{cursor:grabbing}
40
+ #overlay{position:absolute;top:0;left:0;right:0;pointer-events:none;padding:12px 16px;display:flex;justify-content:space-between;align-items:flex-start}
41
+ #info{color:#e0e0e0;font-size:13px;background:rgba(20,20,40,0.85);padding:8px 14px;border-radius:8px;backdrop-filter:blur(8px);pointer-events:auto}
42
+ #info h2{font-size:14px;font-weight:600;margin-bottom:2px;color:#fff}
43
+ #info span{opacity:0.7;font-size:12px}
44
+ #status{color:#e0e0e0;font-size:12px;background:rgba(20,20,40,0.85);padding:8px 14px;border-radius:8px;backdrop-filter:blur(8px);text-align:right;pointer-events:auto}
45
+ #progress-wrap{position:absolute;bottom:0;left:0;right:0;height:3px;background:rgba(255,255,255,0.1)}
46
+ #progress-bar{height:100%;width:0%;background:linear-gradient(90deg,#4f8cff,#a855f7);transition:width 0.2s}
47
+ #pick-info{position:absolute;bottom:16px;left:16px;color:#fff;font-size:12px;background:rgba(20,20,40,0.9);padding:10px 14px;border-radius:8px;backdrop-filter:blur(8px);display:none;max-width:350px;pointer-events:auto}
48
+ #pick-info .label{opacity:0.6;font-size:11px;text-transform:uppercase;letter-spacing:0.5px}
49
+ #pick-info .value{font-weight:500;margin-bottom:4px}
50
+ #cmd-log{position:absolute;bottom:16px;right:16px;color:#a0f0a0;font-size:11px;background:rgba(20,20,40,0.9);padding:8px 12px;border-radius:8px;display:none;pointer-events:auto;max-width:320px;font-family:monospace}
51
+ .loading-screen{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;background:#1a1a2e;color:#fff;z-index:10}
52
+ .loading-screen h1{font-size:24px;font-weight:300;margin-bottom:8px}
53
+ .loading-screen p{font-size:14px;opacity:0.6}
54
+ .spinner{width:40px;height:40px;border:3px solid rgba(255,255,255,0.1);border-top-color:#4f8cff;border-radius:50%;animation:spin 0.8s linear infinite;margin-bottom:20px}
55
+ @keyframes spin{to{transform:rotate(360deg)}}
56
+ </style>
57
+ </head>
58
+ <body>
59
+ <div id="loading" class="loading-screen">
60
+ <div class="spinner"></div>
61
+ <h1>Loading ${safe}</h1>
62
+ <p id="loading-text">Initializing WASM engine...</p>
63
+ </div>
64
+ <canvas id="c" tabindex="0"></canvas>
65
+ <div id="overlay">
66
+ <div id="info"><h2>${safe}</h2><span id="model-stats">Loading...</span></div>
67
+ <div id="status"><span id="fps"></span></div>
68
+ </div>
69
+ <div id="progress-wrap"><div id="progress-bar"></div></div>
70
+ <div id="pick-info"></div>
71
+ <div id="cmd-log"></div>
72
+
73
+ <script type="module">
74
+ // ═══════════════════════════════════════════════════════════════════
75
+ // 1. MATH UTILITIES
76
+ // ═══════════════════════════════════════════════════════════════════
77
+ const mat4 = {
78
+ create() { const m = new Float32Array(16); m[0]=m[5]=m[10]=m[15]=1; return m; },
79
+ perspective(fov, aspect, near, far) {
80
+ const f = 1/Math.tan(fov/2), nf = 1/(near-far), m = new Float32Array(16);
81
+ m[0]=f/aspect; m[5]=f; m[10]=(far+near)*nf; m[11]=-1; m[14]=2*far*near*nf;
82
+ return m;
83
+ },
84
+ lookAt(eye, center, up) {
85
+ const m = new Float32Array(16);
86
+ let zx=eye[0]-center[0], zy=eye[1]-center[1], zz=eye[2]-center[2];
87
+ let len = 1/Math.sqrt(zx*zx+zy*zy+zz*zz+1e-10); zx*=len; zy*=len; zz*=len;
88
+ let xx=up[1]*zz-up[2]*zy, xy=up[2]*zx-up[0]*zz, xz=up[0]*zy-up[1]*zx;
89
+ len = Math.sqrt(xx*xx+xy*xy+xz*xz);
90
+ if(len>1e-10){len=1/len; xx*=len; xy*=len; xz*=len;}
91
+ let yx=zy*xz-zz*xy, yy=zz*xx-zx*xz, yz=zx*xy-zy*xx;
92
+ m[0]=xx;m[1]=yx;m[2]=zx;m[4]=xy;m[5]=yy;m[6]=zy;m[8]=xz;m[9]=yz;m[10]=zz;
93
+ m[12]=-(xx*eye[0]+xy*eye[1]+xz*eye[2]);
94
+ m[13]=-(yx*eye[0]+yy*eye[1]+yz*eye[2]);
95
+ m[14]=-(zx*eye[0]+zy*eye[1]+zz*eye[2]);
96
+ m[15]=1;
97
+ return m;
98
+ },
99
+ multiply(a, b) {
100
+ const m = new Float32Array(16);
101
+ for(let i=0;i<4;i++) for(let j=0;j<4;j++){
102
+ m[j*4+i]=a[i]*b[j*4]+a[4+i]*b[j*4+1]+a[8+i]*b[j*4+2]+a[12+i]*b[j*4+3];
103
+ }
104
+ return m;
105
+ },
106
+ invert(a) {
107
+ const m = new Float32Array(16);
108
+ const a00=a[0],a01=a[1],a02=a[2],a03=a[3],a10=a[4],a11=a[5],a12=a[6],a13=a[7];
109
+ const a20=a[8],a21=a[9],a22=a[10],a23=a[11],a30=a[12],a31=a[13],a32=a[14],a33=a[15];
110
+ const b00=a00*a11-a01*a10,b01=a00*a12-a02*a10,b02=a00*a13-a03*a10;
111
+ const b03=a01*a12-a02*a11,b04=a01*a13-a03*a11,b05=a02*a13-a03*a12;
112
+ const b06=a20*a31-a21*a30,b07=a20*a32-a22*a30,b08=a20*a33-a23*a30;
113
+ const b09=a21*a32-a22*a31,b10=a21*a33-a23*a31,b11=a22*a33-a23*a32;
114
+ let det=b00*b11-b01*b10+b02*b09+b03*b08-b04*b07+b05*b06;
115
+ if(Math.abs(det)<1e-10) return m;
116
+ det=1/det;
117
+ m[0]=(a11*b11-a12*b10+a13*b09)*det; m[1]=(a02*b10-a01*b11-a03*b09)*det;
118
+ m[2]=(a31*b05-a32*b04+a33*b03)*det; m[3]=(a22*b04-a21*b05-a23*b03)*det;
119
+ m[4]=(a12*b08-a10*b11-a13*b07)*det; m[5]=(a00*b11-a02*b08+a03*b07)*det;
120
+ m[6]=(a32*b02-a30*b05-a33*b01)*det; m[7]=(a20*b05-a22*b02+a23*b01)*det;
121
+ m[8]=(a10*b10-a11*b08+a13*b06)*det; m[9]=(a01*b08-a00*b10-a03*b06)*det;
122
+ m[10]=(a30*b04-a31*b02+a33*b00)*det; m[11]=(a21*b02-a20*b04-a23*b00)*det;
123
+ m[12]=(a11*b07-a10*b09-a12*b06)*det; m[13]=(a00*b09-a01*b07+a02*b06)*det;
124
+ m[14]=(a31*b01-a30*b03-a32*b00)*det; m[15]=(a20*b03-a21*b01+a22*b00)*det;
125
+ return m;
126
+ },
127
+ transpose(a) {
128
+ const m = new Float32Array(16);
129
+ m[0]=a[0];m[1]=a[4];m[2]=a[8];m[3]=a[12];
130
+ m[4]=a[1];m[5]=a[5];m[6]=a[9];m[7]=a[13];
131
+ m[8]=a[2];m[9]=a[6];m[10]=a[10];m[11]=a[14];
132
+ m[12]=a[3];m[13]=a[7];m[14]=a[11];m[15]=a[15];
133
+ return m;
134
+ },
135
+ };
136
+
137
+ // ═══════════════════════════════════════════════════════════════════
138
+ // 2. WEBGL SETUP
139
+ // ═══════════════════════════════════════════════════════════════════
140
+ const canvas = document.getElementById('c');
141
+ const gl = canvas.getContext('webgl2', { antialias: true, alpha: false });
142
+ if (!gl) { document.getElementById('loading-text').textContent = 'WebGL 2 not supported'; throw new Error('No WebGL2'); }
143
+
144
+ function resize() {
145
+ const dpr = Math.min(window.devicePixelRatio, 2);
146
+ const w = canvas.clientWidth * dpr, h = canvas.clientHeight * dpr;
147
+ if (canvas.width !== w || canvas.height !== h) {
148
+ canvas.width = w; canvas.height = h;
149
+ gl.viewport(0, 0, w, h);
150
+ }
151
+ }
152
+ window.addEventListener('resize', resize);
153
+ resize();
154
+
155
+ // ── Main shader with edge detection + section plane ──
156
+ const VS = \`#version 300 es
157
+ precision highp float;
158
+ layout(location=0) in vec3 aPos;
159
+ layout(location=1) in vec3 aNorm;
160
+ layout(location=2) in vec4 aCol;
161
+ uniform mat4 uMVP;
162
+ uniform mat4 uNormMat;
163
+ out vec3 vNorm;
164
+ out vec4 vCol;
165
+ out vec3 vWorldPos;
166
+ void main(){
167
+ gl_Position = uMVP * vec4(aPos, 1.0);
168
+ vNorm = mat3(uNormMat) * aNorm;
169
+ vCol = aCol;
170
+ vWorldPos = aPos;
171
+ }\`;
172
+
173
+ const FS = \`#version 300 es
174
+ precision highp float;
175
+ in vec3 vNorm;
176
+ in vec4 vCol;
177
+ in vec3 vWorldPos;
178
+ uniform vec4 uSectionPlane;
179
+ uniform int uSectionEnabled;
180
+ uniform float uEdgeStrength;
181
+ out vec4 fragColor;
182
+ void main(){
183
+ // Section plane clipping
184
+ if(uSectionEnabled == 1){
185
+ if(dot(vWorldPos, uSectionPlane.xyz) > uSectionPlane.w) discard;
186
+ }
187
+ if(vCol.a < 0.01) discard;
188
+ vec3 n = normalize(vNorm);
189
+
190
+ // Three-point lighting for architectural quality
191
+ vec3 keyDir = normalize(vec3(0.4, 0.9, 0.3));
192
+ vec3 fillDir = normalize(vec3(-0.6, 0.3, -0.4));
193
+ vec3 rimDir = normalize(vec3(0.0, -0.5, -0.8));
194
+ float key = abs(dot(n, keyDir)) * 0.55;
195
+ float fill = abs(dot(n, fillDir)) * 0.25;
196
+ float rim = pow(max(0.0, 1.0 - abs(dot(n, rimDir))), 3.0) * 0.15;
197
+ float ambient = 0.28;
198
+ float light = ambient + key + fill + rim;
199
+
200
+ // Edge detection via normal discontinuity (dFdx/dFdy)
201
+ vec3 ndx = dFdx(vNorm);
202
+ vec3 ndy = dFdy(vNorm);
203
+ float edgeFactor = length(ndx) + length(ndy);
204
+ float edge = smoothstep(0.1, 0.6, edgeFactor * uEdgeStrength);
205
+
206
+ vec3 litColor = vCol.rgb * min(light, 1.0);
207
+ // Darken edges for architectural line effect
208
+ litColor = mix(litColor, litColor * 0.35, edge * 0.7);
209
+
210
+ fragColor = vec4(litColor, vCol.a);
211
+ }\`;
212
+
213
+ // ── Grid shader ──
214
+ const GRID_VS = \`#version 300 es
215
+ precision highp float;
216
+ layout(location=0) in vec2 aPos;
217
+ uniform mat4 uMVP;
218
+ uniform float uGridY;
219
+ uniform float uGridExtent;
220
+ out vec3 vWorldPos;
221
+ void main(){
222
+ vec3 wp = vec3(aPos.x * uGridExtent, uGridY, aPos.y * uGridExtent);
223
+ gl_Position = uMVP * vec4(wp, 1.0);
224
+ vWorldPos = wp;
225
+ }\`;
226
+
227
+ const GRID_FS = \`#version 300 es
228
+ precision highp float;
229
+ in vec3 vWorldPos;
230
+ uniform float uGridScale;
231
+ uniform float uGridExtent;
232
+ out vec4 fragColor;
233
+ void main(){
234
+ vec2 coord = vWorldPos.xz / uGridScale;
235
+ vec2 grid = abs(fract(coord - 0.5) - 0.5) / fwidth(coord);
236
+ float line = min(grid.x, grid.y);
237
+ float alpha = 1.0 - min(line, 1.0);
238
+ // Major grid lines
239
+ vec2 coordMajor = vWorldPos.xz / (uGridScale * 5.0);
240
+ vec2 gridMajor = abs(fract(coordMajor - 0.5) - 0.5) / fwidth(coordMajor);
241
+ float lineMajor = min(gridMajor.x, gridMajor.y);
242
+ float alphaMajor = 1.0 - min(lineMajor, 1.0);
243
+ alpha = max(alpha * 0.15, alphaMajor * 0.3);
244
+ // Distance fade
245
+ float dist = length(vWorldPos.xz);
246
+ alpha *= smoothstep(uGridExtent, uGridExtent * 0.3, dist);
247
+ if(alpha < 0.005) discard;
248
+ fragColor = vec4(0.5, 0.5, 0.6, alpha);
249
+ }\`;
250
+
251
+ // ── Pick shader (encodes expressId per vertex, uploaded once) ──
252
+ const PICK_VS2 = \`#version 300 es
253
+ precision highp float;
254
+ layout(location=0) in vec3 aPos;
255
+ layout(location=1) in vec4 aPickCol;
256
+ uniform mat4 uMVP;
257
+ flat out vec4 vPickCol;
258
+ void main(){
259
+ gl_Position = uMVP * vec4(aPos, 1.0);
260
+ vPickCol = aPickCol;
261
+ }\`;
262
+
263
+ const PICK_FS2 = \`#version 300 es
264
+ precision highp float;
265
+ flat in vec4 vPickCol;
266
+ out vec4 fragColor;
267
+ void main(){
268
+ fragColor = vPickCol;
269
+ }\`;
270
+
271
+ function compileShader(src, type) {
272
+ const s = gl.createShader(type);
273
+ gl.shaderSource(s, src);
274
+ gl.compileShader(s);
275
+ if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
276
+ console.error('Shader error:', gl.getShaderInfoLog(s));
277
+ return null;
278
+ }
279
+ return s;
280
+ }
281
+
282
+ function createProgram(vs, fs) {
283
+ const p = gl.createProgram();
284
+ gl.attachShader(p, compileShader(vs, gl.VERTEX_SHADER));
285
+ gl.attachShader(p, compileShader(fs, gl.FRAGMENT_SHADER));
286
+ gl.linkProgram(p);
287
+ if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {
288
+ console.error('Program error:', gl.getProgramInfoLog(p));
289
+ }
290
+ return p;
291
+ }
292
+
293
+ // Main program
294
+ const prog = createProgram(VS, FS);
295
+ const uMVP = gl.getUniformLocation(prog, 'uMVP');
296
+ const uNormMat = gl.getUniformLocation(prog, 'uNormMat');
297
+ const uSectionPlane = gl.getUniformLocation(prog, 'uSectionPlane');
298
+ const uSectionEnabled = gl.getUniformLocation(prog, 'uSectionEnabled');
299
+ const uEdgeStrength = gl.getUniformLocation(prog, 'uEdgeStrength');
300
+
301
+ // Grid program
302
+ const gridProg = createProgram(GRID_VS, GRID_FS);
303
+ const gMVP = gl.getUniformLocation(gridProg, 'uMVP');
304
+ const gGridY = gl.getUniformLocation(gridProg, 'uGridY');
305
+ const gGridScale = gl.getUniformLocation(gridProg, 'uGridScale');
306
+ const gGridExtent = gl.getUniformLocation(gridProg, 'uGridExtent');
307
+
308
+ // Pick program (uses per-vertex entity ID colors, uploaded once)
309
+ const pickProg = createProgram(PICK_VS2, PICK_FS2);
310
+ const pMVP = gl.getUniformLocation(pickProg, 'uMVP');
311
+
312
+ // Grid geometry (unit quad)
313
+ const gridVao = gl.createVertexArray();
314
+ gl.bindVertexArray(gridVao);
315
+ const gridBuf = gl.createBuffer();
316
+ gl.bindBuffer(gl.ARRAY_BUFFER, gridBuf);
317
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 1,-1, 1,1, -1,-1, 1,1, -1,1]), gl.STATIC_DRAW);
318
+ gl.enableVertexAttribArray(0);
319
+ gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
320
+ gl.bindVertexArray(null);
321
+
322
+ // Section plane state
323
+ let sectionEnabled = false;
324
+ let sectionPlane = [0, 1, 0, 0]; // normal xyz, distance w
325
+
326
+ // ═══════════════════════════════════════════════════════════════════
327
+ // 3. SCENE STATE
328
+ // ═══════════════════════════════════════════════════════════════════
329
+
330
+ // Entity tracking: expressId -> { segments[], defaultColor, ifcType, boundsMin, boundsMax }
331
+ const entityMap = new Map();
332
+ // Merged geometry buffers
333
+ let positions = []; // Float32Array segments
334
+ let normals = [];
335
+ let indices = [];
336
+ let colors = []; // Per-vertex RGBA
337
+ let pickColors = []; // Per-vertex entity-ID encoding (uploaded once)
338
+ let totalVertices = 0;
339
+ let totalIndices = 0;
340
+ let totalTriangles = 0;
341
+
342
+ // WebGL buffers
343
+ let vao = null;
344
+ let posBuffer = null;
345
+ let normBuffer = null;
346
+ let colBuffer = null;
347
+ let idxBuffer = null;
348
+ let pickVao = null; // Separate VAO for pick pass
349
+ let pickColBuffer = null;
350
+ let drawCount = 0;
351
+
352
+ // Model bounds
353
+ let boundsMin = [Infinity, Infinity, Infinity];
354
+ let boundsMax = [-Infinity, -Infinity, -Infinity];
355
+
356
+ // Type summary
357
+ const typeCounts = new Map();
358
+
359
+ // WASM API reference (for addGeometry)
360
+ let wasmApi = null;
361
+
362
+ // Federated ID tracking — each addGeometry call gets its own ID namespace
363
+ // to avoid collisions between separately-loaded IFC fragments
364
+ let nextIdNamespace = 0; // Increments per addGeometry call
365
+ const ID_NAMESPACE_SIZE = 100000; // IDs per namespace
366
+
367
+ // Track entity IDs added via addGeometry (for removeCreated)
368
+ const createdEntityIds = new Set();
369
+
370
+ function updateBounds(pos) {
371
+ for (let i = 0; i < pos.length; i += 3) {
372
+ const x = pos[i], y = pos[i+1], z = pos[i+2];
373
+ if (x < boundsMin[0]) boundsMin[0] = x;
374
+ if (y < boundsMin[1]) boundsMin[1] = y;
375
+ if (z < boundsMin[2]) boundsMin[2] = z;
376
+ if (x > boundsMax[0]) boundsMax[0] = x;
377
+ if (y > boundsMax[1]) boundsMax[1] = y;
378
+ if (z > boundsMax[2]) boundsMax[2] = z;
379
+ }
380
+ }
381
+
382
+ function computeEntityBounds(posArr, startVert, vertCount) {
383
+ const bMin = [Infinity, Infinity, Infinity], bMax = [-Infinity, -Infinity, -Infinity];
384
+ const base = startVert * 3;
385
+ for (let i = 0; i < vertCount * 3; i += 3) {
386
+ const x = posArr[base+i], y = posArr[base+i+1], z = posArr[base+i+2];
387
+ if (x < bMin[0]) bMin[0] = x; if (y < bMin[1]) bMin[1] = y; if (z < bMin[2]) bMin[2] = z;
388
+ if (x > bMax[0]) bMax[0] = x; if (y > bMax[1]) bMax[1] = y; if (z > bMax[2]) bMax[2] = z;
389
+ }
390
+ return { min: bMin, max: bMax };
391
+ }
392
+
393
+ function addMeshBatch(meshes) {
394
+ const prevVerts = totalVertices;
395
+ const prevIndices = totalIndices;
396
+ for (const mesh of meshes) {
397
+ const vStart = totalVertices;
398
+ const vCount = mesh.positions.length / 3;
399
+ const iStart = totalIndices;
400
+ const iCount = mesh.indices.length;
401
+ const ifcType = mesh.ifcType || 'Unknown';
402
+
403
+ // Entity bounds from this mesh
404
+ const meshBounds = computeEntityBounds(mesh.positions, 0, vCount);
405
+
406
+ // Track entity
407
+ const existing = entityMap.get(mesh.expressId);
408
+ if (!existing) {
409
+ entityMap.set(mesh.expressId, {
410
+ vertexCount: vCount, indexCount: iCount,
411
+ defaultColor: [...mesh.color], ifcType,
412
+ segments: [{ vertexStart: vStart, vertexCount: vCount, indexStart: iStart, indexCount: iCount }],
413
+ boundsMin: meshBounds.min, boundsMax: meshBounds.max,
414
+ });
415
+ } else {
416
+ existing.segments.push({ vertexStart: vStart, vertexCount: vCount, indexStart: iStart, indexCount: iCount });
417
+ existing.vertexCount += vCount;
418
+ existing.indexCount += iCount;
419
+ // Expand entity bounds
420
+ for (let k = 0; k < 3; k++) {
421
+ existing.boundsMin[k] = Math.min(existing.boundsMin[k], meshBounds.min[k]);
422
+ existing.boundsMax[k] = Math.max(existing.boundsMax[k], meshBounds.max[k]);
423
+ }
424
+ }
425
+
426
+ typeCounts.set(ifcType, (typeCounts.get(ifcType) || 0) + 1);
427
+
428
+ positions.push(mesh.positions);
429
+ normals.push(mesh.normals);
430
+
431
+ // Offset indices
432
+ const offsetIndices = new Uint32Array(iCount);
433
+ for (let i = 0; i < iCount; i++) offsetIndices[i] = mesh.indices[i] + vStart;
434
+ indices.push(offsetIndices);
435
+
436
+ // Per-vertex colors
437
+ const vc = new Float32Array(vCount * 4);
438
+ for (let i = 0; i < vCount; i++) {
439
+ vc[i*4] = mesh.color[0]; vc[i*4+1] = mesh.color[1];
440
+ vc[i*4+2] = mesh.color[2]; vc[i*4+3] = mesh.color[3];
441
+ }
442
+ colors.push(vc);
443
+
444
+ // Per-vertex pick color (entity ID encoded as RGB, uploaded once)
445
+ const pc = new Float32Array(vCount * 4);
446
+ const pr = ((mesh.expressId >> 16) & 255) / 255;
447
+ const pg = ((mesh.expressId >> 8) & 255) / 255;
448
+ const pb = (mesh.expressId & 255) / 255;
449
+ for (let i = 0; i < vCount; i++) {
450
+ pc[i*4] = pr; pc[i*4+1] = pg; pc[i*4+2] = pb; pc[i*4+3] = 1;
451
+ }
452
+ pickColors.push(pc);
453
+
454
+ updateBounds(mesh.positions);
455
+ totalVertices += vCount;
456
+ totalIndices += iCount;
457
+ totalTriangles += iCount / 3;
458
+ }
459
+ uploadGeometry(prevVerts, prevIndices);
460
+ }
461
+
462
+ // GPU buffer capacity tracking for append-only uploads
463
+ let gpuCapVerts = 0;
464
+ let gpuCapIndices = 0;
465
+
466
+ function uploadGeometry(prevVerts = 0, prevIndices = 0) {
467
+ const needsRebuild = !vao || totalVertices > gpuCapVerts || totalIndices > gpuCapIndices;
468
+
469
+ if (needsRebuild) {
470
+ // Allocate with 2x headroom to reduce future rebuilds
471
+ gpuCapVerts = Math.max(totalVertices * 2, 65536);
472
+ gpuCapIndices = Math.max(totalIndices * 2, 196608);
473
+
474
+ if (!vao) {
475
+ vao = gl.createVertexArray();
476
+ posBuffer = gl.createBuffer();
477
+ normBuffer = gl.createBuffer();
478
+ colBuffer = gl.createBuffer();
479
+ idxBuffer = gl.createBuffer();
480
+ pickVao = gl.createVertexArray();
481
+ pickColBuffer = gl.createBuffer();
482
+ }
483
+
484
+ // Full re-upload: merge all arrays and allocate new GPU buffers
485
+ const allPos = mergeFloat32(positions, totalVertices * 3);
486
+ const allNorm = mergeFloat32(normals, totalVertices * 3);
487
+ const allCol = mergeFloat32(colors, totalVertices * 4);
488
+ const allPick = mergeFloat32(pickColors, totalVertices * 4);
489
+ const allIdx = mergeUint32(indices, totalIndices);
490
+
491
+ // Main render VAO
492
+ gl.bindVertexArray(vao);
493
+
494
+ gl.bindBuffer(gl.ARRAY_BUFFER, posBuffer);
495
+ gl.bufferData(gl.ARRAY_BUFFER, gpuCapVerts * 3 * 4, gl.DYNAMIC_DRAW);
496
+ gl.bufferSubData(gl.ARRAY_BUFFER, 0, allPos);
497
+ gl.enableVertexAttribArray(0);
498
+ gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0);
499
+
500
+ gl.bindBuffer(gl.ARRAY_BUFFER, normBuffer);
501
+ gl.bufferData(gl.ARRAY_BUFFER, gpuCapVerts * 3 * 4, gl.DYNAMIC_DRAW);
502
+ gl.bufferSubData(gl.ARRAY_BUFFER, 0, allNorm);
503
+ gl.enableVertexAttribArray(1);
504
+ gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 0, 0);
505
+
506
+ gl.bindBuffer(gl.ARRAY_BUFFER, colBuffer);
507
+ gl.bufferData(gl.ARRAY_BUFFER, gpuCapVerts * 4 * 4, gl.DYNAMIC_DRAW);
508
+ gl.bufferSubData(gl.ARRAY_BUFFER, 0, allCol);
509
+ gl.enableVertexAttribArray(2);
510
+ gl.vertexAttribPointer(2, 4, gl.FLOAT, false, 0, 0);
511
+
512
+ gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, idxBuffer);
513
+ gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, gpuCapIndices * 4, gl.DYNAMIC_DRAW);
514
+ gl.bufferSubData(gl.ELEMENT_ARRAY_BUFFER, 0, allIdx);
515
+
516
+ gl.bindVertexArray(null);
517
+
518
+ // Pick VAO
519
+ gl.bindVertexArray(pickVao);
520
+ gl.bindBuffer(gl.ARRAY_BUFFER, posBuffer);
521
+ gl.enableVertexAttribArray(0);
522
+ gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0);
523
+
524
+ gl.bindBuffer(gl.ARRAY_BUFFER, pickColBuffer);
525
+ gl.bufferData(gl.ARRAY_BUFFER, gpuCapVerts * 4 * 4, gl.DYNAMIC_DRAW);
526
+ gl.bufferSubData(gl.ARRAY_BUFFER, 0, allPick);
527
+ gl.enableVertexAttribArray(1);
528
+ gl.vertexAttribPointer(1, 4, gl.FLOAT, false, 0, 0);
529
+
530
+ gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, idxBuffer);
531
+ gl.bindVertexArray(null);
532
+ } else if (prevVerts < totalVertices) {
533
+ // Append-only: just upload the new data at the end of existing buffers
534
+ const newPos = mergeFloat32(positions.slice(getChunkIndex(prevVerts)), (totalVertices - prevVerts) * 3);
535
+ const newNorm = mergeFloat32(normals.slice(getChunkIndex(prevVerts)), (totalVertices - prevVerts) * 3);
536
+ const newCol = mergeFloat32(colors.slice(getChunkIndex(prevVerts)), (totalVertices - prevVerts) * 4);
537
+ const newPick = mergeFloat32(pickColors.slice(getChunkIndex(prevVerts)), (totalVertices - prevVerts) * 4);
538
+ const newIdx = mergeUint32(indices.slice(getChunkIndex(prevIndices, true)), totalIndices - prevIndices);
539
+
540
+ gl.bindBuffer(gl.ARRAY_BUFFER, posBuffer);
541
+ gl.bufferSubData(gl.ARRAY_BUFFER, prevVerts * 3 * 4, newPos);
542
+ gl.bindBuffer(gl.ARRAY_BUFFER, normBuffer);
543
+ gl.bufferSubData(gl.ARRAY_BUFFER, prevVerts * 3 * 4, newNorm);
544
+ gl.bindBuffer(gl.ARRAY_BUFFER, colBuffer);
545
+ gl.bufferSubData(gl.ARRAY_BUFFER, prevVerts * 4 * 4, newCol);
546
+ gl.bindBuffer(gl.ARRAY_BUFFER, pickColBuffer);
547
+ gl.bufferSubData(gl.ARRAY_BUFFER, prevVerts * 4 * 4, newPick);
548
+ gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, idxBuffer);
549
+ gl.bufferSubData(gl.ELEMENT_ARRAY_BUFFER, prevIndices * 4, newIdx);
550
+ }
551
+ drawCount = totalIndices;
552
+ }
553
+
554
+ // Map a vertex/index count to the corresponding chunk array index
555
+ function getChunkIndex(targetCount, isIndices = false) {
556
+ let count = 0;
557
+ const arr = isIndices ? indices : positions;
558
+ const divisor = isIndices ? 1 : 3;
559
+ for (let i = 0; i < arr.length; i++) {
560
+ count += arr[i].length / divisor;
561
+ if (count >= targetCount) return i;
562
+ }
563
+ return arr.length;
564
+ }
565
+
566
+ function mergeFloat32(arrays, totalLen) {
567
+ const m = new Float32Array(totalLen);
568
+ let off = 0;
569
+ for (const a of arrays) { m.set(a, off); off += a.length; }
570
+ return m;
571
+ }
572
+ function mergeUint32(arrays, totalLen) {
573
+ const m = new Uint32Array(totalLen);
574
+ let off = 0;
575
+ for (const a of arrays) { m.set(a, off); off += a.length; }
576
+ return m;
577
+ }
578
+
579
+ // ═══════════════════════════════════════════════════════════════════
580
+ // 4. CAMERA WITH INERTIA
581
+ // ═══════════════════════════════════════════════════════════════════
582
+ let camTheta = Math.PI * 0.25;
583
+ let camPhi = Math.PI * 0.3;
584
+ let camDist = 50;
585
+ let camTarget = [0, 0, 0];
586
+ // Inertia
587
+ let camVelTheta = 0, camVelPhi = 0;
588
+ let camVelPanX = 0, camVelPanY = 0, camVelPanZ = 0;
589
+ const FRICTION = 0.88;
590
+ // Animation
591
+ let camAnimating = false;
592
+ let camAnimStart, camAnimDuration, camAnimFrom, camAnimTo;
593
+
594
+ function getCamPos() {
595
+ const sp = Math.sin(camPhi), cp = Math.cos(camPhi);
596
+ const st = Math.sin(camTheta), ct = Math.cos(camTheta);
597
+ return [
598
+ camTarget[0] + camDist * sp * ct,
599
+ camTarget[1] + camDist * cp,
600
+ camTarget[2] + camDist * sp * st,
601
+ ];
602
+ }
603
+
604
+ function fitCamera() {
605
+ const cx = (boundsMin[0] + boundsMax[0]) / 2;
606
+ const cy = (boundsMin[1] + boundsMax[1]) / 2;
607
+ const cz = (boundsMin[2] + boundsMax[2]) / 2;
608
+ const dx = boundsMax[0] - boundsMin[0];
609
+ const dy = boundsMax[1] - boundsMin[1];
610
+ const dz = boundsMax[2] - boundsMin[2];
611
+ const maxDim = Math.max(dx, dy, dz, 0.1);
612
+ camTarget = [cx, cy, cz];
613
+ camDist = maxDim * 1.5;
614
+ camTheta = Math.PI * 0.25;
615
+ camPhi = Math.PI * 0.3;
616
+ camVelTheta = camVelPhi = camVelPanX = camVelPanY = camVelPanZ = 0;
617
+ }
618
+
619
+ function flyTo(targetPos, dist) {
620
+ camAnimating = true;
621
+ camAnimStart = performance.now();
622
+ camAnimDuration = 600;
623
+ camAnimFrom = { target: [...camTarget], dist: camDist };
624
+ camAnimTo = { target: targetPos, dist };
625
+ camVelTheta = camVelPhi = camVelPanX = camVelPanY = camVelPanZ = 0;
626
+ }
627
+
628
+ function updateCamAnimation() {
629
+ if (camAnimating) {
630
+ const t = Math.min(1, (performance.now() - camAnimStart) / camAnimDuration);
631
+ const ease = t < 0.5 ? 2*t*t : 1-(-2*t+2)*(-2*t+2)/2;
632
+ camTarget = camAnimFrom.target.map((v,i) => v + (camAnimTo.target[i]-v)*ease);
633
+ camDist = camAnimFrom.dist + (camAnimTo.dist - camAnimFrom.dist) * ease;
634
+ if (t >= 1) camAnimating = false;
635
+ }
636
+ // Inertia (only when not dragging)
637
+ if (!isDragging) {
638
+ camTheta += camVelTheta;
639
+ camPhi = Math.max(0.05, Math.min(Math.PI - 0.05, camPhi + camVelPhi));
640
+ camTarget[0] += camVelPanX;
641
+ camTarget[1] += camVelPanY;
642
+ camTarget[2] += camVelPanZ;
643
+ camVelTheta *= FRICTION; camVelPhi *= FRICTION;
644
+ camVelPanX *= FRICTION; camVelPanY *= FRICTION; camVelPanZ *= FRICTION;
645
+ if (Math.abs(camVelTheta) < 1e-5) camVelTheta = 0;
646
+ if (Math.abs(camVelPhi) < 1e-5) camVelPhi = 0;
647
+ }
648
+ }
649
+
650
+ // Mouse controls
651
+ let isDragging = false;
652
+ let isPanning = false;
653
+ let lastMouse = [0, 0];
654
+
655
+ canvas.addEventListener('mousedown', (e) => {
656
+ isDragging = true;
657
+ isPanning = e.button === 1 || e.button === 2 || e.shiftKey;
658
+ lastMouse = [e.clientX, e.clientY];
659
+ camVelTheta = camVelPhi = camVelPanX = camVelPanY = camVelPanZ = 0;
660
+ e.preventDefault();
661
+ });
662
+ canvas.addEventListener('contextmenu', (e) => e.preventDefault());
663
+ window.addEventListener('mouseup', () => { isDragging = false; isPanning = false; });
664
+
665
+ window.addEventListener('mousemove', (e) => {
666
+ if (!isDragging) return;
667
+ const dx = e.clientX - lastMouse[0];
668
+ const dy = e.clientY - lastMouse[1];
669
+ lastMouse = [e.clientX, e.clientY];
670
+
671
+ if (isPanning) {
672
+ const panSpeed = camDist * 0.0015;
673
+ // Compute camera right and up vectors for screen-aligned panning
674
+ const ct = Math.cos(camTheta), st = Math.sin(camTheta);
675
+ const rightX = st, rightZ = -ct;
676
+ camVelPanX = -dx * panSpeed * rightX;
677
+ camVelPanZ = -dx * panSpeed * rightZ;
678
+ camVelPanY = dy * panSpeed;
679
+ camTarget[0] += camVelPanX;
680
+ camTarget[1] += camVelPanY;
681
+ camTarget[2] += camVelPanZ;
682
+ } else {
683
+ camVelTheta = dx * 0.004;
684
+ camVelPhi = -dy * 0.004;
685
+ camTheta += camVelTheta;
686
+ camPhi = Math.max(0.05, Math.min(Math.PI - 0.05, camPhi + camVelPhi));
687
+ }
688
+ });
689
+
690
+ canvas.addEventListener('wheel', (e) => {
691
+ e.preventDefault();
692
+ camDist *= 1 + e.deltaY * 0.001;
693
+ camDist = Math.max(0.01, camDist);
694
+ }, { passive: false });
695
+
696
+ // Touch controls
697
+ let lastTouches = [];
698
+ canvas.addEventListener('touchstart', (e) => {
699
+ e.preventDefault();
700
+ lastTouches = [...e.touches].map(t => [t.clientX, t.clientY]);
701
+ }, { passive: false });
702
+ canvas.addEventListener('touchmove', (e) => {
703
+ e.preventDefault();
704
+ const touches = [...e.touches].map(t => [t.clientX, t.clientY]);
705
+ if (touches.length === 1 && lastTouches.length >= 1) {
706
+ const dx = touches[0][0] - lastTouches[0][0];
707
+ const dy = touches[0][1] - lastTouches[0][1];
708
+ camTheta -= dx * 0.005;
709
+ camPhi = Math.max(0.05, Math.min(Math.PI - 0.05, camPhi - dy * 0.005));
710
+ } else if (touches.length === 2 && lastTouches.length >= 2) {
711
+ const d1 = Math.hypot(lastTouches[1][0]-lastTouches[0][0], lastTouches[1][1]-lastTouches[0][1]);
712
+ const d2 = Math.hypot(touches[1][0]-touches[0][0], touches[1][1]-touches[0][1]);
713
+ camDist *= d1 / Math.max(d2, 1);
714
+ camDist = Math.max(0.01, camDist);
715
+ }
716
+ lastTouches = touches;
717
+ }, { passive: false });
718
+
719
+ // ═══════════════════════════════════════════════════════════════════
720
+ // 5. PICKING
721
+ // ═══════════════════════════════════════════════════════════════════
722
+ let pickFbo = null, pickTex = null, pickDepth = null;
723
+ let pickW = 0, pickH = 0;
724
+
725
+ function ensurePickFbo() {
726
+ if (pickFbo && pickW === canvas.width && pickH === canvas.height) return;
727
+ if (pickFbo) { gl.deleteFramebuffer(pickFbo); gl.deleteTexture(pickTex); gl.deleteRenderbuffer(pickDepth); }
728
+ pickW = canvas.width; pickH = canvas.height;
729
+ pickFbo = gl.createFramebuffer();
730
+ gl.bindFramebuffer(gl.FRAMEBUFFER, pickFbo);
731
+ pickTex = gl.createTexture();
732
+ gl.bindTexture(gl.TEXTURE_2D, pickTex);
733
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, pickW, pickH, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
734
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, pickTex, 0);
735
+ pickDepth = gl.createRenderbuffer();
736
+ gl.bindRenderbuffer(gl.RENDERBUFFER, pickDepth);
737
+ gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT24, pickW, pickH);
738
+ gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, pickDepth);
739
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
740
+ }
741
+
742
+ canvas.addEventListener('click', (e) => {
743
+ if (!pickVao || drawCount === 0) return;
744
+ ensurePickFbo();
745
+ const mvp = getMVP();
746
+
747
+ // Render entity IDs into pick FBO using dedicated pick shader + pick VAO
748
+ gl.bindFramebuffer(gl.FRAMEBUFFER, pickFbo);
749
+ gl.viewport(0, 0, pickW, pickH);
750
+ gl.clearColor(0, 0, 0, 0);
751
+ gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
752
+ gl.disable(gl.BLEND);
753
+
754
+ gl.useProgram(pickProg);
755
+ gl.uniformMatrix4fv(pMVP, false, mvp);
756
+ gl.bindVertexArray(pickVao);
757
+ gl.drawElements(gl.TRIANGLES, drawCount, gl.UNSIGNED_INT, 0);
758
+ gl.bindVertexArray(null);
759
+
760
+ // Read pixel
761
+ const dpr = Math.min(window.devicePixelRatio, 2);
762
+ const px = Math.floor(e.clientX * dpr);
763
+ const py = pickH - Math.floor(e.clientY * dpr) - 1;
764
+ const pixel = new Uint8Array(4);
765
+ gl.readPixels(px, py, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixel);
766
+
767
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
768
+ gl.viewport(0, 0, canvas.width, canvas.height);
769
+ gl.enable(gl.BLEND);
770
+
771
+ const pickedId = (pixel[0] << 16) | (pixel[1] << 8) | pixel[2];
772
+ if (pickedId > 0 && entityMap.has(pickedId)) {
773
+ showPickInfo(pickedId);
774
+ const info = entityMap.get(pickedId);
775
+ fetch('/api/command', {
776
+ method: 'POST',
777
+ headers: { 'Content-Type': 'application/json' },
778
+ body: JSON.stringify({ action: 'picked', expressId: pickedId, ifcType: info.ifcType }),
779
+ }).catch(() => {});
780
+ } else {
781
+ document.getElementById('pick-info').style.display = 'none';
782
+ }
783
+ });
784
+
785
+ function showPickInfo(eid) {
786
+ const info = entityMap.get(eid);
787
+ if (!info) return;
788
+ const el = document.getElementById('pick-info');
789
+ el.style.display = 'block';
790
+ el.innerHTML =
791
+ '<div class="label">Entity #' + eid + '</div>' +
792
+ '<div class="value">' + info.ifcType + '</div>' +
793
+ '<div class="label">Triangles</div>' +
794
+ '<div class="value">' + Math.floor(info.indexCount / 3).toLocaleString() + '</div>';
795
+ }
796
+
797
+ // ═══════════════════════════════════════════════════════════════════
798
+ // 6. COMMAND HANDLER
799
+ // ═══════════════════════════════════════════════════════════════════
800
+ const colorOverrides = new Map();
801
+ const STOREY_PALETTE = [
802
+ [0.23,0.55,0.96,1],[0.16,0.73,0.44,1],[0.90,0.30,0.24,1],
803
+ [0.95,0.77,0.06,1],[0.60,0.36,0.71,1],[1.0,0.50,0.05,1],
804
+ [0.10,0.74,0.74,1],[0.83,0.33,0.58,1],[0.38,0.70,0.24,1],
805
+ [0.35,0.47,0.85,1],
806
+ ];
807
+
808
+ function applyColorOverrides(colArray) {
809
+ for (const [eid, color] of colorOverrides) {
810
+ const info = entityMap.get(eid);
811
+ if (!info) continue;
812
+ for (const seg of info.segments) {
813
+ for (let i = 0; i < seg.vertexCount; i++) {
814
+ const vi = (seg.vertexStart + i) * 4;
815
+ colArray[vi] = color[0]; colArray[vi+1] = color[1];
816
+ colArray[vi+2] = color[2]; colArray[vi+3] = color[3];
817
+ }
818
+ }
819
+ }
820
+ }
821
+
822
+ // Track which entity IDs have changed since last refreshColors
823
+ let colorDirtyAll = true; // true = full rebuild needed (initial load, reset)
824
+ const colorDirtyEntities = new Set();
825
+
826
+ function markColorDirty(eid) { colorDirtyEntities.add(eid); }
827
+ function markAllColorsDirty() { colorDirtyAll = true; }
828
+
829
+ function refreshColors() {
830
+ if (!vao) return;
831
+
832
+ if (colorDirtyAll) {
833
+ // Full rebuild — needed after initial load or reset
834
+ const col = mergeFloat32(colors, totalVertices * 4);
835
+ applyColorOverrides(col);
836
+ gl.bindVertexArray(vao);
837
+ gl.bindBuffer(gl.ARRAY_BUFFER, colBuffer);
838
+ gl.bufferData(gl.ARRAY_BUFFER, col, gl.STATIC_DRAW);
839
+ gl.bindVertexArray(null);
840
+ colorDirtyAll = false;
841
+ colorDirtyEntities.clear();
842
+ return;
843
+ }
844
+
845
+ // Partial update — only update changed entities via bufferSubData
846
+ if (colorDirtyEntities.size === 0) return;
847
+ gl.bindBuffer(gl.ARRAY_BUFFER, colBuffer);
848
+ for (const eid of colorDirtyEntities) {
849
+ const info = entityMap.get(eid);
850
+ if (!info) continue;
851
+ const override = colorOverrides.get(eid);
852
+ for (const seg of info.segments) {
853
+ const buf = new Float32Array(seg.vertexCount * 4);
854
+ if (override) {
855
+ for (let i = 0; i < seg.vertexCount; i++) {
856
+ buf[i*4] = override[0]; buf[i*4+1] = override[1];
857
+ buf[i*4+2] = override[2]; buf[i*4+3] = override[3];
858
+ }
859
+ } else {
860
+ // Restore default color from the original colors array
861
+ const dc = info.defaultColor;
862
+ for (let i = 0; i < seg.vertexCount; i++) {
863
+ buf[i*4] = dc[0]; buf[i*4+1] = dc[1]; buf[i*4+2] = dc[2]; buf[i*4+3] = dc[3];
864
+ }
865
+ }
866
+ gl.bufferSubData(gl.ARRAY_BUFFER, seg.vertexStart * 4 * 4, buf);
867
+ }
868
+ }
869
+ colorDirtyEntities.clear();
870
+ }
871
+
872
+ const NAMED_COLORS = {
873
+ red:[1,0,0,1],green:[0,0.7,0,1],blue:[0,0.3,1,1],yellow:[1,0.9,0,1],
874
+ orange:[1,0.5,0,1],purple:[0.6,0.2,0.8,1],cyan:[0,0.8,0.8,1],
875
+ white:[1,1,1,1],pink:[1,0.4,0.7,1],gray:[0.5,0.5,0.5,1],
876
+ };
877
+
878
+ function resolveColor(c) {
879
+ if (typeof c === 'string') return NAMED_COLORS[c.toLowerCase()] || [1,0,0,1];
880
+ if (Array.isArray(c)) return c;
881
+ return [1,0,0,1];
882
+ }
883
+
884
+ function matchesType(info, type) {
885
+ // Require exact IFC EXPRESS name match (e.g. "IfcWall", not "Wall")
886
+ return info.ifcType === type;
887
+ }
888
+
889
+ function getEntityBoundsForFilter(filterFn) {
890
+ const tMin = [Infinity,Infinity,Infinity], tMax = [-Infinity,-Infinity,-Infinity];
891
+ let found = false;
892
+ for (const [eid, info] of entityMap) {
893
+ if (!filterFn(eid, info)) continue;
894
+ found = true;
895
+ for (let k = 0; k < 3; k++) {
896
+ tMin[k] = Math.min(tMin[k], info.boundsMin[k]);
897
+ tMax[k] = Math.max(tMax[k], info.boundsMax[k]);
898
+ }
899
+ }
900
+ if (!found) return null;
901
+ return { min: tMin, max: tMax };
902
+ }
903
+
904
+ function handleCommand(cmd) {
905
+ showCmdLog(cmd.action);
906
+
907
+ switch (cmd.action) {
908
+ // ── Type-based commands ──
909
+ case 'colorize': {
910
+ const color = resolveColor(cmd.color);
911
+ for (const [eid, info] of entityMap) {
912
+ if (matchesType(info, cmd.type)) { colorOverrides.set(eid, color); markColorDirty(eid); }
913
+ }
914
+ refreshColors();
915
+ break;
916
+ }
917
+ case 'isolate': {
918
+ const types = cmd.types || [cmd.type];
919
+ for (const [eid, info] of entityMap) {
920
+ if (!types.some(t => matchesType(info, t))) {
921
+ colorOverrides.set(eid, [0.3, 0.3, 0.35, 0.06]);
922
+ } else {
923
+ colorOverrides.delete(eid);
924
+ }
925
+ markColorDirty(eid);
926
+ }
927
+ refreshColors();
928
+ break;
929
+ }
930
+ case 'xray': {
931
+ const opacity = cmd.opacity ?? 0.15;
932
+ for (const [eid, info] of entityMap) {
933
+ if (matchesType(info, cmd.type)) {
934
+ const dc = info.defaultColor;
935
+ colorOverrides.set(eid, [dc[0], dc[1], dc[2], opacity]);
936
+ markColorDirty(eid);
937
+ }
938
+ }
939
+ refreshColors();
940
+ break;
941
+ }
942
+ case 'flyto': {
943
+ const bounds = getEntityBoundsForFilter((eid, info) =>
944
+ cmd.ids ? cmd.ids.includes(eid) : matchesType(info, cmd.type)
945
+ );
946
+ if (bounds) {
947
+ const center = bounds.min.map((v,i) => (v + bounds.max[i]) / 2);
948
+ const dim = Math.max(...bounds.max.map((v,i) => v - bounds.min[i]), 0.1);
949
+ flyTo(center, dim * 1.5);
950
+ }
951
+ break;
952
+ }
953
+
954
+ // ── Entity ID-based commands (from streaming adapter) ──
955
+ case 'colorizeEntities': {
956
+ const color = resolveColor(cmd.color);
957
+ for (const id of cmd.ids) { colorOverrides.set(id, color); markColorDirty(id); }
958
+ refreshColors();
959
+ break;
960
+ }
961
+ case 'isolateEntities': {
962
+ const idSet = new Set(cmd.ids);
963
+ for (const [eid] of entityMap) {
964
+ if (!idSet.has(eid)) {
965
+ colorOverrides.set(eid, [0.3, 0.3, 0.35, 0.06]);
966
+ } else {
967
+ colorOverrides.delete(eid);
968
+ }
969
+ markColorDirty(eid);
970
+ }
971
+ refreshColors();
972
+ break;
973
+ }
974
+ case 'hideEntities': {
975
+ for (const id of cmd.ids) { colorOverrides.set(id, [0, 0, 0, 0]); markColorDirty(id); }
976
+ refreshColors();
977
+ break;
978
+ }
979
+ case 'showEntities': {
980
+ for (const id of cmd.ids) { colorOverrides.delete(id); markColorDirty(id); }
981
+ refreshColors();
982
+ break;
983
+ }
984
+ case 'resetColorEntities': {
985
+ for (const id of cmd.ids) { colorOverrides.delete(id); markColorDirty(id); }
986
+ refreshColors();
987
+ break;
988
+ }
989
+ case 'highlight': {
990
+ for (const id of (cmd.ids || [])) { colorOverrides.set(id, [1, 0.9, 0, 1]); markColorDirty(id); }
991
+ refreshColors();
992
+ break;
993
+ }
994
+
995
+ // ── Section plane ──
996
+ case 'section': {
997
+ sectionEnabled = true;
998
+ // Accept both flat (cmd.axis/cmd.position) and nested (cmd.section.axis/position) formats
999
+ const sec = cmd.section || cmd;
1000
+ const axis = (sec.axis || 'y').toLowerCase();
1001
+ const axisIdx = axis === 'x' ? 0 : axis === 'z' ? 2 : 1;
1002
+ // Support "center", percentage strings like "50%", or absolute numbers
1003
+ let pos;
1004
+ const rawPos = sec.position;
1005
+ if (rawPos === 'center' || rawPos === undefined) {
1006
+ pos = (boundsMin[axisIdx] + boundsMax[axisIdx]) / 2;
1007
+ } else if (typeof rawPos === 'string' && rawPos.endsWith('%')) {
1008
+ const pct = parseFloat(rawPos) / 100;
1009
+ pos = boundsMin[axisIdx] + (boundsMax[axisIdx] - boundsMin[axisIdx]) * pct;
1010
+ } else {
1011
+ pos = Number(rawPos) || 0;
1012
+ }
1013
+ sectionPlane = [
1014
+ axis === 'x' ? 1 : 0,
1015
+ axis === 'y' ? 1 : 0,
1016
+ axis === 'z' ? 1 : 0,
1017
+ pos,
1018
+ ];
1019
+ break;
1020
+ }
1021
+ case 'clearSection':
1022
+ sectionEnabled = false;
1023
+ break;
1024
+
1025
+ // ── Color by storey (Y-based binning, adaptive to model scale) ──
1026
+ case 'colorByStorey': {
1027
+ // Compute adaptive bin size from model Y extent instead of hardcoded 3m
1028
+ const yExtent = boundsMax[1] - boundsMin[1];
1029
+ // Aim for ~3-10 storeys; clamp bin size to reasonable range
1030
+ const targetStoreys = Math.max(3, Math.min(10, Math.round(yExtent / 3)));
1031
+ const binSize = Math.max(yExtent / targetStoreys, 0.01);
1032
+ const yGroups = new Map();
1033
+ for (const [eid, info] of entityMap) {
1034
+ const avgY = (info.boundsMin[1] + info.boundsMax[1]) / 2;
1035
+ const bin = Math.floor((avgY - boundsMin[1]) / binSize);
1036
+ if (!yGroups.has(bin)) yGroups.set(bin, []);
1037
+ yGroups.get(bin).push(eid);
1038
+ }
1039
+ const sortedBins = [...yGroups.keys()].sort((a,b) => a-b);
1040
+ for (let i = 0; i < sortedBins.length; i++) {
1041
+ const color = STOREY_PALETTE[i % STOREY_PALETTE.length];
1042
+ for (const eid of yGroups.get(sortedBins[i])) colorOverrides.set(eid, color);
1043
+ }
1044
+ markAllColorsDirty();
1045
+ refreshColors();
1046
+ break;
1047
+ }
1048
+
1049
+ // ── Add geometry (live creation streaming) ──
1050
+ case 'addGeometry': {
1051
+ if (!wasmApi || !cmd.ifcContent) break;
1052
+ // Each addGeometry call gets a unique ID namespace to prevent collisions
1053
+ nextIdNamespace++;
1054
+ const idOffset = nextIdNamespace * ID_NAMESPACE_SIZE;
1055
+ wasmApi.parseMeshesAsync(cmd.ifcContent, {
1056
+ batchSize: 50,
1057
+ onBatch: (meshes) => {
1058
+ const batch = meshes.map(m => ({
1059
+ expressId: m.expressId + idOffset,
1060
+ ifcType: m.ifcType || 'Created',
1061
+ positions: m.positions,
1062
+ normals: m.normals,
1063
+ indices: m.indices,
1064
+ color: [m.color[0], m.color[1], m.color[2], m.color[3] ?? 1],
1065
+ }));
1066
+ addMeshBatch(batch);
1067
+ // Track and auto-highlight new geometry in green
1068
+ for (const m of batch) {
1069
+ createdEntityIds.add(m.expressId);
1070
+ colorOverrides.set(m.expressId, [0.2, 0.9, 0.4, 1]);
1071
+ markColorDirty(m.expressId);
1072
+ }
1073
+ refreshColors();
1074
+ document.getElementById('model-stats').textContent =
1075
+ totalTriangles.toLocaleString() + ' triangles, ' +
1076
+ entityMap.size.toLocaleString() + ' entities';
1077
+ },
1078
+ }).catch(err => console.error('addGeometry error:', err));
1079
+ break;
1080
+ }
1081
+
1082
+ // ── Programmatic camera views (for CLI/LLM) ──
1083
+ case 'setView': {
1084
+ // Named views: front, back, left, right, top, bottom, iso
1085
+ // theta = azimuth around Y, phi = angle from Y+ (0=top, PI/2=horizon)
1086
+ const VIEWS = {
1087
+ front: { theta: 0, phi: Math.PI * 0.4 },
1088
+ back: { theta: Math.PI, phi: Math.PI * 0.4 },
1089
+ left: { theta: Math.PI * 1.5, phi: Math.PI * 0.4 },
1090
+ right: { theta: Math.PI * 0.5, phi: Math.PI * 0.4 },
1091
+ top: { theta: 0, phi: 0.05 },
1092
+ bottom: { theta: 0, phi: Math.PI - 0.05 },
1093
+ iso: { theta: Math.PI * 0.25, phi: Math.PI * 0.3 },
1094
+ };
1095
+ const view = cmd.view?.toLowerCase();
1096
+ const preset = VIEWS[view];
1097
+ if (preset) {
1098
+ camAnimating = true;
1099
+ camAnimStart = performance.now();
1100
+ camAnimDuration = 500;
1101
+ camAnimFrom = { target: [...camTarget], dist: camDist };
1102
+ camAnimTo = { target: [...camTarget], dist: camDist };
1103
+ // Animate theta/phi by setting target directly after animation
1104
+ camTheta = preset.theta;
1105
+ camPhi = preset.phi;
1106
+ camVelTheta = camVelPhi = 0;
1107
+ }
1108
+ break;
1109
+ }
1110
+
1111
+ // ── Remove created geometry ──
1112
+ case 'removeCreated': {
1113
+ // Hide all entities added via addGeometry by making them fully transparent
1114
+ for (const eid of createdEntityIds) {
1115
+ colorOverrides.set(eid, [0, 0, 0, 0]);
1116
+ markColorDirty(eid);
1117
+ }
1118
+ createdEntityIds.clear();
1119
+ refreshColors();
1120
+ document.getElementById('model-stats').textContent =
1121
+ totalTriangles.toLocaleString() + ' triangles, ' +
1122
+ entityMap.size.toLocaleString() + ' entities';
1123
+ break;
1124
+ }
1125
+
1126
+ // ── General ──
1127
+ case 'showall':
1128
+ colorOverrides.clear();
1129
+ markAllColorsDirty();
1130
+ refreshColors();
1131
+ break;
1132
+ case 'reset':
1133
+ colorOverrides.clear();
1134
+ sectionEnabled = false;
1135
+ markAllColorsDirty();
1136
+ refreshColors();
1137
+ fitCamera();
1138
+ break;
1139
+ case 'connected':
1140
+ break;
1141
+ default:
1142
+ console.log('Unknown command:', cmd);
1143
+ }
1144
+ }
1145
+
1146
+ function showCmdLog(action) {
1147
+ if (action === 'connected') return;
1148
+ const el = document.getElementById('cmd-log');
1149
+ el.style.display = 'block';
1150
+ el.textContent = '> ' + action;
1151
+ clearTimeout(el._timer);
1152
+ el._timer = setTimeout(() => { el.style.display = 'none'; }, 2500);
1153
+ }
1154
+
1155
+ // ═══════════════════════════════════════════════════════════════════
1156
+ // 7. RENDER LOOP
1157
+ // ═══════════════════════════════════════════════════════════════════
1158
+ const BG = [0.102, 0.102, 0.18, 1];
1159
+
1160
+ function getMVP() {
1161
+ const aspect = canvas.width / canvas.height;
1162
+ const proj = mat4.perspective(Math.PI / 4, aspect, camDist * 0.001, camDist * 100);
1163
+ const eye = getCamPos();
1164
+ const view = mat4.lookAt(eye, camTarget, [0, 1, 0]);
1165
+ return mat4.multiply(proj, view);
1166
+ }
1167
+
1168
+ function render() {
1169
+ updateCamAnimation();
1170
+ resize();
1171
+
1172
+ gl.clearColor(...BG);
1173
+ gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
1174
+ gl.enable(gl.DEPTH_TEST);
1175
+
1176
+ const mvp = getMVP();
1177
+
1178
+ // ── Draw ground grid ──
1179
+ if (boundsMax[0] > boundsMin[0]) {
1180
+ gl.enable(gl.BLEND);
1181
+ gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
1182
+ gl.depthMask(false);
1183
+ gl.useProgram(gridProg);
1184
+ gl.uniformMatrix4fv(gMVP, false, mvp);
1185
+ gl.uniform1f(gGridY, boundsMin[1] - 0.01);
1186
+ const maxDim = Math.max(boundsMax[0]-boundsMin[0], boundsMax[2]-boundsMin[2], 1);
1187
+ gl.uniform1f(gGridScale, Math.pow(10, Math.floor(Math.log10(maxDim / 5))));
1188
+ gl.uniform1f(gGridExtent, maxDim * 3);
1189
+ gl.bindVertexArray(gridVao);
1190
+ gl.drawArrays(gl.TRIANGLES, 0, 6);
1191
+ gl.bindVertexArray(null);
1192
+ gl.depthMask(true);
1193
+ }
1194
+
1195
+ // ── Draw model ──
1196
+ if (vao && drawCount > 0) {
1197
+ gl.enable(gl.BLEND);
1198
+ gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
1199
+
1200
+ const view = mat4.lookAt(getCamPos(), camTarget, [0, 1, 0]);
1201
+ const normMat = mat4.transpose(mat4.invert(view));
1202
+
1203
+ gl.useProgram(prog);
1204
+ gl.uniformMatrix4fv(uMVP, false, mvp);
1205
+ gl.uniformMatrix4fv(uNormMat, false, normMat);
1206
+ gl.uniform1i(uSectionEnabled, sectionEnabled ? 1 : 0);
1207
+ gl.uniform4fv(uSectionPlane, sectionPlane);
1208
+ gl.uniform1f(uEdgeStrength, 8.0);
1209
+
1210
+ gl.bindVertexArray(vao);
1211
+ gl.drawElements(gl.TRIANGLES, drawCount, gl.UNSIGNED_INT, 0);
1212
+ gl.bindVertexArray(null);
1213
+ }
1214
+
1215
+ requestAnimationFrame(render);
1216
+ }
1217
+
1218
+ // ═══════════════════════════════════════════════════════════════════
1219
+ // 8. SSE CLIENT
1220
+ // ═══════════════════════════════════════════════════════════════════
1221
+ let sseRetryDelay = 1000;
1222
+ function connectSSE() {
1223
+ const es = new EventSource('/events');
1224
+ es.onopen = () => { sseRetryDelay = 1000; }; // Reset backoff on success
1225
+ es.onmessage = (e) => {
1226
+ try { handleCommand(JSON.parse(e.data)); }
1227
+ catch (err) { console.error('SSE parse error:', err); }
1228
+ };
1229
+ es.onerror = () => {
1230
+ es.close();
1231
+ setTimeout(connectSSE, sseRetryDelay);
1232
+ sseRetryDelay = Math.min(sseRetryDelay * 2, 30000);
1233
+ };
1234
+ }
1235
+
1236
+ // ═══════════════════════════════════════════════════════════════════
1237
+ // 9. LOAD MODEL
1238
+ // ═══════════════════════════════════════════════════════════════════
1239
+ async function loadModel() {
1240
+ const loadingText = document.getElementById('loading-text');
1241
+ const progressBar = document.getElementById('progress-bar');
1242
+ const statsEl = document.getElementById('model-stats');
1243
+
1244
+ try {
1245
+ loadingText.textContent = 'Initializing geometry engine...';
1246
+ const wasm = await import('/wasm/ifc-lite.js');
1247
+ await wasm.default();
1248
+ const api = new wasm.IfcAPI();
1249
+ wasmApi = api; // Store globally for addGeometry
1250
+
1251
+ loadingText.textContent = 'Downloading model...';
1252
+ const resp = await fetch('/model.ifc');
1253
+
1254
+ if (resp.status === 204) {
1255
+ // Empty mode — no model to load, just wait for commands
1256
+ statsEl.textContent = 'Empty scene — waiting for geometry';
1257
+ } else {
1258
+ const buffer = await resp.arrayBuffer();
1259
+ const content = new TextDecoder().decode(buffer);
1260
+ loadingText.textContent = 'Parsing geometry...';
1261
+
1262
+ let cameraFitted = false;
1263
+ await api.parseMeshesAsync(content, {
1264
+ batchSize: 50,
1265
+ onBatch: (meshes, progress) => {
1266
+ const batch = meshes.map(m => ({
1267
+ expressId: m.expressId,
1268
+ ifcType: m.ifcType || 'Unknown',
1269
+ positions: m.positions,
1270
+ normals: m.normals,
1271
+ indices: m.indices,
1272
+ color: [m.color[0], m.color[1], m.color[2], m.color[3] ?? 1],
1273
+ }));
1274
+ addMeshBatch(batch);
1275
+ progressBar.style.width = progress.percent + '%';
1276
+
1277
+ if (!cameraFitted && totalVertices > 0) {
1278
+ fitCamera();
1279
+ cameraFitted = true;
1280
+ }
1281
+
1282
+ statsEl.textContent = totalTriangles.toLocaleString() + ' triangles, ' +
1283
+ entityMap.size.toLocaleString() + ' entities (' + Math.round(progress.percent) + '%)';
1284
+ },
1285
+ onComplete: () => {
1286
+ progressBar.style.width = '100%';
1287
+ setTimeout(() => { document.getElementById('progress-wrap').style.opacity = '0'; }, 1000);
1288
+ },
1289
+ });
1290
+
1291
+ if (totalVertices > 0) fitCamera();
1292
+
1293
+ statsEl.textContent = totalTriangles.toLocaleString() + ' triangles, ' +
1294
+ entityMap.size.toLocaleString() + ' entities';
1295
+ statsEl.title = [...typeCounts.entries()].sort((a,b) => b[1]-a[1]).slice(0, 8)
1296
+ .map(([t,c]) => t + ': ' + c).join(', ');
1297
+ }
1298
+
1299
+ document.getElementById('loading').style.display = 'none';
1300
+
1301
+ } catch (err) {
1302
+ loadingText.textContent = 'Error: ' + err.message;
1303
+ console.error('Load error:', err);
1304
+ }
1305
+ }
1306
+
1307
+ // ═══════════════════════════════════════════════════════════════════
1308
+ // 10. INIT
1309
+ // ═══════════════════════════════════════════════════════════════════
1310
+ requestAnimationFrame(render);
1311
+ connectSSE();
1312
+ loadModel();
1313
+
1314
+ window.addEventListener('keydown', (e) => {
1315
+ if (e.key === 'h' || e.key === 'Home') fitCamera();
1316
+ if (e.key === 'Escape') {
1317
+ colorOverrides.clear();
1318
+ sectionEnabled = false;
1319
+ markAllColorsDirty();
1320
+ refreshColors();
1321
+ document.getElementById('pick-info').style.display = 'none';
1322
+ }
1323
+ });
1324
+ </script>
1325
+ </body>
1326
+ </html>`;
1327
+ }
1328
+ //# sourceMappingURL=viewer-html.js.map