From 2a247184fd287ef929d699aba6b2e3a828c480f4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 22 Aug 2026 17:50:45 +0900 Subject: [PATCH] =?UTF-8?q?feat(B06):=20=EC=A7=91=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EC=A2=8C=EC=9A=B0=C2=B7=EC=83=81=ED=95=98=20=EC=9D=B4=EB=8F=99?= =?UTF-8?q?=20=EB=B0=8F=20=EB=82=B4=EA=B3=B5=20=EC=A1=B0=EC=A0=95=20+=20?= =?UTF-8?q?=EC=84=A4=EA=B3=84=EC=84=A0=20=EC=8A=A4=EB=83=85=20=EA=B8=89?= =?UTF-8?q?=EA=B2=BD=EC=82=AC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 집수정 조정값(BasinAdjust: 내공 폭·높이, 좌우 lateralM, 상하 slopeM) 신설. 측점별 세션 보관 + 백엔드 design.basin_adjust 저장 경로(Schema·Router) 연동 - 조정창에 집수정 조정 항목 추가, I형 관 시작점을 벽 안쪽 변과 원지반의 교차점으로 산출(이분법), 집수정 성토(되메움)선 5° → 성토 비탈 1:1.2로 통일 - 설계선 트림 수정: 배수관 레이아웃이 사면 폴리라인을 준 쪽은 그 시작점(노견)에서 설계선을 끊는다 — 종전에는 트림 경계에서 끝점 표고를 벽 상단으로 강제 스냅해 집수정을 좌우로 옮기면 마지막 구간이 수직으로 튀는 선이 남았다(사용자 지적) - basinApproachPoints의 길이 0 중복 꼭짓점 제거 검증: 4+4.3(집수정 I형 좌우 2.9m 이동)·13+15.7·5+0.0·2+0.0에서 급경사 세그먼트 0건 · tsc --noEmit 통과 · ruff check 통과 · prettier 적용 · pytest tmp/tests/ 148 passed, 7 skipped · B06_Section 700줄 초과 0건. Co-Authored-By: Claude Opus 5 (1M context) --- B06_Section/B06_Section_Api_Fetch.ts | 14 +++ B06_Section/B06_Section_Router.py | 9 +- B06_Section/B06_Section_Router_Confirm.py | 4 + B06_Section/B06_Section_Router_Design.py | 4 + B06_Section/B06_Section_Schema.py | 11 ++ B06_Section/B06_Section_UI_Cross_Culvert.ts | 5 +- .../B06_Section_UI_Cross_Culvert_Basin.ts | 103 ++++++++++++++++-- .../B06_Section_UI_Cross_Culvert_Const.ts | 8 +- .../B06_Section_UI_Cross_Culvert_Geom.ts | 37 ++++--- .../B06_Section_UI_Cross_Culvert_Types.ts | 15 +++ .../B06_Section_UI_Cross_Culvert_Wire.ts | 5 + B06_Section/B06_Section_UI_Cross_Design.ts | 18 ++- .../B06_Section_UI_Cross_Structure_Panel.ts | 70 +++++++++++- B06_Section/B06_Section_UI_Cross_View.ts | 13 +++ B06_Section/B06_Section_UI_Page.ts | 20 +++- .../B06_Section_UI_Page_Station_Controls.ts | 69 +++++++++++- .../B06_Section_UI_Style_Cross_Controls.css | 10 ++ 17 files changed, 362 insertions(+), 53 deletions(-) diff --git a/B06_Section/B06_Section_Api_Fetch.ts b/B06_Section/B06_Section_Api_Fetch.ts index ed498fc1..8402739f 100644 --- a/B06_Section/B06_Section_Api_Fetch.ts +++ b/B06_Section/B06_Section_Api_Fetch.ts @@ -244,6 +244,13 @@ export type DitchType = "standard" | "l_type"; /** 측점 표준횡단 설계 계산 결과(잠정치). data.design에 저장되는 구조와 동일. */ export interface CrossDesign { + inlet_structure?: "auto" | "revet" | "I" | "L" | "U"; + basin_adjust?: { + innerWidthM: number; + innerHeightM: number; + lateralM: number; + slopeM: number; + }; ground_type: GroundType; geometry_preset: "soil" | "rock"; section_mode: SectionMode; @@ -333,6 +340,13 @@ export interface CrossSectionPatch { rock_boundary_offset_m?: number; /** 측점 개별 표시 반폭(m, 2026-08-06 사용자 지시). */ display_half_width_m?: number; + inlet_structure?: "auto" | "revet" | "I" | "L" | "U"; + basin_adjust?: { + innerWidthM: number; + innerHeightM: number; + lateralM: number; + slopeM: number; + }; } /** 공통 fetch 헬퍼: 타임아웃 + 인증 헤더 + 오류 응답 변환. */ diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index 7ef13814..52987849 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -640,11 +640,10 @@ async def compute_cross_section_design( for record in stored_designs: if abs(float(record["chainage_m"]) - request.chainage_m) < 0.01: stored_design = record.get("design") - if ( - isinstance(stored_design, dict) - and stored_design.get("display_half_width_m") is not None - ): - design["display_half_width_m"] = stored_design["display_half_width_m"] + if isinstance(stored_design, dict): + for key in ("display_half_width_m", "inlet_structure", "basin_adjust"): + if stored_design.get(key) is not None: + design[key] = stored_design[key] break await connection.begin() try: diff --git a/B06_Section/B06_Section_Router_Confirm.py b/B06_Section/B06_Section_Router_Confirm.py index bb603b3b..3f260526 100644 --- a/B06_Section/B06_Section_Router_Confirm.py +++ b/B06_Section/B06_Section_Router_Confirm.py @@ -79,6 +79,10 @@ async def _apply_section_edits( patch["rock_boundary_offset_m"] = patch_item.rock_boundary_offset_m if patch_item.display_half_width_m is not None: patch["display_half_width_m"] = patch_item.display_half_width_m + if patch_item.inlet_structure is not None: + patch["inlet_structure"] = patch_item.inlet_structure + if patch_item.basin_adjust is not None: + patch["basin_adjust"] = patch_item.basin_adjust.model_dump() if patch: await merge_cross_section_design_patch( connection, route_id=route_id, chainage_m=patch_item.chainage_m, patch=patch diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py index 39b9452a..da71fd09 100644 --- a/B06_Section/B06_Section_Router_Design.py +++ b/B06_Section/B06_Section_Router_Design.py @@ -136,4 +136,8 @@ def recompute_designs_for_alignment( design.update(status="provisional", pavement_suggested=suggested) if stored.get("display_half_width_m") is not None: design["display_half_width_m"] = stored["display_half_width_m"] + if stored.get("inlet_structure") is not None: + design["inlet_structure"] = stored["inlet_structure"] + if stored.get("basin_adjust") is not None: + design["basin_adjust"] = stored["basin_adjust"] section["design"] = design diff --git a/B06_Section/B06_Section_Schema.py b/B06_Section/B06_Section_Schema.py index 9165cf42..aeb56084 100644 --- a/B06_Section/B06_Section_Schema.py +++ b/B06_Section/B06_Section_Schema.py @@ -66,6 +66,15 @@ class CrossDesignResponse(BaseModel): design: dict[str, Any] +class BasinAdjustPatch(BaseModel): + """집수정 내부 치수와 기본 위치 대비 이동량.""" + + innerWidthM: float = Field(..., ge=1.0, le=2.0) + innerHeightM: float = Field(..., ge=1.0, le=2.0) + lateralM: float = Field(..., ge=0.0, le=10.0) + slopeM: float = Field(..., ge=0.0, le=10.0) + + class CrossSectionPatch(BaseModel): """확정 시 측점별 data.design에 병합할 프론트 세션 보관값.""" @@ -74,6 +83,8 @@ class CrossSectionPatch(BaseModel): rock_boundary_offset_m: float | None = None # 측점별 표시 반폭(m) — 카드 개별 조절값. 전역 반폭과 다를 때만 실린다(2026-08-06). display_half_width_m: float | None = Field(default=None, gt=0) + inlet_structure: Literal["auto", "revet", "I", "L", "U"] | None = None + basin_adjust: BasinAdjustPatch | None = None class SectionConfirmRequest(BaseModel): diff --git a/B06_Section/B06_Section_UI_Cross_Culvert.ts b/B06_Section/B06_Section_UI_Cross_Culvert.ts index 82b29197..2afb5e50 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert.ts @@ -254,8 +254,7 @@ export function appendCulvertOverlay( layer.append(cut); } if (basin.fillLine) { - // 구조물이 원지반 밖으로 나온 경우의 성토(되메움) 계획선 — 경사 5도(임시값, - // 2026-08-22 사용자 지정). + // 구조물이 원지반 밖으로 나온 경우의 성토(되메움) 계획선 — 성토 비탈 1:1.2. const fill = document.createElementNS(SVG_NS, "line"); fill.setAttribute("x1", String(x(basin.fillLine.from.offset))); fill.setAttribute("y1", String(toDisplayY(basin.fillLine.from.elevation))); @@ -266,7 +265,7 @@ export function appendCulvertOverlay( fillTitle.textContent = basin.shape === "I" ? "집수정(I형) 되메움(성토)선 — 관 하단에서 3도(계류 쪽 오름)로 원지반과 연결" - : `집수정(${shapeName}) 성토(되메움)선 — 구조물이 원지반 밖으로 나와 5° 경사로 채움`; + : `집수정(${shapeName}) 성토(되메움)선 — 구조물이 원지반 밖으로 나와 1:1.2로 채움`; fill.append(fillTitle); layer.append(fill); } diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts index b5d44e57..2b8c21bb 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts @@ -22,11 +22,50 @@ import { import { minShoulderWallOffset, slopeToeOffset } from "./B06_Section_UI_Cross_Culvert_Solve"; import type { BasinLayout, + BasinAdjust, BasinShape, EndFace, OffsetPoint, + InletStructureChoice, } from "./B06_Section_UI_Cross_Culvert_Types"; +export function resolveBasinChoice( + choice: InletStructureChoice, + ruleReason: BasinLayout["reason"] | null, +): { reason: BasinLayout["reason"] | null; shape: BasinShape } { + const selected = choice === "I" || choice === "L" || choice === "U"; + return { + reason: choice === "revet" ? null : selected ? (ruleReason ?? "manual") : ruleReason, + shape: selected ? choice : "L", + }; +} + +export function basinApproachPoints( + edge: { offset_m: number; elevation_m: number }, + outward: number, + adjust: BasinAdjust, + trim: OffsetPoint, +): OffsetPoint[] | null { + // 접근선은 **설계선이 벽 상단까지 따라올 경로**다. 이 선을 주지 않으면 설계선이 + // 자기 성토 물매(1:1.2)로 내려갔다가 트림 표고(벽 상단)로 되튀어 올라 급경사 + // 선이 남는다(2026-08-22 사용자 지적). 좌우만 옮겨도 반드시 준다 — 집수정이 + // 나간 만큼 노견이 수평으로 연장되는 것이 맞는 표현이다. + if (adjust.lateralM <= 1e-6 && adjust.slopeM <= 1e-6) return null; + const points: OffsetPoint[] = [{ offset: edge.offset_m, elevation: edge.elevation_m }]; + if (adjust.lateralM > 1e-6) { + points.push({ + offset: edge.offset_m + outward * adjust.lateralM, + elevation: edge.elevation_m, + }); + } + // 중복 꼭짓점 제거 — 수평 선반 끝과 벽 상단이 겹치면 길이 0 구간이 남는다. + const last = points[points.length - 1]; + if (Math.hypot(trim.offset - last.offset, trim.elevation - last.elevation) > 1e-6) { + points.push(trim); + } + return points.length >= 2 ? points : null; +} + /** 집수정 구성 입력 — 기하 본체가 자기 상태를 명시적으로 물려 준다. */ export interface BasinBuildInput { /** 관 유입 기준점(노견 끝 offset, invert 표고 — ㄴ·ㄷ형은 상승분 반영 후). */ @@ -42,6 +81,7 @@ export interface BasinBuildInput { groundAt: (offset: number) => number; /** 표준단면 절토 경사(1:n) — 절토 계획선용. */ cutSlopeRatio: number; + adjust: BasinAdjust; } /** 집수정 구성 결과 — 본체가 트림·관 끝단면·관 시작점에 나눠 꽂는다. */ @@ -58,21 +98,35 @@ export interface BasinBuildResult { /** 실무 내공 폭(m) — 울진 돌집수정. I형 관 시작점(노견+1.0m) 계산에도 쓴다. */ export const BASIN_INNER_WIDTH_M = 1.0; -const INNER_WIDTH_M = BASIN_INNER_WIDTH_M; -/** 집수정 주변 성토(되메움) 표면 경사(도) — 2026-08-22 사용자 지정 "일단 5도". */ -const BASIN_FILL_ANGLE_DEG = 5; +/** 집수정 주변 성토(되메움) 기울기(1:n) — 성토 비탈 기본 1:1.2. */ +const BASIN_FILL_SLOPE_RATIO = 1.2; /** 집수정 단면을 만든다. 형태별 규칙은 파일 머리말 참조. */ export function buildBasin(input: BasinBuildInput): BasinBuildResult { - const { anchor, outward, shape, reason, diameterM, edge, groundAt, cutSlopeRatio } = input; + const { + anchor: baseAnchor, + outward, + shape, + reason, + diameterM, + edge, + groundAt, + cutSlopeRatio, + adjust, + } = input; + const movedAnchor = { + offset: baseAnchor.offset + outward * (adjust.lateralM + adjust.slopeM), + elevation: baseAnchor.elevation - adjust.slopeM / 1.2, + }; + const anchor = movedAnchor; const memberT = BASIN_MEMBER_THICKNESS_M; const floorThickness = memberT; // 벽 높이: I형은 노견 접점까지 성장, ㄴ·ㄷ형은 내부 높이 1.2m 고정(임시값). const wallHeight = shape === "I" ? Math.max(diameterM + REVET_FREEBOARD_M, edge.elevation_m - anchor.elevation) - : BASIN_INNER_HEIGHT_M; + : adjust.innerHeightM; // 기움은 **수직 기준 반전** — 상단이 도로측(내공 쪽)으로 1:0.3 물러난다(2026-08-20). // bottomElevation을 내리면 면 기울기를 그대로 연장한다(I형 근입용). const defaultBottom = anchor.elevation - floorThickness; @@ -112,6 +166,26 @@ export function buildBasin(input: BasinBuildInput): BasinBuildResult { { kind: "wall", points: wallOf(wallBase, outward, iWallBottom) }, ]; const iWallPoints = parts[0].points; + const iGroundIntersection = (): OffsetPoint => { + const low = iWallPoints[3]; + const high = iWallPoints[2]; + let lo = 0; + let hi = 1; + for (let pass = 0; pass < 32; pass += 1) { + const mid = (lo + hi) / 2; + const point = { + offset: low.offset + (high.offset - low.offset) * mid, + elevation: low.elevation + (high.elevation - low.elevation) * mid, + }; + if (point.elevation < groundAt(point.offset)) lo = mid; + else hi = mid; + } + const t = (lo + hi) / 2; + return { + offset: low.offset + (high.offset - low.offset) * t, + elevation: low.elevation + (high.elevation - low.elevation) * t, + }; + }; // ㄴ형 = I형 + 바닥판. 바닥은 벽 바깥(계류측) 변에 맞대고 그 너머로 뻗는다 // (2026-08-20 확정). 벽이 기울어 있어 안쪽 변도 벽 바깥면 선을 그대로 따른다. const wallOuterAt = (elevation: number): number => { @@ -121,7 +195,7 @@ export function buildBasin(input: BasinBuildInput): BasinBuildResult { }; const floorTopInner = wallOuterAt(anchor.elevation); const floorBottomInner = wallOuterAt(anchor.elevation - floorThickness); - const floorOuter = floorTopInner + outward * (INNER_WIDTH_M + memberT); + const floorOuter = floorTopInner + outward * (adjust.innerWidthM + memberT); const floorTaper = REVET_LEAN_RATIO * floorThickness; if (shape !== "I") { parts.push({ @@ -147,7 +221,7 @@ export function buildBasin(input: BasinBuildInput): BasinBuildResult { // 구조물 계류측 끝(ㄴ형 = 바닥 바깥 끝 상단, ㄷ형 = 막음벽 계류측 상단 꼭짓점)과 // 원지반의 관계(2026-08-22 사용자 ①): // · 원지반 안쪽에 박히면 → 표준단면 절토경사(1:n)로 **절토선** - // · 원지반 밖으로 나오면 → **성토(되메움)선** — 경사 5도(임시값)로 지반까지. + // · 원지반 밖으로 나오면 → **성토(되메움)선** — 1:1.2로 지반까지. let basinCut: BasinLayout["cutLine"] = null; let basinFill: BasinLayout["cutLine"] = null; if (shape !== "I") { @@ -167,8 +241,11 @@ export function buildBasin(input: BasinBuildInput): BasinBuildResult { break; } } - } else if (groundHere < refTop.elevation - 0.05) { - const dropPerM = Math.tan((BASIN_FILL_ANGLE_DEG * Math.PI) / 180); + } 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; @@ -203,7 +280,7 @@ export function buildBasin(input: BasinBuildInput): BasinBuildResult { }, pipeEnd: shape === "I" - ? { offset: anchor.offset + outward * INNER_WIDTH_M, elevation: anchor.elevation } + ? iGroundIntersection() : { offset: floorTopInner, elevation: anchor.elevation }, trimOffset: wallBase - outward * REVET_LEAN_RATIO * wallHeight, trimElevation: anchor.elevation + wallHeight, @@ -248,6 +325,12 @@ export function inletChoiceAvailability(input: { edge, groundAt, cutSlopeRatio: input.cutSlopeRatio, + adjust: { + innerWidthM: BASIN_INNER_WIDTH_M, + innerHeightM: BASIN_INNER_HEIGHT_M, + lateralM: 0, + slopeM: 0, + }, }); return { revetAllowed, basinLUAllowed: probe.basin.cutLine != null, revetAutoOffset }; } diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts index a528988b..00177d39 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts @@ -88,8 +88,8 @@ export function revetHeightLimit(form: string | null | undefined): number { /* ── 기슭막이 재질·높이 조작(2026-08-22 사용자 확정) ───────────────────── * 높이 조작이 교본 형태별 한계와 부딪히면 **재질을 바꿔야** 더 올릴 수 있다: - * 메쌓기 2.0 / 찰쌓기 3.0(돌쌓기.md §1) / 콘크리트 5.0(임시 — 사용자 지정 - * "일단 5m", 흙막이.md 산복기초 4.0과 다름·확정 시 교체). 높이 기준은 근입 + * 메쌓기 2.0 / 찰쌓기 3.0(돌쌓기.md §1) / 콘크리트 5.0(프로젝트 기본값, + * 외부 기준 미확인). 높이 기준은 근입 * 0.5m 위 기준선(관 invert 자리)~상단의 **계산용 높이**로 종전과 동일. */ export type RevetMaterial = "dry" | "wet" | "concrete"; @@ -118,7 +118,7 @@ export const PIPE_CONNECT_GRADE = Math.tan((3 * Math.PI) / 180); export const PIPE_WALL_MIN_HEIGHT_M = 2.0; /** 배관 기슭막이 기본 높이(m) — 재질과 무관하게 2.0(2026-08-22 사용자 정정). */ export const PIPE_WALL_DEFAULT_HEIGHT_M = 2.0; -/** 일반(추가) 기슭막이 높이 조작 하한(m). */ +/** 일반(추가) 기슭막이 높이 조작 하한(m) — 프로젝트 기본값 0.5m. */ export const EXTRA_WALL_MIN_HEIGHT_M = 0.5; /** 일반(추가) 기슭막이 기본 높이(m). */ export const EXTRA_WALL_DEFAULT_HEIGHT_M = 1.5; @@ -161,7 +161,7 @@ export const BASIN_BOTTOM_CLEARANCE_M = 0.5; /** * 집수정 내부 높이(m) — 바닥판이 있는 ㄴ(L)·ㄷ형의 **바닥 윗면~벽 상단** 깊이. - * ⚠ 임시 설정값 1.2(2026-08-22 사용자 지정 "일단 1.2로") — 근거 확정 시 교체. + * 프로젝트 기본값 1.2m(2026-08-22 사용자 확정). 외부 기준은 확인되지 않았다. * I형(공유벽뿐)은 이 값을 쓰지 않고 노견 끝 접점까지 벽을 키운다. */ export const BASIN_INNER_HEIGHT_M = 1.2; diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts index 33aed366..644b65db 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts @@ -10,7 +10,6 @@ import type { CrossSection, CulvertSideSpec, SectionSample } from "./B06_Section_Api_Fetch"; import { - BASIN_INNER_HEIGHT_M, BASIN_MAX_FILL_SLOPE_M, FILL_MIN_RISE_M, materialFromForm, @@ -32,7 +31,7 @@ import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const"; import { ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types"; import type { BasinLayout, - BasinShape, + BasinAdjust, CulvertLayout, EndFace, InletStructureChoice, @@ -41,6 +40,7 @@ import type { WallAdjust, WallLayout, } from "./B06_Section_UI_Cross_Culvert_Types"; +import { DEFAULT_BASIN_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types"; // 상수·자료형은 분리 파일에 있고, 기존 import 경로를 유지하기 위해 그대로 다시 내보낸다. export * from "./B06_Section_UI_Cross_Culvert_Const"; @@ -48,9 +48,11 @@ export * from "./B06_Section_UI_Cross_Culvert_Types"; import { applyBasinPipeFill, + basinApproachPoints, 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 type { FillSlopeSegment, WallVertical } from "./B06_Section_UI_Cross_Culvert_Solve"; @@ -76,9 +78,8 @@ export function computeCulvertLayout( groundSamples: SectionSample[], /** 사용자 조작값(좌우 x·상하 d·높이 h·재질 m — 2026-08-22 4축). 없으면 자동. */ revetShift?: { inlet?: WallAdjust; outlet?: WallAdjust; extras?: WallAdjust[] }, - /** 유입측 구조물 형식 선택(드롭다운) — 없으면 auto(규칙). */ inletStructure?: InletStructureChoice, - /** 다단 등간격 배치 1회성 트리거(2026-08-22 사용자 ①). */ + basinAdjustment?: BasinAdjust, equalizeExtras?: boolean, ): CulvertLayout | null { const culvert = section.culvert; @@ -92,6 +93,7 @@ export function computeCulvertLayout( const minSample = Math.min(...sampleOffsets); const maxSample = Math.max(...sampleOffsets); if (!(maxSample > minSample)) return null; + const basinAdjust = basinAdjustment ?? DEFAULT_BASIN_ADJUST; // 좌표 규약: +offset = 좌측. 상단측 = 유입(미상이면 좌측 폴백). const uphill = section.uphill_side ?? "left"; @@ -132,24 +134,16 @@ export function computeCulvertLayout( : inletFillSlopeLen <= BASIN_MAX_FILL_SLOPE_M + 1e-6 ? "short" : null; - // 사용자 선택(드롭다운 — 2026-08-22)이 규칙보다 우선한다: - // revet = 기슭막이+배관 강제(양측성토 로직), I/L/U = 해당 형식 집수정 강제. const choice: InletStructureChoice = inletStructure ?? "auto"; - const basinReason: BasinLayout["reason"] | null = - choice === "revet" - ? null - : choice === "I" || choice === "L" || choice === "U" - ? (ruleReason ?? "manual") - : ruleReason; - // 집수정 형식 기본값 = ㄴ(L)형(2026-08-20 확정) — 선택 시 그 형식. - const basinShape: BasinShape = choice === "I" || choice === "L" || choice === "U" ? choice : "L"; + const { reason: basinReason, shape: basinShape } = resolveBasinChoice(choice, ruleReason); // ㄴ·ㄷ형은 사이즈 유지한 채 통째로 노견 끝점 일치(2026-08-22 확정) — 바닥(=관 // 유입 invert)이 따라 올라 물매도 바뀐다. I형은 원지반 배관 유지(상세 Basin 참조). if (basinReason && basinShape !== "I") { - inlet.elevation = inletInfo.edge.elevation_m - BASIN_INNER_HEIGHT_M; + inlet.elevation = inletInfo.edge.elevation_m - basinAdjust.innerHeightM; } else if (basinReason) { // I형 — 관 시작점(내공 1.0m 자리) invert = 그 x의 원지반(토피 상한 이내). - const startOffset = inlet.offset + inletInfo.outward * BASIN_INNER_WIDTH_M; + const startOffset = + inlet.offset + inletInfo.outward * (BASIN_INNER_WIDTH_M + basinAdjust.lateralM); inlet.elevation = Math.min(groundAt(startOffset), invertCap(inletInfo.edge)); } @@ -305,6 +299,7 @@ export function computeCulvertLayout( edge: spec.role === "inlet" ? inletInfo.edge : outletInfo.edge, groundAt, cutSlopeRatio: section.design?.cut_slope_ratio ?? 1.0, + adjust: basinAdjust, }); basin = built.basin; basinPipeEnd = built.pipeEnd; @@ -317,6 +312,14 @@ export function computeCulvertLayout( 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 }; + } return null; } // 형상(사용자 스케치 확정 — 좌측 벽 기준, 우측은 반전): 배면(도로측) 수직, 전면 @@ -611,7 +614,7 @@ export function computeCulvertLayout( } // I형 집수정: 관 하단 꼭짓점이 지반 위에 뜨면 수평 되메움선(Basin 분리 — 700줄). - if (basin) { + if (basin && !(basinAdjust.lateralM > 1e-6 && basinAdjust.slopeM <= 1e-6)) { applyBasinPipeFill(basin, pipeCorners.inlet.bottom, inletInfo.outward, groundAt); } // 유입 기슭막이의 관 시작 접속선(2026-08-22 ②) — 위 0도 성토선 / 아래 0도 1m+절토선. diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts index cc4dae12..13747f58 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts @@ -105,6 +105,21 @@ export interface BasinLayout { fillLine: { from: OffsetPoint; to: OffsetPoint } | null; } +/** 측점별 집수정 조정값. 위치값은 기본 배치 기준이며 +는 계류측/사면 아래 방향이다. */ +export interface BasinAdjust { + innerWidthM: number; + innerHeightM: number; + lateralM: number; + slopeM: number; +} + +export const DEFAULT_BASIN_ADJUST: BasinAdjust = { + innerWidthM: 1.0, + innerHeightM: 1.2, + lateralM: 0, + slopeM: 0, +}; + /** 관 끝단을 자를 마감면 — 구조물(기슭막이·집수정)의 계류측 변을 **그대로 복사**한다. */ export interface EndFace { base: OffsetPoint; diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts index d1cdc1ea..4edac236 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts @@ -7,6 +7,7 @@ import type { CrossSection, SectionSample } from "./B06_Section_Api_Fetch"; import { computeCulvertLayout } from "./B06_Section_UI_Cross_Culvert"; import type { CulvertLayout, InletStructureChoice, RevetKey } from "./B06_Section_UI_Cross_Culvert"; +import type { BasinAdjust } from "./B06_Section_UI_Cross_Culvert_Types"; import { materialLabel, materialLimit } from "./B06_Section_UI_Cross_Culvert_Const"; import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types"; import { @@ -33,7 +34,10 @@ export interface RevetOffsetControl { /** 유입측 구조물 형식 선택(드롭다운 — 2026-08-22 사용자). 세션 보관은 Page가 한다. */ export interface InletStructureControl { valueFor: (section: CrossSection) => InletStructureChoice; + adjustFor: (section: CrossSection) => BasinAdjust; set: (chainageM: number, value: InletStructureChoice) => void; + updateAdjust: (chainageM: number, patch: Partial) => void; + resetAdjust: (chainageM: number) => void; } /** 유출측 다단 기슭막이 단 수 제어(2026-08-22 사용자 — 유출 벽 기준 숫자 입력). */ @@ -77,6 +81,7 @@ export function computeCardCulvert( sourceSamples, adjustsInput(section, revetOffset, extraWalls), inletStructure?.valueFor(section), + inletStructure?.adjustFor(section), equalizeExtras, ); if (!layout) return null; diff --git a/B06_Section/B06_Section_UI_Cross_Design.ts b/B06_Section/B06_Section_UI_Cross_Design.ts index fa40b843..6e0349aa 100644 --- a/B06_Section/B06_Section_UI_Cross_Design.ts +++ b/B06_Section/B06_Section_UI_Cross_Design.ts @@ -543,8 +543,22 @@ export function appendCrossDesignOverlay( | [{ offset_m: number; elevation_m: number }, { offset_m: number; elevation_m: number }] | null => { // 배수관 세트 트림 + 사면 끝 접점 트림을 합친 유효 범위. - const minOffset = Math.max(trim?.minOffset ?? -Infinity, meetLeft ?? -Infinity); - const maxOffset = Math.min(trim?.maxOffset ?? Infinity, meetRight ?? Infinity); + // 배수관 레이아웃이 사면 폴리라인(min/maxSlope)을 준 쪽은 **그 시작점(노견)에서** + // 설계선을 끊는다 — 그 바깥은 폴리라인이 그리므로 이중선이 되고, 트림 경계에서 + // 표고를 벽 상단으로 스냅하면서 수직으로 튀는 선이 남는다(2026-08-22 사용자 지적: + // 집수정을 좌우로 옮기면 벽 상단이 노견 표고 그대로라 스냅 폭이 그만큼 커진다). + const minSlopeStart = trim?.minSlope?.points[0]?.offset; + const maxSlopeStart = trim?.maxSlope?.points[0]?.offset; + const minOffset = Math.max( + trim?.minOffset ?? -Infinity, + meetLeft ?? -Infinity, + minSlopeStart ?? -Infinity, + ); + const maxOffset = Math.min( + trim?.maxOffset ?? Infinity, + meetRight ?? Infinity, + maxSlopeStart ?? Infinity, + ); if (!Number.isFinite(minOffset) && !Number.isFinite(maxOffset)) return [a, b]; const effective = { minOffset, maxOffset }; const lo = Math.min(a.offset_m, b.offset_m); diff --git a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts index 054a11ff..06c85315 100644 --- a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts @@ -13,7 +13,7 @@ import type { InletStructureChoice, RevetKey } from "./B06_Section_UI_Cross_Culvert"; import { materialLimit, REVET_MATERIALS } from "./B06_Section_UI_Cross_Culvert_Const"; import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const"; -import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types"; +import type { BasinAdjust, WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types"; import { L } from "./B06_Section_UI_Section_Common"; /** 조정창이 쓰는 값·동작. 카드가 자기 상태에 맞춰 물려 준다. */ @@ -40,6 +40,9 @@ export interface StructurePanelDeps { structureFor: () => InletStructureChoice; /** 유입측 구조물 형식 변경 — 카드를 다시 그린다. */ setStructure: (value: InletStructureChoice) => void; + basinAdjustFor: () => BasinAdjust; + updateBasin: (patch: Partial) => void; + resetBasin: () => void; /** 이동 조작 가능 여부 — 집수정(자리 고정)은 숨긴다. */ canNudge: (key: RevetKey) => boolean; /** 상황에 안 맞는 선택지 숨김(2026-08-22 사용자) — 기하가 판정한 가용성. */ @@ -64,6 +67,7 @@ export interface StructurePanelHandle { */ const STEP_M = 1.0; const HEIGHT_STEP_M = 0.1; +const BASIN_STEP_M = 0.1; function makeButton(label: string, title: string, onClick: () => void): HTMLButtonElement { const button = document.createElement("button"); @@ -227,6 +231,45 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan }); structureParts.controls.append(select); + 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, + })); + basinRow.controls.append( + basinHeightUp, + basinHeightDown, + basinWidthDown, + basinWidthUp, + basinSlopeUp, + basinSlopeDown, + basinIn, + basinOut, + ); + // 다단 기슭막이 단 수(2026-08-22 사용자) — 높이 행과 같은 형식(- 값 +), // 직접 입력 없음. 현재 단 수는 show()가 채운다. const extraParts = makeRow(L("B06_Cross_Extra_Count")); @@ -248,8 +291,19 @@ 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, materialRow, heightRow, extraRow, moveRow.row); + const basinReset = makeButton("↺", "집수정 기본값", () => deps.resetBasin()); + basinReset.classList.add("b06-structure-panel__basin-reset"); + head.append(title, basinReset, closeButton); + root.append( + head, + value, + structureRow, + basinRow.row, + materialRow, + heightRow, + extraRow, + moveRow.row, + ); return { root, @@ -259,6 +313,9 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan if (!key) return; const isExtra = key.startsWith("extra"); const movable = deps.canNudge(key); + const structure = deps.structureFor(); + const isBasin = + key === "inlet" && (structure === "I" || structure === "L" || structure === "U"); title.textContent = isExtra ? `${L("B06_Cross_Revet_Extra")} ${Number(key.slice(5)) + 1}` : key === "outlet" @@ -269,7 +326,6 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan // 형식 선택은 유입측에서만, 이동·높이 조작은 기슭막이(집수정 제외)만. structureRow.classList.toggle("is-hidden", key !== "inlet"); if (key === "inlet") { - const structure = deps.structureFor(); // 상황에 안 맞는 선택지는 숨긴다(2026-08-22 사용자). 단 지금 선택된 값은 // 남긴다 — 숨기면 셀렉트가 빈 값이 된다. const allow = deps.optionsFor(); @@ -283,6 +339,12 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan select.value = structure; } moveRow.row.classList.toggle("is-hidden", !movable); + basinRow.row.classList.toggle("is-hidden", !isBasin); + basinReset.classList.toggle("is-hidden", !isBasin); + const isI = structure === "I"; + for (const button of [basinHeightUp, basinHeightDown, basinWidthDown, basinWidthUp]) { + button.classList.toggle("is-hidden", isI); + } materialRow.classList.toggle("is-hidden", !movable); heightRow.classList.toggle("is-hidden", !movable); if (movable) { diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index bb4f9db7..a0b97ff1 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -550,6 +550,19 @@ export function createCrossSectionCard( }, structureFor: () => inletStructure?.valueFor(section) ?? "auto", setStructure: (value) => inletStructure?.set(section.chainage_m, value), + basinAdjustFor: () => + inletStructure?.adjustFor(section) ?? { + innerWidthM: 1, + innerHeightM: 1.2, + lateralM: 0, + slopeM: 0, + }, + updateBasin: (patch) => + inletStructure?.updateAdjust(section.chainage_m, { + ...inletStructure.adjustFor(section), + ...patch, + }), + resetBasin: () => inletStructure?.resetAdjust(section.chainage_m), // 집수정은 자리 고정 — 유입이 집수정이면 ◀/▶/↺를 숨긴다(추가 벽은 항상 이동). canNudge: (key) => key !== "inlet" || !culvertInletIsBasin, optionsFor: () => culvertInletOptions, diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index d274b946..c9fd8f79 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -151,7 +151,11 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { standard_cross_section: standardPanel?.getValues(), }); if (designRequestSeq.get(chainageM) !== seq) return; - target.design = response.design; + target.design = { + ...response.design, + inlet_structure: target.design?.inlet_structure, + basin_adjust: target.design?.basin_adjust, + }; sectionView.refreshCard(chainageM); } catch (error) { if (designRequestSeq.get(chainageM) !== seq) return; @@ -236,7 +240,11 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { const next = designByChainage.get(section.chainage_m.toFixed(3)); if (next) { // full_designs 응답은 설계 전체(설계선 좌표 포함)라 통째로 교체한다. - section.design = next as typeof section.design; + section.design = { + ...(next as NonNullable), + inlet_structure: section.design?.inlet_structure, + basin_adjust: section.design?.basin_adjust, + }; sectionView.refreshCard(section.chainage_m); } } @@ -387,6 +395,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { const stationWidthControl = stationControls.stationWidth; const revetOffsetControl = stationControls.revetOffset; const stationWidths = stationControls.widths; + const inletStructures = stationControls.inletStructures; + const basinAdjustments = stationControls.basinAdjustments; const sectionView = createSectionView( (chainageM, change) => { @@ -491,6 +501,12 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { stationWidths.forEach((width, chainage) => { patchFor(Number(chainage)).display_half_width_m = width; }); + inletStructures.forEach((structure, chainage) => { + patchFor(Number(chainage)).inlet_structure = structure; + }); + basinAdjustments.forEach((adjust, chainage) => { + patchFor(Number(chainage)).basin_adjust = adjust; + }); const crossPatches: CrossSectionPatch[] = [...patchByChainage.values()]; // 유토곡선은 화면 표시 내내 프론트 메모리에만 있다가 저장 시점에만 영구 저장된다. const result = diff --git a/B06_Section/B06_Section_UI_Page_Station_Controls.ts b/B06_Section/B06_Section_UI_Page_Station_Controls.ts index 5276dd7b..28e8ff2c 100644 --- a/B06_Section/B06_Section_UI_Page_Station_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Station_Controls.ts @@ -7,8 +7,8 @@ import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; import type { InletStructureChoice, RevetKey } from "./B06_Section_UI_Cross_Culvert"; -import { ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types"; -import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types"; +import { DEFAULT_BASIN_ADJUST, ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types"; +import type { BasinAdjust, WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types"; import type { ExtraWallControl, InletStructureControl, @@ -18,7 +18,9 @@ import type { /** 제어가 페이지에서 가져다 쓰는 값들 — 클로저 대신 함수로 받아 결합을 끊는다. */ export interface StationControlDeps { - sessionKey: (kind: "crossw" | "revetx" | "inletstruct" | "extrawall") => string | null; + sessionKey: ( + kind: "crossw" | "revetx" | "inletstruct" | "basinadjust" | "extrawall", + ) => string | null; refreshCard: (chainageM: number) => void; detail: () => SectionDetailResponse | null; crossHalfWidth: () => number | undefined; @@ -32,6 +34,8 @@ export interface StationControls { inletStructure: InletStructureControl; extraWalls: ExtraWallControl; widths: Map; + inletStructures: Map; + basinAdjustments: Map; load: () => void; /** 전체 반영 — 개별 반폭을 전역값으로 덮는다(없으면 비운다). */ applyGlobalWidth: (requested: number | undefined, chainages: number[]) => void; @@ -201,7 +205,32 @@ export function createStationControls(deps: StationControlDeps): StationControls /* ── 유입측 구조물 형식(2026-08-22 사용자 — 드롭다운) ──────────────── * auto(규칙)/revet(기슭막이+배관)/I/L/U(집수정 형식). 세션에만 담는다. */ const inletStructures = new Map(); + const basinAdjustments = new Map(); const structSessionKey = (): string | null => deps.sessionKey("inletstruct"); + const basinSessionKey = (): string | null => deps.sessionKey("basinadjust"); + + function loadBasinAdjustments(): void { + basinAdjustments.clear(); + const key = basinSessionKey(); + if (!key) return; + try { + const parsed = JSON.parse(window.sessionStorage.getItem(key) ?? "{}") as Record< + string, + BasinAdjust + >; + Object.entries(parsed).forEach(([chainage, value]) => + basinAdjustments.set(chainage, { ...DEFAULT_BASIN_ADJUST, ...value }), + ); + } catch { + /* 손상된 세션 값은 기본값으로 대체. */ + } + } + + function persistBasinAdjustments(): void { + const key = basinSessionKey(); + if (key) + window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(basinAdjustments))); + } function loadInletStructures(): void { inletStructures.clear(); @@ -300,13 +329,38 @@ export function createStationControls(deps: StationControlDeps): StationControls }; const inletStructureControl: InletStructureControl = { - valueFor: (section) => inletStructures.get(section.chainage_m.toFixed(2)) ?? "auto", + valueFor: (section) => + inletStructures.get(section.chainage_m.toFixed(2)) ?? + section.design?.inlet_structure ?? + "auto", + adjustFor: (section) => + basinAdjustments.get(section.chainage_m.toFixed(2)) ?? { + ...DEFAULT_BASIN_ADJUST, + ...(section.design?.basin_adjust ?? {}), + }, set: (chainageM, value) => { - if (value === "auto") inletStructures.delete(chainageM.toFixed(2)); - else inletStructures.set(chainageM.toFixed(2), value); + inletStructures.set(chainageM.toFixed(2), value); persistInletStructures(); deps.refreshCard(chainageM); }, + updateAdjust: (chainageM, patch) => { + const key = chainageM.toFixed(2); + const current = basinAdjustments.get(key) ?? { ...DEFAULT_BASIN_ADJUST }; + const next = { ...current, ...patch }; + basinAdjustments.set(key, { + innerWidthM: Math.min(2, Math.max(1, round1(next.innerWidthM))), + innerHeightM: Math.min(2, Math.max(1, round1(next.innerHeightM))), + lateralM: Math.max(0, clampMove(next.lateralM)), + slopeM: Math.max(0, clampMove(next.slopeM)), + }); + persistBasinAdjustments(); + deps.refreshCard(chainageM); + }, + resetAdjust: (chainageM) => { + basinAdjustments.set(chainageM.toFixed(2), { ...DEFAULT_BASIN_ADJUST }); + persistBasinAdjustments(); + deps.refreshCard(chainageM); + }, }; return { @@ -315,10 +369,13 @@ export function createStationControls(deps: StationControlDeps): StationControls inletStructure: inletStructureControl, extraWalls: extraWallControl, widths: stationWidths, + inletStructures, + basinAdjustments, load: () => { loadStationWidths(); loadRevetShifts(); loadInletStructures(); + loadBasinAdjustments(); loadExtraCounts(); }, applyGlobalWidth: (requested, chainages) => { diff --git a/B06_Section/B06_Section_UI_Style_Cross_Controls.css b/B06_Section/B06_Section_UI_Style_Cross_Controls.css index 980bc6df..ac477bed 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Controls.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Controls.css @@ -107,6 +107,16 @@ gap: 2px; } +.b06-structure-panel__basin-buttons { + display: grid; + grid-template-columns: repeat(4, max-content); + gap: 2px; +} + +.b06-structure-panel__basin-reset { + margin-left: auto; +} + .b06-structure-panel__select { flex: 1; padding: 1px var(--spacing-4);