Files
Aislo/common_util/common_util_route_polyline.py
T
eomsangdonandClaude Opus 5 1944414940 fix(B05): [확인]을 누를 때마다 계획노선이 깎이던 것
같은 노선으로 [확인]을 되풀이하면 계획노선 정점이 136 → 134 → 119, 노드가
22 → 21 로 계속 줄었음(보조 창 실측). 사용자가 아무것도 안 옮겨도 누를
때마다 자기 노선이 뭉개졌음.

원인은 **이미 폴리라인인 것을 다시 단순화**한 것. 두 자리 모두 그랬음 —
  - read_route_plan 이 노드를 계획노선(원호 점이 섞인 폴리라인)에서 되뽑음
  - replan_route 가 화면이 보낸 노드를 또 한 번 단순화해서 씀
단순화(Douglas-Peucker)는 원본 점군에서 한 번만 돌아야 함.

고친 것:
  - build_planned_polyline 에 simplify 갈래 신설. False 면 받은 점을
    꺾임점으로 그대로 씀(중복 제거만).
  - 폴리라인을 쓸 때 그것을 낳은 **노드도 함께 저장**
    (planned_route_nodes.csv · planned_route_initial_nodes.csv).
    노드는 폴리라인에서 되뽑을 수 없으므로 낳은 값을 보관함.
  - read_route_plan 은 저장된 노드를 그대로 씀(없는 옛 프로젝트만 뽑음).
  - replan_route 는 simplify=False 로 씀.

실측(용화 b269ea34 실제 노선 파일, 4회 왕복)
  옛 방식  노드21/정점115 → 20/115 → 20/113 → 20/115  (깎이고 흔들림)
  고친 방식 노드21/정점115 → 이후 매 회차 **완전히 동일**

시험 tmp/tests/test_route_polyline_idempotent.py 2건 신설 — 노드 왕복이
제자리인지, 그리고 옛 방식이 실제로 깎이는지(갈래가 필요한 이유) 못박음.
pytest 414 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 05:08:07 +09:00

329 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""예상노선(점 묶음)을 **계획노선 폴리라인**으로 바꾸는 자리.
왜 필요한가(2026-09-06 사용자 지시) — 예상노선은 폴리라인이 아니라 **점(포인트)으로 이뤄진
데이터**이고 규칙 없는 폴리라인과도 맞지 않는다. 그래서 계획노선은 **원데이터를 복사한 뒤
폴리라인으로 바꾼 것**이어야 하고, 그것이 **불변의 초기 데이터**가 된다. 유토곡선·3D 에
투영되는 선도, 사용자가 노드를 잡아 고치는 대상도 이 폴리라인이다.
**곡선 기준은 지식DB 값**(`resources/knowledge/technical_info/01_임도/02_상세설계/평면선형.md`,
근거는 산림자원법 시행규칙 별표2 Ⅰ.2.다) — 코드에서는 `config_system_design` 이 그대로 들고 있다.
· 최소곡선반지름 — 설계속도 40: 일반 60 / 특수 40 · 30: 30 / 20 · 20: 15 / 12 (중심선 기준)
· 배향곡선 중심선 반지름 10m 이상
· **내각 155° 이상**(교각 25° 이하)이면 곡선을 두지 않을 수 있음
**하는 일은 「모양 정리」뿐이다** — 점을 옮기지 않는다. 꺾이는 점(IP)을 그대로 두고 그 자리에
원호를 끼워 넣어 매끄럽게 잇는다. 원호가 들어갈 자리(접선 길이)가 모자라면 반지름을 줄여
맞추고, 법정 하한 아래로 내려가면 **줄이되 위반으로 표시**한다 — 자동으로 점을 옮겨
「고쳐 주지」 않는다(2026-09-06 사용자 확정: 자동 보정·차단은 하지 않고 경고만).
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
from typing import Any
# 같은 자리로 볼 점 사이 거리(m) — 이보다 가까우면 뒤엣것을 버린다. 원본 점군에 중복·
# 미세 진동이 섞여 있으면 내각이 튀어 없는 곡선이 생긴다.
DUPLICATE_TOLERANCE_M = 0.5
# 꺾임점(IP)을 뽑는 단순화 허용오차(m). 예상노선은 격자 탐색이 낸 **조밀한 점군**이라
# (용화 실측: 1,097m 에 331점 = 약 3.3m 간격) 점마다 곡선을 끼우면 접선 자리가 1.5m 밖에
# 안 나와 반지름이 2~6m 로 뭉개진다. 격자 해상도(`ROUTE_GRID_RES_M` 2.0m)의 두 배로 잡아
# 계단 모양만 걷어내고 실제 굴곡은 남긴다.
SIMPLIFY_TOLERANCE_M = 4.0
# 남은 노드 사이가 이보다 멀면 그 구간만 더 촘촘히 다시 뽑는다(m).
#
# 왜 필요한가(2026-09-06 실측) — Douglas-Peucker 허용오차는 **절대 거리**라 굴곡이 완만하고
# 길수록 통째로 삼켜진다. 4.5km 짜리 S자 노선에서 노드가 20개(간격 238m)·곡선 2곳만 남아
# 「S자」가 사라졌다. 같은 4m 로 1.1km 노선은 노드 25개(간격 46m)·곡선 13곳으로 알맞았다.
# 노선 길이로 허용오차를 바꾸면 짧고 급한 굴곡이 다시 뭉개지므로, **간격이 벌어진 구간만**
# 골라 허용오차를 절반으로 낮춰 다시 뽑는다.
MAX_NODE_SPACING_M = 100.0
# 위 되뽑기를 몇 겹까지 할지 — 겹마다 허용오차가 절반이 된다(4 → 2 → 1 → 0.5m).
MAX_REFINE_DEPTH = 3
# 원호를 몇 도마다 한 점씩 찍을지 — 촘촘할수록 매끄럽지만 정점이 늘어난다.
ARC_STEP_DEG = 5.0
# 이 값 이상으로 펴진 자리는 곡선을 두지 않는다(별표2: 내각 155° 이상).
STRAIGHT_INNER_ANGLE_DEG = 155.0
@dataclass
class RouteNode:
"""사용자가 잡아 옮기는 제어점 하나 = 원본 꺾임점(IP)."""
x: float
y: float
inner_angle_deg: float | None = None
"""직전·직후 구간이 이루는 내각(도). 끝점은 None."""
radius_m: float | None = None
"""이 자리에 끼운 원호 반지름(m). 곡선을 안 둔 자리는 None."""
tangent_m: float | None = None
"""접선 길이(m) = R·tan(교각/2). 곡선을 안 둔 자리는 None."""
violations: list[str] = field(default_factory=list)
"""법정 기준 위반 표시 — 값은 넣되 막지 않는다."""
def as_dict(self) -> dict[str, Any]:
return {
"x": round(self.x, 4),
"y": round(self.y, 4),
"inner_angle_deg": None
if self.inner_angle_deg is None
else round(self.inner_angle_deg, 2),
"radius_m": None if self.radius_m is None else round(self.radius_m, 2),
"tangent_m": None if self.tangent_m is None else round(self.tangent_m, 2),
"violations": list(self.violations),
}
@dataclass
class PlannedPolyline:
"""폴리라인화 결과. `nodes` 는 편집 대상, `vertices` 는 그리고 계산에 쓰는 선."""
nodes: list[RouteNode]
vertices: list[tuple[float, float]]
@property
def curve_count(self) -> int:
return sum(1 for node in self.nodes if node.radius_m is not None)
@property
def violation_count(self) -> int:
return sum(1 for node in self.nodes if node.violations)
def _distance(a: tuple[float, float], b: tuple[float, float]) -> float:
return math.hypot(b[0] - a[0], b[1] - a[1])
def dedupe_points(
points: list[tuple[float, float]], tolerance_m: float = DUPLICATE_TOLERANCE_M
) -> list[tuple[float, float]]:
"""붙어 있는 점을 하나로 줄인다. 순서는 그대로 둔다."""
cleaned: list[tuple[float, float]] = []
for point in points:
if not cleaned or _distance(cleaned[-1], point) > tolerance_m:
cleaned.append(point)
return cleaned
def _perpendicular_distance(
point: tuple[float, float], start: tuple[float, float], end: tuple[float, float]
) -> float:
"""점에서 선분까지의 수직 거리(m). 선분이 한 점이면 그 점까지의 거리."""
dx, dy = end[0] - start[0], end[1] - start[1]
if dx == 0 and dy == 0:
return _distance(point, start)
return abs(dy * point[0] - dx * point[1] + end[0] * start[1] - end[1] * start[0]) / math.hypot(
dx, dy
)
def _douglas_peucker(points: list[tuple[float, float]], tolerance_m: float) -> list[int]:
"""남길 점의 **원본 색인**을 돌려준다 — 되뽑기가 원본 구간을 다시 꺼내야 해서 색인이다."""
if len(points) < 3:
return list(range(len(points)))
keep = {0, len(points) - 1}
stack = [(0, len(points) - 1)]
while stack:
first, last = stack.pop()
if last <= first + 1:
continue
worst, worst_index = -1.0, first
for index in range(first + 1, last):
gap = _perpendicular_distance(points[index], points[first], points[last])
if gap > worst:
worst, worst_index = gap, index
if worst > tolerance_m:
keep.add(worst_index)
stack.append((first, worst_index))
stack.append((worst_index, last))
return sorted(keep)
def simplify_to_nodes(
points: list[tuple[float, float]],
tolerance_m: float = SIMPLIFY_TOLERANCE_M,
max_spacing_m: float = MAX_NODE_SPACING_M,
depth: int = MAX_REFINE_DEPTH,
) -> list[tuple[float, float]]:
"""조밀한 점군에서 **꺾임점(IP)** 만 남긴다 — Douglas-Peucker + 벌어진 구간 되뽑기.
예상노선은 격자 탐색이 낸 점군이라 3m 간격으로 촘촘하다. 그대로 두면 곡선을 끼울
접선 자리가 없어 반지름이 뭉개진다. 원래 선에서 `tolerance_m` 보다 멀어지지 않는
선에서 점을 걷어내므로 **모양은 그대로**다.
남은 노드 사이가 `max_spacing_m` 을 넘으면 **그 구간만** 허용오차를 절반으로 낮춰 다시
뽑는다 — 완만하고 긴 굴곡이 통째로 삼켜지는 것을 막는다(2026-09-06 S자 노선 실측).
"""
if len(points) < 3:
return list(points)
kept = _douglas_peucker(points, tolerance_m)
if depth <= 0 or max_spacing_m <= 0:
return [points[index] for index in kept]
result: list[tuple[float, float]] = [points[kept[0]]]
for previous, current in zip(kept, kept[1:]):
if _distance(points[previous], points[current]) > max_spacing_m and current > previous + 1:
refined = simplify_to_nodes(
points[previous : current + 1],
tolerance_m / 2,
max_spacing_m,
depth - 1,
)
result.extend(refined[1:])
else:
result.append(points[current])
return result
def _inner_angle_deg(
before: tuple[float, float], at: tuple[float, float], after: tuple[float, float]
) -> float:
"""세 점이 이루는 내각(도). 일직선이면 180."""
ax, ay = before[0] - at[0], before[1] - at[1]
bx, by = after[0] - at[0], after[1] - at[1]
la, lb = math.hypot(ax, ay), math.hypot(bx, by)
if la <= 0 or lb <= 0:
return 180.0
cosine = max(-1.0, min(1.0, (ax * bx + ay * by) / (la * lb)))
return math.degrees(math.acos(cosine))
def _unit(from_point: tuple[float, float], to_point: tuple[float, float]) -> tuple[float, float]:
length = _distance(from_point, to_point)
if length <= 0:
return (0.0, 0.0)
return ((to_point[0] - from_point[0]) / length, (to_point[1] - from_point[1]) / length)
def _arc_points(
center: tuple[float, float],
start: tuple[float, float],
end: tuple[float, float],
clockwise: bool,
) -> list[tuple[float, float]]:
"""중심과 두 끝점으로 원호 위 점을 찍는다(양 끝 포함하지 않음 — 부르는 쪽이 붙인다)."""
radius = _distance(center, start)
if radius <= 0:
return []
start_angle = math.atan2(start[1] - center[1], start[0] - center[0])
end_angle = math.atan2(end[1] - center[1], end[0] - center[0])
sweep = end_angle - start_angle
if clockwise:
while sweep > 0:
sweep -= 2 * math.pi
else:
while sweep < 0:
sweep += 2 * math.pi
steps = max(1, int(abs(math.degrees(sweep)) / ARC_STEP_DEG))
return [
(
center[0] + radius * math.cos(start_angle + sweep * step / steps),
center[1] + radius * math.sin(start_angle + sweep * step / steps),
)
for step in range(1, steps)
]
def build_planned_polyline(
points: list[tuple[float, float]],
*,
min_radius_m: float,
hairpin_min_radius_m: float = 10.0,
straight_inner_angle_deg: float = STRAIGHT_INNER_ANGLE_DEG,
simplify: bool = True,
) -> PlannedPolyline:
"""점 묶음을 계획노선 폴리라인으로 바꾼다.
`min_radius_m` 은 설계속도·지형으로 고른 법정 최소곡선반지름이다
(`config_system_design.FOREST_ROAD_PROFILE_CRITERIA["min_plan_radius_m"]`).
**먼저 꺾임점을 뽑는다**(`simplify_to_nodes`) — 예상노선은 조밀한 점군이라 그대로 두면
곡선을 끼울 접선 자리가 없어 반지름이 뭉개진다(용화 실측: 점마다 끼우면 R 2~6m).
`simplify=False` 는 **이미 꺾임점인 것을 넘길 때** 쓴다 — 사용자가 옮긴 노드가 그렇다.
⚠ 이 갈래가 없으면 [확인]을 누를 때마다 선이 깎인다(2026-09-07 실측: 정점 136 → 134 →
119, 노드 22 → 21). 원호 점이 섞인 폴리라인을 다시 단순화하면 꺾임점이 조금씩 지워지고,
그 결과로 만든 폴리라인을 또 단순화하기 때문이다. **단순화는 원본 점군에서 한 번만.**
"""
cleaned = simplify_to_nodes(dedupe_points(points)) if simplify else dedupe_points(points)
if len(cleaned) < 3:
nodes = [RouteNode(x=x, y=y) for x, y in cleaned]
return PlannedPolyline(nodes=nodes, vertices=list(cleaned))
nodes = [RouteNode(x=x, y=y) for x, y in cleaned]
vertices: list[tuple[float, float]] = [cleaned[0]]
for index in range(1, len(cleaned) - 1):
before, at, after = cleaned[index - 1], cleaned[index], cleaned[index + 1]
inner = _inner_angle_deg(before, at, after)
node = nodes[index]
node.inner_angle_deg = inner
if inner >= straight_inner_angle_deg:
# 별표2 — 내각 155° 이상은 곡선을 두지 않을 수 있다. 점을 그대로 잇는다.
vertices.append(at)
continue
deflection = math.radians(180.0 - inner) # 교각(IA)
half_tan = math.tan(deflection / 2)
if half_tan <= 1e-9:
vertices.append(at)
continue
# 접선이 들어갈 자리 — 앞뒤 구간을 이웃 곡선과 나눠 쓰므로 절반까지만 쓴다.
available = min(_distance(before, at), _distance(at, after)) / 2
# 반지름은 **법정 하한 그대로** 쓴다 — 사용자가 「지식DB 의 R 기준값으로 선을 만들라」고
# 한 그 값이고, 기하로도 하한이 원본에 가장 가깝다: 중앙종거 M = R(1/cos(Δ/2) 1) 이라
# R 이 클수록 원호가 꺾임점에서 멀어진다. 원본 굴곡에서 R 을 재어 키우는 방법도 재 봤으나
# (2026-09-06) 직각 꺾임에서 R 70m·중앙종거 20m 가 나와 원본과 크게 어긋났다.
# 곡선마다 R 을 다르게 주는 것은 사용자가 노드에서 고르는 별도 기능으로 둔다(PLAN 0-10).
radius = min_radius_m
tangent = radius * half_tan
if tangent > available:
radius = available / half_tan
tangent = available
if radius <= 0:
vertices.append(at)
continue
if radius < min_radius_m:
node.violations.append(f"최소곡선반지름 미달({radius:.1f} < {min_radius_m:.1f}m)")
if radius < hairpin_min_radius_m:
node.violations.append(
f"배향곡선 하한 미달({radius:.1f} < {hairpin_min_radius_m:.1f}m)"
)
node.radius_m = radius
node.tangent_m = tangent
to_before = _unit(at, before)
to_after = _unit(at, after)
start = (at[0] + to_before[0] * tangent, at[1] + to_before[1] * tangent)
end = (at[0] + to_after[0] * tangent, at[1] + to_after[1] * tangent)
# 중심은 두 접선의 이등분 방향으로 R/sin(내각/2) 만큼 떨어진 자리다.
bisector = (to_before[0] + to_after[0], to_before[1] + to_after[1])
bisector_length = math.hypot(*bisector)
if bisector_length <= 1e-9: # 완전히 되돌아가는 자리 — 원호를 못 끼운다.
node.radius_m = None
node.tangent_m = None
vertices.append(at)
continue
center_distance = radius / math.sin(math.radians(inner) / 2)
center = (
at[0] + bisector[0] / bisector_length * center_distance,
at[1] + bisector[1] / bisector_length * center_distance,
)
# 도는 방향 — 진행 방향 기준 외적 부호.
cross = (at[0] - before[0]) * (after[1] - at[1]) - (at[1] - before[1]) * (after[0] - at[0])
vertices.append(start)
vertices.extend(_arc_points(center, start, end, clockwise=cross < 0))
vertices.append(end)
vertices.append(cleaned[-1])
return PlannedPolyline(nodes=nodes, vertices=vertices)