From a04da05fe1187ae2d5c9b31c2ebb99e9e036d110 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 21 Aug 2026 15:51:42 +0900 Subject: [PATCH] =?UTF-8?q?feat(B06):=20=EB=B0=B0=EC=88=98=EA=B4=80=20?= =?UTF-8?q?=EA=B8=B0=EC=8A=AD=EB=A7=89=EC=9D=B4=20=EC=97=AC=EC=9C=A0?= =?UTF-8?q?=EA=B3=A0=C2=B7=EC=84=B1=ED=86=A0=20=EB=AC=BC=EB=A7=A4=20?= =?UTF-8?q?=EC=9E=AC=EC=84=A4=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기슭막이 높이 = 관경 + 여유고 0.5m (0.1m 눈금 올림). 좌우 여유고 동일. - 관 길이는 절단 긴 변 기준 정수 m. 벽 자리 이동으로 맞추고, 물매·5m 제약에 걸리면 벽 폭으로 흡수(폭은 5m 한계를 넘지 않는 범위까지만). - 성토 물매를 1:1.2 고정에서 1:1.2~2.0 범위(성토_비탈면.md §1)로 전환. 벽 자리가 굳은 뒤 물매를 역산해 사면선이 벽 이음선 상단점을 지나게 한다. 종전에는 관 길이 맞춤이 벽을 옮겨 접점이 깨졌고 스냅으로 가리고 있었다. - 성토 사면선은 단일 각도 직선 하나를 배수관 레이아웃이 직접 공급(설계선 대체). - 벽 자리 탐색 상한 = 성토 사면 끝, 사면길이 5m 초과 전까지. 성토고 3m 이상이면 5m 쪽으로 끌어올린다. - 기슭막이로 성토를 못 받는 자리는 structureRequired로 표시(옹벽·석축 검토). - 700줄 제한으로 Const/Types/Solve 3개 파일 분리. 검증: 계획고 -2.0~+3.0 스윕 24케이스 문제 0 (접점 오차 <1e-6, 여유고 0.5, 5m 초과는 전부 경고). tsc 통과, pytest 148 pass. Co-Authored-By: Claude Opus 5 (1M context) --- B06_Section/B06_Section_UI_Cross_Culvert.ts | 7 +- .../B06_Section_UI_Cross_Culvert_Const.ts | 107 +++ .../B06_Section_UI_Cross_Culvert_Geom.ts | 613 +++++++++--------- .../B06_Section_UI_Cross_Culvert_Solve.ts | 221 +++++++ .../B06_Section_UI_Cross_Culvert_Types.ts | 114 ++++ B06_Section/B06_Section_UI_Cross_Design.ts | 43 +- 6 files changed, 777 insertions(+), 328 deletions(-) create mode 100644 B06_Section/B06_Section_UI_Cross_Culvert_Const.ts create mode 100644 B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts create mode 100644 B06_Section/B06_Section_UI_Cross_Culvert_Types.ts diff --git a/B06_Section/B06_Section_UI_Cross_Culvert.ts b/B06_Section/B06_Section_UI_Cross_Culvert.ts index 45324a1f..a2116e3e 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert.ts @@ -77,7 +77,12 @@ export function appendCulvertOverlay( ` · t=${PITCHING_THICKNESS_M}m(실무 L3=45)` + ` · 시작 변은 기슭막이 전면과 같은 경사, 윗면 시작점 = 관 하단 꼭짓점` + ` · 성토사면 경사길이 ${layout.fillSlope.lengthM.toFixed(2)}m` + - `(법정 5m ${layout.fillSlope.withinLimit ? "이내 충족" : "초과 — 구조물 보강 필요"})`, + `(법정 5m ${layout.fillSlope.withinLimit ? "이내 충족" : "초과 — 구조물 보강 필요"})` + + ` · 성토 물매 1:${layout.fillSlope.ratio.toFixed(2)}` + + `(허용 1:1.2~2.0${layout.fillSlope.ratioClamped ? " 벗어남" : " 충족"})` + + (layout.fillSlope.structureRequired + ? " · ⚠ 기슭막이로 성토를 못 받는 자리 — 옹벽·석축 검토(성토_비탈면 §2)" + : ""), ), ); }; diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts new file mode 100644 index 00000000..6b8fb40a --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts @@ -0,0 +1,107 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Culvert_Const.ts + * 배수관 세트 **치수 상수·형태별 한계** — 기하 계산(`_Cross_Culvert_Geom.ts`)에서 + * 분리했다(700줄 제한). 값의 근거는 각 상수 주석에 지식DB 출처로 남긴다. + * ========================================================================== */ + +/** 관 위 최소 토피(m) — 프론트 정의처(백엔드는 `B06_Section_Engine_Culvert.MIN_PIPE_COVER_M`). + * ⚠ 임도 배수관 토피 규정이 지식DB에 없어 별표2 교량·암거 "복토 50㎝ 이상"을 교차 + * 참조로 채택(2026-08-19 사용자 확정). B05 배수관 측점 계획고 하향 차단 기준이자, + * 절토측 유입 벽에서 관 invert의 상한(노면 − 관경 − 토피)을 정한다. */ +export const MIN_PIPE_COVER_M = 0.5; + +/** 기슭막이 벽 두께(m) — 실무 견치돌 뒷길이 관측치 45㎝(울진 L3=45). 표시용 형상 값. */ +export const REVET_THICKNESS_M = 0.45; + +/** 기슭막이 전면 기울기(1:n) — 돌쌓기 전면 1:0.3(교본 7-3). 벽이 사면 쪽으로 기운다. */ +export const REVET_LEAN_RATIO = 0.3; + +/** 돌붙임 두께(m) — 실무 돌붙임 L3=45(울진 1공구 수량집계표). */ +export const PITCHING_THICKNESS_M = 0.45; + +/** 사다리꼴(배면부) 상단 폭(m) — 띠 두께의 절반(2026-08-20 사용자 확정). */ +export const REVET_TRAP_TOP_M = REVET_THICKNESS_M / 2; + +/** + * 기슭막이 여유고(m) — 관 상단에서 벽 상단까지의 간격. 유입·유출 **공통**이다. + * + * ⚠ 확정값 아님(2026-08-21 사용자 지정). 지식DB에 임도 배수관 기슭막이 여유고 + * 규정이 없다(`통수단면.md` §여유고 — "임도·KDS 원문에 수치 없음"). 관 상단 최소 + * 토피 0.5m와 같은 값을 잠정 채택했고, 추후 **사용자 입력값**으로 교체 예정이다. + * + * 이 값 때문에 벽 높이는 좌·우가 같은 규칙으로 정해진다 — 관이 경사져 벽 밑(invert) + * 이 서로 달라도 관 상단 위 간격은 항상 같다. + */ +export const REVET_FREEBOARD_M = 0.5; + +/** 기슭막이 높이 눈금(m) — 계산 높이를 이 단위로 올린다(2026-08-21 사용자 확정). */ +export const REVET_HEIGHT_STEP_M = 0.1; + +/** 관경에 여유고를 더해 눈금(0.1m)으로 올린 기슭막이 높이(m). */ +export function revetTargetHeight(diameterM: number): number { + const raw = diameterM + REVET_FREEBOARD_M; + return Math.ceil(raw / REVET_HEIGHT_STEP_M - 1e-9) * REVET_HEIGHT_STEP_M; +} + +/** + * 관 벽 두께(m) — 관종별. 지식DB 조회(2026-08-20): + * · 파형강관: 강판 두께 **1.6T~3.2T**(교본 부록 4-6 §2.5.2, 표-1 표준형 강관 치수, + * KS D 3590). Ø1000 이상은 1.6~3.2 전 규격 사용 — 중간값 2.7T 채택. + * · 흄관·VR관(원심력 철근콘크리트관): 관경별 벽 두께 표가 지식DB에 없어 + * 관경의 **1/12**을 적용한다(⚠ 근거 미확정 — KS F 4403 확보 시 교체). + * 도면상 내경·외경 2줄을 긋기 위한 값이다. + */ +export function pipeWallThicknessM(kind: string | null, diameterM: number): number { + if (kind && kind.includes("파형강관")) return 0.0027; + return Math.max(diameterM / 12, 0.05); +} + +/** + * 기슭막이 근입 깊이(m) — 벽 바닥이 원지반 아래로 묻히는 깊이. + * + * 지식DB 조회 결과(2026-08-20): + * · 기슭막이 자체의 근입 깊이 **규정 없음** — 사방교본은 "기초 세굴 위험 시 + * 밑다짐·바닥막이·수제 병행"만 정한다(`00_구조물_선정기준.md` §4). + * · 흙막이: "하부 땅밀림 방지 위해 **본바닥(원지반)에 기초**"(`흙막이.md`) — + * 원지반에 묻어야 한다는 원칙만 있고 수치는 없다. + * · 실무(울진소광 구조도): 돌기슭막이 **기초형 = 기초콘크리트 H=0.5**, + * 깬돌 ℓ3=45·1:0.3 (`_원단위라이브러리_울진소광.md` §4). + * → 실무 기초콘크리트 높이 0.5m를 근입 깊이로 채택한다. 임도 법정 확정값이 아니라 + * **실무 관측 기반**이므로 확정 기준 확보 시 교체한다. + */ +export const REVET_EMBED_DEPTH_M = 0.5; + +/** + * 기슭막이 형태별 높이 한계(m) — 지식DB `돌쌓기.md` §1 / `흙막이.md` §2: + * 돌 **찰쌓기 3.0m 이하**, 돌 **메쌓기 2.0m 이하**(교본 7-3 돌흙막이 기준), + * 돌망태 2m 정도. 큰돌쌓기는 총높이 한계 없음(1일 시공 1.5m 제한만). + * 초과 시 콘크리트 옹벽 등 상위 구조물 검토 대상이다. + */ +export function revetHeightLimit(form: string | null | undefined): number { + if (!form) return 3.0; + if (form.includes("메")) return 2.0; + if (form.includes("찰")) return 3.0; + if (form.includes("돌망태")) return 2.0; + if (form.includes("콘크리트")) return 4.0; // 콘크리트 흙막이 산복기초 4.0m 이하 + return 3.0; +} + +/** + * 성토 비탈 기울기(1:n) 허용 범위 — 지식DB `성토_비탈면.md` §1: + * "**1:1.2~2.0** 범위에서 토질·용수 등 지형여건을 종합 고려해 설정". + * 1.2는 **범위의 급한 끝**이지 고정값이 아니다 — 배수관 구조물이 서는 측점에서는 + * 사면선이 기슭막이 이음선 상단점을 지나도록 이 범위 안에서 물매를 정한다 + * (2026-08-21 사용자 지시). + */ +export const FILL_SLOPE_RATIO_MIN = 1.2; +export const FILL_SLOPE_RATIO_MAX = 2.0; + +/** 성토사면 길이 한계(m) — `성토_비탈면.md` §2 "5m 이내". 초과 시 옹벽·석축 등 의무. */ +export const FILL_SLOPE_MAX_LENGTH_M = 5.0; + +/** + * 성토 전체고(노견 − 원지반) 기준(m) — 이 높이를 넘으면 1:1.2로도 사면이 5m를 넘어 + * 구조물이 필요해진다(3m × √(1+1.2²) = 4.69m, 3.2m에서 5.0m). 기슭막이를 자연 사면 + * 끝이 아니라 **사면길이 5m 지점**으로 끌어올리는 판정에 쓴다(2026-08-21 사용자 지시). + */ +export const FILL_STRUCTURE_HEIGHT_M = 3.0; diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts index 1de1347a..2c821139 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts @@ -21,252 +21,44 @@ * 그리지 않고 집수정 단면(I/ㄴ/ㄷ형)으로 바꾼다. * ========================================================================== */ +import type { CrossSection, CulvertSideSpec, SectionSample } from "./B06_Section_Api_Fetch"; +import { + FILL_SLOPE_MAX_LENGTH_M, + FILL_STRUCTURE_HEIGHT_M, + FILL_SLOPE_RATIO_MAX, + FILL_SLOPE_RATIO_MIN, + MIN_PIPE_COVER_M, + PITCHING_THICKNESS_M, + REVET_EMBED_DEPTH_M, + REVET_LEAN_RATIO, + REVET_THICKNESS_M, + revetHeightLimit, + revetTargetHeight, +} from "./B06_Section_UI_Cross_Culvert_Const"; import type { - CrossSection, - CulvertSet, - CulvertSideSpec, - SectionSample, -} from "./B06_Section_Api_Fetch"; + BasinLayout, + BasinShape, + CulvertLayout, + EndFace, + OffsetPoint, + PipeEnd, + WallLayout, +} from "./B06_Section_UI_Cross_Culvert_Types"; -/** 관 위 최소 토피(m) — 프론트 정의처(백엔드는 `B06_Section_Engine_Culvert.MIN_PIPE_COVER_M`). - * ⚠ 임도 배수관 토피 규정이 지식DB에 없어 별표2 교량·암거 "복토 50㎝ 이상"을 교차 - * 참조로 채택(2026-08-19 사용자 확정). B05 배수관 측점 계획고 하향 차단 기준이자, - * 절토측 유입 벽에서 관 invert의 상한(노면 − 관경 − 토피)을 정한다. */ -export const MIN_PIPE_COVER_M = 0.5; +// 상수·자료형은 분리 파일에 있고, 기존 import 경로를 유지하기 위해 그대로 다시 내보낸다. +export * from "./B06_Section_UI_Cross_Culvert_Const"; +export * from "./B06_Section_UI_Cross_Culvert_Types"; -/** 기슭막이 벽 두께(m) — 실무 견치돌 뒷길이 관측치 45㎝(울진 L3=45). 표시용 형상 값. */ -export const REVET_THICKNESS_M = 0.45; - -/** 기슭막이 전면 기울기(1:n) — 돌쌓기 전면 1:0.3(교본 7-3). 벽이 사면 쪽으로 기운다. */ -export const REVET_LEAN_RATIO = 0.3; - -/** 돌붙임 두께(m) — 실무 돌붙임 L3=45(울진 1공구 수량집계표). */ -export const PITCHING_THICKNESS_M = 0.45; - -/** 사다리꼴(배면부) 상단 폭(m) — 띠 두께의 절반(2026-08-20 사용자 확정). */ -export const REVET_TRAP_TOP_M = REVET_THICKNESS_M / 2; - -/** - * 관 벽 두께(m) — 관종별. 지식DB 조회(2026-08-20): - * · 파형강관: 강판 두께 **1.6T~3.2T**(교본 부록 4-6 §2.5.2, 표-1 표준형 강관 치수, - * KS D 3590). Ø1000 이상은 1.6~3.2 전 규격 사용 — 중간값 2.7T 채택. - * · 흄관·VR관(원심력 철근콘크리트관): 관경별 벽 두께 표가 지식DB에 없어 - * 관경의 **1/12**을 적용한다(⚠ 근거 미확정 — KS F 4403 확보 시 교체). - * 도면상 내경·외경 2줄을 긋기 위한 값이다. - */ -export function pipeWallThicknessM(kind: string | null, diameterM: number): number { - if (kind && kind.includes("파형강관")) return 0.0027; - return Math.max(diameterM / 12, 0.05); -} - -/** - * 기슭막이 근입 깊이(m) — 벽 바닥이 원지반 아래로 묻히는 깊이. - * - * 지식DB 조회 결과(2026-08-20): - * · 기슭막이 자체의 근입 깊이 **규정 없음** — 사방교본은 "기초 세굴 위험 시 - * 밑다짐·바닥막이·수제 병행"만 정한다(`00_구조물_선정기준.md` §4). - * · 흙막이: "하부 땅밀림 방지 위해 **본바닥(원지반)에 기초**"(`흙막이.md`) — - * 원지반에 묻어야 한다는 원칙만 있고 수치는 없다. - * · 실무(울진소광 구조도): 돌기슭막이 **기초형 = 기초콘크리트 H=0.5**, - * 깬돌 ℓ3=45·1:0.3 (`_원단위라이브러리_울진소광.md` §4). - * → 실무 기초콘크리트 높이 0.5m를 근입 깊이로 채택한다. 임도 법정 확정값이 아니라 - * **실무 관측 기반**이므로 확정 기준 확보 시 교체한다. - */ -export const REVET_EMBED_DEPTH_M = 0.5; - -/** - * 기슭막이 형태별 높이 한계(m) — 지식DB `돌쌓기.md` §1 / `흙막이.md` §2: - * 돌 **찰쌓기 3.0m 이하**, 돌 **메쌓기 2.0m 이하**(교본 7-3 돌흙막이 기준), - * 돌망태 2m 정도. 큰돌쌓기는 총높이 한계 없음(1일 시공 1.5m 제한만). - * 초과 시 콘크리트 옹벽 등 상위 구조물 검토 대상이다. - */ -export function revetHeightLimit(form: string | null | undefined): number { - if (!form) return 3.0; - if (form.includes("메")) return 2.0; - if (form.includes("찰")) return 3.0; - if (form.includes("돌망태")) return 2.0; - if (form.includes("콘크리트")) return 4.0; // 콘크리트 흙막이 산복기초 4.0m 이하 - return 3.0; -} - -export interface OffsetPoint { - offset: number; - elevation: number; -} - -/** 설계선(성토 경사) 트림 범위 — 이 밖은 벽·보호공이 대신하므로 그리지 않는다. */ -export interface CulvertDesignTrim { - minOffset: number; - maxOffset: number; -} - -export interface WallLayout { - role: "inlet" | "outlet"; - form: string | null; - lengthM: number | null; - /** 배면(도로측) 바닥 offset. */ - backOffset: number; - /** 전면(계류측) 바닥 offset. */ - outerOffset: number; - base: number; - height: number; - outward: number; - /** 합성 단면(하부 사다리꼴 + 상부 평행사변형) 꼭짓점 — 데이터 좌표. */ - points: OffsetPoint[]; - /** 상단 배면 꼭짓점 — 성토 사면선과의 접점(설계선 트림 경계). */ - topBack: OffsetPoint; -} - -/** - * 유입 집수정 단면 — 실무 도면(2026-08-20 사용자 제공 L형 확대본) 기준. - * · **I형** = 기슭막이와 같은 **평행사변형 벽 1매**만. - * · **ㄴ(L)형** = I형 + **바닥판**. - * · **ㄷ형** = ㄴ형 + I형 **반대측 막음벽**. - * 돌 표현은 재질 확정 시 반영 예정(성토부 기슭막이와 동일 규칙) — 지금은 윤곽만. - */ -export type BasinShape = "I" | "L" | "U"; - -export interface BasinLayout { - shape: BasinShape; - /** 부재별 폴리곤(벽·바닥·막음벽). 각각 분리 도형으로 그린다. */ - parts: Array<{ kind: "wall" | "floor"; points: OffsetPoint[] }>; - label: OffsetPoint; - /** 자동 전환 사유 — 툴팁·라벨에 쓴다. */ - reason: "cut" | "blocked"; - /** 집수정이 원지반 안으로 들어갈 때의 절토선(구조물 바깥 끝 → 지반 교차점). */ - cutLine: { from: OffsetPoint; to: OffsetPoint } | null; -} - -/** 관 끝단을 자를 마감면 — 구조물(기슭막이·집수정)의 계류측 변을 **그대로 복사**한다. */ -export interface EndFace { - base: OffsetPoint; - direction: { offset: number; elevation: number }; -} - -/** 관 한쪽 끝단의 두 꼭짓점(마감면에서 자른 결과). */ -export interface PipeEnd { - bottom: OffsetPoint; - top: OffsetPoint; -} - -/** 유출측 보호공(돌붙임) 띠 — 윗면/밑면 폴리라인 쌍. */ -export interface PitchingLayout { - top: OffsetPoint[]; - base: OffsetPoint[]; - lengthM: number; - /** 최소 보호공 길이(낙차고×2 — 백엔드 `apron_length_m`). */ - minLengthM: number; -} - -export interface CulvertLayout { - culvert: CulvertSet; - pipe: { inlet: OffsetPoint; outlet: OffsetPoint; lengthM: number; slopePct: number }; - /** 관 축·법선 단위벡터(그리기가 두께 방향으로 밀 때 쓴다). */ - pipeAxis: { axis: OffsetPoint; normal: OffsetPoint }; - /** 관 양 끝단 꼭짓점 — 보호공 시작점이 유출 하단 꼭짓점을 그대로 쓴다. */ - pipeCorners: { inlet: PipeEnd; outlet: PipeEnd }; - walls: WallLayout[]; - /** 유출측 성토사면 경사길이(m)와 법정 5m 충족 여부(별표2). */ - fillSlope: { lengthM: number; withinLimit: boolean }; - /** 유출측 보호공 띠(없으면 top/base 빈 배열). */ - pitching: PitchingLayout; - /** 유입 집수정 단면(있을 때만). */ - basin: BasinLayout | null; - designTrim: CulvertDesignTrim | null; -} - -/** 유효 지반 샘플 → offset 오름차순 보간기. 범위 밖은 끝값 클램프, 샘플 없으면 null. */ -function groundInterpolator(samples: SectionSample[]): ((offset: number) => number) | null { - const ground = samples - .filter((s) => s.valid !== false && s.elevation_m !== null && Number.isFinite(s.elevation_m)) - .map((s) => ({ offset: s.offset_m ?? 0, elevation: s.elevation_m as number })) - .sort((a, b) => a.offset - b.offset); - if (!ground.length) return null; - return (offset: number): number => { - if (offset <= ground[0].offset) return ground[0].elevation; - const last = ground[ground.length - 1]; - if (offset >= last.offset) return last.elevation; - for (let i = 1; i < ground.length; i += 1) { - if (offset > ground[i].offset) continue; - const a = ground[i - 1]; - const b = ground[i]; - const span = b.offset - a.offset; - if (span <= 0) return b.elevation; - return a.elevation + (b.elevation - a.elevation) * ((offset - a.offset) / span); - } - return last.elevation; - }; -} - -/** 설계선 보간기(offset 오름차순, 범위 밖 클램프). 설계선이 없으면 null. */ -function designInterpolator( - line: Array<{ offset_m: number; elevation_m: number }> | undefined, -): ((offset: number) => number) | null { - if (!line || line.length < 2) return null; - const sorted = [...line].sort((a, b) => a.offset_m - b.offset_m); - return (offset: number): number => { - if (offset <= sorted[0].offset_m) return sorted[0].elevation_m; - const last = sorted[sorted.length - 1]; - if (offset >= last.offset_m) return last.elevation_m; - for (let i = 1; i < sorted.length; i += 1) { - if (offset > sorted[i].offset_m) continue; - const a = sorted[i - 1]; - const b = sorted[i]; - const span = b.offset_m - a.offset_m; - if (span <= 0) return b.elevation_m; - return a.elevation_m + (b.elevation_m - a.elevation_m) * ((offset - a.offset_m) / span); - } - return last.elevation_m; - }; -} - -/** 두 직선(점 + 방향)의 교점. 거의 나란하면 null. */ -function intersect( - p: OffsetPoint, - d: { offset: number; elevation: number }, - q: OffsetPoint, - u: { offset: number; elevation: number }, -): OffsetPoint | null { - const cross = u.offset * d.elevation - u.elevation * d.offset; - if (Math.abs(cross) < 1e-9) return null; - const t = ((p.offset - q.offset) * d.elevation - (p.elevation - q.elevation) * d.offset) / cross; - return { offset: q.offset + u.offset * t, elevation: q.elevation + u.elevation * t }; -} - -/** 성토사면 길이 한계(m) — **법정**: 별표2 Ⅰ.2.차.(3) "성토사면 길이 5m 이내, 초과 시 - * 옹벽·석축 등 구조물 설치 의무"(지식DB 성토_비탈면.md §2). 유출 벽 자리의 바깥 한계. */ -const FILL_SLOPE_MAX_LENGTH_M = 5.0; - -/** 관 축과 벽 전면이 거의 나란하면 교점이 멀리 발산한다 — 이 거리를 넘으면 축 직각 마감. */ -const STRAY_LIMIT_M = 2.5; - -/** 노견에서 바깥으로 훑어 설계선(사면)이 지반과 만나는 첫 지점(사면 끝)을 찾는다. - * 사면 경사길이 5m 한계(별표2)에 걸리면 그 지점에서 멈춘다 — 거기가 구조물 자리다. */ -function slopeToeOffset( - designAt: (offset: number) => number, - groundAt: (offset: number) => number, - startOffset: number, - limitOffset: number, -): number { - const stepCount = 120; - const step = (limitOffset - startOffset) / stepCount; - if (!Number.isFinite(step) || step === 0) return limitOffset; - let previousDiff = designAt(startOffset) - groundAt(startOffset); - let previousElevation = designAt(startOffset); - let slopeLength = 0; - for (let i = 1; i <= stepCount; i += 1) { - const offset = startOffset + step * i; - const elevation = designAt(offset); - slopeLength += Math.hypot(step, elevation - previousElevation); - previousElevation = elevation; - const diff = elevation - groundAt(offset); - if (previousDiff !== 0 && Math.sign(diff) !== Math.sign(previousDiff)) return offset; - if (Math.abs(diff) < 0.02) return offset; - if (slopeLength >= FILL_SLOPE_MAX_LENGTH_M) return offset; - previousDiff = diff; - } - return limitOffset; -} +import { + fillSlopeOf, + groundInterpolator, + designInterpolator, + intersect, + slopeToeOffset, + solveFillWallOffset, + STRAY_LIMIT_M, +} from "./B06_Section_UI_Cross_Culvert_Solve"; +import type { FillSlopeSegment, FillWallPlacement } from "./B06_Section_UI_Cross_Culvert_Solve"; /** 배수관 세트 기하 계산. 부족한 입력이면 null — 그리기와 분리(설계선 트림이 먼저 쓴다). */ export function computeCulvertLayout( @@ -303,43 +95,6 @@ export function computeCulvertLayout( const inletInvert = Math.min(groundAt(inletInfo.edge.offset_m), invertCap(inletInfo.edge)); const inlet: OffsetPoint = { offset: inletInfo.edge.offset_m, elevation: inletInvert }; - // 유출 목표점 = 유출측 성토사면이 지반과 만나는 사면 끝(경사길이 5m 한계 — 별표2). - // 그 자리가 유출 기슭막이 자리이고, invert는 원지반(관 끝이 원지반 위 — 실무 도면). - const outletAnchorOffset = designAt - ? slopeToeOffset(designAt, groundAt, outletInfo.edge.offset_m, outletInfo.limit) - : outletInfo.edge.offset_m; - // 성토사면 경사길이 실측(노견 → 사면 끝) — 법정 5m 이내 충족 확인용(사용자 ①). - let fillSlopeLength = 0; - if (designAt) { - const from = outletInfo.edge.offset_m; - const steps = 60; - let prevElev = designAt(from); - for (let i = 1; i <= steps; i += 1) { - const o = from + ((outletAnchorOffset - from) * i) / steps; - const e = designAt(o); - fillSlopeLength += Math.hypot((outletAnchorOffset - from) / steps, e - prevElev); - prevElev = e; - } - } - // 역경사는 수평으로 클램프(수평 가능 — 사용자 확정). - const outletInvert = Math.min(groundAt(outletAnchorOffset), inletInvert); - const outletAnchor: OffsetPoint = { offset: outletAnchorOffset, elevation: outletInvert }; - - const run0 = outletAnchor.offset - inlet.offset; - const rise0 = outletAnchor.elevation - inlet.elevation; - const length0 = Math.hypot(run0, rise0); - if (!(length0 > 0.5)) return null; - // 관은 m 단위 설치 — 올림 연장분은 유출 쪽으로(기슭막이를 조금 벗어나도 무관, - // 2026-08-20 사용자 확정). 실무 표기 L=10~12m대가 여기서 나온다. - let lengthM = Math.ceil(length0 - 1e-6); - const scale = lengthM / length0; - const outlet: OffsetPoint = { - offset: inlet.offset + run0 * scale, - elevation: inlet.elevation + rise0 * scale, - }; - const span = Math.abs(outletAnchor.offset - inlet.offset); - const slopePct = span > 0 ? ((inletInvert - outletInvert) / span) * 100 : 0; - // 유입측 절토 판정: 절토측 유입은 자연스럽게 집수정(ㄴ형 기본)이 된다. const mode = section.design?.section_mode; const inletSideName: "left" | "right" = uphill === "left" ? "left" : "right"; @@ -359,6 +114,66 @@ export function computeCulvertLayout( ? "blocked" : null; + // 성토측 유입 기슭막이는 노견에 붙여 두지 않는다 — 사면선이 벽 이음선 상단점을 지나야 + // 하므로 자리를 풀어서 정한다(2026-08-21 사용자 ②). 집수정(절토·막힘)은 측구부 자리 + // 그대로 둔다. 벽 높이는 관경 + 여유고(0.1m 눈금 올림). + const wallHeightFor = (spec: CulvertSideSpec): number => + Math.min(revetTargetHeight(diameter), revetHeightLimit(spec.revet_form)); + let inletPlacement: FillWallPlacement | null = null; + if (!basinReason && designAt) { + // 훑는 범위는 **성토 사면 끝까지**다 — 그 너머는 받칠 성토가 없다. 성토가 얕아 벽 + // 상단이 노견보다 높은 구간에서는 물매가 성립하지 않는데, 한계를 지반 샘플 끝까지 + // 두면 벽이 몇 m 밖으로 밀려난다(계획고 스윕에서 확인). + inletPlacement = solveFillWallOffset( + inletInfo.edge, + inletInfo.outward, + wallHeightFor(culvert.inlet), + (offset) => Math.min(groundAt(offset), invertCap(inletInfo.edge)), + slopeToeOffset(designAt, groundAt, inletInfo.edge.offset_m, inletInfo.limit), + ); + if (inletPlacement) { + inlet.offset = inletPlacement.offset; + inlet.elevation = Math.min(groundAt(inletPlacement.offset), invertCap(inletInfo.edge)); + } + } + + // 유출 목표점 = 유출측 성토사면이 지반과 만나는 사면 끝(경사길이 5m 한계 — 별표2). + // 그 자리가 유출 기슭막이 자리이고, invert는 원지반(관 끝이 원지반 위 — 실무 도면). + const outletAnchorOffset = designAt + ? slopeToeOffset(designAt, groundAt, outletInfo.edge.offset_m, outletInfo.limit) + : outletInfo.edge.offset_m; + // 성토사면 경사길이 실측(노견 → 사면 끝) — 법정 5m 이내 충족 확인용(사용자 ①). + let fillSlopeLength = 0; + if (designAt) { + const from = outletInfo.edge.offset_m; + const steps = 60; + let prevElev = designAt(from); + for (let i = 1; i <= steps; i += 1) { + const o = from + ((outletAnchorOffset - from) * i) / steps; + const e = designAt(o); + fillSlopeLength += Math.hypot((outletAnchorOffset - from) / steps, e - prevElev); + prevElev = e; + } + } + // 역경사는 수평으로 클램프(수평 가능 — 사용자 확정). + const outletInvert = Math.min(groundAt(outletAnchorOffset), inlet.elevation); + const outletAnchor: OffsetPoint = { offset: outletAnchorOffset, elevation: outletInvert }; + + const run0 = outletAnchor.offset - inlet.offset; + const rise0 = outletAnchor.elevation - inlet.elevation; + const length0 = Math.hypot(run0, rise0); + if (!(length0 > 0.5)) return null; + // 관은 m 단위 설치 — 올림 연장분은 유출 쪽으로(기슭막이를 조금 벗어나도 무관, + // 2026-08-20 사용자 확정). 실무 표기 L=10~12m대가 여기서 나온다. + let lengthM = Math.ceil(length0 - 1e-6); + const scale = lengthM / length0; + const outlet: OffsetPoint = { + offset: inlet.offset + run0 * scale, + elevation: inlet.elevation + rise0 * scale, + }; + const span = Math.abs(outletAnchor.offset - inlet.offset); + let slopePct = span > 0 ? ((inlet.elevation - outletInvert) / span) * 100 : 0; + // 기슭막이 합성 단면(사용자 ①·②): 하부 = 배면 수직·전면 1:0.3 **사다리꼴**(관이 // 지나는 관경 높이), 상부 = 전·배면이 나란히 기운 **평행사변형**. 상단 배면 꼭짓점이 // 성토 사면선(설계선)과 만나는 접점이 되도록 높이를 정한다 — 사면선은 거기서 끊긴다. @@ -373,11 +188,20 @@ export function computeCulvertLayout( }; let trimMin = Number.NEGATIVE_INFINITY; let trimMax = Number.POSITIVE_INFINITY; + // 트림 경계에서 성토 경사선 끝단이 **닿아야 할 표고** — 벽 높이를 관경+여유고로 고정한 + // 뒤로는 설계 사면선이 벽 상단과 어긋나므로, 사면선 끝을 이 점까지 내려 맞춘다 + // (2026-08-21 사용자 ② — 평행사변형·사다리꼴 교차 두 점 중 **상단** 교차점). + 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; const buildWall = ( spec: CulvertSideSpec, anchor: OffsetPoint, outward: number, forceBasinReason: BasinLayout["reason"] | null, + thickness: number = REVET_THICKNESS_M, ): WallLayout | null => { const reason: BasinLayout["reason"] | null = forceBasinReason ?? (spec.structure === "집수정" ? "cut" : null); @@ -487,8 +311,13 @@ export function computeCulvertLayout( // 성토 사면선은 **벽 상단 도로측 꼭지점(교점)**에서 끊는다 — 바닥 끝을 기준으로 // 두면 사면선이 집수정을 뚫고 들어간다(2026-08-20 사용자 ①). const wallTopInner = wallBase - outward * REVET_LEAN_RATIO * wallHeight; - if (outward > 0) trimMax = Math.min(trimMax, wallTopInner); - else trimMin = Math.max(trimMin, wallTopInner); + if (outward > 0) { + trimMax = wallTopInner; + trimMaxElevation = anchor.elevation + wallHeight; + } else { + trimMin = wallTopInner; + trimMinElevation = anchor.elevation + wallHeight; + } // 관 유입 끝단 마감면 = 집수정 벽의 **계류측 변**을 그대로 복사(사용자 ③). const faceBottom = { offset: wallBase + outward * REVET_THICKNESS_M, @@ -517,18 +346,12 @@ export function computeCulvertLayout( // **전면(계류측)은 1:0.3 기운 평행사변형 띠**. 띠 안쪽 평행선과 수직 배면 사이가 // 사다리꼴 — 합집합의 내부 대각 이음선은 배면 상단에서 바닥으로 내려온다. // 바닥이 넓고 상단이 좁다(상단 폭 = 띠 두께 0.45). - // 높이: **이음선 상단점(평행사변형·사다리꼴 교차점)**이 성토 경사선과 교차하는 - // 지점까지(2026-08-20 사용자 스케치 — 빨간 점). 하한은 관경 + 돌 한 단(0.45) — - // 관이 벽을 관통하므로 벽은 최소한 관을 덮어야 한다(사면 끝의 유출 벽은 접점 - // 높이가 0이라 하한이 실제 높이가 된다). 옵션 높이는 상한. - const minHeight = diameter + REVET_THICKNESS_M; - const topJoint = anchor.offset + outward * REVET_TRAP_TOP_M; - let height = designAt - ? Math.max(designAt(topJoint) - anchor.elevation, minHeight) - : (spec.revet_height_m ?? minHeight); - if (spec.revet_height_m != null) height = Math.min(height, spec.revet_height_m); + // 높이: **관경 + 여유고**를 0.1m 눈금으로 올린 값(2026-08-21 사용자 확정). 관이 + // 물매를 두어 좌·우 invert가 달라도 **관 상단 위 여유고는 같아야** 한다. 사면선은 + // 높이를 정하지 않고 벽이 설 **자리**만 정한다(`solveFillWallOffset`). + const topJoint = anchor.offset + outward * (thickness / 2); // 형태별 법정·교본 높이 한계(찰 3.0 / 메 2.0 — 돌쌓기.md §1)를 넘지 못한다. - height = Math.min(height, revetHeightLimit(spec.revet_form)); + const height = Math.min(revetTargetHeight(diameter), revetHeightLimit(spec.revet_form)); if (!(height > 0.05)) return null; const topBack: OffsetPoint = { offset: anchor.offset, @@ -537,9 +360,9 @@ export function computeCulvertLayout( // 상단 변: 사다리꼴 상단(0.45) + 띠 상단(0.45). 이음선은 상단 변 중간점(topJoint) // 에서 1:0.3으로 바닥까지 — 사다리꼴(수직 배면, 상단 0.45 → 하단 0.45+0.3H)과 // 평행사변형 띠(폭 0.45)가 진짜 사다리꼴·평행사변형이 된다. - const topFront = topJoint + outward * REVET_THICKNESS_M; + const topFront = topJoint + outward * thickness; const jointBase = topJoint + outward * REVET_LEAN_RATIO * height; - const frontBase = jointBase + outward * REVET_THICKNESS_M; + const frontBase = jointBase + outward * thickness; // 근입: 벽 바닥은 원지반 아래 REVET_EMBED_DEPTH_M만큼 묻힌다(흙막이 "본바닥에 // 기초" 원칙 + 실무 기초콘크리트 H=0.5). 바닥 꼭짓점 2개만 내리고 상단·이음선· // 관 위치는 그대로 — 관은 여전히 원지반 높이로 지난다. @@ -554,6 +377,7 @@ export function computeCulvertLayout( height, outward, topBack, + topJoint: { offset: topJoint, elevation: anchor.elevation + height }, points: [ { offset: anchor.offset, elevation: embedBase }, topBack, @@ -562,9 +386,14 @@ export function computeCulvertLayout( ], }; walls.push(wall); - // 성토 사면선은 **이음선 상단점**에서 끊는다(교차점 = 트림 경계 — 사용자 스케치). - if (outward > 0) trimMax = Math.min(trimMax, topJoint); - else trimMin = Math.max(trimMin, topJoint); + // 성토 사면선은 **이음선 상단점**에서 끊고, 끝단 표고도 그 점에 맞춘다(사용자 ②). + if (outward > 0) { + trimMax = topJoint; + trimMaxElevation = anchor.elevation + height; + } else { + trimMin = topJoint; + trimMinElevation = anchor.elevation + height; + } // 관 끝단 마감면 = 기슭막이의 **계류측 변**(전면)을 그대로 복사(사용자 ③). endFaces[spec.role] = { base: { offset: frontBase, elevation: embedBase }, @@ -576,32 +405,33 @@ export function computeCulvertLayout( return wall; }; buildWall(culvert.inlet, inlet, inletInfo.outward, basinReason); - // 유출 벽 자리: 사면 끝에서는 사면선이 벽 상단보다 낮아 교차점이 성립하지 않는다. - // 관 축을 따라 노견 쪽으로 되짚으며 **이음선 상단점(base + 하한높이)이 사면선과 - // 만나는 지점**을 찾는다 — 관·보호공 끝(anchor)은 그대로 두고 벽만 그 자리에 선다. + // 유출 벽 자리 — 유입과 같은 규칙(사면선이 벽 이음선 상단점을 지나는 자리, 물매는 + // 1:1.2~2.0 범위에서 역산). 벽 밑은 관 축을 따라간 invert다 — 관이 벽을 관통하므로 + // 벽 바닥은 관 자리를 따라야 한다. + const axisRun0 = outletAnchor.offset - inlet.offset; + const axisRise0 = outletAnchor.elevation - inlet.elevation; + const invertAt = (offset: number): number => + Math.abs(axisRun0) > 1e-9 + ? inlet.elevation + axisRise0 * ((offset - inlet.offset) / axisRun0) + : inlet.elevation; let outletWallAnchor = outletAnchor; + let outletPlacement: FillWallPlacement | null = null; if (designAt) { - const axisRun = outletAnchor.offset - inlet.offset; - const axisRise = outletAnchor.elevation - inlet.elevation; - const invertAt = (offset: number): number => - Math.abs(axisRun) > 1e-9 - ? inlet.elevation + axisRise * ((offset - inlet.offset) / axisRun) - : inlet.elevation; - const minWallHeight = diameter + REVET_THICKNESS_M; - const start = outletInfo.edge.offset_m; - const steps = 80; - for (let i = 0; i <= steps; i += 1) { - const offset = start + ((outletAnchor.offset - start) * i) / steps; - const invert = invertAt(offset); - const jointOffset = offset + outletInfo.outward * REVET_TRAP_TOP_M; - // 사면선고 − invert가 하한 높이까지 내려온 첫 지점 = 교차 성립 자리. - if (designAt(jointOffset) - invert <= minWallHeight + 1e-6) { - outletWallAnchor = { offset, elevation: invert }; - break; - } + outletPlacement = solveFillWallOffset( + outletInfo.edge, + outletInfo.outward, + wallHeightFor(culvert.outlet), + invertAt, + outletAnchorOffset, + ); + if (outletPlacement) { + outletWallAnchor = { + offset: outletPlacement.offset, + elevation: invertAt(outletPlacement.offset), + }; } } - const outletWall = buildWall(culvert.outlet, outletWallAnchor, outletInfo.outward, null); + let outletWall = buildWall(culvert.outlet, outletWallAnchor, outletInfo.outward, null); // 유출 벽이 사면 끝보다 안쪽으로 당겨졌으면 **관 끝도 그 자리에 맞춘다** — 관만 사면 // 끝까지 남으면 벽과 떨어져 마감면 교차가 실패한다(2026-08-20 사용자 ② 원인). if (outletWall && Math.abs(outletWallAnchor.offset - outletAnchor.offset) > 0.05) { @@ -649,11 +479,101 @@ export function computeCulvertLayout( STRAY_LIMIT_M; return strayed ? fallback : { bottom, top }; }; - const pipeCorners = { + let pipeCorners = { inlet: endCorners("inlet", pipeStart), outlet: endCorners("outlet", outlet), }; + // ── 관 길이 m 단위 맞춤(2026-08-21 사용자 ①). 관 끝은 기운 벽 전면으로 잘려 **상단·하단 + // 길이가 다르다** — 긴 변을 기준으로 올림해 정수 m로 잡고, 모자란 만큼 **유출 벽 자리** + // 를 관 축 방향 바깥으로 민다(벽 두께는 0.45 고정 — 사용자 확정). 벽 상단이 사면선과 + // 벌어지는 문제는 설계선 트림이 벽 상단을 따라오므로 생기지 않는다(사용자 ②). + const cutLength = (corners: { inlet: PipeEnd; outlet: PipeEnd }): number => + Math.max( + Math.hypot( + corners.outlet.bottom.offset - corners.inlet.bottom.offset, + corners.outlet.bottom.elevation - corners.inlet.bottom.elevation, + ), + Math.hypot( + corners.outlet.top.offset - corners.inlet.top.offset, + corners.outlet.top.elevation - corners.inlet.top.elevation, + ), + ); + /** 그 자리에 벽을 세웠을 때의 성토 사면 — 물매(1:n)와 사면길이(노견 → 이음선 상단점). */ + const outletSlopeAt = (offset: number, thickness: number): { ratio: number; lengthM: number } => { + const joint = offset + outletInfo.outward * (thickness / 2); + const rise = outletInfo.edge.elevation_m - (invertAt(offset) + wallHeightFor(culvert.outlet)); + const run = Math.abs(joint - outletInfo.edge.offset_m); + return { + ratio: rise > 1e-6 ? run / rise : Number.POSITIVE_INFINITY, + lengthM: Math.hypot(run, Math.max(rise, 0)), + }; + }; + /** 유출 벽 두께 — 자리 이동만으로 관 길이를 정수로 못 맞출 때만 0.45에서 벗어난다. */ + let outletThickness = REVET_THICKNESS_M; + if (outletWall) { + const target = Math.ceil(cutLength(pipeCorners) - 1e-6); + const rebuild = (): void => { + walls.pop(); + outletWall = buildWall( + culvert.outlet, + outletWallAnchor, + outletInfo.outward, + null, + outletThickness, + ); + outlet.offset = pipeStart.offset + axis.offset * target; + outlet.elevation = pipeStart.elevation + axis.elevation * target; + pipeCorners = { + inlet: endCorners("inlet", pipeStart), + outlet: endCorners("outlet", outlet), + }; + }; + for (let pass = 0; pass < 8; pass += 1) { + const gap = target - cutLength(pipeCorners); + if (Math.abs(gap) < 0.005) break; + // ① 벽 자리를 관 축 방향으로 gap 만큼 옮긴다(두께 불변 — 사용자 확정 우선순위). + const moved = outletWallAnchor.offset + axis.offset * gap; + const movedSlope = outletSlopeAt(moved, outletThickness); + if ( + 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(); + if (!outletWall) break; + continue; + } + // ② 자리를 더 옮기면 성토 물매가 1:1.2~2.0을 벗어나거나 사면이 5m를 넘는다 — + // 자리는 그대로 두고 **벽 폭**으로 나머지를 흡수한다(2026-08-21 사용자 폴백). + // 전면(관을 자르는 변)은 두께를 t 늘리면 t의 1.5배만큼 바깥으로 간다 + // (이음선 t/2 + 전면 t). 관 축에 투영한 몫만 길이에 반영된다. + const grip = Math.max(Math.abs(axis.offset), 0.2) * 1.5; + const wanted = outletThickness + gap / grip; + let capped = Math.min(Math.max(wanted, REVET_THICKNESS_M * 0.6), REVET_THICKNESS_M * 2.5); + // 폭을 넓히면 이음선도 바깥으로 밀려 사면이 길어진다 — **5m 한계가 우선**이라 + // 넘는 몫은 포기한다(별표2). 그 경우 관은 표기 길이보다 짧게 벽을 조금 벗어난다 + // (2026-08-20 확정 규칙 그대로). + while ( + capped > outletThickness && + outletSlopeAt(outletWallAnchor.offset, capped).lengthM > FILL_SLOPE_MAX_LENGTH_M + ) { + capped -= 0.01; + } + if (capped <= outletThickness + 1e-4) break; + outletThickness = capped; + rebuild(); + if (!outletWall) break; + } + lengthM = target; + slopePct = + Math.abs(outlet.offset - pipeStart.offset) > 1e-9 + ? ((pipeStart.elevation - outlet.elevation) / Math.abs(outlet.offset - pipeStart.offset)) * + 100 + : 0; + } + // 보호공(돌붙임) — 유출측. 2026-08-20 사용자 ①·②: // ① 시작 변은 **기슭막이 전면과 같은 경사**(밑면을 벽 전면 방향으로 민다), // 윗면 시작점 = **관 하단 꼭짓점**(관 밑면과 한 점에서 만난다). @@ -707,6 +627,28 @@ export function computeCulvertLayout( } } + // ── 성토 사면 구간 확정. 벽 자리가 다 굳은 뒤에 물매를 역산해야 관 길이 맞춤(벽 이동) + // 이 접점을 다시 깨뜨리지 않는다 — 종전 어긋남의 직접 원인이 이 순서였다. + // 사면은 **단일 각도**(2026-08-21 사용자)라 노견 → 벽 이음선 상단점 직선 하나다. + const slopeOf = (wall: WallLayout): FillSlopeSegment => + fillSlopeOf(wall, wall.role === "inlet" ? inletInfo.edge : outletInfo.edge); + for (const wall of walls) { + const slope = slopeOf(wall); + // 사면선은 노견부터 우리가 그린다 — 트림 경계를 노견까지 당겨 백엔드 설계선의 + // 성토 구간을 통째로 덮는다(설계선은 1:1.2 고정이라 그대로 두면 다시 어긋난다). + if (wall.outward > 0) { + trimMax = slope.from.offset; + trimMaxElevation = null; + trimMaxSlope = { from: slope.from, to: slope.to }; + } else { + trimMin = slope.from.offset; + trimMinElevation = null; + trimMinSlope = { from: slope.from, to: slope.to }; + } + } + const outletWallFinal = walls.find((wall) => wall.role === "outlet") ?? null; + const outletSlope = outletWallFinal ? slopeOf(outletWallFinal) : null; + return { culvert, pipe: { inlet: pipeStart, outlet, lengthM, slopePct }, @@ -714,8 +656,21 @@ export function computeCulvertLayout( pipeCorners, walls, fillSlope: { - lengthM: fillSlopeLength, - withinLimit: fillSlopeLength <= FILL_SLOPE_MAX_LENGTH_M + 1e-6, + lengthM: outletSlope ? outletSlope.lengthM : fillSlopeLength, + withinLimit: outletSlope + ? outletSlope.lengthM <= FILL_SLOPE_MAX_LENGTH_M + 1e-6 + : fillSlopeLength <= FILL_SLOPE_MAX_LENGTH_M + 1e-6, + ratio: outletSlope + ? outletSlope.ratio + : (section.design?.fill_slope_ratio ?? FILL_SLOPE_RATIO_MIN), + ratioClamped: outletSlope ? outletSlope.clamped : false, + // 벽 자리 풀이가 아예 실패했거나(성토고가 사면 5m 안에 안 들어옴), 성토고 3m 이상 + // 인데 물매가 1:1.2보다 급하면 기슭막이로는 못 받는다 — 옹벽·석축 검토 대상. + structureRequired: + outletPlacement === null || + (outletSlope != null && + outletSlope.fillHeightM >= FILL_STRUCTURE_HEIGHT_M && + outletSlope.ratio < FILL_SLOPE_RATIO_MIN), }, pitching: { top: pitchingTop, @@ -724,6 +679,16 @@ export function computeCulvertLayout( minLengthM: minPitchingM, }, basin, - designTrim: walls.length || basin ? { minOffset: trimMin, maxOffset: trimMax } : null, + designTrim: + walls.length || basin + ? { + minOffset: trimMin, + maxOffset: trimMax, + ...(trimMinElevation != null ? { minElevation: trimMinElevation } : {}), + ...(trimMaxElevation != null ? { maxElevation: trimMaxElevation } : {}), + ...(trimMinSlope ? { minSlope: trimMinSlope } : {}), + ...(trimMaxSlope ? { maxSlope: trimMaxSlope } : {}), + } + : null, }; } diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts new file mode 100644 index 00000000..83b9e717 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts @@ -0,0 +1,221 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Culvert_Solve.ts + * 배수관 세트 기하의 **보조 풀이** — 보간기·직선 교점·사면 끝 탐색·성토측 구조물 + * 자리 풀이. 본체(`_Cross_Culvert_Geom.ts`)에서 분리했다(700줄 제한). + * ========================================================================== */ + +import type { SectionSample } from "./B06_Section_Api_Fetch"; +import { + FILL_SLOPE_MAX_LENGTH_M, + FILL_SLOPE_RATIO_MAX, + FILL_SLOPE_RATIO_MIN, + FILL_STRUCTURE_HEIGHT_M, + REVET_TRAP_TOP_M, +} from "./B06_Section_UI_Cross_Culvert_Const"; +import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types"; + +/** 유효 지반 샘플 → offset 오름차순 보간기. 범위 밖은 끝값 클램프, 샘플 없으면 null. */ +export function groundInterpolator(samples: SectionSample[]): ((offset: number) => number) | null { + const ground = samples + .filter((s) => s.valid !== false && s.elevation_m !== null && Number.isFinite(s.elevation_m)) + .map((s) => ({ offset: s.offset_m ?? 0, elevation: s.elevation_m as number })) + .sort((a, b) => a.offset - b.offset); + if (!ground.length) return null; + return (offset: number): number => { + if (offset <= ground[0].offset) return ground[0].elevation; + const last = ground[ground.length - 1]; + if (offset >= last.offset) return last.elevation; + for (let i = 1; i < ground.length; i += 1) { + if (offset > ground[i].offset) continue; + const a = ground[i - 1]; + const b = ground[i]; + const span = b.offset - a.offset; + if (span <= 0) return b.elevation; + return a.elevation + (b.elevation - a.elevation) * ((offset - a.offset) / span); + } + return last.elevation; + }; +} + +/** 설계선 보간기(offset 오름차순, 범위 밖 클램프). 설계선이 없으면 null. */ +export function designInterpolator( + line: Array<{ offset_m: number; elevation_m: number }> | undefined, +): ((offset: number) => number) | null { + if (!line || line.length < 2) return null; + const sorted = [...line].sort((a, b) => a.offset_m - b.offset_m); + return (offset: number): number => { + if (offset <= sorted[0].offset_m) return sorted[0].elevation_m; + const last = sorted[sorted.length - 1]; + if (offset >= last.offset_m) return last.elevation_m; + for (let i = 1; i < sorted.length; i += 1) { + if (offset > sorted[i].offset_m) continue; + const a = sorted[i - 1]; + const b = sorted[i]; + const span = b.offset_m - a.offset_m; + if (span <= 0) return b.elevation_m; + return a.elevation_m + (b.elevation_m - a.elevation_m) * ((offset - a.offset_m) / span); + } + return last.elevation_m; + }; +} + +/** 두 직선(점 + 방향)의 교점. 거의 나란하면 null. */ +export function intersect( + p: OffsetPoint, + d: { offset: number; elevation: number }, + q: OffsetPoint, + u: { offset: number; elevation: number }, +): OffsetPoint | null { + const cross = u.offset * d.elevation - u.elevation * d.offset; + if (Math.abs(cross) < 1e-9) return null; + const t = ((p.offset - q.offset) * d.elevation - (p.elevation - q.elevation) * d.offset) / cross; + return { offset: q.offset + u.offset * t, elevation: q.elevation + u.elevation * t }; +} + +/** 성토사면 길이 한계(m) — **법정**: 별표2 Ⅰ.2.차.(3) "성토사면 길이 5m 이내, 초과 시 + * 옹벽·석축 등 구조물 설치 의무"(지식DB 성토_비탈면.md §2). 유출 벽 자리의 바깥 한계. */ + +/** 관 축과 벽 전면이 거의 나란하면 교점이 멀리 발산한다 — 이 거리를 넘으면 축 직각 마감. */ +export const STRAY_LIMIT_M = 2.5; + +/** 노견에서 바깥으로 훑어 설계선(사면)이 지반과 만나는 첫 지점(사면 끝)을 찾는다. + * 사면 경사길이 5m 한계(별표2)에 걸리면 그 지점에서 멈춘다 — 거기가 구조물 자리다. */ +export function slopeToeOffset( + designAt: (offset: number) => number, + groundAt: (offset: number) => number, + startOffset: number, + limitOffset: number, +): number { + const stepCount = 120; + const step = (limitOffset - startOffset) / stepCount; + if (!Number.isFinite(step) || step === 0) return limitOffset; + let previousDiff = designAt(startOffset) - groundAt(startOffset); + let previousElevation = designAt(startOffset); + let slopeLength = 0; + for (let i = 1; i <= stepCount; i += 1) { + const offset = startOffset + step * i; + const elevation = designAt(offset); + slopeLength += Math.hypot(step, elevation - previousElevation); + previousElevation = elevation; + const diff = elevation - groundAt(offset); + if (previousDiff !== 0 && Math.sign(diff) !== Math.sign(previousDiff)) return offset; + if (Math.abs(diff) < 0.02) return offset; + if (slopeLength >= FILL_SLOPE_MAX_LENGTH_M) return offset; + previousDiff = diff; + } + return limitOffset; +} + +/** 성토측 구조물 자리 풀이 결과 — 벽 offset과 그 자리에서 성립하는 사면 물매. */ +export interface FillWallPlacement { + offset: number; + /** 사면선이 벽 이음선 상단점을 지나는 물매(1:n). 범위 밖이면 끝값으로 고정된 값. */ + ratio: number; + slopeLengthM: number; + withinLimit: boolean; + ratioClamped: boolean; + /** 그 자리의 성토 전체고(노견 − 벽 상단). 3m 이상이면 벽을 5m 쪽으로 끌어올린다. */ + fillHeightM: number; +} + +/** + * 성토측 기슭막이 자리 풀이 — 노견에서 바깥으로 훑으며 **성토 사면선이 벽 이음선 + * 상단점(평행사변형↔사다리꼴 상단 교차점)을 지나는** 자리를 찾는다. + * + * 종전에는 사면선(백엔드 1:1.2 고정)과의 교차로 벽 **높이**를 정했으나, 높이가 + * 관경+여유고로 고정된 뒤로는 그 방식이 성립하지 않는다. 물매는 고정값이 아니라 + * **1:1.2~2.0 범위**(`성토_비탈면.md` §1)이므로, 벽 자리를 정하면 물매가 따라 정해진다. + * 노견에서 멀어질수록 물매는 급 → 완만으로 단조 증가하므로 **범위에 처음 드는 자리** + * (= 사면이 가장 짧은 1:1.2 쪽)를 고른다. 사면길이 5m(별표2)를 넘기 전까지만 훑는다. + * + * 범위 안에 드는 자리가 없으면(급경사 지형) 5m 한계 직전 자리를 쓰고 물매를 끝값으로 + * 고정한다 — `ratioClamped`로 알린다. + */ +export function solveFillWallOffset( + edge: { offset_m: number; elevation_m: number }, + outward: number, + height: number, + baseAt: (offset: number) => number, + limitOffset: number, +): FillWallPlacement | null { + const span = (limitOffset - edge.offset_m) * outward; + if (!(span > 0)) return null; + const steps = 400; + const inRange: FillWallPlacement[] = []; + let last: FillWallPlacement | null = null; + for (let i = 0; i <= steps; i += 1) { + const offset = edge.offset_m + outward * ((span * i) / steps); + const joint = offset + outward * REVET_TRAP_TOP_M; + const rise = edge.elevation_m - (baseAt(offset) + height); + if (!(rise > 1e-6)) continue; + const run = Math.abs(joint - edge.offset_m); + const slopeLengthM = Math.hypot(run, rise); + // 사면길이 5m를 넘는 자리는 구조물 의무 구간이라 벽 자리 후보가 아니다(별표2). + if (slopeLengthM > FILL_SLOPE_MAX_LENGTH_M) break; + const candidate: FillWallPlacement = { + offset, + ratio: run / rise, + slopeLengthM, + withinLimit: true, + ratioClamped: false, + fillHeightM: rise, + }; + last = candidate; + if (candidate.ratio >= FILL_SLOPE_RATIO_MIN && candidate.ratio <= FILL_SLOPE_RATIO_MAX) { + inRange.push(candidate); + } + } + if (!last) return null; + if (inRange.length) { + // 성토 전체고가 3m 이상이면 1:1.2로도 사면이 5m에 육박한다 — 벽을 **사면길이 5m + // 쪽(가장 바깥 후보)**으로 끌어올린다(2026-08-21 사용자 ①). 낮은 성토는 사면을 + // 짧게 두는 **가장 안쪽 후보**를 쓴다. + return last.fillHeightM >= FILL_STRUCTURE_HEIGHT_M ? inRange[inRange.length - 1] : inRange[0]; + } + // 범위 안에 드는 자리가 없다 = 지형이 1:1.2보다 급하거나 1:2.0보다 완만하다. + // 5m 한계 직전 자리를 쓰고 물매를 가까운 끝값으로 고정한다 — 도면에 경고로 알린다. + return { + ...last, + ratio: last.ratio < FILL_SLOPE_RATIO_MIN ? FILL_SLOPE_RATIO_MIN : FILL_SLOPE_RATIO_MAX, + withinLimit: last.slopeLengthM <= FILL_SLOPE_MAX_LENGTH_M + 1e-6, + ratioClamped: true, + }; +} + +/** 노견 → 벽 이음선 상단점 성토 사면 구간(단일 각도)과 그 물매·경고. */ +export interface FillSlopeSegment { + ratio: number; + lengthM: number; + clamped: boolean; + fillHeightM: number; + from: OffsetPoint; + to: OffsetPoint; +} + +/** + * 벽이 선 자리에서 성립하는 성토 사면 구간을 만든다. 벽 자리가 다 굳은 뒤에 불러야 + * 한다 — 관 길이 맞춤(벽 이동)이 끝나기 전에 물매를 잡으면 접점이 다시 어긋난다 + * (2026-08-21 사용자 ② 어긋남의 직접 원인이 이 순서였다). + */ +export function fillSlopeOf( + wall: { role: "inlet" | "outlet"; topJoint: OffsetPoint }, + 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, + }; +} diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts new file mode 100644 index 00000000..e1479e9a --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts @@ -0,0 +1,114 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Culvert_Types.ts + * 배수관 세트 기하 **자료형** — 기하 계산(`_Cross_Culvert_Geom.ts`)에서 분리했다 + * (700줄 제한). 계산·그리기 양쪽이 함께 쓴다. + * ========================================================================== */ + +import type { CulvertSet } from "./B06_Section_Api_Fetch"; + +export interface OffsetPoint { + offset: number; + elevation: number; +} + +/** 설계선(성토 경사) 트림 범위 — 이 밖은 벽·보호공이 대신하므로 그리지 않는다. */ +export interface CulvertDesignTrim { + minOffset: number; + maxOffset: number; + /** 트림 경계에서 성토 경사선이 **닿아야 할 표고**(= 벽 이음선 상단점). 없으면 설계선 그대로. */ + minElevation?: number; + maxElevation?: number; + /** 노견 → 벽 이음선 상단점 **성토 사면 구간**. 백엔드 설계선(1:1.2 고정)을 대신해 그린다. */ + minSlope?: { from: OffsetPoint; to: OffsetPoint }; + maxSlope?: { from: OffsetPoint; to: OffsetPoint }; +} + +export interface WallLayout { + role: "inlet" | "outlet"; + form: string | null; + lengthM: number | null; + /** 배면(도로측) 바닥 offset. */ + backOffset: number; + /** 전면(계류측) 바닥 offset. */ + outerOffset: number; + base: number; + height: number; + outward: number; + /** 합성 단면(하부 사다리꼴 + 상부 평행사변형) 꼭짓점 — 데이터 좌표. */ + points: OffsetPoint[]; + /** 상단 배면 꼭짓점 — 성토 사면선과의 접점(설계선 트림 경계). */ + topBack: OffsetPoint; + /** 이음선 상단점(평행사변형↔사다리꼴 상단 교차점) — 성토 사면선이 여기서 끝난다. */ + topJoint: OffsetPoint; +} + +/** + * 유입 집수정 단면 — 실무 도면(2026-08-20 사용자 제공 L형 확대본) 기준. + * · **I형** = 기슭막이와 같은 **평행사변형 벽 1매**만. + * · **ㄴ(L)형** = I형 + **바닥판**. + * · **ㄷ형** = ㄴ형 + I형 **반대측 막음벽**. + * 돌 표현은 재질 확정 시 반영 예정(성토부 기슭막이와 동일 규칙) — 지금은 윤곽만. + */ +export type BasinShape = "I" | "L" | "U"; + +export interface BasinLayout { + shape: BasinShape; + /** 부재별 폴리곤(벽·바닥·막음벽). 각각 분리 도형으로 그린다. */ + parts: Array<{ kind: "wall" | "floor"; points: OffsetPoint[] }>; + label: OffsetPoint; + /** 자동 전환 사유 — 툴팁·라벨에 쓴다. */ + reason: "cut" | "blocked"; + /** 집수정이 원지반 안으로 들어갈 때의 절토선(구조물 바깥 끝 → 지반 교차점). */ + cutLine: { from: OffsetPoint; to: OffsetPoint } | null; +} + +/** 관 끝단을 자를 마감면 — 구조물(기슭막이·집수정)의 계류측 변을 **그대로 복사**한다. */ +export interface EndFace { + base: OffsetPoint; + direction: { offset: number; elevation: number }; +} + +/** 관 한쪽 끝단의 두 꼭짓점(마감면에서 자른 결과). */ +export interface PipeEnd { + bottom: OffsetPoint; + top: OffsetPoint; +} + +/** 유출측 보호공(돌붙임) 띠 — 윗면/밑면 폴리라인 쌍. */ +export interface PitchingLayout { + top: OffsetPoint[]; + base: OffsetPoint[]; + lengthM: number; + /** 최소 보호공 길이(낙차고×2 — 백엔드 `apron_length_m`). */ + minLengthM: number; +} + +export interface CulvertLayout { + culvert: CulvertSet; + pipe: { inlet: OffsetPoint; outlet: OffsetPoint; lengthM: number; slopePct: number }; + /** 관 축·법선 단위벡터(그리기가 두께 방향으로 밀 때 쓴다). */ + pipeAxis: { axis: OffsetPoint; normal: OffsetPoint }; + /** 관 양 끝단 꼭짓점 — 보호공 시작점이 유출 하단 꼭짓점을 그대로 쓴다. */ + pipeCorners: { inlet: PipeEnd; outlet: PipeEnd }; + walls: WallLayout[]; + /** 유출측 성토사면 — 경사길이(m)·법정 5m 충족 여부(별표2)·풀어낸 물매(1:n). */ + fillSlope: { + lengthM: number; + withinLimit: boolean; + /** 사면선이 벽 이음선 상단점을 지나도록 역산한 물매. 범위 밖이면 끝값으로 고정. */ + ratio: number; + /** 물매가 1.2~2.0 범위를 벗어났는지 — 도면 경고용. */ + ratioClamped: boolean; + /** + * 기슭막이만으로는 성토를 받을 수 없는 자리(성토고 3m↑인데 사면 5m·물매 1:1.2를 + * 동시에 만족하는 벽 자리가 없다) — **옹벽·석축 등 구조물 검토 대상** + * (`성토_비탈면.md` §2 "5m 초과 시 구조물 설치 의무"). + */ + structureRequired: boolean; + }; + /** 유출측 보호공 띠(없으면 top/base 빈 배열). */ + pitching: PitchingLayout; + /** 유입 집수정 단면(있을 때만). */ + basin: BasinLayout | null; + designTrim: CulvertDesignTrim | null; +} diff --git a/B06_Section/B06_Section_UI_Cross_Design.ts b/B06_Section/B06_Section_UI_Cross_Design.ts index b2a5f89d..c548ca60 100644 --- a/B06_Section/B06_Section_UI_Cross_Design.ts +++ b/B06_Section/B06_Section_UI_Cross_Design.ts @@ -446,7 +446,20 @@ export function appendCrossDesignOverlay( x: (offset: number) => number, toDisplayY: (elevation: number) => number, groundSamples: SectionSample[], - trim?: { minOffset: number; maxOffset: number }, + trim?: { + minOffset: number; + 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 }; + }; + }, ): void { const line = design.design_line; if (!line || line.length < 2) return; @@ -544,11 +557,21 @@ export function appendCrossDesignOverlay( const t = Math.abs(span) > 1e-9 ? (offset - a.offset_m) / span : 0; return { offset_m: offset, elevation_m: a.elevation_m + (b.elevation_m - a.elevation_m) * t }; }; + // 배수관 트림 경계에서는 끝단 표고를 **벽 이음선 상단점**에 맞춰 내린다 — 벽 높이가 + // 관경+여유고로 고정된 뒤로는 설계 사면선 표고와 어긋나 사면선 끝이 벽에서 떨어진다 + // (2026-08-21 사용자 ②). 사면 끝 접점 트림이 이긴 경계에는 적용하지 않는다. + const snap = ( + p: { offset_m: number; elevation_m: number }, + elevation: number | undefined, + wins: boolean, + ) => (elevation != null && wins ? { offset_m: p.offset_m, elevation_m: elevation } : p); + const minIsCulvert = trim != null && Math.abs(effective.minOffset - trim.minOffset) < 1e-9; + const maxIsCulvert = trim != null && Math.abs(effective.maxOffset - trim.maxOffset) < 1e-9; const clamp = (p: { offset_m: number; elevation_m: number }) => p.offset_m < effective.minOffset - ? at(effective.minOffset) + ? snap(at(effective.minOffset), trim?.minElevation, minIsCulvert) : p.offset_m > effective.maxOffset - ? at(effective.maxOffset) + ? snap(at(effective.maxOffset), trim?.maxElevation, maxIsCulvert) : p; return [clamp(a), clamp(b)]; }; @@ -577,6 +600,20 @@ export function appendCrossDesignOverlay( svg.append(seg); } + // 배수관 측점 성토 사면 — 백엔드 설계선(1:1.2 고정) 대신 **배수관 레이아웃이 준 직선** + // 을 그린다. 벽 자리가 관 길이 맞춤으로 옮겨져도 노견 → 벽 이음선 상단점을 잇는 단일 + // 각도가 유지된다(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); + } + // 차도·노견 경계 짧은 수직 틱(N-4-2): ±3.6px(기존 ±6의 60%). 노면 단일 기울기라 육안 // 구분이 안 되는 경계를 표시한다. 노견 바깥 끝(road_edges)은 측구·사면 꺾임으로 이미 구분됨. const edges = design.carriageway_edges;