Files
Aislo/B05_Profile/logic/B05_Profile_Grade_Carry.ts
T
eomsangdonandClaude Opus 5.5 0714e9653e feat(B05 종단): 종단곡선 기본 L 20 m · 초기값 R 500 m 넘는 꼭짓점 곡선 없음 · 법정 R · L 미달 경고 (432-1)
- config 기본 L 20 · 초기값 직선 문턱 500 m ↔ TS `logic/B05_Profile_Profile_CurveLaw.ts` 상수 짝
- 초기값(1차 지반 종단 · 추천 [적용])만 정책 `no_curve_keys` 로 얼림 · 손 편집 · R 이면 기본 곡선
- 법정 종단곡선 최소 R · L 미달 = 경고 글(파이썬 · TS 같은 글) + 종단 요약줄 「종단곡선 미달」
- 노선 [저장]은 옛 정책(L · 열쇠) 이어받음 · 바뀐 자리 새 꼭짓점만 새 지반 열쇠(`carry_policy` ↔ `carryNoCurveKeys`)
- 시험 test_432_1 + 헬퍼 스텁 넷

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
2026-10-18 16:20:03 +09:00

265 lines
10 KiB
TypeScript

/* =============================================================================
* B05_Profile/logic/B05_Profile_Grade_Carry.ts
* 노선이 바뀔 때 계획고 옮김 — 같은 자리는 옛 계획고 · 바뀐 자리만 새 지반 첫 계획선(344-2 · 400).
*
* 짝 = 파이썬 `B05_Profile_Engine_Grade_Carry.py`(서버 정본 · 같은 규칙) · 거울 시험 =
* resources/tester/test_400_profile_carry.py · test_344_2_route_pairs.py.
* - `changedSpan` = 옛 · 새 정점열의 같은 앞머리 · 꼬리를 뺀 가운데 — 「노선 그대로?」 · 묶음 범위만.
* - 같은 자리(`lineMatcher`) = 새 선 점을 옛 선에 투영 · 옆 `SAME_LINE_TOL_M` 안 · 방향 30° 안
* (측점 스냅이 뒤 교점을 밀어도 이어받음).
* - 자동 꼭짓점(`carryBase`) · 편집(`carryEdits`)은 **새 측점 위에** 다시 놓음 — 같은 자리면 옛 선 그 자리
* 높이 · 아니면 새 지반 첫 계획선 · 편집은 옛 사용자 꼭짓점이 닿은 자리만 · R 은 가까운 측점.
* ========================================================================== */
import {
chainageKey,
resolvePvi,
type AlignmentBase,
type AlignmentEdits,
type AlignmentNode,
} from "./B05_Profile_UI_Profile_Alignment";
/** 같은 자리로 볼 옆 어긋남(m) — 브레인 정함 10-17 · 바꿀 수 있음 · 파이썬 `SAME_LINE_TOL_M` 짝. */
export const SAME_LINE_TOL_M = 1.0;
/** 같은 자리로 볼 방향 차(cos 30°) — 엇갈려 지나는 선 · 머리핀 맞은편을 뺌. */
const SAME_DIRECTION_COS = 0.8660254037844386;
/** 꼭짓점 위로 볼 거리(m) — 서버 `SAME_STATION_SNAP_M` 짝. */
const ON_NODE_M = 0.006;
/** 같은 정점으로 볼 거리(m) — 파이썬 `SAME_VERTEX_M` 짝. */
export const SAME_VERTEX_M = 1e-3;
const EPS = 1e-6;
export interface XY {
x: number;
y: number;
}
export interface ChangedSpan {
start_m: number;
old_end_m: number;
new_end_m: number;
delta_m: number;
start_fixed: boolean;
end_fixed: boolean;
}
const roundTo = (value: number, digits: number): number => Number(value.toFixed(digits));
function cumulative(xy: ReadonlyArray<XY>): number[] {
const total = [0];
for (let index = 1; index < xy.length; index += 1) {
const prev = xy[index - 1];
total.push(total[index - 1] + Math.hypot(xy[index].x - prev.x, xy[index].y - prev.y));
}
return total;
}
const same = (a: XY, b: XY): boolean =>
Math.abs(a.x - b.x) <= SAME_VERTEX_M && Math.abs(a.y - b.y) <= SAME_VERTEX_M;
/** 옛 · 새 노선 정점열 → 바뀐 누가거리 구간 · 같으면 null(파이썬 `changed_span` 짝). */
export function changedSpan(
oldXy: ReadonlyArray<XY>,
newXy: ReadonlyArray<XY>,
): ChangedSpan | null {
const limit = Math.min(oldXy.length, newXy.length);
let prefix = 0;
while (prefix < limit && same(oldXy[prefix], newXy[prefix])) prefix += 1;
if (prefix === oldXy.length && prefix === newXy.length) return null;
let suffix = 0;
while (
suffix < limit - prefix &&
same(oldXy[oldXy.length - 1 - suffix], newXy[newXy.length - 1 - suffix])
)
suffix += 1;
const oldCum = cumulative(oldXy);
const newCum = cumulative(newXy);
return {
start_m: prefix ? oldCum[prefix - 1] : 0,
old_end_m: suffix ? oldCum[oldXy.length - suffix] : oldCum[oldCum.length - 1],
new_end_m: suffix ? newCum[newXy.length - suffix] : newCum[newCum.length - 1],
delta_m: newCum[newCum.length - 1] - oldCum[oldCum.length - 1],
start_fixed: prefix > 0,
end_fixed: suffix > 0,
};
}
/** `np.interp` — 오름차순 xs · 범위 밖은 양 끝값. */
function interp(xs: number[], ys: number[], value: number): number {
if (value <= xs[0]) return ys[0];
if (value >= xs[xs.length - 1]) return ys[ys.length - 1];
let low = 0;
let high = xs.length - 1;
while (high - low > 1) {
const mid = (low + high) >> 1;
if (xs[mid] <= value) low = mid;
else high = mid;
}
const span = xs[high] - xs[low];
return span <= 0 ? ys[high] : ys[low] + ((ys[high] - ys[low]) * (value - xs[low])) / span;
}
interface Segment {
x: number;
y: number;
ux: number;
uy: number;
length: number;
from: number;
}
function segmentsOf(xy: ReadonlyArray<XY>): Segment[] {
const out: Segment[] = [];
let from = 0;
for (let index = 1; index < xy.length; index += 1) {
const dx = xy[index].x - xy[index - 1].x;
const dy = xy[index].y - xy[index - 1].y;
const length = Math.sqrt(dx * dx + dy * dy);
if (length > EPS) {
const { x, y } = xy[index - 1];
out.push({ x, y, ux: dx / length, uy: dy / length, length, from });
}
from += length;
}
return out;
}
/** 새 누가거리 → 같은 자리 옛 누가거리(옆 `tolM` · 방향 30° 안) · 아니면 null(파이썬 `line_matcher` 짝). */
export function lineMatcher(
oldXy: ReadonlyArray<XY>,
newXy: ReadonlyArray<XY>,
tolM: number = SAME_LINE_TOL_M,
): (chainageM: number) => number | null {
const olds = segmentsOf(oldXy);
const news = segmentsOf(newXy);
return (chainageM) => {
if (!olds.length || !news.length) return null;
const seg = news.find((item) => chainageM <= item.from + item.length) ?? news[news.length - 1];
const along = Math.min(Math.max(chainageM - seg.from, 0), seg.length);
const qx = seg.x + seg.ux * along;
const qy = seg.y + seg.uy * along;
let best = Infinity;
let at: number | null = null;
for (const item of olds) {
if (item.ux * seg.ux + item.uy * seg.uy < SAME_DIRECTION_COS) continue;
const p = Math.min(
Math.max((qx - item.x) * item.ux + (qy - item.y) * item.uy, 0),
item.length,
);
const dx = qx - (item.x + item.ux * p);
const dy = qy - (item.y + item.uy * p);
const gap = Math.sqrt(dx * dx + dy * dy);
if (gap < best) {
best = gap;
at = item.from + p;
}
}
return best <= tolM ? at : null;
};
}
/** 새 자동 꼭짓점 — 지반 첫 계획선 측점마다 같은 자리면 옛 자동선 높이 · 아니면 지반(파이썬 `carry_base` 짝). */
export function carryBase(
oldXy: ReadonlyArray<XY>,
newXy: ReadonlyArray<XY>,
oldBase: ReadonlyArray<AlignmentNode>,
groundPvi: ReadonlyArray<AlignmentNode>,
tolM: number = SAME_LINE_TOL_M,
): AlignmentNode[] {
if (!oldBase.length || !groundPvi.length) return oldBase.map((node) => ({ ...node }));
const match = lineMatcher(oldXy, newXy, tolM);
const baseS = oldBase.map((node) => node.chainage_m);
const baseZ = oldBase.map((node) => node.elevation_m);
return groundPvi.map((node) => {
const s = match(node.chainage_m);
return {
chainage_m: node.chainage_m,
elevation_m: s === null ? node.elevation_m : roundTo(interp(baseS, baseZ, s), 6),
};
});
}
/**
* 새 편집 — 새 측점마다 같은 자리이고 옛 사용자 꼭짓점이 닿은 자리(꼭짓점 위 · 앞뒤 꼭짓점 하나가 사용자)면
* 옛 계획선 높이 − 옛 자동선 높이 · 종단곡선 R = 옛 키에서 `halfM` 안 가장 가까운 같은 자리 측점(파이썬
* `carry_edits` 짝). 새 자동 꼭짓점이 같은 자리에서 옛 자동선 높이(`carryBase`)라 계획고가 그대로.
*/
export function carryEdits(
oldXy: ReadonlyArray<XY>,
newXy: ReadonlyArray<XY>,
oldBase: ReadonlyArray<AlignmentNode>,
edits: Partial<AlignmentEdits> | null | undefined,
stations: ReadonlyArray<number>,
halfM: number,
tolM: number = SAME_LINE_TOL_M,
): AlignmentEdits {
const out: AlignmentEdits = { station_offsets: {}, curve_radii: {} };
if (!oldBase.length) return out;
const match = lineMatcher(oldXy, newXy, tolM);
const mapped: Array<{ t: number; s: number }> = [];
[...new Set(stations.filter(Number.isFinite).map((value) => roundTo(value, 3)))]
.sort((a, b) => a - b)
.forEach((t) => {
const s = match(t);
if (s !== null) mapped.push({ t, s });
});
const baseS = oldBase.map((node) => node.chainage_m);
const baseZ = oldBase.map((node) => node.elevation_m);
const pvi = resolvePvi({ basePvi: [...oldBase] } as AlignmentBase, edits?.station_offsets ?? {});
const pviS = pvi.map((node) => node.chainage_m);
const pviZ = pvi.map((node) => node.elevation_m);
const touched = (s: number): boolean => {
const on = pvi.find((node) => Math.abs(node.chainage_m - s) < ON_NODE_M);
if (on) return on.source === "user";
const next = pvi.findIndex((node) => node.chainage_m > s);
return next > 0 && (pvi[next - 1].source === "user" || pvi[next].source === "user");
};
mapped.forEach(({ t, s }) => {
if (touched(s))
out.station_offsets[chainageKey(t)] = roundTo(
interp(pviS, pviZ, s) - interp(baseS, baseZ, s),
6,
);
});
const gaps = new Map<string, number>();
Object.entries(edits?.curve_radii ?? {}).forEach(([key, value]) => {
const chainage = Number(key);
if (!Number.isFinite(chainage) || value == null) return;
let slot: string | null = null;
let best = Infinity;
mapped.forEach(({ t, s }) => {
const gap = roundTo(Math.abs(s - chainage), 3); // mm — 가운데 같은 거리면 앞 측점
if (gap <= halfM && gap < best) [slot, best] = [chainageKey(t), gap];
});
if (slot === null || best >= (gaps.get(slot) ?? Infinity)) return;
gaps.set(slot, best);
out.curve_radii[slot] = Number(value);
});
return out;
}
/**
* 새 꼭짓점의 초기값 직선 열쇠(432) — 같은 자리면 옛 꼭짓점 열쇠(꼭짓점 위일 때만) · 바뀐 자리면 새
* 지반 첫 계획선 열쇠(파이썬 `carry_no_curve_keys` 짝). 초기값만 · 그 뒤는 사용자 몫(요구 262).
*/
export function carryNoCurveKeys(
oldXy: ReadonlyArray<XY>,
newXy: ReadonlyArray<XY>,
oldKeys: ReadonlyArray<string>,
groundKeys: ReadonlyArray<string>,
groundPvi: ReadonlyArray<AlignmentNode>,
tolM: number = SAME_LINE_TOL_M,
): string[] {
const match = lineMatcher(oldXy, newXy, tolM);
const old = oldKeys.map(Number);
const ground = new Set(groundKeys);
return groundPvi
.filter((node) => {
const s = match(node.chainage_m);
if (s === null) return ground.has(node.chainage_m.toFixed(3));
return old.some((value) => Math.abs(s - value) < ON_NODE_M);
})
.map((node) => node.chainage_m.toFixed(3));
}