/* ============================================================================= * B06_Section_UI_Cross_Culvert_Geom.ts * 배수관 세트(배관·기슭막이·성토부) **기하 계산** — 그리기(`_Cross_Culvert.ts`)와 분리. * 백엔드 `section.culvert` 제원을 좌표로 옮긴다(치수 결정 금지). 기슭막이는 전면 * 1:n 평행사변형(n = 품셈 표준경사 · `outlineLeanOf`), 관 끝단면은 구조물 변과 평행, 성토 경사선은 벽 접점에서 끊는다 * (designTrim). 유입 = 상단측. 집수정은 `_Basin.ts`, 성토부·다단은 `_Extra.ts`. * ========================================================================== */ import type { CrossSection, CulvertSideSpec, SectionSample } from "./B06_Section_Api_Fetch"; import { revetWallSpec, PIPE_WALL_DEFAULT_RUN_M, FILL_SLOPE_MAX_LENGTH_M, FILL_SLOPE_RATIO_MAX, FILL_SLOPE_RATIO_MIN, MIN_PIPE_COVER_M, REVET_EMBED_DEPTH_M, REVET_LEAN_RATIO, REVET_THICKNESS_M, revetTargetHeight, } from "./B06_Section_UI_Cross_Culvert_Const"; import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const"; import { outlineLeanOf } from "./B06_Section_UI_Cross_Lean"; import { buildRevetWallGeometry } from "./B06_Section_UI_Cross_Wall"; import { ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types"; import type { BasinLayout, BasinAdjust, CulvertLayout, EndFace, InletStructureChoice, OffsetPoint, WallAdjust, WallLayout, } from "./B06_Section_UI_Cross_Culvert_Types"; import { DEFAULT_BASIN_ADJUST } 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 { applyBasinPipeFill, attachBasin, clampBasinMove, decideInletBasin, BASIN_INNER_WIDTH_M, inletChoiceAvailability, } from "./B06_Section_UI_Cross_Culvert_Basin"; import { inletGroundConnector } from "./B06_Section_UI_Cross_Culvert_Extra"; import { buildCulvertTiers, drawnFillPoints, outletPipeEnd, slopeZeroAnchor, } from "./B06_Section_UI_Cross_Culvert_Tiers"; import type { FillSlopeSegment, WallVertical } from "./B06_Section_UI_Cross_Culvert_Solve"; import { cutLength, outletSlopeFactory, fillSlopeOf, groundInterpolator, designInterpolator, slopeToeOffset, pipeAxisSolver, placePipeWall, slopeLengthAlong, } from "./B06_Section_UI_Cross_Culvert_Solve"; /** 배수관 세트 기하 계산. 부족한 입력이면 null — 그리기와 분리(설계선 트림이 먼저 쓴다). */ export function computeCulvertLayout( section: CrossSection, groundSamples: SectionSample[], /** 사용자 조작값(좌우 x·상하 d·높이 h·재질 m — 2026-08-22 4축). 없으면 자동. */ revetShift?: { inlet?: WallAdjust; outlet?: WallAdjust; extras?: WallAdjust[]; basinExtras?: WallAdjust[]; }, inletStructure?: InletStructureChoice, basinAdjustment?: BasinAdjust, equalizeExtras?: boolean, /** 내부용 — 집수정 이동 한계 탐색 재귀에서 다시 클램프하지 않게 막는다. */ skipBasinClamp?: boolean, ): 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; // I형 집수정 이동 한계 — 관 길이가 바뀌는 지점 직전까지(판정은 Basin이 맡는다). const probeBasin = (candidate: BasinAdjust) => { const p = computeCulvertLayout( section, groundSamples, revetShift, inletStructure, candidate, false, true, ); return p ? { shape: p.basin?.shape ?? null, lengthM: p.pipe.lengthM } : null; }; const requestedBasin = basinAdjustment ?? DEFAULT_BASIN_ADJUST; const basinAdjust = skipBasinClamp ? requestedBasin : clampBasinMove(requestedBasin, probeBasin); const basinHeightM = (culvert.inlet.basin_height_m ?? 0) > 0 ? culvert.inlet.basin_height_m : 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 }; // 벽 제원(4축): 형태별 높이 한계 없음(사용자 입력 그대로). 높이 기준 = **순수 높이** // (바닥~상단 — 2026-08-23 사용자). 하한·기본 = 관경 + 아래 0.5 + 위 0.5 — // 관이 벽 위로 삐져나오거나 바닥에 붙으면 안 된다. 기하는 종전대로 // invert~상단 계산용 높이(= 순수 − 0.5)로 돈다. const adjustOf = (value?: WallAdjust): WallAdjust => ({ ...ZERO_ADJUST, ...(value ?? {}) }); const adjInlet = adjustOf(revetShift?.inlet); const adjOutlet = adjustOf(revetShift?.outlet); // 전면 기울기 = 표준경사 표(단면유형 × 설치 측 × 높이) — 형태는 안 무늬만(외곽 같음). const pipeWallSpec = (spec: CulvertSideSpec, adjust: WallAdjust) => { const wall = revetWallSpec(spec, adjust, culvert.hidden_pipe === true, diameter); const lean = outlineLeanOf({ form: wall.form, height_m: wall.pureHeight, section_mode: section.design?.section_mode, side: culvert.hidden_pipe ? spec.face_side : null, face_role: spec.face_role, face_slope_ratio: spec.face_slope_ratio, }); return { ...wall, lean }; }; const inletWallSpec = pipeWallSpec(culvert.inlet, adjInlet); const outletWallSpec = pipeWallSpec(culvert.outlet, adjOutlet); // 조정창 선택지 가용성 — 판정은 Basin이 맡는다. const inletOptions = inletChoiceAvailability({ designAt, groundAt, edge: inletInfo.edge, outward: inletInfo.outward, limitOffset: inletInfo.limit, wallHeight: inletWallSpec.height, invertCapM: invertCap(inletInfo.edge), diameterM: diameter, cutSlopeRatio: section.design?.cut_slope_ratio ?? 1.0, }); // 집수정 판정 — 모양 · 까닭(null = 안 세움). 판정은 Basin이 맡는다. const { shape: basinShape, reason: basinReason } = decideInletBasin({ sectionMode: section.design?.section_mode, inletSide: uphill === "left" ? "left" : "right", inlet: culvert.inlet, hiddenPipe: culvert.hidden_pipe === true, designAt, groundAt, edgeOffset: inletInfo.edge.offset_m, limitOffset: inletInfo.limit, patchChoice: inletStructure, luAllowed: inletOptions.basinLUAllowed, }); // ㄴ·ㄷ형은 사이즈 유지한 채 통째로 노견 끝점 일치 — 바닥(=관 invert)이 따라 오른다. if (basinReason && basinShape !== "I") { inlet.elevation = inletInfo.edge.elevation_m - (basinHeightM ?? basinAdjust.innerHeightM); } else if (basinReason) { // I형 — 관 시작점(내공 1.0m 자리) invert = 그 x의 원지반(토피 상한 이내). const startOffset = inlet.offset + inletInfo.outward * (BASIN_INNER_WIDTH_M + basinAdjust.lateralM); inlet.elevation = Math.min(groundAt(startOffset), invertCap(inletInfo.edge)); } /** 한계에 잘린 뒤의 실제 조작값 — 조정창이 되받는다(높이는 순수 높이 기준). */ const appliedAdjust = { inlet: { ...adjInlet, h: adjInlet.h != null ? inletWallSpec.pureHeight : null }, outlet: { ...adjOutlet, h: adjOutlet.h != null ? outletWallSpec.pureHeight : null }, }; /** 손으로 민 벽의 수직 제원(높이 증가·바닥 띄움) — buildWall에 물려 준다. */ const wallVertical: { inlet: WallVertical | null; outlet: WallVertical | null } = { inlet: null, outlet: null, }; if (!basinReason && designAt) { const inletBaseAt = (offset: number): number => Math.min(groundAt(offset), invertCap(inletInfo.edge)); // 4축 배치는 공용 풀이(placePipeWall — Solve). 원지반은 자리를 막지 않는다(54-45). const placed = placePipeWall({ autoOffset: slopeZeroAnchor( inletInfo.edge, inletInfo.outward, inletWallSpec.height, inletWallSpec.lean, ), outward: inletInfo.outward, height: inletWallSpec.height, baseElevation0: inletInfo.edge.elevation_m - inletWallSpec.height, edgeElevation: inletInfo.edge.elevation_m, // 독립 기슭막이(관 숨김)는 관 토피·역경사 제약이 없다 — 유입도 노견까지 자유롭게 오르내린다. invertCap: culvert.hidden_pipe ? Number.POSITIVE_INFINITY : invertCap(inletInfo.edge), adjust: adjInlet, defaultD: PIPE_WALL_DEFAULT_RUN_M, }); appliedAdjust.inlet.x = placed.x; appliedAdjust.inlet.d = placed.d; wallVertical.inlet = { height: inletWallSpec.height, baseElevation: placed.invert, floatGapM: Math.max(0, placed.invert - inletBaseAt(placed.anchorOffset)), }; inlet.offset = placed.anchorOffset; inlet.elevation = placed.invert; } // 유출 목표점 = 유출측 성토사면이 지반과 만나는 사면 끝(경사길이 5m 한계 — 별표2). // 그 자리가 유출 기슭막이 자리이고, invert는 원지반(관 끝이 원지반 위 — 실무 도면). const outletAnchorOffset = designAt ? slopeToeOffset(designAt, groundAt, outletInfo.edge.offset_m, outletInfo.limit) : outletInfo.edge.offset_m; // 성토사면 경사길이 실측(노견 → 사면 끝) — 법정 5m 이내 충족 확인용(사용자 ①). const fillSlopeLength = designAt ? slopeLengthAlong(designAt, outletInfo.edge.offset_m, outletAnchorOffset) : 0; // 역경사는 수평으로 클램프(수평 가능 — 사용자 확정). 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; /** 집수정 계류측 성토부 시작점·바닥(ㄴ·ㄷ형이 원지반 위로 나올 때만). */ let basinFillStart: OffsetPoint | null = null; let basinFillBottom = 0; /** 관 끝단 마감면 — 구조물 계류측 변 복사(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: { points: OffsetPoint[] } | null = null; let trimMaxSlope: { points: OffsetPoint[] } | null = null; const buildWall = ( spec: CulvertSideSpec, anchor: OffsetPoint, outward: number, forceBasinReason: BasinLayout["reason"] | null, thickness: number = REVET_THICKNESS_M, vertical: WallVertical | null = null, material: RevetMaterial = "dry", formLabel: string | null = null, lean: number = REVET_LEAN_RATIO, ): WallLayout | null => { // spec의 "집수정"은 ruleReason에 이미 반영 — 여기서 되살리면 revet 선택이 깨진다. const reason: BasinLayout["reason"] | null = forceBasinReason; if (reason) { const built = attachBasin({ anchor, outward, shape: basinShape, reason, diameterM: diameter, edge: spec.role === "inlet" ? inletInfo.edge : outletInfo.edge, groundAt, cutSlopeRatio: section.design?.cut_slope_ratio ?? 1.0, adjust: basinAdjust, heightM: basinHeightM, }); basin = built.basin; basinPipeEnd = built.pipeEnd; basinFillStart = built.fillStart; basinFillBottom = built.fillBottomElevation; endFaces[spec.role] = built.endFace; // 사면선은 벽 상단 도로측 꼭짓점에서 끊고 끝단 표고도 거기 맞춘다. if (outward > 0) { trimMax = built.trimOffset; trimMaxElevation = built.trimElevation; if (built.approach) trimMaxSlope = built.approach; } else { trimMin = built.trimOffset; trimMinElevation = built.trimElevation; if (built.approach) trimMinSlope = built.approach; } return null; } // 구조 · 형태를 안 고른 배관 벽 = 그리지 않음(54-39 · 조정창 형태가 있으면 그 값) const pickedForm = (spec.role === "inlet" ? adjInlet : adjOutlet).m || spec.revet_form; if (!culvert.hidden_pipe && (spec.structure !== "기슭막이" || !pickedForm)) return null; // 형상 = 공용 `buildRevetWallGeometry`(추가·사면안정 흙막이·옹벽과 한 벌). 높이는 // vertical(계산용)이 들고 온다 — 형태별 높이 한계 없음, 윗면은 노면에서 멈춤. const height = vertical?.height ?? revetTargetHeight(diameter); // 근입 포함 순수 높이가 서면 그림 — 관경 · 근입보다 낮아도 그대로(54-51). if (!(height + REVET_EMBED_DEPTH_M > 0.05)) return null; const side = outward > 0 ? edges.left : edges.right; const wall = buildRevetWallGeometry({ anchor, outward, height, material, role: spec.role, form: formLabel, lengthM: spec.revet_length_m ?? null, thickness, floatGapM: vertical?.floatGapM ?? 0, lean, roadElevation: side.elevation_m, }); const topJoint = wall.topJoint.offset; const topElevation = wall.topJoint.elevation; const topFront = wall.points[2].offset; const bottomFront = wall.bottomFront; walls.push(wall); // 성토 사면선은 **이음선 상단점**에서 끊고, 끝단 표고도 그 점에 맞춘다(사용자 ②). if (outward > 0) { trimMax = topJoint; trimMaxElevation = topElevation; } else { trimMin = topJoint; trimMinElevation = topElevation; } // 관 끝단 마감면 = 기슭막이의 **계류측 변**(전면)을 그대로 복사(사용자 ③). endFaces[spec.role] = { base: bottomFront, direction: { offset: topFront - bottomFront.offset, elevation: topElevation - bottomFront.elevation, }, }; return wall; }; const inletWall = buildWall( culvert.inlet, inlet, inletInfo.outward, basinReason, REVET_THICKNESS_M, wallVertical.inlet, inletWallSpec.material, inletWallSpec.form, inletWallSpec.lean, ); // 유출 벽 밑 = 그 자리 원지반(역경사는 유입 invert로 클램프 — 2026-08-21). const invertAt = (offset: number): number => Math.min(groundAt(offset), inlet.elevation); let outletWallAnchor = outletAnchor; // 유출도 성토선 0점 기준(2026-08-23) — 기본 자리는 경사길이 4m 지점. 관 길이 // 정수 맞춤 루프가 벽을 옮길 때 invert가 같은 성토선을 타도록 함수로 둔다. const outletZeroAnchor = slopeZeroAnchor( outletInfo.edge, outletInfo.outward, outletWallSpec.height, outletWallSpec.lean, ); const outletZeroInvert = outletInfo.edge.elevation_m - outletWallSpec.height; const outletLineInvertAt = (offset: number): number => designAt ? outletZeroInvert - ((offset - outletZeroAnchor) * outletInfo.outward) / FILL_SLOPE_RATIO_MIN : invertAt(offset); if (designAt) { // 4축 배치(유입과 동일 풀이). const placed = placePipeWall({ autoOffset: outletZeroAnchor, outward: outletInfo.outward, height: outletWallSpec.height, baseElevation0: outletZeroInvert, edgeElevation: outletInfo.edge.elevation_m, // 독립 기슭막이는 양쪽 벽이 독립 — 유출을 유입 invert로 묶는 역경사 클램프를 뺀다. invertCap: culvert.hidden_pipe ? Number.POSITIVE_INFINITY : inlet.elevation, adjust: adjOutlet, defaultD: PIPE_WALL_DEFAULT_RUN_M, }); appliedAdjust.outlet.x = placed.x; appliedAdjust.outlet.d = placed.d; wallVertical.outlet = { height: outletWallSpec.height, baseElevation: placed.invert, floatGapM: Math.max(0, placed.invert - invertAt(placed.anchorOffset)), }; outletWallAnchor = { offset: placed.anchorOffset, elevation: placed.invert }; } let outletWall = buildWall( culvert.outlet, outletWallAnchor, outletInfo.outward, null, REVET_THICKNESS_M, wallVertical.outlet, outletWallSpec.material, outletWallSpec.form, outletWallSpec.lean, ); // ── 관 축 확정 — 관 하단선은 시작점과 유출 벽 전면 기준선 교차점을 잇는다. const pipeStart = basinPipeEnd ?? inlet; // 유출 벽이 사면 끝과 다른 자리면 관 끝도 그 자리 기준으로 맞춘다(올림 연장분은 // 유출 쪽 — 2026-08-20 확정). 관 하단선은 전면 기준선 교차점을 지나 정수 길이로. if (outletWall) { const face = outletPipeEnd(outletWall); const runW = face.offset - pipeStart.offset; const riseW = face.elevation - pipeStart.elevation; const lenW = Math.hypot(runW, riseW); if (lenW > 0.5) { const scaleW = Math.ceil(lenW - 1e-6) / lenW; outlet.offset = pipeStart.offset + runW * scaleW; outlet.elevation = pipeStart.elevation + riseW * scaleW; lengthM = Math.ceil(lenW - 1e-6); } } // ── 관 단면 꼭짓점 — 끝단면을 구조물 계류측 변으로 자른다. 축·법선은 벽이 옮겨질 // 때마다 다시 유도한다(2026-08-20 사용자 ③). const pipeAxis = pipeAxisSolver(pipeStart, outlet, diameter, endFaces); const deriveAxis = pipeAxis.derive; const endCorners = pipeAxis.corners; let pipeCorners = { inlet: endCorners("inlet", pipeStart), outlet: endCorners("outlet", outlet), }; // ── 관 길이 m 단위 맞춤(2026-08-21 사용자 ①) — 긴 변 기준 올림, 모자란 만큼 유출 // 벽 자리를 관 축 방향 바깥으로 민다(벽 두께 0.45 고정). // 정수 맞춤이 옮길 후보 자리도 같은 성토선을 탄다(2026-08-23 — 0점 기준 통일). const outletSlopeAt = outletSlopeFactory(outletInfo, outletLineInvertAt, outletWallSpec.height); /** 유출 벽 두께 — 항상 0.45 고정(2026-08-21 사용자 확정). 폭으로 길이를 맞추지 않는다. */ const outletThickness = REVET_THICKNESS_M; // 손으로 민 벽은 정수 맞춤이 **절대 옮기지 않는다**(2026-08-22) — 표기만 올림한다. // d = null(기본 자리)만 자동으로 본다(2026-08-23 — d는 0점 기준 절대값). const outletPinned = Math.abs(adjOutlet.x) > 1e-9 || adjOutlet.d != null; if (outletWall && !outletPinned) { const target = Math.ceil(cutLength(pipeCorners) - 1e-6); const rebuild = (): void => { walls.pop(); outletWall = buildWall( culvert.outlet, outletWallAnchor, outletInfo.outward, null, outletThickness, { height: outletWallSpec.height, baseElevation: outletWallAnchor.elevation, floatGapM: Math.max(0, outletWallAnchor.elevation - invertAt(outletWallAnchor.offset)), }, outletWallSpec.material, outletWallSpec.form, outletWallSpec.lean, ); // 관 하단선은 옮겨진 벽의 **전면 기준선 교차점**을 지나야 한다(사용자 ①). const face = outletWall ? outletPipeEnd(outletWall) : outletWallAnchor; const runW = face.offset - pipeStart.offset; const riseW = face.elevation - pipeStart.elevation; const lenW = Math.hypot(runW, riseW) || 1; outlet.offset = pipeStart.offset + (runW / lenW) * target; outlet.elevation = pipeStart.elevation + (riseW / lenW) * target; deriveAxis(); 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 + pipeAxis.axis.offset * gap; const movedSlope = outletSlopeAt(moved, outletThickness); // 사면 역전(벽 상단이 노견 위)·노견 안쪽으로는 옮기지 않는다 — 길이 맞춤보다 // 도면 성립이 먼저다. 성토고 0(성토선 0점)까지는 허용(2026-08-23 사용자). if ( (moved - outletInfo.edge.offset_m) * outletInfo.outward < 0 || outletInfo.edge.elevation_m - (outletLineInvertAt(moved) + outletWallSpec.height) < 0 ) { break; } // 자동 자리만 옮긴다(수동 벽은 이 루프에 아예 안 들어온다). 폭은 0.45 고정 — // 폭으로 흡수하면 미는 동안 벽 단면이 변한다(2026-08-21 사용자 지적). if ( movedSlope.ratio >= FILL_SLOPE_RATIO_MIN - 1e-6 && movedSlope.ratio <= FILL_SLOPE_RATIO_MAX && movedSlope.lengthM <= FILL_SLOPE_MAX_LENGTH_M + 1e-6 ) { outletWallAnchor = { offset: moved, elevation: outletLineInvertAt(moved) }; rebuild(); if (!outletWall) break; continue; } // ② 물매·사면 5m를 벗어나면 멈춘다. 폭으로 흡수하지 않는다(좌·우 단면이 달라진다). break; } } if (outletWall) { // 표기 길이 = **실제 그려진 관**의 올림값(목표를 적으면 도면보다 길게 적힌다). // 수동 벽으로 실길이가 정수가 아니어도 표기만 올림한다(2026-08-22 사용자 확정). lengthM = Math.max(1, Math.ceil(cutLength(pipeCorners) - 1e-6)); slopePct = Math.abs(outlet.offset - pipeStart.offset) > 1e-9 ? ((pipeStart.elevation - outlet.elevation) / Math.abs(outlet.offset - pipeStart.offset)) * 100 : 0; } // I형 집수정: 관 하단 꼭짓점이 지반 위에 뜨면 3도 되메움선(Basin 분리). if (basin) { applyBasinPipeFill(basin, pipeCorners.inlet.bottom, inletInfo.outward, groundAt); } // 유입 기슭막이의 관 시작 접속선 — 위 0도 성토선 / 아래 0도 1m 공간 확보에서 끝 // (2026-08-23 사용자 — 절토 경사 연장 삭제). const inletFill = !basin && !culvert.hidden_pipe ? inletGroundConnector( pipeCorners.inlet.bottom, inletInfo.outward, groundAt, section.design?.cut_slope_ratio ?? 1.0, inletInfo.limit, ) : null; // 다단 흙막이(유출측 · 집수정 계류측) — `_Culvert_Tiers` 가 푼다. const { extras, basinExtras, extrasForArea, basinExtrasForArea } = buildCulvertTiers({ section, outletStart: outletWall ? pipeCorners.outlet.bottom : null, outletBottom: outletWall?.bottomBack.elevation ?? 0, basinStart: culvert.hidden_pipe ? inletWall ? pipeCorners.inlet.bottom : null : basinFillStart, basinBottom: culvert.hidden_pipe ? (inletWall?.bottomBack.elevation ?? 0) : basinFillBottom, outletInfo, inletInfo, groundAt, extras: (revetShift?.extras ?? []).map(adjustOf), basinExtras: (revetShift?.basinExtras ?? []).map(adjustOf), outletForm: outletWallSpec.form, inletForm: inletWallSpec.form, equalize: equalizeExtras === true, }); // ── 성토 사면 구간 확정. 벽 자리가 굳은 뒤에 물매를 역산해야 관 길이 맞춤(벽 이동)이 // 접점을 다시 깨뜨리지 않는다 — 종전 어긋남의 직접 원인이 이 순서였다. 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.points[0].offset; trimMaxElevation = null; trimMaxSlope = { points: slope.points }; } else { trimMin = slope.points[0].offset; trimMinElevation = null; trimMinSlope = { points: slope.points }; } } // 다단 기슭막이의 성토부선까지 트림에 넣는다 — 넣지 않으면 단을 올려도 폐회로가 그대로라 // **물량이 안 바뀐다**(2026-09-07 사용자 확정). 기준벽 사면은 노견~벽 상단까지고, 그 // 바깥은 지금까지 원지반으로 봐서 면적이 0이었다. 화면이 그리는 선(`outletFill.segments`)과 // 같은 선을 면적도 보게 맞춘다 — 그리지 않는 `cut` 갈래(벽이 원지반에 묻힌 자리)는 뺀다. // ↓ 되돌릴 자리(2026-09-07) — 「단이 2개 이상일 때만 반영」으로 좁히려면 아래 두 줄의 // `drawnFillPoints(extras)` 를 `extras.walls.length ? drawnFillPoints(extras) : []` 로 // 바꾸면 된다(basinExtras 도 같은 꼴). 다만 그러면 화면이 그리는 성토부선과 면적이 // 다시 어긋난다 — 사용자 판단 대기 중인 항목임(PLAN 3-5 ⓑ). const extendTrimSlope = (points: OffsetPoint[], outward: number): void => { if (!points.length) return; if (outward > 0) { trimMaxSlope = { points: [...(trimMaxSlope?.points ?? []), ...points] }; } else { trimMinSlope = { points: [...(trimMinSlope?.points ?? []), ...points] }; } }; extendTrimSlope(drawnFillPoints(extrasForArea), outletInfo.outward); extendTrimSlope(drawnFillPoints(basinExtrasForArea), inletInfo.outward); const outletWallFinal = walls.find((wall) => wall.role === "outlet") ?? null; const outletSlope = outletWallFinal ? slopeOf(outletWallFinal) : null; return { inletOptions, culvert, pipe: { inlet: pipeStart, outlet, lengthM, slopePct }, pipeAxis: { axis: pipeAxis.axis, normal: pipeAxis.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, roadWideningM: outletSlope ? outletSlope.wideningM : 0, // 사면길이 5m 이상 = **기슭막이(구조물) 의무 구간**(성토_비탈면.md §2 — // 2026-08-21 사용자 정정: 5m는 자리 한계가 아니라 의무 발생 기준이다). structureRequired: outletSlope != null && outletSlope.lengthM >= FILL_SLOPE_MAX_LENGTH_M, }, outletFill: { segments: extras.segments, addable: extras.addable }, basinFill: { segments: basinExtras.segments, addable: basinExtras.addable }, basinExtras: basinExtras.walls, basinAdjust, inletFill, extraWalls: extras.walls, basin, revetShift: { inlet: appliedAdjust.inlet, outlet: appliedAdjust.outlet, extras: extras.appliedAdjusts, basinExtras: basinExtras.appliedAdjusts, }, designTrim: walls.length || basin ? { minOffset: trimMin, maxOffset: trimMax, ...(trimMinElevation != null ? { minElevation: trimMinElevation } : {}), ...(trimMaxElevation != null ? { maxElevation: trimMaxElevation } : {}), ...(trimMinSlope ? { minSlope: trimMinSlope } : {}), ...(trimMaxSlope ? { maxSlope: trimMaxSlope } : {}), } : null, }; }