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

This commit is contained in:
2026-09-09 11:34:54 +09:00
12 changed files with 696 additions and 5 deletions
+19
View File
@@ -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;
/**
+30 -1
View File
@@ -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 {
+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)
return soil, max(total - soil, 0.0)
return 0.0, 0.0
def _fill_area_beyond(offsets: list[float], diffs: list[float], x0: float, side: str) -> float:
"""`x0` **바깥쪽**(사토장이 서는 쪽)의 성토 면적(㎡)만 따로 낸다.
⚠ 왜 있나 — 사토장이 선 측점에서는 노면 끝 바깥이 **사토장 몫**이라 노선 성토
(`fill_area_m2`)에서 빼야 한다. 안 빼면 **같은 흙을 두 번 센다**(2026-09-09 확정 ㉠).
⚠ 경계(`x0`)의 종거는 **보간해서** 넣는다 — 그냥 버리면 경계 한 칸이 통째로 빠져
값이 작아진다. 좌는 `x0` 위쪽, 우는 `x0` 아래쪽이며 **둘 다 오름차순**으로 넘긴다.
짝: TS `fillAreaBeyond`.
"""
if len(offsets) < 2:
return 0.0
inside = (lambda x: x >= x0) if side == "left" else (lambda x: x <= x0)
sub_offsets: list[float] = []
sub_diffs: list[float] = []
for index, x in enumerate(offsets):
if index > 0:
x_prev = offsets[index - 1]
crosses = (x_prev < x0 < x) or (x < x0 < x_prev)
if crosses:
ratio = (x0 - x_prev) / (x - x_prev)
sub_offsets.append(x0)
sub_diffs.append(diffs[index - 1] + (diffs[index] - diffs[index - 1]) * ratio)
if inside(x):
sub_offsets.append(x)
sub_diffs.append(diffs[index])
order = sorted(range(len(sub_offsets)), key=lambda i: sub_offsets[i])
sub_offsets = [sub_offsets[i] for i in order]
sub_diffs = [sub_diffs[i] for i in order]
if len(sub_offsets) < 2:
return 0.0
return _trapezoid_areas(sub_offsets, sub_diffs)[1]
+43
View File
@@ -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 이 측점 사이를 이어 ㎡ 로 만든다. 여기서 ㎥ 로 바꾸지 않는다 —
# 단의 높이·폭이 설계도서 값이라 지어낼 수 없다.
+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 사용자 확정대로 「읽기는 영구저장소에서 가져오기만」이므로 이쪽으로 옮겼다.
"""
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;
}
+4
View File
@@ -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;
}
+86 -3
View File
@@ -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,
+55 -1
View File
@@ -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];
}