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:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user