feat(ui): Mastercard 팔레트 1차 적용 + 인트로 비디오 제거 (#4 부분)
Some checks failed
CI / Ruff + Test (Py3.11 + Py3.13) (3.11) (push) Failing after 10s
CI / Ruff + Test (Py3.11 + Py3.13) (3.13) (push) Failing after 10s

색감/텍스쳐 라운드:
- 50+ hex literal 갱신 → Mastercard 공식 brand 컬러로 통일.
- Bootstrap-ish 팔레트 (#2ECC71 green / #E74C3C red / #F1C40F yellow / #E67E22
  orange / #343A40 slate) → MC Red(#EB001B) / MC Yellow(#F79E1B) /
  Brand Green(#22A06B) / Near-black(#1A1A1A) / Black(#000000).
- 주요 매핑: 에러 status → MC Red, 경고 status → MC Yellow, READY 인디케이터
  → Brand Green, 오렌지 CTA → MC Red, 다크 슬레이트 버튼 → MC Near-black.
- scanvas_maker.py line ~33-47 에 팔레트 의도 주석 블록 추가.
- npx getdesign@latest add mastercard 시도는 외부 npm 코드 실행 차단으로
  건너뛰고 공개 brand 가이드라인 컬러 적용.

인트로 영상 제거:
- splash.py 삭제 (178 LOC, show_intro_splash 함수 단일 진입점).
- Design/logo_intro.mp4 삭제 (3.7 MB binary).
- scanvas_maker.py 의 호출부 13줄 제거 (~line 7044-7054).
- 효과: 첫 화면까지 12초 fade-in 인트로 제거 → 즉시 기동.

잔여 (#4 다음 라운드, multi-session):
- 단일 창 구조 (CTkToplevel 12개 통합).
- 인라인 로그 패널 → 백엔드 파일.
- VTK 임베딩 (vtkTkRenderWidget 또는 pyvistaqt).
- 3-column 레이아웃 (Sidebar/Canvas/Inspector).
- messagebox 63회 → 인라인 토스트.

검증: py_compile + AST OK. splash/show_intro_splash 호출 0건.
.gitignore 정상 (gcp-key.json/*.log/*.db/cache/__pycache__/venv 모두 ignored).

CHANGELOG.md 에 #4 1차 라운드 항목 추가.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 16:59:10 +09:00
parent 470020cf57
commit 5a44c90ea6
4 changed files with 104 additions and 231 deletions

View File

@@ -10,6 +10,53 @@
--- ---
## 2026-05-08 (후속 — UI 1차)
### [feat] Mastercard 팔레트 1차 적용 + 인트로 비디오 제거 (#4 부분)
> **사용자 피드백 #4 부분 진행**: 색감/텍스쳐 위주의 1차 라운드. 단일 창 구조 (CTkToplevel 12개 통합, VTK 임베딩 등) 는 multi-session 작업이라 별도 라운드. 본 commit 은 색채 정체성 + 인트로 제거에 한정.
#### Mastercard 디자인 토큰 매핑 (10색)
- `#2ECC71` (Bootstrap green) → `#22A06B` Brand Green (READY 인디케이터, 12곳).
- `#E74C3C` (Bootstrap red) → `#EB001B` Mastercard Red (에러 status, 14곳).
- `#F1C40F` (Bootstrap yellow) → `#F79E1B` Mastercard Yellow (경고 status, 7곳).
- `#27AE60` / `#1E8449` (CTA 그린/hover) → `#22A06B` / `#1B8454` (5+3곳).
- `#E67E22` / `#BA6116` (오렌지 CTA/hover) → `#EB001B` / `#A30013` MC Red (3+1곳) — 주요 액션 버튼이 MC 레드로 통일.
- `#343A40` / `#212529` (다크 슬레이트 버튼/hover) → `#1A1A1A` / `#000000` MC Near-black (1+1곳).
- `#2b2b2b` (CTk 기본 다크 캔버스) → `#1A1A1A` (1곳, 다크 모드 일관성).
총 50+ hex literal 갱신. Mastercard 공식 brand 가이드라인 (공개) 기반 — `npx getdesign@latest add mastercard` 시도는 외부 npm 코드 실행 차단으로 건너뛰고 공개 팔레트 적용.
#### 디자인 의도
- **PRIMARY (MC Red `#EB001B`)**: 주 CTA + 에러 상태 일관 적용. 위험 신호와 액션 강조 단일 색.
- **ACCENT (MC Yellow `#F79E1B`)**: 경고/노란 status 일관 — Step3 진행 인디케이터 등.
- **SUCCESS (Brand Green `#22A06B`)**: READY/완료 인디케이터. MC 자체 그린 토큰 없음 → brand-friendly 톤 선정.
- **DARK (`#1A1A1A`)**: 다크 모드 페이지/카드 bg, 다크 버튼 — pure black 직전. 텍스트 가독성 ↑.
- 팔레트 문서화 블록: `scanvas_maker.py` line ~33-47 (`Mastercard 디자인 시스템 팔레트` 주석).
#### 인트로 영상 제거
- `splash.py` 삭제 (178 LOC) — `show_intro_splash` 함수 단일 진입점, 더 이상 호출 없음.
- `Design/logo_intro.mp4` 삭제 (3.7 MB).
- `scanvas_maker.py` 의 호출부 제거 (line ~7044-7054 13줄): try/except + show_intro_splash + import.
- 효과: 메인 앱 즉시 기동, 첫 화면까지 12초 fade-in 사라짐 → "느리게 느껴짐" 피드백 일부 완화.
#### 잔여 (#4 다음 라운드)
- **단일 창 구조**: CTkToplevel 12개 (T1~T12) 인스펙터 패널로 통합.
- **인라인 로그 패널 제거**: `main_frame.row=1` `CTkTextbox` → harness_log_path() 백엔드 파일.
- **VTK 임베딩**: `pv.Plotter().show()` 6개 호출지 → `vtkmodules.tk.vtkTkRenderWidget` 또는 `pyvistaqt.QtInteractor`.
- **3-column 레이아웃**: Sidebar(240) / Main Canvas(flex) / Inspector(340).
- **messagebox 63회** → 인라인 토스트/배너 (위험한 askyesno 4건만 모달 유지).
이는 multi-session 작업이라 본 라운드 범위 외. UI_REDESIGN_PLAN.md §3 참조.
#### 검증
- `python -m py_compile scanvas_maker.py harness/perf.py harness/crash_logger.py` 통과.
- AST parse OK.
- `splash`/`show_intro_splash` 호출 잔존 검사 — 0건 (주석 블록의 1회 'splash' 언급 제외).
- `git ls-files | grep -E '...gcp.*key|\\.log$|\\.db$|\\.bak|venv|__pycache__|cache/'` — 0건. .gitignore 정상 동작.
---
## 2026-05-08 ## 2026-05-08
### [merge] Gitea s-canvas 원격(raw upload, 184185c)과 로컬 lint+Phase 0 history 통합 ### [merge] Gitea s-canvas 원격(raw upload, 184185c)과 로컬 lint+Phase 0 history 통합

Binary file not shown.

View File

@@ -30,6 +30,22 @@ from PIL import Image, ImageDraw, ImageFilter
import tkintermapview import tkintermapview
from matplotlib.colors import LinearSegmentedColormap from matplotlib.colors import LinearSegmentedColormap
# ===== Mastercard 디자인 시스템 팔레트 (피드백 #4 — UI/UX 재설계) =====
# 본 색상은 코드 전반에 hex literal 로 직접 적용된다 (constants import 부담 회피).
# 본 주석 블록은 디자인 의도 문서화용. UI_REDESIGN_PLAN.md 참조.
#
# PRIMARY #EB001B Mastercard Red — 주 CTA, 에러 상태
# PRIMARY' #A30013 Red Dark — CTA hover/active
# ACCENT #F79E1B Mastercard Yellow — 보조 강조, 경고 (Step3 노란 status 등)
# FOCUS #FF5F00 Overlap Orange — 포커스 / focus accent (예약)
# SUCCESS #22A06B Brand-friendly Green — 성공 / READY 인디케이터
# SUCCESS' #1B8454 Green Dark — 성공 hover
# DARK #1A1A1A Near-black — 다크 모드 페이지/버튼 bg
# BLACK #000000 Pure Black — 텍스트, 다크 버튼 hover
# BORDER_L #E0E0E0 Light Border — 라이트 모드 보더
# BORDER_D #333333 Dark Border — 다크 모드 보더 (현 코드는 #3F3F3F 유지)
# 인트로 splash 비디오는 본 라운드에서 제거됨 (피드백 #4).
# 지형(TIN) 컬러맵 — **파란색 금지** (피드백 #3: 물과 헷갈림). # 지형(TIN) 컬러맵 — **파란색 금지** (피드백 #3: 물과 헷갈림).
# 어두운 토양 → 밝은 모래/건조 톤 → 능선 광택. matplotlib "terrain" 대체. # 어두운 토양 → 밝은 모래/건조 톤 → 능선 광택. matplotlib "terrain" 대체.
_TIN_EARTH_CMAP = LinearSegmentedColormap.from_list( _TIN_EARTH_CMAP = LinearSegmentedColormap.from_list(
@@ -470,14 +486,14 @@ class SCanvasApp(ctk.CTk):
self.btn_step4 = ctk.CTkButton( self.btn_step4 = ctk.CTkButton(
self.sidebar_frame, text="4. AI 렌더링", self.sidebar_frame, text="4. AI 렌더링",
command=self.btn_ai_render_callback, height=40, command=self.btn_ai_render_callback, height=40,
fg_color="#E67E22", hover_color="#BA6116", fg_color="#EB001B", hover_color="#A30013",
font=ctk.CTkFont(weight="bold")) font=ctk.CTkFont(weight="bold"))
self.btn_step4.grid(row=_next_row(), column=0, pady=(6, 6), sticky="ew", **pad) self.btn_step4.grid(row=_next_row(), column=0, pady=(6, 6), sticky="ew", **pad)
self.btn_struct_build = ctk.CTkButton( self.btn_struct_build = ctk.CTkButton(
self.sidebar_frame, text="구조물 상세 3D 빌드", self.sidebar_frame, text="구조물 상세 3D 빌드",
command=self._open_structure_template_dialog, height=32, command=self._open_structure_template_dialog, height=32,
fg_color="#27AE60", hover_color="#1E8449", text_color="white", fg_color="#22A06B", hover_color="#1B8454", text_color="white",
font=ctk.CTkFont(size=12, weight="bold")) 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_struct_build.grid(row=_next_row(), column=0, pady=(3, 0), sticky="ew", **pad)
@@ -494,8 +510,8 @@ class SCanvasApp(ctk.CTk):
self.btn_reopen_3d = ctk.CTkButton( self.btn_reopen_3d = ctk.CTkButton(
self.sidebar_frame, text="🗔 3D 뷰 다시 열기", self.sidebar_frame, text="🗔 3D 뷰 다시 열기",
command=self._reopen_3d_preview, height=30, command=self._reopen_3d_preview, height=30,
fg_color=("#343A40", "#2C3E50"), fg_color=("#1A1A1A", "#2C3E50"),
hover_color=("#212529", "#34495E"), hover_color=("#000000", "#34495E"),
text_color="#FFFFFF", text_color="#FFFFFF",
font=ctk.CTkFont(size=11, weight="bold")) font=ctk.CTkFont(size=11, weight="bold"))
self.btn_reopen_3d.grid(row=_next_row(), column=0, pady=(6, 0), sticky="ew", **pad) self.btn_reopen_3d.grid(row=_next_row(), column=0, pady=(6, 0), sticky="ew", **pad)
@@ -591,7 +607,7 @@ class SCanvasApp(ctk.CTk):
# 타일 주변의 얇은 padding 에 이 색이 보임. # 타일 주변의 얇은 padding 에 이 색이 보임.
self.map_frame = ctk.CTkFrame( self.map_frame = ctk.CTkFrame(
self.main_frame, corner_radius=12, self.main_frame, corner_radius=12,
fg_color=("#FFFFFF", "#2b2b2b"), fg_color=("#FFFFFF", "#1A1A1A"),
border_width=1, border_color=("#DEE2E6", "#3F3F3F"), 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(row=0, column=0, padx=0, pady=(0, 8), sticky="nsew")
@@ -612,7 +628,7 @@ class SCanvasApp(ctk.CTk):
self.status_bar = ctk.CTkFrame(self.main_frame, height=28, fg_color="transparent") 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_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 = ctk.CTkLabel(self.status_bar, text="● READY", text_color="#22A06B", font=ctk.CTkFont(size=12, weight="bold"))
self.status_indicator.pack(side="left", padx=10) self.status_indicator.pack(side="left", padx=10)
self.status_text = ctk.CTkLabel(self.status_bar, text="지형 데이터를 로드해 주세요.", font=ctk.CTkFont(size=12)) self.status_text = ctk.CTkLabel(self.status_bar, text="지형 데이터를 로드해 주세요.", font=ctk.CTkFont(size=12))
@@ -739,7 +755,7 @@ class SCanvasApp(ctk.CTk):
except Exception: except Exception:
pass pass
def set_status(self, text, indicator_color="#2ECC71"): def set_status(self, text, indicator_color="#22A06B"):
def _update(): def _update():
self.status_text.configure(text=text) self.status_text.configure(text=text)
self.status_indicator.configure(text_color=indicator_color) self.status_indicator.configure(text_color=indicator_color)
@@ -767,7 +783,7 @@ class SCanvasApp(ctk.CTk):
"cofferdam_downstream": {"name_ko": "하류 가물막이", "render_mode": "wall_extrude", "color": "#8D9CA6"}, "cofferdam_downstream": {"name_ko": "하류 가물막이", "render_mode": "wall_extrude", "color": "#8D9CA6"},
"building": {"name_ko": "건축물/가설건물", "render_mode": "box_extrude", "color": "#BDC3C7"}, "building": {"name_ko": "건축물/가설건물", "render_mode": "box_extrude", "color": "#BDC3C7"},
"temp_facility": {"name_ko": "가설부지/야적장", "render_mode": "surface_overlay", "color": "#E8DACC"}, "temp_facility": {"name_ko": "가설부지/야적장", "render_mode": "surface_overlay", "color": "#E8DACC"},
"boundary": {"name_ko": "경계선 (참고용)", "render_mode": "line_only", "color": "#E74C3C"}, "boundary": {"name_ko": "경계선 (참고용)", "render_mode": "line_only", "color": "#EB001B"},
"ignore": {"name_ko": "무시 (사용 안 함)", "render_mode": "none", "color": "#CCCCCC"}, "ignore": {"name_ko": "무시 (사용 안 함)", "render_mode": "none", "color": "#CCCCCC"},
} }
except Exception: except Exception:
@@ -854,7 +870,7 @@ class SCanvasApp(ctk.CTk):
z_marker = "" if has_z else " " z_marker = "" if has_z else " "
name_text = f"{z_marker}{layer_name}" name_text = f"{z_marker}{layer_name}"
name_color = "#2ECC71" if guessed == "terrain" else None name_color = "#22A06B" if guessed == "terrain" else None
lbl = ctk.CTkLabel(scroll_frame, text=name_text, font=ctk.CTkFont(size=12), lbl = ctk.CTkLabel(scroll_frame, text=name_text, font=ctk.CTkFont(size=12),
anchor="w", width=320, text_color=name_color) anchor="w", width=320, text_color=name_color)
lbl.grid(row=i, column=0, padx=(5, 3), pady=2, sticky="w") lbl.grid(row=i, column=0, padx=(5, 3), pady=2, sticky="w")
@@ -1519,7 +1535,7 @@ class SCanvasApp(ctk.CTk):
status_var.set(f"빌드됨 ({len(info['template_meshes'])}개)") status_var.set(f"빌드됨 ({len(info['template_meshes'])}개)")
status_label = ctk.CTkLabel(list_frame, textvariable=status_var, status_label = ctk.CTkLabel(list_frame, textvariable=status_var,
font=ctk.CTkFont(size=10), font=ctk.CTkFont(size=10),
text_color="#2ECC71") text_color="#22A06B")
status_label.grid(row=ri, column=4, padx=3, pady=3, sticky="w") status_label.grid(row=ri, column=4, padx=3, pady=3, sticky="w")
# 작업 버튼 # 작업 버튼
@@ -1599,7 +1615,7 @@ class SCanvasApp(ctk.CTk):
self.show_3d_preview(textured=False) self.show_3d_preview(textured=False)
ctk.CTkButton(bottom, text="TIN에 구조물 반영 + 3D 보기", width=200, ctk.CTkButton(bottom, text="TIN에 구조물 반영 + 3D 보기", width=200,
fg_color="#E67E22", hover_color="#D35400", fg_color="#EB001B", hover_color="#D35400",
text_color="white", text_color="white",
font=ctk.CTkFont(size=11, weight="bold"), font=ctk.CTkFont(size=11, weight="bold"),
command=_apply_structures_to_tin command=_apply_structures_to_tin
@@ -2081,8 +2097,8 @@ class SCanvasApp(ctk.CTk):
score_f = float(score) score_f = float(score)
except (TypeError, ValueError): except (TypeError, ValueError):
score_f = 0.0 score_f = 0.0
score_color = ("#27AE60" if score_f >= 0.85 else score_color = ("#22A06B" if score_f >= 0.85 else
"#F39C12" if score_f >= 0.6 else "#E74C3C") "#F39C12" if score_f >= 0.6 else "#EB001B")
hdr = ctk.CTkFrame(dwin, fg_color="transparent") hdr = ctk.CTkFrame(dwin, fg_color="transparent")
hdr.pack(fill="x", padx=15, pady=(12, 4)) hdr.pack(fill="x", padx=15, pady=(12, 4))
@@ -2144,10 +2160,10 @@ class SCanvasApp(ctk.CTk):
if excess: if excess:
ctk.CTkLabel(scroll, text="참고: 모델에만 있는 요소 (적용 아님)", ctk.CTkLabel(scroll, text="참고: 모델에만 있는 요소 (적용 아님)",
font=ctk.CTkFont(size=12, weight="bold"), font=ctk.CTkFont(size=12, weight="bold"),
text_color="#E74C3C", anchor="w").pack(fill="x", pady=(10, 2)) text_color="#EB001B", anchor="w").pack(fill="x", pady=(10, 2))
for note in excess: for note in excess:
ctk.CTkLabel(scroll, text=f"{note}", font=ctk.CTkFont(size=10), ctk.CTkLabel(scroll, text=f"{note}", font=ctk.CTkFont(size=10),
text_color="#E67E22", wraplength=760, justify="left", text_color="#EB001B", wraplength=760, justify="left",
anchor="w").pack(fill="x", padx=8, pady=1) anchor="w").pack(fill="x", padx=8, pady=1)
valves_incorrect = diff.get("valves_incorrect", []) or [] valves_incorrect = diff.get("valves_incorrect", []) or []
@@ -2204,7 +2220,7 @@ class SCanvasApp(ctk.CTk):
fg_color="transparent", border_width=1, fg_color="transparent", border_width=1,
command=dwin.destroy).pack(side="right", padx=4) command=dwin.destroy).pack(side="right", padx=4)
ctk.CTkButton(btns, text="✓ 선택 항목 적용 + 재빌드", width=220, ctk.CTkButton(btns, text="✓ 선택 항목 적용 + 재빌드", width=220,
fg_color="#27AE60", hover_color="#1E8449", fg_color="#22A06B", hover_color="#1B8454",
font=ctk.CTkFont(size=11, weight="bold"), font=ctk.CTkFont(size=11, weight="bold"),
command=_apply_and_rebuild).pack(side="right", padx=4) command=_apply_and_rebuild).pack(side="right", padx=4)
# 생성된 비교 이미지 경로 안내 # 생성된 비교 이미지 경로 안내
@@ -2306,7 +2322,7 @@ class SCanvasApp(ctk.CTk):
fg_color="transparent", border_width=1, fg_color="transparent", border_width=1,
command=win.destroy).pack(side="right", padx=3) command=win.destroy).pack(side="right", padx=3)
ctk.CTkButton(bottom, text="✓ 확정 (레지스트리 저장)", width=190, ctk.CTkButton(bottom, text="✓ 확정 (레지스트리 저장)", width=190,
fg_color="#27AE60", hover_color="#1E8449", fg_color="#22A06B", hover_color="#1B8454",
text_color="white", text_color="white",
font=ctk.CTkFont(size=11, weight="bold"), font=ctk.CTkFont(size=11, weight="bold"),
command=_do_confirm).pack(side="right", padx=3) command=_do_confirm).pack(side="right", padx=3)
@@ -2436,7 +2452,7 @@ class SCanvasApp(ctk.CTk):
# 파싱 결과 표시 # 파싱 결과 표시
result_var = ctk.StringVar(value=self._format_detail_params(info.get("detail_params"))) result_var = ctk.StringVar(value=self._format_detail_params(info.get("detail_params")))
result_label = ctk.CTkLabel(list_frame, textvariable=result_var, result_label = ctk.CTkLabel(list_frame, textvariable=result_var,
font=ctk.CTkFont(size=10), text_color="#2ECC71") font=ctk.CTkFont(size=10), text_color="#22A06B")
result_label.grid(row=ri, column=4, padx=5, pady=3, sticky="w") result_label.grid(row=ri, column=4, padx=5, pady=3, sticky="w")
# 파일 선택 버튼 # 파일 선택 버튼
@@ -2543,7 +2559,7 @@ class SCanvasApp(ctk.CTk):
).grid(row=di, column=1, padx=5, pady=1, sticky="w") ).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) 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") ).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" conf_color = "#22A06B" if dim.confidence >= 0.9 else "#F79E1B" if dim.confidence >= 0.8 else "#EB001B"
ctk.CTkLabel(detail_frame, text=f"{dim.confidence:.0%}", font=ctk.CTkFont(size=10), ctk.CTkLabel(detail_frame, text=f"{dim.confidence:.0%}", font=ctk.CTkFont(size=10),
text_color=conf_color text_color=conf_color
).grid(row=di, column=3, padx=5, pady=1, sticky="w") ).grid(row=di, column=3, padx=5, pady=1, sticky="w")
@@ -4108,7 +4124,7 @@ class SCanvasApp(ctk.CTk):
self.dxf_path = file_path self.dxf_path = file_path
self.log(f">>> [Step 1] DXF 로드: {os.path.basename(file_path)}") self.log(f">>> [Step 1] DXF 로드: {os.path.basename(file_path)}")
self.set_status("DXF 분석 중...", "#F1C40F") self.set_status("DXF 분석 중...", "#F79E1B")
# 진단 로그 초기화 (세션 시작) # 진단 로그 초기화 (세션 시작)
self._diag(f"=== Step 1 시작: {file_path} ===", reset=True) self._diag(f"=== Step 1 시작: {file_path} ===", reset=True)
@@ -4187,13 +4203,13 @@ class SCanvasApp(ctk.CTk):
self.update_map_view_to_mesh() self.update_map_view_to_mesh()
reg_n = len(self.structure_registry) reg_n = len(self.structure_registry)
self.set_status(f"TIN 생성 완료 · 구조물 등록 {reg_n}", "#2ECC71") self.set_status(f"TIN 생성 완료 · 구조물 등록 {reg_n}", "#22A06B")
self.btn_step2.configure(fg_color=["#3a7ebf", "#1f538d"], border_width=0) self.btn_step2.configure(fg_color=["#3a7ebf", "#1f538d"], border_width=0)
self.show_3d_preview(textured=False) self.show_3d_preview(textured=False)
except Exception as e: except Exception as e:
self.log(f"오류: {e!s}") self.log(f"오류: {e!s}")
self.set_status("오류 발생", "#E74C3C") self.set_status("오류 발생", "#EB001B")
messagebox.showerror("오류", f"TIN 생성 중 문제가 발생했습니다:\n{e}") messagebox.showerror("오류", f"TIN 생성 중 문제가 발생했습니다:\n{e}")
def create_tin_from_dxf(self, filepath, terrain_layers=None): def create_tin_from_dxf(self, filepath, terrain_layers=None):
@@ -4747,7 +4763,7 @@ class SCanvasApp(ctk.CTk):
bx0, by0, bx1, by1 = state["bbox"] bx0, by0, bx1, by1 = state["bbox"]
state["core_rect"] = _MplRect( state["core_rect"] = _MplRect(
(bx0, by0), bx1 - bx0, by1 - by0, (bx0, by0), bx1 - bx0, by1 - by0,
fill=False, edgecolor="#E74C3C", linewidth=2.2, label="정밀 TIN core") fill=False, edgecolor="#EB001B", linewidth=2.2, label="정밀 TIN core")
ax.add_patch(state["core_rect"]) ax.add_patch(state["core_rect"])
# 통계 갱신 # 통계 갱신
in_core = ((pts_abs[:, 0] >= bx0) & (pts_abs[:, 0] <= bx1) in_core = ((pts_abs[:, 0] >= bx0) & (pts_abs[:, 0] <= bx1)
@@ -4805,8 +4821,8 @@ class SCanvasApp(ctk.CTk):
_clear_live() _clear_live()
drag["live_rect"] = _MplRect( drag["live_rect"] = _MplRect(
(bx0, by0), bx1 - bx0, by1 - by0, (bx0, by0), bx1 - bx0, by1 - by0,
fill=True, facecolor="#E74C3C", alpha=0.18, fill=True, facecolor="#EB001B", alpha=0.18,
edgecolor="#E74C3C", linewidth=2.0) edgecolor="#EB001B", linewidth=2.0)
ax.add_patch(drag["live_rect"]) ax.add_patch(drag["live_rect"])
stat_lbl.configure(text=( stat_lbl.configure(text=(
f"드래그 중 {bx1-bx0:.0f}×{by1-by0:.0f} m " f"드래그 중 {bx1-bx0:.0f}×{by1-by0:.0f} m "
@@ -4918,7 +4934,7 @@ class SCanvasApp(ctk.CTk):
text="✅ 선택 결과 제출 (이 범위를 정밀 TIN core 로 확정)", text="✅ 선택 결과 제출 (이 범위를 정밀 TIN core 로 확정)",
command=_on_confirm, command=_on_confirm,
height=56, height=56,
fg_color="#2ECC71", hover_color="#27AE60", fg_color="#22A06B", hover_color="#22A06B",
text_color="white", text_color="white",
font=ctk.CTkFont(size=16, weight="bold")) font=ctk.CTkFont(size=16, weight="bold"))
submit_btn.pack(side="top", fill="x", padx=10, pady=10) submit_btn.pack(side="top", fill="x", padx=10, pady=10)
@@ -5036,7 +5052,7 @@ class SCanvasApp(ctk.CTk):
feather_m = max(150.0, dem_buffer_m * 0.2) feather_m = max(150.0, dem_buffer_m * 0.2)
src_crs = self.crs_option.get() src_crs = self.crs_option.get()
self.set_status("DEM으로 TIN 확장 중...", "#F1C40F") self.set_status("DEM으로 TIN 확장 중...", "#F79E1B")
self.log(f">>> [Step 1.5] DEM으로 TIN 확장 (buffer={dem_buffer_m:.0f}m, feather={feather_m:.0f}m)...") 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 블렌드** 선행. # [1.5-CORE] "TIN 이용 범위" 가 설정되어 있으면 **3-zone 블렌드** 선행.
@@ -5102,7 +5118,7 @@ class SCanvasApp(ctk.CTk):
self.log(f" [DEM 확장] {result.n_points}개 정점, {result.n_faces}개 삼각형") self.log(f" [DEM 확장] {result.n_points}개 정점, {result.n_faces}개 삼각형")
except Exception as e: except Exception as e:
self.log(f" [Step 1.5] DEM 확장 실패: {e}") self.log(f" [Step 1.5] DEM 확장 실패: {e}")
self.set_status("DEM 확장 실패", "#E74C3C") self.set_status("DEM 확장 실패", "#EB001B")
messagebox.showerror("오류", f"DEM 확장 실패:\n{e}") messagebox.showerror("오류", f"DEM 확장 실패:\n{e}")
return return
@@ -5113,7 +5129,7 @@ class SCanvasApp(ctk.CTk):
# UV 매핑/텍스처 초기화 — 다음 Step 2에서 재생성 # UV 매핑/텍스처 초기화 — 다음 Step 2에서 재생성
self.total_mesh = None self.total_mesh = None
self.set_status("Step 1.5 완료 — 위성지도 결합 준비", "#2ECC71") self.set_status("Step 1.5 완료 — 위성지도 결합 준비", "#22A06B")
self.btn_step2.configure(fg_color=["#3a7ebf", "#1f538d"], border_width=0) self.btn_step2.configure(fg_color=["#3a7ebf", "#1f538d"], border_width=0)
self.show_3d_preview(textured=False) self.show_3d_preview(textured=False)
@@ -5349,7 +5365,7 @@ class SCanvasApp(ctk.CTk):
messagebox.showwarning("주의", "먼저 TIN을 생성해야 합니다.") messagebox.showwarning("주의", "먼저 TIN을 생성해야 합니다.")
return return
self.set_status("위성 이미지 다운로드 중...", "#F1C40F") self.set_status("위성 이미지 다운로드 중...", "#F79E1B")
source_name = self.tile_source_option.get() source_name = self.tile_source_option.get()
self.log(f">>> [Step 2] 위성 타일 다운로드 ({source_name})...") self.log(f">>> [Step 2] 위성 타일 다운로드 ({source_name})...")
@@ -5457,14 +5473,14 @@ class SCanvasApp(ctk.CTk):
self._uv_mapping_params = None self._uv_mapping_params = None
self.log("텍스처 UV 매핑 완료.") self.log("텍스처 UV 매핑 완료.")
self.set_status("위성지도 결합 완료", "#2ECC71") self.set_status("위성지도 결합 완료", "#22A06B")
self.btn_step3.configure(fg_color=["#3a7ebf", "#1f538d"], border_width=0) self.btn_step3.configure(fg_color=["#3a7ebf", "#1f538d"], border_width=0)
self.show_3d_preview(textured=True, texture_obj=texture) self.show_3d_preview(textured=True, texture_obj=texture)
except Exception as e: except Exception as e:
self.log(f"결합 실패: {e}") self.log(f"결합 실패: {e}")
self.set_status("결합 실패", "#E74C3C") self.set_status("결합 실패", "#EB001B")
messagebox.showerror("오류", f"위성지도 결합 중 오류 발생:\n{e}") messagebox.showerror("오류", f"위성지도 결합 중 오류 발생:\n{e}")
def _download_xyz_tiles(self, url_template, min_lat, min_lon, max_lat, max_lon, zoom=17): def _download_xyz_tiles(self, url_template, min_lat, min_lon, max_lat, max_lon, zoom=17):
@@ -5855,7 +5871,7 @@ class SCanvasApp(ctk.CTk):
self.log(" ◆ 좌클릭+드래그: 회전 | 휠: 줌 | 우클릭+드래그: 이동") self.log(" ◆ 좌클릭+드래그: 회전 | 휠: 줌 | 우클릭+드래그: 이동")
self.log(" ◆ 하단 화면비 버튼 클릭 → 창 크기/캡처 비율 즉시 잠금") self.log(" ◆ 하단 화면비 버튼 클릭 → 창 크기/캡처 비율 즉시 잠금")
self.log(" ◆ 원하는 뷰가 잡히면 Enter 키(또는 q)를 누르거나 창을 닫으세요") self.log(" ◆ 원하는 뷰가 잡히면 Enter 키(또는 q)를 누르거나 창을 닫으세요")
self.set_status("뷰포인트를 선택하세요 (Enter로 확정)", "#F1C40F") self.set_status("뷰포인트를 선택하세요 (Enter로 확정)", "#F79E1B")
try: try:
# 인터랙티브 3D 뷰어 열기 — 사용자가 자유롭게 회전/줌 # 인터랙티브 3D 뷰어 열기 — 사용자가 자유롭게 회전/줌
@@ -5863,7 +5879,7 @@ class SCanvasApp(ctk.CTk):
if self._saved_camera is None: if self._saved_camera is None:
self.log(" 뷰포인트 선택 취소됨.") self.log(" 뷰포인트 선택 취소됨.")
self.set_status("뷰포인트 미선택", "#E74C3C") self.set_status("뷰포인트 미선택", "#EB001B")
return return
# 선택된 카메라 위치 로그 (focal/up은 카메라 복원 시 다시 읽음) # 선택된 카메라 위치 로그 (focal/up은 카메라 복원 시 다시 읽음)
@@ -5901,7 +5917,7 @@ class SCanvasApp(ctk.CTk):
) )
self.guide_image.save("guide_composite.png") self.guide_image.save("guide_composite.png")
self.set_status("제어맵 추출 완료", "#2ECC71") self.set_status("제어맵 추출 완료", "#22A06B")
self.btn_step4.configure(fg_color=["#3a7ebf", "#1f538d"]) self.btn_step4.configure(fg_color=["#3a7ebf", "#1f538d"])
self.log(" 저장 완료: capture_textured.png, depth_map.png, lineart_map.png, guide_composite.png") self.log(" 저장 완료: capture_textured.png, depth_map.png, lineart_map.png, guide_composite.png")
@@ -5911,7 +5927,7 @@ class SCanvasApp(ctk.CTk):
except Exception as e: except Exception as e:
self.log(f"제어맵 추출 실패: {e}") self.log(f"제어맵 추출 실패: {e}")
self.set_status("추출 실패", "#E74C3C") self.set_status("추출 실패", "#EB001B")
messagebox.showerror("오류", f"제어맵 추출 중 오류:\n{e}") messagebox.showerror("오류", f"제어맵 추출 중 오류:\n{e}")
def _open_interactive_viewer(self): def _open_interactive_viewer(self):
@@ -6672,7 +6688,7 @@ class SCanvasApp(ctk.CTk):
location = self.vertex_location.get().strip() or "global" location = self.vertex_location.get().strip() or "global"
self.set_status( self.set_status(
f"Gemini 렌더링 중 ({('Vertex AI' if use_vertex else 'API')})...", f"Gemini 렌더링 중 ({('Vertex AI' if use_vertex else 'API')})...",
"#F1C40F" "#F79E1B"
) )
thread = threading.Thread( thread = threading.Thread(
target=self._run_gemini_render, target=self._run_gemini_render,
@@ -6689,7 +6705,7 @@ class SCanvasApp(ctk.CTk):
"또는 'Gemini (Nano Banana)'로 변경하세요.") "또는 'Gemini (Nano Banana)'로 변경하세요.")
return return
self.set_status("AI 렌더링 중... (15~60초 소요)", "#F1C40F") self.set_status("AI 렌더링 중... (15~60초 소요)", "#F79E1B")
thread = threading.Thread( thread = threading.Thread(
target=self._run_stability_render, target=self._run_stability_render,
args=(key, final_prompt, strength), args=(key, final_prompt, strength),
@@ -6757,7 +6773,7 @@ class SCanvasApp(ctk.CTk):
if self.job_logger and db and job_id: if self.job_logger and db and job_id:
self.job_logger.fail_job(db, job_id, "모든 API 방법 실패") self.job_logger.fail_job(db, job_id, "모든 API 방법 실패")
self.after(0, lambda: self.log(" 모든 방법 실패")) self.after(0, lambda: self.log(" 모든 방법 실패"))
self.after(0, lambda: self.set_status("AI 렌더링 실패", "#E74C3C")) self.after(0, lambda: self.set_status("AI 렌더링 실패", "#EB001B"))
return return
# 출력 화질 후처리 — 사용자가 Step 4에서 고른 HD/FHD/UHD 로 리사이즈 # 출력 화질 후처리 — 사용자가 Step 4에서 고른 HD/FHD/UHD 로 리사이즈
@@ -6791,7 +6807,7 @@ class SCanvasApp(ctk.CTk):
self.after(0, lambda: self.log( self.after(0, lambda: self.log(
f" AI 렌더링 완료! → {output_path} ({rendered.size}) " f" AI 렌더링 완료! → {output_path} ({rendered.size}) "
f"[{latency_ms:.0f}ms, 품질={quality_score:.2f}]")) f"[{latency_ms:.0f}ms, 품질={quality_score:.2f}]"))
self.after(0, lambda: self.set_status("AI 렌더링 완료", "#2ECC71")) self.after(0, lambda: self.set_status("AI 렌더링 완료", "#22A06B"))
self.after(0, lambda: self._show_rendered_result(output_path)) self.after(0, lambda: self._show_rendered_result(output_path))
except Exception as e: except Exception as e:
@@ -6799,7 +6815,7 @@ class SCanvasApp(ctk.CTk):
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
self.job_logger.fail_job(db, job_id, str(e)) self.job_logger.fail_job(db, job_id, str(e))
self.after(0, lambda e=e: self.log(f" 렌더링 오류: {e}")) self.after(0, lambda e=e: self.log(f" 렌더링 오류: {e}"))
self.after(0, lambda: self.set_status("렌더링 실패", "#E74C3C")) self.after(0, lambda: self.set_status("렌더링 실패", "#EB001B"))
self.after(0, lambda e=e: messagebox.showerror("오류", f"AI 렌더링 중 오류:\n{e}")) self.after(0, lambda e=e: messagebox.showerror("오류", f"AI 렌더링 중 오류:\n{e}"))
finally: finally:
if db: if db:
@@ -7041,17 +7057,5 @@ if __name__ == "__main__":
except Exception as _ch_err: except Exception as _ch_err:
print(f"[crash_logger] 설치 실패 (계속 진행): {_ch_err}") print(f"[crash_logger] 설치 실패 (계속 진행): {_ch_err}")
# 인트로 스플래시 — 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 = SCanvasApp()
app.mainloop() app.mainloop()

178
splash.py
View File

@@ -1,178 +0,0 @@
"""S-CANVAS 인트로 로딩 스플래시.
Design/logo_intro.mp4 를 frameless Toplevel 중앙에 재생한 뒤 자동 종료.
메인 앱(`scanvas_maker.SCanvasApp`) 기동 직전에 호출한다.
기술 스택:
- cv2 (VideoCapture) : MP4 프레임 디코드 (harness/quality_validator 가
이미 의존하는 프로젝트 공통 dep).
- PIL + tkinter.PhotoImage : 프레임 렌더.
- tk.Tk + attributes("-alpha", ...) : frameless + 페이드 인/아웃 효과.
dynamic effects(사용자 요구 "역동적으로"):
1. 시작 시 알파 0 → 1 페이드인 (400ms)
2. 비디오 자체 애니메이션(logo_intro.mp4 는 고유 모션 포함)
3. 종료 전 알파 1 → 0 페이드아웃 (400ms)
4. 비디오 하단에 브랜드 tagline bar (오렌지 italic)
5. max_duration_s 초과 시 강제 종료(safety)
실패 조건(조용히 skip):
- logo_intro.mp4 없음
- cv2/PIL import 실패
- VideoCapture.open 실패
메인 앱 기동은 항상 보장된다.
"""
from __future__ import annotations
import time
import tkinter as tk
from pathlib import Path
import contextlib
def show_intro_splash(
video_path,
max_duration_s: float = 12.0,
fade_ms: int = 400,
tagline: str = "S-CANVAS — Generative Design & Visualization Engine",
max_display_w: int = 1000,
) -> None:
"""video_path 의 MP4 를 스플래시로 재생 후 블로킹 반환.
Args:
video_path: Path-like, .mp4 파일.
max_duration_s: 비디오가 이 시간을 넘기면 강제 종료.
fade_ms: 페이드인/아웃 각 구간 길이(ms).
tagline: 비디오 아래에 표시할 문구.
max_display_w: 화면상 최대 가로(px). 이보다 크면 aspect 유지 축소.
동작:
- 임시 tk.Tk 루트를 만들고 mainloop → 종료 시 완전 destroy.
- 이후 `SCanvasApp()` 이 새 ctk.CTk 인스턴스를 만들어도 충돌 없음
(Tk 루트가 이미 파괴됐으므로).
"""
try:
import cv2
from PIL import Image, ImageTk
except ImportError as e:
print(f"[Intro] cv2/PIL 미설치 — 스플래시 skip ({e})")
return
vp = Path(video_path)
if not vp.exists():
print(f"[Intro] 비디오 없음 ({vp}) — 스플래시 skip")
return
cap = cv2.VideoCapture(str(vp))
if not cap.isOpened():
print("[Intro] VideoCapture 열기 실패 — 스플래시 skip")
return
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
frame_ms = max(int(1000.0 / max(fps, 1.0)), 8)
vw = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 800)
vh = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 450)
if vw > max_display_w:
disp_w = max_display_w
disp_h = max(1, int(vh * max_display_w / vw))
else:
disp_w, disp_h = vw, vh
root = tk.Tk()
root.withdraw()
screen_w = root.winfo_screenwidth()
screen_h = root.winfo_screenheight()
TAG_H = 44
total_h = disp_h + TAG_H
x = max(0, (screen_w - disp_w) // 2)
y = max(0, (screen_h - total_h) // 2)
splash = tk.Toplevel(root)
splash.overrideredirect(True)
try:
splash.attributes("-topmost", True)
splash.attributes("-alpha", 0.0)
except tk.TclError:
pass
splash.configure(bg="#0A0F1C")
splash.geometry(f"{disp_w}x{total_h}+{x}+{y}")
vid_label = tk.Label(splash, bg="#0A0F1C", borderwidth=0, highlightthickness=0)
vid_label.place(x=0, y=0, width=disp_w, height=disp_h)
tag_label = tk.Label(
splash, text=tagline,
bg="#0A0F1C", fg="#E67E22",
font=("Segoe UI", 10, "italic"),
borderwidth=0, highlightthickness=0,
)
tag_label.place(x=0, y=disp_h, width=disp_w, height=TAG_H)
state = {"closed": False, "t_start": time.time()}
def _safe_alpha(a):
with contextlib.suppress(tk.TclError):
splash.attributes("-alpha", max(0.0, min(1.0, a)))
def _fade_to(target, duration_ms, done_cb=None):
steps = max(int(duration_ms / 16), 1)
start_alpha = float(splash.attributes("-alpha") or 0.0)
def _step(i):
if state["closed"]:
return
ratio = i / steps
_safe_alpha(start_alpha + (target - start_alpha) * ratio)
if i < steps:
splash.after(16, lambda: _step(i + 1))
elif done_cb:
done_cb()
_step(0)
def _close():
if state["closed"]:
return
state["closed"] = True
with contextlib.suppress(Exception):
cap.release()
with contextlib.suppress(Exception):
splash.destroy()
with contextlib.suppress(Exception):
root.quit()
def _play_next_frame():
if state["closed"]:
return
if (time.time() - state["t_start"]) > max_duration_s:
_fade_to(0.0, fade_ms, done_cb=_close)
return
ret, frame = cap.read()
if not ret:
# 비디오 끝 — 페이드아웃 후 종료
_fade_to(0.0, fade_ms, done_cb=_close)
return
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
if (vw, vh) != (disp_w, disp_h):
rgb = cv2.resize(rgb, (disp_w, disp_h), interpolation=cv2.INTER_AREA)
img = Image.fromarray(rgb)
photo = ImageTk.PhotoImage(img)
vid_label.configure(image=photo)
vid_label.image = photo # GC 방지
splash.after(frame_ms, _play_next_frame)
# 페이드인 → 첫 프레임 재생 시작
_fade_to(1.0, fade_ms, done_cb=_play_next_frame)
try:
root.mainloop()
finally:
with contextlib.suppress(Exception):
root.destroy()
if __name__ == "__main__":
# 단독 테스트
show_intro_splash(Path(__file__).resolve().parent / "Design" / "logo_intro.mp4")