Files
Pier_v1/src/core/rebar/multicol-coping.ts
T
ypshimandClaude Haiku 5.5 7340f8c1a1 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>
2026-11-10 16:53:15 +09:00

304 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 다주형(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;
}