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:
2026-08-23 14:40:56 +09:00
co-authored by Claude Opus 5
parent 59ca25b182
commit 1f631bc3d4
3 changed files with 65 additions and 17 deletions
+5 -5
View File
@@ -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 ?? [],
+51 -10
View File
@@ -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);
+9 -2
View File
@@ -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),