From 0e9d89951aea62169fd0268b3fcf6fa87b752d9d Mon Sep 17 00:00:00 2001 From: ypshim Date: Tue, 27 Oct 2026 13:56:05 +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=EB=8B=A8=EB=A9=B4(?= =?UTF-8?q?=EA=B8=B8=EC=9D=B4=20L)=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 - view/footing-section-l-svg.ts: 길이 방향 단면 (B1–B4 점, B5–B8 선, B9 세로선, B10-2 점·B10-1 선, B11 점, B12 선) - 탭 전환: 교각축 직각 단면(폭 W) ↔ 교각축 단면(길이 L), 다리 배치 상세·B10 모서리는 다음 단계 - 테스트: 점·선 개수가 철근표 개수와 같음 Co-Authored-By: Claude Haiku 5.5 --- src/main.ts | 42 +++++--- src/view/footing-section-l-svg.ts | 144 ++++++++++++++++++++++++++++ tests/footing-section-l-svg.test.ts | 40 ++++++++ 3 files changed, 211 insertions(+), 15 deletions(-) create mode 100644 src/view/footing-section-l-svg.ts create mode 100644 tests/footing-section-l-svg.test.ts diff --git a/src/main.ts b/src/main.ts index f801ae0..3a5e61c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -33,6 +33,7 @@ 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 { footingSectionLSvg } from './view/footing-section-l-svg'; import { columnElevationSvg } from './view/column-elevation-svg'; import { type ColumnSection, columnSectionSvg } from './view/column-section-svg'; import { groundSectionSvg } from './view/ground-svg'; @@ -1373,25 +1374,36 @@ function renderFootingSettings(box: HTMLDivElement, pier: Pier): void { applyFootingRebar(pier.input.name, next); }); wrap.append(msg, apply); - // 확대기초 단면 탭 (승인 2026-10-26, 추천안 2단계): 첫 탭 "교각축 직각 단면 (폭 W)"만 작동하고, 나머지 탭은 다음 단계 자리만 둡니다. + // 확대기초 단면 탭 (승인 2026-10-26): 교각축 직각 단면 (폭 W) and 교각축 단면 (길이 L) work; the other tabs are next steps. + const footingModel = pier.model; + let footingView: 'W' | 'L' = 'W'; const tabs = el('div'); tabs.style.display = 'flex'; tabs.style.flexWrap = 'wrap'; tabs.style.gap = '6px'; - for (const [label, enabled] of [['교각축 직각 단면 (폭 W)', true], ['교각축 단면 (길이 L)', false], ['다리 배치 상세', false], ['B10 모서리', false]] as const) { - const b = el('button', label, 'btn small'); - b.type = 'button'; - b.setAttribute('aria-pressed', String(enabled)); - b.disabled = !enabled; - if (!enabled) b.title = '다음 단계에서 추가'; - tabs.appendChild(b); - } - wrap.appendChild(tabs); - // 교각축 직각 단면 (폭 W): 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')); - wrap.appendChild(sectionFig); + 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) { + 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'; + drawFootingFigure(); + }); + tabs.appendChild(b); + } + figBox.replaceChildren(); + 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')); + figBox.appendChild(sectionFig); + }; + drawFootingFigure(); + wrap.append(tabs, figBox); box.appendChild(wrap); } diff --git a/src/view/footing-section-l-svg.ts b/src/view/footing-section-l-svg.ts new file mode 100644 index 0000000..fb6be40 --- /dev/null +++ b/src/view/footing-section-l-svg.ts @@ -0,0 +1,144 @@ +import type { PierModel } from '../core/geometry'; +import { NOMINAL_DIAMETER } from '../core/rebar/common'; +import { assemblyGridIndices, assemblyJs, b10Levels, b9Bars, grid, gridPositions, layers, sideDepth } from '../core/rebar/footing'; +import { DEFAULT_FOOTING_REBAR } from '../core/rebar/footing-settings'; + +/** + * 확대기초 · 교각축 단면 (길이 L) (승인 2026-10-27, 2단계 탭). A cut across the footing length: horizontal = L, vertical = height from the 하면. + * Bars that run along W cross this plane as points; bars that run along L lie in it as lines (방향 교환 후 배치). + * - B1–B4 (along W, spaced along L): points at the L grid (odd → B1·B2·B3, even → B4) + * - B5–B8 (along L): one line per family from cover to cover + * - B9: vertical lines at the L grid columns + * - B10-2 (on the L faces): points per level (lowest removed); B10-1 (on the W faces, along L): lines per level (lowest removed) + * - B11 (along W, spaced along L): points on the L grid per assembly level; B12 (along L, spaced along W): lines per assembly level + * Every mark comes from the same functions as the rebar table and the 3D view. + */ +const COLOR = { outline: '#55606E', b5: '#0F5C66', b14: '#B4531F', b9: '#6B3FA0', b10: '#2E7D32', b11: '#55606E', b12: '#55606E', dim: '#B42318', column: '#B08D57', pile: '#8898aa' }; +const L_PX = 380; +const X0 = 60; +const Y0 = 230; + +export function footingSectionLSvg(model: PierModel): string { + const foot = model.footings[0]; + if (!foot) return '확대기초 없음'; + const fs = model.footingRebar ?? DEFAULT_FOOTING_REBAR; + const L = foot.length * 1000; + const H = (foot.top - foot.bottom) * 1000; + const k = L_PX / L; + const cx = X0 + L_PX / 2; + const x = (mm: number) => cx + mm * k; + const y = (z: number) => Y0 - z * k; + 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, dash = '') => + ``; + 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}`; + const tick = (xx: number, yy: number) => ``; + + // Context: the column (centred, above the footing) and the piles (below, one stub per distinct L position). + const colD = model.columnDiameterMm; + parts.push(``); + const piles = model.piles.filter((p) => Math.abs(p.s - foot.s) <= foot.length / 2 + 1e-6); + for (const s of [...new Set(piles.map((p) => Math.round((p.s - foot.s) * 1000)))]) { + const d = (piles.find((p) => Math.round((p.s - foot.s) * 1000) === s)?.diameter ?? 0.6) * 1000; + parts.push(``); + } + + // Footing outline and the 하면 line. + parts.push(``); + parts.push(line('base', '', x(-L / 2), x(L / 2), y(0), COLOR.outline, 1)); + + // Cover lines on the L faces (측면 피복 c_s). + const xa = x(-L / 2 + fs.cSide); + const xb = x(L / 2 - fs.cSide); + + // B5–B8 (along L): one line per family from cover to cover. + parts.push(line('b5', 'B5', xa, xb, y(z.B5), COLOR.b5, 1.6)); + parts.push(line('b6', 'B6', xa, xb, y(z.B68), COLOR.b5, 1.2)); + parts.push(line('b7', 'B7', xa, xb, y(z.B7), COLOR.b5, 1.2)); + parts.push(line('b8', 'B8', xa, xb, y(z.B68), COLOR.b5, 1.2)); + + // B1–B4 (along W, spaced along L): points on the L grid. Odd positions carry B1, B2, B3; even carry B4 (as bottomBars). + const xsL = gridPositions(L, fs, 'sL'); + xsL.forEach((mm, i) => { + const xx = x(mm); + if (i % 2 === 1) { + parts.push(dot('b1', 'B1', xx, y(z.B1), COLOR.b14)); + parts.push(dot('b2', 'B2', xx, y(z.B24), COLOR.b14)); + parts.push(dot('b3', 'B3', xx, y(z.B3), COLOR.b14)); + } else { + parts.push(dot('b4', 'B4', xx, y(z.B24), COLOR.b14)); + } + }); + + // B9: one vertical line per L column of the B9 grid. + const b9 = b9Bars(L, foot.width * 1000, [], 2000, fs); + for (const xm of [...new Set(b9.bars.map((b) => b.x))]) parts.push(vline('b9', 'B9', x(xm), y(z.B24), y(z.B5), COLOR.b9)); + + // B10-2 (on the L faces, points) and B10-1 (on the W faces, lines along L), per level above the lowest. + const levels = b10Levels(H, fs); + const dB10 = NOMINAL_DIAMETER.S19; + const depth2 = sideDepth(NOMINAL_DIAMETER.S25, NOMINAL_DIAMETER.S19, fs); + for (let j = 1; j < levels; j++) { + const zj = fs.cBottom + j * fs.sV; + parts.push(dot('b10', 'B10-2', x(-L / 2 + depth2), y(zj), COLOR.b10, 2.2)); + parts.push(dot('b10', 'B10-2', x(L / 2 - depth2), y(zj), COLOR.b10, 2.2)); + parts.push(line('b10', 'B10-1', x(-L / 2 + depth2), x(L / 2 - depth2), y(zj + dB10), COLOR.b10, 1.2, '2 2')); + } + + // B11 (along W, spaced along L): points on the L grid, per assembly level. B12 (along L, spaced along W): lines per level. + const js = assemblyJs(H, fs); + const nL = grid(L, fs, 'sL').positions - 1; + js.forEach((j, idx) => { + const zA = fs.cBottom + j * fs.sV; + for (const i of assemblyGridIndices(nL, fs.kB11, idx)) parts.push(dot('b11', 'B11', x(xsL[i]), y(zA), COLOR.b11, 2.2)); + parts.push(line('b12', 'B12', xa, xb, y(zA + dB10), COLOR.b12, 1.2, '6 3')); + }); + + // Dimensions: the length line with ticks and the grid string, and the right-hand heights. + const g = grid(L, fs, 'sL'); + const yDim = Y0 + 40; + parts.push(``); + parts.push(tick(x(-L / 2), yDim) + tick(x(L / 2), yDim)); + parts.push(text(x(-L / 2) - 5, yDim + 14, `${fs.cSide}`, 'end', COLOR.dim)); + parts.push(text(x(L / 2) + 5, yDim + 14, `${fs.cSide}`, 'start', COLOR.dim)); + parts.push(text(x(-L / 2 + fs.cSide) + 2, yDim + 14, `${g.e.toFixed(1)}`, 'start', COLOR.dim)); + parts.push(text(x(L / 2 - fs.cSide) - 2, yDim + 14, `${g.e.toFixed(1)}`, 'end', COLOR.dim)); + parts.push(text(cx + 150, yDim - 6, `${g.n}@${fs.sL}=${g.G}`, 'middle', COLOR.dim)); + parts.push(text(cx, 20, `교각축 단면 · 길이 ${L.toFixed(0)}`, 'middle', '#1F2937')); + const xr = X0 + L_PX + 18; + parts.push(``); + parts.push(tick(xr, y(H)) + tick(xr, y(0))); + parts.push(text(xr + 6, y(H) + 12, `${fs.cTop}`, 'start', COLOR.dim)); + parts.push(text(xr + 6, (y(H) + y(0)) / 2, `${levels - 1}@${fs.sV}=${(levels - 1) * fs.sV}`, 'start', COLOR.dim)); + parts.push(text(xr + 6, y(0) - 4, `${fs.cBottom}`, 'start', COLOR.dim)); + + // Bubbles outside the outline with a leader to a point of their family. + const bubble = (name: string, bx: number, by: number, tx: number, ty: number, dotColor: string) => { + const edgeY = by < ty ? by + 11 : by - 11; + return `${name}`; + }; + const yTop = 40; + const zB11 = js.length > 0 ? fs.cBottom + js[0] * fs.sV : fs.cBottom; + const zB12 = zB11 + dB10; + parts.push(bubble('B5', xa + 12, yTop, xa + 12, y(z.B5), COLOR.b5)); + parts.push(bubble('B12', xb - 40, yTop, xb, y(zB12), COLOR.b12)); + parts.push(bubble('B11', xb - 12, yTop + 0, x(xsL[xsL.length - 2]), y(zB11), COLOR.b11)); + parts.push(bubble('B10-2', X0 - 18, y(fs.cBottom + (levels - 1) * fs.sV / 2), x(-L / 2 + depth2), y(fs.cBottom + (levels - 1) * fs.sV / 2), COLOR.b10)); + + // Legend and units note. + const yLeg = 352; + parts.push(`${text(50, yLeg + 3, '주철근 (B5–B8)', 'start', COLOR.outline)}`); + parts.push(`${text(188, yLeg + 3, '직각·보조 (B1–B4)', 'start', COLOR.outline)}`); + parts.push(`${text(338, yLeg + 3, '전단·띠 (B9)', 'start', COLOR.outline)}`); + parts.push(`${text(28, yLeg + 25, '측면 수평 (B10-1 · B10-2)', 'start', COLOR.outline)}`); + parts.push(`${text(280, yLeg + 25, '조립 (B11·B12)', 'start', COLOR.outline)}`); + parts.push(text(20, yLeg + 48, '치수 mm · 높이 방향은 실제 비율 · 다리 생략 · 기준: 확대기초 하면 0', 'start', COLOR.outline)); + + return `${parts.join('')}`; +} diff --git a/tests/footing-section-l-svg.test.ts b/tests/footing-section-l-svg.test.ts new file mode 100644 index 0000000..6a958df --- /dev/null +++ b/tests/footing-section-l-svg.test.ts @@ -0,0 +1,40 @@ +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 { footingSectionLSvg } from '../src/view/footing-section-l-svg'; +import { loadP1 } from './helpers'; + +const countMark = (svg: string, cls: string, bar: string) => svg.split(`class="${cls}" data-bar="${bar}"`).length - 1; + +describe('확대기초 교각축 단면 (길이 L) (승인 2026-10-27)', () => { + 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 = footingSectionLSvg(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, 'b10', 'B10-2')).toBe(sideBars(L, W, H, fs).find((b) => b.name === 'B10-2')!.count); + expect(countMark(svg, 'b10', 'B10-1')).toBe(sideBars(L, W, H, fs).find((b) => b.name === 'B10-1')!.count / 2); + expect(countMark(svg, 'b11', 'B11')).toBe(assemblyBars(L, W, H, fs).find((b) => b.name === 'B11')!.count); + expect(countMark(svg, 'b12', 'B12')).toBe(assemblyJs(H, fs).length); + expect(svg.split('class="b9"').length - 1).toBe(new Set(b9Bars(L, W, [], 2000, fs).bars.map((b) => b.x)).size); + expect(crossBars(L, W, H, fs).length).toBe(4); + }); + + it('shows the length, the grid string, and the B10 levels as dimensions', () => { + expect(svg).toContain('길이 10400'); + expect(svg).toContain('8@200=1600'); + }); + + it('has bubbles for B5, B11, B12 and B10-2', () => { + for (const name of ['B5', 'B11', 'B12', 'B10-2']) expect(svg).toContain(`data-bubble="${name}"`); + }); +});