"""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 typing import Any from B06_Section.B06_Section_Engine_Areas import ( _bench_cut_length, _fill_area_beyond, _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 common_util.common_util_spoil_fill import spoil_fill_section 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 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, 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, spoil_fill: dict[str, Any] | 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, 토글로 해제). 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 사용자 확정). 값이 없으면 확폭을 넣지 않는다. surface_drop_m: 노면을 통째로 내리는 양(m) — 세월교 월류 높이. 구체 위 노면은 월류 높이만큼 낮게 앉으므로 계획고를 그만큼 내려 잡는다. 단면 전체가 평행 이동하므로 횡단경사·측구·사면 규칙은 그대로고 절·성토 면적만 따라 바뀐다(2026-08-30 사용자). spoil_fill: 이 측점에 선 유용토운반작업장(구 사토장) — `{"side", "width_m", "slope_ratio_n"}`. 폭은 **노면 끝**(노견이 시작하는 자리)에서 재고, 그 바깥 성토는 **노선 몫이 아니라 사토장 몫**이라 `fill_area_m2` 에서 뺀다(2026-09-09 확정 ㉠ — 두 번 세지 않기). `slope_ratio_n` 이 비면 그 측점의 **노선 성토 기울기**를 그대로 쓴다. """ 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"] 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, ) # 적분 오프셋 = 지반 샘플 ∪ 설계 꼭짓점(샘플 범위 안쪽만). 꼭짓점을 넣어야 # 측구 모서리·노면 끝이 잘리지 않아 면적과 설계선이 정확해진다. 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 ) # 사토장(유용토운반작업장) — 노면 끝 바깥에 쌓는 성토. 짝: TS `computeCrossDesign`. # ⚠ 그 바깥 성토는 **노선 몫이 아니다** — 빼지 않으면 같은 흙을 두 번 센다(확정 ㉠). spoil_section = None spoil_replaced = 0.0 spoil_side = str((spoil_fill or {}).get("side") or "") spoil_width = _as_float((spoil_fill or {}).get("width_m"), 0.0) if spoil_side in ("left", "right") and spoil_width > 0: spoil_x0 = geometry.half_road_left if spoil_side == "left" else -geometry.half_road_right spoil_ratio = _as_float((spoil_fill or {}).get("slope_ratio_n"), 0.0) or geometry.fill_ratio spoil_section = spoil_fill_section( valid, spoil_x0, geometry.road_z(spoil_x0), spoil_side, spoil_width, spoil_ratio, ) spoil_replaced = _fill_area_beyond(offsets, diffs, spoil_x0, spoil_side) fill_area = max(fill_area - spoil_replaced, 0.0) # 절토면적 토사/암반 분리 — 지표면~암반 경계선이 토사, 그 아래가 암이다. 경계선 위치가 # 곧 유토곡선 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), "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), # 선택 — **사용자가 정한 것**(`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), # 사토장 몫 — **`fill_area_m2` 와 합치지 않는다**(받는 쪽이 갈라 볼 수 있어야 한다). "spoil_fill_area_m2": round(spoil_section.area_m2, 4) if spoil_section else 0.0, "spoil_fill_side": spoil_side if spoil_section else None, "spoil_fill_width_m": round(spoil_width, 4) if spoil_section else 0.0, "spoil_fill_max_width_m": round(spoil_section.max_width_m, 4) if spoil_section else 0.0, "spoil_fill_line": ( [ {"offset_m": offset, "elevation_m": elevation} for offset, elevation in spoil_section.line ] if spoil_section else [] ), "spoil_fill_unclosed": bool(spoil_section.unclosed) if spoil_section else False, # 사토장이 대신 차지해 노선 성토에서 뺀 몫(㎡) — 되짚기용. 합계에 또 넣지 말 것. "spoil_fill_replaced_fill_m2": round(spoil_replaced, 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 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), } 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