From f62ccf0058af61f46c68f367ca416a98bbb93d35 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 9 Oct 2026 15:07:02 +0900 Subject: [PATCH] =?UTF-8?q?feat(B06):=20=EC=84=B8=EC=9B=94=EA=B5=90=20?= =?UTF-8?q?=EB=B0=94=EB=8B=A5=ED=8C=90=20=ED=91=9C=EA=B3=A0=20=ED=95=98?= =?UTF-8?q?=EB=82=98(=EC=88=98=ED=8F=89)=20=C2=B7=20=EC=B8=A1=EB=B2=BD?= =?UTF-8?q?=EB=A7=88=EB=8B=A4=20=EC=A2=8C=EC=9A=B0=EB=A7=8C=20=C2=B7=20?= =?UTF-8?q?=EB=82=B4=EB=A6=BC=20=EC=96=91=EC=AA=BD=20=ED=95=9C=20=EA=B0=92?= =?UTF-8?q?=20=EC=95=84=EB=9E=98=EB=A1=9C=EB=A7=8C=20=C2=B7=20=EB=81=8C?= =?UTF-8?q?=EA=B8=B0=20=EC=88=98=EC=A7=81=20=C2=B7=20=EC=98=9B=20ford=5Fad?= =?UTF-8?q?just=20=ED=8F=89=EA=B7=A0=20=EC=98=AE=EA=B9=80=20=C2=B7=20?= =?UTF-8?q?=EB=B0=95=EC=8A=A4=20=ED=91=9C=EA=B3=A0=20=EC=95=84=EB=9E=98?= =?UTF-8?q?=EB=A1=9C=EB=A7=8C(139-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P --- .../B05_Profile_UI_Corridor_Structures.ts | 10 +-- ...B05_Profile_UI_Corridor_Structures_Hash.ts | 1 + B06_Section/B06_Section_Api_Types.ts | 2 + B06_Section/B06_Section_Schema.py | 2 + B06_Section/B06_Section_Structure_Layouts.ts | 7 +- .../B06_Section_UI_Cross_Body_Guard.ts | 7 +- B06_Section/B06_Section_UI_Cross_Box_Geom.ts | 5 +- B06_Section/B06_Section_UI_Cross_Box_Panel.ts | 2 +- B06_Section/B06_Section_UI_Cross_Ford.ts | 2 + B06_Section/B06_Section_UI_Cross_Ford_Geom.ts | 80 ++++++++++++++----- .../B06_Section_UI_Cross_Ford_Panel.ts | 32 +++++--- B06_Section/B06_Section_UI_Cross_View_Ford.ts | 14 ++-- .../B06_Section_UI_Page_Ford_Controls.ts | 43 +++++----- resources/tester/helper_108_drag_box_hit.cjs | 6 +- resources/tester/helper_139_3_ford_level.cjs | 60 ++++++++++++++ .../tester/helper_b06_adjust_single_form.cjs | 1 + resources/tester/test_107_ford_free.py | 6 +- resources/tester/test_108_drag_box_hit.py | 23 ++++-- resources/tester/test_139_3_ford_level.py | 55 +++++++++++++ resources/tester/test_79_guard_shape.py | 5 +- resources/tester/test_91_ground_free.py | 6 +- 21 files changed, 270 insertions(+), 99 deletions(-) create mode 100644 resources/tester/helper_139_3_ford_level.cjs create mode 100644 resources/tester/test_139_3_ford_level.py diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts index c85bb404a..34699e620 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts @@ -26,10 +26,7 @@ import { computeCulvertLayout } from "../B06_Section/B06_Section_UI_Cross_Culver import { restrictToSide, tierSpanOf } from "../B06_Section/B06_Section_UI_Cross_Culvert_Wire"; import { pipeWallThicknessM } from "../B06_Section/B06_Section_UI_Cross_Culvert_Const"; import type { CulvertLayout } from "../B06_Section/B06_Section_UI_Cross_Culvert"; -import { - computeFordLayout, - DEFAULT_FORD_WALL_ADJUST, -} from "../B06_Section/B06_Section_UI_Cross_Ford"; +import { computeFordLayout, fordAdjustOf } from "../B06_Section/B06_Section_UI_Cross_Ford"; import type { FordLayout } from "../B06_Section/B06_Section_UI_Cross_Ford"; import { boreBandAt, @@ -203,10 +200,7 @@ export function fordLayoutOf(section: CrossSection): FordLayout | null { if (!section.ford || !section.design) return null; const stored = section.design.ford_adjust; try { - return computeFordLayout(section, section.samples, { - inlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(stored?.inlet ?? {}) }, - outlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(stored?.outlet ?? {}) }, - }); + return computeFordLayout(section, section.samples, fordAdjustOf(stored)); } catch { return null; // 한 측점의 기하 실패가 3D 전체를 막으면 안 된다. } diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures_Hash.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures_Hash.ts index d99b5f4c2..4d38bc662 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures_Hash.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures_Hash.ts @@ -63,6 +63,7 @@ export function structureHashParts(section: CrossSection): Array { diff --git a/B06_Section/B06_Section_UI_Cross_Box_Geom.ts b/B06_Section/B06_Section_UI_Cross_Box_Geom.ts index 247e0dae7..fecab8916 100644 --- a/B06_Section/B06_Section_UI_Cross_Box_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Box_Geom.ts @@ -133,8 +133,9 @@ export function computeBoxLayout( const inletOnLeft = (section.uphill_side ?? "left") === "left"; const baseTop = Math.min(roadTopAt(leftEdge), roadTopAt(rightEdge), roadTopAt(0)) - box.cover_m; const bodyHeight = box.top_thickness_m + box.inner_height_m + box.slab_thickness_m; - // 본체는 늘 수평(79-7) — 좌 · 우 표고 조정이 달라도(옛 저장) 평균 하나로 섬. - const riseM = (adjust.left.riseM + adjust.right.riseM) / 2; + // 본체는 늘 수평(79-7) — 좌 · 우 표고 조정이 달라도(옛 저장) 평균 하나로 섬 · 아래로만(노면 − + // 복토 위 금지 · 옛 위 값은 자름 · 139-3). + const riseM = Math.min((adjust.left.riseM + adjust.right.riseM) / 2, 0); const sampleOffsets = groundSamples.map((sample) => sample.offset_m ?? 0); // 끝 절토 = 본선 절토 사면과 같은 복합경사(91-4) const cutSpec = structureCutSpec(section.design); diff --git a/B06_Section/B06_Section_UI_Cross_Box_Panel.ts b/B06_Section/B06_Section_UI_Cross_Box_Panel.ts index ed34f7a80..ac1f08861 100644 --- a/B06_Section/B06_Section_UI_Cross_Box_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Box_Panel.ts @@ -127,7 +127,7 @@ export function buildBoxPanel(deps: BoxPanelDeps, opts?: { dock?: boolean }): Bo slopeOutward: null, clamp: (left, down) => [ clampAxis(from.lengthM, left * lengthSign, 0, Infinity) * lengthSign, - -clampAxis(from.riseM, -down, -RISE_LIMIT_M, RISE_LIMIT_M), + -clampAxis(from.riseM, -down, -RISE_LIMIT_M, 0), // 아래로만(139-3) ], apply: (left, down) => deps.move(role, left, -down), }); diff --git a/B06_Section/B06_Section_UI_Cross_Ford.ts b/B06_Section/B06_Section_UI_Cross_Ford.ts index d084e7ced..fc9f3324c 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford.ts @@ -17,6 +17,8 @@ export { computeFordLayout, DEFAULT_FORD_ADJUST, DEFAULT_FORD_WALL_ADJUST, + fordAdjustOf, + fordDropOf, } from "./B06_Section_UI_Cross_Ford_Geom"; export type { FordAdjust, diff --git a/B06_Section/B06_Section_UI_Cross_Ford_Geom.ts b/B06_Section/B06_Section_UI_Cross_Ford_Geom.ts index 41b80dd4a..5c4bfb605 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford_Geom.ts @@ -4,13 +4,16 @@ * 백엔드 `section.ford` 제원을 좌표로 옮긴다(치수 결정 금지). * * 구성(2026-08-25 사용자 확정): - * · 양측 **측벽** = ㄴ형 집수정 부재를 재사용한다(`buildBasin`) — 좌우·상하(대각) - * 이동·계류측 성토부선이 집수정과 같은 규칙으로 따라온다. 단 **벽은 수직**이다 + * · 양측 **측벽** = ㄴ형 집수정 부재를 재사용한다(`buildBasin`) — 좌우 이동·계류측 + * 성토부선이 집수정과 같은 규칙으로 따라온다. 단 **벽은 수직**이다 * (`leanRatio: 0` — 2026-08-30 사용자 확정, 교본 그림 3-18). 집수정의 1:0.3 * 기움은 세월교 근거가 아니라 부재를 빌려 오며 딸려 온 값이었다. * · **바닥판** = 두 측벽 사이를 잇는 판 + 각 측 바깥으로 **날개벽 투영**(길이×cos각) * 만큼 뻗은 판. 집수정 내공 폭 자리에 그 연장량을 넣어 같은 기하로 만든다. * · **관**은 바닥판 상면(측벽 바깥면)에 안히고, 관 위 ~ 노면 아래는 **성토(노체)** 다. + * · **수평**(139-3 · 박스 「구체 수평」 준용) — 바닥판 표고 하나 = 낮은 쪽 노견 노면 − + * (관경 + 토피) − 내림(양쪽 한 값 · 아래로만) · 측벽마다 좌우만(도로 쪽 금지) · + * 측벽 높이 = 그 쪽 노견 노면 − 바닥판(결과). * · 구체는 월류 폭만큼 도로 방향으로 이어져 기준 측점 전후로 절반씩 걸친다 * (붙는 측점 판정은 백엔드 `attach_culvert_sets`). * 성토측이라도 기슭막이 세트(다단·재질)와는 연계하지 않는다. @@ -29,20 +32,32 @@ import { bermCut, buildExtrasAt } from "./B06_Section_UI_Cross_Culvert_Extra"; import { designInterpolator, groundInterpolator } from "./B06_Section_UI_Cross_Culvert_Solve"; import { structureCutSpec } from "./B06_Section_UI_Cross_Structure_Cut"; -/** 측벽 한 매의 조작값 — 집수정 9키와 같은 축(높이·좌우·상하 대각). */ +/** 측벽 한 매의 조작값 — 좌우만(139-3). `heightM` · `slopeM` 은 옛 저장(측벽마다 높이 · 1:1.2 + * 대각) 읽기 전용 — `dropM` 이 없을 때 바닥판 표고로 옮김(`fordDropOf`). */ export interface FordWallAdjust { - /** 벽 높이(m, 노견 상단 ~ 바닥판 상면). null = 기본 = 관경 + 최소 토피(원지반 안 봄 · 107-2). */ + /** 옛 벽 높이(m) — 새 값은 결과라 늘 null. */ heightM: number | null; /** 좌우(m, + = 노견 바깥). 안쪽으로는 못 간다. */ lateralM: number; - /** 상하(m, 수평 성분, + = 사면 아래) — 집수정과 같이 1:1.2 대각으로 내려간다. */ + /** 옛 상하(m, 수평 성분 · 1:1.2 대각) — 새 값은 늘 0. */ slopeM: number; } -/** 측점별 세월교 조정값 — 유입·유출 측벽을 따로 잡는다. */ +/** 측점별 세월교 조정값 — 측벽마다 좌우 · 바닥판 내림 하나. */ export interface FordAdjust { inlet: FordWallAdjust; outlet: FordWallAdjust; + /** 바닥판 내림(m · 수직 · ≥ 0 · 양쪽 한 값 · 139-3). null/없음 = 옛 저장 → `fordDropOf`. */ + dropM?: number | null; +} + +/** 저장값 → 조정값(빈 칸 기본) — 화면 · 3D · 서버가 같은 꼴로 읽음. */ +export function fordAdjustOf(stored?: Partial | null): FordAdjust { + return { + inlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(stored?.inlet ?? {}) }, + outlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(stored?.outlet ?? {}) }, + dropM: stored?.dropM ?? null, + }; } export const DEFAULT_FORD_WALL_ADJUST: FordWallAdjust = { @@ -54,6 +69,7 @@ export const DEFAULT_FORD_WALL_ADJUST: FordWallAdjust = { export const DEFAULT_FORD_ADJUST: FordAdjust = { inlet: DEFAULT_FORD_WALL_ADJUST, outlet: DEFAULT_FORD_WALL_ADJUST, + dropM: 0, }; export type FordWallRole = "inlet" | "outlet"; @@ -110,6 +126,27 @@ function minWallHeight(ford: FordSet): number { return ford.diameter_m + ford.min_cover_m; } +/** + * 바닥판 내림(m · ≥ 0) — 새 값(`dropM`) 그대로 · 옛 저장(측벽마다 높이 · 1:1.2 대각)은 측벽마다 + * 바닥판 표고의 평균으로 옮김(박스 79-7 꼴) · 기본 자리 위는 0 에서 자름. 노견이 없으면 0. + */ +export function fordDropOf(section: CrossSection, adjust: FordAdjust): number { + if (adjust.dropM != null) return Math.max(adjust.dropM, 0); + const ford = section.ford; + const edges = section.design?.road_edges; + if (!ford || !edges) return 0; + const roadTopAt = roadTopFactory(section, edges.left, edges.right); + const minH = minWallHeight(ford); + const inletOnLeft = (section.uphill_side ?? "left") === "left"; + const oldSlab = (role: FordWallRole): number => { + const edge = (role === "inlet") === inletOnLeft ? edges.left : edges.right; + const wall = adjust[role]; + return roadTopAt(edge.offset_m) - Math.max(wall.heightM ?? minH, minH) - wall.slopeM / 1.2; + }; + const base = Math.min(roadTopAt(edges.left.offset_m), roadTopAt(edges.right.offset_m)) - minH; + return Math.max(base - (oldSlab("inlet") + oldSlab("outlet")) / 2, 0); +} + /** 성토 채움 윗면을 만들 노면 표고 — 설계선이 있으면 그 곡선, 없으면 노견 직선. */ function roadTopFactory( section: CrossSection, @@ -150,6 +187,12 @@ export function computeFordLayout( const uphill = section.uphill_side ?? "left"; const inletOnLeft = uphill === "left"; const roadTopAt = roadTopFactory(section, edges.left, edges.right); + // 바닥판 표고 하나(139-3) — 낮은 쪽 노견 노면 − (관경 + 토피) − 내림 + const dropM = fordDropOf(section, adjust); + const slabTop = + Math.min(roadTopAt(edges.left.offset_m), roadTopAt(edges.right.offset_m)) - + minWallHeight(ford) - + dropM; const buildSide = (role: FordWallRole): FordSideLayout | null => { const onLeft = role === "inlet" ? inletOnLeft : !inletOnLeft; @@ -159,12 +202,9 @@ export function computeFordLayout( const wing = role === "inlet" ? ford.wing_in : ford.wing_out; const wanted = role === "inlet" ? adjust.inlet : adjust.outlet; const lateralM = Math.max(wanted.lateralM, 0); - const heightM = Math.max(wanted.heightM ?? minWallHeight(ford), minWallHeight(ford)); - // 벽 상단이 노견에 붙도록 바닥판 상면을 역산한다. 상하 이동은 buildBasin이 얹는다. - const anchor: OffsetPoint = { - offset: edge.offset_m, - elevation: roadTopAt(edge.offset_m) - heightM, - }; + // 측벽 높이 = 그 쪽 노견 노면 − 바닥판(결과) — 벽 상단이 노견 표고 · 좌우는 노견 수평 확장 + const heightM = roadTopAt(edge.offset_m) - slabTop; + const anchor: OffsetPoint = { offset: edge.offset_m, elevation: slabTop }; const built = buildBasin({ anchor, outward, @@ -180,7 +220,7 @@ export function computeFordLayout( innerWidthM: Math.max(wing.slab_extend_m, 0), innerHeightM: heightM, lateralM, - slopeM: wanted.slopeM, + slopeM: 0, }, memberThicknessM: ford.wall_thickness_m, floorThicknessM: ford.slab_thickness_m, @@ -200,11 +240,11 @@ export function computeFordLayout( adjusts: [], equalize: false, }); - // 접근선 — 좌우 이동분은 노견 표고 그대로 수평, 상하 이동분만 대각(1:1.2). + // 접근선 — 좌우 이동분은 노견 표고 그대로 수평(상하는 벽이 길어짐 · 139-3). const approach = basinApproachPoints( { offset_m: edge.offset_m, elevation_m: roadTopAt(edge.offset_m) }, outward, - { innerWidthM: 0, innerHeightM: 0, lateralM, slopeM: wanted.slopeM }, + { innerWidthM: 0, innerHeightM: 0, lateralM, slopeM: 0 }, { offset: built.trimOffset, elevation: built.trimElevation }, ); return { @@ -241,11 +281,7 @@ export function computeFordLayout( limitOffset, ).points : built.basin.cutLine, - adjust: { - heightM: wanted.heightM === null ? null : heightM, - lateralM, - slopeM: wanted.slopeM, - }, + adjust: { heightM: null, lateralM, slopeM: 0 }, }; }; @@ -268,7 +304,7 @@ export function computeFordLayout( Math.min(...sides.map((side) => side.parts[1].points[1].offset)), ); - // 관 — 각 측 바닥판 상면(벽 바깥면)에 안힌다. 양측 표고가 다르면 그만큼 기운다. + // 관 — 각 측 바닥판 상면(벽 바깥면)에 안힌다 · 바닥판 표고 하나라 수평(139-3). const pipeWall = pipeWallThicknessM(ford.pipe_kind, ford.diameter_m); const leftInvert = left.pipeEnd; const rightInvert = right.pipeEnd; @@ -315,6 +351,6 @@ export function computeFordLayout( minSlope: right.approach ? { points: right.approach } : undefined, maxSlope: left.approach ? { points: left.approach } : undefined, }, - adjust: { inlet: inlet.adjust, outlet: outlet.adjust }, + adjust: { inlet: inlet.adjust, outlet: outlet.adjust, dropM }, }; } diff --git a/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts b/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts index 0b31dd209..9a869638b 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts @@ -6,7 +6,8 @@ * `_Cross_Panel_Base.ts`)을 쓴다 — 머리줄(제목·닫기), 2행 항목 구조, * **십자 조작 세트는 창 맨 아래**, 키보드 방향키 제어 포함(2026-08-25 일원화). * - * 조작: 측벽 좌우(노견 확장) · 측벽 상하(1:1.2 대각) — 십자 · 그림 끌기. 측벽 높이 줄 없음(107-3) · + * 조작: 측벽 좌우(노견 확장 · 측벽마다) · 바닥판 상하(수직 · 양쪽 한 값 · 아래로만 · 139-3) — 십자 · + * 그림 끌기. 측벽 높이 = 결과(읽기 줄만) · * 관경 · 수량 · 날개벽 제원은 폼 칸 하나(계획서 24-4a). * 바닥판 연장은 날개벽 길이×cos(각도)가 정하므로 읽기 전용으로 보여 준다. * ========================================================================== */ @@ -32,11 +33,13 @@ export interface FordPanelDeps { heightFor: (role: FordWallRole) => number; /** 좌우: 화면 좌(+)/우(−)로 미는 양(m). 부호 환산은 카드가 한다. */ nudge: (role: FordWallRole, screenDeltaM: number) => void; - /** 상하: 1:1.2 사면을 타는 대각 이동(수평 성분 m, + = 사면 아래). */ + /** 상하: 바닥판 수직 내림(m, + = 아래 · 양쪽 한 값 · 139-3). */ nudgeSlope: (role: FordWallRole, deltaM: number) => void; + /** 지금 바닥판 내림(m · ≥ 0). */ + dropFor: () => number; /** 사면 바깥이 화면 왼쪽이면 1 · 오른쪽이면 −1(끌기 축 나누기 · 108-2). */ outwardOf: (role: FordWallRole) => number; - /** 그림 끌기 — 좌우(화면 좌 +) · 상하(사면) 를 한 번에 반영. */ + /** 그림 끌기 — 좌우(화면 좌 +) · 상하(수직 내림) 를 한 번에 반영. */ move: (role: FordWallRole, screenDeltaM: number, slopeDeltaM: number) => void; /** 자동 자리로 되돌린다(세 축 모두). */ reset: (role: FordWallRole) => void; @@ -88,7 +91,7 @@ export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }): dpadButton( "▲", "up", - "사면 위로(1:1.2 대각)", + "바닥판 위로(양쪽 같이 · 기본 자리까지)", act((role) => deps.nudgeSlope(role, -STEP_M)), ), dpadButton( @@ -107,7 +110,7 @@ export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }): dpadButton( "▼", "down", - "사면 아래로(1:1.2 대각)", + "바닥판 아래로(양쪽 같이)", act((role) => deps.nudgeSlope(role, STEP_M)), ), ); @@ -119,8 +122,8 @@ export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }): const arrows = opts?.dock ? { attach: (): void => undefined, detach: (): void => undefined } : bindArrowKeys(root, () => [moveRow.controls]); - // 그림 위 끌기 손잡이 — 십자와 같은 걸음 · 좌우 + 1:1.2 사면 두 축 함께(108-2). - // 한계 = 좌우 0 ~ 10 m(노견 안쪽 금지) · 상하 ±10 m. + // 그림 위 끌기 손잡이 — 십자와 같은 걸음 · 좌우 + 수직 두 축 함께(108-2 · 139-3). + // 한계 = 좌우 0 ~ 10 m(노견 안쪽 금지) · 내림 0 ~ 10 m(기본 자리 위 금지). const drag = opts?.dock ? { attach: (): void => undefined, detach: (): void => undefined } : bindDragHandle(root, { @@ -131,10 +134,10 @@ export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }): const outward = deps.outwardOf(role); return snapAxisDrag(leftM, downM, { stepM: STEP_M, - slopeOutward: outward, + slopeOutward: null, clamp: (left, down) => [ clampAxis(from.lateralM, left * outward, 0, MOVE_LIMIT_M) * outward, - clampAxis(from.slopeM, down, -MOVE_LIMIT_M, MOVE_LIMIT_M), + clampAxis(deps.dropFor(), down, 0, MOVE_LIMIT_M), ], apply: (left, down) => deps.move(role, left, down), }); @@ -147,9 +150,8 @@ export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }): const role = current === "inlet" ? "유입" : "유출"; title.textContent = `세월교 ${role} 측벽`; const adjust: FordWallAdjust = deps.adjustFor(current); - heightLine.textContent = - `높이 ${deps.heightFor(current).toFixed(2)}m` + (adjust.heightM === null ? " (자동)" : ""); - moveLine.textContent = `좌우 ${adjust.lateralM.toFixed(1)}m · 상하 ${adjust.slopeM.toFixed(1)}m`; + heightLine.textContent = `높이 ${deps.heightFor(current).toFixed(2)}m`; + moveLine.textContent = `좌우 ${adjust.lateralM.toFixed(1)}m · 내림 ${deps.dropFor().toFixed(1)}m`; slabLine.textContent = `바닥판 ${deps.slabLengthM().toFixed(2)}m`; } @@ -177,7 +179,11 @@ export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }): /** 측점별 세월교 조작값 제어 — 페이지(세션·정본)가 구현한다. */ export interface FordControl { adjustFor: (chainageM: number) => FordAdjust; - update: (chainageM: number, role: FordWallRole, patch: Partial) => void; + update: ( + chainageM: number, + role: FordWallRole, + patch: { lateralM?: number; dropM?: number }, + ) => void; reset: (chainageM: number, role: FordWallRole) => void; /** 관경(mm)·수량(련)·관종·월류 폭 저장 — B05 정본(`pipe_points`)으로 간다. * 좌측 「구조물 배치」 폼만 보낸다(조정창에 칸 없음 · 계획서 24-4a). */ diff --git a/B06_Section/B06_Section_UI_Cross_View_Ford.ts b/B06_Section/B06_Section_UI_Cross_View_Ford.ts index 66e70ab11..010673cd2 100644 --- a/B06_Section/B06_Section_UI_Cross_View_Ford.ts +++ b/B06_Section/B06_Section_UI_Cross_View_Ford.ts @@ -35,16 +35,16 @@ export function fordPanelDeps(context: FordPanelContext): FordPanelDeps { ford.update(chainage, role, { lateralM: ford.adjustFor(chainage)[role].lateralM + screenDeltaM * outwardOf(section, role), }), + // 상하 = 바닥판 내림(양쪽 한 값 · 아래로만 · 139-3) nudgeSlope: (role, deltaM) => - ford.update(chainage, role, { - slopeM: ford.adjustFor(chainage)[role].slopeM + deltaM, - }), + ford.update(chainage, role, { dropM: (ford.adjustFor(chainage).dropM ?? 0) + deltaM }), + dropFor: () => ford.adjustFor(chainage).dropM ?? 0, outwardOf: (role) => outwardOf(section, role), - move: (role, screenDeltaM, slopeDeltaM) => { - const from = ford.adjustFor(chainage)[role]; + move: (role, screenDeltaM, dropDeltaM) => { + const from = ford.adjustFor(chainage); ford.update(chainage, role, { - lateralM: from.lateralM + screenDeltaM * outwardOf(section, role), - slopeM: from.slopeM + slopeDeltaM, + lateralM: from[role].lateralM + screenDeltaM * outwardOf(section, role), + dropM: (from.dropM ?? 0) + dropDeltaM, }); }, reset: (role) => ford.reset(chainage, role), diff --git a/B06_Section/B06_Section_UI_Page_Ford_Controls.ts b/B06_Section/B06_Section_UI_Page_Ford_Controls.ts index bf422f71b..27aa43bde 100644 --- a/B06_Section/B06_Section_UI_Page_Ford_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Ford_Controls.ts @@ -15,7 +15,7 @@ import { wingSlabExtendM, } from "@util/common_util_culvert_sets"; import type { CrossDesign, CrossSection } from "./B06_Section_Api_Fetch"; -import { DEFAULT_FORD_WALL_ADJUST } from "./B06_Section_UI_Cross_Ford"; +import { DEFAULT_FORD_WALL_ADJUST, fordAdjustOf, fordDropOf } from "./B06_Section_UI_Cross_Ford"; import type { FordAdjust, FordPick, FordWallAdjust } from "./B06_Section_UI_Cross_Ford"; import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel"; import { DEFAULT_BOX_SIDE_ADJUST } from "./B06_Section_UI_Cross_Box"; @@ -141,10 +141,7 @@ export function createFordControls(deps: FordControlDeps): FordControls { FordAdjust >; Object.entries(parsed).forEach(([chainage, value]) => - fordAdjustments.set(chainage, { - inlet: { ...DEFAULT_FORD_WALL_ADJUST, ...value.inlet }, - outlet: { ...DEFAULT_FORD_WALL_ADJUST, ...value.outlet }, - }), + fordAdjustments.set(chainage, fordAdjustOf(value)), ); } catch { /* 손상된 세션 값은 기본값으로 대체. */ @@ -157,14 +154,18 @@ export function createFordControls(deps: FordControlDeps): FordControls { window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(fordAdjustments))); } + // 옛 저장(측벽마다 높이 · 대각)은 읽을 때 바닥판 내림 하나로 옮김(139-3 · `fordDropOf`) const fordAdjustAt = (chainageM: number): FordAdjust => { - const stored = sectionAt(chainageM)?.design?.ford_adjust; - return ( - fordAdjustments.get(chainageM.toFixed(2)) ?? { - inlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(stored?.inlet ?? {}) }, - outlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(stored?.outlet ?? {}) }, - } - ); + const section = sectionAt(chainageM); + const adjust = + fordAdjustments.get(chainageM.toFixed(2)) ?? fordAdjustOf(section?.design?.ford_adjust); + if (adjust.dropM != null || !section) return adjust; + const side = (wall: FordWallAdjust): FordWallAdjust => ({ ...wall, heightM: null, slopeM: 0 }); + return { + inlet: side(adjust.inlet), + outlet: side(adjust.outlet), + dropM: fordDropOf(section, adjust), + }; }; const writeFord = (chainageM: number, next: FordAdjust): void => { @@ -176,22 +177,20 @@ export function createFordControls(deps: FordControlDeps): FordControls { const fordControl: FordControl = { adjustFor: fordAdjustAt, + // 측벽마다 좌우(도로 쪽 금지) · 바닥판 내림은 양쪽 한 값 · 아래로만(139-3) update: (chainageM, role, patch) => { const current = fordAdjustAt(chainageM); - const wall: FordWallAdjust = { ...current[role], ...patch }; + const lateralM = patch.lateralM ?? current[role].lateralM; writeFord(chainageM, { ...current, - [role]: { - // 높이는 0.1m 눈금, 하한(관경+토피)은 기하가 다시 잡는다. - heightM: wall.heightM === null ? null : Math.max(round1(wall.heightM), 0), - lateralM: Math.max(0, clampMove(wall.lateralM)), - slopeM: clampMove(wall.slopeM), - }, + [role]: { heightM: null, lateralM: Math.max(0, clampMove(lateralM)), slopeM: 0 }, + dropM: Math.max(0, round1(clampMove(patch.dropM ?? current.dropM ?? 0))), }); }, + // 되돌림 = 그 측벽 좌우 + 바닥판 내림(양쪽 한 값이라 함께 0) reset: (chainageM, role) => { const current = fordAdjustAt(chainageM); - writeFord(chainageM, { ...current, [role]: { ...DEFAULT_FORD_WALL_ADJUST } }); + writeFord(chainageM, { ...current, [role]: { ...DEFAULT_FORD_WALL_ADJUST }, dropM: 0 }); }, setPipe: (chainageM, patch) => { const spec = sectionAt(chainageM)?.ford; @@ -380,8 +379,8 @@ export function createBoxControls(deps: FordControlDeps): { update: (chainageM, role, patch) => { const current = adjustAt(chainageM); const side: BoxSideAdjust = { ...current[role], ...patch }; - // 본체는 늘 수평(79-7) — 표고는 좌 · 우가 한 값. - const riseM = clampMove(side.riseM); + // 본체는 늘 수평(79-7) — 표고는 좌 · 우가 한 값 · 아래로만(노면 − 복토 위 금지 · 139-3). + const riseM = Math.min(clampMove(side.riseM), 0); const other = role === "left" ? "right" : "left"; const next: BoxAdjust = { ...current, [other]: { ...current[other], riseM } }; // 길이는 바깥으로만 — 안쪽으로 줄이면 성토선 물매가 깨진다. diff --git a/resources/tester/helper_108_drag_box_hit.cjs b/resources/tester/helper_108_drag_box_hit.cjs index aee466c97..1ab75d93f 100644 --- a/resources/tester/helper_108_drag_box_hit.cjs +++ b/resources/tester/helper_108_drag_box_hit.cjs @@ -252,14 +252,15 @@ out.box.left = boxCase("left", { lengthM: 0, riseM: 0 }, 0.47, 0.33); out.box.right = boxCase("right", { lengthM: 1, riseM: 0 }, -0.47, -0.21); out.box.shorten = boxCase("left", { lengthM: 0.2, riseM: 9.9 }, -0.9, -0.5); -// ── 세월교 창 — 좌우 + 1:1.2 사면 · 0.1 걸음 ───────────────────────────────────── -function fordCase(outward, adjust, leftM, downM) { +// ── 세월교 창 — 좌우 + 수직 내림(양쪽 한 값 · 아래로만 · 139-3) · 0.1 걸음 ───────────── +function fordCase(outward, adjust, leftM, downM, dropM = 0) { const moves = []; const panel = buildFordPanel({ adjustFor: () => adjust, heightFor: () => 1.5, nudge: () => {}, nudgeSlope: () => {}, + dropFor: () => dropM, outwardOf: () => outward, move: (r, screen, slope) => moves.push({ role: r, screen: r3(screen), slope: r3(slope) }), reset: () => {}, @@ -278,6 +279,7 @@ function fordCase(outward, adjust, leftM, downM) { out.ford = { diag: fordCase(1, { heightM: null, lateralM: 0, slopeM: 0 }, 0.53, 0.27), inward: fordCase(-1, { heightM: null, lateralM: 0.2, slopeM: 0 }, 0.9, 0), + up: fordCase(1, { heightM: null, lateralM: 0, slopeM: 0 }, 0, -0.5, 0.2), }; // ── BOX 판정면 — 좌측 끝 날개벽(에이프런 4 → 6) · 우측 끝 날개벽 없음 ─────────── diff --git a/resources/tester/helper_139_3_ford_level.cjs b/resources/tester/helper_139_3_ford_level.cjs new file mode 100644 index 000000000..d1c9e6c21 --- /dev/null +++ b/resources/tester/helper_139_3_ford_level.cjs @@ -0,0 +1,60 @@ +/* 139-3 세월교 수평 헬퍼 — `_Cross_Ford_Geom`(computeFordLayout · fordDropOf)를 그 자리에서 트랜스파일해 + * 73-4 픽스처 세월교(173.087)로 돌림. 짝 = `test_139_3_ford_level.py`. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); +const noop = new Proxy({}, { get: () => () => "" }); + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (request.startsWith("@ui/") || request.startsWith("@config/")) return noop; + if (request.startsWith("@util/")) + return loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`)); + return loadTs(path.join(path.dirname(full), `${request}.ts`)); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const { computeFordLayout, fordAdjustOf } = loadTs( + path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Ford_Geom.ts"), +); +const sections = JSON.parse( + fs.readFileSync(path.join(__dirname, "fixtures", "b06_73_4_body_guard_sections.json"), "utf8"), +).cross_sections; +const ford = sections.find((s) => s.ford); +const r3 = (v) => Math.round(v * 1000) / 1000; +const run = (stored) => { + const layout = computeFordLayout(ford, ford.samples, fordAdjustOf(stored)); + return { + slab: layout.sides.map((s) => r3(s.slabTopElevation)), + pipeEnds: layout.sides.map((s) => r3(s.pipeEnd.elevation)), + tops: layout.sides.map((s) => r3(s.top.elevation)), + heights: layout.sides.map((s) => r3(s.heightM)), + wallX: layout.sides.map((s) => r3(s.top.offset)), + dropM: r3(layout.adjust.dropM), + }; +}; +const wall = (o) => ({ heightM: null, lateralM: 0, slopeM: 0, ...o }); +process.stdout.write( + JSON.stringify({ + base: run(null), + drop: run({ inlet: wall(), outlet: wall(), dropM: 0.4 }), + up: run({ inlet: wall(), outlet: wall(), dropM: -1 }), + lateral: run({ inlet: wall({ lateralM: 1 }), outlet: wall({ lateralM: -2 }), dropM: 0 }), + // 옛 저장 — 유입 높이 + 0.6 · 유출 대각 1.2(= 수직 1.0) → 평균 내림 0.8 언저리 + old: run({ inlet: wall({ heightM: 2.1 }), outlet: wall({ slopeM: 1.2 }) }), + }), +); diff --git a/resources/tester/helper_b06_adjust_single_form.cjs b/resources/tester/helper_b06_adjust_single_form.cjs index 6e97bc8ed..4e71e340e 100644 --- a/resources/tester/helper_b06_adjust_single_form.cjs +++ b/resources/tester/helper_b06_adjust_single_form.cjs @@ -188,6 +188,7 @@ const fordDeps = { heightFor: () => 1.5, nudge: () => {}, nudgeSlope: () => {}, + dropFor: () => 0, reset: () => {}, slabLengthM: () => 7.6, close: () => {}, diff --git a/resources/tester/test_107_ford_free.py b/resources/tester/test_107_ford_free.py index 5fc90acd0..cad5d2664 100644 --- a/resources/tester/test_107_ford_free.py +++ b/resources/tester/test_107_ford_free.py @@ -60,7 +60,11 @@ def test_세월교_원지반을_바꿔도_바닥_그대로(out: dict, variant: s for name, shape in runs.items(): assert shape == runs["base"], name base = runs["base"] - assert all(side["depth"] == pytest.approx(base["pipeCover"]) for side in base["sides"]) + # 바닥판 수평(139-3) — 표고 하나 · 낮은 노견 쪽 깊이 = 관경 + 토피 · 높은 쪽은 그보다 깊음 + sides = base["sides"] + assert len({side["slabTop"] for side in sides}) == 1 + assert min(side["depth"] for side in sides) == pytest.approx(base["pipeCover"]) + assert all(side["depth"] >= base["pipeCover"] - 1e-6 for side in sides) @pytest.mark.parametrize("variant", ["guards", "bare"]) diff --git a/resources/tester/test_108_drag_box_hit.py b/resources/tester/test_108_drag_box_hit.py index d1e417e01..78a1ba35d 100644 --- a/resources/tester/test_108_drag_box_hit.py +++ b/resources/tester/test_108_drag_box_hit.py @@ -82,19 +82,26 @@ def test_걸음_반도_안_끌면_반영_없음(out) -> None: def test_BOX_끌기는_길이_표고_함께(out) -> None: assert out["box"]["left"]["move"] == {"role": "left", "screen": 0.5, "rise": -0.3} assert out["box"]["left"]["snap"]["leftM"] == 0.5 - assert out["box"]["right"]["move"] == {"role": "right", "screen": -0.5, "rise": 0.2} + # 위로는 기본 자리(노면 − 복토)까지만(139-3) — 0 에서 0.2 올리기 = 그대로 + assert out["box"]["right"]["move"] == {"role": "right", "screen": -0.5, "rise": 0} -def test_BOX_한계_길이_0_표고_10(out) -> None: - """안으로 줄이면 길이 0 에서 · 표고는 +10 m 에서 멈춤.""" - assert out["box"]["shorten"]["move"] == {"role": "left", "screen": -0.2, "rise": 0.1} +def test_BOX_한계_길이_0_표고_0(out) -> None: + """안으로 줄이면 길이 0 에서 · 표고는 0(기본 자리) 위로 못 감 — 옛 +9.9 는 0 으로 내려옴(139-3).""" + assert out["box"]["shorten"]["move"] == {"role": "left", "screen": -0.2, "rise": -9.9} -def test_세월교_끌기는_좌우_사면_함께(out) -> None: - """사면 d 0.3 · 좌우 0.2(0.1 걸음) — 미리 보기 = 좌 0.2 + 0.3 · 아래 0.3/1.2.""" +def test_세월교_끌기는_좌우_수직_함께(out) -> None: + """좌우 0.5 · 바닥판 내림 0.3(수직 · 0.1 걸음 · 139-3) — 두 축이 따로.""" case = out["ford"]["diag"] - assert case["move"] == {"role": "inlet", "screen": 0.2, "slope": 0.3} - assert case["snap"] == {"leftM": 0.5, "downM": 0.25, "text": "◀ 0.2m · ▼ 0.3m"} + assert case["move"] == {"role": "inlet", "screen": 0.5, "slope": 0.3} + assert case["snap"] == {"leftM": 0.5, "downM": 0.3, "text": "◀ 0.5m · ▼ 0.3m"} + + +def test_세월교_기본_자리_위로는_못_감(out) -> None: + """내림 0.2 에서 0.5 올려도 0(기본 자리)까지만(139-3).""" + case = out["ford"]["up"] + assert case["move"] == {"role": "inlet", "screen": 0, "slope": -0.2} def test_세월교_노견_안쪽은_0_에서_멈춤(out) -> None: diff --git a/resources/tester/test_139_3_ford_level.py b/resources/tester/test_139_3_ford_level.py new file mode 100644 index 000000000..7acfc6864 --- /dev/null +++ b/resources/tester/test_139_3_ford_level.py @@ -0,0 +1,55 @@ +"""139-3 세월교 수평 — 바닥판 표고 하나 · 좌우만(도로 쪽 금지) · 내림 = 양쪽 한 값 · 아래로만 · 옛 값 옮김. + +화면 TS(`_Cross_Ford_Geom`)를 그 자리에서 돌린 값(`helper_139_3_ford_level.cjs` · 73-4 픽스처 세월교 173.087). +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +HELPER = Path(__file__).parent / "helper_139_3_ford_level.cjs" +NODE_MODULES = ROOT / "config" / "node_modules" + +pytestmark = pytest.mark.skipif(not NODE_MODULES.is_dir(), reason="프론트엔드 의존성 미설치") + + +@pytest.fixture(scope="module") +def out() -> dict: + done = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HELPER)], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8" + ) + assert done.returncode == 0, done.stderr + return json.loads(done.stdout) + + +def test_바닥판_하나_관_수평_벽높이는_결과(out: dict) -> None: + base = out["base"] + assert base["slab"][0] == base["slab"][1] == base["pipeEnds"][0] == base["pipeEnds"][1] + # 낮은 노견 쪽 = 관경 + 토피(1.5) · 높은 쪽은 노견 차만큼 더 김 · 벽 윗면 = 그 쪽 노견 + assert min(base["heights"]) == pytest.approx(1.5) + assert base["heights"][1] - base["heights"][0] == pytest.approx( + base["tops"][1] - base["tops"][0], abs=1e-3 + ) + + +def test_내림은_양쪽_한_값_아래로만(out: dict) -> None: + assert out["drop"]["slab"] == [pytest.approx(out["base"]["slab"][0] - 0.4)] * 2 + assert out["drop"]["tops"] == out["base"]["tops"] # 벽 윗면은 노견에 그대로 + assert out["up"] == out["base"] # 기본 자리 위 금지 — 음수 내림 = 0 + + +def test_좌우만_도로_쪽_금지(out: dict) -> None: + lat, base = out["lateral"], out["base"] + assert lat["slab"] == base["slab"] + assert abs(lat["wallX"][0] - base["wallX"][0]) == pytest.approx(1.0) # 바깥 1 m + assert lat["wallX"][1] == base["wallX"][1] # 안쪽 −2 = 0 에서 멈춤 + + +def test_옛_값은_평균_내림으로_옮김(out: dict) -> None: + """유입 높이 2.1(기본 1.5 + 0.6) · 유출 대각 1.2(수직 1.0) + 노견 차 0.12 → (0.6 + 0.88) / 2 = 0.74.""" + old = out["old"] + assert old["dropM"] == pytest.approx(0.74, abs=1e-3) + assert old["slab"][0] == old["slab"][1] diff --git a/resources/tester/test_79_guard_shape.py b/resources/tester/test_79_guard_shape.py index 81f6bb302..54e8e5ad8 100644 --- a/resources/tester/test_79_guard_shape.py +++ b/resources/tester/test_79_guard_shape.py @@ -105,11 +105,12 @@ def test_유입_수평공간_시작은_본체_바닥(shape: dict) -> None: def test_관_보호공_기본_자리는_제일_위(shape: dict) -> None: pipe = shape["pipe"] assert pipe is not None - assert pipe["d"]["inlet"] == pytest.approx(0, abs=1e-6) + # 벽 윗면 = 관 윗면(139-1) · 관 위 토피 0.5 → 유입 벽 윗면 = 노견 − 0.5 = 사면 d 0.6(1:1.2) + assert pipe["d"]["inlet"] == pytest.approx(0.6, abs=1e-6) # 유출은 관 m 단위 맞춤이 사면 아래로 민 만큼만 내려감(종전 기본 = 경사길이 4 m · d 3.07) assert pipe["d"]["outlet"] < 1.0 inlet = next(w for w in pipe["walls"] if w["role"] == "inlet") - assert inlet["topBelowEdge"] == pytest.approx(0, abs=0.05) + assert inlet["topBelowEdge"] == pytest.approx(0.5, abs=0.05) assert float(pipe["lengthM"]).is_integer() diff --git a/resources/tester/test_91_ground_free.py b/resources/tester/test_91_ground_free.py index 821be3e19..efde76d43 100644 --- a/resources/tester/test_91_ground_free.py +++ b/resources/tester/test_91_ground_free.py @@ -103,8 +103,12 @@ def test_흙막이_수평공간_뒤_절토_복합경사(fit: dict) -> None: def test_박스_끝_세월교_측벽_끝_절토_복합경사(fit: dict) -> None: + # 세월교 바닥판 수평(139-3) — 높은 노견 쪽 끝은 암까지 안 닿는 짧은 토사 절토일 수 있음 for cuts in (fit["boxEnd"], fit["fordEnd"]): - assert cuts and all(_knee(cut) and cut["endGap"] < 0.01 for cut in cuts) + assert cuts and all(cut["endGap"] < 0.01 for cut in cuts) + assert any(_knee(cut) for cut in cuts) + rock = [c for c in cuts if any(leg["ratio"] == 0.4 for leg in c["legs"])] + assert all(_knee(cut) for cut in rock) def test_절토_쪽_배관_유출_벽_뒤_절토사면_살아남(fit: dict) -> None: