feat(B05): 계획노선을 폴리라인으로 만드는 초기본 신설
사용자 확인(2026-09-06) — 예상노선은 폴리라인이 아니라 점 묶음이고 규칙 없는
폴리라인과도 맞지 않음. 그래서 계획노선은 원데이터를 복사해 폴리라인으로 바꾼 것이어야
하고 그것이 불변의 초기 데이터가 됨. 곡선 기준은 지식DB(별표2 I.2.다) 값을 씀.
- common_util_route_polyline.py 신설 — 점 묶음을 폴리라인으로.
① Douglas-Peucker 로 꺾임점(IP)만 남김. 예상노선은 격자 탐색이 낸 조밀한 점군이라
(용화: 1,097m 에 331점) 점마다 곡선을 끼우면 접선 자리가 1.5m 뿐이라 R 이 2~6m 로
뭉개짐. 허용오차는 격자 해상도(2m)의 두 배.
② 내각 155도 이상은 곡선 생략(별표2), 그 아래는 최소곡선반지름으로 원호를 끼움.
자리가 모자라면 반지름을 줄이되 막지 않고 위반으로 표시(사용자 확정: 경고만).
③ 점은 옮기지 않음 — 노드는 원본 자리에 그대로 두고 그 사이에 원호를 넣음.
- planned_route_initial_path 신설 — 세 벌 구조(예상노선 점 묶음 / 초기 폴리라인 /
수정본). load_design_route 읽는 순서에 초기 폴리라인을 예상노선보다 앞에 끼움.
- 최소곡선반지름은 임도 종류·설계속도·지형으로 고름
(FOREST_ROAD_PROFILE_CRITERIA[min_plan_radius_m], 못 읽으면 가장 완화된 조건).
- GET /route/plan 이 노드(반지름·내각·위반)까지 함께 돌려줌 — 노드만 옮기면 선이
저절로 규칙을 지키게 하는 것이 목적. POST /route/replan 도 받은 노드로 다시 폴리라인화.
자체검증(용화, 실화면 API) — 예상노선 331점 -> 편집 노드 25개 + 곡선 13곳
(전부 R 12m = 설계속도 20·특수지형 법정 하한, 위반 0) -> 계획노선 정점 142개. 응답 180ms.
단순화 전에는 곡선 15곳이 R 2.75~6.51m 로 전부 위반이었음.
시험 tmp/tests/test_route_polyline.py 7건(원호가 접선과 맞물리는지 좌표로 대조).
전체 407 통과·17 건너뜀.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -246,6 +246,21 @@ def planned_route_working_path(project_root: Path) -> Path:
|
||||
return Path(project_root) / "B05_Profile" / "route" / "planned_route.csv"
|
||||
|
||||
|
||||
def planned_route_initial_path(project_root: Path) -> Path:
|
||||
"""계획노선 **초기 폴리라인** 자리 — 예상노선(점 묶음)을 폴리라인으로 바꾼 한 벌.
|
||||
|
||||
왜 한 벌 더 두나(2026-09-06 사용자 지시) — 예상노선은 폴리라인이 아니라 **점 묶음**이라
|
||||
그대로는 설계선이 못 된다. 계획노선은 「원본을 복사해 폴리라인으로 바꾼 것」이며 그것이
|
||||
**불변의 초기 데이터**다. 노선 초기화는 수정본을 지워 이 파일로 돌아가는 것이다.
|
||||
|
||||
세 벌의 관계 —
|
||||
· `expected_route.csv` 예상노선(원본 점 묶음, 불변)
|
||||
· `planned_route_initial.csv` 그것을 폴리라인화한 것(**불변 초기 데이터**)
|
||||
· `planned_route.csv` 사용자가 고친 수정본(있으면 이것이 설계 노선)
|
||||
"""
|
||||
return Path(project_root) / "B05_Profile" / "route" / "planned_route_initial.csv"
|
||||
|
||||
|
||||
def load_design_route(
|
||||
project_root: Path,
|
||||
surface_params: dict[str, Any] | None = None,
|
||||
@@ -278,10 +293,13 @@ def load_design_route(
|
||||
# 지표면·노선이 바뀌면 `discard_initial_snapshot()`이 폴더째 지우므로 이 경로가 저절로
|
||||
# 닫히고 원본 재판독으로 되돌아간다. 트림 **전** 원본이 필요한 호출(도엽 범위 —
|
||||
# surface_params 없음)은 여기를 타지 않는다.
|
||||
# 읽는 순서 — 계획노선(수정본) → 예상노선(원본) → 초기값 스냅샷(옛 자리).
|
||||
if surface_params:
|
||||
# 읽는 순서 — 수정본 → **초기 폴리라인** → 예상노선(점 묶음) → 초기값 스냅샷.
|
||||
# 초기 폴리라인이 예상노선보다 앞선다: 예상노선은 점 묶음이라 그대로 이으면
|
||||
# 규칙 없는 선이 된다(2026-09-06 사용자 지시).
|
||||
for master in (
|
||||
planned_route_working_path(project_root),
|
||||
planned_route_initial_path(project_root),
|
||||
expected_route_csv_path(project_root),
|
||||
design_route_csv_path(project_root),
|
||||
):
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
"""예상노선(점 묶음)을 **계획노선 폴리라인**으로 바꾸는 자리.
|
||||
|
||||
왜 필요한가(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
|
||||
|
||||
# 원호를 몇 도마다 한 점씩 찍을지 — 촘촘할수록 매끄럽지만 정점이 늘어난다.
|
||||
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 simplify_to_nodes(
|
||||
points: list[tuple[float, float]], tolerance_m: float = SIMPLIFY_TOLERANCE_M
|
||||
) -> list[tuple[float, float]]:
|
||||
"""조밀한 점군에서 **꺾임점(IP)** 만 남긴다 — Douglas-Peucker.
|
||||
|
||||
예상노선은 격자 탐색이 낸 점군이라 3m 간격으로 촘촘하다. 그대로 두면 곡선을 끼울
|
||||
접선 자리가 없어 반지름이 뭉개진다. 원래 선에서 `tolerance_m` 보다 멀어지지 않는
|
||||
선에서 점을 걷어내므로 **모양은 그대로**다.
|
||||
"""
|
||||
if len(points) < 3:
|
||||
return list(points)
|
||||
from shapely.geometry import LineString
|
||||
|
||||
simplified = LineString(points).simplify(tolerance_m, preserve_topology=False)
|
||||
result = [(float(x), float(y)) for x, y in simplified.coords]
|
||||
return result if len(result) >= 2 else list(points)
|
||||
|
||||
|
||||
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,
|
||||
) -> PlannedPolyline:
|
||||
"""점 묶음을 계획노선 폴리라인으로 바꾼다.
|
||||
|
||||
`min_radius_m` 은 설계속도·지형으로 고른 법정 최소곡선반지름이다
|
||||
(`config_system_design.FOREST_ROAD_PROFILE_CRITERIA["min_plan_radius_m"]`).
|
||||
|
||||
**먼저 꺾임점을 뽑는다**(`simplify_to_nodes`) — 예상노선은 조밀한 점군이라 그대로 두면
|
||||
곡선을 끼울 접선 자리가 없어 반지름이 뭉개진다(용화 실측: 점마다 끼우면 R 2~6m).
|
||||
"""
|
||||
cleaned = simplify_to_nodes(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
|
||||
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)
|
||||
Reference in New Issue
Block a user