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

400
valve_chamber_3d_builder.py Normal file
View File

@@ -0,0 +1,400 @@
"""제수변실 + 도수관로 3D 파라메트릭 빌더.
구성요소:
1. 실 본체 (콘크리트 박스)
2. 벽체 절개 뷰 (내부가 보이도록 상부 일부 제거)
3. 도수관 (chamber 관통 파이프)
4. 송수관 (외부 연장)
5. 밸브 × N (원통 + 핸들)
6. 상단 슬라이드 뚜껑 / 맨홀
7. 내부 바닥 slabs (각 EL)
8. 외부 출입 계단
9. 지반
"""
from __future__ import annotations
import numpy as np
import pyvista as pv
from valve_chamber_parser import ValveChamberParams, Valve
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):
hx = 0 if n == 1 else -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):
hx = 0 if p.hatch_count == 1 else -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)}개 구성요소 생성")