Files
drawing3d-web/tools/extract_from_dxf.py
T
오문성andClaude Haiku 5.5 9068b6c7f2 공개용 정리본: 1차 제출본(도면 좌표·과업명 제거)으로 새 이력 시작 (SPEC 34장)
- 정리는 코치 세션이 저장소 밖 도구로 수행, 하이쿠 세션이 커밋.
- 옛 81커밋은 로컬에만 보존. 기록은 evidence/gitlog_v1.txt.
- F13 기준 변경: .gitignore 에 private/*.dwg·private/full.dxf 제외 (SPEC 34장 끝 문장).
- check.sh 결과: 결과: PASS 151 / FAIL 0 / SKIP 3 (검사 범위 F01~F46)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-27 14:27:50 +09:00

1181 lines
56 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# extract_from_dxf.py — 도면 DXF 에서 모델 데이터를 뽑는다 (표준 라이브러리만 사용, SPEC 20.1)
# 사용: python3 tools/extract_from_dxf.py <도면.dxf> model_extracted.js
# 단위: DXF 의 1 = 1 m → 출력은 mm. 좌표·정답 값은 코드에 두지 않고 도면에서 찾는다.
import json
import math
import os
import re
import sys
from collections import Counter
# SPEC 규칙의 상수 (도면 좌표가 아니다)
UNIT = 1000.0 # 1 m = 1000 mm
WALL_MIN_LEN = 60.0 # 전체 단면 벽: 길이 60 m 넘는 세로 선분
SLAB_THICK = 0.3 # 슬래브 두께 0.3 m
EPS = 0.01 # 좌표 허용 오차 (도면 단위)
NEAR = 0.2 # 「근처」 허용
R_IN = 4.85 # 평면 안쪽 원 반지름
R_OUT = 5.5 # 평면 바깥 원 반지름
RAIL_H = 1100 # 난간 높이 mm
WALL_COLOR = "#8a9bb0"
SLAB_COLOR = "#c8c8c8"
RAIL_COLOR = "#d8c890"
GREEN = 3 # 지반선 색
RAIL_PEN = 2 # 난간 색 (노랑 계열 ACI 2)
STAIR_PEN = 8 # 단면 계단 윤곽 색 (ACI 8)
STAIR_MIN_VERTS = 6 # 단면 계단 윤곽 꼭짓점 하한
STAIR_HOLE_MIN = 4000 # 계단 구멍: 폭 4 m 넘는 것 (mm)
STAIR_RUNS = 18 # 계단 런 수
STAIR_COLOR = "#7fb07f"
TUNNEL_COLOR = "#b08a5a"
TUNNEL_EXT = 1.0 # 단면 터널 선: 벽 바깥으로 1 m 넘게 뻗는다
TUNNEL_SLOPE = 0.05 # 단면 터널 선: 기울기 5% 미만
TUNNEL_PLAN_LAYER = "-평면계획"
TUNNEL_PLAN_PEN = 8 # 평면 터널 선 색 (ACI 8)
TUNNEL_PLAN_MIN = 4.8 # 평면 선분 두 끝의 중심 거리 (m)
TUNNEL_PLAN_MAX = 8.0
TUNNEL_LEN_EXTRA = 20000 # 벽 바깥으로 20 m
GRAVEL_T = 125 # 잡석 두께 mm
PAD_TOP_T = 50 # 패드 1단 두께 mm
PAD_TOP_HALF = 400 # 패드 1단 폭 800 의 절반 mm
Z_TOL = 5 # 높이 비교 허용 오차 (mm)
HOLE_LAYERS = ("오프닝", "구조물 계단")
RAIL_LAYER = "계단"
WALL_LAYER = "구조물"
TITLE_LAYER = "S-TITLE"
PLAN_TITLES = {"중간슬래브(홀수층)": "odd", "중간슬래브(짝수층)": "even",
"중간슬래브(18B)": "18B", "중간슬래브(19B)": "19B", "하부슬래브(20B)": "20B"}
FRAME_TITLE = "평면도" # 평면도 1 글자 (공백 무시)
VENT_LAYER = "환기구" # 환기구 중심선 레이어
PLAN_LEG_PEN = 1 # 평면도 다리 꺾은선 색 (ACI 1)
PARALLEL_RAD = 0.01 # 다리와 나란함 허용 (rad)
R_TOL = 0.02 # 다리에서 r_out 떨어짐 허용 (m)
BRANCH_COLOR = "#9fb8c8"
VENT_COLOR = "#b8c8a0"
BRANCH_S_MIN = 4.55 # 분기구 윤곽: 다리 방향 s 가 이 값(m)보다 커야 한다
BRANCH_T_MAX = 2.0 # 분기구 윤곽: |t| 가 이 값(m)보다 작아야 한다
BRANCH_END_EXTRA = 0.5 # 분기구 윤곽: s 는 다리 길이 + 0.5 m 보다 작다
X_MIN = 1.0 # 벽 개구부 X 사선: 길이 1 m 넘는 것
X_DEPTH = 10.0 # 벽 개구부 X 사선: 지표 아래 10 m 안
HAUNCH_MIN = 0.1 # 헌치 사선 길이 하한 (m)
HAUNCH_MAX = 0.5 # 헌치 사선 길이 상한 (m)
DUCT_NEAR = 3.0 # 덕트 벽선: 가운데 꼭짓점이 V1 에서 3 m 안
GROUND_Z = 0 # 지표 높이 (mm)
TOP_COLOR = "#c8c8c8" # 상부 슬래브 색
NECK_COLOR = "#b8b8b8" # 맨홀 목 색
PAD_COLOR = "#e0b060" # 19B 패드 색
BOTTOM_COLOR = "#a0a0a0" # 하부 슬래브 색
LEAN_COLOR = "#707070" # 바닥 콘크리트 색
WEIR_COLOR = "#9a9a9a" # 월류벽 색
PIT_PUMP_COLOR = "#8fc3e0" # 집수조 색
PIT_TBM_COLOR = "#a8d8f0" # 침사조 색
PIT_ALPHA = 0.25
HOLE_R_MAX = 1.0 # 맨홀 구멍 원: 반지름 1 m 미만 (m)
TOP_SPAN_FRAC = 0.9 # 상부 슬래브 밑면 수평선: 안쪽 지름의 0.9 배 넘는 길이
FLOOR_HAUNCH = (0.1, 0.6) # 하부 슬래브 윗면 사선 길이 범위 (m)
PAD_LEN_MIN = 9.0 # 패드 윗면 수평선 길이 하한 (m)
X_MARK_MIN = 0.5 # X 표시 사선 길이 하한 (m)
X_MARK_LAYERS = ("오프닝", "구조물")
WEIR_WALL_MIN = 5.0 # 월류벽 세로 선 길이 하한 (m, 평면)
SECTION_WEIR_MIN = 1.0 # 월류벽 세로 선 길이 하한 (m, 단면)
WEIR_SIDE = 0.05 # 안쪽 벽선에서 이만큼 떨어진 선만 (m)
PIT_LABEL = "집수조" # 단면 글자: 왼쪽이면 왼쪽 칸이 펌프 쪽
def load_dxf(path):
# DXF 는 「그룹 코드 줄 + 값 줄」이 번갈아 나오는 텍스트다
with open(path, encoding="utf-8", errors="replace") as f:
lines = f.read().split("\n")
pairs = [(lines[i].strip(), lines[i + 1].strip()) for i in range(0, len(lines) - 1, 2)]
# 레이어 색 표 (TABLES 의 LAYER)
layer_color = {}
in_layer = False
cur_name = None
for code, val in pairs:
if code == "0" and val == "LAYER":
in_layer = True
cur_name = None
elif code == "2" and in_layer and cur_name is None:
cur_name = val
elif code == "62" and in_layer and cur_name is not None:
layer_color[cur_name] = abs(int(val))
cur_name = None
in_layer = False
elif code == "0" and val == "ENDTAB":
in_layer = False
# ENTITIES 구역의 객체를 묶는다
start = next(i for i, (c, v) in enumerate(pairs) if c == "2" and v == "ENTITIES")
end = next(i for i, (c, v) in enumerate(pairs) if i > start and c == "0" and v == "ENDSEC")
raw = []
for code, val in pairs[start + 1:end]:
if code == "0":
raw.append({"type": val, "codes": []})
else:
raw[-1]["codes"].append((code, val))
ents = []
poly_head = None
for r in raw:
t = r["type"]
codes = r["codes"]
if t == "VERTEX" and poly_head is not None:
x = next(float(v) for c, v in codes if c == "10")
y = next(float(v) for c, v in codes if c == "20")
poly_head["pts"].append((x, y))
continue
if t == "SEQEND":
poly_head = None
continue
layer = next((v for c, v in codes if c == "8"), "0")
color = next((int(v) for c, v in codes if c == "62"), None)
if color in (None, 0, 256): # 없거나 「레이어 따름」이면 레이어 색
color = layer_color.get(layer)
e = {"type": t, "layer": layer, "color": color, "pts": [], "closed": False}
if t == "LINE":
vals = {c: float(v) for c, v in codes if c in ("10", "20", "11", "21")}
e["pts"] = [(vals["10"], vals["20"]), (vals["11"], vals["21"])]
elif t == "LWPOLYLINE":
x = None
for c, v in codes:
if c == "10":
x = float(v)
elif c == "20" and x is not None:
e["pts"].append((x, float(v)))
x = None
elif c == "70":
e["closed"] = bool(int(v) & 1)
elif t == "POLYLINE":
for c, v in codes:
if c == "70":
e["closed"] = bool(int(v) & 1)
poly_head = e
elif t == "CIRCLE":
e["center"] = (float(next(v for c, v in codes if c == "10")), float(next(v for c, v in codes if c == "20")))
e["r"] = float(next(v for c, v in codes if c == "40"))
elif t == "TEXT":
e["text"] = next((v for c, v in codes if c == "1"), "")
e["pts"] = [(float(next(v for c, v in codes if c == "10")), float(next(v for c, v in codes if c == "20")))]
else:
continue
ents.append(e)
return ents
def segments_of(ents, layer=None):
# 선분 목록: LINE 과 꼭짓점 두 개짜리 꺾은선(POLYLINE·LWPOLYLINE 의 이웃 점) 전부
out = []
for e in ents:
if layer is not None and e["layer"] != layer:
continue
if e["type"] == "LINE":
out.append((e["pts"][0], e["pts"][1], e))
elif e["type"] in ("POLYLINE", "LWPOLYLINE") and len(e["pts"]) >= 2:
for a, b in zip(e["pts"], e["pts"][1:]):
out.append((a, b, e))
return out
def section_walls(ents, start=0):
# 1. 전체 단면: 구조물 세로 선분 중 길이 60 m 넘는 것, X 가 작은 쪽부터 네 선 (start=4 면 B-B, 5~8번째)
tall = []
for a, b, _ in segments_of(ents, WALL_LAYER):
if abs(a[0] - b[0]) <= EPS and abs(a[1] - b[1]) > WALL_MIN_LEN:
tall.append(a[0])
xs = sorted(tall)[start:start + 4]
if len(xs) < 4:
raise SystemExit("전체 단면 벽 선 네 개를 찾지 못했다")
outer_l, inner_l, inner_r, outer_r = xs
return {
"r_out": (outer_r - outer_l) / 2 * UNIT,
"r_in": (inner_r - inner_l) / 2 * UNIT,
"axis": (outer_l + outer_r) / 2,
"inner_l": inner_l,
"inner_r": inner_r,
"outer_l": outer_l,
"outer_r": outer_r,
}
def ground_y(ents, axis, tol=EPS):
# 2. 지표: 녹색 선 중 단면 축 X 를 지나는 것, 그 X 에서의 Y (선이 살짝 기울어도 된다)
for a, b, e in segments_of(ents):
if e["color"] != GREEN or abs(a[0] - b[0]) <= EPS:
continue
lo, hi = min(a[0], b[0]), max(a[0], b[0])
if lo - tol <= axis <= hi + tol:
t = (axis - a[0]) / (b[0] - a[0])
return a[1] + t * (b[1] - a[1])
raise SystemExit("지표선을 찾지 못했다")
def slab_tops(ents, walls, gy):
# 3. 슬래브 윗면: 왼쪽 안쪽 벽에서 시작하는 수평선 S, 0.3 아래에 같은 시작점 근처의 선 T 가 있는 것
hs = []
for a, b, _ in segments_of(ents, WALL_LAYER):
if abs(a[1] - b[1]) > EPS:
continue
left = a if a[0] < b[0] else b
hs.append((left, max(a[0], b[0])))
tops = []
for left, _ in hs:
if abs(left[0] - walls["inner_l"]) > EPS:
continue
below = [h for h, _ in hs if abs(h[1] - (left[1] - SLAB_THICK)) <= EPS and abs(h[0] - walls["inner_l"]) <= NEAR]
if below:
tops.append(left[1])
tops = sorted(set(round(y, 4) for y in tops), reverse=True)
if len(tops) != 19:
raise SystemExit(f"슬래브 윗면을 19개 찾지 못했다 ({len(tops)}개)")
return [(y - gy) * UNIT for y in tops]
def find_slab_plans(ents):
# 4. 슬래브 평면: 글자 바로 아래에서 가장 가까운, 반지름 4.85 와 5.5 원이 겹친 중심
circles = [e for e in ents if e["type"] == "CIRCLE"]
centers = []
for c in circles:
if abs(c["r"] - R_OUT) <= EPS:
if any(abs(d["r"] - R_IN) <= EPS and math.dist(c["center"], d["center"]) <= EPS for d in circles):
centers.append(c["center"])
plans = {}
for t in ents:
if t["type"] != "TEXT" or t["layer"] != TITLE_LAYER:
continue
key = PLAN_TITLES.get(t["text"].replace(" ", ""))
if key is None:
continue
tx, ty = t["pts"][0]
below = [c for c in centers if c[1] < ty]
if below:
plans[key] = min(below, key=lambda c: math.dist(c, (tx, ty)))
if len(plans) != 5:
raise SystemExit("슬래브 평면 다섯 개를 찾지 못했다")
return plans
def inside_plan(e, center):
# 평면 안쪽 원 안에 있는 객체: 점들의 무게중심이 안쪽 원 안
xs = [p[0] for p in e["pts"]]
ys = [p[1] for p in e["pts"]]
gx, gy = sum(xs) / len(xs), sum(ys) / len(ys)
return math.dist((gx, gy), center) <= R_IN
def axis_aligned(a, b):
return abs(a[0] - b[0]) <= EPS or abs(a[1] - b[1]) <= EPS
def same_pt(p, q):
return abs(p[0] - q[0]) <= EPS and abs(p[1] - q[1]) <= EPS
def close_open_triangle(e, lines):
# 나: 꼭짓점 3개 열린 꺾은선의 끝점 B 에서 수평·수직 선을 차례로 이어 끝점 A 에 닿으면 후보
a, m, b = e["pts"]
frontier = [(b, [])]
seen = []
while frontier:
here, trail = frontier.pop(0)
if len(trail) >= 2:
continue
for p, q, _ in lines:
if not axis_aligned(p, q):
continue
if same_pt(p, here):
nxt = q
elif same_pt(q, here):
nxt = p
else:
continue
if same_pt(nxt, a):
return [a, m, b] + trail
if any(same_pt(nxt, s) for s in seen):
continue
seen.append(nxt)
frontier.append((nxt, trail + [nxt]))
return None
def same_shape(p, q):
return sorted((round(x, 1), round(y, 1)) for x, y in p) == sorted((round(x, 1), round(y, 1)) for x, y in q)
def area(poly):
s = 0.0
for i in range(len(poly)):
x1, y1 = poly[i]
x2, y2 = poly[(i + 1) % len(poly)]
s += x1 * y2 - x2 * y1
return abs(s) / 2
def centroid(poly):
return (sum(p[0] for p in poly) / len(poly), sum(p[1] for p in poly) / len(poly))
def point_in_poly(pt, poly):
x, y = pt
inside = False
for i in range(len(poly)):
x1, y1 = poly[i]
x2, y2 = poly[(i + 1) % len(poly)]
if (y1 > y) != (y2 > y) and x < (x2 - x1) * (y - y1) / (y2 - y1) + x1:
inside = not inside
return inside
def plan_holes(ents, center):
# 5. 구멍: 평면 안쪽 원 안의 오프닝·구조물 계단 객체
lines = [(a, b, e) for a, b, e in segments_of(ents) if e["type"] == "LINE" and e["layer"] in HOLE_LAYERS]
cands = []
for e in ents:
if e["layer"] not in HOLE_LAYERS or e["type"] not in ("LWPOLYLINE", "POLYLINE") or not inside_plan(e, center):
continue
n = len(e["pts"])
if n >= 4:
cands.append(e["pts"])
elif n == 3 and not e["closed"]:
poly = close_open_triangle(e, [(a, b, x) for a, b, x in lines if x["layer"] == e["layer"]])
if poly:
cands.append(poly)
uniq = []
for c in cands:
if not any(same_shape(c, u) for u in uniq):
uniq.append(c)
# 다른 후보를 감싸는 후보(난간 윤곽)는 뺀다: 더 크고, 다른 후보의 무게중심을 품은 것
keep = []
for c in uniq:
wraps = any(d is not c and area(c) > area(d) and point_in_poly(centroid(d), c) for d in uniq)
if not wraps:
keep.append(c)
return [[[round((x - center[0]) * UNIT), round((y - center[1]) * UNIT)] for x, y in poly] for poly in keep]
def plan_railings(ents, center):
# 6. 방호 난간: 평면 원 안의 계단 레이어, 색 2, 꼭짓점 3개 이상 꺾은선
paths = []
for e in ents:
if e["layer"] != RAIL_LAYER or e["color"] != RAIL_PEN or e["type"] not in ("LWPOLYLINE", "POLYLINE"):
continue
if len(e["pts"]) < 3 or not inside_plan(e, center):
continue
pts = list(e["pts"])
if e["closed"]:
pts.append(pts[0])
paths.append([[round((x - center[0]) * UNIT), round((y - center[1]) * UNIT)] for x, y in pts])
return paths
def section_stairs(ents, walls, gy):
# 21.1: 단면 계단 윤곽 — 구조물 색 8 의 닫힌 꺾은선, 꼭짓점 6개 이상, 바깥 벽 두 선 사이
# 돌려주는 값: 윤곽마다 [(x, z)] (mm, x = 단면 축 기준, z = 지표 기준, 위 +)
out = []
for e in ents:
if e["layer"] != WALL_LAYER or e["color"] != STAIR_PEN or e["type"] not in ("LWPOLYLINE", "POLYLINE"):
continue
if not e["closed"] or len(e["pts"]) < STAIR_MIN_VERTS:
continue
xs = [p[0] for p in e["pts"]]
if min(xs) < walls["outer_l"] - EPS or max(xs) > walls["outer_r"] + EPS:
continue
out.append([((x - walls["axis"]) * UNIT, (y - gy) * UNIT) for x, y in e["pts"]])
return out
def stair_profile(contours):
# 21.2: 단 높이 = 세로 변 길이 중 가장 많은 값, 첫 단 = 가장 작은 세로 변, 디딤판 = 가로 변 가장 많은 값
verts, horiz = [], []
for poly in contours:
n = len(poly)
for i in range(n):
(x1, z1), (x2, z2) = poly[i], poly[(i + 1) % n]
length = round(math.dist((x1, z1), (x2, z2)))
if length == 0:
continue
if abs(x1 - x2) <= EPS * UNIT:
verts.append(round(abs(z1 - z2)))
elif abs(z1 - z2) <= EPS * UNIT:
horiz.append(round(abs(x1 - x2)))
riser = Counter(verts).most_common(1)[0][0]
tread = Counter(horiz).most_common(1)[0][0]
return min(verts), riser, tread
def stair_dir(contours, z_low):
# 21.4: z_low 에 가장 낮은 꼭짓점이 있는 윤곽에서, 그 꼭짓점들이 왼쪽 끝이면 −1, 오른쪽 끝이면 +1
for poly in contours:
zmin = min(z for _, z in poly)
if abs(zmin - z_low) > Z_TOL:
continue
lows = [x for x, z in poly if abs(z - zmin) <= Z_TOL]
mid = (min(x for x, _ in poly) + max(x for x, _ in poly)) / 2
return -1 if sum(lows) / len(lows) < mid else 1
return None
def stair_parts(ents, walls, gy, tops, holes_of):
# 21.3~21.6: 18런. tops[k] = k+1 층 윗면(mm, 1B 가 맨 위). 런 i: z_high = tops[i-1], z_low = tops[i]
contours = section_stairs(ents, walls, gy)
first_riser, riser, tread = stair_profile(contours)
dirs = []
for i in range(1, STAIR_RUNS + 1):
found = stair_dir(contours, tops[i])
# 윤곽이 없는 런(18)은 바로 위 런(i−1)의 반대
dirs.append(found if found is not None else -dirs[-1])
parts = []
for i in range(1, STAIR_RUNS + 1):
z_high, z_low = round(tops[i - 1]), round(tops[i])
name = f"{i}B"
key = name if name in ("18B", "19B") else ("odd" if i % 2 else "even")
# 21.5: 같은 층 평면의 계단 구멍(폭 4 m 넘는 것)
hole = next(h for h in holes_of[key] if max(p[0] for p in h) - min(p[0] for p in h) > STAIR_HOLE_MIN)
d = dirs[i - 1]
x_top = max(p[0] for p in hole) if d == -1 else min(p[0] for p in hole)
parts.append({
"id": f"stair_{i:02d}", "name": f"계단 {i}런 ({name}→{i + 1}B)", "kind": "stair", "type": "stair",
"color": STAIR_COLOR, "z_low": z_low, "z_high": z_high,
"risers": round((z_high - z_low - first_riser) / riser + 1), "first_riser": first_riser,
"riser": riser, "tread": tread, "x_top": x_top, "dir": d,
"y0": min(p[1] for p in hole), "y1": max(p[1] for p in hole),
})
return parts
def section_tunnel(ents, walls, gy):
# 23.1: 단면 A-A 오른쪽 바깥 벽선(X 최대)에서 바깥으로 1 m 넘게 뻗는 구조물 수평선(기울기 5% 미만)
# 벽 쪽 끝의 z 다섯 개를 위에서부터 [바깥 위, 안 위, 패드 윗면, 안 아래, 바깥 아래] 로 본다
zs = []
for a, b, _ in segments_of(ents, WALL_LAYER):
if abs(a[0] - b[0]) <= EPS:
continue
near, far = (a, b) if a[0] < b[0] else (b, a)
if abs(near[0] - walls["outer_r"]) > EPS or far[0] - near[0] <= TUNNEL_EXT:
continue
if abs(far[1] - near[1]) / (far[0] - near[0]) >= TUNNEL_SLOPE:
continue
zs.append((near[1] - gy) * UNIT)
zs.sort(reverse=True)
if len(zs) != 5:
raise SystemExit(f"단면 터널 선을 다섯 개 찾지 못했다 ({len(zs)}개)")
outer_top, inner_top, pad_top, inner_bot, outer_bot = zs
return {
"z": (outer_top + outer_bot) / 2,
"r_out": (outer_top - outer_bot) / 2,
"r_in": (inner_top - inner_bot) / 2,
"pad_z": pad_top,
}
def plan_tunnel(ents, center):
# 23.2: 19B 평면 중심에서 두 끝이 4.8~8 m 인 -평면계획 색 8 선분. 방향 = 단위벡터 합, 축 어긋남 = 왼쪽 +(mm)
segs = []
for a, b, e in segments_of(ents, TUNNEL_PLAN_LAYER):
if e["color"] != TUNNEL_PLAN_PEN:
continue
da, db = math.dist(a, center), math.dist(b, center)
if TUNNEL_PLAN_MIN <= da <= TUNNEL_PLAN_MAX and TUNNEL_PLAN_MIN <= db <= TUNNEL_PLAN_MAX:
segs.append((a, b) if da < db else (b, a))
if not segs:
raise SystemExit("19B 평면의 터널 선을 찾지 못했다")
dx = dy = 0.0
for near, far in segs:
length = math.dist(near, far)
dx += (far[0] - near[0]) / length
dy += (far[1] - near[1]) / length
norm = math.hypot(dx, dy)
ux, uy = dx / norm, dy / norm
offs = [(near[0] - center[0]) * -uy + (near[1] - center[1]) * ux for near, _ in segs]
return {"ux": ux, "uy": uy, "angle": math.degrees(math.atan2(uy, ux)) % 360,
"off": sum(offs) / len(offs) * UNIT}
def tunnel_parts(ents, walls, gy, center):
# 23.3·23.4: 터널 1개(pipe) + 채움 3개(잡석·패드 2단·패드 1단). 채움은 터널과 같은 축
sec = section_tunnel(ents, walls, gy)
pl = plan_tunnel(ents, center)
ux, uy = pl["ux"], pl["uy"]
sx = pl["off"] * -uy + walls["r_in"] * ux
sy = pl["off"] * ux + walls["r_in"] * uy
length = (walls["outer_r"] - walls["inner_r"]) * UNIT + TUNNEL_LEN_EXTRA
axis = {"x": round(sx), "y": round(sy), "z": round(sec["z"]), "angle_deg": round(pl["angle"], 2),
"length": round(length)}
r_in = sec["r_in"]
pad_v = sec["pad_z"] - sec["z"]
tunnel = {"id": "tunnel_TBM", "name": "TBM 터널", "kind": "tunnel", "type": "pipe", "color": TUNNEL_COLOR,
**axis, "r_out": round(sec["r_out"]), "r_in": round(r_in)}
fills = [
("fill_gravel", "터널 잡석", "#7a7a7a", -r_in, -r_in + GRAVEL_T, 0),
("fill_pad_low", "터널 패드 2단 (아래)", "#c8bfa8", -r_in + GRAVEL_T, pad_v - PAD_TOP_T, 0),
("fill_pad_high", "터널 패드 1단 (위)", "#e0d8c4", pad_v - PAD_TOP_T, pad_v, PAD_TOP_HALF),
]
parts = [tunnel]
for fid, name, color, v0, v1, half in fills:
parts.append({"id": fid, "name": name, "kind": "fill", "type": "fill", "color": color, **axis,
"r": round(r_in), "v0": round(v0), "v1": round(v1), "half_width": half})
return parts
def plan_center(ents):
# 24.1-1: 「평면도」 글자 아래에서 가장 가까운 반지름 4.85 원의 중심
circles = [e["center"] for e in ents if e["type"] == "CIRCLE" and abs(e["r"] - R_IN) <= EPS]
for t in ents:
if t["type"] == "TEXT" and t["layer"] == TITLE_LAYER and t["text"].replace(" ", "") == FRAME_TITLE:
tx, ty = t["pts"][0]
below = [c for c in circles if c[1] < ty]
if below:
return min(below, key=lambda c: math.dist(c, (tx, ty)))
raise SystemExit("평면도 중심을 찾지 못했다")
def ang_of(p, c):
return math.degrees(math.atan2(p[1] - c[1], p[0] - c[0]))
def frame_angle(ents, center, r_out_mm, tunnel_angle):
# 24.1-2~4: 평면도 다리 두 개 중 터널 다리(나란한 색 8 선분 2개 이상, r_out 떨어짐)를 고르고 φ 를 구한다
legs = []
for e in ents:
if e["layer"] == TUNNEL_PLAN_LAYER and e["color"] == PLAN_LEG_PEN and e["type"] in ("LWPOLYLINE", "POLYLINE") \
and len(e["pts"]) == 3 and same_pt(e["pts"][1], center):
legs = [e["pts"][0], e["pts"][2]]
if len(legs) != 2:
raise SystemExit("평면도 다리 꺾은선을 찾지 못했다")
r_m = r_out_mm / UNIT
segs8 = [(a, b) for a, b, e in segments_of(ents, TUNNEL_PLAN_LAYER) if e["color"] == TUNNEL_PLAN_PEN]
def parallel_count(leg):
a = math.radians(ang_of(leg, center))
ux, uy = math.cos(a), math.sin(a)
n = 0
for p, q in segs8:
dx, dy = q[0] - p[0], q[1] - p[1]
if abs(dx * uy - dy * ux) / math.hypot(dx, dy) > math.sin(PARALLEL_RAD):
continue
dist = abs((p[0] - center[0]) * uy - (p[1] - center[1]) * ux)
if abs(dist - r_m) <= R_TOL:
n += 1
return n
tun = [leg for leg in legs if parallel_count(leg) >= 2]
if len(tun) != 1:
raise SystemExit("터널 다리를 하나로 고르지 못했다")
br = next(leg for leg in legs if leg is not tun[0])
phi = (tunnel_angle - ang_of(tun[0], center)) % 360
return phi, br
def rot(x, y, deg):
c, s = math.cos(math.radians(deg)), math.sin(math.radians(deg))
return x * c - y * s, x * s + y * c
def obox(kind, pid, name, color, cx, cy, ang, s0, s1, t0, t1, z0, z1):
return {"id": pid, "name": name, "kind": kind, "type": "obox", "color": color,
"cx": round(cx), "cy": round(cy), "angle_deg": round(ang, 2),
"s0": round(s0), "s1": round(s1), "t0": round(t0), "t1": round(t1),
"z0": round(z0), "z1": round(z1)}
def section_heights(ents, walls, gy):
# 24.2: X 사선이 있는 쪽 = 구조물 쪽. X 의 아래끝 = 바닥 윗면, 위끝 = 지붕 아랫면.
# 그 쪽 바깥면에서 나간 헌치: 지붕 윗면(내려감), 바닥 아랫면(올라감). 값은 gy 기준 mm
sides = {-1: (walls["outer_l"], walls["inner_l"]), 1: (walls["outer_r"], walls["inner_r"])}
diag = [(a, b) for a, b, _ in segments_of(ents, WALL_LAYER)
if abs(a[0] - b[0]) > EPS and abs(a[1] - b[1]) > EPS and math.dist(a, b) > X_MIN]
found = {}
for side, (outer, inner) in sides.items():
zs = []
for a, b in diag:
if not all(-X_DEPTH <= (p[1] - gy) <= 0 for p in (a, b)):
continue
if (abs(a[0] - outer) <= EPS and abs(b[0] - inner) <= EPS) or \
(abs(b[0] - outer) <= EPS and abs(a[0] - inner) <= EPS):
zs += [(a[1] - gy) * UNIT, (b[1] - gy) * UNIT]
if zs:
found[side] = zs
if len(found) != 1:
raise SystemExit("벽 개구부 X 를 한 쪽에서 찾지 못했다")
side, zs = found.popitem()
floor_top, roof_bottom = min(zs), max(zs)
outer = sides[side][0]
roof_tops, floor_bots = [], []
for a, b, _ in segments_of(ents, WALL_LAYER):
if abs(a[0] - b[0]) <= EPS or abs(a[1] - b[1]) <= EPS or not HAUNCH_MIN <= math.dist(a, b) <= HAUNCH_MAX:
continue
for w, o in ((a, b), (b, a)):
if abs(w[0] - outer) > EPS or (o[0] - outer) * side <= EPS:
continue
wz, oz = (w[1] - gy) * UNIT, (o[1] - gy) * UNIT
if roof_bottom < oz < wz:
roof_tops.append(oz)
elif floor_top > oz > wz:
floor_bots.append(oz)
if len(roof_tops) != 1 or len(floor_bots) != 1:
raise SystemExit("헌치 사선을 지붕·바닥에서 각각 하나 찾지 못했다")
return {"side": side, "floor_bottom": round(floor_bots[0]), "floor_top": round(floor_top),
"roof_bottom": round(roof_bottom), "roof_top": round(roof_tops[0])}
def branch_parts(ents, walls, center, phi, leg, h):
# 24.3: 분기구 다리 방향 s, 왼쪽 수직 t (C 기준 m). 윤곽 두 개 → 안 (t_in, s_in), 바깥 (t_out, s_out)
a = ang_of(leg, center)
ua = math.radians(a)
ux, uy = math.cos(ua), math.sin(ua)
length = math.dist(leg, center)
def st(p):
dx, dy = p[0] - center[0], p[1] - center[1]
return dx * ux + dy * uy, -dx * uy + dy * ux
cands = []
for e in ents:
if e["layer"] != WALL_LAYER or e["closed"] or e["type"] not in ("LWPOLYLINE", "POLYLINE") or len(e["pts"]) < 4:
continue
sts = [st(p) for p in e["pts"]]
if all(BRANCH_S_MIN < s < length + BRANCH_END_EXTRA and abs(t) < BRANCH_T_MAX for s, t in sts):
cands.append(sts)
if len(cands) != 2:
raise SystemExit(f"분기구 윤곽 두 개를 찾지 못했다 ({len(cands)}개)")
inner, outer = sorted(cands, key=lambda sts: max(abs(t) for _, t in sts))
t_in = max(abs(t) for _, t in inner) * UNIT
s_in = max(s for s, _ in inner) * UNIT
t_out = max(abs(t) for _, t in outer) * UNIT
s_out = max(s for s, _ in outer) * UNIT
r_in, ang = walls["r_in"], a + phi
fb, ft, rb, rt = h["floor_bottom"], h["floor_top"], h["roof_bottom"], h["roof_top"]
return [
obox("branch", "branch_floor", "분기구 바닥", BRANCH_COLOR, 0, 0, ang, r_in, s_out, -t_out, t_out, fb, ft),
obox("branch", "branch_roof", "분기구 지붕", BRANCH_COLOR, 0, 0, ang, r_in, s_out, -t_out, t_out, rb, rt),
obox("branch", "branch_side_1", "분기구 옆벽 1", BRANCH_COLOR, 0, 0, ang, r_in, s_out, t_in, t_out, ft, rb),
obox("branch", "branch_side_2", "분기구 옆벽 2", BRANCH_COLOR, 0, 0, ang, r_in, s_out, -t_out, -t_in, ft, rb),
obox("branch", "branch_end", "분기구 끝벽", BRANCH_COLOR, 0, 0, ang, s_in, s_out, -t_in, t_in, ft, rb),
]
def vent_parts(ents, walls, center, phi, h):
# 24.4: 중심선 C→V1→T. 다리 1 = C→V1, 다리 2 = V1→T. 덕트 벽 t_in·t_out, 탑 안·바깥 사각형(다리 2 축)
pl = next((e["pts"] for e in ents if e["layer"] == VENT_LAYER and e["type"] == "LWPOLYLINE"
and len(e["pts"]) == 3 and same_pt(e["pts"][0], center)), None)
if pl is None:
raise SystemExit("환기구 중심선을 찾지 못했다")
v1, tip = pl[1], pl[2]
a1, a2 = ang_of(v1, center), ang_of(tip, v1)
l1 = math.dist(v1, center) * UNIT
# 꺾이는 쪽: 다리 2 가 다리 1 의 왼쪽으로 꺾이면 +1
turn = 1 if math.sin(math.radians(a2 - a1)) > 0 else -1
r1 = math.radians(a1)
n1x, n1y = -math.sin(r1), math.cos(r1)
duct = []
for e in ents:
if e["layer"] != WALL_LAYER or e["closed"] or e["type"] not in ("LWPOLYLINE", "POLYLINE") or len(e["pts"]) != 3:
continue
mid = e["pts"][1]
if math.dist(mid, v1) <= DUCT_NEAR:
duct.append(abs((mid[0] - center[0]) * n1x + (mid[1] - center[1]) * n1y))
if len(duct) != 4:
raise SystemExit(f"덕트 벽선을 네 개 찾지 못했다 ({len(duct)}개)")
ts = sorted(duct)
t_in = (ts[0] + ts[1]) / 2 * UNIT
t_out = (ts[2] + ts[3]) / 2 * UNIT
tower = [e["pts"] for e in ents if e["layer"] == WALL_LAYER and e["closed"] and len(e["pts"]) >= 4
and point_in_poly(tip, e["pts"])]
if len(tower) != 2:
raise SystemExit("환기탑 윤곽 두 개를 찾지 못했다")
outer_pts, inner_pts = min(tower, key=len), max(tower, key=len)
r2 = math.radians(a2)
u2 = (math.cos(r2), math.sin(r2))
n2 = (-math.sin(r2), math.cos(r2))
def st2(p):
dx, dy = p[0] - v1[0], p[1] - v1[1]
return (dx * u2[0] + dy * u2[1]) * UNIT, (dx * n2[0] + dy * n2[1]) * UNIT
so = [st2(p) for p in outer_pts]
si = [st2(p) for p in inner_pts]
s_o0, s_o1 = min(s for s, _ in so), max(s for s, _ in so)
t_o0, t_o1 = min(t for _, t in so), max(t for _, t in so)
s_i0, s_i1 = min(s for s, _ in si), max(s for s, _ in si)
t_i0, t_i1 = min(t for _, t in si), max(t for _, t in si)
r_in = walls["r_in"]
fb, ft, rb, rt = h["floor_bottom"], h["floor_top"], h["roof_bottom"], h["roof_top"]
ang1, ang2 = a1 + phi, a2 + phi
cx2, cy2 = rot((v1[0] - center[0]) * UNIT, (v1[1] - center[1]) * UNIT, phi)
ins = (sorted([turn * t_in, turn * t_out]))
outs = (sorted([-turn * t_out, -turn * t_in]))
return [
obox("vent", "duct_leg1_floor", "환기 덕트 1 바닥", VENT_COLOR, 0, 0, ang1, r_in, l1 + t_out, -t_out, t_out, fb, ft),
obox("vent", "duct_leg1_roof", "환기 덕트 1 지붕", VENT_COLOR, 0, 0, ang1, r_in, l1 + t_out, -t_out, t_out, rb, rt),
obox("vent", "duct_leg1_in", "환기 덕트 1 안쪽 벽", VENT_COLOR, 0, 0, ang1, r_in, l1 - t_out, ins[0], ins[1], ft, rb),
obox("vent", "duct_leg1_out", "환기 덕트 1 바깥 벽", VENT_COLOR, 0, 0, ang1, r_in, l1 + t_out, outs[0], outs[1], ft, rb),
obox("vent", "duct_leg2_floor", "환기 덕트 2 바닥", VENT_COLOR, cx2, cy2, ang2, t_out, s_o0, -t_out, t_out, fb, ft),
obox("vent", "duct_leg2_roof", "환기 덕트 2 지붕", VENT_COLOR, cx2, cy2, ang2, t_out, s_o0, -t_out, t_out, rb, rt),
obox("vent", "duct_leg2_in", "환기 덕트 2 안쪽 벽", VENT_COLOR, cx2, cy2, ang2, t_in, s_o0, ins[0], ins[1], ft, rb),
obox("vent", "duct_leg2_out", "환기 덕트 2 바깥 벽", VENT_COLOR, cx2, cy2, ang2, -t_out, s_o0, outs[0], outs[1], ft, rb),
obox("vent", "tower_floor", "환기탑 바닥", VENT_COLOR, cx2, cy2, ang2, s_o0, s_o1, t_o0, t_o1, fb, ft),
obox("vent", "tower_wall_near", "환기탑 벽 (덕트 쪽)", VENT_COLOR, cx2, cy2, ang2, s_o0, s_i0, t_o0, t_o1, rb, GROUND_Z),
obox("vent", "tower_wall_far", "환기탑 벽 (먼 쪽)", VENT_COLOR, cx2, cy2, ang2, s_i1, s_o1, t_o0, t_o1, ft, GROUND_Z),
obox("vent", "tower_wall_side_1", "환기탑 벽 (옆 1)", VENT_COLOR, cx2, cy2, ang2, s_i0, s_i1, t_o0, t_i0, ft, GROUND_Z),
obox("vent", "tower_wall_side_2", "환기탑 벽 (옆 2)", VENT_COLOR, cx2, cy2, ang2, s_i0, s_i1, t_i1, t_o1, ft, GROUND_Z),
]
def span_levels(ents, walls, gy):
# 25.1-1: 벽 전체 수평선(바깥 왼 벽선 ~ 바깥 오른 벽선, 양 끝 EPS) 의 높이, 높은 것부터 (mm)
levels = set()
for a, b, _ in segments_of(ents, WALL_LAYER):
if abs(a[1] - b[1]) > EPS:
continue
if abs(min(a[0], b[0]) - walls["outer_l"]) <= EPS and abs(max(a[0], b[0]) - walls["outer_r"]) <= EPS:
levels.add(round((a[1] - gy) * UNIT, 1))
if len(levels) < 3:
raise SystemExit(f"벽 전체 수평선을 셋 이상 찾지 못했다 ({len(levels)}개)")
return sorted(levels, reverse=True)
def top_slab_bottom(ents, walls, gy, top_z):
# 25.1-2: 윗면보다 낮고 바깥 벽 사이에 있으며 길이가 안쪽 지름의 0.9 배 넘는 수평선 중 가장 높은 것 (mm)
span = TOP_SPAN_FRAC * 2 * walls["r_in"] / UNIT
best = None
for a, b, _ in segments_of(ents, WALL_LAYER):
if abs(a[1] - b[1]) > EPS:
continue
lo, hi = min(a[0], b[0]), max(a[0], b[0])
if lo < walls["outer_l"] - EPS or hi > walls["outer_r"] + EPS or hi - lo <= span:
continue
z = round((a[1] - gy) * UNIT, 1)
if z < top_z - Z_TOL and (best is None or z > best):
best = z
if best is None:
raise SystemExit("상부 슬래브 밑면 선을 찾지 못했다")
return best
def bottom_slab_top(ents, walls, gy):
# 25.1-4: 안쪽 벽면에 한 끝이 있고 안쪽·아래로 내려가는 짧은 사선(길이 범위 FLOOR_HAUNCH)의 아래 끝 중 가장 낮은 것 (mm)
lows = []
for a, b, _ in segments_of(ents, WALL_LAYER):
if abs(a[0] - b[0]) <= EPS or abs(a[1] - b[1]) <= EPS:
continue
if not FLOOR_HAUNCH[0] <= math.dist(a, b) <= FLOOR_HAUNCH[1]:
continue
for w, o in ((a, b), (b, a)):
if abs(w[0] - walls["inner_l"]) > EPS and abs(w[0] - walls["inner_r"]) > EPS:
continue
if abs(o[0] - walls["axis"]) < abs(w[0] - walls["axis"]) and o[1] < w[1]:
lows.append((o[1] - gy) * UNIT)
if not lows:
raise SystemExit("하부 슬래브 윗면 사선을 찾지 못했다")
return round(min(lows), 1)
def plan1_circles(ents, center):
# 25.2: 평면도 1 안의 반지름 1 m 미만 구조물 원 (중복 하나로), 돌려주는 값: (중심 m, 반지름 m)
out = []
for e in ents:
if e["type"] != "CIRCLE" or e["layer"] != WALL_LAYER or e["r"] >= HOLE_R_MAX:
continue
if math.dist(e["center"], center) > R_IN or any(same_pt(c, e["center"]) for c, _ in out):
continue
out.append((e["center"], e["r"]))
return out
def rot_circle(c, r, center, phi):
# C 기준 mm 를 φ 만큼 돌린 원 {"circle": [x, y, r]}
x, y = rot((c[0] - center[0]) * UNIT, (c[1] - center[1]) * UNIT, phi)
return {"circle": [round(x), round(y), round(r * UNIT)]}
def top_slab_part(walls, top_z, bottom_z, center, phi, circles):
# 25.2: 상부 슬래브 — disc, r = 바깥 반지름, 구멍 = 평면도 1 의 작은 원
return {"id": "topslab", "name": "상부 슬래브", "kind": "topslab", "type": "disc", "color": TOP_COLOR,
"cx": 0, "cy": 0, "z0": round(bottom_z), "z1": round(top_z), "r": round(walls["r_out"]),
"holes": [rot_circle(c, r, center, phi) for c, r in circles]}
def neck_parts(ents, center, phi, top_z, circles):
# 25.3: 맨홀 목 — 평면도 1 의 닫힌 꼭짓점 4개 구조물 꺾은선 중 구멍 원 중심을 품는 것. y 가 작은 쪽이 neck_1
polys = []
for e in ents:
if e["layer"] != WALL_LAYER or not e["closed"] or e["type"] not in ("LWPOLYLINE", "POLYLINE") \
or len(e["pts"]) != 4 or not inside_plan(e, center):
continue
if not any(same_shape(e["pts"], p) for p in polys):
polys.append(e["pts"])
necks = []
for n, (c, r) in enumerate(sorted(circles, key=lambda h: h[0][1]), 1):
found = [p for p in polys if point_in_poly(c, p)]
if len(found) != 1:
raise SystemExit(f"맨홀 목 윤곽을 구멍마다 하나 찾지 못했다 ({len(found)}개)")
poly = [[round(v) for v in rot((x - center[0]) * UNIT, (y - center[1]) * UNIT, phi)] for x, y in found[0]]
necks.append({"id": f"neck_{n}", "name": f"맨홀 목 {n}", "kind": "neck", "type": "prism", "color": NECK_COLOR,
"polygon": poly, "z0": round(top_z), "z1": GROUND_Z,
"holes": [rot_circle(c, r, center, phi)]})
return necks
def x_marks(ents, center, layers):
# 25.5: X 표시 = 같은 레이어의 엇갈린 사선 LINE 두 개(길이 0.5 m 넘고 X·Y 범위가 같은 것).
# 돌려주는 값: 범위 (x0, x1, y0, y1) m, 중복 하나로
diag = []
for a, b, e in segments_of(ents):
if e["type"] != "LINE" or e["layer"] not in layers or not inside_plan(e, center):
continue
if abs(a[0] - b[0]) <= EPS or abs(a[1] - b[1]) <= EPS or math.dist(a, b) <= X_MARK_MIN:
continue
diag.append((a, b, e["layer"]))
marks = []
for i, (a, b, la) in enumerate(diag):
for c, d, lc in diag[i + 1:]:
if la != lc or (b[0] - a[0]) * (b[1] - a[1]) * (d[0] - c[0]) * (d[1] - c[1]) >= 0:
continue
ra = (min(a[0], b[0]), max(a[0], b[0]), min(a[1], b[1]), max(a[1], b[1]))
rc = (min(c[0], d[0]), max(c[0], d[0]), min(c[1], d[1]), max(c[1], d[1]))
if any(abs(p - q) > NEAR for p, q in zip(ra, rc)):
continue
rect = (min(ra[0], rc[0]), max(ra[1], rc[1]), min(ra[2], rc[2]), max(ra[3], rc[3]))
if not any(all(abs(p - q) <= EPS for p, q in zip(rect, m)) for m in marks):
marks.append(rect)
return marks
def rect_poly(x0, x1, y0, y1):
return [[x0, y0], [x1, y0], [x1, y1], [x0, y1]]
def bbox_mm(poly):
xs = [p[0] for p in poly]
ys = [p[1] for p in poly]
return min(xs), max(xs), min(ys), max(ys)
def pad_and_sump(ents, walls, gy, center, slab_top, slab_holes):
# 25.5: 19B 패드 — 19B 슬래브 윗면 위 0~0.3 m 의 안쪽 벽 사이 길이 9 m 넘는 수평선 → 윗면, r = 길이/2.
# 구멍 = X 표시 사각형을 ±r 로 자른 것. 자르지 않은 사각형이 슬래브 구멍에 없으면 침사구로 더한다
band = []
for a, b, _ in segments_of(ents, WALL_LAYER):
if abs(a[1] - b[1]) > EPS:
continue
lo, hi = min(a[0], b[0]), max(a[0], b[0])
z = round((a[1] - gy) * UNIT, 1)
if not slab_top + Z_TOL < z <= slab_top + SLAB_THICK * UNIT + Z_TOL:
continue
if lo < walls["inner_l"] - EPS or hi > walls["inner_r"] + EPS or hi - lo <= PAD_LEN_MIN:
continue
band.append((z, (hi - lo) / 2 * UNIT))
if len(band) != 1:
raise SystemExit(f"19B 패드 윗면 선을 하나 찾지 못했다 ({len(band)}개)")
pad_top, pad_r = band[0]
cut = lambda v: max(-pad_r, min(pad_r, v))
pad_holes, sump = [], []
for x0, x1, y0, y1 in x_marks(ents, center, X_MARK_LAYERS):
mm = (round((x0 - center[0]) * UNIT), round((x1 - center[0]) * UNIT),
round((y0 - center[1]) * UNIT), round((y1 - center[1]) * UNIT))
pad_holes.append(rect_poly(cut(mm[0]), cut(mm[1]), cut(mm[2]), cut(mm[3])))
if not any(all(abs(p - q) <= 10 for p, q in zip(bbox_mm(h), mm)) for h in slab_holes):
sump.append(rect_poly(*mm))
return round(pad_top), round(pad_r), pad_holes, sump
def plan_weir_walls(ents, center):
# 25.6-1: 20B 평면 안쪽 원 안의 길이 5 m 넘는 구조물 세로 LINE 두 개 → x0, x1 (m, C 기준)
xs = set()
for e in ents:
if e["type"] != "LINE" or e["layer"] != WALL_LAYER or not inside_plan(e, center):
continue
a, b = e["pts"]
if abs(a[0] - b[0]) > EPS or abs(a[1] - b[1]) <= WEIR_WALL_MIN:
continue
xs.add(round(a[0] - center[0], 4))
if len(xs) != 2:
raise SystemExit(f"월류벽 세로 선을 두 개 찾지 못했다 ({len(xs)}개)")
return min(xs), max(xs)
def weir_heights(ents, walls, gy, floor_top):
# 25.6-2: 단면 안쪽 벽 사이(벽선에서 WEIR_SIDE 넘게 떨어진) 길이 1 m 넘는 세로 선 중 아래 끝이
# 하부 슬래브 윗면 위 0~0.3 m 인 것 → 위끝 = 월류벽 윗면. 그 사이에 걸친 수평선 중 윗면보다 낮은 것 = 낮은 칸 윗면
verts = []
for a, b, _ in segments_of(ents, WALL_LAYER):
if abs(a[0] - b[0]) > EPS or abs(a[1] - b[1]) <= SECTION_WEIR_MIN:
continue
x = a[0]
if not walls["inner_l"] + WEIR_SIDE < x < walls["inner_r"] - WEIR_SIDE:
continue
lo, hi = sorted((a[1], b[1]))
z_lo = (lo - gy) * UNIT
if floor_top - Z_TOL <= z_lo <= floor_top + SLAB_THICK * UNIT + Z_TOL:
verts.append((x, round((hi - gy) * UNIT, 1)))
if len(verts) != 2 or verts[0][1] != verts[1][1]:
raise SystemExit(f"월류벽 세로 선 두 개의 윗끝을 맞추지 못했다 ({len(verts)}개)")
top_z = verts[0][1]
x_lo, x_hi = sorted(x for x, _ in verts)
low = []
for a, b, _ in segments_of(ents, WALL_LAYER):
if abs(a[1] - b[1]) > EPS:
continue
lo, hi = min(a[0], b[0]), max(a[0], b[0])
z = round((a[1] - gy) * UNIT, 1)
if lo <= x_lo + EPS and hi >= x_hi - EPS and z < top_z - Z_TOL:
low.append(z)
if not low:
raise SystemExit("월류벽 낮은 칸 윗면을 찾지 못했다")
return top_z, max(low)
def weir_parts(ents, walls, gy, plan20, floor_top, weir_top, low_top):
# 25.6-1·3: 월류벽 3개 (서쪽 L, 동쪽 R, 홈 아래 mid). 바깥 y 끝 = ±√(안쪽 반지름² − x1²)
x0, x1 = plan_weir_walls(ents, plan20)
marks = x_marks(ents, plan20, (WALL_LAYER,))
if len(marks) != 1:
raise SystemExit(f"하부슬래브 평면의 X 표시를 하나 찾지 못했다 ({len(marks)}개)")
_, _, y0, y1 = marks[0]
y0, y1 = round((y0 - plan20[1]) * UNIT), round((y1 - plan20[1]) * UNIT)
xa, xb = round(x0 * UNIT), round(x1 * UNIT)
reach = round(math.sqrt((walls["r_in"] / UNIT) ** 2 - x1 * x1) * UNIT)
fl = round(floor_top)
parts = [
{"id": "weir_L", "name": "월류벽 (넘침 홈 서쪽)", "kind": "weir", "type": "prism", "color": WEIR_COLOR,
"polygon": rect_poly(xa, xb, -reach, y0), "z0": fl, "z1": round(weir_top)},
{"id": "weir_R", "name": "월류벽 (넘침 홈 동쪽)", "kind": "weir", "type": "prism", "color": WEIR_COLOR,
"polygon": rect_poly(xa, xb, y1, reach), "z0": fl, "z1": round(weir_top)},
{"id": "weir_mid", "name": "월류벽 (홈 아래)", "kind": "weir", "type": "prism", "color": WEIR_COLOR,
"polygon": rect_poly(xa, xb, y0, y1), "z0": fl, "z1": round(low_top)},
]
return parts, xa, xb
def pit_parts(ents, walls, gy, floor_top, weir_top, xa, xb):
# 25.7: 집수조·침사조 — 월류벽 왼쪽 −r~x0, 오른쪽 x1~r. 단면 글자 「집수조」가 축 왼쪽이면 왼쪽이 pit_pump
labels = [t for t in ents if t["type"] == "TEXT" and t["text"].replace(" ", "") == PIT_LABEL
and walls["outer_l"] <= t["pts"][0][0] <= walls["outer_r"] and t["pts"][0][1] < gy]
if len(labels) != 1:
raise SystemExit(f"단면의 「{PIT_LABEL}」 글자를 하나 찾지 못했다 ({len(labels)}개)")
pump_left = labels[0]["pts"][0][0] < walls["axis"]
r = round(walls["r_in"])
left_x, right_x = (-r, xa), (xb, r)
pump_x, tbm_x = (left_x, right_x) if pump_left else (right_x, left_x)
common = {"kind": "pit", "type": "zone", "alpha": PIT_ALPHA, "r": r, "z0": round(floor_top), "z1": round(weir_top)}
return [
{"id": "pit_pump", "name": "집수조 (펌프 쪽)", "color": PIT_PUMP_COLOR, "x_min": pump_x[0], "x_max": pump_x[1], **common},
{"id": "pit_tbm", "name": "침사조 (터널 쪽)", "color": PIT_TBM_COLOR, "x_min": tbm_x[0], "x_max": tbm_x[1], **common},
]
def load_design_rules():
# 27장: 설계 규칙 파일 (🔶 부재 6개 + 색 규칙). 스크립트 옆의 design_rules.json
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "design_rules.json")
with open(path, encoding="utf-8") as f:
return json.load(f)
def apply_design_rules(parts, rules):
# 도면에서 읽은 부재 뒤에 🔶 부재를 붙이고, 모든 부재에 kind → id 순으로 color·alpha 를 덮어쓴다
out = parts + json.loads(json.dumps(rules["parts"]))
for p in out:
p.update(rules["style"].get(p["kind"], {}))
p.update(rules["ids"].get(p["id"], {}))
return out
# 29.2 도면 연결값: views.json 의 boxes 순서 (사람 입력) — 0 전체 단면, 1 평면도, 2 생략 단면, 3 슬래브 평면
SEC_BOX, PLAN_BOX, BREAK_BOX, SLAB_BOX = 0, 1, 2, 3
DRAWING_SRC = "private/drawing_gen.svg" # 웹이 읽는 SVG (make_model.sh 가 만든다)
BREAK_LONG = 10.0 # 생략 단면 세로 선: 길이 10 m 넘는 것
BREAK_LABEL_X = 8.0 # 층 글자: 축에서 8 m 안
BREAK_LINE_BELOW = 0.3 # 층 글자 아래 0.3 m 안의 구조물 수평선
BREAK_GROUND_TOL = 0.05 # 지반선이 축을 지나는 허용 (m), 지표와 같은 층 판정 허용
BREAK_TOP_PAD = 300 # z_upper_min = 위 구간 가장 깊은 윗면 − 300 mm
PLAN2_MAX = 30.0 # 평면도 2: 평면도 1 아래 30 m 안
PLAN_VIEW_LABELS = {"plan1": "평면도", "plan2": "평면도 2"}
SLAB_VIEW_LABELS = {"odd": "중간슬래브 (홀수층)", "even": "중간슬래브 (짝수층)",
"18B": "중간슬래브 (18B)", "19B": "중간슬래브 (19B)", "20B": "하부슬래브 (20B)"}
def svg_pt(pt, boxes, box):
# 도면 점 → SVG 점: 상자 place 로 (X − dx, dy − Y), 소수 둘째 자리
dx, dy = boxes[box]["place"]
return round(pt[0] - dx, 2), round(dy - pt[1], 2)
def title_x(ents, title):
for t in ents:
if t["type"] == "TEXT" and t["layer"] == TITLE_LAYER and t["text"].replace(" ", "") == title:
return t["pts"][0][0]
raise SystemExit(f"제목 글자 {title} 을 찾지 못했다")
def section_view(ents, walls, boxes, vid, label, gy_check=EPS):
# 20.1 ① / 24.2: 전체 단면의 축 X 와 그 축의 지표 Y
axis = walls["axis"]
gy = ground_y(ents, axis, gy_check)
x0, y0 = svg_pt((axis, gy), boxes, SEC_BOX)
return {"id": vid, "type": "section", "label": label, "x0": x0, "y0": y0}
def break_view(ents, tops, boxes, vid, label, title):
# 생략 단면: 「단면 X-X」 글자 가까운 구조물 세로선 4개 = 벽, 층 글자마다 지표에 해당하는 Y 로 위·아래 구간을 나눈다
tx = title_x(ents, title)
tall = {round(a[0], 2) for a, b, _ in segments_of(ents, WALL_LAYER)
if abs(a[0] - b[0]) <= EPS and abs(a[1] - b[1]) > BREAK_LONG}
xs = sorted(sorted(tall, key=lambda x: abs(x - tx))[:4])
if len(xs) < 4:
raise SystemExit(f"{title} 생략 단면의 벽 선 네 개를 찾지 못했다")
outer_l, inner_l, inner_r, outer_r = xs
axis = (outer_l + outer_r) / 2
gy = ground_y(ents, axis, BREAK_GROUND_TOL)
upper, lower = [], []
for t in ents:
m = re.fullmatch(r"(\d+)B", t["text"].replace(" ", "")) if t["type"] == "TEXT" else None
if not m or int(m.group(1)) > len(tops) or abs(t["pts"][0][0] - axis) > BREAK_LABEL_X:
continue # 19B 까지의 층 글자만 (20B 는 하부 슬래브)
lx, ly = t["pts"][0]
below = [a[1] for a, b, _ in segments_of(ents, WALL_LAYER)
if abs(a[1] - b[1]) <= EPS and abs(min(a[0], b[0]) - inner_l) <= EPS and ly - BREAK_LINE_BELOW <= a[1] <= ly]
if not below:
raise SystemExit(f"층 글자 {t['text']} 아래 수평선을 찾지 못했다")
top = tops[int(m.group(1)) - 1]
surface = max(below) - top / UNIT
(upper if abs(surface - gy) <= BREAK_GROUND_TOL else lower).append((top, surface))
if not upper or not lower:
raise SystemExit(f"{title} 위·아래 구간을 나누지 못했다")
x0, y0_upper = svg_pt((axis, gy), boxes, BREAK_BOX)
y0_lower = svg_pt((axis, sum(s for _, s in lower) / len(lower)), boxes, BREAK_BOX)[1]
return {"id": vid, "type": "section_break", "label": label, "x0": x0, "y0_upper": y0_upper,
"y0_lower": y0_lower, "z_upper_min": round(min(z for z, _ in upper) - BREAK_TOP_PAD),
"z_lower_max": round(max(z for z, _ in lower))}
def plan_views(ents, center, phi, boxes):
# 24.1: 평면도 1 = 「평면도」 아래 중심, 평면도 2 = 그 아래 30 m 안의 가장 가까운 반지름 4.85 원, 회전 = −φ
circles = [e["center"] for e in ents if e["type"] == "CIRCLE" and abs(e["r"] - R_IN) <= EPS]
below = [c for c in circles if c[1] < center[1] and math.dist(c, center) <= PLAN2_MAX]
if not below:
raise SystemExit("평면도 2 중심을 찾지 못했다")
p2 = min(below, key=lambda c: math.dist(c, center))
rot = round(-phi, 1)
cx1, cy1 = svg_pt(center, boxes, PLAN_BOX)
cx2, cy2 = svg_pt(p2, boxes, PLAN_BOX)
return [
{"id": "plan1", "type": "plan", "rot_deg": rot, "label": PLAN_VIEW_LABELS["plan1"], "cx": cx1, "cy": cy1, "tunnel": True},
{"id": "plan2", "type": "plan", "rot_deg": rot, "label": PLAN_VIEW_LABELS["plan2"], "cx": cx2, "cy": cy2, "tunnel": False},
]
def slab_views(plans, tops, floor_top, boxes):
# 20.1 ④: 슬래브 평면 다섯 개, z1 = 그 평면을 쓰는 층들의 윗면
spec = [
("slabOdd", "odd", list(tops[0:17:2]), "slab", False),
("slabEven", "even", list(tops[1:16:2]), "slab", False),
("slab18", "18B", [tops[17]], "slab", False),
("slab19", "19B", [tops[18]], "slab", True),
("slab20", "20B", [floor_top], "bottom", False),
]
out = []
for vid, key, z1, kind_, tunnel in spec:
cx, cy = svg_pt(plans[key], boxes, SLAB_BOX)
out.append({"id": vid, "type": "slabplan", "label": SLAB_VIEW_LABELS[key], "cx": cx, "cy": cy,
"kind": kind_, "z1": [round(z) for z in z1], "tunnel": tunnel})
return out
def drawing_views(ents, walls, tops, floor_top, phi, center, plans, vj):
# 29.2: 도면 연결값 — views.json 의 상자 배치로 도면 점을 SVG 점으로 옮긴다
boxes = vj["boxes"]
sec_a = section_view(ents, section_walls(ents, 0), boxes, "secA", "단면 A-A")
sec_b = section_view(ents, section_walls(ents, 4), boxes, "secB", "단면 B-B")
views = [sec_a, sec_b,
break_view(ents, tops, boxes, "secA2", "단면 A-A (4B~15B 생략)", "단면A-A"),
break_view(ents, tops, boxes, "secB2", "단면 B-B (4B~15B 생략)", "단면B-B")]
views += plan_views(ents, center, phi, boxes)
views += slab_views(plans, tops, floor_top, boxes)
return {
"src": DRAWING_SRC, "viewBox": vj["viewBox"], "fit": vj["fit"],
"section": {"x0": sec_a["x0"], "y0": sec_a["y0"], "mm_per_unit": 1000},
"plan": {"cx": views[4]["cx"], "cy": views[4]["cy"], "mm_per_unit": 1000},
"views": views,
}
def build_model(path, views_path):
ents = load_dxf(path)
walls = section_walls(ents)
gy = ground_y(ents, walls["axis"])
tops = slab_tops(ents, walls, gy)
plans = find_slab_plans(ents)
# 25.1: 벽 전체 수평선 → 바닥 콘크리트 밑면(가장 낮음), 하부 슬래브 밑면(둘째), 상부 슬래브 윗면(가장 높음)
levels = span_levels(ents, walls, gy)
top_z, floor_bot, lean_bot = levels[0], levels[-2], levels[-1]
top_bot = top_slab_bottom(ents, walls, gy, top_z)
floor_top = bottom_slab_top(ents, walls, gy)
parts = [{
"id": "wall", "name": "벽체", "kind": "wall", "type": "ring", "color": WALL_COLOR, "alpha": 0.25,
"cx": 0, "cy": 0, "z0": round(floor_bot), "z1": round(top_bot),
"r_out": round(walls["r_out"]), "r_in": round(walls["r_in"]),
}]
slabs, rails = [], []
holes_of = {key: plan_holes(ents, plans[key]) for key in plans}
rail_of = {key: plan_railings(ents, plans[key]) for key in plans}
# 25.5: 19B 패드와 침사구 (19B 슬래브 윗면 = 가장 낮은 슬래브 윗면)
pad_top, pad_r, pad_holes, sump = pad_and_sump(ents, walls, gy, plans["19B"], tops[18], holes_of["19B"])
for i, z1 in enumerate(tops):
name = f"{i + 1}B"
# 홀수층 1B·3B·…·17B, 짝수층 2B·…·16B, 18B, 19B (같은 평면을 공유)
key = name if name in ("18B", "19B") else ("odd" if (i + 1) % 2 else "even")
holes = holes_of[key] + (sump if name == "19B" else [])
slabs.append({
"id": f"slab_{name}", "name": f"{name} 슬래브", "kind": "slab", "type": "disc",
"color": SLAB_COLOR, "cx": 0, "cy": 0, "z0": round(z1 - SLAB_THICK * UNIT), "z1": round(z1),
"r": round(walls["r_in"]), "holes": holes,
})
if rail_of[key]:
rails.append({
"id": f"rail_{name}", "name": f"방호 난간 ({name} 윗면)", "kind": "railing", "type": "railing",
"color": RAIL_COLOR, "z": round(z1), "h": RAIL_H, "paths": rail_of[key],
})
pad = {"id": "pad_19B", "name": "19B 패드", "kind": "pad", "type": "disc", "color": PAD_COLOR,
"cx": 0, "cy": 0, "z0": round(tops[18]), "z1": pad_top, "r": pad_r, "holes": pad_holes}
bottom = {"id": "bottom_20B", "name": "하부 슬래브(20B)", "kind": "bottom", "type": "disc", "color": BOTTOM_COLOR,
"cx": 0, "cy": 0, "z0": round(floor_bot), "z1": round(floor_top), "r": round(walls["r_in"])}
lean = {"id": "lean_concrete", "name": "바닥콘크리트", "kind": "lean", "type": "disc", "color": LEAN_COLOR,
"cx": 0, "cy": 0, "z0": round(lean_bot), "z1": round(floor_bot), "r": round(walls["r_out"])}
stairs = stair_parts(ents, walls, gy, tops, holes_of)
tunnels = tunnel_parts(ents, walls, gy, plans["19B"])
# 24.1: 평면도 1 의 φ 와 분기구 다리, 24.2: A-A(분기구)·B-B(환기구) 높이
center = plan_center(ents)
tunnel_angle = plan_tunnel(ents, plans["19B"])["angle"]
phi, branch_leg = frame_angle(ents, center, section_tunnel(ents, walls, gy)["r_out"], tunnel_angle)
# 25.2·25.3: 상부 슬래브 구멍과 맨홀 목 (평면도 1 기준, φ 만큼 돌린다)
circles = plan1_circles(ents, center)
top = top_slab_part(walls, top_z, top_bot, center, phi, circles)
necks = neck_parts(ents, center, phi, top_z, circles)
# 25.6·25.7: 월류벽·집수조·침사조 (20B 평면, 단면 높이)
weir_top, low_top = weir_heights(ents, walls, gy, floor_top)
weirs, xa, xb = weir_parts(ents, walls, gy, plans["20B"], floor_top, weir_top, low_top)
pits = pit_parts(ents, walls, gy, floor_top, weir_top, xa, xb)
branches = branch_parts(ents, walls, center, phi, branch_leg, section_heights(ents, walls, gy))
vents = vent_parts(ents, walls, center, phi, section_heights(ents, section_walls(ents, 4), gy))
parts = parts[:1] + [top] + necks + slabs + [pad, bottom, lean] + stairs + tunnels + weirs + pits + branches + vents + rails
with open(views_path, encoding="utf-8") as f:
vj = json.load(f)
drawing = drawing_views(ents, walls, tops, floor_top, phi, center, plans, vj)
return {"units": "mm", "parts": apply_design_rules(parts, load_design_rules()), "drawing": drawing}
def main(argv):
if len(argv) not in (3, 4):
print("사용법: python3 tools/extract_from_dxf.py <도면.dxf> model_extracted.js [views.json]")
return 2
views_path = argv[3] if len(argv) == 4 else os.path.join(os.path.dirname(os.path.abspath(__file__)), "views.json")
model = build_model(argv[1], views_path)
with open(argv[2], "w", encoding="utf-8") as f:
f.write("const MODEL = " + json.dumps(model, ensure_ascii=False, indent=1) + ";\n")
print(f"완료: {argv[2]} (부재 {len(model['parts'])}개)")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))