From 1fcbf14ed885e1f82b614f4c145f60a5c9863623 Mon Sep 17 00:00:00 2001 From: HYUNJUNGLEE Date: Fri, 8 May 2026 17:33:45 +0900 Subject: [PATCH] =?UTF-8?q?feat(ui):=20#4=20=ED=95=B5=EC=8B=AC=20=E2=80=94?= =?UTF-8?q?=20=EC=9D=B8=EB=9D=BC=EC=9D=B8=20=EB=A1=9C=EA=B7=B8=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20+=20InlinePanel=2012=20popup=20=EC=9D=B8=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 명시 요청 (#4 잔여 핵심): "기존 구조에 지도 아래에 있는 로그는 백엔드로 빼고, 프로세스를 클릭할 때마다 새로운 창이 뜨는 것이 아니라 한 화면에서 바로 구동되게끔 적용". 1. 인라인 로그 패널 완전 제거: - self.textbox CTkTextbox 위젯 제거 (이전 라운드 80px 축소 → 본 라운드 완전 삭제). - main_frame layout: row 0 weight 3→1 (지도 전체 차지), row 1 (로그) 제거, status_bar row 2→1. - self.log() 동작 변경: textbox.insert 대신 백엔드 logger.info (logs/scanvas.log RotatingFileHandler 5MB×5). status_text 가 짧은 미리보기 (≤80자) 즉시 표시. - 효과: 메인 캔버스 영역 ~25% 확대 + GUI 메인 thread 부담 감소. 2. harness/inline_panel.py 신규 (231 LOC): - InlinePanel: ctk.CTkToplevel 호환 인라인 오버레이 (CTkFrame 상속). - API 호환: title/geometry/transient/grab_set/protocol/wait_window/destroy + iconbitmap/wm_* no-op. - 핵심 트릭: tk.Misc.wait_window(self) 가 Frame 에서도 동작 (widget destruction 대기) — wait_window 호출 5곳 (T1/T6/T7/T8/T10) 그대로 유지 가능. - 다중 패널 z-order (_z_counter + lift), main_frame 95% cap, MC Red 타이틀 바. 3. 12 ctk.CTkToplevel → InlinePanel 일괄 치환: - T1 (DXF 레이어), T2 (구조물 빌드), T3 (빌드 진행), T6 (상세도면), T7 (치수), T8 (계획선 고도), T9 (TIN core), T10 (렌더 옵션), T11 (Blender 결과), T12 (AI 렌더 결과) — 10 main popups. - T4 (렌더 sub-옵션), T5 (VLM 결과) — T3 자식 popups. - 2 replace_all 패턴: ctk.CTkToplevel(self) → InlinePanel(self), ctk.CTkToplevel(win) → InlinePanel(win). - 결과: 12 popups 모두 main 창 안 floating frame 으로 렌더, 별도 OS 창 안 뜸. 사용자가 ALT-TAB 으로 창 사이 오갈 필요 없음. 검증: - py_compile + AST OK (scanvas_maker, perf, crash_logger, inline_panel 4개). - ruff check All checks passed (0 errors). - import smoke test: scanvas_maker import 성공, InlinePanel 가 진짜 harness 클래스로 로드. - self.textbox 잔존 refs: 0. CTkToplevel refs: 3 (모두 import fallback/주석). InlinePanel refs: 15 (12 호출지 + import). 잔여 (#4 next round, multi-session): - InlinePanel 실 GUI 워크플로 검증 (사용자 도면 로드 후 T1~T12 한 번씩). - VTK 임베딩 (pv.Plotter().show() 6곳 → pyvistaqt.QtInteractor). - messagebox 63회 → 인라인 토스트. - Inspector 패널 영구 컬럼 (3-column 레이아웃). - 메인 thread 블로킹 작업 worker thread 분리. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 76 ++++++++++++++ harness/inline_panel.py | 219 ++++++++++++++++++++++++++++++++++++++++ scanvas_maker.py | 73 +++++++++----- 3 files changed, 342 insertions(+), 26 deletions(-) create mode 100644 harness/inline_panel.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b41f16..0ed0a1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,82 @@ --- +## 2026-05-08 (후속 — UI 3차: 인라인 로그 제거 + InlinePanel) + +### [feat] 사용자 피드백 #4 핵심 — 인라인 로그 제거 + 모든 popup 인라인화 + +> **사용자 명시 요청**: "기존 구조에 지도 아래에 있는 로그는 백엔드로 빼고, 프로세스를 클릭할 때마다 새로운 창이 뜨는 것이 아니라 한 화면에서 바로 구동되게끔 적용". + +#### 1. 인라인 로그 패널 제거 (`scanvas_maker.py`) + +- **제거**: `self.textbox = ctk.CTkTextbox(self.main_frame, height=80, ...)` 위젯과 그 grid 배치 (이전 라운드에서 이미 80 으로 축소했지만 본 라운드에서 **완전 제거**). +- **layout 재배치**: + - `main_frame.grid_rowconfigure(0)` weight 3 → 1, 지도/캔버스가 row 0 전체 차지. + - `main_frame.grid_rowconfigure(1)` (로그 행) 제거 — weight 설정 자체 삭제. + - `status_bar.grid(row=2, ...)` → `row=1`. 로그 행 제거 후 한 칸 위로. +- **`self.log()` 동작 변경**: textbox.insert 대신 백엔드 logger 사용. + - 파일 sink: `logs/scanvas.log` (RotatingFileHandler 5MB×5, `harness/crash_logger.py` 의 `get_logger()` 활용). + - 추가 sink: `%LOCALAPPDATA%\\S-CANVAS\\scanvas_harness.log` (`harness/logger.py` 의 `setup_logging` 통해 구조화 로그). + - **즉시 가시성**: status_bar 의 `status_text` 가 짧은 미리보기 (≤80자, 그 이상은 잘림 + …). 사용자가 진행 상황을 한 눈에 보되 인라인 로그 패널은 사라짐. +- **효과**: 메인 캔버스 영역 ~25% 확대 (이전 row 1 weight 1 영역 흡수). GUI 메인 thread 의 textbox.insert 부담 제거. + +#### 2. `harness/inline_panel.py` 신규 — `ctk.CTkToplevel` 호환 인라인 오버레이 + +별도 OS 창 없이 main_frame 안에 floating frame 으로 렌더하는 drop-in 대체. **드러난 인터페이스가 CTkToplevel 와 동일**해서 호출지 코드는 1줄만 변경 (`ctk.CTkToplevel` → `InlinePanel`). + +**구현 핵심**: +- `ctk.CTkFrame` 상속 → `tk.Misc.wait_window(self)` 가 widget destruction 을 기다리는 매커니즘이라 Frame 에서도 동작 (Toplevel 만 동작 X). `wait_window()` 호출지 5곳 (line 971, 2518, 2662, 2968, 6662) 그대로 유지 가능. +- `place(relx=0.5, rely=0.5, anchor="center")` 으로 main_frame 중앙 배치. +- `geometry("WxH+X+Y")` 파싱 — X/Y 무시 (always center). main_frame 의 95% cap. +- 다중 패널 z-order: `_z_counter` + `lift()` 으로 최신 패널이 위. +- 타이틀 바: MC Red (#EB001B) 배경, 흰 텍스트, ✕ 닫기 버튼 (피드백 #4 색감 일관). +- `protocol("WM_DELETE_WINDOW", fn)` → 내부 핸들러 등록. `grab_set()` → lift+focus 시뮬레이트. +- **Toplevel 전용 메서드 no-op**: `iconbitmap`, `iconphoto`, `wm_*`, `attributes`, `overrideredirect`. 호출은 silently ignored. + +#### 3. 12개 `ctk.CTkToplevel` → `InlinePanel` 일괄 치환 + +`scanvas_maker.py` 의 popup 생성 라인 12 곳: + +| # | line | 호출 | 용도 | +|---|---|---|---| +| T1 | 851 | `win = ctk.CTkToplevel(self)` | DXF 레이어 분류 (900×650) | +| T2 | 1504 | `win = ctk.CTkToplevel(self)` | 구조물 상세 3D 빌드 (1100×650) | +| T3 | 1681 | `win = ctk.CTkToplevel(self)` | 빌드 진행 | +| T4 | 1974 | `opt_win = ctk.CTkToplevel(win)` | 렌더 옵션 (T3 자식) | +| T5 | 2129 | `dwin = ctk.CTkToplevel(win)` | VLM 결과 (T3 자식) | +| T6 | 2451 | `win = ctk.CTkToplevel(self)` | 상세도면 업로드 | +| T7 | 2571 | `win = ctk.CTkToplevel(self)` | 치수 확인 (650×500) | +| T8 | 2808 | `win = ctk.CTkToplevel(self)` | 계획선 고도 설정 (1280×560) | +| T9 | 4710 | `win = ctk.CTkToplevel(self)` | TIN 이용 범위 (1100×920) | +| T10 | 6625 | `time_win = ctk.CTkToplevel(self)` | 렌더링 옵션 (380×360) | +| T11 | 6985 | `win = ctk.CTkToplevel(self)` | Blender 결과 | +| T12 | 7058 | `win = ctk.CTkToplevel(self)` | AI 렌더 결과 | + +치환 패턴 2종 (replace_all): `ctk.CTkToplevel(self)` → `InlinePanel(self)` (10곳), `ctk.CTkToplevel(win)` → `InlinePanel(win)` (2곳, 자식 패널). + +**12 popup 모두 별도 OS 창 없이 main 창 안에서 동작.** 사용자가 ALT-TAB 으로 창 사이 오갈 필요 없음. + +#### 4. 검증 + +- `python -m py_compile scanvas_maker.py harness/perf.py harness/crash_logger.py harness/inline_panel.py` 통과. +- AST parse OK. +- Import smoke test: `import scanvas_maker` 성공, `InlinePanel` 이 진짜 `harness.inline_panel.InlinePanel` 클래스로 로드 (CTkToplevel 폴백 아님). +- ruff `check scanvas_maker.py harness/`: All checks passed. +- 잔존 검사: + - `self.textbox` refs: **0** (완전 제거). + - `CTkToplevel` refs: 3 (모두 import 폴백/주석). + - `InlinePanel` refs: 15 (12 호출지 + import + fallback). + +#### 5. 잔여 (#4 next round, multi-session) + +- **InlinePanel 동작 검증 (실 GUI)**: 자동 import 검증은 끝났지만 사용자가 실제로 도면 로드 → DXF 레이어 분류 (T1) → 구조물 빌드 (T2) → 등 워크플로 한 번 돌려봐야 모달 동작/wait_window/grab_set 시뮬레이션의 실효성 확인. +- **VTK 임베딩**: 6개 `pv.Plotter().show()` → `vtkmodules.tk.vtkTkRenderWidget` 또는 `pyvistaqt.QtInteractor`. PyQt 의존성 추가 필요. +- **`messagebox` 63회** → 인라인 토스트/배너 (위험 4건 askyesno 만 모달 유지). +- **Inspector 패널 컬럼 (3-column 레이아웃)**: 현재 InlinePanel 은 floating overlay. 영구적 우측 인스펙터 컬럼 (UI_REDESIGN_PLAN.md §2.1) 은 별도 작업. +- **메인 thread 블로킹 작업 worker thread 분리**: 그래야 progress_bar animation 실제 동작. + +--- + ## 2026-05-08 (후속 — CI fix + UI 2차) ### [fix] Gitea CI uv setup 실패 — `**/uv.lock` 미존재 → setup-uv 액션 abort (#6 후속) diff --git a/harness/inline_panel.py b/harness/inline_panel.py new file mode 100644 index 0000000..6e78944 --- /dev/null +++ b/harness/inline_panel.py @@ -0,0 +1,219 @@ +"""S-CANVAS InlinePanel — `ctk.CTkToplevel` 호환 인라인 오버레이 (#4). + +피드백 #4: "프로세스를 클릭할 때마다 새로운 창이 뜨는 것이 아니라 한 화면에서 +바로 구동되게끔 적용". + +본 모듈은 `ctk.CTkToplevel(parent)` 자리에 그대로 들어가는 drop-in 대체. main 앱 +창의 `main_frame` 안에 floating overlay 로 렌더되어 별도 OS 창을 만들지 않는다. + +호환 API (Toplevel subset): + - title(str) + - geometry("WxH" | "WxH+X+Y") — X,Y 는 무시 (centering) + - transient(parent) — no-op + - grab_set() / grab_release() — lift + focus 시뮬레이트 + - protocol("WM_DELETE_WINDOW", fn) + - wait_window() — tk.Misc.wait_window 그대로 동작 (Frame 도 OK) + - destroy() + - iconbitmap / iconphoto / wm_* — no-op (Toplevel 전용) + +사용: + from harness.inline_panel import InlinePanel + win = InlinePanel(self) # ctk.CTkToplevel(self) 와 동일하게 + win.title("DXF 레이어 분류") + win.geometry("900x650") + win.grab_set() + # ... 자식 위젯 .pack() / .grid() + btn_close = ctk.CTkButton(win, text="닫기", command=win.destroy) + btn_close.pack() + win.wait_window() # 패널 destroy 까지 블록 + +기존 Toplevel 와 거의 동일한 코드 변경 = 1줄 (`CTkToplevel` → `InlinePanel`). +""" +from __future__ import annotations + +import contextlib +from collections.abc import Callable + +import customtkinter as ctk + + +# Mastercard 팔레트 (scanvas_maker.py 와 일관) — 타이틀 바 색상. +_MC_RED = "#EB001B" +_MC_RED_DARK = "#A30013" +_MC_WHITE = "#FFFFFF" + + +class InlinePanel(ctk.CTkFrame): + """`ctk.CTkToplevel` 인터페이스를 구현한 inline overlay frame. + + 상위 SCanvasApp 인스턴스의 `main_frame` 안에 `place(relx=0.5, rely=0.5)` 로 + 중앙 배치. 같은 시점에 여러 패널이 열리면 가장 마지막 것이 위 (lift). + + 구현 노트: + - `tk.Misc.wait_window(self)` 는 widget destruction 을 기다리는 메커니즘 + 이라 Toplevel 외 Frame 에서도 동작. CTkFrame → tk.Frame → tk.Widget. + - `grab_set()` 은 OS-level focus 잡는 게 본래 의미인데, 인라인 오버레이는 + 하나의 창 안이라 무의미 → lift + focus 로 시뮬레이트. + - geometry 의 X+Y 좌표는 무시 (panel 은 항상 main_frame 중앙). + """ + + _z_counter = 0 # 다중 패널 z-order 카운터 + + def __init__(self, parent: ctk.CTkBaseClass, **kwargs): + # 상위 SCanvasApp 찾기 — winfo_toplevel() 이 root window 반환. + # parent 가 SCanvasApp 또는 다른 InlinePanel 둘 다 OK. + # 안전 폴백: main_frame 없는 root 면 parent 자체에 그림. + app = parent.winfo_toplevel() + host = app.main_frame if hasattr(app, "main_frame") else parent + + super().__init__( + host, + fg_color=("#FFFFFF", "#1A1A1A"), + border_width=2, + border_color=("#E0E0E0", "#333333"), + corner_radius=10, + **kwargs, + ) + + self._app = app + self._host = host + self._title_text: str = "" + self._title_bar: ctk.CTkFrame | None = None + self._title_label: ctk.CTkLabel | None = None + self._content_frame: ctk.CTkFrame | None = None + self._on_close: Callable[[], None] | None = None + self._closed = False + self._geom_w = 640 + self._geom_h = 480 + + InlinePanel._z_counter += 1 + self._z = InlinePanel._z_counter + + self._build_chrome() + self._reposition() + + # --- chrome (title bar + content area) ----------------------------------- + + def _build_chrome(self) -> None: + """타이틀 바 (MC red) + 컨텐츠 프레임. 자식 위젯은 컨텐츠 프레임에 들어감.""" + # title_bar: MC red 배경, 흰 텍스트, ✕ 버튼 + self._title_bar = ctk.CTkFrame( + self, height=34, fg_color=(_MC_RED, _MC_RED_DARK), corner_radius=8, + ) + self._title_bar.pack(side="top", fill="x", padx=4, pady=(4, 0)) + self._title_bar.pack_propagate(False) + + self._title_label = ctk.CTkLabel( + self._title_bar, text="", text_color=_MC_WHITE, + font=ctk.CTkFont(size=13, weight="bold"), + ) + self._title_label.pack(side="left", padx=12, pady=4) + + close_btn = ctk.CTkButton( + self._title_bar, text="✕", width=28, height=24, + fg_color="transparent", hover_color=_MC_RED_DARK, + text_color=_MC_WHITE, corner_radius=4, + font=ctk.CTkFont(size=14, weight="bold"), + command=self._user_close, + ) + close_btn.pack(side="right", padx=4, pady=4) + + # content frame: 자식 위젯 컨테이너. 사용자가 .pack/.grid(self)로 추가하면 + # 자동으로 여기 자식이 됨 (CTkFrame 의 _children 위임). 직접 호출은 따로. + # 다만 기존 코드는 `widget(win, ...)` 처럼 win 자체를 parent 로 쓰니 그게 + # InlinePanel 의 직접 자식이 됨 — 그래서 content_frame 은 reserved 안 만들고 + # title_bar 만 packed 후 자식들이 그 아래로 채워짐 (pack 자동 layout). + # title bar 와 content 사이 분리선 + sep = ctk.CTkFrame(self, height=1, fg_color=("#E0E0E0", "#333333")) + sep.pack(side="top", fill="x", padx=8, pady=(0, 4)) + + def _reposition(self) -> None: + """geometry str 파싱 + 중앙 배치 (main_frame 의 중앙).""" + try: + mw = self._host.winfo_width() or 1200 + mh = self._host.winfo_height() or 800 + # main_frame 의 95% 안으로 cap + w = min(self._geom_w, int(mw * 0.95)) + h = min(self._geom_h, int(mh * 0.95)) + self.place(relx=0.5, rely=0.5, anchor="center", width=w, height=h) + self.lift() + except Exception: + with contextlib.suppress(Exception): + self.place(relx=0.5, rely=0.5, anchor="center") + self.lift() + + # --- ctk.CTkToplevel 호환 메서드 ----------------------------------------- + + def title(self, t: str) -> None: + self._title_text = t + if self._title_label is not None: + self._title_label.configure(text=t) + + def geometry(self, geom_str: str) -> None: + """\"WxH\" 또는 \"WxH+X+Y\" 형식 파싱. X,Y 는 무시 (always center).""" + try: + size_part = geom_str.split("+", 1)[0] + w_str, h_str = size_part.split("x") + self._geom_w = int(w_str) + self._geom_h = int(h_str) + self._reposition() + except (ValueError, AttributeError): + pass + + def transient(self, master) -> None: + """no-op (이미 main_frame 안).""" + + def grab_set(self) -> None: + """모달 시뮬레이트 — lift + focus.""" + with contextlib.suppress(Exception): + self.lift() + self.focus_set() + + def grab_release(self) -> None: + """no-op.""" + + def protocol(self, name: str, handler: Callable[[], None]) -> None: + if name == "WM_DELETE_WINDOW": + self._on_close = handler + + def iconbitmap(self, *args, **kwargs) -> None: + """Toplevel 전용 — no-op (인라인 패널은 OS 창이 아님).""" + + def iconphoto(self, *args, **kwargs) -> None: + """Toplevel 전용 — no-op.""" + + def wm_iconbitmap(self, *args, **kwargs) -> None: + """Toplevel 전용 — no-op.""" + + def wm_iconphoto(self, *args, **kwargs) -> None: + """Toplevel 전용 — no-op.""" + + def attributes(self, *args, **kwargs): + """\"-topmost\", \"-alpha\" 등 — no-op (인라인 패널은 z-order 관리만).""" + return None + + def overrideredirect(self, *args, **kwargs) -> None: + """no-op.""" + + # --- 종료 처리 ----------------------------------------------------------- + + def _user_close(self) -> None: + """타이틀 바 ✕ 또는 protocol(WM_DELETE_WINDOW) 호출 시 진입점.""" + if self._on_close is not None: + try: + self._on_close() + except Exception: + self.destroy() + else: + self.destroy() + + def destroy(self) -> None: + if self._closed: + return + self._closed = True + with contextlib.suppress(Exception): + self.place_forget() + super().destroy() + + # wait_window() 는 별도 정의 안 함 — tk.Misc.wait_window(self) 가 widget + # destruction 을 기다리는데, CTkFrame 이 tk.Widget 상속이라 Frame 에서도 동작. diff --git a/scanvas_maker.py b/scanvas_maker.py index 878ff75..38af92d 100644 --- a/scanvas_maker.py +++ b/scanvas_maker.py @@ -81,6 +81,20 @@ except ImportError: def set_perf_log(fn): # type: ignore[no-redef] pass +# InlinePanel (#4) — CTkToplevel 호환 인라인 오버레이. 별도 OS 창 안 띄우고 +# main_frame 안 floating frame 으로 렌더. 실패 시 폴백으로 CTkToplevel 사용. +try: + from harness.inline_panel import InlinePanel +except ImportError: + InlinePanel = ctk.CTkToplevel # type: ignore[assignment,misc] + +# 크래시 로거의 일반 로그 채널 — self.log() 가 인라인 textbox 대신 백엔드 파일에 +# 흘리도록 하기 위해 (#4 "로그는 백엔드로"). harness.logger 의 get_logger 와 별개. +try: + from harness.crash_logger import get_logger as _get_crash_logger +except ImportError: + _get_crash_logger = None # type: ignore[assignment] + # 구조물 상세도면 치수 파서 try: from detail_parser import DetailParser, dimensions_to_structure_params @@ -600,8 +614,8 @@ class SCanvasApp(ctk.CTk): 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) # 로그 (좁게) + # 피드백 #4: 인라인 로그 패널 제거. row 0 (지도/캔버스) 전체, row 1 (status_bar) 만 남음. + self.main_frame.grid_rowconfigure(0, weight=1) # 지도/캔버스 (전체) # 1. 지도 (상단 — 넓게). Light/Dark 테마별 배경 쌍 — tkintermapview # 타일 주변의 얇은 padding 에 이 색이 보임. @@ -620,15 +634,13 @@ class SCanvasApp(ctk.CTk): self.map_view.set_zoom(6) self.map_view.set_position(36.5, 127.5) - # 2. 로그 (하단 — 스크롤 가능. 피드백 #4: 인라인 로그 비중 축소, 백엔드 파일이 - # 주 기록처. height 120 → 80 으로 캔버스 영역 확보. - # 파일 로그: %LOCALAPPDATA%\\S-CANVAS\\scanvas_harness.log + logs/scanvas.log) - self.textbox = ctk.CTkTextbox(self.main_frame, height=80, font=ctk.CTkFont(family="Consolas", size=12), border_width=1) - self.textbox.grid(row=1, column=0, padx=0, pady=0, sticky="nsew") + # 2. 로그 패널 — 피드백 #4: **제거** (인라인 → 백엔드 파일). + # 파일 위치: %LOCALAPPDATA%\\S-CANVAS\\scanvas_harness.log + logs/scanvas.log. + # self.log() 호출은 그대로 유지하되 logger 로 흘러가고 status_text 만 갱신. - # 3. 하단 상태 바 + # 3. 하단 상태 바 — 로그 제거로 row 2 → row 1 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=1, column=0, sticky="ew", pady=(5, 0)) self.status_indicator = ctk.CTkLabel(self.status_bar, text="● READY", text_color="#22A06B", font=ctk.CTkFont(size=12, weight="bold")) self.status_indicator.pack(side="left", padx=10) @@ -747,11 +759,20 @@ class SCanvasApp(ctk.CTk): 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) + """피드백 #4: 인라인 로그 패널 제거 후, 모든 메시지는 백엔드 파일로. + + - 백엔드: %LOCALAPPDATA%\\S-CANVAS\\scanvas_harness.log + logs/scanvas.log + (RotatingFileHandler 5MB×5). + - 사용자 즉시 피드백: status_bar 의 status_text 가 짧은 미리보기로 갱신 + (긴 메시지는 80자에서 잘림 + …). + - 매 호출이 textbox.insert 으로 GUI 메인 thread 부담 주던 패턴 제거됨. + """ + if _get_crash_logger is not None: + with contextlib.suppress(Exception): + _get_crash_logger().info(message) + if hasattr(self, "status_text"): + short = message if len(message) <= 80 else message[:77] + "…" + self.after(0, lambda s=short: self.status_text.configure(text=s)) def start_progress(self, label: str | None = None) -> None: """진행률 인디케이터 표시 + indeterminate animation 시작. @@ -848,7 +869,7 @@ class SCanvasApp(ctk.CTk): option_list = list(type_options.values()) # 팝업 창 - win = ctk.CTkToplevel(self) + win = InlinePanel(self) win.title("S-CANVAS: DXF 레이어 분류") win.geometry("900x650") win.grab_set() @@ -1501,7 +1522,7 @@ class SCanvasApp(ctk.CTk): "완료하여 구조물 레이어를 등록해 주세요.") return - win = ctk.CTkToplevel(self) + win = InlinePanel(self) win.title("S-CANVAS: 구조물 상세 3D 빌드 (템플릿 기반)") win.geometry("1100x650") win.grab_set() @@ -1678,7 +1699,7 @@ class SCanvasApp(ctk.CTk): messagebox.showerror("오류", f"템플릿을 찾을 수 없습니다: {tid}") return - win = ctk.CTkToplevel(self) + win = InlinePanel(self) win.title(f"구조물 검토: {name}") win.geometry("760x820") win.grab_set() @@ -1971,7 +1992,7 @@ class SCanvasApp(ctk.CTk): return # 옵션 다이얼로그 — 시간대 + 투명배경 + 샘플 - opt_win = ctk.CTkToplevel(win) + opt_win = InlinePanel(win) opt_win.title("Blender Cycles 렌더 옵션") opt_win.geometry("420x340") opt_win.transient(win); opt_win.grab_set() @@ -2126,7 +2147,7 @@ class SCanvasApp(ctk.CTk): def _show_diff_dialog(diff: dict): """Gemini가 반환한 diff를 테이블로 보여주고 사용자가 체크한 항목만 적용.""" - dwin = ctk.CTkToplevel(win) + dwin = InlinePanel(win) dwin.title(f"AI 검증 결과: {name}") dwin.geometry("820x680") dwin.grab_set() @@ -2448,7 +2469,7 @@ class SCanvasApp(ctk.CTk): messagebox.showerror("오류", "detail_parser 모듈을 찾을 수 없습니다.") return - win = ctk.CTkToplevel(self) + win = InlinePanel(self) win.title("S-CANVAS: 구조물 상세도면 추가") win.geometry("950x600") win.grab_set() @@ -2568,7 +2589,7 @@ class SCanvasApp(ctk.CTk): """ info = self.structure_registry[layer_name] - win = ctk.CTkToplevel(self) + win = InlinePanel(self) win.title(f"치수 확인: {info['name']}") win.geometry("650x500") win.grab_set() @@ -2805,7 +2826,7 @@ class SCanvasApp(ctk.CTk): except Exception as e: self.log(f" 마커 표시 오류: {e}") - win = ctk.CTkToplevel(self) + win = InlinePanel(self) win.title("S-CANVAS: 계획선 고도 설정") win.geometry("1280x560") win.grab_set() @@ -4707,7 +4728,7 @@ class SCanvasApp(ctk.CTk): pts_abs = pts_zero + origin x0p, y0p, x1p, y1p = [float(v) for v in self.projected_bounds] - win = ctk.CTkToplevel(self) + win = InlinePanel(self) win.title("🎯 TIN 이용 범위 선택") win.geometry("1100x920") win.minsize(900, 640) @@ -6622,7 +6643,7 @@ class SCanvasApp(ctk.CTk): engine = self.render_engine.get() # 시간대 + 출력 화질 선택 (Step 4 실행 시) - time_win = ctk.CTkToplevel(self) + time_win = InlinePanel(self) time_win.title("렌더링 옵션") time_win.geometry("380x360") time_win.grab_set() @@ -6982,7 +7003,7 @@ class SCanvasApp(ctk.CTk): bg.convert("RGBA"), pil_img ).convert("RGB") - win = ctk.CTkToplevel(self) + win = InlinePanel(self) win.title(f"🎨 Blender 렌더 결과 - {Path(image_path).name}") sw = self.winfo_screenwidth(); sh = self.winfo_screenheight() @@ -7055,7 +7076,7 @@ class SCanvasApp(ctk.CTk): try: from PIL import ImageTk - win = ctk.CTkToplevel(self) + win = InlinePanel(self) win.title("S-CANVAS: AI 렌더링 결과") win.geometry("820x860")