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

+10 -47
View File
@@ -5,6 +5,9 @@
import { DEFAULT_MATERIAL, developmentLength, fyOf, hookLength, Material, NOMINAL_DIAMETER } from './common';
import { type CopingGeom, P1_COPING_GEOM } from '../coping-geom';
import { DEFAULT_COPING_REBAR } from './coping-settings';
import { widthLayout } from './coping-main';
import { type StepBar, stepBars, STEP_MIN_MM } from './step-bars';
import type { PierModel } from '../geometry';
export const COPING_REBAR = {
/** 피복 (최외측 철근 중심까지). */
cover: 100,
@@ -138,17 +141,6 @@ export function copingSideBars(L: number, zBottom: number, zBreak: number, g: Co
/** C15 (거꾸로 된 U, 3852) and C16 (L형, 4070 / 4069) lengths, mm (코핑철근.md §7). */
export const STEP_LENGTHS = { C15: 3852, C16: [4070, 4069] as const, C14: 3447.5 } as const;
/**
* Stair zone of the P1 coping (코핑철근.md §7.1): each segment from x to x (mm from the coping start), and its top rise
* above the lowest top (mm). The stair positions are the document's; the model has no stair geometry yet.
*/
export const STAIR_SEGMENTS = [
{ from: 2196, to: 4701, rise: 72 }, // 2구간
{ from: 4701, to: 7207, rise: 144 }, // 3구간
{ from: 7207, to: 9712, rise: 215 }, // 4구간
{ from: 9712, to: 12000, rise: 287 }, // 5구간 (to = L in the document)
] as const;
/** C14 end gap e_14 (코핑철근.md §8.1) and the inset of its ends from the side faces: c + (d_shear + d_14)/2 = 122.25. */
const C14_END_GAP = 500;
export const C14_INSET = COPING_REBAR.cover + (NOMINAL_DIAMETER.H25 + NOMINAL_DIAMETER.H19) / 2;
@@ -212,44 +204,15 @@ export function c14Length(B: number): number {
return B - 2 * C14_INSET + 2 * Math.round(hookLength('main90', NOMINAL_DIAMETER.H19)); // legs rounded to mm (296)
}
export interface StepBar {
/** Position across the width, mm from the centre line. */
n: number;
/** Start and end along the coping (mm). */
x0: number;
x1: number;
/** EL of the horizontal run (m). */
z: number;
/** Legs: C15 hangs at both ends, C16 only at its start. */
legs: 'both' | 'start';
/** Leg length l_d (mm). */
leg: number;
/** Developed length (mm): the horizontal run plus the leg(s) at its ends. */
length: number;
}
/**
* C15 (거꾸로 된 U, on the highest segment) and C16 (L형, on segments 3 and 4), 11 bars each across the width (코핑철근.md §7).
* Across: 137 from the cover line, then @pitch (피복선 기준). pitch = 2 × 주철근 기본간격 (승인 2026-10-26; 250 at P1).
* Along: from each segment start + c to the next end − c.
* C15 · C16 of a T형 coping from the model's own steps (결정 2026-11-10, src/core/rebar/step-bars.ts).
* Across: every second interior main-bar grid line from the first (P1: 137.5 from the cover line, 10@250, 11 bars).
*/
export function stairBars(L: number, B: number, topEl: number, m: Material = DEFAULT_MATERIAL, pitch = 2 * DEFAULT_COPING_REBAR.mainSpacing): { c15: StepBar[]; c16: StepBar[] } {
const ld = stepDevLength(m);
const c = COPING_REBAR.cover;
const ns = Array.from({ length: 11 }, (_, k) => -B / 2 + c + 137 + pitch * k);
const seg = STAIR_SEGMENTS;
const top = (i: number) => topEl + seg[i].rise / 1000;
const c15Z = top(3) - c / 1000;
const c16Z = (i: number) => top(i) - c / 1000 - NOMINAL_DIAMETER.H19 / 1000; // (d_C15 + d_C16)/2 = 19.1
const x0c15 = seg[3].from + c;
const x1c15 = L - c;
const c15: StepBar[] = ns.map((n) => ({ n, x0: x0c15, x1: x1c15, z: c15Z, legs: 'both', leg: ld, length: x1c15 - x0c15 + 2 * ld }));
const c16: StepBar[] = [1, 2].flatMap((i) => {
const x0 = seg[i].from + c;
const x1 = seg[i].to - c + ld; // the horizontal run runs on past the segment end by l_d
return ns.map((n): StepBar => ({ n, x0, x1, z: c16Z(i), legs: 'start', leg: ld, length: x1 - x0 + ld }));
});
return { c15, c16 };
export function tStepBars(model: PierModel, m: Material = DEFAULT_MATERIAL): { bars: StepBar[]; warnings: string[] } {
const B = model.copingB * 1000;
const w = widthLayout(B, model.copingRebar);
const across = Array.from({ length: Math.floor(w.n / 2) + 1 }, (_, k) => (w.yE + w.g + 2 * k * w.spacing - B / 2) / 1000);
return stepBars(model, { cover: COPING_REBAR.cover, ld: stepDevLength(m), d: NOMINAL_DIAMETER.H19, stepMin: STEP_MIN_MM, across });
}
/**
+8 -13
View File
@@ -8,6 +8,7 @@
*/
import type { PierModel } from '../geometry';
import { DEFAULT_MATERIAL, developmentLength, fyOf, hookLength, type Material, NOMINAL_DIAMETER, spliceLength } from './common';
import { stepBars } from './step-bars';
export type CopingPoint = [number, number, number];
export interface CopingBar { name: string; size: string; diameterMm: number; pts: CopingPoint[]; length: number }
@@ -48,7 +49,7 @@ export const MULTI_REBAR = {
standardMm: 8000,
/** C14: vertical pitch, pitch along the axis, stagger between rows. */
c14Vertical: 0.6, c14Along: 1.2, c14Stagger: 0.6,
/** h_16: a step at least this much above the end top gets C15 (m). */
/** h_16: a step at least this much above the row's lowest top gets C15 / C16 (m, step-bars.ts). */
stepMin: 0.1,
} as const;
@@ -283,18 +284,12 @@ export function multicolCopingInfo(model: PierModel, material: Material = DEFAUL
}
});
// --- C15 계단부 (§8): 끝단보다 h_16 이상 높은 계단 전체에 ⊓ 하나 ------------------------------------------
const front = model.segments;
const endTop = Math.min(front[0].top, front.at(-1)!.top);
const high = front.filter(g => g.top - endTop >= MULTI_REBAR.stepMin - 1e-9);
if (high.length) {
const from = high[0].s0 + c, to = high.at(-1)!.s1 - c;
const z15 = Math.min(...high.map(g => g.top)) - c;
const ld = developmentLength(d('H19'), fy('H19'), material.fck) / 1000;
top.filter((_, i) => i % 2 === 0).forEach(y => add('C15', 'H19', [[from, z15 - ld, y], [from, z15, y], [to, z15, y], [to, z15 - ld, y]]));
const above = Math.max(...front.map(g => g.top)) - (z15 + c);
if (above > MULTI_REBAR.stepMin + 1e-9) warnings.push({ code: 'RB-M04', message: `가장 높은 계단 상면이 C15보다 ${Math.round(above * 1000)} mm 높습니다 (h_16 ${MULTI_REBAR.stepMin * 1000} 초과). C16 병행은 미정 (Q-M7).` });
}
// --- C15 · C16 계단부: T형과 같은 규칙 (결정 2026-11-10, step-bars.ts) ---------------------------------------
// 가장 높은 계단마다 ⊓ (C15), 그 밖의 계단(최저 상면 + h_16 이상)마다 L형 (C16). 폭 방향은 상면 주철근 하나 건너.
const stairs = stepBars(model, { cover: c * 1000, ld: Math.round(developmentLength(d('H19'), fy('H19'), material.fck)), d: d('H19'),
stepMin: MULTI_REBAR.stepMin * 1000, across: top.filter((_, i) => i % 2 === 0) });
for (const b of stairs.bars) add(b.name, 'H19', b.pts.map(([x, z]): CopingPoint => [x / 1000, z, b.n]));
for (const message of stairs.warnings) warnings.push({ code: 'RB-S01', message });
// RB-M03: 상면 1단 외 철근이 12 m를 넘으면 이음 미정 (Q-M12).
const long = [...new Set(bars.filter(x => !['C1', 'C4'].includes(x.name) && x.length > 12000).map(x => x.name))];
+112
View File
@@ -0,0 +1,112 @@
/**
* C15 · C16 계단부 철근, T형·다주형 공통 규칙 (결정 2026-11-10, 코핑철근.md §7을 모델 계단으로 일반화). Pure.
*
* - 열마다 (전·후열 단차가 있으면 전열·후열 코핑 쪽 각각, 없으면 전체 폭) 그 열의 계단(model.segments / backSegments)을 본다.
* 상면이 같은 이웃 구간은 하나로 합친다. 경계는 mm로 반올림한다 (도면 치수, 문서 값 재현).
* - 계단 구간 = 그 열의 최저 상면보다 h_16 이상 높은 구간. 없으면 철근 없음.
* - C15 (⊓): 이웃보다 모두 높은 구간마다. z = 상면 − c, 수평 = 구간 시작 + c ~ 구간 끝 − c, 다리 양끝 l_d.
* - C16 (L): 나머지 계단 구간. 연직 다리는 낮은 쪽 끝 (낮은 이웃 쪽, 코핑 끝에 붙어 안쪽이 높으면 코핑 끝 쪽) + c,
* 수평은 높은 쪽으로 구간 끝 − c 에서 l_d 더 (코핑 끝 − c 를 넘지 않음). z = 상면 − c − d_H19.
* - 양쪽 이웃이 모두 높은 계단 구간은 규칙 밖이라 철근을 두지 않고 알린다 (RB-S01).
* - 꺾인 코핑: 좌표가 코핑을 편 g이므로 꺾임점을 지나는 철근은 꺾은 한 개다 (결정 2026-11-10).
* - 다리와 상면 주철근 간섭은 미정으로 둔다 (Q-K10).
*/
import type { PierModel } from '../geometry';
/** h_16 (mm): the least rise above the row's lowest top that gets a step bar (코핑철근.md, Q-K10 유지 2026-11-10). */
export const STEP_MIN_MM = 100;
export type StepRow = '전체' | '전열' | '후열';
export interface StepBar {
name: 'C15' | 'C16';
row: StepRow;
/** Across the width (m, model n). */
n: number;
/** Horizontal run, x0 < x1 (mm from the coping start), and its EL (m). */
x0: number;
x1: number;
z: number;
/** Where the leg(s) hang: both ends (C15), or the start / end of the run (C16). */
legs: 'both' | 'start' | 'end';
/** Leg length l_d (mm). */
leg: number;
/** Developed length (mm): run plus leg(s). */
length: number;
/** Centre line (x mm, EL m) from the first leg end to the last. */
pts: [number, number][];
}
export interface StepOptions {
/** Cover c (mm). */
cover: number;
/** Development length l_d (mm). */
ld: number;
/** H19 diameter (mm): C16 sits this much below its top so it passes under the higher bar. */
d: number;
/** h_16 (mm). */
stepMin: number;
/** Candidate positions across the width (m, model n), e.g. every second main-bar grid line. */
across: number[];
}
interface Step { s0: number; s1: number; top: number }
/** Merge neighbours with the same top (within 1 mm) and round the bounds to mm. */
function merged(segments: { s0: number; s1: number; top: number }[]): Step[] {
const out: Step[] = [];
for (const g of segments) {
const s = { s0: Math.round(g.s0 * 1000), s1: Math.round(g.s1 * 1000), top: g.top };
const last = out[out.length - 1];
if (last && Math.abs(last.top - s.top) < 0.001) last.s1 = s.s1;
else out.push(s);
}
return out;
}
export function stepBars(model: PierModel, opt: StepOptions): { bars: StepBar[]; warnings: string[] } {
const L = Math.round(model.copingL * 1000);
const c = opt.cover;
const half = model.copingB / 2;
const parts = model.copingParts.filter((p) => p.k === 0);
// Rows: with a lateral step the front and back parts (order [front, back]), each to its own width; else the whole width.
const rows: { row: StepRow; segments: Step[]; lo: number; hi: number }[] = parts.length === 2 && model.backSegments
? ([['전열', model.segments, parts[0]], ['후열', model.backSegments, parts[1]]] as const).map(([row, segs, p]) => ({
row,
segments: merged(segs),
// The split line is a step face inside the coping: keep the cover from it as from the side faces.
lo: p.nFrom + (p.nFrom > -half + 1e-9 ? c / 1000 : 0),
hi: p.nTo - (p.nTo < half - 1e-9 ? c / 1000 : 0),
}))
: [{ row: '전체', segments: merged(model.segments), lo: -half, hi: half }];
const bars: StepBar[] = [];
const warnings: string[] = [];
for (const r of rows) {
const ns = opt.across.filter((n) => n >= r.lo - 1e-9 && n <= r.hi + 1e-9);
const zMin = Math.min(...r.segments.map((g) => g.top));
r.segments.forEach((g, i) => {
if ((g.top - zMin) * 1000 < opt.stepMin - 1e-6) return;
const left = r.segments[i - 1], right = r.segments[i + 1];
const leftHigher = !!left && left.top > g.top + 1e-6;
const rightHigher = !!right && right.top > g.top + 1e-6;
const ld = opt.ld;
if (!leftHigher && !rightHigher) {
const x0 = g.s0 + c, x1 = g.s1 - c, z = g.top - c / 1000;
for (const n of ns) bars.push({ name: 'C15', row: r.row, n, x0, x1, z, legs: 'both', leg: ld, length: x1 - x0 + 2 * ld,
pts: [[x0, z - ld / 1000], [x0, z], [x1, z], [x1, z - ld / 1000]] });
} else if (rightHigher && !leftHigher) {
const x0 = g.s0 + c, x1 = Math.min(g.s1 - c + ld, L - c), z = g.top - (c + opt.d) / 1000;
for (const n of ns) bars.push({ name: 'C16', row: r.row, n, x0, x1, z, legs: 'start', leg: ld, length: x1 - x0 + ld,
pts: [[x0, z - ld / 1000], [x0, z], [x1, z]] });
} else if (leftHigher && !rightHigher) {
const x1 = g.s1 - c, x0 = Math.max(g.s0 + c - ld, c), z = g.top - (c + opt.d) / 1000;
for (const n of ns) bars.push({ name: 'C16', row: r.row, n, x0, x1, z, legs: 'end', leg: ld, length: x1 - x0 + ld,
pts: [[x1, z - ld / 1000], [x1, z], [x0, z]] });
} else {
warnings.push(`${r.row === '전체' ? '' : `${r.row} `}계단 ${(g.s0 / 1000).toFixed(3)}~${(g.s1 / 1000).toFixed(3)} m는 양쪽이 더 높아 C15·C16 규칙 밖입니다. 철근을 두지 않았습니다.`);
}
});
}
return { bars, warnings };
}
+16 -7
View File
@@ -4,7 +4,7 @@ import { copingDefaults } from './coping-settings';
import { DEFAULT_COLUMN_REBAR } from './column-settings';
import { DEFAULT_FOOTING_REBAR, type FootingRebarSettings } from './footing-settings';
import { bottomPiece, topBarLength, widthLayout, topSplicePieces } from './coping-main';
import { shearBars, shearBarsMulti, copingSideBars, SIDE_HOOK_LEG, c14Bars, c14Length, stairBars, stepDevLength, undersideAt, shearULength, capLength, PIECES_PER_POSITION } from './coping';
import { shearBars, shearBarsMulti, copingSideBars, SIDE_HOOK_LEG, c14Bars, c14Length, tStepBars, stepDevLength, undersideAt, shearULength, capLength, PIECES_PER_POSITION } from './coping';
import { sideRowLevels } from '../coping-geom';
import { CAST_PENDING } from './cast-in-place';
import { castRebarLayout } from './cast-layout';
@@ -56,7 +56,7 @@ const MULTI_NOTE: Record<string, string> = {
C7: '하면 전 길이, Level 꺾임점 겹침, 양끝 위로 갈고리', C8: '경간마다 오목 하면, 기둥 중심 ± 2230 안쪽',
C9: '전단 · 테이퍼·헌치', C10: '전단 · 기둥 Level', C11: '전단 · 기둥 사이 오목',
C12: '측면 상단 (전 길이), 다리 위치마다', C13: '측면 하단 변화구간 (하면에서 끊김), 다리 위치마다',
C14: '크로스타이 연직 600 · 교각축 1200 엇갈림', C15: '계단부 ⊓ (끝단보다 100 이상 높은 계단 전체)',
C14: '크로스타이 연직 600 · 교각축 1200 엇갈림', C15: '계단부 ⊓ (가장 높은 계단)', C16: '계단부 L형 (그 밖의 계단, 낮은 쪽 다리)',
};
/** Table metadata per bar (코핑철근.md, 확대기초철근 (1).md, 기둥철근 (1).md, 받침블럭철근.md). */
@@ -77,8 +77,8 @@ const META: Record<string, Pick<RebarRow, 'spacing' | 'actual' | 'position' | 'd
C12: { spacing: '250', actual: '250', position: '측면 상단 (끝면까지)', direction: '교각축', shape: '일자 + 좌우 ㄷ형', basis: 'calc' },
C13: { spacing: '250', actual: '250', position: '측면 하단 변화구간 (경사 하면에서 끊김)', direction: '교각축', shape: '일자 + 좌우 ㄷ형', basis: 'calc' },
C14: { spacing: '1000 / 2000', actual: '1000', position: '직각 수평 (위 2줄 · 아래 엇갈림)', direction: '교각축 직각', shape: '⊓ (폭 방향)', basis: 'calc' },
C15: { spacing: '250', actual: '250', position: '계단부 가장 높은 5구간', direction: '교각축', shape: '거꾸로 된 U', basis: 'calc' },
C16: { spacing: '250', actual: '250', position: '계단부 3·4구간', direction: '교각축', shape: 'L형', basis: 'calc' },
C15: { spacing: '250', actual: '250', position: '계단부 가장 높은 구간', direction: '교각축', shape: '거꾸로 된 U', basis: 'calc' },
C16: { spacing: '250', actual: '250', position: '계단부 그 밖의 구간 (최저 상면 + 100 이상)', direction: '교각축', shape: 'L형', basis: 'calc' },
P1: { spacing: '원주 46등분', actual: '—', position: '외측 r 875', direction: '연직', shape: '아래 끝 90° 갈고리, 커플러 이음', basis: 'calc' },
P2: { spacing: '원주 46등분', actual: '—', position: '내측 r 843.2', direction: '연직', shape: '직선, 커플러 이음', basis: 'calc' },
'주철근 커플러': { spacing: '@3000 (P2 +150)', actual: '—', position: 'P1·P2 각 줄', direction: '원통', shape: 'L 150 · Ø60', basis: 'default' },
@@ -298,9 +298,18 @@ export function rebarSummary(
const lineCount = (line: number) => c14.filter((b) => b.line === line).length;
const omitted = c14.filter((b) => !b.legs).length;
rows.push({ member: 'coping', name: 'C14', size: 'H19', count: c14.length, length: c14Length(model.copingB * 1000), note: `위 2 줄 ${lineCount(1)} · ${lineCount(2)}본씩, 아래 ${[3, 4, 5, 6].map(lineCount).join(' · ')} (합 ${c14.length}) · 연직 발 생략 ${omitted}개 (하면 밖)` });
const stairs = stairBars(model.copingL * 1000, model.copingB * 1000, model.copingRefTop, mCoping, 2 * model.copingRebar.mainSpacing);
rows.push({ member: 'coping', name: 'C15', size: 'H19', count: stairs.c15.length, length: stairs.c15[0].length, note: '계단부, 가장 높은 구간 (거꾸로 U)' });
rows.push({ member: 'coping', name: 'C16', size: 'H19', count: stairs.c16.length, length: stairs.c16[0].length, note: `계단부, 3·4구간 (${Math.round(stairs.c16[0].length)} / ${Math.round(stairs.c16[stairs.c16.length - 1].length)})` });
// C15 · C16 from the model's own steps (결정 2026-11-10): none when no step is h_16 above the lowest top.
const steps = tStepBars(model, mCoping);
for (const name of ['C15', 'C16'] as const) {
const bars = steps.bars.filter((b) => b.name === name);
if (!bars.length) continue;
const byLength = new Map<number, number>();
for (const b of bars) byLength.set(Math.round(b.length), (byLength.get(Math.round(b.length)) ?? 0) + 1);
const pieces = [...byLength].map(([length, count]) => ({ length, count }));
rows.push({ member: 'coping', name, size: 'H19', count: bars.length, length: pieces.length === 1 ? pieces[0].length : null, ...(pieces.length > 1 ? { pieces } : {}),
note: `${name === 'C15' ? '계단부, 가장 높은 구간 (거꾸로 U)' : '계단부, 그 밖의 계단 구간 (L형, 낮은 쪽 다리)'} · ${pieces.map((q) => `${q.length} × ${q.count}`).join(' / ')}` });
}
for (const message of steps.warnings) warnings.push({ code: 'RB-S01', target: 'coping', message });
}
// Column (기둥철근.md · plan C4), P1 defaults
+7 -19
View File
@@ -2,7 +2,7 @@ import * as THREE from 'three';
import type { PierModel } from '../core/geometry';
import { assemblyEndOffset, assemblyGridIndices, assemblyJs, b10Levels, b9Bars, gridPositions, grid, layers, legLength, legOffsets, sideDepth } from '../core/rebar/footing';
import { bottomPiece, MAIN, topSplicePieces, widthLayout } from '../core/rebar/coping-main';
import { C14_INSET, c14Bars, CAP, COPING_REBAR, SHEAR_LOOP_STEP, shearBars, shearBarsMulti, shearUHeight, copingSideBars, SIDE_HOOK_LEG, stairBars, undersideAt } from '../core/rebar/coping';
import { C14_INSET, c14Bars, CAP, COPING_REBAR, SHEAR_LOOP_STEP, shearBars, shearBarsMulti, shearUHeight, copingSideBars, SIDE_HOOK_LEG, tStepBars, undersideAt } from '../core/rebar/coping';
import { sideRowLevels } from '../core/coping-geom';
import { columnRebar, columnInputFor } from '../core/rebar/column';
import { castRebarLayout } from '../core/rebar/cast-layout';
@@ -287,25 +287,13 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL,
addBar('C14', 0, [xm, b.z, c14Nb], [xm, b.z - c14Hook, c14Nb], NOMINAL_DIAMETER.H19, COLOR.coping);
}
}
// C15 (거꾸로 된 U) and C16 (L형) on the stair (코핑철근.md §7): 11 bars each across the width.
const stairs = stairBars(L * 1000, B * 1000, topEl, mCoping, 2 * m.copingRebar.mainSpacing);
for (const s of stairs.c15) {
const n = mm(s.n);
const ld = mm(s.leg);
const x0 = mm(s.x0);
const x1 = mm(s.x1);
addBar('C15', 0, [x0, s.z - ld, n], [x0, s.z, n], NOMINAL_DIAMETER.H19, COLOR.coping);
addBar('C15', 0, [x0, s.z, n], [x1, s.z, n], NOMINAL_DIAMETER.H19, COLOR.coping);
addBar('C15', 0, [x1, s.z, n], [x1, s.z - ld, n], NOMINAL_DIAMETER.H19, COLOR.coping);
// C15 (⊓) and C16 (L형) on the model's own steps (결정 2026-11-10, src/core/rebar/step-bars.ts).
for (const b of tStepBars(m, mCoping).bars) {
const n = b.n;
for (let i = 1; i < b.pts.length; i++) {
addBar(b.name, 0, [mm(b.pts[i - 1][0]), b.pts[i - 1][1], n], [mm(b.pts[i][0]), b.pts[i][1], n], NOMINAL_DIAMETER.H19, COLOR.coping);
}
}
for (const s of stairs.c16) {
const n = mm(s.n);
const ld = mm(s.leg);
const x0 = mm(s.x0);
const x1 = mm(s.x1);
addBar('C16', 0, [x0, s.z - ld, n], [x0, s.z, n], NOMINAL_DIAMETER.H19, COLOR.coping);
addBar('C16', 0, [x0, s.z, n], [x1, s.z, n], NOMINAL_DIAMETER.H19, COLOR.coping);
}
}
// Columns: P1 / P2 main bars on their radii, ties P3 / P5 / P4 as rings, P6 crossties across the column.
+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);
});
});