Files
s-canvas/structure_templates.py

1584 lines
67 KiB
Python

"""구조물 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())}")