feat(rebar): 확대기초 단면 삽도 2단계 (범례·선 스타일·치수선·다리 주석·기둥·말뚝·실제 비율·탭 4개)

- 선 스타일을 삽도에 맞춤: 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 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-10-26 18:02:51 +09:00
1 parent bad220d791
commit 6c7a7ff781
3 files changed
+104 -45

No files matched your search

+16 -2
View File
@@ -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<SVGGElement>('[data-bubble]').forEach((g) => g.addEventListener('click', () => highlightFootingBar(sectionFig, g.dataset.bubble ?? '')));
wrap.appendChild(sectionFig);
box.appendChild(wrap);
+81 -43
View File
@@ -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 '<svg viewBox="0 0 520 260" width="520" height="260" role="img" aria-label="확대기초 없음"><text x="20" y="130" font-size="12">확대기초 없음</text></svg>';
if (!foot) return '<svg viewBox="0 0 560 360" width="560" height="360" role="img" aria-label="확대기초 없음"><text x="20" y="180" font-size="12">확대기초 없음</text></svg>';
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) =>
`<line class="${cls}" data-bar="${bar}" x1="${x1.toFixed(1)}" x2="${x2.toFixed(1)}" y1="${yy.toFixed(1)}" y2="${yy.toFixed(1)}" stroke="${color}" stroke-width="${width}"/>`;
const line = (cls: string, bar: string, x1: number, x2: number, yy: number, color: string, width = 1.4, dash = '') =>
`<line class="${cls}" data-bar="${bar}" x1="${x1.toFixed(1)}" x2="${x2.toFixed(1)}" y1="${yy.toFixed(1)}" y2="${yy.toFixed(1)}" stroke="${color}" stroke-width="${width}"${dash ? ` stroke-dasharray="${dash}"` : ''}/>`;
const dot = (cls: string, bar: string, xx: number, yy: number, color: string, r = 2.6) =>
`<circle class="${cls}" data-bar="${bar}" cx="${xx.toFixed(1)}" cy="${yy.toFixed(1)}" r="${r}" fill="${color}"/>`;
const vline = (cls: string, bar: string, xx: number, y1: number, y2: number, color: string) =>
`<line class="${cls}" data-bar="${bar}" x1="${xx.toFixed(1)}" x2="${xx.toFixed(1)}" y1="${y1.toFixed(1)}" y2="${y2.toFixed(1)}" stroke="${color}" stroke-width="1"/>`;
// 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) =>
`<text x="${xx.toFixed(1)}" y="${yy.toFixed(1)}" font-size="10" font-family="JetBrains Mono, monospace" fill="${fill}" stroke="#fff" stroke-width="3" paint-order="stroke" text-anchor="${anchor}">${s}</text>`;
const tick = (xx: number, yy: number) => `<line x1="${(xx - 3).toFixed(1)}" x2="${(xx + 3).toFixed(1)}" y1="${(yy + 3).toFixed(1)}" y2="${(yy - 3).toFixed(1)}" stroke="${COLOR.dim}" stroke-width="0.8"/>`;
// 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(`<rect class="column" x="${x(-colD / 2).toFixed(1)}" y="${y(H) - 36}" width="${(colD * kX).toFixed(1)}" height="36" fill="#F2E8D5" stroke="${COLOR.column}" stroke-width="0.8"/>`);
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(`<rect class="pile" x="${(x(n) - d * kX / 2).toFixed(1)}" y="${Y0}" width="${Math.max(3, d * kX).toFixed(1)}" height="26" fill="${COLOR.pile}" stroke="#3b4656" stroke-width="0.6"/>`);
}
// Footing outline (W × H) and the 하면 line.
parts.push(`<rect x="${x(-W / 2).toFixed(1)}" y="${y(H).toFixed(1)}" width="${(W * kX).toFixed(1)}" height="${(H * kY).toFixed(1)}" fill="none" stroke="${COLOR.outline}" stroke-width="1.2"/>`);
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(`<line x1="${x(-W / 2).toFixed(1)}" x2="${x(W / 2).toFixed(1)}" y1="${yDim}" y2="${yDim}" stroke="${COLOR.dim}" stroke-width="0.8"/>`);
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(`<line x1="${xr}" x2="${xr}" y1="${y(H).toFixed(1)}" y2="${y(0).toFixed(1)}" stroke="${COLOR.dim}" stroke-width="0.8"/>`);
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 `<g class="bubble" data-bubble="${name}" style="cursor:pointer"><line class="leader" data-bar="${name}" x1="${bx.toFixed(1)}" x2="${tx.toFixed(1)}" y1="${edgeY.toFixed(1)}" y2="${ty.toFixed(1)}" stroke="${COLOR.outline}" stroke-width="0.7"/><circle cx="${bx.toFixed(1)}" cy="${by.toFixed(1)}" r="11" fill="#fff" stroke="${COLOR.outline}"/><text x="${bx.toFixed(1)}" y="${(by + 3).toFixed(1)}" font-size="9" text-anchor="middle" fill="${COLOR.outline}">${name}</text></g>`;
};
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 `<svg viewBox="0 0 520 300" width="520" height="300" role="img" aria-label="확대기초 교각축 직각 단면">${parts.join('')}</svg>`;
// Legend (선 모양별, 삽도 기준) and the units note.
const yLeg = 330;
parts.push(`<line x1="20" x2="44" y1="${yLeg}" y2="${yLeg}" stroke="${COLOR.b5}" stroke-width="1.6"/>${text(50, yLeg + 3, '주철근 (B5–B8)', 'start', COLOR.outline)}`);
parts.push(`<circle cx="180" cy="${yLeg}" r="2.6" fill="${COLOR.b14}"/>${text(188, yLeg + 3, '직각·보조 (B1–B4)', 'start', COLOR.outline)}`);
parts.push(`<line x1="330" x2="330" y1="${yLeg - 8}" y2="${yLeg + 8}" stroke="${COLOR.b9}" stroke-width="1"/>${text(338, yLeg + 3, '전단·띠 (B9)', 'start', COLOR.outline)}`);
parts.push(`<circle cx="20" cy="${yLeg + 22}" r="2.2" fill="${COLOR.b10}"/>${text(28, yLeg + 25, '측면 수평 (B10-1 · B10-2)', 'start', COLOR.outline)}`);
parts.push(`<line x1="250" x2="274" y1="${yLeg + 22}" y2="${yLeg + 22}" stroke="${COLOR.b11}" stroke-width="1.2" stroke-dasharray="6 3"/>${text(280, yLeg + 25, '조립 (B11·B12)', 'start', COLOR.outline)}`);
parts.push(text(20, yLeg + 48, '치수 mm · 높이 방향은 실제 비율 · 다리 생략 (B6 다리는 주석) · 기준: 확대기초 하면 0', 'start', COLOR.outline));
return `<svg viewBox="0 0 560 360" width="560" height="360" role="img" aria-label="확대기초 교각축 직각 단면">${parts.join('')}</svg>`;
}
+7
View File
@@ -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);
});
});