/* ============================================================================= * 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 { 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·운반거리가 안 따라간다. * * 돌려주는 값은 **실제로 뺀 양(㎥)**. 사토가 모자라면 받은 값보다 작다. */ 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; }); const deductionInput = options?.collected_stone_deduction_m3 ?? null; const deducted = applyCollectedStoneDeduction(settled, deductionInput); const structureSpoilInput = options?.structure_spoil_m3 ?? null; const structureSpoilPoints = options?.structure_spoil_points ?? null; const structureSpoilAdded = applyStructureSpoil( settled, structureSpoilInput, structureSpoilPoints, ); 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, }; }