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:
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user