refactor(380-1): 사토장 횡단 그림 · 사토 단면 셈 지움 — 부품 처음값 형상 · 3D 없음
- 부품 처음값 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
This commit is contained in:
@@ -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;
|
||||
/**
|
||||
|
||||
@@ -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) — 입력 지문(지반 표본 · 계획고 · 횡단 입력 · 구조물 맥락 · 표준값)이 같고
|
||||
// 설계가 그때 낸 그 객체면 앞 결과 그대로. 계획고 ▲ 한 번에 다시 세는 측점은 곡선 둘레 몇 곳뿐.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 이 측점 사이를 이어 ㎡ 로 만든다. 여기서 ㎥ 로 바꾸지 않는다 —
|
||||
# 단의 높이·폭이 설계도서 값이라 지어낼 수 없다.
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -23,7 +23,6 @@ export const DETAIL_TARGETS: Readonly<Record<string, { selector: string } | { zo
|
||||
cut_soil: { selector: "g.b06-chart__area--cut_soil" },
|
||||
cut_rock: { selector: "g.b06-chart__area--cut_rock" },
|
||||
embankment: { selector: "g.b06-chart__area--fill" },
|
||||
spoil_fill: { selector: ".b06-chart__spoil-fill" },
|
||||
ditch_soil: { zone: "ditch" },
|
||||
ditch_rock: { zone: "ditch" },
|
||||
benching: { zone: "fill" },
|
||||
|
||||
@@ -99,7 +99,6 @@ export function collectPieces(elements: Element[], axes: CrossAxesOptions): Spli
|
||||
});
|
||||
else if (el.classList.contains("b06-chart__cross-profile"))
|
||||
others.push({ line: "ground", points });
|
||||
else if (el.classList.contains("b06-chart__spoil-fill")) others.push({ line: "spoil", points });
|
||||
else if (el.classList.contains("b06-chart__rock-boundary"))
|
||||
others.push({ line: "rock", points });
|
||||
}
|
||||
|
||||
@@ -161,14 +161,13 @@ export function groupSpan(slots: ReadonlyArray<ReadonlyArray<string | null>>, 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<Record<string, string>> = {
|
||||
ditch_soil: "측구(토사)",
|
||||
ditch_rock: "측구(암석)",
|
||||
embankment: "쌓기",
|
||||
spoil_fill: "사토장",
|
||||
};
|
||||
|
||||
/** 띠 값 글(259-2) — 「깍기(토사) 면적 X㎡」 · 띠 아님 · 값 없음 = null. */
|
||||
@@ -210,7 +208,6 @@ const AREA_DESIGN: Readonly<Record<string, string>> = {
|
||||
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 =>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 · 거울 시험):
|
||||
|
||||
@@ -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<string, [boolean, boolean]> = {
|
||||
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<string, unknown>;
|
||||
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<string, unknown>,
|
||||
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<string, unknown>;
|
||||
|
||||
/** 측점 하나 — 설계 + 사토장 폭으로 다시 셈(못 세면 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;
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> | 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<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
return {
|
||||
chainageM: station.chainageM,
|
||||
design: (entry?.detail as Record<string, unknown> | 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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
@@ -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` 를 사람이 읽는 말로. 도면·화면이 「왜 이렇게 갈렸나」를 보일 때 쓴다.
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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<SpoilFillInput, "widthM">,
|
||||
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) };
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
/** 3D 부품 — none · pipe · box · ford · chute · wall · surface. */
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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_상세_표_칸_키_누름_배선():
|
||||
|
||||
@@ -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;",
|
||||
):
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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",
|
||||
):
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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_사토장_기준_측점_꼴은_토취장과_같음_옛_구간은_기준_측점으로_읽음():
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user