사용자 확정(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>
158 lines
7.3 KiB
Python
158 lines
7.3 KiB
Python
"""절토 사면의 **계단(소단) 포함 꼭짓점**을 만든다 — 파이썬·TS 짝 (계획서 3-9).
|
|
|
|
짝: `common_util/common_util_cross_berm.ts`. 두 파일은 같은 값을 내야 하며
|
|
`tmp/tests/test_cross_berm_mirror.py` 가 그것을 지킨다.
|
|
|
|
**왜 따로 뺐나** — 소단이 들어가면 절토선이 「하나의 경사」가 아니라 **계단**이 된다.
|
|
지금 코드는 암 경계 무릎을 **하나만** 전제하는데(경계를 한 번 지나면 끝), 사용자가 소단을
|
|
겹쳐 놓을 수 있으므로 경계를 **여러 번** 오갈 수 있다. 그래서 무릎을 미리 한 번 구하는 대신
|
|
**바깥으로 걸어가며 그때그때 경사를 고르는** 방식으로 바꿨다. 소단이 없으면 종전과 같은
|
|
값이 나온다(거울 시험이 그것도 지킨다).
|
|
|
|
**소단 기본값** — 폭 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**.
|
|
넓고 촘촘하면 설계 1:1 이 실제로는 1:1.7 로 서서 다른 비탈이 된다.
|
|
· 기울기 2°는 **2026-09-07 사용자 확정**이다 — 물이 고이지 않게 안쪽으로 기울이는 실무이고
|
|
**법령·교본 근거가 없다**. 그래서 지식DB 에는 적지 않는다(사용자 지시).
|
|
"""
|
|
|
|
import math
|
|
from typing import Callable, NamedTuple
|
|
|
|
# 소단 기본값 — 근거는 위 모듈 설명.
|
|
BERM_DEFAULT_WIDTH_M = 0.5
|
|
BERM_DEFAULT_INTERVAL_M = 3.0
|
|
BERM_DEFAULT_SLOPE_DEG = 2.0
|
|
|
|
# 사면을 따라 걸어가는 보폭(m)과 최대 거리 — 무릎 탐색이 쓰던 값과 같다.
|
|
_STEP_M = 0.05
|
|
_MAX_REACH_M = 200.0
|
|
|
|
|
|
class BermSpec(NamedTuple):
|
|
"""소단 제원 — 폭(m) · 간격(사면길이 m) · 안쪽 기울기(도)."""
|
|
|
|
width_m: float = BERM_DEFAULT_WIDTH_M
|
|
interval_m: float = BERM_DEFAULT_INTERVAL_M
|
|
slope_deg: float = BERM_DEFAULT_SLOPE_DEG
|
|
|
|
|
|
def cut_profile_points(
|
|
start_dist: float,
|
|
start_z: float,
|
|
cut_ratio: float,
|
|
soil_cut_ratio: float,
|
|
rock_boundary_z: Callable[[float], float] | None,
|
|
berm: BermSpec | None,
|
|
max_reach_m: float = _MAX_REACH_M,
|
|
) -> list[tuple[float, float]]:
|
|
"""절토 사면 꼭짓점 `[(거리, 표고), ...]` — 사면 시작에서 바깥으로.
|
|
|
|
`rock_boundary_z` 가 None 이면 2단계 절토가 아니므로 경사는 `cut_ratio` 하나다.
|
|
있으면 걸어가며 경계를 만나는 자리에서 암(`cut_ratio`) → 토사(`soil_cut_ratio`)로 **한 번**
|
|
꺾는다.
|
|
|
|
⚠ **한 번만 꺾는 것은 기존 규칙을 그대로 지킨 것이다.** 소단이 들어가면 사면이 경계를 여러
|
|
번 오갈 수 있어 「무릎은 하나」 전제를 풀 수 있으나, 풀면 **소단이 없는 지금 측점들의 설계도
|
|
같이 바뀐다**(2026-09-07 실측: 물결 경계에서 0.0016m, 경계를 다시 만나는 자리에서는 구조가
|
|
통째로 달라짐). 소단을 넣는 김에 기존 설계를 조용히 바꿀 수는 없으므로 **별건으로 미룬다**.
|
|
|
|
`berm` 이 있으면 사면길이가 `interval_m` 에 닿을 때마다 폭 `width_m` 의 평탄부를 넣는다.
|
|
평탄부는 안쪽이 낮도록 `slope_deg` 만큼 기울어 있어 바깥으로 갈수록 조금 올라간다
|
|
(물이 노면 쪽으로 흐르게 — 소단측구를 놓는 자리다).
|
|
|
|
꼭짓점만 돌려준다 — 경사가 바뀌는 점과 소단 모서리뿐이라 사이는 직선이다.
|
|
"""
|
|
points: list[tuple[float, float]] = [(start_dist, start_z)]
|
|
dist, elevation = start_dist, start_z
|
|
slant_since_berm = 0.0
|
|
limit = start_dist + max_reach_m
|
|
berm_rise = (
|
|
math.tan(math.radians(berm.slope_deg)) * berm.width_m
|
|
if berm is not None and berm.width_m > 0
|
|
else 0.0
|
|
)
|
|
# 시작부터 경계 위면 처음부터 토사다(기존 `knee` 의 첫 판정과 같다).
|
|
in_soil = rock_boundary_z is None or elevation >= rock_boundary_z(dist)
|
|
ratio = soil_cut_ratio if (rock_boundary_z is not None and in_soil) else cut_ratio
|
|
|
|
while dist < limit:
|
|
rise = _STEP_M / ratio
|
|
slant = math.hypot(_STEP_M, rise)
|
|
|
|
# ① 소단 자리가 먼저 오나 — 남은 사면길이만큼만 올라가 정확히 맞춘다.
|
|
if berm is not None and berm.interval_m > 0 and slant_since_berm + slant >= berm.interval_m:
|
|
remain = max(berm.interval_m - slant_since_berm, 0.0)
|
|
run = remain / math.hypot(1.0, 1.0 / ratio)
|
|
dist += run
|
|
elevation += run / ratio
|
|
points.append((dist, elevation)) # 소단 안쪽 모서리
|
|
dist += berm.width_m
|
|
elevation += berm_rise
|
|
points.append((dist, elevation)) # 소단 바깥 모서리
|
|
slant_since_berm = 0.0
|
|
continue
|
|
|
|
next_dist = dist + _STEP_M
|
|
next_z = elevation + rise
|
|
|
|
# ② 암 → 토사 전환(무릎) — 한 번만. 교차점은 보간해 정확히 찍는다.
|
|
if rock_boundary_z is not None and not in_soil:
|
|
diff_now = elevation - rock_boundary_z(dist)
|
|
diff_next = next_z - rock_boundary_z(next_dist)
|
|
if diff_next >= 0:
|
|
span = diff_next - diff_now
|
|
share = (-diff_now) / span if abs(span) > 1e-12 else 0.0
|
|
share = min(max(share, 0.0), 1.0)
|
|
knee_dist = dist + _STEP_M * share
|
|
knee_z = elevation + rise * share
|
|
slant_since_berm += math.hypot(knee_dist - dist, knee_z - elevation)
|
|
dist, elevation = knee_dist, knee_z
|
|
points.append((dist, elevation)) # 무릎
|
|
in_soil = True
|
|
ratio = soil_cut_ratio
|
|
continue
|
|
|
|
dist, elevation = next_dist, next_z
|
|
slant_since_berm += slant
|
|
|
|
points.append((dist, elevation))
|
|
return _dedupe(points)
|
|
|
|
|
|
def _dedupe(points: list[tuple[float, float]]) -> list[tuple[float, float]]:
|
|
"""같은 자리 꼭짓점을 지운다 — 보간이 0 나눗셈을 만나지 않게."""
|
|
out: list[tuple[float, float]] = []
|
|
for point in points:
|
|
if out and abs(point[0] - out[-1][0]) < 1e-9 and abs(point[1] - out[-1][1]) < 1e-9:
|
|
continue
|
|
out.append(point)
|
|
return out
|
|
|
|
|
|
def elevation_at(points: list[tuple[float, float]], dist: float) -> float:
|
|
"""꼭짓점 목록에서 거리 하나의 표고 — 사이는 직선 보간, 끝은 마지막 경사 연장."""
|
|
if not points:
|
|
return 0.0
|
|
if dist <= points[0][0]:
|
|
return points[0][1]
|
|
for index in range(1, len(points)):
|
|
x0, z0 = points[index - 1]
|
|
x1, z1 = points[index]
|
|
if dist > x1 + 1e-12:
|
|
continue
|
|
span = x1 - x0
|
|
if span <= 1e-12:
|
|
return z1
|
|
return z0 + (z1 - z0) * ((dist - x0) / span)
|
|
# 끝을 넘어가면 마지막 두 점의 기울기로 잇는다.
|
|
x0, z0 = points[-2] if len(points) > 1 else points[-1]
|
|
x1, z1 = points[-1]
|
|
span = x1 - x0
|
|
if span <= 1e-12:
|
|
return z1
|
|
return z1 + (z1 - z0) / span * (dist - x1)
|