From 1f631bc3d425376534209e38e070933889b8b69d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 23 Aug 2026 14:40:56 +0900 Subject: [PATCH] =?UTF-8?q?fix(B05):=20=EC=BD=94=EB=A6=AC=EB=8F=84=20?= =?UTF-8?q?=ED=8F=89=EB=A9=B4=20=EA=B6=A4=EC=A0=81=20=EC=8A=A4=ED=94=8C?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8=ED=99=94=20+=20=EC=A2=8C=ED=91=9C=20?= =?UTF-8?q?=EC=A0=95=EB=B0=80=EB=8F=84=20+=20=ED=8E=B8=EC=A7=91=20?= =?UTF-8?q?=EB=90=98=EB=8F=8C=EB=A6=AC=EA=B8=B0=20=EA=B3=A1=EC=84=A0?= =?UTF-8?q?=EB=B0=98=EA=B2=BD=20=EB=B3=B4=EC=A1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 평면 궤적 (사용자 제안 채택) - 노선 정점을 직선으로 잇던 것을 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) --- B05_Profile/B05_Profile_UI_Corridor.ts | 10 ++-- B05_Profile/B05_Profile_UI_Corridor_Build.ts | 61 ++++++++++++++++---- B05_Profile/B05_Profile_UI_Profile_Edit.ts | 11 +++- 3 files changed, 65 insertions(+), 17 deletions(-) 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),