Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
245 lines
12 KiB
Python
245 lines
12 KiB
Python
"""절토 사면의 **계단(소단) 포함 꼭짓점**을 만든다 — 파이썬·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)
|