From e00e43ca6103a1d62423161906544a483e127cbc Mon Sep 17 00:00:00 2001 From: ypshim Date: Wed, 4 Nov 2026 11:06:14 +0900 Subject: [PATCH] =?UTF-8?q?feat(abut-rebar):=20=EA=B5=90=EB=8C=80=20?= =?UTF-8?q?=EC=A0=80=ED=8C=90=20=EB=B0=B0=EA=B7=BC=20=EA=B7=9C=EC=B9=99=20?= =?UTF-8?q?B1=E2=80=93B10=20(AD3)=20=C2=B7=20skew=20=EB=B0=B0=EA=B7=BC=20?= =?UTF-8?q?=C2=B7=20=EA=B2=B9=EC=B9=A8=EC=9D=B4=EC=9D=8C=20=EA=B3=B5?= =?UTF-8?q?=ED=86=B5=20(AD1=20=C2=B7=20AD2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/core/abut/rebar/footing.ts | 318 +++++++++++++++++++++++++++++ src/core/abut/rebar/skew-layout.ts | 58 ++++++ src/core/abut/rebar/splice.ts | 35 ++++ src/core/rebar/common.ts | 2 + tests/abut-rebar-footing.test.ts | 185 +++++++++++++++++ 5 files changed, 598 insertions(+) create mode 100644 src/core/abut/rebar/footing.ts create mode 100644 src/core/abut/rebar/skew-layout.ts create mode 100644 src/core/abut/rebar/splice.ts create mode 100644 tests/abut-rebar-footing.test.ts diff --git a/src/core/abut/rebar/footing.ts b/src/core/abut/rebar/footing.ts new file mode 100644 index 0000000..b3cfc86 --- /dev/null +++ b/src/core/abut/rebar/footing.ts @@ -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; + +/** 형상 (②에서 받음). */ +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 { + 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, 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 & { 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, + }; +} diff --git a/src/core/abut/rebar/skew-layout.ts b/src/core/abut/rebar/skew-layout.ts new file mode 100644 index 0000000..1d13a6f --- /dev/null +++ b/src/core/abut/rebar/skew-layout.ts @@ -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(' · '); +} diff --git a/src/core/abut/rebar/splice.ts b/src/core/abut/rebar/splice.ts new file mode 100644 index 0000000..50336d3 --- /dev/null +++ b/src/core/abut/rebar/splice.ts @@ -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 }; +} diff --git a/src/core/rebar/common.ts b/src/core/rebar/common.ts index 8ad0e78..c0e25df 100644 --- a/src/core/rebar/common.ts +++ b/src/core/rebar/common.ts @@ -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, diff --git a/tests/abut-rebar-footing.test.ts b/tests/abut-rebar-footing.test.ts new file mode 100644 index 0000000..1a58ccc --- /dev/null +++ b/tests/abut-rebar-footing.test.ts @@ -0,0 +1,185 @@ +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 { abutFootingGeom, abutFootingRebar, type AbutFootingGeom } from '../src/core/abut/rebar/footing'; +import { gridLayout, skewAngle, SQUARE } from '../src/core/abut/rebar/skew-layout'; +import { lapLength, splicePlan } from '../src/core/abut/rebar/splice'; + +// 예제 A1 (교대확대기초철근.md §13): L 12197 · W 4600 · H 1100, 벽체 1000 ~ 3400, θ 95°02′52″. +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 geom = abutFootingGeom(model.length, members.shape, skewAngle(rec.skewDeg)); +const A1 = abutFootingRebar(geom, undefined, undefined, 'A1'); +const bar = (r: typeof A1, mark: string) => r.bars.find((b) => b.mark === mark)!; +const codes = (r: typeof A1) => r.issues.map((i) => i.code); + +describe('교대 저판 배근 공통 (AD1 skew · AD2 이음)', () => { + it('skew: sin θ 0.99612; the grid of B1–B4 is 93@125 with e 185.8', () => { + expect(skewAngle(rec.skewDeg).sin).toBeCloseTo(0.99612, 5); + const g = gridLayout(geom.L, 100 / geom.angle.sin, 150, 125); + expect(g.A).toBeCloseTo(11996.5, 0); + expect([g.n, g.G, g.positions.length]).toEqual([93, 11625, 94]); + expect(g.e).toBeCloseTo(185.8, 1); + }); + + it('이음: l_s H16 955 / H19 1147 (fck 27, fy 400); B5·B6 13627 → 2조각 6336 / 8246', () => { + expect(lapLength(15.9, 400, 27)).toBe(955); + expect(lapLength(19.1, 400, 27)).toBe(1147); + const p = splicePlan(13627, 15.9, 400, 27)!; + expect(p.pieces).toBe(2); + expect(p.staggered.map(Math.round)).toEqual([6336, 8246]); + expect(p.over).toBe(false); + expect(splicePlan(11997, 15.9, 400, 27)).toBeNull(); + }); +}); + +describe('교대 저판 B1–B10 (교대확대기초철근.md §13, 예제 A1 옵션 ①)', () => { + it('geometry from the generated abutment: W 4600, H 1100, 벽체 1000 ~ 3400', () => { + expect([geom.W, geom.H, geom.xb, geom.xf]).toEqual([4600, 1100, 1000, 3400]); + expect(geom.L).toBeCloseTo(12197, 0); + }); + + it('높이: B1·B3 1000, B5·B7 984.1, B6·B8 169.05, B2·B4 150', () => { + expect(A1.z.B13).toBe(1000); + expect(A1.z.B57).toBeCloseTo(984.1, 6); + expect(A1.z.B68).toBeCloseTo(169.05, 6); + expect(A1.z.B24).toBe(150); + }); + + it('B1–B4: 93@125, 각 47; B1·B2 6117, B3 3212, B4 4417; 옆붙임 B1 +19.05, B5 +15.9', () => { + expect(A1.main.text).toBe('100.4 · 185.8 · 93@125=11.625 · 185.8 · 100.4'); + for (const m of ['B1', 'B2', 'B3', 'B4']) expect(bar(A1, m).count).toBe(47); + expect([bar(A1, 'B1').length, bar(A1, 'B2').length, bar(A1, 'B3').length, bar(A1, 'B4').length]).toEqual([6117, 6117, 3212, 4417]); + expect(A1.sideShift.B1).toBeCloseTo(19.05, 6); + expect(A1.sideShift.B5).toBeCloseTo(15.9, 6); + }); + + it('B5–B8: 27@150, e 175; 하면 B6 14 / B8 14, 상면 B5 6 / B7 7; B5·B6 13627 → RB-AF08, B7·B8 11997', () => { + expect(A1.dist.bottomText).toBe('100 · 175 · 27@150=4.050 · 175 · 100'); + expect(A1.dist.topText).toBe('100 · 175 · 5@150=750 · 2.400 · 6@150=900 · 175 · 100'); + expect(['B5', 'B7', 'B6', 'B8'].map((m) => bar(A1, m).count)).toEqual([6, 7, 14, 14]); + expect([bar(A1, 'B5').length, bar(A1, 'B6').length, bar(A1, 'B7').length, bar(A1, 'B8').length]).toEqual([13627, 13627, 11997, 11997]); + expect(bar(A1, 'B5').splice?.staggered.map(Math.round)).toEqual([6336, 8246]); + expect(codes(A1)).toContain('RB-AF08'); + }); + + it('B10: 4단 3@200, 위 여유 250, 깊이 120.65 / 117.5, 다리 296; B10-1 8 × 12553 (2조각 5703 / 7997), B10-2 8 × 4968', () => { + expect(A1.side.text).toBe('150 · 3@200=600'); + expect(A1.side.levels).toEqual([150, 350, 550, 750]); + expect(A1.side.topGap).toBe(250); + expect(A1.side.depth1).toBeCloseTo(120.65, 6); + expect(A1.side.depth2).toBeCloseTo(117.5, 6); + expect(A1.side.hook).toBe(296); + expect([bar(A1, 'B10-1').count, bar(A1, 'B10-1').length]).toEqual([8, 12553]); + expect(bar(A1, 'B10-1').splice?.staggered.map(Math.round)).toEqual([5703, 7997]); + expect([bar(A1, 'B10-2').count, bar(A1, 'B10-2').length]).toEqual([8, 4968]); + }); + + it('B9: 6행 × 47 = 282, 1228, rows 0 2 4 / 22 24 26 of the B5–B8 grid, staggered by s_L', () => { + expect([bar(A1, 'B9').count, bar(A1, 'B9').length]).toEqual([282, 1228]); + expect(A1.shear.rows.map((r) => r.x)).toEqual([275, 575, 875, 3575, 3875, 4175]); + expect(A1.shear.rows[1].s[0] - A1.shear.rows[0].s[0]).toBeCloseTo(125, 6); + }); + + it('A1 has no warning: only RB-AF08 (정보) for B5 · B6 · B10-1', () => { + expect(A1.issues.filter((i) => i.level !== 'I')).toEqual([]); + expect(A1.issues.filter((i) => i.code === 'RB-AF08').length).toBe(3); + }); +}); + +describe('옵션 ② 교대축 직각 · 직사각 · 합성 (§13)', () => { + const opt2 = abutFootingRebar(geom, { dir: 'perp' }); + + it('가운데 격자 92@125, e 45.5, 위치 93 (47 / 46); B1·B2 6100, B4 4400, B3 3200; RB-AF03', () => { + expect(opt2.main.positions).toHaveLength(93); + expect(opt2.main.positions[1] - opt2.main.positions[0]).toBeCloseTo(125, 9); + expect(opt2.main.text).toMatch(/^100 · 45\.\d · 92@125=11\.500/); + expect(['B1', 'B3', 'B2', 'B4'].map((m) => bar(opt2, m).count)).toEqual([47, 46, 47, 46]); + expect([bar(opt2, 'B1').length, bar(opt2, 'B4').length, bar(opt2, 'B3').length]).toEqual([6100, 4400, 3200]); + expect(codes(opt2)).toContain('RB-AF03'); + }); + + it('끝 skew 철근: D 451.9, k 3, r 76.9 → 100 · 2@101 · 125, 한쪽 4개; B1-S H16 8 / B2-S H22 8, 각 4417; RB-AF05', () => { + expect(opt2.skewEnd!.D).toBeCloseTo(451.9, 0); + expect(opt2.skewEnd!.k).toBe(3); + expect(opt2.skewEnd!.r).toBeCloseTo(76.9, 0); + expect(opt2.skewEnd!.text).toBe('100 · 2@101 · 125'); + expect([bar(opt2, 'B1-S').size, bar(opt2, 'B1-S').count, bar(opt2, 'B1-S').length]).toEqual(['H16', 8, 4417]); + expect([bar(opt2, 'B2-S').size, bar(opt2, 'B2-S').count, bar(opt2, 'B2-S').length]).toEqual(['H22', 8, 4417]); + expect(codes(opt2)).toContain('RB-AF05'); + }); + + it('삽도 재현: L 12188 → cycle당 46@250, e 40.8, D 447.2 → 2@98.6 · 125', () => { + const r = abutFootingRebar({ ...geom, L: 12188 }, { dir: 'perp' }); + expect(r.main.positions).toHaveLength(93); + expect(r.skewEnd!.D).toBeCloseTo(447.2, 0); + expect((125 + r.skewEnd!.r) / 2).toBeCloseTo(98.6, 0); + expect(r.skewEnd!.text).toBe('100 · 2@99 · 125'); + }); + + it('r = 0: D a multiple of s gives k bars at s', () => { + // A synthetic skew with W·cot = 450, and L so that e = 50 (< s/2) and D = e + 450 = 4·125 exactly. + const g: AbutFootingGeom = { ...geom, angle: { sin: 0.995, cotAbs: 450 / geom.W } }; + const L = 200 + 450 + 92 * 125 + 2 * 50; + const r = abutFootingRebar({ ...g, L }, { dir: 'perp' }); + expect(r.skewEnd!.r).toBeCloseTo(0, 6); + expect(r.skewEnd!.k).toBe(4); + expect(r.skewEnd!.offsets).toHaveLength(4); + }); + + it('skew 90° 합성: 피복 100, l_0 4400; B1·B2 6100 / B3·B4 3200 · 4400, the same for both options', () => { + const sq = { ...geom, angle: SQUARE }; + for (const dir of ['skew', 'perp'] as const) { + const r = abutFootingRebar(sq, { dir }); + expect([bar(r, 'B1').length, bar(r, 'B3').length, bar(r, 'B4').length]).toEqual([6100, 3200, 4400]); + expect(r.skewEnd).toBeNull(); + } + expect(abutFootingRebar(sq).main.text.startsWith('100 · 186.')).toBe(true); + }); + + it('직사각 평면: 옵션 ② 고정, 끝 skew 철근 0 → RB-AF04', () => { + const r = abutFootingRebar({ ...geom, plan: 'rect', angle: SQUARE }, { dir: 'skew' }); + expect(r.dir).toBe('perp'); + expect(r.skewEnd).toBeNull(); + expect(codes(r)).toContain('RB-AF04'); + }); + + it('뒷굽 짧음: 상면 뒷굽 철근 0 → RB-AF02', () => { + // The spec (§13) says 뒷굽 300, but its own rule (§5.2: keep x ≤ x_b + s_W/2) keeps the bar at x 275 ≤ 375 there. + // A 뒷굽 of 150 leaves none (275 > 225). + expect(abutFootingRebar({ ...geom, xb: 300, xf: 2700 }).dist.topKept[0]).toBe(true); + const r = abutFootingRebar({ ...geom, xb: 150, xf: 2550 }); + expect(codes(r)).toContain('RB-AF02'); + }); + + it('기본간격 연동: s_L 150 / s_W 200 / s_v 250 → every count and text recomputed, B9 on the new grid', () => { + const r = abutFootingRebar(geom, { sL: 150, sW: 200, sV: 250 }); + expect(r.main.text).toContain('@150='); + expect(r.dist.bottomText).toContain('@200='); + expect(r.side.text).toBe('150 · 2@250=500'); + expect(bar(r, 'B9').spacing).toBe('300 × 400'); + expect(r.shear.rows[1].s[0] - r.shear.rows[0].s[0]).toBeCloseTo(150, 6); + expect(bar(r, 'B1').count).not.toBe(47); + }); + + it('철근별 수정: B5–B8 only at 200 → B1–B4 and B10 unchanged, RB-AF10, modified badge; removing it links back', () => { + const r = abutFootingRebar(geom, { sDist: 200 }); + expect(bar(r, 'B6').count).not.toBe(14); + expect(bar(r, 'B6').modified).toBe(true); + expect(bar(r, 'B1').modified).toBe(false); + expect(bar(r, 'B1').count).toBe(47); + expect(bar(r, 'B10-1').count).toBe(8); + expect(codes(r)).toContain('RB-AF10'); + expect(codes(abutFootingRebar(geom, {}))).not.toContain('RB-AF10'); + }); + + it('부재 독립: the footing result does not read any other member spacing', () => { + expect(abutFootingRebar(geom, {}).bars).toEqual(A1.bars); + }); +});