diff --git a/B06_Section/B06_Section_UI_Cross_Culvert.ts b/B06_Section/B06_Section_UI_Cross_Culvert.ts index 3bb329e0..82b29197 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert.ts @@ -94,7 +94,7 @@ export function appendCulvertOverlay( // 매몰 구간 — 0도 절토선을 원지반 교차까지(2026-08-22 사용자 ③). planLine( segment.points, - `유출부 0도 절토선 — 벽이 원지반에 묻혀 성토 불필요, 원지반 교차까지 ${segment.lengthM.toFixed(2)}m`, + `유출부 3도 절토선(계류 쪽 내림 — 물이 밖으로) — 벽이 원지반에 묻혀 성토 불필요, 원지반 교차까지 ${segment.lengthM.toFixed(2)}m`, ); continue; } @@ -118,8 +118,8 @@ export function appendCulvertOverlay( planLine( layout.inletFill.points, layout.inletFill.kind === "cut" - ? `유입부 접속 절토선 — 관 하단에서 0도 1m 후 표준 절토 경사로 원지반까지 (연장 ${layout.inletFill.lengthM.toFixed(2)}m)` - : `유입부 0도 성토선 — 관 하단에서 수평, 원지반 교차에서 정지 (연장 ${layout.inletFill.lengthM.toFixed(2)}m)`, + ? `유입부 접속 절토선 — 관 하단에서 3도(계류 쪽 오름) 1m 후 표준 절토 경사로 원지반까지 (연장 ${layout.inletFill.lengthM.toFixed(2)}m)` + : `유입부 3도 성토선(계류 쪽 오름 — 물이 관으로) — 원지반 교차에서 정지 (연장 ${layout.inletFill.lengthM.toFixed(2)}m)`, ); } }; @@ -265,7 +265,7 @@ export function appendCulvertOverlay( const fillTitle = document.createElementNS(SVG_NS, "title"); fillTitle.textContent = basin.shape === "I" - ? "집수정(I형) 되메움(성토)선 — 관 하단에서 수평으로 원지반과 연결" + ? "집수정(I형) 되메움(성토)선 — 관 하단에서 3도(계류 쪽 오름)로 원지반과 연결" : `집수정(${shapeName}) 성토(되메움)선 — 구조물이 원지반 밖으로 나와 5° 경사로 채움`; fill.append(fillTitle); layer.append(fill); diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts index bab25523..b5d44e57 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts @@ -13,6 +13,7 @@ * ========================================================================== */ import { + PIPE_CONNECT_GRADE, BASIN_INNER_HEIGHT_M, BASIN_MEMBER_THICKNESS_M, REVET_FREEBOARD_M, @@ -262,12 +263,26 @@ export function applyBasinPipeFill( 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; - if (groundAt(probe) >= corner.elevation) { - basin.fillLine = { from: corner, to: { offset: probe, elevation: corner.elevation } }; + 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; } } diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts index 5f707d51..a528988b 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts @@ -110,6 +110,10 @@ export function materialFromForm(form: string | null | undefined): RevetMaterial return "dry"; } +/** 관 접속 절·성토선 물매 — 0도 대신 3도(2026-08-22 사용자). 방향은 물 흐름을 + * 따른다: 유입측은 계류 쪽이 높고(물이 관으로), 유출측은 계류 쪽이 낮다(관에서 밖으로). */ +export const PIPE_CONNECT_GRADE = Math.tan((3 * Math.PI) / 180); + /** 배관 기슭막이 높이 조작 하한(m) — 2~3m 구간 지시(2026-08-22)의 하한. */ export const PIPE_WALL_MIN_HEIGHT_M = 2.0; /** 배관 기슭막이 기본 높이(m) — 재질과 무관하게 2.0(2026-08-22 사용자 정정). */ diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts index 4daa9656..2cc6f4a4 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts @@ -13,6 +13,7 @@ import { EXTRA_WALL_DEFAULT_HEIGHT_M, + PIPE_CONNECT_GRADE, EXTRA_WALL_MIN_HEIGHT_M, FILL_SLOPE_MAX_LENGTH_M, FILL_SLOPE_RATIO_MIN, @@ -56,16 +57,26 @@ export interface OutletExtrasResult { /** 수평선(0도)이 계류측으로 나가며 원지반 아래로 다시 떨어지는 첫 지점(절토 교차). * 없으면 null — 그 자리는 매몰 상태로 둘 수 없다(2026-08-22 사용자 ③ 이동 금지). */ -export function horizontalCrossing( +export function slopedCrossing( from: OffsetPoint, outward: number, + grade: number, groundAt: (offset: number) => number, limitOffset: number, ): number | null { const span = (limitOffset - from.offset) * outward; - for (let t = 0.25; t <= span + 1e-9; t += 0.25) { + const step = 0.25; + let previousGap = groundAt(from.offset) - from.elevation; // 지반 − 선 (양수 = 지반 위) + for (let t = step; t <= span + 1e-9; t += step) { const x = from.offset + outward * t; - if (groundAt(x) <= from.elevation) return x; + const gap = groundAt(x) - (from.elevation + grade * t); + if (gap <= 0) { + // 격자 사이 정확 교차점을 선형 보간 — 격자점을 그대로 쓰면 선이 지반을 + // 미세하게 벗어난다(2026-08-22 사용자 지적). + const fraction = previousGap > 1e-12 ? previousGap / (previousGap - gap) : 1; + return x - outward * step * (1 - fraction); + } + previousGap = gap; } return null; } @@ -85,33 +96,58 @@ export function inletGroundConnector( const ground = groundAt(corner.offset); const span = (limitOffset - corner.offset) * outward; if (corner.elevation > ground + 0.02) { - let cross = corner.offset + outward * Math.max(span, 0); + // 3도 성토선 — 계류 쪽이 높다(물이 관으로). 지반 교차점(보간)에서 정지. + let end: OffsetPoint = { + offset: corner.offset + outward * Math.max(span, 0), + elevation: corner.elevation + PIPE_CONNECT_GRADE * Math.max(span, 0), + }; + let previousGap = corner.elevation - groundAt(corner.offset); // 선 − 지반 for (let t = 0.25; t <= span + 1e-9; t += 0.25) { const x = corner.offset + outward * t; - if (groundAt(x) >= corner.elevation) { - cross = x; + const lineElevation = corner.elevation + PIPE_CONNECT_GRADE * t; + const gap = lineElevation - groundAt(x); + if (gap <= 0) { + const fraction = previousGap > 1e-12 ? previousGap / (previousGap - gap) : 1; + const tCross = t - 0.25 * (1 - fraction); + end = { + offset: corner.offset + outward * tCross, + elevation: corner.elevation + PIPE_CONNECT_GRADE * tCross, + }; break; } + previousGap = gap; } return { - points: [corner, { offset: cross, elevation: corner.elevation }], - lengthM: Math.abs(cross - corner.offset), + points: [corner, end], + lengthM: Math.hypot(end.offset - corner.offset, end.elevation - corner.elevation), overLimit: false, kind: "fill", }; } if (corner.elevation < ground - 0.02) { + // 3도 직선 1m(계류 쪽이 높다) 후 표준 절토 경사. const bend: OffsetPoint = { offset: corner.offset + outward * 1.0, - elevation: corner.elevation, + elevation: corner.elevation + PIPE_CONNECT_GRADE * 1.0, }; let last = bend; const cutSpan = (limitOffset - bend.offset) * outward; + let previousGap = groundAt(bend.offset) - bend.elevation; // 지반 − 절토선 for (let t = 0.25; t <= Math.max(cutSpan, 0) + 1e-9; t += 0.25) { const x = bend.offset + outward * t; const elevation = bend.elevation + t / Math.max(cutSlopeRatio, 0.1); + const gap = groundAt(x) - elevation; + if (gap <= 0) { + const fraction = previousGap > 1e-12 ? previousGap / (previousGap - gap) : 1; + const tCross = t - 0.25 * (1 - fraction); + last = { + offset: bend.offset + outward * tCross, + elevation: bend.elevation + tCross / Math.max(cutSlopeRatio, 0.1), + }; + break; + } last = { offset: x, elevation }; - if (elevation >= groundAt(x)) break; + previousGap = gap; } return { points: [corner, bend, last], @@ -138,15 +174,28 @@ function trailingFill( let length = 0; let previous = src; const step = 0.25; + let previousGap = src.elevation - groundAt(src.offset); // 사면선 − 지반 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) }; + const gap = slopeElevation - groundAt(offset); + if (gap <= 0) { + // 끝점은 격자 대신 **보간 교차점**에 붙인다(2026-08-22 사용자 — 미세 이탈 정정). + const fraction = previousGap > 1e-12 ? previousGap / (previousGap - gap) : 1; + const tCross = t - step * (1 - fraction); + const point: OffsetPoint = { + offset: src.offset + outward * tCross, + elevation: src.elevation - tCross / FILL_SLOPE_RATIO_MIN, + }; + length += Math.hypot(point.offset - previous.offset, point.elevation - previous.elevation); + points.push(point); + break; + } + const point: OffsetPoint = { offset, elevation: slopeElevation }; length += Math.hypot(point.offset - previous.offset, point.elevation - previous.elevation); points.push(point); previous = point; - if (slopeElevation <= groundElevation) break; + previousGap = gap; } return { points, @@ -337,11 +386,12 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult const topFrontX = jointX + outward * REVET_THICKNESS_M; const startX = topFrontX + outward * REVET_LEAN_RATIO * (topE - startE); if (groundAt(startX) < startE + 0.05) return true; - // 매몰 — 0도 절토선이 원지반과 다시 만나면 허용(2026-08-22 ③), 아니면 이동 금지. + // 매몰 — 3도 절토선(계류 쪽 내림)이 원지반과 다시 만나면 허용, 아니면 이동 금지. return ( - horizontalCrossing( + slopedCrossing( { offset: startX, elevation: startE }, outward, + -PIPE_CONNECT_GRADE, groundAt, limitOffset, ) !== null @@ -413,11 +463,15 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult segments.push(tail); addable = tail.overLimit; } else { - const cross = horizontalCrossing(src, outward, groundAt, limitOffset); + const cross = slopedCrossing(src, outward, -PIPE_CONNECT_GRADE, groundAt, limitOffset); if (cross !== null) { + const end: OffsetPoint = { + offset: cross, + elevation: src.elevation - PIPE_CONNECT_GRADE * Math.abs(cross - src.offset), + }; segments.push({ - points: [src, { offset: cross, elevation: src.elevation }], - lengthM: Math.abs(cross - src.offset), + points: [src, end], + lengthM: Math.hypot(end.offset - src.offset, end.elevation - src.elevation), overLimit: false, kind: "cut", }); diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts index 375bb0f0..1a6e9480 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts @@ -8,6 +8,7 @@ import type { SectionSample } from "./B06_Section_Api_Fetch"; import { FILL_MIN_RISE_M, FILL_SLOPE_MAX_LENGTH_M, + PIPE_CONNECT_GRADE, FILL_SLOPE_RATIO_MIN, REVET_HEIGHT_STEP_M, REVET_LEAN_RATIO, @@ -15,7 +16,7 @@ import { REVET_TRAP_TOP_M, } from "./B06_Section_UI_Cross_Culvert_Const"; import type { OffsetPoint, PipeEnd, WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types"; -import { horizontalCrossing } from "./B06_Section_UI_Cross_Culvert_Extra"; +import { slopedCrossing } from "./B06_Section_UI_Cross_Culvert_Extra"; /** 기슭막이 하단선 폭(m) — 사다리꼴 밑변. 자리 기준(하단선 중점) 환산에 쓴다. */ export function fillWallBaseWidth(height: number): number { @@ -386,9 +387,10 @@ export function placePipeWall(input: { if (input.requireCrossing) { const placeable = (anchorOffset: number, invertE: number): boolean => invertE >= input.groundAt(anchorOffset) - 0.01 || - horizontalCrossing( + slopedCrossing( { offset: anchorOffset, elevation: invertE }, input.outward, + -PIPE_CONNECT_GRADE, input.groundAt, input.limitOffset, ) !== null; diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts index 3d058e4c..d1cdc1ea 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts @@ -9,6 +9,11 @@ import { computeCulvertLayout } from "./B06_Section_UI_Cross_Culvert"; import type { CulvertLayout, InletStructureChoice, RevetKey } from "./B06_Section_UI_Cross_Culvert"; import { materialLabel, materialLimit } from "./B06_Section_UI_Cross_Culvert_Const"; import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types"; +import { + designInterpolator, + groundInterpolator, + slopeToeOffset, +} from "./B06_Section_UI_Cross_Culvert_Solve"; /** * 기슭막이 4축 조작 제어(2026-08-22 확정) — 벽을 눌러 고르고 조정창에서 * 좌우(x)·상하(d, 성토선 대각)·높이(h)·재질(m)을 만진다. 고른 벽은 여기 담아 두어 @@ -70,15 +75,7 @@ export function computeCardCulvert( const layout = computeCulvertLayout( section, sourceSamples, - revetOffset - ? { - inlet: revetOffset.adjustFor(section, "inlet"), - outlet: revetOffset.adjustFor(section, "outlet"), - extras: Array.from({ length: extraWalls?.countFor(section) ?? 0 }, (_, i) => - revetOffset.adjustFor(section, `extra${i}`), - ), - } - : undefined, + adjustsInput(section, revetOffset, extraWalls), inletStructure?.valueFor(section), equalizeExtras, ); @@ -147,3 +144,41 @@ export function computeCardCulvert( } return layout; } + +function adjustsInput( + section: CrossSection, + revetOffset?: RevetOffsetControl, + extraWalls?: ExtraWallControl, +): { inlet: WallAdjust; outlet: WallAdjust; extras: WallAdjust[] } | undefined { + if (!revetOffset) return undefined; + return { + inlet: revetOffset.adjustFor(section, "inlet"), + outlet: revetOffset.adjustFor(section, "outlet"), + extras: Array.from({ length: extraWalls?.countFor(section) ?? 0 }, (_, i) => + revetOffset.adjustFor(section, `extra${i}`), + ), + }; +} + +/** + * **구조물 없는 상태**의 설계 절·성토선이 원지반과 만나는 지점이 들어오는 데 + * 필요한 표시 반폭(m, 여유 0.5 포함) — 2026-08-22 사용자 확정 줌 기준. + * 구조물(기슭막이·집수정)이 어디로 가든 화면 기준은 지형·설계선이 정한다. + */ +export function culvertRequiredHalfWidth(section: CrossSection): number | null { + const design = section.design; + if (!design) return null; + const groundAt = groundInterpolator(section.samples); + const designAt = designInterpolator(design.design_line); + const edges = design.road_edges; + if (!groundAt || !designAt || !edges) return null; + const offsets = section.samples.map((sample) => sample.offset_m ?? 0); + const limits = { left: Math.max(...offsets), right: Math.min(...offsets) }; + let extent = 0; + for (const side of ["left", "right"] as const) { + const edge = side === "left" ? edges.left : edges.right; + const toe = slopeToeOffset(designAt, groundAt, edge.offset_m, limits[side]); + extent = Math.max(extent, Math.abs(toe)); + } + return extent > 0 ? extent + 0.5 : null; +} diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 2b2efc35..070d4325 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -24,7 +24,7 @@ import { type RockBoundaryControl, } from "./B06_Section_UI_Cross_Design"; import { appendCulvertOverlay } from "./B06_Section_UI_Cross_Culvert"; -import { computeCardCulvert } from "./B06_Section_UI_Cross_Culvert_Wire"; +import { computeCardCulvert, culvertRequiredHalfWidth } from "./B06_Section_UI_Cross_Culvert_Wire"; import type { ExtraWallControl, InletStructureControl, @@ -202,8 +202,14 @@ export function createCrossSectionCard( /** 유출측 추가 기슭막이 개수 제어(2026-08-22). */ extraWalls?: ExtraWallControl, ): CrossCardElement { - // 이 카드의 실효 표시 반폭 — 개별값이 전역 반폭보다 우선한다(2026-08-06). - const effectiveHalfWidth = stationWidth?.widthFor(section) ?? crossHalfWidth; + // 실효 표시 반폭 — 개별값 > 전역값(2026-08-06). **구조물 없는 상태**의 절·성토선이 + // 원지반과 만나는 지점이 반폭 밖이면 이 카드만 자동 줌아웃(2026-08-22 사용자). + const baseHalfWidth = stationWidth?.widthFor(section) ?? crossHalfWidth; + const requiredHalfWidth = culvertRequiredHalfWidth(section); + const effectiveHalfWidth = + baseHalfWidth !== undefined && requiredHalfWidth !== null + ? Math.max(baseHalfWidth, requiredHalfWidth) + : baseHalfWidth; const card: CrossCardElement = document.createElement("article"); card.id = `cross-${section.station_id}`; card.className = `b06-cross-card${selected ? " b06-cross-card--selected" : ""}`;