diff --git a/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/input.json b/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/input.json deleted file mode 100644 index f346224d..00000000 --- a/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/input.json +++ /dev/null @@ -1 +0,0 @@ -{"result": {"points": [{"station_id": "S000", "chainage_m": 0.0, "net_volume_m3": 0.0, "cumulative_volume_m3": 0.0, "cut_soil_m3": 0.0, "cut_rock_m3": 0.0, "cut_rr_m3": 0.0, "cut_br_m3": 0.0, "cut_compacted_m3": 0.0, "fill_m3": -0.0, "natural_spoil": false, "net_area_m2": 0.0}, {"station_id": "S001", "chainage_m": 20.0, "net_volume_m3": 10.0, "cumulative_volume_m3": 10.0, "cut_soil_m3": 10.0, "cut_rock_m3": 0.0, "cut_rr_m3": 0.0, "cut_br_m3": 0.0, "cut_compacted_m3": 10.0, "fill_m3": 0.0, "natural_spoil": false, "net_area_m2": 0.5}, {"station_id": "S002", "chainage_m": 40.0, "net_volume_m3": -10.0, "cumulative_volume_m3": 0.0, "cut_soil_m3": 0.0, "cut_rock_m3": 0.0, "cut_rr_m3": 0.0, "cut_br_m3": 0.0, "cut_compacted_m3": 0.0, "fill_m3": 10.0, "natural_spoil": false, "net_area_m2": -0.5}, {"station_id": "S003", "chainage_m": 60.0, "net_volume_m3": -100.0, "cumulative_volume_m3": -100.0, "cut_soil_m3": 0.0, "cut_rock_m3": 0.0, "cut_rr_m3": 0.0, "cut_br_m3": 0.0, "cut_compacted_m3": 0.0, "fill_m3": 100.0, "natural_spoil": false, "net_area_m2": -5.0}, {"station_id": "S004", "chainage_m": 80.0, "net_volume_m3": -100.0, "cumulative_volume_m3": -200.0, "cut_soil_m3": 0.0, "cut_rock_m3": 0.0, "cut_rr_m3": 0.0, "cut_br_m3": 0.0, "cut_compacted_m3": 0.0, "fill_m3": 100.0, "natural_spoil": false, "net_area_m2": -5.0}, {"station_id": "S005", "chainage_m": 100.0, "net_volume_m3": -100.0, "cumulative_volume_m3": -300.0, "cut_soil_m3": 0.0, "cut_rock_m3": 0.0, "cut_rr_m3": 0.0, "cut_br_m3": 0.0, "cut_compacted_m3": 0.0, "fill_m3": 100.0, "natural_spoil": false, "net_area_m2": -5.0}, {"station_id": "S006", "chainage_m": 120.0, "net_volume_m3": -100.0, "cumulative_volume_m3": -400.0, "cut_soil_m3": 0.0, "cut_rock_m3": 0.0, "cut_rr_m3": 0.0, "cut_br_m3": 0.0, "cut_compacted_m3": 0.0, "fill_m3": 100.0, "natural_spoil": false, "net_area_m2": -5.0}, {"station_id": "S007", "chainage_m": 140.0, "net_volume_m3": -100.0, "cumulative_volume_m3": -500.0, "cut_soil_m3": 0.0, "cut_rock_m3": 0.0, "cut_rr_m3": 0.0, "cut_br_m3": 0.0, "cut_compacted_m3": 0.0, "fill_m3": 100.0, "natural_spoil": false, "net_area_m2": -5.0}, {"station_id": "S008", "chainage_m": 160.0, "net_volume_m3": -100.0, "cumulative_volume_m3": -600.0, "cut_soil_m3": 0.0, "cut_rock_m3": 0.0, "cut_rr_m3": 0.0, "cut_br_m3": 0.0, "cut_compacted_m3": 0.0, "fill_m3": 100.0, "natural_spoil": false, "net_area_m2": -5.0}, {"station_id": "S009", "chainage_m": 180.0, "net_volume_m3": -100.0, "cumulative_volume_m3": -700.0, "cut_soil_m3": 0.0, "cut_rock_m3": 0.0, "cut_rr_m3": 0.0, "cut_br_m3": 0.0, "cut_compacted_m3": 0.0, "fill_m3": 100.0, "natural_spoil": false, "net_area_m2": -5.0}, {"station_id": "S010", "chainage_m": 200.0, "net_volume_m3": -100.0, "cumulative_volume_m3": -800.0, "cut_soil_m3": 0.0, "cut_rock_m3": 0.0, "cut_rr_m3": 0.0, "cut_br_m3": 0.0, "cut_compacted_m3": 0.0, "fill_m3": 100.0, "natural_spoil": false, "net_area_m2": -5.0}, {"station_id": "S011", "chainage_m": 220.0, "net_volume_m3": -100.0, "cumulative_volume_m3": -900.0, "cut_soil_m3": 0.0, "cut_rock_m3": 0.0, "cut_rr_m3": 0.0, "cut_br_m3": 0.0, "cut_compacted_m3": 0.0, "fill_m3": 100.0, "natural_spoil": false, "net_area_m2": -5.0}, {"station_id": "S012", "chainage_m": 240.0, "net_volume_m3": -100.0, "cumulative_volume_m3": -1000.0, "cut_soil_m3": 0.0, "cut_rock_m3": 0.0, "cut_rr_m3": 0.0, "cut_br_m3": 0.0, "cut_compacted_m3": 0.0, "fill_m3": 100.0, "natural_spoil": false, "net_area_m2": -5.0}], "cut_natural_m3": {"ea": 10.0, "rr": 0.0, "br": 0.0}, "cut_compacted_m3": 10.0, "fill_compacted_m3": 1010.0, "final_cumulative_m3": -1000.0, "surplus_m3": 0.0, "shortage_m3": 1000.0, "min_cumulative_m3": -1000.0, "max_cumulative_m3": 10.0, "conversion": {"soil": 1.0, "ripping_rock": 1.0, "blasting_rock": 1.0}}, "cases": [{}, {"drawing": true}]} \ No newline at end of file diff --git a/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul.js b/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul.js deleted file mode 100644 index e0f4a416..00000000 --- a/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul.js +++ /dev/null @@ -1,366 +0,0 @@ -/* ============================================================================= - * common_util_mass_haul.ts - * 유토곡선(Mass Haul Diagram) 계산 엔진 — 순수 함수, DOM 의존 없음. - * - * 구간 토량을 평균단면법으로 만들고 토량환산계수로 기준을 통일해 누가토량을 누적한다. - * 결과는 B08 수량산출·B09 견적이 그대로 재사용할 수 있도록 계산과 렌더링을 분리해 - * 둔다(렌더러는 `common_util_mass_haul_view`). - * - * ── 계산 기준: 다짐상태 ────────────────────────────────────────────── - * "운반거리의 산정 시에 모든 수량은 다짐상태로 환산하여 계산하고, 내역서에 적용하는 - * 수량은 자연상태로 한다." (2021년도 국도건설공사 설계실무 요령 / 표준품셈 계열) - * 유토곡선은 운반계획 도면이므로 다짐상태를 기준상태로 삼는다. 내역서용 자연상태 - * 수량은 `cut_natural_m3`로 함께 보존해 B07이 되돌려 쓸 수 있게 한다. - * - * ── 두 기준(종단/횡단)을 함께 내는 이유 ────────────────────────────── - * [임도의 설계 및 시설기준] 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 - * ========================================================================== */ -const GROUND_TYPES = ["soil", "ripping_rock", "blasting_rock"]; -/** 곡선을 그릴 산출 기준. 배열 순서가 곧 범례 순서다. */ -const SERIES_BASES = ["cross", "longitudinal"]; -/** - * 기본으로 켜 두는 곡선 — 횡단 기준만. 종단 기준은 계획고 기반 개략값이라 참고용이므로 - * 사용자가 필요할 때 범례에서 켠다(2026-08-02 사용자 지시). - */ -export const MASS_HAUL_DEFAULT_VISIBLE = ["cross"]; -/** - * 토량 분배(평형선) 레이어의 표시 토글 키. 곡선 기준(`MassHaulBasis`)이 아니라 곡선 **위에 - * 얹는 레이어**라 별도 키로 둔다 — 같은 범례 줄에서 함께 켜고 끈다. - */ -export const MASS_HAUL_BALANCE_KEY = "balance"; -/** - * 곡선 기준 버튼은 **라디오**다 — 횡단/종단 중 하나를 고르면 그 기준으로 계산된 그래프 - * 하나만 전체 영역에 보인다(2026-08-03 사용자 지시. 겹쳐 비교하던 이전 방식 폐기). - * 켜짐 집합에 기준 키가 없거나 여럿이면 순서상 앞선 기준 하나로 눌러 맞춘다 — - * 두 화면(B05/B06)이 같은 저장 키를 공유하므로 읽는 쪽마다 같은 규칙이어야 한다. - */ -export function normalizeVisibleBasis(visible) { - const active = SERIES_BASES.filter((basis) => visible.has(basis)); - if (active.length === 1) - return visible; - const next = new Set(visible); - SERIES_BASES.forEach((basis) => next.delete(basis)); - next.add(active[0] ?? SERIES_BASES[0]); - return next; -} -/** 범례 클릭 하나를 적용한다 — 기준 키는 라디오 선택, 그 외(분배 레이어)는 켜고 끄기. */ -export function applyLegendToggle(visible, key) { - const next = new Set(visible); - if (SERIES_BASES.includes(key)) { - SERIES_BASES.forEach((basis) => next.delete(basis)); - next.add(key); - } - else if (next.has(key)) { - next.delete(key); - } - else { - next.add(key); - } - return normalizeVisibleBasis(next); -} -/** 환산계수가 비어 있거나 값이 이상하면 1.0으로 떨어뜨려 계산이 멈추지 않게 한다. */ -function factorFor(conversion, ground) { - const factor = conversion?.[ground]?.compacted; - return Number.isFinite(factor) && factor > 0 ? factor : 1; -} -function emptyVolumes() { - return { soil: 0, ripping_rock: 0, blasting_rock: 0 }; -} -function finiteArea(value) { - return Number.isFinite(value) && value > 0 ? value : 0; -} -function normalizeGround(value) { - return value && GROUND_TYPES.includes(value) ? value : "soil"; -} -/** - * 엔진이 낸 절토 분리값을 읽는다. 구 데이터(분리 필드 없음)는 절토 전량을 측점 - * 지반유형으로 돌려 **기존 단일 지반유형 환산과 같은 결과**를 유지한다. - */ -function splitCut(design) { - 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, conversion) { - 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, conversion) { - if (samples.length < 2) - return null; - const points = [ - { - 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) { - return crossSections - .filter((section) => section.design && Number.isFinite(section.chainage_m)) - .sort((a, b) => a.chainage_m - b.chainage_m); -} -/** - * 자연방토 가능 여부. 성토측 자연 지반이 판정 경사보다 가파르면 부어 놓은 흙이 쌓이지 않고 - * 스스로 흘러내린다. 판정 경사(config)를 못 받았으면 **전부 불가**로 본다 — 프론트에 사본을 - * 두지 않으며, 모르면 운반비를 세는 쪽이 안전하다. - */ -function naturalSpoilAllowed(design, minSlope) { - if (!Number.isFinite(minSlope)) - return false; - const slope = design?.fill_ground_slope; - return Number.isFinite(slope) && slope >= minSlope; -} -/** 횡단 기준 — 엔진이 낸 실측 단면적을 그대로 쓴다. */ -function crossAreaSamples(sections, minSlope) { - 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_Profile_UI_Profile_Alignment.ts`가 이미 쓰는 부호 규약과 같다). - * 노반폭과 지반유형은 종단 샘플에 없으므로 **가장 가까운 설계 측점**에서 가져온다. - * 설계된 측점이 하나도 없으면 노반폭을 알 수 없어 곡선을 만들지 않는다. - * - * 개략 단면적에는 암반 경계선이 없으므로, **가장 가까운 측점의 토사:암반 면적비**를 그대로 - * 물려 두 기준이 같은 환산을 타게 한다(비율을 못 구하면 전량 토사로 둔다). - */ -function longitudinalAreaSamples(longitudinal, sections) { - 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, conversion, naturalSpoilMinSlope) { - return integrate(crossAreaSamples(designedSections(crossSections), naturalSpoilMinSlope), conversion); -} -/** - * 비교용 곡선 묶음. 산출 기준 2종(횡단/종단)을 계산한다. - * 계산이 불가능한 기준(설계 측점 부족, 종단 계획선 없음)은 조용히 빠진다. - */ -export function computeMassHaulSeries(longitudinal, crossSections, conversion, naturalSpoilMinSlope, -/** 낼 기준. 총괄값(배지)만 필요하면 `["cross"]` 로 불러 종단 기준 적분을 건너뛴다. */ -bases = SERIES_BASES) { - const sections = designedSections(crossSections); - const samplesFor = (basis) => basis === "cross" - ? crossAreaSamples(sections, naturalSpoilMinSlope) - : longitudinalAreaSamples(longitudinal, sections); - const series = []; - for (const basis of SERIES_BASES) { - if (!bases.includes(basis)) - continue; - const result = integrate(samplesFor(basis), conversion); - if (result) - series.push({ key: basis, basis, result }); - } - return series; -} -/** - * 확정 시 DB(`longitudinal_sections.data.mass_haul`)에 넣을 직렬화 형태로 정리한다. - * - * **토량 분배(`haul_plan`)는 여기서 만들지 않는다** — 그 코드를 브라우저 번들에서 빼려고 - * 서버(Node 진입점)가 얹는다(2026-09-06 사용자 확정). 이 함수는 누가토량만 다룬다. - * 이미 만들어 둔 배분 조각이 있으면 `extra` 로 넘겨 그대로 실린다. - */ -export function massHaulPayload(result, extra, balloonOffsets) { - const round = (value) => Math.round(value * 100) / 100; - return { - basis: "compacted", - conversion: result.conversion, - ...(extra ?? {}), - // 사용자가 끌어 옮긴 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), - })), - }; -} diff --git a/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul_balance.js b/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul_balance.js deleted file mode 100644 index 6dfbc0da..00000000 --- a/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul_balance.js +++ /dev/null @@ -1,579 +0,0 @@ -/* ============================================================================= - * common_util_mass_haul_balance.ts - * 토량 분배(평형선 · 운반 블록 · 장비 띠) 계산 엔진 — 순수 함수, DOM 의존 없음. - * - * 누가토량 곡선(`common_util_mass_haul`)은 "얼마가 남는가"만 말한다. 이 파일은 그 곡선에서 - * **어디 흙을 어디로 얼마나 옮기는가**를 뽑아 낸다: 평형선 → 운반 블록 → 장비 띠 → - * 띠별 운반토량 `Q` · 평균운반거리 `L` · 지반유형 안분. - * - * ── 작도 규칙(참고자료) ───────────────────────────────────────────── - * - * - 상승 = 절토, 하강 = 성토. 극대·극소점 = 절·성토 경계. - * - 산 모양 = 좌→우 운반, 골 모양 = 우→좌 운반. - * - 평형선(수평선)이 곡선을 자르는 두 점 사이는 절토량 = 성토량. - * - 운반토량 = 평형선에서 곡선 정점까지의 종거. - * - 평균운반거리 = **종거의 1/2 지점을 지나는 수평선 B′C′의 길이**(반종거 수평선). - * - 배분 3원칙 = ① 운반거리 최소 ② 높은 곳 → 낮은 곳 ③ 토량을 모아 운반. - * - * ── 평형선을 어떻게 자동으로 잡는가 ───────────────────────────────── - * 참고자료는 "가장 유리한 평형점을 도상에서 구한다"까지만 말한다. **0선 하나로 고정하면 - * 안 된다** — 임도는 절토가 크게 우세해 사토가 전부 종점 한 덩어리로 몰린다. 그래서 곡선을 - * 왼쪽부터 훑으며 평형선 높이를 **계단으로 올린다**: - * - * 커서(곡선 위, 높이 = 현재 평형선)에서 다음 극값을 본다. - * - 올라갔다가(절토) 다시 현재 높이 **아래**까지 내려오면 → 성토가 절토를 다 받는다. - * 평형선은 그대로, 블록은 [커서 … 곡선이 평형선으로 되돌아온 점]. - * - 올라갔다가 현재 높이 **위**에서 멈추면 → 남는 절토가 생긴다. 평형선을 그 골 높이로 - * 올리고, 못 받은 만큼을 **그 자리에서** 사토로 계상한다. - * - 내려가는 경우는 정확히 대칭(토취). - * - * 이렇게 하면 ① 구간이 겹치거나 비지 않고 ② 사토·토취가 발생 지점에 남으며 - * ③ 단차의 합이 최종 누가토량과 정확히 일치한다. - * - * ── 블록 하나를 장비 띠로 가르는 이유 ─────────────────────────────── - * 참고 도면의 토량배분도에는 수평선이 블록마다 여러 개 그려져 있고(A–A′ / B–B′ / C–C′), - * 그 사이 띠마다 번호가 붙어 종단면의 해칭 구간과 1:1로 대응한다. 하단 브래킷 - * (무대운반거리 / 도자 평균운반거리 / 덤프 평균운반거리)의 폭이 각 수평선의 길이와 같다. - * - * 즉 **수평선의 높이는 그 현(弦)의 길이가 장비 경계거리와 같아지는 높이**다. 누가토량 - * 그래프에서 세로축이 곧 토량이므로, 두 수평선 사이 띠의 두께가 그 장비가 옮기는 토량이다. - * 현 길이 20m 되는 높이 위쪽 → 종무대 - * 거기서 60m 되는 높이까지 → 도쟈 - * 그 아래 ~ 평형선 → 덤프 - * 경계값 정의처는 `config_system.EARTHWORK_HAUL_EQUIPMENT_LIMITS_M` 한 곳뿐이다. - * - * ── 단위 ───────────────────────────────────────────────────────── - * 곡선이 다짐상태 기준이므로 `Q`·EA/RR/BR 안분도 **다짐상태**다. 내역서용 자연상태 수량은 - * `MassHaulResult.cut_natural_m3` / 측점별 `cut_soil_m3`·`cut_rr_m3`·`cut_br_m3`가 따로 든다. - * ========================================================================== */ -import { crossFrom, EPSILON, evaluate, extremaIndices, pruneExtrema, segmentOf, } from "./common_util_mass_haul_curve.js"; -import { apportion, cutMix, naturalSpoilRatio, settleResiduals, sortedLimits, } from "./common_util_mass_haul_settle.js"; -export { haulPlanPayload } from "./common_util_mass_haul_settle.js"; -/** - * **그림에서만** 이보다 작은 진동은 블록으로 세지 않는다. 측점 하나짜리 요철까지 블록을 만들면 - * balloon이 수십 개 깔려 도면을 못 읽는다. 곡선 진폭 대비 비율이라 노선 규모에 자동으로 맞는다. - * ⚠ 수량 계획에는 걸지 않는다 — 진폭이 커지면 작은 봉우리가 통째로 지워져 운반량이 0 이 됐다 - * (2026-09-14 936be972 실측 474.14㎥ → 0 · 브레인 ①). - */ -const MIN_SWING_RATIO = 0.02; -/** 경계현 높이를 훑을 때 쪼개는 칸 수. 잔요철에 속지 않을 만큼 촘촘해야 한다. */ -const CHORD_SCAN_STEPS = 64; -/** 훑어서 찾은 칸 안에서 좁히는 이분법 반복 수. 24회면 칸 폭의 1/1600만 남는다. */ -const CHORD_SOLVE_STEPS = 24; -/** 띠 테두리를 곡선(포물선)에 붙이려고 구간 하나를 쪼개는 수. */ -const OUTLINE_STEPS = 4; -function blockGeometry(points, fromM, toM, apexM, base, apex) { - const levelAt = (ratio) => base + (apex - base) * ratio; - const chordAt = (ratio) => { - const level = levelAt(ratio); - const from = crossFrom(points, fromM, apexM, level) ?? fromM; - const to = crossFrom(points, apexM, toM, level) ?? toM; - return { from, to, length: Math.max(to - from, 0) }; - }; - return { levelAt, chordAt }; -} -/** - * 현의 길이가 `target` **이하로 확정되는** 가장 낮은 높이를 찾는다(반환값은 `ratio`). - * - * 현 길이는 평형선(가장 김)에서 정점(0)으로 갈수록 줄지만 **단조가 아니다** — 가지치기로 - * 블록을 하나로 묶어도 그 안의 원래 폴리라인에는 잔요철이 남아, 높이를 올리는 도중 현이 - * 잠깐 다시 길어질 수 있다. 이분법만 쓰면 그 지점에서 엉뚱한 근으로 수렴한다(무작위 검증에서 - * 종무대 경계현이 45m로 잡히는 사례를 잡았다). - * - * 그래서 **훑기 → 이분법** 두 단계로 간다: 먼저 전 구간을 성기게 훑어 현이 `target`을 넘는 - * **가장 높은** 자리를 찾고, 그 바로 위 칸에서만 이분법으로 좁힌다. 이렇게 잡은 경계 위쪽은 - * 전부 `target` 이하가 보장된다 — "이 띠는 이 장비로 옮길 수 있다"가 성립해야 하기 때문이다. - * 평형선에서 이미 `target` 이하면 그 장비가 블록 전체를 맡는다 → 0. - */ -function ratioForChord(geometry, target) { - let lastOver = -1; - for (let step = 0; step <= CHORD_SCAN_STEPS; step += 1) { - if (geometry.chordAt(step / CHORD_SCAN_STEPS).length > target) - lastOver = step; - } - if (lastOver < 0) - return 0; - if (lastOver >= CHORD_SCAN_STEPS) - return 1; - let low = lastOver / CHORD_SCAN_STEPS; - let high = (lastOver + 1) / CHORD_SCAN_STEPS; - for (let step = 0; step < CHORD_SOLVE_STEPS; step += 1) { - const middle = (low + high) / 2; - if (geometry.chordAt(middle).length > target) - low = middle; - else - high = middle; - } - return high; -} -/** - * 띠를 감싸는 다각형. - * - * 예전에는 「아래 현 → 왼쪽 곡선 → 위 현 → 오른쪽 곡선」을 이어 붙였는데, 곡선이 두 현 사이에서 - * 요동치면 그 조각이 띠 밖으로 삐져나가 **면이 스스로 겹쳤다**(2026-08-02 사용자 지적). - * - * 정의를 바꿨다. 띠는 「아래 현의 두 끝 사이에서, 아래 높이부터 **곡선과 위 높이 중 안쪽**까지」다. - * 아래 현을 밑변으로 깔고 그 위를 훑으며 곡선값을 두 높이 사이로 **자른 값**만 찍는다. - * 이러면 어떤 지형에서도 단순 다각형이라 스스로 겹칠 수가 없다. - */ -function bandOutline(points, low, lowLevel, highLevel) { - const floor = Math.min(lowLevel, highLevel); - const ceil = Math.max(lowLevel, highLevel); - const outline = [{ m: low.from, v: lowLevel }]; - const push = (m, v) => { - const previous = outline[outline.length - 1]; - if (previous && Math.abs(previous.m - m) < 1e-9 && Math.abs(previous.v - v) < 1e-9) - return; - outline.push({ m, v }); - }; - for (let index = 1; index < points.length; index += 1) { - const segment = segmentOf(points, index); - if (!segment) - continue; - const start = Math.max(segment.x0, low.from); - const end = Math.min(segment.x0 + segment.span, low.to); - if (!(end > start)) - continue; - for (let step = 0; step <= OUTLINE_STEPS; step += 1) { - const m = start + ((end - start) * step) / OUTLINE_STEPS; - push(m, Math.min(Math.max(evaluate(segment, m - segment.x0), floor), ceil)); - } - } - push(low.to, lowLevel); - return outline; -} -/** - * 누가토량 곡선에서 토량 분배(평형선·운반 블록·장비 띠·사토/토취)를 뽑는다. - * 블록도 잔량도 안 나오면(평탄한 곡선) null. - */ -/** - * 채집석 공제 — **사토에서 한 번만 뺀다**(2026-09-09 사용자 확정, 네 창 합의 문구). - * - * 채집석 공제는 사토에서 한 번만 뺀다. - * B08 은 소요량(collected_stone_deduction_m3, ㎥ 양수)을 내기만 하고 공제하지 않으며, - * 빼는 자리는 유토곡선의 사토뿐이다 — - * 실어 내는 몫(spoil_m3 − natural_spoil_m3)에서 먼저 빼고 모자라면 자연방토에서 뺀다. - * - * ⚠ 순서가 중요하다 — 캔 돌은 **실어 낼 흙 속에 있던 것**이다. 자연방토(운반비를 안 세는 몫) - * 에서 먼저 깎으면 **줄어야 할 운반비가 안 줄어든다.** - * ⚠ 잔량 하나하나(`residuals`)를 줄인다 — 총량만 줄이면 사토 balloon·운반거리가 안 따라간다. - * ⚠ **토취는 안 건드린다.** 「채집석을 캐 가면 성토에 쓸 흙이 줄어 토취가 는다」는 갈래가 - * 있으나 **근거가 없다** — 근거 없이 금액을 올리지 않는다. 확정되면 이 함수만 고치면 된다. - * ⚠ **순서** — 이 함수는 `applyStructureSpoil` **뒤에** 돈다. 잔토가 담긴 뒤라야 뺄 대상이 있다. - * - * 돌려주는 값은 **실제로 뺀 양(㎥)**. 사토가 모자라면 받은 값보다 작다. - */ -function applyCollectedStoneDeduction(residuals, deduction, byGround, groundUnknownM3, conversion) { - // ⚠ **축을 맞춘 뒤 뺀다**(2026-09-09 세 창 확정) — 채집석은 **벽 입적(제자리 부피)**이라 - // 자연 축이고 이 곡선은 다짐 축이다. 갈래별 값이 오면 **갈래마다 ×C** 해서 뺀다. - // 갈래를 못 가른 몫은 **계수가 없어 환산하지 않고** 그대로 뺀다(근거 없이 안 눅인다). - // 갈래가 아예 안 오면 종전처럼 총량을 그대로 뺀다 — 값이 안 오는 것과 0 은 다르다. - const converted = compactedStoneDeduction(byGround, groundUnknownM3, conversion); - const amountToTake = converted === null ? deduction : converted; - if (amountToTake === null || !Number.isFinite(amountToTake) || amountToTake <= 0) - return 0; - const spoils = residuals.filter((residual) => residual.kind === "spoil"); - let left = amountToTake; - const take = (residual, amount) => { - if (amount <= 0) - return; - const before = residual.volume_m3; - const ratio = before > 0 ? (before - amount) / before : 0; - residual.volume_m3 = before - amount; - // 지반유형 안분도 같은 비율로 줄인다 — 남은 사토의 구성비는 그대로다. - residual.ea_m3 *= ratio; - residual.rr_m3 *= ratio; - residual.br_m3 *= ratio; - left -= amount; - }; - // ① 실어 내는 몫부터 - for (const residual of spoils) { - if (left <= EPSILON) - break; - take(residual, Math.min(Math.max(residual.volume_m3 - residual.natural_m3, 0), left)); - } - // ② 모자라면 자연방토에서 - for (const residual of spoils) { - if (left <= EPSILON) - break; - const amount = Math.min(residual.natural_m3, left); - residual.natural_m3 -= amount; - take(residual, amount); - } - return amountToTake - Math.max(left, 0); -} -/** - * 구조물 잔토 — **사토에 한 번만 더한다**(2026-09-09 네 창 합의). - * - * 구조물 잔토는 사토에 한 번만 더한다. - * B08 은 소요량(structure_spoil_m3, ㎥ 양수)을 내기만 하고, - * 더하는 자리는 유토곡선의 사토뿐이다. - * - * ⚠ **잔량 하나하나를 늘린다** — 총량만 늘리면 사토는 늘고 **운반이 안 는다**(공제 때와 같은 자리). - * ⚠ **나누는 법** — B08 이 지금은 **총량 하나**만 준다. 어느 측점에서 나온 잔토인지 모르므로 - * **남은 사토 잔량의 크기에 비례**해 나눈다. 한 곳에 몰면 운반거리가 틀리기 때문이고, - * 측점별 값이 오면 그때 그 자리에 얹을 것(그때는 이 함수만 고치면 된다). - * ⚠ **자연방토(`natural_m3`)는 안 늘린다** — 구조물 잔토는 실어 내는 흙이다. - * ⚠ **지반유형 안분(ea/rr/br)도 안 건드린다** — 어느 지반에서 나온 흙인지 모른다. - * 합계(`volume_m3`)와 갈래 합이 어긋나는 것은 그 사실을 드러내는 표시다. - * - * 돌려주는 값은 **실제로 더한 양(㎥)**. 받을 사토 잔량이 하나도 없으면 0 이다. - */ -/** - * 터파기 토질 문자열 → 잔량의 지반 갈래 칸. 모르면 `null`(그 몫은 「지반 모름」으로 남는다). - * 받는 말은 B08 의 품셈 9-13 3구분과 우리 내부 이름 둘 다 받는다. - */ -function groundBucket(ground) { - const text = (ground ?? "").trim(); - if (!text) - return null; - if (text === "토사" || text === "soil" || text === "ea") - return "ea_m3"; - if (text === "암절취" || text === "리핑암" || text === "ripping_rock" || text === "rr") { - return "rr_m3"; - } - if (text === "발파암" || text === "blasting_rock" || text === "br") - return "br_m3"; - return null; -} -/** 갈래 칸 ↔ 환산계수 이름. `EARTHWORK_CONVERSION_FACTORS` 의 키다. */ -const GROUND_OF_BUCKET = { - ea_m3: "soil", - rr_m3: "ripping_rock", - br_m3: "blasting_rock", -}; -/** 환산계수 이름 ↔ 채집석이 오는 갈래 이름. B08 이 `soil`·`ripping_rock`·`blasting_rock` 로 보낸다. */ -const STONE_GROUND_KEYS = ["soil", "ripping_rock", "blasting_rock"]; -/** - * 채집석 공제를 **다짐 축**으로 옮긴다 — 갈래마다 ×C, 갈래를 모르는 몫은 그대로. - * 갈래가 하나도 안 오면 `null`(= 「갈래를 안 보냈음」)이라 부르는 쪽이 총량으로 되돌아간다. - */ -function compactedStoneDeduction(byGround, groundUnknownM3, conversion) { - if (!byGround) - return null; - const entries = STONE_GROUND_KEYS.map((key) => [key, Number(byGround[key] ?? 0)]).filter(([, value]) => Number.isFinite(value) && value > 0); - const unknown = Number(groundUnknownM3 ?? 0); - const hasUnknown = Number.isFinite(unknown) && unknown > 0; - if (!entries.length && !hasUnknown) - return null; - let total = hasUnknown ? unknown : 0; - for (const [ground, value] of entries) { - const factor = conversion?.[ground]?.compacted; - total += typeof factor === "number" && factor > 0 ? value * factor : value; - } - return total; -} -/** - * **자연상태 → 다짐상태**(× C). 구조물 잔토를 곡선에 얹기 전에 한 번만 거친다. - * - * ⚠ 왜 필요한가 — 유토곡선은 **다짐상태**로 쌓는다(`common_util_mass_haul` 이 절토량에 - * ×C 를 곱해 둔다). B08 이 보내는 구조물 잔토는 **자연상태**(터파기 제자리 기하 부피)라 - * 그대로 더하면 **상태가 다른 두 부피를 섞는 것**이 된다. - * ⚠ **되돌리는 곳은 내보내는 자리 한 곳뿐**(B08 `HaulSummary`·사토 줄에서 ÷C). - * 여기서 ×C, 거기서 ÷C — **왕복 한 번씩이다. 다른 데서 또 환산하지 말 것.** - * ⚠ 계수를 모르면(지반 갈래를 못 붙였으면) **환산하지 않고 그대로 담는다** — 토사 계수로 - * 눅이면 근거 없이 금액이 움직인다. 그 몫은 `ground_unknown_m3` 로 드러난다. - */ -function toCompacted(naturalM3, bucket, conversion) { - if (!bucket || !conversion) - return naturalM3; - const factor = conversion[GROUND_OF_BUCKET[bucket]]?.compacted; - return typeof factor === "number" && factor > 0 ? naturalM3 * factor : naturalM3; -} -function newSpoilResidual(fromM, toM, volumeM3) { - // 잔토를 담을 사토 잔량이 없을 때 **새로 세운다**(2026-09-09 확정 ㉰). - // ⚠ 지반유형 안분·자연방토는 0 이다 — 어느 지반에서 나온 흙인지 모르고, **실어 내는** 흙이다. - return { - index: 0, - kind: "spoil", - from_m: fromM, - to_m: toM, - volume_m3: volumeM3, - level_from_m3: 0, - level_to_m3: 0, - ea_m3: 0, - rr_m3: 0, - br_m3: 0, - natural_m3: 0, - }; -} -/** - * 구조물 잔토 — **사토에 한 번만 더한다**(2026-09-09 네 창 합의). - * - * 구조물 잔토는 사토에 한 번만 더한다. - * B08 은 소요량(structure_spoil_m3, ㎥ 양수)을 내기만 하고, - * 더하는 자리는 유토곡선의 사토뿐이다. - * - * ⚠ **잔량 하나하나를 늘린다** — 총량만 늘리면 사토는 늘고 **운반이 안 는다**. - * ⚠ **담을 사토가 없으면 사토를 새로 세운다**(확정 ㉰). 파낸 흙은 어디로든 가므로 - * **물량이 사라지면 안 된다**. 토취를 줄이는 길(㉯)은 **「그 잔토를 성토재로 쓸 수 있다」**는 - * 근거가 있어야 하는데 구조물 터파기 흙은 암이 섞일 수 있고 우리가 그 판정을 안 한다 — - * 근거 없이 금액을 내리지 않고 **내보내는 쪽(안전측)** 으로 둔다. - * ⇒ 나중에 「성토재로 쓴다」가 확정되면 **이 함수 한 곳만** 바꾸면 된다. - * ⚠ 자연방토는 안 건드린다 — 실어 내는 흙이다. - * ⚠⚠ **상태가 다르다** — B08 이 보내는 잔토는 **자연상태**(`volume_basis: "natural"`, - * 터파기 제자리 기하 부피)이고 이 곡선은 **다짐상태**다. 그래서 담기 전에 `toCompacted` - * 로 **×C** 한다. 되돌리는 것은 **내보내는 자리 한 곳**(B08 운반·사토 줄에서 ÷C)뿐이다. - * 갈래를 못 붙인 몫은 계수가 없어 **환산하지 않고** 그대로 담긴다(근거 없이 안 눅인다). - */ -function applyStructureSpoil(residuals, amount, points, conversion) { - const spoilsOf = () => residuals.filter((residual) => residual.kind === "spoil"); - // ① 측점별 값이 오면 **그 자리**에 얹는다 — 구조물이 실제로 선 자리라 운반거리가 맞다. - if (points && points.length) { - let added = 0; - for (const point of points) { - const value = Number(point?.spoil_m3); - const chainage = Number(point?.chainage_m); - if (!Number.isFinite(value) || value <= 0 || !Number.isFinite(chainage)) - continue; - const spoils = spoilsOf(); - const covering = spoils.find((residual) => chainage >= residual.from_m - 1e-9 && chainage <= residual.to_m + 1e-9); - // 터파기 토질이 함께 오면 **그 갈래로 담는다** — B08 이 이미 판정한 값을 이어받는 것이라 - // 새 근거를 만드는 것이 아니다. 안 오면 갈래 없이 담겨 「지반 모름」으로 남는다. - const bucket = groundBucket(point?.ground_type ?? point?.ground ?? point?.ground_label); - const target = covering ?? newSpoilResidual(chainage, chainage, 0); - if (!covering) - residuals.push(target); - // ⚠ **자연상태로 와서 다짐상태 곡선에 얹힌다** — 갈래를 아는 몫만 ×C(2026-09-09). - // 갈래를 모르면 그대로 담기고 `ground_unknown_m3` 로 「상태도 못 되돌림」이 드러난다. - const placed = toCompacted(value, bucket, conversion); - target.volume_m3 += placed; - if (bucket) - target[bucket] += placed; - added += placed; - } - return added; - } - // ② 총량만 오면 남은 사토 잔량 크기에 **비례**해 나눈다(어느 자리인지 모를 때의 차선). - if (amount === null || !Number.isFinite(amount) || amount <= 0) - return 0; - const spoils = spoilsOf(); - const total = spoils.reduce((sum, residual) => sum + residual.volume_m3, 0); - if (!spoils.length || total <= EPSILON) { - // 담을 사토가 없다 — 있는 잔량이 덮는 구간 전체에 사토를 하나 세운다. 자리를 모르므로 - // 구간을 넓게 잡고, **물량은 보존**한다. - const froms = residuals.map((residual) => residual.from_m); - const tos = residuals.map((residual) => residual.to_m); - const fromM = froms.length ? Math.min(...froms) : 0; - const toM = tos.length ? Math.max(...tos) : 0; - residuals.push(newSpoilResidual(fromM, toM, amount)); - return amount; - } - let added = 0; - spoils.forEach((residual, index) => { - const share = index === spoils.length - 1 ? amount - added : amount * (residual.volume_m3 / total); - residual.volume_m3 += share; - added += share; - }); - return added; -} -export function computeHaulPlan(result, limits, options) { - const points = result.points; - if (points.length < 2) - return null; - const range = Math.max(result.max_cumulative_m3 - result.min_cumulative_m3, 0); - const extrema = options?.drawing - ? pruneExtrema(points, extremaIndices(points), Math.max(range * MIN_SWING_RATIO, 1)) - : extremaIndices(points); - const tiers = sortedLimits(limits); - const blocks = []; - const residuals = []; - const steps = []; - let bandCounter = 0; - const pushBlock = (fromM, toM, base, apexM, apex, direction) => { - const volume = Math.abs(apex - base); - if (!(volume > EPSILON) || !(toM > fromM)) - return; - const geometry = blockGeometry(points, fromM, toM, apexM, base, apex); - // 정점 쪽(가까운 장비)부터 평형선 쪽으로 내려오며 띠를 자른다. - const bands = []; - let upper = 1; - for (const tier of tiers) { - if (upper <= EPSILON) - break; - const lower = tier.max_distance_m === null ? 0 : ratioForChord(geometry, tier.max_distance_m); - if (upper - lower <= 1e-6) { - upper = lower; - continue; - } - const lowChord = geometry.chordAt(lower); - const highChord = geometry.chordAt(upper); - const haulChord = geometry.chordAt((lower + upper) / 2); - // 절토 쪽 변에서 잘라 낸 구간이 이 띠가 퍼오는 흙이다(산이면 왼쪽 변, 골이면 오른쪽 변). - const cutFrom = direction === "forward" ? lowChord.from : highChord.to; - const cutTo = direction === "forward" ? highChord.from : lowChord.to; - const bandVolume = (upper - lower) * volume; - bandCounter += 1; - bands.push({ - index: bandCounter, - equipment: tier.key || null, - level_base_m3: geometry.levelAt(lower), - level_apex_m3: geometry.levelAt(upper), - volume_m3: bandVolume, - boundary_from_m: lowChord.from, - boundary_to_m: lowChord.to, - haul_distance_m: haulChord.length, - haul_from_m: haulChord.from, - haul_to_m: haulChord.to, - outline: bandOutline(points, lowChord, geometry.levelAt(lower), geometry.levelAt(upper)), - ...apportion(cutMix(points, cutFrom, cutTo, result.conversion), bandVolume), - }); - upper = lower; - } - blocks.push({ - index: blocks.length + 1, - base_m3: base, - from_m: fromM, - to_m: toM, - apex_m: apexM, - apex_volume_m3: apex, - volume_m3: volume, - direction, - bands, - }); - steps.push({ from_m: fromM, to_m: toM, level_m3: base }); - }; - const pushResidual = (fromM, toM, from, to) => { - const delta = to - from; - if (Math.abs(delta) < EPSILON) - return; - const volume = Math.abs(delta); - residuals.push({ - index: residuals.length + 1, - kind: delta > 0 ? "spoil" : "borrow", - from_m: fromM, - to_m: toM, - 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) - : { ea_m3: 0, rr_m3: 0, br_m3: 0 }), - }); - }; - let level = points[0].cumulative_volume_m3; - let cursor = points[0].chainage_m; - let k = 1; - while (k < extrema.length) { - const apexX = points[extrema[k]].chainage_m; - const apexY = points[extrema[k]].cumulative_volume_m3; - if (apexX <= cursor + EPSILON) { - k += 1; - continue; - } - const rising = apexY > level + EPSILON; - const falling = apexY < level - EPSILON; - if (!rising && !falling) { - cursor = apexX; - k += 1; - continue; - } - // 짝이 되는 다음 극값이 없으면 받아 줄 성토(또는 절토)가 없다 — 전량 사토/토취. - if (k + 1 >= extrema.length) { - steps.push({ from_m: cursor, to_m: apexX, level_m3: level }); - pushResidual(cursor, apexX, level, apexY); - level = apexY; - cursor = apexX; - k += 1; - continue; - } - const nextX = points[extrema[k + 1]].chainage_m; - const nextY = points[extrema[k + 1]].cumulative_volume_m3; - if (rising) { - if (nextY <= level + EPSILON) { - // 성토가 절토를 다 받는다 — 평형선 그대로, 곡선이 되돌아온 점에서 블록을 닫는다. - const end = crossFrom(points, apexX, nextX, level) ?? nextX; - pushBlock(cursor, end, level, apexX, apexY, "forward"); - cursor = end; - k += 1; - } - else { - // 못 받은 절토가 남는다 — 평형선을 그 골 높이로 올리고 차액을 사토로 계상한다. - const start = crossFrom(points, cursor, apexX, nextY) ?? cursor; - steps.push({ from_m: cursor, to_m: start, level_m3: level }); - pushResidual(cursor, start, level, nextY); - pushBlock(start, nextX, nextY, apexX, apexY, "forward"); - level = nextY; - cursor = nextX; - k += 2; - } - } - else if (nextY >= level - EPSILON) { - // 골 — 오른쪽 절토가 왼쪽 성토를 채운다(우→좌 운반). - const end = crossFrom(points, apexX, nextX, level) ?? nextX; - pushBlock(cursor, end, level, apexX, apexY, "backward"); - cursor = end; - k += 1; - } - else { - // 절토가 모자라 성토를 못 채운다 — 평형선을 내리고 차액을 토취로 계상한다. - const start = crossFrom(points, cursor, apexX, nextY) ?? cursor; - steps.push({ from_m: cursor, to_m: start, level_m3: level }); - pushResidual(cursor, start, level, nextY); - pushBlock(start, nextX, nextY, apexX, apexY, "backward"); - level = nextY; - cursor = nextX; - k += 2; - } - } - // 극값 가지치기로 잘려 나간 꼬리가 있으면 총량이 어긋난다 — 종점 잔량으로 흡수한다. - const endX = points[points.length - 1].chainage_m; - const endY = points[points.length - 1].cumulative_volume_m3; - if (Math.abs(endY - level) > EPSILON) { - steps.push({ from_m: cursor, to_m: endX, level_m3: level }); - pushResidual(cursor, endX, level, endY); - } - if (!blocks.length && !residuals.length) - return null; - const transfers = settleResiduals(points, residuals, result.conversion, limits); - const settled = residuals.filter((residual) => residual.volume_m3 > EPSILON); - settled.forEach((residual, index) => { - residual.index = index + 1; - }); - // ⚠ **순서가 뜻을 가른다 — 「잔토를 더하고 → 공제를 뺀다」**(2026-09-09 확정). - // 유토곡선의 사토는 「현장에 남는 흙 총량」이고 **출처를 안 가린다**. 공제는 그 총량에서 - // 빼는 것이라 **잔토가 담긴 뒤라야 뺄 대상이 있다.** 반대 순서로 두면 사토가 0 인 - // 노선(전 구간 토취)에서 공제가 **영영 안 걸린다** — 두 창 저장분에서 실제로 그랬다. - const structureSpoilInput = options?.structure_spoil_m3 ?? null; - const structureSpoilPoints = options?.structure_spoil_points ?? null; - const structureSpoilAdded = applyStructureSpoil(settled, structureSpoilInput, structureSpoilPoints, options?.conversion ?? null); - const deductionInput = options?.collected_stone_deduction_m3 ?? null; - const deducted = applyCollectedStoneDeduction(settled, deductionInput, options?.collected_stone_by_ground_m3 ?? null, options?.collected_stone_ground_unknown_m3 ?? null, options?.conversion ?? null); - const remaining = settled.filter((residual) => residual.volume_m3 > EPSILON); - remaining.forEach((residual, index) => { - residual.index = index + 1; - }); - let spoil = 0; - let borrow = 0; - let naturalSpoil = 0; - for (const residual of remaining) { - 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; - return { - blocks, - residuals: remaining, - transfers, - steps, - spoil_m3: spoil, - borrow_m3: borrow, - natural_spoil_m3: naturalSpoil, - collected_stone_deduction_m3: deductionInput, - collected_stone_deducted_m3: deducted, - structure_spoil_m3: structureSpoilInput, - structure_spoil_added_m3: structureSpoilAdded, - 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, - }; -} diff --git a/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul_curve.js b/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul_curve.js deleted file mode 100644 index 8ae163fe..00000000 --- a/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul_curve.js +++ /dev/null @@ -1,145 +0,0 @@ -/* ============================================================================= - * common_util_mass_haul_curve.ts - * 누가토량 곡선의 기하 — 포물선 보간 · 수평선 교점 · 극값 추출. - * - * ── 측점 사이를 무엇으로 잇는가: **직선**(2026-08-02 사용자 확정) ────────── - * 이론상으로는 포물선이다 — 평균단면법 `V = (A₁+A₂)/2 × L`이 "구간 내 단면적이 선형으로 - * 변한다"를 전제하므로, 그 적분인 누가토량은 2차식이 된다. 참고 도면의 종무대 평균운반거리에 - * `20/√2 = 14.14m`가 12번 찍히는 것도 포물선 + "종거 1/2 수평선" 규칙으로만 설명된다 - * (직선이면 `20/2 = 10m`). - * - * 그럼에도 **직선을 쓴다.** 측점을 기준으로 절·성토량을 읽고 판단하는 것이 실무이고, 도면도 - * 직선으로 그린다는 사용자(임도설계 실무자) 판단이다. 대가는 평균운반거리가 포물선 대비 - * 약 40% 짧게 나오는 것이며, 이는 20m·70m 장비 경계 판정에 직접 영향을 준다. - * - * 되돌릴 일이 생기면 `PARABOLIC_MASS_CURVE` 하나만 켜면 된다 — 그리기·재기가 함께 바뀐다. - * - * 토량 분배 계산(`common_util_mass_haul_balance`)이 700줄을 넘겨 곡선 기하만 여기로 떼어냈다. - * ========================================================================== */ -export const EPSILON = 1e-9; -/** - * 누가토량 곡선을 측점 사이에서 포물선으로 볼지 여부. **false = 직선**(사용자 확정). - * 이 값 하나가 곡선 렌더링(`curvePath`)과 수평선 교점(`crossFrom`)을 함께 바꾼다 — - * 둘이 어긋나면 수평선 끝이 곡선에서 떠 보인다(2026-08-02에 이미 겪은 문제). - */ -export const PARABOLIC_MASS_CURVE = false; -export function segmentOf(points, index) { - const a = points[index - 1]; - const b = points[index]; - const span = b.chainage_m - a.chainage_m; - if (!(span > 0)) - return null; - const average = (b.cumulative_volume_m3 - a.cumulative_volume_m3) / span; - const start = Number.isFinite(a.net_area_m2) ? a.net_area_m2 : average; - const end = Number.isFinite(b.net_area_m2) ? b.net_area_m2 : average; - // 포물선이 **구간 끝값을 정확히 통과하도록** 두 단면적을 같은 양만큼 옮긴다. - // `V(Δ) = V₀ + (a₀+a₁)Δ/2`이므로 평균이 어긋나면 끝값이 어긋나고, 그러면 수평선 교점을 - // 못 찾아 블록이 엉뚱하게 길어진다(도면 곡선 검증에서 성토 1455㎥가 한 블록에 묻혔다). - // 곡률 `(a₁−a₀)`는 그대로라 포물선 모양은 보존된다. - const shift = average - (start + end) / 2; - return { - x0: a.chainage_m, - span, - v0: a.cumulative_volume_m3, - // 직선 모드에서는 구간 평균 단면적 하나로 기울기를 잡는다 → 곡률 0 = 직선. - a0: PARABOLIC_MASS_CURVE ? start + shift : average, - curvature: PARABOLIC_MASS_CURVE ? (end - start) / (2 * span) : 0, - }; -} -export function evaluate(segment, t) { - return segment.v0 + segment.a0 * t + segment.curvature * t * t; -} -/** 구간 2차식이 `[tLo, tHi]` 안에서 `level`을 처음 지나는 t. 없으면 null. */ -function solveSegment(segment, level, tLo, tHi) { - const c = segment.v0 - level; - const roots = []; - if (Math.abs(segment.curvature) < 1e-12) { - if (Math.abs(segment.a0) > EPSILON) - roots.push(-c / segment.a0); - } - else { - const discriminant = segment.a0 * segment.a0 - 4 * segment.curvature * c; - if (discriminant < 0) - return null; - const root = Math.sqrt(discriminant); - roots.push((-segment.a0 - root) / (2 * segment.curvature)); - roots.push((-segment.a0 + root) / (2 * segment.curvature)); - } - const inside = roots - .filter((t) => t >= tLo - 1e-9 && t <= tHi + 1e-9) - .sort((left, right) => left - right); - return inside.length ? Math.min(Math.max(inside[0], tLo), tHi) : null; -} -/** - * `[fromX, toX]`를 왼쪽부터 훑어 곡선이 `level`을 **처음** 지나는 x. 없으면 null. - * 평형선·경계현·반종거 수평선의 교점이 전부 이 함수 하나로 나온다. - */ -export function crossFrom(points, fromX, toX, level) { - if (!(toX > fromX)) - return null; - for (let index = 1; index < points.length; index += 1) { - const segment = segmentOf(points, index); - if (!segment) - continue; - const x0 = Math.max(segment.x0, fromX); - const x1 = Math.min(segment.x0 + segment.span, toX); - if (!(x1 > x0)) - continue; - const t = solveSegment(segment, level, x0 - segment.x0, x1 - segment.x0); - if (t !== null) - return segment.x0 + t; - // 구간 끝에서 부호가 바뀌었는데 2차 근을 못 찾으면(수치 한계) 직선으로 떨어뜨린다. - // 교점을 놓치면 블록 경계가 통째로 밀리므로 여기서 반드시 하나는 돌려줘야 한다. - const y0 = evaluate(segment, x0 - segment.x0); - const y1 = evaluate(segment, x1 - segment.x0); - if ((y0 - level) * (y1 - level) <= 0) { - if (Math.abs(y1 - y0) < EPSILON) - return x0; - return x0 + ((level - y0) / (y1 - y0)) * (x1 - x0); - } - } - return null; -} -/** 곡선의 극값 인덱스 수열(양 끝점 포함). 기울기 부호가 바뀌는 자리가 극값이다. */ -export function extremaIndices(points) { - const list = [0]; - let sign = 0; - for (let index = 1; index < points.length; index += 1) { - const delta = points[index].cumulative_volume_m3 - points[index - 1].cumulative_volume_m3; - if (Math.abs(delta) < EPSILON) - continue; - const next = delta > 0 ? 1 : -1; - if (sign !== 0 && next !== sign && list[list.length - 1] !== index - 1) - list.push(index - 1); - sign = next; - } - if (list[list.length - 1] !== points.length - 1) - list.push(points.length - 1); - return list; -} -/** - * 잔진동 제거. 진폭이 `minSwing` 미만인 극값은 **짝이 되는 이웃과 함께** 지운다 — - * 하나만 지우면 극대·극소 교대가 깨져 뒤 계산이 방향을 잃는다. 양 끝점은 남긴다. - */ -export function pruneExtrema(points, indices, minSwing) { - const valueOf = (index) => points[index].cumulative_volume_m3; - let list = indices.slice(); - let changed = true; - while (changed && list.length > 2) { - changed = false; - for (let i = 1; i < list.length - 1; i += 1) { - const left = Math.abs(valueOf(list[i]) - valueOf(list[i - 1])); - const right = Math.abs(valueOf(list[i]) - valueOf(list[i + 1])); - if (Math.min(left, right) >= minSwing) - continue; - const partner = left <= right ? i - 1 : i + 1; - const drop = new Set([i]); - if (partner > 0 && partner < list.length - 1) - drop.add(partner); - list = list.filter((_, position) => !drop.has(position)); - changed = true; - break; - } - } - return list; -} diff --git a/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul_settle.js b/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul_settle.js deleted file mode 100644 index 530e002e..00000000 --- a/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul_settle.js +++ /dev/null @@ -1,215 +0,0 @@ -/* ============================================================================= - * common_util_mass_haul_settle.ts - * 토량 분배의 **정산** 쪽 — 지반유형 안분 · 장비 선정 · 잉여/부족 상쇄 · 직렬화. - * - * `common_util_mass_haul_balance`가 700줄을 넘겨, 「곡선에서 블록·띠를 어떻게 뽑나」(그쪽)와 - * 「뽑은 물량을 무엇으로 나누고 어디로 보내나」(여기)로 갈랐다. - * - * 의존 방향은 **한 쪽뿐**이다: Balance → Settle(함수), Settle → Balance(타입만). - * 타입 import는 실행 시 사라지므로 순환이 생기지 않는다. - * ========================================================================== */ -import { EPSILON } from "./common_util_mass_haul_curve.js"; -/** 환산계수가 비어 있거나 값이 이상하면 1.0으로 떨어뜨려 계산이 멈추지 않게 한다. */ -function factorFor(conversion, ground) { - const factor = conversion?.[ground]?.compacted; - return Number.isFinite(factor) && factor > 0 ? factor : 1; -} -/** - * `[fromX, toX]` 절토 구간의 지반유형 구성(다짐상태 가중치). 구간이 측점 사이를 반만 - * 물면 그 비율만큼만 센다 — 경계현 교점은 측점에 딱 떨어지지 않는다. - */ -export function cutMix(points, fromX, toX, conversion) { - const mix = { 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, fromX, toX) { - 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, volume) { - 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) { - 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, distanceM) { - 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, residuals, conversion, limits) { - const center = (residual) => (residual.from_m + residual.to_m) / 2; - const level = (residual) => (residual.level_from_m3 + residual.level_to_m3) / 2; - const transfers = []; - const pairs = []; - 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) { - const round = (value) => 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), - // 구조물 몫 — **받은 값**과 **실제로 먹은 값**을 함께 남긴다. 「통로만 있고 값이 안 흐른다」를 - // 저장분에서 바로 가릴 수 있어야 한다(2026-09-09 그 사고가 세 번 났다). - // `null` 은 「아직 안 옴」, `0` 은 「없음」이다 — 눅이지 않는다. - collected_stone_deduction_m3: plan.collected_stone_deduction_m3, - collected_stone_deducted_m3: round(plan.collected_stone_deducted_m3), - structure_spoil_m3: plan.structure_spoil_m3, - structure_spoil_added_m3: round(plan.structure_spoil_added_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), - // 띠의 기하 — B07 토적도가 경계현·평균운반거리 현을 이 값으로 그린다. - // 여기서 함께 저장하지 않으면 파이썬 쪽에 곡선 보간·배분 로직을 복제해야 한다. - level_base_m3: round(band.level_base_m3), - level_apex_m3: round(band.level_apex_m3), - boundary_from_m: round(band.boundary_from_m), - boundary_to_m: round(band.boundary_to_m), - haul_from_m: round(band.haul_from_m), - haul_to_m: round(band.haul_to_m), - })), - })), - residuals: plan.residuals.map((residual) => ({ - index: residual.index, - kind: residual.kind, - // 평형선 단차 — 토적도 balloon을 곡선 옆 제 높이에 놓는 데 쓴다. - level_from_m3: round(residual.level_from_m3), - level_to_m3: round(residual.level_to_m3), - 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), - // ⚠ **지반을 모르는 몫**(㎥) — 갈래 합과 총량의 차이다. 지금은 **구조물 잔토**가 - // 그것이다(어느 지반에서 파낸 흙인지 우리가 판정하지 않는다). 받는 쪽이 덤프 단가를 - // 토사/암으로 가를 때 **이 몫만 근거가 없다**는 것을 알아야 하므로 값으로 낸다 - // (2026-09-09 — 안 내면 0 으로 눅여 토사로 세기 쉽다). - ground_unknown_m3: round(Math.max(residual.volume_m3 - (residual.ea_m3 + residual.rr_m3 + residual.br_m3), 0)), - })), - }; -} diff --git a/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul_types.js b/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul_types.js deleted file mode 100644 index 53453fc5..00000000 --- a/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/common_util_mass_haul_types.js +++ /dev/null @@ -1,12 +0,0 @@ -/* ============================================================================= - * common_util_mass_haul_types.ts - * 유토곡선 엔진이 읽는 최소 입력 타입 — B05(계획 기준)와 B06(정식 기준)이 함께 쓴다. - * - * 엔진은 횡단 설계 결과에서 **자기가 실제로 읽는 필드만** 알면 된다. 페이지별 API 응답 - * 타입(`B06_..._Api_Fetch.CrossSection` 등)을 그대로 요구하면 B05가 B06 모듈을 끌어와야 - * 하므로, 구조적 부분집합만 여기 두고 각 페이지 타입이 그대로 대입되게 한다. - * - * 지반유형·토량환산계수는 정의처가 `config_system.py` 한 곳이므로 여기서도 한 번만 적고, - * 페이지 API 타입 모듈이 이 정의를 재수출한다(사본 금지). - * ========================================================================== */ -export {}; diff --git a/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/runner.mjs b/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/runner.mjs deleted file mode 100644 index 3ae2a5cb..00000000 --- a/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/js/runner.mjs +++ /dev/null @@ -1,11 +0,0 @@ - -import { readFileSync, writeFileSync } from "node:fs"; -import { computeHaulPlan } from "./common_util_mass_haul_balance.js"; - -const [inputPath, outputPath] = process.argv.slice(2); -const input = JSON.parse(readFileSync(inputPath, "utf8")); -const out = input.cases.map((options) => { - const plan = computeHaulPlan(input.result, null, options); - return plan && { hauled_m3: plan.hauled_m3, borrow_m3: plan.borrow_m3, blocks: plan.blocks.length }; -}); -writeFileSync(outputPath, JSON.stringify(out)); diff --git a/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/output.json b/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/output.json deleted file mode 100644 index 56efec12..00000000 --- a/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0/output.json +++ /dev/null @@ -1 +0,0 @@ -[{"hauled_m3":10,"borrow_m3":1000,"blocks":1},{"hauled_m3":0,"borrow_m3":1000,"blocks":0}] \ No newline at end of file diff --git a/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다current b/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다current deleted file mode 120000 index 0ef2dcea..00000000 --- a/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다current +++ /dev/null @@ -1 +0,0 @@ -C:/Aislo_wt/aislo-b0506/UsersumsanAppDataLocalTempclaudept_unpruned/test_수량_계획은_작은_봉우리도_운반으로_센다0 \ No newline at end of file