Files
Aislo/common_util/common_util_mass_haul.ts
T
eomsangdonandClaude Opus 5 6c90c26eef feat(B05): 계획 유토곡선 이관 + 사토장·토취장 4옵션 자동 선정
절·성토 균형은 종단 시공계획고로 정해지고 사토장 위치도 계획고를 다시 끌어야
정리되므로, 계획용 유토곡선과 부지 선정을 B05로 옮겼다. B06은 실측 단면적으로
낸 정식 곡선과 확정·B08 인계를 맡는다.

B05 계획 유토곡선
- _UI_Profile_MassHaul.ts: 종단면 패널 안 2차 하단 슬라이드. 펼치면 12행 도면
  테이블 자리를 곡선이 대신 차지한다. 곡선 SVG를 종단 그래프와 같은 가로
  스크롤러 안 형제로 넣고 MassHaulAxis에 LONG_PAD + originOffset을 넘겨 X축을
  종단 chainageMapper와 일치시켰다.
- computeLongitudinalMassHaul(): 횡단 설계가 없는 계획 단계용 개략 엔진. 표준횡단
  노반폭을 전 구간 공통으로 물린다. 종단 기반 토량은 국내 기준상 노선계획 개산용
  이므로(2026-08-03 조사) 표준단면까지 씌워 정밀화하지 않는다.

사토장·토취장 자동 선정
- B05_wf2_Route_Engine_Disposal.py + POST /route/disposal-sites: 노선 corridor
  DEM 격자를 훑어 후보 부지를 추리고 옵션별 점수로 정렬한다. 기준 4가지 —
  비용(최단거리+하향 운반), 지형안정성(완경사·계곡 이격), 계곡부(계곡 축 매립),
  임내 공간(라이다 수고 기반 공터). 기준값 정의처는 DISPOSAL_SITE_CRITERIA.
- _UI_Profile_Disposal.ts: 범례 줄의 [토량 분배]와 [도형 위치 초기화] 사이에
  구분기호와 함께 라디오 토글 4개. 부지 카드에 위치·용량·운반거리와 기준별 근거값,
  측점·용량 사용자 정의 편집과 복원. 후보 부족으로 남은 토량은 경고로 알린다.
- 확정 시 종단 정본의 disposal_sites에 심는다(기준 미선택이면 저장분 삭제).

B06 연동
- common_util_mass_haul_sites.ts: 저장된 부지를 정식 곡선의 잔량 위치 기준으로
  운반거리를 다시 재 표시하고 mass_haul.disposal_sites로 B08에 넘긴다.

미구현(계획서에 남김): 토취장 토질 판정(지반유형이 B06 산출물), 계곡부 암거 연장.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 19:06:46 +09:00

533 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* common_util_mass_haul.ts
* 유토곡선(Mass Haul Diagram) 계산 엔진 — 순수 함수, DOM 의존 없음.
*
* 구간 토량을 평균단면법으로 만들고 토량환산계수로 기준을 통일해 누가토량을 누적한다.
* 결과는 B08 수량산출·B09 견적이 그대로 재사용할 수 있도록 계산과 렌더링을 분리해
* 둔다(렌더러는 `common_util_mass_haul_view`).
*
* ── 계산 기준: 다짐상태 ──────────────────────────────────────────────
* "운반거리의 산정 시에 모든 수량은 다짐상태로 환산하여 계산하고, 내역서에 적용하는
* 수량은 자연상태로 한다." (2021년도 국도건설공사 설계실무 요령 / 표준품셈 계열)
* 유토곡선은 운반계획 도면이므로 다짐상태를 기준상태로 삼는다. 내역서용 자연상태
* 수량은 `cut_natural_m3`로 함께 보존해 B08이 되돌려 쓸 수 있게 한다.
*
* ── 두 기준(종단/횡단)을 함께 내는 이유 ──────────────────────────────
* [임도의 설계 및 시설기준] 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";
import type { HaulPlan } from "./common_util_mass_haul_balance";
import { haulPlanPayload } from "./common_util_mass_haul_balance";
/** 지반유형별 토량(㎥). 도면 표기 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";
/** 환산계수가 비어 있거나 값이 이상하면 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_wf2_Route_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,
};
});
}
/**
* 횡단 설계가 아직 없을 때 쓰는 종단 개략 곡선의 입력.
*
* B05 노선 설계 단계에는 측점별 횡단 설계(`CrossDesign`)가 없다 — 그건 B06에서 만든다.
* 그래서 노반폭을 표준횡단 설정(`road_width_m + shoulder_left_m + shoulder_right_m`)에서
* 한 값으로 받아 전 구간에 같게 물린다. 계획선을 끌어 옮기며 절·성토 균형을 잡는
* 용도라 이 정도 개략값이면 충분하고, 정식 물량은 B06 횡단 기준 곡선이 낸다.
*/
export interface LongitudinalMassHaulOptions {
/** 전 구간 공통 노반폭(m). */
roadbedWidthM: number;
/**
* 절토 전량에 물릴 지반유형. B05는 암반 경계선을 모르므로 기본값은 토사이며,
* 그만큼 다짐환산이 보수적으로(작게) 잡힌다.
*/
groundType?: GroundType;
}
/**
* 횡단 설계 없이 종단 계획선만으로 개략 유토곡선을 만든다(B05 계획 단계).
*
* `difference_m = 계획고 − 지반고`이므로 음수면 절토, 양수면 성토다. 단면적은 그 높이차에
* 노반폭을 곱한 사각형으로 어림한다 — 비탈면 몫이 빠져 실제보다 작게 나오지만, 국내
* 설계기준이 종단 기반 토량을 노선계획 단계의 **개산용**으로만 두고 별도 산식을 정하지
* 않으므로(2026-08-03 조사) 표준단면까지 씌워 정밀화하지는 않는다.
* 자연방토는 성토측 지반 경사를 알 수 없어 전부 불가로 본다(보수적).
*/
export function computeLongitudinalMassHaul(
longitudinal: MassHaulLongitudinal,
conversion: EarthworkConversion,
options: LongitudinalMassHaulOptions,
): MassHaulResult | null {
const profile = longitudinal.design_profiles?.[0];
const width = finiteArea(options.roadbedWidthM);
if (!profile || !(width > 0)) return null;
const ground = normalizeGround(options.groundType);
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);
const areas: AreaSample[] = samples.map((sample, index) => {
const height = sample.difference_m;
const cutArea = height < 0 ? -height * width : 0;
return {
station_id: `plan@${index}`,
chainage_m: sample.chainage_m,
cut_soil_area_m2: ground === "soil" ? cutArea : 0,
cut_rock_area_m2: ground === "soil" ? 0 : cutArea,
rock_kind: ground === "soil" ? null : ground,
fill_area_m2: height > 0 ? height * width : 0,
natural_spoil: false,
};
});
return integrate(areas, conversion);
}
/**
* 측점별 설계 결과에서 유토곡선을 계산한다(횡단 기준·지반유형별 환산).
* 확정 저장과 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,
): MassHaulSeries[] {
const sections = designedSections(crossSections);
const sampleSets: Record<MassHaulBasis, AreaSample[]> = {
cross: crossAreaSamples(sections, naturalSpoilMinSlope),
longitudinal: longitudinalAreaSamples(longitudinal, sections),
};
const series: MassHaulSeries[] = [];
for (const basis of SERIES_BASES) {
const result = integrate(sampleSets[basis], conversion);
if (result) series.push({ key: basis, basis, result });
}
return series;
}
/**
* 확정 시 DB(`longitudinal_sections.data.mass_haul`)에 넣을 직렬화 형태로 정리한다.
* 토량 분배(평형선)까지 냈으면 `haul_plan`으로 함께 실어 B08 수량·B09 견적이 되받게 한다.
*/
export function massHaulPayload(
result: MassHaulResult,
haulPlan?: HaulPlan | null,
balloonOffsets?: Record<string, [number, number]>,
/** B05가 고른 사토장·토취장 — 정식 곡선 기준으로 운반거리를 다시 잰 형태로 받는다. */
disposalSites?: Record<string, unknown>,
): Record<string, unknown> {
const round = (value: number): number => Math.round(value * 100) / 100;
return {
basis: "compacted",
conversion: result.conversion,
...(haulPlan ? { haul_plan: haulPlanPayload(haulPlan) } : {}),
// B08 내역서가 사토·토취 운반 항목을 세우는 입력. 선정이 없으면 키 자체가 빠진다.
...(disposalSites ? { disposal_sites: disposalSites } : {}),
// 사용자가 끌어 옮긴 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),
})),
};
}