import customtkinter as ctk import datetime import hashlib import os import sys import io import base64 import json 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 # 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 # 구조물 상세도면 치수 파서 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 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}") # 폰트 에러 방지를 위한 처리 import logging 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) — 캡처 화면비 보존용 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, "r", 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_API_KEY` 우선 로드, 없으면 빈 칸. # 사이드바에서 사용자가 직접 입력 가능. (공개 repo 보안상 하드코딩 금지) self.vworld_api_key = ctk.StringVar(value=os.environ.get("VWORLD_API_KEY", "")) # 그리드 레이아웃 설정 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="#E67E22", hover_color="#BA6116", 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="#27AE60", hover_color="#1E8449", 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=("#343A40", "#2C3E50"), hover_color=("#212529", "#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) self.main_frame.grid_rowconfigure(0, weight=3) # 지도 (넓게) self.main_frame.grid_rowconfigure(1, weight=1) # 로그 (좁게) # 1. 지도 (상단 — 넓게). Light/Dark 테마별 배경 쌍 — tkintermapview # 타일 주변의 얇은 padding 에 이 색이 보임. self.map_frame = ctk.CTkFrame( self.main_frame, corner_radius=12, fg_color=("#FFFFFF", "#2b2b2b"), 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. 로그 (하단 — 스크롤 가능, 높이 줄임) self.textbox = ctk.CTkTextbox(self.main_frame, height=120, font=ctk.CTkFont(family="Consolas", size=12), border_width=1) self.textbox.grid(row=1, column=0, padx=0, pady=0, sticky="nsew") # 3. 하단 상태 바 self.status_bar = ctk.CTkFrame(self.main_frame, height=28, fg_color="transparent") self.status_bar.grid(row=2, column=0, sticky="ew", pady=(5, 0)) self.status_indicator = ctk.CTkLabel(self.status_bar, text="● READY", text_color="#2ECC71", 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") self.log("S-CANVAS Generative Design Engine 구동 완료.") 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, h = pil.size # 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): timestamp = datetime.datetime.now().strftime("[%H:%M:%S]") def _update(): self.textbox.insert("end", f"{timestamp} {message}\n") self.textbox.see("end") self.after(0, _update) 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="#2ECC71"): 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": "#E74C3C"}, "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 = ctk.CTkToplevel(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(f"=== 레이어 분류 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 = "#2ECC71" 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(f" ★ 자동 감지 — 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(f" ★ 자동 감지 — 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'): for edge in boundary_path.edges: if hasattr(edge, 'start') and hasattr(edge, 'end'): geoms.append({"type": "line", "start": (edge.start[0], edge.start[1]), "end": (edge.end[0], edge.end[1])}) 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: try: orientation_deg = compute_orientation_from_points(body_pts) except Exception: pass 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 = ctk.CTkToplevel(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)): 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="#2ECC71") 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="#E67E22", 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 = ctk.CTkToplevel(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: try: p.add_mesh(mesh, color=color, opacity=opacity, smooth_shading=True) except Exception: pass 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_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 = ctk.CTkToplevel(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 = ("#27AE60" if score_f >= 0.85 else "#F39C12" if score_f >= 0.6 else "#E74C3C") 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=f" (모델: 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="#E74C3C", 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="#E67E22", 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'])}건") # 엔트리 위젯 갱신 (스칼라 필드 반영) try: _populate_entries(state["params"]) except Exception: pass # 자동 재빌드 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="#27AE60", hover_color="#1E8449", 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: if 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="#27AE60", hover_color="#1E8449", 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="재파싱", 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 = ctk.CTkToplevel(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="#2ECC71") 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(f" 치수 인식 결과 없음") 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 = ctk.CTkToplevel(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 = "#2ECC71" if dim.confidence >= 0.9 else "#F1C40F" if dim.confidence >= 0.8 else "#E74C3C" 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: try: confirmed[pname] = float(val) except ValueError: pass 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) colors = ["red", "blue", "green", "orange", "purple", "cyan"] ci = 0 for ln, ld in target_layers.items(): color = colors[ci % len(colors)] ci += 1 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 = ctk.CTkToplevel(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: try: m.delete() except Exception: pass 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 matplotlib.path import Path as MplPath 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 layer_name, info in self.structure_registry.items(): 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 poly_path = MplPath(poly_pts) # 폴리곤 크기 기반 파라미터 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) if mode == "cut": road_z = terrain_z - offset_m else: # fill road_z = 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 사용) depth_local = abs(terrain_z - (road_z + self.origin[2])) if mode == "fill": depth_local = abs((road_z + self.origin[2]) - terrain_z) # local Z 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, Delaunay 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 # 사면이 지형에 도달하면 지형 유지 if target_z >= terrain_z_here: target_z = terrain_z_here 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 if target_z <= terrain_z_here: target_z = terrain_z_here 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): for x, y in xy_iter: if not (clip_xmin_raw <= x <= clip_xmax_raw and clip_ymin_raw <= y <= clip_ymax_raw): return False return True 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 버퍼)으로 훨씬 넓게 다운로드된 이미지에 대해 도로· 사면이 안쪽에 압축되어 **확대된 것처럼** 그려진다. """ from PIL import ImageDraw, ImageFilter 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_name, layer_data in self.layer_geometries.items(): 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, None) 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 분석 중...", "#F1C40F") # 진단 로그 초기화 (세션 시작) 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}개", "#2ECC71") self.btn_step2.configure(fg_color=["#3a7ebf", "#1f538d"], border_width=0) self.show_3d_preview(textured=False) except Exception as e: self.log(f"오류: {str(e)}") self.set_status("오류 발생", "#E74C3C") 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 for p in entity.get_points(): points.append([p[0], p[1], z]) # 2. LINE 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') try: for pt in entity.control_points: points.append([pt[0], pt[1], pt[2] if len(pt) > 2 else 0.0]) except Exception: pass # 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(f" ⚠ 지원되지 않는 엔티티 타입입니다. 다른 레이어를 '지형'으로 지정해보세요.") # 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 = [] 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(f" [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(f" [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( f" TIN 경계 벽 컷 v6: 제거 대상 없음 " f"(4변 모든 접촉 삼각형 slope_ratio≤1.5 또는 z_span≤5m) " f"— 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 = ctk.CTkToplevel(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="terrain", 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="#E74C3C", 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: try: drag["live_rect"].remove() except Exception: pass 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="#E74C3C", alpha=0.18, edgecolor="#E74C3C", 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="#2ECC71", hover_color="#27AE60", text_color="white", font=ctk.CTkFont(size=16, weight="bold")) submit_btn.pack(side="top", fill="x", padx=10, pady=10) # Enter 키로도 제출 win.bind("", lambda _e: _on_confirm()) win.bind("", 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()) dz_core = 0.0 # core 는 불변 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 확장 중...", "#F1C40F") 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 확장 실패", "#E74C3C") 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 완료 — 위성지도 결합 준비", "#2ECC71") 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(f" [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("위성 이미지 다운로드 중...", "#F1C40F") 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) 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("위성지도 결합 완료", "#2ECC71") 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("결합 실패", "#E74C3C") 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="terrain", 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="terrain", 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="terrain", 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() try: p.reset_camera(bounds=scene_bounds) except Exception: pass 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(f" └ 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) 계산. - long-side 를 max_long_side 로 캡, 짧은쪽은 비율 보존 후 8배수 정렬. - `_saved_window_size` 가 없으면 정사각형 폴백 (구버전 호환). """ 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, int(round(max_long_side * h / w / 8.0)) * 8) else: out_h = max_long_side out_w = max(8, int(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(" ◆ 원하는 뷰가 잡히면 'q' 키를 누르거나 창을 닫으세요 → 그 뷰로 캡처됩니다") self.set_status("뷰포인트를 선택하세요 (q로 확정)", "#F1C40F") try: # 인터랙티브 3D 뷰어 열기 — 사용자가 자유롭게 회전/줌 self._saved_camera = self._open_interactive_viewer() if self._saved_camera is None: self.log(" 뷰포인트 선택 취소됨.") self.set_status("뷰포인트 미선택", "#E74C3C") return # 선택된 카메라 위치 로그 cam_pos, cam_focal, cam_up = self._saved_camera self.log(f" 카메라 위치 확정: pos={[f'{v:.0f}' for v in cam_pos]}") # 확정된 뷰로 제어맵 추출 — 뷰어 창의 화면비를 그대로 보존 self.log(" 확정된 뷰로 캡처 시작...") out_w, out_h = self._compute_capture_size(1536) self.log(f" 캡처 해상도: {out_w}x{out_h} " f"(뷰어 창 {self._saved_window_size or '미저장'} 기반)") # 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("제어맵 추출 완료", "#2ECC71") 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("추출 실패", "#E74C3C") 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="terrain", 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="terrain", 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: 카메라 정보 텍스트 (좌상단) hud_actor = p.add_text( "마우스로 회전/줌 → 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] hud_text = ( f"마우스로 회전/줌 → q로 확정\n" f"앙각: {elev_deg:.0f}° 방위: {azim_deg:.0f}°({dir_label}) 줌: {zoom_ratio:.1f}x" ) 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) # 인터랙티브 표시 (블로킹) 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, zoom_ratio = 45, 225, 1.0 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="terrain") else: if textured and tex is not None: p.add_mesh(target, texture=tex) else: p.add_mesh(target, scalars="Elevation", cmap="terrain") 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="terrain", 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: try: p.add_mesh(ext_mesh, color="white", lighting=True) except Exception: pass 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: try: p.add_mesh(ext_mesh, color="white", edge_color="black", show_edges=True, line_width=1, lighting=False) except Exception: pass 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 = ctk.CTkToplevel(self) time_win.title("렌더링 옵션") time_win.geometry("360x250") 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=280).pack(pady=5) ctk.CTkLabel(time_win, text="추가 프롬프트 (선택)", font=ctk.CTkFont(size=12)).pack(pady=(10, 2)) extra_entry = ctk.CTkEntry(time_win, width=280, placeholder_text="예: concrete dam, flowing water") extra_entry.pack(pady=5) render_opts = [None] def on_ok(): render_opts[0] = (time_var.get(), extra_entry.get() or "") 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] final_prompt = self._build_render_prompt(user_extra) strength = self.render_strength.get() 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) self.log(f" 캡처 해상도: {out_w}x{out_h} " f"(뷰어 창 {self._saved_window_size or '미저장'} 기반)") 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')})...", "#F1C40F" ) 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초 소요)", "#F1C40F") 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: 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 렌더링 실패", "#E74C3C")) return # 성공: 저장 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: self.log(f" 품질검증 오류: {e}")) # Harness: 작업 완료 기록 if self.job_logger and db and job_id: try: self.job_logger.complete_job(db, job_id, output_path, quality_score, latency_ms) except Exception: pass 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 렌더링 완료", "#2ECC71")) self.after(0, lambda: self._show_rendered_result(output_path)) except Exception as e: if self.job_logger and db and job_id: try: self.job_logger.fail_job(db, job_id, str(e)) except Exception: pass self.after(0, lambda: self.log(f" 렌더링 오류: {e}")) self.after(0, lambda: self.set_status("렌더링 실패", "#E74C3C")) self.after(0, lambda: messagebox.showerror("오류", f"AI 렌더링 중 오류:\n{e}")) finally: if db: try: db.close() except Exception: pass 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: 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: self.log(f" img2img 오류: {e}")) return None def _show_rendered_result(self, image_path): """렌더링 결과를 별도 창에서 표시""" try: from PIL import ImageTk win = ctk.CTkToplevel(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__": # 인트로 스플래시 — Design/logo_intro.mp4 재생 후 메인 앱 기동. # 실패·파일 없음 시 조용히 skip(메인 앱은 항상 뜸). try: from splash import show_intro_splash show_intro_splash( resource_path("Design", "logo_intro.mp4"), max_duration_s=12.0, fade_ms=400, ) except Exception as _splash_err: print(f"[Intro] 스플래시 경고: {_splash_err}") app = SCanvasApp() app.mainloop()