feat(abut-rebar): 교대 흉벽 · 브라켓 배근 삽도 (단면 · 교대축 단면 · P10 다웰 평면)
- 대표 단면(흉벽 높이 = 입력값): P1–P3 · P6 · P7 · P10 선, P4 · P5 점, 두께 · 단 수 치수 - 교대축 단면: 상단 경사와 나란한 P4, P1, 끝면 P11 · P8 · P9 - P10 평면: 슬래브 측면선, 다웰 n개, P3 다리 위치와 비켜 옮긴 다웰 - verify/dump: 흉벽 삽도 덤프 Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ba71911edc
commit
914e3dbda8
3 files changed
+190
No files matched your search
@@ -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<ParapetMark, string> = {
|
||||
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<Record<ParapetMark, string>> = { 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<ParapetBarPath | undefined>((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(`<rect x="${X(xb)}" y="${Y(seat)}" width="${(tR - xb) * k}" height="${(seat - zMin) * k}" fill="${WALL}" stroke="${MUTED}" stroke-dasharray="4 3"/>`);
|
||||
parts.push(`<path d="M${X(xb)},${Y(seat)} L${X(xb)},${Y(br)} L${X(g.parapetBack)},${Y(br)} L${X(g.parapetBack)},${Y(top)} L${X(0)},${Y(top)} L${X(0)},${Y(seat)} Z" fill="${CONC}" stroke="${MUTED}"/>`);
|
||||
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) =>
|
||||
`<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])}"/>`;
|
||||
// 간격재: 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(`<circle cx="${X(p.pts[0][1]).toFixed(1)}" cy="${Y(z).toFixed(1)}" r="2.4" fill="${COLOR[p.mark]}" opacity="${opacity(pick, [p.mark])}"/>`);
|
||||
}
|
||||
// 치수: 흉벽 두께 (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 `<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="흉벽 · 브라켓 교대축 직각 단면 (대표 단면)">${parts.join('')}</svg>`;
|
||||
}
|
||||
|
||||
/** 교대축 단면 (전면): 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(`<path d="${outline} Z" fill="${CONC}" stroke="${MUTED}"/>`);
|
||||
parts.push(`<line x1="${X(0)}" y1="${Y(g.top[0] - g.bracketH)}" x2="${X(L)}" y2="${Y(g.top[1] - g.bracketH)}" stroke="${MUTED}" stroke-dasharray="5 4"/>`);
|
||||
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) =>
|
||||
`<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])}"/>`;
|
||||
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(`<circle cx="${X(p.pts[1][0]).toFixed(1)}" cy="${Y(a[2]).toFixed(1)}" r="1.8" fill="${COLOR[p.mark]}" opacity="${opacity(pick, [p.mark])}"/>`);
|
||||
}
|
||||
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 `<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>`;
|
||||
}
|
||||
|
||||
/** 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(`<rect x="${X(L)}" y="${y0 - 12}" width="${L * k}" height="24" fill="${CONC}" stroke="${MUTED}"/>`);
|
||||
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(`<line x1="${X(u)}" y1="${y0 - 30}" x2="${X(u)}" y2="${y0 + 30}" stroke="${MUTED}" stroke-dasharray="4 3"/>`);
|
||||
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(`<line x1="${X(p.pts[0][0]).toFixed(1)}" y1="${y0 - 8}" x2="${X(p.pts[0][0]).toFixed(1)}" y2="${y0 + 8}" stroke="${C.vertical}" stroke-width="0.5" opacity="${opacity(pick, ['P3'])}"/>`);
|
||||
for (const u of r.dowel.positions) parts.push(`<circle cx="${X(u).toFixed(1)}" cy="${y0}" r="3" fill="${C.side}" opacity="${opacity(pick, ['P10'])}"/>`);
|
||||
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 `<svg viewBox="0 0 ${W.toFixed(0)} ${y0 + 60}" width="100%" style="max-width:${Math.round(W * 1.2)}px" role="img" aria-label="P10 다웰 배치 평면">${parts.join('')}</svg>`;
|
||||
}
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
// 교대 A1 흉벽 · 브라켓 배근 삽도 3장을 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 { 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('<svg ', '<svg xmlns="http://www.w3.org/2000/svg" ');
|
||||
writeFileSync(`${out}/parapet-section.svg`, ns(abutParapetSectionSvg(g, r)));
|
||||
writeFileSync(`${out}/parapet-elevation.svg`, ns(abutParapetElevationSvg(g, r)));
|
||||
writeFileSync(`${out}/parapet-dowel.svg`, ns(abutParapetDowelSvg(g, r)));
|
||||
});
|
||||
Reference in new issue
Block a user