Files
Aislo/common_util/common_util_mass_haul.ts
T
eomsangdonandClaude Opus 5 f4f56069b0 feat(B05/B06): 유토곡선 횡단 기준 통일·자동선정 삭제·계획선 R 연결 외 9건
사용자 피드백 반영(2026-08-03 2차).

유토곡선 일원화
- B05 유토곡선을 B06과 같은 계산(computeMassHaulSeries: 횡단 정식 + 종단 개략
  비교)으로 전환. 상세 조회가 미지정 측점에 기본 설계를 즉석 계산해 얹으므로
  B05 진입 시점에 이미 정식 곡선 재료가 있다. 표시 토글 sessionStorage 키와
  balloon 위치 scope를 B06과 공유해 두 화면이 같은 그림을 유지한다.
- B05 전용 개략 엔진(computeLongitudinalMassHaul) 삭제.
- 유토곡선 펼침 시 종단 그래프:곡선 세로 1:1 분할.

사토장·토취장 자동 선정 삭제
- 임도에는 토취장이 없고 부족분은 설계자가 계획선을 고쳐 맞춘다는 실무 판단에
  따라 4옵션(비용/지형안정성/계곡부/임내공간) 엔진·API·UI·config·저장 훅 전부
  제거. massHaulPayload/createMassHaulLegend 시그니처 원복.

기본 지반 변경
- 기본 설계 프리뷰·확정 기본값을 토사에서 리핑암 + 예상 암반 경계 0.5m로 변경.
  산지 절토는 표토 아래 암이 일반적이라 전량 토사 가정은 물량이 낙관적이다.
  발파암은 B06에서 측점별 수정.

계획선 R·선 연결 (배관 구조물 자리)
- 계획선 샘플이 고정 격자로만 평가되어 격자 사이 변화점(배관 측점 승격분)의
  모서리를 잘라먹던 결함 수정 — 샘플 집합에 PVI·BVC·EVC를 합집합으로 포함
  (프론트 buildAlignment + 서버 build_alignment 동일 규칙). 면적 가중치도
  합쳐진 격자로 재계산. 37.3m 변화점 + R=150 수치 검증 통과.

표시 개선
- EP(종점) 잔량 라벨: 곡선 끝점에 "EP {누가토량}㎥" 불투명 판(B05·B06 공통).
- 유토곡선 0선을 붉은 굵은 실선(2.5px)으로, 0 눈금값도 적색.
- 3D 뷰 [측점 가로선] 우측 [측점 라벨] 토글 신설(기본 꺼짐) — 구조물 측점만
  "측점번호 구조물명" 스프라이트 표시.

typecheck·vite build·ruff·B03 테스트 통과. 실서버 스모크로 리핑암 기본 프리뷰와
disposal-sites 제거 확인.

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

474 lines
21 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,
};
});
}
/**
* 측점별 설계 결과에서 유토곡선을 계산한다(횡단 기준·지반유형별 환산).
* 확정 저장과 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]>,
): Record<string, unknown> {
const round = (value: number): number => Math.round(value * 100) / 100;
return {
basis: "compacted",
conversion: result.conversion,
...(haulPlan ? { haul_plan: haulPlanPayload(haulPlan) } : {}),
// 사용자가 끌어 옮긴 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),
})),
};
}