From 7340f8c1a19a73410cf6593bc3d81dc78bc0c0ef Mon Sep 17 00:00:00 2001 From: ypshim Date: Tue, 10 Nov 2026 16:53:15 +0900 Subject: [PATCH] =?UTF-8?q?feat(rebar):=20=EA=B3=84=EB=8B=A8=EB=B6=80=20C1?= =?UTF-8?q?5=C2=B7C16=EC=9D=84=20=EB=AA=A8=EB=8D=B8=EC=9D=98=20=EC=8B=A4?= =?UTF-8?q?=EC=A0=9C=20=EA=B3=84=EB=8B=A8=EC=9C=BC=EB=A1=9C=20=EB=A7=8C?= =?UTF-8?q?=EB=93=A0=EB=8B=A4=20(T=ED=98=95=C2=B7=EB=8B=A4=EC=A3=BC?= =?UTF-8?q?=ED=98=95=20=EA=B3=B5=ED=86=B5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/core/rebar/coping.ts | 57 +++------------ src/core/rebar/multicol-coping.ts | 21 +++--- src/core/rebar/step-bars.ts | 112 ++++++++++++++++++++++++++++++ src/core/rebar/summary.ts | 23 ++++-- src/view/rebar3d.ts | 26 ++----- tests/rebar-stairs.test.ts | 40 ++++++----- tests/rebar-summary.test.ts | 3 +- tests/step-bars.test.ts | 86 +++++++++++++++++++++++ 8 files changed, 263 insertions(+), 105 deletions(-) create mode 100644 src/core/rebar/step-bars.ts create mode 100644 tests/step-bars.test.ts diff --git a/src/core/rebar/coping.ts b/src/core/rebar/coping.ts index 5fb9bf0..20e2727 100644 --- a/src/core/rebar/coping.ts +++ b/src/core/rebar/coping.ts @@ -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 }); } /** diff --git a/src/core/rebar/multicol-coping.ts b/src/core/rebar/multicol-coping.ts index cb7c240..46663e7 100644 --- a/src/core/rebar/multicol-coping.ts +++ b/src/core/rebar/multicol-coping.ts @@ -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))]; diff --git a/src/core/rebar/step-bars.ts b/src/core/rebar/step-bars.ts new file mode 100644 index 0000000..d97d7b7 --- /dev/null +++ b/src/core/rebar/step-bars.ts @@ -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 }; +} diff --git a/src/core/rebar/summary.ts b/src/core/rebar/summary.ts index d5f236b..24a0524 100644 --- a/src/core/rebar/summary.ts +++ b/src/core/rebar/summary.ts @@ -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 = { 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 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(); + 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 diff --git a/src/view/rebar3d.ts b/src/view/rebar3d.ts index 64a3f93..dec36ba 100644 --- a/src/view/rebar3d.ts +++ b/src/view/rebar3d.ts @@ -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. diff --git a/tests/rebar-stairs.test.ts b/tests/rebar-stairs.test.ts index 9e67573..71936e4 100644 --- a/tests/rebar-stairs.test.ts +++ b/tests/rebar-stairs.test.ts @@ -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); }); }); diff --git a/tests/rebar-summary.test.ts b/tests/rebar-summary.test.ts index a324cc0..32ae982 100644 --- a/tests/rebar-summary.test.ts +++ b/tests/rebar-summary.test.ts @@ -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); }); diff --git a/tests/step-bars.test.ts b/tests/step-bars.test.ts new file mode 100644 index 0000000..3acf65c --- /dev/null +++ b/tests/step-bars.test.ts @@ -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); + }); +});