Files
Aislo/common_util/common_util_cross_berm.py
T

245 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""절토 사면의 **계단(소단) 포함 꼭짓점**을 만든다 — 파이썬·TS 짝 (계획서 3-9).
짝: `common_util/common_util_cross_berm.ts`. 두 파일은 같은 값을 내야 하며
`resources/tester/test_178_2_berm_fill_mirror.py` 가 그것을 지킨다.
**왜 따로 뺐나** — 소단이 들어가면 절토선이 「하나의 경사」가 아니라 **계단**이 된다.
지금 코드는 암 경계 무릎을 **하나만** 전제하는데(경계를 한 번 지나면 끝), 사용자가 소단을
겹쳐 놓을 수 있으므로 경계를 **여러 번** 오갈 수 있다. 그래서 무릎을 미리 한 번 구하는 대신
**바깥으로 걸어가며 그때그때 경사를 고르는** 방식으로 바꿨다. 소단이 없으면 종전과 같은
값이 나온다(거울 시험이 그것도 지킨다).
**소단 기본값** — 폭 0.5m · 간격(사면길이) 3.0m · 안쪽 기울기 0°.
· 폭·간격은 별표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 로 서서 다른 비탈이 된다.
· 안쪽 기울기 기본값은 **0°**다(2026-09-07 사용자 재확정 — 처음 2° 로 잡았다가 바꿨다).
기울이는 것 자체는 실무이나 **법령·교본 근거가 없어** 기본으로 넣지 않고 폼에서 받는다.
그래서 지식DB 에도 적지 않는다(사용자 지시).
· 성토 소단(178-2) = 노면 끝 아래 깊이 `depth_m`(기본 1.0 · 사용자 값)에 **한 단만** ·
0 미만은 0(도로보다 높을 수 없음). 간격은 성토에 안 씀 · 절토는 소단을 안 넣음.
"""
import logging
import math
from typing import Callable, NamedTuple
logger = logging.getLogger(__name__)
# 소단 기본값 — 근거는 위 모듈 설명.
BERM_DEFAULT_WIDTH_M = 0.5
BERM_DEFAULT_INTERVAL_M = 3.0
BERM_DEFAULT_SLOPE_DEG = 0.0
BERM_DEFAULT_DEPTH_M = 1.0
# 사면을 따라 걸어가는 보폭(m)과 최대 거리 — 무릎 탐색이 쓰던 값과 같다.
_STEP_M = 0.05
_MAX_REACH_M = 200.0
# 걸음 수 상한 — 정상 경로의 최대는 200/0.05 = 4,000 이다. 소단은 걸음 없이 거리를 더하므로
# 여유를 크게 두고 **10배**로 잡는다. 넘으면 조용히 자르지 않고 경고를 남긴다 — 조용히
# 자르면 절토선이 짧아진 채 값이 나가 또 조용히 틀린다(2026-09-07 25 지적).
_MAX_STEPS = int(_MAX_REACH_M / _STEP_M) * 10
class BermSpec(NamedTuple):
"""소단 제원 — 폭(m) · 간격(사면길이 m) · 안쪽 기울기(도) · 깊이(노면 끝 아래 m · 성토)."""
width_m: float = BERM_DEFAULT_WIDTH_M
interval_m: float = BERM_DEFAULT_INTERVAL_M
slope_deg: float = BERM_DEFAULT_SLOPE_DEG
depth_m: float = BERM_DEFAULT_DEPTH_M
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,
multi_knee: bool = False,
) -> list[tuple[float, float]]:
"""절토 사면 꼭짓점 `[(거리, 표고), ...]` — 사면 시작에서 바깥으로.
`rock_boundary_z` 가 None 이면 2단계 절토가 아니므로 경사는 `cut_ratio` 하나다.
있으면 걸어가며 경계를 만나는 자리에서 암(`cut_ratio`) → 토사(`soil_cut_ratio`)로 **한 번**
꺾는다.
`multi_knee` 가 거짓이면 **한 번만** 꺾는다(기존 규칙 그대로). 참이면 경계를 오갈 때마다
꺾는다.
⚠ **소단이 있으면 반드시 참이어야 한다.** 소단은 평탄한데 경계선은 지반을 따라 올라가므로,
폭 0.5m 짜리 소단 하나만 지나도 설계선이 경계 **아래로 되돌아가는 일이 흔하다**(지반이
1:1 이면 경계는 0.5m 오르고 소단은 2°=0.017m 만 오른다). 한 번만 꺾으면 그 구간을 **암인데
토사 경사로** 그려 절토가 조용히 커진다 — 경고도 안 뜬다(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
# ⚠ 제자리 무릎을 막는 자리 — 경계선 기울기가 **암 경사와 토사 경사 사이**면 「토사로
# 바꾸면 경계 아래, 암으로 바꾸면 경계 위」가 되어 같은 자리에서 영원히 뒤집힌다
# (보간 비율 `share` 가 0 이라 한 걸음도 안 나간다). 그러면 화면이 통째로 멈춘다
# (2026-09-07 실사고 — 소단 한 건을 놓자 브라우저가 25분간 안 끝남). 직전 무릎 자리를
# 들고 있다가 **같은 자리면 뒤집지 않고 한 걸음 나아간다**.
last_knee_dist = float("-inf")
steps = 0
while dist < limit:
steps += 1
if steps > _MAX_STEPS:
logger.warning(
"절토 사면 걸음이 상한(%d)을 넘어 멈춥니다 — 거리 %.3fm, 경사비 %.3f. "
"제자리 무릎이 남아 있을 수 있습니다.",
_MAX_STEPS,
dist,
ratio,
)
break
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
# ② 경계를 지나는 자리(무릎) — 교차점을 보간해 정확히 찍고 경사를 바꾼다.
# `multi_knee` 가 거짓이면 암 → 토사 한 번만 본다(기존 규칙).
if rock_boundary_z is not None and (multi_knee or not in_soil):
diff_now = elevation - rock_boundary_z(dist)
diff_next = next_z - rock_boundary_z(next_dist)
crossed = (diff_next >= 0) if not in_soil else (diff_next < 0)
if crossed:
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
# 앞으로 나아가는 무릎만 인정한다(위 ⚠ 참조). 같은 자리면 그냥 한 걸음 간다.
if knee_dist > last_knee_dist + 1e-9:
slant_since_berm += math.hypot(knee_dist - dist, knee_z - elevation)
dist, elevation = knee_dist, knee_z
points.append((dist, elevation)) # 무릎
in_soil = not in_soil
ratio = soil_cut_ratio if in_soil else cut_ratio
last_knee_dist = knee_dist
continue
dist, elevation = next_dist, next_z
slant_since_berm += slant
points.append((dist, elevation))
return _dedupe(points)
def fill_profile_points(
start_dist: float,
start_z: float,
fill_ratio: float,
berm: BermSpec | None,
max_reach_m: float = _MAX_REACH_M,
top_z: float | None = None,
) -> list[tuple[float, float]]:
"""성토 사면 꼭짓점 `[(거리, 표고), ...]` — 사면 시작에서 바깥으로 **내려간다**.
소단은 **한 단**(178-2) — 표고가 `노면 끝(top_z) − depth_m` 에 닿으면 폭 `width_m` 의
평탄부를 넣고(안쪽이 낮도록 `slope_deg` 만큼 기울어 바깥으로 조금 올라감) 그 아래로
같은 성토 경사로 이어 내린다. 그 깊이 전에 원지반을 만나면 지반 교점이 먼저라 안 선다.
"""
points: list[tuple[float, float]] = [(start_dist, start_z)]
dist, elevation = start_dist, start_z
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
)
ratio = max(fill_ratio, 1e-6)
top = start_z if top_z is None else top_z
berm_z = top - max(berm.depth_m, 0.0) if berm is not None and berm.width_m > 0 else None
placed = berm_z is None
while dist < limit:
drop = _STEP_M / ratio
if not placed and berm_z is not None and elevation - drop <= berm_z:
run = max(elevation - berm_z, 0.0) * ratio
dist += run
elevation -= run / ratio
points.append((dist, elevation)) # 소단 안쪽 모서리
dist += berm.width_m
elevation += berm_rise
points.append((dist, elevation)) # 소단 바깥 모서리
placed = True
continue
dist += _STEP_M
elevation -= drop
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)