From 29ef36e20ddbf82d629ab2d887533ef4497c37ae Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 21:29:19 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(=EB=85=B8=EC=84=A0=EC=B6=94=EC=B2=9C):?= =?UTF-8?q?=20351-2=20=EA=B3=A1=EC=84=A0=20MC=20=EC=9D=BC=EB=B0=98?= =?UTF-8?q?=EC=B8=A1=EC=A0=90=20=EB=A7=9E=EC=B6=A4=20=E2=80=94=20=EA=B3=A0?= =?UTF-8?q?=EB=A5=B8=20=EC=84=A0=EC=9D=98=20=EA=B3=A1=EC=84=A0=20=EA=B0=80?= =?UTF-8?q?=EC=9A=B4=EB=8D=B0=EB=A5=BC=2020=20m=20=EC=B8=A1=EC=A0=90=20?= =?UTF-8?q?=EC=9C=84=EB=A1=9C(IP=20=EB=B0=80=EA=B8=B0=20=C2=B7=20R=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=A4=20=C2=B7=20=ED=95=A9=EC=B9=A8=20=C2=B7=20?= =?UTF-8?q?=EB=AA=BB=20=EB=86=93=EB=8A=94=20=EA=B3=A1=EC=84=A0=EB=A7=8C=20?= =?UTF-8?q?=EA=B7=B8=20=EC=9E=90=EB=A6=AC)=20=C2=B7=20=EB=A7=9E=EC=B6=98?= =?UTF-8?q?=20=EC=84=A0=20=C2=B7=20=EB=A7=9E=EC=B6=94=EA=B8=B0=20=EC=A0=84?= =?UTF-8?q?=20=EC=84=A0=20=EB=91=90=20=EB=B2=8C=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=C2=B7=20=EB=AA=A8=EB=8B=AC=20=E3=80=8C=EC=B8=A1=EC=A0=90=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=A4=E3=80=8D=20=EC=BC=AC=EB=81=94=20=C2=B7=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20=EC=9B=90=ED=98=B8=20=EB=AF=B8=EB=A6=AC=20?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=C2=B7=20=E3=80=8C=EC=B8=A1=EC=A0=90=20?= =?UTF-8?q?=EB=B0=96=20=EA=B3=A1=EC=84=A0=20N=EA=B0=9C=E3=80=8D=20=C2=B7?= =?UTF-8?q?=20=EC=9D=B4=ED=83=88=20=ED=95=9C=EB=8F=84=206=20m=20=C2=B7=20A?= =?UTF-8?q?LGO=5FVERSION=203=20=C2=B7=20=E3=80=8C=EC=9E=AC=EA=B3=84?= =?UTF-8?q?=EC=82=B0=E3=80=8D=20=EC=9D=B4=EB=A6=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD --- .../B05_Profile_Engine_RouteInitial.py | 84 +++- ...B05_Profile_Engine_RouteInitial_Station.py | 438 ++++++++++++++++++ .../B05_Profile_Engine_RouteInitial_Store.py | 2 +- .../B05_Profile_Router_RouteInitial.py | 10 +- .../B05_Profile_UI_RouteEdit_Initial_Model.ts | 27 +- .../B05_Profile_UI_RouteEdit_Recommend.ts | 54 ++- config/config_system_design.py | 2 + resources/tester/test_351_1_recommend_gaps.py | 2 +- resources/tester/test_351_2_curve_stations.py | 227 +++++++++ resources/tester/test_route_initial.py | 21 +- 10 files changed, 826 insertions(+), 41 deletions(-) create mode 100644 B05_Profile/B05_Profile_Engine_RouteInitial_Station.py create mode 100644 resources/tester/test_351_2_curve_stations.py diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial.py b/B05_Profile/B05_Profile_Engine_RouteInitial.py index dff6e4f64..71472b785 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial.py @@ -56,8 +56,16 @@ from pathlib import Path from typing import Any import numpy as np +from scipy.interpolate import RegularGridInterpolator -from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import GRADE_SMOOTH_M, route_metrics +from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import ( + GRADE_SMOOTH_M, + SAMPLE_M, + STATION_M, + _resample, + route_metrics, + smoothed_ground, +) from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import ( Pool, build_pool, @@ -78,6 +86,10 @@ from B05_Profile.B05_Profile_Engine_RouteInitial_Search import ( SearchGrid, Terrain, ) +from B05_Profile.B05_Profile_Engine_RouteInitial_Station import ( + off_station_count, + snap_curves_to_stations, +) from common_util.common_util_route_polyline import build_planned_polyline __all__ = ["NoRoute", "Terrain", "generate_initial_route"] @@ -315,6 +327,24 @@ def generate_initial_route( target_grade=goal_grade, ) + smooth_z = RegularGridInterpolator( + (terrain.y, terrain.x), + smoothed_ground(terrain, grade_smooth), + bounds_error=False, + fill_value=None, + ) + + def grade_excess(points: list[tuple[float, float]]) -> float: + # 측점 맞춤(351-2)이 옮긴 조각 — 20 m 사이 고른 지반 기울기(비교값과 같은 잣대)가 상한을 + # 넘은 만큼. 20 m 보다 짧으면 양 끝 사이. + xy, chain = _resample(points, SAMPLE_M) + z = smooth_z(xy[:, ::-1]) + span = min(int(round(STATION_M / SAMPLE_M)), len(z) - 1) + if span < 1: + return 0.0 + run = np.maximum(chain[span:] - chain[:-span], 1e-9) + return max(0.0, float((np.abs(z[span:] - z[:-span]) / run).max()) - max_grade) + def follow_line() -> tuple[Any, dict[str, Any]]: # 예상노선 추적 = 전처리 초기값(`planned_route_initial.csv`)과 같은 규칙 · 같은 입력 → # 같은 선(`_write_planned_polyline` 기본값). 제약은 따지지 않고 넘은 곳만 목록에 뜸. @@ -365,12 +395,45 @@ def generate_initial_route( for route in [*pool.candidates, pool.follow]: profiled(route, exact=False) # 줄 세우기 — 고른 하나만 아래에서 바로 셈 outline, metrics = pick(target, pool.candidates, with_profile) - if with_profile: - profiled((outline, metrics)) - if pipes and "error" not in metrics["profile"]: - metrics["profile"].update(cover_shortfall(metrics["profile"], outline.vertices, pipes)) - else: - metrics = plain_metrics(metrics) + # 측점 맞춤(351-2 · 브레인 확정) — 고른 선의 곡선 MC 를 일반측점에 놓은 선을 늘 함께 냄(모달 + # 「측점 맞춤」 켬 = 이 선 · 끔 = 고른 선 그대로). 갈래는 옮긴 조각이 원래보다 더 가파르면 그 + # 곡선만 그 자리(추적은 제약을 안 따짐). 비교값은 맞춘 선으로 다시 셈. + raw = (outline, metrics) + moved = snap_curves_to_stations( + outline, min_radius_m, excess=None if target == "follow" else grade_excess + ) + snapped = ( + raw + if moved is None or moved is outline + else ( + moved, + measure( + moved, + bound=bound, + follow=target == "follow", + weighted=target == "weighted", + ), + ) + ) + + def finish(route: tuple[Any, dict[str, Any]]) -> dict[str, Any]: + line, values = route + if with_profile: + profiled(route) + if pipes and "error" not in values["profile"]: + values["profile"].update(cover_shortfall(values["profile"], line.vertices, pipes)) + else: + values = plain_metrics(values) + return { + "planned": [[round(x, 4), round(y, 4)] for x, y in line.vertices], + "nodes": [node.as_dict() for node in line.nodes], + "curves": [curve.as_dict() for curve in line.curves], + # MC 가 일반측점 밖인 곡선 수(못 맞춘 곡선은 그 자리) — 모달 상태줄 + "off_station_curves": off_station_count(line), + "metrics": values, + } + + shown = finish(snapped) return { "mode": mode, "logic": mode, @@ -378,10 +441,9 @@ def generate_initial_route( "relaxed": bound is not None, # 지형상 불가능할 때만 — 복도 안 어떤 길도 넘어야 하는 기울기(%) · 그 위는 막았음 "terrain_bound_pct": None if bound is None else round(bound * 100.0, 2), - "planned": [[round(x, 4), round(y, 4)] for x, y in outline.vertices], - "nodes": [node.as_dict() for node in outline.nodes], - "curves": [curve.as_dict() for curve in outline.curves], - "metrics": metrics, + **shown, + # 측점 맞춤 끔 = 고른 선 그대로(맞출 곡선이 없거나 못 맞췄으면 None — 위와 같음) + "unsnapped": None if snapped is raw else finish(raw), # 10 m 안인 다른 갈래(같은 노선 알림) · 제약 안 띠 폭 어림 — 추적은 묶음이 이미 있을 때만 "similar": None if pool is None else similar(pool, target, outline, with_profile), "with_profile": with_profile, diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Station.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Station.py new file mode 100644 index 000000000..64791c14a --- /dev/null +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Station.py @@ -0,0 +1,438 @@ +"""추천 노선 곡선 MC → 일반측점(PLAN 351-2 · 요구 235-3). + +`B05_Profile_Engine_RouteInitial.generate_initial_route` 끝이 고른 선(다섯 갈래 · 큰 R 뒤)에 +부름 — 맞춘 선 · 맞추기 전 선 두 벌을 냄(모달 「측점 맞춤」 켬끔 · 브레인 확정). 전처리 초기 +노선 · 손 편집 · [측점 스냅]은 안 거침. + +일반측점 = 누가거리 20 m 격자(No.N+0). 곡선 가운데 MC(BC · EC 누가거리 가운데 = 곡선 중심과 +마주치는 법선 자리)를 일반측점 위에 둔다 → 곡선 수 ≤ 내부 일반측점 수. 앞 곡선이 뒤 누가거리를 +바꾸므로 시점부터 차례로: + + · 곡선마다 교각점(IP)을 들어오는 접선 따라 밀고(앞 곡선 · 앞 직선은 그대로) R 을 맞춰 MC 를 + 가까운 일반측점에 놓음 — 앞 MC 보다 20 m 이상 뒤 · 원래 선에서 `ROUTE_STATION_SNAP_MAX_GAP_M` + 안 · 최소 R 미달을 새로 만들지 않음. + · 못 놓으면 다음 꺾임과 합침(같은 쪽으로 돌면 두 접선 교점 · 아니면 다음 IP 를 뺌) → 그래도 + 안 되면 그 곡선을 끔(꺾임만) → 그래도 이탈 한도를 넘으면 실패. + · 모두 놓는 길이 없으면 못 놓는 곡선만 그 자리에 두고 나머지는 맞춤(부분 · 「측점 밖 곡선 N개」). + · 그것도 못 하면 None — 부르는 쪽이 원래 선을 씀. + +고른 값은 편집 갈래(`simplify=False` · 곡선 켬 · `radii`)로 다시 짠다 — 화면이 곡선을 교각점 +하나로 펴서(`flattenServerPlan`) [확인] 때 다시 짜는 입력과 같아 저장 뒤에도 MC 가 측점 위(TS 짝 +불필요). 누가거리는 짜인 선의 정점(원호 5° 현)을 따라 잼 — 측점 생성과 같은 잣대. +""" + +from __future__ import annotations + +import math +from collections.abc import Callable, Iterator +from dataclasses import dataclass + +import numpy as np +import shapely +from shapely.geometry import LineString, Point + +from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import _flat +from common_util.common_util_route_polyline import ( + STRAIGHT_INNER_ANGLE_DEG, + PlannedPolyline, + _arc_geometry, + _arc_points, + _inner_angle_deg, + _line_intersection, + _turn_sign, + build_planned_polyline, +) +from config.config_system_design import ROUTE_STATION_SNAP_MAX_GAP_M + +Xy = tuple[float, float] +# 점 묶음 → 지반 기울기가 상한을 넘은 만큼(0 = 상한 안) +Excess = Callable[[list[Xy]], float] + +STATION_M = 20.0 +# MC 가 측점에서 이만큼 안이면 「위」(m) — 저장 반올림(교각점 0.1 mm · R 1 mm)을 넉넉히 덮음 +MC_TOL_M = 0.05 +# IP 를 밀 수 있는 거리(m) — 다음 측점까지 한 칸(실제 한도는 원래 선에서 벗어남) +SHIFT_MAX_M = STATION_M +# 곡선 접선이 뒤 직선 절반에서 남길 여유(m) — 다음 IP 를 뒤로 이만큼 두 배 밀 수 있음 +SLACK_M = 5.0 +# 한 곡선이 뒤 꺾임을 몇 번까지 합쳐 볼지 +MERGE_TRIES = 3 +# R 맞춤 후보 수(최소 R ~ 자리 R 사이 · 지금 R 에 가까운 것부터) +RADIUS_STEPS = 8 +# 막혀 앞 곡선의 다음 갈래로 되돌아가는 횟수 한도 — 넘으면 부분 맞춤으로 +BACKTRACK_LIMIT = 30 +# 한 꺾임 꼴(그대로 · 합친 꼴마다)에서 볼 놓기 수 — 막히면 R · 측점만 바꾸기보다 합치기로 +PER_VARIANT = 3 + + +@dataclass +class _Corner: + arc: list[Xy] # BC · 원호 점 · EC — 짜인 선 정점 그대로 + radius: float + tangent: float + length: float # 정점을 따라 잰 BC → EC + + +@dataclass +class _Plan: + points: list[Xy] + flags: list[bool] + radii: list[float | None] + + def copy(self) -> _Plan: + return _Plan(list(self.points), list(self.flags), list(self.radii)) + + +def _corner(before: Xy, at: Xy, after: Xy, radius: float) -> _Corner | None: + """`build_planned_polyline` 편집 갈래 곡선 하나와 같은 셈(접선 자리 모자라면 R 줄임).""" + inner = _inner_angle_deg(before, at, after) + half_tan = math.tan(math.radians(180.0 - inner) / 2) + available = min(math.dist(before, at), math.dist(at, after)) / 2 + if half_tan <= 1e-9 or available <= 0 or radius <= 0: + return None + tangent = radius * half_tan + if tangent > available: + radius, tangent = available / half_tan, available + geometry = _arc_geometry(before, at, after, inner, radius, half_tan) + if geometry is None: + return None + start, end, center = geometry + clockwise = _turn_sign(before, at, after) < 0 + arc = [start, *_arc_points(center, start, end, clockwise=clockwise), end] + length = sum(math.dist(a, b) for a, b in zip(arc, arc[1:])) + return _Corner(arc, radius, tangent, length) + + +def curve_mc_chainages(outline: PlannedPolyline) -> list[float]: + """곡선마다 MC 누가거리 — 짜인 선 정점을 따라 잰 (BC + EC) / 2(측점 스냅과 같은 정의).""" + vertices = outline.vertices + coords = np.asarray(vertices, dtype=float) + along = np.concatenate([[0.0], np.cumsum(np.hypot(*np.diff(coords, axis=0).T))]) + found: list[float] = [] + cursor = 0 + for curve in outline.curves: + first = next(i for i in range(cursor, len(vertices)) if vertices[i] == curve.start) + last = next(i for i in range(first, len(vertices)) if vertices[i] == curve.end) + found.append(float(along[first] + along[last]) / 2) + cursor = last + return found + + +def station_hits(outline: PlannedPolyline, tol: float = MC_TOL_M) -> int: + """MC 가 일반측점(No.1 이상) 위인 곡선 수 — 같은 측점 둘은 하나만 셈.""" + stations = { + n + for mc in curve_mc_chainages(outline) + if (n := round(mc / STATION_M)) >= 1 and abs(mc - n * STATION_M) <= tol + } + return len(stations) + + +def off_station_count(outline: PlannedPolyline) -> int: + """측점 밖 곡선 수 — 모달 상태줄 「측점 밖 곡선 N개」.""" + return len(outline.curves) - station_hits(outline) + + +def _unit(a: Xy, b: Xy) -> Xy: + length = math.dist(a, b) + return ((b[0] - a[0]) / length, (b[1] - a[1]) / length) + + +class _Reference: + """원래 선 — 맞춘 조각과 같은 자리만 잘라 잼(정점 배열 · 누가거리).""" + + def __init__(self, vertices: list[Xy]) -> None: + # 겹친 정점(길이 0 조각)은 뺌 — 내려 잴 때 NaN + vertices = [xy for i, xy in enumerate(vertices) if not i or xy != vertices[i - 1]] + self.line = LineString(vertices) + self.coords = np.asarray(vertices, dtype=float) + self.along = np.concatenate([[0.0], np.cumsum(np.hypot(*np.diff(self.coords, axis=0).T))]) + + def window(self, local: list[Xy]) -> list[Xy]: + """맞춘 조각과 같은 자리의 원래 선 — 조각 양 끝을 내린 사이.""" + s0, s1 = sorted(self.line.project(Point(xy)) for xy in (local[0], local[-1])) + inside = self.coords[(self.along > s0) & (self.along < s1)] + ends = [ + ( + np.interp(s, self.along, self.coords[:, 0]), + np.interp(s, self.along, self.coords[:, 1]), + ) + for s in (s0, s1) + ] + return [ends[0], *map(tuple, inside), ends[1]] + + def gap(self, local: list[Xy]) -> float: + """맞춘 조각과 원래 선의 같은 자리 사이 가장 먼 거리(m).""" + piece = LineString([xy for i, xy in enumerate(local) if not i or xy != local[i - 1]]) + window = LineString(self.window(local)) + gap = float(shapely.hausdorff_distance(piece, window, densify=0.1)) + return gap if math.isfinite(gap) else math.inf + + +@dataclass +class _State: + """차례 셈의 한 걸음 — 곡선 k 앞까지 정해진 선.""" + + plan: _Plan + k: int # 다음에 볼 꺾임점 + end_xy: Xy # 앞 요소 끝(앞 곡선 EC 또는 앞 꺾임점) + end_s: float + prev_tangent: float # 앞 곡선 접선 길이 — 그 곡선 R 이 줄지 않게 + last_station: float # 앞 MC 측점(없으면 0 = 시점) + placed: int = 0 # 측점에 놓은 곡선 수 + + +def _try(state: _State, plan: _Plan, k: int, at: Xy, radius: float) -> tuple[_Corner, float] | None: + """IP 를 `at` 에 두고 R 을 준 곡선과 그 MC — 앞 곡선이 줄거나 BC 가 앞 요소 앞이면 None.""" + before, after = plan.points[k - 1], plan.points[k + 1] + corner = _corner(before, at, after, radius) + if corner is None or state.prev_tangent > math.dist(before, at) / 2 + 1e-6: + return None + u = _unit(before, at) + bc = corner.arc[0] + if (bc[0] - state.end_xy[0]) * u[0] + (bc[1] - state.end_xy[1]) * u[1] < -1e-6: + return None + return corner, state.end_s + math.dist(state.end_xy, bc) + corner.length / 2 + + +def _solve( + state: _State, plan: _Plan, k: int, target: float, radius: float +) -> tuple[Xy, _Corner, float] | None: + """IP 를 들어오는 접선 따라 d 만큼 밀어 MC = target — 할선법(기울기 ≈ 1).""" + base = plan.points[k] + u = _unit(plan.points[k - 1], base) + + def at_of(d: float) -> Xy: + return (round(base[0] + u[0] * d, 4), round(base[1] + u[1] * d, 4)) + + def miss(d: float) -> float | None: + found = _try(state, plan, k, at_of(d), radius) + return None if found is None else found[1] - target + + # 앞 곡선 접선이 앞 직선 절반을 다 쓰면 뒤로는 못 감 — 앞으로 조금 민 데서 시작 + d0 = next((d for d in (0.0, 1.0, 3.0, -1.0) if miss(d) is not None), None) + if d0 is None: + return None + f0 = miss(d0) + assert f0 is not None + d1 = max(-SHIFT_MAX_M, min(SHIFT_MAX_M, d0 - f0)) + for _ in range(10): + if abs(f0) <= MC_TOL_M / 5: + done = _try(state, plan, k, at_of(d0), radius) + return None if done is None else (at_of(d0), done[0], done[1]) + f1 = miss(d1) + if f1 is None or d1 == d0: + return None + slope = (f1 - f0) / (d1 - d0) + if abs(slope) < 0.2: + return None + d0, f0 = d1, f1 + d1 = max(-SHIFT_MAX_M, min(SHIFT_MAX_M, d1 - f1 / slope)) + return None + + +def _absorb_next(plan: _Plan, k: int) -> bool: + """곡선 k 에 뒤 꺾임 하나를 합침 — 같은 쪽 = 두 접선 교점 · 아니면 다음 IP 뺌.""" + if k + 2 >= len(plan.points): + return False + before, at, nxt, beyond = (plan.points[i] for i in range(k - 1, k + 3)) + same = _turn_sign(before, at, nxt) == _turn_sign(at, nxt, beyond) != 0 + cross = _line_intersection(before, at, nxt, beyond) if same else None + ahead = cross is not None and ( + (cross[0] - before[0]) * (at[0] - before[0]) + (cross[1] - before[1]) * (at[1] - before[1]) + > 0 + and (beyond[0] - cross[0]) * (beyond[0] - nxt[0]) + + (beyond[1] - cross[1]) * (beyond[1] - nxt[1]) + > 0 + ) + radius = max(r for r in (plan.radii[k], plan.radii[k + 1], 0.0) if r is not None) + if ahead: + assert cross is not None + plan.points[k : k + 2] = [(round(cross[0], 4), round(cross[1], 4))] + else: + del plan.points[k + 1] + plan.flags[k : k + 2] = [True] + plan.radii[k : k + 2] = [radius or None] + return True + + +class _Snapper: + def __init__( + self, + outline: PlannedPolyline, + min_radius_m: float, + max_gap_m: float, + excess: Excess | None, + ) -> None: + points, flags, radii = _flat(outline) + points = [(round(x, 4), round(y, 4)) for x, y in points] # 화면이 펴는 자릿수 + self.start = _State(_Plan(points, flags, radii), 1, points[0], 0.0, 0.0, 0.0) + self.reference = _Reference(outline.vertices) + self.min_radius = min_radius_m + self.max_gap = max_gap_m + self.excess = excess + self.keep = False + + def _steeper(self, local: list[Xy]) -> bool: + """맞춘 조각이 원래 선 같은 자리보다 상한을 더 넘나.""" + if self.excess is None: + return False + return self.excess(local) > self.excess(self.reference.window(local)) + 1e-6 + + @staticmethod + def _kink(state: _State, plan: _Plan) -> _State: + at = plan.points[state.k] + end_s = state.end_s + math.dist(state.end_xy, at) + return _State(plan, state.k + 1, at, end_s, 0.0, state.last_station, state.placed) + + def _placements(self, state: _State, plan: _Plan) -> Iterator[_State]: + """곡선 k 를 남은 측점에 놓은 걸음들 — 가까운 측점 · 지금 R 에 가까운 것부터.""" + k = state.k + before, at, after = plan.points[k - 1], plan.points[k], plan.points[k + 1] + start = _try(state, plan, k, at, plan.radii[k] or self.min_radius) + if start is None: + return + corner, mc = start + floor_r = min(self.min_radius, corner.radius) + half_tan = math.tan(math.radians(180.0 - _inner_angle_deg(before, at, after)) / 2) + room = min(math.dist(before, at), math.dist(at, after)) / 2 / half_tan + steps = np.geomspace(max(floor_r, 1e-3), room, RADIUS_STEPS) if room > floor_r else [] + radii = [round(corner.radius, 3)] + sorted( + {round(float(r), 2) for r in steps}, key=lambda r: abs(r - corner.radius) + ) + lowest = state.last_station + STATION_M + stations = { + max(lowest, STATION_M * math.floor(mc / STATION_M)), + max(lowest, STATION_M * math.ceil(mc / STATION_M)), + } + found: list[tuple[tuple[bool, float, float], Xy, _Corner, float, float]] = [] + for station in stations: + for radius in radii: + solved = _solve(state, plan, k, station, radius) + if solved is None or solved[1].radius < floor_r - 1e-6: + continue + new_at, new_corner, new_mc = solved + # 뒤 직선 절반에 여유를 남긴 것 먼저 — 다음 IP 가 뒤로도 밀리게 + tight = math.dist(new_at, after) / 2 - new_corner.tangent < SLACK_M + rank = (tight, abs(station - mc), abs(radius - corner.radius)) + found.append((rank, new_at, new_corner, new_mc, station)) + given = 0 + for _, new_at, new_corner, new_mc, station in sorted(found, key=lambda item: item[0]): + middle = ((new_at[0] + after[0]) / 2, (new_at[1] + after[1]) / 2) + local = [state.end_xy, *new_corner.arc, middle] + if self.reference.gap(local) > self.max_gap or self._steeper(local): + continue + placed = plan.copy() + placed.points[k], placed.radii[k] = new_at, round(new_corner.radius, 3) + yield _State( + placed, + k + 1, + new_corner.arc[-1], + new_mc + new_corner.length / 2, + new_corner.tangent, + station, + state.placed + 1, + ) + given += 1 + if given >= PER_VARIANT: + return + + def _options(self, state: _State) -> Iterator[_State]: + """꺾임점 k 의 갈래 — 측점에 놓기 → 뒤 꺾임과 합쳐 놓기 → (완만하면) 곡선 끔.""" + plan, k = state.plan, state.k + before, at, after = plan.points[k - 1 : k + 2] + if not plan.flags[k] or _corner(before, at, after, plan.radii[k] or 1.0) is None: + kink = plan.copy() + kink.flags[k] = False # 꺾임만(나란한 직선 등 곡선 못 끼움 포함) + yield self._kink(state, kink) + return + trial = plan.copy() + for _ in range(MERGE_TRIES + 1): + yield from self._placements(state, trial) + trial = trial.copy() + if not _absorb_next(trial, k): + break + # 곡선 끔 — 법이 곡선을 안 둬도 되는 완만한 꺾임 · 원래 선에서 한도 안일 때만 + middle = ((at[0] + after[0]) / 2, (at[1] + after[1]) / 2) + if ( + _inner_angle_deg(before, at, after) >= STRAIGHT_INNER_ANGLE_DEG + and self.reference.gap([state.end_xy, at, middle]) <= self.max_gap + ): + kink = plan.copy() + kink.flags[k], kink.radii[k] = False, None + yield self._kink(state, kink) + if self.keep: + yield from self._kept(state) + + def _kept(self, state: _State) -> Iterator[_State]: + """못 놓는 곡선은 그 자리 그대로(브레인 확정 · 측점 밖 곡선으로 셈).""" + plan, k = state.plan, state.k + found = _try(state, plan, k, plan.points[k], plan.radii[k] or self.min_radius) + if found is None: + return + corner, mc = found + at, after = plan.points[k], plan.points[k + 1] + middle = ((at[0] + after[0]) / 2, (at[1] + after[1]) / 2) + if self.reference.gap([state.end_xy, *corner.arc, middle]) > self.max_gap: + return + kept = plan.copy() + kept.radii[k] = round(corner.radius, 3) + yield _State( + kept, + k + 1, + corner.arc[-1], + mc + corner.length / 2, + corner.tangent, + max(state.last_station, STATION_M * math.floor(mc / STATION_M)), + state.placed, + ) + + def run(self, keep: bool) -> _State | None: + """시점부터 깊이 우선 — 막히면 앞 곡선의 다음 갈래로(되돌림 `BACKTRACK_LIMIT` 번까지). + + `keep` = 못 놓는 곡선을 그 자리에 두는 갈래를 끝에 더함(부분 맞춤). + """ + self.keep = keep + stack = [self._options(self.start)] + backtracks = 0 + while stack: + state = next(stack[-1], None) + if state is None: + stack.pop() + backtracks += 1 + if backtracks > BACKTRACK_LIMIT: + return None + continue + if state.k >= len(state.plan.points) - 1: + return state + stack.append(self._options(state)) + return None + + +def snap_curves_to_stations( + outline: PlannedPolyline, + min_radius_m: float, + max_gap_m: float = ROUTE_STATION_SNAP_MAX_GAP_M, + *, + excess: Excess | None = None, +) -> PlannedPolyline | None: + """곡선 MC 를 일반측점에 놓아 다시 짠 선 · 못 하면 None(원래 선을 씀). 곡선 없으면 그대로. + + 모두 놓는 길을 먼저 찾고, 없으면 못 놓는 곡선만 그 자리에 둔 부분 맞춤(`off_station_count`). + `excess`(점 묶음 → 기울기가 상한을 넘은 만큼)를 주면 옮긴 조각이 원래 자리보다 더 넘을 때 그 + 놓기를 버림 — 급한 산지에서 측점 맞춤이 기울기를 망치지 않게. + """ + if not outline.curves: + return outline + snapper = _Snapper(outline, min_radius_m, max_gap_m, excess) + state = snapper.run(keep=False) or snapper.run(keep=True) + if state is None: + return None + snapped = build_planned_polyline( + state.plan.points, + min_radius_m=min_radius_m, + simplify=False, + curve_flags=state.plan.flags, + radii=state.plan.radii, + ) + return snapped if station_hits(snapped) >= state.placed else None diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Store.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Store.py index 78e523725..878f69139 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Store.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Store.py @@ -32,7 +32,7 @@ from B05_Profile.B05_Profile_Engine_RouteInitial import ( ) from common_util.common_util_json import atomic_write_json -ALGO_VERSION = 2 +ALGO_VERSION = 3 # 3 = 곡선 MC 일반측점(351-2) STORE_DIR = "B05_Profile/route_recommend" # 종류(갈래 · 지형) 마다 남길 벌 수 — 꺼낼 때 수정시각을 새로 해 최근에 쓴 것이 남음 KEEP_PER_KIND = 2 diff --git a/B05_Profile/B05_Profile_Router_RouteInitial.py b/B05_Profile/B05_Profile_Router_RouteInitial.py index 183b48ed0..cdbae3d7e 100644 --- a/B05_Profile/B05_Profile_Router_RouteInitial.py +++ b/B05_Profile/B05_Profile_Router_RouteInitial.py @@ -148,11 +148,15 @@ async def compute_initial( current, ), ) - profile = result["metrics"].get("profile") - if request.with_profile and profile and "error" not in profile: + # 측점 맞춘 선 · 맞추기 전 선(351-2) 둘 다 덮개 수를 지금 관으로 + shown = [result, *([result["unsnapped"]] if result.get("unsnapped") else [])] + profiles = [(item["metrics"].get("profile"), item["planned"]) for item in shown] + profiles = [(p, line) for p, line in profiles if p and "error" not in p] + if request.with_profile and profiles: pipes = await asyncio.to_thread(project_pipes, project_root) if pipes: - profile.update(cover_shortfall(profile, result["planned"], pipes)) + for profile, line in profiles: + profile.update(cover_shortfall(profile, line, pipes)) except (FileNotFoundError, ValueError) as error: return JSONResponse(status_code=409, content={"status": "error", "message": str(error)}) logger.info( diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts index 38346cfce..f82835f2b 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts @@ -193,6 +193,12 @@ export interface InitialChoice { stored?: boolean; /** 균형 종단(계획서 63-4) — 모달 미리 보기에만. 캐시에는 안 적는다(`writeInitialChoice`). */ balance?: BalanceProfile | null; + /** 서버가 짠 선(원호 정점 · 계획서 351-2) — 모달 미리 보기에만. 캐시에는 안 적는다. */ + line?: Vertex[] | null; + /** MC 가 일반측점 밖인 곡선 수(계획서 351-2) — 서버 `off_station_curves`. 옛 답이면 null. */ + offStationCurves?: number | null; + /** 측점 맞춤 끔 = 맞추기 전 선(계획서 351-2) — 모달에만 · 캐시에는 안 적는다. 같으면 null. */ + unsnapped?: InitialChoice | null; } /** 거의 같은 노선인 다른 갈래 하나 — 두 노선 사이 가장 먼 거리(m). */ @@ -339,7 +345,20 @@ export function normalizeRouteInitial( similar: parseSimilar(body.similar), bandWidthM: num(body.band_width_m), stored: body.stored === true, + offStationCurves: num(body.off_station_curves), + unsnapped: + body.unsnapped && typeof body.unsnapped === "object" + ? normalizeRouteInitial( + { ...body, ...(body.unsnapped as Record), unsnapped: null }, + logic, + weights, + criteriaOverrides, + ) + : null, balance: parseBalance(m.profile), + line: Array.isArray(body.planned) + ? (body.planned as unknown[][]).map((xy): Vertex => [Number(xy[0]), Number(xy[1])]) + : null, criteria: body.criteria ? { maxGradePct: pick(c, "max_grade_pct"), @@ -562,6 +581,10 @@ export function readInitialChoice(projectId: string): InitialChoice | null { } export function writeInitialChoice(projectId: string, choice: InitialChoice | null): void { - // 균형 종단은 편집 창 안에서만(계획서 63-4) — 캐시에 싣지 않는다. - writeState("routeedit-initial", choice && { ...choice, balance: undefined }, projectId); + // 균형 종단 · 서버 선 · 맞추기 전 선은 편집 창 안에서만(계획서 63-4 · 351-2) — 캐시에 안 싣는다. + writeState( + "routeedit-initial", + choice && { ...choice, balance: undefined, line: undefined, unsnapped: undefined }, + projectId, + ); } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts index 57af87c81..06ef95d1d 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts @@ -145,6 +145,10 @@ export function openRecommendModal(params: RecommendParams): void { /** 값 칸에 넣은 숫자가 고정 규칙 아래 합 100 을 못 맞추는 비중 — 빨간 테두리 · 서버에 안 보냄. */ const invalid = new Set(); let preview: InitialChoice | null = null; + /** 측점 맞춤(계획서 351-2) — 켬 = 곡선 MC 를 일반측점에 맞춘 선 · 끔 = 맞추기 전 선. */ + let snapOn = true; + const shownChoice = (): InitialChoice | null => + preview && !snapOn ? (preview.unsnapped ?? preview) : preview; let busy = false; /** 마지막 미리 보기 뒤 슬라이더 · 제약 칸을 만졌다 — 미리 본 노선이 칸 값과 다르다. */ let dirty = false; @@ -191,7 +195,7 @@ export function openRecommendModal(params: RecommendParams): void { - +
@@ -212,6 +216,7 @@ export function openRecommendModal(params: RecommendParams): void {
+
@@ -299,6 +304,13 @@ export function openRecommendModal(params: RecommendParams): void { const pills = [...body.querySelectorAll("[data-logic]")]; const weightsBox = q(".b05-routerec__weights"); const rerun = q('[data-act="rerun"]'); + const snapToggle = toggleButton("측점 맞춤", snapOn, (active) => { + snapOn = active; + render(); + }); + snapToggle.dataset.act = "snap"; + snapToggle.title = "곡선 가운데(MC)를 일반측점(20 m) 위에 — 끄면 맞추기 전 선"; + rerun.before(snapToggle); const stateEl = q("[data-state]"); const line = (name: string): HTMLElement => q(`[data-line="${name}"]`); const criteriaInputs = body.querySelectorAll("[data-criteria]"); @@ -330,7 +342,9 @@ export function openRecommendModal(params: RecommendParams): void { { kind: "expected", points: params.expected }, { kind: "current", points: params.current }, ]; - if (preview) layers.push({ kind: "preview", points: preview.snapshot.planned }); + // 서버가 짠 원호 그대로(351-2 · 측점 맞춤 켬끔) — 옛 답이면 꺾임점 + const view = shownChoice(); + if (view) layers.push({ kind: "preview", points: view.line ?? view.snapshot.planned }); map?.setLayers(layers); } @@ -382,40 +396,45 @@ export function openRecommendModal(params: RecommendParams): void { q("[data-target-row]").hidden = logic !== "gradeline"; rerun.hidden = !dirty; rerun.disabled = busy || invalid.size > 0; - applyButton.disabled = busy || dirty || !preview || invalid.size > 0; - const shown = metricsLines(preview?.metrics ?? null); - line("metrics").textContent = preview - ? preview.metrics + const view = shownChoice(); + applyButton.disabled = busy || dirty || !view || invalid.size > 0; + const shown = metricsLines(view?.metrics ?? null); + line("metrics").textContent = view + ? view.metrics ? shown.route : "비교값 — 서버 답에 없습니다." : "비교값 — 알약을 누르면 서버가 셈해 미리 보여 줍니다."; - line("metrics-work").textContent = preview ? shown.work : ""; + line("metrics-work").textContent = view ? shown.work : ""; line("metrics-work").hidden = !line("metrics-work").textContent; - const balance = preview?.balance ?? null; + const balance = view?.balance ?? null; line("balance").textContent = balanceLine(balance); line("balance").hidden = !balance; line("profile").innerHTML = profileSvg(balance); line("profile").hidden = !balance; line("candidates").innerHTML = candidatesTable([...seen]); line("candidates").hidden = seen.size === 0; - const violations = violationsLine(preview?.metrics ?? null); + const violations = violationsLine(view?.metrics ?? null); line("violations").textContent = violations; - line("violations").title = (preview?.metrics?.violations ?? []) + line("violations").title = (view?.metrics?.violations ?? []) .map((item) => `${violationText(item)} — ${item.reason}`) .join("\n"); line("violations").hidden = !violations; - const relaxed = relaxedLine(preview); + const relaxed = relaxedLine(view); line("relaxed").textContent = relaxed; line("relaxed").hidden = !relaxed; - const similar = similarLine(preview); + // 곡선 MC 를 일반측점에 못 놓은 곡선(351-2 · 그 자리에 둠) + const offStation = view?.offStationCurves ?? 0; + line("stations").textContent = offStation > 0 ? `측점 밖 곡선 ${offStation}개` : ""; + line("stations").hidden = offStation <= 0; + const similar = similarLine(view); line("similar").textContent = similar; line("similar").hidden = !similar; line("similar-hint").textContent = similar ? SIMILAR_HINT : ""; line("similar-hint").hidden = !similar; - line("criteria").textContent = preview - ? criteriaLine(preview.criteria, params.planMinRadiusM, preview.logic === "gradeline") + line("criteria").textContent = view + ? criteriaLine(view.criteria, params.planMinRadiusM, view.logic === "gradeline") : ""; - line("criteria").hidden = !preview; + line("criteria").hidden = !view; syncRecommendHelp(body, logic); drawMap(); } @@ -541,8 +560,9 @@ export function openRecommendModal(params: RecommendParams): void { }); rerun.addEventListener("click", () => void run()); applyButton.addEventListener("click", () => { - if (!preview) return; - params.onApply(preview); + const view = shownChoice(); // 보이는 쪽(측점 맞춤 켬끔) + if (!view) return; + params.onApply(view); closed = true; close(); }); diff --git a/config/config_system_design.py b/config/config_system_design.py index 9a2cea600..cf5d31a89 100644 --- a/config/config_system_design.py +++ b/config/config_system_design.py @@ -40,6 +40,8 @@ ROUTE_DIRECT_LINK_CELL_FACTOR = float(os.getenv("ROUTE_DIRECT_LINK_CELL_FACTOR", ROUTE_PLANNED_DENSIFY_SAFETY = float(os.getenv("ROUTE_PLANNED_DENSIFY_SAFETY", "0.9")) # 추천 노선 큰 R — 곡선을 키울 때 탐색 경로에서 벗어나도 되는 가장 먼 거리(m) · 탐색 칸 하나 ROUTE_RECOMMEND_MAX_GAP_M = float(os.getenv("ROUTE_RECOMMEND_MAX_GAP_M", "4.0")) +# 추천 노선 곡선 MC → 일반측점 — IP 를 밀어 맞춘 선이 맞추기 전 선에서 벗어나도 되는 가장 먼 거리(m) +ROUTE_STATION_SNAP_MAX_GAP_M = float(os.getenv("ROUTE_STATION_SNAP_MAX_GAP_M", "6.0")) # 임도 종류 — 현행 규칙(별표2)의 3종. `branch`(지선)는 규칙에서 폐지됐으나 기존 # 저장분이 남아 있어 값으로는 계속 받는다(화면 선택지에서는 뺀다, 2026-08-19). ROUTE_GRADE_CLASSES = ("trunk", "fire", "work", "branch") diff --git a/resources/tester/test_351_1_recommend_gaps.py b/resources/tester/test_351_1_recommend_gaps.py index 4913089b8..f9d58bc84 100644 --- a/resources/tester/test_351_1_recommend_gaps.py +++ b/resources/tester/test_351_1_recommend_gaps.py @@ -13,6 +13,7 @@ from pathlib import Path from uuid import uuid4 import pytest +from test_224_3_recommend_store import _ask, wired # noqa: F401 — 픽스처 from B05_Profile import B05_Profile_Router_RouteInitial as router from B05_Profile.B05_Profile_Engine_RouteInitial import write_initial_choice @@ -28,7 +29,6 @@ from common_util.common_util_route_geometry import ( planned_route_working_path, write_route_csv, ) -from test_224_3_recommend_store import _ask, wired # noqa: F401 — 픽스처 def _saved(root: Path) -> list[dict]: diff --git a/resources/tester/test_351_2_curve_stations.py b/resources/tester/test_351_2_curve_stations.py new file mode 100644 index 000000000..a725dfe86 --- /dev/null +++ b/resources/tester/test_351_2_curve_stations.py @@ -0,0 +1,227 @@ +"""351-2 추천 노선 곡선 MC → 일반측점 — `B05_Profile_Engine_RouteInitial_Station`. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_351_2_curve_stations.py -q + +모든 곡선 MC % 20 ≈ 0 · 이웃 MC 20 m 이상 · 곡선 수 ≤ 내부 일반측점 수 · 맞추기 전 선에서 +`ROUTE_STATION_SNAP_MAX_GAP_M`(6 m) 안 · 화면이 펴서 다시 짜도(저장 반올림) 그대로 · 추천 결과의 +기울기 넘은 곳이 맞추기 전보다 늘지 않음 · 검증_sub1 예상노선 8벌 수치. +""" + +import math +import random +from pathlib import Path + +import numpy as np +import pytest +from shapely.geometry import LineString + +from B05_Profile.B05_Profile_Engine_RouteInitial import Terrain, generate_initial_route +from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import _flat, widen +from B05_Profile.B05_Profile_Engine_RouteInitial_Station import ( + MC_TOL_M, + STATION_M, + curve_mc_chainages, + off_station_count, + snap_curves_to_stations, + station_hits, +) +from common_util.common_util_route_geometry import densify_route, read_planned_route_csv +from common_util.common_util_route_polyline import build_planned_polyline +from config.config_system_design import ROUTE_STATION_SNAP_MAX_GAP_M + +PROJECT = Path(__file__).resolve().parents[2] / "storage/1/3/7e420ed6-f940-4d12-a1a5-39cdcf70a391" + + +def _ls(vertices: list) -> LineString: + """겹친 정점 뺀 선(길이 0 조각은 GEOS 경고).""" + return LineString([xy for i, xy in enumerate(vertices) if not i or xy != vertices[i - 1]]) + + +def _zigzag(seed: int) -> list[tuple[float, float]]: + rng = random.Random(seed) + points, heading = [(0.0, 0.0)], 0.0 + for _ in range(12): + heading += rng.uniform(-0.8, 0.8) + step = rng.uniform(30.0, 90.0) + x, y = points[-1] + points.append((x + step * math.cos(heading), y + step * math.sin(heading))) + return points + + +def _check(outline, before) -> None: + mcs = curve_mc_chainages(outline) + assert outline.curves and off_station_count(outline) == 0 + for mc in mcs: + assert abs(mc - STATION_M * round(mc / STATION_M)) <= MC_TOL_M and mc >= STATION_M - 1 + assert all(b - a >= STATION_M - 2 * MC_TOL_M for a, b in zip(mcs, mcs[1:])) + length = _ls(outline.vertices).length + assert len(outline.curves) <= int((length - 1e-6) // STATION_M) # 내부 일반측점 수 + gap = _ls(outline.vertices).hausdorff_distance(_ls(before.vertices)) + assert gap <= ROUTE_STATION_SNAP_MAX_GAP_M + 0.05 + # 시점 · 종점 그대로 + assert outline.vertices[0] == pytest.approx(before.vertices[0], abs=1e-3) + assert outline.vertices[-1] == pytest.approx(before.vertices[-1], abs=1e-3) + + +def _reflattened(outline, min_radius: float): + """화면 `flattenServerPlan` → [확인] 다시 짜기와 같은 꼴(저장 반올림: IP 0.1 mm · R 1 mm).""" + points, flags, radii = _flat(outline) + return build_planned_polyline( + [(round(x, 4), round(y, 4)) for x, y in points], + min_radius_m=min_radius, + simplify=False, + curve_flags=flags, + radii=[None if r is None else round(r, 3) for r in radii], + ) + + +@pytest.mark.parametrize("seed", range(8)) +def test_zigzag_mc_on_stations(seed: int) -> None: + before = build_planned_polyline( + _zigzag(seed), min_radius_m=15.0, simplify=False, curve_flags=[True] * 13 + ) + after = snap_curves_to_stations(before, 15.0) + assert after is not None + _check(after, before) + for curve in after.curves: + assert curve.radius_m >= 15.0 - 1e-6 or curve.violations + # 저장 뒤 다시 짜도 MC 그대로 + again = _reflattened(after, 15.0) + assert curve_mc_chainages(again) == pytest.approx(curve_mc_chainages(after), abs=MC_TOL_M) + + +def test_no_curves_unchanged() -> None: + straight = build_planned_polyline([(0.0, 0.0), (100.0, 0.0)], min_radius_m=12.0) + assert snap_curves_to_stations(straight, 12.0) is straight + + +def test_too_close_curves_merge_or_drop() -> None: + """20 m 안에 꺾임 셋 — 곡선 수 ≤ 내부 일반측점 수로 합쳐짐.""" + points = [(0.0, 0.0), (40.0, 0.0), (48.0, 4.0), (54.0, 10.0), (60.0, 20.0), (60.0, 80.0)] + before = build_planned_polyline( + points, min_radius_m=12.0, simplify=False, curve_flags=[True] * 6 + ) + after = snap_curves_to_stations(before, 12.0) + assert after is not None and station_hits(after) == len(after.curves) + assert len(after.curves) < len(before.curves) + + +def _terrain() -> Terrain: + x = np.arange(0.0, 802.0, 2.0) + y = np.arange(0.0, 402.0, 2.0) + xx, yy = np.meshgrid(x, y) + z = 40.0 * np.exp(-((xx - 400.0) ** 2 + (yy - 200.0) ** 2) / 90.0**2) + return Terrain(x, y, z, np.ones(xx.shape, dtype=bool)) + + +@pytest.fixture(scope="module") +def hill_runs() -> dict: + expected = [(float(x), 200.0 + 40.0 * math.sin(x / 60.0)) for x in np.arange(50.0, 752.0, 4.0)] + return { + mode: generate_initial_route( + expected, _terrain(), [], mode=mode, weights=None, max_grade=0.14, min_radius_m=12.0 + ) + for mode in ("follow", "gentle", "earthwork", "weighted") + } + + +@pytest.mark.parametrize("mode", ["follow", "gentle", "earthwork", "weighted"]) +def test_recommend_result_on_stations(hill_runs: dict, mode: str) -> None: + result = hill_runs[mode] + assert result["off_station_curves"] == 0 + outline = build_planned_polyline( + [tuple(n[k] for k in ("x", "y")) for n in result["nodes"]], + min_radius_m=12.0, + simplify=False, + curve_flags=[n["radius_m"] is not None for n in result["nodes"]], + radii=[n["radius_m"] for n in result["nodes"]], + ) + # 화면이 펴서 다시 짠 선 = 서버 선 + assert np.allclose(outline.vertices, result["planned"], atol=1e-3) + assert station_hits(outline) == len(outline.curves) > 0 + if mode != "follow": # 갈래는 기울기 상한 안(언덕은 비켜 갈 수 있음) + assert result["metrics"]["over_limit"]["total"] == 0 + assert result["metrics"]["max_grade_pct"] <= 14.0 + 1e-6 + # 측점 맞춤 끔 = 맞추기 전 선(같으면 None) — 비교값 따로 + raw = result["unsnapped"] + if raw is not None: + assert raw["planned"] != result["planned"] and raw["metrics"]["length_m"] > 0 + assert set(raw) == {"planned", "nodes", "curves", "off_station_curves", "metrics"} + + +def _project_terrain(): + from pyproj import Transformer + + from B04_PreProcess.B04_PreProcess_Router_Watershed import ( + STREAM_FILE, + _load_features, + _reproject_features, + ) + from B05_Profile.B05_Profile_Engine_RouteInitial import stream_lines + from common_util.common_util_crs import resolve_project_crs + + cached = np.load( + PROJECT / "B05_Profile/route/cost_surface_classification_dtm_smooth.npz", + allow_pickle=False, + ) + terrain = Terrain(cached["x"], cached["y"], cached["z"], cached["valid_mask"]) + to_metric = Transformer.from_crs("EPSG:4326", resolve_project_crs(PROJECT), always_xy=True) + features = _load_features(PROJECT / "B04_PreProcess" / "processed", STREAM_FILE) + return terrain, stream_lines(_reproject_features(features, to_metric)) + + +def test_project_modes_before_after() -> None: + """검증_sub1 실지형(14 % · R12) — 갈래별 측점 맞춤 전후 넘은 곳 · 최대 기울기 · 측점 밖 곡선.""" + if not (PROJECT / "B05_Profile/route/cost_surface_classification_dtm_smooth.npz").is_file(): + pytest.skip("검증_sub1 storage 없음") + terrain, streams = _project_terrain() + path = PROJECT / "B05_Profile/route/expected_route.csv" + expected = [(v.x, v.y) for v in read_planned_route_csv(path).vertices] + on_total, curves_total = 0, 0 + for mode in ("follow", "gradeline", "earthwork", "gentle", "weighted"): + result = generate_initial_route( + expected, + terrain, + streams, + mode=mode, + weights=None, + max_grade=0.14, + min_radius_m=12.0, + with_profile=False, + ) + raw = result["unsnapped"] or result + after, before = result["metrics"], raw["metrics"] + curves = len(result["curves"]) + on_total += curves - result["off_station_curves"] + curves_total += curves + print( + f"{mode}: 넘은 곳 {before['over_limit']['total']} → {after['over_limit']['total']}" + f" · 최대 기울기 {before['max_grade_pct']:.1f} → {after['max_grade_pct']:.1f} %" + f" · 곡선 {len(raw['curves'])} → {curves}(측점 밖 {result['off_station_curves']})" + ) + assert raw["off_station_curves"] >= result["off_station_curves"] + print(f"측점 위 곡선 {on_total}/{curves_total} = {100 * on_total / curves_total:.0f}%") + assert on_total / curves_total >= 0.75 + + +def test_project_eight_outlines() -> None: + """검증_sub1 예상노선 — R 12 · 15 · 20 · 30 × 추적 · 큰 R 8벌 수치(부분 맞춤 허용 · 브레인).""" + path = PROJECT / "B05_Profile/route/expected_route.csv" + if not path.is_file(): + pytest.skip("검증_sub1 storage 없음") + points = [(v.x, v.y) for v in read_planned_route_csv(path).vertices] + full, hits, total = 0, 0, 0 + for radius in (12.0, 15.0, 20.0, 30.0): + follow = build_planned_polyline(points, min_radius_m=radius) + for before in (follow, widen(follow, densify_route(points, 2.0), radius)): + after = snap_curves_to_stations(before, radius) + assert after is not None + on = station_hits(after) + full += on == len(after.curves) + hits, total = hits + on, total + len(after.curves) + gap = _ls(after.vertices).hausdorff_distance(_ls(before.vertices)) + assert gap <= ROUTE_STATION_SNAP_MAX_GAP_M + 0.05 + print( + f"검증_sub1 8벌 중 {full}벌 전부 · 곡선 {hits}/{total} = {100 * hits / total:.0f}% 측점 위" + ) + assert full >= 7 and hits / total >= 0.95 diff --git a/resources/tester/test_route_initial.py b/resources/tester/test_route_initial.py index 9fafd060b..d657a8828 100644 --- a/resources/tester/test_route_initial.py +++ b/resources/tester/test_route_initial.py @@ -100,7 +100,8 @@ def test_every_mode_keeps_grade_limit_on_hill(hill: Terrain, mode: str) -> None: def test_follow_is_initial_polyline_and_lists_violations(hill: Terrain) -> None: - """추종 = 전처리 초기값 규칙(`build_planned_polyline` 기본값) — 언덕을 넘는 곳은 목록에.""" + """추종 = 전처리 초기값 규칙(`build_planned_polyline` 기본값 · 곧은 선이라 곡선 MC 맞춤 없음) + — 언덕을 넘는 곳은 목록에.""" result = _run(hill, "follow") initial = build_planned_polyline(_line(200.0), min_radius_m=RADIUS) assert result["planned"] == [[round(x, 4), round(y, 4)] for x, y in initial.vertices] @@ -119,9 +120,16 @@ def _earth(result: dict) -> float: return result["metrics"]["cut_m3"] + result["metrics"]["fill_m3"] +def _raw(result: dict) -> dict: + """측점 맞추기 전 선(351-2) — 갈래 고르기 · `similar` 는 이 선으로 함.""" + return {**result, **(result.get("unsnapped") or {})} + + def _check_branch_goals(runs: dict) -> None: """갈래마다 제 목표 비교값(`GOALS` — 절성토 · 최대 기울기 · 목표 기울기 편차 · 위험 길이 · - 계류 횡단 · 공사비)이 갈래(같은 후보 묶음) 가운데 1 등 · 새 비교값이 서로 맞음(PLAN 23-5).""" + 계류 횡단 · 공사비)이 갈래(같은 후보 묶음) 가운데 1 등 · 새 비교값이 서로 맞음(PLAN 23-5). + 고르기는 측점 맞추기 전 선으로 함(351-2).""" + runs = {mode: _raw(result) for mode, result in runs.items()} branches = [runs[mode]["metrics"] for mode in BRANCHES] for mode, goal in GOALS.items(): assert all(goal(runs[mode]["metrics"]) <= goal(m) for m in branches), mode @@ -418,17 +426,18 @@ def project_runs() -> dict: def test_project_follow_is_preprocess_initial(project_runs: dict) -> None: - """추종 = 전처리 초기값 `planned_route_initial.csv`(1078 m) 그대로.""" + """추종 = 전처리 초기값(1078 m)의 곡선 MC 를 일반측점에 맞춘 선(351-2).""" from common_util.common_util_route_geometry import ( planned_route_initial_path, read_planned_route_csv, ) + from config.config_system_design import ROUTE_STATION_SNAP_MAX_GAP_M initial = read_planned_route_csv(planned_route_initial_path(PROJECT)) stored = LineString([(v.x, v.y) for v in initial.vertices]) follow = LineString(project_runs["follow"]["planned"]) - assert follow.hausdorff_distance(stored) < 0.01 - assert abs(follow.length - stored.length) < 0.01 + assert follow.hausdorff_distance(stored) <= ROUTE_STATION_SNAP_MAX_GAP_M + 0.05 + assert project_runs["follow"]["off_station_curves"] <= 2 def test_project_branches_differ_by_goal(project_runs: dict) -> None: @@ -445,7 +454,7 @@ def test_project_similar_and_band(project_runs: dict) -> None: earthwork = project_runs["earthwork"] assert earthwork["band_width_m"] < 20.0 near = {item["logic"]: item["max_gap_m"] for item in earthwork["similar"]} - assert near["gentle"] == round(_gap(earthwork, project_runs["gentle"]), 1) + assert near["gentle"] == round(_gap(_raw(earthwork), _raw(project_runs["gentle"])), 1) assert all(gap < 10.0 for gap in near.values()) and "earthwork" not in near assert "follow" not in near # 추적은 40 m 넘게 떨어짐 From e671b7739a36ac30debbd47ca6a48b93f5f6fe77 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 21:37:29 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(=EB=90=98=EB=8F=8C=EB=A6=AC=EA=B8=B0):?= =?UTF-8?q?=20356-4=20=EB=85=B8=EC=84=A0=20=EB=90=98=EB=8F=8C=EB=A6=AC?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EB=8B=A4=EC=8B=9C=ED=95=98=EA=B8=B0=20=3D?= =?UTF-8?q?=20=EA=B7=B8=20=EA=B1=B8=EC=9D=8C=20=EC=82=AC=EC=A7=84=EC=9D=98?= =?UTF-8?q?=20=EA=B3=84=ED=9A=8D=EA=B3=A0=20=ED=8E=B8=EC=A7=91=20=EA=B7=B8?= =?UTF-8?q?=EB=8C=80=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 사진첩 되살림 표시(pageReplaying) — 되살림 중 바뀐 노선은 새 조작 아님 - 묶음 follow 가 그 차례엔 계획고 편집을 옮기지 않고 세션 노선 0 칸(사진) 그대로 · 관 · 구조물도 세션 그대로 - 바뀐 구간 첫 계획선 맞춤 · 계획고 옮김은 새 조작 때만 - 시험 test_356_4 · helper_356_4(끌기 · ▲ · 다시 끌기 · 중심점 보정 · 되돌리기 · 다시하기 · 옛 동작 대조) · test_349_0q 입력 만들기 함수로 뗌 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ --- A00_Common/design/design_pipeline_page.ts | 8 + B05_Profile/B05_Profile_UI_Profile_History.ts | 8 +- resources/tester/helper_356_4_undo_grade.cjs | 448 ++++++++++++++++++ resources/tester/test_349_0q_undo_echo.py | 10 +- resources/tester/test_356_4_undo_grade.py | 88 ++++ 5 files changed, 558 insertions(+), 4 deletions(-) create mode 100644 resources/tester/helper_356_4_undo_grade.cjs create mode 100644 resources/tester/test_356_4_undo_grade.py diff --git a/A00_Common/design/design_pipeline_page.ts b/A00_Common/design/design_pipeline_page.ts index 48413bc6c..3ec3d51f0 100644 --- a/A00_Common/design/design_pipeline_page.ts +++ b/A00_Common/design/design_pipeline_page.ts @@ -55,6 +55,7 @@ import { readAlignmentDraft, } from "../../B05_Profile/B05_Profile_UI_Profile_Edit"; import { readAlignment } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Data"; +import { pageReplaying } from "../../B05_Profile/B05_Profile_UI_Profile_History"; import { analysisTerrain, fetchRoutePlan, @@ -259,6 +260,9 @@ function createPagePipeline( /** 묶음이 가게에 마지막으로 쓴 관 · 구조물 · 쓰는 동안(메아리 — 다시 입력으로 안 받음). */ let written: Facilities | null = null; let echo = false; + /** 되돌리기 · 다시하기로 바뀐 노선(356-4) — follow 가 계획고 편집을 옮기지 않고 그 걸음 사진(세션 노선 0 칸) + * 그대로. 바뀐 구간 첫 계획선 맞춤 · 계획고 옮김은 새 조작 때만. */ + let replay = false; const savedLine = (): Vertex[] | null => plan ? plan.planned.map(([x, y]): Vertex => [x, y]) : null; @@ -320,6 +324,9 @@ function createPagePipeline( savedLine: () => plan && { line: savedLine()!, curves: savedCurves() }, }), follow: (ctx) => { + const kept = replay && ctx.inputs.route ? readAlignmentDraft(DRAFT_ROUTE_ID) : null; + replay = false; + if (kept) return { alignment: kept }; // 관 · 구조물도 세션 그대로(가게 · 쓰기 그대로) const line = ctx.before.route?.line ?? savedLine() ?? []; const { pipes, structures } = ctx.inputs; const inputs = { @@ -567,6 +574,7 @@ function createPagePipeline( if (stored && same({ ...stored, rev: 0 }, { ...draft, rev: 0 })) draft = stored; } if (draft && !wasDraft && !held) held = holdNow(); + replay = pageReplaying(); store.set("routeDraft", draft); if (!started) return start(); const before = pipe.output("route")?.line ?? null; diff --git a/B05_Profile/B05_Profile_UI_Profile_History.ts b/B05_Profile/B05_Profile_UI_Profile_History.ts index 932521a9d..d7c21b5f7 100644 --- a/B05_Profile/B05_Profile_UI_Profile_History.ts +++ b/B05_Profile/B05_Profile_UI_Profile_History.ts @@ -89,6 +89,10 @@ export function setPageHistory(history: ProfileHistory): void { active = history; } +/** 되돌리기 · 다시하기가 세션을 되쓰고 화면을 되살리는 중인가 — 그 사이 바뀐 노선은 새 조작이 아님(356-4). */ +let replaying = false; +export const pageReplaying = (): boolean => replaying; + export type PageHistoryEvent = "undo" | "redo"; interface PageHistoryHooks { /** 세션을 되쓴 뒤 — 자기 화면 값을 다시 읽음(사진첩이 막고 있는 동안이라 쌓이지 않음). */ @@ -193,7 +197,7 @@ export function createProfileHistory(onRestore: () => void, limit = 100): Profil if (index < 0 || index >= stack.length) return false; const kind: PageHistoryEvent = index < cursor ? "undo" : "redo"; cursor = index; - restoring = true; + restoring = replaying = true; restoreSnapshot(stack[cursor]); try { onRestore(); @@ -201,7 +205,7 @@ export function createProfileHistory(onRestore: () => void, limit = 100): Profil // 되살리며 다시 쓴 같은 값(정리된 꼴)을 이 걸음으로 — 다음 찍기가 헛걸음을 안 만듦. stack[cursor] = takeSnapshot(stack[cursor]); } finally { - restoring = false; + restoring = replaying = false; } changed(); return true; diff --git a/resources/tester/helper_356_4_undo_grade.cjs b/resources/tester/helper_356_4_undo_grade.cjs new file mode 100644 index 000000000..c72761132 --- /dev/null +++ b/resources/tester/helper_356_4_undo_grade.cjs @@ -0,0 +1,448 @@ +/* PLAN 356-4 sub7 헬퍼 — 되돌리기 · 다시하기는 그 걸음 사진(노선 + 계획고 편집)을 그대로 되살림. + * 판 = `helper_349_0q_undo_echo.cjs` 그대로(진짜 종단도 · 묶음 · 상세 가게 · 페이지 사진첩 · 서버 창구만 가짜). + * 편집기 흉내 = 사진첩 되살림 훅 안에서 세션 노선 초안을 다리에 넘김(`_RouteEdit_Draft` fromPage 꼴). + * 걸음 — 끌기 → 계획고 ▲(측점 C) → 다시 끌기(C 가 바뀐 구간 = 새 조작은 ▲ 버림) → 되돌리기 · 다시하기 → + * 중심점 좌우 꼴(끌기 + C 높이 보정 · 한 걸음) → 되돌리기 · 다시하기 → 열기까지 되돌리기. + * argv[4] = "off" 면 되살림 표시를 끔(옛 동작 대조). 결과 = 표준출력 JSON. 짝 = `test_356_4_undo_grade.py`. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); + +const ROOT = path.join(__dirname, "..", ".."); +const job = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); + +function memoryStorage() { + const map = new Map(); + return { + getItem: (key) => (map.has(key) ? map.get(key) : null), + setItem: (key, value) => map.set(key, String(value)), + removeItem: (key) => map.delete(key), + key: (index) => [...map.keys()][index] ?? null, + get length() { + return map.size; + }, + }; +} +const winListeners = {}; +const windowBase = { + sessionStorage: memoryStorage(), + localStorage: memoryStorage(), + setTimeout, + clearTimeout, + addEventListener: (type, fn) => (winListeners[type] ??= []).push(fn), + removeEventListener: () => undefined, +}; +globalThis.window = new Proxy(windowBase, { + get: (t, key) => (key in t ? t[key] : undefined), + set: (t, key, value) => ((t[key] = value), true), +}); + +// 흙 배분 서버 — 늦게 답함 · 끊기면 버림 +globalThis.fetch = (url, init) => + new Promise((resolve, reject) => { + const timer = setTimeout( + () => resolve({ ok: true, json: async () => ({ haul_plan: { blocks: [], url } }) }), + 20, + ); + init.signal.addEventListener("abort", () => { + clearTimeout(timer); + reject(new Error("aborted")); + }); + }); + +const STUBS = {}; +const cache = new Map(); +function loadTs(full) { + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (CATALOG.isCatalogRequest(request)) return CATALOG; + const key = Object.keys(STUBS).find((name) => request.endsWith(name)); + if (key) return STUBS[key]; + if (request.startsWith("@config/")) return { API_BASE_URL: "http://fake" }; + if (request.startsWith("@ui/")) return anyModule(); + if (request.endsWith(".css")) return {}; + if (request.endsWith(".json")) return require(path.join(path.dirname(full), request)); + if (request.startsWith("@util/")) + return loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`)); + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} +const load = (...parts) => loadTs(path.join(ROOT, ...parts)); + +const sampler = load("common_util", "common_util_surface_sampler.ts"); +const bytes = fs.readFileSync(process.argv[3]); +const grid = sampler.parseDtmGrid( + bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.length), +); +const local = { grid, design: "las" }; + +/** 아무 화면 부품 — 무엇을 읽든 · 부르든 또 그것(DOM · 오버레이 · 도구줄 흉내). */ +function anyNode() { + const box = { dataset: {}, style: {}, classList: { add() {}, remove() {}, toggle() {} } }; + return new Proxy(function node() {}, { + get: (_t, key) => { + if (key in box) return box[key]; + if (key === "then" || typeof key === "symbol") return undefined; + return (box[key] = anyNode()); + }, + set: (_t, key, value) => ((box[key] = value), true), + apply: () => anyNode(), + }); +} +const anyModule = (own = {}) => + new Proxy(own, { + get: (t, key) => + key in t + ? t[key] + : key === "__esModule" + ? true + : typeof key === "symbol" + ? undefined + : anyNode(), + }); +globalThis.document = anyNode(); +globalThis.HTMLElement = function HTMLElement() {}; + +STUBS["B05_Profile_Api_Replan"] = { + analysisTerrain: () => "las", + fetchRoutePlan: async () => ({ + planned: job.line, + curves: job.curves, + min_radius_m: job.minRadius, + station_snap: job.stationSnap, + }), +}; +STUBS["B05_Profile_Api_Structures"] = anyModule({ + fetchStructures: async () => ({ structures: job.structures }), + fetchStructureTypes: async () => job.types, + readPendingStructures: () => null, +}); +STUBS["B05_Profile_Api_TerrainGrid"] = { + loadDesignTerrainGrid: async () => local, + groundStageDeps: () => ({ grid: () => local, remote: async () => [] }), +}; +STUBS["B04_PreProcess_Api_Fetch"] = { + fetchDetailPipePoints: async () => ({ + pipe_points: job.pipes.map((pipe) => ({ ...pipe, lonlat: [0, 0] })), + }), +}; +STUBS["B06_Section_UI_Standard_Panel"] = { + effectiveStandardCross: () => job.standard, + readRockBoundaryDefault: () => -0.5, +}; +// 구조물 초안 얹기(측점 계획 · 형상) — 이 시험 밖 · 아무 일 없음 +STUBS["B06_Section_Section_Drafts"] = { + withDraftStations: async () => undefined, + withDraftShapes: async () => undefined, + forgetDraftStations: () => undefined, +}; +STUBS["B06_Section_Api_Fetch"] = { + fetchSectionDetail: async () => JSON.parse(JSON.stringify(job.detail)), + saveSections: async () => undefined, +}; +// 횡단 다시 셈(브라우저 · B05 crossPreview · B06 카드 같은 창구) — 부른 횟수 · 면적은 서버와 달라짐(두 배) +let refreshCalls = 0; +const doubled = (detail) => + detail.cross_sections.forEach((section) => { + if (!section.design) return; + const design = { ...section.design }; + for (const [key, value] of Object.entries(design)) + if (key.endsWith("_m2") && typeof value === "number") design[key] = value * 2; + section.design = design; + }); +STUBS["B06_Section_Cross_Refresh"] = { + refreshCrossDesigns: async ({ detail }) => { + refreshCalls += 1; + doubled(detail); + return detail.cross_sections.map((s) => s.chainage_m); + }, + // 화면 선 맡김 · 낡음 판정 = 진짜 `staleAgainstScreen` 그대로(화면 선 → 없으면 상세 자기 선) + holdScreenAlignment: (line) => { + screenLine = line; + return () => undefined; + }, + staleAgainstScreen: (detail, routeId) => { + const screen = screenLine?.(routeId); + if (!screen) return common.hasStaleDesigns(detail); + return common.hasStaleDesigns({ + longitudinal: { + design_profiles: [ + profileData.toDesignProfile(screen, detail.longitudinal.design_profiles?.[0]), + ], + }, + cross_sections: detail.cross_sections, + }); + }, + USER_TOUCHED_KEYS: [], +}; +let screenLine = null; +STUBS["B06_Section_UI_Cross_Detail"] = anyModule(); +STUBS["B06_Section_UI_Page_Common"] = anyModule({ L: (key) => key }); +// 그리기 — 진짜 `renderProfile` 의 낡음 판정 줄만(같은 함수 · 같은 인자) · SVG 는 안 그림 +const draws = []; +let opened = null; +STUBS["B05_Profile_UI_Profile_Render"] = { + renderProfile: (ctx) => { + const detail = ctx.detail(); + if (!detail) return; + const alignment = ctx.alignment(); + const longitudinal = detail.longitudinal; + const designProfiles = alignment + ? [profileData.toDesignProfile(alignment, longitudinal.design_profiles?.[0])] + : (longitudinal.design_profiles ?? []); + const key = common.staleKey({ + longitudinal: { design_profiles: designProfiles }, + cross_sections: detail.cross_sections, + }); + // 저장 상세를 그림 = 묶음 새 노선 상세(preview)가 아님 · 그린 객체(가게 것인지 견줌) + draws.push({ key, saved: !detail.cross_sections.some((s) => s.preview), ref: detail }); + ctx.onStaleDesigns?.(key); + }, +}; +STUBS["B05_Profile_UI_Profile_Tools"] = anyModule(); +STUBS["design_profile_free_view"] = anyModule(); +STUBS["B06_Section_UI_Cross_Panel_Base"] = anyModule(); +globalThis.sessionStorage = window.sessionStorage; +STUBS["common_util_view_window"] = { frameOf: (_root, fn) => fn() }; +for (const name of [ + "B05_Profile_UI_Profile_Balance", + "B05_Profile_UI_Profile_Heights", + "B05_Profile_UI_Profile_Zoom", + "B05_Profile_UI_Profile_Drag", + "B05_Profile_UI_Profile_MassHaul", + "design_profile_overlays", + "design_profile_viewport", + "design_save", + "B05_Profile_Api_Fetch", + "common_util_mass_haul_balance_view", +]) + STUBS[name] = anyModule(); + +const pageMod = load("A00_Common", "design", "design_pipeline_page.ts"); +const storeMod = load("A00_Common", "design", "design_store.ts"); +const sectionStore = load("B06_Section", "B06_Section_Section_Store.ts"); +const common = load("B06_Section", "B06_Section_UI_Section_Common.ts"); +const layouts = load("B06_Section", "B06_Section_Structure_Layouts.ts"); +const profileData = load("B05_Profile", "logic", "B05_Profile_UI_Profile_Data.ts"); +const alignmentMod = load("B05_Profile", "logic", "B05_Profile_UI_Profile_Alignment.ts"); +const baseMod = load("A00_Common", "design", "design_profile_base.ts"); +const pageState = load("A00_Common", "b_page_state.ts"); +const massHaul = load("common_util", "common_util_mass_haul.ts"); +const historyMod = load("B05_Profile", "B05_Profile_UI_Profile_History.ts"); +const shiftMod = load("B05_Profile", "logic", "B05_Profile_RouteShift.ts"); +const profileMod = load("A00_Common", "design", "design_profile.ts"); +const designSync = load("B06_Section", "B06_Section_UI_Page_Design_Sync.ts"); +const designSession = load("B06_Section", "B06_Section_Cross_Design_Session.ts"); +const stationKeys = load("B06_Section", "B06_Section_Station_Keys.ts"); +// 진짜 다시 셈 창구(묶음 새 노선 상세를 안 고치는지만 봄 · 다른 곳은 위 가짜를 부름) +const realRefresh = loadTs(path.join(ROOT, "B06_Section", "B06_Section_Cross_Refresh.ts")); + +const tick = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms)); +const runs = () => globalThis.__aisloPipeline?.runs ?? []; +/** 묶음 · 상세 가게 · 다시 셈이 조용해질 때까지. */ +async function quiet() { + let seen = -1; + for (let i = 0; i < 400; i += 1) { + await tick(30); + const last = runs()[runs().length - 1]; + const count = runs().length + refreshCalls * 1000 + draws.length * 1e6; + if (count === seen && (!last || last.outcome !== "running")) return; + seen = count; + } + throw new Error("묶음이 조용해지지 않음"); +} +const n = job.nodes.length; +const draftOf = (nodes) => ({ + nodes, + curveOn: Array(n).fill(true), + curveRadius: Array(n).fill(null), + curveLock: Array(n).fill(null), + curveArc: Array(n).fill(null), + stationSnap: job.stationSnap, +}); +const shift = (index, dx, dy) => + job.nodes.map((node, i) => (i === index ? [node[0] + dx, node[1] + dy] : node)); +const sliceRuns = (mark) => + runs() + .slice(mark) + .map((r) => ({ + sources: r.sources, + outcome: r.outcome, + restored: r.restored ?? null, + why: r.restoreWhy ?? null, + })); +const finalOf = (detail) => { + const [series] = massHaul.computeMassHaulSeries( + detail.longitudinal, + detail.cross_sections, + job.conversion, + undefined, + ["cross"], + ); + const points = series?.result.points ?? []; + return points.length ? points[points.length - 1].cumulative_volume_m3 : null; +}; +const crossOf = (detail) => + JSON.stringify(detail.cross_sections.map((s) => [s.chainage_m, s.design, s.samples])); +/** 서버 정본 흉내 — 저장 횡단 계획고 = 열 때 종단도 계획선(낡음 0 · 서버가 낸 상세와 같음). */ +function settleDesigns(detail) { + const pa = detail.longitudinal.profile_alignment; + const base = baseMod.listedBase(alignmentMod.toAlignmentBase(pa), detail, []); + const samples = alignmentMod.buildAlignment(base, pa.edits).samples; + const at = (c) => { + const i = samples.findIndex((s) => s.chainage_m >= c); + if (i < 0) return samples[samples.length - 1].elevation_m; + if (i === 0) return samples[0].elevation_m; + const a = samples[i - 1]; + const b = samples[i]; + return ( + a.elevation_m + + ((b.elevation_m - a.elevation_m) * (c - a.chainage_m)) / (b.chainage_m - a.chainage_m) + ); + }; + detail.cross_sections.forEach((s) => (s.design.design_elevation_m = at(s.chainage_m))); +} + +async function run() { + const P = "proj-3564"; + const R = 7; + window.localStorage.setItem("frd_current_project_id", P); + opened = await sectionStore.loadSectionDetail(P, R); + settleDesigns(opened); + stationKeys.useStationKeys(P, R, opened); + const profile = profileMod.createDesignProfile({ + projectId: P, + view: "profile", + onSelectStation: () => undefined, + }); + pageMod.routeDraftBridge(P, profile, { + stationIntervalM: job.interval, + structureAreas: ({ cross_sections: rows }) => + layouts.applyStructureAreaRows(rows, layouts.structureAreaRows(rows)), + holdDraft: (routeId, saved) => { + const hold = saved && sectionStore.holdSectionDetail(P, routeId, saved); + return async (stations) => { + if (!hold) { + await sectionStore.swapSectionStations(P, routeId, stations); + return "no-saved"; + } + const back = await sectionStore.restoreSectionDetail(P, routeId, hold, stations); + return !back ? "no-cache" : back.same ? "saved" : "saved-diff"; + }; + }, + }); + const show = (detail) => { + profile.render(detail, job.interval, R); + profile.setIrregularStations([]); + }; + const stopListen = sectionStore.subscribeSectionDetail(P, R, (next) => show(next)); + const stopStore = storeMod.useDesignStore(P, null).subscribe("stations", (stations) => { + if (stations?.length) void sectionStore.swapSectionStations(P, R, stations); + }); + show(opened); + profile.setEarthworkContext({ conversion: job.conversion }); + await quiet(); + + if (process.argv[4] === "off") historyMod.pageReplaying = () => false; // 옛 동작 대조 + const bridge = pageMod.routeDraftBridge(P, profile); + const designStore = storeMod.useDesignStore(P, null); + // 편집기 흉내 — 되살림 훅 안에서 세션 노선 초안을 다리에 넘김(노선 편집기가 사진첩에 단 훅 꼴) + historyMod.onPageHistory({ + restore: () => { + const kept = designStore.get("routeDraft"); + bridge.edited(kept ? draftOf(kept.nodes) : null); + }, + }); + const album = () => historyMod.pageHistory(); + const pipe = () => pageMod.pagePipeline(P); + const gesture = () => (winListeners.keydown ?? []).forEach((fn) => fn({})); + /** 한 걸음 — 손길 · 사진첩 멈춤 · 조작 · 조용해짐 · 한 번 쌓음. */ + const step = async (fn) => { + gesture(); + album().pause(); + await fn(); + await quiet(); + album().resume(); + }; + const grade = async (edits) => { + pageMod.pipelineGrade(P, edits); + await quiet(); + }; + const stations = pipe().output("ground").stations; + const [nx, ny] = job.nodes[1]; + const C = stations.reduce((a, b) => + Math.hypot(b.x - nx, b.y - ny) < Math.hypot(a.x - nx, a.y - ny) ? b : a, + ).chainageM; + const state = () => ({ + route: designStore.get("routeDraft")?.nodes[1] ?? null, + z: alignmentMod.planElevationAt(pipe().output("profile"), C), + offs: JSON.stringify(pipe().input("alignment")?.station_offsets ?? {}), + session: window.sessionStorage.getItem(`aislo:draft:alignment-draft:${P}:0`), + }); + const out = { C, open: state() }; + await step(() => bridge.edited(draftOf(shift(1, 6, -4)))); + out.drag1 = state(); + await step(() => { + const p = pipe(); + return grade(alignmentMod.adjustStation(p.output("planline"), p.input("alignment"), C, 0.3)); + }); + out.lift = state(); + await step(() => bridge.edited(draftOf(shift(1, 9, -6)))); + out.drag2 = state(); + album().undo(); + await quiet(); + out.undo1 = state(); + album().redo(); + await quiet(); + out.redo1 = state(); + // 중심점 좌우 꼴 — 끌기 + C 계획고를 끌기 전 값으로(한 걸음) + const zBefore = state().z; + await step(async () => { + bridge.edited(draftOf(shift(1, 12, -8))); + await quiet(); + const p = pipe(); + const pinned = shiftMod.pinStationHeight( + p.output("planline"), + p.input("alignment"), + C, + zBefore, + ); + if (pinned) await grade(pinned); + }); + out.pinned = { ...state(), zBefore }; + album().undo(); + await quiet(); + out.undo2 = state(); + album().redo(); + await quiet(); + out.redo2 = state(); + for (let i = 0; i < 6 && album().canUndo(); i += 1) { + album().undo(); + await quiet(); + } + out.home = state(); + out.blocked = pipe().blockedWrites; + stopListen(); + stopStore(); + pageMod.disposePagePipeline(P); + return out; +} + +run() + .then((out) => { + process.stdout.write(JSON.stringify(out)); + process.exit(0); + }) + .catch((error) => { + process.stderr.write(String(error && error.stack ? error.stack : error)); + process.exit(1); + }); diff --git a/resources/tester/test_349_0q_undo_echo.py b/resources/tester/test_349_0q_undo_echo.py index 455486817..fad9cb771 100644 --- a/resources/tester/test_349_0q_undo_echo.py +++ b/resources/tester/test_349_0q_undo_echo.py @@ -60,8 +60,8 @@ def _between(sections: list[dict], index: int) -> dict: } -@pytest.fixture(scope="module") -def r() -> dict: +def build_job() -> dict: + """입력 한 벌 — 356-4 시험도 빌림.""" if not (GRID.is_file() and (SECTION / "longitudinal/longitudinal.json").is_file()): pytest.skip("검증_sub7 없음") curves = _json(ROUTE / "planned_route_curves.json") @@ -120,6 +120,12 @@ def r() -> dict: "cross_sections": sections, }, } + return job + + +@pytest.fixture(scope="module") +def r() -> dict: + job = build_job() with tempfile.TemporaryDirectory() as folder: path = Path(folder) / "job.json" path.write_text(json.dumps(job), encoding="utf-8") diff --git a/resources/tester/test_356_4_undo_grade.py b/resources/tester/test_356_4_undo_grade.py new file mode 100644 index 000000000..5e1d65f18 --- /dev/null +++ b/resources/tester/test_356_4_undo_grade.py @@ -0,0 +1,88 @@ +"""PLAN 356-4 sub7 — 되돌리기 · 다시하기 = 그 걸음 사진(노선 + 계획고 편집) 그대로. + +· 노선 바뀜 규칙(바뀐 구간 첫 계획선 맞춤 · 계획고 옮김)은 새 조작 때만 — 되살림 때는 안 탐 +· 끌기 → 계획고 ▲(측점 C) → 다시 끌기(새 조작 = ▲ 버림) → 되돌리기 = ▲ 그대로 · 다시하기 = 다시 끌기 값 +· 중심점 좌우 꼴(끌기 + C 높이 보정 한 걸음) → 되돌리기 = 조작 전 계획고 · 편집 · 다시하기 = 보정 값 +· 고침을 끈 판(옛 동작)은 되돌리기에서 ▲ · 보정이 사라짐 — 시험이 흠을 실제로 잡음 +짝: `helper_356_4_undo_grade.cjs`(판 = 349-0q · 실제 TS 를 Node 로 · 화면 부품 · 서버 창구만 가짜). +""" + +from __future__ import annotations + +import json +import subprocess +import tempfile +from pathlib import Path + +import pytest + +import test_349_0q_undo_echo as base + +HERE = Path(__file__).resolve().parent + + +def _run(job: dict, mode: str) -> dict: + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) / "job.json" + path.write_text(json.dumps(job), encoding="utf-8") + grid = Path(folder) / "grid.bin" + grid.write_bytes(base.dtm_grid_bytes(base.GRID)) + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_356_4_undo_grade.cjs"), str(path), str(grid), mode], + capture_output=True, + text=True, + encoding="utf-8", + timeout=300, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +@pytest.fixture(scope="module") +def job() -> dict: + return base.build_job() # 349-0q 판 입력(검증_sub7) + + +@pytest.fixture(scope="module") +def r(job) -> dict: + return _run(job, "on") + + +@pytest.fixture(scope="module") +def off(job) -> dict: + return _run(job, "off") + + +def test_새_조작은_바뀐_구간_계획고_편집을_버림(r): + c = f"{r['C']:.3f}" + assert c in json.loads(r["lift"]["offs"]) # ▲ 가 C 에 + assert c not in json.loads(r["drag2"]["offs"]) # 다시 끌기(새 조작) = 버림 + assert r["drag2"]["route"] != r["lift"]["route"] + + +def test_되돌리기_다시하기_그_걸음_사진(r): + assert r["undo1"]["route"] == r["lift"]["route"] + assert r["undo1"]["offs"] == r["lift"]["offs"] # ▲ 그대로 + assert r["undo1"]["z"] == pytest.approx(r["lift"]["z"], abs=1e-4) + assert r["redo1"]["route"] == r["drag2"]["route"] + assert r["redo1"]["offs"] == r["drag2"]["offs"] + assert r["redo1"]["z"] == pytest.approx(r["drag2"]["z"], abs=1e-4) + + +def test_중심점_좌우_보정_되돌리기(r): + pinned = r["pinned"] + assert pinned["z"] == pytest.approx(pinned["zBefore"], abs=1e-3) # 높이 보정 + assert pinned["offs"] != r["redo1"]["offs"] + assert r["undo2"]["route"] == r["redo1"]["route"] + assert r["undo2"]["offs"] == r["redo1"]["offs"] # 조작 전 편집 + assert r["undo2"]["z"] == pytest.approx(pinned["zBefore"], abs=1e-4) # 조작 전 계획고 + assert r["redo2"]["route"] == pinned["route"] + assert r["redo2"]["offs"] == pinned["offs"] # 보정 값 + assert r["home"]["route"] is None and r["home"]["offs"] == r["open"]["offs"] + assert r["blocked"] == 0 + + +def test_옛_동작은_되돌리기에서_편집이_사라짐(off): + c = f"{off['C']:.3f}" + assert c not in json.loads(off["undo1"]["offs"]) # ▲ 사라짐(고친 흠) + assert off["redo2"]["offs"] != off["pinned"]["offs"] # 다시하기에서 보정 사라짐