feat(abut-rebar): 교대 흉벽 · 브라켓 배근 규칙 P1–P11 (AD5)

- P1 · P2 · P3 (벽체 연직 격자 −17.5 / −1.6 / −35), P4 · P5 (상단 경사와 나란함, 계단에서 끊음, P5 브라켓 상면 위 · 아래), P6 · P7 (한 칸 배수, 반 칸 엇갈림, 1/sin θ)
- P8 · P9 끝면 ㄷ형, P11 끝면 연직, P10 다웰 (슬래브 측면선 가운데 맞춤, P3 수평 다리 비켜감 Q-P5 → RB-AP06, 모따기 RB-AP05)
- 대표 단면 = 흉벽 높이가 입력값(2500)인 곳 (흉벽 상단선이 받침 ① · 마지막 받침으로 정해져 IP3 끝은 2468)
- D25 규격 추가. 테스트: 교대흉벽철근.md §11 (fck 27)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-11-04 17:01:32 +09:00
1 parent 74de932eb9
commit 25a4a84138
3 files changed
+520

No files matched your search

+376
View File
@@ -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<string, BarSize>;
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>): 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<AbutParapetRebarSettings>, 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<ParapetMark, { u: number; l: number }[]>();
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<AbutParapetBar, 'length' | 'lengthMin' | 'lengthMax' | 'count' | 'splice'>) => {
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,
};
}
+2
View File
@@ -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,
+142
View File
@@ -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);
});
});