Bim 3d compare.html
· 23 KiB · HTML
Raw
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BIM Compare – Consdoc Viewer</title>
<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">
<style>
:root {
--bg: #0d1117; --surface: #161b22; --surface2: #1c2128;
--border: #30363d; --text: #e6edf3; --muted: #7d8590;
--accent: #58a6ff; --add: #3fb950; --mod: #f0883e; --del: #f85149;
--ai: #bc8cff;
}
* { box-sizing: border-box; margin:0; padding:0; }
body { background:var(--bg); color:var(--text); font-family:'DM Sans',sans-serif; height:100vh; display:flex; flex-direction:column; overflow:hidden; }
nav {
height:48px; background:var(--surface); border-bottom:1px solid var(--border);
display:flex; align-items:center; justify-content:space-between; padding:0 16px; flex-shrink:0;
}
.brand { font-family:'Syne',sans-serif; font-weight:800; font-size:16px; }
.brand span { color:var(--accent); }
.file-info { font-size:12px; color:var(--muted); display:flex; align-items:center; gap:8px; }
.file-info b { color:var(--text); }
.nav-right { display:flex; align-items:center; gap:8px; }
.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; }
.btn:hover { background:var(--border); }
.btn-ai { background:linear-gradient(135deg,#7c3aed,#bc8cff); color:white; border:none; font-weight:600; display:flex; align-items:center; gap:5px; }
.btn-ai:hover { opacity:.88; background:linear-gradient(135deg,#7c3aed,#bc8cff); }
.btn-primary { background:var(--accent); color:#0d1117; border-color:var(--accent); font-weight:600; }
main { flex:1; display:flex; overflow:hidden; }
/* VIEWERS */
.viewers { flex:1; display:flex; gap:2px; background:var(--border); position:relative; }
.viewer-wrap { flex:1; position:relative; background:#0a0e14; }
.viewer-label {
position:absolute; top:10px; left:10px; z-index:10;
font-family:'Syne',sans-serif; font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.8px;
background:rgba(0,0,0,.6); backdrop-filter:blur(8px);
padding:4px 10px; border-radius:5px; border:1px solid var(--border); color:var(--muted);
}
.viewer-ver { color:var(--accent); }
canvas { display:block; width:100% !important; height:100% !important; }
/* DIVIDER HANDLE */
.divider {
position:absolute; left:50%; top:0; bottom:0; width:3px; z-index:20;
background:var(--accent); cursor:ew-resize; transform:translateX(-50%);
display:flex; align-items:center; justify-content:center;
}
.divider-handle {
width:24px; height:40px; background:var(--accent); border-radius:4px;
display:flex; align-items:center; justify-content:center; color:#0d1117; font-size:14px; font-weight:bold;
box-shadow:0 2px 12px rgba(88,166,255,.4);
}
/* overlay mode */
.overlay-badge {
position:absolute; top:10px; left:50%; transform:translateX(-50%); z-index:30;
background:rgba(0,0,0,.7); backdrop-filter:blur(8px);
border:1px solid var(--border); border-radius:6px; padding:4px 12px;
font-size:11px; color:var(--muted); display:none;
}
/* SIDE PANEL */
.side { width:300px; background:var(--surface); border-left:1px solid var(--border); display:flex; flex-direction:column; flex-shrink:0; }
.side-head { padding:14px 16px 10px; border-bottom:1px solid var(--border); }
.side-title { font-family:'Syne',sans-serif; font-weight:700; font-size:14px; display:flex; align-items:center; gap:6px; }
.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; }
.legend { display:flex; gap:8px; padding:8px 16px; border-bottom:1px solid var(--border); flex-shrink:0; flex-wrap:wrap; }
.leg-item { display:flex; align-items:center; gap:4px; font-size:11px; color:var(--muted); }
.leg-dot { width:10px; height:10px; border-radius:2px; }
.side-body { flex:1; overflow-y:auto; padding:12px; display:flex; flex-direction:column; gap:8px; }
.side-body::-webkit-scrollbar { width:3px; }
.side-body::-webkit-scrollbar-thumb { background:var(--border); border-radius:3px; }
.stats-row { display:grid; grid-template-columns:1fr 1fr 1fr; gap:6px; }
.stat { background:var(--surface2); border:1px solid var(--border); border-radius:7px; padding:8px; text-align:center; }
.stat-n { font-family:'Syne',sans-serif; font-size:18px; font-weight:800; }
.stat-l { font-size:10px; color:var(--muted); text-transform:uppercase; letter-spacing:.5px; margin-top:2px; }
.n-add { color:var(--add); } .n-mod { color:var(--mod); } .n-del { color:var(--del); }
.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; }
.change-item {
background:var(--surface2); border:1px solid var(--border); border-radius:7px;
padding:9px 11px; cursor:pointer; transition:.15s;
border-left:3px solid;
}
.change-item:hover, .change-item.active { background:var(--bg); box-shadow:0 0 0 1px var(--border); }
.change-item.add { border-left-color:var(--add); }
.change-item.mod { border-left-color:var(--mod); }
.change-item.del { border-left-color:var(--del); }
.ci-type { font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.5px; margin-bottom:3px; }
.ci-type.add { color:var(--add); } .ci-type.mod { color:var(--mod); } .ci-type.del { color:var(--del); }
.ci-name { font-size:13px; font-weight:500; }
.ci-desc { font-size:11px; color:var(--muted); margin-top:2px; line-height:1.5; }
.ai-box {
background:linear-gradient(135deg,rgba(124,58,237,.1),rgba(188,140,255,.05));
border:1px solid rgba(188,140,255,.2); border-radius:8px; padding:10px 12px;
}
.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; }
.ai-box-text { font-size:12px; line-height:1.6; }
/* BOTTOM */
.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; }
.view-modes { display:flex; gap:4px; }
.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; }
.vmode.active { background:var(--surface2); color:var(--text); border-color:var(--accent); color:var(--accent); }
.opacity-ctrl { display:flex; align-items:center; gap:8px; font-size:11px; color:var(--muted); }
input[type=range] { width:80px; accent-color:var(--accent); }
.spinner { width:28px; height:28px; border:2px solid var(--border); border-top-color:var(--ai); border-radius:50%; animation:spin .7s linear infinite; }
@keyframes spin { to { transform:rotate(360deg); } }
.loading-ai { display:flex; flex-direction:column; align-items:center; gap:8px; color:var(--muted); font-size:12px; padding:20px; }
.dots span { display:inline-block; width:5px; height:5px; background:var(--ai); border-radius:50%; margin:0 2px; animation:blink 1.2s infinite; }
.dots span:nth-child(2){animation-delay:.2s} .dots span:nth-child(3){animation-delay:.4s}
@keyframes blink { 0%,80%,100%{opacity:.2;transform:scale(.8)} 40%{opacity:1;transform:scale(1)} }
</style>
</head>
<body>
<nav>
<div class="brand">⬡ Cons<span>doc</span></div>
<div class="file-info">
<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>
<b>Snowdon Towers Sample Structural.rvt</b>
<span>— BIM Comparison</span>
</div>
<div class="nav-right">
<button class="btn btn-ai" id="aiBtn" onclick="runAIAnalysis()">
<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
</button>
<button class="btn btn-primary">Compare files</button>
</div>
</nav>
<main>
<div class="viewers" id="viewersContainer">
<div class="viewer-wrap" id="wrapA">
<div class="viewer-label">Version A <span class="viewer-ver">V0 · Rev 0</span></div>
<canvas id="canvasA"></canvas>
</div>
<div class="divider" id="divider">
<div class="divider-handle">⇔</div>
</div>
<div class="viewer-wrap" id="wrapB">
<div class="viewer-label">Version B <span class="viewer-ver">V1 · Rev 0</span></div>
<canvas id="canvasB"></canvas>
</div>
<div class="overlay-badge" id="overlayBadge">Overlay Mode</div>
</div>
<div class="side">
<div class="side-head">
<div class="side-title">Compare Document <span class="ai-pill">AI</span></div>
</div>
<div class="legend">
<div class="leg-item"><div class="leg-dot" style="background:#3fb950"></div>Ditambahkan</div>
<div class="leg-item"><div class="leg-dot" style="background:#f0883e"></div>Dimodifikasi</div>
<div class="leg-item"><div class="leg-dot" style="background:#f85149"></div>Dihapus</div>
<div class="leg-item"><div class="leg-dot" style="background:#58a6ff;opacity:.35"></div>Tidak berubah</div>
</div>
<div class="side-body" id="sideBody">
<div style="color:var(--muted);font-size:12px;text-align:center;padding:20px 10px;line-height:1.7">
Klik <strong style="color:var(--text)">Analisis AI</strong> untuk mendeteksi perbedaan antar versi model dan highlight komponen yang berubah.
</div>
</div>
</div>
</main>
<div class="bottom">
<div class="view-modes">
<button class="vmode active" onclick="setViewMode('split',this)">Split</button>
<button class="vmode" onclick="setViewMode('overlay',this)">Overlay</button>
<button class="vmode" onclick="setViewMode('single',this)">Single</button>
</div>
<div class="opacity-ctrl">
<span>Opacity unchanged</span>
<input type="range" id="opacitySlider" min="0" max="100" value="18" oninput="updateOpacity(this.value)">
<span id="opacityVal">18%</span>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
// ─── SCENE SETUP ────────────────────────────────────────────────────────────
let unchangedOpacity = 0.18;
let viewMode = 'split';
let analysisData = null;
let highlightedId = null;
const CHANGES = [
{ 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.' },
{ 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.' },
{ 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.' },
{ 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.' },
{ 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.' },
];
const CHANGE_MAP = {};
CHANGES.forEach(c => CHANGE_MAP[c.id] = c);
// ─── THREE SCENES ────────────────────────────────────────────────────────────
function createScene(canvasId, versionB) {
const canvas = document.getElementById(canvasId);
const renderer = new THREE.WebGLRenderer({ canvas, antialias:true, alpha:true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.shadowMap.enabled = true;
renderer.setClearColor(0x0a0e14, 1);
const scene = new THREE.Scene();
scene.fog = new THREE.Fog(0x0a0e14, 30, 80);
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 200);
camera.position.set(18, 14, 18);
camera.lookAt(0, 5, 0);
// Lighting
scene.add(new THREE.AmbientLight(0x334466, 0.8));
const dir = new THREE.DirectionalLight(0xffffff, 0.9);
dir.position.set(10, 20, 10);
dir.castShadow = true;
scene.add(dir);
scene.add(new THREE.PointLight(0x58a6ff, 0.4, 40));
// Grid
const grid = new THREE.GridHelper(30, 30, 0x1c2128, 0x1c2128);
scene.add(grid);
const meshes = {}; // id → mesh
// ── build model ───────────────────────────────────────────────────────────
const FLOORS = 5;
const COLS = [[-3,-3],[3,-3],[3,3],[-3,3],[0,-3],[0,3],[-3,0],[3,0]];
const dimColor = 0x2d4a6b;
function addMesh(geo, mat, pos, id) {
const m = new THREE.Mesh(geo, mat);
m.position.set(...pos);
m.castShadow = true;
m.receiveShadow = true;
m.userData.id = id;
scene.add(m);
if (id) meshes[id] = m;
return m;
}
// Columns
COLS.forEach(([x,z], ci) => {
for (let f=0; f<FLOORS; f++) {
const mat = new THREE.MeshPhongMaterial({ color:dimColor, transparent:true, opacity:unchangedOpacity });
addMesh(new THREE.BoxGeometry(0.5,3,0.5), mat, [x, f*3+1.5, z], `col_${ci}_${f}`);
}
});
// Slabs
for (let f=0; f<=FLOORS; f++) {
const isChanged = (f===4) && versionB;
const thickness = (isChanged) ? 0.3 : 0.25;
const id = f===4 ? 'slab_l4' : `slab_${f}`;
const mat = isChanged
? new THREE.MeshPhongMaterial({ color:0xf0883e, transparent:true, opacity:0.85, emissive:0xf0883e, emissiveIntensity:.15 })
: new THREE.MeshPhongMaterial({ color:dimColor, transparent:true, opacity:unchangedOpacity });
addMesh(new THREE.BoxGeometry(8, thickness, 6), mat, [0, f*3, 0], id);
}
// Beams
for (let f=0; f<FLOORS; f++) {
for (let b=0; b<3; b++) {
const mat = new THREE.MeshPhongMaterial({ color:dimColor, transparent:true, opacity:unchangedOpacity });
addMesh(new THREE.BoxGeometry(8,0.4,0.4), mat, [0, f*3+2.8, -3+b*3], null);
addMesh(new THREE.BoxGeometry(0.4,0.4,6), mat, [-3+b*3.5, f*3+2.8, 0], null);
}
}
// Version-specific changes
if (versionB) {
// ADD: new beam L3 east
const addMat = new THREE.MeshPhongMaterial({ color:0x3fb950, transparent:true, opacity:.9, emissive:0x3fb950, emissiveIntensity:.2 });
addMesh(new THREE.BoxGeometry(3,0.4,0.4), addMat, [3.5,7.5+2.8,0.5], 'beam_l3_e');
// MOD: column B2 thicker
const modCol = new THREE.Mesh(new THREE.BoxGeometry(0.55,3,0.55),
new THREE.MeshPhongMaterial({ color:0xf0883e, transparent:true, opacity:.9, emissive:0xf0883e, emissiveIntensity:.2 }));
modCol.position.set(-2,4.5,2); modCol.userData.id='col_b2';
scene.add(modCol); meshes['col_b2'] = modCol;
// ADD: staircase C
const stairMat = new THREE.MeshPhongMaterial({ color:0x3fb950, transparent:true, opacity:.85, emissive:0x3fb950, emissiveIntensity:.2 });
addMesh(new THREE.BoxGeometry(1.5,3,2), stairMat, [4,4.5,-2], 'stair_c');
} else {
// DEL: wall N1 only in version A
const delMat = new THREE.MeshPhongMaterial({ color:0xf85149, transparent:true, opacity:.85, emissive:0xf85149, emissiveIntensity:.2 });
addMesh(new THREE.BoxGeometry(0.3,3,4), delMat, [-4,1.5,-2.5], 'wall_n1');
}
// Orbit controls (manual)
let isDragging=false, prevMouse={x:0,y:0};
let theta=Math.PI/4, phi=Math.PI/4, radius=30;
canvas.addEventListener('mousedown', e => { isDragging=true; prevMouse={x:e.clientX,y:e.clientY}; });
window.addEventListener('mouseup', () => isDragging=false);
window.addEventListener('mousemove', e => {
if (!isDragging) return;
const dx=(e.clientX-prevMouse.x)*0.01, dy=(e.clientY-prevMouse.y)*0.01;
theta-=dx; phi=Math.max(.2,Math.min(1.4,phi+dy));
prevMouse={x:e.clientX,y:e.clientY};
});
canvas.addEventListener('wheel', e => { radius=Math.max(10,Math.min(60,radius+e.deltaY*.05)); e.preventDefault(); }, {passive:false});
function resizeCanvas() {
const w=canvas.clientWidth, h=canvas.clientHeight;
renderer.setSize(w,h,false);
camera.aspect=w/h; camera.updateProjectionMatrix();
}
function animate() {
requestAnimationFrame(animate);
if (canvas.clientWidth !== renderer.domElement.width || canvas.clientHeight !== renderer.domElement.height) resizeCanvas();
camera.position.x = radius*Math.sin(theta)*Math.cos(phi);
camera.position.y = radius*Math.sin(phi)+5;
camera.position.z = radius*Math.cos(theta)*Math.cos(phi);
camera.lookAt(0,5,0);
renderer.render(scene,camera);
}
animate();
return { scene, meshes };
}
const sceneA = createScene('canvasA', false);
const sceneB = createScene('canvasB', true);
// ─── OPACITY UPDATE ──────────────────────────────────────────────────────────
function updateOpacity(val) {
unchangedOpacity = val/100;
document.getElementById('opacityVal').textContent = val + '%';
[sceneA, sceneB].forEach(({meshes, scene}) => {
scene.traverse(obj => {
if (!obj.isMesh) return;
const id = obj.userData.id;
const isChanged = id && CHANGE_MAP[id];
if (!isChanged) {
obj.material.opacity = unchangedOpacity;
obj.material.color.setHex(0x2d4a6b);
obj.material.emissiveIntensity = 0;
}
});
});
}
// ─── HIGHLIGHT ITEM ──────────────────────────────────────────────────────────
function focusItem(id) {
highlightedId = id;
document.querySelectorAll('.change-item').forEach(el => el.classList.toggle('active', el.dataset.id===id));
[sceneA, sceneB].forEach(({scene}) => {
scene.traverse(obj => {
if (!obj.isMesh) return;
const oid = obj.userData.id;
if (!oid) return;
const isTarget = oid === id;
const isChanged = oid && CHANGE_MAP[oid];
if (isTarget) {
obj.material.emissiveIntensity = .5;
obj.material.opacity = 1;
} else if (isChanged) {
obj.material.emissiveIntensity = .15;
obj.material.opacity = .85;
} else {
obj.material.opacity = unchangedOpacity * .5;
}
});
});
}
// ─── VIEW MODES ──────────────────────────────────────────────────────────────
function setViewMode(mode, el) {
viewMode = mode;
document.querySelectorAll('.vmode').forEach(b => b.classList.remove('active'));
el.classList.add('active');
const wA = document.getElementById('wrapA');
const wB = document.getElementById('wrapB');
const div = document.getElementById('divider');
const badge = document.getElementById('overlayBadge');
if (mode==='split') {
wA.style.display=''; wB.style.display=''; div.style.display='';
badge.style.display='none';
wA.style.flex='1'; wB.style.flex='1';
} else if (mode==='overlay') {
wA.style.display=''; wB.style.display='none'; div.style.display='none';
badge.style.display='block';
} else {
wA.style.display=''; wB.style.display='none'; div.style.display='none';
badge.style.display='none';
}
}
// ─── AI ANALYSIS ─────────────────────────────────────────────────────────────
async function runAIAnalysis() {
const btn = document.getElementById('aiBtn');
btn.disabled = true;
btn.innerHTML = '<div class="spinner" style="width:14px;height:14px;border-width:2px"></div> Analyzing...';
document.getElementById('sideBody').innerHTML = `
<div class="loading-ai">
<div class="spinner"></div>
<div>AI sedang menganalisis perubahan struktural…</div>
<div class="dots"><span></span><span></span><span></span></div>
</div>`;
const modelDescription = `
Ini adalah model BIM struktural gedung "Snowdon Towers" dengan 5 lantai.
Perubahan antara Version A (Revision 0) dan Version B (Revision 1):
1. Balok baru (Beam L3-E) ditambahkan di lantai 3 sisi timur, dimensi 300×400mm
2. Kolom B2 dimodifikasi – penampang naik dari 400×400mm ke 500×500mm di lantai 2
3. Pelat lantai 4 (Slab L4) – ketebalan diubah dari 200mm ke 250mm
4. Dinding geser N1 dihapus dari lantai 1
5. Tangga darurat C (Staircase C) ditambahkan di sisi utara lantai 2
Berikan analisis profesional singkat tentang implikasi struktural dan konstruksi dari perubahan-perubahan ini dalam Bahasa Indonesia. Maksimal 3 kalimat.`;
try {
const res = await fetch('https://api.anthropic.com/v1/messages', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({
model:'claude-sonnet-4-20250514',
max_tokens:1000,
messages:[{ role:'user', content: modelDescription }]
})
});
const data = await res.json();
const summary = data.content.map(i => i.text||'').join('');
renderSidePanel(summary);
} catch(e) {
renderSidePanel('Perubahan signifikan terdeteksi pada elemen struktural. Terdapat modifikasi kolom dan pelat yang memerlukan review insinyur struktural.');
}
btn.disabled = false;
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';
}
function renderSidePanel(summary) {
const added = CHANGES.filter(c=>c.type==='add').length;
const mods = CHANGES.filter(c=>c.type==='mod').length;
const dels = CHANGES.filter(c=>c.type==='del').length;
const typeLabel = {add:'+ Ditambahkan', mod:'✎ Dimodifikasi', del:'- Dihapus'};
document.getElementById('sideBody').innerHTML = `
<div class="stats-row">
<div class="stat"><div class="stat-n n-add">${added}</div><div class="stat-l">Added</div></div>
<div class="stat"><div class="stat-n n-mod">${mods}</div><div class="stat-l">Modified</div></div>
<div class="stat"><div class="stat-n n-del">${dels}</div><div class="stat-l">Removed</div></div>
</div>
<div class="ai-box">
<div class="ai-box-head">
<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>
Ringkasan AI
</div>
<div class="ai-box-text" id="aiSummaryText"></div>
</div>
<div class="sec-title">Komponen Berubah</div>
${CHANGES.map(c => `
<div class="change-item ${c.type}" data-id="${c.id}" onclick="focusItem('${c.id}')">
<div class="ci-type ${c.type}">${typeLabel[c.type]}</div>
<div class="ci-name">${c.label}</div>
<div class="ci-desc">${c.desc}</div>
</div>
`).join('')}
`;
streamText('aiSummaryText', summary);
}
function streamText(elId, text) {
const el = document.getElementById(elId);
if (!el) return;
el.textContent = '';
let i = 0;
const iv = setInterval(() => {
if (i >= text.length) { clearInterval(iv); return; }
el.textContent += text[i++];
}, 16);
}
// Slider tooltip
document.getElementById('opacitySlider').addEventListener('input', function() {
document.getElementById('opacityVal').textContent = this.value + '%';
});
</script>
</body>
</html>
| 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> |