Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
192 lines
8.3 KiB
TypeScript
192 lines
8.3 KiB
TypeScript
/* =============================================================================
|
|
* B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts
|
|
* 측점 스냅 켬 = 곡선 가운데(MC)를 일반측점에(366-3 · 요구 240-11) — 손 편집 평면.
|
|
*
|
|
* 추천 노선 맞춤(`B05_Profile_Engine_RouteInitial_Station.py` `_try` · `_solve`)을 줄인 꼴:
|
|
* · `from`(`fitStart` = 고친 자리 앞)부터 끝까지 차례로 — 고정 아닌 교각점(IP)을 들어오는 접선 따라 ±간격/2 안에서
|
|
* 밀어 MC(BC · EC 누가거리 가운데)를 가까운 일반측점(앞 MC 측점 + 간격 이상 · No.1 이상)에 둠.
|
|
* · 곡선 하한을 새로 깨거나(`shortfallCrossed`) 앞 곡선 MC 를 흔들면 버림 → 그 곡선은 그대로.
|
|
* · 반지름 · 켬끔은 안 바꿈 — IP 자리만. 합치기 · 곡선 끄기 · 되돌아가기 없음.
|
|
* · 간격 = 스냅 간격(390-2 · 0.1 · 1 · 10 m · 측점 No.) · 허용 오차도 간격 따라(`mcTolM`).
|
|
* · 끄는 동안 · 방향키 걸음마다 미리보기(`fitDrag` · 390-5) = 누르기 전 노선 + 옮긴 IP 하나로 매번 새로
|
|
* 맞춤(앞 프레임 맞춤이 쌓이지 않음) · 놓을 때 `fitCurveStations` 정본 맞춤이 같은 값.
|
|
* 파이썬 짝 아님 — [저장] 때 서버가 저장된 IP 로 선을 다시 짬(`buildEditedPolyline` 짝)이라 MC 그대로.
|
|
* 순수 계산 — 화면 · fetch · sessionStorage 없음. 부르는 쪽은 `B05_Profile_UI_RouteEdit`.
|
|
* ========================================================================== */
|
|
|
|
import {
|
|
buildEditedPolyline,
|
|
type EditedCurve,
|
|
type EditedPolyline,
|
|
type Vertex,
|
|
} from "./B05_Profile_UI_RouteEdit_Curve";
|
|
import {
|
|
applyArcLocks,
|
|
applyCurveLimits,
|
|
curveShortfalls,
|
|
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);
|
|
|
|
/** 선을 짜는 값 — 편집 창이 쥔 하한 · 간격. */
|
|
export interface FitLimits {
|
|
minRadiusM: number;
|
|
limitRadiusM: number;
|
|
limitArcM: number;
|
|
intervalM: number;
|
|
}
|
|
|
|
type Columns = Pick<NodeColumns, "curveOn" | "curveRadius" | "curveLock" | "curveArc" | "apexLock">;
|
|
|
|
/** 편집 창 `markEdited` 와 같은 길(길이 고정 R · 하한 끌어올림 → 선) — 값은 사본에. */
|
|
function build(cols: Columns, planned: Vertex[], limits: FitLimits): EditedPolyline {
|
|
const radius = [...cols.curveRadius];
|
|
applyArcLocks(planned, cols.curveLock, cols.curveArc, radius);
|
|
applyCurveLimits(planned, cols.curveOn, radius, limits.limitRadiusM, limits.limitArcM);
|
|
return buildEditedPolyline(planned, cols.curveOn, radius, limits.minRadiusM);
|
|
}
|
|
|
|
/** 꺾임점 `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<Vertex>,
|
|
curves: ReadonlyArray<EditedCurve>,
|
|
intervalM: number,
|
|
): number {
|
|
if (!(intervalM > 0)) return 0;
|
|
const spans = curveSpans(line, curves);
|
|
const hits = new Set<number>();
|
|
for (const [start, end] of spans) {
|
|
const mc = (start + end) / 2;
|
|
const n = Math.round(mc / intervalM);
|
|
if (n >= 1 && Math.abs(mc - n * intervalM) <= mcTolM(intervalM)) hits.add(n);
|
|
}
|
|
return spans.length - hits.size;
|
|
}
|
|
|
|
/** 맞춤을 시작할 꺾임점 — 지난 걸음과 처음 다른 자리 바로 앞(그 곡선 끝 접선도 바뀜) · 같으면 1. */
|
|
export function fitStart(
|
|
before: (Columns & Pick<NodeColumns, "planned">) | null | undefined,
|
|
now: Columns & Pick<NodeColumns, "planned">,
|
|
): 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` 를 들어오는 접선 따라 d 밀어 MC = target(할선법 · 기울기 ≈ 1). */
|
|
function solve(
|
|
cols: Columns,
|
|
limits: FitLimits,
|
|
planned: Vertex[],
|
|
k: number,
|
|
target: number,
|
|
mc: 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 reach = limits.intervalM / 2;
|
|
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 는 그대로). */
|
|
export function fitCurveStations(
|
|
cols: Columns & Pick<NodeColumns, "planned">,
|
|
limits: FitLimits,
|
|
from: number,
|
|
): 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);
|
|
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 near = Math.max(lastStation + 1, Math.round(mc / interval));
|
|
const onStation = Math.abs(mc - near * interval) <= mcTolM(interval);
|
|
const fitted =
|
|
onStation || k < from || cols.apexLock[k] || Math.abs(near * interval - mc) > interval / 2
|
|
? null
|
|
: solve(cols, limits, planned, k, near * interval, mc);
|
|
const keep =
|
|
fitted &&
|
|
!shortfallCrossed(
|
|
floor,
|
|
curveShortfalls(fitted.built.nodes, limits.limitRadiusM, limits.limitArcM),
|
|
) &&
|
|
(previous < 0 ||
|
|
Math.abs((mcOf(fitted.built, previous) ?? Infinity) - (mcOf(built, previous) ?? 0)) <=
|
|
mcTolM(interval) / 5);
|
|
if (fitted && keep) ({ planned, built } = fitted);
|
|
if (onStation || keep) lastStation = near;
|
|
previous = k;
|
|
}
|
|
return planned;
|
|
}
|
|
|
|
/** 끄는 중 미리보기(390-5) — `base`(누르기 전 IP) 에 IP `k` 만 지금 자리(`cols.planned[k]`)로 두고
|
|
* `k` 앞 곡선부터 맞춤(`fitStart` 와 같은 시작). */
|
|
export function fitDrag(
|
|
cols: Columns & Pick<NodeColumns, "planned">,
|
|
base: ReadonlyArray<Vertex>,
|
|
k: number,
|
|
limits: FitLimits,
|
|
): Vertex[] {
|
|
if (base.length !== cols.planned.length)
|
|
return fitCurveStations(cols, limits, Math.max(1, k - 1));
|
|
const planned = base.map((vertex, i): Vertex => (i === k ? cols.planned[k] : vertex));
|
|
return fitCurveStations({ ...cols, planned }, limits, Math.max(1, k - 1));
|
|
}
|