Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
177 lines
7.8 KiB
TypeScript
177 lines
7.8 KiB
TypeScript
/* =============================================================================
|
|
* 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<Vertex>): { 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<Vertex>,
|
|
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<number> | null;
|
|
apex?: ReadonlyArray<number> | null;
|
|
end?: ReadonlyArray<number> | null;
|
|
radius_m?: number | null;
|
|
}
|
|
|
|
/** 규칙 측점 누가거리 — 0 · 간격 · 2·간격 · … (+ 끝 자투리 `includeEndpoint`). `_chainages` 짝. */
|
|
export function stationsAlong(
|
|
line: ReadonlyArray<Vertex>,
|
|
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, 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<Vertex>,
|
|
curves: ReadonlyArray<CurveEnds>,
|
|
): 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<number>,
|
|
spans: ReadonlyArray<readonly [number, number]>,
|
|
): 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);
|
|
}
|