Files
s-canvas/valve_chamber_3d_builder.py

409 lines
14 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 파라메트릭 빌더.
구성요소:
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)}개 구성요소 생성")