From c91fe74d9a050f25f17cde0d136ea0989ee362fc Mon Sep 17 00:00:00 2001 From: ypshim Date: Mon, 26 Oct 2026 17:42:03 +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=EA=B5=90=EA=B0=81=EC=B6=95=20=EC=A7=81=EA=B0=81=20?= =?UTF-8?q?=EB=8B=A8=EB=A9=B4(=ED=8F=AD=20W)=20=EC=82=BD=EB=8F=84=201?= =?UTF-8?q?=EB=8B=A8=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - view/footing-section-svg.ts: B5–B8 선, B1–B4 점, B9 행 선, B10-1 측면 점, B11 선, B12 점을 계산 함수 결과로 그림 - 치수: 폭 W, 격자(e · n@s=G · e), 피복, B10 단 수 @ s_v, 높이 방향 축소 표시 - 풍선(B1–B5, B11, B2–B4, B10-1)을 누르면 그 철근만 강조, 다시 누르면 해제 - 테스트: 점·선 개수가 철근표 개수와 같음 (B1·B2·B4, B9 행, B10-1, B11 단, B12) Co-Authored-By: Claude Haiku 5.5 --- src/main.ts | 16 +++++ src/view/footing-section-svg.ts | 111 ++++++++++++++++++++++++++++++ tests/footing-section-svg.test.ts | 41 +++++++++++ 3 files changed, 168 insertions(+) create mode 100644 src/view/footing-section-svg.ts create mode 100644 tests/footing-section-svg.test.ts diff --git a/src/main.ts b/src/main.ts index c3a8930..9f34ac0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -32,6 +32,7 @@ import { COLUMN_REBAR_RANGES, type ColumnRebarSettings, columnSettingsIssues, DE import { columnInputFor, columnRebar } from './core/rebar/column'; import { DEFAULT_FOOTING_REBAR, FOOTING_REBAR_RANGES, type FootingRebarSettings, footingSettingsIssues } from './core/rebar/footing-settings'; import { footingRuleValues } from './core/rebar/footing'; +import { footingSectionSvg } from './view/footing-section-svg'; import { columnElevationSvg } from './view/column-elevation-svg'; import { type ColumnSection, columnSectionSvg } from './view/column-section-svg'; import { groundSectionSvg } from './view/ground-svg'; @@ -1372,9 +1373,24 @@ function renderFootingSettings(box: HTMLDivElement, pier: Pier): void { applyFootingRebar(pier.input.name, next); }); wrap.append(msg, apply); + // 교각축 직각 단면 (폭 W) (승인 2026-10-26, 추천안 1단계): a bubble click highlights that bar family; a second click clears it. + const sectionFig = el('figure', undefined, 'ground-fig'); + sectionFig.innerHTML = footingSectionSvg(pier.model); + sectionFig.appendChild(el('figcaption', '교각축 직각 단면 (폭 W) · 풍선을 누르면 그 철근만 강조 · 높이 방향 축소 · 다리 생략', 'muted small')); + sectionFig.querySelectorAll('[data-bubble]').forEach((g) => g.addEventListener('click', () => highlightFootingBar(sectionFig, g.dataset.bubble ?? ''))); + wrap.appendChild(sectionFig); box.appendChild(wrap); } +/** Highlights the marks of one bar family in a footing section (data-bar); the others dim. A second click on the same family clears it. */ +function highlightFootingBar(fig: HTMLElement, name: string): void { + const active = fig.dataset.active === name ? '' : name; + fig.dataset.active = active; + fig.querySelectorAll('[data-bar]').forEach((m) => { + m.style.opacity = active === '' || m.dataset.bar === active ? '1' : '0.2'; + }); +} + function renderCopingDrawings(box: HTMLDivElement, pier: Pier): void { if (!pier.model || !rebar3d) return; const L = pier.model.copingL; diff --git a/src/view/footing-section-svg.ts b/src/view/footing-section-svg.ts new file mode 100644 index 0000000..a50078f --- /dev/null +++ b/src/view/footing-section-svg.ts @@ -0,0 +1,111 @@ +import type { PierModel } from '../core/geometry'; +import { NOMINAL_DIAMETER } from '../core/rebar/common'; +import { assemblyJs, b10Levels, b9Bars, grid, gridPositions, layers, sideDepth } from '../core/rebar/footing'; +import { DEFAULT_FOOTING_REBAR } from '../core/rebar/footing-settings'; + +/** + * 확대기초 · 교각축 직각 단면 (폭 W) (승인 2026-10-26, 추천안 1단계). A cut across the footing width: horizontal = W, vertical = height + * from the 하면 (drawn compressed, 높이 방향 축소). Every mark comes from the same functions as the rebar table and the 3D view: + * - B5–B8 are lines (bars along W), B1–B4 are points on the grid along W, B9 are vertical lines per row, B10-1 are side points per level, + * - B11 are lines per assembly level, B12 are points on its W grid (every k·s). Legs are not drawn in this view. + * Each mark carries data-bar="<철근 번호>" so a bubble can highlight its bars (철근표 강조와 같은 방식). + */ +const COLOR = { outline: '#55606E', axis: '#0F5C66', b1: '#0F5C66', b5: '#6B3FA0', b9: '#2E7D32', b10: '#B4531F', b11: '#1F2937', b12: '#7C3AED', dim: '#B42318' }; +const W_PX = 400; +const X0 = 60; +const Y_BOTTOM = 200; +const SCALE_Y = 0.8; + +export function footingSectionSvg(model: PierModel): string { + 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 kX = W_PX / W; + const kY = kX * SCALE_Y; + const cx = X0 + W_PX / 2; + const x = (mm: number) => cx + mm * kX; + const y = (z: number) => Y_BOTTOM - z * kY; + const z = layers(H, fs); + const parts: string[] = []; + const line = (cls: string, bar: string, x1: number, x2: number, yy: number, color: string, width = 1.4) => + ``; + const dot = (cls: string, bar: string, xx: number, yy: number, color: string, r = 2.6) => + ``; + const vline = (cls: string, bar: string, xx: number, y1: number, y2: number, color: string) => + ``; + const text = (xx: number, yy: number, s: string, anchor = 'start', fill = COLOR.outline) => + `${s}`; + + // Footing outline (W × H) and the 하면 / 상면 levels. + parts.push(``); + parts.push(line('base', '', x(-W / 2), x(W / 2), y(0), COLOR.outline, 1)); + + // B5–B8: lines along W between the covers. B5 at the top, B6 and B8 at the 하면 1단, B7 at the 하면 2단. + const xa = x(-W / 2 + fs.cSide); + const xb = x(W / 2 - fs.cSide); + parts.push(line('b5', 'B5', xa, xb, y(z.B5), COLOR.b5, 1.6)); + parts.push(line('b5', 'B6', xa, xb, y(z.B68), COLOR.b5, 1.2)); + parts.push(line('b5', 'B7', xa, xb, y(z.B7), COLOR.b5, 1.2)); + parts.push(line('b5', 'B8', xa, xb, y(z.B68), COLOR.b5, 1.2)); + + // B1–B4 on the grid along W (교각축, 점): odd grid positions carry B1, B2 and B3, even carry B4 (same parity as bottomBars). + const xs = gridPositions(W, fs, 'sAxis'); + xs.forEach((mm, i) => { + const xx = x(mm); + if (i % 2 === 1) { + parts.push(dot('b1', 'B1', xx, y(z.B1), COLOR.b1)); + parts.push(dot('b2', 'B2', xx, y(z.B24), COLOR.axis)); + parts.push(dot('b3', 'B3', xx, y(z.B3), COLOR.axis)); + } else { + parts.push(dot('b4', 'B4', xx, y(z.B24), COLOR.axis)); + } + }); + + // B9: one vertical line per row along W (the rows of b9Bars). + const b9 = b9Bars(L, W, [], 2000, fs); + for (const n of [...new Set(b9.bars.map((b) => b.n))]) parts.push(vline('b9', 'B9', x(n), y(z.B24), y(z.B5), COLOR.b9)); + + // B10-1: side points at both W faces, one per level (9 levels). + const levels = b10Levels(H, fs); + const dB1 = sideDepth(NOMINAL_DIAMETER.S29, NOMINAL_DIAMETER.S19, fs); // B10-1 depth from the W face, as in the table and 3D + for (let j = 0; j < levels; j++) { + const zj = fs.cBottom + j * fs.sV; + parts.push(dot('b10', 'B10-1', x(-W / 2 + dB1), y(zj), COLOR.b10, 2.2)); + parts.push(dot('b10', 'B10-1', x(W / 2 - dB1), y(zj), COLOR.b10, 2.2)); + } + + // B11: lines per assembly level (along L, shown as a line in this section). B12: points on its W grid, every k·s, 2-step stagger per level. + const js = assemblyJs(H, fs); + const dB10 = NOMINAL_DIAMETER.S19; + js.forEach((j, idx) => { + const zA = fs.cBottom + j * fs.sV; + parts.push(line('b11', 'B11', xa, xb, y(zA), COLOR.b11, 1.2)); + const i0 = idx % 2 === 0 ? 0 : 2; + const xsB12 = gridPositions(W, fs, 'sAxis'); + for (let i = i0; i < xsB12.length; i += fs.kB12) parts.push(dot('b12', 'B12', x(xsB12[i]), y(zA + dB10), COLOR.b12, 2.2)); + }); + + // Dimensions: width, grid, covers, and the B10 levels. + const g = grid(W, fs, 'sAxis'); + const yDim = Y_BOTTOM + 20; + parts.push(text(cx, 16, `교각축 직각 단면 · 폭 ${W.toFixed(0)}`, 'middle', '#1F2937')); + parts.push(text(X0, yDim, `${fs.cSide}`, 'start', COLOR.dim)); + parts.push(text(cx, yDim, `${g.e.toFixed(1)} · ${g.n}@${fs.sAxis}=${g.G} · ${g.e.toFixed(1)}`, 'middle', COLOR.dim)); + parts.push(text(X0 + W_PX, yDim, `${fs.cSide}`, 'end', COLOR.dim)); + const xr = X0 + W_PX + 14; + parts.push(text(xr, y(H) + 10, `${fs.cTop}`, 'start', COLOR.dim)); + parts.push(text(xr, (y(H) + y(0)) / 2, `${levels - 1}@${fs.sV}=${(levels - 1) * fs.sV}`, 'start', COLOR.dim)); + parts.push(text(xr, y(0) - 4, `${fs.cBottom}`, 'start', COLOR.dim)); + parts.push(text(X0, y(0) + 36, `높이 방향 축소 (×${SCALE_Y}) · 다리 생략 · 기준: 확대기초 하면 0`, 'start', COLOR.outline)); + + // Bubbles: one per bar family, linked to its marks through data-bar (click to highlight, in the panel). + const bubbles: [string, number, number][] = [['B5', xa + 6, 24], ['B1', x(xs[1]), 40], ['B11', xb - 6, 56], ['B2', x(xs[1]), y(0) + 40], ['B4', x(xs[2]), y(0) + 40], ['B3', x(xs[3]), y(0) + 40], ['B10-1', X0 - 16, y(fs.cBottom) - 6]]; + for (const [name, bx, by] of bubbles) { + parts.push(`${name}`); + } + + return `${parts.join('')}`; +} diff --git a/tests/footing-section-svg.test.ts b/tests/footing-section-svg.test.ts new file mode 100644 index 0000000..0277f9d --- /dev/null +++ b/tests/footing-section-svg.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { buildPier } from '../src/core/geometry'; +import { assemblyBars, assemblyJs, b9Bars, bottomBars, crossBars, sideBars } from '../src/core/rebar/footing'; +import { DEFAULT_FOOTING_REBAR } from '../src/core/rebar/footing-settings'; +import { footingSectionSvg } from '../src/view/footing-section-svg'; +import { loadP1 } from './helpers'; + +const countMark = (svg: string, cls: string, bar: string) => svg.split(`class="${cls}" data-bar="${bar}"`).length - 1; + +describe('확대기초 교각축 직각 단면 (폭 W) (승인 2026-10-26, 추천안 1단계)', () => { + const m = buildPier(loadP1()); + const foot = m.footings[0]; + const L = foot.length * 1000; + const W = foot.width * 1000; + const H = (foot.top - foot.bottom) * 1000; + const fs = DEFAULT_FOOTING_REBAR; + const svg = footingSectionSvg(m); + + it('point and line counts match the rebar table families', () => { + const bottom = bottomBars(L, W, H, fs); + expect(countMark(svg, 'b1', 'B1')).toBe(bottom.find((b) => b.name === 'B1')!.count); + expect(countMark(svg, 'b2', 'B2')).toBe(bottom.find((b) => b.name === 'B2')!.count); + expect(countMark(svg, 'b4', 'B4')).toBe(bottom.find((b) => b.name === 'B4')!.count); + expect(countMark(svg, 'b9', 'B9')).toBe(b9Bars(L, W, [], 2000, fs).rows); + expect(countMark(svg, 'b10', 'B10-1')).toBe(sideBars(L, W, H, fs).find((b) => b.name === 'B10-1')!.count); + expect(countMark(svg, 'b11', 'B11')).toBe(assemblyJs(H, fs).length); + expect(countMark(svg, 'b12', 'B12')).toBe(assemblyBars(L, W, H, fs).find((b) => b.name === 'B12')!.count); + for (const name of ['B5', 'B6', 'B7', 'B8']) expect(countMark(svg, 'b5', name)).toBe(1); + expect(crossBars(L, W, H, fs).length).toBe(4); + }); + + it('shows the width, the grid string, and the B10 levels as dimensions', () => { + expect(svg).toContain('폭 8150'); + expect(svg).toContain('8@200=1600'); + expect(svg).toContain(`${fs.cBottom}`); + }); + + it('has one bubble per highlighted bar family', () => { + for (const name of ['B1', 'B2', 'B3', 'B4', 'B5', 'B11', 'B10-1']) expect(svg).toContain(`data-bubble="${name}"`); + }); +});