From 10b4b32a855e7f6a451f3fb22e782e02f93c8a37 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 18:50:42 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05):=20=EB=85=B8=EC=84=A0=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=20=EA=B3=A1=EC=84=A0=EC=9D=84=20=EB=B8=8C=EB=9D=BC?= =?UTF-8?q?=EC=9A=B0=EC=A0=80=EA=B0=80=20=EC=A6=89=EC=8B=9C=20=EA=B7=B8?= =?UTF-8?q?=EB=A6=AC=EA=B2=8C=20=E2=80=94=20=EC=86=90=EB=8C=80=EB=A9=B4=20?= =?UTF-8?q?=EA=B3=A1=EC=84=A0=EC=9D=B4=20=EC=82=AC=EB=9D=BC=EC=A7=80?= =?UTF-8?q?=EB=8D=98=20=EA=B2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 지적 ①② 의 뿌리 하나를 고침. 곡선을 서버만 그려서, 노드를 끌거나 손잡이를 살짝 건드리기만 해도 그려 둔 선·손잡이·노드 요약을 통째로 비웠음. 그 결과 화면에서 곡선이 전부 사라져 「R 이 지워졌다」로 보였음(값 자체는 남아 [확인] 때 반영됐음). - `buildEditedPolyline` 신설 — `build_planned_polyline` 의 **편집 갈래** (simplify=False + curve_flags/radii) 파이썬·TS 짝. 단순화·IP 추출·반지름 피팅은 초기 변환 전용이라 안 옮김. - `markEdited` 가 비우는 대신 **같은 규칙으로 다시 그림**. 나머지 곡선은 안 사라짐. - 손잡이는 곡선 목록 자리가 아니라 **노드 번호**로 잡음 — 다시 그릴 때마다 목록이 새로 나므로 자리로 들면 엉뚱한 곡선을 가리킴. - 접선 자리가 모자라 R 이 눌리는 것이 끄는 즉시 보임(상태줄 「기준 미달 N곳」). - 끌기 중에도 상태줄을 갱신 — 예전에는 아무 말이 없어 사라진 인상만 남았음. - 서버가 곡선을 안 둔 자리(내각 179° 이상)는 **곡선 없음으로 열게** 고침. 전부 켬으로 열면 아무것도 안 만지고 [확인]만 눌러도 곡선이 새로 생겨 노선이 조용히 바뀌었음. 거울 시험 `tmp/tests/test_route_polyline_browser_mirror.py` 10건 추가. 원호 표본 개수는 `math.sin` 의 마지막 자리 차이로 90° 처럼 딱 떨어지는 자리에서 하나 갈릴 수 있어(같은 원 위의 같은 호), 개수가 같을 때는 1e-9 로 자리까지, 갈릴 때는 현 하나분 안쪽으로 모양을 맞춤. 일부러 공식을 틀어 시험이 잡는 것도 확인함. 실화면(5174) — 노드/손잡이를 끈 뒤에도 상태줄이 「곡선 22곳(하한 R 12m)」 유지, 「기준 미달 2곳」이 그 자리에서 뜸. 종전에는 「곡선 기준 R 12m」로 바뀌며 다 사라졌음. 전체 554 passed · 18 skipped, tsc 통과. Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_UI_RouteEdit.ts | 67 ++--- B05_Profile/B05_Profile_UI_RouteEdit_Curve.ts | 251 +++++++++++++++++- 2 files changed, 280 insertions(+), 38 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index fdf9159f..1d2b41fb 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -29,7 +29,7 @@ import { showToast } from "@ui/ui_template_elements"; import { fetchDrainageLayers } from "./B05_Profile_UI_Drainage_Parts"; import { fetchRoutePlan, replanRoute, resetRoutePlan } from "./B05_Profile_Api_Replan"; import type { RoutePlanCurve } from "./B05_Profile_Api_Replan"; -import { dragHandleTo as curveDragTo } from "./B05_Profile_UI_RouteEdit_Curve"; +import { buildEditedPolyline, dragHandleTo as curveDragTo } from "./B05_Profile_UI_RouteEdit_Curve"; import "./B05_Profile_UI_Style_RouteEdit.css"; /** 노드를 잡았다고 볼 거리(px). 손가락·마우스 모두 무리 없는 크기. */ @@ -259,18 +259,21 @@ export async function openRouteEditModal( context.restore(); } - /** 화면 좌표에 가장 가까운 **곡선 손잡이**(시작·끝점). 없으면 null. */ - function handleAt(px: number, py: number): { curve: number; end: "start" | "end" } | null { - let best: { curve: number; end: "start" | "end" } | null = null; + /** 화면 좌표에 가장 가까운 **곡선 손잡이**(시작·끝점). 없으면 null. + * + * 잡은 것을 **노드 번호**로 기억한다 — 곡선 목록은 고칠 때마다 다시 만들어지므로 목록 + * 자리(index)로 들고 있으면 끄는 도중 엉뚱한 곡선을 가리키게 된다. */ + function handleAt(px: number, py: number): { node: number; end: "start" | "end" } | null { + let best: { node: number; end: "start" | "end" } | null = null; let bestDistance = NODE_HIT_PX + 2; - curveInfo.forEach((curve, index) => { + curveInfo.forEach((curve) => { (["start", "end"] as const).forEach((which) => { const point = which === "start" ? curve.start : curve.end; const [x, y] = toScreen([point[0], point[1]]); const distance = Math.hypot(x - px, y - py); if (distance <= bestDistance) { bestDistance = distance; - best = { curve: index, end: which }; + best = { node: curve.node_first, end: which }; } }); }); @@ -279,13 +282,12 @@ export async function openRouteEditModal( /** 끈 접선점으로 새 교각점·새 반지름을 구한다 — 셈은 `_RouteEdit_Curve` 몫. */ function dragHandleTo( - curveIndex: number, + node: number, which: "start" | "end", to: Vertex, ): { apex: Vertex; radius: number } | null { - const curve = curveInfo[curveIndex]; + const curve = curveInfo.find((entry) => entry.node_first === node); if (!curve) return null; - const node = curve.node_first; const before = planned[node - 1]; const after = planned[node + 1]; if (!before || !after) return null; @@ -327,12 +329,17 @@ export async function openRouteEditModal( return best; } - /** 노드를 고쳤다 — 서버가 만든 폴리라인은 낡았으므로 지우고 직선으로 미리 보인다. - * 곡선은 [확인] 때 서버가 같은 R 규칙으로 다시 끼운다(계산을 두 벌로 짜지 않는다). */ + /** 노드를 고쳤다 — **곡선을 그 자리에서 다시 그린다**(2026-09-07 사용자 지적 ①②). + * + * 예전에는 그려 둔 선과 손잡이를 통째로 비웠다. 그러면 노드 하나만 건드려도 **곡선이 전부 + * 사라진 것처럼** 보였다 — 값은 남아 있는데 화면만 「지워졌다」고 말하니 되돌릴 길을 찾게 됐다. + * 지금은 서버와 **같은 규칙**(`buildEditedPolyline` 짝)으로 즉시 다시 만든다. [확인] 때 + * 서버가 정본으로 다시 내는 것은 그대로다. */ function markEdited(): void { - plannedLine = []; - nodeInfo = []; - curveInfo = []; // 손잡이 자리도 낡았다 — [확인] 때 서버가 다시 낸다. + const built = buildEditedPolyline(planned, curveOn, curveRadius, minRadiusM); + plannedLine = built.vertices; + curveInfo = built.curves; + nodeInfo = built.nodes; } /** 상태줄 꼬리 — 곡선 기준과 위반 수를 알린다. */ @@ -429,7 +436,7 @@ export async function openRouteEditModal( // ── 조작 — 노드 끌기 / 배경 끌기(팬) / 휠 확대 / 두 번 클릭 삽입 / 오른쪽 클릭 삭제 ── let dragNode = -1; /** 끌고 있는 곡선 손잡이(시작·끝점). 노드 끌기보다 우선한다. */ - let dragHandle: { curve: number; end: "start" | "end" } | null = null; + let dragHandle: { node: number; end: "start" | "end" } | null = null; let panFrom: { x: number; y: number; offsetX: number; offsetY: number } | null = null; canvas.addEventListener("pointerdown", (event) => { @@ -441,7 +448,7 @@ export async function openRouteEditModal( dragHandle = handleAt(px, py); dragNode = dragHandle ? -1 : nodeAt(px, py); if (dragHandle) { - picked = curveInfo[dragHandle.curve]?.node_first ?? -1; + picked = dragHandle.node; syncCurveBar(); draw(); } else if (dragNode >= 0) { @@ -460,31 +467,28 @@ export async function openRouteEditModal( const py = event.clientY - rect.top; if (dragHandle) { // 곡선 시작·끝점을 끈다 — 그쪽 직선 각도와 반지름이 함께 바뀐다(2026-09-07 사용자 확정). - const moved = dragHandleTo(dragHandle.curve, dragHandle.end, toMetric(px, py)); + const node = dragHandle.node; + const moved = dragHandleTo(node, dragHandle.end, toMetric(px, py)); if (moved) { - const node = curveInfo[dragHandle.curve].node_first; planned[node] = moved.apex; curveRadius[node] = Math.round(moved.radius * 100) / 100; curveOn[node] = true; picked = node; - // 손잡이 자리도 따라 움직여야 계속 끌 수 있다 — 그림은 [확인] 때 서버가 다시 낸다. - const which = dragHandle.end === "start" ? "start" : "end"; - curveInfo[dragHandle.curve] = { - ...curveInfo[dragHandle.curve], - apex: [moved.apex[0], moved.apex[1]], - radius_m: moved.radius, - [which]: toMetric(px, py), - } as (typeof curveInfo)[number]; - plannedLine = []; - nodeInfo = []; + // 손잡이 자리는 다시 셈한 곡선에서 나온다 — 접선 자리가 모자라 R 이 눌리면 손이 + // 끄는 자리보다 덜 따라오고, 그 눌림이 그 자리에서 눈에 보인다. + markEdited(); syncCurveBar(); + status.textContent = `노드 ${planned.length}개 — 곡선을 잡는 중. ${curveHint()}`; draw(); } return; } if (dragNode >= 0) { planned[dragNode] = toMetric(px, py); - markEdited(); // 폴리라인은 [확인] 때 서버가 다시 만든다 — 지금은 직선으로 미리 보인다. + markEdited(); // 곡선을 그 자리에서 다시 그린다 — 나머지 곡선은 그대로 남는다. + // 끄는 동안에도 상태줄이 살아 있어야 한다 — 예전에는 여기서 아무 말이 없어 + // 「곡선이 사라졌다」는 인상만 남았다(2026-09-07 사용자 지적 ②). + status.textContent = `노드 ${planned.length}개 — 옮기는 중. ${curveHint()}`; draw(); return; } @@ -654,7 +658,10 @@ export async function openRouteEditModal( inner_angle_deg: curve ? curve.inner_angle_deg : node.inner_angle_deg, violations: curve ? (curve.violations ?? []) : (node.violations ?? []), }); - curveOn.push(true); + // 서버가 **곡선을 안 둔 자리는 「곡선 없음」으로 연다**(2026-09-07). 전부 켬으로 열면 + // 아무것도 안 만지고 [확인]만 눌러도 그 자리에 곡선이 새로 생겨 노선이 조용히 바뀐다 + // (서버의 편집 갈래는 켜진 자리마다 원호를 끼우기 때문). 내각 179° 이상인 자리가 그렇다. + curveOn.push(curve !== undefined); // 서버가 고른 반지름을 **그대로 들고 간다** — 안 그러면 편집 한 번에 하한으로 눌린다. curveRadius.push(curve ? Math.round(curve.radius_m * 100) / 100 : null); if (curve) curveInfo.push({ ...curve, node_first: seat, node_last: seat }); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Curve.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Curve.ts index 49b97466..4a35d75b 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Curve.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Curve.ts @@ -1,16 +1,57 @@ /* ============================================================================= * B05_Profile_UI_RouteEdit_Curve.ts - * 곡선 손잡이 셈 — 편집 모달(`B05_Profile_UI_RouteEdit.ts`)에서 떼어냄 - * (700줄 제한, 2026-09-07). 화면·DOM 을 안 만지는 순수 기하만 둔다. + * 계획노선 편집의 **기하 셈** — 화면·DOM 을 안 만지는 순수 함수만 둔다. + * (편집 모달 `B05_Profile_UI_RouteEdit.ts` 에서 떼어냄, 700줄 제한 2026-09-07) * - * ⚠ 이 셈은 서버(`common_util/common_util_route_polyline.py`)의 **반대 방향**이다 — - * 서버는 교각점·R 에서 접선점을 내고, 여기서는 끈 접선점에서 교각점·R 을 낸다. - * 두 벌이 아니라 **짝**이며, 왕복이 제자리인지는 - * `tmp/tests/test_route_polyline_handle_drag.py` 가 지킨다. + * 두 가지가 들어 있다. + * + * ① **손잡이 → 교각점·반지름**(`dragHandleTo`) — 서버 셈의 **반대 방향**이다. 서버는 + * 교각점·R 에서 접선점을 내고, 여기서는 끈 접선점에서 교각점·R 을 낸다. + * 왕복이 제자리인지는 `tmp/tests/test_route_polyline_handle_drag.py` 가 지킨다. + * + * ② **노드 → 폴리라인**(`buildEditedPolyline`) — ⚠⚠ **짝**: + * `common_util/common_util_route_polyline.py` 의 `build_planned_polyline` 중 + * **편집 갈래**(`simplify=False` + `curve_flags`/`radii`)와 같은 값을 내야 한다. + * 거울 시험: `tmp/tests/test_route_polyline_browser_mirror.py`. + * + * 왜 브라우저에도 두나(2026-09-07 사용자 지적 ①②) — 곡선을 서버만 그리면 노드를 잡는 + * 순간 그려 둔 선을 통째로 버려야 해서 **곡선이 전부 사라진 것처럼 보인다**. 값은 남아 + * 있는데 화면만 「지워졌다」고 말하니 더 나쁘다. 조작 중에는 브라우저가 같은 규칙으로 + * 즉시 그리고, [확인] 때 서버가 정본으로 다시 낸다(CLAUDE.md 「계산 자리」 ① 짝). + * + * 옮기지 않은 것 — 단순화·IP 추출·반지름 피팅(`_fit_radius_m`)은 **예상노선을 처음 + * 폴리라인으로 바꿀 때만** 쓰는 것이라 편집 갈래에서는 돌지 않는다(`node_indices` 가 None). * ========================================================================== */ export type Vertex = [number, number]; +/** 곡선 성분 하나 — 서버 `RoutePlanCurve` 와 **같은 꼴**이라 그대로 바꿔 쓸 수 있다. + * + * ⚠ 여기서 다시 적는 이유 — 이 파일은 거울 시험이 `tsc` 로 **혼자 컴파일**하므로 바깥 + * 모듈을 들이지 않는다(경로 별칭 `@config/…` 가 딸려 와 컴파일이 깨진다). */ +export interface EditedCurve { + apex: [number, number]; + radius_m: number; + tangent_m: number; + inner_angle_deg: number; + start: [number, number]; + end: [number, number]; + node_first: number; + node_last: number; + violations: string[]; +} + +/** 짝: `DUPLICATE_TOLERANCE_M`. 이보다 가까운 뒤엣점은 같은 자리로 보고 버린다. */ +const DUPLICATE_TOLERANCE_M = 0.5; +/** 짝: `ARC_STEP_DEG`. 원호를 몇 도마다 한 점씩 찍을지. */ +const ARC_STEP_DEG = 5.0; +/** 짝: `math.degrees`/`math.radians` — 파이썬은 **상수 하나를 곱한다**. 곱셈 순서가 다르면 + * 90° 가 89.999…9 로 떨어져 원호 점 수가 하나 어긋난다(2026-09-07 거울 시험에서 실제로 남). */ +const DEG_PER_RAD = 180 / Math.PI; +const RAD_PER_DEG = Math.PI / 180; +/** 짝: `build_planned_polyline` 의 `hairpin_min_radius_m` 기본값(배향곡선 하한). */ +export const HAIRPIN_MIN_RADIUS_M = 10.0; + /** 두 **직선**(선분 아님)이 만나는 자리. 나란하면 null. */ export function intersect(a1: Vertex, a2: Vertex, b1: Vertex, b2: Vertex): Vertex | null { const dx1 = a2[0] - a1[0]; @@ -33,7 +74,7 @@ export function innerAngleDeg(before: Vertex, at: Vertex, after: Vertex): number const lb = Math.hypot(bx, by); if (la <= 0 || lb <= 0) return 180; const cosine = Math.max(-1, Math.min(1, (ax * bx + ay * by) / (la * lb))); - return (Math.acos(cosine) * 180) / Math.PI; + return Math.acos(cosine) * DEG_PER_RAD; } /** 끈 접선점으로 **새 교각점과 새 반지름**을 구한다 — 「직선의 각도와 반지름 값 변경」. @@ -59,10 +100,204 @@ export function dragHandleTo( : intersect(before, oldApex, to, after); // 나가는 직선을 돌린다 if (!apex) return null; const inner = innerAngleDeg(before, apex, after); - const halfTan = Math.tan(((180 - inner) * Math.PI) / 360); + const halfTan = Math.tan(((180 - inner) * RAD_PER_DEG) / 2); if (!(halfTan > 1e-9)) return null; const tangent = Math.hypot(apex[0] - to[0], apex[1] - to[1]); const radius = tangent / halfTan; if (!(radius > 0) || !Number.isFinite(radius)) return null; return { apex, radius }; } + +const distance = (a: Vertex, b: Vertex): number => Math.hypot(a[0] - b[0], a[1] - b[1]); + +/** 짝: `_unit`. from → to 방향의 단위벡터. 같은 자리면 (0,0). */ +function unit(from: Vertex, to: Vertex): Vertex { + const dx = to[0] - from[0]; + const dy = to[1] - from[1]; + const length = Math.hypot(dx, dy); + if (length <= 0) return [0, 0]; + return [dx / length, dy / length]; +} + +/** 짝: `_turn_sign`. 도는 방향 — 왼쪽 +1, 오른쪽 −1, 곧게 0. */ +function turnSign(before: Vertex, at: Vertex, after: Vertex): number { + const cross = (at[0] - before[0]) * (after[1] - at[1]) - (at[1] - before[1]) * (after[0] - at[0]); + if (Math.abs(cross) <= 1e-9) return 0; + return cross > 0 ? 1 : -1; +} + +/** 짝: `_arc_geometry`. 반지름 하나에 대한 (접선시작, 접선끝, 중심). 못 끼우면 null. */ +function arcGeometry( + before: Vertex, + at: Vertex, + after: Vertex, + innerDeg: number, + radius: number, + halfTan: number, +): { start: Vertex; end: Vertex; center: Vertex } | null { + const tangent = radius * halfTan; + const toBefore = unit(at, before); + const toAfter = unit(at, after); + const start: Vertex = [at[0] + toBefore[0] * tangent, at[1] + toBefore[1] * tangent]; + const end: Vertex = [at[0] + toAfter[0] * tangent, at[1] + toAfter[1] * tangent]; + const bisector: Vertex = [toBefore[0] + toAfter[0], toBefore[1] + toAfter[1]]; + const bisectorLength = Math.hypot(bisector[0], bisector[1]); + if (bisectorLength <= 1e-9) return null; + const centerDistance = radius / Math.sin((innerDeg * RAD_PER_DEG) / 2); + const center: Vertex = [ + at[0] + (bisector[0] / bisectorLength) * centerDistance, + at[1] + (bisector[1] / bisectorLength) * centerDistance, + ]; + return { start, end, center }; +} + +/** 짝: `_arc_points`. 원호 위 점(양 끝은 빼고 — 부르는 쪽이 붙인다). */ +function arcPoints(center: Vertex, start: Vertex, end: Vertex, clockwise: boolean): Vertex[] { + const radius = distance(center, start); + if (radius <= 0) return []; + const startAngle = Math.atan2(start[1] - center[1], start[0] - center[0]); + const endAngle = Math.atan2(end[1] - center[1], end[0] - center[0]); + let sweep = endAngle - startAngle; + if (clockwise) { + while (sweep > 0) sweep -= 2 * Math.PI; + } else { + while (sweep < 0) sweep += 2 * Math.PI; + } + const steps = Math.max(1, Math.trunc(Math.abs(sweep * DEG_PER_RAD) / ARC_STEP_DEG)); + const points: Vertex[] = []; + for (let step = 1; step < steps; step += 1) { + const angle = startAngle + (sweep * step) / steps; + points.push([center[0] + radius * Math.cos(angle), center[1] + radius * Math.sin(angle)]); + } + return points; +} + +/** 노드 하나의 요약 — 화면이 붉은 점·내각·R 을 그리는 재료(서버 `RouteNode` 와 같은 꼴). */ +export interface EditedNode { + inner_angle_deg: number | null; + radius_m: number | null; + tangent_m: number | null; + violations: string[]; +} + +export interface EditedPolyline { + /** 그려 보이는 선(원호 포함). */ + vertices: Vertex[]; + /** 곡선 성분 — 손잡이·R 칸의 재료. `node_first`/`node_last` 는 **넘긴 목록의 자리**다. */ + curves: EditedCurve[]; + /** 넘긴 목록과 **자리가 같은** 노드 요약. */ + nodes: EditedNode[]; +} + +/** + * 짝: `build_planned_polyline` 의 편집 갈래. 노드·곡선 켬끔·반지름으로 폴리라인을 만든다. + * + * `curveOn[i]` 가 거짓이면 그 자리에 곡선을 두지 않는다(직선이 그대로 꺾인다). + * `curveRadius[i]` 가 있으면 그 반지름으로 못박고, 없으면 법정 하한을 쓴다. + * 접선 자리가 모자라면 **줄이되 막지 않고** 위반으로 표시한다(서버와 같은 규칙). + * + * ⚠ 서버는 0.5m 안에 겹친 점을 버린다. 여기서도 같이 버리되, 돌려주는 자리 번호는 + * **넘긴 목록 기준**으로 되돌려 놓는다 — 화면이 잡고 있는 배열이 그것이기 때문이다. + */ +export function buildEditedPolyline( + points: Vertex[], + curveOn: boolean[], + curveRadius: Array, + minRadiusM: number, + hairpinMinRadiusM: number = HAIRPIN_MIN_RADIUS_M, +): EditedPolyline { + const nodes: EditedNode[] = points.map(() => ({ + inner_angle_deg: null, + radius_m: null, + tangent_m: null, + violations: [], + })); + + // 겹친 점 버리기 — 편집값과 **함께** 걸러야 자리가 어긋나지 않는다. + const cleaned: Vertex[] = []; + const flags: boolean[] = []; + const forcedRadii: Array = []; + const origin: number[] = []; // cleaned 자리 → 넘긴 목록 자리 + points.forEach((point, index) => { + if (cleaned.length && distance(cleaned[cleaned.length - 1], point) <= DUPLICATE_TOLERANCE_M) { + return; + } + cleaned.push(point); + flags.push(curveOn[index] !== false); + forcedRadii.push(curveRadius[index] ?? null); + origin.push(index); + }); + + if (cleaned.length < 3) return { vertices: [...cleaned], curves: [], nodes }; + + for (let index = 1; index < cleaned.length - 1; index += 1) { + nodes[origin[index]].inner_angle_deg = innerAngleDeg( + cleaned[index - 1], + cleaned[index], + cleaned[index + 1], + ); + } + + const vertices: Vertex[] = [cleaned[0]]; + const curves: EditedCurve[] = []; + let cursor = 0; // 아직 선에 안 실은 첫 꺾임점 + + for (let at = 1; at < cleaned.length - 1; at += 1) { + if (!flags[at]) continue; // 곡선을 지운 자리 — 직선이 그대로 꺾인다. + const entryFrom = cleaned[at - 1]; + const apex = cleaned[at]; + const exitTo = cleaned[at + 1]; + const node = nodes[origin[at]]; + + const inner = innerAngleDeg(entryFrom, apex, exitTo); + const halfTan = Math.tan(((180 - inner) * RAD_PER_DEG) / 2); + // 접선이 들어갈 자리 — 앞뒤 직선을 이웃 곡선과 나눠 쓰므로 절반까지만. + const available = Math.min(distance(entryFrom, apex), distance(apex, exitTo)) / 2; + if (!(halfTan > 1e-9) || available <= 0) continue; + + const forced = forcedRadii[at]; + let radius = forced !== null && forced > 0 ? forced : minRadiusM; + let tangent = radius * halfTan; + if (tangent > available) { + radius = available / halfTan; + tangent = available; + } + + const geometry = arcGeometry(entryFrom, apex, exitTo, inner, radius, halfTan); + if (radius <= 0 || geometry === null) continue; + + if (radius < minRadiusM) { + node.violations.push(`최소곡선반지름 미달(${radius.toFixed(1)} < ${minRadiusM.toFixed(1)}m)`); + } + if (radius < hairpinMinRadiusM) { + node.violations.push( + `배향곡선 하한 미달(${radius.toFixed(1)} < ${hairpinMinRadiusM.toFixed(1)}m)`, + ); + } + node.radius_m = radius; + node.tangent_m = tangent; + curves.push({ + apex: [apex[0], apex[1]], + radius_m: radius, + tangent_m: tangent, + inner_angle_deg: inner, + start: [geometry.start[0], geometry.start[1]], + end: [geometry.end[0], geometry.end[1]], + node_first: origin[at], + node_last: origin[at], + violations: [...node.violations], + }); + + // 곡선 앞의 직선 위 꺾임점들은 그대로 잇는다. + for (let index = cursor + 1; index < at; index += 1) vertices.push(cleaned[index]); + cursor = at; + + const clockwise = turnSign(entryFrom, apex, exitTo) < 0; + vertices.push(geometry.start); + vertices.push(...arcPoints(geometry.center, geometry.start, geometry.end, clockwise)); + vertices.push(geometry.end); + } + + for (let index = cursor + 1; index < cleaned.length; index += 1) vertices.push(cleaned[index]); + return { vertices, curves, nodes }; +}