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>
This commit is contained in:
2026-05-08 10:29:08 +09:00
parent 53d8b53c2f
commit b9342f6726
92 changed files with 3413501 additions and 0 deletions

454
intake_tower_3d_builder.py Normal file
View File

@@ -0,0 +1,454 @@
"""취수탑 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 신설 취수탑 설비 설치도(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}")