feat(abut-rebar): 교대 벽체 배근 삽도 (교대축 직각 단면 · 계단 단면)
- 배근 규칙 경로로 그림: 구간 1 단면 (W1–W7 · W9 · W10, 단 치수), 계단 단면 (W1 · W5 · W9 · W10 · W11 · W8, 구간 높이) - 번호 풍선 · 선택 강조 · 선택만 보기는 저판 삽도와 같은 함수 (공용으로 export) - verify/dump: 삽도 SVG 덤프 전용 설정 (기본 테스트에 포함 안 됨) Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
66e7d4d90d
commit
079748e5e7
5 files changed
+176
-8
No files matched your search
@@ -6,26 +6,26 @@
|
||||
import type { AbutFootingGeom, AbutFootingRebar } from '../core/abut/rebar/footing';
|
||||
|
||||
export const ABUT_FOOTING_COLORS = { main: '#0F5C66', dist: '#B4531F', side: '#2E7D32', shear: '#6B3FA0' } as const;
|
||||
const INK = '#18212b';
|
||||
const MUTED = '#55606e';
|
||||
const CONC = '#eef1f4';
|
||||
const WALL = '#d9dee4';
|
||||
export const INK = '#18212b';
|
||||
export const MUTED = '#55606e';
|
||||
export const CONC = '#eef1f4';
|
||||
export const WALL = '#d9dee4';
|
||||
|
||||
const text = (x: number, y: number, s: string, anchor = 'middle', fill = INK, size = 11) =>
|
||||
export const text = (x: number, y: number, s: string, anchor = 'middle', fill = INK, size = 11) =>
|
||||
`<text x="${x.toFixed(1)}" y="${y.toFixed(1)}" text-anchor="${anchor}" font-size="${size}" fill="${fill}" font-family="JetBrains Mono, monospace">${s}</text>`;
|
||||
|
||||
/** Selected bars (철근표 번호) and 선택만 보기. An empty selection shows every bar. */
|
||||
export type AbutRebarPick = { marks: ReadonlySet<string>; only: boolean };
|
||||
const NO_PICK: AbutRebarPick = { marks: new Set(), only: false };
|
||||
export const NO_PICK: AbutRebarPick = { marks: new Set(), only: false };
|
||||
|
||||
/** Opacity of one bar group: dimmed (or hidden with 선택만 보기) when other bars are selected. */
|
||||
function opacity(pick: AbutRebarPick, marks: string[]): number {
|
||||
export function opacity(pick: AbutRebarPick, marks: string[]): number {
|
||||
if (pick.marks.size === 0) return 1;
|
||||
return marks.some((m) => pick.marks.has(m)) ? 1 : pick.only ? 0 : 0.15;
|
||||
}
|
||||
|
||||
/** A clickable 번호 풍선. */
|
||||
function balloon(x: number, y: number, mark: string, color: string, pick: AbutRebarPick): string {
|
||||
export function balloon(x: number, y: number, mark: string, color: string, pick: AbutRebarPick): string {
|
||||
// The B10 풍선 stands for B10-1 and B10-2.
|
||||
const on = [...pick.marks].some((m) => m === mark || (mark === 'B10' && m.startsWith('B10')));
|
||||
const r = mark.length > 3 ? 15 : 12;
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* 교대 벽체 배근 삽도 (2026-11-04, 시안 ③ 벽체 카드, 교대벽체철근.md §9): 교대축 직각 단면 (구간 1), 교대축 단면 (계단).
|
||||
* 순수 문자열 함수. 철근은 배근 규칙의 경로(wall.ts paths)에서 그린다. 번호 풍선(data-pick)과 선택은 저판 삽도와 같다.
|
||||
* 색: 연직 #0F5C66, 수평 #B4531F, 간격재 #6B3FA0, 계단 상면 W9 · W10 #C0501A (W10 파선).
|
||||
*/
|
||||
import type { AbutWallGeom, AbutWallRebar, WallBarPath, WallMark } from '../core/abut/rebar/wall';
|
||||
import { balloon, CONC, MUTED, NO_PICK, opacity, text, WALL, type AbutRebarPick } from './abut-footing-rebar-svg';
|
||||
|
||||
export const ABUT_WALL_COLORS = { vertical: '#0F5C66', horizontal: '#B4531F', tie: '#6B3FA0', step: '#C0501A' } as const;
|
||||
const C = ABUT_WALL_COLORS;
|
||||
const COLOR: Record<WallMark, string> = {
|
||||
W1: C.vertical, W3: C.vertical, W4: C.vertical, W11: C.vertical,
|
||||
W2: C.horizontal, W5: C.horizontal, W6: C.horizontal, W8: C.horizontal,
|
||||
W7: C.tie, W9: C.step, W10: C.step,
|
||||
};
|
||||
const DASH: Partial<Record<WallMark, string>> = { W4: '6 3', W7: '3 3', W10: '6 3' };
|
||||
|
||||
/** 교대축 직각 단면 at 구간 1 (IP3 끝): t 가로 (왼쪽 배면 → 오른쪽 전면), z 세로. */
|
||||
export function abutWallSectionSvg(g: AbutWallGeom, r: AbutWallRebar, pick: AbutRebarPick = NO_PICK): string {
|
||||
const seg = g.segments.find((s) => s.no === 1) ?? g.segments[0];
|
||||
const uMid = (seg.from + seg.to) / 2;
|
||||
const k = 0.1;
|
||||
const x0 = 70;
|
||||
const zTop = g.HF + seg.H;
|
||||
const zMax = Math.max(zTop + r.ld.H19, g.bracketTop[0], g.bracketTop[1]) + 150;
|
||||
const X = (t: number) => x0 + (t - g.xb) * k;
|
||||
const Y = (z: number) => 30 + (zMax - z) * k;
|
||||
const parts: string[] = [];
|
||||
const pad = 600;
|
||||
// 저판 (위 부분) · 벽체 · 교좌면 뒤 흉벽 · 브라켓 (점선).
|
||||
parts.push(`<rect x="${X(g.xb - pad)}" y="${Y(g.HF)}" width="${(g.xf - g.xb + 2 * pad) * k}" height="${g.HF * k}" fill="${CONC}" stroke="${MUTED}"/>`);
|
||||
parts.push(`<rect x="${X(g.xb)}" y="${Y(zTop)}" width="${(g.xf - g.xb) * k}" height="${seg.H * k}" fill="${CONC}" stroke="${MUTED}"/>`);
|
||||
const bt = Math.min(g.bracketTop[0], g.bracketTop[1]);
|
||||
parts.push(`<rect x="${X(g.xb)}" y="${Y(bt)}" width="${-g.xb * k}" height="${(bt - zTop) * k}" fill="${WALL}" stroke="${MUTED}" stroke-dasharray="4 3"/>`);
|
||||
parts.push(text((X(g.xb) + X(0)) / 2, Y(bt) - 6, '흉벽 · 브라켓', 'middle', MUTED, 10));
|
||||
parts.push(text((X(0) + X(g.xf)) / 2, Y(zTop) - 6, `교좌면 (구간 1)`, 'middle', MUTED, 10));
|
||||
const line = (p: WallBarPath, w = 1.6) =>
|
||||
`<polyline points="${p.pts.map(([, t, z]) => `${X(t).toFixed(1)},${Y(z).toFixed(1)}`).join(' ')}" fill="none" stroke="${COLOR[p.mark]}" stroke-width="${w}"${DASH[p.mark] ? ` stroke-dasharray="${DASH[p.mark]}"` : ''} opacity="${opacity(pick, [p.mark])}"/>`;
|
||||
const dot = (p: WallBarPath) => {
|
||||
const [, t, z] = p.pts[0];
|
||||
return `<circle cx="${X(t).toFixed(1)}" cy="${Y(z).toFixed(1)}" r="2.4" fill="${COLOR[p.mark]}" opacity="${opacity(pick, [p.mark])}"/>`;
|
||||
};
|
||||
// Bars across the section (in the t-z plane): the first of each mark in 구간 1.
|
||||
const first = (mark: WallMark) => r.paths.find((p) => p.mark === mark && p.pts[1][0] >= seg.from && p.pts[1][0] < seg.to);
|
||||
for (const mark of ['W7', 'W1', 'W3', 'W4', 'W2'] as WallMark[]) {
|
||||
if (mark === 'W7') {
|
||||
for (const p of r.paths.filter((q) => q.mark === 'W7' && Math.abs(q.pts[1][0] - (first('W7')?.pts[1][0] ?? NaN)) < 1e-6)) parts.push(line(p, 1.1));
|
||||
} else {
|
||||
const p = first(mark);
|
||||
if (p) parts.push(line(p, mark === 'W4' ? 1.3 : 1.8));
|
||||
}
|
||||
}
|
||||
// Bars along the axis that pass the middle of 구간 1: dots.
|
||||
const along = r.paths.filter((p) => ['W5', 'W6', 'W9', 'W10'].includes(p.mark) && Math.min(p.pts[0][0], p.pts[1][0]) <= uMid && Math.max(p.pts[0][0], p.pts[1][0]) >= uMid);
|
||||
for (const p of along) parts.push(dot(p));
|
||||
// 치수: 전면 수평 단 (구간 1), 상면 W9 간격.
|
||||
const lv = r.levels.find((l) => l.no === seg.no);
|
||||
if (lv) {
|
||||
const xd = X(g.xf) + 14;
|
||||
parts.push(`<line x1="${xd}" y1="${Y(g.HF)}" x2="${xd}" y2="${Y(zTop)}" stroke="${MUTED}"/>`);
|
||||
parts.push(`<text x="${xd + 6}" y="${(Y(g.HF) + Y(zTop)) / 2}" font-size="10.5" fill="${MUTED}" font-family="JetBrains Mono, monospace" transform="rotate(-90 ${xd + 6} ${(Y(g.HF) + Y(zTop)) / 2})" text-anchor="middle">${r.settings.c} · ${lv.text}</text>`);
|
||||
}
|
||||
if (r.x.W9.length > 1) parts.push(text((X(r.x.W9[0]) + X(r.x.W9[r.x.W9.length - 1])) / 2, Y(zTop) + 22, `W9 · W10 ${r.x.W9.length - 1}@${Math.round(r.x.W9[0] - r.x.W9[1])}`, 'middle', C.step, 10));
|
||||
// 번호 풍선.
|
||||
const yMid = Y(g.HF + seg.H / 2);
|
||||
parts.push(balloon(X(g.xf) + 60, yMid - 40, 'W1', C.vertical, pick), balloon(X(g.xf) + 60, yMid + 10, 'W5', C.horizontal, pick));
|
||||
parts.push(balloon(X(g.xb) - 40, yMid - 60, 'W3', C.vertical, pick), balloon(X(g.xb) - 40, yMid - 10, 'W4', C.vertical, pick), balloon(X(g.xb) - 40, yMid + 40, 'W6', C.horizontal, pick));
|
||||
parts.push(balloon((X(0) + X(g.xf)) / 2 - 20, Y(zTop) - 34, 'W2', C.horizontal, pick), balloon((X(0) + X(g.xf)) / 2 + 20, Y(zTop) - 34, 'W9', C.step, pick), balloon((X(0) + X(g.xf)) / 2 + 60, Y(zTop) - 34, 'W10', C.step, pick));
|
||||
parts.push(balloon((X(g.xb) + X(g.xf)) / 2, Y(g.HF + 300), 'W7', C.tie, pick));
|
||||
const W = X(g.xf + pad) + 40;
|
||||
const H = Y(0) + 10;
|
||||
return `<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" style="max-width:${Math.round(W * 1.4)}px" role="img" aria-label="벽체 교대축 직각 단면 (구간 1)">${parts.join('')}</svg>`;
|
||||
}
|
||||
|
||||
/** 교대축 단면 (계단): u 가로 (왼쪽 IP3 = 구간 1 → 오른쪽 IP1, 시안과 같은 좌우), z 세로. 전면 철근 (W1 · W5) · 계단 상면 · 끝면. */
|
||||
export function abutWallElevationSvg(g: AbutWallGeom, r: AbutWallRebar, pick: AbutRebarPick = NO_PICK): string {
|
||||
const k = 0.045;
|
||||
const kz = 0.08;
|
||||
const x0 = 70;
|
||||
const zMax = g.HF + Math.max(...g.segments.map((s) => s.H)) + 300;
|
||||
const X = (u: number) => x0 + (g.L - u) * k;
|
||||
const Y = (z: number) => 56 + (zMax - z) * kz;
|
||||
const parts: string[] = [];
|
||||
parts.push(`<rect x="${X(g.L)}" y="${Y(g.HF)}" width="${g.L * k}" height="${g.HF * kz}" fill="${CONC}" stroke="${MUTED}"/>`);
|
||||
let outline = `M${X(0).toFixed(1)},${Y(g.HF).toFixed(1)}`;
|
||||
for (const s of g.segments) outline += ` L${X(s.from).toFixed(1)},${Y(g.HF + s.H).toFixed(1)} L${X(s.to).toFixed(1)},${Y(g.HF + s.H).toFixed(1)}`;
|
||||
outline += ` L${X(g.L).toFixed(1)},${Y(g.HF).toFixed(1)} Z`;
|
||||
parts.push(`<path d="${outline}" fill="${CONC}" stroke="${MUTED}"/>`);
|
||||
for (const s of g.segments) parts.push(text((X(s.from) + X(s.to)) / 2, Y(g.HF + s.H) - 8, `구간 ${s.no} · ${Math.round(s.H)}`, 'middle', MUTED, 10));
|
||||
const seg = (mark: WallMark, a: [number, number], b: [number, number], w: number) =>
|
||||
`<line x1="${X(a[0]).toFixed(1)}" y1="${Y(a[1]).toFixed(1)}" x2="${X(b[0]).toFixed(1)}" y2="${Y(b[1]).toFixed(1)}" stroke="${COLOR[mark]}" stroke-width="${w}"${DASH[mark] ? ` stroke-dasharray="${DASH[mark]}"` : ''} opacity="${opacity(pick, [mark])}"/>`;
|
||||
const front = r.x.W1;
|
||||
const w11Front = Math.max(...r.paths.filter((p) => p.mark === 'W11').map((p) => p.pts[1][1]));
|
||||
for (const p of r.paths) {
|
||||
const [u0, t0, z0] = p.pts[0];
|
||||
const [u1, , z1] = p.pts[p.pts.length - 1];
|
||||
if (p.mark === 'W1') parts.push(seg('W1', [p.pts[1][0], p.pts[1][2]], [u1, z1], 0.7));
|
||||
else if (p.mark === 'W5') parts.push(seg('W5', [u0, z0], [u1, z1], 0.8));
|
||||
else if ((p.mark === 'W9' || p.mark === 'W10') && Math.abs(t0 - r.x.W9[0]) < 1e-6) parts.push(seg(p.mark, [u0, z0], [u1, z1], 2));
|
||||
else if (p.mark === 'W11' && Math.abs(t0 - w11Front) < 1e-6) parts.push(seg('W11', [p.pts[1][0], p.pts[1][2]], [u1, z1], 2));
|
||||
else if (p.mark === 'W8') parts.push(`<circle cx="${X(p.pts[1][0]).toFixed(1)}" cy="${Y(z0).toFixed(1)}" r="1.6" fill="${COLOR.W8}" opacity="${opacity(pick, ['W8'])}"/>`);
|
||||
}
|
||||
// 단 치수 (양끝).
|
||||
const low = r.levels.find((l) => l.no === 1);
|
||||
const high = r.levels.find((l) => l.no === g.segments.length);
|
||||
const lvText = [low, high && g.segments.length > 1 ? high : undefined].filter((l) => l).map((l) => `구간 ${l!.no} 수평 ${l!.text}`).join(' ');
|
||||
parts.push(text((X(0) + X(g.L)) / 2, Y(0) + 30, lvText, 'middle', MUTED, 10));
|
||||
parts.push(text((X(0) + X(g.L)) / 2, Y(g.HF / 2), `저판 (연직철근 갈고리는 B6 · B8 위) · 전면 W1 x ${Math.round(front * 10) / 10}`, 'middle', MUTED, 10));
|
||||
parts.push(text(X(g.L), Y(0) + 14, 'IP3 (구간 1)', 'start', MUTED, 10), text(X(0), Y(0) + 14, 'IP1', 'end', MUTED, 10));
|
||||
const top = 20;
|
||||
parts.push(balloon(X(g.L * 0.85), top, 'W9', C.step, pick), balloon(X(g.L * 0.5), top, 'W10', C.step, pick));
|
||||
parts.push(balloon(X(g.L * 0.3), top, 'W1', C.vertical, pick), balloon(X(g.L * 0.65), top, 'W5', C.horizontal, pick));
|
||||
parts.push(balloon(X(0) + 34, Y(g.HF + 1200), 'W11', C.vertical, pick), balloon(X(0) + 34, Y(g.HF + 600), 'W8', C.horizontal, pick));
|
||||
const W = X(0) + 60;
|
||||
const H = Y(0) + 40;
|
||||
return `<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" style="max-width:${Math.round(W * 1.2)}px" role="img" aria-label="벽체 교대축 단면 (계단)">${parts.join('')}</svg>`;
|
||||
}
|
||||
@@ -154,3 +154,23 @@ describe('③ 벽체 배근 입력 저장', () => {
|
||||
expect(() => checkStoredAbutments([{ ...rec, rebar: { wall: { sL: 'x' } } as never }])).toThrow('벽체 배근');
|
||||
});
|
||||
});
|
||||
|
||||
import { abutWallElevationSvg, abutWallSectionSvg } from '../src/view/abut-wall-rebar-svg';
|
||||
|
||||
describe('③ 벽체 삽도', () => {
|
||||
it('단면: 구간 1 단 치수 "100 · 17@125=2.125 · 147", W9 · W10 간격, 번호 풍선', () => {
|
||||
const svg = abutWallSectionSvg(geom, A1);
|
||||
expect(svg).toContain('100 · 17@125=2.125 · 147');
|
||||
expect(svg).toContain('W9 · W10 5@250');
|
||||
for (const m of ['W1', 'W2', 'W3', 'W4', 'W5', 'W6', 'W7', 'W9', 'W10']) expect(svg).toContain(`data-pick="${m}"`);
|
||||
});
|
||||
|
||||
it('계단 단면: 구간별 높이, 양끝 단 치수, W8 · W11 풍선; 선택하면 나머지는 흐리게 / 숨김', () => {
|
||||
const svg = abutWallElevationSvg(geom, A1);
|
||||
expect(svg).toContain('구간 1 · 2372');
|
||||
expect(svg).toContain('19@125=2.375 · 181');
|
||||
for (const m of ['W8', 'W11']) expect(svg).toContain(`data-pick="${m}"`);
|
||||
expect(abutWallElevationSvg(geom, A1, { marks: new Set(['W9']), only: false })).toContain('opacity="0.15"');
|
||||
expect(abutWallElevationSvg(geom, A1, { marks: new Set(['W9']), only: true })).toContain('opacity="0"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
// 교대 A1 벽체 배근 삽도 2장을 SVG로 저장한다 (PNG 확인: python3 -I ../verify/svg_to_png.py <svg> <png>).
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { it } from 'vitest';
|
||||
import { buildAbut } from '../../src/core/abut/geometry';
|
||||
import { parseAbutments, toAbutInput } from '../../src/core/abut/parse';
|
||||
import { buildMembers } from '../../src/core/abut/members';
|
||||
import { abutFootingGeom, abutFootingRebar } from '../../src/core/abut/rebar/footing';
|
||||
import { skewAngle } from '../../src/core/abut/rebar/skew-layout';
|
||||
import { abutWallGeom, abutWallRebar } from '../../src/core/abut/rebar/wall';
|
||||
import { abutWallElevationSvg, abutWallSectionSvg } from '../../src/view/abut-wall-rebar-svg';
|
||||
|
||||
it('dump 교대 A1 벽체 삽도', () => {
|
||||
const out = process.env.DUMP_OUT ?? fileURLToPath(new URL('./out', import.meta.url));
|
||||
mkdirSync(out, { recursive: true });
|
||||
const xlsx = fileURLToPath(new URL('../../../../RAG/운곡교P1입력.xlsx', import.meta.url));
|
||||
const rec = parseAbutments(XLSX.read(readFileSync(xlsx), { type: 'buffer' })).abutments[0];
|
||||
const model = buildAbut(toAbutInput(rec));
|
||||
const members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm });
|
||||
const angle = skewAngle(rec.skewDeg);
|
||||
const footing = abutFootingRebar(abutFootingGeom(model.length, members.shape, angle));
|
||||
const g = abutWallGeom(model.length, members, angle);
|
||||
const r = abutWallRebar(g, footing);
|
||||
const ns = (svg: string) => svg.replace('<svg ', '<svg xmlns="http://www.w3.org/2000/svg" ');
|
||||
writeFileSync(`${out}/wall-section.svg`, ns(abutWallSectionSvg(g, r)));
|
||||
writeFileSync(`${out}/wall-elevation.svg`, ns(abutWallElevationSvg(g, r)));
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
// 삽도 덤프 전용 설정: verify/dump/*.dump.ts만 실행한다 (기본 테스트에는 포함되지 않음).
|
||||
// 사용: npx vitest run --config verify/vitest.dump.config.mjs (출력 폴더는 DUMP_OUT, 기본 verify/dump/out)
|
||||
export default { test: { include: ['verify/dump/**/*.dump.ts'] } };
|
||||
Reference in new issue
Block a user