/* ============================================================================= * B06_Section_UI_Cross_Culvert_Basin.ts * 유입 **집수정** 단면 구성 — 기하 본체(`_Cross_Culvert_Geom.ts`)에서 분리(700줄 제한). * * 집수정은 기슭막이와 달리 **물을 모으려고 만든 구조물**이다(2026-08-22 사용자 확정): * · I형(공유벽뿐): 배관은 원지반 자리 유지. 기본 높이(관경+여유)로 노견 끝 접점이 * 안 되면 벽을 노견 표고까지 키운다. * · ㄴ(L)형(= I형 + 바닥판, 기본값): 내부 높이 1.2m(임시값) 등 사이즈를 유지한 채 * 통째로 노견 끝점에 일치한다 — 바닥(=관 유입 invert)이 따라 올라 관 물매가 바뀐다. * · ㄷ(U)형: ㄴ형 + 반대측(도로측) 막음벽(I형 수직반전). * 부재(벽·바닥판) 두께 0.2m(2026-08-22 사용자 지정). 벽 자리 = 상단 도로측 꼭짓점이 * **노견 끝 지점**에 닿는 자리(우측 집수정이면 상단 좌측점). * ========================================================================== */ import { PIPE_CONNECT_GRADE, BASIN_INNER_HEIGHT_M, BASIN_MEMBER_THICKNESS_M, REVET_FREEBOARD_M, REVET_LEAN_RATIO, } from "./B06_Section_UI_Cross_Culvert_Const"; import { minShoulderWallOffset, slopeToeOffset } from "./B06_Section_UI_Cross_Culvert_Solve"; import type { BasinLayout, BasinAdjust, BasinShape, EndFace, OffsetPoint, InletStructureChoice, } from "./B06_Section_UI_Cross_Culvert_Types"; export function resolveBasinChoice( choice: InletStructureChoice, ruleReason: BasinLayout["reason"] | null, ): { reason: BasinLayout["reason"] | null; shape: BasinShape } { const selected = choice === "I" || choice === "L" || choice === "U"; return { reason: choice === "revet" ? null : selected ? (ruleReason ?? "manual") : ruleReason, // 자동(기본) 집수정은 **I형**(2026-08-23 사용자) — 종전 L형 기본은 ㄴ·ㄷ형이 // 선택지에서 숨는 지형(basinLUAllowed=false)에서 선택지 밖 구조물이 그려졌다. shape: selected ? choice : "I", }; } export function basinApproachPoints( edge: { offset_m: number; elevation_m: number }, outward: number, adjust: BasinAdjust, trim: OffsetPoint, ): OffsetPoint[] | null { // 접근선은 **설계선이 벽 상단까지 따라올 경로**다. 이 선을 주지 않으면 설계선이 // 자기 성토 물매(1:1.2)로 내려갔다가 트림 표고(벽 상단)로 되튀어 올라 급경사 // 선이 남는다(2026-08-22 사용자 지적). 좌우만 옮겨도 반드시 준다 — 집수정이 // 나간 만큼 노견이 수평으로 연장되는 것이 맞는 표현이다. if (adjust.lateralM <= 1e-6 && adjust.slopeM <= 1e-6) return null; const points: OffsetPoint[] = [{ offset: edge.offset_m, elevation: edge.elevation_m }]; if (adjust.lateralM > 1e-6) { points.push({ offset: edge.offset_m + outward * adjust.lateralM, elevation: edge.elevation_m, }); } // 중복 꼭짓점 제거 — 수평 선반 끝과 벽 상단이 겹치면 길이 0 구간이 남는다. const last = points[points.length - 1]; if (Math.hypot(trim.offset - last.offset, trim.elevation - last.elevation) > 1e-6) { points.push(trim); } return points.length >= 2 ? points : null; } /** 집수정 구성 입력 — 기하 본체가 자기 상태를 명시적으로 물려 준다. */ export interface BasinBuildInput { /** 관 유입 기준점(노견 끝 offset, invert 표고 — ㄴ·ㄷ형은 상승분 반영 후). */ anchor: OffsetPoint; /** 계류측 방향 부호(+ = 좌). */ outward: number; shape: BasinShape; reason: BasinLayout["reason"]; /** 관 외경 지름(m). */ diameterM: number; /** 이 측의 노견 끝 점 — 벽 상단 접점·I형 높이 성장 기준. */ edge: { offset_m: number; elevation_m: number }; groundAt: (offset: number) => number; /** 표준단면 절토 경사(1:n) — 절토 계획선용. */ cutSlopeRatio: number; adjust: BasinAdjust; /** 벽 두께(m). 기본 = 집수정 부재 두께. 세월교 측벽이 다른 두께를 쓸 때 넘긴다. */ memberThicknessM?: number; /** 바닥판 두께(m). 기본 = 벽 두께. 세월교는 교본 물받이 최소 0.3m를 쓴다. */ floorThicknessM?: number; /** * 벽 기움(1:n의 n). 기본 = 집수정 규칙 0.3. **세월교는 0**(수직) — 기움은 집수정 * 부재를 재사용하며 딸려 온 값이고, 교본 그림 3-18 세월교 단면은 수직 벽이다 * (2026-08-30 사용자 확정: 집수정은 그대로 두고 세월교만 세운다). */ leanRatio?: number; } /** 집수정 구성 결과 — 본체가 트림·관 끝단면·관 시작점에 나눠 꽂는다. */ export interface BasinBuildResult { basin: BasinLayout; /** 관 끝단 마감면 = I형 벽의 안쪽(물받는 쪽) 변 복사(2026-08-22 사용자 확정). */ endFace: EndFace; /** 관 시작점(하단 기준) — ㄴ·ㄷ형은 측벽 안쪽 변×바닥 윗면 교차점, I형은 내공 1.0m. */ pipeEnd: OffsetPoint; /** 성토 사면선 트림 경계(벽 상단 도로측 꼭짓점)와 그 표고. */ trimOffset: number; trimElevation: number; /** * 계류측 성토부선 시작점(2026-08-22 사용자 확정 — 유출측과 같은 체계). * ㄴ·ㄷ형에서 구조물 계류측 끝이 **원지반 위**로 나올 때만 준다. 그 자리에서 * 1:1.2 성토부선을 긋고 5m를 넘으면 다단 기슭막이를 둘 수 있다. 원지반 안으로 * 박히면 종전대로 절토선(cutLine)이고, I형은 관 하단 되메움선이 맡는다. */ fillStart: OffsetPoint | null; /** 구조물 바닥 표고 — 다단 1단이 이 아래를 뚫지 못하게 하는 기준. */ fillBottomElevation: number; } /** 실무 내공 폭(m) — 울진 돌집수정. I형 관 시작점(노견+1.0m) 계산에도 쓴다. */ export const BASIN_INNER_WIDTH_M = 1.0; /** 집수정 단면을 만든다. 형태별 규칙은 파일 머리말 참조. */ export function buildBasin(input: BasinBuildInput): BasinBuildResult { const { anchor: baseAnchor, outward, shape, reason, diameterM, edge, groundAt, cutSlopeRatio, adjust, } = input; /** 이 부재의 기움 — 집수정 0.3, 세월교 0(수직). */ const lean = input.leanRatio ?? REVET_LEAN_RATIO; const movedAnchor = { offset: baseAnchor.offset + outward * (adjust.lateralM + adjust.slopeM), elevation: baseAnchor.elevation - adjust.slopeM / 1.2, }; const anchor = movedAnchor; const memberT = input.memberThicknessM ?? BASIN_MEMBER_THICKNESS_M; const floorThickness = input.floorThicknessM ?? memberT; // 벽 높이: I형은 노견 접점까지 성장, ㄴ·ㄷ형은 내부 높이 1.2m 고정(임시값). const wallHeight = shape === "I" ? Math.max(diameterM + REVET_FREEBOARD_M, edge.elevation_m - anchor.elevation) : adjust.innerHeightM; // 기움은 **수직 기준 반전** — 상단이 도로측(내공 쪽)으로 1:0.3 물러난다(2026-08-20). // bottomElevation을 내리면 면 기울기를 그대로 연장한다(I형 근입용). const defaultBottom = anchor.elevation - floorThickness; const wallOf = ( baseOffset: number, dir: number, bottomElevation = defaultBottom, ): OffsetPoint[] => { const topShift = -dir * lean * wallHeight; const spanZ = wallHeight + floorThickness; const xAt = (elevation: number): number => baseOffset + topShift * ((elevation - defaultBottom) / spanZ); return [ { offset: xAt(bottomElevation), elevation: bottomElevation }, { offset: baseOffset + topShift, elevation: anchor.elevation + wallHeight }, { offset: baseOffset + topShift + dir * memberT, elevation: anchor.elevation + wallHeight, }, { offset: xAt(bottomElevation) + dir * memberT, elevation: bottomElevation, }, ]; }; // I형 벽 자리 = **상단 도로측 꼭짓점이 노견 끝 지점에 닿는다**(2026-08-22 사용자 // 확정). 벽 상단이 도로측으로 1:0.3 물러나므로 하단(base)은 그만큼 계류측 바깥. const wallBaseAt = (height: number): number => anchor.offset + outward * (lean * height); // I형 벽 하단은 **원지반까지 묻는다**(2026-08-22 사용자 — 지반 아래 부재두께만큼 // 근입 후 배관을 둔다). ㄴ·ㄷ형은 바닥판이 원지반을 대체하므로 현행 유지. const iBottomAt = (base: number): number => Math.min(anchor.elevation, groundAt(base), groundAt(base + outward * memberT)) - floorThickness; /** 벽 계류측 변(하단-전면 → 상단-전면)과 원지반의 교점 — I형 관 하단 자리. */ const groundOnFace = (points: OffsetPoint[]): OffsetPoint => { const low = points[3]; const high = points[2]; let lo = 0; let hi = 1; for (let pass = 0; pass < 32; pass += 1) { const mid = (lo + hi) / 2; const point = { offset: low.offset + (high.offset - low.offset) * mid, elevation: low.elevation + (high.elevation - low.elevation) * mid, }; if (point.elevation < groundAt(point.offset)) lo = mid; else hi = mid; } const t = (lo + hi) / 2; return { offset: low.offset + (high.offset - low.offset) * t, elevation: low.elevation + (high.elevation - low.elevation) * t, }; }; const wallBase = wallBaseAt(wallHeight); const iWallBottom = shape === "I" ? iBottomAt(wallBase) : defaultBottom; const parts: BasinLayout["parts"] = [ { kind: "wall", points: wallOf(wallBase, outward, iWallBottom) }, ]; const iWallPoints = parts[0].points; /** 벽 계류측 변 위에서 그 표고에 해당하는 점 — 관 하단을 벽면을 따라 내릴 때 쓴다. */ const pointOnFaceAt = (elevation: number): OffsetPoint => { const low = iWallPoints[3]; const high = iWallPoints[2]; const span = high.elevation - low.elevation; const t = span > 1e-9 ? Math.min(Math.max((elevation - low.elevation) / span, 0), 1) : 0; return { offset: low.offset + (high.offset - low.offset) * t, elevation: low.elevation + span * t, }; }; /** * I형 관 하단 자리 = 벽 계류측 변과 원지반의 교점. 다만 **관이 벽을 관통해야** 하므로 * (ㄴ·ㄷ형은 바닥판 위에서 이미 물린다) 벽 상단에서 관경+여유만큼 아래로 제한한다. * 원지반이 벽 상단보다 높은 자리에서 제한이 없으면 관이 벽 위로 떠 벽과 만나지 않았다 * (2026-09-03 사용자 지적: 「측벽과 배관의 형상이 교차하고 있어야 함」). */ const iGroundIntersection = (): OffsetPoint => { const hit = groundOnFace(iWallPoints); const invertCeiling = anchor.elevation + wallHeight - (diameterM + REVET_FREEBOARD_M); return hit.elevation <= invertCeiling ? hit : pointOnFaceAt(invertCeiling); }; // ㄴ형 = I형 + 바닥판. 바닥은 벽 바깥(계류측) 변에 맞대고 그 너머로 뻗는다 // (2026-08-20 확정). 벽이 기울어 있어 안쪽 변도 벽 바깥면 선을 그대로 따른다. const wallOuterAt = (elevation: number): number => { const bottom = anchor.elevation - floorThickness; const t = (elevation - bottom) / (wallHeight + floorThickness); return wallBase + outward * memberT - outward * lean * wallHeight * t; }; const floorTopInner = wallOuterAt(anchor.elevation); const floorBottomInner = wallOuterAt(anchor.elevation - floorThickness); const floorOuter = floorTopInner + outward * (adjust.innerWidthM + memberT); const floorTaper = lean * floorThickness; if (shape !== "I") { 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 }, ], }); } // ㄷ형 = ㄴ형 + 막음벽 — I형 벽을 **바닥부 중심 기준 수직 반전**한 자리·형상 // (2026-08-22 사용자 ① — 종전에는 노견 쪽에 세워 방향이 틀렸다). const floorCenter = (floorTopInner + floorOuter) / 2; const mirroredWallBase = 2 * floorCenter - wallBase; if (shape === "U") { parts.push({ kind: "wall", points: wallOf(mirroredWallBase, -outward) }); } // 구조물 계류측 끝(ㄴ형 = 바닥 바깥 끝 상단, ㄷ형 = 막음벽 계류측 상단 꼭짓점)과 // 원지반의 관계(2026-08-22 사용자 ①): // · 원지반 안쪽에 박히면 → 표준단면 절토경사(1:n)로 **절토선** // · 원지반 밖으로 나오면 → **성토(되메움)선** — 1:1.2로 지반까지. let basinCut: BasinLayout["cutLine"] = null; let fillStart: OffsetPoint | null = null; if (shape !== "I") { const refTop = shape === "U" ? { offset: mirroredWallBase + outward * lean * wallHeight, elevation: anchor.elevation + wallHeight, } : { offset: floorOuter, elevation: anchor.elevation }; const groundHere = groundAt(refTop.offset); if (groundHere > refTop.elevation + 0.05) { for (let h = 0.05; h <= 20; h += 0.05) { const probe = refTop.offset + outward * cutSlopeRatio * h; if (groundAt(probe) <= refTop.elevation + h) { basinCut = { from: refTop, to: { offset: probe, elevation: refTop.elevation + h } }; break; } } } else if (groundHere < refTop.elevation - 0.05) { // 원지반 밖으로 나왔다 — 성토부선은 유출측과 같은 체계(1:1.2 + 5m 다단)로 // 본체가 그린다(2026-08-22 사용자 확정). 여기서는 시작점만 넘긴다. fillStart = refTop; } } return { basin: { shape, parts, // 라벨 자리 = I형 벽 하단(2026-08-20 사용자) — 그리기 쪽에서 아래로 내려 붙인다. label: { offset: wallBase + outward * (memberT / 2), elevation: anchor.elevation - floorThickness, }, reason, cutLine: basinCut, // I형 관 하단 되메움선만 여기 담긴다(applyBasinPipeFill). ㄴ·ㄷ형 성토는 // 성토부선 체계로 옮겼다. fillLine: null, }, // 관 끝선 = I형 벽의 안쪽(물받는 쪽 = 내공측) 변 복사(2026-08-22 사용자 확정). // 벽 폴리곤의 하단-전면(3)~상단-전면(2) 꼭짓점을 그대로 쓴다 — I형 근입 연장도 반영. endFace: { base: iWallPoints[3], direction: { offset: iWallPoints[2].offset - iWallPoints[3].offset, elevation: iWallPoints[2].elevation - iWallPoints[3].elevation, }, }, pipeEnd: shape === "I" ? iGroundIntersection() : { offset: floorTopInner, elevation: anchor.elevation }, fillStart, fillBottomElevation: anchor.elevation - floorThickness, trimOffset: wallBase - outward * lean * wallHeight, trimElevation: anchor.elevation + wallHeight, }; } /** * 조정창 드롭다운 선택지 가용성(2026-08-22 사용자 — 상황에 안 맞는 선택지 숨김): * · 기슭막이+배관: 자동 자리의 원지반이 토피 상한(노면−관경−토피)보다 높으면 배관이 * 원지반에 묻힐 수밖에 없다 → 숨김. * · 집수정 ㄴ·ㄷ형: ㄴ형으로 가정해 세워 봤을 때 구조물이 원지반으로 밀고 들어가지 * 않으면(절토선이 안 생기면) → 숨김. */ export function inletChoiceAvailability(input: { designAt: ((offset: number) => number) | null; groundAt: (offset: number) => number; edge: { offset_m: number; elevation_m: number }; outward: number; limitOffset: number; wallHeight: number; invertCapM: number; diameterM: number; cutSlopeRatio: number; }): { revetAllowed: boolean; basinLUAllowed: boolean; revetAutoOffset: number } { const { designAt, groundAt, edge, outward, limitOffset, wallHeight, invertCapM } = input; let revetAllowed = true; // 기슭막이 자동 자리(노견 최소 지점 ?? 사면 끝) — 본체가 벽 배치에도 재사용한다. let revetAutoOffset = edge.offset_m; if (designAt) { const baseAt = (offset: number): number => Math.min(groundAt(offset), invertCapM); const toe = slopeToeOffset(designAt, groundAt, edge.offset_m, limitOffset); revetAutoOffset = minShoulderWallOffset(edge, outward, wallHeight, baseAt, toe, limitOffset) ?? toe; revetAllowed = groundAt(revetAutoOffset) <= invertCapM + 0.02; } const probe = buildBasin({ anchor: { offset: edge.offset_m, elevation: edge.elevation_m - BASIN_INNER_HEIGHT_M }, outward, shape: "L", reason: "short", diameterM: input.diameterM, edge, groundAt, cutSlopeRatio: input.cutSlopeRatio, adjust: { innerWidthM: BASIN_INNER_WIDTH_M, innerHeightM: BASIN_INNER_HEIGHT_M, lateralM: 0, slopeM: 0, }, }); return { revetAllowed, basinLUAllowed: probe.basin.cutLine != null, revetAutoOffset }; } /** * I형 집수정의 **관 하단 수평 되메움(성토)선**(2026-08-22 사용자). 관 유입 하단 * 꼭짓점이 원지반 위에 뜨면 그 표고 그대로 계류측으로 나가 원지반과 만나는 점까지 * 잇고, 그 아래를 성토로 본다. 지반 교점 절단 방식은 보고 후 철회. */ export function applyBasinPipeFill( basin: BasinLayout, corner: OffsetPoint, outward: number, groundAt: (offset: number) => number, ): void { // 되메움선 물매 3도(2026-08-22 사용자) — 유입측이라 계류 쪽이 높다(물이 관으로). if (basin.shape !== "I" || corner.elevation <= groundAt(corner.offset) + 0.02) return; let previousGap = corner.elevation - groundAt(corner.offset); // 선 − 지반 for (let run = 0.05; run <= 30; run += 0.05) { const probe = corner.offset + outward * run; const elevation = corner.elevation + PIPE_CONNECT_GRADE * run; const gap = elevation - groundAt(probe); if (gap <= 0) { // 끝점은 보간 교차점 — 격자점이면 지반을 미세하게 벗어난다(2026-08-22 사용자). const fraction = previousGap > 1e-12 ? previousGap / (previousGap - gap) : 1; const runCross = run - 0.05 * (1 - fraction); basin.fillLine = { from: corner, to: { offset: corner.offset + outward * runCross, elevation: corner.elevation + PIPE_CONNECT_GRADE * runCross, }, }; return; } previousGap = gap; } } /** 집수정 조립 결과 — 본체가 트림·접근선·관 접속에 그대로 꽂아 쓴다. */ export interface BasinAttachResult extends BasinBuildResult { /** 성토 사면선 폴리라인(노견 → 수평 연장 → 벽 상단). 없으면 트림만. */ approach: { points: OffsetPoint[] } | null; } /** buildBasin + 접근선까지 한 번에 — 본체(Geom) 700줄 제한용 묶음. */ export function attachBasin(input: BasinBuildInput): BasinAttachResult { const built = buildBasin(input); const points = basinApproachPoints(input.edge, input.outward, input.adjust, { offset: built.trimOffset, elevation: built.trimElevation, }); return { ...built, approach: points ? { points } : null }; } /** * I형 집수정 **이동 한계**(2026-08-22 사용자 확정) — I형은 관을 감싸는 구조라 옮기면 * 관 시작점이 따라가 **관 길이(m 규격)가 바뀐다**. 길이가 바뀌는 지점 직전이 최대다. * 요청값에서 0.1씩(상하 먼저, 다음 좌우) 되돌려 기준 길이와 같아지는 자리를 찾는다. * `probe`는 같은 단면을 다시 계산하는 함수(본체가 재귀로 물려 준다). */ export function clampBasinMove( adjust: BasinAdjust, probe: (candidate: BasinAdjust) => { shape: BasinShape | null; lengthM: number } | null, ): BasinAdjust { if (adjust.lateralM <= 1e-9 && adjust.slopeM <= 1e-9) return adjust; const base = probe({ ...adjust, lateralM: 0, slopeM: 0 }); if (base?.shape !== "I") return adjust; const round1 = (value: number): number => Math.round(value * 10) / 10; let lateralM = adjust.lateralM; let slopeM = adjust.slopeM; for (let pass = 0; pass < 400 && (lateralM > 1e-9 || slopeM > 1e-9); pass += 1) { if (probe({ ...adjust, lateralM, slopeM })?.lengthM === base.lengthM) break; if (slopeM > 1e-9) slopeM = Math.max(0, round1(slopeM - 0.1)); else lateralM = Math.max(0, round1(lateralM - 0.1)); } return { ...adjust, lateralM, slopeM }; }