Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1

# Conflicts:
#	B06_Section/B06_Section_UI_Cross_Areas.ts
#	B06_Section/B06_Section_UI_Style_Cross_Areas.css
This commit is contained in:
2026-10-17 15:29:53 +09:00
42 changed files with 354 additions and 1774 deletions
@@ -47,11 +47,7 @@ import { showsAnchor } from "./logic/B05_Profile_UI_Structures_Define";
import { hiddenOptionKeys } from "./B05_Profile_UI_Structures_Define_Form";
import { stationLabel as formatStation } from "../common_util/common_util_station";
import { PIPE_MATCH_M, resolveSelection } from "../A00_Common/design/design_selection";
import {
movedStructure,
snapOldStations,
snapStructureStation,
} from "./B05_Profile_UI_Structures_Snap";
import { movedStructure, snapOldStations, snapStationFor } from "./B05_Profile_UI_Structures_Snap";
/** 공개 타입·표시 상수는 700줄 제한으로 `_Structures_Panel_Types` 로 옮겼다 —
* 옛 임포트 경로가 그대로 동작하도록 여기서 다시 내보낸다(2026-09-04). */
@@ -645,7 +641,7 @@ export function createStructuresSection(
loadForm(structures.find((entry) => entry.structure_id === structureId) ?? null);
},
addAt(chainageM, typeId) {
chainageM = snapStructureStation(chainageM); // 정수 m · 일반 측점 붙기(234-1)
chainageM = snapStationFor(chainageM, placementOf(typeId)); // 정수 m · site = 기존 측점(234-1 · 380-2)
const type = typeMap().get(typeId);
if (!type) return;
// 우클릭 = [추가]까지 끝난 상태(116-1 · 08-18 「즉시 추가 폐지」를 바꿈) — 새로 놓기에서
@@ -23,7 +23,10 @@ import { readStructureSpec, specRenderedKeys } from "./B05_Profile_UI_Structures
import { asRoad } from "./logic/B05_Profile_UI_Structures_Define";
import type { GroupBoxes } from "./B05_Profile_UI_Structures_Groups";
import { specFor } from "../A00_Common/design/logic/design_structure_pipe";
import { snapStructureStation } from "../common_util/common_util_structure_station";
import {
snapExistingStation,
snapStructureStation,
} from "../common_util/common_util_structure_station";
import {
readGroupItems,
readSummaryItem,
@@ -328,6 +331,11 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis
} else {
anchor = ctx.anchorFields.read(step, true);
if (anchor === null) return stop(ctx.anchorFields.station, "기준 측점");
if (placement === "site") {
// 사토장 · 토취장 = 친 m 에서 가장 가까운 기존 측점 · 새 측점 안 심음(380-2)
anchor = snapExistingStation(anchor);
ctx.anchorFields.write(anchor, step);
}
}
// 필수 옵션(미확정 기본값 없음)이 비어 있으면 추가하지 않는다 — 서버도 거절한다.
// 상세(detail) 옵션은 폼에 없으므로 여기 걸리지 않는다(B06/B07에서 받는다).
+14 -4
View File
@@ -11,18 +11,21 @@
import { showToast } from "@ui/ui_template_elements";
import {
isSnappedStation,
snapExistingStation,
snapStationFor,
snapStructureStation,
} from "../common_util/common_util_structure_station";
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
export { snapStructureStation };
export { snapStationFor, snapStructureStation };
import { structureAnchorM, type StructureInstance } from "./B05_Profile_Api_Structures";
import type { FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility";
import type { PipeFacilityItem } from "./B05_Profile_UI_Structures_Panel_Types";
/** 구조물을 그 측점으로 옮긴 모양 — 구간은 기준과의 거리 · 길이를 지키되 시작 · 끝도 맞춤. */
/** 구조물을 그 측점으로 옮긴 모양 — 구간은 기준과의 거리 · 길이를 지키되 시작 · 끝도 맞춤 ·
* site(사토장 · 토취장) = 가장 가까운 기존 측점(380-2). */
export function movedStructure(target: StructureInstance, toChainageM: number): StructureInstance {
const anchor = snapStructureStation(toChainageM);
const anchor = snapStationFor(toChainageM, target.placement);
if (target.placement !== "interval") return { ...target, chainage_m: anchor };
const length = (target.end_m ?? 0) - (target.start_m ?? 0);
const start = snapStructureStation(anchor - (structureAnchorM(target) - (target.start_m ?? 0)));
@@ -47,9 +50,16 @@ export function followSnapped<T extends { chainage_m: number }>(
const offGrid = (value: number | null | undefined): boolean =>
typeof value === "number" && !isSnappedStation(value);
/** 기존 측점(끝 소수 · 옛 소수 구조물 측점 포함)에 선 site 인가 — 맞춤 밖으로 안 셈(380-2). */
const onExisting = (structure: StructureInstance): boolean =>
structure.placement === "site" &&
typeof structure.chainage_m === "number" &&
snapExistingStation(structure.chainage_m) === structure.chainage_m;
/** 기준 · 시작 · 끝 중 하나라도 맞춤 밖(소수)인 구조물인가. */
export const structureOffGrid = (structure: StructureInstance): boolean =>
offGrid(structure.chainage_m) || offGrid(structure.start_m) || offGrid(structure.end_m);
!onExisting(structure) &&
(offGrid(structure.chainage_m) || offGrid(structure.start_m) || offGrid(structure.end_m));
export interface SnapPlan {
/** 목록 자리 · 옮긴 모양. */
-19
View File
@@ -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;
/**
+1 -30
View File
@@ -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) — 입력 지문(지반 표본 · 계획고 · 횡단 입력 · 구조물 맥락 · 표준값)이 같고
// 설계가 그때 낸 그 객체면 앞 결과 그대로. 계획고 ▲ 한 번에 다시 세는 측점은 곡선 둘레 몇 곳뿐.
-34
View File
@@ -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]
-43
View File
@@ -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 이 측점 사이를 이어 ㎡ 로 만든다. 여기서 ㎥ 로 바꾸지 않는다 —
# 단의 높이·폭이 설계도서 값이라 지어낼 수 없다.
-326
View File
@@ -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:
@@ -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;
}
-4
View File
@@ -25,7 +25,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,
@@ -522,9 +521,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(
@@ -477,29 +477,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;
}
+1 -1
View File
@@ -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 -123
View File
@@ -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;
}
+4 -46
View File
@@ -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),
}
+1 -55
View File
@@ -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];
}
-223
View File
@@ -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)
-241
View File
@@ -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) };
}
+1 -1
View File
@@ -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. */
+2 -8
View File
@@ -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",
+26 -1
View File
@@ -40,14 +40,16 @@ const EPS = 1e-6;
export const SAME_STATION_M = 0.005;
let general: number[] = [];
let listed: number[] = [];
/** 일반 측점 목록을 둔다 — 횡단 상세를 읽을 때마다(B05 · B06 한 노선). */
/** 일반 측점 목록을 둔다 — 횡단 상세를 읽을 때마다(B05 · B06 한 노선). 구조물 측점까지 든 목록도 둠(380-2). */
export function setGeneralStations(
stations: ReadonlyArray<{ chainage_m: number; kind?: string }> | null | undefined,
): void {
general = (stations ?? [])
.filter((station) => station.kind !== "irregular")
.map((station) => station.chainage_m);
listed = (stations ?? []).map((station) => station.chainage_m);
}
export const generalStations = (): readonly number[] => general;
@@ -94,6 +96,29 @@ export const isSnappedStation = (
stations: readonly number[] = general,
): boolean => Math.abs(snapStructureStation(chainageM, stations) - chainageM) < EPS;
/* ── 기존 측점 맞춤(380-2 · 요구 241-8) — 새 측점을 안 심는 자리 꼴(site · 사토장 · 토취장) ── */
/** 기존 측점(일반 · 구조물 · 추가) — 오름차순 · 같은 m 하나.
* ponytail: 구조물 측점 = 마지막으로 읽은 횡단 상세 것 — 세션에서 새로 놓은 관 측점은 다시 읽을 때까지 빠짐. */
export const existingStations = (): number[] =>
[...new Set([...listed, ...plusSource()])].sort((a, b) => a - b);
/** 가장 가까운 기존 측점(가운데면 앞 측점) — 목록이 비면 정수 m 맞춤. 두 번 맞춰도 같다. */
export function snapExistingStation(
chainageM: number,
stations: readonly number[] = existingStations(),
): number {
if (!Number.isFinite(chainageM) || !stations.length) return snapStructureStation(chainageM);
return stations.reduce((best, at) => {
const [gap, bestGap] = [Math.abs(at - chainageM), Math.abs(best - chainageM)];
return gap < bestGap - EPS || (gap < bestGap + EPS && at < best) ? at : best;
});
}
/** 놓는 꼴 따라 맞춤 — site = 가장 가까운 기존 측점 · 그 밖 = 정수 m. */
export const snapStationFor = (chainageM: number, placement: string | null | undefined): number =>
placement === "site" ? snapExistingStation(chainageM) : snapStructureStation(chainageM);
/* ── 측점을 심는 자리(272 · 서버 `resolve_extra_stations` · `is_station_planting_type` 짝) ── */
export interface PlantingPipe {
@@ -0,0 +1,52 @@
/* 380-2 사토장 · 토취장(site) = 가장 가까운 기존 측점(일반 · 구조물 · 추가) · 새 측점 안 심음.
* 진짜 `common_util_structure_station` · `_Structures_Snap` 을 Node 로 돌림. 입력 argv[2] JSON · 표준출력 JSON.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
function loadTs(file, stubs = {}) {
const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, file), "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
const localRequire = (request) => {
if (stubs[request]) return stubs[request];
throw new Error(`stub 없음: ${request} (${file})`);
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
return box.exports;
}
const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const catalog = require("./helper_322_catalog_module.cjs");
const station = loadTs("common_util/common_util_structure_station.ts", {
"./common_util_structure_catalog": catalog,
});
const snap = loadTs("B05_Profile/B05_Profile_UI_Structures_Snap.ts", {
"@ui/ui_template_elements": { showToast: () => {}, showConfirmDialog: async () => true },
"../common_util/common_util_structure_station": station,
"../A00_Common/design/design_selection": { PIPE_MATCH_M: 0.51 },
"./B05_Profile_Api_Structures": {
structureAnchorM: (entry) => entry.chainage_m ?? entry.start_m ?? 0,
},
});
const out = {};
out.emptySnap = station.snapExistingStation(25.4); // 목록 전 = 정수 m
station.setGeneralStations(input.stations);
station.setPlusStations(() => input.plus);
out.existing = station.existingStations();
out.snap = input.typed.map((value) => station.snapExistingStation(value));
out.twice = out.snap.map((value) => station.snapExistingStation(value));
out.siteFor = station.snapStationFor(25.4, "site");
out.pointFor = station.snapStationFor(25.4, "point");
out.movedSite = snap.movedStructure({ placement: "site", chainage_m: 20 }, 35.2).chainage_m;
out.movedPoint = snap.movedStructure({ placement: "point", chainage_m: 20 }, 35.2).chainage_m;
out.offGrid = input.offGrid.map((entry) => snap.structureOffGrid(entry));
out.planned = snap
.snapPlan(input.offGrid, [])
.structures.map((entry) => [entry.index, entry.next.chainage_m]);
process.stdout.write(JSON.stringify(out));
+3 -3
View File
@@ -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_상세_표_칸_키_누름_배선():
-1
View File
@@ -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;",
):
+1 -1
View File
@@ -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])
-1
View File
@@ -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):
+132
View File
@@ -0,0 +1,132 @@
# -*- coding: utf-8 -*-
"""380-2 사토장 · 토취장(site) 자리 = 가장 가까운 기존 측점 · 새 측점 안 심음(요구 241-8).
기존 측점 = 일반(격자 · 시종점) · 구조물(irregular) · 추가(plus). 아무 m 를 넣어도 그중 가장 가까운
측점 · 가운데면 앞 측점 · 목록이 비면 정수 m. 폼 [추가] · 끌기 · 우클릭이 같은 맞춤 ·
옛 소수 자동 맞춤은 기존 측점(끝 소수 포함)에 선 site 를 안 옮김.
진짜 TS 를 `helper_380_2_site_station.cjs` 가 Node 로 돌린다.
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
STATIONS = [
{"chainage_m": 0.0, "kind": "bp"},
{"chainage_m": 20.0, "kind": "regular"},
{"chainage_m": 33.4, "kind": "irregular"}, # 옛 소수 구조물 측점
{"chainage_m": 40.0, "kind": "regular"},
{"chainage_m": 60.0, "kind": "regular"},
{"chainage_m": 79.8, "kind": "ep"}, # 끝 소수
]
PLUS = [47.0, 20.0] # 추가 측점(같은 m 는 하나)
# (친 m, 기대 측점)
TYPED = [
(25.0, 20.0),
(27.0, 33.4), # 구조물 측점
(43.0, 40.0),
(44.0, 47.0), # 추가 측점
(10.0, 0.0), # 가운데 = 앞 측점
(53.5, 47.0), # 가운데 = 앞 측점
(78.0, 79.8), # 끝 소수 그대로
(1000.0, 79.8), # 노선 밖 = 끝
(-5.0, 0.0),
(60.0, 60.0),
]
OFF_GRID = [
{"placement": "site", "chainage_m": 79.8}, # 기존 측점(끝 소수) — 안 옮김
{"placement": "site", "chainage_m": 33.4}, # 기존 구조물 측점 — 안 옮김
{"placement": "site", "chainage_m": 50.3}, # 소수 · 측점 아님 — 가까운 기존 측점으로
{"placement": "site", "chainage_m": 55.0}, # 정수 — 옛 규칙대로 안 옮김
{"placement": "point", "chainage_m": 79.8}, # site 아님 — 옛 규칙(정수 m)
]
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules" / "typescript").is_dir(),
reason="프론트엔드 의존성 미설치",
)
@pytest.fixture(scope="module")
def ts(tmp_path_factory) -> dict:
source = tmp_path_factory.mktemp("s380") / "in.json"
source.write_text(
json.dumps(
{
"stations": STATIONS,
"plus": PLUS,
"typed": [value for value, _ in TYPED],
"offGrid": OFF_GRID,
}
),
encoding="utf-8",
)
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_380_2_site_station.cjs"), str(source)],
capture_output=True,
text=True,
encoding="utf-8",
cwd=str(ROOT),
check=False,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_기존_측점_일반_구조물_추가(ts) -> None:
assert ts["existing"] == [0.0, 20.0, 33.4, 40.0, 47.0, 60.0, 79.8]
def test_아무_m_입력_가까운_기존_측점(ts) -> None:
assert ts["snap"] == [expected for _, expected in TYPED]
assert ts["twice"] == ts["snap"] # 두 번 맞춰도 같음
assert ts["emptySnap"] == 25 # 목록 전 = 정수 m
def test_site_만_기존_측점_그_밖_정수(ts) -> None:
assert ts["siteFor"] == 20 and ts["pointFor"] == 25
assert ts["movedSite"] == 33.4 # 끌기 = 가까운 기존 측점
assert ts["movedPoint"] == 35
def test_옛_자동_맞춤은_기존_측점_site_안_옮김(ts) -> None:
assert ts["offGrid"] == [False, False, True, False, True]
assert ts["planned"] == [[2, 47.0], [4, 79.0]] # 끝 79.8 아래 정수
def test_상세_CAD_글_사토장_부피_줄() -> None:
"""맞춘 기존 측점 = 상세 모달 CAD 글 측점(±0.51 m) — 종류 이름 + 「사토장 부피」 줄."""
import sys
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_CulvertNote import (
structure_note_lines,
with_option_lines,
)
things = with_option_lines(
[
{
"name": "유용토운반작업장(사토장)",
"pipe": False,
"at": 33.4,
"summary_item": None,
"group_items": None,
"type_id": "spoil_bank",
"options": {"capacity_m3": 150},
}
]
)
assert structure_note_lines(things, 33.4) == [
"유용토운반작업장(사토장)",
"상세 내용: 사토장 부피 150㎥",
]
assert structure_note_lines(things, 40.0) == [] # 이웃 측점엔 없음
+6 -14
View File
@@ -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)
+28 -140
View File
@@ -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