From 917cdce5df83b5258575baae19fa58b13c6546b6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 22 Aug 2026 18:59:09 +0900 Subject: [PATCH] =?UTF-8?q?feat(B06):=20=EC=A7=91=EC=88=98=EC=A0=95=209?= =?UTF-8?q?=ED=82=A4=20=EC=A1=B0=EC=9E=91=20=C2=B7=20=EC=A2=8C=EC=9A=B0=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EA=B8=B0=EC=A4=80=20=ED=86=B5=EC=9D=BC=20?= =?UTF-8?q?=C2=B7=20=EA=B3=84=EB=A5=98=EC=B8=A1=20=EC=84=B1=ED=86=A0?= =?UTF-8?q?=EB=B6=80=20=EC=B2=B4=EA=B3=84=20=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ① 집수정 조작을 9키(3×3)로 — 십자 축은 이동(◀▶ 좌우 / ▲▼ 사면 대각), 네 모서리는 크기(⇕ 내부 높이 · ⇔ 내부 폭), 가운데는 초기화. 기슭막이 십자와 같은 축 규칙·버튼 크기라 행열이 맞는다. I형은 내공 개념이 없어 네 모서리만 숨긴다. ② 좌우 반전 해소 — 집수정 lateralM이 계류측(outward) 기준값에 그대로 묶여 유입이 우측인 측점에서 ◀가 화면 오른쪽으로 갔다. 버튼을 **화면 기준**으로 받아 카드가 부호를 환산한다. 노견 안쪽(음수)은 0에서 멈추고 토스트로 알린다. ③ 집수정 계류측에 **유출측과 같은 성토부 체계** 적용(ㄴ·ㄷ형) — 구조물 계류측 끝이 원지반 위로 나오면 1:1.2 성토부선을 긋고, 5m를 넘으면 다단 기슭막이를 둘 수 있다(키 , 단 수 행 별도). 원지반 안이면 종전 절토선, I형은 관 하단 3도 되메움선이 맡는다. 좌우 이동 시 마감선을 억제하던 임시 조건은 제거했다(급경사 선의 근본 원인은 이미 설계선 트림에서 해결). - 700줄 유지: attachBasin(Basin)·buildExtrasAt(Extra) 추출 + 주석 정리 검증: 4+4.3 ㄴ형 좌우 2m — 성토부선 2.41m가 원지반에 0.001m 오차로 접함 (종전 마감선 없음) · ◀ 클릭 시 4+4.3(유입 좌)·13+15.7(유입 우) 모두 화면 왼쪽으로 이동 · 9키 3×3 좌표 정렬 확인 · tsc·ruff·prettier 통과 · pytest 148 passed, 7 skipped · B06_Section 700줄 초과 0건. Co-Authored-By: Claude Opus 5 (1M context) --- B06_Section/B06_Section_UI_Cross_Culvert.ts | 54 +++++-- .../B06_Section_UI_Cross_Culvert_Basin.ts | 53 ++++--- .../B06_Section_UI_Cross_Culvert_Extra.ts | 16 +++ .../B06_Section_UI_Cross_Culvert_Geom.ts | 133 ++++++++--------- .../B06_Section_UI_Cross_Culvert_Types.ts | 12 +- .../B06_Section_UI_Cross_Culvert_Wire.ts | 38 +++-- .../B06_Section_UI_Cross_Structure_Panel.ts | 134 +++++++++++++----- B06_Section/B06_Section_UI_Cross_View.ts | 46 +++++- .../B06_Section_UI_Page_Station_Controls.ts | 22 ++- .../B06_Section_UI_Style_Cross_Controls.css | 26 +++- 10 files changed, 368 insertions(+), 166 deletions(-) diff --git a/B06_Section/B06_Section_UI_Cross_Culvert.ts b/B06_Section/B06_Section_UI_Cross_Culvert.ts index 2afb5e50..11d8ac34 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert.ts @@ -17,7 +17,12 @@ import { pipeWallThicknessM, revetHeightLimit, } from "./B06_Section_UI_Cross_Culvert_Geom"; -import type { BasinShape, CulvertLayout, PipeEnd } from "./B06_Section_UI_Cross_Culvert_Geom"; +import type { + BasinShape, + CulvertLayout, + PipeEnd, + WallLayout, +} from "./B06_Section_UI_Cross_Culvert_Geom"; // 기하 계산 진입점과 공개 상수·타입은 여기서 재수출한다 — B05(최소 토피)와 횡단 뷰가 // 이 모듈 경로로 이미 참조하고 있어 분리 후에도 import 경로를 바꾸지 않는다. @@ -48,7 +53,7 @@ function polygon( } /** 기슭막이 선택 키 — 측점 안에서 어느 벽인지 가린다. 추가 벽은 `extra{n}`. */ -export type RevetKey = "inlet" | "outlet" | `extra${number}`; +export type RevetKey = "inlet" | "outlet" | `extra${number}` | `bextra${number}`; /** 벽 강조를 카드 재생성 없이 갈아 끼우는 setter. */ export type RevetHighlightSetter = (key: RevetKey | null) => void; @@ -113,6 +118,19 @@ export function appendCulvertOverlay( : ""), ); } + // 집수정(ㄴ·ㄷ형) 계류측 성토부선 — 유출측과 같은 체계(2026-08-22 사용자 확정). + for (const segment of layout.basinFill.segments) { + planLine( + segment.points, + segment.kind === "cut" + ? `집수정 계류측 3도 절토선 — 구조물이 원지반에 묻혀 성토 불필요, 원지반 교차까지 ${segment.lengthM.toFixed(2)}m` + : `집수정 계류측 성토부선 — 사면길이 ${segment.lengthM.toFixed(2)}m` + + (segment.overLimit + ? " (법정 5m 이상 — 기슭막이 의무 구간, 조정창에서 추가)" + : " (법정 5m 이내)") + + ` · 성토 물매 1:${(segment.ratio ?? 1.2).toFixed(2)}`, + ); + } // 유입 기슭막이의 관 시작 접속선(2026-08-22 사용자 ②). if (layout.inletFill) { planLine( @@ -125,17 +143,27 @@ export function appendCulvertOverlay( }; // ①·② 구조물 — 유출측(추가 벽 포함)이 먼저 서고, 그 다음 유입측. - const wallsInOrder = [ - ...layout.walls.filter((w) => w.role === "outlet"), - ...layout.extraWalls, - ...layout.walls.filter((w) => w.role === "inlet"), + // 다단 벽은 유출측(extra)·집수정측(bextra) 두 갈래라 **출처를 키에 담아** 돈다. + const wallsInOrder: Array<{ wall: WallLayout; key: RevetKey }> = [ + ...layout.walls + .filter((w) => w.role === "outlet") + .map((wall) => ({ wall, key: "outlet" as RevetKey })), + ...layout.extraWalls.map((wall) => ({ + wall, + key: `extra${wall.extraIndex ?? 0}` as RevetKey, + })), + ...layout.basinExtras.map((wall) => ({ + wall, + key: `bextra${wall.extraIndex ?? 0}` as RevetKey, + })), + ...layout.walls + .filter((w) => w.role === "inlet") + .map((wall) => ({ wall, key: "inlet" as RevetKey })), ]; - 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[] = []; - for (const wall of wallsInOrder) { + for (const { wall, key: wallKey } of wallsInOrder) { // 합성 단면(하부 사다리꼴 + 상부 평행사변형) — 상단 배면이 사면선 접점(사용자 ①·②). const revetShape = polygon( wall.points.map((p) => [x(p.offset), toDisplayY(p.elevation)] as [number, number]), @@ -149,7 +177,7 @@ export function appendCulvertOverlay( (wall.lengthM ? ` · 연장 ${wall.lengthM.toFixed(1)}m` : ""), ); if (onSelectRevet) revetShape.classList.add("is-selectable"); - revetShapes.set(keyOf(wall), revetShape); + revetShapes.set(wallKey, revetShape); layer.append(revetShape); // 평행사변형 띠와 사다리꼴 사이 대각 이음선(내부 경계 — 사용자 스케치의 가운데 선): // 상단 변 중간점(배면+0.45)에서 전면과 나란히 바닥으로 내려온다. @@ -174,7 +202,7 @@ export function appendCulvertOverlay( // ① 배치 구간을 근입 바닥~상단 **전체**로 잡고 ② 벽 폴리곤 clip 안에만 그려 // 어떤 높이에서도 돌이 벽 밖으로 새지 않게 한다(2026-08-20 미스매치 정정). const pixelsPerMeter = Math.abs(x(1) - x(0)) || 1; - const clipId = `b06-revet-clip-${keyOf(wall)}-${Math.round(wall.backOffset * 100)}`; + const clipId = `b06-revet-clip-${wallKey}-${Math.round(wall.backOffset * 100)}`; const clip = document.createElementNS(SVG_NS, "clipPath"); clip.setAttribute("id", clipId); clip.append( @@ -342,7 +370,7 @@ export function appendCulvertOverlay( // 먼저 먹는다(2026-08-21 화면 확인). 그리기 순서(벽 → 관 → 보호공)는 그대로 두고 // 판정면만 위로 올린다. 관 자체의 툴팁은 벽 밖 구간에서 그대로 뜬다. if (onSelectRevet) { - for (const wall of wallsInOrder) { + for (const { wall, key: wallKey } of wallsInOrder) { const hit = polygon( wall.points.map((p) => [x(p.offset), toDisplayY(p.elevation)] as [number, number]), "b06-chart__culvert-revet-hit", @@ -350,7 +378,7 @@ export function appendCulvertOverlay( ); hit.addEventListener("click", (event) => { event.stopPropagation(); - onSelectRevet(keyOf(wall)); + onSelectRevet(wallKey); }); layer.append(hit); } diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts index 2b8c21bb..7c5b3e6e 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts @@ -94,14 +94,20 @@ export interface BasinBuildResult { /** 성토 사면선 트림 경계(벽 상단 도로측 꼭짓점)와 그 표고. */ trimOffset: number; trimElevation: number; + /** + * 계류측 성토부선 시작점(2026-08-22 사용자 확정 — 유출측과 같은 체계). + * ㄴ·ㄷ형에서 구조물 계류측 끝이 **원지반 위**로 나올 때만 준다. 그 자리에서 + * 1:1.2 성토부선을 긋고 5m를 넘으면 다단 기슭막이를 둘 수 있다. 원지반 안으로 + * 박히면 종전대로 절토선(cutLine)이고, I형은 관 하단 되메움선이 맡는다. + */ + fillStart: OffsetPoint | null; + /** 구조물 바닥 표고 — 다단 1단이 이 아래를 뚫지 못하게 하는 기준. */ + fillBottomElevation: number; } /** 실무 내공 폭(m) — 울진 돌집수정. I형 관 시작점(노견+1.0m) 계산에도 쓴다. */ export const BASIN_INNER_WIDTH_M = 1.0; -/** 집수정 주변 성토(되메움) 기울기(1:n) — 성토 비탈 기본 1:1.2. */ -const BASIN_FILL_SLOPE_RATIO = 1.2; - /** 집수정 단면을 만든다. 형태별 규칙은 파일 머리말 참조. */ export function buildBasin(input: BasinBuildInput): BasinBuildResult { const { @@ -223,7 +229,7 @@ export function buildBasin(input: BasinBuildInput): BasinBuildResult { // · 원지반 안쪽에 박히면 → 표준단면 절토경사(1:n)로 **절토선** // · 원지반 밖으로 나오면 → **성토(되메움)선** — 1:1.2로 지반까지. let basinCut: BasinLayout["cutLine"] = null; - let basinFill: BasinLayout["cutLine"] = null; + let fillStart: OffsetPoint | null = null; if (shape !== "I") { const refTop = shape === "U" @@ -241,19 +247,10 @@ export function buildBasin(input: BasinBuildInput): BasinBuildResult { break; } } - } else if ( - groundHere < refTop.elevation - 0.05 && - !(adjust.lateralM > 1e-6 && adjust.slopeM <= 1e-6) - ) { - const dropPerM = 1 / BASIN_FILL_SLOPE_RATIO; - for (let run = 0.05; run <= 30; run += 0.05) { - const probe = refTop.offset + outward * run; - const elevation = refTop.elevation - dropPerM * run; - if (elevation <= groundAt(probe)) { - basinFill = { from: refTop, to: { offset: probe, elevation } }; - break; - } - } + } else if (groundHere < refTop.elevation - 0.05) { + // 원지반 밖으로 나왔다 — 성토부선은 유출측과 같은 체계(1:1.2 + 5m 다단)로 + // 본체가 그린다(2026-08-22 사용자 확정). 여기서는 시작점만 넘긴다. + fillStart = refTop; } } return { @@ -267,7 +264,9 @@ export function buildBasin(input: BasinBuildInput): BasinBuildResult { }, reason, cutLine: basinCut, - fillLine: basinFill, + // I형 관 하단 되메움선만 여기 담긴다(applyBasinPipeFill). ㄴ·ㄷ형 성토는 + // 성토부선 체계로 옮겼다. + fillLine: null, }, // 관 끝선 = I형 벽의 안쪽(물받는 쪽 = 내공측) 변 복사(2026-08-22 사용자 확정). // 벽 폴리곤의 하단-전면(3)~상단-전면(2) 꼭짓점을 그대로 쓴다 — I형 근입 연장도 반영. @@ -282,6 +281,8 @@ export function buildBasin(input: BasinBuildInput): BasinBuildResult { shape === "I" ? iGroundIntersection() : { offset: floorTopInner, elevation: anchor.elevation }, + fillStart, + fillBottomElevation: anchor.elevation - floorThickness, trimOffset: wallBase - outward * REVET_LEAN_RATIO * wallHeight, trimElevation: anchor.elevation + wallHeight, }; @@ -369,3 +370,19 @@ export function applyBasinPipeFill( previousGap = gap; } } + +/** 집수정 조립 결과 — 본체가 트림·접근선·관 접속에 그대로 꽂아 쓴다. */ +export interface BasinAttachResult extends BasinBuildResult { + /** 성토 사면선 폴리라인(노견 → 수평 연장 → 벽 상단). 없으면 트림만. */ + approach: { points: OffsetPoint[] } | null; +} + +/** buildBasin + 접근선까지 한 번에 — 본체(Geom) 700줄 제한용 묶음. */ +export function attachBasin(input: BasinBuildInput): BasinAttachResult { + const built = buildBasin(input); + const points = basinApproachPoints(input.edge, input.outward, input.adjust, { + offset: built.trimOffset, + elevation: built.trimElevation, + }); + return { ...built, approach: points ? { points } : null }; +} diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts index 2cc6f4a4..8c4d0f59 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts @@ -499,3 +499,19 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult } return result; } + +/** 성토부가 없는 쪽(구조물 미설치·매몰)의 빈 결과. */ +export const NO_EXTRAS: OutletExtrasResult = { + walls: [], + segments: [], + appliedAdjusts: [], + addable: false, +}; + +/** start가 있을 때만 성토부·다단을 만든다 — 호출부의 삼항 중복을 없앤다. */ +export function buildExtrasAt( + start: OffsetPoint | null, + rest: Omit, +): OutletExtrasResult { + return start ? buildOutletExtras({ ...rest, start }) : NO_EXTRAS; +} diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts index 644b65db..10bb80e0 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts @@ -48,13 +48,12 @@ export * from "./B06_Section_UI_Cross_Culvert_Types"; import { applyBasinPipeFill, - basinApproachPoints, + attachBasin, BASIN_INNER_WIDTH_M, - buildBasin, inletChoiceAvailability, resolveBasinChoice, } from "./B06_Section_UI_Cross_Culvert_Basin"; -import { buildOutletExtras, inletGroundConnector } from "./B06_Section_UI_Cross_Culvert_Extra"; +import { buildExtrasAt, inletGroundConnector } from "./B06_Section_UI_Cross_Culvert_Extra"; import type { FillSlopeSegment, WallVertical } from "./B06_Section_UI_Cross_Culvert_Solve"; import { clampToFace, @@ -77,7 +76,12 @@ export function computeCulvertLayout( section: CrossSection, groundSamples: SectionSample[], /** 사용자 조작값(좌우 x·상하 d·높이 h·재질 m — 2026-08-22 4축). 없으면 자동. */ - revetShift?: { inlet?: WallAdjust; outlet?: WallAdjust; extras?: WallAdjust[] }, + revetShift?: { + inlet?: WallAdjust; + outlet?: WallAdjust; + extras?: WallAdjust[]; + basinExtras?: WallAdjust[]; + }, inletStructure?: InletStructureChoice, basinAdjustment?: BasinAdjust, equalizeExtras?: boolean, @@ -112,7 +116,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 }; - // 유입측 집수정 판정(2026-08-22 재정의): 성토사면 ≤3m면 집수정 기본(절토 포함). + // 집수정 판정: 유입 성토사면 ≤3m면 기본(절토측 포함). const mode = section.design?.section_mode; const inletSideName: "left" | "right" = uphill === "left" ? "left" : "right"; const inletIsCut = @@ -136,8 +140,7 @@ export function computeCulvertLayout( : null; const choice: InletStructureChoice = inletStructure ?? "auto"; const { reason: basinReason, shape: basinShape } = resolveBasinChoice(choice, ruleReason); - // ㄴ·ㄷ형은 사이즈 유지한 채 통째로 노견 끝점 일치(2026-08-22 확정) — 바닥(=관 - // 유입 invert)이 따라 올라 물매도 바뀐다. I형은 원지반 배관 유지(상세 Basin 참조). + // ㄴ·ㄷ형은 사이즈 유지한 채 통째로 노견 끝점 일치 — 바닥(=관 invert)이 따라 오른다. if (basinReason && basinShape !== "I") { inlet.elevation = inletInfo.edge.elevation_m - basinAdjust.innerHeightM; } else if (basinReason) { @@ -147,9 +150,8 @@ export function computeCulvertLayout( inlet.elevation = Math.min(groundAt(startOffset), invertCap(inletInfo.edge)); } - // 벽 제원(2026-08-22 4축 조작): 재질(기본 메쌓기)이 높이 한계(메2/찰3/콘5)를 - // 정하고, 높이 = 사용자 설정 ?? 기본 2.5(한계로 절삭). 하한은 2.0과 관경+여유고 - // 중 큰 값 — 관이 벽 위로 삐져나오면 안 된다. 자동 자리 계산도 이 높이 기준. + // 벽 제원(4축): 재질이 높이 한계(메2/찰3/콘5)를 정하고, 높이 = 설정 ?? 기본 2.0. + // 하한 = max(2.0, 관경+여유고) — 관이 벽 위로 삐져나오면 안 된다. const adjustOf = (value?: WallAdjust): WallAdjust => ({ ...ZERO_ADJUST, ...(value ?? {}) }); const adjInlet = adjustOf(revetShift?.inlet); const adjOutlet = adjustOf(revetShift?.outlet); @@ -165,7 +167,7 @@ export function computeCulvertLayout( }; const inletWallSpec = pipeWallSpec(culvert.inlet, adjInlet); const outletWallSpec = pipeWallSpec(culvert.outlet, adjOutlet); - // 조정창 드롭다운 선택지 가용성 — 판정은 분리 파일(Basin)이 맡는다(700줄 제한). + // 조정창 선택지 가용성 — 판정은 Basin이 맡는다. const inletOptions = inletChoiceAvailability({ designAt, groundAt, @@ -177,7 +179,7 @@ export function computeCulvertLayout( diameterM: diameter, cutSlopeRatio: section.design?.cut_slope_ratio ?? 1.0, }); - /** 요청값이 한계에 걸려 잘린 뒤의 **실제** 조작값. 조정창이 이 값을 되받는다. */ + /** 한계에 잘린 뒤의 실제 조작값 — 조정창이 되받는다. */ const appliedAdjust = { inlet: { ...adjInlet, h: adjInlet.h != null ? inletWallSpec.height : null }, outlet: { ...adjOutlet, h: adjOutlet.h != null ? outletWallSpec.height : null }, @@ -187,16 +189,14 @@ export function computeCulvertLayout( inlet: null, outlet: null, }; - // 기슭막이 자동 자리 = **노견(노폭 연장)이 최소가 되는 지점**(2026-08-22 사용자 확정) - // — 표준 높이 벽으로 물매 1:1.2가 딱 성립하는 가장 안쪽 자리. 관 m단위 올림 잔여가 - // 벽을 바깥으로 밀면 그만큼만 노견이 조정된다. 못 찾으면 사면 끝으로 폴백. + // 자동 자리 = **노폭 연장 최소 지점**(1:1.2가 딱 성립하는 가장 안쪽). 없으면 사면 끝. let inletAutoOffset: number | null = null; if (!basinReason && designAt) { const inletBaseAt = (offset: number): number => Math.min(groundAt(offset), invertCap(inletInfo.edge)); // 자동 자리 = 가용성 판정과 같은 스캔 결과(노견 최소 지점 ?? 사면 끝) 재사용. inletAutoOffset = inletOptions.revetAutoOffset; - // 4축 배치는 공용 풀이(placePipeWall — Solve, 700줄 제한): 좌우 = 노견 연장 + // 4축 배치는 공용 풀이(placePipeWall — Solve): 좌우 = 노견 연장 // 평행이동, 상하 = 성토선 대각, 위 한계 = 최소 성토고·토피 상한. // 유입을 내리면 유출도 따라 내려간다 — 유출이 못 받으면 유입도 못 내려간다(가드). const placed = placeInletWall({ @@ -260,6 +260,9 @@ export function computeCulvertLayout( let basin: BasinLayout | null = null; /** 집수정이 서면 관 유입단을 내공 안으로 옮긴다(접속 표현). */ let basinPipeEnd: OffsetPoint | null = null; + /** 집수정 계류측 성토부 시작점·바닥(ㄴ·ㄷ형이 원지반 위로 나올 때만). */ + let basinFillStart: OffsetPoint | null = null; + let basinFillBottom = 0; /** 관 끝단 마감면 — 구조물 계류측 변 복사(2026-08-20 사용자 ③). */ const endFaces: { inlet: EndFace | null; outlet: EndFace | null } = { inlet: null, @@ -267,9 +270,7 @@ export function computeCulvertLayout( }; let trimMin = Number.NEGATIVE_INFINITY; let trimMax = Number.POSITIVE_INFINITY; - // 트림 경계에서 성토 경사선 끝단이 **닿아야 할 표고** — 벽 높이를 관경+여유고로 고정한 - // 뒤로는 설계 사면선이 벽 상단과 어긋나므로, 사면선 끝을 이 점까지 내려 맞춘다 - // (2026-08-21 사용자 ② — 평행사변형·사다리꼴 교차 두 점 중 **상단** 교차점). + // 트림 경계에서 사면선 끝이 닿아야 할 표고(= 벽 이음선 상단점 — 2026-08-21 사용자 ②). let trimMinElevation: number | null = null; let trimMaxElevation: number | null = null; /** 노견 → 벽 이음선 상단점 성토 사면 구간(단일 각도). 설계선 대신 그린다. */ @@ -284,13 +285,10 @@ export function computeCulvertLayout( vertical: WallVertical | null = null, material: RevetMaterial = "dry", ): WallLayout | null => { - const reason: BasinLayout["reason"] | null = - // 백엔드 spec의 "집수정"은 판정(ruleReason)에 이미 반영됐다 — 사용자 선택 - // revet(기슭막이 강제)를 존중하려면 여기서 다시 살리면 안 된다(2026-08-22). - forceBasinReason; + // spec의 "집수정"은 ruleReason에 이미 반영 — 여기서 되살리면 revet 선택이 깨진다. + const reason: BasinLayout["reason"] | null = forceBasinReason; if (reason) { - // 집수정 구성은 분리 파일(_Cross_Culvert_Basin.ts — 700줄 제한)이 맡는다. - const built = buildBasin({ + const built = attachBasin({ anchor, outward, shape: basinShape, @@ -303,35 +301,27 @@ export function computeCulvertLayout( }); basin = built.basin; basinPipeEnd = built.pipeEnd; + basinFillStart = built.fillStart; + basinFillBottom = built.fillBottomElevation; endFaces[spec.role] = built.endFace; - // 성토 사면선은 벽 상단 도로측 꼭짓점에서 끊고, 끝단 표고도 거기 맞춘다. + // 사면선은 벽 상단 도로측 꼭짓점에서 끊고 끝단 표고도 거기 맞춘다. if (outward > 0) { trimMax = built.trimOffset; trimMaxElevation = built.trimElevation; + if (built.approach) trimMaxSlope = built.approach; } else { trimMin = built.trimOffset; trimMinElevation = built.trimElevation; - } - const points = basinApproachPoints(inletInfo.edge, outward, basinAdjust, { - offset: built.trimOffset, - elevation: built.trimElevation, - }); - if (points) { - if (outward > 0) trimMaxSlope = { points }; - else trimMinSlope = { points }; + if (built.approach) trimMinSlope = built.approach; } return null; } - // 형상(사용자 스케치 확정 — 좌측 벽 기준, 우측은 반전): 배면(도로측) 수직, 전면 - // (계류측)은 1:0.3 기운 평행사변형 띠. 높이는 4축 조작 제원(vertical)이 들고 - // 들어온다 — 계산용 높이(근입 0.5 위 기준선~상단), 재질 한계는 pipeWallSpec 절삭. + // 형상: 배면 수직 + 계류측 1:0.3 평행사변형 띠. 높이는 vertical(계산용)이 들고 온다. const height = vertical?.height ?? Math.min(revetTargetHeight(diameter), revetHeightLimit(spec.revet_form)); if (!(height > 0.05)) return null; const floatGapM = vertical?.floatGapM ?? 0; - // **자리 기준 = 하단선 중점**(2026-08-21 사용자 확정). 배면 기준으로 잡으면 벽이 - // 기울 때 체감 위치가 어긋난다. anchor.offset이 하단선 중점, anchor.elevation이 - // 그 자리 원지반(= 관 invert)이다. + // 자리 기준 = **하단선 중점**(2026-08-21) — anchor.elevation은 그 자리 관 invert. const baseWidth = thickness * 1.5 + REVET_LEAN_RATIO * height; const backOffset = anchor.offset - outward * (baseWidth / 2); const topJoint = backOffset + outward * (thickness / 2); @@ -339,10 +329,7 @@ export function computeCulvertLayout( const topBack: OffsetPoint = { offset: backOffset, elevation: topElevation }; // 상단 변: 사다리꼴 상단(t/2) + 띠(t). 이음선은 상단 변 중간점에서 1:0.3으로 바닥까지. const topFront = topJoint + outward * thickness; - // 하단선: **수평 기초**, 깊이 = 기준선(관 invert) 아래 근입 0.5m 고정 — - // 높이 기준을 "근입 0.5 위 기준선~상단"으로 확정하며 발자국 최저 지반 추적을 - // 대체했다(2026-08-22 사용자). 지반이 더 낮으면 바닥이 뜨고(floatGapM 경고), - // 더 높으면 그만큼 더 묻힌다. 전면 경사선(1:0.3)은 하단까지 연장. + // 하단선 = **수평 기초**, 깊이 = 기준선 아래 근입 0.5m(전면 측 기준으로 수렴). const frontXAt = (elevation: number): number => topFront + outward * REVET_LEAN_RATIO * (topElevation - elevation); let bottomElevation = anchor.elevation - REVET_EMBED_DEPTH_M; @@ -484,9 +471,8 @@ export function computeCulvertLayout( } } - // ── 관 단면 꼭짓점 — 끝단면을 구조물 계류측 변에 맞춰 자른다(2026-08-20 사용자 ③). - // 보호공 시작점이 **관 하단 꼭짓점**을 그대로 써야 하므로 여기서 확정한다. - // 축·법선은 정수 맞춤이 벽(=관 끝)을 옮길 때마다 다시 유도한다. + // ── 관 단면 꼭짓점 — 끝단면을 구조물 계류측 변으로 자른다. 축·법선은 벽이 옮겨질 + // 때마다 다시 유도한다(2026-08-20 사용자 ③). let axis: OffsetPoint = { offset: 1, elevation: 0 }; let normal: OffsetPoint = { offset: 0, elevation: 1 }; let topAnchor: OffsetPoint = pipeStart; @@ -529,16 +515,12 @@ export function computeCulvertLayout( outlet: endCorners("outlet", outlet), }; - // ── 관 길이 m 단위 맞춤(2026-08-21 사용자 ①). 관 끝은 기운 벽 전면으로 잘려 **상단·하단 - // 길이가 다르다** — 긴 변을 기준으로 올림해 정수 m로 잡고, 모자란 만큼 **유출 벽 자리** - // 를 관 축 방향 바깥으로 민다(벽 두께는 0.45 고정 — 사용자 확정). 벽 상단이 사면선과 - // 벌어지는 문제는 설계선 트림이 벽 상단을 따라오므로 생기지 않는다(사용자 ②). + // ── 관 길이 m 단위 맞춤(2026-08-21 사용자 ①) — 긴 변 기준 올림, 모자란 만큼 유출 + // 벽 자리를 관 축 방향 바깥으로 민다(벽 두께 0.45 고정). const outletSlopeAt = outletSlopeFactory(outletInfo, invertAt, outletWallSpec.height); /** 유출 벽 두께 — 항상 0.45 고정(2026-08-21 사용자 확정). 폭으로 길이를 맞추지 않는다. */ const outletThickness = REVET_THICKNESS_M; - // 손으로 민 벽은 정수 맞춤이 **절대 옮기지 않는다**(2026-08-22 사용자 확정 — 종전에는 - // 한쪽 벽을 밀면 이 루프가 반대쪽 벽을 따라 옮겨 좌우가 동시에 움직였다). 관 그림은 - // 두 벽 전면 사이 실길이로 두고 표기 길이만 올림(m)한다. + // 손으로 민 벽은 정수 맞춤이 **절대 옮기지 않는다**(2026-08-22) — 표기만 올림한다. const outletPinned = Math.abs(adjOutlet.x) > 1e-9 || Math.abs(adjOutlet.d) > 1e-9; if (outletWall && !outletPinned) { const target = Math.ceil(cutLength(pipeCorners) - 1e-6); @@ -595,10 +577,7 @@ export function computeCulvertLayout( if (!outletWall) break; continue; } - // ② 더 옮기면 물매가 범위를 벗어나거나 사면이 5m를 넘는다 — 여기서 멈춘다. - // **폭으로 흡수하지 않는다**: 폭을 건드리면 좌·우 벽 단면이 서로 달라지고 - // (2026-08-21 사용자 지적 — 유입을 밀었더니 유출 벽이 0.45→0.85로 부풀었다), - // 남는 몫은 관이 벽을 조금 벗어나는 것으로 두는 게 기존 확정 규칙이다. + // ② 물매·사면 5m를 벗어나면 멈춘다. 폭으로 흡수하지 않는다(좌·우 단면이 달라진다). break; } } @@ -613,8 +592,8 @@ export function computeCulvertLayout( : 0; } - // I형 집수정: 관 하단 꼭짓점이 지반 위에 뜨면 수평 되메움선(Basin 분리 — 700줄). - if (basin && !(basinAdjust.lateralM > 1e-6 && basinAdjust.slopeM <= 1e-6)) { + // I형 집수정: 관 하단 꼭짓점이 지반 위에 뜨면 3도 되메움선(Basin 분리). + if (basin) { applyBasinPipeFill(basin, pipeCorners.inlet.bottom, inletInfo.outward, groundAt); } // 유입 기슭막이의 관 시작 접속선(2026-08-22 ②) — 위 0도 성토선 / 아래 0도 1m+절토선. @@ -628,19 +607,24 @@ export function computeCulvertLayout( ) : null; - // 성토부선 + 추가 기슭막이(2026-08-22 사용자 — 보호공 삭제, 윗면 선만 성토부선으로 - // 남긴다). 끝 구간이 5m 이상이면 사용자가 추가 기슭막이(벽만)를 계단식으로 더 둔다. - const extras = outletWall - ? buildOutletExtras({ - start: pipeCorners.outlet.bottom, - startBottomElevation: outletWall.bottomBack.elevation, - outward: outletInfo.outward, - groundAt, - limitOffset: outletInfo.limit, - adjusts: (revetShift?.extras ?? []).map(adjustOf), - equalize: equalizeExtras === true, - }) - : { walls: [], segments: [], appliedAdjusts: [], addable: false }; + // 성토부선 + 다단 기슭막이(2026-08-22 — 보호공 삭제, 윗면 선만 성토부선으로). + // 유출측과 **집수정 계류측**이 같은 체계를 쓴다: 5m 넘으면 다단을 둘 수 있다. + const extras = buildExtrasAt(outletWall ? pipeCorners.outlet.bottom : null, { + startBottomElevation: outletWall?.bottomBack.elevation ?? 0, + outward: outletInfo.outward, + groundAt, + limitOffset: outletInfo.limit, + adjusts: (revetShift?.extras ?? []).map(adjustOf), + equalize: equalizeExtras === true, + }); + const basinExtras = buildExtrasAt(basinFillStart, { + startBottomElevation: basinFillBottom, + outward: inletInfo.outward, + groundAt, + limitOffset: inletInfo.limit, + adjusts: (revetShift?.basinExtras ?? []).map(adjustOf), + equalize: false, + }); // ── 성토 사면 구간 확정. 벽 자리가 굳은 뒤에 물매를 역산해야 관 길이 맞춤(벽 이동)이 // 접점을 다시 깨뜨리지 않는다 — 종전 어긋남의 직접 원인이 이 순서였다. @@ -685,6 +669,8 @@ export function computeCulvertLayout( structureRequired: outletSlope != null && outletSlope.lengthM >= FILL_SLOPE_MAX_LENGTH_M, }, outletFill: { segments: extras.segments, addable: extras.addable }, + basinFill: { segments: basinExtras.segments, addable: basinExtras.addable }, + basinExtras: basinExtras.walls, inletFill, extraWalls: extras.walls, basin, @@ -692,6 +678,7 @@ export function computeCulvertLayout( inlet: appliedAdjust.inlet, outlet: appliedAdjust.outlet, extras: extras.appliedAdjusts, + basinExtras: basinExtras.appliedAdjusts, }, designTrim: walls.length || basin diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts index 13747f58..cdb5dfe2 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts @@ -174,6 +174,10 @@ export interface CulvertLayout { }; /** 유출측 성토부선(보호공 대체 — 2026-08-22). 끝 구간이 5m 이상이면 추가 가능. */ outletFill: { segments: OutletFillSegment[]; addable: boolean }; + /** 유입 집수정(ㄴ·ㄷ형) 계류측 성토부선 — 유출측과 같은 체계(2026-08-22 사용자). */ + basinFill: { segments: OutletFillSegment[]; addable: boolean }; + /** 집수정 계류측 다단 기슭막이 — 조정창 키 `bextra{n}`. */ + basinExtras: WallLayout[]; /** 유입 기슭막이의 관 시작 접속선(2026-08-22 ②) — 위면 0도 성토선(지반 정지), * 아래면 0도 1m 후 표준 절토 경사선. 집수정·접속 불필요면 null. */ inletFill: OutletFillSegment | null; @@ -187,7 +191,13 @@ export interface CulvertLayout { * 한계에 걸리면 잘려 들어온다 — 조정창은 이 값을 보여 주고 저장한다. 그러지 않으면 * 눌러도 안 움직이는데 숫자만 계속 커진다(2026-08-21 사용자 지적). */ - revetShift: { inlet: WallAdjust; outlet: WallAdjust; extras: WallAdjust[] }; + revetShift: { + inlet: WallAdjust; + outlet: WallAdjust; + extras: WallAdjust[]; + /** 집수정 계류측 다단의 적용 조작값. */ + basinExtras: WallAdjust[]; + }; /** * 조정창 드롭다운 선택지 가용성(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 index 4edac236..71d39e2e 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts @@ -41,16 +41,20 @@ export interface InletStructureControl { } /** 유출측 다단 기슭막이 단 수 제어(2026-08-22 사용자 — 유출 벽 기준 숫자 입력). */ +/** 다단 기슭막이가 붙는 쪽 — 유출 성토부 / 집수정 계류측. */ +export type ExtraSide = "outlet" | "basin"; + export interface ExtraWallControl { - countFor: (section: CrossSection) => number; + /** side: "outlet" = 유출 다단, "basin" = 집수정 계류측 다단(2026-08-22). */ + countFor: (section: CrossSection, side?: ExtraSide) => number; /** 단 수 지정 — 줄이면 사라지는 단의 이동량도 함께 지운다. */ - setCount: (chainageM: number, count: number) => void; + setCount: (chainageM: number, count: number, side?: ExtraSide) => void; /** 다단 등간격 배치 요청(2026-08-22 ①) — 다음 계산 1회에 적용된다. */ equalize: (chainageM: number) => void; /** 등간격 요청을 소비(1회성) — 계산 직전에 Wire가 부른다. */ consumeEqualize: (section: CrossSection) => boolean; /** 기하가 실제로 세운 단 수로 잘라 동기화(지형상 못 세운 단 정리). */ - syncCount: (chainageM: number, built: number) => void; + syncCount: (chainageM: number, built: number, side?: ExtraSide) => void; } import { showToast } from "@ui/ui_template_elements"; @@ -85,7 +89,10 @@ export function computeCardCulvert( equalizeExtras, ); if (!layout) return null; - if (extraWalls && extraWalls.countFor(section) > layout.extraWalls.length) { + if (extraWalls && extraWalls.countFor(section, "basin") > layout.basinExtras.length) { + extraWalls.syncCount(section.chainage_m, layout.basinExtras.length, "basin"); + } + if (extraWalls && extraWalls.countFor(section, "outlet") > layout.extraWalls.length) { // 요청 단 수보다 지형이 허락하는 단이 적다(벽이 원지반에 0.5m 이상 묻히면 성토 // 불필요 — 그 아래 단은 못 세운다). 조작 중일 때만 가능한 단 수를 토스트로 알린다. if (activeRevet === "outlet" || activeRevet?.startsWith("extra")) { @@ -94,7 +101,7 @@ export function computeCardCulvert( "info", ); } - extraWalls.syncCount(section.chainage_m, layout.extraWalls.length); + extraWalls.syncCount(section.chainage_m, layout.extraWalls.length, "outlet"); } if (revetOffset) { const roles: Array<[RevetKey, WallAdjust]> = [ @@ -104,11 +111,17 @@ export function computeCardCulvert( `extra${i}`, applied, ]), + ...layout.revetShift.basinExtras.map((applied, i): [RevetKey, WallAdjust] => [ + `bextra${i}`, + applied, + ]), ]; const wallOf = (role: RevetKey) => - role.startsWith("extra") - ? layout.extraWalls[Number(role.slice(5))] - : layout.walls.find((wall) => wall.role === role); + 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); // 축별로 요청이 잘렸으면 왜 안 됐는지 토스트로 알린다(조작 중인 벽만). @@ -154,14 +167,19 @@ function adjustsInput( section: CrossSection, revetOffset?: RevetOffsetControl, extraWalls?: ExtraWallControl, -): { inlet: WallAdjust; outlet: WallAdjust; extras: WallAdjust[] } | undefined { +): + | { inlet: WallAdjust; outlet: WallAdjust; extras: WallAdjust[]; basinExtras: WallAdjust[] } + | undefined { if (!revetOffset) return undefined; return { inlet: revetOffset.adjustFor(section, "inlet"), outlet: revetOffset.adjustFor(section, "outlet"), - extras: Array.from({ length: extraWalls?.countFor(section) ?? 0 }, (_, i) => + extras: Array.from({ length: extraWalls?.countFor(section, "outlet") ?? 0 }, (_, i) => revetOffset.adjustFor(section, `extra${i}`), ), + basinExtras: Array.from({ length: extraWalls?.countFor(section, "basin") ?? 0 }, (_, i) => + revetOffset.adjustFor(section, `bextra${i}`), + ), }; } diff --git a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts index 06c85315..814313d2 100644 --- a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts @@ -42,7 +42,12 @@ export interface StructurePanelDeps { setStructure: (value: InletStructureChoice) => void; basinAdjustFor: () => BasinAdjust; updateBasin: (patch: Partial) => void; + /** 집수정 좌우 — **화면 기준**(+ = 화면 왼쪽). 부호 환산·한계 토스트는 카드가 한다. */ + nudgeBasinLateral: (screenDeltaM: number) => void; resetBasin: () => void; + /** 집수정 계류측 다단 상태·단 수 지정(2026-08-22 — 유출측과 같은 체계). */ + basinExtraState: () => { canAdd: boolean; count: number }; + setBasinExtraCount: (count: number) => void; /** 이동 조작 가능 여부 — 집수정(자리 고정)은 숨긴다. */ canNudge: (key: RevetKey) => boolean; /** 상황에 안 맞는 선택지 숨김(2026-08-22 사용자) — 기하가 판정한 가용성. */ @@ -231,43 +236,89 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan }); structureParts.controls.append(select); + // 집수정 조작 = **9키(3×3)**(2026-08-22 사용자 확정) — 십자 축은 이동, 네 모서리는 + // 크기, 가운데는 초기화. 기슭막이 십자와 축 규칙이 같아 따로 익힐 게 없다. + // ⇕+ 사면▲ ⇔+ + // ◀ ↺ ▶ + // ⇕- 사면▼ ⇔- const basinRow = makeRow("집수정 크기·위치"); basinRow.controls.classList.add("b06-structure-panel__basin-buttons"); - const basinButton = (label: string, titleText: string, patch: () => Partial) => - makeButton(label, titleText, () => deps.updateBasin(patch())); - const basinHeightUp = basinButton("높이+", "내부 높이 +0.1m", () => ({ - innerHeightM: Math.min(2, deps.basinAdjustFor().innerHeightM + BASIN_STEP_M), - })); - const basinHeightDown = basinButton("높이-", "내부 높이 -0.1m", () => ({ - innerHeightM: Math.max(1, deps.basinAdjustFor().innerHeightM - BASIN_STEP_M), - })); - const basinWidthDown = basinButton("폭-", "내부 폭 -0.1m", () => ({ - innerWidthM: Math.max(1, deps.basinAdjustFor().innerWidthM - BASIN_STEP_M), - })); - const basinWidthUp = basinButton("폭+", "내부 폭 +0.1m", () => ({ - innerWidthM: Math.min(2, deps.basinAdjustFor().innerWidthM + BASIN_STEP_M), - })); - const basinSlopeUp = basinButton("사면▲", "1:1.2 성토선을 따라 위로", () => ({ - slopeM: Math.max(0, deps.basinAdjustFor().slopeM - BASIN_STEP_M), - })); - const basinSlopeDown = basinButton("사면▼", "1:1.2 성토선을 따라 아래로", () => ({ - slopeM: deps.basinAdjustFor().slopeM + BASIN_STEP_M, - })); - const basinIn = basinButton("노견◀", "노견측으로 0.1m", () => ({ - lateralM: Math.max(0, deps.basinAdjustFor().lateralM - BASIN_STEP_M), - })); - const basinOut = basinButton("바깥▶", "노견 반대측으로 0.1m", () => ({ - lateralM: deps.basinAdjustFor().lateralM + BASIN_STEP_M, - })); + const basinKey = ( + label: string, + slot: string, + titleText: string, + onClick: () => void, + ): HTMLButtonElement => { + const button = makeButton(label, titleText, onClick); + button.classList.add(`b06-structure-panel__btn--${slot}`); + return button; + }; + const basinPatch = (patch: () => Partial) => () => deps.updateBasin(patch()); basinRow.controls.append( - basinHeightUp, - basinHeightDown, - basinWidthDown, - basinWidthUp, - basinSlopeUp, - basinSlopeDown, - basinIn, - basinOut, + basinKey( + "⇕+", + "nw", + "내부 높이 +0.1m", + basinPatch(() => ({ + innerHeightM: Math.min(2, deps.basinAdjustFor().innerHeightM + BASIN_STEP_M), + })), + ), + basinKey( + "▲", + "up", + "1:1.2 성토선을 따라 위로 0.1m", + basinPatch(() => ({ slopeM: Math.max(0, deps.basinAdjustFor().slopeM - BASIN_STEP_M) })), + ), + basinKey( + "⇔+", + "ne", + "내부 폭 +0.1m", + basinPatch(() => ({ + innerWidthM: Math.min(2, deps.basinAdjustFor().innerWidthM + BASIN_STEP_M), + })), + ), + basinKey("◀", "left", L("B06_Cross_Revet_Left"), () => deps.nudgeBasinLateral(BASIN_STEP_M)), + basinKey("↺", "reset", L("B06_Cross_Revet_Reset"), () => deps.resetBasin()), + basinKey("▶", "right", L("B06_Cross_Revet_Right"), () => deps.nudgeBasinLateral(-BASIN_STEP_M)), + basinKey( + "⇕-", + "sw", + "내부 높이 -0.1m", + basinPatch(() => ({ + innerHeightM: Math.max(1, deps.basinAdjustFor().innerHeightM - BASIN_STEP_M), + })), + ), + basinKey( + "▼", + "down", + "1:1.2 성토선을 따라 아래로 0.1m", + basinPatch(() => ({ slopeM: deps.basinAdjustFor().slopeM + BASIN_STEP_M })), + ), + basinKey( + "⇔-", + "se", + "내부 폭 -0.1m", + basinPatch(() => ({ + innerWidthM: Math.max(1, deps.basinAdjustFor().innerWidthM - BASIN_STEP_M), + })), + ), + ); + + // 집수정 계류측 다단 기슭막이 단 수 — 유출측 행과 같은 형식([- 값 +]). + const basinExtraParts = makeRow("집수정 계류측 기슭막이(단)"); + const basinExtraRow = basinExtraParts.row; + const basinCountValue = document.createElement("span"); + basinCountValue.className = "b06-structure-panel__hval"; + let basinCount = 0; + const clampBasinCount = (value: number): number => Math.max(0, Math.min(9, Math.round(value))); + basinExtraParts.controls.append( + makeButton("-", L("B06_Cross_Extra_Remove"), () => + deps.setBasinExtraCount(clampBasinCount(basinCount - 1)), + ), + basinCountValue, + makeButton("+", L("B06_Cross_Extra_Add"), () => + deps.setBasinExtraCount(clampBasinCount(basinCount + 1)), + ), ); // 다단 기슭막이 단 수(2026-08-22 사용자) — 높이 행과 같은 형식(- 값 +), @@ -299,6 +350,7 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan value, structureRow, basinRow.row, + basinExtraRow, materialRow, heightRow, extraRow, @@ -341,9 +393,19 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan moveRow.row.classList.toggle("is-hidden", !movable); basinRow.row.classList.toggle("is-hidden", !isBasin); basinReset.classList.toggle("is-hidden", !isBasin); + const basinExtra = deps.basinExtraState(); + basinExtraRow.classList.toggle( + "is-hidden", + !(isBasin && (basinExtra.canAdd || basinExtra.count > 0)), + ); + basinCount = basinExtra.count; + basinCountValue.textContent = `${basinExtra.count}단`; + // I형은 내공(높이·폭) 개념이 없다 — 9키의 **네 모서리**만 숨긴다. const isI = structure === "I"; - for (const button of [basinHeightUp, basinHeightDown, basinWidthDown, basinWidthUp]) { - button.classList.toggle("is-hidden", isI); + for (const slot of ["nw", "ne", "sw", "se"]) { + basinRow.controls + .querySelector(`.b06-structure-panel__btn--${slot}`) + ?.classList.toggle("is-hidden", isI); } materialRow.classList.toggle("is-hidden", !movable); heightRow.classList.toggle("is-hidden", !movable); diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index a0b97ff1..edb31e9b 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -23,6 +23,7 @@ import { sectionModeLabel, type RockBoundaryControl, } from "./B06_Section_UI_Cross_Design"; +import { showToast } from "@ui/ui_template_elements"; import { appendCulvertOverlay } from "./B06_Section_UI_Cross_Culvert"; import { computeCardCulvert, culvertRequiredHalfWidth } from "./B06_Section_UI_Cross_Culvert_Wire"; import type { @@ -146,6 +147,8 @@ export function createCrossSectionCard( let culvertInletOptions = { revetAllowed: true, basinLUAllowed: true }; /** 추가 기슭막이 상태(2026-08-22) — 끝 성토부 5m 이상(canAdd)·세워진 개수. */ let culvertExtraState = { canAdd: false, count: 0 }; + /** 집수정 계류측 다단 상태(2026-08-22) — 조정창의 집수정 단 수 행이 쓴다. */ + let culvertBasinExtraState = { canAdd: false, count: 0 }; /** 마지막 계산의 벽 제원(높이·재질) — 조정창 표시·높이 조작 기준. */ let culvertWallSpecs = new Map(); /** @@ -423,12 +426,16 @@ export function createCrossSectionCard( canAdd: culvertLayout.outletFill.addable, count: culvertLayout.extraWalls.length, }; + culvertBasinExtraState = { + canAdd: culvertLayout.basinFill.addable, + count: culvertLayout.basinExtras.length, + }; culvertWallSpecs = new Map(); - for (const wall of [...culvertLayout.walls, ...culvertLayout.extraWalls]) { - const key = - wall.role === "extra" ? (`extra${wall.extraIndex ?? 0}` as RevetKey) : wall.role; + const specOf = (wall: (typeof culvertLayout.walls)[number], key: RevetKey) => culvertWallSpecs.set(key, { height: wall.height, material: wall.material }); - } + for (const wall of culvertLayout.walls) specOf(wall, wall.role as RevetKey); + culvertLayout.extraWalls.forEach((wall, i) => specOf(wall, `extra${i}` as RevetKey)); + culvertLayout.basinExtras.forEach((wall, i) => specOf(wall, `bextra${i}` as RevetKey)); } if (culvertLayout) { setRevetActive = appendCulvertOverlay( @@ -562,18 +569,45 @@ export function createCrossSectionCard( ...inletStructure.adjustFor(section), ...patch, }), + // 집수정 좌우는 **화면 기준**으로 받는다(2026-08-22 사용자 확정) — 유입이 우측인 + // 측점에서 ◀가 화면 오른쪽으로 가던 반전을 없앤다. 노견 안쪽(음수)은 금지라 + // 0에서 멈추고, 멈춘 사실은 토스트로 알린다(기슭막이와 같은 규칙). + nudgeBasinLateral: (screenDeltaM) => { + const current = inletStructure?.adjustFor(section); + if (!current) return; + const next = current.lateralM + screenDeltaM * outwardOf("inlet"); + if (next < -1e-9) { + showToast(L("B06_Cross_Revet_Limit_Inward"), "info"); + } + inletStructure?.updateAdjust(section.chainage_m, { + ...current, + lateralM: Math.max(0, Math.round(next * 10) / 10), + }); + }, resetBasin: () => inletStructure?.resetAdjust(section.chainage_m), // 집수정은 자리 고정 — 유입이 집수정이면 ◀/▶/↺를 숨긴다(추가 벽은 항상 이동). canNudge: (key) => key !== "inlet" || !culvertInletIsBasin, optionsFor: () => culvertInletOptions, extraState: () => culvertExtraState, + basinExtraState: () => culvertBasinExtraState, setExtraCount: (count) => { // 줄어들며 사라지는 벽이 선택 중이면 선택을 유출 벽으로 옮긴다(빈 대상 방지). - if (activeRevet?.startsWith("extra") && Number(activeRevet.slice(5)) >= count) { + if ( + activeRevet?.startsWith("extra") && + !activeRevet.startsWith("bextra") && + Number(activeRevet.slice(5)) >= count + ) { activeRevet = "outlet"; revetOffset?.select(section.chainage_m, "outlet"); } - extraWalls?.setCount(section.chainage_m, count); + extraWalls?.setCount(section.chainage_m, count, "outlet"); + }, + setBasinExtraCount: (count) => { + if (activeRevet?.startsWith("bextra") && Number(activeRevet.slice(6)) >= count) { + activeRevet = "inlet"; + revetOffset?.select(section.chainage_m, "inlet"); + } + extraWalls?.setCount(section.chainage_m, count, "basin"); }, equalizeExtras: () => extraWalls?.equalize(section.chainage_m), }); diff --git a/B06_Section/B06_Section_UI_Page_Station_Controls.ts b/B06_Section/B06_Section_UI_Page_Station_Controls.ts index 28e8ff2c..17f8828e 100644 --- a/B06_Section/B06_Section_UI_Page_Station_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Station_Controls.ts @@ -292,16 +292,22 @@ export function createStationControls(deps: StationControlDeps): StationControls /** 등간격 배치 1회성 요청(2026-08-22 ①) — 다음 카드 계산에서 소비된다. */ const pendingEqualize = new Set(); + // 다단은 유출 성토부(outlet)·집수정 계류측(basin) 두 갈래라 키에 쪽을 담는다. + const extraKey = (chainageM: number, side: "outlet" | "basin"): string => + side === "basin" ? `b${chainageM.toFixed(2)}` : chainageM.toFixed(2); + const extraPrefix = (side: "outlet" | "basin"): string => (side === "basin" ? "bextra" : "extra"); + const extraWallControl: ExtraWallControl = { - countFor: (section) => extraCounts.get(section.chainage_m.toFixed(2)) ?? 0, + countFor: (section, side = "outlet") => + extraCounts.get(extraKey(section.chainage_m, side)) ?? 0, equalize: (chainageM) => { if ((extraCounts.get(chainageM.toFixed(2)) ?? 0) <= 0) return; // 단이 없으면 무의미 pendingEqualize.add(chainageM.toFixed(2)); deps.refreshCard(chainageM); }, consumeEqualize: (section) => pendingEqualize.delete(section.chainage_m.toFixed(2)), - setCount: (chainageM, requested) => { - const key = chainageM.toFixed(2); + setCount: (chainageM, requested, side = "outlet") => { + const key = extraKey(chainageM, side); const previous = extraCounts.get(key) ?? 0; const count = Math.max(0, Math.min(9, Math.round(requested))); if (count === previous) return; @@ -309,20 +315,22 @@ export function createStationControls(deps: StationControlDeps): StationControls else extraCounts.set(key, count); // 줄어든 단의 이동량은 지운다 — 다시 늘리면 자동 자리에서 시작한다. for (let i = count; i < previous; i += 1) { - revetShifts.delete(revetKey(chainageM, `extra${i}`)); + revetShifts.delete(revetKey(chainageM, `${extraPrefix(side)}${i}` as RevetKey)); } persistRevetShifts(); persistExtraCounts(); deps.refreshCard(chainageM); }, - syncCount: (chainageM, built) => { - const key = chainageM.toFixed(2); + syncCount: (chainageM, built, side = "outlet") => { + const key = extraKey(chainageM, side); 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}`)); + for (let i = built; i < count; i += 1) { + revetShifts.delete(revetKey(chainageM, `${extraPrefix(side)}${i}` as RevetKey)); + } persistRevetShifts(); persistExtraCounts(); }, diff --git a/B06_Section/B06_Section_UI_Style_Cross_Controls.css b/B06_Section/B06_Section_UI_Style_Cross_Controls.css index ac477bed..8a0ad7a5 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Controls.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Controls.css @@ -107,12 +107,34 @@ gap: 2px; } -.b06-structure-panel__basin-buttons { +/* 집수정 조작 = 9키(3×3, 2026-08-22 사용자) — 십자 축 이동 · 모서리 크기 · 중앙 초기화. + 기슭막이 십자와 같은 폭·버튼 크기로 맞춰 행열이 어긋나지 않게 한다. */ +.b06-structure-panel__controls.b06-structure-panel__basin-buttons { display: grid; - grid-template-columns: repeat(4, max-content); + grid-template-areas: + "nw up ne" + "left reset right" + "sw down se"; + justify-content: center; gap: 2px; } +.b06-structure-panel__btn--nw { + grid-area: nw; +} + +.b06-structure-panel__btn--ne { + grid-area: ne; +} + +.b06-structure-panel__btn--sw { + grid-area: sw; +} + +.b06-structure-panel__btn--se { + grid-area: se; +} + .b06-structure-panel__basin-reset { margin-left: auto; }