diff --git a/src/view/abut-parapet-rebar-svg.ts b/src/view/abut-parapet-rebar-svg.ts new file mode 100644 index 0000000..af2690c --- /dev/null +++ b/src/view/abut-parapet-rebar-svg.ts @@ -0,0 +1,130 @@ +/** + * 교대 흉벽 · 브라켓 배근 삽도 (2026-11-04, 시안 ③ 흉벽·브라켓 카드, 교대흉벽철근.md §10): 교대축 직각 단면 (대표 단면), + * 교대축 단면 (전면, 상단 경사), P10 다웰 평면. 순수 문자열 함수, 철근은 배근 규칙의 경로(parapet.ts paths)에서 그린다. + * 색: 연직 #0F5C66, 수평 #B4531F, 간격재 #6B3FA0, 측면 · 다웰 #2E7D32. + */ +import type { AbutParapetGeom, AbutParapetRebar, ParapetBarPath, ParapetMark } from '../core/abut/rebar/parapet'; +import { balloon, CONC, MUTED, NO_PICK, opacity, text, WALL, type AbutRebarPick } from './abut-footing-rebar-svg'; + +export const ABUT_PARAPET_COLORS = { vertical: '#0F5C66', horizontal: '#B4531F', tie: '#6B3FA0', side: '#2E7D32' } as const; +const C = ABUT_PARAPET_COLORS; +const COLOR: Record = { + P1: C.vertical, P2: C.vertical, P3: C.vertical, P11: C.vertical, + P4: C.horizontal, P5: C.horizontal, + P6: C.tie, P7: C.tie, + P8: C.side, P9: C.side, P10: C.side, +}; +const DASH: Partial> = { P6: '3 3', P7: '3 3', P8: '2 2', P9: '2 2' }; + +const topAt = (g: AbutParapetGeom, u: number) => g.top[0] + ((g.top[1] - g.top[0]) * u) / g.wall.L; +const seatAt = (g: AbutParapetGeom, u: number) => { + const sg = g.wall.segments.find((s) => u < s.to) ?? g.wall.segments[g.wall.segments.length - 1]; + return g.wall.HF + sg.H; +}; +const nearest = (paths: ParapetBarPath[], mark: ParapetMark, u: number, at = 1) => + paths.filter((p) => p.mark === mark).reduce((a, b) => (!a || Math.abs(b.pts[at][0] - u) < Math.abs(a.pts[at][0] - u) ? b : a), undefined); + +/** 교대축 직각 단면 at the 대표 단면 (흉벽 높이 = H_p): t 가로 (왼쪽 브라켓 배면 → 오른쪽 흉벽 전면), z 세로. */ +export function abutParapetSectionSvg(g: AbutParapetGeom, r: AbutParapetRebar, pick: AbutRebarPick = NO_PICK): string { + const u = r.uRef; + const seat = seatAt(g, u); + const top = topAt(g, u); + const br = top - g.bracketH; + const zMin = seat - r.ld.H16 - 150; + const k = 0.16; + const tL = g.wall.xb - 150; + const tR = 450; + const X = (t: number) => 60 + (t - tL) * k; + const Y = (z: number) => 40 + (top + 120 - z) * k; + const parts: string[] = []; + const xb = g.wall.xb; + // 벽체 (교좌면 아래) · 교좌면 · 브라켓 · 흉벽. + parts.push(``); + parts.push(``); + parts.push(text(X(tR) - 4, Y(seat) - 6, '교좌면', 'end', MUTED, 10), text((X(xb) + X(g.parapetBack)) / 2, Y(br) - 6, '브라켓 상면', 'middle', MUTED, 10)); + const line = (p: ParapetBarPath, w = 1.6) => + ``; + // 간격재: every level at the 교대축 position nearest the section. + for (const mark of ['P6', 'P7'] as const) { + const ref = nearest(r.paths, mark, u); + // That column and its staggered levels (반 칸 뒤), not the previous column's. + if (ref) for (const p of r.paths.filter((q) => q.mark === mark && q.pts[1][0] - ref.pts[1][0] > -1 && q.pts[1][0] - ref.pts[1][0] < 600)) parts.push(line(p, 1.1)); + } + for (const mark of ['P3', 'P2', 'P1', 'P10'] as const) { + const p = nearest(r.paths, mark, u, mark === 'P10' ? 0 : 1); + if (p) parts.push(line(p, mark === 'P10' ? 3 : 1.8)); + } + // 수평 P4 · P5: dots where they pass the section. + for (const p of r.paths.filter((q) => (q.mark === 'P4' || q.mark === 'P5') && q.pts[0][0] <= u && q.pts[1][0] >= u)) { + const f = (u - p.pts[0][0]) / (p.pts[1][0] - p.pts[0][0]); + const z = p.pts[0][2] + f * (p.pts[1][2] - p.pts[0][2]); + parts.push(``); + } + // 치수: 흉벽 두께 (c · 사이 · c), 단 수. + const c = r.settings.c; + parts.push(text((X(g.parapetBack) + X(0)) / 2, Y(top) - 8, `${c} · ${Math.round(-g.parapetBack - 2 * c)} · ${c}`, 'middle', MUTED, 10)); + parts.push(text((X(xb) + X(g.parapetBack)) / 2, Y(br) + 14, `브라켓 ${Math.round(g.parapetBack - xb)}`, 'middle', MUTED, 10)); + parts.push(text(X(tR) - 4, Y((top + seat) / 2), `P4 ${r.levels.P4}단 · P5 ${r.levels.P5up} + ${r.levels.P5low}단`, 'end', MUTED, 10)); + // 번호 풍선. + const ym = Y((top + seat) / 2); + parts.push(balloon(X(0) + 30, Y(top) + 30, 'P1', C.vertical, pick), balloon(X(0) + 30, ym + 30, 'P4', C.horizontal, pick)); + parts.push(balloon(X(g.parapetBack) + 30, Y(top) - 26, 'P2', C.vertical, pick), balloon(X(xb) - 26, Y(br) + 40, 'P3', C.vertical, pick), balloon(X(xb) - 26, Y(br) + 80, 'P5', C.horizontal, pick)); + parts.push(balloon((X(g.parapetBack) + X(0)) / 2, Y(br) - 40, 'P7', C.tie, pick), balloon((X(xb) + X(0)) / 2, Y(seat) - 30, 'P6', C.tie, pick), balloon(X(r.x.P10) - 26, Y(br) - 30, 'P10', C.side, pick)); + const W = X(tR) + 20; + const H = Y(zMin) + 16; + return `${parts.join('')}`; +} + +/** 교대축 단면 (전면): u 가로 (왼쪽 IP3 → 오른쪽 IP1), z 세로. 상단 경사와 나란한 P4, P1, 끝면 P11 · P9 · P8. */ +export function abutParapetElevationSvg(g: AbutParapetGeom, r: AbutParapetRebar, pick: AbutRebarPick = NO_PICK): string { + const L = g.wall.L; + const k = 0.045; + const kz = 0.12; + const zMax = Math.max(g.top[0], g.top[1]) + 100; + const zMin = Math.min(...g.wall.segments.map((s) => g.wall.HF + s.H)) - r.ld.H16 - 100; + const X = (u: number) => 60 + (L - u) * k; + const Y = (z: number) => 50 + (zMax - z) * kz; + const parts: string[] = []; + let outline = `M${X(0).toFixed(1)},${Y(g.top[0]).toFixed(1)} L${X(L).toFixed(1)},${Y(g.top[1]).toFixed(1)}`; + for (const s of [...g.wall.segments].reverse()) outline += ` L${X(s.to).toFixed(1)},${Y(g.wall.HF + s.H).toFixed(1)} L${X(s.from).toFixed(1)},${Y(g.wall.HF + s.H).toFixed(1)}`; + parts.push(``); + parts.push(``); + parts.push(text(X(L / 2), Y(g.top[0] - g.bracketH + ((g.top[1] - g.top[0]) / 2)) - 4, '브라켓 상면 (배면)', 'middle', MUTED, 10)); + const seg = (mark: ParapetMark, a: [number, number], b: [number, number], w: number) => + ``; + for (const p of r.paths) { + const a = p.pts[0]; + const b = p.pts[p.pts.length - 1]; + if (p.mark === 'P4') parts.push(seg('P4', [a[0], a[2]], [b[0], b[2]], 0.8)); + else if (p.mark === 'P1') parts.push(seg('P1', [a[0], a[2]], [b[0], b[2]], 0.6)); + else if (p.mark === 'P11' && Math.abs(a[1] - r.x.P4) < 1e-6) parts.push(seg('P11', [a[0], a[2]], [b[0], b[2]], 2)); + else if (p.mark === 'P8' || p.mark === 'P9') parts.push(``); + } + parts.push(text(X(L), Y(zMin) + 14, 'IP3 (구간 1)', 'start', MUTED, 10), text(X(0), Y(zMin) + 14, 'IP1', 'end', MUTED, 10)); + parts.push(balloon(X(L * 0.7), 22, 'P4', C.horizontal, pick), balloon(X(L * 0.4), 22, 'P1', C.vertical, pick)); + parts.push(balloon(X(0) + 30, Y(g.top[0] - 300), 'P9', C.side, pick), balloon(X(0) + 30, Y(g.top[0] - 1300), 'P8', C.side, pick), balloon(X(0) + 30, Y(g.top[0] - 2300), 'P11', C.vertical, pick)); + const W = X(0) + 60; + const H = Y(zMin) + 24; + return `${parts.join('')}`; +} + +/** P10 다웰 배치 평면: u 가로 (왼쪽 IP3 → 오른쪽 IP1), 다웰 n개, 슬래브 측면선, P3 수평 다리를 비켜 옮긴 다웰. */ +export function abutParapetDowelSvg(g: AbutParapetGeom, r: AbutParapetRebar, pick: AbutRebarPick = NO_PICK): string { + const L = g.wall.L; + const k = 0.07; + const X = (u: number) => 40 + (L - u) * k; + const y0 = 70; + const parts: string[] = []; + parts.push(``); + parts.push(text(X(L / 2), y0 - 18, `브라켓 상면 · 다웰 선 x ${r.x.P10}`, 'middle', MUTED, 10)); + if (g.slab) { + for (const u of [g.slab.a, g.slab.b]) parts.push(``); + parts.push(text(X((g.slab.a + g.slab.b) / 2), y0 + 44, `접속슬래브 폭 ${Math.round(g.slab.B)} · n = ${r.dowel.n} · 양끝 여유 c + e = ${Math.round((r.settings.c + r.dowel.e) * 10) / 10}${r.dowel.moved ? ` · ${r.dowel.moved}개 P3 비켜 옮김` : ''}`, 'middle', MUTED, 10)); + } + for (const p of r.paths.filter((q) => q.mark === 'P3')) parts.push(``); + for (const u of r.dowel.positions) parts.push(``); + parts.push(text(X(L), y0 + 26, 'IP3', 'start', MUTED, 10), text(X(0), y0 + 26, 'IP1', 'end', MUTED, 10)); + parts.push(balloon(X(L * 0.5), 20, 'P10', C.side, pick), balloon(X(L * 0.5) + 30, 20, 'P3', C.vertical, pick)); + const W = X(0) + 40; + return `${parts.join('')}`; +} diff --git a/tests/abut-rebar-parapet.test.ts b/tests/abut-rebar-parapet.test.ts index 5713a27..4bd1910 100644 --- a/tests/abut-rebar-parapet.test.ts +++ b/tests/abut-rebar-parapet.test.ts @@ -158,3 +158,23 @@ describe('③ 흉벽 배근 입력 저장', () => { expect(() => checkStoredAbutments([{ ...rec, rebar: { parapet: { c: 'x' } } as never }])).toThrow('흉벽 배근'); }); }); + +import { abutParapetDowelSvg, abutParapetElevationSvg, abutParapetSectionSvg } from '../src/view/abut-parapet-rebar-svg'; + +describe('③ 흉벽 삽도', () => { + it('단면: 두께 치수 "100 · 300 · 100", 단 수, 번호 풍선 P1–P7 · P10', () => { + const svg = abutParapetSectionSvg(geom, A1); + expect(svg).toContain('100 · 300 · 100'); + expect(svg).toContain('P4 19단 · P5 7 + 12단'); + for (const m of ['P1', 'P2', 'P3', 'P4', 'P5', 'P6', 'P7', 'P10']) expect(svg).toContain(`data-pick="${m}"`); + }); + + it('교대축 단면 · 다웰 평면: 끝면 P8 · P9 · P11 풍선, 다웰 n과 옮긴 개수; 선택하면 나머지 흐리게 / 숨김', () => { + const el = abutParapetElevationSvg(geom, A1); + for (const m of ['P8', 'P9', 'P11', 'P4']) expect(el).toContain(`data-pick="${m}"`); + const dw = abutParapetDowelSvg(geom, A1); + expect(dw).toContain('n = 28'); + expect(dw).toContain(`${A1.dowel.moved}개 P3 비켜 옮김`); + expect(abutParapetDowelSvg(geom, A1, { marks: new Set(['P10']), only: true })).toContain('opacity="0"'); + }); +}); diff --git a/verify/dump/abut-parapet-svg.dump.ts b/verify/dump/abut-parapet-svg.dump.ts new file mode 100644 index 0000000..a096644 --- /dev/null +++ b/verify/dump/abut-parapet-svg.dump.ts @@ -0,0 +1,40 @@ +// 교대 A1 흉벽 · 브라켓 배근 삽도 3장을 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 } from '../../src/core/abut/slab'; +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 { abutParapetGeom, abutParapetRebar, slabSpanAt } from '../../src/core/abut/rebar/parapet'; +import { abutPlanOutline, abutSkewK } from '../../src/view/abut-scene'; +import { abutParapetDowelSvg, abutParapetElevationSvg, abutParapetSectionSvg } from '../../src/view/abut-parapet-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 angle = skewAngle(rec.skewDeg); + const footing = abutFootingRebar(abutFootingGeom(model.length, members.shape, angle)); + const wg = abutWallGeom(model.length, members, angle); + const wall = abutWallRebar(wg, footing); + const slab = resolveSlab(); + const tM = ((wg.xb + members.shape.parapetBackM * 1000) / 2) / 1000; + const span = slabSpanAt(abutPlanOutline(model, members, wings, slab).slab, tM, abutSkewK(model))!; + const ch = (sEnd: number) => (wings.find((w) => w.sEnd === sEnd)?.haunchM ?? 0) * 1000; + const g = abutParapetGeom(wg, members.shape, { B: model.wing!.slabWidthMm, a: span.a, b: span.b, nearT: members.shape.parapetBackM * 1000 - slab.parapetGapMm, chamferA: ch(0), chamferB: ch(model.length) }); + const r = abutParapetRebar(g, wall); + const ns = (svg: string) => svg.replace('