feat(rebar): 계단부 C15·C16을 모델의 실제 계단으로 만든다 (T형·다주형 공통)

T형은 P1 문서의 고정 위치(2.196~12.000 m)·높이·11개를 써서 코핑 길이가 다르면 철근이 코핑 밖으로 나갔고,
다주형은 높은 계단 전체에 C15 하나만 두었다. 공통 규칙 (step-bars.ts):
- 열마다(전·후열 단차가 있으면 각 쪽) 최저 상면 + h_16(100) 이상 구간이 계단, 경계는 mm 반올림
- 이웃보다 모두 높은 구간 C15(⊓), 나머지 계단 C16(L형, 낮은 쪽 다리, 높은 쪽으로 l_d 연장)
- 폭 방향: T형은 주철근 내부 격자 하나 건너, 다주형은 상면 주철근 하나 건너
- 양쪽이 높은 계단은 RB-S01 알림, RB-M04 폐지. 꺾임은 편 좌표의 한 개(꺾은 철근)
P1 문서값(C15 11 × 3852, C16 22 × 4070/4069, 137.5 · 10@250)을 그대로 재현한다.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-11-10 16:53:15 +09:00
1 parent 9f336e2be4
commit 7340f8c1a1
8 files changed
+263 -105

No files matched your search

+22 -18
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { buildPier } from '../src/core/geometry';
import { c14Bars, c14Length, copingSideBars, stairBars, undersideAt } from '../src/core/rebar/coping';
import { c14Bars, c14Length, copingSideBars, tStepBars, undersideAt } from '../src/core/rebar/coping';
import { loadP1 } from './helpers';
const m = buildPier(loadP1());
@@ -32,27 +32,31 @@ describe('C14 / C15 / C16 (코핑철근.md §7–8)', () => {
expect(c14Length(B)).toBeCloseTo(3447.5, 0);
});
it('C15: 11 bars, z 43.598, length 3852 (2088 + 2 × 882)', () => {
const { c15 } = stairBars(L, B, m.copingRefTop);
// P1 문서값 (§7) = 일반 규칙의 기준 (결정 2026-11-10): 모델의 계단 72/144/215/287 → C15 5구간, C16 3·4구간.
const steps = tStepBars(m).bars;
const c15 = steps.filter((b) => b.name === 'C15');
const c16 = steps.filter((b) => b.name === 'C16');
it('C15: 11 bars, z 43.598, length 3852 (2088 + 2 × 882), from 9.812 to 11.900', () => {
expect(c15.length).toBe(11);
expect(c15[0].z).toBeCloseTo(43.598, 3);
expect(c15[0].length).toBeCloseTo(3852, 0);
expect(c15[0].x0).toBeCloseTo(9812, 0);
expect(c15[0].length).toBe(3852);
expect([c15[0].x0, c15[0].x1]).toEqual([9812, 11900]);
});
it('C16: 22 bars (3·4구간), z 43.436 / 43.507, lengths 4070 / 4069', () => {
const { c16 } = stairBars(L, B, m.copingRefTop);
it('C16: 22 bars (3·4구간), z 43.436 / 43.507, lengths 4070 / 4069, leg at the low side', () => {
expect(c16.length).toBe(22);
expect(c16[0].z).toBeCloseTo(43.436, 3);
expect(c16[11].z).toBeCloseTo(43.507, 3);
expect(c16[0].length).toBeCloseTo(4070, 0);
expect(c16[11].length).toBeCloseTo(4069, 0);
expect(c16[0].length).toBe(4070);
expect(c16[11].length).toBe(4069);
expect([c16[0].x0, c16[0].x1, c16[11].x0, c16[11].x1]).toEqual([4801, 7989, 7307, 10494]);
expect(c16.every((b) => b.legs === 'start')).toBe(true);
});
it('C15 / C16 across positions: 137 from the cover line, then @250, 11 bars', () => {
const { c15 } = stairBars(L, B, m.copingRefTop);
expect(c15[0].n).toBeCloseTo(-B / 2 + 237, 0);
expect(c15[10].n - c15[0].n).toBeCloseTo(2500, 0);
it('C15 / C16 across positions: 137.5 from the cover line (문서 137), then @250, 11 bars', () => {
expect(c15[0].n * 1000).toBeCloseTo(-B / 2 + 237.5, 6);
expect((c15[10].n - c15[0].n) * 1000).toBeCloseTo(2500, 6);
});
});
@@ -67,10 +71,10 @@ describe('C14 연직 발 (코핑철근.md §8)', () => {
});
describe('C15 간격 = 2 × 주철근 기본간격 (승인 2026-10-26)', () => {
it('the default pitch is 250, and a larger pitch moves the 11 C15 bars apart', () => {
const { c15 } = stairBars(L, B, m.copingRefTop);
expect(c15[1].n - c15[0].n).toBe(250);
const wider = stairBars(L, B, m.copingRefTop, undefined, 280).c15;
expect(wider[1].n - wider[0].n).toBe(280);
it('the default pitch is 250, and a 140 기본간격 moves the C15 bars to 280', () => {
const base = tStepBars(m).bars.filter((b) => b.name === 'C15');
expect(Math.round((base[1].n - base[0].n) * 1000)).toBe(250);
const wider = tStepBars(buildPier({ ...loadP1(), copingRebar: { mainSpacing: 140 } })).bars.filter((b) => b.name === 'C15');
expect(Math.round((wider[1].n - wider[0].n) * 1000)).toBe(280);
});
});
+2 -1
View File
@@ -57,7 +57,8 @@ describe('③ 철근표 요약 (P1)', () => {
expect(rows.find((r) => r.name === 'C12')!.pieces!.some((p) => p.length === 11746)).toBe(true);
expect(rows.find((r) => r.name === 'C14')!.length).toBe(3447.5);
expect(rows.find((r) => r.name === 'C15')!.length).toBe(3852);
expect(rows.find((r) => r.name === 'C16')!.length).toBe(4070);
// C16 has two lengths (3·4구간), so the row lists them as pieces (2026-11-10).
expect(rows.find((r) => r.name === 'C16')!.pieces).toEqual([{ length: 4070, count: 11 }, { length: 4069, count: 11 }]);
expect(rows.find((r) => r.name === 'P5')!.length).toBeCloseTo(5657.7, 0);
expect(rows.find((r) => r.name === 'P3')!.length!).toBeCloseTo(5657.7 + 1146.6, 0);
});
+86
View File
@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest';
import { buildPier } from '../src/core/geometry';
import { tStepBars } from '../src/core/rebar/coping';
import { multicolCopingInfo } from '../src/core/rebar/multicol-coping';
import type { PierInput } from '../src/core/types';
import { loadP1 } from './helpers';
// 계단부 C15 · C16 일반 규칙 (결정 2026-11-10, src/core/rebar/step-bars.ts). P1 문서값은 rebar-stairs.test.ts.
/** P1 with the 교좌면 EL (and z with it) of bearings 1–5 / 6–10 set to `seats` (m), both rows alike. */
function withSeats(seats: number[]): PierInput {
const p1 = loadP1();
return {
...p1,
bearings: p1.bearings.map((b) => {
const s = seats[(b.no - 1) % 5];
return { ...b, z: b.z - b.seatEl! + s, seatEl: s };
}),
};
}
const SEAT = [43.411, 43.483, 43.555, 43.626, 43.698];
describe('계단부 철근 일반 규칙', () => {
it('steps that fall towards the end: C15 on the first step (leg at the coping start + c), C16 legs at the low (end) side', () => {
const m = buildPier(withSeats([...SEAT].reverse()));
const bars = tStepBars(m).bars;
const c15 = bars.filter((b) => b.name === 'C15');
const c16 = bars.filter((b) => b.name === 'C16');
expect(c15.length).toBe(11);
expect(c15[0].x0).toBe(100);
expect(c16.length).toBe(22);
expect(c16.every((b) => b.legs === 'end')).toBe(true);
// Mirror of P1: the run reaches l_d into the higher (start-side) step.
expect(c16.map((b) => b.length).sort()).toEqual([...Array(11).fill(4069), ...Array(11).fill(4070)].sort());
});
it('every bar stays inside the coping length (the P1 positions no longer leak past a shorter coping)', () => {
const m = buildPier({ ...withSeats([...SEAT].reverse()), copingLengthMm: 12000 });
const L = m.copingL * 1000;
for (const b of tStepBars(m).bars) {
expect(b.x0).toBeGreaterThanOrEqual(100);
expect(b.x1).toBeLessThanOrEqual(L - 100);
}
});
it('no step bar when no step is h_16 (100 mm) above the lowest top', () => {
const m = buildPier(withSeats([43.411, 43.45, 43.48, 43.5, 43.51]));
expect(tStepBars(m).bars).toEqual([]);
});
it('a step with both neighbours higher gets no bar and a warning (RB-S01 규칙 밖)', () => {
const m = buildPier(withSeats([43.698, 43.561, 43.711, 43.411, 43.411]));
const { bars, warnings } = tStepBars(m);
expect(warnings).toHaveLength(1);
expect(warnings[0]).toContain('양쪽이 더 높아');
// The two peaks each get C15; the valley step none.
expect(bars.filter((b) => b.name === 'C15').length).toBe(22);
expect(bars.filter((b) => b.name === 'C16')).toEqual([]);
});
it('with a lateral step, front and back rows are separate and stay in their own part of the width', () => {
const p1 = loadP1();
const bearings = p1.bearings.map((b) => (b.row === '후열' && b.no === 7 ? { ...b, z: b.z + 0.3, seatEl: b.seatEl! + 0.3 } : b));
const m = buildPier({ ...p1, bearings });
const [front, back] = m.copingParts.filter((p) => p.k === 0);
const bars = tStepBars(m).bars;
expect(new Set(bars.map((b) => b.row))).toEqual(new Set(['전열', '후열']));
for (const b of bars) {
const part = b.row === '전열' ? front : back;
expect(b.n).toBeGreaterThanOrEqual(part.nFrom - 1e-9);
expect(b.n).toBeLessThanOrEqual(part.nTo + 1e-9);
}
// The raised back segment 2 (⑦) is a peak: C15 there, on the back row only.
const backC15 = bars.filter((b) => b.row === '후열' && b.name === 'C15');
expect(new Set(backC15.map((b) => b.x0))).toEqual(new Set([2296, 9812]));
});
it('multi-column copings use the same rule: a peak gets C15, a lower step C16 (no more RB-M04)', () => {
const m = buildPier({ ...withSeats(SEAT), type: '2주형', columnCount: 2, foundationType: '말뚝기초', groundEl: 30, pileLengthMm: 24000 });
const info = multicolCopingInfo(m);
const names = info.bars.map((b) => b.name);
expect(names).toContain('C15');
expect(names).toContain('C16');
expect(info.warnings.some((w) => w.code === 'RB-M04')).toBe(false);
});
});