S-CANVAS (Saman Corp.) — DXF + DEM + AI 기반 3D 조감도 생성 엔진. ~24k LOC Python (scanvas_maker.py 7072 LOC GUI + 구조물 파서/빌더 다수). 이 커밋은 7-iter cleanup이 적용된 상태로 import: - F821 8 + B023 6: 비동기 lambda + except/loop 변수 캡처 NameError (Py3.13에서 reproduce 확인된 진짜 버그) - RUF012 4 + RUF013 1: ClassVar / implicit Optional 명시화 - F811/B905/B904/F401/F841/W293/F541/UP/SIM/RUF/PLR 700+ cleanup/modernization 신규 파일: - ruff.toml: target=py313, Korean unicode/저자 스타일/도메인 복잡도 무력화 - requirements-py313.txt: pyproj>=3.7, scipy>=1.14, numpy>=2.0.2 (Py3.13 wheel) - .gitignore: gcp-key.json, 캐시, 백업, 생성 이미지 제외 검증: ruff 0 errors, py_compile 0 errors, import 33/33 OK on Py3.13.13. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1081 lines
40 KiB
Python
1081 lines
40 KiB
Python
"""구조물 조감도 생성 UI 앱.
|
||
|
||
독립적인 CustomTkinter 애플리케이션.
|
||
|
||
워크플로우:
|
||
Step 1: DXF 파일 업로드 (다중 파일 가능)
|
||
Step 2: 구조물 유형 선택 (수문/건물/옹벽/교량/터널/일반)
|
||
Step 3: 자동 파싱 결과 확인 & 파라미터 편집
|
||
Step 4: 3D 미리보기 (인터랙티브 또는 캡처)
|
||
Step 5: 저장 / AI 렌더링
|
||
|
||
실행:
|
||
python structure_ui.py
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import sys
|
||
import math
|
||
import json
|
||
import threading
|
||
from pathlib import Path
|
||
from tkinter import filedialog, messagebox
|
||
|
||
import customtkinter as ctk
|
||
import numpy as np
|
||
import pyvista as pv
|
||
from PIL import Image
|
||
|
||
from structure_templates import (
|
||
REGISTRY,
|
||
StructureTemplate,
|
||
StructureParams,
|
||
ParamField,
|
||
)
|
||
|
||
try:
|
||
from filename_classifier import suggest_with_confidence
|
||
FILENAME_CLASSIFIER_AVAILABLE = True
|
||
except ImportError:
|
||
FILENAME_CLASSIFIER_AVAILABLE = False
|
||
|
||
|
||
# UI 테마 설정
|
||
ctk.set_appearance_mode("dark")
|
||
ctk.set_default_color_theme("blue")
|
||
|
||
|
||
class StructureUIApp(ctk.CTk):
|
||
"""구조물 조감도 생성 UI 메인 앱."""
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
|
||
self.title("EG-VIEW Structure: 구조물 조감도 생성기")
|
||
self.geometry("1240x820")
|
||
|
||
# 상태 변수
|
||
self.dxf_paths: list[str] = []
|
||
self.current_template: StructureTemplate | None = None
|
||
self.current_params: StructureParams | None = None
|
||
self.current_meshes: list = []
|
||
self.param_entries: dict[str, ctk.CTkEntry] = {}
|
||
self.current_capture: Image.Image | None = None
|
||
self.ai_rendered: Image.Image | None = None
|
||
|
||
# 실시간 렌더링용 지속 plotter
|
||
self._live_plotter: pv.Plotter | None = None
|
||
self._live_size: int = 600 # 실시간 미리보기 해상도 (빠른 응답을 위해 작게)
|
||
self._render_after_id = None # 디바운싱용 after() ID
|
||
self._render_in_progress = False
|
||
|
||
# 렌더 설정
|
||
self.elevation_var = ctk.DoubleVar(value=35.0)
|
||
self.azimuth_var = ctk.DoubleVar(value=225.0)
|
||
self.zoom_var = ctk.DoubleVar(value=1.0)
|
||
self.time_var = ctk.StringVar(value="낮 (Daytime)")
|
||
self.api_key_var = ctk.StringVar(
|
||
value=os.environ.get("GCP_PROJECT_ID", "")
|
||
or os.environ.get("GEMINI_API_KEY", "")
|
||
)
|
||
self.use_vertex_var = ctk.BooleanVar(
|
||
value=bool(os.environ.get("GCP_PROJECT_ID", ""))
|
||
)
|
||
self.vertex_location_var = ctk.StringVar(
|
||
value=os.environ.get("GCP_LOCATION", "us-central1")
|
||
)
|
||
|
||
self._build_layout()
|
||
|
||
# 앱 종료 시 plotter 정리
|
||
self.protocol("WM_DELETE_WINDOW", self._on_close)
|
||
|
||
# ----------------------------------------------------------------------
|
||
# 레이아웃
|
||
# ----------------------------------------------------------------------
|
||
|
||
def _build_layout(self):
|
||
self.grid_columnconfigure(0, weight=0, minsize=420)
|
||
self.grid_columnconfigure(1, weight=1)
|
||
self.grid_rowconfigure(0, weight=1)
|
||
|
||
# --- 왼쪽 사이드바 ---
|
||
sidebar = ctk.CTkScrollableFrame(self, corner_radius=0, width=400)
|
||
sidebar.grid(row=0, column=0, sticky="nsew", padx=0, pady=0)
|
||
self.sidebar = sidebar
|
||
|
||
# 로고
|
||
ctk.CTkLabel(
|
||
sidebar, text="EG-VIEW Structure",
|
||
font=ctk.CTkFont(size=22, weight="bold"),
|
||
).pack(padx=20, pady=(20, 0), anchor="w")
|
||
ctk.CTkLabel(
|
||
sidebar, text="토목 구조물 도면 → 3D 조감도",
|
||
font=ctk.CTkFont(size=11, slant="italic"),
|
||
text_color="gray",
|
||
).pack(padx=20, pady=(0, 15), anchor="w")
|
||
|
||
self._build_step1_upload(sidebar)
|
||
self._build_step2_template(sidebar)
|
||
self._build_step3_params(sidebar)
|
||
self._build_step4_render(sidebar)
|
||
self._build_step5_export(sidebar)
|
||
|
||
# --- 오른쪽 메인 영역 ---
|
||
main = ctk.CTkFrame(self, corner_radius=10)
|
||
main.grid(row=0, column=1, sticky="nsew", padx=10, pady=10)
|
||
main.grid_columnconfigure(0, weight=1)
|
||
main.grid_rowconfigure(0, weight=3)
|
||
main.grid_rowconfigure(1, weight=1)
|
||
|
||
# 이미지 프리뷰
|
||
self.preview_label = ctk.CTkLabel(
|
||
main, text="3D 미리보기는 구조물 빌드 후 표시됩니다",
|
||
font=ctk.CTkFont(size=14),
|
||
fg_color="#1e1e1e", corner_radius=8,
|
||
)
|
||
self.preview_label.grid(row=0, column=0, sticky="nsew", padx=10, pady=(10, 5))
|
||
|
||
# 로그
|
||
self.log_box = ctk.CTkTextbox(
|
||
main, height=180, font=ctk.CTkFont(family="Consolas", size=11),
|
||
)
|
||
self.log_box.grid(row=1, column=0, sticky="nsew", padx=10, pady=(5, 10))
|
||
|
||
self.log("EG-VIEW Structure 앱 시작. 구조물 DXF 파일을 업로드하세요.")
|
||
|
||
# ----------------------------------------------------------------------
|
||
# Step 1: 파일 업로드
|
||
# ----------------------------------------------------------------------
|
||
|
||
def _build_step1_upload(self, parent):
|
||
frame = self._section_frame(parent, "Step 1: DXF 파일 업로드")
|
||
|
||
self.file_listbox = ctk.CTkTextbox(
|
||
frame, height=90, font=ctk.CTkFont(family="Consolas", size=11),
|
||
)
|
||
self.file_listbox.pack(fill="x", padx=10, pady=(5, 5))
|
||
self.file_listbox.insert("1.0", "(파일 없음)")
|
||
|
||
btn_row = ctk.CTkFrame(frame, fg_color="transparent")
|
||
btn_row.pack(fill="x", padx=10, pady=(0, 10))
|
||
|
||
ctk.CTkButton(
|
||
btn_row, text="파일 추가", width=100,
|
||
command=self._on_add_files,
|
||
).pack(side="left", padx=2)
|
||
ctk.CTkButton(
|
||
btn_row, text="목록 초기화", width=100, fg_color="transparent",
|
||
border_width=1, command=self._on_clear_files,
|
||
).pack(side="left", padx=2)
|
||
|
||
def _on_add_files(self):
|
||
paths = filedialog.askopenfilenames(
|
||
title="구조물 DXF 파일 선택",
|
||
filetypes=[("AutoCAD DXF", "*.dxf"), ("All Files", "*.*")],
|
||
)
|
||
if not paths:
|
||
return
|
||
for p in paths:
|
||
if p not in self.dxf_paths:
|
||
self.dxf_paths.append(p)
|
||
self._refresh_file_list()
|
||
|
||
# 파일명으로 구조물 유형 자동 추정
|
||
if FILENAME_CLASSIFIER_AVAILABLE and self.dxf_paths:
|
||
self._auto_suggest_template()
|
||
|
||
def _auto_suggest_template(self):
|
||
"""업로드된 파일명들에서 구조물 유형 추정 → 드롭다운 자동 선택."""
|
||
# 각 파일마다 추정하고 최다 득표 수집
|
||
votes = {} # {template_id: total_confidence}
|
||
for path in self.dxf_paths:
|
||
tid, conf = suggest_with_confidence(path)
|
||
if tid:
|
||
votes[tid] = votes.get(tid, 0.0) + conf
|
||
|
||
if not votes:
|
||
self.log(" 파일명에서 구조물 유형을 추정하지 못함. 수동 선택하세요.")
|
||
return
|
||
|
||
# 최고 득표
|
||
best_tid = max(votes.items(), key=lambda x: x[1])[0]
|
||
best_score = votes[best_tid]
|
||
|
||
# 현재 선택이 다르면 변경
|
||
tpl = REGISTRY.get(best_tid)
|
||
if tpl:
|
||
current_name = self.template_var.get()
|
||
target_name = tpl.name_ko
|
||
if current_name != target_name:
|
||
self.template_var.set(target_name)
|
||
self._on_template_changed(target_name)
|
||
self.log(f" 🔍 파일명 분석 → [{tpl.name_ko}] 자동 선택 (신뢰도 {best_score:.0%})")
|
||
else:
|
||
self.log(f" 🔍 파일명 확인 → [{tpl.name_ko}] 유형 일치 (신뢰도 {best_score:.0%})")
|
||
|
||
def _on_clear_files(self):
|
||
self.dxf_paths = []
|
||
self._refresh_file_list()
|
||
|
||
def _refresh_file_list(self):
|
||
self.file_listbox.delete("1.0", "end")
|
||
if not self.dxf_paths:
|
||
self.file_listbox.insert("1.0", "(파일 없음)")
|
||
else:
|
||
for i, p in enumerate(self.dxf_paths, 1):
|
||
self.file_listbox.insert("end", f"{i}. {os.path.basename(p)}\n")
|
||
|
||
# ----------------------------------------------------------------------
|
||
# Step 2: 템플릿 선택
|
||
# ----------------------------------------------------------------------
|
||
|
||
def _build_step2_template(self, parent):
|
||
frame = self._section_frame(parent, "Step 2: 구조물 유형 선택")
|
||
|
||
choices = REGISTRY.list_choices()
|
||
self.template_var = ctk.StringVar(value=choices[0][1])
|
||
|
||
# 이름→ID 매핑
|
||
self._template_name_to_id = {name: tid for tid, name in choices}
|
||
|
||
self.template_menu = ctk.CTkOptionMenu(
|
||
frame, variable=self.template_var,
|
||
values=[name for _, name in choices],
|
||
command=self._on_template_changed,
|
||
width=360,
|
||
)
|
||
self.template_menu.pack(fill="x", padx=10, pady=(5, 5))
|
||
|
||
self.template_desc = ctk.CTkLabel(
|
||
frame, text="", font=ctk.CTkFont(size=11),
|
||
text_color="gray", wraplength=360, justify="left",
|
||
)
|
||
self.template_desc.pack(fill="x", padx=10, pady=(0, 5))
|
||
|
||
ctk.CTkButton(
|
||
frame, text="자동 파싱 실행", height=32,
|
||
command=self._on_parse,
|
||
).pack(fill="x", padx=10, pady=(5, 10))
|
||
|
||
# 초기 템플릿 설명
|
||
self._on_template_changed(choices[0][1])
|
||
|
||
def _on_template_changed(self, name: str):
|
||
tid = self._template_name_to_id.get(name)
|
||
tpl = REGISTRY.get(tid)
|
||
if tpl:
|
||
self.current_template = tpl
|
||
self.template_desc.configure(
|
||
text=f"{tpl.description} (파일 {tpl.required_files[0]}~{tpl.required_files[1]}개)"
|
||
)
|
||
|
||
def _on_parse(self):
|
||
if not self.dxf_paths:
|
||
messagebox.showwarning("주의", "먼저 DXF 파일을 업로드하세요.")
|
||
return
|
||
if not self.current_template:
|
||
messagebox.showwarning("주의", "구조물 유형을 선택하세요.")
|
||
return
|
||
|
||
tpl = self.current_template
|
||
self.log(f">>> 파싱 시작 [{tpl.name_ko}]...")
|
||
|
||
try:
|
||
params = tpl.parse(self.dxf_paths)
|
||
self.current_params = params
|
||
|
||
# 검출된 뷰 로그
|
||
views = params.get("_views") or []
|
||
if views:
|
||
self.log(f" 📐 뷰 {len(views)}개 검출:")
|
||
for v in views:
|
||
frame = "프레임" if v.has_frame else "추정"
|
||
scale = f" {v.scale_hint}" if v.scale_hint else ""
|
||
label_short = v.label_text[:40]
|
||
self.log(f" • [{v.view_type_ko}] \"{label_short}\" "
|
||
f"({frame}{scale}, {v.width:.1f}×{v.height:.1f}m, "
|
||
f"{len(v.shapes)}개 요소)")
|
||
else:
|
||
self.log(f" 뷰 라벨 없음 → geometry-based 처리")
|
||
|
||
self._refresh_param_fields()
|
||
self.log(f" 파싱 완료: 파라미터 {sum(1 for k in params.params if not k.startswith('_'))}개 (편집 가능)")
|
||
for k, v in params.params.items():
|
||
if k.startswith("_"):
|
||
continue
|
||
if isinstance(v, (int, float)):
|
||
self.log(f" {k} = {v}")
|
||
except Exception as e:
|
||
self.log(f" 파싱 오류: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
messagebox.showerror("오류", f"파싱 중 오류:\n{e}")
|
||
|
||
# ----------------------------------------------------------------------
|
||
# Step 3: 파라미터 편집
|
||
# ----------------------------------------------------------------------
|
||
|
||
def _build_step3_params(self, parent):
|
||
frame = self._section_frame(parent, "Step 3: 파라미터 확인/편집")
|
||
|
||
self.param_frame = ctk.CTkFrame(frame, fg_color="transparent")
|
||
self.param_frame.pack(fill="x", padx=10, pady=(5, 5))
|
||
|
||
ctk.CTkLabel(
|
||
self.param_frame, text="(파싱 후 표시됩니다)",
|
||
text_color="gray", font=ctk.CTkFont(size=11),
|
||
).pack(padx=10, pady=10)
|
||
|
||
ctk.CTkButton(
|
||
frame, text="3D 모델 빌드 / 재생성", height=32,
|
||
fg_color="#1f538d", hover_color="#14375e",
|
||
command=self._on_build_3d,
|
||
).pack(fill="x", padx=10, pady=(5, 10))
|
||
|
||
def _refresh_param_fields(self):
|
||
# 기존 위젯 제거
|
||
for w in self.param_frame.winfo_children():
|
||
w.destroy()
|
||
self.param_entries = {}
|
||
|
||
if not self.current_template or not self.current_params:
|
||
return
|
||
|
||
schema = self.current_template.get_parameter_schema()
|
||
params = self.current_params
|
||
|
||
# 2열 그리드
|
||
self.param_frame.grid_columnconfigure(0, weight=0)
|
||
self.param_frame.grid_columnconfigure(1, weight=1)
|
||
self.param_frame.grid_columnconfigure(2, weight=0)
|
||
self.param_frame.grid_columnconfigure(3, weight=1)
|
||
|
||
row = 0
|
||
col = 0
|
||
for field in schema:
|
||
base_col = col * 2
|
||
|
||
label_text = f"{field.label}"
|
||
if field.unit:
|
||
label_text += f" ({field.unit})"
|
||
ctk.CTkLabel(
|
||
self.param_frame, text=label_text,
|
||
font=ctk.CTkFont(size=11),
|
||
anchor="w",
|
||
).grid(row=row, column=base_col, padx=(5, 2), pady=3, sticky="w")
|
||
|
||
current_val = params.get(field.name, field.default)
|
||
|
||
if field.param_type == "choice" and field.choices:
|
||
var = ctk.StringVar(
|
||
value=field.choices[int(current_val)] if isinstance(current_val, (int, float)) and 0 <= int(current_val) < len(field.choices) else field.choices[0]
|
||
)
|
||
menu = ctk.CTkOptionMenu(
|
||
self.param_frame, variable=var,
|
||
values=field.choices, width=140,
|
||
)
|
||
menu.grid(row=row, column=base_col + 1, padx=(2, 10), pady=3, sticky="w")
|
||
self.param_entries[field.name] = var
|
||
else:
|
||
entry = ctk.CTkEntry(self.param_frame, width=120)
|
||
entry.grid(row=row, column=base_col + 1, padx=(2, 10), pady=3, sticky="w")
|
||
entry.insert(0, f"{current_val}")
|
||
self.param_entries[field.name] = entry
|
||
|
||
col += 1
|
||
if col >= 2:
|
||
col = 0
|
||
row += 1
|
||
|
||
def _collect_params(self) -> dict:
|
||
"""UI의 편집 값을 수집."""
|
||
if not self.current_template:
|
||
return {}
|
||
|
||
schema = self.current_template.get_parameter_schema()
|
||
collected = {}
|
||
|
||
for field in schema:
|
||
widget = self.param_entries.get(field.name)
|
||
if widget is None:
|
||
continue
|
||
if field.param_type == "choice" and field.choices:
|
||
# StringVar → 선택 인덱스
|
||
val_str = widget.get()
|
||
if val_str in field.choices:
|
||
collected[field.name] = field.choices.index(val_str)
|
||
else:
|
||
collected[field.name] = int(field.default)
|
||
else:
|
||
try:
|
||
val_str = widget.get()
|
||
if field.param_type == "int":
|
||
collected[field.name] = int(float(val_str))
|
||
else:
|
||
collected[field.name] = float(val_str)
|
||
except (ValueError, TypeError):
|
||
collected[field.name] = field.default
|
||
|
||
return collected
|
||
|
||
def _on_build_3d(self):
|
||
if not self.current_template or not self.current_params:
|
||
messagebox.showwarning("주의", "먼저 자동 파싱을 실행하세요.")
|
||
return
|
||
|
||
# UI 값 수집 → params 갱신
|
||
collected = self._collect_params()
|
||
self.current_params.update(collected)
|
||
|
||
self.log(">>> 3D 모델 빌드 중...")
|
||
try:
|
||
meshes = self.current_template.build_meshes(self.current_params)
|
||
self.current_meshes = meshes
|
||
self.log(f" {len(meshes)}개 메쉬 컴포넌트 생성 → 실시간 프리뷰 활성화")
|
||
|
||
# 지속 plotter 셋업 (이후 슬라이더 이동 시 카메라만 갱신)
|
||
self._setup_live_plotter()
|
||
self._live_render()
|
||
except Exception as e:
|
||
self.log(f" 빌드 오류: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
messagebox.showerror("오류", f"3D 빌드 오류:\n{e}")
|
||
|
||
# ----------------------------------------------------------------------
|
||
# Step 4: 렌더 컨트롤
|
||
# ----------------------------------------------------------------------
|
||
|
||
def _build_step4_render(self, parent):
|
||
frame = self._section_frame(parent, "Step 4: 렌더 설정 (실시간)")
|
||
|
||
ctk.CTkLabel(
|
||
frame, text="슬라이더를 움직이면 3D 화면이 실시간으로 갱신됩니다",
|
||
font=ctk.CTkFont(size=10), text_color="gray",
|
||
).pack(padx=10, pady=(2, 5), anchor="w")
|
||
|
||
# 앙각/방위각 슬라이더
|
||
self._build_slider(frame, "앙각", self.elevation_var, 0, 90, "°")
|
||
self._build_slider(frame, "방위각", self.azimuth_var, 0, 360, "°")
|
||
self._build_slider(frame, "줌", self.zoom_var, 0.5, 3.0, "×")
|
||
|
||
# 시간대
|
||
row = ctk.CTkFrame(frame, fg_color="transparent")
|
||
row.pack(fill="x", padx=10, pady=(5, 5))
|
||
ctk.CTkLabel(row, text="시간대", width=60).pack(side="left")
|
||
ctk.CTkOptionMenu(
|
||
row, variable=self.time_var,
|
||
values=["낮 (Daytime)", "일몰 (Sunset)", "흐림 (Overcast)"],
|
||
width=180,
|
||
).pack(side="left", padx=5)
|
||
|
||
btn_row = ctk.CTkFrame(frame, fg_color="transparent")
|
||
btn_row.pack(fill="x", padx=10, pady=(5, 10))
|
||
ctk.CTkButton(
|
||
btn_row, text="뷰 초기화", width=150,
|
||
command=self._on_reset_view,
|
||
).pack(side="left", padx=2)
|
||
ctk.CTkButton(
|
||
btn_row, text="인터랙티브 3D", width=150, fg_color="transparent",
|
||
border_width=1, command=self._on_interactive,
|
||
).pack(side="left", padx=2)
|
||
|
||
def _on_reset_view(self):
|
||
"""카메라 슬라이더를 기본값으로 재설정."""
|
||
self.elevation_var.set(35.0)
|
||
self.azimuth_var.set(225.0)
|
||
self.zoom_var.set(1.0)
|
||
self._schedule_live_render(0)
|
||
|
||
def _build_slider(self, parent, label, var, mn, mx, unit):
|
||
row = ctk.CTkFrame(parent, fg_color="transparent")
|
||
row.pack(fill="x", padx=10, pady=2)
|
||
|
||
ctk.CTkLabel(row, text=label, width=60).pack(side="left")
|
||
slider = ctk.CTkSlider(
|
||
row, from_=mn, to=mx, variable=var, width=200,
|
||
)
|
||
slider.pack(side="left", padx=5)
|
||
value_label = ctk.CTkLabel(row, text=f"{var.get():.1f}{unit}", width=50)
|
||
value_label.pack(side="left")
|
||
|
||
def _update(val):
|
||
value_label.configure(text=f"{float(val):.1f}{unit}")
|
||
# 실시간 프리뷰 트리거 (디바운싱)
|
||
self._schedule_live_render()
|
||
|
||
slider.configure(command=_update)
|
||
|
||
def _capture_and_show(self):
|
||
if not self.current_meshes:
|
||
return
|
||
|
||
try:
|
||
# live plotter가 있으면 그걸 사용 (훨씬 빠름)
|
||
if self._live_plotter is not None:
|
||
self._live_render()
|
||
return
|
||
img = self._capture_3d(size=800)
|
||
self.current_capture = img
|
||
self._show_in_preview(img)
|
||
self.log(f" 미리보기 갱신 완료 (800×800)")
|
||
except Exception as e:
|
||
self.log(f" 캡처 오류: {e}")
|
||
|
||
# --- 실시간 렌더링 시스템 ---
|
||
|
||
def _setup_live_plotter(self):
|
||
"""메쉬들을 한번만 로드한 지속 plotter 생성.
|
||
|
||
이후 슬라이더 변경 시엔 카메라만 갱신하여 빠르게 재렌더.
|
||
"""
|
||
# 기존 plotter 정리
|
||
if self._live_plotter is not None:
|
||
try:
|
||
self._live_plotter.close()
|
||
except Exception:
|
||
pass
|
||
self._live_plotter = None
|
||
|
||
if not self.current_meshes:
|
||
return
|
||
|
||
plotter = pv.Plotter(off_screen=True, window_size=(self._live_size, self._live_size))
|
||
plotter.set_background("#C8D4E0")
|
||
|
||
for mesh, color, opacity in self.current_meshes:
|
||
try:
|
||
plotter.add_mesh(
|
||
mesh, color=color, opacity=opacity,
|
||
smooth_shading=True, specular=0.3, specular_power=10,
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
plotter.enable_3_lights()
|
||
|
||
# 초기 카메라 설정 및 렌더
|
||
cam_pos, focal, up = self._compute_camera()
|
||
plotter.camera_position = [cam_pos, focal, up]
|
||
|
||
# show(auto_close=False)로 렌더 파이프라인 초기화
|
||
try:
|
||
plotter.show(auto_close=False)
|
||
except Exception:
|
||
pass
|
||
|
||
self._live_plotter = plotter
|
||
|
||
def _schedule_live_render(self, delay_ms: int = 30):
|
||
"""디바운싱: 슬라이더 이동 중 너무 많은 렌더 호출 방지.
|
||
|
||
delay_ms 후 실제 렌더. 그 사이 추가 호출이 오면 취소하고 재스케줄.
|
||
"""
|
||
if self._render_after_id is not None:
|
||
try:
|
||
self.after_cancel(self._render_after_id)
|
||
except Exception:
|
||
pass
|
||
self._render_after_id = self.after(delay_ms, self._live_render)
|
||
|
||
def _live_render(self):
|
||
"""카메라만 갱신하여 빠르게 재렌더."""
|
||
self._render_after_id = None
|
||
|
||
if self._live_plotter is None or not self.current_meshes:
|
||
return
|
||
if self._render_in_progress:
|
||
# 이미 렌더링 중이면 끝난 후 다시 호출되도록 스케줄
|
||
self._schedule_live_render(50)
|
||
return
|
||
|
||
self._render_in_progress = True
|
||
try:
|
||
cam_pos, focal, up = self._compute_camera()
|
||
self._live_plotter.camera_position = [cam_pos, focal, up]
|
||
|
||
img_arr = self._live_plotter.screenshot(
|
||
return_img=True,
|
||
window_size=(self._live_size, self._live_size),
|
||
)
|
||
img = Image.fromarray(img_arr)
|
||
self.current_capture = img
|
||
self._show_in_preview(img)
|
||
except Exception as e:
|
||
self.log(f" 실시간 렌더 오류: {e}")
|
||
finally:
|
||
self._render_in_progress = False
|
||
|
||
def _on_close(self):
|
||
"""앱 종료 시 plotter 정리."""
|
||
if self._live_plotter is not None:
|
||
try:
|
||
self._live_plotter.close()
|
||
except Exception:
|
||
pass
|
||
self.destroy()
|
||
|
||
def _capture_3d(self, size=1024) -> Image.Image:
|
||
"""현재 설정으로 3D 캡처."""
|
||
if not self.current_meshes:
|
||
return Image.new("RGB", (size, size), (50, 50, 50))
|
||
|
||
plotter = pv.Plotter(off_screen=True, window_size=(size, size))
|
||
plotter.set_background("#C8D4E0")
|
||
|
||
for mesh, color, opacity in self.current_meshes:
|
||
try:
|
||
plotter.add_mesh(
|
||
mesh, color=color, opacity=opacity,
|
||
smooth_shading=True, specular=0.3, specular_power=10,
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
# 카메라 설정
|
||
cam_pos, focal, up = self._compute_camera()
|
||
plotter.camera_position = [cam_pos, focal, up]
|
||
plotter.enable_3_lights()
|
||
|
||
img_arr = plotter.screenshot(return_img=True, window_size=(size, size))
|
||
plotter.close()
|
||
|
||
return Image.fromarray(img_arr)
|
||
|
||
def _compute_camera(self):
|
||
"""모든 메쉬의 경계를 포함하는 카메라 계산."""
|
||
all_pts = []
|
||
for mesh, _, _ in self.current_meshes:
|
||
all_pts.append(np.array(mesh.bounds).reshape(3, 2))
|
||
|
||
if not all_pts:
|
||
return (50, 50, 50), (0, 0, 0), (0, 0, 1)
|
||
|
||
bounds = np.array(all_pts)
|
||
mins = bounds[:, :, 0].min(axis=0)
|
||
maxs = bounds[:, :, 1].max(axis=0)
|
||
center = (mins + maxs) / 2
|
||
diag = float(np.linalg.norm(maxs - mins))
|
||
|
||
dist = diag * self.zoom_var.get()
|
||
el_rad = math.radians(self.elevation_var.get())
|
||
az_rad = math.radians(self.azimuth_var.get())
|
||
|
||
dx = dist * math.cos(el_rad) * math.sin(az_rad)
|
||
dy = -dist * math.cos(el_rad) * math.cos(az_rad)
|
||
dz = dist * math.sin(el_rad)
|
||
|
||
cam_pos = (float(center[0] + dx), float(center[1] + dy), float(center[2] + dz))
|
||
focal = tuple(float(v) for v in center)
|
||
up = (0, 0, 1)
|
||
return cam_pos, focal, up
|
||
|
||
def _on_interactive(self):
|
||
if not self.current_meshes:
|
||
messagebox.showwarning("주의", "먼저 3D 모델을 빌드하세요.")
|
||
return
|
||
|
||
self.log(" 인터랙티브 3D 뷰어 열기 (q로 종료)...")
|
||
|
||
def _run():
|
||
plotter = pv.Plotter(title="EG-VIEW Structure: Interactive 3D")
|
||
plotter.set_background("#2B3A4A")
|
||
for mesh, color, opacity in self.current_meshes:
|
||
try:
|
||
plotter.add_mesh(
|
||
mesh, color=color, opacity=opacity,
|
||
smooth_shading=True,
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
cam_pos, focal, up = self._compute_camera()
|
||
plotter.camera_position = [cam_pos, focal, up]
|
||
plotter.enable_3_lights()
|
||
plotter.show_grid(color="#555")
|
||
plotter.add_axes()
|
||
plotter.show()
|
||
|
||
threading.Thread(target=_run, daemon=True).start()
|
||
|
||
# ----------------------------------------------------------------------
|
||
# Step 5: 저장 / AI 렌더
|
||
# ----------------------------------------------------------------------
|
||
|
||
def _build_step5_export(self, parent):
|
||
frame = self._section_frame(parent, "Step 5: 저장 / AI 렌더")
|
||
|
||
# Vertex AI 사용 체크박스
|
||
vx_row = ctk.CTkFrame(frame, fg_color="transparent")
|
||
vx_row.pack(fill="x", padx=10, pady=(5, 0))
|
||
ctk.CTkCheckBox(
|
||
vx_row, text="Vertex AI 사용 (GCP Project)",
|
||
variable=self.use_vertex_var,
|
||
command=self._on_toggle_vertex,
|
||
font=ctk.CTkFont(size=11),
|
||
).pack(side="left")
|
||
|
||
# API Key / GCP Project ID
|
||
self._api_label = ctk.CTkLabel(frame, text="GCP Project ID (Vertex)" if self.use_vertex_var.get() else "Gemini API Key",
|
||
font=ctk.CTkFont(size=11))
|
||
self._api_label.pack(fill="x", padx=10, pady=(5, 0), anchor="w")
|
||
self._api_entry = ctk.CTkEntry(
|
||
frame, textvariable=self.api_key_var,
|
||
placeholder_text="예: my-project-12345" if self.use_vertex_var.get() else "aistudio.google.com",
|
||
show="" if self.use_vertex_var.get() else "*",
|
||
)
|
||
self._api_entry.pack(fill="x", padx=10, pady=(0, 3))
|
||
|
||
# Vertex AI Location
|
||
self._loc_entry = ctk.CTkEntry(
|
||
frame, textvariable=self.vertex_location_var,
|
||
placeholder_text="Vertex AI location (us-central1)",
|
||
)
|
||
self._loc_entry.pack(fill="x", padx=10, pady=(0, 5))
|
||
if not self.use_vertex_var.get():
|
||
self._loc_entry.pack_forget()
|
||
|
||
btn_row1 = ctk.CTkFrame(frame, fg_color="transparent")
|
||
btn_row1.pack(fill="x", padx=10, pady=(5, 2))
|
||
|
||
ctk.CTkButton(
|
||
btn_row1, text="캡처 PNG 저장", width=180,
|
||
command=self._on_save_capture,
|
||
).pack(side="left", padx=2)
|
||
ctk.CTkButton(
|
||
btn_row1, text="파라미터 JSON 저장", width=180, fg_color="transparent",
|
||
border_width=1, command=self._on_save_params,
|
||
).pack(side="left", padx=2)
|
||
|
||
btn_row2 = ctk.CTkFrame(frame, fg_color="transparent")
|
||
btn_row2.pack(fill="x", padx=10, pady=(2, 2))
|
||
|
||
ctk.CTkButton(
|
||
btn_row2, text="AI 렌더링 실행", width=180,
|
||
fg_color="#27AE60", hover_color="#1E8449",
|
||
command=self._on_ai_render,
|
||
).pack(side="left", padx=2)
|
||
ctk.CTkButton(
|
||
btn_row2, text="AI 결과 저장", width=180, fg_color="transparent",
|
||
border_width=1, command=self._on_save_ai,
|
||
).pack(side="left", padx=2)
|
||
|
||
btn_row3 = ctk.CTkFrame(frame, fg_color="transparent")
|
||
btn_row3.pack(fill="x", padx=10, pady=(2, 10))
|
||
|
||
ctk.CTkButton(
|
||
btn_row3, text="전체 내보내기 (ZIP)", width=360,
|
||
command=self._on_export_all,
|
||
).pack(side="left", padx=2)
|
||
|
||
def _on_save_capture(self):
|
||
if self.current_capture is None:
|
||
messagebox.showwarning("주의", "먼저 미리보기를 생성하세요.")
|
||
return
|
||
path = filedialog.asksaveasfilename(
|
||
defaultextension=".png", filetypes=[("PNG", "*.png")],
|
||
initialfile="structure_capture.png",
|
||
)
|
||
if path:
|
||
# 고해상도로 다시 캡처
|
||
high_res = self._capture_3d(size=1536)
|
||
high_res.save(path)
|
||
self.log(f" 캡처 저장: {path}")
|
||
|
||
def _on_save_params(self):
|
||
if not self.current_params:
|
||
messagebox.showwarning("주의", "먼저 파싱을 실행하세요.")
|
||
return
|
||
|
||
# 최신 UI 값 반영
|
||
self.current_params.update(self._collect_params())
|
||
|
||
path = filedialog.asksaveasfilename(
|
||
defaultextension=".json", filetypes=[("JSON", "*.json")],
|
||
initialfile="structure_params.json",
|
||
)
|
||
if path:
|
||
data = {
|
||
"template_id": self.current_params.template_id,
|
||
"name": self.current_params.name,
|
||
"params": self._json_safe_params(self.current_params.params),
|
||
"source_files": self.current_params.source_files,
|
||
}
|
||
with open(path, "w", encoding="utf-8") as f:
|
||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||
self.log(f" 파라미터 저장: {path}")
|
||
|
||
def _json_safe_params(self, params: dict) -> dict:
|
||
"""numpy/복잡 객체를 JSON 직렬화 가능 형태로 변환."""
|
||
out = {}
|
||
for k, v in params.items():
|
||
if isinstance(v, (int, float, str, bool)) or v is None:
|
||
out[k] = v
|
||
elif isinstance(v, (list, tuple)):
|
||
try:
|
||
out[k] = [list(p) if isinstance(p, (tuple, list)) else p for p in v]
|
||
except Exception:
|
||
out[k] = str(v)
|
||
elif isinstance(v, np.ndarray):
|
||
out[k] = v.tolist()
|
||
else:
|
||
out[k] = str(v)
|
||
return out
|
||
|
||
def _on_toggle_vertex(self):
|
||
"""Vertex AI 체크박스 토글."""
|
||
use_vertex = self.use_vertex_var.get()
|
||
if use_vertex:
|
||
self._api_label.configure(text="GCP Project ID (Vertex)")
|
||
self._api_entry.configure(
|
||
placeholder_text="예: my-project-12345", show="")
|
||
self._loc_entry.pack(fill="x", padx=10, pady=(0, 5))
|
||
else:
|
||
self._api_label.configure(text="Gemini API Key")
|
||
self._api_entry.configure(
|
||
placeholder_text="aistudio.google.com", show="*")
|
||
self._loc_entry.pack_forget()
|
||
|
||
def _on_ai_render(self):
|
||
if self.current_capture is None:
|
||
messagebox.showwarning("주의", "먼저 미리보기를 생성하세요.")
|
||
return
|
||
credential = self.api_key_var.get().strip()
|
||
use_vertex = self.use_vertex_var.get()
|
||
location = self.vertex_location_var.get().strip() or "us-central1"
|
||
|
||
if not credential:
|
||
messagebox.showwarning(
|
||
"주의",
|
||
"GCP Project ID를 입력하세요." if use_vertex
|
||
else "Gemini API Key를 입력하세요."
|
||
)
|
||
return
|
||
|
||
self.log(f">>> AI 렌더링 실행 ({'Vertex AI' if use_vertex else 'Gemini API'})...")
|
||
|
||
def _run():
|
||
try:
|
||
guide_img = self._capture_3d(size=1536)
|
||
prompt = self._build_ai_prompt()
|
||
self.log(f" 프롬프트: {prompt[:80]}...")
|
||
|
||
rendered = self._call_gemini(
|
||
guide_img, prompt, credential,
|
||
use_vertex=use_vertex, location=location,
|
||
)
|
||
if rendered is None:
|
||
self.log(" AI 렌더링 실패")
|
||
return
|
||
|
||
self.ai_rendered = rendered
|
||
self._show_in_preview(rendered)
|
||
self.log(" AI 렌더링 완료")
|
||
except Exception as e:
|
||
self.log(f" AI 오류: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
|
||
threading.Thread(target=_run, daemon=True).start()
|
||
|
||
def _build_ai_prompt(self) -> str:
|
||
tpl = self.current_template
|
||
params = self.current_params
|
||
time_desc = {
|
||
"낮 (Daytime)": "bright daylight, clear blue sky, sharp shadows",
|
||
"일몰 (Sunset)": "warm sunset light, orange golden glow",
|
||
"흐림 (Overcast)": "soft overcast lighting, muted tones",
|
||
}.get(self.time_var.get(), "bright daylight")
|
||
|
||
type_desc = f"civil engineering structure: {tpl.name_ko} ({tpl.description})" if tpl else "civil engineering structure"
|
||
|
||
return (
|
||
f"Photorealistic bird's eye aerial view of a {type_desc}, "
|
||
f"{time_desc}, "
|
||
f"maintain exact structural geometry, layout, and proportions from the input image, "
|
||
f"preserve all element positions precisely, "
|
||
f"professional architectural rendering, "
|
||
f"8K ultra sharp detail, high dynamic range, "
|
||
f"realistic concrete texture, steel details, "
|
||
f"natural ground texture, realistic vegetation if any"
|
||
)
|
||
|
||
def _call_gemini(self, img: Image.Image, prompt: str, credential: str,
|
||
use_vertex: bool = False,
|
||
location: str = "us-central1") -> Image.Image | None:
|
||
"""Gemini 이미지 생성 호출.
|
||
|
||
Args:
|
||
credential: API Key 또는 GCP Project ID
|
||
use_vertex: True면 Vertex AI 클라이언트 사용
|
||
location: Vertex AI region
|
||
"""
|
||
try:
|
||
from google import genai
|
||
from google.genai import types as gtypes
|
||
import io as _io
|
||
|
||
if use_vertex:
|
||
client = genai.Client(
|
||
vertexai=True,
|
||
project=credential,
|
||
location=location,
|
||
)
|
||
self.log(f" Vertex AI 클라이언트 (project={credential}, loc={location})")
|
||
else:
|
||
client = genai.Client(api_key=credential)
|
||
|
||
buf = _io.BytesIO()
|
||
img.save(buf, format="PNG")
|
||
img_bytes = buf.getvalue()
|
||
|
||
response = client.models.generate_content(
|
||
model="gemini-2.5-flash-image",
|
||
contents=[
|
||
prompt,
|
||
gtypes.Part.from_bytes(data=img_bytes, mime_type="image/png"),
|
||
],
|
||
config=gtypes.GenerateContentConfig(
|
||
response_modalities=["IMAGE", "TEXT"],
|
||
),
|
||
)
|
||
|
||
for part in response.candidates[0].content.parts:
|
||
if hasattr(part, "inline_data") and part.inline_data:
|
||
return Image.open(_io.BytesIO(part.inline_data.data))
|
||
|
||
return None
|
||
except Exception as e:
|
||
self.log(f" Gemini 오류: {e}")
|
||
return None
|
||
|
||
def _on_save_ai(self):
|
||
if self.ai_rendered is None:
|
||
messagebox.showwarning("주의", "AI 렌더링을 먼저 실행하세요.")
|
||
return
|
||
path = filedialog.asksaveasfilename(
|
||
defaultextension=".png", filetypes=[("PNG", "*.png")],
|
||
initialfile="ai_rendered.png",
|
||
)
|
||
if path:
|
||
self.ai_rendered.save(path)
|
||
self.log(f" AI 렌더 저장: {path}")
|
||
|
||
def _on_export_all(self):
|
||
if not self.current_meshes or self.current_capture is None:
|
||
messagebox.showwarning("주의", "먼저 3D 빌드와 미리보기를 완료하세요.")
|
||
return
|
||
|
||
out_dir = filedialog.askdirectory(title="내보내기 폴더 선택")
|
||
if not out_dir:
|
||
return
|
||
|
||
out_path = Path(out_dir) / "structure_export"
|
||
out_path.mkdir(exist_ok=True)
|
||
|
||
# 캡처 (고해상도)
|
||
capture = self._capture_3d(size=1536)
|
||
capture.save(out_path / "capture_high.png")
|
||
|
||
# 파라미터 JSON
|
||
if self.current_params:
|
||
self.current_params.update(self._collect_params())
|
||
data = {
|
||
"template_id": self.current_params.template_id,
|
||
"name": self.current_params.name,
|
||
"params": self._json_safe_params(self.current_params.params),
|
||
"source_files": self.current_params.source_files,
|
||
}
|
||
with open(out_path / "params.json", "w", encoding="utf-8") as f:
|
||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||
|
||
# 다각도 캡처
|
||
original_elev = self.elevation_var.get()
|
||
original_azim = self.azimuth_var.get()
|
||
views = [
|
||
("top_down", 75, 180),
|
||
("bird_eye_ne", 35, 225),
|
||
("bird_eye_nw", 35, 135),
|
||
("elevation", 5, 180),
|
||
]
|
||
for name, el, az in views:
|
||
self.elevation_var.set(el)
|
||
self.azimuth_var.set(az)
|
||
img = self._capture_3d(size=1024)
|
||
img.save(out_path / f"view_{name}.png")
|
||
self.elevation_var.set(original_elev)
|
||
self.azimuth_var.set(original_azim)
|
||
|
||
# AI 결과도
|
||
if self.ai_rendered is not None:
|
||
self.ai_rendered.save(out_path / "ai_rendered.png")
|
||
|
||
self.log(f" 전체 내보내기 완료: {out_path}")
|
||
messagebox.showinfo("완료", f"내보내기 완료:\n{out_path}")
|
||
|
||
# ----------------------------------------------------------------------
|
||
# 유틸리티
|
||
# ----------------------------------------------------------------------
|
||
|
||
def _section_frame(self, parent, title: str) -> ctk.CTkFrame:
|
||
"""섹션 프레임 + 타이틀 라벨."""
|
||
container = ctk.CTkFrame(parent, corner_radius=8, fg_color="#2a2a2a")
|
||
container.pack(fill="x", padx=15, pady=6)
|
||
|
||
ctk.CTkLabel(
|
||
container, text=title,
|
||
font=ctk.CTkFont(size=13, weight="bold"),
|
||
).pack(padx=10, pady=(8, 0), anchor="w")
|
||
|
||
return container
|
||
|
||
def _show_in_preview(self, img: Image.Image):
|
||
"""이미지를 프리뷰 영역에 표시."""
|
||
# 프리뷰 영역 크기에 맞춰 리사이즈
|
||
self.update_idletasks()
|
||
max_w = self.preview_label.winfo_width() - 20
|
||
max_h = self.preview_label.winfo_height() - 20
|
||
if max_w <= 1 or max_h <= 1:
|
||
max_w, max_h = 800, 600
|
||
|
||
img_ratio = img.width / img.height
|
||
area_ratio = max_w / max_h
|
||
|
||
if img_ratio > area_ratio:
|
||
new_w = max_w
|
||
new_h = int(max_w / img_ratio)
|
||
else:
|
||
new_h = max_h
|
||
new_w = int(max_h * img_ratio)
|
||
|
||
new_w = max(new_w, 100)
|
||
new_h = max(new_h, 100)
|
||
resized = img.resize((new_w, new_h), Image.LANCZOS)
|
||
|
||
ctk_img = ctk.CTkImage(light_image=resized, dark_image=resized, size=(new_w, new_h))
|
||
self.preview_label.configure(image=ctk_img, text="")
|
||
self.preview_label.image = ctk_img # GC 방지
|
||
|
||
def log(self, msg: str):
|
||
import datetime
|
||
ts = datetime.datetime.now().strftime("[%H:%M:%S]")
|
||
self.log_box.insert("end", f"{ts} {msg}\n")
|
||
self.log_box.see("end")
|
||
self.update_idletasks()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 메인
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def main():
|
||
app = StructureUIApp()
|
||
app.mainloop()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|