refactor(380-1): 사토장 횡단 그림 · 사토 단면 셈 지움 — 부품 처음값 형상 · 3D 없음
- 부품 처음값 spoil_bank 형상 · 3D = none · 형상 부품 「사토(성토 채움)」 지움 - 사토 단면 셈 파이썬 · TS 짝 지움(Engine_SpoilFill · common_util_spoil_fill · spoil_fill 옵션 · fill_area_beyond) - 카드 그림 · 면적표 줄 · 모달 띠 · 고름 선 · B07 표 「사토장」 칸 지움(빈 줄로 되돌림) - B08 수량 · 유토 배분(haul-plan) 미사용 확인 · 남은 사토 값 = [저장] 서버 재계산 · 브라우저 단계 다시 셈으로 정리 - 시험 고쳐 씀: 322_1 · 349_0n · b06_spoil_fill_allocation(남은 값 정리) · spoil_fill_mirror(죽은 길) · 52_9 · cross_design_mirror · 150 · 164 · 179_4 · 316 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
This commit is contained in:
@@ -24,11 +24,8 @@
|
||||
* ========================================================================== */
|
||||
|
||||
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 {
|
||||
benchCutLength,
|
||||
fillAreaBeyond,
|
||||
splitCutAreas,
|
||||
splitDitchArea,
|
||||
trapezoidAreas,
|
||||
@@ -110,13 +107,6 @@ 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;
|
||||
/** 설계선 쉬프트 = 쪽마다 사면 이동(m · ≥ 0 · 139-6 · 151-2 노견째). 짝: 파이썬 `shoulder_extend_*_m`. */
|
||||
shoulderExtendLeftM?: number;
|
||||
shoulderExtendRightM?: number;
|
||||
@@ -167,15 +157,6 @@ 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;
|
||||
@@ -405,8 +386,7 @@ export function computeCrossDesign(
|
||||
}
|
||||
|
||||
// 측구 굴착은 설계선에 포함돼 절토 면적에 자연 반영된다(별도 가산 없음).
|
||||
const [cutArea, baseFillArea] = trapezoidAreas(offsets, diffs);
|
||||
let fillArea = baseFillArea;
|
||||
const [cutArea, fillArea] = trapezoidAreas(offsets, diffs);
|
||||
// 층따기 밑수(길이 m) — 성토부 아래 원지반이 1:4 보다 급한 구간의 지표면 길이.
|
||||
const benchCut = benchCutLength(offsets, grounds, diffs);
|
||||
const fillGroundSlope = geometry.fillGroundSlope();
|
||||
@@ -415,28 +395,6 @@ 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;
|
||||
@@ -572,18 +530,6 @@ 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,46 +159,3 @@ 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];
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -28,7 +28,7 @@ export interface CatalogRow {
|
||||
station: string;
|
||||
/** 카드 이름 부품 — name · pipe. */
|
||||
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;
|
||||
shapeOpts: Record<string, unknown>;
|
||||
/** 3D 부품 — none · pipe · box · ford · chute · wall · surface. */
|
||||
|
||||
@@ -150,12 +150,6 @@
|
||||
"읽는 칸": ["pave_spec", "height_m", "widening_off"],
|
||||
"자리꼴": ["interval"],
|
||||
"정본": ["structures"]
|
||||
},
|
||||
"spoil": {
|
||||
"이름": "사토(성토 채움)",
|
||||
"읽는 칸": ["capacity_m3"],
|
||||
"자리꼴": ["site"],
|
||||
"정본": ["structures"]
|
||||
}
|
||||
},
|
||||
"3D": {
|
||||
@@ -465,8 +459,8 @@
|
||||
"목록": "both",
|
||||
"횡단도": "none",
|
||||
"카드이름": "name",
|
||||
"형상": "spoil",
|
||||
"3D": "surface"
|
||||
"형상": "none",
|
||||
"3D": "none"
|
||||
},
|
||||
"borrow_pit": {
|
||||
"목록": "both",
|
||||
|
||||
Reference in New Issue
Block a user