@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.
- package/LICENSE +373 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/server.d.ts +43 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +256 -0
- package/dist/server.js.map +1 -0
- package/dist/streaming-viewer.d.ts +19 -0
- package/dist/streaming-viewer.d.ts.map +1 -0
- package/dist/streaming-viewer.js +103 -0
- package/dist/streaming-viewer.js.map +1 -0
- package/dist/viewer-html.d.ts +19 -0
- package/dist/viewer-html.d.ts.map +1 -0
- package/dist/viewer-html.js +1328 -0
- package/dist/viewer-html.js.map +1 -0
- package/package.json +55 -0
|
@@ -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, '<')
|
|
26
|
+
.replace(/>/g, '>')
|
|
27
|
+
.replace(/"/g, '"')
|
|
28
|
+
.replace(/'/g, ''');
|
|
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
|