2026-08-03(d21b0ec)에 곡선 기준을 R에서 L로 바꿨지만 배관 정착 계획선 산출부
한 곳에만 들어갔다. 공용 곡선 계산기와 정책값은 R 기준(측점간격 20m ×
curve_radius_ratio 0.40 = 8m)으로 남아, 사용자가 변화점을 새로 추가하면 그 자리만
R=8m가 적용됐다(대수차 3%면 L=0.24m — 도면에서 곡선이 사라진다). 화면 요약도
"기본 R 8.0 m"를 그대로 보여줘 오해를 키웠다.
- config: pipe_anchor_curve_length_m -> default_curve_length_m(15.0)로 승격.
배관 정착뿐 아니라 모든 변화점의 1차 기준값이다.
- AlignmentPolicy에 default_curve_length_m 추가, 정책 스냅샷에도 실어 보낸다.
옛 R 기준 값(default_curve_radius_m)은 옛 저장분 호환용으로 남긴다.
- build_curves(백엔드)와 buildCurves(프론트) 규칙 일원화:
사용자 지정 R > 기본 L > (옛 저장분) 기본 R × 대수차.
- 인접 직선이 짧아 목표 L을 못 넣으면 **넣을 수 있는 최대 L**까지만 줄이고 경고를
남긴다(2026-08-08 사용자 지시). 경고 문구도 길이 우선으로 고쳤다.
- 옛 저장분(정책에 L 없음)은 양쪽 모두 R 기준을 유지한다 — 서버만 L로 올리면
화면과 다른 곡선이 나온다. 새 기준은 계획선을 다시 산출할 때 적용된다.
- 패널 요약 라벨: "기본 R" -> "기본 곡선길이 L"(옛 저장분은 "기본 R(옛 저장분)").
검증(측점간격 20m, 대수차 3%): 기본 L=15.00m/R=500m, 사용자 R=300 지정 시
L=9.00m/R=300m, 인접 직선 10m면 L=9.00m로 잘리고 경고 1건, 옛 스냅샷은 종전대로
L=0.24m/R=8m. typecheck·ruff format·check 통과.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
535 lines
20 KiB
TypeScript
535 lines
20 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;
|
|
}
|
|
|
|
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;
|
|
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;
|
|
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,
|
|
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[],
|
|
): 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;
|
|
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);
|
|
|
|
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,
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 직선 구간 전체를 평행이동한다 (기울기 유지, 양 끝 변화점 동시 이동).
|
|
*
|
|
* 측점 버튼은 그 점을 꺾는 조작이라 "구배는 그대로 두고 높이만" 옮길 수 없다.
|
|
* 구간 양 끝에 같은 델타를 주면 그 직선의 기울기는 보존되고 인접 직선의 각도만 바뀐다.
|
|
*/
|
|
export function shiftSegment(
|
|
base: AlignmentBase,
|
|
edits: AlignmentEdits,
|
|
segment: AlignmentSegment,
|
|
delta: number,
|
|
): AlignmentEdits {
|
|
const baseS = base.basePvi.map((node) => node.chainage_m);
|
|
const baseZ = base.basePvi.map((node) => node.elevation_m);
|
|
const current = buildAlignment(base, edits);
|
|
const offsets = { ...edits.station_offsets };
|
|
[segment.from_m, segment.to_m].forEach((chainage) => {
|
|
const key = chainageKey(chainage);
|
|
const existing =
|
|
offsets[key] ?? planElevationAt(current, chainage) - interpolate(baseS, baseZ, chainage);
|
|
offsets[key] = Number((existing + delta).toFixed(6));
|
|
});
|
|
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;
|
|
}
|