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

726
blender_renderer.py Normal file
View File

@@ -0,0 +1,726 @@
"""Blender 헤드리스 기반 구조물 단독 렌더링 워커.
scanvas_maker.SCanvasApp 안의 구조물 빌드 다이얼로그(_open_structure_template_dialog)
에서 호출되어, 사용자 파라미터로 빌드된 구조물을 Blender Cycles로 고품질 렌더한다.
워크플로 안에서의 위치:
[A] DXF + 파라미터 → Blender 빌더 → Cycles 렌더 → 구조물 PNG ← 이 모듈
[B] 별도 트랙: DEM/위성 → 지형 capture map
[C] 합성: 지형 capture에 구조물 PNG 얹기 ← 추후 작업
[D] AI 트랙: 합성 control map → Gemini → 최종 조감도
따라서:
- 출력 파일명은 'structure_render.png' (AI 결과 'rendered_birdseye.png'
덮어쓰지 않음)
- 결과 표시는 app._show_rendered_result()가 아니라 app._show_structure_render()
로 분리 (없으면 OS 기본 뷰어 폴백, GUI 없는 환경에서는 단순 print)
- transparent_bg=True 면 RGBA PNG → 추후 [C] 합성에 직접 사용 가능
- transparent_bg=False 면 Sky 배경 → 단독 발표용 그림으로 사용
공개 API:
run_blender_render(app, blender_exe, params_json, ...)
----------------------------------------------------------------------
v3 (워크플로 분리):
- 출력 기본값: rendered_birdseye.png → structure_render.png
- app._show_rendered_result → app._show_structure_render (없으면 OS 폴백)
- transparent_bg 인자 추가 → 빌더의 setup_lighting_and_camera/render_to_png
호출 시 동일하게 전달
- PIL 후처리 분기: 투명 PNG는 RGBA로 리사이즈 (RGB 변환하면 알파 손실)
v2: tkinter / PIL 을 lazy import (GUI 없는 환경에서도 모듈 로드 가능)
v1: 초기 버전
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
import tempfile
import textwrap
import time as _time
from pathlib import Path
import contextlib
# tkinter / PIL 은 GUI 컨텍스트에서만 필요 → lazy import.
# Harness 의존 (선택적 — 없으면 그냥 렌더만 진행)
try:
from harness.seed_manager import SeedManager
from harness.logger import get_db_session
_HARNESS_OK = True
except Exception:
SeedManager = None # type: ignore
get_db_session = None # type: ignore
_HARNESS_OK = False
# ===========================================================================
# Lazy import 헬퍼
# ===========================================================================
def _show_error_dialog(title: str, msg: str) -> None:
"""tkinter messagebox로 에러 다이얼로그. 없으면 stderr 폴백."""
try:
from tkinter import messagebox
messagebox.showerror(title, msg)
except Exception:
sys.stderr.write(f"[ERROR] {title}: {msg}\n")
def _open_image(path: str):
"""PIL.Image.open 의 lazy wrapper."""
from PIL import Image
return Image.open(path)
def _image_lanczos():
"""PIL.Image.LANCZOS 상수 lazy 접근."""
from PIL import Image
return Image.LANCZOS
def _open_in_os_default_viewer(path: str) -> bool:
"""플랫폼별 기본 이미지 뷰어로 PNG 열기. 결과 표시 폴백용.
Returns: True if launched successfully (best-effort)
"""
try:
if sys.platform == "win32":
os.startfile(path) # type: ignore[attr-defined]
elif sys.platform == "darwin":
subprocess.Popen(["open", path])
else:
subprocess.Popen(["xdg-open", path])
return True
except Exception:
return False
# ===========================================================================
# Blender 실행파일 자동 탐색
# ===========================================================================
_BLENDER_SEARCH_PATHS = {
"win32": [
r"C:\Program Files\Blender Foundation\Blender 4.5\blender.exe",
r"C:\Program Files\Blender Foundation\Blender 4.4\blender.exe",
r"C:\Program Files\Blender Foundation\Blender 4.3\blender.exe",
r"C:\Program Files\Blender Foundation\Blender 4.2\blender.exe",
r"C:\Program Files\Blender Foundation\Blender 4.1\blender.exe",
r"C:\Program Files\Blender Foundation\Blender 4.0\blender.exe",
r"C:\Program Files\Blender Foundation\Blender 3.6\blender.exe",
],
"darwin": [
"/Applications/Blender.app/Contents/MacOS/Blender",
],
"linux": [
"/usr/bin/blender",
"/usr/local/bin/blender",
"/snap/bin/blender",
],
}
def find_blender_executable(explicit: str | None = None) -> str | None:
"""Blender 실행파일 위치 결정.
우선순위:
1) 인자로 명시
2) 환경변수 BLENDER_EXE
3) PATH 안의 'blender' / 'blender.exe'
4) 플랫폼별 표준 설치 경로 (win/mac/linux)
5) Windows의 경우 'C:\\Program Files\\Blender Foundation\\*' glob
Returns: 절대 경로 (str) 또는 None
"""
if explicit and Path(explicit).is_file():
return str(Path(explicit).resolve())
env_exe = os.environ.get("BLENDER_EXE")
if env_exe and Path(env_exe).is_file():
return str(Path(env_exe).resolve())
which = shutil.which("blender") or shutil.which("blender.exe")
if which:
return str(Path(which).resolve())
candidates = _BLENDER_SEARCH_PATHS.get(sys.platform, [])
for c in candidates:
if Path(c).is_file():
return str(Path(c).resolve())
if sys.platform == "win32":
prog_files = Path(r"C:\Program Files\Blender Foundation")
if prog_files.is_dir():
for sub in sorted(prog_files.iterdir(), reverse=True):
exe = sub / "blender.exe"
if exe.is_file():
return str(exe.resolve())
return None
# ===========================================================================
# 구조물 종류 자동 감지 + 빌더 라우팅
# ===========================================================================
_STRUCTURE_REGISTRY = {
"gate": {
"module": "gate_3d_builder_bpy",
"klass": "GateBuilderBpy",
"marker": "n_gates",
},
"intake_tower": {
"module": "intake_tower_3d_builder_bpy",
"klass": "IntakeTowerBuilderBpy",
"marker": "body_top_el",
},
}
def detect_structure_kind(params_json_path: str) -> str | None:
"""JSON 안의 marker 필드를 보고 구조물 종류 판별."""
try:
with open(params_json_path, encoding="utf-8") as f:
d = json.load(f)
except Exception:
return None
if not isinstance(d, dict):
return None
for kind, spec in _STRUCTURE_REGISTRY.items():
marker = spec["marker"]
if marker in d and isinstance(d[marker], (int, float)):
return kind
return None
# ===========================================================================
# 동적 wrapper script 생성 (Cycles seed + transparent_bg 주입)
# ===========================================================================
def _generate_blender_runner(
*,
s_canvas_dir: str,
structure_kind: str,
params_json: str,
output_path: str,
seed: int,
samples: int,
engine: str,
time_preset: str,
width: int,
height: int,
transparent_bg: bool = False,
blend_path: str | None = None,
glb_path: str | None = None,
) -> str:
"""헤드리스 Blender에 줄 임시 Python 스크립트.
Cycles seed + transparent_bg 모두 빌더 외부에서 주입.
빌더 파일은 immutable 유지.
"""
spec = _STRUCTURE_REGISTRY[structure_kind]
module = spec["module"]
klass = spec["klass"]
src = textwrap.dedent(f"""
# Auto-generated by blender_renderer.py — do not edit.
import sys, json, traceback
from pathlib import Path
SCANVAS_DIR = {s_canvas_dir!r}
if SCANVAS_DIR not in sys.path:
sys.path.insert(0, SCANVAS_DIR)
try:
import bpy
import {module} as B
# 1) Params 로드
params_dict = json.loads(Path({params_json!r}).read_text(encoding="utf-8"))
params = B.params_from_dict(params_dict)
# 2) 3D scene 빌드
builder = B.{klass}(params, clear_scene=True)
builder.build_all()
print(f"[blender_renderer] objects={{builder._object_count}}")
# 3) 카메라 + 조명 (transparent_bg 옵션 포함)
B.setup_lighting_and_camera(
params,
time_preset={time_preset!r},
transparent_bg={transparent_bg!r},
)
# 4) Cycles seed (결정론적) — 빌더 외부에서 주입
scene = bpy.context.scene
if {engine!r} == "CYCLES":
scene.cycles.seed = {seed}
print(f"[blender_renderer] cycles.seed={{scene.cycles.seed}}")
# 5) (옵션) .blend / .glb 부수 출력
blend_path = {blend_path!r}
glb_path = {glb_path!r}
if blend_path:
B.save_blend(blend_path)
print(f"[blender_renderer] saved .blend -> {{blend_path}}")
if glb_path:
B.export_glb(glb_path)
print(f"[blender_renderer] saved .glb -> {{glb_path}}")
# 6) PNG 렌더 (transparent_bg 전달)
B.render_to_png(
{output_path!r},
resolution=({width}, {height}),
samples={samples},
engine={engine!r},
transparent_bg={transparent_bg!r},
)
print(f"[blender_renderer] OK -> {output_path}")
except Exception as e:
print("[blender_renderer] FAIL:", e)
traceback.print_exc()
sys.exit(2)
""")
return src
# ===========================================================================
# subprocess 실행 + stdout 스트리밍
# ===========================================================================
def _run_blender_subprocess(
blender_exe: str,
runner_script: str,
*,
log_callback,
timeout_sec: int = 1800,
) -> tuple[int, str]:
"""Blender를 subprocess로 호출. stdout 줄 단위로 log_callback에 전달."""
cmd = [blender_exe, "--background", "--python", runner_script]
log_callback(f" $ {Path(blender_exe).name} --background --python <runner.py>")
try:
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
bufsize=1,
)
except FileNotFoundError as e:
return -1, f"Blender 실행 실패: {e}"
captured: list[str] = []
t_start = _time.time()
try:
for raw_line in proc.stdout: # type: ignore
line = raw_line.rstrip()
captured.append(line)
if any(tag in line for tag in
("[blender_renderer]", "[bpy-gate]", "[bpy-builder]",
"Saved:", "Render finished")):
log_callback(f" {line}")
if _time.time() - t_start > timeout_sec:
proc.kill()
captured.append(f"[timeout] {timeout_sec}초 초과")
log_callback(f"{timeout_sec}초 초과 — Blender 강제 종료")
break
proc.wait(timeout=10)
except Exception as e:
proc.kill()
captured.append(f"[exception] {e}")
return proc.returncode, "\n".join(captured)
# ===========================================================================
# 메인 진입점
# ===========================================================================
def run_blender_render(
app,
blender_exe: str | None,
params_json: str,
*,
time_preset: str = "daytime",
engine: str = "CYCLES",
samples: int = 128,
output_path: str = "structure_render.png",
transparent_bg: bool = False,
save_blend: bool = False,
save_glb: bool = False,
structure_kind: str | None = None,
timeout_sec: int = 1800,
) -> None:
"""구조물 단독을 Blender Cycles로 렌더 + Harness 통합.
AI 워크플로(Step 4)와 **별개의 트랙**입니다. 결과 PNG는 추후 지형 합성([C])
의 입력으로 사용되거나 단독 발표용으로 사용됩니다.
Args:
app: scanvas_maker.SCanvasApp (상태/로그/UI scheduling 접근)
blender_exe: Blender 실행파일 경로. None이면 자동 탐색.
params_json: GateParams 또는 IntakeTowerParams JSON 경로.
time_preset: 'daytime' | 'sunset' | 'overcast'
engine: 'CYCLES' (사실적) | 'BLENDER_EEVEE_NEXT' (빠른 미리보기)
samples: Cycles 샘플 수 (CYCLES 외 엔진은 무시)
output_path: 결과 PNG. 기본 'structure_render.png' (AI 결과와 분리)
transparent_bg: True면 RGBA + 투명 배경 (지형 합성 입력용)
save_blend: True면 .blend 추가 저장
save_glb: True면 .glb 추가 저장 (VR/AR/외부 뷰어용)
structure_kind: 'gate' | 'intake_tower' | None (auto-detect)
timeout_sec: subprocess 강제 종료 임계 (기본 30분)
UI / harness 통합 동작:
- app.log(...) 로 진행 상황 출력
- app.set_status(...) 로 상태바 갱신
- SeedManager로 결정론적 seed 산출 → Cycles seed 주입
- QualityValidator로 결과 검증
- JobLogger로 SQLite 이력 기록
- 완료 후 app._show_structure_render(output_path) 호출
(없으면 OS 기본 뷰어로 폴백)
"""
t_start = _time.time()
job_id = None
db = None
runner_path: Path | None = None
# ── 0) 사전 점검 ────────────────────────────────────────────────────
blender_exe = find_blender_executable(blender_exe)
if not blender_exe:
msg = (
"Blender 실행파일을 찾을 수 없습니다.\n\n"
"다음 중 하나로 해결:\n"
" 1) 환경변수 BLENDER_EXE 설정 (절대경로)\n"
" 2) 표준 위치에 설치 (Windows: "
"C:\\Program Files\\Blender Foundation\\Blender X.X\\blender.exe)\n"
" 3) PATH에 'blender' 추가\n\n"
"다운로드: https://www.blender.org/download/"
)
app.after(0, lambda: app.log(f"{msg.splitlines()[0]}"))
app.after(0, lambda m=msg: _show_error_dialog("Blender 없음", m))
app.after(0, lambda: app.set_status("Blender 실행파일 없음", "#E74C3C"))
return
if not Path(params_json).is_file():
app.after(0, lambda p=params_json: app.log(f" ✗ params_json 없음: {p}"))
app.after(0, lambda p=params_json: _show_error_dialog("파일 없음",
f"파라미터 JSON 파일을 찾을 수 없습니다:\n{p}\n\n"
"구조물 상세 3D 빌드 단계에서 먼저 JSON을 생성해야 합니다."))
app.after(0, lambda: app.set_status("params.json 없음", "#E74C3C"))
return
if structure_kind is None:
structure_kind = detect_structure_kind(params_json)
if structure_kind not in _STRUCTURE_REGISTRY:
app.after(0, lambda k=structure_kind: app.log(
f" ✗ 구조물 종류 인식 실패 (kind={k!r})"))
kinds = ', '.join(_STRUCTURE_REGISTRY.keys())
app.after(0, lambda p=params_json, ks=kinds: _show_error_dialog(
"구조물 인식 실패",
f"JSON에서 구조물 종류를 자동 감지할 수 없습니다.\n"
f"params_json: {p}\n"
f"지원 종류: {ks}"))
return
s_canvas_dir = str(Path(params_json).resolve().parent)
builder_module = _STRUCTURE_REGISTRY[structure_kind]["module"]
builder_path = Path(s_canvas_dir) / f"{builder_module}.py"
if not builder_path.is_file():
app.after(0, lambda bp=builder_path: app.log(f" ✗ 빌더 모듈 없음: {bp}"))
app.after(0, lambda bm=builder_module, bp=builder_path: _show_error_dialog(
"빌더 모듈 없음",
f"{bm}.py 가 다음 위치에 없습니다:\n{bp}"))
return
# ── 1) Harness — Job 시작 ────────────────────────────────────────────
dxf_hash = ""
prompt_hash = ""
prompt_ver = "blender_v1"
seed = 0
try:
dxf_hash = app._get_dxf_hash()
except Exception:
try:
import hashlib
with open(params_json, "rb") as f:
dxf_hash = hashlib.sha256(f.read()).hexdigest()[:16]
except Exception:
dxf_hash = ""
with contextlib.suppress(Exception):
prompt_hash = app._get_prompt_hash(
f"engine={engine}|time={time_preset}|samples={samples}|"
f"transparent={transparent_bg}|kind={structure_kind}"
)
if app.job_logger and _HARNESS_OK:
try:
db = get_db_session()
job = app.job_logger.create_job(db, app.dxf_path or params_json, dxf_hash)
job_id = job.id
seed = app.seed_mgr.get_or_create_seed(db, job_id, dxf_hash)
if app.prompt_reg:
prompt_ver = app.prompt_reg.latest_version() or "blender_v1"
app.job_logger.start_job(db, job_id, seed, prompt_ver, prompt_hash)
app.after(0, lambda jid=job_id, s=seed: app.log(
f" Harness: job#{jid}, {SeedManager.describe(s)}, "
f"engine={engine}, samples={samples}, time={time_preset}, "
f"bg={'transparent' if transparent_bg else 'sky'}"))
except Exception as e:
app.after(0, lambda em=str(e): app.log(f" Harness 초기화 경고: {em}"))
# ── 2) Wrapper script + subprocess 실행 ──────────────────────────────
try:
# 출력 해상도 — app.target_resolution 우선, 없으면 1920×1080
# (구조물 단독 렌더는 AI 입력 해상도와 다를 수 있어 별도 속성도 검사)
struct_tgt = getattr(app, "structure_render_resolution", None)
if struct_tgt and struct_tgt[0] > 0 and struct_tgt[1] > 0:
width, height = int(struct_tgt[0]), int(struct_tgt[1])
else:
tgt = getattr(app, "target_resolution", None)
if tgt and tgt[0] > 0 and tgt[1] > 0:
width, height = int(tgt[0]), int(tgt[1])
else:
width, height = 1920, 1080
out_abs = str(Path(output_path).resolve())
blend_path = (Path(out_abs).with_suffix(".blend").as_posix()
if save_blend else None)
glb_path = (Path(out_abs).with_suffix(".glb").as_posix()
if save_glb else None)
runner_src = _generate_blender_runner(
s_canvas_dir=s_canvas_dir,
structure_kind=structure_kind,
params_json=str(Path(params_json).resolve()),
output_path=out_abs,
seed=seed,
samples=samples,
engine=engine,
time_preset=time_preset,
width=width,
height=height,
transparent_bg=transparent_bg,
blend_path=blend_path,
glb_path=glb_path,
)
tmp_dir = tempfile.gettempdir()
runner_path = Path(tmp_dir) / f"scanvas_blender_runner_{os.getpid()}.py"
runner_path.write_text(runner_src, encoding="utf-8")
bg_label = "투명배경" if transparent_bg else "Sky 배경"
app.after(0, lambda be=blender_exe, sk=structure_kind, w=width, h=height,
s=seed, bl=bg_label: app.log(
f" Blender 실행 중... ({Path(be).name}, {sk}, {w}×{h}, "
f"seed={s}, {bl})"))
app.after(0, lambda: app.set_status("Blender 렌더링 중...", "#3498DB"))
def _ui_log(msg: str):
app.after(0, lambda m=msg: app.log(m))
rc, full_log = _run_blender_subprocess(
blender_exe, str(runner_path),
log_callback=_ui_log,
timeout_sec=timeout_sec,
)
if rc != 0:
tail = "\n".join(full_log.splitlines()[-30:])
err_msg = f"Blender 종료 코드 {rc}.\n\n마지막 출력:\n{tail}"
if app.job_logger and db and job_id:
with contextlib.suppress(Exception):
app.job_logger.fail_job(db, job_id, f"rc={rc}")
app.after(0, lambda r=rc: app.log(f" ✗ Blender 실패 (rc={r})"))
app.after(0, lambda em=err_msg: _show_error_dialog("Blender 오류", em))
app.after(0, lambda: app.set_status("Blender 렌더링 실패", "#E74C3C"))
return
if not Path(out_abs).is_file():
if app.job_logger and db and job_id:
with contextlib.suppress(Exception):
app.job_logger.fail_job(db, job_id, "출력 PNG 없음")
app.after(0, lambda o=out_abs: app.log(f" ✗ 출력 파일이 생성되지 않았습니다: {o}"))
app.after(0, lambda: app.set_status("출력 파일 없음", "#E74C3C"))
return
# ── 3) 출력 후처리 (해상도 강제 시) ─────────────────────────────
rendered = _open_image(out_abs)
# 투명 PNG는 RGBA 유지 (RGB 변환하면 알파 손실)
if transparent_bg and rendered.mode != "RGBA":
rendered = rendered.convert("RGBA")
# 사용자가 명시적으로 다른 해상도를 요청한 경우만 리사이즈
# (struct_tgt 기준; 일반적으론 빌더가 만든 그대로 사용)
target_size = (width, height)
if rendered.size != target_size:
src_size = rendered.size
app.after(0, lambda s=src_size, t=target_size: app.log(
f" 화질 리사이즈: {s[0]}x{s[1]}{t[0]}x{t[1]}"))
rendered = rendered.resize(target_size, _image_lanczos())
rendered.save(out_abs)
latency_ms = (_time.time() - t_start) * 1000
# ── 4) 품질 검증 ────────────────────────────────────────────────
quality_score = 0.0
if app.quality_val:
try:
vr = app.quality_val.validate(Path(out_abs))
quality_score = vr.score
app.after(0, lambda s=vr.summary: app.log(f" 품질검증: {s}"))
except Exception as e:
app.after(0, lambda em=str(e): app.log(f" 품질검증 오류: {em}"))
# ── 5) Job 완료 ─────────────────────────────────────────────────
if app.job_logger and db and job_id:
with contextlib.suppress(Exception):
app.job_logger.complete_job(
db, job_id, out_abs, quality_score, latency_ms
)
app.after(0, lambda o=out_abs, sz=rendered.size, lm=latency_ms,
q=quality_score, s=seed: app.log(
f" Blender 렌더링 완료! → {o} ({sz}) "
f"[{lm:.0f}ms, 품질={q:.2f}, seed={s}]"))
app.after(0, lambda: app.set_status("Blender 렌더링 완료", "#2ECC71"))
# ── 6) 결과 표시 — AI 워크플로와 분리 ────────────────────────────
# 우선 app._show_structure_render(path) 시도. 없으면 OS 기본 뷰어 폴백.
def _present_result(path=out_abs):
shown = False
shower = getattr(app, "_show_structure_render", None)
if callable(shower):
try:
shower(path)
shown = True
except Exception as e:
app.log(f" 결과 표시 실패 (_show_structure_render): {e}")
if not shown:
if _open_in_os_default_viewer(path):
app.log(f" 결과 PNG를 기본 뷰어로 열었습니다: {path}")
else:
app.log(f" 결과 PNG: {path} (뷰어 자동 실행 실패 — "
f"파일을 직접 열어 확인하세요)")
app.after(0, _present_result)
except Exception as e:
if app.job_logger and db and job_id:
with contextlib.suppress(Exception):
app.job_logger.fail_job(db, job_id, str(e))
err_msg = str(e)[:300]
app.after(0, lambda em=err_msg: app.log(f" Blender 워커 오류: {em}"))
app.after(0, lambda: app.set_status("렌더링 실패", "#E74C3C"))
app.after(0, lambda em=err_msg: _show_error_dialog(
"Blender 워커 오류", f"실행 오류:\n{em}"))
finally:
if runner_path and runner_path.is_file():
with contextlib.suppress(Exception):
runner_path.unlink()
if db:
with contextlib.suppress(Exception):
db.close()
# ===========================================================================
# CLI 진입점 (단독 실행 / 디버그용)
# ===========================================================================
class _StubApp:
"""app 인터페이스 흉내 — CLI 모드에서 가짜 app 객체로 사용."""
def __init__(self, dxf_path: str = ""):
self.dxf_path = dxf_path
self.target_resolution = None
self.structure_render_resolution = None
self.job_logger = None
self.seed_mgr = None
self.prompt_reg = None
self.quality_val = None
def log(self, msg: str):
print(msg)
def set_status(self, text: str, color: str = ""):
print(f"[status] {text}")
def after(self, delay_ms: int, fn):
try:
fn()
except Exception as e:
print(f"[after-error] {e}")
def _get_dxf_hash(self) -> str:
return ""
def _get_prompt_hash(self, prompt: str) -> str:
import hashlib
return hashlib.sha256(prompt.encode()).hexdigest()[:16]
def _show_structure_render(self, path: str):
print(f"[structure-render] {path}")
def _cli():
import argparse
ap = argparse.ArgumentParser(
description="Blender 헤드리스 구조물 렌더 (S-CANVAS 외부 단독 실행)"
)
ap.add_argument("--params", required=True, help="GateParams/IntakeTowerParams JSON")
ap.add_argument("--blender", default=None,
help="Blender 실행파일 경로 (없으면 자동 탐색)")
ap.add_argument("--output", default="structure_render.png",
help="출력 PNG (기본 'structure_render.png')")
ap.add_argument("--time", default="daytime",
choices=["daytime", "sunset", "overcast"])
ap.add_argument("--engine", default="CYCLES",
choices=["CYCLES", "BLENDER_EEVEE", "BLENDER_EEVEE_NEXT"])
ap.add_argument("--samples", type=int, default=128)
ap.add_argument("--width", type=int, default=1920)
ap.add_argument("--height", type=int, default=1080)
ap.add_argument("--transparent", action="store_true",
help="투명 배경 RGBA PNG (지형 합성 입력용)")
ap.add_argument("--save-blend", action="store_true")
ap.add_argument("--save-glb", action="store_true")
ap.add_argument("--kind", default=None,
choices=list(_STRUCTURE_REGISTRY.keys()),
help="구조물 종류 (없으면 JSON에서 자동 감지)")
ap.add_argument("--timeout", type=int, default=1800,
help="subprocess timeout (초)")
args = ap.parse_args()
stub = _StubApp()
stub.structure_render_resolution = (args.width, args.height)
run_blender_render(
stub,
blender_exe=args.blender,
params_json=args.params,
time_preset=args.time,
engine=args.engine,
samples=args.samples,
output_path=args.output,
transparent_bg=args.transparent,
save_blend=args.save_blend,
save_glb=args.save_glb,
structure_kind=args.kind,
timeout_sec=args.timeout,
)
if __name__ == "__main__":
_cli()