fix(B05 종단): 직선 판정 한 벌(꼭짓점 높이 1e-5 m) · 끝점 꼭짓점 · 기울이기 누름 쌓임 · 편집 측점 수 (426)
- TS `logic/B05_Profile_Profile_Straight.ts` ↔ 파이썬 `is_straight_bend` 짝 · 곡선 넣기 · ▲▼ 축 · 직선 찾기 - 적용 한 곳 `tidyEdits` — 직선 위 기억 R · 0 오프셋 지움(모양 그대로) - 직선화 · 자유지정 끝이 꼭짓점 아니면 지금 높이로 세움 · 고른 직선 기울이기 = 지금 경첩 높이 + 걸음 - 「편집 측점 N개」 = 꺾인 편집 꼭짓점 수 · 시험 test_426 + 헬퍼 스텁 넷 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
This commit is contained in:
@@ -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" &&
|
||||
|
||||
@@ -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<string, number>();
|
||||
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),
|
||||
);
|
||||
|
||||
@@ -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)-(다)는 "종단곡선을 두지 않을 수 있다"는 허용 조항이다.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<AlignmentNode>, 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)]),
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user