Files
Pier_v1/src/core/abut/rebar/footing.ts
T
ypshimandClaude Haiku 5.5 e00e43ca61 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>
2026-11-04 11:06:14 +09:00

319 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 교대 확대기초(저판) 배근 · 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,
};
}