Files
s-canvas/blender_renderer.py
HYUNJUNGLEE b9342f6726 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>
2026-05-08 10:29:08 +09:00

727 lines
28 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()