Files
Aislo/B05_Profile/B05_Profile_UI_RouteEdit_Curve.ts
eomsangdonandClaude Opus 5 10b4b32a85 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>
2026-09-07 18:50:42 +09:00

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 };
}