Files
Aislo/B05_Profile/B05_Profile_UI_Profile_Straighten.ts
T
eomsangdonandClaude Opus 5 b8aa5ce014 fix(B05): 종단 기준 절·성토 불균형 표시 삭제 + 유토곡선 깜빡임 제거 + 쉬프트 안내
사용자 지적 3건.

1. 상단 표시줄의 「절·성토 불균형」은 **종단 기준** 값이었음 — 계획선과 지반선 사이 세로
   면적(㎡)의 비라 실제 토량이 아니고 판단 근거가 안 됨. 삭제함. 의미 있는 균형은 횡단
   단면적을 쌓아 부피(㎥)로 내는 하단 유토곡선 요약이고 그쪽은 그대로 둠.

2. 계획고를 연속으로 누르면 유토곡선이 매번 사라졌다 다시 서서 깜빡였음. 재계산 대기
   중에 곡선을 **지우지 않고 직전 것을 그대로 두며**, 요약줄 끝에 「다시 계산 중…」
   칩만 붙임. 재계산이 브라우저 안에서 수십 ms에 끝나므로 화면이 비는 구간이 없어짐.
   아직 한 번도 못 그렸을 때만 예전처럼 문구로 자리를 채움.

3. [쉬프트]에서 아무것도 안 골라지던 것은 **직선화된 구간이 없어서**였음(초기 계획선은
   마디마다 기울기가 달라 직선 구간이 0개). 안내를 갈라 적음 —
   「직선화된 구간이 없습니다 — [직선화]로 먼저 만드세요」 / 「…고르세요 (여러 개 가능)
   — 끝내려면 [쉬프트]를 다시 누르세요」. 직선화 안내에도 취소 방법을 덧붙임.

전체 테스트 183건 통과, `tsc --noEmit` 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 20:32:46 +09:00

280 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,
),
};
}
/**
* 지금 계획선에 **직선화된 구간이 하나라도 있는가** — [쉬프트] 안내 문구용.
*
* 초기 계획선은 모든 측점이 변화점이라 마디마다 기울기가 달라 직선 구간이 없다. 그때
* [쉬프트]로 아무 데나 눌러도 잡히는 것이 없어 「선택이 안 된다」로 보였다
* (2026-09-03 사용자 보고). 안내를 나누려면 이 판정이 필요하다.
*/
export function hasStraightRuns(alignment: ProfileAlignment): boolean {
for (let index = 1; index < alignment.segments.length; index += 1) {
if (Math.abs(gradeAt(alignment, index) - gradeAt(alignment, index - 1)) < COLLINEAR_EPSILON) {
return true;
}
}
return false;
}
/** 두 측점 사이(끝 제외)의 변화점 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
);
}