/* ============================================================================= * common_util_mass_haul.ts * 유토곡선(Mass Haul Diagram) 계산 엔진 — 순수 함수, DOM 의존 없음. * * 구간 토량을 평균단면법으로 만들고 토량환산계수로 기준을 통일해 누가토량을 누적한다. * 결과는 B08 수량산출·B09 견적이 그대로 재사용할 수 있도록 계산과 렌더링을 분리해 * 둔다(렌더러는 `common_util_mass_haul_view`). * * ── 계산 기준: 다짐상태 ────────────────────────────────────────────── * "운반거리의 산정 시에 모든 수량은 다짐상태로 환산하여 계산하고, 내역서에 적용하는 * 수량은 자연상태로 한다." (2021년도 국도건설공사 설계실무 요령 / 표준품셈 계열) * 유토곡선은 운반계획 도면이므로 다짐상태를 기준상태로 삼는다. 내역서용 자연상태 * 수량은 `cut_natural_m3`로 함께 보존해 B07이 되돌려 쓸 수 있게 한다. * * ── 두 기준(종단/횡단)을 함께 내는 이유 ────────────────────────────── * [임도의 설계 및 시설기준] 2.다.(3)(나)는 "시공계획고는 절토량과 성토량이 균형을 * 이루게 하되"라고 정해 **종단 계획고**를 균형 판정 대상으로 삼는다. 반면 실제 물량은 * 같은 기준 2.다.(4)(마)의 측점별 횡단 단면적에서 나온다. 임도는 산복 사면을 따라가 * 편절·편성이 지배적이므로 두 기준이 크게 어긋날 수 있다. 어느 쪽을 정식 기준으로 삼을지 * 사용자가 그래프를 보고 판단할 수 있게, 두 기준을 같은 축에 겹쳐 그린다. * * ── 절토를 토사/암반으로 나눠 환산하는 이유 ────────────────────────── * 같은 기준 2.다.(4)(다)는 "절토부분은 토사·암반으로 구분하되, 암반부분은 추정선으로 * 기입한다"고 정한다. 절토면적은 계수로 나누는 것이 아니라 **횡단도 기하로 갈린다** — * 지표면~암반 경계선이 토사, 그 아래가 암이다. 엔진(`compute_cross_design()`)이 그 경계로 * 나눈 `cut_soil_area_m2` / `cut_rock_area_m2`를 각각 자기 환산계수로 곱해 합산한다. * 참고 도면(3공구)의 작업별 EA:RR 비율이 34:66~71:29로 흩어지는 것이 이 구조의 근거다. * * 예전에 있던 비교군 2종(토사 단일 환산 / 환산 없음)은 걷어냈다(2026-08-02 사용자 지시). * 절토면적이 암반 경계선으로 갈리기 전에는 환산계수 차이를 눈으로 대조할 필요가 있었지만, * 이제 지형이 면적으로 갈려 나오므로 **부피비를 단일값이나 1로 밀어 넣은 곡선은 정보가 없다.** * * 분석 근거: docs/raw/2026-08-02_유토곡선_3공구_분석.md * ========================================================================== */ import type { EarthworkConversion, GroundType, MassHaulDesign, MassHaulLongitudinal, MassHaulSection, } from "./common_util_mass_haul_types"; /** 지반유형별 토량(㎥). 도면 표기 EA(토사)/RR(리핑암)/BR(발파암)에 대응한다. */ export interface GroundVolumes { soil: number; ripping_rock: number; blasting_rock: number; } /** 측점 하나의 유토곡선 좌표. 구간 물량은 **직전 측점부터 이 측점까지**의 몫이다(첫 측점은 0). */ export interface MassHaulPoint { station_id: string; chainage_m: number; /** 직전 측점부터 이 측점까지 구간의 순토량(㎥). 첫 측점은 0. */ net_volume_m3: number; /** 시점부터의 누가토량(㎥). */ cumulative_volume_m3: number; /** 구간 절토량(자연상태) — 토사분/암반분. 측점 선택 시 물량 표기에 그대로 쓴다. */ cut_soil_m3: number; cut_rock_m3: number; /** * 암반분을 암종까지 가른 값(자연상태, `cut_rr_m3 + cut_br_m3 === cut_rock_m3`). * 운반 블록의 EA/RR/BR 안분(`common_util_mass_haul_balance`)이 이 구간 단위를 되받아 쓴다 — * 한 구간의 양 끝 측점이 서로 다른 암종일 수 있어 합계만으로는 되돌릴 수 없다. */ cut_rr_m3: number; cut_br_m3: number; /** 구간 절토량(환산 후) — 곡선에 실제로 들어간 값. */ cut_compacted_m3: number; /** 구간 성토량(다짐상태 = 설계 물량). */ fill_m3: number; /** * 이 구간에서 자연방토가 가능한가 — 양 끝 측점이 **둘 다** 가능할 때만 참(보수적). * 남는 흙을 성토사면으로 흘려보내 운반비를 세지 않을 수 있는지 판단하는 입력이다. */ natural_spoil: boolean; /** * 이 측점의 **순단면적**(㎡, 다짐환산) = 절토환산단면적 − 성토단면적. * * 유토곡선은 측점 사이에서 직선이 아니라 **포물선**이다 — 단면적이 선형으로 변하면 * 그 적분인 누가토량은 2차식이 된다. 평균운반거리(반종거 현)를 직선으로 재면 참고 도면 대비 * 약 40% 짧게 나와 장비 경계 판정이 어긋난다(도면의 20m 구간 반종거가 20/√2 = 14.14m). * 이 값이 있어야 `common_util_mass_haul_balance`가 구간마다 2차식을 세울 수 있다. */ net_area_m2: number; } export interface MassHaulResult { points: MassHaulPoint[]; /** 총 절토량(자연상태, 지반유형별) — 내역서 기준값. 환산 방식과 무관하게 실제 지반유형으로 쌓는다. */ cut_natural_m3: GroundVolumes; /** 총 절토량(환산 후 합계) — 곡선 기준값. */ cut_compacted_m3: number; /** 총 성토량(설계 성토량 = 다짐상태). */ fill_compacted_m3: number; /** 최종 누가토량. 양수면 잉여(사토), 음수면 부족(토취). */ final_cumulative_m3: number; /** 최종 잉여 토량(사토 대상, ㎥). 부족하면 0. */ surplus_m3: number; /** 최종 부족 토량(토취 대상, ㎥). 잉여면 0. */ shortage_m3: number; min_cumulative_m3: number; max_cumulative_m3: number; /** 계산에 실제 적용한 환산계수 — 나중에 검산할 수 있도록 함께 보존한다. */ conversion: EarthworkConversion; } /** 토량을 어디서 뽑았는지. `cross`가 정식(실측 단면적), `longitudinal`은 계획고 기반 개략값. */ export type MassHaulBasis = "cross" | "longitudinal"; export interface MassHaulSeries { /** 표시 토글 저장 키이자 CSS 변형 클래스 접미사(= `basis`). */ key: MassHaulBasis; basis: MassHaulBasis; result: MassHaulResult; } const GROUND_TYPES: GroundType[] = ["soil", "ripping_rock", "blasting_rock"]; /** 곡선을 그릴 산출 기준. 배열 순서가 곧 범례 순서다. */ const SERIES_BASES: MassHaulBasis[] = ["cross", "longitudinal"]; /** * 기본으로 켜 두는 곡선 — 횡단 기준만. 종단 기준은 계획고 기반 개략값이라 참고용이므로 * 사용자가 필요할 때 범례에서 켠다(2026-08-02 사용자 지시). */ export const MASS_HAUL_DEFAULT_VISIBLE: MassHaulBasis[] = ["cross"]; /** * 토량 분배(평형선) 레이어의 표시 토글 키. 곡선 기준(`MassHaulBasis`)이 아니라 곡선 **위에 * 얹는 레이어**라 별도 키로 둔다 — 같은 범례 줄에서 함께 켜고 끈다. */ export const MASS_HAUL_BALANCE_KEY = "balance"; /** * 곡선 기준 버튼은 **라디오**다 — 횡단/종단 중 하나를 고르면 그 기준으로 계산된 그래프 * 하나만 전체 영역에 보인다(2026-08-03 사용자 지시. 겹쳐 비교하던 이전 방식 폐기). * 켜짐 집합에 기준 키가 없거나 여럿이면 순서상 앞선 기준 하나로 눌러 맞춘다 — * 두 화면(B05/B06)이 같은 저장 키를 공유하므로 읽는 쪽마다 같은 규칙이어야 한다. */ export function normalizeVisibleBasis(visible: Set): Set { const active = SERIES_BASES.filter((basis) => visible.has(basis)); if (active.length === 1) return visible; const next = new Set(visible); SERIES_BASES.forEach((basis) => next.delete(basis)); next.add(active[0] ?? SERIES_BASES[0]); return next; } /** 범례 클릭 하나를 적용한다 — 기준 키는 라디오 선택, 그 외(분배 레이어)는 켜고 끄기. */ export function applyLegendToggle(visible: Set, key: string): Set { const next = new Set(visible); if ((SERIES_BASES as string[]).includes(key)) { SERIES_BASES.forEach((basis) => next.delete(basis)); next.add(key); } else if (next.has(key)) { next.delete(key); } else { next.add(key); } return normalizeVisibleBasis(next); } /** 환산계수가 비어 있거나 값이 이상하면 1.0으로 떨어뜨려 계산이 멈추지 않게 한다. */ function factorFor(conversion: EarthworkConversion, ground: GroundType): number { const factor = conversion?.[ground]?.compacted; return Number.isFinite(factor) && factor > 0 ? factor : 1; } function emptyVolumes(): GroundVolumes { return { soil: 0, ripping_rock: 0, blasting_rock: 0 }; } function finiteArea(value: number | undefined): number { return Number.isFinite(value) && (value as number) > 0 ? (value as number) : 0; } function normalizeGround(value: GroundType | undefined): GroundType { return value && GROUND_TYPES.includes(value) ? value : "soil"; } /** * 적분 입력 한 점. 기준이 달라도 여기까지 오면 같은 코드를 탄다. * 절토는 암반 경계선으로 이미 갈린 상태로 들어온다(토사분 / 암반분). */ interface AreaSample { station_id: string; chainage_m: number; cut_soil_area_m2: number; cut_rock_area_m2: number; /** 암반분에 물릴 지반유형. 토사 측점은 null(암반분 0). */ rock_kind: GroundType | null; fill_area_m2: number; /** 이 측점에서 자연방토가 가능한가(성토측 지반이 판정 경사보다 가파른가). */ natural_spoil: boolean; } /** * 엔진이 낸 절토 분리값을 읽는다. 구 데이터(분리 필드 없음)는 절토 전량을 측점 * 지반유형으로 돌려 **기존 단일 지반유형 환산과 같은 결과**를 유지한다. */ function splitCut(design: MassHaulDesign | undefined): { soil: number; rock: number; rock_kind: GroundType | null; } { const ground = normalizeGround(design?.ground_type); const total = finiteArea(design?.cut_area_m2); if (design?.cut_soil_area_m2 === undefined || design?.cut_rock_area_m2 === undefined) { return ground === "soil" ? { soil: total, rock: 0, rock_kind: null } : { soil: 0, rock: total, rock_kind: ground }; } return { soil: finiteArea(design.cut_soil_area_m2), rock: finiteArea(design.cut_rock_area_m2), rock_kind: design.cut_rock_kind ?? (ground === "soil" ? null : ground), }; } /** * 측점 하나의 순단면적(다짐환산 절토 − 성토). 구간 토량 `(a₀ + a₁)Δ/2`가 평균단면법과 * 정확히 같으므로, 이 값으로 세운 2차식은 지금 곡선을 그대로 통과한다. */ function sampleNetArea(sample: AreaSample, conversion: EarthworkConversion): number { const soil = sample.cut_soil_area_m2 * factorFor(conversion, "soil"); const rock = sample.rock_kind ? sample.cut_rock_area_m2 * factorFor(conversion, sample.rock_kind) : 0; return soil + rock - sample.fill_area_m2; } /** * 단면적 시퀀스를 누가토량으로 적분한다. * * 구간 토량은 평균단면법으로 구한다. 지반유형은 점마다 다를 수 있으므로, 각 점이 자기 쪽 * 절반(`단면적 × Δd / 2`)을 자기 지반유형으로 가져간다. 두 절반을 합치면 * `(A_i + A_i+1) / 2 × Δd`가 되어 평균단면법과 정확히 일치한다. */ function integrate(samples: AreaSample[], conversion: EarthworkConversion): MassHaulResult | null { if (samples.length < 2) return null; const points: MassHaulPoint[] = [ { station_id: samples[0].station_id, chainage_m: samples[0].chainage_m, net_volume_m3: 0, cumulative_volume_m3: 0, cut_soil_m3: 0, cut_rock_m3: 0, cut_rr_m3: 0, cut_br_m3: 0, cut_compacted_m3: 0, fill_m3: 0, natural_spoil: samples[0].natural_spoil, net_area_m2: sampleNetArea(samples[0], conversion), }, ]; const cutNatural = emptyVolumes(); let cutCompacted = 0; let fillCompacted = 0; let cumulative = 0; let minCumulative = 0; let maxCumulative = 0; for (let index = 1; index < samples.length; index += 1) { const previous = samples[index - 1]; const current = samples[index]; const spanM = current.chainage_m - previous.chainage_m; // 같은 위치에 점이 겹쳐 들어오면 구간 부피가 0이라 곡선에 기여하지 않는다. if (!(spanM > 0)) continue; const halfSpan = spanM / 2; // 절토: 양 끝 점이 각자 절반씩 가져가고, 각 점 안에서 토사분·암반분이 따로 환산된다. let segmentCut = 0; let segmentCutSoil = 0; let segmentCutRipping = 0; let segmentCutBlasting = 0; for (const end of [previous, current]) { const soilM3 = end.cut_soil_area_m2 * halfSpan; const rockM3 = end.cut_rock_area_m2 * halfSpan; if (soilM3 > 0) { cutNatural.soil += soilM3; segmentCutSoil += soilM3; segmentCut += soilM3 * factorFor(conversion, "soil"); } if (rockM3 > 0 && end.rock_kind) { cutNatural[end.rock_kind] += rockM3; if (end.rock_kind === "blasting_rock") segmentCutBlasting += rockM3; else segmentCutRipping += rockM3; segmentCut += rockM3 * factorFor(conversion, end.rock_kind); } } // 성토: 설계 성토량이 곧 다짐상태 물량이라 환산하지 않는다. const segmentFill = (previous.fill_area_m2 + current.fill_area_m2) * halfSpan; const net = segmentCut - segmentFill; cutCompacted += segmentCut; fillCompacted += segmentFill; cumulative += net; minCumulative = Math.min(minCumulative, cumulative); maxCumulative = Math.max(maxCumulative, cumulative); points.push({ station_id: current.station_id, chainage_m: current.chainage_m, net_volume_m3: net, cumulative_volume_m3: cumulative, cut_soil_m3: segmentCutSoil, cut_rock_m3: segmentCutRipping + segmentCutBlasting, cut_rr_m3: segmentCutRipping, cut_br_m3: segmentCutBlasting, cut_compacted_m3: segmentCut, fill_m3: segmentFill, natural_spoil: previous.natural_spoil && current.natural_spoil, net_area_m2: sampleNetArea(current, conversion), }); } if (points.length < 2) return null; return { points, cut_natural_m3: cutNatural, cut_compacted_m3: cutCompacted, fill_compacted_m3: fillCompacted, final_cumulative_m3: cumulative, surplus_m3: Math.max(cumulative, 0), shortage_m3: Math.max(-cumulative, 0), min_cumulative_m3: minCumulative, max_cumulative_m3: maxCumulative, conversion, }; } /** 설계가 지정된 측점만 누가거리 순으로 모은다. 두 기준이 공통으로 쓰는 기준점 목록. */ function designedSections(crossSections: MassHaulSection[]): MassHaulSection[] { return crossSections .filter((section) => section.design && Number.isFinite(section.chainage_m)) .sort((a, b) => a.chainage_m - b.chainage_m); } /** * 자연방토 가능 여부. 성토측 자연 지반이 판정 경사보다 가파르면 부어 놓은 흙이 쌓이지 않고 * 스스로 흘러내린다. 판정 경사(config)를 못 받았으면 **전부 불가**로 본다 — 프론트에 사본을 * 두지 않으며, 모르면 운반비를 세는 쪽이 안전하다. */ function naturalSpoilAllowed(design: MassHaulDesign | undefined, minSlope?: number): boolean { if (!Number.isFinite(minSlope)) return false; const slope = design?.fill_ground_slope; return Number.isFinite(slope) && (slope as number) >= (minSlope as number); } /** 횡단 기준 — 엔진이 낸 실측 단면적을 그대로 쓴다. */ function crossAreaSamples(sections: MassHaulSection[], minSlope?: number): AreaSample[] { return sections.map((section) => { const cut = splitCut(section.design); return { station_id: section.station_id, chainage_m: section.chainage_m, cut_soil_area_m2: cut.soil, cut_rock_area_m2: cut.rock, rock_kind: cut.rock_kind, fill_area_m2: finiteArea(section.design?.fill_area_m2), natural_spoil: naturalSpoilAllowed(section.design, minSlope), }; }); } /** * 종단 기준 — 계획고와 지반고의 차이(절토고/성토고)에 노반폭을 곱한 개략 단면적. * * `difference_m = 계획고 − 지반고`이므로 음수면 절토, 양수면 성토다 * (`B05_Profile_UI_Profile_Alignment.ts`가 이미 쓰는 부호 규약과 같다). * 노반폭과 지반유형은 종단 샘플에 없으므로 **가장 가까운 설계 측점**에서 가져온다. * 설계된 측점이 하나도 없으면 노반폭을 알 수 없어 곡선을 만들지 않는다. * * 개략 단면적에는 암반 경계선이 없으므로, **가장 가까운 측점의 토사:암반 면적비**를 그대로 * 물려 두 기준이 같은 환산을 타게 한다(비율을 못 구하면 전량 토사로 둔다). */ function longitudinalAreaSamples( longitudinal: MassHaulLongitudinal, sections: MassHaulSection[], ): AreaSample[] { const profile = longitudinal.design_profiles?.[0]; if (!profile || !sections.length) return []; const samples = profile.samples .filter((sample) => Number.isFinite(sample.chainage_m) && Number.isFinite(sample.difference_m)) .sort((a, b) => a.chainage_m - b.chainage_m); if (samples.length < 2) return []; // 두 배열 모두 누가거리 순이라 포인터 하나로 최근접 측점을 훑는다. let cursor = 0; return samples.map((sample, index) => { while ( cursor + 1 < sections.length && Math.abs(sections[cursor + 1].chainage_m - sample.chainage_m) <= Math.abs(sections[cursor].chainage_m - sample.chainage_m) ) { cursor += 1; } const nearest = sections[cursor]; const width = finiteArea(nearest.design?.roadbed_width_m); const height = sample.difference_m; const cutArea = height < 0 ? -height * width : 0; const cut = splitCut(nearest.design); const total = cut.soil + cut.rock; const rockRatio = total > 0 ? cut.rock / total : 0; return { station_id: `${nearest.station_id}@${index}`, chainage_m: sample.chainage_m, cut_soil_area_m2: cutArea * (1 - rockRatio), cut_rock_area_m2: cutArea * rockRatio, rock_kind: cut.rock_kind, fill_area_m2: height > 0 ? height * width : 0, natural_spoil: false, }; }); } /** * 측점별 설계 결과에서 유토곡선을 계산한다(횡단 기준·지반유형별 환산). * 확정 저장과 B08 인계에 쓰는 **정식 곡선**이라 시그니처를 그대로 유지한다. */ export function computeMassHaul( crossSections: MassHaulSection[], conversion: EarthworkConversion, naturalSpoilMinSlope?: number, ): MassHaulResult | null { return integrate( crossAreaSamples(designedSections(crossSections), naturalSpoilMinSlope), conversion, ); } /** * 비교용 곡선 묶음. 산출 기준 2종(횡단/종단)을 계산한다. * 계산이 불가능한 기준(설계 측점 부족, 종단 계획선 없음)은 조용히 빠진다. */ export function computeMassHaulSeries( longitudinal: MassHaulLongitudinal, crossSections: MassHaulSection[], conversion: EarthworkConversion, naturalSpoilMinSlope?: number, /** 낼 기준. 총괄값(배지)만 필요하면 `["cross"]` 로 불러 종단 기준 적분을 건너뛴다. */ bases: readonly MassHaulBasis[] = SERIES_BASES, ): MassHaulSeries[] { const sections = designedSections(crossSections); const samplesFor = (basis: MassHaulBasis): AreaSample[] => basis === "cross" ? crossAreaSamples(sections, naturalSpoilMinSlope) : longitudinalAreaSamples(longitudinal, sections); const series: MassHaulSeries[] = []; for (const basis of SERIES_BASES) { if (!bases.includes(basis)) continue; const result = integrate(samplesFor(basis), conversion); if (result) series.push({ key: basis, basis, result }); } return series; } /** * 확정 시 DB(`longitudinal_sections.data.mass_haul`)에 넣을 직렬화 형태로 정리한다. * * **토량 분배(`haul_plan`)는 여기서 만들지 않는다** — 그 코드를 브라우저 번들에서 빼려고 * 서버(Node 진입점)가 얹는다(2026-09-06 사용자 확정). 이 함수는 누가토량만 다룬다. * 이미 만들어 둔 배분 조각이 있으면 `extra` 로 넘겨 그대로 실린다. */ export function massHaulPayload( result: MassHaulResult, extra?: Record | null, balloonOffsets?: Record, ): Record { const round = (value: number): number => Math.round(value * 100) / 100; return { basis: "compacted", conversion: result.conversion, ...(extra ?? {}), // 사용자가 끌어 옮긴 balloon 위치 — 비어 있어도 보낸다(초기화가 저장에 반영돼야 한다). ...(balloonOffsets ? { balloon_offsets: balloonOffsets } : {}), cut_natural_m3: { soil: round(result.cut_natural_m3.soil), ripping_rock: round(result.cut_natural_m3.ripping_rock), blasting_rock: round(result.cut_natural_m3.blasting_rock), }, cut_compacted_m3: round(result.cut_compacted_m3), fill_compacted_m3: round(result.fill_compacted_m3), final_cumulative_m3: round(result.final_cumulative_m3), surplus_m3: round(result.surplus_m3), shortage_m3: round(result.shortage_m3), points: result.points.map((point) => ({ station_id: point.station_id, chainage_m: round(point.chainage_m), net_volume_m3: round(point.net_volume_m3), cumulative_volume_m3: round(point.cumulative_volume_m3), // 구간 절토 내역(자연상태) — B08 내역서가 EA/RR/BR로 되받는 단위. cut_soil_m3: round(point.cut_soil_m3), cut_rock_m3: round(point.cut_rock_m3), cut_rr_m3: round(point.cut_rr_m3), cut_br_m3: round(point.cut_br_m3), net_area_m2: round(point.net_area_m2), fill_m3: round(point.fill_m3), })), }; }