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>
507 lines
20 KiB
Python
507 lines
20 KiB
Python
"""구조물 메쉬를 지형 위에 배치하는 유틸.
|
|
|
|
구조물 빌더들(intake_tower, valve_chamber, retaining_wall 등)은 구조물을
|
|
**원점 중심 + Z=구조물 설계 EL** 로컬 좌표계에서 생성한다.
|
|
|
|
이를 전체계획 평면도의 해당 구조물 위치에 배치하려면:
|
|
1. XY: 평면도 centroid로 평행이동
|
|
2. 회전: 평면 오리엔테이션(예: 옹벽 길이축)으로 Z축 기준 회전
|
|
3. Z: 옵션1) 구조물 bottom_el 그대로 유지 (설계 EL 기준)
|
|
옵션2) TIN 표면 고도로 이동 (지형에 내려앉힘)
|
|
|
|
사용법:
|
|
from structure_placement import apply_placement
|
|
|
|
placed = apply_placement(
|
|
meshes=template.build_meshes(params),
|
|
plan_centroid=(x, y),
|
|
rotation_deg=0.0,
|
|
z_mode="design", # or "terrain"
|
|
terrain_mesh=tin_mesh, # z_mode="terrain" 시 필요
|
|
z_offset=0.0,
|
|
)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
|
|
import numpy as np
|
|
import pyvista as pv
|
|
|
|
|
|
def rotate_points_z(points: np.ndarray, angle_rad: float,
|
|
cx: float = 0, cy: float = 0) -> np.ndarray:
|
|
"""Z축 기준 회전 (각 points[i] = [x, y, z])."""
|
|
cos_a = math.cos(angle_rad)
|
|
sin_a = math.sin(angle_rad)
|
|
out = points.copy()
|
|
dx = out[:, 0] - cx
|
|
dy = out[:, 1] - cy
|
|
out[:, 0] = cx + dx * cos_a - dy * sin_a
|
|
out[:, 1] = cy + dx * sin_a + dy * cos_a
|
|
return out
|
|
|
|
|
|
def interpolate_terrain_z(tin_mesh: pv.PolyData, xy: tuple[float, float],
|
|
origin: np.ndarray = None) -> float | None:
|
|
"""TIN 표면의 (x, y) 위치에서 Z값 보간.
|
|
|
|
Args:
|
|
tin_mesh: PyVista PolyData (TIN 메쉬, 로컬 좌표)
|
|
xy: 월드 좌표 (x, y)
|
|
origin: TIN의 원점 보정 offset [x, y, z] (scanvas_maker.self.origin)
|
|
|
|
Returns:
|
|
보간된 Z값 (월드 EL, m) 또는 None (범위 밖)
|
|
"""
|
|
if tin_mesh is None:
|
|
return None
|
|
|
|
try:
|
|
from scipy.interpolate import LinearNDInterpolator
|
|
pts = np.array(tin_mesh.points)
|
|
|
|
# origin 보정: xy를 TIN 로컬 좌표계로 변환
|
|
if origin is not None:
|
|
local_x = xy[0] - origin[0]
|
|
local_y = xy[1] - origin[1]
|
|
else:
|
|
local_x, local_y = xy[0], xy[1]
|
|
|
|
interp = LinearNDInterpolator(pts[:, :2], pts[:, 2])
|
|
z_local = interp(local_x, local_y)
|
|
|
|
if np.isnan(z_local):
|
|
# 범위 밖 → 중앙값 사용
|
|
z_local = float(np.median(pts[:, 2]))
|
|
|
|
# origin Z 복원 (월드 EL로)
|
|
if origin is not None:
|
|
return float(z_local) + origin[2]
|
|
return float(z_local)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def apply_placement(
|
|
meshes: list[tuple[pv.PolyData, str, float]],
|
|
plan_centroid: tuple[float, float],
|
|
rotation_deg: float = 0.0,
|
|
z_mode: str = "design",
|
|
terrain_mesh: pv.PolyData | None = None,
|
|
terrain_origin: np.ndarray | None = None,
|
|
z_offset: float = 0.0,
|
|
structure_bottom_el: float | None = None,
|
|
skip_ground: bool = False,
|
|
scale: float = 1.0,
|
|
skip_terrain: bool = False,
|
|
pad_surface_z: float | None = None,
|
|
embed_offset: float = 0.02,
|
|
) -> list[tuple[pv.PolyData, str, float]]:
|
|
"""구조물 메쉬 리스트를 지형 위 해당 위치에 배치.
|
|
|
|
Args:
|
|
meshes: 템플릿이 생성한 로컬 좌표 메쉬들
|
|
plan_centroid: 평면도에서 구조물의 중심 월드 좌표 (x, y)
|
|
rotation_deg: Z축 기준 회전 (도). 옹벽 등 선형 구조물 방향 맞춤용
|
|
z_mode:
|
|
- "design": 구조물 설계 EL 그대로 (Z 이동 없음)
|
|
- "terrain": TIN 표면 고도에 bottom_el이 오도록 이동
|
|
- "offset": z_offset만큼 이동
|
|
terrain_mesh: z_mode="terrain"일 때 사용할 TIN
|
|
terrain_origin: TIN의 origin 보정값
|
|
z_offset: z_mode="offset"일 때 적용할 Z 이동량
|
|
structure_bottom_el: 구조물 바닥 EL (z_mode="terrain"에서 지형에 맞출 때)
|
|
skip_ground: True면 구조물 자체 "ground" 메쉬 제외 (지형 TIN과 중복 방지)
|
|
scale: XY 방향 균등 스케일 (Geo-Referencing 결과). 1.0이면 무변화.
|
|
Z는 의도적으로 유지 (구조물 설계 EL 보존).
|
|
skip_terrain: True면 물/지면/에이프런/뒤채움 관련 메쉬 전부 제외
|
|
(geo_referencing.EXCLUDE_COLORS 집합 사용)
|
|
|
|
Returns:
|
|
변환된 (mesh, color, opacity) 리스트
|
|
"""
|
|
if not meshes:
|
|
return []
|
|
|
|
# 모든 좌표를 TIN 로컬 좌표계로 통일
|
|
# 템플릿 메쉬: XY=(0,0) 중심, Z=설계 EL (월드)
|
|
# TIN 메쉬: origin이 빠진 로컬 좌표
|
|
# → plan_centroid(월드) - origin = 로컬 배치 위치
|
|
origin = terrain_origin if terrain_origin is not None else np.zeros(3)
|
|
|
|
# XY 이동량: 월드 → 로컬
|
|
dx = plan_centroid[0] - origin[0]
|
|
dy = plan_centroid[1] - origin[1]
|
|
|
|
# Z 이동량
|
|
dz = 0.0
|
|
if z_mode == "terrain" and structure_bottom_el is not None:
|
|
if pad_surface_z is not None:
|
|
z_local = float(pad_surface_z)
|
|
elif terrain_mesh is not None:
|
|
# TIN 로컬 좌표에서 직접 보간 (interpolate_terrain_z는 월드 EL 반환 → 사용 안 함)
|
|
from scipy.interpolate import LinearNDInterpolator
|
|
tin_pts = np.array(terrain_mesh.points)
|
|
interp = LinearNDInterpolator(tin_pts[:, :2], tin_pts[:, 2])
|
|
z_local = float(interp(dx, dy))
|
|
if np.isnan(z_local):
|
|
z_local = float(np.median(tin_pts[:, 2]))
|
|
else:
|
|
z_local = None
|
|
if z_local is not None:
|
|
# 구조물 바닥 EL(설계 월드값)을 TIN 로컬 표면에 맞춤
|
|
# + embed_offset만큼 아래로 → TIN이 구조물 underside를 가림
|
|
dz = z_local - structure_bottom_el - float(embed_offset)
|
|
elif z_mode == "offset":
|
|
dz = z_offset
|
|
|
|
angle_rad = math.radians(rotation_deg)
|
|
|
|
out = []
|
|
ground_colors = {"#8B7D6B", "#7F6F5F"} # ground 색상
|
|
|
|
# 물/지면류 전체 제외용 색상 집합 (geo_referencing.EXCLUDE_COLORS와 동일)
|
|
terrain_exclude_colors = {
|
|
"#3A7AA8", "#7F6F5F", "#8B7D6B", "#9A968C", "#8B7355",
|
|
}
|
|
|
|
def _norm_color(c):
|
|
if not isinstance(c, str):
|
|
return ""
|
|
s = c.strip().upper()
|
|
if not s.startswith("#"):
|
|
s = "#" + s
|
|
return s
|
|
|
|
# CW quad 보정 — apply_placement은 centroid/rotation 기반 폴백이므로
|
|
# plan_centroid만 주어진 경우엔 Y-flip이 무의미함. 하지만 rotation_deg가
|
|
# CW convention으로 주어졌다고 가정하면 y_sign 적용.
|
|
# 단순화를 위해 apply_placement은 현재 CW Y-flip은 적용하지 않음.
|
|
|
|
for mesh, color, opacity in meshes:
|
|
norm = _norm_color(color)
|
|
# ground 메쉬 건너뛰기 (지형이 있으면 중복)
|
|
if skip_ground and norm in {c.upper() for c in ground_colors}:
|
|
continue
|
|
# 물/지면/apron/backfill 전부 건너뛰기 (미리보기·최종 배치용)
|
|
if skip_terrain and norm in {c.upper() for c in terrain_exclude_colors}:
|
|
continue
|
|
|
|
try:
|
|
new_pts = np.array(mesh.points).copy()
|
|
|
|
# 1) XY 스케일 (Z 보존)
|
|
if abs(scale - 1.0) > 1e-6:
|
|
new_pts[:, 0] *= scale
|
|
new_pts[:, 1] *= scale
|
|
|
|
# 2) 회전 (원점 기준)
|
|
if abs(rotation_deg) > 0.01:
|
|
new_pts = rotate_points_z(new_pts, angle_rad, cx=0, cy=0)
|
|
|
|
# 3) 평행이동 (XY + Z)
|
|
new_pts[:, 0] += dx
|
|
new_pts[:, 1] += dy
|
|
new_pts[:, 2] += dz
|
|
|
|
new_mesh = mesh.copy()
|
|
new_mesh.points = new_pts
|
|
out.append((new_mesh, color, opacity))
|
|
except Exception:
|
|
# 개별 메쉬 변환 실패시 건너뜀
|
|
continue
|
|
|
|
return out
|
|
|
|
|
|
def fit_meshes_to_quad(
|
|
meshes: list[tuple[pv.PolyData, str, float]],
|
|
quad_world_pts: list,
|
|
terrain_mesh: pv.PolyData | None = None,
|
|
terrain_origin: np.ndarray | None = None,
|
|
structure_bottom_el: float | None = None,
|
|
z_mode: str = "terrain",
|
|
z_offset: float = 0.0,
|
|
skip_ground: bool = True,
|
|
skip_terrain: bool = True,
|
|
scale_mode: str = "none",
|
|
pad_surface_z: float | None = None,
|
|
embed_offset: float = 0.02,
|
|
detail_quad_pts: list | None = None,
|
|
plan_frame_angle_deg: float = 0.0,
|
|
flip_y_for_cw_quad: bool = True,
|
|
) -> list[tuple[pv.PolyData, str, float]]:
|
|
"""3D 메쉬를 TIN 평면도의 4점 사각형에 **강제 맞춤** (anisotropic scale).
|
|
|
|
동작(모든 모드 공통):
|
|
1. 메쉬 XY bbox 중심을 원점으로 이동
|
|
2. scale_mode에 따라 스케일 적용
|
|
3. quad 첫 엣지 각도로 XY 회전
|
|
4. quad 중심(TIN 로컬)으로 XY 이동
|
|
5. z_mode에 따라 Z 이동 (기본: 메쉬 z_min을 TIN 표면에 맞춤)
|
|
|
|
Args:
|
|
meshes: 템플릿 빌드 결과 (mesh, color_hex, opacity)
|
|
quad_world_pts: TIN 평면도 4점 (시계방향, 월드 좌표)
|
|
terrain_mesh: TIN PolyData (z_mode="terrain"일 때 표면 Z 보간)
|
|
terrain_origin: TIN 로컬 좌표 변환용 origin (self.origin)
|
|
structure_bottom_el: 호환용 (실제로는 스케일 후 mesh z_min 사용)
|
|
z_mode: "terrain"(표면 안착) | "design"(Z 유지) | "offset"(z_offset)
|
|
skip_ground: True면 ground 색상 메쉬 제외
|
|
skip_terrain: True면 물/지면/apron/backfill 색상 전체 제외
|
|
scale_mode:
|
|
- "none" (기본/권장): 스케일 안 함. 구조물 설계 크기 유지.
|
|
- "xy_only": quad에 맞춰 X/Y만 anisotropic 스케일 (Z 보존).
|
|
- "xyz_uniform": sqrt(sx·sy) 균등 스케일을 X/Y/Z 모두에 적용
|
|
+ [0.01, 100] 범위로 clamp (extreme 단위 mismatch 방지)
|
|
pad_surface_z: 굴착 pad 표면 Z (TIN 로컬). 지정 시 quad 중심 보간값 대신
|
|
이 값을 z_surface로 사용 (굴착 영역 내부는 평탄 pad이므로 quad 중심
|
|
보간이 아주 약간 흔들릴 수 있는 경우에도 안정).
|
|
embed_offset: 구조물 바닥 z_min을 표면보다 이만큼 **아래로** 내림 (m).
|
|
TIN(pad 표면)이 구조물의 아래면보다 살짝 위에 있어야 아래에서 위로
|
|
봤을 때 TIN이 underside를 가림 (z-fighting 및 관통 방지).
|
|
기본 0.02m(2cm) — bedding/거푸집 두께 수준으로 물리적으로도 자연스러움.
|
|
detail_quad_pts: 구조물 상세 평면도의 4점 (사용자가 시계방향으로 picked).
|
|
지정 시 detail ↔ TIN의 **상대 회전**을 사용하여 detail이 DXF 원본에서
|
|
비수평으로 그려진 경우도 올바르게 처리. 없으면 절대 angle(TIN only) 폴백.
|
|
plan_frame_angle_deg: detail DXF 내에서 mesh의 +X축이 향하는 각도(도).
|
|
파서가 plan outline의 PCA 주축을 추출해 전달. mesh는 이 각도만큼 먼저
|
|
회전되어 detail의 span 방향에 정렬된 후 detail→TIN 전체 변환이 적용됨.
|
|
0 = 이미 detail의 +X를 따르는 경우.
|
|
flip_y_for_cw_quad: True면 회전 전 mesh의 Y를 반전. 사용자가 picks를
|
|
**시계방향**으로 찍었을 때 기본 회전만으로는 mesh의 +Y가 quad의 edge 3→0
|
|
방향(반시계)으로 가버려 앞뒤가 뒤집힌다. Y-flip으로 mesh +Y가 edge 1→2
|
|
방향(사용자 의도의 "downward/downstream")을 따르도록 보정.
|
|
|
|
Returns:
|
|
변환된 (mesh, color, opacity) 리스트 (좌표 = TIN 로컬)
|
|
"""
|
|
if not meshes or not quad_world_pts or len(quad_world_pts) < 4:
|
|
return []
|
|
|
|
# 색상 필터
|
|
ground_colors = {"#8B7D6B", "#7F6F5F"}
|
|
terrain_colors = {"#3A7AA8", "#7F6F5F", "#8B7D6B", "#9A968C", "#8B7355"}
|
|
exclude = set()
|
|
if skip_ground:
|
|
exclude |= ground_colors
|
|
if skip_terrain:
|
|
exclude |= terrain_colors
|
|
exclude_upper = {c.upper() for c in exclude}
|
|
|
|
def _norm(c):
|
|
if not isinstance(c, str):
|
|
return ""
|
|
s = c.strip().upper()
|
|
if not s.startswith("#"):
|
|
s = "#" + s
|
|
return s
|
|
|
|
filtered = [(m, c, o) for (m, c, o) in meshes
|
|
if _norm(c) not in exclude_upper]
|
|
if not filtered:
|
|
return []
|
|
|
|
# 전체 메쉬 XY/Z bounding box (모든 컴포넌트 통합)
|
|
all_pts_list = []
|
|
for m, _, _ in filtered:
|
|
try:
|
|
pts = np.asarray(m.points, dtype=np.float64)
|
|
if pts.size:
|
|
all_pts_list.append(pts)
|
|
except Exception:
|
|
continue
|
|
if not all_pts_list:
|
|
return []
|
|
all_pts = np.concatenate(all_pts_list, axis=0)
|
|
mesh_xmin, mesh_ymin = float(all_pts[:, 0].min()), float(all_pts[:, 1].min())
|
|
mesh_xmax, mesh_ymax = float(all_pts[:, 0].max()), float(all_pts[:, 1].max())
|
|
aggregate_z_min = float(all_pts[:, 2].min()) # 모든 컴포넌트 중 최저 Z (상대 Z 보존용)
|
|
mesh_cx = (mesh_xmin + mesh_xmax) / 2.0
|
|
mesh_cy = (mesh_ymin + mesh_ymax) / 2.0
|
|
mesh_w = mesh_xmax - mesh_xmin
|
|
mesh_h = mesh_ymax - mesh_ymin
|
|
|
|
if mesh_w < 1e-6 or mesh_h < 1e-6:
|
|
return filtered
|
|
|
|
# TIN quad (월드)
|
|
quad = np.asarray(quad_world_pts[:4], dtype=np.float64)
|
|
q_center_world = quad.mean(axis=0)
|
|
|
|
# 월드 → TIN 로컬 (terrain_origin 차감)
|
|
origin = np.asarray(terrain_origin if terrain_origin is not None else np.zeros(3),
|
|
dtype=np.float64)
|
|
q_center_local_x = float(q_center_world[0] - origin[0])
|
|
q_center_local_y = float(q_center_world[1] - origin[1])
|
|
|
|
edge_01 = quad[1] - quad[0]
|
|
edge_12 = quad[2] - quad[1]
|
|
q_w = float(np.linalg.norm(edge_01))
|
|
q_h = float(np.linalg.norm(edge_12))
|
|
if q_w < 1e-6 or q_h < 1e-6:
|
|
return filtered
|
|
|
|
# TIN quad 첫 엣지 각도 (반시계 양수)
|
|
tin_angle = math.atan2(float(edge_01[1]), float(edge_01[0]))
|
|
|
|
# Detail quad의 첫 엣지 각도 (있으면 상대회전 계산용)
|
|
if detail_quad_pts and len(detail_quad_pts) >= 4:
|
|
d_quad = np.asarray(detail_quad_pts[:4], dtype=np.float64)
|
|
d_edge_01 = d_quad[1] - d_quad[0]
|
|
if np.linalg.norm(d_edge_01) >= 1e-6:
|
|
detail_angle = math.atan2(float(d_edge_01[1]), float(d_edge_01[0]))
|
|
else:
|
|
detail_angle = 0.0
|
|
else:
|
|
# 폴백: detail의 +X가 edge 0→1과 같다고 가정 (기존 동작)
|
|
detail_angle = 0.0
|
|
|
|
# mesh가 detail 프레임 내에서 frame_angle만큼 회전되어 있으면 그만큼 상쇄
|
|
# 최종 회전 = (TIN 각도) - (Detail 각도) - (mesh의 detail 내 회전)
|
|
q_angle = tin_angle - detail_angle - math.radians(plan_frame_angle_deg)
|
|
cos_a = math.cos(q_angle)
|
|
sin_a = math.sin(q_angle)
|
|
# CW quad 보정: True면 mesh Y 반전 후 회전 (mesh +Y가 edge 1→2 따라가도록)
|
|
y_sign = -1.0 if flip_y_for_cw_quad else 1.0
|
|
|
|
# Scale 선택
|
|
raw_sx = q_w / mesh_w
|
|
raw_sy = q_h / mesh_h
|
|
if scale_mode == "xy_only":
|
|
scale_x, scale_y, scale_z_val = raw_sx, raw_sy, 1.0
|
|
elif scale_mode == "xyz_uniform":
|
|
s = math.sqrt(abs(raw_sx * raw_sy))
|
|
s = max(0.01, min(100.0, s)) # extreme 방지
|
|
scale_x = scale_y = scale_z_val = s
|
|
else: # "none" 기본: 구조물 설계 크기 유지
|
|
scale_x = scale_y = scale_z_val = 1.0
|
|
|
|
# TIN 표면 Z (quad 중심에서 보간) — terrain 모드 공통
|
|
z_surface = None
|
|
if z_mode == "terrain":
|
|
if pad_surface_z is not None:
|
|
# 굴착 pad가 있으면 TIN 보간 대신 pad Z 직접 사용 (더 정확/안정)
|
|
z_surface = float(pad_surface_z)
|
|
elif terrain_mesh is not None:
|
|
try:
|
|
from scipy.interpolate import LinearNDInterpolator
|
|
tin_pts = np.asarray(terrain_mesh.points, dtype=np.float64)
|
|
interp = LinearNDInterpolator(tin_pts[:, :2], tin_pts[:, 2])
|
|
zs = float(interp(q_center_local_x, q_center_local_y))
|
|
if np.isnan(zs):
|
|
zs = float(np.median(tin_pts[:, 2]))
|
|
z_surface = zs
|
|
except Exception:
|
|
z_surface = None
|
|
|
|
out = []
|
|
for mesh, color, opacity in filtered:
|
|
try:
|
|
pts = np.asarray(mesh.points, dtype=np.float64).copy()
|
|
|
|
# 1) XY 중심을 원점으로 이동 (rotation/scale을 원점 기준으로)
|
|
pts[:, 0] -= mesh_cx
|
|
pts[:, 1] -= mesh_cy
|
|
|
|
# 2) Anisotropic XY scale + Z scale (기하평균)
|
|
pts[:, 0] *= scale_x
|
|
pts[:, 1] *= scale_y
|
|
if abs(scale_z_val - 1.0) > 1e-9:
|
|
pts[:, 2] *= scale_z_val
|
|
|
|
# 2.5) CW quad 보정: mesh Y 반전
|
|
# 사용자가 시계방향으로 picks를 찍으면 quad의 local +Y가 edge 1→2 방향
|
|
# (수학적 CCW 기준 -Y)이 됨. mesh의 +Y를 이쪽으로 정렬하려면 먼저 Y를
|
|
# 반전한 후 표준 CCW 회전을 적용해야 함.
|
|
if y_sign < 0:
|
|
pts[:, 1] = -pts[:, 1]
|
|
|
|
# 3) XY 회전 (상대 각도: TIN - detail - plan_frame_angle)
|
|
x = pts[:, 0].copy()
|
|
y = pts[:, 1].copy()
|
|
pts[:, 0] = x * cos_a - y * sin_a
|
|
pts[:, 1] = x * sin_a + y * cos_a
|
|
|
|
# 4) XY를 quad 중심(TIN 로컬)으로 이동
|
|
pts[:, 0] += q_center_local_x
|
|
pts[:, 1] += q_center_local_y
|
|
|
|
# 5) Z 이동 — 전체 구조물 최저 z_min을 TIN 표면에 맞춤
|
|
# (컴포넌트 개별 z_min이 아니라 aggregate z_min 사용 → 상대 Z 보존)
|
|
if z_mode == "terrain" and z_surface is not None:
|
|
# embed_offset만큼 구조물을 아래로 내림 → TIN이 구조물 bottom보다
|
|
# 살짝 위에 있어 아래에서 보면 TIN이 underside를 가림
|
|
dz = z_surface - aggregate_z_min * scale_z_val - float(embed_offset)
|
|
pts[:, 2] += dz
|
|
elif z_mode == "offset":
|
|
pts[:, 2] += float(z_offset)
|
|
# z_mode == "design" 이면 Z 유지
|
|
|
|
new_mesh = mesh.copy()
|
|
new_mesh.points = pts
|
|
out.append((new_mesh, color, opacity))
|
|
except Exception:
|
|
continue
|
|
|
|
return out
|
|
|
|
|
|
def compute_orientation_from_points(points: list) -> float:
|
|
"""점들의 PCA로 주축 각도(도) 반환.
|
|
|
|
Returns:
|
|
주축과 X축 사이 각도 (-90 ~ +90 도)
|
|
"""
|
|
if len(points) < 3:
|
|
return 0.0
|
|
arr = np.array(points, dtype=np.float64)
|
|
arr = np.unique(arr, axis=0)
|
|
if len(arr) < 3:
|
|
return 0.0
|
|
|
|
centered = arr[:, :2] - arr[:, :2].mean(axis=0)
|
|
try:
|
|
cov = np.cov(centered.T)
|
|
eigenvals, eigenvecs = np.linalg.eigh(cov)
|
|
# 최대 고유값 벡터가 주축
|
|
idx = np.argmax(eigenvals)
|
|
main_axis = eigenvecs[:, idx]
|
|
angle = math.degrees(math.atan2(main_axis[1], main_axis[0]))
|
|
# -90 ~ +90 범위로 정규화
|
|
while angle > 90:
|
|
angle -= 180
|
|
while angle < -90:
|
|
angle += 180
|
|
return angle
|
|
except Exception:
|
|
return 0.0
|
|
|
|
|
|
def combine_meshes(mesh_groups: list[list[tuple]]) -> list[tuple]:
|
|
"""여러 구조물의 메쉬 리스트들을 하나로 합침."""
|
|
combined = []
|
|
for group in mesh_groups:
|
|
combined.extend(group)
|
|
return combined
|
|
|
|
|
|
if __name__ == "__main__":
|
|
# 간단 테스트
|
|
import pyvista as pv
|
|
# 작은 박스
|
|
box = pv.Box(bounds=(-1, 1, -1, 1, 0, 2))
|
|
meshes = [(box, "#888888", 1.0)]
|
|
placed = apply_placement(
|
|
meshes=meshes,
|
|
plan_centroid=(100.0, 50.0),
|
|
rotation_deg=45.0,
|
|
z_mode="offset",
|
|
z_offset=10.0,
|
|
)
|
|
print(f"Original bounds: {meshes[0][0].bounds}")
|
|
print(f"Placed bounds: {placed[0][0].bounds}")
|
|
print("(X=99~101 → 99~101 회전, Y=49~51, Z=10~12)")
|