Files
s-canvas/gate_3d_builder_bpy.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

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()