#!/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))