feat(abut-rebar): 교대 접속슬래브 배근 삽도 (평면 · 단면)

- 평면: 왼쪽 절반 하면 (R3 · R4 · R8) · 오른쪽 절반 상면 (R1 · R2), 모따기 모서리 R7, 교대축 · 길이 배치 치수
- 단면 (길이 방향, 폭 가운데): 브라켓 위 평탄 + 경사, 층 순서, R8은 R1 · R3 사이
- verify/dump: 접속슬래브 삽도 덤프

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-11-05 09:26:13 +09:00
1 parent 9ae6cf0945
commit e9d79957c8
3 files changed
+173

No files matched your search

+124
View File
@@ -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<SlabMark, string> = { 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(`<polygon points="${outline.join(' ')}" fill="${CONC}" stroke="${MUTED}"/>`);
parts.push(`<line x1="${X(half)}" y1="${Y(0) - 10}" x2="${X(half)}" y2="${Y(g.Ls) + 10}" stroke="${MUTED}" stroke-dasharray="6 4"/>`);
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) =>
`<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}" opacity="${opacity(pick, [mark])}"/>`;
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(`<circle cx="${X(u).toFixed(1)}" cy="${Y(tp).toFixed(1)}" r="2.2" fill="${C.shear}" opacity="${opacity(pick, ['R8'])}"/>`);
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 `<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" style="max-width:${Math.round(W * 1.3)}px" role="img" aria-label="접속슬래브 배근 평면 (왼쪽 하면 · 오른쪽 상면)">${parts.join('')}</svg>`;
}
/** 단면 (길이 방향, 폭 가운데): 가로 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(`<polygon points="${[...topPts, ...botPts].join(' ')}" fill="${CONC}" stroke="${MUTED}"/>`);
// 폭 가운데에 가장 가까운 길이 방향 철근 (R1 · R3), 교대축 방향 철근 (R2 · R4) 점, R8 한 줄.
const nearest = (mark: SlabMark) =>
r.paths.filter((p) => p.mark === mark).reduce<SlabBarPath | undefined>((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(`<polyline points="${p.pts.map(([, t, z]) => `${X(f.tp(t)).toFixed(1)},${Y(z).toFixed(1)}`).join(' ')}" fill="none" stroke="${C.main}" stroke-width="1.6" opacity="${opacity(pick, [mark])}"/>`);
}
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(`<circle cx="${X(f.tp(t)).toFixed(1)}" cy="${Y(z).toFixed(1)}" r="1.8" fill="${C.dist}" opacity="${opacity(pick, [p.mark])}"/>`);
}
// 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(`<line x1="${X(f.tp(t)).toFixed(1)}" y1="${Y(zb).toFixed(1)}" x2="${X(f.tp(t)).toFixed(1)}" y2="${Y(zt).toFixed(1)}" stroke="${C.shear}" stroke-width="0.9" opacity="${opacity(pick, ['R8'])}"/>`);
}
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 `<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>`;
}
+18
View File
@@ -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"');
});
});
+31
View File
@@ -0,0 +1,31 @@
// 교대 A1 접속슬래브 배근 삽도 2장을 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, 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('<svg ', '<svg xmlns="http://www.w3.org/2000/svg" ');
writeFileSync(`${out}/slab-plan.svg`, ns(abutSlabPlanSvg(g, r)));
writeFileSync(`${out}/slab-section.svg`, ns(abutSlabSectionSvg(g, r)));
});