feat(abut-rebar): 교대 저판 배근 규칙 B1–B10 (AD3) · skew 배근 · 겹침이음 공통 (AD1 · AD2)

- skew-layout: 교대축을 따라 잰 격자, 끝면 피복 환산, 치수 문자열
- splice: l_s = 1.3 l_d (올림 1 mm), 최소 조각 수, ± l_s 엇갈림
- footing: 층 높이, B1–B4 옵션 ① skew / ② 교대축 직각 (가운데 격자 + 끝 skew 철근 B1-S · B2-S),
  B5–B8 (상면 벽체 밑 제외), B9 교차 배치, B10-1 · B10-2, 철근별 간격 수정, 검사 RB-AF01–10
- 교대확대기초철근.md §13 기대값 테스트 (뒷굽 짧음 합성은 §5.2 규칙에 맞춰 뒷굽 150으로 확인)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-11-04 11:06:14 +09:00
1 parent 59c2607418
commit e00e43ca61
5 files changed
+598

No files matched your search

+318
View File
@@ -0,0 +1,318 @@
/**
* 교대 확대기초(저판) 배근 · AD3 (2026-11-04, 교대확대기초철근.md, 결정 2026-11-02). 순수 함수, 길이는 mm.
*
* - B1–B4: 폭 방향 주철근, 교대축을 따라 s_L 격자에 번갈아(cycle-1 B1·B2 ⊓⊔, cycle-2 B3·B4 직선). 최외측.
* 옵션 ① skew 방향(날개벽 선과 나란히) / ② 교대축 직각(가운데 격자 + 양끝 끝 skew 철근 B1-S·B2-S). 직사각 평면은 ② 고정.
* - B5–B8: 길이 방향 배력철근, 폭을 따라 s_W 격자. 상면(B5·B7)은 벽체 밑에서 빼되 벽체 면 안쪽 s_W/2 이내는 남긴다.
* - B9: 전단, B1–B4 격자와 B5–B8 격자의 교점. 행 = 폭 격자 k_9W 배수(벽체 밑 제외), 행 안 = 교대축 격자 k_9L 배수, 행마다 엇갈림.
* - B10-1(전면·배면, 교대축 방향) · B10-2(양 끝면, skew 선과 나란히): 측면 수평, s_v 단.
* - 간격은 저판 기본간격(s_L, s_W, s_v)에서 계산한다. 철근별로 간격을 고치면 그 철근 묶음만 연동이 끊긴다(RB-AF10).
* - 높이 z는 저판 하면 기준, 폭 위치 x는 뒷굽 끝(배면)에서, 길이 위치 s는 교대축을 따라 IP1 쪽 끝면에서 잰다.
*/
import { DEFAULT_MATERIAL, fyOf, hookLength, NOMINAL_DIAMETER, STANDARD_BAR, type BarSize, type Material } from '../../rebar/common';
import type { Issue } from '../../types';
import { gridLayout, gridText, lenText, type GridLayout, type SkewAngle } from './skew-layout';
import { splicePlan, type SplicePlan } from './splice';
export type MainDir = 'skew' | 'perp';
export interface AbutFootingRebarSettings {
/** 피복 상면 / 측면 / 하면 (철근 중심까지). */
cTop: number;
cSide: number;
cBottom: number;
/** 첫 철근 최소 여유 (측면 피복 다음). */
eMin: number;
/** 저판 기본간격: 교대축 / 교대축 직각 / 연직. */
sL: number;
sW: number;
sV: number;
/** B9 간격 배수 (교대축 / 폭), 정수 ≥ 1. */
k9L: number;
k9W: number;
/** 상면 철근 아래 최소 여유 (B10). */
eTop: number;
/** B1–B4 배근 방향 (skew 평면일 때). */
dir: MainDir;
/** 철근별 간격 수정 (없으면 기본간격에 연동): B1–B4 (교대축), B5–B8 (폭), B10 (연직). */
sMain?: number;
sDist?: number;
sSide?: number;
}
export const ABUT_FOOTING_REBAR_DEFAULT: AbutFootingRebarSettings = {
cTop: 100,
cSide: 100,
cBottom: 150,
eMin: 150,
sL: 125,
sW: 150,
sV: 200,
k9L: 2,
k9W: 2,
eTop: 150,
dir: 'skew',
};
/** 규격 (기획서 §1.2, 엑셀 Abut 시트). */
export const ABUT_FOOTING_SIZES = { top: 'H16', bottom: 'H22', dist: 'H16', shear: 'H16', side: 'H19' } as const satisfies Record<string, BarSize>;
/** 형상 (②에서 받음). */
export interface AbutFootingGeom {
/** 길이 (교대축 방향) · 폭 (교대축 직각) · 두께. */
L: number;
W: number;
H: number;
/** 벽체 배면 / 전면 위치 (뒷굽 끝에서). */
xb: number;
xf: number;
angle: SkewAngle;
plan: 'skew' | 'rect';
}
export type BarRole = '주철근' | '보조' | '전단·간격재' | '측면';
export interface AbutFootingBar {
mark: string;
size: BarSize;
role: BarRole;
/** 간격 (cycle별) · 실간격, as the table writes them. */
spacing: string;
actual: string;
shape: string;
length: number;
count: number;
position: string;
note: string;
/** 간격을 기본간격과 따로 고친 묶음 (RB-AF10 "수정됨"). */
modified: boolean;
splice?: SplicePlan;
}
export interface AbutFootingRebar {
settings: AbutFootingRebarSettings;
dir: MainDir;
z: { B13: number; B57: number; B68: number; B24: number };
/** B1–B4 배치: 위치(교대축, IP1 끝면에서)와 cycle. 옵션 ②는 가운데 격자. */
main: { positions: number[]; layout: GridLayout | null; text: string };
/** 옵션 ② 끝 skew 철근 (한쪽): 끝면 피복선에서 잰 위치, 치수 문자열. */
skewEnd: { offsets: number[]; D: number; k: number; r: number; text: string } | null;
/** B5–B8 배치: x (뒷굽 끝에서), 상면에 남는지. */
dist: { positions: number[]; topKept: boolean[]; layout: GridLayout; bottomText: string; topText: string };
/** B10 단 높이와 위 여유, 치수 문자열. */
side: { levels: number[]; topGap: number; text: string; depth1: number; depth2: number; hook: number };
/** B9 행: 폭 위치 x와 그 행의 교대축 위치. */
shear: { rows: { x: number; s: number[] }[]; length: number };
/** 옆붙임 (B1 교대축 방향, B5 폭 방향). */
sideShift: { B1: number; B5: number };
bars: AbutFootingBar[];
issues: Issue[];
}
const d = (size: BarSize) => NOMINAL_DIAMETER[size];
export function resolveFootingRebar(changes?: Partial<AbutFootingRebarSettings>): AbutFootingRebarSettings {
return { ...ABUT_FOOTING_REBAR_DEFAULT, ...(changes ?? {}) };
}
/** Input problems in Korean (the edit refuses them). */
export function footingRebarIssues(s: AbutFootingRebarSettings): string[] {
const out: string[] = [];
for (const key of ['cTop', 'cSide', 'cBottom', 'eMin', 'sL', 'sW', 'sV', 'eTop'] as const) if (!(s[key] > 0)) out.push(`${key}은(는) 0보다 커야 합니다.`);
for (const key of ['sMain', 'sDist', 'sSide'] as const) if (s[key] !== undefined && !((s[key] as number) > 0)) out.push('철근 간격은 0보다 커야 합니다.');
if (!(Number.isInteger(s.k9L) && s.k9L >= 1 && Number.isInteger(s.k9W) && s.k9W >= 1)) out.push('B9 배수는 1 이상의 정수여야 합니다.');
return out;
}
/**
* The footing rebar of one abutment. `who` names the abutment in the issues. Lengths are rounded to 1 mm in the table;
* the layouts keep the exact values for the drawings.
*/
export function abutFootingRebar(g: AbutFootingGeom, changes?: Partial<AbutFootingRebarSettings>, material: Material = DEFAULT_MATERIAL, who = ''): AbutFootingRebar {
const s = resolveFootingRebar(changes);
const issues: Issue[] = [];
const add = (code: string, level: Issue['level'], message: string) => issues.push({ code, level, pier: who || undefined, message });
const SZ = ABUT_FOOTING_SIZES;
const sin = g.angle.sin;
const dir: MainDir = g.plan === 'rect' ? 'perp' : s.dir;
if (g.plan === 'rect') add('RB-AF04', 'I', '직사각 평면이라 B1–B4 배근 방향을 ② 교대축 직각으로 고정합니다.');
const sMain = s.sMain ?? s.sL;
const sDist = s.sDist ?? s.sW;
const sSide = s.sSide ?? s.sV;
if (s.sMain !== undefined || s.sDist !== undefined || s.sSide !== undefined || s.k9L !== 2 || s.k9W !== 2) {
add('RB-AF10', 'I', '철근 간격을 기본간격과 따로 고쳤거나 B9 배수가 기본값(2)이 아닙니다. [기본간격으로 되돌리기]로 다시 연동합니다.');
}
// 높이 (§3).
const z = {
B13: g.H - s.cTop,
B57: g.H - s.cTop - (d(SZ.top) + d(SZ.dist)) / 2,
B24: s.cBottom,
B68: s.cBottom + (d(SZ.bottom) + d(SZ.dist)) / 2,
};
if (!(z.B57 - z.B68 > d(SZ.dist))) add('RB-AF07', 'W', '상면과 하면 철근 층 사이 순간격이 없습니다 (규격 · 피복 확인).');
const legMain = z.B13 - z.B24;
const legDist = z.B57 - z.B68;
const fy = (size: BarSize) => fyOf(material, size);
// B1–B4 (§4).
let mainPositions: number[] = [];
let mainLayout: GridLayout | null = null;
let mainText = '';
let l0Main: number;
let l3: number;
let skewEnd: AbutFootingRebar['skewEnd'] = null;
if (dir === 'skew') {
mainLayout = gridLayout(g.L, s.cSide / sin, s.eMin, sMain);
mainPositions = mainLayout.positions;
mainText = gridText(mainLayout);
l0Main = (g.W - 2 * s.cSide) / sin;
l3 = (g.xf - 2 * s.cSide) / sin;
} else {
const shift = g.W * g.angle.cotAbs;
const C = g.L - 2 * s.cSide - shift;
const n = Math.floor(C / sMain);
const e = (C - Math.max(0, n) * sMain) / 2;
const start = s.cSide + shift;
mainPositions = n < 0 ? [] : Array.from({ length: n + 1 }, (_, i) => start + e + i * sMain);
mainText = `${lenText(s.cSide)} · ${lenText(e)} · ${n}@${lenText(sMain)}=${lenText(n * sMain)} · ${lenText(e)} · ${lenText(s.cSide)} (가운데 격자)`;
l0Main = g.W - 2 * s.cSide;
l3 = g.xf - 2 * s.cSide;
// 끝 skew 철근 (§4.3 ②).
const D = e + shift;
const k = Math.floor(D / sMain + 1e-9);
const r = D - k * sMain;
if (k >= 1) {
const offsets: number[] = []; // from the first perpendicular bar toward the end face
for (let i = 1; i <= k - 1; i++) offsets.push(i * sMain);
let text: string;
if (r < 1e-6) {
offsets.push(k * sMain);
text = `${lenText(s.cSide)} · ${k - 1 > 0 ? `${k - 1}@${lenText(sMain)}` : ''}`.replace(/ · $/, '');
} else {
const half = (sMain + r) / 2;
const base = (k - 1) * sMain;
offsets.push(base + half, D);
const rest = k - 2;
text = `${lenText(s.cSide)} · 2@${Math.round(half)}${rest > 0 ? ` · ${rest > 1 ? `${rest}@` : ''}${lenText(sMain)}` : ''}`;
}
skewEnd = { offsets: offsets.map((o) => D - o), D, k, r, text };
add('RB-AF05', 'I', `옵션 ② 끝 skew 철근 B1-S · B2-S를 양끝에 한쪽 ${offsets.length}개씩 둡니다 (${text}).`);
}
}
if (mainPositions.length < 2) add('RB-AF01', 'E', 'B1–B4를 놓을 수 없습니다 (길이 · 간격 확인).');
if (mainPositions.length % 2 === 1) add('RB-AF03', 'I', `B1–B4 위치 수가 ${mainPositions.length}개(홀수)라 cycle-1이 1개 많습니다.`);
const c1 = Math.ceil(mainPositions.length / 2);
const c2 = Math.floor(mainPositions.length / 2);
// B5–B8 (§5).
const distLayout = gridLayout(g.W, s.cSide, s.eMin, sDist);
const xs = distLayout.positions;
const topKept = xs.map((x) => x <= g.xb + sDist / 2 + 1e-9 || x >= g.xf - sDist / 2 - 1e-9);
const backIdx = xs.map((x, i) => (topKept[i] && x < g.xb + sDist / 2 + 1e-9 ? i : -1)).filter((i) => i >= 0);
const frontIdx = xs.map((x, i) => (topKept[i] && x >= g.xf - sDist / 2 - 1e-9 ? i : -1)).filter((i) => i >= 0);
if (backIdx.length === 0 || frontIdx.length === 0) add('RB-AF02', 'W', `${backIdx.length === 0 ? '뒷굽' : '앞굽'} 상면 길이 방향 철근이 0개입니다 (${backIdx.length === 0 ? '뒷굽' : '앞굽'}이 짧음).`);
if (xs.length < 2) add('RB-AF01', 'E', 'B5–B8을 놓을 수 없습니다 (폭 · 간격 확인).');
if (xs.length % 2 === 1) add('RB-AF03', 'I', `B5–B8 위치 수가 ${xs.length}개(홀수)라 cycle-1이 1개 많습니다.`);
const run = (idx: number[]) => (idx.length > 1 ? `${idx.length - 1}@${lenText(sDist)}=${lenText((idx.length - 1) * sDist)}` : '');
const topText =
backIdx.length && frontIdx.length
? [lenText(s.cSide), lenText(distLayout.e), run(backIdx), lenText(xs[frontIdx[0]] - xs[backIdx[backIdx.length - 1]]), run(frontIdx), lenText(distLayout.e), lenText(s.cSide)].filter(Boolean).join(' · ')
: gridText(distLayout);
const evenCount = (idx: number[]) => idx.filter((i) => i % 2 === 0).length;
const oddCount = (idx: number[]) => idx.filter((i) => i % 2 === 1).length;
const kept = [...backIdx, ...frontIdx];
const l0Dist = g.L - (2 * s.cSide) / sin;
// B10 (§6).
const m = Math.floor((z.B13 - s.eTop - s.cBottom) / sSide);
const levels = m < 0 ? [] : Array.from({ length: m + 1 }, (_, j) => s.cBottom + j * sSide);
if (m < 1) add('RB-AF09', 'W', 'B10 단 수가 1 미만입니다 (두께 · 연직 간격 확인).');
const hook10 = Math.round(hookLength('main90', d(SZ.side)));
const depth1 = s.cSide + (d(SZ.bottom) + d(SZ.side)) / 2;
const depth2 = s.cSide + (d(SZ.dist) + d(SZ.side)) / 2;
const topGap = levels.length ? z.B13 - levels[levels.length - 1] : 0;
// B9 (§8): rows on the B5–B8 grid (k_9W multiples, not under the wall), bars on the B1–B4 grid (k_9L multiples, staggered).
const rowIdx = xs.map((_, i) => i).filter((i) => i % s.k9W === 0 && topKept[i]);
const rows = rowIdx.map((i, r) => {
const shift = s.k9L >= 2 && r % 2 === 1 ? 1 : 0;
return { x: xs[i], s: mainPositions.filter((_, j) => j >= shift && (j - shift) % s.k9L === 0) };
});
const b9Count = rows.reduce((a, row) => a + row.s.length, 0);
const b9Length = z.B13 - z.B24 + 2 * hookLength('stirrup135', d(SZ.shear));
// 표 (시안 순서).
const bars: AbutFootingBar[] = [];
const push = (b: Omit<AbutFootingBar, 'length' | 'splice'> & { length: number }) => {
const splice = splicePlan(b.length, d(b.size), fy(b.size), material.fck) ?? undefined;
const length = Math.round(b.length);
if (splice) {
const text = `${splice.pieces}조각 이음 l_s ${splice.lap} (${Math.round(splice.staggered[0])} / ${Math.round(splice.staggered[1])} 엇갈림)`;
b.note = b.note ? `${b.note} · ${text}` : text;
add('RB-AF08', splice.over ? 'W' : 'I', `${b.mark} ${length} > 정척 ${STANDARD_BAR}: ${text}${splice.over ? ' — 조각이 정척을 넘습니다' : ''}.`);
}
bars.push({ ...b, length, splice });
};
const mainMod = s.sMain !== undefined;
const distMod = s.sDist !== undefined;
const sideMod = s.sSide !== undefined;
const mainSp = `${lenText(2 * sMain)}`;
const mainAct = `${lenText(sMain)}`;
const dirText = dir === 'skew' ? '폭 방향 (skew 선과 나란히)' : '교대축 직각';
const shiftB1 = (d(SZ.top) + d(SZ.bottom)) / 2;
const shiftB5 = (d(SZ.dist) + d(SZ.dist)) / 2;
push({ mark: 'B1', size: SZ.top, role: '주철근', spacing: mainSp, actual: mainAct, shape: `⊓ 다리 아래로 ${Math.round(legMain)} (B2 높이까지)`, length: l0Main + 2 * legMain, count: c1, position: `상면 최외측 z ${lenText(z.B13)} · ${dirText}`, note: `cycle-1 · B2 옆 +${shiftB1.toFixed(2)} (교대축 방향)`, modified: mainMod });
push({ mark: 'B3', size: SZ.top, role: '주철근', spacing: mainSp, actual: mainAct, shape: '직선', length: l3, count: c2, position: `상면 · 배면 끝 ~ 벽체 전면 − ${lenText(s.cSide)}`, note: 'cycle-2 · 앞굽 상면은 B1만', modified: mainMod });
push({ mark: 'B2', size: SZ.bottom, role: '주철근', spacing: mainSp, actual: mainAct, shape: `⊔ 다리 위로 ${Math.round(legMain)} (B1 높이까지)`, length: l0Main + 2 * legMain, count: c1, position: `하면 최외측 z ${lenText(z.B24)}`, note: 'cycle-1', modified: mainMod });
push({ mark: 'B4', size: SZ.bottom, role: '주철근', spacing: mainSp, actual: mainAct, shape: '직선', length: l0Main, count: c2, position: '하면 최외측', note: 'cycle-2', modified: mainMod });
if (skewEnd) {
const n = skewEnd.offsets.length * 2;
const len = (g.W - 2 * s.cSide) / sin;
push({ mark: 'B1-S', size: SZ.top, role: '주철근', spacing: skewEnd.text, actual: '—', shape: '직선 · 끝면과 나란히 · 전체 폭', length: len, count: n, position: `상면 · 양끝 삼각형 (한쪽 ${skewEnd.offsets.length}개)`, note: `D ${lenText(skewEnd.D)} · k ${skewEnd.k} · r ${lenText(skewEnd.r)}`, modified: mainMod });
push({ mark: 'B2-S', size: SZ.bottom, role: '주철근', spacing: skewEnd.text, actual: '—', shape: '직선 · 끝면과 나란히 · 전체 폭', length: len, count: n, position: `하면 · 양끝 삼각형 (한쪽 ${skewEnd.offsets.length}개)`, note: 'B1-S와 같은 위치', modified: mainMod });
}
const distSp = lenText(2 * sDist);
const distAct = lenText(sDist);
push({ mark: 'B5', size: SZ.dist, role: '보조', spacing: distSp, actual: distAct, shape: `⊓ 다리 ${Math.round(legDist)} (B6 높이까지)`, length: l0Dist + 2 * legDist, count: evenCount(kept), position: `상면 B1 아래 z ${lenText(z.B57)} · 뒷굽·앞굽 · 교대축 방향`, note: `+${shiftB5.toFixed(1)} 옆붙임 (폭 방향)`, modified: distMod });
push({ mark: 'B7', size: SZ.dist, role: '보조', spacing: distSp, actual: distAct, shape: '직선', length: l0Dist, count: oddCount(kept), position: '상면 · 뒷굽·앞굽 · 교대축 방향', note: `벽체 밑 제외 (면 안쪽 s_W/2 = ${lenText(sDist / 2)} 이내는 남김)`, modified: distMod });
push({ mark: 'B6', size: SZ.dist, role: '보조', spacing: distSp, actual: distAct, shape: `⊔ 다리 ${Math.round(legDist)} (B5 높이까지)`, length: l0Dist + 2 * legDist, count: xs.filter((_, i) => i % 2 === 0).length, position: `하면 B2 위 z ${lenText(z.B68)}`, note: 'cycle-1', modified: distMod });
push({ mark: 'B8', size: SZ.dist, role: '보조', spacing: distSp, actual: distAct, shape: '직선', length: l0Dist, count: xs.filter((_, i) => i % 2 === 1).length, position: '하면 B2 위', note: 'cycle-2', modified: distMod });
push({ mark: 'B9', size: SZ.shear, role: '전단·간격재', spacing: `${lenText(s.k9L * sMain)} × ${lenText(s.k9W * sDist)}`, actual: '—', shape: '양끝 135° 갈고리', length: b9Length, count: b9Count, position: `전단 · B1 ~ B2 감쌈 · 벽체 밑 제외 ${rows.length}행`, note: `교대축 ${s.k9L}·s_L × 폭 ${s.k9W}·s_W · 행마다 s_L 엇갈림 · 배수로만 수정`, modified: s.k9L !== 2 || s.k9W !== 2 });
const sideSp = lenText(sSide);
push({ mark: 'B10-1', size: SZ.side, role: '측면', spacing: sideSp, actual: sideSp, shape: `양끝 90° 갈고리 ${hook10}`, length: g.L - (2 * depth2) / sin + 2 * hook10, count: levels.length * 2, position: `측면 · 전면·배면 · 깊이 ${depth1.toFixed(2)} · +${d(SZ.side).toFixed(1)}`, note: `${levels.length}단 ${m}@${sideSp} · 위 여유 ${lenText(topGap)}`, modified: sideMod });
push({ mark: 'B10-2', size: SZ.side, role: '측면', spacing: sideSp, actual: sideSp, shape: `양끝 90° 갈고리 ${hook10}`, length: (g.W - 2 * depth1) / sin + 2 * hook10, count: levels.length * 2, position: `측면 · 양 끝면 (skew) · 깊이 ${depth2.toFixed(1)}`, note: `${levels.length}단 ${m}@${sideSp}`, modified: sideMod });
return {
settings: s,
dir,
z,
main: { positions: mainPositions, layout: mainLayout, text: mainText },
skewEnd,
dist: { positions: xs, topKept, layout: distLayout, bottomText: gridText(distLayout), topText },
side: { levels, topGap, text: `${lenText(s.cBottom)} · ${m}@${sideSp}=${lenText(m * sSide)}`, depth1, depth2, hook: hook10 },
shear: { rows, length: Math.round(b9Length) },
sideShift: { B1: shiftB1, B5: shiftB5 },
bars,
issues,
};
}
/** The 형상 of the 저판 from the generated abutment (m → mm): 길이 = 교대축 길이, 폭 · 두께 · 벽체 위치는 형상 입력, θ = skew. */
export function abutFootingGeom(
lengthM: number,
shape: { footingFrontM: number; footingBackM: number; footingThicknessM: number; wallBackM: number; wallFrontM: number },
angle: SkewAngle,
plan: 'skew' | 'rect' = 'skew',
): AbutFootingGeom {
const mm = (m: number) => Math.round(m * 1e6) / 1e3;
return {
L: mm(lengthM),
W: mm(shape.footingFrontM - shape.footingBackM),
H: mm(shape.footingThicknessM),
xb: mm(shape.wallBackM - shape.footingBackM),
xf: mm(shape.wallFrontM - shape.footingBackM),
angle,
plan,
};
}
+58
View File
@@ -0,0 +1,58 @@
/**
* 교대 skew 배근 공통 (plan_abut AD1, spec_abut §6.1, 2026-11-04). 순수 함수, 길이는 mm.
*
* - 교대축 직각 철근은 날개벽(skew) 선과 나란히 뻗고, 간격은 교대축을 따라 잰다. 실제 직각 간격은 s · sin θ다.
* - skew 끝면의 피복을 교대축 방향으로 재면 c / sin θ, 폭을 skew 선 방향으로 재면 W / sin θ다.
* - θ는 교대축과 날개벽 선의 사잇각이다 (예제 A1 95°02′52″, sin θ = 0.99612).
*/
export interface SkewAngle {
/** sin θ (> 0). */
sin: number;
/** |cot θ|: the shift of the end face along the axis per unit of width. 0 for a square plan. */
cotAbs: number;
}
export function skewAngle(skewDeg: number): SkewAngle {
const r = (skewDeg * Math.PI) / 180;
return { sin: Math.abs(Math.sin(r)), cotAbs: Math.abs(Math.cos(r) / Math.sin(r)) };
}
/** A square plan (θ = 90°). */
export const SQUARE: SkewAngle = { sin: 1, cotAbs: 0 };
/** One row of bars laid out over a length with an edge on both sides (교각 확대기초 §3.1과 같은 식). */
export interface GridLayout {
/** 배치 가능 길이 A = total − 2·edge. */
A: number;
/** 격자 칸 수 n = floor((A − 2·e_min) / s); positions n + 1. Below 0 when nothing fits (RB-AF01). */
n: number;
s: number;
/** 격자 길이 G = n·s and the end gap e = (A − G) / 2. */
G: number;
e: number;
edge: number;
/** Positions from the start of `total`: edge + e + i·s. */
positions: number[];
}
export function gridLayout(total: number, edge: number, eMin: number, s: number): GridLayout {
const A = total - 2 * edge;
const n = Math.floor((A - 2 * eMin) / s);
const G = Math.max(0, n) * s;
const e = (A - G) / 2;
const positions = n < 0 ? [] : Array.from({ length: n + 1 }, (_, i) => edge + e + i * s);
return { A, n, s, G, e, edge, positions };
}
/** A length as the drawings write it: m with 3 decimals from 1000 mm ("4.050"), else mm with up to 1 decimal ("185.8", "175"). */
export function lenText(mm: number): string {
if (mm >= 1000 - 1e-9) return (mm / 1000).toFixed(3);
const r = Math.round(mm * 10) / 10;
return Number.isInteger(r) ? String(r) : r.toFixed(1);
}
/** "100.4 · 185.8 · 93@125=11.625 · 185.8 · 100.4" of a grid (edge · e · n@s=G · e · edge). */
export function gridText(g: GridLayout): string {
return [lenText(g.edge), lenText(g.e), `${g.n}@${lenText(g.s)}=${lenText(g.G)}`, lenText(g.e), lenText(g.edge)].join(' · ');
}
+35
View File
@@ -0,0 +1,35 @@
/**
* 교대 겹침이음 공통 (plan_abut AD2, 교대확대기초철근.md §12.1, 결정 2026-11-02 Q-AF11). 순수 함수, 길이는 mm.
*
* l_d = 0.6·d_b·f_y / (λ·√fck), l_s = 1.3·l_d (올림 1 mm)
* 조각 수 p = 가장 작은 p, (l + (p − 1)·l_s) / p ≤ L_bar
* 이음 중심은 인접 철근끼리 ± l_s 엇갈림 → 끝 조각 길이가 ± l_s 바뀐다.
*/
import { spliceLength, STANDARD_BAR } from '../../rebar/common';
/** l_s (mm), rounded up to 1 mm. */
export function lapLength(db: number, fy: number, fck: number): number {
return Math.ceil(spliceLength(db, fy, fck) - 1e-9);
}
export interface SplicePlan {
pieces: number;
lap: number;
/** Piece length with the splice in the middle (equal pieces). */
even: number;
/** End piece lengths with the stagger: [even − l_s, even + l_s]. */
staggered: [number, number];
/** True when a staggered piece is still longer than L_bar (RB-AF08 경고). */
over: boolean;
}
/** The splice of a bar longer than L_bar; null when it fits in one piece. */
export function splicePlan(total: number, db: number, fy: number, fck: number, standard = STANDARD_BAR): SplicePlan | null {
if (total <= standard) return null;
const lap = lapLength(db, fy, fck);
let p = 2;
while ((total + (p - 1) * lap) / p > standard && p < 20) p++;
const even = (total + (p - 1) * lap) / p;
const staggered: [number, number] = [even - lap, even + lap];
return { pieces: p, lap, even, staggered, over: staggered[1] > standard + 1e-9 };
}
+2
View File
@@ -140,6 +140,8 @@ export const NOMINAL_DIAMETER = {
H16: 15.9,
H19: 19.1,
D22: 22.2,
/** 교대 저판 B2·B4 (교대확대기초철근.md §1.3, 2026-11-04). */
H22: 22.2,
H25: 25.4,
H29: 28.6,
S32: 31.8,