"""횡단 설계선의 **기하** — 노면 → 측구 → 사면 순으로 offset 의 설계고를 낸다. ⚠⚠ **TS 짝과 같은 선에서 갈랐다** — `common_util/common_util_cross_design_geometry.ts`. TS 는 2026-09-04 에 이미 이 선으로 떨어져 있었고, 파이썬만 한 파일에 붙어 있어 924줄이 됐다(700줄 제한 초과). **새로 긋는 선이 아니라 TS 에 있던 선을 이쪽에도 그은 것** 이라 거울 시험(`tmp/tests/test_b06_cross_design_mirror.py`)이 그대로 돈다. 같이 옮긴 것 — `_side_role`(TS `sideRole`). 클래스만 쓰는 도우미다. 안 옮긴 것 — 측구 방향 해석·지반 보간·사면 폐합 허용오차는 TS 도 본체에 두었다. ⚠ 계산은 **한 줄도 바꾸지 않았다.** 옮기기만 한 것이라, 값이 달라지면 옮기다 흘린 것이다. """ from __future__ import annotations import math from collections.abc import Callable from typing import Any from common_util.common_util_cross_berm import ( BermSpec, cut_profile_points, fill_profile_points, ) from common_util.common_util_cross_berm import elevation_at as berm_elevation_at def _side_role(section_mode: str) -> tuple[str, str]: """단면유형 → (좌측 역할, 우측 역할). 역할은 'cut' 또는 'fill'.""" if section_mode == "left_cut": return "cut", "fill" if section_mode == "right_cut": return "fill", "cut" if section_mode == "both_cut": return "cut", "cut" if section_mode == "both_fill": return "fill", "fill" raise ValueError(f"지원하지 않는 단면유형입니다: {section_mode}") class _SectionGeometry: """설계선 피스와이즈 평가기. 노면 → 측구 → 사면 순으로 offset의 설계고를 계산한다.""" def __init__( self, *, design_elevation_m: float, group: dict[str, float], section_mode: str, ditch_side: str, ditch_type: str, cross_slope_pct: float, ground_at: Callable[[float], float] | None = None, soil_cut_ratio: float | None = None, rock_boundary_offset_m: float | None = None, two_stage_slope: bool = False, ditch_enabled: bool | None = None, widening_left_m: float = 0.0, widening_right_m: float = 0.0, berm: BermSpec | None = None, ) -> None: half_road = group["road_width_m"] / 2.0 # 곡선부 확폭은 **한쪽으로만** 붙는다(2026-09-06 사용자 확정: 곡선 바깥쪽). # 그래서 반폭을 좌·우로 나눠 든다 — 확폭이 0이면 예전과 똑같은 대칭 단면이다. self.half_road_left = half_road + max(widening_left_m, 0.0) self.half_road_right = half_road + max(widening_right_m, 0.0) self.half_road = half_road # 규격 차도 반폭(확폭 전) — 수량·표기 기준 self.left_extent = self.half_road_left + group["shoulder_left_m"] # 좌(+) 노면 끝 self.right_extent = self.half_road_right + group["shoulder_right_m"] # 우(-) 노면 끝 self.z_center = design_elevation_m self.cut_ratio = max(group["cut_slope_ratio"], 1e-6) # 암 구간(하단) 절토 경사 self.fill_ratio = max(group["fill_slope_ratio"], 1e-6) self.left_role, self.right_role = _side_role(section_mode) self.ditch_side = ditch_side # 2단계 절토: 암반 경계선(지반선 + rock_boundary_offset) 아래는 암 경사(cut_ratio), # 위는 토사 경사(soil_cut_ratio)를 쓴다. 경계 아래→위 전환점(무릎)을 측별로 미리 구한다. self.soil_cut_ratio = max(soil_cut_ratio or group["cut_slope_ratio"], 1e-6) self.two_stage = bool( two_stage_slope and ground_at is not None and rock_boundary_offset_m is not None ) self._ground_at = ground_at self._rock_offset = rock_boundary_offset_m or 0.0 # 소단 제원(없으면 None) — 절토 사면 꼭짓점 셈에 그대로 넘어간다. self.berm = berm self._cut_points_cache: dict[str, list[tuple[float, float]]] = {} self._fill_points_cache: dict[str, list[tuple[float, float]]] = {} # 절토 사면·지반 최초 교차거리(측별 캐시) — 교차 후 절토 종료용(N-2-4). self._cut_cross: dict[str, float | None] = {} self._fill_cross: dict[str, float | None] = {} self.ditch_type = ditch_type # 횡단경사: 측구 방향으로 내려가는 단일 사면 (좌=+offset 규약). slope = cross_slope_pct / 100.0 self.slope_per_offset = -slope if ditch_side == "left" else slope # 단면 유형 자동 판정(D-2): 각 측 절/성토 역할을 노면 끝 지반이 설계면보다 # 높은지(절토)/낮은지(성토)로 결정한다. 좌절/우절/양절/양성이 모두 지형에서 # 자연 도출된다. 사용자 입력 section_mode는 측구 방향(ditch_side) 기본값에만 쓰고 # 절/성토 역할은 손대지 않는다. ground_at이 없으면 section_mode 기반 역할을 쓴다. if ground_at is not None: self.left_role = ( "cut" if ground_at(self.left_extent) > self.road_z(self.left_extent) + 1e-3 else "fill" ) self.right_role = ( "cut" if ground_at(-self.right_extent) > self.road_z(-self.right_extent) + 1e-3 else "fill" ) # 측구 생성 여부(D-1): 양성은 항상 미생성. ditch_enabled가 오면 그 값을 따르고(수동 # override), None이면 자동 판정 — 측구측 노면 끝에서 지반이 설계면보다 높으면(절토 # 상황) 생성, 낮으면(성토 상황, 자연 배수) 미생성. ground_at 없으면 보수적으로 생성. if section_mode == "both_fill": self.has_ditch = False elif ditch_enabled is not None: self.has_ditch = ditch_enabled elif ground_at is not None: ditch_edge = self.left_extent if ditch_side == "left" else -self.right_extent self.has_ditch = ground_at(ditch_edge) > self.road_z(ditch_edge) + 1e-3 else: self.has_ditch = True # 측구 꼭짓점(측구측 노면 끝 기준, 바깥 방향 부호 적용). self.ditch_points: list[tuple[float, float]] = [] edge_offset = self.left_extent if ditch_side == "left" else -self.right_extent outward = 1.0 if ditch_side == "left" else -1.0 edge_z = self.road_z(edge_offset) if self.has_ditch: if ditch_type == "l_type": # L형: 노면 끝에서 폭 W 동안 깊이 D로 내려가는 경사 바닥 + 바깥 수직벽. # 바깥(노견 반대측) 상단은 **노견과 같은 표고**로 닫는다 — 사면 시작점이 # 측구 바닥 높이로 내려가면 안 된다(2026-08-23 사용자 지시). width = group["l_ditch_width_m"] depth = group["l_ditch_depth_m"] self.ditch_points = [ (edge_offset, edge_z), (edge_offset + outward * width, edge_z - depth), (edge_offset + outward * width, edge_z), ] else: # 일반: 상단폭/저폭/깊이 사다리꼴. top = group["ditch_top_width_m"] bottom = min(group["ditch_bottom_width_m"], top) depth = group["ditch_depth_m"] inset = (top - bottom) / 2.0 self.ditch_points = [ (edge_offset, edge_z), (edge_offset + outward * inset, edge_z - depth), (edge_offset + outward * (inset + bottom), edge_z - depth), (edge_offset + outward * top, edge_z), ] def road_z(self, offset_m: float) -> float: """노면(노견 포함) 설계고 — 중심 계획고에서 횡단경사로 기운 단일 평면.""" return self.z_center + self.slope_per_offset * offset_m def _slope_start(self, side: str) -> tuple[float, float]: """사면 시작점(오프셋 절대값 기준 거리, 표고)을 계산한다.""" if side == "left": edge_offset, edge_z = self.left_extent, self.road_z(self.left_extent) else: edge_offset, edge_z = self.right_extent, self.road_z(-self.right_extent) if side == self.ditch_side and self.ditch_points: outer = self.ditch_points[-1] return abs(outer[0]), outer[1] return edge_offset, edge_z def _rock_boundary_z(self, side: str, dist: float) -> float: """측·거리(절대 오프셋)에서 암반 경계선 표고 = 지반선 + 오프셋(음수=하향).""" signed = dist if side == "left" else -dist assert self._ground_at is not None # two_stage일 때만 호출 return self._ground_at(signed) + self._rock_offset def cut_points(self, side: str) -> list[tuple[float, float]]: """절토 사면 꼭짓점 `[(거리, 표고), ...]` — 무릎과 소단이 모두 여기 들어 있다. 셈은 짝 모듈 `common_util_cross_berm` 한 벌이 한다(TS 도 같은 것을 부른다). 소단이 없으면 종전 무릎 방식과 **같은 값**이다(동치 시험으로 지킨다). """ if side in self._cut_points_cache: return self._cut_points_cache[side] start_dist, start_z = self._slope_start(side) boundary = (lambda dist: self._rock_boundary_z(side, dist)) if self.two_stage else None points = cut_profile_points( start_dist, start_z, self.cut_ratio, self.soil_cut_ratio, boundary, self.berm, # 소단이 있으면 경계를 오갈 때마다 꺾는다 — 소단은 평탄한데 경계선은 지반을 # 따라 올라가서 되돌아 들어가는 일이 흔하다. 한 번만 꺾으면 그 구간을 암인데 # 토사 경사로 그려 절토가 조용히 커진다(2026-09-07). multi_knee=self.berm is not None, ) self._cut_points_cache[side] = points return points def _cut_slope_z(self, side: str, dist: float) -> float: """절토 사면선 표고(무릎·소단 반영). 지반 교차 클램프는 하지 않는다.""" return berm_elevation_at(self.cut_points(side), dist) def fill_points(self, side: str) -> list[tuple[float, float]]: """성토 사면 꼭짓점 — 소단이 들어 있다. 절토와 달리 무릎은 없다.""" if side in self._fill_points_cache: return self._fill_points_cache[side] start_dist, start_z = self._slope_start(side) points = fill_profile_points(start_dist, start_z, self.fill_ratio, self.berm) self._fill_points_cache[side] = points return points def _fill_slope_z(self, side: str, dist: float) -> float: """성토 사면선 표고(소단 반영). 지반 교차 클램프는 하지 않는다.""" return berm_elevation_at(self.fill_points(side), dist) def cut_slope_segments(self) -> list[dict[str, Any]]: """절토 사면을 **경사 구간별로** 쪼갠 목록. ⚠ **지금 이 값을 읽는 곳은 없다**(2026-09-07). 임자였던 별표2 법정 경사 검사가 폐기됐고 (암질을 횡단도에서 안 고르기로 사용자 확정), 저장분에도 안 들어간다. 소단 기하가 이 셈 위에 서 있어 남겨 둔다 — **되살릴 때는 저장분에서 읽지 말고 계산해서 쓸 것.** 원래 필요했던 까닭(되살릴 때 그대로 유효) — 소단이 서면 사면 전체를 하나로 재는 「실효 경사」가 완만해져 **위반이 사라진 것처럼** 보인다(폭 1.0·간격 2 이면 설계 1:1 이 실효 1:1.71). 검사는 소단을 뺀 **사면 구간 자체의 경사**를 봐야 한다. · 평탄부(소단)는 싣지 않는다 — 경사 구간이 아니고 경사비가 무한대가 된다. · 지반과 만난 뒤 구간도 싣지 않는다 — 절토가 아니다. · `material` 은 암반 경계 기준 `rock`/`soil`. 경계를 모르면(2단계 아님) None. 암을 다시 가르는 값은 측점의 `cut_rock_kind` 를 읽는다(구간에 싣지 않는다). """ segments: list[dict[str, Any]] = [] for side in ("left", "right"): role = self.left_role if side == "left" else self.right_role if role != "cut": continue cross = self.cut_cross_dist(side) points = self.cut_points(side) sign = 1.0 if side == "left" else -1.0 for index in range(1, len(points)): start_d, start_z = points[index - 1] end_d, end_z = points[index] if cross is not None and start_d >= cross - 1e-9: break # 지반과 만난 뒤는 절토가 없다 if cross is not None and end_d > cross: # 지반과 만나는 점에서 구간을 자른다. end_z = berm_elevation_at(points, cross) end_d = cross run = end_d - start_d rise = end_z - start_z if run <= 1e-9 or rise <= 1e-6: continue # 길이 0·역방향은 검사 대상이 아니다 if self.berm is not None and abs(run - self.berm.width_m) < 1e-6: # 소단(평탄부) — 폭이 딱 맞고 오름이 기울기(2°)만큼이면 그것이다. berm_rise = math.tan(math.radians(self.berm.slope_deg)) * self.berm.width_m if abs(rise - berm_rise) < 1e-9: continue # 재료는 **그 구간을 실제로 그린 경사비**로 가른다 — 경계선을 다시 재면 # 안 된다. 무릎을 지난 뒤에도 경계선은 지반을 따라 계속 오르므로, 토사 # 경사로 그린 구간이 경계 아래로 되돌아가 있는 일이 흔하다. 그것을 경계로 # 재면 「경사비는 토사인데 재료는 암」인 구간이 생긴다(2026-09-07 다른 창 # 실측: 용화 63측점에서 13구간). 그린 대로 적는 것이 맞다. material: str | None = None if self.two_stage and abs(self.soil_cut_ratio - self.cut_ratio) > 1e-9: drawn = run / rise material = ( "soil" if abs(drawn - self.soil_cut_ratio) < abs(drawn - self.cut_ratio) else "rock" ) segments.append( { "side": side, "ratio": round(run / rise, 4), "rise_m": round(rise, 4), "run_m": round(run, 4), "start_offset_m": round(sign * start_d, 4), "end_offset_m": round(sign * end_d, 4), "material": material, } ) return segments def cut_cross_dist(self, side: str) -> float | None: """절토 사면이 지반선과 처음 만나는 거리(절대 오프셋). 이후는 절토 없음(N-2-4). 지면과 1회 교차하면 그다음 경사(2단계 전환 포함)는 의미가 없으므로 교차점에서 절토를 종료한다. 시작(노면 끝)부터 사면이 지반 위면 교차거리=시작(절토 없음), 끝까지 못 만나면 None. """ if side in self._cut_cross: return self._cut_cross[side] result: float | None = None if self._ground_at is not None: start_dist, _start_z = self._slope_start(side) step = 0.05 dist = start_dist max_dist = start_dist + 500.0 while dist <= max_dist: signed = dist if side == "left" else -dist if self._cut_slope_z(side, dist) - self._ground_at(signed) >= 0: result = dist break dist += step self._cut_cross[side] = result return result def fill_ground_slope(self) -> float | None: """성토측 **자연 지반**의 평균 경사(rise/run, 무차원). 성토측이 없으면 None. 자연방토 판정에 쓴다 — 지반이 가파르면 부어 놓은 흙이 쌓이지 않고 흘러내린다. 구간은 노면 끝(사면 시작)부터 성토 사면이 지반과 처음 만나는 곳까지이며, 끝까지 만나지 못하면 10m를 본다. 양쪽이 다 성토면 **완만한 쪽**을 택한다(보수적 판정). """ if self._ground_at is None: return None slopes: list[float] = [] for side in ("left", "right"): role = self.left_role if side == "left" else self.right_role if role != "fill": continue start_dist, _start_z = self._slope_start(side) end_dist = self.fill_cross_dist(side) or (start_dist + 10.0) run = end_dist - start_dist if run <= 1e-6: continue sign = 1.0 if side == "left" else -1.0 rise = abs(self._ground_at(sign * end_dist) - self._ground_at(sign * start_dist)) slopes.append(rise / run) return min(slopes) if slopes else None def fill_cross_dist(self, side: str) -> float | None: """성토 사면이 지반선과 **처음** 만나는 거리(절대 오프셋). 이후는 성토 없음. 절토(cut_cross_dist)와 같은 규칙이다. 실제 지면은 울퉁불퉁해서 성토 사면이 지반과 여러 번 만날 수 있는데, **첫 교차점이 성토사면의 끝**이고 그 바깥은 손대지 않은 지반이다(2026-08-02 사용자 지시). `max(fill_line, ground)`만 쓰면 지반이 다시 꺼졌을 때 성토 사면이 되살아나 사면이 끊겼다 이어지는 그림이 나온다. 시작(노면 끝)부터 사면이 지반 아래면 교차거리=시작(성토 없음), 끝까지 못 만나면 None. """ if side in self._fill_cross: return self._fill_cross[side] result: float | None = None if self._ground_at is not None: start_dist, start_z = self._slope_start(side) step = 0.05 dist = start_dist max_dist = start_dist + 500.0 while dist <= max_dist: signed = dist if side == "left" else -dist fill_line = self._fill_slope_z(side, dist) if fill_line - self._ground_at(signed) <= 0: result = dist break dist += step self._fill_cross[side] = result return result def design_z(self, offset_m: float, ground_m: float) -> float: """offset 하나의 설계 표고(사면은 지반 교차점 이후 지반 추종).""" side = "left" if offset_m >= 0 else "right" extent = self.left_extent if side == "left" else self.right_extent if abs(offset_m) <= extent + 1e-9: return self.road_z(offset_m) # 측구 구간: 꼭짓점 사이 선형 보간(지반 무관 강제 굴착). if side == self.ditch_side and self.ditch_points: inner = abs(self.ditch_points[0][0]) outer = abs(self.ditch_points[-1][0]) if inner - 1e-9 <= abs(offset_m) <= outer + 1e-9: points = self.ditch_points for index in range(1, len(points)): x0, z0 = abs(points[index - 1][0]), points[index - 1][1] x1, z1 = abs(points[index][0]), points[index][1] if abs(offset_m) > x1 + 1e-9: continue span = x1 - x0 if span <= 1e-9: return z1 ratio = (abs(offset_m) - x0) / span return z0 + (z1 - z0) * ratio return points[-1][1] role = self.left_role if side == "left" else self.right_role dist = abs(offset_m) if role == "cut": # 지반과 1회 교차하면 그 이후 절토는 의미 없음 → 지반 추종(N-2-4). cross = self.cut_cross_dist(side) if cross is not None and dist >= cross: return ground_m return min(self._cut_slope_z(side, dist), ground_m) # 지반과 1회 교차하면 그 바깥은 성토가 아니라 원지반이다(절토와 같은 규칙). cross = self.fill_cross_dist(side) if cross is not None and dist >= cross: return ground_m return max(self._fill_slope_z(side, dist), ground_m) def breakpoints(self) -> list[float]: """적분·설계선에 반드시 포함할 설계 꼭짓점 오프셋 목록(2단계 무릎·소단 포함).""" points = [0.0, self.left_extent, -self.right_extent] points.extend(offset for offset, _z in self.ditch_points) # 절토 사면 꼭짓점(무릎·소단 모서리) — 빠뜨리면 계단이 설계선에 안 실린다. if self.two_stage or self.berm is not None: for side in ("left", "right"): role = self.left_role if side == "left" else self.right_role if role != "cut": continue cross = self.cut_cross_dist(side) for offset, _z in self.cut_points(side): if cross is not None and offset > cross + 1e-9: break # 지반과 만난 뒤는 절토가 없다 points.append(offset if side == "left" else -offset) # 성토 사면 소단 모서리 — 절토와 같은 까닭으로 설계선에 실어야 계단이 그려진다. if self.berm is not None: for side in ("left", "right"): role = self.left_role if side == "left" else self.right_role if role != "fill": continue cross = self.fill_cross_dist(side) for offset, _z in self.fill_points(side): if cross is not None and offset > cross + 1e-9: break points.append(offset if side == "left" else -offset) # 절·성토 사면과 지반의 **첫** 교차점을 꼭짓점에 넣어 면적 절단을 정확히 한다(N-2-4). for side in ("left", "right"): role = self.left_role if side == "left" else self.right_role cross = self.cut_cross_dist(side) if role == "cut" else self.fill_cross_dist(side) if cross is not None: points.append(cross if side == "left" else -cross) return points