fix(B05): 도로부(차도·노견·측구) 위치를 한 몸으로 제어 + 빌드 판번호로 캐시 만료
측구가 노견에서 떨어지던 문제 - 측구는 있는 측점과 없는 측점이 갈려 절대 좌표로 따로 보간되고 있었다. 도로 끝은 다음 측점을 향해 내려가는데 측구만 제자리에 남아, 측점에서 멀어질수록 벌어졌다 (실측 ch2.0 0.18m / ch5.0 0.46m / ch9.8 0.90m - 측구 폭 0.69m보다 큰 틈) - 측구 조각을 road edge 기준 상대 좌표로 들고 있다가 프레임의 도로 끝에 얹는다. "측점 사이 중간에서 끊기" 규칙은 표시 구간만 자르고 위치는 항상 노견을 따라간다 - 비탈 안쪽 끝도 그 프레임의 도로부 바깥 끝(측구 끝 또는 노견 끝)에 스냅. 종단 보정분을 얹어 노면과 높이를 맞춘다 - 실측: 차도-노견, 노견-측구, 측구-절토 경계가 전부 0.0000 m. 측구 단면(폭 0.690 / 깊이 0.300)은 구간 전체에서 유지 저장본 캐시 만료 - 해시가 종횡단 입력만 요약해, 빌드 코드를 고쳐도 입력이 같으면 옛 저장본을 그대로 다시 썼다(사용자 보고: 수정이 반영 안 되다 갑자기 반영됨). BUILD_VERSION을 해시에 섞어 기하를 바꾸는 수정마다 저장본이 만료되게 한다 검증: pytest 162 passed, tsc 통과, 화면 실측 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<string | number> = [routePoints.length];
|
||||
const parts: Array<string | number> = [`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;
|
||||
|
||||
@@ -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<string, OffsetPoint[]>;
|
||||
}
|
||||
|
||||
function pieceKey(kind: CorridorKind, side: CorridorSide): string {
|
||||
@@ -198,6 +207,28 @@ function catchOffset(
|
||||
return end; // 반폭 안에서 지반을 못 만남(깊은 절토·높은 성토) — 샘플 끝까지.
|
||||
}
|
||||
|
||||
/** 측구 조각을 도로 끝 기준 상대 좌표로 바꾼다(도로부 한 몸 제어용). */
|
||||
function relativeToRoadEdge(
|
||||
pieces: Map<string, OffsetPoint[]>,
|
||||
roadEdge: { left: OffsetPoint; right: OffsetPoint },
|
||||
): Map<string, OffsetPoint[]> {
|
||||
const relative = new Map<string, OffsetPoint[]>();
|
||||
(["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,
|
||||
|
||||
Reference in New Issue
Block a user