From 6c7a7ff781512c6c9ada71072b132230e63109c1 Mon Sep 17 00:00:00 2001 From: ypshim Date: Mon, 26 Oct 2026 18:02:51 +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=A8=EB=A9=B4=20=EC=82=BD=EB=8F=84=202?= =?UTF-8?q?=EB=8B=A8=EA=B3=84=20(=EB=B2=94=EB=A1=80=C2=B7=EC=84=A0=20?= =?UTF-8?q?=EC=8A=A4=ED=83=80=EC=9D=BC=C2=B7=EC=B9=98=EC=88=98=EC=84=A0?= =?UTF-8?q?=C2=B7=EB=8B=A4=EB=A6=AC=20=EC=A3=BC=EC=84=9D=C2=B7=EA=B8=B0?= =?UTF-8?q?=EB=91=A5=C2=B7=EB=A7=90=EB=9A=9D=C2=B7=EC=8B=A4=EC=A0=9C=20?= =?UTF-8?q?=EB=B9=84=EC=9C=A8=C2=B7=ED=83=AD=204=EA=B0=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 선 스타일을 삽도에 맞춤: B5–B8 청록 실선, B1–B4 주황 점, B9 보라 세로선, B10 녹색 점, B11 회색 파선, B12 회색 점 - 범례 5항목 (선 모양별) - 하단 치수선(피복 · 격자 e · n@s=G · e)과 우측 높이 치수선(상면 피복 · B10 단 · 하면 피복), 화살 끝 표시 - B6 다리 주석 "B6 다리 → B5 높이 (값)" - 기둥 블록(상부)과 말뚝 머리(하부)를 맥락 그림으로 표시 - 높이 방향을 실제 비율로 그림 (축소 없음) - 탭 4개 중 "교각축 직각 단면 (폭 W)"만 작동, 나머지는 다음 단계 자리로 비활성 Co-Authored-By: Claude Haiku 5.5 --- src/main.ts | 18 ++++- src/view/footing-section-svg.ts | 124 +++++++++++++++++++----------- tests/footing-section-svg.test.ts | 7 ++ 3 files changed, 104 insertions(+), 45 deletions(-) diff --git a/src/main.ts b/src/main.ts index 9f34ac0..f930b85 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1373,10 +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. + // 확대기초 단면 탭 (승인 2026-10-26, 추천안 2단계): 첫 탭 "교각축 직각 단면 (폭 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')); + 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); diff --git a/src/view/footing-section-svg.ts b/src/view/footing-section-svg.ts index 99b1bc7..4f8ecc9 100644 --- a/src/view/footing-section-svg.ts +++ b/src/view/footing-section-svg.ts @@ -4,118 +4,156 @@ import { assemblyGridIndices, assemblyJs, b10Levels, b9Bars, grid, gridPositions 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. + * 확대기초 · 교각축 직각 단면 (폭 W) (승인 2026-10-26, 추천안 1·2단계). A cut across the footing width: horizontal = W, vertical = height + * from the 하면 at true scale. Every mark comes from the same functions as the rebar table and the 3D view: + * - B5–B8 teal lines (bars along W), B1–B4 orange points on the grid along W, B9 purple lines per row, + * - B10-1 green points at both W faces per level, B11 grey dashed lines per assembly level, B12 grey points on its W grid. + * Legs are not drawn; the B6 leg length is noted in the figure. The column and the piles are shown as context. * 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 COLOR = { + outline: '#55606E', + b5: '#0F5C66', + b14: '#B4531F', + b9: '#6B3FA0', + b10: '#2E7D32', + b11: '#55606E', + b12: '#55606E', + dim: '#B42318', + column: '#B08D57', + pile: '#8898aa', +}; +const W_PX = 380; const X0 = 60; -const Y_BOTTOM = 200; -const SCALE_Y = 0.8; +/** Height of the 하면 (z = 0) in the view; the figure is drawn at true scale (kY = kX). */ +const Y0 = 230; export function footingSectionSvg(model: PierModel): string { const foot = model.footings[0]; - if (!foot) return '확대기초 없음'; + 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 kY = kX; const cx = X0 + W_PX / 2; const x = (mm: number) => cx + mm * kX; - const y = (z: number) => Y_BOTTOM - z * kY; + const y = (z: number) => Y0 - 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 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) => ``; - // Dimension text has a white halo (paint-order stroke) so that leader lines passing under it stay readable (도면 글자 가독성). + // Dimension and note text has a white halo (paint-order stroke) so that leader lines under it stay readable. const text = (xx: number, yy: number, s: string, anchor = 'start', fill = COLOR.outline) => `${s}`; + const tick = (xx: number, yy: number) => ``; - // Footing outline (W × H) and the 하면 / 상면 levels. + // Context: the column (centred on W, above the footing) and the piles (below, one stub per distinct W 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 n of [...new Set(piles.map((p) => Math.round(p.n * 1000)))]) { + const d = (piles.find((p) => Math.round(p.n * 1000) === n)?.diameter ?? 0.6) * 1000; + parts.push(``); + } + + // Footing outline (W × H) and the 하면 line. 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단. + // B5–B8: teal 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)); + // B6 leg note: the leg runs from the 하면 1단 up to the B5 height (B6 다리 → B5 높이). + parts.push(text(cx, (y(z.B5) + y(z.B68)) / 2 - 3, `B6 다리 → B5 높이 ${(z.B5 - z.B68).toFixed(0)}`, 'middle', COLOR.b5)); - // B1–B4 on the grid along W (교각축, 점): odd grid positions carry B1, B2 and B3, even carry B4 (same parity as bottomBars). + // B1–B4 on the grid along W (교각축, orange points): odd grid positions carry B1, B2 and B3; even carry B4 (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)); + 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.axis)); + parts.push(dot('b4', 'B4', xx, y(z.B24), COLOR.b14)); } }); - // B9: one vertical line per row along W (the rows of b9Bars). + // B9: one purple 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). + // B10-1: green points at both W faces, one per level (9 levels), at the side depth of the table. 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 + const dB1 = sideDepth(NOMINAL_DIAMETER.S29, NOMINAL_DIAMETER.S19, fs); 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. + // B11: grey dashed lines per assembly level. B12: grey points on its W grid, every k·s, on the even grid (1단계 규칙). 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)); + parts.push(line('b11', 'B11', xa, xb, y(zA), COLOR.b11, 1.2, '6 3')); const xsB12 = gridPositions(W, fs, 'sAxis'); for (const i of assemblyGridIndices(xsB12.length - 1, fs.kB12, idx)) parts.push(dot('b12', 'B12', x(xsB12[i]), y(zA + dB10), COLOR.b12, 2.2)); }); - // Dimensions: width, grid, covers, and the B10 levels. + // Dimensions: the width line with ticks and the grid string, and the right-hand heights. 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, 292, `높이 방향 축소 (×${SCALE_Y}) · 다리 생략 · 기준: 확대기초 하면 0`, 'start', COLOR.outline)); + const yDim = Y0 + 34; + parts.push(``); + parts.push(tick(x(-W / 2), yDim) + tick(x(W / 2), yDim)); + parts.push(text(x(-W / 2) + 4, yDim + 14, `${fs.cSide}`, 'start', COLOR.dim)); + parts.push(text(x(-W / 2 + fs.cSide + g.e / 2), yDim + 14, `${g.e.toFixed(1)}`, 'middle', COLOR.dim)); + parts.push(text(cx, yDim + 14, `${g.n}@${fs.sAxis}=${g.G}`, 'middle', COLOR.dim)); + parts.push(text(x(W / 2 - fs.cSide - g.e / 2), yDim + 14, `${g.e.toFixed(1)}`, 'middle', COLOR.dim)); + parts.push(text(x(W / 2) - 4, yDim + 14, `${fs.cSide}`, 'end', COLOR.dim)); + parts.push(text(cx, yDim - 6, `교각축 직각 단면 · 폭 ${W.toFixed(0)}`, 'middle', '#1F2937')); + const xr = X0 + W_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, spread apart, each with a leader to its bar (the leader carries the same data-bar, so it highlights too). const bubble = (name: string, bx: number, by: number, tx: number, ty: number) => { const edgeY = by < ty ? by + 11 : by - 11; return `${name}`; }; - const yTop = 52; - const yBot = y(0) + 50; - parts.push(bubble('B5', xa + 150, yTop, xa + 150, y(z.B5))); - parts.push(bubble('B1', x(xs[1]) + 30, yTop, x(xs[1]), y(z.B1))); + const yTop = 40; + const yBot = Y0 + 66; const zB11 = js.length > 0 ? fs.cBottom + js[0] * fs.sV : fs.cBottom; - parts.push(bubble('B11', xb - 30, yTop, xb, y(zB11))); + parts.push(bubble('B5', xa + 12, yTop, xa + 12, y(z.B5))); + parts.push(bubble('B1', x(xs[1]) + 60, yTop, x(xs[1]), y(z.B1))); + parts.push(bubble('B11', xb - 20, yTop, xb, y(zB11))); parts.push(bubble('B10-1', X0 - 18, y(fs.cBottom + (levels - 1) * fs.sV / 2), X0 + 2, y(fs.cBottom + (levels - 1) * fs.sV / 2))); parts.push(bubble('B2', cx - 70, yBot, x(xs[1]), y(z.B24))); parts.push(bubble('B4', cx, yBot, x(xs[2]), y(z.B24))); parts.push(bubble('B3', cx + 70, yBot, x(xs[3]), y(z.B3))); - return `${parts.join('')}`; + // Legend (선 모양별, 삽도 기준) and the units note. + const yLeg = 330; + 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 · 높이 방향은 실제 비율 · 다리 생략 (B6 다리는 주석) · 기준: 확대기초 하면 0', 'start', COLOR.outline)); + + return `${parts.join('')}`; } diff --git a/tests/footing-section-svg.test.ts b/tests/footing-section-svg.test.ts index 8e5dd11..ad417b0 100644 --- a/tests/footing-section-svg.test.ts +++ b/tests/footing-section-svg.test.ts @@ -58,4 +58,11 @@ describe('확대기초 교각축 직각 단면 (폭 W) (승인 2026-10-26, 추 } } }); + + it('2단계: B6 다리 주석 1723, 범례 5항목, 기둥과 말뚝 맥락 표시, 탭 4개 중 첫 탭만 작동', () => { + expect(svg).toContain('B6 다리 → B5 높이 1723'); + for (const label of ['주철근 (B5–B8)', '직각·보조 (B1–B4)', '전단·띠 (B9)', '측면 수평 (B10-1 · B10-2)', '조립 (B11·B12)']) expect(svg).toContain(label); + expect(svg).toContain('class="column"'); + expect(svg.split('class="pile"').length - 1).toBeGreaterThan(0); + }); });