From 3898cb001913abacbcb2068b8aa0747fcc0eb11e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 14:56:42 +0900 Subject: [PATCH 1/2] =?UTF-8?q?refactor(380-1):=20=EC=82=AC=ED=86=A0?= =?UTF-8?q?=EC=9E=A5=20=ED=9A=A1=EB=8B=A8=20=EA=B7=B8=EB=A6=BC=20=C2=B7=20?= =?UTF-8?q?=EC=82=AC=ED=86=A0=20=EB=8B=A8=EB=A9=B4=20=EC=85=88=20=EC=A7=80?= =?UTF-8?q?=EC=9B=80=20=E2=80=94=20=EB=B6=80=ED=92=88=20=EC=B2=98=EC=9D=8C?= =?UTF-8?q?=EA=B0=92=20=ED=98=95=EC=83=81=20=C2=B7=203D=20=EC=97=86?= =?UTF-8?q?=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 부품 처음값 spoil_bank 형상 · 3D = none · 형상 부품 「사토(성토 채움)」 지움 - 사토 단면 셈 파이썬 · TS 짝 지움(Engine_SpoilFill · common_util_spoil_fill · spoil_fill 옵션 · fill_area_beyond) - 카드 그림 · 면적표 줄 · 모달 띠 · 고름 선 · B07 표 「사토장」 칸 지움(빈 줄로 되돌림) - B08 수량 · 유토 배분(haul-plan) 미사용 확인 · 남은 사토 값 = [저장] 서버 재계산 · 브라우저 단계 다시 셈으로 정리 - 시험 고쳐 씀: 322_1 · 349_0n · b06_spoil_fill_allocation(남은 값 정리) · spoil_fill_mirror(죽은 길) · 52_9 · cross_design_mirror · 150 · 164 · 179_4 · 316 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn --- B06_Section/B06_Section_Api_Types.ts | 19 - B06_Section/B06_Section_Cross_Refresh.ts | 31 +- B06_Section/B06_Section_Engine_Areas.py | 34 -- B06_Section/B06_Section_Engine_Design.py | 43 --- B06_Section/B06_Section_Engine_SpoilFill.py | 326 ------------------ .../B06_Section_Server_Calc_Prebuild.py | 4 - B06_Section/B06_Section_UI_Cross_Areas.ts | 15 - .../B06_Section_UI_Cross_Detail_Pick.ts | 1 - .../B06_Section_UI_Cross_Detail_Select.ts | 1 - .../B06_Section_UI_Cross_Detail_Table.ts | 7 +- B06_Section/B06_Section_UI_Cross_SpoilFill.ts | 95 ----- B06_Section/B06_Section_UI_Cross_View.ts | 4 - .../B06_Section_UI_Style_Cross_Areas.css | 28 -- .../B06_Section_UI_Style_Cross_Detail.css | 8 +- B06_Section/CLAUDE.md | 2 +- .../logic/B06_Section_Cross_Enforce.ts | 124 +------ B06_Section/logic/B06_Section_Cross_Stage.ts | 50 +-- .../logic/B06_Section_UI_Cross_Detail_Geom.ts | 1 - .../B07_DesignDetail_Engine_Cad_Table.py | 8 +- .../B07_DesignDetail_Engine_Cross_Quantity.py | 3 - ...07_DesignDetail_Engine_Cross_Table_Edit.py | 5 +- common_util/common_util_cross_design.ts | 56 +-- common_util/common_util_cross_design_areas.ts | 43 --- common_util/common_util_spoil_fill.py | 223 ------------ common_util/common_util_spoil_fill.ts | 241 ------------- common_util/common_util_structure_catalog.ts | 2 +- common_util/common_util_structure_parts.json | 10 +- resources/tester/test_150_detail_pick.py | 6 +- resources/tester/test_164_detail_zoom.py | 1 - resources/tester/test_179_4_detail_table.py | 2 +- resources/tester/test_316_defaults.py | 1 - resources/tester/test_322_1_catalog_mirror.py | 5 +- .../test_349_0n_cross_enforce_mirror.py | 32 +- resources/tester/test_52_9_spoil_borrow.py | 20 +- .../tester/test_b06_cross_design_mirror.py | 50 +-- .../tester/test_b06_spoil_fill_allocation.py | 232 +++---------- resources/tester/test_spoil_fill_mirror.py | 168 ++------- 37 files changed, 119 insertions(+), 1782 deletions(-) delete mode 100644 B06_Section/B06_Section_Engine_SpoilFill.py delete mode 100644 B06_Section/B06_Section_UI_Cross_SpoilFill.ts delete mode 100644 common_util/common_util_spoil_fill.py delete mode 100644 common_util/common_util_spoil_fill.ts diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index 1c614bb0a..6711fed51 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -518,25 +518,6 @@ export interface CrossDesign { /** 암반부에 적용할 지반유형(`ripping_rock`/`blasting_rock`). 토사 측점은 null. */ cut_rock_kind?: GroundType | null; fill_area_m2: number; - /** - * 사토장(유용토운반작업장) 몫 — **`fill_area_m2` 와 합치지 않는다**(2026-09-09 확정 ㉠). - * 노면 끝 바깥은 노선 성토가 아니라 사토장 성토라, 받는 쪽이 갈라 볼 수 있어야 한다. - * 사토장이 없는 측점은 전부 0·null 이다(구 데이터에는 아예 없어 optional). - */ - 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?: Array<{ offset_m: number; elevation_m: number }>; - spoil_fill_unclosed?: boolean; - /** 사토장이 대신 차지해 노선 성토에서 뺀 몫(㎡) — 되짚기용. 합계에 또 넣지 말 것. */ - spoil_fill_replaced_fill_m2?: number; - /** 구간(구조물) 단위 값 — 측점마다 같은 값이 실린다. 말풍선·수량이 되짚는 데 쓴다. */ - spoil_fill_capacity_m3?: number; - spoil_fill_placed_m3?: number; - spoil_fill_unplaced_m3?: number; - spoil_fill_structure_id?: string | null; - spoil_fill_extra_distance_m?: number | null; /** 성토측 자연 지반 경사(rise/run). 자연방토 판정 입력. 성토측이 없으면 null. */ fill_ground_slope?: number | null; /** diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index 2f72e8c68..3b00ffc4d 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -117,23 +117,7 @@ export const USER_TOUCHED_KEYS = [ ] as const; /** 다시 계산해도 살려 두는 값 — 위 사용자 값에 **상태를 나르는 둘**을 더한 것. */ -// 사토장 구간값 — **브라우저가 못 만드는 값**이라 이어 붙인다. 구간 전체를 봐야 나오는 -// 값이고(용량 배분) 정본은 [저장] 때 서버가 다시 낸다. 안 이으면 계획선을 만지는 순간 -// 말풍선에서 「구간 용량 …」이 사라진다. -const SPOIL_KEYS = [ - "spoil_fill_capacity_m3", - "spoil_fill_placed_m3", - "spoil_fill_unplaced_m3", - "spoil_fill_structure_id", - "spoil_fill_extra_distance_m", -] as const; - -const PRESERVED_KEYS = [ - "status", - "pavement_suggested", - ...USER_TOUCHED_KEYS, - ...SPOIL_KEYS, -] as const; +const PRESERVED_KEYS = ["status", "pavement_suggested", ...USER_TOUCHED_KEYS] as const; /** * 포장 = 노면공 · 물넘이 범위 안만(138-1) — 측점에 얹힌 제원(`pavement` · `ford_pavement`)이 곧 범위다. @@ -461,19 +445,6 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { curveWideningM: section.curve_widening_m ?? null, shoulderExtendLeftM: extendLeft, shoulderExtendRightM: extendRight, - // 사토장 — **저장분 폭을 그대로 잇는다**. 폭은 구간 용량에서 서버가 정한 값이라 - // 브라우저가 다시 풀지 않는다(다시 풀면 그 측점만 폭이 달라져 작업장 모양이 깨진다). - // 이어 붙이지 않으면 계획선을 만질 때마다 사토장이 그림에서 사라진다. - spoilFill: - typeof design.spoil_fill_width_m === "number" && - design.spoil_fill_width_m > 0 && - (design.spoil_fill_side === "left" || design.spoil_fill_side === "right") - ? { - side: design.spoil_fill_side, - widthM: design.spoil_fill_width_m, - slopeRatioN: null, - } - : null, }; // 국부 연산(346-2) — 입력 지문(지반 표본 · 계획고 · 횡단 입력 · 구조물 맥락 · 표준값)이 같고 // 설계가 그때 낸 그 객체면 앞 결과 그대로. 계획고 ▲ 한 번에 다시 세는 측점은 곡선 둘레 몇 곳뿐. diff --git a/B06_Section/B06_Section_Engine_Areas.py b/B06_Section/B06_Section_Engine_Areas.py index 07e1c6e50..62220348c 100644 --- a/B06_Section/B06_Section_Engine_Areas.py +++ b/B06_Section/B06_Section_Engine_Areas.py @@ -166,37 +166,3 @@ 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 ca0386692..b6cc10149 100644 --- a/B06_Section/B06_Section_Engine_Design.py +++ b/B06_Section/B06_Section_Engine_Design.py @@ -31,7 +31,6 @@ 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, @@ -42,7 +41,6 @@ from B06_Section.B06_Section_Engine_Design_Geometry import ( from common_util.common_util_cross_berm import ( BermSpec, ) -from common_util.common_util_spoil_fill import spoil_fill_section from config.config_system import ( CURVE_WIDENING_MAX_WIDTH_M, SECTION_DITCH_SIDES, @@ -198,7 +196,6 @@ 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, shoulder_extend_left_m: float = 0.0, shoulder_extend_right_m: float = 0.0, tilted: bool = False, @@ -225,12 +222,8 @@ def compute_cross_design( surface_drop_m: 노면을 통째로 내리는 양(m) — 세월교 월류 높이. 구체 위 노면은 월류 높이만큼 낮게 앉으므로 계획고를 그만큼 내려 잡는다. 단면 전체가 평행 이동하므로 횡단경사·측구·사면 규칙은 그대로고 절·성토 면적만 따라 바뀐다(2026-08-30 사용자). - spoil_fill: 이 측점에 선 유용토운반작업장(구 사토장) — `{"side", "width_m", "slope_ratio_n"}`. shoulder_extend_left_m · shoulder_extend_right_m: 설계선 쉬프트 = 쪽마다 사면 이동(m · ≥ 0 · 139-6) — 사용자 값 · 저장분은 `stored_shoulder_extend`. TS 짝 `shoulderExtendLeftM` 등. - 폭은 **노면 끝**(노견이 시작하는 자리)에서 재고, 그 바깥 성토는 **노선 몫이 아니라 - 사토장 몫**이라 `fill_area_m2` 에서 뺀다(2026-09-09 확정 ㉠ — 두 번 세지 않기). - `slope_ratio_n` 이 비면 그 측점의 **노선 성토 기울기**를 그대로 쓴다. """ if ground_type not in SECTION_GROUND_TYPE_PRESET: raise ValueError(f"지원하지 않는 지반유형입니다: {ground_type}") @@ -349,26 +342,6 @@ 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 비율을 만들므로, 사용자가 경계선을 올리내리면 이 값이 함께 바뀐다. # 토사 지반은 암반 경계선 자체가 없어 전량 토사, 암 지반인데 경계선 값이 없으면(구 데이터) @@ -503,22 +476,6 @@ 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/B06_Section/B06_Section_Engine_SpoilFill.py b/B06_Section/B06_Section_Engine_SpoilFill.py deleted file mode 100644 index eff47a1be..000000000 --- a/B06_Section/B06_Section_Engine_SpoilFill.py +++ /dev/null @@ -1,326 +0,0 @@ -"""사토장(유용토운반작업장) — 용량에서 **폭을 정해** 측점마다 단면을 세운다. - -왜 여기 있나 - 사용자는 「이 구간에 ○㎥ 를 쌓겠다」고 정한다. 그런데 횡단 단면은 **폭**을 알아야 - 그려진다. 그래서 그 구간 측점들을 한꺼번에 보고 **폭 하나**를 되풀이로 찾는다. - (측점마다 폭을 달리하면 실제로 못 쌓는 모양이 나온다 — 작업장은 폭이 일정하다.) - - `enforce_ford_surface_drops` 와 같은 자리·같은 방식이다 — **저장분을 쓰는 시점에** - 바로잡고, 저장분과 지금 값이 다를 때만 다시 계산한다. - -정하는 것과 안 정하는 것 - ⚠ **기울기·적치높이 기본값을 지어내지 않는다** — 지식DB - `01_임도/02_상세설계/유용토운반작업장.md` §4 가 「근거에 없다. 사용자 협의 없이 - 기본값을 만들지 않는다」로 못 박았다. 기울기가 비면 **그 측점의 노선 성토 기울기**를 - 그대로 쓰고(이미 설계된 값), 높이는 **노면 끝 높이**로 정해진다. - ⚠ **용량이 없으면 아무것도 안 세운다** — 폭을 정할 근거가 없다. - ⚠ **지반 샘플이 있는 데까지만 넓힌다.** 상한에서도 용량이 남으면 그 몫은 - `unplaced_m3` 로 드러낸다 — 임의로 더 넓히지 않는다. - -짝(349-0n): `spoil_sites` · `enforce_spoil_fills` ↔ `B06_Section/logic/B06_Section_Cross_Enforce.ts` -(거울 시험 `test_349_0n_cross_enforce_mirror.py`). -""" - -from __future__ import annotations - -from pathlib import Path -from typing import Any - -from B06_Section.B06_Section_Engine_Design import ( - compute_cross_design, - stored_shoulder_extend, - stored_tilt, -) -from common_util.common_util_structure_catalog import catalog_row -from common_util.common_util_structure_face_role import structure_face_role - -#: 폭을 좁혀 가는 이분법 반복 수. TS 짝(`solveSpoilWidthM`)과 같은 값이다. -_SOLVE_STEPS = 24 -#: 상한을 재려고 한 번 크게 넣어 보는 폭(m). 실제로는 지반 샘플에서 잘린다. -_MAX_PROBE_WIDTH_M = 1000.0 -#: 「자동(성토 쪽)」 — 등록부 `side` 의 기본 선택지. C군 구조물과 같은 낱말이다. -_SIDE_AUTO = "자동(성토 쪽)" -_SIDE_WORDS = {"좌": "left", "우": "right"} - - -def _section_at(cross_sections: list[dict[str, Any]], chainage_m: float) -> list[dict]: - """기준 측점에 가장 가까운 측점 하나(PLAN 54-7 · 가운데면 앞 측점). - - **새 측점을 만들지 않는다**(확정 ③). - """ - placed = [section for section in cross_sections if section.get("chainage_m") is not None] - if not placed: - return [] - return [ - min( - placed, - key=lambda item: ( - abs(float(item["chainage_m"]) - chainage_m), - float(item["chainage_m"]), - ), - ) - ] - - -def _span_of(cross_sections: list[dict[str, Any]], section: dict[str, Any]) -> float: - """그 측점의 대표 길이(m) — 앞뒤 측점과의 절반씩. 끝 측점은 한쪽만.""" - chainages = sorted( - {float(item["chainage_m"]) for item in cross_sections if item.get("chainage_m") is not None} - ) - here = float(section["chainage_m"]) - index = chainages.index(here) - left = chainages[index - 1] if index else here - right = chainages[index + 1] if index + 1 < len(chainages) else here - return (right - left) / 2 - - -def _has_spoil_value(design: dict[str, Any]) -> bool: - """그 측점에 **사토장 값이 실제로 남아 있나**. 칸이 0 으로 있는 것은 값이 아니다.""" - for key in ("spoil_fill_area_m2", "spoil_fill_width_m", "spoil_fill_capacity_m3"): - try: - if float(design.get(key) or 0.0) > 0: - return True - except (TypeError, ValueError): - continue - return bool(design.get("spoil_fill_structure_id")) - - -def _side_of(design: dict[str, Any], option: Any) -> str | None: - """쌓는 쪽 — 「좌·우」면 그대로, 「자동」이면 그 측점의 **성토 쪽**.""" - word = str(option or "").strip() - if word in _SIDE_WORDS: - return _SIDE_WORDS[word] - if word and word != _SIDE_AUTO: - return None - mode = str(design.get("section_mode") or "") - for korean, key in _SIDE_WORDS.items(): - role, _reason = structure_face_role(mode, korean) - if role == "성토": - return key - return None - - -def spoil_sites(structures: list[Any]) -> list[dict[str, Any]]: - """배치된 사토장만 골라 쓰기 좋은 모양으로. 기준 측점·용량이 없으면 뺀다. - - 기준 측점이 없는 옛 구간 저장분은 시작 측점으로 읽음(PLAN 54-7). - """ - sites: list[dict[str, Any]] = [] - for item in structures: - type_id = getattr(item, "type_id", None) or ( - item.get("type_id") if isinstance(item, dict) else None - ) - if (catalog_row(str(type_id)) or {}).get("shape") != "spoil": # 표 형상(322-1) - continue - options = getattr(item, "options", None) - if options is None and isinstance(item, dict): - options = item.get("options") - options = options or {} - chainage = getattr(item, "chainage_m", None) - if isinstance(item, dict): - chainage = item.get("chainage_m") - if chainage is None: - chainage = item.get("start_m") - capacity = options.get("capacity_m3") - if chainage is None or capacity in (None, ""): - continue - try: - capacity_value = float(capacity) - except (TypeError, ValueError): - continue - if capacity_value <= 0: - continue - sites.append( - { - "structure_id": getattr(item, "structure_id", None) - or (item.get("structure_id") if isinstance(item, dict) else None), - "chainage_m": float(chainage), - "capacity_m3": capacity_value, - "side_option": options.get("side"), - "slope_ratio_n": options.get("fill_slope_ratio"), - "extra_distance_m": options.get("extra_distance_m"), - } - ) - return sites - - -def _volume_at( - width_m: float, - sections: list[dict[str, Any]], - spans: list[float], - sides: list[str | None], - slope_ratio_n: Any, - longitudinal: dict[str, Any], - standard: dict[str, Any] | None, - recompute, -) -> tuple[float, list[dict[str, Any] | None]]: - """그 폭으로 쌓이는 총 부피(㎥)와 측점별 설계. 평균단면적법이 아니라 대표길이 곱이다.""" - designs: list[dict[str, Any] | None] = [] - total = 0.0 - for section, span, side in zip(sections, spans, sides, strict=True): - if side is None or width_m <= 0: - designs.append(None) - continue - design = recompute(section, side, width_m, slope_ratio_n, longitudinal, standard) - designs.append(design) - if design: - total += float(design.get("spoil_fill_area_m2") or 0.0) * span - return total, designs - - -def enforce_spoil_fills( - longitudinal: dict[str, Any], - cross_sections: list[dict[str, Any]], - project_root: Path, - standard: dict[str, Any] | None = None, -) -> int: - """사토장이 선 측점의 설계를 다시 계산한다. 바뀐 측점 수를 돌려준다. - - 폭은 **구간 하나에 하나** — 용량에 맞춰 이분법으로 찾는다. 상한(지반 샘플이 있는 - 데까지)에서도 모자라면 그 폭으로 두고 못 담은 몫을 `spoil_fill_unplaced_m3` 로 낸다. - """ - from B05_Profile.B05_Profile_Structures_Repository import load_structures - from B06_Section.B06_Section_Engine_Design import curve_widening_args - from B06_Section.B06_Section_Router_Design import ( - USER_TOUCHED_KEYS, - stored_berm, - stored_cut_slope, - ) - from common_util.common_util_route_profile import design_elevation_from_longitudinal - - try: - _revision, structures = load_structures(str(project_root)) - except Exception: # noqa: BLE001 — 정본이 없으면 사토장도 없다 - structures = [] - sites = spoil_sites(structures) - - def recompute(section, side, width_m, slope_ratio_n, longitudinal_data, standard_spec): - design = section.get("design") - if not isinstance(design, dict): - return None - chainage = float(section.get("chainage_m", 0.0)) - try: - return compute_cross_design( - section.get("samples", []), - design_elevation_from_longitudinal(longitudinal_data, chainage), - ground_type=str(design.get("ground_type") or "soil"), - section_mode=str(design.get("section_mode") or "left_cut"), - ditch_side=design.get("ditch_side"), - ditch_type=str(design.get("ditch_type") or "standard"), - paved=bool(design.get("paved", False)), - standard=standard_spec, - rock_boundary_offset_m=design.get("rock_boundary_offset_m"), - two_stage_slope=bool(design.get("two_stage_slope", False)), - cut_slope_ratio=stored_cut_slope(design), - ditch_enabled=design.get("ditch_enabled"), - ditch_choice=design.get("ditch_choice"), - surface_drop_m=float(design.get("surface_drop_m") or 0.0), - berm=stored_berm(design), - spoil_fill={ - "side": side, - "width_m": width_m, - "slope_ratio_n": slope_ratio_n, - }, - **curve_widening_args(section), - **stored_shoulder_extend(design), # 노견 확장(139-6) - **stored_tilt(design), # 물넘이 · 맹암거 자리 측구 없음 · 기울기(286-2 · 287-4) - ) - except (ValueError, KeyError): - return None - - changed = 0 - # ⚠⚠ **지운 사토장이 그림·수량에 남던 자리**(2026-09-09 화면 실측으로 잡음). - # `enforce_spoil_fills` 는 **얹기만** 했으므로 구조물을 지워도 저장분의 `spoil_fill_*` - # 가 그대로 남아 **횡단도에 계속 그려지고 면적표에도 섰다.** - # ⇒ 사토장이 덮지 않는 측점에 값이 남아 있으면 **사토장 없이 다시 계산**해 지운다. - # (노선 성토도 그때 원래 값으로 돌아온다 — 사토장 몫을 빼 두었기 때문이다.) - covered: set[float] = set() - for site in sites: - for section in _section_at(cross_sections, site["chainage_m"]): - covered.add(float(section["chainage_m"])) - for section in cross_sections: - design = section.get("design") - if not isinstance(design, dict): - continue - chainage = section.get("chainage_m") - if chainage is None or float(chainage) in covered: - continue - # ⚠ **칸이 있는 것과 값이 있는 것은 다르다** — 설계 결과는 사토장이 없어도 - # `spoil_fill_area_m2: 0.0` 을 늘 싣는다. 「값이 남아 있는」 측점만 되돌린다. - if not _has_spoil_value(design): - continue - side = _side_of(design, None) - plain = recompute(section, side, 0.0, None, longitudinal, standard) if side else None - if plain is None: - # 다시 계산할 수 없으면 **적어도 칸은 지운다** — 값이 남아 그려지는 것보다 낫다. - for key in [k for k in design if str(k).startswith("spoil_fill_")]: - design.pop(key, None) - # 노선 성토는 사토장 몫을 뺀 값이라, 다시 계산 못 하면 그 몫을 되돌려 준다. - moved = float(design.get("spoil_fill_replaced_fill_m2") or 0.0) - if moved > 0: - design["fill_area_m2"] = round(float(design.get("fill_area_m2") or 0.0) + moved, 4) - changed += 1 - continue - for key in ("status", "pavement_suggested", *USER_TOUCHED_KEYS): - if design.get(key) is not None: - plain[key] = design[key] - section["design"] = plain - changed += 1 - if not sites: - return changed - for site in sites: - sections = _section_at(cross_sections, site["chainage_m"]) - if not sections: - continue - spans = [_span_of(cross_sections, section) for section in sections] - sides = [_side_of(section.get("design") or {}, site["side_option"]) for section in sections] - ratio = site["slope_ratio_n"] - - def volume(width_m: float): - return _volume_at( - width_m, sections, spans, sides, ratio, longitudinal, standard, recompute - ) - - # 상한 = 그 구간에서 가장 좁은 측점이 허락하는 폭. 한 측점이라도 지반 샘플이 - # 모자라면 거기서 잘리므로, 넓혀도 그 측점은 안 늘어난다. - top_total, top_designs = volume(_MAX_PROBE_WIDTH_M) - limit = min( - ( - float(design.get("spoil_fill_max_width_m") or 0.0) - for design in top_designs - if design - ), - default=0.0, - ) - if limit <= 0: - continue - total, designs = volume(limit) - width = limit - if total > site["capacity_m3"]: - low, high = 0.0, limit - for _ in range(_SOLVE_STEPS): - mid = (low + high) / 2 - if volume(mid)[0] < site["capacity_m3"]: - low = mid - else: - high = mid - width = round(high, 4) - total, designs = volume(width) - - unplaced = max(site["capacity_m3"] - total, 0.0) - for section, design in zip(sections, designs, strict=True): - if not design: - continue - stored = section.get("design") or {} - for key in ("status", "pavement_suggested", *USER_TOUCHED_KEYS): - if stored.get(key) is not None: - design[key] = stored[key] - # 구간 전체 값도 측점마다 실어 둔다 — 화면 말풍선·수량이 되짚을 수 있게. - design["spoil_fill_capacity_m3"] = round(site["capacity_m3"], 4) - design["spoil_fill_placed_m3"] = round(total, 4) - design["spoil_fill_unplaced_m3"] = round(unplaced, 4) - design["spoil_fill_structure_id"] = site["structure_id"] - design["spoil_fill_extra_distance_m"] = site["extra_distance_m"] - section["design"] = design - changed += 1 - return changed diff --git a/B06_Section/B06_Section_Server_Calc_Prebuild.py b/B06_Section/B06_Section_Server_Calc_Prebuild.py index 262bf28cc..9532a11c1 100644 --- a/B06_Section/B06_Section_Server_Calc_Prebuild.py +++ b/B06_Section/B06_Section_Server_Calc_Prebuild.py @@ -218,7 +218,6 @@ def _enforce_stored_designs( 2026-09-06 사용자 확정대로 「읽기는 영구저장소에서 가져오기만」이므로 이쪽으로 옮겼다. """ from B06_Section.B06_Section_Engine_SampleExtend import extend_unclosed_sections - from B06_Section.B06_Section_Engine_SpoilFill import enforce_spoil_fills from B06_Section.B06_Section_Router_Design import ( enforce_ford_surface_drops, enforce_pavement_ranges, @@ -228,9 +227,6 @@ def _enforce_stored_designs( extend_unclosed_sections(longitudinal, sections, project_root, standard, sampler) enforce_pavement_ranges(longitudinal, sections, project_root, standard) enforce_ford_surface_drops(longitudinal, sections, project_root, standard) - # ⚠ 사토장은 **맨 뒤**다 — 앞의 두 보정이 설계를 다시 계산하면서 사토장 칸을 지운다. - # 맨 뒤에 두면 그 결과 위에 사토장 단면이 얹힌다(2026-09-09). - enforce_spoil_fills(longitudinal, sections, project_root, standard) async def recompute_server_side(project_id: UUID | str, route_id: int) -> int: diff --git a/B06_Section/B06_Section_UI_Cross_Areas.ts b/B06_Section/B06_Section_UI_Cross_Areas.ts index 6bdaaee94..09d62d5d5 100644 --- a/B06_Section/B06_Section_UI_Cross_Areas.ts +++ b/B06_Section/B06_Section_UI_Cross_Areas.ts @@ -375,21 +375,6 @@ export function buildAreaReadout( ], }, ]; - // 사토장이 선 측점만 한 줄 더 — **성토와 합치지 않는다**(확정 ㉠). 없는 측점에 빈 줄을 - // 세우면 어느 측점에 사토장이 있는지 표에서 안 보인다. - const spoilArea = Number(design.spoil_fill_area_m2 ?? 0); - if (spoilArea > 0) { - rows.push({ - label: L("B06_Design_SpoilFill_Area"), - variant: "spoil-fill", - cells: [ - { key: null, value: null, variant: "spoil-fill" }, - { key: null, value: null, variant: "spoil-fill" }, - { key: null, value: null, variant: "spoil-fill" }, - { key: null, value: spoilArea, variant: "spoil-fill" }, - ], - }); - } for (const row of rows) { const tr = document.createElement("tr"); const th = document.createElement("th"); diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Pick.ts b/B06_Section/B06_Section_UI_Cross_Detail_Pick.ts index 0ef41df87..577b14652 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail_Pick.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail_Pick.ts @@ -23,7 +23,6 @@ export const DETAIL_TARGETS: Readonly>, in /* ── 그래프 고르기 → 출처 ───────────────────────────────────────────────── */ -/** 띠 · 사토장 클래스 → 표 키(서버 `AREA_KEYS` 의 표 키). */ +/** 띠 클래스 → 표 키(서버 `AREA_KEYS` 의 표 키). */ const AREA_BANDS: ReadonlyArray<[string, string]> = [ ["b06-chart__area--cut_soil", "cut_soil"], ["b06-chart__area--cut_rock", "cut_rock"], ["b06-chart__area--fill", "embankment"], - ["b06-chart__spoil-fill", "spoil_fill"], ]; -export const AREA_PICK_SELECTOR = ".b06-chart__area, .b06-chart__spoil-fill"; +export const AREA_PICK_SELECTOR = ".b06-chart__area"; export function areaSourceOf(target: Element): CrossCellSource | null { const hit = target.closest(AREA_PICK_SELECTOR); @@ -193,7 +192,6 @@ const BAND_NAME: Readonly> = { ditch_soil: "측구(토사)", ditch_rock: "측구(암석)", embankment: "쌓기", - spoil_fill: "사토장", }; /** 띠 값 글(259-2) — 「깍기(토사) 면적 X㎡」 · 띠 아님 · 값 없음 = null. */ @@ -210,7 +208,6 @@ const AREA_DESIGN: Readonly> = { embankment: "fill_area_m2", ditch_soil: "ditch_soil_area_m2", ditch_rock: "ditch_rock_area_m2", - spoil_fill: "spoil_fill_area_m2", }; const finite = (value: unknown): number | null => diff --git a/B06_Section/B06_Section_UI_Cross_SpoilFill.ts b/B06_Section/B06_Section_UI_Cross_SpoilFill.ts deleted file mode 100644 index c62db5d81..000000000 --- a/B06_Section/B06_Section_UI_Cross_SpoilFill.ts +++ /dev/null @@ -1,95 +0,0 @@ -/* ============================================================================= - * B06_Section_UI_Cross_SpoilFill.ts - * 횡단도에 **사토장(유용토운반작업장) 성토선**을 얹는다. - * - * 왜 있나 — 임도기술교본 6장 3절이 **운반처리 위치를 평면도와 횡단도에 표시**하도록 - * 요구한다(지식DB `01_임도/02_상세설계/유용토운반작업장.md` §2). 수량을 세기 전에 - * 도면에 그것이 보여야 한다. - * - * ⚠ **터파기 파선과 선 종류를 가른다** — 터파기는 짧은 점선, 사토장은 긴 파선+점. - * 같은 파선으로 두면 도면에서 둘을 구별할 수 없다(2026-09-09 네 창 확정). - * ⚠ 값은 설계 결과(`spoil_fill_*`)에서 온다 — 여기서 **다시 세지 않는다**. - * ========================================================================== */ - -const SVG_NS = "http://www.w3.org/2000/svg"; - -/** 설계 결과에서 사토장 그리기에 쓰는 값만 추려 받는다. */ -export interface SpoilFillDrawing { - spoil_fill_line?: Array<{ offset_m: number; elevation_m: number }> | null; - spoil_fill_area_m2?: number | null; - spoil_fill_width_m?: number | null; - spoil_fill_unclosed?: boolean | null; - spoil_fill_capacity_m3?: number | null; - spoil_fill_placed_m3?: number | null; - spoil_fill_unplaced_m3?: number | null; -} - -/** 말풍선 문구 — 무엇이 얼마나 쌓였는지와 **근거**를 함께 적는다. */ -export function spoilFillTooltip(design: SpoilFillDrawing): string { - const area = Number(design.spoil_fill_area_m2 ?? 0); - const width = Number(design.spoil_fill_width_m ?? 0); - const lines = [ - `유용토운반작업장(구 사토장) · 단면 ${area.toFixed(2)}㎡ · 폭 ${width.toFixed(2)}m`, - ]; - const capacity = design.spoil_fill_capacity_m3; - if (typeof capacity === "number" && capacity > 0) { - const placed = Number(design.spoil_fill_placed_m3 ?? 0); - lines.push(`구간 용량 ${capacity.toFixed(1)}㎥ 중 ${placed.toFixed(1)}㎥ 담김`); - const unplaced = Number(design.spoil_fill_unplaced_m3 ?? 0); - // ⚠ **적어 보이는 0 을 경고로 띄우지 않는다**(2026-09-09 화면 실측). 폭을 이분법으로 - // 찾으므로 용량과 담긴 양이 소수점 아래에서 조금 남는다(400 − 399.9937 = 0.0063). - // 그것을 「못 담음」으로 띄우면 **늘 경고가 뜬 채**가 되어 진짜 경고가 안 보인다. - // 표시 자릿수(0.1㎥)에서 보이지 않는 몫은 없는 것으로 본다. - if (unplaced >= 0.05) { - lines.push(`⚠ ${unplaced.toFixed(1)}㎥ 는 못 담음 — 지반 자료가 있는 데까지만 넓힘`); - } - } - if (design.spoil_fill_unclosed) { - lines.push("⚠ 비탈이 원지반을 못 만나 잘림 — 지반 자료 범위를 넘어감"); - } - lines.push("폭은 노면 끝(노견이 시작하는 자리)에서 잼 — 그 구간 노견도 이 성토 안에 듦"); - lines.push("교본 6장 3절이 평면도·횡단도 표시를 요구함"); - return lines.join("\n"); -} - -/** - * 사토장 성토선을 그린다. 선이 없으면 아무것도 하지 않는다. - * 돌려주는 값은 그린 폴리라인(없으면 `null`) — 부르는 쪽이 강조에 쓸 수 있다. - */ -export function appendSpoilFillOverlay( - layer: SVGElement, - design: SpoilFillDrawing | null | undefined, - x: (offset: number) => number, - toDisplayY: (elevation: number) => number, -): SVGElement | null { - const line = design?.spoil_fill_line; - if (!design || !Array.isArray(line) || line.length < 2) return null; - const polyline = document.createElementNS(SVG_NS, "polyline"); - polyline.setAttribute( - "points", - line.map((point) => `${x(point.offset_m)},${toDisplayY(point.elevation_m)}`).join(" "), - ); - polyline.setAttribute( - "class", - design.spoil_fill_unclosed ? "b06-chart__spoil-fill is-unclosed" : "b06-chart__spoil-fill", - ); - const title = document.createElementNS(SVG_NS, "title"); - title.textContent = spoilFillTooltip(design); - polyline.append(title); - layer.append(polyline); - - // 이름표 — 평상 한가운데 위에 얹는다. 선만 있으면 그것이 무엇인지 도면에서 모른다. - const first = line[0]; - const last = line[line.length - 1]; - const label = document.createElementNS(SVG_NS, "text"); - label.setAttribute("x", String((x(first.offset_m) + x(last.offset_m)) / 2)); - label.setAttribute("y", String(toDisplayY(Math.max(first.elevation_m, last.elevation_m)) - 4)); - label.setAttribute("text-anchor", "middle"); - label.setAttribute("class", "b06-chart__spoil-fill-label"); - label.textContent = `유용토운반작업장 ${Number(design.spoil_fill_area_m2 ?? 0).toFixed(2)}㎡`; - const labelTitle = document.createElementNS(SVG_NS, "title"); - labelTitle.textContent = spoilFillTooltip(design); - label.append(labelTitle); - layer.append(label); - return polyline; -} diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index c4acae6b0..721c61fc8 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -26,7 +26,6 @@ import { stackPickTargets } from "./B06_Section_UI_Cross_Pick_Order"; import { appendUnderdrainOverlay } from "./B06_Section_UI_Cross_Underdrain"; import { raiseWalls } from "./B06_Section_UI_Cross_Wall"; import { attachChuteBandPick, groundLine } from "./B06_Section_UI_Cross_Wall_Hatch"; -import { appendSpoilFillOverlay } from "./B06_Section_UI_Cross_SpoilFill"; import { appendRevetmentOverlay, computeRevetmentLayout } from "./B06_Section_UI_Cross_Revetment"; import { appendCrossDesignOverlay, @@ -528,9 +527,6 @@ export function createCrossSectionCard( [...plotLayer.children] .slice(designFrom) .forEach((el) => el.setAttribute(DESIGN_LINE_ATTR, "")); - // 사토장(유용토운반작업장) — 교본 6장 3절이 횡단도 표시를 요구한다. 값은 설계 결과에서 - // 그대로 오고 여기서 다시 세지 않는다. 선 종류는 터파기 파선과 갈라 둔다. - appendSpoilFillOverlay(plotLayer, section.design, x, toDisplayY); // 암 경계선 = 지면선 복사 + 오프셋(계획선 기준 아님). if (rockBoundary && section.design.geometry_preset === "rock") { appendRockBoundaryOverlay( diff --git a/B06_Section/B06_Section_UI_Style_Cross_Areas.css b/B06_Section/B06_Section_UI_Style_Cross_Areas.css index 0fe12440b..242576189 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -85,11 +85,6 @@ color: var(--color-chart-0); } -/* 사토장 — 성토와 **다른 색**으로 둔다. 같은 색이면 표에서 한 덩어리로 읽힌다. */ -.b06-design__area--spoil-fill { - color: #6d7a20; -} - .b06-design__area--unset { color: var(--color-text-muted); } @@ -563,29 +558,6 @@ stroke-width: 1; } -/* 사토장(유용토운반작업장) — **터파기 파선과 선 종류를 가른다**(2026-09-09). - 터파기는 짧은 점선(4 3), 사토장은 **긴 파선 + 점**(9 3 2 3)이라 한눈에 갈린다. - 색은 등록부 사토장 색(#8c9a2e)을 그대로 쓴다. */ -.b06-chart__spoil-fill { - fill: none; - stroke: #8c9a2e; - stroke-dasharray: 9 3 2 3; - stroke-width: 1.4; -} - -.b06-chart__spoil-fill-label { - fill: #6d7a20; - font-size: 10px; - paint-order: stroke; - stroke: rgba(255, 255, 255, 0.9); - stroke-width: 3; -} - -/* 지반을 못 만나 잘린 사토장 — 사면 미폐합 경고와 같은 결로 붉게 알린다. */ -.b06-chart__spoil-fill.is-unclosed { - stroke: #c0392b; -} - /* 「측점 없는 관 N개」 알림 — 조용히 빠지던 것을 드러내는 줄(계획서 3-14 ㉯). */ .b06-missing-stations { align-items: center; diff --git a/B06_Section/B06_Section_UI_Style_Cross_Detail.css b/B06_Section/B06_Section_UI_Style_Cross_Detail.css index 27207c53b..ced79a277 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Detail.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Detail.css @@ -159,11 +159,6 @@ stroke-width: 3; } -.b06-detail__cad .b06-chart__spoil-fill.is-active { - stroke: var(--color-danger); - stroke-width: 3; -} - /* 판정 선은 복제에서 누를 일 없음 — 손가락 지움 */ .b06-detail__cad .b06-chart__design-hit, .b06-detail__cad .b06-chart__area > polygon { @@ -190,8 +185,7 @@ } /* 그래프에서 고르기 — 고를 대상만 손가락 · 왼쪽 아래(눈금 자리) 안내 글 */ -.b06-detail__cad[data-pick="area"] .b06-chart__area > polygon, -.b06-detail__cad[data-pick="area"] .b06-chart__spoil-fill { +.b06-detail__cad[data-pick="area"] .b06-chart__area > polygon { cursor: pointer; } diff --git a/B06_Section/CLAUDE.md b/B06_Section/CLAUDE.md index 195c2679c..c008e4dd5 100644 --- a/B06_Section/CLAUDE.md +++ b/B06_Section/CLAUDE.md @@ -6,7 +6,7 @@ - `UI_Cross_View_*` 횡단 보기 · `UI_Cross_Detail_*` 상세 창 · `UI_Cross_Design*` 설계선 편집 - `UI_Page_*` 페이지 틀 · 저장 · 구조물 칸 · `UI_Section_*` 측점 · 횡단 공용 - `Section_Store` · `Cross_Refresh` · `Api_*` 횡단 상세 가게 · 서버 요청 -- `Engine_Design*` 횡단 설계 · `Engine_Areas` 면적 · `Engine_Culvert` 관 · `Engine_SampleExtend` 지반 덧붙임 · `Engine_SpoilFill` · `Engine_Structures_Wall` · `Engine_Tilt` · `Router_*` · `Repository*` +- `Engine_Design*` 횡단 설계 · `Engine_Areas` 면적 · `Engine_Culvert` 관 · `Engine_SampleExtend` 지반 덧붙임 · `Engine_Structures_Wall` · `Engine_Tilt` · `Router_*` · `Repository*` - `logic/` 순수 계산 TS ① 짝(ts ↔ py · 거울 시험): diff --git a/B06_Section/logic/B06_Section_Cross_Enforce.ts b/B06_Section/logic/B06_Section_Cross_Enforce.ts index 8976ddb5b..52ccb4147 100644 --- a/B06_Section/logic/B06_Section_Cross_Enforce.ts +++ b/B06_Section/logic/B06_Section_Cross_Enforce.ts @@ -1,13 +1,11 @@ /* ============================================================================= * B06_Section_Cross_Enforce.ts * 계산 묶음 cross 단계의 [확정] 보정(PLAN 349-0n) — 서버 `_enforce_stored_designs` 차례 그대로 - * (미교차 넓힘 → 포장 구간 → 세월교 노면 하강 → 사토장 맨 뒤). 넓힘은 `B06_Section_Cross_Stage`. + * (미교차 넓힘 → 포장 구간 → 세월교 노면 하강). 넓힘은 `B06_Section_Cross_Stage`. * * facilityRanges · facilityAt 새 노선 관 · 구조물 자리 → 측점마다 포장 · 기울임 · 노면 하강. * ⚠ ① 짝 — `B06_Section_Router_Design.py` `pavement_ranges` · `ford_surface_drops` · `paved_at` · * `ford_drop_at` + `B06_Section_Engine_Tilt.py` `tilt_ranges` · `tilt_args`. - * spoilSites · enforceSpoilFills 사토장 기준 측점 하나 · 용량에서 폭 하나(이분법). - * ⚠ ① 짝 — `B06_Section_Engine_SpoilFill.py` `spoil_sites` · `enforce_spoil_fills`. * 거울 시험 `resources/tester/test_349_0n_cross_enforce_mirror.py`. * 세트 · 포장 · 맹암거 제원은 이미 있는 짝(`buildCulvertSets` · `pavementSpecsFrom` · `underdrainSpecsFrom`). * 순수 — 화면 · fetch · 세션 없음. @@ -81,123 +79,3 @@ export function facilityAt(ranges: FacilityRanges, chainageM: number) { surfaceDropM: ranges.drops.find(([at]) => Math.abs(chainageM - at) <= EDGE_M)?.[1] ?? 0, }; } - -/* ── 사토장 ───────────────────────────────────────────────────────────── */ - -const SOLVE_STEPS = 24; -const MAX_PROBE_WIDTH_M = 1000; -const SIDE_AUTO = "자동(성토 쪽)"; -/** 단면 유형 → [좌, 우] 성토면인가(파이썬 `structure_face_role` 표). */ -const FILL_SIDES: Record = { - left_cut: [false, true], - right_cut: [true, false], - both_cut: [false, false], - both_fill: [true, true], -}; - -export interface SpoilSite { - structureId: string | null; - chainageM: number; - capacityM3: number; - sideOption: unknown; - slopeRatioN: unknown; - extraDistanceM: unknown; -} - -/** 배치된 사토장만 — 기준 측점 · 용량 > 0(서버 정본 읽기 = 구조물 객체 길 · 시작 측점으로 갈음 안 함). */ -export function spoilSites(structures: readonly FacilityStructure[]): SpoilSite[] { - return structures.flatMap((item) => { - if (catalogRow(item.type_id)?.shape !== "spoil") return []; - const options = (item.options ?? {}) as Record; - const chainage = num(item.chainage_m); - const capacity = num(options.capacity_m3); - if (chainage === null || capacity === null || capacity <= 0) return []; - return [ - { - structureId: item.structure_id ?? null, - chainageM: chainage, - capacityM3: capacity, - sideOption: options.side, - slopeRatioN: options.fill_slope_ratio, - extraDistanceM: options.extra_distance_m, - }, - ]; - }); -} - -/** 쌓는 쪽 — 「좌 · 우」 그대로 · 「자동」 · 빈칸 = 그 측점 성토면(좌 먼저) · 못 가르면 null. */ -export function spoilSideOf( - design: Record, - option: unknown, -): "left" | "right" | null { - const word = String(option ?? "").trim(); - if (word === "좌") return "left"; - if (word === "우") return "right"; - if (word && word !== SIDE_AUTO) return null; - const sides = FILL_SIDES[String(design.section_mode ?? "")]; - if (!sides) return null; - return sides[0] ? "left" : sides[1] ? "right" : null; -} - -type Design = Record; - -/** 측점 하나 — 설계 + 사토장 폭으로 다시 셈(못 세면 null). */ -export interface SpoilStation { - chainageM: number; - design: Design | null; - recompute: (side: "left" | "right", widthM: number, slopeRatioN: unknown) => Design | null; -} - -const round4 = (value: number) => Math.round(value * 1e4) / 1e4; - -/** 사토장 측점 설계를 다시 셈 — `stations[i].design` 을 갈아 끼움 · 바뀐 측점 수. */ -export function enforceSpoilFills(stations: SpoilStation[], sites: readonly SpoilSite[]): number { - const chainages = [...new Set(stations.map((s) => s.chainageM))].sort((a, b) => a - b); - const spanOf = (here: number) => { - const index = chainages.indexOf(here); - const left = index ? chainages[index - 1] : here; - const right = index + 1 < chainages.length ? chainages[index + 1] : here; - return (right - left) / 2; - }; - let changed = 0; - for (const site of sites) { - if (!stations.length) continue; - // 기준 측점에 가장 가까운 측점 하나(가운데면 앞 측점) - const station = stations.reduce((best, s) => { - const gap = Math.abs(s.chainageM - site.chainageM); - const bestGap = Math.abs(best.chainageM - site.chainageM); - return gap < bestGap || (gap === bestGap && s.chainageM < best.chainageM) ? s : best; - }); - const span = spanOf(station.chainageM); - const side = spoilSideOf(station.design ?? {}, site.sideOption); - const volume = (widthM: number): [number, Design | null] => { - if (side === null || widthM <= 0 || !station.design) return [0, null]; - const design = station.recompute(side, widthM, site.slopeRatioN); - return [design ? (num(design.spoil_fill_area_m2) ?? 0) * span : 0, design]; - }; - const limit = num(volume(MAX_PROBE_WIDTH_M)[1]?.spoil_fill_max_width_m) ?? 0; - if (limit <= 0) continue; - let [total, design] = volume(limit); - if (total > site.capacityM3) { - let low = 0; - let high = limit; - for (let step = 0; step < SOLVE_STEPS; step += 1) { - const mid = (low + high) / 2; - if (volume(mid)[0] < site.capacityM3) low = mid; - else high = mid; - } - [total, design] = volume(round4(high)); - } - if (!design) continue; - station.design = { - ...design, - spoil_fill_capacity_m3: round4(site.capacityM3), - spoil_fill_placed_m3: round4(total), - spoil_fill_unplaced_m3: round4(Math.max(site.capacityM3 - total, 0)), - spoil_fill_structure_id: site.structureId, - spoil_fill_extra_distance_m: site.extraDistanceM, - }; - changed += 1; - } - return changed; -} diff --git a/B06_Section/logic/B06_Section_Cross_Stage.ts b/B06_Section/logic/B06_Section_Cross_Stage.ts index 4cb2b8dfd..11d79590d 100644 --- a/B06_Section/logic/B06_Section_Cross_Stage.ts +++ b/B06_Section/logic/B06_Section_Cross_Stage.ts @@ -9,8 +9,8 @@ * · 국부 연산 = 측점 열쇠마다 입력 지문(지반 표본 · 계획고 · 횡단 입력 · 확폭 · 표준값) — 같으면 앞 결과 그대로. * · 바뀐 범위(`ctx.range`) 밖이고 앞 출력(`ctx.prev`)에 있고 계획고가 그대로면 셈 없이 재사용 — 노선 초안의 * 첫 계획선은 바뀐 구간 앞 꼭짓점까지 움직이므로 범위만 보면 낡은 설계가 남음(349-0f). - * · [확정] 보정 ② 포장 구간 ③ 세월교 노면 하강 = 새 노선 관 · 구조물 자리(입력 pipes · structures)로 판정 · - * ④ 사토장 = 맨 뒤(`B06_Section_Cross_Enforce` · 서버 `_enforce_stored_designs` 차례 · 349-0n). + * · [확정] 보정 ② 포장 구간 ③ 세월교 노면 하강 = 새 노선 관 · 구조물 자리(입력 pipes · structures)로 판정 + * (`B06_Section_Cross_Enforce` · 서버 `_enforce_stored_designs` 차례 · 349-0n). * · 단계는 자기 출력만 돌려줌(입력 · 앞 단계 출력은 읽기만). DOM · fetch · 세션 없음. * input.cross 모양 = `CrossStageInput`(부르는 쪽이 세션 선택 · 저장분 · 구조물 맥락을 측점 열쇠로 모아 넘김). * ========================================================================== */ @@ -28,13 +28,7 @@ import { groundSamples } from "./B06_Section_Station_Swap"; import { extendUnclosed } from "../../B05_Profile/logic/B05_Profile_Sections_Ground"; import type { DtmGrid } from "@util/common_util_surface_sampler"; import type { CulvertPipePoint } from "@util/common_util_culvert_sets"; -import { - enforceSpoilFills, - facilityAt, - facilityRanges, - spoilSites, - type FacilityStructure, -} from "./B06_Section_Cross_Enforce"; +import { facilityAt, facilityRanges, type FacilityStructure } from "./B06_Section_Cross_Enforce"; export { groundSamples }; @@ -122,6 +116,7 @@ export function createCrossStage(deps: CrossStageDeps = {}) { for (const station of ground.stations) { const kept = ctx.prev?.byKey[station.key]; const elevation = planElevationAt(profile, station.chainageM); + // 옛 사토장 값이 남은 앞 출력은 다시 셈(사토 단면 셈 지움 · 380-1) const spoiled = Number((kept?.detail as Record | null)?.spoil_fill_width_m) > 0; if (kept && !spoiled && !inRange(station, ctx) && sameSpot(kept, station, elevation)) { @@ -169,43 +164,6 @@ export function createCrossStage(deps: CrossStageDeps = {}) { last.computed += 1; } for (const key of [...memo.keys()]) if (!byKey[key]) memo.delete(key); // 사라진 측점 - // ④ 사토장 — 맨 뒤 · 기준 측점 하나만 다시 셈(단계 기억은 사토장 전 설계) - const sites = Array.isArray(structures) ? spoilSites(structures) : []; - if (sites.length) { - const list = ground.stations.map((station) => { - const entry = byKey[station.key] as CrossStageResult | undefined; - const recompute = (side: "left" | "right", widthM: number, ratio: unknown) => { - try { - const samples = (entry?.samples ?? groundSamples(station)) as ReturnType< - typeof groundSamples - >; - return computeCrossDesign(samples, planElevationAt(profile, station.chainageM), { - ...optionsOf(station), - spoilFill: { side, widthM, slopeRatioN: ratio as number | null }, - }) as unknown as Record; - } catch { - return null; - } - }; - return { - chainageM: station.chainageM, - design: (entry?.detail as Record | undefined) ?? null, - recompute, - }; - }); - enforceSpoilFills(list, sites); - list.forEach(({ design }, index) => { - const key = ground.stations[index].key; - const entry = byKey[key]; - if (!entry || !design || design === entry.detail) return; - byKey[key] = { - ...entry, - cutAreaM2: Number(design.cut_area_m2), - fillAreaM2: Number(design.fill_area_m2), - detail: design, - }; - }); - } return { byKey }; } diff --git a/B06_Section/logic/B06_Section_UI_Cross_Detail_Geom.ts b/B06_Section/logic/B06_Section_UI_Cross_Detail_Geom.ts index be1571f2a..b0d80450c 100644 --- a/B06_Section/logic/B06_Section_UI_Cross_Detail_Geom.ts +++ b/B06_Section/logic/B06_Section_UI_Cross_Detail_Geom.ts @@ -64,7 +64,6 @@ export function pickLines( return { design: [polyline(design?.design_line)], ground: groundRuns(samples), - spoil: [polyline(design?.spoil_fill_line)], rock: rockRuns(design, samples), }; } diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py index fe102f202..3f4806594 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py @@ -50,11 +50,7 @@ _CROSS_RIGHT_ROWS: tuple[tuple[str | None, str | None], ...] = ( ("성토파종", "fill_seeding"), ("절토살포", "cut_spraying"), ("제근", "grubbing"), - # 사토장(유용토운반작업장) — **「쌓기」와 갈라 세운다**(2026-09-09 확정 ㉠). - # ⚠ 여기는 원래 **빈 줄**이었고, 실무 원본 횡단도에는 그 자리가 **아예 없다** - # (2026-09-09 400dpi 실측). 즉 **납품 양식을 늘리는 것이 아니라 안 쓰던 칸을 쓰는 것**이다. - # ⚠ 사토장이 없는 측점에는 값이 안 실려 종전처럼 빈칸으로 남는다. - ("사토장", "spoil_fill"), + (None, None), ("노면다짐", "road_compaction"), ) @@ -79,8 +75,6 @@ QUANTITY_VALUE_KEYS: tuple[str, ...] = ( "fill_seeding", "cut_spraying", "grubbing", - # 사토장 — 「쌓기(embankment)」와 **다른 칸**이다. 합치지 않는다(확정 ㉠). - "spoil_fill", "road_compaction", ) diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cross_Quantity.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cross_Quantity.py index 20344059d..6f3311213 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cross_Quantity.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cross_Quantity.py @@ -52,9 +52,6 @@ AREA_KEYS: tuple[tuple[str, str], ...] = ( ("embankment", "fill_area_m2"), ("ditch_soil", "ditch_soil_area_m2"), ("ditch_rock", "ditch_rock_area_m2"), - # 사토장 — **`fill_area_m2` 와 갈라 든다**. B06 이 이미 노선 성토에서 뺀 값이라 - # 여기서 더하거나 빼지 않는다(확정 ㉠ — 같은 흙을 두 번 세지 않기). - ("spoil_fill", "spoil_fill_area_m2"), ) #: `ditch_split_basis` 를 사람이 읽는 말로. 도면·화면이 「왜 이렇게 갈렸나」를 보일 때 쓴다. diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cross_Table_Edit.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cross_Table_Edit.py index ea2fa7091..2364857de 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cross_Table_Edit.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cross_Table_Edit.py @@ -8,7 +8,7 @@ text = 글 그대로 — 더한 칸(`r:` 행 · `c:` 열)만 고른 조각(209-1 · `pieces` = [{line, start, end}]) — 교차점에서 나눈 선 조각 목록: length = 조각 길이 합 · area = 조각이 닫힌 고리를 이루면 그 넓이(안 닫힘 → 「다시 고름」) - 조각 길 = 그 선(설계선 · 원지반선 · 사토장선) 위 두 끝 사이 꺾은선 + 조각 길 = 그 선(설계선 · 원지반선 · 암 경계선) 위 두 끝 사이 꺾은선 (두 끝이 선 밖 → 「다시 고름」) · 구조물 선(`structure` · 브라우저 기하)은 위치 검산 없이 두 끝 직선. 행(`rows`) = 묶음 안(이 행 아래 · 묶음이면 하위 줄) 또는 끝에 항목 줄(좌 · 중 · 우 이름 + 값). @@ -190,11 +190,10 @@ def rock_runs(design: dict[str, Any], samples: Any = None) -> list[list[Point]]: def pick_lines(design: dict[str, Any], samples: Any = None) -> dict[str, list[list[Point]]]: - """서버가 아는 선 — 고른 조각 위치 검산 대상(설계선 · 원지반선 · 사토장선 · 암 경계선).""" + """서버가 아는 선 — 고른 조각 위치 검산 대상(설계선 · 원지반선 · 암 경계선).""" return { "design": [_design_line(design)], "ground": ground_runs(samples), - "spoil": [_polyline(design.get("spoil_fill_line"))], "rock": rock_runs(design, samples), } diff --git a/common_util/common_util_cross_design.ts b/common_util/common_util_cross_design.ts index f33151829..2020408af 100644 --- a/common_util/common_util_cross_design.ts +++ b/common_util/common_util_cross_design.ts @@ -24,11 +24,8 @@ * ========================================================================== */ import { BERM_DEFAULT_DEPTH_M, 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, @@ -110,13 +107,6 @@ 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; /** 설계선 쉬프트 = 쪽마다 사면 이동(m · ≥ 0 · 139-6 · 151-2 노견째). 짝: 파이썬 `shoulder_extend_*_m`. */ shoulderExtendLeftM?: number; shoulderExtendRightM?: number; @@ -167,15 +157,6 @@ 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; @@ -405,8 +386,7 @@ export function computeCrossDesign( } // 측구 굴착은 설계선에 포함돼 절토 면적에 자연 반영된다(별도 가산 없음). - const [cutArea, baseFillArea] = trapezoidAreas(offsets, diffs); - let fillArea = baseFillArea; + const [cutArea, fillArea] = trapezoidAreas(offsets, diffs); // 층따기 밑수(길이 m) — 성토부 아래 원지반이 1:4 보다 급한 구간의 지표면 길이. const benchCut = benchCutLength(offsets, grounds, diffs); const fillGroundSlope = geometry.fillGroundSlope(); @@ -415,28 +395,6 @@ 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; @@ -572,18 +530,6 @@ 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 9cc1bc1c9..dfb411847 100644 --- a/common_util/common_util_cross_design_areas.ts +++ b/common_util/common_util_cross_design_areas.ts @@ -159,46 +159,3 @@ 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]; -} diff --git a/common_util/common_util_spoil_fill.py b/common_util/common_util_spoil_fill.py deleted file mode 100644 index 729457218..000000000 --- a/common_util/common_util_spoil_fill.py +++ /dev/null @@ -1,223 +0,0 @@ -"""유용토운반작업장(구 사토장) — 노선 옆에 남는 흙을 쌓는 **성토 단면**. - -⚠⚠ TS 짝 파일과 **한 벌**이다 — `common_util/common_util_spoil_fill.ts`. - 거울 시험: `tmp/tests/test_spoil_fill_mirror.py` (고칠 때 같이 돌릴 것). - 화면(B06 횡단도)이 그리고 서버(B08 수량)가 세는 값이라 두 쪽에 같은 수가 있어야 한다. - -── 무엇을 재나 ───────────────────────────────────────────────────── -사용자 확정(2026-09-09): **폭의 시작점은 노면 끝**(노견이 시작하는 자리)이다. -노견 바깥 끝이 아니다 — 그래서 **그 구간의 노견도 이 성토 안에 들어간다**. - - 노면 끝(x0, z0) - ├──── 평상(폭 w, 노면 끝 높이 그대로) ────┐ - │ ╲ 1:n 비탈 - │ ╲ - ────────────── 원지반 ─────────────────────────╳ 비탈 끝(toe) - -단면적 = 이 선과 원지반 사이(설계선이 지반보다 높은 몫). 대략 `w·h + w²/(2n)` 이지만 -지반이 기울어 있으므로 **적분으로 정확히** 센다. - -── 지어내지 않는 것 ──────────────────────────────────────────────── -지식DB `01_임도/02_상세설계/유용토운반작업장.md` §4: - 「부지 면적에서 처리용량을 자동 산정하는 현행 법정 공식, 기본 적치높이, 기본 - 비탈기울기는 확보 근거에 없다. **사용자 협의 없이 기본값을 만들지 않는다**」 -⇒ 기울기 `n` 은 **받아서 쓴다**(비면 그 측점의 노선 성토 기울기를 그대로 씀 — 새 값을 - 만드는 것이 아니라 이미 설계된 값을 따르는 것이다). 높이는 **노면 끝 높이**로 정해진다. -⚠ **폭 상한은 지반 샘플이 있는 데까지**다. 샘플 밖은 지반을 모르므로 넓히지 않는다. -""" - -from __future__ import annotations - -import math -from dataclasses import dataclass, field - -#: 적분·비탈 추적을 잘게 나누는 폭(m). TS 짝과 같은 값이어야 수가 맞는다. -STEP_M = 0.05 -#: 지반과 만나는 자리를 좁히는 이분법 반복 수. -SOLVE_STEPS = 24 - - -@dataclass(slots=True) -class SpoilFillSection: - """사토장 성토 단면 하나.""" - - area_m2: float = 0.0 - #: 평상 + 비탈을 그린 선 [(offset_m, elevation_m)] — 그리기와 적분이 같은 선을 쓴다. - line: list[tuple[float, float]] = field(default_factory=list) - #: 비탈 끝 오프셋 — 지반과 만난 자리. 못 만나면 샘플 끝에서 잘린다. - toe_offset_m: float = 0.0 - #: 비탈이 지반을 못 만나 잘렸나 — 화면이 「닫히지 않음」을 알린다. - unclosed: bool = False - #: 이 측점에서 넓힐 수 있는 최대 평상 폭(m). - max_width_m: float = 0.0 - - -def _interpolator(ground: list[tuple[float, float]]): - points = sorted( - (float(offset), float(elevation)) - for offset, elevation in ground - if offset is not None and elevation is not None - ) - - def at(offset_m: float) -> float: - if not points: - return float("nan") - if offset_m <= points[0][0]: - return points[0][1] - if offset_m >= points[-1][0]: - return points[-1][1] - for index in range(1, len(points)): - left = points[index - 1] - right = points[index] - if offset_m <= right[0]: - span = right[0] - left[0] - if span <= 0: - return right[1] - ratio = (offset_m - left[0]) / span - return left[1] + (right[1] - left[1]) * ratio - return points[-1][1] - - return at - - -def spoil_max_width_m(ground: list[tuple[float, float]], start_offset_m: float, side: str) -> float: - """그 쪽으로 지반 샘플이 남아 있는 거리(m). 이보다 넓게는 못 쌓는다.""" - offsets = [float(offset) for offset, _ in ground if offset is not None] - if not offsets: - return 0.0 - edge = max(offsets) if side == "left" else min(offsets) - reach = edge - start_offset_m if side == "left" else start_offset_m - edge - return max(reach, 0.0) - - -def spoil_fill_section( - ground: list[tuple[float, float]], - start_offset_m: float, - start_elevation_m: float, - side: str, - width_m: float, - slope_ratio_n: float, -) -> SpoilFillSection: - """사토장 성토 단면 하나. 평상(노면 끝 높이) → 1:n 비탈 → 지반과 만나는 데까지. - - ⚠ 지반보다 낮아지는 몫은 세지 않는다 — 그것은 절토이지 쌓은 흙이 아니다. - """ - sign = 1.0 if side == "left" else -1.0 - ground_at = _interpolator(ground) - max_width = spoil_max_width_m(ground, start_offset_m, side) - empty = SpoilFillSection(toe_offset_m=start_offset_m, max_width_m=round(max_width, 4)) - width = max(float(width_m), 0.0) - ratio = float(slope_ratio_n) - if ratio <= 0 or not ground: - return empty - - platform = min(width, max_width) - - def design_at(u: float) -> float: - return start_elevation_m if u <= platform else start_elevation_m - (u - platform) / ratio - - def offset_at(u: float) -> float: - return start_offset_m + sign * u - - def height_at(u: float) -> float: - return design_at(u) - ground_at(offset_at(u)) - - # ① 비탈이 지반을 만나는 자리 — 평상 끝부터 바깥으로 훑는다. - toe_u = max_width - unclosed = True - if height_at(platform) <= 0: - toe_u = platform - unclosed = False - else: - previous = platform - steps = int((max_width - platform) / STEP_M) + 2 - for step in range(1, steps + 1): - current = min(platform + STEP_M * step, max_width) - if height_at(current) <= 0: - low, high = previous, current - for _ in range(SOLVE_STEPS): - mid = (low + high) / 2 - if height_at(mid) > 0: - low = mid - else: - high = mid - toe_u = high - unclosed = False - break - previous = current - if current >= max_width: - break - - # ② 면적 — 설계선이 지반보다 높은 몫만 사다리꼴로 적분한다. - area = 0.0 - cuts = [0.0] - if 0 < platform < toe_u: - cuts.append(platform) - cuts.append(toe_u) - for index in range(1, len(cuts)): - start, end = cuts[index - 1], cuts[index] - span = end - start - if span <= 0: - continue - count = max(1, math.ceil(span / STEP_M)) - step_width = span / count - for step in range(count): - u_a = start + step_width * step - u_b = u_a + step_width - h_a = max(height_at(u_a), 0.0) - h_b = max(height_at(u_b), 0.0) - area += (h_a + h_b) / 2 * step_width - - # ③ 그리는 선 — 적분과 같은 선을 쓴다(그림과 수량이 어긋나지 않게). - line: list[tuple[float, float]] = [(start_offset_m, start_elevation_m)] - if platform > 0: - line.append((offset_at(platform), start_elevation_m)) - if toe_u > platform: - line.append((offset_at(toe_u), design_at(toe_u))) - if sign < 0: - line.reverse() - - return SpoilFillSection( - area_m2=round(area, 4), - line=[(round(offset, 4), round(elevation, 4)) for offset, elevation in line], - toe_offset_m=round(offset_at(toe_u), 4), - unclosed=unclosed, - max_width_m=round(max_width, 4), - ) - - -def solve_spoil_width_m( - ground: list[tuple[float, float]], - start_offset_m: float, - start_elevation_m: float, - side: str, - slope_ratio_n: float, - target_area_m2: float, -) -> tuple[float, SpoilFillSection]: - """원하는 단면적이 나오도록 **평상 폭을 되풀이로 찾는다**. - - ⚠ 면적은 폭에 대해 단조증가라 이분법으로 충분하다. - ⚠ 상한(지반 샘플이 있는 데까지)에서도 모자라면 **그 폭을 돌려준다** — 못 담는 몫은 - 부르는 쪽이 「남은 사토」로 드러낸다(임의로 넓히지 않는다). - """ - max_width = spoil_max_width_m(ground, start_offset_m, side) - - def at(width_m: float) -> SpoilFillSection: - return spoil_fill_section( - ground, start_offset_m, start_elevation_m, side, width_m, slope_ratio_n - ) - - if not target_area_m2 > 0: - return 0.0, at(0.0) - full = at(max_width) - if full.area_m2 <= target_area_m2: - return round(max_width, 4), full - low, high = 0.0, max_width - for _ in range(SOLVE_STEPS): - mid = (low + high) / 2 - if at(mid).area_m2 < target_area_m2: - low = mid - else: - high = mid - width_m = round(high, 4) - return width_m, at(width_m) diff --git a/common_util/common_util_spoil_fill.ts b/common_util/common_util_spoil_fill.ts deleted file mode 100644 index 795e1a81c..000000000 --- a/common_util/common_util_spoil_fill.ts +++ /dev/null @@ -1,241 +0,0 @@ -/* ============================================================================= - * common_util_spoil_fill.ts - * 유용토운반작업장(구 사토장) — 노선 옆에 남는 흙을 쌓는 **성토 단면**. - * - * ⚠⚠ 파이썬 짝 파일과 **한 벌**이다 — `common_util/common_util_spoil_fill.py`. - * 거울 시험: `tmp/tests/test_spoil_fill_mirror.py`. 어느 쪽을 고치든 같이 돌릴 것. - * 화면(B06 횡단도)이 그리고 서버(B08 수량)가 세는 값이라 두 쪽 수가 같아야 한다. - * - * ── 무엇을 재나 ───────────────────────────────────────────────────── - * 사용자 확정(2026-09-09): **폭의 시작점은 노면 끝**(노견이 시작하는 자리)이다. - * 노견 바깥 끝이 아니다 — 그래서 **그 구간의 노견도 이 성토 안에 들어간다**. - * - * 노면 끝(x0, z0) - * ├──── 평상(폭 w, 노면 끝 높이 그대로) ────┐ - * │ ╲ 1:n 비탈 - * │ ╲ - * ────────────── 원지반 ─────────────────────────╳ 비탈 끝(toe) - * - * 단면적 = 이 선과 원지반 사이(설계선이 지반보다 높은 몫). 대략 `w·h + w²/(2n)` 이지만 - * 지반이 기울어 있으므로 **적분으로 정확히** 센다. - * - * ── 지어내지 않는 것 ──────────────────────────────────────────────── - * 지식DB `01_임도/02_상세설계/유용토운반작업장.md` §4: - * 「부지 면적에서 처리용량을 자동 산정하는 현행 법정 공식, 기본 적치높이, 기본 - * 비탈기울기는 확보 근거에 없다. **사용자 협의 없이 기본값을 만들지 않는다**」 - * ⇒ 기울기 `n` 은 **받아서 쓴다**(비면 그 측점의 노선 성토 기울기를 그대로 씀 — 새 값을 - * 만드는 것이 아니라 이미 설계된 값을 따르는 것이다). 높이는 **노면 끝 높이**로 정해진다. - * ⚠ **폭 상한은 지반 샘플이 있는 데까지**다. 샘플 밖은 지반을 모르므로 넓히지 않는다. - * ========================================================================== */ - -/** 지반 표본 한 점. `offset_m` 오름차순으로 주어야 한다. */ -export interface SpoilGroundSample { - offset_m: number; - elevation_m: number; -} - -export interface SpoilFillInput { - /** 원지반 — 오름차순. */ - ground: SpoilGroundSample[]; - /** 노면 끝(= 노견이 시작하는 자리)의 오프셋·표고. */ - startOffsetM: number; - startElevationM: number; - /** 쌓는 쪽. 좌 = +offset, 우 = −offset (config 5-4-2 의 부호 규약). */ - side: "left" | "right"; - /** 평상 폭(m). 0 이면 비탈만 남는다. */ - widthM: number; - /** 비탈 기울기 1:n 의 n. 0 이하면 계산하지 않는다. */ - slopeRatioN: number; -} - -export interface SpoilFillSection { - /** 이 측점의 사토장 성토 단면적(㎡). */ - area_m2: number; - /** 평상 + 비탈을 그린 선(오프셋 오름차순). 그리기와 적분이 같은 선을 쓴다. */ - line: SpoilGroundSample[]; - /** 비탈 끝 오프셋 — 지반과 만난 자리. 못 만나면 샘플 끝에서 잘린다. */ - toeOffsetM: number; - /** 비탈이 지반을 못 만나 샘플 끝에서 잘렸나 — 화면이 「닫히지 않음」을 알린다. */ - unclosed: boolean; - /** 이 측점에서 넓힐 수 있는 최대 평상 폭(m) — 지반 샘플이 있는 데까지. */ - maxWidthM: number; -} - -/** 짝: 파이썬 `round(value, 4)`. */ -function round4(value: number): number { - return Math.round(value * 1e4) / 1e4; -} - -/** 적분·비탈 추적 잘게 나누는 폭(m). 0.05 m 면 20 m 폭에서 400 칸이다. */ -const STEP_M = 0.05; -/** 지반과 만나는 자리를 좁히는 이분법 반복 수. */ -const SOLVE_STEPS = 24; - -function interpolator(ground: SpoilGroundSample[]): (offsetM: number) => number { - const points = ground - .filter((item) => Number.isFinite(item.offset_m) && Number.isFinite(item.elevation_m)) - .sort((a, b) => a.offset_m - b.offset_m); - return (offsetM: number): number => { - if (!points.length) return Number.NaN; - if (offsetM <= points[0].offset_m) return points[0].elevation_m; - const last = points[points.length - 1]; - if (offsetM >= last.offset_m) return last.elevation_m; - for (let index = 1; index < points.length; index += 1) { - const a = points[index - 1]; - const b = points[index]; - if (offsetM <= b.offset_m) { - const span = b.offset_m - a.offset_m; - if (span <= 0) return b.elevation_m; - const ratio = (offsetM - a.offset_m) / span; - return a.elevation_m + (b.elevation_m - a.elevation_m) * ratio; - } - } - return last.elevation_m; - }; -} - -/** 그 쪽으로 지반 샘플이 남아 있는 거리(m). 이보다 넓게는 못 쌓는다. */ -export function spoilMaxWidthM(input: { - ground: SpoilGroundSample[]; - startOffsetM: number; - side: "left" | "right"; -}): number { - const offsets = input.ground - .filter((item) => Number.isFinite(item.offset_m)) - .map((item) => item.offset_m); - if (!offsets.length) return 0; - const edge = input.side === "left" ? Math.max(...offsets) : Math.min(...offsets); - const reach = input.side === "left" ? edge - input.startOffsetM : input.startOffsetM - edge; - return Math.max(reach, 0); -} - -/** - * 사토장 성토 단면 하나. 평상(노면 끝 높이) → 1:n 비탈 → 지반과 만나는 데까지. - * - * ⚠ 지반보다 낮아지는 몫은 세지 않는다 — 그것은 절토이지 쌓은 흙이 아니다. - */ -export function spoilFillSection(input: SpoilFillInput): SpoilFillSection { - const sign = input.side === "left" ? 1 : -1; - const groundAt = interpolator(input.ground); - const maxWidth = spoilMaxWidthM(input); - const empty: SpoilFillSection = { - area_m2: 0, - line: [], - toeOffsetM: input.startOffsetM, - unclosed: false, - maxWidthM: maxWidth, - }; - const width = Math.max(input.widthM, 0); - const ratio = input.slopeRatioN; - if (!Number.isFinite(width) || !Number.isFinite(ratio) || ratio <= 0) return empty; - if (!input.ground.length || !Number.isFinite(input.startElevationM)) return empty; - - const platform = Math.min(width, maxWidth); - /** 바깥으로 `u` m 나간 자리의 설계고. 평상 끝부터 1:n 으로 내려간다. */ - const designAt = (u: number): number => - u <= platform ? input.startElevationM : input.startElevationM - (u - platform) / ratio; - const offsetAt = (u: number): number => input.startOffsetM + sign * u; - const heightAt = (u: number): number => designAt(u) - groundAt(offsetAt(u)); - - // ① 비탈이 지반을 만나는 자리 — 평상 끝부터 바깥으로 훑는다. - let toeU = maxWidth; - let unclosed = true; - if (heightAt(platform) <= 0) { - // 평상 끝이 이미 지반 아래·같음 ⇒ 쌓을 것이 없다. - toeU = platform; - unclosed = false; - } else { - let previous = platform; - // ⚠ 걸음은 **정수 번호**로 센다 — 실수를 더해 나가면 파이썬 짝과 자리가 미세하게 갈린다. - const steps = Math.floor((maxWidth - platform) / STEP_M) + 2; - for (let step = 1; step <= steps; step += 1) { - const current = Math.min(platform + STEP_M * step, maxWidth); - if (heightAt(current) <= 0) { - let low = previous; - let high = current; - for (let step = 0; step < SOLVE_STEPS; step += 1) { - const mid = (low + high) / 2; - if (heightAt(mid) > 0) low = mid; - else high = mid; - } - toeU = high; - unclosed = false; - break; - } - previous = current; - if (current >= maxWidth) break; - } - } - - // ② 면적 — 설계선이 지반보다 높은 몫만 사다리꼴로 적분한다. - let area = 0; - const cuts: number[] = [0]; - if (platform > 0 && platform < toeU) cuts.push(platform); - cuts.push(toeU); - for (let index = 1; index < cuts.length; index += 1) { - const from = cuts[index - 1]; - const to = cuts[index]; - const span = to - from; - if (span <= 0) continue; - const steps = Math.max(1, Math.ceil(span / STEP_M)); - const width0 = span / steps; - for (let step = 0; step < steps; step += 1) { - const uA = from + width0 * step; - const uB = uA + width0; - const hA = Math.max(heightAt(uA), 0); - const hB = Math.max(heightAt(uB), 0); - area += ((hA + hB) / 2) * width0; - } - } - - // ③ 그리는 선 — 적분과 같은 선을 쓴다(그림과 수량이 어긋나지 않게). - const line: SpoilGroundSample[] = [ - { offset_m: input.startOffsetM, elevation_m: input.startElevationM }, - ]; - if (platform > 0) { - line.push({ offset_m: offsetAt(platform), elevation_m: input.startElevationM }); - } - if (toeU > platform) { - line.push({ offset_m: offsetAt(toeU), elevation_m: designAt(toeU) }); - } - if (sign < 0) line.reverse(); - - return { - area_m2: round4(area), - // 좌표도 **소수 넷째 자리에서 맞춘다** — 파이썬 짝과 같은 수를 내야 그림과 수량이 붙는다. - line: line.map((point) => ({ - offset_m: round4(point.offset_m), - elevation_m: round4(point.elevation_m), - })), - toeOffsetM: round4(offsetAt(toeU)), - unclosed, - maxWidthM: round4(maxWidth), - }; -} - -/** - * 원하는 단면적이 나오도록 **평상 폭을 되풀이로 찾는다**. - * - * ⚠ 면적은 폭에 대해 단조증가라(넓히면 넓어진 만큼 더 쌓임) 이분법으로 충분하다. - * ⚠ 지반 샘플이 있는 데까지가 상한이라, 상한에서도 모자라면 **그 폭을 돌려준다** — - * 못 담는 몫은 부르는 쪽이 「남은 사토」로 드러낸다(임의로 넓히지 않는다). - */ -export function solveSpoilWidthM( - input: Omit, - targetAreaM2: number, -): { widthM: number; section: SpoilFillSection } { - const maxWidth = spoilMaxWidthM(input); - const at = (widthM: number): SpoilFillSection => spoilFillSection({ ...input, widthM }); - if (!(targetAreaM2 > 0)) return { widthM: 0, section: at(0) }; - const full = at(maxWidth); - if (full.area_m2 <= targetAreaM2) return { widthM: maxWidth, section: full }; - let low = 0; - let high = maxWidth; - for (let step = 0; step < SOLVE_STEPS; step += 1) { - const mid = (low + high) / 2; - if (at(mid).area_m2 < targetAreaM2) low = mid; - else high = mid; - } - const widthM = round4(high); - return { widthM, section: at(widthM) }; -} diff --git a/common_util/common_util_structure_catalog.ts b/common_util/common_util_structure_catalog.ts index c8c67f05b..f1dfb15c2 100644 --- a/common_util/common_util_structure_catalog.ts +++ b/common_util/common_util_structure_catalog.ts @@ -28,7 +28,7 @@ export interface CatalogRow { station: string; /** 카드 이름 부품 — name · pipe. */ title: string; - /** 형상 부품 — none · pipe_set · box · ford · ford_pavement · hidden_revet · wall · berm · underdrain · pavement · spoil. */ + /** 형상 부품 — none · pipe_set · box · ford · ford_pavement · hidden_revet · wall · berm · underdrain · pavement. */ shape: string; shapeOpts: Record; /** 3D 부품 — none · pipe · box · ford · chute · wall · surface. */ diff --git a/common_util/common_util_structure_parts.json b/common_util/common_util_structure_parts.json index 9b3cbd800..aba3e422f 100644 --- a/common_util/common_util_structure_parts.json +++ b/common_util/common_util_structure_parts.json @@ -150,12 +150,6 @@ "읽는 칸": ["pave_spec", "height_m", "widening_off"], "자리꼴": ["interval"], "정본": ["structures"] - }, - "spoil": { - "이름": "사토(성토 채움)", - "읽는 칸": ["capacity_m3"], - "자리꼴": ["site"], - "정본": ["structures"] } }, "3D": { @@ -465,8 +459,8 @@ "목록": "both", "횡단도": "none", "카드이름": "name", - "형상": "spoil", - "3D": "surface" + "형상": "none", + "3D": "none" }, "borrow_pit": { "목록": "both", diff --git a/resources/tester/test_150_detail_pick.py b/resources/tester/test_150_detail_pick.py index 4019e8a86..4cc40892b 100644 --- a/resources/tester/test_150_detail_pick.py +++ b/resources/tester/test_150_detail_pick.py @@ -59,7 +59,7 @@ def test_칸마다_그림_강조(out): assert active["cut_soil"] == ["b06-chart__area--cut_soil"] assert active["cut_rock"] == ["b06-chart__area--cut_rock"] assert active["embankment"] == ["b06-chart__area--fill"] - assert active["spoil_fill"] == ["b06-chart__spoil-fill"] + assert "spoil_fill" not in out["targets"] # 사토장 횡단 그림 없음(380-1) assert active["ground"] == ["b06-chart__center"] assert active["ditch_soil"] == ["ditch"] assert active["grading_cut"] == ["cutSlope"] @@ -69,9 +69,9 @@ def test_칸마다_그림_강조(out): def test_대상_있는_키만_빈칸은_빠짐(out): - """164-2 — 그림에 대상이 없는 칸(암석 띠 · 사토장 없음)은 `data-empty`.""" + """164-2 — 그림에 대상이 없는 칸(암석 띠 없음)은 `data-empty`.""" assert out["present"] == sorted(out["targets"]) - assert set(out["present"]) - set(out["presentBare"]) == {"cut_rock", "spoil_fill"} + assert set(out["present"]) - set(out["presentBare"]) == {"cut_rock"} def test_상세_표_칸_키_누름_배선(): diff --git a/resources/tester/test_164_detail_zoom.py b/resources/tester/test_164_detail_zoom.py index 3be68e9e6..de19bf158 100644 --- a/resources/tester/test_164_detail_zoom.py +++ b/resources/tester/test_164_detail_zoom.py @@ -106,7 +106,6 @@ def test_강조_붉은_한_벌_고름_hover_다름_빈칸(): ".b06-detail__cad .b06-chart__area.is-active > polygon {\n fill: color-mix(in srgb, var(--color-danger)", ".b06-detail__cad .b06-chart__design-hit.is-active {\n stroke: color-mix(in srgb, var(--color-danger)", ".b06-detail__cad .b06-chart__center.is-active line {\n stroke: var(--color-danger);", - ".b06-detail__cad .b06-chart__spoil-fill.is-active {\n stroke: var(--color-danger);", ".b06-detail__qtable .is-picked {\n background: color-mix(in srgb, var(--color-danger) 18%", ".b06-detail__qtable [data-key][data-empty] {\n cursor: default;", ): diff --git a/resources/tester/test_179_4_detail_table.py b/resources/tester/test_179_4_detail_table.py index 6df144346..2407afd01 100644 --- a/resources/tester/test_179_4_detail_table.py +++ b/resources/tester/test_179_4_detail_table.py @@ -111,7 +111,7 @@ def test_그래프_고르기_띠_키_판정_선_m(out): assert area["cut_soil"] == {"src": "area", "key": "cut_soil"} assert area["cut_rock"] == {"src": "area", "key": "cut_rock"} assert area["fill"] == {"src": "area", "key": "embankment"} - assert area["spoil"] == {"src": "area", "key": "spoil_fill"} + assert area["spoil"] is None # 사토장 띠 없음(380-1) assert area["other"] is None # 화면 px → m(209-1 `pxToM`) · 옛 저장 꼴(두 끝) 길이 = hypot 그대로 assert out["pxBack"][0] == pytest.approx(PICK[0]) and out["pxBack"][1] == pytest.approx(PICK[1]) diff --git a/resources/tester/test_316_defaults.py b/resources/tester/test_316_defaults.py index 6c0175b12..46fcbd6c3 100644 --- a/resources/tester/test_316_defaults.py +++ b/resources/tester/test_316_defaults.py @@ -59,7 +59,6 @@ def test_request_schema_default_off(): def test_python_stored_reads_default_off(): for rel in ( "B06_Section/B06_Section_Router_Design.py", - "B06_Section/B06_Section_Engine_SpoilFill.py", "B06_Section/B06_Section_Engine_SampleExtend.py", "B05_Profile/B05_Profile_Router_Confirm.py", ): diff --git a/resources/tester/test_322_1_catalog_mirror.py b/resources/tester/test_322_1_catalog_mirror.py index 05e8f71b0..906428a7b 100644 --- a/resources/tester/test_322_1_catalog_mirror.py +++ b/resources/tester/test_322_1_catalog_mirror.py @@ -77,11 +77,12 @@ TABLE = { "barrier_gate", "completion_sign", "guardrail", - "borrow_pit", "etc", ) }, - "spoil_bank": ("both", "none", "name", "spoil", "surface"), + # 사토장 = 횡단 그림 · 3D 없음 · 상세 CAD 글만(380-1) · 토취장 = M02 에서 켬(정의 enabled 꺼짐 = B06 만) + "spoil_bank": ("both", "none", "name", "none", "none"), + "borrow_pit": ("b06", "none", "name", "none", "none"), "wildlife_path": ("b06", "none", "name", "none", "none"), "revegetation": ("b06", "none", "name", "none", "none"), "pavement_concrete": ("both", "none", "name", "pavement", "surface"), diff --git a/resources/tester/test_349_0n_cross_enforce_mirror.py b/resources/tester/test_349_0n_cross_enforce_mirror.py index b692157b6..f1508b96a 100644 --- a/resources/tester/test_349_0n_cross_enforce_mirror.py +++ b/resources/tester/test_349_0n_cross_enforce_mirror.py @@ -2,9 +2,8 @@ · 포장 구간 · 기울임 · 세월교 노면 하강 판정 = 서버 `paved_at` · `tilt_args` · `ford_drop_at` (누가거리 촘촘히 · 경계 ±0.02) -· cross 단계 결과 = 서버 차례(포장 구간 → 세월교 노면 하강 → 사토장 맨 뒤) 뒤 설계 — 측점마다 - 면적 · 포장 · 기울임 · 하강 · 사토장 폭 · 면적 · 담은 양 - (용량 이분법 · 상한 · 「자동(성토 쪽)」 · 「좌」 · 못 가르는 「자동」) +· cross 단계 결과 = 서버 차례(포장 구간 → 세월교 노면 하강) 뒤 설계 — 측점마다 면적 · 포장 · 기울임 · 하강 +· 사토장이 놓여도 단면 칸(`spoil_fill_*`) 없음(380-1 — 사토 단면 셈 지움) · 관 · 구조물 입력이 없으면 저장분 측점 열쇠 값 그대로(보정 안 함) 짝: `helper_349_0n_cross_enforce.cjs` · TS `B06_Section/logic/B06_Section_Cross_Enforce.ts`. """ @@ -30,7 +29,6 @@ import B06_Section.B06_Section_Router_Design as design_mod # noqa: E402 from B05_Profile.B05_Profile_Structures_Schema import StructureInstance # noqa: E402 from B06_Section.B06_Section_Engine_Culvert import culvert_sets_from # noqa: E402 from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402 -from B06_Section.B06_Section_Engine_SpoilFill import enforce_spoil_fills # noqa: E402 from common_util.common_util_drainage_pipes import parse_pipe_points # noqa: E402 from config.config_system_design import STANDARD_CROSS_SECTION # noqa: E402 @@ -156,7 +154,7 @@ def r(tmp_path_factory) -> dict: @pytest.fixture(scope="module") def server(r) -> list[dict]: - """서버 [확정] 차례 그대로 — 저장분(보정 전) → 포장 구간 → 세월교 하강 → 사토장.""" + """서버 [확정] 차례 그대로 — 저장분(보정 전) → 포장 구간 → 세월교 하강.""" patch = pytest.MonkeyPatch() sets = culvert_sets_from(parse_pipe_points(PIPES)) for module in (design_mod, tilt_mod): @@ -191,7 +189,6 @@ def server(r) -> list[dict]: try: design_mod.enforce_pavement_ranges(longitudinal, sections, root, STANDARD_CROSS_SECTION) design_mod.enforce_ford_surface_drops(longitudinal, sections, root, STANDARD_CROSS_SECTION) - enforce_spoil_fills(longitudinal, sections, root, STANDARD_CROSS_SECTION) pave = design_mod.pavement_ranges(root) tilts = tilt_mod.tilt_ranges(root) drops = design_mod.ford_surface_drops(root) @@ -230,12 +227,6 @@ KEYS = ( "cut_area_m2", "fill_area_m2", "surface_drop_m", - "spoil_fill_width_m", - "spoil_fill_area_m2", - "spoil_fill_replaced_fill_m2", - "spoil_fill_placed_m3", - "spoil_fill_unplaced_m3", - "spoil_fill_capacity_m3", ) @@ -248,19 +239,16 @@ def test_cross_단계_결과_서버_보정_뒤와_같음(r, server): station["key"], key, ) - for key in ("paved", "tilted", "spoil_fill_side", "spoil_fill_structure_id"): + for key in ("paved", "tilted"): assert a.get(key) == b.get(key), (station["key"], key) -def test_사토장_세_경우(r, server): - by = {s["chainage_m"]: s["design"] for s in server} - assert by[340.0]["spoil_fill_placed_m3"] == pytest.approx(300, abs=0.5) # 이분법 — 용량만큼 - assert by[340.0]["spoil_fill_side"] == "right" # left_cut 의 성토면 - assert by[360.0]["spoil_fill_unplaced_m3"] > 0 # 상한 — 못 담은 몫 - assert not by[20.0].get("spoil_fill_width_m") # 「자동」 = 못 가름 - assert r["ts"]["stage"]["byKey"]["grid:17"]["spoil_fill_placed_m3"] == pytest.approx( - 300, abs=0.5 - ) +def test_사토장_놓여도_단면_칸_없음(r, server): + """380-1 — 사토장 셋(sp1~3)이 놓여도 서버 · 브라우저 단계 모두 사토 단면 칸 없음.""" + designs = [s["design"] for s in server] + list(r["ts"]["stage"]["byKey"].values()) + assert len(designs) > len(server) + for design in designs: + assert not [key for key in design if key.startswith("spoil_fill")] def test_관_구조물_입력이_없으면_보정_안_함(r): diff --git a/resources/tester/test_52_9_spoil_borrow.py b/resources/tester/test_52_9_spoil_borrow.py index 83e39ff0f..272ddc58d 100644 --- a/resources/tester/test_52_9_spoil_borrow.py +++ b/resources/tester/test_52_9_spoil_borrow.py @@ -1,7 +1,7 @@ """PLAN 52-9 — 안전부대용지 › 사토장 · 토취장 = 사용자 구조물 표 08. · 있는 종류 키 그대로(spoil_bank · borrow_pit) · 기준 측점 + 컨테이너 「상세 내용」(정의 그룹) 하나 -· 부피 손 입력 = 옛 용량 키 `capacity_m3`(B06 사토 배분이 읽는 자리) +· 부피 손 입력 = 옛 용량 키 `capacity_m3`(상세 CAD 글 「사토장 부피」 · 380) · 옛 칸은 정의에서 빼 지난 정의 값 · 잇기 = 그룹 id(`group_items`) — 집계 표가 그룹마다 한 줄 """ @@ -10,7 +10,7 @@ import json from pathlib import Path from B05_Profile.B05_Profile_Structures_Schema import DEFINITION_PATH, load_structure_types -from B06_Section.B06_Section_Engine_SpoilFill import spoil_sites +from common_util.common_util_structure_catalog import catalog_row from M02_MasterTemplete.M02_Table_Fill import _with_group_items from M02_MasterTemplete.StructOptions import M02_SO_Definition from M02_MasterTemplete.StructOptions.M02_SO_Groups import groups_of @@ -60,18 +60,10 @@ def test_잇기는_그룹마다_집계_한_줄(): assert rows[1]["summary_item"] == link and rows[1]["그룹"] == "상세 내용" -def test_B06_사토_배분은_부피를_그대로_읽음_옛_값도(): - new = {"type_id": "spoil_bank", "start_m": 20, "end_m": 40, "options": {"capacity_m3": 150}} - old = { - "type_id": "spoil_bank", - "start_m": 60, - "end_m": 80, - "options": {"capacity_m3": 90, "side": "좌", "area_m2": 30, "extra_distance_m": 5}, - } - sites = spoil_sites([new, old]) - assert [s["capacity_m3"] for s in sites] == [150.0, 90.0] - assert sites[0]["side_option"] is None and sites[1]["side_option"] == "좌" - assert sites[1]["extra_distance_m"] == 5 +def test_380_사토장_토취장은_횡단_그림_3D_없음(): + for type_id in KINDS: + row = catalog_row(type_id) + assert (row["station"], row["shape"], row["model3d"]) == ("none", "none", "none"), type_id def test_54_7_사토장_기준_측점_꼴은_토취장과_같음_옛_구간은_기준_측점으로_읽음(): diff --git a/resources/tester/test_b06_cross_design_mirror.py b/resources/tester/test_b06_cross_design_mirror.py index f0554ff98..7477c7430 100644 --- a/resources/tester/test_b06_cross_design_mirror.py +++ b/resources/tester/test_b06_cross_design_mirror.py @@ -192,30 +192,6 @@ CASES = [ "cut_slope_ratio": 1.0, }, }, - { - # 사토장(유용토운반작업장) — 노면 끝 바깥에 쌓는 성토(2026-09-09 사용자 확정). - # ⚠ 그 바깥 성토는 노선 몫이 아니라 사토장 몫이라 `fill_area_m2` 에서 빠진다. - "name": "사토장이 선 측점", - "samples": _ground(-0.5), - "design_elevation_m": 99.8, - "options": { - "ground_type": "soil", - "section_mode": "left_cut", - "ditch_side": "left", - "spoil_fill": {"side": "left", "width_m": 4.0}, - }, - }, - { - "name": "사토장 — 기울기를 따로 준 측점", - "samples": _ground(-0.5), - "design_elevation_m": 99.8, - "options": { - "ground_type": "soil", - "section_mode": "left_cut", - "ditch_side": "left", - "spoil_fill": {"side": "left", "width_m": 8.0, "slope_ratio_n": 2.0}, - }, - }, { # 설계선 쉬프트(139-6) — 쪽마다 노견 확장 · 측구 쪽(절토)과 성토 쪽을 같이 넓힘 "name": "노견 확장 좌 0.7 · 우 1.2", @@ -347,17 +323,9 @@ const camel = { tilted: "tilted", tilt_pct: "tiltPct", }; -// 사토장 제원만 칸 이름이 다르다 — 파이썬은 snake, TS 는 camel 이다. -const spoilCamel = { side: "side", width_m: "widthM", slope_ratio_n: "slopeRatioN" }; const results = input.cases.map((item) => { const options = { standard: input.standard }; for (const [key, value] of Object.entries(item.options)) { - if (key === "spoil_fill") { - const spoil = {}; - for (const [k, v] of Object.entries(value)) spoil[spoilCamel[k]] = v; - options.spoilFill = spoil; - continue; - } options[camel[key]] = value; } return computeCrossDesign(item.samples, item.design_elevation_m, options); @@ -477,20 +445,10 @@ def test_횡단_설계선과_단면적이_두_쪽에서_같다(tmp_path: Path) - assert not problems, "짝이 갈림:\n" + "\n".join(problems[:40]) -def test_사토장_갈래가_빈_값으로_통과하지_않는다() -> None: - """사토장 측점이 0 ㎡ 면 대조가 아무것도 안 잠근다 — 값이 실제로 서는지 본다.""" - spoil = [ - result - for case, result in zip(CASES, _python_results(), strict=True) - if case["name"].startswith("사토장") - ] - assert spoil, "사토장 사례가 있어야 한다" - for result in spoil: - assert result["spoil_fill_area_m2"] > 0 - assert result["spoil_fill_replaced_fill_m2"] > 0 - assert len(result["spoil_fill_line"]) >= 3 - # 기울기를 눕히고 넓히면 더 담긴다. - assert spoil[1]["spoil_fill_area_m2"] > spoil[0]["spoil_fill_area_m2"] +def test_사토장_단면_칸_없음(tmp_path: Path) -> None: + """380-1 — 사토장은 횡단에 안 그림 · 사토 단면 셈 지움 — 두 쪽 결과 모두 `spoil_fill_*` 칸 없음.""" + for result in [*_python_results(), *_ts_results(tmp_path)]: + assert not [key for key in result if key.startswith("spoil_fill")] def test_시험이_빈_결과로_통과하지_않는다() -> None: diff --git a/resources/tester/test_b06_spoil_fill_allocation.py b/resources/tester/test_b06_spoil_fill_allocation.py index c1adda319..25496c9d5 100644 --- a/resources/tester/test_b06_spoil_fill_allocation.py +++ b/resources/tester/test_b06_spoil_fill_allocation.py @@ -1,11 +1,10 @@ -"""사토장 — 용량에서 폭을 정해 측점마다 단면을 세운다 (2026-09-09). +"""사토장 — 횡단 단면 셈 지움 · 남은 사토 값 정리 (PLAN 380-1 · 요구 241-8). ⚠ 잠그는 것 - ① 용량이 커질수록 폭이 넓어지고, 상한에서 멈추면 못 담은 몫이 값으로 남는다. - ② 사토장이 선 측점은 **노선 성토가 줄고** 그만큼이 사토장 몫으로 간다(두 번 세지 않기). - ③ 「자동」이면 그 측점의 **성토 쪽**에 선다. - ④ 기준 측점 밖 측점은 안 건드린다 — **새 측점을 만들지 않는다**. - ⑤ 사토장 = 기준 측점 한 점(PLAN 54-7) — 가장 가까운 측점 하나 · 옛 구간 저장분은 시작 측점. + ① 사토장이 놓여도 [저장]·[확정] 서버 재계산(`enforce_design_lines` → `_enforce_stored_designs`)이 + 사토 단면을 세우지 않는다 — 횡단도에 그림 없음 · 상세 CAD 글만. + ② 옛 저장분에 남은 `spoil_fill_*` 값(노선 성토를 뺀 꼴)은 [저장] 한 번에 지워지고 + 노선 성토(`fill_area_m2`)가 사토장 없는 값으로 돌아온다. """ from __future__ import annotations @@ -21,32 +20,22 @@ if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402 -from B06_Section.B06_Section_Engine_SpoilFill import enforce_spoil_fills # noqa: E402 +from B06_Section.B06_Section_Router_Design import enforce_design_lines # noqa: E402 +from B06_Section.B06_Section_Server_Calc_Prebuild import _enforce_stored_designs # noqa: E402 def _samples() -> list[dict]: - """우측(-offset)이 높고 좌측(+offset)이 낮은 지반 — 좌가 성토 쪽이다.""" + """우측(-offset)이 높고 좌측(+offset)이 낮은 지반 — 좌가 성토 쪽.""" return [ {"offset_m": round(offset, 3), "elevation_m": round(100.0 - 0.5 * offset, 4), "valid": True} for offset in [x * 0.5 for x in range(-30, 31)] ] -def _sections(chainages: list[float]) -> list[dict]: - return [ - { - "chainage_m": chainage, - "samples": _samples(), - "design": compute_cross_design( - _samples(), - 99.8, - ground_type="soil", - section_mode="right_cut", - ditch_side="right", - ), - } - for chainage in chainages - ] +def _plain() -> dict: + return compute_cross_design( + _samples(), 99.8, ground_type="soil", section_mode="right_cut", ditch_side="right" + ) _LONGITUDINAL = { @@ -56,7 +45,7 @@ _LONGITUDINAL = { } -def _write_site(root: Path, capacity_m3: float, **options) -> None: +def _write_site(root: Path) -> None: (root / "B05_Profile" / "route").mkdir(parents=True, exist_ok=True) (root / "B05_Profile" / "route" / "structures.json").write_text( json.dumps( @@ -68,7 +57,7 @@ def _write_site(root: Path, capacity_m3: float, **options) -> None: "type_id": "spoil_bank", "placement": "site", "chainage_m": 120.0, - "options": {"capacity_m3": capacity_m3, **options}, + "options": {"capacity_m3": 400.0, "side": "좌"}, } ], }, @@ -78,170 +67,43 @@ def _write_site(root: Path, capacity_m3: float, **options) -> None: ) -def _run(root: Path, capacity_m3: float, **options) -> list[dict]: - _write_site(root, capacity_m3, **options) - sections = _sections([80.0, 100.0, 120.0, 140.0, 160.0, 180.0]) - enforce_spoil_fills(_LONGITUDINAL, sections, root) - return sections +def _leftover() -> dict: + """옛 저장분 꼴 — 사토장 몫 1 ㎡ 를 노선 성토에서 뺀 채 `spoil_fill_*` 가 남음.""" + design = _plain() + return { + **design, + "fill_area_m2": round(design["fill_area_m2"] - 1.0, 4), + "spoil_fill_area_m2": 7.0, + "spoil_fill_side": "left", + "spoil_fill_width_m": 3.0, + "spoil_fill_line": [{"offset_m": 3.0, "elevation_m": 99.0}], + "spoil_fill_replaced_fill_m2": 1.0, + "spoil_fill_capacity_m3": 400.0, + "spoil_fill_structure_id": "sp-1", + } -def _placed(sections: list[dict]) -> float: - inside = [s for s in sections if s["chainage_m"] == 120.0] - return float(inside[0]["design"].get("spoil_fill_placed_m3") or 0.0) +def test_설계_결과에_사토_칸_없음() -> None: + assert not [key for key in _plain() if key.startswith("spoil_fill")] -def test_용량이_클수록_폭이_넓어진다(tmp_path: Path) -> None: - small = _run(tmp_path, 200.0) - large = _run(tmp_path, 800.0) - narrow = small[2]["design"]["spoil_fill_width_m"] - wide = large[2]["design"]["spoil_fill_width_m"] - assert 0 < narrow < wide - assert _placed(small) == pytest.approx(200.0, rel=0.02) - assert _placed(large) == pytest.approx(800.0, rel=0.02) - - -def test_담을_수_없으면_남은_몫이_값으로_남는다(tmp_path: Path) -> None: - """상한에서 멈춘다 — 임의로 더 넓히지 않는다.""" - sections = _run(tmp_path, 1_000_000.0) - design = sections[2]["design"] - assert design["spoil_fill_unplaced_m3"] > 0 - assert design["spoil_fill_width_m"] == pytest.approx(design["spoil_fill_max_width_m"]) - - -def test_노선_성토가_그만큼_줄어든다(tmp_path: Path) -> None: - """같은 흙을 두 번 세지 않는다(확정 ㉠).""" - plain = _sections([120.0])[0]["design"] - sections = _run(tmp_path, 400.0) - inside = sections[2]["design"] - assert inside["spoil_fill_area_m2"] > 0 - assert inside["spoil_fill_replaced_fill_m2"] > 0 - assert inside["fill_area_m2"] == pytest.approx( - plain["fill_area_m2"] - inside["spoil_fill_replaced_fill_m2"], abs=1e-6 - ) - - -def test_자동이면_성토_쪽에_선다(tmp_path: Path) -> None: - sections = _run(tmp_path, 400.0, side="자동(성토 쪽)") - # right_cut = 우측 절토 ⇒ 성토는 좌측(+offset). - assert sections[2]["design"]["spoil_fill_side"] == "left" - - -def test_기준_측점_밖_측점은_안_건드린다(tmp_path: Path) -> None: - """새 측점을 만들지 않고, 기준 측점 밖은 종전 그대로다(확정 ③).""" - sections = _run(tmp_path, 400.0) - assert len(sections) == 6 - for section in sections: - design = section["design"] - if section["chainage_m"] == 120.0: - assert design["spoil_fill_area_m2"] > 0 - else: - assert design.get("spoil_fill_area_m2", 0) == 0 - - -def test_용량이_없으면_아무것도_안_세운다(tmp_path: Path) -> None: - (tmp_path / "B05_Profile" / "route").mkdir(parents=True, exist_ok=True) - (tmp_path / "B05_Profile" / "route" / "structures.json").write_text( - json.dumps( - { - "revision": 1, - "structures": [ - { - "structure_id": "sp-1", - "type_id": "spoil_bank", - "placement": "interval", - "start_m": 100.0, - "end_m": 160.0, - "options": {}, - } - ], - } - ), - encoding="utf-8", - ) - sections = _sections([120.0]) - assert enforce_spoil_fills(_LONGITUDINAL, sections, tmp_path) == 0 - assert sections[0]["design"]["spoil_fill_area_m2"] == 0 - - -def test_사토장을_지우면_값도_사라진다(tmp_path: Path) -> None: - """⚠ 화면 실측으로 잡은 자리(2026-09-09) — 얹기만 하고 **지우지 않아** 구조물을 없앤 뒤에도 - 횡단도에 계속 그려지고 면적표에도 서 있었다. - """ - sections = _run(tmp_path, 400.0) - inside = sections[2]["design"] - assert inside["spoil_fill_area_m2"] > 0 - plain_fill = _sections([120.0])[0]["design"]["fill_area_m2"] - - # 구조물을 지운다 — 빈 목록으로 덮어쓴다. - (tmp_path / "B05_Profile" / "route" / "structures.json").write_text( - json.dumps({"revision": 2, "structures": []}, ensure_ascii=False), encoding="utf-8" - ) - changed = enforce_spoil_fills(_LONGITUDINAL, sections, tmp_path) - assert changed > 0, "지운 사토장을 되돌리지 않았다" - for section in sections: - design = section["design"] - # 칸은 남아도 **값이 0** 이어야 한다 — 설계 결과는 사토장이 없어도 칸을 늘 싣는다. - assert float(design.get("spoil_fill_area_m2") or 0) == 0, section["chainage_m"] - assert float(design.get("spoil_fill_width_m") or 0) == 0, section["chainage_m"] - assert design.get("spoil_fill_capacity_m3") in (None, 0), section["chainage_m"] - # 노선 성토도 원래 값으로 돌아온다 — 사토장 몫을 빼 두었던 것이 복구된다. - assert sections[2]["design"]["fill_area_m2"] == pytest.approx(plain_fill, abs=1e-6) - - -def _write_raw(root: Path, item: dict) -> None: - (root / "B05_Profile" / "route").mkdir(parents=True, exist_ok=True) - (root / "B05_Profile" / "route" / "structures.json").write_text( - json.dumps({"revision": 1, "structures": [item]}, ensure_ascii=False), encoding="utf-8" - ) - - -def _spoiled(sections: list[dict]) -> list[float]: - return [ - s["chainage_m"] for s in sections if float(s["design"].get("spoil_fill_area_m2") or 0) > 0 +def test_사토장이_놓여도_저장_재계산이_사토_단면을_안_세움(tmp_path: Path) -> None: + _write_site(tmp_path) + sections = [ + {"chainage_m": c, "samples": _samples(), "design": _plain()} for c in (100.0, 120.0, 140.0) ] + enforce_design_lines(_LONGITUDINAL, sections, tmp_path) + _enforce_stored_designs(_LONGITUDINAL, sections, tmp_path, None) + for section in sections: + assert not [key for key in section["design"] if key.startswith("spoil_fill")] -@pytest.mark.parametrize( - ("legacy", "station"), - [ - ({"chainage_m": 140.0}, 140.0), # 옛 기준이 있으면 옛 기준 - ({}, 100.0), # 없으면 시작 - ], -) -def test_옛_구간_저장분은_옛_기준_시작_차례로_읽는다( - tmp_path: Path, legacy: dict, station: float -) -> None: - _write_raw( - tmp_path, - { - "structure_id": "sp-1", - "type_id": "spoil_bank", - "placement": "interval", - "start_m": 100.0, - "end_m": 160.0, - "options": {"capacity_m3": 200.0}, - **legacy, - }, - ) - sections = _sections([80.0, 100.0, 120.0, 140.0, 160.0, 180.0]) - enforce_spoil_fills(_LONGITUDINAL, sections, tmp_path) - assert _spoiled(sections) == [station] - - -def test_기준_측점_플러스_거리는_가장_가까운_측점에_선다(tmp_path: Path) -> None: - _write_raw( - tmp_path, - { - "structure_id": "sp-1", - "type_id": "spoil_bank", - "placement": "site", - "chainage_m": 127.0, - "options": {"capacity_m3": 200.0}, - }, - ) - sections = _sections([80.0, 100.0, 120.0, 140.0, 160.0, 180.0]) - enforce_spoil_fills(_LONGITUDINAL, sections, tmp_path) - assert _spoiled(sections) == [120.0] - # 대표 길이 = 앞뒤 측점 절반씩 20 m — 부피 = 면적 × 20 - design = sections[2]["design"] - assert design["spoil_fill_placed_m3"] == pytest.approx(design["spoil_fill_area_m2"] * 20.0) +def test_옛_사토_값은_저장_한_번에_지워지고_성토_돌아옴(tmp_path: Path) -> None: + _write_site(tmp_path) + plain_fill = _plain()["fill_area_m2"] + assert plain_fill > 1.0 # 성토가 실제로 서는 지반이라야 「돌아옴」이 뜻을 가짐 + sections = [{"chainage_m": 120.0, "samples": _samples(), "design": _leftover()}] + assert enforce_design_lines(_LONGITUDINAL, sections, tmp_path) == 1 + design = sections[0]["design"] + assert not [key for key in design if key.startswith("spoil_fill")] + assert design["fill_area_m2"] == pytest.approx(plain_fill, abs=1e-6) diff --git a/resources/tester/test_spoil_fill_mirror.py b/resources/tester/test_spoil_fill_mirror.py index dc84e0b72..9d9b83588 100644 --- a/resources/tester/test_spoil_fill_mirror.py +++ b/resources/tester/test_spoil_fill_mirror.py @@ -1,154 +1,42 @@ -"""사토장(유용토운반작업장) 성토 단면 — 파이썬·TS 짝이 같은 값을 내는지 (2026-09-09). +"""사토장 단면 — 죽은 길이 남지 않음 (PLAN 380-1 · 요구 241-8). -화면(B06 횡단도)이 그리고 서버(B08 수량)가 세는 값이라 두 쪽이 갈리면 -「그림은 이런데 수량은 저렇다」가 된다. - -⚠ 폭의 시작점이 **노면 끝**이라는 것과, 폭 상한이 **지반 샘플이 있는 데까지**라는 것을 - 함께 잠근다. 둘 다 사용자 확정이다. +사토장 · 토취장은 B06 횡단도에 그리지도 조절하지도 않음 — 사토 단면 셈(파이썬 · TS 짝)과 +그림 · 면적표 줄 · 모달 띠 · B07 표 칸을 지움. 남은 자리는 둘뿐: + · `B06_Section_Cross_Stage.ts` — 옛 사토 값이 남은 앞 출력을 다시 셈(남은 값 정리) + · 로케일 키 `B06_Design_SpoilFill_Area` — 로케일은 더하기만 """ from __future__ import annotations -import json -import re import subprocess -import sys from pathlib import Path -import pytest - PROJECT_ROOT = Path(__file__).resolve().parents[2] -if str(PROJECT_ROOT) not in sys.path: - sys.path.insert(0, str(PROJECT_ROOT)) - -from common_util.common_util_spoil_fill import ( # noqa: E402 - solve_spoil_width_m, - spoil_fill_section, - spoil_max_width_m, -) - -TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" - -#: 왼쪽으로 완만히 내려가는 지반. 노면 끝은 (2.0, 100.0). -GROUND = [(-10.0, 98.0), (0.0, 100.0), (5.0, 99.5), (10.0, 98.0), (20.0, 95.0), (30.0, 92.0)] - -_RUNNER = """ -import { writeFileSync } from "node:fs"; -import { spoilFillSection, solveSpoilWidthM, spoilMaxWidthM } from "./common_util_spoil_fill.js"; - -const ground = %s.map(([offset_m, elevation_m]) => ({ offset_m, elevation_m })); -const base = { ground, startOffsetM: 2.0, startElevationM: 100.0, side: "left" }; -const out = { - maxWidth: spoilMaxWidthM(base), - widths: [0, 2, 4, 8, 40].map((widthM) => { - const s = spoilFillSection({ ...base, widthM, slopeRatioN: 1.5 }); - return { area: s.area_m2, toe: s.toeOffsetM, unclosed: s.unclosed, line: s.line.map((p) => [p.offset_m, p.elevation_m]) }; - }), - right: (() => { - const s = spoilFillSection({ ...base, side: "right", startOffsetM: -2.0, widthM: 4, slopeRatioN: 1.5 }); - return { area: s.area_m2, toe: s.toeOffsetM, line: s.line.map((p) => [p.offset_m, p.elevation_m]) }; - })(), - solved: [10, 40, 100000].map((target) => { - const r = solveSpoilWidthM({ ...base, slopeRatioN: 1.5 }, target); - return { widthM: r.widthM, area: r.section.area_m2 }; - }), -}; -writeFileSync(process.argv[2], JSON.stringify(out)); -""" % json.dumps(GROUND) +ALLOWED = { + "B06_Section/logic/B06_Section_Cross_Stage.ts", + "ui_template/ui_template_locale_b2.ts", +} -def _ts_values(tmp_path: Path) -> dict: - out = tmp_path / "js" - subprocess.run( # noqa: S603 — 고정 실행 파일 - [ - "node", - str(TSC), - str(PROJECT_ROOT / "common_util" / "common_util_spoil_fill.ts"), - "--outDir", - str(out), - "--module", - "esnext", - "--target", - "es2022", - "--moduleResolution", - "bundler", - "--ignoreConfig", - ], +def test_사토_단면_코드_파일_없음() -> None: + for rel in ( + "B06_Section/B06_Section_Engine_SpoilFill.py", + "B06_Section/B06_Section_UI_Cross_SpoilFill.ts", + "common_util/common_util_spoil_fill.py", + "common_util/common_util_spoil_fill.ts", + ): + assert not (PROJECT_ROOT / rel).exists(), rel + + +def test_사토_단면_칸을_읽는_코드_없음() -> None: + out = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["git", "grep", "-l", "-i", "-E", r"spoil_fill|spoil-fill|spoilfill|spoil_sites", "--"] + + ["*.py", "*.ts", "*.css", ":!resources/tester", ":!docs"], cwd=str(PROJECT_ROOT), - check=True, capture_output=True, + text=True, + encoding="utf-8", + check=False, ) - for emitted in out.glob("*.js"): - text = emitted.read_text(encoding="utf-8") - emitted.write_text( - re.sub(r'(from "\./[^"]+?)(")', lambda m: m.group(1) + ".js" + m.group(2), text), - encoding="utf-8", - ) - (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8") - result = tmp_path / "out.json" - subprocess.run( # noqa: S603 - ["node", str(out / "runner.mjs"), str(result)], - cwd=str(PROJECT_ROOT), - check=True, - capture_output=True, - ) - return json.loads(result.read_text(encoding="utf-8")) - - -@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치") -def test_두_쪽이_같은_값을_낸다(tmp_path: Path) -> None: - ts = _ts_values(tmp_path) - - assert ts["maxWidth"] == pytest.approx(spoil_max_width_m(GROUND, 2.0, "left")) - for index, width in enumerate((0, 2, 4, 8, 40)): - py = spoil_fill_section(GROUND, 2.0, 100.0, "left", width, 1.5) - got = ts["widths"][index] - assert got["area"] == pytest.approx(py.area_m2), width - assert got["toe"] == pytest.approx(py.toe_offset_m), width - assert got["unclosed"] == py.unclosed, width - assert [tuple(point) for point in got["line"]] == py.line, width - - right = spoil_fill_section(GROUND, -2.0, 100.0, "right", 4, 1.5) - assert ts["right"]["area"] == pytest.approx(right.area_m2) - assert [tuple(point) for point in ts["right"]["line"]] == right.line - - for index, target in enumerate((10, 40, 100000)): - width, section = solve_spoil_width_m(GROUND, 2.0, 100.0, "left", 1.5, target) - assert ts["solved"][index]["widthM"] == pytest.approx(width), target - assert ts["solved"][index]["area"] == pytest.approx(section.area_m2), target - - -def test_폭은_노면_끝에서_잰다() -> None: - """시작점이 노면 끝이라 **그 구간 노견도 이 성토 안에 든다**(사용자 확정).""" - section = spoil_fill_section(GROUND, 2.0, 100.0, "left", 4.0, 1.5) - assert section.line[0] == (2.0, 100.0) - assert section.line[1] == (6.0, 100.0) # 평상은 노면 끝 높이 그대로 - assert section.toe_offset_m > 6.0 - - -def test_넓힐수록_많이_담긴다() -> None: - areas = [spoil_fill_section(GROUND, 2.0, 100.0, "left", w, 1.5).area_m2 for w in (0, 2, 4, 8)] - assert areas == sorted(areas) - assert areas[0] < areas[-1] - - -def test_지반_샘플_밖으로는_안_넓힌다() -> None: - """샘플 밖은 지반을 모른다 — 넓히면 근거 없는 부피가 된다.""" - limit = spoil_max_width_m(GROUND, 2.0, "left") - assert limit == pytest.approx(28.0) - wide = spoil_fill_section(GROUND, 2.0, 100.0, "left", 100.0, 1.5) - assert wide.toe_offset_m <= 30.0 - assert wide.unclosed is True # 지반을 못 만나고 잘렸음을 드러낸다 - - -def test_담을_수_없는_양은_상한_폭을_돌려준다() -> None: - """임의로 더 넓히지 않는다 — 못 담는 몫은 부르는 쪽이 「남은 사토」로 드러낸다.""" - width, section = solve_spoil_width_m(GROUND, 2.0, 100.0, "left", 1.5, 1e6) - assert width == pytest.approx(spoil_max_width_m(GROUND, 2.0, "left")) - assert section.area_m2 < 1e6 - - -def test_기울기가_완만하면_더_담긴다() -> None: - steep = spoil_fill_section(GROUND, 2.0, 100.0, "left", 4.0, 1.0) - gentle = spoil_fill_section(GROUND, 2.0, 100.0, "left", 4.0, 2.0) - assert gentle.area_m2 > steep.area_m2 + assert out.returncode in (0, 1), out.stderr + assert set(out.stdout.split()) <= ALLOWED, out.stdout From 8563da1c76f727e0a7462ec98100f95e231b5a75 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 15:03:16 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(380-2):=20=EC=82=AC=ED=86=A0=EC=9E=A5?= =?UTF-8?q?=20=C2=B7=20=ED=86=A0=EC=B7=A8=EC=9E=A5=20=EC=9E=90=EB=A6=AC=20?= =?UTF-8?q?=3D=20=EA=B0=80=EC=9E=A5=20=EA=B0=80=EA=B9=8C=EC=9A=B4=20?= =?UTF-8?q?=EA=B8=B0=EC=A1=B4=20=EC=B8=A1=EC=A0=90=20=C2=B7=20=EC=83=88=20?= =?UTF-8?q?=EC=B8=A1=EC=A0=90=20=EC=95=88=20=EC=8B=AC=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - common_util_structure_station: 기존 측점(일반 · 구조물 · 추가) · snapExistingStation(가운데면 앞 측점) · snapStationFor(site 만) - 폼 [추가] · 끌기(movedStructure) · 우클릭 추가 = 같은 맞춤 · 폼 칸에 맞춘 측점 다시 씀 - 옛 소수 자동 맞춤 = 기존 측점(끝 소수 · 옛 구조물 측점)에 선 site 안 옮김 - 상세 모달 CAD 글 = 맞춘 측점에서 종류 이름 + 「상세 내용: 사토장 부피」 줄(기존 길 그대로) - 시험 test_380_2_site_station(아무 m 입력 = 가까운 측점 · CAD 글 줄) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn --- .../B05_Profile_UI_Structures_Panel.ts | 8 +- .../B05_Profile_UI_Structures_Panel_Commit.ts | 10 +- B05_Profile/B05_Profile_UI_Structures_Snap.ts | 18 ++- common_util/common_util_structure_station.ts | 27 +++- .../tester/helper_380_2_site_station.cjs | 52 +++++++ resources/tester/test_380_2_site_station.py | 132 ++++++++++++++++++ 6 files changed, 235 insertions(+), 12 deletions(-) create mode 100644 resources/tester/helper_380_2_site_station.cjs create mode 100644 resources/tester/test_380_2_site_station.py diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 8b6328978..436a16adc 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -47,11 +47,7 @@ import { showsAnchor } from "./logic/B05_Profile_UI_Structures_Define"; import { hiddenOptionKeys } from "./B05_Profile_UI_Structures_Define_Form"; import { stationLabel as formatStation } from "../common_util/common_util_station"; import { PIPE_MATCH_M, resolveSelection } from "../A00_Common/design/design_selection"; -import { - movedStructure, - snapOldStations, - snapStructureStation, -} from "./B05_Profile_UI_Structures_Snap"; +import { movedStructure, snapOldStations, snapStationFor } from "./B05_Profile_UI_Structures_Snap"; /** 공개 타입·표시 상수는 700줄 제한으로 `_Structures_Panel_Types` 로 옮겼다 — * 옛 임포트 경로가 그대로 동작하도록 여기서 다시 내보낸다(2026-09-04). */ @@ -645,7 +641,7 @@ export function createStructuresSection( loadForm(structures.find((entry) => entry.structure_id === structureId) ?? null); }, addAt(chainageM, typeId) { - chainageM = snapStructureStation(chainageM); // 정수 m · 일반 측점 붙기(234-1) + chainageM = snapStationFor(chainageM, placementOf(typeId)); // 정수 m · site = 기존 측점(234-1 · 380-2) const type = typeMap().get(typeId); if (!type) return; // 우클릭 = [추가]까지 끝난 상태(116-1 · 08-18 「즉시 추가 폐지」를 바꿈) — 새로 놓기에서 diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts index a5d8d2110..dbd879ce4 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts @@ -23,7 +23,10 @@ import { readStructureSpec, specRenderedKeys } from "./B05_Profile_UI_Structures import { asRoad } from "./logic/B05_Profile_UI_Structures_Define"; import type { GroupBoxes } from "./B05_Profile_UI_Structures_Groups"; import { specFor } from "../A00_Common/design/logic/design_structure_pipe"; -import { snapStructureStation } from "../common_util/common_util_structure_station"; +import { + snapExistingStation, + snapStructureStation, +} from "../common_util/common_util_structure_station"; import { readGroupItems, readSummaryItem, @@ -328,6 +331,11 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis } else { anchor = ctx.anchorFields.read(step, true); if (anchor === null) return stop(ctx.anchorFields.station, "기준 측점"); + if (placement === "site") { + // 사토장 · 토취장 = 친 m 에서 가장 가까운 기존 측점 · 새 측점 안 심음(380-2) + anchor = snapExistingStation(anchor); + ctx.anchorFields.write(anchor, step); + } } // 필수 옵션(미확정 기본값 없음)이 비어 있으면 추가하지 않는다 — 서버도 거절한다. // 상세(detail) 옵션은 폼에 없으므로 여기 걸리지 않는다(B06/B07에서 받는다). diff --git a/B05_Profile/B05_Profile_UI_Structures_Snap.ts b/B05_Profile/B05_Profile_UI_Structures_Snap.ts index 29ac14a58..1b16c8f2b 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Snap.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Snap.ts @@ -11,18 +11,21 @@ import { showToast } from "@ui/ui_template_elements"; import { isSnappedStation, + snapExistingStation, + snapStationFor, snapStructureStation, } from "../common_util/common_util_structure_station"; import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; -export { snapStructureStation }; +export { snapStationFor, snapStructureStation }; import { structureAnchorM, type StructureInstance } from "./B05_Profile_Api_Structures"; import type { FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility"; import type { PipeFacilityItem } from "./B05_Profile_UI_Structures_Panel_Types"; -/** 구조물을 그 측점으로 옮긴 모양 — 구간은 기준과의 거리 · 길이를 지키되 시작 · 끝도 맞춤. */ +/** 구조물을 그 측점으로 옮긴 모양 — 구간은 기준과의 거리 · 길이를 지키되 시작 · 끝도 맞춤 · + * site(사토장 · 토취장) = 가장 가까운 기존 측점(380-2). */ export function movedStructure(target: StructureInstance, toChainageM: number): StructureInstance { - const anchor = snapStructureStation(toChainageM); + const anchor = snapStationFor(toChainageM, target.placement); if (target.placement !== "interval") return { ...target, chainage_m: anchor }; const length = (target.end_m ?? 0) - (target.start_m ?? 0); const start = snapStructureStation(anchor - (structureAnchorM(target) - (target.start_m ?? 0))); @@ -47,9 +50,16 @@ export function followSnapped( const offGrid = (value: number | null | undefined): boolean => typeof value === "number" && !isSnappedStation(value); +/** 기존 측점(끝 소수 · 옛 소수 구조물 측점 포함)에 선 site 인가 — 맞춤 밖으로 안 셈(380-2). */ +const onExisting = (structure: StructureInstance): boolean => + structure.placement === "site" && + typeof structure.chainage_m === "number" && + snapExistingStation(structure.chainage_m) === structure.chainage_m; + /** 기준 · 시작 · 끝 중 하나라도 맞춤 밖(소수)인 구조물인가. */ export const structureOffGrid = (structure: StructureInstance): boolean => - offGrid(structure.chainage_m) || offGrid(structure.start_m) || offGrid(structure.end_m); + !onExisting(structure) && + (offGrid(structure.chainage_m) || offGrid(structure.start_m) || offGrid(structure.end_m)); export interface SnapPlan { /** 목록 자리 · 옮긴 모양. */ diff --git a/common_util/common_util_structure_station.ts b/common_util/common_util_structure_station.ts index 0bd1463b2..f0b2f8c99 100644 --- a/common_util/common_util_structure_station.ts +++ b/common_util/common_util_structure_station.ts @@ -40,14 +40,16 @@ const EPS = 1e-6; export const SAME_STATION_M = 0.005; let general: number[] = []; +let listed: number[] = []; -/** 일반 측점 목록을 둔다 — 횡단 상세를 읽을 때마다(B05 · B06 한 노선). */ +/** 일반 측점 목록을 둔다 — 횡단 상세를 읽을 때마다(B05 · B06 한 노선). 구조물 측점까지 든 목록도 둠(380-2). */ export function setGeneralStations( stations: ReadonlyArray<{ chainage_m: number; kind?: string }> | null | undefined, ): void { general = (stations ?? []) .filter((station) => station.kind !== "irregular") .map((station) => station.chainage_m); + listed = (stations ?? []).map((station) => station.chainage_m); } export const generalStations = (): readonly number[] => general; @@ -94,6 +96,29 @@ export const isSnappedStation = ( stations: readonly number[] = general, ): boolean => Math.abs(snapStructureStation(chainageM, stations) - chainageM) < EPS; +/* ── 기존 측점 맞춤(380-2 · 요구 241-8) — 새 측점을 안 심는 자리 꼴(site · 사토장 · 토취장) ── */ + +/** 기존 측점(일반 · 구조물 · 추가) — 오름차순 · 같은 m 하나. + * ponytail: 구조물 측점 = 마지막으로 읽은 횡단 상세 것 — 세션에서 새로 놓은 관 측점은 다시 읽을 때까지 빠짐. */ +export const existingStations = (): number[] => + [...new Set([...listed, ...plusSource()])].sort((a, b) => a - b); + +/** 가장 가까운 기존 측점(가운데면 앞 측점) — 목록이 비면 정수 m 맞춤. 두 번 맞춰도 같다. */ +export function snapExistingStation( + chainageM: number, + stations: readonly number[] = existingStations(), +): number { + if (!Number.isFinite(chainageM) || !stations.length) return snapStructureStation(chainageM); + return stations.reduce((best, at) => { + const [gap, bestGap] = [Math.abs(at - chainageM), Math.abs(best - chainageM)]; + return gap < bestGap - EPS || (gap < bestGap + EPS && at < best) ? at : best; + }); +} + +/** 놓는 꼴 따라 맞춤 — site = 가장 가까운 기존 측점 · 그 밖 = 정수 m. */ +export const snapStationFor = (chainageM: number, placement: string | null | undefined): number => + placement === "site" ? snapExistingStation(chainageM) : snapStructureStation(chainageM); + /* ── 측점을 심는 자리(272 · 서버 `resolve_extra_stations` · `is_station_planting_type` 짝) ── */ export interface PlantingPipe { diff --git a/resources/tester/helper_380_2_site_station.cjs b/resources/tester/helper_380_2_site_station.cjs new file mode 100644 index 000000000..33eaf4a12 --- /dev/null +++ b/resources/tester/helper_380_2_site_station.cjs @@ -0,0 +1,52 @@ +/* 380-2 사토장 · 토취장(site) = 가장 가까운 기존 측점(일반 · 구조물 · 추가) · 새 측점 안 심음. + * 진짜 `common_util_structure_station` · `_Structures_Snap` 을 Node 로 돌림. 입력 argv[2] JSON · 표준출력 JSON. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); + +function loadTs(file, stubs = {}) { + const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, file), "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + if (stubs[request]) return stubs[request]; + throw new Error(`stub 없음: ${request} (${file})`); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const catalog = require("./helper_322_catalog_module.cjs"); +const station = loadTs("common_util/common_util_structure_station.ts", { + "./common_util_structure_catalog": catalog, +}); +const snap = loadTs("B05_Profile/B05_Profile_UI_Structures_Snap.ts", { + "@ui/ui_template_elements": { showToast: () => {}, showConfirmDialog: async () => true }, + "../common_util/common_util_structure_station": station, + "../A00_Common/design/design_selection": { PIPE_MATCH_M: 0.51 }, + "./B05_Profile_Api_Structures": { + structureAnchorM: (entry) => entry.chainage_m ?? entry.start_m ?? 0, + }, +}); + +const out = {}; +out.emptySnap = station.snapExistingStation(25.4); // 목록 전 = 정수 m +station.setGeneralStations(input.stations); +station.setPlusStations(() => input.plus); +out.existing = station.existingStations(); +out.snap = input.typed.map((value) => station.snapExistingStation(value)); +out.twice = out.snap.map((value) => station.snapExistingStation(value)); +out.siteFor = station.snapStationFor(25.4, "site"); +out.pointFor = station.snapStationFor(25.4, "point"); +out.movedSite = snap.movedStructure({ placement: "site", chainage_m: 20 }, 35.2).chainage_m; +out.movedPoint = snap.movedStructure({ placement: "point", chainage_m: 20 }, 35.2).chainage_m; +out.offGrid = input.offGrid.map((entry) => snap.structureOffGrid(entry)); +out.planned = snap + .snapPlan(input.offGrid, []) + .structures.map((entry) => [entry.index, entry.next.chainage_m]); +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_380_2_site_station.py b/resources/tester/test_380_2_site_station.py new file mode 100644 index 000000000..ca6df0198 --- /dev/null +++ b/resources/tester/test_380_2_site_station.py @@ -0,0 +1,132 @@ +# -*- coding: utf-8 -*- +"""380-2 사토장 · 토취장(site) 자리 = 가장 가까운 기존 측점 · 새 측점 안 심음(요구 241-8). + +기존 측점 = 일반(격자 · 시종점) · 구조물(irregular) · 추가(plus). 아무 m 를 넣어도 그중 가장 가까운 +측점 · 가운데면 앞 측점 · 목록이 비면 정수 m. 폼 [추가] · 끌기 · 우클릭이 같은 맞춤 · +옛 소수 자동 맞춤은 기존 측점(끝 소수 포함)에 선 site 를 안 옮김. +진짜 TS 를 `helper_380_2_site_station.cjs` 가 Node 로 돌린다. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] + +STATIONS = [ + {"chainage_m": 0.0, "kind": "bp"}, + {"chainage_m": 20.0, "kind": "regular"}, + {"chainage_m": 33.4, "kind": "irregular"}, # 옛 소수 구조물 측점 + {"chainage_m": 40.0, "kind": "regular"}, + {"chainage_m": 60.0, "kind": "regular"}, + {"chainage_m": 79.8, "kind": "ep"}, # 끝 소수 +] +PLUS = [47.0, 20.0] # 추가 측점(같은 m 는 하나) +# (친 m, 기대 측점) +TYPED = [ + (25.0, 20.0), + (27.0, 33.4), # 구조물 측점 + (43.0, 40.0), + (44.0, 47.0), # 추가 측점 + (10.0, 0.0), # 가운데 = 앞 측점 + (53.5, 47.0), # 가운데 = 앞 측점 + (78.0, 79.8), # 끝 소수 그대로 + (1000.0, 79.8), # 노선 밖 = 끝 + (-5.0, 0.0), + (60.0, 60.0), +] +OFF_GRID = [ + {"placement": "site", "chainage_m": 79.8}, # 기존 측점(끝 소수) — 안 옮김 + {"placement": "site", "chainage_m": 33.4}, # 기존 구조물 측점 — 안 옮김 + {"placement": "site", "chainage_m": 50.3}, # 소수 · 측점 아님 — 가까운 기존 측점으로 + {"placement": "site", "chainage_m": 55.0}, # 정수 — 옛 규칙대로 안 옮김 + {"placement": "point", "chainage_m": 79.8}, # site 아님 — 옛 규칙(정수 m) +] + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "typescript").is_dir(), + reason="프론트엔드 의존성 미설치", +) + + +@pytest.fixture(scope="module") +def ts(tmp_path_factory) -> dict: + source = tmp_path_factory.mktemp("s380") / "in.json" + source.write_text( + json.dumps( + { + "stations": STATIONS, + "plus": PLUS, + "typed": [value for value, _ in TYPED], + "offGrid": OFF_GRID, + } + ), + encoding="utf-8", + ) + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_380_2_site_station.cjs"), str(source)], + capture_output=True, + text=True, + encoding="utf-8", + cwd=str(ROOT), + check=False, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_기존_측점_일반_구조물_추가(ts) -> None: + assert ts["existing"] == [0.0, 20.0, 33.4, 40.0, 47.0, 60.0, 79.8] + + +def test_아무_m_입력_가까운_기존_측점(ts) -> None: + assert ts["snap"] == [expected for _, expected in TYPED] + assert ts["twice"] == ts["snap"] # 두 번 맞춰도 같음 + assert ts["emptySnap"] == 25 # 목록 전 = 정수 m + + +def test_site_만_기존_측점_그_밖_정수(ts) -> None: + assert ts["siteFor"] == 20 and ts["pointFor"] == 25 + assert ts["movedSite"] == 33.4 # 끌기 = 가까운 기존 측점 + assert ts["movedPoint"] == 35 + + +def test_옛_자동_맞춤은_기존_측점_site_안_옮김(ts) -> None: + assert ts["offGrid"] == [False, False, True, False, True] + assert ts["planned"] == [[2, 47.0], [4, 79.0]] # 끝 79.8 아래 정수 + + +def test_상세_CAD_글_사토장_부피_줄() -> None: + """맞춘 기존 측점 = 상세 모달 CAD 글 측점(±0.51 m) — 종류 이름 + 「사토장 부피」 줄.""" + import sys + + if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + from B07_DesignDetail.B07_DesignDetail_Engine_Cad_CulvertNote import ( + structure_note_lines, + with_option_lines, + ) + + things = with_option_lines( + [ + { + "name": "유용토운반작업장(사토장)", + "pipe": False, + "at": 33.4, + "summary_item": None, + "group_items": None, + "type_id": "spoil_bank", + "options": {"capacity_m3": 150}, + } + ] + ) + assert structure_note_lines(things, 33.4) == [ + "유용토운반작업장(사토장)", + "상세 내용: 사토장 부피 150㎥", + ] + assert structure_note_lines(things, 40.0) == [] # 이웃 측점엔 없음