101 lines
4.3 KiB
TypeScript
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 };
|
|
}
|