"""여수로 수문(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()