"""노선이 바뀔 때 구조물이 땅 위 자리(좌표)를 따라감 — 누가거리를 다시 매김(344-2). 사용자 확정(10-16 · 341 결정 2): 관 · 구조물의 자리 정본 = 좌표(x · y) · 누가거리는 그 자리를 지금 노선에서 읽은 값. 노선이 바뀌면 좌표를 새 노선에 투영해 누가거리를 다시 매기고(정수 m · 234-1), 새 노선에서 `STRUCTURE_ROUTE_DRIFT_MAX_M` 넘게 멀면 「노선에서 멀어짐」 표시만(버리지 않음). - `fill_structure_coordinates` — 저장 때 노선 위 기준 자리로 x · y 채움(자리가 그대로면 옛 좌표 지킴 · 보낸 좌표가 그 누가거리를 낳으면 살림 · 410) · 옛 자료는 읽을 때 없는 것만 채움. - `follow_structures` — 좌표 → 새 노선 누가거리 · 구간 끝은 같은 만큼 밂 · 노선과의 거리 (셈 = TS `followStructures` · Node 입구 `facility.follow` · 472-4 ①). - `route_outside` — 노선 밖(시점 앞 · 종점 뒤 0.51 m 넘음) · 측점 세울 때 셈(459 · 정본에 안 씀). 짝 = TS `common_util/common_util_route_follow.ts` `followStructures`(구조물) · `projectPipePoints` (관 — 파이썬 본체 = `common_util_drainage_pipes.project_pipe_points`) · `placeOnLine`(좌표 낡음 판정) · 거울 시험 = `resources/tester/test_344_2_route_pairs.py` · `test_410_follow_xy.py`. """ from __future__ import annotations import math from typing import Any, Iterable, Sequence from shapely.geometry import LineString, Point from common_util.common_util_calc_runner import run_calc from common_util.common_util_structure_station import ( SAME_STATION_M, route_general_stations, snap_existing_station, snap_structure_station, ) from config.config_system import SECTION_STATION_INTERVAL_M, STRUCTURE_ROUTE_DRIFT_MAX_M _POSITION_KEYS = ("chainage_m", "start_m", "end_m") _FOLLOW_KEYS = ("placement", *_POSITION_KEYS, "x", "y") #: 노선 밖 판정(m) — 투영이 시점 앞 · 종점 뒤로 이만큼 넘으면 측점 안 세움(459 · TS 짝). ROUTE_OUTSIDE_M = 0.51 def _line(xy: Sequence[Sequence[float]]) -> LineString | None: points = [(float(point[0]), float(point[1])) for point in xy] if len(points) < 2: return None line = LineString(points) return line if line.length > 0 else None def route_outside( x: float | None, y: float | None, xy: Sequence[Sequence[float]], tol_m: float = ROUTE_OUTSIDE_M ) -> bool: """노선 밖인가(459) — 가장 가까운 자리가 시점 · 종점이고 그 끝 선분 연장으로 `tol_m` 넘게 나감 (옆으로 먼 것은 아님) · 좌표 없으면 거짓. TS `routeOutside` 짝 · 측점 계획에서 뺌.""" line = _line(xy) if line is None or x is None or y is None: return False at = float(line.project(Point(float(x), float(y)))) if 0.0 < at < float(line.length): return False coords = list(line.coords) (ax, ay), (bx, by) = (coords[0], coords[1]) if at <= 0.0 else (coords[-1], coords[-2]) span = math.hypot(bx - ax, by - ay) if span <= 0.0: return False return -((float(x) - ax) * (bx - ax) + (float(y) - ay) * (by - ay)) / span > tol_m def _positions(item: Any) -> tuple[Any, ...]: return tuple(getattr(item, key, None) for key in _POSITION_KEYS) def _holds_station(item: Any, line: LineString) -> bool: """좌표가 그 기준 측점을 낳는가 — 같은 노선 따라감이 누가거리를 안 옮김(TS `holdsStation`).""" at = float(line.project(Point(float(item.x), float(item.y)))) length = float(line.length) target = ( snap_existing_station(at, route_general_stations(length)) if getattr(item, "placement", None) == "site" else snap_structure_station(at, [length]) ) anchor = float(item.chainage_m) shift = target - anchor return abs(min(max(anchor + shift, 0.0), length) - anchor) <= SAME_STATION_M def fill_structure_coordinates( items: Iterable[Any], xy: Sequence[Sequence[float]], *, previous: dict[str, Any] | None = None, only_missing: bool = False, ) -> int: """구조물 x · y 를 노선 위 기준 자리(`chainage_m`)로 채운다(제자리) · 채운 수. `previous` = 저장 전 정본 {structure_id: 항목} — 자리(누가거리 셋)가 그대로면 옛 좌표를 지킨다 (노선이 바뀌어 멀어진 땅 자리를 다음 저장이 노선 위로 덮지 않게). 보낸 좌표가 그 누가거리를 낳으면(지금 노선 따라감 = 제자리) 그 좌표를 살린다(410 · 옮긴 자리 · 따라간 땅 자리) — 낡은 좌표(누가거리만 옮김)는 노선 위로. TS 짝 = `placeOnLine`. `only_missing` = 읽을 때. """ line = _line(xy) if line is None: return 0 filled = 0 for item in items: anchor = getattr(item, "chainage_m", None) if anchor is None: continue has_xy = getattr(item, "x", None) is not None and getattr(item, "y", None) is not None if only_missing and has_xy: continue old = (previous or {}).get(getattr(item, "structure_id", None) or "") if old is not None and _positions(old) == _positions(item) and old.x is not None: item.x, item.y = old.x, old.y continue if has_xy and _holds_station(item, line): continue point = line.interpolate(float(anchor)) item.x, item.y = round(float(point.x), 4), round(float(point.y), 4) filled += 1 return filled def follow_structures( items: Iterable[Any], xy: Sequence[Sequence[float]], max_drift_m: float = STRUCTURE_ROUTE_DRIFT_MAX_M, ) -> list[tuple[float | None, bool]]: """좌표를 새 노선에 투영해 누가거리를 다시 매긴다(제자리) → 항목마다 (노선과 거리, 멀어짐). 기준 자리 = 투영 → 정수 m(노선 끝 안) · 구간 끝(`start_m` · `end_m`)은 같은 만큼 밀고 0 ~ 연장 안으로 자름. 좌표가 없는 옛 자료는 그대로(거리 None) · site 꼴(사토장 · 토취장) = 새 노선 일반 측점 중 가장 가까운 곳(380-3). 셈 = TS `followStructures`(Node 입구 `facility.follow` · 472-4). """ items = list(items) line = _line(xy) if line is None or not items: return [(None, False)] * len(items) rows = [{key: getattr(item, key, None) for key in _FOLLOW_KEYS} for item in items] results = run_calc( ["facility.follow"], { "follow": { "items": rows, "line": [[x, y] for x, y in line.coords], "max_drift_m": max_drift_m, "interval_m": SECTION_STATION_INTERVAL_M, } }, ) if results is None: raise RuntimeError("구조물 따라감 실패 — 계산 Node 입구(facility.follow)") out: list[tuple[float | None, bool]] = [] for item, row in zip(items, results["facility.follow"], strict=True): for key in _POSITION_KEYS: if getattr(item, key, None) is not None: setattr(item, key, float(row["item"][key])) out.append((row["drift_m"], bool(row["far"]))) return out