사용자 명시 요청 (#4 잔여 핵심): "기존 구조에 지도 아래에 있는 로그는 백엔드로 빼고, 프로세스를 클릭할 때마다 새로운 창이 뜨는 것이 아니라 한 화면에서 바로 구동되게끔 적용". 1. 인라인 로그 패널 완전 제거: - self.textbox CTkTextbox 위젯 제거 (이전 라운드 80px 축소 → 본 라운드 완전 삭제). - main_frame layout: row 0 weight 3→1 (지도 전체 차지), row 1 (로그) 제거, status_bar row 2→1. - self.log() 동작 변경: textbox.insert 대신 백엔드 logger.info (logs/scanvas.log RotatingFileHandler 5MB×5). status_text 가 짧은 미리보기 (≤80자) 즉시 표시. - 효과: 메인 캔버스 영역 ~25% 확대 + GUI 메인 thread 부담 감소. 2. harness/inline_panel.py 신규 (231 LOC): - InlinePanel: ctk.CTkToplevel 호환 인라인 오버레이 (CTkFrame 상속). - API 호환: title/geometry/transient/grab_set/protocol/wait_window/destroy + iconbitmap/wm_* no-op. - 핵심 트릭: tk.Misc.wait_window(self) 가 Frame 에서도 동작 (widget destruction 대기) — wait_window 호출 5곳 (T1/T6/T7/T8/T10) 그대로 유지 가능. - 다중 패널 z-order (_z_counter + lift), main_frame 95% cap, MC Red 타이틀 바. 3. 12 ctk.CTkToplevel → InlinePanel 일괄 치환: - T1 (DXF 레이어), T2 (구조물 빌드), T3 (빌드 진행), T6 (상세도면), T7 (치수), T8 (계획선 고도), T9 (TIN core), T10 (렌더 옵션), T11 (Blender 결과), T12 (AI 렌더 결과) — 10 main popups. - T4 (렌더 sub-옵션), T5 (VLM 결과) — T3 자식 popups. - 2 replace_all 패턴: ctk.CTkToplevel(self) → InlinePanel(self), ctk.CTkToplevel(win) → InlinePanel(win). - 결과: 12 popups 모두 main 창 안 floating frame 으로 렌더, 별도 OS 창 안 뜸. 사용자가 ALT-TAB 으로 창 사이 오갈 필요 없음. 검증: - py_compile + AST OK (scanvas_maker, perf, crash_logger, inline_panel 4개). - ruff check All checks passed (0 errors). - import smoke test: scanvas_maker import 성공, InlinePanel 가 진짜 harness 클래스로 로드. - self.textbox 잔존 refs: 0. CTkToplevel refs: 3 (모두 import fallback/주석). InlinePanel refs: 15 (12 호출지 + import). 잔여 (#4 next round, multi-session): - InlinePanel 실 GUI 워크플로 검증 (사용자 도면 로드 후 T1~T12 한 번씩). - VTK 임베딩 (pv.Plotter().show() 6곳 → pyvistaqt.QtInteractor). - messagebox 63회 → 인라인 토스트. - Inspector 패널 영구 컬럼 (3-column 레이아웃). - 메인 thread 블로킹 작업 worker thread 분리. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
7122 lines
342 KiB
Python
7122 lines
342 KiB
Python
import contextlib
|
||
import customtkinter as ctk
|
||
import datetime
|
||
import hashlib
|
||
import io
|
||
import json
|
||
import logging
|
||
import os
|
||
import sys
|
||
import threading
|
||
import time as _time
|
||
from pathlib import Path
|
||
from tkinter import filedialog, messagebox
|
||
|
||
# 런타임 경로 — PyInstaller 번들 vs 소스 실행 자동 분기. 자산은 asset_root,
|
||
# 쓰기 데이터(DB·로그·캐시)는 user_data_dir(`%LOCALAPPDATA%\\S-CANVAS\\`).
|
||
from resource_paths import (
|
||
resource_path, user_data_dir, db_path,
|
||
harness_log_path, diagnostic_log_path, cache_dir,
|
||
)
|
||
|
||
# GIS/3D/Network 라이브러리
|
||
import ezdxf
|
||
import numpy as np
|
||
import pyvista as pv
|
||
from scipy.spatial import Delaunay
|
||
import pyproj
|
||
import requests
|
||
from PIL import Image, ImageDraw, ImageFilter
|
||
import tkintermapview
|
||
from matplotlib.colors import LinearSegmentedColormap
|
||
|
||
# ===== Mastercard 디자인 시스템 팔레트 (피드백 #4 — UI/UX 재설계) =====
|
||
# 본 색상은 코드 전반에 hex literal 로 직접 적용된다 (constants import 부담 회피).
|
||
# 본 주석 블록은 디자인 의도 문서화용. UI_REDESIGN_PLAN.md 참조.
|
||
#
|
||
# PRIMARY #EB001B Mastercard Red — 주 CTA, 에러 상태
|
||
# PRIMARY' #A30013 Red Dark — CTA hover/active
|
||
# ACCENT #F79E1B Mastercard Yellow — 보조 강조, 경고 (Step3 노란 status 등)
|
||
# FOCUS #FF5F00 Overlap Orange — 포커스 / focus accent (예약)
|
||
# SUCCESS #22A06B Brand-friendly Green — 성공 / READY 인디케이터
|
||
# SUCCESS' #1B8454 Green Dark — 성공 hover
|
||
# DARK #1A1A1A Near-black — 다크 모드 페이지/버튼 bg
|
||
# BLACK #000000 Pure Black — 텍스트, 다크 버튼 hover
|
||
# BORDER_L #E0E0E0 Light Border — 라이트 모드 보더
|
||
# BORDER_D #333333 Dark Border — 다크 모드 보더 (현 코드는 #3F3F3F 유지)
|
||
# 인트로 splash 비디오는 본 라운드에서 제거됨 (피드백 #4).
|
||
|
||
# 지형(TIN) 컬러맵 — **파란색 금지** (피드백 #3: 물과 헷갈림).
|
||
# 어두운 토양 → 밝은 모래/건조 톤 → 능선 광택. matplotlib "terrain" 대체.
|
||
_TIN_EARTH_CMAP = LinearSegmentedColormap.from_list(
|
||
"scanvas_earth",
|
||
[
|
||
(0.00, "#3F2E1A"), # 저지대 — 짙은 갈색 (배수로 음영)
|
||
(0.20, "#6E5235"), # 토양
|
||
(0.45, "#9C7B4F"), # 황토
|
||
(0.70, "#C7AA7C"), # 모래/건조
|
||
(0.88, "#E5D5B0"), # 고지 능선
|
||
(1.00, "#F5EBD3"), # 정상 광택
|
||
],
|
||
N=256,
|
||
)
|
||
|
||
# Harness 모듈 (동일 디렉토리의 harness/ 폴더)
|
||
try:
|
||
from harness.logger import init_db, get_db_session, JobLogger, setup_logging, get_logger
|
||
from harness.seed_manager import SeedManager
|
||
from harness.quality_validator import QualityValidator
|
||
from harness.prompt_registry import PromptRegistry
|
||
HARNESS_AVAILABLE = True
|
||
except ImportError:
|
||
HARNESS_AVAILABLE = False
|
||
|
||
# Perf instrumentation (#11) — ms 단위 wall/CPU 측정. import 실패 시 no-op 폴백.
|
||
try:
|
||
from harness.perf import perf_block, set_perf_log
|
||
except ImportError:
|
||
@contextlib.contextmanager
|
||
def perf_block(label): # type: ignore[no-redef]
|
||
yield
|
||
def set_perf_log(fn): # type: ignore[no-redef]
|
||
pass
|
||
|
||
# InlinePanel (#4) — CTkToplevel 호환 인라인 오버레이. 별도 OS 창 안 띄우고
|
||
# main_frame 안 floating frame 으로 렌더. 실패 시 폴백으로 CTkToplevel 사용.
|
||
try:
|
||
from harness.inline_panel import InlinePanel
|
||
except ImportError:
|
||
InlinePanel = ctk.CTkToplevel # type: ignore[assignment,misc]
|
||
|
||
# 크래시 로거의 일반 로그 채널 — self.log() 가 인라인 textbox 대신 백엔드 파일에
|
||
# 흘리도록 하기 위해 (#4 "로그는 백엔드로"). harness.logger 의 get_logger 와 별개.
|
||
try:
|
||
from harness.crash_logger import get_logger as _get_crash_logger
|
||
except ImportError:
|
||
_get_crash_logger = None # type: ignore[assignment]
|
||
|
||
# 구조물 상세도면 치수 파서
|
||
try:
|
||
from detail_parser import DetailParser, dimensions_to_structure_params
|
||
DETAIL_PARSER_AVAILABLE = True
|
||
except ImportError:
|
||
DETAIL_PARSER_AVAILABLE = False
|
||
|
||
# 구조물 템플릿 시스템 (취수탑/제수변실/옹벽/수문 등 상세 빌더)
|
||
try:
|
||
from structure_templates import REGISTRY as STRUCTURE_REGISTRY
|
||
from structure_placement import (
|
||
apply_placement,
|
||
compute_orientation_from_points,
|
||
fit_meshes_to_quad,
|
||
)
|
||
from filename_classifier import suggest_with_confidence # noqa: F401 (protective availability check)
|
||
STRUCTURE_TEMPLATES_AVAILABLE = True
|
||
except ImportError as _e:
|
||
STRUCTURE_REGISTRY = None
|
||
STRUCTURE_TEMPLATES_AVAILABLE = False
|
||
print(f"[Warning] structure_templates not available: {_e}")
|
||
|
||
# DEM 기반 지형 확장 (DXF 범위 밖의 실제 지형)
|
||
try:
|
||
from dem_extender import (
|
||
build_extended_terrain_ring,
|
||
fetch_terrarium_grid,
|
||
_sample_grid_bilinear,
|
||
)
|
||
DEM_EXTENDER_AVAILABLE = True
|
||
except ImportError as _e:
|
||
DEM_EXTENDER_AVAILABLE = False
|
||
print(f"[Warning] dem_extender not available: {_e}")
|
||
|
||
# 구조물 파서·빌더 결과 ↔ 원본 도면 VLM 피드백 루프 (Gemini Vision)
|
||
try:
|
||
import structure_vlm_feedback as _svf
|
||
STRUCTURE_VLM_AVAILABLE = True
|
||
except ImportError as _e:
|
||
STRUCTURE_VLM_AVAILABLE = False
|
||
print(f"[Warning] structure_vlm_feedback not available: {_e}")
|
||
|
||
# 폰트 에러 방지 — matplotlib font_manager 로그 비활성.
|
||
logging.getLogger('matplotlib.font_manager').disabled = True
|
||
os.environ['PYTHONIOENCODING'] = 'utf-8'
|
||
|
||
# UI 테마 — **Default Light**. 사용자가 사이드바에서 언제든 Dark 로 전환 가능.
|
||
ctk.set_appearance_mode("light")
|
||
ctk.set_default_color_theme("blue")
|
||
|
||
|
||
def _load_image_strip_white_bg(path, threshold: int = 240):
|
||
"""PIL 이미지 로드 후 (R,G,B 모두 ≥ threshold 인) 흰색 배경을 투명화.
|
||
|
||
GIF 같은 팔레트 이미지·이미 RGBA 이미지 모두 처리. SAMAN_CI 흰 배경 제거용.
|
||
"""
|
||
pil = Image.open(path).convert("RGBA")
|
||
arr = np.asarray(pil).copy()
|
||
r, g, b = arr[..., 0], arr[..., 1], arr[..., 2]
|
||
white = (r >= threshold) & (g >= threshold) & (b >= threshold)
|
||
arr[..., 3] = np.where(white, 0, arr[..., 3])
|
||
return Image.fromarray(arr)
|
||
|
||
|
||
|
||
def _load_image_strip_dark_bg(path, v_low: int = 30, v_high: int = 80):
|
||
"""어두운 배경(밤하늘·검정)을 부드럽게 투명화. logo_V2.png 처럼
|
||
V(=max RGB) 가 낮은 픽셀이 배경인 경우 사용.
|
||
|
||
- max(R,G,B) ≤ v_low → 완전 투명(alpha 0)
|
||
- max(R,G,B) ≥ v_high → 완전 불투명(원본 alpha 유지)
|
||
- 사이 구간은 선형 보간 → 부드러운 경계(halo 최소화)
|
||
"""
|
||
pil = Image.open(path).convert("RGBA")
|
||
arr = np.asarray(pil).astype(np.float32).copy()
|
||
v = np.maximum.reduce([arr[..., 0], arr[..., 1], arr[..., 2]])
|
||
factor = np.clip((v - v_low) / max(v_high - v_low, 1.0), 0.0, 1.0)
|
||
arr[..., 3] = arr[..., 3] * factor
|
||
return Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8))
|
||
|
||
|
||
|
||
def _signed_distance_to_polygon(points_xy: np.ndarray,
|
||
poly_pts: np.ndarray) -> np.ndarray:
|
||
"""각 점에서 다각형 경계까지의 부호 있는 거리. 음수=내부, 양수=외부.
|
||
|
||
- 경계 최소거리는 모든 엣지에 대한 점-선분 거리 중 최소값
|
||
- 부호는 matplotlib.path의 inside 판정으로 결정
|
||
- 폐합 폴리곤(N>=3) 가정. 끝-처음 엣지도 자동 포함.
|
||
"""
|
||
from matplotlib.path import Path as _MplPath
|
||
pts = np.asarray(points_xy, dtype=np.float64)
|
||
poly = np.asarray(poly_pts, dtype=np.float64)
|
||
n_edges = len(poly)
|
||
if len(pts) == 0 or n_edges < 2:
|
||
return np.full(len(pts), np.inf, dtype=np.float64)
|
||
|
||
min_d = np.full(len(pts), np.inf, dtype=np.float64)
|
||
for i in range(n_edges):
|
||
p1 = poly[i]
|
||
p2 = poly[(i + 1) % n_edges]
|
||
edge = p2 - p1
|
||
edge_len_sq = float(edge @ edge)
|
||
if edge_len_sq < 1e-14:
|
||
continue
|
||
rel = pts - p1
|
||
t = np.clip((rel @ edge) / edge_len_sq, 0.0, 1.0)
|
||
proj = p1 + np.outer(t, edge)
|
||
d = np.linalg.norm(pts - proj, axis=1)
|
||
np.minimum(min_d, d, out=min_d)
|
||
|
||
try:
|
||
inside = _MplPath(poly).contains_points(pts)
|
||
except Exception:
|
||
inside = np.zeros(len(pts), dtype=bool)
|
||
return np.where(inside, -min_d, min_d)
|
||
|
||
|
||
class SCanvasApp(ctk.CTk):
|
||
def __init__(self):
|
||
super().__init__()
|
||
|
||
# 프로그램 기본 설정
|
||
self.title("S-CANVAS — Generative Design & Visualization Engine")
|
||
self.geometry("1200x900")
|
||
self._setup_window_icon() # 타이틀바·작업표시줄 'S' 아이콘
|
||
|
||
# 인스턴스 변수
|
||
self.tin_mesh = None
|
||
self.total_mesh = None # 텍스처가 입혀진 메쉬 저장용
|
||
self.tin_extension_mesh = None # DEM 기반 외곽 확장 메시 (도넛)
|
||
self.tin_extension_textured = None # 텍스처 UV 매핑된 확장 메시
|
||
self._dem_extend_info = "" # 마지막 DEM 확장 요약 로그
|
||
# [TIN 이용 범위 3-zone] core_bbox = 사용자가 지정한 정밀 TIN 구역(abs XY).
|
||
# None = 전체 TIN 사용(legacy). 설정 시 Step 1.5에서 core/transition/DEM 3구역
|
||
# smoothstep 블렌드 적용.
|
||
self.tin_core_bbox = None
|
||
self.tin_blend_width_m = 80.0
|
||
# core zone 변경 이력 — 초기화/재선택 구분용
|
||
self._tin_core_original_points = None # core 적용 전 원본 Z 백업
|
||
self.dxf_path = None
|
||
self.origin = np.array([0.0, 0.0, 0.0])
|
||
|
||
# 제어맵 & 렌더링 결과 저장
|
||
self.depth_map = None # PIL Image
|
||
self.lineart_map = None # PIL Image
|
||
self.guide_image = None # PIL Image (합성본)
|
||
self.capture_image = None # PIL Image (3D 캡처)
|
||
self._saved_camera = None # PyVista 카메라 위치 (pos, focal, up)
|
||
self._saved_window_size = None # 인터랙티브 뷰어 창 크기 (w, h) — 캡처 화면비 보존용
|
||
# Step 3 화면비 락 — 사용자가 명시 비율을 클릭할 때까지는 None(자유 모드)
|
||
self.extraction_aspect_ratio = None # None=자유 / (rw, rh) 정수 튜플
|
||
self._aspect_buttons = [] # [(x_norm, y_norm, w_norm, h_norm, label, ratio, actor)]
|
||
# Step 4 출력 해상도 — 렌더 후 PIL.LANCZOS 리사이즈 타깃
|
||
self.target_resolution = None # None / (target_w, target_h)
|
||
self.gemini_api_key = ctk.StringVar(value="")
|
||
|
||
# gcp-key.json 자동 로드: 서비스 어카운트 키 파일이 프로젝트 루트에 있으면
|
||
# GOOGLE_APPLICATION_CREDENTIALS 환경변수로 설정해 gcloud auth 없이 Vertex AI
|
||
# 인증 가능. project_id도 JSON에서 추출해 기본값으로 사용.
|
||
self._gcp_key_project_id = None
|
||
try:
|
||
# gcp-key.json 검색 순서: (1) user_data_dir (배포본 권장)
|
||
# (2) 자산 루트 옆 (개발/legacy)
|
||
_gcp_key_user = user_data_dir() / "gcp-key.json"
|
||
_gcp_key_dev = resource_path("gcp-key.json")
|
||
_gcp_key_path = _gcp_key_user if _gcp_key_user.exists() else _gcp_key_dev
|
||
if _gcp_key_path.exists():
|
||
os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = str(_gcp_key_path)
|
||
with open(_gcp_key_path, encoding="utf-8") as _gf:
|
||
_gcp_key_data = json.load(_gf)
|
||
self._gcp_key_project_id = _gcp_key_data.get("project_id")
|
||
except Exception:
|
||
self._gcp_key_project_id = None
|
||
|
||
# Phase 4: 레이어 시맨틱 매핑
|
||
self.dxf_doc = None # ezdxf 문서 객체
|
||
self.layer_mapping = {} # {레이어명: 구조물유형ID}
|
||
self.layer_geometries = {} # {레이어명: [(type, coords), ...]}
|
||
self.structure_types = {} # YAML에서 로드된 구조물 유형 정의
|
||
self.layer_elevations = {} # {레이어명: {"mode": "terrain"|"manual", "start_el": float, "end_el": float}}
|
||
self.structure_registry = {} # {레이어명: {"centroid": (x,y), "bounds": (minx,miny,maxx,maxy), "name": str, "type_id": str, "detail_params": dict|None, "detail_dxf": str|None}}
|
||
self._load_structure_types()
|
||
|
||
# Harness 통합 (품질검증, seed, 프롬프트, 로거)
|
||
if HARNESS_AVAILABLE:
|
||
setup_logging(log_file=harness_log_path())
|
||
init_db(str(db_path()))
|
||
self.hlog = get_logger("scanvas")
|
||
self.job_logger = JobLogger()
|
||
self.seed_mgr = SeedManager()
|
||
self.quality_val = QualityValidator(min_resolution=1024, sharpness_threshold=50.0)
|
||
self.prompt_reg = PromptRegistry(resource_path("prompt_templates"))
|
||
self.hlog.info("Harness 모듈 로드 완료", modules=["logger", "seed", "quality", "prompt"])
|
||
else:
|
||
self.hlog = None
|
||
self.job_logger = None
|
||
self.seed_mgr = None
|
||
self.quality_val = None
|
||
self.prompt_reg = None
|
||
|
||
# 구조물 분류/추출 진단 로그 (Step 1 실행 시 덮어씀)
|
||
self.diag_log_path = diagnostic_log_path()
|
||
|
||
# 렌더링 옵션
|
||
self.time_of_day = ctk.StringVar(value="낮 (Daytime)")
|
||
self.camera_elevation = ctk.DoubleVar(value=45.0) # 카메라 앙각 (도)
|
||
self.camera_azimuth = ctk.DoubleVar(value=225.0) # 카메라 방위각 (도)
|
||
self.render_strength = ctk.DoubleVar(value=0.3) # upscale creativity (낮을수록 원본 유지)
|
||
|
||
# 위성 타일 서버 설정
|
||
self.tile_servers = {
|
||
"Google Satellite": "https://mt{s}.google.com/vt/lyrs=s&x={x}&y={y}&z={z}",
|
||
"Google Hybrid": "https://mt{s}.google.com/vt/lyrs=y&x={x}&y={y}&z={z}",
|
||
"ArcGIS World Imagery": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
|
||
"ArcGIS Hybrid": "https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}",
|
||
"Bing Aerial": "https://ecn.t{s}.tiles.virtualearth.net/tiles/a{q}.jpeg?g=1",
|
||
"OpenStreetMap": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||
"OpenTopo": "https://tile.opentopomap.org/{z}/{x}/{y}.png",
|
||
"Vworld 위성 (키필요)": "https://api.vworld.kr/req/wmts/1.0.0/{vworld_key}/Satellite/{z}/{y}/{x}.jpeg",
|
||
"Vworld 기본 (키필요)": "https://api.vworld.kr/req/wmts/1.0.0/{vworld_key}/Base/{z}/{y}/{x}.png",
|
||
"Vworld 하이브리드 (키필요)": "https://api.vworld.kr/req/wmts/1.0.0/{vworld_key}/Hybrid/{z}/{y}/{x}.png",
|
||
}
|
||
# Vworld API Key 기본값 — 사용자 제공 키 프리필(빈 칸이면 Vworld 타일 실패).
|
||
self.vworld_api_key = ctk.StringVar(value="383CB30A-2AD8-3199-8A7B-215DE3E4280C")
|
||
|
||
# 그리드 레이아웃 설정
|
||
self.grid_columnconfigure(1, weight=1)
|
||
self.grid_rowconfigure(0, weight=1)
|
||
|
||
# --- 사이드바 프레임 (스크롤 가능 — 창이 작아도 모든 위젯 접근 보장) ---
|
||
# 바깥 컨테이너: 고정 폭 확보 + 스크롤 프레임을 담는 역할
|
||
self.sidebar_container = ctk.CTkFrame(self, width=270, corner_radius=0)
|
||
self.sidebar_container.grid(row=0, column=0, sticky="nsew")
|
||
self.sidebar_container.grid_propagate(False) # 내부 위젯이 폭을 바꿔도 고정
|
||
self.sidebar_container.grid_columnconfigure(0, weight=1)
|
||
self.sidebar_container.grid_rowconfigure(0, weight=1)
|
||
|
||
self.sidebar_frame = ctk.CTkScrollableFrame(
|
||
self.sidebar_container, width=250, corner_radius=0,
|
||
label_text=None, fg_color="transparent")
|
||
self.sidebar_frame.grid(row=0, column=0, sticky="nsew")
|
||
self.sidebar_frame.grid_columnconfigure(0, weight=1)
|
||
|
||
# pack(row) 일괄 관리 — add 순서대로 자동 증가. row 번호 충돌 방지.
|
||
self._sidebar_row = 0
|
||
def _next_row():
|
||
r = self._sidebar_row
|
||
self._sidebar_row += 1
|
||
return r
|
||
|
||
pad = {"padx": 14}
|
||
|
||
def _divider(pady=(8, 8)):
|
||
"""Thin 1px 수평 구분선 — Light/Dark 테마별 색상 쌍."""
|
||
d = ctk.CTkFrame(
|
||
self.sidebar_frame, height=1, corner_radius=0,
|
||
fg_color=("#DEE2E6", "#3F3F3F"))
|
||
d.grid(row=_next_row(), column=0, sticky="ew", padx=14, pady=pady)
|
||
return d
|
||
|
||
# S-CANVAS 로고 — Design/Logo.png.
|
||
# 이미지가 이미 알파 채널이 있어도 엣지에 남은 near-white 픽셀 / 체커 배경
|
||
# 아티팩트를 확실히 제거하기 위해 `_load_image_strip_white_bg` 를 거침.
|
||
# CTkLabel `fg_color="transparent"` 로 사이드바 배경이 투명 픽셀을 관통해
|
||
# 자연스럽게 보이도록.
|
||
self._saman_asset_dir = resource_path("Design")
|
||
try:
|
||
# logo_V2.png — 다크 네이비 배경 + 회로 패턴. 어두운 배경을 소프트
|
||
# 임계로 투명화(v_low=25 ~ v_high=75)해 사이드바 bg 와 자연스럽게
|
||
# 어우러지게. 로고/서브타이틀 자체는 충분히 밝아 보존됨.
|
||
_logo_src = self._saman_asset_dir / "logo_V2.png"
|
||
_logo_pil = _load_image_strip_dark_bg(_logo_src, v_low=25, v_high=75)
|
||
_lw, _lh = _logo_pil.size
|
||
_target_w = 230
|
||
_target_h = int(_lh * _target_w / max(_lw, 1))
|
||
_logo_img = ctk.CTkImage(
|
||
light_image=_logo_pil, dark_image=_logo_pil,
|
||
size=(_target_w, _target_h),
|
||
)
|
||
self.logo_label = ctk.CTkLabel(
|
||
self.sidebar_frame, image=_logo_img, text="",
|
||
fg_color="transparent")
|
||
except Exception as _le:
|
||
self.logo_label = ctk.CTkLabel(
|
||
self.sidebar_frame, text="S-CANVAS",
|
||
font=ctk.CTkFont(size=24, weight="bold"),
|
||
fg_color="transparent")
|
||
print(f"[Warning] Logo 이미지 로드 실패, 텍스트 폴백: {_le}")
|
||
self.logo_label.grid(row=_next_row(), column=0, pady=(22, 6), **pad)
|
||
|
||
self.sub_logo_label = ctk.CTkLabel(
|
||
self.sidebar_frame,
|
||
text="Generative Design & Visualization Engine",
|
||
font=ctk.CTkFont(size=10, slant="italic"),
|
||
text_color=("#6C757D", "#9A9A9A"))
|
||
self.sub_logo_label.grid(row=_next_row(), column=0, pady=(0, 14), **pad)
|
||
|
||
# --- 구분선: 헤더 ↔ SETTINGS ---
|
||
_divider(pady=(0, 12))
|
||
|
||
# SETTINGS — 섹션 헤더 (uppercase 소문자 tracking 느낌의 bold).
|
||
self.settings_label = ctk.CTkLabel(
|
||
self.sidebar_frame, text="SETTINGS",
|
||
font=ctk.CTkFont(size=10, weight="bold"),
|
||
text_color=("#6C757D", "#9A9A9A"))
|
||
self.settings_label.grid(row=_next_row(), column=0, pady=(0, 6), sticky="w", **pad)
|
||
|
||
# 위성 타일 소스 선택
|
||
self.tile_label = ctk.CTkLabel(self.sidebar_frame, text="Satellite Source:",
|
||
font=ctk.CTkFont(size=11))
|
||
self.tile_label.grid(row=_next_row(), column=0, sticky="w", **pad)
|
||
self.tile_source_option = ctk.CTkOptionMenu(
|
||
self.sidebar_frame,
|
||
values=list(self.tile_servers.keys()),
|
||
command=self._on_tile_source_changed,
|
||
)
|
||
self.tile_source_option.grid(row=_next_row(), column=0, pady=(0, 3), sticky="ew", **pad)
|
||
self.tile_source_option.set("Google Satellite")
|
||
|
||
# Vworld API Key
|
||
self.vworld_label = ctk.CTkLabel(self.sidebar_frame, text="Vworld API Key:",
|
||
font=ctk.CTkFont(size=11))
|
||
self.vworld_label.grid(row=_next_row(), column=0, sticky="w", **pad)
|
||
self.vworld_entry = ctk.CTkEntry(
|
||
self.sidebar_frame, textvariable=self.vworld_api_key,
|
||
placeholder_text="Vworld 외에는 불필요", show="*")
|
||
self.vworld_entry.grid(row=_next_row(), column=0, pady=(0, 4), sticky="ew", **pad)
|
||
|
||
# 렌더링 엔진
|
||
self.render_engine = ctk.StringVar(value="Gemini (Vertex AI)")
|
||
self.engine_label = ctk.CTkLabel(self.sidebar_frame, text="AI Render Engine:",
|
||
font=ctk.CTkFont(size=11))
|
||
self.engine_label.grid(row=_next_row(), column=0, sticky="w", **pad)
|
||
self.engine_option = ctk.CTkOptionMenu(
|
||
self.sidebar_frame, variable=self.render_engine,
|
||
values=["Gemini (Vertex AI)", "Gemini (API Key)", "Stability AI (API)"],
|
||
command=self._on_engine_changed)
|
||
self.engine_option.grid(row=_next_row(), column=0, pady=(0, 3), sticky="ew", **pad)
|
||
|
||
# API Key / GCP Project ID
|
||
self.stab_label = ctk.CTkLabel(
|
||
self.sidebar_frame, text="GCP Project ID / API Key:",
|
||
font=ctk.CTkFont(size=11))
|
||
self.stab_label.grid(row=_next_row(), column=0, sticky="w", **pad)
|
||
default_proj = (self._gcp_key_project_id
|
||
or os.environ.get("GCP_PROJECT_ID", ""))
|
||
self.gemini_api_key.set(default_proj)
|
||
self.stab_entry = ctk.CTkEntry(
|
||
self.sidebar_frame, textvariable=self.gemini_api_key,
|
||
placeholder_text="GCP 프로젝트 ID (Vertex AI) / API Key",
|
||
show="*")
|
||
self.stab_entry.grid(row=_next_row(), column=0, pady=(0, 3), sticky="ew", **pad)
|
||
|
||
# Vertex AI Location
|
||
self.vertex_location = ctk.StringVar(value=os.environ.get("GCP_LOCATION", "global"))
|
||
self.loc_label = ctk.CTkLabel(
|
||
self.sidebar_frame, text="Vertex AI Location:",
|
||
font=ctk.CTkFont(size=10), text_color="gray")
|
||
self.loc_label.grid(row=_next_row(), column=0, sticky="w", **pad)
|
||
self.loc_entry = ctk.CTkEntry(
|
||
self.sidebar_frame, textvariable=self.vertex_location,
|
||
placeholder_text="us-central1")
|
||
self.loc_entry.grid(row=_next_row(), column=0, pady=(0, 4), sticky="ew", **pad)
|
||
|
||
# 좌표계
|
||
self.crs_label = ctk.CTkLabel(self.sidebar_frame, text="Project CRS (DXF):",
|
||
font=ctk.CTkFont(size=11))
|
||
self.crs_label.grid(row=_next_row(), column=0, sticky="w", **pad)
|
||
self.crs_option = ctk.CTkOptionMenu(
|
||
self.sidebar_frame,
|
||
values=["EPSG:5187", "EPSG:5186", "EPSG:5185", "EPSG:5181", "EPSG:3857"])
|
||
self.crs_option.grid(row=_next_row(), column=0, pady=(0, 8), sticky="ew", **pad)
|
||
self.crs_option.set("EPSG:5187")
|
||
|
||
# --- 구분선 + WORKFLOW 섹션 ---
|
||
_divider(pady=(10, 10))
|
||
self.workflow_label = ctk.CTkLabel(
|
||
self.sidebar_frame, text="WORKFLOW",
|
||
font=ctk.CTkFont(size=10, weight="bold"),
|
||
text_color=("#6C757D", "#9A9A9A"))
|
||
self.workflow_label.grid(row=_next_row(), column=0, pady=(0, 6), sticky="w", **pad)
|
||
|
||
# Workflow 버튼 — 높이 축소·간격 축소
|
||
self.btn_step1 = self.create_sidebar_button(
|
||
"1. TIN 생성 (DXF)", self.btn_tin_callback, row=_next_row())
|
||
self.btn_step1_core = self.create_sidebar_button(
|
||
"🎯 TIN 이용 범위 (정밀 구역)", self.btn_select_core_range_callback,
|
||
row=_next_row(), fg_color="transparent", border_width=1)
|
||
self.btn_step1p5 = self.create_sidebar_button(
|
||
"1.5 DEM으로 TIN 확장", self.btn_extend_tin_with_dem_callback,
|
||
row=_next_row(), fg_color="transparent", border_width=1)
|
||
self.btn_step2 = self.create_sidebar_button(
|
||
"2. 위성지도 결합", self.btn_draping_callback, row=_next_row(),
|
||
fg_color="transparent", border_width=1)
|
||
self.btn_step3 = self.create_sidebar_button(
|
||
"3. 제어맵 추출", self.btn_control_map_callback, row=_next_row(),
|
||
fg_color="transparent", border_width=1)
|
||
|
||
# 메인 액션 버튼 — S-CANVAS 브랜드 오렌지(Saman Corp 컬러 계열).
|
||
# 사이드바 내 다른 버튼(blue-theme 파생)과 대비되어 사용자의 눈이
|
||
# 자연스럽게 이 버튼으로 유도됨.
|
||
self.btn_step4 = ctk.CTkButton(
|
||
self.sidebar_frame, text="4. AI 렌더링",
|
||
command=self.btn_ai_render_callback, height=40,
|
||
fg_color="#EB001B", hover_color="#A30013",
|
||
font=ctk.CTkFont(weight="bold"))
|
||
self.btn_step4.grid(row=_next_row(), column=0, pady=(6, 6), sticky="ew", **pad)
|
||
|
||
self.btn_struct_build = ctk.CTkButton(
|
||
self.sidebar_frame, text="구조물 상세 3D 빌드",
|
||
command=self._open_structure_template_dialog, height=32,
|
||
fg_color="#22A06B", hover_color="#1B8454", text_color="white",
|
||
font=ctk.CTkFont(size=12, weight="bold"))
|
||
self.btn_struct_build.grid(row=_next_row(), column=0, pady=(3, 0), sticky="ew", **pad)
|
||
|
||
self.btn_detail = ctk.CTkButton(
|
||
self.sidebar_frame, text="간단 치수 추가 (구)",
|
||
command=self._open_detail_upload_dialog, height=26,
|
||
fg_color="transparent", border_width=1,
|
||
border_color=("#CED4DA", "#3F3F3F"),
|
||
text_color=("#6C757D", "#7F8C8D"),
|
||
hover_color=("#E9ECEF", "#2C3E50"),
|
||
font=ctk.CTkFont(size=10))
|
||
self.btn_detail.grid(row=_next_row(), column=0, pady=(3, 0), sticky="ew", **pad)
|
||
|
||
self.btn_reopen_3d = ctk.CTkButton(
|
||
self.sidebar_frame, text="🗔 3D 뷰 다시 열기",
|
||
command=self._reopen_3d_preview, height=30,
|
||
fg_color=("#1A1A1A", "#2C3E50"),
|
||
hover_color=("#000000", "#34495E"),
|
||
text_color="#FFFFFF",
|
||
font=ctk.CTkFont(size=11, weight="bold"))
|
||
self.btn_reopen_3d.grid(row=_next_row(), column=0, pady=(6, 0), sticky="ew", **pad)
|
||
|
||
# --- 구분선 + OPTIONS 섹션 ---
|
||
_divider(pady=(12, 10))
|
||
self.options_label = ctk.CTkLabel(
|
||
self.sidebar_frame, text="OPTIONS",
|
||
font=ctk.CTkFont(size=10, weight="bold"),
|
||
text_color=("#6C757D", "#9A9A9A"))
|
||
self.options_label.grid(row=_next_row(), column=0, pady=(0, 6), sticky="w", **pad)
|
||
|
||
self.wireframe_var = ctk.BooleanVar(value=False)
|
||
self.wireframe_check = ctk.CTkCheckBox(
|
||
self.sidebar_frame, text="와이어프레임 보기",
|
||
variable=self.wireframe_var)
|
||
self.wireframe_check.grid(row=_next_row(), column=0, pady=(8, 2), sticky="w", **pad)
|
||
|
||
# 뷰/DEM 버퍼 설정
|
||
self.dem_extend_var = ctk.BooleanVar(value=False)
|
||
self.dem_frame = ctk.CTkFrame(self.sidebar_frame, fg_color="transparent")
|
||
self.dem_frame.grid(row=_next_row(), column=0, pady=(2, 4), sticky="ew", **pad)
|
||
self.dem_frame.grid_columnconfigure(0, weight=1)
|
||
|
||
self.buffer_percent_label = ctk.CTkLabel(
|
||
self.dem_frame, text="뷰 버퍼 (%) [Step2/3]",
|
||
font=ctk.CTkFont(size=11))
|
||
self.buffer_percent_label.grid(row=0, column=0, sticky="w")
|
||
self.buffer_percent_var = ctk.StringVar(value="5")
|
||
self.buffer_percent_entry = ctk.CTkEntry(
|
||
self.dem_frame, textvariable=self.buffer_percent_var,
|
||
placeholder_text="%", width=60,
|
||
font=ctk.CTkFont(size=11))
|
||
self.buffer_percent_entry.grid(row=0, column=1, padx=(6, 0), sticky="e")
|
||
|
||
self.dem_extend_check = ctk.CTkCheckBox(
|
||
self.dem_frame, text="지형 확장 (DEM)",
|
||
variable=self.dem_extend_var,
|
||
font=ctk.CTkFont(size=11))
|
||
self.dem_extend_check.grid(row=1, column=0, pady=(4, 0), sticky="w")
|
||
self.dem_buffer_var = ctk.StringVar(value="1000")
|
||
self.dem_buffer_entry = ctk.CTkEntry(
|
||
self.dem_frame, textvariable=self.dem_buffer_var,
|
||
placeholder_text="m", width=60,
|
||
font=ctk.CTkFont(size=11))
|
||
self.dem_buffer_entry.grid(row=1, column=1, padx=(6, 0), pady=(4, 0), sticky="e")
|
||
|
||
# 테마 설정 — 별도 row (이전에는 dem_frame과 row 충돌)
|
||
self.appearance_mode_optionemenu = ctk.CTkOptionMenu(
|
||
self.sidebar_frame, values=["Dark", "Light"],
|
||
command=self.change_appearance_mode_event)
|
||
self.appearance_mode_optionemenu.grid(
|
||
row=_next_row(), column=0, pady=(8, 4), sticky="ew", **pad)
|
||
self.appearance_mode_optionemenu.set("Light")
|
||
|
||
# --- 구분선: OPTIONS ↔ Saman 크레딧 푸터 ---
|
||
_divider(pady=(14, 0))
|
||
|
||
# SAMAN Corp 크레딧 — 사이드바 최하단 푸터.
|
||
# Design/SAMAN_CI.gif 는 흰 배경 GIF → `_load_image_strip_white_bg` 로
|
||
# 흰색 픽셀을 알파 0 으로 변환해 Light/Dark 테마 어느 쪽에서도 배경과
|
||
# 자연스럽게 어우러지도록. 실패 시 텍스트 폴백.
|
||
try:
|
||
_saman_src = self._saman_asset_dir / "SAMAN_CI.gif"
|
||
_saman_pil = _load_image_strip_white_bg(_saman_src, threshold=235)
|
||
_sw, _sh = _saman_pil.size
|
||
_saman_w = 150
|
||
_saman_h = int(_sh * _saman_w / max(_sw, 1))
|
||
_saman_img = ctk.CTkImage(
|
||
light_image=_saman_pil, dark_image=_saman_pil,
|
||
size=(_saman_w, _saman_h),
|
||
)
|
||
self.saman_credit = ctk.CTkLabel(
|
||
self.sidebar_frame, image=_saman_img, text="",
|
||
fg_color="transparent")
|
||
except Exception as _se:
|
||
self.saman_credit = ctk.CTkLabel(
|
||
self.sidebar_frame, text="© Saman Corp.",
|
||
font=ctk.CTkFont(size=10),
|
||
text_color=("#6C757D", "gray"),
|
||
fg_color="transparent")
|
||
print(f"[Warning] SAMAN_CI 로드 실패: {_se}")
|
||
self.saman_credit.grid(row=_next_row(), column=0, pady=(10, 16), **pad)
|
||
|
||
# --- 메인 콘텐츠 프레임 ---
|
||
self.main_frame = ctk.CTkFrame(self, corner_radius=15, fg_color="transparent")
|
||
self.main_frame.grid(row=0, column=1, padx=20, pady=20, sticky="nsew")
|
||
self.main_frame.grid_columnconfigure(0, weight=1)
|
||
# 피드백 #4: 인라인 로그 패널 제거. row 0 (지도/캔버스) 전체, row 1 (status_bar) 만 남음.
|
||
self.main_frame.grid_rowconfigure(0, weight=1) # 지도/캔버스 (전체)
|
||
|
||
# 1. 지도 (상단 — 넓게). Light/Dark 테마별 배경 쌍 — tkintermapview
|
||
# 타일 주변의 얇은 padding 에 이 색이 보임.
|
||
self.map_frame = ctk.CTkFrame(
|
||
self.main_frame, corner_radius=12,
|
||
fg_color=("#FFFFFF", "#1A1A1A"),
|
||
border_width=1, border_color=("#DEE2E6", "#3F3F3F"),
|
||
)
|
||
self.map_frame.grid(row=0, column=0, padx=0, pady=(0, 8), sticky="nsew")
|
||
self.map_frame.grid_columnconfigure(0, weight=1)
|
||
self.map_frame.grid_rowconfigure(0, weight=1)
|
||
|
||
self.map_view = tkintermapview.TkinterMapView(self.map_frame, corner_radius=10)
|
||
self.map_view.grid(row=0, column=0, padx=5, pady=5, sticky="nsew")
|
||
self.map_view.set_tile_server("https://mt0.google.com/vt/lyrs=s&x={x}&y={y}&z={z}")
|
||
self.map_view.set_zoom(6)
|
||
self.map_view.set_position(36.5, 127.5)
|
||
|
||
# 2. 로그 패널 — 피드백 #4: **제거** (인라인 → 백엔드 파일).
|
||
# 파일 위치: %LOCALAPPDATA%\\S-CANVAS\\scanvas_harness.log + logs/scanvas.log.
|
||
# self.log() 호출은 그대로 유지하되 logger 로 흘러가고 status_text 만 갱신.
|
||
|
||
# 3. 하단 상태 바 — 로그 제거로 row 2 → row 1
|
||
self.status_bar = ctk.CTkFrame(self.main_frame, height=28, fg_color="transparent")
|
||
self.status_bar.grid(row=1, column=0, sticky="ew", pady=(5, 0))
|
||
|
||
self.status_indicator = ctk.CTkLabel(self.status_bar, text="● READY", text_color="#22A06B", font=ctk.CTkFont(size=12, weight="bold"))
|
||
self.status_indicator.pack(side="left", padx=10)
|
||
|
||
self.status_text = ctk.CTkLabel(self.status_bar, text="지형 데이터를 로드해 주세요.", font=ctk.CTkFont(size=12))
|
||
self.status_text.pack(side="left")
|
||
|
||
# 진행률 인디케이터 (피드백 #4 — "느리게 느껴짐" 일부 해결).
|
||
# 기본 hidden. start_progress/stop_progress 로 토글. indeterminate animation
|
||
# 으로 "시스템이 살아있다" 시그널 — 실 진행률 측정은 future work (#11 perf 와 연계).
|
||
# MC accent 색상 (#FF5F00 overlap orange) 적용.
|
||
self.progress_bar = ctk.CTkProgressBar(
|
||
self.status_bar, mode="indeterminate", width=180, height=10,
|
||
progress_color="#FF5F00", fg_color=("#E0E0E0", "#333333"),
|
||
)
|
||
# 초기엔 hidden (pack 안 함). start_progress 시 등장.
|
||
|
||
self.log("S-CANVAS Generative Design Engine 구동 완료.")
|
||
|
||
# Perf 측정 라인을 GUI 로그에도 함께 표시 (#11). harness/perf.py 폴백 import 시
|
||
# set_perf_log는 no-op이라 실패해도 안전.
|
||
set_perf_log(self.log)
|
||
|
||
def create_sidebar_button(self, text, command, row, **kwargs):
|
||
btn = ctk.CTkButton(
|
||
self.sidebar_frame, text=text, command=command, height=34, **kwargs)
|
||
btn.grid(row=row, column=0, padx=14, pady=4, sticky="ew")
|
||
return btn
|
||
|
||
def _setup_window_icon(self):
|
||
"""logo_V2.png 의 'S' 글자만 잘라 ICO 로 변환 → 타이틀바·작업표시줄 아이콘.
|
||
|
||
절차:
|
||
1. logo_V2.png 다크 배경 소프트 스트립
|
||
2. 좌측 850px 크롭 → getbbox 로 'S' 만 타이트하게 트림
|
||
3. 정사각 패딩(투명) → 멀티사이즈 ICO 저장 (16/32/48/64/128/256)
|
||
4. self.iconbitmap 으로 적용. 실패 시 PhotoImage 폴백.
|
||
|
||
캐시: cache/icons/scanvas_S.ico. logo_V2.png 가 더 새로우면 재생성.
|
||
"""
|
||
try:
|
||
src = resource_path("Design", "logo_V2.png")
|
||
if not src.exists():
|
||
return
|
||
icon_cache = cache_dir("icons")
|
||
ico_path = icon_cache / "scanvas_S.ico"
|
||
png_path = icon_cache / "scanvas_S.png"
|
||
|
||
need_regen = (
|
||
not ico_path.exists()
|
||
or src.stat().st_mtime > ico_path.stat().st_mtime
|
||
)
|
||
if need_regen:
|
||
# 1) 다크 bg **하드 스트립** (아이콘용 — 잔여 회로 패턴 제거를 위해
|
||
# 소프트 전이 대신 단일 임계 사용. v < 90 인 픽셀은 알파 0).
|
||
pil = Image.open(src).convert("RGBA")
|
||
_arr = np.asarray(pil).copy()
|
||
_v = np.maximum.reduce([_arr[..., 0], _arr[..., 1], _arr[..., 2]])
|
||
_arr[..., 3] = np.where(_v < 90, 0, _arr[..., 3])
|
||
w = pil.width
|
||
# 2) 좌측 ~32% 크롭 (S 영역)
|
||
crop_w = min(w, 850)
|
||
_carr = _arr[:, :crop_w, :]
|
||
# 3) **연결 컴포넌트 기반 S 만 추출** — 회로 패턴/EG-BIM 워터마크 등
|
||
# 잔여 잡음 제거. 가장 큰 컴포넌트 + 그 10% 이상 크기인 다른
|
||
# 컴포넌트(예: S 의 하부 곡선이 strip 으로 끊긴 경우) 만 유지.
|
||
from scipy import ndimage as _nd
|
||
_mask = _carr[..., 3] > 100
|
||
_labeled, _ncomp = _nd.label(_mask)
|
||
if _ncomp > 0:
|
||
_sizes = _nd.sum(_mask, _labeled, range(1, _ncomp + 1))
|
||
_max_size = float(_sizes.max())
|
||
_keep_ids = [i + 1 for i, s in enumerate(_sizes)
|
||
if s >= _max_size * 0.1]
|
||
_keep_mask = np.isin(_labeled, _keep_ids)
|
||
# 비-S 픽셀의 알파 0 처리
|
||
_carr_clean = _carr.copy()
|
||
_carr_clean[..., 3] = np.where(_keep_mask, _carr[..., 3], 0)
|
||
# 타이트 bbox
|
||
_ys, _xs = np.where(_keep_mask)
|
||
_x0, _x1 = int(_xs.min()), int(_xs.max()) + 1
|
||
_y0, _y1 = int(_ys.min()), int(_ys.max()) + 1
|
||
cropped = Image.fromarray(
|
||
_carr_clean[_y0:_y1, _x0:_x1, :], mode="RGBA")
|
||
else:
|
||
cropped = Image.fromarray(_carr, mode="RGBA")
|
||
# 4) 정사각 패딩 (투명)
|
||
cw, ch = cropped.size
|
||
side = max(cw, ch)
|
||
# 살짝 외곽 padding 추가 (아이콘이 너무 꽉 차 보이지 않게)
|
||
pad_px = max(int(side * 0.04), 4)
|
||
side_p = side + 2 * pad_px
|
||
square = Image.new("RGBA", (side_p, side_p), (0, 0, 0, 0))
|
||
offset = ((side_p - cw) // 2, (side_p - ch) // 2)
|
||
square.paste(cropped, offset, cropped)
|
||
# 4) ICO 멀티사이즈 저장 + PNG 폴백 사본
|
||
square.save(
|
||
ico_path, format="ICO",
|
||
sizes=[(16, 16), (32, 32), (48, 48), (64, 64),
|
||
(128, 128), (256, 256)],
|
||
)
|
||
square.resize((256, 256), Image.LANCZOS).save(png_path)
|
||
|
||
# 5) Tk 적용 — Windows 는 iconbitmap, 실패 시 iconphoto 폴백
|
||
try:
|
||
self.iconbitmap(default=str(ico_path))
|
||
except Exception:
|
||
try:
|
||
self.iconbitmap(str(ico_path))
|
||
except Exception:
|
||
from tkinter import PhotoImage
|
||
photo = PhotoImage(file=str(png_path))
|
||
self.iconphoto(True, photo)
|
||
self._icon_photo_ref = photo # GC 방지
|
||
except Exception as e:
|
||
print(f"[Warning] 윈도우 아이콘 설정 실패: {e}")
|
||
|
||
def log(self, message):
|
||
"""피드백 #4: 인라인 로그 패널 제거 후, 모든 메시지는 백엔드 파일로.
|
||
|
||
- 백엔드: %LOCALAPPDATA%\\S-CANVAS\\scanvas_harness.log + logs/scanvas.log
|
||
(RotatingFileHandler 5MB×5).
|
||
- 사용자 즉시 피드백: status_bar 의 status_text 가 짧은 미리보기로 갱신
|
||
(긴 메시지는 80자에서 잘림 + …).
|
||
- 매 호출이 textbox.insert 으로 GUI 메인 thread 부담 주던 패턴 제거됨.
|
||
"""
|
||
if _get_crash_logger is not None:
|
||
with contextlib.suppress(Exception):
|
||
_get_crash_logger().info(message)
|
||
if hasattr(self, "status_text"):
|
||
short = message if len(message) <= 80 else message[:77] + "…"
|
||
self.after(0, lambda s=short: self.status_text.configure(text=s))
|
||
|
||
def start_progress(self, label: str | None = None) -> None:
|
||
"""진행률 인디케이터 표시 + indeterminate animation 시작.
|
||
|
||
피드백 #4 — 긴 작업 시 "시스템 살아있음" 시그널. label 주면 status_text 도
|
||
함께 갱신. 메인 thread 블로킹 작업이라도 호출 직전/직후에 표시 가능 (실
|
||
애니메이션은 idle time 에 의존).
|
||
"""
|
||
def _start():
|
||
with contextlib.suppress(Exception):
|
||
self.progress_bar.pack(side="right", padx=(8, 12), pady=4)
|
||
self.progress_bar.start()
|
||
if label is not None:
|
||
self.status_text.configure(text=label)
|
||
self.update_idletasks()
|
||
self.after(0, _start)
|
||
|
||
def stop_progress(self, final_label: str | None = None) -> None:
|
||
"""진행률 인디케이터 숨김 + animation 정지."""
|
||
def _stop():
|
||
with contextlib.suppress(Exception):
|
||
self.progress_bar.stop()
|
||
self.progress_bar.pack_forget()
|
||
if final_label is not None:
|
||
self.status_text.configure(text=final_label)
|
||
self.update_idletasks()
|
||
self.after(0, _stop)
|
||
|
||
def _diag(self, message, *, reset=False):
|
||
"""구조물 분류/추출 진단 로그 (scanvas_diagnostic.log).
|
||
|
||
reset=True면 파일을 새로 덮어쓰기(세션 시작). 그 외에는 append.
|
||
사용자가 이 파일을 제출하면 Step 1 흐름의 모든 결정을 재구성할 수 있다.
|
||
"""
|
||
mode = "w" if reset else "a"
|
||
try:
|
||
with open(self.diag_log_path, mode, encoding="utf-8") as f:
|
||
ts = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
f.write(f"[{ts}] {message}\n")
|
||
except Exception:
|
||
pass
|
||
|
||
def set_status(self, text, indicator_color="#22A06B"):
|
||
def _update():
|
||
self.status_text.configure(text=text)
|
||
self.status_indicator.configure(text_color=indicator_color)
|
||
self.after(0, _update)
|
||
|
||
def change_appearance_mode_event(self, new_appearance_mode: str):
|
||
ctk.set_appearance_mode(new_appearance_mode)
|
||
|
||
def _load_structure_types(self):
|
||
"""YAML에서 구조물 유형 레지스트리를 로드"""
|
||
try:
|
||
import yaml
|
||
yaml_path = resource_path("structure_types", "structure_v1.yaml")
|
||
if yaml_path.exists():
|
||
with open(yaml_path, encoding="utf-8") as f:
|
||
data = yaml.safe_load(f)
|
||
self.structure_types = data.get("types", {})
|
||
else:
|
||
# 기본 유형 (YAML 없을 때 폴백)
|
||
self.structure_types = {
|
||
"terrain": {"name_ko": "지형 (등고선)", "render_mode": "tin", "color": "#8B7355"},
|
||
"excavation": {"name_ko": "굴착", "render_mode": "surface_overlay", "color": "#D4A373"},
|
||
"road": {"name_ko": "도로/공사용도로", "render_mode": "path_extrude", "color": "#3D3D3D"},
|
||
"cofferdam_upstream": {"name_ko": "상류 가물막이", "render_mode": "wall_extrude", "color": "#6C757D"},
|
||
"cofferdam_downstream": {"name_ko": "하류 가물막이", "render_mode": "wall_extrude", "color": "#8D9CA6"},
|
||
"building": {"name_ko": "건축물/가설건물", "render_mode": "box_extrude", "color": "#BDC3C7"},
|
||
"temp_facility": {"name_ko": "가설부지/야적장", "render_mode": "surface_overlay", "color": "#E8DACC"},
|
||
"boundary": {"name_ko": "경계선 (참고용)", "render_mode": "line_only", "color": "#EB001B"},
|
||
"ignore": {"name_ko": "무시 (사용 안 함)", "render_mode": "none", "color": "#CCCCCC"},
|
||
}
|
||
except Exception:
|
||
self.structure_types = {"terrain": {"name_ko": "지형", "render_mode": "tin"},
|
||
"ignore": {"name_ko": "무시", "render_mode": "none"}}
|
||
|
||
def _open_layer_classifier(self):
|
||
"""DXF 레이어 목록을 표시하고 사용자가 각 레이어에 구조물 유형을 지정하는 팝업.
|
||
레이어별 엔티티 분석 결과(타입, 개수, Z값 유무)를 함께 표시.
|
||
"""
|
||
if not self.dxf_doc:
|
||
return False
|
||
|
||
# 레이어별 엔티티 분석 (자동 스캔)
|
||
layer_info = self._scan_layer_entities()
|
||
layers = sorted(layer_info.keys())
|
||
|
||
if not layers:
|
||
return False
|
||
|
||
# 유형 선택지 구성
|
||
type_options = {tid: tdef.get("name_ko", tid) for tid, tdef in self.structure_types.items()}
|
||
option_list = list(type_options.values())
|
||
|
||
# 팝업 창
|
||
win = InlinePanel(self)
|
||
win.title("S-CANVAS: DXF 레이어 분류")
|
||
win.geometry("900x650")
|
||
win.grab_set()
|
||
|
||
header = ctk.CTkLabel(win, text="각 레이어에 구조물 유형을 지정하세요",
|
||
font=ctk.CTkFont(size=15, weight="bold"))
|
||
header.pack(padx=20, pady=(15, 5))
|
||
|
||
hint = ctk.CTkLabel(win,
|
||
text="★ = Z값 있음 (지형 후보) | 엔티티 타입과 개수를 참고하세요 | 초록색 = 지형 자동 감지",
|
||
font=ctk.CTkFont(size=12), text_color="gray")
|
||
hint.pack(padx=20, pady=(0, 10))
|
||
|
||
# 스크롤 가능한 프레임
|
||
scroll_frame = ctk.CTkScrollableFrame(win, height=440)
|
||
scroll_frame.pack(padx=20, pady=5, fill="both", expand=True)
|
||
scroll_frame.grid_columnconfigure(0, weight=1) # 레이어 이름
|
||
scroll_frame.grid_columnconfigure(1, weight=0) # 엔티티 정보
|
||
scroll_frame.grid_columnconfigure(2, weight=0) # 드롭다운
|
||
|
||
# Z값 기반 상위 3개 지형 후보 계산
|
||
# 블랙리스트: 블록의 기본 레이어("0") 및 AutoCAD 메타 레이어는 auto-terrain 제외
|
||
# — INSERT 재귀 explode 후 "0"에 구조물 치수/보조선 Z값이 쌓여 false-positive됨
|
||
_terrain_blacklist = {"0", "Defpoints", "DEFPOINTS"}
|
||
scored = []
|
||
for ln, inf in layer_info.items():
|
||
if ln in _terrain_blacklist:
|
||
continue
|
||
if ln.upper().startswith(("CR-", "AM_", "_")): # 도면 템플릿/프레임 레이어
|
||
continue
|
||
if inf.get("has_z", False):
|
||
zv = len(inf.get("z_values", set()))
|
||
pc = inf.get("point_count", 0)
|
||
if zv * pc > 0:
|
||
scored.append((ln, zv * pc))
|
||
scored.sort(key=lambda x: -x[1])
|
||
top3_terrain = set(s[0] for s in scored[:3])
|
||
|
||
self._diag("=== 레이어 분류 UI 초기 자동추측 ===")
|
||
self._diag(f" top3 terrain 후보 (Z값 기반): {sorted(top3_terrain)}")
|
||
|
||
layer_vars = {}
|
||
for i, layer_name in enumerate(layers):
|
||
info = layer_info[layer_name]
|
||
entity_summary = info.get("summary", "")
|
||
has_z = info.get("has_z", False)
|
||
|
||
# 자동 추측: 상위 3개는 지형, 나머지는 키워드
|
||
if layer_name in top3_terrain:
|
||
guessed = "terrain"
|
||
reason = "top3 Z"
|
||
else:
|
||
guessed = self._guess_layer_type(layer_name)
|
||
reason = "keyword" if guessed != "ignore" else "fallback"
|
||
self._diag(f" {layer_name} → [guess] {guessed} ({reason})")
|
||
|
||
# 레이어 이름 (Z값 있으면 ★ 표시)
|
||
z_marker = "★ " if has_z else " "
|
||
name_text = f"{z_marker}{layer_name}"
|
||
|
||
name_color = "#22A06B" if guessed == "terrain" else None
|
||
lbl = ctk.CTkLabel(scroll_frame, text=name_text, font=ctk.CTkFont(size=12),
|
||
anchor="w", width=320, text_color=name_color)
|
||
lbl.grid(row=i, column=0, padx=(5, 3), pady=2, sticky="w")
|
||
|
||
# 엔티티 정보
|
||
info_lbl = ctk.CTkLabel(scroll_frame, text=entity_summary,
|
||
font=ctk.CTkFont(size=10), text_color="gray",
|
||
anchor="w", width=250)
|
||
info_lbl.grid(row=i, column=1, padx=3, pady=2, sticky="w")
|
||
|
||
# 드롭다운
|
||
var = ctk.StringVar(value=type_options.get(guessed, type_options.get("ignore", option_list[-1])))
|
||
dropdown = ctk.CTkOptionMenu(scroll_frame, variable=var, values=option_list, width=200)
|
||
dropdown.grid(row=i, column=2, padx=(3, 5), pady=2, sticky="e")
|
||
|
||
layer_vars[layer_name] = var
|
||
|
||
# 확인/취소 버튼
|
||
result = [False]
|
||
|
||
def on_confirm():
|
||
name_to_id = {v: k for k, v in type_options.items()}
|
||
self.layer_mapping = {}
|
||
for layer_name, var in layer_vars.items():
|
||
selected_name = var.get()
|
||
type_id = name_to_id.get(selected_name, "ignore")
|
||
self.layer_mapping[layer_name] = type_id
|
||
result[0] = True
|
||
# 진단 로그: 분류 결과
|
||
self._diag("=== 레이어 분류 확인 (사용자 최종 선택) ===")
|
||
for ln, tid in self.layer_mapping.items():
|
||
tdef = self.structure_types.get(tid, {})
|
||
rmode = tdef.get("render_mode", "?")
|
||
self._diag(f" {ln} → {tid} ({tdef.get('name_ko', '?')}, render_mode={rmode})")
|
||
win.destroy()
|
||
|
||
def on_cancel():
|
||
win.destroy()
|
||
|
||
def set_all(type_id):
|
||
target = type_options.get(type_id, option_list[-1])
|
||
for v in layer_vars.values():
|
||
v.set(target)
|
||
|
||
btn_frame = ctk.CTkFrame(win, fg_color="transparent")
|
||
btn_frame.pack(padx=20, pady=10, fill="x")
|
||
|
||
ctk.CTkButton(btn_frame, text="전체 무시", width=90, fg_color="transparent", border_width=1,
|
||
command=lambda: set_all("ignore")).pack(side="left", padx=3)
|
||
ctk.CTkButton(btn_frame, text="전체 지형", width=90, fg_color="transparent", border_width=1,
|
||
command=lambda: set_all("terrain")).pack(side="left", padx=3)
|
||
ctk.CTkButton(btn_frame, text="★ 자동 감지", width=100, fg_color="transparent", border_width=1,
|
||
command=lambda: self._auto_detect_layers(layer_vars, layer_info, type_options)).pack(side="left", padx=3)
|
||
ctk.CTkButton(btn_frame, text="취소", width=80, fg_color="transparent", border_width=1,
|
||
command=on_cancel).pack(side="right", padx=3)
|
||
ctk.CTkButton(btn_frame, text="확인 (분류 적용)", width=160,
|
||
command=on_confirm).pack(side="right", padx=3)
|
||
|
||
win.wait_window()
|
||
return result[0]
|
||
|
||
def _iter_exploded_entities(self, msp):
|
||
"""msp의 모든 엔티티를 INSERT 재귀 explode.
|
||
|
||
Yield: (실제_레이어, entity, from_insert)
|
||
from_insert=True면 블록 참조 내부에서 나온 엔티티 (도로 등에서 제외 판단용)
|
||
|
||
AutoCAD 블록 규칙:
|
||
- 블록 내부 엔티티 dxf.layer="0" → INSERT의 레이어 상속
|
||
- 구체 레이어명 → 유지
|
||
- 중첩 INSERT 재귀
|
||
"""
|
||
def _walk(ent, inherit_layer, from_insert):
|
||
etype = ent.dxftype()
|
||
if etype == "INSERT":
|
||
try:
|
||
for sub in ent.virtual_entities():
|
||
sub_layer = sub.dxf.layer
|
||
effective = inherit_layer if sub_layer == "0" else sub_layer
|
||
yield from _walk(sub, effective, True)
|
||
except Exception:
|
||
pass
|
||
else:
|
||
yield (inherit_layer, ent, from_insert)
|
||
|
||
for entity in msp:
|
||
yield from _walk(entity, entity.dxf.layer, False)
|
||
|
||
def _scan_layer_entities(self):
|
||
"""모든 레이어의 엔티티 타입, 개수, Z값 유무를 스캔 (INSERT explode 포함)"""
|
||
msp = self.dxf_doc.modelspace()
|
||
layer_info = {}
|
||
|
||
for layer_name, entity, _from_insert in self._iter_exploded_entities(msp):
|
||
if layer_name not in layer_info:
|
||
layer_info[layer_name] = {"types": {}, "has_z": False, "point_count": 0, "z_values": set()}
|
||
|
||
info = layer_info[layer_name]
|
||
etype = entity.dxftype()
|
||
info["types"][etype] = info["types"].get(etype, 0) + 1
|
||
|
||
# Z값 체크
|
||
try:
|
||
if etype == "LWPOLYLINE":
|
||
z = entity.dxf.elevation if entity.dxf.hasattr('elevation') else 0.0
|
||
pts = list(entity.get_points())
|
||
info["point_count"] += len(pts)
|
||
if abs(z) > 0.01:
|
||
info["has_z"] = True
|
||
info["z_values"].add(round(z, 1))
|
||
elif etype == "LINE":
|
||
for attr in ['start', 'end']:
|
||
pt = getattr(entity.dxf, attr)
|
||
if abs(pt.z) > 0.01:
|
||
info["has_z"] = True
|
||
info["z_values"].add(round(pt.z, 1))
|
||
info["point_count"] += 2
|
||
elif etype == "POLYLINE":
|
||
for v in entity.vertices:
|
||
loc = v.dxf.location
|
||
if abs(loc.z) > 0.01:
|
||
info["has_z"] = True
|
||
info["z_values"].add(round(loc.z, 1))
|
||
info["point_count"] += 1
|
||
elif etype == "POINT":
|
||
pt = entity.dxf.location
|
||
if abs(pt.z) > 0.01:
|
||
info["has_z"] = True
|
||
info["point_count"] += 1
|
||
elif etype == "SPLINE":
|
||
for pt in entity.control_points:
|
||
if len(pt) > 2 and abs(pt[2]) > 0.01:
|
||
info["has_z"] = True
|
||
info["point_count"] += 1
|
||
except Exception:
|
||
pass
|
||
|
||
# 요약 문자열 생성
|
||
for layer_name, info in layer_info.items():
|
||
parts = []
|
||
for etype, count in sorted(info["types"].items(), key=lambda x: -x[1]):
|
||
parts.append(f"{etype}:{count}")
|
||
z_tag = f" Z({len(info['z_values'])}종)" if info["has_z"] else ""
|
||
info["summary"] = ", ".join(parts[:3]) + z_tag
|
||
|
||
# 진단 로그: 전체 레이어 엔티티 스캔 결과
|
||
self._diag("=== _scan_layer_entities 결과 (DXF 원본 분석) ===")
|
||
for ln in sorted(layer_info.keys()):
|
||
inf = layer_info[ln]
|
||
self._diag(f" {ln}: entities={dict(inf['types'])}, has_z={inf['has_z']}, points={inf['point_count']}")
|
||
|
||
# 레이어 목록에 없지만 layers 테이블에 있는 것도 추가 (빈 레이어)
|
||
for layer in self.dxf_doc.layers:
|
||
if layer.dxf.name not in layer_info:
|
||
layer_info[layer.dxf.name] = {"types": {}, "has_z": False, "point_count": 0,
|
||
"z_values": set(), "summary": "(빈 레이어)"}
|
||
|
||
return layer_info
|
||
|
||
def _auto_detect_layers(self, layer_vars, layer_info, type_options):
|
||
"""Z값 다양성 × 점 개수로 점수화 → 상위 3개만 지형, 나머지는 키워드 추측 또는 무시"""
|
||
|
||
# 1. Z값이 있는 레이어만 점수 계산
|
||
scored = []
|
||
for layer_name, info in layer_info.items():
|
||
if not info.get("has_z", False):
|
||
continue
|
||
z_variety = len(info.get("z_values", set())) # Z값 종류 수
|
||
pt_count = info.get("point_count", 0)
|
||
# 점수 = Z값 다양성 × 점 개수 (등고선은 Z값 종류가 많고 점도 많음)
|
||
score = z_variety * pt_count
|
||
if score > 0:
|
||
scored.append((layer_name, score, z_variety, pt_count))
|
||
|
||
# 2. 점수 내림차순 정렬 → 상위 3개
|
||
scored.sort(key=lambda x: -x[1])
|
||
top3 = set(s[0] for s in scored[:3])
|
||
|
||
# 로그
|
||
if scored:
|
||
self.log(" ★ 자동 감지 — Z값 기반 지형 랭킹:")
|
||
for rank, (name, score, zv, pc) in enumerate(scored[:5], 1):
|
||
marker = "→ 지형" if name in top3 else " (제외)"
|
||
self.log(f" {rank}. {name}: 점수={score:,} (Z {zv}종 × {pc:,}점) {marker}")
|
||
else:
|
||
self.log(" ★ 자동 감지 — Z값이 있는 레이어를 찾지 못했습니다.")
|
||
|
||
# 3. 적용
|
||
for layer_name, var in layer_vars.items():
|
||
if layer_name in top3:
|
||
var.set(type_options.get("terrain", "지형 (등고선)"))
|
||
else:
|
||
guessed = self._guess_layer_type(layer_name)
|
||
if guessed != "ignore":
|
||
var.set(type_options.get(guessed, type_options.get("ignore")))
|
||
else:
|
||
var.set(type_options.get("ignore", "무시 (사용 안 함)"))
|
||
|
||
def _guess_layer_type(self, layer_name):
|
||
"""레이어 이름에서 구조물 유형을 자동 추측.
|
||
|
||
한글/영문/약어/복합어를 넓게 커버. 긴 키워드 우선 매칭되도록
|
||
순서를 구체 → 일반 순으로 둔다.
|
||
"""
|
||
name = layer_name.lower()
|
||
|
||
# "노리"(비탈면/사면 표시선)는 도로가 아니라 경계선으로 분류
|
||
if "노리" in name or "nori" in name or "사면" in name or "slope_line" in name:
|
||
return "boundary"
|
||
|
||
# 한글/영문 키워드 매핑 (구체적인 것부터 위에)
|
||
# 순서 중요: 복합어("취수탑_옹벽")는 앞에 오는 키워드가 우선 매칭됨
|
||
keywords = {
|
||
"terrain": ["등고", "contour", "지형", "elev", "topo", "tin", "ground"],
|
||
"intake_tower": ["취수탑", "intake_tower", "intake tower", "intake"],
|
||
"valve_chamber": ["제수변실", "밸브실", "valve_chamber", "valve chamber"],
|
||
"spillway_gate": ["수문", "gate", "게이트", "래디얼", "radial", "spillway_gate"],
|
||
"cofferdam_upstream": ["상류가물", "upstream_coffer", "상류물막이", "상류 가물막이"],
|
||
"cofferdam_downstream": ["하류가물", "downstream_coffer", "하류물막이", "하류 가물막이"],
|
||
"diversion": ["유수전환", "diversion", "전환수로"],
|
||
"spillway": ["여수로", "spillway", "방류", "spill_way"],
|
||
"retaining_wall": ["옹벽", "retaining", "ret_wall", "좌안옹벽", "우안옹벽", "방벽"],
|
||
"revetment": ["호안", "revetment"],
|
||
"excavation": ["굴착", "excavat", "절토", "터파기", "cut"],
|
||
"embankment": ["성토", "제체", "embankment", "fill", "dam_body"],
|
||
"building": ["건물", "건축물", "building", "가설건물", "사무소", "현장사무", "관리동"],
|
||
"temp_facility": ["가설", "야적", "temp", "staging", "부지"],
|
||
"bridge": ["교량", "bridge", "viaduct"],
|
||
"tunnel": ["터널", "tunnel", "갱구"],
|
||
"pipeline": ["관로", "pipe", "파이프"],
|
||
"road": ["도로", "road", "진입", "access", "가도", "공사용도로", "관리도로"],
|
||
"boundary": ["경계", "boundary", "범위", "부지경계"],
|
||
# "wall"은 retaining_wall 다음에 — 너무 일반적이라 최하위
|
||
}
|
||
for type_id, kws in keywords.items():
|
||
if any(kw in name for kw in kws):
|
||
return type_id
|
||
# wall 단독 매칭 (다른 키워드 모두 실패 후에만)
|
||
if "wall" in name:
|
||
return "retaining_wall"
|
||
return "ignore"
|
||
|
||
def _extract_layer_geometries(self):
|
||
"""분류된 레이어에서 지오메트리를 추출 (Phase 4-1: 좌표만 저장)"""
|
||
import math as _math
|
||
|
||
self._diag("=== _extract_layer_geometries 시작 ===")
|
||
|
||
if not self.dxf_doc or not self.layer_mapping:
|
||
self.log(" 계획선 추출 건너뜀: DXF 문서 또는 레이어 매핑 없음")
|
||
self._diag(f" 건너뜀: dxf_doc={bool(self.dxf_doc)}, layer_mapping={len(self.layer_mapping or {})}")
|
||
return 0
|
||
|
||
msp = self.dxf_doc.modelspace()
|
||
self.layer_geometries = {}
|
||
|
||
# 지형/무시 제외한 레이어만 처리
|
||
target_layers = {ln: tid for ln, tid in self.layer_mapping.items()
|
||
if tid not in ("ignore", "terrain")}
|
||
|
||
self._diag(f" 전체 레이어: {len(self.layer_mapping)}개, 추출대상(비terrain/비ignore): {len(target_layers)}개")
|
||
|
||
if not target_layers:
|
||
self.log(" 계획선 추출 건너뜀: 지형/무시 외 분류된 레이어 없음")
|
||
self._diag(" 결과: 추출대상 0개 — 모든 레이어가 terrain/ignore로 분류됨")
|
||
return 0
|
||
|
||
self.log(f" 계획선 대상 레이어: {list(target_layers.keys())}")
|
||
self._diag(f" 대상 레이어 상세: {target_layers}")
|
||
|
||
# Phase 1: 전체 msp를 한 번만 순회하며 (레이어 → 엔티티 리스트) 버킷팅
|
||
# INSERT는 재귀 explode하여 sub-entity의 실제 레이어로 귀속
|
||
buckets = {ln: [] for ln in target_layers}
|
||
for ent_layer, ent, from_insert in self._iter_exploded_entities(msp):
|
||
if ent_layer not in buckets:
|
||
continue
|
||
# 도로/path_extrude 레이어의 INSERT 내부 엔티티는 제외 (횡단/사면/치수 혼재)
|
||
type_id = target_layers[ent_layer]
|
||
rmode = self.structure_types.get(type_id, {}).get("render_mode")
|
||
if from_insert and (type_id == "road" or rmode == "path_extrude"):
|
||
continue
|
||
buckets[ent_layer].append(ent)
|
||
|
||
# Phase 2: 각 레이어의 엔티티에서 geometry 추출
|
||
for layer_name, type_id in target_layers.items():
|
||
geoms = []
|
||
entity_types_found = {}
|
||
|
||
for entity in buckets[layer_name]:
|
||
etype = entity.dxftype()
|
||
entity_types_found[etype] = entity_types_found.get(etype, 0) + 1
|
||
|
||
if etype == "LWPOLYLINE":
|
||
pts = [(p[0], p[1]) for p in entity.get_points()]
|
||
is_closed = entity.closed
|
||
if len(pts) >= 2:
|
||
geoms.append({"type": "polyline", "points": pts, "closed": is_closed})
|
||
|
||
elif etype == "LINE":
|
||
geoms.append({"type": "line",
|
||
"start": (entity.dxf.start.x, entity.dxf.start.y),
|
||
"end": (entity.dxf.end.x, entity.dxf.end.y)})
|
||
|
||
elif etype == "POLYLINE":
|
||
pts = [(v.dxf.location.x, v.dxf.location.y) for v in entity.vertices]
|
||
if len(pts) >= 2:
|
||
geoms.append({"type": "polyline", "points": pts, "closed": entity.is_closed})
|
||
|
||
elif etype == "CIRCLE":
|
||
c = entity.dxf.center
|
||
geoms.append({"type": "circle", "center": (c.x, c.y), "radius": entity.dxf.radius})
|
||
|
||
elif etype == "ARC":
|
||
c = entity.dxf.center
|
||
sa = _math.radians(entity.dxf.start_angle)
|
||
ea = _math.radians(entity.dxf.end_angle)
|
||
if ea < sa: ea += 2 * _math.pi
|
||
r = entity.dxf.radius
|
||
n_pts = max(8, int((ea - sa) / _math.radians(5)))
|
||
arc_pts = []
|
||
for ai in range(n_pts + 1):
|
||
angle = sa + (ea - sa) * ai / n_pts
|
||
arc_pts.append((c.x + r * _math.cos(angle), c.y + r * _math.sin(angle)))
|
||
if len(arc_pts) >= 2:
|
||
geoms.append({"type": "polyline", "points": arc_pts, "closed": False})
|
||
|
||
elif etype == "SPLINE":
|
||
try:
|
||
pts = [(pt[0], pt[1]) for pt in entity.control_points]
|
||
if len(pts) >= 2:
|
||
geoms.append({"type": "polyline", "points": pts, "closed": entity.closed})
|
||
except Exception:
|
||
pass
|
||
|
||
elif etype == "HATCH":
|
||
try:
|
||
for boundary_path in entity.paths:
|
||
if hasattr(boundary_path, 'vertices'):
|
||
pts = [(v[0], v[1]) for v in boundary_path.vertices]
|
||
if len(pts) >= 3:
|
||
geoms.append({"type": "polyline", "points": pts, "closed": True})
|
||
elif hasattr(boundary_path, 'edges'):
|
||
geoms.extend({"type": "line",
|
||
"start": (edge.start[0], edge.start[1]),
|
||
"end": (edge.end[0], edge.end[1])}
|
||
for edge in boundary_path.edges
|
||
if hasattr(edge, 'start') and hasattr(edge, 'end'))
|
||
except Exception:
|
||
pass
|
||
|
||
# 로그: 어떤 엔티티 타입이 있었는지 표시
|
||
types_str = ", ".join(f"{k}:{v}" for k, v in sorted(entity_types_found.items(), key=lambda x: -x[1]))
|
||
if geoms:
|
||
self.layer_geometries[layer_name] = {
|
||
"type_id": type_id,
|
||
"type_def": self.structure_types.get(type_id, {}),
|
||
"geometries": geoms
|
||
}
|
||
self.log(f" {layer_name}: {len(geoms)}개 요소 → {type_id} ({types_str})")
|
||
self._diag(f" [OK] {layer_name} ({type_id}): geoms={len(geoms)}, entities={{{types_str}}}")
|
||
else:
|
||
self.log(f" {layer_name}: 추출 가능한 요소 없음 (엔티티: {types_str if types_str else '없음'})")
|
||
self._diag(f" [SKIP] {layer_name} ({type_id}): geoms=0, entities={{{types_str if types_str else '없음'}}}"
|
||
f" — 지원 엔티티(LWPOLYLINE/LINE/POLYLINE/CIRCLE/ARC/SPLINE/HATCH/INSERT) 없음")
|
||
|
||
self._diag(f"=== _extract_layer_geometries 완료: {len(self.layer_geometries)}개 레이어에서 geom 추출 ===")
|
||
return len(self.layer_geometries)
|
||
|
||
# --- Phase 4-1b: 구조물 위치 레지스트리 ---
|
||
|
||
def _populate_structure_registry(self):
|
||
"""layer_geometries에서 구조물 위치/경계/이름을 레지스트리에 등록.
|
||
|
||
TIN 변형 대상(path_extrude, surface_overlay)은 이미 지형에 반영되므로
|
||
레지스트리에서는 별도 상세도면이 필요한 구조물만 등록한다:
|
||
wall_extrude, box_extrude, elevated_path, tube_path, line_only.
|
||
"""
|
||
import math
|
||
|
||
self._diag("=== _populate_structure_registry 시작 ===")
|
||
self.structure_registry = {}
|
||
|
||
if not self.layer_geometries:
|
||
self._diag(" 건너뜀: layer_geometries 비어있음 (geom 추출 실패)")
|
||
return
|
||
|
||
# 상세도면 대상 렌더 모드 (지형 변형 모드 제외)
|
||
detail_target_modes = {"wall_extrude", "box_extrude", "elevated_path", "tube_path", "line_only"}
|
||
self._diag(f" 레지스트리 대상 render_mode: {sorted(detail_target_modes)}")
|
||
|
||
# DXF에서 TEXT 엔티티 수집 (구조물명 자동 인식용)
|
||
text_entities = []
|
||
if self.dxf_doc:
|
||
msp = self.dxf_doc.modelspace()
|
||
for e in msp:
|
||
if e.dxftype() in ("TEXT", "MTEXT"):
|
||
try:
|
||
txt = e.dxf.text.strip() if e.dxftype() == "TEXT" else (e.text or "").strip()
|
||
pos = e.dxf.insert
|
||
if txt and len(txt) >= 2:
|
||
text_entities.append((txt, pos.x, pos.y))
|
||
except Exception:
|
||
pass
|
||
|
||
for layer_name, layer_data in self.layer_geometries.items():
|
||
type_def = layer_data["type_def"]
|
||
render_mode = type_def.get("render_mode", "line_only")
|
||
type_id = layer_data["type_id"]
|
||
|
||
if render_mode not in detail_target_modes:
|
||
self._diag(f" [SKIP] {layer_name} ({type_id}): render_mode={render_mode} — 상세빌드 대상 아님 (지형변형으로 처리됨)")
|
||
continue
|
||
|
||
geoms = layer_data["geometries"]
|
||
if not geoms:
|
||
self._diag(f" [SKIP] {layer_name} ({type_id}): geoms=0")
|
||
continue
|
||
|
||
# 중심/바운드 계산용 "구조체 몸체 포인트" 수집
|
||
# 치수선/지시선(LINE) outlier가 centroid를 끌어당기지 않도록
|
||
# 1순위: 폐합 폴리라인(구조물 외곽) → 2순위: LWPOLYLINE → 3순위: 전체
|
||
closed_poly_pts = []
|
||
open_poly_pts = []
|
||
line_pts = []
|
||
circle_pts = []
|
||
for geom in geoms:
|
||
if geom["type"] == "polyline":
|
||
if geom.get("closed"):
|
||
closed_poly_pts.extend(geom["points"])
|
||
else:
|
||
open_poly_pts.extend(geom["points"])
|
||
elif geom["type"] == "line":
|
||
line_pts.extend([geom["start"], geom["end"]])
|
||
elif geom["type"] == "circle":
|
||
cx, cy = geom["center"]
|
||
r = geom["radius"]
|
||
circle_pts.extend([(cx - r, cy), (cx + r, cy), (cx, cy - r), (cx, cy + r)])
|
||
|
||
all_pts = closed_poly_pts + open_poly_pts + line_pts + circle_pts
|
||
if not all_pts:
|
||
continue
|
||
|
||
# 중심 계산용 body_pts: 폐합 폴리라인 우선, 없으면 열린 폴리라인+원
|
||
body_pts = closed_poly_pts or (open_poly_pts + circle_pts) or all_pts
|
||
body_arr = np.array(body_pts)
|
||
full_arr = np.array(all_pts)
|
||
|
||
# 중위값 centroid (outlier에 강함)
|
||
centroid = (float(np.median(body_arr[:, 0])), float(np.median(body_arr[:, 1])))
|
||
# 5~95 퍼센타일 bounds (치수선 끝점 무시)
|
||
bounds = (
|
||
float(np.percentile(body_arr[:, 0], 5)),
|
||
float(np.percentile(body_arr[:, 1], 5)),
|
||
float(np.percentile(body_arr[:, 0], 95)),
|
||
float(np.percentile(body_arr[:, 1], 95)),
|
||
)
|
||
self._diag(f" [CENTROID] {layer_name}: body_pts={len(body_pts)}/전체 {len(all_pts)} "
|
||
f"→ median ({centroid[0]:.1f}, {centroid[1]:.1f}) "
|
||
f"vs mean ({float(np.mean(full_arr[:,0])):.1f}, {float(np.mean(full_arr[:,1])):.1f})")
|
||
|
||
# 인근 TEXT에서 구조물 이름 추론
|
||
name = type_def.get("name_ko", layer_name)
|
||
if text_entities:
|
||
best_dist = float("inf")
|
||
for txt, tx, ty in text_entities:
|
||
dist = math.sqrt((tx - centroid[0]) ** 2 + (ty - centroid[1]) ** 2)
|
||
# 구조물 범위의 2배 내에 있는 TEXT 중 가장 가까운 것
|
||
extent = max(bounds[2] - bounds[0], bounds[3] - bounds[1], 10)
|
||
if dist < extent * 2 and dist < best_dist:
|
||
# 숫자만 있는 텍스트, 좌표 형식(N,NNN) 제외
|
||
import re as _re
|
||
if not _re.match(r"^[\d.,\s]+$", txt):
|
||
best_dist = dist
|
||
name = txt
|
||
|
||
# === 템플릿 자동 추정 ===
|
||
suggested_template = self._suggest_template_for_structure(
|
||
layer_name, name, type_id, all_pts
|
||
)
|
||
|
||
# === 오리엔테이션 계산 (PCA) — body_pts 기반 (치수선 제외) ===
|
||
orientation_deg = 0.0
|
||
if STRUCTURE_TEMPLATES_AVAILABLE and len(body_pts) >= 3:
|
||
with contextlib.suppress(Exception):
|
||
orientation_deg = compute_orientation_from_points(body_pts)
|
||
|
||
self.structure_registry[layer_name] = {
|
||
"centroid": centroid,
|
||
"bounds": bounds,
|
||
"name": name,
|
||
"type_id": type_id,
|
||
"render_mode": render_mode,
|
||
"detail_params": None, # 기존 치수 파싱 (하위 호환)
|
||
"detail_dxf": None,
|
||
# --- 새 필드: 템플릿 시스템 통합 ---
|
||
"template_id": suggested_template, # 자동 추정 템플릿
|
||
"template_detail_dxfs": [], # 상세 DXF 파일들
|
||
"template_params": None, # 템플릿 파라미터 (StructureParams)
|
||
"template_meshes": None, # 빌드된 3D 메쉬
|
||
"orientation_deg": orientation_deg, # Z축 기준 회전각
|
||
"z_mode": "terrain", # 기본: 지형에 맞춤
|
||
}
|
||
|
||
if self.structure_registry:
|
||
self.log(f" 구조물 레지스트리: {len(self.structure_registry)}개 등록")
|
||
self._diag(f"=== _populate_structure_registry 완료: {len(self.structure_registry)}개 등록 ===")
|
||
for ln, info in self.structure_registry.items():
|
||
cx, cy = info["centroid"]
|
||
tpl = info.get("template_id") or "-"
|
||
rot = info.get("orientation_deg", 0.0)
|
||
self.log(f" [{info['type_id']}→{tpl}] {info['name']} "
|
||
f"@ ({cx:.0f}, {cy:.0f}) rot={rot:+.1f}°")
|
||
self._diag(f" [REG] {ln} ({info['type_id']}→{tpl}): {info['name']} @ ({cx:.1f}, {cy:.1f}) rot={rot:+.1f}°")
|
||
else:
|
||
self._diag("=== _populate_structure_registry 완료: 등록된 구조물 0개 ===")
|
||
|
||
def _suggest_template_for_structure(self, layer_name: str, name: str,
|
||
type_id: str, points: list) -> str:
|
||
"""구조물의 레이어/이름/유형으로 적절한 템플릿 추정.
|
||
|
||
우선순위:
|
||
1. 레이어명/name에 명시적 키워드
|
||
2. YAML type_id → template_id 매핑
|
||
3. 기본값 "generic"
|
||
"""
|
||
if not STRUCTURE_TEMPLATES_AVAILABLE:
|
||
return "generic"
|
||
|
||
combined_text = f"{layer_name} {name}".lower()
|
||
|
||
# 명시적 키워드 매핑 (우선순위순)
|
||
keyword_map = [
|
||
(["취수탑", "intake.*tower"], "intake_tower"),
|
||
(["제수변실", "밸브실", "도수관", "valve.*(?:room|chamber)"], "valve_chamber"),
|
||
(["옹벽", "retaining.*wall"], "retaining_wall"),
|
||
(["수문", "여수로.*수문", "spillway.*gate", "래디얼"], "spillway_gate"),
|
||
(["교량", "bridge", "공도교"], "bridge"),
|
||
(["터널", "갱구", "tunnel"], "tunnel_portal"),
|
||
(["건물", "건축", "사무소", "관리동", "building"], "building"),
|
||
]
|
||
import re
|
||
for kws, tid in keyword_map:
|
||
for kw in kws:
|
||
if re.search(kw, combined_text, re.IGNORECASE):
|
||
return tid
|
||
|
||
# type_id 직접 매핑 (YAML 유형 → 템플릿)
|
||
type_to_template = {
|
||
"cofferdam_upstream": "spillway_gate",
|
||
"cofferdam_downstream": "spillway_gate",
|
||
"diversion": "tunnel_portal",
|
||
"spillway": "spillway_gate",
|
||
"spillway_gate": "spillway_gate",
|
||
"intake_tower": "intake_tower",
|
||
"valve_chamber": "valve_chamber",
|
||
"building": "building",
|
||
"temp_facility": "building",
|
||
"retaining_wall": "retaining_wall",
|
||
"bridge": "bridge",
|
||
"tunnel": "tunnel_portal",
|
||
"pipeline": "valve_chamber",
|
||
}
|
||
if type_id in type_to_template:
|
||
return type_to_template[type_id]
|
||
|
||
return "generic"
|
||
|
||
# ========================================================================
|
||
# 구조물 템플릿 빌드 다이얼로그 (structure_templates 통합)
|
||
# ========================================================================
|
||
|
||
def _open_structure_template_dialog(self):
|
||
"""각 등록 구조물에 대해 전용 템플릿(취수탑/제수변실/옹벽/수문 등)
|
||
으로 상세 3D를 빌드하는 통합 다이얼로그.
|
||
|
||
1. 구조물 목록 (자동 추정된 템플릿 표시)
|
||
2. 템플릿 변경 가능
|
||
3. 상세 DXF 업로드
|
||
4. 템플릿 파싱 + 3D 빌드
|
||
5. 빌드된 메쉬를 레지스트리에 저장
|
||
"""
|
||
if not STRUCTURE_TEMPLATES_AVAILABLE:
|
||
messagebox.showerror("오류", "structure_templates 모듈을 찾을 수 없습니다.")
|
||
return
|
||
|
||
if not self.structure_registry:
|
||
messagebox.showinfo("안내",
|
||
"등록된 구조물이 없습니다. 먼저 Step 1 (TIN 생성 + 레이어 분류)을 "
|
||
"완료하여 구조물 레이어를 등록해 주세요.")
|
||
return
|
||
|
||
win = InlinePanel(self)
|
||
win.title("S-CANVAS: 구조물 상세 3D 빌드 (템플릿 기반)")
|
||
win.geometry("1100x650")
|
||
win.grab_set()
|
||
|
||
ctk.CTkLabel(win,
|
||
text="구조물별 상세도면을 업로드하여 정밀 3D 모델을 생성합니다",
|
||
font=ctk.CTkFont(size=14, weight="bold")
|
||
).pack(padx=20, pady=(15, 5))
|
||
|
||
ctk.CTkLabel(win,
|
||
text="자동 추정된 템플릿을 확인하고, 상세 DXF를 추가하세요. "
|
||
"빌드된 구조물은 지형 3D 뷰에 자동 배치됩니다.",
|
||
font=ctk.CTkFont(size=11), text_color="gray"
|
||
).pack(padx=20, pady=(0, 10))
|
||
|
||
# 헤더 + 구조물 목록 프레임
|
||
list_frame = ctk.CTkScrollableFrame(win, height=460)
|
||
list_frame.pack(padx=15, pady=5, fill="both", expand=True)
|
||
|
||
headers = ["구조물 이름", "위치 (m)", "템플릿", "상세 DXF", "빌드 상태", "작업"]
|
||
widths = [180, 120, 160, 180, 120, 100]
|
||
for ci, (h, w) in enumerate(zip(headers, widths, strict=False)):
|
||
list_frame.grid_columnconfigure(ci, weight=1 if ci == 0 else 0,
|
||
minsize=w)
|
||
ctk.CTkLabel(list_frame, text=h,
|
||
font=ctk.CTkFont(size=11, weight="bold")
|
||
).grid(row=0, column=ci, padx=3, pady=5, sticky="w")
|
||
|
||
template_choices = [(t.template_id, t.name_ko)
|
||
for t in STRUCTURE_REGISTRY.list_all()]
|
||
template_id_to_name = {tid: name for tid, name in template_choices}
|
||
template_name_to_id = {name: tid for tid, name in template_choices}
|
||
|
||
row_widgets = {} # layer_name → widget refs
|
||
|
||
for ri, (layer_name, info) in enumerate(self.structure_registry.items(), 1):
|
||
name = info["name"]
|
||
centroid = info["centroid"]
|
||
tid = info.get("template_id", "generic")
|
||
|
||
ctk.CTkLabel(list_frame, text=name[:22],
|
||
font=ctk.CTkFont(size=11)
|
||
).grid(row=ri, column=0, padx=3, pady=3, sticky="w")
|
||
ctk.CTkLabel(list_frame, text=f"({centroid[0]:.0f}, {centroid[1]:.0f})",
|
||
font=ctk.CTkFont(size=10), text_color="gray"
|
||
).grid(row=ri, column=1, padx=3, pady=3, sticky="w")
|
||
|
||
# 템플릿 드롭다운
|
||
current_name = template_id_to_name.get(tid, "일반 / 범용")
|
||
tpl_var = ctk.StringVar(value=current_name)
|
||
tpl_menu = ctk.CTkOptionMenu(
|
||
list_frame, variable=tpl_var,
|
||
values=[n for _, n in template_choices], width=150,
|
||
)
|
||
tpl_menu.grid(row=ri, column=2, padx=3, pady=3, sticky="w")
|
||
|
||
# 상세 DXF 파일 상태
|
||
dxf_var = ctk.StringVar(value="미등록")
|
||
dxfs = info.get("template_detail_dxfs", [])
|
||
if dxfs:
|
||
dxf_var.set(f"{len(dxfs)}개 파일")
|
||
dxf_label = ctk.CTkLabel(list_frame, textvariable=dxf_var,
|
||
font=ctk.CTkFont(size=10),
|
||
text_color="#3498DB")
|
||
dxf_label.grid(row=ri, column=3, padx=3, pady=3, sticky="w")
|
||
|
||
# 빌드 상태
|
||
status_var = ctk.StringVar(value="대기")
|
||
if info.get("template_meshes"):
|
||
status_var.set(f"빌드됨 ({len(info['template_meshes'])}개)")
|
||
status_label = ctk.CTkLabel(list_frame, textvariable=status_var,
|
||
font=ctk.CTkFont(size=10),
|
||
text_color="#22A06B")
|
||
status_label.grid(row=ri, column=4, padx=3, pady=3, sticky="w")
|
||
|
||
# 작업 버튼
|
||
btn_frame = ctk.CTkFrame(list_frame, fg_color="transparent")
|
||
btn_frame.grid(row=ri, column=5, padx=3, pady=3, sticky="w")
|
||
|
||
def _make_review(ln=layer_name, tv=tpl_var):
|
||
def _review():
|
||
self._open_structure_review_dialog(
|
||
ln, tv, template_name_to_id, row_widgets,
|
||
)
|
||
return _review
|
||
|
||
ctk.CTkButton(btn_frame, text="검토+빌드", width=95, height=26,
|
||
command=_make_review(),
|
||
fg_color="#1f538d", hover_color="#14375e",
|
||
font=ctk.CTkFont(size=10)
|
||
).pack(side="left")
|
||
|
||
row_widgets[layer_name] = {
|
||
"tpl_var": tpl_var, "dxf_var": dxf_var, "status_var": status_var,
|
||
}
|
||
|
||
# 하단 버튼
|
||
bottom = ctk.CTkFrame(win, fg_color="transparent")
|
||
bottom.pack(padx=20, pady=10, fill="x")
|
||
|
||
ctk.CTkLabel(bottom,
|
||
text="※ 상세 DXF 없이 기본 파라미터로 빌드도 가능합니다.",
|
||
font=ctk.CTkFont(size=10), text_color="gray"
|
||
).pack(side="left", padx=5)
|
||
|
||
ctk.CTkButton(bottom, text="닫기", width=80,
|
||
fg_color="transparent", border_width=1,
|
||
command=win.destroy).pack(side="right", padx=5)
|
||
|
||
def _build_all_without_detail():
|
||
"""모든 구조물을 상세 DXF 없이 기본 템플릿 값으로 빌드."""
|
||
for ln, info in self.structure_registry.items():
|
||
if info.get("template_meshes"):
|
||
continue # 이미 빌드됨
|
||
tid = info.get("template_id", "generic")
|
||
tpl = STRUCTURE_REGISTRY.get(tid)
|
||
if not tpl:
|
||
continue
|
||
try:
|
||
params = tpl.parse([]) # 빈 리스트 → 기본값
|
||
meshes = tpl.build_meshes(params)
|
||
info["template_params"] = params
|
||
info["template_meshes"] = meshes
|
||
w = row_widgets.get(ln)
|
||
if w:
|
||
w["status_var"].set(f"빌드됨 ({len(meshes)}개)")
|
||
self.log(f" [{ln}] 기본값 빌드: {len(meshes)}개 메쉬")
|
||
except Exception as e:
|
||
self.log(f" [{ln}] 빌드 실패: {e}")
|
||
|
||
messagebox.showinfo("완료",
|
||
"모든 구조물이 기본값으로 빌드되었습니다. "
|
||
"3D 프리뷰에서 확인하세요.")
|
||
|
||
ctk.CTkButton(bottom, text="기본값으로 모두 빌드", width=150,
|
||
command=_build_all_without_detail
|
||
).pack(side="right", padx=5)
|
||
|
||
def _apply_structures_to_tin():
|
||
"""확정된 구조물들을 TIN에 반영 (굴착 + 배치) → 3D 프리뷰."""
|
||
confirmed = [ln for ln, i in self.structure_registry.items()
|
||
if i.get("template_meshes")]
|
||
if not confirmed:
|
||
messagebox.showinfo("안내", "확정된 구조물이 없습니다.\n"
|
||
"각 구조물의 '검토+빌드'에서 확정하세요.")
|
||
return
|
||
self.log(f">>> 구조물 TIN 반영 시작 ({len(confirmed)}개 확정)")
|
||
self._excavate_tin_for_structures()
|
||
win.destroy()
|
||
self.show_3d_preview(textured=False)
|
||
|
||
ctk.CTkButton(bottom, text="TIN에 구조물 반영 + 3D 보기", width=200,
|
||
fg_color="#EB001B", hover_color="#D35400",
|
||
text_color="white",
|
||
font=ctk.CTkFont(size=11, weight="bold"),
|
||
command=_apply_structures_to_tin
|
||
).pack(side="right", padx=5)
|
||
|
||
def _open_structure_review_dialog(self, layer_name, tpl_var,
|
||
template_name_to_id, row_widgets):
|
||
"""구조물 상세 검토/편집/미리보기/확정 4단계 다이얼로그.
|
||
|
||
Parse → Edit → Build Preview → Confirm 흐름.
|
||
중간 단계에서 자유롭게 파라미터 수정/재빌드 가능.
|
||
확정 전까지는 structure_registry에 반영되지 않음.
|
||
"""
|
||
info = self.structure_registry[layer_name]
|
||
name = info["name"]
|
||
|
||
# 템플릿 확정 (메인 다이얼로그의 드롭다운 선택값)
|
||
tid = template_name_to_id.get(tpl_var.get(), info.get("template_id", "generic"))
|
||
tpl = STRUCTURE_REGISTRY.get(tid)
|
||
if not tpl:
|
||
messagebox.showerror("오류", f"템플릿을 찾을 수 없습니다: {tid}")
|
||
return
|
||
|
||
win = InlinePanel(self)
|
||
win.title(f"구조물 검토: {name}")
|
||
win.geometry("760x820")
|
||
win.grab_set()
|
||
|
||
# placement_transform: geo_referencing.PlacementTransform | None
|
||
state = {
|
||
"params": info.get("template_params"),
|
||
"meshes": info.get("template_meshes"),
|
||
"dxf_paths": list(info.get("template_detail_dxfs") or []),
|
||
"placement_transform": info.get("placement_transform"),
|
||
}
|
||
|
||
# --- 헤더 ---
|
||
cx, cy = info["centroid"]
|
||
rot = info.get("orientation_deg", 0.0)
|
||
ctk.CTkLabel(win, text=f"{tpl.name_ko}: {name}",
|
||
font=ctk.CTkFont(size=15, weight="bold")).pack(padx=15, pady=(12, 2))
|
||
ctk.CTkLabel(win, text=f"위치 ({cx:.0f}, {cy:.0f}) · 방향 {rot:+.1f}° · 템플릿 {tid}",
|
||
font=ctk.CTkFont(size=10), text_color="gray").pack(pady=(0, 8))
|
||
|
||
# --- DXF 선택 영역 ---
|
||
dxf_frame = ctk.CTkFrame(win)
|
||
dxf_frame.pack(fill="x", padx=15, pady=5)
|
||
|
||
_initial_dxf_text = (
|
||
f"{len(state['dxf_paths'])}개 파일: " + ", ".join(Path(p).name for p in state['dxf_paths'][:2])
|
||
if state['dxf_paths'] else "상세 DXF 미선택 (기본값으로 빌드 가능)"
|
||
)
|
||
dxf_label_var = ctk.StringVar(value=_initial_dxf_text)
|
||
ctk.CTkLabel(dxf_frame, textvariable=dxf_label_var,
|
||
font=ctk.CTkFont(size=11), wraplength=500,
|
||
anchor="w").pack(side="left", padx=10, pady=8, fill="x", expand=True)
|
||
|
||
# --- 굴착 깊이 (필수) ---
|
||
exc_frame = ctk.CTkFrame(win)
|
||
exc_frame.pack(fill="x", padx=15, pady=5)
|
||
ctk.CTkLabel(exc_frame, text="원지반 대비 굴착 깊이 (m):",
|
||
font=ctk.CTkFont(size=12, weight="bold")
|
||
).pack(side="left", padx=10, pady=8)
|
||
exc_entry = ctk.CTkEntry(exc_frame, width=100,
|
||
placeholder_text="예: 3.0")
|
||
exc_entry.insert(0, str(info.get("excavation_depth", 0.0)))
|
||
exc_entry.pack(side="left", padx=5, pady=8)
|
||
ctk.CTkLabel(exc_frame, text="양수(+)=굴착, 음수(-)=성토",
|
||
font=ctk.CTkFont(size=10), text_color="gray"
|
||
).pack(side="left", padx=10, pady=8)
|
||
|
||
# --- 파라미터 편집 영역 ---
|
||
param_frame = ctk.CTkScrollableFrame(win, height=340)
|
||
param_frame.pack(fill="both", expand=True, padx=15, pady=5)
|
||
ctk.CTkLabel(param_frame, text="파라미터 (자동 파싱 후 직접 편집 가능)",
|
||
font=ctk.CTkFont(size=12, weight="bold")
|
||
).grid(row=0, column=0, columnspan=4, sticky="w", pady=(2, 8))
|
||
|
||
schema = tpl.get_parameter_schema()
|
||
entries = {}
|
||
for ri, pf in enumerate(schema, 1):
|
||
ctk.CTkLabel(param_frame, text=pf.label, font=ctk.CTkFont(size=11)
|
||
).grid(row=ri, column=0, sticky="w", padx=(5, 5), pady=2)
|
||
entry = ctk.CTkEntry(param_frame, width=120)
|
||
entry.insert(0, str(pf.default))
|
||
entry.grid(row=ri, column=1, sticky="w", padx=5, pady=2)
|
||
entries[pf.name] = entry
|
||
ctk.CTkLabel(param_frame, text=pf.unit, font=ctk.CTkFont(size=10), text_color="gray"
|
||
).grid(row=ri, column=2, sticky="w", padx=5, pady=2)
|
||
if pf.description:
|
||
ctk.CTkLabel(param_frame, text=pf.description[:70],
|
||
font=ctk.CTkFont(size=9), text_color="#7F8C8D",
|
||
wraplength=280, justify="left"
|
||
).grid(row=ri, column=3, sticky="w", padx=5, pady=2)
|
||
|
||
def _populate_entries(params):
|
||
"""StructureParams → entry 위젯에 값 세팅."""
|
||
if params is None:
|
||
return
|
||
for key, entry in entries.items():
|
||
v = params.get(key)
|
||
if v is not None:
|
||
entry.delete(0, "end")
|
||
entry.insert(0, str(v))
|
||
|
||
# 기존 파라미터 있으면 채우기
|
||
if state["params"] is not None:
|
||
_populate_entries(state["params"])
|
||
|
||
# --- 상태 표시 ---
|
||
status_frame = ctk.CTkFrame(win, fg_color="transparent")
|
||
status_frame.pack(fill="x", padx=15, pady=(5, 0))
|
||
status_var = ctk.StringVar(value="대기 중 (DXF 선택 또는 파라미터 수동 입력)")
|
||
if state["meshes"]:
|
||
status_var.set(f"기존 빌드 유지 · {len(state['meshes'])}개 메쉬")
|
||
ctk.CTkLabel(status_frame, textvariable=status_var,
|
||
font=ctk.CTkFont(size=11), text_color="#F39C12",
|
||
anchor="w").pack(side="left", fill="x", expand=True)
|
||
|
||
# --- 동작 함수들 ---
|
||
def _do_parse():
|
||
if not state["dxf_paths"]:
|
||
messagebox.showinfo("안내", "먼저 DXF 파일을 선택하세요.")
|
||
return
|
||
try:
|
||
params = tpl.parse(state["dxf_paths"])
|
||
state["params"] = params
|
||
state["meshes"] = None # 파싱 바뀌면 이전 빌드 무효화
|
||
_populate_entries(params)
|
||
status_var.set(f"파싱 완료 · {len(params.params)}개 항목 · 미리보기 대기")
|
||
self.log(f" [{name}] 파싱: {list(params.params.keys())}")
|
||
except Exception as e:
|
||
import traceback; traceback.print_exc()
|
||
messagebox.showerror("파싱 오류", f"{e}")
|
||
status_var.set("파싱 오류")
|
||
|
||
def _select_dxf():
|
||
paths = filedialog.askopenfilenames(
|
||
title=f"상세 DXF: {name}",
|
||
filetypes=[("AutoCAD DXF", "*.dxf"), ("All Files", "*.*")])
|
||
if not paths:
|
||
return
|
||
state["dxf_paths"] = list(paths)
|
||
names = ", ".join(Path(p).name for p in paths[:2])
|
||
if len(paths) > 2:
|
||
names += f" 외 {len(paths) - 2}"
|
||
dxf_label_var.set(f"{len(paths)}개 파일: {names}")
|
||
_do_parse()
|
||
|
||
def _make_default_params():
|
||
"""schema 기본값으로 빈 StructureParams 생성 (parse() 호출 없이).
|
||
|
||
일부 템플릿은 parse([])에서 IndexError 발생 (최소 1개 DXF 요구).
|
||
"""
|
||
from structure_templates import StructureParams
|
||
sp = StructureParams(template_id=tid, name=name)
|
||
for pf in schema:
|
||
sp.set(pf.name, pf.default)
|
||
return sp
|
||
|
||
def _collect_params():
|
||
"""entry 위젯 값을 StructureParams에 반영."""
|
||
if state["params"] is None:
|
||
state["params"] = _make_default_params()
|
||
for key, entry in entries.items():
|
||
txt = entry.get().strip()
|
||
if not txt:
|
||
continue
|
||
try:
|
||
v = float(txt)
|
||
if v.is_integer():
|
||
v = int(v)
|
||
state["params"].set(key, v)
|
||
except ValueError:
|
||
state["params"].set(key, txt)
|
||
return state["params"]
|
||
|
||
def _do_build_preview():
|
||
params = _collect_params()
|
||
try:
|
||
meshes = tpl.build_meshes(params)
|
||
state["meshes"] = meshes
|
||
# 미리보기에는 물/지면/apron/backfill 제외 (구조물 본체만)
|
||
from geo_referencing import filter_terrain_meshes, to_ascii_title
|
||
meshes_view = filter_terrain_meshes(meshes)
|
||
status_var.set(
|
||
f"빌드 완료 · {len(meshes)}개 메쉬 "
|
||
f"(미리보기 {len(meshes_view)}개: 구조물만) · 다음 단계: 위치 설정"
|
||
)
|
||
self.log(f" [{name}] 빌드: {len(meshes)}개 메쉬 "
|
||
f"(미리보기 {len(meshes_view)}개)")
|
||
# 독립 미리보기 창 — VTK native 창은 한글 깨짐 방지 위해 ASCII 제목 사용
|
||
ascii_title = (
|
||
f"Preview: {to_ascii_title(tpl.name_ko, 'structure')}"
|
||
f" - {to_ascii_title(name, 'layer')}"
|
||
)
|
||
p = pv.Plotter(title=ascii_title)
|
||
p.set_background("#1e1e1e")
|
||
for mesh, color, opacity in meshes_view:
|
||
with contextlib.suppress(Exception):
|
||
p.add_mesh(mesh, color=color, opacity=opacity, smooth_shading=True)
|
||
p.enable_3_lights()
|
||
p.add_axes()
|
||
try:
|
||
p.show_bounds(xlabel="X (m)", ylabel="Y (m)", zlabel="Z (m)",
|
||
color="#CCCCCC", grid=True)
|
||
except Exception:
|
||
p.show_grid(color="gray")
|
||
p.view_isometric()
|
||
p.show() # 마우스 드래그로 회전·줌·팬 가능
|
||
except Exception as e:
|
||
import traceback; traceback.print_exc()
|
||
messagebox.showerror("빌드 오류", f"{e}")
|
||
status_var.set("빌드 오류 · 파라미터 확인 후 재시도")
|
||
|
||
def _do_geo_referencing():
|
||
"""구조물 평면도 ↔ TIN 평면도 4점 매칭 창을 열어 위치 변환을 설정."""
|
||
if not state["meshes"]:
|
||
messagebox.showwarning(
|
||
"안내", "먼저 '미리보기 (빌드)'로 모델을 생성한 뒤 사용하세요.",
|
||
parent=win)
|
||
return
|
||
if not state["dxf_paths"]:
|
||
messagebox.showwarning(
|
||
"안내", "구조물 상세 DXF가 필요합니다 ('DXF 선택 + 파싱' 먼저).",
|
||
parent=win)
|
||
return
|
||
if not self.dxf_path:
|
||
messagebox.showwarning(
|
||
"안내", "TIN 생성용 메인 DXF가 로드되지 않았습니다.\n"
|
||
"Step 1에서 계획 평면도를 먼저 업로드하세요.",
|
||
parent=win)
|
||
return
|
||
|
||
try:
|
||
from geo_referencing import GeoReferencingDialog
|
||
except Exception as e:
|
||
messagebox.showerror("모듈 로드 오류",
|
||
f"geo_referencing 모듈 로드 실패:\n{e}", parent=win)
|
||
return
|
||
|
||
def _on_transform(tr):
|
||
state["placement_transform"] = tr
|
||
status_var.set(
|
||
f"위치 설정 완료 · scale={tr.scale:.3f} "
|
||
f"rot={tr.rotation_deg:+.1f}° "
|
||
f"tx={tr.tx:+.1f} ty={tr.ty:+.1f} "
|
||
f"res={tr.residual:.2f}m · 확정 대기"
|
||
)
|
||
self.log(
|
||
f" [{name}] 위치 설정: {tr.describe()}"
|
||
)
|
||
|
||
# 기존 설정이 있으면 복원해서 재편집 편의
|
||
existing = state.get("placement_transform")
|
||
# TIN 캔버스를 self.origin 차감한 로컬 좌표계로 표시
|
||
# → 사용자 픽이 바로 TIN 로컬 좌표로 저장됨 (self.origin vs pick 좌표계 일치)
|
||
# → 초기 뷰도 self.tin_mesh 바운드로 고정해 주 콘텐츠가 크게 보이게
|
||
tin_view_bounds = None
|
||
if getattr(self, "tin_mesh", None) is not None:
|
||
_tb = self.tin_mesh.bounds
|
||
tin_view_bounds = (float(_tb.x_min), float(_tb.y_min),
|
||
float(_tb.x_max), float(_tb.y_max))
|
||
GeoReferencingDialog(
|
||
win, state["dxf_paths"], self.dxf_path,
|
||
layer_name=name,
|
||
initial_transform=existing,
|
||
on_confirm=_on_transform,
|
||
tin_origin=self.origin,
|
||
tin_view_bounds=tin_view_bounds,
|
||
)
|
||
|
||
def _do_reset_to_default():
|
||
"""파라미터를 템플릿 schema 기본값으로 복원."""
|
||
default_params = _make_default_params()
|
||
state["params"] = default_params
|
||
_populate_entries(default_params)
|
||
status_var.set("기본값 복원됨 · 미리보기로 확인")
|
||
|
||
def _do_blender_render():
|
||
"""Blender Cycles로 구조물 단독 고품질 렌더 (별도 트랙).
|
||
|
||
AI 워크플로(Step 4)와 별개로 실행. 결과는 'structure_render.png'.
|
||
transparent_bg=True 로 RGBA 출력 → 추후 지형 합성 입력으로도 사용 가능.
|
||
"""
|
||
try:
|
||
from blender_renderer import run_blender_render
|
||
from params_to_json import dump_dataclass_to_json as _dump_gate
|
||
except ImportError as e:
|
||
messagebox.showerror(
|
||
"모듈 없음",
|
||
f"Blender 렌더 모듈을 찾을 수 없습니다:\n{e}\n\n"
|
||
"blender_renderer.py / gate_3d_builder_bpy.py 가 "
|
||
"S-CANVAS 폴더에 있는지 확인하세요.",
|
||
parent=win,
|
||
)
|
||
return
|
||
|
||
if state["params"] is None:
|
||
messagebox.showwarning(
|
||
"안내", "파라미터가 비어있습니다. '미리보기 (빌드)' 먼저 실행하세요.",
|
||
parent=win,
|
||
)
|
||
return
|
||
|
||
# 현재는 수문(spillway_gate)만 지원.
|
||
if tid != "spillway_gate":
|
||
messagebox.showinfo(
|
||
"지원 예정",
|
||
f"Blender 렌더는 현재 '여수로 수문(spillway_gate)' 템플릿만 "
|
||
f"지원합니다.\n현재 템플릿: {tid}",
|
||
parent=win,
|
||
)
|
||
return
|
||
|
||
# 옵션 다이얼로그 — 시간대 + 투명배경 + 샘플
|
||
opt_win = InlinePanel(win)
|
||
opt_win.title("Blender Cycles 렌더 옵션")
|
||
opt_win.geometry("420x340")
|
||
opt_win.transient(win); opt_win.grab_set()
|
||
|
||
ctk.CTkLabel(
|
||
opt_win, text="Blender Cycles 렌더 옵션",
|
||
font=ctk.CTkFont(size=14, weight="bold"),
|
||
).pack(pady=(15, 10))
|
||
|
||
ctk.CTkLabel(opt_win, text="조명 / 시간대").pack(anchor="w", padx=20)
|
||
time_var = ctk.StringVar(value="daytime")
|
||
tf = ctk.CTkFrame(opt_win, fg_color="transparent")
|
||
tf.pack(fill="x", padx=20, pady=(0, 10))
|
||
for v, lbl in [("daytime", "주간"), ("sunset", "노을"), ("overcast", "흐림")]:
|
||
ctk.CTkRadioButton(tf, text=lbl, variable=time_var, value=v).pack(side="left", padx=8)
|
||
|
||
transparent_var = ctk.BooleanVar(value=False)
|
||
ctk.CTkCheckBox(
|
||
opt_win,
|
||
text="투명 배경 (RGBA) — 지형 합성용",
|
||
variable=transparent_var,
|
||
).pack(anchor="w", padx=20, pady=(0, 8))
|
||
|
||
ctk.CTkLabel(opt_win, text="Cycles 샘플 (높을수록 깨끗·느림)").pack(anchor="w", padx=20)
|
||
samples_var = ctk.StringVar(value="128")
|
||
sf = ctk.CTkFrame(opt_win, fg_color="transparent")
|
||
sf.pack(fill="x", padx=20, pady=(0, 8))
|
||
for s in ("32", "64", "128", "256"):
|
||
ctk.CTkRadioButton(sf, text=s, variable=samples_var, value=s).pack(side="left", padx=6)
|
||
|
||
save_blend_var = ctk.BooleanVar(value=False)
|
||
save_glb_var = ctk.BooleanVar(value=False)
|
||
ctk.CTkCheckBox(opt_win, text=".blend 저장",
|
||
variable=save_blend_var).pack(anchor="w", padx=20)
|
||
ctk.CTkCheckBox(opt_win, text=".glb 저장 (외부 뷰어/VR)",
|
||
variable=save_glb_var).pack(anchor="w", padx=20, pady=(0, 6))
|
||
|
||
def _start():
|
||
try:
|
||
samples = int(samples_var.get())
|
||
except ValueError:
|
||
samples = 128
|
||
t_preset = time_var.get()
|
||
trans = bool(transparent_var.get())
|
||
save_b = bool(save_blend_var.get())
|
||
save_g = bool(save_glb_var.get())
|
||
opt_win.destroy()
|
||
|
||
try:
|
||
json_path = "gate_params.json"
|
||
_dump_gate(state["params"], json_path)
|
||
self.log(f" [{name}] GateParams -> {json_path}")
|
||
except Exception as e:
|
||
messagebox.showerror("JSON 저장 실패",
|
||
f"GateParams JSON 직렬화 실패:\n{e}", parent=win)
|
||
return
|
||
|
||
self.log(f" [{name}] Blender 렌더 시작 "
|
||
f"(time={t_preset}, samples={samples}, "
|
||
f"bg={'투명' if trans else 'sky'})")
|
||
threading.Thread(
|
||
target=run_blender_render,
|
||
args=(self, None, json_path),
|
||
kwargs=dict(
|
||
time_preset=t_preset,
|
||
engine="CYCLES",
|
||
samples=samples,
|
||
output_path="structure_render.png",
|
||
transparent_bg=trans,
|
||
save_blend=save_b,
|
||
save_glb=save_g,
|
||
structure_kind="gate",
|
||
),
|
||
daemon=True,
|
||
).start()
|
||
|
||
bf = ctk.CTkFrame(opt_win, fg_color="transparent")
|
||
bf.pack(fill="x", pady=15, padx=20)
|
||
ctk.CTkButton(bf, text="취소", width=80,
|
||
fg_color="transparent", border_width=1,
|
||
command=opt_win.destroy).pack(side="left")
|
||
ctk.CTkButton(bf, text="🎨 렌더 시작", width=140,
|
||
fg_color="#16A085", hover_color="#117A65",
|
||
text_color="white",
|
||
command=_start).pack(side="right")
|
||
|
||
def _do_vlm_feedback():
|
||
"""Gemini Vision으로 빌드 결과와 원본 도면 비교 → 파라미터 diff 제안."""
|
||
if not STRUCTURE_VLM_AVAILABLE:
|
||
messagebox.showerror("모듈 없음",
|
||
"structure_vlm_feedback 모듈을 찾을 수 없습니다.", parent=win)
|
||
return
|
||
if state["meshes"] is None:
|
||
messagebox.showwarning("안내",
|
||
"먼저 '미리보기 (빌드)'로 메시를 생성해 주세요.", parent=win)
|
||
return
|
||
if not state["dxf_paths"]:
|
||
messagebox.showwarning("안내",
|
||
"상세 DXF가 필요합니다 ('DXF 선택 + 파싱' 먼저).", parent=win)
|
||
return
|
||
if state["params"] is None:
|
||
messagebox.showwarning("안내", "파라미터가 비어있습니다.", parent=win)
|
||
return
|
||
|
||
# 구조물 유형 추정 (템플릿 id 사용)
|
||
st_type = tid or "generic"
|
||
|
||
# Gemini 인증: scanvas_maker의 Vertex 경로와 동일하게
|
||
try:
|
||
project = (self._gcp_key_project_id
|
||
or os.environ.get("GCP_PROJECT_ID", "")
|
||
or (self.gemini_api_key.get().strip() if hasattr(self, "gemini_api_key") else ""))
|
||
location = (self.vertex_location.get().strip()
|
||
if hasattr(self, "vertex_location") else "global") or "global"
|
||
client = _svf.build_genai_client(
|
||
project=project if project else None,
|
||
location=location,
|
||
use_vertex=bool(self._gcp_key_project_id) or bool(os.environ.get("GCP_PROJECT_ID")),
|
||
api_key=None,
|
||
log_fn=self.log,
|
||
)
|
||
except Exception as e:
|
||
messagebox.showerror("Gemini 인증 실패",
|
||
f"Vertex AI client 생성 실패:\n{e}\n\n"
|
||
"gcp-key.json 또는 GCP Project ID 설정을 확인하세요.",
|
||
parent=win)
|
||
return
|
||
|
||
status_var.set("AI 검증 중... (Gemini Vision 호출, 10~20초 소요)")
|
||
self.log(f" [{name}] AI 검증 시작 (유형: {st_type})")
|
||
|
||
def _worker():
|
||
try:
|
||
diff = _svf.run_feedback_once(
|
||
state["params"], state["meshes"], state["dxf_paths"],
|
||
client, structure_type=st_type,
|
||
model="gemini-2.5-flash",
|
||
work_dir=f"cache/vlm/{st_type}",
|
||
log_fn=self.log,
|
||
)
|
||
self.after(0, lambda: _show_diff_dialog(diff))
|
||
except Exception as e:
|
||
import traceback; traceback.print_exc()
|
||
err_msg = str(e)
|
||
self.after(0, lambda msg=err_msg: (
|
||
status_var.set("AI 검증 실패"),
|
||
self.log(f" [{name}] VLM 오류: {msg}"),
|
||
messagebox.showerror("AI 검증 실패", msg, parent=win),
|
||
))
|
||
|
||
threading.Thread(target=_worker, daemon=True).start()
|
||
|
||
def _show_diff_dialog(diff: dict):
|
||
"""Gemini가 반환한 diff를 테이블로 보여주고 사용자가 체크한 항목만 적용."""
|
||
dwin = InlinePanel(win)
|
||
dwin.title(f"AI 검증 결과: {name}")
|
||
dwin.geometry("820x680")
|
||
dwin.grab_set()
|
||
|
||
score = diff.get("match_score", 0.0)
|
||
try:
|
||
score_f = float(score)
|
||
except (TypeError, ValueError):
|
||
score_f = 0.0
|
||
score_color = ("#22A06B" if score_f >= 0.85 else
|
||
"#F39C12" if score_f >= 0.6 else "#EB001B")
|
||
|
||
hdr = ctk.CTkFrame(dwin, fg_color="transparent")
|
||
hdr.pack(fill="x", padx=15, pady=(12, 4))
|
||
ctk.CTkLabel(hdr, text=f"매치 점수: {score_f:.2f}",
|
||
font=ctk.CTkFont(size=15, weight="bold"),
|
||
text_color=score_color).pack(side="left")
|
||
ctk.CTkLabel(hdr, text=" (모델: gemini-2.5-flash)",
|
||
font=ctk.CTkFont(size=10), text_color="gray"
|
||
).pack(side="left")
|
||
|
||
summary = str(diff.get("summary", "")).strip() or "(요약 없음)"
|
||
ctk.CTkLabel(dwin, text=summary, font=ctk.CTkFont(size=11),
|
||
wraplength=780, justify="left", anchor="w"
|
||
).pack(fill="x", padx=15, pady=(0, 8))
|
||
|
||
# 스크롤 프레임
|
||
scroll = ctk.CTkScrollableFrame(dwin, label_text="제안된 변경 (체크한 항목만 적용)")
|
||
scroll.pack(fill="both", expand=True, padx=15, pady=5)
|
||
|
||
# 각 카테고리별 체크박스 변수
|
||
sel = {"param_updates": [], "valves_missing": [], "pipes_missing": []}
|
||
|
||
def _section(title, items, key_list, make_text):
|
||
if not items:
|
||
return
|
||
ctk.CTkLabel(scroll, text=title,
|
||
font=ctk.CTkFont(size=12, weight="bold"),
|
||
anchor="w").pack(fill="x", pady=(8, 2))
|
||
for i, it in enumerate(items):
|
||
var = ctk.BooleanVar(value=True)
|
||
key_list.append(var)
|
||
fr = ctk.CTkFrame(scroll, fg_color="#2a2a2a", corner_radius=4)
|
||
fr.pack(fill="x", padx=4, pady=2)
|
||
ctk.CTkCheckBox(fr, text="", variable=var, width=20
|
||
).pack(side="left", padx=(6, 2))
|
||
ctk.CTkLabel(fr, text=make_text(it), font=ctk.CTkFont(size=10),
|
||
wraplength=720, justify="left", anchor="w"
|
||
).pack(side="left", fill="x", expand=True, padx=4, pady=4)
|
||
|
||
_section("파라미터 업데이트",
|
||
diff.get("param_updates", []) or [],
|
||
sel["param_updates"],
|
||
lambda u: f"• {u.get('path','?')}: "
|
||
f"{u.get('current','?')} → {u.get('suggested','?')} "
|
||
f"({u.get('reason','')})")
|
||
_section("누락 밸브 추가",
|
||
diff.get("valves_missing", []) or [],
|
||
sel["valves_missing"],
|
||
lambda v: f"• {v.get('name','?')} [{v.get('valve_type','GATE')}] "
|
||
f"@ ({v.get('x',0):.2f}, {v.get('y',0):.2f}) "
|
||
f"D{v.get('diameter_mm','?')}mm — {v.get('reason','')}")
|
||
_section("누락 관로 추가",
|
||
diff.get("pipes_missing", []) or [],
|
||
sel["pipes_missing"],
|
||
lambda p: f"• {p.get('name','?')} D{p.get('diameter_mm','?')}mm "
|
||
f"{p.get('start','?')} → {p.get('end','?')} — {p.get('reason','')}")
|
||
|
||
excess = diff.get("excess_notes", []) or []
|
||
if excess:
|
||
ctk.CTkLabel(scroll, text="참고: 모델에만 있는 요소 (적용 아님)",
|
||
font=ctk.CTkFont(size=12, weight="bold"),
|
||
text_color="#EB001B", anchor="w").pack(fill="x", pady=(10, 2))
|
||
for note in excess:
|
||
ctk.CTkLabel(scroll, text=f"• {note}", font=ctk.CTkFont(size=10),
|
||
text_color="#EB001B", wraplength=760, justify="left",
|
||
anchor="w").pack(fill="x", padx=8, pady=1)
|
||
|
||
valves_incorrect = diff.get("valves_incorrect", []) or []
|
||
pipes_incorrect = diff.get("pipes_incorrect", []) or []
|
||
if valves_incorrect or pipes_incorrect:
|
||
ctk.CTkLabel(scroll, text="참고: 리스트 요소 필드 수정 제안 (수동 반영 권장)",
|
||
font=ctk.CTkFont(size=12, weight="bold"),
|
||
text_color="#8E44AD", anchor="w").pack(fill="x", pady=(10, 2))
|
||
for v in valves_incorrect:
|
||
ctk.CTkLabel(scroll,
|
||
text=f"• 밸브 {v.get('name','?')}.{v.get('field','?')}: "
|
||
f"{v.get('current','?')} → {v.get('suggested','?')} "
|
||
f"({v.get('reason','')})",
|
||
font=ctk.CTkFont(size=10), wraplength=760,
|
||
justify="left", anchor="w").pack(fill="x", padx=8, pady=1)
|
||
for p in pipes_incorrect:
|
||
ctk.CTkLabel(scroll,
|
||
text=f"• 관로 {p.get('name','?')}.{p.get('field','?')}: "
|
||
f"{p.get('current','?')} → {p.get('suggested','?')} "
|
||
f"({p.get('reason','')})",
|
||
font=ctk.CTkFont(size=10), wraplength=760,
|
||
justify="left", anchor="w").pack(fill="x", padx=8, pady=1)
|
||
|
||
# 하단 버튼
|
||
btns = ctk.CTkFrame(dwin, fg_color="transparent")
|
||
btns.pack(fill="x", padx=15, pady=10)
|
||
|
||
def _apply_and_rebuild():
|
||
selections = {
|
||
k: [var.get() for var in vars_list]
|
||
for k, vars_list in sel.items()
|
||
}
|
||
result = _svf.apply_diff_to_params(
|
||
state["params"], diff, selections=selections, log_fn=self.log,
|
||
)
|
||
self.log(f" [{name}] VLM 적용: {result['applied']}개, "
|
||
f"오류 {len(result['errors'])}건")
|
||
# 엔트리 위젯 갱신 (스칼라 필드 반영)
|
||
with contextlib.suppress(Exception):
|
||
_populate_entries(state["params"])
|
||
# 자동 재빌드
|
||
try:
|
||
meshes = tpl.build_meshes(state["params"])
|
||
state["meshes"] = meshes
|
||
status_var.set(
|
||
f"AI 적용 완료 ({result['applied']}건) · 재빌드 {len(meshes)} 메쉬"
|
||
)
|
||
except Exception as e:
|
||
self.log(f" [{name}] 재빌드 오류: {e}")
|
||
status_var.set("AI 적용됨 (재빌드 실패 — 로그 확인)")
|
||
dwin.destroy()
|
||
|
||
ctk.CTkButton(btns, text="취소", width=80,
|
||
fg_color="transparent", border_width=1,
|
||
command=dwin.destroy).pack(side="right", padx=4)
|
||
ctk.CTkButton(btns, text="✓ 선택 항목 적용 + 재빌드", width=220,
|
||
fg_color="#22A06B", hover_color="#1B8454",
|
||
font=ctk.CTkFont(size=11, weight="bold"),
|
||
command=_apply_and_rebuild).pack(side="right", padx=4)
|
||
# 생성된 비교 이미지 경로 안내
|
||
arts = diff.get("_artifacts", {})
|
||
if arts:
|
||
ctk.CTkLabel(btns,
|
||
text=f"비교 이미지: {Path(arts.get('drawing_png','')).name}, "
|
||
f"{Path(arts.get('render_png','')).name}",
|
||
font=ctk.CTkFont(size=9), text_color="gray"
|
||
).pack(side="left", padx=4)
|
||
|
||
def _do_confirm():
|
||
if state["meshes"] is None:
|
||
if not messagebox.askyesno(
|
||
"미빌드 확정",
|
||
"아직 빌드/미리보기를 하지 않았습니다.\n"
|
||
"지금 파라미터로 빌드 후 확정할까요?"):
|
||
return
|
||
params = _collect_params()
|
||
try:
|
||
state["meshes"] = tpl.build_meshes(params)
|
||
except Exception as e:
|
||
messagebox.showerror("빌드 오류", f"{e}")
|
||
return
|
||
# 굴착 깊이 저장
|
||
try:
|
||
info["excavation_depth"] = float(exc_entry.get().strip() or "0")
|
||
except ValueError:
|
||
messagebox.showerror("입력 오류", "굴착 깊이를 숫자로 입력해 주세요.")
|
||
return
|
||
|
||
# 위치 설정 미완료 경고 (하위호환: 기존 centroid/orientation 폴백)
|
||
tr = state.get("placement_transform")
|
||
scale_mode = "none" # 기본: 위치·회전만, 구조물 크기 유지
|
||
if tr is None:
|
||
if not messagebox.askyesno(
|
||
"위치 설정 건너뜀",
|
||
"'위치 설정 (Geo-Referencing)'이 수행되지 않았습니다.\n"
|
||
"이대로 확정하면 평면도 centroid + PCA 주축으로 배치됩니다.\n\n"
|
||
"계속 확정할까요?",
|
||
parent=win):
|
||
return
|
||
else:
|
||
# 스케일 비교: mismatch가 있으면 사용자에게 크기 조정 여부 질문
|
||
if not (0.9 <= tr.scale <= 1.1):
|
||
ans = messagebox.askyesnocancel(
|
||
"배치 모드 선택",
|
||
f"구조물 도면과 평면도(TIN) 사이 스케일 비: {tr.scale:.4f}\n"
|
||
f"(잔차 {tr.residual:.3f} m)\n\n"
|
||
"어떻게 배치할까요?\n\n"
|
||
"[예] 평면도 4점에 맞춰 구조물 크기까지 균등 조정\n"
|
||
" → XY/Z 모두 sqrt(scale_x×scale_y)로 스케일 후 배치\n"
|
||
" → 구조물이 작아 보이는 단위 불일치 수정 시\n\n"
|
||
"[아니오] 위치·회전만 맞추고 구조물은 설계 크기 유지 (권장)\n"
|
||
" → 빌더가 생성한 m 단위 메쉬 그대로 올림\n\n"
|
||
"[취소] 확정 취소, 다시 위치 설정",
|
||
parent=win)
|
||
if ans is None:
|
||
return
|
||
scale_mode = "xyz_uniform" if ans else "none"
|
||
if tr.residual > 0.5 and not messagebox.askyesno(
|
||
"잔차 과다",
|
||
f"4점 매칭 잔차가 {tr.residual:.2f}m 로 큽니다.\n"
|
||
"점 위치를 재확인하지 않고 확정할까요?",
|
||
parent=win):
|
||
return
|
||
|
||
# 메쉬는 건드리지 않음. 스케일 모드는 info에 저장해 배치 시점에 적용.
|
||
info["template_id"] = tid
|
||
info["template_detail_dxfs"] = state["dxf_paths"]
|
||
info["template_params"] = state["params"]
|
||
info["template_meshes"] = state["meshes"]
|
||
info["placement_transform"] = tr
|
||
info["placement_scale_mode"] = scale_mode
|
||
# 메인 다이얼로그 행 갱신
|
||
w = row_widgets.get(layer_name)
|
||
if w:
|
||
geo_tag = " · 📍 위치 설정됨" if tr is not None else ""
|
||
w["status_var"].set(f"✓ 확정 ({len(state['meshes'])}개){geo_tag}")
|
||
if state["dxf_paths"]:
|
||
w["dxf_var"].set(f"{len(state['dxf_paths'])}개 파일")
|
||
if tr is not None:
|
||
self.log(f" [{name}] 레지스트리 확정 · 위치: {tr.describe()}")
|
||
else:
|
||
self.log(f" [{name}] 레지스트리 확정 (위치 설정 생략 — centroid 폴백)")
|
||
win.destroy()
|
||
|
||
# DXF 선택 버튼 (파싱 함수 정의 후 배치)
|
||
ctk.CTkButton(dxf_frame, text="DXF 선택 + 파싱", width=140,
|
||
command=_select_dxf).pack(side="right", padx=10, pady=6)
|
||
|
||
# --- 하단 버튼 행 ---
|
||
bottom = ctk.CTkFrame(win, fg_color="transparent")
|
||
bottom.pack(fill="x", padx=15, pady=10)
|
||
|
||
# 시각 순서(좌→우): [재파싱] [기본값 복원] ... [미리보기] [위치 설정] [확정] [취소]
|
||
# side='right'는 역순 쌓임 → 취소를 먼저 추가
|
||
ctk.CTkButton(bottom, text="취소", width=70,
|
||
fg_color="transparent", border_width=1,
|
||
command=win.destroy).pack(side="right", padx=3)
|
||
ctk.CTkButton(bottom, text="✓ 확정 (레지스트리 저장)", width=190,
|
||
fg_color="#22A06B", hover_color="#1B8454",
|
||
text_color="white",
|
||
font=ctk.CTkFont(size=11, weight="bold"),
|
||
command=_do_confirm).pack(side="right", padx=3)
|
||
ctk.CTkButton(bottom, text="📍 위치 설정", width=130,
|
||
fg_color="#8E44AD", hover_color="#6C3483",
|
||
text_color="white",
|
||
font=ctk.CTkFont(size=11, weight="bold"),
|
||
command=_do_geo_referencing).pack(side="right", padx=3)
|
||
ctk.CTkButton(bottom, text="🗔 미리보기 (빌드)", width=160,
|
||
fg_color="#1f538d", hover_color="#14375e",
|
||
command=_do_build_preview).pack(side="right", padx=3)
|
||
ctk.CTkButton(bottom, text="🤖 AI 검증", width=110,
|
||
fg_color="#D35400", hover_color="#A04000",
|
||
text_color="white",
|
||
font=ctk.CTkFont(size=11, weight="bold"),
|
||
command=_do_vlm_feedback).pack(side="right", padx=3)
|
||
ctk.CTkButton(bottom, text="🎨 Blender 렌더", width=140,
|
||
fg_color="#16A085", hover_color="#117A65",
|
||
text_color="white",
|
||
font=ctk.CTkFont(size=11, weight="bold"),
|
||
command=_do_blender_render).pack(side="right", padx=3)
|
||
ctk.CTkButton(bottom, text="재파싱", width=80,
|
||
fg_color="transparent", border_width=1,
|
||
command=_do_parse).pack(side="left", padx=3)
|
||
ctk.CTkButton(bottom, text="기본값 복원", width=100,
|
||
fg_color="transparent", border_width=1,
|
||
command=_do_reset_to_default).pack(side="left", padx=3)
|
||
|
||
def _browse_template_detail_dxf(self, layer_name, dxf_var, status_var,
|
||
tpl_var, template_name_to_id):
|
||
"""구조물 하나에 상세 DXF 업로드 → 템플릿 파싱 + 3D 빌드."""
|
||
paths = filedialog.askopenfilenames(
|
||
title=f"상세도면 선택: {self.structure_registry[layer_name]['name']}",
|
||
filetypes=[("AutoCAD DXF", "*.dxf"), ("All Files", "*.*")],
|
||
)
|
||
if not paths:
|
||
return
|
||
|
||
# 템플릿 확정 (드롭다운에서 선택된 것)
|
||
selected_name = tpl_var.get()
|
||
tid = template_name_to_id.get(selected_name, "generic")
|
||
tpl = STRUCTURE_REGISTRY.get(tid)
|
||
if not tpl:
|
||
messagebox.showerror("오류", f"템플릿을 찾을 수 없습니다: {tid}")
|
||
return
|
||
|
||
self.log(f" [{layer_name}] 템플릿 [{tpl.name_ko}]로 {len(paths)}개 DXF 파싱 중...")
|
||
dxf_var.set(f"{len(paths)}개 파일")
|
||
|
||
try:
|
||
params = tpl.parse(list(paths))
|
||
meshes = tpl.build_meshes(params)
|
||
info = self.structure_registry[layer_name]
|
||
info["template_id"] = tid
|
||
info["template_detail_dxfs"] = list(paths)
|
||
info["template_params"] = params
|
||
info["template_meshes"] = meshes
|
||
status_var.set(f"빌드됨 ({len(meshes)}개)")
|
||
self.log(f" → {len(meshes)}개 mesh 생성 완료")
|
||
except Exception as e:
|
||
self.log(f" 빌드 오류: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
status_var.set("오류")
|
||
messagebox.showerror("빌드 오류",
|
||
f"템플릿 [{tpl.name_ko}] 빌드 중 오류:\n\n{e}")
|
||
|
||
# ========================================================================
|
||
# 기존: 간단 치수 파서 다이얼로그 (하위 호환)
|
||
# ========================================================================
|
||
|
||
def _open_detail_upload_dialog(self):
|
||
"""등록된 구조물에 상세도면 DXF를 추가하는 팝업 다이얼로그.
|
||
|
||
구조물 목록을 표시하고, 각 구조물에 대해 상세도면 파일을 선택,
|
||
자동 파싱된 치수를 확인/수정할 수 있게 한다.
|
||
"""
|
||
if not self.structure_registry:
|
||
messagebox.showinfo("안내", "상세도면을 추가할 구조물이 없습니다.\n"
|
||
"벽체, 건물, 교량, 관로 등의 레이어를 분류해 주세요.")
|
||
return
|
||
|
||
if not DETAIL_PARSER_AVAILABLE:
|
||
messagebox.showerror("오류", "detail_parser 모듈을 찾을 수 없습니다.")
|
||
return
|
||
|
||
win = InlinePanel(self)
|
||
win.title("S-CANVAS: 구조물 상세도면 추가")
|
||
win.geometry("950x600")
|
||
win.grab_set()
|
||
|
||
ctk.CTkLabel(win, text="구조물별 상세도면을 추가하여 정밀 치수를 반영합니다",
|
||
font=ctk.CTkFont(size=14, weight="bold")).pack(padx=20, pady=(15, 5))
|
||
ctk.CTkLabel(win, text="상세도면 DXF에서 높이, 폭, 계획고 등의 치수를 자동 인식합니다",
|
||
font=ctk.CTkFont(size=11), text_color="gray").pack(padx=20, pady=(0, 10))
|
||
|
||
# 구조물 목록 프레임
|
||
list_frame = ctk.CTkScrollableFrame(win, height=400)
|
||
list_frame.pack(padx=15, pady=5, fill="both", expand=True)
|
||
|
||
headers = ["구조물", "유형", "위치", "상세도면", "파싱 결과", ""]
|
||
for ci, h in enumerate(headers):
|
||
list_frame.grid_columnconfigure(ci, weight=1 if ci in (0, 4) else 0)
|
||
ctk.CTkLabel(list_frame, text=h, font=ctk.CTkFont(size=11, weight="bold")
|
||
).grid(row=0, column=ci, padx=5, sticky="w")
|
||
|
||
row_widgets = {}
|
||
|
||
for ri, (layer_name, info) in enumerate(self.structure_registry.items(), 1):
|
||
name = info["name"]
|
||
type_id = info["type_id"]
|
||
cx, cy = info["centroid"]
|
||
|
||
ctk.CTkLabel(list_frame, text=name[:20], font=ctk.CTkFont(size=11)
|
||
).grid(row=ri, column=0, padx=5, pady=3, sticky="w")
|
||
ctk.CTkLabel(list_frame, text=type_id, font=ctk.CTkFont(size=10), text_color="gray"
|
||
).grid(row=ri, column=1, padx=5, pady=3, sticky="w")
|
||
ctk.CTkLabel(list_frame, text=f"({cx:.0f}, {cy:.0f})", font=ctk.CTkFont(size=10), text_color="gray"
|
||
).grid(row=ri, column=2, padx=5, pady=3, sticky="w")
|
||
|
||
# 파일 경로 표시
|
||
file_var = ctk.StringVar(value=info.get("detail_dxf") or "미등록")
|
||
file_label = ctk.CTkLabel(list_frame, textvariable=file_var,
|
||
font=ctk.CTkFont(size=10), text_color="#3498DB")
|
||
file_label.grid(row=ri, column=3, padx=5, pady=3, sticky="w")
|
||
|
||
# 파싱 결과 표시
|
||
result_var = ctk.StringVar(value=self._format_detail_params(info.get("detail_params")))
|
||
result_label = ctk.CTkLabel(list_frame, textvariable=result_var,
|
||
font=ctk.CTkFont(size=10), text_color="#22A06B")
|
||
result_label.grid(row=ri, column=4, padx=5, pady=3, sticky="w")
|
||
|
||
# 파일 선택 버튼
|
||
def _make_browse(ln=layer_name, fv=file_var, rv=result_var):
|
||
def _browse():
|
||
self._browse_detail_dxf(ln, fv, rv)
|
||
return _browse
|
||
|
||
ctk.CTkButton(list_frame, text="찾아보기", width=80,
|
||
command=_make_browse()
|
||
).grid(row=ri, column=5, padx=5, pady=3)
|
||
|
||
row_widgets[layer_name] = (file_var, result_var)
|
||
|
||
# 하단 버튼
|
||
btn_frame = ctk.CTkFrame(win, fg_color="transparent")
|
||
btn_frame.pack(padx=20, pady=10, fill="x")
|
||
ctk.CTkButton(btn_frame, text="닫기", width=80, fg_color="transparent",
|
||
border_width=1, command=win.destroy).pack(side="right", padx=5)
|
||
ctk.CTkButton(btn_frame, text="모두 적용 & 3D 재생성", width=180,
|
||
command=lambda: self._apply_detail_and_close(win)
|
||
).pack(side="right", padx=5)
|
||
|
||
win.wait_window()
|
||
|
||
def _browse_detail_dxf(self, layer_name, file_var, result_var):
|
||
"""구조물 하나에 대해 상세도면 DXF를 선택하고 파싱."""
|
||
fpath = filedialog.askopenfilename(
|
||
title=f"상세도면 선택: {self.structure_registry[layer_name]['name']}",
|
||
filetypes=[("AutoCAD DXF", "*.dxf"), ("All Files", "*.*")]
|
||
)
|
||
if not fpath:
|
||
return
|
||
|
||
file_var.set(os.path.basename(fpath))
|
||
self.log(f" 상세도면 로드: {os.path.basename(fpath)} → {layer_name}")
|
||
|
||
try:
|
||
parser = DetailParser()
|
||
result = parser.parse(fpath)
|
||
|
||
if not result.dimensions:
|
||
self.log(" 치수 인식 결과 없음")
|
||
result_var.set("치수 없음")
|
||
return
|
||
|
||
# 파싱 결과 → 구조물 파라미터로 변환
|
||
params = dimensions_to_structure_params(result.dimensions)
|
||
self.log(f" 파싱 완료: {params}")
|
||
|
||
# 사용자 확인/수정 다이얼로그
|
||
confirmed = self._open_dimension_confirm_dialog(
|
||
layer_name, result, params
|
||
)
|
||
|
||
if confirmed is not None:
|
||
self.structure_registry[layer_name]["detail_params"] = confirmed
|
||
self.structure_registry[layer_name]["detail_dxf"] = fpath
|
||
result_var.set(self._format_detail_params(confirmed))
|
||
self.log(f" 치수 확정: {confirmed}")
|
||
else:
|
||
result_var.set("취소됨")
|
||
|
||
except Exception as e:
|
||
self.log(f" 상세도면 파싱 오류: {e}")
|
||
result_var.set(f"오류: {e}")
|
||
messagebox.showerror("파싱 오류", f"상세도면 분석 중 오류:\n{e}")
|
||
|
||
def _open_dimension_confirm_dialog(self, layer_name, parse_result, params):
|
||
"""파싱된 치수를 사용자에게 보여주고 수정할 수 있는 다이얼로그.
|
||
|
||
Returns:
|
||
dict: 확정된 파라미터 딕셔너리. 취소 시 None.
|
||
"""
|
||
info = self.structure_registry[layer_name]
|
||
|
||
win = InlinePanel(self)
|
||
win.title(f"치수 확인: {info['name']}")
|
||
win.geometry("650x500")
|
||
win.grab_set()
|
||
|
||
ctk.CTkLabel(win, text=f"구조물: {info['name']} ({info['type_id']})",
|
||
font=ctk.CTkFont(size=14, weight="bold")).pack(padx=20, pady=(15, 5))
|
||
ctk.CTkLabel(win, text=f"상세도면에서 {len(parse_result.dimensions)}개 치수 인식됨 | 값을 수정하거나 빈 칸은 기본값 사용",
|
||
font=ctk.CTkFont(size=11), text_color="gray").pack(padx=20, pady=(0, 10))
|
||
|
||
# 파싱 결과 상세 목록
|
||
detail_frame = ctk.CTkScrollableFrame(win, height=150)
|
||
detail_frame.pack(padx=15, pady=5, fill="x")
|
||
|
||
ctk.CTkLabel(detail_frame, text="원본 텍스트", font=ctk.CTkFont(size=10, weight="bold")
|
||
).grid(row=0, column=0, padx=5, sticky="w")
|
||
ctk.CTkLabel(detail_frame, text="항목", font=ctk.CTkFont(size=10, weight="bold")
|
||
).grid(row=0, column=1, padx=5, sticky="w")
|
||
ctk.CTkLabel(detail_frame, text="인식값", font=ctk.CTkFont(size=10, weight="bold")
|
||
).grid(row=0, column=2, padx=5, sticky="w")
|
||
ctk.CTkLabel(detail_frame, text="신뢰도", font=ctk.CTkFont(size=10, weight="bold")
|
||
).grid(row=0, column=3, padx=5, sticky="w")
|
||
|
||
for di, dim in enumerate(parse_result.dimensions[:20], 1):
|
||
ctk.CTkLabel(detail_frame, text=dim.raw_text[:25], font=ctk.CTkFont(size=10)
|
||
).grid(row=di, column=0, padx=5, pady=1, sticky="w")
|
||
ctk.CTkLabel(detail_frame, text=dim.param, font=ctk.CTkFont(size=10)
|
||
).grid(row=di, column=1, padx=5, pady=1, sticky="w")
|
||
ctk.CTkLabel(detail_frame, text=f"{dim.value:.3f} {dim.unit}", font=ctk.CTkFont(size=10)
|
||
).grid(row=di, column=2, padx=5, pady=1, sticky="w")
|
||
conf_color = "#22A06B" if dim.confidence >= 0.9 else "#F79E1B" if dim.confidence >= 0.8 else "#EB001B"
|
||
ctk.CTkLabel(detail_frame, text=f"{dim.confidence:.0%}", font=ctk.CTkFont(size=10),
|
||
text_color=conf_color
|
||
).grid(row=di, column=3, padx=5, pady=1, sticky="w")
|
||
|
||
# 편집 가능한 파라미터 테이블
|
||
ctk.CTkLabel(win, text="적용할 구조물 치수 (수정 가능)",
|
||
font=ctk.CTkFont(size=12, weight="bold")).pack(padx=20, pady=(15, 5))
|
||
|
||
edit_frame = ctk.CTkFrame(win, fg_color="transparent")
|
||
edit_frame.pack(padx=20, pady=5, fill="x")
|
||
|
||
# 구조물 유형에 따라 표시할 파라미터
|
||
param_labels = {
|
||
"height": "높이 (m)",
|
||
"width": "폭 (m)",
|
||
"thickness": "두께 (m)",
|
||
"elevation": "계획고 EL. (m)",
|
||
"diameter": "관경 (m)",
|
||
"slope_ratio": "사면 경사비",
|
||
"length": "길이 (m)",
|
||
"embedment": "근입깊이 (m)",
|
||
"radius": "반경 (m)",
|
||
}
|
||
|
||
param_entries = {}
|
||
col = 0
|
||
for pname, plabel in param_labels.items():
|
||
if pname in params or pname in ("height", "width", "elevation"):
|
||
ctk.CTkLabel(edit_frame, text=plabel, font=ctk.CTkFont(size=11)
|
||
).grid(row=0, column=col, padx=8, pady=2)
|
||
val_str = f"{params[pname]:.3f}" if pname in params else ""
|
||
entry = ctk.CTkEntry(edit_frame, width=90, placeholder_text="기본값")
|
||
entry.grid(row=1, column=col, padx=8, pady=2)
|
||
if val_str:
|
||
entry.insert(0, val_str)
|
||
param_entries[pname] = entry
|
||
col += 1
|
||
|
||
# 결과 저장
|
||
result_holder = [None]
|
||
|
||
def on_ok():
|
||
confirmed = {}
|
||
for pname, entry in param_entries.items():
|
||
val = entry.get().strip()
|
||
if val:
|
||
with contextlib.suppress(ValueError):
|
||
confirmed[pname] = float(val)
|
||
result_holder[0] = confirmed
|
||
win.destroy()
|
||
|
||
def on_cancel():
|
||
win.destroy()
|
||
|
||
btn_f = ctk.CTkFrame(win, fg_color="transparent")
|
||
btn_f.pack(padx=20, pady=10, fill="x")
|
||
ctk.CTkButton(btn_f, text="취소", width=80, fg_color="transparent",
|
||
border_width=1, command=on_cancel).pack(side="right", padx=5)
|
||
ctk.CTkButton(btn_f, text="적용", width=140, command=on_ok).pack(side="right", padx=5)
|
||
|
||
win.wait_window()
|
||
return result_holder[0]
|
||
|
||
def _apply_detail_and_close(self, win):
|
||
"""상세도면 파라미터를 구조물에 적용하고 3D 프리뷰 재생성."""
|
||
# 상세 파라미터가 있는 구조물의 type_def를 갱신
|
||
updated = 0
|
||
for layer_name, info in self.structure_registry.items():
|
||
dp = info.get("detail_params")
|
||
if not dp:
|
||
continue
|
||
if layer_name not in self.layer_geometries:
|
||
continue
|
||
|
||
# layer_geometries의 type_def에 파싱된 치수를 병합
|
||
merged = dict(self.layer_geometries[layer_name]["type_def"])
|
||
merged.update(dp)
|
||
self.layer_geometries[layer_name]["type_def"] = merged
|
||
updated += 1
|
||
self.log(f" 치수 적용: {layer_name} → {dp}")
|
||
|
||
win.destroy()
|
||
|
||
if updated > 0:
|
||
self.log(f" {updated}개 구조물 치수 업데이트 완료. 3D 프리뷰 재생성...")
|
||
self.show_3d_preview(
|
||
textured=bool(self.total_mesh),
|
||
texture_obj=getattr(self, '_last_texture', None)
|
||
)
|
||
else:
|
||
self.log(" 적용할 상세 치수가 없습니다.")
|
||
|
||
def _format_detail_params(self, params):
|
||
"""detail_params 딕셔너리를 간결한 요약 문자열로 변환."""
|
||
if not params:
|
||
return "미등록"
|
||
parts = []
|
||
labels = {"height": "H", "width": "W", "thickness": "T",
|
||
"elevation": "EL", "diameter": "D", "slope_ratio": "경사",
|
||
"length": "L", "embedment": "근입"}
|
||
for k, v in params.items():
|
||
lbl = labels.get(k, k)
|
||
parts.append(f"{lbl}={v:.1f}")
|
||
return ", ".join(parts)
|
||
|
||
# --- Phase 4-2: Z 투영 + 3D 지오메트리 생성 ---
|
||
def _project_xy_to_tin(self, xy_points):
|
||
"""2D 좌표 목록을 TIN 표면에 Z 투영 (scipy 보간 방식 — 빠름).
|
||
|
||
xy_points: [(x, y), ...] 원본 좌표계 (origin 보정 전)
|
||
returns: np.array([[x, y, z], ...]) origin 보정 후 로컬 좌표
|
||
"""
|
||
from scipy.interpolate import LinearNDInterpolator
|
||
|
||
if not self.tin_mesh or len(xy_points) == 0:
|
||
return np.array([])
|
||
|
||
pts_2d = np.array(xy_points, dtype=np.float64)
|
||
|
||
# origin 보정 (TIN과 같은 로컬 좌표계로)
|
||
pts_local = pts_2d - self.origin[:2]
|
||
|
||
# TIN 메쉬에서 보간기 생성 (캐시)
|
||
if not hasattr(self, '_tin_interpolator') or self._tin_interpolator is None:
|
||
tin_pts = np.array(self.tin_mesh.points)
|
||
self._tin_interpolator = LinearNDInterpolator(tin_pts[:, :2], tin_pts[:, 2])
|
||
self._tin_z_fallback = float(np.median(tin_pts[:, 2]))
|
||
|
||
# 보간으로 Z값 획득
|
||
z_vals = self._tin_interpolator(pts_local)
|
||
|
||
# NaN인 점 (TIN 범위 밖)은 중간값으로 대체
|
||
nan_mask = np.isnan(z_vals)
|
||
z_vals[nan_mask] = self._tin_z_fallback
|
||
|
||
return np.column_stack([pts_local, z_vals])
|
||
|
||
def _open_elevation_dialog(self):
|
||
"""계획선 레이어별 고도 적용 방식을 선택하는 팝업.
|
||
|
||
4가지 mode:
|
||
- "인근 지형 참조" (terrain): TIN 보간으로 도로 중심선 Z 자동 획득
|
||
- "계획고 입력" (manual): 시점/종점 EL을 직접 입력 → 구간 선형보간
|
||
- "절토" (cut): 인근 지형 기준 N m 아래로 도로면
|
||
- "성토" (fill): 인근 지형 기준 N m 위로 도로면
|
||
|
||
결과 self.layer_elevations[layer_name]:
|
||
{"mode": "terrain"|"manual"|"cut"|"fill",
|
||
"start_el": float, "end_el": float, # manual
|
||
"offset_m": float, # cut/fill 값 (양수)
|
||
"transition_m": float} # TIN 전이 폭 (smoothstep blend zone, 기본 10m)
|
||
"""
|
||
if not self.layer_geometries:
|
||
return False
|
||
|
||
# 계획선(도로/수로/면 오버레이)만 — 구조물은 상세 DXF에서 3D 빌드하므로 고도 설정 불필요
|
||
# path_extrude: 도로/수로 중심선 → TIN에 반영
|
||
# surface_overlay: 굴착/성토 영역 → TIN 위/아래 면 오버레이
|
||
# wall_extrude(옹벽)/box_extrude(수문/취수탑/제수변실)는 structure_registry로 이관 → 제외
|
||
target_layers = {}
|
||
for ln, ld in self.layer_geometries.items():
|
||
rm = ld["type_def"].get("render_mode", "")
|
||
if rm in ("path_extrude", "surface_overlay"):
|
||
target_layers[ln] = ld
|
||
|
||
if not target_layers:
|
||
self.layer_elevations = {}
|
||
return True # 대상 없으면 그냥 진행
|
||
|
||
# --- 지도에 시점/종점 마커 표시 ---
|
||
elev_markers = []
|
||
try:
|
||
src_crs = self.crs_option.get()
|
||
transformer = pyproj.Transformer.from_crs(src_crs, "EPSG:4326", always_xy=True)
|
||
|
||
for ln, ld in target_layers.items():
|
||
for geom in ld["geometries"]:
|
||
if geom["type"] == "polyline" and len(geom["points"]) >= 2:
|
||
sp, ep = geom["points"][0], geom["points"][-1]
|
||
s_lon, s_lat = transformer.transform(sp[0], sp[1])
|
||
e_lon, e_lat = transformer.transform(ep[0], ep[1])
|
||
|
||
m1 = self.map_view.set_marker(s_lat, s_lon,
|
||
text=f"시:{ln[:8]}", marker_color_circle="red",
|
||
marker_color_outside="darkred")
|
||
m2 = self.map_view.set_marker(e_lat, e_lon,
|
||
text=f"종:{ln[:8]}", marker_color_circle="blue",
|
||
marker_color_outside="darkblue")
|
||
elev_markers.extend([m1, m2])
|
||
break # 레이어당 첫 번째 polyline만
|
||
elif geom["type"] == "line":
|
||
sp, ep = geom["start"], geom["end"]
|
||
s_lon, s_lat = transformer.transform(sp[0], sp[1])
|
||
e_lon, e_lat = transformer.transform(ep[0], ep[1])
|
||
|
||
m1 = self.map_view.set_marker(s_lat, s_lon,
|
||
text=f"시:{ln[:8]}", marker_color_circle="red",
|
||
marker_color_outside="darkred")
|
||
m2 = self.map_view.set_marker(e_lat, e_lon,
|
||
text=f"종:{ln[:8]}", marker_color_circle="blue",
|
||
marker_color_outside="darkblue")
|
||
elev_markers.extend([m1, m2])
|
||
break
|
||
except Exception as e:
|
||
self.log(f" 마커 표시 오류: {e}")
|
||
|
||
win = InlinePanel(self)
|
||
win.title("S-CANVAS: 계획선 고도 설정")
|
||
win.geometry("1280x560")
|
||
win.grab_set()
|
||
|
||
ctk.CTkLabel(win, text="계획선 레이어별 고도 적용 방식을 선택하세요",
|
||
font=ctk.CTkFont(size=14, weight="bold")).pack(padx=20, pady=(15, 5))
|
||
ctk.CTkLabel(win,
|
||
text="인근지형: TIN 자동보간 | 계획고: 시/종점 EL | 절토/성토: 지형 대비 N m | 경사 V:H=1:? | 소단: N m마다 N m 폭 플랫폼 | 전이폭: terrain/manual용 smoothstep 폭",
|
||
font=ctk.CTkFont(size=10), text_color="gray",
|
||
wraplength=1240, justify="left").pack(padx=20, pady=(0, 10))
|
||
|
||
scroll = ctk.CTkScrollableFrame(win, height=340)
|
||
scroll.pack(padx=15, pady=5, fill="both", expand=True)
|
||
for c in range(10):
|
||
scroll.grid_columnconfigure(c, weight=1 if c == 0 else 0)
|
||
|
||
# 헤더
|
||
headers = ["레이어", "방식", "시 EL", "종 EL", "절·성(m)",
|
||
"V:H", "소단V(m)", "소단W(m)", "전이(m)", "시/종 좌표"]
|
||
for ci, h in enumerate(headers):
|
||
ctk.CTkLabel(scroll, text=h, font=ctk.CTkFont(size=11, weight="bold")
|
||
).grid(row=0, column=ci, padx=3, sticky="w" if ci in (0, 9) else "")
|
||
|
||
layer_widgets = {}
|
||
for i, (ln, ld) in enumerate(target_layers.items(), 1):
|
||
# 시점/종점 좌표 추출 (첫 번째 polyline 기준)
|
||
coord_text = ""
|
||
for geom in ld["geometries"]:
|
||
if geom["type"] == "polyline" and len(geom["points"]) >= 2:
|
||
sp = geom["points"][0]
|
||
ep = geom["points"][-1]
|
||
coord_text = f"시({sp[0]:.0f},{sp[1]:.0f}) → 종({ep[0]:.0f},{ep[1]:.0f})"
|
||
break
|
||
elif geom["type"] == "line":
|
||
sp = geom["start"]
|
||
ep = geom["end"]
|
||
coord_text = f"시({sp[0]:.0f},{sp[1]:.0f}) → 종({ep[0]:.0f},{ep[1]:.0f})"
|
||
break
|
||
|
||
ctk.CTkLabel(scroll, text=f"{ln}", font=ctk.CTkFont(size=11),
|
||
anchor="w").grid(row=i, column=0, padx=3, pady=3, sticky="w")
|
||
|
||
mode_var = ctk.StringVar(value="인근 지형 참조")
|
||
mode_menu = ctk.CTkOptionMenu(scroll, variable=mode_var,
|
||
values=["인근 지형 참조", "계획고 입력", "절토", "성토"], width=120)
|
||
mode_menu.grid(row=i, column=1, padx=3, pady=3)
|
||
|
||
start_var = ctk.StringVar(value="")
|
||
start_entry = ctk.CTkEntry(scroll, textvariable=start_var, width=65,
|
||
placeholder_text="자동")
|
||
start_entry.grid(row=i, column=2, padx=3, pady=3)
|
||
|
||
end_var = ctk.StringVar(value="")
|
||
end_entry = ctk.CTkEntry(scroll, textvariable=end_var, width=65,
|
||
placeholder_text="자동")
|
||
end_entry.grid(row=i, column=3, padx=3, pady=3)
|
||
|
||
offset_var = ctk.StringVar(value="")
|
||
offset_entry = ctk.CTkEntry(scroll, textvariable=offset_var, width=65,
|
||
placeholder_text="-")
|
||
offset_entry.grid(row=i, column=4, padx=3, pady=3)
|
||
|
||
vh_var = ctk.StringVar(value="0.5")
|
||
vh_entry = ctk.CTkEntry(scroll, textvariable=vh_var, width=50,
|
||
placeholder_text="0.5")
|
||
vh_entry.grid(row=i, column=5, padx=3, pady=3)
|
||
|
||
berm_v_var = ctk.StringVar(value="5")
|
||
berm_v_entry = ctk.CTkEntry(scroll, textvariable=berm_v_var, width=60,
|
||
placeholder_text="5")
|
||
berm_v_entry.grid(row=i, column=6, padx=3, pady=3)
|
||
|
||
berm_w_var = ctk.StringVar(value="1")
|
||
berm_w_entry = ctk.CTkEntry(scroll, textvariable=berm_w_var, width=60,
|
||
placeholder_text="1")
|
||
berm_w_entry.grid(row=i, column=7, padx=3, pady=3)
|
||
|
||
trans_var = ctk.StringVar(value="10")
|
||
trans_entry = ctk.CTkEntry(scroll, textvariable=trans_var, width=55,
|
||
placeholder_text="10m")
|
||
trans_entry.grid(row=i, column=8, padx=3, pady=3)
|
||
|
||
ctk.CTkLabel(scroll, text=coord_text, font=ctk.CTkFont(size=10),
|
||
text_color="gray").grid(row=i, column=9, padx=3, pady=3, sticky="w")
|
||
|
||
def _on_mode(val, se=start_entry, ee=end_entry, oe=offset_entry):
|
||
if val == "계획고 입력":
|
||
se.configure(placeholder_text="EL.")
|
||
ee.configure(placeholder_text="EL.")
|
||
oe.configure(placeholder_text="-")
|
||
elif val in ("절토", "성토"):
|
||
se.configure(placeholder_text="-")
|
||
ee.configure(placeholder_text="-")
|
||
oe.configure(placeholder_text=f"{val}(m)")
|
||
se.delete(0, "end"); ee.delete(0, "end")
|
||
else:
|
||
se.configure(placeholder_text="자동")
|
||
ee.configure(placeholder_text="자동")
|
||
oe.configure(placeholder_text="-")
|
||
se.delete(0, "end"); ee.delete(0, "end"); oe.delete(0, "end")
|
||
mode_menu.configure(command=_on_mode)
|
||
|
||
layer_widgets[ln] = (mode_var, start_var, end_var, offset_var,
|
||
vh_var, berm_v_var, berm_w_var, trans_var)
|
||
|
||
result = [False]
|
||
|
||
def _remove_markers():
|
||
for m in elev_markers:
|
||
with contextlib.suppress(Exception):
|
||
m.delete()
|
||
|
||
def on_ok():
|
||
self.layer_elevations = {}
|
||
for ln, widgets in layer_widgets.items():
|
||
mode_var, start_var, end_var, offset_var, \
|
||
vh_var, berm_v_var, berm_w_var, trans_var = widgets
|
||
label = mode_var.get()
|
||
if "지형" in label: mode = "terrain"
|
||
elif "계획고" in label: mode = "manual"
|
||
elif "절토" in label: mode = "cut"
|
||
elif "성토" in label: mode = "fill"
|
||
else: mode = "terrain"
|
||
|
||
def _fltget(v, default):
|
||
try: return float(v.get()) if v.get().strip() else default
|
||
except (ValueError, TypeError): return default
|
||
|
||
start_el = _fltget(start_var, None) if mode == "manual" else None
|
||
end_el = _fltget(end_var, None) if mode == "manual" else None
|
||
offset_m = abs(_fltget(offset_var, 0.0) or 0.0)
|
||
vh = _fltget(vh_var, 0.5) or 0.5
|
||
berm_v = _fltget(berm_v_var, 5.0) or 5.0
|
||
berm_w = _fltget(berm_w_var, 1.0) or 1.0
|
||
transition_m = max(1.0, _fltget(trans_var, 10.0) or 10.0)
|
||
|
||
self.layer_elevations[ln] = {
|
||
"mode": mode,
|
||
"start_el": start_el, "end_el": end_el,
|
||
"offset_m": offset_m,
|
||
"slope_vh": max(0.01, vh),
|
||
"berm_step_v": max(0.1, berm_v),
|
||
"berm_width_h": max(0.0, berm_w),
|
||
"transition_m": transition_m,
|
||
}
|
||
result[0] = True
|
||
_remove_markers()
|
||
win.destroy()
|
||
|
||
def on_cancel():
|
||
_remove_markers()
|
||
win.destroy()
|
||
|
||
bf = ctk.CTkFrame(win, fg_color="transparent")
|
||
bf.pack(padx=20, pady=10, fill="x")
|
||
ctk.CTkButton(bf, text="취소", width=80, fg_color="transparent", border_width=1,
|
||
command=on_cancel).pack(side="right", padx=5)
|
||
ctk.CTkButton(bf, text="적용", width=140, command=on_ok).pack(side="right", padx=5)
|
||
|
||
win.wait_window()
|
||
return result[0]
|
||
|
||
def _excavate_tin_for_structures(self):
|
||
"""구조물 footprint 영역의 TIN을 굴착/성토 반영하여 재구성.
|
||
|
||
- 폴리곤: placement_transform.ref_tin의 4점 quad가 있으면 우선 사용
|
||
(구조물의 실제 회전/크기 반영). 없으면 AABB bounds 폴백.
|
||
- 폴리곤 내부는 평탄 pad(`pad_z = median(원Z 내부) - exc_depth`)로 flatten
|
||
→ 구조물 바닥이 평면이므로 TIN도 평면이어야 "물리적으로 딱 맞춤" 성립.
|
||
- 외곽 전이구간(transition_w)에서 smoothstep으로 pad_z↔원Z 부드럽게 블렌드
|
||
→ 수직 절벽 제거, 자연스러운 절토 사면.
|
||
- 폴리곤+전이구간에 격자 densification 점을 추가한 뒤 Delaunay 재계산
|
||
→ 전이 사면의 삼각망이 조밀해져 시각적으로 부드러움.
|
||
|
||
양수(+) exc_depth = 원지반 대비 깎기, 음수(-) = 성토.
|
||
"""
|
||
from scipy.interpolate import LinearNDInterpolator
|
||
from scipy.spatial import Delaunay
|
||
|
||
if not self.tin_mesh or not self.structure_registry:
|
||
return 0
|
||
|
||
# 스냅샷: 원본 TIN 점 (Z 보간의 기준 = 굴착 전 원지반)
|
||
orig_pts = np.array(self.tin_mesh.points, dtype=np.float64)
|
||
if len(orig_pts) < 4:
|
||
return 0
|
||
|
||
try:
|
||
orig_interp = LinearNDInterpolator(orig_pts[:, :2], orig_pts[:, 2])
|
||
except Exception as e:
|
||
self.log(f" TIN 보간기 생성 실패: {e}")
|
||
return 0
|
||
|
||
# 작업 사본
|
||
work_pts = orig_pts.copy()
|
||
all_new_points: list[np.ndarray] = [] # densification으로 추가되는 점
|
||
modified_count = 0
|
||
any_excavated = False
|
||
|
||
for info in self.structure_registry.values():
|
||
if not info.get("template_meshes"):
|
||
continue
|
||
exc_depth = float(info.get("excavation_depth", 0.0) or 0.0)
|
||
if abs(exc_depth) < 0.001:
|
||
continue
|
||
|
||
# --- 폴리곤 확보 (TIN 로컬 좌표) ---
|
||
poly_pts = self._get_structure_footprint_polygon(info)
|
||
if poly_pts is None or len(poly_pts) < 3:
|
||
self.log(f" [{info['name']}] 폴리곤 없음 — 굴착 건너뜀")
|
||
continue
|
||
|
||
# 폴리곤 크기 기반 파라미터
|
||
px_min, px_max = float(poly_pts[:, 0].min()), float(poly_pts[:, 0].max())
|
||
py_min, py_max = float(poly_pts[:, 1].min()), float(poly_pts[:, 1].max())
|
||
poly_w = max(px_max - px_min, 1e-3)
|
||
poly_h = max(py_max - py_min, 1e-3)
|
||
poly_size = max(poly_w, poly_h)
|
||
|
||
# 전이구간 폭: 절토사면 경사 1:1.5 가정 (depth × 1.5), 최소 2m
|
||
transition_w = max(abs(exc_depth) * 1.5, 2.0)
|
||
|
||
# 기존 TIN 정점의 폴리곤 signed distance
|
||
signed_d = _signed_distance_to_polygon(work_pts[:, :2], poly_pts)
|
||
inside_mask = signed_d <= 0.0
|
||
|
||
n_inside = int(np.sum(inside_mask))
|
||
if n_inside == 0:
|
||
# 폴리곤이 TIN 정점 없는 영역에 있음 — 중심 Z로 추정
|
||
cx = (px_min + px_max) * 0.5
|
||
cy = (py_min + py_max) * 0.5
|
||
z_c = float(orig_interp(cx, cy))
|
||
if np.isnan(z_c):
|
||
z_c = float(np.median(orig_pts[:, 2]))
|
||
pad_z = z_c - exc_depth
|
||
else:
|
||
pad_z = float(np.median(work_pts[inside_mask, 2])) - exc_depth
|
||
|
||
# --- 기존 정점 Z 수정 ---
|
||
# 내부: pad_z로 flatten
|
||
# 전이: smoothstep blend (pad_z → orig_z)
|
||
in_band = signed_d < transition_w
|
||
band_idx = np.where(in_band)[0]
|
||
for i in band_idx:
|
||
d = float(signed_d[i])
|
||
if d <= 0.0:
|
||
work_pts[i, 2] = pad_z
|
||
else:
|
||
t = d / transition_w # 0..1
|
||
blend = t * t * (3.0 - 2.0 * t) # smoothstep
|
||
orig_z = float(orig_pts[i, 2])
|
||
work_pts[i, 2] = pad_z * (1.0 - blend) + orig_z * blend
|
||
n_affected = len(band_idx)
|
||
|
||
# --- Densification: 폴리곤+전이구간에 격자 점 추가 ---
|
||
# 간격: 구조물 크기의 1/18 정도, 최소 1m
|
||
cell = max(min(poly_size / 18.0, transition_w / 4.0), 1.0)
|
||
ex_xmin = px_min - transition_w - cell
|
||
ex_xmax = px_max + transition_w + cell
|
||
ex_ymin = py_min - transition_w - cell
|
||
ex_ymax = py_max + transition_w + cell
|
||
|
||
nx = max(int((ex_xmax - ex_xmin) / cell) + 1, 2)
|
||
ny = max(int((ex_ymax - ex_ymin) / cell) + 1, 2)
|
||
gx = np.linspace(ex_xmin, ex_xmax, nx)
|
||
gy = np.linspace(ex_ymin, ex_ymax, ny)
|
||
gxx, gyy = np.meshgrid(gx, gy)
|
||
grid_xy = np.column_stack([gxx.ravel(), gyy.ravel()])
|
||
|
||
# 격자점의 signed distance
|
||
grid_d = _signed_distance_to_polygon(grid_xy, poly_pts)
|
||
use_mask = grid_d < transition_w # 굴착 영향권만
|
||
grid_xy = grid_xy[use_mask]
|
||
grid_d = grid_d[use_mask]
|
||
|
||
if len(grid_xy) > 0:
|
||
# 각 격자점의 원지형 Z 보간
|
||
grid_orig_z = orig_interp(grid_xy)
|
||
fallback_z = float(np.median(orig_pts[:, 2]))
|
||
grid_orig_z = np.where(
|
||
np.isnan(grid_orig_z), fallback_z, grid_orig_z
|
||
)
|
||
|
||
# 굴착 Z 계산
|
||
grid_new_z = np.empty(len(grid_xy), dtype=np.float64)
|
||
for i, d in enumerate(grid_d):
|
||
if d <= 0.0:
|
||
grid_new_z[i] = pad_z
|
||
else:
|
||
t = float(d) / transition_w
|
||
blend = t * t * (3.0 - 2.0 * t)
|
||
grid_new_z[i] = pad_z * (1.0 - blend) + float(grid_orig_z[i]) * blend
|
||
|
||
new_xyz = np.column_stack([grid_xy, grid_new_z])
|
||
all_new_points.append(new_xyz)
|
||
|
||
# --- 레지스트리에 pad_z 저장 (placement 단계에서 TIN 관통 방지용) ---
|
||
info["_excavation_pad_z"] = pad_z
|
||
|
||
modified_count += n_affected
|
||
any_excavated = True
|
||
self.log(
|
||
f" [{info['name']}] 굴착 {exc_depth:+.2f}m · "
|
||
f"pad_z={pad_z:.2f}m · "
|
||
f"기존정점 {n_affected}개 수정 + "
|
||
f"densify {len(grid_xy) if len(all_new_points) else 0}점 추가 "
|
||
f"(폴리곤 {poly_w:.1f}×{poly_h:.1f}m, 전이 {transition_w:.1f}m)"
|
||
)
|
||
|
||
if not any_excavated:
|
||
return 0
|
||
|
||
# --- Delaunay 재구성 ---
|
||
if all_new_points:
|
||
new_pts = np.concatenate(all_new_points, axis=0)
|
||
combined = np.vstack([work_pts, new_pts])
|
||
else:
|
||
combined = work_pts
|
||
|
||
# 중복 제거 (XY 동일 정점은 Delaunay 불안정)
|
||
combined_xy = combined[:, :2]
|
||
_, unique_idx = np.unique(
|
||
np.round(combined_xy, 4), axis=0, return_index=True
|
||
)
|
||
combined = combined[np.sort(unique_idx)]
|
||
|
||
try:
|
||
tri = Delaunay(combined[:, :2])
|
||
faces = np.column_stack(
|
||
[np.full(len(tri.simplices), 3), tri.simplices]
|
||
)
|
||
new_mesh = pv.PolyData(combined, faces)
|
||
new_mesh["Elevation"] = combined[:, 2]
|
||
self.tin_mesh = new_mesh
|
||
self._tin_interpolator = None
|
||
self._overlay_cache_key = None
|
||
self.log(
|
||
f" TIN 굴착/성토 완료: {modified_count}개 정점 수정, "
|
||
f"{len(combined) - len(orig_pts)}개 densify점 추가 → "
|
||
f"TIN 재삼각망 {len(combined)}정점"
|
||
)
|
||
except Exception as e:
|
||
# Delaunay 실패 시 폴백: 기존 mesh의 points만 교체 (삼각망은 그대로)
|
||
self.log(f" Delaunay 재삼각망 실패 — in-place Z만 적용: {e}")
|
||
self.tin_mesh.points = work_pts
|
||
self.tin_mesh["Elevation"] = work_pts[:, 2]
|
||
self._tin_interpolator = None
|
||
self._overlay_cache_key = None
|
||
|
||
return modified_count
|
||
|
||
def _get_structure_footprint_polygon(self, info):
|
||
"""구조물 footprint 폴리곤을 TIN 로컬 좌표로 반환 (Nx2 ndarray).
|
||
|
||
우선순위:
|
||
1. placement_transform.ref_tin (4점 quad, 사용자 직접 지정)
|
||
2. info['bounds'] AABB (fallback)
|
||
"""
|
||
tr = info.get("placement_transform")
|
||
if tr is not None:
|
||
ref_tin = getattr(tr, "ref_tin", None)
|
||
if ref_tin and len(ref_tin) >= 3:
|
||
arr = np.asarray(ref_tin, dtype=np.float64)[:, :2]
|
||
# ref_tin이 월드 좌표로 저장된 레거시 데이터 감지 → 로컬 변환
|
||
tb = self.tin_mesh.bounds
|
||
cx = float(arr[:, 0].mean())
|
||
cy = float(arr[:, 1].mean())
|
||
if not (tb.x_min - 5000 <= cx <= tb.x_max + 5000
|
||
and tb.y_min - 5000 <= cy <= tb.y_max + 5000):
|
||
arr = arr - np.array(
|
||
[float(self.origin[0]), float(self.origin[1])]
|
||
)
|
||
return arr
|
||
|
||
# AABB 폴백
|
||
bounds = info.get("bounds")
|
||
if not bounds:
|
||
return None
|
||
local = [
|
||
(bounds[0] - self.origin[0], bounds[1] - self.origin[1]),
|
||
(bounds[2] - self.origin[0], bounds[1] - self.origin[1]),
|
||
(bounds[2] - self.origin[0], bounds[3] - self.origin[1]),
|
||
(bounds[0] - self.origin[0], bounds[3] - self.origin[1]),
|
||
]
|
||
return np.asarray(local, dtype=np.float64)
|
||
|
||
@staticmethod
|
||
def _resample_polyline(pts: np.ndarray, step: float = 2.0) -> np.ndarray:
|
||
"""2D polyline을 step 간격으로 re-sampling. pts: (N, 2)."""
|
||
if len(pts) < 2:
|
||
return pts.copy()
|
||
result = [pts[0].copy()]
|
||
for i in range(1, len(pts)):
|
||
seg_vec = pts[i] - pts[i - 1]
|
||
seg_len = float(np.linalg.norm(seg_vec))
|
||
if seg_len < 1e-9:
|
||
continue
|
||
n_sub = max(1, int(np.ceil(seg_len / max(step, 0.1))))
|
||
for j in range(1, n_sub + 1):
|
||
t = j / n_sub
|
||
result.append(pts[i - 1] + t * seg_vec)
|
||
return np.array(result)
|
||
|
||
@staticmethod
|
||
def _slope_breakpoints(depth: float, half_w: float, vh: float,
|
||
berm_v: float, berm_w: float) -> list:
|
||
"""절토/성토 사면의 단절점들을 (cross_dist, v_rise) 쌍으로 반환.
|
||
|
||
단절점은 삼각화가 유지해야 할 주요 위치:
|
||
- 도로 edge (half_w, 0)
|
||
- 각 사면 꼭대기 (= 소단 시작)
|
||
- 각 소단 끝
|
||
- 사면 toe (= depth 도달 지점, 약간 여유)
|
||
|
||
V:H=1:vh 경사로 오르다가 berm_v마다 berm_w 폭 소단 삽입. 총 depth까지.
|
||
"""
|
||
pts = [(half_w, 0.0)] # 도로 edge
|
||
if depth <= 0:
|
||
return pts
|
||
v = 0.0
|
||
h = half_w
|
||
while v < depth:
|
||
seg_v = min(berm_v, depth - v)
|
||
seg_h = seg_v * vh
|
||
h += seg_h
|
||
v += seg_v
|
||
pts.append((h, v)) # 사면 꼭대기 / 소단 시작
|
||
if v >= depth:
|
||
break
|
||
h += berm_w
|
||
pts.append((h, v)) # 소단 끝 / 다음 사면 시작
|
||
# 사면 toe 바깥쪽 약간 여유 (지형과의 자연스러운 연결을 위한 transition 점)
|
||
pts.append((h + 0.3, v))
|
||
return pts
|
||
|
||
@staticmethod
|
||
def _cut_slope_rise(h_dist: float, total_depth: float,
|
||
vh_ratio: float = 0.5,
|
||
berm_step_v: float = 5.0,
|
||
berm_width_h: float = 1.0) -> float:
|
||
"""수평 h_dist만큼 나가면서 토목 표준 사면으로 수직 상승(또는 하강)한 양.
|
||
|
||
V:H = 1 : vh_ratio 의 사면으로 오르다가, 수직 berm_step_v마다 berm_width_h
|
||
폭의 소단(수평 플랫폼)을 삽입. total_depth에서 캡.
|
||
|
||
예) vh_ratio=0.5, berm_step_v=5, berm_width_h=1:
|
||
수직 5m 오르려면 수평 2.5m → 소단 1m → 수직 5m (수평 2.5m) → 소단 1m → …
|
||
수직 10m 사면의 총 수평거리 = 5 + 1 = 6m (소단 1개)
|
||
|
||
Args:
|
||
h_dist: 수평 거리 (도로 edge에서 얼마 나갔는지, ≥0)
|
||
total_depth: 도달해야 할 총 수직 거리 (cut_depth 또는 fill_depth, ≥0)
|
||
vh_ratio: V:H 비율 중 H값 (V=1 기준). 0.5면 1:0.5
|
||
berm_step_v: 소단 간격 (수직 m)
|
||
berm_width_h: 소단 폭 (수평 m)
|
||
|
||
Returns:
|
||
h_dist 지점의 수직 상승량 (0 ≤ ... ≤ total_depth)
|
||
"""
|
||
if h_dist <= 0 or total_depth <= 0:
|
||
return 0.0
|
||
v = 0.0
|
||
h = 0.0
|
||
while v < total_depth:
|
||
seg_v = min(berm_step_v, total_depth - v)
|
||
seg_h = seg_v * vh_ratio
|
||
if h + seg_h >= h_dist:
|
||
# 현재 사면 세그먼트 위
|
||
frac = (h_dist - h) / max(seg_h, 1e-9)
|
||
return min(v + seg_v * frac, total_depth)
|
||
h += seg_h
|
||
v += seg_v
|
||
if v >= total_depth:
|
||
return total_depth
|
||
# 소단 (수평 플랫폼)
|
||
if h + berm_width_h >= h_dist:
|
||
return v
|
||
h += berm_width_h
|
||
return total_depth
|
||
|
||
def _retriangulate_for_cut_fill(self, orig_pts, original_z, interp, z_fallback,
|
||
cut_fill_layers, elev_settings):
|
||
"""cut/fill 레이어에 대해 synthetic 정점 + 재-삼각화로 깨끗한 사면 생성."""
|
||
from matplotlib.path import Path as MplPath
|
||
from scipy.spatial import Delaunay
|
||
|
||
synthetic_pts: list[tuple[float, float, float]] = [] # (x, y, z) local
|
||
cut_zone_polys: list[np.ndarray] = [] # 각 road의 cut zone 폴리곤
|
||
|
||
along_step = 2.0 # 도로 방향 2m 간격 sampling
|
||
|
||
def _sample_terrain(x, y):
|
||
z = float(interp([x, y]))
|
||
if np.isnan(z):
|
||
z = z_fallback
|
||
return z
|
||
|
||
for layer_name, layer_data in cut_fill_layers:
|
||
type_def = layer_data["type_def"]
|
||
width = float(type_def.get("width", 6.0))
|
||
half_w = width / 2.0
|
||
|
||
cfg = elev_settings.get(layer_name, {})
|
||
mode = cfg.get("mode", "terrain")
|
||
offset_m = float(cfg.get("offset_m", 0.0) or 0.0)
|
||
vh = float(cfg.get("slope_vh", 0.5) or 0.5)
|
||
berm_v = float(cfg.get("berm_step_v", 5.0) or 5.0)
|
||
berm_w = float(cfg.get("berm_width_h", 1.0) or 1.0)
|
||
|
||
# 사면 단절점 cross 위치 사전 계산 (depth가 달라도 재사용 기본 패턴)
|
||
# 실제 depth는 per-along 다름 → 각 along마다 _slope_breakpoints 재호출
|
||
|
||
for geom in layer_data["geometries"]:
|
||
if geom["type"] != "polyline" or len(geom["points"]) < 2:
|
||
continue
|
||
|
||
raw_pts = np.array(geom["points"], dtype=np.float64)
|
||
local_pts = raw_pts - self.origin[:2]
|
||
sampled = self._resample_polyline(local_pts, step=along_step)
|
||
n_along = len(sampled)
|
||
if n_along < 2:
|
||
continue
|
||
|
||
# 각 along 점의 tangent → normal
|
||
tangents = np.zeros_like(sampled)
|
||
for i in range(n_along):
|
||
if i == 0:
|
||
t = sampled[1] - sampled[0]
|
||
elif i == n_along - 1:
|
||
t = sampled[-1] - sampled[-2]
|
||
else:
|
||
t = sampled[i + 1] - sampled[i - 1]
|
||
nrm = np.linalg.norm(t)
|
||
tangents[i] = t / max(nrm, 1e-9)
|
||
|
||
# cut zone polygon 외곽선 (left toe forward + right toe reverse)
|
||
left_toe_pts: list[list[float]] = []
|
||
right_toe_pts: list[list[float]] = []
|
||
|
||
# per-along loop
|
||
for i in range(n_along):
|
||
x, y = sampled[i]
|
||
tangent = tangents[i]
|
||
normal = np.array([-tangent[1], tangent[0]])
|
||
|
||
terrain_z = _sample_terrain(x, y)
|
||
# cut: 지형보다 낮게(절토), fill: 지형보다 높게(성토)
|
||
road_z = terrain_z - offset_m if mode == "cut" else terrain_z + offset_m
|
||
# origin Z 보정
|
||
road_z -= self.origin[2]
|
||
# Note: original_z 배열은 이미 origin[2] 보정된 상태이므로
|
||
# interp 결과도 local Z. road_z도 local.
|
||
|
||
# 중심선 정점
|
||
synthetic_pts.append((float(x), float(y), float(road_z)))
|
||
|
||
# Depth: 각 side에서 실제 지형 Z와의 차이 (단순화: 중심선 terrain 사용)
|
||
# local Z (cut/fill 모두 절대값으로 동일하게 계산)
|
||
depth_local_z = abs(terrain_z - (road_z + self.origin[2]))
|
||
|
||
bps = self._slope_breakpoints(
|
||
depth_local_z, half_w, vh, berm_v, berm_w,
|
||
)
|
||
|
||
# 각 side (-1, +1)에 대해
|
||
for side in (-1.0, 1.0):
|
||
for (cross, v_rise) in bps:
|
||
cx = float(x + side * normal[0] * cross)
|
||
cy = float(y + side * normal[1] * cross)
|
||
if mode == "cut":
|
||
cz = road_z + v_rise
|
||
# 지형 이상 cut 금지 → 지형으로 캡
|
||
terrain_here_abs = _sample_terrain(cx, cy)
|
||
terrain_here_local = terrain_here_abs - self.origin[2]
|
||
cz = min(cz, terrain_here_local)
|
||
else: # fill
|
||
cz = road_z - v_rise
|
||
terrain_here_abs = _sample_terrain(cx, cy)
|
||
terrain_here_local = terrain_here_abs - self.origin[2]
|
||
cz = max(cz, terrain_here_local)
|
||
synthetic_pts.append((cx, cy, float(cz)))
|
||
|
||
# cut zone 경계: toe 끝
|
||
toe_cross = bps[-1][0] if bps else half_w
|
||
lx = float(x - normal[0] * toe_cross)
|
||
ly = float(y - normal[1] * toe_cross)
|
||
rx = float(x + normal[0] * toe_cross)
|
||
ry = float(y + normal[1] * toe_cross)
|
||
left_toe_pts.append([lx, ly])
|
||
right_toe_pts.append([rx, ry])
|
||
|
||
# cut zone 폴리곤 닫기 (right forward + left reverse)
|
||
if left_toe_pts and right_toe_pts:
|
||
zone = np.array(right_toe_pts + list(reversed(left_toe_pts)))
|
||
cut_zone_polys.append(zone)
|
||
|
||
if not synthetic_pts:
|
||
self.log(" TIN 변형 대상 없음 (cut/fill synthetic 생성 실패)")
|
||
return
|
||
|
||
# 원본 TIN 정점 중 cut zone 내부에 있는 것들 제거
|
||
keep_mask = np.ones(len(orig_pts), dtype=bool)
|
||
for zone in cut_zone_polys:
|
||
if len(zone) < 3:
|
||
continue
|
||
try:
|
||
path = MplPath(zone)
|
||
inside = path.contains_points(orig_pts[:, :2])
|
||
keep_mask &= ~inside
|
||
except Exception:
|
||
pass
|
||
kept = orig_pts[keep_mask]
|
||
|
||
# 통합
|
||
synth_arr = np.array(synthetic_pts, dtype=np.float64)
|
||
combined = np.vstack([kept, synth_arr])
|
||
|
||
# 재-삼각화
|
||
try:
|
||
tri = Delaunay(combined[:, :2])
|
||
faces = np.column_stack([np.full(len(tri.simplices), 3), tri.simplices])
|
||
import pyvista as pv
|
||
new_mesh = pv.PolyData(combined, faces)
|
||
new_mesh["Elevation"] = combined[:, 2]
|
||
self.tin_mesh = new_mesh
|
||
self._tin_interpolator = None
|
||
self.log(
|
||
f" TIN 재-삼각화: 원본 {len(orig_pts)} → 잔존 {len(kept)} + synthetic {len(synth_arr)} "
|
||
f"= {len(combined)} 정점, {len(tri.simplices)} 삼각형 "
|
||
f"(cut/fill {len(cut_fill_layers)}개 레이어)"
|
||
)
|
||
except Exception as e:
|
||
self.log(f" TIN 재-삼각화 실패: {e} — 원본 유지")
|
||
|
||
def _deform_tin_for_plans(self):
|
||
"""도로/굴착 계획선에 맞게 TIN 지형을 변형.
|
||
|
||
cut/fill 모드: **synthetic 정점 삽입 + cut zone 재-삼각화** (깨끗한 사면·소단)
|
||
terrain/manual 모드: 기존 smoothstep 블렌드 (vertex displacement)
|
||
|
||
cut/fill 재-삼각화 절차:
|
||
1) 각 cut/fill road에 대해 along step 2m로 polyline re-sampling
|
||
2) 각 along 점에서 수직 법선 방향으로 **사면 단절점**(도로 edge, 소단 edge,
|
||
사면 toe) XY 좌표 + 해당 Z 값 생성 → synthetic 정점
|
||
3) road 중심선 자체도 synthetic 정점으로 (평탄 road_z)
|
||
4) 각 road의 **cut zone 폴리곤**(road ± 사면 총폭) 계산
|
||
5) 원본 TIN 정점 중 어떤 cut zone 내부에 속하는 것들을 제거
|
||
6) 잔존 TIN + synthetic 통합 후 Delaunay 재-삼각화로 교체
|
||
7) 결과: 도로 평탄, 사면 경계 날카로운 소단, 지형 매끄러운 전이
|
||
"""
|
||
from scipy.interpolate import LinearNDInterpolator
|
||
from matplotlib.path import Path as MplPath
|
||
from scipy.spatial import cKDTree
|
||
|
||
if not self.layer_geometries or not self.tin_mesh:
|
||
return
|
||
|
||
pts = np.array(self.tin_mesh.points).copy()
|
||
original_z = pts[:, 2].copy() # 변형 전 지형 Z (보존 기준)
|
||
|
||
interp = LinearNDInterpolator(pts[:, :2], original_z)
|
||
z_fallback = float(np.median(original_z))
|
||
tree = cKDTree(pts[:, :2])
|
||
|
||
elev_settings = getattr(self, 'layer_elevations', {})
|
||
|
||
# ─────────────────────────────────────────────────────────
|
||
# cut/fill 모드 레이어가 있으면 **재-삼각화** 경로
|
||
# ─────────────────────────────────────────────────────────
|
||
cut_fill_layers = [
|
||
(ln, ld) for ln, ld in self.layer_geometries.items()
|
||
if elev_settings.get(ln, {}).get("mode") in ("cut", "fill")
|
||
and ld["type_def"].get("render_mode") == "path_extrude"
|
||
]
|
||
|
||
if cut_fill_layers:
|
||
self._retriangulate_for_cut_fill(pts, original_z, interp, z_fallback,
|
||
cut_fill_layers, elev_settings)
|
||
return # 재-삼각화 완료. terrain/manual는 별도 호출 필요 시 확장
|
||
|
||
# ─────────────────────────────────────────────────────────
|
||
# cut/fill 없으면 기존 smoothstep 블렌드 (terrain/manual/surface_overlay)
|
||
# ─────────────────────────────────────────────────────────
|
||
|
||
# per-vertex 누적 버퍼: 각 정점의 target Z 후보를 weight 가중 평균
|
||
n_verts = len(pts)
|
||
sum_wz = np.zeros(n_verts, dtype=np.float64)
|
||
sum_w = np.zeros(n_verts, dtype=np.float64)
|
||
|
||
def _smoothstep(u):
|
||
u = max(0.0, min(1.0, u))
|
||
return u * u * (3.0 - 2.0 * u)
|
||
|
||
for layer_name, layer_data in self.layer_geometries.items():
|
||
type_def = layer_data["type_def"]
|
||
render_mode = type_def.get("render_mode", "line_only")
|
||
if render_mode not in ("path_extrude", "surface_overlay"):
|
||
continue
|
||
|
||
z_offset_yaml = type_def.get("z_offset", 0.0)
|
||
width = type_def.get("width", 6.0)
|
||
|
||
elev_cfg = elev_settings.get(layer_name, {"mode": "terrain"})
|
||
mode = elev_cfg.get("mode", "terrain")
|
||
manual_start = elev_cfg.get("start_el")
|
||
manual_end = elev_cfg.get("end_el")
|
||
offset_m = float(elev_cfg.get("offset_m", 0.0) or 0.0)
|
||
transition_m = float(elev_cfg.get("transition_m", 10.0) or 10.0)
|
||
vh_ratio = float(elev_cfg.get("slope_vh", 0.5) or 0.5)
|
||
berm_step_v = float(elev_cfg.get("berm_step_v", 5.0) or 5.0)
|
||
berm_width_h = float(elev_cfg.get("berm_width_h", 1.0) or 1.0)
|
||
|
||
is_cut_fill_mode = mode in ("cut", "fill")
|
||
|
||
for geom in layer_data["geometries"]:
|
||
try:
|
||
if render_mode == "path_extrude":
|
||
if geom["type"] != "polyline" or len(geom["points"]) < 2:
|
||
continue
|
||
raw_pts = np.array(geom["points"], dtype=np.float64)
|
||
local_pts = raw_pts - self.origin[:2]
|
||
n_pts = len(local_pts)
|
||
|
||
# 각 중심선 점의 target Z
|
||
centerline_z = np.zeros(n_pts)
|
||
if mode == "manual" and manual_start is not None and manual_end is not None:
|
||
z_start = manual_start - self.origin[2]
|
||
z_end = manual_end - self.origin[2]
|
||
dists = np.zeros(n_pts)
|
||
for pi in range(1, n_pts):
|
||
dists[pi] = dists[pi-1] + np.linalg.norm(local_pts[pi] - local_pts[pi-1])
|
||
total_dist = max(dists[-1], 1e-6)
|
||
centerline_z = z_start + (z_end - z_start) * (dists / total_dist)
|
||
else:
|
||
for pi in range(n_pts):
|
||
z_here = interp(local_pts[pi])
|
||
if np.isnan(z_here):
|
||
z_here = z_fallback
|
||
centerline_z[pi] = float(z_here)
|
||
if mode == "cut":
|
||
centerline_z -= offset_m
|
||
elif mode == "fill":
|
||
centerline_z += offset_m
|
||
centerline_z += z_offset_yaml
|
||
|
||
half_w = width / 2.0
|
||
# 사면의 최대 수평 반경: cut/fill 모드는 사면 끝 + 약간 여유,
|
||
# terrain/manual은 기존 transition_m
|
||
if is_cut_fill_mode:
|
||
# 사면 예상 최대 수평: depth * vh_ratio + 소단 영향
|
||
est_max_depth = max(offset_m + 3.0, 3.0) # 여유
|
||
n_berms = int(est_max_depth / max(berm_step_v, 1e-3))
|
||
slope_horiz_max = est_max_depth * vh_ratio + n_berms * berm_width_h
|
||
search_r_beyond = max(slope_horiz_max, transition_m) + 2.0
|
||
else:
|
||
search_r_beyond = transition_m + 2.0
|
||
total_w = half_w + search_r_beyond
|
||
|
||
for seg_i in range(n_pts - 1):
|
||
p1 = local_pts[seg_i]
|
||
p2 = local_pts[seg_i + 1]
|
||
seg_vec = p2 - p1
|
||
seg_len = float(np.linalg.norm(seg_vec))
|
||
if seg_len < 0.1:
|
||
continue
|
||
seg_dir = seg_vec / seg_len
|
||
normal = np.array([-seg_dir[1], seg_dir[0]])
|
||
|
||
mid = (p1 + p2) / 2
|
||
search_r = seg_len / 2 + total_w + 2
|
||
nearby = tree.query_ball_point(mid, search_r)
|
||
|
||
z1 = centerline_z[seg_i]
|
||
z2 = centerline_z[seg_i + 1]
|
||
|
||
for vi in nearby:
|
||
v = pts[vi, :2] - p1
|
||
along = float(np.dot(v, seg_dir))
|
||
if along < 0 or along > seg_len:
|
||
continue
|
||
cross = abs(float(np.dot(v, normal)))
|
||
if cross > total_w:
|
||
continue
|
||
|
||
t = along / seg_len
|
||
road_z = z1 * (1 - t) + z2 * t
|
||
terrain_z_here = original_z[vi]
|
||
|
||
if cross <= half_w:
|
||
# 도로 표면: 완전 평탄화
|
||
target_z = road_z
|
||
w = 1.0
|
||
elif is_cut_fill_mode:
|
||
# 토목 표준 사면으로 cut/fill
|
||
h_from_edge = cross - half_w
|
||
if mode == "cut" or (mode == "fill" and terrain_z_here < road_z):
|
||
# cut: 도로보다 지형이 위 → 상승 사면
|
||
depth = max(0.0, terrain_z_here - road_z)
|
||
rise = self._cut_slope_rise(
|
||
h_from_edge, depth,
|
||
vh_ratio, berm_step_v, berm_width_h,
|
||
)
|
||
target_z = road_z + rise
|
||
# 사면이 지형에 도달하면 지형 유지
|
||
target_z = min(terrain_z_here, target_z)
|
||
else:
|
||
# fill: 지형보다 도로가 위 → 하강 사면
|
||
depth = max(0.0, road_z - terrain_z_here)
|
||
drop = self._cut_slope_rise(
|
||
h_from_edge, depth,
|
||
vh_ratio, berm_step_v, berm_width_h,
|
||
)
|
||
target_z = road_z - drop
|
||
target_z = max(terrain_z_here, target_z)
|
||
w = 1.0 # 사면 영역 완전 적용
|
||
else:
|
||
# terrain/manual: smoothstep 블렌드
|
||
u = (cross - half_w) / transition_m
|
||
w = 1.0 - _smoothstep(u)
|
||
target_z = road_z
|
||
|
||
sum_wz[vi] += w * target_z
|
||
sum_w[vi] += w
|
||
|
||
elif render_mode == "surface_overlay":
|
||
if geom["type"] != "polyline" or not geom.get("closed", False):
|
||
continue
|
||
raw_pts = np.array(geom["points"], dtype=np.float64)
|
||
local_pts = raw_pts - self.origin[:2]
|
||
poly = MplPath(local_pts)
|
||
inside_mask = poly.contains_points(pts[:, :2])
|
||
sign = -1 if mode == "cut" else (+1 if mode == "fill" else 0)
|
||
overlay_offset = sign * offset_m + z_offset_yaml
|
||
if abs(overlay_offset) < 1e-9:
|
||
continue
|
||
for vi in np.where(inside_mask)[0]:
|
||
target = original_z[vi] + overlay_offset
|
||
sum_wz[vi] += target
|
||
sum_w[vi] += 1.0
|
||
|
||
except Exception as e:
|
||
self.log(f" TIN 변형 오류 ({layer_name}): {e}")
|
||
continue
|
||
|
||
influenced = sum_w > 1e-6
|
||
n_influenced = int(np.sum(influenced))
|
||
if n_influenced == 0:
|
||
self.log(" TIN 변형 대상 없음")
|
||
return
|
||
|
||
target_z = np.where(influenced, sum_wz / np.maximum(sum_w, 1e-9), pts[:, 2])
|
||
blend = np.clip(sum_w, 0.0, 1.0)
|
||
pts[:, 2] = blend * target_z + (1.0 - blend) * pts[:, 2]
|
||
|
||
self.tin_mesh.points = pts
|
||
self.tin_mesh["Elevation"] = pts[:, 2]
|
||
self._tin_interpolator = None
|
||
self.log(f" TIN 지형 변형 완료: {n_influenced:,}개 정점 조정됨")
|
||
|
||
def _build_plan_overlay_meshes(self):
|
||
"""분류된 계획선을 3D 메쉬로 변환하여 overlay_meshes 리스트를 반환 (캐시됨).
|
||
|
||
워크플로우 원칙:
|
||
- path_extrude / surface_overlay: TIN 변형으로 이미 처리 → 여기선 스킵
|
||
- 구조물 모드(wall/box/elevated/tube): 평면도에서는 '위치 표시용 outline'만
|
||
(full 3D는 detail DXF 업로드 후 template_meshes가 담당)
|
||
- line_only: 경계선만 표시
|
||
|
||
Returns: [(pv.PolyData, color_hex, opacity), ...]
|
||
"""
|
||
if not self.layer_geometries or not self.tin_mesh:
|
||
return []
|
||
|
||
# 캐시 확인 — (layer_geometries id, tin_mesh id) 기반
|
||
cache_key = (id(self.layer_geometries), id(self.tin_mesh))
|
||
if getattr(self, "_overlay_cache_key", None) == cache_key:
|
||
cached = getattr(self, "_overlay_cache", None)
|
||
if cached is not None:
|
||
return cached
|
||
|
||
tin_deformed_modes = {"path_extrude", "surface_overlay"}
|
||
# 상세빌드 대상 모드 = overlay에서 스킵 (structure_registry가 bbox 마커로 표시)
|
||
structure_modes = {"wall_extrude", "box_extrude", "elevated_path", "tube_path"}
|
||
|
||
# TIN 바운드 기반 클립 박스 (원본 좌표계)
|
||
# — INSERT explode 시 구조물 블록 내부 치수선/보조선이 도면 외곽 좌표를 가져
|
||
# overlay 뷰를 수백만 단위로 늘려 TIN이 작게 보이는 문제 방지
|
||
tb = self.tin_mesh.bounds # local coords
|
||
pad_x = max((tb[1] - tb[0]) * 0.15, 100.0)
|
||
pad_y = max((tb[3] - tb[2]) * 0.15, 100.0)
|
||
clip_xmin_raw = tb[0] + self.origin[0] - pad_x
|
||
clip_xmax_raw = tb[1] + self.origin[0] + pad_x
|
||
clip_ymin_raw = tb[2] + self.origin[1] - pad_y
|
||
clip_ymax_raw = tb[3] + self.origin[1] + pad_y
|
||
self._diag(f"=== _build_plan_overlay_meshes: TIN 바운드 클립 "
|
||
f"X[{clip_xmin_raw:.0f}, {clip_xmax_raw:.0f}] "
|
||
f"Y[{clip_ymin_raw:.0f}, {clip_ymax_raw:.0f}] ===")
|
||
|
||
def _in_bounds(xy_iter):
|
||
return all(clip_xmin_raw <= x <= clip_xmax_raw and clip_ymin_raw <= y <= clip_ymax_raw for x, y in xy_iter)
|
||
|
||
overlay_meshes = []
|
||
skipped_oob = {} # 레이어별 out-of-bounds 스킵 카운트
|
||
|
||
for layer_name, layer_data in self.layer_geometries.items():
|
||
type_def = layer_data["type_def"]
|
||
geoms = layer_data["geometries"]
|
||
|
||
render_mode = type_def.get("render_mode", "line_only")
|
||
color = type_def.get("color", "#FF0000")
|
||
opacity = type_def.get("opacity", 0.8)
|
||
z_offset = type_def.get("z_offset", 0.0)
|
||
|
||
if render_mode in tin_deformed_modes:
|
||
continue # TIN 변형으로 처리됨
|
||
if render_mode in structure_modes:
|
||
continue # structure_registry bbox 마커가 담당
|
||
|
||
# 상세도면에서 계획고가 지정된 경우 고정 Z 사용
|
||
fixed_el = type_def.get("elevation")
|
||
|
||
for geom in geoms:
|
||
try:
|
||
mesh = None
|
||
|
||
if geom["type"] == "polyline" and len(geom["points"]) >= 2:
|
||
pts_2d = geom["points"]
|
||
if not _in_bounds(pts_2d):
|
||
skipped_oob[layer_name] = skipped_oob.get(layer_name, 0) + 1
|
||
continue
|
||
pts_3d = self._project_xy_to_tin(pts_2d)
|
||
if len(pts_3d) < 2:
|
||
continue
|
||
|
||
if fixed_el is not None:
|
||
pts_3d[:, 2] = fixed_el - self.origin[2]
|
||
else:
|
||
pts_3d[:, 2] += z_offset
|
||
|
||
mesh = self._make_line_mesh(pts_3d)
|
||
|
||
elif geom["type"] == "line":
|
||
pts_2d = [geom["start"], geom["end"]]
|
||
if not _in_bounds(pts_2d):
|
||
skipped_oob[layer_name] = skipped_oob.get(layer_name, 0) + 1
|
||
continue
|
||
pts_3d = self._project_xy_to_tin(pts_2d)
|
||
if len(pts_3d) >= 2:
|
||
if fixed_el is not None:
|
||
pts_3d[:, 2] = fixed_el - self.origin[2]
|
||
else:
|
||
pts_3d[:, 2] += z_offset
|
||
mesh = self._make_line_mesh(pts_3d)
|
||
|
||
if mesh is not None and mesh.n_points > 0:
|
||
overlay_meshes.append((mesh, color, opacity))
|
||
|
||
except Exception as e:
|
||
self.log(f" 지오메트리 변환 실패 ({layer_name}/{render_mode}): {e}")
|
||
continue
|
||
|
||
if skipped_oob:
|
||
for ln, cnt in skipped_oob.items():
|
||
self._diag(f" [OOB-SKIP] {ln}: {cnt}개 geom 제외 (TIN 바운드 밖)")
|
||
|
||
# 캐시 저장
|
||
self._overlay_cache_key = cache_key
|
||
self._overlay_cache = overlay_meshes
|
||
return overlay_meshes
|
||
|
||
def _make_surface_mesh(self, pts_3d):
|
||
"""폐합 폴리라인 → 평면 메쉬 (삼각화)"""
|
||
if len(pts_3d) < 3:
|
||
return None
|
||
try:
|
||
tri = Delaunay(pts_3d[:, :2])
|
||
faces = np.column_stack([np.full(len(tri.simplices), 3), tri.simplices])
|
||
return pv.PolyData(pts_3d, faces)
|
||
except Exception:
|
||
return None
|
||
|
||
def _make_path_mesh(self, pts_3d, width):
|
||
"""선형 → 폭 있는 도로/수로 면"""
|
||
if len(pts_3d) < 2:
|
||
return None
|
||
|
||
half_w = width / 2.0
|
||
left_pts = []
|
||
right_pts = []
|
||
|
||
for i in range(len(pts_3d)):
|
||
if i == 0:
|
||
dx = pts_3d[1][0] - pts_3d[0][0]
|
||
dy = pts_3d[1][1] - pts_3d[0][1]
|
||
elif i == len(pts_3d) - 1:
|
||
dx = pts_3d[-1][0] - pts_3d[-2][0]
|
||
dy = pts_3d[-1][1] - pts_3d[-2][1]
|
||
else:
|
||
dx = pts_3d[i+1][0] - pts_3d[i-1][0]
|
||
dy = pts_3d[i+1][1] - pts_3d[i-1][1]
|
||
|
||
length = max(np.sqrt(dx*dx + dy*dy), 1e-6)
|
||
nx, ny = -dy / length * half_w, dx / length * half_w
|
||
|
||
p = pts_3d[i]
|
||
left_pts.append([p[0] + nx, p[1] + ny, p[2]])
|
||
right_pts.append([p[0] - nx, p[1] - ny, p[2]])
|
||
|
||
# 좌+우 점을 합쳐 strip 메쉬 생성
|
||
all_pts = np.array(left_pts + right_pts[::-1])
|
||
n = len(all_pts)
|
||
if n < 3:
|
||
return None
|
||
|
||
try:
|
||
tri = Delaunay(all_pts[:, :2])
|
||
faces = np.column_stack([np.full(len(tri.simplices), 3), tri.simplices])
|
||
return pv.PolyData(all_pts, faces)
|
||
except Exception:
|
||
return None
|
||
|
||
def _make_wall_mesh(self, pts_3d, height):
|
||
"""선형 → 수직 벽체"""
|
||
if len(pts_3d) < 2:
|
||
return None
|
||
|
||
n = len(pts_3d)
|
||
top_pts = pts_3d.copy()
|
||
top_pts[:, 2] += height
|
||
|
||
all_pts = np.vstack([pts_3d, top_pts])
|
||
|
||
# 각 세그먼트를 두 삼각형으로 구성
|
||
faces_list = []
|
||
for i in range(n - 1):
|
||
b0, b1 = i, i + 1
|
||
t0, t1 = i + n, i + 1 + n
|
||
faces_list.append([3, b0, b1, t0])
|
||
faces_list.append([3, b1, t1, t0])
|
||
|
||
faces = np.array(faces_list).flatten()
|
||
return pv.PolyData(all_pts, faces)
|
||
|
||
def _make_box_mesh(self, pts_3d, height):
|
||
"""폐합 → 박스 (바닥면 + 옆면 + 윗면)"""
|
||
wall = self._make_wall_mesh(pts_3d, height)
|
||
bottom = self._make_surface_mesh(pts_3d)
|
||
|
||
top_pts = pts_3d.copy()
|
||
top_pts[:, 2] += height
|
||
top = self._make_surface_mesh(top_pts)
|
||
|
||
parts = [m for m in [wall, bottom, top] if m is not None]
|
||
if not parts:
|
||
return None
|
||
|
||
combined = parts[0]
|
||
for m in parts[1:]:
|
||
combined = combined.merge(m)
|
||
return combined
|
||
|
||
def _make_line_mesh(self, pts_3d):
|
||
"""점 목록 → 선 메쉬 (PyVista PolyLine)"""
|
||
if len(pts_3d) < 2:
|
||
return None
|
||
n = len(pts_3d)
|
||
lines = np.zeros(n + 1, dtype=int)
|
||
lines[0] = n
|
||
lines[1:] = np.arange(n)
|
||
return pv.PolyData(pts_3d, lines=lines)
|
||
|
||
def _make_elevated_path_mesh(self, pts_3d, width, height):
|
||
"""교량: 지형 위 일정 높이로 들어올린 도로면 + 하부 기둥"""
|
||
if len(pts_3d) < 2:
|
||
return None
|
||
|
||
# 상판: 지형 + height 위치에 도로면 생성
|
||
elevated_pts = pts_3d.copy()
|
||
elevated_pts[:, 2] += height
|
||
deck = self._make_path_mesh(elevated_pts, width)
|
||
|
||
# 기둥: 각 점에서 지형 → 상판까지 수직 벽체 (양쪽 끝 + 중간점)
|
||
parts = [deck] if deck else []
|
||
pillar_indices = [0, len(pts_3d) - 1]
|
||
if len(pts_3d) > 4:
|
||
pillar_indices.append(len(pts_3d) // 2)
|
||
|
||
pillar_w = min(width * 0.15, 1.5)
|
||
for idx in pillar_indices:
|
||
base = pts_3d[idx].copy()
|
||
top = base.copy()
|
||
top[2] += height
|
||
pillar_pts = np.array([
|
||
[base[0] - pillar_w, base[1], base[2]],
|
||
[base[0] + pillar_w, base[1], base[2]],
|
||
[base[0] + pillar_w, base[1], top[2]],
|
||
[base[0] - pillar_w, base[1], top[2]],
|
||
])
|
||
faces = np.array([3, 0, 1, 2, 3, 0, 2, 3])
|
||
parts.append(pv.PolyData(pillar_pts, faces))
|
||
|
||
if not parts:
|
||
return None
|
||
combined = parts[0]
|
||
for m in parts[1:]:
|
||
combined = combined.merge(m)
|
||
return combined
|
||
|
||
def _make_tube_mesh(self, pts_3d, diameter):
|
||
"""관로: 중심선을 따라 원형 단면 튜브 생성"""
|
||
if len(pts_3d) < 2:
|
||
return None
|
||
|
||
try:
|
||
n = len(pts_3d)
|
||
lines = np.zeros(n + 1, dtype=int)
|
||
lines[0] = n
|
||
lines[1:] = np.arange(n)
|
||
centerline = pv.PolyData(pts_3d, lines=lines)
|
||
tube = centerline.tube(radius=diameter / 2.0, n_sides=12)
|
||
return tube
|
||
except Exception:
|
||
return self._make_line_mesh(pts_3d)
|
||
|
||
def _on_engine_changed(self, engine_name):
|
||
"""렌더링 엔진 변경 시 UI 업데이트"""
|
||
if "Vertex" in engine_name:
|
||
self.stab_label.configure(text="GCP Project ID:")
|
||
self.stab_entry.configure(placeholder_text="예: my-project-12345", show="")
|
||
self.loc_label.grid()
|
||
self.loc_entry.grid()
|
||
elif "API Key" in engine_name:
|
||
self.stab_label.configure(text="Gemini API Key:")
|
||
self.stab_entry.configure(placeholder_text="aistudio.google.com에서 발급", show="*")
|
||
self.loc_label.grid_remove()
|
||
self.loc_entry.grid_remove()
|
||
else:
|
||
self.stab_label.configure(text="Stability API Key:")
|
||
self.stab_entry.configure(placeholder_text="platform.stability.ai에서 발급", show="*")
|
||
self.loc_label.grid_remove()
|
||
self.loc_entry.grid_remove()
|
||
|
||
# --- 재질 텍스처 합성 시스템 ---
|
||
def _composite_material_textures(self, satellite_img,
|
||
bbox_min_x=None, bbox_min_y=None,
|
||
bbox_max_x=None, bbox_max_y=None):
|
||
"""위성 이미지 위에 계획선 영역의 재질 텍스처를 합성.
|
||
|
||
도로 → 아스팔트 (진회색 + 미세 노이즈)
|
||
사면 → 토사 (연갈색)
|
||
굴착 → 적갈색 흙
|
||
성토 → 짙은 갈색
|
||
건물 → 콘크리트 (밝은 회색)
|
||
|
||
**중요**: 이 함수에 넘기는 bbox는 **satellite_img가 실제로 커버하는
|
||
투영 좌표 범위**와 일치해야 한다. 기본 5% 버퍼를 가정하고 하드코딩하면,
|
||
DEM 확장(예: 1000m 버퍼)으로 훨씬 넓게 다운로드된 이미지에 대해 도로·
|
||
사면이 안쪽에 압축되어 **확대된 것처럼** 그려진다.
|
||
"""
|
||
if not self.layer_geometries or not hasattr(self, 'projected_bounds'):
|
||
return satellite_img
|
||
|
||
img = satellite_img.copy().convert("RGBA")
|
||
img_w, img_h = img.size
|
||
|
||
pb = self.projected_bounds # [min_x, min_y, max_x, max_y]
|
||
# 인자가 주어지지 않은 경우 기본 5% 여유 (legacy 호출 호환)
|
||
if bbox_min_x is None:
|
||
bw_ = pb[2] - pb[0]
|
||
bh_ = pb[3] - pb[1]
|
||
bbox_min_x = pb[0] - bw_ * 0.05
|
||
bbox_max_x = pb[2] + bw_ * 0.05
|
||
bbox_min_y = pb[1] - bh_ * 0.05
|
||
bbox_max_y = pb[3] + bh_ * 0.05
|
||
|
||
# 재질 색상 맵
|
||
material_colors = {
|
||
"path_extrude": {
|
||
"road": (60, 60, 65, 220), # 아스팔트
|
||
"slope": (160, 130, 90, 180), # 토사 사면
|
||
},
|
||
"surface_overlay": {
|
||
"fill": (140, 110, 75, 160), # 굴착/성토 흙
|
||
},
|
||
"wall_extrude": {
|
||
"fill": (150, 150, 155, 200), # 콘크리트
|
||
},
|
||
"box_extrude": {
|
||
"fill": (180, 180, 185, 200), # 건물 콘크리트
|
||
},
|
||
}
|
||
|
||
def world_to_pixel(wx, wy):
|
||
"""원본 좌표계 → 이미지 픽셀 좌표"""
|
||
px = (wx - bbox_min_x) / (bbox_max_x - bbox_min_x) * img_w
|
||
# Y축 반전 (이미지 좌표는 위→아래, 지도 좌표는 아래→위)
|
||
py = (1.0 - (wy - bbox_min_y) / (bbox_max_y - bbox_min_y)) * img_h
|
||
return (int(px), int(py))
|
||
|
||
overlay = Image.new("RGBA", (img_w, img_h), (0, 0, 0, 0))
|
||
draw = ImageDraw.Draw(overlay)
|
||
|
||
drawn_count = 0
|
||
|
||
for layer_data in self.layer_geometries.values():
|
||
type_def = layer_data["type_def"]
|
||
render_mode = type_def.get("render_mode", "line_only")
|
||
width = type_def.get("width", 6.0)
|
||
slope_ratio = type_def.get("slope_ratio", 1.5)
|
||
|
||
colors = material_colors.get(render_mode)
|
||
if not colors:
|
||
continue
|
||
|
||
for geom in layer_data["geometries"]:
|
||
try:
|
||
if geom["type"] == "polyline" and len(geom["points"]) >= 2:
|
||
pts_world = geom["points"]
|
||
|
||
if render_mode == "path_extrude":
|
||
# 도로: 중심선 기준으로 도로면 + 사면 그리기
|
||
half_w = width / 2.0
|
||
slope_w = half_w * slope_ratio
|
||
total_w = half_w + slope_w
|
||
|
||
# 사면 (바깥쪽) 먼저 그리기
|
||
slope_pixels = self._offset_polyline_to_pixels(
|
||
pts_world, total_w, world_to_pixel)
|
||
if slope_pixels:
|
||
draw.polygon(slope_pixels, fill=colors["slope"])
|
||
|
||
# 도로면 (안쪽)
|
||
road_pixels = self._offset_polyline_to_pixels(
|
||
pts_world, half_w, world_to_pixel)
|
||
if road_pixels:
|
||
draw.polygon(road_pixels, fill=colors["road"])
|
||
|
||
drawn_count += 1
|
||
|
||
elif render_mode in ("surface_overlay", "wall_extrude", "box_extrude"):
|
||
if geom.get("closed", False) and len(pts_world) >= 3:
|
||
pixels = [world_to_pixel(p[0], p[1]) for p in pts_world]
|
||
draw.polygon(pixels, fill=colors["fill"])
|
||
drawn_count += 1
|
||
|
||
elif geom["type"] == "line":
|
||
if render_mode == "path_extrude":
|
||
pts_world = [geom["start"], geom["end"]]
|
||
half_w = width / 2.0
|
||
road_pixels = self._offset_polyline_to_pixels(
|
||
pts_world, half_w, world_to_pixel)
|
||
if road_pixels:
|
||
draw.polygon(road_pixels, fill=colors.get("road", (60, 60, 65, 220)))
|
||
drawn_count += 1
|
||
|
||
except Exception:
|
||
continue
|
||
|
||
if drawn_count > 0:
|
||
# 아스팔트 노이즈 추가 (도로면에 미세한 질감)
|
||
noise_layer = self._generate_asphalt_noise(img_w, img_h)
|
||
overlay = Image.alpha_composite(overlay, noise_layer)
|
||
|
||
# 약간 블러로 경계 자연스럽게
|
||
overlay = overlay.filter(ImageFilter.GaussianBlur(radius=1.5))
|
||
|
||
# 위성 이미지와 합성
|
||
result = Image.alpha_composite(img, overlay)
|
||
self.log(f" 재질 합성: {drawn_count}개 요소 적용됨")
|
||
return result.convert("RGB")
|
||
|
||
return satellite_img
|
||
|
||
def _offset_polyline_to_pixels(self, pts_world, half_width, world_to_pixel):
|
||
"""폴리라인을 양쪽으로 오프셋하여 폐합 폴리곤 픽셀 좌표 반환"""
|
||
import math
|
||
|
||
pts = np.array(pts_world, dtype=np.float64)
|
||
n = len(pts)
|
||
if n < 2:
|
||
return None
|
||
|
||
left_pts = []
|
||
right_pts = []
|
||
|
||
for i in range(n):
|
||
if i == 0:
|
||
dx, dy = pts[1][0] - pts[0][0], pts[1][1] - pts[0][1]
|
||
elif i == n - 1:
|
||
dx, dy = pts[-1][0] - pts[-2][0], pts[-1][1] - pts[-2][1]
|
||
else:
|
||
dx, dy = pts[i+1][0] - pts[i-1][0], pts[i+1][1] - pts[i-1][1]
|
||
|
||
length = max(math.sqrt(dx*dx + dy*dy), 1e-6)
|
||
nx, ny = -dy / length * half_width, dx / length * half_width
|
||
|
||
left_pts.append(world_to_pixel(pts[i][0] + nx, pts[i][1] + ny))
|
||
right_pts.append(world_to_pixel(pts[i][0] - nx, pts[i][1] - ny))
|
||
|
||
# 좌 → 우(역순) 으로 폐합 폴리곤
|
||
polygon = left_pts + right_pts[::-1]
|
||
|
||
# 유효한 폴리곤인지 확인
|
||
if len(polygon) < 3:
|
||
return None
|
||
|
||
return polygon
|
||
|
||
def _generate_asphalt_noise(self, width, height):
|
||
"""아스팔트 표면의 미세한 노이즈 패턴 생성"""
|
||
# 랜덤 노이즈로 아스팔트 질감
|
||
noise = np.random.randint(0, 15, (height, width), dtype=np.uint8)
|
||
noise_img = Image.fromarray(noise)
|
||
|
||
# RGBA로 변환 (매우 약한 흰색 노이즈)
|
||
noise_rgba = Image.new("RGBA", (width, height), (0, 0, 0, 0))
|
||
noise_data = np.array(noise_rgba)
|
||
noise_arr = np.array(noise_img)
|
||
|
||
# 노이즈를 알파 채널에만 적용 (밝은 반점)
|
||
noise_data[:, :, 0] = 200 # R
|
||
noise_data[:, :, 1] = 200 # G
|
||
noise_data[:, :, 2] = 200 # B
|
||
noise_data[:, :, 3] = noise_arr // 3 # 매우 약한 알파
|
||
|
||
return Image.fromarray(noise_data)
|
||
|
||
def _on_tile_source_changed(self, source_name):
|
||
"""타일 소스 변경 시 지도 뷰어 타일 서버도 갱신"""
|
||
template = self.tile_servers.get(source_name, "")
|
||
|
||
# Vworld: API 키 치환
|
||
if "vworld_key" in template:
|
||
vk = self.vworld_api_key.get()
|
||
if not vk:
|
||
messagebox.showinfo("Vworld API Key",
|
||
"Vworld 지도를 사용하려면 API Key가 필요합니다.\n"
|
||
"사이드바에 Vworld API Key를 입력해주세요.\n"
|
||
"(vworld.kr 에서 무료 가입 후 발급)")
|
||
return
|
||
template = template.replace("{vworld_key}", vk)
|
||
|
||
# tkintermapview용 URL (서브도메인 고정)
|
||
viewer_url = template.replace("{s}", "0")
|
||
self.map_view.set_tile_server(viewer_url)
|
||
self.log(f"타일 소스 변경: {source_name}")
|
||
|
||
# --- Step 1: TIN 생성 로직 ---
|
||
def btn_tin_callback(self):
|
||
file_path = filedialog.askopenfilename(
|
||
title="DXF 파일 선택 (등고선 + 계획도)",
|
||
filetypes=[("AutoCAD DXF", "*.dxf"), ("All Files", "*.*")]
|
||
)
|
||
if not file_path: return
|
||
|
||
self.dxf_path = file_path
|
||
self.log(f">>> [Step 1] DXF 로드: {os.path.basename(file_path)}")
|
||
self.set_status("DXF 분석 중...", "#F79E1B")
|
||
|
||
# 진단 로그 초기화 (세션 시작)
|
||
self._diag(f"=== Step 1 시작: {file_path} ===", reset=True)
|
||
|
||
try:
|
||
# DXF 문서 로드 및 저장
|
||
self.dxf_doc = ezdxf.readfile(file_path)
|
||
|
||
# 레이어 목록 로그
|
||
layer_names = sorted(set(l.dxf.name for l in self.dxf_doc.layers))
|
||
self.log(f" 레이어 {len(layer_names)}개 발견: {', '.join(layer_names[:10])}{'...' if len(layer_names) > 10 else ''}")
|
||
self._diag(f"DXF 레이어 총 {len(layer_names)}개: {layer_names}")
|
||
|
||
# 레이어 분류 팝업 (Phase 4)
|
||
self.log(" 레이어 분류 UI 표시 중...")
|
||
classified = self._open_layer_classifier()
|
||
|
||
if not classified:
|
||
self.log(" 레이어 분류 취소. 전체를 지형으로 처리합니다.")
|
||
self.layer_mapping = {ln: "terrain" for ln in layer_names}
|
||
self._diag("분류 취소됨 → 전체를 terrain으로 fallback")
|
||
|
||
# 지형 레이어만 필터링하여 TIN 생성
|
||
terrain_layers = [ln for ln, tid in self.layer_mapping.items() if tid == "terrain"]
|
||
self.log(f" 지형 레이어: {terrain_layers}")
|
||
|
||
self.tin_mesh = self.create_tin_from_dxf(file_path, terrain_layers)
|
||
# TIN 재생성 시 기존 확장/텍스처 캐시 무효화
|
||
self.total_mesh = None
|
||
self.tin_extension_mesh = None
|
||
self.tin_extension_textured = None
|
||
# core 선택도 리셋 — 새 TIN 에서는 좌표/측점 분포가 달라지므로 재선택 필요
|
||
self.tin_core_bbox = None
|
||
self._tin_core_original_points = None
|
||
|
||
if self.tin_mesh:
|
||
z_range = self.tin_mesh.bounds[5] - self.tin_mesh.bounds[4]
|
||
self.log(f"TIN 생성 완료. (정점: {self.tin_mesh.n_points:,}개, 편차: {z_range:.2f}m)")
|
||
self._diag(f"TIN: 정점 {self.tin_mesh.n_points}, Z편차 {z_range:.2f}m")
|
||
|
||
# 계획선 지오메트리 추출 (Phase 4)
|
||
n_plan = self._extract_layer_geometries()
|
||
total_geoms = sum(len(v["geometries"]) for v in self.layer_geometries.values()) if self.layer_geometries else 0
|
||
if n_plan:
|
||
self.log(f" 계획선 추출: {n_plan}개 레이어, {total_geoms}개 요소")
|
||
|
||
# 구조물 위치 레지스트리 구축 (항상 호출 — 진단 로그 완결성 확보)
|
||
self._populate_structure_registry()
|
||
|
||
# 사용자가 구조물 레이어를 지정했는데 레지스트리가 비어있으면 경고
|
||
structure_layers_selected = [
|
||
(ln, tid) for ln, tid in self.layer_mapping.items()
|
||
if tid not in ("terrain", "ignore")
|
||
]
|
||
if structure_layers_selected and not self.structure_registry:
|
||
reasons = []
|
||
for ln, tid in structure_layers_selected:
|
||
if ln not in self.layer_geometries:
|
||
reasons.append(f" • {ln} ({tid}): 지원 엔티티 없음 (빈 레이어 또는 TEXT/DIM만 존재)")
|
||
else:
|
||
rmode = self.structure_types.get(tid, {}).get("render_mode", "?")
|
||
reasons.append(f" • {ln} ({tid}, render_mode={rmode}): 지형변형으로 처리됨 (별도 상세빌드 대상 아님)")
|
||
msg = (
|
||
f"{len(structure_layers_selected)}개 구조물 레이어가 지정되었으나 "
|
||
f"'구조물 상세 3D 빌드' 대상이 0개입니다.\n\n" + "\n".join(reasons[:8]) +
|
||
f"\n\n상세 진단: {self.diag_log_path.resolve()}"
|
||
)
|
||
self._diag("⚠ 구조물 레이어 지정 있음 + 레지스트리 비어있음 → 사용자 경고 표시")
|
||
messagebox.showwarning("구조물 등록 경고", msg)
|
||
|
||
if n_plan:
|
||
# 고도 설정 팝업 → TIN 변형
|
||
self.log(" 계획선 고도 설정 중...")
|
||
if self._open_elevation_dialog():
|
||
self._deform_tin_for_plans()
|
||
|
||
self.update_map_view_to_mesh()
|
||
reg_n = len(self.structure_registry)
|
||
self.set_status(f"TIN 생성 완료 · 구조물 등록 {reg_n}개", "#22A06B")
|
||
self.btn_step2.configure(fg_color=["#3a7ebf", "#1f538d"], border_width=0)
|
||
|
||
self.show_3d_preview(textured=False)
|
||
except Exception as e:
|
||
self.log(f"오류: {e!s}")
|
||
self.set_status("오류 발생", "#EB001B")
|
||
messagebox.showerror("오류", f"TIN 생성 중 문제가 발생했습니다:\n{e}")
|
||
|
||
def create_tin_from_dxf(self, filepath, terrain_layers=None):
|
||
"""DXF에서 지형 레이어만 필터링하여 TIN 생성.
|
||
terrain_layers가 None이면 모든 레이어 사용 (이전 동작).
|
||
"""
|
||
doc = self.dxf_doc if self.dxf_doc else ezdxf.readfile(filepath)
|
||
msp = doc.modelspace()
|
||
points = []
|
||
entity_counts = {}
|
||
|
||
def _in_terrain(entity):
|
||
if terrain_layers is None:
|
||
return True
|
||
return entity.dxf.layer in terrain_layers
|
||
|
||
def _count(etype):
|
||
entity_counts[etype] = entity_counts.get(etype, 0) + 1
|
||
|
||
# 1. LWPOLYLINE
|
||
for entity in msp.query('LWPOLYLINE'):
|
||
if not _in_terrain(entity): continue
|
||
_count('LWPOLYLINE')
|
||
z = entity.dxf.elevation if entity.dxf.hasattr('elevation') else 0.0
|
||
points.extend([p[0], p[1], z] for p in entity.get_points())
|
||
# 2. LINE — _count side-effect로 inner extend
|
||
for entity in msp.query('LINE'):
|
||
if not _in_terrain(entity): continue
|
||
_count('LINE')
|
||
points.append([entity.dxf.start.x, entity.dxf.start.y, entity.dxf.start.z])
|
||
points.append([entity.dxf.end.x, entity.dxf.end.y, entity.dxf.end.z])
|
||
# 3. POLYLINE: 3D 폴리선일 경우 각 vertex의 Z값 반영
|
||
for entity in msp.query('POLYLINE'):
|
||
if not _in_terrain(entity): continue
|
||
_count('POLYLINE')
|
||
for vertex in entity.vertices:
|
||
loc = vertex.dxf.location
|
||
points.append([loc.x, loc.y, loc.z])
|
||
# 4. POINT
|
||
for entity in msp.query('POINT'):
|
||
if not _in_terrain(entity): continue
|
||
_count('POINT')
|
||
points.append([entity.dxf.location.x, entity.dxf.location.y, entity.dxf.location.z])
|
||
# 5. SPLINE (추가)
|
||
for entity in msp.query('SPLINE'):
|
||
if not _in_terrain(entity): continue
|
||
_count('SPLINE')
|
||
with contextlib.suppress(Exception):
|
||
points.extend([pt[0], pt[1], pt[2] if len(pt) > 2 else 0.0]
|
||
for pt in entity.control_points)
|
||
# 6. 3DFACE (추가)
|
||
for entity in msp.query('3DFACE'):
|
||
if not _in_terrain(entity): continue
|
||
_count('3DFACE')
|
||
for attr in ['vtx0', 'vtx1', 'vtx2', 'vtx3']:
|
||
try:
|
||
v = getattr(entity.dxf, attr)
|
||
points.append([v.x, v.y, v.z])
|
||
except Exception:
|
||
pass
|
||
|
||
# 디버그: 지형 레이어에서 발견된 엔티티 타입별 개수 로그
|
||
if terrain_layers:
|
||
self.log(f" 지형 레이어 엔티티: {entity_counts if entity_counts else '없음'}")
|
||
|
||
# 지형 레이어에 어떤 엔티티가 있는지 전수 조사
|
||
if not entity_counts:
|
||
all_types = {}
|
||
for entity in msp:
|
||
if entity.dxf.layer in terrain_layers:
|
||
et = entity.dxftype()
|
||
all_types[et] = all_types.get(et, 0) + 1
|
||
self.log(f" 지형 레이어 전체 엔티티 타입: {all_types if all_types else '엔티티 없음'}")
|
||
|
||
if all_types:
|
||
self.log(" ⚠ 지원되지 않는 엔티티 타입입니다. 다른 레이어를 '지형'으로 지정해보세요.")
|
||
# HATCH가 있으면 안내
|
||
if 'HATCH' in all_types:
|
||
self.log(f" ⚠ HATCH({all_types['HATCH']}개) 발견 — 등고선이 HATCH가 아닌 LWPOLYLINE/POLYLINE 레이어에 있을 수 있습니다.")
|
||
|
||
if not points:
|
||
# 상세 에러 메시지
|
||
if terrain_layers:
|
||
raise ValueError(
|
||
f"지형 레이어 {terrain_layers}에서 좌표 데이터를 찾지 못했습니다.\n"
|
||
f"발견된 엔티티 타입: {entity_counts}\n"
|
||
f"레이어 분류에서 등고선이 포함된 올바른 레이어를 '지형'으로 지정해 주세요.\n"
|
||
f"팁: 등고선은 보통 LWPOLYLINE 또는 POLYLINE 타입입니다."
|
||
)
|
||
raise ValueError("DXF에서 좌표 데이터를 찾지 못했습니다.")
|
||
|
||
pts = np.array(points)
|
||
pts = np.unique(pts, axis=0)
|
||
|
||
# XY 중복 정리 — 같은 (X,Y)에 Z가 여러 개면 **최저 Z만 유지**.
|
||
# 원인: 등고선(LWPOLYLINE) + 스포트/3D 폴리선이 동일 지점에 서로 다른 Z로 찍히거나
|
||
# 도로 계획면과 원지형 등고선이 같은 XY에 겹쳐 있음. Delaunay가 어떤 Z를 택하든
|
||
# 인접 삼각형과 Z 점프가 발생해 **수직 벽**으로 렌더됨(특히 위성 텍스처 합성 후).
|
||
# 최저 Z를 택하는 이유: 원지형(지면) 우선 + 계획고가 그 위에 쌓이는 구조이므로
|
||
# 바닥 면을 유지해야 벽이 안 생김. 필요하면 계획고는 structure_registry/overlay로 따로 반영.
|
||
try:
|
||
# 6자리 반올림으로 수치 오차를 흡수한 key 생성
|
||
keys_xy = np.round(pts[:, :2], 3)
|
||
# lexsort로 같은 XY 묶음 안에서 Z 오름차순 정렬 → 각 그룹 첫 번째가 최저 Z
|
||
order = np.lexsort((pts[:, 2], keys_xy[:, 1], keys_xy[:, 0]))
|
||
pts_sorted = pts[order]
|
||
keys_sorted = keys_xy[order]
|
||
# 같은 XY 그룹의 첫 행만 유지
|
||
diff_mask = np.ones(len(pts_sorted), dtype=bool)
|
||
diff_mask[1:] = np.any(keys_sorted[1:] != keys_sorted[:-1], axis=1)
|
||
pts_dedup = pts_sorted[diff_mask]
|
||
n_collapsed = len(pts) - len(pts_dedup)
|
||
if n_collapsed > 0:
|
||
self.log(f" 동일 XY 중복 점 {n_collapsed}개 통합 (최저 Z 유지) — "
|
||
f"Z 불일치로 생기던 수직 벽 제거")
|
||
pts = pts_dedup
|
||
except Exception as _de:
|
||
self.log(f" XY 중복 정리 경고: {_de}")
|
||
|
||
# 원본 좌표계 바운딩 박스 계산 및 저장
|
||
self.projected_bounds = [np.min(pts[:, 0]), np.min(pts[:, 1]), np.max(pts[:, 0]), np.max(pts[:, 1])]
|
||
|
||
# ========================================================================
|
||
# [TIN 삼각망 조밀화 — Phase A: 경계 gap densify, Phase B: 내부 긴 edge densify]
|
||
# 측점(측량)은 정확하니 원본 100% 보존. DEM은 보조적으로만 사용:
|
||
# - Phase A: bbox 4변에서 인접 측점 gap > 50m → 그 사이에 DEM 샘플 점 추가.
|
||
# - Phase B: 임시 Delaunay 수행 → edge_max > 50m 삼각형 중심에 DEM 샘플 추가.
|
||
# 결과: 삼각망 간격이 50m 이내로 조밀 → 경계 sliver·내부 긴 edge로 인한
|
||
# 수직 벽이 시각적으로 소멸 + DEM 확장 시 seam도 매끄러움.
|
||
# ========================================================================
|
||
try:
|
||
if DEM_EXTENDER_AVAILABLE:
|
||
_gap_thresh = 50.0 # Phase A: 경계 gap 기준
|
||
_long_edge_thresh = 50.0 # Phase B: 내부 edge 기준
|
||
x0_abs = float(self.projected_bounds[0]); x1_abs = float(self.projected_bounds[2])
|
||
y0_abs = float(self.projected_bounds[1]); y1_abs = float(self.projected_bounds[3])
|
||
bbox_tol_abs = max(x1_abs - x0_abs, y1_abs - y0_abs) * 1e-4 + 1e-3
|
||
|
||
# DEM 타일/변환기를 Phase A·B 공통으로 1회 준비 (cache 재사용)
|
||
src_crs = self.crs_option.get()
|
||
to_wgs = pyproj.Transformer.from_crs(src_crs, "EPSG:4326", always_xy=True)
|
||
margin = 100.0
|
||
cx_arr = np.array([x0_abs - margin, x1_abs + margin, x0_abs - margin, x1_abs + margin])
|
||
cy_arr = np.array([y0_abs - margin, y0_abs - margin, y1_abs + margin, y1_abs + margin])
|
||
cx_lon, cx_lat = to_wgs.transform(cx_arr, cy_arr)
|
||
min_lat, max_lat = float(np.min(cx_lat)), float(np.max(cx_lat))
|
||
min_lon, max_lon = float(np.min(cx_lon)), float(np.max(cx_lon))
|
||
elev_grid, grid_bounds = fetch_terrarium_grid(
|
||
min_lat, min_lon, max_lat, max_lon,
|
||
zoom=13, cache_dir=str(cache_dir("dem")), log_fn=self.log,
|
||
)
|
||
# vertical datum offset: 측점 전체 XY의 DEM vs TIN 차이 median
|
||
s_lons, s_lats = to_wgs.transform(pts[:, 0], pts[:, 1])
|
||
s_dem_z = _sample_grid_bilinear(
|
||
elev_grid, grid_bounds,
|
||
np.asarray(s_lats), np.asarray(s_lons))
|
||
fin = np.isfinite(s_dem_z)
|
||
offset_v = float(np.median(s_dem_z[fin] - pts[fin, 2])) if fin.any() else 0.0
|
||
# **통일 offset** — 이후 _fill_tin_bbox_gap_with_dem / build_extended_terrain_ring
|
||
# 이 값을 그대로 재사용해 bbox 내/외 DEM Z를 **동일 datum**으로 정렬.
|
||
# 서로 다른 offset 을 3번 계산하면 bbox 에서 Z 단차가 발생해 네모박스 크리스
|
||
# 가 보임. 통일하면 seam 이 자연스럽게 이어짐.
|
||
self._dem_datum_offset = offset_v
|
||
self._dem_elev_grid = elev_grid
|
||
self._dem_grid_bounds = grid_bounds
|
||
self.log(f" [TIN densify] DEM datum offset={offset_v:+.2f}m "
|
||
f"(self._dem_datum_offset에 저장 → Step 1.5에서 재사용)")
|
||
# 좌표계 진단 — 사용자 검증용
|
||
try:
|
||
_mid_x = 0.5 * (x0_abs + x1_abs); _mid_y = 0.5 * (y0_abs + y1_abs)
|
||
_to_p = pyproj.Transformer.from_crs("EPSG:4326", src_crs, always_xy=True)
|
||
_lon_m, _lat_m = to_wgs.transform(_mid_x, _mid_y)
|
||
_rx, _ry = _to_p.transform(_lon_m, _lat_m)
|
||
_rt = float(((_rx - _mid_x) ** 2 + (_ry - _mid_y) ** 2) ** 0.5)
|
||
self.log(f" [CRS 검증] DXF={src_crs} → WGS84(DEM 샘플) → {src_crs}. "
|
||
f"per-point 점변환이라 **XY 평면 warp/resample 왜곡 없음**. "
|
||
f"왕복 오차 {_rt:.4f}m (0에 가까움 = 변환 정확).")
|
||
except Exception:
|
||
pass
|
||
|
||
def _dem_sample_minus_offset(xy_abs):
|
||
_lons, _lats = to_wgs.transform(xy_abs[:, 0], xy_abs[:, 1])
|
||
_z = _sample_grid_bilinear(
|
||
elev_grid, grid_bounds,
|
||
np.asarray(_lats), np.asarray(_lons))
|
||
if np.any(np.isnan(_z)):
|
||
_m = float(np.nanmedian(_z))
|
||
_z = np.where(np.isnan(_z), _m, _z)
|
||
return _z - offset_v
|
||
|
||
# --- Phase A: bbox 4변 gap densify ---
|
||
edges_spec = [
|
||
('bottom', np.abs(pts[:, 1] - y0_abs) < bbox_tol_abs, 0, 1, y0_abs),
|
||
('top', np.abs(pts[:, 1] - y1_abs) < bbox_tol_abs, 0, 1, y1_abs),
|
||
('left', np.abs(pts[:, 0] - x0_abs) < bbox_tol_abs, 1, 0, x0_abs),
|
||
('right', np.abs(pts[:, 0] - x1_abs) < bbox_tol_abs, 1, 0, x1_abs),
|
||
]
|
||
new_xy_list = []
|
||
for label, emask, sort_col, fixed_col, fixed_val in edges_spec:
|
||
n_on = int(emask.sum())
|
||
if n_on >= 2:
|
||
idx = np.where(emask)[0]
|
||
order = np.argsort(pts[idx, sort_col])
|
||
main_coord = pts[idx[order], sort_col]
|
||
gaps = np.diff(main_coord)
|
||
for k, gap in enumerate(gaps):
|
||
if gap > _gap_thresh:
|
||
n_add = int(np.ceil(gap / _gap_thresh)) - 1
|
||
mid_vals = np.linspace(main_coord[k], main_coord[k+1], n_add + 2)[1:-1]
|
||
for mv in mid_vals:
|
||
pt2 = [0.0, 0.0]
|
||
pt2[sort_col] = float(mv)
|
||
pt2[fixed_col] = float(fixed_val)
|
||
new_xy_list.append(pt2)
|
||
else:
|
||
span = (x1_abs - x0_abs) if sort_col == 0 else (y1_abs - y0_abs)
|
||
if span > _gap_thresh:
|
||
n_add = int(np.ceil(span / _gap_thresh)) - 1
|
||
base0 = x0_abs if sort_col == 0 else y0_abs
|
||
base1 = x1_abs if sort_col == 0 else y1_abs
|
||
mid_vals = np.linspace(base0, base1, n_add + 2)[1:-1]
|
||
for mv in mid_vals:
|
||
pt2 = [0.0, 0.0]
|
||
pt2[sort_col] = float(mv)
|
||
pt2[fixed_col] = float(fixed_val)
|
||
new_xy_list.append(pt2)
|
||
|
||
n_phase_a = len(new_xy_list)
|
||
if n_phase_a > 0:
|
||
new_xy = np.asarray(new_xy_list, dtype=np.float64)
|
||
new_z = _dem_sample_minus_offset(new_xy)
|
||
pts = np.vstack([pts, np.column_stack([new_xy, new_z])])
|
||
self.log(
|
||
f" [Phase A] bbox gap densify: {n_phase_a}개 추가 "
|
||
f"(간격>{_gap_thresh:.0f}m)")
|
||
else:
|
||
self.log(f" [Phase A] bbox gap densify: skip (모두 ≤ {_gap_thresh:.0f}m)")
|
||
|
||
# --- Phase C: hull 바깥 × bbox 내부 grid densify (10m → 1m 점진) ---
|
||
# 10m 격자부터 시작, 각 step마다 현재 hull을 재계산해 아직 비어있는
|
||
# 구간에 격자점 추가. bbox가 좁거나 잔여 빈 영역이 10m 격자로 안
|
||
# 메워지면 step을 1m씩 줄여(9m, 8m, …, 1m) 재시도. 목표: bbox 꽉 채움.
|
||
try:
|
||
from scipy.spatial import ConvexHull as _ConvexHull, cKDTree as _cKDTreeC
|
||
from matplotlib.path import Path as _MplPath
|
||
total_phase_c = 0
|
||
steps_log = []
|
||
with perf_block("TIN densify Phase C (10m→1m)"):
|
||
for _step in (10.0, 9.0, 8.0, 7.0, 6.0, 5.0, 4.0, 3.0, 2.0, 1.0):
|
||
try:
|
||
hull_c = _ConvexHull(pts[:, :2])
|
||
except Exception:
|
||
break
|
||
hull_poly_xy = pts[hull_c.vertices, :2]
|
||
hull_path_c = _MplPath(hull_poly_xy, closed=True)
|
||
gx = np.arange(x0_abs, x1_abs + _step * 0.5, _step)
|
||
gy = np.arange(y0_abs, y1_abs + _step * 0.5, _step)
|
||
ggx, ggy = np.meshgrid(gx, gy)
|
||
grid_xy_c = np.column_stack([ggx.ravel(), ggy.ravel()])
|
||
inside_bbox = (
|
||
(grid_xy_c[:, 0] >= x0_abs - 1e-6)
|
||
& (grid_xy_c[:, 0] <= x1_abs + 1e-6)
|
||
& (grid_xy_c[:, 1] >= y0_abs - 1e-6)
|
||
& (grid_xy_c[:, 1] <= y1_abs + 1e-6)
|
||
)
|
||
grid_xy_c = grid_xy_c[inside_bbox]
|
||
if len(grid_xy_c) == 0:
|
||
continue
|
||
inside_hull = hull_path_c.contains_points(grid_xy_c)
|
||
outside_hull_xy = grid_xy_c[~inside_hull]
|
||
if len(outside_hull_xy) == 0:
|
||
continue
|
||
# 기존 점과 너무 가까운 격자점(≤ step×0.4) 제외 — 중복 방지
|
||
tree_ex = _cKDTreeC(pts[:, :2])
|
||
d_ex, _ = tree_ex.query(outside_hull_xy, k=1)
|
||
new_only_xy = outside_hull_xy[d_ex > _step * 0.4]
|
||
if len(new_only_xy) == 0:
|
||
continue
|
||
new_z_c = _dem_sample_minus_offset(new_only_xy)
|
||
pts = np.vstack([pts, np.column_stack([new_only_xy, new_z_c])])
|
||
total_phase_c += len(new_only_xy)
|
||
steps_log.append(f"{_step:.0f}m:{len(new_only_xy)}")
|
||
if total_phase_c > 0:
|
||
self.log(
|
||
f" [Phase C] hull 바깥 × bbox 내부 점진 densify: "
|
||
f"총 {total_phase_c}개 추가 [{', '.join(steps_log)}] — bbox 꽉 채움")
|
||
else:
|
||
self.log(" [Phase C] hull이 이미 bbox 덮음 — skip")
|
||
except Exception as _ce:
|
||
self.log(f" [Phase C] 경고: {_ce}")
|
||
|
||
# --- Phase B: 내부 긴 edge densify (임시 Delaunay → centroid 추가) ---
|
||
tri_tmp = Delaunay(pts[:, :2])
|
||
sx_tmp = tri_tmp.simplices
|
||
p0t = pts[sx_tmp[:, 0], :2]
|
||
p1t = pts[sx_tmp[:, 1], :2]
|
||
p2t = pts[sx_tmp[:, 2], :2]
|
||
e_max_tmp = np.maximum(
|
||
np.maximum(np.linalg.norm(p0t - p1t, axis=1),
|
||
np.linalg.norm(p1t - p2t, axis=1)),
|
||
np.linalg.norm(p2t - p0t, axis=1))
|
||
long_mask = e_max_tmp > _long_edge_thresh
|
||
n_phase_b = int(long_mask.sum())
|
||
if n_phase_b > 0:
|
||
cc_xy = (p0t[long_mask] + p1t[long_mask] + p2t[long_mask]) / 3.0
|
||
cc_z = _dem_sample_minus_offset(cc_xy)
|
||
pts = np.vstack([pts, np.column_stack([cc_xy, cc_z])])
|
||
self.log(
|
||
f" [Phase B] 내부 긴 edge densify: {n_phase_b}개 삼각형 중심 추가 "
|
||
f"(edge>{_long_edge_thresh:.0f}m) — 삼각망 조밀화")
|
||
else:
|
||
self.log(" [Phase B] 긴 edge 없음 — skip")
|
||
except Exception as _gde:
|
||
self.log(f" TIN DEM densify 경고: {_gde}")
|
||
|
||
# Zero-Basing (시각화용) — densify로 추가된 점도 포함
|
||
self.origin = np.min(pts, axis=0)
|
||
pts -= self.origin
|
||
|
||
self.log(f"원점 보정: {self.origin}")
|
||
|
||
# ========================================================================
|
||
# [bbox 4변 종잇장 처리 — 제거됨 (2026-04-23)]
|
||
# 이전: 변 정점 Z를 win=5 moving-avg 3pass로 평활 + 30m 안쪽 feather 블렌드.
|
||
# 문제: 평활된 변 Z vs 30m 안쪽 자연 DEM Z의 경계에 얇은 **30m 네모박스**
|
||
# 크리스(crease) 가 시각적으로 남음 (error.png). 그리고 Step 1.5의
|
||
# _reinterpolate_tin_boundary_with_dem이 또 다른 feather_m(~200m)에서
|
||
# Laplacian 평활을 하므로, 두 평활 링이 서로 다른 거리에서 내부 박스
|
||
# 처럼 보임.
|
||
# 해결: 여기서 인위적 평활을 하지 않는다. Phase A가 이미 bbox 변에 DEM
|
||
# 샘플 점을 50m 간격으로 채웠고, Phase C가 1m까지 bbox 내부를 DEM으로
|
||
# 채우므로 변/내부 Z 모두 **동일한 DEM 기준**이라 자연스럽게 이어짐.
|
||
# 수직 벽 방지는 뒤의 v6 slope_ratio 컷이 담당.
|
||
# ========================================================================
|
||
self.log(" TIN bbox 종잇장 평활 skip — Phase A/C의 DEM 일관성으로 자연 이어짐")
|
||
|
||
tri = Delaunay(pts[:, :2])
|
||
simplices = tri.simplices
|
||
|
||
# ========================================================================
|
||
# [경계 벽 컷 v6] — slope_ratio 기반. 절대 edge 기준 v5 폐기.
|
||
# 사용자 요구(2026-04-23): 처음 TIN 만들 때부터 bbox 내부에 구멍이
|
||
# **없어야 함**, 동시에 벽도 없어야 함, 4변까지 꽉 찬 TIN이 되어야 함.
|
||
#
|
||
# v5 문제: `edge > median × 4.0` 절대 기준으로 컷 → 정상 산지 경사면
|
||
# (z_span 크지만 xy_edge도 커서 slope_ratio 작음)이 함께 삭제돼
|
||
# bbox 내부에 듬성듬성 구멍이 났다 (error.png).
|
||
#
|
||
# memory/feedback_wall_root_cause.md Rule 2 준수:
|
||
# "벽 판정은 slope_ratio = z_span / max_xy_edge 기준으로만.
|
||
# 절대 Z/edge 기준은 정상 경사면까지 지워 구멍을 낸다."
|
||
#
|
||
# v6 방침:
|
||
# 1. Phase A(50m bbox edge) + Phase C(10→1m grid) + Phase B 완료 후
|
||
# convex hull ≈ bbox 상태 → Delaunay가 bbox 4변까지 꽉 참.
|
||
# 2. 이 상태에서 컷 기준은 **진짜 벽만** 제거:
|
||
# `slope_ratio = z_span / max_xy_edge > 1.5` (≈56°)
|
||
# AND `z_span > 5m`
|
||
# 3. bbox 4변 접촉 삼각형에만 적용. 내부는 불가침.
|
||
# 4. 결과: 내부 구멍 없음 + 경계 수직 벽만 제거 + 4변 꽉 참.
|
||
# ========================================================================
|
||
x_min_z = 0.0; y_min_z = 0.0
|
||
x_max_z = float(pts[:, 0].max()) if len(pts) else 0.0
|
||
y_max_z = float(pts[:, 1].max()) if len(pts) else 0.0
|
||
bbox_tol = max(x_max_z, y_max_z) * 1e-4 + 1e-3
|
||
|
||
def _touches_bbox_vid(vid_array):
|
||
xs = pts[vid_array, 0]
|
||
ys = pts[vid_array, 1]
|
||
return (
|
||
(np.abs(xs - x_min_z) < bbox_tol)
|
||
| (np.abs(xs - x_max_z) < bbox_tol)
|
||
| (np.abs(ys - y_min_z) < bbox_tol)
|
||
| (np.abs(ys - y_max_z) < bbox_tol)
|
||
)
|
||
|
||
if len(simplices) > 0:
|
||
p0 = pts[simplices[:, 0], :2]
|
||
p1 = pts[simplices[:, 1], :2]
|
||
p2 = pts[simplices[:, 2], :2]
|
||
e_max = np.maximum(
|
||
np.maximum(np.linalg.norm(p0 - p1, axis=1),
|
||
np.linalg.norm(p1 - p2, axis=1)),
|
||
np.linalg.norm(p2 - p0, axis=1))
|
||
z0 = pts[simplices[:, 0], 2]
|
||
z1 = pts[simplices[:, 1], 2]
|
||
z2 = pts[simplices[:, 2], 2]
|
||
z_span = np.maximum(np.maximum(z0, z1), z2) - np.minimum(np.minimum(z0, z1), z2)
|
||
slope_ratio = z_span / np.maximum(e_max, 1e-6)
|
||
|
||
touches = (_touches_bbox_vid(simplices[:, 0])
|
||
| _touches_bbox_vid(simplices[:, 1])
|
||
| _touches_bbox_vid(simplices[:, 2]))
|
||
drop = touches & (slope_ratio > 1.5) & (z_span > 5.0)
|
||
n_drop = int(drop.sum())
|
||
if n_drop > 0:
|
||
simplices = simplices[~drop]
|
||
self.log(
|
||
f" TIN 경계 벽 컷 v6: {n_drop}개 제거 "
|
||
f"(bbox 4변 접촉 + slope_ratio>1.5(≈56°) + z_span>5m) "
|
||
f"— 정상 경사면 100% 보존, 수직 벽만 제거")
|
||
else:
|
||
self.log(
|
||
" TIN 경계 벽 컷 v6: 제거 대상 없음 "
|
||
"(4변 모든 접촉 삼각형 slope_ratio≤1.5 또는 z_span≤5m) "
|
||
"— bbox 4변까지 꽉 찬 TIN")
|
||
|
||
faces = np.column_stack([np.full(len(simplices), 3), simplices])
|
||
mesh = pv.PolyData(pts, faces)
|
||
mesh["Elevation"] = pts[:, 2]
|
||
self._tin_interpolator = None
|
||
return mesh
|
||
|
||
def update_map_view_to_mesh(self):
|
||
"""TIN 메쉬의 원래 좌표를 위경도로 변환하여 지도 중심 이동"""
|
||
try:
|
||
src_crs = self.crs_option.get()
|
||
transformer = pyproj.Transformer.from_crs(src_crs, "EPSG:4326", always_xy=True)
|
||
|
||
# 중심점 계산
|
||
cx = (self.projected_bounds[0] + self.projected_bounds[2]) / 2
|
||
cy = (self.projected_bounds[1] + self.projected_bounds[3]) / 2
|
||
|
||
lon, lat = transformer.transform(cx, cy)
|
||
self.map_view.set_position(lat, lon)
|
||
self.map_view.set_zoom(15)
|
||
self.log(f"지도 위치 동기화: Lat {lat:.5f}, Lon {lon:.5f}")
|
||
except Exception as e:
|
||
self.log(f"지도 업데이트 실패: {e}")
|
||
|
||
# --- [TIN 이용 범위] 정밀 TIN 구역 선택 ---
|
||
def btn_select_core_range_callback(self):
|
||
"""Toplevel 탑뷰 창에서 좌상단·우하단 2클릭 또는 드래그박스로
|
||
**정밀 TIN 구역(core bbox)** 을 선택. 선택 후 Step 1.5 가 3-zone
|
||
(core 100% TIN / transition smoothstep / 바깥 DEM) 로 동작.
|
||
|
||
사용 이유: 도면 전체가 아니라 **사용자가 정확히 필요한 일부 영역만**
|
||
원본 측점 그대로 유지하고, 나머지는 DEM 으로 부드럽게 확장해 경계/외곽의
|
||
측점 희박 구간에서 생기던 튀는 Z·fin 을 원천 차단.
|
||
"""
|
||
if self.tin_mesh is None or self.projected_bounds is None:
|
||
messagebox.showwarning("주의", "먼저 Step 1 (TIN 생성) 을 실행하세요.")
|
||
return
|
||
try:
|
||
import tkinter as _tk
|
||
from matplotlib.figure import Figure
|
||
from matplotlib.backends.backend_tkagg import (
|
||
FigureCanvasTkAgg, NavigationToolbar2Tk)
|
||
from matplotlib.patches import Rectangle as _MplRect
|
||
except ImportError as _e:
|
||
messagebox.showerror("오류", f"UI 모듈 로드 실패: {_e}")
|
||
return
|
||
|
||
origin = np.asarray(self.origin, dtype=np.float64)
|
||
pts_zero = np.asarray(self.tin_mesh.points, dtype=np.float64)
|
||
pts_abs = pts_zero + origin
|
||
x0p, y0p, x1p, y1p = [float(v) for v in self.projected_bounds]
|
||
|
||
win = InlinePanel(self)
|
||
win.title("🎯 TIN 이용 범위 선택")
|
||
win.geometry("1100x920")
|
||
win.minsize(900, 640)
|
||
win.transient(self)
|
||
win.grab_set()
|
||
|
||
info_frame = ctk.CTkFrame(win)
|
||
info_frame.pack(side="top", fill="x", padx=10, pady=6)
|
||
instr_lbl = ctk.CTkLabel(
|
||
info_frame,
|
||
text="🖱 마우스 좌클릭 드래그로 정밀 TIN 사각형을 그리세요 (놓으면 확정).",
|
||
font=ctk.CTkFont(size=14, weight="bold"))
|
||
instr_lbl.pack(side="top", anchor="w", padx=6, pady=(4, 2))
|
||
stat_lbl = ctk.CTkLabel(info_frame, text="선택 없음", font=ctk.CTkFont(size=12))
|
||
stat_lbl.pack(side="top", anchor="w", padx=6, pady=(0, 4))
|
||
|
||
# blend_width 슬라이더
|
||
slide_frame = ctk.CTkFrame(win)
|
||
slide_frame.pack(side="top", fill="x", padx=10, pady=(0, 6))
|
||
ctk.CTkLabel(slide_frame, text="전이대 폭(blend_width_m):").pack(side="left", padx=6)
|
||
blend_var = _tk.DoubleVar(value=float(self.tin_blend_width_m))
|
||
blend_val_lbl = ctk.CTkLabel(slide_frame, text=f"{blend_var.get():.0f} m",
|
||
width=60)
|
||
blend_val_lbl.pack(side="left", padx=6)
|
||
def _on_blend_change(v):
|
||
blend_val_lbl.configure(text=f"{float(v):.0f} m")
|
||
_redraw_blend()
|
||
blend_slider = ctk.CTkSlider(
|
||
slide_frame, from_=20, to=300, number_of_steps=28,
|
||
variable=blend_var, command=_on_blend_change)
|
||
blend_slider.pack(side="left", fill="x", expand=True, padx=6)
|
||
|
||
fig = Figure(figsize=(10, 7), dpi=100)
|
||
ax = fig.add_subplot(111)
|
||
ax.set_aspect("equal")
|
||
sample = pts_abs if len(pts_abs) <= 30000 else pts_abs[
|
||
np.random.RandomState(0).choice(len(pts_abs), 30000, replace=False)]
|
||
sc = ax.scatter(sample[:, 0], sample[:, 1], c=sample[:, 2],
|
||
cmap=_TIN_EARTH_CMAP, s=2, alpha=0.85)
|
||
fig.colorbar(sc, ax=ax, label="Elevation (m)")
|
||
# 도면 bbox 표시
|
||
ax.add_patch(_MplRect((x0p, y0p), x1p - x0p, y1p - y0p,
|
||
fill=False, edgecolor="black", linewidth=1.2,
|
||
linestyle="--", label="도면 bbox"))
|
||
ax.set_xlabel("X (EPSG:5187 m)")
|
||
ax.set_ylabel("Y (EPSG:5187 m)")
|
||
ax.set_title("TIN elevation — 마우스 좌클릭 드래그로 사각형 영역 선택")
|
||
|
||
# 하단 버튼 프레임을 캔버스보다 먼저 pack — 창이 좁아도 제출 버튼이 가려지지 않음.
|
||
# pack 순서가 뒤쪽일수록 canvas(expand=True)가 먼저 공간을 잡아 하단이 잘려 보임.
|
||
submit_frame = ctk.CTkFrame(win, fg_color="#0b1b10")
|
||
submit_frame.pack(side="bottom", fill="x", padx=0, pady=0)
|
||
btn_row = ctk.CTkFrame(win)
|
||
btn_row.pack(side="bottom", fill="x", padx=10, pady=(6, 2))
|
||
|
||
canvas = FigureCanvasTkAgg(fig, master=win)
|
||
canvas.draw()
|
||
# 툴바는 btn_row 바로 위(side=bottom, pack_toolbar=False 로 수동 배치)
|
||
tb = NavigationToolbar2Tk(canvas, win, pack_toolbar=False)
|
||
tb.update()
|
||
tb.pack(side="bottom", fill="x")
|
||
# 캔버스는 마지막에 — 남은 중앙 공간을 expand=True 로 채움
|
||
canvas.get_tk_widget().pack(side="top", fill="both", expand=True, padx=10, pady=(0, 4))
|
||
|
||
# 선택 상태
|
||
state = {"core_rect": None, "blend_rect": None, "bbox": None}
|
||
if self.tin_core_bbox is not None:
|
||
state["bbox"] = tuple(self.tin_core_bbox)
|
||
|
||
def _redraw_blend():
|
||
if state["blend_rect"] is not None:
|
||
state["blend_rect"].remove()
|
||
state["blend_rect"] = None
|
||
if state["bbox"] is None:
|
||
canvas.draw_idle()
|
||
return
|
||
bw = float(blend_var.get())
|
||
bx0, by0, bx1, by1 = state["bbox"]
|
||
state["blend_rect"] = _MplRect(
|
||
(bx0 - bw, by0 - bw), (bx1 - bx0) + 2 * bw, (by1 - by0) + 2 * bw,
|
||
fill=False, edgecolor="#FFA726", linewidth=1.2, linestyle=":",
|
||
label=f"전이대 +{bw:.0f}m")
|
||
ax.add_patch(state["blend_rect"])
|
||
canvas.draw_idle()
|
||
|
||
def _draw_bbox():
|
||
if state["core_rect"] is not None:
|
||
state["core_rect"].remove()
|
||
state["core_rect"] = None
|
||
if state["bbox"] is None:
|
||
return
|
||
bx0, by0, bx1, by1 = state["bbox"]
|
||
state["core_rect"] = _MplRect(
|
||
(bx0, by0), bx1 - bx0, by1 - by0,
|
||
fill=False, edgecolor="#EB001B", linewidth=2.2, label="정밀 TIN core")
|
||
ax.add_patch(state["core_rect"])
|
||
# 통계 갱신
|
||
in_core = ((pts_abs[:, 0] >= bx0) & (pts_abs[:, 0] <= bx1)
|
||
& (pts_abs[:, 1] >= by0) & (pts_abs[:, 1] <= by1))
|
||
n_core = int(in_core.sum())
|
||
cw, ch = bx1 - bx0, by1 - by0
|
||
if n_core > 0:
|
||
zc = pts_abs[in_core, 2]
|
||
stat_lbl.configure(text=(
|
||
f"Core bbox: X=[{bx0:.1f}, {bx1:.1f}] Y=[{by0:.1f}, {by1:.1f}] "
|
||
f"({cw:.0f}×{ch:.0f}m = {cw*ch/1e6:.3f} km²) "
|
||
f"측점 {n_core:,}개 Z={zc.min():.1f}~{zc.max():.1f}m"))
|
||
else:
|
||
stat_lbl.configure(text="⚠ 선택 영역에 측점이 없습니다 — 다시 선택하세요.")
|
||
_redraw_blend()
|
||
canvas.draw_idle()
|
||
|
||
# --- 실시간 드래그 핸들러 (press → motion → release) -------------
|
||
drag = {"active": False, "start": None, "live_rect": None}
|
||
|
||
def _clear_live():
|
||
if drag["live_rect"] is not None:
|
||
with contextlib.suppress(Exception):
|
||
drag["live_rect"].remove()
|
||
drag["live_rect"] = None
|
||
|
||
def _on_press(event):
|
||
if event.inaxes != ax or event.button != 1:
|
||
return
|
||
if tb.mode: # pan/zoom 모드면 무시
|
||
return
|
||
drag["active"] = True
|
||
drag["start"] = (float(event.xdata), float(event.ydata))
|
||
_clear_live()
|
||
# 기존 확정 사각형도 제거 (새로 그리는 중)
|
||
if state["core_rect"] is not None:
|
||
state["core_rect"].remove()
|
||
state["core_rect"] = None
|
||
if state["blend_rect"] is not None:
|
||
state["blend_rect"].remove()
|
||
state["blend_rect"] = None
|
||
state["bbox"] = None
|
||
stat_lbl.configure(text="드래그 중…")
|
||
canvas.draw_idle()
|
||
|
||
def _on_motion(event):
|
||
if not drag["active"] or event.inaxes != ax:
|
||
return
|
||
if event.xdata is None or event.ydata is None:
|
||
return
|
||
xa, ya = drag["start"]
|
||
xb, yb = float(event.xdata), float(event.ydata)
|
||
bx0, by0 = min(xa, xb), min(ya, yb)
|
||
bx1, by1 = max(xa, xb), max(ya, yb)
|
||
_clear_live()
|
||
drag["live_rect"] = _MplRect(
|
||
(bx0, by0), bx1 - bx0, by1 - by0,
|
||
fill=True, facecolor="#EB001B", alpha=0.18,
|
||
edgecolor="#EB001B", linewidth=2.0)
|
||
ax.add_patch(drag["live_rect"])
|
||
stat_lbl.configure(text=(
|
||
f"드래그 중 {bx1-bx0:.0f}×{by1-by0:.0f} m "
|
||
f"X=[{bx0:.1f}, {bx1:.1f}] Y=[{by0:.1f}, {by1:.1f}]"))
|
||
canvas.draw_idle()
|
||
|
||
def _on_release(event):
|
||
if not drag["active"]:
|
||
return
|
||
drag["active"] = False
|
||
if event.inaxes != ax or event.xdata is None or event.ydata is None:
|
||
_clear_live()
|
||
stat_lbl.configure(text="드래그가 취소되었습니다. 다시 시도하세요.")
|
||
canvas.draw_idle()
|
||
return
|
||
xa, ya = drag["start"]
|
||
xb, yb = float(event.xdata), float(event.ydata)
|
||
bx0, by0 = min(xa, xb), min(ya, yb)
|
||
bx1, by1 = max(xa, xb), max(ya, yb)
|
||
# 너무 작은 클릭-수준 드래그는 무시
|
||
if (bx1 - bx0) < 1.0 or (by1 - by0) < 1.0:
|
||
_clear_live()
|
||
stat_lbl.configure(text="드래그 범위가 너무 작습니다. 다시 시도하세요.")
|
||
canvas.draw_idle()
|
||
return
|
||
_clear_live()
|
||
state["bbox"] = (bx0, by0, bx1, by1)
|
||
_draw_bbox()
|
||
|
||
canvas.mpl_connect("button_press_event", _on_press)
|
||
canvas.mpl_connect("motion_notify_event", _on_motion)
|
||
canvas.mpl_connect("button_release_event", _on_release)
|
||
|
||
# 초기 bbox 미리 그리기 (이전 선택 복원)
|
||
if state["bbox"] is not None:
|
||
_draw_bbox()
|
||
|
||
# --- 버튼 영역 (submit_frame / btn_row 프레임은 캔버스 위에서 이미 pack 됨) ---
|
||
|
||
def _on_reset():
|
||
state["bbox"] = None
|
||
if state["core_rect"] is not None:
|
||
state["core_rect"].remove()
|
||
state["core_rect"] = None
|
||
if state["blend_rect"] is not None:
|
||
state["blend_rect"].remove()
|
||
state["blend_rect"] = None
|
||
_clear_live()
|
||
stat_lbl.configure(text="선택 없음 — 다시 드래그하세요.")
|
||
canvas.draw_idle()
|
||
|
||
def _on_use_all():
|
||
self.tin_core_bbox = None
|
||
self.log(" [TIN 이용 범위] core_bbox 해제 — 전체 TIN 사용 (legacy 경로)")
|
||
messagebox.showinfo("알림", "core 해제됨. Step 1.5 는 전체 TIN 으로 확장됩니다.")
|
||
win.destroy()
|
||
|
||
def _on_confirm():
|
||
if state["bbox"] is None:
|
||
messagebox.showwarning("주의", "먼저 영역을 선택하세요.")
|
||
return
|
||
bx0, by0, bx1, by1 = state["bbox"]
|
||
# TIN bbox 로 클램프
|
||
bx0 = max(bx0, x0p); by0 = max(by0, y0p)
|
||
bx1 = min(bx1, x1p); by1 = min(by1, y1p)
|
||
if (bx1 - bx0) < 20.0 or (by1 - by0) < 20.0:
|
||
messagebox.showwarning("주의", "선택 영역이 너무 작습니다 (<20m).")
|
||
return
|
||
in_core = ((pts_abs[:, 0] >= bx0) & (pts_abs[:, 0] <= bx1)
|
||
& (pts_abs[:, 1] >= by0) & (pts_abs[:, 1] <= by1))
|
||
if int(in_core.sum()) < 3:
|
||
messagebox.showwarning("주의", "선택 영역에 측점이 부족합니다 (<3).")
|
||
return
|
||
# bbox 를 클램프된 값으로 최종 갱신 + 화면 반영
|
||
state["bbox"] = (bx0, by0, bx1, by1)
|
||
_draw_bbox()
|
||
bw = float(blend_var.get())
|
||
# blend_width 가 core 의 1/4 초과면 자동 제한
|
||
max_bw = min(bx1 - bx0, by1 - by0) * 0.25
|
||
if bw > max_bw:
|
||
self.log(f" [TIN 이용 범위] blend_width_m {bw:.0f} > core/4 "
|
||
f"({max_bw:.0f}) — 자동 제한")
|
||
bw = max_bw
|
||
self.tin_core_bbox = (bx0, by0, bx1, by1)
|
||
self.tin_blend_width_m = float(bw)
|
||
# 확정 시점의 원본 Z 백업 (core 복원·재적용 대비)
|
||
self._tin_core_original_points = np.asarray(self.tin_mesh.points,
|
||
dtype=np.float64).copy()
|
||
self.log(f" [TIN 이용 범위] core=({bx0:.1f},{by0:.1f})~({bx1:.1f},{by1:.1f}), "
|
||
f"blend_width={bw:.0f}m 확정. Step 1.5에서 3-zone 블렌드 적용.")
|
||
messagebox.showinfo("완료",
|
||
f"정밀 TIN core 설정됨\n"
|
||
f"• core: {bx1-bx0:.0f}×{by1-by0:.0f}m\n"
|
||
f"• 전이대: ±{bw:.0f}m\n"
|
||
f"이제 Step 1.5 를 실행하세요.")
|
||
win.destroy()
|
||
|
||
ctk.CTkButton(btn_row, text="🔄 재선택", command=_on_reset,
|
||
fg_color="#555", height=32).pack(side="left", padx=6)
|
||
ctk.CTkButton(btn_row, text="전체 사용 (core 해제)", command=_on_use_all,
|
||
fg_color="#7F8C8D", height=32).pack(side="left", padx=6)
|
||
ctk.CTkButton(btn_row, text="닫기",
|
||
command=win.destroy,
|
||
fg_color="#555", height=32).pack(side="right", padx=6)
|
||
|
||
# 큰 초록 제출 바 (최하단 · 가장 강조)
|
||
submit_btn = ctk.CTkButton(
|
||
submit_frame,
|
||
text="✅ 선택 결과 제출 (이 범위를 정밀 TIN core 로 확정)",
|
||
command=_on_confirm,
|
||
height=56,
|
||
fg_color="#22A06B", hover_color="#22A06B",
|
||
text_color="white",
|
||
font=ctk.CTkFont(size=16, weight="bold"))
|
||
submit_btn.pack(side="top", fill="x", padx=10, pady=10)
|
||
# Enter 키로도 제출
|
||
win.bind("<Return>", lambda _e: _on_confirm())
|
||
win.bind("<Escape>", lambda _e: win.destroy())
|
||
|
||
def _apply_core_precision_zone(self):
|
||
"""TIN 을 3-zone 구조로 재구성.
|
||
|
||
- Core (self.tin_core_bbox 내부): 원본 측점 Z **그대로 유지**.
|
||
- Transition (core 경계로부터 tin_blend_width_m 이내 외부): smoothstep
|
||
가중치로 TIN Z ↔ DEM-aligned Z 블렌드 (경계 0% DEM, 폭 밖 100% DEM).
|
||
- DEM zone (전이대 바깥): 완전히 DEM-aligned Z (self._dem_datum_offset 사용).
|
||
|
||
XY 는 변경 안 함 (Delaunay 재계산 불필요). Z 만 덮어씀.
|
||
self._tin_core_original_points 이 있으면 이를 기준으로 블렌드 (멱등성 보장).
|
||
"""
|
||
if self.tin_core_bbox is None or self.tin_mesh is None:
|
||
return
|
||
elev_grid = getattr(self, "_dem_elev_grid", None)
|
||
grid_bounds = getattr(self, "_dem_grid_bounds", None)
|
||
if elev_grid is None or grid_bounds is None:
|
||
self.log(" [3-zone] DEM 격자 없음 — TIN densify 를 먼저 실행해야 함. skip")
|
||
return
|
||
datum_ov = float(getattr(self, "_dem_datum_offset", 0.0) or 0.0)
|
||
blend_w = float(self.tin_blend_width_m)
|
||
origin = np.asarray(self.origin, dtype=np.float64)
|
||
|
||
# 원본 Z 복원 (멱등) 또는 현재 Z 사용
|
||
if self._tin_core_original_points is not None and \
|
||
len(self._tin_core_original_points) == self.tin_mesh.n_points:
|
||
base_zero = self._tin_core_original_points.copy()
|
||
else:
|
||
base_zero = np.asarray(self.tin_mesh.points, dtype=np.float64).copy()
|
||
self._tin_core_original_points = base_zero.copy()
|
||
|
||
pts_abs = base_zero + origin
|
||
cx0, cy0, cx1, cy1 = [float(v) for v in self.tin_core_bbox]
|
||
|
||
# L∞ (Chebyshev) signed distance to core bbox: 내부 음수, 외부 양수.
|
||
# 코너 주변은 L2 distance 로 보정해 대각 방향도 smooth — _signed_distance_to_polygon 재사용.
|
||
core_poly = np.array([
|
||
[cx0, cy0], [cx1, cy0], [cx1, cy1], [cx0, cy1]
|
||
], dtype=np.float64)
|
||
d = _signed_distance_to_polygon(pts_abs[:, :2], core_poly) # 음수=core 내부
|
||
|
||
# DEM 샘플 (CRS 변환 + bilinear)
|
||
src_crs = self.crs_option.get()
|
||
to_wgs = pyproj.Transformer.from_crs(src_crs, "EPSG:4326", always_xy=True)
|
||
lons, lats = to_wgs.transform(pts_abs[:, 0], pts_abs[:, 1])
|
||
z_dem_raw = _sample_grid_bilinear(
|
||
elev_grid, grid_bounds,
|
||
np.asarray(lats), np.asarray(lons))
|
||
fin = np.isfinite(z_dem_raw)
|
||
if not fin.all():
|
||
med = float(np.nanmedian(z_dem_raw)) if fin.any() else 0.0
|
||
z_dem_raw = np.where(fin, z_dem_raw, med)
|
||
z_dem_aligned = z_dem_raw - datum_ov
|
||
|
||
z_tin = pts_abs[:, 2]
|
||
|
||
# smoothstep weight: d <= 0 (core) -> 0, d >= blend_w -> 1
|
||
t = np.clip(d / max(blend_w, 1e-6), 0.0, 1.0)
|
||
w_dem = t * t * (3.0 - 2.0 * t)
|
||
w_dem = np.where(d <= 0.0, 0.0, w_dem) # core 내부 100% TIN
|
||
z_new = (1.0 - w_dem) * z_tin + w_dem * z_dem_aligned
|
||
|
||
# 갱신
|
||
pts_new_zero = np.column_stack([base_zero[:, 0], base_zero[:, 1],
|
||
z_new - origin[2]])
|
||
self.tin_mesh.points = pts_new_zero
|
||
self.tin_mesh["Elevation"] = pts_new_zero[:, 2]
|
||
self._tin_interpolator = None
|
||
|
||
n_core = int((d <= 0).sum())
|
||
n_tran = int(((d > 0) & (d < blend_w)).sum())
|
||
n_dem = int((d >= blend_w).sum())
|
||
# core는 Z 불변 (dz_core=0); transition/outside만 평균 |ΔZ| 계산
|
||
dz_tran = float(np.mean(np.abs(z_new[(d > 0) & (d < blend_w)] -
|
||
z_tin[(d > 0) & (d < blend_w)]))) \
|
||
if n_tran > 0 else 0.0
|
||
dz_out = float(np.mean(np.abs(z_new[d >= blend_w] - z_tin[d >= blend_w]))) \
|
||
if n_dem > 0 else 0.0
|
||
self.log(
|
||
f" [3-zone] core {n_core:,}(Z 불변), transition {n_tran:,}(|ΔZ|평균={dz_tran:.2f}m), "
|
||
f"DEM zone {n_dem:,}(|ΔZ|평균={dz_out:.2f}m) — blend={blend_w:.0f}m, "
|
||
f"datum offset={datum_ov:+.2f}m")
|
||
|
||
# --- Step 1.5: DEM으로 TIN 확장 + 경계 재보간 ---
|
||
def btn_extend_tin_with_dem_callback(self):
|
||
"""Step 1에서 만든 TIN을 DEM으로 둘러싸고, **원본 도면 경계와 DEM이
|
||
겹치는 구간은 DEM을 참값으로 보간**해 seam을 제거.
|
||
|
||
프로세스:
|
||
1. `build_extended_terrain_ring`으로 도넛형 DEM 확장 메시 생성
|
||
(= `self.tin_extension_mesh`). `dem_extender`의 경계 ramp-down/hull
|
||
override 로직은 그대로 사용.
|
||
2. 원본 TIN 정점 중 bbox 경계에서 feather_m 이내인 정점의 Z를
|
||
DEM 메시의 최근접 정점 Z로 smoothstep 블렌드(경계=100% DEM,
|
||
feather_m 안쪽=100% TIN).
|
||
3. Step 2(위성지도 결합)는 이 확장 결과를 재사용하며 추가 확장
|
||
작업을 하지 않는다(중복 제거).
|
||
"""
|
||
if not DEM_EXTENDER_AVAILABLE:
|
||
messagebox.showerror("오류", "dem_extender 모듈을 사용할 수 없습니다.")
|
||
return
|
||
if not self.tin_mesh:
|
||
messagebox.showwarning("주의", "먼저 TIN을 생성하세요 (Step 1).")
|
||
return
|
||
try:
|
||
dem_buffer_m = max(0.0, float(self.dem_buffer_var.get() or "1000"))
|
||
except ValueError:
|
||
dem_buffer_m = 1000.0
|
||
feather_m = max(150.0, dem_buffer_m * 0.2)
|
||
src_crs = self.crs_option.get()
|
||
|
||
self.set_status("DEM으로 TIN 확장 중...", "#F79E1B")
|
||
self.log(f">>> [Step 1.5] DEM으로 TIN 확장 (buffer={dem_buffer_m:.0f}m, feather={feather_m:.0f}m)...")
|
||
|
||
# [1.5-CORE] "TIN 이용 범위" 가 설정되어 있으면 **3-zone 블렌드** 선행.
|
||
# 이후 링 확장의 inner 경계를 **core+blend** 로 설정해, core 는 원본 100%
|
||
# 측점, transition 은 smoothstep TIN↔DEM, 그 바깥은 DEM 링으로 자연 연결.
|
||
use_core = self.tin_core_bbox is not None
|
||
if use_core:
|
||
try:
|
||
self._apply_core_precision_zone()
|
||
except Exception as _ce:
|
||
self.log(f" [Step 1.5-CORE] 3-zone 블렌드 실패: {_ce} — legacy 경로 폴백")
|
||
use_core = False
|
||
|
||
# [1.5-a] bbox 내부 빈 공간을 DEM으로 먼저 채움 → TIN이 bbox로 꽉 참.
|
||
# core 모드에서는 skip — 전이대가 이미 DEM-aligned Z 를 채웠고, core 밖
|
||
# TIN 측점은 원본 XY 를 유지한 채 Z 만 DEM 으로 대체됐으므로 추가 densify 불필요.
|
||
if use_core:
|
||
self.log(" [Step 1.5-a] core 모드 — bbox gap 채움 skip (3-zone 블렌드로 대체)")
|
||
else:
|
||
try:
|
||
self._fill_tin_bbox_gap_with_dem()
|
||
except Exception as _fe:
|
||
self.log(f" [Step 1.5-a 빈공간 채움] 경고: {_fe}")
|
||
|
||
try:
|
||
tin_xyz = np.asarray(self.tin_mesh.points, dtype=np.float64)
|
||
# **통일 offset + 격자 재사용** — create_tin_from_dxf에서 계산한
|
||
# `self._dem_datum_offset`와 `self._dem_elev_grid/bounds`가 있으면
|
||
# 그대로 넘겨 bbox seam에서 Z 단차 0을 보장. 없으면 override=None으로
|
||
# 기존 경로(경계 근처 자동 보정).
|
||
datum_ov = getattr(self, "_dem_datum_offset", None)
|
||
elev_ov = getattr(self, "_dem_elev_grid", None)
|
||
bounds_ov = getattr(self, "_dem_grid_bounds", None)
|
||
if datum_ov is not None:
|
||
self.log(f" [Step 1.5-b] datum offset 재사용 {datum_ov:+.2f}m "
|
||
f"+ DEM 격자 재사용 → **동일 datum 보장**")
|
||
# core 모드: 링 inner = core_bbox + blend_width (전이대 바깥)
|
||
# core → transition(TIN 안에서 smoothstep) → ring(DEM) 이 하나의 연속 표면.
|
||
if use_core:
|
||
cx0, cy0, cx1, cy1 = self.tin_core_bbox
|
||
bw = float(self.tin_blend_width_m)
|
||
ring_inner_bounds = (cx0 - bw, cy0 - bw, cx1 + bw, cy1 + bw)
|
||
self.log(f" [Step 1.5-b] core 모드: 링 inner = core+blend "
|
||
f"({(cx1-cx0)+2*bw:.0f}×{(cy1-cy0)+2*bw:.0f}m), "
|
||
f"외곽 buffer={dem_buffer_m:.0f}m")
|
||
else:
|
||
ring_inner_bounds = self.projected_bounds
|
||
result = build_extended_terrain_ring(
|
||
projected_bounds=ring_inner_bounds,
|
||
origin=np.asarray(self.origin, dtype=np.float64),
|
||
src_crs=src_crs,
|
||
buffer_m=dem_buffer_m,
|
||
tin_xyz_zerobased=tin_xyz,
|
||
feather_m=feather_m,
|
||
datum_offset_override=datum_ov,
|
||
elev_grid_override=elev_ov,
|
||
grid_bounds_override=bounds_ov,
|
||
log_fn=self.log,
|
||
)
|
||
self.tin_extension_mesh = result.mesh
|
||
self.tin_extension_textured = None # draping에서 재생성
|
||
self._dem_extend_info = result.info
|
||
self.log(f" [DEM 확장] {result.n_points}개 정점, {result.n_faces}개 삼각형")
|
||
except Exception as e:
|
||
self.log(f" [Step 1.5] DEM 확장 실패: {e}")
|
||
self.set_status("DEM 확장 실패", "#EB001B")
|
||
messagebox.showerror("오류", f"DEM 확장 실패:\n{e}")
|
||
return
|
||
|
||
try:
|
||
self._reinterpolate_tin_boundary_with_dem(result.mesh, feather_m)
|
||
except Exception as e:
|
||
self.log(f" [경계 재보간] 경고: {e}")
|
||
|
||
# UV 매핑/텍스처 초기화 — 다음 Step 2에서 재생성
|
||
self.total_mesh = None
|
||
self.set_status("Step 1.5 완료 — 위성지도 결합 준비", "#22A06B")
|
||
self.btn_step2.configure(fg_color=["#3a7ebf", "#1f538d"], border_width=0)
|
||
self.show_3d_preview(textured=False)
|
||
|
||
def _fill_tin_bbox_gap_with_dem(self):
|
||
"""TIN bbox 내부에서 현재 convex hull 바깥인 빈 공간을 DEM 샘플 점으로
|
||
채우고 Delaunay 재계산. 실행 후 tin_mesh의 hull이 bbox와 일치 →
|
||
평면 탑뷰에서 사각형이 꽉 찬 TIN. Step 1.5에서 외곽 확장 **전**에 호출.
|
||
|
||
알고리즘 (10m → 1m 점진):
|
||
for step in [10, 9, …, 1]:
|
||
- 현재 pts의 hull 재계산
|
||
- bbox 전체 step 격자 → hull 바깥·bbox 내부만 필터
|
||
- 기존 pts와 step×0.4 이내 거리는 중복 간주해 제외
|
||
- 남은 점 Z를 DEM에서 샘플 → append
|
||
"""
|
||
if not self.tin_mesh or not DEM_EXTENDER_AVAILABLE:
|
||
return
|
||
if not getattr(self, "projected_bounds", None):
|
||
return
|
||
from scipy.spatial import ConvexHull as _ConvexHullF, cKDTree as _cKDTreeF
|
||
from matplotlib.path import Path as _MplPathF
|
||
|
||
origin = np.asarray(self.origin, dtype=np.float64)
|
||
pts_zero = np.asarray(self.tin_mesh.points, dtype=np.float64).copy()
|
||
pts_abs = pts_zero + origin
|
||
x0_abs, y0_abs, x1_abs, y1_abs = [float(v) for v in self.projected_bounds]
|
||
|
||
# DEM 타일 1회 준비 — **create_tin에서 받은 격자/offset 재사용** 우선.
|
||
# 재사용 시 (1) 네트워크/IO 절약 (2) datum 일관성 → bbox seam 단차 0.
|
||
src_crs = self.crs_option.get()
|
||
to_wgs = pyproj.Transformer.from_crs(src_crs, "EPSG:4326", always_xy=True)
|
||
cached_grid = getattr(self, "_dem_elev_grid", None)
|
||
cached_bounds = getattr(self, "_dem_grid_bounds", None)
|
||
cached_offset = getattr(self, "_dem_datum_offset", None)
|
||
if cached_grid is not None and cached_bounds is not None and cached_offset is not None:
|
||
elev_grid = cached_grid
|
||
grid_bounds = cached_bounds
|
||
offset_v = float(cached_offset)
|
||
self.log(f" [Step 1.5-a 빈공간 채움] DEM 격자+offset 재사용 "
|
||
f"(offset={offset_v:+.2f}m, {elev_grid.shape[1]}x{elev_grid.shape[0]}) "
|
||
f"— create_tin과 **동일 datum**")
|
||
else:
|
||
margin = 100.0
|
||
cx_arr = np.array([x0_abs - margin, x1_abs + margin, x0_abs - margin, x1_abs + margin])
|
||
cy_arr = np.array([y0_abs - margin, y0_abs - margin, y1_abs + margin, y1_abs + margin])
|
||
cx_lon, cx_lat = to_wgs.transform(cx_arr, cy_arr)
|
||
elev_grid, grid_bounds = fetch_terrarium_grid(
|
||
float(np.min(cx_lat)), float(np.min(cx_lon)),
|
||
float(np.max(cx_lat)), float(np.max(cx_lon)),
|
||
zoom=13, cache_dir=str(cache_dir("dem")), log_fn=self.log,
|
||
)
|
||
# datum offset (기존 측점 vs DEM) — fallback 경로
|
||
s_lons, s_lats = to_wgs.transform(pts_abs[:, 0], pts_abs[:, 1])
|
||
s_dem_z = _sample_grid_bilinear(
|
||
elev_grid, grid_bounds,
|
||
np.asarray(s_lats), np.asarray(s_lons))
|
||
fin = np.isfinite(s_dem_z)
|
||
offset_v = float(np.median(s_dem_z[fin] - pts_abs[fin, 2])) if fin.any() else 0.0
|
||
self.log(f" [Step 1.5-a 빈공간 채움] DEM datum offset={offset_v:+.2f}m (fallback)")
|
||
|
||
def _sample_offset(xy_abs):
|
||
_lons, _lats = to_wgs.transform(xy_abs[:, 0], xy_abs[:, 1])
|
||
_z = _sample_grid_bilinear(
|
||
elev_grid, grid_bounds,
|
||
np.asarray(_lats), np.asarray(_lons))
|
||
if np.any(np.isnan(_z)):
|
||
_m = float(np.nanmedian(_z))
|
||
_z = np.where(np.isnan(_z), _m, _z)
|
||
return _z - offset_v
|
||
|
||
# 점진 densify
|
||
current_abs = pts_abs.copy()
|
||
total_added = 0
|
||
steps_log = []
|
||
for _step in (10.0, 9.0, 8.0, 7.0, 6.0, 5.0, 4.0, 3.0, 2.0, 1.0):
|
||
try:
|
||
hull = _ConvexHullF(current_abs[:, :2])
|
||
except Exception:
|
||
break
|
||
hull_path = _MplPathF(current_abs[hull.vertices, :2], closed=True)
|
||
gx = np.arange(x0_abs, x1_abs + _step * 0.5, _step)
|
||
gy = np.arange(y0_abs, y1_abs + _step * 0.5, _step)
|
||
ggx, ggy = np.meshgrid(gx, gy)
|
||
grid_xy = np.column_stack([ggx.ravel(), ggy.ravel()])
|
||
in_bb = (
|
||
(grid_xy[:, 0] >= x0_abs - 1e-6) & (grid_xy[:, 0] <= x1_abs + 1e-6)
|
||
& (grid_xy[:, 1] >= y0_abs - 1e-6) & (grid_xy[:, 1] <= y1_abs + 1e-6)
|
||
)
|
||
grid_xy = grid_xy[in_bb]
|
||
if len(grid_xy) == 0:
|
||
continue
|
||
in_hull = hull_path.contains_points(grid_xy)
|
||
out_hull_xy = grid_xy[~in_hull]
|
||
if len(out_hull_xy) == 0:
|
||
continue
|
||
tree_ex = _cKDTreeF(current_abs[:, :2])
|
||
d_ex, _ = tree_ex.query(out_hull_xy, k=1)
|
||
new_xy = out_hull_xy[d_ex > _step * 0.4]
|
||
if len(new_xy) == 0:
|
||
continue
|
||
new_z = _sample_offset(new_xy)
|
||
current_abs = np.vstack([current_abs, np.column_stack([new_xy, new_z])])
|
||
total_added += len(new_xy)
|
||
steps_log.append(f"{_step:.0f}m:{len(new_xy)}")
|
||
|
||
if total_added == 0:
|
||
self.log(" [Step 1.5-a 빈공간 채움] hull이 이미 bbox 덮음 — skip")
|
||
return
|
||
|
||
# Delaunay 재계산 (zero-based) + v6 벽 컷 (slope_ratio 기반)
|
||
new_zero = current_abs.copy()
|
||
new_zero[:, 0] -= origin[0]
|
||
new_zero[:, 1] -= origin[1]
|
||
new_zero[:, 2] -= origin[2]
|
||
tri_f = Delaunay(new_zero[:, :2])
|
||
simplices_f = tri_f.simplices
|
||
|
||
x_min_z = 0.0; y_min_z = 0.0
|
||
x_max_z = float(new_zero[:, 0].max())
|
||
y_max_z = float(new_zero[:, 1].max())
|
||
bbox_tol_z = max(x_max_z, y_max_z) * 1e-4 + 1e-3
|
||
|
||
def _touches_vid(vids):
|
||
xs = new_zero[vids, 0]; ys = new_zero[vids, 1]
|
||
return ((np.abs(xs - x_min_z) < bbox_tol_z)
|
||
| (np.abs(xs - x_max_z) < bbox_tol_z)
|
||
| (np.abs(ys - y_min_z) < bbox_tol_z)
|
||
| (np.abs(ys - y_max_z) < bbox_tol_z))
|
||
p0 = new_zero[simplices_f[:, 0], :2]
|
||
p1 = new_zero[simplices_f[:, 1], :2]
|
||
p2 = new_zero[simplices_f[:, 2], :2]
|
||
e_max = np.maximum(
|
||
np.maximum(np.linalg.norm(p0 - p1, axis=1),
|
||
np.linalg.norm(p1 - p2, axis=1)),
|
||
np.linalg.norm(p2 - p0, axis=1))
|
||
z0 = new_zero[simplices_f[:, 0], 2]
|
||
z1 = new_zero[simplices_f[:, 1], 2]
|
||
z2 = new_zero[simplices_f[:, 2], 2]
|
||
z_span = np.maximum(np.maximum(z0, z1), z2) - np.minimum(np.minimum(z0, z1), z2)
|
||
slope_ratio = z_span / np.maximum(e_max, 1e-6)
|
||
touches = (_touches_vid(simplices_f[:, 0])
|
||
| _touches_vid(simplices_f[:, 1])
|
||
| _touches_vid(simplices_f[:, 2]))
|
||
# 벽 판정: slope_ratio>3.0(≈72°) AND z_span>20m AND e_max>5m.
|
||
# 이전(1.5/5m)은 실제 급사면(산지 60~70°) 까지 잘라 접합점 구멍 원인이었음.
|
||
# 이번에는 자연 급사면 보존, 진짜 수직 벽(e_max도 작은 sliver)만 제거.
|
||
drop = touches & (slope_ratio > 3.0) & (z_span > 20.0) & (e_max > 5.0)
|
||
if drop.any():
|
||
simplices_f = simplices_f[~drop]
|
||
self.log(f" [Step 1.5-a 빈공간 채움] 벽 컷 v7 {int(drop.sum())}개 제거 "
|
||
f"(slope_ratio>3.0(≈72°), z_span>20m, e_max>5m) — 자연 급사면 보존")
|
||
else:
|
||
self.log(" [Step 1.5-a 빈공간 채움] 벽 컷 v7: 대상 없음 — 모든 삼각형 보존")
|
||
|
||
faces = np.column_stack([np.full(len(simplices_f), 3), simplices_f])
|
||
self.tin_mesh = pv.PolyData(new_zero, faces)
|
||
self.tin_mesh["Elevation"] = new_zero[:, 2]
|
||
self._tin_interpolator = None
|
||
self.log(
|
||
f" [Step 1.5-a 빈공간 채움] {total_added}개 DEM 점 추가 "
|
||
f"[{', '.join(steps_log)}] → TIN 재생성 "
|
||
f"(정점 {self.tin_mesh.n_points}, 삼각형 {len(simplices_f)})")
|
||
|
||
def _reinterpolate_tin_boundary_with_dem(self, dem_mesh, feather_m):
|
||
"""원본 TIN 경계 근처 정점 Z를 DEM 값으로 부드럽게 수렴시킴.
|
||
|
||
2단계로 **내부 네모박스 경계선(= 평활·자연 DEM 경계) 제거**:
|
||
1) DEM smoothstep 블렌드 — 경계 0 → DEM 100%, feather_m → TIN 100%.
|
||
smoothstep은 C1 연속이라 feather_m 경계에서 자연스러운 미분 0 전이.
|
||
2) bbox 4변 정확히 위 정점은 DEM 100% 강제(부분 블렌드에 원본 TIN Z가
|
||
섞여 튀는 것을 원천 차단).
|
||
|
||
이전(v5 이전)의 Laplacian smoothing 4pass는 **제거**.
|
||
이유: feather 영역만 이웃 가중평균으로 평탄화하면, feather 바깥 자연 DEM
|
||
내부와의 경계(= feather_m 거리)에 Z 분산 차이가 남아 **네모박스 경계선**
|
||
으로 보이는 현상이 사용자 error.png에서 확인됨. smoothstep 블렌드만으로
|
||
C1 연속이 확보되므로 Laplacian 추가 평활은 오히려 해가 됨.
|
||
"""
|
||
if dem_mesh is None or dem_mesh.n_points == 0:
|
||
self.log(" [경계 재보간] DEM mesh 없음 — skip")
|
||
return
|
||
ext_pts = np.asarray(dem_mesh.points, dtype=np.float64)
|
||
tin_pts = np.asarray(self.tin_mesh.points, dtype=np.float64).copy()
|
||
if len(tin_pts) == 0:
|
||
return
|
||
xmin = float(tin_pts[:, 0].min()); xmax = float(tin_pts[:, 0].max())
|
||
ymin = float(tin_pts[:, 1].min()); ymax = float(tin_pts[:, 1].max())
|
||
bbox_tol = max(xmax - xmin, ymax - ymin) * 1e-4 + 1e-3
|
||
dist_edge = np.minimum.reduce([
|
||
tin_pts[:, 0] - xmin,
|
||
xmax - tin_pts[:, 0],
|
||
tin_pts[:, 1] - ymin,
|
||
ymax - tin_pts[:, 1],
|
||
])
|
||
near_edge = dist_edge < feather_m
|
||
on_bbox = dist_edge < bbox_tol
|
||
n_near = int(near_edge.sum())
|
||
n_bbox = int(on_bbox.sum())
|
||
if n_near == 0:
|
||
self.log(" [경계 재보간] 경계 근처 정점 없음 — skip")
|
||
return
|
||
from scipy.spatial import cKDTree
|
||
|
||
# (1) DEM 샘플 + smoothstep 블렌드
|
||
dem_tree = cKDTree(ext_pts[:, :2])
|
||
near_idx = np.where(near_edge)[0]
|
||
_, idxs = dem_tree.query(tin_pts[near_idx, :2], k=1)
|
||
dem_z_near = ext_pts[idxs, 2]
|
||
t = np.clip(dist_edge[near_idx] / feather_m, 0.0, 1.0)
|
||
w_dem = 1.0 - (t * t * (3.0 - 2.0 * t))
|
||
z_before = tin_pts[near_idx, 2].copy()
|
||
tin_pts[near_idx, 2] = (1.0 - w_dem) * z_before + w_dem * dem_z_near
|
||
|
||
# (2) bbox 정확히 위 정점은 DEM 100% 강제 (spike 원천 차단)
|
||
if n_bbox > 0:
|
||
bbox_idx = np.where(on_bbox)[0]
|
||
_, b_idxs = dem_tree.query(tin_pts[bbox_idx, :2], k=1)
|
||
tin_pts[bbox_idx, 2] = ext_pts[b_idxs, 2]
|
||
|
||
delta = tin_pts[near_idx, 2] - z_before
|
||
self.tin_mesh.points = tin_pts
|
||
self.tin_mesh["Elevation"] = tin_pts[:, 2]
|
||
self._tin_interpolator = None
|
||
self.log(
|
||
f" [경계 재보간] {n_near}개 정점 smoothstep DEM 블렌드 "
|
||
f"(bbox 고정 {n_bbox}개, feather={feather_m:.0f}m, "
|
||
f"Δ평균={float(np.mean(delta)):+.2f}m, Δmax={float(np.max(np.abs(delta))):.2f}m) "
|
||
f"— Laplacian 제거 (내부 네모박스 경계선 원인)")
|
||
|
||
# --- Step 2: 위성지도 결합 (Draping) ---
|
||
def btn_draping_callback(self):
|
||
if not self.tin_mesh:
|
||
messagebox.showwarning("주의", "먼저 TIN을 생성해야 합니다.")
|
||
return
|
||
|
||
self.set_status("위성 이미지 다운로드 중...", "#F79E1B")
|
||
source_name = self.tile_source_option.get()
|
||
self.log(f">>> [Step 2] 위성 타일 다운로드 ({source_name})...")
|
||
|
||
try:
|
||
# 1. 바운딩 박스를 WGS84 위경도로 변환
|
||
src_crs = self.crs_option.get()
|
||
transformer_wgs84 = pyproj.Transformer.from_crs(src_crs, "EPSG:4326", always_xy=True)
|
||
|
||
# 타일 범위 기준 bbox — tin_extension_mesh가 있으면 확장 범위(Step 1.5 결과)
|
||
# 를, 없으면 원본 도면 범위를 사용. Step 1.5에서 이미 DEM 확장을 했다면
|
||
# 여기서 추가 확장 안 함(사용자 요구: 중복 제거).
|
||
ox = float(self.origin[0]); oy = float(self.origin[1])
|
||
if self.tin_extension_mesh is not None:
|
||
eb = self.tin_extension_mesh.bounds # zero-based
|
||
min_x_p = float(eb[0]) + ox; max_x_p = float(eb[1]) + ox
|
||
min_y_p = float(eb[2]) + oy; max_y_p = float(eb[3]) + oy
|
||
self.log(f" 확장 TIN 범위 사용: {max_x_p-min_x_p:.0f}×{max_y_p-min_y_p:.0f}m")
|
||
else:
|
||
min_x_p, min_y_p, max_x_p, max_y_p = self.projected_bounds
|
||
|
||
# 뷰 버퍼 % (사용자 지정, 기본 5%)
|
||
try:
|
||
buf_pct = max(0.0, float(self.buffer_percent_var.get() or "5"))
|
||
except ValueError:
|
||
buf_pct = 5.0
|
||
bw, bh = max_x_p - min_x_p, max_y_p - min_y_p
|
||
buf_x_m = bw * buf_pct / 100.0
|
||
buf_y_m = bh * buf_pct / 100.0
|
||
min_x_p -= buf_x_m; max_x_p += buf_x_m
|
||
min_y_p -= buf_y_m; max_y_p += buf_y_m
|
||
self.log(f" 위성 타일 버퍼: {buf_pct:.1f}% ({buf_x_m:.0f}/{buf_y_m:.0f}m)")
|
||
|
||
min_lon, min_lat = transformer_wgs84.transform(min_x_p, min_y_p)
|
||
max_lon, max_lat = transformer_wgs84.transform(max_x_p, max_y_p)
|
||
|
||
self.log(f"BBOX(WGS84): Lon [{min_lon:.6f}, {max_lon:.6f}], Lat [{min_lat:.6f}, {max_lat:.6f}]")
|
||
|
||
# 2. XYZ 타일 다운로드
|
||
tile_url_template = self.tile_servers[source_name]
|
||
# Vworld: API 키 치환
|
||
if "{vworld_key}" in tile_url_template:
|
||
vk = self.vworld_api_key.get()
|
||
if not vk:
|
||
raise ValueError("Vworld 타일 사용 시 API Key가 필요합니다. 사이드바에 입력해주세요.")
|
||
tile_url_template = tile_url_template.replace("{vworld_key}", vk)
|
||
with perf_block("위성 타일 다운로드+병합"):
|
||
satellite_img = self._download_xyz_tiles(tile_url_template, min_lat, min_lon, max_lat, max_lon)
|
||
|
||
img_path = "satellite_temp.png"
|
||
satellite_img.save(img_path)
|
||
self.log(f"위성 이미지 합성 완료 ({satellite_img.width}x{satellite_img.height}px).")
|
||
|
||
# 재질 텍스처 합성 (도로→아스팔트, 사면→토사, 굴착→흙)
|
||
# **실제 다운로드 bbox**(버퍼 확장 반영)를 넘겨 도로가 원본 도면 치수대로
|
||
# 그려지게 한다. 이전에는 5% 고정 bbox가 쓰여 DEM 1000m 버퍼 시 도로가
|
||
# 안쪽에 압축되어 2~3배 확대돼 보였음.
|
||
if self.layer_geometries:
|
||
self.log(" 재질 텍스처 합성 중...")
|
||
satellite_img = self._composite_material_textures(
|
||
satellite_img,
|
||
bbox_min_x=min_x_p, bbox_min_y=min_y_p,
|
||
bbox_max_x=max_x_p, bbox_max_y=max_y_p,
|
||
)
|
||
satellite_img.save(img_path)
|
||
self.log(f" 재질 합성 완료 (bbox 동기화: {max_x_p-min_x_p:.0f}×{max_y_p-min_y_p:.0f}m).")
|
||
|
||
# 2.5 DEM 확장은 Step 1.5(btn_extend_tin_with_dem_callback)에서 이미
|
||
# 수행 — 여기서는 중복 실행하지 않는다. self.tin_extension_mesh가 있으면
|
||
# 그대로 재사용해 텍스처만 입힌다(사용자 요구: 중복 제거).
|
||
self.tin_extension_textured = None
|
||
|
||
# 3. PyVista Texture Mapping (Draping)
|
||
texture = pv.read_texture(img_path)
|
||
|
||
# 확장 메시가 있으면 **실제 다운로드 bbox**(버퍼 포함)로 UV 매핑 →
|
||
# 위성 픽셀이 두 메시의 정확한 world 위치에 정렬됨. 두 메시 합친 bbox는
|
||
# DEM 링의 outer 에지 = 이 다운로드 bbox와 같으므로 등가.
|
||
if self.tin_extension_mesh is not None:
|
||
ox = float(self.origin[0]); oy = float(self.origin[1])
|
||
uv_x0 = min_x_p - ox; uv_x1 = max_x_p - ox
|
||
uv_y0 = min_y_p - oy; uv_y1 = max_y_p - oy
|
||
# z는 UV 평면 높이만 — 메시 bounds 중 아무 z 사용
|
||
z_any = self.tin_mesh.bounds[4]
|
||
origin_uv = (uv_x0, uv_y0, z_any)
|
||
point_u = (uv_x1, uv_y0, z_any)
|
||
point_v = (uv_x0, uv_y1, z_any)
|
||
self.total_mesh = self.tin_mesh.texture_map_to_plane(
|
||
origin=origin_uv, point_u=point_u, point_v=point_v, inplace=False)
|
||
self.tin_extension_textured = self.tin_extension_mesh.texture_map_to_plane(
|
||
origin=origin_uv, point_u=point_u, point_v=point_v, inplace=False)
|
||
# **Seam-free 렌더 용** — show_3d_preview / _capture_from_camera 에서
|
||
# 두 메시 merge 후 extract_surface 를 거치면 TCoords 가 소실되므로
|
||
# 동일 UV 파라미터로 재적용하기 위해 저장.
|
||
self._uv_mapping_params = (origin_uv, point_u, point_v)
|
||
self.log("텍스처 UV 매핑 완료 (TIN + 외곽 DEM 확장, 다운로드 bbox 정렬).")
|
||
else:
|
||
mesh_bounds = self.tin_mesh.bounds
|
||
self.total_mesh = self.tin_mesh.texture_map_to_plane(
|
||
origin=(mesh_bounds[0], mesh_bounds[2], mesh_bounds[4]),
|
||
point_u=(mesh_bounds[1], mesh_bounds[2], mesh_bounds[4]),
|
||
point_v=(mesh_bounds[0], mesh_bounds[3], mesh_bounds[4]),
|
||
inplace=False
|
||
)
|
||
# 확장 메시 없음 → UV 재적용 불필요. 이전 값 비활성.
|
||
self._uv_mapping_params = None
|
||
self.log("텍스처 UV 매핑 완료.")
|
||
|
||
self.set_status("위성지도 결합 완료", "#22A06B")
|
||
self.btn_step3.configure(fg_color=["#3a7ebf", "#1f538d"], border_width=0)
|
||
|
||
self.show_3d_preview(textured=True, texture_obj=texture)
|
||
|
||
except Exception as e:
|
||
self.log(f"결합 실패: {e}")
|
||
self.set_status("결합 실패", "#EB001B")
|
||
messagebox.showerror("오류", f"위성지도 결합 중 오류 발생:\n{e}")
|
||
|
||
def _download_xyz_tiles(self, url_template, min_lat, min_lon, max_lat, max_lon, zoom=17):
|
||
"""XYZ 타일 다운로드·합성 — 실제 로직은 tile_downloader 모듈."""
|
||
from tile_downloader import download_xyz_tiles
|
||
return download_xyz_tiles(
|
||
url_template, min_lat, min_lon, max_lat, max_lon,
|
||
zoom=zoom, log_fn=self.log,
|
||
)
|
||
|
||
def _reopen_3d_preview(self):
|
||
"""현재 TIN/텍스처/구조물 상태로 3D 뷰어 재호출.
|
||
|
||
PyVista 창을 닫아도 메쉬 데이터(self.tin_mesh, self.total_mesh)는
|
||
메모리에 유지되므로 언제든 재열기 가능. 구조물이 새로 빌드되면
|
||
자동 갱신되도록 최신 상태로 다시 그림.
|
||
"""
|
||
if not self.tin_mesh:
|
||
messagebox.showinfo("안내", "먼저 Step 1 (TIN 생성)을 완료해 주세요.")
|
||
return
|
||
# total_mesh(텍스처)가 있으면 위성 합성본 우선 표시
|
||
has_texture = self.total_mesh is not None
|
||
self.show_3d_preview(textured=has_texture)
|
||
|
||
def show_3d_preview(self, textured=False, texture_obj=None):
|
||
if not self.tin_mesh: return
|
||
|
||
p = pv.Plotter(title="S-CANVAS 3D Canvas")
|
||
p.set_background("#1e1e1e")
|
||
|
||
target_mesh = self.total_mesh if textured and self.total_mesh else self.tin_mesh
|
||
|
||
# DEM 외곽 확장 메시 (도넛) — TIN 뒤쪽 배경 지형
|
||
ext_mesh = None
|
||
if textured and self.tin_extension_textured is not None:
|
||
ext_mesh = self.tin_extension_textured
|
||
elif self.tin_extension_mesh is not None:
|
||
ext_mesh = self.tin_extension_mesh
|
||
|
||
# **Seam-free 렌더링**: TIN + DEM ring 두 메시가 별도 PolyData 라
|
||
# 각자 내부에서만 normal 이 평균돼 경계에서 쉐이딩 불연속(= error.png
|
||
# 의 사각 선)이 보인다. merge(merge_points=True) 로 공유 경계 정점을
|
||
# weld → 위상적으로 단일 연속 표면. compute_normals(feature_angle=180)
|
||
# 로 모든 edge 를 smooth 처리 → seam normal 평활. Z 는 이미
|
||
# _reinterpolate_tin_boundary_with_dem 에서 bbox 정점까지 DEM 매칭 돼
|
||
# 있어 tolerance=0.01m 로 안전하게 weld 가능.
|
||
unified_mesh = None
|
||
if ext_mesh is not None:
|
||
try:
|
||
merged = target_mesh.merge(ext_mesh, merge_points=True, tolerance=0.01)
|
||
if not isinstance(merged, pv.PolyData):
|
||
merged = merged.extract_surface()
|
||
# **UV 재적용** — merge/extract_surface 가 TCoords 를 떨어뜨리므로
|
||
# 텍스처 모드에서는 draping 때 저장한 동일 파라미터로 다시 매핑.
|
||
# texture_map_to_plane 이 새 메시를 만들어 반환하므로 compute_normals
|
||
# 는 그 다음(inplace=True) 에 호출해야 normal 도 같은 메시에 붙음.
|
||
uv_params = getattr(self, "_uv_mapping_params", None)
|
||
if textured and texture_obj and uv_params is not None:
|
||
o_uv, p_u, p_v = uv_params
|
||
merged = merged.texture_map_to_plane(
|
||
origin=o_uv, point_u=p_u, point_v=p_v, inplace=False)
|
||
merged.compute_normals(
|
||
feature_angle=180.0, auto_orient_normals=True,
|
||
consistent_normals=True, inplace=True,
|
||
)
|
||
unified_mesh = merged
|
||
n_weld = target_mesh.n_points + ext_mesh.n_points - merged.n_points
|
||
uv_note = " + UV 재적용" if (textured and texture_obj and uv_params) else ""
|
||
self.log(
|
||
f" [Render] seam-free 통합: TIN({target_mesh.n_points}) + "
|
||
f"DEM({ext_mesh.n_points}) → weld 후 {merged.n_points}점 "
|
||
f"(공유정점 {n_weld}개 용접, feature_angle=180 normal 평활{uv_note})"
|
||
)
|
||
except Exception as e:
|
||
self.log(f" [Render] 메시 통합 실패, 2-mesh 폴백: {e}")
|
||
unified_mesh = None
|
||
|
||
if unified_mesh is not None:
|
||
if textured and texture_obj:
|
||
p.add_mesh(unified_mesh, texture=texture_obj,
|
||
show_edges=self.wireframe_var.get(), edge_color="white")
|
||
else:
|
||
p.add_mesh(unified_mesh, scalars="Elevation", cmap=_TIN_EARTH_CMAP,
|
||
show_edges=self.wireframe_var.get(), edge_color="white",
|
||
scalar_bar_args={'title': 'Elevation (m)'})
|
||
else:
|
||
# legacy 2-mesh 렌더 — 확장 메시가 없거나 통합 실패 시
|
||
if textured and texture_obj:
|
||
p.add_mesh(target_mesh, texture=texture_obj,
|
||
show_edges=self.wireframe_var.get(), edge_color="white")
|
||
else:
|
||
p.add_mesh(target_mesh, scalars="Elevation", cmap=_TIN_EARTH_CMAP,
|
||
show_edges=self.wireframe_var.get(), edge_color="white",
|
||
scalar_bar_args={'title': 'Elevation (m)'})
|
||
if ext_mesh is not None:
|
||
try:
|
||
if textured and texture_obj:
|
||
p.add_mesh(ext_mesh, texture=texture_obj,
|
||
show_edges=False, lighting=True)
|
||
else:
|
||
p.add_mesh(ext_mesh, scalars="Elevation", cmap=_TIN_EARTH_CMAP,
|
||
show_edges=False, lighting=True,
|
||
show_scalar_bar=False)
|
||
except Exception as e:
|
||
self.log(f" [DEM] 확장 메시 렌더 실패: {e}")
|
||
|
||
# Phase 4: 계획선 오버레이 + 구조물 마커/3D 추가
|
||
self._add_overlay_to_plotter(p)
|
||
|
||
p.add_axes()
|
||
# 확장 메시가 있으면 축·카메라를 **합집합** bounds로 잡아 확장 영역이
|
||
# 프레임 밖으로 잘리지 않게 한다.
|
||
tb = self.tin_mesh.bounds
|
||
if ext_mesh is not None:
|
||
eb = ext_mesh.bounds
|
||
scene_bounds = (
|
||
min(tb[0], eb[0]), max(tb[1], eb[1]),
|
||
min(tb[2], eb[2]), max(tb[3], eb[3]),
|
||
min(tb[4], eb[4]), max(tb[5], eb[5]),
|
||
)
|
||
else:
|
||
scene_bounds = tb
|
||
try:
|
||
p.show_grid(bounds=scene_bounds, color="gray",
|
||
xlabel="X (m)", ylabel="Y (m)", zlabel="Elevation (m)")
|
||
except Exception:
|
||
p.show_grid(color="gray")
|
||
p.enable_eye_dome_lighting()
|
||
p.view_isometric()
|
||
with contextlib.suppress(Exception):
|
||
p.reset_camera(bounds=scene_bounds)
|
||
p.show()
|
||
|
||
def _add_overlay_to_plotter(self, plotter):
|
||
"""플로터에 지형 오버레이 (도로/면 오버레이/경계선) + 빌드된 3D 구조물만 추가.
|
||
|
||
Step 1 TIN 뷰 원칙: 구조물은 신경쓰지 않음. 구조물 위치 참조는 detail DXF가
|
||
빌드되어 template_meshes가 존재할 때만 _add_template_structures_to_plotter가 담당.
|
||
"""
|
||
# === 1) 계획선 오버레이 (도로/면 오버레이/경계선만) ===
|
||
if self.layer_geometries:
|
||
self.log(f" 오버레이 생성 시작 ({len(self.layer_geometries)}개 레이어)...")
|
||
overlays = self._build_plan_overlay_meshes()
|
||
|
||
added = 0
|
||
for mesh, color, opacity in overlays:
|
||
try:
|
||
if mesh.n_lines > 0 and mesh.n_cells == mesh.n_lines:
|
||
plotter.add_mesh(mesh, color=color, line_width=3, opacity=opacity)
|
||
else:
|
||
plotter.add_mesh(mesh, color=color, opacity=opacity,
|
||
show_edges=False, lighting=True)
|
||
added += 1
|
||
except Exception as e:
|
||
self.log(f" 오버레이 메쉬 추가 실패: {e}")
|
||
|
||
self.log(f" 오버레이: {added}/{len(overlays)}개 메쉬 추가됨")
|
||
|
||
# === 2) 템플릿 빌드된 구조물 3D 메쉬만 추가 (detail DXF 업로드 후 상태) ===
|
||
self._add_template_structures_to_plotter(plotter)
|
||
|
||
def _add_template_structures_to_plotter(self, plotter):
|
||
"""구조물 레지스트리의 템플릿 빌드 메쉬들을 지형 위 해당 위치에 배치."""
|
||
if not STRUCTURE_TEMPLATES_AVAILABLE:
|
||
return
|
||
if not getattr(self, "structure_registry", None):
|
||
return
|
||
|
||
built_structures = [
|
||
(ln, info) for ln, info in self.structure_registry.items()
|
||
if info.get("template_meshes")
|
||
]
|
||
if not built_structures:
|
||
return
|
||
|
||
self.log(f" 구조물 3D 배치: {len(built_structures)}개 구조물 변환 중...")
|
||
|
||
total_added = 0
|
||
import numpy as _np
|
||
|
||
# 진단용 TIN 요약
|
||
tb0 = self.tin_mesh.bounds
|
||
self.log(f" TIN bounds (로컬): x=[{tb0.x_min:.1f},{tb0.x_max:.1f}] "
|
||
f"y=[{tb0.y_min:.1f},{tb0.y_max:.1f}] "
|
||
f"z=[{tb0.z_min:.1f},{tb0.z_max:.1f}]")
|
||
self.log(f" origin(월드): ({self.origin[0]:.1f}, {self.origin[1]:.1f}, "
|
||
f"{self.origin[2]:.1f})")
|
||
|
||
for layer_name, info in built_structures:
|
||
try:
|
||
raw_meshes = info["template_meshes"]
|
||
centroid = info["centroid"]
|
||
rotation = info.get("orientation_deg", 0.0)
|
||
|
||
# 원본 메쉬 aggregate bounds (진단용)
|
||
all_pts_raw = _np.concatenate(
|
||
[_np.asarray(m.points) for m, _, _ in raw_meshes], axis=0
|
||
)
|
||
r_xmin, r_ymin, r_zmin = all_pts_raw.min(axis=0)
|
||
r_xmax, r_ymax, r_zmax = all_pts_raw.max(axis=0)
|
||
self.log(f" [{info['name']}] raw mesh bounds: "
|
||
f"x=[{r_xmin:.1f},{r_xmax:.1f}] "
|
||
f"y=[{r_ymin:.1f},{r_ymax:.1f}] "
|
||
f"z=[{r_zmin:.1f},{r_zmax:.1f}] "
|
||
f"(메쉬 {len(raw_meshes)}개)")
|
||
|
||
# 지형 Z 맞춤: 구조물 bottom_el을 지형 표면으로 이동
|
||
struct_bottom_el = self._extract_structure_bottom_el(info)
|
||
|
||
# geo-referencing(placement_transform)이 있으면 TIN 4점에 배치
|
||
tr = info.get("placement_transform")
|
||
scale_mode = info.get("placement_scale_mode", "none")
|
||
if (tr is not None
|
||
and getattr(tr, "ref_tin", None)
|
||
and len(tr.ref_tin) >= 4):
|
||
ref_arr = _np.asarray(tr.ref_tin, dtype=float)
|
||
q_cx = float(ref_arr[:, 0].mean())
|
||
q_cy = float(ref_arr[:, 1].mean())
|
||
# ref_tin은 GeoReferencingDialog에서 이미 origin 차감된 TIN 로컬
|
||
# 단, 레거시(world 좌표로 저장된 것)를 감지하면 자동 변환
|
||
tb_chk = self.tin_mesh.bounds
|
||
is_local = (
|
||
tb_chk.x_min - 5000 <= q_cx <= tb_chk.x_max + 5000 and
|
||
tb_chk.y_min - 5000 <= q_cy <= tb_chk.y_max + 5000
|
||
)
|
||
if not is_local:
|
||
# 레거시 world 좌표 → 로컬로 변환
|
||
ref_arr = ref_arr - _np.array(
|
||
[float(self.origin[0]), float(self.origin[1])]
|
||
)
|
||
q_cx = float(ref_arr[:, 0].mean())
|
||
q_cy = float(ref_arr[:, 1].mean())
|
||
self.log(f" └ geo-ref (legacy world → local 변환): "
|
||
f"quad center=({q_cx:.1f}, {q_cy:.1f})")
|
||
else:
|
||
self.log(f" └ geo-ref: quad center (TIN 로컬)="
|
||
f"({q_cx:.1f}, {q_cy:.1f}) · "
|
||
f"scale_mode={scale_mode} · tr.scale={tr.scale:.4f}")
|
||
pad_z = info.get("_excavation_pad_z")
|
||
# detail quad (ref_plan) — mesh 방향 정렬용
|
||
detail_q = getattr(tr, "ref_plan", None) or []
|
||
# 파서가 제공하는 plan_frame_angle (mesh +X가 detail +X로부터
|
||
# 이 각도만큼 회전되어 있으면 상쇄)
|
||
plan_frame_angle = 0.0
|
||
params_obj = info.get("template_params")
|
||
if params_obj is not None:
|
||
plan_frame_angle = float(
|
||
getattr(params_obj, "plan_frame_angle_deg", 0.0) or 0.0
|
||
)
|
||
placed = fit_meshes_to_quad(
|
||
meshes=raw_meshes,
|
||
quad_world_pts=ref_arr.tolist(),
|
||
terrain_mesh=self.tin_mesh,
|
||
terrain_origin=_np.zeros(3), # ref_tin 이미 로컬
|
||
structure_bottom_el=struct_bottom_el,
|
||
z_mode="terrain",
|
||
scale_mode=scale_mode,
|
||
skip_ground=True,
|
||
skip_terrain=True,
|
||
pad_surface_z=pad_z, # 굴착 pad Z (있으면 보간 대체)
|
||
detail_quad_pts=detail_q, # mesh 방향 정렬
|
||
plan_frame_angle_deg=plan_frame_angle,
|
||
)
|
||
else:
|
||
self.log(f" └ 폴백: centroid={centroid} rot={rotation:.1f}° "
|
||
f"(placement_transform 없음)")
|
||
pad_z = info.get("_excavation_pad_z")
|
||
placed = apply_placement(
|
||
meshes=raw_meshes,
|
||
plan_centroid=centroid,
|
||
rotation_deg=rotation,
|
||
z_mode="terrain",
|
||
terrain_mesh=self.tin_mesh,
|
||
terrain_origin=self.origin,
|
||
structure_bottom_el=struct_bottom_el,
|
||
skip_ground=True,
|
||
skip_terrain=True,
|
||
pad_surface_z=pad_z,
|
||
)
|
||
|
||
# placed 메쉬 aggregate bounds (진단용)
|
||
if placed:
|
||
all_pts_p = _np.concatenate(
|
||
[_np.asarray(m.points) for m, _, _ in placed], axis=0
|
||
)
|
||
p_xmin, p_ymin, p_zmin = all_pts_p.min(axis=0)
|
||
p_xmax, p_ymax, p_zmax = all_pts_p.max(axis=0)
|
||
self.log(f" └ placed bounds: "
|
||
f"x=[{p_xmin:.1f},{p_xmax:.1f}] "
|
||
f"y=[{p_ymin:.1f},{p_ymax:.1f}] "
|
||
f"z=[{p_zmin:.1f},{p_zmax:.1f}]")
|
||
else:
|
||
self.log(" └ placed: 0개 (필터로 모두 제외됨)")
|
||
|
||
# TIN 바운드 기준으로 sanity check — 엄청 벗어난 메쉬는 경고 후 제외
|
||
tin_b = self.tin_mesh.bounds
|
||
tin_w = float(tin_b.x_max - tin_b.x_min)
|
||
tin_h = float(tin_b.y_max - tin_b.y_min)
|
||
tin_z_range = float(tin_b.z_max - tin_b.z_min)
|
||
tin_cx = float((tin_b.x_min + tin_b.x_max) / 2)
|
||
tin_cy = float((tin_b.y_min + tin_b.y_max) / 2)
|
||
tin_cz = float((tin_b.z_min + tin_b.z_max) / 2)
|
||
|
||
for mesh, color, opacity in placed:
|
||
try:
|
||
b = mesh.bounds
|
||
# 메쉬가 TIN 중심으로부터 너무 멀리/너무 크면 제외
|
||
mx = max(abs(b.x_min - tin_cx), abs(b.x_max - tin_cx))
|
||
my = max(abs(b.y_min - tin_cy), abs(b.y_max - tin_cy))
|
||
mz = max(abs(b.z_min - tin_cz), abs(b.z_max - tin_cz))
|
||
if (mx > max(tin_w * 5, 1000) or
|
||
my > max(tin_h * 5, 1000) or
|
||
mz > max(tin_z_range * 5, 1000)):
|
||
self.log(f" [{info['name']}] 메쉬 이상치 제외: "
|
||
f"bounds=({b.x_min:.1f},{b.y_min:.1f},"
|
||
f"{b.z_min:.1f})-({b.x_max:.1f},"
|
||
f"{b.y_max:.1f},{b.z_max:.1f})")
|
||
continue
|
||
plotter.add_mesh(mesh, color=color, opacity=opacity,
|
||
show_edges=False, smooth_shading=True,
|
||
lighting=True)
|
||
total_added += 1
|
||
except Exception:
|
||
continue
|
||
|
||
self.log(f" [{info['name']}] {len(placed)}개 메쉬 배치 완료")
|
||
except Exception as e:
|
||
self.log(f" [{info.get('name', layer_name)}] 배치 실패: {e}")
|
||
continue
|
||
|
||
self.log(f" 구조물 3D 총 {total_added}개 메쉬 추가됨")
|
||
|
||
def _extract_structure_bottom_el(self, info):
|
||
"""레지스트리 정보에서 구조물 바닥 EL을 추출."""
|
||
params = info.get("template_params")
|
||
if params is None:
|
||
return 0.0
|
||
|
||
# StructureParams 딕셔너리에서 bottom_el 계열 키 찾기
|
||
p_dict = getattr(params, "params", {})
|
||
for key in ["body_bottom_el", "bottom_el", "base_el",
|
||
"el_upstream_bed", "el_gate_sill"]:
|
||
if key in p_dict:
|
||
return float(p_dict[key])
|
||
return 0.0
|
||
|
||
def _compute_capture_size(self, max_long_side=1536):
|
||
"""캡처 (out_w, out_h) 계산 — 우선순위: 명시 화면비 → 뷰어 창 크기 → 정사각.
|
||
|
||
- `extraction_aspect_ratio`(rw, rh) 가 설정돼 있으면 그 비율로 lock.
|
||
- 없으면 `_saved_window_size` 기반(자유 모드).
|
||
- 둘 다 없으면 정사각형 폴백.
|
||
- long-side 를 max_long_side 로 캡, 짧은쪽은 비율 보존 후 8배수 정렬.
|
||
"""
|
||
aspect = getattr(self, "extraction_aspect_ratio", None)
|
||
if aspect and len(aspect) == 2 and aspect[0] > 0 and aspect[1] > 0:
|
||
rw, rh = float(aspect[0]), float(aspect[1])
|
||
if rw >= rh:
|
||
out_w = max_long_side
|
||
out_h = max(8, round(max_long_side * rh / rw / 8.0) * 8)
|
||
else:
|
||
out_h = max_long_side
|
||
out_w = max(8, round(max_long_side * rw / rh / 8.0) * 8)
|
||
return int(out_w), int(out_h)
|
||
ws = getattr(self, "_saved_window_size", None)
|
||
if not ws or len(ws) != 2 or ws[0] <= 0 or ws[1] <= 0:
|
||
return max_long_side, max_long_side
|
||
w, h = float(ws[0]), float(ws[1])
|
||
if w >= h:
|
||
out_w = max_long_side
|
||
out_h = max(8, round(max_long_side * h / w / 8.0) * 8)
|
||
else:
|
||
out_h = max_long_side
|
||
out_w = max(8, round(max_long_side * w / h / 8.0) * 8)
|
||
return int(out_w), int(out_h)
|
||
|
||
# --- Step 3: 뷰포인트 선택 + 제어맵 추출 ---
|
||
def btn_control_map_callback(self):
|
||
if not self.tin_mesh:
|
||
messagebox.showwarning("주의", "먼저 TIN을 생성해야 합니다.")
|
||
return
|
||
if not self.total_mesh:
|
||
messagebox.showwarning("주의", "먼저 위성지도 결합(Step 2)을 수행해야 합니다.")
|
||
return
|
||
|
||
self.log(">>> [Step 3] 뷰포인트 선택 모드 진입...")
|
||
self.log(" ◆ 마우스로 원하는 각도를 잡으세요 (Google Earth처럼)")
|
||
self.log(" ◆ 좌클릭+드래그: 회전 | 휠: 줌 | 우클릭+드래그: 이동")
|
||
self.log(" ◆ 하단 화면비 버튼 클릭 → 창 크기/캡처 비율 즉시 잠금")
|
||
self.log(" ◆ 원하는 뷰가 잡히면 Enter 키(또는 q)를 누르거나 창을 닫으세요")
|
||
self.set_status("뷰포인트를 선택하세요 (Enter로 확정)", "#F79E1B")
|
||
|
||
try:
|
||
# 인터랙티브 3D 뷰어 열기 — 사용자가 자유롭게 회전/줌
|
||
self._saved_camera = self._open_interactive_viewer()
|
||
|
||
if self._saved_camera is None:
|
||
self.log(" 뷰포인트 선택 취소됨.")
|
||
self.set_status("뷰포인트 미선택", "#EB001B")
|
||
return
|
||
|
||
# 선택된 카메라 위치 로그 (focal/up은 카메라 복원 시 다시 읽음)
|
||
cam_pos = self._saved_camera[0]
|
||
self.log(f" 카메라 위치 확정: pos={[f'{v:.0f}' for v in cam_pos]}")
|
||
|
||
# 확정된 뷰로 제어맵 추출 — 화면비 락이 있으면 그 비율, 없으면 뷰어 창
|
||
self.log(" 확정된 뷰로 캡처 시작...")
|
||
out_w, out_h = self._compute_capture_size(1536)
|
||
ar = self.extraction_aspect_ratio
|
||
ar_label = f"비율 {ar[0]}:{ar[1]}" if ar else f"뷰어 창 {self._saved_window_size or '미저장'}"
|
||
self.log(f" 캡처 해상도: {out_w}x{out_h} ({ar_label} 기반)")
|
||
|
||
with perf_block("control map capture x3 + composite"):
|
||
# 1. 위성 텍스처 3D 캡처
|
||
self.capture_image = self._capture_from_camera(out_w, out_h, textured=True)
|
||
self.capture_image.save("capture_textured.png")
|
||
self.log(f" 캡처 완료: {self.capture_image.size}")
|
||
|
||
# 2. Depth Map
|
||
self.log(" Depth Map 추출 중...")
|
||
self.depth_map = self._capture_depth_from_camera(out_w, out_h)
|
||
self.depth_map.save("depth_map.png")
|
||
self.log(" Depth Map 완료.")
|
||
|
||
# 3. Lineart Map
|
||
self.log(" Lineart Map 추출 중...")
|
||
self.lineart_map = self._capture_lineart_from_camera(out_w, out_h)
|
||
self.lineart_map.save("lineart_map.png")
|
||
self.log(" Lineart Map 완료.")
|
||
|
||
# 4. 가이드 이미지 합성
|
||
self.guide_image = self._compose_guide_image(
|
||
self.capture_image, self.depth_map, self.lineart_map
|
||
)
|
||
self.guide_image.save("guide_composite.png")
|
||
|
||
self.set_status("제어맵 추출 완료", "#22A06B")
|
||
self.btn_step4.configure(fg_color=["#3a7ebf", "#1f538d"])
|
||
self.log(" 저장 완료: capture_textured.png, depth_map.png, lineart_map.png, guide_composite.png")
|
||
|
||
messagebox.showinfo("완료",
|
||
"뷰포인트 확정 + 제어맵 4종 추출 완료!\n\n"
|
||
"이제 Step 4(AI 렌더링)를 실행하세요.")
|
||
|
||
except Exception as e:
|
||
self.log(f"제어맵 추출 실패: {e}")
|
||
self.set_status("추출 실패", "#EB001B")
|
||
messagebox.showerror("오류", f"제어맵 추출 중 오류:\n{e}")
|
||
|
||
def _open_interactive_viewer(self):
|
||
"""인터랙티브 PyVista 뷰어. 카메라 앙각/방위각을 HUD로 실시간 표시."""
|
||
import math
|
||
|
||
p = pv.Plotter(title="S-CANVAS: 뷰포인트 선택 (마우스로 회전/줌 → q로 확정)")
|
||
p.set_background("#1a1a2e")
|
||
|
||
target = self.total_mesh if self.total_mesh else self.tin_mesh
|
||
tex = None
|
||
if self.total_mesh and os.path.exists("satellite_temp.png"):
|
||
tex = pv.read_texture("satellite_temp.png")
|
||
p.add_mesh(target, texture=tex, show_edges=self.wireframe_var.get(), edge_color="#444444")
|
||
else:
|
||
p.add_mesh(target, scalars="Elevation", cmap=_TIN_EARTH_CMAP,
|
||
show_edges=self.wireframe_var.get(), edge_color="#444444")
|
||
|
||
# DEM 외곽 확장 메시 — 뷰포인트 선택/캡처/AI에 **같은 장면**을 쓰기 위해 같이 렌더
|
||
ext_mesh_view = self.tin_extension_textured or self.tin_extension_mesh
|
||
if ext_mesh_view is not None:
|
||
try:
|
||
if tex is not None and self.tin_extension_textured is not None:
|
||
p.add_mesh(ext_mesh_view, texture=tex, show_edges=False, lighting=True)
|
||
else:
|
||
p.add_mesh(ext_mesh_view, scalars="Elevation", cmap=_TIN_EARTH_CMAP,
|
||
show_edges=False, lighting=True, show_scalar_bar=False)
|
||
except Exception as e:
|
||
self.log(f" [뷰어] 확장 메시 추가 경고: {e}")
|
||
|
||
p.add_axes()
|
||
p.show_grid(color="gray")
|
||
p.enable_eye_dome_lighting()
|
||
|
||
# Phase 4: 계획선 오버레이 추가
|
||
self._add_overlay_to_plotter(p)
|
||
|
||
# 카메라 계산 기준: TIN + 확장 메시 **합집합 bounds** → 전체가 뷰에 들어오게
|
||
tb = target.bounds
|
||
if ext_mesh_view is not None:
|
||
eb = ext_mesh_view.bounds
|
||
bounds = [
|
||
min(tb[0], eb[0]), max(tb[1], eb[1]),
|
||
min(tb[2], eb[2]), max(tb[3], eb[3]),
|
||
min(tb[4], eb[4]), max(tb[5], eb[5]),
|
||
]
|
||
else:
|
||
bounds = list(tb)
|
||
cx = (bounds[0] + bounds[1]) / 2
|
||
cy = (bounds[2] + bounds[3]) / 2
|
||
cz = (bounds[4] + bounds[5]) / 2
|
||
diag = ((bounds[1]-bounds[0])**2 + (bounds[3]-bounds[2])**2)**0.5
|
||
# Step 3 캡처가 같은 frame을 쓰도록 보관
|
||
self._capture_bounds = tuple(bounds)
|
||
|
||
# 초기 뷰: isometric — 사용자 지정 버퍼 %만큼 프레이밍 여유를 줌
|
||
try:
|
||
buf_pct = max(0.0, float(self.buffer_percent_var.get() or "5"))
|
||
except (ValueError, AttributeError):
|
||
buf_pct = 5.0
|
||
elev, azim = 45.0, 225.0
|
||
# 기준 거리 1.3x diag(거의 꽉 참) + buf_pct 만큼 여유
|
||
dist_mult = 1.3 + buf_pct / 100.0
|
||
dist = diag * dist_mult
|
||
cam_x = cx + dist * math.cos(math.radians(elev)) * math.sin(math.radians(azim))
|
||
cam_y = cy + dist * math.cos(math.radians(elev)) * math.cos(math.radians(azim))
|
||
cam_z = cz + dist * math.sin(math.radians(elev))
|
||
p.camera_position = [(cam_x, cam_y, cam_z), (cx, cy, cz), (0, 0, 1)]
|
||
|
||
# HUD: 카메라 정보 텍스트 (좌상단) — actor handle 보관 안 함 (현재 동적 갱신 X)
|
||
p.add_text(
|
||
"마우스로 회전/줌 → Enter로 확정 (q도 가능)\n"
|
||
"앙각: 45° 방위: 225°(SW) 줌: 1.0x 비율: 자유",
|
||
position="upper_left", font_size=10, color="white", name="camera_hud"
|
||
)
|
||
|
||
# 카메라 저장 컨테이너
|
||
captured_cam = [None]
|
||
captured_win = [None] # 인터랙티브 뷰어의 마지막 창 크기 (w, h)
|
||
center = [cx, cy, cz]
|
||
|
||
dirs_map = {0:"N",45:"NE",90:"E",135:"SE",180:"S",225:"SW",270:"W",315:"NW",360:"N"}
|
||
|
||
def _calc_camera_angles():
|
||
"""현재 카메라에서 앙각/방위각/줌 계산"""
|
||
try:
|
||
cam = p.camera_position
|
||
if not cam:
|
||
return 45, 225, 1.0
|
||
pos = cam[0]
|
||
dx = pos[0] - center[0]
|
||
dy = pos[1] - center[1]
|
||
dz = pos[2] - center[2]
|
||
dist_xy = math.sqrt(dx*dx + dy*dy)
|
||
dist_3d = math.sqrt(dx*dx + dy*dy + dz*dz)
|
||
|
||
elev_deg = math.degrees(math.atan2(dz, max(dist_xy, 1e-6)))
|
||
azim_deg = math.degrees(math.atan2(dx, max(dy, 1e-6))) % 360
|
||
zoom_ratio = diag * 1.5 / max(dist_3d, 1e-6)
|
||
|
||
return elev_deg, azim_deg, zoom_ratio
|
||
except Exception:
|
||
return 45, 225, 1.0
|
||
|
||
def _update_hud_and_save(*args):
|
||
"""카메라 정보 갱신 + 카메라 위치/창 크기 저장"""
|
||
try:
|
||
cam = p.camera_position
|
||
if cam:
|
||
captured_cam[0] = (tuple(cam[0]), tuple(cam[1]), tuple(cam[2]))
|
||
# 창 크기도 매 이벤트마다 갱신 — 사용자가 창을 리사이즈해도 추적
|
||
try:
|
||
ws = p.window_size
|
||
if ws is not None and len(ws) == 2 and ws[0] > 0 and ws[1] > 0:
|
||
captured_win[0] = (int(ws[0]), int(ws[1]))
|
||
except Exception:
|
||
pass
|
||
|
||
elev_deg, azim_deg, zoom_ratio = _calc_camera_angles()
|
||
closest_dir = min(dirs_map.keys(), key=lambda k: abs(k - azim_deg % 360))
|
||
dir_label = dirs_map[closest_dir]
|
||
|
||
cur_ratio = self.extraction_aspect_ratio
|
||
ratio_label = f"{cur_ratio[0]}:{cur_ratio[1]}" if cur_ratio else "자유"
|
||
hud_text = (
|
||
f"마우스로 회전/줌 → Enter로 확정 (q도 가능)\n"
|
||
f"앙각: {elev_deg:.0f}° 방위: {azim_deg:.0f}°({dir_label}) "
|
||
f"줌: {zoom_ratio:.1f}x 비율: {ratio_label}"
|
||
)
|
||
p.add_text(hud_text, position="upper_left", font_size=10,
|
||
color="white", name="camera_hud")
|
||
except Exception:
|
||
pass
|
||
|
||
p.iren.add_observer("ExitEvent", lambda *a: _update_hud_and_save())
|
||
p.iren.add_observer("KeyPressEvent", _update_hud_and_save)
|
||
p.iren.add_observer("InteractionEvent", _update_hud_and_save)
|
||
|
||
# ─── 화면비 락 버튼 (사진 편집기 스타일) ──────────────────────────────
|
||
# PIL 로 버튼 이미지(배경+테두리+텍스트)를 그려서 vtkTexturedActor2D 로
|
||
# 화면 하단에 띄우고, 클릭 시 self.extraction_aspect_ratio 변경 + 창 크기 조정.
|
||
# vtkTextActor 만 쓰면 "텍스트만 떠서" 버튼처럼 안 보이는 문제 해결.
|
||
from PIL import Image as _PILImage, ImageDraw as _PILDraw, ImageFont as _PILFont
|
||
try:
|
||
import vtk as _vtk
|
||
except Exception:
|
||
_vtk = None
|
||
|
||
RATIOS = [
|
||
("자유", None), ("1:1", (1,1)), ("9:16", (9,16)), ("16:9", (16,9)),
|
||
("4:5", (4,5)), ("5:4", (5,4)), ("3:4", (3,4)), ("4:3", (4,3)),
|
||
("2:3", (2,3)), ("3:2", (3,2)), ("5:7", (5,7)), ("7:5", (7,5)),
|
||
("1:2", (1,2)), ("2:1", (2,1)),
|
||
]
|
||
NUM_BTN = len(RATIOS)
|
||
# 픽셀 단위 버튼 사이즈 — 가독성 우선
|
||
BTN_PX_W, BTN_PX_H, BTN_PX_GAP, BTN_PX_BOTTOM = 78, 32, 4, 14
|
||
|
||
# 한글 지원 폰트 로드 (Windows 기본). 실패 시 PIL 기본 폰트 폴백.
|
||
def _load_button_font():
|
||
for fp in [
|
||
"C:/Windows/Fonts/malgun.ttf",
|
||
"C:/Windows/Fonts/malgunbd.ttf",
|
||
"C:/Windows/Fonts/gulim.ttc",
|
||
]:
|
||
try:
|
||
return _PILFont.truetype(fp, 14)
|
||
except Exception:
|
||
continue
|
||
return _PILFont.load_default()
|
||
|
||
_btn_font = _load_button_font()
|
||
|
||
def _make_button_image(label, active):
|
||
"""버튼 한 개를 PIL 이미지로 렌더 → numpy 배열 반환."""
|
||
img = _PILImage.new("RGBA", (BTN_PX_W, BTN_PX_H), (0, 0, 0, 0))
|
||
d = _PILDraw.Draw(img)
|
||
# 배경
|
||
bg = (255, 215, 0, 235) if active else (50, 50, 70, 220)
|
||
border = (255, 255, 255, 255) if active else (180, 180, 200, 255)
|
||
text_color = (30, 20, 0, 255) if active else (240, 240, 240, 255)
|
||
d.rounded_rectangle(
|
||
[(0, 0), (BTN_PX_W - 1, BTN_PX_H - 1)],
|
||
radius=6, fill=bg, outline=border, width=2,
|
||
)
|
||
# 가운데 텍스트
|
||
try:
|
||
tb = d.textbbox((0, 0), label, font=_btn_font)
|
||
tw, th = tb[2] - tb[0], tb[3] - tb[1]
|
||
tx = (BTN_PX_W - tw) // 2 - tb[0]
|
||
ty = (BTN_PX_H - th) // 2 - tb[1]
|
||
except Exception:
|
||
tw, th = d.textsize(label, font=_btn_font)
|
||
tx = (BTN_PX_W - tw) // 2
|
||
ty = (BTN_PX_H - th) // 2
|
||
d.text((tx, ty), label, fill=text_color, font=_btn_font)
|
||
return np.array(img)
|
||
|
||
def _np_to_vtk_image(arr):
|
||
"""RGBA numpy → vtkImageData (vtkTexturedButtonRepresentation2D 용)."""
|
||
if _vtk is None:
|
||
return None
|
||
h, w = arr.shape[:2]
|
||
img = _vtk.vtkImageData()
|
||
img.SetDimensions(w, h, 1)
|
||
img.AllocateScalars(_vtk.VTK_UNSIGNED_CHAR, 4)
|
||
# 위아래 뒤집기 (PIL 은 top-down, VTK 은 bottom-up)
|
||
flipped = np.ascontiguousarray(arr[::-1])
|
||
try:
|
||
from vtkmodules.util import numpy_support as _ns
|
||
vtk_arr = _ns.numpy_to_vtk(
|
||
flipped.reshape(-1, 4), deep=True,
|
||
array_type=_vtk.VTK_UNSIGNED_CHAR)
|
||
vtk_arr.SetNumberOfComponents(4)
|
||
img.GetPointData().SetScalars(vtk_arr)
|
||
except Exception:
|
||
# 폴백: 픽셀 단위 SetScalarComponentFromFloat
|
||
for yy in range(h):
|
||
for xx in range(w):
|
||
r, g, b, a = flipped[yy, xx]
|
||
img.SetScalarComponentFromFloat(xx, yy, 0, 0, r)
|
||
img.SetScalarComponentFromFloat(xx, yy, 0, 1, g)
|
||
img.SetScalarComponentFromFloat(xx, yy, 0, 2, b)
|
||
img.SetScalarComponentFromFloat(xx, yy, 0, 3, a)
|
||
return img
|
||
|
||
# 버튼 행을 화면 너비에 맞게 가운데 정렬 (픽셀 단위, 좌하단 원점)
|
||
ren_win = p.iren.GetRenderWindow() if hasattr(p.iren, 'GetRenderWindow') else \
|
||
getattr(p.iren, 'interactor', p.iren).GetRenderWindow()
|
||
|
||
def _layout_buttons():
|
||
try:
|
||
ws = p.window_size
|
||
win_w = int(ws[0]) if ws and ws[0] > 0 else 1280
|
||
except Exception:
|
||
win_w = 1280
|
||
total_w = NUM_BTN * BTN_PX_W + (NUM_BTN - 1) * BTN_PX_GAP
|
||
start_x = max(4, (win_w - total_w) // 2)
|
||
return [
|
||
(start_x + i * (BTN_PX_W + BTN_PX_GAP), BTN_PX_BOTTOM)
|
||
for i in range(NUM_BTN)
|
||
]
|
||
|
||
# 각 버튼 = vtkButtonWidget + TexturedButtonRepresentation2D
|
||
self._aspect_buttons = [] # [(widget, rep, label, ratio)]
|
||
button_widgets = []
|
||
|
||
if _vtk is not None:
|
||
for i, (label, ratio) in enumerate(RATIOS):
|
||
is_active = (ratio == self.extraction_aspect_ratio)
|
||
rep = _vtk.vtkTexturedButtonRepresentation2D()
|
||
rep.SetNumberOfStates(1)
|
||
vtk_img = _np_to_vtk_image(_make_button_image(label, is_active))
|
||
if vtk_img is not None:
|
||
rep.SetButtonTexture(0, vtk_img)
|
||
# placement (픽셀)
|
||
bds = [0, BTN_PX_W, 0, BTN_PX_H, 0, 0]
|
||
rep.SetPlaceFactor(1.0)
|
||
rep.PlaceWidget(bds)
|
||
|
||
widget = _vtk.vtkButtonWidget()
|
||
widget.SetInteractor(p.iren.interactor if hasattr(p.iren, 'interactor') else p.iren)
|
||
widget.SetRepresentation(rep)
|
||
|
||
# 클로저 — 클릭 시 비율 적용
|
||
def _make_callback(_label=label, _ratio=ratio):
|
||
def _cb(obj, evt):
|
||
self.extraction_aspect_ratio = _ratio
|
||
with contextlib.suppress(Exception):
|
||
self.log(f" 화면비 변경: {_label}")
|
||
if _ratio is not None:
|
||
rw, rh = _ratio
|
||
try:
|
||
cur_w, _cur_h = p.window_size
|
||
cur_w = int(cur_w)
|
||
new_h = max(360, round(cur_w * rh / rw))
|
||
p.window_size = (cur_w, new_h)
|
||
except Exception:
|
||
try:
|
||
cur_w = int(p.window_size[0])
|
||
new_h = max(360, round(cur_w * rh / rw))
|
||
ren_win.SetSize(cur_w, new_h)
|
||
except Exception:
|
||
pass
|
||
_refresh_buttons()
|
||
_reposition_buttons()
|
||
_update_hud_and_save()
|
||
with contextlib.suppress(Exception):
|
||
p.render()
|
||
return _cb
|
||
|
||
widget.AddObserver("StateChangedEvent", _make_callback())
|
||
widget.On()
|
||
button_widgets.append(widget)
|
||
self._aspect_buttons.append((widget, rep, label, ratio))
|
||
|
||
# 위치 재계산 — 창 크기에 맞춰 다시 배치
|
||
def _reposition_buttons():
|
||
if _vtk is None:
|
||
return
|
||
positions = _layout_buttons()
|
||
for (widget, rep, _label, _ratio), (px, py) in zip(self._aspect_buttons, positions, strict=False):
|
||
try:
|
||
bds = [px, px + BTN_PX_W, py, py + BTN_PX_H, 0, 0]
|
||
rep.SetPlaceFactor(1.0)
|
||
rep.PlaceWidget(bds)
|
||
except Exception:
|
||
pass
|
||
|
||
def _refresh_buttons():
|
||
"""활성 버튼 텍스처를 갱신 (활성=금색 강조)."""
|
||
if _vtk is None:
|
||
return
|
||
for (widget, rep, label, ratio) in self._aspect_buttons:
|
||
is_active = (ratio == self.extraction_aspect_ratio)
|
||
try:
|
||
vtk_img = _np_to_vtk_image(_make_button_image(label, is_active))
|
||
if vtk_img is not None:
|
||
rep.SetButtonTexture(0, vtk_img)
|
||
except Exception:
|
||
pass
|
||
|
||
# 초기 배치
|
||
_reposition_buttons()
|
||
|
||
# 창 크기 변경 시 버튼 재배치
|
||
with contextlib.suppress(Exception):
|
||
ren_win.AddObserver("ModifiedEvent", lambda *a: _reposition_buttons())
|
||
|
||
# ─── Enter 키 → 확정 (q는 VTK 기본 동작으로 폴백 유지) ───────────────
|
||
def _on_enter():
|
||
with contextlib.suppress(Exception):
|
||
_update_hud_and_save()
|
||
try:
|
||
p.iren.terminate_app()
|
||
except Exception:
|
||
with contextlib.suppress(Exception):
|
||
p.close()
|
||
|
||
try:
|
||
p.add_key_event("Return", _on_enter)
|
||
p.add_key_event("KP_Enter", _on_enter)
|
||
except Exception:
|
||
pass
|
||
|
||
# 인터랙티브 표시 (블로킹)
|
||
p.show()
|
||
|
||
# show() 후 폴백
|
||
if captured_cam[0] is None:
|
||
try:
|
||
cam = p.camera_position
|
||
if cam:
|
||
captured_cam[0] = (tuple(cam[0]), tuple(cam[1]), tuple(cam[2]))
|
||
except Exception:
|
||
pass
|
||
if captured_win[0] is None:
|
||
try:
|
||
ws = p.window_size
|
||
if ws is not None and len(ws) == 2 and ws[0] > 0 and ws[1] > 0:
|
||
captured_win[0] = (int(ws[0]), int(ws[1]))
|
||
except Exception:
|
||
pass
|
||
|
||
# 사용자 창 크기 → app 상태에 영구 저장 (Step 3/4 캡처에서 화면비로 사용)
|
||
self._saved_window_size = captured_win[0]
|
||
|
||
# 최종 카메라 각도 로그
|
||
if captured_cam[0]:
|
||
elev_deg, azim_deg = 45, 225
|
||
try:
|
||
pos = captured_cam[0][0]
|
||
dx = pos[0] - cx
|
||
dy = pos[1] - cy
|
||
dz = pos[2] - cz
|
||
dist_xy = math.sqrt(dx*dx + dy*dy)
|
||
elev_deg = math.degrees(math.atan2(dz, max(dist_xy, 1e-6)))
|
||
azim_deg = math.degrees(math.atan2(dx, max(dy, 1e-6))) % 360
|
||
except Exception:
|
||
pass
|
||
self.camera_elevation.set(elev_deg)
|
||
self.camera_azimuth.set(azim_deg)
|
||
|
||
return captured_cam[0]
|
||
|
||
def _capture_from_camera(self, width, height, textured=True):
|
||
"""저장된 카메라 위치로 offscreen 캡처 (계획선 오버레이 + DEM 외곽 확장 포함).
|
||
|
||
AI 렌더링용 제어맵(capture_textured.png)은 DXF 범위뿐 아니라
|
||
`tin_extension_mesh`(DEM+위성으로 확장된 외곽 링)도 함께 포함해야 한다.
|
||
이전에는 `self.total_mesh`만 add_mesh되어, 사용자가 지정한 bbox 너머는
|
||
흰 배경으로 비어 있었다 → 조감도 결과도 그 범위에만 그려짐.
|
||
"""
|
||
p = pv.Plotter(off_screen=True, window_size=[width, height])
|
||
p.set_background("white")
|
||
|
||
target = self.total_mesh if textured and self.total_mesh else self.tin_mesh
|
||
tex = None
|
||
if textured and os.path.exists("satellite_temp.png"):
|
||
tex = pv.read_texture("satellite_temp.png")
|
||
|
||
# DEM 외곽 확장 메시 (TIN 뒤쪽 배경 지형) — 캡처에도 포함
|
||
ext_mesh = None
|
||
if textured and self.tin_extension_textured is not None:
|
||
ext_mesh = self.tin_extension_textured
|
||
elif self.tin_extension_mesh is not None:
|
||
ext_mesh = self.tin_extension_mesh
|
||
|
||
# **Seam-free 통합** — show_3d_preview 와 동일 파이프라인. AI 프롬프트용
|
||
# 캡처 이미지에도 사각 경계선이 보이면 AI가 그 선을 실제 지형 피처로
|
||
# 오인할 수 있으므로, 인터랙티브 뷰어와 동일하게 weld+smooth 적용.
|
||
unified = None
|
||
if ext_mesh is not None:
|
||
try:
|
||
merged = target.merge(ext_mesh, merge_points=True, tolerance=0.01)
|
||
if not isinstance(merged, pv.PolyData):
|
||
merged = merged.extract_surface()
|
||
# UV 재적용 — show_3d_preview 와 동일 이유(TCoords 소실 방지).
|
||
uv_params = getattr(self, "_uv_mapping_params", None)
|
||
if textured and tex is not None and uv_params is not None:
|
||
o_uv, p_u, p_v = uv_params
|
||
merged = merged.texture_map_to_plane(
|
||
origin=o_uv, point_u=p_u, point_v=p_v, inplace=False)
|
||
merged.compute_normals(
|
||
feature_angle=180.0, auto_orient_normals=True,
|
||
consistent_normals=True, inplace=True,
|
||
)
|
||
unified = merged
|
||
except Exception as e:
|
||
self.log(f" [capture] 메시 통합 실패, 2-mesh 폴백: {e}")
|
||
unified = None
|
||
|
||
if unified is not None:
|
||
if textured and tex is not None:
|
||
p.add_mesh(unified, texture=tex)
|
||
else:
|
||
p.add_mesh(unified, scalars="Elevation", cmap=_TIN_EARTH_CMAP)
|
||
else:
|
||
if textured and tex is not None:
|
||
p.add_mesh(target, texture=tex)
|
||
else:
|
||
p.add_mesh(target, scalars="Elevation", cmap=_TIN_EARTH_CMAP)
|
||
if ext_mesh is not None:
|
||
try:
|
||
if textured and tex is not None:
|
||
p.add_mesh(ext_mesh, texture=tex,
|
||
show_edges=False, lighting=True)
|
||
else:
|
||
p.add_mesh(ext_mesh, scalars="Elevation", cmap=_TIN_EARTH_CMAP,
|
||
show_edges=False, lighting=True,
|
||
show_scalar_bar=False)
|
||
except Exception as e:
|
||
self.log(f" [capture] 확장 메시 렌더 경고: {e}")
|
||
|
||
# Phase 4: 계획선 오버레이
|
||
self._add_overlay_to_plotter(p)
|
||
|
||
p.enable_eye_dome_lighting()
|
||
|
||
if self._saved_camera:
|
||
p.camera_position = list(self._saved_camera)
|
||
|
||
p.show(auto_close=False)
|
||
img_array = p.screenshot(return_img=True)
|
||
p.close()
|
||
return Image.fromarray(img_array)
|
||
|
||
def _capture_depth_from_camera(self, width, height):
|
||
"""저장된 카메라 위치로 Depth Map 캡처 (DEM 외곽 확장 포함)"""
|
||
p = pv.Plotter(off_screen=True, window_size=[width, height])
|
||
p.set_background("black")
|
||
p.add_mesh(self.tin_mesh, color="white", lighting=True)
|
||
# DEM 외곽 확장 — AI가 DXF 밖 지형도 깊이로 인식하도록 포함
|
||
ext_mesh = self.tin_extension_textured or self.tin_extension_mesh
|
||
if ext_mesh is not None:
|
||
with contextlib.suppress(Exception):
|
||
p.add_mesh(ext_mesh, color="white", lighting=True)
|
||
p.enable_eye_dome_lighting()
|
||
|
||
if self._saved_camera:
|
||
p.camera_position = list(self._saved_camera)
|
||
|
||
p.show(auto_close=False)
|
||
z_buf = p.get_image_depth(fill_value=0.0)
|
||
p.close()
|
||
|
||
valid = z_buf[z_buf > 0]
|
||
if len(valid) == 0:
|
||
return Image.new("L", (width, height), 0)
|
||
|
||
z_min, z_max = valid.min(), valid.max()
|
||
if z_max - z_min < 1e-6:
|
||
normalized = np.zeros_like(z_buf, dtype=np.uint8)
|
||
else:
|
||
normalized = np.clip((z_buf - z_min) / (z_max - z_min), 0, 1)
|
||
normalized = (1.0 - normalized) * 255
|
||
normalized[z_buf <= 0] = 0
|
||
normalized = normalized.astype(np.uint8)
|
||
|
||
return Image.fromarray(normalized)
|
||
|
||
def _capture_lineart_from_camera(self, width, height):
|
||
"""저장된 카메라 위치로 Lineart Map 캡처 (DEM 외곽 확장 포함)"""
|
||
p = pv.Plotter(off_screen=True, window_size=[width, height])
|
||
p.set_background("white")
|
||
p.add_mesh(self.tin_mesh, color="white", edge_color="black",
|
||
show_edges=True, line_width=1, lighting=False)
|
||
ext_mesh = self.tin_extension_textured or self.tin_extension_mesh
|
||
if ext_mesh is not None:
|
||
with contextlib.suppress(Exception):
|
||
p.add_mesh(ext_mesh, color="white", edge_color="black",
|
||
show_edges=True, line_width=1, lighting=False)
|
||
|
||
if self._saved_camera:
|
||
p.camera_position = list(self._saved_camera)
|
||
|
||
p.show(auto_close=False)
|
||
img_array = p.screenshot(return_img=True)
|
||
p.close()
|
||
|
||
img = Image.fromarray(img_array).convert("L")
|
||
img = img.point(lambda x: 0 if x < 200 else 255, '1')
|
||
return img.convert("L")
|
||
|
||
def _compose_guide_image(self, capture, depth, lineart):
|
||
"""캡처 + Depth(반투명) + Lineart(오버레이) 합성"""
|
||
size = capture.size
|
||
|
||
# 모든 맵을 동일 사이즈로 리사이즈
|
||
depth_rgb = depth.convert("RGB").resize(size, Image.LANCZOS)
|
||
lineart_rgb = lineart.convert("RGB").resize(size, Image.LANCZOS)
|
||
|
||
# 캡처를 베이스로
|
||
result = capture.copy()
|
||
|
||
# Depth를 20% 불투명도로 블렌드
|
||
depth_blend = Image.blend(result, depth_rgb, alpha=0.2)
|
||
result = depth_blend
|
||
|
||
# Lineart의 검정 선만 오버레이 (검정 부분만 합성)
|
||
lineart_np = np.array(lineart_rgb)
|
||
result_np = np.array(result)
|
||
|
||
# 선이 있는 곳 (어두운 부분)만 덮어쓰기
|
||
mask = lineart_np.mean(axis=2) < 128
|
||
result_np[mask] = [30, 30, 30] # 진한 회색 선
|
||
|
||
return Image.fromarray(result_np)
|
||
|
||
|
||
# --- Step 4: AI 렌더링 (Stability AI API + Harness) ---
|
||
def _build_render_prompt(self, user_extra=""):
|
||
"""프롬프트 레지스트리(YAML)에서 구조 보존형 프롬프트를 자동 조합"""
|
||
|
||
# Harness 프롬프트 레지스트리 사용 시도
|
||
template = None
|
||
if self.prompt_reg:
|
||
try:
|
||
ver = self.prompt_reg.latest_version()
|
||
if ver:
|
||
template = self.prompt_reg.load_template(ver)
|
||
except Exception:
|
||
pass
|
||
|
||
if template:
|
||
# YAML 기반 프롬프트 조합
|
||
time_key_map = {
|
||
"낮 (Daytime)": "daytime", "석양 (Sunset)": "sunset",
|
||
"밤 (Night)": "night", "새벽 (Dawn)": "dawn", "흐린 날 (Overcast)": "overcast",
|
||
}
|
||
time_key = time_key_map.get(self.time_of_day.get(), "daytime")
|
||
time_desc = template.get("time_presets", {}).get(time_key, "")
|
||
|
||
# 앙각 → 프리셋 매핑
|
||
elev = int(self.camera_elevation.get())
|
||
thresholds = template.get("angle_thresholds", {})
|
||
if elev >= thresholds.get("top_down", 70):
|
||
angle_desc = template.get("angle_presets", {}).get("top_down", "")
|
||
elif elev >= thresholds.get("high_angle", 45):
|
||
angle_desc = template.get("angle_presets", {}).get("high_angle", "")
|
||
elif elev >= thresholds.get("oblique", 30):
|
||
angle_desc = template.get("angle_presets", {}).get("oblique", "")
|
||
else:
|
||
angle_desc = template.get("angle_presets", {}).get("low_angle", "")
|
||
|
||
struct_parts = template.get("structure_preservation", [])
|
||
quality_parts = template.get("quality_enhancement", [])
|
||
|
||
parts = [angle_desc, time_desc, *struct_parts, *quality_parts]
|
||
if user_extra:
|
||
parts.append(user_extra)
|
||
|
||
return ", ".join(p for p in parts if p)
|
||
|
||
# 폴백: 하드코딩 프롬프트
|
||
time_map = {
|
||
"낮 (Daytime)": "bright daylight, clear blue sky, sharp shadows, vivid green vegetation",
|
||
"석양 (Sunset)": "golden hour sunset, warm orange light, long dramatic shadows, glowing sky",
|
||
"밤 (Night)": "nighttime aerial view, moonlight reflections on water, dark blue sky",
|
||
"새벽 (Dawn)": "early dawn, soft pink and purple sky, morning mist over valleys",
|
||
"흐린 날 (Overcast)": "overcast sky, diffused soft light, muted colors, atmospheric fog",
|
||
}
|
||
elev = int(self.camera_elevation.get())
|
||
angle_desc = ("top-down overhead aerial view" if elev >= 70 else
|
||
"high-angle bird's-eye view" if elev >= 45 else
|
||
"oblique aerial perspective" if elev >= 30 else
|
||
"low-angle dramatic perspective")
|
||
time_desc = time_map.get(self.time_of_day.get(), time_map["낮 (Daytime)"])
|
||
|
||
base = (f"Ultra high resolution {angle_desc} of terrain with satellite imagery texture, "
|
||
f"{time_desc}, photorealistic, enhance existing terrain details, "
|
||
"maintain exact terrain shape and layout, 8K sharp detail")
|
||
if user_extra:
|
||
base = f"{base}, {user_extra}"
|
||
return base
|
||
|
||
def _get_negative_prompt(self):
|
||
"""네거티브 프롬프트도 YAML에서 로드"""
|
||
if self.prompt_reg:
|
||
try:
|
||
ver = self.prompt_reg.latest_version()
|
||
if ver:
|
||
template = self.prompt_reg.load_template(ver)
|
||
return template.get("negative_prompt", "")
|
||
except Exception:
|
||
pass
|
||
return ("blurry, low quality, distorted, watermark, text, logo, "
|
||
"cartoon, anime, illustration, painting, sketch, "
|
||
"completely different scene, unrelated content, changed terrain layout")
|
||
|
||
def _get_dxf_hash(self):
|
||
"""현재 DXF 파일의 SHA256 해시"""
|
||
if not self.dxf_path:
|
||
return "unknown"
|
||
try:
|
||
h = hashlib.sha256()
|
||
with open(self.dxf_path, "rb") as f:
|
||
for chunk in iter(lambda: f.read(8192), b""):
|
||
h.update(chunk)
|
||
return h.hexdigest()[:16]
|
||
except Exception:
|
||
return "unknown"
|
||
|
||
def _get_prompt_hash(self, prompt):
|
||
"""프롬프트 텍스트의 해시"""
|
||
return hashlib.sha256(prompt.encode()).hexdigest()[:16]
|
||
|
||
def btn_ai_render_callback(self):
|
||
if not self.guide_image and not self.capture_image:
|
||
messagebox.showwarning("주의", "먼저 제어맵 추출(Step 3)을 수행해야 합니다.")
|
||
return
|
||
|
||
engine = self.render_engine.get()
|
||
|
||
# 시간대 + 출력 화질 선택 (Step 4 실행 시)
|
||
time_win = InlinePanel(self)
|
||
time_win.title("렌더링 옵션")
|
||
time_win.geometry("380x360")
|
||
time_win.grab_set()
|
||
|
||
ctk.CTkLabel(time_win, text="시간대 / 조명", font=ctk.CTkFont(size=14, weight="bold")).pack(pady=(15, 5))
|
||
time_var = ctk.StringVar(value="낮 (Daytime)")
|
||
ctk.CTkOptionMenu(time_win, variable=time_var,
|
||
values=["낮 (Daytime)", "석양 (Sunset)", "밤 (Night)", "새벽 (Dawn)", "흐린 날 (Overcast)"],
|
||
width=300).pack(pady=5)
|
||
|
||
ctk.CTkLabel(time_win, text="추가 프롬프트 (선택)", font=ctk.CTkFont(size=12)).pack(pady=(10, 2))
|
||
extra_entry = ctk.CTkEntry(time_win, width=300, placeholder_text="예: concrete dam, flowing water")
|
||
extra_entry.pack(pady=5)
|
||
|
||
# 출력 화질 — Step 3 화면비와 결합해 최종 픽셀 크기 결정
|
||
ctk.CTkLabel(time_win, text="출력 화질",
|
||
font=ctk.CTkFont(size=12, weight="bold")).pack(pady=(12, 2))
|
||
res_var = ctk.StringVar(value="HD (720p)")
|
||
res_frame = ctk.CTkFrame(time_win, fg_color="transparent")
|
||
for _label in ["HD (720p)", "FHD (1080p)", "UHD (4K)"]:
|
||
ctk.CTkRadioButton(res_frame, text=_label, variable=res_var,
|
||
value=_label).pack(side="left", padx=8)
|
||
res_frame.pack(pady=2)
|
||
|
||
render_opts = [None]
|
||
def on_ok():
|
||
render_opts[0] = (time_var.get(), extra_entry.get() or "", res_var.get())
|
||
time_win.destroy()
|
||
def on_cancel():
|
||
time_win.destroy()
|
||
|
||
btn_f = ctk.CTkFrame(time_win, fg_color="transparent")
|
||
btn_f.pack(pady=15)
|
||
ctk.CTkButton(btn_f, text="취소", width=80, fg_color="transparent", border_width=1, command=on_cancel).pack(side="left", padx=10)
|
||
ctk.CTkButton(btn_f, text="렌더링 시작", width=140, command=on_ok).pack(side="left", padx=10)
|
||
|
||
time_win.wait_window()
|
||
|
||
if render_opts[0] is None:
|
||
return
|
||
|
||
self.time_of_day.set(render_opts[0][0])
|
||
user_extra = render_opts[0][1]
|
||
res_choice = render_opts[0][2]
|
||
final_prompt = self._build_render_prompt(user_extra)
|
||
strength = self.render_strength.get()
|
||
|
||
# 출력 화질 → (target_w, target_h) 계산. Step 3 화면비가 있으면 적용,
|
||
# 자유 모드면 캡처 이미지의 실제 비율 사용.
|
||
RES_HEIGHT = {"HD (720p)": 720, "FHD (1080p)": 1080, "UHD (4K)": 2160}
|
||
target_h = RES_HEIGHT.get(res_choice, 720)
|
||
aspect = self.extraction_aspect_ratio
|
||
if aspect:
|
||
rw, rh = aspect
|
||
target_w = max(8, round(target_h * rw / rh / 8.0) * 8)
|
||
else:
|
||
cap = self.capture_image
|
||
if cap is not None and cap.size[1] > 0:
|
||
cap_w, cap_h = cap.size
|
||
target_w = max(8, round(target_h * cap_w / cap_h / 8.0) * 8)
|
||
else:
|
||
target_w = target_h
|
||
self.target_resolution = (target_w, target_h)
|
||
self.log(f" 목표 해상도: {target_w}x{target_h} ({res_choice})")
|
||
|
||
self.log(f">>> [Step 4] AI 렌더링 시작 ({engine})...")
|
||
self.log(f" 시간대: {self.time_of_day.get()}")
|
||
self.log(f" 변환 강도: {strength:.2f}")
|
||
self.log(f" 프롬프트: {final_prompt[:100]}...")
|
||
|
||
# 뷰포인트 캡처 갱신 — 화면비 락이 있으면 그 비율, 없으면 뷰어 창
|
||
if hasattr(self, '_saved_camera') and self._saved_camera:
|
||
self.log(" 저장된 뷰포인트로 캡처 갱신...")
|
||
out_w, out_h = self._compute_capture_size(1536)
|
||
ar = self.extraction_aspect_ratio
|
||
ar_label = f"비율 {ar[0]}:{ar[1]}" if ar else f"뷰어 창 {self._saved_window_size or '미저장'}"
|
||
self.log(f" 캡처 해상도: {out_w}x{out_h} ({ar_label} 기반)")
|
||
self.capture_image = self._capture_from_camera(out_w, out_h, textured=True)
|
||
self.capture_image.save("capture_textured.png")
|
||
self.depth_map = self._capture_depth_from_camera(out_w, out_h)
|
||
self.depth_map.save("depth_map.png")
|
||
|
||
if "Gemini" in engine:
|
||
key = self.gemini_api_key.get().strip()
|
||
use_vertex = "Vertex" in engine
|
||
# Vertex 모드에서 key가 비어 있으면 gcp-key.json의 project_id로 폴백
|
||
if use_vertex and not key and self._gcp_key_project_id:
|
||
key = self._gcp_key_project_id
|
||
self.gemini_api_key.set(key)
|
||
self.log(f" gcp-key.json에서 project_id 자동 로드: {key}")
|
||
if not key:
|
||
if use_vertex:
|
||
messagebox.showwarning("GCP Project ID 필요",
|
||
"GCP Project ID를 입력해 주세요.\n"
|
||
"또는 프로젝트 루트에 gcp-key.json을 배치하세요.")
|
||
else:
|
||
messagebox.showwarning("API Key 필요",
|
||
"Gemini API 키를 입력해 주세요.\n"
|
||
"aistudio.google.com → API Key에서 무료 발급 가능.")
|
||
return
|
||
# Gemini 3.x 이미지 모델은 location="global"만 허용
|
||
location = self.vertex_location.get().strip() or "global"
|
||
self.set_status(
|
||
f"Gemini 렌더링 중 ({('Vertex AI' if use_vertex else 'API')})...",
|
||
"#F79E1B"
|
||
)
|
||
thread = threading.Thread(
|
||
target=self._run_gemini_render,
|
||
args=(key, final_prompt, use_vertex, location),
|
||
daemon=True
|
||
)
|
||
thread.start()
|
||
else:
|
||
# Stability AI API
|
||
key = self.gemini_api_key.get()
|
||
if not key:
|
||
messagebox.showwarning("API Key 필요",
|
||
"Stability AI API 키를 입력해 주세요.\n"
|
||
"또는 'Gemini (Nano Banana)'로 변경하세요.")
|
||
return
|
||
|
||
self.set_status("AI 렌더링 중... (15~60초 소요)", "#F79E1B")
|
||
thread = threading.Thread(
|
||
target=self._run_stability_render,
|
||
args=(key, final_prompt, strength),
|
||
daemon=True
|
||
)
|
||
thread.start()
|
||
|
||
def _run_gemini_render(self, credential, prompt,
|
||
use_vertex=False, location="us-central1"):
|
||
"""Gemini 자동 호출 + Harness 통합 — 실제 로직은 gemini_renderer 모듈."""
|
||
from gemini_renderer import run_gemini_render
|
||
return run_gemini_render(self, credential, prompt, use_vertex, location)
|
||
|
||
def _run_stability_render(self, api_key, prompt, strength):
|
||
"""Stability AI API 호출 + Harness 통합 (백그라운드 스레드)
|
||
|
||
전략: Conservative/Creative Upscale 우선 → img2img 초저강도 폴백
|
||
원본 위성 텍스처를 최대한 보존하면서 해상도와 디테일만 향상.
|
||
"""
|
||
t_start = _time.time()
|
||
job_id = None
|
||
db = None
|
||
|
||
# Harness: 작업 이력 시작
|
||
dxf_hash = self._get_dxf_hash()
|
||
prompt_hash = self._get_prompt_hash(prompt)
|
||
prompt_ver = "v1"
|
||
seed = 0
|
||
|
||
if self.job_logger:
|
||
try:
|
||
db = get_db_session()
|
||
job = self.job_logger.create_job(db, self.dxf_path or "unknown", dxf_hash)
|
||
job_id = job.id
|
||
seed = self.seed_mgr.get_or_create_seed(db, job_id, dxf_hash)
|
||
if self.prompt_reg:
|
||
prompt_ver = self.prompt_reg.latest_version() or "v1"
|
||
self.job_logger.start_job(db, job_id, seed, prompt_ver, prompt_hash)
|
||
self.after(0, lambda: self.log(f" Harness: job#{job_id}, {SeedManager.describe(seed)}, prompt={prompt_ver}"))
|
||
except Exception as e:
|
||
self.after(0, lambda e=e: self.log(f" Harness 초기화 경고: {e}"))
|
||
|
||
try:
|
||
neg_prompt = self._get_negative_prompt()
|
||
rendered = None
|
||
|
||
# --- 방법 1: Conservative Upscale (원본 보존 최우선) ---
|
||
self.after(0, lambda: self.log(" [1/3] Conservative Upscale (원본 텍스처 보존)..."))
|
||
rendered = self._stability_upscale(api_key, prompt, neg_prompt, seed,
|
||
mode="conservative", creativity=strength)
|
||
|
||
# --- 방법 2: Creative Upscale (약간 더 창의적) ---
|
||
if not rendered:
|
||
self.after(0, lambda: self.log(" [2/3] Creative Upscale 시도..."))
|
||
rendered = self._stability_upscale(api_key, prompt, neg_prompt, seed,
|
||
mode="creative", creativity=min(strength, 0.35))
|
||
|
||
# --- 방법 3: img2img 초저강도 폴백 ---
|
||
if not rendered:
|
||
self.after(0, lambda: self.log(" [3/3] img2img 초저강도 폴백 (strength=0.2)..."))
|
||
rendered = self._stability_img2img(api_key, prompt, neg_prompt,
|
||
min(strength, 0.2), seed)
|
||
|
||
if not rendered:
|
||
if self.job_logger and db and job_id:
|
||
self.job_logger.fail_job(db, job_id, "모든 API 방법 실패")
|
||
self.after(0, lambda: self.log(" 모든 방법 실패"))
|
||
self.after(0, lambda: self.set_status("AI 렌더링 실패", "#EB001B"))
|
||
return
|
||
|
||
# 출력 화질 후처리 — 사용자가 Step 4에서 고른 HD/FHD/UHD 로 리사이즈
|
||
tgt = getattr(self, "target_resolution", None)
|
||
if tgt and tgt[0] > 0 and tgt[1] > 0 and rendered.size != tuple(tgt):
|
||
src_size = rendered.size
|
||
self.after(0, lambda s=src_size, t=tgt: self.log(
|
||
f" 화질 리사이즈: {s[0]}x{s[1]} → {t[0]}x{t[1]}"))
|
||
rendered = rendered.resize(tuple(tgt), Image.LANCZOS)
|
||
|
||
# 성공: 저장
|
||
output_path = "rendered_birdseye.png"
|
||
rendered.save(output_path)
|
||
latency_ms = (_time.time() - t_start) * 1000
|
||
|
||
# Harness: 품질 검증
|
||
quality_score = 0.0
|
||
if self.quality_val:
|
||
try:
|
||
vr = self.quality_val.validate(Path(output_path))
|
||
quality_score = vr.score
|
||
self.after(0, lambda: self.log(f" 품질검증: {vr.summary}"))
|
||
except Exception as e:
|
||
self.after(0, lambda e=e: self.log(f" 품질검증 오류: {e}"))
|
||
|
||
# Harness: 작업 완료 기록
|
||
if self.job_logger and db and job_id:
|
||
with contextlib.suppress(Exception):
|
||
self.job_logger.complete_job(db, job_id, output_path, quality_score, latency_ms)
|
||
|
||
self.after(0, lambda: self.log(
|
||
f" AI 렌더링 완료! → {output_path} ({rendered.size}) "
|
||
f"[{latency_ms:.0f}ms, 품질={quality_score:.2f}]"))
|
||
self.after(0, lambda: self.set_status("AI 렌더링 완료", "#22A06B"))
|
||
self.after(0, lambda: self._show_rendered_result(output_path))
|
||
|
||
except Exception as e:
|
||
if self.job_logger and db and job_id:
|
||
with contextlib.suppress(Exception):
|
||
self.job_logger.fail_job(db, job_id, str(e))
|
||
self.after(0, lambda e=e: self.log(f" 렌더링 오류: {e}"))
|
||
self.after(0, lambda: self.set_status("렌더링 실패", "#EB001B"))
|
||
self.after(0, lambda e=e: messagebox.showerror("오류", f"AI 렌더링 중 오류:\n{e}"))
|
||
finally:
|
||
if db:
|
||
with contextlib.suppress(Exception):
|
||
db.close()
|
||
|
||
def _stability_upscale(self, api_key, prompt, neg_prompt, seed, mode="conservative", creativity=0.3):
|
||
"""Stability AI Upscale API (원본 보존 + 해상도/디테일 향상)
|
||
|
||
mode: "conservative" (원본 충실) 또는 "creative" (디테일 추가)
|
||
creativity: 0.1~0.5 (낮을수록 원본에 가까움)
|
||
"""
|
||
url = f"https://api.stability.ai/v2beta/stable-image/upscale/{mode}"
|
||
|
||
# 캡처 이미지를 적절한 크기로 준비 (upscale 입력은 1MP 이하)
|
||
source_img = self.capture_image if self.capture_image else self.guide_image
|
||
|
||
# 입력 이미지를 768x768 이하로 축소 (upscale이 확대하므로)
|
||
img_resized = source_img.copy()
|
||
img_resized.thumbnail((768, 768), Image.LANCZOS)
|
||
|
||
img_buf = io.BytesIO()
|
||
img_resized.save(img_buf, format="PNG")
|
||
img_buf.seek(0)
|
||
|
||
headers = {
|
||
"Authorization": f"Bearer {api_key}",
|
||
"Accept": "image/*",
|
||
}
|
||
files = {"image": ("capture.png", img_buf, "image/png")}
|
||
data = {
|
||
"prompt": prompt,
|
||
"negative_prompt": neg_prompt,
|
||
"creativity": str(max(0.1, min(0.5, creativity))),
|
||
"seed": str(seed),
|
||
"output_format": "png",
|
||
}
|
||
|
||
try:
|
||
resp = requests.post(url, headers=headers, files=files, data=data, timeout=180)
|
||
|
||
if resp.status_code == 200 and "image" in resp.headers.get("Content-Type", ""):
|
||
result_img = Image.open(io.BytesIO(resp.content))
|
||
self.after(0, lambda m=mode, s=result_img.size: self.log(
|
||
f" {m} Upscale 성공! {s[0]}x{s[1]}"))
|
||
return result_img
|
||
else:
|
||
detail = resp.text[:200] if resp.text else str(resp.status_code)
|
||
self.after(0, lambda m=mode, d=detail, sc=resp.status_code: self.log(
|
||
f" {m} Upscale [{sc}]: {d}"))
|
||
return None
|
||
except Exception as e:
|
||
self.after(0, lambda m=mode, e=e: self.log(f" {m} Upscale 오류: {e}"))
|
||
return None
|
||
|
||
def _stability_img2img(self, api_key, prompt, neg_prompt, strength, seed=0):
|
||
"""Stability AI Image-to-Image API (위성 텍스처 캡처 이미지 기반)"""
|
||
url = "https://api.stability.ai/v2beta/stable-image/generate/sd3"
|
||
|
||
# 위성 텍스처가 입힌 캡처 이미지 사용 (guide_image 대신 capture_image)
|
||
source_img = self.capture_image if self.capture_image else self.guide_image
|
||
|
||
img_buf = io.BytesIO()
|
||
img_resized = source_img.resize((1024, 1024), Image.LANCZOS)
|
||
img_resized.save(img_buf, format="PNG")
|
||
img_buf.seek(0)
|
||
|
||
headers = {
|
||
"Authorization": f"Bearer {api_key}",
|
||
"Accept": "image/*",
|
||
}
|
||
files = {"image": ("capture.png", img_buf, "image/png")}
|
||
data = {
|
||
"prompt": prompt,
|
||
"negative_prompt": neg_prompt,
|
||
"strength": str(strength),
|
||
"seed": str(seed),
|
||
"output_format": "png",
|
||
"model": "sd3.5-large",
|
||
"mode": "image-to-image",
|
||
}
|
||
|
||
try:
|
||
resp = requests.post(url, headers=headers, files=files, data=data, timeout=120)
|
||
if resp.status_code == 200 and "image" in resp.headers.get("Content-Type", ""):
|
||
return Image.open(io.BytesIO(resp.content))
|
||
else:
|
||
detail = resp.text[:300] if resp.text else str(resp.status_code)
|
||
self.after(0, lambda d=detail, sc=resp.status_code: self.log(f" img2img [{sc}]: {d}"))
|
||
self.after(0, lambda d=detail, sc=resp.status_code: messagebox.showerror("API 오류",
|
||
f"Stability AI 호출 실패 ({sc}):\n{d}"))
|
||
return None
|
||
except Exception as e:
|
||
self.after(0, lambda e=e: self.log(f" img2img 오류: {e}"))
|
||
return None
|
||
|
||
|
||
def _show_structure_render(self, image_path):
|
||
"""Blender 구조물 렌더 결과를 별도 창에 표시 (AI 결과와 분리).
|
||
|
||
blender_renderer.py 가 호출. 투명 PNG도 정상 표시(체커보드 배경).
|
||
"""
|
||
try:
|
||
from PIL import ImageTk, Image as _PILImage
|
||
try:
|
||
pil_img = _PILImage.open(image_path)
|
||
except Exception as e:
|
||
messagebox.showerror("이미지 열기 실패",
|
||
f"렌더 결과 PNG를 열 수 없습니다:\n{image_path}\n\n{e}")
|
||
return
|
||
|
||
# 투명 PNG는 체커보드 배경 위에 합성 표시
|
||
display_img = pil_img
|
||
if pil_img.mode == "RGBA":
|
||
from PIL import ImageDraw
|
||
tile = _PILImage.new("RGB", (16, 16), (200, 200, 200))
|
||
d = ImageDraw.Draw(tile)
|
||
d.rectangle((0, 0, 7, 7), fill=(170, 170, 170))
|
||
d.rectangle((8, 8, 15, 15), fill=(170, 170, 170))
|
||
bg = _PILImage.new("RGB", pil_img.size, (200, 200, 200))
|
||
for y in range(0, pil_img.size[1], 16):
|
||
for x in range(0, pil_img.size[0], 16):
|
||
bg.paste(tile, (x, y))
|
||
display_img = _PILImage.alpha_composite(
|
||
bg.convert("RGBA"), pil_img
|
||
).convert("RGB")
|
||
|
||
win = InlinePanel(self)
|
||
win.title(f"🎨 Blender 렌더 결과 - {Path(image_path).name}")
|
||
|
||
sw = self.winfo_screenwidth(); sh = self.winfo_screenheight()
|
||
max_w, max_h = int(sw * 0.7), int(sh * 0.75)
|
||
iw, ih = display_img.size
|
||
scale = min(max_w / iw, max_h / ih, 1.0)
|
||
disp_w, disp_h = int(iw * scale), int(ih * scale)
|
||
disp = display_img.resize((disp_w, disp_h), _PILImage.LANCZOS)
|
||
|
||
tk_img = ImageTk.PhotoImage(disp)
|
||
lbl = ctk.CTkLabel(win, text="", image=tk_img)
|
||
lbl.image = tk_img
|
||
lbl.pack(padx=10, pady=10)
|
||
|
||
info = ctk.CTkFrame(win, fg_color="transparent")
|
||
info.pack(fill="x", padx=10, pady=(0, 5))
|
||
mode_str = "투명 배경 (RGBA, 합성용)" if pil_img.mode == "RGBA" else "Sky 배경 (RGB)"
|
||
ctk.CTkLabel(
|
||
info,
|
||
text=f"파일: {image_path} · 원본 {iw}×{ih} · {mode_str}",
|
||
font=ctk.CTkFont(size=11),
|
||
).pack(side="left", padx=5)
|
||
|
||
btnf = ctk.CTkFrame(win, fg_color="transparent")
|
||
btnf.pack(fill="x", padx=10, pady=(0, 10))
|
||
|
||
def _open_external():
|
||
try:
|
||
if sys.platform == "win32":
|
||
os.startfile(image_path)
|
||
elif sys.platform == "darwin":
|
||
import subprocess
|
||
subprocess.Popen(["open", image_path])
|
||
else:
|
||
import subprocess
|
||
subprocess.Popen(["xdg-open", image_path])
|
||
except Exception as e:
|
||
messagebox.showerror("열기 실패",
|
||
f"기본 뷰어 실행 실패:\n{e}", parent=win)
|
||
|
||
def _open_folder():
|
||
try:
|
||
folder = str(Path(image_path).resolve().parent)
|
||
if sys.platform == "win32":
|
||
os.startfile(folder)
|
||
elif sys.platform == "darwin":
|
||
import subprocess
|
||
subprocess.Popen(["open", folder])
|
||
else:
|
||
import subprocess
|
||
subprocess.Popen(["xdg-open", folder])
|
||
except Exception as e:
|
||
messagebox.showerror("폴더 열기 실패", f"{e}", parent=win)
|
||
|
||
ctk.CTkButton(btnf, text="📂 폴더 열기", width=110,
|
||
command=_open_folder).pack(side="right", padx=3)
|
||
ctk.CTkButton(btnf, text="🖼 외부 뷰어로 열기", width=160,
|
||
command=_open_external).pack(side="right", padx=3)
|
||
ctk.CTkButton(btnf, text="닫기", width=80,
|
||
fg_color="transparent", border_width=1,
|
||
command=win.destroy).pack(side="left", padx=3)
|
||
|
||
except Exception as e:
|
||
import traceback
|
||
traceback.print_exc()
|
||
messagebox.showerror("결과 창 오류", f"렌더 결과 창 생성 실패:\n{e}")
|
||
|
||
def _show_rendered_result(self, image_path):
|
||
"""렌더링 결과를 별도 창에서 표시"""
|
||
try:
|
||
from PIL import ImageTk
|
||
|
||
win = InlinePanel(self)
|
||
win.title("S-CANVAS: AI 렌더링 결과")
|
||
win.geometry("820x860")
|
||
|
||
img = Image.open(image_path)
|
||
display_size = 800
|
||
img_display = img.copy()
|
||
img_display.thumbnail((display_size, display_size), Image.LANCZOS)
|
||
|
||
photo = ImageTk.PhotoImage(img_display)
|
||
|
||
label = ctk.CTkLabel(win, text="", image=photo)
|
||
label.image = photo # 참조 유지
|
||
label.pack(padx=10, pady=10)
|
||
|
||
# 저장 버튼
|
||
def save_hires():
|
||
save_path = filedialog.asksaveasfilename(
|
||
defaultextension=".png",
|
||
filetypes=[("PNG", "*.png"), ("JPEG", "*.jpg")],
|
||
initialfile="S-CANVAS_rendered.png"
|
||
)
|
||
if save_path:
|
||
img.save(save_path)
|
||
self.log(f" 고해상도 저장: {save_path}")
|
||
|
||
btn_save = ctk.CTkButton(win, text="고해상도 저장", command=save_hires, height=40)
|
||
btn_save.pack(pady=(0, 10))
|
||
|
||
except Exception as e:
|
||
self.log(f" 결과 표시 오류: {e}")
|
||
|
||
if __name__ == "__main__":
|
||
# 크래시 핸들러 — Python 미처리 예외 + thread 예외 + faulthandler. logs/ 에 회전 저장.
|
||
try:
|
||
from harness.crash_logger import install_crash_handlers
|
||
_log_dir = install_crash_handlers()
|
||
print(f"[crash_logger] logs → {_log_dir}")
|
||
except Exception as _ch_err:
|
||
print(f"[crash_logger] 설치 실패 (계속 진행): {_ch_err}")
|
||
|
||
app = SCanvasApp()
|
||
app.mainloop()
|