feat(b06): 횡단도 벽 전면 기울기도 품셈 13-4-4 [주]⑪ 표준경사 표 — 관 유입·유출·독립 기슭막이·다단·C군 벽 · TS 짝(common_util_masonry_slope) 거울 시험 · 번들이 표 JSON 도 봄(B5)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
This commit is contained in:
2026-09-14 13:14:20 +09:00
co-authored by Claude Opus 5
parent e8cce87413
commit 0e609e69a6
18 changed files with 401 additions and 38 deletions
+5 -1
View File
@@ -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 내외 (사방교본 교차 참조). */
@@ -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)
@@ -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
+1 -2
View File
@@ -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 — 하부 지지 구조물 별도(추가 예정)`
@@ -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공구 수량집계표). */
@@ -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;
@@ -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,
},
);
@@ -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;
@@ -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[];
+18
View File
@@ -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);
}
+30 -3
View File
@@ -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<string, string> = {
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
+6 -2
View File
@@ -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 },
+4
View File
@@ -177,6 +177,10 @@ function revetSide(values: Record<string, unknown>, 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);
+88
View File
@@ -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<string, Record<string, number[]>>;
}
/** 벽 한 매의 기울기 입력 — 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<string, boolean> = { "돌쌓기(찰)": true, "돌쌓기(메)": false };
/** 단면유형 → (좌측 역할, 우측 역할). 좌 = +offset. */
const MODE_ROLES: Record<string, [string, string]> = {
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)];
}
+6
View File
@@ -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
@@ -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;
@@ -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
@@ -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