Files
Aislo/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Design.py
T
eomsangdonandClaude Opus 5 b0fe443269 feat(B06): 자연방토 반영 — 사토를 자연방토/사토장 운반으로 가름
판정 기준은 성토측 자연 지반 경사(사용자 확정). 지반이 임계보다 가파르면 부어 놓은 흙이
쌓이지 않고 스스로 흘러내리므로 운반비를 세지 않는다.

계상 방식은 참고 도면을 근거로 정했다. 도면의 사토 balloon은 L=(운반거리) 없이
M.N=측점만 적는다 - 그 노선의 사토가 이미 운반비 없이 처리되고 있다는 뜻이고,
자연방토라는 이름을 안 썼을 뿐 처리 방식이 자연방토다. 그래서 새 항목을 만들지 않고
사토 안에서 가른다. 판정 통과분은 도면과 똑같이 M.N만, 실패분에만 "사토장=X㎥"를 더한다.
수량은 그대로 두고 성토로 흡수하지 않아 유토곡선 모양과 누가토량은 안 바뀐다.

- config NATURAL_SPOIL_MIN_GROUND_SLOPE = 1/1.5 (rise/run, 약 33.7도) 신설.
  유일한 정의처이며 프론트는 context로 받아 쓴다. 못 받으면 자연방토 없음(보수적)
- 엔진 fill_ground_slope(): 노면 끝~성토 사면의 첫 지반 교차까지 평균 경사.
  양쪽이 다 성토면 완만한 쪽을 택한다(보수적)
- AreaSample/MassHaulPoint에 natural_spoil - 양 끝 측점이 둘 다 가능할 때만 참
- HaulResidual.natural_m3 - 잔량 구간에서 자연방토 가능한 길이 비율만큼 계상.
  장거리 상쇄로 깎이면 안분·자연방토도 같은 비율로 감소
- HaulPlan.natural_spoil_m3 + 요약 칩

700줄 초과 2건 분리: _Engine_Areas.py(면적 적분), _UI_MassHaul_Settle.ts(안분·장비·상쇄·
직렬화). 의존은 한 방향(Balance -> Settle 함수, Settle -> Balance 타입만)이라 순환 없음.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 18:18:08 +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_wf3_ProfileCross.B06_wf3_ProfileCross_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