Akihira77 revised this gist 1 week ago. Go to revision
1 file changed, 510 insertions
Bim 3d compare.html (file created)
| @@ -0,0 +1,510 @@ | |||
| 1 | + | <!DOCTYPE html> | |
| 2 | + | <html lang="en"> | |
| 3 | + | <head> | |
| 4 | + | <meta charset="UTF-8"> | |
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 6 | + | <title>BIM Compare – Consdoc Viewer</title> | |
| 7 | + | <link href="https://fonts.googleapis.com/css2?family=Syne:wght@400;600;700;800&family=DM+Mono:wght@400;500&family=DM+Sans:wght@300;400;500&display=swap" rel="stylesheet"> | |
| 8 | + | <style> | |
| 9 | + | :root { | |
| 10 | + | --bg: #0d1117; --surface: #161b22; --surface2: #1c2128; | |
| 11 | + | --border: #30363d; --text: #e6edf3; --muted: #7d8590; | |
| 12 | + | --accent: #58a6ff; --add: #3fb950; --mod: #f0883e; --del: #f85149; | |
| 13 | + | --ai: #bc8cff; | |
| 14 | + | } | |
| 15 | + | * { box-sizing: border-box; margin:0; padding:0; } | |
| 16 | + | body { background:var(--bg); color:var(--text); font-family:'DM Sans',sans-serif; height:100vh; display:flex; flex-direction:column; overflow:hidden; } | |
| 17 | + | ||
| 18 | + | nav { | |
| 19 | + | height:48px; background:var(--surface); border-bottom:1px solid var(--border); | |
| 20 | + | display:flex; align-items:center; justify-content:space-between; padding:0 16px; flex-shrink:0; | |
| 21 | + | } | |
| 22 | + | .brand { font-family:'Syne',sans-serif; font-weight:800; font-size:16px; } | |
| 23 | + | .brand span { color:var(--accent); } | |
| 24 | + | .file-info { font-size:12px; color:var(--muted); display:flex; align-items:center; gap:8px; } | |
| 25 | + | .file-info b { color:var(--text); } | |
| 26 | + | .nav-right { display:flex; align-items:center; gap:8px; } | |
| 27 | + | .btn { padding:5px 12px; border-radius:6px; border:1px solid var(--border); background:var(--surface2); color:var(--text); font-size:12px; cursor:pointer; font-family:'DM Sans',sans-serif; transition:.15s; } | |
| 28 | + | .btn:hover { background:var(--border); } | |
| 29 | + | .btn-ai { background:linear-gradient(135deg,#7c3aed,#bc8cff); color:white; border:none; font-weight:600; display:flex; align-items:center; gap:5px; } | |
| 30 | + | .btn-ai:hover { opacity:.88; background:linear-gradient(135deg,#7c3aed,#bc8cff); } | |
| 31 | + | .btn-primary { background:var(--accent); color:#0d1117; border-color:var(--accent); font-weight:600; } | |
| 32 | + | ||
| 33 | + | main { flex:1; display:flex; overflow:hidden; } | |
| 34 | + | ||
| 35 | + | /* VIEWERS */ | |
| 36 | + | .viewers { flex:1; display:flex; gap:2px; background:var(--border); position:relative; } | |
| 37 | + | .viewer-wrap { flex:1; position:relative; background:#0a0e14; } | |
| 38 | + | .viewer-label { | |
| 39 | + | position:absolute; top:10px; left:10px; z-index:10; | |
| 40 | + | font-family:'Syne',sans-serif; font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.8px; | |
| 41 | + | background:rgba(0,0,0,.6); backdrop-filter:blur(8px); | |
| 42 | + | padding:4px 10px; border-radius:5px; border:1px solid var(--border); color:var(--muted); | |
| 43 | + | } | |
| 44 | + | .viewer-ver { color:var(--accent); } | |
| 45 | + | canvas { display:block; width:100% !important; height:100% !important; } | |
| 46 | + | ||
| 47 | + | /* DIVIDER HANDLE */ | |
| 48 | + | .divider { | |
| 49 | + | position:absolute; left:50%; top:0; bottom:0; width:3px; z-index:20; | |
| 50 | + | background:var(--accent); cursor:ew-resize; transform:translateX(-50%); | |
| 51 | + | display:flex; align-items:center; justify-content:center; | |
| 52 | + | } | |
| 53 | + | .divider-handle { | |
| 54 | + | width:24px; height:40px; background:var(--accent); border-radius:4px; | |
| 55 | + | display:flex; align-items:center; justify-content:center; color:#0d1117; font-size:14px; font-weight:bold; | |
| 56 | + | box-shadow:0 2px 12px rgba(88,166,255,.4); | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | /* overlay mode */ | |
| 60 | + | .overlay-badge { | |
| 61 | + | position:absolute; top:10px; left:50%; transform:translateX(-50%); z-index:30; | |
| 62 | + | background:rgba(0,0,0,.7); backdrop-filter:blur(8px); | |
| 63 | + | border:1px solid var(--border); border-radius:6px; padding:4px 12px; | |
| 64 | + | font-size:11px; color:var(--muted); display:none; | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | /* SIDE PANEL */ | |
| 68 | + | .side { width:300px; background:var(--surface); border-left:1px solid var(--border); display:flex; flex-direction:column; flex-shrink:0; } | |
| 69 | + | .side-head { padding:14px 16px 10px; border-bottom:1px solid var(--border); } | |
| 70 | + | .side-title { font-family:'Syne',sans-serif; font-weight:700; font-size:14px; display:flex; align-items:center; gap:6px; } | |
| 71 | + | .ai-pill { background:linear-gradient(135deg,#7c3aed,#bc8cff); color:white; font-size:9px; font-weight:800; letter-spacing:1px; text-transform:uppercase; padding:2px 6px; border-radius:4px; } | |
| 72 | + | ||
| 73 | + | .legend { display:flex; gap:8px; padding:8px 16px; border-bottom:1px solid var(--border); flex-shrink:0; flex-wrap:wrap; } | |
| 74 | + | .leg-item { display:flex; align-items:center; gap:4px; font-size:11px; color:var(--muted); } | |
| 75 | + | .leg-dot { width:10px; height:10px; border-radius:2px; } | |
| 76 | + | ||
| 77 | + | .side-body { flex:1; overflow-y:auto; padding:12px; display:flex; flex-direction:column; gap:8px; } | |
| 78 | + | .side-body::-webkit-scrollbar { width:3px; } | |
| 79 | + | .side-body::-webkit-scrollbar-thumb { background:var(--border); border-radius:3px; } | |
| 80 | + | ||
| 81 | + | .stats-row { display:grid; grid-template-columns:1fr 1fr 1fr; gap:6px; } | |
| 82 | + | .stat { background:var(--surface2); border:1px solid var(--border); border-radius:7px; padding:8px; text-align:center; } | |
| 83 | + | .stat-n { font-family:'Syne',sans-serif; font-size:18px; font-weight:800; } | |
| 84 | + | .stat-l { font-size:10px; color:var(--muted); text-transform:uppercase; letter-spacing:.5px; margin-top:2px; } | |
| 85 | + | .n-add { color:var(--add); } .n-mod { color:var(--mod); } .n-del { color:var(--del); } | |
| 86 | + | ||
| 87 | + | .sec-title { font-size:10px; text-transform:uppercase; letter-spacing:.8px; font-weight:700; color:var(--muted); font-family:'Syne',sans-serif; padding:2px 0; } | |
| 88 | + | ||
| 89 | + | .change-item { | |
| 90 | + | background:var(--surface2); border:1px solid var(--border); border-radius:7px; | |
| 91 | + | padding:9px 11px; cursor:pointer; transition:.15s; | |
| 92 | + | border-left:3px solid; | |
| 93 | + | } | |
| 94 | + | .change-item:hover, .change-item.active { background:var(--bg); box-shadow:0 0 0 1px var(--border); } | |
| 95 | + | .change-item.add { border-left-color:var(--add); } | |
| 96 | + | .change-item.mod { border-left-color:var(--mod); } | |
| 97 | + | .change-item.del { border-left-color:var(--del); } | |
| 98 | + | .ci-type { font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.5px; margin-bottom:3px; } | |
| 99 | + | .ci-type.add { color:var(--add); } .ci-type.mod { color:var(--mod); } .ci-type.del { color:var(--del); } | |
| 100 | + | .ci-name { font-size:13px; font-weight:500; } | |
| 101 | + | .ci-desc { font-size:11px; color:var(--muted); margin-top:2px; line-height:1.5; } | |
| 102 | + | ||
| 103 | + | .ai-box { | |
| 104 | + | background:linear-gradient(135deg,rgba(124,58,237,.1),rgba(188,140,255,.05)); | |
| 105 | + | border:1px solid rgba(188,140,255,.2); border-radius:8px; padding:10px 12px; | |
| 106 | + | } | |
| 107 | + | .ai-box-head { font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.6px; color:var(--ai); margin-bottom:6px; display:flex; align-items:center; gap:5px; } | |
| 108 | + | .ai-box-text { font-size:12px; line-height:1.6; } | |
| 109 | + | ||
| 110 | + | /* BOTTOM */ | |
| 111 | + | .bottom { height:44px; background:var(--surface); border-top:1px solid var(--border); display:flex; align-items:center; justify-content:space-between; padding:0 16px; flex-shrink:0; } | |
| 112 | + | .view-modes { display:flex; gap:4px; } | |
| 113 | + | .vmode { padding:4px 10px; border-radius:5px; border:1px solid var(--border); font-size:11px; cursor:pointer; background:transparent; color:var(--muted); font-family:'DM Sans',sans-serif; transition:.15s; } | |
| 114 | + | .vmode.active { background:var(--surface2); color:var(--text); border-color:var(--accent); color:var(--accent); } | |
| 115 | + | .opacity-ctrl { display:flex; align-items:center; gap:8px; font-size:11px; color:var(--muted); } | |
| 116 | + | input[type=range] { width:80px; accent-color:var(--accent); } | |
| 117 | + | ||
| 118 | + | .spinner { width:28px; height:28px; border:2px solid var(--border); border-top-color:var(--ai); border-radius:50%; animation:spin .7s linear infinite; } | |
| 119 | + | @keyframes spin { to { transform:rotate(360deg); } } | |
| 120 | + | .loading-ai { display:flex; flex-direction:column; align-items:center; gap:8px; color:var(--muted); font-size:12px; padding:20px; } | |
| 121 | + | .dots span { display:inline-block; width:5px; height:5px; background:var(--ai); border-radius:50%; margin:0 2px; animation:blink 1.2s infinite; } | |
| 122 | + | .dots span:nth-child(2){animation-delay:.2s} .dots span:nth-child(3){animation-delay:.4s} | |
| 123 | + | @keyframes blink { 0%,80%,100%{opacity:.2;transform:scale(.8)} 40%{opacity:1;transform:scale(1)} } | |
| 124 | + | </style> | |
| 125 | + | </head> | |
| 126 | + | <body> | |
| 127 | + | ||
| 128 | + | <nav> | |
| 129 | + | <div class="brand">⬡ Cons<span>doc</span></div> | |
| 130 | + | <div class="file-info"> | |
| 131 | + | <svg width="13" height="13" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg> | |
| 132 | + | <b>Snowdon Towers Sample Structural.rvt</b> | |
| 133 | + | <span>— BIM Comparison</span> | |
| 134 | + | </div> | |
| 135 | + | <div class="nav-right"> | |
| 136 | + | <button class="btn btn-ai" id="aiBtn" onclick="runAIAnalysis()"> | |
| 137 | + | <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/></svg> | |
| 138 | + | Analisis AI | |
| 139 | + | </button> | |
| 140 | + | <button class="btn btn-primary">Compare files</button> | |
| 141 | + | </div> | |
| 142 | + | </nav> | |
| 143 | + | ||
| 144 | + | <main> | |
| 145 | + | <div class="viewers" id="viewersContainer"> | |
| 146 | + | <div class="viewer-wrap" id="wrapA"> | |
| 147 | + | <div class="viewer-label">Version A <span class="viewer-ver">V0 · Rev 0</span></div> | |
| 148 | + | <canvas id="canvasA"></canvas> | |
| 149 | + | </div> | |
| 150 | + | <div class="divider" id="divider"> | |
| 151 | + | <div class="divider-handle">⇔</div> | |
| 152 | + | </div> | |
| 153 | + | <div class="viewer-wrap" id="wrapB"> | |
| 154 | + | <div class="viewer-label">Version B <span class="viewer-ver">V1 · Rev 0</span></div> | |
| 155 | + | <canvas id="canvasB"></canvas> | |
| 156 | + | </div> | |
| 157 | + | <div class="overlay-badge" id="overlayBadge">Overlay Mode</div> | |
| 158 | + | </div> | |
| 159 | + | ||
| 160 | + | <div class="side"> | |
| 161 | + | <div class="side-head"> | |
| 162 | + | <div class="side-title">Compare Document <span class="ai-pill">AI</span></div> | |
| 163 | + | </div> | |
| 164 | + | <div class="legend"> | |
| 165 | + | <div class="leg-item"><div class="leg-dot" style="background:#3fb950"></div>Ditambahkan</div> | |
| 166 | + | <div class="leg-item"><div class="leg-dot" style="background:#f0883e"></div>Dimodifikasi</div> | |
| 167 | + | <div class="leg-item"><div class="leg-dot" style="background:#f85149"></div>Dihapus</div> | |
| 168 | + | <div class="leg-item"><div class="leg-dot" style="background:#58a6ff;opacity:.35"></div>Tidak berubah</div> | |
| 169 | + | </div> | |
| 170 | + | <div class="side-body" id="sideBody"> | |
| 171 | + | <div style="color:var(--muted);font-size:12px;text-align:center;padding:20px 10px;line-height:1.7"> | |
| 172 | + | Klik <strong style="color:var(--text)">Analisis AI</strong> untuk mendeteksi perbedaan antar versi model dan highlight komponen yang berubah. | |
| 173 | + | </div> | |
| 174 | + | </div> | |
| 175 | + | </div> | |
| 176 | + | </main> | |
| 177 | + | ||
| 178 | + | <div class="bottom"> | |
| 179 | + | <div class="view-modes"> | |
| 180 | + | <button class="vmode active" onclick="setViewMode('split',this)">Split</button> | |
| 181 | + | <button class="vmode" onclick="setViewMode('overlay',this)">Overlay</button> | |
| 182 | + | <button class="vmode" onclick="setViewMode('single',this)">Single</button> | |
| 183 | + | </div> | |
| 184 | + | <div class="opacity-ctrl"> | |
| 185 | + | <span>Opacity unchanged</span> | |
| 186 | + | <input type="range" id="opacitySlider" min="0" max="100" value="18" oninput="updateOpacity(this.value)"> | |
| 187 | + | <span id="opacityVal">18%</span> | |
| 188 | + | </div> | |
| 189 | + | </div> | |
| 190 | + | ||
| 191 | + | <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> | |
| 192 | + | <script> | |
| 193 | + | // ─── SCENE SETUP ──────────────────────────────────────────────────────────── | |
| 194 | + | ||
| 195 | + | let unchangedOpacity = 0.18; | |
| 196 | + | let viewMode = 'split'; | |
| 197 | + | let analysisData = null; | |
| 198 | + | let highlightedId = null; | |
| 199 | + | ||
| 200 | + | const CHANGES = [ | |
| 201 | + | { id:'beam_l3_e', floor:3, pos:[3.5,7.5,0.5], size:[3,0.4,0.4], type:'add', label:'Beam L3-E', desc:'Balok baru ditambahkan di lantai 3, sisi timur.' }, | |
| 202 | + | { id:'col_b2', floor:2, pos:[-2,4.5,2], size:[0.5,3,0.5], type:'mod', label:'Column B2', desc:'Ukuran penampang dimodifikasi dari 400×400 ke 500×500mm.' }, | |
| 203 | + | { id:'slab_l4', floor:4, pos:[0,9,0], size:[8,0.3,6], type:'mod', label:'Slab L4', desc:'Tebal pelat diubah dari 200mm menjadi 250mm.' }, | |
| 204 | + | { id:'wall_n1', floor:1, pos:[-4,1.5,-2.5], size:[0.3,3,4], type:'del', label:'Wall N1', desc:'Dinding geser dihapus pada revisi ini.' }, | |
| 205 | + | { id:'stair_c', floor:2, pos:[4,4.5,-2], size:[1.5,3,2], type:'add', label:'Staircase C', desc:'Tangga darurat baru ditambahkan di sisi utara.' }, | |
| 206 | + | ]; | |
| 207 | + | ||
| 208 | + | const CHANGE_MAP = {}; | |
| 209 | + | CHANGES.forEach(c => CHANGE_MAP[c.id] = c); | |
| 210 | + | ||
| 211 | + | // ─── THREE SCENES ──────────────────────────────────────────────────────────── | |
| 212 | + | ||
| 213 | + | function createScene(canvasId, versionB) { | |
| 214 | + | const canvas = document.getElementById(canvasId); | |
| 215 | + | const renderer = new THREE.WebGLRenderer({ canvas, antialias:true, alpha:true }); | |
| 216 | + | renderer.setPixelRatio(window.devicePixelRatio); | |
| 217 | + | renderer.shadowMap.enabled = true; | |
| 218 | + | renderer.setClearColor(0x0a0e14, 1); | |
| 219 | + | ||
| 220 | + | const scene = new THREE.Scene(); | |
| 221 | + | scene.fog = new THREE.Fog(0x0a0e14, 30, 80); | |
| 222 | + | ||
| 223 | + | const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 200); | |
| 224 | + | camera.position.set(18, 14, 18); | |
| 225 | + | camera.lookAt(0, 5, 0); | |
| 226 | + | ||
| 227 | + | // Lighting | |
| 228 | + | scene.add(new THREE.AmbientLight(0x334466, 0.8)); | |
| 229 | + | const dir = new THREE.DirectionalLight(0xffffff, 0.9); | |
| 230 | + | dir.position.set(10, 20, 10); | |
| 231 | + | dir.castShadow = true; | |
| 232 | + | scene.add(dir); | |
| 233 | + | scene.add(new THREE.PointLight(0x58a6ff, 0.4, 40)); | |
| 234 | + | ||
| 235 | + | // Grid | |
| 236 | + | const grid = new THREE.GridHelper(30, 30, 0x1c2128, 0x1c2128); | |
| 237 | + | scene.add(grid); | |
| 238 | + | ||
| 239 | + | const meshes = {}; // id → mesh | |
| 240 | + | ||
| 241 | + | // ── build model ─────────────────────────────────────────────────────────── | |
| 242 | + | const FLOORS = 5; | |
| 243 | + | const COLS = [[-3,-3],[3,-3],[3,3],[-3,3],[0,-3],[0,3],[-3,0],[3,0]]; | |
| 244 | + | const dimColor = 0x2d4a6b; | |
| 245 | + | ||
| 246 | + | function addMesh(geo, mat, pos, id) { | |
| 247 | + | const m = new THREE.Mesh(geo, mat); | |
| 248 | + | m.position.set(...pos); | |
| 249 | + | m.castShadow = true; | |
| 250 | + | m.receiveShadow = true; | |
| 251 | + | m.userData.id = id; | |
| 252 | + | scene.add(m); | |
| 253 | + | if (id) meshes[id] = m; | |
| 254 | + | return m; | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | // Columns | |
| 258 | + | COLS.forEach(([x,z], ci) => { | |
| 259 | + | for (let f=0; f<FLOORS; f++) { | |
| 260 | + | const mat = new THREE.MeshPhongMaterial({ color:dimColor, transparent:true, opacity:unchangedOpacity }); | |
| 261 | + | addMesh(new THREE.BoxGeometry(0.5,3,0.5), mat, [x, f*3+1.5, z], `col_${ci}_${f}`); | |
| 262 | + | } | |
| 263 | + | }); | |
| 264 | + | ||
| 265 | + | // Slabs | |
| 266 | + | for (let f=0; f<=FLOORS; f++) { | |
| 267 | + | const isChanged = (f===4) && versionB; | |
| 268 | + | const thickness = (isChanged) ? 0.3 : 0.25; | |
| 269 | + | const id = f===4 ? 'slab_l4' : `slab_${f}`; | |
| 270 | + | const mat = isChanged | |
| 271 | + | ? new THREE.MeshPhongMaterial({ color:0xf0883e, transparent:true, opacity:0.85, emissive:0xf0883e, emissiveIntensity:.15 }) | |
| 272 | + | : new THREE.MeshPhongMaterial({ color:dimColor, transparent:true, opacity:unchangedOpacity }); | |
| 273 | + | addMesh(new THREE.BoxGeometry(8, thickness, 6), mat, [0, f*3, 0], id); | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | // Beams | |
| 277 | + | for (let f=0; f<FLOORS; f++) { | |
| 278 | + | for (let b=0; b<3; b++) { | |
| 279 | + | const mat = new THREE.MeshPhongMaterial({ color:dimColor, transparent:true, opacity:unchangedOpacity }); | |
| 280 | + | addMesh(new THREE.BoxGeometry(8,0.4,0.4), mat, [0, f*3+2.8, -3+b*3], null); | |
| 281 | + | addMesh(new THREE.BoxGeometry(0.4,0.4,6), mat, [-3+b*3.5, f*3+2.8, 0], null); | |
| 282 | + | } | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | // Version-specific changes | |
| 286 | + | if (versionB) { | |
| 287 | + | // ADD: new beam L3 east | |
| 288 | + | const addMat = new THREE.MeshPhongMaterial({ color:0x3fb950, transparent:true, opacity:.9, emissive:0x3fb950, emissiveIntensity:.2 }); | |
| 289 | + | addMesh(new THREE.BoxGeometry(3,0.4,0.4), addMat, [3.5,7.5+2.8,0.5], 'beam_l3_e'); | |
| 290 | + | ||
| 291 | + | // MOD: column B2 thicker | |
| 292 | + | const modCol = new THREE.Mesh(new THREE.BoxGeometry(0.55,3,0.55), | |
| 293 | + | new THREE.MeshPhongMaterial({ color:0xf0883e, transparent:true, opacity:.9, emissive:0xf0883e, emissiveIntensity:.2 })); | |
| 294 | + | modCol.position.set(-2,4.5,2); modCol.userData.id='col_b2'; | |
| 295 | + | scene.add(modCol); meshes['col_b2'] = modCol; | |
| 296 | + | ||
| 297 | + | // ADD: staircase C | |
| 298 | + | const stairMat = new THREE.MeshPhongMaterial({ color:0x3fb950, transparent:true, opacity:.85, emissive:0x3fb950, emissiveIntensity:.2 }); | |
| 299 | + | addMesh(new THREE.BoxGeometry(1.5,3,2), stairMat, [4,4.5,-2], 'stair_c'); | |
| 300 | + | } else { | |
| 301 | + | // DEL: wall N1 only in version A | |
| 302 | + | const delMat = new THREE.MeshPhongMaterial({ color:0xf85149, transparent:true, opacity:.85, emissive:0xf85149, emissiveIntensity:.2 }); | |
| 303 | + | addMesh(new THREE.BoxGeometry(0.3,3,4), delMat, [-4,1.5,-2.5], 'wall_n1'); | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | // Orbit controls (manual) | |
| 307 | + | let isDragging=false, prevMouse={x:0,y:0}; | |
| 308 | + | let theta=Math.PI/4, phi=Math.PI/4, radius=30; | |
| 309 | + | canvas.addEventListener('mousedown', e => { isDragging=true; prevMouse={x:e.clientX,y:e.clientY}; }); | |
| 310 | + | window.addEventListener('mouseup', () => isDragging=false); | |
| 311 | + | window.addEventListener('mousemove', e => { | |
| 312 | + | if (!isDragging) return; | |
| 313 | + | const dx=(e.clientX-prevMouse.x)*0.01, dy=(e.clientY-prevMouse.y)*0.01; | |
| 314 | + | theta-=dx; phi=Math.max(.2,Math.min(1.4,phi+dy)); | |
| 315 | + | prevMouse={x:e.clientX,y:e.clientY}; | |
| 316 | + | }); | |
| 317 | + | canvas.addEventListener('wheel', e => { radius=Math.max(10,Math.min(60,radius+e.deltaY*.05)); e.preventDefault(); }, {passive:false}); | |
| 318 | + | ||
| 319 | + | function resizeCanvas() { | |
| 320 | + | const w=canvas.clientWidth, h=canvas.clientHeight; | |
| 321 | + | renderer.setSize(w,h,false); | |
| 322 | + | camera.aspect=w/h; camera.updateProjectionMatrix(); | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | function animate() { | |
| 326 | + | requestAnimationFrame(animate); | |
| 327 | + | if (canvas.clientWidth !== renderer.domElement.width || canvas.clientHeight !== renderer.domElement.height) resizeCanvas(); | |
| 328 | + | camera.position.x = radius*Math.sin(theta)*Math.cos(phi); | |
| 329 | + | camera.position.y = radius*Math.sin(phi)+5; | |
| 330 | + | camera.position.z = radius*Math.cos(theta)*Math.cos(phi); | |
| 331 | + | camera.lookAt(0,5,0); | |
| 332 | + | renderer.render(scene,camera); | |
| 333 | + | } | |
| 334 | + | animate(); | |
| 335 | + | ||
| 336 | + | return { scene, meshes }; | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | const sceneA = createScene('canvasA', false); | |
| 340 | + | const sceneB = createScene('canvasB', true); | |
| 341 | + | ||
| 342 | + | // ─── OPACITY UPDATE ────────────────────────────────────────────────────────── | |
| 343 | + | ||
| 344 | + | function updateOpacity(val) { | |
| 345 | + | unchangedOpacity = val/100; | |
| 346 | + | document.getElementById('opacityVal').textContent = val + '%'; | |
| 347 | + | [sceneA, sceneB].forEach(({meshes, scene}) => { | |
| 348 | + | scene.traverse(obj => { | |
| 349 | + | if (!obj.isMesh) return; | |
| 350 | + | const id = obj.userData.id; | |
| 351 | + | const isChanged = id && CHANGE_MAP[id]; | |
| 352 | + | if (!isChanged) { | |
| 353 | + | obj.material.opacity = unchangedOpacity; | |
| 354 | + | obj.material.color.setHex(0x2d4a6b); | |
| 355 | + | obj.material.emissiveIntensity = 0; | |
| 356 | + | } | |
| 357 | + | }); | |
| 358 | + | }); | |
| 359 | + | } | |
| 360 | + | ||
| 361 | + | // ─── HIGHLIGHT ITEM ────────────────────────────────────────────────────────── | |
| 362 | + | ||
| 363 | + | function focusItem(id) { | |
| 364 | + | highlightedId = id; | |
| 365 | + | document.querySelectorAll('.change-item').forEach(el => el.classList.toggle('active', el.dataset.id===id)); | |
| 366 | + | ||
| 367 | + | [sceneA, sceneB].forEach(({scene}) => { | |
| 368 | + | scene.traverse(obj => { | |
| 369 | + | if (!obj.isMesh) return; | |
| 370 | + | const oid = obj.userData.id; | |
| 371 | + | if (!oid) return; | |
| 372 | + | const isTarget = oid === id; | |
| 373 | + | const isChanged = oid && CHANGE_MAP[oid]; | |
| 374 | + | if (isTarget) { | |
| 375 | + | obj.material.emissiveIntensity = .5; | |
| 376 | + | obj.material.opacity = 1; | |
| 377 | + | } else if (isChanged) { | |
| 378 | + | obj.material.emissiveIntensity = .15; | |
| 379 | + | obj.material.opacity = .85; | |
| 380 | + | } else { | |
| 381 | + | obj.material.opacity = unchangedOpacity * .5; | |
| 382 | + | } | |
| 383 | + | }); | |
| 384 | + | }); | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | // ─── VIEW MODES ────────────────────────────────────────────────────────────── | |
| 388 | + | ||
| 389 | + | function setViewMode(mode, el) { | |
| 390 | + | viewMode = mode; | |
| 391 | + | document.querySelectorAll('.vmode').forEach(b => b.classList.remove('active')); | |
| 392 | + | el.classList.add('active'); | |
| 393 | + | const wA = document.getElementById('wrapA'); | |
| 394 | + | const wB = document.getElementById('wrapB'); | |
| 395 | + | const div = document.getElementById('divider'); | |
| 396 | + | const badge = document.getElementById('overlayBadge'); | |
| 397 | + | ||
| 398 | + | if (mode==='split') { | |
| 399 | + | wA.style.display=''; wB.style.display=''; div.style.display=''; | |
| 400 | + | badge.style.display='none'; | |
| 401 | + | wA.style.flex='1'; wB.style.flex='1'; | |
| 402 | + | } else if (mode==='overlay') { | |
| 403 | + | wA.style.display=''; wB.style.display='none'; div.style.display='none'; | |
| 404 | + | badge.style.display='block'; | |
| 405 | + | } else { | |
| 406 | + | wA.style.display=''; wB.style.display='none'; div.style.display='none'; | |
| 407 | + | badge.style.display='none'; | |
| 408 | + | } | |
| 409 | + | } | |
| 410 | + | ||
| 411 | + | // ─── AI ANALYSIS ───────────────────────────────────────────────────────────── | |
| 412 | + | ||
| 413 | + | async function runAIAnalysis() { | |
| 414 | + | const btn = document.getElementById('aiBtn'); | |
| 415 | + | btn.disabled = true; | |
| 416 | + | btn.innerHTML = '<div class="spinner" style="width:14px;height:14px;border-width:2px"></div> Analyzing...'; | |
| 417 | + | ||
| 418 | + | document.getElementById('sideBody').innerHTML = ` | |
| 419 | + | <div class="loading-ai"> | |
| 420 | + | <div class="spinner"></div> | |
| 421 | + | <div>AI sedang menganalisis perubahan struktural…</div> | |
| 422 | + | <div class="dots"><span></span><span></span><span></span></div> | |
| 423 | + | </div>`; | |
| 424 | + | ||
| 425 | + | const modelDescription = ` | |
| 426 | + | Ini adalah model BIM struktural gedung "Snowdon Towers" dengan 5 lantai. | |
| 427 | + | ||
| 428 | + | Perubahan antara Version A (Revision 0) dan Version B (Revision 1): | |
| 429 | + | 1. Balok baru (Beam L3-E) ditambahkan di lantai 3 sisi timur, dimensi 300×400mm | |
| 430 | + | 2. Kolom B2 dimodifikasi – penampang naik dari 400×400mm ke 500×500mm di lantai 2 | |
| 431 | + | 3. Pelat lantai 4 (Slab L4) – ketebalan diubah dari 200mm ke 250mm | |
| 432 | + | 4. Dinding geser N1 dihapus dari lantai 1 | |
| 433 | + | 5. Tangga darurat C (Staircase C) ditambahkan di sisi utara lantai 2 | |
| 434 | + | ||
| 435 | + | Berikan analisis profesional singkat tentang implikasi struktural dan konstruksi dari perubahan-perubahan ini dalam Bahasa Indonesia. Maksimal 3 kalimat.`; | |
| 436 | + | ||
| 437 | + | try { | |
| 438 | + | const res = await fetch('https://api.anthropic.com/v1/messages', { | |
| 439 | + | method:'POST', | |
| 440 | + | headers:{'Content-Type':'application/json'}, | |
| 441 | + | body: JSON.stringify({ | |
| 442 | + | model:'claude-sonnet-4-20250514', | |
| 443 | + | max_tokens:1000, | |
| 444 | + | messages:[{ role:'user', content: modelDescription }] | |
| 445 | + | }) | |
| 446 | + | }); | |
| 447 | + | const data = await res.json(); | |
| 448 | + | const summary = data.content.map(i => i.text||'').join(''); | |
| 449 | + | renderSidePanel(summary); | |
| 450 | + | } catch(e) { | |
| 451 | + | renderSidePanel('Perubahan signifikan terdeteksi pada elemen struktural. Terdapat modifikasi kolom dan pelat yang memerlukan review insinyur struktural.'); | |
| 452 | + | } | |
| 453 | + | ||
| 454 | + | btn.disabled = false; | |
| 455 | + | btn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/></svg> Analisis AI'; | |
| 456 | + | } | |
| 457 | + | ||
| 458 | + | function renderSidePanel(summary) { | |
| 459 | + | const added = CHANGES.filter(c=>c.type==='add').length; | |
| 460 | + | const mods = CHANGES.filter(c=>c.type==='mod').length; | |
| 461 | + | const dels = CHANGES.filter(c=>c.type==='del').length; | |
| 462 | + | ||
| 463 | + | const typeLabel = {add:'+ Ditambahkan', mod:'✎ Dimodifikasi', del:'- Dihapus'}; | |
| 464 | + | ||
| 465 | + | document.getElementById('sideBody').innerHTML = ` | |
| 466 | + | <div class="stats-row"> | |
| 467 | + | <div class="stat"><div class="stat-n n-add">${added}</div><div class="stat-l">Added</div></div> | |
| 468 | + | <div class="stat"><div class="stat-n n-mod">${mods}</div><div class="stat-l">Modified</div></div> | |
| 469 | + | <div class="stat"><div class="stat-n n-del">${dels}</div><div class="stat-l">Removed</div></div> | |
| 470 | + | </div> | |
| 471 | + | ||
| 472 | + | <div class="ai-box"> | |
| 473 | + | <div class="ai-box-head"> | |
| 474 | + | <svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/></svg> | |
| 475 | + | Ringkasan AI | |
| 476 | + | </div> | |
| 477 | + | <div class="ai-box-text" id="aiSummaryText"></div> | |
| 478 | + | </div> | |
| 479 | + | ||
| 480 | + | <div class="sec-title">Komponen Berubah</div> | |
| 481 | + | ${CHANGES.map(c => ` | |
| 482 | + | <div class="change-item ${c.type}" data-id="${c.id}" onclick="focusItem('${c.id}')"> | |
| 483 | + | <div class="ci-type ${c.type}">${typeLabel[c.type]}</div> | |
| 484 | + | <div class="ci-name">${c.label}</div> | |
| 485 | + | <div class="ci-desc">${c.desc}</div> | |
| 486 | + | </div> | |
| 487 | + | `).join('')} | |
| 488 | + | `; | |
| 489 | + | ||
| 490 | + | streamText('aiSummaryText', summary); | |
| 491 | + | } | |
| 492 | + | ||
| 493 | + | function streamText(elId, text) { | |
| 494 | + | const el = document.getElementById(elId); | |
| 495 | + | if (!el) return; | |
| 496 | + | el.textContent = ''; | |
| 497 | + | let i = 0; | |
| 498 | + | const iv = setInterval(() => { | |
| 499 | + | if (i >= text.length) { clearInterval(iv); return; } | |
| 500 | + | el.textContent += text[i++]; | |
| 501 | + | }, 16); | |
| 502 | + | } | |
| 503 | + | ||
| 504 | + | // Slider tooltip | |
| 505 | + | document.getElementById('opacitySlider').addEventListener('input', function() { | |
| 506 | + | document.getElementById('opacityVal').textContent = this.value + '%'; | |
| 507 | + | }); | |
| 508 | + | </script> | |
| 509 | + | </body> | |
| 510 | + | </html> | |
Newer
Older