feat(B05): 측구·절토 종점을 계획선-지반선 교차점으로 계산

측점 사이 어딘가에서 계획선이 지면선을 통과한다 - 그 자리가 절토+측구의 종점이고
거기서부터 성토로 넘어간다(2026-08-23 사용자 확정). 임시로 쓰던 "측점 사이 중간에서
끊기"를 이 계산으로 대체한다.

- buildCrossingFinder(): 종단 계획선 샘플의 (계획고 - 지반고) 부호가 뒤집히는 구간을
  선형보간해 전환 누가거리를 낸다. 구간 안쪽 교차만 인정 - BP·EP는 정의상 지반과
  만나므로 경계의 0을 거르지 않으면 측구·절토가 0.4m 만에 끝난다(실측으로 확인)
- 측구는 그 지점까지 그리고 마감벽으로 닫는다(위치는 계속 노견을 따라간다)
- 절·성토는 전환점에서 폭 0이 되도록 보간 구간을 다시 잡는다. 구간 전체에 걸쳐
  서서히 사라지던 것을 실제 절토가 끝나고 성토가 시작되는 자리에 맞춘다
- 전환점을 못 찾으면(전 구간 절토 또는 전 구간 성토) 종전대로 중간에서 나눈다

측구 형식(일반/L형)은 3D에 이미 반영되고 있음을 확인했다 - 폭은 형식별 필드
(top_width_m / width_m)를 구분해 쓰고, 단면 형상은 엔진이 만든 design_line을
잘라오므로 사다리꼴과 L형이 그대로 올라온다.

BUILD_VERSION 4로 올려 옛 저장본을 만료시킨다.

검증: 실측 측구·절토 유효구간 0.00~2.80m(계산 교차점 2.83m, 0.2m 세분 격자),
이전 0.00~10.00m. pytest 167 passed(전환점 경계·측구 형식 5건 신규), tsc 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-23 15:38:34 +09:00
co-authored by Claude Opus 5
parent 8f6cf5bba6
commit 0e3bdb145d
2 changed files with 72 additions and 6 deletions
+1 -1
View File
@@ -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 {
+71 -5
View File
@@ -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<string>();
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);