Files
Aislo/B05_Profile/B05_Profile_UI_Profile_Straighten.ts
T
eomsangdonandClaude Opus 5 3651ad9875 feat(B05): 종단 계획선 전체 측점 폴리라인 전환 + 직선화·쉬프트·undo/redo 신설
2026-09-02 사용자 지시 9건 반영. 화면 실조작 검증은 다음 세션 몫 (사용자 지시로
코딩까지만 진행).

1. 초기 계획선 = 전체 측점 폴리라인 — `design_ground_following_profile()` 신설.
   모든 측점을 변화점으로 잡고 계획고 = 원지반고, 라운드는 R을 지정한 자리에만
   (`build_curves(only_explicit=)`·`build_alignment(only_explicit_curves=)`).
   basis `ground_polyline`. 관 정착 선형은 폴백으로 내림.
2. 구간 쉬프트(⬆⬇) 삭제.
3. [직선화] 신설 — `B05_Profile_UI_Profile_Straighten.ts`. 두 측점의 라운드에 탄젠트한
   직선으로 대체하고 사이 라운드 삭제. 직선 틸팅 시 가운데 라운드 + 양측 탄젠트 재구성.
4. [쉬프트] 신설 — 직선 구간을 기하에서 되읽어(`detectStraightRun`) 복수 선택,
   최외곽 라운드 중심 기준 상·하 평행이동.
5. 방향키 조작 — 상하 0.1m 계획고, 좌우 0.1m 누가거리(구조물·비정규 측점 한정).
6. undo/redo 신설 — B05·B06 조작 세션 키 묶음 스냅샷(`_Profile_History.ts`).
   버튼은 요약줄 맨 앞(최대 기울기 좌측), 21x17px. Ctrl+Z / Ctrl+Shift+Z.
7. 종단 요약줄의 횡단배수 최소고 표시 삭제(산식·편집 차단은 유지).
8. [편집 되돌리기] 버튼 삭제 — undo/redo로 대체.
9. 지형 구분 기본값 특수지형 — 패널 셀렉트와 백엔드 기본값(스키마·체인 폴백) 일치.

700줄 제한 — 패널을 `_Profile_Panel_Tools` · `_Profile_Preview` 로 분리하고 측점↔구조물
짝짓기를 `_Profile_Structures` 로 이관(842줄 → 696줄).

검증: tsc --noEmit 오류 0, ruff format/check 통과,
pytest tmp/tests/ -q → 366 passed / 14 skipped / 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 19:30:16 +09:00

264 lines
11 KiB
TypeScript

/* =============================================================================
* B05_Profile_UI_Profile_Straighten.ts
* 종단 계획선 [직선화]·[쉬프트] 기하 (2026-09-02 사용자 지시).
*
* 초기 계획선이 **전체 측점 폴리라인**(계획고 = 지반고)으로 바뀌면서, 구간을 다루는
* 조작은 두 가지가 된다.
*
* [직선화] 측점 2개를 고르면 두 측점의 라운드에 **탄젠트한 직선**으로 잇는다.
* 사이에 있던 라운드는 전부 지운다. 두 끝 측점은 변화점으로 남아 있어
* 그 자리 라운드가 직선의 접선 역할을 한다.
* [쉬프트] 직선화된 라인을 고르면(복수 가능) 그 폴리라인 전체를 위·아래로 옮긴다.
* 직선 내부는 한 줄이므로 평행이동 = **최외곽 라운드 중심을 같이 옮기는 것**과
* 같고, 바깥 고정점과는 새 직선으로 다시 이어진다.
*
* 직선 구간은 따로 저장하지 않고 **기하에서 되읽는다**(`detectStraightRun`) — 편집
* 델타 스키마(`station_offsets`·`curve_radii`)를 그대로 두어 서버 계약을 건드리지 않는다.
* ========================================================================== */
import type {
AlignmentBase,
AlignmentEdits,
ProfileAlignment,
} from "./B05_Profile_UI_Profile_Alignment";
import {
buildAlignment,
chainageKey,
controlElevationAt,
} from "./B05_Profile_UI_Profile_Alignment";
/** 같은 직선 위에 있다고 볼 기울기 차이(무차원). 0.1m 편집 단위의 반올림 오차보다 크게 둔다. */
const COLLINEAR_EPSILON = 1e-6;
/** 두 chainage를 같은 측점으로 볼 허용 오차(m). */
const SAME_STATION_M = 1e-6;
/** 직선화된 한 구간 — 양 끝 변화점과 그 사이 측점들. */
export interface StraightRun {
fromM: number;
toM: number;
/** 양 끝을 포함한 구간 안 변화점 chainage (오름차순). */
nodes: number[];
}
/** 현재 선형의 변화점 chainage 목록 (오름차순). */
function pviChainages(alignment: ProfileAlignment): number[] {
return alignment.pvi.map((node) => node.chainage_m);
}
/** 자동 선형(base_pvi) 위에서의 표고 — 편집 델타의 기준값. */
function baseElevationAt(base: AlignmentBase, chainageM: number): number {
const nodes = base.basePvi;
if (!nodes.length) return 0;
if (chainageM <= nodes[0].chainage_m) return nodes[0].elevation_m;
const last = nodes[nodes.length - 1];
if (chainageM >= last.chainage_m) return last.elevation_m;
for (let index = 1; index < nodes.length; index += 1) {
if (nodes[index].chainage_m < chainageM) continue;
const previous = nodes[index - 1];
const current = nodes[index];
const span = current.chainage_m - previous.chainage_m;
const ratio = span > 1e-12 ? (chainageM - previous.chainage_m) / span : 0;
return previous.elevation_m + (current.elevation_m - previous.elevation_m) * ratio;
}
return last.elevation_m;
}
/** 변화점 사이 기울기 (index 번째 구간). */
function gradeAt(alignment: ProfileAlignment, index: number): number {
const segment = alignment.segments[index];
if (!segment || segment.length_m <= 1e-12) return 0;
return segment.height_m / segment.length_m;
}
/**
* 그 자리를 지나는 **최대 직선 구간**을 찾는다.
*
* 인접 구간의 기울기가 같으면(= 변화점에 꺾임이 없으면) 한 직선으로 본다. 전체 측점
* 폴리라인에서는 직선화하지 않은 자리마다 기울기가 달라지므로, 직선화한 구간만
* 두 측점 이상으로 이어진 직선이 된다.
*/
export function detectStraightRun(
alignment: ProfileAlignment,
chainageM: number,
): StraightRun | null {
const nodes = pviChainages(alignment);
if (nodes.length < 2) return null;
// 클릭 지점을 포함하는 구간 index
let index = alignment.segments.findIndex(
(segment) =>
chainageM >= segment.from_m - SAME_STATION_M && chainageM <= segment.to_m + SAME_STATION_M,
);
if (index < 0) return null;
const grade = gradeAt(alignment, index);
let first = index;
let last = index;
while (first > 0 && Math.abs(gradeAt(alignment, first - 1) - grade) < COLLINEAR_EPSILON) {
first -= 1;
}
while (
last < alignment.segments.length - 1 &&
Math.abs(gradeAt(alignment, last + 1) - grade) < COLLINEAR_EPSILON
) {
last += 1;
}
// 한 구간(측점 두 개)뿐이면 직선화된 라인이 아니라 그냥 폴리라인 한 마디다.
if (last === first) return null;
const fromM = alignment.segments[first].from_m;
const toM = alignment.segments[last].to_m;
return {
fromM,
toM,
nodes: nodes.filter(
(value) => value >= fromM - SAME_STATION_M && value <= toM + SAME_STATION_M,
),
};
}
/** 두 측점 사이(끝 제외)의 변화점 chainage. */
function interiorNodes(alignment: ProfileAlignment, fromM: number, toM: number): number[] {
return pviChainages(alignment).filter(
(value) => value > fromM + SAME_STATION_M && value < toM - SAME_STATION_M,
);
}
/** 편집 델타에 측점 오프셋을 써 넣는다(기존 객체는 건드리지 않는다). */
function withOffsets(
edits: AlignmentEdits,
updates: Array<[string, number]>,
dropRadiusKeys: string[] = [],
): AlignmentEdits {
const stationOffsets = { ...edits.station_offsets };
const curveRadii = { ...edits.curve_radii };
updates.forEach(([key, value]) => {
stationOffsets[key] = Number(value.toFixed(6));
});
dropRadiusKeys.forEach((key) => delete curveRadii[key]);
return { station_offsets: stationOffsets, curve_radii: curveRadii };
}
/**
* 두 측점을 직선으로 잇는다 — 사이 측점을 그 직선 위로 옮기고 라운드를 지운다.
*
* 양 끝 측점의 표고는 건드리지 않는다. 그 자리에 라운드가 있으면 새 직선이 그 라운드의
* 접선이 된다(변화점 z는 그대로이고 좌우 직선만 바뀌므로 자동으로 성립한다).
*/
export function straightenBetween(
base: AlignmentBase,
edits: AlignmentEdits,
fromChainageM: number,
toChainageM: number,
): AlignmentEdits {
const fromM = Math.min(fromChainageM, toChainageM);
const toM = Math.max(fromChainageM, toChainageM);
const span = toM - fromM;
if (span <= SAME_STATION_M) return edits;
const current = buildAlignment(base, edits);
const startZ = controlElevationAt(current, fromM);
const endZ = controlElevationAt(current, toM);
const inner = interiorNodes(current, fromM, toM);
if (!inner.length) return edits;
const updates: Array<[string, number]> = inner.map((chainage) => {
const target = startZ + ((endZ - startZ) * (chainage - fromM)) / span;
return [chainageKey(chainage), target - baseElevationAt(base, chainage)];
});
return withOffsets(edits, updates, inner.map(chainageKey));
}
/** 정책에서 라운드 기본 길이 L(m)을 읽는다 — 옛 저장분은 R 기준으로 되돌아간다. */
function defaultCurveLength(base: AlignmentBase, deltaGrade: number): number {
const length = base.policy.default_curve_length_m;
if (typeof length === "number" && Number.isFinite(length) && length > 0) return length;
return base.policy.default_curve_radius_m * Math.abs(deltaGrade);
}
/**
* 직선화된 구간을 위·아래로 꺾는다 — **가운데 측점에 라운드를 넣고 양쪽에 탄젠트 직선**.
*
* 가운데 측점을 delta만큼 옮기고, 그 좌우를 각각 새 직선 위에 다시 올린다. 라운드는
* 그 변화점에만 생기며(전체 측점 폴리라인은 R을 지정한 자리에만 라운드를 만든다),
* R은 기본 곡선길이 L에서 R = L / |대수차| 로 역산해 둔다.
*/
export function tiltStraightRun(
base: AlignmentBase,
edits: AlignmentEdits,
run: StraightRun,
delta: number,
): AlignmentEdits {
const current = buildAlignment(base, edits);
const inner = interiorNodes(current, run.fromM, run.toM);
if (!inner.length) return edits;
const center = (run.fromM + run.toM) / 2;
const pivot = inner.reduce((best, chainage) =>
Math.abs(chainage - center) < Math.abs(best - center) ? chainage : best,
);
const startZ = controlElevationAt(current, run.fromM);
const endZ = controlElevationAt(current, run.toM);
const span = run.toM - run.fromM;
const pivotZ =
startZ + ((endZ - startZ) * (pivot - run.fromM)) / (span > 1e-12 ? span : 1) + delta;
const updates: Array<[string, number]> = [
[chainageKey(pivot), pivotZ - baseElevationAt(base, pivot)],
];
const place = (chainage: number, aM: number, aZ: number, bM: number, bZ: number): void => {
const width = bM - aM;
if (width <= 1e-12) return;
const target = aZ + ((bZ - aZ) * (chainage - aM)) / width;
updates.push([chainageKey(chainage), target - baseElevationAt(base, chainage)]);
};
inner.forEach((chainage) => {
if (Math.abs(chainage - pivot) < SAME_STATION_M) return;
if (chainage < pivot) place(chainage, run.fromM, startZ, pivot, pivotZ);
else place(chainage, pivot, pivotZ, run.toM, endZ);
});
const gradeIn = (pivotZ - startZ) / Math.max(pivot - run.fromM, 1e-12);
const gradeOut = (endZ - pivotZ) / Math.max(run.toM - pivot, 1e-12);
const deltaGrade = Math.abs(gradeOut - gradeIn);
const next = withOffsets(
edits,
updates,
inner.filter((chainage) => Math.abs(chainage - pivot) >= SAME_STATION_M).map(chainageKey),
);
if (deltaGrade < 1e-9) return next;
return {
...next,
curve_radii: {
...next.curve_radii,
[chainageKey(pivot)]: Number((defaultCurveLength(base, deltaGrade) / deltaGrade).toFixed(6)),
},
};
}
/**
* 고른 직선 구간들을 통째로 위·아래로 옮긴다.
*
* 직선 내부는 한 줄이므로 구간 안 변화점을 모두 같은 델타로 옮기는 것이 곧
* **최외곽 라운드 중심을 옮기는 것**이다(사이 점들은 그 사이에 그대로 실려 간다).
* 바깥 고정점(BP·EP·다른 변화점)과는 새 기울기의 직선으로 다시 이어진다.
*/
export function shiftStraightRuns(
base: AlignmentBase,
edits: AlignmentEdits,
runs: StraightRun[],
delta: number,
): AlignmentEdits {
if (!runs.length) return edits;
const current = buildAlignment(base, edits);
const moving = new Set<number>();
runs.forEach((run) => run.nodes.forEach((chainage) => moving.add(chainage)));
const updates: Array<[string, number]> = [...moving].map((chainage) => {
const target = controlElevationAt(current, chainage) + delta;
return [chainageKey(chainage), target - baseElevationAt(base, chainage)];
});
return withOffsets(edits, updates);
}
/** 같은 구간인지 — 선택 목록에서 중복을 걸러낼 때 쓴다. */
export function sameRun(left: StraightRun, right: StraightRun): boolean {
return (
Math.abs(left.fromM - right.fromM) < SAME_STATION_M &&
Math.abs(left.toM - right.toM) < SAME_STATION_M
);
}