2026-09-02 사용자 지시 9건 반영. 화면 실조작 검증은 다음 세션 몫 (사용자 지시로 코딩까지만 진행). 1. 초기 계획선 = 전체 측점 폴리라인 — `design_ground_following_profile()` 신설. 모든 측점을 변화점으로 잡고 계획고 = 원지반고, 라운드는 R을 지정한 자리에만 (`build_curves(only_explicit=)`·`build_alignment(only_explicit_curves=)`). basis `ground_polyline`. 관 정착 선형은 폴백으로 내림. 2. 구간 쉬프트(⬆⬇) 삭제. 3. [직선화] 신설 — `B05_Profile_UI_Profile_Straighten.ts`. 두 측점의 라운드에 탄젠트한 직선으로 대체하고 사이 라운드 삭제. 직선 틸팅 시 가운데 라운드 + 양측 탄젠트 재구성. 4. [쉬프트] 신설 — 직선 구간을 기하에서 되읽어(`detectStraightRun`) 복수 선택, 최외곽 라운드 중심 기준 상·하 평행이동. 5. 방향키 조작 — 상하 0.1m 계획고, 좌우 0.1m 누가거리(구조물·비정규 측점 한정). 6. undo/redo 신설 — B05·B06 조작 세션 키 묶음 스냅샷(`_Profile_History.ts`). 버튼은 요약줄 맨 앞(최대 기울기 좌측), 21x17px. Ctrl+Z / Ctrl+Shift+Z. 7. 종단 요약줄의 횡단배수 최소고 표시 삭제(산식·편집 차단은 유지). 8. [편집 되돌리기] 버튼 삭제 — undo/redo로 대체. 9. 지형 구분 기본값 특수지형 — 패널 셀렉트와 백엔드 기본값(스키마·체인 폴백) 일치. 700줄 제한 — 패널을 `_Profile_Panel_Tools` · `_Profile_Preview` 로 분리하고 측점↔구조물 짝짓기를 `_Profile_Structures` 로 이관(842줄 → 696줄). 검증: tsc --noEmit 오류 0, ruff format/check 통과, pytest tmp/tests/ -q → 366 passed / 14 skipped / 0 failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
601 lines
23 KiB
TypeScript
601 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;
|
|
}
|
|
|
|
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;
|
|
}
|