/* ============================================================================= * B06_Section_UI_Cross_Box_Geom.ts * BOX암거 구체 **기하 계산** — 그리기(`_Cross_Box.ts`)와 분리(700줄 제한). * 백엔드 `section.box` 제원을 좌표로 옮긴다(치수 결정 금지). * * 구성(2026-08-25 사용자 확정): * · 박스 축 = 계류 방향이라 횡단도에는 **종단 절단면**(상판·내공·저판)이 보인다. * · 상판 윗면 = **노면 − 복토 0.5m**(별표2 교차 참조), 구체는 수평 설치(교본 3장). * · 저판은 **날개벽 투영**(길이×cos각)만큼 계류 상·하류로 길어진다 — 세월교와 같은 산식. * · 내공(유로)은 비워 둔다 — 관 위 성토 채움처럼 면을 칠하지 않는다. * · **성토선은 박스 최상단 모서리에서 끝난다**(2026-08-25 사용자). 물매 1:1.2 고정이라 * 박스가 노견 밖으로 나간 만큼 노견을 수평 연장하고 거기서 사면을 시작한다 — * 기슭막이 "벽이 밖으로 나간 만큼 노폭 연장" 규칙과 같다. 그래서 박스 길이를 * 늘리면 노견부가 저절로 길어지고 사면 각도는 그대로다. * 부재 두께는 세월교 승계(측벽 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"; export interface BoxLayout { box: BoxSet; /** 상판 폴리곤(노견~노견). */ topSlab: OffsetPoint[]; /** 저판 폴리곤 — 날개벽 투영만큼 각 측 연장. */ bottomSlab: OffsetPoint[]; /** 내공(유로) 사각 — 비워 두고 윤곽선·라벨만 쓴다. */ barrel: OffsetPoint[]; /** 상판 윗면·저판 밑면 표고. */ topElevation: number; bottomElevation: number; /** 저판 전체 길이(m) — 노폭 + 유입·유출 날개벽 투영. */ slabLengthM: number; /** 구체 바닥과 계류 하상(원지반 최저)의 고저차(m). +면 구체가 하상 위로 떠 있다. */ bedGapM: number; /** * 양측 성토선 — 노견 → (박스가 나간 만큼 수평 연장) → 박스 최상단 모서리에서 끝. * 물매 1:1.2 고정이며, 모서리 밖은 구체 개구부라 선을 잇지 않는다. */ fillLines: OffsetPoint[][]; label: { at: OffsetPoint; text: string }; /** 노견 밖 설계선은 이 성토선이 대신한다 — 노견에서 끊는다. */ designTrim: CulvertDesignTrim; } /** 노면 표고 — 설계선이 있으면 그 곡선, 없으면 노견 직선(세월교와 같은 규칙). */ 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; }; } /** 사각 폴리곤(좌상 → 우상 → 우하 → 좌하). */ function rect(left: number, right: number, top: number, bottom: number): OffsetPoint[] { return [ { offset: left, elevation: top }, { offset: right, elevation: top }, { offset: right, elevation: bottom }, { offset: left, elevation: bottom }, ]; } /** BOX암거 구체 기하. 제원·설계선·지반이 부족하면 null. */ export function computeBoxLayout( section: CrossSection, groundSamples: SectionSample[], ): 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; // 좌표 규약: +offset = 좌측. 유입 = 상단측(미상이면 좌측 폴백). const inletOnLeft = (section.uphill_side ?? "left") === "left"; 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; // 구체는 수평 설치(교본 3장) — 노면이 가장 낮은 자리에서도 복토를 확보한다. const roadLow = Math.min(roadTopAt(leftEdge), roadTopAt(rightEdge), roadTopAt(0)); const topElevation = roadLow - box.cover_m; const barrelTop = topElevation - box.top_thickness_m; const barrelBottom = barrelTop - box.inner_height_m; const bottomElevation = barrelBottom - box.slab_thickness_m; // 구체 길이 — 성토선이 박스 최상단을 지나려면 (노면 − 박스 상단) × 물매만큼은 노견 // 밖으로 나가야 한다. 노면이 크라운이라 좌·우 필요량이 다르므로 **큰 쪽에 맞춰** // 대칭으로 잡고, 남는 쪽은 노견을 수평 연장해 물매 1:1.2를 지킨다(2026-08-25 사용자). const reach = Math.max(roadTopAt(leftEdge), roadTopAt(rightEdge)) - topElevation > 0 ? (Math.max(roadTopAt(leftEdge), roadTopAt(rightEdge)) - topElevation) * FILL_SLOPE_RATIO_MIN : box.cover_m * FILL_SLOPE_RATIO_MIN; const bodyLeft = leftEdge + reach; const bodyRight = rightEdge - reach; // 저판 연장 — 유입·유출 날개벽 각도가 만든 투영량(백엔드 계산값). const leftExtend = Math.max((inletOnLeft ? box.wing_in : box.wing_out).slab_extend_m, 0); const rightExtend = Math.max((inletOnLeft ? box.wing_out : box.wing_in).slab_extend_m, 0); const slabLeft = bodyLeft + leftExtend; const slabRight = bodyRight - rightExtend; // 계류 하상과의 고저차 — 터파기·성토 판정은 후속이라 값만 넘긴다. let bed = Math.min(groundAt(leftEdge), groundAt(rightEdge)); for (let offset = rightEdge; offset <= leftEdge; offset += 0.25) { bed = Math.min(bed, groundAt(offset)); } // 성토선 — 노견 표고에서 수평으로 나간 뒤 박스 최상단 모서리를 지나 지반까지. const fillLine = (edgeOffset: number, bodyOffset: number, outward: number): OffsetPoint[] => { const roadZ = roadTopAt(edgeOffset); // 사면이 박스 모서리를 지나려면 시작점이 모서리에서 (높이차 × 물매)만큼 안쪽이다. const start = bodyOffset - outward * (roadZ - topElevation) * FILL_SLOPE_RATIO_MIN; const corner: OffsetPoint = { offset: bodyOffset, elevation: topElevation }; const points: OffsetPoint[] = [{ offset: edgeOffset, elevation: roadZ }]; // 박스가 더 길면 노견을 수평으로 늘린다(각도 불변 — 2026-08-25 사용자). if ((start - edgeOffset) * outward > 0.01) points.push({ offset: start, elevation: roadZ }); // 모서리에서 끝난다 — 그 밖은 구체 개구부·날개벽이라 성토가 아니다 // (2026-08-25 사용자: 박스와 만난 뒤의 성토선은 필요 없다). points.push(corner); return points; }; return { box, topSlab: rect(bodyLeft, bodyRight, topElevation, barrelTop), bottomSlab: rect(slabLeft, slabRight, barrelBottom, bottomElevation), barrel: rect(bodyLeft, bodyRight, barrelTop, barrelBottom), fillLines: [fillLine(leftEdge, bodyLeft, 1), fillLine(rightEdge, bodyRight, -1)], designTrim: { minOffset: rightEdge, maxOffset: leftEdge, minElevation: roadTopAt(rightEdge), maxElevation: roadTopAt(leftEdge), }, topElevation, bottomElevation, slabLengthM: Math.abs(slabLeft - slabRight), bedGapM: Number((bottomElevation - bed).toFixed(3)), label: { at: { offset: (bodyLeft + bodyRight) / 2, elevation: (barrelTop + barrelBottom) / 2 }, text: `BOX ${box.inner_width_m.toFixed(1)}×${box.inner_height_m.toFixed(1)}`, }, }; }