절·성토 균형은 종단 시공계획고로 정해지고 사토장 위치도 계획고를 다시 끌어야 정리되므로, 계획용 유토곡선과 부지 선정을 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>
533 lines
24 KiB
TypeScript
533 lines
24 KiB
TypeScript
/* =============================================================================
|
||
* 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),
|
||
})),
|
||
};
|
||
}
|