Import S-CANVAS source + iter=1~7 lint cleanup

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>
This commit is contained in:
2026-05-08 10:29:08 +09:00
parent 53d8b53c2f
commit b9342f6726
92 changed files with 3413501 additions and 0 deletions

178
splash.py Normal file
View File

@@ -0,0 +1,178 @@
"""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")