feat: axis-and-section skeleton GLB and three.js viewer (Task 7-8, skeleton LOD)

build_skeleton.py sweeps the confirmed standard section (R6982 inner, 300 mm
lining, R7402 excavation, crown 6.615 m above road) along the centreline for
4+745..5+480, road = 계획고, ground = 지반고 band. Walls and invert are
approximations and recorded in meta. Verified: inner radius identity, crown
height, chord length 799.9 m for 4+700..5+500. Viewer uses three.js 0.160.0.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5Cun3RHQKna9qxE6ybZ4A
This commit is contained in:
Claude committed 2026-10-21 02:29:06 +00:00
1 parent 6d44386fc2
commit 56243b51a3
6 files changed
+353

No files matched your search

+22
View File
@@ -0,0 +1,22 @@
# 대산2터널 본선 3D 골격 (Code/3d-drawing)
## 실행
1. 이 폴더(`Code/3d-drawing`)에서 HTTP 서버를 엽니다. 예: `python3 -m http.server 8000`
2. 브라우저에서 `http://localhost:8000/viewer/` 를 엽니다.
- `file://`로 열면 GLB를 불러올 수 없습니다.
## 구성
- `model/tunnel_skeleton.glb` : 중심선·표준단면 스윕 골격 (4+745 ~ 5+480)
- `model/tunnel_skeleton_meta.json` : 범위, 출처, 근사 항목, 미완 항목
- `data/` : 추출 중간 결과 (CSV, JSON)
- `scripts/` : 추출·생성 스크립트 (재현 순서는 아래)
- `viewer/index.html` : three.js 0.160.0 기반 뷰어 (jsDelivr CDN)
## 재현 순서
1. `inventory_dxf.py` → 2. `extract_alignment_plan.py` → 3. `extract_profile_gl.py` → 4. `dump_std_table.py` → 5. `build_skeleton.py <이 폴더>`
## 한계 (반드시 읽을 것)
- 2D 도면 재구성 골격이며 실측 모델이 아닙니다.
- 측벽은 수직, 인버트 미모델링. 근사 항목은 meta JSON에 기록.
- 4+700~4+745, 5+480~5+500은 [미확정]으로 제외.
- 지보패턴별 구간(03-003~008)과 라이닝 구조도 상세(04-004~009)는 아직 반영하지 않음.
+11
View File
@@ -0,0 +1,11 @@
{
"inner_radius_max_dev_m": 8.881784197001252e-16,
"lining_thickness_crown_m": 0.3,
"crown_above_road_m (expect 6.615 at S=0 station 5000)": 6.615,
"road_z_4745_m": 50.62,
"road_z_5480_m": 67.311,
"plan_chord_length_total_m (4+700..5+500)": 799.905,
"station_range_modelled": "4745 .. 5480 (ring step 5.0 m, 148 rings)",
"glb_bytes": 470416,
"glb_reload_geometries": 5
}
Binary file not shown.
+33
View File
@@ -0,0 +1,33 @@
{
"model": "tunnel_skeleton.glb",
"scope": "4745~5480 (4+745~5+480)",
"units": "m",
"origin_plan_xy": [
151090.911,
483688.46
],
"sources": {
"centreline": "C0060302-001 CA-ALGN-STSM (side-tick midpoint)",
"road_grade_B": "C0060302-002 CR-TABL-TEX2 (계획고=노면)",
"GL_A": "C0060302-002 CR-TABL-TEX2 (지반고)",
"section": "C0060303-001 CS-TABL-TEX1/2, CS-CONC-MAJR, CS-EXCV"
},
"approximations": [
"walls vertical down to road; invert not modelled",
"lining band thickness at walls is radial-consistent but not as-built",
"centreline and grade linearly interpolated between nodes",
"superelevation sign convention assumed (z += S/100 * x, x = left of tangent)"
],
"layers": [
"lining_outer_R7282",
"lining_inner_R6982",
"excavation_R7402",
"road_surface_계획고",
"ground_GL_지반고"
],
"open": [
"4+700~4+745 and 5+480~5+500 excluded ([미확정])",
"pattern zones (03-003~008) pending Task 5",
"lining details (04-004~009) pending Task 6"
]
}
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env python3
"""Task 7: axis + standard-section sweep skeleton of the main line (본선), 4+745..5+480.
Inputs (all confirmed by the user, 2026-10-21):
- centreline nodes data/alignment_plan_001_centre.csv (midpoint of side ticks)
- profile data/profile_gl_002_draft.csv (A = 지반고 GL, B = 계획고 = 노면)
- section table DOC/03_설계/설계_표준단면치수표_2026-10-21_v0.1.00.md
R1 6,982 mm (lining inner), lining 300 mm -> outer R 7,282, excavation R 7,402
crown A = 6.615 m above road (F = 0.000 = road reference)
- cross slope: 4+745~5+105 -2 %, 5+105~5+165 linear -2 -> +2 %, 5+165~5+480 +2 %
Model (skeleton, not as-built):
- Section = arch (concentric centre c = 6.615 - 6.982) + vertical walls down to the road.
Walls and invert are APPROXIMATIONS and are flagged in the metadata. No invert.
- Superelevation: z += (S/100) * x across the section (sign convention ASSUMED, flagged).
- Coordinates: plan XY in m (station spacing 100 m matches plan units), origin at 4+745.
Outputs: model/tunnel_skeleton.glb, model/tunnel_skeleton_meta.json, data/verify_skeleton.json
"""
import csv
import json
import math
import sys
import numpy as np
import trimesh
ROOT = sys.argv[1] # Code/3d-drawing
C_CSV = f"{ROOT}/data/alignment_plan_001_centre.csv"
P_CSV = f"{ROOT}/data/profile_gl_002_draft.csv"
R1, R_OUT, R_EX = 6.982, 7.282, 7.402 # m (mm / 1000)
CROWN_A = 6.615 # m above road
CENTRE_C = CROWN_A - R1 # arch centre height above road (m)
S0, S1 = 4745, 5480 # modelled range (fully determined)
DS = 5.0 # station step for rings (m)
ANG = np.radians(np.linspace(40.0, 140.0, 41)) # arch arc, right springline -> left
def read_centre():
rows = [r for r in csv.DictReader(open(C_CSV, encoding="utf-8"))]
return np.array([[int(r["station_m"]), float(r["centre_x"]), float(r["centre_y"])] for r in rows])
def read_profile():
gl, dg = {}, {}
for r in csv.DictReader(open(P_CSV, encoding="utf-8")):
sta = int(r["station_anchor"])
(gl if float(r["across_m"]) < -25 else dg)[sta] = float(r["value"])
return gl, dg
def slope_pct(s):
if s < 5105: return -2.0
if s < 5165: return -2.0 + 4.0 * (s - 5105) / 60.0
return 2.0
cn = read_centre()
gl_d, dg_d = read_profile()
gl_s = np.array(sorted(gl_d)); gl_v = np.array([gl_d[k] for k in gl_s])
dg_s = np.array(sorted(dg_d)); dg_v = np.array([dg_d[k] for k in dg_s])
cn_s = cn[:, 0]
def axis_xy(s):
x = np.interp(s, cn_s, cn[:, 1]); y = np.interp(s, cn_s, cn[:, 2])
i = min(max(np.searchsorted(cn_s, s) - 1, 0), len(cn_s) - 2)
dx, dy = cn[i + 1, 1] - cn[i, 1], cn[i + 1, 2] - cn[i, 2]
L = math.hypot(dx, dy)
return x, y, dx / L, dy / L
ORIGIN = np.array(axis_xy(S0)[:2])
stations = np.arange(S0, S1 + 0.001, DS)
def section(R, s, slope):
"""ring (x lateral m, z above road m) for radius R, including superelevation."""
xs = np.concatenate([[R * math.cos(ANG[0])], R * np.cos(ANG), [R * math.cos(ANG[-1])]])
zs = np.concatenate([[0.0], CENTRE_C + R * np.sin(ANG), [0.0]])
zs = zs + slope * xs
return xs, zs
def ring_world(R, s):
x, y, tx, ty = axis_xy(s)
nx, ny = -ty, tx # left normal (horizontal)
zr = np.interp(s, dg_s, dg_v) # road (계획고 = 노면)
xs, zs = section(R, s, slope_pct(s) / 100.0)
P = np.column_stack([x - ORIGIN[0] + xs * nx, y - ORIGIN[1] + xs * ny, zr + zs])
return P
def make_band(R, color, name):
rings = [ring_world(R, s) for s in stations]
n = len(rings[0]); m = len(rings)
V = np.vstack(rings)
F = []
for i in range(m - 1):
for j in range(n - 1):
a = i * n + j; b = a + 1; c = a + n; d = c + 1
F += [[a, b, c], [b, d, c]]
mesh = trimesh.Trimesh(vertices=V, faces=np.array(F), process=False)
mesh.visual.face_colors = np.tile(np.array(color, dtype=np.uint8), (len(F), 1))
mesh.metadata["name"] = name
return mesh
def make_ribbon(z_fn, half_w, color, name):
V, F = [], []
for k, s in enumerate(stations):
x, y, tx, ty = axis_xy(s)
nx, ny = -ty, tx
zr = z_fn(s)
V.append([x - ORIGIN[0] - half_w * nx, y - ORIGIN[1] - half_w * ny, zr])
V.append([x - ORIGIN[0] + half_w * nx, y - ORIGIN[1] + half_w * ny, zr])
V = np.array(V)
for k in range(len(stations) - 1):
a = 2 * k; b = a + 1; c = a + 2; d = a + 3
F += [[a, c, b], [b, c, d]]
mesh = trimesh.Trimesh(vertices=V, faces=np.array(F), process=False)
mesh.visual.face_colors = np.tile(np.array(color, dtype=np.uint8), (len(F), 1))
mesh.metadata["name"] = name
return mesh
parts = [
make_band(R_OUT, (190, 190, 186, 255), "lining_outer_R7282"),
make_band(R1, (235, 235, 230, 255), "lining_inner_R6982"),
make_band(R_EX, (150, 120, 90, 110), "excavation_R7402"),
make_ribbon(lambda s: float(np.interp(s, dg_s, dg_v)), 5.0, (70, 70, 75, 255), "road_surface_계획고"),
make_ribbon(lambda s: float(np.interp(s, gl_s, gl_v)), 20.0, (110, 150, 90, 200), "ground_GL_지반고"),
]
scene = trimesh.Scene()
for p in parts:
scene.add_geometry(p, geom_name=p.metadata["name"])
glb = f"{ROOT}/model/tunnel_skeleton.glb"
scene.export(glb)
# ---- verification (assertions recorded, not forced) ----
checks = {}
# radius check in section plane: distance from arch centre (lateral=0, z=centre_c)
xs, zs = section(R1, S0, 0.0)
rad = np.hypot(xs[1:-1], zs[1:-1] - CENTRE_C)
checks["inner_radius_max_dev_m"] = float(np.max(np.abs(rad - R1)))
checks["lining_thickness_crown_m"] = round(R_OUT - R1, 3)
crown_z = ring_world(R1, 5000)[:, 2].max() - np.interp(5000, dg_s, dg_v)
checks["crown_above_road_m (expect 6.615 at S=0 station 5000)"] = round(float(crown_z), 4)
checks["road_z_4745_m"] = round(float(np.interp(S0, dg_s, dg_v)), 3)
checks["road_z_5480_m"] = round(float(np.interp(S1, dg_s, dg_v)), 3)
L_chord = float(np.sum([math.dist(cn[i, 1:], cn[i + 1, 1:]) for i in range(len(cn) - 1)]))
checks["plan_chord_length_total_m (4+700..5+500)"] = round(L_chord, 3)
checks["station_range_modelled"] = f"{S0} .. {S1} (ring step {DS} m, {len(stations)} rings)"
checks["glb_bytes"] = int(__import__("os").path.getsize(glb))
reload = trimesh.load(glb)
checks["glb_reload_geometries"] = len(reload.geometry)
json.dump(checks, open(f"{ROOT}/data/verify_skeleton.json", "w", encoding="utf-8"), ensure_ascii=False, indent=2)
meta = {
"model": "tunnel_skeleton.glb", "scope": f"{S0}~{S1} (4+745~5+480)",
"units": "m", "origin_plan_xy": [round(ORIGIN[0], 3), round(ORIGIN[1], 3)],
"sources": {"centreline": "C0060302-001 CA-ALGN-STSM (side-tick midpoint)",
"road_grade_B": "C0060302-002 CR-TABL-TEX2 (계획고=노면)",
"GL_A": "C0060302-002 CR-TABL-TEX2 (지반고)",
"section": "C0060303-001 CS-TABL-TEX1/2, CS-CONC-MAJR, CS-EXCV"},
"approximations": ["walls vertical down to road; invert not modelled",
"lining band thickness at walls is radial-consistent but not as-built",
"centreline and grade linearly interpolated between nodes",
"superelevation sign convention assumed (z += S/100 * x, x = left of tangent)"],
"layers": [p.metadata["name"] for p in parts],
"open": ["4+700~4+745 and 5+480~5+500 excluded ([미확정])", "pattern zones (03-003~008) pending Task 5",
"lining details (04-004~009) pending Task 6"]
}
json.dump(meta, open(f"{ROOT}/model/tunnel_skeleton_meta.json", "w", encoding="utf-8"), ensure_ascii=False, indent=2)
print(json.dumps(checks, ensure_ascii=False, indent=1))
+126
View File
@@ -0,0 +1,126 @@
<!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 참조)';
});
fetch(META).then((r) => r.json()).then((m) => {
info.textContent = '범위: ' + m.scope + ' (단위 ' + m.units + ')\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;
info.textContent = (INFO[key] || key) + '\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>