diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index 0abb514b..d0a4b7d4 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -207,8 +207,12 @@ export interface CulvertSideSpec { /** 집수정 기준측점 전/후 몫(m) — 기슭막이와 같은 체계(2026-08-24). 기본 각 1m. */ basin_before_m?: number | null; basin_after_m?: number | null; - /** 기슭막이 전면 기울기(1:n). 돌쌓기 전면 1:0.3(교본 7-3). */ + /** 기슭막이 전면 기울기(1:n) 종전값 — 그림은 `revetWallSpec().lean`(표준경사 표)을 씀. */ face_slope?: number; + /** 독립 기슭막이 기울기 판정 칸(저장 원본) — 설치 측 · 성토/절토 · 전면 기울기(B08 과 같은 칸). */ + face_side?: string | null; + face_role?: string | null; + face_slope_ratio?: number | string | null; /** 보호공(물받이) 길이 = 낙차고 × 2 (사방교본 교차 참조, 2026-08-19 사용자 확정). */ apron_length_m?: number; /** 보호공 두께 1.0m 내외 (사방교본 교차 참조). */ diff --git a/B06_Section/B06_Section_Engine_Culvert.py b/B06_Section/B06_Section_Engine_Culvert.py index db99b34d..5619c0fb 100644 --- a/B06_Section/B06_Section_Engine_Culvert.py +++ b/B06_Section/B06_Section_Engine_Culvert.py @@ -273,6 +273,11 @@ def _revet_side(values: dict[str, Any], role: str) -> dict[str, Any]: values.get(f"{role}_revet_after_m"), _number(values.get("after_m"), None) ), "face_slope": REVET_FACE_SLOPE, + # 전면 기울기를 B08 과 같은 칸으로 가르는 값 — 저장 원본 그대로(2026-09-14 B5 · TS `revetSide`). + # 설치 측은 `side` 기본값(양쪽)을 붙이기 **전** 값이라야 B08(빈 칸 = 자동)과 같게 갈림. + "face_side": values.get("side"), + "face_role": values.get("face_role"), + "face_slope_ratio": values.get("face_slope_ratio"), } if height is not None and height > 0: spec["apron_length_m"] = round(height * APRON_LENGTH_FACTOR, 3) diff --git a/B06_Section/B06_Section_Engine_Structures_Wall.py b/B06_Section/B06_Section_Engine_Structures_Wall.py index 637ebcec..0a57a0ae 100644 --- a/B06_Section/B06_Section_Engine_Structures_Wall.py +++ b/B06_Section/B06_Section_Engine_Structures_Wall.py @@ -79,6 +79,9 @@ def load_wall_structures(project_root: Path) -> list[dict[str, Any]]: "tiers": options.get("tiers"), "lift_m": options.get("lift_m"), "shift_m": options.get("shift_m"), + # 전면 기울기를 B08 과 같은 칸으로 가르는 값 — 저장 원본(2026-09-14 B5 · TS 짝 같은 키). + "face_role": options.get("face_role"), + "face_slope_ratio": options.get("face_slope_ratio"), } ) return found diff --git a/B06_Section/B06_Section_UI_Cross_Culvert.ts b/B06_Section/B06_Section_UI_Cross_Culvert.ts index 37865f31..25955b46 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert.ts @@ -13,7 +13,6 @@ import { REVET_EMBED_DEPTH_M, FILL_SLOPE_RATIO_MIN, - REVET_LEAN_RATIO, pipeWallThicknessM, revetHeightLimit, } from "./B06_Section_UI_Cross_Culvert_Geom"; @@ -183,7 +182,7 @@ export function appendCulvertOverlay( `${roleLabel(wall.role)} 기슭막이 ${wall.form ?? ""} H=${( wall.height + REVET_EMBED_DEPTH_M ).toFixed(1)}m` + - `(상단 = 사면선 접점, 전면 1:${REVET_LEAN_RATIO}` + + `(상단 = 사면선 접점, 전면 1:${wall.lean}(품셈 13-4-4 [주]⑪ 표준경사)` + `, 높이 한계 ${revetHeightLimit(wall.form).toFixed(1)}m — 교본 7-3)` + (wall.floatGapM > 0.01 ? ` · ⚠ 바닥 원지반 이격 ${wall.floatGapM.toFixed(2)}m — 하부 지지 구조물 별도(추가 예정)` diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts index e12be186..0d49b75b 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts @@ -16,7 +16,8 @@ export const MIN_PIPE_COVER_M = 0.5; /** 기슭막이 벽 두께(m) — 실무 견치돌 뒷길이 관측치 45㎝(울진 L3=45). 표시용 형상 값. */ export const REVET_THICKNESS_M = 0.45; -/** 기슭막이 전면 기울기(1:n) — 돌쌓기 전면 1:0.3(교본 7-3). 벽이 사면 쪽으로 기운다. */ +/** 기슭막이 전면 기울기(1:n) **종전값** — 돌쌓기가 아닌 형태(콘크리트·돌망태…)와 성토/절토를 + * 못 가른 자리만 이 값. 돌쌓기 벽은 `B06_Section_UI_Cross_Lean.leanOf`(품셈 13-4-4 [주]⑪ 표준경사). */ export const REVET_LEAN_RATIO = 0.3; /** 돌붙임 두께(m) — 실무 돌붙임 L3=45(울진 1공구 수량집계표). */ diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts index 3eb2a177..c171f35c 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts @@ -6,7 +6,7 @@ * (2026-08-22 사용자 확정 규칙): * · 기슭막이 사이 성토사면은 **정확히 1:1.2** — 벽 높이를 자리에 맞춰 역산한다. * · 아랫단 벽 상단은 윗단 벽 **하단(수평 기초선)을 뚫고 올라갈 수 없다**. - * · 다음 단 성토선 시작점 = 윗단 벽 **하단 수평선 +0.5m와 전면 경사선(1:0.3)의 + * · 다음 단 성토선 시작점 = 윗단 벽 **하단 수평선 +0.5m와 전면 경사선(1:n · 표준경사)의 * 교차점**(배관 기슭막이의 관 하단 꼭짓점과 같은 자리 — 배관만 없다). * · 그 시작점이 원지반 아래면(벽이 0.5m 이상 묻힘) **성토 불필요** — 다단 종료. * ========================================================================== */ @@ -48,6 +48,8 @@ export interface OutletExtrasInput { ownerForm?: string | null; /** 1회성 등간격 배치(2026-08-22 사용자 ①) — 단별 d를 사면 구간이 같아지게 재계산. */ equalize?: boolean; + /** 단 벽의 전면 기울기 — 형태·순수 높이로 표준경사를 고름(없으면 종전 1:0.3). */ + leanFor?: (form: string, pureHeightM: number) => number; } export interface OutletExtrasResult { @@ -167,9 +169,9 @@ export function inletGroundConnector( return null; } -/** 벽 중심(하단 중점)에서 이음선 상단점까지의 수평거리 — 높이에 따라 커진다. */ -function jointRunOf(height: number): number { - return 0.25 * REVET_THICKNESS_M + (REVET_LEAN_RATIO / 2) * height; +/** 벽 중심(하단 중점)에서 이음선 상단점까지의 수평거리 — 높이·기울기에 따라 커진다. */ +function jointRunOf(height: number, lean: number): number { + return 0.25 * REVET_THICKNESS_M + (lean / 2) * height; } /** 끝 성토부선 — src에서 1:1.2로 내려가며 원지반을 만나면 끝(지반이 높으면 지반 따름). */ @@ -226,16 +228,17 @@ function buildExtraWall( material: RevetMaterial, floatGapM: number, form: string | null = null, + lean: number = REVET_LEAN_RATIO, ): WallLayout { const thickness = REVET_THICKNESS_M; - const baseWidth = thickness * 1.5 + REVET_LEAN_RATIO * height; + const baseWidth = thickness * 1.5 + lean * height; const backOffset = anchor.offset - outward * (baseWidth / 2); const topJoint = backOffset + outward * (thickness / 2); const topElevation = anchor.elevation + height; const topBack: OffsetPoint = { offset: backOffset, elevation: topElevation }; const topFront = topJoint + outward * thickness; const frontXAt = (elevation: number): number => - topFront + outward * REVET_LEAN_RATIO * (topElevation - elevation); + topFront + outward * lean * (topElevation - elevation); // 하단 = 기준선(anchor) 아래 근입 0.5m **고정** — 원지반에 묻혀도 바닥을 더 // 내리지 않는다. 벽 형상은 높이값으로만 정한다(2026-08-23 사용자 확정 — // 종전 전면 발끝 지반 추적 수렴 삭제). @@ -256,6 +259,7 @@ function buildExtraWall( height, floatGapM, material, + lean, outward, topBack, topJoint: { offset: topJoint, elevation: topElevation }, @@ -281,7 +285,6 @@ function buildExtraWall( export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult { const { outward, groundAt, limitOffset } = input; const thickness = REVET_THICKNESS_M; - const heightDenominator = 1 - REVET_LEAN_RATIO / 2 / FILL_SLOPE_RATIO_MIN; /** 다단 1회 전개 — plan: "user"(조작값) / "greedy"(등간격 1차 근사) / d 명시 배열. */ const cascadeOnce = (plan: "user" | "greedy" | number[]): OutletExtrasResult => { const walls: WallLayout[] = []; @@ -315,6 +318,9 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult /** 기준선(근입 위)~상단 — 도형·자리 계산은 종전대로 이 값으로 한다. * 지형이 요청 높이만큼 안 나오면 아래 스캔 뒤 **낮춰서** 바닥을 지반에 앉힌다. */ let exposed = height - REVET_EMBED_DEPTH_M; + // 전면 기울기 — 형태·요청 높이로 표준경사(2026-09-14 B5). 자리 닫힌식의 분모도 따라감. + const lean = input.leanFor?.(form, height) ?? REVET_LEAN_RATIO; + const heightDenominator = 1 - lean / 2 / FILL_SLOPE_RATIO_MIN; /** 자리 x(벽 하단 중점)에 지반 안착 + 상단이 성토선에 닿는 데 필요한 높이. */ const heightAt = (x: number): number => { @@ -350,7 +356,7 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult } if (autoOffset === null) autoOffset = bestOffset; if (autoOffset === null) break; // 세울 만한 지형이 아니다 — 되는 만큼만. - const autoJointRun = (autoOffset - src.offset) * outward - jointRunOf(exposed); + const autoJointRun = (autoOffset - src.offset) * outward - jointRunOf(exposed, lean); /** 자동 자리(선반 0·수직 0)의 벽 상단 표고 — 조작은 여기서부터 잰다. */ const autoTop = src.elevation - autoJointRun / FILL_SLOPE_RATIO_MIN; // 관통 금지: 상단 ≤ 윗단 하단 → 그만큼은 반드시 내려간다. @@ -378,7 +384,7 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult const placeable = (xShift: number, vShift: number): boolean => { const topE = autoTop - vShift; const aX = autoOffset + outward * xShift; - const jointX = aX - outward * jointRunOf(exposed); + const jointX = aX - outward * jointRunOf(exposed, lean); if ( Math.max(groundAt(jointX), groundAt(jointX + outward * REVET_THICKNESS_M)) >= topE - 0.01 @@ -388,7 +394,7 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult const bottom = bottomAt(aX, baseE); const startE = bottom + REVET_EMBED_DEPTH_M; const topFrontX = jointX + outward * REVET_THICKNESS_M; - const startX = topFrontX + outward * REVET_LEAN_RATIO * (topE - startE); + const startX = topFrontX + outward * lean * (topE - startE); if (groundAt(startX) < startE + 0.05) return true; // 매몰 — 3도 절토선(계류 쪽 내림)이 원지반과 다시 만나면 허용, 아니면 이동 금지. return ( @@ -438,6 +444,7 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult material, Math.max(0, base - groundBase), form, + lean, ); // 요청한 좌우 이동을 지형이 막았으면 그 양을 남긴다 — 「눌러도 안 움직인다」의 까닭을 // 툴팁으로 알리기 위함이다(2026-09-06 실측: 다섯 측점 중 넷이 1.0m 요청에 0.0m 이동). @@ -479,8 +486,7 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult const startElevation = wall.bottomBack.elevation + REVET_EMBED_DEPTH_M; src = { offset: - wall.points[2].offset + - outward * REVET_LEAN_RATIO * (wall.topJoint.elevation - startElevation), + wall.points[2].offset + outward * wall.lean * (wall.topJoint.elevation - startElevation), elevation: startElevation, }; prevBottom = wall.bottomBack.elevation; diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts index 6c484416..da2ef841 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts @@ -2,7 +2,7 @@ * B06_Section_UI_Cross_Culvert_Geom.ts * 배수관 세트(배관·기슭막이·성토부) **기하 계산** — 그리기(`_Cross_Culvert.ts`)와 분리. * 백엔드 `section.culvert` 제원을 좌표로 옮긴다(치수 결정 금지). 기슭막이는 전면 - * 1:0.3 평행사변형, 관 끝단면은 구조물 변과 평행, 성토 경사선은 벽 접점에서 끊는다 + * 1:n 평행사변형(n = 품셈 표준경사 · `leanOf`), 관 끝단면은 구조물 변과 평행, 성토 경사선은 벽 접점에서 끊는다 * (designTrim). 유입 = 상단측. 집수정은 `_Basin.ts`, 성토부·다단은 `_Extra.ts`. * ========================================================================== */ @@ -24,6 +24,7 @@ import { revetTargetHeight, } from "./B06_Section_UI_Cross_Culvert_Const"; import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const"; +import { leanOf } from "./B06_Section_UI_Cross_Lean"; import { ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types"; import type { BasinLayout, @@ -171,8 +172,19 @@ export function computeCulvertLayout( const adjustOf = (value?: WallAdjust): WallAdjust => ({ ...ZERO_ADJUST, ...(value ?? {}) }); const adjInlet = adjustOf(revetShift?.inlet); const adjOutlet = adjustOf(revetShift?.outlet); - const pipeWallSpec = (spec: CulvertSideSpec, adjust: WallAdjust) => - revetWallSpec(spec, adjust, culvert.hidden_pipe === true, diameter); + // 전면 기울기 = 표준경사 표(단면유형 × 설치 측 × 형태 × 높이 — B08 과 한 벌, 2026-09-14 B5). + const pipeWallSpec = (spec: CulvertSideSpec, adjust: WallAdjust) => { + const wall = revetWallSpec(spec, adjust, culvert.hidden_pipe === true, diameter); + const lean = leanOf({ + form: wall.form, + height_m: wall.pureHeight, + section_mode: section.design?.section_mode, + side: culvert.hidden_pipe ? spec.face_side : null, + face_role: spec.face_role, + face_slope_ratio: spec.face_slope_ratio, + }); + return { ...wall, lean }; + }; const inletWallSpec = pipeWallSpec(culvert.inlet, adjInlet); const outletWallSpec = pipeWallSpec(culvert.outlet, adjOutlet); // 조정창 선택지 가용성 — 판정은 Basin이 맡는다. @@ -204,14 +216,21 @@ export function computeCulvertLayout( edge: { offset_m: number }, outward: number, wallHeight: number, - ): number => edge.offset_m + outward * (fillWallBaseWidth(wallHeight) / 2 - REVET_TRAP_TOP_M); + lean: number, + ): number => + edge.offset_m + outward * (fillWallBaseWidth(wallHeight, lean) / 2 - REVET_TRAP_TOP_M); if (!basinReason && designAt) { const inletBaseAt = (offset: number): number => Math.min(groundAt(offset), invertCap(inletInfo.edge)); // 4축 배치는 공용 풀이(placeInletWall — Solve). 유입을 내리면 유출도 따라 // 내려가므로, 유출이 못 받으면 유입도 못 내려간다(outletGuard). const placed = placeInletWall({ - autoOffset: slopeZeroAnchor(inletInfo.edge, inletInfo.outward, inletWallSpec.height), + autoOffset: slopeZeroAnchor( + inletInfo.edge, + inletInfo.outward, + inletWallSpec.height, + inletWallSpec.lean, + ), outward: inletInfo.outward, height: inletWallSpec.height, baseElevation0: inletInfo.edge.elevation_m - inletWallSpec.height, @@ -238,6 +257,7 @@ export function computeCulvertLayout( outletInfo.limit, ), limitOffset: outletInfo.limit, + lean: outletWallSpec.lean, }, }); appliedAdjust.inlet.x = placed.x; @@ -308,6 +328,7 @@ export function computeCulvertLayout( vertical: WallVertical | null = null, material: RevetMaterial = "dry", formLabel: string | null = null, + lean: number = REVET_LEAN_RATIO, ): WallLayout | null => { // spec의 "집수정"은 ruleReason에 이미 반영 — 여기서 되살리면 revet 선택이 깨진다. const reason: BasinLayout["reason"] | null = forceBasinReason; @@ -340,13 +361,13 @@ export function computeCulvertLayout( } return null; } - // 형상: 배면 수직 + 계류측 1:0.3 평행사변형 띠. 높이는 vertical(계산용)이 들고 온다. + // 형상: 배면 수직 + 계류측 1:n 평행사변형 띠(n = 표준경사). 높이는 vertical(계산용)이 들고 온다. const height = vertical?.height ?? Math.min(revetTargetHeight(diameter), revetHeightLimit(spec.revet_form)); if (!(height > 0.05)) return null; const floatGapM = vertical?.floatGapM ?? 0; // 자리 기준 = **하단선 중점**(2026-08-21) — anchor.elevation은 그 자리 관 invert. - const baseWidth = thickness * 1.5 + REVET_LEAN_RATIO * height; + const baseWidth = thickness * 1.5 + lean * height; const backOffset = anchor.offset - outward * (baseWidth / 2); const topJoint = backOffset + outward * (thickness / 2); const topElevation = anchor.elevation + height; @@ -357,7 +378,7 @@ export function computeCulvertLayout( // 원지반에 묻혀도 바닥을 더 내리지 않는다. 벽 형상은 높이값으로만 정한다 // (2026-08-23 사용자 확정 — 종전 전면 발끝 지반 추적 수렴 삭제). const frontXAt = (elevation: number): number => - topFront + outward * REVET_LEAN_RATIO * (topElevation - elevation); + topFront + outward * lean * (topElevation - elevation); const bottomElevation = anchor.elevation - REVET_EMBED_DEPTH_M; const bottomBack: OffsetPoint = { offset: backOffset, elevation: bottomElevation }; const bottomFront: OffsetPoint = { @@ -376,6 +397,7 @@ export function computeCulvertLayout( height, floatGapM, material, + lean, outward, topBack, topJoint: { offset: topJoint, elevation: topElevation }, @@ -411,6 +433,7 @@ export function computeCulvertLayout( wallVertical.inlet, inletWallSpec.material, inletWallSpec.form, + inletWallSpec.lean, ); // 유출 벽 밑 = 그 자리 원지반(역경사는 유입 invert로 클램프 — 2026-08-21). const invertAt = (offset: number): number => Math.min(groundAt(offset), inlet.elevation); @@ -421,6 +444,7 @@ export function computeCulvertLayout( outletInfo.edge, outletInfo.outward, outletWallSpec.height, + outletWallSpec.lean, ); const outletZeroInvert = outletInfo.edge.elevation_m - outletWallSpec.height; const outletLineInvertAt = (offset: number): number => @@ -461,6 +485,7 @@ export function computeCulvertLayout( wallVertical.outlet, outletWallSpec.material, outletWallSpec.form, + outletWallSpec.lean, ); // ── 관 축 확정 — 관 하단선은 시작점과 유출 벽 전면 기준선 교차점을 잇는다. const pipeStart = basinPipeEnd ?? inlet; @@ -471,8 +496,7 @@ export function computeCulvertLayout( const reference = wall.bottomBack.elevation + REVET_EMBED_DEPTH_M; return { offset: - wall.points[2].offset + - wall.outward * REVET_LEAN_RATIO * (wall.topJoint.elevation - reference), + wall.points[2].offset + wall.outward * wall.lean * (wall.topJoint.elevation - reference), elevation: reference, }; }; @@ -527,6 +551,7 @@ export function computeCulvertLayout( }, outletWallSpec.material, outletWallSpec.form, + outletWallSpec.lean, ); // 관 하단선은 옮겨진 벽의 **전면 기준선 교차점**을 지나야 한다(사용자 ①). const face = outletWall ? outletPipeEnd(outletWall) : outletWallAnchor; @@ -599,6 +624,14 @@ export function computeCulvertLayout( ) : null; + // 다단 벽 기울기 — 같은 측점·같은 설치 측 규칙으로 형태·높이마다 표준경사(B08 은 다단을 안 셈). + const extraLean = (form: string, pureHeightM: number): number => + leanOf({ + form, + height_m: pureHeightM, + section_mode: section.design?.section_mode, + side: culvert.hidden_pipe ? culvert.inlet.face_side : null, + }); // 성토부선 + 다단 기슭막이(2026-08-22 — 보호공 삭제, 윗면 선만 성토부선으로). // 유출측과 **집수정 계류측**이 같은 체계를 쓴다: 5m 넘으면 다단을 둘 수 있다. const extras = buildExtrasAt(outletWall ? pipeCorners.outlet.bottom : null, { @@ -609,6 +642,7 @@ export function computeCulvertLayout( adjusts: (revetShift?.extras ?? []).map(adjustOf), ownerForm: outletWallSpec.form, equalize: equalizeExtras === true, + leanFor: extraLean, }); // 독립 기슭막이(관 숨김)는 집수정이 없어 이 채널이 **유입측 벽의 성토부선·다단**이다 — // 유출측(`extras`)과 같은 시작점(관 하단 꼭짓점)·같은 `bextra` 키(2026-08-29 사용자). @@ -624,6 +658,7 @@ export function computeCulvertLayout( adjusts: (revetShift?.basinExtras ?? []).map(adjustOf), ownerForm: inletWallSpec.form, equalize: false, + leanFor: extraLean, }, ); diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts index 500d1d40..964cc7a3 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts @@ -23,9 +23,10 @@ import type { } from "./B06_Section_UI_Cross_Culvert_Types"; import { slopedCrossing } from "./B06_Section_UI_Cross_Culvert_Extra"; -/** 기슭막이 하단선 폭(m) — 사다리꼴 밑변. 자리 기준(하단선 중점) 환산에 쓴다. */ -export function fillWallBaseWidth(height: number): number { - return REVET_THICKNESS_M * 1.5 + REVET_LEAN_RATIO * height; +/** 기슭막이 하단선 폭(m) — 사다리꼴 밑변. 자리 기준(하단선 중점) 환산에 쓴다. + * `lean` = 그 벽의 전면 기울기(표준경사 · `revetWallSpec().lean`). */ +export function fillWallBaseWidth(height: number, lean: number = REVET_LEAN_RATIO): number { + return REVET_THICKNESS_M * 1.5 + lean * height; } /** 유효 지반 샘플 → offset 오름차순 보간기. 범위 밖은 끝값 클램프, 샘플 없으면 null. */ @@ -252,6 +253,7 @@ export function minShoulderWallOffset( baseAt: (offset: number) => number, toeOffset: number, limitOffset: number, + lean: number = REVET_LEAN_RATIO, ): number | null { const span = (limitOffset - edge.offset_m) * outward; if (!(span > 0)) return null; @@ -260,7 +262,7 @@ export function minShoulderWallOffset( const offset = edge.offset_m + outward * ((span * i) / steps); const rise = edge.elevation_m - (baseAt(offset) + height); if (rise < FILL_MIN_RISE_M) continue; - const joint = offset - outward * (fillWallBaseWidth(height) / 2 - REVET_TRAP_TOP_M); + const joint = offset - outward * (fillWallBaseWidth(height, lean) / 2 - REVET_TRAP_TOP_M); if (((joint - edge.offset_m) * outward) / rise < FILL_SLOPE_RATIO_MIN) continue; if ((offset - toeOffset) * outward > 1e-6) { const toeRise = edge.elevation_m - (baseAt(toeOffset) + height); @@ -296,13 +298,14 @@ export function solveWallVertical( groundBase: number, minHeight: number, limitHeight: number, + lean: number = REVET_LEAN_RATIO, ): WallVertical { const gridUp = (value: number): number => Math.ceil(value / REVET_HEIGHT_STEP_M - 1e-9) * REVET_HEIGHT_STEP_M; let height = minHeight; let desiredTop = groundBase + minHeight; for (let i = 0; i < 4; i += 1) { - const joint = anchorOffset - outward * (fillWallBaseWidth(height) / 2 - REVET_TRAP_TOP_M); + const joint = anchorOffset - outward * (fillWallBaseWidth(height, lean) / 2 - REVET_TRAP_TOP_M); const run = Math.max(0, (joint - edge.offset_m) * outward); desiredTop = edge.elevation_m - run / FILL_SLOPE_RATIO_MIN; const next = Math.min(Math.max(gridUp(desiredTop - groundBase), minHeight), limitHeight); @@ -438,6 +441,8 @@ export function outletReceivable(input: { toeOffset: number; limitOffset: number; groundAt: (offset: number) => number; + /** 유출 벽 전면 기울기(표준경사). */ + lean?: number; }): boolean { const invertAt = (offset: number): number => Math.min(input.groundAt(offset), input.inletElevation); @@ -449,6 +454,7 @@ export function outletReceivable(input: { invertAt, input.toeOffset, input.limitOffset, + input.lean, ) ?? input.toeOffset; const invert = invertAt(auto); if (invert >= input.groundAt(auto) - 0.01) return true; @@ -486,6 +492,7 @@ export function placeInletWall(input: { height: number; toeOffset: number; limitOffset: number; + lean?: number; }; /** 매몰-무교차 자리 금지(유출 벽 규칙) — 독립 기슭막이는 유입도 같은 규칙을 탄다. */ requireCrossing?: boolean; diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts index d431a628..abd8416e 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts @@ -80,6 +80,8 @@ export interface WallLayout { shiftFloorM?: number; /** 재질(메/찰/콘크리트) — 높이 한계를 정한다(2026-08-22 사용자). */ material: RevetMaterial; + /** 전면 기울기 1:n 의 n — 품셈 13-4-4 [주]⑪ 표준경사(`wallLeanRatio`, B08 과 한 벌 · 2026-09-14). */ + lean: number; outward: number; /** 합성 단면(하부 사다리꼴 + 상부 평행사변형) 꼭짓점 — 데이터 좌표. */ points: OffsetPoint[]; diff --git a/B06_Section/B06_Section_UI_Cross_Lean.ts b/B06_Section/B06_Section_UI_Cross_Lean.ts new file mode 100644 index 00000000..e6daa9d8 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Lean.ts @@ -0,0 +1,18 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Lean.ts + * 횡단도 벽 전면 기울기 — 품셈 13-4-4 [주]⑪ 표준경사 표(`resources/data_masonry/masonry_slope_*`)를 + * 읽는 자리 하나(2026-09-14 B5 · B08 과 한 벌). 표 읽기 식은 `common_util_masonry_slope.ts`(파이썬 짝). + * + * 따로 뗀 까닭 — 표(JSON)를 가져오는 모듈을 상수 파일에 두면 그 상수를 쓰는 기하 조각을 Node 로 + * 단독 컴파일해 돌리는 시험들이 표 경로를 못 찾아 깨짐. 기하 조각은 기울기를 **값으로 받고**, + * 표를 읽는 것은 이 파일을 부르는 층(배관 세트·독립·C군 벽 배치)만. + * ⚠ 파일 판이 바뀌면 이 import 도 옮길 것 — 시험 `test_b06_wall_lean_table` 이 최신 판을 대조. + * ========================================================================== */ + +import { type WallLeanInput, wallLeanRatio } from "../common_util/common_util_masonry_slope"; +import masonrySlope from "../resources/data_masonry/masonry_slope_2026-01-01.json"; + +/** 벽 전면 기울기 n — 표를 못 고르면 종전 0.3. */ +export function leanOf(input: WallLeanInput): number { + return wallLeanRatio(masonrySlope, input); +} diff --git a/B06_Section/B06_Section_UI_Cross_Revetment.ts b/B06_Section/B06_Section_UI_Cross_Revetment.ts index d6f4578c..8fc0199e 100644 --- a/B06_Section/B06_Section_UI_Cross_Revetment.ts +++ b/B06_Section/B06_Section_UI_Cross_Revetment.ts @@ -23,10 +23,10 @@ import { PIPE_CONNECT_GRADE, PIPE_WALL_DEFAULT_RUN_M, REVET_EMBED_DEPTH_M, - REVET_LEAN_RATIO, REVET_THICKNESS_M, revetHeightLimit, } from "./B06_Section_UI_Cross_Culvert_Const"; +import { leanOf } from "./B06_Section_UI_Cross_Lean"; import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const"; import { fillWallBaseWidth, groundInterpolator } from "./B06_Section_UI_Cross_Culvert_Solve"; import { buildExtrasAt, slopedCrossing } from "./B06_Section_UI_Cross_Culvert_Extra"; @@ -61,8 +61,17 @@ export interface RevetmentSpec { /** (구 모델) 기준 올림·좌우 이동 — 배관식 전환 뒤 자리는 조정창 4축(x·d)이 정한다. */ lift_m?: number | null; shift_m?: number | null; + /** 전면 기울기를 B08 과 같은 칸으로 가르는 값 — 성토/절토 · 전면 기울기(2026-09-14 B5). */ + face_role?: string | null; + face_slope_ratio?: number | string | null; } +/** 표준경사 표를 읽는 벽 종류 → 형태. B08 이 돌쌓기 식으로 세는 것만(큰돌쌓기·옹벽은 종전 0.3). */ +const TABLE_FORM_BY_TYPE: Record = { + masonry_wet: "돌쌓기(찰)", + masonry_dry: "돌쌓기(메)", +}; + /** 조정창 조작값 중 이 벽이 쓰는 축 — 배관 벽과 같은 형태(x 좌우·d 사면·h 높이). */ export interface RevetmentAdjust { x?: number; @@ -187,9 +196,19 @@ export function computeRevetmentLayout( /** 기준선(근입 위)~상단 — 도형 계산은 종전대로 이 값으로 한다. */ const height = pureHeight - REVET_EMBED_DEPTH_M; + // 전면 기울기 — B08 이 그 벽을 셀 때와 같은 칸(종류·요청 높이·단면유형·설치 측·사용자 칸)으로 + // 표준경사를 고름(2026-09-14 B5). 높이는 형태 한계로 자르기 **전** 값 — B08 은 저장 높이를 씀. + const lean = leanOf({ + form: TABLE_FORM_BY_TYPE[spec.type_id] ?? (spec.type_id === "revetment" ? spec.form : null), + height_m: requestedHeight, + section_mode: design.section_mode, + side: spec.side, + face_role: spec.face_role, + face_slope_ratio: spec.face_slope_ratio, + }); const roadSlope = roadSlopePerOutward(design, side, outward); const autoOffset = - edge.offset_m + outward * (fillWallBaseWidth(height) / 2 - REVET_THICKNESS_M / 2); + edge.offset_m + outward * (fillWallBaseWidth(height, lean) / 2 - REVET_THICKNESS_M / 2); // 자동 자리 = 성토사면(1:1.2) 위 기본 지점 — 배관 유출 벽과 같은 기준값. const autoRun = PIPE_WALL_DEFAULT_RUN_M; /** @@ -244,6 +263,7 @@ export function computeRevetmentLayout( form: spec.form, lengthM, floatGapM, + lean, }); // 넣은 좌우 이동이 **각도 하한**에 눌려 통째로 무시됐으면 그 하한을 남긴다(2026-09-07). // 실측 — d 2.6m 자리에서 하한이 1.0m 를 넘어 「1.0m 를 넣어도 0.00m 이동」이 났고, @@ -267,6 +287,13 @@ export function computeRevetmentLayout( // 그려졌다(2026-08-30 사용자: 형태별 모양이 누락되는 경우). ownerForm: spec.form, equalize: false, + leanFor: (form, pureHeightM) => + leanOf({ + form, + height_m: pureHeightM, + section_mode: design.section_mode, + side: spec.side, + }), }) : { walls: [], segments: [], appliedAdjusts: [], addable: false }; @@ -375,7 +402,7 @@ export function appendRevetmentOverlay( const keyId = index === 0 ? "own" : `own-extra${index - 1}`; const tooltip = `독립 기슭막이 ${wall.form ?? ""} H=${(wall.height + REVET_EMBED_DEPTH_M).toFixed(1)}m` + - `(상단 = 성토선 접점, 전면 1:${REVET_LEAN_RATIO}, 높이 한계 ${revetHeightLimit( + `(상단 = 성토선 접점, 전면 1:${wall.lean}(품셈 13-4-4 [주]⑪ 표준경사), 높이 한계 ${revetHeightLimit( wall.form, ).toFixed(1)}m — 교본 7-3)` + (wall.floatGapM > 0.01 diff --git a/B06_Section/B06_Section_UI_Cross_Wall.ts b/B06_Section/B06_Section_UI_Cross_Wall.ts index 2e05e2e6..184090ad 100644 --- a/B06_Section/B06_Section_UI_Cross_Wall.ts +++ b/B06_Section/B06_Section_UI_Cross_Wall.ts @@ -55,6 +55,8 @@ export interface RevetWallGeometryInput { thickness?: number; floatGapM?: number; extraIndex?: number; + /** 전면 기울기 1:n 의 n — 표준경사(`leanOf`). 없으면 종전 0.3. */ + lean?: number; } /** @@ -65,14 +67,15 @@ export interface RevetWallGeometryInput { export function buildRevetWallGeometry(input: RevetWallGeometryInput): WallLayout { const { anchor, outward, height, material, role } = input; const thickness = input.thickness ?? REVET_THICKNESS_M; - const baseWidth = thickness * 1.5 + REVET_LEAN_RATIO * height; + const lean = input.lean ?? REVET_LEAN_RATIO; + const baseWidth = thickness * 1.5 + lean * height; const backOffset = anchor.offset - outward * (baseWidth / 2); const topJoint = backOffset + outward * (thickness / 2); const topElevation = anchor.elevation + height; const topBack: OffsetPoint = { offset: backOffset, elevation: topElevation }; const topFront = topJoint + outward * thickness; const frontXAt = (elevation: number): number => - topFront + outward * REVET_LEAN_RATIO * (topElevation - elevation); + topFront + outward * lean * (topElevation - elevation); const bottomElevation = anchor.elevation - REVET_EMBED_DEPTH_M; const bottomBack: OffsetPoint = { offset: backOffset, elevation: bottomElevation }; const bottomFront: OffsetPoint = { @@ -90,6 +93,7 @@ export function buildRevetWallGeometry(input: RevetWallGeometryInput): WallLayou height, floatGapM: input.floatGapM ?? 0, material, + lean, outward, topBack, topJoint: { offset: topJoint, elevation: topElevation }, diff --git a/common_util/common_util_culvert_sets.ts b/common_util/common_util_culvert_sets.ts index a86dbb7f..92be3434 100644 --- a/common_util/common_util_culvert_sets.ts +++ b/common_util/common_util_culvert_sets.ts @@ -177,6 +177,10 @@ function revetSide(values: Record, role: string): CulvertSetSpe revet_before_m: num(values[`${role}_revet_before_m`], num(values.before_m, null)), revet_after_m: num(values[`${role}_revet_after_m`], num(values.after_m, null)), face_slope: REVET_FACE_SLOPE, + // 전면 기울기를 B08 과 같은 칸으로 가르는 값 — 저장 원본 그대로(파이썬 `_revet_side`). + face_side: values.side ?? null, + face_role: values.face_role ?? null, + face_slope_ratio: values.face_slope_ratio ?? null, }; if (height !== null && height > 0) { spec.apron_length_m = round(height * APRON_LENGTH_FACTOR, 3); diff --git a/common_util/common_util_masonry_slope.ts b/common_util/common_util_masonry_slope.ts new file mode 100644 index 00000000..7b9f1a98 --- /dev/null +++ b/common_util/common_util_masonry_slope.ts @@ -0,0 +1,88 @@ +/* ============================================================================= + * common_util_masonry_slope.ts + * 벽 전면 기울기(1:n 의 n) — 품셈 13-4-4 [주]⑪ 표준경사 표(원문 L7185~7191)를 읽음. + * + * ⚠ **파이썬과 짝**(CLAUDE.md 5장 ①) — `B08_Quantity_Engine_UnitQuantity_StoneSpec.face_slope_ratio` + * + `common_util_structure_face_role.structure_face_role_of`. 거울 시험 + * `resources/tester/test_b06_wall_lean_table.py` 가 네 갈래(직고 × 성토/절토 × 메/찰 + 사용자 칸)를 + * 한 칸씩 대조. 한쪽만 고치면 횡단도 벽과 B08 수량이 갈림(2026-09-14 브레인 판정 「양쪽 다 이 표를」). + * ⚠ 표는 부르는 쪽이 넘김 — 이 파일은 아무것도 import 하지 않음(거울 시험이 단독으로 돌림). + * ========================================================================== */ + +/** 표준경사 표 — `resources/data_masonry/masonry_slope_*.json` 의 모양. */ +export interface MasonrySlopeTable { + steps_m?: number[]; + table?: Record>; +} + +/** 벽 한 매의 기울기 입력 — B08 이 그 벽을 셀 때 보는 칸과 같음. */ +export interface WallLeanInput { + /** 형태 — 「돌쌓기(찰)」·「돌쌓기(메)」만 표를 읽음(나머지는 종전 0.3). */ + form?: string | null; + /** 직고(m) — 순수 높이. */ + height_m: number; + /** 측점 단면유형 `design.section_mode`. */ + section_mode?: string | null; + /** 설치 측 — 「자동(성토 쪽)」·「좌」·「우」(없으면 자동). */ + side?: string | null; + /** 성토/절토 칸 — 고르면 이김. */ + face_role?: string | null; + /** 전면 기울기 칸 — 수(>0)면 이김(파이썬 `_num` 과 같이 글은 안 봄). */ + face_slope_ratio?: number | string | null; +} + +/** 표를 못 고를 때의 종전값 — 파이썬 `LEGACY_FACE_SLOPE_RATIO`. */ +export const LEGACY_LEAN_RATIO = 0.3; + +const FILL = "성토"; +const CUT = "절토"; +/** 형태 → 찰(true)/메(false) — 파이썬 `REVETMENT_STONE_FORMS`. */ +const STONE_FORMS: Record = { "돌쌓기(찰)": true, "돌쌓기(메)": false }; +/** 단면유형 → (좌측 역할, 우측 역할). 좌 = +offset. */ +const MODE_ROLES: Record = { + left_cut: [CUT, FILL], + right_cut: [FILL, CUT], + both_cut: [CUT, CUT], + both_fill: [FILL, FILL], +}; +const LEFT = new Set(["좌", "left", "L"]); +const RIGHT = new Set(["우", "right", "R"]); +const AUTO = new Set(["자동(성토 쪽)", "자동", "auto", ""]); + +/** 성토/절토 — 칸이 이기고, 비면 단면유형 + 설치 측. 못 가르면 `null`(성토로 눅이지 않음). */ +export function faceRoleOf( + sectionMode: string | null | undefined, + side: string | null | undefined, + faceRole?: string | null, +): string | null { + const picked = String(faceRole ?? "").trim(); + if (picked === FILL || picked === CUT) return picked; + const roles = MODE_ROLES[String(sectionMode ?? "").trim()]; + if (!roles) return null; + const where = String(side ?? "").trim(); + if (LEFT.has(where)) return roles[0]; + if (RIGHT.has(where)) return roles[1]; + if (AUTO.has(where)) return roles[0] === CUT && roles[1] === CUT ? null : FILL; + return null; +} + +/** 벽 전면 기울기 n — 표를 못 고르면 종전 0.3. */ +export function wallLeanRatio(table: MasonrySlopeTable, input: WallLeanInput): number { + const wet = STONE_FORMS[String(input.form ?? "").trim()]; + if (wet === undefined) return LEGACY_LEAN_RATIO; + const given = input.face_slope_ratio; + if (typeof given === "number" && Number.isFinite(given) && given > 0) return given; + const face = faceRoleOf(input.section_mode, input.side, input.face_role); + if (!face) return LEGACY_LEAN_RATIO; + const row = table.table?.[wet ? "찰쌓기" : "메쌓기"]?.[face]; + if (!row?.length) return LEGACY_LEAN_RATIO; + const steps = table.steps_m ?? []; + let index = steps.length; + for (let i = 0; i < steps.length; i += 1) { + if (input.height_m <= steps[i]) { + index = i; + break; + } + } + return row[Math.min(index, row.length - 1)]; +} diff --git a/common_util/common_util_node_bundle.py b/common_util/common_util_node_bundle.py index 3b38affe..9584ab0d 100644 --- a/common_util/common_util_node_bundle.py +++ b/common_util/common_util_node_bundle.py @@ -21,6 +21,8 @@ logger = logging.getLogger(__name__) ROOT = Path(__file__).resolve().parents[1] # 번들이 낡았는지 재는 대상 — 기하 계통이 걸쳐 있는 폴더 + 구조물도 식 풀이(B08, 2026-09-13). SOURCE_DIRS = ("B05_Profile", "B06_Section", "B08_Quantity", "common_util") +# 번들에 박히는 자료(JSON) — 벽 전면 기울기 표준경사 표(2026-09-14 B5). 표만 바뀌어도 다시 빌드. +SOURCE_DATA_DIRS = ("resources/data_masonry",) # 번들 만들기·실행 상한(초). 실측 번들 실행 0.1초, 빌드 3초 수준이라 넉넉하다. BUILD_TIMEOUT_S = 300 RUN_TIMEOUT_S = 600 @@ -48,6 +50,10 @@ def bundle_stale(bundle: Path) -> bool: for path in (ROOT / directory).rglob("*.ts"): if path.stat().st_mtime > built_at: return True + for directory in SOURCE_DATA_DIRS: + for path in (ROOT / directory).glob("*.json"): + if path.stat().st_mtime > built_at: + return True return False diff --git a/common_util/common_util_structure_walls.ts b/common_util/common_util_structure_walls.ts index d6bb4f40..435e33c4 100644 --- a/common_util/common_util_structure_walls.ts +++ b/common_util/common_util_structure_walls.ts @@ -37,6 +37,9 @@ export interface WallSpec { tiers: number | null; lift_m: number | null; shift_m: number | null; + /** 전면 기울기 판정 칸 — 성토/절토 · 전면 기울기(저장 원본 · B08 과 같은 칸, 2026-09-14 B5). */ + face_role: string | null; + face_slope_ratio: number | string | null; } /** 구간 경계 측점을 구간 안으로 볼 허용 오차(m) — 파이썬 `_EDGE_TOLERANCE_M`. */ @@ -95,6 +98,8 @@ export function wallSpecsFrom( tiers: num(options.tiers), lift_m: num(options.lift_m), shift_m: num(options.shift_m), + face_role: (options.face_role as string) ?? null, + face_slope_ratio: (options.face_slope_ratio as number | string) ?? null, }); } return specs; diff --git a/resources/tester/test_b06_structure_walls_mirror.py b/resources/tester/test_b06_structure_walls_mirror.py index ca615abb..e8a2bf1d 100644 --- a/resources/tester/test_b06_structure_walls_mirror.py +++ b/resources/tester/test_b06_structure_walls_mirror.py @@ -88,6 +88,9 @@ def _python_result() -> dict: "tiers": options.get("tiers"), "lift_m": options.get("lift_m"), "shift_m": options.get("shift_m"), + # 전면 기울기 판정 칸(2026-09-14 B5) — 저장 원본 그대로. + "face_role": options.get("face_role"), + "face_slope_ratio": options.get("face_slope_ratio"), } ) # 얹기 규칙은 같은 함수를 쓸 수 없으므로(정본 파일을 읽는다) 여기서 같은 규칙으로 흉내낸다. @@ -144,8 +147,12 @@ def test_wall_specs_mirror(): py = _python_result() assert len(ts["specs"]) == len(py["specs"]) == 2 assert ts["specs"] == py["specs"] - ts_marks = [(s["chainage_m"], (s.get("revetment") or {}).get("type_id")) for s in ts["sections"]] - py_marks = [(s["chainage_m"], (s.get("revetment") or {}).get("type_id")) for s in py["sections"]] + ts_marks = [ + (s["chainage_m"], (s.get("revetment") or {}).get("type_id")) for s in ts["sections"] + ] + py_marks = [ + (s["chainage_m"], (s.get("revetment") or {}).get("type_id")) for s in py["sections"] + ] assert ts_marks == py_marks # 구간 밖 측점에는 안 붙는다 / 경계 오차(0.02m) 안은 붙는다. assert dict(ts_marks)[20.0] is None diff --git a/resources/tester/test_b06_wall_lean_table.py b/resources/tester/test_b06_wall_lean_table.py new file mode 100644 index 00000000..c33845e4 --- /dev/null +++ b/resources/tester/test_b06_wall_lean_table.py @@ -0,0 +1,142 @@ +# -*- coding: utf-8 -*- +"""B06 벽 전면 기울기도 **품셈 표준경사 표**를 읽음 — B5 (2026-09-14 브레인 판정 「양쪽 다 이 표를」). + +앞서 B06 횡단도는 벽(관 유입·유출 · 독립 기슭막이 · 다단 추가 · C군 벽)을 **1:0.3 붙박이**로 그렸고 +B08 은 품셈 13-4-4 [주]⑪ 표(원문 L7185~7191)로 셈 — 메쌓기 성토 H2.5 에서 1:0.3 ↔ 1:0.35 로 갈림. + +재는 것(브레인: 고치기 전 코드에서 빨강부터) + ① 네 갈래 — 직고 구간 × 성토/절토 × 메/찰 (+ 사용자 칸 `face_slope_ratio`·`face_role`, 못 가르면 종전 0.3) + TS 짝 `common_util_masonry_slope.ts` 을 실제로 돌려 파이썬 `face_slope_ratio` 와 한 칸씩 대조. + ② 기하가 그 값을 씀 — 벽 모양 파일에서 붙박이 `REVET_LEAN_RATIO` 곱이 사라지고 벽마다 기울기를 받음. +""" + +from __future__ import annotations + +import itertools +import json +import re +import shutil +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 B08_Quantity.B08_Quantity_Engine_UnitQuantity_StoneSpec import ( # noqa: E402 + face_slope_ratio, + load_slope_table, +) +from common_util.common_util_structure_face_role import structure_face_role_of # noqa: E402 + +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" +TS_FILE = ROOT / "common_util" / "common_util_masonry_slope.ts" + +FORMS = ("돌쌓기(찰)", "돌쌓기(메)", "콘크리트") +HEIGHTS = (1.0, 1.5, 2.5, 3.0, 4.0, 6.0, 7.5) +MODES = ("left_cut", "right_cut", "both_cut", "both_fill", None) +SIDES = (None, "자동(성토 쪽)", "좌", "우", "양쪽") +OVERRIDES = ({}, {"face_role": "절토"}, {"face_slope_ratio": 0.5}) + +_RUNNER = """ +const { readFileSync, writeFileSync } = require("node:fs"); +const { wallLeanRatio } = require("./common_util_masonry_slope.js"); +const input = JSON.parse(readFileSync(process.argv[2], "utf8")); +const out = input.cases.map((c) => wallLeanRatio(input.table, c)); +writeFileSync(process.argv[3], JSON.stringify(out)); +""" + + +def _cases() -> list[dict]: + cases = [] + for form, height, mode, side, extra in itertools.product( + FORMS, HEIGHTS, MODES, SIDES, OVERRIDES + ): + cases.append( + {"form": form, "height_m": height, "section_mode": mode, "side": side, **extra} + ) + return cases + + +def _python(case: dict) -> float: + """B08 이 그 벽을 셀 때의 기울기 — 돌쌓기 형태만 표, 나머지는 종전 0.3.""" + wet = {"돌쌓기(찰)": True, "돌쌓기(메)": False}.get(case["form"]) + if wet is None: + return 0.3 + options = {k: case[k] for k in ("side", "face_role", "face_slope_ratio") if k in case} + face, reason = structure_face_role_of(case["section_mode"], options) + ratio, _basis = face_slope_ratio( + options, wet=wet, height_m=case["height_m"], face=face, face_reason=reason + ) + return ratio + + +@pytest.mark.skipif(shutil.which("node") is None or not TSC.is_file(), reason="node·tsc 없음") +def test_네_갈래_표를_TS_가_파이썬과_같게_읽는다(tmp_path: Path) -> None: + assert TS_FILE.is_file(), "TS 짝이 아직 없음 — B06 이 표를 안 읽는다" + out = tmp_path / "js" + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(TSC), + str(TS_FILE), + "--outDir", + str(out), + "--module", + "commonjs", + "--target", + "es2022", + "--ignoreConfig", + ], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + (out / "runner.cjs").write_text(_RUNNER, encoding="utf-8") + cases = _cases() + payload = tmp_path / "in.json" + result = tmp_path / "out.json" + payload.write_text( + json.dumps({"table": load_slope_table(), "cases": cases}, ensure_ascii=False), + encoding="utf-8", + ) + subprocess.run( # noqa: S603 + ["node", str(out / "runner.cjs"), str(payload), str(result)], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + got = json.loads(result.read_text(encoding="utf-8")) + wrong = [ + (case, ts, _python(case)) + for case, ts in zip(cases, got, strict=True) + if abs(ts - _python(case)) > 1e-9 + ] + assert not wrong, wrong[:5] + # 갈래가 실제로 갈렸는지 — 한 값만 나와 같아지는 것을 막음(메 성토 H2.5 0.35 · 찰 절토 H1.0 0.2) + assert {round(v, 2) for v in got} >= {0.2, 0.25, 0.3, 0.35, 0.4, 0.45, 0.5} + + +GEOMETRY = [ + "B06_Section_UI_Cross_Culvert_Geom.ts", + "B06_Section_UI_Cross_Wall.ts", + "B06_Section_UI_Cross_Culvert_Extra.ts", + "B06_Section_UI_Cross_Culvert_Solve.ts", +] + + +def test_횡단도가_최신_표준경사_판을_읽는다() -> None: + const = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Lean.ts").read_text(encoding="utf-8") + latest = sorted((ROOT / "resources" / "data_masonry").glob("masonry_slope_*.json"))[-1].name + imported = re.search(r'from "\.\./resources/data_masonry/(masonry_slope_[^"]+)"', const) + assert imported and imported.group(1) == latest + + +def test_벽_모양이_붙박이_기울기를_안_곱한다() -> None: + """벽 모양 파일마다 `REVET_LEAN_RATIO *` 곱이 남아 있으면 그 벽은 아직 1:0.3 붙박이.""" + for name in GEOMETRY: + source = (ROOT / "B06_Section" / name).read_text(encoding="utf-8") + assert not re.search(r"REVET_LEAN_RATIO\s*[*/]", source), name