/* ============================================================================= * B06_Section_Cross_Enforce.ts * 계산 묶음 cross 단계의 [확정] 보정(PLAN 349-0n) — 서버 `_enforce_stored_designs` 차례 그대로 * (미교차 넓힘 → 포장 구간 → 세월교 노면 하강 → 사토장 맨 뒤). 넓힘은 `B06_Section_Cross_Stage`. * * facilityRanges · facilityAt 새 노선 관 · 구조물 자리 → 측점마다 포장 · 기울임 · 노면 하강. * ⚠ ① 짝 — `B06_Section_Router_Design.py` `pavement_ranges` · `ford_surface_drops` · `paved_at` · * `ford_drop_at` + `B06_Section_Engine_Tilt.py` `tilt_ranges` · `tilt_args`. * spoilSites · enforceSpoilFills 사토장 기준 측점 하나 · 용량에서 폭 하나(이분법). * ⚠ ① 짝 — `B06_Section_Engine_SpoilFill.py` `spoil_sites` · `enforce_spoil_fills`. * 거울 시험 `resources/tester/test_349_0n_cross_enforce_mirror.py`. * 세트 · 포장 · 맹암거 제원은 이미 있는 짝(`buildCulvertSets` · `pavementSpecsFrom` · `underdrainSpecsFrom`). * 순수 — 화면 · fetch · 세션 없음. * ========================================================================== */ import { buildCulvertSets, type CulvertPipePoint } from "@util/common_util_culvert_sets"; import { catalogRow } from "@util/common_util_structure_catalog"; import { pavementSpecsFrom, type PavementStructureInput, } from "@util/common_util_structure_pavement"; import { underdrainSpecsFrom, type UnderdrainInput } from "@util/common_util_structure_underdrain"; /** 포장 · 기울임 · 노면 하강 판정 폭(m) — 파이썬 `EDGE_TOLERANCE_M` · `_FORD_DROP_TOLERANCE_M`. */ const EDGE_M = 0.02; export type FacilityStructure = PavementStructureInput & UnderdrainInput & { structure_id?: string | null }; export interface FacilityRanges { paved: Array<[number, number]>; tilt: Array<[number, number, number | null]>; drops: Array<[number, number]>; } const num = (value: unknown): number | null => { const parsed = typeof value === "string" && value.trim() ? Number(value) : value; return typeof parsed === "number" && Number.isFinite(parsed) ? parsed : null; }; /** 관 · 구조물 → 포장 구간 · 기울임 구간 · 세월교 하강(서버와 같은 차례 — 첫 것이 이김). */ export function facilityRanges( pipes: readonly CulvertPipePoint[], structures: readonly FacilityStructure[], ): FacilityRanges { // ponytail: 레지스트리 기본값 없이 세트를 지음 — 물넘이 폭 · 경사를 비운 관만 기본 상수(10 m · 3°) const sets = buildCulvertSets(pipes, {}); const pavementIds = new Set( structures.filter((s) => catalogRow(s.type_id)?.shape === "pavement").map((s) => s.type_id), ); const paved: FacilityRanges["paved"] = pavementSpecsFrom(structures, pavementIds).map((s) => [ s.start_m, s.end_m, ]); const tilt: FacilityRanges["tilt"] = []; const drops: FacilityRanges["drops"] = []; for (const [at, spec] of sets) { if (spec.type === "ford_pavement") { const half = (num(spec.span_m) ?? 0) / 2; paved.push([at - half, at + half]); tilt.push([at - half, at + half, num(spec.slope_pct)]); } const depth = num(spec.overflow_depth_m) ?? 0; if (spec.type === "ford" && depth > 0) drops.push([at, depth]); } for (const drain of underdrainSpecsFrom(structures)) tilt.push([drain.chainage_m, drain.chainage_m, drain.slope_pct]); return { paved, tilt, drops }; } /** 그 측점의 포장 · 기울임 · 노면 하강 — 횡단 엔진 입력 칸 이름 그대로. */ export function facilityAt(ranges: FacilityRanges, chainageM: number) { const inside = (start: number, end: number) => start - EDGE_M <= chainageM && chainageM <= end + EDGE_M; const hit = ranges.tilt.find(([start, end]) => inside(start, end)); return { paved: ranges.paved.some(([start, end]) => inside(start, end)), tilted: hit !== undefined, tiltPct: hit?.[2] ?? null, surfaceDropM: ranges.drops.find(([at]) => Math.abs(chainageM - at) <= EDGE_M)?.[1] ?? 0, }; } /* ── 사토장 ───────────────────────────────────────────────────────────── */ const SOLVE_STEPS = 24; const MAX_PROBE_WIDTH_M = 1000; const SIDE_AUTO = "자동(성토 쪽)"; /** 단면 유형 → [좌, 우] 성토면인가(파이썬 `structure_face_role` 표). */ const FILL_SIDES: Record = { left_cut: [false, true], right_cut: [true, false], both_cut: [false, false], both_fill: [true, true], }; export interface SpoilSite { structureId: string | null; chainageM: number; capacityM3: number; sideOption: unknown; slopeRatioN: unknown; extraDistanceM: unknown; } /** 배치된 사토장만 — 기준 측점 · 용량 > 0(서버 정본 읽기 = 구조물 객체 길 · 시작 측점으로 갈음 안 함). */ export function spoilSites(structures: readonly FacilityStructure[]): SpoilSite[] { return structures.flatMap((item) => { if (catalogRow(item.type_id)?.shape !== "spoil") return []; const options = (item.options ?? {}) as Record; const chainage = num(item.chainage_m); const capacity = num(options.capacity_m3); if (chainage === null || capacity === null || capacity <= 0) return []; return [ { structureId: item.structure_id ?? null, chainageM: chainage, capacityM3: capacity, sideOption: options.side, slopeRatioN: options.fill_slope_ratio, extraDistanceM: options.extra_distance_m, }, ]; }); } /** 쌓는 쪽 — 「좌 · 우」 그대로 · 「자동」 · 빈칸 = 그 측점 성토면(좌 먼저) · 못 가르면 null. */ export function spoilSideOf( design: Record, option: unknown, ): "left" | "right" | null { const word = String(option ?? "").trim(); if (word === "좌") return "left"; if (word === "우") return "right"; if (word && word !== SIDE_AUTO) return null; const sides = FILL_SIDES[String(design.section_mode ?? "")]; if (!sides) return null; return sides[0] ? "left" : sides[1] ? "right" : null; } type Design = Record; /** 측점 하나 — 설계 + 사토장 폭으로 다시 셈(못 세면 null). */ export interface SpoilStation { chainageM: number; design: Design | null; recompute: (side: "left" | "right", widthM: number, slopeRatioN: unknown) => Design | null; } const round4 = (value: number) => Math.round(value * 1e4) / 1e4; /** 사토장 측점 설계를 다시 셈 — `stations[i].design` 을 갈아 끼움 · 바뀐 측점 수. */ export function enforceSpoilFills(stations: SpoilStation[], sites: readonly SpoilSite[]): number { const chainages = [...new Set(stations.map((s) => s.chainageM))].sort((a, b) => a - b); const spanOf = (here: number) => { const index = chainages.indexOf(here); const left = index ? chainages[index - 1] : here; const right = index + 1 < chainages.length ? chainages[index + 1] : here; return (right - left) / 2; }; let changed = 0; for (const site of sites) { if (!stations.length) continue; // 기준 측점에 가장 가까운 측점 하나(가운데면 앞 측점) const station = stations.reduce((best, s) => { const gap = Math.abs(s.chainageM - site.chainageM); const bestGap = Math.abs(best.chainageM - site.chainageM); return gap < bestGap || (gap === bestGap && s.chainageM < best.chainageM) ? s : best; }); const span = spanOf(station.chainageM); const side = spoilSideOf(station.design ?? {}, site.sideOption); const volume = (widthM: number): [number, Design | null] => { if (side === null || widthM <= 0 || !station.design) return [0, null]; const design = station.recompute(side, widthM, site.slopeRatioN); return [design ? (num(design.spoil_fill_area_m2) ?? 0) * span : 0, design]; }; const limit = num(volume(MAX_PROBE_WIDTH_M)[1]?.spoil_fill_max_width_m) ?? 0; if (limit <= 0) continue; let [total, design] = volume(limit); if (total > site.capacityM3) { let low = 0; let high = limit; for (let step = 0; step < SOLVE_STEPS; step += 1) { const mid = (low + high) / 2; if (volume(mid)[0] < site.capacityM3) low = mid; else high = mid; } [total, design] = volume(round4(high)); } if (!design) continue; station.design = { ...design, spoil_fill_capacity_m3: round4(site.capacityM3), spoil_fill_placed_m3: round4(total), spoil_fill_unplaced_m3: round4(Math.max(site.capacityM3 - total, 0)), spoil_fill_structure_id: site.structureId, spoil_fill_extra_distance_m: site.extraDistanceM, }; changed += 1; } return changed; }