diff --git a/B06_Section/B06_Section_Engine_Design.py b/B06_Section/B06_Section_Engine_Design.py index 9680168c..849bfe3a 100644 --- a/B06_Section/B06_Section_Engine_Design.py +++ b/B06_Section/B06_Section_Engine_Design.py @@ -27,8 +27,6 @@ 경사비는 수평:수직 = ratio:1 (예: 1:1.2 → ratio=1.2). """ -import math -from collections.abc import Callable from typing import Any from B06_Section.B06_Section_Engine_Areas import ( @@ -38,12 +36,12 @@ from B06_Section.B06_Section_Engine_Areas import ( _split_ditch_area, _trapezoid_areas, ) +from B06_Section.B06_Section_Engine_Design_Geometry import ( + _SectionGeometry, +) 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 from common_util.common_util_spoil_fill import spoil_fill_section from config.config_system import ( CURVE_WIDENING_MAX_WIDTH_M, @@ -61,19 +59,6 @@ from config.config_system import ( _SLOPE_CLOSE_TOLERANCE_M = 0.01 -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}") - - def _resolve_ditch_side(section_mode: str, ditch_side: str | None) -> str: """측구(배수) 배치 측을 결정한다. @@ -156,398 +141,6 @@ def _ground_interpolator(valid: list[tuple[float, float]]): return ground_at -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 - - def curve_widening_args(section: dict[str, Any] | None) -> dict[str, Any]: """측점 기록에서 곡선부 확폭 입력을 뽑는다 — `compute_cross_design(**...)` 로 넘긴다. diff --git a/B06_Section/B06_Section_Engine_Design_Geometry.py b/B06_Section/B06_Section_Engine_Design_Geometry.py new file mode 100644 index 00000000..9caba13f --- /dev/null +++ b/B06_Section/B06_Section_Engine_Design_Geometry.py @@ -0,0 +1,430 @@ +"""횡단 설계선의 **기하** — 노면 → 측구 → 사면 순으로 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