"""B06 성토사면 경사길이 교차검증 헬퍼 (2026-09-03). 화면(`B06_Section_UI_Cross_Fit.fillSlopeLengths`)이 낸 값을 **API 원본으로 다시 계산해** 대조한다. 프론트 구현과 독립적으로 설계선·지반선만 보고 재는 것이 목적이라 pytest 가 아니라 실행 스크립트다. 1) 공용 브라우저에서 `/api/projects/{pid}/sections/{route}/detail` 응답을 파일로 저장 2) ./venv/Scripts/python.exe resources/tester/helper_b06_fill_slope_length.py <그 파일> [측점...] 「≥」는 사면이 설계선 끝(계산 반폭)까지 원지반을 못 만난 측점 — 거기까지의 하한값이다. 허용 오차 — 화면은 교차 판정에 2cm 허용오차(`MEET_TOLERANCE_M`)를 쓰고 여기서는 부호 전환만 보므로, 사면이 지반과 나란히 붙는 자리에서 최대 0.15m 차이가 난다(2026-09-03 실측). """ import json import math import pathlib import sys def interp(points): """(offset, elevation) 목록의 선형보간 함수와 정의역 양 끝을 준다.""" pts = sorted(points, key=lambda t: t[0]) def at(x): if x <= pts[0][0]: return pts[0][1] if x >= pts[-1][0]: return pts[-1][1] lo, hi = 0, len(pts) - 1 while hi - lo > 1: mid = (lo + hi) // 2 if pts[mid][0] <= x: lo = mid else: hi = mid (x0, z0), (x1, z1) = pts[lo], pts[hi] return z0 if x1 == x0 else z0 + (z1 - z0) * (x - x0) / (x1 - x0) return at, pts[0][0], pts[-1][0] def slope_starts(design): """좌·우 사면 시작 오프셋 — 노견 끝(그 측에 측구가 있으면 측구 바깥).""" edges = design["road_edges"] left = max(edges["left"]["offset_m"], edges["right"]["offset_m"]) right = min(edges["left"]["offset_m"], edges["right"]["offset_m"]) spec = design.get("ditch") or {"type": "none"} width = 0.0 if design.get("ditch_enabled") is not False and spec.get("type") != "none": width = spec["top_width_m"] if spec["type"] == "standard" else spec["width_m"] return { "left": left + (width if design.get("ditch_side") == "left" else 0.0), "right": right - (width if design.get("ditch_side") == "right" else 0.0), } def fill_slope_lengths(section, step=0.005): """성토측 사면 경사길이 — {side: (길이 m, 미교차 여부)}.""" design = section["design"] ground_at, _, _ = interp( [ (p["offset_m"], p["elevation_m"]) for p in section["samples"] if p.get("valid") is not False and p.get("elevation_m") is not None ] ) design_at, line_min, line_max = interp( [(p["offset_m"], p["elevation_m"]) for p in design["design_line"]] ) starts = slope_starts(design) slant = math.hypot(1, 1 / design["fill_slope_ratio"]) result = {} for side in ("left", "right"): if design["section_mode"] in ("both_cut", f"{side}_cut"): continue outward = 1 if side == "left" else -1 start = starts[side] limit = line_max if side == "left" else line_min previous = design_at(start) - ground_at(start) offset, meet = start, None while (limit - offset) * outward > 0: offset = start + outward * min(abs(offset - start) + step, abs(limit - start)) diff = design_at(offset) - ground_at(offset) if previous != 0 and (diff > 0) != (previous > 0): # 부호가 뒤집힌 두 걸음 사이를 선형보간한 자리가 사면 끝이다. meet = offset - outward * step + outward * step * (previous / (previous - diff)) break previous = diff end = limit if meet is None else meet result[side] = (abs(end - start) * slant, meet is None) return result def main(argv): detail = json.loads(pathlib.Path(argv[1]).read_text(encoding="utf-8")) want = {round(float(value), 1) for value in argv[2:]} for section in detail["cross_sections"]: if not section.get("design"): continue chainage = round(section["chainage_m"], 1) if want and chainage not in want: continue lengths = fill_slope_lengths(section) if not lengths: continue text = " ".join( f"{side}={'≥' if open_ else ''}{length:.2f}m" for side, (length, open_) in lengths.items() ) print(f"ch={chainage:8.1f} {section['design']['section_mode']:10s} {text}") if __name__ == "__main__": main(sys.argv)