Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1

This commit is contained in:
2026-09-06 23:55:12 +09:00
4 changed files with 401 additions and 23 deletions
+12 -1
View File
@@ -46,6 +46,8 @@ export function createHaulPlanPrefetch(
let pending: unknown = null;
/** 마지막으로 보낸 입력의 표식 — 같은 값이면 다시 묻지 않는다. */
let sentKey: string | null = null;
/** 받은 값을 화면에 반영하려고 다시 그리는 중 — 그 그리기가 거는 요청은 무시한다. */
let applying = false;
/**
* 입력이 달라졌는지 가리는 짧은 표식. 응답이 오면 곡선을 다시 그리고, 그리기가 다시
@@ -81,7 +83,15 @@ export function createHaulPlanPrefetch(
// 늦게 온 응답은 버린다 — 그 사이 사용자가 계획고를 더 만졌을 수 있다.
if (seq !== sequence) return;
plan = payload.haul_plan ?? null;
onReady();
// 다시 그리기가 `schedule` 을 되부르므로 그 한 바퀴를 막는다. 표식(`sentKey`)만으로는
// 부족했다 — 반영 뒤 누계가 아주 조금 달라지는 경로가 있어 2~3바퀴가 더 돌았다
// (2026-09-06 보조 창 실측: 편집이 멈춘 뒤 2.8~6.3초에 걸쳐 3건).
applying = true;
try {
onReady();
} finally {
applying = false;
}
} catch {
// 배분을 못 받아도 곡선 자체는 그대로 보인다 — 화면을 막지 않는다.
} finally {
@@ -91,6 +101,7 @@ export function createHaulPlanPrefetch(
return {
schedule(result) {
if (applying) return; // 응답을 반영하려고 다시 그리는 중이다.
const key = keyOf(result);
if (key === sentKey) return; // 같은 값 — 응답이 부른 재그리기가 되돌아온 것이다.
sentKey = key;
+117 -21
View File
@@ -1,15 +1,18 @@
"""계획노선 편집 — 노선 두 벌(예상노선·계획노선)과 그 뒤 재계산의 서버 몫.
노선은 두 벌이다(2026-09-06 사용자 확정, PLAN 0-7).
노선은 **세 벌**이다(2026-09-06 사용자 확정 → 같은 날 정정, PLAN 0-7).
- **예상노선**(원본) — 파일 업로드 자동 체인이 낸 노선. `initial_snapshot/planned_route.csv`.
어떤 경로로도 고치지 않는다.
- **계획노선**(수정본) — 예상노선과 **같은 값으로 시작**해 사용자가 고쳐 쓰는 노선.
`B05_Profile/route/planned_route.csv`. 설계 계통은 이것이 있으면 이것을 읽는다
(`load_design_route`).
- **예상노선**(원본) — 파일 업로드 자동 체인이 낸 . `B05_Profile/route/expected_route.csv`.
⚠ 이것은 폴리라인이 아니라 **점 묶음**이고 규칙 없는 폴리라인과도 맞지 않는다
(2026-09-06 사용자 확인). 어떤 경로로도 고치지 않는다.
- **계획노선 초기본** — 위를 복사해 **폴리라인으로 바꾼 것**. `planned_route_initial.csv`.
**불변의 초기 데이터**이며, 유토곡선·3D 에 투영되는 선도 이것이다. 곡선은 지식DB
기준(별표2 Ⅰ.2.다 — 설계속도·지형별 최소곡선반지름, 내각 155° 이상은 생략)으로 끼운다.
- **계획노선**(수정본) — 초기본에서 시작해 사용자가 **노드를 잡아** 고친 것.
`planned_route.csv`. 설계 계통은 이것이 있으면 이것을 읽는다(`load_design_route`).
노선 초기화는 수정본 파일을 지우는 것이다 — 그러면 원본을 읽으므로 「원본을 수정본으로
복사」와 결과가 같다.
노선 초기화는 수정본 파일을 지우는 것이다 — 그러면 초기 폴리라인을 읽으므로 「초기본을
수정본으로 복사」와 결과가 같다.
[확인]을 눌렀을 때만 계산이 돈다. 재계산은 **초기 업로드 체인의 로직을 그대로 재사용**한다
(2026-09-06 사용자 제안) — `run_redesign_chain`이 배수유역 다시 분석 → 기본 관 저장 → 관
@@ -41,10 +44,12 @@ from common_util.common_util_initial_snapshot import design_route_csv_path
from common_util.common_util_route_geometry import (
densify_route,
expected_route_csv_path,
planned_route_initial_path,
planned_route_working_path,
read_planned_route_csv,
write_route_csv,
)
from common_util.common_util_route_polyline import build_planned_polyline
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool
from config.config_system import (
@@ -114,6 +119,68 @@ def _ensure_expected_route(project_root: Path) -> str:
return "none"
async def _min_plan_radius_m(project_id: UUID) -> float:
"""이 프로젝트에 적용할 법정 최소곡선반지름(m) — 임도 종류·설계속도·지형으로 고른다.
값의 출처는 지식DB(`01_임도/02_상세설계/평면선형.md`, 별표2 Ⅰ.2.다)이고 코드에서는
`config_system_design` 이 그대로 들고 있다. 프로젝트 설정을 못 읽으면 가장 완화된
조건(설계속도 20·특수지형)으로 떨어진다 — 막지 않고 위반 표시만 하기 때문이다.
"""
from B05_Profile.B05_Profile_Engine_Grade import resolve_design_speed
from config.config_system_design import FOREST_ROAD_PROFILE_CRITERIA
table = FOREST_ROAD_PROFILE_CRITERIA["min_plan_radius_m"]
grade_class, design_speed, terrain = "work", None, "special"
try:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
await cursor.execute(
"SELECT road_type, design_speed_kph, terrain_type FROM projects WHERE id = %s",
(str(project_id),),
)
row = await cursor.fetchone()
if row:
grade_class = str(row[0] or grade_class)
design_speed = int(row[1]) if row[1] else None
terrain = "normal" if str(row[2] or "").lower() == "normal" else "special"
except Exception: # noqa: BLE001 — 설정을 못 읽어도 폴리라인화는 이어 간다
logger.exception("최소곡선반지름 설정을 못 읽어 기본값을 씁니다: %s", project_id)
speed = resolve_design_speed(grade_class, design_speed)
return float(table.get(speed, table[20])[terrain])
def _write_planned_polyline(path: Path, points: list[tuple[float, float]], radius_m: float) -> dict:
"""점 묶음을 폴리라인으로 바꿔 CSV 로 쓴다. 노드 요약을 돌려준다."""
result = build_planned_polyline(points, min_radius_m=radius_m)
write_route_csv(path, [{"x": x, "y": y} for x, y in result.vertices])
return {
"nodes": len(result.nodes),
"curves": result.curve_count,
"violations": result.violation_count,
"vertices": len(result.vertices),
}
def _ensure_planned_initial(project_root: Path, radius_m: float) -> dict | None:
"""계획노선 **초기 폴리라인**이 없으면 예상노선을 폴리라인화해 세운다."""
target = planned_route_initial_path(project_root)
if target.is_file():
return None
points = [(x, y) for x, y in _vertices_of(expected_route_csv_path(project_root))]
if len(points) < 2:
return None
summary = _write_planned_polyline(target, points, radius_m)
logger.info(
"계획노선 초기 폴리라인 생성: %s (노드 %d · 곡선 %d · 위반 %d · 정점 %d)",
target,
summary["nodes"],
summary["curves"],
summary["violations"],
summary["vertices"],
)
return summary
def _write_working_route(path: Path, vertices: list[tuple[float, float]]) -> int:
"""계획노선(수정본) CSV를 쓴다. 열 이름은 `read_planned_route_csv()`가 아는 것.
@@ -221,7 +288,12 @@ async def _recompute(project_id: UUID, project_root: Path, stored_path: str) ->
@router.get("/{project_id}/route/plan", response_model=None)
async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse:
"""예상노선(원본)과 계획노선(수정본) 정점을 함께 돌려준다 — 편집 모달이 둘 다 그린다."""
"""예상노선(점 묶음)·계획노선(폴리라인)·편집할 노드를 함께 돌려준다.
화면이 그리는 것은 셋이다 — 예상노선은 **점선**, 계획노선은 **실선**, 그리고 사용자가
잡아 옮기는 **노드**(꺾임점). 노드에는 그 자리에 끼운 반지름·내각·법정 위반 표시가
붙어 있어 화면이 그대로 보여 줄 수 있다(2026-09-06 사용자 지시).
"""
paths = await _project_paths(project_id)
if paths is None:
return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING)
@@ -230,15 +302,29 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse:
expected = await asyncio.to_thread(_vertices_of, expected_route_csv_path(project_root))
if not expected:
expected = await asyncio.to_thread(_vertices_of, design_route_csv_path(project_root))
working_path = planned_route_working_path(project_root)
planned = await asyncio.to_thread(_vertices_of, working_path)
radius_m = await _min_plan_radius_m(project_id)
await asyncio.to_thread(_ensure_planned_initial, project_root, radius_m)
working = await asyncio.to_thread(_vertices_of, planned_route_working_path(project_root))
initial = await asyncio.to_thread(_vertices_of, planned_route_initial_path(project_root))
planned = working or initial or expected
# 노드는 **원본 점**에서 뽑는다 — 폴리라인 정점에는 원호 위 점이 섞여 있어 편집 대상이
# 아니다. 고친 적이 있으면 그때 보낸 노드가 곧 수정본의 씨앗이므로 같은 규칙으로 다시 냄.
node_source = expected if not working else working
outline = await asyncio.to_thread(
build_planned_polyline, [(x, y) for x, y in node_source], min_radius_m=radius_m
)
return {
"status": "success",
"project_id": str(project_id),
"expected": expected,
# 고친 적이 없으면 계획노선 = 예상노선(같은 값으로 시작한다).
"planned": planned or expected,
"edited": bool(planned),
"planned": planned,
"nodes": [node.as_dict() for node in outline.nodes],
"min_radius_m": round(radius_m, 2),
"curve_count": outline.curve_count,
"violation_count": outline.violation_count,
"edited": bool(working),
}
@@ -257,17 +343,25 @@ async def replan_route(
return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING)
project_root, stored_path = paths
# 고치기 전에 예상노선(원본)이 서 있는지 본다 — 초기화가 돌아갈 자리다.
# 고치기 전에 예상노선(원본)·초기 폴리라인이 서 있는지 본다 — 초기화가 돌아갈 자리다.
await asyncio.to_thread(_ensure_expected_route, project_root)
vertices = [(vertex.x, vertex.y) for vertex in request.vertices]
written = await asyncio.to_thread(
_write_working_route, planned_route_working_path(project_root), vertices
radius_m = await _min_plan_radius_m(project_id)
await asyncio.to_thread(_ensure_planned_initial, project_root, radius_m)
# 화면이 보낸 것은 **노드(꺾임점)** 다 — 같은 R 규칙으로 다시 폴리라인을 만든다.
# 노드만 옮기면 선이 저절로 규칙을 지키는 것이 이 구조의 목적이다(2026-09-06 사용자).
nodes = [(vertex.x, vertex.y) for vertex in request.vertices]
summary = await asyncio.to_thread(
_write_planned_polyline, planned_route_working_path(project_root), nodes, radius_m
)
written = summary["vertices"]
logger.info(
"계획노선 갈아 끼움: project_id=%s 정점 %d%d(조밀화)",
"계획노선 갈아 끼움: project_id=%s 노드 %d → 정점 %d (곡선 %d · 위반 %d · R %.1fm)",
project_id,
len(vertices),
len(nodes),
written,
summary["curves"],
summary["violations"],
radius_m,
)
result = await _recompute(project_id, project_root, stored_path)
if "error" in result:
@@ -288,10 +382,12 @@ async def reset_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse:
return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING)
project_root, stored_path = paths
await asyncio.to_thread(_ensure_expected_route, project_root)
radius_m = await _min_plan_radius_m(project_id)
await asyncio.to_thread(_ensure_planned_initial, project_root, radius_m)
working_path = planned_route_working_path(project_root)
if working_path.is_file():
working_path.unlink()
logger.info("계획노선 초기화(예상노선으로): project_id=%s", project_id)
logger.info("계획노선 초기화(초기 폴리라인으로): project_id=%s", project_id)
result = await _recompute(project_id, project_root, stored_path)
if "error" in result:
return JSONResponse(
+19 -1
View File
@@ -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),
):
+253
View File
@@ -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)