Files
Aislo/B06_Section/B06_Section_Engine_Design.py
T
eomsangdonandClaude Opus 5.5 172ff8170b refactor(511-B B-6 정리): 운영 부름 0 파이썬 짝 걷음 · 남는 짝 머리에 남는 까닭 · 등록부 until 걷음 · 알려진 차 까닭 · 322_3 flow 빨강 고침
걷은 함수(운영 부름 0 · 전수 확인 = 화면 · 서버 · 체인 · 추천 엔진 · B07 · 시험)
- common_util/common_util_excavation.py 통째(126 줄 · pipe_trench_width_m · wall_trench_area_m2 · wall_trench_width_m · 서버 = Node excavation.trench)
- Engine_Grade_Carry.carry_edits(Node carry_seat 겉) · Route_Save_Parts.seat_route_moved(시험 헬퍼 helper_475_1_moved 로 옮김)
- Engine_Tilt.attach_underdrains · Engine_Structures_Wall.attach_wall_structures · load_wall_structures
- Engine_Grade_Alignment.tangent_elevation(부름 0) · station_key.dedupe_station_keys · loose_chainage
줄 수 전 → 후: excavation.py 126 → 0 · Grade_Carry 178 → 157 · Save_Parts 289 → 269 · Wall 221 → 197 · Tilt 115 → 112 · station_key 141 → 122 · Alignment 657 → 654

남는 짝(머리 「남는 까닭」 한 줄) — Grade · Grade_Profile · Alignment · CurveLaw(2~4 순위 폴백 · 추천) · NodeEdits · SampleExtend · Router_Design · Ranges · Engine_Design · Areas · cross_berm(DTM 아닌 지표면 · Node 못 부를 때 · B07 · 추천) · Tilt · Wall · Culvert(B04 B07 B08) · route_follow · structure_station · station_key · route_polyline(745 줄 · 까닭 = 등록부 R1 줄)

등록부 — until 칸 걷음(G1 = py 표본기 남김 · 까닭 note) · C6 pair = attach_underdrain_specs · F8 pair null
알려진 차 — golden KNOWN 셋 그대로 나타남(R7 표본 출처 · W0 저장 때 경고 · X2 입력 선 자릿수 · 까닭 고침) · underdrain 알려진 차 까닭 · 511_b5 KNOWN_ARC 그대로
계산 자리 창 — 45 줄 · 서버만 아닌 41(바꿀 수 있음 40 + F9) · 서버만 넷(G3 D1 D2 N1 줄마다 까닭) · 옛 까닭 「짝 모으는 중 · B-4」 글 걷음
test_322_3_flow 빨강 — 헬퍼가 @config/config_frontend 흉내(511-A design_calc_server import)
시험 기준 — 걷은 짝 거울 = 걷기 전 파이썬 값 고정(excavation · station_key) · Node carry_seat · attach_underdrain_specs · wall_specs_from

검증: 고친 대상 시험 97 파일 562 통과 · 21 건너뜀 · typecheck 0 · 700 줄 넘는 새 파일 없음 — 요구 320-4

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:34:08 +09:00

563 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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).
남는 까닭(511-B B-6) — [저장] · [확정] · 기본 설계 = Node `cross.design` · 이 짝 = B07 · 추천 엔진
되풀이 · DTM 아닌 지표면 · Node 못 부를 때.
"""
from typing import Any
from B06_Section.B06_Section_Engine_Areas import (
_bench_cut_length,
_split_cut_areas,
_split_ditch_area,
_trapezoid_areas,
)
from B06_Section.B06_Section_Engine_Design_Geometry import (
_SectionGeometry,
)
from common_util.common_util_cross_berm import (
BermSpec,
)
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 _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
def stored_shoulder_extend(stored: dict[str, Any] | None) -> dict[str, float]:
"""저장분의 설계선 쉬프트(쪽마다 노견 확장 · 139-6) — `compute_cross_design(**...)` 로 넘긴다.
⚠ 소단 · 암 절토 경사와 같은 **기하 입력**이다 — 다시 계산한 뒤 키만 베껴 붙이면 설계선은 옛
노견으로 나오고 값만 남아 어긋난다. 없거나 음수 = 0.
"""
out: dict[str, float] = {}
for side in ("left", "right"):
value = (stored or {}).get(f"shoulder_extend_{side}_m")
ok = isinstance(value, (int, float)) and not isinstance(value, bool)
out[f"shoulder_extend_{side}_m"] = max(float(value), 0.0) if ok else 0.0
return out
# 노선 저장(B05 `_carry_cross`)이 측점 열쇠마다 더 넘기는 사용자 손질(417) — 사면 쉬프트 ·
# 소단 깊이(기하 입력) · 측점 폭 · 메모 · 상세 표. 구조물 조절 = `STRUCTURE_ADJUST_KEYS`(관 · 구조물
# 열쇠만). 브라우저 짝 = `B06_Section_Station_Keys.ts` `ROUTE_CARRY_STATES`
# (거울 시험 test_417_route_carry_mirror).
ROUTE_CARRY_KEYS = (
"shoulder_extend_left_m",
"shoulder_extend_right_m",
"berm_depth_left_m",
"berm_depth_right_m",
"display_half_width_m",
"structure_note",
"structure_note_cad",
"detail_table",
)
def stored_tilt(stored: dict[str, Any] | None) -> dict[str, Any]:
"""저장분의 구조물 자리 기울기(287-4 · 288-1) — `compute_cross_design(**...)` 로 넘긴다."""
value = (stored or {}).get("tilt_pct")
pct = float(value) if isinstance(value, (int, float)) and not isinstance(value, bool) else None
return {"tilted": bool((stored or {}).get("tilted")), "tilt_pct": pct}
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 = False,
cut_slope_ratio: float | None = None,
ditch_enabled: bool | None = None,
ditch_choice: 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,
shoulder_extend_left_m: float = 0.0,
shoulder_extend_right_m: float = 0.0,
tilted: bool = False,
tilt_pct: float | 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단계 경사 적용 여부(기본 False, 토글로 켬).
cut_slope_ratio: 이 측점만 쓰는 절토 경사비(1:n 의 n). 사용자가 카드에서 넣은 값이며
None 이면 표준 횡단면 설정값을 그대로 쓴다(2026-09-07 사용자 지시).
plan_radius_m: 이 측점의 평면 곡선반경(m). 곡선부 확폭(별표2 Ⅰ.2.나.(4))을 정하는
입력이며, None·45m 이상이면 확폭이 없다.
curve_outer_side: 곡선 **바깥쪽**("left"/"right"). 확폭은 그쪽으로만 붙는다
(2026-09-06 사용자 확정). 값이 없으면 확폭을 넣지 않는다.
tilted · tilt_pct: 물넘이포장 범위 · 맹암거 측점(286-2 · 287-4 · 288-1) — 측구 없음 ·
노면 = `tilt_pct`(비면 표준 횡단경사)로 지대 낮은 쪽 한 경사. 결과에 `tilted` · `tilt_pct`
로 되실음(`stored_tilt`). TS 짝 `tilted` · `tiltPct`.
surface_drop_m: 노면을 통째로 내리는 양(m) — 세월교 월류 높이. 구체 위 노면은 월류
높이만큼 낮게 앉으므로 계획고를 그만큼 내려 잡는다. 단면 전체가 평행 이동하므로
횡단경사·측구·사면 규칙은 그대로고 절·성토 면적만 따라 바뀐다(2026-08-30 사용자).
shoulder_extend_left_m · shoulder_extend_right_m: 설계선 쉬프트 = 쪽마다 사면 이동(m · ≥ 0 ·
139-6) — 사용자 값 · 저장분은 `stored_shoulder_extend`. TS 짝 `shoulderExtendLeftM` 등.
"""
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)
# 측점 하나만 다른 절토 경사(2026-09-07 사용자 지시) — 표준 그룹의 경사비만 갈아 끼운다.
# ⚠ 여기서 갈아야 아래 기하·소단이 전부 새 경사를 따른다. 계산이 끝난 뒤 결과에 값만
# 베껴 붙이면 설계선은 옛 경사로 그려지고 숫자만 새것이 되어 어긋난다.
if cut_slope_ratio is not None and float(cut_slope_ratio) > 0:
group = {**group, "cut_slope_ratio": float(cut_slope_ratio)}
paved_group = _resolve_group("paved", standard)
# 포장 중첩: 횡단경사와 포장층 두께만 포장 그룹을 따른다.
cross_slope_pct = paved_group["cross_slope_pct"] if paved else group["cross_slope_pct"]
if tilted and tilt_pct is not None and float(tilt_pct) > 0:
cross_slope_pct = float(tilt_pct) # 구조물 자리 경사(287-4 · 288-1)
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_choice`, 옛 저장분은 `ditch_enabled` 로 온다(자동값과 다를 때만 뜻).
ditch_choice=ditch_choice,
legacy_ditch_enabled=ditch_enabled,
widening_left_m=widening_left,
widening_right_m=widening_right,
berm=berm,
shoulder_extend_left_m=shoulder_extend_left_m,
shoulder_extend_right_m=shoulder_extend_right_m,
tilted=bool(tilted),
ground_span=(valid[0][0], valid[-1][0]),
)
# 적분 오프셋 = 지반 샘플 ∪ 설계 꼭짓점(샘플 범위 안쪽만). 꼭짓점을 넣어야
# 측구 모서리·노면 끝이 잘리지 않아 면적과 설계선이 정확해진다.
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] = []
grounds: 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)
grounds.append(ground_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)
# 층따기 밑수(길이 m) — 성토부 아래 원지반이 1:4 보다 급한 구간의 지표면 길이.
# 면적(㎡)은 측점 사이를 평균단면적법으로 이어 B08 이 낸다(2026-09-09 사용자 확정).
bench_cut_length = _bench_cut_length(offsets, grounds, 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"],
}
# 측구터파기 토사/암 분리 — **새 입력을 만들지 않는다.** 절토 분리와 같은 근거
# (지반 유형 + 암반 경계선)를 그대로 쓴다. 별표2 Ⅰ.1.나.(5) 「측구터파기 단면적」이
# 횡단도 표의 법정 칸이라 반만 채워 나가면 안 된다(2026-09-09).
# 근거가 없으면 **나누지 않고** 사유를 함께 내보낸다 — 절반을 임의로 가르지 않는다.
if not geometry.has_ditch:
ditch_soil_area, ditch_rock_area = 0.0, 0.0
ditch_split_basis = "no_ditch"
elif preset_key != "rock":
# 토사 지반 — 암반 경계선 자체가 없다. 전량 토사(절토 분리와 같은 판정).
ditch_soil_area, ditch_rock_area = ditch_area, 0.0
ditch_split_basis = "soil_ground"
elif rock_boundary_offset_m is None or not geometry.ditch_points:
# 암 지반인데 경계선 값이 없다(구 데이터) — 가를 근거가 없으므로 전량 암.
ditch_soil_area, ditch_rock_area = 0.0, ditch_area
ditch_split_basis = "rock_ground_no_boundary"
else:
ditch_top_z = geometry.ditch_points[0][1]
mid_offset = sum(point[0] for point in geometry.ditch_points) / len(geometry.ditch_points)
boundary_z = ground_at(mid_offset) - abs(float(rock_boundary_offset_m))
ditch_soil_area, ditch_rock_area = _split_ditch_area(ditch_spec, ditch_top_z - boundary_z)
ditch_split_basis = "rock_boundary"
# 자동 판정된 절/성토 역할에서 실제 단면 유형을 도출해 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),
# 확폭 = 곡선부 몫만 — 사면 이동(151-2)이 넓힌 차도는 뺀다(TS 짝 같음).
"widening_left_m": round(
geometry.half_road_left - geometry.half_road - geometry.shoulder_extend_left, 4
),
"widening_right_m": round(
geometry.half_road_right - geometry.half_road - geometry.shoulder_extend_right, 4
),
"cross_slope_pct": round(cross_slope_pct, 4),
"ditch": ditch_spec,
# 결과 — **실제로 섰나**. 읽는 쪽 여섯이 이 뜻으로 쓴다.
"ditch_enabled": bool(geometry.has_ditch),
# 선택 — **사용자가 정한 것**(`None` = 자동). 결과와 갈라 둔다(2026-09-09).
"ditch_choice": geometry.ditch_choice,
"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),
# 층따기 밑수 — 성토부 아래 원지반(1:4 보다 급한 구간)의 지표면 길이(m).
# B08 이 측점 사이를 이어 ㎡ 로 만든다. 여기서 ㎥ 로 바꾸지 않는다 —
# 단의 높이·폭이 설계도서 값이라 지어낼 수 없다.
"bench_cut_length_m": round(bench_cut_length, 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),
# 측구터파기 내역(합=ditch_area_m2). 가른 근거는 `ditch_split_basis` 로 함께 낸다:
# rock_boundary(암반 경계선으로 가름) · soil_ground(토사 지반이라 전량 토사) ·
# rock_ground_no_boundary(암 지반인데 경계선 없음 — 전량 암) · no_ditch(측구 없음).
"ditch_soil_area_m2": round(ditch_soil_area, 4),
"ditch_rock_area_m2": round(ditch_rock_area, 4),
"ditch_split_basis": ditch_split_basis,
"design_line": design_line,
# 절토 사면을 경사 구간별로 쪼갠 목록(소단 제외).
# ⚠ **지금 이 값을 읽는 곳은 없다**(2026-09-07). 원래 임자였던 별표2 법정 경사 검사는
# 폐기됐고(암질을 횡단도에서 안 고르기로 사용자 확정), 저장분(`cross_sections.data.design`
# 33키)에도 안 들어간다 — 저장되는 것은 화면이 보낸 설계 지정값이다.
# 그래도 남겨 두는 까닭: 소단 기하가 이 셈 위에 서 있고, 구간 경사를 아는 유일한 값이라
# 뒤에 쓸 자리가 있다. **되살릴 때는 저장분에서 읽지 말고 계산해서 쓸 것.**
"cut_slope_segments": geometry.cut_slope_segments(),
}
if tilted:
result["tilted"] = True
for side, reach in geometry.reach.items(): # 낮은 쪽 늘인 노면 끝(294-3)
signed = reach if side == "left" else -reach
result["tilt_reach"] = {
"side": side,
"offset_m": round(signed, 4),
"elevation_m": round(geometry.road_z(signed), 4),
}
if tilt_pct is not None:
result["tilt_pct"] = round(float(tilt_pct), 4)
if drop > 0:
# 내려 앉힌 양 — 프론트가 "월류가 없었다면" 노면을 점선으로 되그리는 데 쓴다.
result["surface_drop_m"] = round(drop, 4)
if berm is not None:
# 소단 제원을 설계에 되싣는다 — 세션이 비어도(확정 뒤·다른 PC) 저장분만으로
# 계단이 다시 서야 한다. 암 경계선 오프셋을 echo 하는 것과 같은 까닭이다.
result["berm"] = {
"width_m": round(float(berm.width_m), 4),
"interval_m": round(float(berm.interval_m), 4),
"slope_deg": round(float(berm.slope_deg), 4),
"depth_m": round(max(float(berm.depth_m), 0.0), 4),
}
# 쪽마다 옮긴 깊이(194-1) — 옮긴 쪽만 되싣는다(사용자 값 · 다시 셀 때 입력)
for side, own in (("left", berm.depth_left_m), ("right", berm.depth_right_m)):
if own is not None:
result[f"berm_depth_{side}_m"] = round(max(float(own), 0.0), 4)
# 노견 확장(139-6) — 넓힌 쪽만 되싣는다(사용자 값 · 다시 셀 때 입력)
for side, extend in (
("left", geometry.shoulder_extend_left),
("right", geometry.shoulder_extend_right),
):
if extend > 0:
result[f"shoulder_extend_{side}_m"] = round(extend, 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