From c98706be06bb9ab5571454be449a9121fc517891 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 16:43:08 +0900 Subject: [PATCH] =?UTF-8?q?feat(b06):=20=EA=B4=80=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EA=B8=80=20=EB=85=B8=ED=8F=AD=20=EB=9D=BC=EB=B2=A8=20=EC=9C=84?= =?UTF-8?q?=20=C2=B7=20=EA=B4=80=EB=B3=B4=ED=98=B8=EA=B3=B5=200.1=20m=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=20=C2=B7=20=EA=B4=80=20=EA=B8=B8=EC=9D=B4=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=20=E3=80=8C8=20m=20(7.6)=E3=80=8D=20=C2=B7?= =?UTF-8?q?=20B08=20=EA=B4=80=EC=97=B0=EC=9E=A5=20=EC=82=AC=EC=82=AC?= =?UTF-8?q?=EC=98=A4=EC=9E=85=20(225-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 관 길이 = 유출 벽 자리에서 셈(0.1 m) · 정수 올림 연장 · 길이 맞춤 이동(_Culvert_Fit) 없앰 - 관보호공 ◀▶▲▼ · 끌기 0.1 m 걸음(세월교 흙막이만 1 m) · ◀▶ 때 d 빈 칸은 지금 자리로 묶음 - 관 · 세월교 정보 글 = 노폭 · 노견 라벨 바로 위 줄 · 카드 · 좌측 패널 「정수 m (소수 첫째)」 - 구조물 집계(M02_Table_Fill) 관연장 = 사사오입 정수 · TS pipeLengthWhole 짝 · 새 시험 test_225_1 - 정수 맞춤 없앰에 맞춰 139_6 · 186_1 · 108 · 79 기대값 고침 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU --- .../design/design_structure_pipe_calc.ts | 1 + .../B05_Profile_UI_Drainage_Facility.ts | 3 +- .../B06_Section_UI_Cross_Body_Guard.ts | 2 +- B06_Section/B06_Section_UI_Cross_Culvert.ts | 8 +- .../B06_Section_UI_Cross_Culvert_Const.ts | 8 + .../B06_Section_UI_Cross_Culvert_Fit.ts | 90 ---------- .../B06_Section_UI_Cross_Culvert_Geom.ts | 103 ++---------- .../B06_Section_UI_Cross_Culvert_Types.ts | 25 +++ B06_Section/B06_Section_UI_Cross_Ford.ts | 16 +- .../B06_Section_UI_Cross_Structure_Panel.ts | 8 +- .../B06_Section_UI_Cross_View_Structure.ts | 11 +- B08_Quantity/B08_Quantity_Router_Summary.py | 2 +- M02_MasterTemplete/M02_Table_Fill.py | 6 +- resources/tester/helper_225_1_pipe_round.cjs | 154 ++++++++++++++++++ resources/tester/test_108_drag_box_hit.py | 4 +- resources/tester/test_139_6_pipe_fit.py | 12 +- resources/tester/test_186_1_outlet_fold.py | 3 +- resources/tester/test_225_1_pipe_round.py | 117 +++++++++++++ resources/tester/test_79_guard_shape.py | 4 +- 19 files changed, 364 insertions(+), 213 deletions(-) delete mode 100644 B06_Section/B06_Section_UI_Cross_Culvert_Fit.ts create mode 100644 resources/tester/helper_225_1_pipe_round.cjs create mode 100644 resources/tester/test_225_1_pipe_round.py diff --git a/A00_Common/design/design_structure_pipe_calc.ts b/A00_Common/design/design_structure_pipe_calc.ts index 54b6bb940..3f8223d5c 100644 --- a/A00_Common/design/design_structure_pipe_calc.ts +++ b/A00_Common/design/design_structure_pipe_calc.ts @@ -12,6 +12,7 @@ import type { CrossSection } from "../../B06_Section/B06_Section_Api_Fetch"; import { computeStoredLayouts } from "../../B06_Section/B06_Section_Structure_Layouts"; export { DEFAULT_BASIN_SHAPE } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Basin"; +export { pipeLengthText } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Const"; // 「8 m (7.6)」(225-1) import { PIPE_MATCH_M } from "./design_selection"; /** 횡단 측점 목록을 대는 곳 — 횡단 보기가 자료를 받으면 걸어 둠(종단만 연 동안은 없음). */ diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index a58f42145..fb5437511 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -70,6 +70,7 @@ import { pipeDrawn, pipeExtraCount, pipeLengthM, + pipeLengthText, } from "../A00_Common/design/design_structure_pipe_calc"; import { hasLinks, @@ -339,7 +340,7 @@ export function createFacilityOptionsForm( auto: (_c, item) => { if (item.id !== "length") return ""; const length = pipeLengthM(chainage); - return length === null ? "횡단 설계 뒤 셈" : String(Math.round(length)); + return length === null ? "횡단 설계 뒤 셈" : pipeLengthText(length); }, // 흙막이 개수 = 유출 「추가 기슭막이(단)」(54-1) count: (c) => (c.id === "soil_add" ? pipeExtraCount(chainage) : undefined), diff --git a/B06_Section/B06_Section_UI_Cross_Body_Guard.ts b/B06_Section/B06_Section_UI_Cross_Body_Guard.ts index e0cb89151..d9e5c8461 100644 --- a/B06_Section/B06_Section_UI_Cross_Body_Guard.ts +++ b/B06_Section/B06_Section_UI_Cross_Body_Guard.ts @@ -147,7 +147,7 @@ export type BodyAxes = { x: number; d: number; through: number; outward: number * 맞춤 길이는 **기본 자리**(측벽 조작 0)로만 잼 — 한쪽 이동이 반대쪽 흙막이를 안 밈(107-1 · * 수량 L 은 그린 관 길이 올림 그대로). 노견을 넓힌 쪽(139-6)은 사면 우선 — 그 쪽 흙막이는 안 밀고 * 반대쪽(안 넓힌 쪽)을 밂 · 둘 다 넓혔으면 표기만 올림. - * ponytail: 흙막이 높이 역산 없음 — 윗면이 본체 끝 윗면에 묶임(79-7) · 필요하면 배관 `_Culvert_Fit` 꼴로. */ + * ponytail: 흙막이 높이 역산 없음 — 윗면이 본체 끝 윗면에 묶임(79-7) · 필요하면 높이 역산을 더함. */ export function fordPipeFit( section: CrossSection, samples: SectionSample[], diff --git a/B06_Section/B06_Section_UI_Cross_Culvert.ts b/B06_Section/B06_Section_UI_Cross_Culvert.ts index 640890541..6413b563e 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert.ts @@ -21,7 +21,8 @@ import type { PipeEnd, WallLayout, } from "./B06_Section_UI_Cross_Culvert_Geom"; -import { outlineBox } from "./B06_Section_UI_Cross_Culvert_Types"; +import { appendPipeInfoText, outlineBox } from "./B06_Section_UI_Cross_Culvert_Types"; +import { pipeLengthText } from "./B06_Section_UI_Cross_Culvert_Const"; import { appendWallTrench, foundationChoice } from "./B06_Section_UI_Cross_Excavation"; import { appendChuteBand, @@ -305,8 +306,11 @@ export function appendCulvertOverlay( const kindLabel = culvert.pipe_kind ? `${culvert.pipe_kind} ` : ""; const wallThickness = pipeWallThicknessM(culvert.pipe_kind, diameter); + const pipeInfo = `관매설 ${kindLabel}Φ${Math.round(diameter * 1000)}m/m, L=${pipeLengthText(pipe.lengthM)}`; + // 관 정보 글 = 노폭 · 노견 라벨 위 줄(225-1 · B06 카드만 — 그 라벨 없는 덧그림엔 안 그림) + if (!hidePipe) appendPipeInfoText(layer, pipeInfo, null); const tip = - `관매설 ${kindLabel}Φ${Math.round(diameter * 1000)}m/m, L=${pipe.lengthM}.0m` + + pipeInfo + ` · 매설 경사 ${pipe.slopePct.toFixed(1)}%(수평 가능)` + ` · 관 두께 ${(wallThickness * 1000).toFixed(1)}㎜(외경선 기준)` + ` · 최소 토피 ${culvert.min_cover_m.toFixed(1)}m(별표2 복토 교차 참조)`; diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts index a6ac1bf2b..73a94ed11 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts @@ -13,6 +13,14 @@ import type { SpanRole, SpanValues } from "./B06_Section_UI_Cross_Culvert_Wire"; * 절토측 유입 벽에서 관 invert의 상한(노면 − 관경 − 토피)을 정한다. */ export const MIN_PIPE_COVER_M = 0.5; +/** 관 길이 정수(사사오입 · 0.5 올림 · 225-1) — 저장 값은 실제 길이(0.1 m) 그대로. + * ⚠ 파이썬 짝 `M02_Table_Fill.pipe_lengths_from_designs`(집계 값 · 거울 시험 test_225_1). */ +export const pipeLengthWhole = (lengthM: number): number => Math.floor(lengthM + 0.5); + +/** 관 길이 표시 「8 m (7.6)」(225-1 — 카드 글 · 좌측 패널). */ +export const pipeLengthText = (lengthM: number): string => + `${pipeLengthWhole(lengthM)} m (${lengthM.toFixed(1)})`; + /** 기슭막이 벽 두께(m) — 실무 견치돌 뒷길이 관측치 45㎝(울진 L3=45). 표시용 형상 값. */ export const REVET_THICKNESS_M = 0.45; diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Fit.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Fit.ts deleted file mode 100644 index 022a7f12f..000000000 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Fit.ts +++ /dev/null @@ -1,90 +0,0 @@ -/* ============================================================================= - * B06_Section_UI_Cross_Culvert_Fit.ts - * 관 길이 **m 단위 맞춤** — 배수관 기하(`_Cross_Culvert_Geom.ts`)에서 700줄 제한으로 분리. - * - * · 보통(2026-08-21 사용자 ①) — 긴 변 기준 올림, 모자란 만큼 유출 벽 자리를 관 축 방향 바깥으로 - * 민다(벽 두께 0.45 · 높이 고정 · 벽 상단은 1:1.2 성토선을 탐). - * · 노견을 넓힌 쪽(139-6 설계선 쉬프트) — 사면 우선: 벽을 관 축을 따라 옮기되 벽 상단이 그 쪽 - * 성토선(넓힌 노견에서 같은 물매)에 닿도록 **벽 높이를 0.1 m 눈금으로 역산**. 눈금 탓에 남는 - * 차는 표기 올림. - * ========================================================================== */ - -import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types"; -import { - FILL_SLOPE_MAX_LENGTH_M, - FILL_SLOPE_RATIO_MAX, - FILL_SLOPE_RATIO_MIN, - REVET_EMBED_DEPTH_M, - REVET_HEIGHT_STEP_M, - WALL_MIN_PURE_HEIGHT_M, -} from "./B06_Section_UI_Cross_Culvert_Const"; - -export interface OutletFitInput { - /** 유출 쪽 노견을 넓혔나(139-6) — 높이 역산 갈래. */ - shifted: boolean; - edge: { offset_m: number; elevation_m: number }; - outward: number; - axis: () => OffsetPoint; - thickness: number; - anchor: OffsetPoint; - /** 계산용 높이(순수 − 근입). */ - height: number; - /** 보통 갈래의 invert 선(높이 고정 · 성토선 평행). */ - lineInvertAt: (offset: number) => number; - slopeAt: ( - offset: number, - thickness: number, - height: number, - ) => { ratio: number; lengthM: number }; - cutLength: () => number; - /** 벽을 새 자리 · 높이로 다시 세우고 관 축 길이 `target` 으로 겨눔 — 벽이 안 서면 false. */ - rebuild: (anchor: OffsetPoint, height: number, target: number) => boolean; -} - -/** 맞춤 뒤 벽 자리 · 높이. */ -export function fitOutletPipeLength(c: OutletFitInput): { anchor: OffsetPoint; height: number } { - const target = Math.ceil(c.cutLength() - 1e-6); - let anchor = c.anchor; - let height = c.height; - const ratio0 = c.slopeAt(anchor.offset, c.thickness, height).ratio; - for (let pass = 0; pass < 8; pass += 1) { - const gap = target - c.cutLength(); - if (Math.abs(gap) < 0.005) break; - const axis = c.axis(); - const moved = anchor.offset + axis.offset * gap; - // 노견 안쪽으로는 옮기지 않는다 — 길이 맞춤보다 도면 성립이 먼저. - if ((moved - c.edge.offset_m) * c.outward < 0) break; - if (c.shifted) { - // 사면 우선 — invert 는 관 축을 따라 · 벽 상단 = 같은 물매 성토선 → 높이 역산(0.1 m 눈금) - if (!Number.isFinite(ratio0)) break; - const invert = anchor.elevation + axis.elevation * gap; - const run = Math.abs(moved + c.outward * (c.thickness / 2) - c.edge.offset_m); - const pure = c.edge.elevation_m - run / ratio0 - invert + REVET_EMBED_DEPTH_M; - const stepped = Math.round(pure / REVET_HEIGHT_STEP_M) * REVET_HEIGHT_STEP_M; - if (stepped < WALL_MIN_PURE_HEIGHT_M - 1e-9) break; - const next = stepped - REVET_EMBED_DEPTH_M; - if (Math.abs(next - height) < 1e-9 && Math.abs(moved - anchor.offset) < 0.01) break; - anchor = { offset: moved, elevation: invert }; - height = next; - if (!c.rebuild(anchor, height, target)) break; - continue; - } - const movedSlope = c.slopeAt(moved, c.thickness, height); - // 사면 역전(벽 상단이 노견 위)으로는 옮기지 않는다 · 성토고 0(성토선 0점)까지는 허용(2026-08-23). - if (c.edge.elevation_m - (c.lineInvertAt(moved) + height) < 0) break; - // 자동 자리만 옮긴다(수동 벽은 이 루프에 아예 안 들어온다). 폭은 0.45 고정 — - // 폭으로 흡수하면 미는 동안 벽 단면이 변한다(2026-08-21 사용자 지적). - if ( - movedSlope.ratio >= FILL_SLOPE_RATIO_MIN - 1e-6 && - movedSlope.ratio <= FILL_SLOPE_RATIO_MAX && - movedSlope.lengthM <= FILL_SLOPE_MAX_LENGTH_M + 1e-6 - ) { - anchor = { offset: moved, elevation: c.lineInvertAt(moved) }; - if (!c.rebuild(anchor, height, target)) break; - continue; - } - // 물매·사면 5m를 벗어나면 멈춘다. 폭으로 흡수하지 않는다(좌·우 단면이 달라진다). - break; - } - return { anchor, height }; -} diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts index 70e2cd2de..3e46b469e 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts @@ -13,6 +13,7 @@ import { FILL_SLOPE_MAX_LENGTH_M, FILL_SLOPE_RATIO_MIN, MIN_PIPE_COVER_M, + pipeLengthWhole, REVET_EMBED_DEPTH_M, REVET_LEAN_RATIO, REVET_THICKNESS_M, @@ -57,7 +58,6 @@ import { } from "./B06_Section_UI_Cross_Culvert_Basin"; import { inletGroundConnector } from "./B06_Section_UI_Cross_Culvert_Extra"; import { withBackCut } from "./B06_Section_UI_Cross_Structure_Cut"; -import { fitOutletPipeLength } from "./B06_Section_UI_Cross_Culvert_Fit"; import { buildCulvertTiers, drawnFillPoints, @@ -67,7 +67,6 @@ import { import type { FillSlopeSegment, WallVertical } from "./B06_Section_UI_Cross_Culvert_Solve"; import { cutLength, - outletSlopeFactory, fillSlopeOf, groundInterpolator, designInterpolator, @@ -116,7 +115,7 @@ export function computeCulvertLayout( false, true, ); - return p ? { shape: p.basin?.shape ?? null, lengthM: p.pipe.lengthM } : null; + return p ? { shape: p.basin?.shape ?? null, lengthM: pipeLengthWhole(p.pipe.lengthM) } : null; }; const requestedBasin = basinAdjustment ?? DEFAULT_BASIN_ADJUST; const basinAdjust = skipBasinClamp ? requestedBasin : clampBasinMove(requestedBasin, probeBasin); @@ -284,13 +283,9 @@ export function computeCulvertLayout( const rise0 = outletAnchor.elevation - inlet.elevation; const length0 = Math.hypot(run0, rise0); if (!(length0 > 0.5)) return null; - // 관은 m 단위 설치 — 올림 연장분은 유출 쪽으로(2026-08-20 사용자 확정). - let lengthM = Math.ceil(length0 - 1e-6); - const scale = lengthM / length0; - const outlet: OffsetPoint = { - offset: inlet.offset + run0 * scale, - elevation: inlet.elevation + rise0 * scale, - }; + // 관 길이 = 그려진 실제 길이(0.1 m · 225-1 — 정수 올림 연장 없음 · 표시 · 집계만 반올림). + let lengthM = Math.round(length0 * 10) / 10; + const outlet: OffsetPoint = { ...outletAnchor }; const span = Math.abs(outletAnchor.offset - inlet.offset); let slopePct = span > 0 ? ((inlet.elevation - outletInvert) / span) * 100 : 0; @@ -418,8 +413,7 @@ export function computeCulvertLayout( // 유출 벽 밑 = 그 자리 원지반(역경사는 유입 invert로 클램프 — 2026-08-21). const invertAt = (offset: number): number => Math.min(groundAt(offset), inlet.elevation); let outletWallAnchor = outletAnchor; - // 유출도 성토선 0점 기준(2026-08-23) — 기본 자리는 성토선 0 지점(79-5). 관 길이 - // 정수 맞춤 루프가 벽을 옮길 때 invert가 같은 성토선을 타도록 함수로 둔다. + // 유출도 성토선 0점 기준(2026-08-23) — 기본 자리는 성토선 0 지점(79-5). const outletZeroAnchor = slopeZeroAnchor( outletInfo.edge, outletInfo.outward, @@ -427,10 +421,6 @@ export function computeCulvertLayout( outletWallSpec.lean, ); 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축 배치(유입과 동일 풀이). const placed = placePipeWall({ @@ -454,7 +444,7 @@ export function computeCulvertLayout( }; outletWallAnchor = { offset: placed.anchorOffset, elevation: placed.invert }; } - let outletWall = buildWall( + const outletWall = buildWall( culvert.outlet, outletWallAnchor, outletInfo.outward, @@ -467,91 +457,28 @@ export function computeCulvertLayout( ); // ── 관 축 확정 — 관 하단선은 시작점과 유출 벽 전면 기준선 교차점을 잇는다. const pipeStart = basinPipeEnd ?? inlet; - // 유출 벽이 사면 끝과 다른 자리면 관 끝도 그 자리 기준으로 맞춘다(올림 연장분은 - // 유출 쪽 — 2026-08-20 확정). 관 하단선은 전면 기준선 교차점을 지나 정수 길이로. + // 관 끝 = 유출 벽 전면 기준선 교차점 — 벽은 관 길이와 무관하게 0.1 m 로 움직이고 길이는 + // 벽 자리에서 셈(225-1 · 옛 정수 올림 연장 · 길이 맞춤 이동 없앰). if (outletWall) { const face = outletPipeEnd(outletWall); - const runW = face.offset - pipeStart.offset; - const riseW = face.elevation - pipeStart.elevation; - const lenW = Math.hypot(runW, riseW); - if (lenW > 0.5) { - const scaleW = Math.ceil(lenW - 1e-6) / lenW; - outlet.offset = pipeStart.offset + runW * scaleW; - outlet.elevation = pipeStart.elevation + riseW * scaleW; - lengthM = Math.ceil(lenW - 1e-6); + if (Math.hypot(face.offset - pipeStart.offset, face.elevation - pipeStart.elevation) > 0.5) { + outlet.offset = face.offset; + outlet.elevation = face.elevation; } } // ── 관 단면 꼭짓점 — 끝단면을 구조물 계류측 변으로 자른다. 축·법선은 벽이 옮겨질 // 때마다 다시 유도한다(2026-08-20 사용자 ③). const pipeAxis = pipeAxisSolver(pipeStart, outlet, diameter, endFaces); - const deriveAxis = pipeAxis.derive; const endCorners = pipeAxis.corners; - let pipeCorners = { + const pipeCorners = { inlet: endCorners("inlet", pipeStart), outlet: endCorners("outlet", outlet), }; - // ── 관 길이 m 단위 맞춤 — `_Culvert_Fit`(보통 = 유출 벽을 관 축 바깥으로 · 노견을 넓힌 - // 쪽 = 사면 우선 + 벽 높이 역산 · 139-6). 정수 맞춤 후보 자리도 같은 성토선을 탄다(2026-08-23). - const outletThickness = REVET_THICKNESS_M; // 늘 0.45 고정(2026-08-21) — 폭으로 길이를 맞추지 않음 - // 손으로 민 벽은 정수 맞춤이 **절대 옮기지 않는다**(2026-08-22) — 표기만 올림한다. - // d = null(기본 자리)만 자동으로 본다(2026-08-23 — d는 0점 기준 절대값). - const outletPinned = !!outletBody || Math.abs(adjOutlet.x) > 1e-9 || adjOutlet.d != null; - if (outletWall && !outletPinned) { - const extend = - section.design?.[`shoulder_extend_${outletInfo.outward > 0 ? "left" : "right"}_m`]; - fitOutletPipeLength({ - shifted: (extend ?? 0) > 1e-9, - edge: outletInfo.edge, - outward: outletInfo.outward, - axis: () => pipeAxis.axis, - thickness: outletThickness, - anchor: outletWallAnchor, - height: outletWallSpec.height, - lineInvertAt: outletLineInvertAt, - slopeAt: (offset, thickness, height) => - outletSlopeFactory(outletInfo, outletLineInvertAt, height)(offset, thickness), - cutLength: () => cutLength(pipeCorners), - rebuild: (anchor, height, target) => { - outletWallAnchor = anchor; - walls.pop(); - outletWall = buildWall( - culvert.outlet, - anchor, - outletInfo.outward, - null, - outletThickness, - { - height, - baseElevation: anchor.elevation, - floatGapM: Math.max(0, anchor.elevation - invertAt(anchor.offset)), - }, - outletWallSpec.material, - outletWallSpec.form, - outletWallSpec.lean, - ); - if (!outletWall) return false; - // 관 하단선은 옮겨진 벽의 **전면 기준선 교차점**을 지나야 한다(사용자 ①). - const face = outletPipeEnd(outletWall); - const runW = face.offset - pipeStart.offset; - const riseW = face.elevation - pipeStart.elevation; - const lenW = Math.hypot(runW, riseW) || 1; - outlet.offset = pipeStart.offset + (runW / lenW) * target; - outlet.elevation = pipeStart.elevation + (riseW / lenW) * target; - deriveAxis(); - pipeCorners = { - inlet: endCorners("inlet", pipeStart), - outlet: endCorners("outlet", outlet), - }; - return true; - }, - }); - } if (outletWall) { - // 표기 길이 = **실제 그려진 관**의 올림값(목표를 적으면 도면보다 길게 적힌다). - // 수동 벽으로 실길이가 정수가 아니어도 표기만 올림한다(2026-08-22 사용자 확정). - lengthM = Math.max(1, Math.ceil(cutLength(pipeCorners) - 1e-6)); + // 관 길이 = **실제 그려진 관** 0.1 m(225-1 — 표시 · 집계 반올림은 `pipeLengthWhole`). + lengthM = Math.max(0.1, Math.round(cutLength(pipeCorners) * 10) / 10); slopePct = Math.abs(outlet.offset - pipeStart.offset) > 1e-9 ? ((pipeStart.elevation - outlet.elevation) / Math.abs(outlet.offset - pipeStart.offset)) * diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts index 94dc53ea0..8ffe3bed8 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts @@ -50,6 +50,31 @@ export function outlineBox(points: OffsetPoint[]): OffsetPoint[] { ]; } +/** + * 관 정보 글(세월교 · 관 자리) — 노폭 · 노견 라벨(`.b06-chart__carriageway-label`) 바로 위 줄(225-1). + * 같은 그림에 그 라벨이 없으면(B07 덧그림) `fallback` 자리 · fallback 도 없으면 안 그림. + */ +export function appendPipeInfoText( + layer: SVGElement, + text: string, + fallback: [number, number] | null, +): void { + const svg: Element = layer.ownerSVGElement ?? layer; // 맨 위 svg 는 ownerSVGElement = null + const road = svg.querySelector?.(".b06-chart__carriageway-label"); + if (!road && !fallback) return; + const label = document.createElementNS("http://www.w3.org/2000/svg", "text"); + const stacked = svg.querySelectorAll?.(".b06-chart__pipe-info").length ?? 0; + const [x, y] = road + ? [Number(road.getAttribute("x")), Number(road.getAttribute("y")) - 13 * (stacked + 1)] + : fallback!; + label.setAttribute("x", String(x)); + label.setAttribute("y", String(y)); + label.setAttribute("text-anchor", "middle"); + label.setAttribute("class", "b06-chart__culvert-label b06-chart__pipe-info"); + label.textContent = text; + (road?.parentNode ?? layer).appendChild(label); +} + /** 설계선(성토 경사) 트림 범위 — 이 밖은 벽·보호공이 대신하므로 그리지 않는다. */ export interface CulvertDesignTrim { minOffset: number; diff --git a/B06_Section/B06_Section_UI_Cross_Ford.ts b/B06_Section/B06_Section_UI_Cross_Ford.ts index 769e7ce9f..fde0f514b 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford.ts @@ -10,7 +10,11 @@ * ========================================================================== */ import type { FordLayout, FordWallRole } from "./B06_Section_UI_Cross_Ford_Geom"; -import { outlineBox, type OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types"; +import { + appendPipeInfoText, + outlineBox, + type OffsetPoint, +} from "./B06_Section_UI_Cross_Culvert_Types"; import { appendChuteBand } from "./B06_Section_UI_Cross_Wall_Hatch"; export { @@ -196,14 +200,8 @@ export function appendFordOverlay( } } - const label = document.createElementNS(SVG_NS, "text"); - const [labelX, labelY] = toXY(layout.label.at); - label.setAttribute("x", String(labelX)); - label.setAttribute("y", String(labelY)); - label.setAttribute("text-anchor", "middle"); - label.setAttribute("class", "b06-chart__culvert-label"); - label.textContent = layout.label.text; - layer.append(label); + // 세월교 관 정보 글 = 노폭 · 노견 라벨 위 줄(225-1) · 그 라벨이 없으면 옛 자리. + appendPipeInfoText(layer, layout.label.text, toXY(layout.label.at)); return (key: FordPick | null): void => { for (const [role, shapes] of walls) { diff --git a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts index 0dec26028..57a37320c 100644 --- a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts @@ -67,7 +67,7 @@ export interface StructurePanelDeps { bodyGuard?: () => boolean; /** 이동 조작 가능 여부 — 집수정(자리 고정)은 숨긴다. */ canNudge: (key: RevetKey) => boolean; - /** 좌우·상하 걸음이 0.1m인가(독립·추가 기슭막이). 배관 기준벽은 false = 1m. */ + /** 좌우·상하 걸음이 0.1m인가(관보호공 · 독립 · 추가 기슭막이). 세월교 흙막이는 false = 1m. */ fineMove: (key: RevetKey) => boolean; /** 그림 끌기 두 축(108-2) — ▲▼ 축 뜻 · 한계 · 한 번 반영. 없으면 끌 수 없다. */ dragAxes?: (key: RevetKey, basin: boolean) => Omit | null; @@ -102,13 +102,13 @@ export interface StructurePanelHandle { } /** - * 좌우·상하 한 걸음(m) — **관 길이 1m**에 맞춘다(2026-08-21 사용자 확정). + * 세월교 흙막이 좌우·상하 한 걸음(m) — 안이 관이라 **1m**(80). * 높이는 0.1m 눈금(2026-08-22 사용자 확정 2~3m·0.5~3m 구간 0.1 단위 제어). */ const STEP_M = 1.0; /** - * 독립·추가 기슭막이의 좌우·상하 한 걸음(m) — **0.1m**(2026-08-29 사용자). - * 배관 기준벽(유입·유출)만 관 길이에 맞춘 1m 걸음을 그대로 쓴다. + * 관보호공(배관 기준벽) · 독립 · 추가 기슭막이의 좌우·상하 한 걸음(m) — **0.1m** + * (관 길이와 무관 · 225-1). */ const OWN_STEP_M = 0.1; const HEIGHT_STEP_M = 0.1; diff --git a/B06_Section/B06_Section_UI_Cross_View_Structure.ts b/B06_Section/B06_Section_UI_Cross_View_Structure.ts index 00fa535c1..15cb2226a 100644 --- a/B06_Section/B06_Section_UI_Cross_View_Structure.ts +++ b/B06_Section/B06_Section_UI_Cross_View_Structure.ts @@ -92,9 +92,13 @@ export function structurePanelDeps(ctx: StructurePanelContext): StructurePanelDe return { scrollKey: ctx.scrollKey, adjustFor: ctx.adjustOf, + // 배관 기준벽 d 빈 칸은 지금 자리로 묶음 — x 만 넣으면 옛 기본 d(경사길이 4m · 79b)로 튐(225-1). nudge: (key, screenDeltaM) => ctx.revetOffset?.update(ctx.adjustChainage(), key, { x: ctx.adjustOf(key).x + screenDeltaM * ctx.outwardOf(key), + ...(key === "inlet" || key === "outlet" + ? { d: ctx.adjustOf(key).d ?? ctx.appliedD().get(key) ?? null } + : {}), }), // d 미지정(자동)이면 실제 적용 자리에서부터 움직인다(2026-08-23 — d는 0점 // 기준 절대값이라 시작값이 필요하다). @@ -148,12 +152,11 @@ export function structurePanelDeps(ctx: StructurePanelContext): StructurePanelDe resetBasin: () => ctx.inletStructure?.resetAdjust(ctx.adjustChainage()), // 집수정은 자리 고정 — 유입이 집수정이면 ◀/▶/↺를 숨긴다(추가 벽은 항상 이동). canNudge: (key) => key !== "inlet" || !ctx.inletIsBasin(), - // 걸음 분류(2026-08-29 사용자): **배관 기준벽만 1m**, 나머지(독립 기슭막이 기준벽· - // 배관/독립 종속 추가 벽·옛 D경로 독립 벽)는 0.1m. 세월교 흙막이는 안이 관이라 1m(80). - // 연동을 푼 옆 측점 카드는 자기 자리를 잡는 벽이라 0.1m(139-2). + // 걸음 분류: 관보호공(배관 기준벽)도 관 길이와 무관하게 0.1m(225-1) · 세월교 흙막이만 + // 안이 관이라 1m(80) · 연동을 푼 옆 측점 카드는 자기 자리를 잡는 벽이라 0.1m(139-2). fineMove: (key) => (key !== "inlet" && key !== "outlet") || - (ctx.hiddenPipe() && !ctx.fordGuard?.()) || + !(ctx.hiddenPipe() && ctx.fordGuard?.()) || (ctx.isLinked && ctx.revetLink?.linkedFor(ctx.section) === false), dragAxes: (key, basin) => wallDragAxes(ctx, key, basin), extraState: () => ctx.extraState(), diff --git a/B08_Quantity/B08_Quantity_Router_Summary.py b/B08_Quantity/B08_Quantity_Router_Summary.py index 104bcc4ac..20b13dcf9 100644 --- a/B08_Quantity/B08_Quantity_Router_Summary.py +++ b/B08_Quantity/B08_Quantity_Router_Summary.py @@ -7,7 +7,7 @@ 흙막이 › 형태 › H=높이 m 유입(흙막이일 때) · 유출 · 흙막이 n(실제 선 단까지) · 길이 = 전 + 후 · 빈 높이 = 횡단도가 그리는 관경 최소 높이 돌붙임 › 찰붙임 · 메붙임 ㎡ 도수로/돌붙임 붙임 형식 · 면적(빈칸이면 안 셈 · 98-3) - 관 공 › Φ{관경}m/m › 관매설 m B06 관 길이(1 m 올림 그대로) + 관 공 › Φ{관경}m/m › 관매설 m B06 관 길이 사사오입 정수(225-1) › 커플링 ea 올림(L ÷ 1본) − 1 · 1본 = 프로젝트 층 M02 구조물집계표 변수만 관경 · 련 빈칸 = 「미입력」 열(임의 값으로 안 채움 · 143-2) › 관보호공 모양 실제로 선 집수정 모양마다 1 개소(98-2 `inlet_basin`) diff --git a/M02_MasterTemplete/M02_Table_Fill.py b/M02_MasterTemplete/M02_Table_Fill.py index 1cb0db1ff..fb34caad1 100644 --- a/M02_MasterTemplete/M02_Table_Fill.py +++ b/M02_MasterTemplete/M02_Table_Fill.py @@ -50,7 +50,9 @@ _REF = re.compile(r"\[([^\]$@][^\]@]*)\]") def pipe_lengths_from_designs(designs: Iterable[dict[str, Any]]) -> dict[float, float]: - """B06 횡단 설계 `[{chainage_m, design}]` → `{측점: 관 연장}` · 없는 측점은 안 담음.""" + """B06 횡단 설계 `[{chainage_m, design}]` → `{측점: 관 연장}` · 없는 측점은 안 담음. + + 관 연장 = 사사오입 정수(0.5 올림 · 225-1 · ⚠ TS 짝 `pipeLengthWhole` · 거울 시험 test_225_1).""" found: dict[float, float] = {} for row in designs or []: design = row.get("design") if isinstance(row, dict) else None @@ -58,7 +60,7 @@ def pipe_lengths_from_designs(designs: Iterable[dict[str, Any]]) -> dict[float, continue length = _number(design.get("pipe_length_m")) if length and length > 0: - found[round(float(row.get("chainage_m") or 0.0), 3)] = length + found[round(float(row.get("chainage_m") or 0.0), 3)] = int(length + 0.5) return found diff --git a/resources/tester/helper_225_1_pipe_round.cjs b/resources/tester/helper_225_1_pipe_round.cjs new file mode 100644 index 000000000..a56c21a89 --- /dev/null +++ b/resources/tester/helper_225_1_pipe_round.cjs @@ -0,0 +1,154 @@ +/* 225-1 관 정보 글 자리 · 관보호공 0.1 m 이동 · 관 길이 표시 반올림 헬퍼 — 짝 = `test_225_1_pipe_round.py`. + * 입력 = 인자(반올림 거울 값 JSON 배열) · B07 픽스처 배관 측점(139-6 과 같은 자료). + */ +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: () => () => "" }); + +/** 아주 작은 SVG 가짜 — 글 자리 판정에 쓰는 것만(class 고르기 · 속성 · 부모). */ +class El { + constructor(tag) { + this.tag = tag; + this.attrs = {}; + this.children = []; + this.parentNode = null; + this.ownerSVGElement = null; + this.textContent = ""; + } + setAttribute(k, v) { + this.attrs[k] = String(v); + } + getAttribute(k) { + return this.attrs[k] ?? null; + } + appendChild(c) { + c.parentNode = this; + c.ownerSVGElement = this.tag === "svg" ? this : this.ownerSVGElement; + this.children.push(c); + return c; + } + append(...cs) { + cs.forEach((c) => this.appendChild(c)); + } + addEventListener() {} + all() { + return this.children.flatMap((c) => [c, ...c.all()]); + } + querySelectorAll(sel) { + return this.all().filter((e) => (e.attrs.class ?? "").split(" ").includes(sel.slice(1))); + } + querySelector(sel) { + return this.querySelectorAll(sel)[0] ?? null; + } +} +globalThis.document = { addEventListener: () => undefined, createElementNS: (_ns, t) => new El(t) }; + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + if (full.endsWith(".json")) return JSON.parse(fs.readFileSync(full, "utf8")); + 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 resolve = (base) => + fs.existsSync(base) && fs.statSync(base).isFile() ? base : `${base}.ts`; + const req = (r) => { + if (r.startsWith("@ui/") || r.startsWith("@config/")) return noop; + if (r.startsWith("@util/")) return loadTs(resolve(path.join(ROOT, "common_util", r.slice(6)))); + return loadTs(resolve(path.join(path.dirname(full), r))); + }; + new Function("exports", "module", "require", js)(box.exports, box, req); + cache.set(full, box.exports); + return box.exports; +} + +const B06 = (name) => loadTs(path.join(ROOT, "B06_Section", `${name}.ts`)); +const { computeCulvertLayout } = B06("B06_Section_UI_Cross_Culvert_Geom"); +const { cutLength } = B06("B06_Section_UI_Cross_Culvert_Solve"); +const { pipeLengthText, pipeLengthWhole } = B06("B06_Section_UI_Cross_Culvert_Const"); +const { appendPipeInfoText } = B06("B06_Section_UI_Cross_Culvert_Types"); +const { structurePanelDeps } = B06("B06_Section_UI_Cross_View_Structure"); + +// ① 글 자리 — 노폭 · 노견 라벨(x 200 · y 180) 바로 위 줄 · 둘이면 한 줄 더 위 · 라벨 없으면 fallback +const svg = new El("svg"); +const road = new El("text"); +road.setAttribute("class", "b06-chart__carriageway-label"); +road.setAttribute("x", 200); +road.setAttribute("y", 180); +svg.appendChild(road); +const plot = new El("g"); +svg.appendChild(plot); +appendPipeInfoText(plot, "관매설 Φ600m/m, L=8 m (7.6)", null); +appendPipeInfoText(plot, "세월교 L8m", [10, 10]); +const infos = svg.querySelectorAll(".b06-chart__pipe-info").map((e) => ({ + x: Number(e.getAttribute("x")), + y: Number(e.getAttribute("y")), + text: e.textContent, + inSvg: e.parentNode === svg, +})); +const bare = new El("svg"); +appendPipeInfoText(bare, "관", null); +appendPipeInfoText(bare, "세월교", [10, 20]); +const bareInfos = bare.querySelectorAll(".b06-chart__pipe-info").map((e) => ({ + x: Number(e.getAttribute("x")), + y: Number(e.getAttribute("y")), + text: e.textContent, +})); + +// ② 0.1 m 이동 — 걸음 분류 · 유출 벽을 0.1 m 씩 밀면 관 길이가 벽 자리를 따라 0.1 m 로 바뀜 +const fine = (key, hiddenPipe, fordGuard) => + structurePanelDeps({ + hiddenPipe: () => hiddenPipe, + fordGuard: () => fordGuard, + isLinked: false, + }).fineMove(key); +const steps = { + pipeOutlet: fine("outlet", false, false), + pipeInlet: fine("inlet", false, false), + fordGuard: fine("outlet", true, true), + hiddenOwn: fine("outlet", true, false), +}; +const sections = JSON.parse( + fs.readFileSync(path.join(__dirname, "fixtures", "b07_cross_overlay_sections.json"), "utf8"), +).cross_sections; +const section = sections.find((s) => s.culvert && !s.culvert.hidden_pipe); +const r3 = (v) => Math.round(v * 1000) / 1000; +// ◀/▶ 한 번 = x 0.1 + d 빈 칸은 지금 자리로 묶음(옛 기본 d 로 튀지 않게) +const patches = []; +const nudgeDeps = structurePanelDeps({ + adjustOf: () => ({ x: 0, d: null, h: null, m: null }), + appliedD: () => new Map([["outlet", 0.4]]), + outwardOf: () => 1, + adjustChainage: () => 0, + revetOffset: { update: (_c, key, patch) => patches.push({ key, ...patch }) }, +}); +nudgeDeps.nudge("outlet", 0.1); +nudgeDeps.nudge("extra0", 0.1); +const base = computeCulvertLayout(section, section.samples); +const d0 = base.revetShift.outlet.d; +const moved = [0, 0.1, 0.2, 0.3].map((x) => { + const layout = computeCulvertLayout(section, section.samples, { + outlet: { x, d: x ? d0 : null, h: null, m: null }, + }); + return { x, lengthM: layout.pipe.lengthM, cut: r3(cutLength(layout.pipeCorners)) }; +}); + +// ③ · ④ 표시 · 반올림 거울 +const values = JSON.parse(process.argv[2] ?? "[]"); +process.stdout.write( + JSON.stringify({ + infos, + bareInfos, + steps, + patches, + moved, + text: values.map((v) => pipeLengthText(v)), + whole: values.map((v) => pipeLengthWhole(v)), + }), +); diff --git a/resources/tester/test_108_drag_box_hit.py b/resources/tester/test_108_drag_box_hit.py index 78a1ba35d..a5033b204 100644 --- a/resources/tester/test_108_drag_box_hit.py +++ b/resources/tester/test_108_drag_box_hit.py @@ -31,9 +31,9 @@ def out() -> dict: @pytest.mark.parametrize("side", ["diag1", "diag-1"]) def test_대각_끌기는_두_축이_함께_걸음_배수로(out, side) -> None: - """배관 기준벽(1 m 걸음) — 사면 d 두 칸 + 수평 x 한 칸이 한 번에 · 값은 걸음 배수.""" + """배관 기준벽(0.1 m 걸음 · 225-1) — 사면 d · 수평 x 가 한 번에 · 값은 걸음 배수.""" case = out["wall"][side] - assert case["update"] == {"role": "outlet", "x": 1, "d": 4} + assert case["update"] == {"role": "outlet", "x": 1.3, "d": 4} assert "◀" in case["snap"]["text"] or "▶" in case["snap"]["text"] assert "▼ 2.0m" in case["snap"]["text"] diff --git a/resources/tester/test_139_6_pipe_fit.py b/resources/tester/test_139_6_pipe_fit.py index 34c642776..d2959f13a 100644 --- a/resources/tester/test_139_6_pipe_fit.py +++ b/resources/tester/test_139_6_pipe_fit.py @@ -1,7 +1,7 @@ """139-6 노견을 넓힌 유출 쪽 관 길이 자리 — 사면 우선(물매 1:1.2 그대로) + 유출 벽 높이 역산(0.1 m). 설계선 엔진으로 노견 확장 설계를 내고 배수관 기하를 돌린 값(`helper_139_6_pipe_fit.cjs` · B07 픽스처 -배관 85.052 · 유출 = 좌). 안 넓힌 측점은 종전 맞춤 그대로(나눔 전 코드와 같은 값 · 5.772 → 표기 6). +배관 85.052 · 유출 = 좌). 225-1 부터 정수 길이 맞춤 · 높이 역산 없음 — 관 길이 = 그려진 길이 0.1 m. """ import json @@ -40,8 +40,9 @@ def out(tmp_path_factory) -> dict: def test_안_넓힌_측점은_종전_그대로(out: dict) -> None: + """225-1 — 정수 맞춤 없앰: 벽은 기본 자리 · 관 길이 = 그려진 길이 0.1 m(5.772 → 5.8).""" base = out["base"] - assert (base["edge"], base["pureHeight"], base["lengthM"]) == (2, 2, 6) + assert (base["edge"], base["pureHeight"], base["lengthM"]) == (2, 2, 5.8) assert base["cut"] == pytest.approx(5.772) @@ -51,10 +52,9 @@ def test_넓힌_쪽은_물매_그대로_높이_역산(out: dict, name: str, exte assert case["edge"] == pytest.approx(2 + extend) and case["extend"] == extend assert case["slope"] == pytest.approx(1.2, abs=0.05) # 사면 우선 — 물매 그대로 assert round(case["pureHeight"] * 10) == pytest.approx(case["pureHeight"] * 10) # 0.1 m 눈금 - assert case["lengthM"] - case["cut"] < 0.1 # 눈금 탓 남는 차만 표기 올림 - assert float(case["lengthM"]).is_integer() + assert case["lengthM"] == round(case["cut"], 1) # 225-1 — 길이는 벽 자리에서 셈(올림 없음) def test_높이가_실제로_바뀜(out: dict) -> None: - """대조 — 1.3 m 넓히면 유출 벽 높이가 2.0 → 1.8 로 역산됨(시험이 높이 길을 실제로 잼).""" - assert out["shifted2"]["pureHeight"] == pytest.approx(1.8) + """225-1 — 길이 맞춤 높이 역산 없앰: 1.3 m 넓혀도 유출 벽 높이는 기본 2.0 그대로.""" + assert out["shifted2"]["pureHeight"] == pytest.approx(2.0) diff --git a/resources/tester/test_186_1_outlet_fold.py b/resources/tester/test_186_1_outlet_fold.py index c477551ca..4a173b744 100644 --- a/resources/tester/test_186_1_outlet_fold.py +++ b/resources/tester/test_186_1_outlet_fold.py @@ -63,7 +63,8 @@ def test_저장값_사본_면적(out: dict) -> None: """접힌 폐회로(절토 27.94 · 성토 0.34 ㎡) → 노견~벽~3도 선 절토만.""" assert out["stored"]["cut"] == pytest.approx(34.0391, abs=1e-3) assert out["stored"]["fill"] == 0 - assert out["auto"]["cut"] == pytest.approx(16.8211, abs=1e-3) + # 225-1 — 유출 벽이 관 길이 맞춤으로 밀리지 않고 기본 자리(16.8211 → 16.0929) + assert out["auto"]["cut"] == pytest.approx(16.0929, abs=1e-3) def test_절토_쪽_배관_유출도_안_접힘(out: dict) -> None: diff --git a/resources/tester/test_225_1_pipe_round.py b/resources/tester/test_225_1_pipe_round.py new file mode 100644 index 000000000..ac99b55a7 --- /dev/null +++ b/resources/tester/test_225_1_pipe_round.py @@ -0,0 +1,117 @@ +"""225-1 B06 관 정보 글 자리 · 관보호공 0.1 m 이동 · 관 길이 표시 반올림 · B08 집계 정수. + +화면 TS 를 그 자리에서 돌린 값(`helper_225_1_pipe_round.cjs`) + 파이썬 집계(`M02_Table_Fill`). +짝(반올림) = TS `pipeLengthWhole`(`B06_Section_UI_Cross_Culvert_Const.ts`) ↔ +파이썬 `pipe_lengths_from_designs` — 사사오입 · 0.5 올림. +""" + +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)) + +from M02_MasterTemplete.M02_Table_Fill import pipe_lengths_from_designs # noqa: E402 + +HELPER = Path(__file__).parent / "helper_225_1_pipe_round.cjs" +VALUES = [0.5, 1.4, 1.5, 2.5, 7.4, 7.5, 7.6, 10.0] + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" +) + + +@pytest.fixture(scope="module") +def out() -> dict: + done = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HELPER), json.dumps(VALUES)], + 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: + """노폭 · 노견 라벨(y 180) 위 줄 = 180 − 13 · 세월교 글이 또 있으면 한 줄 더 위 · 같은 x.""" + pipe, ford = out["infos"] + assert (pipe["x"], pipe["y"], pipe["inSvg"]) == (200, 167, True) + assert pipe["text"] == "관매설 Φ600m/m, L=8 m (7.6)" + assert (ford["x"], ford["y"]) == (200, 154) + + +def test_노폭_라벨_없으면_옛_자리_또는_안_그림(out: dict) -> None: + """B07 덧그림(라벨 없음) — 관 글은 안 그림 · 세월교 글은 옛 자리.""" + assert out["bareInfos"] == [{"x": 10, "y": 20, "text": "세월교"}] + + +def test_관보호공은_0_1m_걸음_세월교_흙막이만_1m(out: dict) -> None: + assert out["steps"] == { + "pipeOutlet": True, + "pipeInlet": True, + "fordGuard": False, + "hiddenOwn": True, + } + + +def test_좌우_한_번은_d_를_지금_자리로_묶음(out: dict) -> None: + """d 빈 칸 그대로 x 만 넣으면 옛 기본 d(경사길이 4 m)로 튐 — 배관 기준벽만 묶음.""" + assert out["patches"] == [ + {"key": "outlet", "x": 0.1, "d": 0.4}, + {"key": "extra0", "x": 0.1}, + ] + + +def test_관_길이는_벽_자리에서_0_1m_로(out: dict) -> None: + """유출 벽을 0.1 m 씩 밀면 관 길이도 0.1 m 씩 — 정수 올림 · 길이 맞춤 이동 없음.""" + lengths = [case["lengthM"] for case in out["moved"]] + assert lengths == [7.9, 8.0, 8.1, 8.2] + for case in out["moved"]: + assert case["lengthM"] == round(case["cut"], 1) + + +def test_표시는_정수_괄호_소수_첫째(out: dict) -> None: + assert out["text"][VALUES.index(7.6)] == "8 m (7.6)" + assert out["text"][VALUES.index(7.4)] == "7 m (7.4)" + assert out["text"][VALUES.index(7.5)] == "8 m (7.5)" + + +def test_B08_집계_값은_사사오입_정수() -> None: + designs = [ + {"chainage_m": 10, "design": {"pipe_length_m": 7.6}}, + {"chainage_m": 20, "design": {"pipe_length_m": 7.5}}, + {"chainage_m": 30, "design": {"pipe_length_m": 7.4}}, + ] + assert pipe_lengths_from_designs(designs) == {10.0: 8, 20.0: 8, 30.0: 7} + + +def test_반올림_짝_거울(out: dict) -> None: + """TS `pipeLengthWhole` ↔ 파이썬 집계 — 같은 값마다 같은 정수(0.5 올림).""" + designs = [{"chainage_m": i, "design": {"pipe_length_m": v}} for i, v in enumerate(VALUES)] + py = pipe_lengths_from_designs(designs) + assert [py[float(i)] for i in range(len(VALUES))] == out["whole"] + assert out["whole"] == [1, 1, 2, 3, 7, 8, 8, 10] + ts = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Culvert_Const.ts").read_text( + encoding="utf-8" + ) + assert "Math.floor(lengthM + 0.5)" in ts and "M02_Table_Fill.pipe_lengths_from_designs" in ts + src = (ROOT / "M02_MasterTemplete" / "M02_Table_Fill.py").read_text(encoding="utf-8") + assert "int(length + 0.5)" in src and "pipeLengthWhole" in src + + +def test_카드_글과_좌측_패널이_같은_표시를_씀() -> None: + card = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Culvert.ts").read_text(encoding="utf-8") + assert ( + "L=${pipeLengthText(pipe.lengthM)}" in card and "appendPipeInfoText(layer, pipeInfo" in card + ) + panel = (ROOT / "B05_Profile" / "B05_Profile_UI_Drainage_Facility.ts").read_text( + encoding="utf-8" + ) + assert '"횡단 설계 뒤 셈" : pipeLengthText(length)' in panel diff --git a/resources/tester/test_79_guard_shape.py b/resources/tester/test_79_guard_shape.py index a89e33d92..8d40967f0 100644 --- a/resources/tester/test_79_guard_shape.py +++ b/resources/tester/test_79_guard_shape.py @@ -111,11 +111,11 @@ def test_관_보호공_기본_자리는_제일_위(shape: dict) -> None: assert pipe is not None # 벽 윗면 = 관 윗면(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) + # 유출은 기본 자리(225-1 — 관 길이 맞춤 이동 없음 · 종전 기본 = 경사길이 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.5, abs=0.05) - assert float(pipe["lengthM"]).is_integer() + assert round(pipe["lengthM"], 1) == pipe["lengthM"] # 225-1 — 실제 길이 0.1 m def test_저장_x_있는_관_보호공은_종전_자리_그대로(shape: dict) -> None: