From f59932de66cdf0c2b57b12af622c84f4da5a5fb4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 08:08:47 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05):=20=EA=B3=A1=EC=84=A0=C2=B7=EC=A7=81?= =?UTF-8?q?=EC=84=A0=20=EC=A7=80=EC=9A=B0=EA=B3=A0=20=EB=8D=94=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20+=20=EB=B0=98=EC=A7=80=EB=A6=84=20=EB=AA=BB?= =?UTF-8?q?=EB=B0=95=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 지시(2026-09-07) — 「r과 직선 삭제나 추가가 있어야 하지 않을까?」 편집을 **꺾임점 목록 + 자리마다 곡선 켬끔 + 반지름** 셋으로 표현함. · 직선 삭제·추가 = 목록에서 점을 빼거나 더하기(빼면 앞뒤 직선이 하나로 합쳐지고, 직선 위에 더하면 둘로 갈리며 그 자리에 곡선이 생김) · 곡선 삭제·추가 = `curve` 를 끄고 켜기(끄면 직선이 그대로 꺾임) · 반지름 변경 = `radius_m` 못박기(없으면 서버가 고름) `POST /route/replan` 의 정점마다 `curve`·`radius_m` 을 받게 함. 편집값이 오면 **묶지 않음** — 사용자가 곡선 하나로 본 것을 임의로 합치면 손잡이가 사라지기 때문. 편집값은 점과 짝이라 중복 제거도 함께 함. 시험 tmp/tests/test_route_polyline_edit.py 7건 — 곡선 삭제·추가, 반지름 못박기, **반지름을 바꿔도 교각점은 안 움직임**(「주변 직선 각도 구속」), 직선 삭제·추가. pytest 427 passed. Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_Router_Replan.py | 37 ++++++++++++++++--- common_util/common_util_route_polyline.py | 44 ++++++++++++++++++++--- 2 files changed, 72 insertions(+), 9 deletions(-) diff --git a/B05_Profile/B05_Profile_Router_Replan.py b/B05_Profile/B05_Profile_Router_Replan.py index 2e16ff8a..cfbe16d5 100644 --- a/B05_Profile/B05_Profile_Router_Replan.py +++ b/B05_Profile/B05_Profile_Router_Replan.py @@ -68,10 +68,20 @@ _PROJECT_PATH_MISSING = {"status": "error", "message": "프로젝트 저장 경 class RouteVertexInput(BaseModel): - """계획노선 정점 하나 — 사업지 좌표계(m).""" + """계획노선 꺾임점 하나 — 사업지 좌표계(m). + + 사용자 편집을 그대로 나른다(2026-09-07 사용자 지시) — + · **직선 삭제·추가** = 이 목록에서 점을 빼거나 더하는 것. + · **곡선 삭제·추가** = `curve` 를 끄고 켜는 것. + · **반지름 변경** = `radius_m` 을 주는 것. 안 주면 서버가 고른다. + """ x: float y: float + curve: bool = True + """이 자리에 곡선을 둘지. 끄면 직선이 그대로 꺾인다.""" + radius_m: float | None = None + """못박을 곡선 반지름(m). 없으면 예정노선에 맞추거나 법정 하한을 쓴다.""" class RouteReplanRequest(BaseModel): @@ -195,7 +205,13 @@ def _read_curves(path: Path) -> list[dict]: def _write_planned_polyline( - path: Path, points: list[tuple[float, float]], radius_m: float, *, simplify: bool = True + path: Path, + points: list[tuple[float, float]], + radius_m: float, + *, + simplify: bool = True, + curve_flags: list[bool] | None = None, + radii: list[float | None] | None = None, ) -> dict: """점 묶음을 폴리라인으로 바꿔 CSV 로 쓴다. 노드 요약을 돌려준다. @@ -203,7 +219,13 @@ def _write_planned_polyline( 섞여 있어 다시 단순화하면 꺾임점이 조금씩 지워지고, 그것을 반복하면 [확인]을 누를 때마다 선이 깎인다(2026-09-07 실측: 정점 136 → 134 → 119). 낳은 값을 그대로 보관한다. """ - result = build_planned_polyline(points, min_radius_m=radius_m, simplify=simplify) + result = build_planned_polyline( + points, + min_radius_m=radius_m, + simplify=simplify, + curve_flags=curve_flags, + radii=radii, + ) write_route_csv(path, [{"x": x, "y": y} for x, y in result.vertices]) write_route_csv(_nodes_path(path), [{"x": node.x, "y": node.y} for node in result.nodes]) _curves_path(path).write_text( @@ -452,12 +474,19 @@ async def replan_route( # 노드만 옮기면 선이 저절로 규칙을 지키는 것이 이 구조의 목적이다(2026-09-06 사용자). # 받은 것은 이미 꺾임점이므로 **다시 뽑지 않는다** — 뽑으면 사용자가 둔 노드가 지워진다. nodes = [(vertex.x, vertex.y) for vertex in request.vertices] + curve_flags = [bool(vertex.curve) for vertex in request.vertices] + radii = [vertex.radius_m for vertex in request.vertices] # 체인이 끊기면 되돌릴 수 있게 이전 수정본을 손에 쥔다 — 실패했는데 노선만 바뀌면 # 종횡단과 어긋난 채 굳고, 다시 누를수록 어긋남이 쌓인다(2026-09-06 실측). working_path = planned_route_working_path(project_root) previous = working_path.read_bytes() if working_path.is_file() else None summary = await asyncio.to_thread( - functools.partial(_write_planned_polyline, simplify=False), working_path, nodes, radius_m + functools.partial( + _write_planned_polyline, simplify=False, curve_flags=curve_flags, radii=radii + ), + working_path, + nodes, + radius_m, ) written = summary["vertices"] logger.info( diff --git a/common_util/common_util_route_polyline.py b/common_util/common_util_route_polyline.py index 25e3365b..8224bc9d 100644 --- a/common_util/common_util_route_polyline.py +++ b/common_util/common_util_route_polyline.py @@ -582,6 +582,8 @@ def build_planned_polyline( min_radius_m: float, hairpin_min_radius_m: float = 10.0, simplify: bool = True, + curve_flags: list[bool] | None = None, + radii: list[float | None] | None = None, ) -> PlannedPolyline: """점 묶음을 계획노선 폴리라인으로 바꾼다. @@ -595,8 +597,33 @@ def build_planned_polyline( ⚠ 이 갈래가 없으면 [확인]을 누를 때마다 선이 깎인다(2026-09-07 실측: 정점 136 → 134 → 119, 노드 22 → 21). 원호 점이 섞인 폴리라인을 다시 단순화하면 꺾임점이 조금씩 지워지고, 그 결과로 만든 폴리라인을 또 단순화하기 때문이다. **단순화는 원본 점군에서 한 번만.** + + `curve_flags`·`radii` 는 **사용자 편집을 그대로 받는 자리**다(2026-09-07 사용자 지시: + 「r과 직선 삭제나 추가가 있어야 하지 않을까」). 점마다 짝을 이룬다. + · `curve_flags[i] = False` → 그 자리에 **곡선을 두지 않는다**(곡선 삭제). 직선이 그대로 + 꺾인다. 다시 True 로 주면 곡선이 돌아온다(곡선 추가). + · `radii[i]` → 그 곡선의 반지름을 **그 값으로 못박는다**(R 변경). 없으면 예정노선에 + 맞춰 고르거나 법정 하한을 쓴다. + · 직선을 지우거나 더하는 것은 **점 목록 자체**로 표현된다 — 점을 빼면 앞뒤 직선이 하나로 + 합쳐지고, 직선 위에 점을 더하면 둘로 갈리며 그 자리에 곡선이 생긴다. + 편집값이 주어지면 **묶지 않는다** — 사용자가 곡선 하나로 본 것을 임의로 합치면 손잡이가 + 사라지기 때문이다. """ - deduped = dedupe_points(points) + # 편집값은 점과 짝이므로 **함께** 중복을 걸러야 어긋나지 않는다. + edited = curve_flags is not None or radii is not None + flags = list(curve_flags) if curve_flags is not None else [True] * len(points) + given = list(radii) if radii is not None else [None] * len(points) + flags += [True] * (len(points) - len(flags)) + given += [None] * (len(points) - len(given)) + deduped: list[tuple[float, float]] = [] + kept_flags: list[bool] = [] + kept_radii: list[float | None] = [] + for point, flag, radius_value in zip(points, flags, given): + if deduped and _distance(deduped[-1], point) <= DUPLICATE_TOLERANCE_M: + continue + deduped.append(point) + kept_flags.append(bool(flag)) + kept_radii.append(radius_value) cleaned = simplify_to_nodes(deduped) if simplify else deduped if len(cleaned) < 3: nodes = [RouteNode(x=x, y=y) for x, y in cleaned] @@ -612,9 +639,13 @@ def build_planned_polyline( cleaned[index - 1], cleaned[index], cleaned[index + 1] ) - runs = _curve_runs(cleaned) - if node_indices is not None: - runs = _split_wide_runs(runs, cleaned, deduped, node_indices, min_radius_m) + if edited: + # 사용자가 손댄 목록 — 묶지 않고 자리마다 하나씩 본다. 곡선을 지운 자리는 뺀다. + runs = [(index, index) for index in range(1, len(cleaned) - 1) if kept_flags[index]] + else: + runs = _curve_runs(cleaned) + if node_indices is not None: + runs = _split_wide_runs(runs, cleaned, deduped, node_indices, min_radius_m) vertices: list[tuple[float, float]] = [cleaned[0]] curves: list[RouteCurve] = [] cursor = 0 # 아직 선에 안 실은 첫 꺾임점 @@ -649,7 +680,10 @@ def build_planned_polyline( # 반지름은 **법정 하한 이상에서 예정노선에 가장 가까운 값**(2026-09-07 사용자 확정). # 자리가 모자라면 하한 아래로 줄이되 **막지 않고 위반으로 표시**한다(기존 규칙). radius = min_radius_m - if node_indices is not None: + forced = kept_radii[first] if edited and first == last else None + if forced is not None and forced > 0: + radius = float(forced) # 사용자가 못박은 반지름 — 맞추지 않고 그대로 쓴다. + elif node_indices is not None: samples = deduped[node_indices[first - 1] : node_indices[last + 1] + 1] radius = _fit_radius_m( samples,