"""구조물 3D 렌더링 템플릿 레지스트리. 모든 구조물 유형은 StructureTemplate을 상속하여 3개 메서드를 구현한다: 1. parse(dxf_paths) → StructureParams (DXF에서 파라미터 추출) 2. build_meshes(params) → list[(mesh, color, opacity)] 3. get_parameter_schema() → 파라미터 편집 UI용 메타데이터 레지스트리에 등록된 템플릿은 UI에서 드롭다운으로 선택 가능. """ from __future__ import annotations from abc import ABC, abstractmethod from dataclasses import dataclass, field from pathlib import Path from typing import Any import numpy as np import pyvista as pv # --------------------------------------------------------------------------- # 공통 데이터 구조 # --------------------------------------------------------------------------- @dataclass class StructureParams: """범용 구조물 파라미터. template_id로 어떤 템플릿에서 사용되는지 식별. params dict에 모든 수치/설정이 들어가며, 템플릿마다 의미가 다름. """ template_id: str = "generic_box" name: str = "Structure" params: dict = field(default_factory=dict) # 공통 메타데이터 source_files: list = field(default_factory=list) raw_annotations: list = field(default_factory=list) # [(text, x, y), ...] def get(self, key: str, default: Any = None) -> Any: return self.params.get(key, default) def set(self, key: str, value: Any): self.params[key] = value def update(self, other: dict): self.params.update(other) @dataclass class ParamField: """파라미터 편집 UI 필드 정의.""" name: str # internal key (e.g., "height") label: str # UI 표시 이름 (e.g., "높이") unit: str = "m" # 단위 default: float = 0.0 min_val: float = 0.0 max_val: float = 1000.0 param_type: str = "float" # "float" | "int" | "text" | "choice" choices: list = None # param_type="choice"일 때 description: str = "" # --------------------------------------------------------------------------- # 베이스 클래스 # --------------------------------------------------------------------------- class StructureTemplate(ABC): """모든 구조물 템플릿의 베이스 클래스.""" template_id: str = "" name_ko: str = "" description: str = "" icon_hint: str = "" # UI용 이모지/텍스트 힌트 (non-functional) # 이 템플릿이 요구하는 입력 파일 개수 (min, max, typical) required_files: tuple[int, int, int] = (1, 2, 1) # 뷰 기반 재구성 지원 여부 (기본 True) supports_view_based: bool = True @abstractmethod def get_parameter_schema(self) -> list[ParamField]: """파라미터 편집 UI용 필드 정의 목록.""" ... @abstractmethod def parse(self, dxf_paths: list[str]) -> StructureParams: """DXF 파일에서 파라미터 추출.""" ... @abstractmethod def build_meshes(self, params: StructureParams) -> list[tuple[pv.PolyData, str, float]]: """파라미터로 3D 메쉬 생성. (mesh, color, opacity) 튜플 리스트 반환.""" ... def default_params(self) -> dict: """스키마의 기본값 딕셔너리.""" return {f.name: f.default for f in self.get_parameter_schema()} def try_view_based_parse(self, dxf_paths: list[str]) -> dict: """뷰 검출을 시도하고 결과 메타를 반환. 각 템플릿의 parse()에서 호출하여 params에 저장. Returns: {"views": [...], "detected": bool, "views_count": int} """ if not self.supports_view_based: return {"views": [], "detected": False, "views_count": 0} try: from view_detector import detect_views_multi views = detect_views_multi(dxf_paths) return { "views": views, "detected": len(views) > 0, "views_count": len(views), } except Exception: return {"views": [], "detected": False, "views_count": 0} def try_view_based_meshes(self, params: StructureParams): """뷰 기반 3D 재구성 시도. Returns: list of (mesh, color, opacity) or None if failed. """ if not self.supports_view_based: return None views = params.params.get("_views") if not views: return None try: from view_reconstructor import reconstruct_from_views meshes = reconstruct_from_views(views, self.template_id) return meshes if meshes else None except Exception: return None # --------------------------------------------------------------------------- # 공통 유틸리티: 기본 형상 # --------------------------------------------------------------------------- def make_box(x0, x1, y0, y1, z0, z1) -> pv.PolyData: """축정렬 박스 메쉬.""" pts = np.array([ [x0, y0, z0], [x1, y0, z0], [x1, y1, z0], [x0, y1, z0], [x0, y0, z1], [x1, y0, z1], [x1, y1, z1], [x0, y1, z1], ]) faces = np.hstack([ [4, 0, 3, 2, 1], [4, 4, 5, 6, 7], [4, 0, 1, 5, 4], [4, 2, 3, 7, 6], [4, 1, 2, 6, 5], [4, 0, 4, 7, 3], ]) return pv.PolyData(pts, faces) def make_flat_rect(x0, x1, y0, y1, z) -> pv.PolyData: """수평 사각 평판.""" pts = np.array([ [x0, y0, z], [x1, y0, z], [x1, y1, z], [x0, y1, z], ]) return pv.PolyData(pts, np.array([4, 0, 1, 2, 3])) def make_cylinder(cx, cy, z0, z1, radius, n_sides=24) -> pv.PolyData: """세로 원기둥.""" cyl = pv.Cylinder( center=(cx, cy, (z0 + z1) / 2), direction=(0, 0, 1), radius=radius, height=(z1 - z0), resolution=n_sides, ) return cyl.extract_surface() # --------------------------------------------------------------------------- # 템플릿 구현: 여수로 수문 (기존 gate_3d_builder 래핑) # --------------------------------------------------------------------------- class SpillwayGateTemplate(StructureTemplate): template_id = "spillway_gate" name_ko = "여수로 수문 (래디얼)" description = "ogee 여수로 + 래디얼(Tainter) 수문 + 공도교 + 개폐장치" required_files = (1, 2, 2) # plan + section def get_parameter_schema(self) -> list[ParamField]: return [ ParamField("n_gates", "수문 개수", "문", 3, 1, 20, "int"), ParamField("gate_width", "수문 폭", "m", 15.0, 1.0, 50.0), ParamField("gate_height", "수문 높이", "m", 7.0, 1.0, 30.0), ParamField("pier_width", "교각 폭", "m", 4.85, 0.5, 10.0), ParamField("pier_length", "교각 길이", "m", 25.0, 2.0, 60.0), ParamField("el_gate_sill", "Gate Sill EL.", "m", 46.700, -100, 500), ParamField("el_weir_crest", "Weir Crest EL.", "m", 47.000, -100, 500), ParamField("el_gate_top", "Gate Top EL.", "m", 53.700, -100, 500), ParamField("el_trunnion_pin", "Trunnion EL.", "m", 50.200, -100, 500), ParamField("el_bridge_top", "Bridge Top EL.", "m", 56.000, -100, 500), ParamField("el_nhwl", "N.H.W.L", "m", 52.500, -100, 500), ParamField("el_mwl", "M.W.L", "m", 53.830, -100, 500), ParamField("el_lwl", "L.W.L", "m", 45.000, -100, 500), ParamField("el_upstream_bed", "상류 바닥 EL.", "m", 41.500, -100, 500), ParamField("el_downstream", "하류 바닥 EL.", "m", 44.000, -100, 500), # 부속 on/off 토글 (1=켜기, 0=끄기) — 사용자가 파서 자동검출을 오버라이드 가능 ParamField("has_service_bridge", "공도교 (1=켜기/0=끄기)", "", 0, 0, 1, "int"), ParamField("has_hoist_housings", "여수로 개폐장치 (1/0)", "", 1, 0, 1, "int"), ParamField("has_downstream_apron", "하류 에이프런 (1/0)", "", 1, 0, 1, "int"), ParamField("has_water_surface", "상류 수면 (1/0)", "", 1, 0, 1, "int"), # 공도교 위치 수동지정 — 4개 모두 x1>x0·y1>y0 유효하면 사용자 override로 사용 # 자동 검출값이 있으면 여기에 채워져 표시됨. 0 또는 역순이면 자동·parametric 사용 ParamField("bridge_x_start", "공도교 X 시작", "m", 0.0, -50, 500), ParamField("bridge_x_end", "공도교 X 끝", "m", 0.0, -50, 500), ParamField("bridge_y_start", "공도교 Y 시작(상류)", "m", 0.0, -50, 500), ParamField("bridge_y_end", "공도교 Y 끝(하류)", "m", 0.0, -50, 500), ] def parse(self, dxf_paths: list[str]) -> StructureParams: from gate_parser import parse_gate_dxf, GateParams plan = dxf_paths[0] section = dxf_paths[1] if len(dxf_paths) > 1 else None sp: GateParams = parse_gate_dxf(plan, section) # GateParams → StructureParams 변환 params = StructureParams(template_id=self.template_id, name="여수로 수문") params.source_files = sp.source_files params.raw_annotations = sp.raw_text_annotations params.params = { "n_gates": sp.n_gates, "gate_width": sp.gate_width, "gate_height": sp.gate_height, "pier_width": sp.pier_width, "pier_length": sp.pier_length, "el_gate_sill": sp.el_gate_sill, "el_weir_crest": sp.el_weir_crest, "el_gate_top": sp.el_gate_top, "el_trunnion_pin": sp.el_trunnion_pin, "el_bridge_top": sp.el_bridge_top, "el_mwl": sp.el_mwl, "el_nhwl": sp.el_nhwl, "el_lwl": sp.el_lwl, "el_upstream_bed": sp.el_upstream_bed, "el_downstream": sp.el_downstream, "total_span": sp.total_span, "gate_centers_x": sp.gate_centers_x, "ogee_profile": sp.ogee_profile, # Phase A: 부속 존재성 플래그 (schema에 int 토글로 노출, 1/0으로 저장) "has_service_bridge": int(bool(sp.has_service_bridge)), "has_hoist_housings": int(bool(sp.has_hoist_housings)), "has_downstream_apron": int(bool(sp.has_downstream_apron)), "has_water_surface": int(bool(sp.has_water_surface)), # Phase B': 실제 도면 기하 (pass-through, UI 편집 없음) "plan_outline_polygon": sp.plan_outline_polygon, "pier_plan_polygons": sp.pier_plan_polygons, "bridge_plan_bbox": sp.bridge_plan_bbox, "bridge_deck_thickness_m": sp.bridge_deck_thickness_m, # 방향 결정용 (FLOW 화살표 → plan_frame_angle) "plan_frame_angle_deg": sp.plan_frame_angle_deg, "flow_direction_2d": sp.flow_direction_2d, # 사용자 편집 가능한 bridge 위치 (파서가 추출한 bbox 값으로 초기화됨) "bridge_x_start": float(sp.bridge_x_start) if sp.bridge_x_start is not None else 0.0, "bridge_y_start": float(sp.bridge_y_start) if sp.bridge_y_start is not None else 0.0, "bridge_x_end": float(sp.bridge_x_end) if sp.bridge_x_end is not None else 0.0, "bridge_y_end": float(sp.bridge_y_end) if sp.bridge_y_end is not None else 0.0, } return params def build_meshes(self, params: StructureParams): from gate_parser import GateParams from gate_3d_builder import GateBuilder # StructureParams → GateParams 변환 # 사용자가 UI에서 수정한 has_* 플래그(int 1/0)를 bool로 변환 반영 sp = GateParams() for k, v in params.params.items(): if hasattr(sp, k): # 부속 존재 플래그는 int(1/0) ↔ bool 상호 변환 if k.startswith("has_") and isinstance(v, (int, float)): setattr(sp, k, bool(v)) else: setattr(sp, k, v) # 편집된 값이 있으면 반영, gate_centers_x와 total_span 재계산 pw = sp.pier_width gw = sp.gate_width if not sp.gate_centers_x or len(sp.gate_centers_x) != sp.n_gates: sp.gate_centers_x = [ pw + gw * 0.5 + i * (gw + pw) for i in range(sp.n_gates) ] sp.total_span = sp.n_gates * gw + (sp.n_gates + 1) * pw builder = GateBuilder(sp) return builder.build_all() # --------------------------------------------------------------------------- # 공통 헬퍼: 지오메트리 기반 추출 & 빌드 # --------------------------------------------------------------------------- def _center_points(pts_list: list[list[tuple]], bounds: tuple) -> list[list[tuple]]: """좌표들을 bbox 중심 기준으로 원점 보정.""" cx = (bounds[0] + bounds[2]) / 2 cy = (bounds[1] + bounds[3]) / 2 return [[(p[0] - cx, p[1] - cy) for p in poly] for poly in pts_list] def _extrude_polygon(pts_2d: list, base_z: float, height: float, color: str = "#BDC3C7", opacity: float = 1.0): """2D 폴리곤을 높이만큼 extrude하여 (mesh, color, opacity) 튜플 반환.""" if len(pts_2d) < 3: return None arr = np.array(pts_2d) # 중복 끝점 제거 if np.allclose(arr[0], arr[-1]): arr = arr[:-1] n = len(arr) if n < 3: return None pts_3d = np.zeros((2 * n, 3)) for i, (x, y) in enumerate(arr): pts_3d[i] = [x, y, base_z] pts_3d[i + n] = [x, y, base_z + height] faces = [] # 측면 for i in range(n): ni = (i + 1) % n faces.append([3, i, ni, ni + n]) faces.append([3, i, ni + n, i + n]) # 바닥/상부 (fan triangulation) for i in range(1, n - 1): faces.append([3, 0, i + 1, i]) faces.append([3, n, n + i, n + i + 1]) try: mesh = pv.PolyData(pts_3d, np.concatenate(faces)) return (mesh, color, opacity) except Exception: return None def _extrude_polyline_wall(pts_2d: list, base_z: float, height: float, thickness: float = 0.5, color: str = "#7F8C8D", opacity: float = 1.0): """열린 폴리라인을 두께 있는 수직 벽체로 extrude.""" if len(pts_2d) < 2: return None arr = np.array(pts_2d) # 각 세그먼트의 법선 방향으로 양쪽 오프셋 left_pts = [] right_pts = [] n_segs = len(arr) half_t = thickness / 2 for i in range(n_segs): # 세그먼트 방향 (중간 노드는 평균) if i == 0: d = arr[1] - arr[0] elif i == n_segs - 1: d = arr[-1] - arr[-2] else: d = arr[i + 1] - arr[i - 1] length = np.linalg.norm(d) if length < 1e-6: continue normal = np.array([-d[1] / length, d[0] / length]) left_pts.append(arr[i] + normal * half_t) right_pts.append(arr[i] - normal * half_t) if len(left_pts) < 2: return None # 폐합 폴리곤 (left + right 뒤집기) outline = left_pts + right_pts[::-1] return _extrude_polygon(outline, base_z, height, color, opacity) def _build_ground_plane(bounds: tuple, base_el: float = 0.0, margin: float = 0.3) -> tuple: """지반 평면 생성.""" minx, miny, maxx, maxy = bounds w = max(maxx - minx, 1.0) h = max(maxy - miny, 1.0) mx = w * margin my = h * margin cx = (minx + maxx) / 2 cy = (miny + maxy) / 2 g = make_flat_rect( -(w / 2 + mx), (w / 2 + mx), -(h / 2 + my), (h / 2 + my), base_el - 0.05, ) return (g, "#8B7D6B", 1.0) # --------------------------------------------------------------------------- # 템플릿 구현: 건축물 (Building) # --------------------------------------------------------------------------- class BuildingTemplate(StructureTemplate): template_id = "building" name_ko = "건축물 / 가설건물" description = "평면(폐합 폴리라인)에서 높이만큼 박스 extrude" required_files = (1, 1, 1) def get_parameter_schema(self) -> list[ParamField]: return [ ParamField("height", "건물 높이", "m", 5.0, 0.5, 200.0), ParamField("n_floors", "층수", "층", 1, 1, 100, "int"), ParamField("base_el", "지반 EL.", "m", 0.0, -100, 500), ParamField("min_area", "무시할 최소 면적 (m²)", "", 0.5, 0.0, 100.0), ParamField("max_buildings", "최대 건물 개수", "", 20, 1, 200, "int"), ParamField("use_view_based", "뷰 기반 재구성 사용", "", 1, 0, 1, "choice", choices=["끄기", "켜기"]), ] def parse(self, dxf_paths: list[str]) -> StructureParams: from detail_parser import DetailParser, dimensions_to_structure_params from dxf_geometry import extract_all params = StructureParams(template_id=self.template_id, name="건축물") params.source_files = dxf_paths params.params = self.default_params() # 뷰 검출 시도 view_info = self.try_view_based_parse(dxf_paths) params.set("_views", view_info["views"]) params.set("_views_detected", view_info["detected"]) # 1) 치수 텍스트 파싱 (높이 추출용) parser = DetailParser() all_dims = [] for p in dxf_paths: try: result = parser.parse(p) all_dims.extend(result.dimensions) params.raw_annotations.extend( [(d.raw_text, d.position[0], d.position[1]) for d in result.dimensions] ) except Exception: pass # DIMENSION에서 높이 값 후보 수집 (vertical만, sanity 범위) # 너무 작은/큰 값은 제외 (2m~100m만 건물 높이로 간주) h_candidates = [d.value for d in all_dims if d.param == "height" and 2.0 <= d.value <= 100.0] if h_candidates: # 중앙값 사용 (단일 튀는 값 영향 최소화) params.set("height", float(np.median(h_candidates))) # 2) 구조 지오메트리 추출 (단위 자동 감지) geom = extract_all(dxf_paths) params.set("_geom_unit", geom.detected_unit) params.set("_geom_bounds", geom.total_bounds) # 3) 건물 외곽 후보: closed shape들 min_area = params.get("min_area", 0.5) max_buildings = int(params.get("max_buildings", 20)) candidates = [s for s in geom.closed_shapes if s.area >= min_area] if not candidates: # 폐합이 없으면 가장 긴 열린 폴리라인 사용 longest = geom.longest_polyline() if longest and longest.length > 3.0: candidates = [longest] # 면적 내림차순, 상위 N개 candidates.sort(key=lambda s: -s.area) candidates = candidates[:max_buildings] outlines = [] for s in candidates: outlines.append({ "points": s.points, "closed": s.closed, "layer": s.layer, "area": s.area, }) params.set("outlines", outlines) return params def build_meshes(self, params: StructureParams): # 뷰 기반 재구성 우선 시도 use_view = int(params.get("use_view_based", 1)) == 1 if use_view and params.get("_views_detected", False): view_meshes = self.try_view_based_meshes(params) if view_meshes and len(view_meshes) >= 2: # 지반 포함 2개 이상 return view_meshes # Fallback: 기존 geometry-based height = params.get("height", 5.0) base_el = params.get("base_el", 0.0) outlines = params.get("outlines") or [] bounds = params.get("_geom_bounds", (0, 0, 20, 20)) meshes = [] # 원점 보정 (bbox 중심을 원점으로) cx = (bounds[0] + bounds[2]) / 2 cy = (bounds[1] + bounds[3]) / 2 if outlines: for outline in outlines: pts = outline["points"] centered = [(p[0] - cx, p[1] - cy) for p in pts] if outline["closed"]: result = _extrude_polygon(centered, base_el, height, "#BDC3C7") if result: meshes.append(result) else: result = _extrude_polyline_wall(centered, base_el, height, thickness=0.3, color="#BDC3C7") if result: meshes.append(result) else: box = make_box(-5, 5, -5, 5, base_el, base_el + height) meshes.append((box, "#BDC3C7", 1.0)) bounds_centered = (bounds[0] - cx, bounds[1] - cy, bounds[2] - cx, bounds[3] - cy) meshes.append(_build_ground_plane(bounds_centered, base_el)) return meshes # --------------------------------------------------------------------------- # 템플릿 구현: 옹벽 (Retaining Wall) # --------------------------------------------------------------------------- class RetainingWallTemplate(StructureTemplate): template_id = "retaining_wall" name_ko = "옹벽 / 방벽" description = "DXF 폴리라인 경로를 따라 수직 벽체 생성" required_files = (1, 1, 1) def get_parameter_schema(self) -> list[ParamField]: return [ ParamField("height", "벽체 높이", "m", 4.0, 0.5, 30.0), ParamField("thickness", "벽체 두께", "m", 0.5, 0.1, 3.0), ParamField("base_el", "바닥 EL.", "m", 0.0, -100, 500), ParamField("batter", "경사 (상단수축 비율)", "", 0.0, 0.0, 0.3), ParamField("min_length", "무시할 최소 길이 (m)", "", 5.0, 0.0, 100.0), ParamField("max_walls", "최대 벽체 개수", "", 10, 1, 100, "int"), ParamField("use_view_based", "뷰 기반 재구성 사용", "", 1, 0, 1, "choice", choices=["끄기", "켜기"]), ] def parse(self, dxf_paths: list[str]) -> StructureParams: from detail_parser import DetailParser, dimensions_to_structure_params from dxf_geometry import extract_all params = StructureParams(template_id=self.template_id, name="옹벽") params.source_files = dxf_paths params.params = self.default_params() # 뷰 검출 시도 view_info = self.try_view_based_parse(dxf_paths) params.set("_views", view_info["views"]) params.set("_views_detected", view_info["detected"]) # 치수 파싱 parser = DetailParser() all_dims = [] for p in dxf_paths: try: result = parser.parse(p) all_dims.extend(result.dimensions) params.raw_annotations.extend( [(d.raw_text, d.position[0], d.position[1]) for d in result.dimensions] ) except Exception: pass # 옹벽 높이: 1~30m 범위만 유효 h_candidates = [d.value for d in all_dims if d.param == "height" and 1.0 <= d.value <= 30.0] if h_candidates: params.set("height", float(np.median(h_candidates))) # 두께: 0.2~3m 범위 t_candidates = [d.value for d in all_dims if d.param == "thickness" and 0.2 <= d.value <= 3.0] if t_candidates: params.set("thickness", float(np.median(t_candidates))) # 지오메트리 추출: 옹벽은 보통 열린 폴리라인으로 표현됨 geom = extract_all(dxf_paths) params.set("_geom_bounds", geom.total_bounds) # 최소 길이 이상의 폴리라인/선을 모두 벽체 경로로 사용 min_len = params.get("min_length", 5.0) max_walls = int(params.get("max_walls", 10)) paths = [] for s in geom.shapes: if s.kind in ("polyline", "line") and s.length >= min_len: paths.append({ "points": s.points, "closed": s.closed, "layer": s.layer, "length": s.length, }) # 길이 내림차순 정렬 → 상위 N개만 paths.sort(key=lambda x: -x["length"]) paths = paths[:max_walls] params.set("wall_paths", paths) return params def build_meshes(self, params: StructureParams): # 뷰 기반 재구성 우선 시도 use_view = int(params.get("use_view_based", 1)) == 1 if use_view and params.get("_views_detected", False): view_meshes = self.try_view_based_meshes(params) if view_meshes and len(view_meshes) >= 2: return view_meshes h = params.get("height", 4.0) t = params.get("thickness", 0.5) base = params.get("base_el", 0.0) batter = params.get("batter", 0.0) paths = params.get("wall_paths") or [] bounds = params.get("_geom_bounds", (0, 0, 20, 20)) meshes = [] cx = (bounds[0] + bounds[2]) / 2 cy = (bounds[1] + bounds[3]) / 2 if paths: for path in paths: pts = path["points"] centered = [(p[0] - cx, p[1] - cy) for p in pts] if path["closed"]: result = _extrude_polygon(centered, base, h, "#7F8C8D") else: # 배터(경사)는 현재 버전에서 무시. 두께 있는 벽체로. result = _extrude_polyline_wall(centered, base, h, thickness=t, color="#7F8C8D") if result: meshes.append(result) else: # 폴백: 기본 사다리꼴 벽체 L = 20.0 t_top = t * (1 - batter) pts = np.array([ [0, -t / 2, base], [0, t / 2, base], [0, t_top / 2, base + h], [0, -t_top / 2, base + h], [L, -t / 2, base], [L, t / 2, base], [L, t_top / 2, base + h], [L, -t_top / 2, base + h], ]) faces = np.hstack([ [4, 0, 3, 2, 1], [4, 4, 5, 6, 7], [4, 0, 1, 5, 4], [4, 3, 7, 6, 2], [4, 0, 4, 7, 3], [4, 1, 2, 6, 5], ]) meshes.append((pv.PolyData(pts, faces), "#7F8C8D", 1.0)) # 지반 bounds_centered = (bounds[0] - cx, bounds[1] - cy, bounds[2] - cx, bounds[3] - cy) meshes.append(_build_ground_plane(bounds_centered, base)) return meshes # --------------------------------------------------------------------------- # 템플릿 구현: 교량 (Bridge) # --------------------------------------------------------------------------- class BridgeTemplate(StructureTemplate): template_id = "bridge" name_ko = "교량" description = "DXF에서 상판 외곽+교각 위치 추출하여 3D 교량 생성" required_files = (1, 1, 1) def get_parameter_schema(self) -> list[ParamField]: return [ ParamField("deck_width", "상판 폭", "m", 10.0, 3.0, 40.0), ParamField("deck_thickness", "상판 두께", "m", 1.2, 0.3, 3.0), ParamField("pier_height", "교각 높이", "m", 8.0, 2.0, 80.0), ParamField("pier_width", "교각 폭", "m", 1.5, 0.5, 6.0), ParamField("base_el", "지반 EL.", "m", 0.0, -100, 500), ParamField("n_fallback_spans", "외곽 없을 시 경간 수", "개", 3, 1, 20, "int"), ParamField("use_view_based", "뷰 기반 재구성 사용", "", 1, 0, 1, "choice", choices=["끄기", "켜기"]), ] def parse(self, dxf_paths: list[str]) -> StructureParams: import math as _math from detail_parser import DetailParser from dxf_geometry import extract_all params = StructureParams(template_id=self.template_id, name="교량") params.source_files = dxf_paths params.params = self.default_params() # 뷰 검출 view_info = self.try_view_based_parse(dxf_paths) params.set("_views", view_info["views"]) params.set("_views_detected", view_info["detected"]) # 치수 파싱 parser = DetailParser() all_dims = [] for p in dxf_paths: try: result = parser.parse(p) all_dims.extend(result.dimensions) params.raw_annotations.extend( [(d.raw_text, d.position[0], d.position[1]) for d in result.dimensions] ) except Exception: pass for d in all_dims: if d.param == "height" and d.value > 2: params.set("pier_height", d.value) elif d.param == "width" and d.value > 3: params.set("deck_width", d.value) # 지오메트리 geom = extract_all(dxf_paths) params.set("_geom_bounds", geom.total_bounds) # 상판 외곽 = 가장 긴 폴리라인 or 가장 큰 closed shape (둘 중 선택) longest = geom.longest_polyline() largest = geom.largest_closed() deck_outline = None if largest and largest.area > 5.0: deck_outline = { "points": largest.points, "closed": True, "length": largest.length, } elif longest and longest.length > 5.0: deck_outline = { "points": longest.points, "closed": longest.closed, "length": longest.length, } params.set("deck_outline", deck_outline) # 교각 위치: 작은 closed 폴리곤들 (교각 단면) piers = [] for s in geom.closed_shapes: if 0.2 <= s.area <= 30.0: # 교각 단면 크기 범위 piers.append({ "centroid": s.centroid, "area": s.area, }) # 중심점이 너무 가까운 것은 제거 (1m 이내) unique_piers = [] for p in piers: pcx, pcy = p["centroid"] if not any( _math.sqrt((pcx - u["centroid"][0]) ** 2 + (pcy - u["centroid"][1]) ** 2) < 1.0 for u in unique_piers ): unique_piers.append(p) params.set("pier_positions", unique_piers) return params def build_meshes(self, params: StructureParams): # 뷰 기반 재구성 우선 use_view = int(params.get("use_view_based", 1)) == 1 if use_view and params.get("_views_detected", False): view_meshes = self.try_view_based_meshes(params) if view_meshes and len(view_meshes) >= 2: return view_meshes deck_w = params.get("deck_width", 10.0) deck_t = params.get("deck_thickness", 1.2) pier_h = params.get("pier_height", 8.0) pier_w = params.get("pier_width", 1.5) base = params.get("base_el", 0.0) deck_outline = params.get("deck_outline") pier_positions = params.get("pier_positions") or [] bounds = params.get("_geom_bounds", (0, 0, 30, 10)) meshes = [] cx = (bounds[0] + bounds[2]) / 2 cy = (bounds[1] + bounds[3]) / 2 deck_top = base + pier_h + deck_t deck_bot = base + pier_h if deck_outline: # 실제 DXF 외곽으로 상판 생성 pts = [(p[0] - cx, p[1] - cy) for p in deck_outline["points"]] if deck_outline["closed"]: result = _extrude_polygon(pts, deck_bot, deck_t, "#95A5A6") if result: meshes.append(result) else: # 열린 폴리라인 → 폭 있는 상판 (벽체 방식) result = _extrude_polyline_wall(pts, deck_bot, deck_t, thickness=deck_w, color="#95A5A6") if result: meshes.append(result) # 교각: DXF에서 추출한 위치 if pier_positions: for p in pier_positions: px, py = p["centroid"] px -= cx py -= cy pier = make_box( px - pier_w / 2, px + pier_w / 2, py - pier_w / 2, py + pier_w / 2, base, deck_bot, ) meshes.append((pier, "#A8A59B", 1.0)) else: # 폴백: 기본 교량 n_spans = int(params.get("n_fallback_spans", 3)) span_L = 30.0 total_L = span_L * n_spans deck = make_box(0, total_L, -deck_w / 2, deck_w / 2, deck_bot, deck_top) meshes.append((deck, "#95A5A6", 1.0)) for i in range(n_spans + 1): px = i * span_L pier = make_box( px - pier_w / 2, px + pier_w / 2, -pier_w / 2, pier_w / 2, base, deck_bot, ) meshes.append((pier, "#A8A59B", 1.0)) # 지반 bounds_centered = (bounds[0] - cx, bounds[1] - cy, bounds[2] - cx, bounds[3] - cy) meshes.append(_build_ground_plane(bounds_centered, base)) return meshes # --------------------------------------------------------------------------- # 템플릿 구현: 터널 갱구 (Tunnel Portal) # --------------------------------------------------------------------------- class TunnelPortalTemplate(StructureTemplate): template_id = "tunnel_portal" name_ko = "터널 갱구" description = "DXF 외곽(갱구 형태) + 터널 단면(원형/박스) 자동 추출" required_files = (1, 1, 1) def get_parameter_schema(self) -> list[ParamField]: return [ ParamField("length", "터널 연장", "m", 30.0, 5.0, 500.0), ParamField("base_el", "갱구 바닥 EL.", "m", 0.0, -100, 500), ParamField("portal_thickness", "갱구 벽체 두께", "m", 2.0, 0.5, 8.0), ParamField("extrude_depth", "갱구 돌출 깊이", "m", 3.0, 0.5, 20.0), ParamField("use_view_based", "뷰 기반 재구성 사용", "", 1, 0, 1, "choice", choices=["끄기", "켜기"]), ] def parse(self, dxf_paths: list[str]) -> StructureParams: from detail_parser import DetailParser from dxf_geometry import extract_all params = StructureParams(template_id=self.template_id, name="터널 갱구") params.source_files = dxf_paths params.params = self.default_params() # 뷰 검출 view_info = self.try_view_based_parse(dxf_paths) params.set("_views", view_info["views"]) params.set("_views_detected", view_info["detected"]) # 치수 파싱 parser = DetailParser() all_dims = [] for p in dxf_paths: try: result = parser.parse(p) all_dims.extend(result.dimensions) params.raw_annotations.extend( [(d.raw_text, d.position[0], d.position[1]) for d in result.dimensions] ) except Exception: pass for d in all_dims: if d.param == "length" and d.value > 5: params.set("length", d.value) # 지오메트리 geom = extract_all(dxf_paths) params.set("_geom_bounds", geom.total_bounds) # 갱구 외곽: 가장 큰 closed shape largest = geom.largest_closed() if largest: params.set("portal_outline", { "points": largest.points, "closed": True, }) # 터널 단면: 두 번째로 큰 closed shape (또는 원) sorted_closed = sorted(geom.closed_shapes, key=lambda s: -s.area) tunnel_section = None if len(sorted_closed) >= 2: tunnel_section = sorted_closed[1] elif len(sorted_closed) == 1: tunnel_section = sorted_closed[0] if tunnel_section: params.set("tunnel_section", { "points": tunnel_section.points, "centroid": tunnel_section.centroid, "area": tunnel_section.area, }) return params def build_meshes(self, params: StructureParams): # 뷰 기반 재구성 우선 use_view = int(params.get("use_view_based", 1)) == 1 if use_view and params.get("_views_detected", False): view_meshes = self.try_view_based_meshes(params) if view_meshes and len(view_meshes) >= 2: return view_meshes length = params.get("length", 30.0) base = params.get("base_el", 0.0) ext_depth = params.get("extrude_depth", 3.0) portal = params.get("portal_outline") tunnel = params.get("tunnel_section") bounds = params.get("_geom_bounds", (0, 0, 20, 15)) meshes = [] cx = (bounds[0] + bounds[2]) / 2 cy = (bounds[1] + bounds[3]) / 2 if portal: # 갱구 외곽을 Y 방향으로 extrude (depth만큼) # 2D 평면도 → XZ 평면으로 재배치 (외곽이 정면도라 가정) pts_2d = portal["points"] # Z를 원본 Y로, Y를 extrude 방향으로 centered = [(p[0] - cx, 0, p[1]) for p in pts_2d] # (X, Y=0, Z=원본Y) # depth만큼 복제 centered_back = [(p[0], ext_depth, p[2]) for p in centered] # 앞/뒤 면을 잇는 prism 생성 if len(centered) >= 3: front_pts = np.array(centered) back_pts = np.array(centered_back) all_pts = np.vstack([front_pts, back_pts]) n = len(front_pts) faces = [] # 측면 for i in range(n): ni = (i + 1) % n faces.append([3, i, ni, ni + n]) faces.append([3, i, ni + n, i + n]) # 앞/뒤 면 for i in range(1, n - 1): faces.append([3, 0, i + 1, i]) faces.append([3, n, n + i, n + i + 1]) try: mesh = pv.PolyData(all_pts, np.concatenate(faces)) meshes.append((mesh, "#A8A59B", 1.0)) except Exception: pass # 터널 단면을 갱구에서 length만큼 Y 방향으로 extrude if tunnel: ts_pts = tunnel["points"] ts_centroid = tunnel["centroid"] tcx, tcy = ts_centroid[0] - cx, ts_centroid[1] # 단면을 Y 방향으로 extrude (ext_depth → ext_depth + length) front = [(p[0] - cx, ext_depth, p[1]) for p in ts_pts] back = [(p[0] - cx, ext_depth + length, p[1]) for p in ts_pts] if len(front) >= 3: all_pts = np.vstack([np.array(front), np.array(back)]) n = len(front) faces = [] for i in range(n): ni = (i + 1) % n faces.append([3, i, ni, ni + n]) faces.append([3, i, ni + n, i + n]) # 앞면만 닫음 (터널 어두운 입구) for i in range(1, n - 1): faces.append([3, 0, i + 1, i]) try: mesh = pv.PolyData(all_pts, np.concatenate(faces)) meshes.append((mesh, "#2C3E50", 1.0)) except Exception: pass # 폴백: 지오메트리 없으면 단순 박스 갱구 + 원형 터널 if not portal and not tunnel: pw = 20.0 ph = 12.0 portal_wall = make_box(-pw / 2, pw / 2, 0, ext_depth, base, base + ph) meshes.append((portal_wall, "#A8A59B", 1.0)) cyl = pv.Cylinder( center=(0, ext_depth + length / 2, base + 5), direction=(0, 1, 0), radius=4.0, height=length, resolution=32, ).extract_surface() meshes.append((cyl, "#2C3E50", 1.0)) # 지반 bounds_centered = (bounds[0] - cx, 0, bounds[2] - cx, ext_depth + length) meshes.append(_build_ground_plane(bounds_centered, base)) return meshes # --------------------------------------------------------------------------- # 템플릿 구현: 일반 (범용) # --------------------------------------------------------------------------- class GenericStructureTemplate(StructureTemplate): template_id = "generic" name_ko = "일반 / 범용" description = "DXF 모든 외곽을 높이만큼 extrude (단위 자동 감지)" required_files = (1, 2, 1) def get_parameter_schema(self) -> list[ParamField]: return [ ParamField("height", "높이", "m", 5.0, 0.1, 500.0), ParamField("base_el", "바닥 EL.", "m", 0.0, -100, 500), ParamField("render_mode", "렌더 모드", "", 0, 0, 2, "choice", choices=["closed만 extrude", "open도 벽체", "모든 요소 선"]), ParamField("min_area", "무시할 최소 면적 (m²)", "", 1.0, 0.0, 100.0), ParamField("wall_thickness", "열린 폴리라인 두께", "m", 0.3, 0.1, 2.0), ParamField("max_shapes", "최대 렌더 요소 수", "", 30, 1, 500, "int"), ParamField("use_view_based", "뷰 기반 재구성 사용", "", 1, 0, 1, "choice", choices=["끄기", "켜기"]), ] def parse(self, dxf_paths: list[str]) -> StructureParams: from detail_parser import DetailParser, dimensions_to_structure_params from dxf_geometry import extract_all params = StructureParams(template_id=self.template_id, name="일반 구조물") params.source_files = dxf_paths params.params = self.default_params() # 뷰 검출 view_info = self.try_view_based_parse(dxf_paths) params.set("_views", view_info["views"]) params.set("_views_detected", view_info["detected"]) # 치수 파싱 parser = DetailParser() all_dims = [] for p in dxf_paths: try: result = parser.parse(p) all_dims.extend(result.dimensions) params.raw_annotations.extend( [(d.raw_text, d.position[0], d.position[1]) for d in result.dimensions] ) except Exception: pass # 높이 후보: 0.5~100m 범위 h_candidates = [d.value for d in all_dims if d.param == "height" and 0.5 <= d.value <= 100.0] if h_candidates: params.set("height", float(np.median(h_candidates))) # 지오메트리 (단위 자동 정규화) geom = extract_all(dxf_paths) params.set("_geom_bounds", geom.total_bounds) params.set("_geom_unit", geom.detected_unit) # 모든 shape 저장 shapes_data = [] for s in geom.shapes: shapes_data.append({ "kind": s.kind, "points": s.points, "closed": s.closed, "layer": s.layer, "area": s.area, "length": s.length, }) params.set("shapes", shapes_data) return params def build_meshes(self, params: StructureParams): # 뷰 기반 재구성 우선 use_view = int(params.get("use_view_based", 1)) == 1 if use_view and params.get("_views_detected", False): view_meshes = self.try_view_based_meshes(params) if view_meshes and len(view_meshes) >= 2: return view_meshes h = params.get("height", 5.0) base = params.get("base_el", 0.0) render_mode = int(params.get("render_mode", 0)) min_area = params.get("min_area", 1.0) wall_t = params.get("wall_thickness", 0.3) max_shapes = int(params.get("max_shapes", 30)) shapes = params.get("shapes") or [] bounds = params.get("_geom_bounds", (0, 0, 20, 20)) meshes = [] cx = (bounds[0] + bounds[2]) / 2 cy = (bounds[1] + bounds[3]) / 2 # 중요도 기준 정렬 (면적 또는 길이) → 상위 max_shapes개만 def _importance(s): return s["area"] if s["closed"] else s["length"] shapes_sorted = sorted(shapes, key=_importance, reverse=True)[:max_shapes] # 색상 팔레트 (레이어별 구분) colors = ["#B8B5A8", "#A8C4D0", "#D4A373", "#8FBC8F", "#CDB79E", "#B0A59F", "#9A968C", "#BDC3C7"] for i, s in enumerate(shapes_sorted): centered = [(p[0] - cx, p[1] - cy) for p in s["points"]] color = colors[i % len(colors)] if render_mode == 0: # closed만 extrude if s["closed"] and s["area"] >= min_area: result = _extrude_polygon(centered, base, h, color) if result: meshes.append(result) elif render_mode == 1: # closed는 extrude, open은 벽체 if s["closed"] and s["area"] >= min_area: result = _extrude_polygon(centered, base, h, color) if result: meshes.append(result) elif not s["closed"] and len(centered) >= 2 and s["length"] >= 1.0: result = _extrude_polyline_wall(centered, base, h, wall_t, color) if result: meshes.append(result) else: # 모든 요소 선으로만 if len(centered) >= 2: pts_3d = np.array([[p[0], p[1], base + h / 2] for p in centered]) n = len(pts_3d) line = pv.PolyData(pts_3d, lines=np.concatenate([[n], np.arange(n)])) meshes.append((line, color, 1.0)) if not meshes: # 폴백 box = make_box(-5, 5, -5, 5, base, base + h) meshes.append((box, "#B8B5A8", 1.0)) # 지반 bounds_centered = (bounds[0] - cx, bounds[1] - cy, bounds[2] - cx, bounds[3] - cy) meshes.append(_build_ground_plane(bounds_centered, base)) return meshes # --------------------------------------------------------------------------- # 상세 템플릿: 취수탑 (Intake Tower) # --------------------------------------------------------------------------- class IntakeTowerTemplate(StructureTemplate): template_id = "intake_tower" name_ko = "취수탑" description = "L자 본체 + 수문N개 + 개폐장치 + 호이스트 + 점검구 + 계단" required_files = (1, 2, 2) supports_view_based = False # 자체 파서 사용 def get_parameter_schema(self) -> list[ParamField]: return [ ParamField("body_width", "본체 폭(X)", "m", 11.2, 3.0, 50.0), ParamField("body_depth", "본체 깊이(Y)", "m", 6.4, 2.0, 40.0), ParamField("body_bottom_el", "본체 바닥 EL.", "m", 39.0, 0, 500), ParamField("body_top_el", "본체 상단 EL.", "m", 57.2, 0, 500), ParamField("n_gates", "수문 개수", "문", 3, 1, 10, "int"), ParamField("gate_spacing_z", "수문 수직 간격", "m", 2.5, 0.5, 10.0), ParamField("gate_width", "수문 폭", "m", 2.0, 0.5, 8.0), ParamField("gate_height", "수문 높이", "m", 2.0, 0.5, 8.0), ParamField("actuator_radius", "개폐장치 반경", "m", 0.6, 0.2, 2.0), ParamField("has_hoist", "호이스트 포함", "", 1, 0, 1, "choice", choices=["X", "O"]), ParamField("hoist_rail_el", "호이스트 레일 EL.", "m", 56.0, 0, 500), ParamField("has_l_extension", "L자 연장부", "", 1, 0, 1, "choice", choices=["X", "O"]), ParamField("extension_length", "연장부 길이", "m", 14.5, 0, 100), ParamField("extension_width", "연장부 폭", "m", 6.4, 1, 40), ParamField("has_entry_stairs", "외부 출입계단", "", 1, 0, 1, "choice", choices=["X", "O"]), ParamField("stairs_width", "계단 폭", "m", 1.5, 0.5, 5.0), ParamField("stairs_side", "계단 위치", "", 0, 0, 3, "choice", choices=["좌", "우", "전", "후"]), ParamField("has_inspection_cover", "상단 점검구", "", 1, 0, 1, "choice", choices=["X", "O"]), ParamField("inspection_cover_size", "점검구 크기", "m", 2.5, 0.5, 10.0), ParamField("has_parapet", "상단 난간", "", 1, 0, 1, "choice", choices=["X", "O"]), ParamField("parapet_height", "난간 높이", "m", 1.1, 0.5, 2.5), ParamField("roof_thickness", "지붕 두께", "m", 0.5, 0.1, 2.0), ] def parse(self, dxf_paths: list[str]) -> StructureParams: from intake_tower_parser import parse_intake_tower it_params = parse_intake_tower(dxf_paths) params = StructureParams(template_id=self.template_id, name="취수탑") params.source_files = it_params.source_files params.raw_annotations = it_params.raw_annotations params.params = { "body_width": it_params.body_width, "body_depth": it_params.body_depth, "body_bottom_el": it_params.body_bottom_el, "body_top_el": it_params.body_top_el, "n_gates": len(it_params.gates), "gate_spacing_z": 2.5, "gate_width": it_params.gates[0].gate_width if it_params.gates else 2.0, "gate_height": it_params.gates[0].gate_height if it_params.gates else 2.0, "actuator_radius": it_params.gates[0].actuator_radius if it_params.gates else 0.6, "has_hoist": 1 if it_params.has_hoist else 0, "hoist_rail_el": it_params.hoist_rail_el, "has_l_extension": 1 if it_params.has_l_extension else 0, "extension_length": it_params.extension_length, "extension_width": it_params.extension_width, "has_entry_stairs": 1 if it_params.has_entry_stairs else 0, "stairs_width": it_params.stairs_width, "stairs_side": 0, "has_inspection_cover": 1 if it_params.has_inspection_cover else 0, "inspection_cover_size": it_params.inspection_cover_size, "has_parapet": 1 if it_params.has_parapet else 0, "parapet_height": it_params.parapet_height, "roof_thickness": it_params.roof_thickness, "_gates": it_params.gates, "_floor_elevations": it_params.floor_elevations, } return params def build_meshes(self, params: StructureParams): from intake_tower_parser import IntakeTowerParams, GatePosition from intake_tower_3d_builder import IntakeTowerBuilder it = IntakeTowerParams() it.body_width = params.get("body_width", 11.2) it.body_depth = params.get("body_depth", 6.4) it.body_bottom_el = params.get("body_bottom_el", 39.0) it.body_top_el = params.get("body_top_el", 57.2) it.has_hoist = bool(int(params.get("has_hoist", 1))) it.hoist_rail_el = params.get("hoist_rail_el", 56.0) it.has_l_extension = bool(int(params.get("has_l_extension", 1))) it.extension_length = params.get("extension_length", 14.5) it.extension_width = params.get("extension_width", 6.4) it.has_entry_stairs = bool(int(params.get("has_entry_stairs", 1))) it.stairs_width = params.get("stairs_width", 1.5) it.stairs_side = ["left", "right", "front", "back"][int(params.get("stairs_side", 0))] it.has_inspection_cover = bool(int(params.get("has_inspection_cover", 1))) it.inspection_cover_size = params.get("inspection_cover_size", 2.5) it.has_parapet = bool(int(params.get("has_parapet", 1))) it.parapet_height = params.get("parapet_height", 1.1) it.roof_thickness = params.get("roof_thickness", 0.5) # Gates: 기존 값 재사용 또는 파라미터로 재생성 existing_gates = params.get("_gates") if existing_gates and len(existing_gates) == int(params.get("n_gates", 3)): # Width/Height가 편집되었을 수 있음 → 업데이트 for g in existing_gates: g.gate_width = params.get("gate_width", g.gate_width) g.gate_height = params.get("gate_height", g.gate_height) g.actuator_radius = params.get("actuator_radius", g.actuator_radius) it.gates = existing_gates else: n = int(params.get("n_gates", 3)) dz = params.get("gate_spacing_z", 2.5) gw = params.get("gate_width", 2.0) gh = params.get("gate_height", 2.0) ar = params.get("actuator_radius", 0.6) el_base = it.body_bottom_el + 4 it.gates = [ GatePosition( index=i, center_x=(i - (n - 1) / 2) * 3, elevation=el_base + i * dz, actuator_radius=ar, gate_width=gw, gate_height=gh, label=f"수문{i+1}", ) for i in range(n) ] floor_els = params.get("_floor_elevations", []) it.floor_elevations = floor_els return IntakeTowerBuilder(it).build_all() # --------------------------------------------------------------------------- # 상세 템플릿: 제수변실 + 도수관로 # --------------------------------------------------------------------------- class ValveChamberTemplate(StructureTemplate): template_id = "valve_chamber" name_ko = "제수변실 / 도수관로" description = "실 본체 + 밸브N개 + 도수관 + 송수관 + 상단 뚜껑" required_files = (1, 2, 1) supports_view_based = False def get_parameter_schema(self) -> list[ParamField]: return [ ParamField("chamber_width", "실 폭(X)", "m", 27.0, 3.0, 80.0), ParamField("chamber_depth", "실 깊이(Y)", "m", 9.0, 2.0, 40.0), ParamField("wall_thickness", "벽 두께", "m", 0.6, 0.2, 2.0), ParamField("bottom_el", "바닥 EL.", "m", 21.0, 0, 500), ParamField("top_el", "상판 EL.", "m", 28.5, 0, 500), ParamField("n_valves", "밸브 개수", "개", 5, 1, 20, "int"), ParamField("main_conduit_diameter", "도수관 관경", "m", 1.0, 0.2, 3.0), ParamField("main_conduit_el", "도수관 EL.", "m", 22.0, 0, 500), ParamField("main_conduit_direction", "도수관 방향", "", 0, 0, 1, "choice", choices=["X방향", "Y방향"]), ParamField("external_pipe_length", "외부 관로 길이(legacy)", "m", 5.0, 0, 100), ParamField("upstream_pipe_length", "상류 도수관 길이", "m", 3.0, 0, 50), ParamField("downstream_pipe_length", "하류 송수관 길이", "m", 4.0, 0, 50), ParamField("has_inlet_branch", "상류 Y-분기", "", 1, 0, 1, "choice", choices=["X", "O"]), ParamField("branch_spread_m", "분기 상·하단 간격", "m", 4.4, 0.5, 20), ParamField("branch_angle_deg", "분기 합류각", "°", 35.0, 10, 70), ParamField("branch_trunk_length", "분기점 이전 도수관 길이", "m", 3.0, 0, 30), ParamField("has_hatch", "상단 뚜껑", "", 1, 0, 1, "choice", choices=["X", "O"]), ParamField("hatch_count", "뚜껑 개수", "개", 1, 1, 10, "int"), ParamField("hatch_size", "뚜껑 크기", "m", 1.0, 0.3, 3.0), ParamField("has_entry_stairs", "출입 계단", "", 1, 0, 1, "choice", choices=["X", "O"]), ] def parse(self, dxf_paths: list[str]) -> StructureParams: from valve_chamber_parser import parse_valve_chamber vc = parse_valve_chamber(dxf_paths) params = StructureParams(template_id=self.template_id, name="제수변실") params.source_files = vc.source_files params.raw_annotations = vc.raw_annotations params.params = { "chamber_width": vc.chamber_width, "chamber_depth": vc.chamber_depth, "wall_thickness": vc.chamber_wall_thickness, "bottom_el": vc.bottom_el, "top_el": vc.top_el, "n_valves": len(vc.valves), "main_conduit_diameter": vc.main_conduit_diameter, "main_conduit_el": vc.main_conduit_el, "main_conduit_direction": 0 if vc.main_conduit_direction == "X" else 1, "external_pipe_length": vc.external_pipe_length, "upstream_pipe_length": vc.upstream_pipe_length, "downstream_pipe_length": vc.downstream_pipe_length, "has_inlet_branch": 1 if vc.has_inlet_branch else 0, "branch_spread_m": vc.branch_spread_m, "branch_angle_deg": vc.branch_angle_deg, "branch_trunk_length": vc.branch_trunk_length, "has_hatch": 1 if vc.has_hatch else 0, "hatch_count": vc.hatch_count, "hatch_size": vc.hatch_size, "has_entry_stairs": 1 if vc.has_entry_stairs else 0, "_valves": vc.valves, "_pipes": vc.pipes, "_floor_elevations": vc.floor_elevations, } return params def build_meshes(self, params: StructureParams): from valve_chamber_parser import ValveChamberParams, Valve from valve_chamber_3d_builder import ValveChamberBuilder vc = ValveChamberParams() vc.chamber_width = params.get("chamber_width", 27.0) vc.chamber_depth = params.get("chamber_depth", 9.0) vc.chamber_wall_thickness = params.get("wall_thickness", 0.6) vc.bottom_el = params.get("bottom_el", 21.0) vc.top_el = params.get("top_el", 28.5) vc.main_conduit_diameter = params.get("main_conduit_diameter", 1.0) vc.main_conduit_el = params.get("main_conduit_el", 22.0) vc.main_conduit_direction = "X" if int(params.get("main_conduit_direction", 0)) == 0 else "Y" vc.external_pipe_length = params.get("external_pipe_length", 5.0) vc.upstream_pipe_length = params.get("upstream_pipe_length", 3.0) vc.downstream_pipe_length = params.get("downstream_pipe_length", 4.0) vc.has_inlet_branch = bool(int(params.get("has_inlet_branch", 1))) vc.branch_spread_m = params.get("branch_spread_m", 4.4) vc.branch_angle_deg = params.get("branch_angle_deg", 35.0) vc.branch_trunk_length = params.get("branch_trunk_length", 3.0) vc.has_hatch = bool(int(params.get("has_hatch", 1))) vc.hatch_count = int(params.get("hatch_count", 1)) vc.hatch_size = params.get("hatch_size", 1.0) vc.has_entry_stairs = bool(int(params.get("has_entry_stairs", 1))) existing_valves = params.get("_valves") or [] n_valves = int(params.get("n_valves", 5)) if existing_valves and len(existing_valves) == n_valves: vc.valves = existing_valves else: # n_valves에 맞춰 재생성 vc.valves = [] for i in range(n_valves): t = (i + 0.5) / n_valves - 0.5 v = Valve( index=i, name=f"V-{i+1}", valve_type="GATE", center_x=t * vc.chamber_width * 0.7, center_y=0, elevation=vc.bottom_el + 2, diameter=0.4, label=f"밸브{i+1}", ) vc.valves.append(v) # _pipes는 **도면 파싱 결과만** 반영. UI에서 시드를 추가해 없던 도수관을 # 만들어내지 않는다(사용자 지적: 도면에 없는 관로가 계속 생성되던 버그). existing_pipes = list(params.get("_pipes") or []) # UI 토글로 분기 기능을 끈 경우, 기존 M-301 분기 pipe들까지 완전히 제거. if not vc.has_inlet_branch: existing_pipes = [pp for pp in existing_pipes if not ("도수관" in pp.name or pp.name.startswith("M-301"))] vc.pipes = existing_pipes # 파싱된 도수관이 있을 때만 finalize를 돌려 분기 재생성 (시드 주입 없음). has_any_main = any(("도수관" in pp.name or pp.name.startswith("M-301")) for pp in vc.pipes) if has_any_main: from valve_chamber_parser import ValveChamberParser ValveChamberParser()._finalize(vc) vc.floor_elevations = params.get("_floor_elevations", []) return ValveChamberBuilder(vc).build_all() # --------------------------------------------------------------------------- # 상세 템플릿: 옹벽 (교체) # --------------------------------------------------------------------------- class DetailedRetainingWallTemplate(StructureTemplate): template_id = "retaining_wall" name_ko = "옹벽 (상세)" description = "사다리꼴 본체 sweep + 기초 slab + 앵커바 격자 + 파라펫 + 배수공" required_files = (1, 2, 1) supports_view_based = False def get_parameter_schema(self) -> list[ParamField]: return [ ParamField("total_length", "총 연장", "m", 100.0, 5.0, 500.0), ParamField("bottom_el", "바닥 EL.", "m", 41.5, 0, 500), ParamField("top_el", "상단 EL.", "m", 60.0, 0, 500), ParamField("avg_top_width", "상단 폭", "m", 0.6, 0.2, 3.0), ParamField("avg_bottom_width", "하단 폭", "m", 3.0, 0.5, 10.0), ParamField("base_slab_width", "기초 slab 폭", "m", 5.0, 1.0, 15.0), ParamField("base_slab_thickness", "기초 slab 두께", "m", 1.0, 0.3, 3.0), ParamField("front_batter_ratio", "전면 경사비 (1:N 중 1)", "", 0.05, 0.0, 0.3), ParamField("has_anchors", "배면 앵커바", "", 1, 0, 1, "choice", choices=["X", "O"]), ParamField("anchor_count", "앵커 개수", "개", 78, 0, 500, "int"), ParamField("anchor_spacing_h", "앵커 수평간격", "m", 3.0, 0.5, 10.0), ParamField("anchor_spacing_v", "앵커 수직간격", "m", 3.0, 0.5, 10.0), ParamField("anchor_length", "앵커 매입길이", "m", 12.0, 1, 30), ParamField("anchor_angle_deg", "앵커 경사각(°)", "", 15, 0, 45), ParamField("has_parapet", "상단 파라펫", "", 1, 0, 1, "choice", choices=["X", "O"]), ParamField("parapet_height", "파라펫 높이", "m", 1.1, 0.5, 2.5), ParamField("has_contraction_joints", "수축이음", "", 1, 0, 1, "choice", choices=["X", "O"]), ParamField("joint_spacing", "수축이음 간격", "m", 10.0, 3.0, 30.0), ParamField("has_weep_holes", "배수공", "", 1, 0, 1, "choice", choices=["X", "O"]), ParamField("weep_hole_spacing", "배수공 간격", "m", 3.0, 1.0, 10.0), ParamField("ground_level", "전면 지반 EL.", "m", 41.5, 0, 500), ] def parse(self, dxf_paths: list[str]) -> StructureParams: from retaining_wall_parser import parse_retaining_wall rw = parse_retaining_wall(dxf_paths) params = StructureParams(template_id=self.template_id, name="옹벽") params.source_files = rw.source_files params.raw_annotations = rw.raw_annotations params.params = { "total_length": rw.total_length, "bottom_el": rw.bottom_el, "top_el": rw.top_el, "avg_top_width": rw.avg_top_width, "avg_bottom_width": rw.avg_bottom_width, "base_slab_width": rw.base_slab_width, "base_slab_thickness": rw.base_slab_thickness, "front_batter_ratio": rw.front_batter_ratio, "has_anchors": 1 if rw.has_anchors else 0, "anchor_count": rw.anchor_count, "anchor_spacing_h": rw.anchor_spacing_h, "anchor_spacing_v": rw.anchor_spacing_v, "anchor_length": rw.anchor_length, "anchor_angle_deg": rw.anchor_angle_deg, "has_parapet": 1 if rw.has_parapet else 0, "parapet_height": rw.parapet_height, "has_contraction_joints": 1 if rw.has_contraction_joints else 0, "joint_spacing": rw.joint_spacing, "has_weep_holes": 1 if rw.has_weep_holes else 0, "weep_hole_spacing": rw.weep_hole_spacing, "ground_level": rw.ground_level, } return params def build_meshes(self, params: StructureParams): from retaining_wall_parser import RetainingWallParams from retaining_wall_3d_builder import RetainingWallBuilder rw = RetainingWallParams() for k in ["total_length", "bottom_el", "top_el", "avg_top_width", "avg_bottom_width", "base_slab_width", "base_slab_thickness", "front_batter_ratio", "anchor_count", "anchor_spacing_h", "anchor_spacing_v", "anchor_length", "anchor_angle_deg", "parapet_height", "joint_spacing", "weep_hole_spacing", "ground_level"]: if k in params.params: setattr(rw, k, params.get(k)) rw.has_anchors = bool(int(params.get("has_anchors", 1))) rw.has_parapet = bool(int(params.get("has_parapet", 1))) rw.has_contraction_joints = bool(int(params.get("has_contraction_joints", 1))) rw.has_weep_holes = bool(int(params.get("has_weep_holes", 1))) return RetainingWallBuilder(rw).build_all() # --------------------------------------------------------------------------- # 레지스트리 # --------------------------------------------------------------------------- class TemplateRegistry: """구조물 템플릿 레지스트리 (싱글톤 패턴).""" _instance = None _templates: dict[str, StructureTemplate] = {} def __new__(cls): if cls._instance is None: cls._instance = super().__new__(cls) cls._templates = {} cls._instance._register_defaults() return cls._instance def _register_defaults(self): # 상세 템플릿 우선 등록 (같은 template_id면 덮어씀) for tpl_cls in [ SpillwayGateTemplate, IntakeTowerTemplate, ValveChamberTemplate, DetailedRetainingWallTemplate, # 기존 RetainingWallTemplate 대체 BuildingTemplate, BridgeTemplate, TunnelPortalTemplate, GenericStructureTemplate, ]: tpl = tpl_cls() self._templates[tpl.template_id] = tpl def get(self, template_id: str) -> StructureTemplate | None: return self._templates.get(template_id) def list_all(self) -> list[StructureTemplate]: return list(self._templates.values()) def list_choices(self) -> list[tuple[str, str]]: """(template_id, name_ko) 쌍 목록.""" return [(t.template_id, t.name_ko) for t in self._templates.values()] # 모듈 레벨 편의 인스턴스 REGISTRY = TemplateRegistry() if __name__ == "__main__": print("등록된 구조물 템플릿:") for tid, name in REGISTRY.list_choices(): tpl = REGISTRY.get(tid) print(f" [{tid}] {name}") print(f" {tpl.description}") print(f" 파라미터 {len(tpl.get_parameter_schema())}개")