/* ============================================================================= * common_util_mass_haul_balance.ts * 토량 분배(평형선 · 운반 블록 · 장비 띠) 계산 엔진 — 순수 함수, DOM 의존 없음. * * 누가토량 곡선(`common_util_mass_haul`)은 "얼마가 남는가"만 말한다. 이 파일은 그 곡선에서 * **어디 흙을 어디로 얼마나 옮기는가**를 뽑아 낸다: 평형선 → 운반 블록 → 장비 띠 → * 띠별 운반토량 `Q` · 평균운반거리 `L` · 지반유형 안분. * * ── 작도 규칙(참고자료) ───────────────────────────────────────────── * * - 상승 = 절토, 하강 = 성토. 극대·극소점 = 절·성토 경계. * - 산 모양 = 좌→우 운반, 골 모양 = 우→좌 운반. * - 평형선(수평선)이 곡선을 자르는 두 점 사이는 절토량 = 성토량. * - 운반토량 = 평형선에서 곡선 정점까지의 종거. * - 평균운반거리 = **종거의 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 type { EarthworkConversion } from "./common_util_mass_haul_types"; 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이 수십 개 깔려 도면을 못 읽는다. 곡선 진폭 대비 비율이라 노선 규모에 자동으로 맞는다. * ⚠ 수량 계획에는 걸지 않는다 — 진폭이 커지면 작은 봉우리가 통째로 지워져 운반량이 0 이 됐다 * (2026-09-14 936be972 실측 474.14㎥ → 0 · 브레인 ①). */ 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, byGround?: Record | null, groundUnknownM3?: number | null, conversion?: EarthworkConversion | null, ): number { // ⚠ **축을 맞춘 뒤 뺀다**(2026-09-09 세 창 확정) — 채집석은 **벽 입적(제자리 부피)**이라 // 자연 축이고 이 곡선은 다짐 축이다. 갈래별 값이 오면 **갈래마다 ×C** 해서 뺀다. // 갈래를 못 가른 몫은 **계수가 없어 환산하지 않고** 그대로 뺀다(근거 없이 안 눅인다). // 갈래가 아예 안 오면 종전처럼 총량을 그대로 뺀다 — 값이 안 오는 것과 0 은 다르다. const converted = compactedStoneDeduction(byGround, groundUnknownM3, conversion); const amountToTake = converted === null ? deduction : converted; if (amountToTake === null || !Number.isFinite(amountToTake) || amountToTake <= 0) return 0; const spoils = residuals.filter((residual) => residual.kind === "spoil"); let left = amountToTake; 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 amountToTake - 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; } /** 갈래 칸 ↔ 환산계수 이름. `EARTHWORK_CONVERSION_FACTORS` 의 키다. */ const GROUND_OF_BUCKET = { ea_m3: "soil", rr_m3: "ripping_rock", br_m3: "blasting_rock", } as const; /** 환산계수 이름 ↔ 채집석이 오는 갈래 이름. B08 이 `soil`·`ripping_rock`·`blasting_rock` 로 보낸다. */ const STONE_GROUND_KEYS = ["soil", "ripping_rock", "blasting_rock"] as const; /** * 채집석 공제를 **다짐 축**으로 옮긴다 — 갈래마다 ×C, 갈래를 모르는 몫은 그대로. * 갈래가 하나도 안 오면 `null`(= 「갈래를 안 보냈음」)이라 부르는 쪽이 총량으로 되돌아간다. */ function compactedStoneDeduction( byGround: Record | null | undefined, groundUnknownM3: number | null | undefined, conversion: EarthworkConversion | null | undefined, ): number | null { if (!byGround) return null; const entries = STONE_GROUND_KEYS.map((key) => [key, Number(byGround[key] ?? 0)] as const).filter( ([, value]) => Number.isFinite(value) && value > 0, ); const unknown = Number(groundUnknownM3 ?? 0); const hasUnknown = Number.isFinite(unknown) && unknown > 0; if (!entries.length && !hasUnknown) return null; let total = hasUnknown ? unknown : 0; for (const [ground, value] of entries) { const factor = conversion?.[ground]?.compacted; total += typeof factor === "number" && factor > 0 ? value * factor : value; } return total; } /** * **자연상태 → 다짐상태**(× C). 구조물 잔토를 곡선에 얹기 전에 한 번만 거친다. * * ⚠ 왜 필요한가 — 유토곡선은 **다짐상태**로 쌓는다(`common_util_mass_haul` 이 절토량에 * ×C 를 곱해 둔다). B08 이 보내는 구조물 잔토는 **자연상태**(터파기 제자리 기하 부피)라 * 그대로 더하면 **상태가 다른 두 부피를 섞는 것**이 된다. * ⚠ **되돌리는 곳은 내보내는 자리 한 곳뿐**(B08 `HaulSummary`·사토 줄에서 ÷C). * 여기서 ×C, 거기서 ÷C — **왕복 한 번씩이다. 다른 데서 또 환산하지 말 것.** * ⚠ 계수를 모르면(지반 갈래를 못 붙였으면) **환산하지 않고 그대로 담는다** — 토사 계수로 * 눅이면 근거 없이 금액이 움직인다. 그 몫은 `ground_unknown_m3` 로 드러난다. */ function toCompacted( naturalM3: number, bucket: keyof typeof GROUND_OF_BUCKET | null, conversion: EarthworkConversion | null | undefined, ): number { if (!bucket || !conversion) return naturalM3; const factor = conversion[GROUND_OF_BUCKET[bucket]]?.compacted; return typeof factor === "number" && factor > 0 ? naturalM3 * factor : naturalM3; } 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, ㎥ 양수)을 내기만 하고, * 더하는 자리는 유토곡선의 사토뿐이다. * * ⚠ **잔량 하나하나를 늘린다** — 총량만 늘리면 사토는 늘고 **운반이 안 는다**. * ⚠ **담을 사토가 없으면 사토를 새로 세운다**(확정 ㉰). 파낸 흙은 어디로든 가므로 * **물량이 사라지면 안 된다**. 토취를 줄이는 길(㉯)은 **「그 잔토를 성토재로 쓸 수 있다」**는 * 근거가 있어야 하는데 구조물 터파기 흙은 암이 섞일 수 있고 우리가 그 판정을 안 한다 — * 근거 없이 금액을 내리지 않고 **내보내는 쪽(안전측)** 으로 둔다. * ⇒ 나중에 「성토재로 쓴다」가 확정되면 **이 함수 한 곳만** 바꾸면 된다. * ⚠ 자연방토는 안 건드린다 — 실어 내는 흙이다. * ⚠⚠ **상태가 다르다** — B08 이 보내는 잔토는 **자연상태**(`volume_basis: "natural"`, * 터파기 제자리 기하 부피)이고 이 곡선은 **다짐상태**다. 그래서 담기 전에 `toCompacted` * 로 **×C** 한다. 되돌리는 것은 **내보내는 자리 한 곳**(B08 운반·사토 줄에서 ÷C)뿐이다. * 갈래를 못 붙인 몫은 계수가 없어 **환산하지 않고** 그대로 담긴다(근거 없이 안 눅인다). */ 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, conversion?: EarthworkConversion | 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); // ⚠ **자연상태로 와서 다짐상태 곡선에 얹힌다** — 갈래를 아는 몫만 ×C(2026-09-09). // 갈래를 모르면 그대로 담기고 `ground_unknown_m3` 로 「상태도 못 되돌림」이 드러난다. const placed = toCompacted(value, bucket, conversion); target.volume_m3 += placed; if (bucket) target[bucket] += placed; added += placed; } 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; /** 갈래별 채집석(㎥) — **벽 입적(자연 축)**이라 여기서 ×C 해 다짐 축에 맞춘다. */ collected_stone_by_ground_m3?: Record | null; /** 갈래를 못 가른 채집석(㎥) — 계수가 없어 **환산하지 않고** 그대로 뺀다. */ collected_stone_ground_unknown_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; /** 토량환산계수 — 구조물 잔토(자연상태)를 이 곡선의 **다짐상태**로 옮길 때만 쓴다. * 안 오면 환산 없이 담긴다(값을 지어내지 않는다). */ conversion?: EarthworkConversion | null; /** 그림용 — 잔진동을 거른다(`MIN_SWING_RATIO`). 안 오면 **거르지 않는다**(수량 계획). */ drawing?: boolean; }, ): 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 extrema = options?.drawing ? pruneExtrema(points, extremaIndices(points), Math.max(range * MIN_SWING_RATIO, 1)) : extremaIndices(points); 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, options?.conversion ?? null, ); const deductionInput = options?.collected_stone_deduction_m3 ?? null; const deducted = applyCollectedStoneDeduction( settled, deductionInput, options?.collected_stone_by_ground_m3 ?? null, options?.collected_stone_ground_unknown_m3 ?? null, options?.conversion ?? null, ); 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, }; }