Files
Aislo/common_util/common_util_mass_haul_balance.ts
T
eomsangdonandClaude Opus 5 f60fb9c355 feat(B06): 채집석 공제를 사토에서 빼는 통로 신설
네 창 합의 문구 그대로 못 박음:

    채집석 공제는 사토에서 한 번만 뺀다.
    B08 은 소요량(collected_stone_deduction_m3, ㎥ 양수)을 내기만 하고 공제하지 않으며,
    빼는 자리는 유토곡선의 사토뿐이다 —
    실어 내는 몫(spoil_m3 − natural_spoil_m3)에서 먼저 빼고 모자라면 자연방토에서 뺀다.

- `computeHaulPlan` 이 공제를 받아 **잔량 하나하나**를 줄인다 — 총량만 줄이면 사토
  balloon·운반거리가 안 따라간다. 지반유형 안분도 같은 비율로 줄인다.
- ⚠ `null`(아직 안 옴)과 `0`(공제 없음)을 가른다. 결과에 받은 값과 실제로 뺀 값을 함께 싣는다.
- 서버 진입점·파이썬 context 를 통로로 이어 둠. B08 이 값을 내면 실어 주기만 하면 됨.

시험 3건(공제 반영·잔량 동반 감소·사토 초과 시 사토까지만·자연방토는 나중) 을
TS 를 실제로 돌려 확인.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 19:15:27 +09:00

585 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* common_util_mass_haul_balance.ts
* 토량 분배(평형선 · 운반 블록 · 장비 띠) 계산 엔진 — 순수 함수, DOM 의존 없음.
*
* 누가토량 곡선(`common_util_mass_haul`)은 "얼마가 남는가"만 말한다. 이 파일은 그 곡선에서
* **어디 흙을 어디로 얼마나 옮기는가**를 뽑아 낸다: 평형선 → 운반 블록 → 장비 띠 →
* 띠별 운반토량 `Q` · 평균운반거리 `L` · 지반유형 안분.
*
* ── 작도 규칙(참고자료) ─────────────────────────────────────────────
* <https://clouds-daily.tistory.com/249>
* - 상승 = 절토, 하강 = 성토. 극대·극소점 = 절·성토 경계.
* - 산 모양 = 좌→우 운반, 골 모양 = 우→좌 운반.
* - 평형선(수평선)이 곡선을 자르는 두 점 사이는 절토량 = 성토량.
* - 운반토량 = 평형선에서 곡선 정점까지의 종거.
* - 평균운반거리 = **종거의 1/2 지점을 지나는 수평선 B′C′의 길이**(반종거 수평선).
* - 배분 3원칙 = ① 운반거리 최소 ② 높은 곳 → 낮은 곳 ③ 토량을 모아 운반.
*
* ── 평형선을 어떻게 자동으로 잡는가 ─────────────────────────────────
* 참고자료는 "가장 유리한 평형점을 도상에서 구한다"까지만 말한다. **0선 하나로 고정하면
* 안 된다** — 임도는 절토가 크게 우세해 사토가 전부 종점 한 덩어리로 몰린다. 그래서 곡선을
* 왼쪽부터 훑으며 평형선 높이를 **계단으로 올린다**:
*
* 커서(곡선 위, 높이 = 현재 평형선)에서 다음 극값을 본다.
* - 올라갔다가(절토) 다시 현재 높이 **아래**까지 내려오면 → 성토가 절토를 다 받는다.
* 평형선은 그대로, 블록은 [커서 … 곡선이 평형선으로 되돌아온 점].
* - 올라갔다가 현재 높이 **위**에서 멈추면 → 남는 절토가 생긴다. 평형선을 그 골 높이로
* 올리고, 못 받은 만큼을 **그 자리에서** 사토로 계상한다.
* - 내려가는 경우는 정확히 대칭(토취).
*
* 이렇게 하면 ① 구간이 겹치거나 비지 않고 ② 사토·토취가 발생 지점에 남으며
* ③ 단차의 합이 최종 누가토량과 정확히 일치한다.
*
* ── 블록 하나를 장비 띠로 가르는 이유 ───────────────────────────────
* 참고 도면의 토량배분도에는 수평선이 블록마다 여러 개 그려져 있고(A–A′ / B–B′ / CC),
* 그 사이 띠마다 번호가 붙어 종단면의 해칭 구간과 1:1로 대응한다. 하단 브래킷
* (무대운반거리 / 도자 평균운반거리 / 덤프 평균운반거리)의 폭이 각 수평선의 길이와 같다.
*
* 즉 **수평선의 높이는 그 현(弦)의 길이가 장비 경계거리와 같아지는 높이**다. 누가토량
* 그래프에서 세로축이 곧 토량이므로, 두 수평선 사이 띠의 두께가 그 장비가 옮기는 토량이다.
* 현 길이 20m 되는 높이 위쪽 → 종무대
* 거기서 60m 되는 높이까지 → 도쟈
* 그 아래 ~ 평형선 → 덤프
* 경계값 정의처는 `config_system.EARTHWORK_HAUL_EQUIPMENT_LIMITS_M` 한 곳뿐이다.
*
* ── 단위 ─────────────────────────────────────────────────────────
* 곡선이 다짐상태 기준이므로 `Q`·EA/RR/BR 안분도 **다짐상태**다. 내역서용 자연상태 수량은
* `MassHaulResult.cut_natural_m3` / 측점별 `cut_soil_m3`·`cut_rr_m3`·`cut_br_m3`가 따로 든다.
* ========================================================================== */
import type { MassHaulPoint, MassHaulResult } from "./common_util_mass_haul";
import {
crossFrom,
EPSILON,
evaluate,
extremaIndices,
pruneExtrema,
segmentOf,
} from "./common_util_mass_haul_curve";
import {
apportion,
cutMix,
naturalSpoilRatio,
settleResiduals,
sortedLimits,
} from "./common_util_mass_haul_settle";
export { haulPlanPayload } from "./common_util_mass_haul_settle";
/** 운반거리 상한 하나(m). `max_distance_m: null`이면 상한 없음(나머지를 전부 받는다). */
export interface HaulEquipmentLimit {
key: string;
max_distance_m: number | null;
}
/** 운반 방향. 산 모양이면 좌→우(`forward`), 골 모양이면 우→좌(`backward`). */
export type HaulDirection = "forward" | "backward";
/** 곡선 위 한 점(누가거리 m, 누가토량 ㎥). 렌더러가 그대로 좌표로 옮긴다. */
export interface CurvePoint {
m: number;
v: number;
}
/** 블록 하나를 장비 경계현으로 가른 띠. 띠 두께가 곧 그 장비의 운반토량이다. */
export interface HaulBand {
/** 전체 통번호(1부터). 도면의 ①②③에 해당한다. */
index: number;
/** config 경계로 고른 장비 키. 경계값을 못 받으면 null(프론트에 사본을 두지 않는다). */
equipment: string | null;
/** 띠의 평형선 쪽 / 정점 쪽 경계 높이(누가토량 ㎥). */
level_base_m3: number;
level_apex_m3: number;
/** 이 띠의 운반토량(㎥, 다짐상태) = 두 경계의 차. */
volume_m3: number;
/** 평형선 쪽 경계현(= 이 장비의 경계거리와 길이가 같은 수평선)의 양 끝. */
boundary_from_m: number;
boundary_to_m: number;
/** 평균운반거리 `L`(m) = 띠 중간 높이 현의 길이. */
haul_distance_m: number;
haul_from_m: number;
haul_to_m: number;
/** `Q`를 절토 구간의 지반유형 구성비로 안분한 값(㎥). 합 = `volume_m3`. */
ea_m3: number;
rr_m3: number;
br_m3: number;
/** 띠를 감싸는 다각형(곡선 두 변 + 위아래 현). 클릭 대상이자 해칭 면이다. */
outline: CurvePoint[];
}
export interface HaulBlock {
/** 1부터. */
index: number;
/** 이 블록의 평형선 높이(누가토량 축, ㎥). */
base_m3: number;
/** 평형선이 곡선을 자르는 두 점(누가거리 m). */
from_m: number;
to_m: number;
/** 정점(극대/극소) 위치와 값. */
apex_m: number;
apex_volume_m3: number;
/** 블록 전체 운반토량 `Q`(㎥) = 종거. 띠 물량의 합과 같다. */
volume_m3: number;
direction: HaulDirection;
/** 장비 경계현으로 가른 띠. 정점 쪽(가까운 장비)부터 평형선 쪽 순으로 담긴다. */
bands: HaulBand[];
}
/** 받아 줄 짝이 없어 남은 토량. `spoil` = 사토(잉여), `borrow` = 토취(부족). */
export interface HaulResidual {
index: number;
kind: "spoil" | "borrow";
from_m: number;
to_m: number;
volume_m3: number;
/** 평형선이 이 잔량만큼 계단으로 옮겨 간다 — 렌더러가 단차를 그대로 그린다. */
level_from_m3: number;
level_to_m3: number;
/**
* 사토의 지반유형 안분(㎥). 사토는 절토에서 남은 흙이라 구성비를 물을 수 있다.
* **토취는 0이다** — 밖에서 사 오는 흙이라 이 노선의 지반유형이 없다.
*/
ea_m3: number;
rr_m3: number;
br_m3: number;
/**
* 사토 중 **자연방토**로 처리되는 몫(㎥) — 성토사면으로 흘려보내 운반비를 세지 않는다.
* 나머지(`volume_m3 natural_m3`)는 사토장으로 실어 내야 한다.
*
* 참고 도면은 사토 balloon에 `L=`(운반거리) 없이 `M.N=측점`만 적는다. 그 노선의 사토가
* 이미 운반비 없이 처리됐다는 뜻이라, 프로그램도 **새 항목을 만들지 않고 사토 안에서**
* 가른다(2026-08-02 사용자 확정). 토취는 밖에서 사 오는 흙이라 해당 없음(0).
*/
natural_m3: number;
}
/**
* 떨어진 잉여 구간 → 부족 구간 **장거리 운반**. 참고 도면의 덤프 운반 중 L이 900~1000m대인
* 것들이 이것으로, 한 산의 현으로는 설명되지 않는다(노선 연장보다 긴 현은 없다).
*
* 배분 3원칙 ③ "토량을 모아 한 가지 방법으로 운반"이 바로 이 항목이다. 이것이 없으면
* 절토가 우세한 노선인데도 국지 부족이 그대로 토취로 계상돼, 사지 않아도 될 흙을 사게 된다.
*/
export interface HaulTransfer {
index: number;
/** 퍼오는 자리 / 채우는 자리(누가거리 m, 각 잔량 구간의 중앙). */
from_m: number;
to_m: number;
volume_m3: number;
/** 두 자리 사이 거리 = 이 운반의 `L`. */
haul_distance_m: number;
/** 연결 수평선을 그릴 높이(누가토량 ㎥). */
level_m3: number;
equipment: string | null;
ea_m3: number;
rr_m3: number;
br_m3: number;
}
/** 계단형 평형선 한 칸. 렌더러가 칸 사이를 수직선으로 이어 계단을 만든다. */
export interface BalanceStep {
from_m: number;
to_m: number;
level_m3: number;
}
export interface HaulPlan {
blocks: HaulBlock[];
residuals: HaulResidual[];
transfers: HaulTransfer[];
steps: BalanceStep[];
spoil_m3: number;
borrow_m3: number;
/** 사토 중 자연방토 몫(㎥) — 운반비를 세지 않는다. */
natural_spoil_m3: number;
/**
* 채집석 공제로 받은 값(㎥, 양수). **`null` 은 「아직 안 옴」**이고 `0` 은 「공제 없음」이다 —
* 둘을 같게 보면 값이 안 온 것을 공제 0 으로 읽어 조용히 넘어간다(2026-09-09).
*/
collected_stone_deduction_m3: number | null;
/** 실제로 사토에서 뺀 양(㎥). 사토가 모자라면 받은 값보다 작을 수 있다. */
collected_stone_deducted_m3: number;
/** 블록 안에서 옮기는 양(㎥). */
hauled_m3: number;
/** 떨어진 구간끼리 장거리로 옮기는 양(㎥). */
transferred_m3: number;
/**
* 총 성토량(㎥) — **검산 기준값**. 성토는 전부 운반으로 채워지므로
* `hauled_m3 + transferred_m3`가 이 값과 같아야 한다.
* 참고 도면에서 balloon 43개 합이 총 성토량과 소수점까지 일치하는 것이 근거다.
*/
fill_total_m3: number;
}
/**
* 이보다 작은 진동은 블록으로 세지 않는다. 측점 하나짜리 요철까지 블록을 만들면 balloon이
* 수십 개 깔려 도면을 못 읽는다. 곡선 진폭 대비 비율이라 노선 규모에 자동으로 맞는다.
*/
const MIN_SWING_RATIO = 0.02;
/** 경계현 높이를 훑을 때 쪼개는 칸 수. 잔요철에 속지 않을 만큼 촘촘해야 한다. */
const CHORD_SCAN_STEPS = 64;
/** 훑어서 찾은 칸 안에서 좁히는 이분법 반복 수. 24회면 칸 폭의 1/1600만 남는다. */
const CHORD_SOLVE_STEPS = 24;
/** 띠 테두리를 곡선(포물선)에 붙이려고 구간 하나를 쪼개는 수. */
const OUTLINE_STEPS = 4;
/** 한 블록을 장비 띠로 가르는 데 필요한 기하 계산 묶음. */
interface BlockGeometry {
/** `ratio`(0 = 평형선, 1 = 정점)에 해당하는 누가토량 높이. */
levelAt: (ratio: number) => number;
/** 그 높이 수평선이 블록 안에서 곡선을 자르는 두 점. */
chordAt: (ratio: number) => { from: number; to: number; length: number };
}
function blockGeometry(
points: MassHaulPoint[],
fromM: number,
toM: number,
apexM: number,
base: number,
apex: number,
): BlockGeometry {
const levelAt = (ratio: number): number => base + (apex - base) * ratio;
const chordAt = (ratio: number): { from: number; to: number; length: number } => {
const level = levelAt(ratio);
const from = crossFrom(points, fromM, apexM, level) ?? fromM;
const to = crossFrom(points, apexM, toM, level) ?? toM;
return { from, to, length: Math.max(to - from, 0) };
};
return { levelAt, chordAt };
}
/**
* 현의 길이가 `target` **이하로 확정되는** 가장 낮은 높이를 찾는다(반환값은 `ratio`).
*
* 현 길이는 평형선(가장 김)에서 정점(0)으로 갈수록 줄지만 **단조가 아니다** — 가지치기로
* 블록을 하나로 묶어도 그 안의 원래 폴리라인에는 잔요철이 남아, 높이를 올리는 도중 현이
* 잠깐 다시 길어질 수 있다. 이분법만 쓰면 그 지점에서 엉뚱한 근으로 수렴한다(무작위 검증에서
* 종무대 경계현이 45m로 잡히는 사례를 잡았다).
*
* 그래서 **훑기 → 이분법** 두 단계로 간다: 먼저 전 구간을 성기게 훑어 현이 `target`을 넘는
* **가장 높은** 자리를 찾고, 그 바로 위 칸에서만 이분법으로 좁힌다. 이렇게 잡은 경계 위쪽은
* 전부 `target` 이하가 보장된다 — "이 띠는 이 장비로 옮길 수 있다"가 성립해야 하기 때문이다.
* 평형선에서 이미 `target` 이하면 그 장비가 블록 전체를 맡는다 → 0.
*/
function ratioForChord(geometry: BlockGeometry, target: number): number {
let lastOver = -1;
for (let step = 0; step <= CHORD_SCAN_STEPS; step += 1) {
if (geometry.chordAt(step / CHORD_SCAN_STEPS).length > target) lastOver = step;
}
if (lastOver < 0) return 0;
if (lastOver >= CHORD_SCAN_STEPS) return 1;
let low = lastOver / CHORD_SCAN_STEPS;
let high = (lastOver + 1) / CHORD_SCAN_STEPS;
for (let step = 0; step < CHORD_SOLVE_STEPS; step += 1) {
const middle = (low + high) / 2;
if (geometry.chordAt(middle).length > target) low = middle;
else high = middle;
}
return high;
}
/**
* 띠를 감싸는 다각형.
*
* 예전에는 「아래 현 → 왼쪽 곡선 → 위 현 → 오른쪽 곡선」을 이어 붙였는데, 곡선이 두 현 사이에서
* 요동치면 그 조각이 띠 밖으로 삐져나가 **면이 스스로 겹쳤다**(2026-08-02 사용자 지적).
*
* 정의를 바꿨다. 띠는 「아래 현의 두 끝 사이에서, 아래 높이부터 **곡선과 위 높이 중 안쪽**까지」다.
* 아래 현을 밑변으로 깔고 그 위를 훑으며 곡선값을 두 높이 사이로 **자른 값**만 찍는다.
* 이러면 어떤 지형에서도 단순 다각형이라 스스로 겹칠 수가 없다.
*/
function bandOutline(
points: MassHaulPoint[],
low: { from: number; to: number },
lowLevel: number,
highLevel: number,
): CurvePoint[] {
const floor = Math.min(lowLevel, highLevel);
const ceil = Math.max(lowLevel, highLevel);
const outline: CurvePoint[] = [{ m: low.from, v: lowLevel }];
const push = (m: number, v: number): void => {
const previous = outline[outline.length - 1];
if (previous && Math.abs(previous.m - m) < 1e-9 && Math.abs(previous.v - v) < 1e-9) return;
outline.push({ m, v });
};
for (let index = 1; index < points.length; index += 1) {
const segment = segmentOf(points, index);
if (!segment) continue;
const start = Math.max(segment.x0, low.from);
const end = Math.min(segment.x0 + segment.span, low.to);
if (!(end > start)) continue;
for (let step = 0; step <= OUTLINE_STEPS; step += 1) {
const m = start + ((end - start) * step) / OUTLINE_STEPS;
push(m, Math.min(Math.max(evaluate(segment, m - segment.x0), floor), ceil));
}
}
push(low.to, lowLevel);
return outline;
}
/**
* 누가토량 곡선에서 토량 분배(평형선·운반 블록·장비 띠·사토/토취)를 뽑는다.
* 블록도 잔량도 안 나오면(평탄한 곡선) null.
*/
/**
* 채집석 공제 — **사토에서 한 번만 뺀다**(2026-09-09 사용자 확정, 네 창 합의 문구).
*
* 채집석 공제는 사토에서 한 번만 뺀다.
* B08 은 소요량(collected_stone_deduction_m3, ㎥ 양수)을 내기만 하고 공제하지 않으며,
* 빼는 자리는 유토곡선의 사토뿐이다 —
* 실어 내는 몫(spoil_m3 natural_spoil_m3)에서 먼저 빼고 모자라면 자연방토에서 뺀다.
*
* ⚠ 순서가 중요하다 — 캔 돌은 **실어 낼 흙 속에 있던 것**이다. 자연방토(운반비를 안 세는 몫)
* 에서 먼저 깎으면 **줄어야 할 운반비가 안 줄어든다.**
* ⚠ 잔량 하나하나(`residuals`)를 줄인다 — 총량만 줄이면 사토 balloon·운반거리가 안 따라간다.
*
* 돌려주는 값은 **실제로 뺀 양(㎥)**. 사토가 모자라면 받은 값보다 작다.
*/
function applyCollectedStoneDeduction(residuals: HaulResidual[], deduction: number | null): number {
if (deduction === null || !Number.isFinite(deduction) || deduction <= 0) return 0;
const spoils = residuals.filter((residual) => residual.kind === "spoil");
let left = deduction;
const take = (residual: HaulResidual, amount: number): void => {
if (amount <= 0) return;
const before = residual.volume_m3;
const ratio = before > 0 ? (before - amount) / before : 0;
residual.volume_m3 = before - amount;
// 지반유형 안분도 같은 비율로 줄인다 — 남은 사토의 구성비는 그대로다.
residual.ea_m3 *= ratio;
residual.rr_m3 *= ratio;
residual.br_m3 *= ratio;
left -= amount;
};
// ① 실어 내는 몫부터
for (const residual of spoils) {
if (left <= EPSILON) break;
take(residual, Math.min(Math.max(residual.volume_m3 - residual.natural_m3, 0), left));
}
// ② 모자라면 자연방토에서
for (const residual of spoils) {
if (left <= EPSILON) break;
const amount = Math.min(residual.natural_m3, left);
residual.natural_m3 -= amount;
take(residual, amount);
}
return deduction - Math.max(left, 0);
}
export function computeHaulPlan(
result: MassHaulResult,
limits: HaulEquipmentLimit[] | undefined,
options?: { collected_stone_deduction_m3?: number | null },
): HaulPlan | null {
const points = result.points;
if (points.length < 2) return null;
const range = Math.max(result.max_cumulative_m3 - result.min_cumulative_m3, 0);
const minSwing = Math.max(range * MIN_SWING_RATIO, 1);
const extrema = pruneExtrema(points, extremaIndices(points), minSwing);
const tiers = sortedLimits(limits);
const blocks: HaulBlock[] = [];
const residuals: HaulResidual[] = [];
const steps: BalanceStep[] = [];
let bandCounter = 0;
const pushBlock = (
fromM: number,
toM: number,
base: number,
apexM: number,
apex: number,
direction: HaulDirection,
): void => {
const volume = Math.abs(apex - base);
if (!(volume > EPSILON) || !(toM > fromM)) return;
const geometry = blockGeometry(points, fromM, toM, apexM, base, apex);
// 정점 쪽(가까운 장비)부터 평형선 쪽으로 내려오며 띠를 자른다.
const bands: HaulBand[] = [];
let upper = 1;
for (const tier of tiers) {
if (upper <= EPSILON) break;
const lower = tier.max_distance_m === null ? 0 : ratioForChord(geometry, tier.max_distance_m);
if (upper - lower <= 1e-6) {
upper = lower;
continue;
}
const lowChord = geometry.chordAt(lower);
const highChord = geometry.chordAt(upper);
const haulChord = geometry.chordAt((lower + upper) / 2);
// 절토 쪽 변에서 잘라 낸 구간이 이 띠가 퍼오는 흙이다(산이면 왼쪽 변, 골이면 오른쪽 변).
const cutFrom = direction === "forward" ? lowChord.from : highChord.to;
const cutTo = direction === "forward" ? highChord.from : lowChord.to;
const bandVolume = (upper - lower) * volume;
bandCounter += 1;
bands.push({
index: bandCounter,
equipment: tier.key || null,
level_base_m3: geometry.levelAt(lower),
level_apex_m3: geometry.levelAt(upper),
volume_m3: bandVolume,
boundary_from_m: lowChord.from,
boundary_to_m: lowChord.to,
haul_distance_m: haulChord.length,
haul_from_m: haulChord.from,
haul_to_m: haulChord.to,
outline: bandOutline(points, lowChord, geometry.levelAt(lower), geometry.levelAt(upper)),
...apportion(cutMix(points, cutFrom, cutTo, result.conversion), bandVolume),
});
upper = lower;
}
blocks.push({
index: blocks.length + 1,
base_m3: base,
from_m: fromM,
to_m: toM,
apex_m: apexM,
apex_volume_m3: apex,
volume_m3: volume,
direction,
bands,
});
steps.push({ from_m: fromM, to_m: toM, level_m3: base });
};
const pushResidual = (fromM: number, toM: number, from: number, to: number): void => {
const delta = to - from;
if (Math.abs(delta) < EPSILON) return;
const volume = Math.abs(delta);
residuals.push({
index: residuals.length + 1,
kind: delta > 0 ? "spoil" : "borrow",
from_m: fromM,
to_m: toM,
volume_m3: volume,
level_from_m3: from,
level_to_m3: to,
// 자연방토는 사토에만 해당한다 — 토취는 밖에서 사 오는 흙이다.
natural_m3: delta > 0 ? volume * naturalSpoilRatio(points, fromM, toM) : 0,
// 사토만 지반유형을 물을 수 있다(절토에서 남은 흙). 토취는 밖에서 사 온다.
...(delta > 0
? apportion(cutMix(points, fromM, toM, result.conversion), volume)
: { ea_m3: 0, rr_m3: 0, br_m3: 0 }),
});
};
let level = points[0].cumulative_volume_m3;
let cursor = points[0].chainage_m;
let k = 1;
while (k < extrema.length) {
const apexX = points[extrema[k]].chainage_m;
const apexY = points[extrema[k]].cumulative_volume_m3;
if (apexX <= cursor + EPSILON) {
k += 1;
continue;
}
const rising = apexY > level + EPSILON;
const falling = apexY < level - EPSILON;
if (!rising && !falling) {
cursor = apexX;
k += 1;
continue;
}
// 짝이 되는 다음 극값이 없으면 받아 줄 성토(또는 절토)가 없다 — 전량 사토/토취.
if (k + 1 >= extrema.length) {
steps.push({ from_m: cursor, to_m: apexX, level_m3: level });
pushResidual(cursor, apexX, level, apexY);
level = apexY;
cursor = apexX;
k += 1;
continue;
}
const nextX = points[extrema[k + 1]].chainage_m;
const nextY = points[extrema[k + 1]].cumulative_volume_m3;
if (rising) {
if (nextY <= level + EPSILON) {
// 성토가 절토를 다 받는다 — 평형선 그대로, 곡선이 되돌아온 점에서 블록을 닫는다.
const end = crossFrom(points, apexX, nextX, level) ?? nextX;
pushBlock(cursor, end, level, apexX, apexY, "forward");
cursor = end;
k += 1;
} else {
// 못 받은 절토가 남는다 — 평형선을 그 골 높이로 올리고 차액을 사토로 계상한다.
const start = crossFrom(points, cursor, apexX, nextY) ?? cursor;
steps.push({ from_m: cursor, to_m: start, level_m3: level });
pushResidual(cursor, start, level, nextY);
pushBlock(start, nextX, nextY, apexX, apexY, "forward");
level = nextY;
cursor = nextX;
k += 2;
}
} else if (nextY >= level - EPSILON) {
// 골 — 오른쪽 절토가 왼쪽 성토를 채운다(우→좌 운반).
const end = crossFrom(points, apexX, nextX, level) ?? nextX;
pushBlock(cursor, end, level, apexX, apexY, "backward");
cursor = end;
k += 1;
} else {
// 절토가 모자라 성토를 못 채운다 — 평형선을 내리고 차액을 토취로 계상한다.
const start = crossFrom(points, cursor, apexX, nextY) ?? cursor;
steps.push({ from_m: cursor, to_m: start, level_m3: level });
pushResidual(cursor, start, level, nextY);
pushBlock(start, nextX, nextY, apexX, apexY, "backward");
level = nextY;
cursor = nextX;
k += 2;
}
}
// 극값 가지치기로 잘려 나간 꼬리가 있으면 총량이 어긋난다 — 종점 잔량으로 흡수한다.
const endX = points[points.length - 1].chainage_m;
const endY = points[points.length - 1].cumulative_volume_m3;
if (Math.abs(endY - level) > EPSILON) {
steps.push({ from_m: cursor, to_m: endX, level_m3: level });
pushResidual(cursor, endX, level, endY);
}
if (!blocks.length && !residuals.length) return null;
const transfers = settleResiduals(points, residuals, result.conversion, limits);
const settled = residuals.filter((residual) => residual.volume_m3 > EPSILON);
settled.forEach((residual, index) => {
residual.index = index + 1;
});
const deductionInput = options?.collected_stone_deduction_m3 ?? null;
const deducted = applyCollectedStoneDeduction(settled, deductionInput);
const remaining = settled.filter((residual) => residual.volume_m3 > EPSILON);
remaining.forEach((residual, index) => {
residual.index = index + 1;
});
let spoil = 0;
let borrow = 0;
let naturalSpoil = 0;
for (const residual of remaining) {
if (residual.kind === "spoil") {
spoil += residual.volume_m3;
naturalSpoil += residual.natural_m3;
} else borrow += residual.volume_m3;
}
let fillTotal = 0;
for (const point of points) fillTotal += point.fill_m3;
return {
blocks,
residuals: remaining,
transfers,
steps,
spoil_m3: spoil,
borrow_m3: borrow,
natural_spoil_m3: naturalSpoil,
collected_stone_deduction_m3: deductionInput,
collected_stone_deducted_m3: deducted,
hauled_m3: blocks.reduce((sum, block) => sum + block.volume_m3, 0),
transferred_m3: transfers.reduce((sum, entry) => sum + entry.volume_m3, 0),
fill_total_m3: fillTotal,
};
}