/* ============================================================================= * B05_Profile_UI_RouteEdit_Frame.ts * 측점 한 자리의 **평면 틀**(중심점 · 좌측 축) — 노선을 끄는 동안 브라우저가 바로 셈한다. * * ⚠ 파이썬·TS 짝 — `B05_Profile_Engine_Sections_Core.py` 의 `_interpolate_xy`·`_tangent_at` * (`generate_sections` 가 측점 자리·좌측 축을 내는 식). 한쪽을 고치면 다른 쪽도 고치고 * 거울 시험 `resources/tester/test_b05_routeedit_frame_mirror.py` 를 돌림. * * ⚠ 파이썬·TS 짝 — 측점 스냅(요구 224-2) `stationsAlong` · `curveSpans` · `snapCurveStations` * ↔ `B05_Profile_Engine_Sections_Core.py` `_chainages` · `_design_curve_spans` · * `snap_curve_stations`. 거울 시험 `resources/tester/test_224_2_station_snap.py`. * ========================================================================== */ type Vertex = [number, number]; /** 접선을 재는 앞뒤 거리(m) — 파이썬 `max(SECTION_LONG_SAMPLE_INTERVAL_M, 0.5)` 와 같은 값. */ export const SECTION_LONG_SAMPLE_INTERVAL_M = 1.0; export interface StationFrame { origin: Vertex; /** 좌측(+offset) 단위 벡터 — 우측은 반대 방향. */ left: Vertex; } /** 길이 0 인 선분을 걸러 낸 정점과 누가거리 — 파이썬 `_clean_polyline` 과 같은 거름. */ function cleanPolyline(line: ReadonlyArray): { points: Vertex[]; chainage: number[] } { const points: Vertex[] = []; const chainage: number[] = []; line.forEach((vertex, index) => { if (index > 0) { const last = points[points.length - 1]; const step = Math.hypot(vertex[0] - last[0], vertex[1] - last[1]); if (step <= 1e-8) return; chainage.push(chainage[chainage.length - 1] + step); } else { chainage.push(0); } points.push([vertex[0], vertex[1]]); }); return { points, chainage }; } /** `np.interp` 와 같은 한 점 보간 — 범위 밖은 끝값에 붙는다. */ function interpolate(points: Vertex[], chainage: number[], target: number): Vertex { const last = chainage.length - 1; if (target <= chainage[0]) return points[0]; if (target >= chainage[last]) return points[last]; let index = 1; while (chainage[index] < target) index += 1; const span = chainage[index] - chainage[index - 1]; const ratio = span > 0 ? (target - chainage[index - 1]) / span : 0; return [ points[index - 1][0] + (points[index][0] - points[index - 1][0]) * ratio, points[index - 1][1] + (points[index][1] - points[index - 1][1]) * ratio, ]; } /** 누가거리 `chainageM` 측점의 중심점과 좌측 축. 노선이 없거나 방향을 못 재면 null. * 노선보다 긴 누가거리는 끝점에 붙인다 — 노선이 짧아지면 서버도 마지막 측점을 준다. */ export function stationFrameAt( line: ReadonlyArray, target: number, probeM: number = Math.max(SECTION_LONG_SAMPLE_INTERVAL_M, 0.5), ): StationFrame | null { const { points, chainage } = cleanPolyline(line); if (points.length < 2) return null; const total = chainage[chainage.length - 1]; const chainageM = Math.min(Math.max(target, 0), total); let before = Math.max(0, chainageM - probeM); let after = Math.min(total, chainageM + probeM); if (after - before <= 1e-9) { before = Math.max(0, chainageM - 1e-3); after = Math.min(total, chainageM + 1e-3); } const from = interpolate(points, chainage, before); const to = interpolate(points, chainage, after); const length = Math.hypot(to[0] - from[0], to[1] - from[1]); if (length <= 1e-9) return null; const tangent: Vertex = [(to[0] - from[0]) / length, (to[1] - from[1]) / length]; return { origin: interpolate(points, chainage, chainageM), left: [-tangent[1], tangent[0]] }; } /** 곡선표 시 · 종점이 노선에서 이보다 멀면 그 노선 곡선이 아님(m) — 파이썬 같은 이름. */ export const DESIGN_CURVE_MATCH_TOLERANCE_M = 5.0; /** 호 끝(BC · EC)에서 이만큼 안쪽 측점만 「호 안」(m) — 파이썬 같은 이름. */ export const CURVE_STATION_EDGE_M = 0.005; /** 곡선표 한 줄에서 스냅이 읽는 칸 — 서버 곡선표 · `EditedCurve` 모두 이 꼴을 품음. */ export interface CurveEnds { start?: ReadonlyArray | null; apex?: ReadonlyArray | null; end?: ReadonlyArray | null; radius_m?: number | null; } /** 규칙 측점 누가거리 — 0 · 간격 · 2·간격 · … (+ 끝 자투리 `includeEndpoint`). `_chainages` 짝. */ export function stationsAlong( line: ReadonlyArray, intervalM: number, includeEndpoint: boolean, ): number[] { const { chainage } = cleanPolyline(line); const total = chainage.length ? chainage[chainage.length - 1] : 0; const values: number[] = []; if (intervalM > 0) for (let i = 0; i * intervalM < total + 1e-9; i += 1) values.push(i * intervalM); if (!values.length || Math.abs(values[0]) > 1e-9) values.unshift(0); if (includeEndpoint && total - values[values.length - 1] > 1e-6) values.push(total); return values; } /** 점을 노선 위로 내려 [떨어진 거리, 누가거리] — `_project_chainage` 짝(같으면 앞 선분). */ function projectChainage( points: Vertex[], chainage: number[], xy: ReadonlyArray, ): [number, number] { let bestGap = Infinity; let bestAt = 0; for (let index = 0; index < points.length - 1; index += 1) { const [sx, sy] = points[index]; const vx = points[index + 1][0] - sx; const vy = points[index + 1][1] - sy; const length2 = vx * vx + vy * vy; const safe = length2 > 1e-12 ? length2 : 1; const ratio = Math.min(1, Math.max(0, ((xy[0] - sx) * vx + (xy[1] - sy) * vy) / safe)); const gap = Math.hypot(sx + vx * ratio - xy[0], sy + vy * ratio - xy[1]); if (gap < bestGap) { bestGap = gap; bestAt = chainage[index] + (chainage[index + 1] - chainage[index]) * ratio; } } return [bestGap, bestAt]; } /** 곡선표 → [BC, EC] 누가거리(앞쪽부터) — 노선 밖 곡선 · 방향 못 재는 곡선은 버림. * `_design_curve_spans` 짝(반지름 · 바깥쪽 칸은 스냅이 안 써 뺌). */ export function curveSpans( line: ReadonlyArray, curves: ReadonlyArray, ): Array<[number, number]> { const { points, chainage } = cleanPolyline(line); if (points.length < 2) return []; const spans: Array<[number, number]> = []; for (const { start, apex, end, radius_m } of curves) { if (!start?.length || !apex?.length || !end?.length || typeof radius_m !== "number") continue; const [startGap, startAt] = projectChainage(points, chainage, start); const [endGap, endAt] = projectChainage(points, chainage, end); if (Math.max(startGap, endGap) > DESIGN_CURVE_MATCH_TOLERANCE_M) continue; const cross = (apex[0] - start[0]) * (end[1] - apex[1]) - (apex[1] - start[1]) * (end[0] - apex[0]); if (Math.abs(cross) < 1e-12) continue; spans.push([Math.min(startAt, endAt), Math.max(startAt, endAt)]); } return spans.sort((a, b) => a[0] - b[0] || a[1] - b[1]); } /** 측점 스냅 — 호 안 측점 중 곡선 중점 MC 에 가장 가까운 하나를 floor(MC+0.5) 로 옮김 · * 측점 없는 호는 그대로 · 같은 m 는 하나로(오름차순). `snap_curve_stations` 짝. */ export function snapCurveStations( stations: ReadonlyArray, spans: ReadonlyArray, ): number[] { const values = [...stations]; for (const [start, end] of spans) { const middle = (start + end) / 2; let nearest = -1; values.forEach((value, index) => { if (value <= start + CURVE_STATION_EDGE_M || value >= end - CURVE_STATION_EDGE_M) return; if (nearest < 0 || Math.abs(value - middle) < Math.abs(values[nearest] - middle)) nearest = index; }); if (nearest >= 0) values[nearest] = Math.floor(middle + 0.5); } return [...new Set(values)].sort((a, b) => a - b); }