/* ============================================================================= * B06_Section_UI_Cross_Ford_Geom.ts * 세월교 구체 **기하 계산** — 그리기(`_Cross_Ford.ts`)와 분리(700줄 제한). * 백엔드 `section.ford` 제원을 좌표로 옮긴다(치수 결정 금지). * * 구성(2026-08-25 사용자 확정): * · 양측 **측벽** = ㄴ형 집수정 부재를 재사용한다(`buildBasin`) — 좌우 이동·계류측 * 성토부선이 집수정과 같은 규칙으로 따라온다. 단 **벽은 수직**이다 * (`leanRatio: 0` — 2026-08-30 사용자 확정, 교본 그림 3-18). 집수정의 1:0.3 * 기움은 세월교 근거가 아니라 부재를 빌려 오며 딸려 온 값이었다. * · **바닥판** = 두 측벽 사이를 잇는 판 + 각 측 바깥으로 **날개벽 투영**(길이×cos각) * 만큼 뻗은 판. 집수정 내공 폭 자리에 그 연장량을 넣어 같은 기하로 만든다. * · **관**은 바닥판 상면(측벽 바깥면)에 안히고, 관 위 ~ 노면 아래는 **성토(노체)** 다. * · **수평**(139-3 · 박스 「구체 수평」 준용) — 바닥판 표고 하나 = 낮은 쪽 노견 노면 − * (관경 + 토피) − 내림(양쪽 한 값 · 아래로만) · 측벽마다 좌우만(도로 쪽 금지) · * 측벽 높이 = 그 쪽 노견 노면 − 바닥판(결과). * · 구체는 월류 폭만큼 도로 방향으로 이어져 기준 측점 전후로 절반씩 걸친다 * (붙는 측점 판정은 백엔드 `attach_culvert_sets`). * 성토측이라도 기슭막이 세트(다단·재질)와는 연계하지 않는다. * ========================================================================== */ import type { CrossSection, FordSet, SectionSample } from "./B06_Section_Api_Fetch"; import { GUARD_CUT_BERM_M, pipeWallThicknessM } from "./B06_Section_UI_Cross_Culvert_Const"; import type { BasinLayout, CulvertDesignTrim, OffsetPoint, OutletFillSegment, } from "./B06_Section_UI_Cross_Culvert_Types"; import { basinApproachPoints, buildBasin } from "./B06_Section_UI_Cross_Culvert_Basin"; import { bermCut, buildExtrasAt } from "./B06_Section_UI_Cross_Culvert_Extra"; import { designInterpolator, groundInterpolator } from "./B06_Section_UI_Cross_Culvert_Solve"; import { structureCutSpec } from "./B06_Section_UI_Cross_Structure_Cut"; /** 측벽 한 매의 조작값 — 좌우만(139-3). `heightM` · `slopeM` 은 옛 저장(측벽마다 높이 · 1:1.2 * 대각) 읽기 전용 — `dropM` 이 없을 때 바닥판 표고로 옮김(`fordDropOf`). */ export interface FordWallAdjust { /** 옛 벽 높이(m) — 새 값은 결과라 늘 null. */ heightM: number | null; /** 좌우(m, + = 노견 바깥). 안쪽으로는 못 간다. */ lateralM: number; /** 옛 상하(m, 수평 성분 · 1:1.2 대각) — 새 값은 늘 0. */ slopeM: number; } /** 측점별 세월교 조정값 — 측벽마다 좌우 · 바닥판 내림 하나. */ export interface FordAdjust { inlet: FordWallAdjust; outlet: FordWallAdjust; /** 바닥판 내림(m · 수직 · ≥ 0 · 양쪽 한 값 · 139-3). null/없음 = 옛 저장 → `fordDropOf`. */ dropM?: number | null; } /** 저장값 → 조정값(빈 칸 기본) — 화면 · 3D · 서버가 같은 꼴로 읽음. */ export function fordAdjustOf(stored?: Partial | null): FordAdjust { return { inlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(stored?.inlet ?? {}) }, outlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(stored?.outlet ?? {}) }, dropM: stored?.dropM ?? null, }; } export const DEFAULT_FORD_WALL_ADJUST: FordWallAdjust = { heightM: null, lateralM: 0, slopeM: 0, }; export const DEFAULT_FORD_ADJUST: FordAdjust = { inlet: DEFAULT_FORD_WALL_ADJUST, outlet: DEFAULT_FORD_WALL_ADJUST, dropM: 0, }; export type FordWallRole = "inlet" | "outlet"; export interface FordSideLayout { role: FordWallRole; /** 계류측 방향 부호(+ = 좌). */ outward: number; /** 벽·바닥판 폴리곤(집수정 ㄴ형과 같은 부재 구성). */ parts: BasinLayout["parts"]; /** 벽 상단 도로측 꼭짓점 = 설계선 트림 경계. */ top: OffsetPoint; heightM: number; /** 바닥판 상면 표고(이동 반영) = 이쪽 관 끝의 invert. */ slabTopElevation: number; /** 관 끝점(벽 바깥면 × 바닥판 상면). */ pipeEnd: OffsetPoint; /** 바닥판 안쪽 변(위·아래) — 두 측벽 사이를 잇는 판이 여기서 시작한다. */ slabInner: [OffsetPoint, OffsetPoint]; /** 측벽 상단의 **계류측 꼭짓점** — 좌측 벽이면 좌상단, 우측 벽이면 우상단. * 월류 전 노면(점선)이 이 점까지 대각으로 내려온다(2026-08-30 사용자, 각도 무시). */ wallTopOuter: OffsetPoint; /** 구조물 계류측 끝이 원지반 위일 때의 성토부선(집수정과 같은 체계). */ fillSegments: OutletFillSegment[]; /** 원지반 안으로 박힐 때의 절토선. */ cutLine: BasinLayout["cutLine"]; /** * 노견 → 벽 상단 접근선(2026-08-25 사용자) — **좌우 이동은 노견이 수평으로 * 확장**되고, 상하(대각) 이동분만 성토선이 된다(집수정과 같은 규칙). * 이동이 없으면 null. */ approach: OffsetPoint[] | null; /** 한계에 걸려 실제 적용된 조작값. */ adjust: FordWallAdjust; } export interface FordLayout { ford: FordSet; sides: FordSideLayout[]; /** 두 측벽 사이를 잇는 바닥판. */ slabBridge: OffsetPoint[]; /** 바닥판 전체 길이(m) — 날개벽 연장 포함. */ slabLengthM: number; /** 관 외곽·내경 사각형(양 끝은 각 측 바닥판 상면). */ pipe: { outer: OffsetPoint[]; inner: OffsetPoint[] }; label: { at: OffsetPoint; text: string }; designTrim: CulvertDesignTrim; adjust: FordAdjust; } /** 측벽 높이 하한 · 기본 = 관경 + 최소 토피 — 바닥 = 노면 − (관경 + 토피)(박스 「노면 − 복토」 꼴 · * 원지반 안 봄 · 깊은 계류면 ▲▼ 로 내림 · 107-2). 관이 노면 위로 솟지 않게 막는다. */ function minWallHeight(ford: FordSet): number { return ford.diameter_m + ford.min_cover_m; } /** * 바닥판 내림(m · ≥ 0) — 새 값(`dropM`) 그대로 · 옛 저장(측벽마다 높이 · 1:1.2 대각)은 측벽마다 * 바닥판 표고의 평균으로 옮김(박스 79-7 꼴) · 기본 자리 위는 0 에서 자름. 노견이 없으면 0. */ export function fordDropOf(section: CrossSection, adjust: FordAdjust): number { if (adjust.dropM != null) return Math.max(adjust.dropM, 0); const ford = section.ford; const edges = section.design?.road_edges; if (!ford || !edges) return 0; const roadTopAt = roadTopFactory(section, edges.left, edges.right); const minH = minWallHeight(ford); const inletOnLeft = (section.uphill_side ?? "left") === "left"; const oldSlab = (role: FordWallRole): number => { const edge = (role === "inlet") === inletOnLeft ? edges.left : edges.right; const wall = adjust[role]; return roadTopAt(edge.offset_m) - Math.max(wall.heightM ?? minH, minH) - wall.slopeM / 1.2; }; const base = Math.min(roadTopAt(edges.left.offset_m), roadTopAt(edges.right.offset_m)) - minH; return Math.max(base - (oldSlab("inlet") + oldSlab("outlet")) / 2, 0); } /** 성토 채움 윗면을 만들 노면 표고 — 설계선이 있으면 그 곡선, 없으면 노견 직선. */ 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; }; } /** * 세월교 구체 기하. 제원·설계선·지반이 부족하면 null (그리기와 분리 — 설계선 트림이 * 먼저 쓴다). */ export function computeFordLayout( section: CrossSection, groundSamples: SectionSample[], adjust: FordAdjust = DEFAULT_FORD_ADJUST, ): FordLayout | null { const ford = section.ford; if (!ford) return null; const edges = section.design?.road_edges; if (!edges) return null; const groundAt = groundInterpolator(groundSamples); if (!groundAt) return null; 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 inletOnLeft = uphill === "left"; const roadTopAt = roadTopFactory(section, edges.left, edges.right); // 바닥판 표고 하나(139-3) — 낮은 쪽 노견 노면 − (관경 + 토피) − 내림 const dropM = fordDropOf(section, adjust); const slabTop = Math.min(roadTopAt(edges.left.offset_m), roadTopAt(edges.right.offset_m)) - minWallHeight(ford) - dropM; const buildSide = (role: FordWallRole): FordSideLayout | null => { const onLeft = role === "inlet" ? inletOnLeft : !inletOnLeft; const edge = onLeft ? edges.left : edges.right; const outward = onLeft ? 1 : -1; const limitOffset = onLeft ? maxSample : minSample; const wing = role === "inlet" ? ford.wing_in : ford.wing_out; const wanted = role === "inlet" ? adjust.inlet : adjust.outlet; const lateralM = Math.max(wanted.lateralM, 0); // 측벽 높이 = 그 쪽 노견 노면 − 바닥판(결과) — 벽 상단이 노견 표고 · 좌우는 노견 수평 확장 const heightM = roadTopAt(edge.offset_m) - slabTop; const anchor: OffsetPoint = { offset: edge.offset_m, elevation: slabTop }; const built = buildBasin({ anchor, outward, shape: "L", reason: "manual", diameterM: ford.diameter_m, edge: { offset_m: edge.offset_m, elevation_m: roadTopAt(edge.offset_m) }, groundAt, // 측벽 끝 절토 = 본선 절토 사면과 같은 복합경사(91-4) cutDesign: section.design, // 내공 폭 자리에 날개벽 투영 연장을 넣는다 — 바닥판이 그만큼 계류측으로 뻗는다. adjust: { innerWidthM: Math.max(wing.slab_extend_m, 0), innerHeightM: heightM, lateralM, slopeM: 0, }, memberThicknessM: ford.wall_thickness_m, floorThicknessM: ford.slab_thickness_m, // 세월교 측벽은 **수직**이다(2026-08-30 사용자 확정 — 교본 그림 3-18 세월교 // 단면). 1:0.3 기움은 ㄴ형 집수정 부재를 재사용하며 딸려 온 값이라 여기서 끈다. // 바닥판 taper(0.3×두께)도 같은 값에서 나오므로 함께 사라져 직사각형이 된다. leanRatio: 0, }); const wall = built.basin.parts.find((part) => part.kind === "wall"); const floor = built.basin.parts.find((part) => part.kind === "floor"); if (!wall || !floor) return null; const extras = buildExtrasAt(built.fillStart, { startBottomElevation: built.fillBottomElevation, outward, groundAt, limitOffset, adjusts: [], equalize: false, }); // 접근선 — 좌우 이동분은 노견 표고 그대로 수평(상하는 벽이 길어짐 · 139-3). const approach = basinApproachPoints( { offset_m: edge.offset_m, elevation_m: roadTopAt(edge.offset_m) }, outward, { innerWidthM: 0, innerHeightM: 0, lateralM, slopeM: 0 }, { offset: built.trimOffset, elevation: built.trimElevation }, ); return { role, outward, parts: built.basin.parts, top: { offset: built.trimOffset, elevation: built.trimElevation }, approach, heightM, slabTopElevation: built.pipeEnd.elevation, pipeEnd: built.pipeEnd, slabInner: [floor.points[0], floor.points[3]], // 벽 상단 두 꼭짓점 중 계류측 것 — 점선이 여기로 내려와 붙는다. wallTopOuter: (() => { const topZ = Math.max(...wall.points.map((point) => point.elevation)); const tops = wall.points.filter((point) => point.elevation > topZ - 1e-6); return tops.reduce( (far, point) => ((point.offset - far.offset) * outward > 0 ? point : far), tops[0], ); })(), fillSegments: extras.segments, // 유입 날개벽 끝이 묻히면 수평 여유 뒤 절토 — 흙막이 수평공간과 같은 칸 · 기본 1 m(107-3) cutLine: role === "inlet" && wing.installed && built.basin.cutLine ? bermCut( floor.points[1], outward, { lengthM: Math.max(wing.berm_m ?? GUARD_CUT_BERM_M, 0), cut: structureCutSpec(section.design), }, groundAt, limitOffset, ).points : built.basin.cutLine, adjust: { heightM: null, lateralM, slopeM: 0 }, }; }; const inlet = buildSide("inlet"); const outlet = buildSide("outlet"); if (!inlet || !outlet) return null; const sides = [inlet, outlet]; const left = inlet.outward > 0 ? inlet : outlet; const right = inlet.outward > 0 ? outlet : inlet; // 두 측벽 사이를 잇는 바닥판 — 각 측 바닥판 안쪽 변을 그대로 잇는다. const slabBridge: OffsetPoint[] = [ left.slabInner[0], right.slabInner[0], right.slabInner[1], left.slabInner[1], ]; const slabLengthM = Math.abs( Math.max(...sides.map((side) => side.parts[1].points[1].offset)) - Math.min(...sides.map((side) => side.parts[1].points[1].offset)), ); // 관 — 각 측 바닥판 상면(벽 바깥면)에 안힌다 · 바닥판 표고 하나라 수평(139-3). const pipeWall = pipeWallThicknessM(ford.pipe_kind, ford.diameter_m); const leftInvert = left.pipeEnd; const rightInvert = right.pipeEnd; const rise = (point: OffsetPoint, dz: number): OffsetPoint => ({ offset: point.offset, elevation: point.elevation + dz, }); const pipe = { outer: [ rise(leftInvert, ford.diameter_m), rise(rightInvert, ford.diameter_m), rightInvert, leftInvert, ], inner: [ rise(leftInvert, ford.diameter_m - pipeWall), rise(rightInvert, ford.diameter_m - pipeWall), rise(rightInvert, pipeWall), rise(leftInvert, pipeWall), ], }; const countText = ford.pipe_count > 1 ? ` ${ford.pipe_count}련` : ""; return { ford, sides, slabBridge, slabLengthM, pipe, label: { at: { offset: (leftInvert.offset + rightInvert.offset) / 2, elevation: (leftInvert.elevation + rightInvert.elevation) / 2 + ford.diameter_m / 2, }, text: `${ford.pipe_kind ?? "관"} Ø${Math.round(ford.diameter_m * 1000)}${countText}`, }, // 노견 밖 설계선은 측벽이 대신한다 — 벽 상단 도로측 꼭짓점에서 끊는다. // 이동이 있으면 접근선(노견 수평 연장 → 대각)을 실어 설계선이 그 경로로 온다. designTrim: { minOffset: right.top.offset, maxOffset: left.top.offset, minElevation: right.top.elevation, maxElevation: left.top.elevation, minSlope: right.approach ? { points: right.approach } : undefined, maxSlope: left.approach ? { points: left.approach } : undefined, }, adjust: { inlet: inlet.adjust, outlet: outlet.adjust, dropM }, }; }