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>
455 lines
15 KiB
Python
455 lines
15 KiB
Python
"""취수탑 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 numpy as np
|
||
import pyvista as pv
|
||
|
||
from intake_tower_parser import IntakeTowerParams
|
||
|
||
|
||
# 색상 팔레트
|
||
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
|
||
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
|
||
|
||
# 크레인 주황색 박스 (레일 중간에 매달린 형태)
|
||
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
|
||
|
||
# 지상(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 # 디딤판 깊이
|
||
|
||
# 계단 위치: 본체 좌측 외부
|
||
x_start = -hw - 1.0
|
||
if p.stairs_side == "right":
|
||
x_start = hw + 1.0
|
||
|
||
# 계단을 한 덩어리 경사판으로 표현 (간략화)
|
||
# 또는 각 단을 박스로
|
||
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
|
||
|
||
paths = [
|
||
"SAMPLE_CAD/12996710-M40-001 신설 취수탑 설비 설치도(1/2).dxf",
|
||
"SAMPLE_CAD/12996710-M40-002 신설 취수탑 설비 설치도(2/2).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}")
|