diff --git a/src/core/abut/rebar/block.ts b/src/core/abut/rebar/block.ts index 3109576..5c3b2ab 100644 --- a/src/core/abut/rebar/block.ts +++ b/src/core/abut/rebar/block.ts @@ -88,6 +88,8 @@ export interface AbutBlockPath { export interface AbutBlockRebar { settings: AbutBlockSettings; concreteH: number; + /** 교좌면 높이 (mm, 저판 하면 기준). 경로 z에서 빼면 교좌면 기준. */ + seatZ: number; depth: { S1: number; S2: number }; embedment: { S1: number; S2: number }; paths: AbutBlockPath[]; @@ -184,5 +186,6 @@ export function abutBlockRebar( basis: '규칙', modified: s.spacingS2 !== ABUT_BLOCK_REBAR_DEFAULT.spacingS2, }); - return { settings: s, concreteH, depth, embedment, paths, bars, issues }; + const seatZ = blocks[0] ? (blocks[0].seatEl - bottomEl) * 1000 : 0; + return { settings: s, concreteH, seatZ, depth, embedment, paths, bars, issues }; } diff --git a/src/view/abut-block-rebar-svg.ts b/src/view/abut-block-rebar-svg.ts new file mode 100644 index 0000000..362146a --- /dev/null +++ b/src/view/abut-block-rebar-svg.ts @@ -0,0 +1,73 @@ +/** + * 교대 받침블럭 배근 삽도 (2026-11-05, 시안 ③ 받침블럭 카드, 받침블럭철근.md §8): 평면 (블럭 1개, S2 실선 · S1 파선), + * 단면 (교대축 방향 절단: S1 아래 줄, S2 위 줄, 다리, 블럭 · 패드 · 교좌면). 순수 문자열 함수. 철근은 block.ts paths에서 그린다. + * 색: S1 (주철근, 아래) #0F5C66, S2 (보조, 위) #B4531F. + */ +import type { AbutBlockRebar, AbutBlockPath } from '../core/abut/rebar/block'; +import { balloon, CONC, MUTED, NO_PICK, opacity, text, WALL, type AbutRebarPick } from './abut-footing-rebar-svg'; + +export const ABUT_BLOCK_COLORS = { s1: '#0F5C66', s2: '#B4531F' } as const; +const C = ABUT_BLOCK_COLORS; + +/** 블럭 한 개의 경로만 쓴다 (첫 블럭, 방위각 0 가정). */ +function firstBlock(paths: AbutBlockPath[]): AbutBlockPath[] { + const b = paths[0]?.block; + return paths.filter((p) => p.block === b); +} + +/** 평면: 블럭 1200 × 1200. S2 (교대축 간격, 교대축 직각으로 뻗음) 실선, S1 (교대축 직각 간격, 교대축 방향) 파선. */ +export function abutBlockPlanSvg(r: AbutBlockRebar, pick: AbutRebarPick = NO_PICK, blockCenter: [number, number] = [0, 0]): string { + const ps = firstBlock(r.paths); + const k = 0.2; + const X = (s: number) => 40 + (s - blockCenter[0] + 600) * k; + const Y = (t: number) => 40 + (blockCenter[1] + 600 - t) * k; + const parts: string[] = []; + parts.push(``); + for (const p of ps) { + const a = p.pts[1]; + const b = p.pts[2]; + const dashed = p.mark === 'S1' ? ' stroke-dasharray="4 3"' : ''; + parts.push(``); + } + parts.push(text(40 + 600 * k, 40 + 1200 * k + 16, '블럭 1200 × 1200 · S2 실선 (위) · S1 파선 (아래) · 교대축 → ', 'middle', MUTED, 10)); + parts.push(balloon(40 - 16, 40 + 600 * k, 'S2', C.s2, pick), balloon(40 + 1200 * k + 16, 40 + 600 * k, 'S1', C.s1, pick)); + const W = 40 + 1200 * k + 60; + const H = 40 + 1200 * k + 40; + return `${parts.join('')}`; +} + +/** 단면 (교대축 방향 절단, 블럭 가운데): 가로 s (블럭 안), 세로 z (교좌면 0, 블럭 상면 = 콘크리트 높이, 패드 위 125). */ +export function abutBlockSectionSvg(r: AbutBlockRebar, pick: AbutRebarPick = NO_PICK): string { + const ps = firstBlock(r.paths).map((p) => ({ ...p, pts: p.pts.map(([s, t, z]) => [s, t, z - r.seatZ] as [number, number, number]) })); + const k = 0.42; + const kz = 0.42; + const zTop = r.concreteH + 50; + const zBot = Math.min(...ps.flatMap((p) => p.pts.map((q) => q[2])), 0) - 60; + const sLeft = Math.min(...ps.flatMap((p) => p.pts.map((q) => q[0]))); + const X = (s: number) => 50 + (s - sLeft) * k; + const Y = (z: number) => 40 + (zTop - z) * kz; + const W = 50 + 1200 * k + 60; + const H = Y(zBot) + 40; + const parts: string[] = []; + const blockL = Math.min(...ps.flatMap((p) => p.pts.map((q) => q[0]))); + parts.push(``); + parts.push(``); + parts.push(text(X(blockL + 50) - 6, Y(zTop) + 4, '몰탈패드 50', 'end', MUTED, 10)); + parts.push(``); + parts.push(text(X(blockL + 1300) + 6, Y(0) + 4, '교좌면', 'start', MUTED, 10)); + for (const p of ps) { + const color = p.mark === 'S1' ? C.s1 : C.s2; + if (p.mark === 'S1') { + // S1: 교대축 방향 줄 (단면 가운데에서 보면 점 · 선). 다리 두 개를 같이 그린다. + const pts = p.pts.map(([s, , zz]) => `${X(s).toFixed(1)},${Y(zz).toFixed(1)}`).join(' '); + parts.push(``); + } else { + // S2: 교대축 간격으로 놓인 본 — 단면에서 점. + const s = p.pts[1][0]; + parts.push(``); + } + } + parts.push(text(X(blockL + 600), Y(zBot) + 14, `블럭 콘크리트 ${r.concreteH} · 묻힘 S2 ${Math.round(r.embedment.S2)} · S1 ${Math.round(r.embedment.S1)}`, 'middle', MUTED, 10)); + parts.push(balloon(X(blockL + 600), Y(r.depth.S2 + 0) - 10, 'S2', C.s2, pick), balloon(X(blockL + 600), Y(0) - 22, 'S1', C.s1, pick)); + return `${parts.join('')}`; +} diff --git a/tests/abut-rebar-block.test.ts b/tests/abut-rebar-block.test.ts index fe0f763..b43b71c 100644 --- a/tests/abut-rebar-block.test.ts +++ b/tests/abut-rebar-block.test.ts @@ -72,3 +72,17 @@ describe('③ 받침블럭 배근 입력 저장', () => { expect(() => checkStoredAbutments([{ ...rec, rebar: { block: { coverTop: 'x' } } as never }])).toThrow('받침블럭 배근'); }); }); + +import { abutBlockPlanSvg, abutBlockSectionSvg } from '../src/view/abut-block-rebar-svg'; + +describe('③ 받침블럭 삽도', () => { + it('평면: S1 · S2 번호 풍선, 단면: 묻힘 치수 문자열 · 몰탈패드 · 교좌면; 선택하면 나머지 흐리게 / 숨김', () => { + const p = abutBlockPlanSvg(A1); + expect(p).toContain('data-pick="S1"'); + expect(p).toContain('data-pick="S2"'); + const s = abutBlockSectionSvg(A1); + expect(s).toContain('블럭 콘크리트 75 · 묻힘 S2 675 · S1 688'); + expect(s).toContain('몰탈패드 50'); + expect(abutBlockSectionSvg(A1, { marks: new Set(['S2']), only: true })).toContain('opacity="0"'); + }); +}); diff --git a/verify/dump/abut-block-svg.dump.ts b/verify/dump/abut-block-svg.dump.ts new file mode 100644 index 0000000..3cc1e5b --- /dev/null +++ b/verify/dump/abut-block-svg.dump.ts @@ -0,0 +1,16 @@ +// 교대 A1 받침블럭 배근 삽도 2장을 SVG로 저장한다 (PNG 확인: python3 -I ../verify/svg_to_png.py ). +import { mkdirSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { it } from 'vitest'; +import { abutBlockRebar } from '../../src/core/abut/rebar/block'; +import { abutBlockPlanSvg, abutBlockSectionSvg } from '../../src/view/abut-block-rebar-svg'; + +it('dump 교대 받침블럭 삽도', () => { + const out = process.env.DUMP_OUT ?? fileURLToPath(new URL('./out', import.meta.url)); + mkdirSync(out, { recursive: true }); + const blocks = [1, 2, 3, 4, 5].map((no, i) => ({ no, s: 1 + i * 2, t: 0.2, rotDeg: 0, seatEl: 44.367, blockHeightMm: 125 })); + const r = abutBlockRebar(blocks, 40.895); + const ns = (svg: string) => svg.replace('