From 030b81b7445a0f1395e66f1de53094c70f856f09 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 22 Aug 2026 09:07:35 +0900 Subject: [PATCH] =?UTF-8?q?feat(B06):=20=EA=B8=B0=EC=8A=AD=EB=A7=89?= =?UTF-8?q?=EC=9D=B4=20=EC=9E=90=EB=8F=99=20=EC=9E=90=EB=A6=AC=20=EB=85=B8?= =?UTF-8?q?=EA=B2=AC=20=EC=B5=9C=EC=86=8C=ED=99=94=C2=B7=EC=88=98=EB=8F=99?= =?UTF-8?q?=20=EC=9D=B4=EB=8F=99=20=EB=8F=85=EB=A6=BD=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 자동 자리 = 물매 1:1.2가 딱 성립하는 가장 안쪽(노견 최소) 지점. 관 m단위 올림 잔여만 유출 벽 바깥 이동으로 흡수(minShoulderWallOffset). 사면 끝 밖 자리는 급물매 교정일 때만 인정 — 얕은 성토 폭주 방지. - 손으로 민 벽은 관 정수 맞춤이 절대 옮기지 않음 — 한쪽 이동 시 반대쪽 벽 동시 이동(관 +2m) 소멸. 표기 길이만 올림(m). - 안쪽 당김 한계 = 1:1.2 성립 자리(우선순위: 배관 연장 > 높이 증가 > 바닥 띄움). 1:1.2 자리가 없는 지형 폴백에서만 높이 증가·바닥 띄움 (solveWallVertical, 한계 찰3.0/메2.0). - 기슭막이는 관을 감싼다: 하단은 항상 관 invert 아래 0.5m 확보, 뜬 바닥도 동일(뜬 공간은 별도 지지 구조물 — 추가 예정). - 검증: 4+4.3 관 13m→9m·노폭연장 1.52→0.08, 13+15.7 UI 조작 (◀▶↺·클램프·단독이동) 및 dz 스윕 24건 물매 이탈 0건. Co-Authored-By: Claude Opus 5 (1M context) --- B06_Section/B06_Section_UI_Cross_Culvert.ts | 4 +- .../B06_Section_UI_Cross_Culvert_Geom.ts | 138 +++++++++++++----- .../B06_Section_UI_Cross_Culvert_Solve.ts | 124 ++++++++++------ .../B06_Section_UI_Cross_Culvert_Types.ts | 6 + 4 files changed, 187 insertions(+), 85 deletions(-) diff --git a/B06_Section/B06_Section_UI_Cross_Culvert.ts b/B06_Section/B06_Section_UI_Cross_Culvert.ts index 4a824de8..90049599 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert.ts @@ -117,7 +117,9 @@ export function appendCulvertOverlay( `${roleLabel(wall.role)} 기슭막이 ${wall.form ?? ""} H=${wall.height.toFixed(1)}m` + `(상단 = 사면선 접점, 전면 1:${REVET_LEAN_RATIO}` + `, 높이 한계 ${revetHeightLimit(wall.form).toFixed(1)}m — 교본 7-3)` + - ` · 근입 ${REVET_EMBED_DEPTH_M.toFixed(2)}m(실무 기초콘크리트 H=0.5 — 법정 규정 없음)` + + (wall.floatGapM > 0.01 + ? ` · ⚠ 바닥 원지반 이격 ${wall.floatGapM.toFixed(2)}m — 하부 지지 구조물 별도(추가 예정)` + : ` · 근입 ${REVET_EMBED_DEPTH_M.toFixed(2)}m(실무 기초콘크리트 H=0.5 — 법정 규정 없음)`) + (wall.lengthM ? ` · 연장 ${wall.lengthM.toFixed(1)}m` : ""), ); if (onSelectRevet) revetShape.classList.add("is-selectable"); diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts index b1447e62..81912fd7 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts @@ -45,17 +45,18 @@ import type { export * from "./B06_Section_UI_Cross_Culvert_Const"; export * from "./B06_Section_UI_Cross_Culvert_Types"; -import type { FillSlopeSegment } from "./B06_Section_UI_Cross_Culvert_Solve"; +import type { FillSlopeSegment, WallVertical } from "./B06_Section_UI_Cross_Culvert_Solve"; import { clampWallOffset, cutLength, - enforceMinFillRatio, + minShoulderWallOffset, outletSlopeFactory, fillSlopeOf, groundInterpolator, designInterpolator, intersect, slopeToeOffset, + solveWallVertical, STRAY_LIMIT_M, } from "./B06_Section_UI_Cross_Culvert_Solve"; @@ -121,34 +122,51 @@ export function computeCulvertLayout( Math.min(revetTargetHeight(diameter), revetHeightLimit(spec.revet_form)); /** 요청값이 한계에 걸려 잘린 뒤의 **실제** 이동량. 조정창이 이 값을 되받는다. */ const appliedShift = { inlet: 0, outlet: 0 }; - // 기슭막이 기본 자리 = **성토 사면이 원지반과 만나는 지점**(사면 끝)이고, 그 자리가 - // 하단선 중점이 된다(2026-08-21 사용자 확정). 사면길이 5m는 자리 한계가 아니라 - // "5m 이상이면 기슭막이 의무"라는 뜻이라 여기서 자르지 않는다. + /** 손으로 민 벽의 수직 제원(높이 증가·바닥 띄움) — buildWall에 물려 준다. */ + const wallVertical: { inlet: WallVertical | null; outlet: WallVertical | null } = { + inlet: null, + outlet: null, + }; + // 기슭막이 자동 자리 = **노견(노폭 연장)이 최소가 되는 지점**(2026-08-22 사용자 확정) + // — 표준 높이 벽으로 물매 1:1.2가 딱 성립하는 가장 안쪽 자리. 관 m단위 올림 잔여가 + // 벽을 바깥으로 밀면 그만큼만 노견이 조정된다. 못 찾으면 사면 끝으로 폴백. let inletAutoOffset: number | null = null; if (!basinReason && designAt) { const inletBaseAt = (offset: number): number => Math.min(groundAt(offset), invertCap(inletInfo.edge)); - // 사면 끝이 기본 자리. 그 자리 물매가 1:1.2보다 급하면 규정 위반이라 바깥으로 민다 - // (완만한 쪽은 노폭 연장으로 맞춘다 — fillSlopeOf). - inletAutoOffset = enforceMinFillRatio( + const inletToe = slopeToeOffset(designAt, groundAt, inletInfo.edge.offset_m, inletInfo.limit); + const inletFeasible = minShoulderWallOffset( inletInfo.edge, inletInfo.outward, wallHeightFor(culvert.inlet), inletBaseAt, - slopeToeOffset(designAt, groundAt, inletInfo.edge.offset_m, inletInfo.limit), + inletToe, inletInfo.limit, ); + inletAutoOffset = inletFeasible ?? inletToe; + // 안쪽 당김 한계 = 1:1.2 성립 자리(= 자동 자리). 그런 자리가 없는 지형만 노견까지 + // 허용하고, 그때 높이 증가·바닥 띄움이 발동한다(2026-08-22 사용자 확정). const shifted = clampWallOffset( - inletAutoOffset, inletAutoOffset + inletInfo.outward * (revetShift?.inlet ?? 0), - wallHeightFor(culvert.inlet), - inletInfo.edge, - inletBaseAt, + inletFeasible ?? inletInfo.edge.offset_m, inletInfo.outward, ); appliedShift.inlet = Math.round((shifted - inletAutoOffset) * inletInfo.outward * 10) / 10; + // 안쪽으로 당겨 노견이 부족하면 ① 높이 증가 ② 바닥 띄움(2026-08-22 사용자 확정). + const vertical = solveWallVertical( + shifted, + inletInfo.edge, + inletInfo.outward, + inletBaseAt(shifted), + wallHeightFor(culvert.inlet), + revetHeightLimit(culvert.inlet.revet_form), + ); + // 뜬 바닥(= 관 invert)이 노면−관경−토피 상한을 넘으면 토피가 부족하다 — 상한으로 자른다. + vertical.baseElevation = Math.min(vertical.baseElevation, invertCap(inletInfo.edge)); + vertical.floatGapM = Math.max(0, vertical.baseElevation - inletBaseAt(shifted)); + wallVertical.inlet = vertical; inlet.offset = shifted; - inlet.elevation = inletBaseAt(shifted); + inlet.elevation = vertical.baseElevation; } // 유출 목표점 = 유출측 성토사면이 지반과 만나는 사면 끝(경사길이 5m 한계 — 별표2). @@ -215,6 +233,7 @@ export function computeCulvertLayout( outward: number, forceBasinReason: BasinLayout["reason"] | null, thickness: number = REVET_THICKNESS_M, + vertical: WallVertical | null = null, ): WallLayout | null => { const reason: BasinLayout["reason"] | null = forceBasinReason ?? (spec.structure === "집수정" ? "cut" : null); @@ -347,12 +366,14 @@ export function computeCulvertLayout( // 형상(사용자 스케치 확정 — 좌측 벽 기준, 우측은 반전): 배면(도로측) 수직, 전면 // (계류측)은 1:0.3 기운 평행사변형 띠. 띠 안쪽 평행선과 수직 배면 사이가 사다리꼴로, // 바닥이 넓고 상단이 좁다(상단 폭 = 띠 두께 0.45). - // 높이: **관경 + 여유고**를 0.1m 눈금으로 올린 값(2026-08-21 사용자 확정). 관이 - // 물매를 두어 좌·우 invert가 달라도 **관 상단 위 여유고는 같아야** 한다. 사면선은 - // 높이를 정하지 않고 벽이 설 **자리**만 정한다(`solveFillWallOffset`). + // 높이: 기본은 **관경 + 여유고**를 0.1m 눈금으로 올린 값(2026-08-21 사용자 확정). + // 손으로 안쪽에 당겨 노견이 부족하면 수직 제원(vertical)이 높이를 키우거나 바닥을 + // 띄워 들어온다(2026-08-22 사용자 확정 — 최소 노견 유지). // 형태별 법정·교본 높이 한계(찰 3.0 / 메 2.0 — 돌쌓기.md §1)를 넘지 못한다. - const height = Math.min(revetTargetHeight(diameter), revetHeightLimit(spec.revet_form)); + const height = + vertical?.height ?? Math.min(revetTargetHeight(diameter), revetHeightLimit(spec.revet_form)); if (!(height > 0.05)) return null; + const floatGapM = vertical?.floatGapM ?? 0; // **자리 기준 = 하단선 중점**(2026-08-21 사용자 확정). 배면 기준으로 잡으면 벽이 // 기울 때 체감 위치가 어긋난다. anchor.offset이 하단선 중점, anchor.elevation이 // 그 자리 원지반(= 관 invert)이다. @@ -366,7 +387,14 @@ export function computeCulvertLayout( const topFront = topJoint + outward * thickness; // 근입: 하단선은 **원지반을 수직으로 0.5m 내린 선**이다(2026-08-21 사용자 확정). // 종전에는 수평 바닥이라 사면에서 앞 모서리가 떴다. - const bottomAt = (offset: number): number => groundAt(offset) - REVET_EMBED_DEPTH_M; + // 기슭막이는 **배관을 묻고 보강하는 구조물 — 관을 감싸야 한다**(2026-08-22 사용자 + // 확정): 하단은 어떤 경우에도 관 하단(invert = anchor.elevation) 아래 0.5m를 + // 확보한다. 관이 지반보다 낮게 묻힌 자리는 invert−0.5가 지반−0.5보다 깊고, + // 바닥이 원지반에서 뜬 벽(floatGapM>0)도 관 기준 0.5는 그대로 내려간다 + // (뜬 공간은 별도 지지 구조물 — 추가 예정). + const bottomAt = (offset: number): number => + (floatGapM > 1e-6 ? anchor.elevation : Math.min(groundAt(offset), anchor.elevation)) - + REVET_EMBED_DEPTH_M; const bottomBack: OffsetPoint = { offset: backOffset, elevation: bottomAt(backOffset) }; const bottomFront: OffsetPoint = { offset: frontBase, elevation: bottomAt(frontBase) }; const wall: WallLayout = { @@ -377,6 +405,7 @@ export function computeCulvertLayout( outerOffset: frontBase, base: anchor.elevation, height, + floatGapM, outward, topBack, topJoint: { offset: topJoint, elevation: topElevation }, @@ -403,7 +432,14 @@ export function computeCulvertLayout( }; return wall; }; - buildWall(culvert.inlet, inlet, inletInfo.outward, basinReason); + buildWall( + culvert.inlet, + inlet, + inletInfo.outward, + basinReason, + REVET_THICKNESS_M, + wallVertical.inlet, + ); // 유출 벽 자리 — 유입과 같은 규칙(사면선이 벽 이음선 상단점을 지나는 자리, 물매는 // 1:1.2~2.0 범위에서 역산). // @@ -412,31 +448,49 @@ export function computeCulvertLayout( // (2026-08-21 사용자 지적). 관 끝은 원지반 위라는 원칙대로 지반을 따르면 벽이 // 지반에 앉고 관 물매가 그 자리에 맞춰진다. 역경사는 유입 invert로 클램프. const invertAt = (offset: number): number => Math.min(groundAt(offset), inlet.elevation); - // 유출 벽 기본 자리도 **사면 끝**(= outletAnchorOffset)이고 그 자리가 하단선 중점이다. + // 유출 벽 자동 자리도 **노견 최소 지점**(2026-08-22 사용자 확정) — 못 찾으면 사면 끝. let outletWallAnchor = outletAnchor; - const outletAutoOffset = designAt - ? enforceMinFillRatio( + const outletFeasible = designAt + ? minShoulderWallOffset( outletInfo.edge, outletInfo.outward, wallHeightFor(culvert.outlet), - (offset) => Math.min(groundAt(offset), inlet.elevation), + invertAt, outletAnchorOffset, outletInfo.limit, ) - : outletAnchorOffset; + : null; + const outletAutoOffset = outletFeasible ?? outletAnchorOffset; if (designAt) { + // 안쪽 당김 한계 = 1:1.2 성립 자리(유입과 동일 규칙 — 2026-08-22 사용자 확정). const shifted = clampWallOffset( - outletAutoOffset, outletAutoOffset + outletInfo.outward * (revetShift?.outlet ?? 0), - wallHeightFor(culvert.outlet), - outletInfo.edge, - invertAt, + outletFeasible ?? outletInfo.edge.offset_m, outletInfo.outward, ); appliedShift.outlet = Math.round((shifted - outletAutoOffset) * outletInfo.outward * 10) / 10; - outletWallAnchor = { offset: shifted, elevation: invertAt(shifted) }; + const vertical = solveWallVertical( + shifted, + outletInfo.edge, + outletInfo.outward, + invertAt(shifted), + wallHeightFor(culvert.outlet), + revetHeightLimit(culvert.outlet.revet_form), + ); + // 뜬 바닥(= 유출 invert)이 유입 invert보다 높으면 역경사다 — 유입 높이로 자른다. + vertical.baseElevation = Math.min(vertical.baseElevation, inlet.elevation); + vertical.floatGapM = Math.max(0, vertical.baseElevation - invertAt(shifted)); + wallVertical.outlet = vertical; + outletWallAnchor = { offset: shifted, elevation: vertical.baseElevation }; } - let outletWall = buildWall(culvert.outlet, outletWallAnchor, outletInfo.outward, null); + let outletWall = buildWall( + culvert.outlet, + outletWallAnchor, + outletInfo.outward, + null, + REVET_THICKNESS_M, + wallVertical.outlet, + ); // 유출 벽이 사면 끝보다 안쪽으로 당겨졌으면 **관 끝도 그 자리에 맞춘다** — 관만 사면 // 끝까지 남으면 벽과 떨어져 마감면 교차가 실패한다(2026-08-20 사용자 ② 원인). if (outletWall && Math.abs(outletWallAnchor.offset - outletAnchor.offset) > 0.05) { @@ -496,7 +550,11 @@ export function computeCulvertLayout( const outletSlopeAt = outletSlopeFactory(outletInfo, invertAt, wallHeightFor(culvert.outlet)); /** 유출 벽 두께 — 항상 0.45 고정(2026-08-21 사용자 확정). 폭으로 길이를 맞추지 않는다. */ const outletThickness = REVET_THICKNESS_M; - if (outletWall) { + // 손으로 민 벽은 정수 맞춤이 **절대 옮기지 않는다**(2026-08-22 사용자 확정 — 종전에는 + // 한쪽 벽을 밀면 이 루프가 반대쪽 벽을 따라 옮겨 좌우가 동시에 움직였다). 관 그림은 + // 두 벽 전면 사이 실길이로 두고 표기 길이만 올림(m)한다. + const outletPinned = Math.abs(revetShift?.outlet ?? 0) > 1e-9; + if (outletWall && !outletPinned) { const target = Math.ceil(cutLength(pipeCorners) - 1e-6); const rebuild = (): void => { walls.pop(); @@ -528,15 +586,12 @@ export function computeCulvertLayout( ) { break; } - // 조정창으로 옮긴 벽은 **폭을 건드리지 않는다** — 폭으로 흡수하면 미는 동안 벽 - // 단면이 변한다(2026-08-21 사용자 지적: 유출 벽만 0.45→0.90). 조정이 관 길이 1m - // 단위라 자리만 맞추면 정수가 성립한다. 물매·5m 한계는 자동 배치용 제약이라 - // 사용자가 지정한 자리에는 걸지 않는다(경고로 알린다). + // 자동 자리만 옮긴다(수동 벽은 이 루프에 아예 안 들어온다). 폭은 0.45 고정 — + // 폭으로 흡수하면 미는 동안 벽 단면이 변한다(2026-08-21 사용자 지적). if ( - Math.abs(revetShift?.outlet ?? 0) > 1e-9 || - (movedSlope.ratio >= FILL_SLOPE_RATIO_MIN && - movedSlope.ratio <= FILL_SLOPE_RATIO_MAX && - movedSlope.lengthM <= FILL_SLOPE_MAX_LENGTH_M + 1e-6) + movedSlope.ratio >= FILL_SLOPE_RATIO_MIN && + movedSlope.ratio <= FILL_SLOPE_RATIO_MAX && + movedSlope.lengthM <= FILL_SLOPE_MAX_LENGTH_M + 1e-6 ) { outletWallAnchor = { offset: moved, elevation: invertAt(moved) }; rebuild(); @@ -549,7 +604,10 @@ export function computeCulvertLayout( // 남는 몫은 관이 벽을 조금 벗어나는 것으로 두는 게 기존 확정 규칙이다. 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 diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts index 294bf208..bde85fc7 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts @@ -9,6 +9,7 @@ import { FILL_MIN_RISE_M, FILL_SLOPE_MAX_LENGTH_M, FILL_SLOPE_RATIO_MIN, + REVET_HEIGHT_STEP_M, REVET_LEAN_RATIO, REVET_THICKNESS_M, REVET_TRAP_TOP_M, @@ -211,64 +212,99 @@ export function outletSlopeFactory( } /** - * 손으로 민 벽 자리를 **성토가 성립하는 범위**로 되돌린다. - * · 벽은 제 노견 바깥에 선다(도로 밑을 파고들면 안 된다) - * · 성토고 하한(FILL_MIN_RISE_M) — 그 아래면 사면선이 역전된다 - * · 안쪽으로 당길 때는 **물매 1:1.2**를 넘지 않는 자리까지만(2026-08-21 사용자 지시). - * 바깥으로 밀 때는 물매가 1:1.2로 고정된 채 노폭이 늘어나므로 막지 않는다. + * 손으로 민 벽 자리 한계 — 안쪽 당김은 **1:1.2가 성립하는 가장 안쪽 자리**(innerLimit + * = 자동 자리)에서 멈춘다(2026-08-22 사용자 확정 — 우선순위: 배관 연장 > 높이 증가 > + * 바닥 띄움. 당겨서 물매가 깨질 바에는 관을 길게 유지한다). 1:1.2 자리가 아예 없는 + * 지형 폴백에서는 innerLimit이 노견까지 내려오고, 그때만 높이 증가·바닥 띄움 + * (`solveWallVertical`)이 발동한다. 바깥 밀기는 막지 않는다(노폭 연장으로 흡수). */ export function clampWallOffset( - autoOffset: number, wantedOffset: number, - height: number, - edge: { offset_m: number; elevation_m: number }, - baseAt: (offset: number) => number, + innerLimitOffset: number, outward: number, ): number { - const steps = 40; - const inward = (wantedOffset - autoOffset) * outward < 0; - let allowed = autoOffset; - for (let i = 1; i <= steps; i += 1) { - const candidate = autoOffset + ((wantedOffset - autoOffset) * i) / steps; - if ((candidate - edge.offset_m) * outward < 0) break; - const rise = edge.elevation_m - (baseAt(candidate) + height); - if (rise < FILL_MIN_RISE_M) break; - if (inward) { - 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; - } - return allowed; + return (wantedOffset - innerLimitOffset) * outward < 0 ? innerLimitOffset : wantedOffset; } /** - * 사면 끝 자리의 물매가 **1:1.2보다 급하면** 규정 위반이라 1:1.2가 될 때까지 바깥으로 - * 민다. 반대로 완만한 쪽(1:1.2보다 느슨)은 `fillSlopeOf`가 **노폭을 늘려** 맞추므로 - * 여기서 건드리지 않는다(2026-08-21 사용자 확정 — 물매는 1:1.2 고정). - * 지반이 1:1.2보다 급해 끝내 못 맞추면 시작 자리를 돌려주고 경고로 알린다. + * 기슭막이 **자동 자리 = 노견(노폭 연장)이 최소가 되는 지점**(2026-08-22 사용자 확정). + * 노견 끝에서 바깥으로 훑어, 표준 높이 벽으로 ① 성토고 하한(FILL_MIN_RISE_M)과 + * ② 물매 1:1.2 이상(급하지 않음)을 처음 만족하는 자리를 돌려준다 — 그 자리가 + * 물매 1:1.2가 딱 성립하는 가장 안쪽 자리라 노폭 연장이 0에 수렴한다. + * (관 m단위 올림 잔여가 벽을 바깥으로 밀면 그만큼만 노견이 조정된다.) + * + * 사면 끝(toeOffset) **밖**의 자리는 사면 끝에 성토고가 서 있는데 물매만 급한 경우 + * (규정 위반 교정)만 인정한다 — 성토가 얕아 자리 자체가 안 서는 지형에서 절벽 너머로 + * 폭주하는 것을 막는다(계획고 인하 스윕에서 노폭 연장 11m 관측). 못 찾으면 null — + * 호출부가 사면 끝으로 폴백한다. */ -export function enforceMinFillRatio( +export function minShoulderWallOffset( edge: { offset_m: number; elevation_m: number }, outward: number, height: number, baseAt: (offset: number) => number, - startOffset: number, + toeOffset: number, limitOffset: number, -): number { - const ratioAt = (offset: number): number => { - const rise = edge.elevation_m - (baseAt(offset) + height); - if (!(rise > 1e-6)) return Number.POSITIVE_INFINITY; - const joint = offset - outward * (fillWallBaseWidth(height) / 2 - REVET_TRAP_TOP_M); - return Math.abs(joint - 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; +): number | null { + const span = (limitOffset - edge.offset_m) * outward; + if (!(span > 0)) return null; + const steps = 240; for (let i = 1; i <= steps; i += 1) { - const candidate = startOffset + outward * ((span * i) / steps); - if (ratioAt(candidate) >= FILL_SLOPE_RATIO_MIN) return candidate; + const offset = edge.offset_m + outward * ((span * i) / steps); + const rise = edge.elevation_m - (baseAt(offset) + height); + if (rise < FILL_MIN_RISE_M) continue; + const joint = offset - outward * (fillWallBaseWidth(height) / 2 - REVET_TRAP_TOP_M); + if (((joint - edge.offset_m) * outward) / rise < FILL_SLOPE_RATIO_MIN) continue; + if ((offset - toeOffset) * outward > 1e-6) { + const toeRise = edge.elevation_m - (baseAt(toeOffset) + height); + if (toeRise < FILL_MIN_RISE_M) return null; // 성토 얕음 — 사면 끝 폴백 + } + return offset; } - return startOffset; + return null; +} + +/** 벽의 수직 제원 — 높이·바닥 표고·바닥이 원지반에서 뜬 간격. */ +export interface WallVertical { + height: number; + baseElevation: number; + /** 바닥~원지반 간격(m). 0이면 지반 위에 선다. 뜬 공간은 별도 구조물 예정. */ + floatGapM: number; +} + +/** + * 벽 자리에 맞는 **수직 제원**을 푼다(2026-08-22 사용자 확정 — 최소 노견 유지 규칙). + * 벽 상단(이음선 상단점)은 기본 노견에서 내려오는 **1:1.2 사면선 위**에 놓여야 한다. + * ① 자리가 안쪽이라 표준 높이(관경+여유고)로 사면선에 못 닿으면 → 높이를 키운다. + * ② 형태별 높이 한계(찰 3.0/메 2.0)에 걸리면 → 바닥을 원지반에서 띄운다 + * (뜬 공간은 별도 지지 구조물 — 추가 예정). + * ③ 자리가 바깥이라 사면선이 표준 높이 아래로 지나면 → 표준 높이 유지, 모자란 + * 물매는 `fillSlopeOf`가 노폭 연장으로 맞춘다(기존 규칙). + * 높이는 0.1m 눈금 올림(2026-08-21 확정)이라 상단이 사면선보다 반 눈금쯤 높을 수 있다. + */ +export function solveWallVertical( + anchorOffset: number, + edge: { offset_m: number; elevation_m: number }, + outward: number, + groundBase: number, + minHeight: number, + limitHeight: number, +): WallVertical { + const gridUp = (value: number): number => + Math.ceil(value / REVET_HEIGHT_STEP_M - 1e-9) * REVET_HEIGHT_STEP_M; + let height = minHeight; + let desiredTop = groundBase + minHeight; + for (let i = 0; i < 4; i += 1) { + const joint = anchorOffset - outward * (fillWallBaseWidth(height) / 2 - REVET_TRAP_TOP_M); + const run = Math.max(0, (joint - edge.offset_m) * outward); + desiredTop = edge.elevation_m - run / FILL_SLOPE_RATIO_MIN; + const next = Math.min(Math.max(gridUp(desiredTop - groundBase), minHeight), limitHeight); + if (Math.abs(next - height) < 1e-9) { + height = next; + break; + } + height = next; + } + const floatGap = Math.max(0, desiredTop - limitHeight - groundBase); + return { height, baseElevation: groundBase + floatGap, floatGapM: floatGap }; } diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts index ac18a404..02bf62d4 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts @@ -36,6 +36,12 @@ export interface WallLayout { outerOffset: number; base: number; height: number; + /** + * 바닥이 원지반에서 뜬 간격(m, 2026-08-22 사용자 확정). 손으로 안쪽에 당겨 높이 + * 한계로도 사면선에 못 닿을 때 바닥을 띄워 최소 노견을 확보한다 — 뜬 공간은 + * 별도 지지 구조물로 메운다(추가 예정). 0이면 지반 위(근입 0.5m). + */ + floatGapM: number; outward: number; /** 합성 단면(하부 사다리꼴 + 상부 평행사변형) 꼭짓점 — 데이터 좌표. */ points: OffsetPoint[];