/* ============================================================================= * B05_Profile/logic/B05_Profile_UI_Profile_Alignment_Edit.ts * 종단 계획선 편집 도구 — 구간 평행이동(쉬프트) · 곡선 R 입력. `B05_Profile_UI_Profile_Alignment.ts` * 에서 나눔(700줄 · 511-B B-3) · 선형 짓기(`buildAlignment`)는 그 파일. * ========================================================================== */ import { buildAlignment, chainageKey, interpolate, planElevationAt, type AlignmentBase, type AlignmentCurve, type AlignmentEdits, type AlignmentSegment, type ProfileAlignment, } from "./B05_Profile_UI_Profile_Alignment"; const FEEDBACK_PASSES = 4; const FEEDBACK_TOLERANCE_M = 1e-4; /** * 구간 이동에서 실제로 움직일 두 지점(2026-08-23 사용자 개념 확정). * * 구간 끝이 사용자 변화점(틸팅한 점)이면 그 점이 직접 움직인다 — 반복 쉬프트. * 고정점(BP·EP·자동 변화점 = 횡단배수 앵커)이면 **절대 움직이지 않고**, 구간 안쪽 * 틸트 안 된 정규 측점 중 외곽(첫/끝)을 힌지로 승격시킨다 — 힌지에 라운드가 생기고 * 고정점과는 직선으로 이어진다. 힌지 둘을 만들 수 없으면 null(쉬프트 불가 구간: * 안쪽 미틸트 측점 2개 미만). */ export function shiftMovingPoints( alignment: ProfileAlignment, segment: AlignmentSegment, ): [number, number] | null { const offsets = alignment.edits.station_offsets; const movable = (chainage: number): boolean => offsets[chainageKey(chainage)] !== undefined; const interior = alignment.stations .map((station) => station.chainage_m) .filter((chainage) => chainage > segment.from_m + 1e-6 && chainage < segment.to_m - 1e-6); const left = movable(segment.from_m) ? segment.from_m : interior[0]; const right = movable(segment.to_m) ? segment.to_m : interior[interior.length - 1]; if (left === undefined || right === undefined) return null; if (right - left < 1e-6) return null; return [left, right]; } /** * 구간 평행이동 — 움직일 두 지점(끝 변화점 또는 승격 힌지)에 같은 델타를 준다. * 두 지점 사이 직선의 기울기는 보존되고, 고정점과의 연결 직선·라운드만 다시 잡힌다. * 라운드(중앙종거) 탓에 1클릭이 정확히 delta가 되도록 화면 계획고 기준으로 되먹인다. */ export function shiftSegment( base: AlignmentBase, edits: AlignmentEdits, segment: AlignmentSegment, delta: number, ): AlignmentEdits { const current = buildAlignment(base, edits); const moving = shiftMovingPoints(current, segment); if (!moving) return edits; const baseS = base.basePvi.map((node) => node.chainage_m); const baseZ = base.basePvi.map((node) => node.elevation_m); const targets = moving.map((chainage) => planElevationAt(current, chainage) + delta); const offsets = { ...edits.station_offsets }; moving.forEach((chainage) => { const key = chainageKey(chainage); const seed = offsets[key] ?? planElevationAt(current, chainage) - interpolate(baseS, baseZ, chainage); offsets[key] = Number((seed + delta).toFixed(6)); }); for (let pass = 0; pass < FEEDBACK_PASSES; pass += 1) { const trial = buildAlignment(base, { ...edits, station_offsets: offsets }); let worst = 0; moving.forEach((chainage, index) => { const key = chainageKey(chainage); const error = targets[index] - planElevationAt(trial, chainage); worst = Math.max(worst, Math.abs(error)); offsets[key] = Number((offsets[key] + error).toFixed(6)); }); if (worst < FEEDBACK_TOLERANCE_M) break; } return { ...edits, station_offsets: offsets }; } /** * 곡선 행의 R 입력을 저장한다. R이 1차 값이므로 그대로 담고, 곡선길이는 * L = R × |대수차| 로 매 렌더마다 다시 계산된다(계획고를 편집해도 R은 유지된다). * 빈 값이면 키를 지워 정책 기본 반경으로 돌아간다. */ export function setCurveRadius( edits: AlignmentEdits, curve: AlignmentCurve, radiusM: number, ): AlignmentEdits { const key = chainageKey(curve.chainage_m); const curveRadii = { ...edits.curve_radii }; if (!Number.isFinite(radiusM) || radiusM <= 0) delete curveRadii[key]; else curveRadii[key] = Number(radiusM.toFixed(6)); return { ...edits, curve_radii: curveRadii }; }