"""뷰 기반 통합 3D 재구성. 핵심 원칙: 여러 뷰(평면도/정면도/측면도)는 같은 하나의 구조물을 다른 방향에서 본 서로 다른 투영(projection)이다. 따라서 N개의 뷰 → 1개의 통합 3D 구조물. 좌표계 매핑 (표준 토목 정투영): ┌──────────┬───────────┬───────────┬───────────────┐ │ 뷰 타입 │ 도면의 X │ 도면의 Y │ 3D 대응 (실물) │ ├──────────┼───────────┼───────────┼───────────────┤ │ 평면도 │ 실물 X │ 실물 Y │ XY (위에서 봄) │ │ 정면도 │ 실물 X │ 실물 Z │ XZ (앞에서 봄) │ │ 측면도 │ 실물 Y │ 실물 Z │ YZ (옆에서 봄) │ │ 단면도 │ 실물 X/Y │ 실물 Z │ 단면 프로파일 │ └──────────┴───────────┴───────────┴───────────────┘ 알고리즘: 1. 각 뷰 종류별로 가장 대표적인 뷰 1개씩 선택 (라벨/면적 기준) 2. 각 뷰에서 메인 실루엣(silhouette) 추출 (단일 폴리곤) 3. 실루엣들로부터 3D 구조물의 (X폭, Y깊이, Z높이) 결정 4. 가장 정보량이 많은 뷰를 base로 단일 메쉬 생성 - 평면도 있으면: 평면 외곽 → Z방향 extrude - 정면만: 정면 외곽 → Y방향 extrude (얕은 두께) - 측면만: 측면 외곽 → X방향 extrude """ from __future__ import annotations import math from typing import Optional import numpy as np import pyvista as pv from view_detector import ViewRegion from dxf_geometry import Shape # --------------------------------------------------------------------------- # 템플릿별 색상 / 키워드 # --------------------------------------------------------------------------- TEMPLATE_COLORS = { "spillway_gate": "#B8B5A8", "building": "#BDC3C7", "retaining_wall": "#7F8C8D", "bridge": "#95A5A6", "tunnel_portal": "#A8A59B", "generic": "#B8B5A8", } # 템플릿별 키워드 (메인 뷰 식별용) TEMPLATE_KEYWORDS = { "spillway_gate": ["여수로", "수문", "spillway", "gate"], "building": ["취수탑", "탑", "건물", "사무소", "관리", "tower", "building", "제수변실", "valve"], "retaining_wall": ["옹벽", "벽", "방벽", "wall"], "bridge": ["교량", "교", "bridge", "공도교"], "tunnel_portal": ["터널", "갱구", "tunnel", "portal"], } # --------------------------------------------------------------------------- # 기본 헬퍼: 폴리곤 면적/길이/외곽 추출 # --------------------------------------------------------------------------- def _polygon_area(points: list) -> float: """Shoelace 면적 (closed polygon 가정).""" if len(points) < 3: return 0.0 n = len(points) s = 0.0 for i in range(n): x1, y1 = points[i][0], points[i][1] x2, y2 = points[(i + 1) % n][0], points[(i + 1) % n][1] s += x1 * y2 - x2 * y1 return abs(s) / 2 def _polyline_length(points: list) -> float: if len(points) < 2: return 0.0 arr = np.array(points, dtype=np.float64) diffs = np.diff(arr, axis=0) return float(np.sum(np.linalg.norm(diffs, axis=1))) # --------------------------------------------------------------------------- # 메인 뷰 선택 (여러 평면도 중 어떤 게 진짜 평면도?) # --------------------------------------------------------------------------- def pick_main_view(views: list[ViewRegion], view_type: str, template_id: Optional[str] = None) -> Optional[ViewRegion]: """주어진 타입의 뷰 중 가장 대표적인 것 선택. 선택 기준: 1. 해당 타입이 1개면 그것 2. 라벨이 템플릿 키워드를 포함하면 우선 3. 그 외엔 가장 큰 영역 """ candidates = [v for v in views if v.view_type == view_type] if not candidates: return None if len(candidates) == 1: return candidates[0] # 키워드 매칭 우선 if template_id and template_id in TEMPLATE_KEYWORDS: keywords = TEMPLATE_KEYWORDS[template_id] for v in candidates: label = v.label_text.lower() for kw in keywords: if kw.lower() in label: return v # 가장 큰 영역 return max(candidates, key=lambda v: v.width * v.height) # --------------------------------------------------------------------------- # 뷰에서 메인 실루엣(silhouette) 추출 # --------------------------------------------------------------------------- def extract_main_silhouette(view: ViewRegion) -> Optional[list]: """뷰에서 구조물의 외곽선을 단일 폴리곤으로 추출. 전략 (실루엣 면적이 뷰 면적의 일정 비율 이상이어야 진짜 외곽으로 간주): 1. 충분히 큰 closed 폴리곤 (뷰 면적의 10% 이상) 중 최대 2. 그것도 없으면 작은 closed 폴리곤들의 합집합 bbox 3. convex hull (모든 점 기준) — 모든 LINE/ARC가 그리는 외곽 4. 위 모두 실패 시 뷰 영역 자체 사각형 반환은 로컬 좌표 (뷰 bbox 좌하단을 원점으로). """ local_shapes = view.get_local_shapes() if not local_shapes: return _view_rect_silhouette(view) view_area = max(view.width * view.height, 1.0) # === 1) 충분히 큰 closed 폴리곤 우선 === closed_candidates = [] for s in local_shapes: if not s.closed or len(s.points) < 3: continue a = _polygon_area(s.points) # 뷰 면적의 10% 이상 + 95% 이하 (프레임 잔재 제외) if view_area * 0.10 <= a <= view_area * 0.95: closed_candidates.append((a, s.points)) if closed_candidates: closed_candidates.sort(key=lambda x: -x[0]) return closed_candidates[0][1] # === 2) Convex hull 폴백: 모든 LINE/ARC/POLYLINE 점들의 외곽 === # 노이즈를 줄이기 위해 너무 작은 shape는 제외 significant_shapes = [] for s in local_shapes: if s.kind in ("polyline", "line", "arc", "circle"): # 길이/면적이 충분히 큰 것만 if s.closed: if _polygon_area(s.points) >= view_area * 0.001: significant_shapes.append(s) else: if _polyline_length(s.points) >= max(view.width, view.height) * 0.05: significant_shapes.append(s) if significant_shapes: try: from scipy.spatial import ConvexHull all_pts = [] for s in significant_shapes: all_pts.extend(s.points) if len(all_pts) >= 3: arr = np.array(all_pts) # 중복 제거 arr = np.unique(arr, axis=0) if len(arr) >= 3: hull = ConvexHull(arr) hull_pts = [tuple(arr[i]) for i in hull.vertices] # Hull이 너무 작으면 뷰 사각형 사용 hull_area = _polygon_area(hull_pts) if hull_area >= view_area * 0.10: return hull_pts except Exception: pass # === 3) 작은 closed들의 union bbox (마지막 수단 전 단계) === small_closed = [s.points for s in local_shapes if s.closed and len(s.points) >= 3 and _polygon_area(s.points) >= view_area * 0.001] if small_closed: # 모든 작은 closed의 점들을 모아 hull try: from scipy.spatial import ConvexHull all_pts = [] for pts in small_closed: all_pts.extend(pts) if len(all_pts) >= 3: arr = np.array(all_pts) arr = np.unique(arr, axis=0) if len(arr) >= 3: hull = ConvexHull(arr) return [tuple(arr[i]) for i in hull.vertices] except Exception: pass # === 4) 최종 폴백: 뷰 영역 사각형 자체 === return _view_rect_silhouette(view) def _view_rect_silhouette(view: ViewRegion) -> list: """뷰 영역 사각형을 실루엣으로 반환 (로컬 좌표).""" return [(0, 0), (view.width, 0), (view.width, view.height), (0, view.height)] # --------------------------------------------------------------------------- # Oriented Bounding Box (PCA 기반) — 가늘고 긴 구조 감지용 # --------------------------------------------------------------------------- def compute_oriented_bbox(points: list) -> Optional[dict]: """점들의 PCA로 oriented bounding box 계산. Returns: { "center": (x, y), "axis_long": (dx, dy), # 길이방향 단위벡터 "axis_short": (dx, dy), # 폭방향 단위벡터 "length": float, # 길이방향 길이 "width": float, # 폭방향 길이 "corners": [(x,y) × 4], # 사각형 4꼭지점 (반시계) "aspect_ratio": length/width, } """ if len(points) < 3: return None arr = np.array(points, dtype=np.float64) # 중복 점 제거 arr = np.unique(arr, axis=0) if len(arr) < 3: return None center = arr.mean(axis=0) centered = arr - center # 공분산 + 고유벡터 try: cov = np.cov(centered.T) eigenvals, eigenvecs = np.linalg.eigh(cov) except Exception: return None # 고유값 내림차순 정렬 (큰 게 길이축) idx = np.argsort(-eigenvals) eigenvecs = eigenvecs[:, idx] # 주축으로 점 투영 proj = centered @ eigenvecs long_min, long_max = float(proj[:, 0].min()), float(proj[:, 0].max()) short_min, short_max = float(proj[:, 1].min()), float(proj[:, 1].max()) length = long_max - long_min width = max(short_max - short_min, 0.01) # 4꼭지점 (주축 좌표계 → 원본 좌표계) local_corners = np.array([ [long_min, short_min], [long_max, short_min], [long_max, short_max], [long_min, short_max], ]) world_corners = local_corners @ eigenvecs.T + center return { "center": (float(center[0]), float(center[1])), "axis_long": (float(eigenvecs[0, 0]), float(eigenvecs[1, 0])), "axis_short": (float(eigenvecs[0, 1]), float(eigenvecs[1, 1])), "length": length, "width": width, "corners": [tuple(c) for c in world_corners], "aspect_ratio": length / width, } def is_elongated_structure(plan_view: ViewRegion, min_aspect: float = 2.5) -> Optional[dict]: """평면도가 가늘고 긴 구조물(옹벽 등)인지 감지. Returns: oriented bbox dict (is elongated인 경우) None (그렇지 않으면) """ local_shapes = plan_view.get_local_shapes() if not local_shapes: return None char_size = max(plan_view.width, plan_view.height) # 임계 길이를 매우 낮게 (전체 크기의 1%만 넘어도 포함) min_sig_len = char_size * 0.01 min_sig_area = (char_size ** 2) * 0.0001 significant_pts = [] for s in local_shapes: if s.kind not in ("polyline", "line", "arc", "circle"): continue if s.closed: if _polygon_area(s.points) < min_sig_area: continue else: if _polyline_length(s.points) < min_sig_len: continue significant_pts.extend(s.points) # 점이 충분치 않으면 모든 점 사용 if len(significant_pts) < 10: significant_pts = [] for s in local_shapes: significant_pts.extend(s.points) if len(significant_pts) < 5: return None obb = compute_oriented_bbox(significant_pts) if obb is None: return None if obb["aspect_ratio"] >= min_aspect: return obb return None def get_centerline_from_obb(obb: dict, n_samples: int = 2) -> list: """OBB의 길이방향 중심선 점 목록 반환.""" cx, cy = obb["center"] ax, ay = obb["axis_long"] half_L = obb["length"] / 2 centerline = [] for i in range(n_samples): t = -1 + 2 * i / max(n_samples - 1, 1) # -1 ~ 1 x = cx + ax * t * half_L y = cy + ay * t * half_L centerline.append((x, y)) return centerline def get_obb_polygon(obb: dict) -> list: """OBB 사각형을 polygon 점 목록으로 반환.""" return obb["corners"] def silhouette_bbox(silhouette: list) -> tuple: """실루엣의 bbox 반환 (xmin, ymin, xmax, ymax).""" arr = np.array(silhouette) return (float(arr[:, 0].min()), float(arr[:, 1].min()), float(arr[:, 0].max()), float(arr[:, 1].max())) # --------------------------------------------------------------------------- # 차원 결정: 뷰들로부터 실제 3D 구조물 (X, Y, Z) 크기 계산 # --------------------------------------------------------------------------- def compute_3d_dimensions(silhouettes: dict) -> dict: """뷰별 실루엣들로부터 3D 구조물의 실제 크기를 추정. Args: silhouettes: {"plan": [...], "front": [...], "side": [...]} Returns: {"width": X폭, "depth": Y깊이, "height": Z높이} """ width = depth = height = None # 평면도: 도면X=실물X, 도면Y=실물Y if "plan" in silhouettes: bb = silhouette_bbox(silhouettes["plan"]) width = bb[2] - bb[0] depth = bb[3] - bb[1] # 정면도: 도면X=실물X, 도면Y=실물Z if "front" in silhouettes: bb = silhouette_bbox(silhouettes["front"]) if width is None: width = bb[2] - bb[0] height = bb[3] - bb[1] # 측면도: 도면X=실물Y, 도면Y=실물Z if "side" in silhouettes: bb = silhouette_bbox(silhouettes["side"]) if depth is None: depth = bb[2] - bb[0] if height is None: height = bb[3] - bb[1] # 일관성 검증: width가 plan과 front 모두 있으면 더 큰 값 사용 (노이즈 대비) # (실제로는 같아야 함. 다르면 둘 중 더 큰 것이 정확할 가능성) # 그러나 단순함을 위해 우선 적용된 값 유지 return { "width": width if width and width > 0 else 10.0, "depth": depth if depth and depth > 0 else 10.0, "height": height if height and height > 0 else 5.0, } # --------------------------------------------------------------------------- # 메쉬 빌더 (저수준) # --------------------------------------------------------------------------- def _build_extrude_mesh(profile: list, base_z: float, height: float, color: str = "#BDC3C7", opacity: float = 1.0 ) -> Optional[tuple]: """폴리곤(2D)을 Z방향으로 extrude. profile: [(x, y), ...] 로컬 좌표 """ if not profile or len(profile) < 3: return None arr = np.array(profile) # 끝점 중복 제거 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]) for i in range(1, n - 1): faces.append([3, 0, i + 1, i]) faces.append([3, n, n + i, n + i + 1]) try: return (pv.PolyData(pts_3d, np.concatenate(faces)), color, opacity) except Exception: return None def _build_extrude_along_y(profile_xz: list, depth: float, base_y: float = 0, color: str = "#BDC3C7", opacity: float = 1.0 ) -> Optional[tuple]: """X-Z 프로파일을 Y방향으로 depth만큼 extrude. profile_xz: [(x, z), ...] 로컬 좌표 """ if not profile_xz or len(profile_xz) < 3: return None arr = np.array(profile_xz) if np.allclose(arr[0], arr[-1]): arr = arr[:-1] n = len(arr) if n < 3: return None front = np.zeros((n, 3)) back = np.zeros((n, 3)) for i, (x, z) in enumerate(arr): front[i] = [x, base_y, z] back[i] = [x, base_y + depth, z] all_pts = np.vstack([front, back]) 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: return (pv.PolyData(all_pts, np.concatenate(faces)), color, opacity) except Exception: return None def _build_extrude_along_x(profile_yz: list, width: float, base_x: float = 0, color: str = "#BDC3C7", opacity: float = 1.0 ) -> Optional[tuple]: """Y-Z 프로파일을 X방향으로 width만큼 extrude.""" if not profile_yz or len(profile_yz) < 3: return None arr = np.array(profile_yz) if np.allclose(arr[0], arr[-1]): arr = arr[:-1] n = len(arr) if n < 3: return None left = np.zeros((n, 3)) right = np.zeros((n, 3)) for i, (y, z) in enumerate(arr): left[i] = [base_x, y, z] right[i] = [base_x + width, y, z] all_pts = np.vstack([left, right]) 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: return (pv.PolyData(all_pts, np.concatenate(faces)), color, opacity) except Exception: return None def _build_wall_swept(obb: dict, section_2d: list, fallback_height: float, color: str = "#7F8C8D", opacity: float = 1.0) -> Optional[tuple]: """OBB 길이방향을 따라 단면(section)을 sweep. section_2d: [(perp_offset, z_height), ...] 단면 프로파일 점들 폭 방향 = OBB short axis, 높이 방향 = Z """ if not section_2d or len(section_2d) < 3: # 단면 없음 → 단순 OBB extrude return None # 단면 프로파일 정규화: x = 폭 (중심 0), y = 높이 (바닥 0) sec_arr = np.array(section_2d, dtype=np.float64) if np.allclose(sec_arr[0], sec_arr[-1]): sec_arr = sec_arr[:-1] sec_cx = (sec_arr[:, 0].min() + sec_arr[:, 0].max()) / 2 sec_y_min = sec_arr[:, 1].min() sec_normalized = [(p[0] - sec_cx, p[1] - sec_y_min) for p in sec_arr] # OBB의 길이방향 양 끝점 (월드 좌표) cx, cy = obb["center"] ax, ay = obb["axis_long"] nx, ny = obb["axis_short"] # 폭 방향 (단면 폭 방향) half_L = obb["length"] / 2 # 경로: 길이방향 양 끝 path_pts = [ (cx - ax * half_L, cy - ay * half_L), (cx + ax * half_L, cy + ay * half_L), ] # 각 경로 점에서 단면을 배치 n_sec = len(sec_normalized) n_path = len(path_pts) all_pts = [] for i, (px, py) in enumerate(path_pts): for u, z in sec_normalized: # 폭 방향 = OBB short axis wx = px + nx * u wy = py + ny * u all_pts.append([wx, wy, z]) pts_3d = np.array(all_pts) # 면 생성 faces = [] for i in range(n_path - 1): for j in range(n_sec): a = i * n_sec + j b = i * n_sec + (j + 1) % n_sec c = (i + 1) * n_sec + (j + 1) % n_sec d = (i + 1) * n_sec + j faces.append([3, a, b, c]) faces.append([3, a, c, d]) # 양끝 단면 닫기 (fan) for j in range(1, n_sec - 1): faces.append([3, 0, j, j + 1]) last_base = (n_path - 1) * n_sec faces.append([3, last_base, last_base + j + 1, last_base + j]) try: return (pv.PolyData(pts_3d, np.concatenate(faces)), color, opacity) except Exception: return None def _make_ground(width: float, depth: float, z: float = 0.0) -> tuple: """구조물 주변 지반.""" margin = max(width, depth) * 0.3 half_w = width / 2 + margin half_d = depth / 2 + margin pts = np.array([ [-half_w, -half_d, z], [half_w, -half_d, z], [half_w, half_d, z], [-half_w, half_d, z], ]) return (pv.PolyData(pts, np.array([4, 0, 1, 2, 3])), "#8B7D6B", 1.0) # --------------------------------------------------------------------------- # 실루엣 정규화 (좌표를 중심 원점으로) # --------------------------------------------------------------------------- def _center_silhouette(silhouette: list, axis: str = "xy") -> list: """실루엣의 좌표 원점을 정규화. axis="xy": 둘 다 중심으로 이동 (평면도용) axis="x_only": X만 중심, Y는 최소값을 0으로 (정면/측면 - Z가 바닥부터 시작) """ if not silhouette: return silhouette arr = np.array(silhouette) cx = (arr[:, 0].min() + arr[:, 0].max()) / 2 if axis == "xy": cy = (arr[:, 1].min() + arr[:, 1].max()) / 2 return [(p[0] - cx, p[1] - cy) for p in silhouette] else: # x_only: Y는 0부터 y_min = arr[:, 1].min() return [(p[0] - cx, p[1] - y_min) for p in silhouette] # --------------------------------------------------------------------------- # 메인 통합 재구성 함수 # --------------------------------------------------------------------------- def reconstruct_from_views(views: list[ViewRegion], template_id: Optional[str] = None ) -> list[tuple]: """검출된 뷰들로부터 단일 통합 3D 구조물 메쉬 생성. 핵심: 입력 뷰가 N개여도 출력은 하나의 구조물 (+ 지반). Args: views: detect_view_regions() 결과 template_id: "spillway_gate", "building", "retaining_wall" 등 Returns: [(mesh, color, opacity), (ground, color, opacity)] — 보통 2개 실패 시 빈 리스트 """ if not views: return [] # === 1) 메인 뷰 선택 (각 타입별 1개씩) === plan_view = pick_main_view(views, "plan", template_id) front_view = pick_main_view(views, "front", template_id) side_view = pick_main_view(views, "side", template_id) section_view = pick_main_view(views, "section", template_id) # cross_section / longitudinal도 section 카테고리로 폴백 if section_view is None: section_view = (pick_main_view(views, "cross_section", template_id) or pick_main_view(views, "longitudinal", template_id)) # 정면도 없으면 elevation_generic 또는 입면도 시도 if front_view is None: front_view = pick_main_view(views, "elevation_generic", template_id) # === 2) 각 뷰에서 메인 실루엣 추출 === silhouettes = {} if plan_view: sil = extract_main_silhouette(plan_view) if sil: silhouettes["plan"] = sil if front_view: sil = extract_main_silhouette(front_view) if sil: silhouettes["front"] = sil if side_view: sil = extract_main_silhouette(side_view) if sil: silhouettes["side"] = sil if section_view: sil = extract_main_silhouette(section_view) if sil: silhouettes["section"] = sil if not silhouettes: return [] # === 3) 3D 차원 계산 === dims = compute_3d_dimensions(silhouettes) # === 4) 단일 메쉬 빌드 === color = TEMPLATE_COLORS.get(template_id, "#B8B5A8") section_sil = silhouettes.get("section") main_mesh = _build_unified_structure(silhouettes, dims, color, template_id, plan_view=plan_view, section_silhouette=section_sil) if main_mesh is None: return [] # === 5) 지반 추가 === meshes = [main_mesh] # OBB이면 ground도 OBB 길이 기준 obb = is_elongated_structure(plan_view) if plan_view else None if obb: ground_w = obb["length"] * 1.2 ground_d = max(obb["width"] * 5, dims["height"] * 1.5) else: ground_w = dims["width"] ground_d = dims["depth"] meshes.append(_make_ground(ground_w, ground_d, z=-0.05)) return meshes def _build_unified_structure(silhouettes: dict, dims: dict, color: str, template_id: Optional[str], plan_view: Optional[ViewRegion] = None, section_silhouette: Optional[list] = None, ) -> Optional[tuple]: """가용 실루엣들로부터 단일 3D 구조물 메쉬 생성. 우선순위: 1. 평면도 있음 + elongated (옹벽) → sweep 또는 OBB extrude 2. 평면도 있음 → 평면 외곽 Z 방향 extrude 3. 정면도만 → Y방향 extrude 4. 측면도만 → X방향 extrude """ # === 케이스 1a: 옹벽 등 elongated 구조 (PCA로 감지) === # retaining_wall 템플릿이면 임계 낮춰 더 적극적으로 OBB 적용 obb = None if plan_view is not None: if template_id == "retaining_wall": obb = is_elongated_structure(plan_view, min_aspect=1.2) else: obb = is_elongated_structure(plan_view, min_aspect=2.5) if obb is not None: # 길이방향 정확한 사각형 OBB로 평면 외곽 사용 # 단면도 있으면 sweep, 없으면 OBB extrude if section_silhouette and template_id == "retaining_wall": return _build_wall_swept(obb, section_silhouette, dims["height"], color) else: # OBB 사각형을 평면 외곽으로 사용 → Z방향 extrude obb_corners = obb["corners"] # 중심을 원점으로 arr = np.array(obb_corners) cx = arr[:, 0].mean() cy = arr[:, 1].mean() centered_obb = [(p[0] - cx, p[1] - cy) for p in obb_corners] return _build_extrude_mesh(centered_obb, base_z=0, height=dims["height"], color=color) # === 케이스 1b: 평면도 일반 (closed polygon) === if "plan" in silhouettes: plan_pts = silhouettes["plan"] plan_centered = _center_silhouette(plan_pts, axis="xy") return _build_extrude_mesh(plan_centered, base_z=0, height=dims["height"], color=color) # === 케이스 2: 정면도만 (평면도 없음) === if "front" in silhouettes: front_pts = silhouettes["front"] front_centered = _center_silhouette(front_pts, axis="x_only") # Y 방향(깊이)는 측면도가 있으면 그 폭, 없으면 폭의 절반 정도 depth_for_extrude = dims["depth"] # 측면도가 없으면 폭의 1/3 정도로 추정 (얇은 두께) if "side" not in silhouettes: depth_for_extrude = max(dims["width"] * 0.3, 2.0) # 정면도 중심을 Y=0으로, depth/2 만큼 양쪽으로 return _build_extrude_along_y(front_centered, depth=depth_for_extrude, base_y=-depth_for_extrude / 2, color=color) # === 케이스 3: 측면도만 === if "side" in silhouettes: side_pts = silhouettes["side"] side_centered = _center_silhouette(side_pts, axis="x_only") width_for_extrude = dims["width"] if "front" not in silhouettes: width_for_extrude = max(dims["depth"] * 0.3, 2.0) return _build_extrude_along_x(side_centered, width=width_for_extrude, base_x=-width_for_extrude / 2, color=color) # === 케이스 4: 단면도만 === if "section" in silhouettes: sec_pts = silhouettes["section"] sec_centered = _center_silhouette(sec_pts, axis="x_only") # 단면을 X방향으로 width만큼 extrude return _build_extrude_along_x(sec_centered, width=dims["width"], base_x=-dims["width"] / 2, color=color) return None # --------------------------------------------------------------------------- # 진단 정보 (UI에 표시용) # --------------------------------------------------------------------------- def diagnose_views(views: list[ViewRegion], template_id: Optional[str] = None) -> dict: """뷰 검출/재구성 진단 정보 반환. Returns: { "main_views": {"plan": ..., "front": ..., "side": ...}, "silhouettes_extracted": {"plan": True, ...}, "dimensions": {"width": ..., "depth": ..., "height": ...}, "build_strategy": "plan_extrude" | "front_extrude" | ..., } """ info = { "main_views": {}, "silhouettes_extracted": {}, "dimensions": {"width": 0, "depth": 0, "height": 0}, "build_strategy": "none", "total_views": len(views), } plan_view = pick_main_view(views, "plan", template_id) front_view = pick_main_view(views, "front", template_id) side_view = pick_main_view(views, "side", template_id) section_view = pick_main_view(views, "section", template_id) if plan_view: info["main_views"]["plan"] = plan_view.label_text[:40] if front_view: info["main_views"]["front"] = front_view.label_text[:40] if side_view: info["main_views"]["side"] = side_view.label_text[:40] if section_view: info["main_views"]["section"] = section_view.label_text[:40] silhouettes = {} for name, v in [("plan", plan_view), ("front", front_view), ("side", side_view), ("section", section_view)]: if v: sil = extract_main_silhouette(v) info["silhouettes_extracted"][name] = sil is not None if sil: silhouettes[name] = sil if silhouettes: info["dimensions"] = compute_3d_dimensions(silhouettes) # OBB 정보 (옹벽 등 elongated) obb = None if plan_view is not None: if template_id == "retaining_wall": obb = is_elongated_structure(plan_view, min_aspect=1.2) else: obb = is_elongated_structure(plan_view, min_aspect=2.5) info["is_elongated"] = obb is not None if obb: info["obb_length"] = obb["length"] info["obb_width"] = obb["width"] info["obb_aspect"] = obb["aspect_ratio"] # OBB가 있으면 차원 갱신 info["dimensions"]["width"] = obb["length"] info["dimensions"]["depth"] = obb["width"] # 빌드 전략 결정 if obb is not None: if "section" in silhouettes and template_id == "retaining_wall": info["build_strategy"] = f"옹벽 sweep (길이 {obb['length']:.1f}m × 단면)" else: info["build_strategy"] = f"OBB extrude (길이 {obb['length']:.1f}m × 폭 {obb['width']:.1f}m × 높이)" elif "plan" in silhouettes: info["build_strategy"] = "평면도 → Z방향 extrude (높이는 정면/측면에서)" elif "front" in silhouettes: info["build_strategy"] = "정면도 → Y방향 extrude" elif "side" in silhouettes: info["build_strategy"] = "측면도 → X방향 extrude" elif "section" in silhouettes: info["build_strategy"] = "단면도 → X방향 extrude" else: info["build_strategy"] = "실루엣 추출 실패" return info # --------------------------------------------------------------------------- # 테스트 # --------------------------------------------------------------------------- if __name__ == "__main__": from pathlib import Path from view_detector import detect_view_regions samples = [ ("SAMPLE_CAD/1. 좌안옹벽 일반도 작성(2026.0109).dxf", "retaining_wall"), ("SAMPLE_CAD/12995740-M40-001 여수로 수문 설치도(1/2).dxf", "spillway_gate"), ("SAMPLE_CAD/12996710-M40-001 신설 취수탑 설비 설치도(1/2).dxf", "building"), ("SAMPLE_CAD/12996710-M43-002 신설 제수변실 설비 배치도.dxf", "building"), ] for path, tid in samples: print(f"\n=== {Path(path).name} ({tid}) ===") try: views = detect_view_regions(path) info = diagnose_views(views, tid) print(f" 뷰 {info['total_views']}개 검출") print(f" 메인 뷰:") for vt, label in info["main_views"].items(): ok = "✓" if info["silhouettes_extracted"].get(vt) else "✗" print(f" [{vt}] {ok} \"{label}\"") d = info["dimensions"] print(f" 3D 크기: W{d['width']:.1f}m × D{d['depth']:.1f}m × H{d['height']:.1f}m") print(f" 전략: {info['build_strategy']}") meshes = reconstruct_from_views(views, tid) print(f" 결과 메쉬: {len(meshes)}개") except Exception as e: print(f" 오류: {e}") import traceback traceback.print_exc()