/* ============================================================================= * B06_Section_UI_Cross_Culvert_Geom.ts * 배수관 세트(배관·기슭막이·보호공) **기하 계산** — 그리기(`_Cross_Culvert.ts`)와 분리. * 백엔드가 만든 `section.culvert` 제원을 좌표로 옮긴다 — 치수를 새로 정하지 않는다. * * 배치 규칙 — 실무 횡단도 4장 기준(2026-08-20 사용자 제공, 울진 계열 주기): * · 기슭막이 2기 — 유입/유출측 성토사면 안. 사면 쪽으로 기운 평행사변형(전면 1:0.3, * 두께 0.45m). 자리는 사면선이 이음선 상단점을 지나도록 풀어서 정한다. * · 배관 — 두 벽 사이(도로 하부)만 직선. 끝단면은 벽 전면과 평행하게 마감(수평 가능). * · 보호공(돌붙임) — 관 하단 꼭짓점에서 성토사면 경사를 따라 원지반까지, 최소 길이 * (낙차고×2)를 채울 때까지 지반을 따라 더 간다. 유입측은 수평 보호공 없음. * · 성토 경사 — 벽 전면과 교차된 이후는 끊는다(designTrim — 벽·보호공이 대신한다). * * 유입 = 상단측(uphill_side), 유출 = 하단측. 유입구가 "집수정"이면 그쪽 기슭막이는 * 그리지 않고 집수정 단면(I/ㄴ/ㄷ형)으로 바꾼다. * ========================================================================== */ import type { CrossSection, CulvertSideSpec, SectionSample } from "./B06_Section_Api_Fetch"; import { FILL_MIN_RISE_M, FILL_SLOPE_MAX_LENGTH_M, FILL_STRUCTURE_HEIGHT_M, FILL_SLOPE_RATIO_MAX, FILL_SLOPE_RATIO_MIN, MIN_PIPE_COVER_M, PITCHING_THICKNESS_M, REVET_EMBED_DEPTH_M, REVET_LEAN_RATIO, REVET_THICKNESS_M, revetHeightLimit, revetTargetHeight, } from "./B06_Section_UI_Cross_Culvert_Const"; import type { BasinLayout, BasinShape, CulvertLayout, EndFace, OffsetPoint, PipeEnd, WallLayout, } from "./B06_Section_UI_Cross_Culvert_Types"; // 상수·자료형은 분리 파일에 있고, 기존 import 경로를 유지하기 위해 그대로 다시 내보낸다. export * from "./B06_Section_UI_Cross_Culvert_Const"; export * from "./B06_Section_UI_Cross_Culvert_Types"; import { clampWallOffset, cutLength, outletSlopeFactory, fillSlopeOf, groundInterpolator, designInterpolator, intersect, slopeToeOffset, solveFillWallOffset, STRAY_LIMIT_M, } from "./B06_Section_UI_Cross_Culvert_Solve"; import type { FillSlopeSegment, FillWallPlacement } from "./B06_Section_UI_Cross_Culvert_Solve"; /** 배수관 세트 기하 계산. 부족한 입력이면 null — 그리기와 분리(설계선 트림이 먼저 쓴다). */ export function computeCulvertLayout( section: CrossSection, groundSamples: SectionSample[], /** 사용자가 손으로 민 기슭막이 X 이동량(m, + = 계류측 바깥). 없으면 자동 자리. */ revetShift?: { inlet?: number; outlet?: number }, ): CulvertLayout | null { const culvert = section.culvert; if (!culvert) return null; const groundAt = groundInterpolator(groundSamples); if (!groundAt) return null; const edges = section.design?.road_edges; if (!edges) return null; const designAt = designInterpolator(section.design?.design_line); const sampleOffsets = groundSamples.map((sample) => sample.offset_m ?? 0); const minSample = Math.min(...sampleOffsets); const maxSample = Math.max(...sampleOffsets); if (!(maxSample > minSample)) return null; // 좌표 규약: +offset = 좌측(화면 왼쪽). 상단측이 유입이다(미상이면 좌측 폴백). const uphill = section.uphill_side ?? "left"; const sideInfo = (side: "left" | "right") => ({ edge: side === "left" ? edges.left : edges.right, outward: side === "left" ? 1 : -1, limit: side === "left" ? maxSample : minSample, }); const inletInfo = sideInfo(uphill === "left" ? "left" : "right"); const outletInfo = sideInfo(uphill === "left" ? "right" : "left"); // 유입 invert: 유입 노견 아래 원지반. 절토측이면 "노면 − 관경 − 토피"가 상한 // (관 상단 + 토피가 노면 안에 들어가는 최고 자리 — B05 하향 차단식과 동일). const diameter = culvert.diameter_m; const invertCap = (edge: { offset_m: number; elevation_m: number }): number => (designAt ? designAt(edge.offset_m) : edge.elevation_m) - diameter - MIN_PIPE_COVER_M; const inletInvert = Math.min(groundAt(inletInfo.edge.offset_m), invertCap(inletInfo.edge)); const inlet: OffsetPoint = { offset: inletInfo.edge.offset_m, elevation: inletInvert }; // 유입측 절토 판정: 절토측 유입은 자연스럽게 집수정(ㄴ형 기본)이 된다. const mode = section.design?.section_mode; const inletSideName: "left" | "right" = uphill === "left" ? "left" : "right"; const inletIsCut = mode === "both_cut" || (mode === "left_cut" && inletSideName === "left") || (mode === "right_cut" && inletSideName === "right"); // 성토측 유입이라도 관 앞 원지반이 관 단면의 30% 이상을 막으면 집수정으로 바꾼다 // (2026-08-20 사용자 확정 — 자연 유입이 불가해 옆도랑 물을 받아야 하는 형상). const inletFrontOffset = inlet.offset + inletInfo.outward * (REVET_THICKNESS_M * 2 + REVET_LEAN_RATIO * diameter); const blockedRatio = (groundAt(inletFrontOffset) - inletInvert) / Math.max(diameter, 1e-6); const inletBlocked = !inletIsCut && blockedRatio >= 0.3; const basinReason: BasinLayout["reason"] | null = inletIsCut ? "cut" : inletBlocked ? "blocked" : null; // 성토측 유입 기슭막이는 노견에 붙여 두지 않는다 — 사면선이 벽 이음선 상단점을 지나야 // 하므로 자리를 풀어서 정한다(집수정은 측구부 자리 그대로). const wallHeightFor = (spec: CulvertSideSpec): number => Math.min(revetTargetHeight(diameter), revetHeightLimit(spec.revet_form)); let inletPlacement: FillWallPlacement | null = null; if (!basinReason && designAt) { // 훑는 범위는 **성토 사면 끝까지**다 — 그 너머는 받칠 성토가 없다. 성토가 얕아 벽 // 상단이 노견보다 높은 구간에서는 물매가 성립하지 않는데, 한계를 지반 샘플 끝까지 // 두면 벽이 몇 m 밖으로 밀려난다(계획고 스윕에서 확인). inletPlacement = solveFillWallOffset( inletInfo.edge, inletInfo.outward, wallHeightFor(culvert.inlet), (offset) => Math.min(groundAt(offset), invertCap(inletInfo.edge)), slopeToeOffset(designAt, groundAt, inletInfo.edge.offset_m, inletInfo.limit), ); if (inletPlacement) { // 사용자가 조정창으로 민 만큼 자동 자리에서 더 옮긴다 — 사면 역전 구간은 막는다. const inletBaseAt = (offset: number): number => Math.min(groundAt(offset), invertCap(inletInfo.edge)); const shifted = clampWallOffset( inletPlacement.offset, inletPlacement.offset + inletInfo.outward * (revetShift?.inlet ?? 0), wallHeightFor(culvert.inlet), inletInfo.edge, inletBaseAt, inletInfo.outward, ); inlet.offset = shifted; inlet.elevation = Math.min(groundAt(shifted), invertCap(inletInfo.edge)); } } // 유출 목표점 = 유출측 성토사면이 지반과 만나는 사면 끝(경사길이 5m 한계 — 별표2). // 그 자리가 유출 기슭막이 자리이고, invert는 원지반(관 끝이 원지반 위 — 실무 도면). const outletAnchorOffset = designAt ? slopeToeOffset(designAt, groundAt, outletInfo.edge.offset_m, outletInfo.limit) : outletInfo.edge.offset_m; // 성토사면 경사길이 실측(노견 → 사면 끝) — 법정 5m 이내 충족 확인용(사용자 ①). let fillSlopeLength = 0; if (designAt) { const from = outletInfo.edge.offset_m; const steps = 60; let prevElev = designAt(from); for (let i = 1; i <= steps; i += 1) { const o = from + ((outletAnchorOffset - from) * i) / steps; const e = designAt(o); fillSlopeLength += Math.hypot((outletAnchorOffset - from) / steps, e - prevElev); prevElev = e; } } // 역경사는 수평으로 클램프(수평 가능 — 사용자 확정). const outletInvert = Math.min(groundAt(outletAnchorOffset), inlet.elevation); const outletAnchor: OffsetPoint = { offset: outletAnchorOffset, elevation: outletInvert }; const run0 = outletAnchor.offset - inlet.offset; const rise0 = outletAnchor.elevation - inlet.elevation; const length0 = Math.hypot(run0, rise0); if (!(length0 > 0.5)) return null; // 관은 m 단위 설치 — 올림 연장분은 유출 쪽으로(2026-08-20 사용자 확정). let lengthM = Math.ceil(length0 - 1e-6); const scale = lengthM / length0; const outlet: OffsetPoint = { offset: inlet.offset + run0 * scale, elevation: inlet.elevation + rise0 * scale, }; const span = Math.abs(outletAnchor.offset - inlet.offset); let slopePct = span > 0 ? ((inlet.elevation - outletInvert) / span) * 100 : 0; // 기슭막이 합성 단면(사용자 ①·②): 하부 = 배면 수직·전면 1:0.3 **사다리꼴**(관이 // 지나는 관경 높이), 상부 = 전·배면이 나란히 기운 **평행사변형**. 상단 배면 꼭짓점이 // 성토 사면선(설계선)과 만나는 접점이 되도록 높이를 정한다 — 사면선은 거기서 끊긴다. const walls: WallLayout[] = []; let basin: BasinLayout | null = null; /** 집수정이 서면 관 유입단을 내공 안으로 옮긴다(접속 표현). */ let basinPipeEnd: OffsetPoint | null = null; /** 관 끝단 마감면 — 구조물 계류측 변 복사(2026-08-20 사용자 ③). */ const endFaces: { inlet: EndFace | null; outlet: EndFace | null } = { inlet: null, outlet: null, }; let trimMin = Number.NEGATIVE_INFINITY; let trimMax = Number.POSITIVE_INFINITY; // 트림 경계에서 성토 경사선 끝단이 **닿아야 할 표고** — 벽 높이를 관경+여유고로 고정한 // 뒤로는 설계 사면선이 벽 상단과 어긋나므로, 사면선 끝을 이 점까지 내려 맞춘다 // (2026-08-21 사용자 ② — 평행사변형·사다리꼴 교차 두 점 중 **상단** 교차점). let trimMinElevation: number | null = null; let trimMaxElevation: number | null = null; /** 노견 → 벽 이음선 상단점 성토 사면 구간(단일 각도). 설계선 대신 그린다. */ let trimMinSlope: { from: OffsetPoint; to: OffsetPoint } | null = null; let trimMaxSlope: { from: OffsetPoint; to: OffsetPoint } | null = null; const buildWall = ( spec: CulvertSideSpec, anchor: OffsetPoint, outward: number, forceBasinReason: BasinLayout["reason"] | null, thickness: number = REVET_THICKNESS_M, ): WallLayout | null => { const reason: BasinLayout["reason"] | null = forceBasinReason ?? (spec.structure === "집수정" ? "cut" : null); if (reason) { // 집수정 = 기슭막이와 같은 **평행사변형 벽**(+ 형식에 따라 바닥·반대측 막음). // 형식 기본값 = ㄴ(L)형(2026-08-20 사용자 확정). 실무 내공 1.0m(울진 돌집수정). const shape: BasinShape = "L"; const innerWidth = 1.0; const floorThickness = REVET_THICKNESS_M; const roadTop = designAt ? designAt(anchor.offset) : anchor.elevation; // 벽 높이: **상단 도로측 꼭지점이 성토 경사선(설계선)과 만나는 교점**까지 // (2026-08-20 사용자 — 기슭막이와 같은 접점 규칙). 하한은 관경 + 0.45. const basinMinHeight = diameter + REVET_THICKNESS_M; let wallHeight = Math.max(roadTop - anchor.elevation, basinMinHeight); if (designAt) { const wallBaseProbe = anchor.offset + outward * innerWidth; for (let h = basinMinHeight; h <= 4.0 + 1e-6; h += 0.05) { // 상단 도로측 꼭지점(기움 반전 — 상단이 도로측으로 물러난다). const topInner = wallBaseProbe - outward * REVET_LEAN_RATIO * h; wallHeight = h; if (anchor.elevation + h >= designAt(topInner)) break; } wallHeight = Math.max(wallHeight, basinMinHeight); } // 벽 단면은 기슭막이와 동일(두께 0.45). 기움은 **수직 기준 반전** — 상단이 // 도로측(내공 쪽)으로 1:0.3 물러난다(2026-08-20 사용자 정정). const wallOf = (baseOffset: number, dir: number): OffsetPoint[] => { const topShift = -dir * REVET_LEAN_RATIO * wallHeight; return [ { offset: baseOffset, elevation: anchor.elevation - floorThickness }, { offset: baseOffset + topShift, elevation: anchor.elevation + wallHeight }, { offset: baseOffset + topShift + dir * REVET_THICKNESS_M, elevation: anchor.elevation + wallHeight, }, { offset: baseOffset + dir * REVET_THICKNESS_M, elevation: anchor.elevation - floorThickness, }, ]; }; // I형 벽 = 계류측(도로 바깥) 면 — 관 유입단에서 내공 1.0m 떨어져 선다. const wallBase = anchor.offset + outward * innerWidth; const parts: BasinLayout["parts"] = [{ kind: "wall", points: wallOf(wallBase, outward) }]; // ㄴ형 = I형 + 바닥판. 바닥은 **아래가 짧은 사다리꼴**(윗변이 길고 아랫변이 // 짧다 — 벽과 같은 1:0.3 물매가 양 끝에 붙는다). 위치는 **I형 벽 기준 반대측** // = 도로측으로 뻗는다(2026-08-20 사용자 확정). // 바닥은 **I형 벽의 바깥(계류측) 변에 좌측 변을 맞대고** 그 너머로 뻗는다 // (2026-08-20 사용자 확정). 벽이 기울어 있으므로 바닥 좌측 변도 **벽 바깥면 선을 // 그대로 따라** 기울여야 틈 없이 붙는다(높이마다 벽 x가 달라진다). const wallOuterAt = (elevation: number): number => { const bottom = anchor.elevation - floorThickness; const t = (elevation - bottom) / (wallHeight + floorThickness); return wallBase + outward * REVET_THICKNESS_M - outward * REVET_LEAN_RATIO * wallHeight * t; }; const floorTopInner = wallOuterAt(anchor.elevation); const floorBottomInner = wallOuterAt(anchor.elevation - floorThickness); const floorOuter = floorTopInner + outward * (innerWidth + REVET_THICKNESS_M); const floorTaper = REVET_LEAN_RATIO * floorThickness; parts.push({ kind: "floor", points: [ { offset: floorTopInner, elevation: anchor.elevation }, { offset: floorOuter, elevation: anchor.elevation }, { offset: floorOuter - outward * floorTaper, elevation: anchor.elevation - floorThickness, }, { offset: floorBottomInner, elevation: anchor.elevation - floorThickness }, ], }); // ㄷ형이면 반대측(도로측) 막음벽을 하나 더 세운다 — 지금은 L형 기본이라 미사용. if ((shape as BasinShape) === "U") { parts.push({ kind: "wall", points: wallOf(anchor.offset, -outward) }); } // ㄴ·ㄷ형이 원지반 **안쪽**에 박히면 그만큼 절토가 필요하다(2026-08-20 사용자 ①). // 구조물 바깥 끝 상단에서 표준단면 절토경사(1:n)로 원지반과 만나는 점까지 긋는다. let basinCut: BasinLayout["cutLine"] = null; if ((shape as BasinShape) !== "I") { const outerTop = { offset: floorOuter, elevation: anchor.elevation }; if (groundAt(floorOuter) > anchor.elevation + 0.05) { const cutRatio = section.design?.cut_slope_ratio ?? 1.0; for (let h = 0.05; h <= 20; h += 0.05) { const probe = floorOuter + outward * cutRatio * h; if (groundAt(probe) <= anchor.elevation + h) { basinCut = { from: outerTop, to: { offset: probe, elevation: anchor.elevation + h }, }; break; } } } } basin = { shape, parts, // 라벨 자리 = **I형 벽 하단**(2026-08-20 사용자). 상단에 두면 노면·계획선과 // 겹친다 — 벽 바닥 변 중앙에 두고 그리기 쪽에서 아래로 내려 붙인다. label: { offset: wallBase + outward * (REVET_THICKNESS_M / 2), elevation: anchor.elevation - floorThickness, }, reason, cutLine: basinCut, }; // 성토 사면선은 **벽 상단 도로측 꼭지점(교점)**에서 끊는다 — 바닥 끝을 기준으로 // 두면 사면선이 집수정을 뚫고 들어간다(2026-08-20 사용자 ①). const wallTopInner = wallBase - outward * REVET_LEAN_RATIO * wallHeight; if (outward > 0) { trimMax = wallTopInner; trimMaxElevation = anchor.elevation + wallHeight; } else { trimMin = wallTopInner; trimMinElevation = anchor.elevation + wallHeight; } // 관 유입 끝단 마감면 = 집수정 벽의 **계류측 변**을 그대로 복사(사용자 ③). const faceBottom = { offset: wallBase + outward * REVET_THICKNESS_M, elevation: anchor.elevation - floorThickness, }; const faceTop = { offset: wallBase - outward * REVET_LEAN_RATIO * wallHeight + outward * REVET_THICKNESS_M, elevation: anchor.elevation + wallHeight, }; endFaces[spec.role] = { base: faceBottom, direction: { offset: faceTop.offset - faceBottom.offset, elevation: faceTop.elevation - faceBottom.elevation, }, }; // 관은 집수정 내공을 가로질러 **안쪽 벽면까지** 물린다 — 벽 몸통 중간에서 끊기면 // 접속이 어긋나 보인다(2026-08-20 사용자 ③). basinPipeEnd = { offset: anchor.offset + outward * innerWidth, elevation: anchor.elevation, }; return null; } // 형상(사용자 스케치 확정 — 좌측 벽 기준, 우측은 반전): 배면(도로측) 수직, 전면 // (계류측)은 1:0.3 기운 평행사변형 띠. 띠 안쪽 평행선과 수직 배면 사이가 사다리꼴로, // 바닥이 넓고 상단이 좁다(상단 폭 = 띠 두께 0.45). // 높이: **관경 + 여유고**를 0.1m 눈금으로 올린 값(2026-08-21 사용자 확정). 관이 // 물매를 두어 좌·우 invert가 달라도 **관 상단 위 여유고는 같아야** 한다. 사면선은 // 높이를 정하지 않고 벽이 설 **자리**만 정한다(`solveFillWallOffset`). const topJoint = anchor.offset + outward * (thickness / 2); // 형태별 법정·교본 높이 한계(찰 3.0 / 메 2.0 — 돌쌓기.md §1)를 넘지 못한다. const height = Math.min(revetTargetHeight(diameter), revetHeightLimit(spec.revet_form)); if (!(height > 0.05)) return null; const topBack: OffsetPoint = { offset: anchor.offset, elevation: anchor.elevation + height, }; // 상단 변: 사다리꼴 상단(0.45) + 띠 상단(0.45). 이음선은 상단 변 중간점(topJoint) // 에서 1:0.3으로 바닥까지 — 사다리꼴(수직 배면, 상단 0.45 → 하단 0.45+0.3H)과 // 평행사변형 띠(폭 0.45)가 진짜 사다리꼴·평행사변형이 된다. const topFront = topJoint + outward * thickness; const jointBase = topJoint + outward * REVET_LEAN_RATIO * height; const frontBase = jointBase + outward * thickness; // 근입: 벽 바닥은 원지반 아래 REVET_EMBED_DEPTH_M만큼 묻힌다(흙막이 "본바닥에 // 기초" 원칙 + 실무 기초콘크리트 H=0.5). 바닥 꼭짓점 2개만 내리고 상단·이음선· // 관 위치는 그대로 — 관은 여전히 원지반 높이로 지난다. const embedBase = anchor.elevation - REVET_EMBED_DEPTH_M; const wall: WallLayout = { role: spec.role, form: spec.revet_form ?? null, lengthM: spec.revet_length_m ?? null, backOffset: anchor.offset, outerOffset: frontBase, base: anchor.elevation, height, outward, topBack, topJoint: { offset: topJoint, elevation: anchor.elevation + height }, points: [ { offset: anchor.offset, elevation: embedBase }, topBack, { offset: topFront, elevation: anchor.elevation + height }, { offset: frontBase, elevation: embedBase }, ], }; walls.push(wall); // 성토 사면선은 **이음선 상단점**에서 끊고, 끝단 표고도 그 점에 맞춘다(사용자 ②). if (outward > 0) { trimMax = topJoint; trimMaxElevation = anchor.elevation + height; } else { trimMin = topJoint; trimMinElevation = anchor.elevation + height; } // 관 끝단 마감면 = 기슭막이의 **계류측 변**(전면)을 그대로 복사(사용자 ③). endFaces[spec.role] = { base: { offset: frontBase, elevation: embedBase }, direction: { offset: topFront - frontBase, elevation: anchor.elevation + height - embedBase, }, }; return wall; }; buildWall(culvert.inlet, inlet, inletInfo.outward, basinReason); // 유출 벽 자리 — 유입과 같은 규칙(사면선이 벽 이음선 상단점을 지나는 자리, 물매는 // 1:1.2~2.0 범위에서 역산). 벽 밑은 관 축을 따라간 invert다 — 관이 벽을 관통하므로 // 벽 바닥은 관 자리를 따라야 한다. const axisRun0 = outletAnchor.offset - inlet.offset; const axisRise0 = outletAnchor.elevation - inlet.elevation; const invertAt = (offset: number): number => Math.abs(axisRun0) > 1e-9 ? inlet.elevation + axisRise0 * ((offset - inlet.offset) / axisRun0) : inlet.elevation; let outletWallAnchor = outletAnchor; let outletPlacement: FillWallPlacement | null = null; if (designAt) { outletPlacement = solveFillWallOffset( outletInfo.edge, outletInfo.outward, wallHeightFor(culvert.outlet), invertAt, outletAnchorOffset, ); if (outletPlacement) { const shifted = clampWallOffset( outletPlacement.offset, outletPlacement.offset + outletInfo.outward * (revetShift?.outlet ?? 0), wallHeightFor(culvert.outlet), outletInfo.edge, invertAt, outletInfo.outward, ); outletWallAnchor = { offset: shifted, elevation: invertAt(shifted) }; } } let outletWall = buildWall(culvert.outlet, outletWallAnchor, outletInfo.outward, null); // 유출 벽이 사면 끝보다 안쪽으로 당겨졌으면 **관 끝도 그 자리에 맞춘다** — 관만 사면 // 끝까지 남으면 벽과 떨어져 마감면 교차가 실패한다(2026-08-20 사용자 ② 원인). if (outletWall && Math.abs(outletWallAnchor.offset - outletAnchor.offset) > 0.05) { const runW = outletWallAnchor.offset - inlet.offset; const riseW = outletWallAnchor.elevation - inlet.elevation; const lenW = Math.hypot(runW, riseW); if (lenW > 0.5) { const scaleW = Math.ceil(lenW - 1e-6) / lenW; outlet.offset = inlet.offset + runW * scaleW; outlet.elevation = inlet.elevation + riseW * scaleW; lengthM = Math.ceil(lenW - 1e-6); } } // ── 관 단면 꼭짓점 — 끝단면을 구조물 계류측 변에 맞춰 자른다(2026-08-20 사용자 ③). // 보호공 시작점이 **관 하단 꼭짓점**을 그대로 써야 하므로 여기서 확정한다. const pipeStart = basinPipeEnd ?? inlet; const runP = outlet.offset - pipeStart.offset; const riseP = outlet.elevation - pipeStart.elevation; const axisLength = Math.hypot(runP, riseP) || 1; const axis: OffsetPoint = { offset: runP / axisLength, elevation: riseP / axisLength }; let normal: OffsetPoint = { offset: -axis.elevation, elevation: axis.offset }; if (normal.elevation < 0) normal = { offset: -normal.offset, elevation: -normal.elevation }; const topAnchor: OffsetPoint = { offset: pipeStart.offset + normal.offset * diameter, elevation: pipeStart.elevation + normal.elevation * diameter, }; const endCorners = (role: "inlet" | "outlet", endPoint: OffsetPoint): PipeEnd => { const fallback: PipeEnd = { bottom: endPoint, top: { offset: endPoint.offset + normal.offset * diameter, elevation: endPoint.elevation + normal.elevation * diameter, }, }; const face = endFaces[role]; if (!face) return fallback; const bottom = intersect(face.base, face.direction, pipeStart, axis); const top = intersect(face.base, face.direction, topAnchor, axis); if (!bottom || !top) return fallback; const strayed = Math.hypot(bottom.offset - endPoint.offset, bottom.elevation - endPoint.elevation) > STRAY_LIMIT_M || Math.hypot(top.offset - fallback.top.offset, top.elevation - fallback.top.elevation) > STRAY_LIMIT_M; return strayed ? fallback : { bottom, top }; }; let pipeCorners = { inlet: endCorners("inlet", pipeStart), outlet: endCorners("outlet", outlet), }; // ── 관 길이 m 단위 맞춤(2026-08-21 사용자 ①). 관 끝은 기운 벽 전면으로 잘려 **상단·하단 // 길이가 다르다** — 긴 변을 기준으로 올림해 정수 m로 잡고, 모자란 만큼 **유출 벽 자리** // 를 관 축 방향 바깥으로 민다(벽 두께는 0.45 고정 — 사용자 확정). 벽 상단이 사면선과 // 벌어지는 문제는 설계선 트림이 벽 상단을 따라오므로 생기지 않는다(사용자 ②). const outletSlopeAt = outletSlopeFactory(outletInfo, invertAt, wallHeightFor(culvert.outlet)); /** 유출 벽 두께 — 자리 이동만으로 관 길이를 정수로 못 맞출 때만 0.45에서 벗어난다. */ let outletThickness = REVET_THICKNESS_M; if (outletWall) { const target = Math.ceil(cutLength(pipeCorners) - 1e-6); const rebuild = (): void => { walls.pop(); outletWall = buildWall( culvert.outlet, outletWallAnchor, outletInfo.outward, null, outletThickness, ); outlet.offset = pipeStart.offset + axis.offset * target; outlet.elevation = pipeStart.elevation + axis.elevation * target; pipeCorners = { inlet: endCorners("inlet", pipeStart), outlet: endCorners("outlet", outlet), }; }; for (let pass = 0; pass < 8; pass += 1) { const gap = target - cutLength(pipeCorners); if (Math.abs(gap) < 0.005) break; // ① 벽 자리를 관 축 방향으로 gap 만큼 옮긴다(두께 불변 — 사용자 확정 우선순위). const moved = outletWallAnchor.offset + axis.offset * gap; const movedSlope = outletSlopeAt(moved, outletThickness); // 사면 역전·노견 안쪽으로는 옮기지 않는다 — 길이 맞춤보다 도면 성립이 먼저다. if ( (moved - outletInfo.edge.offset_m) * outletInfo.outward < 0 || outletInfo.edge.elevation_m - (invertAt(moved) + wallHeightFor(culvert.outlet)) < FILL_MIN_RISE_M ) { break; } // 조정창으로 옮긴 벽은 **폭을 건드리지 않는다** — 폭으로 흡수하면 미는 동안 벽 // 단면이 변한다(2026-08-21 사용자 지적: 유출 벽만 0.45→0.90). 조정이 관 길이 1m // 단위라 자리만 맞추면 정수가 성립한다. 물매·5m 한계는 자동 배치용 제약이라 // 사용자가 지정한 자리에는 걸지 않는다(경고로 알린다). if ( Math.abs(revetShift?.outlet ?? 0) > 1e-9 || (movedSlope.ratio >= FILL_SLOPE_RATIO_MIN && movedSlope.ratio <= FILL_SLOPE_RATIO_MAX && movedSlope.lengthM <= FILL_SLOPE_MAX_LENGTH_M + 1e-6) ) { outletWallAnchor = { offset: moved, elevation: invertAt(moved) }; rebuild(); if (!outletWall) break; continue; } // ② 자리를 더 옮기면 성토 물매가 1:1.2~2.0을 벗어나거나 사면이 5m를 넘는다 — // 자리는 그대로 두고 **벽 폭**으로 나머지를 흡수한다(2026-08-21 사용자 폴백). // 전면(관을 자르는 변)은 두께를 t 늘리면 t의 1.5배만큼 바깥으로 간다 // (이음선 t/2 + 전면 t). 관 축에 투영한 몫만 길이에 반영된다. const grip = Math.max(Math.abs(axis.offset), 0.2) * 1.5; const wanted = outletThickness + gap / grip; // 폭은 0.45의 0.6~2배까지만 — 남는 몫은 관이 벽을 조금 벗어나는 것으로 둔다. let capped = Math.min(Math.max(wanted, REVET_THICKNESS_M * 0.6), REVET_THICKNESS_M * 2); // 폭을 넓히면 이음선도 바깥으로 밀려 사면이 길어진다 — **5m 한계가 우선**이라 // 넘는 몫은 포기한다(별표2). 그 경우 관은 표기 길이보다 짧게 벽을 조금 벗어난다 // (2026-08-20 확정 규칙 그대로). while ( capped > outletThickness && outletSlopeAt(outletWallAnchor.offset, capped).lengthM > FILL_SLOPE_MAX_LENGTH_M ) { capped -= 0.01; } if (capped <= outletThickness + 1e-4) break; outletThickness = capped; rebuild(); if (!outletWall) break; } lengthM = target; slopePct = Math.abs(outlet.offset - pipeStart.offset) > 1e-9 ? ((pipeStart.elevation - outlet.elevation) / Math.abs(outlet.offset - pipeStart.offset)) * 100 : 0; } // 보호공(돌붙임) — 유출측. 2026-08-20 사용자 ①·②: // ① 시작 변은 **기슭막이 전면과 같은 경사**(밑면을 벽 전면 방향으로 민다), // 윗면 시작점 = **관 하단 꼭짓점**(관 밑면과 한 점에서 만난다). // ② 성토사면 경사(1:n)로 원지반까지 내려간 뒤에도 **최소 보호공 길이** // (낙차고×2 — 백엔드 `apron_length_m`)를 채울 때까지 원지반을 따라 더 간다. // 끝단 윗점이 원지반 위에 놓인다. const pitchingTop: OffsetPoint[] = []; const pitchingBase: OffsetPoint[] = []; let pitchingLength = 0; const minPitchingM = culvert.outlet.apron_length_m ?? 0; if (outletWall) { const fillRatio = section.design?.fill_slope_ratio ?? 1.2; // 밑면 이동 방향 = 기슭막이 전면 변과 나란한 **아래 방향** 단위벡터 × 두께. let shift: OffsetPoint = { offset: 0, elevation: -PITCHING_THICKNESS_M }; const face = endFaces.outlet; if (face) { const faceLength = Math.hypot(face.direction.offset, face.direction.elevation); if (faceLength > 1e-9) { shift = { offset: (-face.direction.offset / faceLength) * PITCHING_THICKNESS_M, elevation: (-face.direction.elevation / faceLength) * PITCHING_THICKNESS_M, }; } } const start = pipeCorners.outlet.bottom; pitchingTop.push(start); let previous = start; const step = 0.25; for (let t = step; t <= 30 + 1e-9; t += step) { const offset = start.offset + outletWall.outward * t; const slopeElevation = start.elevation - t / fillRatio; const groundElevation = groundAt(offset); const point: OffsetPoint = { offset, elevation: Math.max(slopeElevation, groundElevation), }; pitchingLength += Math.hypot( point.offset - previous.offset, point.elevation - previous.elevation, ); pitchingTop.push(point); previous = point; // 지반에 닿았고 최소 길이도 채웠으면 마감 — 아직이면 지반을 따라 계속 간다. if (slopeElevation <= groundElevation && pitchingLength >= minPitchingM - 1e-6) break; } for (const point of pitchingTop) { pitchingBase.push({ offset: point.offset + shift.offset, elevation: point.elevation + shift.elevation, }); } } // ── 성토 사면 구간 확정. 벽 자리가 굳은 뒤에 물매를 역산해야 관 길이 맞춤(벽 이동)이 // 접점을 다시 깨뜨리지 않는다 — 종전 어긋남의 직접 원인이 이 순서였다. const slopeOf = (wall: WallLayout): FillSlopeSegment => fillSlopeOf(wall, wall.role === "inlet" ? inletInfo.edge : outletInfo.edge); for (const wall of walls) { const slope = slopeOf(wall); // 사면선은 노견부터 우리가 그린다 — 트림 경계를 노견까지 당겨 백엔드 설계선의 // 성토 구간을 통째로 덮는다(설계선은 1:1.2 고정이라 그대로 두면 다시 어긋난다). if (wall.outward > 0) { trimMax = slope.from.offset; trimMaxElevation = null; trimMaxSlope = { from: slope.from, to: slope.to }; } else { trimMin = slope.from.offset; trimMinElevation = null; trimMinSlope = { from: slope.from, to: slope.to }; } } const outletWallFinal = walls.find((wall) => wall.role === "outlet") ?? null; const outletSlope = outletWallFinal ? slopeOf(outletWallFinal) : null; return { culvert, pipe: { inlet: pipeStart, outlet, lengthM, slopePct }, pipeAxis: { axis, normal }, pipeCorners, walls, fillSlope: { lengthM: outletSlope ? outletSlope.lengthM : fillSlopeLength, withinLimit: outletSlope ? outletSlope.lengthM <= FILL_SLOPE_MAX_LENGTH_M + 1e-6 : fillSlopeLength <= FILL_SLOPE_MAX_LENGTH_M + 1e-6, ratio: outletSlope ? outletSlope.ratio : (section.design?.fill_slope_ratio ?? FILL_SLOPE_RATIO_MIN), ratioClamped: outletSlope ? outletSlope.clamped : false, // 벽 자리 풀이가 아예 실패했거나(성토고가 사면 5m 안에 안 들어옴), 성토고 3m 이상 // 인데 물매가 1:1.2보다 급하면 기슭막이로는 못 받는다 — 옹벽·석축 검토 대상. structureRequired: outletPlacement === null || (outletSlope != null && outletSlope.fillHeightM >= FILL_STRUCTURE_HEIGHT_M && outletSlope.ratio < FILL_SLOPE_RATIO_MIN), }, pitching: { top: pitchingTop, base: pitchingBase, lengthM: pitchingLength, minLengthM: minPitchingM, }, basin, designTrim: walls.length || basin ? { minOffset: trimMin, maxOffset: trimMax, ...(trimMinElevation != null ? { minElevation: trimMinElevation } : {}), ...(trimMaxElevation != null ? { maxElevation: trimMaxElevation } : {}), ...(trimMinSlope ? { minSlope: trimMinSlope } : {}), ...(trimMaxSlope ? { maxSlope: trimMaxSlope } : {}), } : null, }; }