diff --git a/src/view/abut-footing-rebar-svg.ts b/src/view/abut-footing-rebar-svg.ts index a31e699..54cee13 100644 --- a/src/view/abut-footing-rebar-svg.ts +++ b/src/view/abut-footing-rebar-svg.ts @@ -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) => `${s}`; /** Selected bars (철근표 번호) and 선택만 보기. An empty selection shows every bar. */ export type AbutRebarPick = { marks: ReadonlySet; 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; diff --git a/src/view/abut-wall-rebar-svg.ts b/src/view/abut-wall-rebar-svg.ts new file mode 100644 index 0000000..79673e2 --- /dev/null +++ b/src/view/abut-wall-rebar-svg.ts @@ -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 = { + 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> = { 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(``); + parts.push(``); + const bt = Math.min(g.bracketTop[0], g.bracketTop[1]); + parts.push(``); + 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) => + ``; + const dot = (p: WallBarPath) => { + const [, t, z] = p.pts[0]; + return ``; + }; + // 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(``); + parts.push(`${r.settings.c} · ${lv.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 `${parts.join('')}`; +} + +/** 교대축 단면 (계단): 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(``); + 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(``); + 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) => + ``; + 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(``); + } + // 단 치수 (양끝). + 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 `${parts.join('')}`; +} diff --git a/tests/abut-rebar-wall.test.ts b/tests/abut-rebar-wall.test.ts index f80184d..9483d5b 100644 --- a/tests/abut-rebar-wall.test.ts +++ b/tests/abut-rebar-wall.test.ts @@ -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"'); + }); +}); diff --git a/verify/dump/abut-wall-svg.dump.ts b/verify/dump/abut-wall-svg.dump.ts new file mode 100644 index 0000000..703a69f --- /dev/null +++ b/verify/dump/abut-wall-svg.dump.ts @@ -0,0 +1,28 @@ +// 교대 A1 벽체 배근 삽도 2장을 SVG로 저장한다 (PNG 확인: python3 -I ../verify/svg_to_png.py ). +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('