"""apply_blender_patch.py — scanvas_maker.py 에 Blender 렌더 통합 패치 적용. 패치 내용 (3곳): P1) `_open_structure_template_dialog` 안에 `_do_blender_render` 콜백 추가 (기존 `_do_vlm_feedback` 함수 정의 바로 앞에 삽입) P2) "🤖 AI 검증" 버튼 다음 줄에 "🎨 Blender 렌더" 버튼 추가 P3) 클래스 메서드로 `_show_structure_render(self, image_path)` 추가 (`_show_rendered_result` 메서드 바로 앞에 삽입) 특징: - Idempotent: 이미 적용된 상태에서 다시 실행해도 변경 없음 (안전) - Anchor 기반: 원본의 정확한 텍스트를 찾아 그 위치에만 삽입 anchor 못 찾으면 즉시 중단 (파일 망가뜨리지 않음) - 백업: 실행 전 scanvas_maker.py.bak_blender 자동 생성 - 줄바꿈: 입력 파일이 CRLF면 출력도 CRLF, LF면 LF (보존) - AST parse 검증으로 결과 syntax 체크 사용법: cd D:\\2026\\PROGRAM\\1_S-CANVAS python apply_blender_patch.py 옵션: --dry-run 실제 쓰지 않고 어떤 변경이 일어날지 출력만 --no-backup 백업 파일 생성 생략 --check 이미 적용됐는지만 확인 (변경 없음) """ from __future__ import annotations import argparse import ast import shutil import sys from pathlib import Path TARGET = Path("scanvas_maker.py") BACKUP = Path("scanvas_maker.py.bak_blender") # =========================================================================== # 패치 정의 (anchor + insert) # =========================================================================== P1_GUARD = "def _do_blender_render():" P1_ANCHOR = " def _do_vlm_feedback():\n" P1_INSERT = ''' def _do_blender_render(): """Blender Cycles로 구조물 단독 고품질 렌더 (별도 트랙). AI 워크플로(Step 4)와 별개로 실행. 결과는 'structure_render.png'. transparent_bg=True 로 RGBA 출력 → 추후 지형 합성 입력으로도 사용 가능. """ try: from blender_renderer import run_blender_render from gate_3d_builder_bpy import dump_params_to_json as _dump_gate except ImportError as e: messagebox.showerror( "모듈 없음", f"Blender 렌더 모듈을 찾을 수 없습니다:\\n{e}\\n\\n" "blender_renderer.py / gate_3d_builder_bpy.py 가 " "S-CANVAS 폴더에 있는지 확인하세요.", parent=win, ) return if state["params"] is None: messagebox.showwarning( "안내", "파라미터가 비어있습니다. '미리보기 (빌드)' 먼저 실행하세요.", parent=win, ) return # 현재는 수문(spillway_gate)만 지원. if tid != "spillway_gate": messagebox.showinfo( "지원 예정", f"Blender 렌더는 현재 '여수로 수문(spillway_gate)' 템플릿만 " f"지원합니다.\\n현재 템플릿: {tid}", parent=win, ) return # 옵션 다이얼로그 — 시간대 + 투명배경 + 샘플 opt_win = ctk.CTkToplevel(win) opt_win.title("Blender Cycles 렌더 옵션") opt_win.geometry("420x340") opt_win.transient(win); opt_win.grab_set() ctk.CTkLabel( opt_win, text="Blender Cycles 렌더 옵션", font=ctk.CTkFont(size=14, weight="bold"), ).pack(pady=(15, 10)) ctk.CTkLabel(opt_win, text="조명 / 시간대").pack(anchor="w", padx=20) time_var = ctk.StringVar(value="daytime") tf = ctk.CTkFrame(opt_win, fg_color="transparent") tf.pack(fill="x", padx=20, pady=(0, 10)) for v, lbl in [("daytime", "주간"), ("sunset", "노을"), ("overcast", "흐림")]: ctk.CTkRadioButton(tf, text=lbl, variable=time_var, value=v).pack(side="left", padx=8) transparent_var = ctk.BooleanVar(value=False) ctk.CTkCheckBox( opt_win, text="투명 배경 (RGBA) — 지형 합성용", variable=transparent_var, ).pack(anchor="w", padx=20, pady=(0, 8)) ctk.CTkLabel(opt_win, text="Cycles 샘플 (높을수록 깨끗·느림)").pack(anchor="w", padx=20) samples_var = ctk.StringVar(value="128") sf = ctk.CTkFrame(opt_win, fg_color="transparent") sf.pack(fill="x", padx=20, pady=(0, 8)) for s in ("32", "64", "128", "256"): ctk.CTkRadioButton(sf, text=s, variable=samples_var, value=s).pack(side="left", padx=6) save_blend_var = ctk.BooleanVar(value=False) save_glb_var = ctk.BooleanVar(value=False) ctk.CTkCheckBox(opt_win, text=".blend 저장", variable=save_blend_var).pack(anchor="w", padx=20) ctk.CTkCheckBox(opt_win, text=".glb 저장 (외부 뷰어/VR)", variable=save_glb_var).pack(anchor="w", padx=20, pady=(0, 6)) def _start(): try: samples = int(samples_var.get()) except ValueError: samples = 128 t_preset = time_var.get() trans = bool(transparent_var.get()) save_b = bool(save_blend_var.get()) save_g = bool(save_glb_var.get()) opt_win.destroy() try: json_path = "gate_params.json" _dump_gate(state["params"], json_path) self.log(f" [{name}] GateParams -> {json_path}") except Exception as e: messagebox.showerror("JSON 저장 실패", f"GateParams JSON 직렬화 실패:\\n{e}", parent=win) return self.log(f" [{name}] Blender 렌더 시작 " f"(time={t_preset}, samples={samples}, " f"bg={'투명' if trans else 'sky'})") threading.Thread( target=run_blender_render, args=(self, None, json_path), kwargs=dict( time_preset=t_preset, engine="CYCLES", samples=samples, output_path="structure_render.png", transparent_bg=trans, save_blend=save_b, save_glb=save_g, structure_kind="gate", ), daemon=True, ).start() bf = ctk.CTkFrame(opt_win, fg_color="transparent") bf.pack(fill="x", pady=15, padx=20) ctk.CTkButton(bf, text="취소", width=80, fg_color="transparent", border_width=1, command=opt_win.destroy).pack(side="left") ctk.CTkButton(bf, text="🎨 렌더 시작", width=140, fg_color="#16A085", hover_color="#117A65", text_color="white", command=_start).pack(side="right") ''' + P1_ANCHOR P2_GUARD = '🎨 Blender 렌더' P2_ANCHOR = ( ' ctk.CTkButton(bottom, text="🤖 AI 검증", width=110,\n' ' fg_color="#D35400", hover_color="#A04000",\n' ' text_color="white",\n' ' font=ctk.CTkFont(size=11, weight="bold"),\n' ' command=_do_vlm_feedback).pack(side="right", padx=3)\n' ) P2_INSERT = P2_ANCHOR + ( ' ctk.CTkButton(bottom, text="🎨 Blender 렌더", width=140,\n' ' fg_color="#16A085", hover_color="#117A65",\n' ' text_color="white",\n' ' font=ctk.CTkFont(size=11, weight="bold"),\n' ' command=_do_blender_render).pack(side="right", padx=3)\n' ) P3_GUARD = "def _show_structure_render(self," P3_ANCHOR = " def _show_rendered_result(self, image_path):\n" P3_INSERT = ''' def _show_structure_render(self, image_path): """Blender 구조물 렌더 결과를 별도 창에 표시 (AI 결과와 분리). blender_renderer.py 가 호출. 투명 PNG도 정상 표시(체커보드 배경). """ try: from PIL import ImageTk, Image as _PILImage try: pil_img = _PILImage.open(image_path) except Exception as e: messagebox.showerror("이미지 열기 실패", f"렌더 결과 PNG를 열 수 없습니다:\\n{image_path}\\n\\n{e}") return # 투명 PNG는 체커보드 배경 위에 합성 표시 display_img = pil_img if pil_img.mode == "RGBA": from PIL import ImageDraw tile = _PILImage.new("RGB", (16, 16), (200, 200, 200)) d = ImageDraw.Draw(tile) d.rectangle((0, 0, 7, 7), fill=(170, 170, 170)) d.rectangle((8, 8, 15, 15), fill=(170, 170, 170)) bg = _PILImage.new("RGB", pil_img.size, (200, 200, 200)) for y in range(0, pil_img.size[1], 16): for x in range(0, pil_img.size[0], 16): bg.paste(tile, (x, y)) display_img = _PILImage.alpha_composite( bg.convert("RGBA"), pil_img ).convert("RGB") win = ctk.CTkToplevel(self) win.title(f"🎨 Blender 렌더 결과 - {Path(image_path).name}") sw = self.winfo_screenwidth(); sh = self.winfo_screenheight() max_w, max_h = int(sw * 0.7), int(sh * 0.75) iw, ih = display_img.size scale = min(max_w / iw, max_h / ih, 1.0) disp_w, disp_h = int(iw * scale), int(ih * scale) disp = display_img.resize((disp_w, disp_h), _PILImage.LANCZOS) tk_img = ImageTk.PhotoImage(disp) lbl = ctk.CTkLabel(win, text="", image=tk_img) lbl.image = tk_img lbl.pack(padx=10, pady=10) info = ctk.CTkFrame(win, fg_color="transparent") info.pack(fill="x", padx=10, pady=(0, 5)) mode_str = "투명 배경 (RGBA, 합성용)" if pil_img.mode == "RGBA" else "Sky 배경 (RGB)" ctk.CTkLabel( info, text=f"파일: {image_path} · 원본 {iw}×{ih} · {mode_str}", font=ctk.CTkFont(size=11), ).pack(side="left", padx=5) btnf = ctk.CTkFrame(win, fg_color="transparent") btnf.pack(fill="x", padx=10, pady=(0, 10)) def _open_external(): try: if sys.platform == "win32": os.startfile(image_path) elif sys.platform == "darwin": import subprocess subprocess.Popen(["open", image_path]) else: import subprocess subprocess.Popen(["xdg-open", image_path]) except Exception as e: messagebox.showerror("열기 실패", f"기본 뷰어 실행 실패:\\n{e}", parent=win) def _open_folder(): try: folder = str(Path(image_path).resolve().parent) if sys.platform == "win32": os.startfile(folder) elif sys.platform == "darwin": import subprocess subprocess.Popen(["open", folder]) else: import subprocess subprocess.Popen(["xdg-open", folder]) except Exception as e: messagebox.showerror("폴더 열기 실패", f"{e}", parent=win) ctk.CTkButton(btnf, text="📂 폴더 열기", width=110, command=_open_folder).pack(side="right", padx=3) ctk.CTkButton(btnf, text="🖼 외부 뷰어로 열기", width=160, command=_open_external).pack(side="right", padx=3) ctk.CTkButton(btnf, text="닫기", width=80, fg_color="transparent", border_width=1, command=win.destroy).pack(side="left", padx=3) except Exception as e: import traceback traceback.print_exc() messagebox.showerror("결과 창 오류", f"렌더 결과 창 생성 실패:\\n{e}") ''' + P3_ANCHOR # =========================================================================== # 메인 # =========================================================================== def detect_eol(raw_bytes: bytes) -> str: """원본 파일의 줄바꿈 형식 감지. 패치 후 보존.""" crlf = raw_bytes.count(b"\r\n") lf = raw_bytes.count(b"\n") - crlf if crlf > lf: return "\r\n" return "\n" def main(): ap = argparse.ArgumentParser() ap.add_argument("--dry-run", action="store_true", help="실제 쓰지 않고 변경 사항만 출력") ap.add_argument("--no-backup", action="store_true", help="백업 파일 생성 생략") ap.add_argument("--check", action="store_true", help="패치 적용 상태만 확인 (변경 없음)") args = ap.parse_args() if not TARGET.is_file(): sys.exit(f"[ERR] {TARGET} 가 현재 폴더에 없습니다. " f"D:\\2026\\PROGRAM\\1_S-CANVAS 에서 실행하세요.") raw = TARGET.read_bytes() eol = detect_eol(raw) print(f"파일: {TARGET} ({len(raw):,} bytes, EOL={'CRLF' if eol == chr(13) + chr(10) else 'LF'})") # 항상 LF로 정규화해서 작업 (anchor 매칭 일관성) src = raw.decode("utf-8").replace("\r\n", "\n") original_src = src # 적용 상태 점검 states = { "P1 (callback _do_blender_render)": P1_GUARD in src, "P2 (button '🎨 Blender 렌더')": P2_GUARD in src, "P3 (method _show_structure_render)": P3_GUARD in src, } all_applied = all(states.values()) none_applied = not any(states.values()) print("\n현재 상태:") for name, applied in states.items(): mark = "✓ 적용됨" if applied else " 미적용" print(f" {mark} {name}") if args.check: if all_applied: print("\n[CHECK] 모든 패치 적용됨") sys.exit(0) elif none_applied: print("\n[CHECK] 패치 미적용") sys.exit(1) else: print("\n[CHECK] 부분 적용 — 권장: --dry-run 으로 확인 후 정상 실행") sys.exit(2) if all_applied: print("\n→ 모든 패치 이미 적용됨. 추가 작업 없음.") return 0 # 적용 changes = [] if P1_GUARD not in src: if P1_ANCHOR not in src: sys.exit("[ERR] P1 anchor 'def _do_vlm_feedback():' 못 찾음. " "scanvas_maker.py 구조가 변경되었을 수 있습니다.") src = src.replace(P1_ANCHOR, P1_INSERT, 1) changes.append("P1: callback _do_blender_render 추가") if P2_GUARD not in src: if P2_ANCHOR not in src: sys.exit("[ERR] P2 anchor (🤖 AI 검증 버튼 블록) 못 찾음.") src = src.replace(P2_ANCHOR, P2_INSERT, 1) changes.append("P2: 🎨 Blender 렌더 버튼 추가") if P3_GUARD not in src: if P3_ANCHOR not in src: sys.exit("[ERR] P3 anchor 'def _show_rendered_result' 못 찾음.") src = src.replace(P3_ANCHOR, P3_INSERT, 1) changes.append("P3: method _show_structure_render 추가") print("\n적용할 변경:") for c in changes: print(f" + {c}") print(f"\n 파일 크기: {len(original_src):,} → {len(src):,} chars " f"({len(src) - len(original_src):+,})") # AST parse 검증 try: ast.parse(src) print(f" AST parse: OK ({len(src.splitlines()):,} lines)") except SyntaxError as e: sys.exit(f"\n[ERR] 패치 후 syntax error 발생: {e}\n" f"파일 변경 안 함. 원본 유지.") if args.dry_run: print("\n[DRY RUN] 실제 파일은 변경하지 않았습니다. 적용하려면 --dry-run 빼고 재실행.") return 0 # 백업 if not args.no_backup: shutil.copy2(TARGET, BACKUP) print(f"\n 백업: {BACKUP}") # 원본 EOL로 복원해서 쓰기 final_bytes = src.encode("utf-8") if eol == "\r\n": final_bytes = final_bytes.replace(b"\r\n", b"\n").replace(b"\n", b"\r\n") TARGET.write_bytes(final_bytes) print(f"\n✓ 패치 적용 완료: {TARGET} ({len(final_bytes):,} bytes)") print("\n다음 단계:") print(" 1) S-CANVAS 실행: python scanvas_maker.py") print(" 2) Step 2에서 도면 로드 → 구조물 식별") print(" 3) 사이드바 '구조물 상세 빌드' 다이얼로그 → 수문 선택") print(" 4) 파라미터 조정 후 '🗔 미리보기' 또는 '🎨 Blender 렌더'") if __name__ == "__main__": sys.exit(main() or 0)