feat(abut-rebar): 교대 날개벽 배근 규칙 WR1–WR20 (AD7)

- 연직 WR1 · 2 · 11 · 12 (기준선 뒷굽 끝 − c, 등단면 올림 δ, 변화단면 끝 여유), 수평 WR3–5 · 13–15 (띠 3개, 맨 위 2단 경사와 나란히 Q-WR1, 교대 쪽 갈고리)
- WR6 경사 하단, WR7-1 · 7-2 · 7-3 헌치 꺾임, WR9 헌치 대각 @2·s_h 자동 개수 (Q-WR3), WR10 모서리 꺾임, WR20 간격재. 간섭은 보지 않음 (Q-WR2 · Q-WR4)
- 단축형: WR1 · WR11 · WR6 없음. 좌우는 ② 형상 그대로 (skew로 배면 길이 ± 두께 · cot φ)
- 테스트: 교대날개벽철근.md §11 (fck 27, 벽체 높이 2500), 삽도 = 우측 날개벽 치수 재현

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-11-04 17:56:48 +09:00
1 parent 3800a75b78
commit 06220fa312
2 files changed
+565

No files matched your search

+473
View File
@@ -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<string, BarSize>;
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>): 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<AbutWingRebarSettings>, 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<WingMark, number[]>();
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<AbutWingBar, 'length' | 'lengthMin' | 'lengthMax' | 'count' | 'splice'>) => {
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 };
}
+92
View File
@@ -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);
});
});