diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts index 1fbd9aea..8ddd04c7 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts @@ -86,6 +86,39 @@ export function revetHeightLimit(form: string | null | undefined): number { return 3.0; } +/* ── 기슭막이 재질·높이 조작(2026-08-22 사용자 확정) ───────────────────── + * 높이 조작이 교본 형태별 한계와 부딪히면 **재질을 바꿔야** 더 올릴 수 있다: + * 메쌓기 2.0 / 찰쌓기 3.0(돌쌓기.md §1) / 콘크리트 5.0(임시 — 사용자 지정 + * "일단 5m", 흙막이.md 산복기초 4.0과 다름·확정 시 교체). 높이 기준은 근입 + * 0.5m 위 기준선(관 invert 자리)~상단의 **계산용 높이**로 종전과 동일. */ +export type RevetMaterial = "dry" | "wet" | "concrete"; + +export const REVET_MATERIALS: RevetMaterial[] = ["dry", "wet", "concrete"]; + +export function materialLimit(material: RevetMaterial): number { + return material === "dry" ? 2.0 : material === "wet" ? 3.0 : 5.0; +} + +export function materialLabel(material: RevetMaterial): string { + return material === "dry" ? "메쌓기" : material === "wet" ? "찰쌓기" : "콘크리트"; +} + +/** 백엔드 spec 형식 문자열("돌쌓기(메)" 등) → 재질 기본값. 기본은 메쌓기. */ +export function materialFromForm(form: string | null | undefined): RevetMaterial { + if (form?.includes("찰")) return "wet"; + if (form?.includes("콘크리트")) return "concrete"; + return "dry"; +} + +/** 배관 기슭막이 높이 조작 하한(m) — 2~3m 구간 지시(2026-08-22)의 하한. */ +export const PIPE_WALL_MIN_HEIGHT_M = 2.0; +/** 배관 기슭막이 기본 높이(m, 2026-08-22 지시 2.5) — 재질 한계로 잘린다(메→2.0). */ +export const PIPE_WALL_DEFAULT_HEIGHT_M = 2.5; +/** 일반(추가) 기슭막이 높이 조작 하한(m). */ +export const EXTRA_WALL_MIN_HEIGHT_M = 0.5; +/** 일반(추가) 기슭막이 기본 높이(m). */ +export const EXTRA_WALL_DEFAULT_HEIGHT_M = 1.5; + /** * 성토 비탈 기울기(1:n) 허용 범위 — 지식DB `성토_비탈면.md` §1: * "**1:1.2~2.0** 범위에서 토질·용수 등 지형여건을 종합 고려해 설정". diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts index bcddb778..41569733 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts @@ -12,21 +12,24 @@ * ========================================================================== */ import { + EXTRA_WALL_DEFAULT_HEIGHT_M, + EXTRA_WALL_MIN_HEIGHT_M, FILL_SLOPE_MAX_LENGTH_M, FILL_SLOPE_RATIO_MIN, + materialLabel, + materialLimit, REVET_EMBED_DEPTH_M, REVET_LEAN_RATIO, REVET_THICKNESS_M, } from "./B06_Section_UI_Cross_Culvert_Const"; +import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const"; import type { OffsetPoint, OutletFillSegment, + WallAdjust, WallLayout, } from "./B06_Section_UI_Cross_Culvert_Types"; -/** 추가 벽 최소 높이(m, 임시) — 이보다 낮아지는 자리(바깥)로는 밀 수 없다. */ -export const EXTRA_MIN_HEIGHT_M = 0.5; - export interface OutletExtrasInput { /** 성토부선 시작점 = 관 유출 하단 꼭짓점(배관 벽의 성토선 시작 자리). */ start: OffsetPoint; @@ -34,22 +37,17 @@ export interface OutletExtrasInput { startBottomElevation: number; outward: number; groundAt: (offset: number) => number; - /** 표준 높이(관경+여유고) — 자동 자리는 이 높이가 딱 맞는 지점이다. */ - wallHeight: number; - /** 형태별 높이 한계(찰 3.0/메 2.0) — 안쪽 당김 한계. */ - heightLimit: number; - form: string | null; /** 계류측 샘플 한계 offset. */ limitOffset: number; - /** 사용자가 민 이동량(m, + = 계류측 바깥) — 요청한 단 수만큼. */ - shifts: number[]; + /** 단별 사용자 조작값(좌우 x·상하 d·높이 h·재질 m) — 요청한 단 수만큼. */ + adjusts: WallAdjust[]; } export interface OutletExtrasResult { walls: WallLayout[]; segments: OutletFillSegment[]; - /** 한계에 잘린 뒤의 실제 이동량 — 조정창이 되받는다. */ - appliedShifts: number[]; + /** 한계에 잘린 뒤의 실제 조작값 — 조정창이 되받는다. */ + appliedAdjusts: WallAdjust[]; /** 끝 성토부가 아직 5m 이상 — 단을 더 둘 수 있다(의무 구간). */ addable: boolean; } @@ -97,23 +95,20 @@ function buildExtraWall( anchor: OffsetPoint, outward: number, height: number, - form: string | null, - groundAt: (offset: number) => number, + material: RevetMaterial, + floatGapM: 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 = - Math.min(groundAt(backOffset), groundAt(frontBase), anchor.elevation) - REVET_EMBED_DEPTH_M; - const toeGround = Math.min(groundAt(frontXAt(bottomElevation)), anchor.elevation); - bottomElevation = Math.min(bottomElevation, toeGround - REVET_EMBED_DEPTH_M); + // 하단 = 기준선(anchor) 아래 근입 0.5m 고정 — 높이 기준 "근입 0.5 위~상단"과 일치. + const bottomElevation = anchor.elevation - REVET_EMBED_DEPTH_M; const bottomBack: OffsetPoint = { offset: backOffset, elevation: bottomElevation }; const bottomFront: OffsetPoint = { offset: frontXAt(bottomElevation), @@ -122,13 +117,14 @@ function buildExtraWall( return { role: "extra", extraIndex: index, - form, + form: materialLabel(material), lengthM: null, backOffset, outerOffset: bottomFront.offset, base: anchor.elevation, height, - floatGapM: 0, + floatGapM, + material, outward, topBack, topJoint: { offset: topJoint, elevation: topElevation }, @@ -139,34 +135,43 @@ function buildExtraWall( } /** - * 유출측 성토부선·다단 기슭막이 일괄 계산. + * 유출측 성토부선·다단 기슭막이 일괄 계산 (2026-08-22 4축 조작 체계). * - * 각 단의 벽 높이는 자리에서 역산한다 — 이음선 상단점이 src에서 내려오는 - * **1:1.2 사면선 위에 정확히** 놓이는 높이. 닫힌식(D = 벽 중심~src 수평거리): - * 이음선 자리 R = D − jointRun(h), 상단 = src.elev − R/1.2 = 지반(x) + h - * → h = (src.elev − 지반(x) − (D − 0.25t)/1.2) / (1 − 0.15/1.2) - * 자동 자리 = h가 표준 높이(관경+여유고)에 처음 닿는 **가장 안쪽 지점**. - * 이동 한계: 안쪽 = 윗단 하단 관통 금지(상단 ≤ 윗단 하단)·높이 한계(찰3/메2), - * 바깥쪽 = 최소 높이 0.5m. 요청 단 수보다 지형이 허락하는 단이 적으면 되는 - * 만큼만 세운다 — 조정창이 개수 차이를 보고 토스트로 가능한 단 수를 알린다. + * 단별 벽 높이 h = 사용자 설정 ?? 기본 1.5m (0.5~재질 한계로 절삭). 벽 상단은 + * 항상 src에서 내려오는 **1:1.2 성토선 위** — 자동 자리는 높이 h 벽이 지반에 + * 앉으며 상단이 선에 닿는 지점(heightAt(x)=h 교차점, 닫힌식): + * heightAt(x) = (src.elev − 지반(x) − (D − 0.25t)/1.2) / (1 − 0.15/1.2) + * 조작: 좌우 x = src 높이의 **수평 선반**을 끼워 평행이동(물매 불변, 상단 표고 + * 불변 — 윗단 기준 0.5m 수평 구현), 상하 d = 성토선을 타는 대각(수평 성분). + * 위(−d) 한계 = 윗단 하단 관통 금지(상단 ≤ 윗단 하단). 바닥은 상단−h−0.5로 + * 지반과 무관 — 못 닿으면 뜨고(floatGap 경고), 지나면 묻힌다. + * 요청 단 수보다 지형이 허락하는 단이 적으면 되는 만큼만 세운다. */ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult { - const { outward, groundAt, wallHeight, heightLimit, form, limitOffset } = input; + const { outward, groundAt, limitOffset } = input; const thickness = REVET_THICKNESS_M; const heightDenominator = 1 - REVET_LEAN_RATIO / 2 / FILL_SLOPE_RATIO_MIN; const walls: WallLayout[] = []; const segments: OutletFillSegment[] = []; - const appliedShifts: number[] = []; + const appliedAdjusts: WallAdjust[] = []; let src = input.start; let prevBottom = input.startBottomElevation; - for (let i = 0; i < input.shifts.length; i += 1) { + for (let i = 0; i < input.adjusts.length; i += 1) { // 시작점이 원지반 아래 = 윗단 벽이 0.5m 이상 묻힘 → 성토 불필요, 다단 종료. if (groundAt(src.offset) >= src.elevation - 0.01) break; const trailing = trailingFill(src, outward, groundAt); if (trailing.lengthM < 0.7) break; - /** 자리 x(벽 하단 중점)에서 1:1.2를 정확히 지키는 벽 높이(닫힌식). */ + const adjust = input.adjusts[i]; + const material = adjust.m ?? "dry"; + const limit = materialLimit(material); + const height = Math.min( + Math.max(adjust.h ?? EXTRA_WALL_DEFAULT_HEIGHT_M, EXTRA_WALL_MIN_HEIGHT_M), + limit, + ); + + /** 자리 x(벽 하단 중점)에 지반 안착 + 상단이 성토선에 닿는 데 필요한 높이. */ const heightAt = (x: number): number => { const run = (x - src.offset) * outward; return ( @@ -174,66 +179,75 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult heightDenominator ); }; - // 아랫단 상단이 윗단 하단을 넘지 않을 이음선 최소 수평거리(상단 ≤ 윗단 하단). - const minJointRun = FILL_SLOPE_RATIO_MIN * Math.max(src.elevation - prevBottom, 0); - const feasible = (x: number): boolean => { - const h = heightAt(x); - return ( - h >= EXTRA_MIN_HEIGHT_M - 1e-9 && - h <= heightLimit + 1e-9 && - (x - src.offset) * outward - jointRunOf(h) >= minJointRun - 1e-6 - ); - }; - // 자동 자리 = 표준 높이가 처음 성립하는 가장 안쪽 지점(0.05m 스캔). 표준까지 - // 못 크는 지형이면 조건을 만족하는 첫 자리로 폴백. + // 자동 자리 = heightAt이 사용자 높이 h에 처음 닿는 지점(0.05m 스캔 후 선형 보간). + // 지형이 완만해 h까지 못 크면 **가장 깊이 앉는 지점**(heightAt 최대)으로 폴백 — + // 바닥은 상단−h−0.5라 지반과 어긋나도 된다(뜨면 경고, 묻히면 그대로). const step = 0.05; const span = (limitOffset - src.offset) * outward; - let auto: number | null = null; - let fallback: number | null = null; + let autoOffset: number | null = null; + let bestOffset: number | null = null; + let bestHeight = 0.05; // 이 이하로만 앉는 지형이면 단을 세우지 않는다 + let previousShort = 0; // 직전 스캔점의 h 부족량(heightAt < h) — 보간용 for (let t = step; t <= Math.max(span, 0) + 1e-9; t += step) { const x = src.offset + outward * t; - if (!feasible(x)) continue; - if (fallback === null) fallback = x; - if (heightAt(x) >= wallHeight - 1e-9) { - auto = x; + const gap = heightAt(x) - height; + if (gap >= 0) { + // 교차점 선형 보간 — 격자 대신 정확 자리(사면선·지반 동시 접점). + const back = previousShort + gap > 1e-9 ? (gap / (previousShort + gap)) * step : 0; + autoOffset = x - outward * back; break; } + previousShort = -gap; + if (heightAt(x) > bestHeight) { + bestHeight = heightAt(x); + bestOffset = x; + } } - const autoOffset = auto ?? fallback; - if (autoOffset === null) break; // 이 단은 세울 자리가 없다 — 되는 만큼만. - // 사용자 이동 반영 후, 안 되는 자리면 되는 쪽으로 0.05m씩 되돌린다 - // (안쪽 위반 → 바깥으로, 바깥 위반(최소 높이 미달) → 안쪽으로). - let shifted = autoOffset + outward * input.shifts[i]; - for (let pass = 0; pass < 400 && !feasible(shifted); pass += 1) { - const h = heightAt(shifted); - const tooInner = - h > heightLimit || - (shifted - src.offset) * outward - jointRunOf(Math.min(h, heightLimit)) < minJointRun; - shifted += outward * (tooInner ? step : -step); - } - if (!feasible(shifted)) break; - appliedShifts.push(Math.round((shifted - autoOffset) * outward * 10) / 10); + if (autoOffset === null) autoOffset = bestOffset; + if (autoOffset === null) break; // 세울 만한 지형이 아니다 — 되는 만큼만. + // 관통 금지: 상단 ≤ 윗단 하단 → 성토선 수평거리(경사부) ≥ 1.2×(시작−윗단하단). + const autoJointRun = (autoOffset - src.offset) * outward - jointRunOf(height); + const minJointRun = FILL_SLOPE_RATIO_MIN * Math.max(src.elevation - prevBottom, 0); + const appliedD = Math.max(adjust.d, Math.ceil((minJointRun - autoJointRun) * 10) / 10); + const appliedX = Math.max(adjust.x, 0); + appliedAdjusts.push({ + x: appliedX, + d: appliedD, + h: adjust.h != null ? height : null, + m: adjust.m, + }); - const height = heightAt(shifted); - const anchor: OffsetPoint = { offset: shifted, elevation: groundAt(shifted) }; - const wall = buildExtraWall(i, anchor, outward, height, form, groundAt); + const slopeRun = autoJointRun + appliedD; + const topElevation = src.elevation - slopeRun / FILL_SLOPE_RATIO_MIN; + const anchorX = autoOffset + outward * (appliedX + appliedD); + const base = topElevation - height; // 근입 0.5 위 기준선 + const wall = buildExtraWall( + i, + { offset: anchorX, elevation: base }, + outward, + height, + material, + Math.max(0, base - groundAt(anchorX)), + ); walls.push(wall); - // src → 이음선 상단점 — 정확히 1:1.2(높이 역산으로 보장). - const run = Math.abs(wall.topJoint.offset - src.offset); - const rise = src.elevation - wall.topJoint.elevation; + // 성토선: src → (수평 선반 x>0이면 선반 끝) → 이음선 상단점. 사면길이 = 경사부. + const shelf: OffsetPoint | null = + appliedX > 1e-9 + ? { offset: src.offset + outward * appliedX, elevation: src.elevation } + : null; + const slopeFrom = shelf ?? src; + const run = Math.abs(wall.topJoint.offset - slopeFrom.offset); + const rise = slopeFrom.elevation - wall.topJoint.elevation; segments.push({ - points: [src, wall.topJoint], + points: shelf ? [src, shelf, wall.topJoint] : [src, wall.topJoint], lengthM: Math.hypot(run, rise), ratio: rise > 1e-9 ? run / rise : FILL_SLOPE_RATIO_MIN, overLimit: Math.hypot(run, rise) >= FILL_SLOPE_MAX_LENGTH_M - 1e-6, }); - // 다음 단 시작점 = 이 벽 하단 +0.5m 수평선과 전면 경사선(1:0.3)의 교차점. - const startElevation = wall.bottomBack.elevation + REVET_EMBED_DEPTH_M; + // 다음 단 시작점 = 이 벽 하단 +0.5m(기준선) 수평선과 전면 경사선(1:0.3)의 교차점. src = { - offset: - wall.points[2].offset + - outward * REVET_LEAN_RATIO * (wall.topJoint.elevation - startElevation), - elevation: startElevation, + offset: wall.points[2].offset + outward * REVET_LEAN_RATIO * height, + elevation: base, }; prevBottom = wall.bottomBack.elevation; } @@ -245,5 +259,5 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult segments.push(tail); addable = tail.overLimit; } - return { walls, segments, appliedShifts, addable }; + return { walls, segments, appliedAdjusts, addable }; } diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts index 73ab3660..f89ee77e 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts @@ -13,6 +13,11 @@ import { BASIN_INNER_HEIGHT_M, BASIN_MAX_FILL_SLOPE_M, FILL_MIN_RISE_M, + materialFromForm, + materialLabel, + materialLimit, + PIPE_WALL_DEFAULT_HEIGHT_M, + PIPE_WALL_MIN_HEIGHT_M, FILL_SLOPE_MAX_LENGTH_M, FILL_SLOPE_RATIO_MAX, FILL_SLOPE_RATIO_MIN, @@ -23,6 +28,8 @@ import { revetHeightLimit, revetTargetHeight, } from "./B06_Section_UI_Cross_Culvert_Const"; +import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const"; +import { ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types"; import type { BasinLayout, BasinShape, @@ -30,6 +37,7 @@ import type { EndFace, OffsetPoint, PipeEnd, + WallAdjust, WallLayout, } from "./B06_Section_UI_Cross_Culvert_Types"; @@ -47,7 +55,6 @@ import { buildOutletExtras } from "./B06_Section_UI_Cross_Culvert_Extra"; import type { FillSlopeSegment, WallVertical } from "./B06_Section_UI_Cross_Culvert_Solve"; import { clampToFace, - clampWallOffset, cutLength, minShoulderWallOffset, outletSlopeFactory, @@ -57,7 +64,6 @@ import { intersect, slopeToeOffset, slopeLengthAlong, - solveWallVertical, STRAY_LIMIT_M, } from "./B06_Section_UI_Cross_Culvert_Solve"; @@ -68,8 +74,8 @@ export type InletStructureChoice = "auto" | "revet" | "I" | "L" | "U"; export function computeCulvertLayout( section: CrossSection, groundSamples: SectionSample[], - /** 사용자가 손으로 민 기슭막이 X 이동량(m, + = 계류측 바깥). 없으면 자동 자리. */ - revetShift?: { inlet?: number; outlet?: number; extras?: number[] }, + /** 사용자 조작값(좌우 x·상하 d·높이 h·재질 m — 2026-08-22 4축). 없으면 자동. */ + revetShift?: { inlet?: WallAdjust; outlet?: WallAdjust; extras?: WallAdjust[] }, /** 유입측 구조물 형식 선택(드롭다운) — 없으면 auto(규칙). */ inletStructure?: InletStructureChoice, ): CulvertLayout | null { @@ -148,10 +154,24 @@ export function computeCulvertLayout( inlet.elevation = Math.min(groundAt(startOffset), invertCap(inletInfo.edge)); } - // 성토측 유입 기슭막이는 노견에 붙여 두지 않는다 — 사면선이 벽 이음선 상단점을 지나야 - // 하므로 자리를 풀어서 정한다(집수정은 측구부 자리 그대로). - const wallHeightFor = (spec: CulvertSideSpec): number => - Math.min(revetTargetHeight(diameter), revetHeightLimit(spec.revet_form)); + // 벽 제원(2026-08-22 4축 조작): 재질(기본 메쌓기)이 높이 한계(메2/찰3/콘5)를 + // 정하고, 높이 = 사용자 설정 ?? 기본 2.5(한계로 절삭). 하한은 2.0과 관경+여유고 + // 중 큰 값 — 관이 벽 위로 삐져나오면 안 된다. 자동 자리 계산도 이 높이 기준. + const adjustOf = (value?: WallAdjust): WallAdjust => ({ ...ZERO_ADJUST, ...(value ?? {}) }); + const adjInlet = adjustOf(revetShift?.inlet); + const adjOutlet = adjustOf(revetShift?.outlet); + const pipeWallSpec = (spec: CulvertSideSpec, adjust: WallAdjust) => { + const material = adjust.m ?? materialFromForm(spec.revet_form); + const limit = materialLimit(material); + const floor = Math.max(PIPE_WALL_MIN_HEIGHT_M, revetTargetHeight(diameter)); + const height = Math.min( + Math.max(adjust.h ?? Math.min(PIPE_WALL_DEFAULT_HEIGHT_M, limit), floor), + Math.max(limit, floor), + ); + return { material, limit, height }; + }; + const inletWallSpec = pipeWallSpec(culvert.inlet, adjInlet); + const outletWallSpec = pipeWallSpec(culvert.outlet, adjOutlet); // 조정창 드롭다운 선택지 가용성 — 판정은 분리 파일(Basin)이 맡는다(700줄 제한). const inletOptions = inletChoiceAvailability({ designAt, @@ -159,13 +179,16 @@ export function computeCulvertLayout( edge: inletInfo.edge, outward: inletInfo.outward, limitOffset: inletInfo.limit, - wallHeight: wallHeightFor(culvert.inlet), + wallHeight: inletWallSpec.height, invertCapM: invertCap(inletInfo.edge), diameterM: diameter, cutSlopeRatio: section.design?.cut_slope_ratio ?? 1.0, }); - /** 요청값이 한계에 걸려 잘린 뒤의 **실제** 이동량. 조정창이 이 값을 되받는다. */ - const appliedShift = { inlet: 0, outlet: 0 }; + /** 요청값이 한계에 걸려 잘린 뒤의 **실제** 조작값. 조정창이 이 값을 되받는다. */ + const appliedAdjust = { + inlet: { ...adjInlet, h: adjInlet.h != null ? inletWallSpec.height : null }, + outlet: { ...adjOutlet, h: adjOutlet.h != null ? outletWallSpec.height : null }, + }; /** 손으로 민 벽의 수직 제원(높이 증가·바닥 띄움) — buildWall에 물려 준다. */ const wallVertical: { inlet: WallVertical | null; outlet: WallVertical | null } = { inlet: null, @@ -180,30 +203,30 @@ export function computeCulvertLayout( Math.min(groundAt(offset), invertCap(inletInfo.edge)); // 자동 자리 = 가용성 판정과 같은 스캔 결과(노견 최소 지점 ?? 사면 끝) 재사용. inletAutoOffset = inletOptions.revetAutoOffset; - // 안쪽 당김 한계 = **노견**(도로 밑 침범 금지). 1:1.2 자리 안쪽으로 당기면 - // ① 높이 증가 ② 한계 도달 시 바닥 띄움으로 노견·물매를 지킨다 - // (2026-08-22 사용자 재확정 — 13+15.7 확인 요청). - const shifted = clampWallOffset( - inletAutoOffset + inletInfo.outward * (revetShift?.inlet ?? 0), - inletInfo.edge.offset_m, - inletInfo.outward, + // 4축 조작(2026-08-22 확정): 좌우 x = 노견 연장 **평행이동**(상단 표고·물매 + // 1:1.2 불변, 안쪽 x<0 금지 — 자동 자리가 이미 노견 최소), 상하 d = 성토선을 + // 타는 대각 이동(수평 성분, +아래). 위(−d) 한계 = 최소 성토고·토피 상한. + // 바닥이 지반에 못 닿으면 떠도 무관(별도 지지 구조물 예정). + const h = inletWallSpec.height; + const top0 = inletBaseAt(inletAutoOffset) + h; + const dLow = Math.max( + -FILL_SLOPE_RATIO_MIN * (inletInfo.edge.elevation_m - FILL_MIN_RISE_M - top0), + -FILL_SLOPE_RATIO_MIN * (invertCap(inletInfo.edge) - (top0 - h)), ); - appliedShift.inlet = Math.round((shifted - inletAutoOffset) * inletInfo.outward * 10) / 10; - // 안쪽으로 당겨 노견이 부족하면 ① 높이 증가 ② 바닥 띄움(2026-08-22 사용자 확정). - const vertical = solveWallVertical( - shifted, - inletInfo.edge, - inletInfo.outward, - inletBaseAt(shifted), - wallHeightFor(culvert.inlet), - revetHeightLimit(culvert.inlet.revet_form), - ); - // 뜬 바닥(= 관 invert)이 노면−관경−토피 상한을 넘으면 토피가 부족하다 — 상한으로 자른다. - vertical.baseElevation = Math.min(vertical.baseElevation, invertCap(inletInfo.edge)); - vertical.floatGapM = Math.max(0, vertical.baseElevation - inletBaseAt(shifted)); - wallVertical.inlet = vertical; - inlet.offset = shifted; - inlet.elevation = vertical.baseElevation; + const appliedD = Math.max(adjInlet.d, Math.ceil(dLow * 10) / 10); + const appliedX = Math.max(adjInlet.x, 0); + appliedAdjust.inlet.x = appliedX; + appliedAdjust.inlet.d = appliedD; + const topElev = top0 - appliedD / FILL_SLOPE_RATIO_MIN; + const anchorX = inletAutoOffset + inletInfo.outward * (appliedX + appliedD); + const invert = topElev - h; + wallVertical.inlet = { + height: h, + baseElevation: invert, + floatGapM: Math.max(0, invert - inletBaseAt(anchorX)), + }; + inlet.offset = anchorX; + inlet.elevation = invert; } // 유출 목표점 = 유출측 성토사면이 지반과 만나는 사면 끝(경사길이 5m 한계 — 별표2). @@ -262,6 +285,7 @@ export function computeCulvertLayout( forceBasinReason: BasinLayout["reason"] | null, thickness: number = REVET_THICKNESS_M, vertical: WallVertical | null = null, + material: RevetMaterial = "dry", ): WallLayout | null => { const reason: BasinLayout["reason"] | null = // 백엔드 spec의 "집수정"은 판정(ruleReason)에 이미 반영됐다 — 사용자 선택 @@ -293,12 +317,8 @@ export function computeCulvertLayout( return null; } // 형상(사용자 스케치 확정 — 좌측 벽 기준, 우측은 반전): 배면(도로측) 수직, 전면 - // (계류측)은 1:0.3 기운 평행사변형 띠. 띠 안쪽 평행선과 수직 배면 사이가 사다리꼴로, - // 바닥이 넓고 상단이 좁다(상단 폭 = 띠 두께 0.45). - // 높이: 기본은 **관경 + 여유고**를 0.1m 눈금으로 올린 값(2026-08-21 사용자 확정). - // 손으로 안쪽에 당겨 노견이 부족하면 수직 제원(vertical)이 높이를 키우거나 바닥을 - // 띄워 들어온다(2026-08-22 사용자 확정 — 최소 노견 유지). - // 형태별 법정·교본 높이 한계(찰 3.0 / 메 2.0 — 돌쌓기.md §1)를 넘지 못한다. + // (계류측)은 1:0.3 기운 평행사변형 띠. 높이는 4축 조작 제원(vertical)이 들고 + // 들어온다 — 계산용 높이(근입 0.5 위 기준선~상단), 재질 한계는 pipeWallSpec 절삭. const height = vertical?.height ?? Math.min(revetTargetHeight(diameter), revetHeightLimit(spec.revet_form)); if (!(height > 0.05)) return null; @@ -308,30 +328,18 @@ export function computeCulvertLayout( // 그 자리 원지반(= 관 invert)이다. 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 }; // 상단 변: 사다리꼴 상단(t/2) + 띠(t). 이음선은 상단 변 중간점에서 1:0.3으로 바닥까지. const topFront = topJoint + outward * thickness; - // 하단선: **수평**(2026-08-22 사용자 확정 — 기초공사는 수평 기초라 지반을 따라 - // 기울지 않는다). 깊이는 벽 발자국 안 기준선(원지반 또는 관 invert)의 **최저점** - // 아래 0.5m — 기슭막이는 관을 감싸는 구조물이라 관 하단(invert) 아래 0.5m는 어떤 - // 경우에도 확보한다. 바닥이 원지반에서 뜬 벽(floatGapM>0)은 관 invert 기준 0.5만 - // 내려간다(뜬 공간은 별도 지지 구조물 — 추가 예정). - // 전면(계류측) 경사선(1:0.3)은 그대로 하단까지 **연장**한다 — 바닥이 깊어진 만큼 - // 전면 발끝이 바깥으로 나간다. 발끝 지반이 더 낮으면 한 번 더 내린다(2-pass). + // 하단선: **수평 기초**, 깊이 = 기준선(관 invert) 아래 근입 0.5m 고정 — + // 높이 기준을 "근입 0.5 위 기준선~상단"으로 확정하며 발자국 최저 지반 추적을 + // 대체했다(2026-08-22 사용자). 지반이 더 낮으면 바닥이 뜨고(floatGapM 경고), + // 더 높으면 그만큼 더 묻힌다. 전면 경사선(1:0.3)은 하단까지 연장. 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 bottomElevation = anchor.elevation - REVET_EMBED_DEPTH_M; const bottomBack: OffsetPoint = { offset: backOffset, elevation: bottomElevation }; const bottomFront: OffsetPoint = { offset: frontXAt(bottomElevation), @@ -339,7 +347,7 @@ export function computeCulvertLayout( }; const wall: WallLayout = { role: spec.role, - form: spec.revet_form ?? null, + form: materialLabel(material), lengthM: spec.revet_length_m ?? null, backOffset, outerOffset: bottomFront.offset, @@ -348,6 +356,7 @@ export function computeCulvertLayout( // 확정 — 기초는 시공 시 묻히는 부분이라 높이·한계 검사 모두 이 기준). height, floatGapM, + material, outward, topBack, topJoint: { offset: topJoint, elevation: topElevation }, @@ -381,6 +390,7 @@ export function computeCulvertLayout( basinReason, REVET_THICKNESS_M, wallVertical.inlet, + inletWallSpec.material, ); // 유출 벽 자리 — 유입과 같은 규칙(사면선이 벽 이음선 상단점을 지나는 자리, 물매는 // 1:1.2~2.0 범위에서 역산). @@ -396,7 +406,7 @@ export function computeCulvertLayout( ? minShoulderWallOffset( outletInfo.edge, outletInfo.outward, - wallHeightFor(culvert.outlet), + outletWallSpec.height, invertAt, outletAnchorOffset, outletInfo.limit, @@ -404,26 +414,26 @@ export function computeCulvertLayout( : null; const outletAutoOffset = outletFeasible ?? outletAnchorOffset; if (designAt) { - // 안쪽 당김 한계 = 노견(유입과 동일 — 안쪽은 높이 증가·바닥 띄움으로 잇는다). - const shifted = clampWallOffset( - outletAutoOffset + outletInfo.outward * (revetShift?.outlet ?? 0), - outletInfo.edge.offset_m, - outletInfo.outward, + // 4축 조작(유입과 동일). 위(−d) 한계 = 최소 성토고 + 역경사 방지(invert ≤ 유입). + const h = outletWallSpec.height; + const top0 = invertAt(outletAutoOffset) + h; + const dLow = Math.max( + -FILL_SLOPE_RATIO_MIN * (outletInfo.edge.elevation_m - FILL_MIN_RISE_M - top0), + -FILL_SLOPE_RATIO_MIN * (inlet.elevation - (top0 - h)), ); - appliedShift.outlet = Math.round((shifted - outletAutoOffset) * outletInfo.outward * 10) / 10; - const vertical = solveWallVertical( - shifted, - outletInfo.edge, - outletInfo.outward, - invertAt(shifted), - wallHeightFor(culvert.outlet), - revetHeightLimit(culvert.outlet.revet_form), - ); - // 뜬 바닥(= 유출 invert)이 유입 invert보다 높으면 역경사다 — 유입 높이로 자른다. - vertical.baseElevation = Math.min(vertical.baseElevation, inlet.elevation); - vertical.floatGapM = Math.max(0, vertical.baseElevation - invertAt(shifted)); - wallVertical.outlet = vertical; - outletWallAnchor = { offset: shifted, elevation: vertical.baseElevation }; + const appliedD = Math.max(adjOutlet.d, Math.ceil(dLow * 10) / 10); + const appliedX = Math.max(adjOutlet.x, 0); + appliedAdjust.outlet.x = appliedX; + appliedAdjust.outlet.d = appliedD; + const topElev = top0 - appliedD / FILL_SLOPE_RATIO_MIN; + const anchorX = outletAutoOffset + outletInfo.outward * (appliedX + appliedD); + const invert = topElev - h; + wallVertical.outlet = { + height: h, + baseElevation: invert, + floatGapM: Math.max(0, invert - invertAt(anchorX)), + }; + outletWallAnchor = { offset: anchorX, elevation: invert }; } let outletWall = buildWall( culvert.outlet, @@ -432,6 +442,7 @@ export function computeCulvertLayout( null, REVET_THICKNESS_M, wallVertical.outlet, + outletWallSpec.material, ); // ── 관 축 확정. 관 하단선은 **관 시작점(집수정 내공 벽 또는 유입 벽 자리)과 // 유출 벽 하단선 중점(invert)을 잇는 직선**이다 — 벽이 어디로 가든 관은 그 벽 @@ -502,13 +513,13 @@ export function computeCulvertLayout( // 길이가 다르다** — 긴 변을 기준으로 올림해 정수 m로 잡고, 모자란 만큼 **유출 벽 자리** // 를 관 축 방향 바깥으로 민다(벽 두께는 0.45 고정 — 사용자 확정). 벽 상단이 사면선과 // 벌어지는 문제는 설계선 트림이 벽 상단을 따라오므로 생기지 않는다(사용자 ②). - const outletSlopeAt = outletSlopeFactory(outletInfo, invertAt, wallHeightFor(culvert.outlet)); + const outletSlopeAt = outletSlopeFactory(outletInfo, invertAt, outletWallSpec.height); /** 유출 벽 두께 — 항상 0.45 고정(2026-08-21 사용자 확정). 폭으로 길이를 맞추지 않는다. */ const outletThickness = REVET_THICKNESS_M; // 손으로 민 벽은 정수 맞춤이 **절대 옮기지 않는다**(2026-08-22 사용자 확정 — 종전에는 // 한쪽 벽을 밀면 이 루프가 반대쪽 벽을 따라 옮겨 좌우가 동시에 움직였다). 관 그림은 // 두 벽 전면 사이 실길이로 두고 표기 길이만 올림(m)한다. - const outletPinned = Math.abs(revetShift?.outlet ?? 0) > 1e-9; + 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); const rebuild = (): void => { @@ -519,6 +530,12 @@ export function computeCulvertLayout( outletInfo.outward, null, outletThickness, + { + height: outletWallSpec.height, + baseElevation: outletWallAnchor.elevation, + floatGapM: Math.max(0, outletWallAnchor.elevation - invertAt(outletWallAnchor.offset)), + }, + outletWallSpec.material, ); // 관 하단선은 옮겨진 벽의 **하단선 중점을 지나야** 한다 — 옛 축을 그대로 늘리면 // 가파른 지반에서 관이 벽 바닥 아래로 삐져나간다(2026-08-22 13+15.7 사용자 지적). @@ -542,8 +559,7 @@ export function computeCulvertLayout( // 사면 역전·노견 안쪽으로는 옮기지 않는다 — 길이 맞춤보다 도면 성립이 먼저다. if ( (moved - outletInfo.edge.offset_m) * outletInfo.outward < 0 || - outletInfo.edge.elevation_m - (invertAt(moved) + wallHeightFor(culvert.outlet)) < - FILL_MIN_RISE_M + outletInfo.edge.elevation_m - (invertAt(moved) + outletWallSpec.height) < FILL_MIN_RISE_M ) { break; } @@ -590,13 +606,10 @@ export function computeCulvertLayout( startBottomElevation: outletWall.bottomBack.elevation, outward: outletInfo.outward, groundAt, - wallHeight: wallHeightFor(culvert.outlet), - heightLimit: revetHeightLimit(culvert.outlet.revet_form), - form: culvert.outlet.revet_form ?? null, limitOffset: outletInfo.limit, - shifts: revetShift?.extras ?? [], + adjusts: (revetShift?.extras ?? []).map(adjustOf), }) - : { walls: [], segments: [], appliedShifts: [], addable: false }; + : { walls: [], segments: [], appliedAdjusts: [], addable: false }; // ── 성토 사면 구간 확정. 벽 자리가 굳은 뒤에 물매를 역산해야 관 길이 맞춤(벽 이동)이 // 접점을 다시 깨뜨리지 않는다 — 종전 어긋남의 직접 원인이 이 순서였다. @@ -643,7 +656,11 @@ export function computeCulvertLayout( outletFill: { segments: extras.segments, addable: extras.addable }, extraWalls: extras.walls, basin, - revetShift: { ...appliedShift, extras: extras.appliedShifts }, + revetShift: { + inlet: appliedAdjust.inlet, + outlet: appliedAdjust.outlet, + extras: extras.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 9ebcc0a1..43d6883c 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts @@ -5,6 +5,26 @@ * ========================================================================== */ import type { CulvertSet } from "./B06_Section_Api_Fetch"; +import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const"; + +/** + * 기슭막이 1매의 사용자 조작값(2026-08-22 확정 4축): + * x = 좌우(m, + = 계류측 바깥) — 노견(또는 윗단 시작점 수평선)을 늘려 평행이동, + * 성토선 물매 1:1.2 불변. + * d = 상하(m, 수평 성분, + = 사면 아래) — 성토선을 타고 대각 이동. + * h = 높이(m, 계산용: 근입 0.5 위 기준선~상단). null = 기본값 + * (배관 2.5·일반 1.5, 재질 한계로 잘림). + * m = 재질. null = 기본값(메쌓기). 한계: 메 2.0 / 찰 3.0 / 콘크리트 5.0. + */ +export interface WallAdjust { + x: number; + d: number; + h: number | null; + m: RevetMaterial | null; +} + +/** 조작값 기본(전부 자동). */ +export const ZERO_ADJUST: WallAdjust = { x: 0, d: 0, h: null, m: null }; export interface OffsetPoint { offset: number; @@ -46,6 +66,8 @@ export interface WallLayout { * 별도 지지 구조물로 메운다(추가 예정). 0이면 지반 위(근입 0.5m). */ floatGapM: number; + /** 재질(메/찰/콘크리트) — 높이 한계를 정한다(2026-08-22 사용자). */ + material: RevetMaterial; outward: number; /** 합성 단면(하부 사다리꼴 + 상부 평행사변형) 꼭짓점 — 데이터 좌표. */ points: OffsetPoint[]; @@ -145,7 +167,7 @@ export interface CulvertLayout { * 한계에 걸리면 잘려 들어온다 — 조정창은 이 값을 보여 주고 저장한다. 그러지 않으면 * 눌러도 안 움직이는데 숫자만 계속 커진다(2026-08-21 사용자 지적). */ - revetShift: { inlet: number; outlet: number; extras: number[] }; + revetShift: { inlet: WallAdjust; outlet: WallAdjust; extras: 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 f94502a1..231f62ed 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts @@ -6,12 +6,40 @@ 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 type { CulvertLayout, InletStructureChoice, RevetKey } from "./B06_Section_UI_Cross_Culvert"; +import { materialLabel, materialLimit } from "./B06_Section_UI_Cross_Culvert_Const"; +import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types"; +/** + * 기슭막이 4축 조작 제어(2026-08-22 확정) — 벽을 눌러 고르고 조정창에서 + * 좌우(x)·상하(d, 성토선 대각)·높이(h)·재질(m)을 만진다. 고른 벽은 여기 담아 두어 + * 카드가 다시 그려져도 되살아난다. `select`는 값만 담고 다시 그리지 않는다. + */ +export interface RevetOffsetControl { + adjustFor: (section: CrossSection, role: RevetKey) => WallAdjust; + selectedFor: (section: CrossSection) => RevetKey | null; + select: (chainageM: number, key: RevetKey | null) => void; + /** 기하가 실제로 적용한 조작값을 되받아 담는다(한계에 걸린 요청값을 잘라 낸다). */ + syncApplied: (chainageM: number, role: RevetKey, applied: WallAdjust) => void; + /** 일부 축만 바꾼다 — 나머지는 유지. h: null = 기본값 복귀. */ + update: (chainageM: number, role: RevetKey, patch: Partial) => void; + reset: (chainageM: number, role: RevetKey) => void; +} + +/** 유입측 구조물 형식 선택(드롭다운 — 2026-08-22 사용자). 세션 보관은 Page가 한다. */ +export interface InletStructureControl { + valueFor: (section: CrossSection) => InletStructureChoice; + set: (chainageM: number, value: InletStructureChoice) => void; +} + +/** 유출측 다단 기슭막이 단 수 제어(2026-08-22 사용자 — 유출 벽 기준 숫자 입력). */ +export interface ExtraWallControl { + countFor: (section: CrossSection) => number; + /** 단 수 지정 — 줄이면 사라지는 단의 이동량도 함께 지운다. */ + setCount: (chainageM: number, count: number) => void; + /** 기하가 실제로 세운 단 수로 잘라 동기화(지형상 못 세운 단 정리). */ + syncCount: (chainageM: number, built: number) => void; +} + import { showToast } from "@ui/ui_template_elements"; import { L } from "./B06_Section_UI_Section_Common"; @@ -39,10 +67,10 @@ export function computeCardCulvert( sourceSamples, revetOffset ? { - inlet: revetOffset.shiftFor(section, "inlet"), - outlet: revetOffset.shiftFor(section, "outlet"), + inlet: revetOffset.adjustFor(section, "inlet"), + outlet: revetOffset.adjustFor(section, "outlet"), extras: Array.from({ length: extraWalls?.countFor(section) ?? 0 }, (_, i) => - revetOffset.shiftFor(section, `extra${i}`), + revetOffset.adjustFor(section, `extra${i}`), ), } : undefined, @@ -61,21 +89,54 @@ export function computeCardCulvert( extraWalls.syncCount(section.chainage_m, layout.extraWalls.length); } if (revetOffset) { - const roles: Array<[RevetKey, number]> = [ + const roles: Array<[RevetKey, WallAdjust]> = [ ["inlet", layout.revetShift.inlet], ["outlet", layout.revetShift.outlet], - ...layout.revetShift.extras.map((applied, i): [RevetKey, number] => [`extra${i}`, applied]), + ...layout.revetShift.extras.map((applied, i): [RevetKey, WallAdjust] => [ + `extra${i}`, + applied, + ]), ]; + const wallOf = (role: RevetKey) => + 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.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", - ); + const requested = revetOffset.adjustFor(section, role); + // 축별로 요청이 잘렸으면 왜 안 됐는지 토스트로 알린다(조작 중인 벽만). + if (activeRevet === role) { + if (Math.abs(requested.x - applied.x) > 0.05) { + showToast( + L( + requested.x < applied.x + ? "B06_Cross_Revet_Limit_Inward" + : "B06_Cross_Revet_Limit_Outward", + ), + "info", + ); + } + if (Math.abs(requested.d - applied.d) > 0.05) { + showToast( + L(requested.d < applied.d ? "B06_Cross_Revet_Limit_Up" : "B06_Cross_Revet_Limit_Down"), + "info", + ); + } + // 높이가 재질 한계에 잘렸으면 재질 변경을 안내(2026-08-22 사용자 — 경고와 + // 높이 해제는 재질 선택으로). + const wall = wallOf(role); + if (requested.h != null && applied.h != null && requested.h > applied.h + 0.05 && wall) { + showToast( + L("B06_Cross_Height_Limit") + .replace("{mat}", materialLabel(wall.material)) + .replace("{limit}", materialLimit(wall.material).toFixed(1)), + "info", + ); + } + if (requested.h != null && applied.h != null && requested.h < applied.h - 0.05) { + showToast(L("B06_Cross_Height_Floor"), "info"); + } } - revetOffset.syncShift(section.chainage_m, role, applied); + revetOffset.syncApplied(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 b4f85fcf..410dfd28 100644 --- a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts @@ -1,25 +1,36 @@ /* ============================================================================= * B06_Section_UI_Cross_Structure_Panel.ts - * 횡단도 안에 뜨는 **구조물 위치 조정 오버레이 창**(2026-08-21 사용자 확정). + * 횡단도 안에 뜨는 **구조물 조정 오버레이 창**(2026-08-21 사용자 확정). * * 조정 조작구는 카드 하단이 아니라 **해당 횡단도 안에** 있어야 한다 — 어느 도면의 - * 구조물을 만지는지 눈으로 붙어 있어야 하고, 카드 하단은 표시 반폭 조작구와 섞여 - * 구분이 안 됐다. 앞으로 구조물 위치 조정은 모두 이 창을 통한다. + * 구조물을 만지는지 눈으로 붙어 있어야 한다. 기슭막이는 4축(2026-08-22 확정): + * 좌우 ◀▶(노견/시작점 수평 연장, 물매 1:1.2 불변), 상하 ▲▼(성토선을 타는 대각), + * 높이 ±0.1m(재질 한계), 재질(메2.0/찰3.0/콘크리트5.0). 다단은 단 수 숫자++/-. * - * 창은 그래프 영역(`.b06-cross-card__chart-wrap`) 안에 절대 위치로 얹힌다. 줌 버튼은 - * 우측 상단, 면적표는 중상단이라 이 창은 **좌측 하단**에 둔다. + * 창은 그래프 영역(`.b06-cross-card__chart-wrap`) 안 **좌측 하단**에 얹힌다. * ========================================================================== */ 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 { L } from "./B06_Section_UI_Section_Common"; /** 조정창이 쓰는 값·동작. 카드가 자기 상태에 맞춰 물려 준다. */ export interface StructurePanelDeps { - /** 지금 이동량(m, + = 계류측 바깥). 창을 열거나 값이 바뀔 때마다 읽는다. */ - shiftFor: (key: RevetKey) => number; - /** 화면 좌(+)/우(−) 방향으로 미는 양(m). 부호 환산은 카드가 한다. */ + /** 지금 조작값(좌우 x·상하 d·높이 h·재질 m). 창을 열거나 값이 바뀔 때마다 읽는다. */ + adjustFor: (key: RevetKey) => WallAdjust; + /** 좌우: 화면 좌(+)/우(−)로 미는 양(m). 부호 환산은 카드가 한다. */ nudge: (key: RevetKey, screenDeltaM: number) => void; - /** 자동 자리로 되돌린다. */ + /** 상하: 성토선을 타는 대각 이동(수평 성분 m, + = 사면 아래). */ + nudgeSlope: (key: RevetKey, deltaM: number) => void; + /** 높이 ±0.1m. 재질 한계 절삭·경고는 기하·카드가 한다. */ + nudgeHeight: (key: RevetKey, deltaM: number) => void; + /** 실제 그려진 벽 높이(m) — 표시·높이 조작의 기준값. */ + heightFor: (key: RevetKey) => number; + materialFor: (key: RevetKey) => RevetMaterial; + setMaterial: (key: RevetKey, material: RevetMaterial) => void; + /** 자동 자리로 되돌린다(4축 모두). */ reset: (key: RevetKey) => void; /** 지금 관 길이(m) — 조정 단위가 관 길이 1m이라 창에 같이 적는다. */ pipeLengthM: () => number | null; @@ -29,14 +40,13 @@ export interface StructurePanelDeps { structureFor: () => InletStructureChoice; /** 유입측 구조물 형식 변경 — 카드를 다시 그린다. */ setStructure: (value: InletStructureChoice) => void; - /** ◀/▶ 이동 가능 여부 — 집수정(자리 고정)은 숨긴다. */ + /** 이동 조작 가능 여부 — 집수정(자리 고정)은 숨긴다. */ canNudge: (key: RevetKey) => boolean; /** 상황에 안 맞는 선택지 숨김(2026-08-22 사용자) — 기하가 판정한 가용성. */ optionsFor: () => { revetAllowed: boolean; basinLUAllowed: boolean }; - /** 다단 기슭막이 상태 — 유출 벽 선택 시 개수 입력 행 표시 판단에 쓴다. */ + /** 다단 기슭막이 상태 — 유출 벽 선택 시 단 수 행 표시 판단에 쓴다. */ extraState: () => { canAdd: boolean; count: number }; - /** 다단 기슭막이 단 수 지정(2026-08-22 사용자 — 숫자 입력). 지형이 허락하는 - * 단 수보다 크면 기하가 되는 만큼만 세우고 토스트로 알린다. */ + /** 다단 기슭막이 단 수 지정 — 지형 허용보다 크면 기하가 자르고 토스트로 알린다. */ setExtraCount: (count: number) => void; } @@ -47,11 +57,11 @@ export interface StructurePanelHandle { } /** - * 한 걸음 이동량(m) — **관 길이 1m**에 맞춘다(2026-08-21 사용자 확정). - * 관은 m 단위로 설치하므로 0.1m씩 밀면 그 끝수를 벽 폭이 흡수하게 되어 미는 동안 - * 벽 단면이 변한다. 한 걸음을 1m로 두면 벽은 자리만 옮기고 형상은 그대로다. + * 좌우·상하 한 걸음(m) — **관 길이 1m**에 맞춘다(2026-08-21 사용자 확정). + * 높이는 0.1m 눈금(2026-08-22 사용자 확정 2~3m·0.5~3m 구간 0.1 단위 제어). */ const STEP_M = 1.0; +const HEIGHT_STEP_M = 0.1; function makeButton(label: string, title: string, onClick: () => void): HTMLButtonElement { const button = document.createElement("button"); @@ -67,10 +77,11 @@ function makeButton(label: string, title: string, onClick: () => void): HTMLButt return button; } -/** 구조물 위치 조정 오버레이 창을 만든다. 처음에는 숨어 있다. */ +/** 구조물 조정 오버레이 창을 만든다. 처음에는 숨어 있다. */ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHandle { const root = document.createElement("div"); - root.className = "b06-structure-panel is-hidden"; + root.className = "b06-structure-panel"; + root.classList.add("is-hidden"); // 창 안에서의 클릭이 카드 선택으로 번지지 않게 한 번에 막는다. root.addEventListener("click", (event) => event.stopPropagation()); @@ -98,6 +109,16 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan L("B06_Cross_Revet_Right"), act((key) => deps.nudge(key, -STEP_M)), ), + makeButton( + "▲", + L("B06_Cross_Revet_Up"), + act((key) => deps.nudgeSlope(key, -STEP_M)), + ), + makeButton( + "▼", + L("B06_Cross_Revet_Down"), + act((key) => deps.nudgeSlope(key, STEP_M)), + ), makeButton( "↺", L("B06_Cross_Revet_Reset"), @@ -105,23 +126,40 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan ), ); - // 다단 기슭막이 단 수 입력(2026-08-22 사용자) — 유출 벽 기준 숫자 입력. - const extraRow = document.createElement("label"); - extraRow.className = "b06-structure-panel__struct"; - const extraLabel = document.createElement("span"); - extraLabel.textContent = L("B06_Cross_Extra_Count"); - const countInput = document.createElement("input"); - countInput.type = "number"; - countInput.min = "0"; - countInput.max = "9"; - countInput.step = "1"; - countInput.className = "b06-structure-panel__count"; - countInput.addEventListener("change", () => { - const requested = Math.max(0, Math.min(9, Math.round(Number(countInput.value) || 0))); - countInput.value = String(requested); - deps.setExtraCount(requested); + // 높이·재질 행(2026-08-22 사용자) — 높이 ±0.1m, 재질이 한계를 정한다. + const heightRow = document.createElement("div"); + heightRow.className = "b06-structure-panel__struct"; + const heightLabel = document.createElement("span"); + heightLabel.textContent = L("B06_Cross_Height_Label"); + const heightValue = document.createElement("span"); + heightValue.className = "b06-structure-panel__hval"; + const heightMinus = makeButton( + "-", + L("B06_Cross_Height_Minus"), + act((key) => deps.nudgeHeight(key, -HEIGHT_STEP_M)), + ); + const heightPlus = makeButton( + "+", + L("B06_Cross_Height_Plus"), + act((key) => deps.nudgeHeight(key, HEIGHT_STEP_M)), + ); + const materialSelect = document.createElement("select"); + materialSelect.className = "b06-structure-panel__select"; + const MATERIAL_LABEL: Record = { + dry: L("B06_Cross_Mat_Dry"), + wet: L("B06_Cross_Mat_Wet"), + concrete: L("B06_Cross_Mat_Concrete"), + }; + for (const material of REVET_MATERIALS) { + const option = document.createElement("option"); + option.value = material; + option.textContent = `${MATERIAL_LABEL[material]}(~${materialLimit(material).toFixed(1)}m)`; + materialSelect.append(option); + } + materialSelect.addEventListener("change", () => { + if (current) deps.setMaterial(current, materialSelect.value as RevetMaterial); }); - extraRow.append(extraLabel, countInput); + heightRow.append(heightLabel, heightMinus, heightValue, heightPlus, materialSelect); // 유입측 구조물 형식 드롭다운(2026-08-22 사용자) — 자동/기슭막이/집수정 I·ㄴ·ㄷ. const structureRow = document.createElement("label"); @@ -148,13 +186,39 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan }); structureRow.append(structureLabel, select); + // 다단 기슭막이 단 수(2026-08-22 사용자) — 숫자 입력 + +/- 병행(스피너는 CSS 제거). + const extraRow = document.createElement("label"); + extraRow.className = "b06-structure-panel__struct"; + const extraLabel = document.createElement("span"); + extraLabel.textContent = L("B06_Cross_Extra_Count"); + const countInput = document.createElement("input"); + countInput.type = "number"; + countInput.min = "0"; + countInput.max = "9"; + countInput.step = "1"; + countInput.className = "b06-structure-panel__count"; + const clampCount = (value: number): number => + Math.max(0, Math.min(9, Math.round(Number.isFinite(value) ? value : 0))); + countInput.addEventListener("change", () => { + const requested = clampCount(Number(countInput.value)); + countInput.value = String(requested); + deps.setExtraCount(requested); + }); + const countMinus = makeButton("-", L("B06_Cross_Extra_Remove"), () => + deps.setExtraCount(clampCount(Number(countInput.value) - 1)), + ); + const countPlus = makeButton("+", L("B06_Cross_Extra_Add"), () => + deps.setExtraCount(clampCount(Number(countInput.value) + 1)), + ); + extraRow.append(extraLabel, countMinus, countInput, countPlus); + const closeButton = makeButton("✕", L("B06_Cross_Revet_Close"), () => deps.close()); closeButton.classList.add("b06-structure-panel__close"); const head = document.createElement("div"); head.className = "b06-structure-panel__head"; head.append(title, closeButton); - root.append(head, value, structureRow, extraRow, buttons); + root.append(head, value, structureRow, heightRow, extraRow, buttons); return { root, @@ -163,45 +227,57 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan root.classList.toggle("is-hidden", key === null); if (!key) return; const isExtra = key.startsWith("extra"); + const movable = deps.canNudge(key); title.textContent = isExtra ? `${L("B06_Cross_Revet_Extra")} ${Number(key.slice(5)) + 1}` : key === "outlet" ? L("B06_Cross_Revet_Outlet") - : deps.canNudge("inlet") + : movable ? L("B06_Cross_Revet_Inlet") : L("B06_Cross_Struct_InletBasin"); - // 형식 선택은 유입측에서만, ◀/▶/↺는 자리를 옮길 수 있는 구조물(기슭막이)만. + // 형식 선택은 유입측에서만, 이동·높이 조작은 기슭막이(집수정 제외)만. structureRow.classList.toggle("is-hidden", key !== "inlet"); if (key === "inlet") { - const current = deps.structureFor(); + const structure = deps.structureFor(); // 상황에 안 맞는 선택지는 숨긴다(2026-08-22 사용자). 단 지금 선택된 값은 // 남긴다 — 숨기면 셀렉트가 빈 값이 된다. const allow = deps.optionsFor(); for (const option of select.options) { - const value = option.value as InletStructureChoice; + const optionValue = option.value as InletStructureChoice; const hidden = - (value === "revet" && !allow.revetAllowed) || - ((value === "L" || value === "U") && !allow.basinLUAllowed); - option.hidden = hidden && value !== current; + (optionValue === "revet" && !allow.revetAllowed) || + ((optionValue === "L" || optionValue === "U") && !allow.basinLUAllowed); + option.hidden = hidden && optionValue !== structure; } - select.value = current; + select.value = structure; } - buttons.classList.toggle("is-hidden", !deps.canNudge(key)); - // 단 수 입력은 **유출 벽에서만** — 성토부가 5m 이상(의무)이거나 이미 단이 있을 때. + buttons.classList.toggle("is-hidden", !movable); + heightRow.classList.toggle("is-hidden", !movable); + if (movable) { + heightValue.textContent = `${deps.heightFor(key).toFixed(1)}m`; + materialSelect.value = deps.materialFor(key); + } + // 단 수 행은 **유출 벽에서만** — 성토부 5m 이상(의무)이거나 이미 단이 있을 때. const extra = deps.extraState(); extraRow.classList.toggle( "is-hidden", !(key === "outlet" && (extra.canAdd || extra.count > 0)), ); countInput.value = String(extra.count); - const shift = deps.shiftFor(key); - // 자동 자리 기준 이동량 — 0이면 "자동"으로 적어 손을 안 댔음을 바로 알린다. - // 부호(+/−)만 적으면 좌·우 벽에서 어느 쪽인지 읽히지 않아 **안/바깥**으로 적는다. - const direction = L(shift > 0 ? "B06_Cross_Revet_Outward" : "B06_Cross_Revet_Inward"); - const moved = - Math.abs(shift) < 1e-9 - ? L("B06_Cross_Revet_Auto") - : `${direction} ${Math.abs(shift).toFixed(1)}m`; + const adjust = deps.adjustFor(key); + // 조작 요약 — 0이면 "자동". 좌우는 안/바깥, 상하는 위/아래로 적는다. + const parts: string[] = []; + if (Math.abs(adjust.x) > 1e-9) { + parts.push( + `${L(adjust.x > 0 ? "B06_Cross_Revet_Outward" : "B06_Cross_Revet_Inward")} ${Math.abs(adjust.x).toFixed(1)}m`, + ); + } + if (Math.abs(adjust.d) > 1e-9) { + parts.push( + `${L(adjust.d > 0 ? "B06_Cross_Revet_DirDown" : "B06_Cross_Revet_DirUp")} ${Math.abs(adjust.d).toFixed(1)}m`, + ); + } + const moved = parts.length ? parts.join(" · ") : L("B06_Cross_Revet_Auto"); const pipeLength = deps.pipeLengthM(); value.textContent = pipeLength === null ? moved : `${L("B06_Cross_Revet_Pipe")} ${pipeLength}m · ${moved}`; diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index cf42b9ee..80e9dfa4 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -25,10 +25,16 @@ import { } from "./B06_Section_UI_Cross_Design"; 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 type { + ExtraWallControl, + InletStructureControl, + RevetOffsetControl, +} from "./B06_Section_UI_Cross_Culvert_Wire"; import { buildStructurePanel } from "./B06_Section_UI_Cross_Structure_Panel"; import { attachZoomPan, buildZoomControls } from "./B06_Section_UI_Cross_View_Zoom"; import type { RevetHighlightSetter, RevetKey } from "./B06_Section_UI_Cross_Culvert"; +import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const"; +import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types"; import { CROSS_HEIGHT, CROSS_PAD, @@ -160,35 +166,13 @@ export interface StationWidthControl { reset: (chainageM: number) => void; } -/** - * 기슭막이 X 자리 제어(2026-08-21 사용자 ①) — 벽을 눌러 고르고 ◀/▶로 0.1m씩 민다. - * 고른 벽은 여기 담아 두어 카드가 다시 그려져도 되살아난다. `select`는 값만 담고 - * 다시 그리지 않는다 — 강조는 클래스만 갈아 끼워 줌·팬을 지킨다. - */ -export interface RevetOffsetControl { - shiftFor: (section: CrossSection, role: RevetKey) => number; - selectedFor: (section: CrossSection) => RevetKey | null; - select: (chainageM: number, key: RevetKey | null) => void; - /** 기하가 실제로 적용한 이동량을 되받아 담는다(한계에 걸린 요청값을 잘라 낸다). */ - syncShift: (chainageM: number, role: RevetKey, appliedM: number) => void; - adjust: (chainageM: number, role: RevetKey, deltaM: number) => void; - reset: (chainageM: number, role: RevetKey) => void; -} - -/** 유입측 구조물 형식 선택(드롭다운 — 2026-08-22 사용자). 세션 보관은 Page가 한다. */ -export interface InletStructureControl { - valueFor: (section: CrossSection) => InletStructureChoice; - set: (chainageM: number, value: InletStructureChoice) => void; -} - -/** 유출측 다단 기슭막이 단 수 제어(2026-08-22 사용자 — 유출 벽 기준 숫자 입력). */ -export interface ExtraWallControl { - countFor: (section: CrossSection) => number; - /** 단 수 지정 — 줄이면 사라지는 단의 이동량도 함께 지운다. */ - setCount: (chainageM: number, count: number) => void; - /** 기하가 실제로 세운 단 수로 잘라 동기화(지형상 못 세운 단 정리). */ - syncCount: (chainageM: number, built: number) => void; -} +// 기슭막이·유입 구조물·다단 제어 인터페이스는 Wire로 옮겼다(700줄 제한) — +// 기존 import 경로 유지를 위해 재수출한다. +export type { + ExtraWallControl, + InletStructureControl, + RevetOffsetControl, +} from "./B06_Section_UI_Cross_Culvert_Wire"; export function createCrossSectionCard( section: CrossSection, @@ -243,6 +227,8 @@ export function createCrossSectionCard( let culvertInletOptions = { revetAllowed: true, basinLUAllowed: true }; /** 추가 기슭막이 상태(2026-08-22) — 끝 성토부 5m 이상(canAdd)·세워진 개수. */ let culvertExtraState = { canAdd: false, count: 0 }; + /** 마지막 계산의 벽 제원(높이·재질) — 조정창 높이 행 표시·높이 조작 기준값. */ + let culvertWallSpecs = new Map(); /** * 기슭막이를 고른다. 구조물 선택과 절·성토 면적 강조는 **같은 레벨**이라 하나를 * 고르면 다른 하나는 풀린다(2026-08-21 사용자). 또 구조물을 고르면 그 **측점 카드도 @@ -519,6 +505,12 @@ export function createCrossSectionCard( canAdd: culvertLayout.outletFill.addable, count: culvertLayout.extraWalls.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; + culvertWallSpecs.set(key, { height: wall.height, material: wall.material }); + } } if (culvertLayout) { setRevetActive = appendCulvertOverlay( @@ -605,10 +597,30 @@ export function createCrossSectionCard( // 화면 좌(◀)로 민다 = offset이 커진다 — 벽 기준 이동량으로 환산해 넘긴다. const outwardOf = (role: RevetKey): number => ((section.uphill_side ?? "left") === "left") === (role === "inlet") ? 1 : -1; + const heightOfWall = (key: RevetKey): number => culvertWallSpecs.get(key)?.height ?? 0; + const materialOfWall = (key: RevetKey): RevetMaterial => + culvertWallSpecs.get(key)?.material ?? "dry"; + const adjustOf = (key: RevetKey): WallAdjust => + revetOffset?.adjustFor(section, key) ?? { x: 0, d: 0, h: null, m: null }; const panel = buildStructurePanel({ - shiftFor: (key) => revetOffset?.shiftFor(section, key) ?? 0, + adjustFor: adjustOf, nudge: (key, screenDeltaM) => - revetOffset?.adjust(section.chainage_m, key, screenDeltaM * outwardOf(key)), + revetOffset?.update(section.chainage_m, key, { + x: adjustOf(key).x + screenDeltaM * outwardOf(key), + }), + // 상하(대각) — ▲(위) = 성토선을 타고 노견 쪽, ▼(아래) = 계류 쪽. 수평 성분 1m. + nudgeSlope: (key, deltaM) => + revetOffset?.update(section.chainage_m, key, { d: adjustOf(key).d + deltaM }), + nudgeHeight: (key, deltaM) => { + const current = adjustOf(key); + const base = current.h ?? heightOfWall(key); + revetOffset?.update(section.chainage_m, key, { + h: Math.round((base + deltaM) * 10) / 10, + }); + }, + materialFor: (key) => materialOfWall(key), + setMaterial: (key, material) => revetOffset?.update(section.chainage_m, key, { m: material }), + heightFor: (key) => heightOfWall(key), reset: (key) => revetOffset?.reset(section.chainage_m, key), pipeLengthM: () => culvertPipeLengthM, close: () => { diff --git a/B06_Section/B06_Section_UI_Page_Station_Controls.ts b/B06_Section/B06_Section_UI_Page_Station_Controls.ts index 0993572d..768979db 100644 --- a/B06_Section/B06_Section_UI_Page_Station_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Station_Controls.ts @@ -7,6 +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 type { ExtraWallControl, InletStructureControl, @@ -106,11 +108,11 @@ export function createStationControls(deps: StationControlDeps): StationControls }, }; - /* ── 기슭막이 X 자리(2026-08-21 사용자 ①) ──────────────────────────── - * 벽을 눌러 고르고 카드 하단 ◀/▶로 0.1m씩 민다. 값은 세션에만 담는다 — 자동 자리가 - * 지형·계획고를 따라 다시 풀리므로, 손으로 민 값은 그 세션의 표시 조정으로 본다. - * 키는 `누가거리:역할`. `select`는 다시 그리지 않고 값만 담는다(줌·팬 보존). */ - const revetShifts = new Map(); + /* ── 기슭막이 4축 조작값(2026-08-22 확정: 좌우 x·상하 d·높이 h·재질 m) ── + * 값은 세션에만 담는다 — 자동 자리가 지형·계획고를 따라 다시 풀리므로, 손으로 + * 만진 값은 그 세션의 표시 조정으로 본다. 키는 `누가거리:역할`. + * 구 형식(숫자 = x 이동량)도 읽어 준다. `select`는 다시 그리지 않는다(줌·팬 보존). */ + const revetShifts = new Map(); const revetSelected = new Map(); const revetKey = (chainageM: number, role: RevetKey): string => `${chainageM.toFixed(2)}:${role}`; const revetSessionKey = (): string | null => deps.sessionKey("revetx"); @@ -123,9 +125,13 @@ export function createStationControls(deps: StationControlDeps): StationControls try { const raw = window.sessionStorage.getItem(key); if (!raw) return; - const parsed = JSON.parse(raw) as Record; - Object.entries(parsed).forEach(([entry, shift]) => { - if (Number.isFinite(shift)) revetShifts.set(entry, shift); + const parsed = JSON.parse(raw) as Record>; + Object.entries(parsed).forEach(([entry, value]) => { + if (typeof value === "number" && Number.isFinite(value)) { + revetShifts.set(entry, { ...ZERO_ADJUST, x: value }); + } else if (value && typeof value === "object") { + revetShifts.set(entry, { ...ZERO_ADJUST, ...value }); + } }); } catch { /* 손상된 세션 값은 무시 — 자동 자리로 재시작. */ @@ -143,28 +149,47 @@ export function createStationControls(deps: StationControlDeps): StationControls } /** 손으로 미는 범위 한계(m) — 조정 단위가 관 길이 1m이라 ±10m(=관 10m분)까지 둔다. */ - const clampRevetShift = (value: number): number => - Math.min(Math.max(Math.round(value * 10) / 10, -10), 10); + const round1 = (value: number): number => Math.round(value * 10) / 10; + const clampMove = (value: number): number => Math.min(Math.max(round1(value), -10), 10); + const isDefaultAdjust = (value: WallAdjust): boolean => + Math.abs(value.x) < 1e-9 && Math.abs(value.d) < 1e-9 && value.h == null && value.m == null; + const storeAdjust = (key: string, value: WallAdjust): void => { + if (isDefaultAdjust(value)) revetShifts.delete(key); + else revetShifts.set(key, value); + persistRevetShifts(); + }; + const sameAdjust = (a: WallAdjust, b: WallAdjust): boolean => + Math.abs(a.x - b.x) < 1e-9 && + Math.abs(a.d - b.d) < 1e-9 && + (a.h ?? null) === (b.h ?? null) && + (a.m ?? null) === (b.m ?? null); const revetOffsetControl: RevetOffsetControl = { - shiftFor: (section, role) => revetShifts.get(revetKey(section.chainage_m, role)) ?? 0, + adjustFor: (section, role) => + revetShifts.get(revetKey(section.chainage_m, role)) ?? { ...ZERO_ADJUST }, selectedFor: (section) => revetSelected.get(section.chainage_m.toFixed(2)) ?? null, select: (chainageM, key) => { if (key) revetSelected.set(chainageM.toFixed(2), key); else revetSelected.delete(chainageM.toFixed(2)); }, - syncShift: (chainageM, role, appliedM) => { + syncApplied: (chainageM, role, applied) => { + // 기하가 실제로 적용한 값(한계 절삭 후)을 되받아 담는다 — 숫자만 커지는 것 방지. const key = revetKey(chainageM, role); - const stored = revetShifts.get(key) ?? 0; - if (Math.abs(stored - appliedM) < 1e-9) return; - if (Math.abs(appliedM) < 1e-9) revetShifts.delete(key); - else revetShifts.set(key, appliedM); - persistRevetShifts(); + const stored = revetShifts.get(key) ?? { ...ZERO_ADJUST }; + if (sameAdjust(stored, applied)) return; + storeAdjust(key, { ...applied }); }, - adjust: (chainageM, role, deltaM) => { + update: (chainageM, role, patch) => { const key = revetKey(chainageM, role); - revetShifts.set(key, clampRevetShift((revetShifts.get(key) ?? 0) + deltaM)); - persistRevetShifts(); + const current = revetShifts.get(key) ?? { ...ZERO_ADJUST }; + const next: WallAdjust = { + x: clampMove(patch.x ?? current.x), + d: clampMove(patch.d ?? current.d), + // 높이는 0.1 눈금 반올림만 — 재질 한계 절삭은 기하가 하고 되받는다. + h: patch.h === undefined ? current.h : patch.h === null ? null : round1(patch.h), + m: patch.m === undefined ? current.m : patch.m, + }; + storeAdjust(key, next); deps.refreshCard(chainageM); }, reset: (chainageM, role) => { diff --git a/B06_Section/B06_Section_UI_Style_Cross.css b/B06_Section/B06_Section_UI_Style_Cross.css index 5f7139fa..18f76007 100644 --- a/B06_Section/B06_Section_UI_Style_Cross.css +++ b/B06_Section/B06_Section_UI_Style_Cross.css @@ -610,8 +610,18 @@ display: none; } +/* 숫자 입력의 브라우저 스피너 제거(2026-08-22 사용자 — +/- 버튼으로 조작). */ +.b06-structure-panel__count::-webkit-inner-spin-button, +.b06-structure-panel__count::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} + .b06-structure-panel__count { - width: 3.5em; + -moz-appearance: textfield; + appearance: textfield; + text-align: center; + width: 2.5em; padding: 0 4px; border: 1px solid var(--color-border); border-radius: var(--radius-inputs); @@ -620,6 +630,14 @@ font-size: var(--text-caption); } +.b06-structure-panel__hval { + min-width: 3em; + text-align: center; + color: var(--color-text-body); + font-size: var(--text-caption); + align-self: center; +} + .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 016c998a..798d83c4 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -252,7 +252,32 @@ export const ui_locales_b2 = { B06_Cross_Revet_Outlet: ["유출 기슭막이", "Outlet revetment"], B06_Cross_Struct_Label: ["구조물 형식", "Structure type"], B06_Cross_Revet_Extra: ["추가 기슭막이", "Extra revetment"], + B06_Cross_Revet_Up: ["사면 위로(대각) 1m", "Up along fill slope 1m"], + B06_Cross_Revet_Down: ["사면 아래로(대각) 1m", "Down along fill slope 1m"], + B06_Cross_Revet_DirUp: ["위", "up"], + B06_Cross_Revet_DirDown: ["아래", "down"], + B06_Cross_Revet_Limit_Up: [ + "사면 위로는 여기까지입니다 — 최소 성토고·토피(역경사) 한계", + "Cannot move further up the slope — min fill height / cover limit", + ], + B06_Cross_Revet_Limit_Down: [ + "사면 아래로는 여기까지입니다", + "Cannot move further down the slope", + ], + B06_Cross_Height_Label: ["높이", "Height"], + B06_Cross_Height_Minus: ["높이 −0.1m", "Height −0.1m"], + B06_Cross_Height_Plus: ["높이 +0.1m", "Height +0.1m"], + B06_Cross_Height_Limit: [ + "{mat} 높이 한계 {limit}m — 더 올리려면 재질을 변경하세요", + "{mat} height limit {limit}m — change material to go higher", + ], + B06_Cross_Height_Floor: ["최소 높이라 더 낮출 수 없습니다", "Already at the minimum height"], + B06_Cross_Mat_Dry: ["메쌓기", "Dry masonry"], + B06_Cross_Mat_Wet: ["찰쌓기", "Wet masonry"], + B06_Cross_Mat_Concrete: ["콘크리트", "Concrete"], B06_Cross_Extra_Count: ["추가 기슭막이(단)", "Extra revetments"], + B06_Cross_Extra_Add: ["한 단 추가", "Add one tier"], + B06_Cross_Extra_Remove: ["한 단 삭제", "Remove one tier"], B06_Cross_Extra_Limit: [ "지형상 추가 기슭막이는 {n}단까지만 가능합니다 — 벽이 원지반에 0.5m 이상 묻히면 성토가 필요 없습니다", "Terrain allows only {n} extra revetment tier(s) — no fill needed once a wall is buried 0.5m+",