feat(횡단): 소단을 미리보기·재계산에 실어 화면에 계단이 서게 함 (계획서 3-9)

계단이 값에만 있던 것을 화면까지 연결함.

배선 — 세션 열쇠 `berm`(측점키 → 폭·간격·기울기)을 등록표에 두고,
`readBermSession` 으로 읽어 ① 브라우저 재계산(`refreshCrossDesigns`)과
② 서버 미리보기(`cross-design/preview` 의 `berms`) 양쪽에 실음.
암 경계선 오프셋과 같은 길이라 「계획선을 고치면 계단이 사라지는」 일이 없음.

실화면 확인(8001·5174, `/api/health` `stale:false`) — 측점 4120.0m 에 소단을 놓고
계획고를 한 칸 올렸다 내려 전 구간 재계산을 태움.
· 폭 0.5m · 간격 3.0m → 절토 6.84 → 8.10㎡ (토사 2.42→3.24 · 암 4.42→4.86)
· 폭 1.0m · 간격 2.0m → 절토 6.84 → 12.96㎡, 횡단도에 **계단이 눈으로 보임**
· 소단을 안 놓은 옆 측점(4100.0m)은 3.77㎡ 그대로 — 놓은 곳만 달라짐
· 되돌린 뒤 6.84㎡ 로 복귀. [저장]·[확정] 안 눌렀으므로 정본은 그대로.

`cut_slope_segments` 신설 — 절토 사면을 경사 구간별로 쪼갠 목록(파이썬·TS 짝).
법정 경사 검사가 읽을 값임(다른 창 요청). 소단이 서면 사면 전체를 하나로 재는
「실효 경사」가 완만해져 **위반이 사라진 것처럼** 보이므로(폭 1.0·간격 2 이면 설계 1:1 이
실효 1:1.71), 검사는 소단을 뺀 구간 자체를 봐야 함. 실측 — 소단을 놓아도 구간별 경사비는
1.0 그대로 나옴. 평탄부(소단)와 지반 만난 뒤 구간은 싣지 않음.

자체검증 — 거울 시험에 사면 구간 대조를 더해 파이썬·TS 일치 확인.
전체 539 passed · 18 skipped. TS 타입 검사 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-07 15:28:16 +09:00
co-authored by Claude Opus 5
parent 5a6617f278
commit 061d550e55
11 changed files with 203 additions and 4 deletions
+61
View File
@@ -27,6 +27,7 @@
경사비는 수평:수직 = ratio:1 (예: 1:1.2 → ratio=1.2).
"""
import math
from collections.abc import Callable
from typing import Any
@@ -312,6 +313,64 @@ class _SectionGeometry:
"""절토 사면선 표고(무릎·소단 반영). 지반 교차 클램프는 하지 않는다."""
return berm_elevation_at(self.cut_points(side), dist)
def cut_slope_segments(self) -> list[dict[str, Any]]:
"""절토 사면을 **경사 구간별로** 쪼갠 목록 — 법정 경사 검사가 읽는 값이다.
왜 필요한가 — 소단이 서면 사면 전체를 하나로 재는 「실효 경사」가 완만해져
**위반이 사라진 것처럼** 보인다(폭 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
material: str | None = None
if self.two_stage:
middle_d = (start_d + end_d) / 2.0
middle_z = (start_z + end_z) / 2.0
material = (
"soil" if middle_z >= self._rock_boundary_z(side, middle_d) 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).
@@ -715,6 +774,8 @@ def compute_cross_design(
),
"ditch_area_m2": round(ditch_area, 4),
"design_line": design_line,
# 절토 사면을 경사 구간별로 쪼갠 목록 — 법정 경사 검사가 읽는다(소단 제외).
"cut_slope_segments": geometry.cut_slope_segments(),
}
if drop > 0:
# 내려 앉힌 양 — 프론트가 "월류가 없었다면" 노면을 점선으로 되그리는 데 쓴다.