/* ============================================================================= * 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"; import type { BalanceStep, CurvePoint, HaulBand, HaulBlock, HaulDirection, HaulEquipmentLimit, HaulPlan, HaulResidual, } from "./common_util_mass_haul_balance_types"; import { applyCollectedStoneDeduction, applyStructureSpoil, } from "./common_util_mass_haul_balance_apply"; export type { BalanceStep, CurvePoint, HaulBand, HaulBlock, HaulDirection, HaulEquipmentLimit, HaulPlan, HaulResidual, HaulTransfer, } from "./common_util_mass_haul_balance_types"; /** * **그림에서만** 이보다 작은 진동은 블록으로 세지 않는다. 측점 하나짜리 요철까지 블록을 만들면 * 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. */ 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, }; }