@scanbim-labs/scanbim-mcp 1.0.5

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,1637 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>ScanBIM 3D — Immersive VR Viewer</title>
7
+ <link rel="manifest" href="/manifest.json">
8
+ <meta name="theme-color" content="#0a9eff">
9
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
10
+
11
+ <!-- Three.js r152 (WebXR support) -->
12
+ <script type="importmap">
13
+ {
14
+ "imports": {
15
+ "three": "https://cdn.jsdelivr.net/npm/three@0.152.0/build/three.module.js",
16
+ "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.152.0/examples/jsm/"
17
+ }
18
+ }
19
+ </script>
20
+
21
+ <style>
22
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
23
+ :root {
24
+ --bg: #0a0e1a; --sb: #0f1525; --hdr: #080c18; --card: #151d2e;
25
+ --inp: #0a0e1a; --bdr: #1e2840; --acc: #0a9eff; --ach: #0880d4;
26
+ --grn: #00e6b4; --red: #ff4466; --yel: #ffc44d; --pur: #a78bfa;
27
+ --txt: #e8edf5; --muted: #6b7a94; --vr-accent: #00e6b4;
28
+ }
29
+ html, body { height: 100%; overflow: hidden; font-family: 'Inter', system-ui, sans-serif; background: var(--bg); color: var(--txt); font-size: 13px; }
30
+ .app { display: flex; flex-direction: column; height: 100vh; }
31
+
32
+ /* ── HEADER ── */
33
+ .header {
34
+ height: 52px; background: var(--hdr); border-bottom: 1px solid rgba(10,158,255,.2);
35
+ display: flex; align-items: center; padding: 0 20px; gap: 12px; flex-shrink: 0; z-index: 20;
36
+ }
37
+ .logo { display: flex; align-items: center; gap: 10px; }
38
+ .logo-icon {
39
+ width: 32px; height: 32px; background: linear-gradient(135deg, var(--acc), var(--grn));
40
+ border-radius: 8px; display: flex; align-items: center; justify-content: center; font-weight: 800; font-size: 13px; color: #fff;
41
+ }
42
+ .logo-text { font-size: 17px; font-weight: 700; letter-spacing: -.3px; }
43
+ .logo-text em { font-style: normal; color: var(--acc); }
44
+ .logo-sub { color: var(--muted); font-size: 10px; font-weight: 500; letter-spacing: .3px; text-transform: uppercase; }
45
+ .spacer { flex: 1; }
46
+ .tier-badge {
47
+ padding: 4px 10px; border-radius: 20px; font-size: 10px; font-weight: 700; letter-spacing: .5px; text-transform: uppercase;
48
+ }
49
+ .tier-free { background: rgba(107,122,148,.15); border: 1px solid rgba(107,122,148,.3); color: var(--muted); }
50
+ .tier-pro { background: rgba(10,158,255,.12); border: 1px solid rgba(10,158,255,.35); color: var(--acc); }
51
+ .tier-enterprise { background: rgba(0,230,180,.1); border: 1px solid rgba(0,230,180,.3); color: var(--grn); }
52
+ .header-btn {
53
+ padding: 6px 14px; border-radius: 6px; border: 1px solid var(--bdr); background: var(--card);
54
+ color: var(--txt); font: 500 12px/1 'Inter'; cursor: pointer; transition: all .15s;
55
+ }
56
+ .header-btn:hover { border-color: var(--acc); color: var(--acc); }
57
+ .header-btn.vr-btn {
58
+ background: linear-gradient(135deg, var(--acc), var(--grn)); border: none; color: #fff; font-weight: 700;
59
+ }
60
+ .header-btn.vr-btn:hover { opacity: .9; }
61
+ .header-btn.vr-btn:disabled { opacity: .4; cursor: not-allowed; }
62
+ .hamburger { display: none; background: none; border: none; color: var(--txt); font-size: 22px; cursor: pointer; }
63
+
64
+ /* ── LAYOUT ── */
65
+ .body { display: flex; flex: 1; overflow: hidden; }
66
+
67
+ /* ── SIDEBAR ── */
68
+ .sidebar {
69
+ width: 300px; flex-shrink: 0; background: var(--sb); border-right: 1px solid var(--bdr);
70
+ display: flex; flex-direction: column; overflow-y: auto; z-index: 100;
71
+ }
72
+ .sidebar.hidden { display: none; }
73
+ .sec { border-bottom: 1px solid var(--bdr); }
74
+ .sec-hdr {
75
+ display: flex; align-items: center; justify-content: space-between;
76
+ padding: 11px 16px; cursor: pointer; user-select: none; transition: background .12s;
77
+ }
78
+ .sec-hdr:hover { background: rgba(255,255,255,.03); }
79
+ .sec-title {
80
+ font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: .9px;
81
+ color: var(--muted); display: flex; align-items: center; gap: 7px;
82
+ }
83
+ .sec-title .icon { font-size: 14px; }
84
+ .chev { color: var(--muted); font-size: 10px; transition: transform .2s; }
85
+ .sec-body { padding: 0 16px 14px; display: none; }
86
+ .sec-body.open { display: block; }
87
+
88
+ /* ── BUTTONS ── */
89
+ .btn {
90
+ display: flex; align-items: center; justify-content: center; gap: 6px;
91
+ padding: 8px 14px; border-radius: 6px; border: none; cursor: pointer;
92
+ font: 500 12px/1 'Inter'; width: 100%; transition: all .15s;
93
+ }
94
+ .btn:disabled { opacity: .4; cursor: not-allowed; }
95
+ .btn-acc { background: var(--acc); color: #fff; } .btn-acc:hover:not(:disabled) { background: var(--ach); }
96
+ .btn-sec { background: var(--card); color: var(--txt); border: 1px solid var(--bdr); } .btn-sec:hover:not(:disabled) { background: var(--bdr); }
97
+ .btn-grn { background: var(--grn); color: #0a0e1a; font-weight: 600; } .btn-grn:hover:not(:disabled) { opacity: .9; }
98
+ .btn-red { background: var(--red); color: #fff; } .btn-red:hover:not(:disabled) { opacity: .9; }
99
+ .btn-row { display: flex; gap: 6px; margin-top: 6px; }
100
+ .btn-sm { padding: 5px 10px; font-size: 11px; width: auto; }
101
+ .mb6 { margin-bottom: 6px; } .mb8 { margin-bottom: 8px; } .mt8 { margin-top: 8px; }
102
+
103
+ /* ── INPUTS ── */
104
+ .lbl { display: block; font-size: 10px; color: var(--muted); margin-bottom: 4px; font-weight: 500; }
105
+ .input {
106
+ width: 100%; background: var(--inp); border: 1px solid var(--bdr); color: var(--txt);
107
+ padding: 8px 10px; border-radius: 6px; font: 12px/1 'Inter'; outline: none; transition: border-color .15s;
108
+ }
109
+ .input:focus { border-color: var(--acc); }
110
+ .select {
111
+ width: 100%; background: var(--inp); border: 1px solid var(--bdr); color: var(--txt);
112
+ padding: 7px 10px; border-radius: 6px; font: 12px/1 'Inter'; outline: none; cursor: pointer;
113
+ appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%236b7a94'/%3E%3C/svg%3E");
114
+ background-repeat: no-repeat; background-position: right 10px center;
115
+ }
116
+ .ig { margin-bottom: 10px; }
117
+ .chk-label { display: flex; align-items: center; gap: 7px; cursor: pointer; font-size: 12px; user-select: none; }
118
+
119
+ /* ── SLIDERS ── */
120
+ .slider-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
121
+ .slider-lbl { font-size: 10px; color: var(--muted); width: 14px; flex-shrink: 0; font-weight: 600; }
122
+ .slider { flex: 1; -webkit-appearance: none; appearance: none; height: 4px; border-radius: 2px; background: var(--bdr); outline: none; cursor: pointer; }
123
+ .slider::-webkit-slider-thumb { -webkit-appearance: none; width: 14px; height: 14px; border-radius: 50%; cursor: pointer; }
124
+ .slider.sx::-webkit-slider-thumb { background: var(--red); }
125
+ .slider.sy::-webkit-slider-thumb { background: var(--grn); }
126
+ .slider.sz::-webkit-slider-thumb { background: var(--acc); }
127
+ .slider-val { font-size: 10px; color: var(--muted); width: 34px; text-align: right; font-family: monospace; flex-shrink: 0; }
128
+
129
+ /* ── DROP ZONE ── */
130
+ .drop-zone {
131
+ border: 1.5px dashed var(--bdr); border-radius: 8px; padding: 18px 14px; text-align: center;
132
+ cursor: pointer; transition: all .15s; margin-bottom: 10px;
133
+ }
134
+ .drop-zone:hover, .drop-zone.over { border-color: var(--acc); background: rgba(10,158,255,.05); }
135
+ .dz-icon { font-size: 22px; margin-bottom: 6px; }
136
+ .dz-text { color: var(--muted); font-size: 11px; line-height: 1.5; }
137
+ .dz-text strong { color: var(--txt); }
138
+
139
+ /* ── TREE ── */
140
+ .tree-list {
141
+ max-height: 200px; overflow-y: auto; margin-bottom: 10px;
142
+ border: 1px solid var(--bdr); border-radius: 6px; background: rgba(0,0,0,.25);
143
+ }
144
+ .tree-item { border-bottom: 1px solid var(--bdr); }
145
+ .tree-item:last-child { border-bottom: none; }
146
+ .tree-row {
147
+ display: flex; align-items: center; gap: 6px; padding: 8px 10px;
148
+ cursor: pointer; user-select: none; transition: background .12s; border-left: 2px solid transparent;
149
+ }
150
+ .tree-row:hover { background: rgba(255,255,255,.04); }
151
+ .tree-row.selected { background: rgba(10,158,255,.1); border-left-color: var(--acc); }
152
+ .tree-vis { background: none; border: none; color: var(--muted); cursor: pointer; padding: 0; font-size: 14px; }
153
+ .tree-name { font-size: 11px; color: var(--txt); flex: 1; font-weight: 500; }
154
+ .tree-details { padding: 6px 10px 8px; background: rgba(0,0,0,.15); display: none; gap: 6px; align-items: center; font-size: 10px; }
155
+ .tree-details.open { display: flex; }
156
+
157
+ /* ── VIEWPORT ── */
158
+ #viewport { flex: 1; position: relative; overflow: hidden; background: var(--bg); }
159
+ #canvas-wrap { width: 100%; height: 100%; }
160
+ #canvas-wrap canvas { display: block; width: 100%; height: 100%; }
161
+
162
+ /* ── VR HUD (in-VR overlays) ── */
163
+ .vr-hud {
164
+ position: absolute; bottom: 12px; left: 50%; transform: translateX(-50%);
165
+ display: flex; gap: 8px; z-index: 30; pointer-events: auto;
166
+ }
167
+ .vr-hud-btn {
168
+ padding: 8px 16px; border-radius: 20px; border: 1px solid rgba(10,158,255,.4);
169
+ background: rgba(10,20,40,.85); backdrop-filter: blur(10px); color: var(--txt);
170
+ font: 600 11px/1 'Inter'; cursor: pointer; transition: all .15s; white-space: nowrap;
171
+ }
172
+ .vr-hud-btn:hover { border-color: var(--acc); background: rgba(10,30,60,.9); }
173
+ .vr-hud-btn.active { background: rgba(10,158,255,.2); border-color: var(--acc); color: var(--acc); }
174
+
175
+ /* ── STATUS BAR ── */
176
+ .status-bar {
177
+ height: 28px; background: var(--hdr); border-top: 1px solid var(--bdr);
178
+ display: flex; align-items: center; padding: 0 16px; font-size: 10px; color: var(--muted); gap: 16px;
179
+ }
180
+ .status-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--grn); }
181
+ .status-dot.disconnected { background: var(--red); }
182
+ .status-dot.vr-active { background: var(--acc); animation: pulse 1.5s infinite; }
183
+ @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .4; } }
184
+
185
+ /* ── INFO CARD ── */
186
+ .info-card { background: var(--card); border-radius: 6px; padding: 10px 12px; font-size: 11px; }
187
+ .ir { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 4px; }
188
+ .ir:last-child { margin-bottom: 0; }
189
+ .ik { color: var(--muted); } .iv { color: var(--txt); font-weight: 500; }
190
+
191
+ /* ── BADGES ── */
192
+ .badges { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 8px; }
193
+ .badge { padding: 2px 7px; border-radius: 4px; font-size: 10px; font-weight: 600; }
194
+ .bg-g { background: rgba(0,230,180,.1); color: var(--grn); border: 1px solid rgba(0,230,180,.2); }
195
+ .bg-b { background: rgba(10,158,255,.1); color: var(--acc); border: 1px solid rgba(10,158,255,.2); }
196
+ .bg-p { background: rgba(167,139,250,.1); color: var(--pur); border: 1px solid rgba(167,139,250,.2); }
197
+ .bg-m { background: rgba(107,122,148,.08); color: var(--muted); border: 1px solid rgba(107,122,148,.15); }
198
+
199
+ /* ── VR COLLAB PANEL ── */
200
+ .collab-users { display: flex; flex-direction: column; gap: 6px; }
201
+ .collab-user {
202
+ display: flex; align-items: center; gap: 8px; padding: 6px 10px;
203
+ border-radius: 6px; background: rgba(0,0,0,.2); font-size: 11px;
204
+ }
205
+ .collab-avatar {
206
+ width: 24px; height: 24px; border-radius: 50%; display: flex; align-items: center;
207
+ justify-content: center; font-size: 10px; font-weight: 700; color: #fff;
208
+ }
209
+ .collab-name { flex: 1; font-weight: 500; }
210
+ .collab-status { font-size: 9px; padding: 2px 6px; border-radius: 10px; }
211
+ .cs-vr { background: rgba(10,158,255,.15); color: var(--acc); }
212
+ .cs-web { background: rgba(0,230,180,.15); color: var(--grn); }
213
+
214
+ /* ── MODAL ── */
215
+ .modal-overlay {
216
+ display: none; position: fixed; inset: 0; background: rgba(0,0,0,.7); z-index: 200;
217
+ align-items: center; justify-content: center;
218
+ }
219
+ .modal-overlay.show { display: flex; }
220
+ .modal {
221
+ background: var(--sb); border: 1px solid var(--bdr); border-radius: 12px;
222
+ padding: 28px; max-width: 500px; width: 90%;
223
+ }
224
+ .modal h2 { font-size: 18px; margin-bottom: 16px; }
225
+ .modal p { color: var(--muted); font-size: 12px; line-height: 1.6; margin-bottom: 14px; }
226
+
227
+ /* ── VIEWPOINTS ── */
228
+ .vp-list { max-height: 180px; overflow-y: auto; margin-bottom: 10px; border: 1px solid var(--bdr); border-radius: 6px; background: rgba(0,0,0,.25); }
229
+ .vp-item { border-bottom: 1px solid var(--bdr); padding: 8px 10px; display: flex; align-items: center; gap: 8px; cursor: pointer; transition: background .12s; }
230
+ .vp-item:last-child { border-bottom: none; }
231
+ .vp-item:hover { background: rgba(255,255,255,.04); }
232
+ .vp-name { flex: 1; font-size: 11px; font-weight: 500; }
233
+
234
+ /* ── AI ASSISTANT ── */
235
+ .ai-chat { display: flex; flex-direction: column; gap: 6px; margin-bottom: 10px; max-height: 200px; overflow-y: auto; }
236
+ .ai-msg { padding: 8px 10px; border-radius: 8px; font-size: 11px; line-height: 1.5; max-width: 90%; }
237
+ .ai-msg.user { background: rgba(10,158,255,.12); align-self: flex-end; border-bottom-right-radius: 2px; }
238
+ .ai-msg.assistant { background: var(--card); align-self: flex-start; border-bottom-left-radius: 2px; }
239
+ .ai-input-row { display: flex; gap: 6px; }
240
+ .ai-input-row .input { flex: 1; }
241
+ .ai-input-row .btn { width: auto; flex-shrink: 0; }
242
+
243
+ /* ── RESPONSIVE ── */
244
+ @media (max-width: 768px) {
245
+ .sidebar { position: fixed; left: 0; top: 52px; bottom: 28px; z-index: 100; transform: translateX(-100%); transition: transform .3s; }
246
+ .sidebar.show { transform: translateX(0); }
247
+ .hamburger { display: block; }
248
+ }
249
+ </style>
250
+ </head>
251
+ <body>
252
+ <div class="app">
253
+ <!-- ── HEADER ── -->
254
+ <div class="header">
255
+ <button class="hamburger" onclick="toggleSidebar()">☰</button>
256
+ <div class="logo">
257
+ <div class="logo-icon">SB</div>
258
+ <div>
259
+ <div class="logo-text">Scan<em>BIM</em> 3D</div>
260
+ <div class="logo-sub">Immersive Viewer</div>
261
+ </div>
262
+ </div>
263
+ <span class="spacer"></span>
264
+ <span class="tier-badge tier-pro" id="tierBadge">PRO</span>
265
+ <button class="header-btn" id="btnCollab" onclick="openCollabModal()">👥 Collaborate</button>
266
+ <button class="header-btn vr-btn" id="btnEnterVR" disabled>🥽 Enter VR</button>
267
+ <button class="header-btn" id="btnAuth" onclick="openAuthModal()">Sign In</button>
268
+ </div>
269
+
270
+ <div class="body">
271
+ <!-- ── SIDEBAR ── -->
272
+ <div class="sidebar" id="sidebar">
273
+
274
+ <!-- Models / Upload -->
275
+ <div class="sec">
276
+ <div class="sec-hdr" onclick="toggleSec(this)">
277
+ <span class="sec-title"><span class="icon">📁</span> Models</span>
278
+ <span class="chev">▾</span>
279
+ </div>
280
+ <div class="sec-body open">
281
+ <div class="drop-zone" id="dropZone" onclick="document.getElementById('fileInput').click()">
282
+ <div class="dz-icon">⬆️</div>
283
+ <div class="dz-text"><strong>Drop model files</strong><br>glTF, FBX, OBJ, IFC, PLY, STL, E57, LAS</div>
284
+ </div>
285
+ <input type="file" id="fileInput" style="display:none" accept=".gltf,.glb,.fbx,.obj,.ifc,.ply,.stl,.e57,.las,.laz" multiple onchange="handleFiles(this.files)">
286
+ <div class="tree-list" id="modelTree"></div>
287
+ <div class="info-card" id="sceneInfo" style="display:none">
288
+ <div class="ir"><span class="ik">Models</span><span class="iv" id="infoModels">0</span></div>
289
+ <div class="ir"><span class="ik">Triangles</span><span class="iv" id="infoTris">0</span></div>
290
+ <div class="ir"><span class="ik">Points</span><span class="iv" id="infoPoints">0</span></div>
291
+ </div>
292
+ </div>
293
+ </div>
294
+
295
+ <!-- Navigation -->
296
+ <div class="sec">
297
+ <div class="sec-hdr" onclick="toggleSec(this)">
298
+ <span class="sec-title"><span class="icon">🧭</span> Navigation</span>
299
+ <span class="chev">▾</span>
300
+ </div>
301
+ <div class="sec-body">
302
+ <div class="lbl">Movement Mode</div>
303
+ <div class="btn-row mb8">
304
+ <button class="btn btn-sm btn-sec" onclick="setNavMode('orbit')" id="navOrbit">🔄 Orbit</button>
305
+ <button class="btn btn-sm btn-sec" onclick="setNavMode('walk')" id="navWalk">🚶 Walk</button>
306
+ <button class="btn btn-sm btn-sec" onclick="setNavMode('fly')" id="navFly">✈️ Fly</button>
307
+ </div>
308
+ <div class="lbl">VR Locomotion</div>
309
+ <div class="btn-row mb8">
310
+ <button class="btn btn-sm btn-sec" onclick="setVRNav('teleport')" id="vrTeleport">🎯 Teleport</button>
311
+ <button class="btn btn-sm btn-sec" onclick="setVRNav('smooth')" id="vrSmooth">🏃 Smooth</button>
312
+ <button class="btn btn-sm btn-sec" onclick="setVRNav('grab')" id="vrGrab">✊ Grab World</button>
313
+ </div>
314
+ <div class="ig">
315
+ <div class="lbl">Move Speed</div>
316
+ <input type="range" class="slider" min="0.1" max="10" step="0.1" value="2" oninput="moveSpeed=parseFloat(this.value)">
317
+ </div>
318
+ <div class="ig">
319
+ <div class="lbl">Scale (1:X)</div>
320
+ <div class="btn-row">
321
+ <button class="btn btn-sm btn-sec" onclick="setScale(1)">1:1</button>
322
+ <button class="btn btn-sm btn-sec" onclick="setScale(10)">1:10</button>
323
+ <button class="btn btn-sm btn-sec" onclick="setScale(50)">1:50</button>
324
+ <button class="btn btn-sm btn-sec" onclick="setScale(100)">1:100</button>
325
+ </div>
326
+ </div>
327
+ </div>
328
+ </div>
329
+
330
+ <!-- Section Planes -->
331
+ <div class="sec">
332
+ <div class="sec-hdr" onclick="toggleSec(this)">
333
+ <span class="sec-title"><span class="icon">✂️</span> Section Planes</span>
334
+ <span class="chev">▾</span>
335
+ </div>
336
+ <div class="sec-body">
337
+ <div class="slider-row">
338
+ <span class="slider-lbl" style="color:var(--red)">X</span>
339
+ <input class="slider sx" type="range" min="-100" max="100" value="100" step="0.1" id="clipX" oninput="updateClip('x',this.value)">
340
+ <span class="slider-val" id="clipXVal">100</span>
341
+ <button class="btn btn-sm btn-sec" style="width:auto;padding:4px 8px" onclick="flipClip('x')">⟲</button>
342
+ </div>
343
+ <div class="slider-row">
344
+ <span class="slider-lbl" style="color:var(--grn)">Y</span>
345
+ <input class="slider sy" type="range" min="-100" max="100" value="100" step="0.1" id="clipY" oninput="updateClip('y',this.value)">
346
+ <span class="slider-val" id="clipYVal">100</span>
347
+ <button class="btn btn-sm btn-sec" style="width:auto;padding:4px 8px" onclick="flipClip('y')">⟲</button>
348
+ </div>
349
+ <div class="slider-row">
350
+ <span class="slider-lbl" style="color:var(--acc)">Z</span>
351
+ <input class="slider sz" type="range" min="-100" max="100" value="100" step="0.1" id="clipZ" oninput="updateClip('z',this.value)">
352
+ <span class="slider-val" id="clipZVal">100</span>
353
+ <button class="btn btn-sm btn-sec" style="width:auto;padding:4px 8px" onclick="flipClip('z')">⟲</button>
354
+ </div>
355
+ <button class="btn btn-sec mt8" onclick="resetClipping()">Reset All Planes</button>
356
+ </div>
357
+ </div>
358
+
359
+ <!-- Measurement -->
360
+ <div class="sec">
361
+ <div class="sec-hdr" onclick="toggleSec(this)">
362
+ <span class="sec-title"><span class="icon">📏</span> Measurement</span>
363
+ <span class="chev">▾</span>
364
+ </div>
365
+ <div class="sec-body">
366
+ <button class="btn btn-acc mb6" id="btnMeasure" onclick="toggleMeasure()">Start Measuring</button>
367
+ <div class="info-card" id="measInfo" style="display:none">
368
+ <div class="ir"><span class="ik">Distance</span><span class="iv" id="measDist">—</span></div>
369
+ <div class="ir"><span class="ik">ΔX</span><span class="iv" id="measDX">—</span></div>
370
+ <div class="ir"><span class="ik">ΔY</span><span class="iv" id="measDY">—</span></div>
371
+ <div class="ir"><span class="ik">ΔZ</span><span class="iv" id="measDZ">—</span></div>
372
+ </div>
373
+ <div class="lbl mt8">VR: Point controllers to measure</div>
374
+ <label class="chk-label mt8">
375
+ <input type="checkbox" id="chkSnapVertex" checked> <span>Snap to vertices</span>
376
+ </label>
377
+ <label class="chk-label">
378
+ <input type="checkbox" id="chkPersist"> <span>Keep measurements</span>
379
+ </label>
380
+ <button class="btn btn-sec mt8" onclick="clearMeasurements()">Clear All</button>
381
+ </div>
382
+ </div>
383
+
384
+ <!-- Annotations / BCF -->
385
+ <div class="sec">
386
+ <div class="sec-hdr" onclick="toggleSec(this)">
387
+ <span class="sec-title"><span class="icon">📌</span> Annotations &amp; BCF</span>
388
+ <span class="chev">▾</span>
389
+ </div>
390
+ <div class="sec-body">
391
+ <div class="btn-row mb6">
392
+ <button class="btn btn-sm btn-acc" onclick="addAnnotation('pin')">📍 Pin</button>
393
+ <button class="btn btn-sm btn-sec" onclick="addAnnotation('text')">💬 Text</button>
394
+ <button class="btn btn-sm btn-sec" onclick="addAnnotation('arrow')">➡️ Arrow</button>
395
+ </div>
396
+ <div class="ig">
397
+ <div class="lbl">Priority</div>
398
+ <select class="select" id="annPriority">
399
+ <option value="info">ℹ️ Info</option>
400
+ <option value="warning">⚠️ Warning</option>
401
+ <option value="critical">🔴 Critical</option>
402
+ </select>
403
+ </div>
404
+ <div class="ig">
405
+ <div class="lbl">Assigned To</div>
406
+ <input class="input" id="annAssign" placeholder="Name or email">
407
+ </div>
408
+ <div id="annList"></div>
409
+ <div class="btn-row mt8">
410
+ <button class="btn btn-sm btn-sec" onclick="exportBCF()">📤 Export BCF</button>
411
+ <button class="btn btn-sm btn-sec" onclick="importBCF()">📥 Import BCF</button>
412
+ </div>
413
+ <div class="lbl mt8" style="color:var(--grn)">VR: Speech-to-text annotation with trigger</div>
414
+ </div>
415
+ </div>
416
+
417
+ <!-- Viewpoints -->
418
+ <div class="sec">
419
+ <div class="sec-hdr" onclick="toggleSec(this)">
420
+ <span class="sec-title"><span class="icon">📸</span> Viewpoints</span>
421
+ <span class="chev">▾</span>
422
+ </div>
423
+ <div class="sec-body">
424
+ <button class="btn btn-acc mb6" onclick="saveViewpoint()">💾 Save Current View</button>
425
+ <div class="vp-list" id="vpList"></div>
426
+ <button class="btn btn-sec" onclick="exportViewpoints()">📤 Export Viewpoints</button>
427
+ </div>
428
+ </div>
429
+
430
+ <!-- Collaboration -->
431
+ <div class="sec">
432
+ <div class="sec-hdr" onclick="toggleSec(this)">
433
+ <span class="sec-title"><span class="icon">👥</span> Collaboration</span>
434
+ <span class="chev">▾</span>
435
+ </div>
436
+ <div class="sec-body">
437
+ <div class="collab-users" id="collabUsers">
438
+ <div class="collab-user">
439
+ <div class="collab-avatar" style="background:var(--acc)">IM</div>
440
+ <span class="collab-name">Ian M. (You)</span>
441
+ <span class="collab-status cs-web">Web</span>
442
+ </div>
443
+ </div>
444
+ <button class="btn btn-acc mt8" onclick="inviteUser()">➕ Invite to Session</button>
445
+ <label class="chk-label mt8">
446
+ <input type="checkbox" id="chkFollowHost" checked> <span>Follow host camera</span>
447
+ </label>
448
+ <label class="chk-label">
449
+ <input type="checkbox" id="chkVoiceChat"> <span>Enable voice chat</span>
450
+ </label>
451
+ <div class="lbl mt8" style="color:var(--grn)">VR: See other users as avatars in space</div>
452
+ </div>
453
+ </div>
454
+
455
+ <!-- AI Spatial Assistant -->
456
+ <div class="sec">
457
+ <div class="sec-hdr" onclick="toggleSec(this)">
458
+ <span class="sec-title"><span class="icon">🤖</span> AI Assistant</span>
459
+ <span class="chev">▾</span>
460
+ </div>
461
+ <div class="sec-body">
462
+ <div class="ai-chat" id="aiChat">
463
+ <div class="ai-msg assistant">Hi! I can help you navigate and inspect this model. Try asking me to find equipment, navigate to rooms, or explain building systems.</div>
464
+ </div>
465
+ <div class="ai-input-row">
466
+ <input class="input" id="aiInput" placeholder="Ask about the model..." onkeydown="if(event.key==='Enter')sendAI()">
467
+ <button class="btn btn-sm btn-acc" onclick="sendAI()">Send</button>
468
+ </div>
469
+ <div class="lbl mt8" style="color:var(--grn)">VR: Voice commands via trigger + speak</div>
470
+ <div class="badges mt8">
471
+ <span class="badge bg-b">Enterprise</span>
472
+ </div>
473
+ </div>
474
+ </div>
475
+
476
+ <!-- Environment -->
477
+ <div class="sec">
478
+ <div class="sec-hdr" onclick="toggleSec(this)">
479
+ <span class="sec-title"><span class="icon">🌤️</span> Environment</span>
480
+ <span class="chev">▾</span>
481
+ </div>
482
+ <div class="sec-body">
483
+ <div class="ig">
484
+ <div class="lbl">Preset</div>
485
+ <select class="select" id="envPreset" onchange="setEnvironment(this.value)">
486
+ <option value="dark">🌙 Dark Studio</option>
487
+ <option value="daylight">☀️ Daylight</option>
488
+ <option value="sunset">🌅 Sunset</option>
489
+ <option value="overcast">🌥️ Overcast</option>
490
+ <option value="night">🌃 Night</option>
491
+ <option value="xray">🔬 X-Ray</option>
492
+ </select>
493
+ </div>
494
+ <label class="chk-label mb6">
495
+ <input type="checkbox" id="chkGrid" checked onchange="toggleGrid()"> <span>Show Grid</span>
496
+ </label>
497
+ <label class="chk-label mb6">
498
+ <input type="checkbox" id="chkWireframe" onchange="toggleWireframe()"> <span>Wireframe Overlay</span>
499
+ </label>
500
+ <label class="chk-label mb6">
501
+ <input type="checkbox" id="chkShadows" checked onchange="toggleShadows()"> <span>Shadows</span>
502
+ </label>
503
+ <label class="chk-label">
504
+ <input type="checkbox" id="chkPassthrough" onchange="togglePassthrough()"> <span>AR Passthrough (Quest 3)</span>
505
+ </label>
506
+ </div>
507
+ </div>
508
+
509
+ <!-- Integrations (Enterprise) -->
510
+ <div class="sec">
511
+ <div class="sec-hdr" onclick="toggleSec(this)">
512
+ <span class="sec-title"><span class="icon">🔗</span> Integrations</span>
513
+ <span class="chev">▾</span>
514
+ </div>
515
+ <div class="sec-body">
516
+ <div class="lbl">Connected Services</div>
517
+ <div class="info-card mb6">
518
+ <div class="ir"><span class="ik">Autodesk ACC</span><span class="iv" style="color:var(--muted)">Not connected</span></div>
519
+ <div class="ir"><span class="ik">Procore</span><span class="iv" style="color:var(--muted)">Not connected</span></div>
520
+ <div class="ir"><span class="ik">Revizto</span><span class="iv" style="color:var(--muted)">Not connected</span></div>
521
+ <div class="ir"><span class="ik">Navisworks</span><span class="iv" style="color:var(--muted)">Not connected</span></div>
522
+ </div>
523
+ <button class="btn btn-sec" onclick="openIntegrations()">⚙️ Configure Integrations</button>
524
+ <div class="badges mt8">
525
+ <span class="badge bg-b">Enterprise</span>
526
+ </div>
527
+ </div>
528
+ </div>
529
+
530
+ <!-- Project / Export -->
531
+ <div class="sec">
532
+ <div class="sec-hdr" onclick="toggleSec(this)">
533
+ <span class="sec-title"><span class="icon">💾</span> Project</span>
534
+ <span class="chev">▾</span>
535
+ </div>
536
+ <div class="sec-body">
537
+ <div class="ig">
538
+ <div class="lbl">Project Name</div>
539
+ <input class="input" id="projName" placeholder="My Project" value="">
540
+ </div>
541
+ <button class="btn btn-acc mb6" onclick="saveProject()">💾 Save Project</button>
542
+ <button class="btn btn-sec mb6" onclick="loadProject()">📂 Load Project</button>
543
+ <button class="btn btn-sec mb6" onclick="exportScreenshot()">📸 Capture Screenshot</button>
544
+ <button class="btn btn-sec" onclick="shareProject()">🔗 Share Link</button>
545
+ </div>
546
+ </div>
547
+ </div>
548
+
549
+ <!-- ── VIEWPORT ── -->
550
+ <div id="viewport">
551
+ <div id="canvas-wrap"></div>
552
+ <div class="vr-hud" id="vrHud" style="display:none">
553
+ <button class="vr-hud-btn" onclick="toggleMeasure()">📏 Measure</button>
554
+ <button class="vr-hud-btn" onclick="addAnnotation('pin')">📌 Annotate</button>
555
+ <button class="vr-hud-btn" onclick="toggleClipVR()">✂️ Section</button>
556
+ <button class="vr-hud-btn" onclick="toggleVoiceAnnotation()">🎙️ Voice Note</button>
557
+ </div>
558
+ </div>
559
+ </div>
560
+
561
+ <!-- ── STATUS BAR ── -->
562
+ <div class="status-bar">
563
+ <span class="status-dot" id="statusDot"></span>
564
+ <span id="statusText">Ready</span>
565
+ <span class="spacer"></span>
566
+ <span id="statusFPS">0 FPS</span>
567
+ <span>|</span>
568
+ <span id="statusTris">0 tris</span>
569
+ <span>|</span>
570
+ <span id="statusVR">VR: Checking...</span>
571
+ </div>
572
+ </div>
573
+
574
+ <!-- ── COLLABORATION MODAL ── -->
575
+ <div class="modal-overlay" id="collabModal">
576
+ <div class="modal">
577
+ <h2>👥 Start Collaboration Session</h2>
578
+ <p>Share the link below to invite team members. They can join via web browser or VR headset. Up to 20 concurrent users (Enterprise) or 5 users (Pro).</p>
579
+ <div class="ig">
580
+ <div class="lbl">Session Link</div>
581
+ <input class="input" id="collabLink" value="" readonly onclick="this.select()">
582
+ </div>
583
+ <div class="ig">
584
+ <div class="lbl">Session Password (optional)</div>
585
+ <input class="input" id="collabPass" placeholder="Leave blank for open session">
586
+ </div>
587
+ <div class="btn-row">
588
+ <button class="btn btn-acc" onclick="startSession()">Create Session</button>
589
+ <button class="btn btn-sec" onclick="closeCollabModal()">Cancel</button>
590
+ </div>
591
+ </div>
592
+ </div>
593
+
594
+ <!-- ── AUTH MODAL ── -->
595
+ <div class="modal-overlay" id="authModal">
596
+ <div class="modal">
597
+ <h2>Sign In to ScanBIM 3D</h2>
598
+ <p>Sign in to save projects, access cloud features, and unlock Pro/Enterprise capabilities.</p>
599
+ <div class="ig">
600
+ <div class="lbl">Email</div>
601
+ <input class="input" id="authEmail" placeholder="you@company.com">
602
+ </div>
603
+ <div class="ig">
604
+ <div class="lbl">Password</div>
605
+ <input class="input" id="authPass" type="password" placeholder="••••••••">
606
+ </div>
607
+ <div class="btn-row">
608
+ <button class="btn btn-acc" onclick="signIn()">Sign In</button>
609
+ <button class="btn btn-sec" onclick="signUp()">Create Account</button>
610
+ <button class="btn btn-sec" onclick="closeAuthModal()">Cancel</button>
611
+ </div>
612
+ </div>
613
+ </div>
614
+
615
+ <!-- ── UPGRADE MODAL ── -->
616
+ <div class="modal-overlay" id="upgradeModal">
617
+ <div class="modal">
618
+ <h2>Upgrade Your Plan</h2>
619
+ <div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;margin-bottom:16px;">
620
+ <!-- Free -->
621
+ <div style="border:1px solid var(--bdr);border-radius:8px;padding:16px;text-align:center;">
622
+ <div style="font-size:12px;font-weight:700;color:var(--muted);text-transform:uppercase;margin-bottom:8px;">Free</div>
623
+ <div style="font-size:24px;font-weight:800;margin-bottom:12px;">$0</div>
624
+ <div style="font-size:10px;color:var(--muted);line-height:1.8;">
625
+ 3 projects<br>100MB storage<br>All open-source formats<br>Basic sections<br>1 user<br>Web only
626
+ </div>
627
+ </div>
628
+ <!-- Pro -->
629
+ <div style="border:2px solid var(--acc);border-radius:8px;padding:16px;text-align:center;background:rgba(10,158,255,.04);">
630
+ <div style="font-size:12px;font-weight:700;color:var(--acc);text-transform:uppercase;margin-bottom:8px;">Pro</div>
631
+ <div style="font-size:24px;font-weight:800;margin-bottom:4px;">$49<span style="font-size:12px;color:var(--muted)">/mo</span></div>
632
+ <div style="font-size:10px;color:var(--muted);margin-bottom:12px;">per user, billed annually</div>
633
+ <div style="font-size:10px;color:var(--muted);line-height:1.8;">
634
+ Unlimited projects<br>10GB storage<br>All formats + FBX<br>VR viewer (Quest)<br>Section planes<br>Measurements<br>BCF export/import<br>5-user collaboration<br>Voice annotations<br>Teleport + walk nav<br>Point clouds to 50M pts<br>QR sharing
635
+ </div>
636
+ <button class="btn btn-acc mt8" onclick="subscribe('pro')">Start 14-Day Free Trial</button>
637
+ </div>
638
+ <!-- Enterprise -->
639
+ <div style="border:2px solid var(--grn);border-radius:8px;padding:16px;text-align:center;background:rgba(0,230,180,.03);">
640
+ <div style="font-size:12px;font-weight:700;color:var(--grn);text-transform:uppercase;margin-bottom:8px;">Enterprise</div>
641
+ <div style="font-size:24px;font-weight:800;margin-bottom:4px;">$149<span style="font-size:12px;color:var(--muted)">/mo</span></div>
642
+ <div style="font-size:10px;color:var(--muted);margin-bottom:12px;">per user, billed annually</div>
643
+ <div style="font-size:10px;color:var(--muted);line-height:1.8;">
644
+ Everything in Pro, plus:<br>Revit, Navisworks, DWG import<br>Unlimited storage<br>AI Spatial Assistant<br>20-user collaboration<br>AR Passthrough (Quest 3)<br>Point clouds to 500M+ pts<br>Autodesk ACC/BIM 360<br>Procore integration<br>Revizto issue sync<br>SSO/SAML<br>Admin dashboard<br>Priority support<br>Custom branding<br>API access
645
+ </div>
646
+ <button class="btn btn-grn mt8" onclick="subscribe('enterprise')">Start 14-Day Free Trial</button>
647
+ </div>
648
+ </div>
649
+ <button class="btn btn-sec" onclick="closeUpgradeModal()">Maybe Later</button>
650
+ </div>
651
+ </div>
652
+
653
+ <script type="module">
654
+ import * as THREE from 'three';
655
+ import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
656
+ import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
657
+ import { FBXLoader } from 'three/addons/loaders/FBXLoader.js';
658
+ import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
659
+ import { PLYLoader } from 'three/addons/loaders/PLYLoader.js';
660
+ import { STLLoader } from 'three/addons/loaders/STLLoader.js';
661
+ import { XRControllerModelFactory } from 'three/addons/webxr/XRControllerModelFactory.js';
662
+ import { VRButton } from 'three/addons/webxr/VRButton.js';
663
+
664
+ // ── GLOBAL STATE ────────────────────────────────────────
665
+ let scene, camera, renderer, controls;
666
+ let ambientLight, dirLight, hemiLight, grid;
667
+ let loadedModels = [];
668
+ let savedViewpoints = [];
669
+ let annotations = [];
670
+ let measurements = [];
671
+ let collabUsers = [];
672
+
673
+ // Clipping planes
674
+ let clipPlanes = {
675
+ x: new THREE.Plane(new THREE.Vector3(-1, 0, 0), 100),
676
+ y: new THREE.Plane(new THREE.Vector3(0, -1, 0), 100),
677
+ z: new THREE.Plane(new THREE.Vector3(0, 0, -1), 100),
678
+ };
679
+ let clipFlip = { x: false, y: false, z: false };
680
+
681
+ // Measurement
682
+ let measState = 0;
683
+ let measPoints = [];
684
+ let measMarkers = [];
685
+ let measLines = [];
686
+
687
+ // VR state
688
+ let vrSession = null;
689
+ let vrControllers = [];
690
+ let vrNavMode = 'teleport';
691
+ let moveSpeed = 2;
692
+ let teleportMarker = null;
693
+ let vrRaycaster = new THREE.Raycaster();
694
+ let tempMatrix = new THREE.Matrix4();
695
+ let controllerGrips = [];
696
+ let controllerModelFactory = new XRControllerModelFactory();
697
+
698
+ // Navigation
699
+ let navMode = 'orbit';
700
+ let walkVelocity = new THREE.Vector3();
701
+ let keys = {};
702
+
703
+ // Scale
704
+ let worldScale = 1;
705
+
706
+ // Performance
707
+ let clock = new THREE.Clock();
708
+ let frameCount = 0;
709
+ let fpsTime = 0;
710
+
711
+ // User tier
712
+ let userTier = 'pro'; // free | pro | enterprise
713
+
714
+ // ── INIT ────────────────────────────────────────────────
715
+ function init() {
716
+ const vp = document.getElementById('viewport');
717
+ const wrap = document.getElementById('canvas-wrap');
718
+
719
+ // Scene
720
+ scene = new THREE.Scene();
721
+ scene.background = new THREE.Color(0x0a0e1a);
722
+ scene.fog = new THREE.Fog(0x0a0e1a, 500, 2000);
723
+
724
+ // Camera
725
+ camera = new THREE.PerspectiveCamera(60, vp.clientWidth / vp.clientHeight, 0.01, 20000);
726
+ camera.position.set(15, 12, 15);
727
+
728
+ // Renderer with WebXR
729
+ renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true, alpha: true });
730
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
731
+ renderer.setSize(vp.clientWidth, vp.clientHeight);
732
+ renderer.shadowMap.enabled = true;
733
+ renderer.shadowMap.type = THREE.PCFSoftShadowMap;
734
+ renderer.localClippingEnabled = true;
735
+ renderer.xr.enabled = true;
736
+ wrap.appendChild(renderer.domElement);
737
+
738
+ // Clipping planes
739
+ renderer.clippingPlanes = [clipPlanes.x, clipPlanes.y, clipPlanes.z];
740
+
741
+ // Controls (desktop)
742
+ controls = new OrbitControls(camera, renderer.domElement);
743
+ controls.enableDamping = true;
744
+ controls.dampingFactor = 0.08;
745
+ controls.minDistance = 0.01;
746
+ controls.maxDistance = 8000;
747
+ controls.screenSpacePanning = true;
748
+
749
+ // Lighting
750
+ ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
751
+ scene.add(ambientLight);
752
+
753
+ dirLight = new THREE.DirectionalLight(0xfff5e0, 1.0);
754
+ dirLight.position.set(60, 120, 60);
755
+ dirLight.castShadow = true;
756
+ dirLight.shadow.mapSize.set(2048, 2048);
757
+ dirLight.shadow.camera.near = 0.5;
758
+ dirLight.shadow.camera.far = 500;
759
+ dirLight.shadow.camera.left = dirLight.shadow.camera.bottom = -100;
760
+ dirLight.shadow.camera.right = dirLight.shadow.camera.top = 100;
761
+ scene.add(dirLight);
762
+
763
+ hemiLight = new THREE.HemisphereLight(0x8eb4d4, 0x1a1a30, 0.35);
764
+ scene.add(hemiLight);
765
+
766
+ // Grid
767
+ grid = new THREE.GridHelper(300, 300, 0x1e2840, 0x151b28);
768
+ scene.add(grid);
769
+ scene.add(new THREE.AxesHelper(5));
770
+
771
+ // ── WebXR VR Button ──
772
+ setupWebXR();
773
+
774
+ // ── Teleport marker ──
775
+ const teleGeo = new THREE.RingGeometry(0.15, 0.2, 32).rotateX(-Math.PI / 2);
776
+ const teleMat = new THREE.MeshBasicMaterial({ color: 0x00e6b4, side: THREE.DoubleSide });
777
+ teleportMarker = new THREE.Mesh(teleGeo, teleMat);
778
+ teleportMarker.visible = false;
779
+ scene.add(teleportMarker);
780
+
781
+ // Event listeners
782
+ window.addEventListener('resize', onResize);
783
+ window.addEventListener('keydown', e => keys[e.code] = true);
784
+ window.addEventListener('keyup', e => keys[e.code] = false);
785
+ renderer.domElement.addEventListener('click', onViewportClick);
786
+
787
+ // Drop zone
788
+ setupDropZone();
789
+
790
+ // Start render loop
791
+ renderer.setAnimationLoop(animate);
792
+
793
+ updateStatus('Ready — Drop files or enter VR');
794
+ }
795
+
796
+ // ── WebXR SETUP ─────────────────────────────────────────
797
+ function setupWebXR() {
798
+ if ('xr' in navigator) {
799
+ navigator.xr.isSessionSupported('immersive-vr').then(supported => {
800
+ const vrBtn = document.getElementById('btnEnterVR');
801
+ if (supported) {
802
+ vrBtn.disabled = false;
803
+ vrBtn.onclick = toggleVR;
804
+ document.getElementById('statusVR').textContent = 'VR: Ready';
805
+ document.getElementById('statusVR').style.color = '#00e6b4';
806
+ } else {
807
+ vrBtn.textContent = '🥽 No VR';
808
+ document.getElementById('statusVR').textContent = 'VR: Not available';
809
+ document.getElementById('statusVR').style.color = '#6b7a94';
810
+ }
811
+ });
812
+ } else {
813
+ document.getElementById('statusVR').textContent = 'VR: Not supported';
814
+ }
815
+
816
+ // VR Controllers
817
+ for (let i = 0; i < 2; i++) {
818
+ const controller = renderer.xr.getController(i);
819
+ controller.userData.index = i;
820
+ controller.addEventListener('selectstart', onSelectStart);
821
+ controller.addEventListener('selectend', onSelectEnd);
822
+ controller.addEventListener('squeezestart', onSqueezeStart);
823
+ controller.addEventListener('squeezeend', onSqueezeEnd);
824
+ scene.add(controller);
825
+ vrControllers.push(controller);
826
+
827
+ // Controller ray
828
+ const geo = new THREE.BufferGeometry().setFromPoints([
829
+ new THREE.Vector3(0, 0, 0),
830
+ new THREE.Vector3(0, 0, -5)
831
+ ]);
832
+ const line = new THREE.Line(geo, new THREE.LineBasicMaterial({
833
+ color: i === 0 ? 0x0a9eff : 0x00e6b4,
834
+ linewidth: 2
835
+ }));
836
+ line.name = 'ray';
837
+ line.scale.z = 5;
838
+ controller.add(line);
839
+
840
+ // Controller grip model
841
+ const grip = renderer.xr.getControllerGrip(i);
842
+ grip.add(controllerModelFactory.createControllerModel(grip));
843
+ scene.add(grip);
844
+ controllerGrips.push(grip);
845
+ }
846
+ }
847
+
848
+ async function toggleVR() {
849
+ const vrBtn = document.getElementById('btnEnterVR');
850
+ if (vrSession) {
851
+ await vrSession.end();
852
+ return;
853
+ }
854
+ try {
855
+ const sessionInit = {
856
+ optionalFeatures: ['local-floor', 'bounded-floor', 'hand-tracking', 'layers']
857
+ };
858
+ const session = await navigator.xr.requestSession('immersive-vr', sessionInit);
859
+ renderer.xr.setSession(session);
860
+ vrSession = session;
861
+ vrBtn.textContent = '🥽 Exit VR';
862
+ document.getElementById('vrHud').style.display = 'flex';
863
+ document.getElementById('statusDot').classList.add('vr-active');
864
+ updateStatus('VR Session Active');
865
+
866
+ session.addEventListener('end', () => {
867
+ vrSession = null;
868
+ vrBtn.textContent = '🥽 Enter VR';
869
+ document.getElementById('vrHud').style.display = 'none';
870
+ document.getElementById('statusDot').classList.remove('vr-active');
871
+ updateStatus('VR Session Ended');
872
+ });
873
+ } catch (e) {
874
+ console.error('VR session failed:', e);
875
+ updateStatus('VR Error: ' + e.message);
876
+ }
877
+ }
878
+
879
+ // ── VR CONTROLLER EVENTS ────────────────────────────────
880
+ function onSelectStart(event) {
881
+ const controller = event.target;
882
+ if (vrNavMode === 'teleport') {
883
+ controller.userData.selecting = true;
884
+ } else if (measState > 0) {
885
+ vrMeasure(controller);
886
+ }
887
+ }
888
+
889
+ function onSelectEnd(event) {
890
+ const controller = event.target;
891
+ if (vrNavMode === 'teleport' && controller.userData.selecting) {
892
+ controller.userData.selecting = false;
893
+ doTeleport(controller);
894
+ }
895
+ }
896
+
897
+ function onSqueezeStart(event) {
898
+ const controller = event.target;
899
+ if (vrNavMode === 'grab') {
900
+ controller.userData.grabbing = true;
901
+ controller.userData.grabPos = controller.position.clone();
902
+ }
903
+ }
904
+
905
+ function onSqueezeEnd(event) {
906
+ const controller = event.target;
907
+ if (vrNavMode === 'grab') {
908
+ controller.userData.grabbing = false;
909
+ }
910
+ }
911
+
912
+ function doTeleport(controller) {
913
+ if (!teleportMarker.visible) return;
914
+ const baseRef = renderer.xr.getReferenceSpace();
915
+ const offsetRef = new XRRigidTransform(
916
+ { x: -teleportMarker.position.x, y: -teleportMarker.position.y, z: -teleportMarker.position.z, w: 1 }
917
+ );
918
+ // Move user group to teleport position
919
+ const userGroup = renderer.xr.getCamera().parent;
920
+ if (userGroup) {
921
+ userGroup.position.copy(teleportMarker.position);
922
+ }
923
+ teleportMarker.visible = false;
924
+ }
925
+
926
+ function vrMeasure(controller) {
927
+ tempMatrix.identity().extractRotation(controller.matrixWorld);
928
+ vrRaycaster.ray.origin.setFromMatrixPosition(controller.matrixWorld);
929
+ vrRaycaster.ray.direction.set(0, 0, -1).applyMatrix4(tempMatrix);
930
+
931
+ const meshes = [];
932
+ scene.traverse(obj => { if (obj.isMesh) meshes.push(obj); });
933
+ const hits = vrRaycaster.intersectObjects(meshes, false);
934
+
935
+ if (hits.length > 0) {
936
+ const pt = hits[0].point;
937
+ addMeasurementPoint(pt);
938
+ }
939
+ }
940
+
941
+ // ── MEASUREMENT SYSTEM ──────────────────────────────────
942
+ function addMeasurementPoint(point) {
943
+ const sphere = new THREE.Mesh(
944
+ new THREE.SphereGeometry(0.08, 12, 12),
945
+ new THREE.MeshBasicMaterial({ color: 0xffc44d, depthTest: false })
946
+ );
947
+ sphere.position.copy(point);
948
+ sphere.renderOrder = 999;
949
+ scene.add(sphere);
950
+ measMarkers.push(sphere);
951
+ measPoints.push(point.clone());
952
+
953
+ if (measPoints.length % 2 === 0) {
954
+ const a = measPoints[measPoints.length - 2];
955
+ const b = measPoints[measPoints.length - 1];
956
+ const lineGeo = new THREE.BufferGeometry().setFromPoints([a, b]);
957
+ const lineMat = new THREE.LineBasicMaterial({ color: 0xffc44d, depthTest: false, linewidth: 2 });
958
+ const line = new THREE.Line(lineGeo, lineMat);
959
+ line.renderOrder = 998;
960
+ scene.add(line);
961
+ measLines.push(line);
962
+
963
+ const dist = a.distanceTo(b);
964
+ const dx = Math.abs(b.x - a.x);
965
+ const dy = Math.abs(b.y - a.y);
966
+ const dz = Math.abs(b.z - a.z);
967
+
968
+ document.getElementById('measDist').textContent = dist.toFixed(3) + 'm';
969
+ document.getElementById('measDX').textContent = dx.toFixed(3) + 'm';
970
+ document.getElementById('measDY').textContent = dy.toFixed(3) + 'm';
971
+ document.getElementById('measDZ').textContent = dz.toFixed(3) + 'm';
972
+ document.getElementById('measInfo').style.display = 'block';
973
+
974
+ // 3D distance label
975
+ const midPoint = new THREE.Vector3().lerpVectors(a, b, 0.5);
976
+ const sprite = makeTextSprite(dist.toFixed(2) + 'm', midPoint);
977
+ scene.add(sprite);
978
+ measMarkers.push(sprite);
979
+
980
+ if (!document.getElementById('chkPersist').checked) {
981
+ measState = 0;
982
+ document.getElementById('btnMeasure').textContent = 'Start Measuring';
983
+ document.getElementById('btnMeasure').classList.remove('btn-red');
984
+ document.getElementById('btnMeasure').classList.add('btn-acc');
985
+ }
986
+ }
987
+ }
988
+
989
+ function makeTextSprite(text, position) {
990
+ const canvas = document.createElement('canvas');
991
+ const ctx = canvas.getContext('2d');
992
+ canvas.width = 256;
993
+ canvas.height = 64;
994
+ ctx.fillStyle = 'rgba(10,14,26,0.85)';
995
+ ctx.roundRect(0, 0, 256, 64, 8);
996
+ ctx.fill();
997
+ ctx.fillStyle = '#ffc44d';
998
+ ctx.font = 'bold 28px Inter, sans-serif';
999
+ ctx.textAlign = 'center';
1000
+ ctx.textBaseline = 'middle';
1001
+ ctx.fillText(text, 128, 32);
1002
+
1003
+ const tex = new THREE.CanvasTexture(canvas);
1004
+ const mat = new THREE.SpriteMaterial({ map: tex, depthTest: false });
1005
+ const sprite = new THREE.Sprite(mat);
1006
+ sprite.position.copy(position);
1007
+ sprite.position.y += 0.3;
1008
+ sprite.scale.set(1.5, 0.375, 1);
1009
+ sprite.renderOrder = 1000;
1010
+ return sprite;
1011
+ }
1012
+
1013
+ // ── FILE LOADING ────────────────────────────────────────
1014
+ const gltfLoader = new GLTFLoader();
1015
+ const fbxLoader = new FBXLoader();
1016
+ const objLoader = new OBJLoader();
1017
+ const plyLoader = new PLYLoader();
1018
+ const stlLoader = new STLLoader();
1019
+
1020
+ function handleFiles(files) {
1021
+ for (const file of files) {
1022
+ loadFile(file);
1023
+ }
1024
+ }
1025
+
1026
+ function loadFile(file) {
1027
+ const name = file.name;
1028
+ const ext = name.split('.').pop().toLowerCase();
1029
+ const url = URL.createObjectURL(file);
1030
+
1031
+ updateStatus('Loading: ' + name + '...');
1032
+
1033
+ const onLoad = (object, isPoints = false) => {
1034
+ // Normalize
1035
+ const box = new THREE.Box3().setFromObject(object);
1036
+ const size = box.getSize(new THREE.Vector3());
1037
+ const center = box.getCenter(new THREE.Vector3());
1038
+ const maxDim = Math.max(size.x, size.y, size.z);
1039
+
1040
+ if (loadedModels.length === 0) {
1041
+ // Auto-fit first model
1042
+ const dist = maxDim * 1.5;
1043
+ camera.position.set(center.x + dist * 0.7, center.y + dist * 0.5, center.z + dist * 0.7);
1044
+ controls.target.copy(center);
1045
+ controls.update();
1046
+ }
1047
+
1048
+ // Enable shadows
1049
+ object.traverse(child => {
1050
+ if (child.isMesh) {
1051
+ child.castShadow = true;
1052
+ child.receiveShadow = true;
1053
+ if (child.material) {
1054
+ if (Array.isArray(child.material)) {
1055
+ child.material.forEach(m => { m.clippingPlanes = renderer.clippingPlanes; m.clipShadows = true; });
1056
+ } else {
1057
+ child.material.clippingPlanes = renderer.clippingPlanes;
1058
+ child.material.clipShadows = true;
1059
+ }
1060
+ }
1061
+ }
1062
+ });
1063
+
1064
+ scene.add(object);
1065
+
1066
+ const model = {
1067
+ id: Date.now() + Math.random(),
1068
+ name: name,
1069
+ format: ext.toUpperCase(),
1070
+ object: object,
1071
+ visible: true,
1072
+ isPoints: isPoints,
1073
+ };
1074
+ loadedModels.push(model);
1075
+ updateModelTree();
1076
+ updateSceneInfo();
1077
+ updateStatus('Loaded: ' + name);
1078
+ };
1079
+
1080
+ try {
1081
+ switch (ext) {
1082
+ case 'gltf': case 'glb':
1083
+ gltfLoader.load(url, gltf => onLoad(gltf.scene), undefined, e => loadError(name, e));
1084
+ break;
1085
+ case 'fbx':
1086
+ fbxLoader.load(url, obj => onLoad(obj), undefined, e => loadError(name, e));
1087
+ break;
1088
+ case 'obj':
1089
+ objLoader.load(url, obj => onLoad(obj), undefined, e => loadError(name, e));
1090
+ break;
1091
+ case 'ply':
1092
+ plyLoader.load(url, geo => {
1093
+ geo.computeVertexNormals();
1094
+ let obj;
1095
+ if (geo.attributes.color) {
1096
+ const mat = new THREE.PointsMaterial({ size: 0.02, vertexColors: true });
1097
+ obj = new THREE.Points(geo, mat);
1098
+ onLoad(obj, true);
1099
+ } else {
1100
+ const mat = new THREE.MeshStandardMaterial({ color: 0x8eb4d4, side: THREE.DoubleSide });
1101
+ obj = new THREE.Mesh(geo, mat);
1102
+ onLoad(obj);
1103
+ }
1104
+ }, undefined, e => loadError(name, e));
1105
+ break;
1106
+ case 'stl':
1107
+ stlLoader.load(url, geo => {
1108
+ geo.computeVertexNormals();
1109
+ const mat = new THREE.MeshStandardMaterial({ color: 0x8eb4d4, side: THREE.DoubleSide });
1110
+ const mesh = new THREE.Mesh(geo, mat);
1111
+ onLoad(mesh);
1112
+ }, undefined, e => loadError(name, e));
1113
+ break;
1114
+ default:
1115
+ updateStatus('Unsupported format: ' + ext);
1116
+ }
1117
+ } catch (e) {
1118
+ loadError(name, e);
1119
+ }
1120
+ }
1121
+
1122
+ function loadError(name, err) {
1123
+ console.error('Load error:', name, err);
1124
+ updateStatus('Error loading: ' + name);
1125
+ }
1126
+
1127
+ // ── CLIPPING ────────────────────────────────────────────
1128
+ window.updateClip = function(axis, val) {
1129
+ const v = parseFloat(val);
1130
+ clipPlanes[axis].constant = clipFlip[axis] ? -v : v;
1131
+ document.getElementById('clip' + axis.toUpperCase() + 'Val').textContent = v.toFixed(1);
1132
+ };
1133
+
1134
+ window.flipClip = function(axis) {
1135
+ clipFlip[axis] = !clipFlip[axis];
1136
+ clipPlanes[axis].normal.negate();
1137
+ };
1138
+
1139
+ window.resetClipping = function() {
1140
+ ['x', 'y', 'z'].forEach(a => {
1141
+ clipPlanes[a].constant = 100;
1142
+ clipFlip[a] = false;
1143
+ clipPlanes[a].normal.set(a === 'x' ? -1 : 0, a === 'y' ? -1 : 0, a === 'z' ? -1 : 0);
1144
+ document.getElementById('clip' + a.toUpperCase()).value = 100;
1145
+ document.getElementById('clip' + a.toUpperCase() + 'Val').textContent = '100';
1146
+ });
1147
+ };
1148
+
1149
+ // ── NAVIGATION ──────────────────────────────────────────
1150
+ window.setNavMode = function(mode) {
1151
+ navMode = mode;
1152
+ ['orbit', 'walk', 'fly'].forEach(m => {
1153
+ const el = document.getElementById('nav' + m.charAt(0).toUpperCase() + m.slice(1));
1154
+ if (el) el.classList.toggle('active', m === mode);
1155
+ });
1156
+ controls.enabled = (mode === 'orbit');
1157
+ };
1158
+
1159
+ window.setVRNav = function(mode) {
1160
+ vrNavMode = mode;
1161
+ ['teleport', 'smooth', 'grab'].forEach(m => {
1162
+ const el = document.getElementById('vr' + m.charAt(0).toUpperCase() + m.slice(1));
1163
+ if (el) el.classList.toggle('active', m === mode);
1164
+ });
1165
+ };
1166
+
1167
+ window.setScale = function(s) {
1168
+ worldScale = s;
1169
+ scene.scale.setScalar(1 / s);
1170
+ updateStatus('Scale set to 1:' + s);
1171
+ };
1172
+
1173
+ // ── ENVIRONMENT ─────────────────────────────────────────
1174
+ window.setEnvironment = function(preset) {
1175
+ const presets = {
1176
+ dark: { bg: 0x0a0e1a, fog: 0x0a0e1a, amb: 0.5, dir: 1.0, dirColor: 0xfff5e0 },
1177
+ daylight: { bg: 0xd4e5f7, fog: 0xd4e5f7, amb: 0.8, dir: 1.5, dirColor: 0xfffaf0 },
1178
+ sunset: { bg: 0x1a0d2e, fog: 0x1a0d2e, amb: 0.35, dir: 0.8, dirColor: 0xff8844 },
1179
+ overcast: { bg: 0x3a4555, fog: 0x3a4555, amb: 0.7, dir: 0.6, dirColor: 0xddddee },
1180
+ night: { bg: 0x050510, fog: 0x050510, amb: 0.15, dir: 0.2, dirColor: 0x6688bb },
1181
+ xray: { bg: 0x0a0e1a, fog: 0x0a0e1a, amb: 0.9, dir: 0.3, dirColor: 0xffffff },
1182
+ };
1183
+ const p = presets[preset] || presets.dark;
1184
+ scene.background.setHex(p.bg);
1185
+ scene.fog.color.setHex(p.fog);
1186
+ ambientLight.intensity = p.amb;
1187
+ dirLight.intensity = p.dir;
1188
+ dirLight.color.setHex(p.dirColor);
1189
+ };
1190
+
1191
+ window.toggleGrid = function() { grid.visible = document.getElementById('chkGrid').checked; };
1192
+ window.toggleShadows = function() { renderer.shadowMap.enabled = document.getElementById('chkShadows').checked; };
1193
+ window.toggleWireframe = function() {
1194
+ const wf = document.getElementById('chkWireframe').checked;
1195
+ scene.traverse(obj => {
1196
+ if (obj.isMesh && obj.material) {
1197
+ const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
1198
+ mats.forEach(m => m.wireframe = wf);
1199
+ }
1200
+ });
1201
+ };
1202
+ window.togglePassthrough = function() {
1203
+ const pt = document.getElementById('chkPassthrough').checked;
1204
+ if (pt) {
1205
+ scene.background = null;
1206
+ renderer.setClearColor(0x000000, 0);
1207
+ } else {
1208
+ scene.background = new THREE.Color(0x0a0e1a);
1209
+ }
1210
+ };
1211
+
1212
+ // ── MEASUREMENT TOGGLE ──────────────────────────────────
1213
+ window.toggleMeasure = function() {
1214
+ measState = measState === 0 ? 1 : 0;
1215
+ const btn = document.getElementById('btnMeasure');
1216
+ if (measState) {
1217
+ btn.textContent = '⏹ Stop Measuring';
1218
+ btn.classList.remove('btn-acc');
1219
+ btn.classList.add('btn-red');
1220
+ updateStatus('Click two points to measure');
1221
+ } else {
1222
+ btn.textContent = 'Start Measuring';
1223
+ btn.classList.remove('btn-red');
1224
+ btn.classList.add('btn-acc');
1225
+ }
1226
+ };
1227
+
1228
+ window.clearMeasurements = function() {
1229
+ measMarkers.forEach(m => { scene.remove(m); if (m.material) m.material.dispose(); if (m.geometry) m.geometry.dispose(); });
1230
+ measLines.forEach(l => { scene.remove(l); l.material.dispose(); l.geometry.dispose(); });
1231
+ measMarkers = [];
1232
+ measLines = [];
1233
+ measPoints = [];
1234
+ document.getElementById('measInfo').style.display = 'none';
1235
+ };
1236
+
1237
+ // ── VIEWPORT CLICK ──────────────────────────────────────
1238
+ function onViewportClick(event) {
1239
+ if (measState === 0) return;
1240
+ const rect = renderer.domElement.getBoundingClientRect();
1241
+ const mouse = new THREE.Vector2(
1242
+ ((event.clientX - rect.left) / rect.width) * 2 - 1,
1243
+ -((event.clientY - rect.top) / rect.height) * 2 + 1
1244
+ );
1245
+ const raycaster = new THREE.Raycaster();
1246
+ raycaster.setFromCamera(mouse, camera);
1247
+
1248
+ const meshes = [];
1249
+ scene.traverse(obj => { if (obj.isMesh || obj.isPoints) meshes.push(obj); });
1250
+ const hits = raycaster.intersectObjects(meshes, false);
1251
+
1252
+ if (hits.length > 0) {
1253
+ addMeasurementPoint(hits[0].point);
1254
+ }
1255
+ }
1256
+
1257
+ // ── ANNOTATIONS ─────────────────────────────────────────
1258
+ window.addAnnotation = function(type) {
1259
+ updateStatus('Click to place ' + type + ' annotation');
1260
+ const handler = (event) => {
1261
+ const rect = renderer.domElement.getBoundingClientRect();
1262
+ const mouse = new THREE.Vector2(
1263
+ ((event.clientX - rect.left) / rect.width) * 2 - 1,
1264
+ -((event.clientY - rect.top) / rect.height) * 2 + 1
1265
+ );
1266
+ const raycaster = new THREE.Raycaster();
1267
+ raycaster.setFromCamera(mouse, camera);
1268
+ const meshes = [];
1269
+ scene.traverse(obj => { if (obj.isMesh) meshes.push(obj); });
1270
+ const hits = raycaster.intersectObjects(meshes, false);
1271
+
1272
+ if (hits.length > 0) {
1273
+ const pt = hits[0].point;
1274
+ const priority = document.getElementById('annPriority').value;
1275
+ const colors = { info: 0x0a9eff, warning: 0xffc44d, critical: 0xff4466 };
1276
+
1277
+ // Pin marker
1278
+ const pinGeo = new THREE.SphereGeometry(0.12, 16, 16);
1279
+ const pinMat = new THREE.MeshBasicMaterial({ color: colors[priority], depthTest: false });
1280
+ const pin = new THREE.Mesh(pinGeo, pinMat);
1281
+ pin.position.copy(pt);
1282
+ pin.renderOrder = 999;
1283
+ scene.add(pin);
1284
+
1285
+ // Label sprite
1286
+ const label = makeAnnotationSprite(type === 'text' ? 'Note' : '#' + (annotations.length + 1), priority);
1287
+ label.position.copy(pt);
1288
+ label.position.y += 0.4;
1289
+ scene.add(label);
1290
+
1291
+ annotations.push({ type, point: pt.clone(), priority, pin, label });
1292
+ updateAnnotationList();
1293
+ updateStatus('Annotation placed');
1294
+ }
1295
+ renderer.domElement.removeEventListener('click', handler);
1296
+ };
1297
+ renderer.domElement.addEventListener('click', handler, { once: true });
1298
+ };
1299
+
1300
+ function makeAnnotationSprite(text, priority) {
1301
+ const canvas = document.createElement('canvas');
1302
+ const ctx = canvas.getContext('2d');
1303
+ canvas.width = 256;
1304
+ canvas.height = 80;
1305
+ const colors = { info: '#0a9eff', warning: '#ffc44d', critical: '#ff4466' };
1306
+ ctx.fillStyle = 'rgba(10,14,26,0.9)';
1307
+ ctx.roundRect(0, 0, 256, 80, 10);
1308
+ ctx.fill();
1309
+ ctx.strokeStyle = colors[priority];
1310
+ ctx.lineWidth = 3;
1311
+ ctx.roundRect(0, 0, 256, 80, 10);
1312
+ ctx.stroke();
1313
+ ctx.fillStyle = colors[priority];
1314
+ ctx.font = 'bold 24px Inter, sans-serif';
1315
+ ctx.textAlign = 'center';
1316
+ ctx.textBaseline = 'middle';
1317
+ ctx.fillText(text, 128, 40);
1318
+
1319
+ const tex = new THREE.CanvasTexture(canvas);
1320
+ const mat = new THREE.SpriteMaterial({ map: tex, depthTest: false });
1321
+ const sprite = new THREE.Sprite(mat);
1322
+ sprite.scale.set(1.2, 0.375, 1);
1323
+ sprite.renderOrder = 1000;
1324
+ return sprite;
1325
+ }
1326
+
1327
+ function updateAnnotationList() {
1328
+ const el = document.getElementById('annList');
1329
+ if (!el) return;
1330
+ el.innerHTML = annotations.map((a, i) =>
1331
+ `<div class="info-card mb6" style="border-left:3px solid ${
1332
+ a.priority === 'info' ? 'var(--acc)' : a.priority === 'warning' ? 'var(--yel)' : 'var(--red)'
1333
+ }">
1334
+ <div class="ir"><span class="ik">#${i + 1} ${a.type}</span><span class="iv">${a.priority}</span></div>
1335
+ </div>`
1336
+ ).join('');
1337
+ }
1338
+
1339
+ // ── BCF EXPORT ──────────────────────────────────────────
1340
+ window.exportBCF = function() {
1341
+ updateStatus('BCF export — requires JSZip (available in full build)');
1342
+ };
1343
+ window.importBCF = function() {
1344
+ updateStatus('BCF import — select .bcf file');
1345
+ };
1346
+
1347
+ // ── VIEWPOINTS ──────────────────────────────────────────
1348
+ window.saveViewpoint = function() {
1349
+ const vp = {
1350
+ id: Date.now(),
1351
+ name: 'View ' + (savedViewpoints.length + 1),
1352
+ position: camera.position.clone(),
1353
+ target: controls.target.clone(),
1354
+ clip: {
1355
+ x: clipPlanes.x.constant,
1356
+ y: clipPlanes.y.constant,
1357
+ z: clipPlanes.z.constant,
1358
+ },
1359
+ timestamp: new Date().toLocaleTimeString(),
1360
+ };
1361
+ savedViewpoints.push(vp);
1362
+ updateViewpointList();
1363
+ updateStatus('Viewpoint saved');
1364
+ };
1365
+
1366
+ function restoreViewpoint(vp) {
1367
+ camera.position.copy(vp.position);
1368
+ controls.target.copy(vp.target);
1369
+ controls.update();
1370
+ if (vp.clip) {
1371
+ clipPlanes.x.constant = vp.clip.x;
1372
+ clipPlanes.y.constant = vp.clip.y;
1373
+ clipPlanes.z.constant = vp.clip.z;
1374
+ document.getElementById('clipX').value = vp.clip.x;
1375
+ document.getElementById('clipY').value = vp.clip.y;
1376
+ document.getElementById('clipZ').value = vp.clip.z;
1377
+ }
1378
+ }
1379
+
1380
+ function updateViewpointList() {
1381
+ const el = document.getElementById('vpList');
1382
+ el.innerHTML = savedViewpoints.map((vp, i) =>
1383
+ `<div class="vp-item" onclick="restoreVP(${i})">
1384
+ <span class="vp-name">${vp.name}</span>
1385
+ <span style="font-size:9px;color:var(--muted)">${vp.timestamp}</span>
1386
+ </div>`
1387
+ ).join('');
1388
+ }
1389
+
1390
+ window.restoreVP = function(i) { restoreViewpoint(savedViewpoints[i]); };
1391
+ window.exportViewpoints = function() { updateStatus('Exporting viewpoints...'); };
1392
+
1393
+ // ── AI ASSISTANT ────────────────────────────────────────
1394
+ window.sendAI = function() {
1395
+ const input = document.getElementById('aiInput');
1396
+ const text = input.value.trim();
1397
+ if (!text) return;
1398
+ input.value = '';
1399
+
1400
+ const chat = document.getElementById('aiChat');
1401
+ chat.innerHTML += `<div class="ai-msg user">${text}</div>`;
1402
+
1403
+ // Simulated AI responses based on keywords
1404
+ let response = "I'll help you with that. Let me analyze the model...";
1405
+ const lower = text.toLowerCase();
1406
+ if (lower.includes('find') || lower.includes('where')) {
1407
+ response = "I found the element you're looking for. Navigating there now... In the full version, I'll highlight the component and move your camera to it.";
1408
+ } else if (lower.includes('measure') || lower.includes('distance')) {
1409
+ response = "I've activated the measurement tool. Point at two surfaces to measure the distance between them.";
1410
+ } else if (lower.includes('section') || lower.includes('cut')) {
1411
+ response = "I'll create a section plane through that area. You can adjust it with the Section Planes panel or by grabbing the plane handle in VR.";
1412
+ } else if (lower.includes('clash') || lower.includes('conflict') || lower.includes('issue')) {
1413
+ response = "Running clash detection... I found 3 potential clashes between MEP and structural elements. Shall I create BCF issues for each?";
1414
+ } else if (lower.includes('navigate') || lower.includes('go to') || lower.includes('take me')) {
1415
+ response = "Navigating to that location now. In VR, you'll be teleported directly there.";
1416
+ }
1417
+
1418
+ setTimeout(() => {
1419
+ chat.innerHTML += `<div class="ai-msg assistant">${response}</div>`;
1420
+ chat.scrollTop = chat.scrollHeight;
1421
+ }, 600);
1422
+ };
1423
+
1424
+ // ── MODEL TREE ──────────────────────────────────────────
1425
+ function updateModelTree() {
1426
+ const el = document.getElementById('modelTree');
1427
+ el.innerHTML = loadedModels.map((m, i) =>
1428
+ `<div class="tree-item">
1429
+ <div class="tree-row${i === 0 ? ' selected' : ''}">
1430
+ <button class="tree-vis" onclick="toggleModelVis(${i})">${m.visible ? '👁️' : '👁️‍🗨️'}</button>
1431
+ <span class="tree-name">${m.name}</span>
1432
+ <span style="font-size:9px;color:var(--muted)">${m.format}</span>
1433
+ </div>
1434
+ </div>`
1435
+ ).join('');
1436
+ }
1437
+
1438
+ window.toggleModelVis = function(i) {
1439
+ loadedModels[i].visible = !loadedModels[i].visible;
1440
+ loadedModels[i].object.visible = loadedModels[i].visible;
1441
+ updateModelTree();
1442
+ };
1443
+
1444
+ function updateSceneInfo() {
1445
+ let tris = 0, pts = 0;
1446
+ scene.traverse(obj => {
1447
+ if (obj.isMesh && obj.geometry) {
1448
+ const idx = obj.geometry.index;
1449
+ tris += idx ? idx.count / 3 : (obj.geometry.attributes.position?.count || 0) / 3;
1450
+ }
1451
+ if (obj.isPoints && obj.geometry) {
1452
+ pts += obj.geometry.attributes.position?.count || 0;
1453
+ }
1454
+ });
1455
+ document.getElementById('infoModels').textContent = loadedModels.length;
1456
+ document.getElementById('infoTris').textContent = formatNum(tris);
1457
+ document.getElementById('infoPoints').textContent = formatNum(pts);
1458
+ document.getElementById('statusTris').textContent = formatNum(tris) + ' tris';
1459
+ document.getElementById('sceneInfo').style.display = 'block';
1460
+ }
1461
+
1462
+ function formatNum(n) {
1463
+ if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
1464
+ if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
1465
+ return Math.floor(n).toString();
1466
+ }
1467
+
1468
+ // ── DROP ZONE ───────────────────────────────────────────
1469
+ function setupDropZone() {
1470
+ const dz = document.getElementById('dropZone');
1471
+ const vp = document.getElementById('viewport');
1472
+
1473
+ ['dragenter', 'dragover'].forEach(ev => {
1474
+ vp.addEventListener(ev, e => { e.preventDefault(); dz.classList.add('over'); });
1475
+ dz.addEventListener(ev, e => { e.preventDefault(); dz.classList.add('over'); });
1476
+ });
1477
+ ['dragleave', 'drop'].forEach(ev => {
1478
+ vp.addEventListener(ev, () => dz.classList.remove('over'));
1479
+ dz.addEventListener(ev, () => dz.classList.remove('over'));
1480
+ });
1481
+ vp.addEventListener('drop', e => { e.preventDefault(); handleFiles(e.dataTransfer.files); });
1482
+ dz.addEventListener('drop', e => { e.preventDefault(); handleFiles(e.dataTransfer.files); });
1483
+ }
1484
+
1485
+ // ── ANIMATION LOOP ──────────────────────────────────────
1486
+ function animate() {
1487
+ const delta = clock.getDelta();
1488
+
1489
+ // FPS counter
1490
+ frameCount++;
1491
+ fpsTime += delta;
1492
+ if (fpsTime >= 0.5) {
1493
+ document.getElementById('statusFPS').textContent = Math.round(frameCount / fpsTime) + ' FPS';
1494
+ frameCount = 0;
1495
+ fpsTime = 0;
1496
+ }
1497
+
1498
+ // Desktop walk/fly navigation
1499
+ if (navMode === 'walk' || navMode === 'fly') {
1500
+ const speed = moveSpeed * delta;
1501
+ const dir = new THREE.Vector3();
1502
+ camera.getWorldDirection(dir);
1503
+ if (navMode === 'walk') dir.y = 0;
1504
+ dir.normalize();
1505
+ const right = new THREE.Vector3().crossVectors(dir, camera.up).normalize();
1506
+
1507
+ if (keys['KeyW']) camera.position.addScaledVector(dir, speed);
1508
+ if (keys['KeyS']) camera.position.addScaledVector(dir, -speed);
1509
+ if (keys['KeyA']) camera.position.addScaledVector(right, -speed);
1510
+ if (keys['KeyD']) camera.position.addScaledVector(right, speed);
1511
+ if (keys['Space']) camera.position.y += speed;
1512
+ if (keys['ShiftLeft']) camera.position.y -= speed;
1513
+ controls.target.copy(camera.position).add(dir);
1514
+ }
1515
+
1516
+ // VR teleport raycast
1517
+ if (vrSession && vrNavMode === 'teleport') {
1518
+ vrControllers.forEach(ctrl => {
1519
+ if (ctrl.userData.selecting) {
1520
+ tempMatrix.identity().extractRotation(ctrl.matrixWorld);
1521
+ vrRaycaster.ray.origin.setFromMatrixPosition(ctrl.matrixWorld);
1522
+ vrRaycaster.ray.direction.set(0, 0, -1).applyMatrix4(tempMatrix);
1523
+
1524
+ const hits = vrRaycaster.intersectObject(grid, false);
1525
+ if (hits.length > 0) {
1526
+ teleportMarker.position.copy(hits[0].point);
1527
+ teleportMarker.visible = true;
1528
+ }
1529
+ }
1530
+ });
1531
+ }
1532
+
1533
+ // VR smooth locomotion
1534
+ if (vrSession && vrNavMode === 'smooth') {
1535
+ const session = renderer.xr.getSession();
1536
+ if (session && session.inputSources) {
1537
+ for (const source of session.inputSources) {
1538
+ if (source.gamepad) {
1539
+ const axes = source.gamepad.axes;
1540
+ if (axes.length >= 4) {
1541
+ const cam = renderer.xr.getCamera();
1542
+ const dir = new THREE.Vector3();
1543
+ cam.getWorldDirection(dir);
1544
+ dir.y = 0;
1545
+ dir.normalize();
1546
+ const right = new THREE.Vector3().crossVectors(dir, new THREE.Vector3(0, 1, 0)).normalize();
1547
+
1548
+ const userGroup = cam.parent;
1549
+ if (userGroup) {
1550
+ userGroup.position.addScaledVector(right, axes[2] * moveSpeed * delta);
1551
+ userGroup.position.addScaledVector(dir, -axes[3] * moveSpeed * delta);
1552
+ }
1553
+ }
1554
+ }
1555
+ }
1556
+ }
1557
+ }
1558
+
1559
+ // Update controls
1560
+ if (!vrSession) {
1561
+ controls.update();
1562
+ }
1563
+
1564
+ // Render
1565
+ renderer.render(scene, camera);
1566
+ }
1567
+
1568
+ // ── UI HELPERS ──────────────────────────────────────────
1569
+ function onResize() {
1570
+ const vp = document.getElementById('viewport');
1571
+ camera.aspect = vp.clientWidth / vp.clientHeight;
1572
+ camera.updateProjectionMatrix();
1573
+ renderer.setSize(vp.clientWidth, vp.clientHeight);
1574
+ }
1575
+
1576
+ function updateStatus(text) {
1577
+ document.getElementById('statusText').textContent = text;
1578
+ }
1579
+
1580
+ // Expose to global scope for onclick handlers
1581
+ window.toggleSec = function(el) {
1582
+ const body = el.nextElementSibling;
1583
+ body.classList.toggle('open');
1584
+ const chev = el.querySelector('.chev');
1585
+ if (chev) chev.style.transform = body.classList.contains('open') ? 'rotate(0deg)' : 'rotate(-90deg)';
1586
+ };
1587
+
1588
+ window.toggleSidebar = function() {
1589
+ document.getElementById('sidebar').classList.toggle('show');
1590
+ };
1591
+
1592
+ window.handleFiles = handleFiles;
1593
+ window.openCollabModal = function() { document.getElementById('collabModal').classList.add('show'); };
1594
+ window.closeCollabModal = function() { document.getElementById('collabModal').classList.remove('show'); };
1595
+ window.openAuthModal = function() { document.getElementById('authModal').classList.add('show'); };
1596
+ window.closeAuthModal = function() { document.getElementById('authModal').classList.remove('show'); };
1597
+ window.openUpgradeModal = function() { document.getElementById('upgradeModal').classList.add('show'); };
1598
+ window.closeUpgradeModal = function() { document.getElementById('upgradeModal').classList.remove('show'); };
1599
+
1600
+ window.startSession = function() {
1601
+ const id = Math.random().toString(36).substr(2, 8);
1602
+ document.getElementById('collabLink').value = window.location.origin + '/vr-viewer.html?session=' + id;
1603
+ updateStatus('Collaboration session created: ' + id);
1604
+ };
1605
+
1606
+ window.inviteUser = function() { openCollabModal(); };
1607
+ window.signIn = function() { closeAuthModal(); updateStatus('Signed in'); };
1608
+ window.signUp = function() { closeAuthModal(); updateStatus('Account created'); };
1609
+ window.subscribe = function(plan) {
1610
+ if (plan === 'enterprise') {
1611
+ updateStatus('Contact sales for Enterprise plan');
1612
+ } else {
1613
+ updateStatus('Starting Pro trial...');
1614
+ }
1615
+ closeUpgradeModal();
1616
+ };
1617
+
1618
+ window.openIntegrations = function() { updateStatus('Integration settings — Enterprise feature'); };
1619
+ window.saveProject = function() { updateStatus('Project saved'); };
1620
+ window.loadProject = function() { updateStatus('Select .scanbim project file'); };
1621
+ window.exportScreenshot = function() {
1622
+ const dataUrl = renderer.domElement.toDataURL('image/png');
1623
+ const a = document.createElement('a');
1624
+ a.href = dataUrl;
1625
+ a.download = 'scanbim-capture.png';
1626
+ a.click();
1627
+ updateStatus('Screenshot saved');
1628
+ };
1629
+ window.shareProject = function() { updateStatus('Generating share link...'); };
1630
+ window.toggleClipVR = function() { updateStatus('VR section plane — grab to position'); };
1631
+ window.toggleVoiceAnnotation = function() { updateStatus('Voice annotation — speak after beep'); };
1632
+
1633
+ // ── INIT ────────────────────────────────────────────────
1634
+ init();
1635
+ </script>
1636
+ </body>
1637
+ </html>