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
@@ -1,3 +1,5 @@
|
||||
data/*.tmp
|
||||
__pycache__/
|
||||
*.pyc
|
||||
model/parts/
|
||||
model/tunnel_solids.step
|
||||
@@ -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
@@ -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
@@ -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