Files
dxf-to-3d-gasiseol/index.html
T
Claude HaikuandClaude Haiku 4.5 54d4b58a2a 뷰어: 모바일 최적화 (하단 시트 패널, 터치 조작, 핀치 줌)
좁은 화면에서는 패널을 하단 시트로 바꾸고 접기·펼치기를 추가한다.
마우스 이벤트를 포인터 이벤트로 바꿔 한 손가락 회전, 두 손가락 핀치 줌, 탭 측정을 지원한다.
버튼 터치 영역을 키우고 캔버스의 기본 스크롤을 막는다.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-21 12:10:11 +09:00

396 lines
21 KiB
HTML

<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>가시설 통합모델 v4</title>
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #0d1f2d; font-family: 'Segoe UI', sans-serif; }
canvas { display: block; width: 100%; height: 100%; }
#panel { position: fixed; top: 10px; left: 10px; color: #fff; background: rgba(13,31,45,0.9); padding: 10px 12px; border-radius: 6px; font-size: 13px; line-height: 1.6; min-width: 300px; }
#panel h4 { margin: 0 0 4px; font-size: 14px; }
#panel .row { display: flex; justify-content: space-between; gap: 10px; }
#panel .spec { color: #9fb3c8; font-size: 11px; }
#panel .vrow { display: flex; gap: 4px; margin-top: 6px; }
#panel button.vb { margin-top: 0; width: auto; flex: 1; padding: 5px 4px; background: #1e3a4f; color: #fff; border: 1px solid #10b981; font-size: 12px; font-weight: 600; }
#panel button.vb.on { background: #10b981; color: #0d1f2d; }
#measureLabel { position: fixed; top: 10px; left: 50%; transform: translateX(-50%); background: rgba(13,31,45,0.92); color: #ffd166; padding: 8px 14px; border-radius: 6px; font-size: 14px; display: none; pointer-events: none; z-index: 5; }
#panel button { margin-top: 8px; width: 100%; padding: 6px; background: #10b981; color: #0d1f2d; border: none; border-radius: 4px; font-weight: 700; cursor: pointer; }
#hint { position: fixed; bottom: 8px; left: 10px; color: #9fb3c8; font-size: 12px; }
canvas { touch-action: none; }
#panel .phead { display: flex; justify-content: space-between; align-items: center; }
#panel .ptoggle { display: none; margin: 0; width: auto; padding: 4px 10px; font-size: 12px; }
@media (max-width: 720px) {
#panel { top: auto; bottom: 0; left: 0; right: 0; min-width: 0; max-width: none; max-height: 48vh; overflow-y: auto; border-radius: 12px 12px 0 0; padding: 10px 12px calc(10px + env(safe-area-inset-bottom)); font-size: 13px; }
#panel .ptoggle { display: inline-block; }
#panel.collapsed > *:not(.phead) { display: none; }
#panel button.vb { min-height: 40px; font-size: 14px; }
#panel button { min-height: 40px; }
#panel input[type=checkbox] { width: 18px; height: 18px; }
#panel input[type=range] { height: 28px; }
#hint { display: none; }
#measureLabel { top: 8px; font-size: 13px; max-width: 92vw; box-sizing: border-box; text-align: center; }
}
</style>
</head>
<body>
<div id="panel">
<div class="phead"><h4>부재 구성 (규격)</h4><button id="btnPanel" class="ptoggle">접기</button></div>
<div id="counts">로딩 중...</div>
<button id="btnFit">줌핏 (보이는 부재)</button>
<div class="vrow">
<button class="vb" data-view="front">정면</button>
<button class="vb" data-view="top">평면</button>
<button class="vb" data-view="side">측면</button>
<button class="vb" data-view="iso">등각</button>
</div>
<div class="vrow">
<button id="btnMeasure" class="vb">거리 측정: 끔</button>
<button id="btnClear" class="vb">측정 지우기</button>
</div>
<label style="display:block;margin-top:6px;cursor:pointer"><input type="checkbox" id="chkExc" checked> 굴착면 표시 (벽체 내측면)</label>
</div>
<div id="hint">드래그: 회전 · 휠·두 손가락: 줌 · 우클릭 드래그: 이동</div>
<canvas id="canvas"></canvas>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
const canvas = document.getElementById('canvas');
const countsEl = document.getElementById('counts');
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0d1f2d);
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight, false);
const camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.01, 100000);
camera.up.set(0, 0, 1);
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
const sun = new THREE.DirectionalLight(0xffffff, 0.8);
sun.position.set(1, -1, 2);
scene.add(sun);
const COLORS = { '엄지말뚝': 0x7ed321, '토류판': 0x4a90e2, '중간말뚝': 0xff6b6b, '수직 H300 (미확정)': 0xffd166, '버팀보': 0xf5a623, '띠장': 0x2dd4bf, '수직 PIECE': 0xa78bfa, '채널': 0xf472b6 };
// H형강 단면 (폭 w, 춤 h, 플랜지 tf, 웨브 tw). 축 방향 길이 L 로 압출, 중심은 원점
function hSection(d) {
const hw = d.w / 2, hh = d.h / 2, hwb = d.tw / 2;
const s = new THREE.Shape();
s.moveTo(-hw, -hh); s.lineTo(hw, -hh); s.lineTo(hw, -hh + d.tf);
s.lineTo(hwb, -hh + d.tf); s.lineTo(hwb, hh - d.tf); s.lineTo(hw, hh - d.tf);
s.lineTo(hw, hh); s.lineTo(-hw, hh); s.lineTo(-hw, hh - d.tf);
s.lineTo(-hwb, hh - d.tf); s.lineTo(-hwb, -hh + d.tf); s.lineTo(-hw, -hh + d.tf);
s.closePath();
return s;
}
function buildGeometry(p) {
const d = p.dims;
if (p.shape === 'PANEL') {
return new THREE.BoxGeometry(d.w, d.t, d.length);
}
if (p.shape === 'CHANNEL') {
const B = d.w, H = d.h, t = d.tw, tf = d.tf;
const s = new THREE.Shape();
s.moveTo(-B / 2, -H / 2); s.lineTo(B / 2, -H / 2); s.lineTo(B / 2, -H / 2 + tf);
s.lineTo(-B / 2 + t, -H / 2 + tf); s.lineTo(-B / 2 + t, H / 2 - tf); s.lineTo(B / 2, H / 2 - tf);
s.lineTo(B / 2, H / 2); s.lineTo(-B / 2, H / 2); s.closePath();
const g = new THREE.ExtrudeGeometry(s, { depth: d.length, bevelEnabled: false, steps: 1 });
g.translate(0, 0, -d.length / 2);
g.applyMatrix4(new THREE.Matrix4().set(0, 0, 1, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1));
return g;
}
const g = new THREE.ExtrudeGeometry(hSection(d), { depth: d.length, bevelEnabled: false, steps: 1 });
g.translate(0, 0, -d.length / 2);
return g;
}
const groups = {};
const excavationMeshes = [];
const allBox = new THREE.Box3();
const target = new THREE.Vector3();
let radius = 1000, theta = Math.PI / 4, phi = Math.PI / 3;
function updateCamera() {
const sp = Math.sin(phi);
camera.position.set(
target.x + radius * sp * Math.cos(theta),
target.y + radius * sp * Math.sin(theta),
target.z + radius * Math.cos(phi)
);
camera.lookAt(target);
}
function fitVisible() {
const box = new THREE.Box3();
Object.values(groups).forEach(list => list.forEach(m => { if (m.visible) box.expandByObject(m); }));
if (box.isEmpty()) return;
box.getCenter(target);
const size = box.getSize(new THREE.Vector3());
radius = Math.max(size.x, size.y, size.z) / 2 / Math.tan(THREE.MathUtils.degToRad(camera.fov / 2)) * 1.1;
updateCamera();
}
function buildJack() {
// 상세도 jack 블록(mm): 길이 599.5(Y축), 높이 309.3(Z축), 폭 0.3m(X축)을 m로 환산
const g = new THREE.Group();
const add = (geo, x, y, z) => {
const m = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({ color: 0xfbbf24, roughness: 0.6 }));
m.position.set(x, y, z);
g.add(m);
};
add(new THREE.BoxGeometry(0.30, 0.0141, 0.3093), 0, -0.29268, 0);
add(new THREE.BoxGeometry(0.30, 0.0145, 0.3093), 0, 0.29253, 0);
add(new THREE.BoxGeometry(0.20, 0.5709, 0.1557), 0, 0, 0);
add(new THREE.CylinderGeometry(0.0467, 0.0467, 0.3218, 20), 0, 0, 0);
return g;
}
fetch('gasiseol-v4-stats.json')
.then(r => r.json())
.then(data => {
data.parts.forEach(p => {
const mesh = p.shape === 'JACK' ? buildJack() : new THREE.Mesh(buildGeometry(p), new THREE.MeshStandardMaterial({ color: COLORS[p.memberType] || 0x999999, roughness: 0.7 }));
const axisHoriz = p.axis === 'horizontal';
// 수직 부재: 평면 단면(x-y), 길이는 z 방향 (지표 z=0 에서 아래로)
// 수평 부재(버팀보): 길이를 x 방향으로 눕힘, 설치 깊이는 말뚝 길이의 절반
if (p.axis === 'horizontal') mesh.rotation.y = Math.PI / 2;
if (p.axis === 'horizontalY') mesh.rotation.x = Math.PI / 2;
mesh.position.set(p.position.x, p.position.y, p.position.z);
scene.add(mesh);
allBox.expandByObject(mesh);
(groups[p.memberType] = groups[p.memberType] || []).push(mesh);
});
const buildSurface = (y0, y1, profile, color) => {
const pos = [];
for (let i = 0; i + 1 < profile.length; i++) {
const a = profile[i], b = profile[i + 1];
const A = [a.x, y0, a.z], B = [a.x, y1, a.z], C = [b.x, y0, b.z], D = [b.x, y1, b.z];
pos.push(...A, ...B, ...C, ...C, ...B, ...D);
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
g.computeVertexNormals();
const m = new THREE.Mesh(g, new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.3, side: THREE.DoubleSide, depthWrite: false }));
scene.add(m);
excavationMeshes.push(m);
allBox.expandByObject(m);
};
buildSurface(data.excavation.yA, data.excavation.yB, data.excavation.profile, 0x8ecae6);
data.road.forEach(r => buildSurface(r.y0, r.y1, r.profile, 0xd6b98c));
document.getElementById('chkExc').addEventListener('change', e => {
excavationMeshes.forEach(m => m.visible = e.target.checked);
});
const total = data.totalParts;
countsEl.innerHTML = Object.keys(groups).map(name => {
const sample = data.parts.find(p => p.memberType === name);
return `<label class="row" style="cursor:pointer"><span><input type="checkbox" data-type="${name}" checked> ${name}</span><span>${groups[name].length}</span></label><div class="spec">${sample.spec}</div><div class="row" style="align-items:center"><span class="spec">투명도</span><input type="range" min="0.1" max="1" step="0.05" value="1" data-op="${name}" style="flex:1;margin-left:8px"></div>`;
}).join('') + `<div class="row" style="border-top:1px solid #9fb3c8;margin-top:4px;padding-top:4px"><b>합계</b><b id="visibleTotal"></b></div>`;
const updateTotal = () => {
let shown = 0;
Object.values(groups).forEach(list => { if (list[0].visible) shown += list.length; });
document.getElementById('visibleTotal').textContent = shown + ' / ' + total;
};
countsEl.querySelectorAll('input[type=checkbox]').forEach(box => {
box.addEventListener('change', () => {
groups[box.dataset.type].forEach(m => m.visible = box.checked);
updateTotal();
});
});
countsEl.querySelectorAll('input[type=range][data-op]').forEach(slider => {
slider.addEventListener('input', () => {
const o = parseFloat(slider.value);
groups[slider.dataset.op].forEach(obj => obj.traverse(c => {
if (!c.isMesh) return;
c.material.transparent = o < 1;
c.material.opacity = o;
c.material.depthWrite = o >= 1;
c.material.needsUpdate = true;
}));
});
});
updateTotal();
fitVisible();
})
.catch(err => { countsEl.textContent = '오류: ' + err.message; });
document.getElementById('btnFit').addEventListener('click', fitVisible);
const panelEl = document.getElementById('panel');
document.getElementById('btnPanel').addEventListener('click', e => {
const collapsed = panelEl.classList.toggle('collapsed');
e.target.textContent = collapsed ? '펼치기' : '접기';
});
const VIEWS = {
front: { theta: Math.PI / 2, phi: Math.PI / 2 },
top: { theta: -Math.PI / 2, phi: 0.02 },
side: { theta: 0, phi: Math.PI / 2 },
iso: { theta: Math.PI / 4, phi: Math.PI / 3 }
};
document.querySelectorAll('button.vb[data-view]').forEach(btn => {
btn.addEventListener('click', () => {
const v = VIEWS[btn.dataset.view];
theta = v.theta; phi = v.phi;
fitVisible();
document.querySelectorAll('button.vb[data-view]').forEach(b => b.classList.toggle('on', b === btn));
});
});
const measureLabel = document.createElement('div');
measureLabel.id = 'measureLabel';
document.body.appendChild(measureLabel);
const measurePts = [];
const measureMarkers = [];
const measureLine = new THREE.Line(new THREE.BufferGeometry(), new THREE.LineBasicMaterial({ color: 0xffd166, depthTest: false }));
measureLine.renderOrder = 999;
scene.add(measureLine);
let measuring = false;
function clearMeasure() {
measureMarkers.forEach(m => { scene.remove(m); m.geometry.dispose(); m.material.dispose(); });
measureMarkers.length = 0;
measurePts.length = 0;
measureLine.geometry.dispose();
measureLine.geometry = new THREE.BufferGeometry();
measureLabel.style.display = 'none';
}
function pickPoint(e) {
const rect = canvas.getBoundingClientRect();
const ndc = new THREE.Vector2(((e.clientX - rect.left) / rect.width) * 2 - 1, -((e.clientY - rect.top) / rect.height) * 2 + 1);
camera.updateMatrixWorld();
raycaster.setFromCamera(ndc, camera);
const targets = [];
Object.values(groups).forEach(list => list.forEach(m => { if (m.visible) targets.push(m); }));
const hit = raycaster.intersectObjects(targets, true)[0];
if (!hit) return;
if (measurePts.length === 2) clearMeasure();
measurePts.push(hit.point.clone());
const marker = new THREE.Mesh(
new THREE.SphereGeometry(Math.max(0.05, radius * 0.005), 12, 12),
new THREE.MeshBasicMaterial({ color: 0xffd166, depthTest: false })
);
marker.position.copy(hit.point);
marker.renderOrder = 999;
scene.add(marker);
measureMarkers.push(marker);
if (measurePts.length === 1) {
measureLabel.textContent = '첫 점 선택됨 · 두 번째 점을 클릭';
measureLabel.style.display = 'block';
return;
}
const [a, b] = measurePts;
measureLine.geometry = new THREE.BufferGeometry().setFromPoints([a, b]);
measureLabel.textContent = `거리 ${a.distanceTo(b).toFixed(3)} m (ΔX ${(b.x - a.x).toFixed(3)} · ΔY ${(b.y - a.y).toFixed(3)} · ΔZ ${(b.z - a.z).toFixed(3)})`;
measureLabel.style.display = 'block';
}
const btnMeasure = document.getElementById('btnMeasure');
btnMeasure.addEventListener('click', () => {
measuring = !measuring;
btnMeasure.textContent = '거리 측정: ' + (measuring ? '켜짐' : '끔');
btnMeasure.classList.toggle('on', measuring);
canvas.style.cursor = measuring ? 'crosshair' : 'default';
});
document.getElementById('btnClear').addEventListener('click', clearMeasure);
let mode = null, lastX = 0, lastY = 0, downX = 0, downY = 0, pinchDist = 0;
const pointers = new Map();
const pinchSpan = () => {
const [a, b] = [...pointers.values()];
return Math.hypot(a.x - b.x, a.y - b.y);
};
canvas.addEventListener('contextmenu', e => e.preventDefault());
canvas.addEventListener('pointerdown', e => {
canvas.setPointerCapture(e.pointerId);
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pointers.size === 1) {
mode = e.button === 2 ? 'pan' : 'rotate';
lastX = e.clientX; lastY = e.clientY;
downX = e.clientX; downY = e.clientY;
} else if (pointers.size === 2) {
mode = 'pinch';
pinchDist = pinchSpan();
zoomPivot = target.clone();
}
});
canvas.addEventListener('pointermove', e => {
if (!pointers.has(e.pointerId)) return;
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (mode === 'pinch') {
const d = pinchSpan();
if (d > 0 && pinchDist > 0) {
zoomTarget = Math.min(1e6, Math.max(0.05, (zoomTarget ?? radius) * (pinchDist / d)));
}
pinchDist = d;
return;
}
if (!mode) return;
const dx = e.clientX - lastX, dy = e.clientY - lastY;
lastX = e.clientX; lastY = e.clientY;
if (mode === 'rotate') {
theta -= dx * 0.005;
phi = Math.min(Math.PI - 0.01, Math.max(0.01, phi - dy * 0.005));
} else {
const right = new THREE.Vector3(), up = new THREE.Vector3();
camera.matrixWorld.extractBasis(right, up, new THREE.Vector3());
const s = radius * 0.0015;
target.addScaledVector(right, -dx * s);
target.addScaledVector(up, dy * s);
}
updateCamera();
});
const endPointer = e => {
if (!pointers.has(e.pointerId)) return;
if (measuring && mode === 'rotate' && pointers.size === 1 && Math.hypot(e.clientX - downX, e.clientY - downY) < 6) pickPoint(e);
pointers.delete(e.pointerId);
if (pointers.size === 0) {
mode = null;
} else if (pointers.size === 1) {
mode = 'rotate';
const p = [...pointers.values()][0];
lastX = p.x; lastY = p.y;
}
};
canvas.addEventListener('pointerup', endPointer);
canvas.addEventListener('pointercancel', endPointer);
const raycaster = new THREE.Raycaster();
const ground = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0);
canvas.addEventListener('wheel', e => {
e.preventDefault();
const dy = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY;
zoomTarget = Math.min(1e6, Math.max(0.05, (zoomTarget ?? radius) * Math.exp(dy * 0.0015)));
const rect = canvas.getBoundingClientRect();
const ndc = new THREE.Vector2(((e.clientX - rect.left) / rect.width) * 2 - 1, -((e.clientY - rect.top) / rect.height) * 2 + 1);
camera.updateMatrixWorld();
raycaster.setFromCamera(ndc, camera);
const hit = new THREE.Vector3();
zoomPivot = raycaster.ray.intersectPlane(ground, hit) ? hit : target.clone();
}, { passive: false });
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight, false);
});
let zoomTarget = null, zoomPivot = null;
(function animate() {
requestAnimationFrame(animate);
if (zoomTarget !== null) {
const prev = radius;
let next = prev + (zoomTarget - prev) * 0.18;
if (Math.abs(next - zoomTarget) < zoomTarget * 0.0005) { next = zoomTarget; zoomTarget = null; }
target.lerp(zoomPivot, 1 - next / prev);
radius = next;
updateCamera();
}
renderer.render(scene, camera);
})();
</script>
</body>
</html>