From ac646d554c37b7b9ff82dbc8efc4a21ef3a626c0 Mon Sep 17 00:00:00 2001 From: ypshim Date: Tue, 10 Nov 2026 17:46:47 +0900 Subject: [PATCH] =?UTF-8?q?feat(shape):=20=EA=BA=BE=EC=9E=84=EC=A0=90?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EA=B2=B9=EC=B9=98=EB=8A=94=20=EA=B8=B0?= =?UTF-8?q?=EC=B4=88=EB=A5=BC=20=ED=95=98=EB=82=98=EC=9D=98=20=EA=BA=BE?= =?UTF-8?q?=EC=9D=B8=20=EA=B8=B0=EC=B4=88=EB=A1=9C=20=ED=95=A9=EC=B9=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 꺾임을 넘는 기초를 합치지 않아, 기둥 간격이 기초 길이보다 짧은 꺾인 교각(산외교 P11: 2.75 m)에서 두 기초가 겹치고(콘크리트 쐐기 틈, 철근 겹침, 수량 이중 계산) 말뚝도 겹쳤다. 꺾임 전후 기초가 겹치거나 미터 퍼짐 안에서 맞닿으면 하나의 꺾인 기초로 본다: - 형상: 구간별 조각 둘(bent), 각 조각을 미터 퍼짐 + 10 mm 너머로 늘려 미터면에서 틈 없이 잇는다 - 말뚝: 편 길이에 한 번 배치하고 꺾임점 기준으로 구간을 나눈다 - 철근: 편 좌표의 한 기초(footing-units.ts)로 철근표와 3D가 같이 계산, 3D는 미터점에서 접는다 Co-Authored-By: Claude Haiku 5.5 --- src/core/geometry.ts | 37 ++++++++++++++++--- src/core/rebar/footing-units.ts | 40 +++++++++++++++++++++ src/core/rebar/summary.ts | 8 ++--- src/view/rebar3d.ts | 25 +++++++++---- tests/bent-coping.test.ts | 15 +++++--- tests/kink-steps.test.ts | 63 ++++++++++++++++++++------------- 6 files changed, 142 insertions(+), 46 deletions(-) create mode 100644 src/core/rebar/footing-units.ts diff --git a/src/core/geometry.ts b/src/core/geometry.ts index 9f1ed5e..75ee583 100644 --- a/src/core/geometry.ts +++ b/src/core/geometry.ts @@ -128,6 +128,11 @@ export interface FootingModel { bottom: number; length: number; width: number; + /** + * One footing across the kink (2026-11-10): this record is one stretch's part, reaching past the mitre (the mitre plane + * trims it), and gFrom … gTo is the whole footing in unfolded g. Rebar and quantities take the pair as one footing. + */ + bent?: { gFrom: number; gTo: number; part: 0 | 1 }; } export interface BlindingModel { @@ -443,12 +448,30 @@ export function buildPier(input: PierInput, ctx: PierContext = {}): PierModel { const fd = footingDims(input); // Footings are grouped per stretch so that a 일체기초 never crosses the kink; each group is aligned with its stretch. - const footingSets: { s: number; k: number; length: number }[] = []; + const footingSets: { s: number; k: number; length: number; bent?: FootingModel['bent'] }[] = []; for (let k = 0; k < stretchCount; k++) { for (const g of footingGroups(colG.filter((_, i) => colK[i] === k), fd.L)) { footingSets.push({ s: g.s - base[k], k, length: g.length }); } } + // Footings that meet or overlap across the kink become one bent footing (2026-11-10, 산외교 P11: 2.75 m overlap). + // Each part reaches past the mitre spread (W/2)·tan(θ/2) so the trimmed concrete joins without a wedge gap. + if (kinkG !== null) { + const last0 = footingSets.filter((f) => f.k === 0).sort((a, b) => b.s - a.s)[0]; + const first1 = footingSets.filter((f) => f.k === 1).sort((a, b) => a.s - b.s)[0]; + if (last0 && first1) { + const spreadF = (fd.W / 2) * Math.tan((Math.abs(ip4.angleDeg) * Math.PI) / 360); + const g0s = last0.s - last0.length / 2, g0e = last0.s + last0.length / 2; + const g1s = kinkG + first1.s - first1.length / 2, g1e = kinkG + first1.s + first1.length / 2; + if (g0e >= g1s - 2 * spreadF) { + const ext = spreadF + 0.01; + const p0e = Math.max(g0e, kinkG + ext), p1s = Math.min(g1s, kinkG - ext) - kinkG; + const bent = { gFrom: g0s, gTo: g1e }; + Object.assign(last0, { s: (g0s + p0e) / 2, length: p0e - g0s, bent: { ...bent, part: 0 as const } }); + Object.assign(first1, { s: (p1s + g1e - kinkG) / 2, length: g1e - kinkG - p1s, bent: { ...bent, part: 1 as const } }); + } + } + } const cast = castShape(input); if (input.foundationType === '현장타설말뚝' && (!Number.isFinite(cast.pileTopEl) || cast.pileTopEl >= levelBottom || !Number.isFinite(input.pileLengthMm) || input.pileLengthMm <= 0)) throw new Error('현장타설말뚝은 지반고가 코핑 Level 하면보다 낮고 말뚝 길이가 양수여야 합니다.'); @@ -473,7 +496,7 @@ export function buildPier(input: PierInput, ctx: PierContext = {}): PierModel { }); } else { for (const f of footingSets) { - footings.push({ s: f.s, k: f.k, top: footingTop, bottom: input.footingBottomEl, length: f.length, width: fd.W }); + footings.push({ s: f.s, k: f.k, top: footingTop, bottom: input.footingBottomEl, length: f.length, width: fd.W, ...(f.bent ? { bent: f.bent } : {}) }); blindings.push({ s: f.s, k: f.k, @@ -482,10 +505,14 @@ export function buildPier(input: PierInput, ctx: PierContext = {}): PierModel { width: fd.W + fd.blM, thickness: fd.blT, }); - if (input.foundationType === '말뚝기초') { - const g = pileGeometry(f.length, fd.W); + if (input.foundationType === '말뚝기초' && f.bent?.part !== 1) { + // A bent footing lays its piles once over the unfolded length; each pile goes to the stretch of its side of the kink. + const len = f.bent ? f.bent.gTo - f.bent.gFrom : f.length; + const g = pileGeometry(len, fd.W); const counts = resolveRowCounts(input.pileRows, g); - piles.push(...pilesFor(f.s, input.footingBottomEl, pileLength, g, counts, fd.pileD).map((p) => ({ ...p, k: f.k }))); + const centre = f.bent ? (f.bent.gFrom + f.bent.gTo) / 2 : f.s; + piles.push(...pilesFor(centre, input.footingBottomEl, pileLength, g, counts, fd.pileD).map((p) => + f.bent && kinkG !== null && p.s > kinkG ? { ...p, s: p.s - kinkG, k: 1 } : { ...p, k: f.k })); } } } diff --git a/src/core/rebar/footing-units.ts b/src/core/rebar/footing-units.ts new file mode 100644 index 0000000..a965049 --- /dev/null +++ b/src/core/rebar/footing-units.ts @@ -0,0 +1,40 @@ +/** + * 확대기초 철근의 계산 단위 (2026-11-10). Pure. + * + * model.footings는 구간마다 한 기록이다. 꺾임점을 지나는 꺾인 기초(bent)는 두 기록(구간 0 · 1 조각)이지만 철근과 수량은 + * 하나의 기초로 계산한다: 편 좌표(g)의 길이 gTo − gFrom, 가운데 g, 두 구간의 기둥 모두. 철근표와 3D가 같은 단위를 쓴다. + * 3D는 편 좌표 철근을 coping-fold.ts로 꺾임점에서 접는다 (교축 철근은 미터점에서 꺾음). + */ +import type { PierModel } from '../geometry'; + +export interface FootingUnit { + /** Centre along the axis (m): the stretch's local s, or the unfolded g of a bent footing. */ + s: number; + k: number; + length: number; + width: number; + top: number; + bottom: number; + /** Columns on this footing, as x mm from its centre (for the B9 column-zone rule). */ + columnX: number[]; + /** True for one footing across the kink, in unfolded g (stretch 0 coordinates before folding). */ + bent: boolean; +} + +export function footingUnits(model: PierModel): FootingUnit[] { + const base1 = model.stretches[1]?.base ?? 0; + const gOf = (c: { s: number; k: number }) => (c.k === 0 ? c.s : c.s + base1); + const out: FootingUnit[] = []; + for (const f of model.footings) { + if (f.bent) { + if (f.bent.part === 1) continue; + const s = (f.bent.gFrom + f.bent.gTo) / 2, length = f.bent.gTo - f.bent.gFrom; + const columnX = model.columns.filter((c) => Math.abs(gOf(c) - s) <= length / 2).map((c) => (gOf(c) - s) * 1000); + out.push({ s, k: 0, length, width: f.width, top: f.top, bottom: f.bottom, columnX, bent: true }); + } else { + const columnX = model.columns.filter((c) => c.k === f.k && Math.abs(c.s - f.s) <= f.length / 2).map((c) => (c.s - f.s) * 1000); + out.push({ s: f.s, k: f.k, length: f.length, width: f.width, top: f.top, bottom: f.bottom, columnX, bent: false }); + } + } + return out; +} diff --git a/src/core/rebar/summary.ts b/src/core/rebar/summary.ts index edd953b..669339b 100644 --- a/src/core/rebar/summary.ts +++ b/src/core/rebar/summary.ts @@ -10,6 +10,7 @@ import { CAST_PENDING } from './cast-in-place'; import { castRebarLayout } from './cast-layout'; import { multicolCopingInfo } from './multicol-coping'; import { stepBlockBars } from './step-bars'; +import { footingUnits } from './footing-units'; import { columnRebar, columnInputFor } from './column'; import { bottomBars, crossBars, sideBars, assemblyBars, shearB9 } from './footing'; import { DEFAULT_MATERIAL, developmentLength, FckByMember, fyOf, isAssumedMaterial, Material, materialFor, NOMINAL_DIAMETER, splitPieces, STANDARD_BAR } from './common'; @@ -394,10 +395,9 @@ export function rebarSummary( // Per footing (2026-11-10): the columns of its own stretch only, and every bar length kept (a bent pier has footings // of different lengths; the longest no longer stands for all). const byName = new Map }>(); - for (const f of model.footings) { - // Columns on this footing, as x mm from the footing centre (for the B9 column-zone rule). - const columnX = model.columns.filter((c) => c.k === f.k && Math.abs(c.s - f.s) <= f.length / 2).map((c) => (c.s - f.s) * 1000); - for (const b of footingBarList(f.length * 1000, f.width * 1000, (f.top - f.bottom) * 1000, columnX, model.footingRebar)) { + // One unit per real footing: a bent footing across the kink is one footing in unfolded g (footing-units.ts). + for (const f of footingUnits(model)) { + for (const b of footingBarList(f.length * 1000, f.width * 1000, (f.top - f.bottom) * 1000, f.columnX, model.footingRebar)) { const cur = byName.get(b.name) ?? { first: b, lengths: new Map() }; cur.lengths.set(b.length, (cur.lengths.get(b.length) ?? 0) + b.count); byName.set(b.name, cur); diff --git a/src/view/rebar3d.ts b/src/view/rebar3d.ts index 33f5d6a..b665938 100644 --- a/src/view/rebar3d.ts +++ b/src/view/rebar3d.ts @@ -9,6 +9,7 @@ import { castRebarLayout } from '../core/rebar/cast-layout'; import { multicolCopingBars } from '../core/rebar/multicol-coping'; import { copingFold, foldBar } from '../core/rebar/coping-fold'; import { stepBlockBars } from '../core/rebar/step-bars'; +import { footingUnits } from '../core/rebar/footing-units'; import { barDepths, barsAcross } from '../core/rebar/bearing-block'; import { blockFramePoint } from '../core/geometry'; import { INTEGRATED_BLOCK } from '../core/integrated-block'; @@ -432,7 +433,17 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL, } } // Footing bars B1–B8 (교각축 · 교각축 직각), the grids from §3 and §6 of 확대기초철근 (1).md. - for (const f of m.footings) { + // One unit per real footing (footing-units.ts); a bent footing is drawn in unfolded g and folded at the mitre. + for (const f of footingUnits(m)) { + const fold = f.bent ? copingFold(m) : null; + const fBar = (name: string, a: [number, number, number], b: [number, number, number], dMm: number, color: number) => { + if (!fold) return addBar(name, f.k, a, b, dMm, color); + for (const p of foldBar(fold, [a, b]).pieces) for (let i = 1; i < p.pts.length; i++) addBar(name, p.k, p.pts[i - 1], p.pts[i], dMm, color); + }; + const fPath = (name: string, pts: [number, number, number][], dMm: number, color: number) => { + if (!fold) return addPath(name, f.k, pts, dMm, color); + for (const p of foldBar(fold, pts).pieces) addPath(name, p.k, p.pts, dMm, color); + }; const Lf = f.length; const Wf = f.width; const Hf = (f.top - f.bottom) * 1000; @@ -452,7 +463,7 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL, const off = legOffsets(); // One name, several cylinders: each segment of the path is one bar piece. const path = (name: string, pts: [number, number, number][], dMm: number) => { - addPath(name, f.k, pts, dMm, COLOR.footing); + fPath(name, pts, dMm, COLOR.footing); }; // B1–B4: along s, at W position n. legTo (absolute EL, m) is where both ends turn up or down (§5.1); undefined = straight. const along = (name: string, n: number, y: number, legTo: number | undefined, dMm: number) => { @@ -492,21 +503,21 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL, // Each hook tail (6·d) turns toward the footing centre along the pier axis (Q-B19). const dB9 = NOMINAL_DIAMETER.H16; const tail9 = (6 * dB9) / 1000; - const colX = m.columns.filter((c) => c.k === f.k && Math.abs(c.s - f.s) <= Lf / 2).map((c) => (c.s - f.s) * 1000); + const colX = f.columnX; const yB9Bot = bottom + mm(z.B24); const yB9Top = bottom + mm(z.B5); for (const b of b9Bars(Lf * 1000, Wf * 1000, colX, 2000, fs).bars) { const x = mm(b.x); const n = mm(b.n); const tailDir = x >= 0 ? -1 : 1; - addBar('B9', f.k, [f.s + x, yB9Bot, n], [f.s + x, yB9Top, n], dB9, COLOR.footing); - addBar('B9', f.k, [f.s + x, yB9Top, n], [f.s + x + tailDir * tail9, yB9Top, n], dB9, COLOR.footing); - addBar('B9', f.k, [f.s + x, yB9Bot, n], [f.s + x + tailDir * tail9, yB9Bot, n], dB9, COLOR.footing); + fBar('B9', [f.s + x, yB9Bot, n], [f.s + x, yB9Top, n], dB9, COLOR.footing); + fBar('B9', [f.s + x, yB9Top, n], [f.s + x + tailDir * tail9, yB9Top, n], dB9, COLOR.footing); + fBar('B9', [f.s + x, yB9Bot, n], [f.s + x + tailDir * tail9, yB9Bot, n], dB9, COLOR.footing); } // B10-1 / B10-2 (§7) at each B10 level, B11 / B12 (§8) between them. Ends turn 90° inward (B10) or down (B11, B12). const polyline = (name: string, pts: [number, number, number][], dMm: number) => { - addPath(name, f.k, pts, dMm, COLOR.footing); + fPath(name, pts, dMm, COLOR.footing); }; const hookB10 = mm(hookLength('main90', NOMINAL_DIAMETER.S19)); // 296 const hookAsm = mm(Math.round(hookLength('main90', NOMINAL_DIAMETER.S16))); // 246 diff --git a/tests/bent-coping.test.ts b/tests/bent-coping.test.ts index 18bccfe..d3c3341 100644 --- a/tests/bent-coping.test.ts +++ b/tests/bent-coping.test.ts @@ -134,12 +134,17 @@ describe('bent coping model (stage 2)', () => { expect(buildPier(bentInput('직접기초')).piles).toHaveLength(0); }); - it('2주형 bent with columns on both sides of the kink: separate footings, one per stretch', () => { + it('2주형 bent with columns on both sides of the kink: the overlapping footings become one bent footing (2026-11-10)', () => { const m = buildPier(bentInput('말뚝기초', '2주형', 10)); - expect(m.footings.map((f) => f.k)).toEqual([0, 1]); - expect(m.footings.every((f) => Math.abs(f.length - 10.4) < 1e-9)).toBe(true); - expect(m.piles).toHaveLength(40); - expect(m.piles.filter((p) => p.k === 1)).toHaveLength(20); + // The two 10.4 m footings overlap across the kink, so they are one bent footing, a part per stretch. + expect(m.footings.map((f) => [f.k, f.bent?.part])).toEqual([[0, 0], [1, 1]]); + // Piles are laid once over the unfolded footing: both stretches get some, and none overlap (they used to: 2 × 20). + expect(m.piles.some((p) => p.k === 0) && m.piles.some((p) => p.k === 1)).toBe(true); + const kink = m.stretches[1].base; + const g = m.piles.map((p) => ({ g: p.k === 0 ? p.s : p.s + kink, n: p.n })); + for (let i = 0; i < g.length; i++) for (let j = i + 1; j < g.length; j++) { + expect(Math.hypot(g[i].g - g[j].g, g[i].n - g[j].n)).toBeGreaterThan(1.0); + } const scene = buildPierScene(m); const footings = scene.parts.footings.children as THREE.Mesh[]; expect(footings).toHaveLength(2); diff --git a/tests/kink-steps.test.ts b/tests/kink-steps.test.ts index 9496e68..c09ca4a 100644 --- a/tests/kink-steps.test.ts +++ b/tests/kink-steps.test.ts @@ -10,6 +10,7 @@ import { multicolCopingInfo } from '../src/core/rebar/multicol-coping'; import { copingFold, inWedge, isTransverse } from '../src/core/rebar/coping-fold'; import { buildRebar3D } from '../src/view/rebar3d'; import { shearB9 } from '../src/core/rebar/footing'; +import { footingUnits } from '../src/core/rebar/footing-units'; import { loadP1 } from './helpers'; // 산외교 P11 (샘플01) with the coping bent 8.767° at IP4 (2026-11-10 검토): back ⑥ g 11.64, ⑦ g 18.49, kink g 15.14. @@ -210,35 +211,47 @@ describe('꺾인 코핑 3D 철근 (E, 2026-11-10)', () => { }, 60000); }); -describe('꺾인 교각의 확대기초 철근 (2026-11-10)', () => { - // 3주형 as in the project (산외교 P11): columns ① ② share one 18.05 m footing in stretch 0, ③ has a 10.4 m footing in stretch 1. +describe('꺾인 기초 (2026-11-10)', () => { + // 3주형 as in the project (산외교 P11): footings ① ② (−0.10 … 17.95) and ③ (15.20 … 25.60) overlap 2.75 m across the kink. const m = buildPier({ ...p11(), type: '3주형', columnCount: 3 }); + const kink = m.stretches[1].base; + const spread = (m.footings[0].width / 2) * Math.tan((8.767 * Math.PI) / 360); - it('each footing draws its rebar in its own stretch', () => { - const rb = buildRebar3D(m); - const footingNames = /^B\d/; - const ks = new Set(rb.records.filter((r) => footingNames.test(r.name)).map((r) => r.k)); - expect(m.footings.map((f) => f.k)).toEqual([0, 1]); - expect(ks).toEqual(new Set([0, 1])); - }, 60000); - - it('B9 leaves out only the column zones of the same stretch', () => { - const rows = rebarSummary(m).rows; - const expected = m.footings.reduce((sum, f) => { - const cols = m.columns.filter((c) => c.k === f.k && Math.abs(c.s - f.s) <= f.length / 2).map((c) => (c.s - f.s) * 1000); - return sum + shearB9(f.length * 1000, f.width * 1000, (f.top - f.bottom) * 1000, cols, 2000, m.footingRebar).count; - }, 0); - expect(rows.find((r) => r.name === 'B9')!.count).toBe(expected); + it('the two footings become one bent footing: each part reaches past the mitre spread, one unfolded extent', () => { + expect(m.footings.map((f) => [f.k, f.bent?.part])).toEqual([[0, 0], [1, 1]]); + const [p0, p1] = m.footings; + expect(p0.bent!.gFrom).toBeCloseTo(-0.1, 9); + expect(p0.bent!.gTo).toBeCloseTo(kink + m.columns[2].s + 5.2, 9); + expect(p0.s + p0.length / 2).toBeGreaterThanOrEqual(kink + spread); + expect(p1.s - p1.length / 2).toBeLessThanOrEqual(-spread); }); - it('footings of different lengths keep each length (no longest-for-all), spliced bars count both pieces', () => { - const rows = rebarSummary(m).rows.filter((r) => r.member === 'footing'); - expect(m.footings.map((f) => f.length)).toEqual([18.05, 10.4]); - const b5 = rows.find((r) => r.name === 'B5')!; - expect(b5.pieces!.length).toBe(2); - const total = b5.pieces!.reduce((s, p) => s + p.count, 0); - // The 18.05 m footing's bars are two pieces each; the 10.4 m footing's one piece. - expect(total).toBeGreaterThan(b5.count); + it('rebar is one footing of the unfolded length with all three columns (no double count of the overlap)', () => { + const units = footingUnits(m); + expect(units).toHaveLength(1); + expect(units[0].length).toBeCloseTo(m.footings[0].bent!.gTo - m.footings[0].bent!.gFrom, 9); + expect(units[0].columnX).toHaveLength(3); + const rows = rebarSummary(m).rows; + const u = units[0]; + expect(rows.find((r) => r.name === 'B9')!.count).toBe(shearB9(u.length * 1000, u.width * 1000, (u.top - u.bottom) * 1000, u.columnX, 2000, m.footingRebar).count); + }); + + it('3D draws the bent footing in both stretches, bars along the footing turning at the mitre', () => { + const rb = buildRebar3D(m); + const footing = rb.records.filter((r) => /^B\d/.test(r.name)); + expect(new Set(footing.map((r) => r.k))).toEqual(new Set([0, 1])); + // Every footing bar point stays within the unfolded footing (no part drawn past its own stretch end). + for (const r of footing) for (const p of r.pts) { + const g = r.k === 0 ? p[0] : p[0] + kink; + expect(g).toBeGreaterThan(-0.1 - spread - 1e-6); + expect(g).toBeLessThan(m.footings[0].bent!.gTo + spread + 1e-6); + } + }, 60000); + + it('footings apart across the kink stay separate (2주형 sample: 10.4 m footings 4.9 m apart)', () => { + const two = buildPier(p11()); + expect(two.footings.every((f) => f.bent === undefined)).toBe(true); + expect(footingUnits(two)).toHaveLength(2); }); });