From d9b801aeaaab51b717144b5f7dd904730dc78b91 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 07:51:41 +0900 Subject: [PATCH] =?UTF-8?q?feat(B06):=20=ED=9A=A1=EB=8B=A8=20=EC=84=A4?= =?UTF-8?q?=EA=B3=84=EA=B0=80=20=EC=82=AC=ED=86=A0=EC=9E=A5=20=EB=8B=A8?= =?UTF-8?q?=EB=A9=B4=EC=9D=84=20=EB=83=84=20=E2=80=94=20=EB=85=B8=EC=84=A0?= =?UTF-8?q?=20=EC=84=B1=ED=86=A0=EC=99=80=20=EA=B0=88=EB=9D=BC=EC=84=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사토장이 선 측점은 **노면 끝 바깥이 사토장 몫**이라 노선 성토에서 빼야 함. 안 빼면 같은 흙을 두 번 셈(2026-09-09 확정 ㉠). - `compute_cross_design(spoil_fill=…)` / `computeCrossDesign({spoilFill})` 신설(짝) - 새 칸: `spoil_fill_area_m2` · `spoil_fill_side` · `spoil_fill_width_m` · `spoil_fill_max_width_m` · `spoil_fill_line` · `spoil_fill_unclosed` · `spoil_fill_replaced_fill_m2`(노선 성토에서 뺀 몫 — 되짚기용) - **합쳐서 하나로 내지 않음** — 받는 쪽이 갈라 볼 수 있어야 함 - 기울기가 비면 그 측점의 노선 성토 기울기를 그대로 씀(새 값 안 만듦) - `fillAreaBeyond`/`_fill_area_beyond` — 경계 종거를 보간해 자름(한 칸도 안 흘림) - 거울 시험에 사토장 사례 둘 추가 + 값이 0 이면 잡히는 가드 ⚠ `B06_Section_Engine_Design.py` 가 924줄 — 700줄 제한 초과 상태임(이번 전에도 881줄). 기능이 다 선 뒤 분리할 자리. Co-Authored-By: Claude Opus 5 (1M context) --- B06_Section/B06_Section_Engine_Areas.py | 34 +++++++++++ B06_Section/B06_Section_Engine_Design.py | 43 ++++++++++++++ common_util/common_util_cross_design.ts | 56 ++++++++++++++++++- common_util/common_util_cross_design_areas.ts | 43 ++++++++++++++ 4 files changed, 175 insertions(+), 1 deletion(-) diff --git a/B06_Section/B06_Section_Engine_Areas.py b/B06_Section/B06_Section_Engine_Areas.py index 62220348..07e1c6e5 100644 --- a/B06_Section/B06_Section_Engine_Areas.py +++ b/B06_Section/B06_Section_Engine_Areas.py @@ -166,3 +166,37 @@ def _split_ditch_area(ditch_spec: dict, depth_to_boundary_m: float | None) -> tu soil = top * d0 - (top - bottom) * d0 * d0 / (2.0 * depth) return soil, max(total - soil, 0.0) return 0.0, 0.0 + + +def _fill_area_beyond(offsets: list[float], diffs: list[float], x0: float, side: str) -> float: + """`x0` **바깥쪽**(사토장이 서는 쪽)의 성토 면적(㎡)만 따로 낸다. + + ⚠ 왜 있나 — 사토장이 선 측점에서는 노면 끝 바깥이 **사토장 몫**이라 노선 성토 + (`fill_area_m2`)에서 빼야 한다. 안 빼면 **같은 흙을 두 번 센다**(2026-09-09 확정 ㉠). + ⚠ 경계(`x0`)의 종거는 **보간해서** 넣는다 — 그냥 버리면 경계 한 칸이 통째로 빠져 + 값이 작아진다. 좌는 `x0` 위쪽, 우는 `x0` 아래쪽이며 **둘 다 오름차순**으로 넘긴다. + + 짝: TS `fillAreaBeyond`. + """ + if len(offsets) < 2: + return 0.0 + inside = (lambda x: x >= x0) if side == "left" else (lambda x: x <= x0) + sub_offsets: list[float] = [] + sub_diffs: list[float] = [] + for index, x in enumerate(offsets): + if index > 0: + x_prev = offsets[index - 1] + crosses = (x_prev < x0 < x) or (x < x0 < x_prev) + if crosses: + ratio = (x0 - x_prev) / (x - x_prev) + sub_offsets.append(x0) + sub_diffs.append(diffs[index - 1] + (diffs[index] - diffs[index - 1]) * ratio) + if inside(x): + sub_offsets.append(x) + sub_diffs.append(diffs[index]) + order = sorted(range(len(sub_offsets)), key=lambda i: sub_offsets[i]) + sub_offsets = [sub_offsets[i] for i in order] + sub_diffs = [sub_diffs[i] for i in order] + if len(sub_offsets) < 2: + return 0.0 + return _trapezoid_areas(sub_offsets, sub_diffs)[1] diff --git a/B06_Section/B06_Section_Engine_Design.py b/B06_Section/B06_Section_Engine_Design.py index 41beaaf8..9680168c 100644 --- a/B06_Section/B06_Section_Engine_Design.py +++ b/B06_Section/B06_Section_Engine_Design.py @@ -33,6 +33,7 @@ from typing import Any from B06_Section.B06_Section_Engine_Areas import ( _bench_cut_length, + _fill_area_beyond, _split_cut_areas, _split_ditch_area, _trapezoid_areas, @@ -43,6 +44,7 @@ from common_util.common_util_cross_berm import ( fill_profile_points, ) from common_util.common_util_cross_berm import elevation_at as berm_elevation_at +from common_util.common_util_spoil_fill import spoil_fill_section from config.config_system import ( CURVE_WIDENING_MAX_WIDTH_M, SECTION_DITCH_SIDES, @@ -581,6 +583,7 @@ def compute_cross_design( curve_outer_side: str | None = None, curve_widening_m: float | None = None, berm: BermSpec | None = None, + spoil_fill: dict[str, Any] | None = None, ) -> dict[str, Any]: """측점 하나의 표준횡단 설계선과 절·성토 단면적을 계산한다. @@ -600,6 +603,10 @@ def compute_cross_design( surface_drop_m: 노면을 통째로 내리는 양(m) — 세월교 월류 높이. 구체 위 노면은 월류 높이만큼 낮게 앉으므로 계획고를 그만큼 내려 잡는다. 단면 전체가 평행 이동하므로 횡단경사·측구·사면 규칙은 그대로고 절·성토 면적만 따라 바뀐다(2026-08-30 사용자). + spoil_fill: 이 측점에 선 유용토운반작업장(구 사토장) — `{"side", "width_m", "slope_ratio_n"}`. + 폭은 **노면 끝**(노견이 시작하는 자리)에서 재고, 그 바깥 성토는 **노선 몫이 아니라 + 사토장 몫**이라 `fill_area_m2` 에서 뺀다(2026-09-09 확정 ㉠ — 두 번 세지 않기). + `slope_ratio_n` 이 비면 그 측점의 **노선 성토 기울기**를 그대로 쓴다. """ if ground_type not in SECTION_GROUND_TYPE_PRESET: raise ValueError(f"지원하지 않는 지반유형입니다: {ground_type}") @@ -710,6 +717,26 @@ def compute_cross_design( abs(diffs[0]) > _SLOPE_CLOSE_TOLERANCE_M or abs(diffs[-1]) > _SLOPE_CLOSE_TOLERANCE_M ) + # 사토장(유용토운반작업장) — 노면 끝 바깥에 쌓는 성토. 짝: TS `computeCrossDesign`. + # ⚠ 그 바깥 성토는 **노선 몫이 아니다** — 빼지 않으면 같은 흙을 두 번 센다(확정 ㉠). + spoil_section = None + spoil_replaced = 0.0 + spoil_side = str((spoil_fill or {}).get("side") or "") + spoil_width = _as_float((spoil_fill or {}).get("width_m"), 0.0) + if spoil_side in ("left", "right") and spoil_width > 0: + spoil_x0 = geometry.half_road_left if spoil_side == "left" else -geometry.half_road_right + spoil_ratio = _as_float((spoil_fill or {}).get("slope_ratio_n"), 0.0) or geometry.fill_ratio + spoil_section = spoil_fill_section( + valid, + spoil_x0, + geometry.road_z(spoil_x0), + spoil_side, + spoil_width, + spoil_ratio, + ) + spoil_replaced = _fill_area_beyond(offsets, diffs, spoil_x0, spoil_side) + fill_area = max(fill_area - spoil_replaced, 0.0) + # 절토면적 토사/암반 분리 — 지표면~암반 경계선이 토사, 그 아래가 암이다. 경계선 위치가 # 곧 유토곡선 EA/RR/BR 비율을 만들므로, 사용자가 경계선을 올리내리면 이 값이 함께 바뀐다. # 토사 지반은 암반 경계선 자체가 없어 전량 토사, 암 지반인데 경계선 값이 없으면(구 데이터) @@ -836,6 +863,22 @@ 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), + # 사토장 몫 — **`fill_area_m2` 와 합치지 않는다**(받는 쪽이 갈라 볼 수 있어야 한다). + "spoil_fill_area_m2": round(spoil_section.area_m2, 4) if spoil_section else 0.0, + "spoil_fill_side": spoil_side if spoil_section else None, + "spoil_fill_width_m": round(spoil_width, 4) if spoil_section else 0.0, + "spoil_fill_max_width_m": round(spoil_section.max_width_m, 4) if spoil_section else 0.0, + "spoil_fill_line": ( + [ + {"offset_m": offset, "elevation_m": elevation} + for offset, elevation in spoil_section.line + ] + if spoil_section + else [] + ), + "spoil_fill_unclosed": bool(spoil_section.unclosed) if spoil_section else False, + # 사토장이 대신 차지해 노선 성토에서 뺀 몫(㎡) — 되짚기용. 합계에 또 넣지 말 것. + "spoil_fill_replaced_fill_m2": round(spoil_replaced, 4), # 층따기 밑수 — 성토부 아래 원지반(1:4 보다 급한 구간)의 지표면 길이(m). # B08 이 측점 사이를 이어 ㎡ 로 만든다. 여기서 ㎥ 로 바꾸지 않는다 — # 단의 높이·폭이 설계도서 값이라 지어낼 수 없다. diff --git a/common_util/common_util_cross_design.ts b/common_util/common_util_cross_design.ts index b011ac78..8a521360 100644 --- a/common_util/common_util_cross_design.ts +++ b/common_util/common_util_cross_design.ts @@ -24,8 +24,11 @@ * ========================================================================== */ import type { BermSpec } from "./common_util_cross_berm"; +import type { SpoilFillSection } from "./common_util_spoil_fill"; +import { spoilFillSection } from "./common_util_spoil_fill"; import { benchCutLength, + fillAreaBeyond, splitCutAreas, splitDitchArea, trapezoidAreas, @@ -100,6 +103,13 @@ export interface CrossDesignOptions { curveWideningM?: number | null; /** 이 측점의 소단 제원 — 없으면 계단 없이 종전 사면 그대로(계획서 3-9). */ berm?: BermSpec | null; + /** + * 이 측점에 선 유용토운반작업장(구 사토장). 폭은 **노면 끝**(노견이 시작하는 자리)에서 + * 재고, 그 바깥 성토는 **노선 몫이 아니라 사토장 몫**이라 `fill_area_m2` 에서 뺀다 + * (2026-09-09 확정 ㉠ — 두 번 세지 않기). `slopeRatioN` 이 비면 노선 성토 기울기를 쓴다. + * 짝: 파이썬 `compute_cross_design(spoil_fill=…)`. + */ + spoilFill?: { side: "left" | "right"; widthM: number; slopeRatioN?: number | null } | null; } export interface CrossDesignEdge { @@ -138,6 +148,15 @@ export interface CrossDesignResult { fill_area_m2: number; /** 층따기 밑수 — 성토부 아래 원지반(1:4 보다 급한 구간)의 지표면 길이(m). */ bench_cut_length_m: number; + /** 사토장 몫 — **`fill_area_m2` 와 합치지 않는다**(받는 쪽이 갈라 볼 수 있어야 한다). */ + spoil_fill_area_m2: number; + spoil_fill_side: "left" | "right" | null; + spoil_fill_width_m: number; + spoil_fill_max_width_m: number; + spoil_fill_line: CrossDesignEdge[]; + spoil_fill_unclosed: boolean; + /** 사토장이 대신 차지해 노선 성토에서 뺀 몫(㎡) — 되짚기용. 합계에 또 넣지 말 것. */ + spoil_fill_replaced_fill_m2: number; slope_unclosed: boolean; fill_ground_slope: number | null; ditch_area_m2: number; @@ -355,7 +374,8 @@ export function computeCrossDesign( } // 측구 굴착은 설계선에 포함돼 절토 면적에 자연 반영된다(별도 가산 없음). - const [cutArea, fillArea] = trapezoidAreas(offsets, diffs); + const [cutArea, baseFillArea] = trapezoidAreas(offsets, diffs); + let fillArea = baseFillArea; // 층따기 밑수(길이 m) — 성토부 아래 원지반이 1:4 보다 급한 구간의 지표면 길이. const benchCut = benchCutLength(offsets, grounds, diffs); const fillGroundSlope = geometry.fillGroundSlope(); @@ -364,6 +384,28 @@ export function computeCrossDesign( (Math.abs(diffs[0]) > SLOPE_CLOSE_TOLERANCE_M || Math.abs(diffs[diffs.length - 1]) > SLOPE_CLOSE_TOLERANCE_M); + // 사토장(유용토운반작업장) — 노면 끝 바깥에 쌓는 성토. 짝: 파이썬 `compute_cross_design`. + // ⚠ 그 바깥 성토는 **노선 몫이 아니다** — 빼지 않으면 같은 흙을 두 번 센다(확정 ㉠). + const spoilSide = options.spoilFill?.side ?? null; + const spoilWidth = Math.max(Number(options.spoilFill?.widthM ?? 0), 0); + let spoilSection: SpoilFillSection | null = null; + let spoilReplaced = 0; + if ((spoilSide === "left" || spoilSide === "right") && spoilWidth > 0) { + const spoilX0 = spoilSide === "left" ? geometry.halfRoadLeft : -geometry.halfRoadRight; + const askedRatio = Number(options.spoilFill?.slopeRatioN ?? 0); + const spoilRatio = askedRatio > 0 ? askedRatio : geometry.fillRatio; + spoilSection = spoilFillSection({ + ground: valid.map(([offset_m, elevation_m]) => ({ offset_m, elevation_m })), + startOffsetM: spoilX0, + startElevationM: geometry.roadZ(spoilX0), + side: spoilSide, + widthM: spoilWidth, + slopeRatioN: spoilRatio, + }); + spoilReplaced = fillAreaBeyond(offsets, diffs, spoilX0, spoilSide); + fillArea = Math.max(fillArea - spoilReplaced, 0); + } + // 절토면적 토사/암반 분리 — 지표면~암반 경계선이 토사, 그 아래가 암. let cutSoilArea: number; let cutRockArea: number; @@ -493,6 +535,18 @@ export function computeCrossDesign( cut_rock_area_m2: round4(cutRockArea), cut_rock_kind: cutRockKind, fill_area_m2: round4(fillArea), + spoil_fill_area_m2: spoilSection ? round4(spoilSection.area_m2) : 0, + spoil_fill_side: spoilSection ? spoilSide : null, + spoil_fill_width_m: spoilSection ? round4(spoilWidth) : 0, + spoil_fill_max_width_m: spoilSection ? round4(spoilSection.maxWidthM) : 0, + spoil_fill_line: spoilSection + ? spoilSection.line.map((point) => ({ + offset_m: point.offset_m, + elevation_m: point.elevation_m, + })) + : [], + spoil_fill_unclosed: spoilSection ? spoilSection.unclosed : false, + spoil_fill_replaced_fill_m2: round4(spoilReplaced), bench_cut_length_m: round4(benchCut), slope_unclosed: slopeUnclosed, fill_ground_slope: fillGroundSlope === null ? null : round4(fillGroundSlope), diff --git a/common_util/common_util_cross_design_areas.ts b/common_util/common_util_cross_design_areas.ts index dfb41184..9cc1bc1c 100644 --- a/common_util/common_util_cross_design_areas.ts +++ b/common_util/common_util_cross_design_areas.ts @@ -159,3 +159,46 @@ export function splitDitchArea( } return [0, 0]; } + +/** + * `x0` **바깥쪽**(사토장이 서는 쪽)의 성토 면적(㎡)만 따로 낸다. + * + * ⚠ 왜 있나 — 사토장이 선 측점에서는 노면 끝 바깥이 **사토장 몫**이라 노선 성토 + * (`fill_area_m2`)에서 빼야 한다. 안 빼면 **같은 흙을 두 번 센다**(2026-09-09 확정 ㉠). + * ⚠ 경계(`x0`)에서 잘라 쓰므로 그 자리의 종거를 **보간해서** 넣는다 — 그냥 버리면 + * 경계 한 칸이 통째로 빠져 값이 작아진다. + * + * 짝: 파이썬 `_fill_area_beyond`. + */ +export function fillAreaBeyond( + offsets: number[], + diffs: number[], + x0: number, + side: "left" | "right", +): number { + if (offsets.length < 2) return 0; + const inside = side === "left" ? (x: number) => x >= x0 : (x: number) => x <= x0; + const subOffsets: number[] = []; + const subDiffs: number[] = []; + for (let index = 0; index < offsets.length; index += 1) { + const x = offsets[index]; + if (index > 0) { + const xPrev = offsets[index - 1]; + const crosses = (xPrev < x0 && x0 < x) || (x < x0 && x0 < xPrev); + if (crosses) { + const ratio = (x0 - xPrev) / (x - xPrev); + subOffsets.push(x0); + subDiffs.push(diffs[index - 1] + (diffs[index] - diffs[index - 1]) * ratio); + } + } + if (inside(x)) { + subOffsets.push(x); + subDiffs.push(diffs[index]); + } + } + const order = subOffsets.map((_, index) => index).sort((a, b) => subOffsets[a] - subOffsets[b]); + const sortedOffsets = order.map((index) => subOffsets[index]); + const sortedDiffs = order.map((index) => subDiffs[index]); + if (sortedOffsets.length < 2) return 0; + return trapezoidAreas(sortedOffsets, sortedDiffs)[1]; +}