diff --git a/src/core/abut/rebar/slab.ts b/src/core/abut/rebar/slab.ts new file mode 100644 index 0000000..d5fd2e9 --- /dev/null +++ b/src/core/abut/rebar/slab.ts @@ -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; +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 { + 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, 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(); + 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) => { + 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, + }; +} diff --git a/tests/abut-rebar-slab.test.ts b/tests/abut-rebar-slab.test.ts new file mode 100644 index 0000000..8881ec0 --- /dev/null +++ b/tests/abut-rebar-slab.test.ts @@ -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[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); // 헌치 · 벽체 배면에서 꺾임 + }); +});