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

This commit is contained in:
2026-09-09 11:31:33 +09:00
6 changed files with 443 additions and 1 deletions
+34
View File
@@ -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) 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
@@ -33,6 +33,7 @@ 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,
@@ -43,6 +44,7 @@ from common_util.common_util_cross_berm import (
fill_profile_points, fill_profile_points,
) )
from common_util.common_util_cross_berm import elevation_at as berm_elevation_at 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 ( from config.config_system import (
CURVE_WIDENING_MAX_WIDTH_M, CURVE_WIDENING_MAX_WIDTH_M,
SECTION_DITCH_SIDES, SECTION_DITCH_SIDES,
@@ -581,6 +583,7 @@ 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,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""측점 하나의 표준횡단 설계선과 절·성토 단면적을 계산한다. """측점 하나의 표준횡단 설계선과 절·성토 단면적을 계산한다.
@@ -600,6 +603,10 @@ 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"}`.
폭은 **노면 끝**(노견이 시작하는 자리)에서 재고, 그 바깥 성토는 **노선 몫이 아니라
사토장 몫**이라 `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}")
@@ -710,6 +717,26 @@ 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 비율을 만들므로, 사용자가 경계선을 올리내리면 이 값이 함께 바뀐다.
# 토사 지반은 암반 경계선 자체가 없어 전량 토사, 암 지반인데 경계선 값이 없으면(구 데이터) # 토사 지반은 암반 경계선 자체가 없어 전량 토사, 암 지반인데 경계선 값이 없으면(구 데이터)
@@ -836,6 +863,22 @@ 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 이 측점 사이를 이어 ㎡ 로 만든다. 여기서 ㎥ 로 바꾸지 않는다 —
# 단의 높이·폭이 설계도서 값이라 지어낼 수 없다. # 단의 높이·폭이 설계도서 값이라 지어낼 수 없다.
+264
View File
@@ -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 사용자 확정대로 「읽기는 영구저장소에서 가져오기만」이므로 이쪽으로 옮겼다. 2026-09-06 사용자 확정대로 「읽기는 영구저장소에서 가져오기만」이므로 이쪽으로 옮겼다.
""" """
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,
@@ -134,6 +135,9 @@ def _enforce_stored_designs(
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:
+55 -1
View File
@@ -24,8 +24,11 @@
* ========================================================================== */ * ========================================================================== */
import type { BermSpec } from "./common_util_cross_berm"; import 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,
@@ -100,6 +103,13 @@ 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;
} }
export interface CrossDesignEdge { export interface CrossDesignEdge {
@@ -138,6 +148,15 @@ 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;
@@ -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 보다 급한 구간의 지표면 길이. // 층따기 밑수(길이 m) — 성토부 아래 원지반이 1:4 보다 급한 구간의 지표면 길이.
const benchCut = benchCutLength(offsets, grounds, diffs); const benchCut = benchCutLength(offsets, grounds, diffs);
const fillGroundSlope = geometry.fillGroundSlope(); const fillGroundSlope = geometry.fillGroundSlope();
@@ -364,6 +384,28 @@ 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;
@@ -493,6 +535,18 @@ 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,3 +159,46 @@ 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];
}