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:
@@ -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
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)];
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user