Merge remote-tracking branch 'origin/main_laptop_1' into main_desktop_1
This commit is contained in:
@@ -453,6 +453,25 @@ export interface CrossDesign {
|
||||
/** 암반부에 적용할 지반유형(`ripping_rock`/`blasting_rock`). 토사 측점은 null. */
|
||||
cut_rock_kind?: GroundType | null;
|
||||
fill_area_m2: number;
|
||||
/**
|
||||
* 사토장(유용토운반작업장) 몫 — **`fill_area_m2` 와 합치지 않는다**(2026-09-09 확정 ㉠).
|
||||
* 노면 끝 바깥은 노선 성토가 아니라 사토장 성토라, 받는 쪽이 갈라 볼 수 있어야 한다.
|
||||
* 사토장이 없는 측점은 전부 0·null 이다(구 데이터에는 아예 없어 optional).
|
||||
*/
|
||||
spoil_fill_area_m2?: number;
|
||||
spoil_fill_side?: "left" | "right" | null;
|
||||
spoil_fill_width_m?: number;
|
||||
spoil_fill_max_width_m?: number;
|
||||
spoil_fill_line?: Array<{ offset_m: number; elevation_m: number }>;
|
||||
spoil_fill_unclosed?: boolean;
|
||||
/** 사토장이 대신 차지해 노선 성토에서 뺀 몫(㎡) — 되짚기용. 합계에 또 넣지 말 것. */
|
||||
spoil_fill_replaced_fill_m2?: number;
|
||||
/** 구간(구조물) 단위 값 — 측점마다 같은 값이 실린다. 말풍선·수량이 되짚는 데 쓴다. */
|
||||
spoil_fill_capacity_m3?: number;
|
||||
spoil_fill_placed_m3?: number;
|
||||
spoil_fill_unplaced_m3?: number;
|
||||
spoil_fill_structure_id?: string | null;
|
||||
spoil_fill_extra_distance_m?: number | null;
|
||||
/** 성토측 자연 지반 경사(rise/run). 자연방토 판정 입력. 성토측이 없으면 null. */
|
||||
fill_ground_slope?: number | null;
|
||||
/**
|
||||
|
||||
@@ -89,7 +89,23 @@ export const USER_TOUCHED_KEYS = [
|
||||
] 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;
|
||||
|
||||
function preserveUserFields(
|
||||
next: NonNullable<CrossSection["design"]>,
|
||||
@@ -281,6 +297,19 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
|
||||
? section.curve_outer_side
|
||||
: null,
|
||||
curveWideningM: section.curve_widening_m ?? null,
|
||||
// 사토장 — **저장분 폭을 그대로 잇는다**. 폭은 구간 용량에서 서버가 정한 값이라
|
||||
// 브라우저가 다시 풀지 않는다(다시 풀면 그 측점만 폭이 달라져 작업장 모양이 깨진다).
|
||||
// 이어 붙이지 않으면 계획선을 만질 때마다 사토장이 그림에서 사라진다.
|
||||
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,
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
|
||||
@@ -166,3 +166,37 @@ def _split_ditch_area(ditch_spec: dict, depth_to_boundary_m: float | None) -> tu
|
||||
soil = top * d0 - (top - bottom) * d0 * d0 / (2.0 * depth)
|
||||
return soil, max(total - soil, 0.0)
|
||||
return 0.0, 0.0
|
||||
|
||||
|
||||
def _fill_area_beyond(offsets: list[float], diffs: list[float], x0: float, side: str) -> float:
|
||||
"""`x0` **바깥쪽**(사토장이 서는 쪽)의 성토 면적(㎡)만 따로 낸다.
|
||||
|
||||
⚠ 왜 있나 — 사토장이 선 측점에서는 노면 끝 바깥이 **사토장 몫**이라 노선 성토
|
||||
(`fill_area_m2`)에서 빼야 한다. 안 빼면 **같은 흙을 두 번 센다**(2026-09-09 확정 ㉠).
|
||||
⚠ 경계(`x0`)의 종거는 **보간해서** 넣는다 — 그냥 버리면 경계 한 칸이 통째로 빠져
|
||||
값이 작아진다. 좌는 `x0` 위쪽, 우는 `x0` 아래쪽이며 **둘 다 오름차순**으로 넘긴다.
|
||||
|
||||
짝: TS `fillAreaBeyond`.
|
||||
"""
|
||||
if len(offsets) < 2:
|
||||
return 0.0
|
||||
inside = (lambda x: x >= x0) if side == "left" else (lambda x: x <= x0)
|
||||
sub_offsets: list[float] = []
|
||||
sub_diffs: list[float] = []
|
||||
for index, x in enumerate(offsets):
|
||||
if index > 0:
|
||||
x_prev = offsets[index - 1]
|
||||
crosses = (x_prev < x0 < x) or (x < x0 < x_prev)
|
||||
if crosses:
|
||||
ratio = (x0 - x_prev) / (x - x_prev)
|
||||
sub_offsets.append(x0)
|
||||
sub_diffs.append(diffs[index - 1] + (diffs[index] - diffs[index - 1]) * ratio)
|
||||
if inside(x):
|
||||
sub_offsets.append(x)
|
||||
sub_diffs.append(diffs[index])
|
||||
order = sorted(range(len(sub_offsets)), key=lambda i: sub_offsets[i])
|
||||
sub_offsets = [sub_offsets[i] for i in order]
|
||||
sub_diffs = [sub_diffs[i] for i in order]
|
||||
if len(sub_offsets) < 2:
|
||||
return 0.0
|
||||
return _trapezoid_areas(sub_offsets, sub_diffs)[1]
|
||||
|
||||
@@ -33,6 +33,7 @@ from typing import Any
|
||||
|
||||
from B06_Section.B06_Section_Engine_Areas import (
|
||||
_bench_cut_length,
|
||||
_fill_area_beyond,
|
||||
_split_cut_areas,
|
||||
_split_ditch_area,
|
||||
_trapezoid_areas,
|
||||
@@ -43,6 +44,7 @@ from common_util.common_util_cross_berm import (
|
||||
fill_profile_points,
|
||||
)
|
||||
from common_util.common_util_cross_berm import elevation_at as berm_elevation_at
|
||||
from common_util.common_util_spoil_fill import spoil_fill_section
|
||||
from config.config_system import (
|
||||
CURVE_WIDENING_MAX_WIDTH_M,
|
||||
SECTION_DITCH_SIDES,
|
||||
@@ -581,6 +583,7 @@ def compute_cross_design(
|
||||
curve_outer_side: str | None = None,
|
||||
curve_widening_m: float | None = None,
|
||||
berm: BermSpec | None = None,
|
||||
spoil_fill: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""측점 하나의 표준횡단 설계선과 절·성토 단면적을 계산한다.
|
||||
|
||||
@@ -600,6 +603,10 @@ def compute_cross_design(
|
||||
surface_drop_m: 노면을 통째로 내리는 양(m) — 세월교 월류 높이. 구체 위 노면은 월류
|
||||
높이만큼 낮게 앉으므로 계획고를 그만큼 내려 잡는다. 단면 전체가 평행 이동하므로
|
||||
횡단경사·측구·사면 규칙은 그대로고 절·성토 면적만 따라 바뀐다(2026-08-30 사용자).
|
||||
spoil_fill: 이 측점에 선 유용토운반작업장(구 사토장) — `{"side", "width_m", "slope_ratio_n"}`.
|
||||
폭은 **노면 끝**(노견이 시작하는 자리)에서 재고, 그 바깥 성토는 **노선 몫이 아니라
|
||||
사토장 몫**이라 `fill_area_m2` 에서 뺀다(2026-09-09 확정 ㉠ — 두 번 세지 않기).
|
||||
`slope_ratio_n` 이 비면 그 측점의 **노선 성토 기울기**를 그대로 쓴다.
|
||||
"""
|
||||
if ground_type not in SECTION_GROUND_TYPE_PRESET:
|
||||
raise ValueError(f"지원하지 않는 지반유형입니다: {ground_type}")
|
||||
@@ -710,6 +717,26 @@ def compute_cross_design(
|
||||
abs(diffs[0]) > _SLOPE_CLOSE_TOLERANCE_M or abs(diffs[-1]) > _SLOPE_CLOSE_TOLERANCE_M
|
||||
)
|
||||
|
||||
# 사토장(유용토운반작업장) — 노면 끝 바깥에 쌓는 성토. 짝: TS `computeCrossDesign`.
|
||||
# ⚠ 그 바깥 성토는 **노선 몫이 아니다** — 빼지 않으면 같은 흙을 두 번 센다(확정 ㉠).
|
||||
spoil_section = None
|
||||
spoil_replaced = 0.0
|
||||
spoil_side = str((spoil_fill or {}).get("side") or "")
|
||||
spoil_width = _as_float((spoil_fill or {}).get("width_m"), 0.0)
|
||||
if spoil_side in ("left", "right") and spoil_width > 0:
|
||||
spoil_x0 = geometry.half_road_left if spoil_side == "left" else -geometry.half_road_right
|
||||
spoil_ratio = _as_float((spoil_fill or {}).get("slope_ratio_n"), 0.0) or geometry.fill_ratio
|
||||
spoil_section = spoil_fill_section(
|
||||
valid,
|
||||
spoil_x0,
|
||||
geometry.road_z(spoil_x0),
|
||||
spoil_side,
|
||||
spoil_width,
|
||||
spoil_ratio,
|
||||
)
|
||||
spoil_replaced = _fill_area_beyond(offsets, diffs, spoil_x0, spoil_side)
|
||||
fill_area = max(fill_area - spoil_replaced, 0.0)
|
||||
|
||||
# 절토면적 토사/암반 분리 — 지표면~암반 경계선이 토사, 그 아래가 암이다. 경계선 위치가
|
||||
# 곧 유토곡선 EA/RR/BR 비율을 만들므로, 사용자가 경계선을 올리내리면 이 값이 함께 바뀐다.
|
||||
# 토사 지반은 암반 경계선 자체가 없어 전량 토사, 암 지반인데 경계선 값이 없으면(구 데이터)
|
||||
@@ -836,6 +863,22 @@ def compute_cross_design(
|
||||
"cut_rock_area_m2": round(cut_rock_area, 4),
|
||||
"cut_rock_kind": cut_rock_kind,
|
||||
"fill_area_m2": round(fill_area, 4),
|
||||
# 사토장 몫 — **`fill_area_m2` 와 합치지 않는다**(받는 쪽이 갈라 볼 수 있어야 한다).
|
||||
"spoil_fill_area_m2": round(spoil_section.area_m2, 4) if spoil_section else 0.0,
|
||||
"spoil_fill_side": spoil_side if spoil_section else None,
|
||||
"spoil_fill_width_m": round(spoil_width, 4) if spoil_section else 0.0,
|
||||
"spoil_fill_max_width_m": round(spoil_section.max_width_m, 4) if spoil_section else 0.0,
|
||||
"spoil_fill_line": (
|
||||
[
|
||||
{"offset_m": offset, "elevation_m": elevation}
|
||||
for offset, elevation in spoil_section.line
|
||||
]
|
||||
if spoil_section
|
||||
else []
|
||||
),
|
||||
"spoil_fill_unclosed": bool(spoil_section.unclosed) if spoil_section else False,
|
||||
# 사토장이 대신 차지해 노선 성토에서 뺀 몫(㎡) — 되짚기용. 합계에 또 넣지 말 것.
|
||||
"spoil_fill_replaced_fill_m2": round(spoil_replaced, 4),
|
||||
# 층따기 밑수 — 성토부 아래 원지반(1:4 보다 급한 구간)의 지표면 길이(m).
|
||||
# B08 이 측점 사이를 이어 ㎡ 로 만든다. 여기서 ㎥ 로 바꾸지 않는다 —
|
||||
# 단의 높이·폭이 설계도서 값이라 지어낼 수 없다.
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
"""사토장(유용토운반작업장) — 용량에서 **폭을 정해** 측점마다 단면을 세운다.
|
||||
|
||||
왜 여기 있나
|
||||
사용자는 「이 구간에 ○㎥ 를 쌓겠다」고 정한다. 그런데 횡단 단면은 **폭**을 알아야
|
||||
그려진다. 그래서 그 구간 측점들을 한꺼번에 보고 **폭 하나**를 되풀이로 찾는다.
|
||||
(측점마다 폭을 달리하면 실제로 못 쌓는 모양이 나온다 — 작업장은 폭이 일정하다.)
|
||||
|
||||
`enforce_ford_surface_drops` 와 같은 자리·같은 방식이다 — **저장분을 쓰는 시점에**
|
||||
바로잡고, 저장분과 지금 값이 다를 때만 다시 계산한다.
|
||||
|
||||
정하는 것과 안 정하는 것
|
||||
⚠ **기울기·적치높이 기본값을 지어내지 않는다** — 지식DB
|
||||
`01_임도/02_상세설계/유용토운반작업장.md` §4 가 「근거에 없다. 사용자 협의 없이
|
||||
기본값을 만들지 않는다」로 못 박았다. 기울기가 비면 **그 측점의 노선 성토 기울기**를
|
||||
그대로 쓰고(이미 설계된 값), 높이는 **노면 끝 높이**로 정해진다.
|
||||
⚠ **용량이 없으면 아무것도 안 세운다** — 폭을 정할 근거가 없다.
|
||||
⚠ **지반 샘플이 있는 데까지만 넓힌다.** 상한에서도 용량이 남으면 그 몫은
|
||||
`unplaced_m3` 로 드러낸다 — 임의로 더 넓히지 않는다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B06_Section.B06_Section_Engine_Design import compute_cross_design
|
||||
from common_util.common_util_structure_face_role import structure_face_role
|
||||
|
||||
#: 폭을 좁혀 가는 이분법 반복 수. TS 짝(`solveSpoilWidthM`)과 같은 값이다.
|
||||
_SOLVE_STEPS = 24
|
||||
#: 상한을 재려고 한 번 크게 넣어 보는 폭(m). 실제로는 지반 샘플에서 잘린다.
|
||||
_MAX_PROBE_WIDTH_M = 1000.0
|
||||
#: 사토장 종류 이름 — 등록부 `spoil_bank`(현행 명칭 유용토운반작업장).
|
||||
SPOIL_TYPE_ID = "spoil_bank"
|
||||
#: 「자동(성토 쪽)」 — 등록부 `side` 의 기본 선택지. C군 구조물과 같은 낱말이다.
|
||||
_SIDE_AUTO = "자동(성토 쪽)"
|
||||
_SIDE_WORDS = {"좌": "left", "우": "right"}
|
||||
|
||||
|
||||
def _sections_in(cross_sections: list[dict[str, Any]], start_m: float, end_m: float) -> list[dict]:
|
||||
"""구간 안에 든 측점만. **새 측점을 만들지 않는다**(2026-09-09 사용자 확정 ③)."""
|
||||
picked = []
|
||||
for section in cross_sections:
|
||||
chainage = section.get("chainage_m")
|
||||
if chainage is None:
|
||||
continue
|
||||
value = float(chainage)
|
||||
if start_m - 1e-6 <= value <= end_m + 1e-6:
|
||||
picked.append(section)
|
||||
return sorted(picked, key=lambda item: float(item["chainage_m"]))
|
||||
|
||||
|
||||
def _spans(sections: list[dict[str, Any]], start_m: float, end_m: float) -> list[float]:
|
||||
"""측점마다 대표 길이(m) — 앞뒤 측점과의 절반씩. 구간 끝은 경계까지만."""
|
||||
spans: list[float] = []
|
||||
for index, section in enumerate(sections):
|
||||
chainage = float(section["chainage_m"])
|
||||
left = float(sections[index - 1]["chainage_m"]) if index else max(start_m, chainage)
|
||||
right = (
|
||||
float(sections[index + 1]["chainage_m"])
|
||||
if index + 1 < len(sections)
|
||||
else min(end_m, chainage)
|
||||
)
|
||||
spans.append(max((chainage - left) / 2 + (right - chainage) / 2, 0.0))
|
||||
return spans
|
||||
|
||||
|
||||
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]]:
|
||||
"""배치된 사토장만 골라 쓰기 좋은 모양으로. 구간·용량이 없으면 뺀다."""
|
||||
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 str(type_id) != SPOIL_TYPE_ID:
|
||||
continue
|
||||
options = getattr(item, "options", None)
|
||||
if options is None and isinstance(item, dict):
|
||||
options = item.get("options")
|
||||
options = options or {}
|
||||
start = getattr(item, "start_m", None)
|
||||
end = getattr(item, "end_m", None)
|
||||
if isinstance(item, dict):
|
||||
start = item.get("start_m")
|
||||
end = item.get("end_m")
|
||||
capacity = options.get("capacity_m3")
|
||||
if start is None or end 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),
|
||||
"start_m": min(float(start), float(end)),
|
||||
"end_m": max(float(start), float(end)),
|
||||
"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 — 정본이 없으면 사토장도 없다
|
||||
return 0
|
||||
sites = spoil_sites(structures)
|
||||
if not sites:
|
||||
return 0
|
||||
|
||||
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", True)),
|
||||
cut_slope_ratio=stored_cut_slope(design),
|
||||
ditch_enabled=design.get("ditch_enabled"),
|
||||
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),
|
||||
)
|
||||
except (ValueError, KeyError):
|
||||
return None
|
||||
|
||||
changed = 0
|
||||
for site in sites:
|
||||
sections = _sections_in(cross_sections, site["start_m"], site["end_m"])
|
||||
if not sections:
|
||||
continue
|
||||
spans = _spans(sections, site["start_m"], site["end_m"])
|
||||
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
|
||||
@@ -127,6 +127,7 @@ def _enforce_stored_designs(
|
||||
예전에는 상세를 **읽을 때마다** 돌려 화면이 볼 때만 맞았다(저장분은 낡은 채로).
|
||||
2026-09-06 사용자 확정대로 「읽기는 영구저장소에서 가져오기만」이므로 이쪽으로 옮겼다.
|
||||
"""
|
||||
from B06_Section.B06_Section_Engine_SpoilFill import enforce_spoil_fills
|
||||
from B06_Section.B06_Section_Router_Design import (
|
||||
enforce_ford_surface_drops,
|
||||
enforce_pavement_ranges,
|
||||
@@ -134,6 +135,9 @@ def _enforce_stored_designs(
|
||||
|
||||
enforce_pavement_ranges(longitudinal, sections, project_root, standard)
|
||||
enforce_ford_surface_drops(longitudinal, sections, project_root, standard)
|
||||
# ⚠ 사토장은 **맨 뒤**다 — 앞의 두 보정이 설계를 다시 계산하면서 사토장 칸을 지운다.
|
||||
# 맨 뒤에 두면 그 결과 위에 사토장 단면이 얹힌다(2026-09-09).
|
||||
enforce_spoil_fills(longitudinal, sections, project_root, standard)
|
||||
|
||||
|
||||
async def recompute_server_side(project_id: UUID | str, route_id: int) -> int:
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/* =============================================================================
|
||||
* 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);
|
||||
if (unplaced > 0) {
|
||||
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;
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
appendFordSurfaceDropPlan,
|
||||
} from "./B06_Section_UI_Cross_Ford_Pavement";
|
||||
import { culvertWallsStandAt } from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
import { appendSpoilFillOverlay } from "./B06_Section_UI_Cross_SpoilFill";
|
||||
import { appendRevetmentOverlay, computeRevetmentLayout } from "./B06_Section_UI_Cross_Revetment";
|
||||
import {
|
||||
appendCrossDesignOverlay,
|
||||
@@ -467,6 +468,9 @@ export function createCrossSectionCard(
|
||||
// 같은 트림 값을 쓰므로 "그림은 이런데 수량은 저렇다"가 생기지 않는다.
|
||||
applyStructureAreas(section, designTrim);
|
||||
appendCrossDesignOverlay(plotLayer, section.design, x, toDisplayY, drawSamples, designTrim);
|
||||
// 사토장(유용토운반작업장) — 교본 6장 3절이 횡단도 표시를 요구한다. 값은 설계 결과에서
|
||||
// 그대로 오고 여기서 다시 세지 않는다. 선 종류는 터파기 파선과 갈라 둔다.
|
||||
appendSpoilFillOverlay(plotLayer, section.design, x, toDisplayY);
|
||||
// 암 경계선 = 지면선 복사 + 오프셋(계획선 기준 아님).
|
||||
if (rockBoundary && section.design.geometry_preset === "rock") {
|
||||
appendRockBoundaryOverlay(
|
||||
|
||||
@@ -388,3 +388,26 @@
|
||||
stroke-dasharray: 4 3;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
|
||||
# 사토 — **운반 줄이 되는 값**인데 유토곡선의 띠·이동에는 안 들어 있다(잔량으로 남는다).
|
||||
# 여기서 그 값을 운반표에 실어 인계가 「사토 운반」 한 줄을 세우게 한다.
|
||||
# ⚠ 거리는 품셈이 정하지 않는다 — 설계 입력(`spoil_site_distance_m`)이고 없으면 막힌다.
|
||||
haul["spoil"] = _spoil_of(plan, settings)
|
||||
haul["spoil"] = _spoil_of(plan, settings, _spoil_sites(designs))
|
||||
# 배수관 연장 — B06 이 측점 `design.pipe_length_m` 에 남긴 값. **여기서 짓지 않는다.**
|
||||
# 인계가 관 줄을 세울 때 쓴다. 단면을 두 번 읽지 않으려고 이 응답에 실어 보낸다.
|
||||
table["pipe_lengths"] = [
|
||||
@@ -167,7 +167,75 @@ _COMPACTED_FACTOR = {
|
||||
}
|
||||
|
||||
|
||||
def _spoil_of(plan: dict[str, Any] | None, settings: dict[str, Any]) -> dict[str, Any]:
|
||||
def _spoil_sites(designs: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""배치된 사토장(유용토운반작업장) — 측점 설계에 실려 온 구간값을 모은다.
|
||||
|
||||
⚠ 여기서 **다시 세지 않는다** — 용량·담긴 양은 B06 이 정한 값이고, 이 함수는 그것을
|
||||
구조물 단위로 접어 「어디에 얼마나 담기나」만 만든다.
|
||||
"""
|
||||
sites: dict[str, dict[str, Any]] = {}
|
||||
for row in designs:
|
||||
design = (row or {}).get("design") or {}
|
||||
key = str(design.get("spoil_fill_structure_id") or "")
|
||||
if not key or not float(design.get("spoil_fill_area_m2") or 0.0) > 0:
|
||||
continue
|
||||
chainage = float(row.get("chainage_m") or 0.0)
|
||||
site = sites.setdefault(
|
||||
key,
|
||||
{
|
||||
"structure_id": key,
|
||||
"from_m": chainage,
|
||||
"to_m": chainage,
|
||||
"capacity_m3": float(design.get("spoil_fill_capacity_m3") or 0.0),
|
||||
"placed_m3": float(design.get("spoil_fill_placed_m3") or 0.0),
|
||||
"unplaced_m3": float(design.get("spoil_fill_unplaced_m3") or 0.0),
|
||||
"extra_distance_m": design.get("spoil_fill_extra_distance_m"),
|
||||
},
|
||||
)
|
||||
site["from_m"] = min(site["from_m"], chainage)
|
||||
site["to_m"] = max(site["to_m"], chainage)
|
||||
for site in sites.values():
|
||||
site["center_m"] = (site["from_m"] + site["to_m"]) / 2
|
||||
return sorted(sites.values(), key=lambda item: item["center_m"])
|
||||
|
||||
|
||||
def _site_distance_m(
|
||||
sites: list[dict[str, Any]], residuals: list[dict[str, Any]]
|
||||
) -> tuple[float | None, str]:
|
||||
"""사토장까지의 **가중평균 운반거리**(m)와 근거 문구.
|
||||
|
||||
「발생점 → 사토장 측점」 누가거리다(2026-09-09 사용자 확정 ③ — 사토장이 측점 위에만
|
||||
서므로 가정할 것이 없다). 사토가 여러 자리에 남으면 물량으로 가중평균한다.
|
||||
⚠ 사토장이 없으면 `None` — 설계 입력(`spoil_site_distance_m`)으로 되돌아간다.
|
||||
**임의 거리를 넣지 않는다**(그대로 금액이 된다).
|
||||
"""
|
||||
if not sites:
|
||||
return None, ""
|
||||
work = 0.0
|
||||
volume = 0.0
|
||||
for residual in residuals:
|
||||
if str(residual.get("kind") or "") != "spoil":
|
||||
continue
|
||||
amount = float(residual.get("volume_m3") or 0.0) - float(residual.get("natural_m3") or 0.0)
|
||||
if amount <= 0:
|
||||
continue
|
||||
center = (float(residual.get("from_m") or 0.0) + float(residual.get("to_m") or 0.0)) / 2
|
||||
nearest = min(sites, key=lambda site: abs(site["center_m"] - center))
|
||||
extra = nearest.get("extra_distance_m")
|
||||
distance = abs(nearest["center_m"] - center) + float(extra or 0.0)
|
||||
work += amount * distance
|
||||
volume += amount
|
||||
if volume <= 0:
|
||||
return None, ""
|
||||
where = " · ".join(f"{site['center_m']:,.1f}m" for site in sites)
|
||||
return work / volume, f"사토장 측점({where})까지 발생점 기준 가중평균"
|
||||
|
||||
|
||||
def _spoil_of(
|
||||
plan: dict[str, Any] | None,
|
||||
settings: dict[str, Any],
|
||||
sites: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""사토 — 실어 낼 물량과 거리. 유토곡선 결과에서 **다시 세지 않고 그대로** 가져온다.
|
||||
|
||||
⚠ `spoil_m3` 는 **공제·가산이 끝난 값**이다(채집석 공제는 빼고 구조물 잔토는 더한 뒤).
|
||||
@@ -229,10 +297,25 @@ def _spoil_of(plan: dict[str, Any] | None, settings: dict[str, Any]) -> dict[str
|
||||
}
|
||||
if unknown > 0:
|
||||
note_parts.append(f"⚠ 갈래를 못 붙인 {unknown:,.2f}㎥ 는 상태도 못 되돌림")
|
||||
# 거리 — 사토장이 서 있으면 **그 측점까지의 누가거리**로 나온다. 없으면 설계 입력값.
|
||||
site_distance, site_basis = _site_distance_m(sites or [], source.get("residuals") or [])
|
||||
if site_distance is not None:
|
||||
note_parts.append(site_basis)
|
||||
placed = sum(float(site.get("placed_m3") or 0.0) for site in sites or [])
|
||||
unplaced = sum(float(site.get("unplaced_m3") or 0.0) for site in sites or [])
|
||||
note_parts.append(f"사토장 수용 {placed:,.1f}㎥")
|
||||
if unplaced > 0:
|
||||
note_parts.append(f"⚠ 못 담는 {unplaced:,.1f}㎥ 는 밖으로 내야 함")
|
||||
return {
|
||||
"volume_m3": round(volume, 3),
|
||||
"volume_basis": "compacted",
|
||||
"distance_m": settings.get("spoil_site_distance_m"),
|
||||
"distance_m": (
|
||||
round(site_distance, 3)
|
||||
if site_distance is not None
|
||||
else settings.get("spoil_site_distance_m")
|
||||
),
|
||||
"distance_basis": ("사토장(측점) 기준" if site_distance is not None else "설계 입력값"),
|
||||
"sites": sites or [],
|
||||
"note": " · ".join(note_parts),
|
||||
"by_ground_m3": {key: round(value, 3) for key, value in grounds.items()},
|
||||
"natural_m3_by_ground": natural_by_ground,
|
||||
|
||||
@@ -24,8 +24,11 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import type { BermSpec } from "./common_util_cross_berm";
|
||||
import type { SpoilFillSection } from "./common_util_spoil_fill";
|
||||
import { spoilFillSection } from "./common_util_spoil_fill";
|
||||
import {
|
||||
benchCutLength,
|
||||
fillAreaBeyond,
|
||||
splitCutAreas,
|
||||
splitDitchArea,
|
||||
trapezoidAreas,
|
||||
@@ -100,6 +103,13 @@ export interface CrossDesignOptions {
|
||||
curveWideningM?: number | null;
|
||||
/** 이 측점의 소단 제원 — 없으면 계단 없이 종전 사면 그대로(계획서 3-9). */
|
||||
berm?: BermSpec | null;
|
||||
/**
|
||||
* 이 측점에 선 유용토운반작업장(구 사토장). 폭은 **노면 끝**(노견이 시작하는 자리)에서
|
||||
* 재고, 그 바깥 성토는 **노선 몫이 아니라 사토장 몫**이라 `fill_area_m2` 에서 뺀다
|
||||
* (2026-09-09 확정 ㉠ — 두 번 세지 않기). `slopeRatioN` 이 비면 노선 성토 기울기를 쓴다.
|
||||
* 짝: 파이썬 `compute_cross_design(spoil_fill=…)`.
|
||||
*/
|
||||
spoilFill?: { side: "left" | "right"; widthM: number; slopeRatioN?: number | null } | null;
|
||||
}
|
||||
|
||||
export interface CrossDesignEdge {
|
||||
@@ -138,6 +148,15 @@ export interface CrossDesignResult {
|
||||
fill_area_m2: number;
|
||||
/** 층따기 밑수 — 성토부 아래 원지반(1:4 보다 급한 구간)의 지표면 길이(m). */
|
||||
bench_cut_length_m: number;
|
||||
/** 사토장 몫 — **`fill_area_m2` 와 합치지 않는다**(받는 쪽이 갈라 볼 수 있어야 한다). */
|
||||
spoil_fill_area_m2: number;
|
||||
spoil_fill_side: "left" | "right" | null;
|
||||
spoil_fill_width_m: number;
|
||||
spoil_fill_max_width_m: number;
|
||||
spoil_fill_line: CrossDesignEdge[];
|
||||
spoil_fill_unclosed: boolean;
|
||||
/** 사토장이 대신 차지해 노선 성토에서 뺀 몫(㎡) — 되짚기용. 합계에 또 넣지 말 것. */
|
||||
spoil_fill_replaced_fill_m2: number;
|
||||
slope_unclosed: boolean;
|
||||
fill_ground_slope: number | null;
|
||||
ditch_area_m2: number;
|
||||
@@ -355,7 +374,8 @@ export function computeCrossDesign(
|
||||
}
|
||||
|
||||
// 측구 굴착은 설계선에 포함돼 절토 면적에 자연 반영된다(별도 가산 없음).
|
||||
const [cutArea, fillArea] = trapezoidAreas(offsets, diffs);
|
||||
const [cutArea, baseFillArea] = trapezoidAreas(offsets, diffs);
|
||||
let fillArea = baseFillArea;
|
||||
// 층따기 밑수(길이 m) — 성토부 아래 원지반이 1:4 보다 급한 구간의 지표면 길이.
|
||||
const benchCut = benchCutLength(offsets, grounds, diffs);
|
||||
const fillGroundSlope = geometry.fillGroundSlope();
|
||||
@@ -364,6 +384,28 @@ export function computeCrossDesign(
|
||||
(Math.abs(diffs[0]) > SLOPE_CLOSE_TOLERANCE_M ||
|
||||
Math.abs(diffs[diffs.length - 1]) > SLOPE_CLOSE_TOLERANCE_M);
|
||||
|
||||
// 사토장(유용토운반작업장) — 노면 끝 바깥에 쌓는 성토. 짝: 파이썬 `compute_cross_design`.
|
||||
// ⚠ 그 바깥 성토는 **노선 몫이 아니다** — 빼지 않으면 같은 흙을 두 번 센다(확정 ㉠).
|
||||
const spoilSide = options.spoilFill?.side ?? null;
|
||||
const spoilWidth = Math.max(Number(options.spoilFill?.widthM ?? 0), 0);
|
||||
let spoilSection: SpoilFillSection | null = null;
|
||||
let spoilReplaced = 0;
|
||||
if ((spoilSide === "left" || spoilSide === "right") && spoilWidth > 0) {
|
||||
const spoilX0 = spoilSide === "left" ? geometry.halfRoadLeft : -geometry.halfRoadRight;
|
||||
const askedRatio = Number(options.spoilFill?.slopeRatioN ?? 0);
|
||||
const spoilRatio = askedRatio > 0 ? askedRatio : geometry.fillRatio;
|
||||
spoilSection = spoilFillSection({
|
||||
ground: valid.map(([offset_m, elevation_m]) => ({ offset_m, elevation_m })),
|
||||
startOffsetM: spoilX0,
|
||||
startElevationM: geometry.roadZ(spoilX0),
|
||||
side: spoilSide,
|
||||
widthM: spoilWidth,
|
||||
slopeRatioN: spoilRatio,
|
||||
});
|
||||
spoilReplaced = fillAreaBeyond(offsets, diffs, spoilX0, spoilSide);
|
||||
fillArea = Math.max(fillArea - spoilReplaced, 0);
|
||||
}
|
||||
|
||||
// 절토면적 토사/암반 분리 — 지표면~암반 경계선이 토사, 그 아래가 암.
|
||||
let cutSoilArea: number;
|
||||
let cutRockArea: number;
|
||||
@@ -493,6 +535,18 @@ export function computeCrossDesign(
|
||||
cut_rock_area_m2: round4(cutRockArea),
|
||||
cut_rock_kind: cutRockKind,
|
||||
fill_area_m2: round4(fillArea),
|
||||
spoil_fill_area_m2: spoilSection ? round4(spoilSection.area_m2) : 0,
|
||||
spoil_fill_side: spoilSection ? spoilSide : null,
|
||||
spoil_fill_width_m: spoilSection ? round4(spoilWidth) : 0,
|
||||
spoil_fill_max_width_m: spoilSection ? round4(spoilSection.maxWidthM) : 0,
|
||||
spoil_fill_line: spoilSection
|
||||
? spoilSection.line.map((point) => ({
|
||||
offset_m: point.offset_m,
|
||||
elevation_m: point.elevation_m,
|
||||
}))
|
||||
: [],
|
||||
spoil_fill_unclosed: spoilSection ? spoilSection.unclosed : false,
|
||||
spoil_fill_replaced_fill_m2: round4(spoilReplaced),
|
||||
bench_cut_length_m: round4(benchCut),
|
||||
slope_unclosed: slopeUnclosed,
|
||||
fill_ground_slope: fillGroundSlope === null ? null : round4(fillGroundSlope),
|
||||
|
||||
@@ -159,3 +159,46 @@ export function splitDitchArea(
|
||||
}
|
||||
return [0, 0];
|
||||
}
|
||||
|
||||
/**
|
||||
* `x0` **바깥쪽**(사토장이 서는 쪽)의 성토 면적(㎡)만 따로 낸다.
|
||||
*
|
||||
* ⚠ 왜 있나 — 사토장이 선 측점에서는 노면 끝 바깥이 **사토장 몫**이라 노선 성토
|
||||
* (`fill_area_m2`)에서 빼야 한다. 안 빼면 **같은 흙을 두 번 센다**(2026-09-09 확정 ㉠).
|
||||
* ⚠ 경계(`x0`)에서 잘라 쓰므로 그 자리의 종거를 **보간해서** 넣는다 — 그냥 버리면
|
||||
* 경계 한 칸이 통째로 빠져 값이 작아진다.
|
||||
*
|
||||
* 짝: 파이썬 `_fill_area_beyond`.
|
||||
*/
|
||||
export function fillAreaBeyond(
|
||||
offsets: number[],
|
||||
diffs: number[],
|
||||
x0: number,
|
||||
side: "left" | "right",
|
||||
): number {
|
||||
if (offsets.length < 2) return 0;
|
||||
const inside = side === "left" ? (x: number) => x >= x0 : (x: number) => x <= x0;
|
||||
const subOffsets: number[] = [];
|
||||
const subDiffs: number[] = [];
|
||||
for (let index = 0; index < offsets.length; index += 1) {
|
||||
const x = offsets[index];
|
||||
if (index > 0) {
|
||||
const xPrev = offsets[index - 1];
|
||||
const crosses = (xPrev < x0 && x0 < x) || (x < x0 && x0 < xPrev);
|
||||
if (crosses) {
|
||||
const ratio = (x0 - xPrev) / (x - xPrev);
|
||||
subOffsets.push(x0);
|
||||
subDiffs.push(diffs[index - 1] + (diffs[index] - diffs[index - 1]) * ratio);
|
||||
}
|
||||
}
|
||||
if (inside(x)) {
|
||||
subOffsets.push(x);
|
||||
subDiffs.push(diffs[index]);
|
||||
}
|
||||
}
|
||||
const order = subOffsets.map((_, index) => index).sort((a, b) => subOffsets[a] - subOffsets[b]);
|
||||
const sortedOffsets = order.map((index) => subOffsets[index]);
|
||||
const sortedDiffs = order.map((index) => subDiffs[index]);
|
||||
if (sortedOffsets.length < 2) return 0;
|
||||
return trapezoidAreas(sortedOffsets, sortedDiffs)[1];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user