feat(rebar): 꺾인 코핑 철근을 꺾임에 맞춰 접는다 (교축 꺾은 철근 · 미터 전단 1조)

편 좌표(g)로 계산한 다주형 코핑 철근을 꺾인 코핑에 맞춘다 (coping-fold.ts):
- 교축 방향 철근은 그 n에서 미터면과 만나는 점에서 꺾은 한 개, 길이는 실제 경로 (바깥 길고 안쪽 짧음)
- 교축 직각 철근(전단·크로스타이)은 미터 쐐기에 걸리면 빼고, 꺾임점에 가장 가까운 전단 위치 1조를 미터 방향으로
- 상면 1단 이음은 꺾임점에서 l_s/2 + 미터 퍼짐 밖으로
조각(pieces)은 구간별 국부 좌표로 담아 3D가 그대로 그릴 수 있게 한다. 곧은 코핑은 바뀌지 않는다.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-11-10 17:09:27 +09:00
1 parent 51209cccf9
commit 6a15a13fc7
3 files changed
+145 -2

No files matched your search

+73
View File
@@ -0,0 +1,73 @@
/**
* 꺾인 코핑의 철근 접기 (결정 2026-11-10). Pure.
*
* 코핑 철근은 코핑을 편 좌표 (g = 코핑 시작에서 축을 따라 잰 거리, EL, n)로 계산한다. 꺾인 코핑에서는:
* - 교축 방향 철근은 꺾임점에서 꺾은 한 개: 그 철근의 n에서 미터면과 만나는 점에서 꺾는다. 길이는 실제 경로로 다시 잰다
* (편 길이 + a0(n) − a1(n), 바깥쪽이 길고 안쪽이 짧다).
* - 교축 직각 철근 (전단 · 크로스타이)은 각 구간 축에 직각이다. 미터 쐐기에 걸리는 것은 빼고, 꺾임점에 전단철근
* 1조를 미터 방향으로 둔다 (가장 가까운 전단 위치의 모양 그대로).
* 조각 좌표는 각 구간의 국부 좌표 (s, EL, n)이다: 구간 0은 s = g, 구간 1은 s = g − 꺾임점 g.
*/
import type { PierModel } from '../geometry';
export type FoldPoint = [number, number, number];
export interface FoldPiece { k: 0 | 1; pts: FoldPoint[] }
export interface CopingFold {
/** g of the kink (m). */
kinkG: number;
/** Local s of the mitre at offset n, in stretch 0 (from the kink, + ahead) and stretch 1 (from its base). */
a0: (n: number) => number;
a1: (n: number) => number;
/** Largest |a| over the coping width (m): half the wedge the mitre sweeps. */
wedge: number;
}
export function copingFold(model: PierModel): CopingFold | null {
if (!model.kink || !model.mitre || model.stretches.length < 2) return null;
const m = model.mitre;
const dot = (a: { e: number; n: number }, b: { e: number; n: number }) => a.e * b.e + a.n * b.n;
const [s0, s1] = model.stretches;
// A point (s, n) of stretch k is on the mitre when (s − s_kink)·(u·m) + n·(nrm·m) = 0.
const r0 = dot(s0.nrm, m) / dot(s0.u, m);
const r1 = dot(s1.nrm, m) / dot(s1.u, m);
return { kinkG: s1.base, a0: (n) => -n * r0, a1: (n) => -n * r1, wedge: (model.copingB / 2) * Math.max(Math.abs(r0), Math.abs(r1)) };
}
const pathLength = (pts: FoldPoint[]) => pts.slice(1).reduce((sum, p, i) => sum + Math.hypot(p[0] - pts[i][0], p[1] - pts[i][1], p[2] - pts[i][2]), 0);
/** One bar from the unfolded g into stretch pieces; a run across the kink turns at its own mitre point. Length in mm. */
export function foldBar(f: CopingFold, pts: FoldPoint[]): { pieces: FoldPiece[]; length: number } {
const side = (x: number): 0 | 1 => (x <= f.kinkG + 1e-12 ? 0 : 1);
const local = (p: FoldPoint, k: 0 | 1): FoldPoint => [k === 0 ? p[0] : p[0] - f.kinkG, p[1], p[2]];
const pieces: FoldPiece[] = [{ k: side(pts[0][0]), pts: [local(pts[0], side(pts[0][0]))] }];
for (let i = 1; i < pts.length; i++) {
const a = pts[i - 1], b = pts[i];
const ka = side(a[0]), kb = side(b[0]);
if (ka !== kb) {
const t = (f.kinkG - a[0]) / (b[0] - a[0]);
const z = a[1] + t * (b[1] - a[1]), n = a[2] + t * (b[2] - a[2]);
const m0: FoldPoint = [f.kinkG + f.a0(n), z, n], m1: FoldPoint = [f.a1(n), z, n];
// Both are the same world point on the mitre, seen from each stretch.
if (ka === 0) { pieces[pieces.length - 1].pts.push(m0); pieces.push({ k: 1, pts: [m1] }); }
else { pieces[pieces.length - 1].pts.push(m1); pieces.push({ k: 0, pts: [m0] }); }
}
pieces[pieces.length - 1].pts.push(local(b, kb));
}
return { pieces, length: pieces.reduce((sum, p) => sum + pathLength(p.pts), 0) * 1000 };
}
/** True for a bar across the width: every point at one g. */
export const isTransverse = (pts: FoldPoint[]) => pts.every((p) => Math.abs(p[0] - pts[0][0]) < 1e-9) && pts.some((p) => Math.abs(p[2] - pts[0][2]) > 1e-9);
/** True when a transverse bar at g would cut the mitre plane somewhere over its width. */
export function inWedge(f: CopingFold, pts: FoldPoint[], margin = 0): boolean {
const reach = Math.max(...pts.map((p) => Math.max(Math.abs(f.a0(p[2])), Math.abs(f.a1(p[2])))));
return Math.abs(pts[0][0] - f.kinkG) <= reach + margin + 1e-9;
}
/** A transverse bar laid on the mitre (stretch 0 coordinates): each point moves to the mitre at its n, plus `dx` along the axis. */
export function onMitre(f: CopingFold, pts: FoldPoint[], dx = 0): { pieces: FoldPiece[]; length: number } {
const moved = pts.map((p): FoldPoint => [f.kinkG + f.a0(p[2]) + dx, p[1], p[2]]);
return { pieces: [{ k: 0, pts: moved }], length: pathLength(moved) * 1000 };
}
+31 -2
View File
@@ -9,9 +9,11 @@
import type { PierModel } from '../geometry';
import { DEFAULT_MATERIAL, developmentLength, fyOf, hookLength, type Material, NOMINAL_DIAMETER, spliceLength } from './common';
import { stepBars } from './step-bars';
import { copingFold, foldBar, type FoldPiece, inWedge, isTransverse, onMitre } from './coping-fold';
export type CopingPoint = [number, number, number];
export interface CopingBar { name: string; size: string; diameterMm: number; pts: CopingPoint[]; length: number }
/** pieces: on a bent coping, the bar in stretch-local coordinates (coping-fold.ts); pts stay unfolded. */
export interface CopingBar { name: string; size: string; diameterMm: number; pts: CopingPoint[]; length: number; pieces?: FoldPiece[] }
/** Actual concrete lower envelope, including every haunch vertex. Coordinates are m, EL m. */
export function copingEnvelope(model: PierModel) {
@@ -133,6 +135,7 @@ export function multicolCopingInfo(model: PierModel, material: Material = DEFAUL
}
const c = settings.cover / 1000, s = settings.spacingS / 1000, sd = MULTI_REBAR.denseSpacingMm / 1000;
const envelope = copingEnvelope(model);
const fold = copingFold(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) });
@@ -181,7 +184,10 @@ export function multicolCopingInfo(model: PierModel, material: Material = DEFAUL
// 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);
// On a bent coping a lap keeps clear of the mitre (2026-11-10): a cut within l_s/2 + the mitre wedge moves out.
const clear = fold ? ls25 / 2 + fold.wedge : 0;
const cuts = Array.from({ length: n - 1 }, (_, j) => a + (b - a) * (j + 1) / n + shift)
.map((x) => (fold && Math.abs(x - fold.kinkG) < clear ? fold.kinkG + Math.sign(x - fold.kinkG || 1) * clear : x));
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));
@@ -291,6 +297,29 @@ export function multicolCopingInfo(model: PierModel, material: Material = DEFAUL
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 });
// Bent coping (결정 2026-11-10, coping-fold.ts): runs along the axis turn at their mitre point; bars across the width that
// would cut the mitre are left out, and one shear set (the position nearest the kink) is laid on the mitre.
if (fold) {
const shear = ['C9', 'C10', 'C11', 'C9·C11 사이'];
const tShear = bars.filter((b) => shear.includes(b.name) && isTransverse(b.pts));
const xNear = tShear.length ? tShear.reduce((best, b) => (Math.abs(b.pts[0][0] - fold.kinkG) < Math.abs(best - fold.kinkG) ? b.pts[0][0] : best), tShear[0].pts[0][0]) : null;
const group = xNear === null ? [] : tShear.filter((b) => Math.abs(b.pts[0][0] - xNear) < 0.1);
const kept = bars.filter((b) => !(isTransverse(b.pts) && inWedge(fold, b.pts, shear.includes(b.name) ? 0.1 : 0)));
const folded: CopingBar[] = kept.map((b) => {
if (isTransverse(b.pts)) {
const k = b.pts[0][0] <= fold.kinkG ? 0 : 1;
return { ...b, pieces: [{ k, pts: b.pts.map((p): CopingPoint => [k === 0 ? p[0] : p[0] - fold.kinkG, p[1], p[2]]) }] };
}
const f = foldBar(fold, b.pts);
return { ...b, pieces: f.pieces, length: f.length };
});
for (const b of group) {
const f = onMitre(fold, b.pts, b.pts[0][0] - xNear!);
folded.push({ ...b, pieces: f.pieces, length: f.length });
}
bars.splice(0, bars.length, ...folded);
}
// 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).` });