diff --git a/src/core/abut/rebar/parapet.ts b/src/core/abut/rebar/parapet.ts new file mode 100644 index 0000000..65acced --- /dev/null +++ b/src/core/abut/rebar/parapet.ts @@ -0,0 +1,376 @@ +/** + * 교대 흉벽 · 브라켓 배근 · AD5 (2026-11-04, 교대흉벽철근.md, 결정 2026-11-02 · 2026-11-04). 순수 함수, 길이는 mm. + * + * - P1 전면 연직 (cycle-1) / P2 배면 상단 ⊓ (cycle-1·2) / P3 배면 하단 ㄱ형 (cycle-1·2): 벽체 연직 격자에서 −0 / +15.9 / −17.5. + * - P4 · P5 전면 · 배면 수평 (흉벽 상단 경사와 나란함, P5는 브라켓 상면 위 · 아래 두 줄) / P6 · P7 간격재 (브라켓 구간 · 흉벽). + * - P8 · P9 끝면 수평 ㄷ형 (아래 · 위) / P10 다웰 (접속슬래브 폭 안, P3 수평 다리를 비켜감, Q-P5) / P11 끝면 연직. + * - 좌표는 벽체(wall.ts)와 같다: u = skew 격자 위치, t = 교대축에서 전면 +, z = 저판 하면에서. + */ +import { DEFAULT_MATERIAL, developmentLength, fyOf, hookLength, NOMINAL_DIAMETER, STANDARD_BAR, type BarSize, type Material } from '../../rebar/common'; +import type { Issue } from '../../types'; +import { gridLayout, lenText } from './skew-layout'; +import { splicePlan, type SplicePlan } from './splice'; +import type { AbutWallGeom, AbutWallRebar } from './wall'; + +export interface AbutParapetRebarSettings { + /** 피복 (철근 중심까지). */ + c: number; + /** 흉벽 기본간격: 교대축 / 연직. 교대축 직각은 두지 않는다 (두께 방향 철근이 2줄). */ + sL: number; + sV: number; + /** P6 · P7 간격 배수 (교대축 / 연직), 정수 ≥ 1. 한 칸으로 두 철근에 함께 쓴다 (사용자 결정 2026-11-04). */ + k67L: number; + k67V: number; + /** P10 간격 · 길이 (기본간격과 연동 안 함). */ + s10: number; + L10: number; + /** 묶음별 간격 수정: 연직 P1–P3 (교대축 실간격), 수평 P4 · P5 · P8 · P9 (연직). */ + sVert?: number; + sHoriz?: number; +} + +export const ABUT_PARAPET_REBAR_DEFAULT: AbutParapetRebarSettings = { c: 100, sL: 125, sV: 125, k67L: 8, k67V: 4, s10: 400, L10: 600 }; + +/** 규격 (기획서 §1.2). */ +export const ABUT_PARAPET_SIZES = { + P1: 'H16', P2: 'H16', P3: 'H16', P4: 'H16', P5: 'H16', P11: 'H16', + P6: 'H13', P7: 'H13', P8: 'H13', P9: 'H13', + P10: 'D25', +} as const satisfies Record; +export type ParapetMark = keyof typeof ABUT_PARAPET_SIZES; + +/** 흉벽 형상: 벽체 형상 + 흉벽 · 슬래브 값. */ +export interface AbutParapetGeom { + wall: AbutWallGeom; + /** 흉벽 배면 t (−500), 벽체 배면 t (−800, 브라켓 배면), 흉벽 전면 t (0). */ + parapetBack: number; + /** 흉벽 상단 z at u = 0 and u = L (저판 하면에서), straight between. 브라켓 상면 = 흉벽 상단 − bracketH. */ + top: [number, number]; + bracketH: number; + /** 흉벽 높이 입력 (교좌면 → 흉벽 상단). 철근표 대표 단면 = 흉벽 높이가 이 값인 곳 (기획서 §2 "구간 1 끝 2500"과 같은 단면). */ + Hp: number; + /** 접속슬래브: 폭 B (교대축 방향), 다웰 선(브라켓 가운데)에서 슬래브 양 측면의 u, 슬래브 끝 t, 측별 모서리 모따기 (0 = 없음). */ + slab: { B: number; a: number; b: number; nearT: number; chamferA: number; chamferB: number } | null; +} + +export type ParapetRole = '연직' | '수평' | '간격재' | '측면' | '다웰'; + +export interface AbutParapetBar { + mark: ParapetMark; + size: BarSize; + role: ParapetRole; + spacing: string; + actual: string; + shape: string; + /** 대표 길이 (흉벽 높이 = H_p 단면) and the range. */ + length: number; + lengthMin: number; + lengthMax: number; + count: number; + position: string; + note: string; + basis: '규칙' | '가정'; + modified: boolean; + splice?: SplicePlan; +} + +export interface ParapetBarPath { + mark: ParapetMark; + pts: [number, number, number][]; +} + +export interface AbutParapetRebar { + settings: AbutParapetRebarSettings; + x: { P1: number; P2back: number; P3: number; P4: number; P5up: number; P5low: number; P10: number }; + /** 연직 격자 오프셋 (저판 격자 기준). */ + offsets: { P1: number; P2: number; P3: number }; + /** 대표 단면 (흉벽 높이 = H_p)의 단 수, and that section's u. */ + levels: { P4: number; P5up: number; P5low: number; P6: number; P7: number }; + uRef: number; + dowel: { n: number; e: number; moved: number; positions: number[] }; + ld: Record<'H13' | 'H16', number>; + paths: ParapetBarPath[]; + bars: AbutParapetBar[]; + issues: Issue[]; +} + +const d = (size: BarSize) => NOMINAL_DIAMETER[size]; + +export function resolveParapetRebar(changes?: Partial): AbutParapetRebarSettings { + return { ...ABUT_PARAPET_REBAR_DEFAULT, ...(changes ?? {}) }; +} + +/** Input problems in Korean (the edit refuses them). */ +export function parapetRebarIssues(s: AbutParapetRebarSettings): string[] { + const out: string[] = []; + for (const key of ['c', 'sL', 'sV', 's10', 'L10'] as const) if (!(s[key] > 0)) out.push(`${key}은(는) 0보다 커야 합니다.`); + for (const key of ['sVert', 'sHoriz'] as const) if (s[key] !== undefined && !((s[key] as number) > 0)) out.push('철근 간격은 0보다 커야 합니다.'); + if (!(Number.isInteger(s.k67L) && s.k67L >= 1 && Number.isInteger(s.k67V) && s.k67V >= 1)) out.push('P6 · P7 배수는 1 이상의 정수여야 합니다.'); + return out; +} + +/** 흉벽 형상 from the 벽체 형상 (브라켓 상면 + 브라켓 높이 = 흉벽 상단) and the 접속슬래브 values (mm). */ +export function abutParapetGeom(wall: AbutWallGeom, shape: { parapetBackM: number; bracketHeightM: number; parapetHeightM: number }, slab: AbutParapetGeom['slab']): AbutParapetGeom { + const mm = (m: number) => Math.round(m * 1e6) / 1e3; + const bracketH = mm(shape.bracketHeightM); + return { wall, parapetBack: mm(shape.parapetBackM), top: [wall.bracketTop[0] + bracketH, wall.bracketTop[1] + bracketH], bracketH, Hp: mm(shape.parapetHeightM), slab }; +} + +/** + * u of the two slab side lines (모따기 무시) on the line t = tM, in mm along the skew grid (u = s − k·t). `poly` is the slab of + * abutPlanOutline (axis s, t in m): [side-0 near point(s)…, side-L near point(s)…, far L, far 0], so each side line runs through + * its first near point and its far corner. + */ +export function slabSpanAt(poly: [number, number][], tM: number, skewK: number): { a: number; b: number } | null { + const n = poly.length; + if (n < 4) return null; + const at = (p: [number, number], q: [number, number]) => p[0] + ((tM - p[1]) * (q[0] - p[0])) / (q[1] - p[1]); + const s0 = at(poly[0], poly[n - 1]); + const sL = at(poly[n - 3], poly[n - 2]); + const toU = (s: number) => (s - skewK * tM) * 1000; + return { a: toU(Math.min(s0, sL)), b: toU(Math.max(s0, sL)) }; +} + +/** + * The 흉벽 · 브라켓 rebar of one abutment, on the 벽체 연직 격자 (`wall`). `who` names the abutment in the issues. + */ +export function abutParapetRebar(g: AbutParapetGeom, wall: AbutWallRebar, changes?: Partial, material: Material = DEFAULT_MATERIAL, who = ''): AbutParapetRebar { + const s = resolveParapetRebar(changes); + const issues: Issue[] = []; + const add = (code: string, level: Issue['level'], message: string) => issues.push({ code, level, pier: who || undefined, message }); + const SZ = ABUT_PARAPET_SIZES; + const W = g.wall; + const sin = W.angle.sin; + const c = s.c; + const sHoriz = s.sHoriz ?? s.sV; + if (s.sVert !== undefined || s.sHoriz !== undefined || s.k67L !== 8 || s.k67V !== 4) { + add('RB-AP03', 'I', '철근 간격을 기본간격과 따로 고쳤거나 P6 · P7 배수가 기본값(8 × 4)이 아닙니다. [되돌리기]로 다시 연동합니다.'); + } + const fy = (size: BarSize) => fyOf(material, size); + const ldOf = (size: BarSize) => Math.ceil(developmentLength(d(size), fy(size), material.fck) - 1e-9); + const ld = { H13: ldOf('H13'), H16: ldOf('H16') }; + const hook = (size: BarSize) => Math.round(hookLength('main90', d(size))); + + const segs = W.segments; + const last = segs.length - 1; + const segIndex = (u: number) => { + const i = segs.findIndex((sg) => u < sg.to); + return i < 0 ? last : i; + }; + const seat = (u: number) => W.HF + segs[segIndex(u)].H; + const top = (u: number) => g.top[0] + ((g.top[1] - g.top[0]) * u) / W.L; + const br = (u: number) => top(u) - g.bracketH; + // 대표 단면: 흉벽 높이(상단 − 교좌면)가 입력값 H_p인 곳 (기획서 §2의 "구간 1 끝 2500" 단면). Linear in each step, so the + // closest point is a clamped root per step. + let uRef = W.L; + let best = Infinity; + for (const sg of segs) { + const hAt = (u: number) => top(u) - (W.HF + sg.H); + const h0 = hAt(sg.from); + const h1 = hAt(sg.to); + const f = h1 === h0 ? 0 : Math.min(1, Math.max(0, (g.Hp - h0) / (h1 - h0))); + const u = sg.from + f * (sg.to - sg.from); + if (Math.abs(hAt(u) - g.Hp) < best - 1e-9) { + best = Math.abs(hAt(u) - g.Hp); + uRef = Math.min(u, sg.to - 1e-6); + } + } + + // 단면 위치 (§3). + const xP4 = -c; + const xP1 = xP4 - (d(SZ.P4) + d(SZ.P1)) / 2; + const xP2back = g.parapetBack + c; + const xP5up = xP2back + (d(SZ.P2) + d(SZ.P5)) / 2; + const xP3 = W.xb + c; + const xP5low = xP3 + (d(SZ.P3) + d(SZ.P5)) / 2; + const xP10 = (W.xb + g.parapetBack) / 2; + + // 연직 격자 (§4.1): P1 = 벽체 연직 격자, P2 = P1 + (d_P1 + d_P2)/2, P3 = 벽체 격자 − (d_W + d_P3)/2. + const base = s.sVert === undefined ? wall.vertical.positions : gridLayout(W.L, wall.settings.c / sin, wall.settings.eMin, s.sVert).positions.map((p) => p - wall.vertical.shift); + const offP2 = (d(SZ.P1) + d(SZ.P2)) / 2; + const offP3 = -(d('H19') + d(SZ.P3)) / 2; + const offsets = { P1: -wall.vertical.shift, P2: -wall.vertical.shift + offP2, P3: -wall.vertical.shift + offP3 }; + + const paths: ParapetBarPath[] = []; + const lengths = new Map(); + const put = (mark: ParapetMark, pts: [number, number, number][], length: number, u: number) => { + paths.push({ mark, pts }); + lengths.set(mark, [...(lengths.get(mark) ?? []), { u, l: length }]); + }; + + // P1 · P2 · P3 (§4.2). + let shortBack = false; + base.forEach((u, i) => { + const z0 = seat(u); + const zt = top(u); + const zb = br(u); + if (i % 2 === 0) put('P1', [[u, xP1, z0 - ld.H16], [u, xP1, zt - c - offP2]], zt - c - offP2 - (z0 - ld.H16), u); + const u2 = u + offP2; + const zh = zt - c; + const backEnd = zb - ld.H16; + if (backEnd < seat(u2)) shortBack = true; + put('P2', [[u2, xP1, zh - ld.H16], [u2, xP1, zh], [u2, xP2back, zh], [u2, xP2back, backEnd]], ld.H16 + (xP1 - xP2back) + (zh - backEnd), u2); + const u3 = u + offP3; + put('P3', [[u3, xP3, seat(u3) - ld.H16], [u3, xP3, br(u3) - c], [u3, xP4, br(u3) - c]], br(u3) - c - (seat(u3) - ld.H16) + (xP4 - xP3), u3); + }); + if (shortBack) add('RB-AP01', 'W', `흉벽 높이 − 브라켓 높이가 l_d ${ld.H16}보다 작아 P2 뒤 다리가 교좌면 아래로 내려갑니다.`); + + // 수평 단 (§5): 흉벽 상단에서 c + k·s_v 아래, 상단 경사와 나란함. + const endIn = (size: BarSize) => (c + (d(SZ.P11) + d(size)) / 2) / sin; + const levelZ = (u: number, k: number) => top(u) - c - k * sHoriz; + const kMax = Math.ceil((Math.max(g.top[0], g.top[1]) - W.HF) / sHoriz) + 1; + // One bar per run of segments where `ok(k, segment)` holds at both segment ends. + const runs = (mark: ParapetMark, x: number, ok: (k: number, a: number, b: number) => boolean) => { + for (let k = 0; k <= kMax; k++) { + const valid = segs.map((sg) => ok(k, sg.from, sg.to)); + for (let a = 0; a <= last; a++) { + if (!valid[a] || (a > 0 && valid[a - 1])) continue; + let b = a; + while (b < last && valid[b + 1]) b++; + const ua = a === 0 ? endIn(SZ[mark]) : segs[a].from + c; + const ub = b === last ? W.L - endIn(SZ[mark]) : segs[b].to - c; + put(mark, [[ua, x, levelZ(ua, k)], [ub, x, levelZ(ub, k)]], Math.hypot(ub - ua, levelZ(ub, k) - levelZ(ua, k)), (ua + ub) / 2); + } + } + }; + const aboveSeat = (k: number, u: number) => levelZ(u, k) >= seat(Math.min(u, W.L - 1e-6)) + c - 1e-6; + const upper = (k: number, u: number) => levelZ(u, k) >= br(u) - 1e-6; + const lower = (k: number, u: number) => levelZ(u, k) <= br(u) - c + 1e-6; + const segAt = (sg: { from: number; to: number }) => [sg.from, Math.max(sg.from, sg.to - 1e-6)]; + runs('P4', xP4, (k, a, b) => segAt({ from: a, to: b }).every((u) => aboveSeat(k, u))); + runs('P5', xP5up, (k, a, b) => segAt({ from: a, to: b }).every((u) => upper(k, u))); + runs('P5', xP5low, (k, a, b) => segAt({ from: a, to: b }).every((u) => lower(k, u) && aboveSeat(k, u))); + const countAt = (u: number, ok: (k: number, u: number) => boolean) => Array.from({ length: kMax + 1 }, (_, k) => k).filter((k) => ok(k, u)).length; + const levels = { + P4: countAt(uRef, aboveSeat), + P5up: countAt(uRef, upper), + P5low: countAt(uRef, (k, u) => lower(k, u) && aboveSeat(k, u)), + P6: 0, + P7: 0, + }; + + // P6 · P7 (§6): 교대축 @k·s_L, 연직 @k·s_v, 단마다 반 칸 엇갈림. 길이는 skew 방향 (1/sin θ, 벽체 W7과 같은 기준). + const h13 = hook(SZ.P6); + const sVertBase = s.sVert ?? s.sL; + const g67 = gridLayout(W.L, c / sin, wall.settings.eMin, s.k67L * sVertBase); + const step = s.k67L * sVertBase; + const lo = g67.edge; + const hi = W.L - g67.edge; + const pRef = g67.positions.reduce((a, b) => (Math.abs(b - uRef) < Math.abs(a - uRef) ? b : a), g67.positions[0] ?? 0); + const tie = (mark: 'P6' | 'P7', x0: number, zFrom: (u: number) => number, zTo: (u: number) => number) => { + let refCount = 0; + g67.positions.forEach((p) => { + for (let j = 0, z = zFrom(p); z <= zTo(p) + 1e-6; j++, z += s.k67V * sHoriz) { + const u = Math.min(hi, Math.max(lo, p + (j % 2 === 1 ? step / 2 : 0))); + put(mark, [[u, x0, z - h13], [u, x0, z], [u, xP4, z], [u, xP4, z - h13]], (xP4 - x0) / sin + 2 * h13, u); + if (p === pRef) refCount++; + } + }); + return refCount; + }; + levels.P6 = tie('P6', xP3, (u) => seat(u) + c, (u) => br(u) - c); + levels.P7 = tie('P7', xP2back, (u) => br(u) + c, (u) => top(u) - c); + + // P8 · P9 (§7): 끝면 ㄷ형, P11 안쪽 c + (d_P11 + d_P8)/2. 단은 P5 아래 · 위와 같은 높이. + const in8 = (c + (d(SZ.P11) + d(SZ.P8)) / 2) / sin; + for (const [u, dir] of [[in8, 1], [W.L - in8, -1]] as const) { + const leg = u + dir * ld.H13; + for (let k = 0; k <= kMax; k++) { + const z = levelZ(u, k); + if (upper(k, u)) put('P9', [[leg, xP2back, z], [u, xP2back, z], [u, xP4, z], [leg, xP4, z]], (xP4 - xP2back) / sin + 2 * ld.H13, u); + else if (lower(k, u) && aboveSeat(k, u)) put('P8', [[leg, xP3, z], [u, xP3, z], [u, xP4, z], [leg, xP4, z]], (xP4 - xP3) / sin + 2 * ld.H13, u); + } + } + + // P11 (§7.1): 끝면 최외측 연직, x = P4 선 · P2 뒤 다리 선 (흉벽 상단 − c까지) · P3 선 (브라켓 상면 − c까지). + for (const u of [c / sin, W.L - c / sin]) { + const z0 = seat(u) - ld.H16; + for (const [x, zt] of [[xP4, top(u) - c], [xP2back, top(u) - c], [xP3, br(u) - c]] as const) put('P11', [[u, x, z0], [u, x, zt]], zt - z0, u); + } + + // P10 (§8): n = floor((B − 2c)/s_10) + 1, 슬래브 가운데 맞춤. P3 수평 다리(브라켓 상면 − c)와 (d_P10 + d_P3)/2 이상 떨어지게 + // 가장 가까운 위치로 옮기고, 반 칸 안에 없으면 RB-AP06 (사용자 결정 2026-11-04, Q-P5). 접속슬래브 R3 회피는 AD8에서. + const dowel = { n: 0, e: 0, moved: 0, positions: [] as number[] }; + if (g.slab) { + const { B, a, b } = g.slab; + const n = Math.floor((B - 2 * c) / s.s10 + 1e-9) + 1; + const e = (B - 2 * c - (n - 1) * s.s10) / 2; + const mid = (a + b) / 2; + const p3 = base.map((u) => u + offP3); + const gap = (d(SZ.P10) + d(SZ.P3)) / 2; + const clear = (u: number) => p3.every((q) => Math.abs(u - q) >= gap - 1e-6); + let lost = 0; + for (let i = 0; i < n; i++) { + const u0 = mid + (i - (n - 1) / 2) * s.s10; + let u = u0; + if (!clear(u0)) { + const cands = p3.flatMap((q) => [q - gap, q + gap]).filter(clear).sort((x, y) => Math.abs(x - u0) - Math.abs(y - u0)); + u = cands[0] ?? u0; + if (cands.length === 0 || Math.abs(u - u0) > s.s10 / 2) lost++; + else dowel.moved++; + } + dowel.positions.push(u); + const zb = br(u); + put('P10', [[u, xP10, zb - s.L10 / 2], [u, xP10, zb + s.L10 / 2]], s.L10, u); + } + dowel.n = n; + dowel.e = e; + if (n < 2) add('RB-AP02', 'W', `다웰 P10이 ${n}개입니다 (슬래브 폭 · 간격 확인).`); + if (lost) add('RB-AP06', 'W', `다웰 P10 ${lost}개는 P3 수평 다리와 ${lenText(gap)} 이상 떨어진 위치를 반 칸 안에서 찾지 못했습니다.`); + // 모따기 (§8): 첫 · 끝 다웰의 슬래브 측면 거리 + 슬래브 끝 거리 ≤ 모따기면 모서리 안. + const fromEnd = g.slab.nearT - xP10; + const first = dowel.positions[0] - a; + const lastD = b - dowel.positions[dowel.positions.length - 1]; + if ((g.slab.chamferA > 0 && first + fromEnd <= g.slab.chamferA) || (g.slab.chamferB > 0 && lastD + fromEnd <= g.slab.chamferB)) { + add('RB-AP05', 'W', '끝 다웰이 접속슬래브 모서리 모따기 안에 듭니다.'); + } + } else { + add('RB-AP02', 'W', '접속슬래브 폭을 알 수 없어 다웰 P10을 놓지 않습니다.'); + } + + // 표 (시안 순서). + const bars: AbutParapetBar[] = []; + const push = (b: Omit) => { + const all = lengths.get(b.mark) ?? []; + if (all.length === 0) return; + const ls = all.map((x) => x.l); + const ref = all.reduce((p, q) => (Math.abs(q.u - uRef) < Math.abs(p.u - uRef) ? q : p)); + const max = Math.max(...ls); + const kinds = new Set(ls.map((l) => Math.round(l))).size; + if (kinds > 10) add('RB-AP04', 'I', `${b.mark} 길이가 흉벽 상단 경사 · 계단으로 ${kinds}종입니다 (철근표에는 범위).`); + const splice = splicePlan(max, d(b.size), fy(b.size), material.fck) ?? undefined; + if (splice) add('RB-AP07', 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(ref.l), lengthMin: Math.round(Math.min(...ls)), lengthMax: Math.round(max), count: all.length, splice }); + }; + const vMod = s.sVert !== undefined; + const hMod = s.sHoriz !== undefined; + const sv = s.sVert ?? s.sL; + const vSp = lenText(2 * sv); + const hSp = lenText(sHoriz); + const tieSp = `${lenText(s.k67L * sv)} × ${lenText(s.k67V * sHoriz)}`; + const off = (v: number) => (v >= 0 ? `+ ${lenText(v)}` : `− ${lenText(-v)}`); + push({ mark: 'P1', size: SZ.P1, role: '연직', spacing: vSp, actual: vSp, shape: `직선 · 교좌면 아래 l_d ${ld.H16}`, position: `전면 x ${lenText(xP1)} (P4 안쪽) · cycle-1`, note: `격자 = 저판 ${off(offsets.P1)} (W2 비켜감) · 위 P2 아래`, basis: '가정', modified: vMod }); + push({ mark: 'P2', size: SZ.P2, role: '연직', spacing: vSp, actual: lenText(sv), shape: `⊓ 앞 다리 l_d · 뒤 다리 브라켓 상면 아래 l_d`, position: `흉벽 상단 − ${lenText(c)} · 수평부 x ${lenText(xP2back)} ~ ${lenText(xP1)}`, note: `cycle-1 · 2 · 격자 저판 ${off(offsets.P2)}`, basis: '가정', modified: vMod }); + push({ mark: 'P3', size: SZ.P3, role: '연직', spacing: vSp, actual: lenText(sv), shape: `ㄱ형 · 연직 + 위 끝 수평 다리 (x ${lenText(xP4)}까지)`, position: `브라켓 배면 x ${lenText(xP3)} · 위 끝 브라켓 상면 − ${lenText(c)}`, note: `cycle-1 · 2 · 격자 저판 ${off(offsets.P3)} · 다웰 P10은 이 수평 다리를 비켜감 (Q-P5)`, basis: '가정', modified: vMod }); + push({ mark: 'P4', size: SZ.P4, role: '수평', spacing: hSp, actual: hSp, shape: '직선 · 상단 경사와 나란함', position: `전면 최외측 x ${lenText(xP4)} · 교대축`, note: `대표 단면 ${levels.P4}단 · 교좌면 계단에서 끊음`, basis: '규칙', modified: hMod }); + push({ mark: 'P5', size: SZ.P5, role: '수평', spacing: hSp, actual: hSp, shape: '직선 · 상단 경사와 나란함', position: `배면 · 브라켓 상면 위 x ${lenText(xP5up)} / 아래 x ${lenText(xP5low)}`, note: `대표 단면 위 ${levels.P5up}단 + 아래 ${levels.P5low}단 (브라켓 상면 높이 단은 위)`, basis: '규칙', modified: hMod }); + push({ mark: 'P6', size: SZ.P6, role: '간격재', spacing: tieSp, actual: '—', shape: `양끝 90° 갈고리 ${h13}`, position: `브라켓 높이 구간 간격재 · 두께 ${lenText(xP4 - xP3)}`, note: `${s.k67L}·s × ${s.k67V}·s · 단마다 반 칸 엇갈림 · skew 방향 (1/sin θ)`, basis: '규칙', modified: s.k67L !== 8 || s.k67V !== 4 }); + push({ mark: 'P7', size: SZ.P7, role: '간격재', spacing: tieSp, actual: '—', shape: `양끝 90° 갈고리 ${h13}`, position: `흉벽 간격재 · 두께 ${lenText(xP4 - xP2back)}`, note: `${s.k67L}·s × ${s.k67V}·s · skew 방향 (1/sin θ)`, basis: '규칙', modified: s.k67L !== 8 || s.k67V !== 4 }); + push({ mark: 'P8', size: SZ.P8, role: '측면', spacing: hSp, actual: hSp, shape: `ㄷ형 · 다리 l_d ${ld.H13}`, position: '측면 하단 (브라켓 높이 구간)', note: `끝면에서 ${lenText(in8 * sin)}`, basis: '규칙', modified: hMod }); + push({ mark: 'P9', size: SZ.P9, role: '측면', spacing: hSp, actual: hSp, shape: `ㄷ형 · 다리 l_d ${ld.H13}`, position: '측면 상단 (브라켓 상면 위)', note: '삽도 기준 상단 = P9', basis: '규칙', modified: hMod }); + push({ mark: 'P10', size: SZ.P10, role: '다웰', spacing: lenText(s.s10), actual: lenText(s.s10), shape: `직선 · 브라켓 ${lenText(s.L10 / 2)} + 슬래브 ${lenText(s.L10 / 2)}`, position: `브라켓 상면 x ${lenText(xP10)} · 다웰`, note: `n = floor((B − 2c)/${lenText(s.s10)}) + 1 · 양끝 여유 ${lenText(dowel.e)}${dowel.moved ? ` · ${dowel.moved}개는 P3 다리를 비켜 옮김` : ''} · R3 회피는 접속슬래브 배근에서`, basis: '규칙', modified: s.s10 !== 400 || s.L10 !== 600 }); + push({ mark: 'P11', size: SZ.P11, role: '연직', spacing: '—', actual: '—', shape: '직선', position: `끝면 최외측 연직 · x ${lenText(xP4)} · ${lenText(xP2back)} / ${lenText(xP3)}`, note: `W11 위로 이음 (교좌면 − l_d) · 위 흉벽 상단 − ${lenText(c)} / 브라켓 상면 − ${lenText(c)}`, basis: '가정', modified: false }); + + return { + settings: s, + x: { P1: xP1, P2back: xP2back, P3: xP3, P4: xP4, P5up: xP5up, P5low: xP5low, P10: xP10 }, + offsets, + levels, + uRef, + dowel, + ld, + paths, + bars, + issues, + }; +} diff --git a/src/core/rebar/common.ts b/src/core/rebar/common.ts index e723fad..9b3f149 100644 --- a/src/core/rebar/common.ts +++ b/src/core/rebar/common.ts @@ -145,6 +145,8 @@ export const NOMINAL_DIAMETER = { /** 교대 저판 B2·B4 (교대확대기초철근.md §1.3, 2026-11-04). */ H22: 22.2, H25: 25.4, + /** 교대 흉벽 다웰 P10 (교대흉벽철근.md §1.2, 2026-11-04). */ + D25: 25.4, H29: 28.6, S32: 31.8, D35: 34.9, diff --git a/tests/abut-rebar-parapet.test.ts b/tests/abut-rebar-parapet.test.ts new file mode 100644 index 0000000..251e296 --- /dev/null +++ b/tests/abut-rebar-parapet.test.ts @@ -0,0 +1,142 @@ +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 { abutFootingGeom, abutFootingRebar } from '../src/core/abut/rebar/footing'; +import { skewAngle } from '../src/core/abut/rebar/skew-layout'; +import { abutWallGeom, abutWallRebar } from '../src/core/abut/rebar/wall'; +import { abutParapetGeom, abutParapetRebar, slabSpanAt, type AbutParapetGeom } from '../src/core/abut/rebar/parapet'; +import { resolveSlab } from '../src/core/abut/slab'; +import { abutPlanOutline, abutSkewK } from '../src/view/abut-scene'; +import { DEFAULT_MATERIAL } from '../src/core/rebar/common'; + +// 예제 A1 (교대흉벽철근.md §11). 기획서 기대값은 fck 27 · fy 400, 구간 1 끝 (IP3 끝) 단면. +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 wings = buildWings(model, members); +const angle = skewAngle(rec.skewDeg); +const footing = abutFootingRebar(abutFootingGeom(model.length, members.shape, angle)); +const fck27 = { ...DEFAULT_MATERIAL, fck: 27 }; +const wGeom = abutWallGeom(model.length, members, angle); +const wall = abutWallRebar(wGeom, footing, undefined, fck27); +const k = abutSkewK(model); +const xP10 = (wGeom.xb - 500) / 2; +const slab = resolveSlab(); +const span = slabSpanAt(abutPlanOutline(model, members, wings, slab).slab, xP10 / 1000, k)!; +const chamfer = (sEnd: number) => (wings.find((w) => w.sEnd === sEnd)?.haunchM ?? 0) * 1000; +const geom: AbutParapetGeom = abutParapetGeom(wGeom, members.shape, { + B: model.wing!.slabWidthMm, + a: span.a, + b: span.b, + nearT: members.shape.parapetBackM * 1000 - slab.parapetGapMm, + chamferA: chamfer(0), + chamferB: chamfer(model.length), +}); +const A1 = abutParapetRebar(geom, wall, undefined, fck27, 'A1'); +const bar = (r: typeof A1, mark: string) => r.bars.find((b) => b.mark === mark)!; + +describe('교대 흉벽 · 브라켓 배근 A1 (AD5, 교대흉벽철근.md §11)', () => { + it('geometry: 흉벽 높이 입력 2500, 브라켓 850, 흉벽 배면 −500, 슬래브 폭 11253 (반올림 11254). 상단선은 받침 ① · 마지막 받침이 정해 IP1 2529 · IP3 2468', () => { + expect([geom.Hp, geom.bracketH, geom.parapetBack]).toEqual([2500, 850, -500]); + expect(Math.abs(geom.slab!.B - 11253)).toBeLessThanOrEqual(1); + const hAt = (u: number) => geom.top[0] + ((geom.top[1] - geom.top[0]) * u) / wGeom.L - (wGeom.HF + wGeom.segments.find((s) => u < s.to)!.H); + expect(hAt(A1.uRef)).toBeCloseTo(2500, 6); // 대표 단면 = 흉벽 높이 2500인 곳 + }); + + it('위치: P4 −100, P1 −115.9, P2 뒤 다리 −400, P5 −384.1 / −684.1, P3 −700, P10 −650', () => { + expect([A1.x.P4, A1.x.P2back, A1.x.P3, A1.x.P10]).toEqual([-100, -400, -700, -650]); + expect(A1.x.P1).toBeCloseTo(-115.9, 6); + expect(A1.x.P5up).toBeCloseTo(-384.1, 6); + expect(A1.x.P5low).toBeCloseTo(-684.1, 6); + }); + + it('격자: P1 = 저판 − 17.5 (47), P2 − 1.6 (94), P3 − 35 (94)', () => { + expect(A1.offsets.P1).toBeCloseTo(-17.5, 6); + expect(A1.offsets.P2).toBeCloseTo(-1.6, 6); + expect(A1.offsets.P3).toBeCloseTo(-35, 6); + expect(['P1', 'P2', 'P3'].map((m) => bar(A1, m).count)).toEqual([47, 94, 94]); + }); + + it('길이 (대표 단면, 흉벽 높이 2500): P1 3119, P2 2504, P3 2885 (ㄱ형)', () => { + expect(['P1', 'P2', 'P3'].map((m) => bar(A1, m).length)).toEqual([3119, 2504, 2885]); + }); + + it('수평 단 (대표 단면): P4 19단, P5 7 + 12단 (1650 단은 위 그룹)', () => { + expect([A1.levels.P4, A1.levels.P5up, A1.levels.P5low]).toEqual([19, 7, 12]); + }); + + it('P6 · P7: 대표 단면 3단 / 2단, 교대축 12개, 길이 600 / 300 (1/sin θ) + 2 × 197 = 996 / 695', () => { + expect([A1.levels.P6, A1.levels.P7]).toEqual([3, 2]); + expect(bar(A1, 'P7').count).toBe(24); + expect([bar(A1, 'P6').length, bar(A1, 'P7').length]).toEqual([996, 695]); + }); + + it('P8 · P9: 12단 1776 / 7단 1475, 각 × 2면, 끝면에서 114.3', () => { + expect([bar(A1, 'P8').count, bar(A1, 'P8').length]).toEqual([24, 1776]); + expect([bar(A1, 'P9').count, bar(A1, 'P9').length]).toEqual([14, 1475]); + const p8 = A1.paths.find((p) => p.mark === 'P8')!; + expect(p8.pts[1][0] * angle.sin).toBeCloseTo(114.3, 6); + }); + + it('P11: 끝면당 3개 (x −100 · −400 · −700), l_d + 흉벽 높이 − c / l_d + 흉벽 높이 − 850 − c (높이 2500이면 3135 / 3135 / 2285), 양끝 6개', () => { + expect(bar(A1, 'P11').count).toBe(6); + const low = A1.paths.filter((p) => p.mark === 'P11' && p.pts[0][0] > wGeom.L / 2); + const u = low[0].pts[0][0]; // 끝면 − c / sin θ + const H = geom.top[0] + ((geom.top[1] - geom.top[0]) * u) / wGeom.L - (wGeom.HF + 2372); // IP3 끝의 실제 흉벽 높이 (약 2468) + expect(low.map((p) => p.pts[1][2] - p.pts[0][2])).toEqual([735 + H - 100, 735 + H - 100, 735 + H - 850 - 100].map((v) => expect.closeTo(v, 6))); + const flat = abutParapetRebar({ ...geom, top: [wGeom.HF + 2372 + 2500, wGeom.HF + 2372 + 2500] }, wall, undefined, fck27); + const lowFlat = flat.paths.filter((p) => p.mark === 'P11' && p.pts[0][0] > wGeom.L / 2); + expect(lowFlat.map((p) => Math.round(p.pts[1][2] - p.pts[0][2]))).toEqual([3135, 3135, 2285]); + }); + + it('P10: B 11253 → 28개, e 126.5; 한쪽 없음 11725 → 29, 양측 없음 12197 → 30; 모따기 검사 통과', () => { + expect(A1.dowel.n).toBe(28); + expect(abutParapetRebar({ ...geom, slab: { ...geom.slab!, B: 11253 } }, wall, undefined, fck27).dowel.e).toBe(126.5); + // 슬래브 가운데 맞춤: 양끝 다웰이 측면선에서 거의 같은 거리 (c + e, Q-P5로 옮긴 만큼만 다름). + const s = geom.slab!; + expect(Math.abs(A1.dowel.positions[0] - s.a - (s.b - A1.dowel.positions[27]))).toBeLessThan(2 * 20.65 + 1); + const withB = (B: number) => abutParapetRebar({ ...geom, slab: { ...geom.slab!, B } }, wall, undefined, fck27).dowel.n; + expect([withB(11725), withB(12197)]).toEqual([29, 30]); + expect(A1.issues.map((i) => i.code)).not.toContain('RB-AP05'); + }); + + it('Q-P5: every dowel clears the P3 수평 다리 by (25.4 + 15.9)/2', () => { + const p3 = A1.paths.filter((p) => p.mark === 'P3').map((p) => p.pts[0][0]); + for (const u of A1.dowel.positions) expect(Math.min(...p3.map((q) => Math.abs(u - q)))).toBeGreaterThanOrEqual(20.65 - 1e-6); + expect(A1.issues.map((i) => i.code)).not.toContain('RB-AP06'); + }); + + it('경사: 흉벽 상단이 기울어 P1 길이가 위치마다 다름 (범위), 수평 단이 경사와 나란함', () => { + const p1 = bar(A1, 'P1'); + expect(p1.lengthMax).toBeGreaterThan(p1.lengthMin); + const p4 = A1.paths.find((p) => p.mark === 'P4')!; + const slope = (geom.top[1] - geom.top[0]) / wGeom.L; + expect((p4.pts[1][2] - p4.pts[0][2]) / (p4.pts[1][0] - p4.pts[0][0])).toBeCloseTo(slope, 9); + }); + + it('A1 has no warning', () => { + expect(A1.issues.filter((i) => i.level !== 'I').map((i) => i.code)).toEqual([]); + }); + + it('l_d 연동: fck 30 → P1 · P2 · P3 길이 함께 짧아짐', () => { + const r30 = abutParapetRebar(geom, wall, undefined, { ...DEFAULT_MATERIAL, fck: 30 }); + const dl = A1.ld.H16 - r30.ld.H16; + expect(bar(r30, 'P1').length).toBe(3119 - dl); + expect(bar(r30, 'P2').length).toBe(2504 - 2 * dl); + expect(bar(r30, 'P3').length).toBe(2885 - dl); + }); + + it('묶음 수정 · 배수 → RB-AP03 "수정됨"', () => { + const r = abutParapetRebar(geom, wall, { sHoriz: 150, k67L: 6 }, fck27); + expect(r.issues.map((i) => i.code)).toContain('RB-AP03'); + expect(bar(r, 'P4').modified).toBe(true); + expect(bar(r, 'P6').modified).toBe(true); + expect(bar(r, 'P1').modified).toBe(false); + }); +});