/* ============================================================================= * 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, 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) }; }