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 { hiddenOptionKeys } from "./B05_Profile_UI_Structures_Define_Form";
import { stationLabel as formatStation } from "../common_util/common_util_station"; import { stationLabel as formatStation } from "../common_util/common_util_station";
import { PIPE_MATCH_M, resolveSelection } from "../A00_Common/design/design_selection"; import { PIPE_MATCH_M, resolveSelection } from "../A00_Common/design/design_selection";
import { import { movedStructure, snapOldStations, snapStationFor } from "./B05_Profile_UI_Structures_Snap";
movedStructure,
snapOldStations,
snapStructureStation,
} from "./B05_Profile_UI_Structures_Snap";
/** 공개 타입·표시 상수는 700줄 제한으로 `_Structures_Panel_Types` 로 옮겼다 — /** 공개 타입·표시 상수는 700줄 제한으로 `_Structures_Panel_Types` 로 옮겼다 —
* 옛 임포트 경로가 그대로 동작하도록 여기서 다시 내보낸다(2026-09-04). */ * 옛 임포트 경로가 그대로 동작하도록 여기서 다시 내보낸다(2026-09-04). */
@@ -645,7 +641,7 @@ export function createStructuresSection(
loadForm(structures.find((entry) => entry.structure_id === structureId) ?? null); loadForm(structures.find((entry) => entry.structure_id === structureId) ?? null);
}, },
addAt(chainageM, typeId) { 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); const type = typeMap().get(typeId);
if (!type) return; if (!type) return;
// 우클릭 = [추가]까지 끝난 상태(116-1 · 08-18 「즉시 추가 폐지」를 바꿈) — 새로 놓기에서 // 우클릭 = [추가]까지 끝난 상태(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 { asRoad } from "./logic/B05_Profile_UI_Structures_Define";
import type { GroupBoxes } from "./B05_Profile_UI_Structures_Groups"; import type { GroupBoxes } from "./B05_Profile_UI_Structures_Groups";
import { specFor } from "../A00_Common/design/logic/design_structure_pipe"; 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 { import {
readGroupItems, readGroupItems,
readSummaryItem, readSummaryItem,
@@ -328,6 +331,11 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis
} else { } else {
anchor = ctx.anchorFields.read(step, true); anchor = ctx.anchorFields.read(step, true);
if (anchor === null) return stop(ctx.anchorFields.station, "기준 측점"); 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에서 받는다). // 상세(detail) 옵션은 폼에 없으므로 여기 걸리지 않는다(B06/B07에서 받는다).
+14 -4
View File
@@ -11,18 +11,21 @@
import { showToast } from "@ui/ui_template_elements"; import { showToast } from "@ui/ui_template_elements";
import { import {
isSnappedStation, isSnappedStation,
snapExistingStation,
snapStationFor,
snapStructureStation, snapStructureStation,
} from "../common_util/common_util_structure_station"; } from "../common_util/common_util_structure_station";
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; 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 { structureAnchorM, type StructureInstance } from "./B05_Profile_Api_Structures";
import type { FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility"; import type { FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility";
import type { PipeFacilityItem } from "./B05_Profile_UI_Structures_Panel_Types"; import type { PipeFacilityItem } from "./B05_Profile_UI_Structures_Panel_Types";
/** 구조물을 그 측점으로 옮긴 모양 — 구간은 기준과의 거리 · 길이를 지키되 시작 · 끝도 맞춤. */ /** 구조물을 그 측점으로 옮긴 모양 — 구간은 기준과의 거리 · 길이를 지키되 시작 · 끝도 맞춤 ·
* site(사토장 · 토취장) = 가장 가까운 기존 측점(380-2). */
export function movedStructure(target: StructureInstance, toChainageM: number): StructureInstance { 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 }; if (target.placement !== "interval") return { ...target, chainage_m: anchor };
const length = (target.end_m ?? 0) - (target.start_m ?? 0); const length = (target.end_m ?? 0) - (target.start_m ?? 0);
const start = snapStructureStation(anchor - (structureAnchorM(target) - (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 => const offGrid = (value: number | null | undefined): boolean =>
typeof value === "number" && !isSnappedStation(value); 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 => 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 { export interface SnapPlan {
/** 목록 자리 · 옮긴 모양. */ /** 목록 자리 · 옮긴 모양. */
-19
View File
@@ -518,25 +518,6 @@ export interface CrossDesign {
/** 암반부에 적용할 지반유형(`ripping_rock`/`blasting_rock`). 토사 측점은 null. */ /** 암반부에 적용할 지반유형(`ripping_rock`/`blasting_rock`). 토사 측점은 null. */
cut_rock_kind?: GroundType | null; cut_rock_kind?: GroundType | null;
fill_area_m2: number; 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. */ /** 성토측 자연 지반 경사(rise/run). 자연방토 판정 입력. 성토측이 없으면 null. */
fill_ground_slope?: number | null; fill_ground_slope?: number | null;
/** /**
+1 -30
View File
@@ -117,23 +117,7 @@ export const USER_TOUCHED_KEYS = [
] as const; ] as const;
/** 다시 계산해도 살려 두는 값 — 위 사용자 값에 **상태를 나르는 둘**을 더한 것. */ /** 다시 계산해도 살려 두는 값 — 위 사용자 값에 **상태를 나르는 둘**을 더한 것. */
// 사토장 구간값 — **브라우저가 못 만드는 값**이라 이어 붙인다. 구간 전체를 봐야 나오는 const PRESERVED_KEYS = ["status", "pavement_suggested", ...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;
/** /**
* 포장 = 노면공 · 물넘이 범위 안만(138-1) — 측점에 얹힌 제원(`pavement` · `ford_pavement`)이 곧 범위다. * 포장 = 노면공 · 물넘이 범위 안만(138-1) — 측점에 얹힌 제원(`pavement` · `ford_pavement`)이 곧 범위다.
@@ -461,19 +445,6 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
curveWideningM: section.curve_widening_m ?? null, curveWideningM: section.curve_widening_m ?? null,
shoulderExtendLeftM: extendLeft, shoulderExtendLeftM: extendLeft,
shoulderExtendRightM: extendRight, 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) — 입력 지문(지반 표본 · 계획고 · 횡단 입력 · 구조물 맥락 · 표준값)이 같고 // 국부 연산(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) soil = top * d0 - (top - bottom) * d0 * d0 / (2.0 * depth)
return soil, max(total - soil, 0.0) return soil, max(total - soil, 0.0)
return 0.0, 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 ( from B06_Section.B06_Section_Engine_Areas import (
_bench_cut_length, _bench_cut_length,
_fill_area_beyond,
_split_cut_areas, _split_cut_areas,
_split_ditch_area, _split_ditch_area,
_trapezoid_areas, _trapezoid_areas,
@@ -42,7 +41,6 @@ from B06_Section.B06_Section_Engine_Design_Geometry import (
from common_util.common_util_cross_berm import ( from common_util.common_util_cross_berm import (
BermSpec, BermSpec,
) )
from common_util.common_util_spoil_fill import spoil_fill_section
from config.config_system import ( from config.config_system import (
CURVE_WIDENING_MAX_WIDTH_M, CURVE_WIDENING_MAX_WIDTH_M,
SECTION_DITCH_SIDES, SECTION_DITCH_SIDES,
@@ -198,7 +196,6 @@ def compute_cross_design(
curve_outer_side: str | None = None, curve_outer_side: str | None = None,
curve_widening_m: float | None = None, curve_widening_m: float | None = None,
berm: BermSpec | None = None, berm: BermSpec | None = None,
spoil_fill: dict[str, Any] | None = None,
shoulder_extend_left_m: float = 0.0, shoulder_extend_left_m: float = 0.0,
shoulder_extend_right_m: float = 0.0, shoulder_extend_right_m: float = 0.0,
tilted: bool = False, tilted: bool = False,
@@ -225,12 +222,8 @@ def compute_cross_design(
surface_drop_m: 노면을 통째로 내리는 양(m) — 세월교 월류 높이. 구체 위 노면은 월류 surface_drop_m: 노면을 통째로 내리는 양(m) — 세월교 월류 높이. 구체 위 노면은 월류
높이만큼 낮게 앉으므로 계획고를 그만큼 내려 잡는다. 단면 전체가 평행 이동하므로 높이만큼 낮게 앉으므로 계획고를 그만큼 내려 잡는다. 단면 전체가 평행 이동하므로
횡단경사·측구·사면 규칙은 그대로고 절·성토 면적만 따라 바뀐다(2026-08-30 사용자). 횡단경사·측구·사면 규칙은 그대로고 절·성토 면적만 따라 바뀐다(2026-08-30 사용자).
spoil_fill: 이 측점에 선 유용토운반작업장(구 사토장) — `{"side", "width_m", "slope_ratio_n"}`.
shoulder_extend_left_m · shoulder_extend_right_m: 설계선 쉬프트 = 쪽마다 사면 이동(m · ≥ 0 · shoulder_extend_left_m · shoulder_extend_right_m: 설계선 쉬프트 = 쪽마다 사면 이동(m · ≥ 0 ·
139-6) — 사용자 값 · 저장분은 `stored_shoulder_extend`. TS 짝 `shoulderExtendLeftM` 등. 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: if ground_type not in SECTION_GROUND_TYPE_PRESET:
raise ValueError(f"지원하지 않는 지반유형입니다: {ground_type}") 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 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 비율을 만들므로, 사용자가 경계선을 올리내리면 이 값이 함께 바뀐다. # 곧 유토곡선 EA/RR/BR 비율을 만들므로, 사용자가 경계선을 올리내리면 이 값이 함께 바뀐다.
# 토사 지반은 암반 경계선 자체가 없어 전량 토사, 암 지반인데 경계선 값이 없으면(구 데이터) # 토사 지반은 암반 경계선 자체가 없어 전량 토사, 암 지반인데 경계선 값이 없으면(구 데이터)
@@ -503,22 +476,6 @@ def compute_cross_design(
"cut_rock_area_m2": round(cut_rock_area, 4), "cut_rock_area_m2": round(cut_rock_area, 4),
"cut_rock_kind": cut_rock_kind, "cut_rock_kind": cut_rock_kind,
"fill_area_m2": round(fill_area, 4), "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). # 층따기 밑수 — 성토부 아래 원지반(1:4 보다 급한 구간)의 지표면 길이(m).
# B08 이 측점 사이를 이어 ㎡ 로 만든다. 여기서 ㎥ 로 바꾸지 않는다 — # 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 사용자 확정대로 「읽기는 영구저장소에서 가져오기만」이므로 이쪽으로 옮겼다. 2026-09-06 사용자 확정대로 「읽기는 영구저장소에서 가져오기만」이므로 이쪽으로 옮겼다.
""" """
from B06_Section.B06_Section_Engine_SampleExtend import extend_unclosed_sections 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 ( from B06_Section.B06_Section_Router_Design import (
enforce_ford_surface_drops, enforce_ford_surface_drops,
enforce_pavement_ranges, enforce_pavement_ranges,
@@ -228,9 +227,6 @@ def _enforce_stored_designs(
extend_unclosed_sections(longitudinal, sections, project_root, standard, sampler) extend_unclosed_sections(longitudinal, sections, project_root, standard, sampler)
enforce_pavement_ranges(longitudinal, sections, project_root, standard) enforce_pavement_ranges(longitudinal, sections, project_root, standard)
enforce_ford_surface_drops(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: 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_soil: { selector: "g.b06-chart__area--cut_soil" },
cut_rock: { selector: "g.b06-chart__area--cut_rock" }, cut_rock: { selector: "g.b06-chart__area--cut_rock" },
embankment: { selector: "g.b06-chart__area--fill" }, embankment: { selector: "g.b06-chart__area--fill" },
spoil_fill: { selector: ".b06-chart__spoil-fill" },
ditch_soil: { zone: "ditch" }, ditch_soil: { zone: "ditch" },
ditch_rock: { zone: "ditch" }, ditch_rock: { zone: "ditch" },
benching: { zone: "fill" }, benching: { zone: "fill" },
@@ -99,7 +99,6 @@ export function collectPieces(elements: Element[], axes: CrossAxesOptions): Spli
}); });
else if (el.classList.contains("b06-chart__cross-profile")) else if (el.classList.contains("b06-chart__cross-profile"))
others.push({ line: "ground", points }); 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")) else if (el.classList.contains("b06-chart__rock-boundary"))
others.push({ line: "rock", points }); 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]> = [ const AREA_BANDS: ReadonlyArray<[string, string]> = [
["b06-chart__area--cut_soil", "cut_soil"], ["b06-chart__area--cut_soil", "cut_soil"],
["b06-chart__area--cut_rock", "cut_rock"], ["b06-chart__area--cut_rock", "cut_rock"],
["b06-chart__area--fill", "embankment"], ["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 { export function areaSourceOf(target: Element): CrossCellSource | null {
const hit = target.closest(AREA_PICK_SELECTOR); const hit = target.closest(AREA_PICK_SELECTOR);
@@ -193,7 +192,6 @@ const BAND_NAME: Readonly<Record<string, string>> = {
ditch_soil: "측구(토사)", ditch_soil: "측구(토사)",
ditch_rock: "측구(암석)", ditch_rock: "측구(암석)",
embankment: "쌓기", embankment: "쌓기",
spoil_fill: "사토장",
}; };
/** 띠 값 글(259-2) — 「깍기(토사) 면적 X㎡」 · 띠 아님 · 값 없음 = null. */ /** 띠 값 글(259-2) — 「깍기(토사) 면적 X㎡」 · 띠 아님 · 값 없음 = null. */
@@ -210,7 +208,6 @@ const AREA_DESIGN: Readonly<Record<string, string>> = {
embankment: "fill_area_m2", embankment: "fill_area_m2",
ditch_soil: "ditch_soil_area_m2", ditch_soil: "ditch_soil_area_m2",
ditch_rock: "ditch_rock_area_m2", ditch_rock: "ditch_rock_area_m2",
spoil_fill: "spoil_fill_area_m2",
}; };
const finite = (value: unknown): number | null => 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 { appendUnderdrainOverlay } from "./B06_Section_UI_Cross_Underdrain";
import { raiseWalls } from "./B06_Section_UI_Cross_Wall"; import { raiseWalls } from "./B06_Section_UI_Cross_Wall";
import { attachChuteBandPick, groundLine } from "./B06_Section_UI_Cross_Wall_Hatch"; 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 { appendRevetmentOverlay, computeRevetmentLayout } from "./B06_Section_UI_Cross_Revetment";
import { import {
appendCrossDesignOverlay, appendCrossDesignOverlay,
@@ -522,9 +521,6 @@ export function createCrossSectionCard(
[...plotLayer.children] [...plotLayer.children]
.slice(designFrom) .slice(designFrom)
.forEach((el) => el.setAttribute(DESIGN_LINE_ATTR, "")); .forEach((el) => el.setAttribute(DESIGN_LINE_ATTR, ""));
// 사토장(유용토운반작업장) — 교본 6장 3절이 횡단도 표시를 요구한다. 값은 설계 결과에서
// 그대로 오고 여기서 다시 세지 않는다. 선 종류는 터파기 파선과 갈라 둔다.
appendSpoilFillOverlay(plotLayer, section.design, x, toDisplayY);
// 암 경계선 = 지면선 복사 + 오프셋(계획선 기준 아님). // 암 경계선 = 지면선 복사 + 오프셋(계획선 기준 아님).
if (rockBoundary && section.design.geometry_preset === "rock") { if (rockBoundary && section.design.geometry_preset === "rock") {
appendRockBoundaryOverlay( appendRockBoundaryOverlay(
@@ -477,29 +477,6 @@
stroke-width: 1; 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 ㉯). */ /* 「측점 없는 관 N개」 알림 — 조용히 빠지던 것을 드러내는 줄(계획서 3-14 ㉯). */
.b06-missing-stations { .b06-missing-stations {
align-items: center; align-items: center;
@@ -159,11 +159,6 @@
stroke-width: 3; 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__design-hit,
.b06-detail__cad .b06-chart__area > polygon { .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__area > polygon {
.b06-detail__cad[data-pick="area"] .b06-chart__spoil-fill {
cursor: pointer; cursor: pointer;
} }
+1 -1
View File
@@ -6,7 +6,7 @@
- `UI_Cross_View_*` 횡단 보기 · `UI_Cross_Detail_*` 상세 창 · `UI_Cross_Design*` 설계선 편집 - `UI_Cross_View_*` 횡단 보기 · `UI_Cross_Detail_*` 상세 창 · `UI_Cross_Design*` 설계선 편집
- `UI_Page_*` 페이지 틀 · 저장 · 구조물 칸 · `UI_Section_*` 측점 · 횡단 공용 - `UI_Page_*` 페이지 틀 · 저장 · 구조물 칸 · `UI_Section_*` 측점 · 횡단 공용
- `Section_Store` · `Cross_Refresh` · `Api_*` 횡단 상세 가게 · 서버 요청 - `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 - `logic/` 순수 계산 TS
① 짝(ts ↔ py · 거울 시험): ① 짝(ts ↔ py · 거울 시험):
+1 -123
View File
@@ -1,13 +1,11 @@
/* ============================================================================= /* =============================================================================
* B06_Section_Cross_Enforce.ts * B06_Section_Cross_Enforce.ts
* 계산 묶음 cross 단계의 [확정] 보정(PLAN 349-0n) — 서버 `_enforce_stored_designs` 차례 그대로 * 계산 묶음 cross 단계의 [확정] 보정(PLAN 349-0n) — 서버 `_enforce_stored_designs` 차례 그대로
* (미교차 넓힘 → 포장 구간 → 세월교 노면 하강 → 사토장 맨 뒤). 넓힘은 `B06_Section_Cross_Stage`. * (미교차 넓힘 → 포장 구간 → 세월교 노면 하강). 넓힘은 `B06_Section_Cross_Stage`.
* *
* facilityRanges · facilityAt 새 노선 관 · 구조물 자리 → 측점마다 포장 · 기울임 · 노면 하강. * facilityRanges · facilityAt 새 노선 관 · 구조물 자리 → 측점마다 포장 · 기울임 · 노면 하강.
* ⚠ ① 짝 — `B06_Section_Router_Design.py` `pavement_ranges` · `ford_surface_drops` · `paved_at` · * ⚠ ① 짝 — `B06_Section_Router_Design.py` `pavement_ranges` · `ford_surface_drops` · `paved_at` ·
* `ford_drop_at` + `B06_Section_Engine_Tilt.py` `tilt_ranges` · `tilt_args`. * `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`. * 거울 시험 `resources/tester/test_349_0n_cross_enforce_mirror.py`.
* 세트 · 포장 · 맹암거 제원은 이미 있는 짝(`buildCulvertSets` · `pavementSpecsFrom` · `underdrainSpecsFrom`). * 세트 · 포장 · 맹암거 제원은 이미 있는 짝(`buildCulvertSets` · `pavementSpecsFrom` · `underdrainSpecsFrom`).
* 순수 — 화면 · fetch · 세션 없음. * 순수 — 화면 · 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, 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`)에 있고 계획고가 그대로면 셈 없이 재사용 — 노선 초안의 * · 바뀐 범위(`ctx.range`) 밖이고 앞 출력(`ctx.prev`)에 있고 계획고가 그대로면 셈 없이 재사용 — 노선 초안의
* 첫 계획선은 바뀐 구간 앞 꼭짓점까지 움직이므로 범위만 보면 낡은 설계가 남음(349-0f). * 첫 계획선은 바뀐 구간 앞 꼭짓점까지 움직이므로 범위만 보면 낡은 설계가 남음(349-0f).
* · [확정] 보정 ② 포장 구간 ③ 세월교 노면 하강 = 새 노선 관 · 구조물 자리(입력 pipes · structures)로 판정 · * · [확정] 보정 ② 포장 구간 ③ 세월교 노면 하강 = 새 노선 관 · 구조물 자리(입력 pipes · structures)로 판정
* ④ 사토장 = 맨 뒤(`B06_Section_Cross_Enforce` · 서버 `_enforce_stored_designs` 차례 · 349-0n). * (`B06_Section_Cross_Enforce` · 서버 `_enforce_stored_designs` 차례 · 349-0n).
* · 단계는 자기 출력만 돌려줌(입력 · 앞 단계 출력은 읽기만). DOM · fetch · 세션 없음. * · 단계는 자기 출력만 돌려줌(입력 · 앞 단계 출력은 읽기만). DOM · fetch · 세션 없음.
* input.cross 모양 = `CrossStageInput`(부르는 쪽이 세션 선택 · 저장분 · 구조물 맥락을 측점 열쇠로 모아 넘김). * 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 { extendUnclosed } from "../../B05_Profile/logic/B05_Profile_Sections_Ground";
import type { DtmGrid } from "@util/common_util_surface_sampler"; import type { DtmGrid } from "@util/common_util_surface_sampler";
import type { CulvertPipePoint } from "@util/common_util_culvert_sets"; import type { CulvertPipePoint } from "@util/common_util_culvert_sets";
import { import { facilityAt, facilityRanges, type FacilityStructure } from "./B06_Section_Cross_Enforce";
enforceSpoilFills,
facilityAt,
facilityRanges,
spoilSites,
type FacilityStructure,
} from "./B06_Section_Cross_Enforce";
export { groundSamples }; export { groundSamples };
@@ -122,6 +116,7 @@ export function createCrossStage(deps: CrossStageDeps = {}) {
for (const station of ground.stations) { for (const station of ground.stations) {
const kept = ctx.prev?.byKey[station.key]; const kept = ctx.prev?.byKey[station.key];
const elevation = planElevationAt(profile, station.chainageM); const elevation = planElevationAt(profile, station.chainageM);
// 옛 사토장 값이 남은 앞 출력은 다시 셈(사토 단면 셈 지움 · 380-1)
const spoiled = const spoiled =
Number((kept?.detail as Record<string, unknown> | null)?.spoil_fill_width_m) > 0; Number((kept?.detail as Record<string, unknown> | null)?.spoil_fill_width_m) > 0;
if (kept && !spoiled && !inRange(station, ctx) && sameSpot(kept, station, elevation)) { if (kept && !spoiled && !inRange(station, ctx) && sameSpot(kept, station, elevation)) {
@@ -169,43 +164,6 @@ export function createCrossStage(deps: CrossStageDeps = {}) {
last.computed += 1; last.computed += 1;
} }
for (const key of [...memo.keys()]) if (!byKey[key]) memo.delete(key); // 사라진 측점 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 }; return { byKey };
} }
@@ -64,7 +64,6 @@ export function pickLines(
return { return {
design: [polyline(design?.design_line)], design: [polyline(design?.design_line)],
ground: groundRuns(samples), ground: groundRuns(samples),
spoil: [polyline(design?.spoil_fill_line)],
rock: rockRuns(design, samples), rock: rockRuns(design, samples),
}; };
} }
@@ -50,11 +50,7 @@ _CROSS_RIGHT_ROWS: tuple[tuple[str | None, str | None], ...] = (
("성토파종", "fill_seeding"), ("성토파종", "fill_seeding"),
("절토살포", "cut_spraying"), ("절토살포", "cut_spraying"),
("제근", "grubbing"), ("제근", "grubbing"),
# 사토장(유용토운반작업장) — **「쌓기」와 갈라 세운다**(2026-09-09 확정 ㉠). (None, None),
# ⚠ 여기는 원래 **빈 줄**이었고, 실무 원본 횡단도에는 그 자리가 **아예 없다**
# (2026-09-09 400dpi 실측). 즉 **납품 양식을 늘리는 것이 아니라 안 쓰던 칸을 쓰는 것**이다.
# ⚠ 사토장이 없는 측점에는 값이 안 실려 종전처럼 빈칸으로 남는다.
("사토장", "spoil_fill"),
("노면다짐", "road_compaction"), ("노면다짐", "road_compaction"),
) )
@@ -79,8 +75,6 @@ QUANTITY_VALUE_KEYS: tuple[str, ...] = (
"fill_seeding", "fill_seeding",
"cut_spraying", "cut_spraying",
"grubbing", "grubbing",
# 사토장 — 「쌓기(embankment)」와 **다른 칸**이다. 합치지 않는다(확정 ㉠).
"spoil_fill",
"road_compaction", "road_compaction",
) )
@@ -52,9 +52,6 @@ AREA_KEYS: tuple[tuple[str, str], ...] = (
("embankment", "fill_area_m2"), ("embankment", "fill_area_m2"),
("ditch_soil", "ditch_soil_area_m2"), ("ditch_soil", "ditch_soil_area_m2"),
("ditch_rock", "ditch_rock_area_m2"), ("ditch_rock", "ditch_rock_area_m2"),
# 사토장 — **`fill_area_m2` 와 갈라 든다**. B06 이 이미 노선 성토에서 뺀 값이라
# 여기서 더하거나 빼지 않는다(확정 ㉠ — 같은 흙을 두 번 세지 않기).
("spoil_fill", "spoil_fill_area_m2"),
) )
#: `ditch_split_basis` 를 사람이 읽는 말로. 도면·화면이 「왜 이렇게 갈렸나」를 보일 때 쓴다. #: `ditch_split_basis` 를 사람이 읽는 말로. 도면·화면이 「왜 이렇게 갈렸나」를 보일 때 쓴다.
@@ -8,7 +8,7 @@
text = 글 그대로 — 더한 칸(`r:` 행 · `c:` 열)만 text = 글 그대로 — 더한 칸(`r:` 행 · `c:` 열)만
고른 조각(209-1 · `pieces` = [{line, start, end}]) — 교차점에서 나눈 선 조각 목록: 고른 조각(209-1 · `pieces` = [{line, start, end}]) — 교차점에서 나눈 선 조각 목록:
length = 조각 길이 합 · area = 조각이 닫힌 고리를 이루면 그 넓이(안 닫힘 → 「다시 고름」) length = 조각 길이 합 · area = 조각이 닫힌 고리를 이루면 그 넓이(안 닫힘 → 「다시 고름」)
조각 길 = 그 선(설계선 · 원지반선 · 사토장선) 위 두 끝 사이 꺾은선 조각 길 = 그 선(설계선 · 원지반선 · 암 경계선) 위 두 끝 사이 꺾은선
(두 끝이 선 밖 → 「다시 고름」) (두 끝이 선 밖 → 「다시 고름」)
· 구조물 선(`structure` · 브라우저 기하)은 위치 검산 없이 두 끝 직선. · 구조물 선(`structure` · 브라우저 기하)은 위치 검산 없이 두 끝 직선.
행(`rows`) = 묶음 안(이 행 아래 · 묶음이면 하위 줄) 또는 끝에 항목 줄(좌 · 중 · 우 이름 + 값). 행(`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]]]: def pick_lines(design: dict[str, Any], samples: Any = None) -> dict[str, list[list[Point]]]:
"""서버가 아는 선 — 고른 조각 위치 검산 대상(설계선 · 원지반선 · 사토장선 · 암 경계선).""" """서버가 아는 선 — 고른 조각 위치 검산 대상(설계선 · 원지반선 · 암 경계선)."""
return { return {
"design": [_design_line(design)], "design": [_design_line(design)],
"ground": ground_runs(samples), "ground": ground_runs(samples),
"spoil": [_polyline(design.get("spoil_fill_line"))],
"rock": rock_runs(design, samples), "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 { 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 { import {
benchCutLength, benchCutLength,
fillAreaBeyond,
splitCutAreas, splitCutAreas,
splitDitchArea, splitDitchArea,
trapezoidAreas, trapezoidAreas,
@@ -110,13 +107,6 @@ export interface CrossDesignOptions {
curveWideningM?: number | null; curveWideningM?: number | null;
/** 이 측점의 소단 제원 — 없으면 계단 없이 종전 사면 그대로(계획서 3-9). */ /** 이 측점의 소단 제원 — 없으면 계단 없이 종전 사면 그대로(계획서 3-9). */
berm?: BermSpec | null; 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`. */ /** 설계선 쉬프트 = 쪽마다 사면 이동(m · ≥ 0 · 139-6 · 151-2 노견째). 짝: 파이썬 `shoulder_extend_*_m`. */
shoulderExtendLeftM?: number; shoulderExtendLeftM?: number;
shoulderExtendRightM?: number; shoulderExtendRightM?: number;
@@ -167,15 +157,6 @@ export interface CrossDesignResult {
fill_area_m2: number; fill_area_m2: number;
/** 층따기 밑수 — 성토부 아래 원지반(1:4 보다 급한 구간)의 지표면 길이(m). */ /** 층따기 밑수 — 성토부 아래 원지반(1:4 보다 급한 구간)의 지표면 길이(m). */
bench_cut_length_m: number; 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; slope_unclosed: boolean;
fill_ground_slope: number | null; fill_ground_slope: number | null;
ditch_area_m2: number; ditch_area_m2: number;
@@ -405,8 +386,7 @@ export function computeCrossDesign(
} }
// 측구 굴착은 설계선에 포함돼 절토 면적에 자연 반영된다(별도 가산 없음). // 측구 굴착은 설계선에 포함돼 절토 면적에 자연 반영된다(별도 가산 없음).
const [cutArea, baseFillArea] = trapezoidAreas(offsets, diffs); const [cutArea, fillArea] = trapezoidAreas(offsets, diffs);
let fillArea = baseFillArea;
// 층따기 밑수(길이 m) — 성토부 아래 원지반이 1:4 보다 급한 구간의 지표면 길이. // 층따기 밑수(길이 m) — 성토부 아래 원지반이 1:4 보다 급한 구간의 지표면 길이.
const benchCut = benchCutLength(offsets, grounds, diffs); const benchCut = benchCutLength(offsets, grounds, diffs);
const fillGroundSlope = geometry.fillGroundSlope(); const fillGroundSlope = geometry.fillGroundSlope();
@@ -415,28 +395,6 @@ export function computeCrossDesign(
(Math.abs(diffs[0]) > SLOPE_CLOSE_TOLERANCE_M || (Math.abs(diffs[0]) > SLOPE_CLOSE_TOLERANCE_M ||
Math.abs(diffs[diffs.length - 1]) > 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 cutSoilArea: number;
let cutRockArea: number; let cutRockArea: number;
@@ -572,18 +530,6 @@ export function computeCrossDesign(
cut_rock_area_m2: round4(cutRockArea), cut_rock_area_m2: round4(cutRockArea),
cut_rock_kind: cutRockKind, cut_rock_kind: cutRockKind,
fill_area_m2: round4(fillArea), 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), bench_cut_length_m: round4(benchCut),
slope_unclosed: slopeUnclosed, slope_unclosed: slopeUnclosed,
fill_ground_slope: fillGroundSlope === null ? null : round4(fillGroundSlope), fill_ground_slope: fillGroundSlope === null ? null : round4(fillGroundSlope),
@@ -159,46 +159,3 @@ export function splitDitchArea(
} }
return [0, 0]; 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; station: string;
/** 카드 이름 부품 — name · pipe. */ /** 카드 이름 부품 — name · pipe. */
title: string; 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; shape: string;
shapeOpts: Record<string, unknown>; shapeOpts: Record<string, unknown>;
/** 3D 부품 — none · pipe · box · ford · chute · wall · surface. */ /** 3D 부품 — none · pipe · box · ford · chute · wall · surface. */
+2 -8
View File
@@ -150,12 +150,6 @@
"읽는 칸": ["pave_spec", "height_m", "widening_off"], "읽는 칸": ["pave_spec", "height_m", "widening_off"],
"자리꼴": ["interval"], "자리꼴": ["interval"],
"정본": ["structures"] "정본": ["structures"]
},
"spoil": {
"이름": "사토(성토 채움)",
"읽는 칸": ["capacity_m3"],
"자리꼴": ["site"],
"정본": ["structures"]
} }
}, },
"3D": { "3D": {
@@ -465,8 +459,8 @@
"목록": "both", "목록": "both",
"횡단도": "none", "횡단도": "none",
"카드이름": "name", "카드이름": "name",
"형상": "spoil", "형상": "none",
"3D": "surface" "3D": "none"
}, },
"borrow_pit": { "borrow_pit": {
"목록": "both", "목록": "both",
+26 -1
View File
@@ -40,14 +40,16 @@ const EPS = 1e-6;
export const SAME_STATION_M = 0.005; export const SAME_STATION_M = 0.005;
let general: number[] = []; let general: number[] = [];
let listed: number[] = [];
/** 일반 측점 목록을 둔다 — 횡단 상세를 읽을 때마다(B05 · B06 한 노선). */ /** 일반 측점 목록을 둔다 — 횡단 상세를 읽을 때마다(B05 · B06 한 노선). 구조물 측점까지 든 목록도 둠(380-2). */
export function setGeneralStations( export function setGeneralStations(
stations: ReadonlyArray<{ chainage_m: number; kind?: string }> | null | undefined, stations: ReadonlyArray<{ chainage_m: number; kind?: string }> | null | undefined,
): void { ): void {
general = (stations ?? []) general = (stations ?? [])
.filter((station) => station.kind !== "irregular") .filter((station) => station.kind !== "irregular")
.map((station) => station.chainage_m); .map((station) => station.chainage_m);
listed = (stations ?? []).map((station) => station.chainage_m);
} }
export const generalStations = (): readonly number[] => general; export const generalStations = (): readonly number[] => general;
@@ -94,6 +96,29 @@ export const isSnappedStation = (
stations: readonly number[] = general, stations: readonly number[] = general,
): boolean => Math.abs(snapStructureStation(chainageM, stations) - chainageM) < EPS; ): 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` 짝) ── */ /* ── 측점을 심는 자리(272 · 서버 `resolve_extra_stations` · `is_station_planting_type` 짝) ── */
export interface PlantingPipe { 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_soil"] == ["b06-chart__area--cut_soil"]
assert active["cut_rock"] == ["b06-chart__area--cut_rock"] assert active["cut_rock"] == ["b06-chart__area--cut_rock"]
assert active["embankment"] == ["b06-chart__area--fill"] 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["ground"] == ["b06-chart__center"]
assert active["ditch_soil"] == ["ditch"] assert active["ditch_soil"] == ["ditch"]
assert active["grading_cut"] == ["cutSlope"] assert active["grading_cut"] == ["cutSlope"]
@@ -69,9 +69,9 @@ def test_칸마다_그림_강조(out):
def test_대상_있는_키만_빈칸은_빠짐(out): def test_대상_있는_키만_빈칸은_빠짐(out):
"""164-2 — 그림에 대상이 없는 칸(암석 띠 · 사토장 없음)은 `data-empty`.""" """164-2 — 그림에 대상이 없는 칸(암석 띠 없음)은 `data-empty`."""
assert out["present"] == sorted(out["targets"]) 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_상세_표_칸_키_누름_배선(): 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__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__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__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 .is-picked {\n background: color-mix(in srgb, var(--color-danger) 18%",
".b06-detail__qtable [data-key][data-empty] {\n cursor: default;", ".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_soil"] == {"src": "area", "key": "cut_soil"}
assert area["cut_rock"] == {"src": "area", "key": "cut_rock"} assert area["cut_rock"] == {"src": "area", "key": "cut_rock"}
assert area["fill"] == {"src": "area", "key": "embankment"} 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 assert area["other"] is None
# 화면 px → m(209-1 `pxToM`) · 옛 저장 꼴(두 끝) 길이 = hypot 그대로 # 화면 px → m(209-1 `pxToM`) · 옛 저장 꼴(두 끝) 길이 = hypot 그대로
assert out["pxBack"][0] == pytest.approx(PICK[0]) and out["pxBack"][1] == pytest.approx(PICK[1]) 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(): def test_python_stored_reads_default_off():
for rel in ( for rel in (
"B06_Section/B06_Section_Router_Design.py", "B06_Section/B06_Section_Router_Design.py",
"B06_Section/B06_Section_Engine_SpoilFill.py",
"B06_Section/B06_Section_Engine_SampleExtend.py", "B06_Section/B06_Section_Engine_SampleExtend.py",
"B05_Profile/B05_Profile_Router_Confirm.py", "B05_Profile/B05_Profile_Router_Confirm.py",
): ):
@@ -77,11 +77,12 @@ TABLE = {
"barrier_gate", "barrier_gate",
"completion_sign", "completion_sign",
"guardrail", "guardrail",
"borrow_pit",
"etc", "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"), "wildlife_path": ("b06", "none", "name", "none", "none"),
"revegetation": ("b06", "none", "name", "none", "none"), "revegetation": ("b06", "none", "name", "none", "none"),
"pavement_concrete": ("both", "none", "name", "pavement", "surface"), "pavement_concrete": ("both", "none", "name", "pavement", "surface"),
@@ -2,9 +2,8 @@
· 포장 구간 · 기울임 · 세월교 노면 하강 판정 = 서버 `paved_at` · `tilt_args` · `ford_drop_at` · 포장 구간 · 기울임 · 세월교 노면 하강 판정 = 서버 `paved_at` · `tilt_args` · `ford_drop_at`
(누가거리 촘촘히 · 경계 ±0.02) (누가거리 촘촘히 · 경계 ±0.02)
· cross 단계 결과 = 서버 차례(포장 구간 → 세월교 노면 하강 → 사토장 맨 뒤) 뒤 설계 — 측점마다 · cross 단계 결과 = 서버 차례(포장 구간 → 세월교 노면 하강) 뒤 설계 — 측점마다 면적 · 포장 · 기울임 · 하강
면적 · 포장 · 기울임 · 하강 · 사토장 폭 · 면적 · 담은 양 · 사토장이 놓여도 단면 칸(`spoil_fill_*`) 없음(380-1 — 사토 단면 셈 지움)
(용량 이분법 · 상한 · 「자동(성토 쪽)」 · 「좌」 · 못 가르는 「자동」)
· 관 · 구조물 입력이 없으면 저장분 측점 열쇠 값 그대로(보정 안 함) · 관 · 구조물 입력이 없으면 저장분 측점 열쇠 값 그대로(보정 안 함)
짝: `helper_349_0n_cross_enforce.cjs` · TS `B06_Section/logic/B06_Section_Cross_Enforce.ts`. 짝: `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 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_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_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 common_util.common_util_drainage_pipes import parse_pipe_points # noqa: E402
from config.config_system_design import STANDARD_CROSS_SECTION # 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") @pytest.fixture(scope="module")
def server(r) -> list[dict]: def server(r) -> list[dict]:
"""서버 [확정] 차례 그대로 — 저장분(보정 전) → 포장 구간 → 세월교 하강 → 사토장.""" """서버 [확정] 차례 그대로 — 저장분(보정 전) → 포장 구간 → 세월교 하강."""
patch = pytest.MonkeyPatch() patch = pytest.MonkeyPatch()
sets = culvert_sets_from(parse_pipe_points(PIPES)) sets = culvert_sets_from(parse_pipe_points(PIPES))
for module in (design_mod, tilt_mod): for module in (design_mod, tilt_mod):
@@ -191,7 +189,6 @@ def server(r) -> list[dict]:
try: try:
design_mod.enforce_pavement_ranges(longitudinal, sections, root, STANDARD_CROSS_SECTION) design_mod.enforce_pavement_ranges(longitudinal, sections, root, STANDARD_CROSS_SECTION)
design_mod.enforce_ford_surface_drops(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) pave = design_mod.pavement_ranges(root)
tilts = tilt_mod.tilt_ranges(root) tilts = tilt_mod.tilt_ranges(root)
drops = design_mod.ford_surface_drops(root) drops = design_mod.ford_surface_drops(root)
@@ -230,12 +227,6 @@ KEYS = (
"cut_area_m2", "cut_area_m2",
"fill_area_m2", "fill_area_m2",
"surface_drop_m", "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"], station["key"],
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) assert a.get(key) == b.get(key), (station["key"], key)
def test_사토장_세_경우(r, server): def test_사토장_놓여도_단면_칸_없음(r, server):
by = {s["chainage_m"]: s["design"] for s in server} """380-1 — 사토장 셋(sp1~3)이 놓여도 서버 · 브라우저 단계 모두 사토 단면 칸 없음."""
assert by[340.0]["spoil_fill_placed_m3"] == pytest.approx(300, abs=0.5) # 이분법 — 용량만큼 designs = [s["design"] for s in server] + list(r["ts"]["stage"]["byKey"].values())
assert by[340.0]["spoil_fill_side"] == "right" # left_cut 의 성토면 assert len(designs) > len(server)
assert by[360.0]["spoil_fill_unplaced_m3"] > 0 # 상한 — 못 담은 몫 for design in designs:
assert not by[20.0].get("spoil_fill_width_m") # 「자동」 = 못 가름 assert not [key for key in design if key.startswith("spoil_fill")]
assert r["ts"]["stage"]["byKey"]["grid:17"]["spoil_fill_placed_m3"] == pytest.approx(
300, abs=0.5
)
def test_관_구조물_입력이_없으면_보정_안_함(r): 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. """PLAN 52-9 — 안전부대용지 › 사토장 · 토취장 = 사용자 구조물 표 08.
· 있는 종류 키 그대로(spoil_bank · borrow_pit) · 기준 측점 + 컨테이너 「상세 내용」(정의 그룹) 하나 · 있는 종류 키 그대로(spoil_bank · borrow_pit) · 기준 측점 + 컨테이너 「상세 내용」(정의 그룹) 하나
· 부피 손 입력 = 옛 용량 키 `capacity_m3`(B06 사토 배분이 읽는 자리) · 부피 손 입력 = 옛 용량 키 `capacity_m3`(상세 CAD 글 「사토장 부피」 · 380)
· 옛 칸은 정의에서 빼 지난 정의 값 · 옛 칸은 정의에서 빼 지난 정의 값
· 잇기 = 그룹 id(`group_items`) — 집계 표가 그룹마다 한 줄 · 잇기 = 그룹 id(`group_items`) — 집계 표가 그룹마다 한 줄
""" """
@@ -10,7 +10,7 @@ import json
from pathlib import Path from pathlib import Path
from B05_Profile.B05_Profile_Structures_Schema import DEFINITION_PATH, load_structure_types 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.M02_Table_Fill import _with_group_items
from M02_MasterTemplete.StructOptions import M02_SO_Definition from M02_MasterTemplete.StructOptions import M02_SO_Definition
from M02_MasterTemplete.StructOptions.M02_SO_Groups import groups_of 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]["그룹"] == "상세 내용" assert rows[1]["summary_item"] == link and rows[1]["그룹"] == "상세 내용"
def test_B06_사토_배분은_부피를_그대로_읽음_옛_값도(): def test_380_사토장_토취장은_횡단_그림_3D_없음():
new = {"type_id": "spoil_bank", "start_m": 20, "end_m": 40, "options": {"capacity_m3": 150}} for type_id in KINDS:
old = { row = catalog_row(type_id)
"type_id": "spoil_bank", assert (row["station"], row["shape"], row["model3d"]) == ("none", "none", "none"), type_id
"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_54_7_사토장_기준_측점_꼴은_토취장과_같음_옛_구간은_기준_측점으로_읽음(): def test_54_7_사토장_기준_측점_꼴은_토취장과_같음_옛_구간은_기준_측점으로_읽음():
@@ -192,30 +192,6 @@ CASES = [
"cut_slope_ratio": 1.0, "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) — 쪽마다 노견 확장 · 측구 쪽(절토)과 성토 쪽을 같이 넓힘 # 설계선 쉬프트(139-6) — 쪽마다 노견 확장 · 측구 쪽(절토)과 성토 쪽을 같이 넓힘
"name": "노견 확장 좌 0.7 · 우 1.2", "name": "노견 확장 좌 0.7 · 우 1.2",
@@ -347,17 +323,9 @@ const camel = {
tilted: "tilted", tilted: "tilted",
tilt_pct: "tiltPct", tilt_pct: "tiltPct",
}; };
// 사토장 제원만 칸 이름이 다르다 — 파이썬은 snake, TS 는 camel 이다.
const spoilCamel = { side: "side", width_m: "widthM", slope_ratio_n: "slopeRatioN" };
const results = input.cases.map((item) => { const results = input.cases.map((item) => {
const options = { standard: input.standard }; const options = { standard: input.standard };
for (const [key, value] of Object.entries(item.options)) { 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; options[camel[key]] = value;
} }
return computeCrossDesign(item.samples, item.design_elevation_m, options); 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]) assert not problems, "짝이 갈림:\n" + "\n".join(problems[:40])
def test_사토장_갈래가_빈_값으로_통과하지_않는다() -> None: def test_사토장_단면_칸_없음(tmp_path: Path) -> None:
"""사토장 측점이 0 ㎡ 면 대조가 아무것도 안 잠근다 — 값이 실제로 서는지 본다.""" """380-1 — 사토장은 횡단에 안 그림 · 사토 단면 셈 지움 — 두 쪽 결과 모두 `spoil_fill_*` 칸 없음."""
spoil = [ for result in [*_python_results(), *_ts_results(tmp_path)]:
result assert not [key for key in result if key.startswith("spoil_fill")]
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_시험이_빈_결과로_통과하지_않는다() -> None: def test_시험이_빈_결과로_통과하지_않는다() -> None:
@@ -1,11 +1,10 @@
"""사토장 — 용량에서 폭을 정해 측점마다 단면을 세운다 (2026-09-09). """사토장 — 횡단 단면 셈 지움 · 남은 사토 값 정리 (PLAN 380-1 · 요구 241-8).
⚠ 잠그는 것 ⚠ 잠그는 것
① 용량이 커질수록 폭이 넓어지고, 상한에서 멈추면 못 담은 몫이 값으로 남는다. ① 사토장이 놓여도 [저장]·[확정] 서버 재계산(`enforce_design_lines` → `_enforce_stored_designs`)이
② 사토장이 선 측점은 **노선 성토가 줄고** 그만큼이 사토장 몫으로 간다(두 번 세지 않기). 사토 단면을 세우지 않는다 — 횡단도에 그림 없음 · 상세 CAD 글만.
③ 「자동」이면 그 측점의 **성토 쪽**에 선다. ② 옛 저장분에 남은 `spoil_fill_*` 값(노선 성토를 뺀 꼴)은 [저장] 한 번에 지워지고
④ 기준 측점 밖 측점은 안 건드린다 — **새 측점을 만들지 않는다**. 노선 성토(`fill_area_m2`)가 사토장 없는 값으로 돌아온다.
⑤ 사토장 = 기준 측점 한 점(PLAN 54-7) — 가장 가까운 측점 하나 · 옛 구간 저장분은 시작 측점.
""" """
from __future__ import annotations from __future__ import annotations
@@ -21,32 +20,22 @@ if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT)) 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_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]: def _samples() -> list[dict]:
"""우측(-offset)이 높고 좌측(+offset)이 낮은 지반 — 좌가 성토 쪽이다.""" """우측(-offset)이 높고 좌측(+offset)이 낮은 지반 — 좌가 성토 쪽."""
return [ return [
{"offset_m": round(offset, 3), "elevation_m": round(100.0 - 0.5 * offset, 4), "valid": True} {"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)] for offset in [x * 0.5 for x in range(-30, 31)]
] ]
def _sections(chainages: list[float]) -> list[dict]: def _plain() -> dict:
return [ return compute_cross_design(
{ _samples(), 99.8, ground_type="soil", section_mode="right_cut", ditch_side="right"
"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
]
_LONGITUDINAL = { _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").mkdir(parents=True, exist_ok=True)
(root / "B05_Profile" / "route" / "structures.json").write_text( (root / "B05_Profile" / "route" / "structures.json").write_text(
json.dumps( json.dumps(
@@ -68,7 +57,7 @@ def _write_site(root: Path, capacity_m3: float, **options) -> None:
"type_id": "spoil_bank", "type_id": "spoil_bank",
"placement": "site", "placement": "site",
"chainage_m": 120.0, "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]: def _leftover() -> dict:
_write_site(root, capacity_m3, **options) """옛 저장분 꼴 — 사토장 몫 1 ㎡ 를 노선 성토에서 뺀 채 `spoil_fill_*` 가 남음."""
sections = _sections([80.0, 100.0, 120.0, 140.0, 160.0, 180.0]) design = _plain()
enforce_spoil_fills(_LONGITUDINAL, sections, root) return {
return sections **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: def test_설계_결과에_사토_칸_없음() -> None:
inside = [s for s in sections if s["chainage_m"] == 120.0] assert not [key for key in _plain() if key.startswith("spoil_fill")]
return float(inside[0]["design"].get("spoil_fill_placed_m3") or 0.0)
def test_용량이_클수록_폭이_넓어진다(tmp_path: Path) -> None: def test_사토장이_놓여도_저장_재계산이_사토_단면을_안_세움(tmp_path: Path) -> None:
small = _run(tmp_path, 200.0) _write_site(tmp_path)
large = _run(tmp_path, 800.0) sections = [
narrow = small[2]["design"]["spoil_fill_width_m"] {"chainage_m": c, "samples": _samples(), "design": _plain()} for c in (100.0, 120.0, 140.0)
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
] ]
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( def test_옛_사토_값은_저장_한_번에_지워지고_성토_돌아옴(tmp_path: Path) -> None:
("legacy", "station"), _write_site(tmp_path)
[ plain_fill = _plain()["fill_area_m2"]
({"chainage_m": 140.0}, 140.0), # 옛 기준이 있으면 옛 기준 assert plain_fill > 1.0 # 성토가 실제로 서는 지반이라야 「돌아옴」이 뜻을 가짐
({}, 100.0), # 없으면 시작 sections = [{"chainage_m": 120.0, "samples": _samples(), "design": _leftover()}]
], assert enforce_design_lines(_LONGITUDINAL, sections, tmp_path) == 1
) design = sections[0]["design"]
def test_옛_구간_저장분은_옛_기준_시작_차례로_읽는다( assert not [key for key in design if key.startswith("spoil_fill")]
tmp_path: Path, legacy: dict, station: float assert design["fill_area_m2"] == pytest.approx(plain_fill, abs=1e-6)
) -> 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)
+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 from __future__ import annotations
import json
import re
import subprocess import subprocess
import sys
from pathlib import Path from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[2] PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path: ALLOWED = {
sys.path.insert(0, str(PROJECT_ROOT)) "B06_Section/logic/B06_Section_Cross_Stage.ts",
"ui_template/ui_template_locale_b2.ts",
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)
def _ts_values(tmp_path: Path) -> dict: def test_사토_단면_코드_파일_없음() -> None:
out = tmp_path / "js" for rel in (
subprocess.run( # noqa: S603 — 고정 실행 파일 "B06_Section/B06_Section_Engine_SpoilFill.py",
[ "B06_Section/B06_Section_UI_Cross_SpoilFill.ts",
"node", "common_util/common_util_spoil_fill.py",
str(TSC), "common_util/common_util_spoil_fill.ts",
str(PROJECT_ROOT / "common_util" / "common_util_spoil_fill.ts"), ):
"--outDir", assert not (PROJECT_ROOT / rel).exists(), rel
str(out),
"--module",
"esnext", def test_사토_단면_칸을_읽는_코드_없음() -> None:
"--target", out = subprocess.run( # noqa: S603 — 고정 실행 파일
"es2022", ["git", "grep", "-l", "-i", "-E", r"spoil_fill|spoil-fill|spoilfill|spoil_sites", "--"]
"--moduleResolution", + ["*.py", "*.ts", "*.css", ":!resources/tester", ":!docs"],
"bundler",
"--ignoreConfig",
],
cwd=str(PROJECT_ROOT), cwd=str(PROJECT_ROOT),
check=True,
capture_output=True, capture_output=True,
text=True,
encoding="utf-8",
check=False,
) )
for emitted in out.glob("*.js"): assert out.returncode in (0, 1), out.stderr
text = emitted.read_text(encoding="utf-8") assert set(out.stdout.split()) <= ALLOWED, out.stdout
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