Files
Aislo/B05_Profile/logic/B05_Profile_UI_Profile_Alignment_Edit.ts
T

101 lines
4.3 KiB
TypeScript

/* =============================================================================
* 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 };
}