From a1097653e7e431eaa569659df712c2742ac32b93 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 21:53:38 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05=20=EC=A2=85=EB=8B=A8=20=EC=9D=B4?= =?UTF-8?q?=EC=9B=94):=20=ED=8F=89=EB=A9=B4=20=EA=B3=A0=EC=B9=9C=20?= =?UTF-8?q?=EB=92=A4=20=EA=B0=99=EC=9D=80=20=EC=9E=90=EB=A6=AC(=EC=98=86?= =?UTF-8?q?=201=20m=20=C2=B7=20=EB=B0=A9=ED=96=A5=2030=C2=B0=20=EC=95=88)?= =?UTF-8?q?=20=EC=A2=85=EB=8B=A8=20=ED=8E=B8=EC=A7=91=20=EC=9D=B4=EC=96=B4?= =?UTF-8?q?=EB=B0=9B=EC=9D=8C=20=E2=80=94=20=EC=83=88=20=EC=B8=A1=EC=A0=90?= =?UTF-8?q?=20=EC=9C=84=EC=97=90=20=EC=98=9B=20=EA=B3=84=ED=9A=8D=EA=B3=A0?= =?UTF-8?q?=20=C2=B7=20=EB=B0=94=EB=80=90=20=EC=9E=90=EB=A6=AC=EB=A7=8C=20?= =?UTF-8?q?=EC=83=88=20=EC=A7=80=EB=B0=98=20=EC=B2=AB=20=EA=B3=84=ED=9A=8D?= =?UTF-8?q?=EC=84=A0=20=C2=B7=20=EA=B8=B0=EC=A4=80=20=ED=95=9C=20=EB=B2=8C?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=ED=95=9C=20=EB=B2=88(=EA=B1=B8=EC=9D=8C?= =?UTF-8?q?=20=EC=88=98=20=EB=AC=B4=EA=B4=80=20=C2=B7=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=20=EC=A7=9D)=20(400)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 같은 자리 = 새 선 측점을 옛 선에 투영 · 측점 스냅이 뒤 교점을 밀어도 이어받음(옛 = 1 mm 정점 비교라 뒤쪽 전부 다시) - 자동 꼭짓점 · 편집(직선화 · 자유지정 · 쉬프트 · ▲▼ · R)을 새 측점 위에 다시 놓음 — [저장] 측점 밖 키 버림에 안 걸림 - follow 기준 = 마지막 종단 편집 때 선 · 꼭짓점 · 편집 한 벌 · 되돌린 노선 = 옛 편집 - 짝 = B05_Profile_Engine_Grade_Carry.py ↔ logic/B05_Profile_Grade_Carry.ts · 거울 시험 test_400_profile_carry Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG --- A00_Common/design/design_pipeline_page.ts | 14 + B05_Profile/B05_Profile_Engine_Grade_Carry.py | 198 +++++++--- B05_Profile/B05_Profile_Route_Save.py | 18 +- B05_Profile/logic/B05_Profile_Grade_Carry.ts | 217 ++++++++--- .../logic/B05_Profile_Pipeline_Grade.ts | 49 ++- .../logic/B05_Profile_UI_Profile_Alignment.ts | 2 +- resources/tester/helper_344_2_route_pairs.cjs | 12 +- resources/tester/helper_349_0v_span.cjs | 11 +- resources/tester/helper_400_profile_carry.cjs | 123 ++++++ resources/tester/test_344_2_route_pairs.py | 80 ++-- resources/tester/test_346_3_route_save.py | 8 +- .../tester/test_349_0v_route_save_span.py | 24 +- resources/tester/test_400_profile_carry.py | 366 ++++++++++++++++++ 13 files changed, 956 insertions(+), 166 deletions(-) create mode 100644 resources/tester/helper_400_profile_carry.cjs create mode 100644 resources/tester/test_400_profile_carry.py diff --git a/A00_Common/design/design_pipeline_page.ts b/A00_Common/design/design_pipeline_page.ts index 14c71d043..99226eb25 100644 --- a/A00_Common/design/design_pipeline_page.ts +++ b/A00_Common/design/design_pipeline_page.ts @@ -286,6 +286,20 @@ function createPagePipeline( alignment: savedEdits(), }; }, + // 옮긴 편집이 놓일 새 측점 — stations 단계와 같은 셈(400) + stationsAt: (route, patch, inputs) => + buildStations( + route.line, + route.curves, + (patch.pipes ?? inputs.pipes ?? []) as PlanPipe[], + (patch.structures ?? inputs.structures ?? []) as PlanStructure[], + { + intervalM: options.stationIntervalM, + types: enabled, + stationSnap: inputs.route?.stationSnap ?? false, + stationSnapStep: inputs.route?.stationSnapStep, + }, + ).map((station) => station.chainageM), }); /** 다시 셈 창구가 낸 cross 출력(365-4) — 엔진 재사용에서 뺌. */ const savedOut = new WeakSet(); diff --git a/B05_Profile/B05_Profile_Engine_Grade_Carry.py b/B05_Profile/B05_Profile_Engine_Grade_Carry.py index 81c31fbc1..7e30d522d 100644 --- a/B05_Profile/B05_Profile_Engine_Grade_Carry.py +++ b/B05_Profile/B05_Profile_Engine_Grade_Carry.py @@ -1,25 +1,38 @@ -"""노선이 바뀔 때 계획고 옮김 — 바뀐 구간만 지반에 다시 맞추고 그 밖 편집은 길이 차만큼 민다(344-2). +"""노선이 바뀔 때 계획고 옮김 — 같은 자리는 옛 계획고 · 바뀐 자리만 새 지반 첫 계획선(344-2 · 400). 사용자 확정(10-16 · 341 결정 1): 노선 끝쪽을 고쳤는데 앞쪽 계획고가 날아가지 않게 한다. +요구 250-2둘째(400): 평면을 고쳐도 크게 안 바뀐 자리의 종단 편집(직선화 · 자유지정 · 쉬프트 · +▲▼)은 이어받음. - - 바뀐 구간 = 옛 · 새 노선 정점열의 같은 앞머리 · 같은 꼬리를 뺀 가운데(`changed_span`) — 바뀐 - 노드 앞 · 뒤 접선점 사이. 원호 정점까지 같은지 보므로 곡선이 바뀐 이웃 노드 범위도 들어간다. - - 구간 앞 = 같은 땅 · 같은 누가거리 → 그대로. 구간 뒤 = 같은 땅 · 누가거리만 길이 차만큼 밀림. - - 구간 안 = 새 지반 첫 계획선(`design_ground_following_profile` 의 base_pvi) 꼭짓점으로 - 갈아 끼움 · 그 안 편집(계획고 · 종단곡선 R)은 버림. - - 옮기는 것 = base_pvi · station_offsets · curve_radii(누가거리 글자 키). + - `changed_span` = 옛 · 새 노선 정점열의 같은 앞머리 · 같은 꼬리를 뺀 가운데 — + 「노선 그대로?」 · 범위. + - 같은 자리(`line_matcher`) = 새 선 점을 옛 선에 투영 · 옆 `SAME_LINE_TOL_M` 안 · 방향 30° 안 + (측점 스냅이 뒤 교점을 밀어도 이어받음). + - 자동 꼭짓점(`carry_base`) · 편집(`carry_edits`)은 **새 측점 위에** 다시 놓음 — 같은 자리면 + 옛 선 그 자리 높이 · 아니면 새 지반 첫 계획선 · 편집은 옛 사용자 꼭짓점이 닿은 자리만 · + R 은 가까운 측점. 짝 = TS `B05_Profile/logic/B05_Profile_Grade_Carry.ts`(같은 규칙) · 거울 시험 = -`resources/tester/test_344_2_route_pairs.py`. 서버 정본은 이쪽(346-3 [저장]이 부름). +`resources/tester/test_400_profile_carry.py` · `test_344_2_route_pairs.py`. +서버 정본은 이쪽(346-3 [저장]이 부름). """ from __future__ import annotations import math +from collections.abc import Callable from typing import Any -from B05_Profile.B05_Profile_Engine_Grade_Alignment import chainage_key +import numpy as np +from B05_Profile.B05_Profile_Engine_Grade_Alignment import chainage_key, resolve_pvi + +# 같은 자리로 볼 옆 어긋남(m) — 브레인 정함 10-17 · 바꿀 수 있음 · TS `SAME_LINE_TOL_M` 짝. +SAME_LINE_TOL_M = 1.0 +# 같은 자리로 볼 방향 차(cos 30°) — 엇갈려 지나는 선 · 머리핀 맞은편을 뺌. +_SAME_DIRECTION_COS = 0.8660254037844386 +# 꼭짓점 위로 볼 거리(m) — `SAME_STATION_SNAP_M` 짝. +_ON_NODE_M = 0.006 # 같은 정점으로 볼 거리(m) — 저장 좌표 0.0001 m 자리 · 원호 다시 뽑기 잡음보다 크게. SAME_VERTEX_M = 1e-3 _EPS = 1e-6 @@ -67,61 +80,128 @@ def changed_span( } -def _before(span: dict[str, Any], s: float) -> bool: - """구간 앞(그대로 둠) — 앞머리가 같으면 그 끝점까지.""" - start = float(span["start_m"]) - return s < start - _EPS or (bool(span["start_fixed"]) and s <= start + _EPS) +def _segments(xy: list[tuple[float, float]]) -> np.ndarray: + """토막 표 [x, y, ux, uy, 길이, 시작 누가거리] — 길이 0 토막은 뺌(누가거리는 셈).""" + rows: list[tuple[float, ...]] = [] + start = 0.0 + for (x0, y0), (x1, y1) in zip(xy, xy[1:]): + dx, dy = float(x1) - float(x0), float(y1) - float(y0) + length = math.sqrt(dx * dx + dy * dy) + if length > _EPS: + rows.append((float(x0), float(y0), dx / length, dy / length, length, start)) + start += length + return np.array(rows, dtype=np.float64).reshape(-1, 6) -def _after(span: dict[str, Any], s: float, end: float) -> bool: - """구간 뒤(밂) — 꼬리가 같으면 그 시작점부터.""" - return s > end + _EPS or (bool(span["end_fixed"]) and s >= end - _EPS) +def line_matcher( + old_xy: list[tuple[float, float]], + new_xy: list[tuple[float, float]], + tol_m: float = SAME_LINE_TOL_M, +) -> Callable[[float], float | None]: + """새 누가거리 → 같은 자리 옛 누가거리(옆 `tol_m` · 방향 30° 안) · 아니면 None.""" + olds, news = _segments(old_xy), _segments(new_xy) + + def match(chainage: float) -> float | None: + if not len(olds) or not len(news): + return None + ahead = np.nonzero(chainage <= news[:, 5] + news[:, 4])[0] + x, y, ux, uy, length, start = news[ahead[0] if len(ahead) else -1] + along = min(max(chainage - start, 0.0), length) + qx, qy = x + ux * along, y + uy * along + facing = olds[olds[:, 2] * ux + olds[:, 3] * uy >= _SAME_DIRECTION_COS] + if not len(facing): + return None + ox, oy, oux, ouy, olen, ostart = facing.T + p = np.minimum(np.maximum((qx - ox) * oux + (qy - oy) * ouy, 0.0), olen) + dx, dy = qx - (ox + oux * p), qy - (oy + ouy * p) + gap = np.sqrt(dx * dx + dy * dy) + best = int(np.argmin(gap)) + return float(ostart[best] + p[best]) if gap[best] <= tol_m else None + + return match -def carry_profile_edits( - base_pvi: list[dict[str, float]], - edits: dict[str, Any] | None, - span: dict[str, Any], +def carry_base( + old_xy: list[tuple[float, float]], + new_xy: list[tuple[float, float]], + old_base: list[dict[str, float]], ground_pvi: list[dict[str, float]], -) -> tuple[list[dict[str, float]], dict[str, dict[str, float]]]: - """옛 꼭짓점 · 편집을 새 노선으로 옮긴다 → (새 base_pvi, 새 edits). - - `ground_pvi` = 새 노선 지반 첫 계획선 꼭짓점(전 구간 · `design_ground_following_profile`) — - 바뀐 구간 안 몫만 쓴다. - """ - delta = float(span["delta_m"]) - old_end, new_end = float(span["old_end_m"]), float(span["new_end_m"]) - - def moved(s: float) -> float | None: - if _before(span, s): - return s - if _after(span, s, old_end): - return s + delta - return None # 바뀐 구간 안 — 버림 - - nodes: dict[float, dict[str, float]] = {} - for node in base_pvi: - at = moved(float(node["chainage_m"])) - if at is not None: - key = round(at, 3) - nodes[key] = {"chainage_m": round(at, 6), "elevation_m": float(node["elevation_m"])} + tol_m: float = SAME_LINE_TOL_M, +) -> list[dict[str, float]]: + """새 자동 꼭짓점 — 지반 첫 계획선 측점마다 같은 자리면 옛 자동선 높이 · 아니면 지반.""" + if not old_base or not ground_pvi: + return [dict(node) for node in old_base] + match = line_matcher(old_xy, new_xy, tol_m) + base_s = np.array([float(n["chainage_m"]) for n in old_base], dtype=np.float64) + base_z = np.array([float(n["elevation_m"]) for n in old_base], dtype=np.float64) + out = [] for node in ground_pvi: - s = float(node["chainage_m"]) - if _before(span, s) or _after(span, s, new_end): - continue - nodes.setdefault( - round(s, 3), {"chainage_m": round(s, 6), "elevation_m": float(node["elevation_m"])} + s = match(float(node["chainage_m"])) + z = ( + float(node["elevation_m"]) + if s is None + else round(float(np.interp(s, base_s, base_z)), 6) ) + out.append({"chainage_m": float(node["chainage_m"]), "elevation_m": z}) + return out - carried: dict[str, dict[str, float]] = {} - for name in ("station_offsets", "curve_radii"): - values: dict[str, float] = {} - for key, value in ((edits or {}).get(name) or {}).items(): - try: - at = moved(float(key)) - except (TypeError, ValueError): - continue - if at is not None and value is not None: - values[chainage_key(at)] = float(value) - carried[name] = values - return [nodes[key] for key in sorted(nodes)], carried + +def carry_edits( + old_xy: list[tuple[float, float]], + new_xy: list[tuple[float, float]], + old_base: list[dict[str, float]], + edits: dict[str, Any] | None, + stations: list[float], + half_m: float, + tol_m: float = SAME_LINE_TOL_M, +) -> dict[str, dict[str, float]]: + """새 편집 — 새 측점마다 같은 자리 · 옛 사용자 꼭짓점이 닿은 자리면 옛 계획선 − 옛 자동선. + + 닿은 자리 = 꼭짓점 위(그 꼭짓점이 사용자) · 앞뒤 꼭짓점 하나가 사용자. 종단곡선 R = 옛 키에서 + `half_m` 안 가장 가까운 같은 자리 측점. 새 자동 꼭짓점이 같은 자리에서 옛 자동선 + 높이(`carry_base`)라 계획고가 그대로. + """ + out: dict[str, dict[str, float]] = {"station_offsets": {}, "curve_radii": {}} + if not old_base: + return out + match = line_matcher(old_xy, new_xy, tol_m) + mapped: list[tuple[float, float]] = [] + for t in sorted({round(float(v), 3) for v in stations if math.isfinite(float(v))}): + s = match(t) + if s is not None: + mapped.append((t, s)) + base_s = np.array([float(n["chainage_m"]) for n in old_base], dtype=np.float64) + base_z = np.array([float(n["elevation_m"]) for n in old_base], dtype=np.float64) + pvi_s, pvi_z, sources = resolve_pvi(base_s, base_z, (edits or {}).get("station_offsets") or {}) + + def touched(s: float) -> bool: + on = np.nonzero(np.abs(pvi_s - s) < _ON_NODE_M)[0] + if len(on): + return sources[int(on[0])] == "user" + ahead = np.nonzero(pvi_s > s)[0] + nxt = int(ahead[0]) if len(ahead) else -1 + return nxt > 0 and (sources[nxt - 1] == "user" or sources[nxt] == "user") + + for t, s in mapped: + if touched(s): + out["station_offsets"][chainage_key(t)] = round( + float(np.interp(s, pvi_s, pvi_z)) - float(np.interp(s, base_s, base_z)), 6 + ) + gaps: dict[str, float] = {} + for key, value in ((edits or {}).get("curve_radii") or {}).items(): + try: + chainage = float(key) + except (TypeError, ValueError): + continue + if not math.isfinite(chainage) or value is None: + continue + slot, best = None, math.inf + for t, s in mapped: + gap = round(abs(s - chainage), 3) # mm — 가운데 같은 거리면 앞 측점 + if gap <= half_m and gap < best: + slot, best = chainage_key(t), gap + if slot is None or best >= gaps.get(slot, math.inf): + continue + gaps[slot] = best + out["curve_radii"][slot] = float(value) + return out diff --git a/B05_Profile/B05_Profile_Route_Save.py b/B05_Profile/B05_Profile_Route_Save.py index 552e96362..6b930038c 100644 --- a/B05_Profile/B05_Profile_Route_Save.py +++ b/B05_Profile/B05_Profile_Route_Save.py @@ -19,9 +19,9 @@ API — `POST /api/projects/{id}/route/replan`(`B05_Profile_Router_Replan.replan 규칙: - 관 정본을 지우지 않음 — 좌표를 따라감 · 새 세류선 교차점 기본 관만 더함 (`B05_Profile_Route_Save_Parts`) · 배수유역 저장분은 노선 지문이 달라 「낡음」(B04 `_resolve`). - - 계획고 = 바뀐 구간만 새 지반 첫 계획선 · 그 밖 사용자 계획고 지킴 - (`B05_Profile_Engine_Grade_Carry` · 바뀐 구간 = 옛 · 새 **계획 폴리라인** — 화면 planline 과 - 같은 선) · 균형 종단(`apply_profile_target`)은 추천 [적용](`as_initial`)만. + - 계획고 = 바뀐 자리만 새 지반 첫 계획선 · 같은 자리(옆 1 m 안) 사용자 계획고 지킴 + (`B05_Profile_Engine_Grade_Carry` · 옛 · 새 **계획 폴리라인** — 화면 planline 과 같은 선) · + 균형 종단(`apply_profile_target`)은 추천 [적용](`as_initial`)만. - 초기값은 여기서 안 뜸 — 부르는 쪽이 `as_initial` 일 때만(자동설계 체인 직후 + 추천 [적용]). """ @@ -199,8 +199,8 @@ async def _carry_profile( old_xy: list[list[float]], new_xy: list[list[float]], ) -> None: - """바뀐 구간만 새 지반 첫 계획선 · 그 밖 사용자 계획고 · 종단곡선 R 은 길이 차만큼 밀어 지킴.""" - from B05_Profile.B05_Profile_Engine_Grade_Carry import carry_profile_edits, changed_span + """같은 자리(옆 1 m 안) 옛 계획고 · R 을 새 측점 위에 · 바뀐 자리만 새 지반 첫 계획선(400).""" + from B05_Profile.B05_Profile_Engine_Grade_Carry import carry_base, carry_edits, changed_span from B05_Profile.B05_Profile_Repository import update_longitudinal_grade_summary from B05_Profile.B05_Profile_Router import _apply_alignment_edits from common_util.common_util_json import atomic_write_json @@ -214,9 +214,11 @@ async def _carry_profile( if span is None: # 같은 노선 — 옛 계획선 통째 base, edits = old_alignment["base_pvi"], old_alignment.get("edits") or {} else: - base, edits = carry_profile_edits( - old_alignment["base_pvi"], old_alignment.get("edits"), span, alignment["base_pvi"] - ) + old_base = old_alignment["base_pvi"] + stations = [float(row["chainage_m"]) for row in longitudinal.get("stations") or []] + half = float((alignment.get("policy") or {}).get("station_interval_m") or 20.0) / 2 + base = carry_base(old_xy, new_xy, old_base, alignment["base_pvi"]) + edits = carry_edits(old_xy, new_xy, old_base, old_alignment.get("edits"), stations, half) alignment["base_pvi"] = base await asyncio.to_thread(atomic_write_json, path, longitudinal) _alignment, summary, _dropped = await asyncio.to_thread( diff --git a/B05_Profile/logic/B05_Profile_Grade_Carry.ts b/B05_Profile/logic/B05_Profile_Grade_Carry.ts index f464f47fc..cb8f6a18b 100644 --- a/B05_Profile/logic/B05_Profile_Grade_Carry.ts +++ b/B05_Profile/logic/B05_Profile_Grade_Carry.ts @@ -1,21 +1,31 @@ /* ============================================================================= * B05_Profile/logic/B05_Profile_Grade_Carry.ts - * 노선이 바뀔 때 계획고 옮김 — 바뀐 구간만 지반에 다시 맞추고 그 밖 편집은 길이 차만큼 밂(344-2). + * 노선이 바뀔 때 계획고 옮김 — 같은 자리는 옛 계획고 · 바뀐 자리만 새 지반 첫 계획선(344-2 · 400). * * 짝 = 파이썬 `B05_Profile_Engine_Grade_Carry.py`(서버 정본 · 같은 규칙) · 거울 시험 = - * resources/tester/test_344_2_route_pairs.py. - * - 바뀐 구간 = 옛 · 새 노선 정점열의 같은 앞머리 · 꼬리를 뺀 가운데(바뀐 노드 앞 · 뒤 접선점 사이). - * - 앞 = 그대로 · 뒤 = 누가거리만 길이 차만큼 밂 · 안 = 새 지반 첫 계획선 꼭짓점(`groundFollowingBasePvi`) - * · 안의 편집(계획고 · 종단곡선 R)은 버림. - * - 옮기는 것 = base_pvi · station_offsets · curve_radii(누가거리 글자 키). + * resources/tester/test_400_profile_carry.py · test_344_2_route_pairs.py. + * - `changedSpan` = 옛 · 새 정점열의 같은 앞머리 · 꼬리를 뺀 가운데 — 「노선 그대로?」 · 묶음 범위만. + * - 같은 자리(`lineMatcher`) = 새 선 점을 옛 선에 투영 · 옆 `SAME_LINE_TOL_M` 안 · 방향 30° 안 + * (측점 스냅이 뒤 교점을 밀어도 이어받음). + * - 자동 꼭짓점(`carryBase`) · 편집(`carryEdits`)은 **새 측점 위에** 다시 놓음 — 같은 자리면 옛 선 그 자리 + * 높이 · 아니면 새 지반 첫 계획선 · 편집은 옛 사용자 꼭짓점이 닿은 자리만 · R 은 가까운 측점. * ========================================================================== */ import { chainageKey, + resolvePvi, + type AlignmentBase, type AlignmentEdits, type AlignmentNode, } from "./B05_Profile_UI_Profile_Alignment"; +/** 같은 자리로 볼 옆 어긋남(m) — 브레인 정함 10-17 · 바꿀 수 있음 · 파이썬 `SAME_LINE_TOL_M` 짝. */ +export const SAME_LINE_TOL_M = 1.0; +/** 같은 자리로 볼 방향 차(cos 30°) — 엇갈려 지나는 선 · 머리핀 맞은편을 뺌. */ +const SAME_DIRECTION_COS = 0.8660254037844386; +/** 꼭짓점 위로 볼 거리(m) — 서버 `SAME_STATION_SNAP_M` 짝. */ +const ON_NODE_M = 0.006; + /** 같은 정점으로 볼 거리(m) — 파이썬 `SAME_VERTEX_M` 짝. */ export const SAME_VERTEX_M = 1e-3; const EPS = 1e-6; @@ -75,55 +85,156 @@ export function changedSpan( }; } -const before = (span: ChangedSpan, s: number): boolean => - s < span.start_m - EPS || (span.start_fixed && s <= span.start_m + EPS); +/** `np.interp` — 오름차순 xs · 범위 밖은 양 끝값. */ +function interp(xs: number[], ys: number[], value: number): number { + if (value <= xs[0]) return ys[0]; + if (value >= xs[xs.length - 1]) return ys[ys.length - 1]; + let low = 0; + let high = xs.length - 1; + while (high - low > 1) { + const mid = (low + high) >> 1; + if (xs[mid] <= value) low = mid; + else high = mid; + } + const span = xs[high] - xs[low]; + return span <= 0 ? ys[high] : ys[low] + ((ys[high] - ys[low]) * (value - xs[low])) / span; +} -const after = (span: ChangedSpan, s: number, end: number): boolean => - s > end + EPS || (span.end_fixed && s >= end - EPS); +interface Segment { + x: number; + y: number; + ux: number; + uy: number; + length: number; + from: number; +} -/** 옛 꼭짓점 · 편집을 새 노선으로 옮김 — `groundPvi` = 새 노선 지반 첫 계획선(전 구간). */ -export function carryProfileEdits( - basePvi: ReadonlyArray, - edits: Partial | null | undefined, - span: ChangedSpan, - groundPvi: ReadonlyArray, -): { basePvi: AlignmentNode[]; edits: AlignmentEdits } { - const moved = (s: number): number | null => { - if (before(span, s)) return s; - if (after(span, s, span.old_end_m)) return s + span.delta_m; - return null; // 바뀐 구간 안 — 버림 - }; +function segmentsOf(xy: ReadonlyArray): Segment[] { + const out: Segment[] = []; + let from = 0; + for (let index = 1; index < xy.length; index += 1) { + const dx = xy[index].x - xy[index - 1].x; + const dy = xy[index].y - xy[index - 1].y; + const length = Math.sqrt(dx * dx + dy * dy); + if (length > EPS) { + const { x, y } = xy[index - 1]; + out.push({ x, y, ux: dx / length, uy: dy / length, length, from }); + } + from += length; + } + return out; +} - const nodes = new Map(); - basePvi.forEach((node) => { - const at = moved(node.chainage_m); - if (at !== null) - nodes.set(roundTo(at, 3), { chainage_m: roundTo(at, 6), elevation_m: node.elevation_m }); - }); - groundPvi.forEach((node) => { - const s = node.chainage_m; - if (before(span, s) || after(span, s, span.new_end_m)) return; - const slot = roundTo(s, 3); - if (!nodes.has(slot)) - nodes.set(slot, { chainage_m: roundTo(s, 6), elevation_m: node.elevation_m }); - }); - - const carry = (values: Record | undefined): Record => { - const out: Record = {}; - Object.entries(values ?? {}).forEach(([key, value]) => { - const chainage = Number(key); - if (!Number.isFinite(chainage) || value == null) return; - const at = moved(chainage); - if (at !== null) out[chainageKey(at)] = Number(value); - }); - return out; - }; - - return { - basePvi: [...nodes.keys()].sort((a, b) => a - b).map((slot) => nodes.get(slot)!), - edits: { - station_offsets: carry(edits?.station_offsets), - curve_radii: carry(edits?.curve_radii), - }, +/** 새 누가거리 → 같은 자리 옛 누가거리(옆 `tolM` · 방향 30° 안) · 아니면 null(파이썬 `line_matcher` 짝). */ +export function lineMatcher( + oldXy: ReadonlyArray, + newXy: ReadonlyArray, + tolM: number = SAME_LINE_TOL_M, +): (chainageM: number) => number | null { + const olds = segmentsOf(oldXy); + const news = segmentsOf(newXy); + return (chainageM) => { + if (!olds.length || !news.length) return null; + const seg = news.find((item) => chainageM <= item.from + item.length) ?? news[news.length - 1]; + const along = Math.min(Math.max(chainageM - seg.from, 0), seg.length); + const qx = seg.x + seg.ux * along; + const qy = seg.y + seg.uy * along; + let best = Infinity; + let at: number | null = null; + for (const item of olds) { + if (item.ux * seg.ux + item.uy * seg.uy < SAME_DIRECTION_COS) continue; + const p = Math.min( + Math.max((qx - item.x) * item.ux + (qy - item.y) * item.uy, 0), + item.length, + ); + const dx = qx - (item.x + item.ux * p); + const dy = qy - (item.y + item.uy * p); + const gap = Math.sqrt(dx * dx + dy * dy); + if (gap < best) { + best = gap; + at = item.from + p; + } + } + return best <= tolM ? at : null; }; } + +/** 새 자동 꼭짓점 — 지반 첫 계획선 측점마다 같은 자리면 옛 자동선 높이 · 아니면 지반(파이썬 `carry_base` 짝). */ +export function carryBase( + oldXy: ReadonlyArray, + newXy: ReadonlyArray, + oldBase: ReadonlyArray, + groundPvi: ReadonlyArray, + tolM: number = SAME_LINE_TOL_M, +): AlignmentNode[] { + if (!oldBase.length || !groundPvi.length) return oldBase.map((node) => ({ ...node })); + const match = lineMatcher(oldXy, newXy, tolM); + const baseS = oldBase.map((node) => node.chainage_m); + const baseZ = oldBase.map((node) => node.elevation_m); + return groundPvi.map((node) => { + const s = match(node.chainage_m); + return { + chainage_m: node.chainage_m, + elevation_m: s === null ? node.elevation_m : roundTo(interp(baseS, baseZ, s), 6), + }; + }); +} + +/** + * 새 편집 — 새 측점마다 같은 자리이고 옛 사용자 꼭짓점이 닿은 자리(꼭짓점 위 · 앞뒤 꼭짓점 하나가 사용자)면 + * 옛 계획선 높이 − 옛 자동선 높이 · 종단곡선 R = 옛 키에서 `halfM` 안 가장 가까운 같은 자리 측점(파이썬 + * `carry_edits` 짝). 새 자동 꼭짓점이 같은 자리에서 옛 자동선 높이(`carryBase`)라 계획고가 그대로. + */ +export function carryEdits( + oldXy: ReadonlyArray, + newXy: ReadonlyArray, + oldBase: ReadonlyArray, + edits: Partial | null | undefined, + stations: ReadonlyArray, + halfM: number, + tolM: number = SAME_LINE_TOL_M, +): AlignmentEdits { + const out: AlignmentEdits = { station_offsets: {}, curve_radii: {} }; + if (!oldBase.length) return out; + const match = lineMatcher(oldXy, newXy, tolM); + const mapped: Array<{ t: number; s: number }> = []; + [...new Set(stations.filter(Number.isFinite).map((value) => roundTo(value, 3)))] + .sort((a, b) => a - b) + .forEach((t) => { + const s = match(t); + if (s !== null) mapped.push({ t, s }); + }); + const baseS = oldBase.map((node) => node.chainage_m); + const baseZ = oldBase.map((node) => node.elevation_m); + const pvi = resolvePvi({ basePvi: [...oldBase] } as AlignmentBase, edits?.station_offsets ?? {}); + const pviS = pvi.map((node) => node.chainage_m); + const pviZ = pvi.map((node) => node.elevation_m); + const touched = (s: number): boolean => { + const on = pvi.find((node) => Math.abs(node.chainage_m - s) < ON_NODE_M); + if (on) return on.source === "user"; + const next = pvi.findIndex((node) => node.chainage_m > s); + return next > 0 && (pvi[next - 1].source === "user" || pvi[next].source === "user"); + }; + mapped.forEach(({ t, s }) => { + if (touched(s)) + out.station_offsets[chainageKey(t)] = roundTo( + interp(pviS, pviZ, s) - interp(baseS, baseZ, s), + 6, + ); + }); + const gaps = new Map(); + Object.entries(edits?.curve_radii ?? {}).forEach(([key, value]) => { + const chainage = Number(key); + if (!Number.isFinite(chainage) || value == null) return; + let slot: string | null = null; + let best = Infinity; + mapped.forEach(({ t, s }) => { + const gap = roundTo(Math.abs(s - chainage), 3); // mm — 가운데 같은 거리면 앞 측점 + if (gap <= halfM && gap < best) [slot, best] = [chainageKey(t), gap]; + }); + if (slot === null || best >= (gaps.get(slot) ?? Infinity)) return; + gaps.set(slot, best); + out.curve_radii[slot] = Number(value); + }); + return out; +} diff --git a/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts b/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts index 1d7a5df27..ccaa46872 100644 --- a/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts +++ b/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts @@ -4,9 +4,9 @@ * `A00_Common/design/logic/design_pipeline_types.ts`. 셈은 이미 있는 짝을 부르기만(어댑터): * * follow — 관 · 구조물 좌표 따라감(`common_util_route_follow`) · 계획고 편집 옮김(`Grade_Carry` - * `carryProfileEdits` 의 edits 몫 · 옛 노선 `before.route` 가 있을 때만). - * planline — 노선 초안이 없으면 저장 종단 기준 그대로 · 있으면 새 지반 첫 계획선(`Grade_Ground`)으로 - * 바뀐 구간만 다시 맞추고 밖은 저장 꼭짓점을 밂(`carryProfileEdits` 의 basePvi 몫). + * `carryEdits` · 옛 노선 `before.route` 가 있을 때만 · 기준 = 마지막 종단 편집 때 한 벌 · 400). + * planline — 노선 초안이 없으면 저장 종단 기준 그대로 · 있으면 새 측점마다 같은 자리는 저장 꼭짓점 + * 높이 · 바뀐 자리만 새 지반 첫 계획선(`Grade_Ground` · `carryBase`). * profile — `buildAlignment`(첫 계획선 + 계획고 편집) — 종단 보기가 쓰는 같은 함수. * * 화면 · fetch · 세션 없음(순수) — 저장 기준 · 저장 노선은 부르는 쪽이 넣음. @@ -16,6 +16,7 @@ import type { FollowPatch, PipelineInputs, RangePart, + RouteLine, StageFn, Vertex, } from "../../A00_Common/design/logic/design_pipeline_types"; @@ -31,7 +32,7 @@ import { type AlignmentBase, type AlignmentEdits, } from "./B05_Profile_UI_Profile_Alignment"; -import { carryProfileEdits, changedSpan } from "./B05_Profile_Grade_Carry"; +import { carryBase, carryEdits, changedSpan, type XY } from "./B05_Profile_Grade_Carry"; import { SECTION_STATION_INTERVAL_M } from "./B05_Profile_Sections_Stations"; import { groundFollowingBasePvi, groundProfile } from "./B05_Profile_Grade_Ground"; @@ -74,8 +75,17 @@ export function gradeRange( }; } +/** 계획고 편집 기준 한 벌 — 그 편집이 놓인 선 · 자동 꼭짓점. */ +interface GradeRef { + line: XY[]; + basePvi: AlignmentBase["basePvi"]; + edits: AlignmentEdits; +} + /** - * follow — 노선이 바뀐 차례에 한 번 · 관 · 구조물 좌표 따라감 · 계획고 편집 옮김. 첫 계산(옛 노선 없음)은 + * follow — 노선이 바뀐 차례에 한 번 · 관 · 구조물 좌표 따라감 · 계획고 편집 옮김. 편집 기준 = 마지막으로 + * 편집이 바뀐(종단 조작 · 되돌리기 · 열 때) 뒤 첫 노선 걸음의 옛 선 · 자동 꼭짓점 · 편집 — 노선 걸음이 여럿이어도 + * 그 한 벌에서 한 번에 옮김(걸음 수와 무관 · 서버 [저장] = 저장 노선에서 한 번 · 400). 첫 계산(옛 노선 없음)은 * 저장 노선(`savedLine`)을 옛 노선으로 보고 좌표만 따라감(투영은 몇 번 해도 같음 · 계획고 옮김은 안 함 — * 열 때의 계획고 초안은 이미 그 노선 기준). 노선 초안이 없으면(열 때 · 초안 버림) `saved` 를 그대로 — * 저장 노선의 관 · 구조물 누가거리 · 계획고는 정본이라 다시 투영하지 않음(투영 오차 = 측점 목록 어긋남). @@ -83,7 +93,11 @@ export function gradeRange( export function createFollowStage(deps: { savedLine(): Vertex[] | null; saved?(): FollowPatch; + /** 새 노선 측점 누가거리(stations 단계와 같은 셈) — 없으면 일반측점 격자. */ + stationsAt?(route: RouteLine, patch: FollowPatch, inputs: PipelineInputs): number[]; }): StageFn<"follow"> { + let ref: GradeRef | null = null; + let carried: unknown = null; // 마지막으로 낸 편집 — 입력이 이것이면 기준 그대로 return (ctx) => { if (!ctx.inputs.route && deps.saved) return deps.saved(); const before = ctx.before.route?.line ?? deps.savedLine(); @@ -101,12 +115,31 @@ export function createFollowStage(deps: { undefined, SECTION_STATION_INTERVAL_M, // site = 새 노선 일반 측점(380-3) ).map((moved) => moved.item); - const span = ctx.before.route ? changedSpan(xy(before), line) : null; - if (span && alignment) patch.alignment = carryProfileEdits([], alignment, span, []).edits; + const planline = ctx.before.planline; + if (alignment !== carried && JSON.stringify(alignment) !== JSON.stringify(carried)) + ref = + alignment && planline + ? { line: xy(before), basePvi: planline.basePvi, edits: alignment as AlignmentEdits } + : null; + if (ref && ctx.before.route) { + const span = changedSpan(ref.line, line); + const half = (planline?.policy.station_interval_m ?? SECTION_STATION_INTERVAL_M) / 2; + const stations = span ? (deps.stationsAt?.(now, patch, ctx.inputs) ?? gridOf(now)) : []; + carried = patch.alignment = span + ? carryEdits(ref.line, line, ref.basePvi, ref.edits, stations, half) + : ref.edits; + } return patch; }; } +/** 일반측점 격자 + 끝 — `stationsAt` 없을 때(시험 · 단독 묶음). */ +const gridOf = (route: RouteLine): number[] => { + const grid: number[] = []; + for (let s = 0; s < route.lengthM; s += SECTION_STATION_INTERVAL_M) grid.push(s); + return [...grid, route.lengthM]; +}; + export const followStage = createFollowStage({ savedLine: () => null }); export interface PlanlineDeps { @@ -144,7 +177,7 @@ export function createPlanlineStage(deps: PlanlineDeps): StageFn<"planline"> { return { policy: saved.policy, onlyExplicitCurves: saved.onlyExplicitCurves, - basePvi: span ? carryProfileEdits(savedPvi, null, span, groundPvi).basePvi : savedPvi, + basePvi: span ? carryBase(xy(savedLine!), xy(route.line), savedPvi, groundPvi) : savedPvi, chainage: profile.chainage, ground: profile.ground, stations: ground.stations.map((station) => ({ diff --git a/B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts b/B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts index a9edbc674..1612a8dca 100644 --- a/B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts +++ b/B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts @@ -260,7 +260,7 @@ function snapToStations( } /** 자동 변화점에 사용자 편집 측점을 합쳐 최종 변화점 집합을 만든다. */ -function resolvePvi( +export function resolvePvi( base: AlignmentBase, offsets: Record, ): Array { diff --git a/resources/tester/helper_344_2_route_pairs.cjs b/resources/tester/helper_344_2_route_pairs.cjs index bad98ffc5..222cbd91c 100644 --- a/resources/tester/helper_344_2_route_pairs.cjs +++ b/resources/tester/helper_344_2_route_pairs.cjs @@ -46,7 +46,17 @@ const out = { return { span, result: span - ? carry.carryProfileEdits(item.base_pvi, item.edits, span, item.ground_pvi) + ? { + basePvi: carry.carryBase(item.old_xy, item.new_xy, item.base_pvi, item.ground_pvi), + edits: carry.carryEdits( + item.old_xy, + item.new_xy, + item.base_pvi, + item.edits, + item.stations, + 10, + ), + } : null, }; }), diff --git a/resources/tester/helper_349_0v_span.cjs b/resources/tester/helper_349_0v_span.cjs index 0ddfe65ed..571141758 100644 --- a/resources/tester/helper_349_0v_span.cjs +++ b/resources/tester/helper_349_0v_span.cjs @@ -1,5 +1,5 @@ /* 349-0v 바뀐 구간 거울 — 화면 planline 단계 차례 그대로: 새 노선 = `buildEditedPolyline`(노드 초안) · - * 옛 노선 = 저장 계획 폴리라인 · `changedSpan` → `carryProfileEdits`. 입력 argv[2] JSON · 출력 JSON. + * 옛 노선 = 저장 계획 폴리라인 · `changedSpan` → `carryBase` · `carryEdits`(400). 입력 argv[2] JSON · 출력 JSON. */ const fs = require("fs"); const path = require("path"); @@ -37,13 +37,18 @@ const built = curve.buildEditedPolyline( input.curveRadius, input.minRadiusM, ); -const span = carry.changedSpan(xy(input.old_line), xy(built.vertices)); +const old = xy(input.old_line); +const now = xy(built.vertices); +const span = carry.changedSpan(old, now); process.stdout.write( JSON.stringify({ new_line: built.vertices, span, result: span - ? carry.carryProfileEdits(input.base_pvi, input.edits, span, input.ground_pvi) + ? { + basePvi: carry.carryBase(old, now, input.base_pvi, input.ground_pvi), + edits: carry.carryEdits(old, now, input.base_pvi, input.edits, input.stations, 20), + } : null, }), ); diff --git a/resources/tester/helper_400_profile_carry.cjs b/resources/tester/helper_400_profile_carry.cjs new file mode 100644 index 000000000..1416357d7 --- /dev/null +++ b/resources/tester/helper_400_profile_carry.cjs @@ -0,0 +1,123 @@ +/* 400 계획고 이월 거울 — 진짜 TS(`logic/B05_Profile_Grade_Carry` · `logic/B05_Profile_Pipeline_Grade` follow)를 + * Node 로 돌림. 입력 argv[2] JSON(파이썬과 같은 값) · 표준출력 JSON 한 벌. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); + +function loadTs(file, stubs = {}) { + const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, file), "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + if (stubs[request]) return stubs[request]; + throw new Error(`stub 없음: ${request} (${file})`); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts"); +const alignment = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts", { + "./B05_Profile_UI_Profile_Pivot": pivot, +}); +const carry = loadTs("B05_Profile/logic/B05_Profile_Grade_Carry.ts", { + "./B05_Profile_UI_Profile_Alignment": alignment, +}); +const ground = loadTs("B05_Profile/logic/B05_Profile_Grade_Ground.ts", { + "./B05_Profile_UI_Profile_Alignment": alignment, +}); +const grade = loadTs("B05_Profile/logic/B05_Profile_Pipeline_Grade.ts", { + "../../common_util/common_util_route_follow": {}, + "./B05_Profile_UI_Profile_Alignment": alignment, + "./B05_Profile_Grade_Carry": carry, + "./B05_Profile_Sections_Stations": { SECTION_STATION_INTERVAL_M: 20 }, + "./B05_Profile_Grade_Ground": ground, +}); + +const curve = loadTs("B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve.ts"); +const edits = loadTs("B05_Profile/logic/B05_Profile_UI_RouteEdit_Edits.ts", { + "./B05_Profile_UI_RouteEdit_Curve": curve, +}); +const frame = loadTs("B05_Profile/logic/B05_Profile_UI_RouteEdit_Frame.ts"); +const fit = loadTs("B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts", { + "./B05_Profile_UI_RouteEdit_Curve": curve, + "./B05_Profile_UI_RouteEdit_Edits": edits, + "./B05_Profile_UI_RouteEdit_Frame": frame, +}); + +/** 측점 스냅 켠 노선 — 저장 노선(맞춤) · 교점 하나 옮긴 뒤 맞춤(편집기와 같은 `fitCurveStations`). */ +function snapFit(item) { + const n = item.nodes.length; + const cols = (planned) => ({ + planned, + curveOn: Array(n).fill(true), + curveRadius: Array(n).fill(null), + curveLock: Array(n).fill(null), + curveArc: Array(n).fill(null), + apexLock: Array(n).fill(false), + }); + const line = (planned) => + curve.buildEditedPolyline( + planned, + Array(n).fill(true), + Array(n).fill(null), + item.limits.minRadiusM, + ).vertices; + const old = fit.fitCurveStations(cols(item.nodes), item.limits, 1); + const moved = old.map((p, i) => (i === item.index ? item.to : p)); + const now = fit.fitCurveStations(cols(moved), item.limits, Math.max(1, item.index - 1)); + return { old_nodes: old, new_nodes: now, old_line: line(old), new_line: line(now) }; +} + +const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const routeOf = (line) => { + const chainage = [0]; + for (let i = 1; i < line.length; i += 1) + chainage.push( + chainage[i - 1] + Math.hypot(line[i][0] - line[i - 1][0], line[i][1] - line[i - 1][1]), + ); + return { line, curves: [], chainage, lengthM: chainage[chainage.length - 1] }; +}; + +/** follow 단계를 노선 걸음 차례대로 — 각 걸음 뒤 편집(묶음이 입력에 넣는 것과 같음). */ +function walk(item) { + let stations = []; + const stage = grade.createFollowStage({ savedLine: () => null, stationsAt: () => stations }); + const planline = { policy: item.policy, basePvi: item.base_pvi }; + let alignmentNow = item.edits; + let before = routeOf(item.ref_line); + const out = []; + for (const step of item.steps) { + stations = step.stations; + const now = routeOf(step.line); + const patch = stage({ + inputs: { route: { nodes: [] }, pipes: null, structures: null, alignment: alignmentNow }, + out: { route: now }, + before: { route: before, planline }, + }); + if (patch.alignment) alignmentNow = patch.alignment; + out.push(alignmentNow); + before = now; + } + return out; +} + +const result = { + match: input.match.map((item) => { + const fn = carry.lineMatcher(item.old_xy, item.new_xy, item.tol); + return item.queries.map((t) => fn(t)); + }), + base: input.base.map((item) => + carry.carryBase(item.old_xy, item.new_xy, item.old_base, item.ground_pvi), + ), + edits: input.edits.map((item) => + carry.carryEdits(item.old_xy, item.new_xy, item.old_base, item.edits, item.stations, item.half), + ), + follow: input.follow.map(walk), + snap: (input.snap ?? []).map(snapFit), +}; +process.stdout.write(JSON.stringify(result)); diff --git a/resources/tester/test_344_2_route_pairs.py b/resources/tester/test_344_2_route_pairs.py index 949058248..0032176c1 100644 --- a/resources/tester/test_344_2_route_pairs.py +++ b/resources/tester/test_344_2_route_pairs.py @@ -7,7 +7,8 @@ ② 관 자리 투영 — `common_util_drainage_pipes.project_pipe_points` ↔ `common_util_route_follow.ts` `projectPipePoints`. ③ 구조물 따라감 — `common_util_route_follow.follow_structures` ↔ `followStructures`(20 m 상수 같음). - ④ 계획고 옮김 — `B05_Profile_Engine_Grade_Carry` ↔ `logic/B05_Profile_Grade_Carry.ts`. + ④ 계획고 옮김 — `B05_Profile_Engine_Grade_Carry` ↔ `logic/B05_Profile_Grade_Carry.ts` + (400 = 같은 자리 · 새 측점 위 · 자세한 규칙은 test_400_profile_carry.py). 서버 쪽: 저장 때 좌표 채움(자리가 그대로면 옛 좌표 지킴) · 노선 [확인] 때 구조물 정본이 따라감. """ @@ -20,6 +21,7 @@ import sys from pathlib import Path from types import SimpleNamespace +import numpy as np import pytest HERE = Path(__file__).resolve().parent @@ -29,9 +31,12 @@ if str(ROOT) not in sys.path: import B05_Profile.B05_Profile_Structures_Router as structures_router # noqa: E402 from B05_Profile.B05_Profile_Engine_Grade import resolve_grade_options # noqa: E402 +from B05_Profile.B05_Profile_Engine_Grade_Alignment import resolve_pvi # noqa: E402 from B05_Profile.B05_Profile_Engine_Grade_Carry import ( # noqa: E402 - carry_profile_edits, + carry_base, + carry_edits, changed_span, + line_matcher, ) from B05_Profile.B05_Profile_Engine_Grade_Profile import ( # noqa: E402 design_ground_following_profile, @@ -115,6 +120,7 @@ def _carry_case(new_route: list[tuple[float, float]]) -> dict: "base_pvi": base_pvi, "edits": edits, "ground_pvi": alignment["base_pvi"], + "stations": [row["chainage_m"] for row in longitudinal["stations"]], } @@ -335,18 +341,47 @@ def test_노선_확인_때_구조물_정본이_따라감(tmp_path, monkeypatch): # ── ④ 계획고 옮김 ───────────────────────────────────────────────────────────── +def _pts(case, key): + return [(p["x"], p["y"]) for p in case[key]] + + def _py_carry(case): - span = changed_span( - [(p["x"], p["y"]) for p in case["old_xy"]], [(p["x"], p["y"]) for p in case["new_xy"]] - ) + old, new = _pts(case, "old_xy"), _pts(case, "new_xy") + span = changed_span(old, new) result = ( - carry_profile_edits(case["base_pvi"], case["edits"], span, case["ground_pvi"]) + ( + carry_base(old, new, case["base_pvi"], case["ground_pvi"]), + carry_edits(old, new, case["base_pvi"], case["edits"], case["stations"], 10.0), + ) if span else None ) return span, result +def _plan_at(base, edits, s): + pvi_s, pvi_z, _ = resolve_pvi( + np.array([p["chainage_m"] for p in base]), + np.array([p["elevation_m"] for p in base]), + edits["station_offsets"], + ) + return float(np.interp(s, pvi_s, pvi_z)) + + +def _kept_plan(case, base_pvi, edits): + """같은 자리 새 측점마다 새 계획고 = 옛 계획고 · 바뀐 자리 측점 목록.""" + match = line_matcher(_pts(case, "old_xy"), _pts(case, "new_xy")) + moved = [] + for t in case["stations"]: + s = match(t) + if s is None: + moved.append(t) + continue + want = _plan_at(case["base_pvi"], case["edits"], s) + assert _plan_at(base_pvi, edits, t) == pytest.approx(want, abs=1e-3), t + return moved + + @pytest.mark.parametrize("index", range(len(CARRY_CASES))) def test_계획고_옮김_거울(ts, index): span, result = _py_carry(CARRY_CASES[index]) @@ -370,33 +405,32 @@ def test_계획고_옮김_거울(ts, index): assert edits == ts_result["edits"] -def test_가운데를_고치면_앞은_그대로_뒤는_밂(): - span, (base_pvi, edits) = _py_carry(CARRY_CASES[0]) +def test_가운데를_고치면_앞은_그대로_뒤는_같은자리_계획고(): + case = CARRY_CASES[0] + span, (base_pvi, edits) = _py_carry(case) assert span["start_fixed"] and span["end_fixed"] - delta = span["delta_m"] assert span["start_m"] == pytest.approx(math.dist(OLD_ROUTE[0], OLD_ROUTE[1])) - # 앞 편집 그대로 · 안(90.5) 버림 · 뒤(180 · 207.6) 길이 차만큼 + # 앞 편집 그대로 · 안(90.5) 버림 · 앞 R 그대로 · 같은 자리 새 측점 계획고 = 옛 계획고 assert edits["station_offsets"]["20.000"] == 0.5 assert "90.500" not in edits["station_offsets"] - assert edits["station_offsets"][f"{180.0 + delta:.3f}"] == 0.2 - assert edits["curve_radii"] == {"40.000": 300.0, f"{150.0 + delta:.3f}": 250.0} - old = {round(p["chainage_m"], 3): p["elevation_m"] for p in CARRY_CASES[0]["base_pvi"]} + assert edits["curve_radii"]["40.000"] == 300.0 + moved = _kept_plan(case, base_pvi, edits) + # 바뀐 자리는 새 지반 첫 계획선 꼭짓점 · 편집 없음 + ground = {round(p["chainage_m"], 3): p["elevation_m"] for p in case["ground_pvi"]} got = {round(p["chainage_m"], 3): p["elevation_m"] for p in base_pvi} - assert got[20.0] == old[20.0] and got[round(180.0 + delta, 3)] == old[180.0] - # 바뀐 구간 안은 새 지반 첫 계획선 꼭짓점 - ground = {round(p["chainage_m"], 3): p["elevation_m"] for p in CARRY_CASES[0]["ground_pvi"]} - inside = [s for s in got if span["start_m"] + 1e-6 < s < span["new_end_m"] - 1e-6] - assert inside and all(got[s] == ground[s] for s in inside) - assert min(got) == 0.0 and max(got) == pytest.approx(L_OLD + delta, abs=1e-3) + assert moved and all(got[round(t, 3)] == ground[round(t, 3)] for t in moved) + assert not {f"{t:.3f}" for t in moved} & set(edits["station_offsets"]) + assert min(got) == 0.0 and max(got) == pytest.approx(L_OLD + span["delta_m"], abs=1e-3) def test_끝을_고치면_끝_꼭짓점은_새_지반(): - span, (base_pvi, edits) = _py_carry(CARRY_CASES[1]) + case = CARRY_CASES[1] + span, (base_pvi, edits) = _py_carry(case) assert not span["end_fixed"] assert f"{L_OLD:.3f}" not in edits["station_offsets"] # 바뀐 끝 편집은 버림 - assert edits["station_offsets"]["90.500"] == -0.3 # 앞(바뀐 구간 앞) 편집 그대로 - ground_end = CARRY_CASES[1]["ground_pvi"][-1] + _kept_plan(case, base_pvi, edits) # 앞(90.5 편집 둘레) 계획고 그대로 + ground_end = case["ground_pvi"][-1] assert base_pvi[-1] == { - "chainage_m": round(ground_end["chainage_m"], 6), + "chainage_m": ground_end["chainage_m"], "elevation_m": ground_end["elevation_m"], } diff --git a/resources/tester/test_346_3_route_save.py b/resources/tester/test_346_3_route_save.py index 2dc07d578..4fdae39b3 100644 --- a/resources/tester/test_346_3_route_save.py +++ b/resources/tester/test_346_3_route_save.py @@ -522,7 +522,7 @@ def _longitudinal(total: float) -> dict: return {**longitudinal, "profile_alignment": alignment, "design_profiles": [profile]} -def test_계획고_옮김은_앞_그대로_뒤_밂(tmp_path, monkeypatch): +def test_계획고_옮김은_앞_그대로_뒤_같은자리(tmp_path, monkeypatch): new_xy = [list(p) for p in NEW] total = float(vertices_of(NEW)[-1].chainage_m) old = _longitudinal(400.0) @@ -548,10 +548,12 @@ def test_계획고_옮김은_앞_그대로_뒤_밂(tmp_path, monkeypatch): ) ) edits = json.loads(path.read_text("utf-8"))["profile_alignment"]["edits"]["station_offsets"] - delta = total - 400.0 assert edits["40.000"] == 0.7 # 바뀐 구간 앞 — 그대로 assert "200.000" not in edits # 바뀐 구간 안 — 새 지반 - assert edits[f"{380.0 + delta:.3f}"] == 0.3 # 뒤 — 길이 차만큼 밂 + # 뒤(옛 380 둘레) — 같은 자리 새 측점 위 편집으로 이어받음(400 · 길이 차만큼 민 글자 키 아님) + stations = {f"{row['chainage_m']:.3f}" for row in _longitudinal(total)["stations"]} + behind = [key for key in edits if float(key) > 300.0] + assert behind and set(behind) <= stations def test_저장_확정이_옛_횡단_파일을_지움(tmp_path): diff --git a/resources/tester/test_349_0v_route_save_span.py b/resources/tester/test_349_0v_route_save_span.py index 5f0e421c8..3de52f75d 100644 --- a/resources/tester/test_349_0v_route_save_span.py +++ b/resources/tester/test_349_0v_route_save_span.py @@ -2,9 +2,10 @@ """349-0v 손으로 고친 노선 [저장] — 미리보기 계획고 = 저장 뒤 계획고(바뀐 구간 거울). 화면 planline 단계: 옛 = 저장 계획 폴리라인(`read_route_plan` planned) · 새 = `buildEditedPolyline`(노드 -초안) → `changedSpan` → `carryProfileEdits`. 서버 노선 저장: 옛 = 같은 계획 폴리라인 CSV · 새 = -`_write_planned_polyline` 이 쓴 CSV → `changed_span` → `carry_profile_edits`. 두 길이 같은 구간 · 같은 -계획고를 내야 함(풀린 노선 geojson 으로 가르면 점 간격이 달라 앞머리가 일찍 갈렸음). +초안) → `changedSpan` → `carryBase` · `carryEdits`. 서버 노선 저장: 옛 = 같은 계획 폴리라인 +CSV · 새 = `_write_planned_polyline` 이 쓴 CSV → `changed_span` → `carry_base` · `carry_edits`(400). +두 길이 같은 구간 · 같은 계획고를 내야 함(풀린 노선 geojson 으로 가르면 점 간격이 달라 +앞머리가 일찍 갈렸음). """ from __future__ import annotations @@ -24,7 +25,8 @@ if str(ROOT) not in sys.path: from B05_Profile import B05_Profile_Router_Replan as replan # noqa: E402 from B05_Profile.B05_Profile_Engine_Grade_Carry import ( # noqa: E402 - carry_profile_edits, + carry_base, + carry_edits, changed_span, ) @@ -80,6 +82,7 @@ def world(tmp_path_factory): "base_pvi": base_pvi, "edits": edits, "ground_pvi": ground_pvi, + "stations": [p["chainage_m"] for p in ground_pvi], } source = tmp / "input.json" source.write_text(json.dumps(payload), encoding="utf-8") @@ -119,11 +122,18 @@ def test_옮긴_계획고_화면_서버_같음(world): span = changed_span( [tuple(p) for p in world["old_line"]], [tuple(p) for p in world["new_line_py"]] ) - base, edits = carry_profile_edits(world["base_pvi"], world["edits"], span, world["ground_pvi"]) + old, new = world["old_line"], world["new_line_py"] + base = carry_base(old, new, world["base_pvi"], world["ground_pvi"]) + edits = carry_edits(old, new, world["base_pvi"], world["edits"], world["stations"], 20.0) + assert span is not None ts = world["ts"]["result"] flat = [v for n in base for v in (n["chainage_m"], n["elevation_m"])] assert flat == pytest.approx( [v for n in ts["basePvi"] for v in (n["chainage_m"], n["elevation_m"])], abs=1e-3 ) # 서버 새 선 = CSV 0.1 mm 반올림 · 화면 = 원값 - assert edits == ts["edits"] - assert edits["station_offsets"]["150.000"] == 0.8 # 바뀐 구간 앞 사용자 계획고 그대로 + for field in ("station_offsets", "curve_radii"): + assert edits[field].keys() == ts["edits"][field].keys() + for key, value in edits[field].items(): + assert value == pytest.approx(ts["edits"][field][key], abs=1e-3) + # 바뀐 구간 앞 사용자 계획고(150) = 둘레 새 측점(120 · 160) 편집으로 그대로 + assert {"120.000", "160.000"} <= set(edits["station_offsets"]) diff --git a/resources/tester/test_400_profile_carry.py b/resources/tester/test_400_profile_carry.py new file mode 100644 index 000000000..b0f9021dd --- /dev/null +++ b/resources/tester/test_400_profile_carry.py @@ -0,0 +1,366 @@ +# -*- coding: utf-8 -*- +"""400 평면 고친 뒤 종단 편집 이어받기 — 같은 자리(옆 1 m · 방향 30° 안)는 옛 계획고를 새 측점 위에. + +거울(파이썬 = 서버 [저장] 정본 · TS = 브라우저 즉시 · 같은 값): + `B05_Profile_Engine_Grade_Carry` line_matcher · carry_base · carry_edits ↔ + `logic/B05_Profile_Grade_Carry.ts` lineMatcher · carryBase · carryEdits. +묶음 follow(`logic/B05_Profile_Pipeline_Grade.ts`) — 걸음 수와 무관(기준 한 벌에서 한 번) · +되돌린 노선 = 옛 편집. +""" + +from __future__ import annotations + +import json +import math +import subprocess +import sys +from pathlib import Path + +import numpy as np +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B05_Profile.B05_Profile_Engine_Grade_Alignment import resolve_pvi # noqa: E402 +from B05_Profile.B05_Profile_Engine_Grade_Carry import ( # noqa: E402 + SAME_LINE_TOL_M, + carry_base, + carry_edits, + line_matcher, +) + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "typescript").is_dir(), + reason="프론트엔드 의존성 미설치", +) + +INTERVAL = 20.0 +HALF = INTERVAL / 2 + + +# ── 입력 만들기 ─────────────────────────────────────────────────────────────── + + +def _dense(nodes: list[tuple[float, float]], step: float = 2.0) -> list[list[float]]: + """꺾은선을 `step` m 안팎 점으로(그린 노선처럼 정점이 많게).""" + out = [list(nodes[0])] + for (x0, y0), (x1, y1) in zip(nodes, nodes[1:]): + n = max(1, int(math.ceil(math.dist((x0, y0), (x1, y1)) / step))) + out += [[x0 + (x1 - x0) * k / n, y0 + (y1 - y0) * k / n] for k in range(1, n + 1)] + return out + + +def _length(line) -> float: + return sum(math.dist(a, b) for a, b in zip(line, line[1:])) + + +def _stations(line) -> list[float]: + total = _length(line) + grid = [round(k * INTERVAL, 3) for k in range(int(total // INTERVAL) + 1)] + return grid + ([round(total, 3)] if total - grid[-1] > 1e-6 else []) + + +def _ground(s: float) -> float: + return 100.0 + 0.05 * s + 2.0 * math.sin(s / 37.0) + + +def _ground_pvi(line) -> list[dict]: + """새 노선 지반 첫 계획선 꼴(측점마다 지반 · 옛 계획고와 다르게 +3 m).""" + return [{"chainage_m": s, "elevation_m": round(_ground(s) + 3.0, 6)} for s in _stations(line)] + + +OLD_NODES = [(0.0, 0.0), (200.0, 0.0), (400.0, 100.0), (600.0, 100.0), (800.0, 0.0)] +OLD = _dense(OLD_NODES) +OLD_BASE = [{"chainage_m": s, "elevation_m": round(_ground(s), 6)} for s in _stations(OLD)] + + +def _old_edits() -> dict: + """직선화(40 ~ 120 사이 측점) · ▲ 1 m(300) · 종단곡선 R(300) · 자유지정 꺾임(520 · 540).""" + base = {n["chainage_m"]: n["elevation_m"] for n in OLD_BASE} + z40, z120 = base[40.0], base[120.0] + offsets = {} + for s in (60.0, 80.0, 100.0): + offsets[f"{s:.3f}"] = round(z40 + (z120 - z40) * (s - 40) / 80 - base[s], 6) + offsets["300.000"] = 1.0 + offsets["520.000"] = -0.8 + offsets["540.000"] = -1.2 + return {"station_offsets": offsets, "curve_radii": {"300.000": 400.0, "520.000": 250.0}} + + +OLD_EDITS = _old_edits() + + +def _moved(index: int, to: tuple[float, float]) -> list[list[float]]: + nodes = list(OLD_NODES) + nodes[index] = to + return _dense(nodes) + + +CASES = { + # 먼 교점(끝 앞) 하나 옮김 — 앞쪽 편집 그대로 + "far": _moved(3, (620.0, 130.0)), + # 시점을 뒤로 15 m 늘림 — 같은 선 · 누가거리만 +15(새 측점은 옛 측점 사이) + "start": _dense([(-15.0, 0.0)] + OLD_NODES[1:]), + # 끝을 같은 방향으로 늘림 — 옛 끝까지 그대로 · 넘는 자리만 지반 + "end": _dense(OLD_NODES[:-1] + [(840.0, -20.0)]), + # 측점 스냅처럼 뒤 교점이 옆으로 0.6 m(같은 자리) · 1.6 m(바뀐 자리) 밀림 + "snap": _dense(OLD_NODES[:2] + [(400.0, 100.6), (600.0, 101.6), (800.0, 1.6)]), + "same": OLD, +} + + +def _xy(line): + return [{"x": x, "y": y} for x, y in line] + + +def _plan(base: list[dict], edits: dict) -> tuple[np.ndarray, np.ndarray]: + s = np.array([n["chainage_m"] for n in base], dtype=np.float64) + z = np.array([n["elevation_m"] for n in base], dtype=np.float64) + pvi_s, pvi_z, _ = resolve_pvi(s, z, edits["station_offsets"]) + return pvi_s, pvi_z + + +def _py(name: str) -> tuple[list[dict], dict]: + new = CASES[name] + base = carry_base(OLD, new, OLD_BASE, _ground_pvi(new)) + edits = carry_edits(OLD, new, OLD_BASE, OLD_EDITS, _stations(new), HALF) + return base, edits + + +# ── TS 한 번 ───────────────────────────────────────────────────────────────── + +FOLLOW_STEPS = [ + # 기준 → 먼 교점 옮김 → 더 옮김 · 한 번에 옮김과 같아야 + [("far1", _moved(3, (610.0, 115.0))), ("far2", CASES["far"])], + [("far2", CASES["far"])], + # 바뀐 자리를 거쳐 되돌림 — 옛 편집 그대로 + [("mid", _moved(4, (830.0, 30.0))), ("back", OLD)], +] + + +# 측점 스냅 켠 노선 — 앞 교점 하나 옮기면 뒤 교점이 모두 MC 맞춤으로 밀림(StationFit) +SNAP = { + "nodes": [[0, 0], [150, 30], [300, 0], [450, 40], [600, 10], [750, 50], [900, 20], [1050, 40]], + "index": 1, + "to": [150.0, 37.0], + "limits": {"minRadiusM": 60.0, "limitRadiusM": 0.0, "limitArcM": 0.0, "intervalM": INTERVAL}, +} + + +@pytest.fixture(scope="module") +def ts(tmp_path_factory) -> dict: + queries = [0.0, 33.3, 210.0, 430.0, 500.0, 700.0, 815.0, 1e6] + payload = { + "match": [ + {"old_xy": _xy(OLD), "new_xy": _xy(line), "tol": SAME_LINE_TOL_M, "queries": queries} + for line in CASES.values() + ], + "base": [ + { + "old_xy": _xy(OLD), + "new_xy": _xy(line), + "old_base": OLD_BASE, + "ground_pvi": _ground_pvi(line), + } + for line in CASES.values() + ], + "edits": [ + { + "old_xy": _xy(OLD), + "new_xy": _xy(line), + "old_base": OLD_BASE, + "edits": OLD_EDITS, + "stations": _stations(line), + "half": HALF, + } + for line in CASES.values() + ], + "follow": [ + { + "ref_line": OLD, + "policy": {"station_interval_m": INTERVAL}, + "base_pvi": OLD_BASE, + "edits": OLD_EDITS, + "steps": [{"line": line, "stations": _stations(line)} for _, line in steps], + } + for steps in FOLLOW_STEPS + ], + "snap": [SNAP], + } + source = tmp_path_factory.mktemp("s400") / "in.json" + source.write_text(json.dumps(payload), encoding="utf-8") + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_400_profile_carry.cjs"), str(source)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + check=False, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +# ── 거울 ───────────────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize("index", range(len(CASES))) +def test_거울_같은자리_꼭짓점_편집(ts, index): + name, line = list(CASES.items())[index] + match = line_matcher(OLD, line) + for t, got in zip([0.0, 33.3, 210.0, 430.0, 500.0, 700.0, 815.0, 1e6], ts["match"][index]): + want = match(t) + assert (want is None) == (got is None), (name, t) + if want is not None: + assert want == pytest.approx(got, abs=1e-9) + base, edits = _py(name) + assert len(base) == len(ts["base"][index]) + for a, b in zip(base, ts["base"][index]): + assert a["chainage_m"] == pytest.approx(b["chainage_m"], abs=1e-9) + assert a["elevation_m"] == pytest.approx(b["elevation_m"], abs=1e-6) + got = ts["edits"][index] + for field in ("station_offsets", "curve_radii"): + assert edits[field].keys() == got[field].keys(), (name, field) + for key, value in edits[field].items(): + assert value == pytest.approx(got[field][key], abs=1e-6) + + +# ── 규칙 ───────────────────────────────────────────────────────────────────── + + +def _same_place_plan(name: str): + """같은 자리 새 측점마다 (새 계획고, 옛 계획고) · 바뀐 자리 측점.""" + new = CASES[name] + base, edits = _py(name) + match = line_matcher(OLD, new) + old_s, old_z = _plan(OLD_BASE, OLD_EDITS) + new_s, new_z = _plan(base, edits) + kept, moved = [], [] + for t in _stations(new): + s = match(t) + if s is None: + moved.append(t) + else: + kept.append((t, float(np.interp(t, new_s, new_z)), float(np.interp(s, old_s, old_z)))) + return kept, moved, base, edits + + +@pytest.mark.parametrize("name", ["far", "start", "end", "snap", "same"]) +def test_같은자리_계획고_그대로(name): + kept, _, _, _ = _same_place_plan(name) + assert kept + for t, new_z, old_z in kept: + # 옛 자동 꼭짓점 0.006 m 안 측점은 그 꼭짓점 위로 봄 — 기울기 차 × 0.006 m 안 + assert new_z == pytest.approx(old_z, abs=1e-3), (name, t) + + +def test_먼교점_옮김_앞쪽_편집_누가거리_그대로_바뀐자리만_지반(): + kept, moved, base, edits = _same_place_plan("far") + # 앞쪽(같은 선 · 같은 측점) 편집 글자 그대로 + for key in ("60.000", "80.000", "100.000", "300.000"): + assert edits["station_offsets"][key] == pytest.approx(OLD_EDITS["station_offsets"][key]) + assert edits["curve_radii"]["300.000"] == 400.0 + # 바뀐 자리 = 옮긴 교점 둘레 · 그 측점은 지반 첫 계획선 · 편집 없음(옛 520 · 540 자유지정 버림) + assert moved and min(moved) > 423.0 + ground = {n["chainage_m"]: n["elevation_m"] for n in _ground_pvi(CASES["far"])} + by = {n["chainage_m"]: n["elevation_m"] for n in base} + assert all(by[t] == ground[t] for t in moved) + assert not any(float(k) in moved for k in edits["station_offsets"]) + + +def test_시점_늘림_누가거리_투영_새측점_위(): + kept, moved, _, edits = _same_place_plan("start") + assert not moved or max(moved) < 1.0 + stations = set(_stations(CASES["start"])) + assert edits["station_offsets"] and all(float(k) in stations for k in edits["station_offsets"]) + assert all(float(k) in stations for k in edits["curve_radii"]) + # ▲(옛 300) 의 R 은 옛 300 = 새 315 에서 가장 가까운 새 측점(320 · 옛 305) + assert edits["curve_radii"].get("320.000") == 400.0 + + +def test_끝_늘림_옛끝까지_이어받고_넘는자리만_지반(): + kept, moved, base, _ = _same_place_plan("end") + old_len = _length(OLD) + assert moved and min(moved) > old_len + assert max(t for t, _, _ in kept) > old_len - INTERVAL + + +def test_스냅처럼_옆으로_밀림_1m_안_이어받고_넘으면_다시(): + match = line_matcher(OLD, CASES["snap"]) + assert match(410.0) is not None # 0.6 m 쪽 + assert match(650.0) is None # 1.6 m 쪽 + _, _, _, edits = _same_place_plan("snap") + assert "300.000" in edits["station_offsets"] + + +def test_종단_안_고친_프로젝트는_편집_없음_꼭짓점은_옛것_또는_지반(): + empty = {"station_offsets": {}, "curve_radii": {}} + new = CASES["far"] + assert carry_edits(OLD, new, OLD_BASE, empty, _stations(new), HALF) == empty + base = carry_base(OLD, new, OLD_BASE, _ground_pvi(new)) + old_s = [n["chainage_m"] for n in OLD_BASE] + old_z = [n["elevation_m"] for n in OLD_BASE] + ground = {n["chainage_m"]: n["elevation_m"] for n in _ground_pvi(new)} + match = line_matcher(OLD, new) + for node in base: + t = node["chainage_m"] + s = match(t) + want = ground[t] if s is None else round(float(np.interp(s, old_s, old_z)), 6) + assert node["elevation_m"] == pytest.approx(want, abs=1e-9) + + +def test_엇갈려_지나는_선은_같은자리_아님(): + crossing = _dense([(100.0, -50.0), (100.0, 50.0)]) + assert line_matcher(OLD, crossing)(50.0) is None + reverse = OLD[::-1] + assert line_matcher(OLD, reverse)(100.0) is None + + +# ── 묶음 follow ────────────────────────────────────────────────────────────── + + +def test_follow_걸음수와_무관_서버와_같은값(ts): + stepwise, direct, back = ts["follow"] + assert stepwise[-1] == direct[-1] + _, edits = _py("far") + got = direct[-1] + assert edits["station_offsets"].keys() == got["station_offsets"].keys() + for key, value in edits["station_offsets"].items(): + assert value == pytest.approx(got["station_offsets"][key], abs=1e-6) + assert edits["curve_radii"] == got["curve_radii"] + + +def test_follow_바뀐자리_거쳐_되돌리면_옛편집(ts): + _, _, back = ts["follow"] + assert "520.000" in back[0]["station_offsets"] # 끝 교점 옮김 — 앞쪽(먼) 편집은 남음 + assert back[-1] == OLD_EDITS + + +def test_측점스냅_켠_노선_뒤_교점_밀려도_같은자리_편집_이어받음(ts): + got = ts["snap"][0] + old_line, new_line = got["old_line"], got["new_line"] + shifts = [math.dist(a, b) for a, b in zip(got["old_nodes"][2:], got["new_nodes"][2:])] + assert max(shifts) > 0.5 # 뒤 교점이 정말 밀림 — 1 mm 정점 비교로는 뒤쪽 전부 「바뀐 구간」 + stations = _stations(old_line) + base = [{"chainage_m": t, "elevation_m": round(_ground(t), 6)} for t in stations] + far = [t for t in stations if 600.0 <= t <= 900.0] + offsets = {f"{t:.3f}": round(0.5 + 0.001 * t, 6) for t in far} # 뒤쪽 직선화 · ▲ 몫 + edits = {"station_offsets": offsets, "curve_radii": {}} + new_stations = _stations(new_line) + new_base = carry_base(old_line, new_line, base, _ground_pvi(new_line)) + new_edits = carry_edits(old_line, new_line, base, edits, new_stations, HALF) + match = line_matcher(old_line, new_line) + old_s, old_z = _plan(base, edits) + new_s, new_z = _plan(new_base, new_edits) + carried = [t for t in new_stations if f"{t:.3f}" in new_edits["station_offsets"]] + assert ( + len(carried) >= len(far) // 2 + ) # 뒤쪽 편집 절반 넘게 이어받음(나머지 = 1 m 넘게 밀린 곡선 둘레) + for t in carried: + s = match(t) + assert float(np.interp(t, new_s, new_z)) == pytest.approx( + float(np.interp(s, old_s, old_z)), abs=1e-3 + )