diff --git a/src/core/rebar/coping-fold.ts b/src/core/rebar/coping-fold.ts new file mode 100644 index 0000000..70e15b9 --- /dev/null +++ b/src/core/rebar/coping-fold.ts @@ -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 }; +} diff --git a/src/core/rebar/multicol-coping.ts b/src/core/rebar/multicol-coping.ts index 46663e7..59a1636 100644 --- a/src/core/rebar/multicol-coping.ts +++ b/src/core/rebar/multicol-coping.ts @@ -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).` }); diff --git a/tests/kink-steps.test.ts b/tests/kink-steps.test.ts index f2e889a..3c2275e 100644 --- a/tests/kink-steps.test.ts +++ b/tests/kink-steps.test.ts @@ -7,6 +7,7 @@ import { buildPier, DIM, defaultLevelWidthMm } from '../src/core/geometry'; import type { PierInput } from '../src/core/types'; import { rebarSummary } from '../src/core/rebar/summary'; import { multicolCopingInfo } from '../src/core/rebar/multicol-coping'; +import { copingFold, inWedge, isTransverse } from '../src/core/rebar/coping-fold'; // 산외교 P11 (샘플01) with the coping bent 8.767° at IP4 (2026-11-10 검토): back ⑥ g 11.64, ⑦ g 18.49, kink g 15.14. const SAMPLE = fileURLToPath(new URL('../project_data/01_엑셀/샘플01_산외교_교각입력데이터.xlsx', import.meta.url)); @@ -145,3 +146,43 @@ describe('꺾인 다주형 코핑의 철근표 (A, 2026-11-10)', () => { expect(rows.every((r) => r.note.includes('다주형'))).toBe(true); }); }); + +describe('꺾인 코핑 철근 접기 (B·C, 2026-11-10)', () => { + const m = buildPier(p11()); + const f = copingFold(m)!; + const bars = multicolCopingInfo(m).bars; + const unfolded = (pts: [number, number, number][]) => pts.slice(1).reduce((s, p, i) => s + Math.hypot(p[0] - pts[i][0], p[1] - pts[i][1], p[2] - pts[i][2]), 0) * 1000; + + it('a run across the kink turns at its mitre point: length = unfolded + a0(n) − a1(n), outer longer, inner shorter', () => { + const crossing = bars.filter((b) => b.name === 'C7' && b.pieces!.length === 2); + expect(crossing.length).toBeGreaterThan(0); + for (const b of crossing) { + const n = b.pts[1][2]; + expect(b.length).toBeCloseTo(unfolded(b.pts) + (f.a0(n) - f.a1(n)) * 1000, 6); + } + const byN = [...crossing].sort((a, b) => a.pts[1][2] - b.pts[1][2]); + const d = (b: (typeof crossing)[number]) => b.length - unfolded(b.pts); + expect(Math.sign(d(byN[0]))).toBe(-Math.sign(d(byN.at(-1)!))); + expect(Math.abs(d(byN[0]))).toBeGreaterThan(100); + }); + + it('no bar across the width cuts the mitre, except the one shear set laid on it', () => { + const transverse = bars.filter((b) => isTransverse(b.pts)); + const onIt = transverse.filter((b) => b.pieces!.length === 1 && b.pieces![0].pts.every((p) => Math.abs(p[0] - (f.kinkG + f.a0(p[2]))) < 0.2)); + expect(new Set(onIt.map((b) => b.name)).size).toBeGreaterThan(0); + for (const b of transverse.filter((x) => !onIt.includes(x))) expect(inWedge(f, b.pts)).toBe(false); + }); + + it('the top-bar laps keep l_s/2 + the wedge away from the kink', () => { + const c1 = bars.filter((b) => b.name === 'C1'); + const ends = c1.flatMap((b) => [Math.min(...b.pts.map((p) => p[0])), Math.max(...b.pts.map((p) => p[0]))]); + const inner = ends.filter((x) => x > 0.2 && x < m.copingL - 0.2); + expect(inner.length).toBeGreaterThan(0); + for (const x of inner) expect(Math.abs(x - f.kinkG)).toBeGreaterThan(f.wedge - 1e-9); + }); + + it('a straight coping is unchanged (no pieces)', () => { + const straight = buildPier({ ...p11(), ips: p11().ips.map((q) => (q.name === 'IP4' ? { ...q, angleDeg: 0 } : q)) }); + expect(multicolCopingInfo(straight).bars.every((b) => b.pieces === undefined)).toBe(true); + }); +});