diff --git a/A00_Common/design/design_profile.ts b/A00_Common/design/design_profile.ts index 45cf43496..8cbb093ff 100644 --- a/A00_Common/design/design_profile.ts +++ b/A00_Common/design/design_profile.ts @@ -71,6 +71,7 @@ import { } from "../../B05_Profile/B05_Profile_UI_Profile_Drag"; import { planCurveWarning, sampleAt } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Guard"; import { listedBase } from "./design_profile_base"; +import { tidyEdits } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Straighten"; import * as pagePipeline from "./design_pipeline_page"; import type { ElevationWindowResult } from "@util/common_util_chart_ywindow"; import { frameOf } from "@util/common_util_view_window"; @@ -303,6 +304,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { /** 편집을 적용한다. 법정 위반 정책이 block이면 새 위반이 생기는 편집을 막는다. false = 막힘. */ function applyEdits(next: AlignmentEdits): boolean { if (!base || !alignment) return false; + next = tidyEdits(base, next); // 직선 위 R · 0 오프셋 지움 · 모양 그대로(426) const candidate = buildAlignment(base, next); if ( base.policy.grade_violation_policy === "block" && diff --git a/A00_Common/design/logic/design_profile_free.ts b/A00_Common/design/logic/design_profile_free.ts index c658fca63..77d0ae4a9 100644 --- a/A00_Common/design/logic/design_profile_free.ts +++ b/A00_Common/design/logic/design_profile_free.ts @@ -27,7 +27,9 @@ import { import { baseElevationAt, defaultCurveLength, + pinEnds, } from "../../../B05_Profile/logic/B05_Profile_UI_Profile_Straighten"; +import { isStraightNode } from "../../../B05_Profile/logic/B05_Profile_Profile_Straight"; /** 두 chainage 를 같은 측점으로 볼 허용 오차(m). */ const SAME_M = 1e-6; @@ -131,7 +133,8 @@ export function buildFreeEdits( const toM = path[path.length - 1].chainage_m; if (toM - fromM <= SAME_M) return edits; const inner = new Map(); - buildAlignment(base, edits).pvi.forEach((node) => { + const current = buildAlignment(base, edits); + current.pvi.forEach((node) => { if (node.chainage_m > fromM + SAME_M && node.chainage_m < toM - SAME_M) { inner.set(chainageKey(node.chainage_m), node.chainage_m); } @@ -141,6 +144,10 @@ export function buildFreeEdits( .forEach((vertex) => inner.set(chainageKey(vertex.chainage_m), vertex.chainage_m)); const offsets = { ...edits.station_offsets }; const radii = { ...edits.curve_radii }; + // 끝이 꼭짓점 아닌 측점이면 지금 높이로 꼭짓점(426) — 직선화와 같음 + pinEnds(base, current, [fromM, toM]).forEach(([key, value]) => { + offsets[key] = Number(value.toFixed(6)); + }); inner.forEach((chainage, key) => { const target = elevationOnPath(path, chainage); offsets[key] = Number((target - baseElevationAt(base, chainage)).toFixed(6)); @@ -150,7 +157,7 @@ export function buildFreeEdits( const gradeIn = gradePct(path[index - 1], path[index]) / 100; const gradeOut = gradePct(path[index], path[index + 1]) / 100; const deltaGrade = Math.abs(gradeOut - gradeIn); - if (deltaGrade < 1e-9) continue; + if (isStraightNode(path[index - 1], path[index], path[index + 1])) continue; radii[chainageKey(path[index].chainage_m)] = Number( (defaultCurveLength(base, deltaGrade) / deltaGrade).toFixed(6), ); diff --git a/B05_Profile/B05_Profile_Engine_Grade_Alignment.py b/B05_Profile/B05_Profile_Engine_Grade_Alignment.py index d5e0f1223..9cba2cb6f 100644 --- a/B05_Profile/B05_Profile_Engine_Grade_Alignment.py +++ b/B05_Profile/B05_Profile_Engine_Grade_Alignment.py @@ -13,6 +13,9 @@ 수치 최적화(직선 분할 DP·표고 결정)는 [[B05_Profile_Engine_Grade_Solver]]가, 기준 해석과 오케스트레이션은 [[B05_Profile_Engine_Grade]]가 담당한다. 이 모듈은 scipy에 의존하지 않는 순수 기하 계산만 두어 프론트엔드 구현과 1:1로 대응시킨다. + +직선 판정 짝: `is_straight_bend` · `STRAIGHT_HEIGHT_M` ↔ TS `logic/B05_Profile_Profile_Straight.ts` +(거울 시험 `test_426_profile_straight.py`). """ from dataclasses import dataclass @@ -27,6 +30,17 @@ ALIGNMENT_SCHEMA_VERSION = 1 _KEY_DECIMALS = 3 +# 직선으로 볼 꼭짓점 높이 어긋남(m) — TS `STRAIGHT_HEIGHT_M` 과 같은 값(426). +STRAIGHT_HEIGHT_M = 1e-5 + + +def is_straight_bend(delta: float, span_left: float, span_right: float) -> bool: + """꼭짓점이 이웃 둘 잇는 선에서 `STRAIGHT_HEIGHT_M` 안이면 직선 — 높이 = |Δ|·a·b/(a+b).""" + if not (span_left > 0 and span_right > 0): + return True + return abs(delta) * span_left * span_right / (span_left + span_right) < STRAIGHT_HEIGHT_M + + def chainage_key(value: float) -> str: """chainage를 편집 델타 dict의 키 문자열로 바꾼다(프론트와 동일 규칙).""" return f"{float(value):.{_KEY_DECIMALS}f}" @@ -303,8 +317,8 @@ def build_curves( delta = grade_out - grade_in chainage = float(pvi_s[index]) key = chainage_key(chainage) - if abs(delta) < 1e-9: - continue + if is_straight_bend(delta, float(spans[index - 1]), float(spans[index])): + continue # 직선 판정 한 벌(426) if only_explicit and overrides.get(key) is None: continue # 법정 다-(3)-(다)는 "종단곡선을 두지 않을 수 있다"는 허용 조항이다. diff --git a/B05_Profile/B05_Profile_UI_Profile_Balance.ts b/B05_Profile/B05_Profile_UI_Profile_Balance.ts index 03013d8e5..5cbe53030 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Balance.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Balance.ts @@ -16,6 +16,7 @@ import type { ProfileAlignment } from "./logic/B05_Profile_UI_Profile_Alignment"; import type { MinCoverViolation } from "./B05_Profile_UI_Profile_MinCover"; +import { bentEditCount } from "./logic/B05_Profile_Profile_Straight"; export interface BalanceBarParams { /** 표시줄 컨테이너. 그릴 때마다 통째로 갈아 끼운다. */ @@ -99,7 +100,7 @@ export function renderBalanceBar(params: BalanceBarParams): void { if (curvesNeeded) entries.push(["종단곡선 필요", `${curvesNeeded} 곳`, "over"]); // 횡단배수 최소고 표시는 2026-09-02 사용자 지시로 삭제했다. 편집 차단(강제)은 // `B05_Profile_UI_Profile_Render.ts` 에 그대로 남아 있고 기본 해제다. - const editedCount = Object.keys(alignment.edits.station_offsets).length; + const editedCount = bentEditCount(alignment); // 꺾인 편집 꼭짓점만(426) if (editedCount) entries.push(["편집 측점", `${editedCount} 개`, "edited"]); entries.forEach(([label, value, tone]) => { const item = document.createElement("span"); diff --git a/B05_Profile/logic/B05_Profile_Profile_Straight.ts b/B05_Profile/logic/B05_Profile_Profile_Straight.ts new file mode 100644 index 000000000..26ec4f71e --- /dev/null +++ b/B05_Profile/logic/B05_Profile_Profile_Straight.ts @@ -0,0 +1,56 @@ +/* ============================================================================= + * B05_Profile_Profile_Straight.ts + * 종단 「직선인가」 판정 한 벌(426 · 분석 424 6장 · 10장) — 꼭짓점이 이웃 둘을 잇는 선에서 + * `STRAIGHT_HEIGHT_M` 안이면 직선(꺾임 아님). + * + * 짝: `B05_Profile_Engine_Grade_Alignment.py` `is_straight_bend` · `STRAIGHT_HEIGHT_M` — + * 거울 시험 `test_426_profile_straight.py`. + * + * 기울기 정확 비교(1e-9 · 1e-6)는 꼭짓점 높이를 소수 여섯째 자리로 끊은 값(±5e-7 m)이 측점 + * 간격 1 m 안(구조물 측점)에서 기울기 차로 커져 직선을 꺾임으로 읽었다 — 높이로 잰다. + * 부르는 곳: 곡선 넣기(`buildCurves`) · ▲▼ 축(`pivotAxes`) · 직선 찾기(`detectStraightRun`) · + * 편집 정리(`tidyEdits`) · 편집 측점 수. 표의 대수차 빈칸은 안전망으로 따로 둔다. + * ========================================================================== */ + +import type { AlignmentNode, ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment"; + +/** 직선으로 볼 꼭짓점 높이 어긋남(m) — 파이썬 `STRAIGHT_HEIGHT_M` 과 같은 값. */ +export const STRAIGHT_HEIGHT_M = 1e-5; + +/** 대수차 · 좌우 마디 길이로 본 직선 — 꼭짓점이 이웃 잇는 선에서 떨어진 높이 = |Δ|·a·b/(a+b). */ +export function isStraightBend(delta: number, spanLeft: number, spanRight: number): boolean { + const span = spanLeft + spanRight; + if (!(spanLeft > 0 && spanRight > 0)) return true; + return (Math.abs(delta) * spanLeft * spanRight) / span < STRAIGHT_HEIGHT_M; +} + +/** 세 꼭짓점의 가운데가 직선 위인가. */ +export function isStraightNode( + prev: AlignmentNode, + node: AlignmentNode, + next: AlignmentNode, +): boolean { + const spanLeft = node.chainage_m - prev.chainage_m; + const spanRight = next.chainage_m - node.chainage_m; + if (!(spanLeft > 0 && spanRight > 0)) return true; + const delta = + (next.elevation_m - node.elevation_m) / spanRight - + (node.elevation_m - prev.elevation_m) / spanLeft; + return isStraightBend(delta, spanLeft, spanRight); +} + +/** index 번째 꼭짓점이 직선 위인가 — 시종점은 꺾임으로 본다(false). */ +export function straightAt(pvi: ReadonlyArray, index: number): boolean { + if (index <= 0 || index >= pvi.length - 1) return false; + return isStraightNode(pvi[index - 1], pvi[index], pvi[index + 1]); +} + +/** 「편집 측점 N개」 — 높이를 고친 꼭짓점 중 실제로 꺾인 것(시종점은 오프셋이 있으면). */ +export function bentEditCount(alignment: ProfileAlignment): number { + const last = alignment.pvi.length - 1; + return alignment.pvi.filter((node, index) => { + if (node.source !== "user") return false; + if (index === 0 || index === last) return true; + return !straightAt(alignment.pvi, index); + }).length; +} diff --git a/B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts b/B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts index 1612a8dca..9462f9b31 100644 --- a/B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts +++ b/B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts @@ -14,6 +14,7 @@ * ========================================================================== */ import { pivotAxes } from "./B05_Profile_UI_Profile_Pivot"; +import { isStraightBend } from "./B05_Profile_Profile_Straight"; export interface AlignmentPolicy { station_interval_m: number; @@ -305,7 +306,7 @@ export function buildCurves( const gradeIn = (pvi[index].elevation_m - pvi[index - 1].elevation_m) / spanLeft; const gradeOut = (pvi[index + 1].elevation_m - pvi[index].elevation_m) / spanRight; const delta = gradeOut - gradeIn; - if (Math.abs(delta) < 1e-9) continue; + if (isStraightBend(delta, spanLeft, spanRight)) continue; // 직선 판정 한 벌(426) const chainage = pvi[index].chainage_m; const key = chainageKey(chainage); const skipAllowed = !policy.paved && Math.abs(delta) <= skipDelta + 1e-12; diff --git a/B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts b/B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts index 6afe531e8..0977a27c3 100644 --- a/B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts +++ b/B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts @@ -10,9 +10,8 @@ * ========================================================================== */ import type { ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment"; +import { straightAt } from "./B05_Profile_Profile_Straight"; -/** 같은 직선으로 볼 기울기 차이 — `B05_Profile_UI_Profile_Straighten` 과 같은 값. */ -const COLLINEAR_EPSILON = 1e-6; const SAME_STATION_M = 1e-6; export interface PivotAxes { @@ -26,21 +25,17 @@ export interface PivotAxes { /** 누른 점의 두 축과 사이 변화점 — 시종점 · 계획선 밖이면 null. */ export function pivotAxes(alignment: ProfileAlignment, chainageM: number): PivotAxes | null { - const { pvi, segments } = alignment; + const { pvi } = alignment; const index = pvi.findIndex((node) => Math.abs(node.chainage_m - chainageM) < SAME_STATION_M); // 꼭짓점 아닌 자리 = 그 점을 품은 마디 하나가 좌우 마디 const right = index >= 0 ? index : pvi.findIndex((node) => node.chainage_m > chainageM); if (right <= 0 || (index >= 0 && index >= pvi.length - 1)) return null; const [leftSeg, rightSeg] = index >= 0 ? [index - 1, index] : [right - 1, right - 1]; - const grade = (at: number): number => { - const segment = segments[at]; - return segment && segment.length_m > 1e-12 ? segment.height_m / segment.length_m : 0; - }; - const same = (a: number, b: number): boolean => Math.abs(grade(a) - grade(b)) < COLLINEAR_EPSILON; + // 이웃끼리 — 꼭짓점이 직선 위(높이 기준 · 426)면 건넘 let from = leftSeg; - while (from > 0 && same(from - 1, leftSeg)) from -= 1; + while (from > 0 && straightAt(pvi, from)) from -= 1; let to = rightSeg + 1; - while (to < pvi.length - 1 && same(to, rightSeg)) to += 1; + while (to < pvi.length - 1 && straightAt(pvi, to)) to += 1; return { fromM: pvi[from].chainage_m, fromZ: pvi[from].elevation_m, diff --git a/B05_Profile/logic/B05_Profile_UI_Profile_Straighten.ts b/B05_Profile/logic/B05_Profile_UI_Profile_Straighten.ts index 74bc054e7..843024404 100644 --- a/B05_Profile/logic/B05_Profile_UI_Profile_Straighten.ts +++ b/B05_Profile/logic/B05_Profile_UI_Profile_Straighten.ts @@ -26,9 +26,8 @@ import { chainageKey, controlElevationAt, } from "./B05_Profile_UI_Profile_Alignment"; +import { isStraightNode, straightAt } from "./B05_Profile_Profile_Straight"; -/** 같은 직선 위에 있다고 볼 기울기 차이(무차원). 0.1m 편집 단위의 반올림 오차보다 크게 둔다. */ -const COLLINEAR_EPSILON = 1e-6; /** 두 chainage를 같은 측점으로 볼 허용 오차(m). */ const SAME_STATION_M = 1e-6; @@ -63,17 +62,10 @@ export function baseElevationAt(base: AlignmentBase, chainageM: number): number 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; -} - /** * 그 자리를 지나는 **최대 직선 구간**을 찾는다. * - * 인접 구간의 기울기가 같으면(= 변화점에 꺾임이 없으면) 한 직선으로 본다. 전체 측점 + * 사이 꼭짓점이 직선 위(높이 기준 · 426)면 한 직선으로 본다. 전체 측점 * 폴리라인에서는 직선화하지 않은 자리마다 기울기가 달라지므로, 직선화한 구간만 * 두 측점 이상으로 이어진 직선이 된다. */ @@ -89,18 +81,10 @@ export function detectStraightRun( 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; - } + 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; @@ -122,12 +106,7 @@ export function detectStraightRun( * (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; + return alignment.pvi.some((_node, index) => straightAt(alignment.pvi, index)); } /** 두 측점 사이(끝 제외)의 변화점 chainage. */ @@ -177,7 +156,47 @@ export function straightenBetween( const target = startZ + ((endZ - startZ) * (chainage - fromM)) / span; return [chainageKey(chainage), target - baseElevationAt(base, chainage)]; }); - return withOffsets(edits, updates, inner.map(chainageKey)); + return withOffsets( + edits, + [...updates, ...pinEnds(base, current, [fromM, toM])], + inner.map(chainageKey), + ); +} + +/** + * 범위 끝이 꼭짓점 아닌 측점(관 · 구조물 · 덧 · 곡선 측점)이면 지금 높이로 꼭짓점을 세운다(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 기준으로 되돌아간다. */ @@ -209,9 +228,8 @@ export function tiltStraightRun( ); 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; + // 지금 경첩 높이 + 걸음(426) — 처음 직선 위 높이로 다시 잡으면 ▲ 두 번째가 안 오르고 ▼ 가 두 칸 + const pivotZ = controlElevationAt(current, pivot) + delta; const updates: Array<[string, number]> = [ [chainageKey(pivot), pivotZ - baseElevationAt(base, pivot)], @@ -236,7 +254,16 @@ export function tiltStraightRun( updates, inner.filter((chainage) => Math.abs(chainage - pivot) >= SAME_STATION_M).map(chainageKey), ); - if (deltaGrade < 1e-9) return next; + 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: { diff --git a/resources/tester/helper_234_1_structure_station.cjs b/resources/tester/helper_234_1_structure_station.cjs index 1ee3da8d5..c599bdb60 100644 --- a/resources/tester/helper_234_1_structure_station.cjs +++ b/resources/tester/helper_234_1_structure_station.cjs @@ -159,9 +159,13 @@ Promise.resolve( out.second = { count: snap.snapOldStations(again), toasts: [...toasts] }; // ④ 계획고 편집 따라감 — 한꺼번에 여럿 옮김(사라진 둘 · 새 둘) - const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts"); + const straightJudge = loadTs("B05_Profile/logic/B05_Profile_Profile_Straight.ts"); + const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts", { + "./B05_Profile_Profile_Straight": straightJudge, + }); const alignment = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts", { "./B05_Profile_UI_Profile_Pivot": pivot, + "./B05_Profile_Profile_Straight": straightJudge, }); const gone = [ { id: "pipe-80.17", chainage_m: 80.17 }, diff --git a/resources/tester/helper_344_2_route_pairs.cjs b/resources/tester/helper_344_2_route_pairs.cjs index 222cbd91c..275b27073 100644 --- a/resources/tester/helper_344_2_route_pairs.cjs +++ b/resources/tester/helper_344_2_route_pairs.cjs @@ -20,9 +20,13 @@ function loadTs(file, stubs = {}) { return box.exports; } -const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts"); +const straightJudge = loadTs("B05_Profile/logic/B05_Profile_Profile_Straight.ts"); +const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts", { + "./B05_Profile_Profile_Straight": straightJudge, +}); const alignment = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts", { "./B05_Profile_UI_Profile_Pivot": pivot, + "./B05_Profile_Profile_Straight": straightJudge, }); const ground = loadTs("B05_Profile/logic/B05_Profile_Grade_Ground.ts", { "./B05_Profile_UI_Profile_Alignment": alignment, diff --git a/resources/tester/helper_349_0v_span.cjs b/resources/tester/helper_349_0v_span.cjs index 571141758..473788435 100644 --- a/resources/tester/helper_349_0v_span.cjs +++ b/resources/tester/helper_349_0v_span.cjs @@ -21,9 +21,13 @@ function loadTs(file, stubs = {}) { } const curve = loadTs("B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve.ts"); -const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts"); +const straightJudge = loadTs("B05_Profile/logic/B05_Profile_Profile_Straight.ts"); +const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts", { + "./B05_Profile_Profile_Straight": straightJudge, +}); const alignment = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts", { "./B05_Profile_UI_Profile_Pivot": pivot, + "./B05_Profile_Profile_Straight": straightJudge, }); const carry = loadTs("B05_Profile/logic/B05_Profile_Grade_Carry.ts", { "./B05_Profile_UI_Profile_Alignment": alignment, diff --git a/resources/tester/helper_400_profile_carry.cjs b/resources/tester/helper_400_profile_carry.cjs index 1416357d7..758fd809a 100644 --- a/resources/tester/helper_400_profile_carry.cjs +++ b/resources/tester/helper_400_profile_carry.cjs @@ -20,9 +20,13 @@ function loadTs(file, stubs = {}) { return box.exports; } -const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts"); +const straightJudge = loadTs("B05_Profile/logic/B05_Profile_Profile_Straight.ts"); +const pivot = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts", { + "./B05_Profile_Profile_Straight": straightJudge, +}); const alignment = loadTs("B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts", { "./B05_Profile_UI_Profile_Pivot": pivot, + "./B05_Profile_Profile_Straight": straightJudge, }); const carry = loadTs("B05_Profile/logic/B05_Profile_Grade_Carry.ts", { "./B05_Profile_UI_Profile_Alignment": alignment, diff --git a/resources/tester/helper_426_profile_straight.cjs b/resources/tester/helper_426_profile_straight.cjs new file mode 100644 index 000000000..e94097ecd --- /dev/null +++ b/resources/tester/helper_426_profile_straight.cjs @@ -0,0 +1,189 @@ +/* 426 종단 직선 판정 한 벌 단위검증 헬퍼 — `B05_Profile_UI_Profile_Alignment.ts` · `_Straighten.ts` · + * `_Pivot.ts` · `_Profile_Straight.ts` · `design_profile_free.ts` 를 그 자리에서 트랜스파일해 돌린다. + * 붙은 측점(100/100.4 · 120/120.25) 직선화 · 끝점 꼭짓점 아님 · 자유지정 R · 기울이기 누름 쌓임 · + * 편집 측점 수 · 0 오프셋 정리 + 파이썬 거울 입력(base · edits · TS 곡선)을 JSON 으로 낸다. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +const LOGIC = path.join(ROOT, "B05_Profile", "logic"); +const align = loadTs(path.join(LOGIC, "B05_Profile_UI_Profile_Alignment.ts")); +const straight = loadTs(path.join(LOGIC, "B05_Profile_UI_Profile_Straighten.ts")); +const pivot = loadTs(path.join(LOGIC, "B05_Profile_UI_Profile_Pivot.ts")); +const judge = loadTs(path.join(LOGIC, "B05_Profile_Profile_Straight.ts")); +const free = loadTs(path.join(ROOT, "A00_Common", "design", "logic", "design_profile_free.ts")); + +const policy = { + station_interval_m: 20, + curve_radius_ratio: 1, + curve_tangent_max_ratio: 0.5, + curve_skip_legal_exception: false, + default_curve_length_m: 15, + default_curve_radius_m: 300, + balance_tolerance_percent: 10, + edit_step_m: 0.1, + grade_violation_policy: "warn", + max_grade_pct: 14, + curve_skip_delta_pct: 0, + paved: true, +}; +const regular = Array.from({ length: 11 }, (_, i) => i * 20); +const groundAt = (s) => 100 + 0.015 * s + 0.4 * Math.sin(s / 7) + 0.0371 * Math.cos(s * 3.1); +const all = [...regular, 100.4, 120.25].sort((a, b) => a - b); +/** 붙은 측점까지 꼭짓점(A) · 정규 측점만 꼭짓점(B). */ +function makeBase(pviS) { + return { + policy, + onlyExplicitCurves: false, + basePvi: pviS.map((s) => ({ chainage_m: s, elevation_m: Number(groundAt(s).toFixed(6)) })), + chainage: all, + ground: all.map((s) => Number(groundAt(s).toFixed(6))), + stations: all.map((s) => ({ station_id: `S${s}`, chainage_m: s })), + }; +} +const baseA = makeBase(all); +const baseB = makeBase(regular); +const empty = align.emptyEdits(); +const build = (base, edits) => align.buildAlignment(base, edits); +const curvesIn = (alignment, from, to) => + alignment.curves + .filter((c) => c.chainage_m > from + 1e-6 && c.chainage_m < to - 1e-6) + .map((c) => c.chainage_m); +const planAt = (alignment, s) => align.planElevationAt(alignment, s); +const r6 = (v) => Number(v.toFixed(6)); + +// ① 붙은 측점 직선화 40 → 140 (A) +const sA = straight.straightenBetween(baseA, empty, 40, 140); +const alA = build(baseA, sA); +// 옛 판정(대수차 1e-9)이면 꺾임으로 읽혔을 안쪽 꼭짓점 수 — 시험이 반올림 흠을 실제로 거치는지 +let noiseBends = 0; +alA.pvi.forEach((node, i) => { + if (i === 0 || i === alA.pvi.length - 1) return; + if (node.chainage_m <= 40 + 1e-6 || node.chainage_m >= 140 - 1e-6) return; + const g = (a, b) => (b.elevation_m - a.elevation_m) / (b.chainage_m - a.chainage_m); + if (Math.abs(g(node, alA.pvi[i + 1]) - g(alA.pvi[i - 1], node)) >= 1e-9) noiseBends += 1; +}); +const runA = straight.detectStraightRun(alA, 70); +const axesA = pivot.pivotAxes(alA, 80); +const upA = align.adjustStation(baseA, sA, 80, 0.1); +const alUp = build(baseA, upA); +const gradesUp = alUp.segments + .filter((seg) => seg.from_m >= 40 - 1e-6 && seg.to_m <= 140 + 1e-6) + .map((seg) => r6(seg.grade_percent)); +const straightened = { + noiseBends, + curvesInside: curvesIn(alA, 40, 140), + radiiKeys: Object.keys(sA.curve_radii), + run: runA ? [runA.fromM, runA.toM] : null, + axes: axesA ? [axesA.fromM, axesA.toM] : null, + pressRise: r6(planAt(alUp, 80) - planAt(alA, 80)), + curvesAfterPress: curvesIn(alUp, 40, 140), + gradesUp, + editedCount: judge.bentEditCount(alA), + offsetCount: Object.keys(sA.station_offsets).length, +}; + +// ② 끝점 꼭짓점 아님(B) — 100.4 → 160 직선화 · 120.25 → 180 자유지정 +const alB = build(baseB, empty); +const sB = straight.straightenBetween(baseB, empty, 100.4, 160); +const alSB = build(baseB, sB); +const line = (a, b, s) => a[1] + ((b[1] - a[1]) * (s - a[0])) / (b[0] - a[0]); +const endA = [100.4, planAt(alB, 100.4)]; +const endB = [160, align.controlElevationAt(alB, 160)]; +const endpoint = { + // 끝 꼭짓점 = 직선화 전 그 자리 계획고(꼭짓점 높이 · 그 라운드는 직선의 접선) + endHeld: r6(align.controlElevationAt(alSB, 100.4) - planAt(alB, 100.4)), + endIsVertex: alSB.pvi.some((n) => Math.abs(n.chainage_m - 100.4) < 1e-6), + curvesInside: curvesIn(alSB, 100.4, 160), + innerResidual: [120, 120.25, 140].map((s) => r6(planAt(alSB, s) - line(endA, endB, s))), +}; +const z140 = planAt(alB, 140) + 1; +const fEdits = free.buildFreeEdits(baseB, empty, [ + { chainage_m: 120.25, elevation_m: align.controlElevationAt(alB, 120.25) }, + { chainage_m: 140, elevation_m: z140 }, + { chainage_m: 180, elevation_m: align.controlElevationAt(alB, 180) }, +]); +const alF = build(baseB, fEdits); +// 자유지정 뒤 ▼ 로 140 을 다시 120.25 · 180 잇는 선 위로 → 직선 → 정리하면 R 없음 +const back = + line( + [120.25, align.controlElevationAt(alF, 120.25)], + [180, align.controlElevationAt(alF, 180)], + 140, + ) - planAt(alF, 140); +const downF = align.adjustStation(baseB, fEdits, 140, back); +const tidyF = straight.tidyEdits(baseB, downF); +const alTidyF = build(baseB, tidyF); +const freeCase = { + endHeld: r6(align.controlElevationAt(alF, 120.25) - align.controlElevationAt(alB, 120.25)), + radiusAt140: fEdits.curve_radii["140.000"] ?? null, + curveAt140: alF.curves.some((c) => Math.abs(c.chainage_m - 140) < 1e-6), + curvesInsideF: curvesIn(alF, 120.25, 180), + radiusKeptRaw: downF.curve_radii["140.000"] !== undefined, + radiusAfterTidy: tidyF.curve_radii["140.000"] ?? null, + curvesAfterTidy: curvesIn(alTidyF, 120.25, 180), + sameShape: r6(planAt(alTidyF, 140) - planAt(build(baseB, downF), 140)), +}; + +// ③ 고른 직선 기울이기 — 누름 쌓임 · 되돌아오면 R 없음 (A) +const run = straight.detectStraightRun(alA, 70); +let e = sA; +const rises = []; +const pivotM = 80; // 40 · 140 가운데 = 90 → 가장 가까운 안쪽 꼭짓점(80 · 100 중 앞선 80) +[0.1, 0.1, -0.1, -0.1].forEach((d) => { + e = straight.tiltStraightRun(baseA, e, run, d); + rises.push(r6(align.controlElevationAt(build(baseA, e), pivotM) - planAt(alA, pivotM))); +}); +const tiltOnce = straight.tiltStraightRun(baseA, sA, run, 0.1); +const tilt = { + rises, + finalRadii: Object.keys(e.curve_radii), + finalCurves: curvesIn(build(baseA, e), 40, 140), + editedAfterOne: judge.bentEditCount(build(baseA, tiltOnce)), +}; + +// ④ 0 오프셋 정리 +const tidyZero = straight.tidyEdits(baseB, { + station_offsets: { "60.000": 0, "80.000": 0.3 }, + curve_radii: {}, +}); + +console.log( + JSON.stringify({ + straightened, + endpoint, + freeCase, + tilt, + tidyZero: Object.keys(tidyZero.station_offsets), + mirror: { + base: baseA, + cases: [ + { edits: sA, curves: alA.curves.map((c) => [r6(c.chainage_m), r6(c.r_m)]) }, + { edits: upA, curves: alUp.curves.map((c) => [r6(c.chainage_m), r6(c.r_m)]) }, + { + edits: tiltOnce, + curves: build(baseA, tiltOnce).curves.map((c) => [r6(c.chainage_m), r6(c.r_m)]), + }, + ], + }, + }), +); diff --git a/resources/tester/test_426_profile_straight.py b/resources/tester/test_426_profile_straight.py new file mode 100644 index 000000000..b743d8789 --- /dev/null +++ b/resources/tester/test_426_profile_straight.py @@ -0,0 +1,132 @@ +# -*- coding: utf-8 -*- +"""426 B05 종단 직선 판정 한 벌 — 높이 기준(1e-5 m) · 끝점 꼭짓점 · 누름 쌓임 · 편집 측점 수. + +요구 259. + +계산(`logic/B05_Profile_Profile_Straight.ts` · `_Alignment.ts` buildCurves · `_Pivot.ts` · +`_Straighten.ts` · `design_profile_free.ts`)은 Node 헬퍼(`helper_426_profile_straight.cjs`)가 +트랜스파일해 돌리고, 파이썬 짝(`B05_Profile_Engine_Grade_Alignment.py` `is_straight_bend`)은 +같은 base · 편집으로 곡선 자리 · R 이 같은지 거울로 본다. +./venv/Scripts/python.exe -m pytest resources/tester/test_426_profile_straight.py -q +""" + +import json +import os +import subprocess +import sys +from pathlib import Path + +import numpy as np +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parent.parent +sys.path.insert(0, str(ROOT)) + +from B05_Profile.B05_Profile_Engine_Grade_Alignment import ( # noqa: E402 + STRAIGHT_HEIGHT_M, + AlignmentPolicy, + build_alignment, + is_straight_bend, +) + + +def _read(relative: str) -> str: + return (ROOT / relative).read_text(encoding="utf-8") + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_426_profile_straight.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_붙은_측점_직선화_뒤_R_없음_한_직선(out): + got = out["straightened"] + # 옛 판정(대수차 1e-9)이면 꺾임으로 읽혔을 반올림 흠이 실제로 있음 — 시험이 그 길을 거침 + assert got["noiseBends"] > 0 + assert got["curvesInside"] == [] and got["radiiKeys"] == [] + assert got["run"] == [40, 140] + assert got["axes"] == [40, 140] + + +def test_직선_안_누름_톱니_없음(out): + got = out["straightened"] + assert got["pressRise"] == pytest.approx(0.1, abs=1e-4) + assert got["curvesAfterPress"] == [80] # 누른 점 하나만 꺾임 + left, right = got["gradesUp"][:2], got["gradesUp"][2:] + assert max(left) - min(left) < 1e-3 and max(right) - min(right) < 1e-3 # 0.4 m 조각 톱니 없음 + + +def test_편집_측점_수는_꺾인_꼭짓점(out): + assert out["straightened"]["offsetCount"] > 0 + assert out["straightened"]["editedCount"] == 0 + assert out["tilt"]["editedAfterOne"] == 1 + + +def test_끝점_꼭짓점_아니면_지금_높이로_세움(out): + got = out["endpoint"] + assert got["endHeld"] == pytest.approx(0, abs=1e-6) + assert got["endIsVertex"] + assert got["curvesInside"] == [] + assert all(abs(value) < 1e-5 for value in got["innerResidual"]) + + +def test_자유지정_R_뒤_직선되면_R_지움(out): + got = out["freeCase"] + assert got["endHeld"] == pytest.approx(0, abs=1e-6) + assert got["radiusAt140"] and got["curveAt140"] and got["curvesInsideF"] == [140] + assert got["radiusKeptRaw"] # ▼ 만으로는 기억 R 이 남음 → 적용 한 곳의 정리가 지움 + assert got["radiusAfterTidy"] is None and got["curvesAfterTidy"] == [] + assert got["sameShape"] == pytest.approx(0, abs=1e-9) + + +def test_고른_직선_기울이기_누름_쌓임(out): + got = out["tilt"] + assert got["rises"] == pytest.approx([0.1, 0.2, 0.1, 0.0], abs=1e-6) + assert got["finalRadii"] == [] and got["finalCurves"] == [] + + +def test_0_오프셋_정리(out): + assert out["tidyZero"] == ["80.000"] + + +def test_파이썬_짝_같은_기준(): + assert STRAIGHT_HEIGHT_M == 1e-5 + assert is_straight_bend(2.5e-6, 0.4, 20.0) # 반올림 흠(0.4 m 조각) = 직선 + assert not is_straight_bend(1e-3, 20.0, 20.0) # 0.1 % 꺾임 = 꺾임 + assert "STRAIGHT_HEIGHT_M = 1e-5" in _read("B05_Profile/logic/B05_Profile_Profile_Straight.ts") + + +def test_파이썬_거울_곡선_자리_R(out): + base = out["mirror"]["base"] + policy = AlignmentPolicy.from_dict(base["policy"]) + for case in out["mirror"]["cases"]: + got = build_alignment( + base_s=np.array([n["chainage_m"] for n in base["basePvi"]]), + base_z=np.array([n["elevation_m"] for n in base["basePvi"]]), + chainage=np.array(base["chainage"]), + ground=np.array(base["ground"]), + stations=base["stations"], + policy=policy, + edits=case["edits"], + ) + py = [[c["chainage_m"], c["r_m"]] for c in got["curves"]] + assert [c[0] for c in py] == pytest.approx([c[0] for c in case["curves"]], abs=1e-6) + assert [c[1] for c in py] == pytest.approx([c[1] for c in case["curves"]], rel=1e-6) + + +def test_부르는_곳(): + assert "tidyEdits(base, next)" in _read("A00_Common/design/design_profile.ts") + assert "bentEditCount(alignment)" in _read("B05_Profile/B05_Profile_UI_Profile_Balance.ts") + assert "isStraightBend(delta, spanLeft, spanRight)" in _read( + "B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts" + ) + assert "is_straight_bend(delta," in _read("B05_Profile/B05_Profile_Engine_Grade_Alignment.py")