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
@@ -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