Files
Aislo/common_util/common_util_spoil_fill.ts
T
eomsangdonandClaude Opus 5 e1fec62d35 feat(B06): 사토장 성토 단면 엔진(파이썬·TS 짝) + 등록부 확장
유용토운반작업장(구 사토장) — 노선 옆에 남는 흙을 쌓는 성토 단면.

- `common_util_spoil_fill.{ts,py}` 신설(한 벌, 거울 시험) — 평상(노면 끝 높이) →
  1:n 비탈 → 지반과 만나는 데까지. 단면적은 적분으로 정확히 셈.
  폭 시작점 = **노면 끝**(사용자 확정) ⇒ 그 구간 노견도 이 성토 안에 듦.
  폭 상한 = **지반 샘플이 있는 데까지**. 상한에서도 모자라면 그 폭을 냄(임의로 안 넓힘).
  용량에서 폭을 되풀이로 역산하는 `solveSpoilWidthM` 함께 냄.
- 등록부 `spoil_bank` 를 넓힘 — 새 종류를 만들지 않음(지식DB 상 같은 시설임).
  이름 「유용토운반작업장(구 사토장)」 · 구간형 · 횡단도에 그림 ·
  칸 `side`(자동=성토 쪽) · `fill_slope_ratio`(비면 노선 성토 기울기) · `extra_distance_m`.
- 기울기·적치높이 기본값은 지어내지 않음 — 지식DB §4 가 근거 없음을 못 박음.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 07:23:04 +09:00

242 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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) };
}