diff --git a/src/view/abut-slab-rebar-svg.ts b/src/view/abut-slab-rebar-svg.ts new file mode 100644 index 0000000..0b0c747 --- /dev/null +++ b/src/view/abut-slab-rebar-svg.ts @@ -0,0 +1,124 @@ +/** + * 교대 접속슬래브 배근 삽도 (2026-11-05, 시안 ③ 접속슬래브 카드, 교대접속슬래브철근.md §9): 평면 (왼쪽 절반 하면 · 오른쪽 절반 상면, + * 삽도와 같음), 단면 (길이 방향, 폭 가운데). 순수 문자열 함수, 철근은 배근 규칙의 경로(slab.ts paths, 교대 s · t · z)에서 그린다. + * 색: 길이 방향 주철근 #0F5C66, 교대축 방향 #B4531F, R7 #C0501A, R8 #6B3FA0. + */ +import type { AbutSlabGeom, AbutSlabRebar, SlabBarPath, SlabMark } from '../core/abut/rebar/slab'; +import { balloon, CONC, MUTED, NO_PICK, opacity, text, type AbutRebarPick } from './abut-footing-rebar-svg'; + +export const ABUT_SLAB_COLORS = { main: '#0F5C66', dist: '#B4531F', corner: '#C0501A', shear: '#6B3FA0' } as const; +const C = ABUT_SLAB_COLORS; +const COLOR: Record = { R1: C.main, R3: C.main, R2: C.dist, R4: C.dist, R7: C.corner, R8: C.shear }; + +/** u (0 측면에서 교대축을 따라) and t' (흉벽 쪽 끝에서) of a plan point. */ +const frame = (g: AbutSlabGeom) => ({ + u: (s: number, t: number) => s - (g.side0.s + g.side0.k * (t - g.near)), + tp: (t: number) => g.near - t, +}); + +/** 평면: 가로 u (교대축), 세로 t' (위 = 흉벽 쪽 끝). 왼쪽 절반은 하면 (R3 · R4 · R8 · 하면 R7), 오른쪽 절반은 상면 (R1 · R2 · 상면 R7). */ +export function abutSlabPlanSvg(g: AbutSlabGeom, r: AbutSlabRebar, pick: AbutRebarPick = NO_PICK): string { + const f = frame(g); + const k = 0.055; + const X = (u: number) => 50 + u * k; + const Y = (tp: number) => 60 + tp * k; + const half = g.B / 2; + const parts: string[] = []; + const P = (u: number, tp: number) => `${X(u).toFixed(1)},${Y(tp).toFixed(1)}`; + const outline = [P(g.chamfer0, 0), P(g.B - g.chamferL, 0), P(g.B, g.chamferL), P(g.B, g.Ls), P(0, g.Ls), P(0, g.chamfer0)]; + parts.push(``); + parts.push(``); + parts.push(text(X(half / 2), Y(g.Ls) + 24, '하면 (R3 · R4 · R8)', 'middle', MUTED, 11), text(X(half * 1.5), Y(g.Ls) + 24, '상면 (R1 · R2)', 'middle', MUTED, 11)); + parts.push(text(X(half), Y(0) - 14, `흉벽 쪽 끝 · ${r.across.text}`, 'middle', MUTED, 10)); + const seg = (mark: SlabMark, a: [number, number], b: [number, number], w: number) => + ``; + const uvOf = (p: SlabBarPath) => p.pts.map(([s, t]) => [f.u(s, t), f.tp(t)] as [number, number]); + for (const p of r.paths) { + const uv = uvOf(p); + const bottom = p.mark === 'R3' || p.mark === 'R4' || p.mark === 'R8'; + if (p.mark === 'R8') { + const [u, tp] = uv[1]; + if (u < half) parts.push(``); + continue; + } + if (p.mark === 'R7') { + // 상 · 하 R7은 평면에서 같은 자리라 그대로 그린다 (모서리마다 한 모양). + for (let i = 1; i < uv.length; i++) parts.push(seg('R7', uv[i - 1], uv[i], 1.6)); + continue; + } + // 길이 방향 (R1 · R3)은 절반 안의 것만, 교대축 방향 (R2 · R4)은 절반까지 자름. + if (p.mark === 'R1' || p.mark === 'R3') { + const u = uv[0][0]; + if (bottom ? u < half : u >= half) for (let i = 1; i < uv.length; i++) parts.push(seg(p.mark, uv[i - 1], uv[i], 0.7)); + } else { + const [a, b] = [uv[0], uv[uv.length - 1]]; + const lo = a[0] < b[0] ? a : b; + const hi = a[0] < b[0] ? b : a; + if (bottom) parts.push(seg(p.mark, lo, [Math.min(hi[0], half), hi[1]], 0.7)); + else parts.push(seg(p.mark, [Math.max(lo[0], half), lo[1]], hi, 0.7)); + } + } + parts.push(text(X(g.B) + 8, Y(g.Ls / 2), r.along.text, 'start', MUTED, 10)); + parts.push(balloon(X(half * 0.25), Y(g.Ls) + 50, 'R3', C.main, pick), balloon(X(half * 0.5), Y(g.Ls) + 50, 'R4', C.dist, pick), balloon(X(half * 0.75), Y(g.Ls) + 50, 'R8', C.shear, pick)); + parts.push(balloon(X(half * 1.4), Y(g.Ls) + 50, 'R1', C.main, pick), balloon(X(half * 1.7), Y(g.Ls) + 50, 'R2', C.dist, pick), balloon(X(g.B * 0.06), Y(0) + 40, 'R7', C.corner, pick)); + const W = X(g.B) + 260; + const H = Y(g.Ls) + 80; + return `${parts.join('')}`; +} + +/** 단면 (길이 방향, 폭 가운데): 가로 t' (왼쪽 흉벽 쪽 끝), 세로 높이. 경사, 층 순서 (R1 · R2 위, R4 · R3 아래), R8. */ +export function abutSlabSectionSvg(g: AbutSlabGeom, r: AbutSlabRebar, pick: AbutRebarPick = NO_PICK): string { + const f = frame(g); + const uMid = g.B / 2; + const kx = 0.11; + const kz = 0.5; + const sMid = (t: number) => g.side0.s + g.side0.k * (t - g.near) + uMid; + const parapetAt = (sv: number) => g.parapetTop[0] + ((g.parapetTop[1] - g.parapetTop[0]) * sv) / g.L; + const zs = g.stations.flatMap((st) => [parapetAt(sMid(st.t)) + st.top, parapetAt(sMid(st.t)) + st.bottom]); + const zMax = Math.max(...zs) + 60; + const zMin = Math.min(...zs) - 60; + const X = (tp: number) => 40 + tp * kx; + const Y = (z: number) => 50 + (zMax - z) * kz; + const parts: string[] = []; + const topPts = g.stations.map((st) => `${X(f.tp(st.t)).toFixed(1)},${Y(parapetAt(sMid(st.t)) + st.top).toFixed(1)}`); + const botPts = [...g.stations].reverse().map((st) => `${X(f.tp(st.t)).toFixed(1)},${Y(parapetAt(sMid(st.t)) + st.bottom).toFixed(1)}`); + parts.push(``); + // 폭 가운데에 가장 가까운 길이 방향 철근 (R1 · R3), 교대축 방향 철근 (R2 · R4) 점, R8 한 줄. + const nearest = (mark: SlabMark) => + r.paths.filter((p) => p.mark === mark).reduce((a, b) => (!a || Math.abs(f.u(b.pts[0][0], b.pts[0][1]) - uMid) < Math.abs(f.u(a.pts[0][0], a.pts[0][1]) - uMid) ? b : a), undefined); + for (const mark of ['R1', 'R3'] as const) { + const p = nearest(mark); + if (p) parts.push(``); + } + for (const p of r.paths.filter((q) => q.mark === 'R2' || q.mark === 'R4')) { + const t = p.pts[0][1]; + const a = p.pts[0]; + const b = p.pts[p.pts.length - 1]; + const fz = (uMid - f.u(a[0], t)) / (f.u(b[0], t) - f.u(a[0], t)); + const z = a[2] + fz * (b[2] - a[2]); + parts.push(``); + } + // R8: 줄마다 하나, 그린 R1 · R3 선 사이 (슬래브 상면이 교대축을 따라 기울어 실제 R8마다 높이가 조금씩 다름). + const zOn = (p: SlabBarPath | undefined, t: number) => { + if (!p) return NaN; + for (let i = 1; i < p.pts.length; i++) { + const [a0, a1] = [p.pts[i - 1], p.pts[i]]; + if ((t - a0[1]) * (t - a1[1]) <= 0) return a0[2] + ((t - a0[1]) / (a1[1] - a0[1] || 1)) * (a1[2] - a0[2]); + } + return p.pts[p.pts.length - 1][2]; + }; + const r1 = nearest('R1'); + const r3 = nearest('R3'); + const rowTs = [...new Set(r.paths.filter((q) => q.mark === 'R8').map((q) => Math.round(q.pts[1][1])))]; + for (const t of rowTs) { + const zt = zOn(r1, t) + 11.1; + const zb = zOn(r3, t) - 11.1; + parts.push(``); + } + parts.push(text(X(0), Y(zMin) + 16, '흉벽 쪽 끝 (브라켓 위)', 'start', MUTED, 10), text(X(g.Ls), Y(zMin) + 16, `먼 끝 · 경사 ${g.gradePct.toFixed(1)} %`, 'end', MUTED, 10)); + parts.push(text(X(g.Ls / 2), 18, `${g.form === 'buried' ? '매립형' : '노출형'} · 상면 R1 → R2, 하면 R3 → R4 (피복 ${r.settings.cv}) · R8 @${Math.round(r.settings.k8 * (r.settings.sDist ?? r.settings.sL))}`, 'middle', MUTED, 10)); + parts.push(balloon(X(g.Ls * 0.3), 40, 'R1', C.main, pick), balloon(X(g.Ls * 0.4), 40, 'R2', C.dist, pick), balloon(X(g.Ls * 0.5), 40, 'R8', C.shear, pick), balloon(X(g.Ls * 0.6), 40, 'R4', C.dist, pick), balloon(X(g.Ls * 0.7), 40, 'R3', C.main, pick)); + const W = X(g.Ls) + 40; + const H = Y(zMin) + 30; + return `${parts.join('')}`; +} diff --git a/tests/abut-rebar-slab.test.ts b/tests/abut-rebar-slab.test.ts index dd6b87a..5209d83 100644 --- a/tests/abut-rebar-slab.test.ts +++ b/tests/abut-rebar-slab.test.ts @@ -124,3 +124,21 @@ describe('③ 접속슬래브 배근 입력 저장', () => { expect(() => checkStoredAbutments([{ ...rec, rebar: { slab: { c: 'x' } } as never }])).toThrow('접속슬래브 배근'); }); }); + +import { abutSlabPlanSvg, abutSlabSectionSvg } from '../src/view/abut-slab-rebar-svg'; + +describe('③ 접속슬래브 삽도', () => { + it('평면: 교대축 · 길이 배치 치수, 하면 · 상면 반쪽, 번호 풍선 R1–R4 · R7 · R8', () => { + const svg = abutSlabPlanSvg(geom, A1); + expect(svg).toContain(A1.across.text); + expect(svg).toContain('100 · 250 · 21@300=6.300 · 250 · 100'); + for (const m of ['R1', 'R2', 'R3', 'R4', 'R7', 'R8']) expect(svg).toContain(`data-pick="${m}"`); + }); + + it('단면: 매립형 · 경사 −4 %, 선택하면 나머지 흐리게 / 숨김', () => { + const svg = abutSlabSectionSvg(geom, A1); + expect(svg).toContain('매립형'); + expect(svg).toContain('−4.0 %'.replace('−', '-')); + expect(abutSlabSectionSvg(geom, A1, { marks: new Set(['R8']), only: true })).toContain('opacity="0"'); + }); +}); diff --git a/verify/dump/abut-slab-svg.dump.ts b/verify/dump/abut-slab-svg.dump.ts new file mode 100644 index 0000000..a9dacbd --- /dev/null +++ b/verify/dump/abut-slab-svg.dump.ts @@ -0,0 +1,31 @@ +// 교대 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 { buildWings } from '../../src/core/abut/wings'; +import { resolveSlab, slabGradePct, slabSection } from '../../src/core/abut/slab'; +import { abutSlabGeom, abutSlabRebar } from '../../src/core/abut/rebar/slab'; +import { abutPlanOutline } from '../../src/view/abut-scene'; +import { abutSlabPlanSvg, abutSlabSectionSvg } from '../../src/view/abut-slab-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 wings = buildWings(model, members); + const slab = resolveSlab(process.env.FORM === 'exposed' ? { form: 'exposed' } : undefined); + const grade = slabGradePct(slab, model.wing?.slopePct ?? 0); + const ch = (sEnd: number) => (wings.find((w) => Math.abs(w.sEnd - sEnd) < 1e-9)?.haunchM ?? 0) * 1000; + const g = abutSlabGeom(slab.form, abutPlanOutline(model, members, wings, slab).slab, slabSection(slab, grade, members.shape), { startEl: members.parapetTopStartEl, endEl: members.parapetTopEndEl, L: model.length, bottomEl: members.footingBottomEl! }, [ch(0), ch(model.length)], slab.lengthMm, grade); + const r = abutSlabRebar(g); + const ns = (svg: string) => svg.replace('