diff --git a/src/core/geometry.ts b/src/core/geometry.ts index 18f8e0d..91b6278 100644 --- a/src/core/geometry.ts +++ b/src/core/geometry.ts @@ -350,7 +350,7 @@ export function buildPier(input: PierInput, ctx: PierContext = {}): PierModel { const lateralStep = stepSamples.some(s => Math.abs(segmentTop(segments,s)-segmentTop(backSegments,s)) >= DIM.lateralTolerance); // Columns (spec §4.5). Positions are coping coordinates; a column must stay a Level half-width away from the kink. - const colG = columnPositions(input.type, L); + const colG = columnPositions(input.type, L, input.columnEndOffsetMm, input.columnSpacingMm); if (kinkG !== null) { const near = nearKink(axis, colG.map((g) => sStart + g), levelW / 2); if (near.length) throw new Error('미실시: 꺾임점 근처(Level 반폭 이내)에 기둥이 있습니다. 기둥 위치를 확인하세요.'); @@ -551,8 +551,18 @@ export function buildPier(input: PierInput, ctx: PierContext = {}): PierModel { }; } -export function columnPositions(type: PierInput['type'], L: number): number[] { +/** + * Column centres along the coping (m). spec §4.5: L/5 · (L/2) · 4L/5. For 2·3주형 the 끝 여유 e_c and 간격 s_c may be typed + * (Q-M10 미결, 3주형형상배근.md §2.1): e_c + k·s_c, s_c defaulting to the even spacing between e_c and L − e_c. + */ +export function columnPositions(type: PierInput['type'], L: number, endOffsetMm?: number, spacingMm?: number): number[] { if (type === 'T형') return [L / 2]; + const n = type === '2주형' ? 2 : 3; + if (endOffsetMm !== undefined) { + const e = endOffsetMm / 1000; + const step = spacingMm !== undefined ? spacingMm / 1000 : (L - 2 * e) / (n - 1); + return Array.from({ length: n }, (_, k) => e + k * step); + } if (type === '2주형') return [L / 5, L - L / 5]; return [L / 5, L / 2, L - L / 5]; } diff --git a/src/core/rebar/coping-settings.ts b/src/core/rebar/coping-settings.ts index 90210b3..2ab84f6 100644 --- a/src/core/rebar/coping-settings.ts +++ b/src/core/rebar/coping-settings.ts @@ -46,7 +46,8 @@ export const DEFAULT_COPING_REBAR: CopingRebarSettings = { /** 1106 ‘2주형 3주형’ 전용 삽도 우선(사용자 선택). 명시 입력은 이 기본값보다 우선한다. */ export function copingDefaults(columnCount: number): CopingRebarSettings { - return columnCount > 1 ? { ...DEFAULT_COPING_REBAR, spacingS: 300, spacingL: 300, + // 다주형 (3주형형상배근.md §1.2): 피복 120 (Q-M6), 전단 @300 · 다리 8개 (Q-M3), 측면 @200, 크로스타이 연직 600. + return columnCount > 1 ? { ...DEFAULT_COPING_REBAR, cover: 120, shearLegs: 8, spacingS: 300, spacingL: 300, sideSpacing: 200, c14UpperSpacing: 600, c14LowerSpacing: 600 } : { ...DEFAULT_COPING_REBAR }; } diff --git a/src/core/rebar/multicol-coping.ts b/src/core/rebar/multicol-coping.ts index 1172872..cb7c240 100644 --- a/src/core/rebar/multicol-coping.ts +++ b/src/core/rebar/multicol-coping.ts @@ -1,8 +1,13 @@ +/** + * 다주형(2·3주형) 코핑 철근 — RAG/3주형형상배근.md §4–§8 (2026-11-06, 2주형 DWG 실측 규칙, 번호 = 역할 Q-M1). + * Pure. One physical piece per entry; 3D, 삽도, 철근표, 재료표 share these pieces. T형 keeps its own generator. + * + * Coordinates: x along the coping from BP1 (m), EL (m), y across the width from the axis (m, the scene's third axis). + * Rules still open (Q-M2 밀집 구간, Q-M3 다리 8개, Q-M4 3주형 확장, Q-M5 측면 형상, Q-M6 피복, Q-M7 계단부, Q-M12 이음) + * use the document's current assumptions and are flagged in the summary. + */ import type { PierModel } from '../geometry'; -import { sideRowLevels } from '../coping-geom'; -import { DEFAULT_MATERIAL, fyOf, hookLength, type Material, NOMINAL_DIAMETER, spliceLength } from './common'; -import { widthLayout } from './coping-main'; -import { C14_INSET, SIDE_HOOK_LEG, stairBars } from './coping'; +import { DEFAULT_MATERIAL, developmentLength, fyOf, hookLength, type Material, NOMINAL_DIAMETER, spliceLength } from './common'; export type CopingPoint = [number, number, number]; export interface CopingBar { name: string; size: string; diameterMm: number; pts: CopingPoint[]; length: number } @@ -29,110 +34,275 @@ export function copingPathLength(pts: CopingPoint[]): number { return pts.slice(1).reduce((sum, p, i) => sum + Math.hypot(...p.map((v, j) => v - pts[i][j])), 0) * 1000; } -/** Shared physical pieces for multi-column 3D and quantities. T-shaped legacy layout is unchanged. */ -export function multicolCopingBars(model: PierModel, material: Material = DEFAULT_MATERIAL): CopingBar[] { +/** Document values that are not screen inputs yet (3주형형상배근.md §1.2, m unless noted). */ +export const MULTI_REBAR = { + /** 밀집 간격 s_d (mm) and counts: outer (end side) / inner (span side) of a column zone (Q-M2). */ + denseSpacingMm: 150, denseOuter: 14, denseInner: 16, + /** Column zone around the centre: outer columns a_o … a_i (span side +), middle columns ± a_m (Q-M2). */ + zoneOuter: 1.15, zoneInner: 1.25, zoneMiddle: 1.2, + /** Gap between the two leg groups (Q-M3). */ + legGroupGap: 0.25, + /** 상면 2단 끊는 위치 l_cut and C8 끝 l_8 from the column centre. */ + topCut: 4.45, c8End: 2.23, + /** 정척 for the 상면 1단 이음 (Q-M9, mm). */ + 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). */ + stepMin: 0.1, +} as const; + +export interface MulticolInfo { bars: CopingBar[]; warnings: { code: string; message: string }[] } + +/** + * Shear positions along the coping (§4.1): end, dense, column zone, dense, span … (sorted, m). Positions outside the cover + * lines are dropped; `short` lists spans where the dense zones meet or overlap (RB-M01) and `tight` spans whose equal bay + * is below s_d (RB-M02). + */ +export function multicolShearLayout(L: number, columns: number[], c: number, s: number, sd: number): { positions: number[]; short: number[]; tight: number[] } { + const xs = [...columns].sort((a, b) => a - b); + const n = xs.length; + const out: number[] = []; + const run = (from: number, to: number, count: number) => { for (let j = 0; j <= count; j++) out.push(from + (to - from) * j / count); }; + const groups = xs.map((x, k) => { + const zone: [number, number] = n === 1 ? [x - MULTI_REBAR.zoneMiddle, x + MULTI_REBAR.zoneMiddle] + : k === 0 ? [x - MULTI_REBAR.zoneOuter, x + MULTI_REBAR.zoneInner] + : k === n - 1 ? [x - MULTI_REBAR.zoneInner, x + MULTI_REBAR.zoneOuter] + : [x - MULTI_REBAR.zoneMiddle, x + MULTI_REBAR.zoneMiddle]; + const left = k === 0 ? MULTI_REBAR.denseOuter : MULTI_REBAR.denseInner; + const right = k === n - 1 ? MULTI_REBAR.denseOuter : MULTI_REBAR.denseInner; + const start = zone[0] - left * sd, end = zone[1] + right * sd; + run(start, zone[0], left); + run(zone[0], zone[1], Math.max(1, Math.round((zone[1] - zone[0]) / s))); + run(zone[1], end, right); + return [start, end] as const; + }); + // Ends: the bar at the cover line, then @s from the dense start; the remainder is the first space (§4.1). + out.push(c, L - c); + for (let x = groups[0][0] - s; x > c + 1e-6; x -= s) out.push(x); + for (let x = groups[n - 1][1] + s; x < L - c - 1e-6; x += s) out.push(x); + // Spans between dense zones: m = ceil(l / s) equal bays. + const short: number[] = [], tight: number[] = []; + for (let k = 1; k < n; k++) { + const a = groups[k - 1][1], b = groups[k][0]; + if (b - a < 1e-6) { short.push(k); continue; } + const m = Math.ceil((b - a) / s - 1e-9); + if ((b - a) / m < sd - 1e-9) tight.push(k); + for (let j = 1; j < m; j++) out.push(a + (b - a) * j / m); + } + if (groups[0][0] < c - 1e-9 || groups[n - 1][1] > L - c + 1e-9) short.push(0); + const inside = out.filter(x => x >= c - 1e-9 && x <= L - c + 1e-9); + return { positions: [...new Map(inside.map(x => [Math.round(x * 1e6), x])).values()].sort((a, b) => a - b), short, tight }; +} + +/** Shear positions only (see multicolShearLayout). */ +export function multicolShearPositions(L: number, columns: number[], c: number, s: number, sd: number): number[] { + return multicolShearLayout(L, columns, c, s, sd).positions; +} + +/** Leg positions across the width (§4.3): two groups of four, 250 apart; otherwise equal spacing. Closures by index. */ +export function multicolLegs(B: number, c: number, legs: number): { y: number[]; closures: [number, number][] } { + if (legs === 8) { + const p = (B - 2 * c - MULTI_REBAR.legGroupGap) / 6; + const y = [0, 1, 2, 3].map(i => -B / 2 + c + i * p); + y.push(...[0, 1, 2, 3].map(i => -B / 2 + c + 3 * p + MULTI_REBAR.legGroupGap + i * p)); + return { y, closures: [[0, 2], [1, 4], [3, 6], [5, 7]] }; + } + const pitch = (B - 2 * c) / (legs - 1); + const y = Array.from({ length: legs }, (_, i) => -B / 2 + c + i * pitch); + return { y, closures: Array.from({ length: legs / 2 }, (_, i) => [i, i + legs / 2] as [number, number]) }; +} + +/** Bars across the width: the edge bars at the cover line and `spaces` bays of `pitch` between, leftover split (§5.1). */ +function widthBars(B: number, c: number, pitch: number): number[] { + const spaces = Math.floor((B - 2 * c) / pitch + 1e-9) - 2; + const g = (B - 2 * c - spaces * pitch) / 2; + return [-B / 2 + c, ...Array.from({ length: spaces + 1 }, (_, i) => -B / 2 + c + g + i * pitch), B / 2 - c]; +} + +/** Shared physical pieces for multi-column 3D and quantities, with the RB-M warnings of §11. */ +export function multicolCopingInfo(model: PierModel, material: Material = DEFAULT_MATERIAL): MulticolInfo { const bars: CopingBar[] = []; - const L = model.copingL, B = model.copingB, top = model.copingRefTop; + const warnings: MulticolInfo['warnings'] = []; + const L = model.copingL, B = model.copingB; const settings = model.copingRebar; - if ([settings.spacingS, settings.spacingL, settings.sideSpacing, settings.c14UpperSpacing, settings.c14LowerSpacing].some(v => !Number.isFinite(v) || v <= 0) + if ([settings.spacingS, settings.sideSpacing, settings.mainSpacing, settings.mainLayerGap, settings.cover].some(v => !Number.isFinite(v) || v <= 0) || !Number.isInteger(settings.shearLegs) || settings.shearLegs < 2 || settings.shearLegs > 8 || settings.shearLegs % 2) { throw new Error('다주형 배근 간격은 양수, 전단 다리 수는 2~8의 짝수여야 합니다.'); } + const c = settings.cover / 1000, s = settings.spacingS / 1000, sd = MULTI_REBAR.denseSpacingMm / 1000; const envelope = copingEnvelope(model); const add = (name: string, size: keyof typeof NOMINAL_DIAMETER, pts: CopingPoint[]) => { if (pts.length < 2) return; bars.push({ name, size, diameterMm: NOMINAL_DIAMETER[size], pts, length: copingPathLength(pts) }); }; - const width = widthLayout(B * 1000, settings); - const ns = [width.yE, ...Array.from({ length: width.n + 1 }, (_, i) => width.yE + width.g + width.spacing * i), B * 1000 - width.yE].map(v => v / 1000 - B / 2); - // C5/C6 are excluded from the multi-column layout: the 1106 전용 삽도 (A92 · A61) shows no C5/C6 (대안A, 2026-11-06). - // Cycle-specific splice locations remain explicitly pending. - for (let k = 1; k <= 3; k++) for (const [j, n] of ns.entries()) { - if (j % 2 === 0 && k > 1) continue; - const name = `C${k + (j % 2 ? 0 : 3)}`; - const size = Number(name.slice(1)) <= 4 ? 'H25' : 'H29'; - const d = NOMINAL_DIAMETER[size]; - const a = settings.cover / 1000 + (k - 1) * d / 1000, b = L - a; - const z = top - 0.127 - (k - 1) * settings.mainLayerGap / 1000; - const hook = hookLength('main90', d) / 1000; - const total = (b - a + 2 * hook) * 1000; - const lap = spliceLength(d, fyOf(material, size), material.fck) / 1000; - if (model.topSplice.mode === 'apply' && total > 12000) { - const split = model.topSplice.at === 'center' ? L / 2 : b + hook + lap / 2 - 12; - add(name, size, [[a, z - hook, n], [a, z, n], [split + lap / 2, z, n]]); - add(name, size, [[split - lap / 2, z, n], [b, z, n], [b, z - hook, n]]); - } else add(name, size, [[a, z - hook, n], [a, z, n], [b, z, n], [b, z - hook, n]]); + const columns = model.columns.map(col => col.s).sort((a, b) => a - b); + const lw = model.copingGeom.levelWidth / 1000; + const levelBottom = model.levelBottom; + const hr = Math.max(0, ...envelope.xs.map(x => envelope.under(x) - levelBottom).filter(v => v < (model.endBottom - levelBottom) - 1e-6)); + const steps = [...model.segments, ...(model.backSegments ?? [])]; + const stepTop = (x: number) => Math.min(...steps.filter(g => x >= g.s0 - 1e-9 && x <= g.s1 + 1e-9).map(g => g.top)); + const d = (size: keyof typeof NOMINAL_DIAMETER) => NOMINAL_DIAMETER[size]; + const fy = (size: string) => fyOf(material, size); + + // --- 전단 C9 · C10 · C11 (§4) ------------------------------------------------------------------- + const legs = multicolLegs(B, c, settings.shearLegs); + const ds = d('H22') / 1000; + const shearName = (x: number) => { + const rel = envelope.under(x) - levelBottom; + if (Math.abs(rel) < 1e-6) return 'C10'; + if (hr > 0 && Math.abs(rel - hr) < 1e-6) return 'C11'; + return 'C9'; + }; + const layout = multicolShearLayout(L, columns, c, s, sd); + if (layout.short.length) warnings.push({ code: 'RB-M01', message: `코핑이 짧아 기둥 옆 밀집 구간(바깥 ${MULTI_REBAR.denseOuter} · 안쪽 ${MULTI_REBAR.denseInner}칸 @${MULTI_REBAR.denseSpacingMm})이 ${layout.short.includes(0) ? '코핑 끝을 넘거나 ' : ''}서로 겹칩니다. 겹친 위치를 합쳐 표시했습니다 — 3주형형상배근.md는 오류로 정합니다 (Q-M2 밀집 구간 규칙 확인).` }); + if (layout.tight.length) warnings.push({ code: 'RB-M02', message: `기둥 사이 등분 간격이 밀집 간격 ${MULTI_REBAR.denseSpacingMm}보다 작습니다 (경간 ${layout.tight.join(', ')}).` }); + for (const x of layout.positions) { + const name = shearName(x); + // The closures (U + cap each) sit side by side, centred on the position so the end-line bars stay inside the coping. + const span = (legs.closures.length * 2 - 1) * ds; + legs.closures.forEach(([i, j], q) => { + const xu = x - span / 2 + q * 2 * ds, xc = xu + ds; + const yTop = stepTop(x) - c; + const bottom = envelope.inset(xu, c); + const capBottom = envelope.inset(xc, c) + 0.1; + add(name, 'H22', [[xu, yTop, legs.y[i]], [xu, bottom, legs.y[i]], [xu, bottom, legs.y[j]], [xu, yTop, legs.y[j]]]); + add(name, 'H22', [[xc, capBottom, legs.y[i]], [xc, yTop, legs.y[i]], [xc, yTop, legs.y[j]], [xc, capBottom, legs.y[j]]]); + }); } - for (const [j, n] of ns.entries()) { - const name = j % 2 ? 'C7' : 'C8', size = j % 2 ? 'H29' : 'H25'; - const lap = spliceLength(NOMINAL_DIAMETER[size], fyOf(material, size), material.fck) / 2000; - const a = settings.cover / 1000, b = L - a; - const z = (x: number) => envelope.inset(x, (settings.cover + width.yE - 100) / 1000); - const path = (from: number, to: number): CopingPoint[] => [from, ...envelope.xs.filter(x => x > from && x < to), to].map(x => [x, z(x), n]); - add(name, size, [[a, top - 0.127, n], ...path(a, L / 2 + lap)]); - add(name, size, [...path(L / 2 - lap, b), [b, top - 0.127, n]]); + + // --- 상면 C1 · C4 (1단, 8 m 이음) · C3 · C2 · C3-1 · C2-1 (2단) (§5.2) ------------------------------- + const top = widthBars(B, c, settings.mainSpacing / 1000); + const z1 = model.copingRefTop - c, z2 = z1 - settings.mainLayerGap / 1000; + const h25 = hookLength('main90', d('H25')) / 1000; + const ls25 = spliceLength(d('H25'), fy('H25'), material.fck) / 1000; + const a = c, b = L - c; + // Smallest n with every piece ≤ 8 m (hooks included), joints at the n-section points ∓ l_s (C1 −, C4 +). + const pieces = (shift: number, hooks: boolean) => { + for (let n = 1; n < 100; n++) { + const cuts = Array.from({ length: n - 1 }, (_, j) => a + (b - a) * (j + 1) / n + shift); + const ends = [a, ...cuts, b]; + const spans = ends.slice(1).map((e, i) => [i === 0 ? ends[0] : ends[i] - ls25 / 2, i === n - 1 ? e : e + ls25 / 2] as [number, number]); + const lengths = spans.map(([p, q], i) => (q - p) + (hooks && i === 0 ? h25 : 0) + (hooks && i === n - 1 ? h25 : 0)); + if (lengths.every(l => l <= MULTI_REBAR.standardMm / 1000 + 1e-9)) return { spans, cuts }; + } + throw new Error('상면 1단 이음 조각을 정할 수 없습니다.'); + }; + const c1 = pieces(-ls25, true), c4 = pieces(ls25, false); + top.forEach((y, i) => { + const cycle1 = i % 2 === 0; + const plan = cycle1 ? c1 : c4; + plan.spans.forEach(([p, q], k) => { + const pts: CopingPoint[] = [[p, z1, y], [q, z1, y]]; + if (cycle1 && k === 0) pts.unshift([p, z1 - h25, y]); + if (cycle1 && k === plan.spans.length - 1) pts.push([q, z1 - h25, y]); + add(cycle1 ? 'C1' : 'C4', 'H25', pts); + }); + // 2단: 끝단 쪽은 기둥 중심 + l_cut까지 (C3 끝 갈고리 / C2 직선), 가운데 기둥 위는 ± l_cut (C3-1 / C2-1). + const xFirst = columns[0], xLast = columns.at(-1)!; + const outerName = cycle1 ? 'C3' : 'C2', midName = cycle1 ? 'C3-1' : 'C2-1'; + add(outerName, 'H25', [...(cycle1 ? [[a, z2 - h25, y] as CopingPoint] : []), [a, z2, y], [xFirst + MULTI_REBAR.topCut, z2, y]]); + add(outerName, 'H25', [[xLast - MULTI_REBAR.topCut, z2, y], [b, z2, y], ...(cycle1 ? [[b, z2 - h25, y] as CopingPoint] : [])]); + for (const x of columns.slice(1, -1)) add(midName, 'H25', [[x - MULTI_REBAR.topCut, z2, y], [x + MULTI_REBAR.topCut, z2, y]]); + }); + // RB-M05: 상면 1단 이음 중심이 기둥 구간 안 (정보). + const zoneOf = (x: number) => columns.some((col, k) => { + const n = columns.length; + const [lo, hi] = k === 0 ? [col - MULTI_REBAR.zoneOuter, col + MULTI_REBAR.zoneInner] : k === n - 1 ? [col - MULTI_REBAR.zoneInner, col + MULTI_REBAR.zoneOuter] : [col - MULTI_REBAR.zoneMiddle, col + MULTI_REBAR.zoneMiddle]; + return x >= lo - 1e-9 && x <= hi + 1e-9; + }); + const inZone = [...c1.cuts.map(x => ['C1', x] as const), ...c4.cuts.map(x => ['C4', x] as const)].filter(([, x]) => zoneOf(x)); + if (inZone.length) warnings.push({ code: 'RB-M05', message: `상면 1단 8 m 이음 중심 ${inZone.length}곳이 기둥 구간 안입니다 (${inZone.map(([nm, x]) => `${nm} ${x.toFixed(3)}`).join(' · ')}). 정보.` }); + + // --- 하면 C7 (전 길이, Level 꺾임점 겹침) · C8 (경간마다) (§5.3–5.4) --------------------------------- + const bottomBars = widthBars(B, c, 2 * settings.mainSpacing / 1000); + const h29 = hookLength('main90', d('H29')) / 1000; + const ls29 = spliceLength(d('H29'), fy('H29'), material.fck) / 1000; + const path = (from: number, to: number, y: number): CopingPoint[] => + [from, ...envelope.xs.filter(x => x > from + 1e-9 && x < to - 1e-9), to].map(x => [x, envelope.inset(x, c), y]); + // The lap runs l_s/2 along the bar on each side of a Level kink (not l_s/2 horizontally, which is longer on a haunch). + const walk = (x0: number, dist: number, dir: 1 | -1) => { + const stops = [...envelope.xs.filter(x => dir > 0 ? x > x0 + 1e-9 : x < x0 - 1e-9).sort((p, q) => dir * (p - q)), dir > 0 ? b : a]; + let x = x0, left = dist; + for (const next of stops) { + const seg = Math.hypot(next - x, envelope.under(next) - envelope.under(x)); + if (seg >= left - 1e-12) return x + (next - x) * left / seg; + left -= seg; x = next; + } + return x; + }; + const kinks = columns.flatMap(x => [x - lw / 2, x + lw / 2]).slice(1, -1); + const c7Ends = [a, ...kinks, b]; + for (const y of bottomBars) { + for (let k = 1; k < c7Ends.length; k++) { + const from = k === 1 ? a : walk(c7Ends[k - 1], ls29 / 2, -1), to = k === c7Ends.length - 1 ? b : walk(c7Ends[k], ls29 / 2, 1); + const pts = path(from, to, y); + if (k === 1) pts.unshift([a, pts[0][1] + h29, y]); + if (k === c7Ends.length - 1) pts.push([b, pts.at(-1)![1] + h29, y]); + add('C7', 'H29', pts); + } } - // Plan groups retain their functional IDs; the drawing also uses C13 for a shear loop. - const columns = model.columns.map(c => c.s).sort((a, b) => a - b); - const half = model.copingGeom.levelWidth / 2000; - const kMax = Math.ceil(half * 1000 / settings.spacingL); - const groups = columns.map(c => Array.from({ length: 2 * kMax + 1 }, (_, i) => c + (i - kMax) * settings.spacingL / 1000)); - const positions: { name: string; x: number }[] = groups.flatMap(g => g.map(x => ({ name: 'C10', x }))); - const gap = (settings.cover + settings.firstShearGap) / 1000; - for (let x = groups[0][0] - settings.spacingS / 1000; x >= gap - 1e-9; x -= settings.spacingS / 1000) positions.push({ name: 'C9', x }); - for (let x = groups.at(-1)!.at(-1)! + settings.spacingS / 1000; x <= L - gap + 1e-9; x += settings.spacingS / 1000) positions.push({ name: 'C11', x }); - for (let i = 1; i < groups.length; i++) { - const a = groups[i - 1].at(-1)!, b = groups[i][0], count = Math.floor((b - a) * 1000 / settings.spacingS); - for (let j = 1; j < count; j++) positions.push({ name: 'C9·C11 사이', x: a + (b - a) * j / count }); + const c8y = bottomBars.slice(1).map((y, i) => (y + bottomBars[i]) / 2); + for (let k = 1; k < columns.length; k++) { + const from = columns[k - 1] + MULTI_REBAR.c8End, to = columns[k] - MULTI_REBAR.c8End; + for (const y of c8y) add('C8', 'H25', [[from, levelBottom + hr + c, y], [to, levelBottom + hr + c, y]]); } - const ds = NOMINAL_DIAMETER.H22 / 1000, yTop = top - settings.cover / 1000; - const legs = settings.shearLegs, pitch = (B - 2 * settings.cover / 1000) / (legs - 1); - for (const { name, x } of positions) for (let i = 0; i < legs / 2; i++) { - const xu = x + i * 2 * ds, xc = xu + ds; - const a = -B / 2 + settings.cover / 1000 + i * pitch, b = a + legs / 2 * pitch; - const bottom = envelope.inset(xu, (settings.cover + 12) / 1000); - const capBottom = envelope.inset(xc, (settings.cover + 112) / 1000); - add(name, 'H22', [[xu, yTop, a], [xu, bottom, a], [xu, bottom, b], [xu, yTop, b]]); - add(name, 'H22', [[xc, capBottom, a], [xc, yTop, a], [xc, yTop, b], [xc, capBottom, b]]); - } - const levels = sideRowLevels(model.levelBottom, model.endBottom, top); + + // --- 측면 수평 C12 (상단, 전 길이) · C13 (하단 변화구간, 하면에서 끊김), 다리 위치마다 (§6) ----------------- + const zBreak = model.endBottom + c; + const sideTop = z2 - (d('H25') + d('H16')) / 2000; const sideRows: { z: number; intervals: [number, number][] }[] = []; - for (let z = levels.zBottom; z <= top - settings.sideEndGap / 1000 + 1e-9; z += settings.sideSpacing / 1000) { + for (let z = levelBottom + c + settings.sideSpacing / 1000; z <= sideTop + 1e-9; z += settings.sideSpacing / 1000) { + // Where the bar clears the underside by c (vertical): one interval per stretch between the walls of the 오목. + const xsAll = [a, ...envelope.xs.filter(x => x > a && x < b), b]; const intervals: [number, number][] = []; - const points = [0.127, ...envelope.xs.filter(x => x > 0.127 && x < L - 0.127), L - 0.127]; - for (let i = 1; i < points.length; i++) { - let a = points[i - 1], b = points[i]; - const za = envelope.inset(a, 0.127), zb = envelope.inset(b, 0.127); - if (Math.min(za, zb) > z) continue; - if (za > z) a += (b - a) * (za - z) / (za - zb); - else if (zb > z) b = a + (b - a) * (z - za) / (zb - za); - const previous = intervals.at(-1); - if (previous && Math.abs(previous[1] - a) < 1e-8) previous[1] = b; - else intervals.push([a, b]); + for (let i = 1; i < xsAll.length; i++) { + let p = xsAll[i - 1], q = xsAll[i]; + const zp = envelope.under(p) + c, zq = envelope.under(q) + c; + if (Math.min(zp, zq) > z + 1e-9) continue; + if (zp > z) p += (q - p) * (zp - z) / (zp - zq); + else if (zq > z) q = p + (q - p) * (z - zp) / (zq - zp); + const last = intervals.at(-1); + if (last && Math.abs(last[1] - p) < 1e-8) last[1] = q; else intervals.push([p, q]); } sideRows.push({ z, intervals }); - const name = z < levels.zBreak - 1e-9 ? 'C13' : 'C12', face = B / 2 - 0.127; - for (const [a, b] of intervals) { - add(name, 'H16', [[a, z, -face], [b, z, -face]]); - add(name, 'H16', [[a, z, face], [b, z, face]]); - // Short isolated intervals must not grow hooks through the adjacent haunch. - const leg = Math.min(SIDE_HOOK_LEG / 1000, (b - a) / 2), zh = z + NOMINAL_DIAMETER.H16 / 1000; - for (const [x, direction] of [[a, 1], [b, -1]]) add(name, 'H16', [[x + direction * leg, zh, -face], [x, zh, -face], [x, zh, face], [x + direction * leg, zh, face]]); + const name = z < zBreak - 1e-9 ? 'C13' : 'C12'; + for (const [p, q] of intervals) for (const y of legs.y) add(name, 'H16', [[p, z, y], [q, z, y]]); + } + + // --- C14 크로스타이 (§7): 연직 600, 교각축 1200, 단마다 600 엇갈림 --------------------------------------- + const tie = hookLength('tie90', d('H16')) / 1000; + const yEdge = B / 2 - c; + const c14Rows: number[] = []; + for (let z = levelBottom + c + MULTI_REBAR.c14Vertical; z < sideTop - 1e-9; z += MULTI_REBAR.c14Vertical) c14Rows.push(z); + c14Rows.push(sideTop); + c14Rows.forEach((z, r) => { + for (let x = MULTI_REBAR.c14Along / 2 + (r % 2) * MULTI_REBAR.c14Stagger; x <= L - MULTI_REBAR.c14Along / 2 + 1e-9; x += MULTI_REBAR.c14Along) { + if (z < envelope.under(x) + c + tie) continue; + add('C14', 'H16', [[x, z - tie, -yEdge], [x, z, -yEdge], [x, z, yEdge], [x, z - tie, yEdge]]); } + }); + + // --- 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).` }); } - const hook = hookLength('main90', NOMINAL_DIAMETER.H16) / 1000; - const face = B / 2 - C14_INSET / 1000; - const addCross = (x: number, z: number) => { - if (z < envelope.inset(x, 0.1)) return; - const legsFit = z - hook >= envelope.inset(x, 0.1); - add('C14', 'H16', legsFit ? [[x, z - hook, -face], [x, z, -face], [x, z, face], [x, z - hook, face]] : [[x, z, -face], [x, z, face]]); - }; - for (const k of [1, 2]) for (let x = L / 2 % (settings.c14UpperSpacing / 1000); x <= L - 0.6; x += settings.c14UpperSpacing / 1000) if (x >= 0.6) addCross(x, top - 0.127 - k * settings.mainLayerGap / 1000 + 0.024); - for (const [i, row] of sideRows.entries()) if (i % 2 === 0) for (let x = 0.6; x <= L - 0.6; x += settings.c14LowerSpacing / 1000) if (row.intervals.some(([a, b]) => x >= a && x <= b)) addCross(x, row.z + 0.02); - const stairs = stairBars(L * 1000, B * 1000, top, material, 2 * settings.mainSpacing); - for (const [name, list] of [['C15', stairs.c15], ['C16', stairs.c16]] as const) for (const s of list) { - const a = s.x0 / 1000, b = s.x1 / 1000, n = s.n / 1000, h = s.leg / 1000; - add(name, 'H19', [[a, s.z - h, n], [a, s.z, n], [b, s.z, n], ...(s.legs === 'both' ? [[b, s.z - h, n] as CopingPoint] : [])]); - } - return bars; + + // 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))]; + if (long.length) warnings.push({ code: 'RB-M03', message: `${long.join(' · ')} 길이가 12000을 넘습니다. 상면 1단 외 철근의 8 m 이음 적용은 미정입니다 (Q-M12).` }); + return { bars, warnings }; +} + +/** Shared physical pieces for multi-column 3D and quantities (3주형형상배근.md §4–§8). */ +export function multicolCopingBars(model: PierModel, material: Material = DEFAULT_MATERIAL): CopingBar[] { + return multicolCopingInfo(model, material).bars; } diff --git a/src/core/rebar/summary.ts b/src/core/rebar/summary.ts index dbdc492..d5f236b 100644 --- a/src/core/rebar/summary.ts +++ b/src/core/rebar/summary.ts @@ -8,7 +8,7 @@ import { shearBars, shearBarsMulti, copingSideBars, SIDE_HOOK_LEG, c14Bars, c14L import { sideRowLevels } from '../coping-geom'; import { CAST_PENDING } from './cast-in-place'; import { castRebarLayout } from './cast-layout'; -import { multicolCopingBars } from './multicol-coping'; +import { multicolCopingInfo } from './multicol-coping'; import { columnRebar, columnInputFor } from './column'; import { bottomBars, crossBars, sideBars, assemblyBars, shearB9 } from './footing'; import { DEFAULT_MATERIAL, FckByMember, fyOf, isAssumedMaterial, Material, materialFor, splitPieces, STANDARD_BAR } from './common'; @@ -48,6 +48,17 @@ export interface RebarWarning { bar?: string; } +/** 다주형 코핑 번호별 위치 (3주형형상배근.md §3, 번호 = 역할). */ +const MULTI_NOTE: Record = { + C1: '상면 1단 cycle-1, 양끝 갈고리, 8 m 이음', C4: '상면 1단 cycle-2, 직선, 8 m 이음 (± l_s 엇갈림)', + C3: '상면 2단 cycle-1, 끝단 쪽 (기둥 중심 + 4450까지)', C2: '상면 2단 cycle-2, 끝단 쪽', + 'C3-1': '상면 2단 cycle-1, 가운데 기둥 위 ± 4450', 'C2-1': '상면 2단 cycle-2, 가운데 기둥 위', + C7: '하면 전 길이, Level 꺾임점 겹침, 양끝 위로 갈고리', C8: '경간마다 오목 하면, 기둥 중심 ± 2230 안쪽', + C9: '전단 · 테이퍼·헌치', C10: '전단 · 기둥 Level', C11: '전단 · 기둥 사이 오목', + C12: '측면 상단 (전 길이), 다리 위치마다', C13: '측면 하단 변화구간 (하면에서 끊김), 다리 위치마다', + C14: '크로스타이 연직 600 · 교각축 1200 엇갈림', C15: '계단부 ⊓ (끝단보다 100 이상 높은 계단 전체)', +}; + /** Table metadata per bar (코핑철근.md, 확대기초철근 (1).md, 기둥철근 (1).md, 받침블럭철근.md). */ const META: Record> = { S1: { spacing: '125', actual: '125', position: '블럭 상면 − 69.1', direction: '교각축 직각', shape: '양끝 다리 꺾임', basis: 'calc' }, @@ -381,17 +392,18 @@ export function rebarSummary( warnings.push({ code: 'RB-F00', message: '현장타설말뚝은 확대기초가 없어 확대기초 철근을 만들지 않습니다.', target: 'footing' }); } if (nCol > 1 && model.kink === null) { - const grouped = new Map>(); - for (const b of multicolCopingBars(model, mCoping)) { + const info = multicolCopingInfo(model, mCoping); + const grouped = new Map(); + for (const b of info.bars) { const group = grouped.get(b.name) ?? []; group.push(b); grouped.set(b.name, group); } for (const [name, bars] of grouped) rows.push({ member: 'coping', name, size: bars[0].size, count: bars.length, length: null, pieces: bars.map(b => ({ length: b.length, count: 1 })), - note: '다주형 전용 삽도 규격 · 실제 콘크리트 하면에 따른 조각별 중심선 길이. 번호·cycle별 이음 상세 확인 필요' }); - warnings.push({ code: 'RB-M02', target: 'coping', message: '다주형 전용 삽도 우선: C1~4 H25·C7 H29·C8 H25·전단 H22·수평/C14 H16. C5/C6은 전용 삽도에 없어 제외했습니다. C11/C13 중복 번호·cycle별 이음 위치·분리 수평철근 갈고리는 상세 확인 필요. 해당 수량은 검토용입니다.' }); - warnings.push({ code: 'RB-M01', target: 'coping', message: '다주형 C14는 전용 삽도의 H16·기본600 간격과 실제 하면을 사용합니다. 기둥별 반복·번호 대응 상세는 확인이 필요합니다.' }); - warnings.push({ code: 'RB-M03', target: 'column', message: '다주형 단부는 삽도의 지름 이상·코핑 반높이 연장을 적용했습니다. P4/P5 번호는 기존 역할(단부 P5·기초내 P4)을 유지하며 도면 번호 대응 확인이 필요합니다.' }); + note: `다주형 (3주형형상배근.md) · ${MULTI_NOTE[name] ?? ''} · 조각별 중심선 길이` }); + for (const w of info.warnings) warnings.push({ code: w.code, target: 'coping', message: w.message }); + warnings.push({ code: 'RB-M06', target: 'coping', message: '다주형 전단 다리와 코핑 안 기둥 주철근의 간섭은 검토하지 않았습니다 (T형 Q-K3과 같음). 밀집 구간·다리 8개·측면 형상·피복 120은 가정값입니다 (Q-M2·Q-M3·Q-M5·Q-M6).' }); + warnings.push({ code: 'RB-M08', target: 'column', message: '다주형 기둥 단부는 지름 이상·코핑 반높이 연장을 적용했습니다 (그림 D, Pier A337 1.5D — Q-M8 미결).' }); } // Table metadata per bar (구분·간격·실간격·위치·방향·형상·근거). The spacing rows take their values from the inputs (승인 2026-10-26). const withMeta = rows.map((r) => { diff --git a/src/core/types.ts b/src/core/types.ts index 4c3d398..5c585f6 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -67,6 +67,9 @@ export interface PierInput { solplate?: SolplateRule; /** 솔플레이트 입력 (결정 2026-11-06, 시안): h3, T_ref, T_min, 여유, 받침별 A × B, 열 T·받침 S 직접 수정. Absent = 기본값. */ sole?: SoleSettings; + /** 다주형 기둥 위치 입력 (Q-M10 미결): 끝 ~ 기둥1 중심 e_c, 기둥 간격 s_c (mm). Absent = L/5 규칙. */ + columnEndOffsetMm?: number; + columnSpacingMm?: number; /** 교각 종단경사 (소수, (−) = 교량진행방향으로 낮아짐; Pier 시트 "종단경사" 열, Q-SH2). 짝 받침이 없을 때 S에 쓴다. */ longitudinalSlope?: number; /** 지반·지층 (승인 2026-10-26): 위치, 지하수위 고려와 EL, 지층별 행 (위에서 아래, 행의 EL = 그 층의 하단 EL). diff --git a/src/view/rebar3d.ts b/src/view/rebar3d.ts index 4d6c550..64a3f93 100644 --- a/src/view/rebar3d.ts +++ b/src/view/rebar3d.ts @@ -166,7 +166,7 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL, // Coping (straight coping only; a bent coping is not drawn in 3D yet). if (straight && m.columns.length > 1) { for (const b of multicolCopingBars(m, mCoping)) { - if (/^C[1-8]$/.test(b.name)) addPath(b.name, 0, b.pts, b.diameterMm, COLOR.coping); + if (/^C[1-8](-\d+)?$/.test(b.name)) addPath(b.name, 0, b.pts, b.diameterMm, COLOR.coping); else for (let i = 1; i < b.pts.length; i++) addBar(b.name, 0, b.pts[i - 1], b.pts[i], b.diameterMm, COLOR.shear); } } diff --git a/tests/rebar-multi.test.ts b/tests/rebar-multi.test.ts index 4cc9739..58cc64a 100644 --- a/tests/rebar-multi.test.ts +++ b/tests/rebar-multi.test.ts @@ -1,47 +1,116 @@ import { describe, expect, it } from 'vitest'; import { buildPier } from '../src/core/geometry'; -import { shearBarsMulti } from '../src/core/rebar/coping'; +import { multicolCopingInfo, multicolShearLayout } from '../src/core/rebar/multicol-coping'; import { rebarSummary } from '../src/core/rebar/summary'; import { loadP1 } from './helpers'; -// 2주형 on the P1 sheet with a 12 m coping (columns at L/5, 4L/5). 3주형 needs 20 m (columns 4 / 10 / 16): with 12 m the -// columns are 3600 apart and leave no flat 오목 part between the 1500 haunches (RB-M01). -const two = buildPier({ ...loadP1(), type: '2주형', columnCount: 2, copingLengthMm: 12000 }); -const three = buildPier({ ...loadP1(), type: '3주형', columnCount: 3, copingLengthMm: 20000 }); -const count = (rows: ReturnType['rows'], name: string) => rows.find((r) => r.name === name)!.count; +// RAG/3주형형상배근.md §11 (2026-11-06): 2주형 DWG (L 24200) and the 3주형 합성 예제 (L 37200), B 3500, D 3000, +// h1 = h2 = 1500, 기둥 끝 여유 5600 · 간격 13000 (Q-M10 input), 피복 120, 다리 8개. +const dwg = (type: '2주형' | '3주형', L: number, extra = {}) => buildPier({ ...loadP1(), type, columnCount: type === '2주형' ? 2 : 3, + copingLengthMm: L, copingWidthMm: 3500, columnDiameterMm: 3000, copingH1Mm: 1500, copingH2Mm: 1500, columnEndOffsetMm: 5600, ...extra }); +const two = dwg('2주형', 24200), three = dwg('3주형', 37200); +const info2 = multicolCopingInfo(two), info3 = multicolCopingInfo(three); +const named = (info: typeof info2, name: string) => info.bars.filter((b) => b.name === name); +/** Lengths of the pieces of one bar line (the first y of that name), in order along x. */ +const line = (info: typeof info2, name: string) => { + const all = named(info, name); + const y = all[0].pts[1][2]; + return all.filter((b) => b.pts.some((p) => Math.abs(p[2] - y) < 1e-9)).sort((a, b) => a.pts[0][0] - b.pts[0][0]).map((b) => b.length); +}; +const near = (got: number[], want: number[], tol: number) => { + expect(got).toHaveLength(want.length); + got.forEach((g, i) => expect(Math.abs(g - want[i]), `#${i}: ${g} vs ${want[i]}`).toBeLessThanOrEqual(tol)); +}; -describe('다주형 철근 (2주형·3주형, 코핑철근.md §9)', () => { - it('2주형: H22 @300, six physical pieces per position', () => { - const rows = rebarSummary(two).rows; - expect(count(rows, 'C10')).toBe(18 * 6); - expect(count(rows, 'C9')).toBe(3 * 6); - expect(count(rows, 'C11')).toBe(3 * 6); - expect(count(rows, 'C9·C11 사이')).toBe(15 * 6); +describe('다주형 코핑 배근 (3주형형상배근.md §11 재현)', () => { + it('column positions from e_c 5600 / s_c 13000 (Q-M10 input) and the multi-column defaults (피복 120, 다리 8)', () => { + expect(two.columns.map((c) => c.s)).toEqual([5.6, 18.6]); + expect(three.columns.map((c) => c.s).map((x) => Math.round(x * 1000) / 1000)).toEqual([5.6, 18.6, 31.6]); + expect(three.copingRebar).toMatchObject({ cover: 120, shearLegs: 8, spacingS: 300, sideSpacing: 200 }); }); - it('3주형: C10 27 positions and two gaps of eleven, each with six physical pieces', () => { - const rows = rebarSummary(three).rows; - // Per column 9 positions (±4 @300); between groups 8.8 − 5.2 = 3.6 m → floor(3600 / 300) = 12 bays → 11 positions. - expect(count(rows, 'C10')).toBe(27 * 6); - expect(count(rows, 'C9·C11 사이')).toBe(22 * 6); + it('2주형 shear: 112 positions, 120 · 130 · 7@300 · 14@150 · 8@300 · 16@150 · 19@300 · 16@150 · 8@300 · 14@150 · 7@300 · 130 · 120', () => { + const { positions } = multicolShearLayout(24.2, [5.6, 18.6], 0.12, 0.3, 0.15); + expect(positions).toHaveLength(112); + expect(positions[0]).toBeCloseTo(0.12, 9); + const gaps = positions.slice(1).map((x, i) => Math.round((x - positions[i]) * 10000) / 10); + const want = [130, ...Array(7).fill(300), ...Array(14).fill(150), ...Array(8).fill(300), ...Array(16).fill(150), ...Array(19).fill(300), + ...Array(16).fill(150), ...Array(8).fill(300), ...Array(14).fill(150), ...Array(7).fill(300), 130]; + expect(gaps).toEqual(want); }); - it('column bars repeat per column: P1 230 × 2 and 230 × 3', () => { - expect(count(rebarSummary(two).rows, 'P1')).toBe(460); - expect(count(rebarSummary(three).rows, 'P1')).toBe(690); + it('3주형 shear: 173 positions, spans 20@287.5, C9 78 · C10 41 · C11 54 (8 pieces per position: 4 closures × U + 삿갓)', () => { + const { positions } = multicolShearLayout(37.2, [5.6, 18.6, 31.6], 0.12, 0.3, 0.15); + expect(positions).toHaveLength(173); + const gaps = positions.slice(1).map((x, i) => Math.round((x - positions[i]) * 10000) / 10); + expect(gaps.filter((g) => g === 287.5)).toHaveLength(40); + expect([named(info3, 'C9').length, named(info3, 'C10').length, named(info3, 'C11').length].map((n) => n / 8)).toEqual([78, 41, 54]); }); - it('a multi-column pier gets the RB-M01 note about C14', () => { - expect(rebarSummary(two).warnings.map((w) => w.code)).toContain('RB-M01'); + it('3주형 상면 1단: C1 7 pieces 4911.5 · 6805 × 5 · 7961.5, C4 7567.5 · 6805 × 5 · 4517.5 (8 m 이음), 14 / 13 bars', () => { + near(line(info3, 'C1'), [4911.5, 6805, 6805, 6805, 6805, 6805, 7961.5], 1); + near(line(info3, 'C4'), [7567.5, 6805, 6805, 6805, 6805, 6805, 4517.5], 1); + expect(named(info3, 'C1')).toHaveLength(98); + expect(named(info3, 'C4')).toHaveLength(91); }); - it('the single column (T형) has no between row and no RB-M01', () => { - const t = rebarSummary(buildPier(loadP1())); - expect(t.rows.find((r) => r.name === 'C9·C11 사이')).toBeUndefined(); - expect(t.warnings.map((w) => w.code)).not.toContain('RB-M01'); + it('2주형 상면 1단: C1 5 pieces 4423.5 · 6317 × 3 · 7473.5', () => { + near(line(info2, 'C1'), [4423.5, 6317, 6317, 6317, 7473.5], 1); }); - it('shear bars for a columns too close to share a Level are refused', () => { - expect(() => shearBarsMulti(12000, [3000, 3500])).toThrow('겹칩니다'); + it('상면 2단: C3 10324 × 2 · C2 9930 × 2 per bar, 가운데 기둥 위 C3-1 / C2-1 8900', () => { + near(line(info3, 'C3'), [10324, 10324], 1); + near(line(info3, 'C2'), [9930, 9930], 1); + near(line(info3, 'C3-1'), [8900], 1); + expect(named(info3, 'C3-1')).toHaveLength(14); + expect(named(info3, 'C2-1')).toHaveLength(13); + expect(named(info2, 'C3-1')).toHaveLength(0); + }); + + it('RB-M05: 8 m 이음 중심 in a column zone — 3주형 C4 17.485 · C1 19.715, 2주형 C4 6.437 · C1 17.763', () => { + const m3 = info3.warnings.find((w) => w.code === 'RB-M05')!.message; + expect(m3).toContain('C4 17.485'); + expect(m3).toContain('C1 19.715'); + const m2 = info2.warnings.find((w) => w.code === 'RB-M05')!.message; + expect(m2).toContain('C4 6.437'); + expect(m2).toContain('C1 17.763'); + }); + + it('하면 C7: 3주형 5 pieces 8690.5 / 11679 / 4917 / 11679 / 8690.5, 2주형 3 pieces (lap l_s/2 along the bar at the Level kinks)', () => { + // The outer piece follows the taper from the cover line: 7344 + 858.5 + 472 = 8674.5 (DWG 실측 경로 7360 → 8690.5). + near(line(info3, 'C7'), [8690.5, 11679, 4917, 11679, 8690.5], 20); + near(line(info2, 'C7'), [8690.5, 11679, 8690.5], 20); + expect(named(info3, 'C7')).toHaveLength(14 * 5); + }); + + it('하면 C8: 8540 per span (기둥 중심 ± 2230 안쪽), 13 × 2 = 26 for 3주형', () => { + expect(named(info3, 'C8')).toHaveLength(26); + expect(named(info3, 'C8').every((b) => Math.abs(b.length - 8540) < 1e-6)).toBe(true); + }); + + it('측면: C12 6 rows (z 1720 … 2720 above Level) at the 8 leg lines, 36960; C13 z 320 → 4330 / 4400 / 4330 (cut at each column)', () => { + const c12 = named(info3, 'C12'); + expect([...new Set(c12.map((b) => Math.round((b.pts[0][1] - three.levelBottom) * 1000)))]).toEqual([1720, 1920, 2120, 2320, 2520, 2720]); + expect(c12).toHaveLength(6 * 8); + expect(c12.every((b) => Math.abs(b.length - 36960) < 1e-6)).toBe(true); + const low = named(info3, 'C13').filter((b) => Math.abs(b.pts[0][1] - three.levelBottom - 0.32) < 1e-9).map((b) => Math.round(b.length)); + expect([...new Set(low)].sort()).toEqual([4333, 4400]); + expect(low).toHaveLength(3 * 8); + }); + + it('RB-M01: s_c 7000 — the dense zones of neighbouring columns overlap (warning; positions merged)', () => { + const short = multicolCopingInfo(dwg('3주형', 37200, { columnSpacingMm: 7000 })); + expect(short.warnings.map((w) => w.code)).toContain('RB-M01'); + expect(info3.warnings.map((w) => w.code)).not.toContain('RB-M01'); + }); + + it('철근표 rows reuse the pieces; the multi-column pier gets RB-M06 / RB-M08 notes; T형 none', () => { + const summary = rebarSummary(three); + for (const name of ['C1', 'C4', 'C3', 'C2', 'C3-1', 'C2-1', 'C7', 'C8', 'C9', 'C10', 'C11', 'C12', 'C13', 'C14']) { + const row = summary.rows.find((r) => r.name === name)!; + expect(row.count, name).toBe(named(info3, name).length); + } + expect(summary.warnings.map((w) => w.code)).toEqual(expect.arrayContaining(['RB-M05', 'RB-M06', 'RB-M08'])); + expect(rebarSummary(buildPier(loadP1())).warnings.map((w) => w.code)).not.toContain('RB-M06'); }); });