Files
Aislo/B05_Profile/B05_Profile_UI_Profile_Alignment.ts
T
eomsangdonandClaude Opus 5 c7099bd1d3 feat(B05,B06): 측점별 평면 곡선반경 산출 + 법정 최소반경 경고, 자동탐색 입구 내림
2026-09-06 사용자 확정. 자동탐색은 평면만 보고 노선을 정해 실제 판단과 맞지 않으므로
쓰지 않는다 — 버튼만 감추고 코드·API 는 남긴다. 대신 법정 평면 기준을 **경고**로 낸다.

- 횡단 생성이 측점마다 평면 곡선반경(`plan_radius_m`)을 낸다. 노선 폴리라인 위에서
  앞뒤 10m 떨어진 세 점의 외접원 반경이며, 직선(1만m 이상)은 null. 반지름 50m 원호로
  50.008 이 나오는 것을 확인.
- 법정 최소곡선반지름 표(별표2 Ⅰ.2.다.(1) — 40:60/40, 30:30/20, 20:15/12)와 배향곡선
  하한 10m 를 설정에 넣고 계획선 정책에 실어 화면으로 내린다. 탐색이 쓰는 등급별
  상수와는 별개다.
- 종단 상단줄에 「곡선반경 부족 n곳 / 최소 R」 경고 추가 — 자동 보정·차단은 없다.
  툴팁에 하한값과 배향곡선 하한 미만 개수를 적는다.
- 이 반경은 다음 작업(곡선부 확폭)이 그대로 쓴다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 11:55:22 +09:00

605 lines
23 KiB
TypeScript

/* =============================================================================
* B05_Profile_UI_Profile_Alignment.ts
* 종단 계획선 선형(직선 + 측점 위 종단곡선) 프론트엔드 기하 계산.
*
* 백엔드 `B05_Profile_Engine_Grade_Alignment.py`와 **같은 식**을 쓴다. 사용자가
* 0.1m 버튼을 누를 때마다 서버를 오가지 않고 즉시 다시 그리기 위한 계산부이며,
* 확정 시점에는 편집 델타만 서버로 보내 백엔드가 정본을 다시 만든다.
*
* 규칙:
* - 변화점(PVI)은 기준 측점 위에만 놓인다 → 곡선 중심이 측점 수직선상에 있다.
* - 종단곡선은 변화점 대칭이며 좌우에 직선이 반드시 남는다.
* - 반경 R이 1차 값이고 곡선길이 L = R × |대수차| 로 따라온다 (편집해도 R은 유지).
* - 편집 델타는 항상 **자동 선형(base_pvi) 기준**이라 키를 지우면 정확히 원복된다.
* ========================================================================== */
export interface AlignmentPolicy {
station_interval_m: number;
curve_radius_ratio: number;
curve_tangent_max_ratio: number;
curve_skip_legal_exception: boolean;
/** 기본 종단곡선 길이 L(m) — 2026-08-08부터 이쪽이 기준이다. 옛 저장분은 없거나 null. */
default_curve_length_m?: number | null;
/** 옛 R 기준 값. L이 없는 저장분을 되살릴 때만 쓴다. */
default_curve_radius_m: number;
balance_tolerance_percent: number;
edit_step_m: number;
grade_violation_policy: string;
max_grade_pct: number;
curve_skip_delta_pct: number;
paved: boolean;
/** 법정 평면 최소곡선반지름(m) — 위반 표시 기준. 0이면 판정하지 않는다(옛 저장분). */
min_plan_radius_m?: number;
/** 배향곡선 하한(m) — 이보다 급하면 경고만 낸다(2026-09-06 사용자 확정). */
hairpin_min_radius_m?: number;
}
export interface AlignmentNode {
chainage_m: number;
elevation_m: number;
}
export interface AlignmentPvi extends AlignmentNode {
source: "auto" | "user";
kind: string;
grade_in_pct: number | null;
grade_out_pct: number | null;
curve_l_m: number | null;
curve_r_m: number | null;
}
export interface AlignmentSegment {
index: number;
from_m: number;
to_m: number;
length_m: number;
height_m: number;
grade_percent: number;
}
export interface AlignmentCurve {
pvi_index: number;
chainage_m: number;
bvc_m: number;
evc_m: number;
bvc_elevation_m: number;
evc_elevation_m: number;
l_m: number;
r_m: number;
k: number;
delta_pct: number;
middle_ordinate_m: number;
omitted: boolean;
skip_allowed: boolean;
omit_reason: string | null;
}
export interface AlignmentStationRow {
station_id: string | null;
chainage_m: number;
distance_m: number;
ground_elevation_m: number;
plan_elevation_m: number;
cut_m: number;
fill_m: number;
}
export interface AlignmentSample {
chainage_m: number;
elevation_m: number;
ground_elevation_m: number;
difference_m: number;
}
export interface AlignmentBalance {
cut_area_m2: number;
fill_area_m2: number;
net_area_m2: number;
imbalance_percent: number;
tolerance_percent: number;
within_tolerance: boolean;
}
export interface AlignmentViolation {
segment_index: number;
type: string;
value: number;
limit: number;
}
export interface AlignmentEdits {
station_offsets: Record<string, number>;
/** 변화점별 종단곡선 반경(m). 지정하지 않으면 정책 기본 반경을 쓴다. */
curve_radii: Record<string, number>;
}
export interface ProfileAlignment {
schema_version: number;
policy: AlignmentPolicy;
/** 전체 측점 폴리라인이면 true — 사용자가 R을 지정한 변화점에만 라운드를 넣는다
* (2026-09-02 사용자 확정, 백엔드 `build_alignment(only_explicit_curves=)` 와 같은 값). */
only_explicit_curves?: boolean;
base_pvi: AlignmentNode[];
edits: AlignmentEdits;
pvi: AlignmentPvi[];
segments: AlignmentSegment[];
curves: AlignmentCurve[];
stations: AlignmentStationRow[];
samples: AlignmentSample[];
balance: AlignmentBalance;
violations: AlignmentViolation[];
warnings: string[];
}
/** 자동 선형과 지반 종단 — 편집을 얹기 위한 고정 입력. */
export interface AlignmentBase {
policy: AlignmentPolicy;
/** 라운드를 사용자가 지정한 변화점에만 넣을지 — 저장본에서 그대로 물려받는다. */
onlyExplicitCurves: boolean;
basePvi: AlignmentNode[];
chainage: number[];
ground: number[];
stations: Array<{ station_id: string | null; chainage_m: number }>;
}
/** chainage를 편집 델타 dict의 키로 바꾼다 (백엔드 `chainage_key`와 동일 규칙). */
export function chainageKey(value: number): string {
return value.toFixed(3);
}
export function emptyEdits(): AlignmentEdits {
return { station_offsets: {}, curve_radii: {} };
}
/** 오름차순 x 배열 위에서의 선형 보간 (범위 밖은 양 끝값으로 클램프). */
function interpolate(xs: number[], ys: number[], value: number): number {
if (!xs.length) return 0;
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];
if (span <= 0) return ys[high];
return ys[low] + ((ys[high] - ys[low]) * (value - xs[low])) / span;
}
export function toAlignmentBase(alignment: ProfileAlignment): AlignmentBase {
return {
policy: alignment.policy,
onlyExplicitCurves: alignment.only_explicit_curves === true,
basePvi: alignment.base_pvi.map((node) => ({ ...node })),
chainage: alignment.samples.map((sample) => sample.chainage_m),
ground: alignment.samples.map((sample) => sample.ground_elevation_m),
stations: alignment.stations.map((station) => ({
station_id: station.station_id,
chainage_m: station.chainage_m,
})),
};
}
/** 자동 변화점에 사용자 편집 측점을 합쳐 최종 변화점 집합을 만든다. */
function resolvePvi(
base: AlignmentBase,
offsets: Record<string, number>,
): Array<AlignmentNode & { source: "auto" | "user" }> {
const baseS = base.basePvi.map((node) => node.chainage_m);
const baseZ = base.basePvi.map((node) => node.elevation_m);
const nodes = new Map<number, AlignmentNode & { source: "auto" | "user" }>();
base.basePvi.forEach((node) => {
nodes.set(Number(node.chainage_m.toFixed(3)), { ...node, source: "auto" });
});
Object.entries(offsets).forEach(([key, offset]) => {
const chainage = Number(Number(key).toFixed(3));
if (!Number.isFinite(chainage) || !Number.isFinite(offset)) return;
if (chainage < baseS[0] - 1e-6 || chainage > baseS[baseS.length - 1] + 1e-6) return;
nodes.set(chainage, {
chainage_m: chainage,
elevation_m: interpolate(baseS, baseZ, chainage) + offset,
source: "user",
});
});
return [...nodes.values()].sort((a, b) => a.chainage_m - b.chainage_m);
}
interface WorkingCurve extends AlignmentCurve {
grade_in: number;
grade_out: number;
}
/** 각 변화점에 대칭 종단곡선을 삽입한다 (좌우 직선이 남도록 반쪽 길이를 제한). */
function buildCurves(
pvi: AlignmentNode[],
policy: AlignmentPolicy,
curveRadii: Record<string, number>,
warnings: string[],
onlyExplicit = false,
): WorkingCurve[] {
const curves: WorkingCurve[] = [];
const skipDelta = policy.curve_skip_delta_pct / 100;
for (let index = 1; index < pvi.length - 1; index += 1) {
const spanLeft = pvi[index].chainage_m - pvi[index - 1].chainage_m;
const spanRight = pvi[index + 1].chainage_m - pvi[index].chainage_m;
if (spanLeft <= 0 || spanRight <= 0) continue;
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;
const chainage = pvi[index].chainage_m;
const key = chainageKey(chainage);
const skipAllowed = !policy.paved && Math.abs(delta) <= skipDelta + 1e-12;
// 백엔드 build_curves와 같은 규칙 — 기준은 길이 L이고, 사용자가 지정한 R이 있으면 그쪽.
// 기본 L이 없는 옛 저장분만 R 기준으로 되돌아간다.
const requested = curveRadii[key];
const hasRequested = Number.isFinite(requested) && requested > 0;
// 전체 측점 폴리라인 — 사용자가 R을 지정한 변화점에만 라운드가 생긴다.
if (onlyExplicit && !hasRequested) continue;
const fallbackLength = Number.isFinite(policy.default_curve_length_m)
? (policy.default_curve_length_m as number)
: policy.default_curve_radius_m * Math.abs(delta);
const desired = hasRequested ? requested * Math.abs(delta) : fallbackLength;
const halfLimit = Math.min(spanLeft, spanRight) * policy.curve_tangent_max_ratio;
// 인접 직선이 짧으면 넣을 수 있는 최대 L까지만 줄인다 — 0으로 죽이지 않는다.
const half = Math.min(desired / 2, halfLimit);
if (half <= 1e-9) continue;
if (desired / 2 - half > 1e-6) {
warnings.push(
`${chainage.toFixed(1)}m: 인접 직선이 짧아 종단곡선 길이를 ${(half * 2).toFixed(1)}m(반경 ${((half * 2) / Math.abs(delta)).toFixed(0)}m)로 줄였습니다.`,
);
}
const length = half * 2;
curves.push({
pvi_index: index,
chainage_m: chainage,
bvc_m: chainage - half,
evc_m: chainage + half,
bvc_elevation_m: 0,
evc_elevation_m: 0,
l_m: length,
r_m: length / Math.abs(delta),
k: length / (Math.abs(delta) * 100),
delta_pct: delta * 100,
middle_ordinate_m: (Math.abs(delta) * length) / 8,
omitted: skipAllowed && policy.curve_skip_legal_exception,
skip_allowed: skipAllowed,
omit_reason: skipAllowed
? `비포장 대수차 ${policy.curve_skip_delta_pct.toFixed(0)}% 이하 (법정 생략 가능)`
: null,
grade_in: gradeIn,
grade_out: gradeOut,
});
}
return curves;
}
/** 직선 + 종단곡선으로 구성된 계획선을 임의 chainage에서 평가한다. */
function evaluateAt(
pviS: number[],
pviZ: number[],
curves: WorkingCurve[],
chainage: number,
): number {
for (const curve of curves) {
if (curve.omitted) continue;
if (chainage < curve.bvc_m || chainage > curve.evc_m) continue;
const half = curve.l_m / 2;
const local = chainage - curve.bvc_m;
const startZ = pviZ[curve.pvi_index] - curve.grade_in * half;
const delta = curve.grade_out - curve.grade_in;
return startZ + curve.grade_in * local + (delta / (2 * curve.l_m)) * local * local;
}
return interpolate(pviS, pviZ, chainage);
}
/** 사다리꼴 적분 가중치(ds). */
function trapezoidWeights(chainage: number[]): number[] {
const weights = new Array<number>(chainage.length).fill(0);
if (chainage.length < 2) return weights;
for (let index = 1; index < chainage.length - 1; index += 1) {
weights[index] = (chainage[index + 1] - chainage[index - 1]) / 2;
}
weights[0] = (chainage[1] - chainage[0]) / 2;
weights[chainage.length - 1] =
(chainage[chainage.length - 1] - chainage[chainage.length - 2]) / 2;
return weights;
}
/** 저장분·초안이 부분적으로 비어 있어도 계산이 터지지 않도록 편집 맵을 채운다. */
function normalizeEdits(edits: AlignmentEdits | undefined): AlignmentEdits {
return {
station_offsets: edits?.station_offsets ?? {},
curve_radii: edits?.curve_radii ?? {},
};
}
export function buildAlignment(base: AlignmentBase, input: AlignmentEdits): ProfileAlignment {
const edits = normalizeEdits(input);
const warnings: string[] = [];
const nodes = resolvePvi(base, edits.station_offsets);
const pviS = nodes.map((node) => node.chainage_m);
const pviZ = nodes.map((node) => node.elevation_m);
const curves = buildCurves(
nodes,
base.policy,
edits.curve_radii,
warnings,
base.onlyExplicitCurves === true,
);
const segments: AlignmentSegment[] = [];
for (let index = 0; index < nodes.length - 1; index += 1) {
const length = pviS[index + 1] - pviS[index];
const height = pviZ[index + 1] - pviZ[index];
segments.push({
index,
from_m: pviS[index],
to_m: pviS[index + 1],
length_m: length,
height_m: height,
grade_percent: length > 0 ? (height / length) * 100 : 0,
});
}
curves.forEach((curve) => {
curve.bvc_elevation_m = evaluateAt(pviS, pviZ, curves, curve.bvc_m);
curve.evc_elevation_m = evaluateAt(pviS, pviZ, curves, curve.evc_m);
});
// 샘플 격자에 변화점(PVI)과 종단곡선 시·종점(BVC/EVC)을 **합쳐서** 그린다.
// 격자만 쓰면 격자 사이에 놓인 변화점(배관 구조물 자리처럼 임의 chainage에 승격된 점)의
// 모서리를 선이 잘라먹어, 종단곡선 R이 안 보이고 편집 지점과 선이 어긋나 끊긴 것처럼
// 보인다(2026-08-03 사용자 보고). 곡선 구간 끝점까지 넣어야 직선→곡선 이음이 정확히 붙는다.
const sampleChainages = new Set<number>(base.chainage.map((value) => Number(value.toFixed(3))));
const first = base.chainage[0];
const last = base.chainage[base.chainage.length - 1];
for (const extra of [
...pviS,
...curves.flatMap((curve) => [curve.bvc_m, curve.chainage_m, curve.evc_m]),
]) {
if (extra >= first - 1e-6 && extra <= last + 1e-6) {
sampleChainages.add(Number(extra.toFixed(3)));
}
}
const samples: AlignmentSample[] = [...sampleChainages]
.sort((a, b) => a - b)
.map((chainage) => {
const plan = evaluateAt(pviS, pviZ, curves, chainage);
const ground = interpolate(base.chainage, base.ground, chainage);
return {
chainage_m: chainage,
elevation_m: plan,
ground_elevation_m: ground,
difference_m: plan - ground,
};
});
// 면적 가중치도 **합쳐진 샘플 격자**로 계산해야 한다 — base.chainage로 두면 추가된
// 변화점 샘플과 인덱스가 어긋나 절·성토 면적이 통째로 틀어진다.
const weights = trapezoidWeights(samples.map((sample) => sample.chainage_m));
let cutArea = 0;
let fillArea = 0;
samples.forEach((sample, index) => {
if (sample.difference_m < 0) cutArea += weights[index] * -sample.difference_m;
else fillArea += weights[index] * sample.difference_m;
});
const reference = Math.max(cutArea, fillArea);
const imbalance = reference > 1e-9 ? (Math.abs(cutArea - fillArea) / reference) * 100 : 0;
const stations: AlignmentStationRow[] = base.stations.map((station, index) => {
const plan = evaluateAt(pviS, pviZ, curves, station.chainage_m);
const ground = interpolate(base.chainage, base.ground, station.chainage_m);
return {
station_id: station.station_id,
chainage_m: station.chainage_m,
distance_m: index ? station.chainage_m - base.stations[index - 1].chainage_m : 0,
ground_elevation_m: ground,
plan_elevation_m: plan,
cut_m: Math.max(ground - plan, 0),
fill_m: Math.max(plan - ground, 0),
};
});
const curveByPvi = new Map(curves.map((curve) => [curve.pvi_index, curve]));
const pviRows: AlignmentPvi[] = nodes.map((node, index) => {
const curve = curveByPvi.get(index);
return {
chainage_m: node.chainage_m,
elevation_m: node.elevation_m,
source: node.source,
kind: index === 0 ? "bp" : index === nodes.length - 1 ? "ep" : "pvi",
grade_in_pct: index ? segments[index - 1].grade_percent : null,
grade_out_pct: index < segments.length ? segments[index].grade_percent : null,
curve_l_m: curve ? curve.l_m : null,
curve_r_m: curve ? curve.r_m : null,
};
});
const violations: AlignmentViolation[] = segments
.filter((segment) => Math.abs(segment.grade_percent) > base.policy.max_grade_pct + 1e-6)
.map((segment) => ({
segment_index: segment.index,
type: "grade_over",
value: segment.grade_percent,
limit: base.policy.max_grade_pct,
}));
const withinTolerance = imbalance <= base.policy.balance_tolerance_percent + 1e-9;
return {
schema_version: 1,
policy: base.policy,
only_explicit_curves: base.onlyExplicitCurves === true,
base_pvi: base.basePvi,
edits,
pvi: pviRows,
segments,
curves: curves.map(({ grade_in: _in, grade_out: _out, ...rest }) => rest),
stations,
samples,
balance: {
cut_area_m2: cutArea,
fill_area_m2: fillArea,
net_area_m2: fillArea - cutArea,
imbalance_percent: imbalance,
tolerance_percent: base.policy.balance_tolerance_percent,
within_tolerance: withinTolerance,
},
violations,
warnings,
};
}
/**
* 계획선 위 지점의 계획고.
* 측점 위라면 곡선까지 반영해 정확히 계산된 측점 행 값을 쓰고, 그 밖에서는
* 샘플 보간으로 폴백한다(편집 판정은 항상 측점 위에서 일어난다).
*/
export function planElevationAt(alignment: ProfileAlignment, chainageM: number): number {
const station = alignment.stations.find((row) => Math.abs(row.chainage_m - chainageM) < 1e-6);
if (station) return station.plan_elevation_m;
return interpolate(
alignment.samples.map((sample) => sample.chainage_m),
alignment.samples.map((sample) => sample.elevation_m),
chainageM,
);
}
const FEEDBACK_PASSES = 4;
const FEEDBACK_TOLERANCE_M = 1e-4;
/**
* 측점 계획고를 delta만큼 올리거나 내린 편집 델타를 만든다.
*
* 변화점이 새로 생기면 종단곡선의 중앙종거만큼 계획고가 함께 내려가(또는 올라가)
* 버튼 1클릭이 정확히 0.1m가 되지 않는다. **화면에 보이는 계획고**가 정확히 delta만큼
* 움직이도록 중앙종거 변화분을 몇 번 되먹여 보정한다.
*/
export function adjustStation(
base: AlignmentBase,
edits: AlignmentEdits,
chainageM: number,
delta: number,
): AlignmentEdits {
const key = chainageKey(chainageM);
const current = buildAlignment(base, edits);
const target = planElevationAt(current, chainageM) + delta;
const baseElevation = interpolate(
base.basePvi.map((node) => node.chainage_m),
base.basePvi.map((node) => node.elevation_m),
chainageM,
);
let offset =
(edits.station_offsets[key] ?? planElevationAt(current, chainageM) - baseElevation) + delta;
const withOffset = (value: number): AlignmentEdits => ({
...edits,
station_offsets: { ...edits.station_offsets, [key]: Number(value.toFixed(6)) },
});
let next = withOffset(offset);
for (let pass = 0; pass < FEEDBACK_PASSES; pass += 1) {
const error = target - planElevationAt(buildAlignment(base, next), chainageM);
if (Math.abs(error) < FEEDBACK_TOLERANCE_M) break;
offset += error;
next = withOffset(offset);
}
return next;
}
/**
* 제어점 표고 — 그 자리에 변화점이 있으면 그 z(라운드 중심점), 없으면 곡선 샘플.
*
* 틸팅은 지점을 라운드로 주변 직선과 잇는 조작이라, 이웃을 틸팅하면 라운드 **형상**은
* 변해도 제어점 z는 그대로다(2026-08-23 사용자 개념 확정). 횡단배수 최소고 판정도
* 이 값 기준이어야 이웃 틸팅이 부당하게 잠기지 않는다.
*/
export function controlElevationAt(alignment: ProfileAlignment, chainageM: number): number {
const node = alignment.pvi.find((entry) => Math.abs(entry.chainage_m - chainageM) < 1e-6);
return node ? node.elevation_m : planElevationAt(alignment, chainageM);
}
/**
* 구간 이동에서 실제로 움직일 두 지점(2026-08-23 사용자 개념 확정).
*
* 구간 끝이 사용자 변화점(틸팅한 점)이면 그 점이 직접 움직인다 — 반복 쉬프트.
* 고정점(BP·EP·자동 변화점 = 횡단배수 앵커)이면 **절대 움직이지 않고**, 구간 안쪽
* 틸트 안 된 정규 측점 중 외곽(첫/끝)을 힌지로 승격시킨다 — 힌지에 라운드가 생기고
* 고정점과는 직선으로 이어진다. 힌지 둘을 만들 수 없으면 null(쉬프트 불가 구간:
* 안쪽 미틸트 측점 2개 미만).
*/
export function shiftMovingPoints(
alignment: ProfileAlignment,
segment: AlignmentSegment,
): [number, number] | null {
const offsets = alignment.edits.station_offsets;
const movable = (chainage: number): boolean => offsets[chainageKey(chainage)] !== undefined;
const interior = alignment.stations
.map((station) => station.chainage_m)
.filter((chainage) => chainage > segment.from_m + 1e-6 && chainage < segment.to_m - 1e-6);
const left = movable(segment.from_m) ? segment.from_m : interior[0];
const right = movable(segment.to_m) ? segment.to_m : interior[interior.length - 1];
if (left === undefined || right === undefined) return null;
if (right - left < 1e-6) return null;
return [left, right];
}
/**
* 구간 평행이동 — 움직일 두 지점(끝 변화점 또는 승격 힌지)에 같은 델타를 준다.
* 두 지점 사이 직선의 기울기는 보존되고, 고정점과의 연결 직선·라운드만 다시 잡힌다.
* 라운드(중앙종거) 탓에 1클릭이 정확히 delta가 되도록 화면 계획고 기준으로 되먹인다.
*/
export function shiftSegment(
base: AlignmentBase,
edits: AlignmentEdits,
segment: AlignmentSegment,
delta: number,
): AlignmentEdits {
const current = buildAlignment(base, edits);
const moving = shiftMovingPoints(current, segment);
if (!moving) return edits;
const baseS = base.basePvi.map((node) => node.chainage_m);
const baseZ = base.basePvi.map((node) => node.elevation_m);
const targets = moving.map((chainage) => planElevationAt(current, chainage) + delta);
const offsets = { ...edits.station_offsets };
moving.forEach((chainage) => {
const key = chainageKey(chainage);
const seed =
offsets[key] ?? planElevationAt(current, chainage) - interpolate(baseS, baseZ, chainage);
offsets[key] = Number((seed + delta).toFixed(6));
});
for (let pass = 0; pass < FEEDBACK_PASSES; pass += 1) {
const trial = buildAlignment(base, { ...edits, station_offsets: offsets });
let worst = 0;
moving.forEach((chainage, index) => {
const key = chainageKey(chainage);
const error = targets[index] - planElevationAt(trial, chainage);
worst = Math.max(worst, Math.abs(error));
offsets[key] = Number((offsets[key] + error).toFixed(6));
});
if (worst < FEEDBACK_TOLERANCE_M) break;
}
return { ...edits, station_offsets: offsets };
}
/**
* 곡선 행의 R 입력을 저장한다. R이 1차 값이므로 그대로 담고, 곡선길이는
* L = R × |대수차| 로 매 렌더마다 다시 계산된다(계획고를 편집해도 R은 유지된다).
* 빈 값이면 키를 지워 정책 기본 반경으로 돌아간다.
*/
export function setCurveRadius(
edits: AlignmentEdits,
curve: AlignmentCurve,
radiusM: number,
): AlignmentEdits {
const key = chainageKey(curve.chainage_m);
const curveRadii = { ...edits.curve_radii };
if (!Number.isFinite(radiusM) || radiusM <= 0) delete curveRadii[key];
else curveRadii[key] = Number(radiusM.toFixed(6));
return { ...edits, curve_radii: curveRadii };
}
export function hasEdits(edits: AlignmentEdits): boolean {
const safe = normalizeEdits(edits);
return Object.keys(safe.station_offsets).length > 0 || Object.keys(safe.curve_radii).length > 0;
}