Files
CMPlanning/viewer/index.html
T
조선두andClaude Haiku 5.5 b1bb768f09 feat: viewer part picking with highlight and tooltip, station window slider, zoom fit, longitudinal start view (Task 8)
- Z-up model rotated for the Y-up viewer; start view is the longitudinal (종단) view, chainage left to right
- 구간 보기: two-handle station slider (start/end) with 구간 맞춤 / 구간 전체
- click a part: yellow highlight + tooltip (group, pattern, station range, length, volume, part ID)
- 선택 맞춤 (zoom fit), 선택 해제 (Esc), 선택 숨기기, 선택만 보기, 모두 보기, 와이어프레임
- full-length reference lines shown only for the full range

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5Cun3RHQKna9qxE6ybZ4A
2026-10-21 22:34:11 +00:00

289 lines
15 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>대산2터널 본선 3D 모델</title>
<style>
:root{--bg:#f6f6f4;--fg:#1d1d1b;--panel:rgba(255,255,255,.95);--line:#d7d7d2;--accent:#2f6f6a;--muted:#6b6b66}
@media (prefers-color-scheme: dark){:root:not([data-theme="light"]){--bg:#15171a;--fg:#e9e9e6;--panel:rgba(31,34,38,.95);--line:#3a3f46;--accent:#6fb7b0;--muted:#9aa0a6}}
:root[data-theme="dark"]{--bg:#15171a;--fg:#e9e9e6;--panel:rgba(31,34,38,.95);--line:#3a3f46;--accent:#6fb7b0;--muted:#9aa0a6}
html,body{margin:0;height:100%;background:var(--bg);color:var(--fg);font-family:system-ui,"Noto Sans KR",sans-serif;overflow:hidden}
#app{position:fixed;inset:0}
#panel{position:absolute;left:12px;top:12px;width:300px;max-width:calc(100vw - 24px);max-height:calc(100vh - 24px);overflow:auto;background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:12px 14px;font-size:13px;box-sizing:border-box}
#panel h1{font-size:15px;margin:0 0 4px;color:var(--accent)}
#panel h2{font-size:12px;margin:12px 0 5px;color:var(--accent)}
#panel label{display:flex;gap:6px;align-items:center;margin:3px 0;cursor:pointer}
button{font:inherit;font-size:12px;padding:4px 8px;border:1px solid var(--line);border-radius:6px;background:transparent;color:var(--fg);cursor:pointer}
button:hover{border-color:var(--accent)}
.row{display:flex;gap:6px;flex-wrap:wrap;margin:4px 0}
#pats{display:grid;grid-template-columns:1fr 1fr;gap:0 10px}
.dual{position:relative;height:26px;margin:4px 0}
.dual input[type=range]{position:absolute;left:0;top:4px;width:100%;margin:0;pointer-events:none;-webkit-appearance:none;appearance:none;background:transparent;height:18px}
.dual input[type=range]::-webkit-slider-thumb{pointer-events:auto;-webkit-appearance:none;width:14px;height:14px;border-radius:50%;background:var(--accent);cursor:pointer}
.dual input[type=range]::-moz-range-thumb{pointer-events:auto;width:14px;height:14px;border-radius:50%;background:var(--accent);cursor:pointer;border:0}
.dual .track{position:absolute;left:7px;right:7px;top:11px;height:4px;background:var(--line);border-radius:2px}
#staText{font-weight:600;font-size:13px;margin-top:2px}
#info{font-size:12px;color:var(--muted);margin-top:10px;border-top:1px solid var(--line);padding-top:8px;min-height:2.6em}
#tip{position:fixed;display:none;max-width:320px;background:var(--panel);border:1px solid var(--accent);border-radius:8px;padding:8px 10px;font-size:12px;line-height:1.55;white-space:pre-wrap;pointer-events:none;z-index:5;box-shadow:0 2px 10px rgba(0,0,0,.2)}
#tip b{color:var(--accent)}
#status{position:absolute;right:12px;bottom:12px;font-size:12px;background:var(--panel);padding:4px 8px;border-radius:6px;border:1px solid var(--line)}
</style>
<script type="importmap">
{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"}}
</script>
</head>
<body>
<div id="app"></div>
<div id="panel">
<h1>대산2터널 본선 3D 모델</h1>
<div style="color:var(--muted)">측점 4+745 ~ 5+480 · 단위 m</div>
<div class="row"><button id="btnStart" type="button">종단 시점</button><button id="btnAll" type="button">전체 맞춤</button></div>
<h2>구간 보기 (시작 · 끝 측점)</h2>
<div class="dual">
<div class="track"></div>
<input type="range" id="s0" min="4745" max="5480" step="5" value="4745">
<input type="range" id="s1" min="4745" max="5480" step="5" value="5480">
</div>
<div id="staText">4+745 ~ 5+480</div>
<div class="row"><button id="btnWin" type="button">구간 맞춤</button><button id="btnWinReset" type="button">구간 전체</button></div>
<h2>지보패턴 (라이닝·굴착 여유대)</h2>
<div id="pats"></div>
<h2>굴착 여유대</h2>
<label><input type="checkbox" id="showExc" checked> 굴착 여유대 표시</label>
<h2>구성요소</h2>
<div id="comps"></div>
<h2>선택 부품</h2>
<div class="row">
<button id="btnFitSel" type="button">선택 맞춤 (zoom fit)</button>
<button id="btnDesel" type="button">선택 해제 (Esc)</button>
</div>
<div class="row">
<button id="btnHideSel" type="button">선택 숨기기</button>
<button id="btnIsoSel" type="button">선택만 보기</button>
<button id="btnShowAll" type="button">모두 보기</button>
</div>
<label><input type="checkbox" id="wire"> 와이어프레임</label>
<div id="info">부품을 클릭하면 강조되고 속성이 표시됩니다.</div>
</div>
<div id="tip"></div>
<div id="status">불러오는 중…</div>
<script type="module">
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const MODEL = '../model/tunnel_solids.glb';
const META = '../model/tunnel_solids_meta.json';
const S_MIN = 4745, S_MAX = 5480;
const $ = (id) => document.getElementById(id);
const statusEl = $('status'), infoEl = $('info'), tipEl = $('tip');
const dark = matchMedia('(prefers-color-scheme: dark)').matches;
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
$('app').appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(dark ? 0x15171a : 0xf6f6f4);
const camera = new THREE.PerspectiveCamera(30, innerWidth / innerHeight, 0.5, 200000);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
scene.add(new THREE.HemisphereLight(0xffffff, 0x556070, 1.0));
const sun = new THREE.DirectionalLight(0xffffff, 0.9); sun.position.set(1, 2, 1); scene.add(sun);
const fill = new THREE.DirectionalLight(0xffffff, 0.6); fill.position.set(-1, -0.5, -1.2); scene.add(fill);
const fmtSta = (m) => Math.floor(m / 1000) + '+' + String(Math.round(m % 1000)).padStart(3, '0');
const NOTE = {
'노면(계획고)': '노면 포장 280 mm · 측점별 편경사 적용 (도면 계획고 기준)',
'기층·인버트(가정)': '가정 형상 · 도면에 표현되지 않아 가정하여 생성',
'배수(측구)': '측구 300×836 mm 및 관 r150 (단면 도면 기준)',
'공동구(가정)': '가정 형상 · H/I 박스를 공동구로 가정',
'시설한계(참조선)': '시설한계 50 mm 기준선 · 참조용 선 (솔리드 아님)',
'굴착면': '굴착 여유대 R 7.402 m 기준 · 노면 위',
'라이닝': '라이닝 R 6.982 / 7.282 m (지보패턴별 구간)'
};
const meshes = [];
let META_BY_ID = {};
const patternOn = {}, groupOn = {};
let winLo = S_MIN, winHi = S_MAX;
let selected = null, isolate = false;
const hiddenSel = new Set();
let world = null;
function visibleOf(m) {
const u = m.userData;
if (hiddenSel.has(m)) return false;
if (isolate && selected && m !== selected) return false;
if (u.from != null && (u.to < winLo || u.from > winHi)) return false;
if (u.group === '시설한계(참조선)' && (winLo > S_MIN || winHi < S_MAX)) return false; // full-length reference lines: full range only
if (u.pattern) { if (!patternOn[u.pattern]) return false; }
if (u.group === '굴착면' && !$('showExc').checked) return false;
if (u.group in groupOn && !groupOn[u.group]) return false;
return true;
}
function applyVisibility() { for (const m of meshes) m.visible = visibleOf(m); }
function frame(center, dist, dir) {
const d = (dir || new THREE.Vector3(0.6, 0.45, 0.9)).clone().normalize();
controls.target.copy(center);
camera.position.copy(center).addScaledVector(d, dist);
camera.near = Math.max(dist / 3000, 0.05); camera.far = dist * 40;
camera.updateProjectionMatrix();
controls.update();
}
function boxOf(list) {
const b = new THREE.Box3(); let any = false;
for (const m of list) { b.union(new THREE.Box3().setFromObject(m)); any = true; }
return any ? b : null;
}
function frameBox(b, pad) {
const c = b.getCenter(new THREE.Vector3()), s = b.getSize(new THREE.Vector3());
frame(c, Math.max(s.x, s.y, s.z) * (pad || 0.9) + 20);
}
function frameAll() { applyVisibility(); const b = boxOf(meshes.filter((m) => m.visible)); if (b) frameBox(b, 0.75); }
function frameWindow() { applyVisibility(); const b = boxOf(meshes.filter((m) => m.visible)); if (b) frameBox(b, 0.9); }
// longitudinal (종단) start view: chainage runs left -> right, height up
function startView() {
const first = meshes.find((m) => m.userData.group === '라이닝' && m.userData.from === S_MIN);
const last = meshes.find((m) => m.userData.group === '라이닝' && m.userData.to === S_MAX);
if (!first || !last) return frameAll();
const c0 = new THREE.Box3().setFromObject(first).getCenter(new THREE.Vector3());
const c1 = new THREE.Box3().setFromObject(last).getCenter(new THREE.Vector3());
const u = new THREE.Vector3(c1.x - c0.x, 0, c1.z - c0.z);
const L = u.length(); u.normalize();
const mid = c0.clone().add(c1).multiplyScalar(0.5);
// camera looks along lateral w; chainage (u) must point to screen-right: right = w × up = u
const w = new THREE.Vector3(u.z, 0, -u.x);
controls.target.copy(mid);
camera.up.set(0, 1, 0);
camera.position.copy(mid).addScaledVector(w, -L * 2.1).add(new THREE.Vector3(0, L * 0.02, 0));
camera.near = 1; camera.far = L * 40; camera.updateProjectionMatrix();
controls.update();
}
function setSelected(m) {
if (selected && selected.material) selected.material.emissive.setHex(0x000000);
selected = m;
if (m) m.material.emissive.setHex(0xb8a000); // highlight: strong yellow emissive
applyVisibility();
if (!m) { infoEl.textContent = '부품을 클릭하면 강조되고 속성이 표시됩니다.'; tipEl.style.display = 'none'; return; }
infoEl.textContent = '선택: ' + m.userData.group + (m.userData.pattern ? ' · 지보패턴 ' + m.userData.pattern : '');
}
function tipText(u) {
const lines = ['<b>' + u.group + '</b>' + (u.pattern ? ' · 지보패턴 ' + u.pattern : '')];
if (u.from != null) lines.push('측점 ' + fmtSta(u.from) + ' ~ ' + fmtSta(u.to) + ' (' + Math.round(u.to - u.from) + ' m)');
if (u.volume != null) lines.push('부피 ' + Number(u.volume).toFixed(2) + ' m³');
if (NOTE[u.group]) lines.push(NOTE[u.group]);
lines.push('부품 ID ' + u.id);
return lines.join('\n');
}
Promise.all([
fetch(META).then((r) => r.json()),
new Promise((res, rej) => new GLTFLoader().load(MODEL, res, undefined, rej))
]).then(([meta, gltf]) => {
for (const c of meta.components) META_BY_ID[c.id] = c;
world = new THREE.Group();
world.rotation.x = -Math.PI / 2; // model is Z-up (height on z); view is Y-up
world.add(gltf.scene);
scene.add(world);
gltf.scene.traverse((o) => {
if (!o.isMesh) return;
const c = META_BY_ID[o.name] || {};
o.userData = { id: o.name, group: c.group || '', pattern: c.pattern || null,
from: c.from_sta ?? null, to: c.to_sta ?? null, volume: c.volume_m3 ?? null };
o.geometry = o.geometry.toNonIndexed();
o.geometry.computeVertexNormals();
const rgb = c.color || [200, 200, 200];
o.material = new THREE.MeshStandardMaterial({ color: new THREE.Color(rgb[0] / 255, rgb[1] / 255, rgb[2] / 255),
roughness: 0.8, metalness: 0.0, side: THREE.DoubleSide, flatShading: true });
meshes.push(o);
});
world.updateMatrixWorld(true);
const pats = [...new Set(meta.components.filter((x) => x.pattern).map((x) => x.pattern))];
for (const p of pats) {
patternOn[p] = true;
const lab = document.createElement('label'); const cb = document.createElement('input');
cb.type = 'checkbox'; cb.checked = true;
cb.addEventListener('change', () => { patternOn[p] = cb.checked; applyVisibility(); });
lab.append(cb, document.createTextNode(p)); $('pats').appendChild(lab);
}
const groups = [['노면(계획고)', '노면(계획고)'], ['기층·인버트(가정)', '기층·인버트 (가정)'], ['배수(측구)', '배수(측구)'],
['공동구(가정)', '공동구 (가정)'], ['시설한계(참조선)', '시설한계 50 mm 참조선']];
for (const [key, text] of groups) {
groupOn[key] = true;
const lab = document.createElement('label'); const cb = document.createElement('input');
cb.type = 'checkbox'; cb.checked = true;
cb.addEventListener('change', () => { groupOn[key] = cb.checked; applyVisibility(); });
lab.append(cb, document.createTextNode(text)); $('comps').appendChild(lab);
}
startView();
statusEl.textContent = '로드 완료 · 부품 ' + meshes.length + '개';
}).catch((e) => { statusEl.textContent = '로드 실패: HTTP 서버에서 여세요 (' + e.message + ')'; });
// ---- station window (two handles) ----
function syncWin(from) {
let a = Number($('s0').value), b = Number($('s1').value);
if (a > b) { if (from === 0) { b = a; $('s1').value = b; } else { a = b; $('s0').value = a; } }
winLo = a; winHi = b;
$('staText').textContent = fmtSta(a) + ' ~ ' + fmtSta(b);
applyVisibility();
}
$('s0').addEventListener('input', () => syncWin(0));
$('s1').addEventListener('input', () => syncWin(1));
$('btnWin').addEventListener('click', () => frameWindow());
$('btnWinReset').addEventListener('click', () => { $('s0').value = S_MIN; $('s1').value = S_MAX; syncWin(0); frameAll(); });
$('btnStart').addEventListener('click', () => startView());
$('btnAll').addEventListener('click', () => frameAll());
$('showExc').addEventListener('change', applyVisibility);
// ---- selection tools ----
$('btnFitSel').addEventListener('click', () => { if (selected) frameBox(new THREE.Box3().setFromObject(selected), 0.9); });
$('btnDesel').addEventListener('click', () => setSelected(null));
$('btnHideSel').addEventListener('click', () => { if (selected) { hiddenSel.add(selected); setSelected(null); } });
$('btnIsoSel').addEventListener('click', () => { if (selected) { isolate = true; applyVisibility(); } });
$('btnShowAll').addEventListener('click', () => { hiddenSel.clear(); isolate = false; applyVisibility(); });
$('wire').addEventListener('change', () => { for (const m of meshes) m.material.wireframe = $('wire').checked; });
addEventListener('keydown', (e) => { if (e.key === 'Escape') setSelected(null); });
// ---- pick ----
const ray = new THREE.Raycaster(); const ptr = new THREE.Vector2();
let downAt = null;
renderer.domElement.addEventListener('pointerdown', (e) => { downAt = [e.clientX, e.clientY]; });
renderer.domElement.addEventListener('pointerup', (e) => {
if (!downAt || Math.hypot(e.clientX - downAt[0], e.clientY - downAt[1]) > 4) return;
const r = renderer.domElement.getBoundingClientRect();
ptr.set(((e.clientX - r.left) / r.width) * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1);
ray.setFromCamera(ptr, camera);
const hit = ray.intersectObjects(meshes.filter((m) => m.visible), false)[0];
if (!hit) { setSelected(null); return; }
setSelected(hit.object);
tipEl.innerHTML = tipText(hit.object.userData).replace(/\n/g, '<br>');
tipEl.style.display = 'block';
tipEl.style.left = Math.min(e.clientX + 14, innerWidth - 340) + 'px';
tipEl.style.top = Math.min(e.clientY + 14, innerHeight - 160) + 'px';
});
// keep the model centred in the area right of the control panel (wide screens only)
function shiftForPanel() {
const off = innerWidth > 760 ? -170 : 0;
camera.setViewOffset(innerWidth, innerHeight, off, 0, innerWidth, innerHeight);
camera.updateProjectionMatrix();
}
shiftForPanel();
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight; shiftForPanel();
renderer.setSize(innerWidth, innerHeight);
});
renderer.setAnimationLoop(() => { controls.update(); renderer.render(scene, camera); });
</script>
</body>
</html>