"""사면길이 유도 — 저장된 횡단 설계선에서 절토·성토 사면 구간을 가려낸다 (B08 일감 3). 왜 유도하나 (B06 무접촉) 설계 엔진이 `cut_slope_segments` 를 내기는 하나 **정본에 저장되지 않는다**(실측: route 150 측점 20.0 의 저장 `design` 키 33개에 그 키가 없음). 저장되는 것은 화면이 보낸 설계 지정이고 조회는 저장분을 그대로 싣는다. 그래서 그 값을 원천으로 쓰면 사면적이 조용히 0 이 된다. 대신 **`design_line`(설계선 폴리라인) + 저장된 경사비**로 유도한다. 필요한 입력이 전부 정본에 있어 B06 을 고치지 않아도 된다. 가려내는 방법 노체 끝(`road_edges`)에서 바깥으로 나아가며, 구간 기울기가 **저장된 설계 경사비와 맞는 동안**이 사면이다. 원지반은 기울기가 안 맞아 저절로 끊긴다. 2단 사면(암/토사)도 경사비가 달라 그대로 갈린다. 실측(측점 20.0, 절토 0.4 · 토사절토 1.0 · 성토 1.2, 노체 끝 ±2.0): -2.90 → -2.60 n=1.0 측구 바깥 벽 -3.50 → -2.90 n=0.4 절토 사면(암) -4.50 → -3.51 n=1.0 절토 사면(토사) -5.00 → -4.85 n=1.63 원지반 — 여기서 멈춘다 ⚠ 두 가지를 조심한다 · **끝 조각은 딱 안 떨어진다** — 샘플 격자에 걸려 잘리면 `n=1.025` 처럼 나온다. 허용오차를 둔다. · **지형이 우연히 같은 경사면** 사면이 길게 잡힐 수 있다. 노체에서 바깥으로 **연속**인 구간만 세고 끊기면 멈추는 것으로 막는다. """ from __future__ import annotations import math from dataclasses import dataclass from typing import Any, Iterable # 경사비 일치 허용오차(비율). 끝 조각이 격자에 잘려 생기는 오차를 덮는 크기다. _RATIO_TOLERANCE = 0.12 # 평탄부로 볼 기울기 — 소단·측구 바닥은 오름이 거의 없다. _FLAT_RISE_M = 1e-6 @dataclass(slots=True) class SlopeSegment: """사면 한 조각. `side` 는 `left`/`right`, `role` 은 `cut`/`fill`.""" side: str role: str from_offset_m: float to_offset_m: float rise_m: float length_m: float ratio: float material: str | None = None @dataclass(slots=True) class StationSlope: """측점 하나의 사면길이 묶음. 면적 적분이 이 값을 거리로 쓴다.""" chainage_m: float cut_length_m: float = 0.0 fill_length_m: float = 0.0 #: 층따기 밑수 — **원지반 표면**의 경사길이(m). B06 설계가 측점마다 낸다 #: (`design.bench_cut_length_m`, 2026-09-09 랩탑 메인). #: ⚠ **성토 비탈면 길이와 다른 면이다** — 층따기는 성토부 **아래 원지반**을 계단으로 #: 깎는 일이라(교본 6장 4절) 비탈면이 아니라 지표면을 따라간다. 앞서 성토 사면길이를 #: 밑수로 쓰고 있었는데 **면이 달랐다.** bench_cut_length_m: float = 0.0 berm_width_m: float = 0.0 # 성토고(m) — 성토 사면 조각들의 **수직 낙차 합**. 노면 끝에서 원지반까지 내려간 높이다. # ⚠ 좌우가 다르면 **큰 쪽**을 쓴다. 「중심점 성토고 5m 이상」(품셈 11-3 [주]①) 판정은 # 가장 높은 쪽이 기준이고, 양쪽을 더하면 실제보다 두 배가 된다. fill_height_m: float = 0.0 segments: tuple[SlopeSegment, ...] = () # 사면이 샘플 범위 끝까지 원지반을 못 만나 **면적이 잘린** 측점. # 설계 엔진이 `slope_unclosed` 로 이미 경고하는 값을 그대로 물고 온다. 잘린 측점은 # 사면길이도 같이 잘려 있으므로 **조용히 적게 내지 말고 화면에 드러내야 한다.** unclosed: bool = False def _num(value: Any) -> float | None: return float(value) if isinstance(value, (int, float)) else None def _ratios(design: dict[str, Any]) -> dict[str, list[float]]: """역할별로 받아들일 경사비 목록. 2단 사면이면 암·토사 둘 다 절토로 본다.""" cut = [ value for value in ( _num(design.get("cut_slope_ratio")), _num(design.get("soil_cut_slope_ratio")), ) if value is not None and value > 0 ] fill = [value for value in (_num(design.get("fill_slope_ratio")),) if value and value > 0] return {"cut": cut, "fill": fill} def _match(ratio: float, candidates: list[float]) -> float | None: """구간 경사비가 후보 중 하나와 맞으면 그 후보를 돌려준다.""" for candidate in candidates: if abs(ratio - candidate) <= max(_RATIO_TOLERANCE * candidate, _RATIO_TOLERANCE): return candidate return None def _outward( line: list[dict[str, float]], edge_offset: float, side: str ) -> list[tuple[float, float, float, float]]: """노체 끝에서 **바깥으로** 향하는 구간 목록 `(시작오프셋, 끝오프셋, run, rise)`. 좌측은 오프셋이 커지는 쪽, 우측은 작아지는 쪽이 바깥이다(설계선 좌표 관례). """ points = sorted( ((float(p["offset_m"]), float(p["elevation_m"])) for p in line), key=lambda p: p[0] ) if side == "left": outer = [p for p in points if p[0] >= edge_offset] else: outer = [p for p in points if p[0] <= edge_offset][::-1] return [ ( outer[i - 1][0], outer[i][0], abs(outer[i][0] - outer[i - 1][0]), outer[i][1] - outer[i - 1][1], ) for i in range(1, len(outer)) ] def slope_start_offset(design: dict[str, Any], side: str) -> float | None: """사면이 시작하는 오프셋 — 노체 끝, 측구가 있으면 **측구 바깥 끝**. ⚠ 이것이 없으면 **측구 바깥 벽이 사면으로 잡힌다.** 실측(측점 20.0)에서 측구 벽 경사가 n=1.0 으로 토사 절토비와 같아 그대로 걸렸다. 측구는 노체 배수 시설이지 사면이 아니므로 그 바깥 끝에서부터 세어야 한다. """ edges = design.get("road_edges") or {} edge = _num((edges.get(side) or {}).get("offset_m")) if edge is None: return None if not design.get("ditch_enabled"): return edge ditch_side = design.get("ditch_side") if ditch_side not in (side, "both", None): return edge width = _num((design.get("ditch") or {}).get("top_width_m")) or 0.0 # 좌측은 오프셋이 커지는 쪽, 우측은 작아지는 쪽이 바깥이다. return edge + width if side == "left" else edge - width def _side_segments( design: dict[str, Any], side: str, ratios: dict[str, list[float]] ) -> list[SlopeSegment]: """한쪽 사면 구간 목록. 경사비가 안 맞는 구간을 만나면 거기서 멈춘다.""" line = design.get("design_line") or [] edge = slope_start_offset(design, side) if not line or edge is None: return [] berm = design.get("berm") or {} berm_width = _num(berm.get("width_m")) or 0.0 segments: list[SlopeSegment] = [] started = False for start, end, run, rise in _outward(line, float(edge), side): if run <= 1e-9: continue if abs(rise) <= _FLAT_RISE_M: # 평탄부 — 측구 바닥·소단. 사면이 시작된 뒤라면 소단으로 보고 이어 간다. if started and berm_width > 0 and abs(run - berm_width) < 0.05: continue if started: break # 사면이 끝나고 평지를 만난 것이다 continue ratio = run / abs(rise) # 절토는 바깥으로 갈수록 오르고, 성토는 내려간다. role = "cut" if rise > 0 else "fill" matched = _match(ratio, ratios[role]) if matched is None: if started: break # 원지반에 닿았다 continue # 아직 노체·측구 구간이다 started = True segments.append( SlopeSegment( side=side, role=role, from_offset_m=start, to_offset_m=end, rise_m=rise, length_m=math.hypot(run, rise), ratio=matched, material=_material(design, matched), ) ) return segments def _material(design: dict[str, Any], ratio: float) -> str | None: """경사비로 재료를 가른다 — 그린 대로 적는다(B06 `cut_slope_segments` 주석과 같은 규칙).""" if not design.get("two_stage_slope"): return None rock = _num(design.get("cut_slope_ratio")) soil = _num(design.get("soil_cut_slope_ratio")) if rock is None or soil is None or abs(rock - soil) < 1e-9: return None return "rock" if abs(ratio - rock) < abs(ratio - soil) else "soil" def station_slope(chainage_m: float, design: dict[str, Any]) -> StationSlope: """측점 하나의 사면길이. 좌우를 합쳐 절토·성토 각각의 총 사면길이를 낸다.""" ratios = _ratios(design) segments: list[SlopeSegment] = [] for side in ("left", "right"): segments.extend(_side_segments(design, side, ratios)) berm = design.get("berm") or {} fill_by_side = { side: sum(abs(s.rise_m) for s in segments if s.role == "fill" and s.side == side) for side in ("left", "right") } return StationSlope( chainage_m=float(chainage_m), cut_length_m=sum(s.length_m for s in segments if s.role == "cut"), fill_length_m=sum(s.length_m for s in segments if s.role == "fill"), # ⚠ 없는 측점은 0 이다 — 성토 사면길이로 **대신 채우지 않는다**(면이 다름). bench_cut_length_m=_num(design.get("bench_cut_length_m")) or 0.0, fill_height_m=max(fill_by_side.values(), default=0.0), berm_width_m=_num(berm.get("width_m")) or 0.0, segments=tuple(segments), unclosed=bool(design.get("slope_unclosed")), ) def station_slopes(records: Iterable[dict[str, Any]]) -> list[StationSlope]: """`[{chainage_m, design}]` → 측점별 사면길이. 이정 순으로 낸다.""" result = [ station_slope(item["chainage_m"], item.get("design") or {}) for item in records if item.get("chainage_m") is not None ] result.sort(key=lambda s: s.chainage_m) return result