두 창 저장분에서 **공제가 한 톨도 안 빠졌다**(`deducted 0`). 두 노선 다 원래 사토가 0 (전 구간 토취)이라, 공제가 잔토 가산보다 **먼저** 돌아 뺄 대상이 없었다. - 유토곡선의 사토는 「현장에 남는 흙 총량」이고 출처를 안 가린다 — 공제는 그 총량에서 빼는 것이라 **잔토가 담긴 뒤라야** 뺄 대상이 있다. - ⚠ 모자라면 `collected_stone_deducted_m3` 가 작게 나와 「덜 뺐다」가 값으로 드러난다 — 그 장치는 그대로 살려 뒀다. - ⚠ 토취는 계속 안 건드린다. 「채집석을 캐 가면 성토에 쓸 흙이 줄어 토취가 는다」는 갈래가 있으나 근거가 없다 — 그 여지를 주석에 적어 뒀다. 시험 7건(순서 시험 추가: 잔토 100 더한 뒤 공제 40 이 실제로 빠짐). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
716 lines
32 KiB
TypeScript
716 lines
32 KiB
TypeScript
/* =============================================================================
|
||
* 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′ / C–C′),
|
||
* 그 사이 띠마다 번호가 붙어 종단면의 해칭 구간과 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 이다.
|
||
*/
|
||
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 }> | 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,
|
||
);
|
||
if (covering) {
|
||
covering.volume_m3 += value;
|
||
} else {
|
||
// 그 자리를 품는 사토가 없다 — 그 측점에 사토를 새로 세워 담는다.
|
||
residuals.push(newSpoilResidual(chainage, chainage, 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 }> | 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,
|
||
};
|
||
}
|