Files
s-canvas/intake_tower_3d_builder.py

464 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""취수탑 3D 파라메트릭 빌더.
IntakeTowerParams → PyVista 메쉬 리스트 (구성요소별)
구성요소:
1. 주 본체 (concrete shell, 직사각 or L자)
2. 연장부 (접근수로 방향, L자의 경우)
3. 수문 개구부 × N (사각 홀)
4. 수문 개폐장치 × N (원통 + 모터박스)
5. 상부 호이스트 크레인 (beam + trolley)
6. 호이스트 레일 (I-beam)
7. 점검구 커버 (계단식)
8. 각 EL 바닥 slab
9. 출입 계단 (외부)
10. 지붕
11. 난간 / 파라펫
"""
from __future__ import annotations
import math
from typing import Optional
import numpy as np
import pyvista as pv
from intake_tower_parser import IntakeTowerParams, GatePosition
# 색상 팔레트
COLORS = {
"body": "#B8B5A8", # 콘크리트
"body_light": "#C5C2B5", # 상부/내부
"gate_steel": "#3D4A5C", # 수문 강재
"actuator": "#5A4A3A", # 개폐장치 모터박스
"actuator_cyl":"#8D9BA6", # 원통 (잭)
"hoist_rail": "#4A4A4A", # H빔
"hoist_crane": "#D97706", # 호이스트 크레인 (주황)
"stairs": "#8B7D6B", # 계단
"parapet": "#A8A59B", # 난간
"floor": "#9A968C", # 바닥
"inspect": "#D4A373", # 점검구 커버
"roof": "#7A6A5A", # 지붕
"water": "#3A7AA8", # 수면
"ground": "#7F6F5F", # 지반
}
class IntakeTowerBuilder:
"""취수탑 파라메트릭 3D 빌더."""
def __init__(self, params: IntakeTowerParams):
self.p = params
self.meshes: list[tuple[pv.PolyData, str, float]] = []
def build_all(self) -> list[tuple[pv.PolyData, str, float]]:
self.meshes = []
self._build_main_body()
self._build_extension() # L자 연장부
self._build_gate_openings() # 수문 개구부 (구멍)
self._build_gate_actuators() # 개폐장치 × N
self._build_floor_slabs() # 각 EL 바닥
self._build_roof()
self._build_hoist_rail()
self._build_hoist_crane()
self._build_inspection_cover()
self._build_entry_stairs()
self._build_parapet()
self._build_ground_and_water()
return self.meshes
# --- 주 본체 (L자 또는 직사각) ---
def _build_main_body(self):
"""주 본체: 벽체 4면 + 바닥.
좌표계:
- 원점 = 본체 평면 중심 + 바닥 EL
- X: 가로 (body_width)
- Y: 세로 (body_depth)
- Z: 표고 (body_bottom_el ~ body_top_el)
"""
p = self.p
hw = p.body_width / 2
hd = p.body_depth / 2
z0 = p.body_bottom_el
z1 = p.body_top_el
# 벽 두께
wall_t = 0.5
# 4개 벽: 외곽에서 안쪽으로 wall_t 두께
# 전면 벽 (Y = -hd)
self._add_wall(-hw, hw, -hd, -hd + wall_t, z0, z1, COLORS["body"])
# 후면 벽
self._add_wall(-hw, hw, hd - wall_t, hd, z0, z1, COLORS["body"])
# 좌측 벽
self._add_wall(-hw, -hw + wall_t, -hd + wall_t, hd - wall_t, z0, z1, COLORS["body"])
# 우측 벽
self._add_wall(hw - wall_t, hw, -hd + wall_t, hd - wall_t, z0, z1, COLORS["body"])
# 바닥 slab
self._add_wall(-hw, hw, -hd, hd, z0, z0 + 0.5, COLORS["body"])
def _add_wall(self, x0, x1, y0, y1, z0, z1, color):
"""박스 형태의 벽 추가."""
mesh = self._make_box(x0, x1, y0, y1, z0, z1)
self.meshes.append((mesh, color, 1.0))
def _make_box(self, x0, x1, y0, y1, z0, z1) -> pv.PolyData:
pts = np.array([
[x0, y0, z0], [x1, y0, z0], [x1, y1, z0], [x0, y1, z0],
[x0, y0, z1], [x1, y0, z1], [x1, y1, z1], [x0, y1, z1],
])
faces = np.hstack([
[4, 0, 3, 2, 1], [4, 4, 5, 6, 7],
[4, 0, 1, 5, 4], [4, 2, 3, 7, 6],
[4, 1, 2, 6, 5], [4, 0, 4, 7, 3],
])
return pv.PolyData(pts, faces)
# --- 연장부 (L자의 경우 수로 방향) ---
def _build_extension(self):
"""L자 연장부 — 본체 하류 방향으로 뻗은 접근수로 옹벽."""
p = self.p
if not p.has_l_extension or p.extension_length <= 0:
return
hw = p.extension_width / 2
L = p.extension_length
z0 = p.extension_bottom_el
z1 = p.body_top_el - 5.0 # 연장부는 본체보다 낮음
# 연장부: Y+ 방향 (본체 후면에서 계속)
body_hd = p.body_depth / 2
# 양쪽 옹벽
wall_t = 0.5
# 좌측 옹벽
self._add_wall(-hw, -hw + wall_t, body_hd, body_hd + L, z0, z1, COLORS["body"])
# 우측 옹벽
self._add_wall(hw - wall_t, hw, body_hd, body_hd + L, z0, z1, COLORS["body"])
# 바닥 slab
self._add_wall(-hw, hw, body_hd, body_hd + L, z0, z0 + 0.5, COLORS["body"])
# --- 수문 개구부 (벽에 구멍) ---
def _build_gate_openings(self):
"""수문 개구부: 본체 전면 벽에 사각 홀로 표현.
주의: 진짜 boolean cut 대신 어두운 사각형을 벽 앞면에 배치하여 시각적 표현.
"""
p = self.p
hw = p.body_width / 2
hd = p.body_depth / 2
for g in p.gates:
gx = g.center_x
gw = g.gate_width
gh = g.gate_height
z_center = g.elevation
z0 = z_center - gh / 2
z1 = z_center + gh / 2
# 전면 벽 앞쪽에 약간 돌출시킨 검은 사각형 (어두운 개구부 느낌)
# 벽 앞면은 Y=-hd이므로 그 앞에 얇은 판 배치
opening = self._make_box(
gx - gw / 2, gx + gw / 2,
-hd - 0.05, -hd + 0.01, # 벽 전면 바로 앞 (Y-방향으로 밖)
z0, z1,
)
self.meshes.append((opening, "#1A1A1A", 1.0)) # 어두운 홀
# --- 수문 개폐장치 (원통 + 모터박스) ---
def _build_gate_actuators(self):
"""각 수문 위에 수직 잭(원통) + 상부 모터박스."""
p = self.p
hd = p.body_depth / 2
for g in p.gates:
gx = g.center_x
# 개폐장치는 본체 내부 (벽에서 1m 안쪽)
ay = -hd + 1.0
# 잭 원통: 수문 EL부터 상단까지
jack_bottom = g.elevation + g.gate_height / 2
jack_top = p.body_top_el - 1.5
if jack_top <= jack_bottom:
continue
try:
cyl = pv.Cylinder(
center=(gx, ay, (jack_bottom + jack_top) / 2),
direction=(0, 0, 1),
radius=g.actuator_radius * 0.3, # 잭 지름은 장치 크기의 30%
height=(jack_top - jack_bottom),
resolution=16,
).extract_surface()
self.meshes.append((cyl, COLORS["actuator_cyl"], 1.0))
except Exception:
pass
# 상부 모터박스 (권양기)
motor_w = g.actuator_radius * 1.4
motor_h = 1.0
motor_z0 = jack_top - motor_h / 2
motor_z1 = motor_z0 + motor_h
motor = self._make_box(
gx - motor_w, gx + motor_w,
ay - motor_w * 0.7, ay + motor_w * 0.7,
motor_z0, motor_z1,
)
self.meshes.append((motor, COLORS["actuator"], 1.0))
# --- 바닥 slabs (각 EL) ---
def _build_floor_slabs(self):
"""각 중간 EL에 얇은 바닥 slab."""
p = self.p
hw = p.body_width / 2
hd = p.body_depth / 2
slab_t = 0.3
for el in p.floor_elevations:
if el <= p.body_bottom_el + 0.5:
continue
if el >= p.body_top_el - 0.5:
continue
slab = self._make_box(
-hw + 0.5, hw - 0.5,
-hd + 0.5, hd - 0.5,
el - slab_t / 2, el + slab_t / 2,
)
self.meshes.append((slab, COLORS["floor"], 1.0))
# --- 지붕 ---
def _build_roof(self):
p = self.p
hw = p.body_width / 2 + 0.3 # 처마
hd = p.body_depth / 2 + 0.3
z0 = p.body_top_el
z1 = z0 + p.roof_thickness
roof = self._make_box(-hw, hw, -hd, hd, z0, z1)
self.meshes.append((roof, COLORS["roof"], 1.0))
# --- 호이스트 레일 ---
def _build_hoist_rail(self):
p = self.p
if not p.has_hoist:
return
# 레일은 본체 상단 천장 부근에 Y축 방향으로 길게
# 본체 폭 전체에 걸치거나 body_width의 80% 수준
hw = p.body_width / 2
rail_z = p.hoist_rail_el
rail_w = 0.3 # H빔 폭
rail_h = 0.4 # H빔 높이
# 레일 2개 (앞쪽, 뒤쪽)
hd = p.body_depth / 2
for y_rail in [-hd + 1.5, hd - 1.5]:
rail = self._make_box(
-hw + 0.5, hw - 0.5,
y_rail - rail_w / 2, y_rail + rail_w / 2,
rail_z, rail_z + rail_h,
)
self.meshes.append((rail, COLORS["hoist_rail"], 1.0))
# --- 호이스트 크레인 (trolley) ---
def _build_hoist_crane(self):
p = self.p
if not p.has_hoist:
return
# 크레인 주황색 박스 (레일 중간에 매달린 형태)
hw = p.body_width / 2
crane_x = 0.0 # 중앙
crane_w = 1.5
crane_d = 2.5 # Y 방향
crane_h = 1.0
crane_z = p.hoist_rail_el + 0.4 # 레일 위
crane = self._make_box(
crane_x - crane_w / 2, crane_x + crane_w / 2,
-crane_d / 2, crane_d / 2,
crane_z, crane_z + crane_h,
)
self.meshes.append((crane, COLORS["hoist_crane"], 1.0))
# --- 점검구 커버 ---
def _build_inspection_cover(self):
p = self.p
if not p.has_inspection_cover:
return
hw = p.body_width / 2
hd = p.body_depth / 2
# 점검구는 지붕 위의 작은 네모 (계단식 2단)
cx = p.inspection_cover_x
cy = p.inspection_cover_y
size = p.inspection_cover_size
# 본체 내부로 위치 맞춤
cx = max(-hw + size, min(hw - size, cx))
cy = max(-hd + size, min(hd - size, cy))
z_roof = p.body_top_el
# 2단 계단
step1 = self._make_box(
cx - size / 2, cx + size / 2,
cy - size / 2, cy + size / 2,
z_roof + p.roof_thickness, z_roof + p.roof_thickness + 0.3,
)
self.meshes.append((step1, COLORS["inspect"], 1.0))
step2 = self._make_box(
cx - size / 3, cx + size / 3,
cy - size / 3, cy + size / 3,
z_roof + p.roof_thickness + 0.3, z_roof + p.roof_thickness + 0.6,
)
self.meshes.append((step2, COLORS["inspect"], 1.0))
# --- 외부 출입 계단 ---
def _build_entry_stairs(self):
p = self.p
if not p.has_entry_stairs:
return
hw = p.body_width / 2
hd = p.body_depth / 2
# 지상(body_bottom_el 주변)에서 body_top_el까지 계단
# 좌측에서 진입 기본
stair_w = p.stairs_width
z0 = p.body_bottom_el
z1 = p.body_top_el
total_rise = z1 - z0
# 계단 개수 (step height 0.17m 표준)
n_steps = max(int(total_rise / 0.17), 10)
step_h = total_rise / n_steps
step_d = 0.28 # 디딤판 깊이
total_run = n_steps * step_d
# 계단 위치: 본체 좌측 외부
x_start = -hw - 1.0
x_end = x_start - total_run # 서쪽 방향
if p.stairs_side == "right":
x_start = hw + 1.0
x_end = x_start + total_run
# 계단을 한 덩어리 경사판으로 표현 (간략화)
# 또는 각 단을 박스로
for i in range(n_steps):
z_step_bottom = z0
z_step_top = z0 + (i + 1) * step_h
if p.stairs_side == "left":
sx0 = x_start - (i + 1) * step_d
sx1 = x_start - i * step_d
else:
sx0 = x_start + i * step_d
sx1 = x_start + (i + 1) * step_d
step = self._make_box(
sx0, sx1,
-stair_w / 2, stair_w / 2,
z_step_bottom, z_step_top,
)
self.meshes.append((step, COLORS["stairs"], 1.0))
# --- 난간 / 파라펫 ---
def _build_parapet(self):
p = self.p
if not p.has_parapet:
return
hw = p.body_width / 2
hd = p.body_depth / 2
z_base = p.body_top_el + p.roof_thickness
z_top = z_base + p.parapet_height
thick = 0.15
# 4면 파라펫
# 전
self._add_wall(-hw, hw, -hd, -hd + thick, z_base, z_top, COLORS["parapet"])
# 후
self._add_wall(-hw, hw, hd - thick, hd, z_base, z_top, COLORS["parapet"])
# 좌
self._add_wall(-hw, -hw + thick, -hd + thick, hd - thick, z_base, z_top, COLORS["parapet"])
# 우
self._add_wall(hw - thick, hw, -hd + thick, hd - thick, z_base, z_top, COLORS["parapet"])
# --- 지반 + 수면 ---
def _build_ground_and_water(self):
p = self.p
# 지반
ground_margin = max(p.body_width, p.body_depth) * 1.5
hw = p.body_width / 2 + ground_margin
hd = p.body_depth / 2 + ground_margin
# 연장부 고려
if p.has_l_extension:
hd += p.extension_length / 2
ground_pts = np.array([
[-hw, -hd, p.body_bottom_el - 0.5],
[hw, -hd, p.body_bottom_el - 0.5],
[hw, hd, p.body_bottom_el - 0.5],
[-hw, hd, p.body_bottom_el - 0.5],
])
ground = pv.PolyData(ground_pts, np.array([4, 0, 1, 2, 3]))
self.meshes.append((ground, COLORS["ground"], 1.0))
# 상류측 수면 (전방 Y=-hd 방향으로 평판)
if p.gates:
# 수면 EL = 가장 높은 수문 EL + 1m (상시만수위 근사)
max_gate_el = max(g.elevation for g in p.gates)
water_el = max_gate_el + 2.0 # 약간 여유
water_hw = p.body_width / 2 + 20
front_edge = -p.body_depth / 2
water_pts = np.array([
[-water_hw, front_edge - 30, water_el],
[water_hw, front_edge - 30, water_el],
[water_hw, front_edge + 0.3, water_el],
[-water_hw, front_edge + 0.3, water_el],
])
water = pv.PolyData(water_pts, np.array([4, 0, 1, 2, 3]))
self.meshes.append((water, COLORS["water"], 0.8))
# 편의 함수
def build_intake_tower_meshes(params: IntakeTowerParams):
return IntakeTowerBuilder(params).build_all()
if __name__ == "__main__":
from intake_tower_parser import parse_intake_tower
from pathlib import Path
paths = [
"SAMPLE_CAD/12996710-M40-001 신설 취수탑 설비 설치도(12).dxf",
"SAMPLE_CAD/12996710-M40-002 신설 취수탑 설비 설치도(22).dxf",
]
params = parse_intake_tower(paths)
print(params.summary())
builder = IntakeTowerBuilder(params)
meshes = builder.build_all()
print(f"\n{len(meshes)}개 구성요소 생성:")
for i, (m, c, o) in enumerate(meshes):
print(f" [{i:2}] {m.n_points:5}pts {m.n_cells:5}cells color={c}")