feat(abut-rebar): ③ 교대 화면 틀과 확대기초 배근 카드

- ③에서 교대를 고르면 부재 · 교대 목록(받침블럭 → 벽체 → 흉벽·브라켓 → 확대기초 → 날개벽 → 접속슬래브)과 카드를 보여 줌.
  확대기초만 규칙이 있고 나머지는 '다음 단계'. 선택한 교대만 3D에 그림
- 확대기초 카드: 보기 탭 3개(교대축 직각 단면 · 교대축 단면 · 상면 평면)와 번호 풍선 강조, 범례,
  기본간격 · 배근 방향 ① / ② · 피복 · 여유 · B9 배수 입력, 자동 값(cycle 간격 · B10 위 여유 · l_s · 옆붙임),
  철근표(묶음별 실간격 수정 · 수정됨 · 되돌리기, 비고 펼치기), 근거 줄, RB-AF 알림
- 입력은 AbutRecord.rebar.footing에 바꾼 값만 저장 · 프로젝트 파일 검증. fck는 공통 설정의 기초부 값

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-11-04 11:12:43 +09:00
1 parent e00e43ca61
commit b06bf368b6
9 files changed
+591 -10

No files matched your search

+192
View File
@@ -0,0 +1,192 @@
/**
* 교대 저판 배근 삽도 (2026-11-04, 시안 ③ 확대기초 카드, 교대확대기초철근.md §10): 교대축 직각 단면 (폭 W), 교대축 단면 (길이 L),
* 상면 평면. 순수 문자열 함수. 번호 풍선(data-pick)을 누르면 그 철근만 강조한다; 강조할 번호는 `pick`으로 받는다.
* 색은 역할 기준: 주철근 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>`;
/** Opacity of one bar group: dimmed when another bar is picked. B10 picks cover B10-1 and B10-2. */
function opacity(pick: string | null, marks: string[]): number {
if (!pick) return 1;
return marks.some((m) => m === pick || (pick === 'B10' && m.startsWith('B10'))) ? 1 : 0.15;
}
/** A clickable 번호 풍선. */
function balloon(x: number, y: number, mark: string, color: string, pick: string | null): string {
const on = pick === mark;
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: string | null = null): 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: string | null = null): 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: string | null = null): 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>`;
}