- 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
265 lines
10 KiB
TypeScript
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));
|
|
}
|