feat(B06): 배수관 기슭막이 여유고·성토 물매 재설계

- 기슭막이 높이 = 관경 + 여유고 0.5m (0.1m 눈금 올림). 좌우 여유고 동일.
- 관 길이는 절단 긴 변 기준 정수 m. 벽 자리 이동으로 맞추고, 물매·5m 제약에
  걸리면 벽 폭으로 흡수(폭은 5m 한계를 넘지 않는 범위까지만).
- 성토 물매를 1:1.2 고정에서 1:1.2~2.0 범위(성토_비탈면.md §1)로 전환.
  벽 자리가 굳은 뒤 물매를 역산해 사면선이 벽 이음선 상단점을 지나게 한다.
  종전에는 관 길이 맞춤이 벽을 옮겨 접점이 깨졌고 스냅으로 가리고 있었다.
- 성토 사면선은 단일 각도 직선 하나를 배수관 레이아웃이 직접 공급(설계선 대체).
- 벽 자리 탐색 상한 = 성토 사면 끝, 사면길이 5m 초과 전까지. 성토고 3m 이상이면
  5m 쪽으로 끌어올린다.
- 기슭막이로 성토를 못 받는 자리는 structureRequired로 표시(옹벽·석축 검토).
- 700줄 제한으로 Const/Types/Solve 3개 파일 분리.

검증: 계획고 -2.0~+3.0 스윕 24케이스 문제 0 (접점 오차 <1e-6, 여유고 0.5,
5m 초과는 전부 경고). tsc 통과, pytest 148 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-21 15:51:54 +09:00
co-authored by Claude Opus 5
parent 0d5fba4400
commit a04da05fe1
6 changed files with 777 additions and 328 deletions
+40 -3
View File
@@ -446,7 +446,20 @@ export function appendCrossDesignOverlay(
x: (offset: number) => number,
toDisplayY: (elevation: number) => number,
groundSamples: SectionSample[],
trim?: { minOffset: number; maxOffset: number },
trim?: {
minOffset: number;
maxOffset: number;
minElevation?: number;
maxElevation?: number;
minSlope?: {
from: { offset: number; elevation: number };
to: { offset: number; elevation: number };
};
maxSlope?: {
from: { offset: number; elevation: number };
to: { offset: number; elevation: number };
};
},
): void {
const line = design.design_line;
if (!line || line.length < 2) return;
@@ -544,11 +557,21 @@ export function appendCrossDesignOverlay(
const t = Math.abs(span) > 1e-9 ? (offset - a.offset_m) / span : 0;
return { offset_m: offset, elevation_m: a.elevation_m + (b.elevation_m - a.elevation_m) * t };
};
// 배수관 트림 경계에서는 끝단 표고를 **벽 이음선 상단점**에 맞춰 내린다 — 벽 높이가
// 관경+여유고로 고정된 뒤로는 설계 사면선 표고와 어긋나 사면선 끝이 벽에서 떨어진다
// (2026-08-21 사용자 ②). 사면 끝 접점 트림이 이긴 경계에는 적용하지 않는다.
const snap = (
p: { offset_m: number; elevation_m: number },
elevation: number | undefined,
wins: boolean,
) => (elevation != null && wins ? { offset_m: p.offset_m, elevation_m: elevation } : p);
const minIsCulvert = trim != null && Math.abs(effective.minOffset - trim.minOffset) < 1e-9;
const maxIsCulvert = trim != null && Math.abs(effective.maxOffset - trim.maxOffset) < 1e-9;
const clamp = (p: { offset_m: number; elevation_m: number }) =>
p.offset_m < effective.minOffset
? at(effective.minOffset)
? snap(at(effective.minOffset), trim?.minElevation, minIsCulvert)
: p.offset_m > effective.maxOffset
? at(effective.maxOffset)
? snap(at(effective.maxOffset), trim?.maxElevation, maxIsCulvert)
: p;
return [clamp(a), clamp(b)];
};
@@ -577,6 +600,20 @@ export function appendCrossDesignOverlay(
svg.append(seg);
}
// 배수관 측점 성토 사면 — 백엔드 설계선(1:1.2 고정) 대신 **배수관 레이아웃이 준 직선**
// 을 그린다. 벽 자리가 관 길이 맞춤으로 옮겨져도 노견 → 벽 이음선 상단점을 잇는 단일
// 각도가 유지된다(2026-08-21 사용자 ②, 성토 사면은 단일 각도).
for (const segment of [trim?.minSlope, trim?.maxSlope]) {
if (!segment) continue;
const seg = document.createElementNS(SVG_NS, "line");
seg.setAttribute("x1", String(x(segment.from.offset)));
seg.setAttribute("y1", String(toDisplayY(segment.from.elevation)));
seg.setAttribute("x2", String(x(segment.to.offset)));
seg.setAttribute("y2", String(toDisplayY(segment.to.elevation)));
seg.setAttribute("class", "b06-chart__design-cross");
svg.append(seg);
}
// 차도·노견 경계 짧은 수직 틱(N-4-2): ±3.6px(기존 ±6의 60%). 노면 단일 기울기라 육안
// 구분이 안 되는 경계를 표시한다. 노견 바깥 끝(road_edges)은 측구·사면 꺾임으로 이미 구분됨.
const edges = design.carriageway_edges;