Files
Aislo/common_util/common_util_route_polyline.py
T
eomsangdonandClaude Opus 5 db7004964d feat(B05): 계획노선을 「직선 > 곡선 > 직선」으로 — 반지름을 예정노선에 맞춤
사용자 확정(2026-09-07) — 「계획노선은 직선>곡선>직선 형태의 폴리라인임.
반지름은 법정 최소 값을 지키되 기존 예정노선에 가까운 폴리라인을 찾는 게 키임.」
되물어 확정: 반지름 상한은 두지 않고 원본에 가장 가까운 값으로.

옛 방식은 꺾임점마다 법정 하한(R 12m) 원호를 하나씩 끼웠음. 그래서 완만한
긴 곡선이 작은 원호 여러 개로 쪼개져 원본과 벌어졌음.

고친 것 셋
  1. 이어진 꺾임을 **한 곡선으로 묶음**(_curve_runs). 같은 쪽으로 도는
     꺾임을 모으고, 앞뒤 직선을 늘려 만나는 자리를 그 곡선의 교각점으로 씀.
  2. 그 안에서 **반지름을 골라 맞춤**(_fit_radius_m) — 법정 하한 이상에서
     예정노선 점들과의 평균 벗어남이 가장 작은 값.
  3. 묶는 것이 손해면 **도로 쪼갬**(_split_wide_runs) — 평균이 나빠지거나
     한 자리라도 8m 넘게 벌어지면 낱개로 둠.

별표2 의 155°(곡선 생략) 규칙은 **묶은 뒤 전체 교각**에 적용하도록 자리를
옮김. 조각 하나하나에 적용하면 완만한 곡선이 쪼개졌음(내각 156·138·154°
짜리 세 꺾임이 갈려 가운데 하나만 곡선이 됐음).

실측 — 흉내낸 매끄러운 곡선에서 원본 반지름을 되찾음
  원본 R 25m → 25.1m (최대 벗어남 0.12m)   전에는 R 12·12·12m, 1.66m
  원본 R 40m → 39.8m (0.09m)               전에는 R 12·12·12m, 2.66m
  원본 R 80m → 78.2m (0.44m)               전에는 R 12 다섯 개, 1.31m
실제 노선 3개(격자 탐색이 낸 지그재그)는 중립 — 최대 벗어남 3.57m 그대로,
평균 1.082 → 1.100m. 급한 꺾임에서는 하한이 이미 최선이라 그럼.

시험 tmp/tests/test_route_polyline_curve_fit.py 4건 신설 — 반지름 되찾기,
하한 지키기, 직각은 하한 그대로, 손해면 안 묶기. pytest 420 passed.

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

664 lines
29 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
# 한 곡선으로 **묶을지** 볼 때 쓰는 문턱(도) — 위 155° 와 쓰임이 다르다.
#
# 왜 나눴나(2026-09-07 실측) — 155° 를 묶기 판정에까지 쓰면 **완만한 곡선이 쪼개진다**.
# 반지름 40m 짜리 원호를 점으로 흉내 내 넣으니 꺾임점 셋(내각 156°·138°·154°)이 나왔는데
# 양 끝 둘이 155° 위라 「직선」으로 갈려 가운데 하나만 곡선이 됐고, 그 짧은 현 사이에서는
# 반지름을 키울수록 원본에서 멀어졌다(평균 1.75 → 2.19m). 별표2 의 155° 는 **그 곡선의
# 교각**에 대한 규칙이지, 점군을 쪼갠 조각 하나하나에 대한 규칙이 아니다.
# 그래서 묶을 때는 「조금이라도 같은 쪽으로 돈다」로 모으고, 155° 는 **묶은 뒤 전체 교각**에
# 적용한다.
CURVE_GROUP_INNER_ANGLE_DEG = 179.0
# 여러 꺾임을 한 곡선으로 묶을 때 **허용할 최대 벗어남**(m). 평균이 나아져도 한 자리가
# 이보다 크게 벌어지면 안 묶고 낱개로 둔다. 꺾임점 뽑기 허용오차(4m)의 두 배 — 노드 자체가
# 원본에서 4m 안에 있으므로, 곡선이 그보다 크게 벗어나면 다른 모양이 된 것이다.
MERGE_MAX_GAP_M = 8.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 _node_indices(
points: list[tuple[float, float]], nodes: list[tuple[float, float]]
) -> list[int] | None:
"""노드가 원본 점 목록의 몇 번째인지 — 노드는 원본 자리 그대로라 순서대로 찾힌다.
사용자가 옮긴 노드처럼 원본에 없는 점이 섞이면 `None` 을 돌려준다(그때는 피팅을 건너뛴다).
"""
indices: list[int] = []
cursor = 0
for node in nodes:
while cursor < len(points) and points[cursor] != node:
cursor += 1
if cursor >= len(points):
return None
indices.append(cursor)
cursor += 1
return indices
def _point_to_segment_m(
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)
ratio = ((point[0] - start[0]) * dx + (point[1] - start[1]) * dy) / (dx * dx + dy * dy)
ratio = max(0.0, min(1.0, ratio))
return _distance(point, (start[0] + dx * ratio, start[1] + dy * ratio))
def _corner_deviation_m(
samples: list[tuple[float, float]],
before: tuple[float, float],
after: tuple[float, float],
start: tuple[float, float],
end: tuple[float, float],
center: tuple[float, float],
radius: float,
) -> float:
"""예상노선 점들이 「직선-곡선-직선」에서 얼마나 벗어나는지 — **평균** 거리(m).
사용자 확정(2026-09-07): 반지름은 법정 하한을 지키되 **예정노선에 가장 가까운 값**을 고른다.
그 「가까움」을 재는 자다. 원호 구간 밖의 점은 접선 두 개까지의 거리로 잰다.
⚠ 최대값이 아니라 **평균**을 쓴다(2026-09-07 실측) — 최대값은 반지름에 거의 반응하지
않는다. 작은 반지름이면 접선 두 개가 구간의 대부분을 덮어, 가장 먼 점은 어차피 꺾임점을
가로지르는 직선이 정하기 때문이다. 그래서 실제 원호 R 40m 짜리 곡선에서도 하한(12m)이
골라졌다. 평균으로 재면 「굴곡을 얼마나 잘 따라가나」가 값에 들어온다.
"""
if not samples:
return 0.0
total = 0.0
for sample in samples:
gap = min(
_point_to_segment_m(sample, before, start),
_point_to_segment_m(sample, end, after),
# 원호까지 거리 — 중심에서 잰 반지름 차. 원호 밖 각도면 끝점 거리가 더 가까워
# 위 두 값이 이미 그것을 담는다.
abs(_distance(sample, center) - radius),
)
total += gap
return total / len(samples)
def _arc_geometry(
before: tuple[float, float],
at: tuple[float, float],
after: tuple[float, float],
inner_deg: float,
radius: float,
half_tan: float,
) -> tuple[tuple[float, float], tuple[float, float], tuple[float, float]] | None:
"""반지름 하나에 대한 (접선시작, 접선끝, 중심). 원호를 못 끼우면 None."""
tangent = radius * half_tan
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)
bisector = (to_before[0] + to_after[0], to_before[1] + to_after[1])
bisector_length = math.hypot(*bisector)
if bisector_length <= 1e-9:
return None
center_distance = radius / math.sin(math.radians(inner_deg) / 2)
center = (
at[0] + bisector[0] / bisector_length * center_distance,
at[1] + bisector[1] / bisector_length * center_distance,
)
return start, end, center
def _fit_radius_m(
samples: list[tuple[float, float]],
before: tuple[float, float],
at: tuple[float, float],
after: tuple[float, float],
inner_deg: float,
half_tan: float,
min_radius_m: float,
max_radius_m: float,
steps: int = 48,
) -> float:
"""법정 하한 이상에서 **예정노선과 가장 덜 벌어지는** 반지름을 고른다.
왜 하한 고정이 아닌가(2026-09-07 사용자 확정) — 「반지름은 법정 최소 값을 지키되 기존
예정노선에 가까운 폴리라인을 찾는 게 키」. 꺾임이 뚜렷한 자리는 하한이 가장 가깝지만
(중앙종거 M = R(1/cos(Δ/2) 1) 이라 R 이 클수록 꺾임점에서 멀어짐), **완만하고 긴 굴곡**은
반대로 큰 반지름이 원본을 잘 따라간다. 그래서 자리마다 재서 고른다.
"""
if not samples or max_radius_m <= min_radius_m:
return min_radius_m
best_radius = min_radius_m
best_gap = float("inf")
for step in range(steps + 1):
# 작은 반지름 쪽을 촘촘히 본다 — 하한 부근에서 값이 빠르게 변한다.
ratio = step / steps
radius = min_radius_m * (max_radius_m / min_radius_m) ** ratio
geometry = _arc_geometry(before, at, after, inner_deg, radius, half_tan)
if geometry is None:
continue
start, end, center = geometry
gap = _corner_deviation_m(samples, before, after, start, end, center, radius)
if gap < best_gap - 1e-9:
best_gap, best_radius = gap, radius
return best_radius
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 _turn_sign(
before: tuple[float, float], at: tuple[float, float], after: tuple[float, float]
) -> int:
"""도는 방향 — 왼쪽 +1, 오른쪽 1, 곧게 0."""
cross = (at[0] - before[0]) * (after[1] - at[1]) - (at[1] - before[1]) * (after[0] - at[0])
if abs(cross) <= 1e-9:
return 0
return 1 if cross > 0 else -1
def _line_intersection(
a1: tuple[float, float],
a2: tuple[float, float],
b1: tuple[float, float],
b2: tuple[float, float],
) -> tuple[float, float] | None:
"""두 **직선**(선분 아님)의 교차점. 나란하면 None."""
dx1, dy1 = a2[0] - a1[0], a2[1] - a1[1]
dx2, dy2 = b2[0] - b1[0], b2[1] - b1[1]
denominator = dx1 * dy2 - dy1 * dx2
if abs(denominator) <= 1e-12:
return None
t = ((b1[0] - a1[0]) * dy2 - (b1[1] - a1[1]) * dx2) / denominator
return (a1[0] + dx1 * t, a1[1] + dy1 * t)
def _curve_runs(
cleaned: list[tuple[float, float]], straight_inner_angle_deg: float
) -> list[tuple[int, int]]:
"""**한 곡선으로 묶을 꺾임점 구간**을 고른다 — `[(첫 꺾임점, 끝 꺾임점)]`.
왜 묶나(2026-09-07 실측) — 꺾임점 하나마다 원호를 끼우면 **긴 완만한 곡선을 만들 수 없다**.
실제로 반지름 40m 짜리 원호를 점으로 흉내 내 넣어 봤더니, 그 곡선이 꺾임점 세 개로 쪼개져
각각에 작은 원호가 들어갔고 반지름을 키울수록 원본에서 **멀어졌다**(평균 벗어남
1.56m → 1.89m). 한 곡선은 한 원호여야 한다 — 사용자가 말한 「직선 > 곡선 > 직선」이 그것이다.
묶는 규칙: 곡선 대상(내각이 기준 미만)이면서 **도는 방향이 같은** 꺾임점이 이어지면 한 묶음.
"""
runs: list[tuple[int, int]] = []
index = 1
while index < len(cleaned) - 1:
inner = _inner_angle_deg(cleaned[index - 1], cleaned[index], cleaned[index + 1])
if inner >= CURVE_GROUP_INNER_ANGLE_DEG:
index += 1
continue
sign = _turn_sign(cleaned[index - 1], cleaned[index], cleaned[index + 1])
end = index
while end + 1 < len(cleaned) - 1:
following = end + 1
inner_next = _inner_angle_deg(
cleaned[following - 1], cleaned[following], cleaned[following + 1]
)
if inner_next >= CURVE_GROUP_INNER_ANGLE_DEG:
break
if (
_turn_sign(cleaned[following - 1], cleaned[following], cleaned[following + 1])
!= sign
):
break
end = following
# 별표2 의 155° 는 **묶은 뒤 전체 교각**에 적용한다 — 앞뒤 직선이 이루는 내각이
# 그만큼 펴져 있으면 곡선을 두지 않는다.
apex = (
cleaned[index]
if index == end
else _line_intersection(
cleaned[index - 1], cleaned[index], cleaned[end], cleaned[end + 1]
)
)
total_inner = (
180.0 if apex is None else _inner_angle_deg(cleaned[index - 1], apex, cleaned[end + 1])
)
if total_inner < straight_inner_angle_deg:
runs.append((index, end))
index = end + 1
return runs
def _run_worst_gap_m(
first: int,
last: int,
cleaned: list[tuple[float, float]],
samples: list[tuple[float, float]],
min_radius_m: float,
) -> tuple[float, float]:
"""그 묶음을 **한 곡선**으로 폈을 때 예정노선에서 벗어나는 (최대, 평균) 거리(m).
못 끼우면 (무한대, 무한대).
"""
entry_from = cleaned[first - 1]
exit_to = cleaned[last + 1]
apex = (
cleaned[first]
if first == last
else _line_intersection(entry_from, cleaned[first], cleaned[last], exit_to)
)
if apex is None:
return float("inf"), float("inf")
inner = _inner_angle_deg(entry_from, apex, exit_to)
half_tan = math.tan(math.radians(180.0 - inner) / 2)
available = min(_distance(entry_from, apex), _distance(apex, exit_to)) / 2
if half_tan <= 1e-9 or available <= 0:
return float("inf"), float("inf")
radius = min(
_fit_radius_m(
samples, entry_from, apex, exit_to, inner, half_tan, min_radius_m, available / half_tan
),
available / half_tan,
)
geometry = _arc_geometry(entry_from, apex, exit_to, inner, radius, half_tan)
if geometry is None:
return float("inf"), float("inf")
start, end, center = geometry
line = [
entry_from,
start,
*_arc_points(center, start, end, _turn_sign(entry_from, apex, exit_to) < 0),
end,
exit_to,
]
gaps = [
min(_point_to_segment_m(sample, a, b) for a, b in zip(line, line[1:])) for sample in samples
]
return max(gaps), sum(gaps) / len(gaps)
def _split_wide_runs(
runs: list[tuple[int, int]],
cleaned: list[tuple[float, float]],
deduped: list[tuple[float, float]],
node_indices: list[int],
min_radius_m: float,
) -> list[tuple[int, int]]:
"""묶는 것이 **손해면 도로 쪼갠다** — 한 곡선으로 펴서 원본에서 더 멀어지면 안 묶는다.
왜(2026-09-07 실측) — 이어진 꺾임을 한 곡선으로 묶으면 완만한 굴곡은 잘 따라가지만
(반지름 40m 짜리 시험 곡선: 평균 벗어남 0.385 → 0.250m), 성격이 다른 굴곡이 이어 붙은
자리를 통째로 묶으면 **최대 벗어남이 3.6m → 15.2m** 로 벌어졌다. 그래서 묶음마다
「묶었을 때」와 「낱개로 뒀을 때」를 재서 **원본에 더 가까운 쪽**을 고른다.
"""
result: list[tuple[int, int]] = []
for first, last in runs:
if first == last:
result.append((first, last))
continue
samples = deduped[node_indices[first - 1] : node_indices[last + 1] + 1]
merged_max, merged_mean = _run_worst_gap_m(first, last, cleaned, samples, min_radius_m)
apart = [
_run_worst_gap_m(
index,
index,
cleaned,
deduped[node_indices[index - 1] : node_indices[index + 1] + 1],
min_radius_m,
)
for index in range(first, last + 1)
]
apart_max = max(item[0] for item in apart)
apart_mean = sum(item[1] for item in apart) / len(apart)
# 평균이 나으면 묶는다 — 「직선 > 곡선 > 직선」이 사용자가 원한 모양이라 조각을
# 늘리기보다 한 곡선을 우선한다. 다만 **한 자리라도 크게 벌어지면** 안 묶는다.
if merged_mean <= apart_mean and merged_max <= max(apart_max, MERGE_MAX_GAP_M):
result.append((first, last))
else:
result.extend((index, index) for index in range(first, last + 1))
return result
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). 원호 점이 섞인 폴리라인을 다시 단순화하면 꺾임점이 조금씩 지워지고,
그 결과로 만든 폴리라인을 또 단순화하기 때문이다. **단순화는 원본 점군에서 한 번만.**
"""
deduped = dedupe_points(points)
cleaned = simplify_to_nodes(deduped) if simplify else deduped
if len(cleaned) < 3:
nodes = [RouteNode(x=x, y=y) for x, y in cleaned]
return PlannedPolyline(nodes=nodes, vertices=list(cleaned))
# 반지름을 예정노선에 맞추려면 **꺾임점 사이의 원본 점**이 있어야 한다. 사용자가 옮긴
# 노드를 받은 경우(`simplify=False`)나 자리를 못 찾는 경우에는 피팅을 건너뛴다.
node_indices = _node_indices(deduped, cleaned) if simplify else None
nodes = [RouteNode(x=x, y=y) for x, y in cleaned]
for index in range(1, len(cleaned) - 1):
nodes[index].inner_angle_deg = _inner_angle_deg(
cleaned[index - 1], cleaned[index], cleaned[index + 1]
)
runs = _curve_runs(cleaned, straight_inner_angle_deg)
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]]
cursor = 0 # 아직 선에 안 실은 첫 꺾임점
for first, last in runs:
# 곡선 앞뒤의 **직선**. 묶음 안 꺾임점은 그 곡선이 대신하므로 선에 넣지 않는다.
entry_from, entry_to = cleaned[first - 1], cleaned[first]
exit_from, exit_to = cleaned[last], cleaned[last + 1]
# 두 직선을 늘려 만나는 자리가 이 곡선의 꺾임점(IP)이다. 묶음이 하나면 그 노드 자신.
apex = (
cleaned[first]
if first == last
else _line_intersection(entry_from, entry_to, exit_from, exit_to)
)
node = nodes[first]
if apex is None: # 두 직선이 나란하다 — 곡선을 못 끼운다.
for index in range(cursor + 1, last + 1):
vertices.append(cleaned[index])
cursor = last
continue
inner = _inner_angle_deg(entry_from, apex, exit_to)
half_tan = math.tan(math.radians(180.0 - inner) / 2)
# 접선이 들어갈 자리 — 앞뒤 직선을 이웃 곡선과 나눠 쓰므로 절반까지만.
available = min(_distance(entry_from, apex), _distance(apex, exit_to)) / 2
if half_tan <= 1e-9 or available <= 0:
for index in range(cursor + 1, last + 1):
vertices.append(cleaned[index])
cursor = last
continue
# 반지름은 **법정 하한 이상에서 예정노선에 가장 가까운 값**(2026-09-07 사용자 확정).
# 자리가 모자라면 하한 아래로 줄이되 **막지 않고 위반으로 표시**한다(기존 규칙).
radius = min_radius_m
if node_indices is not None:
samples = deduped[node_indices[first - 1] : node_indices[last + 1] + 1]
radius = _fit_radius_m(
samples,
entry_from,
apex,
exit_to,
inner,
half_tan,
min_radius_m,
available / half_tan,
)
tangent = radius * half_tan
if tangent > available:
radius = available / half_tan
tangent = available
geometry = _arc_geometry(entry_from, apex, exit_to, inner, radius, half_tan)
if radius <= 0 or geometry is None:
for index in range(cursor + 1, last + 1):
vertices.append(cleaned[index])
cursor = last
continue
start, end, center = geometry
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
# 곡선 앞의 직선 위 꺾임점들은 그대로 잇는다(이 묶음 앞까지).
for index in range(cursor + 1, first):
vertices.append(cleaned[index])
cursor = last
clockwise = _turn_sign(entry_from, apex, exit_to) < 0
vertices.append(start)
vertices.extend(_arc_points(center, start, end, clockwise=clockwise))
vertices.append(end)
for index in range(cursor + 1, len(cleaned)):
vertices.append(cleaned[index])
return PlannedPolyline(nodes=nodes, vertices=vertices)