⚠⚠ 한 칸(`ditch_enabled`)에 **두 뜻**이 담겨 있었음. 결과(실제 섰나)를 그대로 다시 입력으로 넣어 읽었으므로 **한 번 저장되면 자동 판정이 영영 다시 안 돌았음.** 계획고를 내려 절토가 생겨도 측구가 안 서고 **아무 말도 안 나왔음.** - **선택** `ditch_choice` (없음 = 자동) · **결과** `ditch_enabled` (실제 섰나)로 가름 - 자동 판정을 **먼저** 하고 선택을 그 위에 얹음. 양성 단면은 선택보다 기하가 먼저 - ⚠ **옛 저장분은 자동값과 다를 때만 선택으로 살림** — 같으면 자동이 그렇게 냈던 것이고 다르면 사용자가 일부러 바꾼 것임. 설계 의도를 잃지 않으면서 굳은 값을 푼 것 (오늘 `length_m` 에서 겪은 「저장분이 걸린 자리」를 같은 방식으로 처리) - 화면 카드는 이제 **선택**을 읽어 상태를 세우고, 표시는 **결과**를 보임 - 파이썬·TS 짝 양쪽 + 호출부 여섯 곳(재계산·저장·확정·사토장·요청 스키마) 시험 다섯 추가 — 「결과를 다시 넣어도 자동이 계속 돎」이 그 잠금. 전체 616 통과. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
445 lines
24 KiB
Python
445 lines
24 KiB
Python
"""횡단 설계선의 **기하** — 노면 → 측구 → 사면 순으로 offset 의 설계고를 낸다.
|
|
|
|
⚠⚠ **TS 짝과 같은 선에서 갈랐다** — `common_util/common_util_cross_design_geometry.ts`.
|
|
TS 는 2026-09-04 에 이미 이 선으로 떨어져 있었고, 파이썬만 한 파일에 붙어 있어
|
|
924줄이 됐다(700줄 제한 초과). **새로 긋는 선이 아니라 TS 에 있던 선을 이쪽에도 그은 것**
|
|
이라 거울 시험(`tmp/tests/test_b06_cross_design_mirror.py`)이 그대로 돈다.
|
|
|
|
같이 옮긴 것 — `_side_role`(TS `sideRole`). 클래스만 쓰는 도우미다.
|
|
안 옮긴 것 — 측구 방향 해석·지반 보간·사면 폐합 허용오차는 TS 도 본체에 두었다.
|
|
|
|
⚠ 계산은 **한 줄도 바꾸지 않았다.** 옮기기만 한 것이라, 값이 달라지면 옮기다 흘린 것이다.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
from collections.abc import Callable
|
|
from typing import Any
|
|
|
|
from common_util.common_util_cross_berm import (
|
|
BermSpec,
|
|
cut_profile_points,
|
|
fill_profile_points,
|
|
)
|
|
from common_util.common_util_cross_berm import elevation_at as berm_elevation_at
|
|
|
|
|
|
def _side_role(section_mode: str) -> tuple[str, str]:
|
|
"""단면유형 → (좌측 역할, 우측 역할). 역할은 'cut' 또는 'fill'."""
|
|
if section_mode == "left_cut":
|
|
return "cut", "fill"
|
|
if section_mode == "right_cut":
|
|
return "fill", "cut"
|
|
if section_mode == "both_cut":
|
|
return "cut", "cut"
|
|
if section_mode == "both_fill":
|
|
return "fill", "fill"
|
|
raise ValueError(f"지원하지 않는 단면유형입니다: {section_mode}")
|
|
|
|
|
|
class _SectionGeometry:
|
|
"""설계선 피스와이즈 평가기. 노면 → 측구 → 사면 순으로 offset의 설계고를 계산한다."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
design_elevation_m: float,
|
|
group: dict[str, float],
|
|
section_mode: str,
|
|
ditch_side: str,
|
|
ditch_type: str,
|
|
cross_slope_pct: float,
|
|
ground_at: Callable[[float], float] | None = None,
|
|
soil_cut_ratio: float | None = None,
|
|
rock_boundary_offset_m: float | None = None,
|
|
two_stage_slope: bool = False,
|
|
ditch_choice: bool | None = None,
|
|
legacy_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 사용자 확정: 곡선 바깥쪽).
|
|
# 그래서 반폭을 좌·우로 나눠 든다 — 확폭이 0이면 예전과 똑같은 대칭 단면이다.
|
|
self.half_road_left = half_road + max(widening_left_m, 0.0)
|
|
self.half_road_right = half_road + max(widening_right_m, 0.0)
|
|
self.half_road = half_road # 규격 차도 반폭(확폭 전) — 수량·표기 기준
|
|
self.left_extent = self.half_road_left + group["shoulder_left_m"] # 좌(+) 노면 끝
|
|
self.right_extent = self.half_road_right + group["shoulder_right_m"] # 우(-) 노면 끝
|
|
self.z_center = design_elevation_m
|
|
self.cut_ratio = max(group["cut_slope_ratio"], 1e-6) # 암 구간(하단) 절토 경사
|
|
self.fill_ratio = max(group["fill_slope_ratio"], 1e-6)
|
|
self.left_role, self.right_role = _side_role(section_mode)
|
|
self.ditch_side = ditch_side
|
|
# 2단계 절토: 암반 경계선(지반선 + rock_boundary_offset) 아래는 암 경사(cut_ratio),
|
|
# 위는 토사 경사(soil_cut_ratio)를 쓴다. 경계 아래→위 전환점(무릎)을 측별로 미리 구한다.
|
|
self.soil_cut_ratio = max(soil_cut_ratio or group["cut_slope_ratio"], 1e-6)
|
|
self.two_stage = bool(
|
|
two_stage_slope and ground_at is not None and rock_boundary_offset_m is not None
|
|
)
|
|
self._ground_at = ground_at
|
|
self._rock_offset = rock_boundary_offset_m or 0.0
|
|
# 소단 제원(없으면 None) — 절토 사면 꼭짓점 셈에 그대로 넘어간다.
|
|
self.berm = berm
|
|
self._cut_points_cache: dict[str, list[tuple[float, float]]] = {}
|
|
self._fill_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] = {}
|
|
self.ditch_type = ditch_type
|
|
# 횡단경사: 측구 방향으로 내려가는 단일 사면 (좌=+offset 규약).
|
|
slope = cross_slope_pct / 100.0
|
|
self.slope_per_offset = -slope if ditch_side == "left" else slope
|
|
|
|
# 단면 유형 자동 판정(D-2): 각 측 절/성토 역할을 노면 끝 지반이 설계면보다
|
|
# 높은지(절토)/낮은지(성토)로 결정한다. 좌절/우절/양절/양성이 모두 지형에서
|
|
# 자연 도출된다. 사용자 입력 section_mode는 측구 방향(ditch_side) 기본값에만 쓰고
|
|
# 절/성토 역할은 손대지 않는다. ground_at이 없으면 section_mode 기반 역할을 쓴다.
|
|
if ground_at is not None:
|
|
self.left_role = (
|
|
"cut"
|
|
if ground_at(self.left_extent) > self.road_z(self.left_extent) + 1e-3
|
|
else "fill"
|
|
)
|
|
self.right_role = (
|
|
"cut"
|
|
if ground_at(-self.right_extent) > self.road_z(-self.right_extent) + 1e-3
|
|
else "fill"
|
|
)
|
|
|
|
# 측구 생성 여부(D-1) — **자동 판정이 먼저, 사용자 선택이 그 위**(2026-09-09 정리).
|
|
#
|
|
# ⚠⚠ **한 칸에 두 뜻이 담겨 있던 자리다.** 결과(`ditch_enabled` = 실제 생성됨)를
|
|
# 그대로 다시 입력으로 넣어 읽었으므로, **한 번 저장되면 자동 판정이 영영 다시
|
|
# 안 돌았다.** 계획고를 내려 절토가 생겨도 측구가 안 서고 **아무 말도 안 나왔다.**
|
|
# ⇒ 이제 **선택은 `ditch_choice`**(없음 = 자동)이고 **결과는 `ditch_enabled`** 다.
|
|
# ⚠ 옛 저장분(`ditch_choice` 가 없던 것)은 `legacy_ditch_enabled` 로 온다. 그 값은
|
|
# **자동값과 다를 때만 뜻이 있다** — 같으면 자동이 그렇게 냈던 것이고, 다르면
|
|
# 사용자가 일부러 바꾼 것이다. 그래서 **다를 때만** 선택으로 살린다(설계 의도 보존).
|
|
if section_mode == "both_fill":
|
|
auto_ditch = False
|
|
elif ground_at is not None:
|
|
ditch_edge = self.left_extent if ditch_side == "left" else -self.right_extent
|
|
auto_ditch = ground_at(ditch_edge) > self.road_z(ditch_edge) + 1e-3
|
|
else:
|
|
auto_ditch = True # ground_at 이 없으면 보수적으로 생성
|
|
choice = ditch_choice
|
|
if choice is None and legacy_ditch_enabled is not None:
|
|
choice = (
|
|
None if bool(legacy_ditch_enabled) == auto_ditch else bool(legacy_ditch_enabled)
|
|
)
|
|
# 양성(both_fill)은 측구가 설 자리가 없다 — 선택보다 기하가 먼저다.
|
|
self.has_ditch = auto_ditch if (choice is None or section_mode == "both_fill") else choice
|
|
#: 그 측점에 **사용자가 정한 선택**(없으면 자동). 결과와 갈라 내보낸다.
|
|
self.ditch_choice = choice
|
|
|
|
# 측구 꼭짓점(측구측 노면 끝 기준, 바깥 방향 부호 적용).
|
|
self.ditch_points: list[tuple[float, float]] = []
|
|
edge_offset = self.left_extent if ditch_side == "left" else -self.right_extent
|
|
outward = 1.0 if ditch_side == "left" else -1.0
|
|
edge_z = self.road_z(edge_offset)
|
|
if self.has_ditch:
|
|
if ditch_type == "l_type":
|
|
# L형: 노면 끝에서 폭 W 동안 깊이 D로 내려가는 경사 바닥 + 바깥 수직벽.
|
|
# 바깥(노견 반대측) 상단은 **노견과 같은 표고**로 닫는다 — 사면 시작점이
|
|
# 측구 바닥 높이로 내려가면 안 된다(2026-08-23 사용자 지시).
|
|
width = group["l_ditch_width_m"]
|
|
depth = group["l_ditch_depth_m"]
|
|
self.ditch_points = [
|
|
(edge_offset, edge_z),
|
|
(edge_offset + outward * width, edge_z - depth),
|
|
(edge_offset + outward * width, edge_z),
|
|
]
|
|
else:
|
|
# 일반: 상단폭/저폭/깊이 사다리꼴.
|
|
top = group["ditch_top_width_m"]
|
|
bottom = min(group["ditch_bottom_width_m"], top)
|
|
depth = group["ditch_depth_m"]
|
|
inset = (top - bottom) / 2.0
|
|
self.ditch_points = [
|
|
(edge_offset, edge_z),
|
|
(edge_offset + outward * inset, edge_z - depth),
|
|
(edge_offset + outward * (inset + bottom), edge_z - depth),
|
|
(edge_offset + outward * top, edge_z),
|
|
]
|
|
|
|
def road_z(self, offset_m: float) -> float:
|
|
"""노면(노견 포함) 설계고 — 중심 계획고에서 횡단경사로 기운 단일 평면."""
|
|
return self.z_center + self.slope_per_offset * offset_m
|
|
|
|
def _slope_start(self, side: str) -> tuple[float, float]:
|
|
"""사면 시작점(오프셋 절대값 기준 거리, 표고)을 계산한다."""
|
|
if side == "left":
|
|
edge_offset, edge_z = self.left_extent, self.road_z(self.left_extent)
|
|
else:
|
|
edge_offset, edge_z = self.right_extent, self.road_z(-self.right_extent)
|
|
if side == self.ditch_side and self.ditch_points:
|
|
outer = self.ditch_points[-1]
|
|
return abs(outer[0]), outer[1]
|
|
return edge_offset, edge_z
|
|
|
|
def _rock_boundary_z(self, side: str, dist: float) -> float:
|
|
"""측·거리(절대 오프셋)에서 암반 경계선 표고 = 지반선 + 오프셋(음수=하향)."""
|
|
signed = dist if side == "left" else -dist
|
|
assert self._ground_at is not None # two_stage일 때만 호출
|
|
return self._ground_at(signed) + self._rock_offset
|
|
|
|
def cut_points(self, side: str) -> list[tuple[float, float]]:
|
|
"""절토 사면 꼭짓점 `[(거리, 표고), ...]` — 무릎과 소단이 모두 여기 들어 있다.
|
|
|
|
셈은 짝 모듈 `common_util_cross_berm` 한 벌이 한다(TS 도 같은 것을 부른다).
|
|
소단이 없으면 종전 무릎 방식과 **같은 값**이다(동치 시험으로 지킨다).
|
|
"""
|
|
if side in self._cut_points_cache:
|
|
return self._cut_points_cache[side]
|
|
start_dist, start_z = self._slope_start(side)
|
|
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,
|
|
# 소단이 있으면 경계를 오갈 때마다 꺾는다 — 소단은 평탄한데 경계선은 지반을
|
|
# 따라 올라가서 되돌아 들어가는 일이 흔하다. 한 번만 꺾으면 그 구간을 암인데
|
|
# 토사 경사로 그려 절토가 조용히 커진다(2026-09-07).
|
|
multi_knee=self.berm is not None,
|
|
)
|
|
self._cut_points_cache[side] = points
|
|
return points
|
|
|
|
def _cut_slope_z(self, side: str, dist: float) -> float:
|
|
"""절토 사면선 표고(무릎·소단 반영). 지반 교차 클램프는 하지 않는다."""
|
|
return berm_elevation_at(self.cut_points(side), dist)
|
|
|
|
def fill_points(self, side: str) -> list[tuple[float, float]]:
|
|
"""성토 사면 꼭짓점 — 소단이 들어 있다. 절토와 달리 무릎은 없다."""
|
|
if side in self._fill_points_cache:
|
|
return self._fill_points_cache[side]
|
|
start_dist, start_z = self._slope_start(side)
|
|
points = fill_profile_points(start_dist, start_z, self.fill_ratio, self.berm)
|
|
self._fill_points_cache[side] = points
|
|
return points
|
|
|
|
def _fill_slope_z(self, side: str, dist: float) -> float:
|
|
"""성토 사면선 표고(소단 반영). 지반 교차 클램프는 하지 않는다."""
|
|
return berm_elevation_at(self.fill_points(side), dist)
|
|
|
|
def cut_slope_segments(self) -> list[dict[str, Any]]:
|
|
"""절토 사면을 **경사 구간별로** 쪼갠 목록.
|
|
|
|
⚠ **지금 이 값을 읽는 곳은 없다**(2026-09-07). 임자였던 별표2 법정 경사 검사가 폐기됐고
|
|
(암질을 횡단도에서 안 고르기로 사용자 확정), 저장분에도 안 들어간다. 소단 기하가 이 셈
|
|
위에 서 있어 남겨 둔다 — **되살릴 때는 저장분에서 읽지 말고 계산해서 쓸 것.**
|
|
|
|
원래 필요했던 까닭(되살릴 때 그대로 유효) — 소단이 서면 사면 전체를 하나로 재는
|
|
「실효 경사」가 완만해져 **위반이 사라진 것처럼** 보인다(폭 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
|
|
# 재료는 **그 구간을 실제로 그린 경사비**로 가른다 — 경계선을 다시 재면
|
|
# 안 된다. 무릎을 지난 뒤에도 경계선은 지반을 따라 계속 오르므로, 토사
|
|
# 경사로 그린 구간이 경계 아래로 되돌아가 있는 일이 흔하다. 그것을 경계로
|
|
# 재면 「경사비는 토사인데 재료는 암」인 구간이 생긴다(2026-09-07 다른 창
|
|
# 실측: 용화 63측점에서 13구간). 그린 대로 적는 것이 맞다.
|
|
material: str | None = None
|
|
if self.two_stage and abs(self.soil_cut_ratio - self.cut_ratio) > 1e-9:
|
|
drawn = run / rise
|
|
material = (
|
|
"soil"
|
|
if abs(drawn - self.soil_cut_ratio) < abs(drawn - self.cut_ratio)
|
|
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).
|
|
|
|
지면과 1회 교차하면 그다음 경사(2단계 전환 포함)는 의미가 없으므로 교차점에서
|
|
절토를 종료한다. 시작(노면 끝)부터 사면이 지반 위면 교차거리=시작(절토 없음),
|
|
끝까지 못 만나면 None.
|
|
"""
|
|
if side in self._cut_cross:
|
|
return self._cut_cross[side]
|
|
result: float | None = None
|
|
if self._ground_at is not None:
|
|
start_dist, _start_z = self._slope_start(side)
|
|
step = 0.05
|
|
dist = start_dist
|
|
max_dist = start_dist + 500.0
|
|
while dist <= max_dist:
|
|
signed = dist if side == "left" else -dist
|
|
if self._cut_slope_z(side, dist) - self._ground_at(signed) >= 0:
|
|
result = dist
|
|
break
|
|
dist += step
|
|
self._cut_cross[side] = result
|
|
return result
|
|
|
|
def fill_ground_slope(self) -> float | None:
|
|
"""성토측 **자연 지반**의 평균 경사(rise/run, 무차원). 성토측이 없으면 None.
|
|
|
|
자연방토 판정에 쓴다 — 지반이 가파르면 부어 놓은 흙이 쌓이지 않고 흘러내린다.
|
|
구간은 노면 끝(사면 시작)부터 성토 사면이 지반과 처음 만나는 곳까지이며, 끝까지
|
|
만나지 못하면 10m를 본다. 양쪽이 다 성토면 **완만한 쪽**을 택한다(보수적 판정).
|
|
"""
|
|
if self._ground_at is None:
|
|
return None
|
|
slopes: list[float] = []
|
|
for side in ("left", "right"):
|
|
role = self.left_role if side == "left" else self.right_role
|
|
if role != "fill":
|
|
continue
|
|
start_dist, _start_z = self._slope_start(side)
|
|
end_dist = self.fill_cross_dist(side) or (start_dist + 10.0)
|
|
run = end_dist - start_dist
|
|
if run <= 1e-6:
|
|
continue
|
|
sign = 1.0 if side == "left" else -1.0
|
|
rise = abs(self._ground_at(sign * end_dist) - self._ground_at(sign * start_dist))
|
|
slopes.append(rise / run)
|
|
return min(slopes) if slopes else None
|
|
|
|
def fill_cross_dist(self, side: str) -> float | None:
|
|
"""성토 사면이 지반선과 **처음** 만나는 거리(절대 오프셋). 이후는 성토 없음.
|
|
|
|
절토(cut_cross_dist)와 같은 규칙이다. 실제 지면은 울퉁불퉁해서 성토 사면이 지반과
|
|
여러 번 만날 수 있는데, **첫 교차점이 성토사면의 끝**이고 그 바깥은 손대지 않은
|
|
지반이다(2026-08-02 사용자 지시). `max(fill_line, ground)`만 쓰면 지반이 다시 꺼졌을 때
|
|
성토 사면이 되살아나 사면이 끊겼다 이어지는 그림이 나온다.
|
|
|
|
시작(노면 끝)부터 사면이 지반 아래면 교차거리=시작(성토 없음), 끝까지 못 만나면 None.
|
|
"""
|
|
if side in self._fill_cross:
|
|
return self._fill_cross[side]
|
|
result: float | None = None
|
|
if self._ground_at is not None:
|
|
start_dist, start_z = self._slope_start(side)
|
|
step = 0.05
|
|
dist = start_dist
|
|
max_dist = start_dist + 500.0
|
|
while dist <= max_dist:
|
|
signed = dist if side == "left" else -dist
|
|
fill_line = self._fill_slope_z(side, dist)
|
|
if fill_line - self._ground_at(signed) <= 0:
|
|
result = dist
|
|
break
|
|
dist += step
|
|
self._fill_cross[side] = result
|
|
return result
|
|
|
|
def design_z(self, offset_m: float, ground_m: float) -> float:
|
|
"""offset 하나의 설계 표고(사면은 지반 교차점 이후 지반 추종)."""
|
|
side = "left" if offset_m >= 0 else "right"
|
|
extent = self.left_extent if side == "left" else self.right_extent
|
|
if abs(offset_m) <= extent + 1e-9:
|
|
return self.road_z(offset_m)
|
|
# 측구 구간: 꼭짓점 사이 선형 보간(지반 무관 강제 굴착).
|
|
if side == self.ditch_side and self.ditch_points:
|
|
inner = abs(self.ditch_points[0][0])
|
|
outer = abs(self.ditch_points[-1][0])
|
|
if inner - 1e-9 <= abs(offset_m) <= outer + 1e-9:
|
|
points = self.ditch_points
|
|
for index in range(1, len(points)):
|
|
x0, z0 = abs(points[index - 1][0]), points[index - 1][1]
|
|
x1, z1 = abs(points[index][0]), points[index][1]
|
|
if abs(offset_m) > x1 + 1e-9:
|
|
continue
|
|
span = x1 - x0
|
|
if span <= 1e-9:
|
|
return z1
|
|
ratio = (abs(offset_m) - x0) / span
|
|
return z0 + (z1 - z0) * ratio
|
|
return points[-1][1]
|
|
role = self.left_role if side == "left" else self.right_role
|
|
dist = abs(offset_m)
|
|
if role == "cut":
|
|
# 지반과 1회 교차하면 그 이후 절토는 의미 없음 → 지반 추종(N-2-4).
|
|
cross = self.cut_cross_dist(side)
|
|
if cross is not None and dist >= cross:
|
|
return ground_m
|
|
return min(self._cut_slope_z(side, dist), ground_m)
|
|
# 지반과 1회 교차하면 그 바깥은 성토가 아니라 원지반이다(절토와 같은 규칙).
|
|
cross = self.fill_cross_dist(side)
|
|
if cross is not None and dist >= cross:
|
|
return ground_m
|
|
return max(self._fill_slope_z(side, dist), ground_m)
|
|
|
|
def breakpoints(self) -> list[float]:
|
|
"""적분·설계선에 반드시 포함할 설계 꼭짓점 오프셋 목록(2단계 무릎·소단 포함)."""
|
|
points = [0.0, self.left_extent, -self.right_extent]
|
|
points.extend(offset for offset, _z in self.ditch_points)
|
|
# 절토 사면 꼭짓점(무릎·소단 모서리) — 빠뜨리면 계단이 설계선에 안 실린다.
|
|
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
|
|
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)
|
|
# 성토 사면 소단 모서리 — 절토와 같은 까닭으로 설계선에 실어야 계단이 그려진다.
|
|
if self.berm is not None:
|
|
for side in ("left", "right"):
|
|
role = self.left_role if side == "left" else self.right_role
|
|
if role != "fill":
|
|
continue
|
|
cross = self.fill_cross_dist(side)
|
|
for offset, _z in self.fill_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
|
|
cross = self.cut_cross_dist(side) if role == "cut" else self.fill_cross_dist(side)
|
|
if cross is not None:
|
|
points.append(cross if side == "left" else -cross)
|
|
return points
|