380 lines
12 KiB
Python
380 lines
12 KiB
Python
"""옹벽 3D 파라메트릭 빌더.
|
||
|
||
구성요소:
|
||
1. 본체 (사다리꼴 단면을 길이방향으로 sweep)
|
||
2. 기초 slab (하부 넓은 base)
|
||
3. 뒤채움 지형 (배면 토사)
|
||
4. 배면 앵커바 × N (격자 배치)
|
||
5. 상단 안전난간 (parapet)
|
||
6. 수축이음 세로선 (표면에 시각화)
|
||
7. 배수공 (weep hole)
|
||
8. 전면 지반 + 바위
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
from typing import Optional
|
||
|
||
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
|
||
|
||
H = z_top - z_bot
|
||
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: 전면이 안쪽으로 기움
|
||
|
||
batter_shift = p.front_batter_ratio * H # 전면이 상단에서 안쪽으로 이 만큼 이동
|
||
|
||
# 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
|
||
z_bot = p.bottom_el + p.base_slab_thickness
|
||
|
||
# 배면에서 뒤로 뻗은 토사 (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
|
||
H = p.total_height()
|
||
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
|
||
rail_spacing = 0.4
|
||
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)}개 구성요소 생성")
|