사용자 지적 3건. 1. 상단 표시줄의 「절·성토 불균형」은 **종단 기준** 값이었음 — 계획선과 지반선 사이 세로 면적(㎡)의 비라 실제 토량이 아니고 판단 근거가 안 됨. 삭제함. 의미 있는 균형은 횡단 단면적을 쌓아 부피(㎥)로 내는 하단 유토곡선 요약이고 그쪽은 그대로 둠. 2. 계획고를 연속으로 누르면 유토곡선이 매번 사라졌다 다시 서서 깜빡였음. 재계산 대기 중에 곡선을 **지우지 않고 직전 것을 그대로 두며**, 요약줄 끝에 「다시 계산 중…」 칩만 붙임. 재계산이 브라우저 안에서 수십 ms에 끝나므로 화면이 비는 구간이 없어짐. 아직 한 번도 못 그렸을 때만 예전처럼 문구로 자리를 채움. 3. [쉬프트]에서 아무것도 안 골라지던 것은 **직선화된 구간이 없어서**였음(초기 계획선은 마디마다 기울기가 달라 직선 구간이 0개). 안내를 갈라 적음 — 「직선화된 구간이 없습니다 — [직선화]로 먼저 만드세요」 / 「…고르세요 (여러 개 가능) — 끝내려면 [쉬프트]를 다시 누르세요」. 직선화 안내에도 취소 방법을 덧붙임. 전체 테스트 183건 통과, `tsc --noEmit` 통과. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
280 lines
11 KiB
TypeScript
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
|
|
);
|
|
}
|