feat: solid model of main line 4+745~5+480 (STEP + GLB), per-pattern selection, viewer rework (Task 8)
- scripts/build_solids.py: B-rep solids from standard section 03-001 (rev2, approved 2026-10-21) - lining per 지보패턴 run (independently selectable), excavation band per run - road surface (계획고, 280 mm) with per-station superelevation; base/invert ASSUMED - side drains (box minus pipe), H/I boxes = 공동구 (assumed); 시설한계 50 mm as reference wires only - staged run (4 GB RAM / 180 s per call); STEP and GLB from the same B-rep - model/tunnel_solids.glb, model/tunnel_solids_meta.json, data/verify_solids.json - viewer/index.html: solids, pattern and component toggles, click readout, no development text - STEP (84.6 MB) and model/parts/ are git-ignored; the STEP is generated locally by the script Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5Cun3RHQKna9qxE6ybZ4A
This commit is contained in:
1 parent
ab0a6bc057
commit
ff2c057aa7
6 files changed
+2653
-62
No files matched your search
+118
-62
@@ -3,17 +3,19 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>대산2터널 본선 3D 골격</title>
|
||||
<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}
|
||||
:root{--bg:#f6f6f4;--fg:#1d1d1b;--panel:rgba(255,255,255,.93);--line:#d7d7d2;--accent:#2f6f6a}
|
||||
@media (prefers-color-scheme: dark){:root:not([data-theme="light"]){--bg:#15171a;--fg:#e9e9e6;--panel:rgba(31,34,38,.93);--line:#3a3f46;--accent:#6fb7b0}}
|
||||
:root[data-theme="dark"]{--bg:#15171a;--fg:#e9e9e6;--panel:rgba(31,34,38,.93);--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}
|
||||
#panel{position:absolute;left:12px;top:12px;width:290px;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 6px;color:var(--accent)}
|
||||
#panel h2{font-size:12px;margin:12px 0 4px;color:var(--accent)}
|
||||
#panel label{display:flex;gap:6px;align-items:center;margin:3px 0;cursor:pointer}
|
||||
#pats{display:grid;grid-template-columns:1fr 1fr;gap:0 10px}
|
||||
#info{white-space:pre-wrap;font-size:12px;line-height:1.55;margin-top:10px;border-top:1px solid var(--line);padding-top:8px;min-height:3.2em}
|
||||
#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">
|
||||
@@ -23,28 +25,30 @@ html,body{margin:0;height:100%;background:var(--bg);color:var(--fg);font-family:
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<div id="panel">
|
||||
<h1>대산2터널 본선 3D 골격</h1>
|
||||
<div id="toggles"></div>
|
||||
<div id="info">로딩 중입니다. 모델을 클릭하면 요소 정보가 표시됩니다.</div>
|
||||
<h1>대산2터널 본선 3D 모델</h1>
|
||||
<div style="color:var(--fg);opacity:.75">측점 4+745 ~ 5+480 · 단위 m</div>
|
||||
<div style="display:flex;gap:6px;margin:6px 0 2px"><button id="viewAll" type="button">전체 보기</button><button id="viewStart" type="button">입구 (4+745) 보기</button></div>
|
||||
<h2>지보패턴 (라이닝·굴착 여유대)</h2>
|
||||
<div id="pats"></div>
|
||||
<h2>굴착 여유대</h2>
|
||||
<label><input type="checkbox" id="showExc" checked> 굴착 여유대 표시</label>
|
||||
<h2>구성요소</h2>
|
||||
<div id="comps"></div>
|
||||
<div id="info">구성요소를 클릭하면 정보가 표시됩니다.</div>
|
||||
</div>
|
||||
<div id="status">로딩 중…</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 MODEL = '../model/tunnel_solids.glb';
|
||||
const META = '../model/tunnel_solids_meta.json';
|
||||
const status = document.getElementById('status');
|
||||
const info = document.getElementById('info');
|
||||
const togglesEl = document.getElementById('toggles');
|
||||
const patsEl = document.getElementById('pats');
|
||||
const compsEl = document.getElementById('comps');
|
||||
const showExc = document.getElementById('showExc');
|
||||
|
||||
const dark = matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
@@ -57,51 +61,110 @@ const camera = new THREE.PerspectiveCamera(45, innerWidth / innerHeight, 0.1, 20
|
||||
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);
|
||||
const sun = new THREE.DirectionalLight(0xffffff, 0.9);
|
||||
sun.position.set(1, 2, 1);
|
||||
scene.add(sun);
|
||||
const fill = new THREE.DirectionalLight(0xffffff, 0.7);
|
||||
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 GROUP_NOTE = {
|
||||
'기층·인버트(가정)': '기층·인버트: 도면에 표현되지 않아 가정하여 생성한 형상',
|
||||
'공동구(가정)': '공동구 H/I 박스: 공동구로 가정하여 생성한 형상',
|
||||
'배수(측구)': '측구 및 배수관 (단면 도면 기준)',
|
||||
'노면(계획고)': '노면 (계획고 기준, 측점별 편경사 적용)',
|
||||
'시설한계(참조선)': '시설한계 50 mm 기준선 (참조용 선, 솔리드 아님)',
|
||||
'굴착면': '굴착 여유대 (R 7.402 m 기준)',
|
||||
'라이닝': '라이닝 (지보패턴별)'
|
||||
};
|
||||
const meshes = [];
|
||||
const loader = new GLTFLoader();
|
||||
loader.load(MODEL, (gltf) => {
|
||||
let META_BY_ID = {};
|
||||
let patternOn = {};
|
||||
let groupOn = {};
|
||||
|
||||
function applyVisibility() {
|
||||
for (const m of meshes) {
|
||||
const d = m.userData;
|
||||
let v = true;
|
||||
if (d.pattern) v = !!patternOn[d.pattern];
|
||||
if (d.group === '굴착면') v = v && showExc.checked;
|
||||
if (d.group in groupOn) v = v && groupOn[d.group];
|
||||
m.visible = v;
|
||||
}
|
||||
}
|
||||
showExc.addEventListener('change', applyVisibility);
|
||||
|
||||
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;
|
||||
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);
|
||||
}
|
||||
if (!o.isMesh) return;
|
||||
const c = META_BY_ID[o.name];
|
||||
o.userData = { id: o.name, group: c ? c.group : '', pattern: c && c.pattern ? c.pattern : null,
|
||||
from: c ? c.from_sta : null, to: c ? c.to_sta : null, label: c ? c.label : o.name };
|
||||
o.geometry.computeVertexNormals(); // GLB normals from the exporter are not reliable for shading
|
||||
const rgb = c && c.color ? 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.75, metalness: 0.0, side: THREE.DoubleSide });
|
||||
if (o.userData.group === '굴착면') { 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 viewFrom = (center, dist) => {
|
||||
const dir = new THREE.Vector3(0.6, 0.45, 0.9).normalize();
|
||||
controls.target.copy(center);
|
||||
camera.position.copy(center).addScaledVector(dir, dist);
|
||||
camera.near = Math.max(dist / 2000, 0.05); camera.far = dist * 20; camera.updateProjectionMatrix();
|
||||
};
|
||||
const overview = () => viewFrom(c, d * 0.75);
|
||||
const focusStation = (sta) => {
|
||||
const bb = new THREE.Box3(); let any = false;
|
||||
for (const m of meshes) {
|
||||
if (m.userData.group === '라이닝' && m.userData.from <= sta && sta < m.userData.to) { bb.union(new THREE.Box3().setFromObject(m)); any = true; }
|
||||
}
|
||||
if (!any) return;
|
||||
const cc = bb.getCenter(new THREE.Vector3()), ss = bb.getSize(new THREE.Vector3());
|
||||
viewFrom(cc, Math.max(ss.x, ss.y, ss.z) * 2.2 + 12);
|
||||
};
|
||||
document.getElementById('viewAll').addEventListener('click', overview);
|
||||
document.getElementById('viewStart').addEventListener('click', () => focusStation(4745));
|
||||
overview();
|
||||
|
||||
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', () => meshes.filter((m) => m.userData.key === n).forEach((m) => (m.visible = cb.checked)));
|
||||
lab.append(cb, document.createTextNode(n));
|
||||
togglesEl.appendChild(lab);
|
||||
cb.addEventListener('change', () => { patternOn[p] = cb.checked; applyVisibility(); });
|
||||
lab.append(cb, document.createTextNode(p));
|
||||
patsEl.appendChild(lab);
|
||||
}
|
||||
status.textContent = '로드 완료 · 요소 ' + names.length + '종';
|
||||
}, undefined, () => {
|
||||
status.textContent = '로드 실패: 파일을 HTTP 서버로 여세요 (README 참조)';
|
||||
const groups = [['노면(계획고)', '노면(계획고)'], ['기층·인버트(가정)', '기층·인버트(가정)'], ['배수(측구)', '배수(측구)'], ['공동구(가정)', '공동구(가정)'], ['시설한계(참조선)', '시설한계 50 mm 참조선']];
|
||||
for (const [key, text] of groups) {
|
||||
groupOn[key] = key !== '시설한계(참조선)' ? true : 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));
|
||||
compsEl.appendChild(lab);
|
||||
}
|
||||
applyVisibility();
|
||||
const nLin = meta.components.filter((x) => x.group === '라이닝').length;
|
||||
status.textContent = '로드 완료 · 지보패턴 구간 ' + nLin + '개';
|
||||
}).catch((e) => {
|
||||
status.textContent = '로드 실패: HTTP 서버에서 여세요 (' + e.message + ')';
|
||||
});
|
||||
|
||||
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;
|
||||
@@ -112,19 +175,12 @@ renderer.domElement.addEventListener('pointerup', (e) => {
|
||||
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 : '-') + ' (엑셀 지보패턴표, 당진방향)' + (M.lining_reference ? '\n라이닝 상세: ' + M.lining_reference.summary : '');
|
||||
}
|
||||
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, 모델 좌표)';
|
||||
}
|
||||
if (!hit) return;
|
||||
const u = hit.object.userData;
|
||||
const head = u.group + (u.pattern ? ' · 지보패턴 ' + u.pattern : '');
|
||||
const sta = u.from != null ? '\n측점 ' + fmtSta(u.from) + ' ~ ' + fmtSta(u.to) : '';
|
||||
const note = GROUP_NOTE[u.group] ? '\n' + GROUP_NOTE[u.group] : '';
|
||||
info.textContent = head + sta + note;
|
||||
});
|
||||
|
||||
addEventListener('resize', () => {
|
||||
|
||||
Reference in new issue
Block a user