diff --git a/B05_Profile/B05_Profile_UI_Corridor.ts b/B05_Profile/B05_Profile_UI_Corridor.ts index 8b95877d..40d9bf8c 100644 --- a/B05_Profile/B05_Profile_UI_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Corridor.ts @@ -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 ?? [], diff --git a/B05_Profile/B05_Profile_UI_Corridor_Build.ts b/B05_Profile/B05_Profile_UI_Corridor_Build.ts index 0dd96969..1e096358 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Build.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Build.ts @@ -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 = { }; /** 종방향 세분 간격(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); diff --git a/B05_Profile/B05_Profile_UI_Profile_Edit.ts b/B05_Profile/B05_Profile_UI_Profile_Edit.ts index 97542062..629abe72 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Edit.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Edit.ts @@ -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),