feat(rebar): 현장타설 말뚝 · 다주형 코핑 형상과 철근 계산

- 현장타설 기둥·말뚝 형상(cast-in-place-shape)과 주철근·횡철근·커플러 계산(rebar/cast-in-place, cast-layout)
- 다주형 코핑 철근(multicol-coping), 기둥 높이 · 양단부 연장
- 철근표 요약 · 재료 수량 · 3D 뷰 연결과 삽도(cast-rebar-svg)
- 검사 기준 갱신과 회귀 테스트, 검증 스크립트 보관

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-11-06 13:15:01 +09:00
1 parent 3922b330d5
commit 2e0bfb6fc8
29 files changed
+1750 -71

No files matched your search

+136
View File
@@ -0,0 +1,136 @@
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';
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;
}
/** 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[] {
const bars: CopingBar[] = [];
const L = model.copingL, B = model.copingB, top = model.copingRefTop;
const settings = model.copingRebar;
if ([settings.spacingS, settings.spacingL, settings.sideSpacing, settings.c14UpperSpacing, settings.c14LowerSpacing].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 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 correspondence and cycle-specific splice locations remain explicitly pending.
for (let k = 1; k <= 3; k++) for (const [j, n] of ns.entries()) {
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]]);
}
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]]);
}
// 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 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);
const sideRows: { z: number; intervals: [number, number][] }[] = [];
for (let z = levels.zBottom; z <= top - settings.sideEndGap / 1000 + 1e-9; z += settings.sideSpacing / 1000) {
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]);
}
sideRows.push({ z, intervals });
const name = z < levels.zBreak - 1e-9 ? 'C12' : 'C13', 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 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;
}