/* ============================================================================= * B06_Section_UI_Cross_Box_Geom.ts * BOX암거 구체 **기하 계산** — 그리기(`_Cross_Box.ts`)와 분리(700줄 제한). * 백엔드 `section.box` 제원을 좌표로 옮긴다(치수 결정 금지). * * 구성(2026-08-25 사용자 확정): * · 박스 축 = 계류 방향이라 횡단도에는 **종단 절단면**(상판·내공·저판)이 보인다. * · 상판 윗면 = 노면 − 복토 0.5m(별표2 교차 참조)가 기본이고, 조정창에서 **좌·우 끝 * 표고를 따로** 올리고 내린다 — 값이 다르면 구체가 기울어진다(물매). * · **성토선은 그쪽 구체 최상단 모서리에서 끝난다.** 물매 1:1.2 고정이라 구체가 길수록 * 노견이 수평으로 늘어난다(기슭막이 "벽이 밖으로 나간 만큼 노폭 연장"과 같은 규칙). * · **날개벽 구간 바닥(에이프런)은 각 끝 표고에서 수평**이고 구체 저판만 기울기를 * 따라간다. 에이프런 길이 = 날개벽 투영(길이×cos각). * · 내공(유로)은 비워 둔다 — 면을 칠하지 않는다. * 부재 두께는 세월교 승계(측벽 0.2·상판 0.3·저판 0.3)이며 백엔드가 실어 보낸다. * ========================================================================== */ import type { BoxSet, CrossSection, SectionSample } from "./B06_Section_Api_Fetch"; import type { CulvertDesignTrim, OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types"; import { FILL_SLOPE_RATIO_MIN } from "./B06_Section_UI_Cross_Culvert_Const"; import { designInterpolator, groundInterpolator } from "./B06_Section_UI_Cross_Culvert_Solve"; /** 구체 한쪽 끝의 조작값 — 좌·우를 따로 잡는다(2026-08-25 사용자). */ export interface BoxSideAdjust { /** 기본 길이(성토선 규칙)에서 더 내보낸 양(m, + = 바깥). 음수는 물매가 깨져 막는다. */ lengthM: number; /** 그쪽 끝의 구체 표고 조정(m, + = 위). 좌·우가 다르면 구체가 기운다. */ riseM: number; } /** 측점별 BOX암거 조정값. */ export interface BoxAdjust { left: BoxSideAdjust; right: BoxSideAdjust; } export type BoxSideRole = "left" | "right"; export const DEFAULT_BOX_SIDE_ADJUST: BoxSideAdjust = { lengthM: 0, riseM: 0 }; export const DEFAULT_BOX_ADJUST: BoxAdjust = { left: DEFAULT_BOX_SIDE_ADJUST, right: DEFAULT_BOX_SIDE_ADJUST, }; export interface BoxSideLayout { role: BoxSideRole; /** 구체 끝 offset(+ = 좌측). */ offset: number; /** 그 끝의 상판 윗면 표고. */ topElevation: number; /** 그 끝의 저판 밑면 표고. */ bottomElevation: number; /** 날개벽 구간 바닥(에이프런) — 이 끝에서 수평으로 뻗는다. 연장이 0이면 빈 배열. */ apron: OffsetPoint[]; /** 성토선 — 노견 → (수평 연장) → 구체 최상단 모서리에서 끝. */ fillLine: OffsetPoint[]; /** 적용된 조작값(한계에 걸린 뒤). */ adjust: BoxSideAdjust; } export interface BoxLayout { box: BoxSet; sides: BoxSideLayout[]; /** 상판 폴리곤(좌·우 표고가 다르면 기울어진다). */ topSlab: OffsetPoint[]; /** 구체 저판 폴리곤 — 상판과 같은 기울기. */ bottomSlab: OffsetPoint[]; /** 내공(유로) 사각 — 비워 두고 윤곽선·라벨만 쓴다. */ barrel: OffsetPoint[]; /** 구체 길이(m, 좌우 끝 사이). */ bodyLengthM: number; /** 바닥 전체 길이(m) — 구체 + 양측 에이프런. */ slabLengthM: number; /** 구체 물매(1:n). 수평이면 null. */ slopeRatio: number | null; /** 구체 바닥과 계류 하상(원지반 최저)의 고저차(m). +면 구체가 하상 위로 떠 있다. */ bedGapM: number; label: { at: OffsetPoint; text: string }; /** 노견 밖 설계선은 성토선이 대신한다 — 노견에서 끊는다. */ designTrim: CulvertDesignTrim; /** 한계에 걸린 뒤 **실제 적용된** 조작값 — 조정창이 되받는다. */ adjust: BoxAdjust; } /** 노면 표고 — 설계선이 있으면 그 곡선, 없으면 노견 직선(세월교와 같은 규칙). */ function roadTopFactory( section: CrossSection, left: { offset_m: number; elevation_m: number }, right: { offset_m: number; elevation_m: number }, ): (offset: number) => number { const designAt = designInterpolator(section.design?.design_line); if (designAt) return designAt; const span = left.offset_m - right.offset_m; return (offset: number): number => { if (Math.abs(span) < 1e-6) return left.elevation_m; const t = (offset - right.offset_m) / span; return right.elevation_m + (left.elevation_m - right.elevation_m) * t; }; } /** BOX암거 구체 기하. 제원·설계선·지반이 부족하면 null. */ export function computeBoxLayout( section: CrossSection, groundSamples: SectionSample[], adjust: BoxAdjust = DEFAULT_BOX_ADJUST, ): BoxLayout | null { const box = section.box; if (!box) return null; const edges = section.design?.road_edges; if (!edges) return null; const groundAt = groundInterpolator(groundSamples); if (!groundAt) return null; const roadTopAt = roadTopFactory(section, edges.left, edges.right); const leftEdge = edges.left.offset_m; const rightEdge = edges.right.offset_m; if (!(leftEdge > rightEdge)) return null; // 좌표 규약: +offset = 좌측. 유입 = 상단측(미상이면 좌측 폴백). const inletOnLeft = (section.uphill_side ?? "left") === "left"; const baseTop = Math.min(roadTopAt(leftEdge), roadTopAt(rightEdge), roadTopAt(0)) - box.cover_m; const bodyHeight = box.top_thickness_m + box.inner_height_m + box.slab_thickness_m; const sideOf = (role: BoxSideRole): BoxSideLayout => { const outward = role === "left" ? 1 : -1; const edgeOffset = role === "left" ? leftEdge : rightEdge; const wanted = role === "left" ? adjust.left : adjust.right; const lengthM = Math.max(wanted.lengthM, 0); const topElevation = baseTop + wanted.riseM; // 성토선이 구체 최상단을 지나려면 (노면 − 상단) × 물매만큼은 노견 밖으로 나가야 한다. const reach = Math.max((roadTopAt(edgeOffset) - topElevation) * FILL_SLOPE_RATIO_MIN, 0); const offset = edgeOffset + outward * (reach + lengthM); const bottomElevation = topElevation - bodyHeight; // 날개벽 구간 바닥 — 각 끝 표고에서 **수평**(2026-08-25 사용자). const wing = (role === "left") === inletOnLeft ? box.wing_in : box.wing_out; const extend = Math.max(wing.slab_extend_m, 0); const outer = offset + outward * extend; const apron: OffsetPoint[] = extend ? [ { offset, elevation: bottomElevation + box.slab_thickness_m }, { offset: outer, elevation: bottomElevation + box.slab_thickness_m }, { offset: outer, elevation: bottomElevation }, { offset, elevation: bottomElevation }, ] : []; // 성토선 — 노견 표고에서 수평으로 나간 뒤 구체 최상단 모서리에서 끝난다. const roadZ = roadTopAt(edgeOffset); const start = offset - outward * (roadZ - topElevation) * FILL_SLOPE_RATIO_MIN; const fillLine: OffsetPoint[] = [{ offset: edgeOffset, elevation: roadZ }]; if ((start - edgeOffset) * outward > 0.01) fillLine.push({ offset: start, elevation: roadZ }); fillLine.push({ offset, elevation: topElevation }); return { role, offset, topElevation, bottomElevation, apron, fillLine, adjust: { lengthM, riseM: wanted.riseM }, }; }; const left = sideOf("left"); const right = sideOf("right"); const quad = (topDrop: number, bottomDrop: number): OffsetPoint[] => [ { offset: left.offset, elevation: left.topElevation - topDrop }, { offset: right.offset, elevation: right.topElevation - topDrop }, { offset: right.offset, elevation: right.topElevation - bottomDrop }, { offset: left.offset, elevation: left.topElevation - bottomDrop }, ]; const barrelTopDrop = box.top_thickness_m; const barrelBottomDrop = barrelTopDrop + box.inner_height_m; // 계류 하상과의 고저차 — 터파기·성토 판정은 후속이라 값만 넘긴다. let bed = Math.min(groundAt(leftEdge), groundAt(rightEdge)); for (let offset = rightEdge; offset <= leftEdge; offset += 0.25) { bed = Math.min(bed, groundAt(offset)); } const rise = left.topElevation - right.topElevation; const run = left.offset - right.offset; const apronLengthOf = (side: BoxSideLayout): number => side.apron.length ? Math.abs(side.apron[1].offset - side.apron[0].offset) : 0; return { box, sides: [left, right], topSlab: quad(0, barrelTopDrop), bottomSlab: quad(barrelBottomDrop, barrelBottomDrop + box.slab_thickness_m), barrel: quad(barrelTopDrop, barrelBottomDrop), bodyLengthM: run, slabLengthM: run + apronLengthOf(left) + apronLengthOf(right), slopeRatio: Math.abs(rise) > 1e-6 ? Math.abs(run / rise) : null, bedGapM: Number((Math.min(left.bottomElevation, right.bottomElevation) - bed).toFixed(3)), label: { at: { offset: (left.offset + right.offset) / 2, elevation: (left.topElevation + right.topElevation) / 2 - (barrelTopDrop + barrelBottomDrop) / 2, }, text: `BOX ${box.inner_width_m.toFixed(1)}×${box.inner_height_m.toFixed(1)}`, }, designTrim: { minOffset: rightEdge, maxOffset: leftEdge, minElevation: roadTopAt(rightEdge), maxElevation: roadTopAt(leftEdge), }, adjust: { left: left.adjust, right: right.adjust }, }; }