Files
s-canvas/retaining_wall_3d_builder.py
HYUNJUNGLEE b9342f6726 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>
2026-05-08 10:29:08 +09:00

373 lines
12 KiB
Python
Raw Permalink 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. 본체 (사다리꼴 단면을 길이방향으로 sweep)
2. 기초 slab (하부 넓은 base)
3. 뒤채움 지형 (배면 토사)
4. 배면 앵커바 × N (격자 배치)
5. 상단 안전난간 (parapet)
6. 수축이음 세로선 (표면에 시각화)
7. 배수공 (weep hole)
8. 전면 지반 + 바위
"""
from __future__ import annotations
import math
import numpy as np
import pyvista as pv
from retaining_wall_parser import RetainingWallParams
COLORS = {
"wall": "#A8A59B", # 콘크리트
"wall_face": "#B5B2A7", # 전면 (약간 밝게)
"base": "#8D8A80", # 기초 slab
"backfill": "#8B7355", # 뒤채움 토사
"anchor": "#2C3E50", # 앵커바 (철)
"anchor_plate": "#566573", # 앵커 판
"parapet": "#A8A59B",
"rail": "#4A4A4A", # 난간
"joint": "#5D4A33", # 수축이음 (진한 선)
"weep": "#2C3E50",
"ground": "#8B7D6B",
"rock": "#6B5D50", # 기초암반
}
class RetainingWallBuilder:
def __init__(self, params: RetainingWallParams):
self.p = params
self.meshes: list[tuple[pv.PolyData, str, float]] = []
def build_all(self):
self.meshes = []
self._build_base_slab()
self._build_wall_body()
self._build_backfill()
self._build_anchors()
self._build_parapet()
self._build_contraction_joints()
self._build_weep_holes()
self._build_ground()
return self.meshes
# 좌표계:
# X: 길이방향 (총 연장)
# Y: 전면(-Y) ↔ 배면(+Y) 방향
# Z: 높이 (EL)
# --- 기초 slab (바닥) ---
def _build_base_slab(self):
p = self.p
L = p.total_length
W = p.base_slab_width
T = p.base_slab_thickness
z0 = p.bottom_el
z1 = z0 + T
# 기초는 벽 하단에서 전면/배면 모두 확장
# 중심이 벽 중심과 같다고 가정
self._add_box(-L/2, L/2, -W/2, W/2, z0, z1, COLORS["base"])
# --- 본체 벽 (사다리꼴 단면 sweep) ---
def _build_wall_body(self):
p = self.p
L = p.total_length
z_bot = p.bottom_el + p.base_slab_thickness
z_top = p.top_el
W_bot = p.avg_bottom_width
W_top = p.avg_top_width
# 전면 경사: 하단이 더 앞으로 나온 사다리꼴
# 단면: YZ 평면에서
# 하단: (-W_bot/2, 0) ~ (W_bot/2, 0)
# 상단: (-W_top/2, H) ~ (W_top/2, H)
# 실제로는 배면은 수직, 전면만 경사
# 배면 벽 (Y+ 면)
# 단면:
# 전면(Y-): (-W_bot/2 + batter*H, z_bot) → (-W_top/2, z_top)
# 배면(Y+): (W_bot/2, z_bot) → (W_bot/2, z_top) (수직)
# batter: 전면이 안쪽으로 기움 (현재는 W_top - W_bot 차이로 묵시적 처리)
# 8개 코너 점
y_front_bot = -W_bot / 2
y_front_top = -W_top / 2 # 전면은 상단에서 얇아짐
y_back_bot = W_bot / 2
y_back_top = W_top / 2 # 배면도 상단에서 얇아짐 (대칭 사다리꼴) 아니면 수직
# 실제 옹벽은 배면 수직이 더 흔함. 수직으로 고정:
y_back_bot = W_bot / 2
y_back_top = W_bot / 2
pts = np.array([
[-L/2, y_front_bot, z_bot], # 0 좌하전
[L/2, y_front_bot, z_bot], # 1 우하전
[L/2, y_back_bot, z_bot], # 2 우하배
[-L/2, y_back_bot, z_bot], # 3 좌하배
[-L/2, y_front_top, z_top], # 4 좌상전
[L/2, y_front_top, z_top], # 5 우상전
[L/2, y_back_top, z_top], # 6 우상배
[-L/2, y_back_top, z_top], # 7 좌상배
])
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), COLORS["wall"], 1.0))
# --- 뒤채움 (배면 토사) ---
def _build_backfill(self):
p = self.p
L = p.total_length
z_top = p.top_el
# 배면에서 뒤로 뻗은 토사 (30 길이)
back_depth = 15.0
y_start = p.avg_bottom_width / 2 # 벽 배면
y_end = y_start + back_depth
# 토사는 상단부터 아래로 경사 (자연 지형)
# 단면: 상단 수평, 경사면, 바닥 수평
pts = np.array([
[-L/2, y_start, z_top],
[ L/2, y_start, z_top],
[ L/2, y_end, z_top], # 뒤쪽 상단
[-L/2, y_end, z_top],
# 바닥은 z_top - 1 정도로 살짝 낮게
[-L/2, y_start, z_top - 0.1],
[ L/2, y_start, z_top - 0.1],
[ L/2, y_end, z_top - 3],
[-L/2, y_end, z_top - 3],
])
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), COLORS["backfill"], 1.0))
# --- 배면 앵커바 (격자 배치) ---
def _build_anchors(self):
p = self.p
if not p.has_anchors:
return
L = p.total_length
z_bot = p.bottom_el + p.base_slab_thickness
z_top = p.top_el - 1.0 # 상단은 난간 영역 제외
# 격자 개수 결정
dx = p.anchor_spacing_h
dz = p.anchor_spacing_v
nx = max(int(L / dx), 2)
nz = max(int((z_top - z_bot) / dz), 2)
# 개수 상한 (너무 많으면 안 만듦)
max_total = 200
if nx * nz > max_total:
# 간격 늘리기
ratio = math.sqrt(nx * nz / max_total)
nx = int(nx / ratio)
nz = int(nz / ratio)
# 격자 배치
y_wall_back = p.avg_bottom_width / 2 # 벽 배면 Y 좌표
angle_rad = math.radians(p.anchor_angle_deg)
anchor_L = p.anchor_length
# 앵커는 배면에서 아래쪽으로 경사져 안으로 매입
dx_anchor = math.cos(angle_rad) * anchor_L
dz_anchor = -math.sin(angle_rad) * anchor_L
for i in range(nx):
for j in range(nz):
x = -L/2 + (i + 0.5) * (L / nx)
z = z_bot + (j + 0.5) * ((z_top - z_bot) / nz)
start = np.array([x, y_wall_back, z])
end = np.array([x, y_wall_back + dx_anchor, z + dz_anchor])
# 앵커바 (얇은 실린더)
try:
length = float(np.linalg.norm(end - start))
direction = (end - start) / length
anchor = pv.Cylinder(
center=tuple((start + end) / 2),
direction=tuple(direction),
radius=p.anchor_diameter / 2,
height=length,
resolution=8,
).extract_surface()
self.meshes.append((anchor, COLORS["anchor"], 1.0))
except Exception:
continue
# 앵커 헤드 판 (벽 면에 붙은 작은 사각)
plate_size = 0.2
self._add_box(
x - plate_size / 2, x + plate_size / 2,
y_wall_back, y_wall_back + 0.05,
z - plate_size / 2, z + plate_size / 2,
COLORS["anchor_plate"],
)
# --- 상단 파라펫 / 난간 ---
def _build_parapet(self):
p = self.p
if not p.has_parapet:
return
L = p.total_length
z0 = p.top_el
z1 = z0 + p.parapet_height
t = p.parapet_thickness
# 전면 파라펫
y_front = -p.avg_top_width / 2
self._add_box(-L/2, L/2, y_front, y_front + t, z0, z1, COLORS["parapet"])
# 배면 파라펫 (선택적)
y_back = p.avg_top_width / 2 # 상단 폭 기준
self._add_box(-L/2, L/2, y_back - t, y_back, z0, z1, COLORS["parapet"])
# 난간 (수평 바)
rail_t = 0.08
for rz in [z0 + p.parapet_height * 0.7, z0 + p.parapet_height * 0.4]:
self._add_box(-L/2, L/2, y_front, y_front + rail_t, rz, rz + rail_t, COLORS["rail"])
# --- 수축이음 (표면 세로선) ---
def _build_contraction_joints(self):
p = self.p
if not p.has_contraction_joints:
return
L = p.total_length
z_bot = p.bottom_el + p.base_slab_thickness
z_top = p.top_el
spacing = p.joint_spacing
n = max(int(L / spacing), 1)
# 전면(Y-)에 얇은 세로 띠 (시각적 이음)
joint_width = 0.05
y_front = -p.avg_bottom_width / 2 - 0.01 # 전면 살짝 앞
depth = 0.08
for i in range(1, n):
x = -L/2 + i * (L / n)
self._add_box(
x - joint_width / 2, x + joint_width / 2,
y_front, y_front + depth,
z_bot, z_top,
COLORS["joint"],
)
# --- 배수공 (전면 작은 구멍) ---
def _build_weep_holes(self):
p = self.p
if not p.has_weep_holes:
return
L = p.total_length
z_row = p.bottom_el + p.base_slab_thickness + 1.0 # 바닥 약간 위 한 줄
spacing = p.weep_hole_spacing
n = max(int(L / spacing), 1)
y_front = -p.avg_bottom_width / 2 - 0.05
r = p.weep_hole_diameter / 2
for i in range(n):
x = -L/2 + (i + 0.5) * (L / n)
try:
hole = pv.Cylinder(
center=(x, y_front, z_row),
direction=(0, 1, 0),
radius=r,
height=0.2,
resolution=12,
).extract_surface()
self.meshes.append((hole, COLORS["weep"], 1.0))
except Exception:
continue
# --- 전면 지반 ---
def _build_ground(self):
p = self.p
L = p.total_length
# 전면 지반 (앞쪽으로 15m)
y_start = -p.avg_bottom_width / 2 - 15
y_end = -p.avg_bottom_width / 2
z_ground = p.ground_level
pts = np.array([
[-L/2 - 5, y_start, z_ground],
[L/2 + 5, y_start, z_ground],
[L/2 + 5, y_end, z_ground],
[-L/2 - 5, y_end, z_ground],
])
self.meshes.append((pv.PolyData(pts, np.array([4, 0, 1, 2, 3])),
COLORS["ground"], 1.0))
# 기초암반 (벽 아래에서 전면으로 노출)
rock_depth = 3.0
rock_y_start = -p.avg_bottom_width / 2 - 3
rock_y_end = -p.avg_bottom_width / 2 - 0.5
z_rock_top = p.bottom_el
z_rock_bot = p.bottom_el - rock_depth
self._add_box(
-L/2 - 3, L/2 + 3,
rock_y_start, rock_y_end,
z_rock_bot, z_rock_top,
COLORS["rock"],
)
# --- 헬퍼 ---
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 build_retaining_wall_meshes(params: RetainingWallParams):
return RetainingWallBuilder(params).build_all()
if __name__ == "__main__":
from retaining_wall_parser import parse_retaining_wall
paths = ["SAMPLE_CAD/1. 좌안옹벽 일반도 작성(2026.0109).dxf"]
p = parse_retaining_wall(paths)
print(p.summary())
meshes = RetainingWallBuilder(p).build_all()
print(f"\n{len(meshes)}개 구성요소 생성")