사토장이 선 측점은 **노면 끝 바깥이 사토장 몫**이라 노선 성토에서 빼야 함.
안 빼면 같은 흙을 두 번 셈(2026-09-09 확정 ㉠).
- `compute_cross_design(spoil_fill=…)` / `computeCrossDesign({spoilFill})` 신설(짝)
- 새 칸: `spoil_fill_area_m2` · `spoil_fill_side` · `spoil_fill_width_m` ·
`spoil_fill_max_width_m` · `spoil_fill_line` · `spoil_fill_unclosed` ·
`spoil_fill_replaced_fill_m2`(노선 성토에서 뺀 몫 — 되짚기용)
- **합쳐서 하나로 내지 않음** — 받는 쪽이 갈라 볼 수 있어야 함
- 기울기가 비면 그 측점의 노선 성토 기울기를 그대로 씀(새 값 안 만듦)
- `fillAreaBeyond`/`_fill_area_beyond` — 경계 종거를 보간해 자름(한 칸도 안 흘림)
- 거울 시험에 사토장 사례 둘 추가 + 값이 0 이면 잡히는 가드
⚠ `B06_Section_Engine_Design.py` 가 924줄 — 700줄 제한 초과 상태임(이번 전에도 881줄).
기능이 다 선 뒤 분리할 자리.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
205 lines
8.4 KiB
TypeScript
205 lines
8.4 KiB
TypeScript
/* =============================================================================
|
|
* common_util_cross_design_areas.ts
|
|
* 횡단 단면적 적분 — 절·성토 면적과 절토의 토사/암반 분리.
|
|
*
|
|
* ⚠⚠ 파이썬 짝 파일과 **한 벌**이다 — 한쪽만 고치면 두 화면 값이 갈린다 ⚠⚠
|
|
* 짝: `B06_Section/B06_Section_Engine_Areas.py`
|
|
* 같은 입력에 같은 값을 내야 한다. 회귀 테스트가 두 구현을 실제로 비교한다:
|
|
* `tmp/tests/test_b06_cross_design_mirror.py` — 고칠 때 반드시 같이 돌릴 것.
|
|
* 왜 두 벌인가: 사용자 조작 중 계산은 브라우저 안에서 끝나야 하고(2026-09-03 사용자
|
|
* 확정), 저장·확정·도면 산출은 서버가 정본으로 다시 계산하기 때문이다.
|
|
*
|
|
* 두 함수 모두 지반선과 설계선의 **차이 배열**만 받으므로 설계 로직을 전혀 모른다.
|
|
* ========================================================================== */
|
|
|
|
/**
|
|
* 오프셋 순 (지반-설계) 차이를 사다리꼴 적분해 [절토, 성토] 면적을 낸다.
|
|
*
|
|
* diff>0(지반이 설계보다 높음)=절토, diff<0=성토. 부호가 바뀌는 구간은 영교점에서
|
|
* 나눠 절·성토가 섞이지 않게 한다.
|
|
*/
|
|
export function trapezoidAreas(offsets: number[], diffs: number[]): [number, number] {
|
|
let cutArea = 0;
|
|
let fillArea = 0;
|
|
for (let index = 1; index < offsets.length; index += 1) {
|
|
const x0 = offsets[index - 1];
|
|
const x1 = offsets[index];
|
|
const d0 = diffs[index - 1];
|
|
const d1 = diffs[index];
|
|
const width = x1 - x0;
|
|
if (width <= 0) continue;
|
|
if (d0 === 0 && d1 === 0) continue;
|
|
if (d0 * d1 < 0) {
|
|
// 부호 변화: 영교점에서 두 삼각형으로 분리
|
|
const zeroRatio = d0 / (d0 - d1);
|
|
const xZero = x0 + width * zeroRatio;
|
|
const leftArea = 0.5 * (xZero - x0) * Math.abs(d0);
|
|
const rightArea = 0.5 * (x1 - xZero) * Math.abs(d1);
|
|
if (d0 > 0) {
|
|
cutArea += leftArea;
|
|
fillArea += rightArea;
|
|
} else {
|
|
fillArea += leftArea;
|
|
cutArea += rightArea;
|
|
}
|
|
continue;
|
|
}
|
|
const area = 0.5 * (d0 + d1) * width;
|
|
if (area >= 0) cutArea += area;
|
|
else fillArea += -area;
|
|
}
|
|
return [cutArea, fillArea];
|
|
}
|
|
|
|
/**
|
|
* 절토 면적을 암반 경계선 기준으로 [토사, 암반]으로 나눈다.
|
|
*
|
|
* 암반 경계선은 지반선 평행 복사(`지반고 + rock_boundary_offset_m`)이므로 토사층 두께
|
|
* `t0`가 절토 구간 전체에서 균일하다. 따라서 오프셋별 절토 종거 `d = 지반고 - 설계고`에
|
|
* 대해 토사분은 `min(max(d, 0), t0)`, 암반분은 `max(d - t0, 0)`이며 두 값의 합은 항상
|
|
* `max(d, 0)`이라 `trapezoidAreas`의 절토 면적과 정확히 일치한다.
|
|
*
|
|
* 두 함수 모두 `d = 0`과 `d = t0`에서 꺾이므로 그 교차점을 구간 분할점으로 넣어야
|
|
* 사다리꼴 적분이 근사가 아닌 정확값이 된다.
|
|
*/
|
|
export function splitCutAreas(
|
|
offsets: number[],
|
|
diffs: number[],
|
|
soilDepthM: number,
|
|
): [number, number] {
|
|
const t0 = Math.max(soilDepthM, 0);
|
|
let soilArea = 0;
|
|
let rockArea = 0;
|
|
for (let index = 1; index < offsets.length; index += 1) {
|
|
const x0 = offsets[index - 1];
|
|
const x1 = offsets[index];
|
|
const d0 = diffs[index - 1];
|
|
const d1 = diffs[index];
|
|
const width = x1 - x0;
|
|
if (width <= 0) continue;
|
|
const ratios = [0, 1];
|
|
for (const level of [0, t0]) {
|
|
if ((d0 - level) * (d1 - level) < 0) ratios.push((level - d0) / (d1 - d0));
|
|
}
|
|
ratios.sort((a, b) => a - b);
|
|
for (let step = 1; step < ratios.length; step += 1) {
|
|
const ratioA = ratios[step - 1];
|
|
const ratioB = ratios[step];
|
|
const span = width * (ratioB - ratioA);
|
|
if (span <= 0) continue;
|
|
const dA = d0 + (d1 - d0) * ratioA;
|
|
const dB = d0 + (d1 - d0) * ratioB;
|
|
soilArea += ((Math.min(Math.max(dA, 0), t0) + Math.min(Math.max(dB, 0), t0)) / 2) * span;
|
|
rockArea += ((Math.max(dA - t0, 0) + Math.max(dB - t0, 0)) / 2) * span;
|
|
}
|
|
}
|
|
return [soilArea, rockArea];
|
|
}
|
|
|
|
/** 층따기 대상 판정 기울기 — 원지반 횡단기울기 1:4(=25%)보다 급한 곳에만 한다.
|
|
* 근거: 별표2 · 임도기술교본 6장 4절(「1:4보다 급한 경사를 가진 지반 위에 성토」).
|
|
* ⚠ 파이썬 짝: `B06_Section_Engine_Areas._BENCH_CUT_MIN_GROUND_SLOPE`. */
|
|
export const BENCH_CUT_MIN_GROUND_SLOPE = 0.25;
|
|
|
|
/**
|
|
* 층따기 밑수 — **성토부 아래 원지반 표면의 경사길이(m)**.
|
|
* ⚠ 파이썬 짝: `B06_Section_Engine_Areas._bench_cut_length`. 한 벌로 움직인다.
|
|
* 면적(㎡)은 측점 사이를 평균단면적법으로 이어 B08 이 낸다.
|
|
*/
|
|
export function benchCutLength(offsets: number[], grounds: number[], diffs: number[]): number {
|
|
let total = 0;
|
|
for (let index = 1; index < offsets.length; index += 1) {
|
|
const run = offsets[index] - offsets[index - 1];
|
|
if (run <= 0) continue;
|
|
const d0 = diffs[index - 1];
|
|
const d1 = diffs[index];
|
|
if (d0 >= 0 && d1 >= 0) continue;
|
|
let share = 1;
|
|
if (d0 * d1 < 0) {
|
|
const zeroRatio = d0 / (d0 - d1);
|
|
share = d0 > 0 ? 1 - zeroRatio : zeroRatio;
|
|
}
|
|
if (share <= 0) continue;
|
|
const rise = grounds[index] - grounds[index - 1];
|
|
if (Math.abs(rise) / run < BENCH_CUT_MIN_GROUND_SLOPE) continue;
|
|
total += Math.sqrt(run * run + rise * rise) * share;
|
|
}
|
|
return total;
|
|
}
|
|
|
|
/**
|
|
* 측구 단면적을 [토사, 암반]으로 가른다 — 측구 상단에서 암반 경계선까지의 깊이(m) 기준.
|
|
* ⚠ 파이썬 짝: `B06_Section_Engine_Areas._split_ditch_area`. 한 벌로 움직인다.
|
|
* 측구 단면은 공칭 도형이라 지반선을 따라 적분하지 않는다 — 경계선도 그 자리 한 높이로 본다.
|
|
*/
|
|
export function splitDitchArea(
|
|
ditchSpec: Record<string, unknown>,
|
|
depthToBoundaryM: number | null,
|
|
): [number, number] {
|
|
const kind = String(ditchSpec.type ?? "none");
|
|
const clamp = (depth: number): number => Math.min(Math.max(depthToBoundaryM ?? 0, 0), depth);
|
|
if (kind === "l_type") {
|
|
const width = Number(ditchSpec.width_m ?? 0);
|
|
const depth = Number(ditchSpec.depth_m ?? 0);
|
|
if (depth <= 0 || width <= 0) return [0, 0];
|
|
const total = (width * depth) / 2;
|
|
const d0 = clamp(depth);
|
|
const soil = width * d0 - (width * d0 * d0) / (2 * depth);
|
|
return [soil, Math.max(total - soil, 0)];
|
|
}
|
|
if (kind === "standard") {
|
|
const top = Number(ditchSpec.top_width_m ?? 0);
|
|
const bottom = Math.min(Number(ditchSpec.bottom_width_m ?? 0), top);
|
|
const depth = Number(ditchSpec.depth_m ?? 0);
|
|
if (depth <= 0 || top <= 0) return [0, 0];
|
|
const total = ((top + bottom) / 2) * depth;
|
|
const d0 = clamp(depth);
|
|
const soil = top * d0 - ((top - bottom) * d0 * d0) / (2 * depth);
|
|
return [soil, Math.max(total - soil, 0)];
|
|
}
|
|
return [0, 0];
|
|
}
|
|
|
|
/**
|
|
* `x0` **바깥쪽**(사토장이 서는 쪽)의 성토 면적(㎡)만 따로 낸다.
|
|
*
|
|
* ⚠ 왜 있나 — 사토장이 선 측점에서는 노면 끝 바깥이 **사토장 몫**이라 노선 성토
|
|
* (`fill_area_m2`)에서 빼야 한다. 안 빼면 **같은 흙을 두 번 센다**(2026-09-09 확정 ㉠).
|
|
* ⚠ 경계(`x0`)에서 잘라 쓰므로 그 자리의 종거를 **보간해서** 넣는다 — 그냥 버리면
|
|
* 경계 한 칸이 통째로 빠져 값이 작아진다.
|
|
*
|
|
* 짝: 파이썬 `_fill_area_beyond`.
|
|
*/
|
|
export function fillAreaBeyond(
|
|
offsets: number[],
|
|
diffs: number[],
|
|
x0: number,
|
|
side: "left" | "right",
|
|
): number {
|
|
if (offsets.length < 2) return 0;
|
|
const inside = side === "left" ? (x: number) => x >= x0 : (x: number) => x <= x0;
|
|
const subOffsets: number[] = [];
|
|
const subDiffs: number[] = [];
|
|
for (let index = 0; index < offsets.length; index += 1) {
|
|
const x = offsets[index];
|
|
if (index > 0) {
|
|
const xPrev = offsets[index - 1];
|
|
const crosses = (xPrev < x0 && x0 < x) || (x < x0 && x0 < xPrev);
|
|
if (crosses) {
|
|
const ratio = (x0 - xPrev) / (x - xPrev);
|
|
subOffsets.push(x0);
|
|
subDiffs.push(diffs[index - 1] + (diffs[index] - diffs[index - 1]) * ratio);
|
|
}
|
|
}
|
|
if (inside(x)) {
|
|
subOffsets.push(x);
|
|
subDiffs.push(diffs[index]);
|
|
}
|
|
}
|
|
const order = subOffsets.map((_, index) => index).sort((a, b) => subOffsets[a] - subOffsets[b]);
|
|
const sortedOffsets = order.map((index) => subOffsets[index]);
|
|
const sortedDiffs = order.map((index) => subDiffs[index]);
|
|
if (sortedOffsets.length < 2) return 0;
|
|
return trapezoidAreas(sortedOffsets, sortedDiffs)[1];
|
|
}
|