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>
This commit is contained in:
2026-05-08 10:29:08 +09:00
parent 53d8b53c2f
commit b9342f6726
92 changed files with 3413501 additions and 0 deletions

916
view_reconstructor.py Normal file
View File

@@ -0,0 +1,916 @@
"""뷰 기반 통합 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()