Files
Aislo/B05_Profile/logic/B05_Profile_RouteShift.ts
T
eomsangdonandClaude Opus 5.5 842473bcea fix(B06): 364-7 중심점 좌우 = 노면 그대로 — 바뀐 구간 첫 계획선 맞춤에 쓸린 모든 측점 계획고 · 꺾임 · R 을 옮기기 전 종단으로 되살림(holdGrade · 한 측점 보정 대신)
- 까닭: 노선이 바뀌면 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
2026-10-17 00:32:55 +09:00

159 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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;
}