diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts index eb8ae930..19f31a4e 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch.ts @@ -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). 세션 값이 우선이며 복원 폴백으로 쓴다. */ diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Areas.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Areas.py new file mode 100644 index 00000000..e7ada04d --- /dev/null +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Areas.py @@ -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 diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Design.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Design.py index 71fb2ee9..08a7d393 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Design.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Engine_Design.py @@ -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, } diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py index e3fe5d42..3cddc6e7 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py @@ -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) diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py index c81917d3..d97ad9c9 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Schema.py @@ -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): diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul.ts index 79a0d75a..e38230bc 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul.ts @@ -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 = { - cross: crossAreaSamples(sections), + cross: crossAreaSamples(sections, naturalSpoilMinSlope), longitudinal: longitudinalAreaSamples(longitudinal, sections), }; const series: MassHaulSeries[] = []; diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance.ts index 5b13c257..e055f3e0 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance.ts @@ -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 { - 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 { - 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), - })), - }; -} diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance_View.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance_View.ts index 94a26acf..0cdf2c27 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Balance_View.ts @@ -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 diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Settle.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Settle.ts new file mode 100644 index 00000000..eb44049c --- /dev/null +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_MassHaul_Settle.ts @@ -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 { + 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 { + 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), + })), + }; +} diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts index 11a35ec2..50202be2 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts @@ -357,6 +357,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { 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 { // 유토곡선은 화면 표시 내내 프론트 메모리에만 있다가 확정 시점에만 영구 저장된다. 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, diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts index 746d78b8..e779b51f 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts @@ -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측점이 선택된 채로 diff --git a/config/config_system.py b/config/config_system.py index 83cfee83..69b04c5c 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -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) # diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 9da4d9b3..6de57f7a 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -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"],