From 6391bb4c7eaf7226ef93ae8dbf0f5f5c871ec21f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 21 Aug 2026 18:24:58 +0900 Subject: [PATCH] =?UTF-8?q?fix(B06):=20=EC=84=B1=ED=86=A0=20=EB=AC=BC?= =?UTF-8?q?=EB=A7=A4=201:1.2=20=ED=95=98=ED=95=9C=EC=9D=84=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EC=9E=90=EB=A6=AC=EC=97=90=EB=8F=84=20=EA=B0=95?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기슭막이 기본 자리를 사면 끝으로 바꾼 뒤 물매가 규정보다 급해지는 측점이 생겼다 (13+15.7 실측 1:0.865 — 성토_비탈면.md §1의 1:1.2~2.0 위반). 1:1.2는 지켜야 하는 하한이므로, 사면 끝 자리의 물매가 그보다 급하면 **1:1.2에 닿을 때까지 바깥으로** 민다. 지반이 1:1.2보다 급해 끝내 못 맞추면 사면 끝을 그대로 쓰고 경고로 알린다. 물매 계산의 이음선 위치도 바로잡았다 — 자리 기준이 하단선 중점으로 바뀐 뒤에도 `REVET_TRAP_TOP_M`을 그대로 더해 실제보다 0.3m 바깥으로 보고 있었다. `fillWallBaseWidth()`로 중점↔이음선 환산을 한곳에 모았다. 검증(계획고 스윕 24케이스, 새 계획노선): 물매 최소 1:1.202 — **1:1.2 미만 0건** (종전 0.4~0.9까지 나왔다). 페이지 실데이터로도 13+15.7 1:1.206 확인. tsc 통과, pytest 148 pass. Co-Authored-By: Claude Opus 5 (1M context) --- .../B06_Section_UI_Cross_Culvert_Geom.ts | 22 +++++++++- .../B06_Section_UI_Cross_Culvert_Solve.ts | 44 ++++++++++++++++++- 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts index b7635f5e..4c240cda 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts @@ -49,6 +49,7 @@ import type { FillSlopeSegment } from "./B06_Section_UI_Cross_Culvert_Solve"; import { clampWallOffset, cutLength, + enforceMinFillRatio, outletSlopeFactory, fillSlopeOf, groundInterpolator, @@ -125,9 +126,17 @@ export function computeCulvertLayout( // "5m 이상이면 기슭막이 의무"라는 뜻이라 여기서 자르지 않는다. let inletAutoOffset: number | null = null; if (!basinReason && designAt) { - inletAutoOffset = slopeToeOffset(designAt, groundAt, inletInfo.edge.offset_m, inletInfo.limit); const inletBaseAt = (offset: number): number => Math.min(groundAt(offset), invertCap(inletInfo.edge)); + // 사면 끝이 기본 자리지만, 그 자리 물매가 1:1.2보다 급하면 규정 위반이라 바깥으로 민다. + inletAutoOffset = enforceMinFillRatio( + inletInfo.edge, + inletInfo.outward, + wallHeightFor(culvert.inlet), + inletBaseAt, + slopeToeOffset(designAt, groundAt, inletInfo.edge.offset_m, inletInfo.limit), + inletInfo.limit, + ); const shifted = clampWallOffset( inletAutoOffset, inletAutoOffset + inletInfo.outward * (revetShift?.inlet ?? 0), @@ -404,7 +413,16 @@ export function computeCulvertLayout( const invertAt = (offset: number): number => Math.min(groundAt(offset), inlet.elevation); // 유출 벽 기본 자리도 **사면 끝**(= outletAnchorOffset)이고 그 자리가 하단선 중점이다. let outletWallAnchor = outletAnchor; - const outletAutoOffset = outletAnchorOffset; + const outletAutoOffset = designAt + ? enforceMinFillRatio( + outletInfo.edge, + outletInfo.outward, + wallHeightFor(culvert.outlet), + (offset) => Math.min(groundAt(offset), inlet.elevation), + outletAnchorOffset, + outletInfo.limit, + ) + : outletAnchorOffset; if (designAt) { const shifted = clampWallOffset( outletAutoOffset, diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts index 39b4e477..e279639d 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts @@ -10,10 +10,17 @@ import { FILL_SLOPE_MAX_LENGTH_M, FILL_SLOPE_RATIO_MAX, FILL_SLOPE_RATIO_MIN, + REVET_LEAN_RATIO, + REVET_THICKNESS_M, REVET_TRAP_TOP_M, } from "./B06_Section_UI_Cross_Culvert_Const"; import type { OffsetPoint, PipeEnd } from "./B06_Section_UI_Cross_Culvert_Types"; +/** 기슭막이 하단선 폭(m) — 사다리꼴 밑변. 자리 기준(하단선 중점) 환산에 쓴다. */ +export function fillWallBaseWidth(height: number): number { + return REVET_THICKNESS_M * 1.5 + REVET_LEAN_RATIO * height; +} + /** 유효 지반 샘플 → offset 오름차순 보간기. 범위 밖은 끝값 클램프, 샘플 없으면 null. */ export function groundInterpolator(samples: SectionSample[]): ((offset: number) => number) | null { const ground = samples @@ -175,8 +182,8 @@ export function clampWallOffset( const rise = edge.elevation_m - (baseAt(candidate) + height); if (rise < FILL_MIN_RISE_M) break; if (inward) { - const run = Math.abs(candidate + outward * REVET_TRAP_TOP_M - edge.offset_m); - if (run / rise < FILL_SLOPE_RATIO_MIN) break; + const joint = candidate - outward * (fillWallBaseWidth(height) / 2 - REVET_TRAP_TOP_M); + if (Math.abs(joint - edge.offset_m) / rise < FILL_SLOPE_RATIO_MIN) break; } allowed = candidate; } @@ -219,3 +226,36 @@ export function outletSlopeFactory( }; }; } + +/** + * 성토 물매가 **1:1.2보다 급하면** 규정 위반이다(`성토_비탈면.md` §1 — 1:1.2~2.0). + * 사면 끝에 세운 자리가 그렇다면 물매가 1:1.2에 닿을 때까지 **바깥으로** 민다. + * 지반이 1:1.2보다 급해 끝내 못 맞추면 시작 자리를 그대로 돌려준다 — 그때는 + * 기슭막이로 못 받는 자리라 옹벽·석축 검토 대상이고 경고로 알린다. + */ +export function enforceMinFillRatio( + edge: { offset_m: number; elevation_m: number }, + outward: number, + height: number, + baseAt: (offset: number) => number, + startOffset: number, + limitOffset: number, +): number { + // 자리 기준은 **하단선 중점**이라 이음선 상단점은 중점보다 안쪽에 있다. + const jointOf = (offset: number): number => + offset - outward * (fillWallBaseWidth(height) / 2 - REVET_TRAP_TOP_M); + const ratioAt = (offset: number): number => { + const rise = edge.elevation_m - (baseAt(offset) + height); + if (!(rise > 1e-6)) return Number.POSITIVE_INFINITY; + return Math.abs(jointOf(offset) - edge.offset_m) / rise; + }; + if (ratioAt(startOffset) >= FILL_SLOPE_RATIO_MIN) return startOffset; + const span = (limitOffset - startOffset) * outward; + if (!(span > 0)) return startOffset; + const steps = 200; + for (let i = 1; i <= steps; i += 1) { + const candidate = startOffset + outward * ((span * i) / steps); + if (ratioAt(candidate) >= FILL_SLOPE_RATIO_MIN) return candidate; + } + return startOffset; +}