From 8167dd23c37dd3554ba044475063fc2a7ac1518f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 22 Aug 2026 13:39:21 +0900 Subject: [PATCH] =?UTF-8?q?feat(B06):=20=EB=8B=A4=EB=8B=A8=20=EA=B8=B0?= =?UTF-8?q?=EC=8A=AD=EB=A7=89=EC=9D=B4=20=EA=B7=9C=EC=B9=99=20=EC=9E=AC?= =?UTF-8?q?=EC=A0=95=EB=A6=BD=20=E2=80=94=20=EC=A0=95=ED=99=95=ED=95=9C=20?= =?UTF-8?q?1:1.2=C2=B7=EB=A7=A4=EB=AA=B0=20=EC=A2=85=EB=8B=A8=C2=B7?= =?UTF-8?q?=EB=8B=A8=20=EC=88=98=20=EC=9E=85=EB=A0=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 벽 사이 성토사면 = 정확히 1:1.2 — 벽 높이를 자리에서 역산(닫힌식), 0.1m 눈금 올림 제거(물매 정확 유지 우선) - 아랫단 상단은 윗단 하단(수평 기초) 관통 금지 — 안쪽 이동 한계 - 다음 단 성토선 시작 = 윗단 하단 +0.5m 수평선 × 전면 경사선(1:0.3) 교차점 (배관 벽의 관 하단 꼭짓점과 같은 자리) - 시작점이 원지반 아래(벽 0.5m 이상 매몰)면 성토 불필요 — 다단 종료 - 추가 방식 = 유출 벽 조정창의 단 수 숫자 입력(+/- 버튼 대체), 지형 허용 단 수 초과 시 토스트로 가능한 단 수 안내 후 되는 만큼만 반영 Co-Authored-By: Claude Opus 5 (1M context) --- .../B06_Section_UI_Cross_Culvert_Extra.ts | 211 +++++++++++------- .../B06_Section_UI_Cross_Culvert_Geom.ts | 2 +- .../B06_Section_UI_Cross_Culvert_Types.ts | 2 + .../B06_Section_UI_Cross_Culvert_Wire.ts | 8 + .../B06_Section_UI_Cross_Structure_Panel.ts | 44 ++-- B06_Section/B06_Section_UI_Cross_View.ts | 21 +- .../B06_Section_UI_Page_Station_Controls.ts | 23 +- B06_Section/B06_Section_UI_Style_Cross.css | 10 + ui_template/ui_template_locale_b2.ts | 7 +- 9 files changed, 203 insertions(+), 125 deletions(-) diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts index 08b54565..bcddb778 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts @@ -1,45 +1,47 @@ /* ============================================================================= * B06_Section_UI_Cross_Culvert_Extra.ts - * 유출측 **성토부선 + 추가 기슭막이**(2026-08-22 사용자) — Geom에서 분리(700줄 제한). + * 유출측 **성토부선 + 추가 기슭막이(다단)** — Geom에서 분리(700줄 제한). * - * 종전 보호공(돌붙임) 띠는 삭제하고 그 **윗면 선만 성토부선**으로 남긴다. 관 하단 - * 꼭짓점에서 1:1.2로 원지반까지 내려가는 이 선이 5m 이상이면 기슭막이 의무 구간 - * (성토_비탈면.md §2)이라 사용자가 **추가 기슭막이**(배관 없는 벽)를 둘 수 있다. - * 추가 벽 뒤에도 성토부가 5m 이상 남으면 또 추가할 수 있다(계단식). + * 다단 기슭막이는 **1:1.2 고정 물매에서 안정적으로 성토고를 올리는 수단**이다 + * (2026-08-22 사용자 확정 규칙): + * · 기슭막이 사이 성토사면은 **정확히 1:1.2** — 벽 높이를 자리에 맞춰 역산한다. + * · 아랫단 벽 상단은 윗단 벽 **하단(수평 기초선)을 뚫고 올라갈 수 없다**. + * · 다음 단 성토선 시작점 = 윗단 벽 **하단 수평선 +0.5m와 전면 경사선(1:0.3)의 + * 교차점**(배관 기슭막이의 관 하단 꼭짓점과 같은 자리 — 배관만 없다). + * · 그 시작점이 원지반 아래면(벽이 0.5m 이상 묻힘) **성토 불필요** — 다단 종료. * ========================================================================== */ import { FILL_SLOPE_MAX_LENGTH_M, + FILL_SLOPE_RATIO_MIN, REVET_EMBED_DEPTH_M, REVET_LEAN_RATIO, REVET_THICKNESS_M, } from "./B06_Section_UI_Cross_Culvert_Const"; -import { - clampWallOffset, - minShoulderWallOffset, - solveWallVertical, -} from "./B06_Section_UI_Cross_Culvert_Solve"; import type { OffsetPoint, OutletFillSegment, WallLayout, } from "./B06_Section_UI_Cross_Culvert_Types"; +/** 추가 벽 최소 높이(m, 임시) — 이보다 낮아지는 자리(바깥)로는 밀 수 없다. */ +export const EXTRA_MIN_HEIGHT_M = 0.5; + export interface OutletExtrasInput { - /** 성토부선 시작점 = 관 유출 하단 꼭짓점. */ + /** 성토부선 시작점 = 관 유출 하단 꼭짓점(배관 벽의 성토선 시작 자리). */ start: OffsetPoint; + /** 배관 벽 하단(수평 기초) 표고 — 1단 벽 상단이 이 아래에 있어야 한다. */ + startBottomElevation: number; outward: number; groundAt: (offset: number) => number; - /** 성토부 물매(1:n) — 설계 성토 물매(기본 1.2). */ - fillRatio: number; - /** 추가 벽 표준 높이(배관용과 동일: 관경+여유고, 0.1m 눈금). */ + /** 표준 높이(관경+여유고) — 자동 자리는 이 높이가 딱 맞는 지점이다. */ wallHeight: number; - /** 형태별 높이 한계(찰 3.0/메 2.0). */ + /** 형태별 높이 한계(찰 3.0/메 2.0) — 안쪽 당김 한계. */ heightLimit: number; form: string | null; /** 계류측 샘플 한계 offset. */ limitOffset: number; - /** 사용자가 민 이동량(m, + = 계류측 바깥) — 벽 개수만큼. */ + /** 사용자가 민 이동량(m, + = 계류측 바깥) — 요청한 단 수만큼. */ shifts: number[]; } @@ -48,19 +50,19 @@ export interface OutletExtrasResult { segments: OutletFillSegment[]; /** 한계에 잘린 뒤의 실제 이동량 — 조정창이 되받는다. */ appliedShifts: number[]; - /** 끝 성토부가 아직 5m 이상 — 추가 기슭막이를 더 둘 수 있다. */ + /** 끝 성토부가 아직 5m 이상 — 단을 더 둘 수 있다(의무 구간). */ addable: boolean; } -/** - * 성토부선(끝 구간) — src에서 1:n으로 내려가며 원지반을 만나면 끝난다. - * 지반이 사면보다 높은 구간은 지반을 따른다(종전 보호공 윗면과 동일 규칙, - * 최소 길이 연장은 보호공 삭제와 함께 없앴다). - */ +/** 벽 중심(하단 중점)에서 이음선 상단점까지의 수평거리 — 높이에 따라 커진다. */ +function jointRunOf(height: number): number { + return 0.25 * REVET_THICKNESS_M + (REVET_LEAN_RATIO / 2) * height; +} + +/** 끝 성토부선 — src에서 1:1.2로 내려가며 원지반을 만나면 끝(지반이 높으면 지반 따름). */ function trailingFill( src: OffsetPoint, outward: number, - ratio: number, groundAt: (offset: number) => number, ): OutletFillSegment { const points: OffsetPoint[] = [src]; @@ -69,7 +71,7 @@ function trailingFill( const step = 0.25; for (let t = step; t <= 30 + 1e-9; t += step) { const offset = src.offset + outward * t; - const slopeElevation = src.elevation - t / ratio; + const slopeElevation = src.elevation - t / FILL_SLOPE_RATIO_MIN; const groundElevation = groundAt(offset); const point: OffsetPoint = { offset, elevation: Math.max(slopeElevation, groundElevation) }; length += Math.hypot(point.offset - previous.offset, point.elevation - previous.elevation); @@ -77,24 +79,24 @@ function trailingFill( previous = point; if (slopeElevation <= groundElevation) break; } - return { points, lengthM: length, overLimit: length >= FILL_SLOPE_MAX_LENGTH_M - 1e-6 }; -} - -/** 성토부선이 원지반과 만나는 offset — 추가 벽 자동 자리 스캔의 사면 끝. */ -function trailingToeOffset(segment: OutletFillSegment): number { - return segment.points[segment.points.length - 1].offset; + return { + points, + lengthM: length, + ratio: FILL_SLOPE_RATIO_MIN, + overLimit: length >= FILL_SLOPE_MAX_LENGTH_M - 1e-6, + }; } /** - * 추가 기슭막이 벽 1매 — 배관용 기슭막이와 **같은 형상**(배면 수직·전면 1:0.3, - * 수평 기초 근입 0.5m), 배관·마감면만 없다(2026-08-22 사용자 ③). + * 추가 기슭막이 벽 1매 — 배관용 기슭막이와 같은 형상(배면 수직·전면 1:0.3, + * 수평 기초 근입 0.5m), 배관·마감면만 없다. 높이는 눈금 없이 **정확값**을 쓴다 — + * 0.1m 올림을 하면 상단이 1:1.2 사면선을 벗어난다(사이 물매 정확 유지가 우선). */ function buildExtraWall( index: number, anchor: OffsetPoint, outward: number, height: number, - floatGapM: number, form: string | null, groundAt: (offset: number) => number, ): WallLayout { @@ -109,14 +111,9 @@ function buildExtraWall( const frontXAt = (elevation: number): number => topFront + outward * REVET_LEAN_RATIO * (topElevation - elevation); let bottomElevation = - (floatGapM > 1e-6 - ? anchor.elevation - : Math.min(groundAt(backOffset), groundAt(frontBase), anchor.elevation)) - - REVET_EMBED_DEPTH_M; - if (floatGapM <= 1e-6) { - const toeGround = Math.min(groundAt(frontXAt(bottomElevation)), anchor.elevation); - bottomElevation = Math.min(bottomElevation, toeGround - REVET_EMBED_DEPTH_M); - } + Math.min(groundAt(backOffset), groundAt(frontBase), anchor.elevation) - REVET_EMBED_DEPTH_M; + const toeGround = Math.min(groundAt(frontXAt(bottomElevation)), anchor.elevation); + bottomElevation = Math.min(bottomElevation, toeGround - REVET_EMBED_DEPTH_M); const bottomBack: OffsetPoint = { offset: backOffset, elevation: bottomElevation }; const bottomFront: OffsetPoint = { offset: frontXAt(bottomElevation), @@ -131,7 +128,7 @@ function buildExtraWall( outerOffset: bottomFront.offset, base: anchor.elevation, height, - floatGapM, + floatGapM: 0, outward, topBack, topJoint: { offset: topJoint, elevation: topElevation }, @@ -142,61 +139,111 @@ function buildExtraWall( } /** - * 유출측 성토부선·추가 기슭막이 일괄 계산. + * 유출측 성토부선·다단 기슭막이 일괄 계산. * - * 벽 i의 자동 자리 = 앞 구간 시작점(src)을 노견 삼아 **1:1.2가 딱 성립하는 가장 - * 안쪽 자리**(배관용 기슭막이와 같은 규칙 — minShoulderWallOffset 재사용). 사용자가 - * 안쪽으로 당기면 높이 증가 → 한계 시 바닥 띄움(solveWallVertical 동일 사다리). - * 벽 상단 구간은 src → 이음선 상단점 직선, 벽 다음 src = 전면 상단 꼭짓점. + * 각 단의 벽 높이는 자리에서 역산한다 — 이음선 상단점이 src에서 내려오는 + * **1:1.2 사면선 위에 정확히** 놓이는 높이. 닫힌식(D = 벽 중심~src 수평거리): + * 이음선 자리 R = D − jointRun(h), 상단 = src.elev − R/1.2 = 지반(x) + h + * → h = (src.elev − 지반(x) − (D − 0.25t)/1.2) / (1 − 0.15/1.2) + * 자동 자리 = h가 표준 높이(관경+여유고)에 처음 닿는 **가장 안쪽 지점**. + * 이동 한계: 안쪽 = 윗단 하단 관통 금지(상단 ≤ 윗단 하단)·높이 한계(찰3/메2), + * 바깥쪽 = 최소 높이 0.5m. 요청 단 수보다 지형이 허락하는 단이 적으면 되는 + * 만큼만 세운다 — 조정창이 개수 차이를 보고 토스트로 가능한 단 수를 알린다. */ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult { - const { outward, groundAt, fillRatio, wallHeight, heightLimit, form, limitOffset } = input; + const { outward, groundAt, wallHeight, heightLimit, form, limitOffset } = input; + const thickness = REVET_THICKNESS_M; + const heightDenominator = 1 - REVET_LEAN_RATIO / 2 / FILL_SLOPE_RATIO_MIN; const walls: WallLayout[] = []; const segments: OutletFillSegment[] = []; const appliedShifts: number[] = []; let src = input.start; + let prevBottom = input.startBottomElevation; + for (let i = 0; i < input.shifts.length; i += 1) { - const trailing = trailingFill(src, outward, fillRatio, groundAt); - // 성토부가 이미 원지반에 닿아 사실상 없으면 더 세울 수 없다 — 남은 벽은 버린다. - if (trailing.lengthM < 0.5) break; - const srcEdge = { offset_m: src.offset, elevation_m: src.elevation }; - const toe = trailingToeOffset(trailing); - const auto = - minShoulderWallOffset(srcEdge, outward, wallHeight, groundAt, toe, limitOffset) ?? toe; - // 안쪽 당김 한계 = 구간 시작점(앞 구조물) — 그 안쪽은 앞 벽·관 몫이다. - const shifted = clampWallOffset(auto + outward * input.shifts[i], src.offset, outward); - appliedShifts.push(Math.round((shifted - auto) * outward * 10) / 10); - const vertical = solveWallVertical( - shifted, - srcEdge, - outward, - groundAt(shifted), - wallHeight, - heightLimit, - ); - const wall = buildExtraWall( - i, - { offset: shifted, elevation: vertical.baseElevation }, - outward, - vertical.height, - vertical.floatGapM, - form, - groundAt, - ); + // 시작점이 원지반 아래 = 윗단 벽이 0.5m 이상 묻힘 → 성토 불필요, 다단 종료. + if (groundAt(src.offset) >= src.elevation - 0.01) break; + const trailing = trailingFill(src, outward, groundAt); + if (trailing.lengthM < 0.7) break; + + /** 자리 x(벽 하단 중점)에서 1:1.2를 정확히 지키는 벽 높이(닫힌식). */ + const heightAt = (x: number): number => { + const run = (x - src.offset) * outward; + return ( + (src.elevation - groundAt(x) - (run - 0.25 * thickness) / FILL_SLOPE_RATIO_MIN) / + heightDenominator + ); + }; + // 아랫단 상단이 윗단 하단을 넘지 않을 이음선 최소 수평거리(상단 ≤ 윗단 하단). + const minJointRun = FILL_SLOPE_RATIO_MIN * Math.max(src.elevation - prevBottom, 0); + const feasible = (x: number): boolean => { + const h = heightAt(x); + return ( + h >= EXTRA_MIN_HEIGHT_M - 1e-9 && + h <= heightLimit + 1e-9 && + (x - src.offset) * outward - jointRunOf(h) >= minJointRun - 1e-6 + ); + }; + // 자동 자리 = 표준 높이가 처음 성립하는 가장 안쪽 지점(0.05m 스캔). 표준까지 + // 못 크는 지형이면 조건을 만족하는 첫 자리로 폴백. + const step = 0.05; + const span = (limitOffset - src.offset) * outward; + let auto: number | null = null; + let fallback: number | null = null; + for (let t = step; t <= Math.max(span, 0) + 1e-9; t += step) { + const x = src.offset + outward * t; + if (!feasible(x)) continue; + if (fallback === null) fallback = x; + if (heightAt(x) >= wallHeight - 1e-9) { + auto = x; + break; + } + } + const autoOffset = auto ?? fallback; + if (autoOffset === null) break; // 이 단은 세울 자리가 없다 — 되는 만큼만. + // 사용자 이동 반영 후, 안 되는 자리면 되는 쪽으로 0.05m씩 되돌린다 + // (안쪽 위반 → 바깥으로, 바깥 위반(최소 높이 미달) → 안쪽으로). + let shifted = autoOffset + outward * input.shifts[i]; + for (let pass = 0; pass < 400 && !feasible(shifted); pass += 1) { + const h = heightAt(shifted); + const tooInner = + h > heightLimit || + (shifted - src.offset) * outward - jointRunOf(Math.min(h, heightLimit)) < minJointRun; + shifted += outward * (tooInner ? step : -step); + } + if (!feasible(shifted)) break; + appliedShifts.push(Math.round((shifted - autoOffset) * outward * 10) / 10); + + const height = heightAt(shifted); + const anchor: OffsetPoint = { offset: shifted, elevation: groundAt(shifted) }; + const wall = buildExtraWall(i, anchor, outward, height, form, groundAt); walls.push(wall); - // src → 벽 이음선 상단점 직선 구간. 물매가 1:1.2보다 급하면 경고 대상(overLimit - // 아님 — 길이 기준만 적는다). + // src → 이음선 상단점 — 정확히 1:1.2(높이 역산으로 보장). const run = Math.abs(wall.topJoint.offset - src.offset); const rise = src.elevation - wall.topJoint.elevation; segments.push({ points: [src, wall.topJoint], lengthM: Math.hypot(run, rise), + ratio: rise > 1e-9 ? run / rise : FILL_SLOPE_RATIO_MIN, overLimit: Math.hypot(run, rise) >= FILL_SLOPE_MAX_LENGTH_M - 1e-6, }); - // 다음 성토부 시작점 = 벽 전면 상단 꼭짓점(points[2]). - src = wall.points[2]; + // 다음 단 시작점 = 이 벽 하단 +0.5m 수평선과 전면 경사선(1:0.3)의 교차점. + const startElevation = wall.bottomBack.elevation + REVET_EMBED_DEPTH_M; + src = { + offset: + wall.points[2].offset + + outward * REVET_LEAN_RATIO * (wall.topJoint.elevation - startElevation), + elevation: startElevation, + }; + prevBottom = wall.bottomBack.elevation; } - const tail = trailingFill(src, outward, fillRatio, groundAt); - segments.push(tail); - return { walls, segments, appliedShifts, addable: tail.overLimit }; + + // 끝 성토부 — 마지막 구조물의 시작점이 지반 위일 때만 그린다(묻히면 성토 불필요). + let addable = false; + if (groundAt(src.offset) < src.elevation - 0.01) { + const tail = trailingFill(src, outward, groundAt); + segments.push(tail); + addable = tail.overLimit; + } + return { walls, segments, appliedShifts, addable }; } diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts index 68d65f20..73ab3660 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts @@ -587,9 +587,9 @@ export function computeCulvertLayout( const extras = outletWall ? buildOutletExtras({ start: pipeCorners.outlet.bottom, + startBottomElevation: outletWall.bottomBack.elevation, outward: outletInfo.outward, groundAt, - fillRatio: section.design?.fill_slope_ratio ?? FILL_SLOPE_RATIO_MIN, wallHeight: wallHeightFor(culvert.outlet), heightLimit: revetHeightLimit(culvert.outlet.revet_form), form: culvert.outlet.revet_form ?? null, diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts index 12b43f2b..9ebcc0a1 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts @@ -102,6 +102,8 @@ export interface PipeEnd { export interface OutletFillSegment { points: OffsetPoint[]; lengthM: number; + /** 구간 물매(1:n) — 벽 사이 구간은 높이 역산으로 정확히 1.2다. */ + ratio?: number; /** 사면길이 5m 이상 — 기슭막이 의무 구간(별표2·성토_비탈면 §2). */ overLimit: boolean; } diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts index 4bc389e0..f94502a1 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts @@ -50,6 +50,14 @@ export function computeCardCulvert( ); if (!layout) return null; if (extraWalls && extraWalls.countFor(section) > layout.extraWalls.length) { + // 요청 단 수보다 지형이 허락하는 단이 적다(벽이 원지반에 0.5m 이상 묻히면 성토 + // 불필요 — 그 아래 단은 못 세운다). 조작 중일 때만 가능한 단 수를 토스트로 알린다. + if (activeRevet === "outlet" || activeRevet?.startsWith("extra")) { + showToast( + L("B06_Cross_Extra_Limit").replace("{n}", String(layout.extraWalls.length)), + "info", + ); + } extraWalls.syncCount(section.chainage_m, layout.extraWalls.length); } if (revetOffset) { diff --git a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts index 83b52b98..b4f85fcf 100644 --- a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts @@ -33,12 +33,11 @@ export interface StructurePanelDeps { canNudge: (key: RevetKey) => boolean; /** 상황에 안 맞는 선택지 숨김(2026-08-22 사용자) — 기하가 판정한 가용성. */ optionsFor: () => { revetAllowed: boolean; basinLUAllowed: boolean }; - /** 추가 기슭막이 상태 — 끝 성토부 5m 이상(canAdd)일 때만 +를 보인다. */ + /** 다단 기슭막이 상태 — 유출 벽 선택 시 개수 입력 행 표시 판단에 쓴다. */ extraState: () => { canAdd: boolean; count: number }; - /** 추가 기슭막이 하나 추가(유출 성토부 끝 구간에). */ - addExtra: () => void; - /** 마지막 추가 기슭막이 삭제 — 마지막 벽이 선택된 상태에서만 버튼이 보인다. */ - removeExtra: () => void; + /** 다단 기슭막이 단 수 지정(2026-08-22 사용자 — 숫자 입력). 지형이 허락하는 + * 단 수보다 크면 기하가 되는 만큼만 세우고 토스트로 알린다. */ + setExtraCount: (count: number) => void; } export interface StructurePanelHandle { @@ -86,10 +85,6 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan if (current) run(current); }; - // 추가 기슭막이 +/-(2026-08-22 사용자 — 성토부 5m 이상이면 계단식 추가). - const addButton = makeButton("+", L("B06_Cross_Extra_Add"), () => deps.addExtra()); - const removeButton = makeButton("-", L("B06_Cross_Extra_Remove"), () => deps.removeExtra()); - const buttons = document.createElement("div"); buttons.className = "b06-structure-panel__buttons"; buttons.append( @@ -108,10 +103,26 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan L("B06_Cross_Revet_Reset"), act((key) => deps.reset(key)), ), - addButton, - removeButton, ); + // 다단 기슭막이 단 수 입력(2026-08-22 사용자) — 유출 벽 기준 숫자 입력. + const extraRow = document.createElement("label"); + extraRow.className = "b06-structure-panel__struct"; + const extraLabel = document.createElement("span"); + extraLabel.textContent = L("B06_Cross_Extra_Count"); + const countInput = document.createElement("input"); + countInput.type = "number"; + countInput.min = "0"; + countInput.max = "9"; + countInput.step = "1"; + countInput.className = "b06-structure-panel__count"; + countInput.addEventListener("change", () => { + const requested = Math.max(0, Math.min(9, Math.round(Number(countInput.value) || 0))); + countInput.value = String(requested); + deps.setExtraCount(requested); + }); + extraRow.append(extraLabel, countInput); + // 유입측 구조물 형식 드롭다운(2026-08-22 사용자) — 자동/기슭막이/집수정 I·ㄴ·ㄷ. const structureRow = document.createElement("label"); structureRow.className = "b06-structure-panel__struct"; @@ -143,7 +154,7 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan const head = document.createElement("div"); head.className = "b06-structure-panel__head"; head.append(title, closeButton); - root.append(head, value, structureRow, buttons); + root.append(head, value, structureRow, extraRow, buttons); return { root, @@ -176,10 +187,13 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan select.value = current; } buttons.classList.toggle("is-hidden", !deps.canNudge(key)); - // +는 유출·추가 벽에서 끝 성토부가 5m 이상일 때, -는 마지막 추가 벽에서만. + // 단 수 입력은 **유출 벽에서만** — 성토부가 5m 이상(의무)이거나 이미 단이 있을 때. const extra = deps.extraState(); - addButton.classList.toggle("is-hidden", !(extra.canAdd && (key === "outlet" || isExtra))); - removeButton.classList.toggle("is-hidden", !(isExtra && key === `extra${extra.count - 1}`)); + extraRow.classList.toggle( + "is-hidden", + !(key === "outlet" && (extra.canAdd || extra.count > 0)), + ); + countInput.value = String(extra.count); const shift = deps.shiftFor(key); // 자동 자리 기준 이동량 — 0이면 "자동"으로 적어 손을 안 댔음을 바로 알린다. // 부호(+/−)만 적으면 좌·우 벽에서 어느 쪽인지 읽히지 않아 **안/바깥**으로 적는다. diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 1a29af66..cf42b9ee 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -181,13 +181,12 @@ export interface InletStructureControl { set: (chainageM: number, value: InletStructureChoice) => void; } -/** 유출측 추가 기슭막이 개수 제어(2026-08-22 사용자 — 성토부 5m 이상 계단식 추가). */ +/** 유출측 다단 기슭막이 단 수 제어(2026-08-22 사용자 — 유출 벽 기준 숫자 입력). */ export interface ExtraWallControl { countFor: (section: CrossSection) => number; - add: (chainageM: number) => void; - /** 마지막 추가 벽 삭제(그 벽의 이동량도 함께 지운다). */ - removeLast: (chainageM: number) => void; - /** 기하가 실제로 세운 개수로 잘라 동기화(성토부가 없어져 못 세운 벽 정리). */ + /** 단 수 지정 — 줄이면 사라지는 단의 이동량도 함께 지운다. */ + setCount: (chainageM: number, count: number) => void; + /** 기하가 실제로 세운 단 수로 잘라 동기화(지형상 못 세운 단 정리). */ syncCount: (chainageM: number, built: number) => void; } @@ -621,16 +620,14 @@ export function createCrossSectionCard( canNudge: (key) => key !== "inlet" || !culvertInletIsBasin, optionsFor: () => culvertInletOptions, extraState: () => culvertExtraState, - addExtra: () => extraWalls?.add(section.chainage_m), - removeExtra: () => { - // 삭제되는 벽이 선택 중이면 선택을 유출 벽으로 옮긴다 — 다시 그려질 때 - // 없는 키를 강조하려다 조정창이 빈 대상으로 남는 것을 막는다. - const last = `extra${culvertExtraState.count - 1}` as RevetKey; - if (activeRevet === last) { + setExtraCount: (count) => { + // 줄어들며 사라지는 벽이 선택 중이면 선택을 유출 벽으로 옮긴다 — 다시 + // 그려질 때 없는 키를 강조하려다 조정창이 빈 대상으로 남는 것을 막는다. + if (activeRevet?.startsWith("extra") && Number(activeRevet.slice(5)) >= count) { activeRevet = "outlet"; revetOffset?.select(section.chainage_m, "outlet"); } - extraWalls?.removeLast(section.chainage_m); + extraWalls?.setCount(section.chainage_m, count); }, }); showRevetControl = (visible) => panel.show(visible ? activeRevet : null); diff --git a/B06_Section/B06_Section_UI_Page_Station_Controls.ts b/B06_Section/B06_Section_UI_Page_Station_Controls.ts index adf20edf..0993572d 100644 --- a/B06_Section/B06_Section_UI_Page_Station_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Station_Controls.ts @@ -237,20 +237,17 @@ export function createStationControls(deps: StationControlDeps): StationControls const extraWallControl: ExtraWallControl = { countFor: (section) => extraCounts.get(section.chainage_m.toFixed(2)) ?? 0, - add: (chainageM) => { + setCount: (chainageM, requested) => { const key = chainageM.toFixed(2); - extraCounts.set(key, (extraCounts.get(key) ?? 0) + 1); - persistExtraCounts(); - deps.refreshCard(chainageM); - }, - removeLast: (chainageM) => { - const key = chainageM.toFixed(2); - const count = extraCounts.get(key) ?? 0; - if (count <= 0) return; - if (count === 1) extraCounts.delete(key); - else extraCounts.set(key, count - 1); - // 지운 벽의 이동량도 함께 지운다 — 다시 추가하면 자동 자리에서 시작한다. - revetShifts.delete(revetKey(chainageM, `extra${count - 1}`)); + const previous = extraCounts.get(key) ?? 0; + const count = Math.max(0, Math.min(9, Math.round(requested))); + if (count === previous) return; + if (count === 0) extraCounts.delete(key); + else extraCounts.set(key, count); + // 줄어든 단의 이동량은 지운다 — 다시 늘리면 자동 자리에서 시작한다. + for (let i = count; i < previous; i += 1) { + revetShifts.delete(revetKey(chainageM, `extra${i}`)); + } persistRevetShifts(); persistExtraCounts(); deps.refreshCard(chainageM); diff --git a/B06_Section/B06_Section_UI_Style_Cross.css b/B06_Section/B06_Section_UI_Style_Cross.css index fcc14fc0..5f7139fa 100644 --- a/B06_Section/B06_Section_UI_Style_Cross.css +++ b/B06_Section/B06_Section_UI_Style_Cross.css @@ -610,6 +610,16 @@ display: none; } +.b06-structure-panel__count { + width: 3.5em; + padding: 0 4px; + border: 1px solid var(--color-border); + border-radius: var(--radius-inputs); + background: var(--color-surface); + color: var(--color-text-body); + font-size: var(--text-caption); +} + .b06-structure-panel__buttons.is-hidden { display: none; } diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 2b9b7c3a..016c998a 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -252,8 +252,11 @@ export const ui_locales_b2 = { B06_Cross_Revet_Outlet: ["유출 기슭막이", "Outlet revetment"], B06_Cross_Struct_Label: ["구조물 형식", "Structure type"], B06_Cross_Revet_Extra: ["추가 기슭막이", "Extra revetment"], - B06_Cross_Extra_Add: ["기슭막이 추가 — 성토부 5m 이상", "Add revetment (fill slope ≥ 5m)"], - B06_Cross_Extra_Remove: ["마지막 추가 기슭막이 삭제", "Remove last extra revetment"], + B06_Cross_Extra_Count: ["추가 기슭막이(단)", "Extra revetments"], + B06_Cross_Extra_Limit: [ + "지형상 추가 기슭막이는 {n}단까지만 가능합니다 — 벽이 원지반에 0.5m 이상 묻히면 성토가 필요 없습니다", + "Terrain allows only {n} extra revetment tier(s) — no fill needed once a wall is buried 0.5m+", + ], B06_Cross_Struct_Auto: ["자동(규칙)", "Auto (by rule)"], B06_Cross_Struct_Revet: ["기슭막이+배관", "Revetment + pipe"], B06_Cross_Struct_I: ["집수정 I형", "Catch basin — I"],