"""관 매설 지점 정본 저장소 (B04 관리자 화면 · B05 사용자 화면 공용). 해석 산출물(`01~03`)은 다시 돌리면 덮어써도 되지만, 사용자가 찍고 옮긴 관은 그러면 안 된다. 그래서 편집분만 `{배수유역 폴더}/edits/pipe_points.json`에 따로 남기고 두 화면이 같은 파일을 읽고 쓴다 — 관리자 화면에서 옮긴 관이 사용자 화면에서 다르게 보이면 안 되기 때문이다 (2026-08-01 사용자 지시). 위치는 **누가거리(chainage_m) + 좌표(x, y)** 로 저장한다. 지면 필터나 지표면 모델을 바꾸면 종단 Z가 달라지지만 관이 놓인 자리는 그대로여야 하고, 그때는 세부유역만 다시 나누면 된다. 좌표를 같이 남기는 이유(2026-08-30 사용자 지적 — "결국 노선 위에 위치해야 한다"): 관 자리를 정한 선(계획노선 정본)과 화면에 그려지는 선(B05 최적 경로)은 **같은 자리를 지나면서 연장이 다르다**(실측 350.11m vs 354.83m). 누가거리만 남기면 읽는 쪽이 쥔 선에 따라 같은 값이 3~4m 미끄러져 관이 선 옆에 떨어진 것처럼 보인다. 좌표를 남겨 두면 어느 선으로 읽든 그 좌표를 투영해 **항상 선 위에** 앉힐 수 있다. 노선이 바뀌면(`route_signature` 불일치) 좌표가 있는 저장분은 새 노선에 투영해 이월하고, 좌표가 없는 구 저장분만 버린다. """ from __future__ import annotations import hashlib import json import logging from dataclasses import dataclass from pathlib import Path from typing import Any from shapely.geometry import LineString, Point from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import drainage_dir from common_util.common_util_json import atomic_write_json from common_util.common_util_route_geometry import RouteVertex, interpolate_vertex from config.config_system import ( DRAINAGE_CACHE_DIRNAME, DRAINAGE_DETAIL_FILENAME, DRAINAGE_EDITS_DIRNAME, DRAINAGE_PIPE_POINTS_FILENAME, ) logger = logging.getLogger(__name__) # 관이 그 자리에 있는 이유. 화면 마커 모양과 "자동/수동" 구분이 여기에 달려 있다. PIPE_SOURCE_STREAM = "stream" # 기본 관 — 도로 × 상류 세류선 교차점 PIPE_SOURCE_SPACING = "spacing" # 자동 보충 — 관 최대 간격 규칙 PIPE_SOURCE_USER = "user" # 수동 — 사용자가 우클릭으로 추가하거나 옮긴 관 _KNOWN_SOURCES = (PIPE_SOURCE_STREAM, PIPE_SOURCE_SPACING, PIPE_SOURCE_USER) # 계곡 통과 시설 종류 (2026-08-17 컨테이너 병합). 같은 계곡 교차 지점에서 유량·지형에 # 따라 택일하는 관계라 별도 정본을 만들지 않고 관 지점에 종류만 얹는다 — 유역 계산은 # 기준점(chainage_m)만 읽으므로 어느 종류든 계산이 같다. 교량은 임도용이 아니라 없다 # (2026-08-17 사용자 확정). PIPE_FACILITY_PIPE = "pipe" # 배관(횡단배수관) — 기본 PIPE_FACILITY_BOX = "box_culvert" # BOX암거 PIPE_FACILITY_FORD_PAVEMENT = "ford_pavement" # 물넘이포장 PIPE_FACILITY_FORD_BRIDGE = "ford_bridge" # 세월교 # 독립 기슭막이(2026-08-28 사용자) — 배관 없이 성토 사면에 세우는 벽. 배관 세트 경로를 # 그대로 태우되 관을 숨긴다(hidden_pipe). 수량은 관 정보를 빼고 벽만 센다. PIPE_FACILITY_REVET = "revetment" # 독립 기슭막이(관 숨김) _KNOWN_FACILITIES = ( PIPE_FACILITY_PIPE, PIPE_FACILITY_BOX, PIPE_FACILITY_FORD_PAVEMENT, PIPE_FACILITY_FORD_BRIDGE, PIPE_FACILITY_REVET, ) @dataclass class PipePoint: """계획선 위 계곡 통과 시설 한 개 (배관·BOX암거·물넘이·세월교). `chainage_m`가 기준점(계곡 교차, 종단 마킹 위치)이고, `start_m`/`end_m`는 유입·유출 부속이 차지하는 앞뒤 구간이다. 구간 미지정(None)은 폭 0 — 자동 배치분의 기본값이며 사용자가 필요할 때 벌린다. 상세 치수는 B06/B07 몫이라 여기에는 유무·종류 수준의 `options`(예: 세월교 관 종류/크기/수량)만 둔다 (2026-08-17 사용자 확정). """ chainage_m: float source: str = PIPE_SOURCE_USER facility: str = PIPE_FACILITY_PIPE start_m: float | None = None end_m: float | None = None options: dict[str, Any] | None = None # 관이 실제로 놓인 자리(사업지 CRS, m). 노선이 바뀌어도 이 자리는 그대로다. x: float | None = None y: float | None = None def as_dict(self) -> dict[str, Any]: # 구 형식 저장분이 확장 필드 없이 그대로 다시 저장되도록 기본값은 생략한다. payload: dict[str, Any] = { # 누가거리는 좌표(x·y)와 **같은 밀리미터 기준**으로 남긴다. cm 로 반올림하던 # 옛 규칙은 종단 정본 변화점(3자리)과 최대 5mm 어긋났고, 그 값으로 계획고를 # 편집하면 정본 옆 mm 자리에 가짜 변화점이 하나 더 서서 종단곡선이 mm 로 # 쭈그러들었다 — 그 구간 기울기가 155,791% 로 튀었다(2026-09-04 사용자 보고). "chainage_m": round(float(self.chainage_m), 3), "source": self.source, } if self.facility != PIPE_FACILITY_PIPE: payload["facility"] = self.facility if self.start_m is not None and self.end_m is not None: payload["start_m"] = round(float(self.start_m), 2) payload["end_m"] = round(float(self.end_m), 2) if self.options: payload["options"] = self.options if self.x is not None and self.y is not None: payload["x"] = round(float(self.x), 3) payload["y"] = round(float(self.y), 3) return payload def edits_dir(stored_path: str) -> Path: return drainage_dir(stored_path) / DRAINAGE_EDITS_DIRNAME def pipe_points_path(stored_path: str) -> Path: return edits_dir(stored_path) / DRAINAGE_PIPE_POINTS_FILENAME def pipe_points_path_in(project_root: Path) -> Path: """`pipe_points_path`와 같은 자리를 **프로젝트 실경로**로 가리킨다. B05는 스토리지 상대경로가 아니라 실경로를 쥐고 있어 `resolve_stored_project_path`를 다시 태울 수 없다(절대경로를 거절한다). """ return ( project_root / "B04_PreProcess" / DRAINAGE_CACHE_DIRNAME / DRAINAGE_EDITS_DIRNAME / DRAINAGE_PIPE_POINTS_FILENAME ) def detail_basins_path(stored_path: str) -> Path: return drainage_dir(stored_path) / DRAINAGE_DETAIL_FILENAME def route_signature(vertices: list[RouteVertex]) -> str: """노선이 바뀌었는지 판별할 지문. 정점 좌표를 0.01m로 끊어 해시한다. 연장만 보면 노선이 통째로 옮겨져도 같은 값이 나온다. 좌표를 다 넣되 소수점을 끊어 부동소수 잡음으로 지문이 흔들리지 않게 한다. """ digest = hashlib.sha1(usedforsecurity=False) for vertex in vertices: digest.update(f"{vertex.x:.2f},{vertex.y:.2f};".encode()) return f"{len(vertices)}-{digest.hexdigest()[:16]}" def fill_pipe_coordinates(points: list[PipePoint], vertices: list[RouteVertex]) -> list[PipePoint]: """좌표가 비어 있는 관에 그 누가거리의 노선 좌표를 채운다(제자리 수정).""" if not vertices: return points for point in points: if point.x is None or point.y is None: x, y, _ = interpolate_vertex(vertices, float(point.chainage_m)) point.x, point.y = float(x), float(y) return points # 같은 노선으로 볼 누가거리 어긋남의 한계(m). # # 지문(`route_signature`)은 좌표를 **0.01m 자리에서 끊어** 해시한다. 그런데 같은 노선이 # `planned_route.csv`(소수 4자리)와 `route_main.geojson`(소수 3자리, csv 를 mm 로 반올림한 # 사본)로 **0.5mm 다르게** 저장돼 있어, 그 0.5mm 가 `.xx5` 경계를 넘는 정점마다 글자가 # 바뀐다(2026-09-07 실측: 169개 중 **16개**). 노선을 손댄 적이 없는데도 지문이 늘 달랐다. # # 경계에서 자르는 방식은 저장 자릿수가 또 바뀌면 다시 흔들리므로 **글자 일치 대신 # 허용오차**로 가른다. 값은 0.05m — 위 어긋남이 관 누가거리에 미치는 양이 실측 # **최대 0.01m** 이라 다섯 배 여유를 두었고, 사람이 노선을 실제로 고치면 관은 **m 단위**로 # 밀리므로 그것을 「같다」로 볼 위험은 없다. ROUTE_MATCH_TOLERANCE_M = 0.05 def max_projection_shift(points: list[PipePoint], vertices: list[RouteVertex]) -> float | None: """저장된 관을 이 노선에 투영하면 누가거리가 최대 얼마나 움직이나 (**고치지 않고 잰다**). 좌표가 없는 관이 하나라도 있으면 잴 수 없어 None. """ if not vertices or not points: return None if any(point.x is None or point.y is None for point in points): return None line = LineString([(vertex.x, vertex.y) for vertex in vertices]) if line.length <= 0: return None return max( abs(float(line.project(Point(point.x, point.y))) - float(point.chainage_m)) for point in points ) def project_pipe_points(points: list[PipePoint], vertices: list[RouteVertex]) -> list[PipePoint]: """저장된 좌표를 주어진 노선에 투영해 누가거리를 다시 매긴다. 관이 놓인 **자리**는 좌표가 정본이고 누가거리는 그 자리를 읽는 선에 종속된 값이다. 앞뒤 구간(start_m·end_m)은 기준점이 옮겨간 만큼 같이 민다 — 구간 길이는 시설 치수라 노선이 바뀌어도 변하지 않는다. """ if not vertices: return points line = LineString([(vertex.x, vertex.y) for vertex in vertices]) if line.length <= 0: return points for point in points: if point.x is None or point.y is None: continue moved = float(line.project(Point(point.x, point.y))) shift = moved - float(point.chainage_m) point.chainage_m = moved if point.start_m is not None: point.start_m = float(point.start_m) + shift if point.end_m is not None: point.end_m = float(point.end_m) + shift points.sort(key=lambda item: item.chainage_m) return points def load_pipe_points( stored_path: str, signature: str, vertices: list[RouteVertex] | None = None ) -> list[PipePoint] | None: """저장된 관 지점을 읽는다. 파일이 없거나 이월할 수 없으면 None(= 다시 만들어야 함). 노선 지문이 다르면 예전에는 전량 버렸다. 저장분에 좌표가 있으면 `vertices`(읽는 쪽이 쓰는 노선)에 투영해 이월한다 — 같은 자리를 지나면서 연장만 다른 선끼리 관이 통째로 사라지던 것을 막는다(2026-08-30 사용자 지적). """ return load_pipe_points_file(pipe_points_path(stored_path), signature, vertices) def load_pipe_points_file( path: Path, signature: str, vertices: list[RouteVertex] | None = None ) -> list[PipePoint] | None: """`load_pipe_points`와 같되 파일 경로로 직접 읽는다 (B05는 프로젝트 루트를 쥔다).""" if not path.exists(): return None try: with path.open("r", encoding="utf-8") as file: document = json.load(file) except (OSError, json.JSONDecodeError): logger.warning("배수유역: 관 지점 파일을 읽지 못했습니다 (%s).", path) return None points = parse_pipe_points(document.get("points")) stored_signature = str(document.get("route_signature") or "") if stored_signature == signature: return points # 지문이 다르다고 노선이 바뀐 것은 아니다 — 같은 노선을 두 파일이 0.5mm 다르게 담고 # 있어 글자가 늘 어긋난다(위 `ROUTE_MATCH_TOLERANCE_M` 주석). 관이 실제로 얼마나 # 밀리는지 **재 보고** 한계 안이면 저장분을 그대로 쓴다 — 건드리지 않는 것이 정답이다. shift = max_projection_shift(points, vertices) if vertices else None if shift is not None and shift <= ROUTE_MATCH_TOLERANCE_M: logger.info( "배수유역: 지문은 다르나 같은 노선입니다 — 관 %d건 그대로 씁니다 " "(최대 어긋남 %.4fm ≤ %.2fm, %s).", len(points), shift, ROUTE_MATCH_TOLERANCE_M, path.name, ) return points if vertices and points and all(p.x is not None and p.y is not None for p in points): # ⚠ 이 줄이 찍히면 **투영 이월이 실제로 돈 것**이다. 한동안 0 인 것을 확인한 뒤에야 # 이 가지를 지울 수 있다(계획서 0-7 — 먼저 지우면 관이 통째로 사라진다). logger.warning( "배수유역: 투영 이월 실행 — 노선이 바뀌어 관 지점 %d건을 좌표로 옮깁니다 " "(최대 어긋남 %s, %s).", len(points), f"{shift:.3f}m" if shift is not None else "잴 수 없음", path.name, ) return project_pipe_points(points, vertices) logger.info("배수유역: 노선이 바뀌어 저장된 관 지점을 버립니다 (%s).", path.name) return None def _parse_span(item: dict[str, Any], chainage: float) -> tuple[float | None, float | None]: """시작·종료 구간을 정규화한다 — 기준점을 항상 품고, 뒤집힘은 바로잡는다.""" raw_start, raw_end = item.get("start_m"), item.get("end_m") start = float(raw_start) if isinstance(raw_start, (int, float)) else None end = float(raw_end) if isinstance(raw_end, (int, float)) else None if start is None and end is None: return None, None values = [value for value in (start, end) if value is not None] + [chainage] return min(values), max(values) # 보호공 부위별 기본값 (2026-08-17 사용자 확정) — 구 "없음" 저장분을 끌어올릴 때 쓴다. _PROTECTION_FALLBACK = {"inlet": "돌붙임(찰)", "outlet": "돌붙임(메)"} def _migrate_protection(options: dict[str, Any]) -> dict[str, Any]: """구 저장분의 보호공 키를 새 한 축으로 옮긴다 (2026-08-17 보호공 개편). 개편 전에는 `*_pitching`(있음/없음)과 `*_pitching_finish`(찰/메) 두 축이었다. 읽는 순간 `*_protection`(돌붙임(찰)/돌붙임(메)/도수로) 한 축으로 바꿔 두면 화면도 수량도 옛 키를 알 필요가 없다. "없음"은 선택지가 사라졌으므로 부위 기본값으로 올린다 — 물이 흐르는 자리라 보호공은 반드시 있다(사용자 확정). """ for side, fallback in _PROTECTION_FALLBACK.items(): legacy = options.pop(f"{side}_pitching", None) finish = options.pop(f"{side}_pitching_finish", None) area = options.pop(f"{side}_pitching_area_m2", None) if legacy is None and finish is None and area is None: continue if f"{side}_protection" not in options: options[f"{side}_protection"] = ( f"돌붙임({finish})" if legacy == "있음" and finish in ("찰", "메") else fallback ) if area is not None and f"{side}_protection_area_m2" not in options: options[f"{side}_protection_area_m2"] = area return options def parse_pipe_points(values: Any) -> list[PipePoint]: """외부에서 들어온 시설 목록(파일·요청 본문)을 정리한다. 누가거리 오름차순.""" if not isinstance(values, list): return [] points: list[PipePoint] = [] for item in values: if isinstance(item, (int, float)): points.append(PipePoint(chainage_m=float(item))) continue if not isinstance(item, dict): continue chainage = item.get("chainage_m") if not isinstance(chainage, (int, float)): continue source = str(item.get("source") or PIPE_SOURCE_USER) facility = str(item.get("facility") or PIPE_FACILITY_PIPE) start, end = _parse_span(item, float(chainage)) options = item.get("options") raw_x, raw_y = item.get("x"), item.get("y") points.append( PipePoint( chainage_m=float(chainage), source=source if source in _KNOWN_SOURCES else PIPE_SOURCE_USER, facility=facility if facility in _KNOWN_FACILITIES else PIPE_FACILITY_PIPE, start_m=start, end_m=end, x=float(raw_x) if isinstance(raw_x, (int, float)) else None, y=float(raw_y) if isinstance(raw_y, (int, float)) else None, options=( _migrate_protection(dict(options)) if isinstance(options, dict) and options else None ), ) ) points.sort(key=lambda point: point.chainage_m) return points def carry_facility_attributes(base: list[PipePoint], reference: list[PipePoint]) -> list[PipePoint]: """계산기를 거쳐 재구성된 목록에 시설 종류·구간·옵션을 되붙인다. 세부유역 계산기는 chainage만 다루므로 계산에서 돌아온 목록은 전부 기본 배관이 된다. 그대로 저장하면 사용자가 고른 세월교·BOX암거가 배관으로 되돌아간다. 좌표가 미세 조정(스냅)될 수 있어 정확 일치가 없으면 가장 가까운 원본에서 승계한다 — 생성 사유를 되붙이는 `_retag`(B04 라우터)과 같은 기준이다. """ if not reference: return base by_key = {round(ref.chainage_m, 2): ref for ref in reference} for point in base: ref = by_key.get(round(point.chainage_m, 2)) if ref is None: ref = min(reference, key=lambda item: abs(item.chainage_m - point.chainage_m)) point.facility = ref.facility point.start_m = ref.start_m point.end_m = ref.end_m point.options = ref.options return base def save_pipe_points( stored_path: str, signature: str, points: list[PipePoint], vertices: list[RouteVertex] | None = None, ) -> int: """관 지점을 정본 파일에 쓴다. 저장된 개수를 돌려준다. `vertices`를 주면 좌표가 빈 관을 그 노선 위 좌표로 채워 둔다 — 다음에 다른 선으로 읽어도 그 자리에 되놓을 수 있다. """ if vertices: fill_pipe_coordinates(points, vertices) path = pipe_points_path(stored_path) path.parent.mkdir(parents=True, exist_ok=True) atomic_write_json( path, { "route_signature": signature, "points": [point.as_dict() for point in points], }, ) logger.info("배수유역: 관 지점 %d개를 저장했습니다 (%s).", len(points), path.name) return len(points) def read_pipe_points_file(path: Path) -> list[PipePoint]: """저장분을 **노선 지문과 무관하게** 그대로 읽는다. 파일이 없거나 깨졌으면 빈 목록. 지문 대조는 "이 노선에 그려도 되는가"를 가리는 것이고, 여기서 알고 싶은 것은 "그 자리에 이미 시설이 있는가"다(이관 중복 방지). 두 물음이 달라 읽기도 다르다. """ if not path.is_file(): return [] try: with path.open("r", encoding="utf-8") as file: document = json.load(file) except (OSError, json.JSONDecodeError): logger.warning("배수유역: 관 지점 파일을 읽지 못했습니다 (%s).", path) return [] return parse_pipe_points(document.get("points")) def append_pipe_points_file(path: Path, points: list[PipePoint]) -> int | None: """관 지점 정본에 시설을 덧붙인다. 덧붙인 개수, 파일이 없으면 None. 저장 당시 노선 지문과 이미 있는 관은 그대로 둔다 — 여기서 지문을 새로 만들면 읽는 쪽이 노선이 바뀐 것으로 보고 저장분을 통째로 버린다(`load_pipe_points_file`). 지문을 알 수 없는 상황(파일 없음 = B04 배수유역 산출물 없음)에서는 쓰지 않고 None으로 알린다 — 지어낸 지문으로 쓰면 다음 읽기에서 사라진다. """ if not path.is_file(): return None try: with path.open("r", encoding="utf-8") as file: document = json.load(file) except (OSError, json.JSONDecodeError): logger.warning("배수유역: 관 지점 파일을 읽지 못해 덧붙이지 못했습니다 (%s).", path) return None stored = list(document.get("points") or []) atomic_write_json( path, { "route_signature": str(document.get("route_signature") or ""), "points": [*stored, *(point.as_dict() for point in points)], }, ) logger.info("배수유역: 관 지점 %d개를 덧붙였습니다 (%s).", len(points), path.name) return len(points) def clear_pipe_points(stored_path: str) -> bool: """저장된 관 지점과 그 파생물을 지운다. 하나라도 지웠으면 True. "초기화"는 화면만 되돌리는 것이 아니라 **저장분까지** 되돌린다 — 화면만 되돌리면 다시 들어왔을 때 옛 관이 살아나 사용자가 초기화한 적 없는 상태를 보게 된다 (2026-08-02 사용자 보고). """ removed = False for path in (pipe_points_path(stored_path), detail_basins_path(stored_path)): try: path.unlink() removed = True except FileNotFoundError: continue except OSError: logger.warning("배수유역: 저장분을 지우지 못했습니다 (%s).", path) if removed: logger.info("배수유역: 관 지점 저장분을 초기화했습니다 (%s).", stored_path) return removed def save_detail_basins(stored_path: str, features: list[dict[str, Any]], crs: str) -> Path: """세부유역을 GeoJSON으로 남긴다(파생물 — 관 지점만 있으면 언제든 다시 만든다). `crs`는 좌표를 WGS84로 바꿀 때 쓴 **사업지 좌표계**다. 되읽는 쪽(B07 유역도)이 같은 좌표계로 되돌려야 하는데, 예전에는 이 값을 안 남겨 노선 정본의 EPSG 라벨로 되돌렸다 (2026-09-01: 라벨과 실좌표계가 갈린 프로젝트에서 유역이 딴 자리로 갔다). """ path = detail_basins_path(stored_path) path.parent.mkdir(parents=True, exist_ok=True) atomic_write_json(path, {"type": "FeatureCollection", "crs_input": crs, "features": features}) logger.info("배수유역: 세부유역 %d개를 저장했습니다 (%s).", len(features), path.name) return path # ── 횡단배수 최소 계획고 (2026-08-23 사용자 확정) ───────────────────────────── # 계획 종단선의 변화점(PVI)은 배수 시설 자리다. 그 자리에서 계획고를 지반고와 같게 # 두면 시설이 들어갈 자리가 없다 — 시설 제원만큼 계획고를 들어 올려야 한다. # 배수관 Ø1000 → 지반고 + 1.0(관경) + 0.5(토피) = +1.5 # BOX암거 2×2 → 지반고 + 2.0(구체 높이) + 0.5(토피) = +2.5 # 세월교 Ø1000 → 지반고 + 1.0(관경) + 0.5(토피) + 0.5(물넘이 몫) = +2.0 # 물넘이포장 → 도로에 그대로 앉히는 시설이라 요구 여유 없음 (2026-08-23 사용자) # 세월교는 배관을 여러 개 묶어 다리 형태로 만든 것이라 배수관과 같은 산식을 쓰되, # 그 위에 물넘이가 얹히므로 0.5m를 더 얹는다. 토피 0.5m는 B06 배수관 # 엔진(`MIN_PIPE_COVER_M`)과 같은 값이다. MIN_PIPE_COVER_M = 0.5 FORD_BRIDGE_EXTRA_M = 0.5 DEFAULT_PIPE_DIAMETER_MM = 1000.0 DEFAULT_BOX_HEIGHT_M = 2.0 def _positive(value: Any, fallback: float) -> float: try: parsed = float(value) except (TypeError, ValueError): return fallback return parsed if parsed > 0 else fallback def facility_clearance_m(facility: str, options: dict[str, Any] | None) -> float: """시설이 요구하는 지반고 대비 최소 여유(m). 계획선·경고가 같이 쓰는 정본 산식.""" values = options or {} if facility == PIPE_FACILITY_BOX: return _positive(values.get("body_height_m"), DEFAULT_BOX_HEIGHT_M) + MIN_PIPE_COVER_M if facility == PIPE_FACILITY_FORD_PAVEMENT: # 물넘이포장은 도로 위에 그대로 만든다 — 들어 올릴 이유가 없다. return 0.0 if facility == PIPE_FACILITY_REVET: # 독립 기슭막이는 성토 사면에 세우는 벽 — 관이 없어 들어 올릴 여유가 필요 없다. return 0.0 diameter_m = _positive(values.get("pipe_diameter_mm"), DEFAULT_PIPE_DIAMETER_MM) / 1000.0 extra = FORD_BRIDGE_EXTRA_M if facility == PIPE_FACILITY_FORD_BRIDGE else 0.0 return diameter_m + MIN_PIPE_COVER_M + extra def pipe_anchor_clearances(points: list[PipePoint]) -> list[tuple[float, float]]: """계획선 변화점으로 쓸 (누가거리, 최소 여유) 목록. 누가거리 오름차순.""" return sorted( (float(point.chainage_m), facility_clearance_m(point.facility, point.options)) for point in points )