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>
917 lines
33 KiB
Python
917 lines
33 KiB
Python
"""뷰 기반 통합 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 여수로 수문 설치도(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(" 메인 뷰:")
|
||
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()
|