/* ============================================================================= * B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts * 측점 스냅 켬 = 곡선 가운데(MC)를 일반측점에(366-3 · 요구 240-11) — 손 편집 평면. * * 추천 노선 맞춤(`B05_Profile_Engine_RouteInitial_Station.py` `_try` · `_solve`)을 줄인 꼴: * · `from`(`fitStart` = 고친 자리 앞)부터 끝까지 차례로 — 고정 아닌 교각점(IP)을 들어오는 접선 따라 ±간격 안에서 * 밀어 MC(BC · EC 누가거리 가운데)를 일반측점(앞 MC 측점 + 간격 이상 · No.1 이상)에 둠 — 내림 · 올림 후보 중 뒤 곡선도 서는 쪽(454). * · 곡선 하한을 새로 깨거나(`shortfallCrossed`) 앞 곡선 MC 를 흔들면 버림 → 그 곡선은 그대로. * · 반지름 · 켬끔은 안 바꿈 — IP 자리만. 합치기 · 곡선 끄기 · 되돌아가기 없음. * · 간격 = 스냅 간격(390-2 · 10 m · 측점 No.) · 허용 오차도 간격 따라(`mcTolM`). * · 유지(280-4) — 고치기 전 노선(`before`)에서 MC 가 측점 · 10 m 배수(No.N+10)였던 곡선은 그 IP · 곡선 칸이 * 그대로면 옛 꼴(측점 간격 배수 + 0 · 10)로만 맞춤(밀 거리 = 측점 간격) · 손으로 고친 곡선만 고른 간격. * MC 가 고른 간격 배수 · 10 m 배수면 「위」(No.N+10 특별측점 · 저장 칸 없음 = 노선 모양으로 기억). * · 끄는 동안 · 방향키 걸음마다 미리보기(`fitDrag` · 390-5) = 누르기 전 노선 + 옮긴 IP 하나로 매번 새로 * 맞춤(앞 프레임 맞춤이 쌓이지 않음) · 놓을 때 `fitCurveStations` 정본 맞춤이 같은 값. * 파이썬 짝 아님 — [저장] 때 서버가 저장된 IP 로 선을 다시 짬(`buildEditedPolyline` 짝)이라 MC 그대로. * 순수 계산 — 화면 · fetch · sessionStorage 없음. 부르는 쪽은 `B05_Profile_UI_RouteEdit`. * ========================================================================== */ import type { EditedCurve, EditedPolyline, Vertex } from "./B05_Profile_UI_RouteEdit_Curve"; import { curveShortfalls, rebuildLine, shortfallCrossed, type NodeColumns, } from "./B05_Profile_UI_RouteEdit_Edits"; import { curveSpans } from "./B05_Profile_UI_RouteEdit_Frame"; /** MC 가 측점에서 이만큼 안이면 「위」(m) — 파이썬 `MC_TOL_M` 과 같은 값. */ export const MC_TOL_M = 0.05; /** 간격별 허용 오차 — 0.1 m 간격도 실제로 맞게 간격의 1/20 이하(390-2 · 1 m 이상은 `MC_TOL_M`). */ export const mcTolM = (intervalM: number): number => Math.min(MC_TOL_M, intervalM / 20); /** 특별측점 간격(m) — MC 가 일반측점 사이 10 m 배수(No.N+10)면 측점 위로 봄(280-4). */ export const SPECIAL_STATION_M = 10; /** 선을 짜는 값 — 편집 창이 쥔 하한 · 간격(스냅) · 측점 간격(없으면 스냅 간격). */ export interface FitLimits { minRadiusM: number; limitRadiusM: number; limitArcM: number; intervalM: number; stationM?: number; } /** MC 를 놓을 격자 — 간격 · 어긋남(N+10 꼴 = 10) · 밀 거리. */ type Grid = { step: number; offset: number; reach: number }; type Columns = Pick; /** 편집 창 `markEdited` 와 같은 길(길이 고정 R · 하한 끌어올림 → 선) — 값은 사본에. */ function build(cols: Columns, planned: Vertex[], limits: FitLimits): EditedPolyline { return rebuildLine({ ...cols, planned, curveRadius: [...cols.curveRadius] }, limits); } /** 꺾임점 `k` 곡선의 MC 누가거리 — 곡선이 없으면 null. */ function mcOf(built: EditedPolyline, k: number): number | null { const curve = built.curves.find((item) => item.node_first === k); const span = curve ? curveSpans(built.vertices, [curve])[0] : undefined; return span ? (span[0] + span[1]) / 2 : null; } /** 측점 밖 곡선 수 — MC 가 일반측점(No.1 이상) 위가 아닌 곡선 · 한 측점에 둘이면 하나만 위. */ export function offStationCount( line: ReadonlyArray, curves: ReadonlyArray, intervalM: number, ): number { return offStationMcs(line, curves, intervalM).length; } /** 측점 밖 곡선의 MC 누가거리(408-1 · 상태줄 「못 맞춘 곡선」) — `offStationCount` 와 같은 판정 · * 간격 배수 · 10 m 배수(No.N+10 · 280-4) 둘 다 「위」. */ export function offStationMcs( line: ReadonlyArray, curves: ReadonlyArray, intervalM: number, ): number[] { if (!(intervalM > 0)) return []; const tol = mcTolM(intervalM); const hits = new Set(); const off: number[] = []; for (const [start, end] of curveSpans(line, curves)) { const mc = (start + end) / 2; const at = [intervalM, SPECIAL_STATION_M] .map((step) => Math.round(mc / step) * step) .find((c) => c > tol && Math.abs(mc - c) <= tol && !hits.has(Math.round(c * 1000))); if (at !== undefined) hits.add(Math.round(at * 1000)); else off.push(mc); } return off; } /** 꺾임점 `k` 의 IP 자리 + 곡선 칸 — 고치기 전 · 뒤에 같으면 손대지 않은 곡선. */ const nodeKey = (cols: Columns & Pick, k: number): string => JSON.stringify([ cols.planned[k], cols.curveOn[k], cols.curveRadius[k], cols.curveLock[k], cols.curveArc[k], ]); /** 고치기 전 노선의 곡선 꼴 — MC 가 10 m 배수인 곡선의 `nodeKey` → 측점 간격 안 어긋남(0 · 10). */ function keptForms( before: Columns & Pick, limits: FitLimits, stationM: number, ): Map { const built = build(before, before.planned, limits); const out = new Map(); for (let k = 1; k < before.planned.length - 1; k += 1) { const mc = mcOf(built, k); if (mc === null) continue; const at = Math.round(mc / SPECIAL_STATION_M) * SPECIAL_STATION_M; if (at <= 0 || Math.abs(mc - at) > mcTolM(SPECIAL_STATION_M)) continue; out.set(nodeKey(before, k), ((at % stationM) + stationM) % stationM); } return out; } /** 맞춤을 시작할 꺾임점 — 지난 걸음과 처음 다른 자리 바로 앞(그 곡선 끝 접선도 바뀜) · 같으면 1. */ export function fitStart( before: (Columns & Pick) | null | undefined, now: Columns & Pick, ): number { if (!before) return 1; const keys = ["curveOn", "curveRadius", "curveLock", "curveArc", "apexLock"] as const; const at = now.planned.findIndex( (vertex, i) => vertex[0] !== before.planned[i]?.[0] || vertex[1] !== before.planned[i]?.[1] || keys.some((key) => now[key][i] !== before[key][i]), ); const changed = at < 0 && now.planned.length !== before.planned.length ? now.planned.length - 1 : at; return Math.max(1, changed - 1); } const round4 = (value: number): number => Math.round(value * 1e4) / 1e4; /** 곡선 하나 맞춤 — IP `k` 를 들어오는 접선 따라 ±reach 안 d 밀어 MC = target(할선법 · 기울기 ≈ 1) · * reach = 간격(한가운데 18+9 ~ 19+1 도 가까운 측점까지 · 441 · 반 간격이면 못 닿음). */ function solve( cols: Columns, limits: FitLimits, planned: Vertex[], k: number, target: number, mc: number, reach: number, ): { planned: Vertex[]; built: EditedPolyline; mc: number } | null { const base = planned[k]; const before = planned[k - 1]; const length = Math.hypot(base[0] - before[0], base[1] - before[1]); if (!(length > 1e-9)) return null; const ux = (base[0] - before[0]) / length; const uy = (base[1] - before[1]) / length; const clamp = (d: number): number => Math.max(-reach, Math.min(reach, d)); const at = (d: number) => { const next = planned.slice(); next[k] = [round4(base[0] + ux * d), round4(base[1] + uy * d)]; const built = build(cols, next, limits); const found = mcOf(built, k); return found === null ? null : { planned: next, built, mc: found }; }; let d0 = 0; let f0 = mc - target; let d1 = clamp(-f0); for (let step = 0; step < 10; step += 1) { const tried = at(d1); if (!tried || d1 === d0) return null; const f1 = tried.mc - target; if (Math.abs(f1) <= mcTolM(limits.intervalM) / 5) return tried; const slope = (f1 - f0) / (d1 - d0); if (Math.abs(slope) < 0.2) return null; d0 = d1; f0 = f1; d1 = clamp(d1 - f1 / slope); } return null; } /** `from` 꺾임점부터 끝까지 곡선 MC 를 일반측점에 — 새 IP 목록(못 놓은 곡선 IP 는 그대로). * 곡선마다 후보 측점 둘(내림 · 올림 · 가까운 차례) · 뒤 곡선 하나를 미리 맞춰 보고 둘 다 서는 쪽(433-21 ②) * — 가까운 쪽만 고르면 뒤 곡선이 「앞 측점 + 1」에 몰려 R 이 눌리고 앞 MC 가 흔들려 버려짐. * `before` = 고치기 전 노선 — 손대지 않은 곡선은 그 MC 꼴(N+0 · N+10) 유지(280-4). */ export function fitCurveStations( cols: Columns & Pick, limits: FitLimits, from: number, before?: (Columns & Pick) | null, ): Vertex[] { let planned = cols.planned.map((vertex): Vertex => [vertex[0], vertex[1]]); const interval = limits.intervalM; if (!(interval > 0) || planned.length < 3) return planned; let built = build(cols, planned, limits); const floor = curveShortfalls(built.nodes, limits.limitRadiusM, limits.limitArcM); const tol = mcTolM(interval); const stationM = limits.stationM ?? interval; const forms = before ? keptForms(before, limits, stationM) : new Map(); const gridOf = (k: number): Grid => { const offset = forms.get(nodeKey(cols, k)); return offset === undefined ? { step: interval, offset: 0, reach: interval } : { step: stationM, offset, reach: stationM }; }; type Fit = { planned: Vertex[]; built: EditedPolyline }; /** 곡선을 놓을 측점(누가거리) — 내림 · 올림 중 앞 측점 뒤 · 가까운 차례. */ const near = (mc: number, last: number, grid: Grid): number[] => { const lo = Math.floor((mc - grid.offset) / grid.step); return [lo, lo + 1] .map((n) => n * grid.step + grid.offset) .filter((c) => c > last + tol) .sort((a, b) => Math.abs(a - mc) - Math.abs(b - mc)); }; /** 이미 측점 위 — 그 곡선 격자 · 고른 간격 · 10 m 배수(N+10) 중 하나. */ const loose: Grid[] = [ { step: interval, offset: 0, reach: 0 }, { step: SPECIAL_STATION_M, offset: 0, reach: 0 }, ]; const onAt = (mc: number, last: number, grid: Grid) => [grid, ...loose].flatMap((g) => near(mc, last, g)).find((c) => Math.abs(mc - c) <= tol); /** 곡선 `k` 를 측점 `c` 에 — 하한을 새로 깨거나 앞 곡선(`previous`) MC 를 흔들면 null. */ const attempt = (at: Fit, k: number, mc: number, c: number, previous: number): Fit | null => { const fitted = solve(cols, limits, at.planned, k, c, mc, gridOf(k).reach); if ( !fitted || shortfallCrossed( floor, curveShortfalls(fitted.built.nodes, limits.limitRadiusM, limits.limitArcM), ) ) return null; if (previous < 0) return fitted; const moved = Math.abs( (mcOf(fitted.built, previous) ?? Infinity) - (mcOf(at.built, previous) ?? 0), ); return moved <= tol / 5 ? fitted : null; }; /** `k` 뒤 첫 곡선이 측점 위이거나 맞춰지나(앞보기 한 걸음). */ const nextSettles = (at: Fit, k: number, last: number): boolean => { for (let j = k + 1; j < at.planned.length - 1; j += 1) { const mc = mcOf(at.built, j); if (mc === null) continue; if (onAt(mc, last, gridOf(j)) !== undefined) return true; const grid = gridOf(j); return ( !cols.apexLock[j] && near(mc, last, grid).some((c) => attempt(at, j, mc, c, k) !== null) ); } return true; }; let lastStation = 0; // 앞 곡선이 놓인 측점 누가거리(없으면 0 = 시점 · 서로 다른 측점) let previous = -1; // 앞 곡선 꺾임점 for (let k = 1; k < planned.length - 1; k += 1) { const mc = mcOf(built, k); if (mc === null) continue; const on = onAt(mc, lastStation, gridOf(k)); if (on !== undefined) lastStation = on; else if (k >= from && !cols.apexLock[k]) { let pick: { fit: Fit; c: number } | null = null; for (const c of near(mc, lastStation, gridOf(k))) { const fit = attempt({ planned, built }, k, mc, c, previous); if (!fit) continue; pick ??= { fit, c }; if (nextSettles(fit, k, c)) { pick = { fit, c }; break; } } if (pick) (({ planned, built } = pick.fit), (lastStation = pick.c)); } previous = k; } return planned; } /** 편집 한 걸음 뒤 맞춤 — 고친 자리(`fitStart`)부터 · 손대지 않은 곡선은 `before` 꼴 유지(280-4). */ export function fitAfter( before: (Columns & Pick) | null | undefined, now: Columns & Pick, limits: FitLimits, ): Vertex[] { return fitCurveStations(now, limits, fitStart(before, now), before); } /** 끄는 중 미리보기(390-5) — `base`(누르기 전 IP) 에 IP `k` 만 지금 자리(`cols.planned[k]`)로 두고 * `k` 앞 곡선부터 맞춤(`fitStart` 와 같은 시작) · `before` = 누르기 전 노선(없으면 `base` + 지금 칸 · 280-4 유지). */ export function fitDrag( cols: Columns & Pick, base: ReadonlyArray, k: number, limits: FitLimits, before?: (Columns & Pick) | null, ): Vertex[] { if (base.length !== cols.planned.length) return fitCurveStations(cols, limits, Math.max(1, k - 1), before); const planned = base.map((vertex, i): Vertex => (i === k ? cols.planned[k] : vertex)); const kept = before ?? { ...cols, planned: base.map((v): Vertex => [v[0], v[1]]) }; return fitCurveStations({ ...cols, planned }, limits, Math.max(1, k - 1), kept); }