feat(B06): 자연방토 반영 — 사토를 자연방토/사토장 운반으로 가름

판정 기준은 성토측 자연 지반 경사(사용자 확정). 지반이 임계보다 가파르면 부어 놓은 흙이
쌓이지 않고 스스로 흘러내리므로 운반비를 세지 않는다.

계상 방식은 참고 도면을 근거로 정했다. 도면의 사토 balloon은 L=(운반거리) 없이
M.N=측점만 적는다 - 그 노선의 사토가 이미 운반비 없이 처리되고 있다는 뜻이고,
자연방토라는 이름을 안 썼을 뿐 처리 방식이 자연방토다. 그래서 새 항목을 만들지 않고
사토 안에서 가른다. 판정 통과분은 도면과 똑같이 M.N만, 실패분에만 "사토장=X㎥"를 더한다.
수량은 그대로 두고 성토로 흡수하지 않아 유토곡선 모양과 누가토량은 안 바뀐다.

- config NATURAL_SPOIL_MIN_GROUND_SLOPE = 1/1.5 (rise/run, 약 33.7도) 신설.
  유일한 정의처이며 프론트는 context로 받아 쓴다. 못 받으면 자연방토 없음(보수적)
- 엔진 fill_ground_slope(): 노면 끝~성토 사면의 첫 지반 교차까지 평균 경사.
  양쪽이 다 성토면 완만한 쪽을 택한다(보수적)
- AreaSample/MassHaulPoint에 natural_spoil - 양 끝 측점이 둘 다 가능할 때만 참
- HaulResidual.natural_m3 - 잔량 구간에서 자연방토 가능한 길이 비율만큼 계상.
  장거리 상쇄로 깎이면 안분·자연방토도 같은 비율로 감소
- HaulPlan.natural_spoil_m3 + 요약 칩

700줄 초과 2건 분리: _Engine_Areas.py(면적 적분), _UI_MassHaul_Settle.ts(안분·장비·상쇄·
직렬화). 의존은 한 방향(Balance -> Settle 함수, Settle -> Balance 타입만)이라 순환 없음.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-02 18:18:08 +09:00
co-authored by Claude Opus 5
parent d69b51509d
commit b0fe443269
13 changed files with 452 additions and 258 deletions
@@ -89,6 +89,11 @@ export interface SectionContextResponse {
earthwork_conversion: EarthworkConversion;
/** 평균운반거리별 운반장비 경계. 유토곡선의 토량 분배가 이 값으로 장비를 고른다. */
haul_equipment_limits?: HaulEquipmentLimit[];
/**
* 자연방토 판정 경사(rise/run). 성토측 자연 지반이 이보다 가파르면 흙이 스스로 흘러내려
* 운반비를 세지 않는다. 못 받으면 프론트는 **자연방토 없음**으로 본다(보수적).
*/
natural_spoil_min_ground_slope?: number | null;
}
/** 종단 요약 조회 결과 (SectionSummaryResponse) */
@@ -263,6 +268,8 @@ export interface CrossDesign {
/** 암반부에 적용할 지반유형(`ripping_rock`/`blasting_rock`). 토사 측점은 null. */
cut_rock_kind?: GroundType | null;
fill_area_m2: number;
/** 성토측 자연 지반 경사(rise/run). 자연방토 판정 입력. 성토측이 없으면 null. */
fill_ground_slope?: number | null;
ditch_area_m2: number;
design_line: Array<{ offset_m: number; elevation_m: number }>;
/** 확정 시 병합되는 암 경계선 오프셋(m). 세션 값이 우선이며 복원 폴백으로 쓴다. */
@@ -0,0 +1,82 @@
"""B06 횡단 단면적 적분 — 절·성토 면적과 절토의 토사/암반 분리.
`_Engine_Design.py`가 700줄을 넘겨, 「설계선을 어떻게 세우나」(그쪽)와 「그 선과 지반 사이
넓이를 어떻게 재나」(여기)로 갈랐다. 두 함수 모두 지반선과 설계선의 **차이 배열**만 받으므로
설계 로직을 전혀 모른다 — 그래서 따로 떼어 검산하기도 쉽다.
"""
def _trapezoid_areas(offsets: list[float], diffs: list[float]) -> tuple[float, float]:
"""오프셋 순 (지반-설계) 차이를 사다리꼴 적분해 (절토, 성토) 면적을 반환한다.
diff>0(지반이 설계보다 높음)=절토, diff<0=성토. 부호가 바뀌는 구간은 영교점에서
나눠 절·성토가 섞이지 않게 한다.
"""
cut_area = 0.0
fill_area = 0.0
for index in range(1, len(offsets)):
x0, x1 = offsets[index - 1], offsets[index]
d0, d1 = diffs[index - 1], diffs[index]
width = x1 - x0
if width <= 0:
continue
if d0 == 0 and d1 == 0:
continue
if d0 * d1 < 0:
# 부호 변화: 영교점에서 두 삼각형으로 분리
zero_ratio = d0 / (d0 - d1)
x_zero = x0 + width * zero_ratio
left_area = 0.5 * (x_zero - x0) * abs(d0)
right_area = 0.5 * (x1 - x_zero) * abs(d1)
if d0 > 0:
cut_area += left_area
fill_area += right_area
else:
fill_area += left_area
cut_area += right_area
continue
area = 0.5 * (d0 + d1) * width
if area >= 0:
cut_area += area
else:
fill_area += -area
return cut_area, fill_area
def _split_cut_areas(
offsets: list[float], diffs: list[float], soil_depth_m: float
) -> tuple[float, float]:
"""절토 면적을 암반 경계선 기준으로 (토사, 암반)으로 나눈다.
암반 경계선은 지반선 평행 복사(`지반고 + rock_boundary_offset_m`)이므로 토사층 두께
`t0`가 절토 구간 전체에서 균일하다. 따라서 오프셋별 절토 종거 `d = 지반고 - 설계고`에
대해 토사분은 `min(max(d, 0), t0)`, 암반분은 `max(d - t0, 0)`이며 두 값의 합은 항상
`max(d, 0)`이라 `_trapezoid_areas`의 절토 면적과 정확히 일치한다.
두 함수 모두 `d = 0`과 `d = t0`에서 꺾이므로 그 교차점을 구간 분할점으로 넣어야
사다리꼴 적분이 근사가 아닌 정확값이 된다.
"""
t0 = max(float(soil_depth_m), 0.0)
soil_area = 0.0
rock_area = 0.0
for index in range(1, len(offsets)):
x0, x1 = offsets[index - 1], offsets[index]
d0, d1 = diffs[index - 1], diffs[index]
width = x1 - x0
if width <= 0:
continue
ratios = [0.0, 1.0]
for level in (0.0, t0):
if (d0 - level) * (d1 - level) < 0:
ratios.append((level - d0) / (d1 - d0))
ratios.sort()
for step in range(1, len(ratios)):
ratio_a, ratio_b = ratios[step - 1], ratios[step]
span = width * (ratio_b - ratio_a)
if span <= 0:
continue
d_a = d0 + (d1 - d0) * ratio_a
d_b = d0 + (d1 - d0) * ratio_b
soil_area += (min(max(d_a, 0.0), t0) + min(max(d_b, 0.0), t0)) / 2.0 * span
rock_area += (max(d_a - t0, 0.0) + max(d_b - t0, 0.0)) / 2.0 * span
return soil_area, rock_area
@@ -18,6 +18,10 @@
from collections.abc import Callable
from typing import Any
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Areas import (
_split_cut_areas,
_trapezoid_areas,
)
from config.config_system import (
SECTION_DITCH_SIDES,
SECTION_DITCH_TYPES,
@@ -99,82 +103,6 @@ def _resolve_group(preset_key: str, standard: dict[str, Any] | None) -> dict[str
}
def _trapezoid_areas(offsets: list[float], diffs: list[float]) -> tuple[float, float]:
"""오프셋 순 (지반-설계) 차이를 사다리꼴 적분해 (절토, 성토) 면적을 반환한다.
diff>0(지반이 설계보다 높음)=절토, diff<0=성토. 부호가 바뀌는 구간은 영교점에서
나눠 절·성토가 섞이지 않게 한다.
"""
cut_area = 0.0
fill_area = 0.0
for index in range(1, len(offsets)):
x0, x1 = offsets[index - 1], offsets[index]
d0, d1 = diffs[index - 1], diffs[index]
width = x1 - x0
if width <= 0:
continue
if d0 == 0 and d1 == 0:
continue
if d0 * d1 < 0:
# 부호 변화: 영교점에서 두 삼각형으로 분리
zero_ratio = d0 / (d0 - d1)
x_zero = x0 + width * zero_ratio
left_area = 0.5 * (x_zero - x0) * abs(d0)
right_area = 0.5 * (x1 - x_zero) * abs(d1)
if d0 > 0:
cut_area += left_area
fill_area += right_area
else:
fill_area += left_area
cut_area += right_area
continue
area = 0.5 * (d0 + d1) * width
if area >= 0:
cut_area += area
else:
fill_area += -area
return cut_area, fill_area
def _split_cut_areas(
offsets: list[float], diffs: list[float], soil_depth_m: float
) -> tuple[float, float]:
"""절토 면적을 암반 경계선 기준으로 (토사, 암반)으로 나눈다.
암반 경계선은 지반선 평행 복사(`지반고 + rock_boundary_offset_m`)이므로 토사층 두께
`t0`가 절토 구간 전체에서 균일하다. 따라서 오프셋별 절토 종거 `d = 지반고 - 설계고`에
대해 토사분은 `min(max(d, 0), t0)`, 암반분은 `max(d - t0, 0)`이며 두 값의 합은 항상
`max(d, 0)`이라 `_trapezoid_areas`의 절토 면적과 정확히 일치한다.
두 함수 모두 `d = 0`과 `d = t0`에서 꺾이므로 그 교차점을 구간 분할점으로 넣어야
사다리꼴 적분이 근사가 아닌 정확값이 된다.
"""
t0 = max(float(soil_depth_m), 0.0)
soil_area = 0.0
rock_area = 0.0
for index in range(1, len(offsets)):
x0, x1 = offsets[index - 1], offsets[index]
d0, d1 = diffs[index - 1], diffs[index]
width = x1 - x0
if width <= 0:
continue
ratios = [0.0, 1.0]
for level in (0.0, t0):
if (d0 - level) * (d1 - level) < 0:
ratios.append((level - d0) / (d1 - d0))
ratios.sort()
for step in range(1, len(ratios)):
ratio_a, ratio_b = ratios[step - 1], ratios[step]
span = width * (ratio_b - ratio_a)
if span <= 0:
continue
d_a = d0 + (d1 - d0) * ratio_a
d_b = d0 + (d1 - d0) * ratio_b
soil_area += (min(max(d_a, 0.0), t0) + min(max(d_b, 0.0), t0)) / 2.0 * span
rock_area += (max(d_a - t0, 0.0) + max(d_b - t0, 0.0)) / 2.0 * span
return soil_area, rock_area
def _ground_interpolator(valid: list[tuple[float, float]]):
"""정렬된 (offset, 지반고) 샘플의 선형 보간 함수를 만든다(범위 밖 끝값 클램프)."""
@@ -390,6 +318,30 @@ class _SectionGeometry:
self._cut_cross[side] = result
return result
def fill_ground_slope(self) -> float | None:
"""성토측 **자연 지반**의 평균 경사(rise/run, 무차원). 성토측이 없으면 None.
자연방토 판정에 쓴다 — 지반이 가파르면 부어 놓은 흙이 쌓이지 않고 흘러내린다.
구간은 노면 끝(사면 시작)부터 성토 사면이 지반과 처음 만나는 곳까지이며, 끝까지
만나지 못하면 10m를 본다. 양쪽이 다 성토면 **완만한 쪽**을 택한다(보수적 판정).
"""
if self._ground_at is None:
return None
slopes: list[float] = []
for side in ("left", "right"):
role = self.left_role if side == "left" else self.right_role
if role != "fill":
continue
start_dist, _start_z = self._slope_start(side)
end_dist = self.fill_cross_dist(side) or (start_dist + 10.0)
run = end_dist - start_dist
if run <= 1e-6:
continue
sign = 1.0 if side == "left" else -1.0
rise = abs(self._ground_at(sign * end_dist) - self._ground_at(sign * start_dist))
slopes.append(rise / run)
return min(slopes) if slopes else None
def fill_cross_dist(self, side: str) -> float | None:
"""성토 사면이 지반선과 **처음** 만나는 거리(절대 오프셋). 이후는 성토 없음.
@@ -571,6 +523,7 @@ def compute_cross_design(
# 측구 굴착은 설계선에 포함돼 절토 면적에 자연 반영된다(별도 가산 없음 — 이중계상 방지).
cut_area, fill_area = _trapezoid_areas(offsets, diffs)
fill_ground_slope = geometry.fill_ground_slope()
# 절토면적 토사/암반 분리 — 지표면~암반 경계선이 토사, 그 아래가 암이다. 경계선 위치가
# 곧 유토곡선 EA/RR/BR 비율을 만들므로, 사용자가 경계선을 올리내리면 이 값이 함께 바뀐다.
@@ -667,6 +620,10 @@ def compute_cross_design(
"cut_rock_area_m2": round(cut_rock_area, 4),
"cut_rock_kind": cut_rock_kind,
"fill_area_m2": round(fill_area, 4),
# 성토측 자연 지반 경사(rise/run) — 자연방토 판정 입력. 성토측이 없으면 None.
"fill_ground_slope": (
round(fill_ground_slope, 4) if fill_ground_slope is not None else None
),
"ditch_area_m2": round(ditch_area, 4),
"design_line": design_line,
}
@@ -64,6 +64,7 @@ from config.config_system import (
EARTHWORK_CONVERSION_FACTORS,
EARTHWORK_HAUL_EQUIPMENT_LIMITS_M,
FOREST_ROAD_MIN_WIDTH_M,
NATURAL_SPOIL_MIN_GROUND_SLOPE,
SECTION_VERTICAL_EXAGGERATION,
STANDARD_CROSS_SECTION,
STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
@@ -106,6 +107,7 @@ async def get_section_context(project_id: UUID) -> SectionContextResponse | JSON
HaulEquipmentLimit(key=key, max_distance_m=limit)
for key, limit in EARTHWORK_HAUL_EQUIPMENT_LIMITS_M
],
natural_spoil_min_ground_slope=NATURAL_SPOIL_MIN_GROUND_SLOPE,
)
except Exception:
logger.exception("B06 종횡단 컨텍스트 조회 실패: project_id=%s", project_id)
@@ -146,6 +146,9 @@ class SectionContextResponse(BaseModel):
earthwork_conversion: dict[str, EarthworkConversionFactor] = Field(default_factory=dict)
# 평균운반거리별 운반장비 경계. 유토곡선의 토량 분배가 이 값으로 장비를 고른다.
haul_equipment_limits: list[HaulEquipmentLimit] = Field(default_factory=list)
# 자연방토 판정 경사(rise/run). 성토측 자연 지반이 이보다 가파르면 흙이 스스로 흘러내려
# 운반비를 세지 않는다. 정의처는 config_system.NATURAL_SPOIL_MIN_GROUND_SLOPE 한 곳뿐이다.
natural_spoil_min_ground_slope: float | None = None
class SectionSummaryResponse(BaseModel):
@@ -72,6 +72,11 @@ export interface MassHaulPoint {
cut_compacted_m3: number;
/** 구간 성토량(다짐상태 = 설계 물량). */
fill_m3: number;
/**
* 이 구간에서 자연방토가 가능한가 — 양 끝 측점이 **둘 다** 가능할 때만 참(보수적).
* 남는 흙을 성토사면으로 흘려보내 운반비를 세지 않을 수 있는지 판단하는 입력이다.
*/
natural_spoil: boolean;
/**
* 이 측점의 **순단면적**(㎡, 다짐환산) = 절토환산단면적 − 성토단면적.
*
@@ -160,6 +165,8 @@ interface AreaSample {
/** 암반분에 물릴 지반유형. 토사 측점은 null(암반분 0). */
rock_kind: GroundType | null;
fill_area_m2: number;
/** 이 측점에서 자연방토가 가능한가(성토측 지반이 판정 경사보다 가파른가). */
natural_spoil: boolean;
}
/**
@@ -219,6 +226,7 @@ function integrate(samples: AreaSample[], conversion: EarthworkConversion): Mass
cut_br_m3: 0,
cut_compacted_m3: 0,
fill_m3: 0,
natural_spoil: samples[0].natural_spoil,
net_area_m2: sampleNetArea(samples[0], conversion),
},
];
@@ -278,6 +286,7 @@ function integrate(samples: AreaSample[], conversion: EarthworkConversion): Mass
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),
});
}
@@ -305,8 +314,19 @@ function designedSections(crossSections: CrossSection[]): CrossSection[] {
.sort((a, b) => a.chainage_m - b.chainage_m);
}
/**
* 자연방토 가능 여부. 성토측 자연 지반이 판정 경사보다 가파르면 부어 놓은 흙이 쌓이지 않고
* 스스로 흘러내린다. 판정 경사(config)를 못 받았으면 **전부 불가**로 본다 — 프론트에 사본을
* 두지 않으며, 모르면 운반비를 세는 쪽이 안전하다.
*/
function naturalSpoilAllowed(design: CrossDesign | 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: CrossSection[]): AreaSample[] {
function crossAreaSamples(sections: CrossSection[], minSlope?: number): AreaSample[] {
return sections.map((section) => {
const cut = splitCut(section.design);
return {
@@ -316,6 +336,7 @@ function crossAreaSamples(sections: CrossSection[]): AreaSample[] {
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),
};
});
}
@@ -366,6 +387,7 @@ function longitudinalAreaSamples(
cut_rock_area_m2: cutArea * rockRatio,
rock_kind: cut.rock_kind,
fill_area_m2: height > 0 ? height * width : 0,
natural_spoil: false,
};
});
}
@@ -377,8 +399,12 @@ function longitudinalAreaSamples(
export function computeMassHaul(
crossSections: CrossSection[],
conversion: EarthworkConversion,
naturalSpoilMinSlope?: number,
): MassHaulResult | null {
return integrate(crossAreaSamples(designedSections(crossSections)), conversion);
return integrate(
crossAreaSamples(designedSections(crossSections), naturalSpoilMinSlope),
conversion,
);
}
/**
@@ -389,10 +415,11 @@ export function computeMassHaulSeries(
longitudinal: LongitudinalSection,
crossSections: CrossSection[],
conversion: EarthworkConversion,
naturalSpoilMinSlope?: number,
): MassHaulSeries[] {
const sections = designedSections(crossSections);
const sampleSets: Record<MassHaulBasis, AreaSample[]> = {
cross: crossAreaSamples(sections),
cross: crossAreaSamples(sections, naturalSpoilMinSlope),
longitudinal: longitudinalAreaSamples(longitudinal, sections),
};
const series: MassHaulSeries[] = [];
@@ -47,7 +47,6 @@
* `MassHaulResult.cut_natural_m3` / 측점별 `cut_soil_m3`·`cut_rr_m3`·`cut_br_m3`가 따로 든다.
* ========================================================================== */
import type { EarthworkConversion, GroundType } from "./B06_wf3_ProfileCross_Api_Fetch";
import type { MassHaulPoint, MassHaulResult } from "./B06_wf3_ProfileCross_UI_MassHaul";
import {
crossFrom,
@@ -57,6 +56,15 @@ import {
pruneExtrema,
segmentOf,
} from "./B06_wf3_ProfileCross_UI_MassHaul_Curve";
import {
apportion,
cutMix,
naturalSpoilRatio,
settleResiduals,
sortedLimits,
} from "./B06_wf3_ProfileCross_UI_MassHaul_Settle";
export { haulPlanPayload } from "./B06_wf3_ProfileCross_UI_MassHaul_Settle";
/** 운반거리 상한 하나(m). `max_distance_m: null`이면 상한 없음(나머지를 전부 받는다). */
export interface HaulEquipmentLimit {
@@ -134,6 +142,15 @@ export interface HaulResidual {
ea_m3: number;
rr_m3: number;
br_m3: number;
/**
* 사토 중 **자연방토**로 처리되는 몫(㎥) — 성토사면으로 흘려보내 운반비를 세지 않는다.
* 나머지(`volume_m3 natural_m3`)는 사토장으로 실어 내야 한다.
*
* 참고 도면은 사토 balloon에 `L=`(운반거리) 없이 `M.N=측점`만 적는다. 그 노선의 사토가
* 이미 운반비 없이 처리됐다는 뜻이라, 프로그램도 **새 항목을 만들지 않고 사토 안에서**
* 가른다(2026-08-02 사용자 확정). 토취는 밖에서 사 오는 흙이라 해당 없음(0).
*/
natural_m3: number;
}
/**
@@ -173,6 +190,8 @@ export interface HaulPlan {
steps: BalanceStep[];
spoil_m3: number;
borrow_m3: number;
/** 사토 중 자연방토 몫(㎥) — 운반비를 세지 않는다. */
natural_spoil_m3: number;
/** 블록 안에서 옮기는 양(㎥). */
hauled_m3: number;
/** 떨어진 구간끼리 장거리로 옮기는 양(㎥). */
@@ -198,78 +217,6 @@ const CHORD_SOLVE_STEPS = 24;
/** 띠 테두리를 곡선(포물선)에 붙이려고 구간 하나를 쪼개는 수. */
const OUTLINE_STEPS = 4;
function factorFor(conversion: EarthworkConversion, ground: GroundType): number {
const factor = conversion?.[ground]?.compacted;
return Number.isFinite(factor) && factor > 0 ? factor : 1;
}
interface CutMix {
ea: number;
rr: number;
br: number;
}
/**
* `[fromX, toX]` 절토 구간의 지반유형 구성(다짐상태 가중치). 구간이 측점 사이를 반만
* 물면 그 비율만큼만 센다 — 경계현 교점은 측점에 딱 떨어지지 않는다.
*/
function cutMix(
points: MassHaulPoint[],
fromX: number,
toX: number,
conversion: EarthworkConversion,
): CutMix {
const mix: CutMix = { ea: 0, rr: 0, br: 0 };
for (let index = 1; index < points.length; index += 1) {
const a = points[index - 1].chainage_m;
const b = points[index].chainage_m;
const span = b - a;
if (span <= 0) continue;
const overlap = Math.min(b, toX) - Math.max(a, fromX);
if (overlap <= 0) continue;
const ratio = overlap / span;
mix.ea += points[index].cut_soil_m3 * ratio * factorFor(conversion, "soil");
mix.rr += points[index].cut_rr_m3 * ratio * factorFor(conversion, "ripping_rock");
mix.br += points[index].cut_br_m3 * ratio * factorFor(conversion, "blasting_rock");
}
return mix;
}
/** 구성비로 물량을 안분한다. 구간에 절토 기록이 전혀 없으면 지반유형 미상 = 토사로 둔다. */
function apportion(mix: CutMix, volume: number): Pick<HaulBand, "ea_m3" | "rr_m3" | "br_m3"> {
const total = mix.ea + mix.rr + mix.br;
if (!(total > 0)) return { ea_m3: volume, rr_m3: 0, br_m3: 0 };
return {
ea_m3: (mix.ea / total) * volume,
rr_m3: (mix.rr / total) * volume,
br_m3: (mix.br / total) * volume,
};
}
/** 거리 짧은 장비부터. `max_distance_m: null`(상한 없음)은 항상 맨 뒤. */
function sortedLimits(limits: HaulEquipmentLimit[] | undefined): HaulEquipmentLimit[] {
if (!limits?.length) return [{ key: "", max_distance_m: null }];
return [...limits].sort((a, b) => {
if (a.max_distance_m === null) return 1;
if (b.max_distance_m === null) return -1;
return a.max_distance_m - b.max_distance_m;
});
}
/**
* 운반거리에 맞는 장비를 고른다. 띠 분할은 경계현으로 하지만 **장거리 운반**은 거리가
* 먼저 정해지므로 이렇게 거꾸로 고른다. 경계 정의처는 config 한 곳뿐이라, 값을 못 받았으면
* 프론트에 사본을 두지 않고 그냥 비운다.
*/
function pickEquipment(limits: HaulEquipmentLimit[] | undefined, distanceM: number): string | null {
if (!limits?.length) return null;
for (const limit of sortedLimits(limits)) {
if (limit.max_distance_m === null || distanceM <= limit.max_distance_m)
return limit.key || null;
}
return null;
}
/** 한 블록을 장비 띠로 가르는 데 필요한 기하 계산 묶음. */
interface BlockGeometry {
/** `ratio`(0 = 평형선, 1 = 정점)에 해당하는 누가토량 높이. */
@@ -461,6 +408,8 @@ export function computeHaulPlan(
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)
@@ -552,9 +501,12 @@ export function computeHaulPlan(
let spoil = 0;
let borrow = 0;
let naturalSpoil = 0;
for (const residual of settled) {
if (residual.kind === "spoil") spoil += residual.volume_m3;
else borrow += residual.volume_m3;
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;
@@ -565,110 +517,9 @@ export function computeHaulPlan(
steps,
spoil_m3: spoil,
borrow_m3: borrow,
natural_spoil_m3: naturalSpoil,
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,
};
}
/**
* 남은 잉여(사토)와 부족(토취)을 **거리가 가까운 짝부터** 맞물려 장거리 운반으로 바꾼다
* (배분 3원칙 ① 운반거리 최소). 맞물린 만큼 두 잔량에서 덜어 내고, 끝내 남는 쪽만
* 진짜 사토/토취로 남는다. 잉여가 우세한 노선이면 토취가 전부 사라진다.
*
* `residuals`를 그 자리에서 깎으므로 호출한 쪽은 0이 된 항목을 걸러야 한다.
*/
function settleResiduals(
points: MassHaulPoint[],
residuals: HaulResidual[],
conversion: EarthworkConversion,
limits: HaulEquipmentLimit[] | undefined,
): HaulTransfer[] {
const center = (residual: HaulResidual): number => (residual.from_m + residual.to_m) / 2;
const level = (residual: HaulResidual): number =>
(residual.level_from_m3 + residual.level_to_m3) / 2;
const transfers: HaulTransfer[] = [];
const pairs: Array<{ spoil: HaulResidual; borrow: HaulResidual; distance: number }> = [];
for (const spoil of residuals.filter((entry) => entry.kind === "spoil")) {
for (const borrow of residuals.filter((entry) => entry.kind === "borrow")) {
pairs.push({ spoil, borrow, distance: Math.abs(center(spoil) - center(borrow)) });
}
}
pairs.sort((a, b) => a.distance - b.distance);
for (const pair of pairs) {
const volume = Math.min(pair.spoil.volume_m3, pair.borrow.volume_m3);
if (!(volume > EPSILON)) continue;
// 잔량이 깎인 만큼 지반유형 안분도 같은 비율로 줄인다 — 남은 사토의 구성비는 그대로다.
const ratio = pair.spoil.volume_m3 > 0 ? 1 - volume / pair.spoil.volume_m3 : 0;
pair.spoil.ea_m3 *= ratio;
pair.spoil.rr_m3 *= ratio;
pair.spoil.br_m3 *= ratio;
pair.spoil.volume_m3 -= volume;
pair.borrow.volume_m3 -= volume;
// 퍼오는 쪽이 절토 구간이므로 지반유형은 사토 구간에서 읽는다.
const mix = cutMix(points, pair.spoil.from_m, pair.spoil.to_m, conversion);
transfers.push({
index: transfers.length + 1,
from_m: center(pair.spoil),
to_m: center(pair.borrow),
volume_m3: volume,
haul_distance_m: pair.distance,
level_m3: (level(pair.spoil) + level(pair.borrow)) / 2,
equipment: pickEquipment(limits, pair.distance),
...apportion(mix, volume),
});
}
return transfers;
}
/** 확정 저장·B08 인계용 직렬화. 값은 소수 둘째 자리에서 끊는다. */
export function haulPlanPayload(plan: HaulPlan): Record<string, unknown> {
const round = (value: number): number => Math.round(value * 100) / 100;
return {
spoil_m3: round(plan.spoil_m3),
borrow_m3: round(plan.borrow_m3),
hauled_m3: round(plan.hauled_m3),
transferred_m3: round(plan.transferred_m3),
fill_total_m3: round(plan.fill_total_m3),
// 떨어진 구간끼리의 장거리 운반 — B08 내역서가 별도 운반 항목으로 세운다.
transfers: plan.transfers.map((transfer) => ({
index: transfer.index,
from_m: round(transfer.from_m),
to_m: round(transfer.to_m),
volume_m3: round(transfer.volume_m3),
haul_distance_m: round(transfer.haul_distance_m),
equipment: transfer.equipment,
ea_m3: round(transfer.ea_m3),
rr_m3: round(transfer.rr_m3),
br_m3: round(transfer.br_m3),
})),
blocks: plan.blocks.map((block) => ({
index: block.index,
from_m: round(block.from_m),
to_m: round(block.to_m),
base_m3: round(block.base_m3),
volume_m3: round(block.volume_m3),
direction: block.direction,
// 장비 띠 — B08 내역서는 이 단위로 운반 항목을 세운다.
bands: block.bands.map((band) => ({
index: band.index,
equipment: band.equipment,
volume_m3: round(band.volume_m3),
haul_distance_m: round(band.haul_distance_m),
ea_m3: round(band.ea_m3),
rr_m3: round(band.rr_m3),
br_m3: round(band.br_m3),
})),
})),
residuals: plan.residuals.map((residual) => ({
kind: residual.kind,
from_m: round(residual.from_m),
to_m: round(residual.to_m),
volume_m3: round(residual.volume_m3),
ea_m3: round(residual.ea_m3),
rr_m3: round(residual.rr_m3),
br_m3: round(residual.br_m3),
})),
};
}
@@ -258,6 +258,8 @@ function appendResidual(
const at = (residual.from_m + residual.to_m) / 2;
const station = box.stationInterval ? stationLabel(at, box.stationInterval) : "-";
const kindLabel = L(residual.kind === "spoil" ? "B06_MassHaul_Surplus" : "B06_MassHaul_Shortage");
// 사토장으로 실어 내야 하는 몫(자연방토로 못 빠진 나머지).
const hauledOut = Math.max(residual.volume_m3 - residual.natural_m3, 0);
const label = `${kindLabel} ${compactVolume(residual.volume_m3)}㎥ · ${station}`;
const marker = svgElement("g", {
@@ -292,7 +294,12 @@ function appendResidual(
? [
`${kindLabel} ${residual.index}`,
`Q=${compactVolume(residual.volume_m3)}`,
// 도면과 같은 표기 — 사토에는 운반거리를 매기지 않고 발생 측점만 적는다.
`M.N=${station}`,
// 자연방토로 못 빠진 몫만 따로 밝힌다. 전량 자연방토면 이 줄이 없어 도면과 같다.
...(hauledOut > 0.5
? [`${L("B06_MassHaul_SpoilHauled")}=${compactVolume(hauledOut)}`]
: []),
...(residual.kind === "spoil"
? [
`EA=${compactVolume(residual.ea_m3)}`,
@@ -475,6 +482,9 @@ export function haulPlanChips(plan: HaulPlan | null): Array<[string, string]> {
if (plan.transfers.length) {
chips.push([L("B06_MassHaul_Transfer"), `${compactVolume(plan.transferred_m3)}`]);
}
if (plan.natural_spoil_m3 > 0.5) {
chips.push([L("B06_MassHaul_NaturalSpoil"), `${compactVolume(plan.natural_spoil_m3)}`]);
}
chips.push([
L("B06_MassHaul_Check"),
ratio <= 0.005
@@ -0,0 +1,223 @@
/* =============================================================================
* B06_wf3_ProfileCross_UI_MassHaul_Settle.ts
* 토량 분배의 **정산** 쪽 — 지반유형 안분 · 장비 선정 · 잉여/부족 상쇄 · 직렬화.
*
* `_UI_MassHaul_Balance`가 700줄을 넘겨, 「곡선에서 블록·띠를 어떻게 뽑나」(그쪽)와
* 「뽑은 물량을 무엇으로 나누고 어디로 보내나」(여기)로 갈랐다.
*
* 의존 방향은 **한 쪽뿐**이다: Balance → Settle(함수), Settle → Balance(타입만).
* 타입 import는 실행 시 사라지므로 순환이 생기지 않는다.
* ========================================================================== */
import type { EarthworkConversion, GroundType } from "./B06_wf3_ProfileCross_Api_Fetch";
import type { MassHaulPoint } from "./B06_wf3_ProfileCross_UI_MassHaul";
import type {
HaulBand,
HaulEquipmentLimit,
HaulPlan,
HaulResidual,
HaulTransfer,
} from "./B06_wf3_ProfileCross_UI_MassHaul_Balance";
import { EPSILON } from "./B06_wf3_ProfileCross_UI_MassHaul_Curve";
/** 환산계수가 비어 있거나 값이 이상하면 1.0으로 떨어뜨려 계산이 멈추지 않게 한다. */
function factorFor(conversion: EarthworkConversion, ground: GroundType): number {
const factor = conversion?.[ground]?.compacted;
return Number.isFinite(factor) && factor > 0 ? factor : 1;
}
export interface CutMix {
ea: number;
rr: number;
br: number;
}
/**
* `[fromX, toX]` 절토 구간의 지반유형 구성(다짐상태 가중치). 구간이 측점 사이를 반만
* 물면 그 비율만큼만 센다 — 경계현 교점은 측점에 딱 떨어지지 않는다.
*/
export function cutMix(
points: MassHaulPoint[],
fromX: number,
toX: number,
conversion: EarthworkConversion,
): CutMix {
const mix: CutMix = { ea: 0, rr: 0, br: 0 };
for (let index = 1; index < points.length; index += 1) {
const a = points[index - 1].chainage_m;
const b = points[index].chainage_m;
const span = b - a;
if (span <= 0) continue;
const overlap = Math.min(b, toX) - Math.max(a, fromX);
if (overlap <= 0) continue;
const ratio = overlap / span;
mix.ea += points[index].cut_soil_m3 * ratio * factorFor(conversion, "soil");
mix.rr += points[index].cut_rr_m3 * ratio * factorFor(conversion, "ripping_rock");
mix.br += points[index].cut_br_m3 * ratio * factorFor(conversion, "blasting_rock");
}
return mix;
}
/**
* `[fromX, toX]` 중 자연방토가 가능한 구간의 **길이 비율**(0~1).
* 구간마다 양 끝 측점이 둘 다 가능해야 그 구간을 인정한다(`natural_spoil`).
*/
export function naturalSpoilRatio(points: MassHaulPoint[], fromX: number, toX: number): number {
let total = 0;
let allowed = 0;
for (let index = 1; index < points.length; index += 1) {
const a = points[index - 1].chainage_m;
const b = points[index].chainage_m;
const overlap = Math.min(b, toX) - Math.max(a, fromX);
if (overlap <= 0) continue;
total += overlap;
if (points[index].natural_spoil) allowed += overlap;
}
return total > 0 ? allowed / total : 0;
}
/** 구성비로 물량을 안분한다. 구간에 절토 기록이 전혀 없으면 지반유형 미상 = 토사로 둔다. */
export function apportion(
mix: CutMix,
volume: number,
): Pick<HaulBand, "ea_m3" | "rr_m3" | "br_m3"> {
const total = mix.ea + mix.rr + mix.br;
if (!(total > 0)) return { ea_m3: volume, rr_m3: 0, br_m3: 0 };
return {
ea_m3: (mix.ea / total) * volume,
rr_m3: (mix.rr / total) * volume,
br_m3: (mix.br / total) * volume,
};
}
/** 거리 짧은 장비부터. `max_distance_m: null`(상한 없음)은 항상 맨 뒤. */
export function sortedLimits(limits: HaulEquipmentLimit[] | undefined): HaulEquipmentLimit[] {
if (!limits?.length) return [{ key: "", max_distance_m: null }];
return [...limits].sort((a, b) => {
if (a.max_distance_m === null) return 1;
if (b.max_distance_m === null) return -1;
return a.max_distance_m - b.max_distance_m;
});
}
/**
* 운반거리에 맞는 장비를 고른다. 띠 분할은 경계현으로 하지만 **장거리 운반**은 거리가
* 먼저 정해지므로 이렇게 거꾸로 고른다. 경계 정의처는 config 한 곳뿐이라, 값을 못 받았으면
* 프론트에 사본을 두지 않고 그냥 비운다.
*/
export function pickEquipment(
limits: HaulEquipmentLimit[] | undefined,
distanceM: number,
): string | null {
if (!limits?.length) return null;
for (const limit of sortedLimits(limits)) {
if (limit.max_distance_m === null || distanceM <= limit.max_distance_m)
return limit.key || null;
}
return null;
}
/**
* 남은 잉여(사토)와 부족(토취)을 **거리가 가까운 짝부터** 맞물려 장거리 운반으로 바꾼다
* (배분 3원칙 ① 운반거리 최소). 맞물린 만큼 두 잔량에서 덜어 내고, 끝내 남는 쪽만
* 진짜 사토/토취로 남는다. 잉여가 우세한 노선이면 토취가 전부 사라진다.
*
* `residuals`를 그 자리에서 깎으므로 호출한 쪽은 0이 된 항목을 걸러야 한다.
*/
export function settleResiduals(
points: MassHaulPoint[],
residuals: HaulResidual[],
conversion: EarthworkConversion,
limits: HaulEquipmentLimit[] | undefined,
): HaulTransfer[] {
const center = (residual: HaulResidual): number => (residual.from_m + residual.to_m) / 2;
const level = (residual: HaulResidual): number =>
(residual.level_from_m3 + residual.level_to_m3) / 2;
const transfers: HaulTransfer[] = [];
const pairs: Array<{ spoil: HaulResidual; borrow: HaulResidual; distance: number }> = [];
for (const spoil of residuals.filter((entry) => entry.kind === "spoil")) {
for (const borrow of residuals.filter((entry) => entry.kind === "borrow")) {
pairs.push({ spoil, borrow, distance: Math.abs(center(spoil) - center(borrow)) });
}
}
pairs.sort((a, b) => a.distance - b.distance);
for (const pair of pairs) {
const volume = Math.min(pair.spoil.volume_m3, pair.borrow.volume_m3);
if (!(volume > EPSILON)) continue;
// 잔량이 깎인 만큼 지반유형 안분도 같은 비율로 줄인다 — 남은 사토의 구성비는 그대로다.
const ratio = pair.spoil.volume_m3 > 0 ? 1 - volume / pair.spoil.volume_m3 : 0;
pair.spoil.ea_m3 *= ratio;
pair.spoil.rr_m3 *= ratio;
pair.spoil.br_m3 *= ratio;
pair.spoil.natural_m3 *= ratio;
pair.spoil.volume_m3 -= volume;
pair.borrow.volume_m3 -= volume;
// 퍼오는 쪽이 절토 구간이므로 지반유형은 사토 구간에서 읽는다.
const mix = cutMix(points, pair.spoil.from_m, pair.spoil.to_m, conversion);
transfers.push({
index: transfers.length + 1,
from_m: center(pair.spoil),
to_m: center(pair.borrow),
volume_m3: volume,
haul_distance_m: pair.distance,
level_m3: (level(pair.spoil) + level(pair.borrow)) / 2,
equipment: pickEquipment(limits, pair.distance),
...apportion(mix, volume),
});
}
return transfers;
}
/** 확정 저장·B08 인계용 직렬화. 값은 소수 둘째 자리에서 끊는다. */
export function haulPlanPayload(plan: HaulPlan): Record<string, unknown> {
const round = (value: number): number => Math.round(value * 100) / 100;
return {
spoil_m3: round(plan.spoil_m3),
natural_spoil_m3: round(plan.natural_spoil_m3),
borrow_m3: round(plan.borrow_m3),
hauled_m3: round(plan.hauled_m3),
transferred_m3: round(plan.transferred_m3),
fill_total_m3: round(plan.fill_total_m3),
// 떨어진 구간끼리의 장거리 운반 — B08 내역서가 별도 운반 항목으로 세운다.
transfers: plan.transfers.map((transfer) => ({
index: transfer.index,
from_m: round(transfer.from_m),
to_m: round(transfer.to_m),
volume_m3: round(transfer.volume_m3),
haul_distance_m: round(transfer.haul_distance_m),
equipment: transfer.equipment,
ea_m3: round(transfer.ea_m3),
rr_m3: round(transfer.rr_m3),
br_m3: round(transfer.br_m3),
})),
blocks: plan.blocks.map((block) => ({
index: block.index,
from_m: round(block.from_m),
to_m: round(block.to_m),
base_m3: round(block.base_m3),
volume_m3: round(block.volume_m3),
direction: block.direction,
// 장비 띠 — B08 내역서는 이 단위로 운반 항목을 세운다.
bands: block.bands.map((band) => ({
index: band.index,
equipment: band.equipment,
volume_m3: round(band.volume_m3),
haul_distance_m: round(band.haul_distance_m),
ea_m3: round(band.ea_m3),
rr_m3: round(band.rr_m3),
br_m3: round(band.br_m3),
})),
})),
residuals: plan.residuals.map((residual) => ({
kind: residual.kind,
from_m: round(residual.from_m),
to_m: round(residual.to_m),
volume_m3: round(residual.volume_m3),
natural_m3: round(residual.natural_m3),
ea_m3: round(residual.ea_m3),
rr_m3: round(residual.rr_m3),
br_m3: round(residual.br_m3),
})),
};
}
@@ -357,6 +357,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
context?.earthwork_conversion,
context?.haul_equipment_limits,
`${projectId ?? "-"}:${currentRouteId ?? "-"}`,
context?.natural_spoil_min_ground_slope ?? undefined,
);
}
}
@@ -396,7 +397,11 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
// 유토곡선은 화면 표시 내내 프론트 메모리에만 있다가 확정 시점에만 영구 저장된다.
const massHaul =
sectionDetail && context?.earthwork_conversion
? computeMassHaul(sectionDetail.cross_sections, context.earthwork_conversion)
? computeMassHaul(
sectionDetail.cross_sections,
context.earthwork_conversion,
context.natural_spoil_min_ground_slope ?? undefined,
)
: null;
await confirmSections(
projectId,
@@ -150,6 +150,8 @@ export interface SectionViewController {
haulEquipmentLimits?: HaulEquipmentLimit[],
/** balloon 위치 캐시를 가르는 키(프로젝트+경로). 노선이 다르면 위치가 섞이면 안 된다. */
balloonScope?: string,
/** 자연방토 판정 경사(config). 못 받으면 자연방토 없음으로 본다. */
naturalSpoilMinSlope?: number,
) => void;
/** 측점 하나의 카드만 새로 만들어 교체한다 (전체 재렌더 없이 설계 변경 반영). */
refreshCard: (chainageM: number) => void;
@@ -172,6 +174,7 @@ export function createSectionView(
let currentStationInterval: number | undefined;
let currentConversion: EarthworkConversion | undefined;
let currentHaulLimits: HaulEquipmentLimit[] | undefined;
let currentNaturalSpoilSlope: number | undefined;
let renderWidth = 0;
let resizeTimer = 0;
let panelResizeTimer = 0;
@@ -463,7 +466,12 @@ export function createSectionView(
];
const series: MassHaulSeries[] = currentConversion
? computeMassHaulSeries(detail.longitudinal, detail.cross_sections, currentConversion)
? computeMassHaulSeries(
detail.longitudinal,
detail.cross_sections,
currentConversion,
currentNaturalSpoilSlope,
)
: [];
// 토량 분배는 **면을 깐 곡선 하나**(= 켜 둔 첫 곡선)에만 얹는다 — 곡선마다 평형선을
// 그리면 계단이 서로 엇갈려 어느 쪽 배분인지 읽히지 않는다.
@@ -626,6 +634,7 @@ export function createSectionView(
earthworkConversion,
haulEquipmentLimits,
balloonScope,
naturalSpoilMinSlope,
) {
currentDetail = detail;
currentExaggeration = Math.max(verticalExaggeration, 0.1);
@@ -635,6 +644,7 @@ export function createSectionView(
stationInterval !== undefined && stationInterval > 0 ? stationInterval : undefined;
if (earthworkConversion) currentConversion = earthworkConversion;
if (haulEquipmentLimits?.length) currentHaulLimits = haulEquipmentLimits;
if (Number.isFinite(naturalSpoilMinSlope)) currentNaturalSpoilSlope = naturalSpoilMinSlope;
// balloon 위치는 **영구저장소 값이 이긴다** — 다른 브라우저에서 옮긴 자리를 그대로 받는다.
configureBalloonOffsets(balloonScope ?? "default", detail.balloon_offsets ?? undefined);
// 진입 시 측점을 자동으로 고르지 않는다(2026-08-02 사용자 지시) — 0측점이 선택된 채로
+13
View File
@@ -468,6 +468,19 @@ EARTHWORK_HAUL_EQUIPMENT_LIMITS_M = (
)
# ─────────────────────────────────────────────────────────────────────────
# 5-4-2. 자연방토 판정 경사 (B06 유토곡선)
#
# 자연방토 = 남는 흙을 성토사면 아래로 흘려보내 **운반비를 세지 않는** 처리.
# 판정 기준은 **성토측 자연 지반의 경사**다(2026-08-02 사용자 확정). 지반이 이보다 가파르면
# 부어 놓은 흙이 쌓이지 않고 스스로 흘러내리므로 따로 운반하지 않는다.
#
# 값은 rise/run(무차원). 1/1.5 = 0.667 ≈ 33.7°로, 토사 안식각 상한이자 표준 성토 경사 1:1.5와
# 같은 자리다. 현장·발주처 기준에 따라 달라질 수 있으므로 **이 상수가 유일한 정의처**다.
# ─────────────────────────────────────────────────────────────────────────
NATURAL_SPOIL_MIN_GROUND_SLOPE = 1.0 / 1.5
# ─────────────────────────────────────────────────────────────────────────
# 5-5. 종단 계획선(계획고) 설계 기준 (B05 WF2)
#
+4
View File
@@ -253,6 +253,10 @@ export const ui_locales_b2 = {
/* 검산 — 성토는 전부 운반으로 채워지므로 운반 합계 = 총 성토량이어야 한다. */
B06_MassHaul_Check: ["운반·성토 검산", "Haul vs fill check"],
B06_MassHaul_Check_Ok: ["일치", "Balanced"],
/* 자연방토 = 남는 흙을 성토사면으로 흘려보내 운반비를 세지 않는 처리.
판정 경사 정의처는 config_system.NATURAL_SPOIL_MIN_GROUND_SLOPE 한 곳뿐이다. */
B06_MassHaul_NaturalSpoil: ["자연방토", "Natural side-cast"],
B06_MassHaul_SpoilHauled: ["사토장", "To spoil area"],
B06_MassHaul_Balloon_Reset: ["도형 위치 초기화", "Reset label positions"],
/* 횡단도 줌 버튼 — 휠은 페이지 스크롤로 돌려주고 확대·축소는 버튼이 맡는다. */
B06_Profile_View_ZoomIn: ["확대", "Zoom in"],