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>
1169 lines
43 KiB
Python
1169 lines
43 KiB
Python
"""여수로 수문(Gate) 3D 파라메트릭 빌더 — Blender bpy 포트.
|
|
|
|
원본: gate_3d_builder.py (PyVista + VTK 기반)
|
|
이 파일: 동일 로직을 Blender 4.x bpy로 1:1 포팅.
|
|
|
|
좌표계 (원본과 동일):
|
|
- X: dam axis (span, 수문이 나란히 배치되는 방향)
|
|
- Y: 유출방향 (upstream → downstream)
|
|
- Z: 표고 (해발 m)
|
|
|
|
구성요소 (원본과 동일):
|
|
1. 여수로 본체 (ogee 프로파일 → span 방향 prism extrude)
|
|
2. 교각 (pier) n+1개 — Phase B' 폴리곤 우선, parametric 폴백
|
|
3. 래디얼 게이트 (Tainter gate) n개 — 원호 곡면 스킨 + 양측 암
|
|
4. 공도교 (service bridge) — 데크 + 양측 난간
|
|
5. 여수로 개폐장치 (gate hoist) — 각 pier 상면에 박스 + 지붕
|
|
6. 상류 수면 (N.H.W.L 평판)
|
|
7. 하류 에이프런
|
|
|
|
----------------------------------------------------------------------
|
|
변경 이력 (이 파일)
|
|
----------------------------------------------------------------------
|
|
v3 (지형 합성 워크플로 지원):
|
|
- [feat] setup_lighting_and_camera()에 transparent_bg 옵션 추가.
|
|
True면 Sky 텍스처를 비우고 World 알파 0 → 합성 입력으로 적합.
|
|
- [feat] render_to_png()에 transparent_bg 옵션 추가.
|
|
film.transparent=True + RGBA 출력. PNG 알파 채널 보존.
|
|
- [feat] CLI에 --transparent 토글 추가.
|
|
주의: 두 함수 모두 기본값은 False (기존 동작 유지). 기존 호출자 영향 없음.
|
|
|
|
v2 (검증 후 수정):
|
|
- [bug-fix] _make_yz_extrude_x_mesh / _make_xy_polygon_extrude_mesh 끝면
|
|
fan winding 정정 + enclosed mesh에 recalc_face_normals 안전망
|
|
|
|
----------------------------------------------------------------------
|
|
사용법
|
|
----------------------------------------------------------------------
|
|
|
|
[A] 헤드리스 (S-CANVAS 파이프라인 통합):
|
|
1) S-CANVAS 환경에서:
|
|
from gate_parser import parse_gate_dxf
|
|
from params_to_json import dump_dataclass_to_json
|
|
p = parse_gate_dxf(plan_dxf, section_dxf)
|
|
dump_dataclass_to_json(p, "gate_params.json")
|
|
2) Blender 헤드리스 실행:
|
|
blender --background --python gate_3d_builder_bpy.py -- ^
|
|
--params gate_params.json ^
|
|
--render gate.png --transparent ← 합성용 투명 PNG
|
|
|
|
[B] Blender GUI 안에서 직접:
|
|
import sys; sys.path.append(r"D:\\2026\\PROGRAM\\1_S-CANVAS")
|
|
from gate_3d_builder_bpy import GateBuilderBpy, params_from_dict
|
|
import json
|
|
p = params_from_dict(json.load(open("gate_params.json", encoding="utf-8")))
|
|
GateBuilderBpy(p).build_all()
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import math
|
|
import sys
|
|
from dataclasses import asdict, dataclass, field, fields
|
|
from pathlib import Path
|
|
from typing import Any, ClassVar
|
|
|
|
import bpy
|
|
import bmesh
|
|
from mathutils import Matrix, Vector, Quaternion
|
|
import contextlib
|
|
|
|
|
|
# ===========================================================================
|
|
# 색상 팔레트 (원본과 동일)
|
|
# ===========================================================================
|
|
|
|
COLORS = {
|
|
"concrete": "#B8B5A8", # 콘크리트
|
|
"pier": "#A8A59B", # 교각 (약간 밝게)
|
|
"gate_panel": "#3D4A5C", # 수문 강재 (암회색)
|
|
"gate_frame": "#5A4A3A", # 수문 프레임
|
|
"bridge_deck": "#8B8B8B", # 공도교 상판
|
|
"bridge_rail": "#4A4A4A", # 난간
|
|
"gate_hoist": "#D4A373", # 여수로 개폐장치 본체
|
|
"gate_hoist_roof": "#3A3A3A", # 개폐장치 지붕
|
|
"water": "#3A7AA8", # 수면
|
|
"apron": "#9A968C", # 여수로 에이프런
|
|
}
|
|
|
|
# 컬러별 PBR 프리셋. PyVista 평면색 → 사실적 재질로 업그레이드.
|
|
_PBR_BY_COLOR = {
|
|
"#B8B5A8": dict(metallic=0.00, roughness=0.85), # concrete
|
|
"#A8A59B": dict(metallic=0.00, roughness=0.80), # pier
|
|
"#3D4A5C": dict(metallic=1.00, roughness=0.40), # gate steel skin
|
|
"#5A4A3A": dict(metallic=0.50, roughness=0.55), # gate frame
|
|
"#8B8B8B": dict(metallic=0.10, roughness=0.70), # bridge deck
|
|
"#4A4A4A": dict(metallic=1.00, roughness=0.45), # bridge rail
|
|
"#D4A373": dict(metallic=0.20, roughness=0.65), # hoist housing
|
|
"#3A3A3A": dict(metallic=0.30, roughness=0.55), # hoist roof
|
|
"#3A7AA8": dict(metallic=0.00, roughness=0.05, transmission=0.85), # water
|
|
"#9A968C": dict(metallic=0.00, roughness=0.85), # apron
|
|
}
|
|
|
|
|
|
# ===========================================================================
|
|
# 데이터 클래스 (gate_parser.GateParams 와 동일 shape)
|
|
# ===========================================================================
|
|
|
|
@dataclass
|
|
class GateParams:
|
|
"""gate_parser.GateParams와 동일한 필드 구조 (JSON 라운드트립용 미러)."""
|
|
# 수문 본체
|
|
n_gates: int = 3
|
|
gate_width: float = 15.0
|
|
gate_height: float = 7.0
|
|
|
|
# 교각
|
|
pier_count: int = 4
|
|
pier_width: float = 3.0
|
|
pier_length: float = 25.0
|
|
|
|
# 주요 표고
|
|
el_gate_sill: float = 46.700
|
|
el_stoplog_sill: float = 46.000
|
|
el_weir_crest: float = 47.000
|
|
el_gate_top: float = 53.700
|
|
el_trunnion_pin: float = 50.200
|
|
el_mwl: float = 53.830
|
|
el_nhwl: float = 52.500
|
|
el_lwl: float = 45.000
|
|
el_downstream: float = 44.000
|
|
el_upstream_bed: float = 41.500
|
|
el_bridge_top: float = 56.000
|
|
|
|
# 구조 전체 (평면)
|
|
total_span: float = 75.0
|
|
total_length: float = 25.0
|
|
dam_axis_y: float = 0.0
|
|
|
|
# 단면 / 평면 폴리라인
|
|
ogee_profile: list = field(default_factory=list)
|
|
plan_outline_upstream: list = field(default_factory=list)
|
|
plan_outline_downstream: list = field(default_factory=list)
|
|
gate_centers_x: list = field(default_factory=list)
|
|
plan_frame_angle_deg: float = 0.0
|
|
|
|
# 부속 구조물 플래그
|
|
has_service_bridge: bool = False
|
|
has_hoist_housings: bool = True
|
|
has_downstream_apron: bool = True
|
|
has_water_surface: bool = True
|
|
|
|
# Phase B' 도면 기하
|
|
plan_outline_polygon: list = field(default_factory=list)
|
|
pier_plan_polygons: list = field(default_factory=list)
|
|
bridge_plan_bbox: tuple | None = None
|
|
bridge_deck_thickness_m: float = 1.2
|
|
|
|
# 사용자 override
|
|
bridge_x_start: float | None = None
|
|
bridge_x_end: float | None = None
|
|
bridge_y_start: float | None = None
|
|
bridge_y_end: float | None = None
|
|
|
|
# 메타
|
|
flow_direction_2d: tuple | None = None
|
|
source_files: list = field(default_factory=list)
|
|
raw_text_annotations: list = field(default_factory=list)
|
|
|
|
|
|
def params_from_dict(d: dict) -> GateParams:
|
|
"""JSON dict → GateParams. 알 수 없는 필드는 무시."""
|
|
known = {f.name for f in fields(GateParams)}
|
|
payload = {k: v for k, v in d.items() if k in known}
|
|
|
|
# tuple로 저장된 필드 복원 (JSON에선 list)
|
|
if "bridge_plan_bbox" in payload and payload["bridge_plan_bbox"] is not None:
|
|
v = payload["bridge_plan_bbox"]
|
|
payload["bridge_plan_bbox"] = tuple(v) if isinstance(v, list) else v
|
|
if "flow_direction_2d" in payload and payload["flow_direction_2d"] is not None:
|
|
v = payload["flow_direction_2d"]
|
|
payload["flow_direction_2d"] = tuple(v) if isinstance(v, list) else v
|
|
|
|
def _normalize_xy_list(seq):
|
|
if not seq:
|
|
return []
|
|
return [tuple(p) if isinstance(p, list) else p for p in seq]
|
|
|
|
for key in ("ogee_profile", "gate_centers_x", "plan_outline_polygon",
|
|
"plan_outline_upstream", "plan_outline_downstream"):
|
|
if key in payload:
|
|
payload[key] = _normalize_xy_list(payload[key])
|
|
|
|
if "pier_plan_polygons" in payload:
|
|
payload["pier_plan_polygons"] = [
|
|
_normalize_xy_list(poly) for poly in payload["pier_plan_polygons"]
|
|
]
|
|
|
|
return GateParams(**payload)
|
|
|
|
|
|
def dump_params_to_json(params, path: str) -> None:
|
|
"""GateParams → JSON 파일."""
|
|
def _to_serializable(obj):
|
|
if hasattr(obj, "__dataclass_fields__"):
|
|
return {k: _to_serializable(v) for k, v in asdict(obj).items()}
|
|
if isinstance(obj, (list, tuple)):
|
|
return [_to_serializable(x) for x in obj]
|
|
if isinstance(obj, dict):
|
|
return {k: _to_serializable(v) for k, v in obj.items()}
|
|
if isinstance(obj, (str, int, float, bool)) or obj is None:
|
|
return obj
|
|
try:
|
|
return float(obj)
|
|
except (TypeError, ValueError):
|
|
return str(obj)
|
|
|
|
payload = _to_serializable(params)
|
|
Path(path).write_text(json.dumps(payload, ensure_ascii=False, indent=2),
|
|
encoding="utf-8")
|
|
|
|
|
|
# ===========================================================================
|
|
# 헬퍼
|
|
# ===========================================================================
|
|
|
|
def _hex_to_rgb(hex_color: str) -> tuple[float, float, float]:
|
|
h = hex_color.lstrip("#")
|
|
return (int(h[0:2], 16) / 255.0,
|
|
int(h[2:4], 16) / 255.0,
|
|
int(h[4:6], 16) / 255.0)
|
|
|
|
|
|
def _set_principled_input(bsdf, name: str, value: Any) -> bool:
|
|
candidates = {
|
|
"Transmission": ["Transmission Weight", "Transmission"],
|
|
"Specular": ["Specular IOR Level", "Specular"],
|
|
"Emission": ["Emission Color", "Emission"],
|
|
}.get(name, [name])
|
|
for key in candidates:
|
|
if key in bsdf.inputs:
|
|
bsdf.inputs[key].default_value = value
|
|
return True
|
|
return False
|
|
|
|
|
|
def _recalc_outward_normals(bm: bmesh.types.BMesh) -> None:
|
|
"""enclosed manifold mesh에 대해 노멀이 모두 outward가 되도록 자동 재계산.
|
|
|
|
winding 오류·orientation 불일치를 일괄 정리. open mesh(곡면 strip 등)에는
|
|
부르지 말 것 — 결과가 비결정적일 수 있음.
|
|
"""
|
|
# bm.faces 가 비어있거나 manifold 가 아닐 때 폴백 — 그냥 normal_update만
|
|
with contextlib.suppress(Exception):
|
|
bmesh.ops.recalc_face_normals(bm, faces=bm.faces[:])
|
|
|
|
|
|
# ===========================================================================
|
|
# 빌더
|
|
# ===========================================================================
|
|
|
|
class GateBuilderBpy:
|
|
"""여수로 수문 파라메트릭 3D 빌더 (Blender bpy)."""
|
|
|
|
_COLLECTION_OF: ClassVar[dict[str, str]] = {
|
|
"concrete": "01_SpillwayBody",
|
|
"pier": "02_Piers",
|
|
"gate_panel": "03_Gates",
|
|
"gate_frame": "03_Gates",
|
|
"bridge_deck": "04_ServiceBridge",
|
|
"bridge_rail": "04_ServiceBridge",
|
|
"gate_hoist": "05_Hoists",
|
|
"gate_hoist_roof": "05_Hoists",
|
|
"water": "06_Site",
|
|
"apron": "06_Site",
|
|
}
|
|
_HEX_TO_KEY: dict | None = None
|
|
|
|
def __init__(self, params: GateParams, *,
|
|
clear_scene: bool = True,
|
|
root_collection_name: str = "Gate"):
|
|
self.params = params
|
|
self._materials: dict = {}
|
|
self._collections: dict = {}
|
|
self._object_count = 0
|
|
self._root_name = root_collection_name
|
|
|
|
if GateBuilderBpy._HEX_TO_KEY is None:
|
|
GateBuilderBpy._HEX_TO_KEY = {
|
|
COLORS[k]: v for k, v in self._COLLECTION_OF.items() if k in COLORS
|
|
}
|
|
|
|
if clear_scene:
|
|
self._clear_scene()
|
|
self._init_collections()
|
|
|
|
# ---- 진입점 (원본 build_all과 동일 순서) ----------------------------
|
|
|
|
def build_all(self) -> bpy.types.Collection:
|
|
self._build_spillway_body()
|
|
self._build_piers()
|
|
self._build_radial_gates()
|
|
if getattr(self.params, "has_service_bridge", False):
|
|
self._build_service_bridge()
|
|
if getattr(self.params, "has_hoist_housings", True):
|
|
self._build_gate_hoists()
|
|
if getattr(self.params, "has_water_surface", True):
|
|
self._build_water_surface()
|
|
if getattr(self.params, "has_downstream_apron", True):
|
|
self._build_downstream_apron()
|
|
return self._collections[self._root_name]
|
|
|
|
# ---- 씬/컬렉션 -----------------------------------------------------
|
|
|
|
def _clear_scene(self):
|
|
for obj in list(bpy.data.objects):
|
|
bpy.data.objects.remove(obj, do_unlink=True)
|
|
for coll in list(bpy.data.collections):
|
|
bpy.data.collections.remove(coll)
|
|
for mesh in list(bpy.data.meshes):
|
|
if mesh.users == 0:
|
|
bpy.data.meshes.remove(mesh)
|
|
|
|
def _init_collections(self):
|
|
scene = bpy.context.scene
|
|
root = bpy.data.collections.new(self._root_name)
|
|
scene.collection.children.link(root)
|
|
self._collections[self._root_name] = root
|
|
|
|
for sub in ["01_SpillwayBody", "02_Piers", "03_Gates",
|
|
"04_ServiceBridge", "05_Hoists", "06_Site"]:
|
|
c = bpy.data.collections.new(sub)
|
|
root.children.link(c)
|
|
self._collections[sub] = c
|
|
|
|
def _collection_for(self, color_hex: str) -> bpy.types.Collection:
|
|
key = self._HEX_TO_KEY.get(color_hex, "01_SpillwayBody")
|
|
return self._collections.get(key, self._collections[self._root_name])
|
|
|
|
# ---- 재질 ---------------------------------------------------------
|
|
|
|
def _get_material(self, color_hex: str, alpha: float = 1.0) -> bpy.types.Material:
|
|
key = (color_hex, round(alpha, 3))
|
|
if key in self._materials:
|
|
return self._materials[key]
|
|
|
|
suffix = "" if alpha >= 0.999 else f"_a{alpha:.2f}"
|
|
mat = bpy.data.materials.new(name=f"Mat_{color_hex.lstrip('#')}{suffix}")
|
|
mat.use_nodes = True
|
|
bsdf = mat.node_tree.nodes.get("Principled BSDF")
|
|
if bsdf is None:
|
|
mat.node_tree.nodes.clear()
|
|
bsdf = mat.node_tree.nodes.new("ShaderNodeBsdfPrincipled")
|
|
output = mat.node_tree.nodes.new("ShaderNodeOutputMaterial")
|
|
mat.node_tree.links.new(bsdf.outputs[0], output.inputs[0])
|
|
|
|
r, g, b = _hex_to_rgb(color_hex)
|
|
bsdf.inputs["Base Color"].default_value = (r, g, b, 1.0)
|
|
|
|
pbr = _PBR_BY_COLOR.get(color_hex, dict(metallic=0.0, roughness=0.7))
|
|
bsdf.inputs["Metallic"].default_value = pbr["metallic"]
|
|
bsdf.inputs["Roughness"].default_value = pbr["roughness"]
|
|
if "transmission" in pbr:
|
|
_set_principled_input(bsdf, "Transmission", pbr["transmission"])
|
|
|
|
if alpha < 0.999:
|
|
bsdf.inputs["Alpha"].default_value = alpha
|
|
mat.blend_method = "BLEND"
|
|
with contextlib.suppress(AttributeError):
|
|
mat.shadow_method = "HASHED"
|
|
|
|
self._materials[key] = mat
|
|
return mat
|
|
|
|
# ---- 메시 프리미티브 -----------------------------------------------
|
|
|
|
def _make_box_mesh(self, name: str,
|
|
x0: float, x1: float, y0: float, y1: float,
|
|
z0: float, z1: float) -> bpy.types.Mesh:
|
|
"""축정렬 박스 (8 vertex / 6 quad). enclosed manifold."""
|
|
mesh = bpy.data.meshes.new(name)
|
|
bm = bmesh.new()
|
|
v = [
|
|
bm.verts.new((x0, y0, z0)),
|
|
bm.verts.new((x1, y0, z0)),
|
|
bm.verts.new((x1, y1, z0)),
|
|
bm.verts.new((x0, y1, z0)),
|
|
bm.verts.new((x0, y0, z1)),
|
|
bm.verts.new((x1, y0, z1)),
|
|
bm.verts.new((x1, y1, z1)),
|
|
bm.verts.new((x0, y1, z1)),
|
|
]
|
|
face_indices = [
|
|
(0, 3, 2, 1), # bottom
|
|
(4, 5, 6, 7), # top
|
|
(0, 1, 5, 4), # -Y
|
|
(2, 3, 7, 6), # +Y
|
|
(1, 2, 6, 5), # +X
|
|
(0, 4, 7, 3), # -X
|
|
]
|
|
for fi in face_indices:
|
|
with contextlib.suppress(ValueError):
|
|
bm.faces.new([v[i] for i in fi])
|
|
_recalc_outward_normals(bm)
|
|
bm.normal_update()
|
|
bm.to_mesh(mesh)
|
|
bm.free()
|
|
return mesh
|
|
|
|
def _make_flat_rect_mesh(self, name: str,
|
|
x0: float, x1: float, y0: float, y1: float,
|
|
z: float) -> bpy.types.Mesh:
|
|
"""수평 사각형 평면 (open — recalc 호출하지 않음)."""
|
|
mesh = bpy.data.meshes.new(name)
|
|
bm = bmesh.new()
|
|
verts = [
|
|
bm.verts.new((x0, y0, z)),
|
|
bm.verts.new((x1, y0, z)),
|
|
bm.verts.new((x1, y1, z)),
|
|
bm.verts.new((x0, y1, z)),
|
|
]
|
|
with contextlib.suppress(ValueError):
|
|
bm.faces.new(verts)
|
|
bm.normal_update()
|
|
bm.to_mesh(mesh)
|
|
bm.free()
|
|
return mesh
|
|
|
|
def _make_yz_extrude_x_mesh(self, name: str,
|
|
profile_yz: list,
|
|
x_span: float) -> bpy.types.Mesh:
|
|
"""(y, z) 단면 폴리곤을 X 방향으로 extrude한 prism. enclosed manifold."""
|
|
n = len(profile_yz)
|
|
if n < 3:
|
|
return None # type: ignore
|
|
|
|
mesh = bpy.data.meshes.new(name)
|
|
bm = bmesh.new()
|
|
front = [bm.verts.new((0.0, y, z)) for (y, z) in profile_yz]
|
|
back = [bm.verts.new((x_span, y, z)) for (y, z) in profile_yz]
|
|
|
|
for i in range(n):
|
|
j = (i + 1) % n
|
|
try:
|
|
bm.faces.new([front[i], front[j], back[j], back[i]])
|
|
except ValueError:
|
|
try:
|
|
bm.faces.new([front[i], front[j], back[j]])
|
|
bm.faces.new([front[i], back[j], back[i]])
|
|
except ValueError:
|
|
pass
|
|
|
|
for i in range(1, n - 1):
|
|
with contextlib.suppress(ValueError):
|
|
bm.faces.new([front[0], front[i], front[i + 1]])
|
|
with contextlib.suppress(ValueError):
|
|
bm.faces.new([back[0], back[i + 1], back[i]])
|
|
|
|
_recalc_outward_normals(bm)
|
|
bm.normal_update()
|
|
bm.to_mesh(mesh)
|
|
bm.free()
|
|
return mesh
|
|
|
|
def _make_xy_polygon_extrude_mesh(self, name: str,
|
|
poly_xy: list,
|
|
z_bot: float, z_top: float) -> bpy.types.Mesh:
|
|
"""(x, y) 폴리곤을 Z 방향으로 extrude한 prism (pier 폴리곤용)."""
|
|
if len(poly_xy) < 3:
|
|
return None # type: ignore
|
|
|
|
pts = list(poly_xy)
|
|
if (abs(pts[0][0] - pts[-1][0]) < 1e-6 and
|
|
abs(pts[0][1] - pts[-1][1]) < 1e-6):
|
|
pts = pts[:-1]
|
|
n = len(pts)
|
|
if n < 3:
|
|
return None # type: ignore
|
|
|
|
mesh = bpy.data.meshes.new(name)
|
|
bm = bmesh.new()
|
|
bot = [bm.verts.new((x, y, z_bot)) for (x, y) in pts]
|
|
top = [bm.verts.new((x, y, z_top)) for (x, y) in pts]
|
|
|
|
for i in range(n):
|
|
j = (i + 1) % n
|
|
try:
|
|
bm.faces.new([bot[i], bot[j], top[j], top[i]])
|
|
except ValueError:
|
|
try:
|
|
bm.faces.new([bot[i], bot[j], top[j]])
|
|
bm.faces.new([bot[i], top[j], top[i]])
|
|
except ValueError:
|
|
pass
|
|
|
|
for i in range(1, n - 1):
|
|
with contextlib.suppress(ValueError):
|
|
bm.faces.new([bot[0], bot[i], bot[i + 1]])
|
|
with contextlib.suppress(ValueError):
|
|
bm.faces.new([top[0], top[i + 1], top[i]])
|
|
|
|
_recalc_outward_normals(bm)
|
|
bm.normal_update()
|
|
bm.to_mesh(mesh)
|
|
bm.free()
|
|
return mesh
|
|
|
|
def _make_pier_nose_mesh(self, name: str,
|
|
cx: float, width: float,
|
|
y_front: float,
|
|
z_bot: float, z_top: float) -> bpy.types.Mesh:
|
|
"""교각 상류측 삼각형 물가르기 노즈. enclosed manifold."""
|
|
half_w = width / 2
|
|
nose_len = width * 1.2
|
|
y_tip = y_front - nose_len
|
|
|
|
mesh = bpy.data.meshes.new(name)
|
|
bm = bmesh.new()
|
|
v = [
|
|
bm.verts.new((cx - half_w, y_front, z_bot)), # 0
|
|
bm.verts.new((cx + half_w, y_front, z_bot)), # 1
|
|
bm.verts.new((cx, y_tip, z_bot)), # 2
|
|
bm.verts.new((cx - half_w, y_front, z_top)), # 3
|
|
bm.verts.new((cx + half_w, y_front, z_top)), # 4
|
|
bm.verts.new((cx, y_tip, z_top)), # 5
|
|
]
|
|
for fi in [(0, 1, 2),
|
|
(5, 4, 3),
|
|
(0, 2, 5), (0, 5, 3),
|
|
(1, 4, 5), (1, 5, 2),
|
|
(0, 3, 4, 1)]:
|
|
with contextlib.suppress(ValueError):
|
|
bm.faces.new([v[i] for i in fi])
|
|
|
|
_recalc_outward_normals(bm)
|
|
bm.normal_update()
|
|
bm.to_mesh(mesh)
|
|
bm.free()
|
|
return mesh
|
|
|
|
def _make_radial_skin_mesh(self, name: str,
|
|
cx: float, width: float,
|
|
sill_el: float, top_el: float,
|
|
trunnion_y: float, trunnion_el: float,
|
|
radius: float) -> bpy.types.Mesh:
|
|
"""래디얼 게이트 스킨 (원호 곡면 strip — open mesh)."""
|
|
dz_sill = sill_el - trunnion_el
|
|
dz_top = top_el - trunnion_el
|
|
dx_sill = math.sqrt(max(0.01, radius * radius - dz_sill * dz_sill))
|
|
dx_top = math.sqrt(max(0.01, radius * radius - dz_top * dz_top))
|
|
ang_sill = math.atan2(dz_sill, dx_sill)
|
|
ang_top = math.atan2(dz_top, dx_top)
|
|
|
|
if ang_top - ang_sill > math.pi:
|
|
ang_top -= 2 * math.pi
|
|
elif ang_top - ang_sill < -math.pi:
|
|
ang_top += 2 * math.pi
|
|
|
|
n_circ = 16
|
|
half_w = width / 2 - 0.1
|
|
|
|
mesh = bpy.data.meshes.new(name)
|
|
bm = bmesh.new()
|
|
verts = []
|
|
for i in range(n_circ + 1):
|
|
t = i / n_circ
|
|
ang = ang_sill * (1 - t) + ang_top * t
|
|
dy = math.cos(ang) * radius
|
|
dz = math.sin(ang) * radius
|
|
y = trunnion_y + dy
|
|
z = trunnion_el + dz
|
|
verts.append(bm.verts.new((cx - half_w, y, z)))
|
|
verts.append(bm.verts.new((cx + half_w, y, z)))
|
|
|
|
for i in range(n_circ):
|
|
i0 = 2 * i
|
|
i1 = 2 * i + 1
|
|
i2 = 2 * (i + 1)
|
|
i3 = 2 * (i + 1) + 1
|
|
with contextlib.suppress(ValueError):
|
|
bm.faces.new([verts[i0], verts[i3], verts[i1]])
|
|
with contextlib.suppress(ValueError):
|
|
bm.faces.new([verts[i0], verts[i2], verts[i3]])
|
|
|
|
bm.normal_update()
|
|
bm.to_mesh(mesh)
|
|
bm.free()
|
|
return mesh
|
|
|
|
def _make_tube_between(self, name: str,
|
|
p1: tuple, p2: tuple,
|
|
radius: float, n_sides: int = 8) -> bpy.types.Mesh:
|
|
"""두 점을 잇는 원통 튜브 (PyVista line.tube 대체). enclosed manifold."""
|
|
a = Vector(p1)
|
|
b = Vector(p2)
|
|
direction = b - a
|
|
length = direction.length
|
|
if length < 0.05:
|
|
return None # type: ignore
|
|
center = (a + b) * 0.5
|
|
|
|
z_axis = Vector((0.0, 0.0, 1.0))
|
|
d_norm = direction.normalized()
|
|
dot = z_axis.dot(d_norm)
|
|
if dot > 0.9999:
|
|
rot_quat = Quaternion()
|
|
elif dot < -0.9999:
|
|
rot_quat = Quaternion((1.0, 0.0, 0.0), math.pi)
|
|
else:
|
|
axis = z_axis.cross(d_norm).normalized()
|
|
angle = math.acos(max(-1.0, min(1.0, dot)))
|
|
rot_quat = Quaternion(axis, angle)
|
|
|
|
mat = Matrix.Translation(center) @ rot_quat.to_matrix().to_4x4()
|
|
|
|
mesh = bpy.data.meshes.new(name)
|
|
bm = bmesh.new()
|
|
bmesh.ops.create_cone(
|
|
bm,
|
|
cap_ends=True,
|
|
cap_tris=False,
|
|
segments=n_sides,
|
|
radius1=radius,
|
|
radius2=radius,
|
|
depth=length,
|
|
matrix=mat,
|
|
calc_uvs=False,
|
|
)
|
|
_recalc_outward_normals(bm)
|
|
bm.normal_update()
|
|
bm.to_mesh(mesh)
|
|
bm.free()
|
|
return mesh
|
|
|
|
# ---- 객체 생성 -----------------------------------------------------
|
|
|
|
def _add_object(self, name: str, mesh: bpy.types.Mesh,
|
|
color_hex: str, alpha: float = 1.0) -> bpy.types.Object | None:
|
|
if mesh is None:
|
|
return None
|
|
obj = bpy.data.objects.new(name, mesh)
|
|
obj.data.materials.append(self._get_material(color_hex, alpha))
|
|
self._collection_for(color_hex).objects.link(obj)
|
|
self._object_count += 1
|
|
return obj
|
|
|
|
# ===================================================================
|
|
# 컴포넌트 빌더 (원본과 1:1 대응)
|
|
# ===================================================================
|
|
|
|
def _build_spillway_body(self):
|
|
p = self.params
|
|
profile = p.ogee_profile
|
|
if len(profile) < 3:
|
|
return
|
|
|
|
xs = [pt[0] for pt in profile]
|
|
zs = [pt[1] for pt in profile]
|
|
x_max = max(xs)
|
|
z_min = min(zs) - 1.0
|
|
|
|
closed_yz = [(xs[0], z_min), *list(zip(xs, zs, strict=False)), (x_max, z_min)]
|
|
|
|
mesh = self._make_yz_extrude_x_mesh(
|
|
"SpillwayBody", closed_yz, x_span=p.total_span,
|
|
)
|
|
self._add_object("SpillwayBody", mesh, COLORS["concrete"])
|
|
|
|
def _compute_pier_x_centers(self) -> list:
|
|
p = self.params
|
|
pier_polys = getattr(p, "pier_plan_polygons", None) or []
|
|
expected_n_piers = p.n_gates + 1
|
|
if pier_polys and len(pier_polys) == expected_n_piers \
|
|
and self._validate_pier_polys(pier_polys, p.pier_width, p.pier_length):
|
|
centers = []
|
|
for poly in pier_polys:
|
|
xs = [pt[0] for pt in poly]
|
|
centers.append((min(xs) + max(xs)) / 2.0)
|
|
centers.sort()
|
|
return centers
|
|
|
|
pier_w = p.pier_width
|
|
gate_xs = p.gate_centers_x
|
|
if not gate_xs:
|
|
return []
|
|
centers = [gate_xs[0] - p.gate_width / 2 - pier_w / 2]
|
|
for i in range(len(gate_xs) - 1):
|
|
centers.append((gate_xs[i] + gate_xs[i + 1]) / 2)
|
|
centers.append(gate_xs[-1] + p.gate_width / 2 + pier_w / 2)
|
|
return centers
|
|
|
|
@staticmethod
|
|
def _validate_pier_polys(pier_polys: list, pier_width: float, pier_length: float,
|
|
tol_ratio: float = 0.5) -> bool:
|
|
w_lo = pier_width * (1 - tol_ratio)
|
|
w_hi = pier_width * (1 + tol_ratio)
|
|
l_lo = pier_length * 0.4
|
|
l_hi = pier_length * 1.5
|
|
for poly in pier_polys:
|
|
xs = [p[0] for p in poly]
|
|
ys = [p[1] for p in poly]
|
|
if len(xs) < 3:
|
|
return False
|
|
w = max(xs) - min(xs)
|
|
l = max(ys) - min(ys)
|
|
if not (w_lo <= w <= w_hi):
|
|
return False
|
|
if not (l_lo <= l <= l_hi):
|
|
return False
|
|
return True
|
|
|
|
@staticmethod
|
|
def _validate_bridge_bbox(bbox: tuple, total_span: float, pier_length: float) -> bool:
|
|
if bbox is None or len(bbox) != 4:
|
|
return False
|
|
x0, y0, x1, y1 = bbox
|
|
w = x1 - x0
|
|
h = y1 - y0
|
|
if w < 1.0 or h < 0.5:
|
|
return False
|
|
if not (total_span * 0.2 <= w <= total_span * 1.5):
|
|
return False
|
|
return pier_length * 0.05 <= h <= pier_length * 1.2
|
|
|
|
def _build_piers(self):
|
|
p = self.params
|
|
pier_top_el = p.el_bridge_top
|
|
pier_bot_el = p.el_gate_sill - 2.0
|
|
|
|
pier_polys = getattr(p, "pier_plan_polygons", None) or []
|
|
expected_n_piers = p.n_gates + 1
|
|
|
|
if pier_polys and len(pier_polys) == expected_n_piers \
|
|
and self._validate_pier_polys(pier_polys, p.pier_width, p.pier_length):
|
|
for i, poly in enumerate(pier_polys):
|
|
mesh = self._make_xy_polygon_extrude_mesh(
|
|
f"Pier_{i:02d}", poly, pier_bot_el, pier_top_el
|
|
)
|
|
self._add_object(f"Pier_{i:02d}", mesh, COLORS["pier"])
|
|
return
|
|
|
|
pier_w = p.pier_width
|
|
pier_l = p.pier_length
|
|
pier_x_centers = self._compute_pier_x_centers()
|
|
if not pier_x_centers:
|
|
return
|
|
|
|
nose_len = pier_w * 1.2
|
|
body_y0 = nose_len
|
|
body_y1 = pier_l
|
|
|
|
for i, cx in enumerate(pier_x_centers):
|
|
body_mesh = self._make_box_mesh(
|
|
f"Pier_{i:02d}_body",
|
|
cx - pier_w / 2, cx + pier_w / 2,
|
|
body_y0, body_y1,
|
|
pier_bot_el, pier_top_el,
|
|
)
|
|
self._add_object(f"Pier_{i:02d}_body", body_mesh, COLORS["pier"])
|
|
|
|
nose_mesh = self._make_pier_nose_mesh(
|
|
f"Pier_{i:02d}_nose",
|
|
cx, pier_w, body_y0, pier_bot_el, pier_top_el,
|
|
)
|
|
self._add_object(f"Pier_{i:02d}_nose", nose_mesh, COLORS["pier"])
|
|
|
|
def _compute_gate_geometry(self) -> dict:
|
|
p = self.params
|
|
sill_el = p.el_gate_sill
|
|
top_el = p.el_gate_top
|
|
gate_h = top_el - sill_el
|
|
|
|
radius = gate_h * 1.25
|
|
|
|
mid_el = (sill_el + top_el) / 2
|
|
trunnion_el_user = p.el_trunnion_pin
|
|
trunnion_el = mid_el if abs(trunnion_el_user - mid_el) > 0.5 else trunnion_el_user
|
|
|
|
dz_half = abs(trunnion_el - sill_el)
|
|
horizontal = math.sqrt(max(0.01, radius ** 2 - dz_half ** 2))
|
|
|
|
body_len = p.pier_length if p.pier_length and p.pier_length > 0 else 25.0
|
|
crest_y_candidate: float | None = None
|
|
if p.ogee_profile:
|
|
best_diff = float("inf")
|
|
for (x, z) in p.ogee_profile:
|
|
if x <= 0.1:
|
|
continue
|
|
diff = abs(z - p.el_weir_crest)
|
|
if diff < best_diff:
|
|
best_diff = diff
|
|
crest_y_candidate = float(x)
|
|
if crest_y_candidate is None or crest_y_candidate < 0.5:
|
|
crest_y_candidate = body_len * 0.45
|
|
|
|
hoist_half = 2.0
|
|
gate_y_min = horizontal + hoist_half
|
|
gate_y_max = max(gate_y_min + 0.1, body_len - 0.5)
|
|
gate_y = max(gate_y_min, min(crest_y_candidate, gate_y_max))
|
|
trunnion_y = gate_y - horizontal
|
|
|
|
return {
|
|
"gate_y": gate_y,
|
|
"trunnion_y": trunnion_y,
|
|
"trunnion_el": trunnion_el,
|
|
"radius": radius,
|
|
}
|
|
|
|
def _build_radial_gates(self):
|
|
p = self.params
|
|
gate_w = p.gate_width
|
|
sill_el = p.el_gate_sill
|
|
top_el = p.el_gate_top
|
|
|
|
geom = self._compute_gate_geometry()
|
|
trunnion_y = geom["trunnion_y"]
|
|
trunnion_el = geom["trunnion_el"]
|
|
radius = geom["radius"]
|
|
|
|
for gi, gx in enumerate(p.gate_centers_x):
|
|
skin = self._make_radial_skin_mesh(
|
|
f"GateSkin_{gi:02d}",
|
|
cx=gx, width=gate_w,
|
|
sill_el=sill_el, top_el=top_el,
|
|
trunnion_y=trunnion_y, trunnion_el=trunnion_el,
|
|
radius=radius,
|
|
)
|
|
self._add_object(f"GateSkin_{gi:02d}", skin, COLORS["gate_panel"])
|
|
|
|
half_w = gate_w / 2 - 0.15
|
|
arm_thick = 0.3
|
|
mid_el = (sill_el + top_el) / 2
|
|
for side_idx, side_offset in enumerate([-half_w, half_w]):
|
|
t_pt = (gx + side_offset, trunnion_y, trunnion_el)
|
|
s_pt = (gx + side_offset, trunnion_y + radius, mid_el)
|
|
arm = self._make_tube_between(
|
|
f"GateArm_{gi:02d}_{side_idx}",
|
|
t_pt, s_pt,
|
|
radius=arm_thick, n_sides=8,
|
|
)
|
|
self._add_object(
|
|
f"GateArm_{gi:02d}_{side_idx}", arm, COLORS["gate_frame"]
|
|
)
|
|
|
|
def _build_service_bridge(self):
|
|
p = self.params
|
|
deck_thickness = getattr(p, "bridge_deck_thickness_m", 1.2)
|
|
deck_top = p.el_bridge_top + deck_thickness
|
|
deck_bot = p.el_bridge_top
|
|
|
|
ux0 = getattr(p, "bridge_x_start", None)
|
|
ux1 = getattr(p, "bridge_x_end", None)
|
|
uy0 = getattr(p, "bridge_y_start", None)
|
|
uy1 = getattr(p, "bridge_y_end", None)
|
|
user_bbox = None
|
|
if (ux0 is not None and ux1 is not None and ux1 > ux0 and
|
|
uy0 is not None and uy1 is not None and uy1 > uy0):
|
|
cand = (float(ux0), float(uy0), float(ux1), float(uy1))
|
|
if self._validate_bridge_bbox(cand, p.total_span, p.pier_length):
|
|
user_bbox = cand
|
|
|
|
if user_bbox is not None:
|
|
x0, y0, x1, y1 = user_bbox
|
|
else:
|
|
bbox = getattr(p, "bridge_plan_bbox", None)
|
|
if bbox is not None and self._validate_bridge_bbox(
|
|
bbox, p.total_span, p.pier_length):
|
|
x0, y0, x1, y1 = bbox
|
|
else:
|
|
x0 = -p.pier_width * 0.5
|
|
x1 = p.total_span + p.pier_width * 0.5
|
|
y0 = p.pier_length * 0.3
|
|
y1 = p.pier_length * 0.55
|
|
|
|
deck_mesh = self._make_box_mesh(
|
|
"BridgeDeck", x0, x1, y0, y1, deck_bot, deck_top
|
|
)
|
|
self._add_object("BridgeDeck", deck_mesh, COLORS["bridge_deck"])
|
|
|
|
rail_height = 1.1
|
|
rail_thick = 0.2
|
|
for ri, y_rail in enumerate([y0, y1 - rail_thick]):
|
|
rail_mesh = self._make_box_mesh(
|
|
f"BridgeRail_{ri}",
|
|
x0, x1, y_rail, y_rail + rail_thick,
|
|
deck_top, deck_top + rail_height,
|
|
)
|
|
self._add_object(f"BridgeRail_{ri}", rail_mesh, COLORS["bridge_rail"])
|
|
|
|
def _build_gate_hoists(self):
|
|
p = self.params
|
|
pier_x_centers = self._compute_pier_x_centers()
|
|
if not pier_x_centers:
|
|
return
|
|
|
|
house_w = max(p.pier_width, 2.5)
|
|
house_l = 2.2
|
|
house_h = 2.1
|
|
embed_depth = 0.1
|
|
roof_overhang = 0.2
|
|
roof_thick = 0.2
|
|
|
|
pier_top_el = p.el_bridge_top
|
|
base_z = pier_top_el - embed_depth
|
|
top_z = base_z + house_h
|
|
|
|
pier_w = p.pier_width
|
|
nose_len = pier_w * 1.2
|
|
body_len = p.pier_length if p.pier_length and p.pier_length > 0 else 25.0
|
|
y_center_target = nose_len + 0.5
|
|
margin = house_l / 2 + roof_overhang + 0.1
|
|
y_center = min(max(y_center_target, margin), body_len - margin)
|
|
y0 = y_center - house_l / 2
|
|
y1 = y_center + house_l / 2
|
|
|
|
for i, cx in enumerate(pier_x_centers):
|
|
box = self._make_box_mesh(
|
|
f"Hoist_{i:02d}_body",
|
|
cx - house_w / 2, cx + house_w / 2,
|
|
y0, y1, base_z, top_z,
|
|
)
|
|
self._add_object(f"Hoist_{i:02d}_body", box, COLORS["gate_hoist"])
|
|
roof = self._make_box_mesh(
|
|
f"Hoist_{i:02d}_roof",
|
|
cx - house_w / 2 - roof_overhang, cx + house_w / 2 + roof_overhang,
|
|
y0 - roof_overhang, y1 + roof_overhang,
|
|
top_z, top_z + roof_thick,
|
|
)
|
|
self._add_object(f"Hoist_{i:02d}_roof", roof, COLORS["gate_hoist_roof"])
|
|
|
|
def _build_water_surface(self):
|
|
p = self.params
|
|
water_el = p.el_nhwl
|
|
x0 = -10
|
|
x1 = p.total_span + 10
|
|
y0 = -40
|
|
y1 = 0.5
|
|
|
|
water = self._make_flat_rect_mesh("WaterSurface", x0, x1, y0, y1, water_el)
|
|
self._add_object("WaterSurface", water, COLORS["water"], alpha=0.85)
|
|
|
|
def _build_downstream_apron(self):
|
|
p = self.params
|
|
apron_el = p.el_downstream
|
|
x0 = -5
|
|
x1 = p.total_span + 5
|
|
y0 = p.pier_length
|
|
y1 = y0 + 30
|
|
|
|
apron = self._make_flat_rect_mesh("Apron", x0, x1, y0, y1, apron_el)
|
|
self._add_object("Apron", apron, COLORS["apron"])
|
|
|
|
|
|
# ===========================================================================
|
|
# 옵션: 카메라 + 조명 + 렌더 (헤드리스 워크플로용)
|
|
# ===========================================================================
|
|
|
|
def setup_lighting_and_camera(params: GateParams,
|
|
time_preset: str = "daytime",
|
|
*,
|
|
transparent_bg: bool = False) -> bpy.types.Object:
|
|
"""여수로 전체를 oblique aerial 앵글로 잡는 카메라 + Sun + Sky.
|
|
|
|
Args:
|
|
params: GateParams
|
|
time_preset: 'daytime' | 'sunset' | 'overcast' (Sun 위치/세기 + Sky)
|
|
transparent_bg: True면 World 배경을 투명한 검정으로 (Sky 텍스처 미연결).
|
|
Sun 광원은 그대로 두므로 구조물 라이팅은 정상 유지. 합성 입력에 적합.
|
|
"""
|
|
p = params
|
|
|
|
world = bpy.context.scene.world or bpy.data.worlds.new("World")
|
|
bpy.context.scene.world = world
|
|
world.use_nodes = True
|
|
nt = world.node_tree
|
|
nt.nodes.clear()
|
|
bg = nt.nodes.new("ShaderNodeBackground")
|
|
out = nt.nodes.new("ShaderNodeOutputWorld")
|
|
|
|
presets = {
|
|
"daytime": {"sun_elev": math.radians(60), "intensity": 1.0, "sun_strength": 4.0},
|
|
"sunset": {"sun_elev": math.radians(8), "intensity": 0.8, "sun_strength": 6.0},
|
|
"overcast": {"sun_elev": math.radians(45), "intensity": 0.5, "sun_strength": 1.5},
|
|
}
|
|
pre = presets.get(time_preset, presets["daytime"])
|
|
|
|
if transparent_bg:
|
|
# 투명 배경 모드 — Sky 텍스처를 만들지 않고 검정 배경 + 약한 Strength
|
|
# (구조물 표면이 바라본 방향에 'sky'가 안 보이도록).
|
|
# 실제 투명 픽셀은 render.film_transparent + RGBA로 만들어짐.
|
|
bg.inputs["Color"].default_value = (0.0, 0.0, 0.0, 1.0)
|
|
bg.inputs["Strength"].default_value = 0.0
|
|
else:
|
|
sky = nt.nodes.new("ShaderNodeTexSky")
|
|
with contextlib.suppress(TypeError, AttributeError):
|
|
sky.sky_type = "NISHITA"
|
|
try:
|
|
sky.sun_elevation = pre["sun_elev"]
|
|
sky.sun_rotation = math.radians(225)
|
|
except AttributeError:
|
|
pass
|
|
nt.links.new(sky.outputs[0], bg.inputs["Color"])
|
|
bg.inputs["Strength"].default_value = pre["intensity"]
|
|
|
|
nt.links.new(bg.outputs[0], out.inputs["Surface"])
|
|
|
|
# Sun light는 두 모드 모두 추가 (구조물 라이팅 일관성)
|
|
sun_data = bpy.data.lights.new("Sun", "SUN")
|
|
sun_data.energy = pre["sun_strength"]
|
|
sun_obj = bpy.data.objects.new("Sun", sun_data)
|
|
bpy.context.scene.collection.objects.link(sun_obj)
|
|
sun_obj.rotation_euler = (math.radians(50), math.radians(15), math.radians(225))
|
|
|
|
cam_data = bpy.data.cameras.new("Cam")
|
|
cam = bpy.data.objects.new("Cam", cam_data)
|
|
bpy.context.scene.collection.objects.link(cam)
|
|
|
|
target_x = p.total_span / 2
|
|
target_y = p.pier_length / 2
|
|
target_z = (p.el_gate_sill + p.el_bridge_top) / 2
|
|
target = Vector((target_x, target_y, target_z))
|
|
|
|
span = max(p.total_span, p.pier_length, p.el_bridge_top - p.el_upstream_bed)
|
|
distance = span * 1.6
|
|
|
|
cam_pos = Vector((
|
|
target_x + distance * 0.4,
|
|
target_y - distance * 0.9,
|
|
target_z + span * 0.5,
|
|
))
|
|
cam.location = cam_pos
|
|
|
|
direction = (target - cam_pos).normalized()
|
|
rot_quat = direction.to_track_quat("-Z", "Y")
|
|
cam.rotation_euler = rot_quat.to_euler()
|
|
cam_data.lens = 50
|
|
|
|
bpy.context.scene.camera = cam
|
|
return cam
|
|
|
|
|
|
def render_to_png(filepath: str,
|
|
resolution=(1920, 1080),
|
|
samples: int = 256,
|
|
engine: str = "CYCLES",
|
|
*,
|
|
transparent_bg: bool = False) -> None:
|
|
"""현재 씬을 PNG로 렌더.
|
|
|
|
Args:
|
|
filepath: 출력 PNG 경로
|
|
resolution: (width, height) 픽셀
|
|
samples: Cycles 샘플 수 (CYCLES 외 엔진은 무시)
|
|
engine: 'CYCLES' | 'BLENDER_EEVEE' | 'BLENDER_EEVEE_NEXT'
|
|
transparent_bg: True면 film 알파 채널 활성화 → RGBA PNG.
|
|
지형 합성 워크플로의 입력으로 사용.
|
|
"""
|
|
scene = bpy.context.scene
|
|
scene.render.engine = engine
|
|
if engine == "CYCLES":
|
|
scene.cycles.samples = samples
|
|
try:
|
|
prefs = bpy.context.preferences.addons["cycles"].preferences
|
|
prefs.compute_device_type = "CUDA"
|
|
prefs.get_devices()
|
|
scene.cycles.device = "GPU"
|
|
except (KeyError, AttributeError, RuntimeError):
|
|
scene.cycles.device = "CPU"
|
|
scene.render.resolution_x = resolution[0]
|
|
scene.render.resolution_y = resolution[1]
|
|
scene.render.resolution_percentage = 100
|
|
|
|
# 투명 배경 처리 — 두 엔진 모두 지원
|
|
scene.render.film_transparent = bool(transparent_bg)
|
|
|
|
scene.render.image_settings.file_format = "PNG"
|
|
if transparent_bg:
|
|
scene.render.image_settings.color_mode = "RGBA"
|
|
else:
|
|
scene.render.image_settings.color_mode = "RGB"
|
|
|
|
scene.render.filepath = str(Path(filepath).resolve())
|
|
|
|
bpy.ops.render.render(write_still=True)
|
|
|
|
|
|
def export_glb(filepath: str) -> None:
|
|
bpy.ops.export_scene.gltf(
|
|
filepath=str(Path(filepath).resolve()),
|
|
export_format="GLB",
|
|
export_apply=True,
|
|
export_yup=True,
|
|
)
|
|
|
|
|
|
def save_blend(filepath: str) -> None:
|
|
bpy.ops.wm.save_as_mainfile(filepath=str(Path(filepath).resolve()))
|
|
|
|
|
|
# ===========================================================================
|
|
# CLI 진입점 (Blender 헤드리스)
|
|
# ===========================================================================
|
|
|
|
def _parse_argv():
|
|
import argparse
|
|
argv = sys.argv
|
|
argv = argv[argv.index("--") + 1:] if "--" in argv else []
|
|
|
|
ap = argparse.ArgumentParser(description="Gate (spillway) → Blender scene")
|
|
ap.add_argument("--params", required=True, help="JSON 경로 (GateParams)")
|
|
ap.add_argument("--blend", default=None)
|
|
ap.add_argument("--glb", default=None)
|
|
ap.add_argument("--render", default=None)
|
|
ap.add_argument("--time", default="daytime",
|
|
choices=["daytime", "sunset", "overcast"])
|
|
ap.add_argument("--samples", type=int, default=256)
|
|
ap.add_argument("--width", type=int, default=1920)
|
|
ap.add_argument("--height", type=int, default=1080)
|
|
ap.add_argument("--engine", default="CYCLES",
|
|
choices=["CYCLES", "BLENDER_EEVEE", "BLENDER_EEVEE_NEXT"])
|
|
ap.add_argument("--transparent", action="store_true",
|
|
help="투명 배경 RGBA PNG (지형 합성 입력용)")
|
|
return ap.parse_args(argv)
|
|
|
|
|
|
def main():
|
|
args = _parse_argv()
|
|
|
|
params_dict = json.loads(Path(args.params).read_text(encoding="utf-8"))
|
|
params = params_from_dict(params_dict)
|
|
|
|
print(f"[bpy-gate] Loaded params from {args.params}")
|
|
print(f" Gate: {params.n_gates} gates x W{params.gate_width:.1f}m x H{params.gate_height:.1f}m")
|
|
print(f" Sill EL.{params.el_gate_sill:.2f} / Top EL.{params.el_gate_top:.2f} / "
|
|
f"Trunnion EL.{params.el_trunnion_pin:.2f}")
|
|
print(f" Total: {params.total_span:.1f} x {params.total_length:.1f} m")
|
|
print(f" ogee_profile pts: {len(params.ogee_profile)}, "
|
|
f"piers: {len(params.pier_plan_polygons)}, "
|
|
f"bridge: {params.has_service_bridge}, hoist: {params.has_hoist_housings}")
|
|
if args.transparent:
|
|
print(" [mode] transparent background (RGBA)")
|
|
|
|
builder = GateBuilderBpy(params, clear_scene=True)
|
|
builder.build_all()
|
|
print(f"[bpy-gate] Created {builder._object_count} objects "
|
|
f"in {len(builder._collections) - 1} sub-collections")
|
|
|
|
if args.render or args.blend or args.glb:
|
|
setup_lighting_and_camera(params, time_preset=args.time,
|
|
transparent_bg=args.transparent)
|
|
|
|
if args.blend:
|
|
save_blend(args.blend)
|
|
print(f"[bpy-gate] Saved .blend -> {args.blend}")
|
|
if args.glb:
|
|
export_glb(args.glb)
|
|
print(f"[bpy-gate] Exported glTF -> {args.glb}")
|
|
if args.render:
|
|
render_to_png(args.render,
|
|
resolution=(args.width, args.height),
|
|
samples=args.samples,
|
|
engine=args.engine,
|
|
transparent_bg=args.transparent)
|
|
print(f"[bpy-gate] Rendered -> {args.render}")
|
|
|
|
|
|
if __name__ == "__main__" and "--params" in sys.argv:
|
|
main()
|