diff --git a/B05_Profile/B05_Profile_UI_Corridor.ts b/B05_Profile/B05_Profile_UI_Corridor.ts index 40d9bf8c..43069cb9 100644 --- a/B05_Profile/B05_Profile_UI_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Corridor.ts @@ -65,9 +65,19 @@ function fnv1a(text: string): string { return (hash >>> 0).toString(16).padStart(8, "0"); } +/** + * 빌드 로직 판번호 — **기하를 바꾸는 수정을 하면 반드시 올린다.** + * + * 해시는 종횡단 입력만 요약하므로, 빌드 코드를 고쳐도 입력이 그대로면 해시가 같아 + * 옛 저장본을 그대로 다시 쓴다 — 코드를 고쳤는데 화면이 안 바뀌는 일이 생긴다 + * (2026-08-23 사용자 보고: "원복이 안 된 것 같다가 갑자기 반영됨"). 판번호를 해시에 + * 섞어 두면 배포와 동시에 저장본이 만료된다. + */ +const BUILD_VERSION = 2; + /** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */ export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string { - const parts: Array = [routePoints.length]; + const parts: Array = [`v${BUILD_VERSION}`, routePoints.length]; routePoints.forEach((p) => parts.push(p.x.toFixed(2), p.y.toFixed(2))); detail.cross_sections.forEach((section) => { const design = section.design; diff --git a/B05_Profile/B05_Profile_UI_Corridor_Build.ts b/B05_Profile/B05_Profile_UI_Corridor_Build.ts index 1e096358..c36e81f9 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Build.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Build.ts @@ -85,6 +85,15 @@ interface StationPieces { outer: { left: OffsetPoint; right: OffsetPoint }; /** 마구리용 단면 — catch 사이 [offset, 설계z, 지반z] (시·종점에서만 쓴다). */ capSection: Array<[number, number, number]>; + /** + * 측구 조각을 **도로 끝(road edge) 기준 상대 좌표**로도 들고 있는다. + * + * 도로부(차도·노견·측구)는 한 몸이다 — 그런데 측구는 있는 측점과 없는 측점이 + * 갈려서, 절대 좌표로 따로 보간하면 도로 끝은 다음 측점을 향해 내려가는데 측구만 + * 제자리에 남아 최대 0.9m까지 벌어졌다(2026-08-23 실측). 상대 좌표로 들고 있다가 + * 프레임의 도로 끝에 얹으면 무슨 일이 있어도 노견에 붙는다. + */ + ditchRelative: Map; } function pieceKey(kind: CorridorKind, side: CorridorSide): string { @@ -198,6 +207,28 @@ function catchOffset( return end; // 반폭 안에서 지반을 못 만남(깊은 절토·높은 성토) — 샘플 끝까지. } +/** 측구 조각을 도로 끝 기준 상대 좌표로 바꾼다(도로부 한 몸 제어용). */ +function relativeToRoadEdge( + pieces: Map, + roadEdge: { left: OffsetPoint; right: OffsetPoint }, +): Map { + const relative = new Map(); + (["left", "right"] as const).forEach((side) => { + const key = pieceKey("ditch", side); + const points = pieces.get(key); + if (!points) return; + const edge = roadEdge[side]; + relative.set( + key, + points.map((point) => ({ + offset_m: point.offset_m - edge.offset_m, + elevation_m: point.elevation_m - edge.elevation_m, + })), + ); + }); + return relative; +} + /** 측점 하나를 종류별 조각으로 분류·리샘플. design 없거나 설계선 부실 → null. */ function classifyStation(section: CrossSection): StationPieces | null { const design = section.design; @@ -273,6 +304,10 @@ function classifyStation(section: CrossSection): StationPieces | null { left: { offset_m: endL, elevation_m: zOf(endL) }, right: { offset_m: endR, elevation_m: zOf(endR) }, }, + ditchRelative: relativeToRoadEdge(pieces, { + left: { offset_m: roadL, elevation_m: design.road_edges.left.elevation_m }, + right: { offset_m: roadR, elevation_m: design.road_edges.right.elevation_m }, + }), // 마구리(시·종점 봉인)용 단면 — catch 사이 설계선 점마다 지반고를 짝지운다. capSection: slicePolyline(sorted, endR, endL).map( (point) => @@ -488,9 +523,25 @@ export function buildCorridor( // 폭 0으로 길게 수렴시키면 없는 구간까지 얇은 쐐기가 남는다. 횡단배수 연결 // 자동 계산이 생기기 전까지의 표현 규칙이다. let points: OffsetPoint[]; - if (kind === "ditch" && hasA !== hasB) { - if (hasA ? t > 0.5 : t < 0.5) return; - points = (hasA ? s0 : s1).pieces.get(key)!.map((point) => ({ ...point })); + if (kind === "ditch") { + // 측구는 **도로 끝에 얹는다** — 도로부는 한 몸이므로 위치를 따로 보간하지 + // 않는다(2026-08-23 사용자 지시). 한쪽 측점에만 있으면 그 절반 구간만 + // 그리되, 자리는 항상 그 프레임의 노견 끝을 따라간다. + if (hasA !== hasB && (hasA ? t > 0.5 : t < 0.5)) return; + const relA = s0.ditchRelative.get(key); + const relB = s1.ditchRelative.get(key); + const relative = relA && relB ? lerpPoints(relA, relB, t) : (relA ?? relB); + if (!relative) return; + const edge0 = side === "right" ? s0.roadEdge.right : s0.roadEdge.left; + const edge1 = side === "right" ? s1.roadEdge.right : s1.roadEdge.left; + const edge = { + offset_m: edge0.offset_m + (edge1.offset_m - edge0.offset_m) * t, + elevation_m: edge0.elevation_m + (edge1.elevation_m - edge0.elevation_m) * t, + }; + points = relative.map((point) => ({ + offset_m: edge.offset_m + point.offset_m, + elevation_m: edge.elevation_m + point.elevation_m, + })); } else { const pa = s0.pieces.get(key) ?? degeneratePiece(s0, kind, side); const pb = s1.pieces.get(key) ?? degeneratePiece(s1, kind, side); @@ -499,6 +550,29 @@ export function buildCorridor( if (shift !== 0) applyProfileShift(points, kind, side, shift); sections.set(key, points); }); + // 비탈 안쪽 끝을 그 프레임의 도로부 바깥 끝(측구가 있으면 측구 끝, 없으면 노견 + // 끝)에 붙인다 — 도로부를 한 몸으로 옮겼으니 비탈도 그 자리에서 시작해야 한다. + (["left", "right"] as const).forEach((side) => { + const ditchPoints = sections.get(pieceKey("ditch", side)); + const edge0 = side === "right" ? s0.roadEdge.right : s0.roadEdge.left; + const edge1 = side === "right" ? s1.roadEdge.right : s1.roadEdge.left; + const anchor = ditchPoints + ? side === "right" + ? ditchPoints[0] + : ditchPoints[ditchPoints.length - 1] + : { + offset_m: edge0.offset_m + (edge1.offset_m - edge0.offset_m) * t, + // 도로부는 종단 보정을 전량 받는다 — 그 값을 얹어야 노면과 높이가 맞는다. + elevation_m: edge0.elevation_m + (edge1.elevation_m - edge0.elevation_m) * t + shift, + }; + (["cut", "fill"] as const).forEach((kind) => { + const slope = sections.get(pieceKey(kind, side)); + if (!slope || slope.length < 2) return; + const inner = side === "right" ? slope[slope.length - 1] : slope[0]; + inner.offset_m = anchor.offset_m; + inner.elevation_m = anchor.elevation_m; + }); + }); const lerpOuter = (a: OffsetPoint, b: OffsetPoint): OffsetPoint => ({ offset_m: a.offset_m + (b.offset_m - a.offset_m) * t, elevation_m: a.elevation_m + (b.elevation_m - a.elevation_m) * t,