- 까닭: 노선이 바뀌면 planline 단계가 바뀐 구간을 새 지반 첫 계획선으로 다시 맞춤(B05 꺾임점 끌기와 같은 길) · 옛 보정은 누른 측점 하나만 되돌림 - 고침: 옛 · 새 꺾은선 꼭짓점 · 측점을 견줘 다른 곳만 계획고 편집(바뀐 구간 끝까지 측점 열쇠 그대로 · 뒤는 길이 차) · 옛 R 되살림 · B05 꺾임점 끌기 규칙은 그대로 - 시험: 356 묶음에 바뀐 구간 안 사용자 계획고 · R 넣고 모든 측점 ±0.001 · 최대 기울기 · R 그대로 · 356-4 헬퍼 새 함수로 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
159 lines
7.4 KiB
TypeScript
159 lines
7.4 KiB
TypeScript
/* =============================================================================
|
||
* B05_Profile_RouteShift.ts
|
||
* 횡단 중심점 좌우 이동(356-3) — 측점 자리 노선을 좌측 축으로 Δ m 옮길 노드 편집을 고르고 · 그 측점
|
||
* 계획고를 옮기기 전 값으로 되돌릴 계획고 편집을 냄.
|
||
*
|
||
* · P = 측점 중심 · L = 좌측 단위 벡터(`stationFrameAt` · +offset = 좌)
|
||
* · P 에서 0.5 m 안 노드 = 그 노드 + Δ·L(고정 노드면 안 옮김)
|
||
* · 곡선(BC ~ EC) 안 측점 = 그 교각점 + Δ·L(고정이면 안 옮김)
|
||
* · 둘 다 아니면 P + Δ·L 을 그 자리 노드 토막에 끼움
|
||
* · 높이 = 노면 그대로(364-7) — 옮기기 전 종단의 꼭짓점 · R 을 계획고 편집으로 되살림(`holdGrade`) ·
|
||
* 바뀐 구간 첫 계획선 맞춤(`Pipeline_Grade` planline)에 휩쓸리지 않게 · 지반만 새로
|
||
*
|
||
* 브라우저가 고르는 편집값일 뿐 — 서버는 [저장] 때 노드 · 계획고 편집을 받아 정본으로 다시 셈(짝 없음).
|
||
* ========================================================================== */
|
||
|
||
import {
|
||
buildAlignment,
|
||
chainageKey,
|
||
evaluateAt,
|
||
type AlignmentBase,
|
||
type AlignmentEdits,
|
||
type ProfileAlignment,
|
||
} from "./B05_Profile_UI_Profile_Alignment";
|
||
import { changedSpan } from "./B05_Profile_Grade_Carry";
|
||
import { curveSpans, stationFrameAt, type CurveEnds } from "./B05_Profile_UI_RouteEdit_Frame";
|
||
|
||
type Vertex = [number, number];
|
||
|
||
/** 측점 중심에서 이 안의 노드는 끼우지 않고 옮김(m). */
|
||
export const NODE_SNAP_M = 0.5;
|
||
/** 이보다 작은 높이 차는 보정 안 함(m). */
|
||
export const PIN_TOLERANCE_M = 5e-4;
|
||
/** 노면 그대로 견줌 횟수. */
|
||
const HOLD_PASSES = 4;
|
||
|
||
export type RouteShiftPlan =
|
||
| { kind: "move"; index: number; to: Vertex }
|
||
| { kind: "insert"; index: number; at: Vertex }
|
||
| { kind: "locked"; index: number };
|
||
|
||
export interface RouteShiftInput {
|
||
/** 노드(꺾임점). */
|
||
planned: ReadonlyArray<Vertex>;
|
||
/** 그려진 계획노선(원호 펴진 폴리라인) — 측점 누가거리의 기준. */
|
||
line: ReadonlyArray<Vertex>;
|
||
/** 곡선표 — `node_first` = 그 곡선 교각점 노드 자리. */
|
||
curves: ReadonlyArray<CurveEnds & { node_first: number }>;
|
||
/** 노드마다 고정. */
|
||
locked: ReadonlyArray<boolean>;
|
||
chainageM: number;
|
||
/** 좌(+) · 우(−) 이동(m). */
|
||
deltaM: number;
|
||
}
|
||
|
||
/** 점에서 가장 가까운 노드 토막 차례(앞 노드 자리). */
|
||
function nearestSegment(nodes: ReadonlyArray<Vertex>, point: Vertex): number {
|
||
let best = 0;
|
||
let bestGap = Infinity;
|
||
for (let i = 0; i < nodes.length - 1; i += 1) {
|
||
const [ax, ay] = nodes[i];
|
||
const [vx, vy] = [nodes[i + 1][0] - ax, nodes[i + 1][1] - ay];
|
||
const length2 = vx * vx + vy * vy || 1;
|
||
const t = Math.min(1, Math.max(0, ((point[0] - ax) * vx + (point[1] - ay) * vy) / length2));
|
||
const gap = Math.hypot(ax + vx * t - point[0], ay + vy * t - point[1]);
|
||
if (gap < bestGap) [best, bestGap] = [i, gap];
|
||
}
|
||
return best;
|
||
}
|
||
|
||
/** 노드 편집 고름 — 노선이 없으면 null. */
|
||
export function planRouteShift(input: RouteShiftInput): RouteShiftPlan | null {
|
||
const { planned, line, curves, locked, chainageM, deltaM } = input;
|
||
const frame = planned.length >= 2 ? stationFrameAt(line, chainageM) : null;
|
||
if (!frame) return null;
|
||
const { origin, left } = frame;
|
||
const shifted = ([x, y]: Vertex): Vertex => [x + left[0] * deltaM, y + left[1] * deltaM];
|
||
const move = (index: number): RouteShiftPlan =>
|
||
locked[index]
|
||
? { kind: "locked", index }
|
||
: { kind: "move", index, to: shifted(planned[index]) };
|
||
const near = planned.findIndex(
|
||
([x, y]) => Math.hypot(x - origin[0], y - origin[1]) <= NODE_SNAP_M,
|
||
);
|
||
if (near >= 0) return move(near);
|
||
const curve = curves.find((one) => {
|
||
const [span] = curveSpans(line, [one]);
|
||
return span && chainageM > span[0] && chainageM < span[1];
|
||
});
|
||
if (curve && planned[curve.node_first]) return move(curve.node_first);
|
||
return { kind: "insert", index: nearestSegment(planned, origin) + 1, at: shifted(origin) };
|
||
}
|
||
|
||
const xy = (line: ReadonlyArray<Vertex>) => line.map(([x, y]) => ({ x, y }));
|
||
|
||
/**
|
||
* 옛 ↔ 새 누가거리 — 바뀐 구간 끝까지 그대로(측점 열쇠 = 같은 계획고) · 뒤는 길이 차(`carryProfileEdits` 의 뒤).
|
||
* [옛 → 새, 새 → 옛].
|
||
*/
|
||
function carryChainage(oldLine: ReadonlyArray<Vertex>, newLine: ReadonlyArray<Vertex>) {
|
||
const span = changedSpan(xy(oldLine), xy(newLine));
|
||
const shift = (end: number, delta: number) => (s: number) => (span && s >= end ? s + delta : s);
|
||
return [
|
||
shift(span?.old_end_m ?? 0, span?.delta_m ?? 0),
|
||
shift(span?.new_end_m ?? 0, -(span?.delta_m ?? 0)),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 노면 그대로(364-7) — 노선을 옮긴 뒤 새 기준(`base` · 바뀐 구간 첫 계획선) 위에 옮기기 전 종단(`before`)의
|
||
* 꼭짓점 · 종단곡선 R 을 계획고 편집으로 되살림. 두 꺾은선의 꼭짓점을 모두 맞추면 두 선이 같음 —
|
||
* 다른 꼭짓점 · 새 기준에 없는 옛 꼭짓점만 고정(바뀐 구간 밖은 이미 같아 편집 안 늚). 차가 없으면 null.
|
||
* ponytail: 새 기준에만 있는 꼭짓점(옛 선 위로 고정)이 옛 종단곡선 반쪽 한계를 줄일 수 있음 — 측점마다
|
||
* 꼭짓점인 기준(지금 첫 계획선)이면 옛 선과 꼭짓점이 같아 없음.
|
||
*/
|
||
export function holdGrade(
|
||
base: AlignmentBase,
|
||
edits: AlignmentEdits,
|
||
before: ProfileAlignment,
|
||
oldLine: ReadonlyArray<Vertex>,
|
||
newLine: ReadonlyArray<Vertex>,
|
||
): AlignmentEdits | null {
|
||
const [carry, back] = carryChainage(oldLine, newLine);
|
||
const oldS = before.pvi.map((node) => node.chainage_m);
|
||
const oldZ = before.pvi.map((node) => node.elevation_m);
|
||
const oldKeys = new Set(oldS.map((s) => chainageKey(carry(s))));
|
||
const stationS = base.stations.map((station) => station.chainage_m);
|
||
const baseS = base.basePvi.map((node) => node.chainage_m);
|
||
const baseZ = base.basePvi.map((node) => node.elevation_m);
|
||
const line = (s: number[], z: number[], at: number): number => evaluateAt(s, z, [], at);
|
||
const offsets = { ...edits.station_offsets };
|
||
const radii = { ...edits.curve_radii };
|
||
let changed = false;
|
||
// 고정한 꼭짓점이 이웃 측점 사이 선을 바꿈 — 다시 견줘 남은 측점까지
|
||
for (let pass = 0; pass < HOLD_PASSES; pass += 1) {
|
||
const now = buildAlignment(base, { ...edits, station_offsets: offsets }).pvi;
|
||
const nowS = now.map((node) => node.chainage_m);
|
||
const nowZ = now.map((node) => node.elevation_m);
|
||
const nowKeys = new Set(nowS.map(chainageKey));
|
||
let pinned = false;
|
||
for (const key of new Set([...oldS.map(carry), ...nowS, ...stationS].map(chainageKey))) {
|
||
const at = Number(key);
|
||
const z = line(oldS, oldZ, back(at));
|
||
const lost = oldKeys.has(key) && !nowKeys.has(key); // 옛 꺾임이 새 선에 없음
|
||
if (!lost && Math.abs(line(nowS, nowZ, at) - z) < PIN_TOLERANCE_M) continue;
|
||
offsets[key] = z - line(baseS, baseZ, at); // 반올림 안 함 — 옛 선 위 꼭짓점이 꺾임 안 만듦
|
||
pinned = true;
|
||
}
|
||
if (!pinned) break;
|
||
changed = true;
|
||
}
|
||
Object.entries(before.edits.curve_radii).forEach(([key, radius]) => {
|
||
const at = chainageKey(carry(Number(key)));
|
||
if (radii[at] !== undefined) return;
|
||
radii[at] = radius;
|
||
changed = true;
|
||
});
|
||
return changed ? { ...edits, station_offsets: offsets, curve_radii: radii } : null;
|
||
}
|