/** * 교대 접속슬래브 배근 · 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, }; }