diff --git a/B05_Profile/B05_Profile_UI_Corridor.ts b/B05_Profile/B05_Profile_UI_Corridor.ts index 43069cb9..6d0f2f20 100644 --- a/B05_Profile/B05_Profile_UI_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Corridor.ts @@ -73,7 +73,7 @@ function fnv1a(text: string): string { * (2026-08-23 사용자 보고: "원복이 안 된 것 같다가 갑자기 반영됨"). 판번호를 해시에 * 섞어 두면 배포와 동시에 저장본이 만료된다. */ -const BUILD_VERSION = 2; +const BUILD_VERSION = 4; /** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */ export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string { diff --git a/B05_Profile/B05_Profile_UI_Corridor_Build.ts b/B05_Profile/B05_Profile_UI_Corridor_Build.ts index c36e81f9..577ac197 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Build.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Build.ts @@ -414,6 +414,48 @@ function buildProfileSampler( }; } +/** + * 계획고와 지반고가 만나는 자리(절토↔성토 전환점)를 찾는 함수를 만든다. + * + * 두 측점 사이 어딘가에서 계획선이 지면선을 통과한다 — 그 자리가 측구·절토의 + * 종점이고 거기서부터 성토가 시작된다(2026-08-23 사용자 확정). 종단 계획선 샘플의 + * `계획고 - 지반고` 부호가 뒤집히는 구간을 선형보간해 정확한 누가거리를 낸다. + */ +function buildCrossingFinder( + samples?: Array<{ chainage_m: number; elevation_m: number; ground_elevation_m?: number }>, +): ((fromM: number, toM: number) => number | null) | null { + const usable = (samples ?? []) + .filter((sample) => typeof sample.ground_elevation_m === "number") + .map((sample) => ({ + chainage_m: sample.chainage_m, + difference: sample.elevation_m - (sample.ground_elevation_m as number), + })) + .sort((a, b) => a.chainage_m - b.chainage_m); + if (usable.length < 2) return null; + return (fromM: number, toM: number): number | null => { + const lo = Math.min(fromM, toM); + const hi = Math.max(fromM, toM); + for (let i = 1; i < usable.length; i += 1) { + const a = usable[i - 1]; + const b = usable[i]; + if (b.chainage_m <= lo || a.chainage_m >= hi) continue; + // 계획고와 지반고가 정확히 같은 샘플 — 구간 **안쪽**일 때만 전환점이다. + // BP·EP는 정의상 지반과 만나므로, 이걸 거르지 않으면 구간 시작점이 전환점으로 + // 잡혀 측구·절토가 0.4m 만에 끝났다(2026-08-23 실측). + if (a.difference === 0) { + if (a.chainage_m > lo && a.chainage_m < hi) return a.chainage_m; + continue; + } + if (a.difference * b.difference >= 0) continue; + const span = b.chainage_m - a.chainage_m; + const ratio = span <= 1e-12 ? 0 : a.difference / (a.difference - b.difference); + const crossing = a.chainage_m + span * ratio; + if (crossing > lo && crossing < hi) return crossing; + } + return null; + }; +} + /** * 종단 보정량을 단면에 싣는다. 도로·노견·측구는 계획고에 붙어 있으므로 통째로, * 절·성토 비탈은 안쪽(도로측) 전량 → 바깥쪽(지반 접점) 0으로 감쇠시킨다 — @@ -454,9 +496,17 @@ function degeneratePiece( export function buildCorridor( crossSections: CrossSection[], routePoints: RoutePoint[], - /** 종단 계획선 샘플 — 측점 사이를 직선이 아닌 **계획선 그대로**(종단곡선 포함) - * 따라가게 한다(2026-08-23 사용자: 종단 방향을 더 부드럽게). */ - designSamples?: Array<{ chainage_m: number; elevation_m: number }>, + /** + * 종단 계획선 샘플 — 두 가지에 쓴다. + * ① 측점 사이를 직선이 아닌 **계획선 그대로**(종단곡선 포함) 따라가게 한다. + * ② 계획고와 지반고가 만나는 자리 = **절토↔성토 전환점**을 찾는다(2026-08-23 + * 사용자 확정): 그 지점이 측구·절토의 종점이고, 거기서부터 성토로 넘어간다. + */ + designSamples?: Array<{ + chainage_m: number; + elevation_m: number; + ground_elevation_m?: number; + }>, subdivideStepM: number = SUBDIVIDE_STEP_M, ): CorridorBuildResult | null { const stations = crossSections @@ -468,6 +518,8 @@ export function buildCorridor( const sampler = buildPolylineSampler(routePoints); // 종단 계획선 보간기 — 없으면 측점 간 직선(구 동작)으로 떨어진다. const profileZ = buildProfileSampler(designSamples); + // 절토↔성토 전환점 탐색기 — 측구·절토의 종점을 이 자리로 잡는다. + const crossingAt = buildCrossingFinder(designSamples); // 등장하는 (kind,side) 전체 — 리본 목록 확정. const keys = new Set(); stations.forEach((s) => s.pieces.forEach((_v, key) => keys.add(key))); @@ -490,6 +542,10 @@ export function buildCorridor( // 깎여 종단이 계단처럼 보였다(2026-08-23 사용자 지적, 저장본 702행으로 확인). const steps = Math.max(1, Math.ceil(span / Math.max(0.01, subdivideStepM))); const last = i === stations.length - 2; + // 이 구간의 절토↔성토 전환 위치(0~1). 못 찾으면 중간으로 둔다(구 동작). + const crossing = crossingAt?.(s0.chainage_m, s1.chainage_m) ?? null; + const tSwitch = + crossing === null ? 0.5 : Math.min(0.98, Math.max(0.02, (crossing - s0.chainage_m) / span)); for (let k = 0; k <= (last ? steps : steps - 1); k += 1) { const t = k / steps; const chainage = s0.chainage_m + span * t; @@ -527,7 +583,8 @@ export function buildCorridor( // 측구는 **도로 끝에 얹는다** — 도로부는 한 몸이므로 위치를 따로 보간하지 // 않는다(2026-08-23 사용자 지시). 한쪽 측점에만 있으면 그 절반 구간만 // 그리되, 자리는 항상 그 프레임의 노견 끝을 따라간다. - if (hasA !== hasB && (hasA ? t > 0.5 : t < 0.5)) return; + // 측구 종점 = 절토↔성토 전환점(계획선이 지면선을 통과하는 자리). + if (hasA !== hasB && (hasA ? t > tSwitch : t < tSwitch)) return; const relA = s0.ditchRelative.get(key); const relB = s1.ditchRelative.get(key); const relative = relA && relB ? lerpPoints(relA, relB, t) : (relA ?? relB); @@ -545,7 +602,16 @@ export function buildCorridor( } else { const pa = s0.pieces.get(key) ?? degeneratePiece(s0, kind, side); const pb = s1.pieces.get(key) ?? degeneratePiece(s1, kind, side); - points = lerpPoints(pa, pb, t); + // 절·성토가 한쪽 측점에만 있으면 **전환점에서 폭 0이 되도록** 보간 구간을 + // 다시 잡는다. 구간 전체에 걸쳐 서서히 사라지던 것을, 실제로 절토가 끝나고 + // 성토가 시작되는 자리에 맞춘다(2026-08-23 사용자 확정). + const taper = + (kind === "cut" || kind === "fill") && hasA !== hasB + ? hasA + ? Math.min(1, t / Math.max(1e-6, tSwitch)) + : Math.max(0, (t - tSwitch) / Math.max(1e-6, 1 - tSwitch)) + : t; + points = lerpPoints(pa, pb, taper); } if (shift !== 0) applyProfileShift(points, kind, side, shift); sections.set(key, points);