Files
CMPlanning/viewer/index.html
T
Claude 6c7e555c25 feat: 2 m placeholder pattern zones on lining, station/pattern readout in viewer (Task 5)
No pattern-to-station data exists in the drawings. Per user decision
(2026-10-21) the modelled range 4+745..5+480 is split into 2 m segments with
arbitrary pattern names, marked placeholder in CSV, meta and viewer, to be
replaced later. Ring step reduced to 1 m so that the 2 m segments align.

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

138 lines
6.9 KiB
HTML

<!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,.92);--line:#d7d7d2;--accent:#2f6f6a}
@media (prefers-color-scheme: dark){:root:not([data-theme="light"]){--bg:#15171a;--fg:#e9e9e6;--panel:rgba(31,34,38,.92);--line:#3a3f46;--accent:#6fb7b0}}
:root[data-theme="dark"]{--bg:#15171a;--fg:#e9e9e6;--panel:rgba(31,34,38,.92);--line:#3a3f46;--accent:#6fb7b0}
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);background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:12px 14px;font-size:14px;box-sizing:border-box}
#panel h1{font-size:16px;margin:0 0 8px;color:var(--accent)}
#toggles label{display:flex;gap:6px;align-items:center;margin:4px 0;font-size:13px}
#info{white-space:pre-wrap;font-size:12px;line-height:1.5;margin-top:10px;border-top:1px solid var(--line);padding-top:8px;max-height:38vh;overflow:auto}
#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 id="toggles"></div>
<div id="info">로딩 중입니다. 모델을 클릭하면 요소 정보가 표시됩니다.</div>
</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_skeleton.glb';
const META = '../model/tunnel_skeleton_meta.json';
const INFO = {
'lining_outer_R7282': '라이닝 외면 R 7.282 m\n출처: C0060303-001 CS-CONC-MAJR (R7282 호), 라이닝 두께 300 mm',
'lining_inner_R6982': '라이닝 내면 R 6.982 m (R1)\n출처: C0060303-001 상단 변수표 R1 = 6,982 mm',
'excavation_R7402': '굴착선 R 7.402 m (반투명)\n출처: C0060303-001 CS-EXCV 호',
'road_surface_계획고': '노면 (계획고)\n출처: C0060302-002 CR-TABL-TEX2 B열. 계획고 = 노면, F = 0.000 기준',
'ground_GL_지반고': '지반고 (GL, 개략 표시)\n출처: C0060302-002 CR-TABL-TEX2 A열. 중심선 양측 ±20 m 띠로 표시'
};
const status = document.getElementById('status');
const info = document.getElementById('info');
const togglesEl = document.getElementById('toggles');
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);
document.getElementById('app').appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(dark ? 0x15171a : 0xf6f6f4);
const camera = new THREE.PerspectiveCamera(45, innerWidth / innerHeight, 0.1, 20000);
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.8);
sun.position.set(1, 2, 1);
scene.add(sun);
const meshes = [];
const loader = new GLTFLoader();
loader.load(MODEL, (gltf) => {
const root = gltf.scene;
scene.add(root);
root.traverse((o) => {
if (o.isMesh) {
const name = o.name || (o.parent && o.parent.name) || 'unnamed';
o.userData.key = name;
o.material.side = THREE.DoubleSide;
if (/excavation|ground/.test(name)) { o.material.transparent = true; o.material.opacity = 0.55; o.material.depthWrite = false; }
meshes.push(o);
}
});
const box = new THREE.Box3().setFromObject(root);
const c = box.getCenter(new THREE.Vector3());
const s = box.getSize(new THREE.Vector3());
const d = Math.max(s.x, s.y, s.z);
controls.target.copy(c);
camera.position.set(c.x + d * 0.6, c.y + d * 0.5, c.z + d * 0.9);
camera.near = d / 1000; camera.far = d * 20; camera.updateProjectionMatrix();
const names = [...new Set(meshes.map((m) => m.userData.key))];
for (const n of names) {
const lab = document.createElement('label');
const cb = document.createElement('input');
cb.type = 'checkbox'; cb.checked = true;
cb.addEventListener('change', () => meshes.filter((m) => m.userData.key === n).forEach((m) => (m.visible = cb.checked)));
lab.append(cb, document.createTextNode(n));
togglesEl.appendChild(lab);
}
status.textContent = '로드 완료 · 요소 ' + names.length + '종';
}, undefined, () => {
status.textContent = '로드 실패: 파일을 HTTP 서버로 여세요 (README 참조)';
});
let M = null;
fetch(META).then((r) => r.json()).then((m) => {
M = m;
info.textContent = '범위: ' + m.scope + ' (단위 ' + m.units + ')\n패턴: ' + m.pattern_status + '\n근사 항목:\n- ' + m.approximations.join('\n- ') + '\n미완:\n- ' + m.open.join('\n- ');
}).catch(() => {});
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) {
const key = hit.object.userData.key;
let extra = '';
if (M && key.startsWith('lining') && hit.faceIndex != null) {
const n = M.ring_points;
const ring = Math.floor(hit.faceIndex / (2 * (n - 1)));
const sta = M.first_station + ring * M.ring_step_m;
const z = M.patterns.find((z) => sta >= z.from && sta < z.to);
const km = Math.floor(sta / 1000), m = Math.round(sta % 1000);
extra = '\n측점: ' + km + '+' + String(m).padStart(3, '0') + '\n패턴: ' + (z ? z.pattern : '-') + ' (임의 배정, 실제 구간 아님)';
}
info.textContent = (INFO[key] || key) + extra + '\n교차점: x=' + hit.point.x.toFixed(2) + ' y=' + hit.point.y.toFixed(2) + ' z=' + hit.point.z.toFixed(2) + ' (m, 모델 좌표)';
}
});
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
renderer.setAnimationLoop(() => { controls.update(); renderer.render(scene, camera); });
</script>
</body>
</html>