/* ============================================================================= * common_util_cross_structure_areas.ts * 구조물이 선 자리의 **절·성토 면적** — 지반선과 「실제로 그려지는 설계선」이 이루는 * 폐회로의 넓이다(2026-09-06 사용자 확정: 구조물 자체 면적을 빼는 것이 아니다). * * 왜 따로 있나 — 기본 면적 계산(`common_util_cross_design.ts`)은 지반선과 **표준 설계선**의 * 차이만 적분한다. 그 계산은 구조물이 있는지조차 모른다. 그런데 기슭막이·세월교·BOX암거가 * 서면 성토 사면이 벽에서 끊기고 그 바깥은 벽·성토부선이 대신 그린다 — 화면에 그려지는 * 폐회로가 달라지므로 면적도 달라져야 한다. * * 여기서는 **그리는 쪽이 이미 만든 트림 값**(`designTrim`)을 그대로 받는다. 화면과 면적이 * 같은 입력을 쓰므로 "그림은 이런데 수량은 저렇다"가 생기지 않는다. * * 파이썬 짝은 만들지 않는다 — 서버도 **이 파일을 그대로 실행**한다 * (`B06_Section_Structure_Areas_Node.ts` → 번들, 2026-09-06). 전처리 체인 끝과 * [저장]·[확정] 때 서버가 돌려 정본에 얹으므로, 브라우저를 한 번도 안 열어도 값이 선다. * ========================================================================== */ import { splitCutAreas, trapezoidAreas } from "./common_util_cross_design_areas"; /** 트림 경계 바로 바깥에 찍는 점의 간격(m) — 벽면을 수직으로 만들기 위한 값. */ const BOUNDARY_EPS_M = 1e-6; /** 그리는 쪽이 넘겨 주는 트림 — 배수관·세월교·BOX 세트가 같은 모양으로 낸다. */ export interface DesignTrim { minOffset: number; maxOffset: number; minElevation?: number; maxElevation?: number; /** 트림 바깥(−offset 쪽)을 대신 그리는 폴리라인 — 노견에서 벽까지의 성토부선. */ minSlope?: { points: Array<{ offset: number; elevation: number }> }; /** 트림 바깥(+offset 쪽) 폴리라인. */ maxSlope?: { points: Array<{ offset: number; elevation: number }> }; } export interface StructureAreaInput { /** 표준 설계선(측점 저장분) — 트림 안쪽은 이 선을 그대로 쓴다. */ designLine: Array<{ offset_m: number; elevation_m: number }>; /** 지반선 샘플(유효한 것만, 오프셋 오름차순). */ ground: Array<{ offset: number; elevation: number }>; trim: DesignTrim; /** 암반 경계선 오프셋(m, 절대값). 있으면 절토를 토사/암으로 나눈다. */ rockBoundaryOffsetM?: number | null; } export interface StructureAreaResult { cutAreaM2: number; fillAreaM2: number; cutSoilAreaM2: number; cutRockAreaM2: number; } /** 오프셋 오름차순 폴리라인의 선형 보간기. 범위 밖은 끝값을 문다. */ function interpolator( points: Array<{ offset: number; elevation: number }>, ): ((offset: number) => number) | null { if (points.length === 0) return null; const sorted = [...points].sort((a, b) => a.offset - b.offset); return (offset: number): number => { if (offset <= sorted[0].offset) return sorted[0].elevation; const last = sorted[sorted.length - 1]; if (offset >= last.offset) return last.elevation; for (let index = 1; index < sorted.length; index += 1) { const right = sorted[index]; if (offset > right.offset) continue; const left = sorted[index - 1]; const span = right.offset - left.offset; const ratio = span > 1e-9 ? (offset - left.offset) / span : 0; return left.elevation + (right.elevation - left.elevation) * ratio; } return last.elevation; }; } /** 폐회로의 위(실제로 그려지는 설계선)·아래(지반선) 보간기 — 면적과 벽 겹침이 같은 선을 봄. */ function drawnProfile( input: StructureAreaInput, ): { drawnZ: (offset: number) => number; ground: (offset: number) => number } | null { const design = interpolator( input.designLine.map((point) => ({ offset: point.offset_m, elevation: point.elevation_m })), ); const ground = interpolator(input.ground); if (!design || !ground || input.ground.length < 2) return null; const { trim } = input; const minSlope = trim.minSlope?.points?.length ? interpolator(trim.minSlope.points) : null; const maxSlope = trim.maxSlope?.points?.length ? interpolator(trim.maxSlope.points) : null; const minSlopeRange = trim.minSlope?.points?.length ? [ Math.min(...trim.minSlope.points.map((p) => p.offset)), Math.max(...trim.minSlope.points.map((p) => p.offset)), ] : null; const maxSlopeRange = trim.maxSlope?.points?.length ? [ Math.min(...trim.maxSlope.points.map((p) => p.offset)), Math.max(...trim.maxSlope.points.map((p) => p.offset)), ] : null; /** 이 오프셋에서 **실제로 그려지는** 설계선 표고. 폐회로의 위쪽 경계다. */ const drawnZ = (offset: number): number => { if (offset < trim.minOffset) { if (minSlope && minSlopeRange && offset >= minSlopeRange[0] && offset <= minSlopeRange[1]) { return minSlope(offset); } // 구조물이 그리는 선이 닿지 않는 바깥 — 원지반 그대로(면적 0). return ground(offset); } if (offset > trim.maxOffset) { if (maxSlope && maxSlopeRange && offset >= maxSlopeRange[0] && offset <= maxSlopeRange[1]) { return maxSlope(offset); } return ground(offset); } return design(offset); }; return { drawnZ, ground }; } /** * 구조물이 선 뒤의 절·성토 면적. 트림 바깥은 구조물이 그리는 폴리라인을 따르고, 그 선이 * 없으면 트림 경계 표고에서 끊어 **지반선에 붙인다**(그 바깥은 손대지 않은 원지반이라 * 면적이 0이 된다). */ export function computeStructureAreas(input: StructureAreaInput): StructureAreaResult | null { const profile = drawnProfile(input); if (!profile) return null; const { drawnZ, ground } = profile; const { trim } = input; // 적분 격자 = 지반 샘플 ∪ 설계선 꼭짓점 ∪ 트림 경계 ∪ 구조물 폴리라인 꼭짓점. // 꺾이는 자리를 모두 넣어야 사다리꼴 적분이 모서리를 잘라먹지 않는다. const lo = input.ground[0].offset; const hi = input.ground[input.ground.length - 1].offset; const grid = new Set(); const add = (offset: number): void => { if (offset >= lo && offset <= hi) grid.add(Math.round(offset * 1e6) / 1e6); }; input.ground.forEach((sample) => add(sample.offset)); input.designLine.forEach((point) => add(point.offset_m)); add(trim.minOffset); add(trim.maxOffset); // 트림 경계에서 그려지는 선은 **수직으로 끊긴다**(벽면). 경계 바로 바깥 점을 함께 넣어 // 사다리꼴이 그 단차를 비스듬히 이어 붙이지 않게 한다 — 안 넣으면 격자 한 칸만큼 // 없는 면적이 생긴다(실측: 벽 안쪽 6.25㎡ 가 7.5㎡ 로 잡혔다). add(trim.minOffset - BOUNDARY_EPS_M); add(trim.maxOffset + BOUNDARY_EPS_M); trim.minSlope?.points.forEach((point) => add(point.offset)); trim.maxSlope?.points.forEach((point) => add(point.offset)); const offsets = [...grid].sort((a, b) => a - b); if (offsets.length < 2) return null; const diffs = offsets.map((offset) => ground(offset) - drawnZ(offset)); const [cutArea, fillArea] = trapezoidAreas(offsets, diffs); const rock = input.rockBoundaryOffsetM; const [cutSoil, cutRock] = typeof rock === "number" && Number.isFinite(rock) ? splitCutAreas(offsets, diffs, Math.abs(rock)) : [cutArea, 0]; return { cutAreaM2: cutArea, fillAreaM2: fillArea, cutSoilAreaM2: cutSoil, cutRockAreaM2: cutRock, }; } /** * 벽 몸 도형(볼록 다각형) 중 **성토 폐회로 안에 든 넓이**(㎡). * 폐회로는 구조물 면적을 안 뺌(2026-09-06 확정)이라 벽 몸이 성토에도 셈 — 고치지 않고 그 겹침을 * 재서 수량 사유로 올림(2026-09-14 브레인). 세로줄로 잘라 [max(몸 밑, 지반), min(몸 위, 설계선)] 을 쌓음. */ export function wallBodyInFillM2( points: Array<{ offset: number; elevation: number }>, input: StructureAreaInput, steps = 400, ): number { const profile = drawnProfile(input); if (!profile || points.length < 3) return 0; const offsets = points.map((point) => point.offset); const lo = Math.min(...offsets); const hi = Math.max(...offsets); if (!(hi > lo)) return 0; const dx = (hi - lo) / steps; let area = 0; for (let index = 0; index < steps; index += 1) { const x = lo + (index + 0.5) * dx; const hits: number[] = []; points.forEach((p, k) => { const q = points[(k + 1) % points.length]; if ((p.offset - x) * (q.offset - x) > 0 || Math.abs(q.offset - p.offset) < 1e-12) return; hits.push( p.elevation + ((q.elevation - p.elevation) * (x - p.offset)) / (q.offset - p.offset), ); }); if (hits.length < 2) continue; const top = Math.min(Math.max(...hits), profile.drawnZ(x)); const bottom = Math.max(Math.min(...hits), profile.ground(x)); if (top > bottom) area += (top - bottom) * dx; } return area; }