feat(B05): 곡선·직선 지우고 더하기 + 반지름 못박기

사용자 지시(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) <noreply@anthropic.com>
This commit is contained in:
2026-09-07 08:08:47 +09:00
co-authored by Claude Opus 5
parent 9626d9e8c7
commit f59932de66
2 changed files with 72 additions and 9 deletions
+33 -4
View File
@@ -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(
+36 -2
View File
@@ -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,6 +639,10 @@ def build_planned_polyline(
cleaned[index - 1], cleaned[index], cleaned[index + 1]
)
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)
@@ -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,