/* ============================================================================= * 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; /** 그려진 계획노선(원호 펴진 폴리라인) — 측점 누가거리의 기준. */ line: ReadonlyArray; /** 곡선표 — `node_first` = 그 곡선 교각점 노드 자리. */ curves: ReadonlyArray; /** 노드마다 고정. */ locked: ReadonlyArray; chainageM: number; /** 좌(+) · 우(−) 이동(m). */ deltaM: number; } /** 점에서 가장 가까운 노드 토막 차례(앞 노드 자리). */ function nearestSegment(nodes: ReadonlyArray, 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) => line.map(([x, y]) => ({ x, y })); /** * 옛 ↔ 새 누가거리 — 바뀐 구간 끝까지 그대로(측점 열쇠 = 같은 계획고) · 뒤는 길이 차(`carryProfileEdits` 의 뒤). * [옛 → 새, 새 → 옛]. */ function carryChainage(oldLine: ReadonlyArray, newLine: ReadonlyArray) { 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, newLine: ReadonlyArray, ): 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; }