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:
1 parent
9ae6cf0945
commit
e9d79957c8
3 files changed
+173
No files matched your search
@@ -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>`;
|
||||
}
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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)));
|
||||
});
|
||||
Reference in new issue
Block a user