diff --git a/B06_Section/B06_Section_UI_Cross_Culvert.ts b/B06_Section/B06_Section_UI_Cross_Culvert.ts index 77d29334..4a824de8 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert.ts @@ -91,7 +91,11 @@ export function appendCulvertOverlay( ` · 성토사면 경사길이 ${layout.fillSlope.lengthM.toFixed(2)}m` + `(법정 5m ${layout.fillSlope.withinLimit ? "이내 충족" : "초과 — 구조물 보강 필요"})` + ` · 성토 물매 1:${layout.fillSlope.ratio.toFixed(2)}` + - `(허용 1:1.2~2.0${layout.fillSlope.ratioClamped ? " 벗어남" : " 충족"})` + + `(1:1.2 고정${layout.fillSlope.ratioClamped ? " — 벗어남" : ""})` + + (layout.fillSlope.roadWideningM > 0.01 + ? ` · 노폭 연장 ${layout.fillSlope.roadWideningM.toFixed(2)}m` + + `(기슭막이가 밖으로 나간 만큼 노면이 늘어난다)` + : "") + (layout.fillSlope.structureRequired ? " · ⚠ 기슭막이로 성토를 못 받는 자리 — 옹벽·석축 검토(성토_비탈면 §2)" : ""), diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts index 4c240cda..b1447e62 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts @@ -128,7 +128,8 @@ export function computeCulvertLayout( if (!basinReason && designAt) { const inletBaseAt = (offset: number): number => Math.min(groundAt(offset), invertCap(inletInfo.edge)); - // 사면 끝이 기본 자리지만, 그 자리 물매가 1:1.2보다 급하면 규정 위반이라 바깥으로 민다. + // 사면 끝이 기본 자리. 그 자리 물매가 1:1.2보다 급하면 규정 위반이라 바깥으로 민다 + // (완만한 쪽은 노폭 연장으로 맞춘다 — fillSlopeOf). inletAutoOffset = enforceMinFillRatio( inletInfo.edge, inletInfo.outward, @@ -206,8 +207,8 @@ export function computeCulvertLayout( let trimMinElevation: number | null = null; let trimMaxElevation: number | null = null; /** 노견 → 벽 이음선 상단점 성토 사면 구간(단일 각도). 설계선 대신 그린다. */ - let trimMinSlope: { from: OffsetPoint; to: OffsetPoint } | null = null; - let trimMaxSlope: { from: OffsetPoint; to: OffsetPoint } | null = null; + let trimMinSlope: { points: OffsetPoint[] } | null = null; + let trimMaxSlope: { points: OffsetPoint[] } | null = null; const buildWall = ( spec: CulvertSideSpec, anchor: OffsetPoint, @@ -619,13 +620,13 @@ export function computeCulvertLayout( // 사면선은 노견부터 우리가 그린다 — 트림 경계를 노견까지 당겨 백엔드 설계선의 // 성토 구간을 통째로 덮는다(설계선은 1:1.2 고정이라 그대로 두면 다시 어긋난다). if (wall.outward > 0) { - trimMax = slope.from.offset; + trimMax = slope.points[0].offset; trimMaxElevation = null; - trimMaxSlope = { from: slope.from, to: slope.to }; + trimMaxSlope = { points: slope.points }; } else { - trimMin = slope.from.offset; + trimMin = slope.points[0].offset; trimMinElevation = null; - trimMinSlope = { from: slope.from, to: slope.to }; + trimMinSlope = { points: slope.points }; } } const outletWallFinal = walls.find((wall) => wall.role === "outlet") ?? null; @@ -646,6 +647,7 @@ export function computeCulvertLayout( ? outletSlope.ratio : (section.design?.fill_slope_ratio ?? FILL_SLOPE_RATIO_MIN), ratioClamped: outletSlope ? outletSlope.clamped : false, + roadWideningM: outletSlope ? outletSlope.wideningM : 0, // 사면길이 5m 이상 = **기슭막이(구조물) 의무 구간**(성토_비탈면.md §2 — // 2026-08-21 사용자 정정: 5m는 자리 한계가 아니라 의무 발생 기준이다). structureRequired: outletSlope != null && outletSlope.lengthM >= FILL_SLOPE_MAX_LENGTH_M, diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts index e279639d..294bf208 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts @@ -8,7 +8,6 @@ import type { SectionSample } from "./B06_Section_Api_Fetch"; import { FILL_MIN_RISE_M, FILL_SLOPE_MAX_LENGTH_M, - FILL_SLOPE_RATIO_MAX, FILL_SLOPE_RATIO_MIN, REVET_LEAN_RATIO, REVET_THICKNESS_M, @@ -118,76 +117,60 @@ export function slopeToeOffset( return limitOffset; } -/** 노견 → 벽 이음선 상단점 성토 사면 구간(단일 각도)과 그 물매·경고. */ +/** 노견 → (노폭 연장) → 벽 이음선 상단점 성토 사면 구간과 그 제원. */ export interface FillSlopeSegment { ratio: number; lengthM: number; clamped: boolean; fillHeightM: number; - from: OffsetPoint; - to: OffsetPoint; + /** 노폭 연장량(m, 편측). 벽이 1:1.2 사면이 닿는 자리보다 밖에 있으면 그만큼 늘린다. */ + wideningM: number; + /** 그릴 폴리라인 — [원 노견, (연장된 노견), 벽 이음선 상단점]. */ + points: OffsetPoint[]; } /** - * 벽이 선 자리에서 성립하는 성토 사면 구간을 만든다. 벽 자리가 다 굳은 뒤에 불러야 - * 한다 — 관 길이 맞춤(벽 이동)이 끝나기 전에 물매를 잡으면 접점이 다시 어긋난다 - * (2026-08-21 사용자 ② 어긋남의 직접 원인이 이 순서였다). + * 벽이 선 자리에서 성토 사면 구간을 만든다. 물매는 **1:1.2 고정**(수평 1.2 : 수직 1, + * 2026-08-21 사용자 확정)이고, 벽이 그 사면이 닿는 자리보다 밖에 있으면 부족한 만큼 + * **노폭을 늘린다** — 물매를 완만하게 바꾸지 않는다. + * + * 벽 자리가 다 굳은 뒤에 불러야 한다 — 관 길이 맞춤(벽 이동)이 끝나기 전에 잡으면 + * 접점이 다시 어긋난다. */ export function fillSlopeOf( - wall: { role: "inlet" | "outlet"; topJoint: OffsetPoint }, + wall: { role: "inlet" | "outlet"; topJoint: OffsetPoint; outward: number }, edge: { offset_m: number; elevation_m: number }, ): FillSlopeSegment { - const from: OffsetPoint = { offset: edge.offset_m, elevation: edge.elevation_m }; const to = wall.topJoint; - const run = Math.abs(to.offset - from.offset); - const rise = from.elevation - to.elevation; - // 그림과 어긋나지 않도록 **실제 물매**를 그대로 보고하고, 1:1.2~2.0을 벗어났는지만 - // 따로 알린다(끝값으로 바꿔 보고하면 도면과 수치가 갈린다). - const ratio = rise > 1e-6 ? run / rise : Number.POSITIVE_INFINITY; - // 성토고가 0.5m도 안 되면 사면이랄 게 없다 — 물매가 발산해도 경고하지 않는다. - const meaningful = rise >= 0.5; - return { - ratio, - lengthM: Math.hypot(run, rise), - clamped: meaningful && (ratio < FILL_SLOPE_RATIO_MIN || ratio > FILL_SLOPE_RATIO_MAX), - fillHeightM: rise, - from, - to, - }; -} - -/** - * 손으로 민 벽 자리를 **사면이 역전되지 않는 범위**로 되돌린다(2026-08-21 사용자 지적). - * 노견보다 벽 상단이 높아지면 사면선이 위로 올라가 버린다 — 자동 자리에서 목표 자리로 - * 훑으며 성토고가 하한 아래로 떨어지기 직전까지만 간다. - */ -export function clampWallOffset( - autoOffset: number, - wantedOffset: number, - height: number, - edge: { offset_m: number; elevation_m: number }, - baseAt: (offset: number) => number, - outward: number, -): number { - const steps = 40; - // 안쪽으로 당기면 사면이 짧아져 물매가 급해진다 — **1:1.2(가장 급한 한계)**를 넘지 - // 않게 막는다(2026-08-21 사용자 지시). 바깥으로 밀 때는 완만해지므로 여기서 막지 - // 않고(관 길이 조정 여지를 남긴다) 범위 밖이면 경고로만 알린다. - 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; + const rise = edge.elevation_m - to.elevation; + const edgePoint: OffsetPoint = { offset: edge.offset_m, elevation: edge.elevation_m }; + if (!(rise > 1e-6)) { + return { + ratio: FILL_SLOPE_RATIO_MIN, + lengthM: 0, + clamped: false, + fillHeightM: Math.max(rise, 0), + wideningM: 0, + points: [edgePoint, to], + }; } - return allowed; + // 1:1.2 사면이 벽 상단에 닿으려면 노견이 여기까지 나와 있어야 한다. + const neededEdgeOffset = to.offset - wall.outward * FILL_SLOPE_RATIO_MIN * rise; + const widening = Math.max(0, (neededEdgeOffset - edge.offset_m) * wall.outward); + const startOffset = edge.offset_m + wall.outward * widening; + const points: OffsetPoint[] = [edgePoint]; + if (widening > 1e-6) points.push({ offset: startOffset, elevation: edge.elevation_m }); + points.push(to); + const run = Math.abs(to.offset - startOffset); + return { + ratio: run / rise, + lengthM: Math.hypot(run, rise), + // 벽이 안쪽으로 들어와 1:1.2보다 급해진 경우만 경고한다(연장은 정상 동작). + clamped: run / rise < FILL_SLOPE_RATIO_MIN - 1e-6, + fillHeightM: rise, + wideningM: widening, + points, + }; } /** @@ -228,10 +211,42 @@ export function outletSlopeFactory( } /** - * 성토 물매가 **1:1.2보다 급하면** 규정 위반이다(`성토_비탈면.md` §1 — 1:1.2~2.0). - * 사면 끝에 세운 자리가 그렇다면 물매가 1:1.2에 닿을 때까지 **바깥으로** 민다. - * 지반이 1:1.2보다 급해 끝내 못 맞추면 시작 자리를 그대로 돌려준다 — 그때는 - * 기슭막이로 못 받는 자리라 옹벽·석축 검토 대상이고 경고로 알린다. + * 손으로 민 벽 자리를 **성토가 성립하는 범위**로 되돌린다. + * · 벽은 제 노견 바깥에 선다(도로 밑을 파고들면 안 된다) + * · 성토고 하한(FILL_MIN_RISE_M) — 그 아래면 사면선이 역전된다 + * · 안쪽으로 당길 때는 **물매 1:1.2**를 넘지 않는 자리까지만(2026-08-21 사용자 지시). + * 바깥으로 밀 때는 물매가 1:1.2로 고정된 채 노폭이 늘어나므로 막지 않는다. + */ +export function clampWallOffset( + autoOffset: number, + wantedOffset: number, + height: number, + edge: { offset_m: number; elevation_m: number }, + baseAt: (offset: number) => 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; +} + +/** + * 사면 끝 자리의 물매가 **1:1.2보다 급하면** 규정 위반이라 1:1.2가 될 때까지 바깥으로 + * 민다. 반대로 완만한 쪽(1:1.2보다 느슨)은 `fillSlopeOf`가 **노폭을 늘려** 맞추므로 + * 여기서 건드리지 않는다(2026-08-21 사용자 확정 — 물매는 1:1.2 고정). + * 지반이 1:1.2보다 급해 끝내 못 맞추면 시작 자리를 돌려주고 경고로 알린다. */ export function enforceMinFillRatio( edge: { offset_m: number; elevation_m: number }, @@ -241,13 +256,11 @@ export function enforceMinFillRatio( 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; + 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; diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts index 1087b122..ac18a404 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts @@ -18,9 +18,12 @@ export interface CulvertDesignTrim { /** 트림 경계에서 성토 경사선이 **닿아야 할 표고**(= 벽 이음선 상단점). 없으면 설계선 그대로. */ minElevation?: number; maxElevation?: number; - /** 노견 → 벽 이음선 상단점 **성토 사면 구간**. 백엔드 설계선(1:1.2 고정)을 대신해 그린다. */ - minSlope?: { from: OffsetPoint; to: OffsetPoint }; - maxSlope?: { from: OffsetPoint; to: OffsetPoint }; + /** + * 노견 → (노폭 연장) → 벽 이음선 상단점 폴리라인. 물매가 1:1.2 고정이라 벽이 밖으로 + * 나간 만큼 **노폭이 늘어난다** — 그 연장 구간이 가운데 점으로 들어온다. + */ + minSlope?: { points: OffsetPoint[] }; + maxSlope?: { points: OffsetPoint[] }; } export interface WallLayout { @@ -102,6 +105,8 @@ export interface CulvertLayout { ratio: number; /** 물매가 1.2~2.0 범위를 벗어났는지 — 도면 경고용. */ ratioClamped: boolean; + /** 물매 1:1.2를 지키려고 늘린 **노폭 연장량**(m, 편측). 0이면 연장 없음. */ + roadWideningM: number; /** * 기슭막이만으로는 성토를 받을 수 없는 자리(성토고 3m↑인데 사면 5m·물매 1:1.2를 * 동시에 만족하는 벽 자리가 없다) — **옹벽·석축 등 구조물 검토 대상** diff --git a/B06_Section/B06_Section_UI_Cross_Design.ts b/B06_Section/B06_Section_UI_Cross_Design.ts index c548ca60..b06cd9d2 100644 --- a/B06_Section/B06_Section_UI_Cross_Design.ts +++ b/B06_Section/B06_Section_UI_Cross_Design.ts @@ -451,14 +451,8 @@ export function appendCrossDesignOverlay( maxOffset: number; minElevation?: number; maxElevation?: number; - minSlope?: { - from: { offset: number; elevation: number }; - to: { offset: number; elevation: number }; - }; - maxSlope?: { - from: { offset: number; elevation: number }; - to: { offset: number; elevation: number }; - }; + minSlope?: { points: Array<{ offset: number; elevation: number }> }; + maxSlope?: { points: Array<{ offset: number; elevation: number }> }; }, ): void { const line = design.design_line; @@ -604,14 +598,20 @@ export function appendCrossDesignOverlay( // 을 그린다. 벽 자리가 관 길이 맞춤으로 옮겨져도 노견 → 벽 이음선 상단점을 잇는 단일 // 각도가 유지된다(2026-08-21 사용자 ②, 성토 사면은 단일 각도). for (const segment of [trim?.minSlope, trim?.maxSlope]) { - if (!segment) continue; - const seg = document.createElementNS(SVG_NS, "line"); - seg.setAttribute("x1", String(x(segment.from.offset))); - seg.setAttribute("y1", String(toDisplayY(segment.from.elevation))); - seg.setAttribute("x2", String(x(segment.to.offset))); - seg.setAttribute("y2", String(toDisplayY(segment.to.elevation))); - seg.setAttribute("class", "b06-chart__design-cross"); - svg.append(seg); + if (!segment || segment.points.length < 2) continue; + // 폴리라인 — [원 노견, (연장된 노견), 벽 이음선 상단점]. 물매가 1:1.2 고정이라 + // 벽이 밖으로 나간 만큼 노면이 연장된다(2026-08-21 사용자 확정). + for (let i = 1; i < segment.points.length; i += 1) { + const a = segment.points[i - 1]; + const b = segment.points[i]; + const seg = document.createElementNS(SVG_NS, "line"); + seg.setAttribute("x1", String(x(a.offset))); + seg.setAttribute("y1", String(toDisplayY(a.elevation))); + seg.setAttribute("x2", String(x(b.offset))); + seg.setAttribute("y2", String(toDisplayY(b.elevation))); + seg.setAttribute("class", "b06-chart__design-cross"); + svg.append(seg); + } } // 차도·노견 경계 짧은 수직 틱(N-4-2): ±3.6px(기존 ±6의 60%). 노면 단일 기울기라 육안