From 9044087009099dc7e8bb3026f3a3394dfa39f01f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 08:54:27 +0900 Subject: [PATCH] =?UTF-8?q?feat(B06):=20=EC=84=B8=EC=9B=94=EA=B5=90=20?= =?UTF-8?q?=C2=B7=20=EB=B0=95=EC=8A=A4=20=ED=9D=99=EB=A7=89=EC=9D=B4=20?= =?UTF-8?q?=E2=80=94=20=EB=B3=B8=EC=B2=B4=EA=B0=80=20=ED=9D=99=EB=A7=89?= =?UTF-8?q?=EC=9D=B4=EB=A5=BC=20=EB=94=B0=EB=9D=BC=EA=B0=90=20=C2=B7=20?= =?UTF-8?q?=EC=84=B8=EC=9B=94=EA=B5=90=20=EA=B4=80=201=20m=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84(80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 세월교 흙막이 쪽 관 끝 = 흙막이 전면(관 invert 높이) · 바닥판 끝 = 배면 · 라벨 관 길이 - 세월교 관 길이 1 m 단위 — 기본 자리 올림만큼 흙막이를 바깥으로(유출 먼저) · 좌우 걸음 1 m - 박스 흙막이를 바깥으로 밀면 상판 · 저판 · 내공이 배면까지 따라감 - 흙막이 ↺ 가 캐시 사본(design.revet_adjust)까지 비움 — 옮긴 자리가 되살아나던 것 - 시험: test_80_ford_pipe(새) · test_73_4_body_guard 유출 벽 1 m 맞춤 반영 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C99ToP6eDSTba2XZRUumb8 --- .../B06_Section_UI_Cross_Body_Guard.ts | 97 +++++++++++++- .../B06_Section_UI_Cross_Culvert_Geom.ts | 9 +- B06_Section/B06_Section_UI_Cross_View.ts | 1 + .../B06_Section_UI_Cross_View_Structure.ts | 7 +- .../B06_Section_UI_Page_Station_Controls.ts | 4 +- ...8_plan_80_세월교_박스_흙막이_본체따라감.md | 13 ++ resources/tester/helper_80_ford_pipe.cjs | 120 ++++++++++++++++++ resources/tester/test_73_4_body_guard.py | 3 +- resources/tester/test_80_ford_pipe.py | 65 ++++++++++ 9 files changed, 305 insertions(+), 14 deletions(-) create mode 100644 docs/raw/plans/2026-10-08_plan_80_세월교_박스_흙막이_본체따라감.md create mode 100644 resources/tester/helper_80_ford_pipe.cjs create mode 100644 resources/tester/test_80_ford_pipe.py diff --git a/B06_Section/B06_Section_UI_Cross_Body_Guard.ts b/B06_Section/B06_Section_UI_Cross_Body_Guard.ts index fcafb2c66..be2f43718 100644 --- a/B06_Section/B06_Section_UI_Cross_Body_Guard.ts +++ b/B06_Section/B06_Section_UI_Cross_Body_Guard.ts @@ -17,6 +17,7 @@ import { FILL_SLOPE_RATIO_MIN } from "./B06_Section_UI_Cross_Culvert_Const"; import type { CulvertDesignTrim, CulvertLayout, + OffsetPoint, WallAdjust, WallLayout, } from "./B06_Section_UI_Cross_Culvert_Types"; @@ -26,11 +27,13 @@ import { buildRevetWallGeometry } from "./B06_Section_UI_Cross_Wall"; type Role = "inlet" | "outlet"; -/** 본체 끝 — 전면 자리(offset) · 윗면(top) · 바닥(bottom) 표고 · 흙막이 기본 순수 높이(79-7 · 79b). */ +/** 본체 끝 — 전면 자리(offset) · 윗면(top) · 바닥(bottom) · 관 invert(세월교 바닥판 상면) 표고 · + * 흙막이 기본 순수 높이(79-7 · 79b · 80). */ export interface BodyEnd { offset: number; top: number; bottom: number; + invert: number; height: number; } @@ -62,6 +65,7 @@ export function bodyGuardBase( offset: side.offset, top: side.topElevation, bottom: side.bottomElevation, + invert: side.bottomElevation, // 박스 높이 값 그대로(사용자 원문 2 m → 2 m · 79b) height: layout.box.inner_height_m, } @@ -83,6 +87,7 @@ export function bodyGuardBase( offset: side.pipeEnd.offset, top: side.top.elevation, bottom: side.slabTopElevation - layout.ford.slab_thickness_m, + invert: side.slabTopElevation, height: side.top.elevation - (side.slabTopElevation - layout.ford.slab_thickness_m), } : null; @@ -109,7 +114,7 @@ export function bodyGuardAxes( lean: number; thickness: number; }, -): { x: number; d: number } { +): BodyAxes { const d = FILL_SLOPE_RATIO_MIN * (wall.edgeElevation - end.top); // 배면 위치는 자리(offset)에 평행이동만 함 — 0 에 세워 재고 옮김(노면 멈춤도 같게). const probe = buildRevetWallGeometry({ @@ -123,7 +128,45 @@ export function bodyGuardAxes( roadElevation: wall.edgeElevation, }); const anchorOffset = end.offset - probe.bottomBack.offset; - return { x: (anchorOffset - wall.autoOffset) * wall.outward - d, d }; + // 배면 → 관 invert 높이 전면 — 세월교 관이 흙막이 안으로 드는 길이(80) + const front = + probe.points[2].offset + wall.outward * probe.lean * (probe.topJoint.elevation - end.invert); + const through = (front - probe.bottomBack.offset) * wall.outward; + return { + x: (anchorOffset - wall.autoOffset) * wall.outward - d, + d, + through, + outward: wall.outward, + }; +} + +export type BodyAxes = { x: number; d: number; through: number; outward: number }; + +/** 세월교 관은 1 m 단위(80) — 흙막이 전면까지 든 관 길이를 올림하고 모자란 만큼 흙막이를 바깥으로 + * 미는 기본 x(유출 먼저 · 배관 정수 맞춤과 같은 쪽). 손으로 민 x 는 그 위에 1 m 걸음으로 얹힘. */ +export function fordPipeFit( + base: BodyGuardBase | null, + culvert: { body?: string | null; inlet: { structure?: string }; outlet: { structure?: string } }, + axesOf: (role: Role) => BodyAxes | null, +): [BodyAxes | null, BodyAxes | null] { + const axes: [BodyAxes | null, BodyAxes | null] = [axesOf("inlet"), axesOf("outlet")]; + if (!base || culvert.body !== "ford") return axes; + const roles = ["inlet", "outlet"] as const; + const guarded = roles.map((role, i) => (culvert[role].structure ? axes[i] : null)); + const tips = roles.map((role, i) => { + const end = base.ends[role]; + const reach = guarded[i] ? guarded[i]!.outward * guarded[i]!.through : 0; + return end && { offset: end.offset + reach, z: end.invert }; + }); + if (!tips[0] || !tips[1]) return axes; + // ponytail: 관 기울기는 작아 바깥 수평 이동으로 길이를 채움 + const length = Math.hypot(tips[0].offset - tips[1].offset, tips[0].z - tips[1].z); + const gap = Math.ceil(length - 1e-6) - length; + const at = guarded[1] ? 1 : 0; + const fit = guarded[at]; + if (!fit || gap < 1e-6) return axes; + axes[at] = { ...fit, x: fit.x + gap }; + return axes; } /** 벽이 선 쪽은 흙막이 트림 · 안 선 쪽은 본체 트림(73-4). 벽이 하나도 없으면 null. */ @@ -166,17 +209,61 @@ export function withoutGuardedFill( ): T | null { if (!layout || !culvert?.culvert.body) return layout; const guarded = (outward: number): boolean => culvert.walls.some((w) => w.outward === outward); + const mid = layout.label.at.offset; + const reach = (points: OffsetPoint[], outward: number, offset: number): OffsetPoint[] => + points.map((p) => ((p.offset - mid) * outward > 0 ? { ...p, offset } : p)); if ("box" in layout && "topSlab" in layout) { const sides = layout.sides.map((side) => guarded(side.role === "left" ? 1 : -1) ? { ...side, fillLine: [], fillSegments: [], cutLine: null } : side, ); - return { ...layout, sides }; + // 박스 본체가 흙막이 배면까지 따라감 — 흙막이를 바깥으로 민 만큼만(80) + let { topSlab, bottomSlab, barrel } = layout; + for (const side of layout.sides) { + const outward = side.role === "left" ? 1 : -1; + const wall = culvert.walls.find((w) => w.role !== "extra" && w.outward === outward); + const to = wall?.bottomBack.offset ?? side.offset; + if ((to - side.offset) * outward < 1e-6) continue; + [topSlab, bottomSlab, barrel] = [topSlab, bottomSlab, barrel].map((p) => + reach(p, outward, to), + ); + } + const run = + Math.max(...topSlab.map((p) => p.offset)) - Math.min(...topSlab.map((p) => p.offset)); + return { ...layout, sides, topSlab, bottomSlab, barrel, bodyLengthM: run }; } const ford = layout as FordLayout; const sides = ford.sides.map((side) => guarded(side.outward) ? { ...side, parts: [], fillSegments: [], cutLine: null } : side, ); - return { ...ford, sides } as T; + // 관 · 바닥판이 흙막이까지 따라감 — 관 끝 = 흙막이 전면(관 invert 높이) · 바닥판 끝 = 배면(80) + let { pipe, slabBridge } = ford; + for (const side of ford.sides) { + const wall = culvert.walls.find((w) => w.role === side.role && w.outward === side.outward); + if (!wall) continue; + const front = + wall.points[2].offset + + wall.outward * wall.lean * (wall.topJoint.elevation - side.slabTopElevation); + pipe = { + outer: reach(pipe.outer, side.outward, front), + inner: reach(pipe.inner, side.outward, front), + }; + slabBridge = reach(slabBridge, side.outward, wall.bottomBack.offset); + } + if (pipe === ford.pipe) return { ...ford, sides } as T; + const [right, left] = pipe.outer.slice(2); + const lengthM = Math.hypot(left.offset - right.offset, left.elevation - right.elevation); + const slabOffsets = [ + ...slabBridge, + ...sides.flatMap((s) => s.parts.flatMap((p) => p.points)), + ].map((p) => p.offset); + return { + ...ford, + sides, + pipe, + slabBridge, + slabLengthM: Math.max(...slabOffsets) - Math.min(...slabOffsets), + label: { ...ford.label, text: `${ford.label.text} L${Math.ceil(lengthM - 1e-6)}m` }, + } as T; } diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts index 3cdce0936..7c3b5bbd9 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts @@ -26,6 +26,7 @@ import { bodyGuardAxes, bodyGuardBase, bodyGuardHeight, + fordPipeFit, mergeGuardTrim, } from "./B06_Section_UI_Cross_Body_Guard"; import { ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types"; @@ -169,9 +170,10 @@ export function computeCulvertLayout( const inletWallSpec = pipeWallSpec(culvert.inlet, adjInlet); const outletWallSpec = pipeWallSpec(culvert.outlet, adjOutlet); // 본체 끝 자리(x 0점 · d 기본값). 본체 없으면(연동 옆 측점) 배관 규칙. - const bodyAxesOf = (role: "inlet" | "outlet", info: typeof inletInfo) => { + const bodyAxesOf = (role: "inlet" | "outlet") => { const end = body?.ends[role]; - const spec = role === "inlet" ? inletWallSpec : outletWallSpec; + const [spec, info] = + role === "inlet" ? [inletWallSpec, inletInfo] : [outletWallSpec, outletInfo]; if (!end) return null; return bodyGuardAxes(end, { autoOffset: slopeZeroAnchor(info.edge, info.outward, spec.height, spec.lean), @@ -182,8 +184,7 @@ export function computeCulvertLayout( thickness: REVET_THICKNESS_M, }); }; - const inletBody = bodyAxesOf("inlet", inletInfo); - const outletBody = bodyAxesOf("outlet", outletInfo); + const [inletBody, outletBody] = fordPipeFit(body, culvert, bodyAxesOf); const onBody = (adjust: WallAdjust, axes: { x: number; d: number } | null): WallAdjust => axes ? { ...adjust, x: axes.x + adjust.x, d: adjust.d ?? axes.d } : adjust; const minX = culvert.body ? Number.NEGATIVE_INFINITY : undefined; diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 91825ef70..df688b16b 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -647,6 +647,7 @@ export function createCrossSectionCard( inletIsBasin: () => culvertInletIsBasin, hiddenPipe: () => culvertHiddenPipe, bodyGuard: () => !!(section.culvert ?? culvertLink?.source.culvert)?.body, + fordGuard: () => (section.culvert ?? culvertLink?.source.culvert)?.body === "ford", inletOptions: () => culvertInletOptions, extraState: () => culvertExtraState, basinExtraState: () => culvertBasinExtraState, diff --git a/B06_Section/B06_Section_UI_Cross_View_Structure.ts b/B06_Section/B06_Section_UI_Cross_View_Structure.ts index e41f661d6..c0518def8 100644 --- a/B06_Section/B06_Section_UI_Cross_View_Structure.ts +++ b/B06_Section/B06_Section_UI_Cross_View_Structure.ts @@ -73,6 +73,8 @@ export interface StructurePanelContext { hiddenPipe: () => boolean; /** 세월교 · BOX암거 흙막이 세트인가(링크 카드는 소유 측점 세트 · 79b). */ bodyGuard?: () => boolean; + /** 세월교 흙막이 — 안이 관이라 관 보호공처럼 1 m 걸음(80). */ + fordGuard?: () => boolean; inletOptions: () => { revetAllowed: boolean; basinLUAllowed: boolean }; extraState: () => { canAdd: boolean; count: number }; basinExtraState: () => { canAdd: boolean; count: number }; @@ -144,8 +146,9 @@ export function structurePanelDeps(ctx: StructurePanelContext): StructurePanelDe // 집수정은 자리 고정 — 유입이 집수정이면 ◀/▶/↺를 숨긴다(추가 벽은 항상 이동). canNudge: (key) => key !== "inlet" || !ctx.inletIsBasin(), // 걸음 분류(2026-08-29 사용자): **배관 기준벽만 1m**, 나머지(독립 기슭막이 기준벽· - // 배관/독립 종속 추가 벽·옛 D경로 독립 벽)는 0.1m. - fineMove: (key) => (key !== "inlet" && key !== "outlet") || ctx.hiddenPipe(), + // 배관/독립 종속 추가 벽·옛 D경로 독립 벽)는 0.1m. 세월교 흙막이는 안이 관이라 1m(80). + fineMove: (key) => + (key !== "inlet" && key !== "outlet") || (ctx.hiddenPipe() && !ctx.fordGuard?.()), extraState: () => ctx.extraState(), isLinkedCard: () => ctx.isLinked, basinExtraState: () => ctx.basinExtraState(), diff --git a/B06_Section/B06_Section_UI_Page_Station_Controls.ts b/B06_Section/B06_Section_UI_Page_Station_Controls.ts index 70179e165..d8c1f1b7e 100644 --- a/B06_Section/B06_Section_UI_Page_Station_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Station_Controls.ts @@ -293,8 +293,8 @@ export function createStationControls(deps: StationControlDeps): StationControls refreshWithLinked(chainageM); }, reset: (chainageM, role) => { - revetShifts.delete(revetKey(chainageM, role)); - persistRevetShifts(); + // 캐시 사본(design.revet_adjust)까지 비움 — 남기면 adjustFor 가 옮긴 자리로 되살림(80) + storeAdjust(revetKey(chainageM, role), { ...ZERO_ADJUST }); refreshWithLinked(chainageM); }, }; diff --git a/docs/raw/plans/2026-10-08_plan_80_세월교_박스_흙막이_본체따라감.md b/docs/raw/plans/2026-10-08_plan_80_세월교_박스_흙막이_본체따라감.md new file mode 100644 index 000000000..6c950ccf5 --- /dev/null +++ b/docs/raw/plans/2026-10-08_plan_80_세월교_박스_흙막이_본체따라감.md @@ -0,0 +1,13 @@ +# 상시 계획서 + +**남은 작업만 두는 유일한 계획서.** 목록이 비면 프로젝트 최종 완료. + +## 80. 세월교 · 박스암거 흙막이 — 본체가 흙막이를 따라감 + +사용자 확정(10-8) — 추천 노선 현재 유지 · 횡단 보기 현재 유지 · 세월교 안은 배관(1 m 기준) · 흙막이면 관이 흙막이 안까지 · 좌우 이동에 본체가 따라감 · 박스암거 다시 확인. + +- [x] 80-1 세월교 흙막이 쪽 관 끝 = 흙막이 전면(관 invert 높이) · 바닥판 끝 = 흙막이 배면 · 라벨에 관 길이(L ○m) +- [x] 80-2 세월교 관 길이 1 m 단위 — 기본 자리에서 올림만큼 흙막이를 바깥으로(유출 먼저) · 세월교 흙막이 좌우 걸음 1 m +- [x] 80-3 박스 흙막이를 바깥으로 밀면 박스 본체(상판 · 저판 · 내공)가 흙막이 배면까지 따라감 +- [x] 80-4 흙막이 ↺(자동 자리로)가 캐시 사본까지 비움 — 옮긴 자리가 되살아나던 것 +- 검증완료 — test_80_ford_pipe 5 · 관련 40 통과 · typecheck · 화면 검증_랩탑메인 세월교 9+15 캐시(L6 → ▶ 두 번 L8 · 양쪽 흙막이 L7 · ↺ 복귀) · 검증_sub7 8+15 캐시 박스(▶ 1 m 본체 따라감) · 캐시 지움 · 저장 안 함 diff --git a/resources/tester/helper_80_ford_pipe.cjs b/resources/tester/helper_80_ford_pipe.cjs new file mode 100644 index 000000000..552e817d0 --- /dev/null +++ b/resources/tester/helper_80_ford_pipe.cjs @@ -0,0 +1,120 @@ +/* 80 세월교 흙막이 관 헬퍼 — 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림. + * 입력 = 73-4 픽스처(세월교 173.09 · 박스 352.14 유입 · 유출 흙막이) · 결과 = 표준출력 JSON 한 벌. 짝 = `test_80_ford_pipe.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 STUBS = { + "@ui/ui_template_toast": { showToast: () => undefined }, +}; + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + if (full.endsWith(".json")) { + const data = JSON.parse(fs.readFileSync(full, "utf8")); + cache.set(full, data); + return data; + } + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + esModuleInterop: true, + }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" }; + for (const [prefix, dir] of Object.entries(alias)) + if (request.startsWith(prefix)) + return loadTs(resolve(path.join(ROOT, dir, request.slice(prefix.length)))); + return loadTs(resolve(path.join(path.dirname(full), request))); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} +const resolve = (base) => + [base, `${base}.ts`, path.join(base, "index.ts")].find( + (p) => fs.existsSync(p) && fs.statSync(p).isFile(), + ) ?? `${base}.ts`; + +const B06 = (f) => path.join(ROOT, "B06_Section", f); +const { computeStoredLayouts } = loadTs(B06("B06_Section_Structure_Layouts.ts")); + +const fixture = JSON.parse( + fs.readFileSync(path.join(__dirname, "fixtures", "b06_73_4_body_guard_sections.json"), "utf8"), +); +const sections = fixture.cross_sections; +const FORD = sections.find((s) => Math.abs(s.chainage_m - 173.087) < 0.6); +const clone = (v) => JSON.parse(JSON.stringify(v)); +const r3 = (v) => Math.round(v * 1000) / 1000; + +/** 관 길이 · 쪽마다 관 끝 ↔ 흙막이 전면(관 invert 높이) · 바닥판 끝 ↔ 흙막이 배면. */ +function pipeFit(edit) { + const section = clone(FORD); + edit(section); + const list = sections.map((s) => (s === FORD ? section : s)); + const { ford, culvert } = computeStoredLayouts(section, list); + const [right, left] = ford.pipe.outer.slice(2); + const ends = { 1: left.offset, [-1]: right.offset }; + const slab = ford.slabBridge.map((p) => p.offset); + const slabEnd = { 1: Math.max(...slab), [-1]: Math.min(...slab) }; + return { + lengthM: r3(Math.hypot(left.offset - right.offset, left.elevation - right.elevation)), + label: ford.label.text, + sides: ford.sides.map((side) => { + const wall = culvert.walls.find((w) => w.role === side.role && w.outward === side.outward); + if (!wall) return { role: side.role, guard: false }; + const front = + wall.points[2].offset + + wall.outward * wall.lean * (wall.topJoint.elevation - side.slabTopElevation); + return { + role: side.role, + guard: true, + pipeGap: r3(ends[side.outward] - front), + slabGap: r3(slabEnd[side.outward] - wall.bottomBack.offset), + }; + }), + }; +} + +const move = (role, x) => (s) => { + s.design.revet_adjust = { ...(s.design.revet_adjust ?? {}), [role]: { x, d: null, h: null } }; +}; +const out = { + both: pipeFit(() => undefined), + inletPlus1: pipeFit(move("inlet", 1)), + outletPlus2: pipeFit(move("outlet", 2)), + inletOnly: pipeFit((s) => { + s.culvert.outlet = { role: "outlet", structure: "" }; + }), +}; + +// 박스 352.14 — 흙막이를 바깥으로 밀면 본체 끝이 배면까지 따라감 · 안 밀면 그대로 +const BOX = sections.find((s) => Math.abs(s.chainage_m - 352.14) < 0.6); +function boxFit(edit) { + const section = clone(BOX); + edit(section); + const { box, culvert } = computeStoredLayouts( + section, + sections.map((s) => (s === BOX ? section : s)), + ); + const top = box.topSlab.map((p) => p.offset); + return { + bodyLengthM: r3(box.bodyLengthM), + gaps: culvert.walls + .filter((w) => w.role !== "extra") + .map((w) => r3((w.outward > 0 ? Math.max(...top) : Math.min(...top)) - w.bottomBack.offset)), + }; +} +out.boxAuto = boxFit(() => undefined); +out.boxInletPlus1 = boxFit(move("inlet", 1)); +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_73_4_body_guard.py b/resources/tester/test_73_4_body_guard.py index dc39b4c33..2633298f4 100644 --- a/resources/tester/test_73_4_body_guard.py +++ b/resources/tester/test_73_4_body_guard.py @@ -52,7 +52,8 @@ def test_박스를_늘리면_벽도_m_단위로_따라감(screen: dict) -> None: def test_세월교_벽_배면이_관_끝(screen: dict) -> None: walls = {w["role"]: w for w in screen["ford"]["walls"]} - assert walls["outlet"]["dOffset"] == pytest.approx(0, abs=1e-3) + # 유출은 관 1 m 맞춤만큼 바깥으로 물러남(80 · test_80_ford_pipe) — 1 m 안 + assert abs(walls["outlet"]["dOffset"]) < 1 assert walls["outlet"]["dElevation"] == pytest.approx(0, abs=1e-3) assert walls["inlet"]["dOffset"] == pytest.approx(0, abs=1e-3) # 윗면 = 측벽 상단(상판 높이) — 노면 위로는 안 솟음(79-7) diff --git a/resources/tester/test_80_ford_pipe.py b/resources/tester/test_80_ford_pipe.py new file mode 100644 index 000000000..eead57fc1 --- /dev/null +++ b/resources/tester/test_80_ford_pipe.py @@ -0,0 +1,65 @@ +"""80 세월교 · 박스 흙막이 — 세월교 안은 관이라 1 m 단위 · 관이 흙막이 안까지 · 흙막이를 옮기면 본체가 따라감. + +화면 TS 를 그 자리에서 돌린 값(`helper_80_ford_pipe.cjs`) · 픽스처 = 73-4 세월교 173.09 · 박스 352.14. +""" + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +HELPER = Path(__file__).parent / "helper_80_ford_pipe.cjs" +NODE_MODULES = ROOT / "config" / "node_modules" + +pytestmark = pytest.mark.skipif(not NODE_MODULES.is_dir(), reason="프론트엔드 의존성 미설치") + + +@pytest.fixture(scope="module") +def fit() -> 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 _whole(length: float) -> bool: + return abs(length - round(length)) < 1e-3 + + +def test_기본_자리_관_길이는_1m_단위(fit: dict) -> None: + for case in ("both", "inletOnly"): + assert _whole(fit[case]["lengthM"]), fit[case] + assert fit[case]["label"].endswith(f"L{round(fit[case]['lengthM'])}m") + + +def test_관은_흙막이_전면까지_바닥판은_배면까지(fit: dict) -> None: + for case in (fit[k] for k in ("both", "inletPlus1", "outletPlus2", "inletOnly")): + for side in case["sides"]: + if side["guard"]: + assert side["pipeGap"] == pytest.approx(0, abs=1e-3) + assert side["slabGap"] == pytest.approx(0, abs=1e-3) + + +def test_흙막이를_옮기면_관이_그만큼_따라감(fit: dict) -> None: + base = fit["both"]["lengthM"] + assert fit["inletPlus1"]["lengthM"] == pytest.approx(base + 1, abs=1e-3) + assert fit["outletPlus2"]["lengthM"] == pytest.approx(base + 2, abs=1e-3) + + +def test_흙막이_없는_쪽은_그대로(fit: dict) -> None: + assert [s["guard"] for s in fit["inletOnly"]["sides"]] == [True, False] + + +def test_박스도_흙막이를_밀면_본체가_따라감(fit: dict) -> None: + assert fit["boxAuto"]["gaps"] == [0, 0] + assert fit["boxInletPlus1"]["gaps"] == [0, 0] + assert fit["boxInletPlus1"]["bodyLengthM"] == pytest.approx( + fit["boxAuto"]["bodyLengthM"] + 1, abs=1e-3 + )