Files
Aislo/common_util/common_util_mass_haul_balance.ts
T
eomsangdonandClaude Opus 5 df6d33c462 feat(B06): 구조물 잔토에 터파기 토질을 이어받아 사토 갈래(ea/rr/br)를 채움
B08 이 구조물터파기에서 이미 판정한 토질(`design.ground_type`, 품셈 9-13 3구분)을
잔토 측점값에 함께 실어 준다. **새 근거를 만드는 것이 아니라 이어받는 것**이라
두 자리가 같은 판정을 쓴다(두 벌로 짜면 갈린다).

- `structure_spoil_points[].ground_type` (soil→ea · ripping_rock→rr · blasting_rock→br).
  옛 이름(`ground`)·라벨(`ground_label`)도 함께 받는다.
- ⚠ **섞여서 못 고른 구조물은 `null`** 로 오고 그 몫은 갈래 없이 담겨
  `ground_unknown_m3` 로 드러난다 — 임의로 토사로 몰지 않는다.
- ⚠ 한계는 그대로 넘어온다: 「터파기 깊이가 암반 경계선보다 깊은지」는 안 본다
  (터파기 토질에 이미 있던 한계이고 새로 생기는 것이 아니다).

시험 8건(갈래별로 담김·못 고른 몫은 「모름」으로 남음 포함).

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

748 lines
33 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;
/**
* 구조물 터파기가 남긴 잔토(㎥, 양수) — **사토에 더한다**. `null` 은 「아직 안 옴」이고
* `0` 은 「없음」이다(공제와 같은 태도).
*/
structure_spoil_m3: number | null;
/** 실제로 사토에 더한 양(㎥). 받을 잔량이 없으면 받은 값보다 작을 수 있다. */
structure_spoil_added_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·운반거리가 안 따라간다.
* ⚠ **토취는 안 건드린다.** 「채집석을 캐 가면 성토에 쓸 흙이 줄어 토취가 는다」는 갈래가
* 있으나 **근거가 없다** — 근거 없이 금액을 올리지 않는다. 확정되면 이 함수만 고치면 된다.
* ⚠ **순서** — 이 함수는 `applyStructureSpoil` **뒤에** 돈다. 잔토가 담긴 뒤라야 뺄 대상이 있다.
*
* 돌려주는 값은 **실제로 뺀 양(㎥)**. 사토가 모자라면 받은 값보다 작다.
*/
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);
}
/**
* 구조물 잔토 — **사토에 한 번만 더한다**(2026-09-09 네 창 합의).
*
* 구조물 잔토는 사토에 한 번만 더한다.
* B08 은 소요량(structure_spoil_m3, ㎥ 양수)을 내기만 하고,
* 더하는 자리는 유토곡선의 사토뿐이다.
*
* ⚠ **잔량 하나하나를 늘린다** — 총량만 늘리면 사토는 늘고 **운반이 안 는다**(공제 때와 같은 자리).
* ⚠ **나누는 법** — B08 이 지금은 **총량 하나**만 준다. 어느 측점에서 나온 잔토인지 모르므로
* **남은 사토 잔량의 크기에 비례**해 나눈다. 한 곳에 몰면 운반거리가 틀리기 때문이고,
* 측점별 값이 오면 그때 그 자리에 얹을 것(그때는 이 함수만 고치면 된다).
* ⚠ **자연방토(`natural_m3`)는 안 늘린다** — 구조물 잔토는 실어 내는 흙이다.
* ⚠ **지반유형 안분(ea/rr/br)도 안 건드린다** — 어느 지반에서 나온 흙인지 모른다.
* 합계(`volume_m3`)와 갈래 합이 어긋나는 것은 그 사실을 드러내는 표시다.
*
* 돌려주는 값은 **실제로 더한 양(㎥)**. 받을 사토 잔량이 하나도 없으면 0 이다.
*/
/**
* 터파기 토질 문자열 → 잔량의 지반 갈래 칸. 모르면 `null`(그 몫은 「지반 모름」으로 남는다).
* 받는 말은 B08 의 품셈 9-13 3구분과 우리 내부 이름 둘 다 받는다.
*/
function groundBucket(ground: string | null | undefined): "ea_m3" | "rr_m3" | "br_m3" | null {
const text = (ground ?? "").trim();
if (!text) return null;
if (text === "토사" || text === "soil" || text === "ea") return "ea_m3";
if (text === "암절취" || text === "리핑암" || text === "ripping_rock" || text === "rr") {
return "rr_m3";
}
if (text === "발파암" || text === "blasting_rock" || text === "br") return "br_m3";
return null;
}
function newSpoilResidual(fromM: number, toM: number, volumeM3: number): HaulResidual {
// 잔토를 담을 사토 잔량이 없을 때 **새로 세운다**(2026-09-09 확정 ㉰).
// ⚠ 지반유형 안분·자연방토는 0 이다 — 어느 지반에서 나온 흙인지 모르고, **실어 내는** 흙이다.
return {
index: 0,
kind: "spoil",
from_m: fromM,
to_m: toM,
volume_m3: volumeM3,
level_from_m3: 0,
level_to_m3: 0,
ea_m3: 0,
rr_m3: 0,
br_m3: 0,
natural_m3: 0,
};
}
/**
* 구조물 잔토 — **사토에 한 번만 더한다**(2026-09-09 네 창 합의).
*
* 구조물 잔토는 사토에 한 번만 더한다.
* B08 은 소요량(structure_spoil_m3, ㎥ 양수)을 내기만 하고,
* 더하는 자리는 유토곡선의 사토뿐이다.
*
* ⚠ **잔량 하나하나를 늘린다** — 총량만 늘리면 사토는 늘고 **운반이 안 는다**.
* ⚠ **담을 사토가 없으면 사토를 새로 세운다**(확정 ㉰). 파낸 흙은 어디로든 가므로
* **물량이 사라지면 안 된다**. 토취를 줄이는 길(㉯)은 **「그 잔토를 성토재로 쓸 수 있다」**는
* 근거가 있어야 하는데 구조물 터파기 흙은 암이 섞일 수 있고 우리가 그 판정을 안 한다 —
* 근거 없이 금액을 내리지 않고 **내보내는 쪽(안전측)** 으로 둔다.
* ⇒ 나중에 「성토재로 쓴다」가 확정되면 **이 함수 한 곳만** 바꾸면 된다.
* ⚠ 자연방토·지반유형 안분은 안 건드린다 — 실어 내는 흙이고, 어느 지반에서 나온지 모른다.
*/
function applyStructureSpoil(
residuals: HaulResidual[],
amount: number | null,
points: Array<{
chainage_m: number;
spoil_m3: number;
/** B08 이 보내는 이름은 `ground_type`(soil·ripping_rock·blasting_rock). 옛 이름·라벨도 받는다. */
ground_type?: string | null;
ground_label?: string | null;
ground?: string | null;
}> | null,
): number {
const spoilsOf = (): HaulResidual[] => residuals.filter((residual) => residual.kind === "spoil");
// ① 측점별 값이 오면 **그 자리**에 얹는다 — 구조물이 실제로 선 자리라 운반거리가 맞다.
if (points && points.length) {
let added = 0;
for (const point of points) {
const value = Number(point?.spoil_m3);
const chainage = Number(point?.chainage_m);
if (!Number.isFinite(value) || value <= 0 || !Number.isFinite(chainage)) continue;
const spoils = spoilsOf();
const covering = spoils.find(
(residual) => chainage >= residual.from_m - 1e-9 && chainage <= residual.to_m + 1e-9,
);
// 터파기 토질이 함께 오면 **그 갈래로 담는다** — B08 이 이미 판정한 값을 이어받는 것이라
// 새 근거를 만드는 것이 아니다. 안 오면 갈래 없이 담겨 「지반 모름」으로 남는다.
const bucket = groundBucket(point?.ground_type ?? point?.ground ?? point?.ground_label);
const target = covering ?? newSpoilResidual(chainage, chainage, 0);
if (!covering) residuals.push(target);
target.volume_m3 += value;
if (bucket) target[bucket] += value;
added += value;
}
return added;
}
// ② 총량만 오면 남은 사토 잔량 크기에 **비례**해 나눈다(어느 자리인지 모를 때의 차선).
if (amount === null || !Number.isFinite(amount) || amount <= 0) return 0;
const spoils = spoilsOf();
const total = spoils.reduce((sum, residual) => sum + residual.volume_m3, 0);
if (!spoils.length || total <= EPSILON) {
// 담을 사토가 없다 — 있는 잔량이 덮는 구간 전체에 사토를 하나 세운다. 자리를 모르므로
// 구간을 넓게 잡고, **물량은 보존**한다.
const froms = residuals.map((residual) => residual.from_m);
const tos = residuals.map((residual) => residual.to_m);
const fromM = froms.length ? Math.min(...froms) : 0;
const toM = tos.length ? Math.max(...tos) : 0;
residuals.push(newSpoilResidual(fromM, toM, amount));
return amount;
}
let added = 0;
spoils.forEach((residual, index) => {
const share =
index === spoils.length - 1 ? amount - added : amount * (residual.volume_m3 / total);
residual.volume_m3 += share;
added += share;
});
return added;
}
export function computeHaulPlan(
result: MassHaulResult,
limits: HaulEquipmentLimit[] | undefined,
options?: {
collected_stone_deduction_m3?: number | null;
structure_spoil_m3?: number | null;
/** 측점별 구조물 잔토 — 오면 **이쪽이 이긴다**(그 자리 잔량에 얹어 운반거리를 맞춘다). */
structure_spoil_points?: Array<{
chainage_m: number;
spoil_m3: number;
/** 그 터파기의 토질 — B08 이 `design.ground_type` 으로 이미 판정한 값(품셈 9-13 3구분).
* **새 근거를 만드는 것이 아니라 이어받는 것**이다. 섞여서 못 고른 구조물은 `null` 로
* 오고, 그 몫은 「지반 모름」(`ground_unknown_m3`)으로 남는다. */
ground_type?: string | null;
ground_label?: string | null;
ground?: string | null;
}> | 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;
});
// ⚠ **순서가 뜻을 가른다 — 「잔토를 더하고 → 공제를 뺀다」**(2026-09-09 확정).
// 유토곡선의 사토는 「현장에 남는 흙 총량」이고 **출처를 안 가린다**. 공제는 그 총량에서
// 빼는 것이라 **잔토가 담긴 뒤라야 뺄 대상이 있다.** 반대 순서로 두면 사토가 0 인
// 노선(전 구간 토취)에서 공제가 **영영 안 걸린다** — 두 창 저장분에서 실제로 그랬다.
const structureSpoilInput = options?.structure_spoil_m3 ?? null;
const structureSpoilPoints = options?.structure_spoil_points ?? null;
const structureSpoilAdded = applyStructureSpoil(
settled,
structureSpoilInput,
structureSpoilPoints,
);
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,
structure_spoil_m3: structureSpoilInput,
structure_spoil_added_m3: structureSpoilAdded,
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,
};
}