@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.
- package/README.md +238 -0
- package/acc-mcp-index.js +427 -0
- package/aps-viewer.html +400 -0
- package/mcp-page-index.html +294 -0
- package/navisworks-mcp-index.js +525 -0
- package/package.json +31 -0
- package/pages-site/icon-192.png +0 -0
- package/pages-site/icon-48.png +0 -0
- package/pages-site/icon-512.png +0 -0
- package/pages-site/index.html +644 -0
- package/pages-site/manifest.json +49 -0
- package/pages-site/viewer.html +4244 -0
- package/pages-site/vr-viewer.html +1637 -0
- package/revit-mcp-index.js +709 -0
- package/scanbim-app-index.html +644 -0
- package/scanbim-mcp/README.md +33 -0
- package/scanbim-mcp/package.json +16 -0
- package/scanbim-mcp/src/index.js +694 -0
- package/scanbim-mcp/wrangler.toml +11 -0
- package/schema.sql +45 -0
- package/server.json +21 -0
- package/src/index-v1.0.2.js +396 -0
- package/src/index.js +723 -0
- package/twinmotion-mcp-index.js +516 -0
- package/upload-mcp-page.ps1 +52 -0
- package/wrangler.toml +19 -0
|
@@ -0,0 +1,4244 @@
|
|
|
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, maximum-scale=1.0">
|
|
6
|
+
<title>ScanBIM — QR-Linked 3D Viewer</title>
|
|
7
|
+
<link rel="manifest" href="/manifest.json">
|
|
8
|
+
<meta name="theme-color" content="#f97316">
|
|
9
|
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
10
|
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
11
|
+
<meta name="apple-mobile-web-app-title" content="ScanBIM">
|
|
12
|
+
<link rel="apple-touch-icon" href="/icon-192.png">
|
|
13
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
14
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
|
15
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
|
16
|
+
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
|
|
17
|
+
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/GLTFLoader.js"></script>
|
|
18
|
+
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/FBXLoader.js"></script>
|
|
19
|
+
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/OBJLoader.js"></script>
|
|
20
|
+
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/PLYLoader.js"></script>
|
|
21
|
+
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/STLLoader.js"></script>
|
|
22
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
|
|
23
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
|
|
24
|
+
<script src="https://www.gstatic.com/firebasejs/9.23.0/firebase-app-compat.js"></script>
|
|
25
|
+
<script src="https://www.gstatic.com/firebasejs/9.23.0/firebase-firestore-compat.js"></script>
|
|
26
|
+
<script src="https://www.gstatic.com/firebasejs/9.23.0/firebase-storage-compat.js"></script>
|
|
27
|
+
<script src="https://www.gstatic.com/firebasejs/9.23.0/firebase-auth-compat.js"></script>
|
|
28
|
+
<style>
|
|
29
|
+
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
|
|
30
|
+
:root{
|
|
31
|
+
--bg:#0d1117;--sb:#111827;--hdr:#0a1628;--card:#1c2333;--inp:#0d1117;
|
|
32
|
+
--bdr:#21262d;--acc:#f97316;--ach:#ea6c09;--blue:#3b82f6;
|
|
33
|
+
--grn:#22c55e;--red:#ef4444;--yel:#fbbf24;--pur:#a78bfa;
|
|
34
|
+
--txt:#f0f6fc;--muted:#8b949e;
|
|
35
|
+
}
|
|
36
|
+
html,body{height:100%;overflow:hidden;font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--txt);font-size:13px;}
|
|
37
|
+
.app{display:flex;flex-direction:column;height:100vh;}
|
|
38
|
+
.body{display:flex;flex:1;overflow:hidden;}
|
|
39
|
+
|
|
40
|
+
/* ── HEADER ── */
|
|
41
|
+
.header{height:50px;background:linear-gradient(135deg, var(--hdr), #0f1d32);border-bottom:1px solid rgba(249,115,22,.18);display:flex;align-items:center;padding:0 18px;gap:10px;flex-shrink:0;z-index:20;box-shadow:0 1px 12px rgba(249,115,22,.15);}
|
|
42
|
+
.logo-box{width:30px;height:30px;background:linear-gradient(135deg, var(--acc), #e05d10);border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:15px;box-shadow:0 0 12px rgba(249,115,22,.3);}
|
|
43
|
+
.logo-name{font-size:17px;font-weight:700;letter-spacing:-.4px;}
|
|
44
|
+
.logo-name span{color:var(--acc);}
|
|
45
|
+
.logo-tag{color:var(--muted);font-size:11px;}
|
|
46
|
+
.hs{flex:1;}
|
|
47
|
+
.pill{padding:3px 9px;border-radius:99px;font-size:10px;font-weight:600;letter-spacing:.4px;text-transform:uppercase;background:rgba(249,115,22,.12);border:1px solid rgba(249,115,22,.3);color:var(--acc);}
|
|
48
|
+
.hamburger{display:none;background:none;border:none;color:var(--txt);font-size:20px;cursor:pointer;padding:0 6px;margin-left:auto;}
|
|
49
|
+
|
|
50
|
+
/* ── SIDEBAR ── */
|
|
51
|
+
.sidebar{width:280px;flex-shrink:0;background:linear-gradient(180deg, var(--sb), #0c1220);border-right:1px solid rgba(255,255,255,.06);display:flex;flex-direction:column;overflow-y:auto;transition:transform .3s ease,visibility .3s ease;z-index:100;}
|
|
52
|
+
.sidebar.hidden{transform:translateX(-100%);visibility:hidden;}
|
|
53
|
+
.sidebar-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:99;}
|
|
54
|
+
.sidebar-overlay.show{display:block;}
|
|
55
|
+
.sec{border-bottom:1px solid var(--bdr);}
|
|
56
|
+
.sec-hdr{display:flex;align-items:center;justify-content:space-between;padding:10px 16px;cursor:pointer;user-select:none;transition:background .12s;}
|
|
57
|
+
.sec-hdr:hover{background:rgba(249,115,22,.04);}
|
|
58
|
+
.sec-title{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.9px;color:var(--muted);display:flex;align-items:center;gap:6px;}
|
|
59
|
+
.sec-title .icon{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;}
|
|
60
|
+
.chev{color:var(--muted);font-size:10px;transition:transform .2s;}
|
|
61
|
+
.sec-body{padding:0 16px 14px;display:none;}
|
|
62
|
+
.sec-body.open{display:block;}
|
|
63
|
+
|
|
64
|
+
/* ── BUTTONS ── */
|
|
65
|
+
.btn{display:flex;align-items:center;justify-content:center;gap:6px;padding:7px 12px;border-radius:6px;border:none;cursor:pointer;font:500 12px/1 'Inter',system-ui;width:100%;transition:background .13s,opacity .13s,box-shadow .2s,transform .15s;box-shadow:0 1px 3px rgba(0,0,0,.2);}
|
|
66
|
+
.btn:disabled{opacity:.4;cursor:not-allowed;}
|
|
67
|
+
.btn-acc{background:var(--acc);color:#fff;box-shadow:0 2px 8px rgba(249,115,22,.25);} .btn-acc:hover:not(:disabled){background:var(--ach);box-shadow:0 4px 16px rgba(249,115,22,.35);transform:translateY(-1px);}
|
|
68
|
+
.btn-sec{background:var(--card);color:var(--txt);border:1px solid var(--bdr);} .btn-sec:hover:not(:disabled){background:var(--bdr);}
|
|
69
|
+
.btn-blue{background:var(--blue);color:#fff;} .btn-blue:hover:not(:disabled){background:#2563eb;}
|
|
70
|
+
.btn-grn{background:var(--grn);color:#fff;} .btn-grn:hover:not(:disabled){background:#16a34a;}
|
|
71
|
+
.btn-yel{background:var(--yel);color:#111;} .btn-yel:hover:not(:disabled){background:#f59e0b;}
|
|
72
|
+
.btn-red{background:var(--red);color:#fff;} .btn-red:hover:not(:disabled){background:#dc2626;}
|
|
73
|
+
.btn-sm{padding:5px 9px;font-size:11px;width:auto;}
|
|
74
|
+
.btn-row{display:flex;gap:6px;margin-top:6px;}
|
|
75
|
+
.mb6{margin-bottom:6px;} .mb8{margin-bottom:8px;} .mt8{margin-top:8px;} .mt6{margin-top:6px;}
|
|
76
|
+
|
|
77
|
+
/* ── INPUTS ── */
|
|
78
|
+
.lbl{display:block;font-size:10px;color:var(--muted);margin-bottom:4px;}
|
|
79
|
+
.input{width:100%;background:var(--inp);border:1px solid var(--bdr);color:var(--txt);padding:7px 9px;border-radius:6px;font:12px/1 'Inter',system-ui;outline:none;transition:border-color .13s;}
|
|
80
|
+
.input:focus{border-color:var(--acc);box-shadow:0 0 0 2px rgba(249,115,22,.12);}
|
|
81
|
+
.input::placeholder{color:var(--muted);}
|
|
82
|
+
.select{width:100%;background:var(--inp);border:1px solid var(--bdr);color:var(--txt);padding:7px 28px 7px 10px;border-radius:6px;font:12px/1.3 'Inter',system-ui;outline:none;cursor:pointer;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='%238b949e'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 10px center;transition:border-color .13s,box-shadow .13s;}
|
|
83
|
+
.select:focus{border-color:var(--acc);box-shadow:0 0 0 2px rgba(249,115,22,.12);}
|
|
84
|
+
.select:hover{border-color:rgba(255,255,255,.15);}
|
|
85
|
+
.ig{margin-bottom:9px;}
|
|
86
|
+
.chk-label{display:flex;align-items:center;gap:8px;cursor:pointer;font-size:12px;user-select:none;padding:4px 0;}
|
|
87
|
+
.chk-label input[type=checkbox]{cursor:pointer;accent-color:var(--acc);width:15px;height:15px;}
|
|
88
|
+
.chk-label span{color:var(--txt);}
|
|
89
|
+
|
|
90
|
+
/* ── ENV THEME BUTTONS ── */
|
|
91
|
+
.env-btn.active{border-color:var(--acc)!important;background:rgba(249,115,22,.08)!important;color:var(--acc);}
|
|
92
|
+
|
|
93
|
+
/* ── RANGE SLIDER ── */
|
|
94
|
+
.slider-row{display:flex;align-items:center;gap:8px;margin-bottom:8px;}
|
|
95
|
+
.slider-lbl{font-size:10px;color:var(--muted);width:12px;flex-shrink:0;font-weight:600;}
|
|
96
|
+
.slider{flex:1;-webkit-appearance:none;appearance:none;height:5px;border-radius:3px;background:var(--bdr);outline:none;cursor:pointer;transition:background .2s;}
|
|
97
|
+
.slider:hover{background:rgba(255,255,255,.1);}
|
|
98
|
+
.slider::-webkit-slider-thumb{-webkit-appearance:none;width:16px;height:16px;border-radius:50%;cursor:pointer;box-shadow:0 1px 6px rgba(0,0,0,.5);transition:transform .15s,box-shadow .15s;}
|
|
99
|
+
.slider:hover::-webkit-slider-thumb{transform:scale(1.15);box-shadow:0 2px 8px rgba(0,0,0,.6);}
|
|
100
|
+
.slider.sx::-webkit-slider-thumb{background:var(--red);}
|
|
101
|
+
.slider.sy::-webkit-slider-thumb{background:var(--grn);}
|
|
102
|
+
.slider.sz::-webkit-slider-thumb{background:var(--blue);}
|
|
103
|
+
.slider.so::-webkit-slider-thumb{background:var(--muted);}
|
|
104
|
+
.slider-val{font-size:10px;color:var(--txt);width:32px;text-align:right;font-family:monospace;flex-shrink:0;font-weight:500;}
|
|
105
|
+
|
|
106
|
+
/* ── DROP ZONE ── */
|
|
107
|
+
.drop-zone{border:1.5px dashed var(--bdr);border-radius:8px;padding:16px 12px;text-align:center;cursor:pointer;transition:all .13s;margin-bottom:10px;}
|
|
108
|
+
.drop-zone:hover,.drop-zone.over{border-color:var(--acc);background:rgba(249,115,22,.06);box-shadow:inset 0 0 20px rgba(249,115,22,.04);}
|
|
109
|
+
.dz-icon{font-size:20px;margin-bottom:4px;}
|
|
110
|
+
.dz-text{color:var(--muted);font-size:11px;line-height:1.5;}
|
|
111
|
+
.dz-text strong{color:var(--txt);}
|
|
112
|
+
|
|
113
|
+
/* ── BADGES ── */
|
|
114
|
+
.badges{display:flex;flex-wrap:wrap;gap:4px;margin-top:8px;}
|
|
115
|
+
.badge{padding:3px 8px;border-radius:5px;font-size:10px;font-weight:600;letter-spacing:.3px;}
|
|
116
|
+
.bg-g{background:rgba(34,197,94,.12);color:var(--grn);border:1px solid rgba(34,197,94,.25);}
|
|
117
|
+
.bg-m{background:rgba(139,148,158,.08);color:var(--muted);border:1px solid rgba(139,148,158,.15);}
|
|
118
|
+
|
|
119
|
+
/* ── INFO CARD ── */
|
|
120
|
+
.info-card{background:var(--card);border:1px solid rgba(255,255,255,.04);border-radius:8px;padding:12px;font-size:11px;}
|
|
121
|
+
.ir{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:4px;}
|
|
122
|
+
.ir:last-child{margin-bottom:0;}
|
|
123
|
+
.ik{color:var(--muted);}
|
|
124
|
+
.iv{color:var(--txt);font-weight:500;max-width:155px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:right;}
|
|
125
|
+
|
|
126
|
+
/* ── SELECTION TREE ── */
|
|
127
|
+
.tree-summary{background:var(--card);border-radius:6px;padding:8px 10px;font-size:11px;color:var(--muted);margin-bottom:10px;font-weight:500;}
|
|
128
|
+
.tree-list{max-height:200px;overflow-y:auto;margin-bottom:10px;border:1px solid var(--bdr);border-radius:6px;background:rgba(0,0,0,.2);}
|
|
129
|
+
.tree-item{border-bottom:1px solid var(--bdr);padding:0;overflow:hidden;}
|
|
130
|
+
.tree-item:last-child{border-bottom:none;}
|
|
131
|
+
.tree-row{display:flex;align-items:center;gap:6px;padding:8px 10px;cursor:pointer;user-select:none;transition:background .12s;}
|
|
132
|
+
.tree-row:hover{background:rgba(255,255,255,.05);}
|
|
133
|
+
.tree-row.selected{background:rgba(249,115,22,.15);box-shadow:inset 3px 0 0 var(--acc);}
|
|
134
|
+
.tree-vis{background:none;border:none;color:var(--muted);cursor:pointer;padding:0;font-size:13px;width:20px;height:20px;display:flex;align-items:center;justify-content:center;transition:color .12s;flex-shrink:0;}
|
|
135
|
+
.tree-vis:hover{color:var(--txt);}
|
|
136
|
+
.tree-name{font-size:11px;color:var(--txt);flex:1;font-weight:500;min-width:0;}
|
|
137
|
+
.tree-name.clickable:hover{color:var(--acc);}
|
|
138
|
+
.tree-details{padding:6px 10px 8px;background:rgba(0,0,0,.1);display:none;gap:6px;align-items:center;font-size:10px;}
|
|
139
|
+
.tree-details.open{display:flex;}
|
|
140
|
+
.tree-info{color:var(--muted);font-size:9px;flex-shrink:0;}
|
|
141
|
+
.color-swatch-sm{width:20px;height:20px;border-radius:4px;border:1px solid var(--bdr);cursor:pointer;overflow:hidden;display:flex;flex-shrink:0;}
|
|
142
|
+
.color-swatch-sm input[type=color]{width:200%;height:200%;margin:-25%;border:none;cursor:pointer;padding:0;}
|
|
143
|
+
.tree-opacity{flex:1;max-width:60px;-webkit-appearance:none;appearance:none;height:3px;border-radius:1.5px;background:var(--bdr);outline:none;cursor:pointer;}
|
|
144
|
+
.tree-opacity::-webkit-slider-thumb{-webkit-appearance:none;width:10px;height:10px;border-radius:50%;background:var(--muted);cursor:pointer;box-shadow:0 1px 4px rgba(0,0,0,.4);}
|
|
145
|
+
.tree-del{background:none;border:none;color:var(--muted);cursor:pointer;padding:0;font-size:12px;width:16px;height:16px;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:color .12s;}
|
|
146
|
+
.tree-del:hover{color:var(--red);}
|
|
147
|
+
|
|
148
|
+
/* ── VIEWPOINTS ── */
|
|
149
|
+
.vp-list{max-height:200px;overflow-y:auto;margin-bottom:10px;border:1px solid var(--bdr);border-radius:8px;background:rgba(0,0,0,.2);}
|
|
150
|
+
.vp-item{border-bottom:1px solid rgba(255,255,255,.04);padding:9px 12px;display:flex;align-items:center;gap:8px;cursor:pointer;transition:all .15s;user-select:none;}
|
|
151
|
+
.vp-item:last-child{border-bottom:none;}
|
|
152
|
+
.vp-item:hover{background:rgba(59,130,246,.06);padding-left:14px;}
|
|
153
|
+
.vp-name{flex:1;font-size:11px;color:var(--txt);font-weight:500;min-width:0;word-break:break-word;}
|
|
154
|
+
.vp-name.editable:hover{color:var(--acc);}
|
|
155
|
+
.vp-time{font-size:9px;color:var(--muted);flex-shrink:0;font-family:monospace;}
|
|
156
|
+
.vp-del{background:none;border:none;color:var(--muted);cursor:pointer;padding:2px;font-size:12px;width:20px;height:20px;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:all .12s;border-radius:4px;}
|
|
157
|
+
.vp-del:hover{color:var(--red);background:rgba(239,68,68,.1);}
|
|
158
|
+
|
|
159
|
+
/* ── MEASURE DISPLAY ── */
|
|
160
|
+
.meas-display{background:linear-gradient(135deg, var(--card), rgba(251,191,36,.04));border:1px solid rgba(251,191,36,.15);border-radius:8px;padding:14px;margin-bottom:10px;display:none;}
|
|
161
|
+
.meas-display.show{display:block;animation:fadeIn .25s ease;}
|
|
162
|
+
.meas-total{font-size:20px;font-weight:800;color:var(--yel);margin-bottom:8px;font-family:monospace;text-shadow:0 0 20px rgba(251,191,36,.2);}
|
|
163
|
+
.meas-components{font-size:10px;color:var(--muted);display:grid;grid-template-columns:1fr 1fr 1fr;gap:5px;}
|
|
164
|
+
.meas-comp{background:var(--inp);border:1px solid rgba(255,255,255,.04);border-radius:6px;padding:6px 8px;text-align:center;transition:border-color .2s;}
|
|
165
|
+
.meas-comp:hover{border-color:rgba(255,255,255,.1);}
|
|
166
|
+
.meas-axis{font-weight:700;margin-bottom:3px;font-size:10px;letter-spacing:.5px;}
|
|
167
|
+
.meas-axis.mx{color:var(--red);} .meas-axis.my{color:var(--grn);} .meas-axis.mz{color:var(--blue);}
|
|
168
|
+
.meas-val{color:var(--txt);font-size:11px;font-family:monospace;font-weight:600;}
|
|
169
|
+
.meas-state{font-size:11px;color:var(--muted);margin-bottom:8px;line-height:1.5;padding:8px 10px;background:rgba(251,191,36,.07);border:1px solid rgba(251,191,36,.2);border-radius:6px;display:none;}
|
|
170
|
+
.meas-state.show{display:block;animation:fadeIn .2s ease;}
|
|
171
|
+
@keyframes fadeIn{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:translateY(0);}}
|
|
172
|
+
|
|
173
|
+
/* ── COLOR PICKER ── */
|
|
174
|
+
.color-row{display:flex;align-items:center;gap:10px;margin-bottom:10px;}
|
|
175
|
+
.color-swatch{width:36px;height:36px;border-radius:8px;border:2px solid var(--bdr);cursor:pointer;padding:0;background:none;overflow:hidden;transition:border-color .15s,box-shadow .15s;}
|
|
176
|
+
.color-swatch:hover{border-color:var(--acc);box-shadow:0 0 8px rgba(249,115,22,.2);}
|
|
177
|
+
.color-swatch input[type=color]{width:200%;height:200%;margin:-25%;border:none;cursor:pointer;padding:0;}
|
|
178
|
+
.color-label{font-size:11px;color:var(--muted);flex:1;}
|
|
179
|
+
|
|
180
|
+
/* ── MARKUP TOOL ── */
|
|
181
|
+
.color-btn{width:100%;aspect-ratio:1;border-radius:6px;border:2px solid transparent;cursor:pointer;transition:all .1s;box-shadow:0 2px 4px rgba(0,0,0,.3);}
|
|
182
|
+
.color-btn:hover{border-color:var(--txt);transform:scale(1.05);}
|
|
183
|
+
.color-btn.active{border-color:var(--txt);box-shadow:0 0 0 2px var(--bg),0 0 0 4px var(--txt);transform:scale(1.1);}
|
|
184
|
+
|
|
185
|
+
/* ── QR PANEL ── */
|
|
186
|
+
.qr-box{background:var(--card);border:1px solid var(--bdr);border-radius:8px;padding:14px;text-align:center;margin-bottom:9px;box-shadow:0 4px 20px rgba(0,0,0,.3);}
|
|
187
|
+
.qr-box.hidden{display:none;}
|
|
188
|
+
#qr-code{width:150px;height:150px;margin:0 auto 9px;display:block;background:#fff;border-radius:6px;padding:6px;display:flex;align-items:center;justify-content:center;}
|
|
189
|
+
#qr-code canvas{max-width:100%;height:auto;}
|
|
190
|
+
.qr-sub{font-size:10px;color:var(--muted);margin-bottom:9px;line-height:1.5;}
|
|
191
|
+
.qr-url{background:var(--inp);border:1px solid var(--bdr);border-radius:5px;padding:5px 7px;font-size:9px;color:var(--muted);word-break:break-all;text-align:left;margin-bottom:8px;font-family:monospace;max-height:48px;overflow-y:auto;}
|
|
192
|
+
|
|
193
|
+
/* ── BANNERS ── */
|
|
194
|
+
.banner{border-radius:8px;padding:8px 12px;font-size:11px;margin-bottom:8px;line-height:1.5;display:none;}
|
|
195
|
+
.banner.show{display:block;animation:fadeIn .25s ease;}
|
|
196
|
+
.bn-grn{background:rgba(34,197,94,.06);border:1px solid rgba(34,197,94,.2);color:var(--grn);}
|
|
197
|
+
.bn-blue{background:rgba(59,130,246,.06);border:1px solid rgba(59,130,246,.18);color:#93c5fd;font-size:10px;}
|
|
198
|
+
.bn-yel{background:rgba(251,191,36,.06);border:1px solid rgba(251,191,36,.18);color:var(--yel);font-size:10px;}
|
|
199
|
+
|
|
200
|
+
/* ── VIEWPORT ── */
|
|
201
|
+
.viewport{flex:1;position:relative;overflow:hidden;}
|
|
202
|
+
#canvas-wrap{width:100%;height:100%;}
|
|
203
|
+
.welcome{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;pointer-events:none;}
|
|
204
|
+
.welcome-icon{font-size:52px;opacity:.15;filter:drop-shadow(0 0 20px rgba(249,115,22,.2));}
|
|
205
|
+
.welcome-title{font-size:22px;font-weight:700;opacity:.35;letter-spacing:-.3px;}
|
|
206
|
+
.welcome-sub{font-size:12px;color:var(--muted);opacity:.5;max-width:280px;text-align:center;}
|
|
207
|
+
.drop-over{position:absolute;inset:0;background:rgba(249,115,22,.06);border:2.5px dashed var(--acc);border-radius:8px;display:none;align-items:center;justify-content:center;font-size:18px;font-weight:700;color:var(--acc);backdrop-filter:blur(2px);}
|
|
208
|
+
.loading{position:absolute;inset:0;background:rgba(13,17,23,.92);backdrop-filter:blur(4px);display:none;flex-direction:column;align-items:center;justify-content:center;gap:16px;}
|
|
209
|
+
.loading.on{display:flex;}
|
|
210
|
+
.spinner{width:42px;height:42px;border:3px solid rgba(249,115,22,.15);border-top-color:var(--acc);border-radius:50%;animation:spin .75s linear infinite;box-shadow:0 0 20px rgba(249,115,22,.1);}
|
|
211
|
+
@keyframes spin{to{transform:rotate(360deg);}}
|
|
212
|
+
@keyframes touchPulse{0%{opacity:1;transform:translate(-50%,-50%) scale(1);}100%{opacity:0;transform:translate(-50%,-50%) scale(2);}}
|
|
213
|
+
@keyframes pulse{0%,100%{opacity:.6;}50%{opacity:1;}}
|
|
214
|
+
.loading-txt{color:var(--muted);font-size:12px;animation:pulse 1.5s ease-in-out infinite;}
|
|
215
|
+
.hud{position:absolute;pointer-events:none;background:rgba(13,17,23,.85);border:1px solid rgba(255,255,255,.08);border-radius:8px;padding:6px 10px;font-size:10px;color:var(--muted);line-height:1.6;font-family:monospace;backdrop-filter:blur(8px);}
|
|
216
|
+
.hud-br{bottom:14px;right:14px;}
|
|
217
|
+
.hud-bl{bottom:14px;left:14px;}
|
|
218
|
+
|
|
219
|
+
/* ── MEASURE LABEL (world-space overlay) ── */
|
|
220
|
+
#meas-label{position:absolute;pointer-events:none;background:rgba(13,17,23,.94);border:1px solid var(--yel);border-radius:8px;padding:6px 12px;font-size:11px;color:var(--yel);font-family:monospace;font-weight:600;white-space:nowrap;display:none;transform:translate(-50%,-130%);z-index:10;box-shadow:0 4px 16px rgba(0,0,0,.4);}
|
|
221
|
+
|
|
222
|
+
/* ── CURSOR STATES ── */
|
|
223
|
+
.viewport.measuring{cursor:crosshair;}
|
|
224
|
+
|
|
225
|
+
/* ── STATUS ── */
|
|
226
|
+
.statusbar{height:22px;background:linear-gradient(to right, var(--hdr), #0f1628);border-top:1px solid var(--bdr);display:flex;align-items:center;padding:0 16px;gap:18px;font-size:10px;color:var(--muted);flex-shrink:0;}
|
|
227
|
+
.si{display:flex;align-items:center;gap:5px;}
|
|
228
|
+
.dot{width:6px;height:6px;border-radius:50%;background:var(--bdr);flex-shrink:0;transition:background .3s;}
|
|
229
|
+
.dot.grn{background:var(--grn);box-shadow:0 0 4px var(--grn);} .dot.org{background:var(--acc);box-shadow:0 0 4px var(--acc);} .dot.yel{background:var(--yel);box-shadow:0 0 4px var(--yel);}
|
|
230
|
+
|
|
231
|
+
/* ── TOAST ── */
|
|
232
|
+
.toast{position:fixed;bottom:36px;left:50%;transform:translateX(-50%) translateY(12px);background:rgba(22,27,34,.95);border:1px solid var(--bdr);border-radius:8px;padding:9px 16px;font-size:12px;opacity:0;transition:all .25s;z-index:999;pointer-events:none;white-space:nowrap;box-shadow:0 8px 32px rgba(0,0,0,.4);backdrop-filter:blur(12px);}
|
|
233
|
+
.toast.show{opacity:1;transform:translateX(-50%) translateY(0);}
|
|
234
|
+
.toast.ok{border-color:var(--grn);color:var(--grn);}
|
|
235
|
+
.toast.err{border-color:var(--red);color:var(--red);}
|
|
236
|
+
.toast.info{border-color:var(--yel);color:var(--yel);}
|
|
237
|
+
|
|
238
|
+
#file-input{display:none;}
|
|
239
|
+
#project-file{display:none;}
|
|
240
|
+
::-webkit-scrollbar{width:4px;} ::-webkit-scrollbar-track{background:transparent;} ::-webkit-scrollbar-thumb{background:rgba(249,115,22,.25);border-radius:2px;} ::-webkit-scrollbar-thumb:hover{background:rgba(249,115,22,.4);}
|
|
241
|
+
.divider{height:1px;background:linear-gradient(to right, transparent, var(--bdr), transparent);margin:10px 0;}
|
|
242
|
+
.hint{font-size:10px;color:var(--muted);line-height:1.6;padding:4px 0;}
|
|
243
|
+
|
|
244
|
+
/* ── AUTH MODAL ── */
|
|
245
|
+
/* ── UPGRADE MODAL ── */
|
|
246
|
+
#upgrade-modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,.8);z-index:1001;align-items:center;justify-content:center;backdrop-filter:blur(6px);}
|
|
247
|
+
.upgrade-card{background:linear-gradient(145deg, var(--card), #141b28);border:1px solid rgba(255,255,255,.06);border-radius:16px;padding:36px;width:100%;max-width:680px;box-shadow:0 24px 80px rgba(0,0,0,.7);position:relative;}
|
|
248
|
+
.upgrade-close{position:absolute;top:16px;right:16px;background:rgba(255,255,255,.05);border:none;color:var(--muted);font-size:18px;cursor:pointer;padding:4px 8px;transition:all .2s;border-radius:6px;width:32px;height:32px;display:flex;align-items:center;justify-content:center;}
|
|
249
|
+
.upgrade-close:hover{color:var(--txt);background:rgba(255,255,255,.1);}
|
|
250
|
+
.upgrade-header{text-align:center;margin-bottom:28px;}
|
|
251
|
+
.upgrade-header h2{font-size:24px;font-weight:800;margin-bottom:8px;letter-spacing:-.5px;}
|
|
252
|
+
.upgrade-header p{font-size:12px;color:var(--muted);}
|
|
253
|
+
.upgrade-toggle{display:flex;align-items:center;justify-content:center;gap:14px;margin-bottom:28px;}
|
|
254
|
+
.upgrade-toggle span{font-size:12px;font-weight:500;transition:color .2s;}
|
|
255
|
+
.upgrade-toggle-btn{position:relative;width:46px;height:24px;border-radius:99px;border:none;cursor:pointer;background:var(--acc);padding:0;transition:background .2s;box-shadow:0 2px 6px rgba(249,115,22,.3);}
|
|
256
|
+
.upgrade-toggle-knob{position:absolute;top:2px;left:2px;width:20px;height:20px;border-radius:50%;background:#fff;transition:transform .25s ease;transform:translateX(22px);box-shadow:0 1px 3px rgba(0,0,0,.3);}
|
|
257
|
+
.upgrade-plans{display:grid;grid-template-columns:1fr 1fr;gap:18px;}
|
|
258
|
+
.upgrade-plan{background:var(--inp);border:1px solid var(--bdr);border-radius:12px;padding:24px 20px;text-align:center;transition:all .25s;}
|
|
259
|
+
.upgrade-plan:hover{border-color:rgba(249,115,22,.4);background:rgba(249,115,22,.03);transform:translateY(-2px);box-shadow:0 8px 24px rgba(0,0,0,.3);}
|
|
260
|
+
.upgrade-plan.featured{border-color:var(--acc);background:rgba(249,115,22,.06);box-shadow:0 0 20px rgba(249,115,22,.08);}
|
|
261
|
+
.upgrade-plan-name{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:1.2px;color:var(--muted);margin-bottom:12px;}
|
|
262
|
+
.upgrade-plan.featured .upgrade-plan-name{color:var(--acc);}
|
|
263
|
+
.upgrade-plan-price{font-size:38px;font-weight:800;margin-bottom:2px;letter-spacing:-1.5px;}
|
|
264
|
+
.upgrade-plan-price span{font-size:14px;color:var(--muted);font-weight:400;}
|
|
265
|
+
.upgrade-plan-period{font-size:10px;color:var(--muted);margin-bottom:18px;}
|
|
266
|
+
.upgrade-plan-features{text-align:left;margin-bottom:20px;font-size:11px;color:var(--muted);line-height:2.1;}
|
|
267
|
+
.upgrade-plan-btn{display:block;width:100%;padding:11px;border-radius:8px;font-weight:700;font-size:13px;border:none;cursor:pointer;transition:all .2s;text-align:center;text-decoration:none;color:#fff;letter-spacing:-.2px;}
|
|
268
|
+
.upgrade-plan-btn.pro-btn{background:var(--acc);box-shadow:0 2px 8px rgba(249,115,22,.25);} .upgrade-plan-btn.pro-btn:hover{background:var(--ach);box-shadow:0 4px 16px rgba(249,115,22,.35);transform:translateY(-1px);}
|
|
269
|
+
.upgrade-plan-btn.ent-btn{background:var(--grn);color:#0d1117;box-shadow:0 2px 8px rgba(34,197,94,.25);} .upgrade-plan-btn.ent-btn:hover{background:#1db954;box-shadow:0 4px 16px rgba(34,197,94,.35);transform:translateY(-1px);}
|
|
270
|
+
@media (max-width:600px){.upgrade-plans{grid-template-columns:1fr;}}
|
|
271
|
+
|
|
272
|
+
/* ── AUTH MODAL ── */
|
|
273
|
+
#auth-modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:1000;align-items:center;justify-content:center;backdrop-filter:blur(6px);}
|
|
274
|
+
.auth-card{background:linear-gradient(145deg, var(--card), #141b28);border:1px solid rgba(255,255,255,.06);border-radius:14px;padding:32px;width:100%;max-width:380px;box-shadow:0 24px 80px rgba(0,0,0,.7);}
|
|
275
|
+
.auth-header{text-align:center;margin-bottom:28px;}
|
|
276
|
+
.auth-logo{font-size:28px;margin-bottom:10px;}
|
|
277
|
+
.auth-title{font-size:20px;font-weight:700;color:var(--txt);letter-spacing:-.3px;}
|
|
278
|
+
.auth-subtitle{font-size:11px;color:var(--muted);margin-top:5px;}
|
|
279
|
+
.auth-tabs{display:flex;gap:0;margin-bottom:22px;border-bottom:1px solid var(--bdr);padding-bottom:0;}
|
|
280
|
+
.auth-tab-btn{flex:1;padding:10px 12px;font-size:12px;font-weight:600;background:none;border:none;color:var(--muted);cursor:pointer;transition:all .2s;border-bottom:2px solid transparent;margin-bottom:-1px;}
|
|
281
|
+
.auth-tab-btn:hover{color:var(--txt);}
|
|
282
|
+
.auth-tab-btn.auth-tab-active{color:var(--acc);border-bottom-color:var(--acc);}
|
|
283
|
+
.auth-form{display:none;}
|
|
284
|
+
.auth-form.show{display:block;animation:fadeIn .2s ease;}
|
|
285
|
+
.auth-form .ig{margin-bottom:16px;}
|
|
286
|
+
.auth-form .lbl{font-size:11px;color:var(--muted);margin-bottom:6px;display:block;font-weight:500;}
|
|
287
|
+
.auth-form .input{width:100%;padding:10px 12px;font-size:13px;border-radius:8px;}
|
|
288
|
+
.auth-error{color:var(--red);font-size:11px;margin-bottom:12px;padding:9px 12px;background:rgba(239,68,68,.08);border:1px solid rgba(239,68,68,.25);border-radius:8px;display:none;}
|
|
289
|
+
.auth-btn{width:100%;padding:11px 12px;margin-top:10px;font-size:13px;font-weight:600;border-radius:8px;border:none;cursor:pointer;transition:all .2s;letter-spacing:-.2px;}
|
|
290
|
+
.auth-signin-btn{background:var(--acc);color:#fff;box-shadow:0 2px 8px rgba(249,115,22,.25);} .auth-signin-btn:hover{background:var(--ach);box-shadow:0 4px 16px rgba(249,115,22,.35);transform:translateY(-1px);}
|
|
291
|
+
.auth-signup-btn{background:var(--acc);color:#fff;box-shadow:0 2px 8px rgba(249,115,22,.25);} .auth-signup-btn:hover{background:var(--ach);box-shadow:0 4px 16px rgba(249,115,22,.35);transform:translateY(-1px);}
|
|
292
|
+
.auth-guest-btn{width:100%;text-align:center;padding:12px;margin-top:18px;font-size:11px;color:var(--muted);cursor:pointer;background:none;border:1px solid var(--bdr);border-radius:8px;text-decoration:none;transition:all .2s;}
|
|
293
|
+
.auth-guest-btn:hover{color:var(--txt);border-color:rgba(255,255,255,.15);background:rgba(255,255,255,.03);}
|
|
294
|
+
.auth-forgot{text-align:right;margin-top:8px;}
|
|
295
|
+
.auth-forgot a{font-size:10px;color:var(--blue);cursor:pointer;text-decoration:none;transition:color .2s;}
|
|
296
|
+
.auth-forgot a:hover{color:#60a5fa;}
|
|
297
|
+
.user-name{color:var(--txt);font-size:11px;font-weight:500;vertical-align:middle;margin-right:6px;}
|
|
298
|
+
#auth-header-area{display:flex;align-items:center;gap:6px;}
|
|
299
|
+
|
|
300
|
+
/* ── VIEWCUBE WRAPPER WITH COMPASS ── */
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
/* ── VIEWCUBE (Autodesk/Navisworks style) ── */
|
|
304
|
+
.viewcube-container{position:absolute;top:60px;right:16px;width:220px;z-index:100;pointer-events:none;display:flex;flex-direction:column;align-items:center;gap:2px;}
|
|
305
|
+
.viewcube-wrapper{position:relative;width:200px;height:200px;pointer-events:auto;}
|
|
306
|
+
#viewcube-canvas{width:160px;height:160px;position:absolute;top:20px;left:20px;display:block;cursor:grab;border-radius:6px;background:radial-gradient(ellipse at 40% 35%,rgba(60,65,75,.85),rgba(28,30,36,.92));border:1px solid rgba(255,255,255,.06);box-shadow:0 2px 16px rgba(0,0,0,.45),inset 0 1px 0 rgba(255,255,255,.04);}
|
|
307
|
+
#viewcube-canvas:active{cursor:grabbing;}
|
|
308
|
+
.compass-ring-svg{position:absolute;top:0;left:0;width:200px;height:200px;pointer-events:none;filter:drop-shadow(0 0 3px rgba(0,0,0,.3));}
|
|
309
|
+
.compass-dir{position:absolute;font:700 11px/1 'Inter',system-ui,sans-serif;color:rgba(255,255,255,.55);pointer-events:auto;cursor:pointer;text-align:center;width:20px;transition:color .2s,text-shadow .2s,transform .2s;}
|
|
310
|
+
.compass-dir:hover{color:#f97316;text-shadow:0 0 8px rgba(249,115,22,.4);transform:scale(1.15);}
|
|
311
|
+
.compass-dir.n{top:-2px;left:50%;transform:translateX(-50%);color:rgba(255,200,180,.7);}
|
|
312
|
+
.compass-dir.n:hover{color:#f97316;transform:translateX(-50%) scale(1.15);}
|
|
313
|
+
.compass-dir.s{bottom:-2px;left:50%;transform:translateX(-50%);}
|
|
314
|
+
.compass-dir.s:hover{transform:translateX(-50%) scale(1.15);}
|
|
315
|
+
.compass-dir.e{right:-2px;top:50%;transform:translateY(-50%);}
|
|
316
|
+
.compass-dir.e:hover{transform:translateY(-50%) scale(1.15);}
|
|
317
|
+
.compass-dir.w{left:-2px;top:50%;transform:translateY(-50%);}
|
|
318
|
+
.compass-dir.w:hover{transform:translateY(-50%) scale(1.15);}
|
|
319
|
+
.vc-view-label{font:500 10.5px/1 'Inter',system-ui,sans-serif;color:rgba(255,255,255,.4);text-align:center;pointer-events:none;white-space:nowrap;letter-spacing:.5px;text-transform:uppercase;min-height:14px;}
|
|
320
|
+
.vc-home-btn{display:inline-flex;align-items:center;gap:3px;margin-top:2px;padding:3px 8px;font:500 10px/1 'Inter',system-ui,sans-serif;color:rgba(255,255,255,.35);background:rgba(30,30,36,.6);border:1px solid rgba(255,255,255,.06);border-radius:3px;cursor:pointer;pointer-events:auto;transition:all .2s;letter-spacing:.3px;text-transform:uppercase;}
|
|
321
|
+
.vc-home-btn:hover{color:#f97316;border-color:rgba(249,115,22,.3);background:rgba(40,36,30,.8);}
|
|
322
|
+
.vc-home-btn svg{width:11px;height:11px;fill:currentColor;}
|
|
323
|
+
/* ── BOTTOM TOOLBAR ── */
|
|
324
|
+
.bottom-toolbar{position:absolute;bottom:16px;left:50%;transform:translateX(-50%);display:flex;align-items:center;gap:2px;padding:4px 6px;background:rgba(13,17,23,.85);backdrop-filter:blur(12px);border:1px solid rgba(255,255,255,.08);border-radius:12px;box-shadow:0 4px 24px rgba(0,0,0,.5);z-index:15;}
|
|
325
|
+
.toolbar-btn{width:38px;height:38px;border-radius:8px;border:none;background:transparent;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .13s,color .13s;color:rgba(255,255,255,.6);}
|
|
326
|
+
.toolbar-btn:hover{background:rgba(255,255,255,.08);color:rgba(255,255,255,1);}
|
|
327
|
+
.toolbar-btn.active{background:rgba(249,115,22,.15);color:var(--acc);}
|
|
328
|
+
.toolbar-btn svg{width:18px;height:18px;}
|
|
329
|
+
.toolbar-divider{width:1px;height:20px;background:rgba(255,255,255,.1);margin:0 2px;}
|
|
330
|
+
|
|
331
|
+
/* ── WELCOME STATE ENHANCEMENT ── */
|
|
332
|
+
.welcome-icon{animation:welcomePulse 2s ease-in-out infinite;}
|
|
333
|
+
@keyframes welcomePulse{0%,100%{opacity:.15;transform:scale(1);}50%{opacity:.25;transform:scale(1.05);}}
|
|
334
|
+
|
|
335
|
+
/* ── RESPONSIVE ── */
|
|
336
|
+
@media (max-width:768px) {
|
|
337
|
+
.sidebar{position:fixed;left:0;top:50px;bottom:22px;width:280px;transform:translateX(-100%);}
|
|
338
|
+
.sidebar.show{transform:translateX(0);}
|
|
339
|
+
.sidebar-overlay{position:fixed;top:50px;inset:0;z-index:99;}
|
|
340
|
+
.hamburger{display:block;margin-left:0;}
|
|
341
|
+
.viewport{flex:1;}
|
|
342
|
+
.viewcube-container{width:120px;height:120px;top:8px;right:8px;}
|
|
343
|
+
#viewcube-canvas{width:90px;height:90px;}
|
|
344
|
+
.compass-ring{width:120px;height:120px;}
|
|
345
|
+
.compass-label.n{top:2px;} .compass-label.s{top:118px;} .compass-label.e{left:118px;} .compass-label.w{left:2px;}
|
|
346
|
+
.compass-label{font-size:9px;}
|
|
347
|
+
.bottom-toolbar{gap:1px;padding:3px 4px;border-radius:10px;}
|
|
348
|
+
.toolbar-btn{width:32px;height:32px;border-radius:6px;}
|
|
349
|
+
.toolbar-btn svg{width:15px;height:15px;}
|
|
350
|
+
.toolbar-divider{height:16px;}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
.acct-btn{width:auto;padding:4px 10px;font-size:11px;background:transparent;border:1px solid rgba(255,255,255,0.2);color:#aaa;margin-right:6px;border-radius:4px;cursor:pointer;transition:all 0.2s;}
|
|
354
|
+
.acct-btn:hover{border-color:#f97316;color:#f97316;}
|
|
355
|
+
@media (min-width:600px) and (max-width:1024px){
|
|
356
|
+
.bottom-toolbar{display:flex!important;visibility:visible!important;position:fixed!important;bottom:16px!important;left:50%;transform:translateX(-50%);z-index:1000!important;}
|
|
357
|
+
.sidebar{max-height:100vh;overflow-y:auto!important;}
|
|
358
|
+
}
|
|
359
|
+
#viewport{overflow:visible!important;}
|
|
360
|
+
@supports(padding-bottom:env(safe-area-inset-bottom)){.bottom-toolbar{bottom:calc(16px + env(safe-area-inset-bottom))!important;}}
|
|
361
|
+
.acct-cancel-btn{flex:1;padding:11px;background:rgba(255,255,255,0.06);color:#aaa;border:1px solid rgba(255,255,255,0.1);border-radius:8px;cursor:pointer;font-size:14px;font-weight:500;}
|
|
362
|
+
.acct-cancel-btn:hover{background:rgba(255,255,255,0.1);}
|
|
363
|
+
.acct-delete-btn{flex:1;padding:11px;background:#dc2626;color:#fff;border:none;border-radius:8px;cursor:pointer;font-size:14px;font-weight:500;}
|
|
364
|
+
.acct-delete-btn:hover{background:#b91c1c;}
|
|
365
|
+
.acct-input{width:100%;padding:12px 14px;background:rgba(0,0,0,0.3);border:1px solid rgba(255,255,255,0.1);border-radius:8px;color:#fff;font-size:14px;box-sizing:border-box;outline:none;}
|
|
366
|
+
.acct-input:focus{border-color:#f97316;}
|
|
367
|
+
.acct-spinner{border:3px solid rgba(255,255,255,0.1);border-top:3px solid #f97316;border-radius:50%;width:40px;height:40px;animation:acctSpin 1s linear infinite;margin:0 auto;}
|
|
368
|
+
@keyframes acctSpin{0%{transform:rotate(0deg)}100%{transform:rotate(360deg)}}
|
|
369
|
+
|
|
370
|
+
</style>
|
|
371
|
+
</head>
|
|
372
|
+
<body>
|
|
373
|
+
<div class="app">
|
|
374
|
+
<div id="upload-progress-bar" style="position:absolute;top:0;left:0;height:3px;background:linear-gradient(to right,var(--acc),var(--ach));width:0%;transition:width .2s;z-index:200;display:none"></div>
|
|
375
|
+
<header class="header">
|
|
376
|
+
<button class="hamburger" id="hamburger" onclick="toggleSidebar()">☰</button>
|
|
377
|
+
<div class="logo-box"><svg viewBox="0 0 28 28" width="18" height="18" fill="none"><g stroke="#fff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3L25 9v10L14 25 3 19V9z"/><path d="M14 3v11"/><path d="M14 14l11-5"/><path d="M14 14L3 9"/><path d="M14 14v11" opacity=".45"/></g><circle cx="14" cy="3" r="1.6" fill="#fff"/><circle cx="25" cy="9" r="1.6" fill="#fff"/><circle cx="3" cy="9" r="1.6" fill="#fff"/><circle cx="14" cy="14" r="1.8" fill="#fff"/><circle cx="25" cy="19" r="1.3" fill="#fff" opacity=".6"/><circle cx="3" cy="19" r="1.3" fill="#fff" opacity=".6"/><circle cx="14" cy="25" r="1.3" fill="#fff" opacity=".6"/></svg></div>
|
|
378
|
+
<div class="logo-name">Scan<span>BIM</span> 3D</div>
|
|
379
|
+
<div class="logo-tag">QR-Linked 3D Viewer for AEC</div>
|
|
380
|
+
<div class="hs"></div>
|
|
381
|
+
<div id="auth-header-area"></div>
|
|
382
|
+
<span class="pill">v1.0</span>
|
|
383
|
+
</header>
|
|
384
|
+
|
|
385
|
+
<!-- ── UPGRADE MODAL ── -->
|
|
386
|
+
<div id="upgrade-modal">
|
|
387
|
+
<div class="upgrade-card">
|
|
388
|
+
<button class="upgrade-close" onclick="closeUpgradeModal()">×</button>
|
|
389
|
+
<div class="upgrade-header">
|
|
390
|
+
<h2>Upgrade Your Plan</h2>
|
|
391
|
+
<p>Unlock unlimited projects, more storage, and premium formats</p>
|
|
392
|
+
</div>
|
|
393
|
+
<div class="upgrade-toggle">
|
|
394
|
+
<span id="up-monthly-lbl" style="color:var(--muted);">Monthly</span>
|
|
395
|
+
<button class="upgrade-toggle-btn" onclick="toggleUpgradeBilling()">
|
|
396
|
+
<span class="upgrade-toggle-knob" id="up-toggle-knob"></span>
|
|
397
|
+
</button>
|
|
398
|
+
<span id="up-annual-lbl" style="color:var(--txt);">Annual <span style="color:var(--grn);font-size:10px;font-weight:700;">Save 20%</span></span>
|
|
399
|
+
</div>
|
|
400
|
+
<div class="upgrade-plans">
|
|
401
|
+
<div class="upgrade-plan featured">
|
|
402
|
+
<div class="upgrade-plan-name">Pro</div>
|
|
403
|
+
<div class="upgrade-plan-price" id="up-pro-price">$49<span>/mo</span></div>
|
|
404
|
+
<div class="upgrade-plan-period" id="up-pro-period">billed annually — 14-day free trial</div>
|
|
405
|
+
<div class="upgrade-plan-features">
|
|
406
|
+
✓ Unlimited projects<br>
|
|
407
|
+
✓ 10GB cloud storage<br>
|
|
408
|
+
✓ FBX support + VR viewer<br>
|
|
409
|
+
✓ BCF export/import<br>
|
|
410
|
+
✓ Measurement tools
|
|
411
|
+
</div>
|
|
412
|
+
<a href="#" class="upgrade-plan-btn pro-btn" id="up-pro-btn">Start Free Trial</a>
|
|
413
|
+
</div>
|
|
414
|
+
<div class="upgrade-plan">
|
|
415
|
+
<div class="upgrade-plan-name">Enterprise</div>
|
|
416
|
+
<div class="upgrade-plan-price" id="up-ent-price">$149<span>/mo</span></div>
|
|
417
|
+
<div class="upgrade-plan-period" id="up-ent-period">billed annually — 14-day free trial</div>
|
|
418
|
+
<div class="upgrade-plan-features">
|
|
419
|
+
✓ Everything in Pro<br>
|
|
420
|
+
✓ Revit, Navisworks, DWG<br>
|
|
421
|
+
✓ Unlimited storage<br>
|
|
422
|
+
✓ AI Spatial Assistant<br>
|
|
423
|
+
✓ SSO & API access
|
|
424
|
+
</div>
|
|
425
|
+
<a href="#" class="upgrade-plan-btn ent-btn" id="up-ent-btn">Start Free Trial</a>
|
|
426
|
+
</div>
|
|
427
|
+
</div>
|
|
428
|
+
</div>
|
|
429
|
+
</div>
|
|
430
|
+
|
|
431
|
+
<!-- ── AUTH MODAL ── -->
|
|
432
|
+
<div id="auth-modal">
|
|
433
|
+
<div class="auth-card">
|
|
434
|
+
<div class="auth-header">
|
|
435
|
+
<div class="auth-logo"><svg viewBox="0 0 28 28" width="32" height="32" fill="none"><g stroke="var(--acc)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3L25 9v10L14 25 3 19V9z"/><path d="M14 3v11"/><path d="M14 14l11-5"/><path d="M14 14L3 9"/><path d="M14 14v11" opacity=".45"/></g><circle cx="14" cy="3" r="1.6" fill="var(--acc)"/><circle cx="25" cy="9" r="1.6" fill="var(--acc)"/><circle cx="3" cy="9" r="1.6" fill="var(--acc)"/><circle cx="14" cy="14" r="1.8" fill="var(--acc)"/><circle cx="25" cy="19" r="1.3" fill="var(--acc)" opacity=".6"/><circle cx="3" cy="19" r="1.3" fill="var(--acc)" opacity=".6"/><circle cx="14" cy="25" r="1.3" fill="var(--acc)" opacity=".6"/></svg></div>
|
|
436
|
+
<div class="auth-title">ScanBIM 3D</div>
|
|
437
|
+
<div class="auth-subtitle">QR-Linked 3D Viewer for AEC</div>
|
|
438
|
+
</div>
|
|
439
|
+
|
|
440
|
+
<div id="auth-error" class="auth-error"></div>
|
|
441
|
+
|
|
442
|
+
<div class="auth-tabs">
|
|
443
|
+
<button type="button" class="auth-tab-btn auth-tab-active" id="tab-signin" onclick="switchAuthTab('signin')">Sign In</button>
|
|
444
|
+
<button type="button" class="auth-tab-btn" id="tab-signup" onclick="switchAuthTab('signup')">Sign Up</button>
|
|
445
|
+
</div>
|
|
446
|
+
|
|
447
|
+
<!-- Sign In Form -->
|
|
448
|
+
<form id="signin-form" class="auth-form show" onsubmit="return signInUser(), false">
|
|
449
|
+
<div class="ig">
|
|
450
|
+
<label class="lbl">Email</label>
|
|
451
|
+
<input type="email" id="signin-email" class="input" placeholder="your@email.com" required>
|
|
452
|
+
</div>
|
|
453
|
+
<div class="ig">
|
|
454
|
+
<label class="lbl">Password</label>
|
|
455
|
+
<input type="password" id="signin-pass" class="input" placeholder="••••••" required onkeypress="if(event.key==='Enter'){signInUser();return false}">
|
|
456
|
+
</div>
|
|
457
|
+
<button type="button" class="auth-btn auth-signin-btn" onclick="signInUser()">Sign In</button>
|
|
458
|
+
<div class="auth-forgot">
|
|
459
|
+
<a onclick="resetPassword()" style="cursor:pointer;">Forgot Password?</a>
|
|
460
|
+
</div>
|
|
461
|
+
</form>
|
|
462
|
+
|
|
463
|
+
<!-- Sign Up Form -->
|
|
464
|
+
<form id="signup-form" class="auth-form" onsubmit="return signUpUser(), false">
|
|
465
|
+
<div class="ig">
|
|
466
|
+
<label class="lbl">Full Name</label>
|
|
467
|
+
<input type="text" id="signup-name" class="input" placeholder="John Doe" required>
|
|
468
|
+
</div>
|
|
469
|
+
<div class="ig">
|
|
470
|
+
<label class="lbl">Email</label>
|
|
471
|
+
<input type="email" id="signup-email" class="input" placeholder="your@email.com" required>
|
|
472
|
+
</div>
|
|
473
|
+
<div class="ig">
|
|
474
|
+
<label class="lbl">Password</label>
|
|
475
|
+
<input type="password" id="signup-pass" class="input" placeholder="••••••" required>
|
|
476
|
+
</div>
|
|
477
|
+
<div class="ig">
|
|
478
|
+
<label class="lbl">Confirm Password</label>
|
|
479
|
+
<input type="password" id="signup-confirm" class="input" placeholder="••••••" required onkeypress="if(event.key==='Enter'){signUpUser();return false}">
|
|
480
|
+
</div>
|
|
481
|
+
<button type="button" class="auth-btn auth-signup-btn" onclick="signUpUser()">Create Account</button>
|
|
482
|
+
</form>
|
|
483
|
+
|
|
484
|
+
<button type="button" class="auth-guest-btn" onclick="closeAuthModal()">Continue as Guest</button>
|
|
485
|
+
</div>
|
|
486
|
+
</div>
|
|
487
|
+
|
|
488
|
+
<div class="body">
|
|
489
|
+
<aside class="sidebar" id="sidebar">
|
|
490
|
+
|
|
491
|
+
<!-- ── ADD MODELS ── -->
|
|
492
|
+
<div class="sec">
|
|
493
|
+
<div class="sec-hdr" onclick="toggleSec(this)">
|
|
494
|
+
<span class="sec-title"><span class="icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg></span>Add Models</span>
|
|
495
|
+
<span class="chev">▼</span>
|
|
496
|
+
</div>
|
|
497
|
+
<div class="sec-body open">
|
|
498
|
+
<div class="drop-zone" id="drop-zone" onclick="document.getElementById('file-input').click()">
|
|
499
|
+
<div class="dz-icon"><svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="var(--acc)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 20h20"/><path d="M5 20V8l7-5 7 5v12"/><path d="M9 20v-4h6v4"/><path d="M9 12h6"/><path d="M12 8v4"/></svg></div>
|
|
500
|
+
<div class="dz-text"><strong>Drop models here</strong><br>or click to browse</div>
|
|
501
|
+
</div>
|
|
502
|
+
<input type="file" id="file-input" accept=".glb,.gltf,.fbx,.obj,.ifc,.ply,.stl" multiple>
|
|
503
|
+
<div class="ig">
|
|
504
|
+
<label class="lbl">Load from URL</label>
|
|
505
|
+
<input class="input" type="text" id="model-url" placeholder="https://example.com/model.glb">
|
|
506
|
+
</div>
|
|
507
|
+
<button class="btn btn-acc mb6" onclick="loadFromUrl()">↗ Load URL</button>
|
|
508
|
+
<button class="btn btn-sec" onclick="loadSample()">Load Sample Building</button>
|
|
509
|
+
<div class="hint mt6">Load multiple models to overlay</div>
|
|
510
|
+
<div class="badges">
|
|
511
|
+
<span class="badge bg-g">GLTF/GLB</span>
|
|
512
|
+
<span class="badge bg-g">FBX</span>
|
|
513
|
+
<span class="badge bg-g">OBJ</span>
|
|
514
|
+
<span class="badge bg-g">IFC</span>
|
|
515
|
+
<span class="badge bg-g">PLY</span>
|
|
516
|
+
<span class="badge bg-g">STL</span>
|
|
517
|
+
</div>
|
|
518
|
+
</div>
|
|
519
|
+
</div>
|
|
520
|
+
|
|
521
|
+
<!-- ── SELECTION TREE ── -->
|
|
522
|
+
<div class="sec">
|
|
523
|
+
<div class="sec-hdr" onclick="toggleSec(this)">
|
|
524
|
+
<span class="sec-title"><span class="icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3v12"/><path d="M18 9a3 3 0 100-6 3 3 0 000 6z"/><path d="M6 21a3 3 0 100-6 3 3 0 000 6z"/><path d="M18 9a9 9 0 01-9 9"/></svg></span>Selection Tree</span>
|
|
525
|
+
<span class="chev">▶</span>
|
|
526
|
+
</div>
|
|
527
|
+
<div class="sec-body">
|
|
528
|
+
<div class="tree-summary" id="tree-summary">No models loaded</div>
|
|
529
|
+
<div class="tree-list" id="tree-list"></div>
|
|
530
|
+
</div>
|
|
531
|
+
</div>
|
|
532
|
+
|
|
533
|
+
<!-- ── VIEWPOINTS ── -->
|
|
534
|
+
<div class="sec">
|
|
535
|
+
<div class="sec-hdr" onclick="toggleSec(this)">
|
|
536
|
+
<span class="sec-title"><span class="icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z"/><circle cx="12" cy="10" r="3"/></svg></span>Viewpoints</span>
|
|
537
|
+
<span class="chev">▶</span>
|
|
538
|
+
</div>
|
|
539
|
+
<div class="sec-body">
|
|
540
|
+
<button class="btn btn-blue mb6" onclick="saveViewpoint()">Save Viewpoint</button>
|
|
541
|
+
<button class="btn btn-sec mb6" id="btn-export-bcf" onclick="exportBCF()" style="display:none;">Export BCF</button>
|
|
542
|
+
<div class="vp-list" id="vp-list"></div>
|
|
543
|
+
</div>
|
|
544
|
+
</div>
|
|
545
|
+
|
|
546
|
+
<!-- ── SETTINGS / UNITS ── -->
|
|
547
|
+
<div class="sec">
|
|
548
|
+
<div class="sec-hdr" onclick="toggleSec(this)">
|
|
549
|
+
<span class="sec-title"><span class="icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg></span>Units & Settings</span>
|
|
550
|
+
<span class="chev">▶</span>
|
|
551
|
+
</div>
|
|
552
|
+
<div class="sec-body">
|
|
553
|
+
<div class="ig">
|
|
554
|
+
<label class="lbl">Model native units</label>
|
|
555
|
+
<select class="select" id="model-unit" onchange="unitChanged()">
|
|
556
|
+
<option value="m">Meters (m)</option>
|
|
557
|
+
<option value="mm">Millimeters (mm)</option>
|
|
558
|
+
<option value="ft">Feet (ft)</option>
|
|
559
|
+
<option value="in">Inches (in)</option>
|
|
560
|
+
</select>
|
|
561
|
+
</div>
|
|
562
|
+
<div class="ig">
|
|
563
|
+
<label class="lbl">Display measurements as</label>
|
|
564
|
+
<select class="select" id="display-unit">
|
|
565
|
+
<option value="fi">Feet-Inches (US AEC)</option>
|
|
566
|
+
<option value="m">Decimal Meters</option>
|
|
567
|
+
<option value="mm">Millimeters</option>
|
|
568
|
+
<option value="ft">Decimal Feet</option>
|
|
569
|
+
</select>
|
|
570
|
+
</div>
|
|
571
|
+
<div class="hint">Set model units before measuring for accurate results.</div>
|
|
572
|
+
</div>
|
|
573
|
+
</div>
|
|
574
|
+
|
|
575
|
+
<!-- ── MEASURE ── -->
|
|
576
|
+
<div class="sec">
|
|
577
|
+
<div class="sec-hdr" onclick="toggleSec(this)">
|
|
578
|
+
<span class="sec-title"><span class="icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 2l20 20"/><path d="M6.5 6.5l3-3"/><path d="M10.5 10.5l1-1"/><path d="M14.5 14.5l3-3"/><path d="M18.5 18.5l1-1"/></svg></span>Measure</span>
|
|
579
|
+
<span class="chev">▶</span>
|
|
580
|
+
</div>
|
|
581
|
+
<div class="sec-body">
|
|
582
|
+
<div class="meas-state" id="meas-state-msg"></div>
|
|
583
|
+
<div class="meas-display" id="meas-display">
|
|
584
|
+
<div class="meas-total" id="meas-total">—</div>
|
|
585
|
+
<div class="meas-components">
|
|
586
|
+
<div class="meas-comp">
|
|
587
|
+
<div class="meas-axis mx">ΔX</div>
|
|
588
|
+
<div class="meas-val" id="meas-dx">—</div>
|
|
589
|
+
</div>
|
|
590
|
+
<div class="meas-comp">
|
|
591
|
+
<div class="meas-axis my">ΔY</div>
|
|
592
|
+
<div class="meas-val" id="meas-dy">—</div>
|
|
593
|
+
</div>
|
|
594
|
+
<div class="meas-comp">
|
|
595
|
+
<div class="meas-axis mz">ΔZ</div>
|
|
596
|
+
<div class="meas-val" id="meas-dz">—</div>
|
|
597
|
+
</div>
|
|
598
|
+
</div>
|
|
599
|
+
</div>
|
|
600
|
+
<button class="btn btn-yel mb6" id="meas-btn" onclick="startMeasure()">Click Two Points</button>
|
|
601
|
+
<button class="btn btn-sec" onclick="clearMeasure()">✕ Clear Measurement</button>
|
|
602
|
+
<div class="hint mt8">Click a point on the model, then click a second point. Escape cancels.</div>
|
|
603
|
+
</div>
|
|
604
|
+
</div>
|
|
605
|
+
|
|
606
|
+
<!-- ── SECTION ── -->
|
|
607
|
+
<div class="sec">
|
|
608
|
+
<div class="sec-hdr" onclick="toggleSec(this)">
|
|
609
|
+
<span class="sec-title"><span class="icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/><line x1="14.47" y1="14.48" x2="20" y2="20"/><line x1="8.12" y1="8.12" x2="12" y2="12"/></svg></span>Section Cut</span>
|
|
610
|
+
<span class="chev">▶</span>
|
|
611
|
+
</div>
|
|
612
|
+
<div class="sec-body">
|
|
613
|
+
<div class="hint mb8">Slide to clip the model. ⇄ flips cut direction.</div>
|
|
614
|
+
<div class="slider-row">
|
|
615
|
+
<span class="slider-lbl" style="color:var(--red)">X</span>
|
|
616
|
+
<input type="range" class="slider sx" id="clip-x" min="0" max="100" value="0" oninput="updateClip('x',this.value)">
|
|
617
|
+
<span class="slider-val" id="clip-x-val">0%</span>
|
|
618
|
+
<button id="flip-x" onclick="toggleFlip('x')" title="Flip cut direction" style="width:24px;height:24px;padding:0;background:var(--card);border:1px solid var(--bdr);border-radius:5px;cursor:pointer;font-size:12px;color:var(--muted);flex-shrink:0;transition:all .15s;display:flex;align-items:center;justify-content:center">⇄</button>
|
|
619
|
+
</div>
|
|
620
|
+
<div class="slider-row">
|
|
621
|
+
<span class="slider-lbl" style="color:var(--grn)">Y</span>
|
|
622
|
+
<input type="range" class="slider sy" id="clip-y" min="0" max="100" value="0" oninput="updateClip('y',this.value)">
|
|
623
|
+
<span class="slider-val" id="clip-y-val">0%</span>
|
|
624
|
+
<button id="flip-y" onclick="toggleFlip('y')" title="Flip cut direction" style="width:24px;height:24px;padding:0;background:var(--card);border:1px solid var(--bdr);border-radius:5px;cursor:pointer;font-size:12px;color:var(--muted);flex-shrink:0;transition:all .15s;display:flex;align-items:center;justify-content:center">⇄</button>
|
|
625
|
+
</div>
|
|
626
|
+
<div class="slider-row">
|
|
627
|
+
<span class="slider-lbl" style="color:var(--blue)">Z</span>
|
|
628
|
+
<input type="range" class="slider sz" id="clip-z" min="0" max="100" value="0" oninput="updateClip('z',this.value)">
|
|
629
|
+
<span class="slider-val" id="clip-z-val">0%</span>
|
|
630
|
+
<button id="flip-z" onclick="toggleFlip('z')" title="Flip cut direction" style="width:24px;height:24px;padding:0;background:var(--card);border:1px solid var(--bdr);border-radius:5px;cursor:pointer;font-size:12px;color:var(--muted);flex-shrink:0;transition:all .15s;display:flex;align-items:center;justify-content:center">⇄</button>
|
|
631
|
+
</div>
|
|
632
|
+
<button class="btn btn-sec mt6" onclick="resetSection()">↺ Reset All Cuts</button>
|
|
633
|
+
</div>
|
|
634
|
+
</div>
|
|
635
|
+
|
|
636
|
+
<!-- ── MARKUP & ANNOTATIONS ── -->
|
|
637
|
+
<div class="sec">
|
|
638
|
+
<div class="sec-hdr" onclick="toggleSec(this)">
|
|
639
|
+
<span class="sec-title"><span class="icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19l7-7 3 3-7 7-3-3z"/><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/><path d="M2 2l7.586 7.586"/><circle cx="11" cy="11" r="2"/></svg></span>Markup & Annotations</span>
|
|
640
|
+
<span class="chev">▶</span>
|
|
641
|
+
</div>
|
|
642
|
+
<div class="sec-body">
|
|
643
|
+
<div class="ig">
|
|
644
|
+
<label class="lbl">Tool</label>
|
|
645
|
+
<select class="select" id="markup-tool" onchange="setMarkupTool(this.value)">
|
|
646
|
+
<option value="circle">⭕ Circle</option>
|
|
647
|
+
<option value="rect">▭ Rectangle</option>
|
|
648
|
+
<option value="arrow">→ Arrow</option>
|
|
649
|
+
<option value="text">T Text Box</option>
|
|
650
|
+
</select>
|
|
651
|
+
</div>
|
|
652
|
+
<div class="ig">
|
|
653
|
+
<label class="lbl">Color</label>
|
|
654
|
+
<div style="display:grid;grid-template-columns:repeat(5,1fr);gap:4px">
|
|
655
|
+
<button class="color-btn" style="background:#ef4444" onclick="setMarkupColor('#ef4444')" title="Red"></button>
|
|
656
|
+
<button class="color-btn" style="background:#fbbf24" onclick="setMarkupColor('#fbbf24')" title="Yellow"></button>
|
|
657
|
+
<button class="color-btn" style="background:#22c55e" onclick="setMarkupColor('#22c55e')" title="Green"></button>
|
|
658
|
+
<button class="color-btn" style="background:#3b82f6" onclick="setMarkupColor('#3b82f6')" title="Blue"></button>
|
|
659
|
+
<button class="color-btn" style="background:#ffffff" onclick="setMarkupColor('#ffffff')" title="White"></button>
|
|
660
|
+
</div>
|
|
661
|
+
</div>
|
|
662
|
+
<div class="ig">
|
|
663
|
+
<label class="lbl">Line Width</label>
|
|
664
|
+
<div style="display:flex;gap:4px">
|
|
665
|
+
<button class="btn btn-sec btn-sm" onclick="setMarkupWidth(2)" style="flex:1">Thin</button>
|
|
666
|
+
<button class="btn btn-sec btn-sm" onclick="setMarkupWidth(4)" style="flex:1">Medium</button>
|
|
667
|
+
<button class="btn btn-sec btn-sm" onclick="setMarkupWidth(6)" style="flex:1">Thick</button>
|
|
668
|
+
</div>
|
|
669
|
+
</div>
|
|
670
|
+
<div class="btn-row mb6">
|
|
671
|
+
<button class="btn btn-acc" id="markup-toggle-btn" onclick="toggleMarkup()">✎ Enable Markup</button>
|
|
672
|
+
<button class="btn btn-sec" onclick="clearMarkup()">✕ Clear All</button>
|
|
673
|
+
</div>
|
|
674
|
+
<button class="btn btn-sec mb6" onclick="exportMarkupPNG()">Export as PNG</button>
|
|
675
|
+
<div class="hint">Draw annotations directly on the 3D view. Tap or click to draw shapes.</div>
|
|
676
|
+
</div>
|
|
677
|
+
</div>
|
|
678
|
+
|
|
679
|
+
<!-- ── APPEARANCE ── -->
|
|
680
|
+
<div class="sec">
|
|
681
|
+
<div class="sec-hdr" onclick="toggleSec(this)">
|
|
682
|
+
<span class="sec-title"><span class="icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="13.5" cy="6.5" r="2.5"/><circle cx="17.5" cy="10.5" r="2.5"/><circle cx="8.5" cy="7.5" r="2.5"/><circle cx="6.5" cy="12.5" r="2.5"/><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 011.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z"/></svg></span>Appearance</span>
|
|
683
|
+
<span class="chev">▶</span>
|
|
684
|
+
</div>
|
|
685
|
+
<div class="sec-body">
|
|
686
|
+
<div class="lbl">Selected: <span id="appearance-sel" style="color:var(--acc)">All Models</span></div>
|
|
687
|
+
<div class="ig">
|
|
688
|
+
<label class="lbl">Model color override</label>
|
|
689
|
+
<div class="color-row">
|
|
690
|
+
<label class="color-swatch">
|
|
691
|
+
<input type="color" id="model-color" value="#cccccc" oninput="applyColor(this.value)">
|
|
692
|
+
</label>
|
|
693
|
+
<span class="color-label">Click swatch to change color</span>
|
|
694
|
+
<button class="btn btn-sec btn-sm" onclick="resetColor()">Reset</button>
|
|
695
|
+
</div>
|
|
696
|
+
</div>
|
|
697
|
+
<div class="ig">
|
|
698
|
+
<label class="lbl">Opacity — <span id="opacity-pct">100%</span></label>
|
|
699
|
+
<div class="slider-row" style="margin-bottom:4px">
|
|
700
|
+
<input type="range" class="slider so" id="opacity" min="0" max="100" value="100" style="flex:1" oninput="applyOpacity(this.value)">
|
|
701
|
+
<button class="btn btn-sec btn-sm" onclick="resetOpacity()" style="white-space:nowrap">Reset</button>
|
|
702
|
+
</div>
|
|
703
|
+
</div>
|
|
704
|
+
<div class="divider"></div>
|
|
705
|
+
<div class="btn-row">
|
|
706
|
+
<button class="btn btn-sec btn-sm" onclick="toggleGrid()">Grid</button>
|
|
707
|
+
<button class="btn btn-sec btn-sm" onclick="toggleWire()">Wire</button>
|
|
708
|
+
<button class="btn btn-sec btn-sm" onclick="resetCam()">Reset</button>
|
|
709
|
+
</div>
|
|
710
|
+
</div>
|
|
711
|
+
</div>
|
|
712
|
+
|
|
713
|
+
<!-- ── ENVIRONMENT ── -->
|
|
714
|
+
<div class="sec">
|
|
715
|
+
<div class="sec-hdr" onclick="toggleSec(this)">
|
|
716
|
+
<span class="sec-title"><span class="icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg></span>Environment</span>
|
|
717
|
+
<span class="chev">▶</span>
|
|
718
|
+
</div>
|
|
719
|
+
<div class="sec-body">
|
|
720
|
+
<label class="lbl">Theme Preset</label>
|
|
721
|
+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-bottom:12px;">
|
|
722
|
+
<button class="btn btn-sm btn-sec env-btn" data-theme="dark" onclick="applyEnvTheme('dark')">
|
|
723
|
+
<span style="display:inline-block;width:14px;height:14px;border-radius:50%;background:#0d1117;border:1px solid var(--bdr);margin-right:4px;vertical-align:middle;"></span>Dark
|
|
724
|
+
</button>
|
|
725
|
+
<button class="btn btn-sm btn-sec env-btn" data-theme="daylight" onclick="applyEnvTheme('daylight')">
|
|
726
|
+
<span style="display:inline-block;width:14px;height:14px;border-radius:50%;background:#87CEEB;border:1px solid var(--bdr);margin-right:4px;vertical-align:middle;"></span>Daylight
|
|
727
|
+
</button>
|
|
728
|
+
<button class="btn btn-sm btn-sec env-btn" data-theme="sunset" onclick="applyEnvTheme('sunset')">
|
|
729
|
+
<span style="display:inline-block;width:14px;height:14px;border-radius:50%;background:#ff7744;border:1px solid var(--bdr);margin-right:4px;vertical-align:middle;"></span>Sunset
|
|
730
|
+
</button>
|
|
731
|
+
<button class="btn btn-sm btn-sec env-btn" data-theme="night" onclick="applyEnvTheme('night')">
|
|
732
|
+
<span style="display:inline-block;width:14px;height:14px;border-radius:50%;background:#0a0a1a;border:1px solid var(--bdr);margin-right:4px;vertical-align:middle;"></span>Night
|
|
733
|
+
</button>
|
|
734
|
+
<button class="btn btn-sm btn-sec env-btn" data-theme="overcast" onclick="applyEnvTheme('overcast')">
|
|
735
|
+
<span style="display:inline-block;width:14px;height:14px;border-radius:50%;background:#606068;border:1px solid var(--bdr);margin-right:4px;vertical-align:middle;"></span>Overcast
|
|
736
|
+
</button>
|
|
737
|
+
<button class="btn btn-sm btn-sec env-btn" data-theme="studio" onclick="applyEnvTheme('studio')">
|
|
738
|
+
<span style="display:inline-block;width:14px;height:14px;border-radius:50%;background:#e8e8e8;border:1px solid var(--bdr);margin-right:4px;vertical-align:middle;"></span>Studio
|
|
739
|
+
</button>
|
|
740
|
+
</div>
|
|
741
|
+
<div class="divider"></div>
|
|
742
|
+
<label class="lbl">Sun Position</label>
|
|
743
|
+
<div class="ig">
|
|
744
|
+
<label class="lbl">Sun Azimuth</label>
|
|
745
|
+
<div class="slider-row">
|
|
746
|
+
<input type="range" class="slider so" id="sun-azimuth" min="0" max="360" value="45" oninput="sunAzimuth=+this.value;updateSunPosition();document.getElementById('sun-az-val').textContent=this.value+'°'">
|
|
747
|
+
<span class="slider-val" id="sun-az-val">45°</span>
|
|
748
|
+
</div>
|
|
749
|
+
</div>
|
|
750
|
+
<div class="ig">
|
|
751
|
+
<label class="lbl">Sun Elevation</label>
|
|
752
|
+
<div class="slider-row">
|
|
753
|
+
<input type="range" class="slider so" id="sun-elevation" min="0" max="90" value="60" oninput="sunElevation=+this.value;updateSunPosition();document.getElementById('sun-el-val').textContent=this.value+'°'">
|
|
754
|
+
<span class="slider-val" id="sun-el-val">60°</span>
|
|
755
|
+
</div>
|
|
756
|
+
</div>
|
|
757
|
+
<div class="ig">
|
|
758
|
+
<label class="lbl">Ambient Light — <span id="ambient-pct">40%</span></label>
|
|
759
|
+
<div class="slider-row">
|
|
760
|
+
<input type="range" class="slider so" id="ambient-intensity" min="0" max="200" value="40" oninput="updateAmbientLight(this.value)">
|
|
761
|
+
</div>
|
|
762
|
+
</div>
|
|
763
|
+
<div class="btn-row">
|
|
764
|
+
<label class="chk-label">
|
|
765
|
+
<input type="checkbox" id="sun-auto-rotate" onchange="sunAutoRotate = this.checked">
|
|
766
|
+
<span>Auto-rotate sun</span>
|
|
767
|
+
</label>
|
|
768
|
+
</div>
|
|
769
|
+
</div>
|
|
770
|
+
</div>
|
|
771
|
+
|
|
772
|
+
<!-- ── CAPTURE ── -->
|
|
773
|
+
<div class="sec">
|
|
774
|
+
<div class="sec-hdr" onclick="toggleSec(this)">
|
|
775
|
+
<span class="sec-title"><span class="icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M23 19a2 2 0 01-2 2H3a2 2 0 01-2-2V8a2 2 0 012-2h4l2-3h6l2 3h4a2 2 0 012 2z"/><circle cx="12" cy="13" r="4"/></svg></span>Capture View</span>
|
|
776
|
+
<span class="chev">▶</span>
|
|
777
|
+
</div>
|
|
778
|
+
<div class="sec-body">
|
|
779
|
+
<p class="hint mb8">Navigate to a coordination zone, MEP room, or detail — then capture.</p>
|
|
780
|
+
<div class="banner bn-grn" id="cap-banner">✓ View captured for QR</div>
|
|
781
|
+
<button class="btn btn-blue mb6" onclick="captureView()">Capture for QR</button>
|
|
782
|
+
<div class="btn-row">
|
|
783
|
+
<button class="btn btn-sec btn-sm" onclick="saveViewpoint()">Save Viewpoint</button>
|
|
784
|
+
<button class="btn btn-sec btn-sm" onclick="screenshotView()">Screenshot</button>
|
|
785
|
+
</div>
|
|
786
|
+
</div>
|
|
787
|
+
</div>
|
|
788
|
+
|
|
789
|
+
<!-- ── PROJECT ── -->
|
|
790
|
+
<div class="sec">
|
|
791
|
+
<div class="sec-hdr" onclick="toggleSec(this)">
|
|
792
|
+
<span class="sec-title"><span class="icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg></span>Project</span>
|
|
793
|
+
<span class="chev">▶</span>
|
|
794
|
+
</div>
|
|
795
|
+
<div class="sec-body">
|
|
796
|
+
<p class="hint mb8">A <strong style="color:var(--txt)">.scanbim</strong> project bundles your loaded models, saved viewpoints, QR codes, and all settings into one file.</p>
|
|
797
|
+
<div class="info-card mb8" id="project-summary">
|
|
798
|
+
<div class="ir"><span class="ik">Models</span><span class="iv" id="proj-models">0</span></div>
|
|
799
|
+
<div class="ir"><span class="ik">Viewpoints</span><span class="iv" id="proj-vps">0</span></div>
|
|
800
|
+
<div class="ir"><span class="ik">QR Codes</span><span class="iv" id="proj-qrs">0</span></div>
|
|
801
|
+
</div>
|
|
802
|
+
<div class="btn-row mb6">
|
|
803
|
+
<button class="btn btn-acc btn-sm" onclick="saveProject()">Save Project</button>
|
|
804
|
+
<button class="btn btn-sec btn-sm" onclick="loadProject()">Open Project</button>
|
|
805
|
+
</div>
|
|
806
|
+
<button class="btn btn-sec mb6" onclick="document.getElementById('file-input').click()">+ Add More Models</button>
|
|
807
|
+
<input type="file" id="project-file" accept=".scanbim,.json" style="display:none">
|
|
808
|
+
<div class="hint">Project files save references to model URLs — local files need to be re-added after loading.</div>
|
|
809
|
+
</div>
|
|
810
|
+
</div>
|
|
811
|
+
|
|
812
|
+
<!-- ── QR ── -->
|
|
813
|
+
<div class="sec">
|
|
814
|
+
<div class="sec-hdr" onclick="toggleSec(this)">
|
|
815
|
+
<span class="sec-title"><span class="icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="3" height="3"/><line x1="21" y1="14" x2="21" y2="14.01"/><line x1="21" y1="21" x2="21" y2="21.01"/></svg></span>QR Code — Share Views</span>
|
|
816
|
+
<span class="chev">▶</span>
|
|
817
|
+
</div>
|
|
818
|
+
<div class="sec-body">
|
|
819
|
+
<div id="qr-status-hosted" class="banner bn-grn" style="display:none;margin-bottom:10px">
|
|
820
|
+
✓ <strong>Ready to share!</strong> App is hosted — QR codes will work for anyone.
|
|
821
|
+
</div>
|
|
822
|
+
<div id="qr-status-local" class="banner bn-yel show" style="margin-bottom:10px">
|
|
823
|
+
<strong>To share views via QR:</strong><br>
|
|
824
|
+
1. Put <strong>scanbim.html</strong> + your model files in one folder<br>
|
|
825
|
+
2. Drag folder to <a href="https://app.netlify.com/drop" target="_blank" style="color:var(--acc);text-decoration:underline">netlify.com/drop</a> (free)<br>
|
|
826
|
+
3. Open your new Netlify URL, load a model, generate QR
|
|
827
|
+
</div>
|
|
828
|
+
|
|
829
|
+
<div id="qr-firebase-status" class="banner bn-yel mb6" style="display:none">
|
|
830
|
+
<strong>⚠ Firebase not configured</strong><br>
|
|
831
|
+
To enable cloud sharing:<br>
|
|
832
|
+
1. Create free project at <a href="https://console.firebase.google.com" target="_blank" style="color:var(--acc);text-decoration:underline">console.firebase.google.com</a><br>
|
|
833
|
+
2. Enable Firestore & Storage<br>
|
|
834
|
+
3. Paste config at top of scanbim.html
|
|
835
|
+
</div>
|
|
836
|
+
|
|
837
|
+
<div id="qr-firebase-ready" class="banner bn-grn mb6" style="display:none">
|
|
838
|
+
✓ <strong>Cloud ready!</strong> Firebase configured. Models upload to Storage, views save to Firestore.
|
|
839
|
+
</div>
|
|
840
|
+
|
|
841
|
+
<label class="lbl">Project Name</label>
|
|
842
|
+
<input type="text" id="qr-project-name" class="input mb6" placeholder="e.g. 42 Main St, Phase 2">
|
|
843
|
+
|
|
844
|
+
<div class="btn-row mb6">
|
|
845
|
+
<button class="btn btn-grn" onclick="generateQR()">Generate QR</button>
|
|
846
|
+
<button class="btn btn-acc" onclick="generateAndExportQR()">Export QR PNG</button>
|
|
847
|
+
</div>
|
|
848
|
+
|
|
849
|
+
<div class="qr-box hidden" id="qr-box">
|
|
850
|
+
<div class="qr-sub">Scan to open this exact 3D view on any device</div>
|
|
851
|
+
<div id="qr-code"></div>
|
|
852
|
+
<div class="qr-url" id="qr-url-txt">—</div>
|
|
853
|
+
<div class="btn-row">
|
|
854
|
+
<button class="btn btn-sec btn-sm" onclick="copyUrl()">Copy URL</button>
|
|
855
|
+
<button class="btn btn-sec btn-sm" onclick="dlQr()">Save PNG</button>
|
|
856
|
+
</div>
|
|
857
|
+
</div>
|
|
858
|
+
|
|
859
|
+
<div id="qr-list-container" style="margin-top:12px;display:none">
|
|
860
|
+
<div class="lbl">Recent QRs (this session)</div>
|
|
861
|
+
<div id="qr-list" style="max-height:120px;overflow-y:auto;border:1px solid var(--bdr);border-radius:5px;padding:6px"></div>
|
|
862
|
+
</div>
|
|
863
|
+
|
|
864
|
+
<div class="hint mt8">With Firebase: cloud storage + Firestore sharing. Without: local URLs (requires hosted app).</div>
|
|
865
|
+
</div>
|
|
866
|
+
</div>
|
|
867
|
+
|
|
868
|
+
<!-- ── CONTROLS ── -->
|
|
869
|
+
<div class="sec">
|
|
870
|
+
<div class="sec-hdr" onclick="toggleSec(this)">
|
|
871
|
+
<span class="sec-title"><span class="icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 12h4"/><path d="M8 10v4"/><circle cx="17" cy="10" r="1"/><circle cx="15" cy="14" r="1"/></svg></span>Controls</span>
|
|
872
|
+
<span class="chev">▶</span>
|
|
873
|
+
</div>
|
|
874
|
+
<div class="sec-body">
|
|
875
|
+
<div style="color:var(--muted);font-size:11px;line-height:1.9">
|
|
876
|
+
<div><strong style="color:var(--txt)">Left drag</strong> — Orbit</div>
|
|
877
|
+
<div><strong style="color:var(--txt)">Right drag</strong> — Pan</div>
|
|
878
|
+
<div><strong style="color:var(--txt)">Scroll</strong> — Zoom</div>
|
|
879
|
+
<div><strong style="color:var(--txt)">Two fingers</strong> — Rotate & pinch</div>
|
|
880
|
+
<div><strong style="color:var(--txt)">Esc</strong> — Cancel measure</div>
|
|
881
|
+
</div>
|
|
882
|
+
</div>
|
|
883
|
+
</div>
|
|
884
|
+
|
|
885
|
+
</aside><!-- /sidebar -->
|
|
886
|
+
<div class="sidebar-overlay" id="sidebar-overlay" onclick="closeSidebar()"></div>
|
|
887
|
+
|
|
888
|
+
<!-- ── VIEWPORT ── -->
|
|
889
|
+
<div class="viewport" id="viewport">
|
|
890
|
+
<div id="canvas-wrap"></div>
|
|
891
|
+
<div class="viewcube-container">
|
|
892
|
+
<div class="viewcube-wrapper">
|
|
893
|
+
<canvas id="viewcube-canvas"></canvas>
|
|
894
|
+
<svg class="compass-ring-svg" viewBox="0 0 200 200">
|
|
895
|
+
<defs>
|
|
896
|
+
<linearGradient id="ringGrad" x1="0" y1="0" x2="1" y2="1">
|
|
897
|
+
<stop offset="0%" stop-color="rgba(255,255,255,.18)"/>
|
|
898
|
+
<stop offset="50%" stop-color="rgba(255,255,255,.08)"/>
|
|
899
|
+
<stop offset="100%" stop-color="rgba(255,255,255,.18)"/>
|
|
900
|
+
</linearGradient>
|
|
901
|
+
</defs>
|
|
902
|
+
<circle cx="100" cy="100" r="96" fill="none" stroke="url(#ringGrad)" stroke-width="1.5"/>
|
|
903
|
+
<circle cx="100" cy="100" r="96" fill="none" stroke="rgba(255,255,255,.04)" stroke-width="8"/>
|
|
904
|
+
<!-- Cardinal ticks -->
|
|
905
|
+
<line x1="100" y1="1" x2="100" y2="9" stroke="rgba(255,200,180,.35)" stroke-width="1.5" stroke-linecap="round"/>
|
|
906
|
+
<line x1="100" y1="191" x2="100" y2="199" stroke="rgba(255,255,255,.15)" stroke-width="1" stroke-linecap="round"/>
|
|
907
|
+
<line x1="1" y1="100" x2="9" y2="100" stroke="rgba(255,255,255,.15)" stroke-width="1" stroke-linecap="round"/>
|
|
908
|
+
<line x1="191" y1="100" x2="199" y2="100" stroke="rgba(255,255,255,.15)" stroke-width="1" stroke-linecap="round"/>
|
|
909
|
+
<!-- Intercardinal ticks -->
|
|
910
|
+
<line x1="32" y1="32" x2="37" y2="37" stroke="rgba(255,255,255,.08)" stroke-width="1" stroke-linecap="round"/>
|
|
911
|
+
<line x1="168" y1="32" x2="163" y2="37" stroke="rgba(255,255,255,.08)" stroke-width="1" stroke-linecap="round"/>
|
|
912
|
+
<line x1="32" y1="168" x2="37" y2="163" stroke="rgba(255,255,255,.08)" stroke-width="1" stroke-linecap="round"/>
|
|
913
|
+
<line x1="168" y1="168" x2="163" y2="163" stroke="rgba(255,255,255,.08)" stroke-width="1" stroke-linecap="round"/>
|
|
914
|
+
</svg>
|
|
915
|
+
<div class="compass-dir n" data-dir="north">N</div>
|
|
916
|
+
<div class="compass-dir s" data-dir="south">S</div>
|
|
917
|
+
<div class="compass-dir e" data-dir="east">E</div>
|
|
918
|
+
<div class="compass-dir w" data-dir="west">W</div>
|
|
919
|
+
</div>
|
|
920
|
+
<div class="vc-view-label" id="vc-view-label">SE Isometric</div>
|
|
921
|
+
<button class="vc-home-btn" id="vc-home-btn" title="Reset to Home View"><svg viewBox="0 0 24 24"><path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>Home</button>
|
|
922
|
+
</div>
|
|
923
|
+
<canvas id="markup-canvas" style="position:absolute;top:0;left:0;z-index:5;pointer-events:none"></canvas>
|
|
924
|
+
<div class="welcome" id="welcome">
|
|
925
|
+
<div class="welcome-icon">
|
|
926
|
+
<svg width="88" height="88" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
927
|
+
<g stroke="rgba(249,115,22,.35)" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">
|
|
928
|
+
<path d="M14 3L25 9v10L14 25 3 19V9z"/>
|
|
929
|
+
<path d="M14 3v11"/>
|
|
930
|
+
<path d="M14 14l11-5"/>
|
|
931
|
+
<path d="M14 14L3 9"/>
|
|
932
|
+
<path d="M14 14v11" opacity=".45"/>
|
|
933
|
+
</g>
|
|
934
|
+
<circle cx="14" cy="3" r="1.6" fill="rgba(249,115,22,.35)"/>
|
|
935
|
+
<circle cx="25" cy="9" r="1.6" fill="rgba(249,115,22,.35)"/>
|
|
936
|
+
<circle cx="3" cy="9" r="1.6" fill="rgba(249,115,22,.35)"/>
|
|
937
|
+
<circle cx="14" cy="14" r="1.8" fill="rgba(249,115,22,.4)"/>
|
|
938
|
+
<circle cx="25" cy="19" r="1.3" fill="rgba(249,115,22,.2)"/>
|
|
939
|
+
<circle cx="3" cy="19" r="1.3" fill="rgba(249,115,22,.2)"/>
|
|
940
|
+
<circle cx="14" cy="25" r="1.3" fill="rgba(249,115,22,.2)"/>
|
|
941
|
+
</svg>
|
|
942
|
+
</div>
|
|
943
|
+
<div class="welcome-title">ScanBIM 3D</div>
|
|
944
|
+
<div class="welcome-sub">Drop a model or click Add Models to begin</div>
|
|
945
|
+
</div>
|
|
946
|
+
<div class="drop-over" id="drop-over">Drop model file here</div>
|
|
947
|
+
<div class="loading" id="loading"><div class="spinner"></div><div class="loading-txt" id="loading-txt">Loading...</div></div>
|
|
948
|
+
<div class="hud hud-br" id="cam-hud" style="display:none"></div>
|
|
949
|
+
<div class="hud hud-bl" id="ctrl-hud" style="display:none">Drag: Orbit | Right: Pan | Scroll: Zoom</div>
|
|
950
|
+
<div id="meas-label"></div>
|
|
951
|
+
|
|
952
|
+
<!-- ── BOTTOM TOOLBAR ── -->
|
|
953
|
+
<div class="bottom-toolbar" id="bottom-toolbar">
|
|
954
|
+
<!-- Navigation Group -->
|
|
955
|
+
<button class="toolbar-btn active" id="btn-orbit" title="Orbit" data-tool="orbit">
|
|
956
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-6.22-8.56"/><path d="M21 3v4.5h-4.5"/></svg>
|
|
957
|
+
</button>
|
|
958
|
+
<button class="toolbar-btn" id="btn-pan" title="Pan" data-tool="pan">
|
|
959
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 11V6a2 2 0 00-4 0M14 10V4a2 2 0 00-4 0v7M10 10.5V8a2 2 0 00-4 0v6"/><path d="M18 11a2 2 0 014 0v3a8 8 0 01-8 8h-2c-2.5 0-3.5-.5-5.5-2.5L4 17a2 2 0 012.8-2.8L10 17"/></svg>
|
|
960
|
+
</button>
|
|
961
|
+
<button class="toolbar-btn" id="btn-fit" title="Fit All" data-tool="fit">
|
|
962
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 00-2 2v3"/><path d="M21 8V5a2 2 0 00-2-2h-3"/><path d="M3 16v3a2 2 0 002 2h3"/><path d="M16 21h3a2 2 0 002-2v-3"/></svg>
|
|
963
|
+
</button>
|
|
964
|
+
|
|
965
|
+
<div class="toolbar-divider"></div>
|
|
966
|
+
|
|
967
|
+
<!-- Tools Group -->
|
|
968
|
+
<button class="toolbar-btn" id="btn-measure" title="Measure" data-tool="measure">
|
|
969
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 2l20 20"/><path d="M5.5 5.5l2-2"/><path d="M9.5 9.5l1-1"/><path d="M13.5 13.5l2-2"/><path d="M17.5 17.5l2-2"/></svg>
|
|
970
|
+
</button>
|
|
971
|
+
<button class="toolbar-btn" id="btn-section" title="Section Cut" data-tool="section">
|
|
972
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="1"/><line x1="4" y1="12" x2="20" y2="12" stroke-dasharray="3 2"/><path d="M4 4l16 16" opacity=".3"/></svg>
|
|
973
|
+
</button>
|
|
974
|
+
<button class="toolbar-btn" id="btn-markup" title="Markup" data-tool="markup">
|
|
975
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.83 2.83 0 114 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>
|
|
976
|
+
</button>
|
|
977
|
+
|
|
978
|
+
<div class="toolbar-divider"></div>
|
|
979
|
+
|
|
980
|
+
<!-- View Group -->
|
|
981
|
+
<button class="toolbar-btn" id="btn-grid" title="Grid" data-tool="grid">
|
|
982
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M3 15h18M9 3v18M15 3v18"/></svg>
|
|
983
|
+
</button>
|
|
984
|
+
<button class="toolbar-btn" id="btn-wireframe" title="Wireframe" data-tool="wireframe">
|
|
985
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l10 6v8l-10 6L2 16V8z"/><path d="M12 8l10 4M12 8L2 12"/><path d="M12 8v14"/></svg>
|
|
986
|
+
</button>
|
|
987
|
+
<button class="toolbar-btn" id="btn-screenshot" title="Screenshot" data-tool="screenshot">
|
|
988
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M23 19a2 2 0 01-2 2H3a2 2 0 01-2-2V8a2 2 0 012-2h4l2-3h6l2 3h4a2 2 0 012 2z"/><circle cx="12" cy="13" r="4"/></svg>
|
|
989
|
+
</button>
|
|
990
|
+
</div>
|
|
991
|
+
</div>
|
|
992
|
+
</div>
|
|
993
|
+
|
|
994
|
+
<div class="statusbar">
|
|
995
|
+
<div class="si"><div class="dot" id="dot-model"></div><span id="st-model">No model loaded</span></div>
|
|
996
|
+
<div class="si"><div class="dot" id="dot-meas"></div><span id="st-meas">No measurement</span></div>
|
|
997
|
+
<div class="si"><div class="dot" id="dot-qr"></div><span id="st-qr">No view captured</span></div>
|
|
998
|
+
<div class="si" style="margin-left:auto"><span id="st-fps" style="font-family:monospace"></span></div>
|
|
999
|
+
</div>
|
|
1000
|
+
</div>
|
|
1001
|
+
<div class="toast" id="toast"></div>
|
|
1002
|
+
|
|
1003
|
+
<!-- ════════════════════════════════════════════════════════════
|
|
1004
|
+
SCANBIM v1.0 — Multi-Model AEC 3D Viewer w/ Cloud Sharing
|
|
1005
|
+
═════════════════════════════════════════════════════════════ -->
|
|
1006
|
+
<script>
|
|
1007
|
+
// ── FIREBASE CONFIG ─────────────────────────────────────────
|
|
1008
|
+
// To enable cloud sharing: create a free Firebase project at console.firebase.google.com
|
|
1009
|
+
// Enable Firestore Database and Storage, then paste your config below.
|
|
1010
|
+
var FIREBASE_CONFIG = {
|
|
1011
|
+
apiKey: "AIzaSyCbEh3lK2gwRyVp4uLMgnbu-J8lGe7KOZg",
|
|
1012
|
+
authDomain: "scanbim-ad819.firebaseapp.com",
|
|
1013
|
+
projectId: "scanbim-ad819",
|
|
1014
|
+
storageBucket: "scanbim-ad819.firebasestorage.app",
|
|
1015
|
+
messagingSenderId: "72275079857",
|
|
1016
|
+
appId: "1:72275079857:web:4a5a69192979b0f1f02890"
|
|
1017
|
+
};
|
|
1018
|
+
var db = null, storage = null, firebaseReady = false;
|
|
1019
|
+
var auth = null, currentUser = null;
|
|
1020
|
+
try {
|
|
1021
|
+
if (FIREBASE_CONFIG.apiKey) {
|
|
1022
|
+
firebase.initializeApp(FIREBASE_CONFIG);
|
|
1023
|
+
db = firebase.firestore();
|
|
1024
|
+
storage = firebase.storage();
|
|
1025
|
+
auth = firebase.auth();
|
|
1026
|
+
firebaseReady = true;
|
|
1027
|
+
}
|
|
1028
|
+
} catch(e) { console.warn('Firebase init failed:', e); }
|
|
1029
|
+
|
|
1030
|
+
// ── GLOBALS ──────────────────────────────────────────────────
|
|
1031
|
+
var scene, camera, renderer, controls;
|
|
1032
|
+
var model = null, grid, gridOn = true, wireOn = false;
|
|
1033
|
+
var capturedView = null, modelUrl = '', modelIsLocal = false;
|
|
1034
|
+
var normalizedToModelScale = 1;
|
|
1035
|
+
var modelBBox = null;
|
|
1036
|
+
var fpsLast = performance.now(), frames = 0;
|
|
1037
|
+
var raycaster = new THREE.Raycaster();
|
|
1038
|
+
var mouse = new THREE.Vector2();
|
|
1039
|
+
var mouseDownXY = {x:0, y:0};
|
|
1040
|
+
var qrCodeInstance = null;
|
|
1041
|
+
|
|
1042
|
+
// ── ENVIRONMENT & LIGHTING ───────────────────────────────────
|
|
1043
|
+
var ambientLight, dirLight, hemiLight;
|
|
1044
|
+
var currentEnvTheme = 'dark';
|
|
1045
|
+
var sunAzimuth = 45, sunElevation = 60;
|
|
1046
|
+
var sunAutoRotate = false;
|
|
1047
|
+
|
|
1048
|
+
// ── ENVIRONMENT THEME PRESETS ───────────────────────────────
|
|
1049
|
+
var envThemes = {
|
|
1050
|
+
dark: {
|
|
1051
|
+
name: 'Default Dark',
|
|
1052
|
+
bgColor: 0x0d1117,
|
|
1053
|
+
ambientColor: 0x404040, ambientIntensity: 0.4,
|
|
1054
|
+
dirColor: 0xffffff, dirIntensity: 0.8,
|
|
1055
|
+
hemiSky: 0x404040, hemiGround: 0x202020, hemiIntensity: 0.3,
|
|
1056
|
+
sunAzimuth: 45, sunElevation: 60
|
|
1057
|
+
},
|
|
1058
|
+
daylight: {
|
|
1059
|
+
name: 'Daylight',
|
|
1060
|
+
bgColor: 0x87CEEB,
|
|
1061
|
+
ambientColor: 0x8899aa, ambientIntensity: 0.5,
|
|
1062
|
+
dirColor: 0xfff5e0, dirIntensity: 1.2,
|
|
1063
|
+
hemiSky: 0x87CEEB, hemiGround: 0x8B7355, hemiIntensity: 0.4,
|
|
1064
|
+
sunAzimuth: 135, sunElevation: 65
|
|
1065
|
+
},
|
|
1066
|
+
sunset: {
|
|
1067
|
+
name: 'Sunset',
|
|
1068
|
+
bgColor: 0x3b1a0a,
|
|
1069
|
+
ambientColor: 0x804020, ambientIntensity: 0.35,
|
|
1070
|
+
dirColor: 0xff9944, dirIntensity: 1.1,
|
|
1071
|
+
hemiSky: 0xff6633, hemiGround: 0x442200, hemiIntensity: 0.4,
|
|
1072
|
+
sunAzimuth: 270, sunElevation: 10
|
|
1073
|
+
},
|
|
1074
|
+
night: {
|
|
1075
|
+
name: 'Night',
|
|
1076
|
+
bgColor: 0x0a0a1a,
|
|
1077
|
+
ambientColor: 0x111133, ambientIntensity: 0.15,
|
|
1078
|
+
dirColor: 0x8899cc, dirIntensity: 0.3,
|
|
1079
|
+
hemiSky: 0x111122, hemiGround: 0x000000, hemiIntensity: 0.1,
|
|
1080
|
+
sunAzimuth: 180, sunElevation: 30
|
|
1081
|
+
},
|
|
1082
|
+
overcast: {
|
|
1083
|
+
name: 'Overcast',
|
|
1084
|
+
bgColor: 0x606068,
|
|
1085
|
+
ambientColor: 0x999999, ambientIntensity: 0.6,
|
|
1086
|
+
dirColor: 0xcccccc, dirIntensity: 0.5,
|
|
1087
|
+
hemiSky: 0x888888, hemiGround: 0x555555, hemiIntensity: 0.5,
|
|
1088
|
+
sunAzimuth: 90, sunElevation: 50
|
|
1089
|
+
},
|
|
1090
|
+
studio: {
|
|
1091
|
+
name: 'Studio',
|
|
1092
|
+
bgColor: 0xe8e8e8,
|
|
1093
|
+
ambientColor: 0xffffff, ambientIntensity: 0.6,
|
|
1094
|
+
dirColor: 0xffffff, dirIntensity: 0.9,
|
|
1095
|
+
hemiSky: 0xffffff, hemiGround: 0xcccccc, hemiIntensity: 0.5,
|
|
1096
|
+
sunAzimuth: 45, sunElevation: 55
|
|
1097
|
+
}
|
|
1098
|
+
};
|
|
1099
|
+
|
|
1100
|
+
// ── MULTI-MODEL SYSTEM ────────────────────────────────────────
|
|
1101
|
+
var loadedModels = []; // Array of {id, name, format, obj, meshCount, visible, color, opacity, url, origMaterials}
|
|
1102
|
+
var selectedModelId = -1;
|
|
1103
|
+
var nextModelId = 0;
|
|
1104
|
+
|
|
1105
|
+
// ── VIEWPOINTS ────────────────────────────────────────────────
|
|
1106
|
+
var savedViewpoints = [];
|
|
1107
|
+
var nextViewpointId = 0;
|
|
1108
|
+
|
|
1109
|
+
// ── SAVED QR CODES ────────────────────────────────────────────
|
|
1110
|
+
var savedQRCodes = []; // Array of {id, name, viewUrl, viewpoint}
|
|
1111
|
+
var nextQRId = 0;
|
|
1112
|
+
var autoExportQR = false; // Flag to auto-export QR after generation
|
|
1113
|
+
|
|
1114
|
+
// ── PROJECT SUMMARY UPDATE ────────────────────────────────────
|
|
1115
|
+
function updateProjectSummary() {
|
|
1116
|
+
var me = document.getElementById('proj-models');
|
|
1117
|
+
var ve = document.getElementById('proj-vps');
|
|
1118
|
+
var qe = document.getElementById('proj-qrs');
|
|
1119
|
+
if (me) me.textContent = loadedModels.length;
|
|
1120
|
+
if (ve) ve.textContent = savedViewpoints.length;
|
|
1121
|
+
if (qe) qe.textContent = savedQRCodes.length;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// ── AUTHENTICATION ──────────────────────────────────────────
|
|
1125
|
+
// Auth state observer
|
|
1126
|
+
if (auth) {
|
|
1127
|
+
auth.onAuthStateChanged(function(user) {
|
|
1128
|
+
currentUser = user;
|
|
1129
|
+
updateAuthUI();
|
|
1130
|
+
if (user) {
|
|
1131
|
+
closeAuthModal();
|
|
1132
|
+
loadUserTier(function() {
|
|
1133
|
+
// Check if this user has a pending upgrade from Stripe (paid before signing up)
|
|
1134
|
+
checkPendingUpgrade(user.email);
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
function updateAuthUI() {
|
|
1141
|
+
var area = document.getElementById('auth-header-area');
|
|
1142
|
+
if (!area) return;
|
|
1143
|
+
if (currentUser) {
|
|
1144
|
+
var displayName = currentUser.displayName || currentUser.email.split('@')[0];
|
|
1145
|
+
var upgradeBtn = userTier.plan === 'free' ? ' <button class="btn btn-sm btn-grn" onclick="showUpgradeModal()" style="width:auto;padding:3px 8px;font-size:10px;">Upgrade</button>' : '';
|
|
1146
|
+
area.innerHTML = '<span class="user-name" title="' + currentUser.email + '">' + displayName + '</span>' + upgradeBtn + ' <button onclick="showDeleteAccountModal()" class="acct-btn" title="Account Settings">Account</button><button class="btn btn-sm btn-sec" onclick="signOutUser()" style="width:auto;padding:3px 8px;font-size:10px;">Sign Out</button>';
|
|
1147
|
+
} else {
|
|
1148
|
+
area.innerHTML = '<button class="btn btn-sm btn-acc" onclick="showAuthModal()" style="width:auto;padding:4px 12px;">Sign In</button>';
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
function showAuthModal() {
|
|
1153
|
+
document.getElementById('auth-modal').style.display = 'flex';
|
|
1154
|
+
switchAuthTab('signin');
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
function closeAuthModal() {
|
|
1158
|
+
document.getElementById('auth-modal').style.display = 'none';
|
|
1159
|
+
clearAuthError();
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
function switchAuthTab(tab) {
|
|
1163
|
+
var signinForm = document.getElementById('signin-form');
|
|
1164
|
+
var signupForm = document.getElementById('signup-form');
|
|
1165
|
+
var tabSignin = document.getElementById('tab-signin');
|
|
1166
|
+
var tabSignup = document.getElementById('tab-signup');
|
|
1167
|
+
|
|
1168
|
+
if (tab === 'signin') {
|
|
1169
|
+
signinForm.style.display = 'block';
|
|
1170
|
+
signupForm.style.display = 'none';
|
|
1171
|
+
tabSignin.classList.add('auth-tab-active');
|
|
1172
|
+
tabSignup.classList.remove('auth-tab-active');
|
|
1173
|
+
} else {
|
|
1174
|
+
signinForm.style.display = 'none';
|
|
1175
|
+
signupForm.style.display = 'block';
|
|
1176
|
+
tabSignin.classList.remove('auth-tab-active');
|
|
1177
|
+
tabSignup.classList.add('auth-tab-active');
|
|
1178
|
+
}
|
|
1179
|
+
clearAuthError();
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
function showAuthError(msg) {
|
|
1183
|
+
var el = document.getElementById('auth-error');
|
|
1184
|
+
el.textContent = msg;
|
|
1185
|
+
el.style.display = 'block';
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
function clearAuthError() {
|
|
1189
|
+
var el = document.getElementById('auth-error');
|
|
1190
|
+
el.textContent = '';
|
|
1191
|
+
el.style.display = 'none';
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
function signInUser() {
|
|
1195
|
+
var email = document.getElementById('signin-email').value.trim();
|
|
1196
|
+
var pass = document.getElementById('signin-pass').value;
|
|
1197
|
+
if (!email || !pass) { showAuthError('Please fill in all fields.'); return; }
|
|
1198
|
+
clearAuthError();
|
|
1199
|
+
auth.signInWithEmailAndPassword(email, pass)
|
|
1200
|
+
.then(function() { closeAuthModal(); })
|
|
1201
|
+
.catch(function(err) {
|
|
1202
|
+
if (err.code === 'auth/user-not-found' || err.code === 'auth/wrong-password' || err.code === 'auth/invalid-credential') {
|
|
1203
|
+
showAuthError('Invalid email or password.');
|
|
1204
|
+
} else if (err.code === 'auth/invalid-email') {
|
|
1205
|
+
showAuthError('Invalid email address.');
|
|
1206
|
+
} else {
|
|
1207
|
+
showAuthError(err.message);
|
|
1208
|
+
}
|
|
1209
|
+
});
|
|
1210
|
+
return false;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
function signUpUser() {
|
|
1214
|
+
var name = document.getElementById('signup-name').value.trim();
|
|
1215
|
+
var email = document.getElementById('signup-email').value.trim();
|
|
1216
|
+
var pass = document.getElementById('signup-pass').value;
|
|
1217
|
+
var confirm = document.getElementById('signup-confirm').value;
|
|
1218
|
+
if (!name || !email || !pass || !confirm) { showAuthError('Please fill in all fields.'); return; }
|
|
1219
|
+
if (pass !== confirm) { showAuthError('Passwords do not match.'); return; }
|
|
1220
|
+
if (pass.length < 6) { showAuthError('Password must be at least 6 characters.'); return; }
|
|
1221
|
+
clearAuthError();
|
|
1222
|
+
auth.createUserWithEmailAndPassword(email, pass)
|
|
1223
|
+
.then(function(cred) {
|
|
1224
|
+
return cred.user.updateProfile({ displayName: name });
|
|
1225
|
+
})
|
|
1226
|
+
.then(function() {
|
|
1227
|
+
// Create user document in Firestore
|
|
1228
|
+
if (db && currentUser) {
|
|
1229
|
+
db.collection('users').doc(currentUser.uid).set({
|
|
1230
|
+
name: name,
|
|
1231
|
+
email: email,
|
|
1232
|
+
createdAt: new Date().toISOString(),
|
|
1233
|
+
plan: 'free',
|
|
1234
|
+
projectCount: 0,
|
|
1235
|
+
storageUsedMB: 0
|
|
1236
|
+
}, { merge: true });
|
|
1237
|
+
}
|
|
1238
|
+
closeAuthModal();
|
|
1239
|
+
})
|
|
1240
|
+
.catch(function(err) {
|
|
1241
|
+
if (err.code === 'auth/email-already-in-use') {
|
|
1242
|
+
showAuthError('An account with this email already exists.');
|
|
1243
|
+
} else if (err.code === 'auth/weak-password') {
|
|
1244
|
+
showAuthError('Password must be at least 6 characters.');
|
|
1245
|
+
} else if (err.code === 'auth/invalid-email') {
|
|
1246
|
+
showAuthError('Invalid email address.');
|
|
1247
|
+
} else {
|
|
1248
|
+
showAuthError(err.message);
|
|
1249
|
+
}
|
|
1250
|
+
});
|
|
1251
|
+
return false;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
function signOutUser() {
|
|
1255
|
+
auth.signOut();
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
function resetPassword() {
|
|
1259
|
+
var email = document.getElementById('signin-email').value.trim();
|
|
1260
|
+
if (!email) { showAuthError('Enter your email address first, then click Forgot Password.'); return; }
|
|
1261
|
+
auth.sendPasswordResetEmail(email)
|
|
1262
|
+
.then(function() { showAuthError('Password reset email sent! Check your inbox.'); })
|
|
1263
|
+
.catch(function(err) { showAuthError(err.message); });
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// Gate function — call before any save/share action
|
|
1267
|
+
function requireAuth(callback) {
|
|
1268
|
+
if (currentUser) {
|
|
1269
|
+
callback();
|
|
1270
|
+
} else {
|
|
1271
|
+
showAuthModal();
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
// ── FREE TIER LIMITS ─────────────────────────────────────────
|
|
1276
|
+
var FREE_TIER = { maxProjects: 3, maxStorageMB: 100 };
|
|
1277
|
+
var userTier = { plan: 'free', projectCount: 0, storageUsedMB: 0 };
|
|
1278
|
+
|
|
1279
|
+
function loadUserTier(callback) {
|
|
1280
|
+
if (!db || !currentUser) { if (callback) callback(); return; }
|
|
1281
|
+
db.collection('users').doc(currentUser.uid).get().then(function(doc) {
|
|
1282
|
+
if (doc.exists) {
|
|
1283
|
+
var d = doc.data();
|
|
1284
|
+
userTier.plan = d.plan || 'free';
|
|
1285
|
+
userTier.projectCount = d.projectCount || 0;
|
|
1286
|
+
userTier.storageUsedMB = d.storageUsedMB || 0;
|
|
1287
|
+
}
|
|
1288
|
+
if (callback) callback();
|
|
1289
|
+
}).catch(function() { if (callback) callback(); });
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
function canCreateProject(callback) {
|
|
1293
|
+
loadUserTier(function() {
|
|
1294
|
+
if (userTier.plan !== 'free') { callback(true); return; }
|
|
1295
|
+
if (userTier.projectCount >= FREE_TIER.maxProjects) {
|
|
1296
|
+
toast('Free tier limit: ' + FREE_TIER.maxProjects + ' projects. Upgrade for more.', 'err');
|
|
1297
|
+
showUpgradeModal();
|
|
1298
|
+
callback(false);
|
|
1299
|
+
} else {
|
|
1300
|
+
callback(true);
|
|
1301
|
+
}
|
|
1302
|
+
});
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
function canUploadFile(fileSizeMB, callback) {
|
|
1306
|
+
loadUserTier(function() {
|
|
1307
|
+
if (userTier.plan !== 'free') { callback(true); return; }
|
|
1308
|
+
if (userTier.storageUsedMB + fileSizeMB > FREE_TIER.maxStorageMB) {
|
|
1309
|
+
toast('Free tier limit: ' + FREE_TIER.maxStorageMB + 'MB storage. Used: ' + userTier.storageUsedMB.toFixed(1) + 'MB.', 'err');
|
|
1310
|
+
showUpgradeModal();
|
|
1311
|
+
callback(false);
|
|
1312
|
+
} else {
|
|
1313
|
+
callback(true);
|
|
1314
|
+
}
|
|
1315
|
+
});
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
function incrementProjectCount() {
|
|
1319
|
+
if (!db || !currentUser) return;
|
|
1320
|
+
db.collection('users').doc(currentUser.uid).set({
|
|
1321
|
+
projectCount: firebase.firestore.FieldValue.increment(1)
|
|
1322
|
+
}, { merge: true });
|
|
1323
|
+
userTier.projectCount++;
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
// ── STRIPE PAYMENT LINKS ─────────────────────────────────────
|
|
1327
|
+
// Replace these with your actual Stripe Payment Link URLs.
|
|
1328
|
+
// Create them in Stripe Dashboard → Payment Links → + Create payment link
|
|
1329
|
+
var STRIPE_LINKS = {
|
|
1330
|
+
pro_monthly: 'https://buy.stripe.com/aFafZh2ne0QE2JI3vk63K00',
|
|
1331
|
+
pro_annual: 'https://buy.stripe.com/14AdR96DufLybge7LA63K01',
|
|
1332
|
+
ent_monthly: 'https://buy.stripe.com/8x2eVdbXOeHu6ZYe9Y63K02',
|
|
1333
|
+
ent_annual: 'https://buy.stripe.com/dRmfZh7HyeHu2JI3vk63K03'
|
|
1334
|
+
};
|
|
1335
|
+
|
|
1336
|
+
var UPGRADE_PRICING = {
|
|
1337
|
+
pro_monthly: 59, pro_annual: 49,
|
|
1338
|
+
ent_monthly: 179, ent_annual: 149
|
|
1339
|
+
};
|
|
1340
|
+
|
|
1341
|
+
var upgradeAnnual = true;
|
|
1342
|
+
|
|
1343
|
+
function showUpgradeModal() {
|
|
1344
|
+
document.getElementById('upgrade-modal').style.display = 'flex';
|
|
1345
|
+
updateUpgradePricing();
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
function closeUpgradeModal() {
|
|
1349
|
+
document.getElementById('upgrade-modal').style.display = 'none';
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
function toggleUpgradeBilling() {
|
|
1353
|
+
upgradeAnnual = !upgradeAnnual;
|
|
1354
|
+
updateUpgradePricing();
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
function updateUpgradePricing() {
|
|
1358
|
+
var knob = document.getElementById('up-toggle-knob');
|
|
1359
|
+
var mLbl = document.getElementById('up-monthly-lbl');
|
|
1360
|
+
var aLbl = document.getElementById('up-annual-lbl');
|
|
1361
|
+
var proPrice = document.getElementById('up-pro-price');
|
|
1362
|
+
var proPeriod = document.getElementById('up-pro-period');
|
|
1363
|
+
var entPrice = document.getElementById('up-ent-price');
|
|
1364
|
+
var entPeriod = document.getElementById('up-ent-period');
|
|
1365
|
+
var proBtn = document.getElementById('up-pro-btn');
|
|
1366
|
+
var entBtn = document.getElementById('up-ent-btn');
|
|
1367
|
+
|
|
1368
|
+
if (upgradeAnnual) {
|
|
1369
|
+
knob.style.transform = 'translateX(20px)';
|
|
1370
|
+
mLbl.style.color = 'var(--muted)';
|
|
1371
|
+
aLbl.style.color = 'var(--txt)';
|
|
1372
|
+
proPrice.innerHTML = '$' + UPGRADE_PRICING.pro_annual + '<span>/mo</span>';
|
|
1373
|
+
proPeriod.textContent = 'billed annually \u2014 14-day free trial';
|
|
1374
|
+
entPrice.innerHTML = '$' + UPGRADE_PRICING.ent_annual + '<span>/mo</span>';
|
|
1375
|
+
entPeriod.textContent = 'billed annually \u2014 14-day free trial';
|
|
1376
|
+
proBtn.href = STRIPE_LINKS.pro_annual;
|
|
1377
|
+
entBtn.href = STRIPE_LINKS.ent_annual;
|
|
1378
|
+
} else {
|
|
1379
|
+
knob.style.transform = 'translateX(0)';
|
|
1380
|
+
mLbl.style.color = 'var(--txt)';
|
|
1381
|
+
aLbl.style.color = 'var(--muted)';
|
|
1382
|
+
proPrice.innerHTML = '$' + UPGRADE_PRICING.pro_monthly + '<span>/mo</span>';
|
|
1383
|
+
proPeriod.textContent = 'billed monthly \u2014 14-day free trial';
|
|
1384
|
+
entPrice.innerHTML = '$' + UPGRADE_PRICING.ent_monthly + '<span>/mo</span>';
|
|
1385
|
+
entPeriod.textContent = 'billed monthly \u2014 14-day free trial';
|
|
1386
|
+
proBtn.href = STRIPE_LINKS.pro_monthly;
|
|
1387
|
+
entBtn.href = STRIPE_LINKS.ent_monthly;
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
// Close upgrade modal on outside click
|
|
1392
|
+
document.getElementById('upgrade-modal').addEventListener('click', function(e) {
|
|
1393
|
+
if (e.target === this) closeUpgradeModal();
|
|
1394
|
+
});
|
|
1395
|
+
|
|
1396
|
+
// Check for pending Stripe upgrades (user paid before creating Firebase account)
|
|
1397
|
+
function checkPendingUpgrade(email) {
|
|
1398
|
+
if (!db || !email || userTier.plan !== 'free') return;
|
|
1399
|
+
db.collection('pending_upgrades').where('email', '==', email).limit(1).get()
|
|
1400
|
+
.then(function(snap) {
|
|
1401
|
+
if (!snap.empty) {
|
|
1402
|
+
var doc = snap.docs[0];
|
|
1403
|
+
var data = doc.data();
|
|
1404
|
+
// Apply the upgrade
|
|
1405
|
+
db.collection('users').doc(currentUser.uid).set({
|
|
1406
|
+
plan: data.plan,
|
|
1407
|
+
stripeCustomerId: data.stripeCustomerId || '',
|
|
1408
|
+
stripeSubscriptionId: data.stripeSubscriptionId || ''
|
|
1409
|
+
}, { merge: true }).then(function() {
|
|
1410
|
+
userTier.plan = data.plan;
|
|
1411
|
+
updateAuthUI();
|
|
1412
|
+
toast('Your ' + data.plan.charAt(0).toUpperCase() + data.plan.slice(1) + ' plan is now active!', 'ok');
|
|
1413
|
+
// Clean up the pending record
|
|
1414
|
+
doc.ref.delete();
|
|
1415
|
+
});
|
|
1416
|
+
}
|
|
1417
|
+
})
|
|
1418
|
+
.catch(function() { /* silently ignore */ });
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
function addStorageUsed(mb) {
|
|
1422
|
+
if (!db || !currentUser) return;
|
|
1423
|
+
db.collection('users').doc(currentUser.uid).set({
|
|
1424
|
+
storageUsedMB: firebase.firestore.FieldValue.increment(mb)
|
|
1425
|
+
}, { merge: true });
|
|
1426
|
+
userTier.storageUsedMB += mb;
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
// ── CLIPPING PLANES ──────────────────────────────────────────
|
|
1430
|
+
var clipX = new THREE.Plane(new THREE.Vector3(-1, 0, 0), 99999);
|
|
1431
|
+
var clipY = new THREE.Plane(new THREE.Vector3( 0,-1, 0), 99999);
|
|
1432
|
+
var clipZ = new THREE.Plane(new THREE.Vector3( 0, 0,-1), 99999);
|
|
1433
|
+
var allClipPlanes = [clipX, clipY, clipZ];
|
|
1434
|
+
var clipFlip = {x: false, y: false, z: false};
|
|
1435
|
+
|
|
1436
|
+
// ── MEASUREMENT STATE ────────────────────────────────────────
|
|
1437
|
+
var measState = 0;
|
|
1438
|
+
var measPtA = null, measPtB = null;
|
|
1439
|
+
var measMarkerA, measMarkerB, measLine, measLineGeo;
|
|
1440
|
+
|
|
1441
|
+
// ── MARKUP STATE ──────────────────────────────────────────────
|
|
1442
|
+
var markupActive = false;
|
|
1443
|
+
var markupTool = 'circle'; // circle, rect, arrow, text
|
|
1444
|
+
var markupColor = '#ef4444';
|
|
1445
|
+
var markupWidth = 2;
|
|
1446
|
+
var markupItems = []; // Array of {type, x0, y0, x1, y1, color, width, text}
|
|
1447
|
+
var markupStartPoint = null;
|
|
1448
|
+
var markupCtx = null;
|
|
1449
|
+
var markupCanvas = null;
|
|
1450
|
+
|
|
1451
|
+
// ── INIT ─────────────────────────────────────────────────────
|
|
1452
|
+
function init() {
|
|
1453
|
+
var vp = document.getElementById('viewport');
|
|
1454
|
+
var wrap = document.getElementById('canvas-wrap');
|
|
1455
|
+
|
|
1456
|
+
scene = new THREE.Scene();
|
|
1457
|
+
scene.background = new THREE.Color(0x0d1117);
|
|
1458
|
+
scene.fog = new THREE.Fog(0x0d1117, 300, 1500);
|
|
1459
|
+
|
|
1460
|
+
camera = new THREE.PerspectiveCamera(55, vp.clientWidth / vp.clientHeight, 0.01, 20000);
|
|
1461
|
+
camera.position.set(15, 12, 15);
|
|
1462
|
+
|
|
1463
|
+
renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
|
|
1464
|
+
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
1465
|
+
renderer.setSize(vp.clientWidth, vp.clientHeight);
|
|
1466
|
+
renderer.shadowMap.enabled = true;
|
|
1467
|
+
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
|
1468
|
+
renderer.localClippingEnabled = true;
|
|
1469
|
+
wrap.appendChild(renderer.domElement);
|
|
1470
|
+
|
|
1471
|
+
controls = new THREE.OrbitControls(camera, renderer.domElement);
|
|
1472
|
+
controls.enableDamping = true;
|
|
1473
|
+
controls.dampingFactor = 0.06;
|
|
1474
|
+
controls.minDistance = 0.01;
|
|
1475
|
+
controls.maxDistance = 8000;
|
|
1476
|
+
controls.screenSpacePanning = true;
|
|
1477
|
+
|
|
1478
|
+
// Lighting
|
|
1479
|
+
ambientLight = new THREE.AmbientLight(0xffffff, 0.55);
|
|
1480
|
+
scene.add(ambientLight);
|
|
1481
|
+
dirLight = new THREE.DirectionalLight(0xfff5e0, 1.1);
|
|
1482
|
+
dirLight.position.set(60, 120, 60);
|
|
1483
|
+
dirLight.castShadow = true;
|
|
1484
|
+
dirLight.shadow.mapSize.set(2048, 2048);
|
|
1485
|
+
dirLight.shadow.camera.near = 0.5;
|
|
1486
|
+
dirLight.shadow.camera.far = 500;
|
|
1487
|
+
dirLight.shadow.camera.left = dirLight.shadow.camera.bottom = -100;
|
|
1488
|
+
dirLight.shadow.camera.right = dirLight.shadow.camera.top = 100;
|
|
1489
|
+
scene.add(dirLight);
|
|
1490
|
+
hemiLight = new THREE.HemisphereLight(0x8eb4d4, 0x2a2a40, 0.35);
|
|
1491
|
+
scene.add(hemiLight);
|
|
1492
|
+
|
|
1493
|
+
grid = new THREE.GridHelper(300, 300, 0x1c2333, 0x161d27);
|
|
1494
|
+
scene.add(grid);
|
|
1495
|
+
scene.add(new THREE.AxesHelper(5));
|
|
1496
|
+
|
|
1497
|
+
// Measurement markers
|
|
1498
|
+
var sphGeo = new THREE.SphereGeometry(0.15, 10, 10);
|
|
1499
|
+
measMarkerA = new THREE.Mesh(sphGeo, new THREE.MeshBasicMaterial({color: 0xfbbf24, depthTest: false}));
|
|
1500
|
+
measMarkerB = new THREE.Mesh(sphGeo, new THREE.MeshBasicMaterial({color: 0xfbbf24, depthTest: false}));
|
|
1501
|
+
measMarkerA.visible = measMarkerB.visible = false;
|
|
1502
|
+
measMarkerA.renderOrder = measMarkerB.renderOrder = 999;
|
|
1503
|
+
scene.add(measMarkerA);
|
|
1504
|
+
scene.add(measMarkerB);
|
|
1505
|
+
|
|
1506
|
+
// Measurement line
|
|
1507
|
+
measLineGeo = new THREE.BufferGeometry();
|
|
1508
|
+
var pos = new Float32Array(6);
|
|
1509
|
+
measLineGeo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
|
|
1510
|
+
measLine = new THREE.Line(measLineGeo, new THREE.LineBasicMaterial({color: 0xfbbf24, depthTest: false}));
|
|
1511
|
+
measLine.renderOrder = 998;
|
|
1512
|
+
measLine.visible = false;
|
|
1513
|
+
scene.add(measLine);
|
|
1514
|
+
|
|
1515
|
+
window.addEventListener('resize', function() {
|
|
1516
|
+
var vpEl = document.getElementById('viewport');
|
|
1517
|
+
camera.aspect = vpEl.clientWidth / vpEl.clientHeight;
|
|
1518
|
+
camera.updateProjectionMatrix();
|
|
1519
|
+
renderer.setSize(vpEl.clientWidth, vpEl.clientHeight);
|
|
1520
|
+
});
|
|
1521
|
+
|
|
1522
|
+
var el = renderer.domElement;
|
|
1523
|
+
// Measurement click detection — use pointerdown/pointerup ONLY (not click, to avoid double-fire)
|
|
1524
|
+
var _measClickProcessed = false;
|
|
1525
|
+
el.addEventListener('pointerdown', function(e) {
|
|
1526
|
+
mouseDownXY = {x: e.clientX, y: e.clientY};
|
|
1527
|
+
_measClickProcessed = false;
|
|
1528
|
+
});
|
|
1529
|
+
el.addEventListener('pointerup', function(e) {
|
|
1530
|
+
if (measState === 0 || _measClickProcessed) return;
|
|
1531
|
+
// On touch devices, let touchend handle measurement taps to avoid double-fire
|
|
1532
|
+
if (e.pointerType === 'touch') return;
|
|
1533
|
+
var dx = e.clientX - mouseDownXY.x, dy = e.clientY - mouseDownXY.y;
|
|
1534
|
+
if (Math.sqrt(dx*dx + dy*dy) < 8) {
|
|
1535
|
+
_measClickProcessed = true;
|
|
1536
|
+
onViewportClick(e);
|
|
1537
|
+
}
|
|
1538
|
+
});
|
|
1539
|
+
|
|
1540
|
+
var touchStart = null;
|
|
1541
|
+
el.addEventListener('touchstart', function(e) {
|
|
1542
|
+
if (measState === 0) return;
|
|
1543
|
+
if (e.touches.length === 1) {
|
|
1544
|
+
// Ensure controls are disabled when measuring on touch
|
|
1545
|
+
controls.enabled = false;
|
|
1546
|
+
touchStart = {x: e.touches[0].clientX, y: e.touches[0].clientY};
|
|
1547
|
+
e.preventDefault(); // Prevent OrbitControls from consuming this touch during measurement
|
|
1548
|
+
e.stopPropagation();
|
|
1549
|
+
}
|
|
1550
|
+
}, {passive: false});
|
|
1551
|
+
el.addEventListener('touchend', function(e) {
|
|
1552
|
+
if (measState === 0 || !touchStart) return;
|
|
1553
|
+
if (e.changedTouches.length === 1) {
|
|
1554
|
+
var t = e.changedTouches[0];
|
|
1555
|
+
var dx = t.clientX - touchStart.x, dy = t.clientY - touchStart.y;
|
|
1556
|
+
// Increased threshold from 20 to 30 pixels for better mobile UX
|
|
1557
|
+
if (Math.sqrt(dx*dx + dy*dy) < 30) {
|
|
1558
|
+
e.preventDefault();
|
|
1559
|
+
e.stopPropagation();
|
|
1560
|
+
_measClickProcessed = true; // Prevent pointerup from also firing
|
|
1561
|
+
// Add visual pulse feedback at tap point
|
|
1562
|
+
showTouchPulse(t.clientX, t.clientY);
|
|
1563
|
+
onViewportClick({clientX: t.clientX, clientY: t.clientY});
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
touchStart = null;
|
|
1567
|
+
}, {passive: false});
|
|
1568
|
+
el.addEventListener('touchmove', function(e) {
|
|
1569
|
+
if (measState !== 0) {
|
|
1570
|
+
e.preventDefault(); // Prevent scroll/orbit during measurement mode
|
|
1571
|
+
}
|
|
1572
|
+
}, {passive: false});
|
|
1573
|
+
|
|
1574
|
+
document.addEventListener('keydown', function(e) {
|
|
1575
|
+
if (e.key === 'Escape') { cancelMeasure(); }
|
|
1576
|
+
});
|
|
1577
|
+
|
|
1578
|
+
// ── VIEWCUBE INITIALIZATION ──
|
|
1579
|
+
initViewcube();
|
|
1580
|
+
|
|
1581
|
+
// ── MARKUP INITIALIZATION ──
|
|
1582
|
+
initMarkup();
|
|
1583
|
+
|
|
1584
|
+
// ── ENVIRONMENT INITIALIZATION ──
|
|
1585
|
+
initEnvironment();
|
|
1586
|
+
|
|
1587
|
+
animate();
|
|
1588
|
+
checkUrlParams();
|
|
1589
|
+
setupDrop();
|
|
1590
|
+
updateQRStatus();
|
|
1591
|
+
|
|
1592
|
+
// Show auth modal on load if no shared view and no user logged in
|
|
1593
|
+
setTimeout(function() {
|
|
1594
|
+
checkAndShowAuthModal();
|
|
1595
|
+
}, 500);
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
// ── VIEWCUBE SYSTEM ──────────────────────────────────────────
|
|
1599
|
+
|
|
1600
|
+
|
|
1601
|
+
function makeVCFaceTexture(label, isTop) {
|
|
1602
|
+
var size = 128, c = document.createElement('canvas');
|
|
1603
|
+
c.width = size; c.height = size;
|
|
1604
|
+
var ctx = c.getContext('2d');
|
|
1605
|
+
// Gradient fill — lighter center gives 3D depth feel
|
|
1606
|
+
var g = ctx.createRadialGradient(size*.45, size*.4, size*.1, size/2, size/2, size*.75);
|
|
1607
|
+
if (isTop) {
|
|
1608
|
+
g.addColorStop(0, 'rgba(80,85,95,.88)');
|
|
1609
|
+
g.addColorStop(1, 'rgba(50,54,62,.94)');
|
|
1610
|
+
} else {
|
|
1611
|
+
g.addColorStop(0, 'rgba(68,72,82,.88)');
|
|
1612
|
+
g.addColorStop(1, 'rgba(42,45,52,.94)');
|
|
1613
|
+
}
|
|
1614
|
+
ctx.fillStyle = g;
|
|
1615
|
+
ctx.fillRect(0, 0, size, size);
|
|
1616
|
+
// Subtle inner border (like beveled edges)
|
|
1617
|
+
ctx.strokeStyle = 'rgba(255,255,255,.06)';
|
|
1618
|
+
ctx.lineWidth = 1;
|
|
1619
|
+
ctx.strokeRect(2, 2, size-4, size-4);
|
|
1620
|
+
// Top highlight line
|
|
1621
|
+
ctx.beginPath();
|
|
1622
|
+
ctx.moveTo(4, 2); ctx.lineTo(size-4, 2);
|
|
1623
|
+
ctx.strokeStyle = 'rgba(255,255,255,.1)';
|
|
1624
|
+
ctx.lineWidth = 1;
|
|
1625
|
+
ctx.stroke();
|
|
1626
|
+
// Label
|
|
1627
|
+
ctx.fillStyle = 'rgba(255,255,255,.55)';
|
|
1628
|
+
ctx.font = '600 16px Inter, system-ui, sans-serif';
|
|
1629
|
+
ctx.textAlign = 'center';
|
|
1630
|
+
ctx.textBaseline = 'middle';
|
|
1631
|
+
ctx.fillText(label.toUpperCase(), size/2, size/2);
|
|
1632
|
+
var tex = new THREE.CanvasTexture(c);
|
|
1633
|
+
tex.needsUpdate = true;
|
|
1634
|
+
return tex;
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
// 26 standard views: 6 faces + 12 edges + 8 corners
|
|
1638
|
+
var vcViewDefs = {
|
|
1639
|
+
front: { pos:[0,0,1], label:'Front' },
|
|
1640
|
+
back: { pos:[0,0,-1], label:'Back' },
|
|
1641
|
+
top: { pos:[0,1,0], label:'Top' },
|
|
1642
|
+
bottom: { pos:[0,-1,0], label:'Bottom' },
|
|
1643
|
+
right: { pos:[1,0,0], label:'Right' },
|
|
1644
|
+
left: { pos:[-1,0,0], label:'Left' },
|
|
1645
|
+
frontRightTop: { pos:[1,1,1], label:'SE Isometric' },
|
|
1646
|
+
frontLeftTop: { pos:[-1,1,1], label:'SW Isometric' },
|
|
1647
|
+
backRightTop: { pos:[1,1,-1], label:'NE Isometric' },
|
|
1648
|
+
backLeftTop: { pos:[-1,1,-1], label:'NW Isometric' },
|
|
1649
|
+
frontRightBottom: { pos:[1,-1,1], label:'SE Bottom' },
|
|
1650
|
+
frontLeftBottom: { pos:[-1,-1,1], label:'SW Bottom' },
|
|
1651
|
+
backRightBottom: { pos:[1,-1,-1], label:'NE Bottom' },
|
|
1652
|
+
backLeftBottom: { pos:[-1,-1,-1], label:'NW Bottom' },
|
|
1653
|
+
frontTop: { pos:[0,1,1], label:'Front Top' },
|
|
1654
|
+
frontBottom: { pos:[0,-1,1], label:'Front Bottom' },
|
|
1655
|
+
frontRight: { pos:[1,0,1], label:'Front Right' },
|
|
1656
|
+
frontLeft: { pos:[-1,0,1], label:'Front Left' },
|
|
1657
|
+
backTop: { pos:[0,1,-1], label:'Back Top' },
|
|
1658
|
+
backBottom: { pos:[0,-1,-1], label:'Back Bottom' },
|
|
1659
|
+
backRight: { pos:[1,0,-1], label:'Back Right' },
|
|
1660
|
+
backLeft: { pos:[-1,0,-1], label:'Back Left' },
|
|
1661
|
+
topRight: { pos:[1,1,0], label:'Top Right' },
|
|
1662
|
+
topLeft: { pos:[-1,1,0], label:'Top Left' },
|
|
1663
|
+
bottomRight: { pos:[1,-1,0], label:'Bottom Right' },
|
|
1664
|
+
bottomLeft: { pos:[-1,-1,0], label:'Bottom Left' }
|
|
1665
|
+
};
|
|
1666
|
+
var vcCompassDirs = {
|
|
1667
|
+
north: { pos:[0,1.5,-1], label:'North' },
|
|
1668
|
+
south: { pos:[0,1.5,1], label:'South' },
|
|
1669
|
+
east: { pos:[1,1.5,0], label:'East' },
|
|
1670
|
+
west: { pos:[-1,1.5,0], label:'West' }
|
|
1671
|
+
};
|
|
1672
|
+
|
|
1673
|
+
function initViewcube() {
|
|
1674
|
+
var canvas = document.getElementById('viewcube-canvas');
|
|
1675
|
+
if (!canvas) return;
|
|
1676
|
+
|
|
1677
|
+
vcScene = new THREE.Scene();
|
|
1678
|
+
vcCamera = new THREE.PerspectiveCamera(30, 1, 0.1, 100);
|
|
1679
|
+
vcCamera.position.set(4, 3.5, 4);
|
|
1680
|
+
vcCamera.lookAt(0, 0, 0);
|
|
1681
|
+
|
|
1682
|
+
vcRenderer = new THREE.WebGLRenderer({ canvas: canvas, alpha: true, antialias: true });
|
|
1683
|
+
vcRenderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
|
1684
|
+
vcRenderer.setSize(160, 160);
|
|
1685
|
+
vcRenderer.setClearColor(0x000000, 0);
|
|
1686
|
+
|
|
1687
|
+
// Cube faces: +X, -X, +Y, -Y, +Z, -Z
|
|
1688
|
+
var labels = ['RIGHT','LEFT','TOP','BOTTOM','FRONT','BACK'];
|
|
1689
|
+
var vcMaterials = labels.map(function(lbl, i) {
|
|
1690
|
+
return new THREE.MeshBasicMaterial({ map: makeVCFaceTexture(lbl, i === 2) });
|
|
1691
|
+
});
|
|
1692
|
+
|
|
1693
|
+
var cubeGeo = new THREE.BoxGeometry(2, 2, 2);
|
|
1694
|
+
vcCube = new THREE.Mesh(cubeGeo, vcMaterials);
|
|
1695
|
+
vcScene.add(vcCube);
|
|
1696
|
+
|
|
1697
|
+
// Edges — subtle white wireframe for definition
|
|
1698
|
+
var edgesGeo = new THREE.EdgesGeometry(cubeGeo);
|
|
1699
|
+
var edgesMat = new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.12 });
|
|
1700
|
+
var edgesLine = new THREE.LineSegments(edgesGeo, edgesMat);
|
|
1701
|
+
vcCube.add(edgesLine);
|
|
1702
|
+
|
|
1703
|
+
// Corner hit targets (invisible spheres)
|
|
1704
|
+
var cGeo = new THREE.SphereGeometry(0.22, 8, 8);
|
|
1705
|
+
var cMat = new THREE.MeshBasicMaterial({ visible: false });
|
|
1706
|
+
var cornerDefs = [
|
|
1707
|
+
{ pos:[1,1,1], name:'frontRightTop' },
|
|
1708
|
+
{ pos:[1,1,-1], name:'backRightTop' },
|
|
1709
|
+
{ pos:[-1,1,1], name:'frontLeftTop' },
|
|
1710
|
+
{ pos:[-1,1,-1], name:'backLeftTop' },
|
|
1711
|
+
{ pos:[1,-1,1], name:'frontRightBottom' },
|
|
1712
|
+
{ pos:[1,-1,-1], name:'backRightBottom' },
|
|
1713
|
+
{ pos:[-1,-1,1], name:'frontLeftBottom' },
|
|
1714
|
+
{ pos:[-1,-1,-1],name:'backLeftBottom' }
|
|
1715
|
+
];
|
|
1716
|
+
window._vcCorners = [];
|
|
1717
|
+
cornerDefs.forEach(function(d) {
|
|
1718
|
+
var m = new THREE.Mesh(cGeo.clone(), new THREE.MeshBasicMaterial({ color:0xf97316, transparent:true, opacity:0 }));
|
|
1719
|
+
m.position.set(d.pos[0], d.pos[1], d.pos[2]);
|
|
1720
|
+
m.userData = { viewName: d.name, type:'corner' };
|
|
1721
|
+
vcCube.add(m);
|
|
1722
|
+
window._vcCorners.push(m);
|
|
1723
|
+
});
|
|
1724
|
+
|
|
1725
|
+
// Edge hit targets (invisible cylinders)
|
|
1726
|
+
var eGeo = new THREE.CylinderGeometry(0.12, 0.12, 1.8, 6);
|
|
1727
|
+
var edgeDefs = [
|
|
1728
|
+
{ name:'frontTop', pos:[0,1,1], rot:[0,0,Math.PI/2] },
|
|
1729
|
+
{ name:'frontBottom', pos:[0,-1,1], rot:[0,0,Math.PI/2] },
|
|
1730
|
+
{ name:'frontRight', pos:[1,0,1], rot:[0,0,0] },
|
|
1731
|
+
{ name:'frontLeft', pos:[-1,0,1], rot:[0,0,0] },
|
|
1732
|
+
{ name:'backTop', pos:[0,1,-1], rot:[0,0,Math.PI/2] },
|
|
1733
|
+
{ name:'backBottom', pos:[0,-1,-1], rot:[0,0,Math.PI/2] },
|
|
1734
|
+
{ name:'backRight', pos:[1,0,-1], rot:[0,0,0] },
|
|
1735
|
+
{ name:'backLeft', pos:[-1,0,-1], rot:[0,0,0] },
|
|
1736
|
+
{ name:'topRight', pos:[1,1,0], rot:[Math.PI/2,0,0] },
|
|
1737
|
+
{ name:'topLeft', pos:[-1,1,0], rot:[Math.PI/2,0,0] },
|
|
1738
|
+
{ name:'bottomRight', pos:[1,-1,0], rot:[Math.PI/2,0,0] },
|
|
1739
|
+
{ name:'bottomLeft', pos:[-1,-1,0], rot:[Math.PI/2,0,0] }
|
|
1740
|
+
];
|
|
1741
|
+
window._vcEdges = [];
|
|
1742
|
+
edgeDefs.forEach(function(d) {
|
|
1743
|
+
var m = new THREE.Mesh(eGeo.clone(), new THREE.MeshBasicMaterial({ color:0xf97316, transparent:true, opacity:0 }));
|
|
1744
|
+
m.position.set(d.pos[0], d.pos[1], d.pos[2]);
|
|
1745
|
+
m.rotation.set(d.rot[0], d.rot[1], d.rot[2]);
|
|
1746
|
+
m.userData = { viewName: d.name, type:'edge' };
|
|
1747
|
+
vcCube.add(m);
|
|
1748
|
+
window._vcEdges.push(m);
|
|
1749
|
+
});
|
|
1750
|
+
|
|
1751
|
+
window.vcScene = vcScene;
|
|
1752
|
+
window.vcCamera = vcCamera;
|
|
1753
|
+
window.vcRenderer = vcRenderer;
|
|
1754
|
+
window.vcCube = vcCube;
|
|
1755
|
+
|
|
1756
|
+
// ── Click handling ──
|
|
1757
|
+
var raycaster = new THREE.Raycaster();
|
|
1758
|
+
canvas.addEventListener('click', function(evt) {
|
|
1759
|
+
var rect = canvas.getBoundingClientRect();
|
|
1760
|
+
var mx = ((evt.clientX - rect.left) / rect.width) * 2 - 1;
|
|
1761
|
+
var my = -((evt.clientY - rect.top) / rect.height) * 2 + 1;
|
|
1762
|
+
raycaster.setFromCamera(new THREE.Vector2(mx, my), vcCamera);
|
|
1763
|
+
var allTargets = window._vcCorners.concat(window._vcEdges).concat([vcCube]);
|
|
1764
|
+
var hits = raycaster.intersectObjects(allTargets, false);
|
|
1765
|
+
if (hits.length > 0) {
|
|
1766
|
+
var hit = hits[0].object;
|
|
1767
|
+
var vn = null;
|
|
1768
|
+
if (hit.userData && hit.userData.viewName) {
|
|
1769
|
+
vn = hit.userData.viewName;
|
|
1770
|
+
} else if (hit === vcCube) {
|
|
1771
|
+
var fi = Math.floor(hits[0].faceIndex / 2);
|
|
1772
|
+
vn = ['right','left','top','bottom','front','back'][fi];
|
|
1773
|
+
}
|
|
1774
|
+
if (vn && vcViewDefs[vn]) {
|
|
1775
|
+
var vd = vcViewDefs[vn];
|
|
1776
|
+
var dist = camera.position.distanceTo(controls.target);
|
|
1777
|
+
var dir = new THREE.Vector3(vd.pos[0], vd.pos[1], vd.pos[2]).normalize();
|
|
1778
|
+
var tp = dir.multiplyScalar(dist).add(controls.target.clone());
|
|
1779
|
+
animateCameraToView(tp, controls.target.clone());
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
});
|
|
1783
|
+
|
|
1784
|
+
// ── Hover highlight ──
|
|
1785
|
+
var _lastHover = null;
|
|
1786
|
+
canvas.addEventListener('mousemove', function(evt) {
|
|
1787
|
+
var rect = canvas.getBoundingClientRect();
|
|
1788
|
+
var mx = ((evt.clientX - rect.left) / rect.width) * 2 - 1;
|
|
1789
|
+
var my = -((evt.clientY - rect.top) / rect.height) * 2 + 1;
|
|
1790
|
+
raycaster.setFromCamera(new THREE.Vector2(mx, my), vcCamera);
|
|
1791
|
+
var allTargets = window._vcCorners.concat(window._vcEdges).concat([vcCube]);
|
|
1792
|
+
var hits = raycaster.intersectObjects(allTargets, false);
|
|
1793
|
+
// Reset previous
|
|
1794
|
+
if (_lastHover && _lastHover !== vcCube) {
|
|
1795
|
+
_lastHover.material.opacity = 0;
|
|
1796
|
+
}
|
|
1797
|
+
canvas.style.cursor = 'grab';
|
|
1798
|
+
if (hits.length > 0) {
|
|
1799
|
+
var hit = hits[0].object;
|
|
1800
|
+
if (hit.userData && hit.userData.viewName) {
|
|
1801
|
+
hit.material.opacity = 0.35;
|
|
1802
|
+
canvas.style.cursor = 'pointer';
|
|
1803
|
+
_lastHover = hit;
|
|
1804
|
+
} else if (hit === vcCube) {
|
|
1805
|
+
canvas.style.cursor = 'pointer';
|
|
1806
|
+
_lastHover = hit;
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
});
|
|
1810
|
+
canvas.addEventListener('mouseleave', function() {
|
|
1811
|
+
if (_lastHover && _lastHover !== vcCube) _lastHover.material.opacity = 0;
|
|
1812
|
+
_lastHover = null;
|
|
1813
|
+
canvas.style.cursor = 'grab';
|
|
1814
|
+
});
|
|
1815
|
+
|
|
1816
|
+
// ── Compass clicks ──
|
|
1817
|
+
document.querySelectorAll('.compass-dir').forEach(function(el) {
|
|
1818
|
+
el.addEventListener('click', function() {
|
|
1819
|
+
var dir = el.getAttribute('data-dir');
|
|
1820
|
+
if (vcCompassDirs[dir]) {
|
|
1821
|
+
var vd = vcCompassDirs[dir];
|
|
1822
|
+
var dist = camera.position.distanceTo(controls.target);
|
|
1823
|
+
var d = new THREE.Vector3(vd.pos[0], vd.pos[1], vd.pos[2]).normalize();
|
|
1824
|
+
var tp = d.multiplyScalar(dist).add(controls.target.clone());
|
|
1825
|
+
animateCameraToView(tp, controls.target.clone());
|
|
1826
|
+
}
|
|
1827
|
+
});
|
|
1828
|
+
});
|
|
1829
|
+
|
|
1830
|
+
vcRenderer.render(vcScene, vcCamera);
|
|
1831
|
+
|
|
1832
|
+
// Kickstart sync loop
|
|
1833
|
+
setTimeout(function() { if (window.syncViewcube) window.syncViewcube(); }, 100);
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
window.getViewLabelFromCamera = function() {
|
|
1837
|
+
if (typeof camera === 'undefined' || typeof controls === 'undefined') return '';
|
|
1838
|
+
var dir = new THREE.Vector3().subVectors(camera.position, controls.target).normalize();
|
|
1839
|
+
// Find closest matching named view
|
|
1840
|
+
var bestName = '', bestDot = -2;
|
|
1841
|
+
var allViews = Object.assign({}, vcViewDefs, vcCompassDirs);
|
|
1842
|
+
for (var name in vcViewDefs) {
|
|
1843
|
+
var vd = vcViewDefs[name];
|
|
1844
|
+
var vDir = new THREE.Vector3(vd.pos[0], vd.pos[1], vd.pos[2]).normalize();
|
|
1845
|
+
var dot = dir.dot(vDir);
|
|
1846
|
+
if (dot > bestDot) { bestDot = dot; bestName = name; }
|
|
1847
|
+
}
|
|
1848
|
+
// Only show named label if camera is close enough to a standard view (dot > 0.92)
|
|
1849
|
+
if (bestDot > 0.92 && vcViewDefs[bestName]) {
|
|
1850
|
+
return vcViewDefs[bestName].label;
|
|
1851
|
+
}
|
|
1852
|
+
// Otherwise build a descriptive label from components
|
|
1853
|
+
var parts = [];
|
|
1854
|
+
if (dir.y > 0.3) parts.push('Top');
|
|
1855
|
+
else if (dir.y < -0.3) parts.push('Bottom');
|
|
1856
|
+
if (dir.z > 0.3) parts.push('Front');
|
|
1857
|
+
else if (dir.z < -0.3) parts.push('Back');
|
|
1858
|
+
if (dir.x > 0.3) parts.push('Right');
|
|
1859
|
+
else if (dir.x < -0.3) parts.push('Left');
|
|
1860
|
+
return parts.length > 0 ? parts.join(' ') : 'Custom';
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
function animateCameraToView(targetPos, targetTarget) {
|
|
1864
|
+
var startPos = camera.position.clone();
|
|
1865
|
+
var startTarget = controls.target.clone();
|
|
1866
|
+
var endPos = targetPos.clone ? targetPos.clone() : new THREE.Vector3(targetPos.x, targetPos.y, targetPos.z);
|
|
1867
|
+
var endTarget = targetTarget.clone ? targetTarget.clone() : new THREE.Vector3(targetTarget.x, targetTarget.y, targetTarget.z);
|
|
1868
|
+
var duration = 450;
|
|
1869
|
+
var startTime = performance.now();
|
|
1870
|
+
function ease(t) { return t < 0.5 ? 4*t*t*t : 1 - Math.pow(-2*t+2, 3)/2; }
|
|
1871
|
+
function step(now) {
|
|
1872
|
+
var t = Math.min((now - startTime) / duration, 1);
|
|
1873
|
+
var e = ease(t);
|
|
1874
|
+
camera.position.lerpVectors(startPos, endPos, e);
|
|
1875
|
+
controls.target.lerpVectors(startTarget, endTarget, e);
|
|
1876
|
+
controls.update();
|
|
1877
|
+
if (t < 1) requestAnimationFrame(step);
|
|
1878
|
+
}
|
|
1879
|
+
requestAnimationFrame(step);
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
|
|
1883
|
+
|
|
1884
|
+
// ── ENVIRONMENT THEME FUNCTIONS ──────────────────────────────
|
|
1885
|
+
function initEnvironment() {
|
|
1886
|
+
applyEnvTheme('dark');
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
function applyEnvTheme(themeKey) {
|
|
1890
|
+
if (!envThemes[themeKey]) return;
|
|
1891
|
+
var theme = envThemes[themeKey];
|
|
1892
|
+
|
|
1893
|
+
currentEnvTheme = themeKey;
|
|
1894
|
+
sunAzimuth = theme.sunAzimuth;
|
|
1895
|
+
sunElevation = theme.sunElevation;
|
|
1896
|
+
|
|
1897
|
+
// Update background
|
|
1898
|
+
scene.background = new THREE.Color(theme.bgColor);
|
|
1899
|
+
|
|
1900
|
+
// Update lights
|
|
1901
|
+
ambientLight.color.setHex(theme.ambientColor);
|
|
1902
|
+
ambientLight.intensity = theme.ambientIntensity;
|
|
1903
|
+
|
|
1904
|
+
dirLight.color.setHex(theme.dirColor);
|
|
1905
|
+
dirLight.intensity = theme.dirIntensity;
|
|
1906
|
+
|
|
1907
|
+
hemiLight.color.setHex(theme.hemiSky);
|
|
1908
|
+
hemiLight.groundColor.setHex(theme.hemiGround);
|
|
1909
|
+
hemiLight.intensity = theme.hemiIntensity;
|
|
1910
|
+
|
|
1911
|
+
// Update sun position
|
|
1912
|
+
updateSunPosition();
|
|
1913
|
+
|
|
1914
|
+
// Update sliders
|
|
1915
|
+
document.getElementById('sun-azimuth').value = sunAzimuth;
|
|
1916
|
+
document.getElementById('sun-elevation').value = sunElevation;
|
|
1917
|
+
document.getElementById('sun-az-val').textContent = Math.round(sunAzimuth) + '°';
|
|
1918
|
+
document.getElementById('sun-el-val').textContent = Math.round(sunElevation) + '°';
|
|
1919
|
+
document.getElementById('ambient-intensity').value = Math.round(theme.ambientIntensity * 100);
|
|
1920
|
+
document.getElementById('ambient-pct').textContent = Math.round(theme.ambientIntensity * 100) + '%';
|
|
1921
|
+
|
|
1922
|
+
// Update button active state
|
|
1923
|
+
document.querySelectorAll('.env-btn').forEach(function(btn) {
|
|
1924
|
+
btn.classList.remove('btn-acc');
|
|
1925
|
+
if (btn.dataset.theme === themeKey) {
|
|
1926
|
+
btn.classList.add('btn-acc');
|
|
1927
|
+
}
|
|
1928
|
+
});
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
function updateSunPosition() {
|
|
1932
|
+
var r = 10;
|
|
1933
|
+
var phi = (90 - sunElevation) * Math.PI / 180;
|
|
1934
|
+
var theta = sunAzimuth * Math.PI / 180;
|
|
1935
|
+
dirLight.position.set(
|
|
1936
|
+
r * Math.sin(phi) * Math.cos(theta),
|
|
1937
|
+
r * Math.cos(phi),
|
|
1938
|
+
r * Math.sin(phi) * Math.sin(theta)
|
|
1939
|
+
);
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
function updateAmbientLight(value) {
|
|
1943
|
+
var intensity = value / 100;
|
|
1944
|
+
ambientLight.intensity = intensity;
|
|
1945
|
+
document.getElementById('ambient-pct').textContent = value + '%';
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
// ── ANIMATE ──────────────────────────────────────────────────
|
|
1949
|
+
function animate() {
|
|
1950
|
+
requestAnimationFrame(animate);
|
|
1951
|
+
controls.update();
|
|
1952
|
+
|
|
1953
|
+
// Auto-rotate sun if enabled
|
|
1954
|
+
if (sunAutoRotate) {
|
|
1955
|
+
sunAzimuth = (sunAzimuth + 0.1) % 360;
|
|
1956
|
+
updateSunPosition();
|
|
1957
|
+
document.getElementById('sun-azimuth').value = Math.round(sunAzimuth);
|
|
1958
|
+
document.getElementById('sun-az-val').textContent = Math.round(sunAzimuth) + '°';
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
renderer.render(scene, camera);
|
|
1962
|
+
|
|
1963
|
+
// Render viewcube
|
|
1964
|
+
if (window.vcRenderer && window.vcCube && window.vcCamera) {
|
|
1965
|
+
var q = new THREE.Quaternion();
|
|
1966
|
+
camera.getWorldQuaternion(q);
|
|
1967
|
+
window.vcCube.quaternion.copy(q).invert();
|
|
1968
|
+
window.vcRenderer.render(window.vcScene, window.vcCamera);
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
frames++;
|
|
1972
|
+
var now = performance.now();
|
|
1973
|
+
if (now - fpsLast > 1000) {
|
|
1974
|
+
document.getElementById('st-fps').textContent = Math.round(frames * 1000 / (now - fpsLast)) + ' fps';
|
|
1975
|
+
frames = 0;
|
|
1976
|
+
fpsLast = now;
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
var p = camera.position, t = controls.target;
|
|
1980
|
+
document.getElementById('cam-hud').textContent =
|
|
1981
|
+
'Cam ' + p.x.toFixed(1) + ', ' + p.y.toFixed(1) + ', ' + p.z.toFixed(1) +
|
|
1982
|
+
' Tgt ' + t.x.toFixed(1) + ', ' + t.y.toFixed(1) + ', ' + t.z.toFixed(1);
|
|
1983
|
+
|
|
1984
|
+
if (measState === 0 && measPtA && measPtB) {
|
|
1985
|
+
var mid = new THREE.Vector3().addVectors(measPtA, measPtB).multiplyScalar(0.5);
|
|
1986
|
+
var vp = document.getElementById('viewport');
|
|
1987
|
+
var proj = mid.clone().project(camera);
|
|
1988
|
+
var lx = (proj.x + 1) / 2 * vp.clientWidth;
|
|
1989
|
+
var ly = (-proj.y + 1) / 2 * vp.clientHeight;
|
|
1990
|
+
var label = document.getElementById('meas-label');
|
|
1991
|
+
label.style.left = lx + 'px';
|
|
1992
|
+
label.style.top = ly + 'px';
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
// ── MOBILE SIDEBAR TOGGLE ────────────────────────────────────
|
|
1997
|
+
function toggleSidebar() {
|
|
1998
|
+
var sb = document.getElementById('sidebar');
|
|
1999
|
+
var ov = document.getElementById('sidebar-overlay');
|
|
2000
|
+
sb.classList.toggle('show');
|
|
2001
|
+
if (sb.classList.contains('show')) {
|
|
2002
|
+
ov.classList.add('show');
|
|
2003
|
+
} else {
|
|
2004
|
+
ov.classList.remove('show');
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
function closeSidebar() {
|
|
2009
|
+
var sb = document.getElementById('sidebar');
|
|
2010
|
+
var ov = document.getElementById('sidebar-overlay');
|
|
2011
|
+
sb.classList.remove('show');
|
|
2012
|
+
ov.classList.remove('show');
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
// ── COLLAPSIBLE SECTIONS ─────────────────────────────────────
|
|
2016
|
+
function toggleSec(hdr) {
|
|
2017
|
+
var body = hdr.nextElementSibling;
|
|
2018
|
+
var chev = hdr.querySelector('.chev');
|
|
2019
|
+
var open = body.classList.toggle('open');
|
|
2020
|
+
chev.textContent = open ? '▼' : '▶';
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
// ── URL PARAMS ───────────────────────────────────────────────
|
|
2024
|
+
function checkUrlParams() {
|
|
2025
|
+
var p = new URLSearchParams(window.location.search);
|
|
2026
|
+
var viewDocId = p.get('view');
|
|
2027
|
+
|
|
2028
|
+
// If view parameter exists, load from Firestore
|
|
2029
|
+
if (viewDocId && firebaseReady && db) {
|
|
2030
|
+
loadViewFromFirestore(viewDocId);
|
|
2031
|
+
return;
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
// Otherwise check for legacy ?model= parameter
|
|
2035
|
+
var url = p.get('model');
|
|
2036
|
+
if (!url) return;
|
|
2037
|
+
document.getElementById('model-url').value = url;
|
|
2038
|
+
loadModel(url, null, function() {
|
|
2039
|
+
var cx = parseFloat(p.get('cx')), cy = parseFloat(p.get('cy')), cz = parseFloat(p.get('cz'));
|
|
2040
|
+
var tx = parseFloat(p.get('tx')), ty = parseFloat(p.get('ty')), tz = parseFloat(p.get('tz'));
|
|
2041
|
+
if (!isNaN(cx)) { camera.position.set(cx,cy,cz); controls.target.set(tx,ty,tz); controls.update(); toast('📐 View restored from QR','ok'); }
|
|
2042
|
+
});
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
function checkAndShowAuthModal() {
|
|
2046
|
+
var p = new URLSearchParams(window.location.search);
|
|
2047
|
+
var viewDocId = p.get('view');
|
|
2048
|
+
var modelUrl = p.get('model');
|
|
2049
|
+
|
|
2050
|
+
// If this is a shared view (has ?view= or ?model= parameter), don't show auth modal
|
|
2051
|
+
if (viewDocId || modelUrl) {
|
|
2052
|
+
return;
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
// If user is already logged in, don't show auth modal
|
|
2056
|
+
if (currentUser) {
|
|
2057
|
+
return;
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
// Otherwise show auth modal
|
|
2061
|
+
showAuthModal();
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
function loadViewFromFirestore(docId) {
|
|
2065
|
+
showLoad('Loading view from Firestore…');
|
|
2066
|
+
db.collection('views').doc(docId).get().then(function(doc) {
|
|
2067
|
+
if (!doc.exists) {
|
|
2068
|
+
hideLoad();
|
|
2069
|
+
toast('View not found','err');
|
|
2070
|
+
return;
|
|
2071
|
+
}
|
|
2072
|
+
var data = doc.data();
|
|
2073
|
+
document.getElementById('model-url').value = data.fileName || 'Model';
|
|
2074
|
+
|
|
2075
|
+
function onModelLoaded() {
|
|
2076
|
+
hideLoad();
|
|
2077
|
+
var cam = data.camera;
|
|
2078
|
+
if (cam) {
|
|
2079
|
+
camera.position.set(cam.cx, cam.cy, cam.cz);
|
|
2080
|
+
controls.target.set(cam.tx, cam.ty, cam.tz);
|
|
2081
|
+
controls.update();
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
// Restore all shared viewpoints into the Viewpoints panel
|
|
2085
|
+
if (data.viewpoints && data.viewpoints.length > 0) {
|
|
2086
|
+
data.viewpoints.forEach(function(vp) {
|
|
2087
|
+
savedViewpoints.push({
|
|
2088
|
+
id: nextViewpointId++,
|
|
2089
|
+
name: vp.name,
|
|
2090
|
+
cam: vp.cam,
|
|
2091
|
+
target: vp.target,
|
|
2092
|
+
timestamp: vp.timestamp || 'Shared'
|
|
2093
|
+
});
|
|
2094
|
+
});
|
|
2095
|
+
updateViewpointsList();
|
|
2096
|
+
// Auto-open the Viewpoints section so user sees them
|
|
2097
|
+
var vpSec = document.querySelector('#vp-list');
|
|
2098
|
+
if (vpSec && vpSec.closest('.sec-body')) {
|
|
2099
|
+
vpSec.closest('.sec-body').style.display = 'block';
|
|
2100
|
+
var chev = vpSec.closest('.sec').querySelector('.chev');
|
|
2101
|
+
if (chev) chev.textContent = '▼';
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
var vpCount = (data.viewpoints && data.viewpoints.length) || 0;
|
|
2106
|
+
var msg = '📐 View "' + data.projectName + '" restored from Firestore';
|
|
2107
|
+
if (vpCount > 0) msg += ' (' + vpCount + ' viewpoint' + (vpCount > 1 ? 's' : '') + ')';
|
|
2108
|
+
toast(msg, 'ok');
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
// Use Firebase Storage SDK for fresh download URL, fall back to stored URL
|
|
2112
|
+
if (data.modelStorageRef && typeof firebase !== 'undefined' && firebase.storage) {
|
|
2113
|
+
firebase.storage().ref(data.modelStorageRef).getDownloadURL().then(function(freshUrl) {
|
|
2114
|
+
loadModel(freshUrl, null, onModelLoaded, data.fileName);
|
|
2115
|
+
}).catch(function(err) {
|
|
2116
|
+
console.warn('Storage SDK fallback to stored URL:', err);
|
|
2117
|
+
if (data.modelUrl) {
|
|
2118
|
+
loadModel(data.modelUrl, null, onModelLoaded, data.fileName);
|
|
2119
|
+
} else {
|
|
2120
|
+
hideLoad();
|
|
2121
|
+
toast('Failed to load model: no storage reference','err');
|
|
2122
|
+
}
|
|
2123
|
+
});
|
|
2124
|
+
} else if (data.modelUrl) {
|
|
2125
|
+
loadModel(data.modelUrl, null, onModelLoaded, data.fileName);
|
|
2126
|
+
} else {
|
|
2127
|
+
hideLoad();
|
|
2128
|
+
toast('No model URL in view data','err');
|
|
2129
|
+
}
|
|
2130
|
+
}).catch(function(error) {
|
|
2131
|
+
hideLoad();
|
|
2132
|
+
console.error('Firestore load error:', error);
|
|
2133
|
+
toast('Failed to load view: ' + error.message,'err');
|
|
2134
|
+
});
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
// ── FORMAT DETECT ────────────────────────────────────────────
|
|
2138
|
+
function fmtFromUrl(url) {
|
|
2139
|
+
var u = url.toLowerCase().split('?')[0];
|
|
2140
|
+
if (u.endsWith('.glb') || u.endsWith('.gltf')) return 'gltf';
|
|
2141
|
+
if (u.endsWith('.fbx')) return 'fbx';
|
|
2142
|
+
if (u.endsWith('.obj')) return 'obj';
|
|
2143
|
+
if (u.endsWith('.ifc')) return 'ifc';
|
|
2144
|
+
if (u.endsWith('.ply')) return 'ply';
|
|
2145
|
+
if (u.endsWith('.stl')) return 'stl';
|
|
2146
|
+
return 'gltf';
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
// ── LOAD ─────────────────────────────────────────────────────
|
|
2150
|
+
function loadFromUrl() {
|
|
2151
|
+
var url = document.getElementById('model-url').value.trim();
|
|
2152
|
+
if (!url) { toast('Enter a model URL','err'); return; }
|
|
2153
|
+
modelIsLocal = false;
|
|
2154
|
+
loadModel(url, null);
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
function loadSample() {
|
|
2158
|
+
showLoad('Building sample…');
|
|
2159
|
+
document.getElementById('welcome').style.display = 'none';
|
|
2160
|
+
resetSection();
|
|
2161
|
+
modelIsLocal = true;
|
|
2162
|
+
|
|
2163
|
+
var building = new THREE.Group();
|
|
2164
|
+
building.name = 'SampleBuilding';
|
|
2165
|
+
|
|
2166
|
+
// Materials
|
|
2167
|
+
var matConc = new THREE.MeshStandardMaterial({color:0xc8c0b8, roughness:0.85, name:'Concrete'});
|
|
2168
|
+
var matGlass = new THREE.MeshStandardMaterial({color:0x88ccee, roughness:0.1, metalness:0.3, transparent:true, opacity:0.45, name:'Glass'});
|
|
2169
|
+
var matFloor = new THREE.MeshStandardMaterial({color:0x999185, roughness:0.7, name:'Floor Slab'});
|
|
2170
|
+
var matWallInt = new THREE.MeshStandardMaterial({color:0xe8e0d6, roughness:0.9, name:'Interior Wall'});
|
|
2171
|
+
var matRoof = new THREE.MeshStandardMaterial({color:0x707068, roughness:0.6, name:'Roof'});
|
|
2172
|
+
var matSteel = new THREE.MeshStandardMaterial({color:0x888890, roughness:0.4, metalness:0.6, name:'Steel Frame'});
|
|
2173
|
+
var matGround = new THREE.MeshStandardMaterial({color:0x6b8f5e, roughness:0.95, name:'Ground'});
|
|
2174
|
+
|
|
2175
|
+
var floorH = 3.2; // story height in meters
|
|
2176
|
+
var floors = 5;
|
|
2177
|
+
var bW = 24, bD = 14; // building width, depth
|
|
2178
|
+
var wallT = 0.25; // wall thickness
|
|
2179
|
+
var slabT = 0.3; // slab thickness
|
|
2180
|
+
|
|
2181
|
+
// Ground plane
|
|
2182
|
+
var gnd = new THREE.Mesh(new THREE.BoxGeometry(60, 0.1, 40), matGround);
|
|
2183
|
+
gnd.position.set(0, -0.05, 0);
|
|
2184
|
+
gnd.name = 'Ground';
|
|
2185
|
+
building.add(gnd);
|
|
2186
|
+
|
|
2187
|
+
// Foundation / base slab
|
|
2188
|
+
var baseSlab = new THREE.Mesh(new THREE.BoxGeometry(bW+1, 0.5, bD+1), matConc);
|
|
2189
|
+
baseSlab.position.set(0, -0.25, 0);
|
|
2190
|
+
baseSlab.name = 'Foundation';
|
|
2191
|
+
building.add(baseSlab);
|
|
2192
|
+
|
|
2193
|
+
for (var f = 0; f < floors; f++) {
|
|
2194
|
+
var y0 = f * floorH;
|
|
2195
|
+
var floorGrp = new THREE.Group();
|
|
2196
|
+
floorGrp.name = 'Floor_' + (f + 1);
|
|
2197
|
+
|
|
2198
|
+
// Floor slab
|
|
2199
|
+
var slab = new THREE.Mesh(new THREE.BoxGeometry(bW, slabT, bD), matFloor);
|
|
2200
|
+
slab.position.set(0, y0 + slabT/2, 0);
|
|
2201
|
+
slab.name = 'Slab_F' + (f+1);
|
|
2202
|
+
floorGrp.add(slab);
|
|
2203
|
+
|
|
2204
|
+
// Exterior walls with window openings (simplified: solid panels + glass)
|
|
2205
|
+
// Front wall (z = bD/2)
|
|
2206
|
+
var panelW = bW / 6;
|
|
2207
|
+
for (var i = 0; i < 6; i++) {
|
|
2208
|
+
var px = -bW/2 + panelW/2 + i * panelW;
|
|
2209
|
+
// Wall pier (column between windows)
|
|
2210
|
+
var pierW = panelW * 0.3;
|
|
2211
|
+
var wH = floorH - slabT;
|
|
2212
|
+
if (i < 5) {
|
|
2213
|
+
var pier = new THREE.Mesh(new THREE.BoxGeometry(pierW, wH, wallT), matConc);
|
|
2214
|
+
pier.position.set(px + panelW/2, y0 + slabT + wH/2, bD/2);
|
|
2215
|
+
pier.name = 'FrontPier_F'+(f+1)+'_'+i;
|
|
2216
|
+
floorGrp.add(pier);
|
|
2217
|
+
}
|
|
2218
|
+
// Spandrel (strip below window)
|
|
2219
|
+
var spanH = wH * 0.22;
|
|
2220
|
+
var span = new THREE.Mesh(new THREE.BoxGeometry(panelW - pierW, spanH, wallT), matConc);
|
|
2221
|
+
span.position.set(px, y0 + slabT + spanH/2, bD/2);
|
|
2222
|
+
span.name = 'FrontSpandrel_F'+(f+1)+'_'+i;
|
|
2223
|
+
floorGrp.add(span);
|
|
2224
|
+
// Glass window
|
|
2225
|
+
var glH = wH - spanH - 0.2;
|
|
2226
|
+
var gl = new THREE.Mesh(new THREE.BoxGeometry(panelW - pierW - 0.1, glH, 0.06), matGlass);
|
|
2227
|
+
gl.position.set(px, y0 + slabT + spanH + glH/2 + 0.1, bD/2);
|
|
2228
|
+
gl.name = 'FrontWindow_F'+(f+1)+'_'+i;
|
|
2229
|
+
floorGrp.add(gl);
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
// Back wall (z = -bD/2) - same pattern
|
|
2233
|
+
for (var i = 0; i < 6; i++) {
|
|
2234
|
+
var px = -bW/2 + panelW/2 + i * panelW;
|
|
2235
|
+
var pierW = panelW * 0.3;
|
|
2236
|
+
var wH = floorH - slabT;
|
|
2237
|
+
if (i < 5) {
|
|
2238
|
+
var pier = new THREE.Mesh(new THREE.BoxGeometry(pierW, wH, wallT), matConc);
|
|
2239
|
+
pier.position.set(px + panelW/2, y0 + slabT + wH/2, -bD/2);
|
|
2240
|
+
pier.name = 'BackPier_F'+(f+1)+'_'+i;
|
|
2241
|
+
floorGrp.add(pier);
|
|
2242
|
+
}
|
|
2243
|
+
var spanH = wH * 0.22;
|
|
2244
|
+
var span = new THREE.Mesh(new THREE.BoxGeometry(panelW - pierW, spanH, wallT), matConc);
|
|
2245
|
+
span.position.set(px, y0 + slabT + spanH/2, -bD/2);
|
|
2246
|
+
floorGrp.add(span);
|
|
2247
|
+
var glH = wH - spanH - 0.2;
|
|
2248
|
+
var gl = new THREE.Mesh(new THREE.BoxGeometry(panelW - pierW - 0.1, glH, 0.06), matGlass);
|
|
2249
|
+
gl.position.set(px, y0 + slabT + spanH + glH/2 + 0.1, -bD/2);
|
|
2250
|
+
gl.name = 'BackWindow_F'+(f+1)+'_'+i;
|
|
2251
|
+
floorGrp.add(gl);
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
// Left wall (x = -bW/2) - fewer windows
|
|
2255
|
+
var sideSegments = 3;
|
|
2256
|
+
var segD = bD / sideSegments;
|
|
2257
|
+
for (var j = 0; j < sideSegments; j++) {
|
|
2258
|
+
var pz = -bD/2 + segD/2 + j * segD;
|
|
2259
|
+
var wH = floorH - slabT;
|
|
2260
|
+
// Side pier
|
|
2261
|
+
if (j < sideSegments - 1) {
|
|
2262
|
+
var sp = new THREE.Mesh(new THREE.BoxGeometry(wallT, wH, segD*0.25), matConc);
|
|
2263
|
+
sp.position.set(-bW/2, y0 + slabT + wH/2, pz + segD/2);
|
|
2264
|
+
floorGrp.add(sp);
|
|
2265
|
+
}
|
|
2266
|
+
// Side spandrel
|
|
2267
|
+
var spanH = wH * 0.3;
|
|
2268
|
+
var ss = new THREE.Mesh(new THREE.BoxGeometry(wallT, spanH, segD*0.65), matConc);
|
|
2269
|
+
ss.position.set(-bW/2, y0 + slabT + spanH/2, pz);
|
|
2270
|
+
floorGrp.add(ss);
|
|
2271
|
+
// Side glass
|
|
2272
|
+
var glH = wH - spanH - 0.2;
|
|
2273
|
+
var sg = new THREE.Mesh(new THREE.BoxGeometry(0.06, glH, segD*0.6), matGlass);
|
|
2274
|
+
sg.position.set(-bW/2, y0 + slabT + spanH + glH/2 + 0.1, pz);
|
|
2275
|
+
floorGrp.add(sg);
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
// Right wall (x = bW/2) - same as left
|
|
2279
|
+
for (var j = 0; j < sideSegments; j++) {
|
|
2280
|
+
var pz = -bD/2 + segD/2 + j * segD;
|
|
2281
|
+
var wH = floorH - slabT;
|
|
2282
|
+
if (j < sideSegments - 1) {
|
|
2283
|
+
var sp = new THREE.Mesh(new THREE.BoxGeometry(wallT, wH, segD*0.25), matConc);
|
|
2284
|
+
sp.position.set(bW/2, y0 + slabT + wH/2, pz + segD/2);
|
|
2285
|
+
floorGrp.add(sp);
|
|
2286
|
+
}
|
|
2287
|
+
var spanH = wH * 0.3;
|
|
2288
|
+
var ss = new THREE.Mesh(new THREE.BoxGeometry(wallT, spanH, segD*0.65), matConc);
|
|
2289
|
+
ss.position.set(bW/2, y0 + slabT + spanH/2, pz);
|
|
2290
|
+
floorGrp.add(ss);
|
|
2291
|
+
var glH = wH - spanH - 0.2;
|
|
2292
|
+
var sg = new THREE.Mesh(new THREE.BoxGeometry(0.06, glH, segD*0.6), matGlass);
|
|
2293
|
+
sg.position.set(bW/2, y0 + slabT + spanH + glH/2 + 0.1, pz);
|
|
2294
|
+
floorGrp.add(sg);
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
// Interior walls (corridor + rooms)
|
|
2298
|
+
var wH = floorH - slabT - 0.05;
|
|
2299
|
+
// Corridor wall (runs length of building)
|
|
2300
|
+
var corr = new THREE.Mesh(new THREE.BoxGeometry(bW - wallT*2, wH, 0.15), matWallInt);
|
|
2301
|
+
corr.position.set(0, y0 + slabT + wH/2, 1.5);
|
|
2302
|
+
corr.name = 'Corridor_F'+(f+1);
|
|
2303
|
+
floorGrp.add(corr);
|
|
2304
|
+
|
|
2305
|
+
// Cross walls creating rooms
|
|
2306
|
+
var roomDividers = [-8, -2, 4, 10];
|
|
2307
|
+
for (var d = 0; d < roomDividers.length; d++) {
|
|
2308
|
+
// Front rooms divider
|
|
2309
|
+
var div1 = new THREE.Mesh(new THREE.BoxGeometry(0.15, wH, bD/2 - 1.5 - 0.1), matWallInt);
|
|
2310
|
+
div1.position.set(roomDividers[d], y0 + slabT + wH/2, bD/4 + 0.75 + 0.05);
|
|
2311
|
+
div1.name = 'RoomDiv_F'+(f+1)+'_front_'+d;
|
|
2312
|
+
floorGrp.add(div1);
|
|
2313
|
+
// Back rooms divider
|
|
2314
|
+
var div2 = new THREE.Mesh(new THREE.BoxGeometry(0.15, wH, bD/2 - 1.5 - 0.1), matWallInt);
|
|
2315
|
+
div2.position.set(roomDividers[d], y0 + slabT + wH/2, -(bD/4 - 0.75 + 0.05));
|
|
2316
|
+
div2.name = 'RoomDiv_F'+(f+1)+'_back_'+d;
|
|
2317
|
+
floorGrp.add(div2);
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
// Steel columns at corners and mid-span
|
|
2321
|
+
var colPositions = [
|
|
2322
|
+
[-bW/2+0.3, -bD/2+0.3], [-bW/2+0.3, bD/2-0.3],
|
|
2323
|
+
[bW/2-0.3, -bD/2+0.3], [bW/2-0.3, bD/2-0.3],
|
|
2324
|
+
[0, -bD/2+0.3], [0, bD/2-0.3]
|
|
2325
|
+
];
|
|
2326
|
+
for (var c = 0; c < colPositions.length; c++) {
|
|
2327
|
+
var col = new THREE.Mesh(new THREE.BoxGeometry(0.35, floorH, 0.35), matSteel);
|
|
2328
|
+
col.position.set(colPositions[c][0], y0 + floorH/2, colPositions[c][1]);
|
|
2329
|
+
col.name = 'Column_F'+(f+1)+'_'+c;
|
|
2330
|
+
floorGrp.add(col);
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
building.add(floorGrp);
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
// Roof slab
|
|
2337
|
+
var roofY = floors * floorH;
|
|
2338
|
+
var roof = new THREE.Mesh(new THREE.BoxGeometry(bW + 0.5, slabT, bD + 0.5), matRoof);
|
|
2339
|
+
roof.position.set(0, roofY + slabT/2, 0);
|
|
2340
|
+
roof.name = 'Roof_Slab';
|
|
2341
|
+
building.add(roof);
|
|
2342
|
+
|
|
2343
|
+
// Roof parapet
|
|
2344
|
+
var parH = 0.8;
|
|
2345
|
+
var parT = 0.2;
|
|
2346
|
+
var parF = new THREE.Mesh(new THREE.BoxGeometry(bW+0.5, parH, parT), matConc);
|
|
2347
|
+
parF.position.set(0, roofY + slabT + parH/2, bD/2+0.15);
|
|
2348
|
+
building.add(parF);
|
|
2349
|
+
var parB = new THREE.Mesh(new THREE.BoxGeometry(bW+0.5, parH, parT), matConc);
|
|
2350
|
+
parB.position.set(0, roofY + slabT + parH/2, -bD/2-0.15);
|
|
2351
|
+
building.add(parB);
|
|
2352
|
+
var parL = new THREE.Mesh(new THREE.BoxGeometry(parT, parH, bD+0.5), matConc);
|
|
2353
|
+
parL.position.set(-bW/2-0.15, roofY + slabT + parH/2, 0);
|
|
2354
|
+
building.add(parL);
|
|
2355
|
+
var parR = new THREE.Mesh(new THREE.BoxGeometry(parT, parH, bD+0.5), matConc);
|
|
2356
|
+
parR.position.set(bW/2+0.15, roofY + slabT + parH/2, 0);
|
|
2357
|
+
building.add(parR);
|
|
2358
|
+
|
|
2359
|
+
// Roof mechanical unit
|
|
2360
|
+
var mech = new THREE.Mesh(new THREE.BoxGeometry(4, 2, 3), matSteel);
|
|
2361
|
+
mech.position.set(5, roofY + slabT + 1, -2);
|
|
2362
|
+
mech.name = 'Mech_Unit';
|
|
2363
|
+
building.add(mech);
|
|
2364
|
+
|
|
2365
|
+
// Entrance canopy
|
|
2366
|
+
var canopy = new THREE.Mesh(new THREE.BoxGeometry(6, 0.2, 3), matConc);
|
|
2367
|
+
canopy.position.set(0, 3.0, bD/2 + 1.5);
|
|
2368
|
+
canopy.name = 'Entrance_Canopy';
|
|
2369
|
+
building.add(canopy);
|
|
2370
|
+
// Canopy columns
|
|
2371
|
+
var cc1 = new THREE.Mesh(new THREE.CylinderGeometry(0.15, 0.15, 3, 8), matSteel);
|
|
2372
|
+
cc1.position.set(-2.5, 1.5, bD/2 + 2.8);
|
|
2373
|
+
building.add(cc1);
|
|
2374
|
+
var cc2 = new THREE.Mesh(new THREE.CylinderGeometry(0.15, 0.15, 3, 8), matSteel);
|
|
2375
|
+
cc2.position.set(2.5, 1.5, bD/2 + 2.8);
|
|
2376
|
+
building.add(cc2);
|
|
2377
|
+
|
|
2378
|
+
// Use the standard postProcess pipeline so the building integrates fully
|
|
2379
|
+
postProcess(building, '[Procedural Sample Building]', 'gltf', 'Sample Building');
|
|
2380
|
+
document.getElementById('model-url').value = '[Procedural Sample Building]';
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
function loadModel(url, fmt, onDone, fileName) {
|
|
2384
|
+
fmt = fmt || fmtFromUrl(url);
|
|
2385
|
+
|
|
2386
|
+
if (fmt === 'ifc') {
|
|
2387
|
+
loadIFC(url, onDone);
|
|
2388
|
+
return;
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2391
|
+
showLoad('Loading ' + fmt.toUpperCase() + '…');
|
|
2392
|
+
document.getElementById('welcome').style.display = 'none';
|
|
2393
|
+
resetSection();
|
|
2394
|
+
|
|
2395
|
+
var loader;
|
|
2396
|
+
if (fmt === 'gltf') loader = new THREE.GLTFLoader();
|
|
2397
|
+
else if (fmt === 'fbx') loader = new THREE.FBXLoader();
|
|
2398
|
+
else if (fmt === 'obj') loader = new THREE.OBJLoader();
|
|
2399
|
+
else if (fmt === 'ply') loader = new THREE.PLYLoader();
|
|
2400
|
+
else if (fmt === 'stl') loader = new THREE.STLLoader();
|
|
2401
|
+
|
|
2402
|
+
loader.load(url,
|
|
2403
|
+
function(r) {
|
|
2404
|
+
var sceneObj = r;
|
|
2405
|
+
if (fmt === 'gltf') {
|
|
2406
|
+
sceneObj = r.scene;
|
|
2407
|
+
} else if (fmt === 'ply' || fmt === 'stl') {
|
|
2408
|
+
// For PLY and STL, wrap geometry in a mesh
|
|
2409
|
+
var material;
|
|
2410
|
+
if (fmt === 'ply' && r.attributes && r.attributes.color) {
|
|
2411
|
+
// PLY with vertex colors
|
|
2412
|
+
material = new THREE.MeshStandardMaterial({vertexColors: true, side: THREE.DoubleSide});
|
|
2413
|
+
} else {
|
|
2414
|
+
material = new THREE.MeshStandardMaterial({color: 0xf97316, side: THREE.DoubleSide});
|
|
2415
|
+
}
|
|
2416
|
+
if (r.index || (r.attributes && r.attributes.position && r.attributes.position.count > 0)) {
|
|
2417
|
+
sceneObj = new THREE.Mesh(r, material);
|
|
2418
|
+
} else {
|
|
2419
|
+
sceneObj = r;
|
|
2420
|
+
}
|
|
2421
|
+
}
|
|
2422
|
+
postProcess(sceneObj, url, fmt, fileName);
|
|
2423
|
+
if(onDone)onDone();
|
|
2424
|
+
},
|
|
2425
|
+
function(pg) { if(pg.total>0) document.getElementById('loading-txt').textContent='Loading '+fmt.toUpperCase()+'… '+Math.round(pg.loaded/pg.total*100)+'%'; },
|
|
2426
|
+
function(e) { hideLoad(); console.error(e); toast('Failed to load — check URL/CORS','err'); }
|
|
2427
|
+
);
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
function loadIFC(url, onDone) {
|
|
2431
|
+
showLoad('Loading IFC…');
|
|
2432
|
+
document.getElementById('welcome').style.display = 'none';
|
|
2433
|
+
resetSection();
|
|
2434
|
+
|
|
2435
|
+
fetch(url)
|
|
2436
|
+
.then(function(r) { return r.arrayBuffer(); })
|
|
2437
|
+
.then(function(buffer) {
|
|
2438
|
+
try {
|
|
2439
|
+
hideLoad();
|
|
2440
|
+
toast('IFC support: export as GLTF/FBX from your BIM software for best results','info');
|
|
2441
|
+
document.getElementById('welcome').style.display = 'flex';
|
|
2442
|
+
if (onDone) onDone();
|
|
2443
|
+
} catch (e) {
|
|
2444
|
+
hideLoad();
|
|
2445
|
+
console.error(e);
|
|
2446
|
+
toast('IFC parsing failed — please export as GLTF or FBX','err');
|
|
2447
|
+
document.getElementById('welcome').style.display = 'flex';
|
|
2448
|
+
if (onDone) onDone();
|
|
2449
|
+
}
|
|
2450
|
+
})
|
|
2451
|
+
.catch(function(e) {
|
|
2452
|
+
hideLoad();
|
|
2453
|
+
console.error(e);
|
|
2454
|
+
toast('Failed to load IFC file','err');
|
|
2455
|
+
document.getElementById('welcome').style.display = 'flex';
|
|
2456
|
+
if (onDone) onDone();
|
|
2457
|
+
});
|
|
2458
|
+
}
|
|
2459
|
+
|
|
2460
|
+
function postProcess(obj, url, fmt, fileName) {
|
|
2461
|
+
// Normalize scale
|
|
2462
|
+
var box = new THREE.Box3().setFromObject(obj);
|
|
2463
|
+
var center = box.getCenter(new THREE.Vector3());
|
|
2464
|
+
var size = box.getSize(new THREE.Vector3());
|
|
2465
|
+
var maxDim = Math.max(size.x, size.y, size.z);
|
|
2466
|
+
normalizedToModelScale = 1;
|
|
2467
|
+
if (maxDim > 0 && (maxDim > 50 || maxDim < 0.1)) {
|
|
2468
|
+
var s = 20 / maxDim;
|
|
2469
|
+
normalizedToModelScale = 1 / s;
|
|
2470
|
+
obj.scale.setScalar(s);
|
|
2471
|
+
obj.position.sub(center.clone().multiplyScalar(s));
|
|
2472
|
+
} else {
|
|
2473
|
+
obj.position.sub(center);
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2476
|
+
// Apply material settings
|
|
2477
|
+
var origMaterials = [];
|
|
2478
|
+
obj.traverse(function(c) {
|
|
2479
|
+
if (c.isMesh) {
|
|
2480
|
+
c.castShadow = c.receiveShadow = true;
|
|
2481
|
+
c.material = Array.isArray(c.material) ? c.material.map(function(m){return m.clone();}) : c.material.clone();
|
|
2482
|
+
var mats = Array.isArray(c.material) ? c.material : [c.material];
|
|
2483
|
+
mats.forEach(function(m) {
|
|
2484
|
+
m.side = THREE.DoubleSide;
|
|
2485
|
+
m.clippingPlanes = allClipPlanes;
|
|
2486
|
+
m.clipIntersection = false;
|
|
2487
|
+
origMaterials.push({mat:m, color:m.color.clone(), opacity:m.opacity, transparent:m.transparent});
|
|
2488
|
+
});
|
|
2489
|
+
}
|
|
2490
|
+
});
|
|
2491
|
+
|
|
2492
|
+
scene.add(obj);
|
|
2493
|
+
model = obj;
|
|
2494
|
+
modelUrl = url;
|
|
2495
|
+
|
|
2496
|
+
// Create model entry
|
|
2497
|
+
var meshCount = 0;
|
|
2498
|
+
obj.traverse(function(c) { if(c.isMesh) meshCount++; });
|
|
2499
|
+
|
|
2500
|
+
var name = fileName || url.split('/').pop().split('?')[0] || 'model';
|
|
2501
|
+
var modelEntry = {
|
|
2502
|
+
id: nextModelId,
|
|
2503
|
+
name: name,
|
|
2504
|
+
format: fmt,
|
|
2505
|
+
obj: obj,
|
|
2506
|
+
meshCount: meshCount,
|
|
2507
|
+
visible: true,
|
|
2508
|
+
color: new THREE.Color(0xcccccc),
|
|
2509
|
+
opacity: 1.0,
|
|
2510
|
+
url: url,
|
|
2511
|
+
fileName: fileName || url.split('/').pop().split('?')[0] || 'model',
|
|
2512
|
+
origMaterials: origMaterials,
|
|
2513
|
+
firebaseUrl: null,
|
|
2514
|
+
modelStorageRef: null
|
|
2515
|
+
};
|
|
2516
|
+
loadedModels.push(modelEntry);
|
|
2517
|
+
nextModelId++;
|
|
2518
|
+
|
|
2519
|
+
// Recompute bounding box from all models
|
|
2520
|
+
updateModelBBox();
|
|
2521
|
+
|
|
2522
|
+
// Scale measurement markers
|
|
2523
|
+
if (modelBBox) {
|
|
2524
|
+
var sz = modelBBox.getSize(new THREE.Vector3());
|
|
2525
|
+
var markerR = Math.max(sz.x, sz.y, sz.z) * 0.012;
|
|
2526
|
+
measMarkerA.scale.setScalar(markerR / 0.15);
|
|
2527
|
+
measMarkerB.scale.setScalar(markerR / 0.15);
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
// Reset clip sliders
|
|
2531
|
+
document.getElementById('clip-x').value = 0;
|
|
2532
|
+
document.getElementById('clip-x-val').textContent = '0%';
|
|
2533
|
+
document.getElementById('clip-y').value = 0;
|
|
2534
|
+
document.getElementById('clip-y-val').textContent = '0%';
|
|
2535
|
+
document.getElementById('clip-z').value = 0;
|
|
2536
|
+
document.getElementById('clip-z-val').textContent = '0%';
|
|
2537
|
+
|
|
2538
|
+
updateSelectionTree();
|
|
2539
|
+
resetCam();
|
|
2540
|
+
hideLoad();
|
|
2541
|
+
document.getElementById('dot-model').classList.add('grn');
|
|
2542
|
+
var totalModels = loadedModels.length;
|
|
2543
|
+
var totalMeshes = 0;
|
|
2544
|
+
loadedModels.forEach(function(m) { totalMeshes += m.meshCount; });
|
|
2545
|
+
document.getElementById('st-model').textContent = totalModels + ' model' + (totalModels!==1?'s':'') + ' • ' + totalMeshes + ' meshes';
|
|
2546
|
+
document.getElementById('cam-hud').style.display = 'block';
|
|
2547
|
+
document.getElementById('ctrl-hud').style.display = 'block';
|
|
2548
|
+
toast('✓ ' + fmt.toUpperCase() + ' loaded — ' + meshCount + ' meshes','ok');
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2551
|
+
function updateModelBBox() {
|
|
2552
|
+
modelBBox = null;
|
|
2553
|
+
loadedModels.forEach(function(entry) {
|
|
2554
|
+
var box = new THREE.Box3().setFromObject(entry.obj);
|
|
2555
|
+
if (!modelBBox) {
|
|
2556
|
+
modelBBox = box;
|
|
2557
|
+
} else {
|
|
2558
|
+
modelBBox.union(box);
|
|
2559
|
+
}
|
|
2560
|
+
});
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2563
|
+
function updateSelectionTree() {
|
|
2564
|
+
updateProjectSummary();
|
|
2565
|
+
var list = document.getElementById('tree-list');
|
|
2566
|
+
list.innerHTML = '';
|
|
2567
|
+
var totalModels = loadedModels.length;
|
|
2568
|
+
var totalMeshes = 0;
|
|
2569
|
+
loadedModels.forEach(function(m) { totalMeshes += m.meshCount; });
|
|
2570
|
+
document.getElementById('tree-summary').textContent = totalModels + ' model' + (totalModels!==1?'s':'') + ' • ' + totalMeshes + ' meshes';
|
|
2571
|
+
|
|
2572
|
+
loadedModels.forEach(function(entry) {
|
|
2573
|
+
var item = document.createElement('div');
|
|
2574
|
+
item.className = 'tree-item';
|
|
2575
|
+
item.setAttribute('data-id', entry.id);
|
|
2576
|
+
|
|
2577
|
+
var row = document.createElement('div');
|
|
2578
|
+
row.className = 'tree-row';
|
|
2579
|
+
if (selectedModelId === entry.id) row.classList.add('selected');
|
|
2580
|
+
|
|
2581
|
+
var visBtn = document.createElement('button');
|
|
2582
|
+
visBtn.className = 'tree-vis';
|
|
2583
|
+
visBtn.textContent = entry.visible ? '👁' : '🚫';
|
|
2584
|
+
visBtn.onclick = function(e) { e.stopPropagation(); toggleModelVis(entry.id); };
|
|
2585
|
+
|
|
2586
|
+
var nameSpan = document.createElement('span');
|
|
2587
|
+
nameSpan.className = 'tree-name clickable';
|
|
2588
|
+
nameSpan.textContent = entry.name;
|
|
2589
|
+
nameSpan.onclick = function() { selectModel(entry.id); };
|
|
2590
|
+
|
|
2591
|
+
var badge = document.createElement('span');
|
|
2592
|
+
badge.className = 'badge bg-g';
|
|
2593
|
+
badge.textContent = entry.format.toUpperCase();
|
|
2594
|
+
badge.style.fontSize = '9px';
|
|
2595
|
+
|
|
2596
|
+
row.appendChild(visBtn);
|
|
2597
|
+
row.appendChild(nameSpan);
|
|
2598
|
+
row.appendChild(badge);
|
|
2599
|
+
|
|
2600
|
+
var details = document.createElement('div');
|
|
2601
|
+
details.className = 'tree-details open';
|
|
2602
|
+
|
|
2603
|
+
var info = document.createElement('span');
|
|
2604
|
+
info.className = 'tree-info';
|
|
2605
|
+
info.textContent = entry.meshCount + ' meshes';
|
|
2606
|
+
|
|
2607
|
+
var colorLabel = document.createElement('label');
|
|
2608
|
+
colorLabel.className = 'color-swatch-sm';
|
|
2609
|
+
var colorInput = document.createElement('input');
|
|
2610
|
+
colorInput.type = 'color';
|
|
2611
|
+
colorInput.value = '#' + entry.color.getHexString();
|
|
2612
|
+
colorInput.oninput = function() { applyModelColor(entry.id, this.value); };
|
|
2613
|
+
colorLabel.appendChild(colorInput);
|
|
2614
|
+
|
|
2615
|
+
var opacitySlider = document.createElement('input');
|
|
2616
|
+
opacitySlider.type = 'range';
|
|
2617
|
+
opacitySlider.className = 'tree-opacity';
|
|
2618
|
+
opacitySlider.min = '0';
|
|
2619
|
+
opacitySlider.max = '100';
|
|
2620
|
+
opacitySlider.value = Math.round(entry.opacity * 100);
|
|
2621
|
+
opacitySlider.oninput = function() { applyModelOpacity(entry.id, this.value); };
|
|
2622
|
+
|
|
2623
|
+
var delBtn = document.createElement('button');
|
|
2624
|
+
delBtn.className = 'tree-del';
|
|
2625
|
+
delBtn.textContent = '🗑';
|
|
2626
|
+
delBtn.onclick = function(e) { e.stopPropagation(); removeModel(entry.id); };
|
|
2627
|
+
|
|
2628
|
+
details.appendChild(info);
|
|
2629
|
+
details.appendChild(colorLabel);
|
|
2630
|
+
details.appendChild(opacitySlider);
|
|
2631
|
+
details.appendChild(delBtn);
|
|
2632
|
+
|
|
2633
|
+
item.appendChild(row);
|
|
2634
|
+
item.appendChild(details);
|
|
2635
|
+
list.appendChild(item);
|
|
2636
|
+
});
|
|
2637
|
+
}
|
|
2638
|
+
|
|
2639
|
+
function selectModel(id) {
|
|
2640
|
+
selectedModelId = (selectedModelId === id) ? -1 : id;
|
|
2641
|
+
updateSelectionTree();
|
|
2642
|
+
updateAppearanceLabel();
|
|
2643
|
+
}
|
|
2644
|
+
|
|
2645
|
+
function updateAppearanceLabel() {
|
|
2646
|
+
if (selectedModelId === -1) {
|
|
2647
|
+
document.getElementById('appearance-sel').textContent = 'All Models';
|
|
2648
|
+
} else {
|
|
2649
|
+
var entry = loadedModels.find(function(m) { return m.id === selectedModelId; });
|
|
2650
|
+
document.getElementById('appearance-sel').textContent = entry ? entry.name : 'All Models';
|
|
2651
|
+
}
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2654
|
+
function toggleModelVis(id) {
|
|
2655
|
+
var entry = loadedModels.find(function(m) { return m.id === id; });
|
|
2656
|
+
if (entry) {
|
|
2657
|
+
entry.visible = !entry.visible;
|
|
2658
|
+
entry.obj.visible = entry.visible;
|
|
2659
|
+
updateSelectionTree();
|
|
2660
|
+
}
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2663
|
+
function removeModel(id) {
|
|
2664
|
+
var idx = loadedModels.findIndex(function(m) { return m.id === id; });
|
|
2665
|
+
if (idx !== -1) {
|
|
2666
|
+
scene.remove(loadedModels[idx].obj);
|
|
2667
|
+
loadedModels.splice(idx, 1);
|
|
2668
|
+
if (selectedModelId === id) selectedModelId = -1;
|
|
2669
|
+
updateModelBBox();
|
|
2670
|
+
updateSelectionTree();
|
|
2671
|
+
updateAppearanceLabel();
|
|
2672
|
+
if (loadedModels.length === 0) {
|
|
2673
|
+
document.getElementById('welcome').style.display = 'flex';
|
|
2674
|
+
document.getElementById('dot-model').classList.remove('grn');
|
|
2675
|
+
document.getElementById('st-model').textContent = 'No model loaded';
|
|
2676
|
+
model = null;
|
|
2677
|
+
}
|
|
2678
|
+
}
|
|
2679
|
+
}
|
|
2680
|
+
|
|
2681
|
+
function applyModelColor(id, hex) {
|
|
2682
|
+
var entry = loadedModels.find(function(m) { return m.id === id; });
|
|
2683
|
+
if (!entry) return;
|
|
2684
|
+
var col = new THREE.Color(hex);
|
|
2685
|
+
entry.color = col.clone();
|
|
2686
|
+
entry.obj.traverse(function(c) {
|
|
2687
|
+
if (c.isMesh) {
|
|
2688
|
+
var mats = Array.isArray(c.material) ? c.material : [c.material];
|
|
2689
|
+
mats.forEach(function(m) { m.color.copy(col); });
|
|
2690
|
+
}
|
|
2691
|
+
});
|
|
2692
|
+
updateSelectionTree();
|
|
2693
|
+
}
|
|
2694
|
+
|
|
2695
|
+
function applyModelOpacity(id, val) {
|
|
2696
|
+
val = parseFloat(val) / 100;
|
|
2697
|
+
var entry = loadedModels.find(function(m) { return m.id === id; });
|
|
2698
|
+
if (!entry) return;
|
|
2699
|
+
entry.opacity = val;
|
|
2700
|
+
entry.obj.traverse(function(c) {
|
|
2701
|
+
if (c.isMesh) {
|
|
2702
|
+
var mats = Array.isArray(c.material) ? c.material : [c.material];
|
|
2703
|
+
mats.forEach(function(m) { m.transparent=(val<1); m.opacity=val; });
|
|
2704
|
+
}
|
|
2705
|
+
});
|
|
2706
|
+
}
|
|
2707
|
+
|
|
2708
|
+
// ── DRAG & DROP ──────────────────────────────────────────────
|
|
2709
|
+
function setupDrop() {
|
|
2710
|
+
var dz=document.getElementById('drop-zone'), vp=document.getElementById('viewport');
|
|
2711
|
+
var ov=document.getElementById('drop-over'), fi=document.getElementById('file-input');
|
|
2712
|
+
fi.addEventListener('change', function(e){
|
|
2713
|
+
if (e.target.files.length > 0) {
|
|
2714
|
+
for (var i = 0; i < e.target.files.length; i++) {
|
|
2715
|
+
handleFile(e.target.files[i]);
|
|
2716
|
+
}
|
|
2717
|
+
}
|
|
2718
|
+
});
|
|
2719
|
+
dz.addEventListener('dragover',function(e){e.preventDefault();dz.classList.add('over');});
|
|
2720
|
+
dz.addEventListener('dragleave',function(){dz.classList.remove('over');});
|
|
2721
|
+
dz.addEventListener('drop',function(e){e.preventDefault();dz.classList.remove('over');
|
|
2722
|
+
for (var i = 0; i < e.dataTransfer.files.length; i++) {
|
|
2723
|
+
handleFile(e.dataTransfer.files[i]);
|
|
2724
|
+
}
|
|
2725
|
+
});
|
|
2726
|
+
vp.addEventListener('dragover',function(e){e.preventDefault();ov.style.display='flex';});
|
|
2727
|
+
vp.addEventListener('dragleave',function(){ov.style.display='none';});
|
|
2728
|
+
vp.addEventListener('drop',function(e){e.preventDefault();ov.style.display='none';
|
|
2729
|
+
for (var i = 0; i < e.dataTransfer.files.length; i++) {
|
|
2730
|
+
handleFile(e.dataTransfer.files[i]);
|
|
2731
|
+
}
|
|
2732
|
+
});
|
|
2733
|
+
}
|
|
2734
|
+
|
|
2735
|
+
function handleFile(file) {
|
|
2736
|
+
var ext = file.name.split('.').pop().toLowerCase();
|
|
2737
|
+
if (!['glb','gltf','fbx','obj','ifc'].includes(ext)){toast('Unsupported: .'+ext,'err');return;}
|
|
2738
|
+
|
|
2739
|
+
// Check 100MB file size limit
|
|
2740
|
+
if (file.size > 100 * 1024 * 1024) {
|
|
2741
|
+
toast('File exceeds 100MB limit','err');
|
|
2742
|
+
return;
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
modelIsLocal = true;
|
|
2746
|
+
var blobUrl = URL.createObjectURL(file);
|
|
2747
|
+
|
|
2748
|
+
// Always load locally first (don't wait for Firebase upload)
|
|
2749
|
+
document.getElementById('model-url').value = '[local] ' + file.name;
|
|
2750
|
+
loadModel(blobUrl, ext, null, file.name);
|
|
2751
|
+
|
|
2752
|
+
// Upload to Firebase in background if configured
|
|
2753
|
+
if (firebaseReady && storage && currentUser) {
|
|
2754
|
+
var fileSizeMB = file.size / (1024 * 1024);
|
|
2755
|
+
canUploadFile(fileSizeMB, function(allowed) {
|
|
2756
|
+
if (!allowed) return;
|
|
2757
|
+
_doFirebaseUpload(file, fileSizeMB);
|
|
2758
|
+
});
|
|
2759
|
+
}
|
|
2760
|
+
}
|
|
2761
|
+
|
|
2762
|
+
function _doFirebaseUpload(file, fileSizeMB) {
|
|
2763
|
+
var timestamp = Date.now();
|
|
2764
|
+
var storageRef = 'models/' + timestamp + '_' + file.name;
|
|
2765
|
+
var uploadTask = storage.ref(storageRef).put(file);
|
|
2766
|
+
|
|
2767
|
+
// Show progress bar
|
|
2768
|
+
var progBar = document.getElementById('upload-progress-bar');
|
|
2769
|
+
if (progBar) progBar.style.display = 'block';
|
|
2770
|
+
|
|
2771
|
+
uploadTask.on('state_changed',
|
|
2772
|
+
function(snapshot) {
|
|
2773
|
+
var pct = (snapshot.bytesTransferred / snapshot.totalBytes) * 100;
|
|
2774
|
+
if (progBar) progBar.style.width = pct + '%';
|
|
2775
|
+
},
|
|
2776
|
+
function(error) {
|
|
2777
|
+
console.error('Firebase upload error:', error);
|
|
2778
|
+
if (progBar) progBar.style.display = 'none';
|
|
2779
|
+
toast('Firebase upload failed: ' + error.message, 'err');
|
|
2780
|
+
},
|
|
2781
|
+
function() {
|
|
2782
|
+
uploadTask.snapshot.ref.getDownloadURL().then(function(url) {
|
|
2783
|
+
// Store Firebase URL in the loaded model
|
|
2784
|
+
if (loadedModels.length > 0) {
|
|
2785
|
+
loadedModels[loadedModels.length - 1].firebaseUrl = url;
|
|
2786
|
+
loadedModels[loadedModels.length - 1].modelStorageRef = storageRef;
|
|
2787
|
+
}
|
|
2788
|
+
if (progBar) progBar.style.display = 'none';
|
|
2789
|
+
addStorageUsed(fileSizeMB);
|
|
2790
|
+
toast('✓ Uploaded to Firebase','ok');
|
|
2791
|
+
});
|
|
2792
|
+
}
|
|
2793
|
+
);
|
|
2794
|
+
}
|
|
2795
|
+
|
|
2796
|
+
// ── VIEWPOINTS ───────────────────────────────────────────────
|
|
2797
|
+
function saveViewpoint() {
|
|
2798
|
+
var promptName = prompt('Viewpoint name:', 'Viewpoint ' + (savedViewpoints.length + 1));
|
|
2799
|
+
if (!promptName) return;
|
|
2800
|
+
var p=camera.position, t=controls.target;
|
|
2801
|
+
var vp = {
|
|
2802
|
+
id: nextViewpointId,
|
|
2803
|
+
name: promptName,
|
|
2804
|
+
cam: {x: p.x, y: p.y, z: p.z},
|
|
2805
|
+
target: {x: t.x, y: t.y, z: t.z},
|
|
2806
|
+
timestamp: new Date().toLocaleString()
|
|
2807
|
+
};
|
|
2808
|
+
savedViewpoints.push(vp);
|
|
2809
|
+
nextViewpointId++;
|
|
2810
|
+
updateViewpointsList();
|
|
2811
|
+
toast('✓ Viewpoint saved','ok');
|
|
2812
|
+
}
|
|
2813
|
+
|
|
2814
|
+
function updateViewpointsList() {
|
|
2815
|
+
updateProjectSummary();
|
|
2816
|
+
var list = document.getElementById('vp-list');
|
|
2817
|
+
list.innerHTML = '';
|
|
2818
|
+
savedViewpoints.forEach(function(vp) {
|
|
2819
|
+
var item = document.createElement('div');
|
|
2820
|
+
item.className = 'vp-item';
|
|
2821
|
+
|
|
2822
|
+
var nameSpan = document.createElement('span');
|
|
2823
|
+
nameSpan.className = 'vp-name editable';
|
|
2824
|
+
nameSpan.textContent = vp.name;
|
|
2825
|
+
nameSpan.ondblclick = function(e) {
|
|
2826
|
+
e.stopPropagation();
|
|
2827
|
+
var newName = prompt('Rename viewpoint:', vp.name);
|
|
2828
|
+
if (newName) {
|
|
2829
|
+
vp.name = newName;
|
|
2830
|
+
updateViewpointsList();
|
|
2831
|
+
}
|
|
2832
|
+
};
|
|
2833
|
+
nameSpan.onclick = function() {
|
|
2834
|
+
camera.position.set(vp.cam.x, vp.cam.y, vp.cam.z);
|
|
2835
|
+
controls.target.set(vp.target.x, vp.target.y, vp.target.z);
|
|
2836
|
+
controls.update();
|
|
2837
|
+
toast('📍 Viewpoint restored','ok');
|
|
2838
|
+
};
|
|
2839
|
+
|
|
2840
|
+
var timeSpan = document.createElement('span');
|
|
2841
|
+
timeSpan.className = 'vp-time';
|
|
2842
|
+
timeSpan.textContent = vp.timestamp;
|
|
2843
|
+
|
|
2844
|
+
var delBtn = document.createElement('button');
|
|
2845
|
+
delBtn.className = 'vp-del';
|
|
2846
|
+
delBtn.textContent = '✕';
|
|
2847
|
+
delBtn.onclick = function(e) {
|
|
2848
|
+
e.stopPropagation();
|
|
2849
|
+
var idx = savedViewpoints.findIndex(function(v) { return v.id === vp.id; });
|
|
2850
|
+
if (idx !== -1) {
|
|
2851
|
+
savedViewpoints.splice(idx, 1);
|
|
2852
|
+
updateViewpointsList();
|
|
2853
|
+
toast('Viewpoint deleted','ok');
|
|
2854
|
+
}
|
|
2855
|
+
};
|
|
2856
|
+
|
|
2857
|
+
item.appendChild(nameSpan);
|
|
2858
|
+
item.appendChild(timeSpan);
|
|
2859
|
+
item.appendChild(delBtn);
|
|
2860
|
+
list.appendChild(item);
|
|
2861
|
+
});
|
|
2862
|
+
// Show/hide BCF export button based on viewpoint count
|
|
2863
|
+
var bcfBtn = document.getElementById('btn-export-bcf');
|
|
2864
|
+
if (bcfBtn) bcfBtn.style.display = savedViewpoints.length > 0 ? '' : 'none';
|
|
2865
|
+
}
|
|
2866
|
+
|
|
2867
|
+
// ── BCF-XML EXPORT ──────────────────────────────────────────
|
|
2868
|
+
function generateUUID() {
|
|
2869
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
|
2870
|
+
var r = Math.random() * 16 | 0;
|
|
2871
|
+
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
|
|
2872
|
+
});
|
|
2873
|
+
}
|
|
2874
|
+
|
|
2875
|
+
function exportBCF() {
|
|
2876
|
+
if (savedViewpoints.length === 0) {
|
|
2877
|
+
toast('Save at least one viewpoint first', 'err');
|
|
2878
|
+
return;
|
|
2879
|
+
}
|
|
2880
|
+
if (typeof JSZip === 'undefined') {
|
|
2881
|
+
toast('JSZip not loaded — check your connection', 'err');
|
|
2882
|
+
return;
|
|
2883
|
+
}
|
|
2884
|
+
|
|
2885
|
+
showLoad('Generating BCF file…');
|
|
2886
|
+
var zip = new JSZip();
|
|
2887
|
+
|
|
2888
|
+
// BCF version file (BCF 2.1)
|
|
2889
|
+
zip.file('bcf.version', '<?xml version="1.0" encoding="UTF-8"?>\n' +
|
|
2890
|
+
'<Version VersionId="2.1" xsi:noNamespaceSchemaLocation="version.xsd" ' +
|
|
2891
|
+
'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">\n' +
|
|
2892
|
+
' <DetailedVersion>2.1</DetailedVersion>\n' +
|
|
2893
|
+
'</Version>');
|
|
2894
|
+
|
|
2895
|
+
// Project info
|
|
2896
|
+
var projectName = document.getElementById('qr-project-name')
|
|
2897
|
+
? (document.getElementById('qr-project-name').value || 'ScanBIM Project')
|
|
2898
|
+
: 'ScanBIM Project';
|
|
2899
|
+
var projectGuid = generateUUID();
|
|
2900
|
+
zip.file('project.bcfp', '<?xml version="1.0" encoding="UTF-8"?>\n' +
|
|
2901
|
+
'<ProjectExtension xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">\n' +
|
|
2902
|
+
' <Project ProjectId="' + projectGuid + '">\n' +
|
|
2903
|
+
' <Name>' + escapeXml(projectName) + '</Name>\n' +
|
|
2904
|
+
' </Project>\n' +
|
|
2905
|
+
'</ProjectExtension>');
|
|
2906
|
+
|
|
2907
|
+
// Each viewpoint becomes a BCF topic
|
|
2908
|
+
savedViewpoints.forEach(function(vp) {
|
|
2909
|
+
var topicGuid = generateUUID();
|
|
2910
|
+
var vpGuid = generateUUID();
|
|
2911
|
+
var folder = zip.folder(topicGuid);
|
|
2912
|
+
|
|
2913
|
+
// Compute camera direction vector (target - position, normalized)
|
|
2914
|
+
var dx = vp.target.x - vp.cam.x;
|
|
2915
|
+
var dy = vp.target.y - vp.cam.y;
|
|
2916
|
+
var dz = vp.target.z - vp.cam.z;
|
|
2917
|
+
var len = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
|
2918
|
+
if (len > 0) { dx /= len; dy /= len; dz /= len; }
|
|
2919
|
+
else { dx = 0; dy = 0; dz = -1; }
|
|
2920
|
+
|
|
2921
|
+
// Camera up vector — Three.js default is (0, 1, 0)
|
|
2922
|
+
// For BCF, up must be perpendicular to direction. Recompute via cross products.
|
|
2923
|
+
var upX = 0, upY = 1, upZ = 0;
|
|
2924
|
+
// right = direction x up
|
|
2925
|
+
var rx = dy * upZ - dz * upY;
|
|
2926
|
+
var ry = dz * upX - dx * upZ;
|
|
2927
|
+
var rz = dx * upY - dy * upX;
|
|
2928
|
+
var rLen = Math.sqrt(rx * rx + ry * ry + rz * rz);
|
|
2929
|
+
if (rLen > 0.001) {
|
|
2930
|
+
rx /= rLen; ry /= rLen; rz /= rLen;
|
|
2931
|
+
// Recompute up = right x direction
|
|
2932
|
+
upX = ry * dz - rz * dy;
|
|
2933
|
+
upY = rz * dx - rx * dz;
|
|
2934
|
+
upZ = rx * dy - ry * dx;
|
|
2935
|
+
}
|
|
2936
|
+
|
|
2937
|
+
var fov = (typeof camera !== 'undefined' && camera.fov) ? camera.fov : 60;
|
|
2938
|
+
var creationDate = vp.timestamp && vp.timestamp !== 'Shared'
|
|
2939
|
+
? new Date(vp.timestamp).toISOString()
|
|
2940
|
+
: new Date().toISOString();
|
|
2941
|
+
|
|
2942
|
+
// markup.bcf
|
|
2943
|
+
folder.file('markup.bcf', '<?xml version="1.0" encoding="UTF-8"?>\n' +
|
|
2944
|
+
'<Markup xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">\n' +
|
|
2945
|
+
' <Topic Guid="' + topicGuid + '" TopicType="Information" TopicStatus="Open">\n' +
|
|
2946
|
+
' <Title>' + escapeXml(vp.name) + '</Title>\n' +
|
|
2947
|
+
' <CreationDate>' + creationDate + '</CreationDate>\n' +
|
|
2948
|
+
' <CreationAuthor>ScanBIM</CreationAuthor>\n' +
|
|
2949
|
+
' <Description>Viewpoint exported from ScanBIM</Description>\n' +
|
|
2950
|
+
' </Topic>\n' +
|
|
2951
|
+
' <Viewpoints Guid="' + vpGuid + '">\n' +
|
|
2952
|
+
' <Viewpoint>viewpoint.bcfv</Viewpoint>\n' +
|
|
2953
|
+
' </Viewpoints>\n' +
|
|
2954
|
+
'</Markup>');
|
|
2955
|
+
|
|
2956
|
+
// viewpoint.bcfv
|
|
2957
|
+
folder.file('viewpoint.bcfv', '<?xml version="1.0" encoding="UTF-8"?>\n' +
|
|
2958
|
+
'<VisualizationInfo Guid="' + vpGuid + '" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">\n' +
|
|
2959
|
+
' <PerspectiveCamera>\n' +
|
|
2960
|
+
' <CameraViewPoint>\n' +
|
|
2961
|
+
' <X>' + vp.cam.x.toFixed(6) + '</X>\n' +
|
|
2962
|
+
' <Y>' + vp.cam.z.toFixed(6) + '</Y>\n' +
|
|
2963
|
+
' <Z>' + (-vp.cam.y).toFixed(6) + '</Z>\n' +
|
|
2964
|
+
' </CameraViewPoint>\n' +
|
|
2965
|
+
' <CameraDirection>\n' +
|
|
2966
|
+
' <X>' + dx.toFixed(6) + '</X>\n' +
|
|
2967
|
+
' <Y>' + dz.toFixed(6) + '</Y>\n' +
|
|
2968
|
+
' <Z>' + (-dy).toFixed(6) + '</Z>\n' +
|
|
2969
|
+
' </CameraDirection>\n' +
|
|
2970
|
+
' <CameraUpVector>\n' +
|
|
2971
|
+
' <X>' + upX.toFixed(6) + '</X>\n' +
|
|
2972
|
+
' <Y>' + upZ.toFixed(6) + '</Y>\n' +
|
|
2973
|
+
' <Z>' + (-upY).toFixed(6) + '</Z>\n' +
|
|
2974
|
+
' </CameraUpVector>\n' +
|
|
2975
|
+
' <FieldOfView>' + fov.toFixed(1) + '</FieldOfView>\n' +
|
|
2976
|
+
' </PerspectiveCamera>\n' +
|
|
2977
|
+
'</VisualizationInfo>');
|
|
2978
|
+
});
|
|
2979
|
+
|
|
2980
|
+
zip.generateAsync({type: 'blob'}).then(function(blob) {
|
|
2981
|
+
hideLoad();
|
|
2982
|
+
var url = URL.createObjectURL(blob);
|
|
2983
|
+
var a = document.createElement('a');
|
|
2984
|
+
a.href = url;
|
|
2985
|
+
a.download = projectName.replace(/[^a-zA-Z0-9_-]/g, '_') + '.bcf';
|
|
2986
|
+
a.click();
|
|
2987
|
+
URL.revokeObjectURL(url);
|
|
2988
|
+
toast('📤 BCF exported — ' + savedViewpoints.length + ' viewpoint' + (savedViewpoints.length > 1 ? 's' : ''), 'ok');
|
|
2989
|
+
}).catch(function(err) {
|
|
2990
|
+
hideLoad();
|
|
2991
|
+
console.error('BCF export error:', err);
|
|
2992
|
+
toast('BCF export failed: ' + err.message, 'err');
|
|
2993
|
+
});
|
|
2994
|
+
}
|
|
2995
|
+
|
|
2996
|
+
function escapeXml(str) {
|
|
2997
|
+
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
2998
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
2999
|
+
}
|
|
3000
|
+
|
|
3001
|
+
// ── CAMERA ───────────────────────────────────────────────────
|
|
3002
|
+
function resetCam() {
|
|
3003
|
+
if (loadedModels.length === 0){camera.position.set(15,12,15);controls.target.set(0,0,0);controls.update();return;}
|
|
3004
|
+
if (!modelBBox) return;
|
|
3005
|
+
var cen=modelBBox.getCenter(new THREE.Vector3()), sz=modelBBox.getSize(new THREE.Vector3());
|
|
3006
|
+
var d=Math.max(sz.x,sz.y,sz.z)*1.7;
|
|
3007
|
+
camera.position.set(cen.x+d, cen.y+d*.6, cen.z+d);
|
|
3008
|
+
controls.target.copy(cen);
|
|
3009
|
+
controls.update();
|
|
3010
|
+
}
|
|
3011
|
+
|
|
3012
|
+
function toggleGrid(){gridOn=!gridOn;grid.visible=gridOn;}
|
|
3013
|
+
|
|
3014
|
+
function toggleWire(){
|
|
3015
|
+
if(loadedModels.length===0)return;
|
|
3016
|
+
wireOn=!wireOn;
|
|
3017
|
+
loadedModels.forEach(function(entry) {
|
|
3018
|
+
entry.obj.traverse(function(c){if(c.isMesh&&c.material){var m=Array.isArray(c.material)?c.material:[c.material];m.forEach(function(x){x.wireframe=wireOn;});}});
|
|
3019
|
+
});
|
|
3020
|
+
}
|
|
3021
|
+
|
|
3022
|
+
// ── SECTION / CLIPPING ───────────────────────────────────────
|
|
3023
|
+
function updateClip(axis, pct) {
|
|
3024
|
+
pct = parseFloat(pct) / 100;
|
|
3025
|
+
document.getElementById('clip-' + axis + '-val').textContent = Math.round(pct*100) + '%';
|
|
3026
|
+
if (!modelBBox) return;
|
|
3027
|
+
applyClipPlane(axis, pct);
|
|
3028
|
+
}
|
|
3029
|
+
|
|
3030
|
+
function applyClipPlane(axis, pct) {
|
|
3031
|
+
var mn, mx, plane;
|
|
3032
|
+
|
|
3033
|
+
if (axis==='x'){mn=modelBBox.min.x;mx=modelBBox.max.x;plane=clipX;}
|
|
3034
|
+
else if(axis==='y'){
|
|
3035
|
+
mn=modelBBox.min.z;mx=modelBBox.max.z;plane=clipZ;
|
|
3036
|
+
}
|
|
3037
|
+
else if(axis==='z'){
|
|
3038
|
+
mn=modelBBox.min.y;mx=modelBBox.max.y;plane=clipY;
|
|
3039
|
+
}
|
|
3040
|
+
|
|
3041
|
+
if (pct === 0) { plane.constant = 99999; return; }
|
|
3042
|
+
if (!clipFlip[axis]) {
|
|
3043
|
+
if (axis==='x') plane.normal.set(-1,0,0);
|
|
3044
|
+
else if(axis==='y') plane.normal.set(0,0,-1);
|
|
3045
|
+
else plane.normal.set(0,-1,0);
|
|
3046
|
+
plane.constant = mx - pct * (mx - mn);
|
|
3047
|
+
} else {
|
|
3048
|
+
if (axis==='x') plane.normal.set(1,0,0);
|
|
3049
|
+
else if(axis==='y') plane.normal.set(0,0,1);
|
|
3050
|
+
else plane.normal.set(0,1,0);
|
|
3051
|
+
plane.constant = -(mn + pct * (mx - mn));
|
|
3052
|
+
}
|
|
3053
|
+
}
|
|
3054
|
+
|
|
3055
|
+
function toggleFlip(axis) {
|
|
3056
|
+
clipFlip[axis] = !clipFlip[axis];
|
|
3057
|
+
var btn = document.getElementById('flip-' + axis);
|
|
3058
|
+
btn.style.background = clipFlip[axis] ? 'var(--acc)' : '';
|
|
3059
|
+
btn.style.color = clipFlip[axis] ? '#fff' : '';
|
|
3060
|
+
var pct = parseFloat(document.getElementById('clip-' + axis).value) / 100;
|
|
3061
|
+
applyClipPlane(axis, pct);
|
|
3062
|
+
}
|
|
3063
|
+
|
|
3064
|
+
function resetSection() {
|
|
3065
|
+
['x','y','z'].forEach(function(a){
|
|
3066
|
+
document.getElementById('clip-'+a).value = 0;
|
|
3067
|
+
document.getElementById('clip-'+a+'-val').textContent = '0%';
|
|
3068
|
+
clipFlip[a] = false;
|
|
3069
|
+
var btn = document.getElementById('flip-'+a);
|
|
3070
|
+
if (btn) { btn.style.background = ''; btn.style.color = ''; }
|
|
3071
|
+
});
|
|
3072
|
+
clipX.constant = clipY.constant = clipZ.constant = 99999;
|
|
3073
|
+
clipX.normal.set(-1,0,0);
|
|
3074
|
+
clipY.normal.set(0,-1,0);
|
|
3075
|
+
clipZ.normal.set(0,0,-1);
|
|
3076
|
+
}
|
|
3077
|
+
|
|
3078
|
+
// ── COLOR & OPACITY ──────────────────────────────────────────
|
|
3079
|
+
function applyColor(hex) {
|
|
3080
|
+
if (selectedModelId !== -1) {
|
|
3081
|
+
applyModelColor(selectedModelId, hex);
|
|
3082
|
+
} else {
|
|
3083
|
+
var col = new THREE.Color(hex);
|
|
3084
|
+
loadedModels.forEach(function(entry) {
|
|
3085
|
+
entry.color = col.clone();
|
|
3086
|
+
entry.obj.traverse(function(c) {
|
|
3087
|
+
if (c.isMesh) {
|
|
3088
|
+
var mats = Array.isArray(c.material) ? c.material : [c.material];
|
|
3089
|
+
mats.forEach(function(m){m.color.copy(col);});
|
|
3090
|
+
}
|
|
3091
|
+
});
|
|
3092
|
+
});
|
|
3093
|
+
updateSelectionTree();
|
|
3094
|
+
}
|
|
3095
|
+
}
|
|
3096
|
+
|
|
3097
|
+
function resetColor() {
|
|
3098
|
+
if (selectedModelId !== -1) {
|
|
3099
|
+
var entry = loadedModels.find(function(m) { return m.id === selectedModelId; });
|
|
3100
|
+
if (entry) {
|
|
3101
|
+
entry.origMaterials.forEach(function(o) { o.mat.color.copy(o.color); });
|
|
3102
|
+
updateSelectionTree();
|
|
3103
|
+
toast('✓ Color reset','ok');
|
|
3104
|
+
}
|
|
3105
|
+
} else {
|
|
3106
|
+
loadedModels.forEach(function(entry) {
|
|
3107
|
+
entry.origMaterials.forEach(function(o) { o.mat.color.copy(o.color); });
|
|
3108
|
+
});
|
|
3109
|
+
updateSelectionTree();
|
|
3110
|
+
toast('✓ Colors restored','ok');
|
|
3111
|
+
}
|
|
3112
|
+
document.getElementById('model-color').value = '#cccccc';
|
|
3113
|
+
}
|
|
3114
|
+
|
|
3115
|
+
function applyOpacity(val) {
|
|
3116
|
+
val = parseFloat(val) / 100;
|
|
3117
|
+
document.getElementById('opacity-pct').textContent = Math.round(val*100) + '%';
|
|
3118
|
+
if (selectedModelId !== -1) {
|
|
3119
|
+
applyModelOpacity(selectedModelId, val * 100);
|
|
3120
|
+
} else {
|
|
3121
|
+
loadedModels.forEach(function(entry) {
|
|
3122
|
+
entry.opacity = val;
|
|
3123
|
+
entry.obj.traverse(function(c) {
|
|
3124
|
+
if (c.isMesh) {
|
|
3125
|
+
var mats = Array.isArray(c.material) ? c.material : [c.material];
|
|
3126
|
+
mats.forEach(function(m){m.transparent=(val<1);m.opacity=val;});
|
|
3127
|
+
}
|
|
3128
|
+
});
|
|
3129
|
+
});
|
|
3130
|
+
}
|
|
3131
|
+
}
|
|
3132
|
+
|
|
3133
|
+
function resetOpacity() {
|
|
3134
|
+
if (selectedModelId !== -1) {
|
|
3135
|
+
var entry = loadedModels.find(function(m) { return m.id === selectedModelId; });
|
|
3136
|
+
if (entry) {
|
|
3137
|
+
entry.origMaterials.forEach(function(o) { o.mat.opacity=o.opacity; o.mat.transparent=o.transparent; });
|
|
3138
|
+
updateSelectionTree();
|
|
3139
|
+
toast('✓ Opacity reset','ok');
|
|
3140
|
+
}
|
|
3141
|
+
} else {
|
|
3142
|
+
loadedModels.forEach(function(entry) {
|
|
3143
|
+
entry.origMaterials.forEach(function(o) { o.mat.opacity=o.opacity; o.mat.transparent=o.transparent; });
|
|
3144
|
+
});
|
|
3145
|
+
toast('✓ Opacity restored','ok');
|
|
3146
|
+
}
|
|
3147
|
+
document.getElementById('opacity').value = 100;
|
|
3148
|
+
document.getElementById('opacity-pct').textContent = '100%';
|
|
3149
|
+
}
|
|
3150
|
+
|
|
3151
|
+
function unitChanged() {
|
|
3152
|
+
if (measPtA && measPtB) computeAndShowMeasurement(measPtA, measPtB);
|
|
3153
|
+
}
|
|
3154
|
+
|
|
3155
|
+
// ── TOUCH DEVICE UTILITIES ────────────────────────────────────
|
|
3156
|
+
function isTouchDevice() {
|
|
3157
|
+
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
|
|
3158
|
+
(navigator.maxTouchPoints !== undefined && navigator.maxTouchPoints > 0);
|
|
3159
|
+
}
|
|
3160
|
+
|
|
3161
|
+
function showTouchPulse(x, y) {
|
|
3162
|
+
var pulse = document.createElement('div');
|
|
3163
|
+
pulse.style.cssText = 'position:fixed;left:' + x + 'px;top:' + y + 'px;width:40px;height:40px;' +
|
|
3164
|
+
'border:2px solid var(--yel);border-radius:50%;pointer-events:none;z-index:999;' +
|
|
3165
|
+
'transform:translate(-50%,-50%);animation:touchPulse .5s ease-out forwards';
|
|
3166
|
+
document.body.appendChild(pulse);
|
|
3167
|
+
setTimeout(function() { pulse.remove(); }, 500);
|
|
3168
|
+
}
|
|
3169
|
+
|
|
3170
|
+
// ── MARKUP SYSTEM ─────────────────────────────────────────────
|
|
3171
|
+
function initMarkup() {
|
|
3172
|
+
markupCanvas = document.getElementById('markup-canvas');
|
|
3173
|
+
var vp = document.getElementById('viewport');
|
|
3174
|
+
markupCanvas.width = vp.clientWidth;
|
|
3175
|
+
markupCanvas.height = vp.clientHeight;
|
|
3176
|
+
markupCtx = markupCanvas.getContext('2d');
|
|
3177
|
+
|
|
3178
|
+
markupCanvas.addEventListener('mousedown', markupMouseDown, false);
|
|
3179
|
+
markupCanvas.addEventListener('mousemove', markupMouseMove, false);
|
|
3180
|
+
markupCanvas.addEventListener('mouseup', markupMouseUp, false);
|
|
3181
|
+
markupCanvas.addEventListener('touchstart', markupTouchStart, false);
|
|
3182
|
+
markupCanvas.addEventListener('touchmove', markupTouchMove, false);
|
|
3183
|
+
markupCanvas.addEventListener('touchend', markupTouchEnd, false);
|
|
3184
|
+
|
|
3185
|
+
window.addEventListener('resize', function() {
|
|
3186
|
+
var vp = document.getElementById('viewport');
|
|
3187
|
+
markupCanvas.width = vp.clientWidth;
|
|
3188
|
+
markupCanvas.height = vp.clientHeight;
|
|
3189
|
+
redrawMarkup();
|
|
3190
|
+
});
|
|
3191
|
+
}
|
|
3192
|
+
|
|
3193
|
+
function toggleMarkup() {
|
|
3194
|
+
markupActive = !markupActive;
|
|
3195
|
+
markupCanvas.style.pointerEvents = markupActive ? 'all' : 'none';
|
|
3196
|
+
var btn = document.getElementById('markup-toggle-btn');
|
|
3197
|
+
btn.textContent = markupActive ? '✎ Markup Active' : '✎ Enable Markup';
|
|
3198
|
+
btn.classList.toggle('btn-acc', markupActive);
|
|
3199
|
+
btn.classList.toggle('btn-sec', !markupActive);
|
|
3200
|
+
if (markupActive) toast('Markup mode on — draw on the viewport','info');
|
|
3201
|
+
}
|
|
3202
|
+
|
|
3203
|
+
function setMarkupTool(tool) {
|
|
3204
|
+
markupTool = tool;
|
|
3205
|
+
}
|
|
3206
|
+
|
|
3207
|
+
function setMarkupColor(color) {
|
|
3208
|
+
markupColor = color;
|
|
3209
|
+
// Highlight selected color button
|
|
3210
|
+
document.querySelectorAll('.color-btn').forEach(function(btn) {
|
|
3211
|
+
btn.classList.remove('active');
|
|
3212
|
+
if (btn.style.background === color || btn.style.backgroundColor === color) {
|
|
3213
|
+
btn.classList.add('active');
|
|
3214
|
+
}
|
|
3215
|
+
});
|
|
3216
|
+
}
|
|
3217
|
+
|
|
3218
|
+
function setMarkupWidth(w) {
|
|
3219
|
+
markupWidth = w;
|
|
3220
|
+
}
|
|
3221
|
+
|
|
3222
|
+
function markupMouseDown(e) {
|
|
3223
|
+
if (!markupActive) return;
|
|
3224
|
+
var rect = markupCanvas.getBoundingClientRect();
|
|
3225
|
+
markupStartPoint = {x: e.clientX - rect.left, y: e.clientY - rect.top};
|
|
3226
|
+
}
|
|
3227
|
+
|
|
3228
|
+
function markupMouseMove(e) {
|
|
3229
|
+
if (!markupActive || !markupStartPoint) return;
|
|
3230
|
+
var rect = markupCanvas.getBoundingClientRect();
|
|
3231
|
+
var x = e.clientX - rect.left, y = e.clientY - rect.top;
|
|
3232
|
+
redrawMarkup();
|
|
3233
|
+
drawMarkupShape(markupCtx, markupTool, markupStartPoint.x, markupStartPoint.y, x, y, markupColor, markupWidth);
|
|
3234
|
+
}
|
|
3235
|
+
|
|
3236
|
+
function markupMouseUp(e) {
|
|
3237
|
+
if (!markupActive || !markupStartPoint) return;
|
|
3238
|
+
var rect = markupCanvas.getBoundingClientRect();
|
|
3239
|
+
var x = e.clientX - rect.left, y = e.clientY - rect.top;
|
|
3240
|
+
|
|
3241
|
+
if (markupTool === 'text') {
|
|
3242
|
+
var text = prompt('Enter text:');
|
|
3243
|
+
if (text) {
|
|
3244
|
+
markupItems.push({type: 'text', x0: markupStartPoint.x, y0: markupStartPoint.y, text: text, color: markupColor, width: markupWidth});
|
|
3245
|
+
redrawMarkup();
|
|
3246
|
+
}
|
|
3247
|
+
} else {
|
|
3248
|
+
markupItems.push({type: markupTool, x0: markupStartPoint.x, y0: markupStartPoint.y, x1: x, y1: y, color: markupColor, width: markupWidth});
|
|
3249
|
+
redrawMarkup();
|
|
3250
|
+
}
|
|
3251
|
+
markupStartPoint = null;
|
|
3252
|
+
}
|
|
3253
|
+
|
|
3254
|
+
function markupTouchStart(e) {
|
|
3255
|
+
if (!markupActive) return;
|
|
3256
|
+
var rect = markupCanvas.getBoundingClientRect();
|
|
3257
|
+
markupStartPoint = {x: e.touches[0].clientX - rect.left, y: e.touches[0].clientY - rect.top};
|
|
3258
|
+
e.preventDefault();
|
|
3259
|
+
}
|
|
3260
|
+
|
|
3261
|
+
function markupTouchMove(e) {
|
|
3262
|
+
if (!markupActive || !markupStartPoint) return;
|
|
3263
|
+
var rect = markupCanvas.getBoundingClientRect();
|
|
3264
|
+
var x = e.touches[0].clientX - rect.left, y = e.touches[0].clientY - rect.top;
|
|
3265
|
+
redrawMarkup();
|
|
3266
|
+
drawMarkupShape(markupCtx, markupTool, markupStartPoint.x, markupStartPoint.y, x, y, markupColor, markupWidth);
|
|
3267
|
+
e.preventDefault();
|
|
3268
|
+
}
|
|
3269
|
+
|
|
3270
|
+
function markupTouchEnd(e) {
|
|
3271
|
+
if (!markupActive || !markupStartPoint) return;
|
|
3272
|
+
var rect = markupCanvas.getBoundingClientRect();
|
|
3273
|
+
var x = e.changedTouches[0].clientX - rect.left, y = e.changedTouches[0].clientY - rect.top;
|
|
3274
|
+
|
|
3275
|
+
if (markupTool === 'text') {
|
|
3276
|
+
var text = prompt('Enter text:');
|
|
3277
|
+
if (text) {
|
|
3278
|
+
markupItems.push({type: 'text', x0: markupStartPoint.x, y0: markupStartPoint.y, text: text, color: markupColor, width: markupWidth});
|
|
3279
|
+
redrawMarkup();
|
|
3280
|
+
}
|
|
3281
|
+
} else {
|
|
3282
|
+
markupItems.push({type: markupTool, x0: markupStartPoint.x, y0: markupStartPoint.y, x1: x, y1: y, color: markupColor, width: markupWidth});
|
|
3283
|
+
redrawMarkup();
|
|
3284
|
+
}
|
|
3285
|
+
markupStartPoint = null;
|
|
3286
|
+
e.preventDefault();
|
|
3287
|
+
}
|
|
3288
|
+
|
|
3289
|
+
function drawMarkupShape(ctx, type, x0, y0, x1, y1, color, width) {
|
|
3290
|
+
ctx.strokeStyle = color;
|
|
3291
|
+
ctx.fillStyle = color;
|
|
3292
|
+
ctx.lineWidth = width;
|
|
3293
|
+
ctx.lineCap = 'round';
|
|
3294
|
+
ctx.lineJoin = 'round';
|
|
3295
|
+
|
|
3296
|
+
if (type === 'circle') {
|
|
3297
|
+
var r = Math.sqrt((x1-x0)*(x1-x0) + (y1-y0)*(y1-y0));
|
|
3298
|
+
ctx.beginPath();
|
|
3299
|
+
ctx.arc(x0, y0, r, 0, Math.PI*2);
|
|
3300
|
+
ctx.stroke();
|
|
3301
|
+
} else if (type === 'rect') {
|
|
3302
|
+
ctx.strokeRect(x0, y0, x1-x0, y1-y0);
|
|
3303
|
+
} else if (type === 'arrow') {
|
|
3304
|
+
drawArrow(ctx, x0, y0, x1, y1, color, width);
|
|
3305
|
+
}
|
|
3306
|
+
}
|
|
3307
|
+
|
|
3308
|
+
function drawArrow(ctx, fromX, fromY, toX, toY, color, width) {
|
|
3309
|
+
var headlen = 15;
|
|
3310
|
+
var angle = Math.atan2(toY - fromY, toX - fromX);
|
|
3311
|
+
|
|
3312
|
+
ctx.strokeStyle = color;
|
|
3313
|
+
ctx.fillStyle = color;
|
|
3314
|
+
ctx.lineWidth = width;
|
|
3315
|
+
ctx.beginPath();
|
|
3316
|
+
ctx.moveTo(fromX, fromY);
|
|
3317
|
+
ctx.lineTo(toX, toY);
|
|
3318
|
+
ctx.stroke();
|
|
3319
|
+
|
|
3320
|
+
ctx.beginPath();
|
|
3321
|
+
ctx.moveTo(toX, toY);
|
|
3322
|
+
ctx.lineTo(toX - headlen*Math.cos(angle - Math.PI/6), toY - headlen*Math.sin(angle - Math.PI/6));
|
|
3323
|
+
ctx.lineTo(toX - headlen*Math.cos(angle + Math.PI/6), toY - headlen*Math.sin(angle + Math.PI/6));
|
|
3324
|
+
ctx.closePath();
|
|
3325
|
+
ctx.fill();
|
|
3326
|
+
}
|
|
3327
|
+
|
|
3328
|
+
function redrawMarkup() {
|
|
3329
|
+
markupCtx.clearRect(0, 0, markupCanvas.width, markupCanvas.height);
|
|
3330
|
+
markupItems.forEach(function(item) {
|
|
3331
|
+
if (item.type === 'text') {
|
|
3332
|
+
markupCtx.fillStyle = item.color;
|
|
3333
|
+
markupCtx.font = 'bold 14px Inter, sans-serif';
|
|
3334
|
+
markupCtx.fillText(item.text, item.x0, item.y0);
|
|
3335
|
+
} else {
|
|
3336
|
+
drawMarkupShape(markupCtx, item.type, item.x0, item.y0, item.x1, item.y1, item.color, item.width);
|
|
3337
|
+
}
|
|
3338
|
+
});
|
|
3339
|
+
}
|
|
3340
|
+
|
|
3341
|
+
function clearMarkup() {
|
|
3342
|
+
markupItems = [];
|
|
3343
|
+
markupCtx.clearRect(0, 0, markupCanvas.width, markupCanvas.height);
|
|
3344
|
+
toast('Annotations cleared','ok');
|
|
3345
|
+
}
|
|
3346
|
+
|
|
3347
|
+
function exportMarkupPNG() {
|
|
3348
|
+
if (markupItems.length === 0) {
|
|
3349
|
+
toast('No annotations to export','info');
|
|
3350
|
+
return;
|
|
3351
|
+
}
|
|
3352
|
+
// Force a fresh render so the 3D canvas is current
|
|
3353
|
+
renderer.render(scene, camera);
|
|
3354
|
+
// Create composite canvas combining 3D view + annotations
|
|
3355
|
+
var compCanvas = document.createElement('canvas');
|
|
3356
|
+
compCanvas.width = renderer.domElement.width;
|
|
3357
|
+
compCanvas.height = renderer.domElement.height;
|
|
3358
|
+
var compCtx = compCanvas.getContext('2d');
|
|
3359
|
+
// Draw the 3D renderer output first
|
|
3360
|
+
compCtx.drawImage(renderer.domElement, 0, 0);
|
|
3361
|
+
// Draw the view cube overlay if visible
|
|
3362
|
+
var vcCanvas = document.getElementById('viewCubeCanvas');
|
|
3363
|
+
if (vcCanvas) {
|
|
3364
|
+
var rect = vcCanvas.getBoundingClientRect();
|
|
3365
|
+
var parentRect = renderer.domElement.getBoundingClientRect();
|
|
3366
|
+
var ox = (rect.left - parentRect.left) * (compCanvas.width / parentRect.width);
|
|
3367
|
+
var oy = (rect.top - parentRect.top) * (compCanvas.height / parentRect.height);
|
|
3368
|
+
var ow = rect.width * (compCanvas.width / parentRect.width);
|
|
3369
|
+
var oh = rect.height * (compCanvas.height / parentRect.height);
|
|
3370
|
+
compCtx.drawImage(vcCanvas, ox, oy, ow, oh);
|
|
3371
|
+
}
|
|
3372
|
+
// Draw the annotation layer on top
|
|
3373
|
+
compCtx.drawImage(markupCanvas, 0, 0, compCanvas.width, compCanvas.height);
|
|
3374
|
+
compCanvas.toBlob(function(blob) {
|
|
3375
|
+
var url = URL.createObjectURL(blob);
|
|
3376
|
+
var a = document.createElement('a');
|
|
3377
|
+
a.href = url;
|
|
3378
|
+
a.download = 'scanbim-markup.png';
|
|
3379
|
+
a.click();
|
|
3380
|
+
URL.revokeObjectURL(url);
|
|
3381
|
+
toast('✓ Markup exported as PNG','ok');
|
|
3382
|
+
});
|
|
3383
|
+
}
|
|
3384
|
+
|
|
3385
|
+
// ── MEASUREMENT ──────────────────────────────────────────────
|
|
3386
|
+
function startMeasure() {
|
|
3387
|
+
if (loadedModels.length === 0) { toast('Load a model first','err'); return; }
|
|
3388
|
+
measState = 1;
|
|
3389
|
+
measPtA = null;
|
|
3390
|
+
measPtB = null;
|
|
3391
|
+
measMarkerA.visible = measMarkerB.visible = measLine.visible = false;
|
|
3392
|
+
document.getElementById('meas-label').style.display = 'none';
|
|
3393
|
+
document.getElementById('meas-display').classList.remove('show');
|
|
3394
|
+
document.getElementById('viewport').classList.add('measuring');
|
|
3395
|
+
controls.enabled = false;
|
|
3396
|
+
// On touch devices, explicitly detach OrbitControls listeners to prevent interception
|
|
3397
|
+
if (isTouchDevice()) {
|
|
3398
|
+
controls.removeEventListener('touchstart', controls.onTouchStart);
|
|
3399
|
+
controls.removeEventListener('touchend', controls.onTouchEnd);
|
|
3400
|
+
controls.removeEventListener('touchmove', controls.onTouchMove);
|
|
3401
|
+
}
|
|
3402
|
+
var isMobile = isTouchDevice();
|
|
3403
|
+
var msg = isMobile ? '📍 Tap first point on model (Esc to cancel)' : '📍 Click first point on model (Esc to cancel)';
|
|
3404
|
+
setMeasMsg(msg);
|
|
3405
|
+
document.getElementById('dot-meas').className = 'dot yel';
|
|
3406
|
+
document.getElementById('st-meas').textContent = 'Picking point 1…';
|
|
3407
|
+
var toastMsg = isMobile ? 'Tap your first point on the model' : 'Click your first point on the model';
|
|
3408
|
+
toast(toastMsg,'info');
|
|
3409
|
+
}
|
|
3410
|
+
|
|
3411
|
+
function cancelMeasure() {
|
|
3412
|
+
if (measState === 0) return;
|
|
3413
|
+
measState = 0;
|
|
3414
|
+
controls.enabled = true;
|
|
3415
|
+
// Re-attach OrbitControls listeners on touch devices
|
|
3416
|
+
if (isTouchDevice()) {
|
|
3417
|
+
controls.addEventListener('touchstart', controls.onTouchStart);
|
|
3418
|
+
controls.addEventListener('touchend', controls.onTouchEnd);
|
|
3419
|
+
controls.addEventListener('touchmove', controls.onTouchMove);
|
|
3420
|
+
}
|
|
3421
|
+
document.getElementById('viewport').classList.remove('measuring');
|
|
3422
|
+
setMeasMsg('');
|
|
3423
|
+
document.getElementById('dot-meas').className = 'dot';
|
|
3424
|
+
document.getElementById('st-meas').textContent = measPtA && measPtB ? 'Measurement active' : 'No measurement';
|
|
3425
|
+
}
|
|
3426
|
+
|
|
3427
|
+
function clearMeasure() {
|
|
3428
|
+
cancelMeasure();
|
|
3429
|
+
measPtA = measPtB = null;
|
|
3430
|
+
measMarkerA.visible = measMarkerB.visible = measLine.visible = false;
|
|
3431
|
+
document.getElementById('meas-label').style.display = 'none';
|
|
3432
|
+
document.getElementById('meas-display').classList.remove('show');
|
|
3433
|
+
document.getElementById('dot-meas').className = 'dot';
|
|
3434
|
+
document.getElementById('st-meas').textContent = 'No measurement';
|
|
3435
|
+
}
|
|
3436
|
+
|
|
3437
|
+
function setMeasMsg(msg) {
|
|
3438
|
+
var el = document.getElementById('meas-state-msg');
|
|
3439
|
+
if (msg) { el.textContent = msg; el.classList.add('show'); }
|
|
3440
|
+
else { el.textContent = ''; el.classList.remove('show'); }
|
|
3441
|
+
}
|
|
3442
|
+
|
|
3443
|
+
function onViewportClick(e) {
|
|
3444
|
+
if (measState !== 1 && measState !== 2) return;
|
|
3445
|
+
var rect = renderer.domElement.getBoundingClientRect();
|
|
3446
|
+
mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
|
3447
|
+
mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
|
3448
|
+
raycaster.setFromCamera(mouse, camera);
|
|
3449
|
+
|
|
3450
|
+
// Collect all meshes from all loaded models
|
|
3451
|
+
var meshes = [];
|
|
3452
|
+
loadedModels.forEach(function(entry) {
|
|
3453
|
+
entry.obj.traverse(function(c) { if (c.isMesh) meshes.push(c); });
|
|
3454
|
+
});
|
|
3455
|
+
|
|
3456
|
+
var hits = raycaster.intersectObjects(meshes, false);
|
|
3457
|
+
if (hits.length === 0) { toast('No surface hit — click directly on the model','err'); return; }
|
|
3458
|
+
var pt = hits[0].point;
|
|
3459
|
+
|
|
3460
|
+
if (measState === 1) {
|
|
3461
|
+
measPtA = pt.clone();
|
|
3462
|
+
measMarkerA.position.copy(pt);
|
|
3463
|
+
measMarkerA.visible = true;
|
|
3464
|
+
measState = 2;
|
|
3465
|
+
setMeasMsg('📍 Click second point on model (Esc to cancel)');
|
|
3466
|
+
document.getElementById('st-meas').textContent = 'Picking point 2…';
|
|
3467
|
+
toast('Now click your second point','info');
|
|
3468
|
+
} else {
|
|
3469
|
+
measPtB = pt.clone();
|
|
3470
|
+
measMarkerB.position.copy(pt);
|
|
3471
|
+
measMarkerB.visible = true;
|
|
3472
|
+
var pos = measLineGeo.attributes.position.array;
|
|
3473
|
+
pos[0]=measPtA.x; pos[1]=measPtA.y; pos[2]=measPtA.z;
|
|
3474
|
+
pos[3]=measPtB.x; pos[4]=measPtB.y; pos[5]=measPtB.z;
|
|
3475
|
+
measLineGeo.attributes.position.needsUpdate = true;
|
|
3476
|
+
measLine.visible = true;
|
|
3477
|
+
measState = 0;
|
|
3478
|
+
controls.enabled = true;
|
|
3479
|
+
// Re-attach OrbitControls touch listeners on measurement complete
|
|
3480
|
+
if (isTouchDevice()) {
|
|
3481
|
+
controls.addEventListener('touchstart', controls.onTouchStart);
|
|
3482
|
+
controls.addEventListener('touchend', controls.onTouchEnd);
|
|
3483
|
+
controls.addEventListener('touchmove', controls.onTouchMove);
|
|
3484
|
+
}
|
|
3485
|
+
document.getElementById('viewport').classList.remove('measuring');
|
|
3486
|
+
setMeasMsg('');
|
|
3487
|
+
computeAndShowMeasurement(measPtA, measPtB);
|
|
3488
|
+
}
|
|
3489
|
+
}
|
|
3490
|
+
|
|
3491
|
+
function computeAndShowMeasurement(a, b) {
|
|
3492
|
+
var scale = normalizedToModelScale;
|
|
3493
|
+
var modelUnit = document.getElementById('model-unit').value;
|
|
3494
|
+
var toMeters = {m:1, mm:0.001, ft:0.3048, in:0.0254};
|
|
3495
|
+
var mScale = scale * (toMeters[modelUnit] || 1);
|
|
3496
|
+
|
|
3497
|
+
var dx = (b.x - a.x) * mScale;
|
|
3498
|
+
var dy = (b.y - a.y) * mScale;
|
|
3499
|
+
var dz = (b.z - a.z) * mScale;
|
|
3500
|
+
var dist = Math.sqrt(dx*dx + dy*dy + dz*dz);
|
|
3501
|
+
|
|
3502
|
+
var displayUnit = document.getElementById('display-unit').value;
|
|
3503
|
+
var totalStr = formatDist(dist, displayUnit);
|
|
3504
|
+
var dxStr = formatDist(Math.abs(dx), displayUnit);
|
|
3505
|
+
var dyStr = formatDist(Math.abs(dz), displayUnit);
|
|
3506
|
+
var dzStr = formatDist(Math.abs(dy), displayUnit);
|
|
3507
|
+
|
|
3508
|
+
document.getElementById('meas-total').textContent = '⟵ ' + totalStr + ' ⟶';
|
|
3509
|
+
document.getElementById('meas-dx').textContent = dxStr;
|
|
3510
|
+
document.getElementById('meas-dy').textContent = dyStr;
|
|
3511
|
+
document.getElementById('meas-dz').textContent = dzStr;
|
|
3512
|
+
document.getElementById('meas-display').classList.add('show');
|
|
3513
|
+
|
|
3514
|
+
var label = document.getElementById('meas-label');
|
|
3515
|
+
label.textContent = totalStr;
|
|
3516
|
+
label.style.display = 'block';
|
|
3517
|
+
|
|
3518
|
+
document.getElementById('dot-meas').className = 'dot yel';
|
|
3519
|
+
document.getElementById('st-meas').textContent = totalStr;
|
|
3520
|
+
toast('📏 ' + totalStr,'ok');
|
|
3521
|
+
}
|
|
3522
|
+
|
|
3523
|
+
// ── UNIT FORMATTING ──────────────────────────────────────────
|
|
3524
|
+
function gcd(a, b) { return b === 0 ? a : gcd(b, a % b); }
|
|
3525
|
+
|
|
3526
|
+
function toFeetInches(meters) {
|
|
3527
|
+
var neg = meters < 0;
|
|
3528
|
+
var totalIn = Math.abs(meters) * 39.3701;
|
|
3529
|
+
var feet = Math.floor(totalIn / 12);
|
|
3530
|
+
var remIn = totalIn % 12;
|
|
3531
|
+
var wholeIn = Math.floor(remIn);
|
|
3532
|
+
var frac = remIn - wholeIn;
|
|
3533
|
+
var sixteenths = Math.round(frac * 16);
|
|
3534
|
+
if (sixteenths === 16) { sixteenths = 0; wholeIn++; if (wholeIn === 12) { wholeIn = 0; feet++; } }
|
|
3535
|
+
var fracStr = '';
|
|
3536
|
+
if (sixteenths > 0) {
|
|
3537
|
+
var g = gcd(sixteenths, 16);
|
|
3538
|
+
fracStr = ' ' + (sixteenths/g) + '⁄' + (16/g);
|
|
3539
|
+
}
|
|
3540
|
+
return (neg?'-':'') + feet + "'-" + wholeIn + fracStr + '"';
|
|
3541
|
+
}
|
|
3542
|
+
|
|
3543
|
+
function formatDist(meters, unit) {
|
|
3544
|
+
if (unit === 'fi') return toFeetInches(meters);
|
|
3545
|
+
if (unit === 'm') return meters.toFixed(3) + ' m';
|
|
3546
|
+
if (unit === 'mm') return (meters * 1000).toFixed(1) + ' mm';
|
|
3547
|
+
if (unit === 'ft') return (meters * 3.28084).toFixed(3) + ' ft';
|
|
3548
|
+
return toFeetInches(meters);
|
|
3549
|
+
}
|
|
3550
|
+
|
|
3551
|
+
// ── CAPTURE VIEW ─────────────────────────────────────────────
|
|
3552
|
+
function captureView() {
|
|
3553
|
+
if (loadedModels.length === 0) { toast('Load a model first','err'); return; }
|
|
3554
|
+
var p=camera.position, t=controls.target;
|
|
3555
|
+
capturedView = {cx:+p.x.toFixed(4),cy:+p.y.toFixed(4),cz:+p.z.toFixed(4),tx:+t.x.toFixed(4),ty:+t.y.toFixed(4),tz:+t.z.toFixed(4)};
|
|
3556
|
+
document.getElementById('cap-banner').classList.add('show');
|
|
3557
|
+
document.getElementById('dot-qr').classList.add('org');
|
|
3558
|
+
document.getElementById('st-qr').textContent = 'View captured';
|
|
3559
|
+
toast('📷 View captured! Now generate QR or save as viewpoint.','ok');
|
|
3560
|
+
}
|
|
3561
|
+
|
|
3562
|
+
function screenshotView() {
|
|
3563
|
+
if (loadedModels.length === 0) { toast('Load a model first','err'); return; }
|
|
3564
|
+
try {
|
|
3565
|
+
renderer.render(scene, camera);
|
|
3566
|
+
renderer.domElement.toBlob(function(blob) {
|
|
3567
|
+
var url = URL.createObjectURL(blob);
|
|
3568
|
+
var a = document.createElement('a');
|
|
3569
|
+
a.href = url;
|
|
3570
|
+
a.download = 'scanbim-screenshot.png';
|
|
3571
|
+
a.click();
|
|
3572
|
+
URL.revokeObjectURL(url);
|
|
3573
|
+
toast('📷 Screenshot saved','ok');
|
|
3574
|
+
}, 'image/png');
|
|
3575
|
+
} catch (e) {
|
|
3576
|
+
toast('Screenshot failed','err');
|
|
3577
|
+
console.error(e);
|
|
3578
|
+
}
|
|
3579
|
+
}
|
|
3580
|
+
|
|
3581
|
+
// ── QR ───────────────────────────────────────────────────────
|
|
3582
|
+
function isHosted() {
|
|
3583
|
+
return window.location.protocol === 'https:' || window.location.protocol === 'http:';
|
|
3584
|
+
}
|
|
3585
|
+
|
|
3586
|
+
function getAppBaseUrl() {
|
|
3587
|
+
// Returns the base URL of the app (e.g. https://site.netlify.app/ or https://site.netlify.app/scanbim.html)
|
|
3588
|
+
return window.location.origin + window.location.pathname;
|
|
3589
|
+
}
|
|
3590
|
+
|
|
3591
|
+
function getModelShareUrl(entry) {
|
|
3592
|
+
// For hosted models loaded via URL — use the original URL
|
|
3593
|
+
if (entry.url && !entry.url.startsWith('blob:')) return entry.url;
|
|
3594
|
+
// For local files loaded on a hosted site — assume model is in same folder
|
|
3595
|
+
if (isHosted() && entry.fileName) {
|
|
3596
|
+
var base = window.location.origin + window.location.pathname.replace(/[^\/]*$/, '');
|
|
3597
|
+
return base + entry.fileName;
|
|
3598
|
+
}
|
|
3599
|
+
return null;
|
|
3600
|
+
}
|
|
3601
|
+
|
|
3602
|
+
function updateQRStatus() {
|
|
3603
|
+
var hosted = isHosted();
|
|
3604
|
+
var el1 = document.getElementById('qr-status-hosted');
|
|
3605
|
+
var el2 = document.getElementById('qr-status-local');
|
|
3606
|
+
if (el1) el1.style.display = hosted ? 'block' : 'none';
|
|
3607
|
+
if (el2) el2.style.display = hosted ? 'none' : 'block';
|
|
3608
|
+
|
|
3609
|
+
// Update Firebase status
|
|
3610
|
+
var fbStatus = document.getElementById('qr-firebase-status');
|
|
3611
|
+
var fbReady = document.getElementById('qr-firebase-ready');
|
|
3612
|
+
if (fbStatus) fbStatus.style.display = firebaseReady ? 'none' : 'block';
|
|
3613
|
+
if (fbReady) fbReady.style.display = firebaseReady ? 'block' : 'none';
|
|
3614
|
+
}
|
|
3615
|
+
|
|
3616
|
+
function buildQRUrl() {
|
|
3617
|
+
if (loadedModels.length === 0) return {error: 'Load a model first'};
|
|
3618
|
+
if (!capturedView) return {error: 'Capture a view first (use Capture View button)'};
|
|
3619
|
+
|
|
3620
|
+
// Prefer Firebase URL if available, fallback to other share methods
|
|
3621
|
+
var modelUrl = null;
|
|
3622
|
+
if (loadedModels[0].firebaseUrl) {
|
|
3623
|
+
modelUrl = loadedModels[0].firebaseUrl;
|
|
3624
|
+
} else {
|
|
3625
|
+
modelUrl = getModelShareUrl(loadedModels[0]);
|
|
3626
|
+
}
|
|
3627
|
+
|
|
3628
|
+
if (!modelUrl) return {error: 'Model not shareable'};
|
|
3629
|
+
|
|
3630
|
+
var appUrl = getAppBaseUrl();
|
|
3631
|
+
var viewUrl = appUrl + '?model=' + encodeURIComponent(modelUrl) +
|
|
3632
|
+
'&cx=' + capturedView.cx + '&cy=' + capturedView.cy + '&cz=' + capturedView.cz +
|
|
3633
|
+
'&tx=' + capturedView.tx + '&ty=' + capturedView.ty + '&tz=' + capturedView.tz;
|
|
3634
|
+
|
|
3635
|
+
return {url: viewUrl};
|
|
3636
|
+
}
|
|
3637
|
+
|
|
3638
|
+
function generateQR() {
|
|
3639
|
+
if (!currentUser) {
|
|
3640
|
+
showAuthModal();
|
|
3641
|
+
return;
|
|
3642
|
+
}
|
|
3643
|
+
|
|
3644
|
+
if (!firebaseReady && !isHosted()) {
|
|
3645
|
+
toast('Configure Firebase or deploy to a web host (like Netlify) to share QR codes','err');
|
|
3646
|
+
return;
|
|
3647
|
+
}
|
|
3648
|
+
|
|
3649
|
+
// Auto-capture current view if not captured yet
|
|
3650
|
+
if (!capturedView && loadedModels.length > 0) {
|
|
3651
|
+
captureView();
|
|
3652
|
+
}
|
|
3653
|
+
|
|
3654
|
+
if (loadedModels.length === 0) {
|
|
3655
|
+
toast('Load a model first','err');
|
|
3656
|
+
return;
|
|
3657
|
+
}
|
|
3658
|
+
|
|
3659
|
+
if (!capturedView) {
|
|
3660
|
+
toast('Capture a view first (use Capture View button)','err');
|
|
3661
|
+
return;
|
|
3662
|
+
}
|
|
3663
|
+
|
|
3664
|
+
// If Firebase is ready, save to Firestore; otherwise fall back to legacy URL-based sharing
|
|
3665
|
+
if (firebaseReady && db) {
|
|
3666
|
+
saveViewToFirestore();
|
|
3667
|
+
} else {
|
|
3668
|
+
generateQRLegacy();
|
|
3669
|
+
}
|
|
3670
|
+
}
|
|
3671
|
+
|
|
3672
|
+
function saveViewToFirestore() {
|
|
3673
|
+
if (!currentUser) {
|
|
3674
|
+
showAuthModal();
|
|
3675
|
+
return;
|
|
3676
|
+
}
|
|
3677
|
+
|
|
3678
|
+
canCreateProject(function(allowed) {
|
|
3679
|
+
if (!allowed) return;
|
|
3680
|
+
_doSaveViewToFirestore();
|
|
3681
|
+
});
|
|
3682
|
+
}
|
|
3683
|
+
|
|
3684
|
+
function _doSaveViewToFirestore() {
|
|
3685
|
+
showLoad('Saving view to Firestore…');
|
|
3686
|
+
var projectName = document.getElementById('qr-project-name').value || 'Untitled Project';
|
|
3687
|
+
var modelUrl = loadedModels[0].firebaseUrl || getModelShareUrl(loadedModels[0]) || loadedModels[0].url;
|
|
3688
|
+
|
|
3689
|
+
// Bundle all saved viewpoints for full project sharing
|
|
3690
|
+
var bundledViewpoints = savedViewpoints.map(function(vp) {
|
|
3691
|
+
return { name: vp.name, cam: vp.cam, target: vp.target, timestamp: vp.timestamp };
|
|
3692
|
+
});
|
|
3693
|
+
|
|
3694
|
+
var viewData = {
|
|
3695
|
+
modelUrl: modelUrl,
|
|
3696
|
+
modelStorageRef: loadedModels[0].modelStorageRef || null,
|
|
3697
|
+
fileName: loadedModels[0].fileName,
|
|
3698
|
+
camera: {
|
|
3699
|
+
cx: capturedView.cx,
|
|
3700
|
+
cy: capturedView.cy,
|
|
3701
|
+
cz: capturedView.cz,
|
|
3702
|
+
tx: capturedView.tx,
|
|
3703
|
+
ty: capturedView.ty,
|
|
3704
|
+
tz: capturedView.tz
|
|
3705
|
+
},
|
|
3706
|
+
viewpoints: bundledViewpoints,
|
|
3707
|
+
projectName: projectName,
|
|
3708
|
+
createdAt: new Date().toISOString()
|
|
3709
|
+
};
|
|
3710
|
+
|
|
3711
|
+
db.collection('views').add(viewData).then(function(docRef) {
|
|
3712
|
+
hideLoad();
|
|
3713
|
+
var viewUrl = getAppBaseUrl() + '?view=' + docRef.id;
|
|
3714
|
+
document.getElementById('qr-url-txt').textContent = viewUrl;
|
|
3715
|
+
document.getElementById('qr-box').classList.remove('hidden');
|
|
3716
|
+
|
|
3717
|
+
// Generate QR code
|
|
3718
|
+
var qrContainer = document.getElementById('qr-code');
|
|
3719
|
+
qrContainer.innerHTML = '';
|
|
3720
|
+
|
|
3721
|
+
try {
|
|
3722
|
+
qrCodeInstance = new QRCode(qrContainer, {
|
|
3723
|
+
text: viewUrl,
|
|
3724
|
+
width: 148,
|
|
3725
|
+
height: 148,
|
|
3726
|
+
colorDark: '#000000',
|
|
3727
|
+
colorLight: '#ffffff',
|
|
3728
|
+
correctLevel: QRCode.CorrectLevel.M
|
|
3729
|
+
});
|
|
3730
|
+
|
|
3731
|
+
// Add to session's saved QR codes
|
|
3732
|
+
savedQRCodes.push({
|
|
3733
|
+
id: nextQRId++,
|
|
3734
|
+
name: projectName + ' #' + (savedQRCodes.length + 1),
|
|
3735
|
+
viewUrl: viewUrl,
|
|
3736
|
+
viewpoint: JSON.parse(JSON.stringify(capturedView)),
|
|
3737
|
+
firestoreDocId: docRef.id
|
|
3738
|
+
});
|
|
3739
|
+
incrementProjectCount();
|
|
3740
|
+
updateProjectSummary();
|
|
3741
|
+
updateQRList();
|
|
3742
|
+
toast('✓ View saved to Firestore! QR code ready.','ok');
|
|
3743
|
+
|
|
3744
|
+
// Auto-export if flag is set
|
|
3745
|
+
if (autoExportQR) {
|
|
3746
|
+
autoExportQR = false;
|
|
3747
|
+
setTimeout(function() { dlQr(); }, 300);
|
|
3748
|
+
}
|
|
3749
|
+
} catch (e) {
|
|
3750
|
+
console.error('QR generation error:', e);
|
|
3751
|
+
toast('QR generation failed','err');
|
|
3752
|
+
}
|
|
3753
|
+
}).catch(function(error) {
|
|
3754
|
+
hideLoad();
|
|
3755
|
+
console.error('Firestore save error:', error);
|
|
3756
|
+
toast('Failed to save to Firestore: ' + error.message,'err');
|
|
3757
|
+
});
|
|
3758
|
+
}
|
|
3759
|
+
|
|
3760
|
+
function generateQRLegacy() {
|
|
3761
|
+
var result = buildQRUrl();
|
|
3762
|
+
if (result.error) {
|
|
3763
|
+
toast(result.error, 'err');
|
|
3764
|
+
return;
|
|
3765
|
+
}
|
|
3766
|
+
|
|
3767
|
+
var viewUrl = result.url;
|
|
3768
|
+
document.getElementById('qr-url-txt').textContent = viewUrl;
|
|
3769
|
+
|
|
3770
|
+
// Generate QR code
|
|
3771
|
+
var qrContainer = document.getElementById('qr-code');
|
|
3772
|
+
qrContainer.innerHTML = '';
|
|
3773
|
+
|
|
3774
|
+
try {
|
|
3775
|
+
qrCodeInstance = new QRCode(qrContainer, {
|
|
3776
|
+
text: viewUrl,
|
|
3777
|
+
width: 148,
|
|
3778
|
+
height: 148,
|
|
3779
|
+
colorDark: '#000000',
|
|
3780
|
+
colorLight: '#ffffff',
|
|
3781
|
+
correctLevel: QRCode.CorrectLevel.M
|
|
3782
|
+
});
|
|
3783
|
+
document.getElementById('qr-box').classList.remove('hidden');
|
|
3784
|
+
|
|
3785
|
+
// Save QR to project
|
|
3786
|
+
savedQRCodes.push({
|
|
3787
|
+
id: nextQRId++,
|
|
3788
|
+
name: 'QR ' + (savedQRCodes.length + 1),
|
|
3789
|
+
viewUrl: viewUrl,
|
|
3790
|
+
viewpoint: JSON.parse(JSON.stringify(capturedView))
|
|
3791
|
+
});
|
|
3792
|
+
updateProjectSummary();
|
|
3793
|
+
updateQRList();
|
|
3794
|
+
toast('✓ QR code generated! Anyone scanning loads this view.','ok');
|
|
3795
|
+
|
|
3796
|
+
// Auto-export if flag is set
|
|
3797
|
+
if (autoExportQR) {
|
|
3798
|
+
autoExportQR = false;
|
|
3799
|
+
setTimeout(function() { dlQr(); }, 300);
|
|
3800
|
+
}
|
|
3801
|
+
} catch (e) {
|
|
3802
|
+
console.error('QR generation error:', e);
|
|
3803
|
+
toast('QR generation failed','err');
|
|
3804
|
+
}
|
|
3805
|
+
}
|
|
3806
|
+
|
|
3807
|
+
function copyUrl(){navigator.clipboard.writeText(document.getElementById('qr-url-txt').textContent).then(function(){toast('URL copied','ok');});}
|
|
3808
|
+
|
|
3809
|
+
function dlQr(){
|
|
3810
|
+
var canvas = document.querySelector('#qr-code canvas');
|
|
3811
|
+
if (!canvas) { toast('Generate QR first','err'); return; }
|
|
3812
|
+
var a=document.createElement('a');
|
|
3813
|
+
a.href=canvas.toDataURL('image/png');
|
|
3814
|
+
a.download='scanbim-qr.png';
|
|
3815
|
+
a.click();
|
|
3816
|
+
toast('QR image saved','ok');
|
|
3817
|
+
}
|
|
3818
|
+
|
|
3819
|
+
function generateAndExportQR(){
|
|
3820
|
+
autoExportQR = true;
|
|
3821
|
+
generateQR();
|
|
3822
|
+
}
|
|
3823
|
+
|
|
3824
|
+
function updateQRList() {
|
|
3825
|
+
if (savedQRCodes.length === 0) {
|
|
3826
|
+
var container = document.getElementById('qr-list-container');
|
|
3827
|
+
if (container) container.style.display = 'none';
|
|
3828
|
+
return;
|
|
3829
|
+
}
|
|
3830
|
+
var container = document.getElementById('qr-list-container');
|
|
3831
|
+
if (container) container.style.display = 'block';
|
|
3832
|
+
var list = document.getElementById('qr-list');
|
|
3833
|
+
if (!list) return;
|
|
3834
|
+
list.innerHTML = '';
|
|
3835
|
+
savedQRCodes.slice(-5).reverse().forEach(function(qr, idx) {
|
|
3836
|
+
var item = document.createElement('div');
|
|
3837
|
+
item.style.cssText = 'padding:4px 6px;border-bottom:1px solid var(--bdr);font-size:10px;color:var(--muted);cursor:pointer;overflow:hidden;text-overflow:ellipsis;white-space:nowrap';
|
|
3838
|
+
item.textContent = (idx + 1) + '. ' + qr.name;
|
|
3839
|
+
item.title = qr.viewUrl;
|
|
3840
|
+
item.onclick = function() {
|
|
3841
|
+
document.getElementById('qr-url-txt').textContent = qr.viewUrl;
|
|
3842
|
+
navigator.clipboard.writeText(qr.viewUrl).then(function() { toast('URL copied','ok'); });
|
|
3843
|
+
};
|
|
3844
|
+
list.appendChild(item);
|
|
3845
|
+
});
|
|
3846
|
+
}
|
|
3847
|
+
|
|
3848
|
+
// ── PROJECT SAVE/LOAD ────────────────────────────────────────
|
|
3849
|
+
function saveProject() {
|
|
3850
|
+
if (loadedModels.length === 0) { toast('Load a model first','err'); return; }
|
|
3851
|
+
var p = camera.position, t = controls.target;
|
|
3852
|
+
var modelRefs = loadedModels.map(function(m) {
|
|
3853
|
+
return {
|
|
3854
|
+
name: m.name,
|
|
3855
|
+
format: m.format,
|
|
3856
|
+
url: m.url,
|
|
3857
|
+
fileName: m.fileName,
|
|
3858
|
+
color: '#' + m.color.getHexString(),
|
|
3859
|
+
opacity: m.opacity,
|
|
3860
|
+
firebaseUrl: m.firebaseUrl,
|
|
3861
|
+
modelStorageRef: m.modelStorageRef
|
|
3862
|
+
};
|
|
3863
|
+
});
|
|
3864
|
+
|
|
3865
|
+
var project = {
|
|
3866
|
+
version: '1.0',
|
|
3867
|
+
timestamp: new Date().toISOString(),
|
|
3868
|
+
models: modelRefs,
|
|
3869
|
+
cameraPosition: {x: p.x, y: p.y, z: p.z},
|
|
3870
|
+
cameraTarget: {x: t.x, y: t.y, z: t.z},
|
|
3871
|
+
modelUnit: document.getElementById('model-unit').value,
|
|
3872
|
+
displayUnit: document.getElementById('display-unit').value,
|
|
3873
|
+
clipX: {value: parseFloat(document.getElementById('clip-x').value), flip: clipFlip.x},
|
|
3874
|
+
clipY: {value: parseFloat(document.getElementById('clip-y').value), flip: clipFlip.y},
|
|
3875
|
+
clipZ: {value: parseFloat(document.getElementById('clip-z').value), flip: clipFlip.z},
|
|
3876
|
+
gridVisible: gridOn,
|
|
3877
|
+
wireframeMode: wireOn,
|
|
3878
|
+
capturedView: capturedView,
|
|
3879
|
+
viewpoints: savedViewpoints,
|
|
3880
|
+
qrCodes: savedQRCodes
|
|
3881
|
+
};
|
|
3882
|
+
|
|
3883
|
+
var json = JSON.stringify(project, null, 2);
|
|
3884
|
+
var blob = new Blob([json], {type: 'application/json'});
|
|
3885
|
+
var url = URL.createObjectURL(blob);
|
|
3886
|
+
var a = document.createElement('a');
|
|
3887
|
+
a.href = url;
|
|
3888
|
+
a.download = 'scanbim-project.scanbim';
|
|
3889
|
+
a.click();
|
|
3890
|
+
URL.revokeObjectURL(url);
|
|
3891
|
+
toast('✓ Project saved','ok');
|
|
3892
|
+
}
|
|
3893
|
+
|
|
3894
|
+
function loadProject() {
|
|
3895
|
+
var fi = document.getElementById('project-file');
|
|
3896
|
+
fi.click();
|
|
3897
|
+
fi.onchange = function(e) {
|
|
3898
|
+
var file = e.target.files[0];
|
|
3899
|
+
if (!file) return;
|
|
3900
|
+
var reader = new FileReader();
|
|
3901
|
+
reader.onload = function(evt) {
|
|
3902
|
+
try {
|
|
3903
|
+
var project = JSON.parse(evt.target.result);
|
|
3904
|
+
|
|
3905
|
+
// Clear existing models
|
|
3906
|
+
loadedModels.forEach(function(m) { scene.remove(m.obj); });
|
|
3907
|
+
loadedModels = [];
|
|
3908
|
+
selectedModelId = -1;
|
|
3909
|
+
nextModelId = 0;
|
|
3910
|
+
|
|
3911
|
+
// Load models
|
|
3912
|
+
var modelsToLoad = project.models.length;
|
|
3913
|
+
var modelsLoaded = 0;
|
|
3914
|
+
|
|
3915
|
+
project.models.forEach(function(ref) {
|
|
3916
|
+
// Prefer Firebase URL if available
|
|
3917
|
+
var modelUrl = ref.firebaseUrl || ref.url;
|
|
3918
|
+
loadModel(modelUrl, ref.format, function() {
|
|
3919
|
+
// After loading, restore Firebase metadata if available
|
|
3920
|
+
if (loadedModels.length > 0) {
|
|
3921
|
+
var lastModel = loadedModels[loadedModels.length - 1];
|
|
3922
|
+
lastModel.firebaseUrl = ref.firebaseUrl || null;
|
|
3923
|
+
lastModel.modelStorageRef = ref.modelStorageRef || null;
|
|
3924
|
+
}
|
|
3925
|
+
modelsLoaded++;
|
|
3926
|
+
if (modelsLoaded === modelsToLoad) {
|
|
3927
|
+
// All models loaded — restore settings
|
|
3928
|
+
camera.position.set(project.cameraPosition.x, project.cameraPosition.y, project.cameraPosition.z);
|
|
3929
|
+
controls.target.set(project.cameraTarget.x, project.cameraTarget.y, project.cameraTarget.z);
|
|
3930
|
+
controls.update();
|
|
3931
|
+
|
|
3932
|
+
document.getElementById('model-unit').value = project.modelUnit;
|
|
3933
|
+
document.getElementById('display-unit').value = project.displayUnit;
|
|
3934
|
+
|
|
3935
|
+
document.getElementById('clip-x').value = project.clipX.value;
|
|
3936
|
+
clipFlip.x = project.clipX.flip;
|
|
3937
|
+
updateClip('x', project.clipX.value);
|
|
3938
|
+
document.getElementById('flip-x').style.background = clipFlip.x ? 'var(--acc)' : '';
|
|
3939
|
+
document.getElementById('flip-x').style.color = clipFlip.x ? '#fff' : '';
|
|
3940
|
+
|
|
3941
|
+
document.getElementById('clip-y').value = project.clipY.value;
|
|
3942
|
+
clipFlip.y = project.clipY.flip;
|
|
3943
|
+
updateClip('y', project.clipY.value);
|
|
3944
|
+
document.getElementById('flip-y').style.background = clipFlip.y ? 'var(--acc)' : '';
|
|
3945
|
+
document.getElementById('flip-y').style.color = clipFlip.y ? '#fff' : '';
|
|
3946
|
+
|
|
3947
|
+
document.getElementById('clip-z').value = project.clipZ.value;
|
|
3948
|
+
clipFlip.z = project.clipZ.flip;
|
|
3949
|
+
updateClip('z', project.clipZ.value);
|
|
3950
|
+
document.getElementById('flip-z').style.background = clipFlip.z ? 'var(--acc)' : '';
|
|
3951
|
+
document.getElementById('flip-z').style.color = clipFlip.z ? '#fff' : '';
|
|
3952
|
+
|
|
3953
|
+
gridOn = project.gridVisible;
|
|
3954
|
+
grid.visible = gridOn;
|
|
3955
|
+
wireOn = project.wireframeMode;
|
|
3956
|
+
if (wireOn) { loadedModels.forEach(function(entry) {
|
|
3957
|
+
entry.obj.traverse(function(c){if(c.isMesh&&c.material){var m=Array.isArray(c.material)?c.material:[c.material];m.forEach(function(x){x.wireframe=true;});}});
|
|
3958
|
+
}); }
|
|
3959
|
+
|
|
3960
|
+
if (project.capturedView) {
|
|
3961
|
+
capturedView = project.capturedView;
|
|
3962
|
+
document.getElementById('cap-banner').classList.add('show');
|
|
3963
|
+
document.getElementById('dot-qr').classList.add('org');
|
|
3964
|
+
document.getElementById('st-qr').textContent = 'View captured';
|
|
3965
|
+
}
|
|
3966
|
+
|
|
3967
|
+
if (project.viewpoints) {
|
|
3968
|
+
savedViewpoints = project.viewpoints;
|
|
3969
|
+
nextViewpointId = savedViewpoints.length > 0 ? Math.max.apply(null, savedViewpoints.map(function(v) { return v.id; })) + 1 : 0;
|
|
3970
|
+
updateViewpointsList();
|
|
3971
|
+
}
|
|
3972
|
+
|
|
3973
|
+
if (project.qrCodes) {
|
|
3974
|
+
savedQRCodes = project.qrCodes;
|
|
3975
|
+
nextQRId = savedQRCodes.length > 0 ? Math.max.apply(null, savedQRCodes.map(function(q) { return q.id; })) + 1 : 0;
|
|
3976
|
+
}
|
|
3977
|
+
|
|
3978
|
+
updateProjectSummary();
|
|
3979
|
+
toast('✓ Project loaded','ok');
|
|
3980
|
+
}
|
|
3981
|
+
});
|
|
3982
|
+
});
|
|
3983
|
+
} catch (e) {
|
|
3984
|
+
console.error('Project load error:', e);
|
|
3985
|
+
toast('Failed to load project','err');
|
|
3986
|
+
}
|
|
3987
|
+
};
|
|
3988
|
+
reader.readAsText(file);
|
|
3989
|
+
};
|
|
3990
|
+
}
|
|
3991
|
+
|
|
3992
|
+
// ── UI ───────────────────────────────────────────────────────
|
|
3993
|
+
function showLoad(t){document.getElementById('loading-txt').textContent=t;document.getElementById('loading').classList.add('on');}
|
|
3994
|
+
function hideLoad(){document.getElementById('loading').classList.remove('on');}
|
|
3995
|
+
var _tt;
|
|
3996
|
+
function toast(msg,type){var el=document.getElementById('toast');el.textContent=msg;el.className='toast show '+(type||'');clearTimeout(_tt);_tt=setTimeout(function(){el.className='toast';},3200);}
|
|
3997
|
+
|
|
3998
|
+
// ── VIEWCUBE DRAG-TO-ROTATE ──────────────────────────────────
|
|
3999
|
+
(function() {
|
|
4000
|
+
var canvas = document.getElementById('viewcube-canvas');
|
|
4001
|
+
var isDragging = false;
|
|
4002
|
+
var lastX = 0, lastY = 0;
|
|
4003
|
+
var dragThreshold = 3;
|
|
4004
|
+
var hasMoved = false;
|
|
4005
|
+
|
|
4006
|
+
canvas.addEventListener('mousedown', function(e) {
|
|
4007
|
+
isDragging = true;
|
|
4008
|
+
lastX = e.clientX;
|
|
4009
|
+
lastY = e.clientY;
|
|
4010
|
+
hasMoved = false;
|
|
4011
|
+
e.preventDefault();
|
|
4012
|
+
});
|
|
4013
|
+
|
|
4014
|
+
document.addEventListener('mousemove', function(e) {
|
|
4015
|
+
if (!isDragging) return;
|
|
4016
|
+
|
|
4017
|
+
var dx = e.clientX - lastX;
|
|
4018
|
+
var dy = e.clientY - lastY;
|
|
4019
|
+
|
|
4020
|
+
if (!hasMoved && Math.sqrt(dx*dx + dy*dy) < dragThreshold) return;
|
|
4021
|
+
hasMoved = true;
|
|
4022
|
+
|
|
4023
|
+
if (!camera || !controls) return;
|
|
4024
|
+
// Apply rotation to camera using controls
|
|
4025
|
+
var sensitivity = 0.01;
|
|
4026
|
+
|
|
4027
|
+
// Get current spherical coordinates relative to orbit target
|
|
4028
|
+
var pos = camera.position.clone().sub(controls.target);
|
|
4029
|
+
var dist = pos.length();
|
|
4030
|
+
var theta = Math.atan2(pos.z, pos.x);
|
|
4031
|
+
var phi = Math.acos(pos.y / dist);
|
|
4032
|
+
|
|
4033
|
+
// Update angles based on mouse delta
|
|
4034
|
+
theta -= dx * sensitivity;
|
|
4035
|
+
phi += dy * sensitivity;
|
|
4036
|
+
|
|
4037
|
+
// Clamp phi to avoid gimbal lock
|
|
4038
|
+
phi = Math.max(0.1, Math.min(Math.PI - 0.1, phi));
|
|
4039
|
+
|
|
4040
|
+
// Convert back to Cartesian
|
|
4041
|
+
var newPos = new THREE.Vector3(
|
|
4042
|
+
dist * Math.sin(phi) * Math.cos(theta),
|
|
4043
|
+
dist * Math.cos(phi),
|
|
4044
|
+
dist * Math.sin(phi) * Math.sin(theta)
|
|
4045
|
+
);
|
|
4046
|
+
|
|
4047
|
+
camera.position.copy(newPos.add(controls.target));
|
|
4048
|
+
controls.update();
|
|
4049
|
+
|
|
4050
|
+
lastX = e.clientX;
|
|
4051
|
+
lastY = e.clientY;
|
|
4052
|
+
});
|
|
4053
|
+
|
|
4054
|
+
document.addEventListener('mouseup', function() {
|
|
4055
|
+
isDragging = false;
|
|
4056
|
+
});
|
|
4057
|
+
|
|
4058
|
+
// Compass ring click handlers
|
|
4059
|
+
var compassLabels = document.querySelectorAll('.compass-label');
|
|
4060
|
+
compassLabels.forEach(function(label) {
|
|
4061
|
+
label.style.cursor = 'pointer';
|
|
4062
|
+
label.addEventListener('click', function(e) {
|
|
4063
|
+
if (!controls || !camera) return;
|
|
4064
|
+
var dir = this.getAttribute('data-dir');
|
|
4065
|
+
var targetPos;
|
|
4066
|
+
var dist = camera.position.clone().sub(controls.target).length() || 5;
|
|
4067
|
+
var targetTarget = controls.target.clone();
|
|
4068
|
+
|
|
4069
|
+
var cy = camera.position.y - controls.target.y;
|
|
4070
|
+
var elev = Math.max(dist * 0.3, Math.abs(cy));
|
|
4071
|
+
switch(dir) {
|
|
4072
|
+
case 'N': targetPos = new THREE.Vector3(targetTarget.x, targetTarget.y + elev, targetTarget.z + dist); break;
|
|
4073
|
+
case 'S': targetPos = new THREE.Vector3(targetTarget.x, targetTarget.y + elev, targetTarget.z - dist); break;
|
|
4074
|
+
case 'E': targetPos = new THREE.Vector3(targetTarget.x + dist, targetTarget.y + elev, targetTarget.z); break;
|
|
4075
|
+
case 'W': targetPos = new THREE.Vector3(targetTarget.x - dist, targetTarget.y + elev, targetTarget.z); break;
|
|
4076
|
+
}
|
|
4077
|
+
|
|
4078
|
+
animateCameraToView(targetPos, targetTarget);
|
|
4079
|
+
});
|
|
4080
|
+
});
|
|
4081
|
+
})();
|
|
4082
|
+
|
|
4083
|
+
// ── BOTTOM TOOLBAR HANDLERS ──────────────────────────────────
|
|
4084
|
+
// Deferred until after init() so `controls` exists
|
|
4085
|
+
window.addEventListener('load', function() {
|
|
4086
|
+
setTimeout(function() {
|
|
4087
|
+
var toolbarBtns = document.querySelectorAll('.toolbar-btn');
|
|
4088
|
+
var currentMode = 'orbit';
|
|
4089
|
+
|
|
4090
|
+
toolbarBtns.forEach(function(btn) {
|
|
4091
|
+
btn.addEventListener('click', function() {
|
|
4092
|
+
var tool = this.getAttribute('data-tool');
|
|
4093
|
+
|
|
4094
|
+
switch(tool) {
|
|
4095
|
+
case 'orbit':
|
|
4096
|
+
currentMode = 'orbit';
|
|
4097
|
+
if (controls) controls.mouseButtons.LEFT = THREE.MOUSE.ROTATE;
|
|
4098
|
+
toolbarBtns.forEach(function(b) { b.classList.remove('active'); });
|
|
4099
|
+
btn.classList.add('active');
|
|
4100
|
+
break;
|
|
4101
|
+
|
|
4102
|
+
case 'pan':
|
|
4103
|
+
currentMode = 'pan';
|
|
4104
|
+
if (controls) controls.mouseButtons.LEFT = THREE.MOUSE.PAN;
|
|
4105
|
+
toolbarBtns.forEach(function(b) { b.classList.remove('active'); });
|
|
4106
|
+
btn.classList.add('active');
|
|
4107
|
+
break;
|
|
4108
|
+
|
|
4109
|
+
case 'fit':
|
|
4110
|
+
resetCam();
|
|
4111
|
+
break;
|
|
4112
|
+
|
|
4113
|
+
case 'measure':
|
|
4114
|
+
if (typeof startMeasure === 'function') startMeasure();
|
|
4115
|
+
btn.classList.toggle('active');
|
|
4116
|
+
break;
|
|
4117
|
+
|
|
4118
|
+
case 'section':
|
|
4119
|
+
// Open sidebar section panel
|
|
4120
|
+
var secHdr = Array.from(document.querySelectorAll('.sec-hdr')).find(function(h) {
|
|
4121
|
+
return h.textContent.includes('Section');
|
|
4122
|
+
});
|
|
4123
|
+
if (secHdr) {
|
|
4124
|
+
var body = secHdr.nextElementSibling;
|
|
4125
|
+
if (!body.classList.contains('open')) secHdr.click();
|
|
4126
|
+
// On mobile, also open sidebar
|
|
4127
|
+
var sb = document.getElementById('sidebar');
|
|
4128
|
+
if (window.innerWidth <= 768 && !sb.classList.contains('show')) {
|
|
4129
|
+
toggleSidebar();
|
|
4130
|
+
}
|
|
4131
|
+
}
|
|
4132
|
+
break;
|
|
4133
|
+
|
|
4134
|
+
case 'markup':
|
|
4135
|
+
if (typeof toggleMarkup === 'function') toggleMarkup();
|
|
4136
|
+
btn.classList.toggle('active');
|
|
4137
|
+
break;
|
|
4138
|
+
|
|
4139
|
+
case 'grid':
|
|
4140
|
+
toggleGrid();
|
|
4141
|
+
btn.classList.toggle('active');
|
|
4142
|
+
break;
|
|
4143
|
+
|
|
4144
|
+
case 'wireframe':
|
|
4145
|
+
toggleWire();
|
|
4146
|
+
btn.classList.toggle('active');
|
|
4147
|
+
break;
|
|
4148
|
+
|
|
4149
|
+
case 'screenshot':
|
|
4150
|
+
if (typeof screenshotView === 'function') screenshotView();
|
|
4151
|
+
break;
|
|
4152
|
+
}
|
|
4153
|
+
});
|
|
4154
|
+
});
|
|
4155
|
+
}, 100);
|
|
4156
|
+
});
|
|
4157
|
+
|
|
4158
|
+
window.addEventListener('load', init);
|
|
4159
|
+
|
|
4160
|
+
if ('serviceWorker' in navigator && window.location.protocol === 'https:' && !window.location.hostname.includes('claudeusercontent')) {
|
|
4161
|
+
navigator.serviceWorker.register('/sw.js').then(function() {
|
|
4162
|
+
console.log('ScanBIM SW registered');
|
|
4163
|
+
}).catch(function() { /* sw.js not deployed yet */ });
|
|
4164
|
+
}
|
|
4165
|
+
|
|
4166
|
+
function showDeleteAccountModal(){var m=document.getElementById('delete-account-modal'),e=document.getElementById('delete-account-email'),u=firebase.auth().currentUser;if(!u){alert('You must be signed in.');return;}e.textContent=u.email;document.getElementById('delete-account-step1').style.display='block';document.getElementById('delete-account-step2').style.display='none';document.getElementById('delete-account-step3').style.display='none';document.getElementById('delete-account-step4').style.display='none';document.getElementById('delete-error').style.display='none';m.style.display='flex';}
|
|
4167
|
+
function closeDeleteAccountModal(){document.getElementById('delete-account-modal').style.display='none';}
|
|
4168
|
+
function confirmDeleteAccount(){document.getElementById('delete-account-step1').style.display='none';document.getElementById('delete-account-step2').style.display='block';document.getElementById('delete-confirm-password').value='';setTimeout(function(){document.getElementById('delete-confirm-password').focus();},100);}
|
|
4169
|
+
async function executeDeleteAccount(){var u=firebase.auth().currentUser;if(!u)return;var p=document.getElementById('delete-confirm-password').value,err=document.getElementById('delete-error');if(!p){err.textContent='Please enter your password.';err.style.display='block';return;}document.getElementById('delete-account-step2').style.display='none';document.getElementById('delete-account-step3').style.display='block';try{var cred=firebase.auth.EmailAuthProvider.credential(u.email,p);await u.reauthenticateWithCredential(cred);var db=firebase.firestore();try{await db.collection('users').doc(u.uid).delete();}catch(x){}try{var vs=await db.collection('views').where('userId','==',u.uid).get();if(!vs.empty){var b=db.batch();vs.forEach(function(d){b.delete(d.ref);});await b.commit();}}catch(x){}try{var st=firebase.storage(),ref=st.ref('users/'+u.uid),fl=await ref.listAll();for(var i=0;i<fl.items.length;i++)await fl.items[i].delete();for(var j=0;j<fl.prefixes.length;j++){var sf=await fl.prefixes[j].listAll();for(var k=0;k<sf.items.length;k++)await sf.items[k].delete();}}catch(x){}await u.delete();document.getElementById('delete-account-step3').style.display='none';document.getElementById('delete-account-step4').style.display='block';setTimeout(function(){window.location.href='/';},2500);}catch(e){console.error('Account deletion error:',e);document.getElementById('delete-account-step3').style.display='none';document.getElementById('delete-account-step2').style.display='block';if(e.code==='auth/wrong-password'||e.code==='auth/invalid-credential'){err.textContent='Incorrect password. Please try again.';}else if(e.code==='auth/too-many-requests'){err.textContent='Too many attempts. Please wait.';}else{err.textContent='Error: '+e.message;}err.style.display='block';}}
|
|
4170
|
+
document.addEventListener('click',function(e){if(e.target===document.getElementById('delete-account-modal'))closeDeleteAccountModal();});
|
|
4171
|
+
document.addEventListener('keydown',function(e){if(e.key==='Escape')closeDeleteAccountModal();});
|
|
4172
|
+
|
|
4173
|
+
|
|
4174
|
+
|
|
4175
|
+
// ── HOME VIEW + VIEWCUBE SYNC ──
|
|
4176
|
+
var vcHomeView = { pos: [15, 12, 15], label: 'SE Isometric' };
|
|
4177
|
+
|
|
4178
|
+
function goHomeView() {
|
|
4179
|
+
if (typeof camera === 'undefined' || typeof controls === 'undefined') return;
|
|
4180
|
+
var dir = new THREE.Vector3(vcHomeView.pos[0], vcHomeView.pos[1], vcHomeView.pos[2]).normalize();
|
|
4181
|
+
var homeTarget = controls.target.clone();
|
|
4182
|
+
// If model loaded use scene bounds, else use current target
|
|
4183
|
+
if (typeof sceneBounds !== 'undefined' && sceneBounds) {
|
|
4184
|
+
var center = new THREE.Vector3();
|
|
4185
|
+
sceneBounds.getCenter(center);
|
|
4186
|
+
homeTarget.copy(center);
|
|
4187
|
+
var size = new THREE.Vector3();
|
|
4188
|
+
sceneBounds.getSize(size);
|
|
4189
|
+
var maxDim = Math.max(size.x, size.y, size.z);
|
|
4190
|
+
var homePos = dir.clone().multiplyScalar(maxDim * 2).add(center);
|
|
4191
|
+
animateCameraToView(homePos, homeTarget);
|
|
4192
|
+
} else {
|
|
4193
|
+
var dist = Math.max(camera.position.distanceTo(controls.target), 20);
|
|
4194
|
+
var homePos = dir.clone().multiplyScalar(dist);
|
|
4195
|
+
animateCameraToView(homePos, new THREE.Vector3(0,0,0));
|
|
4196
|
+
}
|
|
4197
|
+
}
|
|
4198
|
+
|
|
4199
|
+
document.addEventListener('DOMContentLoaded', function() {
|
|
4200
|
+
var btn = document.getElementById('vc-home-btn');
|
|
4201
|
+
if (btn) btn.addEventListener('click', goHomeView);
|
|
4202
|
+
});
|
|
4203
|
+
|
|
4204
|
+
// Sync viewcube rotation + dynamic view label
|
|
4205
|
+
window.syncViewcube = function() {
|
|
4206
|
+
try {
|
|
4207
|
+
if (typeof vcCube !== 'undefined' && typeof camera !== 'undefined' && typeof controls !== 'undefined' && typeof vcCamera !== 'undefined' && typeof vcRenderer !== 'undefined' && typeof vcScene !== 'undefined') {
|
|
4208
|
+
var camDir = new THREE.Vector3().subVectors(camera.position, controls.target).normalize();
|
|
4209
|
+
vcCamera.position.copy(camDir.multiplyScalar(6));
|
|
4210
|
+
vcCamera.lookAt(0, 0, 0);
|
|
4211
|
+
vcRenderer.render(vcScene, vcCamera);
|
|
4212
|
+
var lbl = document.getElementById('vc-view-label');
|
|
4213
|
+
if (lbl && typeof window.getViewLabelFromCamera === 'function') {
|
|
4214
|
+
var newText = window.getViewLabelFromCamera();
|
|
4215
|
+
if (newText && lbl.textContent !== newText) lbl.textContent = newText;
|
|
4216
|
+
}
|
|
4217
|
+
}
|
|
4218
|
+
} catch(e) { console.error('syncViewcube error:', e); }
|
|
4219
|
+
(document.hidden ? setTimeout : requestAnimationFrame)(function(){ window.syncViewcube(); }, document.hidden ? 100 : undefined);
|
|
4220
|
+
}
|
|
4221
|
+
window.syncViewcube();
|
|
4222
|
+
|
|
4223
|
+
// Backup sync kickstart
|
|
4224
|
+
if (document.readyState === "complete" || document.readyState === "interactive") {
|
|
4225
|
+
setTimeout(function() { if (window.syncViewcube) window.syncViewcube(); }, 500);
|
|
4226
|
+
} else {
|
|
4227
|
+
document.addEventListener("DOMContentLoaded", function() {
|
|
4228
|
+
setTimeout(function() { if (window.syncViewcube) window.syncViewcube(); }, 500);
|
|
4229
|
+
});
|
|
4230
|
+
}
|
|
4231
|
+
</script>
|
|
4232
|
+
<div id="delete-account-modal" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.85);z-index:20000;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;"><div style="background:#16192a;border:1px solid rgba(255,255,255,0.08);border-radius:16px;padding:32px;max-width:440px;width:90%;color:#ccc;box-shadow:0 24px 64px rgba(0,0,0,0.5);"><h2 style="color:#f97316;margin:0 0 6px 0;font-size:20px;font-weight:600;">Account Settings</h2><div id="delete-account-step1"><p style="font-size:14px;line-height:1.6;color:#888;margin:4px 0 0 0;" id="delete-account-email"></p><hr style="border:none;border-top:1px solid rgba(255,255,255,0.06);margin:20px 0;"><div style="background:rgba(220,38,38,0.08);border:1px solid rgba(220,38,38,0.2);border-radius:10px;padding:18px;"><h3 style="color:#ef4444;font-size:15px;margin:0 0 10px 0;">Delete Account</h3><p style="font-size:13px;line-height:1.6;color:#999;margin:0 0 12px 0;">Permanently remove your account and all data:</p><div style="font-size:13px;line-height:2;color:#888;">• User profile and preferences<br>• All saved views and projects<br>• All uploaded models and files</div><p style="font-size:12px;color:#ef4444;margin:12px 0 0 0;font-weight:500;">This action cannot be undone.</p></div><div style="display:flex;gap:10px;margin-top:24px;"><button onclick="closeDeleteAccountModal()" class="acct-cancel-btn">Cancel</button><button onclick="confirmDeleteAccount()" class="acct-delete-btn">Delete My Account</button></div></div><div id="delete-account-step2" style="display:none;"><p style="font-size:14px;line-height:1.6;color:#999;margin:0 0 16px 0;">To confirm, re-enter your password:</p><input type="password" id="delete-confirm-password" placeholder="Enter your password" class="acct-input"><p id="delete-error" style="color:#ef4444;font-size:13px;display:none;margin:10px 0 0 0;"></p><div style="display:flex;gap:10px;margin-top:20px;"><button onclick="closeDeleteAccountModal()" class="acct-cancel-btn">Cancel</button><button onclick="executeDeleteAccount()" class="acct-delete-btn">Permanently Delete</button></div></div><div id="delete-account-step3" style="display:none;text-align:center;padding:24px 0;"><div class="acct-spinner"></div><p style="font-size:14px;color:#999;margin-top:16px;">Deleting your account...</p></div><div id="delete-account-step4" style="display:none;text-align:center;padding:24px 0;"><div style="font-size:36px;margin-bottom:12px;color:#4ade80;">✓</div><p style="font-size:16px;color:#4ade80;font-weight:500;">Account deleted successfully</p><p style="font-size:13px;color:#666;margin-top:8px;">Redirecting...</p></div></div></div>
|
|
4233
|
+
<script src="viewcube-fix.js"></script>
|
|
4234
|
+
<script src="ui-themes.js"></script>
|
|
4235
|
+
<script src="viewer-tools.js"></script>
|
|
4236
|
+
<script src="responsive.js"></script>
|
|
4237
|
+
<script src="ai-features.js"></script>
|
|
4238
|
+
<script src="/ifc-loader.js"></script>
|
|
4239
|
+
<script src="/aps-viewer.js"></script>
|
|
4240
|
+
<script src="/potree-viewer.js"></script>
|
|
4241
|
+
<script src="/entitlement-gate.js"></script>
|
|
4242
|
+
|
|
4243
|
+
</body>
|
|
4244
|
+
</html>
|