/** * 다주형(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 { 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 } /** Actual concrete lower envelope, including every haunch vertex. Coordinates are m, EL m. */ export function copingEnvelope(model: PierModel) { const polygons = model.copingParts.filter(p => p.k === 0).map(p => p.profile); const xs = [...new Set(polygons.flatMap(p => p.map(v => v[0])))].sort((a, b) => a - b); const under = (x: number) => Math.max(...polygons.map(p => { const hits = p.flatMap((a, i) => { const b = p[(i + 1) % p.length]; if (Math.abs(b[0] - a[0]) < 1e-9 || x < Math.min(a[0], b[0]) - 1e-9 || x > Math.max(a[0], b[0]) + 1e-9) return []; return [a[1] + (b[1] - a[1]) * (x - a[0]) / (b[0] - a[0])]; }); return Math.min(...hits); })); const slopes = xs.slice(1).map((x, i) => (under(x) - under(xs[i])) / (x - xs[i])); // Conservative vertical inset at a corner: both adjoining faces retain the requested normal cover. const inset = (x: number, cover: number) => under(x) + cover * Math.max(1, ...slopes.filter((_, i) => x >= xs[i] - 1e-9 && x <= xs[i + 1] + 1e-9).map(s => Math.hypot(1, s))); return { xs, under, inset }; } 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; } /** 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 row's lowest top gets C15 / C16 (m, step-bars.ts). */ 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 warnings: MulticolInfo['warnings'] = []; const L = model.copingL, B = model.copingB; const settings = model.copingRebar; 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 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]]]); }); } // --- 상면 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); } } 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]]); } // --- 측면 수평 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 = 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][] = []; 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 < 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 · 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))]; 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; }