/* ============================================================================= * B06_Section_UI_Cross_Culvert_Extra.ts * 유출측 **성토부선 + 추가 기슭막이(다단)** — Geom에서 분리(700줄 제한). * * 다단 기슭막이는 **1:1.2 고정 물매에서 안정적으로 성토고를 올리는 수단**이다 * (2026-08-22 사용자 확정 규칙): * · 기슭막이 사이 성토사면은 **정확히 1:1.2** — 벽 높이를 자리에 맞춰 역산한다. * · 아랫단 벽 상단은 윗단 벽 **하단(수평 기초선)을 뚫고 올라갈 수 없다**. * · 다음 단 성토선 시작점 = 윗단 벽 **하단 수평선 +0.5m와 전면 경사선(1:0.3)의 * 교차점**(배관 기슭막이의 관 하단 꼭짓점과 같은 자리 — 배관만 없다). * · 그 시작점이 원지반 아래면(벽이 0.5m 이상 묻힘) **성토 불필요** — 다단 종료. * ========================================================================== */ import { EXTRA_WALL_DEFAULT_HEIGHT_M, EXTRA_WALL_MIN_HEIGHT_M, FILL_SLOPE_MAX_LENGTH_M, FILL_SLOPE_RATIO_MIN, materialLabel, materialLimit, REVET_EMBED_DEPTH_M, REVET_LEAN_RATIO, REVET_THICKNESS_M, } from "./B06_Section_UI_Cross_Culvert_Const"; import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const"; import type { OffsetPoint, OutletFillSegment, WallAdjust, WallLayout, } from "./B06_Section_UI_Cross_Culvert_Types"; export interface OutletExtrasInput { /** 성토부선 시작점 = 관 유출 하단 꼭짓점(배관 벽의 성토선 시작 자리). */ start: OffsetPoint; /** 배관 벽 하단(수평 기초) 표고 — 1단 벽 상단이 이 아래에 있어야 한다. */ startBottomElevation: number; outward: number; groundAt: (offset: number) => number; /** 계류측 샘플 한계 offset. */ limitOffset: number; /** 단별 사용자 조작값(좌우 x·상하 d·높이 h·재질 m) — 요청한 단 수만큼. */ adjusts: WallAdjust[]; } export interface OutletExtrasResult { walls: WallLayout[]; segments: OutletFillSegment[]; /** 한계에 잘린 뒤의 실제 조작값 — 조정창이 되받는다. */ appliedAdjusts: WallAdjust[]; /** 끝 성토부가 아직 5m 이상 — 단을 더 둘 수 있다(의무 구간). */ addable: boolean; } /** 벽 중심(하단 중점)에서 이음선 상단점까지의 수평거리 — 높이에 따라 커진다. */ function jointRunOf(height: number): number { return 0.25 * REVET_THICKNESS_M + (REVET_LEAN_RATIO / 2) * height; } /** 끝 성토부선 — src에서 1:1.2로 내려가며 원지반을 만나면 끝(지반이 높으면 지반 따름). */ function trailingFill( src: OffsetPoint, outward: number, groundAt: (offset: number) => number, ): OutletFillSegment { const points: OffsetPoint[] = [src]; let length = 0; let previous = src; const step = 0.25; for (let t = step; t <= 30 + 1e-9; t += step) { const offset = src.offset + outward * t; const slopeElevation = src.elevation - t / FILL_SLOPE_RATIO_MIN; const groundElevation = groundAt(offset); const point: OffsetPoint = { offset, elevation: Math.max(slopeElevation, groundElevation) }; length += Math.hypot(point.offset - previous.offset, point.elevation - previous.elevation); points.push(point); previous = point; if (slopeElevation <= groundElevation) break; } return { points, lengthM: length, ratio: FILL_SLOPE_RATIO_MIN, overLimit: length >= FILL_SLOPE_MAX_LENGTH_M - 1e-6, }; } /** * 추가 기슭막이 벽 1매 — 배관용 기슭막이와 같은 형상(배면 수직·전면 1:0.3, * 수평 기초 근입 0.5m), 배관·마감면만 없다. 높이는 눈금 없이 **정확값**을 쓴다 — * 0.1m 올림을 하면 상단이 1:1.2 사면선을 벗어난다(사이 물매 정확 유지가 우선). */ function buildExtraWall( index: number, anchor: OffsetPoint, outward: number, height: number, material: RevetMaterial, floatGapM: number, groundAt: (offset: number) => number, ): WallLayout { const thickness = REVET_THICKNESS_M; const baseWidth = thickness * 1.5 + REVET_LEAN_RATIO * height; const backOffset = anchor.offset - outward * (baseWidth / 2); const topJoint = backOffset + outward * (thickness / 2); const topElevation = anchor.elevation + height; const topBack: OffsetPoint = { offset: backOffset, elevation: topElevation }; const topFront = topJoint + outward * thickness; const frontXAt = (elevation: number): number => topFront + outward * REVET_LEAN_RATIO * (topElevation - elevation); // 하단 = 기준선(anchor) 아래 근입 0.5m — 근입은 **경사선(전면) 측 깊이** 기준이라 // 전면 발끝 지반이 낮으면 그 아래 0.5까지 더 내린다(2026-08-22 사용자 ②). let bottomElevation = anchor.elevation - REVET_EMBED_DEPTH_M; if (floatGapM <= 1e-6) { for (let pass = 0; pass < 6; pass += 1) { const toeGround = Math.min(groundAt(frontXAt(bottomElevation)), anchor.elevation); if (toeGround - REVET_EMBED_DEPTH_M >= bottomElevation - 1e-6) break; bottomElevation = toeGround - REVET_EMBED_DEPTH_M; } } const bottomBack: OffsetPoint = { offset: backOffset, elevation: bottomElevation }; const bottomFront: OffsetPoint = { offset: frontXAt(bottomElevation), elevation: bottomElevation, }; return { role: "extra", extraIndex: index, form: materialLabel(material), lengthM: null, backOffset, outerOffset: bottomFront.offset, base: anchor.elevation, height, floatGapM, material, outward, topBack, topJoint: { offset: topJoint, elevation: topElevation }, bottomBack, bottomFront, points: [bottomBack, topBack, { offset: topFront, elevation: topElevation }, bottomFront], }; } /** * 유출측 성토부선·다단 기슭막이 일괄 계산 (2026-08-22 4축 조작 체계). * * 단별 벽 높이 h = 사용자 설정 ?? 기본 1.5m (0.5~재질 한계로 절삭). 벽 상단은 * 항상 src에서 내려오는 **1:1.2 성토선 위** — 자동 자리는 높이 h 벽이 지반에 * 앉으며 상단이 선에 닿는 지점(heightAt(x)=h 교차점, 닫힌식): * heightAt(x) = (src.elev − 지반(x) − (D − 0.25t)/1.2) / (1 − 0.15/1.2) * 조작: 좌우 x = src 높이의 **수평 선반**을 끼워 평행이동(물매 불변, 상단 표고 * 불변 — 윗단 기준 0.5m 수평 구현), 상하 d = 성토선을 타는 대각(수평 성분). * 위(−d) 한계 = 윗단 하단 관통 금지(상단 ≤ 윗단 하단). 바닥은 상단−h−0.5로 * 지반과 무관 — 못 닿으면 뜨고(floatGap 경고), 지나면 묻힌다. * 요청 단 수보다 지형이 허락하는 단이 적으면 되는 만큼만 세운다. */ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult { const { outward, groundAt, limitOffset } = input; const thickness = REVET_THICKNESS_M; const heightDenominator = 1 - REVET_LEAN_RATIO / 2 / FILL_SLOPE_RATIO_MIN; const walls: WallLayout[] = []; const segments: OutletFillSegment[] = []; const appliedAdjusts: WallAdjust[] = []; let src = input.start; let prevBottom = input.startBottomElevation; for (let i = 0; i < input.adjusts.length; i += 1) { // 시작점이 원지반 아래 = 윗단 벽이 0.5m 이상 묻힘 → 성토 불필요, 다단 종료. if (groundAt(src.offset) >= src.elevation - 0.01) break; const trailing = trailingFill(src, outward, groundAt); if (trailing.lengthM < 0.7) break; const adjust = input.adjusts[i]; const material = adjust.m ?? "dry"; const limit = materialLimit(material); const height = Math.min( Math.max(adjust.h ?? EXTRA_WALL_DEFAULT_HEIGHT_M, EXTRA_WALL_MIN_HEIGHT_M), limit, ); /** 자리 x(벽 하단 중점)에 지반 안착 + 상단이 성토선에 닿는 데 필요한 높이. */ const heightAt = (x: number): number => { const run = (x - src.offset) * outward; return ( (src.elevation - groundAt(x) - (run - 0.25 * thickness) / FILL_SLOPE_RATIO_MIN) / heightDenominator ); }; // 자동 자리 = heightAt이 사용자 높이 h에 처음 닿는 지점(0.05m 스캔 후 선형 보간). // 지형이 완만해 h까지 못 크면 **가장 깊이 앉는 지점**(heightAt 최대)으로 폴백 — // 바닥은 상단−h−0.5라 지반과 어긋나도 된다(뜨면 경고, 묻히면 그대로). const step = 0.05; const span = (limitOffset - src.offset) * outward; let autoOffset: number | null = null; let bestOffset: number | null = null; let bestHeight = 0.05; // 이 이하로만 앉는 지형이면 단을 세우지 않는다 let previousShort = 0; // 직전 스캔점의 h 부족량(heightAt < h) — 보간용 for (let t = step; t <= Math.max(span, 0) + 1e-9; t += step) { const x = src.offset + outward * t; const gap = heightAt(x) - height; if (gap >= 0) { // 교차점 선형 보간 — 격자 대신 정확 자리(사면선·지반 동시 접점). const back = previousShort + gap > 1e-9 ? (gap / (previousShort + gap)) * step : 0; autoOffset = x - outward * back; break; } previousShort = -gap; if (heightAt(x) > bestHeight) { bestHeight = heightAt(x); bestOffset = x; } } if (autoOffset === null) autoOffset = bestOffset; if (autoOffset === null) break; // 세울 만한 지형이 아니다 — 되는 만큼만. // 관통 금지: 상단 ≤ 윗단 하단 → 성토선 수평거리(경사부) ≥ 1.2×(시작−윗단하단). const autoJointRun = (autoOffset - src.offset) * outward - jointRunOf(height); const minJointRun = FILL_SLOPE_RATIO_MIN * Math.max(src.elevation - prevBottom, 0); const appliedD = Math.max(adjust.d, Math.ceil((minJointRun - autoJointRun) * 10) / 10); const appliedX = Math.max(adjust.x, 0); appliedAdjusts.push({ x: appliedX, d: appliedD, h: adjust.h != null ? height : null, m: adjust.m, }); const slopeRun = autoJointRun + appliedD; const topElevation = src.elevation - slopeRun / FILL_SLOPE_RATIO_MIN; const anchorX = autoOffset + outward * (appliedX + appliedD); const base = topElevation - height; // 근입 0.5 위 기준선 const wall = buildExtraWall( i, { offset: anchorX, elevation: base }, outward, height, material, Math.max(0, base - groundAt(anchorX)), groundAt, ); walls.push(wall); // 성토선: src → (수평 선반 x>0이면 선반 끝) → 이음선 상단점. 사면길이 = 경사부. const shelf: OffsetPoint | null = appliedX > 1e-9 ? { offset: src.offset + outward * appliedX, elevation: src.elevation } : null; const slopeFrom = shelf ?? src; const run = Math.abs(wall.topJoint.offset - slopeFrom.offset); const rise = slopeFrom.elevation - wall.topJoint.elevation; segments.push({ points: shelf ? [src, shelf, wall.topJoint] : [src, wall.topJoint], lengthM: Math.hypot(run, rise), ratio: rise > 1e-9 ? run / rise : FILL_SLOPE_RATIO_MIN, overLimit: Math.hypot(run, rise) >= FILL_SLOPE_MAX_LENGTH_M - 1e-6, }); // 다음 단 시작점 = 이 벽 하단 +0.5m(기준선) 수평선과 전면 경사선(1:0.3)의 교차점. src = { offset: wall.points[2].offset + outward * REVET_LEAN_RATIO * height, elevation: base, }; prevBottom = wall.bottomBack.elevation; } // 끝 성토부 — 마지막 구조물의 시작점이 지반 위일 때만 그린다(묻히면 성토 불필요). let addable = false; if (groundAt(src.offset) < src.elevation - 0.01) { const tail = trailingFill(src, outward, groundAt); segments.push(tail); addable = tail.overLimit; } return { walls, segments, appliedAdjusts, addable }; }