Files
Aislo/B06_Section/B06_Section_Engine_Design.py
T
eomsangdonandClaude Opus 5 e43ad18f97 feat(횡단): 절토 사면에 소단(계단) 기하를 넣음 — 파이썬·TS 짝 (계획서 3-9)
사용자 확정(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>
2026-09-07 14:54:18 +09:00

719 lines
36 KiB
Python

"""B06 측점 표준횡단 설계 계산 엔진.
⚠⚠ TS 짝 파일과 **한 벌**이다 — 한쪽만 고치면 화면과 저장본이 갈린다 ⚠⚠
짝: `common_util/common_util_cross_design.ts` (`computeCrossDesign`)
면적 적분은 `B06_Section_Engine_Areas.py` ↔ `common_util_cross_design_areas.ts`.
회귀 테스트가 두 구현을 같은 입력으로 실제 비교한다:
`tmp/tests/test_b06_cross_design_mirror.py` — 어느 쪽을 고치든 반드시 같이 돌릴 것.
왜 두 벌인가: 사용자가 계획선을 만지는 동안의 계산은 **브라우저 안에서 끝나야
한다**(2026-09-03 사용자 확정). 계획고가 바뀔 때마다 서버에 전 측점 횡단을 물으면
왕복이 조작 속도를 지배했다. 이 파이썬 엔진은 자동설계 체인·[저장]·[확정]·도면
산출의 **정본**이고, TS 미러는 조작 중 화면용이다. 값이 갈리면 화면과 납품물이
달라지므로 새 필드·상수·판정을 더할 때 양쪽을 함께 고칠 것.
지반유형(토사/리핑암/발파암)과 단면유형(좌절/우절/양절/양성)에 따라 표준횡단
설계선을 구성하고, 지반선과의 차이로 절·성토 단면적을 산출한다. B06에서 사용자가
버튼을 누를 때 즉시 호출되며, 여기서 나온 값은 잠정치로 저장되고 B07 상세설계에서
확정치로 대체된다.
표준단면 기하는 config STANDARD_CROSS_SECTION(단일 진실 원천)을 읽고, B06 설정
패널 편집값(standard 인자)이 오면 요청값 → config 기본값 순으로 우선한다.
설계선은 도면 표준대로 횡단경사(측구 방향), 노견, 측구(사다리꼴/L형), 절·성토
사면을 모두 포함하며, 면적은 지반 샘플과 설계 꼭짓점을 합친 오프셋 격자에서
사다리꼴 적분한다(측구 굴착이 절토 면적에 자연 포함).
좌표 규약(generate_sections cad_exchange 준수): offset_m 양수=좌, 음수=우.
경사비는 수평:수직 = ratio:1 (예: 1:1.2 → ratio=1.2).
"""
from collections.abc import Callable
from typing import Any
from B06_Section.B06_Section_Engine_Areas import (
_split_cut_areas,
_trapezoid_areas,
)
from common_util.common_util_cross_berm import (
BermSpec,
cut_profile_points,
)
from common_util.common_util_cross_berm import elevation_at as berm_elevation_at
from config.config_system import (
CURVE_WIDENING_MAX_WIDTH_M,
SECTION_DITCH_SIDES,
SECTION_DITCH_TYPES,
SECTION_GROUND_TYPE_PRESET,
SECTION_MODES,
STANDARD_CROSS_SECTION,
)
from config.config_system import (
curve_widening_m as _curve_widening_m,
)
# 사면이 원지반과 만났다고 볼 높이차(m). 이보다 크면 샘플 끝에서 잘린 것으로 본다.
_SLOPE_CLOSE_TOLERANCE_M = 0.01
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}")
def _resolve_ditch_side(section_mode: str, ditch_side: str | None) -> str:
"""측구(배수) 배치 측을 결정한다.
편절편성은 절토측이 곧 측구측이라 자동 결정하고, 양절·양성은 배수 방향을
사용자 지정(ditch_side)에 맡긴다(미지정 시 좌측 기본).
"""
if section_mode == "left_cut":
return "left"
if section_mode == "right_cut":
return "right"
if ditch_side in SECTION_DITCH_SIDES:
return ditch_side
return "left"
def _as_float(value: Any, fallback: float) -> float:
"""패널 편집값을 안전하게 float으로 읽는다(손상 시 config 기본값)."""
try:
parsed = float(value)
except (TypeError, ValueError):
return fallback
return parsed if parsed >= 0 else fallback
def _resolve_group(preset_key: str, standard: dict[str, Any] | None) -> dict[str, float]:
"""config 기본값 위에 패널 편집값(standard[preset_key])을 덮어 평탄화한다."""
base = STANDARD_CROSS_SECTION[preset_key]
override = standard.get(preset_key) if isinstance(standard, dict) else None
override = override if isinstance(override, dict) else {}
base_ditch = base["ditch"]
over_ditch = override.get("ditch")
over_ditch = over_ditch if isinstance(over_ditch, dict) else {}
base_l = base.get("ditch_l_type", {})
over_l = override.get("ditch_l_type")
over_l = over_l if isinstance(over_l, dict) else {}
base_slope = base["cross_slope_pct"]
over_slope = override.get("cross_slope_pct")
over_slope = over_slope if isinstance(over_slope, dict) else {}
return {
"road_width_m": _as_float(override.get("road_width_m"), base["road_width_m"]),
"shoulder_left_m": _as_float(override.get("shoulder_left_m"), base["shoulder_left_m"]),
"shoulder_right_m": _as_float(override.get("shoulder_right_m"), base["shoulder_right_m"]),
"ditch_top_width_m": _as_float(over_ditch.get("top_width_m"), base_ditch["top_width_m"]),
"ditch_bottom_width_m": _as_float(
over_ditch.get("bottom_width_m"), base_ditch["bottom_width_m"]
),
"ditch_depth_m": _as_float(over_ditch.get("depth_m"), base_ditch["depth_m"]),
"l_ditch_width_m": _as_float(over_l.get("width_m"), base_l.get("width_m", 0.5)),
"l_ditch_depth_m": _as_float(over_l.get("depth_m"), base_l.get("depth_m", 0.1)),
# 횡단경사는 범위(min~max) 중 하한을 기본 채택한다(도면 표기 앞값).
"cross_slope_pct": _as_float(over_slope.get("min"), base_slope["min"]),
"fill_slope_ratio": _as_float(override.get("fill_slope_ratio"), base["fill_slope_ratio"]),
"cut_slope_ratio": _as_float(override.get("cut_slope_ratio"), base["cut_slope_ratio"]),
"pavement_thickness_m": _as_float(
override.get("pavement_thickness_m"), base.get("pavement_thickness_m", 0.2)
),
}
def _ground_interpolator(valid: list[tuple[float, float]]):
"""정렬된 (offset, 지반고) 샘플의 선형 보간 함수를 만든다(범위 밖 끝값 클램프)."""
def ground_at(offset_m: float) -> float:
if offset_m <= valid[0][0]:
return valid[0][1]
if offset_m >= valid[-1][0]:
return valid[-1][1]
for index in range(1, len(valid)):
x1, z1 = valid[index]
if offset_m > x1:
continue
x0, z0 = valid[index - 1]
span = x1 - x0
if span <= 0:
return z1
ratio = (offset_m - x0) / span
return z0 + (z1 - z0) * ratio
return valid[-1][1]
return ground_at
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_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]]] = {}
# 절토 사면·지반 최초 교차거리(측별 캐시) — 교차 후 절토 종료용(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): 양성은 항상 미생성. ditch_enabled가 오면 그 값을 따르고(수동
# override), None이면 자동 판정 — 측구측 노면 끝에서 지반이 설계면보다 높으면(절토
# 상황) 생성, 낮으면(성토 상황, 자연 배수) 미생성. ground_at 없으면 보수적으로 생성.
if section_mode == "both_fill":
self.has_ditch = False
elif ditch_enabled is not None:
self.has_ditch = ditch_enabled
elif ground_at is not None:
ditch_edge = self.left_extent if ditch_side == "left" else -self.right_extent
self.has_ditch = ground_at(ditch_edge) > self.road_z(ditch_edge) + 1e-3
else:
self.has_ditch = True
# 측구 꼭짓점(측구측 노면 끝 기준, 바깥 방향 부호 적용).
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
)
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 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 = start_z - (dist - start_dist) / self.fill_ratio
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
start_dist, start_z = self._slope_start(side)
dist = abs(offset_m)
run = dist - start_dist
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
fill_line = start_z - run / self.fill_ratio
return max(fill_line, 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)
# 절·성토 사면과 지반의 **첫** 교차점을 꼭짓점에 넣어 면적 절단을 정확히 한다(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
def curve_widening_args(section: dict[str, Any] | None) -> dict[str, Any]:
"""측점 기록에서 곡선부 확폭 입력을 뽑는다 — `compute_cross_design(**...)` 로 넘긴다.
측점마다 실려 오는 값이라 호출자마다 따로 꺼내 쓰면 빠뜨리기 쉽다(2026-09-06).
옛 저장분에는 두 값이 없어 확폭 없이 예전과 같은 단면이 나온다.
"""
if not isinstance(section, dict):
return {"plan_radius_m": None, "curve_outer_side": None, "curve_widening_m": None}
return {
"plan_radius_m": section.get("plan_radius_m"),
"curve_outer_side": section.get("curve_outer_side"),
# 곡선 앞뒤 테이퍼가 얹힌 값 — 있으면 반경 표값 대신 이걸 쓴다(2026-09-06).
"curve_widening_m": section.get("curve_widening_m"),
}
def compute_cross_design(
samples: list[dict[str, Any]],
design_elevation_m: float | None,
*,
ground_type: str,
section_mode: str,
ditch_side: str | None = None,
ditch_type: str = "standard",
paved: bool = False,
standard: dict[str, Any] | None = None,
rock_boundary_offset_m: float | None = None,
two_stage_slope: bool = True,
ditch_enabled: bool | None = None,
surface_drop_m: float = 0.0,
plan_radius_m: float | None = None,
curve_outer_side: str | None = None,
curve_widening_m: float | None = None,
berm: BermSpec | None = None,
) -> dict[str, Any]:
"""측점 하나의 표준횡단 설계선과 절·성토 단면적을 계산한다.
samples: [{offset_m, elevation_m, valid}] 지반선 원시 샘플.
design_elevation_m: 중심선 계획고(노면고). None이면 계산 불가.
ditch_type: 일반(standard)/L형(l_type, 암 구간 전용).
paved: 포장 중첩 여부 — 횡단경사·포장층만 포장 그룹 값으로 바꾼다(기하는 지반유형).
standard: B06 설정 패널 편집값(STANDARD_CROSS_SECTION 형태). 요청값 → config 순.
rock_boundary_offset_m: 암반 경계선 오프셋(지반선 기준, 음수=하향). 암 지반 2단계 절토용.
two_stage_slope: 암 지반에서 암반 경계 기준 2단계 경사 적용 여부(기본 True, 토글로 해제).
plan_radius_m: 이 측점의 평면 곡선반경(m). 곡선부 확폭(별표2 Ⅰ.2.나.(4))을 정하는
입력이며, None·45m 이상이면 확폭이 없다.
curve_outer_side: 곡선 **바깥쪽**("left"/"right"). 확폭은 그쪽으로만 붙는다
(2026-09-06 사용자 확정). 값이 없으면 확폭을 넣지 않는다.
surface_drop_m: 노면을 통째로 내리는 양(m) — 세월교 월류 높이. 구체 위 노면은 월류
높이만큼 낮게 앉으므로 계획고를 그만큼 내려 잡는다. 단면 전체가 평행 이동하므로
횡단경사·측구·사면 규칙은 그대로고 절·성토 면적만 따라 바뀐다(2026-08-30 사용자).
"""
if ground_type not in SECTION_GROUND_TYPE_PRESET:
raise ValueError(f"지원하지 않는 지반유형입니다: {ground_type}")
if section_mode not in SECTION_MODES:
raise ValueError(f"지원하지 않는 단면유형입니다: {section_mode}")
if ditch_type not in SECTION_DITCH_TYPES:
raise ValueError(f"지원하지 않는 측구 형식입니다: {ditch_type}")
if design_elevation_m is None:
raise ValueError("계획고(design_elevation_m)가 없어 횡단 설계를 계산할 수 없습니다.")
drop = max(float(surface_drop_m), 0.0)
design_elevation_m = float(design_elevation_m) - drop
preset_key = SECTION_GROUND_TYPE_PRESET[ground_type]
if ditch_type == "l_type" and preset_key != "rock":
raise ValueError("L형 측구는 암(리핑암/발파암) 구간에서만 선택할 수 있습니다.")
group = _resolve_group(preset_key, standard)
paved_group = _resolve_group("paved", standard)
# 포장 중첩: 횡단경사와 포장층 두께만 포장 그룹을 따른다.
cross_slope_pct = paved_group["cross_slope_pct"] if paved else group["cross_slope_pct"]
resolved_ditch_side = _resolve_ditch_side(section_mode, ditch_side)
valid = sorted(
(
(float(s["offset_m"]), float(s["elevation_m"]))
for s in samples
if s.get("valid") is not False
and s.get("offset_m") is not None
and s.get("elevation_m") is not None
),
key=lambda pair: pair[0],
)
if len(valid) < 2:
raise ValueError("유효한 지반 샘플이 부족해 횡단 설계를 계산할 수 없습니다.")
ground_at = _ground_interpolator(valid)
# 2단계 절토는 암 프리셋에서만, 암반 경계 오프셋이 있을 때만 켠다.
enable_two_stage = (
preset_key == "rock" and two_stage_slope and rock_boundary_offset_m is not None
)
soil_cut_ratio = _resolve_group("soil", standard)["cut_slope_ratio"]
# 곡선부 확폭 — 표는 하한이고, 확폭을 더한 유효너비가 법정 상한(5m)을 넘지 않게 자른다.
# 저장된 확폭량(테이퍼 포함)이 있으면 그것을 쓰고, 없으면 반경 표값으로 되돌아간다.
if curve_outer_side in ("left", "right"):
widening = (
float(curve_widening_m)
if isinstance(curve_widening_m, (int, float))
else _curve_widening_m(plan_radius_m)
)
else:
widening = 0.0
if widening > 0.0:
room = max(CURVE_WIDENING_MAX_WIDTH_M - group["road_width_m"], 0.0)
widening = min(widening, room)
widening_left = widening if curve_outer_side == "left" else 0.0
widening_right = widening if curve_outer_side == "right" else 0.0
geometry = _SectionGeometry(
design_elevation_m=design_elevation_m,
group=group,
section_mode=section_mode,
ditch_side=resolved_ditch_side,
ditch_type=ditch_type,
cross_slope_pct=cross_slope_pct,
ground_at=ground_at,
soil_cut_ratio=soil_cut_ratio,
rock_boundary_offset_m=rock_boundary_offset_m,
two_stage_slope=enable_two_stage,
ditch_enabled=ditch_enabled,
widening_left_m=widening_left,
widening_right_m=widening_right,
berm=berm,
)
# 적분 오프셋 = 지반 샘플 ∪ 설계 꼭짓점(샘플 범위 안쪽만). 꼭짓점을 넣어야
# 측구 모서리·노면 끝이 잘리지 않아 면적과 설계선이 정확해진다.
min_offset, max_offset = valid[0][0], valid[-1][0]
merged: list[float] = [offset for offset, _z in valid]
merged.extend(point for point in geometry.breakpoints() if min_offset <= point <= max_offset)
merged = sorted(set(round(offset, 6) for offset in merged))
offsets: list[float] = []
diffs: list[float] = []
design_line: list[dict[str, float]] = []
for offset_m in merged:
ground_m = ground_at(offset_m)
design_z = geometry.design_z(offset_m, ground_m)
offsets.append(offset_m)
diffs.append(ground_m - design_z)
design_line.append({"offset_m": round(offset_m, 4), "elevation_m": round(design_z, 4)})
# 측구 굴착은 설계선에 포함돼 절토 면적에 자연 반영된다(별도 가산 없음 — 이중계상 방지).
cut_area, fill_area = _trapezoid_areas(offsets, diffs)
fill_ground_slope = geometry.fill_ground_slope()
# 사면이 샘플 범위 끝에서도 원지반과 만나지 않으면 면적이 거기서 잘린다 — 그만큼
# 절·성토량이 실제와 다르고 유토곡선도 그 값을 그대로 쌓는다. 영원히 안 만나는
# 지형이 있을 수 있으므로 계산은 손대지 않고 **경고만** 낸다(2026-09-03 사용자 확정).
slope_unclosed = bool(diffs) and (
abs(diffs[0]) > _SLOPE_CLOSE_TOLERANCE_M or abs(diffs[-1]) > _SLOPE_CLOSE_TOLERANCE_M
)
# 절토면적 토사/암반 분리 — 지표면~암반 경계선이 토사, 그 아래가 암이다. 경계선 위치가
# 곧 유토곡선 EA/RR/BR 비율을 만들므로, 사용자가 경계선을 올리내리면 이 값이 함께 바뀐다.
# 토사 지반은 암반 경계선 자체가 없어 전량 토사, 암 지반인데 경계선 값이 없으면(구 데이터)
# 분리 근거가 없으므로 전량 암으로 둔다(기존 단일 지반유형 환산과 같은 결과).
if preset_key != "rock":
cut_soil_area, cut_rock_area = cut_area, 0.0
cut_rock_kind: str | None = None
elif rock_boundary_offset_m is None:
cut_soil_area, cut_rock_area = 0.0, cut_area
cut_rock_kind = ground_type
else:
cut_soil_area, cut_rock_area = _split_cut_areas(
offsets, diffs, abs(float(rock_boundary_offset_m))
)
cut_rock_kind = ground_type
# 측구 공칭 단면적(수량 산출 참고용): 일반=사다리꼴, L형=직각삼각형 근사.
if not geometry.has_ditch:
ditch_area = 0.0
ditch_spec: dict[str, Any] = {"type": "none"}
elif ditch_type == "l_type":
ditch_area = group["l_ditch_width_m"] * group["l_ditch_depth_m"] / 2.0
ditch_spec = {
"type": "l_type",
"width_m": group["l_ditch_width_m"],
"depth_m": group["l_ditch_depth_m"],
}
else:
ditch_area = (
(group["ditch_top_width_m"] + group["ditch_bottom_width_m"])
/ 2.0
* group["ditch_depth_m"]
)
ditch_spec = {
"type": "standard",
"top_width_m": group["ditch_top_width_m"],
"bottom_width_m": group["ditch_bottom_width_m"],
"depth_m": group["ditch_depth_m"],
}
# 자동 판정된 절/성토 역할에서 실제 단면 유형을 도출해 echo한다(D-2, 표시·저장용).
if geometry.left_role == "cut" and geometry.right_role == "cut":
resolved_mode = "both_cut"
elif geometry.left_role == "fill" and geometry.right_role == "fill":
resolved_mode = "both_fill"
elif geometry.left_role == "cut":
resolved_mode = "left_cut"
else:
resolved_mode = "right_cut"
result: dict[str, Any] = {
"ground_type": ground_type,
"geometry_preset": preset_key,
"section_mode": resolved_mode,
"ditch_side": resolved_ditch_side,
"ditch_type": ditch_type if geometry.has_ditch else None,
"cut_slope_ratio": round(geometry.cut_ratio, 4),
"soil_cut_slope_ratio": round(geometry.soil_cut_ratio, 4),
# 사용자가 **켠 값**을 그대로 돌려준다 — 엔진이 실제로 적용했는지(`geometry.two_stage`)가
# 아니다(2026-09-07). 적용 결과를 저장하면, 토사 측점처럼 못 쓰는 자리에서 false 가
# 저장되고 그 false 가 다음 재계산 인자로 되먹여져 **사용자의 「켬」이 영구히 사라졌다.**
# 「실제로 적용됐나」를 읽는 곳은 코드 전체에 하나도 없다(2026-09-07 전수 확인) —
# 읽는 쪽은 모두 사용자 설정으로 쓴다. TS 짝: `common_util_cross_design.ts`.
"two_stage_slope": bool(two_stage_slope),
"fill_slope_ratio": round(geometry.fill_ratio, 4),
"roadbed_width_m": round(geometry.left_extent + geometry.right_extent, 4),
"carriageway_width_m": round(geometry.half_road_left + geometry.half_road_right, 4),
# 규격 폭과 확폭을 따로 남긴다 — 횡단도 라벨·수량 산출이 둘을 나눠 쓴다.
"carriageway_standard_width_m": round(group["road_width_m"], 4),
"widening_left_m": round(geometry.half_road_left - geometry.half_road, 4),
"widening_right_m": round(geometry.half_road_right - geometry.half_road, 4),
"cross_slope_pct": round(cross_slope_pct, 4),
"ditch": ditch_spec,
"ditch_enabled": bool(geometry.has_ditch),
"paved": bool(paved),
# 노면(노견 포함) 양 끝점 — 노면 렌더링 기준.
"road_edges": {
"left": {
"offset_m": round(geometry.left_extent, 4),
"elevation_m": round(geometry.road_z(geometry.left_extent), 4),
},
"right": {
"offset_m": round(-geometry.right_extent, 4),
"elevation_m": round(geometry.road_z(-geometry.right_extent), 4),
},
},
# 차도(노견 제외) 양 끝점 — 포장 범위 기준(D-5).
"carriageway_edges": {
"left": {
"offset_m": round(geometry.half_road_left, 4),
"elevation_m": round(geometry.road_z(geometry.half_road_left), 4),
},
"right": {
"offset_m": round(-geometry.half_road_right, 4),
"elevation_m": round(geometry.road_z(-geometry.half_road_right), 4),
},
},
"design_elevation_m": round(float(design_elevation_m), 4),
"cut_area_m2": round(cut_area, 4),
# 절토 내역(합=cut_area_m2). 유토곡선이 지반유형별 환산계수를 물리는 단위다.
"cut_soil_area_m2": round(cut_soil_area, 4),
"cut_rock_area_m2": round(cut_rock_area, 4),
"cut_rock_kind": cut_rock_kind,
"fill_area_m2": round(fill_area, 4),
# 사면이 샘플 범위 끝까지 원지반을 못 만나 면적이 잘린 측점 — 경고 표기용.
"slope_unclosed": slope_unclosed,
# 성토측 자연 지반 경사(rise/run) — 자연방토 판정 입력. 성토측이 없으면 None.
"fill_ground_slope": (
round(fill_ground_slope, 4) if fill_ground_slope is not None else None
),
"ditch_area_m2": round(ditch_area, 4),
"design_line": design_line,
}
if drop > 0:
# 내려 앉힌 양 — 프론트가 "월류가 없었다면" 노면을 점선으로 되그리는 데 쓴다.
result["surface_drop_m"] = round(drop, 4)
if paved:
result["pavement_thickness_m"] = round(paved_group["pavement_thickness_m"], 4)
# 암 지반은 경계선 오프셋을 echo해 프론트가 세션값 없이도 오버레이·재계산에 쓰게 한다.
if preset_key == "rock" and rock_boundary_offset_m is not None:
result["rock_boundary_offset_m"] = round(float(rock_boundary_offset_m), 4)
return result