feat(abut-rebar): 교대 접속슬래브 배근 규칙 R1–R8 (AD8)
- R1 · R3 (교대축 배치, 하면은 상면 구간 절반, 결정 A), R2 · R4 (길이 배치, 측면 피복 c / sin), 모따기 자름 (결정 E), R7 모서리 상 · 하, R8 엇갈림 (모따기 안 제외) - ② 슬래브 평면 측면선 · 단면을 그대로 따라감 (상면 철근은 상면, 하면 철근은 하면). 노출형도 같은 규칙 (사용자 결정 2026-11-05) - P10 다웰과의 간섭은 보지 않음 (사용자 결정 2026-11-05). k_8 편집 가능 - 테스트: 교대접속슬래브철근.md §11 (fck 27), 삽도 폭 11670 재현, 날개벽 형식별 R7 · 폭, 기본간격 연동, 노출형 Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bb2af4bdf5
commit
fa7d0032c3
2 files changed
+418
No files matched your search
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* 교대 접속슬래브 배근 · AD8 (2026-11-05, 교대접속슬래브철근.md, 결정 2026-11-02 · 2026-11-03 · 2026-11-05). 순수 함수, 길이는 mm.
|
||||
*
|
||||
* - R1 · R3 상 · 하면 길이 방향 주철근 (교량 진행방향, 측면과 나란히): 교대축을 따라 A = B − 2c, 상면 2·s_a로 구간을 정하고 하면은
|
||||
* 같은 구간을 s_a로 (결정 A). R2 · R4 상 · 하면 교대축 방향 (끝면과 나란히): 길이를 따라 같은 방식, 2·s_l / s_l.
|
||||
* - 모따기 (결정 E): 끝점에서 모따기선까지 < c이면 모따기선 − c에서 자름. R7: 모따기 모서리마다 상 · 하, 끝면 다리 l_d + 대각 + 측면 다리 l_d.
|
||||
* - R8: 하면 교점 @k_8, 이웃 줄 반 칸 엇갈림, R1 · R3을 감쌈 (135° 갈고리). 모따기 안은 뺌. P10 다웰과의 간섭은 보지 않는다 (사용자 결정 2026-11-05).
|
||||
* - 노출형도 같은 규칙 (사용자 결정 2026-11-05): 상면 철근은 상면을, 하면 철근은 하면을 따라간다.
|
||||
* - 좌표: 교대 (s, t, z) mm — s 교대축, t 전면 +, z 저판 하면에서. u = 슬래브 0 측면(s 작은 쪽)에서 교대축을 따라 잰 거리,
|
||||
* t' = 흉벽 쪽 끝에서 길이 방향(t −)으로 잰 거리.
|
||||
*/
|
||||
import { DEFAULT_MATERIAL, developmentLength, fyOf, NOMINAL_DIAMETER, STANDARD_BAR, type BarSize, type Material } from '../../rebar/common';
|
||||
import type { Issue } from '../../types';
|
||||
import { lenText } from './skew-layout';
|
||||
import { splicePlan, type SplicePlan } from './splice';
|
||||
|
||||
export interface AbutSlabRebarSettings {
|
||||
/** 피복 상 · 하면 / 측면 · 끝면. */
|
||||
cv: number;
|
||||
c: number;
|
||||
/** 접속슬래브 기본간격: 교대축 / 교대축 직각. */
|
||||
sA: number;
|
||||
sL: number;
|
||||
eMin: number;
|
||||
/** R8 배수 (두 방향), 엇갈림 k_8·s/2. */
|
||||
k8: number;
|
||||
/** 묶음별 간격 수정 (하면 값, 상면은 2배): R1 · R3 (교대축), R2 · R4 (길이). */
|
||||
sMain?: number;
|
||||
sDist?: number;
|
||||
}
|
||||
|
||||
export const ABUT_SLAB_REBAR_DEFAULT: AbutSlabRebarSettings = { cv: 80, c: 100, sA: 150, sL: 150, eMin: 150, k8: 8 };
|
||||
|
||||
export const ABUT_SLAB_SIZES = { R1: 'H22', R3: 'H22', R2: 'H13', R4: 'H13', R7: 'H13', R8: 'H13' } as const satisfies Record<string, BarSize>;
|
||||
export type SlabMark = keyof typeof ABUT_SLAB_SIZES;
|
||||
|
||||
type V2 = [number, number];
|
||||
|
||||
/** 슬래브 형상 (mm). */
|
||||
export interface AbutSlabGeom {
|
||||
form: 'buried' | 'exposed';
|
||||
/** 폭 (교대축 방향, 흉벽 쪽 끝에서) · 길이 (t 방향). */
|
||||
B: number;
|
||||
Ls: number;
|
||||
/** 흉벽 쪽 끝 t, 먼 끝 t. */
|
||||
near: number;
|
||||
far: number;
|
||||
/** 0 측면 · L 측면 선: 흉벽 쪽 끝 위의 점 (s, t)과 ds/dt (측면 기울기). */
|
||||
side0: { s: number; k: number };
|
||||
sideL: { s: number; k: number };
|
||||
/** 측별 모서리 모따기 (0 = 없음). */
|
||||
chamfer0: number;
|
||||
chamferL: number;
|
||||
/** 측면과 교대축 사잇각의 sin (측면 피복 c를 교대축 방향으로 잴 때 c / sin). */
|
||||
sin: number;
|
||||
/** 단면 (t 내림차순): 흉벽 상단 기준 상면 · 하면 (mm), 흉벽 상단 z (s = 0 · s = L, 저판 하면에서), 교대축 길이 L. */
|
||||
stations: { t: number; top: number; bottom: number }[];
|
||||
parapetTop: [number, number];
|
||||
L: number;
|
||||
/** 경사율 (표시용, %). */
|
||||
gradePct: number;
|
||||
}
|
||||
|
||||
export type SlabRole = '주철근' | '배력철근' | '모서리' | '전단';
|
||||
|
||||
export interface AbutSlabBar {
|
||||
mark: SlabMark;
|
||||
size: BarSize;
|
||||
role: SlabRole;
|
||||
spacing: string;
|
||||
actual: string;
|
||||
shape: string;
|
||||
length: number;
|
||||
lengthMin: number;
|
||||
lengthMax: number;
|
||||
count: number;
|
||||
position: string;
|
||||
note: string;
|
||||
basis: '규칙' | '가정';
|
||||
modified: boolean;
|
||||
splice?: SplicePlan;
|
||||
}
|
||||
|
||||
export interface SlabBarPath {
|
||||
mark: SlabMark;
|
||||
pts: [number, number, number][];
|
||||
}
|
||||
|
||||
export interface AbutSlabRebar {
|
||||
settings: AbutSlabRebarSettings;
|
||||
z: { R1: number; R2: number; R3: number; R4: number };
|
||||
/** 교대축 방향 배치 (u, 상면 R1 / 하면 R3) · 길이 방향 배치 (t', R2 / R4), 치수 문자열. */
|
||||
across: { r1: number[]; r3: number[]; e: number; text: string };
|
||||
along: { r2: number[]; r4: number[]; e: number; text: string };
|
||||
trimmed: number;
|
||||
ld: number;
|
||||
paths: SlabBarPath[];
|
||||
bars: AbutSlabBar[];
|
||||
issues: Issue[];
|
||||
}
|
||||
|
||||
const d = (size: BarSize) => NOMINAL_DIAMETER[size];
|
||||
|
||||
export function resolveSlabRebar(changes?: Partial<AbutSlabRebarSettings>): AbutSlabRebarSettings {
|
||||
return { ...ABUT_SLAB_REBAR_DEFAULT, ...(changes ?? {}) };
|
||||
}
|
||||
|
||||
export function slabRebarIssues(s: AbutSlabRebarSettings): string[] {
|
||||
const out: string[] = [];
|
||||
for (const key of ['cv', 'c', 'sA', 'sL', 'eMin'] as const) if (!(s[key] > 0)) out.push(`${key}은(는) 0보다 커야 합니다.`);
|
||||
for (const key of ['sMain', 'sDist'] as const) if (s[key] !== undefined && !((s[key] as number) > 0)) out.push('철근 간격은 0보다 커야 합니다.');
|
||||
if (!(Number.isInteger(s.k8) && s.k8 >= 1)) out.push('R8 배수는 1 이상의 정수여야 합니다.');
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 상면 2s 격자로 구간을 정하고 하면은 같은 구간을 s로 (결정 A): A = total − 2·edge. */
|
||||
function halfGrid(total: number, edge: number, eMin: number, s: number) {
|
||||
const A = total - 2 * edge;
|
||||
const n = Math.max(0, Math.floor((A - 2 * eMin) / (2 * s) + 1e-9));
|
||||
const G = n * 2 * s;
|
||||
const e = (A - G) / 2;
|
||||
const top = Array.from({ length: n + 1 }, (_, i) => edge + e + i * 2 * s);
|
||||
const bottom = Array.from({ length: 2 * n + 1 }, (_, i) => edge + e + i * s);
|
||||
const text = `${lenText(edge)} · ${lenText(e)} · ${n}@${lenText(2 * s)}=${lenText(G)} · ${lenText(e)} · ${lenText(edge)}`;
|
||||
return { top, bottom, e, text, n };
|
||||
}
|
||||
|
||||
export function abutSlabRebar(g: AbutSlabGeom, changes?: Partial<AbutSlabRebarSettings>, material: Material = DEFAULT_MATERIAL, who = ''): AbutSlabRebar {
|
||||
const s = resolveSlabRebar(changes);
|
||||
const issues: Issue[] = [];
|
||||
const add = (code: string, level: Issue['level'], message: string) => issues.push({ code, level, pier: who || undefined, message });
|
||||
const SZ = ABUT_SLAB_SIZES;
|
||||
const c = s.c;
|
||||
const sMain = s.sMain ?? s.sA;
|
||||
const sDist = s.sDist ?? s.sL;
|
||||
if (s.sMain !== undefined || s.sDist !== undefined || s.k8 !== 8) add('RB-AS04', 'I', '철근 간격을 기본간격과 따로 고쳤거나 R8 배수가 기본값(8)이 아닙니다.');
|
||||
const fy = (size: BarSize) => fyOf(material, size);
|
||||
const ld = Math.ceil(developmentLength(d(SZ.R7), fy(SZ.R7), material.fck) - 1e-9);
|
||||
|
||||
// 높이 (§3): 상면 · 하면에서 잰다 (경사면과 나란히).
|
||||
const z = {
|
||||
R1: s.cv,
|
||||
R2: s.cv + (d(SZ.R1) + d(SZ.R2)) / 2,
|
||||
R3: s.cv,
|
||||
R4: s.cv + (d(SZ.R3) + d(SZ.R4)) / 2,
|
||||
};
|
||||
const parapetAt = (sv: number) => g.parapetTop[0] + ((g.parapetTop[1] - g.parapetTop[0]) * sv) / g.L;
|
||||
const level = (t: number) => {
|
||||
const st = g.stations;
|
||||
for (let i = 0; i + 1 < st.length; i++) {
|
||||
if (t <= st[i].t + 1e-6 && t >= st[i + 1].t - 1e-6) {
|
||||
const k = st[i].t === st[i + 1].t ? 0 : (st[i].t - t) / (st[i].t - st[i + 1].t);
|
||||
return { top: st[i].top + k * (st[i + 1].top - st[i].top), bottom: st[i].bottom + k * (st[i + 1].bottom - st[i].bottom) };
|
||||
}
|
||||
}
|
||||
const end = t > st[0].t ? st[0] : st[st.length - 1];
|
||||
return { top: end.top, bottom: end.bottom };
|
||||
};
|
||||
/** z of a layer at (s, t): top layers measured down from the top, bottom layers up from the bottom. */
|
||||
const zAt = (sv: number, t: number, layer: 'R1' | 'R2' | 'R3' | 'R4') => {
|
||||
const lv = level(t);
|
||||
const p = parapetAt(sv);
|
||||
return layer === 'R1' || layer === 'R2' ? p + lv.top - z[layer] : p + lv.bottom + z[layer];
|
||||
};
|
||||
const side0 = (t: number) => g.side0.s + g.side0.k * (t - g.near);
|
||||
const sideL = (t: number) => g.sideL.s + g.sideL.k * (t - g.near);
|
||||
const sAt = (u: number, t: number) => side0(t) + u; // u along +s from the 0 측면
|
||||
const tOf = (tp: number) => g.near - tp; // t' (from the near end) → t
|
||||
const chamferCut = c * Math.SQRT2;
|
||||
|
||||
const paths: SlabBarPath[] = [];
|
||||
const lengths = new Map<SlabMark, number[]>();
|
||||
const put = (mark: SlabMark, pts: [number, number, number][]) => {
|
||||
let len = 0;
|
||||
for (let i = 1; i < pts.length; i++) len += Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1], pts[i][2] - pts[i - 1][2]);
|
||||
paths.push({ mark, pts });
|
||||
lengths.set(mark, [...(lengths.get(mark) ?? []), len]);
|
||||
};
|
||||
let trimmed = 0;
|
||||
|
||||
// R1 · R3 (§4 · §6): u 배치, t' = c … L_s − c, 흉벽 쪽 끝은 모따기에 걸리면 자름. 단면 꺾임마다 점을 둬 면을 따라감.
|
||||
const across = halfGrid(g.B, c, s.eMin, sMain);
|
||||
const stationTs = g.stations.map((x) => x.t);
|
||||
const runAlong = (mark: 'R1' | 'R3', u: number) => {
|
||||
const fromL = g.B - u;
|
||||
let tpNear = c;
|
||||
if (g.chamfer0 > 0 && u < g.chamfer0 - c + chamferCut) tpNear = g.chamfer0 + chamferCut - u;
|
||||
if (g.chamferL > 0 && fromL < g.chamferL - c + chamferCut) tpNear = Math.max(tpNear, g.chamferL + chamferCut - fromL);
|
||||
if (tpNear > c + 1e-6) trimmed++;
|
||||
const t0 = tOf(tpNear);
|
||||
const t1 = g.far + c;
|
||||
const ts = [t0, ...stationTs.filter((t) => t < t0 - 1e-6 && t > t1 + 1e-6), t1];
|
||||
put(mark, ts.map((t) => [sAt(u, t), t, zAt(sAt(u, t), t, mark)] as [number, number, number]));
|
||||
};
|
||||
for (const u of across.top) runAlong('R1', u);
|
||||
for (const u of across.bottom) runAlong('R3', u);
|
||||
|
||||
// R2 · R4 (§5 · §6): t' 배치, 측면 피복 c / sin, 모따기에 걸리는 쪽은 모따기선 − c.
|
||||
const along = halfGrid(g.Ls, c, s.eMin, sDist);
|
||||
const runAcross = (mark: 'R2' | 'R4', tp: number) => {
|
||||
const t = tOf(tp);
|
||||
let u0 = c / g.sin;
|
||||
let u1 = sideL(t) - side0(t) - c / g.sin;
|
||||
if (g.chamfer0 > 0 && tp < g.chamfer0 - c + chamferCut) {
|
||||
u0 = Math.max(u0, g.chamfer0 + chamferCut - tp);
|
||||
trimmed++;
|
||||
}
|
||||
if (g.chamferL > 0 && tp < g.chamferL - c + chamferCut) {
|
||||
u1 = Math.min(u1, sideL(t) - side0(t) - (g.chamferL + chamferCut - tp));
|
||||
trimmed++;
|
||||
}
|
||||
const a = sAt(u0, t);
|
||||
const b = sAt(u1, t);
|
||||
put(mark, [[a, t, zAt(a, t, mark)], [b, t, zAt(b, t, mark)]]);
|
||||
};
|
||||
for (const tp of along.top) runAcross('R2', tp);
|
||||
for (const tp of along.bottom) runAcross('R4', tp);
|
||||
if (trimmed) add('RB-AS02', 'I', `모따기에 걸려 짧아진 철근 ${trimmed}개 (모따기선 − c에서 자름).`);
|
||||
|
||||
// R7 (§6.1): 모따기 모서리마다 상(R2 층) · 하(R4 층). 끝면 피복선 다리 l_d → 모따기선 c 안쪽 대각 → 측면 피복선 다리 l_d.
|
||||
for (const [ch, side] of [[g.chamfer0, 0], [g.chamferL, 1]] as const) {
|
||||
if (!(ch > 0)) continue;
|
||||
const corner = ch + chamferCut - c; // 341.4 at 300
|
||||
for (const layer of ['R2', 'R4'] as const) {
|
||||
const pt = (u: number, tp: number): [number, number, number] => {
|
||||
const t = tOf(tp);
|
||||
const uu = side === 0 ? u : sideL(t) - side0(t) - u;
|
||||
const sv = sAt(uu, t);
|
||||
return [sv, t, zAt(sv, t, layer)];
|
||||
};
|
||||
put('R7', [pt(corner + ld, c), pt(corner, c), pt(c, corner), pt(c, corner + ld)]);
|
||||
}
|
||||
}
|
||||
|
||||
// R8 (§7): 하면 교점 — R3 위치를 k_8칸마다 (홀수 줄은 k_8/2 밀어), 줄은 R4 위치를 k_8칸마다. 모따기 삼각형 안은 뺌.
|
||||
const hook135 = 151;
|
||||
for (let j = 0; j < along.bottom.length; j += s.k8) {
|
||||
const row = (j / s.k8) % 2;
|
||||
const tp = along.bottom[j];
|
||||
const t = tOf(tp);
|
||||
for (let i = row ? Math.floor(s.k8 / 2) : 0; i < across.bottom.length; i += s.k8) {
|
||||
const u = across.bottom[i];
|
||||
if ((g.chamfer0 > 0 && u + tp < g.chamfer0) || (g.chamferL > 0 && g.B - u + tp < g.chamferL)) continue;
|
||||
const sv = sAt(u, t);
|
||||
const zb = zAt(sv, t, 'R3') - (d(SZ.R3) + d(SZ.R8)) / 2;
|
||||
const zt = zAt(sv, t, 'R1') + (d(SZ.R1) + d(SZ.R8)) / 2;
|
||||
const hk = hook135 / Math.SQRT2;
|
||||
put('R8', [[sv + hk, t, zb + hk], [sv, t, zb], [sv, t, zt], [sv + hk, t, zt - hk]]);
|
||||
}
|
||||
}
|
||||
|
||||
// 표.
|
||||
const bars: AbutSlabBar[] = [];
|
||||
const push = (b: Omit<AbutSlabBar, 'length' | 'lengthMin' | 'lengthMax' | 'count' | 'splice'>) => {
|
||||
const all = lengths.get(b.mark) ?? [];
|
||||
if (all.length === 0) return;
|
||||
const max = Math.max(...all);
|
||||
const splice = splicePlan(max, d(b.size), fy(b.size), material.fck) ?? undefined;
|
||||
if (splice) add('RB-AS01', splice.over ? 'W' : 'I', `${b.mark} ${Math.round(max)} > 정척 ${STANDARD_BAR}: ${splice.pieces}조각 이음 l_s ${splice.lap}.`);
|
||||
bars.push({ ...b, note: splice ? `${b.note} · ${splice.pieces}조각 이음 l_s ${splice.lap}` : b.note, length: Math.round(max), lengthMin: Math.round(Math.min(...all)), lengthMax: Math.round(max), count: all.length, splice });
|
||||
};
|
||||
const mMod = s.sMain !== undefined;
|
||||
const dMod = s.sDist !== undefined;
|
||||
push({ mark: 'R1', size: SZ.R1, role: '주철근', spacing: lenText(2 * sMain), actual: lenText(2 * sMain), shape: '직선 · 면과 나란히', position: `상면 최외측 (피복 ${lenText(s.cv)}) · 교량 진행방향`, note: `${across.text} · 삽도 ② = R1`, basis: '규칙', modified: mMod });
|
||||
push({ mark: 'R2', size: SZ.R2, role: '배력철근', spacing: lenText(2 * sDist), actual: lenText(2 * sDist), shape: '직선', position: `상면 R1 아래 · 교대축 방향 (끝면과 나란히)`, note: along.text, basis: '규칙', modified: dMod });
|
||||
push({ mark: 'R3', size: SZ.R3, role: '주철근', spacing: lenText(sMain), actual: lenText(sMain), shape: '직선 · 면과 나란히', position: `하면 최외측 (피복 ${lenText(s.cv)}) · 교량 진행방향`, note: 'R1 구간을 절반으로 (결정 A) · P10 다웰과의 간섭은 보지 않음', basis: '규칙', modified: mMod });
|
||||
push({ mark: 'R4', size: SZ.R4, role: '배력철근', spacing: lenText(sDist), actual: lenText(sDist), shape: '직선', position: '하면 R3 위 · 교대축 방향', note: 'R2 구간을 절반으로', basis: '규칙', modified: dMod });
|
||||
push({ mark: 'R7', size: SZ.R7, role: '모서리', spacing: '—', actual: '—', shape: `끝면 다리 l_d ${ld} · 모따기 대각 · 측면 다리 l_d ${ld}`, position: '흉벽 쪽 모따기 모서리 · 끝면 · 측면 피복선 위 · 상 · 하', note: '모서리마다 상 · 하 1개 · 상면 R2 층 / 하면 R4 층', basis: '가정', modified: false });
|
||||
push({ mark: 'R8', size: SZ.R8, role: '전단', spacing: `${lenText(s.k8 * sMain)} × ${lenText(s.k8 * sDist)}`, actual: '—', shape: `R1 · R3 감쌈 · 양끝 135° 갈고리 ${hook135}`, position: '전단 · 하면 교점 · 반 칸 엇갈림', note: '모따기 안은 뺌 · R5 · R6 결번', basis: '규칙', modified: s.k8 !== 8 });
|
||||
|
||||
return { settings: s, z, across: { r1: across.top, r3: across.bottom, e: across.e, text: across.text }, along: { r2: along.top, r4: along.bottom, e: along.e, text: along.text }, trimmed, ld, paths, bars, issues };
|
||||
}
|
||||
|
||||
/** 슬래브 형상 from the ② slab plan outline (abutPlanOutline().slab, m) and section (slabSection, m). */
|
||||
export function abutSlabGeom(
|
||||
form: 'buried' | 'exposed',
|
||||
outline: [number, number][],
|
||||
section: { t: number; top: number; bottom: number }[],
|
||||
parapet: { startEl: number; endEl: number; L: number; bottomEl: number },
|
||||
chamfer: [number, number],
|
||||
lengthMm: number,
|
||||
gradePct: number,
|
||||
): AbutSlabGeom {
|
||||
const n = outline.length;
|
||||
const near = section[0].t * 1000;
|
||||
const far = section[section.length - 1].t * 1000;
|
||||
const sideAt = (p: V2, q: V2) => {
|
||||
const k = (q[0] - p[0]) / (q[1] - p[1]);
|
||||
return { s: (p[0] + k * (near / 1000 - p[1])) * 1000, k };
|
||||
};
|
||||
const side0 = sideAt(outline[0], outline[n - 1]);
|
||||
const sideL = sideAt(outline[n - 3], outline[n - 2]);
|
||||
const k0 = side0.k;
|
||||
return {
|
||||
form,
|
||||
B: sideL.s - side0.s,
|
||||
Ls: lengthMm,
|
||||
near,
|
||||
far,
|
||||
side0,
|
||||
sideL,
|
||||
chamfer0: chamfer[0],
|
||||
chamferL: chamfer[1],
|
||||
sin: 1 / Math.sqrt(1 + k0 * k0),
|
||||
stations: section.map((x) => ({ t: x.t * 1000, top: x.top * 1000, bottom: x.bottom * 1000 })),
|
||||
parapetTop: [(parapet.startEl - parapet.bottomEl) * 1000, (parapet.endEl - parapet.bottomEl) * 1000],
|
||||
L: parapet.L * 1000,
|
||||
gradePct,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildAbut } from '../src/core/abut/geometry';
|
||||
import { parseAbutments, toAbutInput } from '../src/core/abut/parse';
|
||||
import { buildMembers } from '../src/core/abut/members';
|
||||
import { buildWings } from '../src/core/abut/wings';
|
||||
import { resolveSlab, slabGradePct, slabSection } from '../src/core/abut/slab';
|
||||
import { abutSlabGeom, abutSlabRebar, type AbutSlabGeom } from '../src/core/abut/rebar/slab';
|
||||
import { abutPlanOutline } from '../src/view/abut-scene';
|
||||
import { DEFAULT_MATERIAL } from '../src/core/rebar/common';
|
||||
|
||||
// 예제 A1 (교대접속슬래브철근.md §11), fck 27.
|
||||
const RAG = fileURLToPath(new URL('../../../RAG/운곡교P1입력.xlsx', import.meta.url));
|
||||
const rec = parseAbutments(XLSX.read(readFileSync(RAG), { type: 'buffer' })).abutments[0];
|
||||
const model = buildAbut(toAbutInput(rec));
|
||||
const members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm });
|
||||
const geomFor = (forms?: { left: 'general' | 'short' | 'none'; right: 'general' | 'short' | 'none' }, slabIn?: Parameters<typeof resolveSlab>[0]): AbutSlabGeom => {
|
||||
const wings = buildWings(model, members, forms);
|
||||
const slab = resolveSlab(slabIn);
|
||||
const grade = slabGradePct(slab, model.wing?.slopePct ?? 0);
|
||||
const ch = (sEnd: number) => (wings.find((w) => Math.abs(w.sEnd - sEnd) < 1e-9)?.haunchM ?? 0) * 1000;
|
||||
return abutSlabGeom(
|
||||
slab.form,
|
||||
abutPlanOutline(model, members, wings, slab).slab,
|
||||
slabSection(slab, grade, members.shape),
|
||||
{ startEl: members.parapetTopStartEl, endEl: members.parapetTopEndEl, L: model.length, bottomEl: members.footingBottomEl! },
|
||||
[ch(0), ch(model.length)],
|
||||
slab.lengthMm,
|
||||
grade,
|
||||
);
|
||||
};
|
||||
const geom = geomFor();
|
||||
const fck27 = { ...DEFAULT_MATERIAL, fck: 27 };
|
||||
const A1 = abutSlabRebar(geom, undefined, fck27, 'A1');
|
||||
const bar = (r: typeof A1, m: string) => r.bars.find((b) => b.mark === m);
|
||||
|
||||
describe('교대 접속슬래브 배근 A1 (AD8, 교대접속슬래브철근.md §11)', () => {
|
||||
it('geometry: 폭 ≈ 11253 (② 평면 측면선 사이 11257.3 — 날개벽 두께를 교대축 방향으로 띄운 skew 차이), 길이 7000, 모따기 양측 300', () => {
|
||||
expect(Math.abs(geom.B - 11253)).toBeLessThan(5);
|
||||
expect(geom.near - geom.far).toBeCloseTo(7000, 6);
|
||||
expect([geom.chamfer0, geom.chamferL]).toEqual([300, 300]);
|
||||
});
|
||||
|
||||
it('A1: R1 36 · R3 71 (35@300, e ≈ 276.5), R2 22 · R4 43 (21@300, e 250)', () => {
|
||||
expect(['R1', 'R3', 'R2', 'R4'].map((m) => bar(A1, m)?.count)).toEqual([36, 71, 22, 43]);
|
||||
expect(A1.across.e).toBeCloseTo((geom.B - 200 - 10500) / 2, 6);
|
||||
expect(A1.along.text).toBe('100 · 250 · 21@300=6.300 · 250 · 100');
|
||||
});
|
||||
|
||||
it('삽도 재현 (B 11670): 100 · 185 · 37@300=11.100 · 185 · 100, 양끝 첫 R1 · R3이 모따기에 걸려 짧아짐 (RB-AS02)', () => {
|
||||
const g = { ...geom, sideL: { ...geom.sideL, s: geom.side0.s + 11670 }, B: 11670 };
|
||||
const r = abutSlabRebar(g, undefined, fck27);
|
||||
expect(r.across.text).toBe('100 · 185 · 37@300=11.100 · 185 · 100');
|
||||
expect(r.issues.map((i) => i.code)).toContain('RB-AS02');
|
||||
});
|
||||
|
||||
it('높이: 상면 R1 = 상면 − 80, R2 = 상면 − 97.45, 하면 R4 = 하면 + 97.45, R3 = 하면 + 80 (t_s 450 → 370 / 352.55 / 97.45 / 80)', () => {
|
||||
expect([A1.z.R1, A1.z.R2, A1.z.R4, A1.z.R3]).toEqual([80, expect.closeTo(97.45, 6), expect.closeTo(97.45, 6), 80]);
|
||||
const r1 = A1.paths.find((p) => p.mark === 'R1')!;
|
||||
const r3 = A1.paths.find((p) => p.mark === 'R3' && Math.abs(p.pts[0][0] - r1.pts[0][0]) < 1)!;
|
||||
expect(r1.pts[0][2] - r3.pts[0][2]).toBeCloseTo(370 - 80, 6);
|
||||
});
|
||||
|
||||
it('길이: R1 · R3 = 6800 (측면 skew와 경사만큼 김), R2 · R4 = B − 2c / sin θ ≈ 11052', () => {
|
||||
const r1 = bar(A1, 'R1')!.length;
|
||||
expect(r1).toBeGreaterThanOrEqual(6800);
|
||||
expect(r1).toBeLessThan(6800 * 1.006);
|
||||
// 3D 길이: 흉벽 상단이 교대축을 따라 기울어 (345 / 12.2 m) 평면 길이보다 약 4.5 김.
|
||||
expect(Math.abs(bar(A1, 'R2')!.length - (geom.B - 200 / geom.sin))).toBeLessThan(6);
|
||||
});
|
||||
|
||||
it('모따기: A1은 짧아지는 철근 없음', () => {
|
||||
expect(A1.trimmed).toBe(0);
|
||||
});
|
||||
|
||||
it('R8 54개 · 627 (= 290 + (22.2 + 22.2)/2 … 135° 갈고리 151 × 2)', () => {
|
||||
expect(bar(A1, 'R8')!.count).toBe(54);
|
||||
});
|
||||
|
||||
it('R7: 양측 날개벽 4 · 한쪽 없음 2 · 양측 없음 0, 길이 2·l_d + 341 = 1515 (skew로 대각이 조금 길어 +25 이내)', () => {
|
||||
expect(bar(A1, 'R7')!.count).toBe(4);
|
||||
expect(bar(A1, 'R7')!.length - 1515).toBeGreaterThanOrEqual(0);
|
||||
expect(bar(A1, 'R7')!.length - 1515).toBeLessThan(25);
|
||||
const one = abutSlabRebar(geomFor({ left: 'general', right: 'none' }), undefined, fck27);
|
||||
expect(bar(one, 'R7')!.count).toBe(2);
|
||||
const none = abutSlabRebar(geomFor({ left: 'none', right: 'none' }), undefined, fck27);
|
||||
expect(bar(none, 'R7')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('날개벽 형식: 한쪽 없음 B ≈ 11725 · 양측 없음 B ≈ 12197 → 개수 다시 계산', () => {
|
||||
expect(Math.abs(geomFor({ left: 'general', right: 'none' }).B - 11725)).toBeLessThan(5);
|
||||
expect(Math.abs(geomFor({ left: 'none', right: 'none' }).B - 12197)).toBeLessThan(5);
|
||||
});
|
||||
|
||||
it('기본간격 연동: s_a 200 → R1 @400, R3 @200, R8 @1600', () => {
|
||||
const r = abutSlabRebar(geom, { sA: 200 }, fck27);
|
||||
expect([bar(r, 'R1')!.spacing, bar(r, 'R3')!.spacing, bar(r, 'R8')!.spacing.split(' × ')[0]]).toEqual(['400', '200', '1.600']);
|
||||
});
|
||||
|
||||
it('노출형도 같은 규칙: 상면 철근은 상면, 하면 철근은 하면을 따라 (사용자 결정 2026-11-05)', () => {
|
||||
const ex = abutSlabRebar(geomFor(undefined, { form: 'exposed' }), undefined, fck27);
|
||||
expect(['R1', 'R3', 'R2', 'R4'].map((m) => bar(ex, m)?.count)).toEqual([36, 71, 22, 43]);
|
||||
const r3 = ex.paths.find((p) => p.mark === 'R3')!;
|
||||
expect(r3.pts.length).toBeGreaterThan(2); // 헌치 · 벽체 배면에서 꺾임
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user