diff --git a/src/core/abut/rebar/wing.ts b/src/core/abut/rebar/wing.ts new file mode 100644 index 0000000..3ad481d --- /dev/null +++ b/src/core/abut/rebar/wing.ts @@ -0,0 +1,473 @@ +/** + * 교대 날개벽 배근 · AD7 (2026-11-04, 교대날개벽철근.md, 결정 2026-11-02 · 2026-11-04). 순수 함수, 길이는 mm. + * + * - 연직: WR1 · WR2 배면 (@s_h), WR11 · WR12 전면 (@2·s_h). 기준선 = 면 위 뒷굽 끝 − c. 등단면(WR2 · WR12)은 교대 쪽으로 + * ceil((L_heel − δ)/s) + 1개, 저판 하면망 위 갈고리. 변화단면(WR1 · WR11)은 끝 쪽으로 floor((끝 − c − 기준선 − e_min)/s)개, 아래 끝 경사 하단 + c. + * - 수평: WR3–5 배면 (@s_v), WR13–15 전면 (@2·s_v), 띠 = 등단면 / 변화단면1 / 변화단면2. 맨 위 2단은 상단 경사와 나란히 + * (사용자 결정 2026-11-04, Q-WR1), 일반 단은 그 아래 s 이상. 교대 쪽 끝은 교대 단면을 지나 반대쪽 면 − c, 90° 갈고리. + * - WR6 경사 하단 2개, WR7-1 · 7-2 · 7-3 헌치 꺾임 (전면 수평과 같은 단), WR9 헌치 대각 위 연직 (@2·s_h 자동 개수, Q-WR3), + * WR10 모서리 꺾임 (@s_v), WR20 간격재. 교대 철근과의 간섭은 보지 않는다 (Q-WR2 · Q-WR4 결정). + * - 단축형 (QA-14 · §9): WR1 · WR11 · WR6 없음, 연직은 모두 등단면. + * - 좌표: 교대 (s, t, z) — s 교대축 (IP1에서), t 교대축 직각 (전면 +), z 저판 하면에서. 날개벽 거리 d는 교대축 선에서 날개벽 방향. + */ +import { DEFAULT_MATERIAL, developmentLength, fyOf, hookLength, 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 AbutWingRebarSettings { + c: number; + /** 날개벽 기본간격: 수평 (연직철근이 늘어서는 간격) / 연직 (수평 단 간격). 배면 s, 전면 · 헌치 2s. */ + sH: number; + sV: number; + /** 등단면 개수 올림 허용차. */ + delta: number; + eMin: number; + /** WR20 배수 (수평 · 연직). */ + k20: number; + /** 묶음별 간격 수정 (배면 값, 전면은 2배): 연직 WR1 · 2 · 11 · 12, 수평 WR3–5 · 13–15. */ + sVert?: number; + sHoriz?: number; +} + +export const ABUT_WING_REBAR_DEFAULT: AbutWingRebarSettings = { c: 100, sH: 125, sV: 125, delta: 25, eMin: 150, k20: 8 }; + +/** 규격 (기획서 §1.2). WR8 · WR16–19는 결번. */ +export const ABUT_WING_SIZES = { + WR1: 'H16', WR2: 'H16', WR11: 'H16', WR12: 'H16', WR6: 'H16', WR9: 'H16', + WR3: 'H25', WR4: 'H25', WR5: 'H25', WR13: 'H25', WR14: 'H25', WR15: 'H25', + 'WR7-1': 'H25', 'WR7-2': 'H25', 'WR7-3': 'H25', WR10: 'H25', + WR20: 'H13', +} as const satisfies Record; +export type WingMark = keyof typeof ABUT_WING_SIZES; + +type V2 = [number, number]; + +/** 한 측 날개벽 형상 (mm, 교대 s · t, z는 저판 하면에서). */ +export interface AbutWingGeom { + side: 'left' | 'right'; + form: 'general' | 'short'; + /** 전면(바깥)면이 교대축 선과 만나는 점 (s, t). */ + p0: V2; + /** 날개벽 방향 단위 벡터 (s, t), 교대에서 멀어지는 쪽. */ + w: V2; + /** 전면 → 배면(흙 쪽) 면 이동 (s, t): 교대축 방향으로 두께만큼. */ + inward: V2; + /** 날개벽 길이 (교대축 선에서), 끝 높이, 헌치 다리. */ + L: number; + endH: number; + haunch: number; + /** 교대축 선에서 날개벽 방향으로 잰 뒷굽 끝 · 벽체 배면 · 흉벽 배면 거리. */ + heelD: number; + wallBackD: number; + parapetBackD: number; + /** 교대 단면 t: 흉벽 배면 · 벽체 배면 · 벽체 전면. */ + parapetBackT: number; + wallBackT: number; + wallFrontT: number; + /** z (저판 하면에서): 저판 상면, 날개벽 상단 시작 (교대축 선) · 경사, 단부 하단, 교좌면 (가장 가까운 받침), 브라켓 상면. */ + footTop: number; + topStart: number; + slope: number; + bottomEnd: number; + seat: number; + bracketTop: number; + /** 저판 하면망 B6 · B8 높이 (z). */ + zB68: number; +} + +export type WingRole = '연직' | '수평' | '헌치' | '모서리' | '간격재' | '경사 하단'; + +export interface AbutWingBar { + mark: WingMark; + size: BarSize; + role: WingRole; + 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 WingBarPath { + mark: WingMark; + /** (s, t, z) mm. */ + pts: [number, number, number][]; +} + +export interface AbutWingRebar { + side: 'left' | 'right'; + settings: AbutWingRebarSettings; + /** 면별 연직 배치 (면 위 거리, 삽도 치수 기준): 기준선, 등단면 칸 수, 변화단면 칸 수, 끝 여유, 마지막 등단면 위치, 치수 문자열. */ + vertical: Record<'front' | 'back', { base: number; nE: number; nV: number; endGap: number; lastE: number; text: string }>; + /** 수평 단 수: 교대 쪽 (상단 높이 H_s) · 끝 (H_e), 면별. */ + levels: Record<'front' | 'back', { start: number; end: number }>; + haunchLevels: { 'WR7-1': number; 'WR7-2': number; 'WR7-3': number }; + ld: Record<'H13' | 'H16' | 'H25', number>; + paths: WingBarPath[]; + bars: AbutWingBar[]; + issues: Issue[]; +} + +const d = (size: BarSize) => NOMINAL_DIAMETER[size]; + +export function resolveWingRebar(changes?: Partial): AbutWingRebarSettings { + return { ...ABUT_WING_REBAR_DEFAULT, ...(changes ?? {}) }; +} + +export function wingRebarIssues(s: AbutWingRebarSettings): string[] { + const out: string[] = []; + for (const key of ['c', 'sH', 'sV', 'eMin'] as const) if (!(s[key] > 0)) out.push(`${key}은(는) 0보다 커야 합니다.`); + if (!(s.delta >= 0)) out.push('등단면 올림 허용차는 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.k20) && s.k20 >= 1)) out.push('WR20 배수는 1 이상의 정수여야 합니다.'); + return out; +} + +/** 일반 단 + 맨 위 경사 2단의 단 수 at 저판 상면 위 높이 H (면 간격 s): floor((H − c − 2s − c)/s) + 1 + 2. */ +export function wingLevelCount(H: number, c: number, s: number): number { + return Math.max(0, Math.floor((H - 2 * c - 2 * s) / s + 1e-9) + 1) + 2; +} + +/** 한 측 날개벽 형상 from the ② wing solid (m, plan E · N) and the abutment (mm, z from the 기초 하단). */ +export function abutWingGeom( + model: { ip1: { e: number; n: number }; u: { e: number; n: number }; nLeft: { e: number; n: number }; frontSide: number }, + members: { shape: { parapetBackM: number; wallBackM: number; wallFrontM: number; bracketHeightM: number }; footingTopEl: number; footingBottomEl: number | undefined }, + wing: { + side: 'left' | 'right'; + form: 'general' | 'short'; + corners: { e: number; n: number }[]; + lengthM: number; + endHeightM: number; + haunchM: number; + heelDistanceM: number; + topStartEl: number; + topEndEl: number; + bottomEndEl: number; + bands: { name: string; zBottomEl: number }[]; + }, + zB68: number, +): AbutWingGeom { + const front = { e: model.nLeft.e * model.frontSide, n: model.nLeft.n * model.frontSide }; + const st = (p: { e: number; n: number }): V2 => { + const d0 = { e: p.e - model.ip1.e, n: p.n - model.ip1.n }; + return [(d0.e * model.u.e + d0.n * model.u.n) * 1000, (d0.e * front.e + d0.n * front.n) * 1000]; + }; + const [c0, c1, , c3] = wing.corners; + const p0 = st(c0); + const p1 = st(c1); + const len = Math.hypot(p1[0] - p0[0], p1[1] - p0[1]); + const w: V2 = [(p1[0] - p0[0]) / len, (p1[1] - p0[1]) / len]; + const q3 = st(c3); + const k = Math.abs(w[1]); + const bottom = members.footingBottomEl ?? 0; + const z = (el: number) => (el - bottom) * 1000; + const L = wing.lengthM * 1000; + const seatBand = wing.bands.find((b) => b.name === '변화단면2'); + return { + side: wing.side, + form: wing.form, + p0, + w, + inward: [q3[0] - p0[0], q3[1] - p0[1]], + L, + endH: wing.endHeightM * 1000, + haunch: wing.haunchM * 1000, + heelD: wing.heelDistanceM * 1000, + wallBackD: (Math.abs(members.shape.wallBackM) * 1000) / k, + parapetBackD: (Math.abs(members.shape.parapetBackM) * 1000) / k, + parapetBackT: members.shape.parapetBackM * 1000, + wallBackT: members.shape.wallBackM * 1000, + wallFrontT: members.shape.wallFrontM * 1000, + footTop: z(members.footingTopEl), + topStart: z(wing.topStartEl), + slope: (wing.topEndEl - wing.topStartEl) / wing.lengthM, + bottomEnd: z(wing.bottomEndEl), + seat: seatBand ? z(wing.bands.find((b) => b.name === '변화단면1')!.zBottomEl) : z(members.footingTopEl), + bracketTop: z(wing.topStartEl - members.shape.bracketHeightM), + zB68, + }; +} + +export function abutWingRebar(g: AbutWingGeom, changes?: Partial, material: Material = DEFAULT_MATERIAL, who = ''): AbutWingRebar { + const s = resolveWingRebar(changes); + const issues: Issue[] = []; + const side = g.side === 'left' ? '좌' : '우'; + const add = (code: string, level: Issue['level'], message: string) => issues.push({ code, level, pier: who || undefined, message: `${side} 날개벽 · ${message}` }); + const SZ = ABUT_WING_SIZES; + const c = s.c; + const sBack = s.sVert ?? s.sH; + const sBackV = s.sHoriz ?? s.sV; + if (s.sVert !== undefined || s.sHoriz !== undefined || s.k20 !== 8 || s.delta !== 25) add('RB-AW03', 'I', '간격을 기본간격과 따로 고쳤거나 δ · WR20 배수가 기본값이 아닙니다.'); + 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'), H25: ldOf('H25') }; + const hook = (size: BarSize) => Math.round(hookLength('main90', d(size))); + + // 평면 틀: 전면 위 점 = p0 + d·w, 배면 위 점 = p0 + inward + d·w. n = 전면 → 배면 단위 (w에 수직), T = 수직 두께. + const [ws, wt] = g.w; + const iw = g.inward[0] * ws + g.inward[1] * wt; + const nv: V2 = [g.inward[0] - iw * ws, g.inward[1] - iw * wt]; + const T = Math.hypot(nv[0], nv[1]); + const n: V2 = [nv[0] / T, nv[1] / T]; + /** Point at axis-based distance d, `depth` from the front face toward the back. */ + const at = (dd: number, depth: number): V2 => [g.p0[0] + dd * ws + depth * n[0], g.p0[1] + dd * wt + depth * n[1]]; + // 배면은 면 위 거리를 수선의 발에서 잰다 (기획서 §4.1: 면 길이 차 = 두께 · cot φ). o = 배면 거리 − 축선 기준 거리. + const o = iw; + const top = (dd: number) => g.topStart + g.slope * dd; + const general = g.form === 'general' && g.heelD < g.L; + const bottom = (dd: number) => (!general || dd <= g.heelD ? g.footTop : g.footTop + ((g.bottomEnd - g.footTop) * (dd - g.heelD)) / (g.L - g.heelD)); + + const paths: WingBarPath[] = []; + const lengths = new Map(); + const put = (mark: WingMark, pts: [number, number, number][], length: number) => { + paths.push({ mark, pts }); + lengths.set(mark, [...(lengths.get(mark) ?? []), length]); + }; + const P3 = (p: V2, z: number): [number, number, number] => [p[0], p[1], z]; + + // 연직 (§4.1 · §4.2). 면 위 거리 df: 전면은 축선 기준, 배면은 수선의 발 기준 (df = d + o). + const zHook16 = g.zB68 + (d('H16') + d(SZ.WR2)) / 2; + const h16 = hook(SZ.WR2); + const vertical = {} as AbutWingRebar['vertical']; + for (const face of ['front', 'back'] as const) { + const sp = face === 'front' ? 2 * sBack : sBack; + const off = face === 'front' ? 0 : o; + const depth = face === 'front' ? c : T - c; + const out = face === 'front' ? -1 : 1; // 갈고리는 날개벽 바깥쪽 + const heelF = Math.min(g.heelD, g.L) + off; + const endF = g.L + off; + const base = heelF - c; + const nE = Math.max(0, Math.ceil((heelF - g.wallBackD - s.delta) / sp - 1e-9)); + const nV = general ? Math.max(0, Math.floor((endF - c - base - s.eMin) / sp + 1e-9)) : 0; + const endGap = endF - c - base - nV * sp; + const markE: WingMark = face === 'front' ? 'WR12' : 'WR2'; + const markV: WingMark = face === 'front' ? 'WR11' : 'WR1'; + for (let i = 0; i <= nE; i++) { + const dd = base - i * sp - off; + const p = at(dd, depth); + const zt = top(dd) - c; + const ph: V2 = [p[0] + out * n[0] * h16, p[1] + out * n[1] * h16]; + put(markE, [P3(ph, zHook16), P3(p, zHook16), P3(p, zt)], zt - zHook16 + h16); + } + for (let j = 1; j <= nV; j++) { + const dd = base + j * sp - off; + const p = at(dd, depth); + const zb = bottom(dd) + c; + const zt = top(dd) - c; + if (zt - zb < 2 * c) continue; + put(markV, [P3(p, zb), P3(p, zt)], zt - zb); + } + const lastE = base - nE * sp; + vertical[face] = { + base, + nE, + nV, + endGap, + lastE, + text: `${lenText(c)} · ${lenText(endGap)} · ${nV}@${lenText(sp)}=${lenText(nV * sp)} · ${nE}@${lenText(sp)}=${lenText(nE * sp)} · ${lenText(lastE)}`, + }; + } + if (g.endH < 2 * c + sBack) add('RB-AW04', 'W', `끝 높이 ${lenText(g.endH)}가 2c + s보다 작아 연직철근 놓을 자리가 없습니다.`); + + // 수평 (§5): 일반 단 h_k = c + k·s (저판 상면 위), 맨 위 2단은 상단 경사와 나란히. 띠는 높이로. + const bandOf = (z: number) => (z >= g.bottomEnd - 1e-6 || !general ? 0 : z > g.seat + 1e-6 ? 1 : 2); + const h25 = hook('H25'); + // 교대 쪽 끝: 흉벽(+브라켓) 전면 − c (t = −c) 또는 벽체 전면 − c, d로. + const dAt = (t: number) => -t / -wt; // t = d·wt along the front face (p0 on the axis, t0 = 0) + const dInner = [dAt(-c), dAt(-c), dAt(g.wallFrontT - c)]; + const abutEdge = (z: number) => (z > g.bracketTop + 1e-6 ? g.parapetBackD : g.wallBackD); + let shortAnchor = false; + const levels = { front: { start: 0, end: 0 }, back: { start: 0, end: 0 } }; + for (const face of ['front', 'back'] as const) { + const sp = face === 'front' ? 2 * sBackV : sBackV; + const depth = face === 'front' ? c + (d('H16') + d('H25')) / 2 : T - c - (d('H16') + d('H25')) / 2; + const marks: WingMark[] = face === 'front' ? ['WR13', 'WR14', 'WR15'] : ['WR3', 'WR4', 'WR5']; + const dEnd = g.L - c; + // 맨 위 2단: 경사와 나란히, 교대 단면을 지나 흉벽 전면 − c. + for (let m = 0; m < 2; m++) { + const za = top(dInner[0]) - c - m * sp; + const zb = top(dEnd) - c - m * sp; + const a = at(dInner[0], depth); + const b = at(dEnd, depth); + put(marks[0], [P3(a, za - h25), P3(a, za), P3(b, zb)], Math.hypot(dEnd - dInner[0], zb - za) + h25); + } + // 일반 단. + const cutTop = (dd: number) => top(dd) - c - 2 * sp; // 일반 단은 이 높이 이하 (맨 위 2단 아래 s 이상) + for (let k = 0; ; k++) { + const z = g.footTop + c + k * sp; + if (z > Math.max(cutTop(0), cutTop(g.L)) + 1e-6) break; + // d 범위: 상단 끊김 (경사로 교대 쪽이 낮음) · 경사 하단 (끝 쪽에서 올라옴). + let dA = 0; + if (z > cutTop(0) + 1e-6) dA = (z - (g.topStart - c - 2 * sp)) / g.slope; + let dB = dEnd; + if (general && z < bottom(dEnd) + c - 1e-6) dB = g.heelD + ((z - c - g.footTop) / (g.bottomEnd - g.footTop)) * (g.L - g.heelD); + else if (general && z > bottom(dEnd) + c - 1e-6) dB = dEnd; + if (dB - dA < c) continue; + const band = bandOf(z); + const mark = marks[band]; + const startsAtAbut = dA <= abutEdge(z) + 1e-6; + const b = at(dB, depth); + if (startsAtAbut) { + const di = dInner[band]; + const a = at(di, depth); + if (band === 0 && abutEdge(z) - di + h25 < ld.H25) shortAnchor = true; + put(mark, [P3(a, z - h25), P3(a, z), P3(b, z)], dB - di + h25); + } else { + const a = at(dA, depth); + put(mark, [P3(a, z), P3(b, z)], dB - dA); + } + } + levels[face] = { start: wingLevelCount(g.topStart - g.footTop, c, sp), end: wingLevelCount(top(g.L) - g.footTop, c, sp) }; + } + if (shortAnchor) add('RB-AW02', 'W', `등단면 수평철근(WR3 · WR13)의 교대 쪽 정착이 l_d ${ld.H25}보다 짧습니다 (흉벽 두께).`); + + // WR6 (§6): 경사 하단과 나란히 c 띄워 전면 · 배면 각 1, 끝면 − c → 뒷굽 끝 → 저판 안 l_d. + if (general) { + const slopeLen = Math.hypot(g.L - g.heelD, g.bottomEnd - g.footTop); + const ux = (g.L - g.heelD) / slopeLen; + const uz = (g.bottomEnd - g.footTop) / slopeLen; + for (const depth of [c, T - c]) { + const dTop = g.L - c; + const zTop = bottom(dTop) + c; + const zH = g.footTop + c; + const dH = g.heelD; // 뒷굽 끝 위 c + const dIn = dH - ld.H16 * ux; + const zIn = zH - ld.H16 * uz; + put('WR6', [P3(at(dTop, depth), zTop), P3(at(dH, depth), zH), P3(at(dIn, depth), zIn)], Math.hypot(dTop - dH, zTop - zH) + ld.H16); + } + } + + // 헌치 · 모서리 (§6 · §7): 평면에서 배면(흙 쪽) 면과 교대 배면이 만나는 모서리. + const eIn: V2 = [-n[0], -n[1]]; // 배면에서 날개벽 안쪽 (전면 쪽) + const tUnit: V2 = [0, 1]; // 교대 안쪽 (전면 쪽) + const uOut: V2 = [Math.sign(g.inward[0]), 0]; // 날개벽 배면에서 교대 배면을 따라 반대쪽 날개벽 쪽 + const lineX = (p: V2, u: V2, q: V2, v: V2): V2 => { + const den = u[0] * v[1] - u[1] * v[0]; + const f = ((q[0] - p[0]) * v[1] - (q[1] - p[1]) * v[0]) / den; + return [p[0] + u[0] * f, p[1] + u[1] * f]; + }; + const add2 = (p: V2, u: V2, f: number): V2 => [p[0] + u[0] * f, p[1] + u[1] * f]; + const haunchLevels = { 'WR7-1': 0, 'WR7-2': 0, 'WR7-3': 0 }; + const wDir: V2 = [ws, wt]; + if (g.haunch > 0) { + const sp = 2 * sBackV; + const legIn = T - c - (d('H16') + d('H25')) / 2 - (d('H25') + d('H25')) / 2; // WR3–5 안쪽 25.4 + for (let k = 0; ; k++) { + const z = g.footTop + c + k * sp; + if (z > g.topStart - c + 1e-6) break; + const mark: WingMark = z > g.bracketTop + 1e-6 ? 'WR7-1' : z > g.seat + 1e-6 ? 'WR7-2' : 'WR7-3'; + haunchLevels[mark]++; + const backT = mark === 'WR7-1' ? g.parapetBackT : g.wallBackT; + const tEnd = mark === 'WR7-3' ? g.wallFrontT - c : -c; + // 모서리 = 배면 선 ∩ 교대 배면 선; 헌치 대각은 날개벽 쪽 점 ~ 교대 쪽 점, 철근은 그 안쪽 c. + const backFace = at(0, T); + const corner = lineX(backFace, wDir, [0, backT], [1, 0]); + const hw = add2(corner, wDir, g.haunch); + const ha = add2(corner, uOut, g.haunch); + const dv: V2 = [ha[0] - hw[0], ha[1] - hw[1]]; + const dl = Math.hypot(dv[0], dv[1]); + const du: V2 = [dv[0] / dl, dv[1] / dl]; + const toCorner: V2 = [corner[0] - hw[0], corner[1] - hw[1]]; + const nrm: V2 = [-du[1], du[0]]; + const sgn = nrm[0] * toCorner[0] + nrm[1] * toCorner[1] > 0 ? 1 : -1; + const diagP = add2(hw, nrm, sgn * c); + const legLine = at(0, legIn); + const A = lineX(legLine, wDir, diagP, du); + const B = lineX(diagP, du, [0, backT + c], [1, 0]); + const P0 = add2(A, wDir, ld.H25); + const C: V2 = [B[0], tEnd]; + const len = ld.H25 + Math.hypot(B[0] - A[0], B[1] - A[1]) + Math.abs(tEnd - B[1]) + h25; + put(mark, [P3(P0, z), P3(A, z), P3(B, z), P3(C, z), P3(C, z - h25)], len); + } + // WR9 (§6, 결정 2026-11-04): 아래 헌치 대각 위 연직 @2·s_h, 개수 = floor(대각 / 간격) + 1, 가운데 맞춤. 위 끝 브라켓 상면 − c (그 위는 헌치 없음). + const corner = lineX(at(0, T), wDir, [0, g.wallBackT], [1, 0]); + const hw = add2(corner, wDir, g.haunch); + const ha = add2(corner, uOut, g.haunch); + const mid: V2 = [(hw[0] + ha[0]) / 2, (hw[1] + ha[1]) / 2]; + const toC: V2 = [corner[0] - mid[0], corner[1] - mid[1]]; + const tc = Math.hypot(toC[0], toC[1]); + const diagLen = Math.hypot(ha[0] - hw[0], ha[1] - hw[1]); + const sp9 = 2 * sBack; + const n9 = Math.floor(diagLen / sp9 + 1e-9) + 1; + const du: V2 = [(ha[0] - hw[0]) / diagLen, (ha[1] - hw[1]) / diagLen]; + for (let i = 0; i < n9; i++) { + const p = add2(add2(mid, du, (i - (n9 - 1) / 2) * sp9), [toC[0] / tc, toC[1] / tc], c); + const ph = add2(p, tUnit, h16); + put('WR9', [P3(ph, zHook16), P3(p, zHook16), P3(p, g.bracketTop - c)], g.bracketTop - c - zHook16 + h16); + } + } + // WR10 (§7): 모서리 L 꺾임 @s_v, 저판 상면 + c ~ 흉벽 상단 − c. 교대 배면 · 날개벽 배면에서 각 120.65 안, 모서리 대각 300, 양끝 l_d. + { + const depth10 = c + (d('H16') + d('H25')) / 2; + const r = 300; + for (let z = g.footTop + c; z <= g.topStart - c + 1e-6; z += sBackV) { + const backT = z > g.bracketTop + 1e-6 ? g.parapetBackT : g.wallBackT; + const wingLine = at(0, T - depth10); + const X = lineX(wingLine, wDir, [0, backT + depth10], [1, 0]); + const X1 = add2(X, uOut, r); + const X2 = add2(X, wDir, r); + put('WR10', [P3(add2(X1, uOut, ld.H25), z), P3(X1, z), P3(X2, z), P3(add2(X2, wDir, ld.H25), z)], 2 * ld.H25 + Math.hypot(X2[0] - X1[0], X2[1] - X1[1])); + } + } + // WR20 (§8): 간격재, 수평 @k·s_h × 연직 @k·s_v, 단마다 반 칸 엇갈림, 전면 · 배면 수평철근 사이 + 양끝 갈고리. + { + const h13 = hook(SZ.WR20); + const dx = s.k20 * sBack; + const dz = s.k20 * sBackV; + const dF = c + (d('H16') + d('H25')) / 2; + const dB = T - dF; + let j = 0; + for (let z = g.footTop + c; z <= g.topStart + g.slope * g.L - c + 1e-6; z += dz, j++) { + for (let dd = g.wallBackD + c + (j % 2 ? dx / 2 : 0); dd <= g.L - c + 1e-6; dd += dx) { + if (z > top(dd) - c || z < bottom(dd) + c) continue; + const a = at(dd, dF); + const b = at(dd, dB); + put('WR20', [P3(a, z - h13), P3(a, z), P3(b, z), P3(b, z - h13)], dB - dF + 2 * h13); + } + } + } + + // 표 (시안 순서). + const bars: AbutWingBar[] = []; + 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-AW01', 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 vMod = s.sVert !== undefined; + const hMod = s.sHoriz !== undefined; + const v2 = lenText(2 * sBack); + const v1 = lenText(sBack); + const hz2 = lenText(2 * sBackV); + const hz1 = lenText(sBackV); + push({ mark: 'WR11', size: SZ.WR11, role: '연직', spacing: v2, actual: v2, shape: '직선 · 아래 경사 하단 + c', position: '전면 · 변화단면', note: `기준선 (뒷굽 끝 − ${lenText(c)}) → 끝 쪽 ${vertical.front.nV}@${v2} · 끝 여유 ${lenText(vertical.front.endGap)}`, basis: '규칙', modified: vMod }); + push({ mark: 'WR12', size: SZ.WR12, role: '연직', spacing: v2, actual: v2, shape: `아래 90° 갈고리 ${h16} (저판 B6 · B8 위)`, position: '전면 · 등단면', note: `기준선 + 교대 쪽 ${vertical.front.nE}@${v2} · 마지막 ${lenText(vertical.front.lastE)}`, basis: '규칙', modified: vMod }); + push({ mark: 'WR1', size: SZ.WR1, role: '연직', spacing: v1, actual: v1, shape: '직선 · 아래 경사 하단 + c', position: '배면 · 변화단면', note: `${vertical.back.nV}@${v1} · 끝 여유 ${lenText(vertical.back.endGap)}`, basis: '규칙', modified: vMod }); + push({ mark: 'WR2', size: SZ.WR2, role: '연직', spacing: v1, actual: v1, shape: `아래 90° 갈고리 ${h16}`, position: '배면 · 등단면', note: `기준선 + ${vertical.back.nE}@${v1} · 마지막 ${lenText(vertical.back.lastE)}`, basis: '규칙', modified: vMod }); + push({ mark: 'WR13', size: SZ.WR13, role: '수평', spacing: hz2, actual: hz2, shape: `교대 쪽 흉벽 지나 90° 갈고리 ${h25} · 맨 위 2단 경사와 나란히`, position: '전면 수평 · 등단면 띠', note: '흉벽 전면 − c까지 (Q-WR1: 맨 위 2단 경사)', basis: '규칙', modified: hMod }); + push({ mark: 'WR14', size: SZ.WR14, role: '수평', spacing: hz2, actual: hz2, shape: `흉벽 + 브라켓 지나 90° 갈고리 ${h25}`, position: '전면 수평 · 변화단면1 (~ 교좌면)', note: '흉벽 전면 − c까지', basis: '규칙', modified: hMod }); + push({ mark: 'WR15', size: SZ.WR15, role: '수평', spacing: hz2, actual: hz2, shape: `벽체 지나 90° 갈고리 ${h25}`, position: '전면 수평 · 변화단면2 (교좌면 아래)', note: '벽체 전면 − c까지', basis: '규칙', modified: hMod }); + push({ mark: 'WR3', size: SZ.WR3, role: '수평', spacing: hz1, actual: hz1, shape: `흉벽 지나 90° 갈고리 ${h25} · 맨 위 2단 경사와 나란히`, position: '배면 수평 · 등단면 띠', note: `교대 쪽 ${levels.back.start}단 · 끝 ${levels.back.end}단`, basis: '규칙', modified: hMod }); + push({ mark: 'WR4', size: SZ.WR4, role: '수평', spacing: hz1, actual: hz1, shape: `흉벽 + 브라켓 지나 90° 갈고리 ${h25}`, position: '배면 수평 · 변화단면1', note: '', basis: '규칙', modified: hMod }); + push({ mark: 'WR5', size: SZ.WR5, role: '수평', spacing: hz1, actual: hz1, shape: `벽체 지나 90° 갈고리 ${h25}`, position: '배면 수평 · 변화단면2', note: '', basis: '규칙', modified: hMod }); + push({ mark: 'WR6', size: SZ.WR6, role: '경사 하단', spacing: '—', actual: '—', shape: `경사 하단과 나란히 · 아래 저판 안 l_d ${ld.H16}`, position: '경사 하단 · 전면 · 배면 각 1', note: '"cycle-2" 삭제 (결정)', basis: '규칙', modified: false }); + push({ mark: 'WR7-1', size: SZ['WR7-1'], role: '헌치', spacing: hz2, actual: hz2, shape: `꺾임 · 날개벽 l_d ${ld.H25} · 헌치 대각 · 흉벽 지나 갈고리`, position: '위 헌치 · 브라켓 상면 ~ 상단', note: '날개벽 다리 WR3–5 안쪽 25.4 (Q-WR7)', basis: '규칙', modified: hMod }); + push({ mark: 'WR7-2', size: SZ['WR7-2'], role: '헌치', spacing: hz2, actual: hz2, shape: '꺾임 · 흉벽 + 브라켓 지나 갈고리', position: '아래 헌치 윗부분 · 교좌면 ~ 브라켓 상면', note: '띠 경계 브라켓 상면 / 교좌면 (Q-WR6)', basis: '규칙', modified: hMod }); + push({ mark: 'WR7-3', size: SZ['WR7-3'], role: '헌치', spacing: hz2, actual: hz2, shape: '꺾임 · 벽체 지나 갈고리', position: '아래 헌치 아랫부분 · 저판 상면 ~ 교좌면', note: 'WR8 결번', basis: '규칙', modified: hMod }); + push({ mark: 'WR9', size: SZ.WR9, role: '헌치', spacing: lenText(2 * sBack), actual: '—', shape: `아래 90° 갈고리 ${h16} (저판 안)`, position: '아래 헌치 대각 위 연직 · 위 끝 브라켓 상면 − c', note: `@${lenText(2 * sBack)} 자동 개수 (사용자 결정 2026-11-04, Q-WR3)`, basis: '규칙', modified: vMod }); + push({ mark: 'WR10', size: SZ.WR10, role: '모서리', spacing: hz1, actual: hz1, shape: `꺾임 · 모서리 대각 300 · 양끝 l_d ${ld.H25}`, position: '벽체 · 흉벽 배면 ↔ 날개벽 배면 모서리 · 저판 상면 ~ 흉벽 상단', note: '헌치 철근과의 간섭은 보지 않음 (Q-WR4)', basis: '규칙', modified: hMod }); + push({ mark: 'WR20', size: SZ.WR20, role: '간격재', spacing: `${lenText(s.k20 * sBack)} × ${lenText(s.k20 * sBackV)}`, actual: '—', shape: `양끝 90° 갈고리 ${hook(SZ.WR20)}`, position: `내측 간격재 · 두께 ${lenText(T - 2 * (c + (d('H16') + d('H25')) / 2))} 걸침`, note: '철근표 H20은 오기 → H13', basis: '규칙', modified: s.k20 !== 8 }); + + return { side: g.side, settings: s, vertical, levels, haunchLevels, ld, paths, bars, issues }; +} diff --git a/tests/abut-rebar-wing.test.ts b/tests/abut-rebar-wing.test.ts new file mode 100644 index 0000000..97d3cf7 --- /dev/null +++ b/tests/abut-rebar-wing.test.ts @@ -0,0 +1,92 @@ +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 { abutWingGeom, abutWingRebar, wingLevelCount } from '../src/core/abut/rebar/wing'; +import { DEFAULT_MATERIAL } from '../src/core/rebar/common'; + +// 예제 A1 좌측 날개벽 (교대날개벽철근.md §11). 기획서 값은 fck 27 · 벽체 높이 2500 기준 (흉벽 상단 46.835 / 47.180). +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 }, { parapetHeightMm: 2500 }); +const wings = buildWings(model, members); +const footing = abutFootingRebar(abutFootingGeom(model.length, members.shape, skewAngle(rec.skewDeg))); +const fck27 = { ...DEFAULT_MATERIAL, fck: 27 }; +const geomOf = (side: 'left' | 'right') => abutWingGeom(model, members, wings.find((w) => w.side === side)!, footing.z.B68); +const left = geomOf('left'); +const L1 = abutWingRebar(left, undefined, fck27, 'A1'); +const bar = (r: typeof L1, mark: string) => r.bars.find((b) => b.mark === mark); + +describe('교대 날개벽 배근 A1 좌 (AD7, 교대날개벽철근.md §11)', () => { + it('geometry: 길이 6500, 뒷굽 끝 1807, 상단 시작 46.835 (저판 상면 위 4840), 일반형', () => { + expect(left.form).toBe('general'); + expect(left.L).toBeCloseTo(6500, 3); + expect(left.heelD).toBeCloseTo(1807, 0); + expect(left.topStart - left.footTop).toBeCloseTo(4840, 0); + }); + + it('삽도 재현 (삽도 = 우측 날개벽): 전면 100 · 193 · 18@250 · 4@250 · 707, 배면 100 · 193 · 36@125 · 9@125 · 622; 연직 WR12 5 + WR11 18, WR2 10 + WR1 36', () => { + const R = abutWingRebar(geomOf('right'), undefined, fck27); + expect(R.vertical.front.text).toBe('100 · 193 · 18@250=4.500 · 4@250=1.000 · 707'); + expect(R.vertical.back.text).toBe('100 · 193 · 36@125=4.500 · 9@125=1.125 · 621.7'); // 삽도 622 (두께 · cot φ 39.7로 반올림) + expect(['WR12', 'WR11', 'WR2', 'WR1'].map((m) => bar(R, m)?.count)).toEqual([5, 18, 10, 36]); + }); + + it('좌측은 skew로 배면이 두께 · cot φ = 39.6 짧음: 배면 기준선 1667.4 → 8@125 · 667.4, WR2 9 (전면은 같음)', () => { + expect(L1.vertical.front.text).toBe('100 · 193 · 18@250=4.500 · 4@250=1.000 · 707'); + expect(L1.vertical.back.text).toBe('100 · 193 · 36@125=4.500 · 8@125=1.000 · 667.4'); + expect(['WR12', 'WR11', 'WR2', 'WR1'].map((m) => bar(L1, m)?.count)).toEqual([5, 18, 9, 36]); + }); + + it('수평 단 (맨 위 2단 경사, 사용자 결정 2026-11-04): 전면 19 (교대 쪽) / 20 (끝), 배면 38 / 39', () => { + expect([L1.levels.front.start, L1.levels.front.end, L1.levels.back.start, L1.levels.back.end]).toEqual([19, 20, 38, 39]); + expect(wingLevelCount(4840, 100, 250)).toBe(19); + }); + + it('WR6 2개, WR7-1 · 7-2 · 7-3 3 / 6 / 10단, WR10 38단', () => { + expect(bar(L1, 'WR6')?.count).toBe(2); + expect(L1.haunchLevels).toEqual({ 'WR7-1': 3, 'WR7-2': 6, 'WR7-3': 10 }); + expect(bar(L1, 'WR10')?.count).toBe(38); + }); + + it('WR9: 아래 헌치 대각 (300 · √2 = 424) 위 @250 → 2개 (자동, Q-WR3)', () => { + expect(bar(L1, 'WR9')?.count).toBe(Math.floor((300 * Math.SQRT2) / 250) + 1); + }); + + it('우측: 상단은 우측 흉벽 상단 47.180 기준 (저판 상면 위 5185)', () => { + const right = geomOf('right'); + expect(right.topStart - right.footTop).toBeCloseTo(5185, 0); + }); + + it('단축형: WR1 · WR11 · WR6 없음 (QA-14)', () => { + const shortW = buildWings(model, members, { left: 'short', right: 'general' }).find((w) => w.side === 'left')!; + const S = abutWingRebar(abutWingGeom(model, members, shortW, footing.z.B68), undefined, fck27); + expect(['WR1', 'WR11', 'WR6'].map((m) => bar(S, m))).toEqual([undefined, undefined, undefined]); + expect(bar(S, 'WR2')!.count).toBeGreaterThan(0); + }); + + it('모든 철근이 날개벽 · 교대 범위 안 (t: 벽체 전면 ~ 날개벽 끝, z: 저판 하면망 위 ~ 상단)', () => { + const tMin = left.p0[1] + left.L * left.w[1] - 50; + for (const p of L1.paths) { + for (const [, t, z] of p.pts) { + expect(t, p.mark).toBeGreaterThan(tMin); + expect(t, p.mark).toBeLessThan(left.wallFrontT + 1); + expect(z, p.mark).toBeLessThan(left.topStart + left.slope * left.L); + expect(z, p.mark).toBeGreaterThan(footing.z.B68 - (p.mark === 'WR6' ? 800 : 400)); + } + } + }); + + it('간격 수정 → RB-AW03', () => { + const r = abutWingRebar(left, { sHoriz: 150 }, fck27); + expect(r.issues.map((i) => i.code)).toContain('RB-AW03'); + expect(bar(r, 'WR3')!.modified).toBe(true); + }); +});