- 부재 목록: 교각처럼 이름 + 경고 수 배지만. 배근 타입 · 번호 · 다음 단계는 툴팁과 카드 설명 줄로 - 철근표: 선택 칸, 행 클릭 단독 선택, 전체 선택/해제, 선택만 보기 (교각과 같은 rebarSelection · rebarIsolate) - 삽도: 여러 철근 강조, 선택만 보기면 나머지 숨김. B10 풍선은 B10-1 · B10-2를 함께 선택 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
198 lines
14 KiB
TypeScript
198 lines
14 KiB
TypeScript
/**
|
||
* 교대 저판 배근 삽도 (2026-11-04, 시안 ③ 확대기초 카드, 교대확대기초철근.md §10): 교대축 직각 단면 (폭 W), 교대축 단면 (길이 L),
|
||
* 상면 평면. 순수 문자열 함수. 번호 풍선(data-pick)을 누르면 그 철근만 강조한다; 선택한 번호는 `pick`(철근표 번호 집합)으로, 선택만 보기는 `only`로 받는다.
|
||
* 색은 역할 기준: 주철근 B1–B4 #0F5C66, 보조 B5–B8 #B4531F, 측면 B10 #2E7D32 점선, 전단 B9 #6B3FA0.
|
||
*/
|
||
import type { AbutFootingGeom, AbutFootingRebar } from '../core/abut/rebar/footing';
|
||
|
||
export const ABUT_FOOTING_COLORS = { main: '#0F5C66', dist: '#B4531F', side: '#2E7D32', shear: '#6B3FA0' } as const;
|
||
const INK = '#18212b';
|
||
const MUTED = '#55606e';
|
||
const CONC = '#eef1f4';
|
||
const WALL = '#d9dee4';
|
||
|
||
const text = (x: number, y: number, s: string, anchor = 'middle', fill = INK, size = 11) =>
|
||
`<text x="${x.toFixed(1)}" y="${y.toFixed(1)}" text-anchor="${anchor}" font-size="${size}" fill="${fill}" font-family="JetBrains Mono, monospace">${s}</text>`;
|
||
|
||
/** Selected bars (철근표 번호) and 선택만 보기. An empty selection shows every bar. */
|
||
export type AbutRebarPick = { marks: ReadonlySet<string>; only: boolean };
|
||
const NO_PICK: AbutRebarPick = { marks: new Set(), only: false };
|
||
|
||
/** Opacity of one bar group: dimmed (or hidden with 선택만 보기) when other bars are selected. */
|
||
function opacity(pick: AbutRebarPick, marks: string[]): number {
|
||
if (pick.marks.size === 0) return 1;
|
||
return marks.some((m) => pick.marks.has(m)) ? 1 : pick.only ? 0 : 0.15;
|
||
}
|
||
|
||
/** A clickable 번호 풍선. */
|
||
function balloon(x: number, y: number, mark: string, color: string, pick: AbutRebarPick): string {
|
||
// The B10 풍선 stands for B10-1 and B10-2.
|
||
const on = [...pick.marks].some((m) => m === mark || (mark === 'B10' && m.startsWith('B10')));
|
||
const r = mark.length > 3 ? 15 : 12;
|
||
return `<g data-pick="${mark}" style="cursor:pointer" role="button" tabindex="0" aria-label="${mark} 강조"><circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="${r}" fill="${on ? color : '#fff'}" stroke="${color}" stroke-width="1.4"/>${text(x, y + 4, mark, 'middle', on ? '#fff' : color, 10)}</g>`;
|
||
}
|
||
|
||
/** 교대축 직각 단면 (폭 W): 왼쪽 배면(뒷굽) → 오른쪽 전면(앞굽). B1–B4 선, B5–B8 점, B10-1 점, B9 연직선. */
|
||
export function abutFootingSectionWSvg(g: AbutFootingGeom, r: AbutFootingRebar, pick: AbutRebarPick = NO_PICK): string {
|
||
const k = 0.12;
|
||
const x0 = 60;
|
||
const y0 = 46;
|
||
const X = (x: number) => x0 + x * k;
|
||
const Y = (z: number) => y0 + (g.H - z) * k;
|
||
const c = r.settings.cSide;
|
||
const C = ABUT_FOOTING_COLORS;
|
||
const parts: string[] = [];
|
||
parts.push(`<rect x="${X(g.xb)}" y="${y0 - 26}" width="${(g.xf - g.xb) * k}" height="26" fill="${WALL}" stroke="${MUTED}" stroke-dasharray="4 3"/>`);
|
||
parts.push(text((X(g.xb) + X(g.xf)) / 2, y0 - 10, `벽체 ${Math.round(g.xf - g.xb)} (상면 B5·B7 없음)`, 'middle', MUTED, 10));
|
||
parts.push(`<rect x="${X(0)}" y="${Y(g.H)}" width="${g.W * k}" height="${g.H * k}" fill="${CONC}" stroke="${MUTED}"/>`);
|
||
const line = (pts: [number, number][], color: string, marks: string[], w = 1.8, dash = '') =>
|
||
`<polyline points="${pts.map(([x, z]) => `${X(x).toFixed(1)},${Y(z).toFixed(1)}`).join(' ')}" fill="none" stroke="${color}" stroke-width="${w}"${dash ? ` stroke-dasharray="${dash}"` : ''} opacity="${opacity(pick, marks)}"/>`;
|
||
const { B13, B24, B57, B68 } = r.z;
|
||
// B9 (behind the others): vertical lines at the row positions.
|
||
for (const row of r.shear.rows) parts.push(line([[row.x, B24], [row.x, B13]], C.shear, ['B9'], 1.2));
|
||
// B1 ⊓ (slightly inside so the legs show next to B2's), B2 ⊔, B3 to the 벽체 전면 − c, B4 straight.
|
||
const lx = c + 6;
|
||
parts.push(line([[lx, B24], [lx, B13], [g.W - lx, B13], [g.W - lx, B24]], C.main, ['B1']));
|
||
parts.push(line([[c, B13], [c, B24], [g.W - c, B24], [g.W - c, B13]], C.main, ['B2']));
|
||
parts.push(line([[c, B13 + 14], [g.xf - c, B13 + 14]], C.main, ['B3'], 1.6, '6 3'));
|
||
parts.push(line([[c, B24 - 14], [g.W - c, B24 - 14]], C.main, ['B4'], 1.6, '6 3'));
|
||
// B5–B8 dots.
|
||
const dot = (x: number, z: number, color: string, mark: string) =>
|
||
`<circle cx="${X(x).toFixed(1)}" cy="${Y(z).toFixed(1)}" r="2.6" fill="${color}" opacity="${opacity(pick, [mark])}"/>`;
|
||
r.dist.positions.forEach((x, i) => {
|
||
if (r.dist.topKept[i]) parts.push(dot(x + (i % 2 === 0 ? r.sideShift.B5 : 0), B57, C.dist, i % 2 === 0 ? 'B5' : 'B7'));
|
||
parts.push(dot(x, B68, C.dist, i % 2 === 0 ? 'B6' : 'B8'));
|
||
});
|
||
// B10-1 dots on both faces.
|
||
for (const z of r.side.levels) {
|
||
const zz = z + 19.1;
|
||
parts.push(dot(r.side.depth1, zz, C.side, 'B10-1'), dot(g.W - r.side.depth1, zz, C.side, 'B10-1'));
|
||
}
|
||
// 치수 문자열.
|
||
parts.push(text(X(g.W / 2), Y(0) + 18, r.dist.bottomText + ' (하면)', 'middle', INK, 10));
|
||
parts.push(text(X(g.W / 2), Y(g.H) - 32, r.dist.topText + ' (상면)', 'middle', INK, 10));
|
||
parts.push(text(X(0) - 6, Y(g.H / 2), r.side.text, 'end', MUTED, 10));
|
||
// 번호 풍선.
|
||
const by = Y(g.H) - 2;
|
||
parts.push(balloon(X(g.xb / 2), Y(B13) + 26, 'B1', C.main, pick), balloon(X(g.xb / 2) + 30, Y(B13) + 26, 'B3', C.main, pick));
|
||
parts.push(balloon(X(g.xb / 2), Y(B24) - 26, 'B2', C.main, pick), balloon(X(g.xb / 2) + 30, Y(B24) - 26, 'B4', C.main, pick));
|
||
parts.push(balloon(X(g.xf + (g.W - g.xf) / 2) - 15, Y(B57) + 30, 'B5', C.dist, pick), balloon(X(g.xf + (g.W - g.xf) / 2) + 15, Y(B57) + 30, 'B7', C.dist, pick));
|
||
parts.push(balloon(X(g.xf + (g.W - g.xf) / 2) - 15, Y(B68) - 30, 'B6', C.dist, pick), balloon(X(g.xf + (g.W - g.xf) / 2) + 15, Y(B68) - 30, 'B8', C.dist, pick));
|
||
if (r.shear.rows.length) parts.push(balloon(X(r.shear.rows[0].x) + 22, Y(g.H / 2), 'B9', C.shear, pick));
|
||
parts.push(balloon(X(g.W) + 22, Y(r.side.levels[1] ?? g.H / 2), 'B10', C.side, pick));
|
||
parts.push(text(X(0), by - 30, '← 배면 (뒷굽)', 'start', MUTED, 10), text(X(g.W), by - 30, '전면 (앞굽) →', 'end', MUTED, 10));
|
||
const W = X(g.W) + 50;
|
||
const H = Y(0) + 30;
|
||
return `<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" role="img" aria-label="교대축 직각 단면: 폭 ${g.W}, 두께 ${g.H}, 하면 ${r.dist.bottomText}">${parts.join('')}</svg>`;
|
||
}
|
||
|
||
/** 교대축 단면 (길이 L, 가로 축척 0.4배): B5–B8 선(이음 위치), B1–B4 점, B10-2 점. 왼쪽 IP3 쪽. */
|
||
export function abutFootingSectionLSvg(g: AbutFootingGeom, r: AbutFootingRebar, pick: AbutRebarPick = NO_PICK): string {
|
||
const kx = 0.045;
|
||
const kz = 0.12;
|
||
const x0 = 50;
|
||
const y0 = 30;
|
||
const X = (s: number) => x0 + s * kx;
|
||
const Y = (z: number) => y0 + (g.H - z) * kz;
|
||
const C = ABUT_FOOTING_COLORS;
|
||
const parts: string[] = [];
|
||
parts.push(`<rect x="${X(0)}" y="${Y(g.H)}" width="${g.L * kx}" height="${g.H * kz}" fill="${CONC}" stroke="${MUTED}"/>`);
|
||
const e = r.settings.cSide / g.angle.sin;
|
||
const line = (pts: [number, number][], color: string, marks: string[], w = 1.8, dash = '') =>
|
||
`<polyline points="${pts.map(([s, z]) => `${X(s).toFixed(1)},${Y(z).toFixed(1)}`).join(' ')}" fill="none" stroke="${color}" stroke-width="${w}"${dash ? ` stroke-dasharray="${dash}"` : ''} opacity="${opacity(pick, marks)}"/>`;
|
||
const { B13, B24, B57, B68 } = r.z;
|
||
parts.push(line([[e + 4, B68], [e + 4, B57], [g.L - e - 4, B57], [g.L - e - 4, B68]], C.dist, ['B5']));
|
||
parts.push(line([[e, B57], [e, B68], [g.L - e, B68], [g.L - e, B57]], C.dist, ['B6']));
|
||
parts.push(line([[e, B57 - 14], [g.L - e, B57 - 14]], C.dist, ['B7'], 1.4, '6 3'));
|
||
parts.push(line([[e, B68 + 14], [g.L - e, B68 + 14]], C.dist, ['B8'], 1.4, '6 3'));
|
||
// 이음 (B5 · B6): the two splice centres (± l_s around the middle) as ticks.
|
||
const lap = r.bars.find((b) => b.mark === 'B5')?.splice?.lap;
|
||
if (lap) {
|
||
for (const sc of [g.L / 2 - lap, g.L / 2 + lap]) {
|
||
parts.push(`<line x1="${X(sc)}" y1="${Y(B57) - 6}" x2="${X(sc)}" y2="${Y(B68) + 6}" stroke="${C.dist}" stroke-dasharray="2 2" opacity="${opacity(pick, ['B5', 'B6'])}"/>`);
|
||
}
|
||
parts.push(text(X(g.L / 2), Y(B57) - 10, `B5·B6 이음 l_s ${lap} (± l_s 엇갈림)`, 'middle', C.dist, 10));
|
||
}
|
||
const dot = (s: number, z: number, color: string, mark: string) =>
|
||
`<circle cx="${X(s).toFixed(1)}" cy="${Y(z).toFixed(1)}" r="1.8" fill="${color}" opacity="${opacity(pick, [mark])}"/>`;
|
||
r.main.positions.forEach((s, i) => {
|
||
const one = i % 2 === 0;
|
||
parts.push(dot(one ? s + r.sideShift.B1 : s, B13, C.main, one ? 'B1' : 'B3'));
|
||
parts.push(dot(s, B24, C.main, one ? 'B2' : 'B4'));
|
||
});
|
||
const d2 = r.side.depth2 / g.angle.sin;
|
||
for (const z of r.side.levels) parts.push(dot(d2, z, C.side, 'B10-2'), dot(g.L - d2, z, C.side, 'B10-2'));
|
||
parts.push(text(X(g.L / 2), Y(0) + 18, r.main.text, 'middle', INK, 10));
|
||
parts.push(balloon(X(g.L * 0.12), Y(B13) - 16, 'B1', C.main, pick), balloon(X(g.L * 0.12) + 28, Y(B13) - 16, 'B3', C.main, pick));
|
||
parts.push(balloon(X(g.L * 0.88), Y(B57) + 30, 'B5', C.dist, pick), balloon(X(g.L * 0.88) + 28, Y(B57) + 30, 'B7', C.dist, pick));
|
||
parts.push(balloon(X(g.L * 0.3), Y(B68) - 30, 'B6', C.dist, pick), balloon(X(g.L * 0.3) + 28, Y(B68) - 30, 'B8', C.dist, pick));
|
||
parts.push(balloon(X(g.L * 0.12), Y(B24) + 20, 'B2', C.main, pick), balloon(X(g.L * 0.12) + 28, Y(B24) + 20, 'B4', C.main, pick));
|
||
parts.push(balloon(X(g.L) + 22, Y(g.H / 2), 'B10', C.side, pick));
|
||
const W = X(g.L) + 50;
|
||
const H = Y(0) + 30;
|
||
return `<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" role="img" aria-label="교대축 단면: 길이 ${Math.round(g.L)}, ${r.main.text}">${parts.join('')}</svg>`;
|
||
}
|
||
|
||
/**
|
||
* 상면 평면: skew 평행사변형, 상면 B1·B3 (옵션에 따라 skew 선과 나란히 / 교대축 직각, 옵션 ② B1-S), B5·B7, B9, 벽체 밑 띠.
|
||
* `skewK` = 날개벽 방향의 s / t 비 (장면의 abutSkewK): s(x) = s + skewK · x. 위 = 전면, 왼쪽 = IP3 끝 (s = L).
|
||
*/
|
||
export function abutFootingRebarPlanSvg(g: AbutFootingGeom, r: AbutFootingRebar, skewK: number, pick: AbutRebarPick = NO_PICK): string {
|
||
const k = 0.045;
|
||
const shiftMax = Math.abs(skewK) * g.W;
|
||
const sMin = Math.min(0, skewK * g.W);
|
||
const x0 = 30;
|
||
const y0 = 24;
|
||
const X = (s: number) => x0 + (g.L + shiftMax + sMin - s) * k; // s = L (IP3) on the left
|
||
const Y = (x: number) => y0 + (g.W - x) * k; // 전면 (x = W) on top
|
||
const P = (s: number, x: number) => `${X(s + skewK * x).toFixed(1)},${Y(x).toFixed(1)}`;
|
||
// Option ② positions start after the end face that leans out (core: start = c + W·|cot θ|); with the faces leaning the
|
||
// other way (skewK < 0) that side is at x = W, so the square bars sit W·|cot θ| further toward s = 0 in this frame.
|
||
const SQ = (s: number) => (skewK >= 0 ? s : s - shiftMax);
|
||
const C = ABUT_FOOTING_COLORS;
|
||
const c = r.settings.cSide;
|
||
const parts: string[] = [];
|
||
parts.push(`<polygon points="${[P(0, 0), P(g.L, 0), P(g.L, g.W), P(0, g.W)].join(' ')}" fill="${CONC}" stroke="${MUTED}"/>`);
|
||
parts.push(`<polygon points="${[P(0, g.xb), P(g.L, g.xb), P(g.L, g.xf), P(0, g.xf)].join(' ')}" fill="${WALL}" opacity="0.7"/>`);
|
||
const seg = (a: string, b: string, color: string, mark: string, w = 1, dash = '') =>
|
||
`<line x1="${a.split(',')[0]}" y1="${a.split(',')[1]}" x2="${b.split(',')[0]}" y2="${b.split(',')[1]}" stroke="${color}" stroke-width="${w}"${dash ? ` stroke-dasharray="${dash}"` : ''} opacity="${opacity(pick, [mark])}"/>`;
|
||
// B5 · B7: along the axis at the kept top positions, cut square at the ends (끝 피복 c / sin θ).
|
||
const e = c / g.angle.sin;
|
||
r.dist.positions.forEach((x, i) => {
|
||
if (!r.dist.topKept[i]) return;
|
||
parts.push(seg(P(e, x), P(g.L - e, x), C.dist, i % 2 === 0 ? 'B5' : 'B7', 0.8));
|
||
});
|
||
// B1 · B3: option ① along the skew line from the end-face grid; option ② square to the axis.
|
||
r.main.positions.forEach((s, i) => {
|
||
const one = i % 2 === 0;
|
||
const xEnd = one ? g.W - c : g.xf - c;
|
||
if (r.dir === 'skew') parts.push(seg(P(s, c), P(s, xEnd), C.main, one ? 'B1' : 'B3', 0.8, one ? '' : '4 2'));
|
||
else parts.push(seg(`${X(SQ(s)).toFixed(1)},${Y(c).toFixed(1)}`, `${X(SQ(s)).toFixed(1)},${Y(xEnd).toFixed(1)}`, C.main, one ? 'B1' : 'B3', 0.8, one ? '' : '4 2'));
|
||
});
|
||
if (r.skewEnd) {
|
||
for (const off of r.skewEnd.offsets) {
|
||
parts.push(seg(P(c + off, c), P(c + off, g.W - c), C.main, 'B1-S', 1.4));
|
||
parts.push(seg(P(g.L - c - off, c), P(g.L - c - off, g.W - c), C.main, 'B1-S', 1.4));
|
||
}
|
||
}
|
||
// B9 dots (rows).
|
||
for (const row of r.shear.rows) {
|
||
for (const s of row.s) {
|
||
const at = r.dir === 'skew' ? P(s, row.x) : `${X(SQ(s)).toFixed(1)},${Y(row.x).toFixed(1)}`;
|
||
const [cx, cy] = at.split(',');
|
||
parts.push(`<circle cx="${cx}" cy="${cy}" r="1.2" fill="${C.shear}" opacity="${opacity(pick, ['B9'])}"/>`);
|
||
}
|
||
}
|
||
parts.push(text(X(g.L / 2 + skewK * (g.xb + g.xf) / 2), Y((g.xb + g.xf) / 2) + 4, '벽체 밑 (상면 B5·B7 없음, B9 없음)', 'middle', MUTED, 10));
|
||
parts.push(text(X(g.L / 2 + skewK * g.W), Y(g.W) - 6, '전면 (앞굽 끝)', 'middle', MUTED, 10));
|
||
parts.push(text(X(g.L / 2), Y(0) + 14, '배면 (뒷굽 끝)', 'middle', MUTED, 10));
|
||
parts.push(text(X(g.L) - 4, Y(g.W / 2), 'IP3', 'end', MUTED, 10), text(X(0) + 4, Y(g.W / 2), 'IP1', 'start', MUTED, 10));
|
||
parts.push(balloon(X(g.L * 0.75 + skewK * g.W * 0.7), Y(g.W * 0.7), 'B1', C.main, pick), balloon(X(g.L * 0.75 + skewK * g.W * 0.7) + 28, Y(g.W * 0.7), 'B3', C.main, pick));
|
||
parts.push(balloon(X(g.L * 0.4 + skewK * g.W * 0.9), Y(g.W * 0.92), 'B5', C.dist, pick), balloon(X(g.L * 0.4 + skewK * g.W * 0.9) + 28, Y(g.W * 0.92), 'B7', C.dist, pick));
|
||
parts.push(balloon(X(g.L * 0.25 + skewK * g.W * 0.1), Y(g.W * 0.1), 'B9', C.shear, pick));
|
||
if (r.skewEnd) parts.push(balloon(X(c + skewK * g.W * 0.5) - 26, Y(g.W * 0.5), 'B1-S', C.main, pick));
|
||
const W = x0 * 2 + (g.L + shiftMax) * k;
|
||
const H = Y(0) + 26;
|
||
return `<svg viewBox="0 0 ${W.toFixed(0)} ${H.toFixed(0)}" width="100%" role="img" aria-label="상면 평면: 배근 방향 ${r.dir === 'skew' ? '① skew' : '② 교대축 직각'}">${parts.join('')}</svg>`;
|
||
}
|