Files
Aislo/B06_Section/B06_Section_Engine_Design.py
T
eomsangdonandClaude Fable 5 54954a05e5 refactor(B05,B06): B05_wf2_Route -> B05_Profile, B06_wf3_ProfileCross -> B06_Section 동시 개명
- 한몸으로 동작하는 두 페이지라 한 커밋으로 처리 (상호 참조 다수)
- B05 37파일 + B06 20파일 접두사 개명 (git mv, 이력 보존)
- 참조 치환 91파일: import 경로, 라우트 슬러그(b05-profile/b06-section),
  라우트 키(B05_PROFILE/B06_SECTION), B03 자동 체인, storage 상수, pyproject 제외 경로
- 로직 변경 없음. typecheck·백엔드 import 검증 통과

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 10:03:11 +09:00

636 lines
30 KiB
Python

"""B06 측점 표준횡단 설계 계산 엔진.
지반유형(토사/리핑암/발파암)과 단면유형(좌절/우절/양절/양성)에 따라 표준횡단
설계선을 구성하고, 지반선과의 차이로 절·성토 단면적을 산출한다. 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 config.config_system import (
SECTION_DITCH_SIDES,
SECTION_DITCH_TYPES,
SECTION_GROUND_TYPE_PRESET,
SECTION_MODES,
STANDARD_CROSS_SECTION,
)
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,
) -> None:
half_road = group["road_width_m"] / 2.0
self.half_road = half_road # 차도 반폭(노견 제외) — 포장 범위 기준
self.left_extent = half_road + group["shoulder_left_m"] # 좌(+) 노면 끝
self.right_extent = half_road + 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
self._rock_knee: dict[str, tuple[float, float] | None] = {}
# 절토 사면·지반 최초 교차거리(측별 캐시) — 교차 후 절토 종료용(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로 내려가는 경사 바닥 한 조각.
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),
]
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 knee(self, side: str) -> tuple[float, float] | None:
"""절토 사면이 암반 경계선을 지나는 전환점(무릎 거리, 표고)을 구한다(측별 캐시).
노면 끝(사면 시작)에서 암 경사(cut_ratio)로 올라가며 경계선을 만나면 그 지점부터
토사 경사로 완만해진다. 시작부터 경계 위면 무릎=시작(전부 토사), 끝까지 못 만나면
None(전부 암). 경계선은 지반을 따라 변하므로 세밀 행진으로 교차점을 찾는다.
"""
if not self.two_stage:
return None
if side in self._rock_knee:
return self._rock_knee[side]
start_dist, start_z = self._slope_start(side)
diff_prev = start_z - self._rock_boundary_z(side, start_dist)
result: tuple[float, float] | None
if diff_prev >= 0:
result = (start_dist, start_z) # 시작부터 토사(경계 위)
else:
result = None
step = 0.05
dist_prev = start_dist
dist = start_dist + step
while dist <= start_dist + 200.0:
z_rock = start_z + (dist - start_dist) / self.cut_ratio
diff = z_rock - self._rock_boundary_z(side, dist)
if diff >= 0:
span = diff - diff_prev
ratio = (-diff_prev) / span if abs(span) > 1e-9 else 0.0
knee_dist = dist_prev + (dist - dist_prev) * ratio
knee_z = start_z + (knee_dist - start_dist) / self.cut_ratio
result = (knee_dist, knee_z)
break
dist_prev, diff_prev = dist, diff
dist += step
self._rock_knee[side] = result
return result
def _cut_slope_z(self, side: str, dist: float) -> float:
"""절토 사면선 표고(2단계 무릎 반영). 지반 교차 클램프는 하지 않는다."""
start_dist, start_z = self._slope_start(side)
knee = self.knee(side) if self.two_stage else None
if knee is not None:
knee_dist, knee_z = knee
if dist <= knee_dist: # 암반 구간(경계 아래): 암 경사
return start_z + (dist - start_dist) / self.cut_ratio
return knee_z + (dist - knee_dist) / self.soil_cut_ratio # 토사 구간: 완만
return start_z + (dist - start_dist) / self.cut_ratio
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:
for side in ("left", "right"):
role = self.left_role if side == "left" else self.right_role
knee = self.knee(side) if role == "cut" else None
if knee is not None:
points.append(knee[0] if side == "left" else -knee[0])
# 절·성토 사면과 지반의 **첫** 교차점을 꼭짓점에 넣어 면적 절단을 정확히 한다(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 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,
) -> 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, 토글로 해제).
"""
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)가 없어 횡단 설계를 계산할 수 없습니다.")
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"]
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,
)
# 적분 오프셋 = 지반 샘플 ∪ 설계 꼭짓점(샘플 범위 안쪽만). 꼭짓점을 넣어야
# 측구 모서리·노면 끝이 잘리지 않아 면적과 설계선이 정확해진다.
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()
# 절토면적 토사/암반 분리 — 지표면~암반 경계선이 토사, 그 아래가 암이다. 경계선 위치가
# 곧 유토곡선 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),
"two_stage_slope": bool(geometry.two_stage),
"fill_slope_ratio": round(geometry.fill_ratio, 4),
"roadbed_width_m": round(geometry.left_extent + geometry.right_extent, 4),
"carriageway_width_m": round(group["road_width_m"], 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, 4),
"elevation_m": round(geometry.road_z(geometry.half_road), 4),
},
"right": {
"offset_m": round(-geometry.half_road, 4),
"elevation_m": round(geometry.road_z(-geometry.half_road), 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),
# 성토측 자연 지반 경사(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 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