409 lines
14 KiB
Python
409 lines
14 KiB
Python
"""제수변실 + 도수관로 3D 파라메트릭 빌더.
|
||
|
||
구성요소:
|
||
1. 실 본체 (콘크리트 박스)
|
||
2. 벽체 절개 뷰 (내부가 보이도록 상부 일부 제거)
|
||
3. 도수관 (chamber 관통 파이프)
|
||
4. 송수관 (외부 연장)
|
||
5. 밸브 × N (원통 + 핸들)
|
||
6. 상단 슬라이드 뚜껑 / 맨홀
|
||
7. 내부 바닥 slabs (각 EL)
|
||
8. 외부 출입 계단
|
||
9. 지반
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
from typing import Optional
|
||
|
||
import numpy as np
|
||
import pyvista as pv
|
||
|
||
from valve_chamber_parser import ValveChamberParams, Valve, Pipe
|
||
|
||
|
||
COLORS = {
|
||
"chamber": "#A8A59B",
|
||
"slab": "#9A968C",
|
||
"pipe_steel": "#4E6E8E", # 강재도관 (강청색)
|
||
"pipe_cast": "#6B7B8C", # 주철관
|
||
"valve_body": "#27AE60", # 밸브 바디 (녹색 - 산업 표준)
|
||
"valve_handle":"#E74C3C", # 밸브 핸들 (빨강)
|
||
"valve_motor":"#F39C12", # 전동 모터 (주황)
|
||
"hatch": "#BDC3C7", # 뚜껑
|
||
"stairs": "#8B7D6B",
|
||
"parapet": "#A8A59B",
|
||
"ground": "#7F6F5F",
|
||
"pipe_conn": "#4A4A4A", # 플랜지
|
||
}
|
||
|
||
|
||
class ValveChamberBuilder:
|
||
def __init__(self, params: ValveChamberParams):
|
||
self.p = params
|
||
self.meshes: list[tuple[pv.PolyData, str, float]] = []
|
||
|
||
def build_all(self):
|
||
self.meshes = []
|
||
self._build_chamber_body()
|
||
self._build_floor_slabs()
|
||
self._build_main_conduit()
|
||
self._build_transmission_pipes()
|
||
self._build_valves()
|
||
self._build_hatch()
|
||
self._build_entry_stairs()
|
||
self._build_ground()
|
||
return self.meshes
|
||
|
||
# --- 실 본체 ---
|
||
|
||
def _build_chamber_body(self):
|
||
p = self.p
|
||
hw = p.chamber_width / 2
|
||
hd = p.chamber_depth / 2
|
||
wt = p.chamber_wall_thickness
|
||
z0 = p.bottom_el
|
||
z_top = p.top_el
|
||
|
||
# 4개 벽 (상단 일부는 뚜껑을 위해 빼지 않음)
|
||
# 앞 (Y = -hd)
|
||
self._add_box(-hw, hw, -hd, -hd + wt, z0, z_top, COLORS["chamber"])
|
||
# 뒤
|
||
self._add_box(-hw, hw, hd - wt, hd, z0, z_top, COLORS["chamber"])
|
||
# 좌
|
||
self._add_box(-hw, -hw + wt, -hd + wt, hd - wt, z0, z_top, COLORS["chamber"])
|
||
# 우
|
||
self._add_box(hw - wt, hw, -hd + wt, hd - wt, z0, z_top, COLORS["chamber"])
|
||
|
||
# 바닥
|
||
self._add_box(-hw, hw, -hd, hd, z0, z0 + 0.4, COLORS["chamber"])
|
||
|
||
# 상판 (지붕) - 뚜껑 공간 제외
|
||
slab_t = 0.4
|
||
if p.has_hatch and p.hatch_count > 0:
|
||
# 뚜껑 위치는 상단 중앙
|
||
hatch_s = p.hatch_size / 2
|
||
n = p.hatch_count
|
||
# 뚜껑 여러 개면 X방향 분산
|
||
for i in range(n):
|
||
if n == 1:
|
||
hx = 0
|
||
else:
|
||
hx = -hw * 0.5 + (i / (n - 1)) * hw
|
||
|
||
# 상판은 뚜껑 기준 좌우로 분할
|
||
# 좌측
|
||
if i == 0:
|
||
self._add_box(-hw, hx - hatch_s, -hd, hd, z_top, z_top + slab_t, COLORS["chamber"])
|
||
# 우측 (마지막이면 우측 끝까지)
|
||
if i == n - 1:
|
||
self._add_box(hx + hatch_s, hw, -hd, hd, z_top, z_top + slab_t, COLORS["chamber"])
|
||
# 뚜껑 주변 Y 방향 채움
|
||
self._add_box(hx - hatch_s, hx + hatch_s, -hd, -hatch_s, z_top, z_top + slab_t, COLORS["chamber"])
|
||
self._add_box(hx - hatch_s, hx + hatch_s, hatch_s, hd, z_top, z_top + slab_t, COLORS["chamber"])
|
||
else:
|
||
# 풀 상판
|
||
self._add_box(-hw, hw, -hd, hd, z_top, z_top + slab_t, COLORS["chamber"])
|
||
|
||
# --- 내부 바닥 slab (각 EL) ---
|
||
|
||
def _build_floor_slabs(self):
|
||
p = self.p
|
||
hw = p.chamber_width / 2 - p.chamber_wall_thickness - 0.1
|
||
hd = p.chamber_depth / 2 - p.chamber_wall_thickness - 0.1
|
||
for el in p.floor_elevations:
|
||
if el <= p.bottom_el + 0.4:
|
||
continue
|
||
if el >= p.top_el - 0.4:
|
||
continue
|
||
self._add_box(-hw, hw, -hd, hd, el - 0.1, el + 0.1, COLORS["slab"])
|
||
|
||
# --- 도수관 (chamber 관통) ---
|
||
|
||
def _build_main_conduit(self):
|
||
"""파서가 M-301/도수관 pipe를 추출했으면 그쪽에 위임 (no-op).
|
||
|
||
분기(has_inlet_branch)·단일 모두 파서 `_finalize`가 pipe 객체로 생성하므로
|
||
빌더는 `_build_transmission_pipes` 한 경로만 돌면 된다. 파서에서 도수관이
|
||
전혀 없을 때만(비정상 상태) 경고 출력 후 종료.
|
||
"""
|
||
p = self.p
|
||
has_main = any(
|
||
("M-301" in pp.name) or ("도수관" in pp.name)
|
||
for pp in (p.pipes or [])
|
||
)
|
||
if has_main:
|
||
return
|
||
# 여기에 도달한다면 파서가 도수관을 생성 실패한 것 — 조용히 skip하여
|
||
# "혼자 떠 있는 고아 파이프"가 생기지 않도록 한다.
|
||
return
|
||
|
||
# --- 송수관 및 외부 연장 ---
|
||
|
||
def _build_transmission_pipes(self):
|
||
"""parser가 추출한 모든 pipe를 명시적 start/end/diameter로 빌드."""
|
||
p = self.p
|
||
if not p.pipes:
|
||
return
|
||
for pipe in p.pipes:
|
||
start = pipe.start
|
||
end = pipe.end
|
||
# 유효성 체크: zero-vector 이면 폴백 _finalize에서 처리됨
|
||
if start == (0.0, 0.0, 0.0) and end == (0.0, 0.0, 0.0):
|
||
continue
|
||
is_main = ("M-301" in pipe.name) or ("도수관" in pipe.name)
|
||
color = COLORS["pipe_steel"] if is_main else COLORS["pipe_cast"]
|
||
self._add_pipe(start, end, pipe.diameter, color)
|
||
# 양 끝 플랜지 (도수관/대구경만)
|
||
if pipe.diameter >= 0.3:
|
||
self._add_flange(start, pipe.diameter, COLORS["pipe_conn"])
|
||
self._add_flange(end, pipe.diameter, COLORS["pipe_conn"])
|
||
|
||
# --- 밸브 ---
|
||
|
||
def _build_valves(self):
|
||
p = self.p
|
||
for v in p.valves:
|
||
self._build_single_valve(v)
|
||
|
||
def _build_single_valve(self, v: Valve):
|
||
"""밸브 하나: 바디 + 핸들/모터."""
|
||
# 위치 검증
|
||
z = v.elevation
|
||
x = v.center_x
|
||
y = v.center_y
|
||
|
||
# 밸브 바디 (축정렬 원통 또는 박스)
|
||
body_r = v.diameter / 2
|
||
body_h = v.diameter * 1.5
|
||
|
||
# 밸브가 놓인 파이프 방향 추정 (X 또는 Y)
|
||
# 가장 가까운 pipe의 (end-start) 방향을 사용; 없으면 X축 기본
|
||
flow_dir = (1.0, 0.0, 0.0)
|
||
try:
|
||
best_d = None
|
||
for pp in (self.p.pipes or []):
|
||
if pp.start == (0,0,0) and pp.end == (0,0,0):
|
||
continue
|
||
# pipe 중간점과 valve 거리
|
||
mx = (pp.start[0] + pp.end[0]) / 2
|
||
my = (pp.start[1] + pp.end[1]) / 2
|
||
d = ((mx - x)**2 + (my - y)**2) ** 0.5
|
||
if best_d is None or d < best_d:
|
||
dx = pp.end[0] - pp.start[0]
|
||
dy = pp.end[1] - pp.start[1]
|
||
L = (dx*dx + dy*dy) ** 0.5
|
||
if L > 1e-6:
|
||
flow_dir = (dx/L, dy/L, 0.0)
|
||
best_d = d
|
||
except Exception:
|
||
pass
|
||
|
||
if v.valve_type == "BUTTERFLY":
|
||
# 버터플라이: 납작한 원통 (관로 방향에 직교 평면)
|
||
body = pv.Cylinder(
|
||
center=(x, y, z),
|
||
direction=flow_dir,
|
||
radius=body_r * 1.1,
|
||
height=body_h * 0.6,
|
||
resolution=20,
|
||
).extract_surface()
|
||
self.meshes.append((body, COLORS["valve_body"], 1.0))
|
||
|
||
# 상부 모터 박스
|
||
motor_size = body_r * 1.2
|
||
motor_base = z + body_r * 1.1
|
||
motor_top = motor_base + motor_size * 2
|
||
self._add_box(
|
||
x - motor_size / 2, x + motor_size / 2,
|
||
y - motor_size / 2, y + motor_size / 2,
|
||
motor_base, motor_top,
|
||
COLORS["valve_motor"],
|
||
)
|
||
|
||
# 모터 축
|
||
stem = pv.Cylinder(
|
||
center=(x, y, (z + body_r + motor_base) / 2),
|
||
direction=(0, 0, 1),
|
||
radius=0.05,
|
||
height=(motor_base - z - body_r),
|
||
resolution=8,
|
||
).extract_surface()
|
||
self.meshes.append((stem, COLORS["pipe_conn"], 1.0))
|
||
|
||
elif v.valve_type == "GATE":
|
||
# 게이트: 박스형 바디. 관로 방향이 X면 X축으로 길게, Y면 Y축으로 길게
|
||
if abs(flow_dir[0]) >= abs(flow_dir[1]):
|
||
# X 방향 흐름 → 박스도 X 길이↑
|
||
self._add_box(
|
||
x - body_r * 1.4, x + body_r * 1.4,
|
||
y - body_r, y + body_r,
|
||
z - body_r, z + body_r,
|
||
COLORS["valve_body"],
|
||
)
|
||
else:
|
||
# Y 방향 흐름
|
||
self._add_box(
|
||
x - body_r, x + body_r,
|
||
y - body_r * 1.4, y + body_r * 1.4,
|
||
z - body_r, z + body_r,
|
||
COLORS["valve_body"],
|
||
)
|
||
|
||
# 상부 stem (게이트 밸브의 특징)
|
||
stem_h = body_r * 4
|
||
stem = pv.Cylinder(
|
||
center=(x, y, z + body_r + stem_h / 2),
|
||
direction=(0, 0, 1),
|
||
radius=0.04,
|
||
height=stem_h,
|
||
resolution=8,
|
||
).extract_surface()
|
||
self.meshes.append((stem, COLORS["pipe_conn"], 1.0))
|
||
|
||
# 상부 핸들 or 모터
|
||
handle_r = body_r * 1.0
|
||
handle = pv.Cylinder(
|
||
center=(x, y, z + body_r + stem_h),
|
||
direction=(0, 0, 1),
|
||
radius=handle_r,
|
||
height=0.1,
|
||
resolution=16,
|
||
).extract_surface()
|
||
self.meshes.append((handle, COLORS["valve_handle"], 1.0))
|
||
|
||
else:
|
||
# 일반: 단순 박스
|
||
self._add_box(
|
||
x - body_r, x + body_r,
|
||
y - body_r, y + body_r,
|
||
z - body_r, z + body_r,
|
||
COLORS["valve_body"],
|
||
)
|
||
|
||
# --- 상단 슬라이드 뚜껑 ---
|
||
|
||
def _build_hatch(self):
|
||
p = self.p
|
||
if not p.has_hatch:
|
||
return
|
||
hw = p.chamber_width / 2
|
||
hs = p.hatch_size / 2
|
||
z = p.top_el + 0.5 # 상판 위
|
||
|
||
for i in range(p.hatch_count):
|
||
if p.hatch_count == 1:
|
||
hx = 0
|
||
else:
|
||
hx = -hw * 0.5 + (i / (p.hatch_count - 1)) * hw
|
||
# 뚜껑 (약간 돌출)
|
||
self._add_box(
|
||
hx - hs, hx + hs,
|
||
-hs, hs,
|
||
z, z + 0.15,
|
||
COLORS["hatch"],
|
||
)
|
||
|
||
# --- 외부 출입 계단 ---
|
||
|
||
def _build_entry_stairs(self):
|
||
p = self.p
|
||
if not p.has_entry_stairs:
|
||
return
|
||
hw = p.chamber_width / 2
|
||
total_rise = p.top_el - p.bottom_el
|
||
n_steps = max(int(total_rise / 0.17), 8)
|
||
step_h = total_rise / n_steps
|
||
step_d = 0.28
|
||
stair_w = 1.2
|
||
z0 = p.bottom_el
|
||
|
||
# 좌측 외부 계단
|
||
x_start = -hw - 0.5
|
||
for i in range(n_steps):
|
||
sx0 = x_start - (i + 1) * step_d
|
||
sx1 = x_start - i * step_d
|
||
z_top = z0 + (i + 1) * step_h
|
||
self._add_box(sx0, sx1,
|
||
-stair_w / 2, stair_w / 2,
|
||
z0, z_top,
|
||
COLORS["stairs"])
|
||
|
||
# --- 지반 ---
|
||
|
||
def _build_ground(self):
|
||
p = self.p
|
||
margin = max(p.chamber_width, p.chamber_depth) * 0.8
|
||
hw = p.chamber_width / 2 + margin
|
||
hd = p.chamber_depth / 2 + margin
|
||
ground_el = p.bottom_el - 0.5
|
||
pts = np.array([
|
||
[-hw, -hd, ground_el], [hw, -hd, ground_el],
|
||
[hw, hd, ground_el], [-hw, hd, ground_el],
|
||
])
|
||
self.meshes.append((pv.PolyData(pts, np.array([4, 0, 1, 2, 3])),
|
||
COLORS["ground"], 1.0))
|
||
|
||
# --- 저수준 헬퍼 ---
|
||
|
||
def _add_box(self, x0, x1, y0, y1, z0, z1, color):
|
||
if x1 <= x0 or y1 <= y0 or z1 <= z0:
|
||
return
|
||
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],
|
||
])
|
||
self.meshes.append((pv.PolyData(pts, faces), color, 1.0))
|
||
|
||
def _add_pipe(self, start, end, diameter, color):
|
||
try:
|
||
s = np.array(start)
|
||
e = np.array(end)
|
||
length = float(np.linalg.norm(e - s))
|
||
if length < 0.1:
|
||
return
|
||
direction = (e - s) / length
|
||
center = (s + e) / 2
|
||
pipe = pv.Cylinder(
|
||
center=tuple(center),
|
||
direction=tuple(direction),
|
||
radius=diameter / 2,
|
||
height=length,
|
||
resolution=20,
|
||
).extract_surface()
|
||
self.meshes.append((pipe, color, 1.0))
|
||
except Exception:
|
||
pass
|
||
|
||
def _add_flange(self, pos, diameter, color):
|
||
try:
|
||
flange = pv.Cylinder(
|
||
center=pos,
|
||
direction=(1, 0, 0), # X방향 간단 배치
|
||
radius=diameter / 2 * 1.3,
|
||
height=0.15,
|
||
resolution=16,
|
||
).extract_surface()
|
||
self.meshes.append((flange, color, 1.0))
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def build_valve_chamber_meshes(params: ValveChamberParams):
|
||
return ValveChamberBuilder(params).build_all()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
from valve_chamber_parser import parse_valve_chamber
|
||
paths = ["SAMPLE_CAD/12996710-M43-002 신설 제수변실 설비 배치도.dxf"]
|
||
p = parse_valve_chamber(paths)
|
||
print(p.summary())
|
||
meshes = ValveChamberBuilder(p).build_all()
|
||
print(f"\n{len(meshes)}개 구성요소 생성")
|