From ab04c19afbcd78b0fa053962a867dd3bd0826ddd Mon Sep 17 00:00:00 2001 From: ypshim Date: Tue, 27 Oct 2026 14:09:13 +0900 Subject: [PATCH] =?UTF-8?q?feat(rebar):=20=ED=99=95=EB=8C=80=EA=B8=B0?= =?UTF-8?q?=EC=B4=88=20=EB=8B=A4=EB=A6=AC=20=EB=B0=B0=EC=B9=98=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=ED=83=AD=EA=B3=BC=20B10=20=EB=AA=A8=EC=84=9C?= =?UTF-8?q?=EB=A6=AC=20=ED=83=AD=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - core/rebar/footing-legs.ts: 번호별 다리 형태·길이·끝 높이·이음 여부 (계산 함수 결과 사용) - view/footing-corner-svg.ts: B10-2 → B10-1 모서리 갈고리, 깊이 122.25 / 123.85, l_h 296, 최하단 제외 표시 - 패널 탭 4개 모두 작동 (B10 모서리는 개략도, 단면 비율 아님) - 테스트: 12개 철근, B2 다리 1723, B6 이음 필요, 모서리 값 Co-Authored-By: Claude Haiku 5.5 --- src/core/rebar/footing-legs.ts | 50 ++++++++++++++++++++++++++++++++++ src/main.ts | 26 ++++++++++++++---- src/view/footing-corner-svg.ts | 47 ++++++++++++++++++++++++++++++++ tests/footing-legs.test.ts | 34 +++++++++++++++++++++++ 4 files changed, 152 insertions(+), 5 deletions(-) create mode 100644 src/core/rebar/footing-legs.ts create mode 100644 src/view/footing-corner-svg.ts create mode 100644 tests/footing-legs.test.ts diff --git a/src/core/rebar/footing-legs.ts b/src/core/rebar/footing-legs.ts new file mode 100644 index 0000000..43ee76a --- /dev/null +++ b/src/core/rebar/footing-legs.ts @@ -0,0 +1,50 @@ +import type { PierModel } from '../geometry'; +import { hookLength, NOMINAL_DIAMETER } from './common'; +import { assemblyBars, bottomBars, crossBars, layers, legB2, legLength, sideBars } from './footing'; +import { DEFAULT_FOOTING_REBAR } from './footing-settings'; + +/** One row of the 다리 배치 상세 tab (확대기초철근 (1).md §5–§8). Lengths in mm, heights in mm from the 하면. */ +export interface FootingLegRow { + bar: string; + size: string; + shape: string; + legLength: number | null; + legTop: number | null; + total: number; + splice: boolean; +} + +const STANDARD = 12000; + +/** Leg of every footing bar family: where it turns and how long the leg is (12d rule, B2/B6 to the B1/B5 height). */ +export function footingLegRows(model: PierModel): FootingLegRow[] { + const foot = model.footings[0]; + if (!foot) return []; + const fs = model.footingRebar ?? DEFAULT_FOOTING_REBAR; + const L = foot.length * 1000; + const W = foot.width * 1000; + const H = (foot.top - foot.bottom) * 1000; + const z = layers(H, fs); + const lenOf = (name: string, list: { name: string; length: number }[]) => list.find((b) => b.name === name)!.length; + const bottom = bottomBars(L, W, H, fs); + const cross = crossBars(L, W, H, fs); + const side = sideBars(L, W, H, fs); + const asm = assemblyBars(L, W, H, fs); + const row = (bar: string, size: string, shape: string, leg: number | null, legTop: number | null, total: number): FootingLegRow => + ({ bar, size, shape, legLength: leg, legTop, total, splice: total > STANDARD }); + const d = (size: keyof typeof NOMINAL_DIAMETER) => NOMINAL_DIAMETER[size]; + return [ + row('B1', 'S25', 'ㅡ 다리 아래로 (측면 피복 c_s)', legLength('S25'), z.B1 - legLength('S25'), lenOf('B1', bottom)), + row('B2', 'S25', '⊔ 다리 B1 높이까지', legB2(H, fs), z.B1, lenOf('B2', bottom)), + row('B3', 'S19', '⊔ 다리 위로', legLength('S19'), z.B3 + legLength('S19'), lenOf('B3', bottom)), + row('B4', 'S25', '직선', null, null, lenOf('B4', bottom)), + row('B5', 'S29', '⊓ 다리 아래로 (측면 피복 c_s)', legLength('S29'), z.B5 - legLength('S29'), lenOf('B5', cross)), + row('B6', 'S29', '⊔ 다리 B5 높이까지', z.B5 - z.B68, z.B5, lenOf('B6', cross)), + row('B7', 'S19', '⊔ 다리 위로', legLength('S19'), z.B7 + legLength('S19'), lenOf('B7', cross)), + row('B8', 'S29', '직선', null, null, lenOf('B8', cross)), + row('B10-1', 'S19', '양끝 90° 갈고리 (옆 면으로)', hookLength('main90', d('S19')), null, lenOf('B10-1', side)), + row('B10-2', 'S19', '양끝 90° 갈고리 (옆 면으로)', hookLength('main90', d('S19')), null, lenOf('B10-2', side)), + row('B11', 'S16', 'U 양끝 아래로', Math.round(hookLength('main90', d('S16'))), null, lenOf('B11', asm)), + row('B12', 'S16', 'U 양끝 아래로', Math.round(hookLength('main90', d('S16'))), null, lenOf('B12', asm)), + ]; +} diff --git a/src/main.ts b/src/main.ts index 3a5e61c..056ba3e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -34,6 +34,8 @@ import { DEFAULT_FOOTING_REBAR, FOOTING_REBAR_RANGES, type FootingRebarSettings, import { footingRuleValues } from './core/rebar/footing'; import { footingSectionSvg } from './view/footing-section-svg'; import { footingSectionLSvg } from './view/footing-section-l-svg'; +import { footingCornerSvg } from './view/footing-corner-svg'; +import { footingLegRows } from './core/rebar/footing-legs'; import { columnElevationSvg } from './view/column-elevation-svg'; import { type ColumnSection, columnSectionSvg } from './view/column-section-svg'; import { groundSectionSvg } from './view/ground-svg'; @@ -1376,7 +1378,7 @@ function renderFootingSettings(box: HTMLDivElement, pier: Pier): void { wrap.append(msg, apply); // 확대기초 단면 탭 (승인 2026-10-26): 교각축 직각 단면 (폭 W) and 교각축 단면 (길이 L) work; the other tabs are next steps. const footingModel = pier.model; - let footingView: 'W' | 'L' = 'W'; + let footingView: 'W' | 'L' | 'D' | 'B10' = 'W'; const tabs = el('div'); tabs.style.display = 'flex'; tabs.style.flexWrap = 'wrap'; @@ -1384,22 +1386,36 @@ function renderFootingSettings(box: HTMLDivElement, pier: Pier): void { const figBox = el('div'); const drawFootingFigure = (): void => { tabs.replaceChildren(); - for (const [view, label, enabled] of [['W', '교각축 직각 단면 (폭 W)', true], ['L', '교각축 단면 (길이 L)', true], ['D', '다리 배치 상세', false], ['B10', 'B10 모서리', false]] as const) { + for (const [view, label, enabled] of [['W', '교각축 직각 단면 (폭 W)', true], ['L', '교각축 단면 (길이 L)', true], ['D', '다리 배치 상세', true], ['B10', 'B10 모서리', true]] as const) { const b = el('button', label, 'btn small'); b.type = 'button'; b.setAttribute('aria-pressed', String(view === footingView)); b.disabled = !enabled; if (!enabled) b.title = '다음 단계에서 추가'; if (enabled) b.addEventListener('click', () => { - footingView = view as 'W' | 'L'; + footingView = view as 'W' | 'L' | 'D' | 'B10'; drawFootingFigure(); }); tabs.appendChild(b); } figBox.replaceChildren(); + if (footingView === 'D') { + // 다리 배치 상세 (승인 2026-10-27): leg shape, leg length and leg top per bar; splices where the bar exceeds the 12000 standard. + const table = el('table', undefined, 'grid-table'); + for (const h of ['번호', '규격', '다리 형태', '다리 길이 (mm)', '다리 끝 높이 (하면 기준, mm)', '전체 길이 (mm)', '이음']) table.appendChild(el('th', h)); + for (const r of footingLegRows(footingModel)) { + const tr = el('tr'); + tr.append(el('td', r.bar), el('td', r.size), el('td', r.shape), el('td', r.legLength === null ? '—' : r.legLength.toFixed(0)), el('td', r.legTop === null ? '—' : r.legTop.toFixed(0)), el('td', r.total.toFixed(0)), el('td', r.splice ? '겹침 필요 (12000 초과)' : '—')); + table.appendChild(tr); + } + figBox.appendChild(table); + figBox.appendChild(el('p', '다리 길이는 12d 기준 (Q-B5 확인 필요). B11·B12 갈고리는 l_h 246.', 'muted small')); + return; + } const sectionFig = el('figure', undefined, 'ground-fig'); - sectionFig.innerHTML = footingView === 'W' ? footingSectionSvg(footingModel) : footingSectionLSvg(footingModel); - sectionFig.appendChild(el('figcaption', footingView === 'W' ? '교각축 직각 단면 (폭 W) · 실제 비율 · 다리 생략' : '교각축 단면 (길이 L) · 실제 비율 · 다리 생략', 'muted small')); + const captions = { W: '교각축 직각 단면 (폭 W) · 실제 비율 · 다리 생략', L: '교각축 단면 (길이 L) · 실제 비율 · 다리 생략', B10: 'B10 모서리 · 개략도 (단면 비율 아님)' } as const; + sectionFig.innerHTML = footingView === 'W' ? footingSectionSvg(footingModel) : footingView === 'L' ? footingSectionLSvg(footingModel) : footingCornerSvg(footingModel); + sectionFig.appendChild(el('figcaption', captions[footingView], 'muted small')); figBox.appendChild(sectionFig); }; drawFootingFigure(); diff --git a/src/view/footing-corner-svg.ts b/src/view/footing-corner-svg.ts new file mode 100644 index 0000000..d7c7d5f --- /dev/null +++ b/src/view/footing-corner-svg.ts @@ -0,0 +1,47 @@ +import type { PierModel } from '../core/geometry'; +import { NOMINAL_DIAMETER, hookLength } from '../core/rebar/common'; +import { b10Levels, layers, sideDepth } from '../core/rebar/footing'; +import { DEFAULT_FOOTING_REBAR } from '../core/rebar/footing-settings'; + +/** + * 확대기초 · B10 모서리 (plan corner, 확대기초철근 (1).md §7, 승인 2026-10-27). Schematic of the corner where the B10-2 bar (L face) + * bends 90° into the B10-1 bar (W face), with the depths and the 19.1 offset from the table. Lowest level removed (B2·B4 take its place). + */ +const COLOR = { outline: '#55606E', b10: '#2E7D32', b14: '#B4531F', dim: '#B42318' }; + +export function footingCornerSvg(model: PierModel): string { + const foot = model.footings[0]; + const fs = model.footingRebar ?? DEFAULT_FOOTING_REBAR; + if (!foot) return '확대기초 없음'; + const H = (foot.top - foot.bottom) * 1000; + const z = layers(H, fs); + const levels = b10Levels(H, fs); + const depth2 = sideDepth(NOMINAL_DIAMETER.S25, NOMINAL_DIAMETER.S19, fs); + const depth1 = sideDepth(NOMINAL_DIAMETER.S29, NOMINAL_DIAMETER.S19, fs); + const hook = hookLength('main90', NOMINAL_DIAMETER.S19); + const k = 1 / 6; + const ox = 120; + const oy = 230; + const cornerX = ox; + const cornerY = oy; + const parts: string[] = []; + const text = (xx: number, yy: number, s: string, anchor = 'start', fill = COLOR.outline) => + `${s}`; + // Plan corner: the L face (horizontal) and the W face (vertical) of the footing. + parts.push(``); + parts.push(``); + // B10-2 (L face) at depth2 from the L face, bending 90° at the corner into the B10-1 (W face) at depth1 from the W face. + const xL = cornerX + depth2 * k; + const yW = cornerY - depth1 * k; + parts.push(``); + parts.push(``); + // B2·B4 at the 하면 1단 (replace the lowest B10 level): the orange bars along the corner. + parts.push(``); + parts.push(text(cornerX + 6, cornerY + 14, `B10-2 ${depth2.toFixed(2)} (L면)`, 'start', COLOR.b10)); + parts.push(text(cornerX + 6, cornerY - 210, `B10-1 ${depth1.toFixed(2)} (W면)`, 'start', COLOR.b10)); + parts.push(text(cornerX + 120, cornerY + 14, `모서리 갈고리 l_h ${hook.toFixed(0)}`, 'start', COLOR.dim)); + parts.push(text(cornerX + 120, cornerY + 28, `B10-1 = B10-2 + 19.1 (높이)`, 'start', COLOR.dim)); + parts.push(text(cornerX + 120, cornerY + 42, `최하단 B10 제외 · 하면 1단 B2·B4가 대체 (z ${z.B24.toFixed(0)})`, 'start', COLOR.outline)); + parts.push(text(cornerX + 120, cornerY + 56, `단 수 ${levels - 1} (B10 9단 중 최하단 제외)`, 'start', COLOR.outline)); + return `${parts.join('')}`; +} diff --git a/tests/footing-legs.test.ts b/tests/footing-legs.test.ts new file mode 100644 index 0000000..f1be639 --- /dev/null +++ b/tests/footing-legs.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { buildPier } from '../src/core/geometry'; +import { footingLegRows } from '../src/core/rebar/footing-legs'; +import { footingCornerSvg } from '../src/view/footing-corner-svg'; +import { loadP1 } from './helpers'; + +describe('확대기초 다리 배치 상세 · B10 모서리 (승인 2026-10-27)', () => { + const m = buildPier(loadP1()); + const rows = footingLegRows(m); + const byBar = (name: string) => rows.find((r) => r.bar === name)!; + + it('lists the 12 bar families of the footing', () => { + expect(rows.map((r) => r.bar)).toEqual(['B1', 'B2', 'B3', 'B4', 'B5', 'B6', 'B7', 'B8', 'B10-1', 'B10-2', 'B11', 'B12']); + }); + + it('B2 leg goes up to the B1 height (1723); straight bars have no leg', () => { + expect(byBar('B2').legLength).toBeCloseTo(1723, 0); + expect(byBar('B4').legLength).toBeNull(); + expect(byBar('B8').legLength).toBeNull(); + }); + + it('B6 (13646) needs a splice, B1 (under 12000) does not', () => { + expect(byBar('B6').splice).toBe(true); + expect(byBar('B1').splice).toBe(false); + }); + + it('B10 corner shows the depths, the 296 hook, and the lowest level removed', () => { + const svg = footingCornerSvg(m); + expect(svg).toContain('B10-2 122.25'); + expect(svg).toContain('B10-1 123.85'); + expect(svg).toContain('l_h 296'); + expect(svg).toContain('단 수 8'); + }); +});