- 직선화 첫 클릭이 직선 안이면 직선 통째 고름(붉은 강조)으로 새던 갈림 지움 · 빈 곳 클릭 측점선에 붙음 - 이미 직선인 범위 = 부분 구간 고름(보라 띠 · 안내) · ▲▼ = 가운데 + 양 끝 기본 L 곡선 - 직선화 · 자유지정 끝 R 이 저장 자동 R 이면 지움 = 끝 곡선 L 20 · 사용자 R 은 둠 - 법정 미달 판정 오차 1e-3 짝(TS · 파이썬) — L 19.99998 거짓 미달 없앰 - 새 도우미 design_profile_range_band — 직선화 · 자유지정 범위 단계 실시간 띠 · 시작–끝 · 길이 글 - Esc = 시작만 취소 · 모드 유지 · 고름 풀기로 안 샘 - 시험 test_271_straighten_pick (거울 포함) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
345 lines
14 KiB
TypeScript
345 lines
14 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";
|
|
import { isStraightNode, straightAt } from "./B05_Profile_Profile_Straight";
|
|
|
|
/** 두 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) 위에서의 표고 — 편집 델타의 기준값. */
|
|
export 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;
|
|
}
|
|
|
|
/**
|
|
* 그 자리를 지나는 **최대 직선 구간**을 찾는다.
|
|
*
|
|
* 사이 꼭짓점이 직선 위(높이 기준 · 426)면 한 직선으로 본다. 전체 측점
|
|
* 폴리라인에서는 직선화하지 않은 자리마다 기울기가 달라지므로, 직선화한 구간만
|
|
* 두 측점 이상으로 이어진 직선이 된다.
|
|
*/
|
|
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;
|
|
let first = index;
|
|
let last = index;
|
|
while (first > 0 && straightAt(alignment.pvi, first)) first -= 1;
|
|
while (last < alignment.segments.length - 1 && straightAt(alignment.pvi, last + 1)) 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 {
|
|
return alignment.pvi.some((_node, index) => straightAt(alignment.pvi, index));
|
|
}
|
|
|
|
/**
|
|
* 고른 범위가 이미 한 직선이면 그 범위를 부분 구간으로 돌려준다(271-1) — [직선화] ▲▼ 로 꺾으면
|
|
* 양 끝에 기본 L 곡선. 사이 꼭짓점이 없거나 하나라도 꺾였으면 null(보통 직선화).
|
|
*/
|
|
export function straightSubRun(
|
|
alignment: ProfileAlignment,
|
|
fromChainageM: number,
|
|
toChainageM: number,
|
|
): StraightRun | null {
|
|
const fromM = Math.min(fromChainageM, toChainageM);
|
|
const toM = Math.max(fromChainageM, toChainageM);
|
|
const inside = alignment.pvi
|
|
.map((node, index) => ({ at: node.chainage_m, index }))
|
|
.filter(({ at }) => at > fromM + SAME_STATION_M && at < toM - SAME_STATION_M);
|
|
if (!inside.length || !inside.every(({ index }) => straightAt(alignment.pvi, index))) return null;
|
|
return { fromM, toM, nodes: [fromM, ...inside.map(({ at }) => at), toM] };
|
|
}
|
|
|
|
/** 두 측점 사이(끝 제외)의 변화점 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 };
|
|
}
|
|
|
|
/**
|
|
* 범위 끝 R 이 저장 자동 R 과 같으면 지운다 — 끝 곡선 = 기본 L(271-1 · ▲▼ 433-13 과 같은 규칙) ·
|
|
* 사용자 R 은 둠. `radii` 를 고쳐 쓴다.
|
|
*/
|
|
export function dropAutoEndRadii(
|
|
radii: Record<string, number>,
|
|
ends: number[],
|
|
autoRadii: Readonly<Record<string, number>>,
|
|
): void {
|
|
ends.map(chainageKey).forEach((key) => {
|
|
if (radii[key] !== undefined && radii[key] === autoRadii[key]) delete radii[key];
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 두 측점을 직선으로 잇는다 — 사이 측점을 그 직선 위로 옮기고 라운드를 지운다.
|
|
*
|
|
* 양 끝 측점의 표고는 건드리지 않는다. 그 자리에 라운드가 있으면 새 직선이 그 라운드의
|
|
* 접선이 된다(변화점 z는 그대로이고 좌우 직선만 바뀌므로 자동으로 성립한다).
|
|
* 끝 R 이 저장 자동 R(`autoRadii`)이면 지워 기본 L(271-1).
|
|
*/
|
|
export function straightenBetween(
|
|
base: AlignmentBase,
|
|
edits: AlignmentEdits,
|
|
fromChainageM: number,
|
|
toChainageM: number,
|
|
autoRadii: Readonly<Record<string, 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)];
|
|
});
|
|
const next = withOffsets(
|
|
edits,
|
|
[...updates, ...pinEnds(base, current, [fromM, toM])],
|
|
inner.map(chainageKey),
|
|
);
|
|
dropAutoEndRadii(next.curve_radii, [fromM, toM], autoRadii);
|
|
return next;
|
|
}
|
|
|
|
/**
|
|
* 범위 끝이 꼭짓점 아닌 측점(관 · 구조물 · 덧 · 곡선 측점)이면 지금 높이로 꼭짓점을 세운다(426) —
|
|
* 안 세우면 끝 높이가 옮긴 안쪽 점을 따라 바뀌어 직선이 다음 꼭짓점부터 시작했다.
|
|
*/
|
|
export function pinEnds(
|
|
base: AlignmentBase,
|
|
current: ProfileAlignment,
|
|
ends: number[],
|
|
): Array<[string, number]> {
|
|
return ends
|
|
.filter((at) => !current.pvi.some((node) => Math.abs(node.chainage_m - at) < SAME_STATION_M))
|
|
.map((at) => [chainageKey(at), controlElevationAt(current, at) - baseElevationAt(base, at)]);
|
|
}
|
|
|
|
/**
|
|
* 편집 정리(426) — 직선 위 꼭짓점의 기억 R 을 지우고, 0 으로 돌아와 모양을 안 바꾸는 오프셋을
|
|
* 지운다(자동 꼭짓점 자리 · 또는 직선 위). 모양은 그대로 — 적용 한 곳(`applyEdits`)이 부른다.
|
|
*/
|
|
export function tidyEdits(base: AlignmentBase, edits: AlignmentEdits): AlignmentEdits {
|
|
const current = buildAlignment(base, edits);
|
|
const pvi = current.pvi;
|
|
const straight = new Set(
|
|
pvi
|
|
.filter((_node, index) => straightAt(pvi, index))
|
|
.map((node) => chainageKey(node.chainage_m)),
|
|
);
|
|
const auto = new Set(base.basePvi.map((node) => chainageKey(node.chainage_m)));
|
|
const offsets = { ...current.edits.station_offsets };
|
|
const radii = { ...current.edits.curve_radii };
|
|
Object.keys(radii).forEach((key) => straight.has(key) && delete radii[key]);
|
|
Object.entries(offsets).forEach(([key, value]) => {
|
|
if (Math.abs(value) < 1e-6 && (auto.has(key) || straight.has(key))) delete offsets[key];
|
|
});
|
|
return { station_offsets: offsets, curve_radii: radii };
|
|
}
|
|
|
|
/** 정책에서 라운드 기본 길이 L(m)을 읽는다 — 옛 저장분은 R 기준으로 되돌아간다. */
|
|
export 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);
|
|
// 지금 경첩 높이 + 걸음(426) — 처음 직선 위 높이로 다시 잡으면 ▲ 두 번째가 안 오르고 ▼ 가 두 칸
|
|
const pivotZ = controlElevationAt(current, pivot) + 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);
|
|
});
|
|
// 부분 구간(271-1) 끝이 꼭짓점 아닌 측점이면 지금 높이로 세움 — 끝이 따라 움직이지 않게
|
|
updates.push(...pinEnds(base, current, [run.fromM, run.toM]));
|
|
|
|
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),
|
|
);
|
|
const straightPivot = isStraightNode(
|
|
{ chainage_m: run.fromM, elevation_m: startZ },
|
|
{ chainage_m: pivot, elevation_m: pivotZ },
|
|
{ chainage_m: run.toM, elevation_m: endZ },
|
|
);
|
|
if (straightPivot) {
|
|
const curveRadii = { ...next.curve_radii };
|
|
delete curveRadii[chainageKey(pivot)];
|
|
return { ...next, curve_radii: curveRadii };
|
|
}
|
|
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
|
|
);
|
|
}
|