fix(B05): 코리도 평면 궤적 스플라인화 + 좌표 정밀도 + 편집 되돌리기 곡선반경 보존
평면 궤적 (사용자 제안 채택) - 노선 정점을 직선으로 잇던 것을 centripetal Catmull-Rom으로 교체. 실측 노선은 정점 135개·평균 간격 2.61m·꺾임각 최대 42.4°라, 0.1m로 잘게 쪼개도 정점 이음매의 각짐은 그대로였다 - 스플라인 도입으로 세분은 0.2m로 되돌림 — 데이터·연산 절반 좌표 정밀도 (지그재그의 진짜 원인) - 코리도 좌표는 EPSG 절대값(183,494 / 489,290)이라 Float32에 담으면 해상도가 0.14m까지 떨어져, 0.2m 세분 점들이 양자화 계단을 타며 최대 35°짜리 각도 노이즈가 생겼다. positions를 Float64로 올린다(화면에 올릴 때 원점을 빼며 Float32로 줄이므로 렌더 비용은 그대로) - 실측: 중심선 꺾임 평균 4.271° → 0.704°, 5° 초과 838개 → 25개 편집 되돌리기 - resetAll이 자동 계획선이 역산해 저장한 curve_radii까지 비워, 프론트가 기본 R로 다시 그리며 화면 계획고가 정본과 어긋났다(세월교 지점 0.22m 낮음 → 최소고 경고 오탐). 사용자 편집 측점만 지우고 곡선반경은 저장분으로 되돌린다 검증: pytest 14/14(스플라인 제어점 통과·오버슈트 없음 3건 신규), tsc 통과, 화면 실측 — 경고 소거, 줌인 시 곡선 매끄러움 확인 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -95,7 +95,7 @@ export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePo
|
||||
return fnv1a(parts.join("|"));
|
||||
}
|
||||
|
||||
function base64FromFloat32(values: Float32Array): string {
|
||||
function base64FromFloats(values: Float64Array): string {
|
||||
const bytes = new Uint8Array(values.buffer, values.byteOffset, values.byteLength);
|
||||
let binary = "";
|
||||
const CHUNK = 0x8000;
|
||||
@@ -105,11 +105,11 @@ function base64FromFloat32(values: Float32Array): string {
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function float32FromBase64(encoded: string): Float32Array {
|
||||
function floatsFromBase64(encoded: string): Float64Array {
|
||||
const binary = atob(encoded);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
||||
return new Float32Array(bytes.buffer);
|
||||
return new Float64Array(bytes.buffer);
|
||||
}
|
||||
|
||||
function serialize(build: CorridorBuildResult, hash: string): CorridorEnvelope {
|
||||
@@ -121,7 +121,7 @@ function serialize(build: CorridorBuildResult, hash: string): CorridorEnvelope {
|
||||
side: ribbon.side,
|
||||
colCount: ribbon.colCount,
|
||||
chainages: ribbon.chainages,
|
||||
positionsBase64: base64FromFloat32(ribbon.positions),
|
||||
positionsBase64: base64FromFloats(ribbon.positions),
|
||||
})),
|
||||
outline: build.outline,
|
||||
caps: build.caps,
|
||||
@@ -135,7 +135,7 @@ function deserialize(envelope: CorridorEnvelope): CorridorBuildResult {
|
||||
side: ribbon.side,
|
||||
colCount: ribbon.colCount,
|
||||
chainages: ribbon.chainages,
|
||||
positions: float32FromBase64(ribbon.positionsBase64),
|
||||
positions: floatsFromBase64(ribbon.positionsBase64),
|
||||
})),
|
||||
outline: envelope.outline,
|
||||
caps: envelope.caps ?? [],
|
||||
|
||||
@@ -24,8 +24,14 @@ export interface CorridorRibbon {
|
||||
side: CorridorSide;
|
||||
colCount: number;
|
||||
chainages: number[];
|
||||
/** rowCount × colCount × 3 (모델 좌표). */
|
||||
positions: Float32Array;
|
||||
/**
|
||||
* rowCount × colCount × 3 (모델 좌표).
|
||||
*
|
||||
* **Float64**다 — 모델 좌표는 EPSG 절대값(십만 m대)이라 Float32에 담으면 해상도가
|
||||
* 0.14m까지 떨어져, 0.2m로 세분한 점들이 양자화 계단을 타며 최대 35°짜리 지그재그가
|
||||
* 생겼다(2026-08-23 실측). 화면에 올릴 때 원점을 빼면서 Float32로 줄인다.
|
||||
*/
|
||||
positions: Float64Array;
|
||||
}
|
||||
|
||||
/** 시·종점 마구리(캡) — 설계선과 지반선 사이를 세로로 봉인하는 스트립(모델 좌표). */
|
||||
@@ -52,9 +58,9 @@ const PIECE_COLS: Record<CorridorKind, number> = {
|
||||
};
|
||||
|
||||
/** 종방향 세분 간격(m) — 곡선 각짐 방지(2026-08-23 사용자: 노선 폴리라인 따라 세분).
|
||||
* 0.1m로 좁혀 종단 방향 서피스를 최대한 부드럽게 한다(2026-08-23 사용자 지정).
|
||||
* 느려지면 재조정하기로 한 값이다. */
|
||||
const SUBDIVIDE_STEP_M = 0.1;
|
||||
* 평면 궤적을 스플라인으로 잇게 되어(2026-08-23) 0.1m까지 쪼갤 이유가 없어졌다 —
|
||||
* 0.2m면 곡선이 충분히 매끄럽고 데이터·연산은 절반이다(사용자 제안). */
|
||||
const SUBDIVIDE_STEP_M = 0.2;
|
||||
|
||||
interface XY {
|
||||
x: number;
|
||||
@@ -280,6 +286,39 @@ function classifyStation(section: CrossSection): StationPieces | null {
|
||||
}
|
||||
|
||||
/** 노선 폴리라인 누적거리 파라미터화 — chainage로 XY를 보간한다. */
|
||||
/**
|
||||
* Centripetal Catmull-Rom 한 구간(p1→p2). 제어점을 지나면서 오버슈트·자기교차가
|
||||
* 없는 성질이라 급커브(실측 꺾임각 최대 42°)에도 안전하다.
|
||||
*/
|
||||
function catmullRom(p0: XY, p1: XY, p2: XY, p3: XY, t: number): XY {
|
||||
const ALPHA = 0.5; // centripetal
|
||||
const knot = (previous: number, a: XY, b: XY): number =>
|
||||
previous + Math.max(1e-6, Math.pow(Math.hypot(b.x - a.x, b.y - a.y), ALPHA));
|
||||
const t0 = 0;
|
||||
const t1 = knot(t0, p0, p1);
|
||||
const t2 = knot(t1, p1, p2);
|
||||
const t3 = knot(t2, p2, p3);
|
||||
const time = t1 + (t2 - t1) * t;
|
||||
const mix = (a: XY, b: XY, ta: number, tb: number): XY => {
|
||||
const span = tb - ta || 1e-9;
|
||||
const w = (tb - time) / span;
|
||||
return { x: a.x * w + b.x * (1 - w), y: a.y * w + b.y * (1 - w) };
|
||||
};
|
||||
const a1 = mix(p0, p1, t0, t1);
|
||||
const a2 = mix(p1, p2, t1, t2);
|
||||
const a3 = mix(p2, p3, t2, t3);
|
||||
const b1 = mix(a1, a2, t0, t2);
|
||||
const b2 = mix(a2, a3, t1, t3);
|
||||
return mix(b1, b2, t1, t2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 노선 폴리라인 누적거리 파라미터화 — chainage로 XY를 돌려준다.
|
||||
*
|
||||
* 정점 사이를 직선으로 이으면 정점 간격(실측 평균 2.6m)만큼 각진다 — 0.1m로 잘게
|
||||
* 쪼개도 그 각짐은 그대로다(2026-08-23 사용자 지적). 그래서 **스플라인**으로 잇고
|
||||
* 세분 간격은 0.2m로 되돌린다: 데이터는 절반, 곡선은 훨씬 매끄럽다.
|
||||
*/
|
||||
function buildPolylineSampler(routePoints: RoutePoint[]): ((chainage: number) => XY) | null {
|
||||
const points = routePoints.filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y));
|
||||
if (points.length < 2) return null;
|
||||
@@ -289,16 +328,18 @@ function buildPolylineSampler(routePoints: RoutePoint[]): ((chainage: number) =>
|
||||
cumulative[i - 1] + Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y),
|
||||
);
|
||||
}
|
||||
const at = (index: number): XY => {
|
||||
const clamped = Math.min(points.length - 1, Math.max(0, index));
|
||||
return { x: points[clamped].x, y: points[clamped].y };
|
||||
};
|
||||
return (chainage: number): XY => {
|
||||
const target = Math.min(Math.max(chainage, 0), cumulative[cumulative.length - 1]);
|
||||
let index = 1;
|
||||
while (index < points.length - 1 && cumulative[index] < target) index += 1;
|
||||
const span = cumulative[index] - cumulative[index - 1];
|
||||
const t = span <= 1e-12 ? 0 : (target - cumulative[index - 1]) / span;
|
||||
return {
|
||||
x: points[index - 1].x + (points[index].x - points[index - 1].x) * t,
|
||||
y: points[index - 1].y + (points[index].y - points[index - 1].y) * t,
|
||||
};
|
||||
// 양 끝 구간은 바깥 제어점이 없다 — 끝점을 겹쳐 써서 접선이 튀지 않게 한다.
|
||||
return catmullRom(at(index - 2), at(index - 1), at(index), at(index + 1), t);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -511,7 +552,7 @@ export function buildCorridor(
|
||||
return;
|
||||
}
|
||||
const chainages: number[] = [];
|
||||
const positions = new Float32Array((endExclusive - start) * colCount * 3);
|
||||
const positions = new Float64Array((endExclusive - start) * colCount * 3);
|
||||
let cursor = 0;
|
||||
for (let row = start; row < endExclusive; row += 1) {
|
||||
chainages.push(rows[row].chainage_m);
|
||||
|
||||
@@ -150,11 +150,18 @@ export function createProfileEditStore(
|
||||
const stationOffsets = { ...current.station_offsets };
|
||||
const curveRadii = { ...current.curve_radii };
|
||||
delete stationOffsets[key];
|
||||
delete curveRadii[key];
|
||||
// 그 측점의 곡선반경은 저장분(자동 계산이 역산해 둔 값)으로 되돌린다 — 지우면
|
||||
// 프론트가 기본 R로 다시 그려 계획고가 서버 정본과 어긋난다(2026-08-23).
|
||||
const savedRadius = saved.curve_radii?.[key];
|
||||
if (savedRadius === undefined) delete curveRadii[key];
|
||||
else curveRadii[key] = savedRadius;
|
||||
commit({ station_offsets: stationOffsets, curve_radii: curveRadii });
|
||||
},
|
||||
resetAll() {
|
||||
commit(emptyEdits());
|
||||
// 지우는 것은 **사용자 편집 측점**이다. 자동 계획선이 역산해 저장한 곡선반경
|
||||
// (`curve_radii`)까지 비우면 호가 달라져 화면 계획고가 정본과 달라진다 —
|
||||
// 세월교 지점에서 0.22m 낮게 나와 최소고 경고가 잘못 떴다(2026-08-23 진단).
|
||||
commit({ ...emptyEdits(), curve_radii: { ...(saved.curve_radii ?? {}) } });
|
||||
},
|
||||
dirty: () => unsaved,
|
||||
edited: () => hasEdits(current),
|
||||
|
||||
Reference in New Issue
Block a user