From 5371534fc3883febe0eece14be52f3fe4fef1c56 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 23 Aug 2026 20:34:30 +0900 Subject: [PATCH] =?UTF-8?q?feat(B06):=20=EA=B8=B0=EC=8A=AD=EB=A7=89?= =?UTF-8?q?=EC=9D=B4=20=EB=86=92=EC=9D=B4=C2=B7=EC=9E=90=EB=A6=AC=20?= =?UTF-8?q?=EC=B2=B4=EA=B3=84=20=EA=B0=9C=ED=8E=B8=20+=20=EC=A1=B0?= =?UTF-8?q?=EC=A0=95=EC=B0=BD=20=EB=B0=A9=ED=96=A5=ED=82=A4=20=EC=A1=B0?= =?UTF-8?q?=EC=9E=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 조정창 십자 키패드 키보드 방향키 조작(집수정 9키는 십자 축만) - 배관 기슭막이 높이 = 순수 높이(바닥~상단), 하한·기본 = 관경+1.0m (고정 2.0m 폐기) - 매몰 시 전면 발끝 지반 추적 바닥 심화 삭제 — 바닥 = invert-0.5 고정 - 상하(d) 0점 = 성토선 0 지점(벽 상단=노견), 기본 자리 = 성토사면 경사길이 4m 지점 (d=null 자동, 저장·표시·정수맞춤 pinned 판정 연동, 구세션 d=0 호환) - 유입 접속 절토선: 지반 교차 없으면 1m 공간 확보에서 끝 - 자동 집수정 기본 형상 L형 -> I형 (선택지 밖 구조물 그려지던 불일치) Co-Authored-By: Claude Fable 5 --- B06_Section/B06_Section_UI_Cross_Culvert.ts | 7 +- .../B06_Section_UI_Cross_Culvert_Basin.ts | 4 +- .../B06_Section_UI_Cross_Culvert_Const.ts | 29 ++++- .../B06_Section_UI_Cross_Culvert_Extra.ts | 59 ++++------ .../B06_Section_UI_Cross_Culvert_Geom.ts | 111 +++++++++--------- .../B06_Section_UI_Cross_Culvert_Solve.ts | 18 ++- .../B06_Section_UI_Cross_Culvert_Types.ts | 12 +- .../B06_Section_UI_Cross_Culvert_Wire.ts | 9 +- .../B06_Section_UI_Cross_Structure_Panel.ts | 44 ++++++- B06_Section/B06_Section_UI_Cross_View.ts | 29 ++++- .../B06_Section_UI_Page_Station_Controls.ts | 13 +- 11 files changed, 209 insertions(+), 126 deletions(-) diff --git a/B06_Section/B06_Section_UI_Cross_Culvert.ts b/B06_Section/B06_Section_UI_Cross_Culvert.ts index 11d8ac34..afc3e6f2 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert.ts @@ -136,7 +136,7 @@ export function appendCulvertOverlay( planLine( layout.inletFill.points, layout.inletFill.kind === "cut" - ? `유입부 접속 절토선 — 관 하단에서 3도(계류 쪽 오름) 1m 후 표준 절토 경사로 원지반까지 (연장 ${layout.inletFill.lengthM.toFixed(2)}m)` + ? `유입부 접속선 — 관 하단에서 3도(계류 쪽 오름) 1m 공간 확보에서 끝 (연장 ${layout.inletFill.lengthM.toFixed(2)}m)` : `유입부 3도 성토선(계류 쪽 오름 — 물이 관으로) — 원지반 교차에서 정지 (연장 ${layout.inletFill.lengthM.toFixed(2)}m)`, ); } @@ -168,7 +168,10 @@ export function appendCulvertOverlay( const revetShape = polygon( wall.points.map((p) => [x(p.offset), toDisplayY(p.elevation)] as [number, number]), "b06-chart__culvert-revet", - `${roleLabel(wall.role)} 기슭막이 ${wall.form ?? ""} H=${wall.height.toFixed(1)}m` + + // 배관 벽 높이 표기 = 순수 높이(바닥~상단, 관 아래 0.5 포함 — 2026-08-23 사용자). + `${roleLabel(wall.role)} 기슭막이 ${wall.form ?? ""} H=${( + wall.height + (wall.role === "extra" ? 0 : REVET_EMBED_DEPTH_M) + ).toFixed(1)}m` + `(상단 = 사면선 접점, 전면 1:${REVET_LEAN_RATIO}` + `, 높이 한계 ${revetHeightLimit(wall.form).toFixed(1)}m — 교본 7-3)` + (wall.floatGapM > 0.01 diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts index fa3dfb11..4bb8f8f0 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts @@ -36,7 +36,9 @@ export function resolveBasinChoice( const selected = choice === "I" || choice === "L" || choice === "U"; return { reason: choice === "revet" ? null : selected ? (ruleReason ?? "manual") : ruleReason, - shape: selected ? choice : "L", + // 자동(기본) 집수정은 **I형**(2026-08-23 사용자) — 종전 L형 기본은 ㄴ·ㄷ형이 + // 선택지에서 숨는 지형(basinLUAllowed=false)에서 선택지 밖 구조물이 그려졌다. + shape: selected ? choice : "I", }; } diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts index 00177d39..1eb1b50b 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts @@ -89,8 +89,9 @@ export function revetHeightLimit(form: string | null | undefined): number { /* ── 기슭막이 재질·높이 조작(2026-08-22 사용자 확정) ───────────────────── * 높이 조작이 교본 형태별 한계와 부딪히면 **재질을 바꿔야** 더 올릴 수 있다: * 메쌓기 2.0 / 찰쌓기 3.0(돌쌓기.md §1) / 콘크리트 5.0(프로젝트 기본값, - * 외부 기준 미확인). 높이 기준은 근입 - * 0.5m 위 기준선(관 invert 자리)~상단의 **계산용 높이**로 종전과 동일. */ + * 외부 기준 미확인). 배관 기슭막이의 높이 제어·표기·한계 기준은 **순수 높이** + * (벽 바닥~상단, 관 아래 0.5m 포함 — 2026-08-23 사용자 확정). 일반(추가) + * 기슭막이는 종전 계산용 높이(근입 0.5 위 기준선~상단) 기준을 유지한다. */ export type RevetMaterial = "dry" | "wet" | "concrete"; export const REVET_MATERIALS: RevetMaterial[] = ["dry", "wet", "concrete"]; @@ -114,10 +115,15 @@ export function materialFromForm(form: string | null | undefined): RevetMaterial * 따른다: 유입측은 계류 쪽이 높고(물이 관으로), 유출측은 계류 쪽이 낮다(관에서 밖으로). */ export const PIPE_CONNECT_GRADE = Math.tan((3 * Math.PI) / 180); -/** 배관 기슭막이 높이 조작 하한(m) — 2~3m 구간 지시(2026-08-22)의 하한. */ -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) — 관 invert 아래 벽 바닥까지의 깊이(2026-08-23 + * 사용자 확정: 배관 기준 아래 0.5·위 0.5 여유). 값은 근입 깊이와 같은 0.5라 + * 형상은 종전과 동일하고, **순수 높이 하한 = 관경 + 위 0.5 + 아래 0.5**가 된다. + * 종전 고정 하한·기본 2.0m(관경 무관)는 폐기. + */ +export function pipeWallMinPureHeight(diameterM: number): number { + return revetTargetHeight(diameterM) + REVET_EMBED_DEPTH_M; +} /** 일반(추가) 기슭막이 높이 조작 하한(m) — 프로젝트 기본값 0.5m. */ export const EXTRA_WALL_MIN_HEIGHT_M = 0.5; /** 일반(추가) 기슭막이 기본 높이(m). */ @@ -136,6 +142,17 @@ export const FILL_SLOPE_RATIO_MAX = 2.0; /** 성토사면 길이 한계(m) — `성토_비탈면.md` §2 "5m 이내". 초과 시 옹벽·석축 등 의무. */ export const FILL_SLOPE_MAX_LENGTH_M = 5.0; +/** + * 배관 기슭막이 기본 자리 — 노견에서 **성토사면 경사길이 4m 지점**(2026-08-23 사용자 + * 확정). 상하(d) 조작의 0점은 성토선 길이 0 지점(벽 상단 = 노견)이고, d는 그 점에서 + * 1:1.2 성토선을 타고 내려간 수평 성분(m)이다. 기본 자리가 안 되는 지형(매몰·역경사· + * 토피)은 배치 풀이의 기존 한계 논리가 안쪽으로 되돌린다. + */ +export const PIPE_WALL_DEFAULT_SLOPE_M = 4.0; +/** 기본 자리(경사길이 4m)의 수평 성분(m) — d 기본값. */ +export const PIPE_WALL_DEFAULT_RUN_M = + (PIPE_WALL_DEFAULT_SLOPE_M * FILL_SLOPE_RATIO_MIN) / Math.hypot(1, FILL_SLOPE_RATIO_MIN); + /** * 성토 전체고(노견 − 원지반) 기준(m) — 이 높이를 넘으면 1:1.2로도 사면이 5m를 넘어 * 구조물이 필요해진다(3m × √(1+1.2²) = 4.69m, 3.2m에서 5.0m). 기슭막이를 자연 사면 diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts index 8c4d0f59..55e96bb2 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts @@ -85,6 +85,7 @@ export function slopedCrossing( * 유입 기슭막이의 관 시작 접속선(2026-08-22 사용자 ②). * · 관 하단점이 원지반보다 **위**: 0도 성토선 — 지반과 만나는 곳에서 정지. * · **아래**: 0도 직선 1m 후 표준 절토 경사(1:n)로 지반까지 올라가는 절토선. + * 지반 교차가 안 나오는 지형은 1m 공간 확보에서 끝(2026-08-23 사용자). */ export function inletGroundConnector( corner: OffsetPoint, @@ -125,13 +126,15 @@ export function inletGroundConnector( }; } if (corner.elevation < ground - 0.02) { - // 3도 직선 1m(계류 쪽이 높다) 후 표준 절토 경사. + // 3도 직선 1m(계류 쪽이 높다) 후 표준 절토 경사로 지반까지. 교차가 안 + // 나오면 절토선 없이 **1m 공간 확보에서 끝**(2026-08-23 사용자 — 종전에는 + // 샘플 한계까지 올라가는 긴 선이 남았다). const bend: OffsetPoint = { offset: corner.offset + outward * 1.0, elevation: corner.elevation + PIPE_CONNECT_GRADE * 1.0, }; - let last = bend; const cutSpan = (limitOffset - bend.offset) * outward; + let cross: OffsetPoint | null = null; let previousGap = groundAt(bend.offset) - bend.elevation; // 지반 − 절토선 for (let t = 0.25; t <= Math.max(cutSpan, 0) + 1e-9; t += 0.25) { const x = bend.offset + outward * t; @@ -140,18 +143,19 @@ export function inletGroundConnector( if (gap <= 0) { const fraction = previousGap > 1e-12 ? previousGap / (previousGap - gap) : 1; const tCross = t - 0.25 * (1 - fraction); - last = { + cross = { offset: bend.offset + outward * tCross, elevation: bend.elevation + tCross / Math.max(cutSlopeRatio, 0.1), }; break; } - last = { offset: x, elevation }; previousGap = gap; } return { - points: [corner, bend, last], - lengthM: 1 + Math.hypot(last.offset - bend.offset, last.elevation - bend.elevation), + points: cross ? [corner, bend, cross] : [corner, bend], + lengthM: cross + ? 1 + Math.hypot(cross.offset - bend.offset, cross.elevation - bend.elevation) + : 1, overLimit: false, kind: "cut", }; @@ -217,7 +221,6 @@ function buildExtraWall( height: number, material: RevetMaterial, floatGapM: number, - groundAt: (offset: number) => number, ): WallLayout { const thickness = REVET_THICKNESS_M; const baseWidth = thickness * 1.5 + REVET_LEAN_RATIO * height; @@ -228,16 +231,10 @@ function buildExtraWall( const topFront = topJoint + outward * thickness; const frontXAt = (elevation: number): number => topFront + outward * REVET_LEAN_RATIO * (topElevation - elevation); - // 하단 = 기준선(anchor) 아래 근입 0.5m — 근입은 **경사선(전면) 측 깊이** 기준이라 - // 전면 발끝 지반이 낮으면 그 아래 0.5까지 더 내린다(2026-08-22 사용자 ②). - let bottomElevation = anchor.elevation - REVET_EMBED_DEPTH_M; - if (floatGapM <= 1e-6) { - for (let pass = 0; pass < 6; pass += 1) { - const toeGround = Math.min(groundAt(frontXAt(bottomElevation)), anchor.elevation); - if (toeGround - REVET_EMBED_DEPTH_M >= bottomElevation - 1e-6) break; - bottomElevation = toeGround - REVET_EMBED_DEPTH_M; - } - } + // 하단 = 기준선(anchor) 아래 근입 0.5m **고정** — 원지반에 묻혀도 바닥을 더 + // 내리지 않는다. 벽 형상은 높이값으로만 정한다(2026-08-23 사용자 확정 — + // 종전 전면 발끝 지반 추적 수렴 삭제). + const bottomElevation = anchor.elevation - REVET_EMBED_DEPTH_M; const bottomBack: OffsetPoint = { offset: backOffset, elevation: bottomElevation }; const bottomFront: OffsetPoint = { offset: frontXAt(bottomElevation), @@ -342,7 +339,7 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult // 등간격 배치: 1차는 남은 구간(끝 포함) 균등 분할 근사, 이후 라운드는 보정 d. const requestedD = plan === "user" - ? adjust.d + ? (adjust.d ?? 0) : plan === "greedy" ? (trailing.lengthM / (input.adjusts.length - i + 1)) * (FILL_SLOPE_RATIO_MIN / Math.hypot(1, FILL_SLOPE_RATIO_MIN)) - @@ -350,24 +347,9 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult : (plan[i] ?? 0); const requestedX = plan === "user" ? adjust.x : 0; const minD = Math.ceil((minJointRun - autoJointRun) * 10) / 10; - /** 후보 자리의 바닥(전면 근입 심화 반영) — buildExtraWall과 같은 계산. */ - const bottomAt = (anchorX: number, baseElevation: number): number => { - const baseWidth = REVET_THICKNESS_M * 1.5 + REVET_LEAN_RATIO * height; - const jointX = anchorX - outward * (baseWidth / 2 - REVET_THICKNESS_M / 2); - const topFrontX = jointX + outward * REVET_THICKNESS_M; - const topElevation2 = baseElevation + height; - const frontXAt = (e: number): number => - topFrontX + outward * REVET_LEAN_RATIO * (topElevation2 - e); - let bottom = baseElevation - REVET_EMBED_DEPTH_M; - if (baseElevation - groundAt(anchorX) <= 1e-6) { - for (let pass = 0; pass < 6; pass += 1) { - const toe = Math.min(groundAt(frontXAt(bottom)), baseElevation); - if (toe - REVET_EMBED_DEPTH_M >= bottom - 1e-6) break; - bottom = toe - REVET_EMBED_DEPTH_M; - } - } - return bottom; - }; + /** 후보 자리의 바닥 — buildExtraWall과 같은 근입 0.5m 고정(2026-08-23). */ + const bottomAt = (_anchorX: number, baseElevation: number): number => + baseElevation - REVET_EMBED_DEPTH_M; // 이동 한계 = 원지반 매몰 자리 금지(2026-08-22 사용자 확정): ① 이음선 상단이 // 지반 아래(벽이 통째로 묻힘) ② 자기 성토선 시작점(실바닥+0.5 전면 교차점)이 // 지반 아래 0.05m 이상(= 0.5m 이상 매몰 → 성토 불필요 자리). @@ -426,7 +408,6 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult height, material, Math.max(0, base - groundAt(anchorX)), - groundAt, ); walls.push(wall); // 성토선: src → (수평 선반 x>0이면 선반 끝) → 이음선 상단점. 사면길이 = 경사부. @@ -443,7 +424,7 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult ratio: rise > 1e-9 ? run / rise : FILL_SLOPE_RATIO_MIN, overLimit: Math.hypot(run, rise) >= FILL_SLOPE_MAX_LENGTH_M - 1e-6, }); - // 다음 단 시작점 = 이 벽 **실제 바닥**(근입 심화 반영) +0.5m 수평선과 전면 + // 다음 단 시작점 = 이 벽 바닥 +0.5m(= 기준선) 수평선과 전면 // 경사선(1:0.3)의 교차점(2026-08-22 사용자 — 관 끝점 규칙과 동일 기준). const startElevation = wall.bottomBack.elevation + REVET_EMBED_DEPTH_M; src = { @@ -493,7 +474,7 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult const mean = lengths.reduce((a, b) => a + b, 0) / lengths.length; if (Math.max(...lengths.map((l) => Math.abs(l - mean))) < 0.05) break; const dNext = result.appliedAdjusts.map( - (applied, i) => applied.d + (mean - (lengths[i] ?? mean)) * horizontalPerSlope, + (applied, i) => (applied.d ?? 0) + (mean - (lengths[i] ?? mean)) * horizontalPerSlope, ); result = cascadeOnce(dNext); } diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts index ef207639..3398e71d 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts @@ -9,12 +9,11 @@ import type { CrossSection, CulvertSideSpec, SectionSample } from "./B06_Section_Api_Fetch"; import { BASIN_MAX_FILL_SLOPE_M, - FILL_MIN_RISE_M, materialFromForm, materialLabel, materialLimit, - PIPE_WALL_DEFAULT_HEIGHT_M, - PIPE_WALL_MIN_HEIGHT_M, + PIPE_WALL_DEFAULT_RUN_M, + pipeWallMinPureHeight, FILL_SLOPE_MAX_LENGTH_M, FILL_SLOPE_RATIO_MAX, FILL_SLOPE_RATIO_MIN, @@ -22,6 +21,7 @@ import { REVET_EMBED_DEPTH_M, REVET_LEAN_RATIO, REVET_THICKNESS_M, + REVET_TRAP_TOP_M, revetHeightLimit, revetTargetHeight, } from "./B06_Section_UI_Cross_Culvert_Const"; @@ -55,7 +55,7 @@ import { buildExtrasAt, inletGroundConnector } from "./B06_Section_UI_Cross_Culv import type { FillSlopeSegment, WallVertical } from "./B06_Section_UI_Cross_Culvert_Solve"; import { cutLength, - minShoulderWallOffset, + fillWallBaseWidth, outletSlopeFactory, fillSlopeOf, groundInterpolator, @@ -162,20 +162,19 @@ export function computeCulvertLayout( inlet.elevation = Math.min(groundAt(startOffset), invertCap(inletInfo.edge)); } - // 벽 제원(4축): 재질이 높이 한계(메2/찰3/콘5)를 정하고, 높이 = 설정 ?? 기본 2.0. - // 하한 = max(2.0, 관경+여유고) — 관이 벽 위로 삐져나오면 안 된다. + // 벽 제원(4축): 재질이 높이 한계(메2/찰3/콘5)를 정한다. 높이 기준 = **순수 높이** + // (바닥~상단 — 2026-08-23 사용자). 하한·기본 = 관경 + 아래 0.5 + 위 0.5 — + // 관이 벽 위로 삐져나오거나 바닥에 붙으면 안 된다. 기하는 종전대로 + // invert~상단 계산용 높이(= 순수 − 0.5)로 돈다. 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 floor = pipeWallMinPureHeight(diameter); + const pureHeight = Math.min(Math.max(adjust.h ?? floor, floor), Math.max(limit, floor)); + return { material, limit, pureHeight, height: pureHeight - REVET_EMBED_DEPTH_M }; }; const inletWallSpec = pipeWallSpec(culvert.inlet, adjInlet); const outletWallSpec = pipeWallSpec(culvert.outlet, adjOutlet); @@ -191,33 +190,38 @@ export function computeCulvertLayout( diameterM: diameter, cutSlopeRatio: section.design?.cut_slope_ratio ?? 1.0, }); - /** 한계에 잘린 뒤의 실제 조작값 — 조정창이 되받는다. */ + /** 한계에 잘린 뒤의 실제 조작값 — 조정창이 되받는다(높이는 순수 높이 기준). */ const appliedAdjust = { - inlet: { ...adjInlet, h: adjInlet.h != null ? inletWallSpec.height : null }, - outlet: { ...adjOutlet, h: adjOutlet.h != null ? outletWallSpec.height : null }, + inlet: { ...adjInlet, h: adjInlet.h != null ? inletWallSpec.pureHeight : null }, + outlet: { ...adjOutlet, h: adjOutlet.h != null ? outletWallSpec.pureHeight : null }, }; /** 손으로 민 벽의 수직 제원(높이 증가·바닥 띄움) — buildWall에 물려 준다. */ const wallVertical: { inlet: WallVertical | null; outlet: WallVertical | null } = { inlet: null, outlet: null, }; - // 자동 자리 = **노폭 연장 최소 지점**(1:1.2가 딱 성립하는 가장 안쪽). 없으면 사면 끝. - let inletAutoOffset: number | null = null; + // d의 0점 = **성토선 길이 0 지점**(벽 상단 = 노견 — 2026-08-23 사용자 확정). + // 그 자리(하단선 중점)는 이음선 상단점이 노견 끝에 오는 offset이고, 기본 자리는 + // 성토선을 타고 경사길이 4m 내려간 지점(PIPE_WALL_DEFAULT_RUN_M)이다. + const slopeZeroAnchor = ( + edge: { offset_m: number }, + outward: number, + wallHeight: number, + ): number => edge.offset_m + outward * (fillWallBaseWidth(wallHeight) / 2 - REVET_TRAP_TOP_M); if (!basinReason && designAt) { const inletBaseAt = (offset: number): number => Math.min(groundAt(offset), invertCap(inletInfo.edge)); - // 자동 자리 = 가용성 판정과 같은 스캔 결과(노견 최소 지점 ?? 사면 끝) 재사용. - inletAutoOffset = inletOptions.revetAutoOffset; // 4축 배치는 공용 풀이(placeInletWall — Solve). 유입을 내리면 유출도 따라 // 내려가므로, 유출이 못 받으면 유입도 못 내려간다(outletGuard). const placed = placeInletWall({ - autoOffset: inletAutoOffset, + autoOffset: slopeZeroAnchor(inletInfo.edge, inletInfo.outward, inletWallSpec.height), outward: inletInfo.outward, height: inletWallSpec.height, - baseElevation0: inletBaseAt(inletAutoOffset), + baseElevation0: inletInfo.edge.elevation_m - inletWallSpec.height, edgeElevation: inletInfo.edge.elevation_m, invertCap: invertCap(inletInfo.edge), adjust: adjInlet, + defaultD: PIPE_WALL_DEFAULT_RUN_M, groundAt, limitOffset: inletInfo.limit, outletGuard: { @@ -340,19 +344,12 @@ export function computeCulvertLayout( const topBack: OffsetPoint = { offset: backOffset, elevation: topElevation }; // 상단 변: 사다리꼴 상단(t/2) + 띠(t). 이음선은 상단 변 중간점에서 1:0.3으로 바닥까지. const topFront = topJoint + outward * thickness; - // 하단선 = **수평 기초**, 깊이 = 기준선 아래 근입 0.5m(전면 측 기준으로 수렴). + // 하단선 = **수평 기초**, 깊이 = 기준선(관 invert) 아래 근입 0.5m 고정 — + // 원지반에 묻혀도 바닥을 더 내리지 않는다. 벽 형상은 높이값으로만 정한다 + // (2026-08-23 사용자 확정 — 종전 전면 발끝 지반 추적 수렴 삭제). const frontXAt = (elevation: number): number => topFront + outward * REVET_LEAN_RATIO * (topElevation - elevation); - let bottomElevation = anchor.elevation - REVET_EMBED_DEPTH_M; - if (floatGapM <= 1e-6) { - // 근입 0.5m는 **경사선(전면) 측 깊이** 기준(2026-08-22 사용자 ②) — 전면 발끝 - // 지반이 더 낮으면 그 아래 0.5까지 내린다. 내려가면 발끝이 더 나가므로 수렴 반복. - for (let pass = 0; pass < 6; pass += 1) { - const toeGround = Math.min(groundAt(frontXAt(bottomElevation)), anchor.elevation); - if (toeGround - REVET_EMBED_DEPTH_M >= bottomElevation - 1e-6) break; - 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), @@ -407,29 +404,30 @@ export function computeCulvertLayout( ); // 유출 벽 밑 = 그 자리 원지반(역경사는 유입 invert로 클램프 — 2026-08-21). const invertAt = (offset: number): number => Math.min(groundAt(offset), inlet.elevation); - // 유출 벽 자동 자리도 **노견 최소 지점**(2026-08-22 사용자 확정) — 못 찾으면 사면 끝. let outletWallAnchor = outletAnchor; - const outletFeasible = designAt - ? minShoulderWallOffset( - outletInfo.edge, - outletInfo.outward, - outletWallSpec.height, - invertAt, - outletAnchorOffset, - outletInfo.limit, - ) - : null; - const outletAutoOffset = outletFeasible ?? outletAnchorOffset; + // 유출도 성토선 0점 기준(2026-08-23) — 기본 자리는 경사길이 4m 지점. 관 길이 + // 정수 맞춤 루프가 벽을 옮길 때 invert가 같은 성토선을 타도록 함수로 둔다. + const outletZeroAnchor = slopeZeroAnchor( + outletInfo.edge, + outletInfo.outward, + outletWallSpec.height, + ); + const outletZeroInvert = outletInfo.edge.elevation_m - outletWallSpec.height; + const outletLineInvertAt = (offset: number): number => + designAt + ? outletZeroInvert - ((offset - outletZeroAnchor) * outletInfo.outward) / FILL_SLOPE_RATIO_MIN + : invertAt(offset); if (designAt) { // 4축 배치(유입과 동일 풀이) + 유출 전용: 매몰-무교차 자리 금지(2026-08-22 ③). const placed = placePipeWall({ - autoOffset: outletAutoOffset, + autoOffset: outletZeroAnchor, outward: outletInfo.outward, height: outletWallSpec.height, - baseElevation0: invertAt(outletAutoOffset), + baseElevation0: outletZeroInvert, edgeElevation: outletInfo.edge.elevation_m, invertCap: inlet.elevation, adjust: adjOutlet, + defaultD: PIPE_WALL_DEFAULT_RUN_M, groundAt, limitOffset: outletInfo.limit, requireCrossing: true, @@ -457,8 +455,7 @@ export function computeCulvertLayout( /** 유출 관 하단 끝점 목표 = 벽 바닥 기준 0.5m 상단(기준선)과 **전면 경사선의 * 교차점**(2026-08-22 사용자 ① — 성토부선 시작 규칙과 같은 자리). */ const outletPipeEnd = (wall: WallLayout): OffsetPoint => { - // 기준은 **실제 바닥**(전면 근입으로 깊어진 값) +0.5 — 기준선(anchor)을 쓰면 - // 가파른 지반에서 바닥만 내려가고 관이 벽 위쪽에 떠 보인다(2026-08-22 사용자 ②). + // 기준 = 바닥 +0.5(= 관 invert 자리 — 2026-08-23부터 바닥은 invert−0.5 고정). const reference = wall.bottomBack.elevation + REVET_EMBED_DEPTH_M; return { offset: @@ -494,11 +491,13 @@ export function computeCulvertLayout( // ── 관 길이 m 단위 맞춤(2026-08-21 사용자 ①) — 긴 변 기준 올림, 모자란 만큼 유출 // 벽 자리를 관 축 방향 바깥으로 민다(벽 두께 0.45 고정). - const outletSlopeAt = outletSlopeFactory(outletInfo, invertAt, outletWallSpec.height); + // 정수 맞춤이 옮길 후보 자리도 같은 성토선을 탄다(2026-08-23 — 0점 기준 통일). + const outletSlopeAt = outletSlopeFactory(outletInfo, outletLineInvertAt, outletWallSpec.height); /** 유출 벽 두께 — 항상 0.45 고정(2026-08-21 사용자 확정). 폭으로 길이를 맞추지 않는다. */ const outletThickness = REVET_THICKNESS_M; // 손으로 민 벽은 정수 맞춤이 **절대 옮기지 않는다**(2026-08-22) — 표기만 올림한다. - const outletPinned = Math.abs(adjOutlet.x) > 1e-9 || Math.abs(adjOutlet.d) > 1e-9; + // d = null(기본 자리)만 자동으로 본다(2026-08-23 — d는 0점 기준 절대값). + const outletPinned = Math.abs(adjOutlet.x) > 1e-9 || adjOutlet.d != null; if (outletWall && !outletPinned) { const target = Math.ceil(cutLength(pipeCorners) - 1e-6); const rebuild = (): void => { @@ -535,21 +534,22 @@ export function computeCulvertLayout( // ① 벽 자리를 관 축 방향으로 gap 만큼 옮긴다(두께 불변 — 사용자 확정 우선순위). const moved = outletWallAnchor.offset + pipeAxis.axis.offset * gap; const movedSlope = outletSlopeAt(moved, outletThickness); - // 사면 역전·노견 안쪽으로는 옮기지 않는다 — 길이 맞춤보다 도면 성립이 먼저다. + // 사면 역전(벽 상단이 노견 위)·노견 안쪽으로는 옮기지 않는다 — 길이 맞춤보다 + // 도면 성립이 먼저다. 성토고 0(성토선 0점)까지는 허용(2026-08-23 사용자). if ( (moved - outletInfo.edge.offset_m) * outletInfo.outward < 0 || - outletInfo.edge.elevation_m - (invertAt(moved) + outletWallSpec.height) < FILL_MIN_RISE_M + outletInfo.edge.elevation_m - (outletLineInvertAt(moved) + outletWallSpec.height) < 0 ) { break; } // 자동 자리만 옮긴다(수동 벽은 이 루프에 아예 안 들어온다). 폭은 0.45 고정 — // 폭으로 흡수하면 미는 동안 벽 단면이 변한다(2026-08-21 사용자 지적). if ( - movedSlope.ratio >= FILL_SLOPE_RATIO_MIN && + movedSlope.ratio >= FILL_SLOPE_RATIO_MIN - 1e-6 && movedSlope.ratio <= FILL_SLOPE_RATIO_MAX && movedSlope.lengthM <= FILL_SLOPE_MAX_LENGTH_M + 1e-6 ) { - outletWallAnchor = { offset: moved, elevation: invertAt(moved) }; + outletWallAnchor = { offset: moved, elevation: outletLineInvertAt(moved) }; rebuild(); if (!outletWall) break; continue; @@ -573,7 +573,8 @@ export function computeCulvertLayout( if (basin) { applyBasinPipeFill(basin, pipeCorners.inlet.bottom, inletInfo.outward, groundAt); } - // 유입 기슭막이의 관 시작 접속선(2026-08-22 ②) — 위 0도 성토선 / 아래 0도 1m+절토선. + // 유입 기슭막이의 관 시작 접속선 — 위 0도 성토선 / 아래 0도 1m 공간 확보에서 끝 + // (2026-08-23 사용자 — 절토 경사 연장 삭제). const inletFill = !basin ? inletGroundConnector( pipeCorners.inlet.bottom, diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts index b7b030e6..8a45e345 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts @@ -363,31 +363,36 @@ export interface PipeWallPlacement { /** * 배관 기슭막이 4축 배치(2026-08-22 확정 규칙 묶음) — 유입·유출 공용. - * · x = 노견 연장 평행이동(안쪽 금지), d = 성토선 대각(위 한계 = 최소 성토고· - * invert 상한(토피/역경사)). + * · 기준(2026-08-23 사용자 확정): autoOffset·baseElevation0 = **성토선 길이 0 + * 지점**(벽 상단 = 노견)의 자리·invert. d는 그 점 기준 절대값(null = 기본 + * defaultD, 경사길이 4m 지점), 위 한계 = 성토선 0 지점(d ≥ 0)·invert 상한. + * · x = 노견 연장 평행이동(안쪽 금지). * · requireCrossing(유출): 매몰인데 0도 절토선이 원지반과 다시 못 만나는 자리는 - * 금지 — 자동 자리 쪽으로 0.05m씩 되돌린다(상하 먼저, 다음 좌우). + * 금지 — 위쪽으로 0.05m씩 되돌린다(상하 먼저, 다음 좌우). */ export function placePipeWall(input: { + /** 성토선 0 지점(벽 상단 = 노견)의 벽 자리(하단선 중점 offset). */ autoOffset: number; outward: number; height: number; - /** 자동 자리의 invert(= baseAt(autoOffset)). */ + /** 성토선 0 지점의 invert(= 노견 표고 − 높이). */ baseElevation0: number; edgeElevation: number; /** invert 상한 — 유입: 노면−관경−토피, 유출: 유입 invert(역경사 방지). */ invertCap: number; adjust: WallAdjust; + /** d 미지정(null) 시의 기본 자리 — 성토사면 경사길이 4m 지점의 수평 성분. */ + defaultD: number; groundAt: (offset: number) => number; limitOffset: number; requireCrossing: boolean; }): PipeWallPlacement { const top0 = input.baseElevation0 + input.height; const dLow = Math.max( - -FILL_SLOPE_RATIO_MIN * (input.edgeElevation - FILL_MIN_RISE_M - top0), + -FILL_SLOPE_RATIO_MIN * (input.edgeElevation - top0), -FILL_SLOPE_RATIO_MIN * (input.invertCap - (top0 - input.height)), ); - let d = Math.max(input.adjust.d, Math.ceil(dLow * 10) / 10); + let d = Math.max(input.adjust.d ?? input.defaultD, Math.ceil(dLow * 10) / 10); let x = Math.max(input.adjust.x, 0); if (input.requireCrossing) { const placeable = (anchorOffset: number, invertE: number): boolean => @@ -471,6 +476,7 @@ export function placeInletWall(input: { edgeElevation: number; invertCap: number; adjust: WallAdjust; + defaultD: number; groundAt: (offset: number) => number; limitOffset: number; /** 유출 쪽 수용 검사 인자 — 없으면 가드 없이 배치한다. */ diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts index e374e0e2..828e22cb 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts @@ -12,19 +12,23 @@ import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const"; * x = 좌우(m, + = 계류측 바깥) — 노견(또는 윗단 시작점 수평선)을 늘려 평행이동, * 성토선 물매 1:1.2 불변. * d = 상하(m, 수평 성분, + = 사면 아래) — 성토선을 타고 대각 이동. - * h = 높이(m, 계산용: 근입 0.5 위 기준선~상단). null = 기본값 - * (배관 2.5·일반 1.5, 재질 한계로 잘림). + * **배관(유입·유출) 벽**은 0점 = 성토선 길이 0 지점(벽 상단 = 노견)인 + * 절대값(2026-08-23 사용자 확정), null = 기본 자리(경사길이 4m 지점). + * 추가 벽은 종전대로 자동 자리 기준 상대값(null = 0). + * h = 높이(m). 배관 벽은 **순수 높이**(바닥~상단, 관 아래 0.5 포함 — + * 2026-08-23), 추가 벽은 계산용(근입 0.5 위 기준선~상단). null = 기본값 + * (배관 관경+1.0·일반 1.5, 재질 한계로 잘림). * m = 재질. null = 기본값(메쌓기). 한계: 메 2.0 / 찰 3.0 / 콘크리트 5.0. */ export interface WallAdjust { x: number; - d: number; + d: number | null; h: number | null; m: RevetMaterial | null; } /** 조작값 기본(전부 자동). */ -export const ZERO_ADJUST: WallAdjust = { x: 0, d: 0, h: null, m: null }; +export const ZERO_ADJUST: WallAdjust = { x: 0, d: null, h: null, m: null }; export interface OffsetPoint { offset: number; diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts index 757e346c..11dcacda 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts @@ -149,7 +149,7 @@ export function computeCardCulvert( "info", ); } - if (Math.abs(requested.d - applied.d) > 0.05) { + if (requested.d != null && applied.d != null && 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", @@ -170,7 +170,12 @@ export function computeCardCulvert( showToast(L("B06_Cross_Height_Floor"), "info"); } } - revetOffset.syncApplied(section.chainage_m, role, applied); + // d = null(기본 자리)은 null로 되돌려 담는다 — 적용 좌표를 그대로 저장하면 + // 자동 자리가 "사용자 지정"으로 굳어 지형·높이 변경을 따라가지 못한다. + revetOffset.syncApplied(section.chainage_m, role, { + ...applied, + d: requested.d == null ? null : applied.d, + }); } } 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 814313d2..aa5c8d46 100644 --- a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts @@ -337,6 +337,38 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan ); extraParts.controls.append(countMinus, countValue, countPlus); + // 키보드 방향키 = 십자 버튼(2026-08-23 사용자). 9키(집수정)도 **십자 축만** — + // 모서리(크기) 키는 대상 아님. 셀렉트 포커스 중엔 드롭다운 탐색에 양보한다. + // 창은 카드마다 새로 만들어지므로 문서 리스너는 show/hide에 맞춰 붙였다 떼고, + // 카드가 통째로 사라진 경우(root 분리)엔 스스로 해제한다. + const ARROW_SLOT: Record = { + ArrowUp: "up", + ArrowDown: "down", + ArrowLeft: "left", + ArrowRight: "right", + }; + const onArrowKey = (event: KeyboardEvent) => { + if (!root.isConnected) { + document.removeEventListener("keydown", onArrowKey); + return; + } + const slot = ARROW_SLOT[event.key]; + if (!slot || root.classList.contains("is-hidden")) return; + const focus = event.target as HTMLElement | null; + if (focus && (focus.tagName === "SELECT" || focus.tagName === "INPUT")) return; + for (const candidate of [basinRow, moveRow]) { + if (candidate.row.classList.contains("is-hidden")) continue; + const button = candidate.controls.querySelector( + `.b06-structure-panel__btn--${slot}`, + ); + if (button) { + event.preventDefault(); + button.click(); + return; + } + } + }; + const closeButton = makeButton("✕", L("B06_Cross_Revet_Close"), () => deps.close()); closeButton.classList.add("b06-structure-panel__close"); @@ -362,7 +394,9 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan show: (key) => { current = key; root.classList.toggle("is-hidden", key === null); + document.removeEventListener("keydown", onArrowKey); if (!key) return; + document.addEventListener("keydown", onArrowKey); const isExtra = key.startsWith("extra"); const movable = deps.canNudge(key); const structure = deps.structureFor(); @@ -431,11 +465,15 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan (Math.abs(adjust.x) > 1e-9 ? `${L(adjust.x > 0 ? "B06_Cross_Revet_Outward" : "B06_Cross_Revet_Inward")} ${Math.abs(adjust.x).toFixed(1)}m` : L("B06_Cross_Revet_Auto")); + // d = null → 자동(기본 자리). 숫자는 성토선 0점(벽 상단=노견) 기준 절대값 + // (배관 벽 — 2026-08-23). 0 = 성토선 0 지점. slopeLine.textContent = `${L("B06_Cross_Slope_Label")} ` + - (Math.abs(adjust.d) > 1e-9 - ? `${L(adjust.d > 0 ? "B06_Cross_Revet_DirDown" : "B06_Cross_Revet_DirUp")} ${Math.abs(adjust.d).toFixed(1)}m` - : L("B06_Cross_Revet_Auto")); + (adjust.d == null + ? L("B06_Cross_Revet_Auto") + : Math.abs(adjust.d) > 1e-9 + ? `${L(adjust.d > 0 ? "B06_Cross_Revet_DirDown" : "B06_Cross_Revet_DirUp")} ${Math.abs(adjust.d).toFixed(1)}m` + : "0m"); }, }; } diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index d40b136f..d55a83cb 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -37,6 +37,7 @@ import type { CrossWidthActions, ZoomPanState } from "./B06_Section_UI_Cross_Vie import { toeFitHalfWidth } from "./B06_Section_UI_Cross_Fit"; import { createCrossAxes, IDENTITY_VIEW } from "./B06_Section_UI_Cross_Axes"; import type { RevetHighlightSetter, RevetKey } from "./B06_Section_UI_Cross_Culvert"; +import { REVET_EMBED_DEPTH_M } 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 { @@ -164,8 +165,11 @@ export function createCrossSectionCard( let culvertExtraState = { canAdd: false, count: 0 }; /** 집수정 계류측 다단 상태(2026-08-22) — 조정창의 집수정 단 수 행이 쓴다. */ let culvertBasinExtraState = { canAdd: false, count: 0 }; - /** 마지막 계산의 벽 제원(높이·재질) — 조정창 표시·높이 조작 기준. */ + /** 마지막 계산의 벽 제원(높이·재질) — 조정창 표시·높이 조작 기준. + * 배관(유입·유출) 벽 높이는 **순수 높이**(바닥~상단 — 2026-08-23 사용자). */ let culvertWallSpecs = new Map(); + /** 마지막 계산의 실제 적용 d(상하) — d 미지정(자동) 벽의 ▲▼ 시작값. */ + let culvertAppliedD = new Map(); /** * 기슭막이를 고른다. 구조물 선택과 절·성토 면적 강조는 **같은 레벨**이라 하나를 * 고르면 다른 하나는 풀린다(2026-08-21 사용자). 또 구조물을 고르면 그 **측점 카드도 @@ -417,10 +421,23 @@ export function createCrossSectionCard( }; culvertWallSpecs = new Map(); const specOf = (wall: (typeof culvertLayout.walls)[number], key: RevetKey) => - culvertWallSpecs.set(key, { height: wall.height, material: wall.material }); + culvertWallSpecs.set(key, { + // 배관 벽은 순수 높이(바닥~상단 = 계산용 + 근입 0.5 — 2026-08-23 사용자). + height: wall.role === "extra" ? wall.height : wall.height + REVET_EMBED_DEPTH_M, + material: wall.material, + }); for (const wall of culvertLayout.walls) specOf(wall, wall.role as RevetKey); culvertLayout.extraWalls.forEach((wall, i) => specOf(wall, `extra${i}` as RevetKey)); culvertLayout.basinExtras.forEach((wall, i) => specOf(wall, `bextra${i}` as RevetKey)); + culvertAppliedD = new Map(); + culvertAppliedD.set("inlet", culvertLayout.revetShift.inlet.d ?? 0); + culvertAppliedD.set("outlet", culvertLayout.revetShift.outlet.d ?? 0); + culvertLayout.revetShift.extras.forEach((applied, i) => + culvertAppliedD.set(`extra${i}` as RevetKey, applied.d ?? 0), + ); + culvertLayout.revetShift.basinExtras.forEach((applied, i) => + culvertAppliedD.set(`bextra${i}` as RevetKey, applied.d ?? 0), + ); } if (culvertLayout) { setRevetActive = appendCulvertOverlay( @@ -534,15 +551,19 @@ export function createCrossSectionCard( 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 }; + revetOffset?.adjustFor(section, key) ?? { x: 0, d: null, h: null, m: null }; const panel = buildStructurePanel({ adjustFor: adjustOf, nudge: (key, screenDeltaM) => revetOffset?.update(section.chainage_m, key, { x: adjustOf(key).x + screenDeltaM * outwardOf(key), }), + // d 미지정(자동)이면 실제 적용 자리에서부터 움직인다(2026-08-23 — d는 0점 + // 기준 절대값이라 시작값이 필요하다). nudgeSlope: (key, deltaM) => - revetOffset?.update(section.chainage_m, key, { d: adjustOf(key).d + deltaM }), + revetOffset?.update(section.chainage_m, key, { + d: (adjustOf(key).d ?? culvertAppliedD.get(key) ?? 0) + deltaM, + }), nudgeHeight: (key, deltaM) => { const current = adjustOf(key); const base = current.h ?? heightOfWall(key); diff --git a/B06_Section/B06_Section_UI_Page_Station_Controls.ts b/B06_Section/B06_Section_UI_Page_Station_Controls.ts index cc33ee2e..86aa1667 100644 --- a/B06_Section/B06_Section_UI_Page_Station_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Station_Controls.ts @@ -135,7 +135,9 @@ export function createStationControls(deps: StationControlDeps): StationControls 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 }); + // 구세션 호환: d=0은 옛 "자동 자리" 의미 — 새 체계(절대 0 = 성토선 0점)로 + // 읽으면 벽이 노견까지 튀므로 null(기본 자리)로 옮긴다. + revetShifts.set(entry, { ...ZERO_ADJUST, ...value, d: value.d ? value.d : null }); } }); } catch { @@ -157,15 +159,17 @@ export function createStationControls(deps: StationControlDeps): StationControls 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; + Math.abs(value.x) < 1e-9 && value.d == null && 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 axisEq = (a: number | null, b: number | null): boolean => + a == null || b == null ? a === b : Math.abs(a - b) < 1e-9; const sameAdjust = (a: WallAdjust, b: WallAdjust): boolean => Math.abs(a.x - b.x) < 1e-9 && - Math.abs(a.d - b.d) < 1e-9 && + axisEq(a.d, b.d) && (a.h ?? null) === (b.h ?? null) && (a.m ?? null) === (b.m ?? null); @@ -189,7 +193,8 @@ export function createStationControls(deps: StationControlDeps): StationControls const current = revetShifts.get(key) ?? { ...ZERO_ADJUST }; const next: WallAdjust = { x: clampMove(patch.x ?? current.x), - d: clampMove(patch.d ?? current.d), + // d: null = 기본 자리(배관 벽 — 경사길이 4m 지점) 유지. + d: patch.d === undefined ? current.d : patch.d === null ? null : clampMove(patch.d), // 높이는 0.1 눈금 반올림만 — 재질 한계 절삭은 기하가 하고 되받는다. h: patch.h === undefined ? current.h : patch.h === null ? null : round1(patch.h), m: patch.m === undefined ? current.m : patch.m,