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:
조선두andClaude Haiku 5.5 committed 2026-10-21 15:32:54 +00:00
1 parent ab0a6bc057
commit ff2c057aa7
6 files changed
+2653 -62

No files matched your search

+2
View File
@@ -1,3 +1,5 @@
data/*.tmp
__pycache__/
*.pyc
model/parts/
model/tunnel_solids.step
+45
View File
@@ -0,0 +1,45 @@
{
"solid_count": 128,
"runs": 40,
"pattern_coverage": "4745..5480 contiguous",
"lining_runs": 40,
"excavation_runs": 40,
"volume_sum_m3": 12406.136,
"step_bytes": 84621457,
"glb_bytes": 1287912,
"glb_geometries": 134,
"section_overlap_m2_by_station": {
"4745": {
"lining_vs_excavation": 0.01732111,
"base_vs_pavement": 0.0,
"lining_vs_base": 0.0,
"pavement_vs_lining": 0.0,
"base_vs_drainL": 0.0,
"base_vs_drainR": 0.0
},
"5105": {
"lining_vs_excavation": 0.01732111,
"base_vs_pavement": 0.0,
"lining_vs_base": 0.0,
"pavement_vs_lining": 0.0,
"base_vs_drainL": 0.0,
"base_vs_drainR": 0.0
},
"5165": {
"lining_vs_excavation": 0.01732111,
"base_vs_pavement": 0.0,
"lining_vs_base": 0.0,
"pavement_vs_lining": 0.0,
"base_vs_drainL": 0.0,
"base_vs_drainR": 0.0
},
"5480": {
"lining_vs_excavation": 0.01732111,
"base_vs_pavement": 0.0,
"lining_vs_base": 0.0,
"pavement_vs_lining": 0.0,
"base_vs_drainL": 0.0,
"base_vs_drainR": 0.0
}
}
}
Binary file not shown.
File diff suppressed because it is too large. Load diff
+327
View File
@@ -0,0 +1,327 @@
#!/usr/bin/env python3
"""Task 8: B-rep solids of the main line (본선) 4+745..5+480 -> STEP (Rhino/Autodesk) + GLB (web viewer).
Section geometry: standard section C0060303-001 (drawing 03-001), revision rev2 approved 2026-10-21.
- lining (라이닝): arch R6982 (inner) / R7282 (outer) about centre C; haunch arcs R3712/R3910 (inner),
R4454/R4699 (outer) run below road level as drawn; inner walls and inner chord (road level) as drawn.
Lining is symmetric (no superelevation). H/I boxes removed from lining (2D).
- excavation band (굴착 여유대): R7402 / R4574 / R4819 minus lining outer, above road level. Symmetric.
- road surface / 계획고 (노면, 포장 280 mm): cross slope S per station (superelevation). Only this part tilts.
- base / invert (기층·인버트, ASSUMED: not in drawing): region between lining outer and inner chord minus
pavement and side drains. Its top follows the tilted pavement bottom.
- side drains (측구): box 300 x 836 mm minus pipe r150 (drawing circles).
- H/I boxes (공동구, ASSUMED by user decision 2026-10-21).
- 시설한계 50 mm: REFERENCE polylines only (not solids), as decided by the user.
Coordinates: local section x = lateral along the LEFT normal of the alignment (sign convention flagged),
z = height above road centre. Superelevation: z += (S/100)*x (sign flagged).
Outputs: model/tunnel_solids.step, model/tunnel_solids.glb, model/tunnel_solids_meta.json, data/verify_solids.json
Usage: python3 scripts/build_solids.py "$PWD"
"""
import csv, json, math, os, sys, tempfile
import numpy as np
from shapely.geometry import Polygon, box
from build123d import Vector, Wire, Solid, Compound, Color, export_step, export_stl, import_step
import trimesh
ROOT = sys.argv[1]
MODE = sys.argv[2] if len(sys.argv) > 2 else 'all' # parts_a | parts_b | assemble (4 GB RAM: run in stages)
PARTS = f"{ROOT}/model/parts"
RANGE = (int(sys.argv[3]), int(sys.argv[4])) if len(sys.argv) > 4 else (0, 10**6) # run index range for parts_a
os.makedirs(PARTS, exist_ok=True)
C_CSV = f"{ROOT}/data/alignment_plan_001_centre.csv"
P_CSV = f"{ROOT}/data/profile_gl_002_draft.csv"
PAT_CSV = f"{ROOT}/data/pattern_stations_dangjin.csv"
S0, S1 = 4745, 5480
STEP_M = 10.0 # section spacing (m): ruled loft between sections (STEP size vs curvature)
CHUNK_M = 100
PIPE_SEG = 24
CLEAR_OFFSET = 0.05
CLEAR_LINES = 6
CLEAR_RIB_W = 0.02
# ---------- drawing geometry (mm) -> local m ----------
CX, CY, Y0 = 14126.29, 15305.02, 15672.0
def P(x, y): return ((x - CX) / 1000.0, (y - Y0) / 1000.0)
def arc(cx, cy, r, a0, a1, n=120):
return [(cx + r * math.cos(math.radians(a0 + (a1 - a0) * i / n)),
cy + r * math.sin(math.radians(a0 + (a1 - a0) * i / n))) for i in range(n + 1)]
def loc(pts): return [P(x, y) for x, y in pts]
WALL_L_INNER = [(8771, 15751), (8771, 15826), (8651, 16001), (8621, 16411), (8471, 16411),
(8171, 16411), (8045, 16411), (8034, 16446)]
WALL_R_INNER = [(19481, 15567), (19481, 15642), (19601, 15817), (19631, 16227), (19781, 16227),
(20181, 16227), (20251, 16239)]
INNER_MM = (WALL_L_INNER + arc(11621.3, 17406.9, 3712, 195.6, 140) + arc(CX, CY, 6982, 140, 40)
+ arc(16479.6, 17279.7, 3910, 40, -15.6) + WALL_R_INNER[::-1])
OUTER_MM = (arc(11959.9, 17122.8, 4454, 204.7, 140) + arc(CX, CY, 7282, 140, 40)
+ arc(16105.0, 16965.3, 4699, 40, -23.7))
EX_MM = (arc(11959.9, 17122.8, 4574, 204.0, 140) + arc(CX, CY, 7402, 140, 40)
+ arc(16105.0, 16965.3, 4819, 40, -23.0))
inner_poly = Polygon(loc(INNER_MM)).buffer(0)
outer_poly = Polygon(loc(OUTER_MM)).buffer(0)
ex_poly = Polygon(loc(EX_MM)).buffer(0)
def _fill_slivers(g, tol=1e-3):
if g.geom_type != 'Polygon': return g
return Polygon(g.exterior, [h for h in g.interiors if Polygon(h).area > tol])
ring = _fill_slivers(outer_poly.difference(inner_poly)) # 37 mm wall/haunch join leaves 1e-5 m2 slivers
CHORD_A, CHORD_B = P(8771, 15751), P(19481, 15567)
_k = (CHORD_B[1] - CHORD_A[1]) / (CHORD_B[0] - CHORD_A[0])
def chord_z(x): return CHORD_A[1] + _k * (x - CHORD_A[0])
below_chord = Polygon([(-9, chord_z(-9)), (9, chord_z(9)), (9, -5), (-9, -5)])
above_chord = Polygon([(-9, chord_z(-9)), (9, chord_z(9)), (9, 9), (-9, 9)])
XL = P(9526, 15764)[0]; XR = P(18726, 15580)[0]
T_FIX = -0.02
PAV_T = 0.28
def pav_corners(t):
return [(XL, t * XL), (XR, t * XR), (XR, t * XR - PAV_T), (XL, t * XL - PAV_T)]
pav_fix = Polygon(pav_corners(T_FIX))
def rect_local(x0_mm, x1_mm, y0_mm, y1_mm):
(a, b), (c, d) = P(x0_mm, y0_mm), P(x1_mm, y1_mm)
return box(min(a, c), min(b, d), max(a, c), max(b, d))
DRAIN_L = rect_local(9101, 9401, 14864, 15700)
DRAIN_R = rect_local(18851, 19151, 14680, 15520)
PIPE_L = (P(9251.3, 15064)[0], P(9251.3, 15064)[1], 0.150)
PIPE_R = (P(19001.3, 14880)[0], P(19001.3, 14880)[1], 0.150)
HBOX = rect_local(8171, 8471, 16051, 16411)
IBOX = rect_local(19781, 20181, 15867, 16227)
lin_full = _fill_slivers(ring.intersection(above_chord))
assert lin_full.geom_type == 'Polygon' and len(lin_full.interiors) == 0, f"lining topology: {lin_full.geom_type}"
LINING_POLY = lin_full.simplify(0.01, preserve_topology=True) # 10 mm: removes 30 um edges (loft invalid) and cuts ~700 -> ~60 section vertices (STEP size)
EXCAV_POLY = ex_poly.difference(outer_poly).intersection(box(-9, 0.0, 9, 9)).simplify(0.01, preserve_topology=True)
assert EXCAV_POLY.geom_type == 'Polygon' and len(EXCAV_POLY.interiors) == 0, f"excavation topology: {EXCAV_POLY.geom_type}"
base_fix = ring.intersection(below_chord).difference(pav_fix).difference(DRAIN_L).difference(DRAIN_R)
assert base_fix.geom_type == 'Polygon' and len(base_fix.interiors) == 0, f"base topology: {base_fix.geom_type}"
BASE_PTS_FIX = np.array(base_fix.exterior.coords)[:-1]
def _find(pt):
d = np.hypot(BASE_PTS_FIX[:, 0] - pt[0], BASE_PTS_FIX[:, 1] - pt[1])
i = int(np.argmin(d)); assert d[i] < 1e-6, f"pavement corner not on base boundary: {d[i]}"
return i
IDX_BL = _find((XL, T_FIX * XL - PAV_T)); IDX_BR = _find((XR, T_FIX * XR - PAV_T))
def base_pts(t):
pts = BASE_PTS_FIX.copy()
pts[IDX_BL, 1] = t * XL - PAV_T
pts[IDX_BR, 1] = t * XR - PAV_T
return pts
LIN_PTS = np.array(LINING_POLY.exterior.coords)[:-1]
EXC_PTS = np.array(EXCAV_POLY.exterior.coords)[:-1]
def PIPE_PTS(cx, cz, r):
return np.array([(cx + r * math.cos(2 * math.pi * k / PIPE_SEG), cz + r * math.sin(2 * math.pi * k / PIPE_SEG)) for k in range(PIPE_SEG)])
DRAIN_L_PTS = np.array(DRAIN_L.exterior.coords)[:-1]
DRAIN_R_PTS = np.array(DRAIN_R.exterior.coords)[:-1]
HBOX_PTS = np.array(HBOX.exterior.coords)[:-1]
IBOX_PTS = np.array(IBOX.exterior.coords)[:-1]
# ---------- alignment, profile ----------
cn = np.array([[int(r["station_m"]), float(r["centre_x"]), float(r["centre_y"])]
for r in csv.DictReader(open(C_CSV, encoding="utf-8"))])
dg_d = {}
for r in csv.DictReader(open(P_CSV, encoding="utf-8")):
if float(r["across_m"]) >= -25:
dg_d[int(r["station_anchor"])] = float(r["value"])
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(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
ORX, ORY = axis(S0)[0], axis(S0)[1]
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
def world(s, x, z):
ax, ay, tx, ty = axis(s)
nx, ny = -ty, tx
zr = float(np.interp(s, dg_s, dg_v))
return (ax - ORX + x * nx, ay - ORY + x * ny, zr + z)
# ---------- B-rep helpers ----------
def loft(stations, pts2_fn):
wires = []
for s in stations:
pts3 = [world(s, float(x), float(z)) for x, z in pts2_fn(s)]
wires.append(Wire.make_polygon([Vector(*p) for p in pts3], close=True))
sol = Solid.make_loft(wires, ruled=True)
if not sol.is_valid: # end caps of lofts with near-degenerate edges; ShapeFix repairs them
sol = sol.fix()
return sol
def stations_of(a, b, step=STEP_M):
n = max(1, int(round((b - a) / step)))
return [float(v) for v in np.linspace(a, b, n + 1)]
def chunks():
out = []; a = S0
while a < S1:
b = min(a + CHUNK_M, S1); out.append((a, b)); a = b
return out
PAL = {"A": (42, 157, 143), "B-1": (231, 111, 81), "B-2": (244, 162, 97), "C-1": (233, 196, 106),
"C-2": (138, 177, 125), "D-1": (38, 70, 83), "D-2": (100, 130, 160), "P-2": (190, 110, 140),
"P-3": (150, 90, 60), "RP-1": (80, 170, 200), "WL-1": (200, 200, 120), "WL-2": (120, 90, 170),
"개착부": (170, 170, 170)}
COL = {"base": (141, 110, 99), "pav": (96, 125, 139), "drain": (30, 136, 229), "hi": (253, 216, 53),
"clear": (255, 111, 0)}
runs = []
for r in csv.DictReader(open(PAT_CSV, encoding="utf-8")):
a, b = int(float(r["sta_from"])), int(float(r["sta_to"])); pat = r["lining_pattern"]
a2, b2 = max(a, S0), min(b, S1)
if b2 > a2: runs.append((a2, b2, pat))
runs.sort()
assert runs[0][0] == S0 and runs[-1][1] == S1, f"pattern coverage {runs[0][0]}..{runs[-1][1]}"
for (a1, b1, _), (a2, _, _) in zip(runs, runs[1:]):
assert b1 == a2, f"pattern gap/overlap at {b1} vs {a2}"
solids, meta_c, clear_wires = [], [], []
def add(sol, cid, label, group, color, pattern=None, a=None, b=None):
if not sol.is_valid:
sol = sol.fix() # booleans on lofts can leave cap faces invalid
assert sol.is_valid, f"invalid solid {cid}"
sol.label = cid; sol.color = Color(*[c / 255 for c in color])
solids.append(sol)
meta_c.append({"id": cid, "label": label, "group": group, "pattern": pattern,
"from_sta": a, "to_sta": b, "volume_m3": round(sol.volume, 4), "color": list(color)})
for i, (a, b, pat) in enumerate(runs):
if MODE != 'parts_a' or not (RANGE[0] <= i < RANGE[1]): continue
print('lining run', i + 1, pat, a, b, flush=True)
lin_run = loft(stations_of(a, b), lambda s: LIN_PTS)
lin_run = lin_run - loft(stations_of(a, b), lambda s: HBOX_PTS) - loft(stations_of(a, b), lambda s: IBOX_PTS)
add(lin_run, f"LIN_{i+1:02d}_{pat}", f"라이닝 {pat} {a/1000:.3f}~{b/1000:.3f}",
"라이닝", PAL[pat], pat, a, b)
for i, (a, b, pat) in enumerate(runs):
if MODE != 'parts_a' or not (RANGE[0] <= i < RANGE[1]): continue
add(loft(stations_of(a, b), lambda s: EXC_PTS), f"EXC_{i+1:02d}_{pat}", f"굴착 여유대 {pat} {a/1000:.3f}~{b/1000:.3f}",
"굴착면", (150, 120, 90), pat, a, b)
for (a, b) in chunks():
if MODE != 'parts_b': break
sts = stations_of(a, b)
add(loft(sts, lambda s: np.array(pav_corners(slope_pct(s) / 100.0))), f"PAV_{a}", f"노면 계획고 포장 {a}~{b}",
"노면(계획고)", COL["pav"], None, a, b)
add(loft(sts, lambda s: base_pts(slope_pct(s) / 100.0)), f"BASE_{a}", f"기층·인버트(가정) {a}~{b}",
"기층·인버트(가정)", COL["base"], None, a, b)
for side, dpts, pipe in (("L", DRAIN_L_PTS, PIPE_L), ("R", DRAIN_R_PTS, PIPE_R)):
bx = loft(sts, lambda s, d=dpts: d)
pp = loft(sts, lambda s, p=pipe: PIPE_PTS(p[0], p[1], p[2]))
add(bx - pp, f"DRN{side}_{a}", f"측구 {side} {a}~{b}", "배수(측구)", COL["drain"], None, a, b)
add(loft(sts, lambda s: HBOX_PTS), f"HBOX_{a}", f"공동구 H(가정) {a}~{b}", "공동구(가정)", COL["hi"], None, a, b)
add(loft(sts, lambda s: IBOX_PTS), f"IBOX_{a}", f"공동구 I(가정) {a}~{b}", "공동구(가정)", COL["hi"], None, a, b)
# 시설한계 50 mm reference lines (wires, not solids)
clr = inner_poly.buffer(CLEAR_OFFSET).exterior
clr_pts = [(x, z) for x, z in list(clr.coords)[:-1] if z >= chord_z(x) - 1e-9 and abs(x) <= 5.4]
idxs = np.round(np.linspace(0, len(clr_pts) - 1, CLEAR_LINES)).astype(int)
clear_lines = []
sts_all = stations_of(S0, S1)
for k, ii in enumerate(idxs):
x0, z0 = clr_pts[ii]
pts3 = [world(s, x0, z0) for s in sts_all]
clear_lines.append(pts3)
if MODE != 'parts_b':
continue
w = Wire.make_polygon([Vector(*p) for p in pts3], close=False)
w.label = f"REF_CLEAR_{k+1}"; w.color = Color(1.0, 0.44, 0.0)
clear_wires.append(w)
meta_c.append({"id": f"REF_CLEAR_{k+1}", "label": f"시설한계 50 mm 참조선 {k+1}", "group": "시설한계(참조선)",
"pattern": None, "from_sta": S0, "to_sta": S1, "volume_m3": 0, "color": list(COL["clear"]), "reference": True})
if MODE in ('parts_a', 'parts_b'):
tag = f'lin_exc_{RANGE[0]:02d}' if MODE == 'parts_a' else 'other'
export_step(Compound(children=solids + clear_wires, label=tag), f"{PARTS}/{tag}.step")
json.dump(meta_c, open(f"{PARTS}/{tag}.json", 'w', encoding='utf-8'), ensure_ascii=False, indent=1)
print('parts written', tag, len(solids), 'solids', len(clear_wires), 'ref wires')
sys.exit(0)
# ---- post-processing modes (each call < 180 s, 4 GB RAM) ----
import glob
TAGS = sorted(os.path.basename(x)[:-5] for x in glob.glob(f"{PARTS}/*.json"))
from build123d import Solid as _Solid
if MODE == 'mesh': # python build_solids.py ROOT mesh <tag>
tag = sys.argv[3]
imp = import_step(f"{PARTS}/{tag}.step")
items = [x for x in (imp.children or list(imp.solids())) if isinstance(x, _Solid)]
pj = [m for m in json.load(open(f"{PARTS}/{tag}.json", encoding='utf-8')) if not m.get('reference')]
assert len(items) == len(pj), f"{tag}: {len(items)} solids vs {len(pj)} meta"
sc = trimesh.Scene(); tmp = tempfile.mkdtemp(prefix='stl_')
for k, (sol, mm) in enumerate(zip(items, pj)):
stl = os.path.join(tmp, f"s{k}.stl")
export_stl(sol, stl, tolerance=0.002, angular_tolerance=0.2)
m = trimesh.load(stl, force='mesh')
m.visual.face_colors = np.tile(np.array(list(mm['color']) + [255], dtype=np.uint8), (len(m.faces), 1))
sc.add_geometry(m, geom_name=mm['id'])
sc.export(f"{PARTS}/{tag}.glb")
print('mesh', tag, len(sc.geometry), 'geometries'); sys.exit(0)
if MODE == 'step': # python build_solids.py ROOT step
allitems = []
for tag in TAGS:
imp = import_step(f"{PARTS}/{tag}.step")
allitems += list(imp.children or list(imp.solids()))
os.makedirs(f"{ROOT}/model", exist_ok=True)
export_step(Compound(children=allitems, label="본선 4+745~5+480 표준단면 모델"), f"{ROOT}/model/tunnel_solids.step")
print('step merged', len(allitems), 'items', os.path.getsize(f"{ROOT}/model/tunnel_solids.step"), 'bytes'); sys.exit(0)
if MODE == 'final': # python build_solids.py ROOT final
meta_c = []
for tag in TAGS:
meta_c += json.load(open(f"{PARTS}/{tag}.json", encoding='utf-8'))
scene = trimesh.Scene()
for tag in TAGS:
g = trimesh.load(f"{PARTS}/{tag}.glb")
for name, geom in g.geometry.items():
scene.add_geometry(geom, geom_name=name)
glb_faces = {}
for k, pts3 in enumerate(clear_lines): # 시설한계 reference ribbons (visual only; exact wires in STEP)
P3 = np.array(pts3); V = []; F = []
for i in range(len(P3)):
d = P3[min(i + 1, len(P3) - 1)] - P3[max(i - 1, 0)]
d = d / (np.linalg.norm(d) + 1e-12)
w = np.cross(d, np.array([0, 0, 1.0])); w = w / (np.linalg.norm(w) + 1e-12) * CLEAR_RIB_W
V += [P3[i] - w, P3[i] + w]
for i in range(len(P3) - 1):
a0, b0, c0, d0 = 2 * i, 2 * i + 1, 2 * i + 2, 2 * i + 3
F += [[a0, c0, b0], [b0, c0, d0]]
m = trimesh.Trimesh(vertices=np.array(V), faces=np.array(F), process=False)
m.visual.face_colors = np.tile(np.array([255, 111, 0, 255], dtype=np.uint8), (len(m.faces), 1))
scene.add_geometry(m, geom_name=f"REF_CLEAR_{k+1}")
glb_path = f"{ROOT}/model/tunnel_solids.glb"
scene.export(glb_path)
step_path = f"{ROOT}/model/tunnel_solids.step"
solid_meta = [x for x in meta_c if not x.get('reference')]
meta = {
"model": ["tunnel_solids.step (STEP B-rep, Rhino/Autodesk)", "tunnel_solids.glb (web viewer, same B-rep tessellated)"],
"scope": f"{S0}~{S1} (4+745~5+480)", "units": "m",
"origin_plan_xy": [round(ORX, 3), round(ORY, 3)],
"sections": {"lining": "rev2: 라이닝 R6982/R7282, 하단 호 도면 끝점까지", "excavation": "굴착 여유대 R7402/R4574/R4819 (노면 위)",
"road": "계획고 노면 포장 280 mm, 측점별 편경사 S (노면만)", "base": "기층·인버트: 가정 (도면 미표현)",
"drains": "측구 300x836 mm, 관 r150", "hi": "H/I 박스: 공동구 가정(사용자 결정)", "clearance": "시설한계 50 mm 참조선 (솔리드 아님)"},
"assumptions": ["기층·인버트 형상·두께는 가정 (도면 미표현, 사용자 결정 '가정하여 생성')",
"H/I 박스 = 공동구 가정 (사용자 결정)",
"편경사 부호 z = (S/100)·x, x = 접선 좌측 법선 (부호 확인 필요)",
"라이닝·굴착 여유대는 대칭 (편경사 미적용)",
"측점 간 축선은 선형 보간, 단면은 10 m 간격 Ruled loft (곡선부 근사)",
"라이닝 H/I 박스 제외 부분은 3D 불리언 차감 (공동구 가정)"],
"pattern_runs": [{"id": f"LIN_{i+1:02d}_{p}", "pattern": p, "from": a, "to": b} for i, (a, b, p) in enumerate(runs)],
"components": meta_c,
"open": ["E-1, E-2, RP-2, WL-3 패턴 배치 [미확정] (당진 표 밖)", "NP-5BE-1-WL-4 해석 [확인 필요]",
"04 공동구·배수 도면 상세 미반영 (측구·공동구 치수는 03-001 단면 기준)", "편경사 부호(좌·우) 확인 필요"]
}
json.dump(meta, open(f"{ROOT}/model/tunnel_solids_meta.json", "w", encoding="utf-8"), ensure_ascii=False, indent=2)
ver = {"solid_count": len(solid_meta), "runs": len(runs), "pattern_coverage": f"{runs[0][0]}..{runs[-1][1]} contiguous",
"lining_runs": sum(1 for x in solid_meta if x['group'] == '라이닝'),
"excavation_runs": sum(1 for x in solid_meta if x['group'] == '굴착면'),
"volume_sum_m3": round(sum(x['volume_m3'] for x in solid_meta), 3),
"step_bytes": os.path.getsize(step_path), "glb_bytes": os.path.getsize(glb_path),
"glb_geometries": len(trimesh.load(glb_path).geometry)}
def _overlap(sample):
base = Polygon(base_pts(slope_pct(sample) / 100.0)); pav = Polygon(pav_corners(slope_pct(sample) / 100.0))
return {"lining_vs_excavation": round(LINING_POLY.intersection(EXCAV_POLY).area, 9),
"base_vs_pavement": round(base.intersection(pav).area, 9),
"lining_vs_base": round(LINING_POLY.intersection(base).area, 9),
"pavement_vs_lining": round(pav.intersection(LINING_POLY).area, 9),
"base_vs_drainL": round(base.intersection(DRAIN_L).area, 9),
"base_vs_drainR": round(base.intersection(DRAIN_R).area, 9)}
ver["section_overlap_m2_by_station"] = {str(st): _overlap(st) for st in [4745, 5105, 5165, 5480]}
json.dump(ver, open(f"{ROOT}/data/verify_solids.json", "w", encoding="utf-8"), ensure_ascii=False, indent=2)
print(json.dumps(ver, ensure_ascii=False, indent=1)); sys.exit(0)
+118 -62
View File
@@ -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', () => {