/* ============================================================================= * B05_Profile_UI_RouteEdit_Curve.ts * 계획노선 편집의 **기하 셈** — 화면·DOM 을 안 만지는 순수 함수만 둔다. * (편집 모달 `B05_Profile_UI_RouteEdit.ts` 에서 떼어냄, 700줄 제한 2026-09-07) * * 두 가지가 들어 있다. * * ① **손잡이 → 교각점·반지름**(`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]; 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) * DEG_PER_RAD; } /** 끈 접선점으로 **새 교각점과 새 반지름**을 구한다 — 「직선의 각도와 반지름 값 변경」. * * 사용자 확정(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) * 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 }; }