Files
s-canvas/view_reconstructor.py
HYUNJUNGLEE b9342f6726 Import S-CANVAS source + iter=1~7 lint cleanup
S-CANVAS (Saman Corp.) — DXF + DEM + AI 기반 3D 조감도 생성 엔진.
~24k LOC Python (scanvas_maker.py 7072 LOC GUI + 구조물 파서/빌더 다수).

이 커밋은 7-iter cleanup이 적용된 상태로 import:
- F821 8 + B023 6: 비동기 lambda + except/loop 변수 캡처 NameError
  (Py3.13에서 reproduce 확인된 진짜 버그)
- RUF012 4 + RUF013 1: ClassVar / implicit Optional 명시화
- F811/B905/B904/F401/F841/W293/F541/UP/SIM/RUF/PLR 700+ cleanup/modernization

신규 파일:
- ruff.toml: target=py313, Korean unicode/저자 스타일/도메인 복잡도 무력화
- requirements-py313.txt: pyproj>=3.7, scipy>=1.14, numpy>=2.0.2 (Py3.13 wheel)
- .gitignore: gcp-key.json, 캐시, 백업, 생성 이미지 제외

검증: ruff 0 errors, py_compile 0 errors, import 33/33 OK on Py3.13.13.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 10:29:08 +09:00

917 lines
33 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""뷰 기반 통합 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 numpy as np
import pyvista as pv
from view_detector import ViewRegion
# ---------------------------------------------------------------------------
# 템플릿별 색상 / 키워드
# ---------------------------------------------------------------------------
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: str | None = None) -> ViewRegion | None:
"""주어진 타입의 뷰 중 가장 대표적인 것 선택.
선택 기준:
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) -> list | None:
"""뷰에서 구조물의 외곽선을 단일 폴리곤으로 추출.
전략 (실루엣 면적이 뷰 면적의 일정 비율 이상이어야 진짜 외곽으로 간주):
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)
elif _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) -> dict | None:
"""점들의 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) -> dict | None:
"""평면도가 가늘고 긴 구조물(옹벽 등)인지 감지.
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
elif _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
) -> tuple | None:
"""폴리곤(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
) -> tuple | None:
"""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
) -> tuple | None:
"""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) -> tuple | None:
"""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: str | None = 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: str | None,
plan_view: ViewRegion | None = None,
section_silhouette: list | None = None,
) -> tuple | None:
"""가용 실루엣들로부터 단일 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: str | None = 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 여수로 수문 설치도(12).dxf", "spillway_gate"),
("SAMPLE_CAD/12996710-M40-001 신설 취수탑 설비 설치도(12).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(" 메인 뷰:")
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()