/* ============================================================================= * 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(); 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 ); }