feat(B05): 노선 편집 곡선을 브라우저가 즉시 그리게 — 손대면 곡선이 사라지던 것

사용자 지적 ①② 의 뿌리 하나를 고침. 곡선을 서버만 그려서, 노드를 끌거나 손잡이를
살짝 건드리기만 해도 그려 둔 선·손잡이·노드 요약을 통째로 비웠음. 그 결과 화면에서
곡선이 전부 사라져 「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) <noreply@anthropic.com>
This commit is contained in:
2026-09-07 18:50:42 +09:00
co-authored by Claude Opus 5
parent 4774eb3efb
commit 10b4b32a85
2 changed files with 280 additions and 38 deletions
+37 -30
View File
@@ -29,7 +29,7 @@ import { showToast } from "@ui/ui_template_elements";
import { fetchDrainageLayers } from "./B05_Profile_UI_Drainage_Parts"; import { fetchDrainageLayers } from "./B05_Profile_UI_Drainage_Parts";
import { fetchRoutePlan, replanRoute, resetRoutePlan } from "./B05_Profile_Api_Replan"; import { fetchRoutePlan, replanRoute, resetRoutePlan } from "./B05_Profile_Api_Replan";
import type { RoutePlanCurve } 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"; import "./B05_Profile_UI_Style_RouteEdit.css";
/** 노드를 잡았다고 볼 거리(px). 손가락·마우스 모두 무리 없는 크기. */ /** 노드를 잡았다고 볼 거리(px). 손가락·마우스 모두 무리 없는 크기. */
@@ -259,18 +259,21 @@ export async function openRouteEditModal(
context.restore(); context.restore();
} }
/** 화면 좌표에 가장 가까운 **곡선 손잡이**(시작·끝점). 없으면 null. */ /** 화면 좌표에 가장 가까운 **곡선 손잡이**(시작·끝점). 없으면 null.
function handleAt(px: number, py: number): { curve: number; end: "start" | "end" } | null { *
let best: { curve: number; end: "start" | "end" } | 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; let bestDistance = NODE_HIT_PX + 2;
curveInfo.forEach((curve, index) => { curveInfo.forEach((curve) => {
(["start", "end"] as const).forEach((which) => { (["start", "end"] as const).forEach((which) => {
const point = which === "start" ? curve.start : curve.end; const point = which === "start" ? curve.start : curve.end;
const [x, y] = toScreen([point[0], point[1]]); const [x, y] = toScreen([point[0], point[1]]);
const distance = Math.hypot(x - px, y - py); const distance = Math.hypot(x - px, y - py);
if (distance <= bestDistance) { if (distance <= bestDistance) {
bestDistance = distance; bestDistance = distance;
best = { curve: index, end: which }; best = { node: curve.node_first, end: which };
} }
}); });
}); });
@@ -279,13 +282,12 @@ export async function openRouteEditModal(
/** 끈 접선점으로 새 교각점·새 반지름을 구한다 — 셈은 `_RouteEdit_Curve` 몫. */ /** 끈 접선점으로 새 교각점·새 반지름을 구한다 — 셈은 `_RouteEdit_Curve` 몫. */
function dragHandleTo( function dragHandleTo(
curveIndex: number, node: number,
which: "start" | "end", which: "start" | "end",
to: Vertex, to: Vertex,
): { apex: Vertex; radius: number } | null { ): { apex: Vertex; radius: number } | null {
const curve = curveInfo[curveIndex]; const curve = curveInfo.find((entry) => entry.node_first === node);
if (!curve) return null; if (!curve) return null;
const node = curve.node_first;
const before = planned[node - 1]; const before = planned[node - 1];
const after = planned[node + 1]; const after = planned[node + 1];
if (!before || !after) return null; if (!before || !after) return null;
@@ -327,12 +329,17 @@ export async function openRouteEditModal(
return best; return best;
} }
/** 노드를 고쳤다 — 서버가 만든 폴리라인은 낡았으므로 지우고 직선으로 미리 보인다. /** 노드를 고쳤다 — **곡선을 그 자리에서 다시 그린다**(2026-09-07 사용자 지적 ①②).
* 곡선은 [확인] 때 서버가 같은 R 규칙으로 다시 끼운다(계산을 두 벌로 짜지 않는다). */ *
* 예전에는 그려 둔 선과 손잡이를 통째로 비웠다. 그러면 노드 하나만 건드려도 **곡선이 전부
* 사라진 것처럼** 보였다 — 값은 남아 있는데 화면만 「지워졌다」고 말하니 되돌릴 길을 찾게 됐다.
* 지금은 서버와 **같은 규칙**(`buildEditedPolyline` 짝)으로 즉시 다시 만든다. [확인] 때
* 서버가 정본으로 다시 내는 것은 그대로다. */
function markEdited(): void { function markEdited(): void {
plannedLine = []; const built = buildEditedPolyline(planned, curveOn, curveRadius, minRadiusM);
nodeInfo = []; plannedLine = built.vertices;
curveInfo = []; // 손잡이 자리도 낡았다 — [확인] 때 서버가 다시 낸다. curveInfo = built.curves;
nodeInfo = built.nodes;
} }
/** 상태줄 꼬리 — 곡선 기준과 위반 수를 알린다. */ /** 상태줄 꼬리 — 곡선 기준과 위반 수를 알린다. */
@@ -429,7 +436,7 @@ export async function openRouteEditModal(
// ── 조작 — 노드 끌기 / 배경 끌기(팬) / 휠 확대 / 두 번 클릭 삽입 / 오른쪽 클릭 삭제 ── // ── 조작 — 노드 끌기 / 배경 끌기(팬) / 휠 확대 / 두 번 클릭 삽입 / 오른쪽 클릭 삭제 ──
let dragNode = -1; 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; let panFrom: { x: number; y: number; offsetX: number; offsetY: number } | null = null;
canvas.addEventListener("pointerdown", (event) => { canvas.addEventListener("pointerdown", (event) => {
@@ -441,7 +448,7 @@ export async function openRouteEditModal(
dragHandle = handleAt(px, py); dragHandle = handleAt(px, py);
dragNode = dragHandle ? -1 : nodeAt(px, py); dragNode = dragHandle ? -1 : nodeAt(px, py);
if (dragHandle) { if (dragHandle) {
picked = curveInfo[dragHandle.curve]?.node_first ?? -1; picked = dragHandle.node;
syncCurveBar(); syncCurveBar();
draw(); draw();
} else if (dragNode >= 0) { } else if (dragNode >= 0) {
@@ -460,31 +467,28 @@ export async function openRouteEditModal(
const py = event.clientY - rect.top; const py = event.clientY - rect.top;
if (dragHandle) { if (dragHandle) {
// 곡선 시작·끝점을 끈다 — 그쪽 직선 각도와 반지름이 함께 바뀐다(2026-09-07 사용자 확정). // 곡선 시작·끝점을 끈다 — 그쪽 직선 각도와 반지름이 함께 바뀐다(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) { if (moved) {
const node = curveInfo[dragHandle.curve].node_first;
planned[node] = moved.apex; planned[node] = moved.apex;
curveRadius[node] = Math.round(moved.radius * 100) / 100; curveRadius[node] = Math.round(moved.radius * 100) / 100;
curveOn[node] = true; curveOn[node] = true;
picked = node; picked = node;
// 손잡이 자리도 따라 움직여야 계속 끌 수 있다 — 그림은 [확인] 때 서버가 다시 낸다. // 손잡이 자리는 다시 셈한 곡선에서 나온다 — 접선 자리가 모자라 R 이 눌리면 손이
const which = dragHandle.end === "start" ? "start" : "end"; // 끄는 자리보다 덜 따라오고, 그 눌림이 그 자리에서 눈에 보인다.
curveInfo[dragHandle.curve] = { markEdited();
...curveInfo[dragHandle.curve],
apex: [moved.apex[0], moved.apex[1]],
radius_m: moved.radius,
[which]: toMetric(px, py),
} as (typeof curveInfo)[number];
plannedLine = [];
nodeInfo = [];
syncCurveBar(); syncCurveBar();
status.textContent = `노드 ${planned.length}개 — 곡선을 잡는 중. ${curveHint()}`;
draw(); draw();
} }
return; return;
} }
if (dragNode >= 0) { if (dragNode >= 0) {
planned[dragNode] = toMetric(px, py); planned[dragNode] = toMetric(px, py);
markEdited(); // 폴리라인은 [확인] 때 서버가 다시 만든다 — 지금은 직선으로 미리 보인다. markEdited(); // 곡선을 그 자리에서 다시 그린다 — 나머지 곡선은 그대로 남는다.
// 끄는 동안에도 상태줄이 살아 있어야 한다 — 예전에는 여기서 아무 말이 없어
// 「곡선이 사라졌다」는 인상만 남았다(2026-09-07 사용자 지적 ②).
status.textContent = `노드 ${planned.length}개 — 옮기는 중. ${curveHint()}`;
draw(); draw();
return; return;
} }
@@ -654,7 +658,10 @@ export async function openRouteEditModal(
inner_angle_deg: curve ? curve.inner_angle_deg : node.inner_angle_deg, inner_angle_deg: curve ? curve.inner_angle_deg : node.inner_angle_deg,
violations: curve ? (curve.violations ?? []) : (node.violations ?? []), 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); curveRadius.push(curve ? Math.round(curve.radius_m * 100) / 100 : null);
if (curve) curveInfo.push({ ...curve, node_first: seat, node_last: seat }); if (curve) curveInfo.push({ ...curve, node_first: seat, node_last: seat });
+243 -8
View File
@@ -1,16 +1,57 @@
/* ============================================================================= /* =============================================================================
* B05_Profile_UI_RouteEdit_Curve.ts * B05_Profile_UI_RouteEdit_Curve.ts
* 곡선 손잡이 셈 — 편집 모달(`B05_Profile_UI_RouteEdit.ts`)에서 떼어냄 * 계획노선 편집의 **기하 셈** — 화면·DOM 을 안 만지는 순수 함수만 둔다.
* (700줄 제한, 2026-09-07). 화면·DOM 을 안 만지는 순수 기하만 둔다. * (편집 모달 `B05_Profile_UI_RouteEdit.ts` 에서 떼어냄, 700줄 제한 2026-09-07)
* *
* ⚠ 이 셈은 서버(`common_util/common_util_route_polyline.py`)의 **반대 방향**이다 — * 두 가지가 들어 있다.
* 서버는 교각점·R 에서 접선점을 내고, 여기서는 끈 접선점에서 교각점·R 을 낸다. *
* 두 벌이 아니라 **짝**이며, 왕복이 제자리인지 * ① **손잡이 → 교각점·반지름**(`dragHandleTo`) — 서버 셈의 **반대 방향**이다. 서버
* `tmp/tests/test_route_polyline_handle_drag.py` 가 지킨다. * 교각점·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]; 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. */ /** 두 **직선**(선분 아님)이 만나는 자리. 나란하면 null. */
export function intersect(a1: Vertex, a2: Vertex, b1: Vertex, b2: Vertex): Vertex | null { export function intersect(a1: Vertex, a2: Vertex, b1: Vertex, b2: Vertex): Vertex | null {
const dx1 = a2[0] - a1[0]; 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); const lb = Math.hypot(bx, by);
if (la <= 0 || lb <= 0) return 180; if (la <= 0 || lb <= 0) return 180;
const cosine = Math.max(-1, Math.min(1, (ax * bx + ay * by) / (la * lb))); 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); // 나가는 직선을 돌린다 : intersect(before, oldApex, to, after); // 나가는 직선을 돌린다
if (!apex) return null; if (!apex) return null;
const inner = innerAngleDeg(before, apex, after); 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; if (!(halfTan > 1e-9)) return null;
const tangent = Math.hypot(apex[0] - to[0], apex[1] - to[1]); const tangent = Math.hypot(apex[0] - to[0], apex[1] - to[1]);
const radius = tangent / halfTan; const radius = tangent / halfTan;
if (!(radius > 0) || !Number.isFinite(radius)) return null; if (!(radius > 0) || !Number.isFinite(radius)) return null;
return { apex, radius }; 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<number | null>,
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<number | null> = [];
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 };
}