feat(횡단): 절토 사면에 소단(계단) 기하를 넣음 — 파이썬·TS 짝 (계획서 3-9)

사용자 확정(2026-09-07)에 따라 소단을 **자동 적용하지 않고 사용자가 놓는 것**으로 만듦.
이 커밋은 그 기하 한 벌이고, 화면 폼은 다음 단계임.

새 짝 모듈 `common_util_cross_berm.py` · `.ts` — 절토 사면 꼭짓점을 만듦.
암 경계 무릎과 소단이 한 목록에 함께 들어가고, `breakpoints` 가 그 꼭짓점을 설계선에
실어 도면·면적·유토곡선·3D 가 계단을 그대로 봄.

기본값 폭 0.5m · 간격(사면길이) 3.0m · 안쪽 기울기 2°.
· 폭·간격은 별표2 범위(사면길이 2~3m마다 · 폭 50~100㎝) 안에서 가장 적게 파는 조합임.
  기본값은 되돌리기 쉬운 쪽이어야 함 — 더 넣는 것은 폼에서 한 번이지만 이미 판 것을
  되돌리면 전 측점을 다시 계산해야 함. 실효 경사로도 그러함(경사 1:1 기준)
  폭 0.5·간격 3 → 1:1.24 / 폭 1.0·간격 3 → 1:1.47 / 폭 1.0·간격 2 → 1:1.71.
· 기울기 2°는 사용자 확정값이고 법령·교본 근거가 없어 지식DB 에 적지 않음(사용자 지시).

무릎은 종전대로 **한 번만** 꺾음. 여러 번 꺾게 풀면 소단이 없는 지금 측점 설계도 같이
바뀌므로 별건으로 미룸(실측: 물결 경계에서 0.0016m 차이, 경계를 다시 만나면 구조가 달라짐).

자체검증
- 거울 시험 10건 `tmp/tests/test_cross_berm_mirror.py` — 소단 켠 5경우 포함해 파이썬·TS
  꼭짓점이 1e-9 안에서 일치. 간격이 수평이 아니라 사면길이 기준인 것, 평탄부가 2° 기운 것,
  소단이 없으면 옛 무릎 방식과 완전히 같은 것까지 확인.
- 면적 시험 6건 `tmp/tests/test_cross_berm_area.py` — 절토 55.115 → 78.785㎡(기본값),
  폭·간격이 물량에 단조로 반영, 소단 모서리가 설계선에 실림, 소단을 안 주면 설계선까지 동일.
- ⚠ 어림식 두 번을 시험이 잡아냄. 「늘어난 면적 = 폭 × 그 위 높이」는 틀림(11.797 vs
  7.770㎡) — 설계선이 밀리면 지반과 만나는 점도 함께 밀려 절토가 더 길어짐. 「폭만큼 밀림」도
  틀림 — 참값은 「그 아래 소단 개수 × 폭」임. 시험은 그 참값으로 잼.
- 기존 거울 시험(`test_b06_cross_design_mirror.py`) 10건 그대로 통과 — 리팩터가 값을
  안 바꿨다는 증거임. 전체 537 passed · 18 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-07 14:54:18 +09:00
co-authored by Claude Opus 5
parent a086b3c124
commit e43ad18f97
4 changed files with 373 additions and 91 deletions
+35 -47
View File
@@ -34,6 +34,11 @@ from B06_Section.B06_Section_Engine_Areas import (
_split_cut_areas,
_trapezoid_areas,
)
from common_util.common_util_cross_berm import (
BermSpec,
cut_profile_points,
)
from common_util.common_util_cross_berm import elevation_at as berm_elevation_at
from config.config_system import (
CURVE_WIDENING_MAX_WIDTH_M,
SECTION_DITCH_SIDES,
@@ -164,6 +169,7 @@ class _SectionGeometry:
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 사용자 확정: 곡선 바깥쪽).
@@ -186,7 +192,9 @@ class _SectionGeometry:
)
self._ground_at = ground_at
self._rock_offset = rock_boundary_offset_m or 0.0
self._rock_knee: dict[str, tuple[float, float] | None] = {}
# 소단 제원(없으면 None) — 절토 사면 꼭짓점 셈에 그대로 넘어간다.
self.berm = berm
self._cut_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] = {}
@@ -275,52 +283,25 @@ class _SectionGeometry:
assert self._ground_at is not None # two_stage일 때만 호출
return self._ground_at(signed) + self._rock_offset
def knee(self, side: str) -> tuple[float, float] | None:
"""절토 사면이 암반 경계선을 지나는 전환점(무릎 거리, 표고)을 구한다(측별 캐시).
def cut_points(self, side: str) -> list[tuple[float, float]]:
"""절토 사면 꼭짓점 `[(거리, 표고), ...]` — 무릎과 소단이 모두 여기 들어 있다.
노면 끝(사면 시작)에서 암 경사(cut_ratio)로 올라가며 경계선을 만나면 그 지점부터
토사 경사로 완만해진다. 시작부터 경계 위면 무릎=시작(전부 토사), 끝까지 못 만나면
None(전부 암). 경계선은 지반을 따라 변하므로 세밀 행진으로 교차점을 찾는다.
셈은 짝 모듈 `common_util_cross_berm` 한 벌이 한다(TS 도 같은 것을 부른다).
소단이 없으면 종전 무릎 방식과 **같은 값**이다(동치 시험으로 지킨다).
"""
if not self.two_stage:
return None
if side in self._rock_knee:
return self._rock_knee[side]
if side in self._cut_points_cache:
return self._cut_points_cache[side]
start_dist, start_z = self._slope_start(side)
diff_prev = start_z - self._rock_boundary_z(side, start_dist)
result: tuple[float, float] | None
if diff_prev >= 0:
result = (start_dist, start_z) # 시작부터 토사(경계 위)
else:
result = None
step = 0.05
dist_prev = start_dist
dist = start_dist + step
while dist <= start_dist + 200.0:
z_rock = start_z + (dist - start_dist) / self.cut_ratio
diff = z_rock - self._rock_boundary_z(side, dist)
if diff >= 0:
span = diff - diff_prev
ratio = (-diff_prev) / span if abs(span) > 1e-9 else 0.0
knee_dist = dist_prev + (dist - dist_prev) * ratio
knee_z = start_z + (knee_dist - start_dist) / self.cut_ratio
result = (knee_dist, knee_z)
break
dist_prev, diff_prev = dist, diff
dist += step
self._rock_knee[side] = result
return result
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
)
self._cut_points_cache[side] = points
return points
def _cut_slope_z(self, side: str, dist: float) -> float:
"""절토 사면선 표고(2단계 무릎 반영). 지반 교차 클램프는 하지 않는다."""
start_dist, start_z = self._slope_start(side)
knee = self.knee(side) if self.two_stage else None
if knee is not None:
knee_dist, knee_z = knee
if dist <= knee_dist: # 암반 구간(경계 아래): 암 경사
return start_z + (dist - start_dist) / self.cut_ratio
return knee_z + (dist - knee_dist) / self.soil_cut_ratio # 토사 구간: 완만
return start_z + (dist - start_dist) / self.cut_ratio
"""절토 사면선 표고(무릎·소단 반영). 지반 교차 클램프는 하지 않는다."""
return berm_elevation_at(self.cut_points(side), dist)
def cut_cross_dist(self, side: str) -> float | None:
"""절토 사면이 지반선과 처음 만나는 거리(절대 오프셋). 이후는 절토 없음(N-2-4).
@@ -439,15 +420,20 @@ class _SectionGeometry:
return max(fill_line, ground_m)
def breakpoints(self) -> list[float]:
"""적분·설계선에 반드시 포함할 설계 꼭짓점 오프셋 목록(2단계 무릎 포함)."""
"""적분·설계선에 반드시 포함할 설계 꼭짓점 오프셋 목록(2단계 무릎·소단 포함)."""
points = [0.0, self.left_extent, -self.right_extent]
points.extend(offset for offset, _z in self.ditch_points)
if self.two_stage:
# 절토 사면 꼭짓점(무릎·소단 모서리) — 빠뜨리면 계단이 설계선에 안 실린다.
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
knee = self.knee(side) if role == "cut" else None
if knee is not None:
points.append(knee[0] if side == "left" else -knee[0])
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)
# 절·성토 사면과 지반의 **첫** 교차점을 꼭짓점에 넣어 면적 절단을 정확히 한다(N-2-4).
for side in ("left", "right"):
role = self.left_role if side == "left" else self.right_role
@@ -490,6 +476,7 @@ def compute_cross_design(
plan_radius_m: float | None = None,
curve_outer_side: str | None = None,
curve_widening_m: float | None = None,
berm: BermSpec | None = None,
) -> dict[str, Any]:
"""측점 하나의 표준횡단 설계선과 절·성토 단면적을 계산한다.
@@ -577,6 +564,7 @@ def compute_cross_design(
ditch_enabled=ditch_enabled,
widening_left_m=widening_left,
widening_right_m=widening_right,
berm=berm,
)
# 적분 오프셋 = 지반 샘플 ∪ 설계 꼭짓점(샘플 범위 안쪽만). 꼭짓점을 넣어야