"""옹벽 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)}개 구성요소 생성")