From 9a58e3b1bdfec58e6316a43dc433282d4374a274 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 22 Aug 2026 13:24:47 +0900 Subject: [PATCH] =?UTF-8?q?feat(B06):=20=EC=9C=A0=EC=B6=9C=EB=B6=80=20?= =?UTF-8?q?=EB=B3=B4=ED=98=B8=EA=B3=B5=20=EC=82=AD=EC=A0=9C=C2=B7=EC=84=B1?= =?UTF-8?q?=ED=86=A0=EB=B6=80=EC=84=A0=20=EC=A0=84=ED=99=98=20+=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EA=B8=B0=EC=8A=AD=EB=A7=89=EC=9D=B4(?= =?UTF-8?q?=EA=B3=84=EB=8B=A8=EC=8B=9D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 보호공(돌붙임) 띠 삭제, 윗면 선만 성토부선(계획선)으로 유지 — 구간별 사면길이·5m 초과 여부 툴팁 표기 - 성토부 5m 이상이면 조정창 +로 추가 기슭막이(배관 없는 동일 형상 벽) 계단식 추가 — 자동 자리는 배관용과 같은 규칙(1:1.2 최소 자리) - 추가 벽도 ◀/▶/↺ 이동(안쪽 한계 = 앞 구조물), 마지막 벽 - 삭제 - 개수·이동량 세션 보관(b06:extrawall / revetx extra{n} 키) - 700줄 제한: Extra(성토부선·추가 벽)·Wire(카드 배선) 분리 신설 Co-Authored-By: Claude Opus 5 (1M context) --- B06_Section/B06_Section_UI_Cross_Culvert.ts | 84 ++++---- .../B06_Section_UI_Cross_Culvert_Extra.ts | 202 ++++++++++++++++++ .../B06_Section_UI_Cross_Culvert_Geom.ts | 85 ++------ .../B06_Section_UI_Cross_Culvert_Solve.ts | 2 +- .../B06_Section_UI_Cross_Culvert_Types.ts | 26 ++- .../B06_Section_UI_Cross_Culvert_Wire.ts | 74 +++++++ .../B06_Section_UI_Cross_Structure_Panel.ts | 22 +- B06_Section/B06_Section_UI_Cross_View.ts | 81 +++---- B06_Section/B06_Section_UI_Page.ts | 1 + .../B06_Section_UI_Page_Station_Controls.ts | 70 +++++- B06_Section/B06_Section_UI_Section_View.ts | 4 + B06_Section/B06_Section_UI_Style_Cross.css | 4 + ui_template/ui_template_locale_b2.ts | 3 + 13 files changed, 503 insertions(+), 155 deletions(-) create mode 100644 B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts create mode 100644 B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts diff --git a/B06_Section/B06_Section_UI_Cross_Culvert.ts b/B06_Section/B06_Section_UI_Cross_Culvert.ts index 6d7defff..3eed160d 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert.ts @@ -6,12 +6,11 @@ * 여기서는 계산 결과(`CulvertLayout`)를 SVG 도형으로 옮기기만 한다 — 치수 결정 금지. * * 그리는 순서 = 시공·계산 순서(2026-08-20 사용자 확정): - * ① 유출구측 구조물(기슭막이) → ② 유입구측 구조물(기슭막이 또는 집수정) - * → ③ 배관 → ④ 유출구측 보호공(돌붙임) + * ① 유출구측 구조물(기슭막이·추가 기슭막이) → ② 유입구측 구조물(기슭막이 또는 + * 집수정) → ③ 배관 → ④ 유출구측 성토부선(보호공 삭제 — 2026-08-22) * ========================================================================== */ import { - PITCHING_THICKNESS_M, REVET_EMBED_DEPTH_M, REVET_LEAN_RATIO, REVET_THICKNESS_M, @@ -29,6 +28,7 @@ export type { CulvertDesignTrim, CulvertLayout, EndFace, + InletStructureChoice, } from "./B06_Section_UI_Cross_Culvert_Geom"; const SVG_NS = "http://www.w3.org/2000/svg"; @@ -47,8 +47,8 @@ function polygon( return shape; } -/** 기슭막이 선택 키 — 측점 안에서 어느 벽인지 가린다. */ -export type RevetKey = "inlet" | "outlet"; +/** 기슭막이 선택 키 — 측점 안에서 어느 벽인지 가린다. 추가 벽은 `extra{n}`. */ +export type RevetKey = "inlet" | "outlet" | `extra${number}`; /** 벽 강조를 카드 재생성 없이 갈아 끼우는 setter. */ export type RevetHighlightSetter = (key: RevetKey | null) => void; @@ -69,45 +69,47 @@ export function appendCulvertOverlay( ): RevetHighlightSetter { const { culvert, pipe, pipeCorners } = layout; const diameter = culvert.diameter_m; - const roleLabel = (role: "inlet" | "outlet") => (role === "inlet" ? "유입" : "유출"); + const roleLabel = (role: "inlet" | "outlet" | "extra") => + role === "inlet" ? "유입" : role === "outlet" ? "유출" : "추가(성토부)"; - // ④ 유출구측 보호공(돌붙임) — 관까지 그린 뒤 마지막에 덮는다. - const drawPitching = (): void => { - const { top, base, lengthM, minLengthM } = layout.pitching; - if (top.length < 2 || base.length !== top.length) return; - layer.append( - polygon( - [ - ...top.map((p) => [x(p.offset), toDisplayY(p.elevation)] as [number, number]), - ...[...base] - .reverse() - .map((p) => [x(p.offset), toDisplayY(p.elevation)] as [number, number]), - ], - "b06-chart__culvert-pitching", - `유출부 보호공(돌붙임) — 성토사면 보호, 단면 연장 ${lengthM.toFixed(1)}m` + - ` (최소 ${minLengthM.toFixed(1)}m = 낙차고×2 — 사방교본 교차 참조)` + - ` · t=${PITCHING_THICKNESS_M}m(실무 L3=45)` + - ` · 시작 변은 기슭막이 전면과 같은 경사, 윗면 시작점 = 관 하단 꼭짓점` + - ` · 성토사면 경사길이 ${layout.fillSlope.lengthM.toFixed(2)}m` + - `(법정 5m ${layout.fillSlope.withinLimit ? "이내 충족" : "초과 — 구조물 보강 필요"})` + - ` · 성토 물매 1:${layout.fillSlope.ratio.toFixed(2)}` + - `(1:1.2 고정${layout.fillSlope.ratioClamped ? " — 벗어남" : ""})` + - (layout.fillSlope.roadWideningM > 0.01 - ? ` · 노폭 연장 ${layout.fillSlope.roadWideningM.toFixed(2)}m` + - `(기슭막이가 밖으로 나간 만큼 노면이 늘어난다)` - : "") + - (layout.fillSlope.structureRequired - ? " · ⚠ 기슭막이로 성토를 못 받는 자리 — 옹벽·석축 검토(성토_비탈면 §2)" - : ""), - ), - ); + // ④ 유출구측 성토부선(2026-08-22 사용자 — 보호공 삭제, 윗면 선만 성토부선으로). + // 구간별 폴리라인: 관 하단 꼭짓점(또는 앞 추가 벽 전면 상단) → 다음 벽/원지반. + const drawOutletFill = (): void => { + for (const segment of layout.outletFill.segments) { + if (segment.points.length < 2) continue; + const line = document.createElementNS(SVG_NS, "polyline"); + line.setAttribute( + "points", + segment.points.map((p) => `${x(p.offset)},${toDisplayY(p.elevation)}`).join(" "), + ); + // 성토부선은 공사 계획선 — 설계선과 같은 보라 실선(집수정 절토·성토선과 동일). + line.setAttribute("class", "b06-chart__design-cross"); + const title = document.createElementNS(SVG_NS, "title"); + title.textContent = + `유출부 성토부선 — 사면길이 ${segment.lengthM.toFixed(2)}m` + + (segment.overLimit + ? " (법정 5m 이상 — 기슭막이 의무 구간, 조정창에서 추가)" + : " (법정 5m 이내)") + + ` · 성토 물매 1:${layout.fillSlope.ratio.toFixed(2)}` + + (layout.fillSlope.roadWideningM > 0.01 + ? ` · 노폭 연장 ${layout.fillSlope.roadWideningM.toFixed(2)}m` + : "") + + (layout.fillSlope.structureRequired + ? " · ⚠ 기슭막이로 성토를 못 받는 자리 — 옹벽·석축 검토(성토_비탈면 §2)" + : ""); + line.append(title); + layer.append(line); + } }; - // ①·② 구조물 — 유출측이 먼저 서고, 그 다음 유입측. + // ①·② 구조물 — 유출측(추가 벽 포함)이 먼저 서고, 그 다음 유입측. const wallsInOrder = [ ...layout.walls.filter((w) => w.role === "outlet"), + ...layout.extraWalls, ...layout.walls.filter((w) => w.role === "inlet"), ]; + const keyOf = (wall: (typeof wallsInOrder)[number]): RevetKey => + wall.role === "extra" ? (`extra${wall.extraIndex ?? 0}` as RevetKey) : wall.role; const revetShapes = new Map(); /** 집수정 부재 도형 — 유입 선택 강조에 쓴다(2026-08-22 사용자 ③). */ const basinShapes: SVGPolygonElement[] = []; @@ -125,7 +127,7 @@ export function appendCulvertOverlay( (wall.lengthM ? ` · 연장 ${wall.lengthM.toFixed(1)}m` : ""), ); if (onSelectRevet) revetShape.classList.add("is-selectable"); - revetShapes.set(wall.role, revetShape); + revetShapes.set(keyOf(wall), revetShape); layer.append(revetShape); // 평행사변형 띠와 사다리꼴 사이 대각 이음선(내부 경계 — 사용자 스케치의 가운데 선): // 상단 변 중간점(배면+0.45)에서 전면과 나란히 바닥으로 내려온다. @@ -150,7 +152,7 @@ export function appendCulvertOverlay( // ① 배치 구간을 근입 바닥~상단 **전체**로 잡고 ② 벽 폴리곤 clip 안에만 그려 // 어떤 높이에서도 돌이 벽 밖으로 새지 않게 한다(2026-08-20 미스매치 정정). const pixelsPerMeter = Math.abs(x(1) - x(0)) || 1; - const clipId = `b06-revet-clip-${wall.role}-${Math.round(wall.backOffset * 100)}`; + const clipId = `b06-revet-clip-${keyOf(wall)}-${Math.round(wall.backOffset * 100)}`; const clip = document.createElementNS(SVG_NS, "clipPath"); clip.setAttribute("id", clipId); clip.append( @@ -312,7 +314,7 @@ export function appendCulvertOverlay( ), ); } - drawPitching(); + drawOutletFill(); // 클릭 판정용 투명 겹면 — **맨 마지막에** 얹는다. 관·보호공이 벽 위를 지나가 그리기 // 순서상 벽 가운데를 덮으므로, 벽 폴리곤에 직접 핸들러를 달면 가운데를 눌러도 관이 @@ -327,7 +329,7 @@ export function appendCulvertOverlay( ); hit.addEventListener("click", (event) => { event.stopPropagation(); - onSelectRevet(wall.role); + onSelectRevet(keyOf(wall)); }); layer.append(hit); } diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts new file mode 100644 index 00000000..08b54565 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts @@ -0,0 +1,202 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Culvert_Extra.ts + * 유출측 **성토부선 + 추가 기슭막이**(2026-08-22 사용자) — Geom에서 분리(700줄 제한). + * + * 종전 보호공(돌붙임) 띠는 삭제하고 그 **윗면 선만 성토부선**으로 남긴다. 관 하단 + * 꼭짓점에서 1:1.2로 원지반까지 내려가는 이 선이 5m 이상이면 기슭막이 의무 구간 + * (성토_비탈면.md §2)이라 사용자가 **추가 기슭막이**(배관 없는 벽)를 둘 수 있다. + * 추가 벽 뒤에도 성토부가 5m 이상 남으면 또 추가할 수 있다(계단식). + * ========================================================================== */ + +import { + FILL_SLOPE_MAX_LENGTH_M, + 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"; + +export interface OutletExtrasInput { + /** 성토부선 시작점 = 관 유출 하단 꼭짓점. */ + start: OffsetPoint; + outward: number; + groundAt: (offset: number) => number; + /** 성토부 물매(1:n) — 설계 성토 물매(기본 1.2). */ + fillRatio: number; + /** 추가 벽 표준 높이(배관용과 동일: 관경+여유고, 0.1m 눈금). */ + wallHeight: number; + /** 형태별 높이 한계(찰 3.0/메 2.0). */ + heightLimit: number; + form: string | null; + /** 계류측 샘플 한계 offset. */ + limitOffset: number; + /** 사용자가 민 이동량(m, + = 계류측 바깥) — 벽 개수만큼. */ + shifts: number[]; +} + +export interface OutletExtrasResult { + walls: WallLayout[]; + segments: OutletFillSegment[]; + /** 한계에 잘린 뒤의 실제 이동량 — 조정창이 되받는다. */ + appliedShifts: number[]; + /** 끝 성토부가 아직 5m 이상 — 추가 기슭막이를 더 둘 수 있다. */ + addable: boolean; +} + +/** + * 성토부선(끝 구간) — src에서 1:n으로 내려가며 원지반을 만나면 끝난다. + * 지반이 사면보다 높은 구간은 지반을 따른다(종전 보호공 윗면과 동일 규칙, + * 최소 길이 연장은 보호공 삭제와 함께 없앴다). + */ +function trailingFill( + src: OffsetPoint, + outward: number, + ratio: number, + groundAt: (offset: number) => number, +): OutletFillSegment { + const points: OffsetPoint[] = [src]; + let length = 0; + let previous = src; + 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 groundElevation = groundAt(offset); + const point: OffsetPoint = { offset, elevation: Math.max(slopeElevation, groundElevation) }; + length += Math.hypot(point.offset - previous.offset, point.elevation - previous.elevation); + points.push(point); + 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; +} + +/** + * 추가 기슭막이 벽 1매 — 배관용 기슭막이와 **같은 형상**(배면 수직·전면 1:0.3, + * 수평 기초 근입 0.5m), 배관·마감면만 없다(2026-08-22 사용자 ③). + */ +function buildExtraWall( + index: number, + anchor: OffsetPoint, + outward: number, + height: number, + floatGapM: number, + form: string | null, + groundAt: (offset: number) => number, +): WallLayout { + const thickness = REVET_THICKNESS_M; + const baseWidth = thickness * 1.5 + REVET_LEAN_RATIO * height; + const backOffset = anchor.offset - outward * (baseWidth / 2); + const frontBase = 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 * 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); + } + const bottomBack: OffsetPoint = { offset: backOffset, elevation: bottomElevation }; + const bottomFront: OffsetPoint = { + offset: frontXAt(bottomElevation), + elevation: bottomElevation, + }; + return { + role: "extra", + extraIndex: index, + form, + lengthM: null, + backOffset, + outerOffset: bottomFront.offset, + base: anchor.elevation, + height, + floatGapM, + outward, + topBack, + topJoint: { offset: topJoint, elevation: topElevation }, + bottomBack, + bottomFront, + points: [bottomBack, topBack, { offset: topFront, elevation: topElevation }, bottomFront], + }; +} + +/** + * 유출측 성토부선·추가 기슭막이 일괄 계산. + * + * 벽 i의 자동 자리 = 앞 구간 시작점(src)을 노견 삼아 **1:1.2가 딱 성립하는 가장 + * 안쪽 자리**(배관용 기슭막이와 같은 규칙 — minShoulderWallOffset 재사용). 사용자가 + * 안쪽으로 당기면 높이 증가 → 한계 시 바닥 띄움(solveWallVertical 동일 사다리). + * 벽 상단 구간은 src → 이음선 상단점 직선, 벽 다음 src = 전면 상단 꼭짓점. + */ +export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult { + const { outward, groundAt, fillRatio, wallHeight, heightLimit, form, limitOffset } = input; + const walls: WallLayout[] = []; + const segments: OutletFillSegment[] = []; + const appliedShifts: number[] = []; + let src = input.start; + 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, + ); + walls.push(wall); + // src → 벽 이음선 상단점 직선 구간. 물매가 1:1.2보다 급하면 경고 대상(overLimit + // 아님 — 길이 기준만 적는다). + 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), + overLimit: Math.hypot(run, rise) >= FILL_SLOPE_MAX_LENGTH_M - 1e-6, + }); + // 다음 성토부 시작점 = 벽 전면 상단 꼭짓점(points[2]). + src = wall.points[2]; + } + const tail = trailingFill(src, outward, fillRatio, groundAt); + segments.push(tail); + return { walls, segments, appliedShifts, addable: tail.overLimit }; +} diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts index 20f17694..68d65f20 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts @@ -3,8 +3,8 @@ * 배수관 세트(배관·기슭막이·보호공) **기하 계산** — 그리기(`_Cross_Culvert.ts`)와 분리. * 백엔드 `section.culvert` 제원을 좌표로 옮긴다(치수 결정 금지). 배치 규칙은 실무 * 횡단도 기준(2026-08-20 사용자 제공, 울진 계열): 기슭막이 평행사변형(전면 1:0.3), - * 배관은 두 벽 사이 직선·끝단면은 구조물 변과 평행, 보호공은 관 하단 꼭짓점부터 - * 원지반까지(최소 낙차고×2), 성토 경사선은 벽 접점에서 끊는다(designTrim). + * 배관은 두 벽 사이 직선·끝단면은 구조물 변과 평행, 유출측은 관 하단 꼭짓점부터 + * 성토부선(보호공 삭제 — 2026-08-22), 성토 경사선은 벽 접점에서 끊는다(designTrim). * 유입 = 상단측(uphill_side). 집수정 구성은 `_Cross_Culvert_Basin.ts` 참조. * ========================================================================== */ @@ -17,7 +17,6 @@ import { 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, @@ -44,6 +43,7 @@ import { buildBasin, inletChoiceAvailability, } from "./B06_Section_UI_Cross_Culvert_Basin"; +import { buildOutletExtras } from "./B06_Section_UI_Cross_Culvert_Extra"; import type { FillSlopeSegment, WallVertical } from "./B06_Section_UI_Cross_Culvert_Solve"; import { clampToFace, @@ -69,7 +69,7 @@ export function computeCulvertLayout( section: CrossSection, groundSamples: SectionSample[], /** 사용자가 손으로 민 기슭막이 X 이동량(m, + = 계류측 바깥). 없으면 자동 자리. */ - revetShift?: { inlet?: number; outlet?: number }, + revetShift?: { inlet?: number; outlet?: number; extras?: number[] }, /** 유입측 구조물 형식 선택(드롭다운) — 없으면 auto(규칙). */ inletStructure?: InletStructureChoice, ): CulvertLayout | null { @@ -582,58 +582,21 @@ export function computeCulvertLayout( applyBasinPipeFill(basin, pipeCorners.inlet.bottom, inletInfo.outward, groundAt); } - // 보호공(돌붙임) — 유출측. 2026-08-20 사용자 ①·②: - // ① 시작 변은 **기슭막이 전면과 같은 경사**(밑면을 벽 전면 방향으로 민다), - // 윗면 시작점 = **관 하단 꼭짓점**(관 밑면과 한 점에서 만난다). - // ② 성토사면 경사(1:n)로 원지반까지 내려간 뒤에도 **최소 보호공 길이** - // (낙차고×2 — 백엔드 `apron_length_m`)를 채울 때까지 원지반을 따라 더 간다. - // 끝단 윗점이 원지반 위에 놓인다. - const pitchingTop: OffsetPoint[] = []; - const pitchingBase: OffsetPoint[] = []; - let pitchingLength = 0; - const minPitchingM = culvert.outlet.apron_length_m ?? 0; - if (outletWall) { - const fillRatio = section.design?.fill_slope_ratio ?? 1.2; - // 밑면 이동 방향 = 기슭막이 전면 변과 나란한 **아래 방향** 단위벡터 × 두께. - let shift: OffsetPoint = { offset: 0, elevation: -PITCHING_THICKNESS_M }; - const face = endFaces.outlet; - if (face) { - const faceLength = Math.hypot(face.direction.offset, face.direction.elevation); - if (faceLength > 1e-9) { - shift = { - offset: (-face.direction.offset / faceLength) * PITCHING_THICKNESS_M, - elevation: (-face.direction.elevation / faceLength) * PITCHING_THICKNESS_M, - }; - } - } - const start = pipeCorners.outlet.bottom; - pitchingTop.push(start); - let previous = start; - const step = 0.25; - for (let t = step; t <= 30 + 1e-9; t += step) { - const offset = start.offset + outletWall.outward * t; - const slopeElevation = start.elevation - t / fillRatio; - const groundElevation = groundAt(offset); - const point: OffsetPoint = { - offset, - elevation: Math.max(slopeElevation, groundElevation), - }; - pitchingLength += Math.hypot( - point.offset - previous.offset, - point.elevation - previous.elevation, - ); - pitchingTop.push(point); - previous = point; - // 지반에 닿았고 최소 길이도 채웠으면 마감 — 아직이면 지반을 따라 계속 간다. - if (slopeElevation <= groundElevation && pitchingLength >= minPitchingM - 1e-6) break; - } - for (const point of pitchingTop) { - pitchingBase.push({ - offset: point.offset + shift.offset, - elevation: point.elevation + shift.elevation, - }); - } - } + // 성토부선 + 추가 기슭막이(2026-08-22 사용자 — 보호공 삭제, 윗면 선만 성토부선으로 + // 남긴다). 끝 구간이 5m 이상이면 사용자가 추가 기슭막이(벽만)를 계단식으로 더 둔다. + const extras = outletWall + ? buildOutletExtras({ + start: pipeCorners.outlet.bottom, + 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, + limitOffset: outletInfo.limit, + shifts: revetShift?.extras ?? [], + }) + : { walls: [], segments: [], appliedShifts: [], addable: false }; // ── 성토 사면 구간 확정. 벽 자리가 굳은 뒤에 물매를 역산해야 관 길이 맞춤(벽 이동)이 // 접점을 다시 깨뜨리지 않는다 — 종전 어긋남의 직접 원인이 이 순서였다. @@ -677,14 +640,10 @@ export function computeCulvertLayout( // 2026-08-21 사용자 정정: 5m는 자리 한계가 아니라 의무 발생 기준이다). structureRequired: outletSlope != null && outletSlope.lengthM >= FILL_SLOPE_MAX_LENGTH_M, }, - pitching: { - top: pitchingTop, - base: pitchingBase, - lengthM: pitchingLength, - minLengthM: minPitchingM, - }, + outletFill: { segments: extras.segments, addable: extras.addable }, + extraWalls: extras.walls, basin, - revetShift: appliedShift, + revetShift: { ...appliedShift, extras: extras.appliedShifts }, designTrim: walls.length || basin ? { diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts index 45b71a7a..6d324e14 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts @@ -139,7 +139,7 @@ export interface FillSlopeSegment { * 접점이 다시 어긋난다. */ export function fillSlopeOf( - wall: { role: "inlet" | "outlet"; topJoint: OffsetPoint; outward: number }, + wall: { role: "inlet" | "outlet" | "extra"; topJoint: OffsetPoint; outward: number }, edge: { offset_m: number; elevation_m: number }, ): FillSlopeSegment { const to = wall.topJoint; diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts index 2b9724b9..12b43f2b 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts @@ -27,7 +27,9 @@ export interface CulvertDesignTrim { } export interface WallLayout { - role: "inlet" | "outlet"; + role: "inlet" | "outlet" | "extra"; + /** 추가 기슭막이 번호(role === "extra"일 때만) — 조정창 키 `extra{n}`이 된다. */ + extraIndex?: number; form: string | null; lengthM: number | null; /** 배면(도로측) 바닥 offset. */ @@ -93,13 +95,15 @@ export interface PipeEnd { top: OffsetPoint; } -/** 유출측 보호공(돌붙임) 띠 — 윗면/밑면 폴리라인 쌍. */ -export interface PitchingLayout { - top: OffsetPoint[]; - base: OffsetPoint[]; +/** + * 유출측 **성토부선** 한 구간(2026-08-22 사용자 — 보호공 삭제, 윗면 선만 성토부선으로). + * 관 하단 꼭짓점(또는 앞 추가 벽 전면 상단)에서 1:1.2로 원지반(또는 다음 벽)까지. + */ +export interface OutletFillSegment { + points: OffsetPoint[]; lengthM: number; - /** 최소 보호공 길이(낙차고×2 — 백엔드 `apron_length_m`). */ - minLengthM: number; + /** 사면길이 5m 이상 — 기슭막이 의무 구간(별표2·성토_비탈면 §2). */ + overLimit: boolean; } export interface CulvertLayout { @@ -127,8 +131,10 @@ export interface CulvertLayout { */ structureRequired: boolean; }; - /** 유출측 보호공 띠(없으면 top/base 빈 배열). */ - pitching: PitchingLayout; + /** 유출측 성토부선(보호공 대체 — 2026-08-22). 끝 구간이 5m 이상이면 추가 가능. */ + outletFill: { segments: OutletFillSegment[]; addable: boolean }; + /** 추가 기슭막이(배관 없는 벽 — 계단식). 조정창 키 = `extra{n}`. */ + extraWalls: WallLayout[]; /** 유입 집수정 단면(있을 때만). */ basin: BasinLayout | null; designTrim: CulvertDesignTrim | null; @@ -137,7 +143,7 @@ export interface CulvertLayout { * 한계에 걸리면 잘려 들어온다 — 조정창은 이 값을 보여 주고 저장한다. 그러지 않으면 * 눌러도 안 움직이는데 숫자만 계속 커진다(2026-08-21 사용자 지적). */ - revetShift: { inlet: number; outlet: number }; + revetShift: { inlet: number; outlet: number; extras: number[] }; /** * 조정창 드롭다운 선택지 가용성(2026-08-22 사용자 — 상황에 안 맞는 선택지 숨김): * revetAllowed = 기슭막이+배관(배관이 원지반에 안 묻힐 때만), diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts new file mode 100644 index 00000000..4bc389e0 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts @@ -0,0 +1,74 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Culvert_Wire.ts + * 횡단 카드의 배수관 세트 **계산 호출 + 이동량 되받기(토스트)** — Cross_View에서 + * 분리(700줄 제한). 카드가 다시 그려질 때마다 한 번 불린다. + * ========================================================================== */ + +import type { CrossSection, SectionSample } from "./B06_Section_Api_Fetch"; +import { computeCulvertLayout } from "./B06_Section_UI_Cross_Culvert"; +import type { CulvertLayout, RevetKey } from "./B06_Section_UI_Cross_Culvert"; +import type { + ExtraWallControl, + InletStructureControl, + RevetOffsetControl, +} from "./B06_Section_UI_Cross_View"; +import { showToast } from "@ui/ui_template_elements"; +import { L } from "./B06_Section_UI_Section_Common"; + +/** + * 배수관 세트 기하 계산 + 제어 상태 동기화. + * + * ① 세션의 이동량(기슭막이·추가 벽)·유입 구조물 선택을 모아 기하를 계산하고, + * ② 성토부가 짧아져 못 세운 추가 벽은 개수를 잘라 동기화하며(고아 이동량 방지), + * ③ 요청 이동량이 한계에 잘렸으면 실제 적용값으로 되돌려 담는다 — 안 그러면 눌러도 + * 안 움직이는데 창의 숫자만 계속 커진다(2026-08-21 사용자 지적). 잘린 순간에는 + * **왜 안 움직였는지 토스트**로 알린다(2026-08-22 사용자 — 13+15.7처럼 자동 + * 자리가 이미 안쪽 한계라 ◀가 그대로 먹히지 않는 경우). 조작 중(그 벽이 선택된 + * 상태)일 때만 띄운다 — 리로드로 복원된 옛 세션 값에는 침묵. + */ +export function computeCardCulvert( + section: CrossSection, + sourceSamples: SectionSample[], + activeRevet: RevetKey | null, + revetOffset?: RevetOffsetControl, + inletStructure?: InletStructureControl, + extraWalls?: ExtraWallControl, +): CulvertLayout | null { + const layout = computeCulvertLayout( + section, + sourceSamples, + revetOffset + ? { + inlet: revetOffset.shiftFor(section, "inlet"), + outlet: revetOffset.shiftFor(section, "outlet"), + extras: Array.from({ length: extraWalls?.countFor(section) ?? 0 }, (_, i) => + revetOffset.shiftFor(section, `extra${i}`), + ), + } + : undefined, + inletStructure?.valueFor(section), + ); + if (!layout) return null; + if (extraWalls && extraWalls.countFor(section) > layout.extraWalls.length) { + extraWalls.syncCount(section.chainage_m, layout.extraWalls.length); + } + if (revetOffset) { + const roles: Array<[RevetKey, number]> = [ + ["inlet", layout.revetShift.inlet], + ["outlet", layout.revetShift.outlet], + ...layout.revetShift.extras.map((applied, i): [RevetKey, number] => [`extra${i}`, applied]), + ]; + for (const [role, applied] of roles) { + const requested = revetOffset.shiftFor(section, role); + if (activeRevet === role && Math.abs(requested - applied) > 0.05) { + // 이동량 부호: + = 계류측 바깥. 요청이 적용보다 작으면 안쪽 요청이 잘린 것. + showToast( + L(requested < applied ? "B06_Cross_Revet_Limit_Inward" : "B06_Cross_Revet_Limit_Outward"), + "info", + ); + } + revetOffset.syncShift(section.chainage_m, role, applied); + } + } + return layout; +} diff --git a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts index 7edb4377..83b52b98 100644 --- a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts @@ -33,6 +33,12 @@ 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; } export interface StructurePanelHandle { @@ -80,6 +86,10 @@ 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( @@ -98,6 +108,8 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan L("B06_Cross_Revet_Reset"), act((key) => deps.reset(key)), ), + addButton, + removeButton, ); // 유입측 구조물 형식 드롭다운(2026-08-22 사용자) — 자동/기슭막이/집수정 I·ㄴ·ㄷ. @@ -139,8 +151,10 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan current = key; root.classList.toggle("is-hidden", key === null); if (!key) return; - title.textContent = - key === "outlet" + const isExtra = key.startsWith("extra"); + title.textContent = isExtra + ? `${L("B06_Cross_Revet_Extra")} ${Number(key.slice(5)) + 1}` + : key === "outlet" ? L("B06_Cross_Revet_Outlet") : deps.canNudge("inlet") ? L("B06_Cross_Revet_Inlet") @@ -162,6 +176,10 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan select.value = current; } buttons.classList.toggle("is-hidden", !deps.canNudge(key)); + // +는 유출·추가 벽에서 끝 성토부가 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}`)); 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 9082d372..1a29af66 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -23,8 +23,8 @@ import { sectionModeLabel, type RockBoundaryControl, } from "./B06_Section_UI_Cross_Design"; -import { showToast } from "@ui/ui_template_elements"; -import { appendCulvertOverlay, computeCulvertLayout } from "./B06_Section_UI_Cross_Culvert"; +import { appendCulvertOverlay } from "./B06_Section_UI_Cross_Culvert"; +import { computeCardCulvert } from "./B06_Section_UI_Cross_Culvert_Wire"; import type { InletStructureChoice } from "./B06_Section_UI_Cross_Culvert"; import { buildStructurePanel } from "./B06_Section_UI_Cross_Structure_Panel"; import { attachZoomPan, buildZoomControls } from "./B06_Section_UI_Cross_View_Zoom"; @@ -181,6 +181,16 @@ export interface InletStructureControl { set: (chainageM: number, value: InletStructureChoice) => void; } +/** 유출측 추가 기슭막이 개수 제어(2026-08-22 사용자 — 성토부 5m 이상 계단식 추가). */ +export interface ExtraWallControl { + countFor: (section: CrossSection) => number; + add: (chainageM: number) => void; + /** 마지막 추가 벽 삭제(그 벽의 이동량도 함께 지운다). */ + removeLast: (chainageM: number) => void; + /** 기하가 실제로 세운 개수로 잘라 동기화(성토부가 없어져 못 세운 벽 정리). */ + syncCount: (chainageM: number, built: number) => void; +} + export function createCrossSectionCard( section: CrossSection, selected: boolean, @@ -203,6 +213,8 @@ export function createCrossSectionCard( revetOffset?: RevetOffsetControl, /** 유입측 구조물 형식 선택(드롭다운 — 2026-08-22). */ inletStructure?: InletStructureControl, + /** 유출측 추가 기슭막이 개수 제어(2026-08-22). */ + extraWalls?: ExtraWallControl, ): CrossCardElement { // 이 카드의 실효 표시 반폭 — 개별값이 전역 반폭보다 우선한다(2026-08-06). const effectiveHalfWidth = stationWidth?.widthFor(section) ?? crossHalfWidth; @@ -230,6 +242,8 @@ export function createCrossSectionCard( let culvertInletIsBasin = false; /** 드롭다운 선택지 가용성(2026-08-22) — 기하 판정을 조정창에 전달. */ let culvertInletOptions = { revetAllowed: true, basinLUAllowed: true }; + /** 추가 기슭막이 상태(2026-08-22) — 끝 성토부 5m 이상(canAdd)·세워진 개수. */ + let culvertExtraState = { canAdd: false, count: 0 }; /** * 기슭막이를 고른다. 구조물 선택과 절·성토 면적 강조는 **같은 레벨**이라 하나를 * 고르면 다른 하나는 풀린다(2026-08-21 사용자). 또 구조물을 고르면 그 **측점 카드도 @@ -467,17 +481,14 @@ export function createCrossSectionCard( toDisplayY, toggleArea, ); - // 배수관 세트 기하를 먼저 계산한다 — 설계선이 기슭막이 밖 성토 경사를 끊는 데 쓴다. - const culvertLayout: ReturnType = computeCulvertLayout( + // 배수관 세트 기하 계산 + 이동량 되받기(토스트) — 분리 파일(Wire, 700줄 제한). + const culvertLayout = computeCardCulvert( section, sourceSamples, - revetOffset - ? { - inlet: revetOffset.shiftFor(section, "inlet"), - outlet: revetOffset.shiftFor(section, "outlet"), - } - : undefined, - inletStructure?.valueFor(section), + activeRevet, + revetOffset, + inletStructure, + extraWalls, ); // 포장층 → 설계선 → 암 경계선 순으로 겹쳐, 설계선이 포장 박스 위에 오게 한다. appendPavementOverlay(plotLayer, section.design, x, toDisplayY); @@ -499,32 +510,16 @@ export function createCrossSectionCard( toDisplayY, ); } - // 배수관 측점 세트(배관·기슭막이·보호공) — 기존 도형 위에 추가만 한다(2026-08-19). + // 배수관 측점 세트 — 기존 도형 위에 추가만 한다(2026-08-19). 조정창이 쓸 + // 카드 상태(관 길이·집수정 여부·선택지·추가 벽 상태)를 여기서 받아 둔다. culvertPipeLengthM = culvertLayout?.pipe.lengthM ?? null; culvertInletIsBasin = !!culvertLayout?.basin; - if (culvertLayout) culvertInletOptions = culvertLayout.inletOptions; - // 요청한 이동량이 한계에 걸려 잘렸으면 그 값으로 되돌려 담는다 — 안 그러면 눌러도 - // 안 움직이는데 창의 숫자만 계속 커진다(2026-08-21 사용자 지적). - // 잘린 순간에는 **왜 안 움직였는지 토스트로 알린다**(2026-08-22 사용자 요청 — - // 13+15.7처럼 자동 자리가 이미 안쪽 한계라 ◀가 그대로 먹히지 않는 경우). - // 조작 중(그 벽이 선택된 상태)일 때만 띄운다 — 리로드로 복원된 옛 세션 값에는 침묵. - if (culvertLayout && revetOffset) { - for (const role of ["inlet", "outlet"] as const) { - const requested = revetOffset.shiftFor(section, role); - const applied = culvertLayout.revetShift[role]; - if (activeRevet === role && Math.abs(requested - applied) > 0.05) { - // 이동량 부호: + = 계류측 바깥. 요청이 적용보다 작으면 안쪽 요청이 잘린 것. - showToast( - L( - requested < applied - ? "B06_Cross_Revet_Limit_Inward" - : "B06_Cross_Revet_Limit_Outward", - ), - "info", - ); - } - revetOffset.syncShift(section.chainage_m, role, applied); - } + if (culvertLayout) { + culvertInletOptions = culvertLayout.inletOptions; + culvertExtraState = { + canAdd: culvertLayout.outletFill.addable, + count: culvertLayout.extraWalls.length, + }; } if (culvertLayout) { setRevetActive = appendCulvertOverlay( @@ -622,9 +617,21 @@ export function createCrossSectionCard( }, structureFor: () => inletStructure?.valueFor(section) ?? "auto", setStructure: (value) => inletStructure?.set(section.chainage_m, value), - // 집수정은 자리 고정 — 유입이 집수정이면 ◀/▶/↺를 숨긴다. - canNudge: (key) => key === "outlet" || !culvertInletIsBasin, + // 집수정은 자리 고정 — 유입이 집수정이면 ◀/▶/↺를 숨긴다(추가 벽은 항상 이동). + 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) { + activeRevet = "outlet"; + revetOffset?.select(section.chainage_m, "outlet"); + } + extraWalls?.removeLast(section.chainage_m); + }, }); showRevetControl = (visible) => panel.show(visible ? activeRevet : null); showRevetControl(activeRevet !== null); diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index 701a3b5b..c3be686e 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -411,6 +411,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { stationWidthControl, revetOffsetControl, stationControls.inletStructure, + stationControls.extraWalls, ); // 메인 영역: 종·횡단 도면(sectionView) 또는 안내 메시지를 표시한다. diff --git a/B06_Section/B06_Section_UI_Page_Station_Controls.ts b/B06_Section/B06_Section_UI_Page_Station_Controls.ts index 0091d36c..adf20edf 100644 --- a/B06_Section/B06_Section_UI_Page_Station_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Station_Controls.ts @@ -8,6 +8,7 @@ import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; import type { InletStructureChoice, RevetKey } from "./B06_Section_UI_Cross_Culvert"; import type { + ExtraWallControl, InletStructureControl, RevetOffsetControl, StationWidthControl, @@ -15,7 +16,7 @@ import type { /** 제어가 페이지에서 가져다 쓰는 값들 — 클로저 대신 함수로 받아 결합을 끊는다. */ export interface StationControlDeps { - sessionKey: (kind: "crossw" | "revetx" | "inletstruct") => string | null; + sessionKey: (kind: "crossw" | "revetx" | "inletstruct" | "extrawall") => string | null; refreshCard: (chainageM: number) => void; detail: () => SectionDetailResponse | null; crossHalfWidth: () => number | undefined; @@ -27,6 +28,7 @@ export interface StationControls { stationWidth: StationWidthControl; revetOffset: RevetOffsetControl; inletStructure: InletStructureControl; + extraWalls: ExtraWallControl; widths: Map; load: () => void; /** 전체 반영 — 개별 반폭을 전역값으로 덮는다(없으면 비운다). */ @@ -202,6 +204,70 @@ export function createStationControls(deps: StationControlDeps): StationControls } } + /* ── 유출측 추가 기슭막이 개수(2026-08-22 사용자 — 성토부 5m 이상 계단식) ── + * 측점별 개수만 세션에 담는다. 각 벽의 이동량은 revetShifts에 `extra{n}` 키로. */ + const extraCounts = new Map(); + const extraSessionKey = (): string | null => deps.sessionKey("extrawall"); + + function loadExtraCounts(): void { + extraCounts.clear(); + const key = extraSessionKey(); + if (!key) return; + try { + const raw = window.sessionStorage.getItem(key); + if (!raw) return; + const parsed = JSON.parse(raw) as Record; + Object.entries(parsed).forEach(([chainage, count]) => { + if (Number.isInteger(count) && count > 0) extraCounts.set(chainage, count); + }); + } catch { + /* 손상된 세션 값은 무시 — 추가 벽 없음으로 재시작. */ + } + } + + function persistExtraCounts(): void { + const key = extraSessionKey(); + if (!key) return; + try { + window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(extraCounts))); + } catch { + /* 세션 저장 실패는 무시 — 값은 메모리에 유지된다. */ + } + } + + const extraWallControl: ExtraWallControl = { + countFor: (section) => extraCounts.get(section.chainage_m.toFixed(2)) ?? 0, + add: (chainageM) => { + 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}`)); + persistRevetShifts(); + persistExtraCounts(); + deps.refreshCard(chainageM); + }, + syncCount: (chainageM, built) => { + const key = chainageM.toFixed(2); + const count = extraCounts.get(key) ?? 0; + if (built >= count) return; + // 성토부가 없어져 못 세운 벽 — 개수·이동량을 세워진 만큼으로 자른다(고아 방지). + if (built <= 0) extraCounts.delete(key); + else extraCounts.set(key, built); + for (let i = built; i < count; i += 1) revetShifts.delete(revetKey(chainageM, `extra${i}`)); + persistRevetShifts(); + persistExtraCounts(); + }, + }; + const inletStructureControl: InletStructureControl = { valueFor: (section) => inletStructures.get(section.chainage_m.toFixed(2)) ?? "auto", set: (chainageM, value) => { @@ -216,11 +282,13 @@ export function createStationControls(deps: StationControlDeps): StationControls stationWidth: stationWidthControl, revetOffset: revetOffsetControl, inletStructure: inletStructureControl, + extraWalls: extraWallControl, widths: stationWidths, load: () => { loadStationWidths(); loadRevetShifts(); loadInletStructures(); + loadExtraCounts(); }, applyGlobalWidth: (requested, chainages) => { stationWidths.clear(); diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index 53f2fc23..61df6bb6 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -27,6 +27,7 @@ import type { RockBoundaryControl } from "./B06_Section_UI_Cross_Design"; import type { CrossAreaKey } from "./B06_Section_UI_Cross_Areas"; import { createCrossSectionCard, + type ExtraWallControl, type InletStructureControl, type RevetOffsetControl, crossCardNaturalHeight, @@ -176,6 +177,8 @@ export function createSectionView( revetOffset?: RevetOffsetControl, /** 유입측 구조물 형식 선택(2026-08-22) — 조정창 드롭다운. */ inletStructure?: InletStructureControl, + /** 유출측 추가 기슭막이 개수 제어(2026-08-22). */ + extraWalls?: ExtraWallControl, ): SectionViewController { const root = document.createElement("div"); root.className = "b06-section"; @@ -448,6 +451,7 @@ export function createSectionView( stationWidth, revetOffset, inletStructure, + extraWalls, ); /** 범례 버튼 — 곡선 하나를 켜고 끈다. 축은 전체 곡선 기준이라 여기서 움직이지 않는다. */ diff --git a/B06_Section/B06_Section_UI_Style_Cross.css b/B06_Section/B06_Section_UI_Style_Cross.css index 0d2fe82e..fcc14fc0 100644 --- a/B06_Section/B06_Section_UI_Style_Cross.css +++ b/B06_Section/B06_Section_UI_Style_Cross.css @@ -606,6 +606,10 @@ gap: 2px; } +.b06-structure-panel__btn.is-hidden { + display: none; +} + .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 524a1bf1..2b9b7c3a 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -251,6 +251,9 @@ export const ui_locales_b2 = { B06_Cross_Revet_Inlet: ["유입 기슭막이", "Inlet revetment"], 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_Struct_Auto: ["자동(규칙)", "Auto (by rule)"], B06_Cross_Struct_Revet: ["기슭막이+배관", "Revetment + pipe"], B06_Cross_Struct_I: ["집수정 I형", "Catch basin — I"],