사용자 지적 ①② 의 뿌리 하나를 고침. 곡선을 서버만 그려서, 노드를 끌거나 손잡이를 살짝 건드리기만 해도 그려 둔 선·손잡이·노드 요약을 통째로 비웠음. 그 결과 화면에서 곡선이 전부 사라져 「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>
304 lines
13 KiB
TypeScript
304 lines
13 KiB
TypeScript
/* =============================================================================
|
|
* 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<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 };
|
|
}
|