/* ============================================================================= * B05_Profile_UI_RouteEdit_Edits.ts * 꺾임점마다의 **편집값**을 다루는 순수 함수 — 잠금과 상태줄 요약. * * **잠금이 왜 필요한가**(2026-09-07 사용자 지시) — 노드를 옮기면 앞뒤 직선의 교각(Δ)이 * 바뀐다. 반지름 R 과 곡선 길이 L 은 **L = R·Δ** 로 묶여 있으므로, 한쪽을 붙들면 다른 쪽은 * 반드시 따라 움직인다. 둘 다 붙들 수는 없다(그러면 Δ 를 못 바꾼다). 그래서 잠금은 셋 중 하나다. * * · `null` — 자동. R 은 법정 하한을 쓰고 L 은 따라온다. * · `"radius"` — **R 고정**. 노드를 옮겨도 R 이 그대로고 L 이 바뀐다. * · `"arc"` — **곡선 길이 고정**. 노드를 옮기면 R 을 L/Δ 로 다시 잡는다. * * ⚠ 접선 자리가 모자라면 그리기 단계에서 R 을 줄이는 것은 그대로다(`buildEditedPolyline`). * 그것은 **그려지는 값**만 줄이고 잠가 둔 값은 안 건드린다 — 자리를 넓히면 되돌아온다. * * **하한은 R 과 L 둘 다 본다**(2026-09-12 사용자 확정 「둘 다 지켜야 함」). L = R·Δ 라 * 한 자리의 하한은 결국 **반지름 하나**로 모인다 — `radiusFloorM` 이 그 값을 낸다. * 다만 **내각 155° 이상은 뺀다**(별표2 Ⅰ.2.다.(1) 「내각 155도 이상 되는 장소는 곡선을 * 설치하지 아니할 수 있다」). 거기서 L 을 채우라고 하면 교각이 0 에 가까워 R 이 수십 배로 * 부풀고, **아무것도 안 고쳤는데 노선이 휜다**(실측: 내각 174.8° 자리가 R 12m → 55m). * ========================================================================== */ import { innerAngleDeg, type EditedCurve, type EditedNode, type Vertex, } from "./B05_Profile_UI_RouteEdit_Curve"; /** 무엇을 붙들고 있나. */ export type CurveLock = "radius" | "arc" | null; /** 그 꺾임점의 **교각 Δ**(라디안) — 내각의 나머지. 곡선 길이 L = R·Δ 에 쓴다. */ export function deflectionRad(innerAngleDeg: number | null | undefined): number { if (innerAngleDeg === null || innerAngleDeg === undefined) return 0; return ((180 - innerAngleDeg) * Math.PI) / 180; } /** 곡선을 **안 둘 수 있는** 내각(도) — 별표2 Ⅰ.2.다.(1) · Ⅰ.3.라.(1). */ export const CURVE_OPTIONAL_INNER_ANGLE_DEG = 155; /** 그 자리에서 곡선을 생략해도 되나 — 내각이 155° 이상이면 그렇다. */ export function curveOptionalAt(innerAngleDeg: number | null | undefined): boolean { return innerAngleDeg !== null && innerAngleDeg !== undefined ? innerAngleDeg >= CURVE_OPTIONAL_INNER_ANGLE_DEG : false; } /** * 그 꺾임점의 **못 넘는 반지름 하한**(m) — R 하한과 L 하한을 함께 본 값. * * L = R·Δ 이므로 「L ≥ 하한」은 「R ≥ 하한/Δ」와 같다. 둘 중 큰 쪽이 그 자리의 하한이다. * 내각 155° 이상(곡선 생략 가능)이거나 교각을 모르면 **R 하한만** 본다. */ export function radiusFloorM( innerAngleDeg: number | null | undefined, limitRadiusM: number, limitArcM: number, ): number { if (!(limitArcM > 0) || curveOptionalAt(innerAngleDeg)) return limitRadiusM; const deflection = deflectionRad(innerAngleDeg); if (!(deflection > 1e-9)) return limitRadiusM; return Math.max(limitRadiusM, limitArcM / deflection); } /** * **곡선 길이를 잠근 자리**의 반지름을 지금 교각에 맞춰 다시 잡는다(`curveRadius` 를 고침). * * 노드를 옮길 때마다 부른다. 잠그지 않았거나 R 을 잠근 자리는 손대지 않는다. * 교각이 0 에 가까우면(거의 직선) 길이를 지킬 방법이 없으므로 그대로 둔다. */ export function applyArcLocks( planned: Vertex[], curveLock: CurveLock[], curveArc: Array, curveRadius: Array, ): void { for (let seat = 1; seat < planned.length - 1; seat += 1) { if (curveLock[seat] !== "arc") continue; const length = curveArc[seat]; if (length === null || length === undefined) continue; const inner = innerAngleDeg(planned[seat - 1], planned[seat], planned[seat + 1]); const deflection = deflectionRad(inner); if (deflection <= 1e-9) continue; curveRadius[seat] = length / deflection; } } /** * **하한(R·L)을 지키도록 지정값을 끌어올린다**(계획서 0-9 ④, 2026-09-12 사용자 확정). * * **비워 둔(자동) 자리는 건드리지 않는다** — 자동은 이미 기본 반지름을 쓰고 있고, 여기서 * 값을 적어 넣으면 아무것도 안 고쳤는데 「R 지정」이 늘어난다. * * 하한은 `radiusFloorM` 이 R·L 을 함께 본 값이다 — 내각 155° 이상은 R 하한만 본다. */ export function applyCurveLimits( planned: Vertex[], curveOn: ReadonlyArray, curveRadius: Array, limitRadiusM: number, limitArcM = 0, ): void { if (limitRadiusM <= 0 && limitArcM <= 0) return; for (let seat = 1; seat < planned.length - 1; seat += 1) { if (curveOn[seat] === false) continue; const current = curveRadius[seat]; if (current === null || current === undefined) continue; const inner = innerAngleDeg(planned[seat - 1], planned[seat], planned[seat + 1]); const floor = radiusFloorM(inner, limitRadiusM, limitArcM); if (current < floor) curveRadius[seat] = floor; } } /** * 노드마다 **하한(R·L)** 을 얼마나 밑돌고 있나(m). 다 지키고 있으면 0. * * 재는 자는 `radiusFloorM` 이다 — L 하한도 반지름으로 환산해 함께 본다. 거의 곧은 자리 * (내각 155° 이상)는 곡선을 안 둬도 되는 자리라 R 하한만 본다. 그 예외가 없으면 그런 * 자리에서 **노드를 한 뼘도 못 옮긴다**. * * 노드를 옮기면 접선 자리가 모자라 그리기 단계에서 R 이 눌릴 수 있다. 그 눌림까지 막으려면 * 옮기기 자체를 되돌려야 하므로, **옮기기 전보다 나빠진 자리가 있는지**만 견준다 — 이미 * 하한을 밑돌던 옛 노선도 그대로 고칠 수 있어야 하기 때문이다(2026-09-12). * * ⚠ 가장 큰 값 하나로 견주면 안 된다. 크게 밑도는 자리가 이미 있으면 **다른 자리가 새로 * 무너져도 최댓값이 안 움직여** 그냥 통과한다(실화면에서 「기준 미달 1곳 → 2곳」이 그대로 * 지나갔다). 자리마다 따로 견준다. */ export function curveShortfalls( nodes: ReadonlyArray, limitRadiusM: number, limitArcM = 0, ): number[] { return nodes.map((node) => { if (node.radius_m === null) return 0; const floor = radiusFloorM(node.inner_angle_deg, limitRadiusM, limitArcM); if (floor <= 0) return 0; return Math.max(0, floor - node.radius_m); }); } /** * 하한을 지키던 자리가 **이번 걸음에 처음으로 무너졌나**. 자리 수가 달라지면(넣기·지우기) * 안 따진다. * * ⚠ 「조금이라도 나빠졌으면 막기」로 두면 **이미 하한을 밑돌던 옛 노선을 아예 못 고친다** — * 그 옆 노드를 1px 만 건드려도 밑돌던 값이 미세하게 더 내려가 첫 걸음부터 막혔다(2026-09-12 * 실화면). 이미 무너진 자리는 그대로 두고(붉은 표시는 남는다), **지키고 있던 자리가 넘어가는 * 것만** 막는다. */ export function shortfallCrossed(before: readonly number[], after: readonly number[]): boolean { if (before.length !== after.length) return false; return after.some((value, index) => value > 1e-6 && before[index] <= 1e-6); } export interface CurveSummaryInput { nodeCount: number; curveOn: boolean[]; curveRadius: Array; curveLock: CurveLock[]; /** 교각점을 못 박은 자리 — 「고정 N곳」에 함께 센다(2026-09-12 사용자 지시 ①). */ apexLock: boolean[]; /** 그려 낸 곡선 수 — 아직 안 그렸으면 0. */ curveCount: number; /** 법정 기준을 못 맞춘 자리 수. */ violationCount: number; minRadiusM: number; /** 아직 한 번도 안 그렸나(막 열었을 때). */ fresh: boolean; } /** 상태줄 꼬리 — 곡선 수·기준 미달·사용자가 손댄 자리를 한 줄로. */ export function curveSummary(input: CurveSummaryInput): string { const off = input.curveOn.filter( (on, index) => !on && index > 0 && index < input.nodeCount - 1, ).length; const forced = input.curveRadius.filter((value) => value !== null).length; const locked = input.curveLock.filter((lock) => lock !== null).length + input.apexLock.filter(Boolean).length; const edits = [ off ? `곡선 지움 ${off}곳` : "", forced ? `R 지정 ${forced}곳` : "", locked ? `고정 ${locked}곳` : "", ] .filter(Boolean) .join(" · "); if (input.fresh) { const base = input.minRadiusM ? `곡선 기준 R ${input.minRadiusM}m — [확인] 때 반영` : ""; return edits ? `${base}${base ? " · " : ""}${edits}` : base; } return ( `곡선 ${input.curveCount}곳(하한 R ${input.minRadiusM}m)` + `${input.violationCount ? ` · 기준 미달 ${input.violationCount}곳` : ""}` + `${edits ? ` · ${edits}` : ""}` ); } /** 서버가 준 노드·곡선을 **편집할 수 있는 꼴**로 편 결과. */ export interface FlattenedPlan { planned: Vertex[]; nodes: EditedNode[]; curves: EditedCurve[]; curveOn: boolean[]; curveRadius: Array; curveLock: CurveLock[]; curveArc: Array; apexLock: boolean[]; } interface ServerNode { x: number; y: number; radius_m: number | null; inner_angle_deg: number | null; violations?: string[]; } /** * 서버가 준 노드·곡선을 **꺾임점 하나 = 곡선 하나**로 편다(2026-09-07). * * 서버는 처음 만들 때 이어진 꺾임을 한 곡선으로 묶는다 — 그 곡선의 교각점은 앞뒤 직선을 * 늘려 만나는 자리라 **원본 꺾임점 중 어느 것도 아니다**. 묶인 채로 두면 한 번만 손대도 * 묶음이 낱개로 흩어지고 **맞춰 둔 반지름이 전부 법정 하한으로 되돌아간다**(실측: R * 12~199m → 전부 12m). 그래서 **묶인 구간을 그 교각점 하나로 갈아 끼우고** 안쪽 꺾임점은 * 뺀다. 앞뒤 직선과 반지름이 그대로라 **그려지는 선은 똑같다**. * * ⚠ 서버가 **곡선을 안 둔 자리는 「곡선 없음」으로 연다**. 전부 켬으로 열면 아무것도 안 * 만지고 [확인]만 눌러도 그 자리에 곡선이 새로 생겨 노선이 조용히 바뀐다(서버의 편집 * 갈래는 켜진 자리마다 원호를 끼운다). 내각 179° 이상인 자리가 그렇다. */ export function flattenServerPlan(nodes: ServerNode[], curves: EditedCurve[]): FlattenedPlan { const replaced = new Map(); const dropped = new Set(); curves.forEach((curve) => { replaced.set(curve.node_first, curve); for (let index = curve.node_first + 1; index <= curve.node_last; index += 1) { dropped.add(index); } }); const out: FlattenedPlan = { planned: [], nodes: [], curves: [], curveOn: [], curveRadius: [], curveLock: [], curveArc: [], apexLock: [], }; nodes.forEach((node, index) => { if (dropped.has(index)) return; const curve = replaced.get(index); const seat = out.planned.length; out.planned.push(curve ? [curve.apex[0], curve.apex[1]] : [node.x, node.y]); out.nodes.push({ radius_m: curve ? curve.radius_m : node.radius_m, inner_angle_deg: curve ? curve.inner_angle_deg : node.inner_angle_deg, tangent_m: null, violations: curve ? (curve.violations ?? []) : (node.violations ?? []), }); out.curveOn.push(curve !== undefined); // 서버가 고른 반지름을 **그대로 들고 간다** — 안 그러면 편집 한 번에 하한으로 눌린다. out.curveRadius.push(curve ? Math.round(curve.radius_m * 100) / 100 : null); out.curveLock.push(null); out.curveArc.push(null); out.apexLock.push(false); if (curve) out.curves.push({ ...curve, node_first: seat, node_last: seat }); }); return out; }