diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 47599d47..fdf9159f 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -29,6 +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 "./B05_Profile_UI_Style_RouteEdit.css"; /** 노드를 잡았다고 볼 거리(px). 손가락·마우스 모두 무리 없는 크기. */ @@ -276,18 +277,7 @@ export async function openRouteEditModal( return best; } - /** 끈 접선점으로 **새 교각점과 새 반지름**을 구한다 — 「직선의 각도와 반지름 값 변경」. - * - * 사용자 확정(2026-09-07) — 곡선 시작·끝점을 옮기면 그쪽 **직선 각도**와 **반지름**이 함께 - * 바뀐다(반대로 반지름만 바꿀 때는 직선이 고정이다). - * - * 셈은 이렇다. 끈 것이 시작점이면 들어오는 직선은 **앞 노드 → 끈 자리**로 돌아간다. - * 나가는 직선은 그대로이므로 **두 직선이 만나는 자리**가 새 교각점이고, 접선 길이 - * T = |새 교각점 − 끈 자리| 에서 R = T / tan(교각/2) 가 나온다. - * - * ⚠ 이 셈은 **서버의 반대 방향**이다(서버는 교각점·R 에서 접선점을 낸다). 두 벌이 아니라 - * 짝이며, 왕복이 제자리인지는 `tmp/tests/test_route_polyline_handle_drag.py` 가 지킨다. - */ + /** 끈 접선점으로 새 교각점·새 반지름을 구한다 — 셈은 `_RouteEdit_Curve` 몫. */ function dragHandleTo( curveIndex: number, which: "start" | "end", @@ -299,45 +289,7 @@ export async function openRouteEditModal( const before = planned[node - 1]; const after = planned[node + 1]; if (!before || !after) return null; - const oldApex: Vertex = [curve.apex[0], curve.apex[1]]; - // 끈 쪽 직선만 돌아간다 — 반대쪽 직선은 옛 교각점을 지나는 그대로다. - const apex = - which === "start" - ? intersect(before, to, oldApex, after) // 들어오는 직선이 끈 자리를 지나게 돌린다 - : intersect(before, oldApex, to, after); // 나가는 직선을 돌린다 - if (!apex) return null; - const inner = innerAngleDeg(before, apex, after); - const halfTan = Math.tan(((180 - inner) * Math.PI) / 360); - 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 }; - } - - /** 두 **직선**(선분 아님)이 만나는 자리. 나란하면 null. */ - function intersect(a1: Vertex, a2: Vertex, b1: Vertex, b2: Vertex): Vertex | null { - const dx1 = a2[0] - a1[0]; - const dy1 = a2[1] - a1[1]; - const dx2 = b2[0] - b1[0]; - const dy2 = b2[1] - b1[1]; - const denominator = dx1 * dy2 - dy1 * dx2; - if (Math.abs(denominator) <= 1e-12) return null; - const t = ((b1[0] - a1[0]) * dy2 - (b1[1] - a1[1]) * dx2) / denominator; - return [a1[0] + dx1 * t, a1[1] + dy1 * t]; - } - - /** 세 점이 이루는 내각(도). 일직선이면 180. */ - function innerAngleDeg(before: Vertex, at: Vertex, after: Vertex): number { - const ax = before[0] - at[0]; - const ay = before[1] - at[1]; - const bx = after[0] - at[0]; - const by = after[1] - at[1]; - const la = Math.hypot(ax, ay); - 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 curveDragTo(before, [curve.apex[0], curve.apex[1]], after, which, to); } /** 화면 좌표에 가장 가까운 노드. 문턱 밖이면 -1. */ diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Curve.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Curve.ts new file mode 100644 index 00000000..49b97466 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Curve.ts @@ -0,0 +1,68 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_Curve.ts + * 곡선 손잡이 셈 — 편집 모달(`B05_Profile_UI_RouteEdit.ts`)에서 떼어냄 + * (700줄 제한, 2026-09-07). 화면·DOM 을 안 만지는 순수 기하만 둔다. + * + * ⚠ 이 셈은 서버(`common_util/common_util_route_polyline.py`)의 **반대 방향**이다 — + * 서버는 교각점·R 에서 접선점을 내고, 여기서는 끈 접선점에서 교각점·R 을 낸다. + * 두 벌이 아니라 **짝**이며, 왕복이 제자리인지는 + * `tmp/tests/test_route_polyline_handle_drag.py` 가 지킨다. + * ========================================================================== */ + +export type Vertex = [number, number]; + +/** 두 **직선**(선분 아님)이 만나는 자리. 나란하면 null. */ +export function intersect(a1: Vertex, a2: Vertex, b1: Vertex, b2: Vertex): Vertex | null { + const dx1 = a2[0] - a1[0]; + const dy1 = a2[1] - a1[1]; + const dx2 = b2[0] - b1[0]; + const dy2 = b2[1] - b1[1]; + const denominator = dx1 * dy2 - dy1 * dx2; + if (Math.abs(denominator) <= 1e-12) return null; + const t = ((b1[0] - a1[0]) * dy2 - (b1[1] - a1[1]) * dx2) / denominator; + return [a1[0] + dx1 * t, a1[1] + dy1 * t]; +} + +/** 세 점이 이루는 내각(도). 일직선이면 180. */ +export function innerAngleDeg(before: Vertex, at: Vertex, after: Vertex): number { + const ax = before[0] - at[0]; + const ay = before[1] - at[1]; + const bx = after[0] - at[0]; + const by = after[1] - at[1]; + const la = Math.hypot(ax, ay); + 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; +} + +/** 끈 접선점으로 **새 교각점과 새 반지름**을 구한다 — 「직선의 각도와 반지름 값 변경」. + * + * 사용자 확정(2026-09-07) — 곡선 시작·끝점을 옮기면 그쪽 **직선 각도**와 **반지름**이 함께 + * 바뀐다(반대로 반지름만 바꿀 때는 직선이 고정이다). + * + * 셈은 이렇다. 끈 것이 시작점이면 들어오는 직선은 **앞 노드 → 끈 자리**로 돌아간다. + * 나가는 직선은 그대로이므로 **두 직선이 만나는 자리**가 새 교각점이고, 접선 길이 + * T = |새 교각점 − 끈 자리| 에서 R = T / tan(교각/2) 가 나온다. + */ +export function dragHandleTo( + before: Vertex, + oldApex: Vertex, + after: Vertex, + which: "start" | "end", + to: Vertex, +): { apex: Vertex; radius: number } | null { + // 끈 쪽 직선만 돌아간다 — 반대쪽 직선은 옛 교각점을 지나는 그대로다. + const apex = + which === "start" + ? intersect(before, to, oldApex, after) // 들어오는 직선이 끈 자리를 지나게 돌린다 + : intersect(before, oldApex, to, after); // 나가는 직선을 돌린다 + if (!apex) return null; + const inner = innerAngleDeg(before, apex, after); + const halfTan = Math.tan(((180 - inner) * Math.PI) / 360); + 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 }; +}