From fb3b24f13fe749ec3a176bae11af3976e2ba4f6b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 14:48:53 +0900 Subject: [PATCH] =?UTF-8?q?fix(section):=20=ED=9A=A1=EB=8B=A8=20=EB=B2=BD?= =?UTF-8?q?=20=ED=98=95=EC=83=81=20=ED=95=9C=20=EB=B2=8C=20=C2=B7=20?= =?UTF-8?q?=ED=98=95=ED=83=9C=EB=B3=84=20=EB=86=92=EC=9D=B4=20=ED=95=9C?= =?UTF-8?q?=EB=8F=84=20=C2=B7=20=EC=9B=90=EC=A7=80=EB=B0=98=20=EB=A7=9E?= =?UTF-8?q?=EC=B6=A4=20=EC=82=AD=EC=A0=9C=20=C2=B7=20=EB=85=B8=EB=A9=B4=20?= =?UTF-8?q?=EC=9C=84=20=EA=B8=88=EC=A7=80(54-42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 관 유입·유출 흙막이 · 추가 흙막이 · 사면안정 흙막이 · 옹벽 모두 공용 buildRevetWallGeometry - 외곽 기울기 형태 무관(outlineLeanOf) — 형태는 안 무늬만 - 형태별 높이 한도(메 2.0 · 찰 3.0 · 콘크리트 5.0) 자르기 · 경고 삭제 — 입력 높이 그대로 - 추가 흙막이 원지반 맞춤 늘이기 삭제(돌쌓기(메) 원지반 따라 연장 흠) - 벽 윗면 노면 위 금지 — 넘으면 노면에서 멈춤 - 다단 묶음 _Cross_Culvert_Tiers 로 분리(Geom 700줄 이하) - 시험 test_b06_wall_shape_unified 추가 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7 --- .../B06_Section_Engine_Structures_Wall.py | 4 +- B06_Section/B06_Section_UI_Cross_Culvert.ts | 4 +- .../B06_Section_UI_Cross_Culvert_Const.ts | 40 +--- .../B06_Section_UI_Cross_Culvert_Extra.ts | 110 ++--------- .../B06_Section_UI_Cross_Culvert_Geom.ts | 180 +++++------------- .../B06_Section_UI_Cross_Culvert_Tiers.ts | 128 +++++++++++++ .../B06_Section_UI_Cross_Culvert_Wire.ts | 19 -- B06_Section/B06_Section_UI_Cross_Revetment.ts | 32 +--- .../B06_Section_UI_Cross_Structure_Panel.ts | 3 +- B06_Section/B06_Section_UI_Cross_Wall.ts | 14 +- .../B06_Section_UI_Page_Pipe_Options.ts | 32 +--- .../tester/test_b06_wall_shape_unified.py | 98 ++++++++++ resources/tester/test_user_values_survive.py | 8 +- ui_template/ui_template_locale_b2.ts | 4 - 14 files changed, 337 insertions(+), 339 deletions(-) create mode 100644 B06_Section/B06_Section_UI_Cross_Culvert_Tiers.ts create mode 100644 resources/tester/test_b06_wall_shape_unified.py diff --git a/B06_Section/B06_Section_Engine_Structures_Wall.py b/B06_Section/B06_Section_Engine_Structures_Wall.py index 0a57a0aea..1343e98bf 100644 --- a/B06_Section/B06_Section_Engine_Structures_Wall.py +++ b/B06_Section/B06_Section_Engine_Structures_Wall.py @@ -28,8 +28,8 @@ logger = logging.getLogger(__name__) # 구간 경계 측점을 구간 안으로 볼 허용 오차(m) — 정본이 누가거리를 0.01m로 끊어 쓴다. _EDGE_TOLERANCE_M = 0.02 -# 구조물 종류 → 횡단 기하가 아는 **형태** 이름. 형태가 벽 높이 한계·두께를 정하므로 -# (`B06_Section_UI_Cross_Revetment.revetHeightLimit`) 가장 가까운 것으로 잇는다. +# 구조물 종류 → 횡단 기하가 아는 **형태** 이름. 형태는 벽 안 무늬만 정하므로 +# 가장 가까운 것으로 잇는다. _FORM_BY_TYPE = { "masonry_wet": "돌쌓기(찰)", "masonry_dry": "돌쌓기(메)", diff --git a/B06_Section/B06_Section_UI_Cross_Culvert.ts b/B06_Section/B06_Section_UI_Cross_Culvert.ts index 25955b460..ff2a572bc 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert.ts @@ -14,7 +14,6 @@ import { REVET_EMBED_DEPTH_M, FILL_SLOPE_RATIO_MIN, pipeWallThicknessM, - revetHeightLimit, } from "./B06_Section_UI_Cross_Culvert_Geom"; import type { BasinShape, @@ -182,8 +181,7 @@ export function appendCulvertOverlay( `${roleLabel(wall.role)} 기슭막이 ${wall.form ?? ""} H=${( wall.height + REVET_EMBED_DEPTH_M ).toFixed(1)}m` + - `(상단 = 사면선 접점, 전면 1:${wall.lean}(품셈 13-4-4 [주]⑪ 표준경사)` + - `, 높이 한계 ${revetHeightLimit(wall.form).toFixed(1)}m — 교본 7-3)` + + `(상단 = 사면선 접점, 전면 1:${wall.lean}(품셈 13-4-4 [주]⑪ 표준경사))` + (wall.floatGapM > 0.01 ? ` · ⚠ 바닥 원지반 이격 ${wall.floatGapM.toFixed(2)}m — 하부 지지 구조물 별도(추가 예정)` : ` · 근입 ${REVET_EMBED_DEPTH_M.toFixed(2)}m(실무 기초콘크리트 H=0.5 — 법정 규정 없음)`) + diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts index 0d49b75b6..ab51b6978 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts @@ -75,35 +75,15 @@ export function pipeWallThicknessM(kind: string | null, diameterM: number): numb */ export const REVET_EMBED_DEPTH_M = 0.5; -/** - * 기슭막이 형태별 높이 한계(m) — 지식DB `돌쌓기.md` §1 / `흙막이.md` §2: - * 돌 **찰쌓기 3.0m 이하**, 돌 **메쌓기 2.0m 이하**(교본 7-3 돌흙막이 기준), - * 돌망태 2m 정도. 큰돌쌓기는 총높이 한계 없음(1일 시공 1.5m 제한만). - * 초과 시 콘크리트 옹벽 등 상위 구조물 검토 대상이다. - * 통나무·목재틀·바자는 지식DB에 수치가 없어 **2.0m**로 둔다(2026-08-29 사용자 확정). - */ -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("통나무") || form.includes("목재") || form.includes("바자")) return 2.0; - if (form.includes("콘크리트")) return 5.0; // 프로젝트 확정값 — materialLimit과 동일 - return 3.0; -} - -/* ── 기슭막이 재질·높이 조작(2026-08-22 사용자 확정) ───────────────────── - * 높이 조작이 교본 형태별 한계와 부딪히면 **재질을 바꿔야** 더 올릴 수 있다: - * 메쌓기 2.0 / 찰쌓기 3.0(돌쌓기.md §1) / 콘크리트 5.0(프로젝트 기본값, - * 외부 기준 미확인). 높이 제어·표기·한계 기준은 모두 **순수 높이**(벽 바닥~상단, - * 근입 0.5m 포함) — 배관 기슭막이 2026-08-23, 추가·독립 기슭막이 2026-08-29 - * 사용자 확정으로 기준을 하나로 맞췄다. */ +/* 기슭막이 높이는 형태와 무관하게 **사용자 입력 그대로** — 형태별 높이 한계·원지반 맞춤 + * 자동 자르기 없음. 형태가 바꾸는 것은 안 무늬뿐. 높이 기준은 **순수 높이**(벽 바닥~상단, + * 근입 0.5m 포함). 단 하나 — 벽 윗면은 노면 위로 올라오지 않음(`buildRevetWallGeometry`). */ export type RevetMaterial = "dry" | "wet" | "concrete"; /** * 기슭막이 **형태** — B05 폼(`B05_Profile_UI_Drainage_Facility_Fields`)과 같은 한 벌이다. * 조정창 선택지도 이 목록을 쓴다(2026-08-29 사용자: "B05 폼 형태로 가는 게 맞다"). - * 높이 한계는 `revetHeightLimit(form)`, 도형의 돌 표현은 `materialFromForm(form)`이 정한다. + * 도형의 돌 표현은 `materialFromForm(form)`이 정한다. */ export const REVET_FORMS = [ "돌쌓기(찰)", @@ -130,10 +110,6 @@ export function revetFormOf(m: string | null | undefined, specForm?: string | nu export const REVET_MATERIALS: RevetMaterial[] = ["dry", "wet", "concrete"]; -export function materialLimit(material: RevetMaterial): number { - return material === "dry" ? 2.0 : material === "wet" ? 3.0 : 5.0; -} - export function materialLabel(material: RevetMaterial): string { return material === "dry" ? "메쌓기" : material === "wet" ? "찰쌓기" : "콘크리트"; } @@ -149,7 +125,7 @@ export function materialFromForm(form: string | null | undefined): RevetMaterial * 벽 한 매의 제원(형태·재질·높이) — 배관 벽과 독립 기슭막이가 같이 쓴다. * * 조정창이 고르는 값은 **형태**(B05 폼과 같은 6종 — 2026-08-29 사용자)이고, 재질(돌 - * 표현)과 높이 한계는 형태에서 파생된다. 높이 기준은 **순수 높이**(바닥~상단, 근입 + * 표현)은 형태에서 파생된다. 높이 기준은 **순수 높이**(바닥~상단, 근입 * 0.5m 포함). 독립 기슭막이(관 숨김)는 관경이 없어 하한이 근입뿐이고 기본 높이는 * 사용자가 고른 형태 높이(`revet_height_m`)다 — 배관은 관이 벽 밖으로 삐져나오지 * 않도록 관경 기준 최소 높이를 하한·기본으로 쓴다. @@ -159,16 +135,14 @@ export function revetWallSpec( adjust: { h?: number | null; m?: string | null }, hiddenPipe: boolean, diameterM: number, -): { form: string; material: RevetMaterial; limit: number; pureHeight: number; height: number } { +): { form: string; material: RevetMaterial; pureHeight: number; height: number } { const form = revetFormOf(adjust.m, spec.revet_form); - const limit = revetHeightLimit(form); const floor = hiddenPipe ? REVET_EMBED_DEPTH_M : pipeWallMinPureHeight(diameterM); const preferred = hiddenPipe ? (spec.revet_height_m ?? floor) : floor; - const pureHeight = Math.min(Math.max(adjust.h ?? preferred, floor), Math.max(limit, floor)); + const pureHeight = Math.max(adjust.h ?? preferred, floor); return { form, material: materialFromForm(form), - limit, pureHeight, height: pureHeight - REVET_EMBED_DEPTH_M, }; diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts index c171f35c4..f3a90ef8b 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts @@ -18,20 +18,18 @@ import { FILL_SLOPE_MAX_LENGTH_M, FILL_SLOPE_RATIO_MIN, materialFromForm, - materialLabel, revetFormOf, - revetHeightLimit, REVET_EMBED_DEPTH_M, REVET_LEAN_RATIO, REVET_THICKNESS_M, } from "./B06_Section_UI_Cross_Culvert_Const"; -import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const"; import type { OffsetPoint, OutletFillSegment, WallAdjust, WallLayout, } from "./B06_Section_UI_Cross_Culvert_Types"; +import { buildRevetWallGeometry } from "./B06_Section_UI_Cross_Wall"; export interface OutletExtrasInput { /** 성토부선 시작점 = 관 유출 하단 꼭짓점(배관 벽의 성토선 시작 자리). */ @@ -215,64 +213,10 @@ function trailingFill( }; } -/** - * 추가 기슭막이 벽 1매 — 배관용 기슭막이와 같은 형상(배면 수직·전면 1:0.3, - * 수평 기초 근입 0.5m), 배관·마감면만 없다. 높이는 눈금 없이 **정확값**을 쓴다 — - * 0.1m 올림을 하면 상단이 1:1.2 사면선을 벗어난다(사이 물매 정확 유지가 우선). - */ -function buildExtraWall( - index: number, - anchor: OffsetPoint, - outward: number, - height: number, - material: RevetMaterial, - floatGapM: number, - form: string | null = null, - lean: number = REVET_LEAN_RATIO, -): WallLayout { - const thickness = REVET_THICKNESS_M; - const baseWidth = thickness * 1.5 + lean * height; - const backOffset = anchor.offset - outward * (baseWidth / 2); - const topJoint = backOffset + outward * (thickness / 2); - const topElevation = anchor.elevation + height; - const topBack: OffsetPoint = { offset: backOffset, elevation: topElevation }; - const topFront = topJoint + outward * thickness; - const frontXAt = (elevation: number): number => - topFront + outward * lean * (topElevation - elevation); - // 하단 = 기준선(anchor) 아래 근입 0.5m **고정** — 원지반에 묻혀도 바닥을 더 - // 내리지 않는다. 벽 형상은 높이값으로만 정한다(2026-08-23 사용자 확정 — - // 종전 전면 발끝 지반 추적 수렴 삭제). - const bottomElevation = anchor.elevation - REVET_EMBED_DEPTH_M; - const bottomBack: OffsetPoint = { offset: backOffset, elevation: bottomElevation }; - const bottomFront: OffsetPoint = { - offset: frontXAt(bottomElevation), - elevation: bottomElevation, - }; - return { - role: "extra", - extraIndex: index, - form: form ?? materialLabel(material), - lengthM: null, - backOffset, - outerOffset: bottomFront.offset, - base: anchor.elevation, - height, - floatGapM, - material, - lean, - outward, - topBack, - topJoint: { offset: topJoint, elevation: topElevation }, - bottomBack, - bottomFront, - points: [bottomBack, topBack, { offset: topFront, elevation: topElevation }, bottomFront], - }; -} - /** * 유출측 성토부선·다단 기슭막이 일괄 계산 (2026-08-22 4축 조작 체계). * - * 단별 벽 높이 h = 사용자 설정 ?? 기본 1.5m (0.5~재질 한계로 절삭). 벽 상단은 + * 단별 벽 높이 h = 사용자 설정 ?? 기본 1.5m (하한 0.5 · 형태별 상한 없음). 벽 상단은 * 항상 src에서 내려오는 **1:1.2 성토선 위** — 자동 자리는 높이 h 벽이 지반에 * 앉으며 상단이 선에 닿는 지점(heightAt(x)=h 교차점, 닫힌식): * heightAt(x) = (src.elev − 지반(x) − (D − 0.25t)/1.2) / (1 − 0.15/1.2) @@ -304,20 +248,15 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult // (2026-08-29 사용자: 조정창 선택지를 형태로 통일). const form = revetFormOf(adjust.m, input.ownerForm); const material = materialFromForm(form); - const limit = revetHeightLimit(form); - // 높이 기준 = **벽 바닥~상단**(근입 0.5 포함) — 배관 기준벽과 같은 기준으로 - // 통일했다(2026-08-29 사용자: 추가 기슭막이 높이가 하단~상단이 아니었다). - const height = Math.min( - Math.max( - adjust.h ?? EXTRA_WALL_DEFAULT_HEIGHT_M, - EXTRA_WALL_MIN_HEIGHT_M, - REVET_EMBED_DEPTH_M + 0.1, - ), - limit, + // 높이 기준 = **벽 바닥~상단**(근입 0.5 포함) — 배관 기준벽과 같은 기준. + // 형태별 높이 한계 없음 — 사용자 입력 그대로(하한만 둠). + const height = Math.max( + adjust.h ?? EXTRA_WALL_DEFAULT_HEIGHT_M, + EXTRA_WALL_MIN_HEIGHT_M, + REVET_EMBED_DEPTH_M + 0.1, ); - /** 기준선(근입 위)~상단 — 도형·자리 계산은 종전대로 이 값으로 한다. - * 지형이 요청 높이만큼 안 나오면 아래 스캔 뒤 **낮춰서** 바닥을 지반에 앉힌다. */ - let exposed = height - REVET_EMBED_DEPTH_M; + /** 기준선(근입 위)~상단 — 도형·자리 계산은 이 값으로 한다. */ + const exposed = height - REVET_EMBED_DEPTH_M; // 전면 기울기 — 형태·요청 높이로 표준경사(2026-09-14 B5). 자리 닫힌식의 분모도 따라감. const lean = input.leanFor?.(form, height) ?? REVET_LEAN_RATIO; const heightDenominator = 1 - lean / 2 / FILL_SLOPE_RATIO_MIN; @@ -425,27 +364,20 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult appliedX = Math.round(appliedX * 10) / 10; const topElevation = autoTop - appliedD; const anchorX = autoOffset + outward * appliedX; - // 바닥을 **원지반에 앉힌다**(2026-09-06 사용자 지적: 연계 기슭막이가 사면과 안 맞음). - // 상단은 1:1.2 성토선에 붙어 있어야 하므로 자리는 그대로 두고 **키만** 지반까지 - // 맞춘다 — 지반이 낮으면 키우고(형태 한계까지), 높으면 줄인다(최소 높이까지). - // 예전에는 요청 높이를 그대로 써 바닥이 최대 0.87m 떠 있었다(실측 264.06·731.31). + // 높이는 입력 그대로 — 원지반에 맞춰 키우거나 줄이지 않는다(뜨면 floatGap 경고). const groundBase = groundAt(anchorX); - const fitted = Math.min( - Math.max(topElevation - groundBase, EXTRA_WALL_MIN_HEIGHT_M - REVET_EMBED_DEPTH_M), - limit - REVET_EMBED_DEPTH_M, - ); - const appliedExposed = Number.isFinite(fitted) ? fitted : exposed; - const base = topElevation - appliedExposed; // 근입 0.5 위 기준선 - const wall = buildExtraWall( - i, - { offset: anchorX, elevation: base }, + const base = topElevation - exposed; // 근입 0.5 위 기준선 + const wall = buildRevetWallGeometry({ + anchor: { offset: anchorX, elevation: base }, outward, - appliedExposed, + height: exposed, material, - Math.max(0, base - groundBase), + role: "extra", form, + extraIndex: i, + floatGapM: Math.max(0, base - groundBase), lean, - ); + }); // 요청한 좌우 이동을 지형이 막았으면 그 양을 남긴다 — 「눌러도 안 움직인다」의 까닭을 // 툴팁으로 알리기 위함이다(2026-09-06 실측: 다섯 측점 중 넷이 1.0m 요청에 0.0m 이동). const blocked = Math.max(0, (requestedX ?? 0) - appliedX); @@ -459,11 +391,11 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult wall.shiftFloorM = Math.round(floor * 100) / 100; } walls.push(wall); - // 되받기 — 지반에 맞춰 조정한 값이 **실제 적용 높이**다(조정창이 그대로 받아 적는다). + // 되받기 — 적용 높이(하한만 걸림)를 조정창이 그대로 받아 적는다. appliedAdjusts.push({ x: appliedX, d: appliedD, - h: adjust.h != null ? appliedExposed + REVET_EMBED_DEPTH_M : null, + h: adjust.h != null ? height : null, m: adjust.m, }); // 성토선: src → (수평 선반 x>0이면 선반 끝) → 이음선 상단점. 사면길이 = 경사부. diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts index 37d6455f2..102cf5ee2 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts @@ -9,22 +9,19 @@ import type { CrossSection, CulvertSideSpec, SectionSample } from "./B06_Section_Api_Fetch"; import { BASIN_MAX_FILL_SLOPE_M, - materialLabel, revetWallSpec, PIPE_WALL_DEFAULT_RUN_M, FILL_SLOPE_MAX_LENGTH_M, FILL_SLOPE_RATIO_MAX, FILL_SLOPE_RATIO_MIN, MIN_PIPE_COVER_M, - REVET_EMBED_DEPTH_M, REVET_LEAN_RATIO, REVET_THICKNESS_M, - REVET_TRAP_TOP_M, - revetHeightLimit, revetTargetHeight, } from "./B06_Section_UI_Cross_Culvert_Const"; import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const"; import { outlineLeanOf } from "./B06_Section_UI_Cross_Lean"; +import { buildRevetWallGeometry } from "./B06_Section_UI_Cross_Wall"; import { ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types"; import type { BasinLayout, @@ -50,12 +47,16 @@ import { inletChoiceAvailability, resolveBasinChoice, } from "./B06_Section_UI_Cross_Culvert_Basin"; -import type { OutletExtrasResult } from "./B06_Section_UI_Cross_Culvert_Extra"; -import { buildExtrasAt, inletGroundConnector } from "./B06_Section_UI_Cross_Culvert_Extra"; +import { inletGroundConnector } from "./B06_Section_UI_Cross_Culvert_Extra"; +import { + buildCulvertTiers, + drawnFillPoints, + outletPipeEnd, + slopeZeroAnchor, +} from "./B06_Section_UI_Cross_Culvert_Tiers"; import type { FillSlopeSegment, WallVertical } from "./B06_Section_UI_Cross_Culvert_Solve"; import { cutLength, - fillWallBaseWidth, outletSlopeFactory, fillSlopeOf, groundInterpolator, @@ -130,7 +131,7 @@ 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 }; - // 벽 제원(4축): 재질이 높이 한계(메2/찰3/콘5)를 정한다. 높이 기준 = **순수 높이** + // 벽 제원(4축): 형태별 높이 한계 없음(사용자 입력 그대로). 높이 기준 = **순수 높이** // (바닥~상단 — 2026-08-23 사용자). 하한·기본 = 관경 + 아래 0.5 + 위 0.5 — // 관이 벽 위로 삐져나오거나 바닥에 붙으면 안 된다. 기하는 종전대로 // invert~상단 계산용 높이(= 순수 − 0.5)로 돈다. @@ -215,16 +216,6 @@ export function computeCulvertLayout( inlet: null, outlet: null, }; - // d의 0점 = **성토선 길이 0 지점**(벽 상단 = 노견 — 2026-08-23 사용자 확정). - // 그 자리(하단선 중점)는 이음선 상단점이 노견 끝에 오는 offset이고, 기본 자리는 - // 성토선을 타고 경사길이 4m 내려간 지점(PIPE_WALL_DEFAULT_RUN_M)이다. - const slopeZeroAnchor = ( - edge: { offset_m: number }, - outward: number, - wallHeight: number, - lean: number, - ): number => - edge.offset_m + outward * (fillWallBaseWidth(wallHeight, lean) / 2 - REVET_TRAP_TOP_M); if (!basinReason && designAt) { const inletBaseAt = (offset: number): number => Math.min(groundAt(offset), invertCap(inletInfo.edge)); @@ -368,50 +359,28 @@ export function computeCulvertLayout( } return null; } - // 형상: 배면 수직 + 계류측 1:n 평행사변형 띠(n = 표준경사). 높이는 vertical(계산용)이 들고 온다. - const height = - vertical?.height ?? Math.min(revetTargetHeight(diameter), revetHeightLimit(spec.revet_form)); + // 형상 = 공용 `buildRevetWallGeometry`(추가·사면안정 흙막이·옹벽과 한 벌). 높이는 + // vertical(계산용)이 들고 온다 — 형태별 높이 한계 없음, 윗면은 노면에서 멈춤. + const height = vertical?.height ?? revetTargetHeight(diameter); if (!(height > 0.05)) return null; - const floatGapM = vertical?.floatGapM ?? 0; - // 자리 기준 = **하단선 중점**(2026-08-21) — anchor.elevation은 그 자리 관 invert. - const baseWidth = thickness * 1.5 + lean * height; - const backOffset = anchor.offset - outward * (baseWidth / 2); - const topJoint = backOffset + outward * (thickness / 2); - const topElevation = anchor.elevation + height; - const topBack: OffsetPoint = { offset: backOffset, elevation: topElevation }; - // 상단 변: 사다리꼴 상단(t/2) + 띠(t). 이음선은 상단 변 중간점에서 1:0.3으로 바닥까지. - const topFront = topJoint + outward * thickness; - // 하단선 = **수평 기초**, 깊이 = 기준선(관 invert) 아래 근입 0.5m 고정 — - // 원지반에 묻혀도 바닥을 더 내리지 않는다. 벽 형상은 높이값으로만 정한다 - // (2026-08-23 사용자 확정 — 종전 전면 발끝 지반 추적 수렴 삭제). - const frontXAt = (elevation: number): number => - topFront + outward * lean * (topElevation - elevation); - const bottomElevation = anchor.elevation - REVET_EMBED_DEPTH_M; - const bottomBack: OffsetPoint = { offset: backOffset, elevation: bottomElevation }; - const bottomFront: OffsetPoint = { - offset: frontXAt(bottomElevation), - elevation: bottomElevation, - }; - const wall: WallLayout = { - role: spec.role, - form: formLabel ?? materialLabel(material), - lengthM: spec.revet_length_m ?? null, - backOffset, - outerOffset: bottomFront.offset, - base: anchor.elevation, - // 높이 = **계산용 높이(관 invert~상단, 근입 제외)로 통일**(2026-08-22 사용자 - // 확정 — 기초는 시공 시 묻히는 부분이라 높이·한계 검사 모두 이 기준). - height, - floatGapM, - material, - lean, + const side = outward > 0 ? edges.left : edges.right; + const wall = buildRevetWallGeometry({ + anchor, outward, - topBack, - topJoint: { offset: topJoint, elevation: topElevation }, - bottomBack, - bottomFront, - points: [bottomBack, topBack, { offset: topFront, elevation: topElevation }, bottomFront], - }; + height, + material, + role: spec.role, + form: formLabel, + lengthM: spec.revet_length_m ?? null, + thickness, + floatGapM: vertical?.floatGapM ?? 0, + lean, + roadElevation: side.elevation_m, + }); + const topJoint = wall.topJoint.offset; + const topElevation = wall.topJoint.elevation; + const topFront = wall.points[2].offset; + const bottomFront = wall.bottomFront; walls.push(wall); // 성토 사면선은 **이음선 상단점**에서 끊고, 끝단 표고도 그 점에 맞춘다(사용자 ②). if (outward > 0) { @@ -496,17 +465,6 @@ export function computeCulvertLayout( ); // ── 관 축 확정 — 관 하단선은 시작점과 유출 벽 전면 기준선 교차점을 잇는다. const pipeStart = basinPipeEnd ?? inlet; - /** 유출 관 하단 끝점 목표 = 벽 바닥 기준 0.5m 상단(기준선)과 **전면 경사선의 - * 교차점**(2026-08-22 사용자 ① — 성토부선 시작 규칙과 같은 자리). */ - const outletPipeEnd = (wall: WallLayout): OffsetPoint => { - // 기준 = 바닥 +0.5(= 관 invert 자리 — 2026-08-23부터 바닥은 invert−0.5 고정). - const reference = wall.bottomBack.elevation + REVET_EMBED_DEPTH_M; - return { - offset: - wall.points[2].offset + wall.outward * wall.lean * (wall.topJoint.elevation - reference), - elevation: reference, - }; - }; // 유출 벽이 사면 끝과 다른 자리면 관 끝도 그 자리 기준으로 맞춘다(올림 연장분은 // 유출 쪽 — 2026-08-20 확정). 관 하단선은 전면 기준선 교차점을 지나 정수 길이로. if (outletWall) { @@ -631,66 +589,26 @@ export function computeCulvertLayout( ) : null; - // 다단 벽 기울기 — 같은 측점·같은 설치 측 규칙으로 높이마다 표준경사(형태 무관 · B08 은 다단을 안 셈). - const extraLean = (form: string, pureHeightM: number): number => - outlineLeanOf({ - form, - height_m: pureHeightM, - section_mode: section.design?.section_mode, - side: culvert.hidden_pipe ? culvert.inlet.face_side : null, - }); - // 성토부선 + 다단 기슭막이(2026-08-22 — 보호공 삭제, 윗면 선만 성토부선으로). - // 유출측과 **집수정 계류측**이 같은 체계를 쓴다: 5m 넘으면 다단을 둘 수 있다. - const outletStart = outletWall ? pipeCorners.outlet.bottom : null; - const outletInput = { - startBottomElevation: outletWall?.bottomBack.elevation ?? 0, - outward: outletInfo.outward, + // 다단 흙막이(유출측 · 집수정 계류측) — `_Culvert_Tiers` 가 푼다. + const { extras, basinExtras, extrasForArea, basinExtrasForArea } = buildCulvertTiers({ + section, + outletStart: outletWall ? pipeCorners.outlet.bottom : null, + outletBottom: outletWall?.bottomBack.elevation ?? 0, + basinStart: culvert.hidden_pipe + ? inletWall + ? pipeCorners.inlet.bottom + : null + : basinFillStart, + basinBottom: culvert.hidden_pipe ? (inletWall?.bottomBack.elevation ?? 0) : basinFillBottom, + outletInfo, + inletInfo, groundAt, - limitOffset: outletInfo.limit, - adjusts: (revetShift?.extras ?? []).map(adjustOf), - ownerForm: outletWallSpec.form, + extras: (revetShift?.extras ?? []).map(adjustOf), + basinExtras: (revetShift?.basinExtras ?? []).map(adjustOf), + outletForm: outletWallSpec.form, + inletForm: inletWallSpec.form, equalize: equalizeExtras === true, - leanFor: extraLean, - }; - const extras = buildExtrasAt(outletStart, outletInput); - // 독립 기슭막이(관 숨김)는 집수정이 없어 이 채널이 **유입측 벽의 성토부선·다단**이다 — - // 유출측(`extras`)과 같은 시작점(관 하단 꼭짓점)·같은 `bextra` 키(2026-08-29 사용자). - const basinStart = culvert.hidden_pipe - ? inletWall - ? pipeCorners.inlet.bottom - : null - : basinFillStart; - const basinInput = { - startBottomElevation: culvert.hidden_pipe - ? (inletWall?.bottomBack.elevation ?? 0) - : basinFillBottom, - outward: inletInfo.outward, - groundAt, - limitOffset: inletInfo.limit, - adjusts: (revetShift?.basinExtras ?? []).map(adjustOf), - ownerForm: inletWallSpec.form, - equalize: false, - leanFor: extraLean, - }; - const basinExtras = buildExtrasAt(basinStart, basinInput); - /** ⭐ 면적용 다단 — **미확정 단(높이 안 적음)부터 아래는 없는 것**으로 봄(2026-09-14 브레인 판정 - * 「미확정 벽은 성토도 안 깎음 · 벽 금액과 성토 감소가 함께」). 그림은 그대로 다 그림. - * B08 도 첫 미확정 단 아래는 미확정으로 셈(`facility_structures`) — 두 곳이 같은 선에서 끊김. */ - const confirmedOnly = ( - built: OutletExtrasResult, - start: OffsetPoint | null, - input: typeof outletInput, - ): OutletExtrasResult => { - const firstUnset = built.appliedAdjusts.findIndex((applied) => applied.h == null); - if (firstUnset < 0) return built; - return buildExtrasAt(start, { - ...input, - adjusts: input.adjusts.slice(0, firstUnset), - equalize: false, - }); - }; - const extrasForArea = confirmedOnly(extras, outletStart, outletInput); - const basinExtrasForArea = confirmedOnly(basinExtras, basinStart, basinInput); + }); // ── 성토 사면 구간 확정. 벽 자리가 굳은 뒤에 물매를 역산해야 관 길이 맞춤(벽 이동)이 // 접점을 다시 깨뜨리지 않는다 — 종전 어긋남의 직접 원인이 이 순서였다. @@ -714,10 +632,6 @@ export function computeCulvertLayout( // **물량이 안 바뀐다**(2026-09-07 사용자 확정). 기준벽 사면은 노견~벽 상단까지고, 그 // 바깥은 지금까지 원지반으로 봐서 면적이 0이었다. 화면이 그리는 선(`outletFill.segments`)과 // 같은 선을 면적도 보게 맞춘다 — 그리지 않는 `cut` 갈래(벽이 원지반에 묻힌 자리)는 뺀다. - const drawnFillPoints = (result: OutletExtrasResult): OffsetPoint[] => - result.segments - .filter((segment) => segment.kind !== "cut") - .flatMap((segment) => segment.points); // ↓ 되돌릴 자리(2026-09-07) — 「단이 2개 이상일 때만 반영」으로 좁히려면 아래 두 줄의 // `drawnFillPoints(extras)` 를 `extras.walls.length ? drawnFillPoints(extras) : []` 로 // 바꾸면 된다(basinExtras 도 같은 꼴). 다만 그러면 화면이 그리는 성토부선과 면적이 diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Tiers.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Tiers.ts new file mode 100644 index 000000000..bf7045d78 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Tiers.ts @@ -0,0 +1,128 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Culvert_Tiers.ts + * 배수관 세트 기하(`_Cross_Culvert_Geom`)에서 떼어 낸 **벽 자리 도우미 + 다단 흙막이** 층. + * 다단 벽은 `buildExtrasAt`(공용 `buildRevetWallGeometry`)이 세우고, 여기서는 유출측과 + * 집수정 계류측 두 채널의 입력을 짜고 면적용(미확정 단 제외) 결과를 함께 돌려준다. + * ========================================================================== */ + +import type { CrossSection } from "./B06_Section_Api_Fetch"; +import { REVET_EMBED_DEPTH_M, REVET_TRAP_TOP_M } from "./B06_Section_UI_Cross_Culvert_Const"; +import type { OutletExtrasResult } from "./B06_Section_UI_Cross_Culvert_Extra"; +import { buildExtrasAt } from "./B06_Section_UI_Cross_Culvert_Extra"; +import { fillWallBaseWidth } from "./B06_Section_UI_Cross_Culvert_Solve"; +import type { OffsetPoint, WallAdjust, WallLayout } from "./B06_Section_UI_Cross_Culvert_Types"; +import { outlineLeanOf } from "./B06_Section_UI_Cross_Lean"; + +// d의 0점 = **성토선 길이 0 지점**(벽 상단 = 노견 — 2026-08-23 사용자 확정). +// 그 자리(하단선 중점)는 이음선 상단점이 노견 끝에 오는 offset이고, 기본 자리는 +// 성토선을 타고 경사길이 4m 내려간 지점(PIPE_WALL_DEFAULT_RUN_M)이다. +export const slopeZeroAnchor = ( + edge: { offset_m: number }, + outward: number, + wallHeight: number, + lean: number, +): number => edge.offset_m + outward * (fillWallBaseWidth(wallHeight, lean) / 2 - REVET_TRAP_TOP_M); + +/** 유출 관 하단 끝점 목표 = 벽 바닥 기준 0.5m 상단(기준선)과 **전면 경사선의 + * 교차점**(2026-08-22 사용자 ① — 성토부선 시작 규칙과 같은 자리). */ +export const outletPipeEnd = (wall: WallLayout): OffsetPoint => { + // 기준 = 바닥 +0.5(= 관 invert 자리 — 2026-08-23부터 바닥은 invert−0.5 고정). + const reference = wall.bottomBack.elevation + REVET_EMBED_DEPTH_M; + return { + offset: + wall.points[2].offset + wall.outward * wall.lean * (wall.topJoint.elevation - reference), + elevation: reference, + }; +}; + +// 다단 기슭막이의 성토부선까지 트림에 넣는다 — 넣지 않으면 단을 올려도 폐회로가 그대로라 +// **물량이 안 바뀐다**(2026-09-07 사용자 확정). 화면이 그리는 선(`outletFill.segments`)과 +// 같은 선을 면적도 보게 맞춘다 — 그리지 않는 `cut` 갈래(벽이 원지반에 묻힌 자리)는 뺀다. +export const drawnFillPoints = (result: OutletExtrasResult): OffsetPoint[] => + result.segments.filter((segment) => segment.kind !== "cut").flatMap((segment) => segment.points); + +interface SideInfo { + outward: number; + limit: number; +} + +/** 다단 흙막이 두 채널 — 유출측(`extras`)과 집수정 계류측·독립 기슭막이 유입측(`basinExtras`). */ +export function buildCulvertTiers(input: { + section: CrossSection; + outletStart: OffsetPoint | null; + outletBottom: number; + basinStart: OffsetPoint | null; + basinBottom: number; + outletInfo: SideInfo; + inletInfo: SideInfo; + groundAt: (offset: number) => number; + extras: WallAdjust[]; + basinExtras: WallAdjust[]; + outletForm: string; + inletForm: string; + equalize: boolean; +}): { + extras: OutletExtrasResult; + basinExtras: OutletExtrasResult; + extrasForArea: OutletExtrasResult; + basinExtrasForArea: OutletExtrasResult; +} { + const { section, groundAt } = input; + const culvert = section.culvert; + // 다단 벽 기울기 — 같은 측점·같은 설치 측 규칙으로 높이마다 표준경사(형태 무관 · B08 은 다단을 안 셈). + const extraLean = (form: string, pureHeightM: number): number => + outlineLeanOf({ + form, + height_m: pureHeightM, + section_mode: section.design?.section_mode, + side: culvert?.hidden_pipe ? culvert.inlet.face_side : null, + }); + // 성토부선 + 다단 기슭막이(2026-08-22 — 보호공 삭제, 윗면 선만 성토부선으로). + // 유출측과 **집수정 계류측**이 같은 체계를 쓴다: 5m 넘으면 다단을 둘 수 있다. + const outletInput = { + startBottomElevation: input.outletBottom, + outward: input.outletInfo.outward, + groundAt, + limitOffset: input.outletInfo.limit, + adjusts: input.extras, + ownerForm: input.outletForm, + equalize: input.equalize, + leanFor: extraLean, + }; + const extras = buildExtrasAt(input.outletStart, outletInput); + // 독립 기슭막이(관 숨김)는 집수정이 없어 이 채널이 **유입측 벽의 성토부선·다단**이다 — + // 유출측(`extras`)과 같은 시작점(관 하단 꼭짓점)·같은 `bextra` 키(2026-08-29 사용자). + const basinInput = { + startBottomElevation: input.basinBottom, + outward: input.inletInfo.outward, + groundAt, + limitOffset: input.inletInfo.limit, + adjusts: input.basinExtras, + ownerForm: input.inletForm, + equalize: false, + leanFor: extraLean, + }; + const basinExtras = buildExtrasAt(input.basinStart, basinInput); + /** ⭐ 면적용 다단 — **미확정 단(높이 안 적음)부터 아래는 없는 것**으로 봄(2026-09-14 브레인 판정 + * 「미확정 벽은 성토도 안 깎음 · 벽 금액과 성토 감소가 함께」). 그림은 그대로 다 그림. + * B08 도 첫 미확정 단 아래는 미확정으로 셈(`facility_structures`) — 두 곳이 같은 선에서 끊김. */ + const confirmedOnly = ( + built: OutletExtrasResult, + start: OffsetPoint | null, + channel: typeof outletInput, + ): OutletExtrasResult => { + const firstUnset = built.appliedAdjusts.findIndex((applied) => applied.h == null); + if (firstUnset < 0) return built; + return buildExtrasAt(start, { + ...channel, + adjusts: channel.adjusts.slice(0, firstUnset), + equalize: false, + }); + }; + return { + extras, + basinExtras, + extrasForArea: confirmedOnly(extras, input.outletStart, outletInput), + basinExtrasForArea: confirmedOnly(basinExtras, input.basinStart, basinInput), + }; +} diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts index e4ccee55c..3a07ae539 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts @@ -11,8 +11,6 @@ import type { BasinAdjust, CulvertDesignTrim } from "./B06_Section_UI_Cross_Culv import { basinSpanOfSpec, FILL_SLOPE_RATIO_MIN, - materialLabel, - materialLimit, revetSpanOfSpec, } from "./B06_Section_UI_Cross_Culvert_Const"; import { EXTRA_SPAN_DEFAULT } from "./B06_Section_UI_Cross_Culvert_Const"; @@ -539,12 +537,6 @@ export function computeCardCulvert( applied, ]), ]; - const wallOf = (role: RevetKey) => - role.startsWith("bextra") - ? layout.basinExtras[Number(role.slice(6))] - : role.startsWith("extra") - ? layout.extraWalls[Number(role.slice(5))] - : layout.walls.find((wall) => wall.role === role); for (const [role, applied] of roles) { const requested = revetOffset.adjustFor(section, role); // 축별로 요청이 잘렸으면 왜 안 됐는지 토스트로 알린다(조작 중인 벽만). @@ -565,17 +557,6 @@ export function computeCardCulvert( "info", ); } - // 높이가 재질 한계에 잘렸으면 재질 변경을 안내(2026-08-22 사용자 — 경고와 - // 높이 해제는 재질 선택으로). - const wall = wallOf(role); - if (requested.h != null && applied.h != null && requested.h > applied.h + 0.05 && wall) { - showToast( - L("B06_Cross_Height_Limit") - .replace("{mat}", materialLabel(wall.material)) - .replace("{limit}", materialLimit(wall.material).toFixed(1)), - "info", - ); - } if (requested.h != null && applied.h != null && requested.h < applied.h - 0.05) { showToast(L("B06_Cross_Height_Floor"), "info"); } diff --git a/B06_Section/B06_Section_UI_Cross_Revetment.ts b/B06_Section/B06_Section_UI_Cross_Revetment.ts index 8fc0199ea..0ff31a13e 100644 --- a/B06_Section/B06_Section_UI_Cross_Revetment.ts +++ b/B06_Section/B06_Section_UI_Cross_Revetment.ts @@ -24,9 +24,8 @@ import { PIPE_WALL_DEFAULT_RUN_M, REVET_EMBED_DEPTH_M, REVET_THICKNESS_M, - revetHeightLimit, } from "./B06_Section_UI_Cross_Culvert_Const"; -import { leanOf } from "./B06_Section_UI_Cross_Lean"; +import { outlineLeanOf } from "./B06_Section_UI_Cross_Lean"; import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const"; import { fillWallBaseWidth, groundInterpolator } from "./B06_Section_UI_Cross_Culvert_Solve"; import { buildExtrasAt, slopedCrossing } from "./B06_Section_UI_Cross_Culvert_Extra"; @@ -66,12 +65,6 @@ export interface RevetmentSpec { face_slope_ratio?: number | string | null; } -/** 표준경사 표를 읽는 벽 종류 → 형태. B08 이 돌쌓기 식으로 세는 것만(큰돌쌓기·옹벽은 종전 0.3). */ -const TABLE_FORM_BY_TYPE: Record = { - masonry_wet: "돌쌓기(찰)", - masonry_dry: "돌쌓기(메)", -}; - /** 조정창 조작값 중 이 벽이 쓰는 축 — 배관 벽과 같은 형태(x 좌우·d 사면·h 높이). */ export interface RevetmentAdjust { x?: number; @@ -187,19 +180,15 @@ export function computeRevetmentLayout( const limit = sampleLimit(section, outward); if (!edge || !groundAt || limit === null) return null; - // 높이 기준 = **벽 바닥~상단**(근입 0.5 포함 — 배관·추가 벽과 같은 기준, - // 2026-08-29 사용자). 재질(형태) 한계도 이 기준으로 자른다. - const pureHeight = Math.min( - Math.max(requestedHeight, REVET_EMBED_DEPTH_M + 0.1), - revetHeightLimit(spec.form), - ); + // 높이 기준 = **벽 바닥~상단**(근입 0.5 포함 — 배관·추가 벽과 같은 기준). + // 형태별 높이 한계 없음 — 사용자 입력 그대로(하한만 둠). + const pureHeight = Math.max(requestedHeight, REVET_EMBED_DEPTH_M + 0.1); /** 기준선(근입 위)~상단 — 도형 계산은 종전대로 이 값으로 한다. */ const height = pureHeight - REVET_EMBED_DEPTH_M; - // 전면 기울기 — B08 이 그 벽을 셀 때와 같은 칸(종류·요청 높이·단면유형·설치 측·사용자 칸)으로 - // 표준경사를 고름(2026-09-14 B5). 높이는 형태 한계로 자르기 **전** 값 — B08 은 저장 높이를 씀. - const lean = leanOf({ - form: TABLE_FORM_BY_TYPE[spec.type_id] ?? (spec.type_id === "revetment" ? spec.form : null), + // 전면 기울기 — 관 흙막이와 같은 `outlineLeanOf`(형태 무관 · 형태는 안 무늬만). 단면유형 · + // 설치 측 · 높이 · 사용자 칸은 그대로 탐. 수량(B08)은 자기 칸으로 따로 셈. + const lean = outlineLeanOf({ height_m: requestedHeight, section_mode: design.section_mode, side: spec.side, @@ -264,6 +253,7 @@ export function computeRevetmentLayout( lengthM, floatGapM, lean, + roadElevation: edge.elevation_m, }); // 넣은 좌우 이동이 **각도 하한**에 눌려 통째로 무시됐으면 그 하한을 남긴다(2026-09-07). // 실측 — d 2.6m 자리에서 하한이 1.0m 를 넘어 「1.0m 를 넣어도 0.00m 이동」이 났고, @@ -288,7 +278,7 @@ export function computeRevetmentLayout( ownerForm: spec.form, equalize: false, leanFor: (form, pureHeightM) => - leanOf({ + outlineLeanOf({ form, height_m: pureHeightM, section_mode: design.section_mode, @@ -402,9 +392,7 @@ export function appendRevetmentOverlay( const keyId = index === 0 ? "own" : `own-extra${index - 1}`; const tooltip = `독립 기슭막이 ${wall.form ?? ""} H=${(wall.height + REVET_EMBED_DEPTH_M).toFixed(1)}m` + - `(상단 = 성토선 접점, 전면 1:${wall.lean}(품셈 13-4-4 [주]⑪ 표준경사), 높이 한계 ${revetHeightLimit( - wall.form, - ).toFixed(1)}m — 교본 7-3)` + + `(상단 = 성토선 접점, 전면 1:${wall.lean}(품셈 13-4-4 [주]⑪ 표준경사))` + (wall.floatGapM > 0.01 ? ` · ⚠ 바닥 원지반 이격 ${wall.floatGapM.toFixed(2)}m — 하부 지지 별도` : ` · 근입 ${REVET_EMBED_DEPTH_M.toFixed(2)}m`) + diff --git a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts index e0d10169c..84c711b03 100644 --- a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts @@ -15,7 +15,7 @@ * ========================================================================== */ import type { InletStructureChoice, RevetKey } from "./B06_Section_UI_Cross_Culvert"; -import { REVET_FORMS, revetHeightLimit } from "./B06_Section_UI_Cross_Culvert_Const"; +import { REVET_FORMS } from "./B06_Section_UI_Cross_Culvert_Const"; import type { BasinAdjust, WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types"; import type { SpanValues } from "./B06_Section_UI_Cross_Culvert_Wire"; import { L } from "./B06_Section_UI_Section_Common"; @@ -230,7 +230,6 @@ export function buildStructurePanel( const option = document.createElement("option"); option.value = form; option.textContent = form; - option.title = `${L("B06_Cross_Height_Label")} ~${revetHeightLimit(form).toFixed(1)}m`; materialSelect.append(option); } materialSelect.addEventListener("change", () => { diff --git a/B06_Section/B06_Section_UI_Cross_Wall.ts b/B06_Section/B06_Section_UI_Cross_Wall.ts index 184090ad1..b087ed153 100644 --- a/B06_Section/B06_Section_UI_Cross_Wall.ts +++ b/B06_Section/B06_Section_UI_Cross_Wall.ts @@ -5,9 +5,8 @@ * (2026-08-28 사용자: "배관용에서 배관만 빼면 대부분 동일해야"). 벽 1매의 합성 단면 * (하부 사다리꼴 + 상부 평행사변형, 배면 수직·전면 1:0.3)과 그 돌쌓기 표현은 여기 하나뿐. * - * 배관 벽 계산은 아직 `_Cross_Culvert_Geom`이 자체 인라인으로 만든다 — 결과가 이 함수와 - * 동일함을 화면으로 확인한 뒤 그쪽도 이 함수로 바꾼다(계획서 후속). 지금은 독립 기슭막이가 - * 이 함수를 써서 배관 벽과 같은 모양·자리로 선다. + * 관 유입·유출 흙막이 · 추가 흙막이 · 사면안정 흙막이 · 옹벽이 모두 이 함수 하나로 선다. + * 형태가 바꾸는 것은 안 무늬(`_Cross_Wall_Hatch`)뿐 — 외곽은 높이·자리·기울기로만 정함. * ========================================================================== */ import { @@ -57,6 +56,8 @@ export interface RevetWallGeometryInput { extraIndex?: number; /** 전면 기울기 1:n 의 n — 표준경사(`leanOf`). 없으면 종전 0.3. */ lean?: number; + /** 노면(계획고) — 벽 윗면이 이보다 높으면 여기서 멈춤. 높이 한도는 이것 하나. */ + roadElevation?: number | null; } /** @@ -65,7 +66,12 @@ export interface RevetWallGeometryInput { * 상단(t/2)+띠(t), 전면 1:0.3, 하단선 = 기준표고 −0.5m 수평 기초. */ export function buildRevetWallGeometry(input: RevetWallGeometryInput): WallLayout { - const { anchor, outward, height, material, role } = input; + const { anchor, outward, material, role } = input; + const road = input.roadElevation; + const height = + road != null && anchor.elevation + input.height > road + 1e-9 + ? Math.max(road - anchor.elevation, 0) + : input.height; const thickness = input.thickness ?? REVET_THICKNESS_M; const lean = input.lean ?? REVET_LEAN_RATIO; const baseWidth = thickness * 1.5 + lean * height; diff --git a/B06_Section/B06_Section_UI_Page_Pipe_Options.ts b/B06_Section/B06_Section_UI_Page_Pipe_Options.ts index 063244321..3ee855477 100644 --- a/B06_Section/B06_Section_UI_Page_Pipe_Options.ts +++ b/B06_Section/B06_Section_UI_Page_Pipe_Options.ts @@ -16,8 +16,6 @@ import { applySpanPatch, EXTRA_WALL_MIN_HEIGHT_M, REVET_EMBED_DEPTH_M, - revetFormOf, - revetHeightLimit, revetWallSpec, } from "./B06_Section_UI_Cross_Culvert_Const"; import type { CrossSection } from "./B06_Section_Api_Fetch"; @@ -121,8 +119,7 @@ export function applyPipeOptionsToCache( h: height ?? blank, m: typeof form === "string" ? form : blank, }); - // 형태마다 높이 한계가 있다(돌쌓기(메) 2.0m 등). 기하가 잘라 낸 실제 높이를 - // 폼에 되돌린다 — 숫자만 커지고 그림은 그대로인 상태를 남기지 않는다. + // 높이 하한(관경 기준 최소)에 걸린 실제 높이를 폼에 되돌린다 — 형태별 상한은 없음. if (height === undefined) continue; const applied = revetWallSpec( role === "outlet" ? culvert.outlet : culvert.inlet, @@ -135,8 +132,8 @@ export function applyPipeOptionsToCache( [`${prefix}_revet_height_m`]: Number(applied.toFixed(1)), }); showToast( - `${prefix === "outlet" ? "유출" : "유입"} 기슭막이 높이는 형태 한계로 ` + - `${applied.toFixed(1)}m까지만 적용됩니다.`, + `${prefix === "outlet" ? "유출" : "유입"} 기슭막이 높이는 최소 ` + + `${applied.toFixed(1)}m로 적용됩니다.`, "error", ); } @@ -170,7 +167,7 @@ const soilSent = new Map(); * 흙막이 n 컨테이너(`soil_add_{n}_*`) → 유출 다단 n 단(`extra{n-1}`) 그림(54-10). 형태 · 높이 = 조정값 * (m · h · 빈 칸 = 자동) · 길이 · 전 · 후 = 단별 구간값(빈 칸 = 그대로). 지형이 세운 단까지만 — * 넘친 컨테이너 값은 파일에만 남음. 높이 = 요청 · 서는 키는 기하 규칙 그대로(지형 맞춤 · 형태 한계) · - * 형태 한계 · 하한을 넘은 요청만 폼에 되돌림. + * 하한을 넘은 요청만 폼에 되돌림(형태별 상한 없음). */ function applySoilTiers( ctx: PipeOptionsContext, @@ -178,13 +175,6 @@ function applySoilTiers( patch: Record, whole: boolean, ): void { - const culvert = owner.culvert!; - const ownerForm = revetWallSpec( - culvert.outlet, - ctx.revetOffset.adjustFor(owner, "outlet"), - false, - culvert.diameter_m, - ).form; const value = (n: number, item: string): number | undefined => { const raw = patch[`soil_add_${n}_${item}`]; return raw === undefined || raw === "" || !Number.isFinite(Number(raw)) @@ -202,20 +192,10 @@ function applySoilTiers( m: typeof form === "string" && form ? form : blank, }; if (want.h != null) { - const limit = revetHeightLimit( - revetFormOf(want.m ?? ctx.revetOffset.adjustFor(owner, role).m, ownerForm), - ); - const h = Math.min( - Math.max(want.h, EXTRA_WALL_MIN_HEIGHT_M, REVET_EMBED_DEPTH_M + 0.1), - limit, - ); + const h = Math.max(want.h, EXTRA_WALL_MIN_HEIGHT_M, REVET_EMBED_DEPTH_M + 0.1); if (Math.abs(h - want.h) > 0.05) { clipped[`soil_add_${n}_height`] = Number(h.toFixed(1)); - showToast( - `흙막이 ${n} 높이는 ${h < want.h ? "형태 한계로" : "최소"} ${h.toFixed(1)}m` + - `${h < want.h ? "까지만" : "로"} 적용됩니다.`, - "error", - ); + showToast(`흙막이 ${n} 높이는 최소 ${h.toFixed(1)}m로 적용됩니다.`, "error"); } want.h = Number(h.toFixed(1)); } diff --git a/resources/tester/test_b06_wall_shape_unified.py b/resources/tester/test_b06_wall_shape_unified.py new file mode 100644 index 000000000..f5b026581 --- /dev/null +++ b/resources/tester/test_b06_wall_shape_unified.py @@ -0,0 +1,98 @@ +"""횡단 벽 형상 한 벌 — 관 유입·유출 흙막이 · 추가 흙막이 · 사면안정 흙막이 · 옹벽. + +규칙(10-6 사용자): + ① 네 벽 모두 공용 `buildRevetWallGeometry` 로 선다 — 형태는 안 무늬만 바꿈(외곽 같음). + ② 형태별 높이 한도(메 2.0 · 찰 3.0 · 콘크리트 5.0) · 원지반 맞춤 자동 자르기 없음. + ③ 벽 윗면은 노면 위로 올라오지 않음(넘으면 노면에서 멈춤). + +서버 면적은 같은 TS 를 Node 로 돌리므로(`B06_Section_Server_Calc_Node.ts`) 이 함수가 곧 서버 기하. +""" + +import json +import re +import subprocess +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +B06 = PROJECT_ROOT / "B06_Section" +TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" + +_RUNNER = """ +import { writeFileSync } from "node:fs"; +import { buildRevetWallGeometry } from "./B06_Section_UI_Cross_Wall.js"; +import { revetWallSpec, REVET_FORMS } from "./B06_Section_UI_Cross_Culvert_Const.js"; +const anchor = { offset: 5, elevation: 100 }; +const outline = (form, role, extra = {}) => + buildRevetWallGeometry({ anchor, outward: 1, height: 2.5, material: "dry", role, form, ...extra }) + .points.map((p) => [p.offset, p.elevation]); +writeFileSync(process.argv[2], JSON.stringify({ + forms: REVET_FORMS.map((form) => outline(form, "outlet")), + roles: ["inlet", "outlet", "extra"].map((role) => outline("돌쌓기(메)", role)), + capped: buildRevetWallGeometry({ + anchor, outward: 1, height: 5, material: "dry", role: "outlet", roadElevation: 103, + }).topJoint.elevation, + under: buildRevetWallGeometry({ + anchor, outward: 1, height: 2, material: "dry", role: "outlet", roadElevation: 103, + }).topJoint.elevation, + dry: revetWallSpec({ revet_form: "돌쌓기(메)" }, { h: 3.0 }, true, 0.3).pureHeight, + concrete: revetWallSpec({ revet_form: "콘크리트" }, { h: 7.5 }, true, 0.3).pureHeight, +})); +""" + + +@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치") +def test_벽_외곽_한_벌_높이_입력_그대로_노면_멈춤(tmp_path: Path) -> None: + out = tmp_path / "js" + # 형(type) import 가 끌어오는 페이지 파일은 별칭을 못 풀어 오류를 내지만 산출물은 나옴. + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(TSC), + str(B06 / "B06_Section_UI_Cross_Wall.ts"), + "--outDir", + str(out), + "--module", + "esnext", + "--target", + "es2022", + "--moduleResolution", + "bundler", + "--skipLibCheck", + "--ignoreConfig", + ], + cwd=str(PROJECT_ROOT), + capture_output=True, + ) + for emitted in out.rglob("*.js"): + emitted.write_text( + re.sub(r'(from\s+"\./[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")), + encoding="utf-8", + ) + base = next(path.parent for path in out.rglob("B06_Section_UI_Cross_Wall.js")) + (base / "runner.mjs").write_text(_RUNNER, encoding="utf-8") + result = tmp_path / "out.json" + subprocess.run( # noqa: S603 + ["node", str(base / "runner.mjs"), str(result)], check=True, capture_output=True + ) + got = json.loads(result.read_text(encoding="utf-8")) + + assert all(outline == got["forms"][0] for outline in got["forms"]), "형태마다 외곽이 다름" + assert all(outline == got["roles"][0] for outline in got["roles"]), "벽 갈래마다 외곽이 다름" + assert got["capped"] == pytest.approx(103.0), "노면 위로 올라옴" + assert got["under"] == pytest.approx(102.0), "노면 아래 벽이 잘림" + assert got["dry"] == pytest.approx(3.0), "돌쌓기(메) 3.0 이 잘림" + assert got["concrete"] == pytest.approx(7.5), "콘크리트 7.5 가 잘림" + + +def test_네_벽이_공용_함수로_서고_형태_한도가_없다() -> None: + for name in ("Culvert_Geom", "Culvert_Extra", "Revetment"): + source = (B06 / f"B06_Section_UI_Cross_{name}.ts").read_text(encoding="utf-8") + assert "buildRevetWallGeometry(" in source, f"{name} 가 공용 벽 함수를 안 씀" + for path in B06.glob("*.ts"): + source = path.read_text(encoding="utf-8") + assert "revetHeightLimit" not in source, f"{path.name} 에 형태 높이 한도가 남음" + assert "materialLimit" not in source, f"{path.name} 에 재질 높이 한도가 남음" + extra = (B06 / "B06_Section_UI_Cross_Culvert_Extra.ts").read_text(encoding="utf-8") + assert "fitted" not in extra, "추가 흙막이가 원지반에 맞춰 늘어남" diff --git a/resources/tester/test_user_values_survive.py b/resources/tester/test_user_values_survive.py index 9a8392fed..7fb7893e3 100644 --- a/resources/tester/test_user_values_survive.py +++ b/resources/tester/test_user_values_survive.py @@ -220,8 +220,12 @@ def test_저장된_표준단면이_브라우저까지_간다(): # 값으로 재는 확인은 화면 실측에 있음(보조 창 b269ea34 81.77m — 단 0 → 1 에서 성토 # 10.9027 → 10.6743㎡). 계획서에 그 수치가 남아 있다. def test_다단_성토부선이_면적_트림에_들어간다(): - source = (PROJECT_ROOT / "B06_Section" / "B06_Section_UI_Cross_Culvert_Geom.ts").read_text( - encoding="utf-8" + # 다단 · 묻힌 구간 거르기는 `_Culvert_Tiers` 로 나뉘어 있어 두 파일을 함께 읽음. + source = "".join( + (PROJECT_ROOT / "B06_Section" / f"B06_Section_UI_Cross_Culvert_{name}.ts").read_text( + encoding="utf-8" + ) + for name in ("Geom", "Tiers") ) assert "const extendTrimSlope = (" in source, "트림 확장 자리가 사라짐" # 유출측·집수정 계류측 **둘 다** — 한쪽만 넣으면 그쪽 단만 물량에 잡힌다. diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 9ab406183..aebdfa3cb 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -267,10 +267,6 @@ export const ui_locales_b2 = { B06_Cross_Move_Label: ["이동(좌우·사면 상하)", "Move (lateral / along slope)"], B06_Cross_Height_Minus: ["높이 −0.1m", "Height −0.1m"], B06_Cross_Height_Plus: ["높이 +0.1m", "Height +0.1m"], - B06_Cross_Height_Limit: [ - "{mat} 높이 한계 {limit}m — 더 올리려면 재질을 변경하세요", - "{mat} height limit {limit}m — change material to go higher", - ], B06_Cross_Height_Floor: ["최소 높이라 더 낮출 수 없습니다", "Already at the minimum height"], B06_Cross_Basin_Limit_Pipe: [ "여기까지입니다 — 더 옮기면 배관 길이가 달라집니다(I형은 관을 감싸는 구조)",