feat(abut-rebar): 교대 날개벽 배근 삽도 (전면도 · 배면도 · 헌치 · 모서리 평면)

- 전면도 · 배면도: 날개벽 방향 거리 × 높이, 교대 단면 · 저판 · 뒷굽 끝, 연직 배치 치수, 단 수
- 헌치 · 모서리 평면: WR7-1 · 7-2 · 7-3 · WR10 띠별 한 단, WR9 점
- verify/dump: 날개벽 삽도 덤프

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-11-04 18:00:41 +09:00
1 parent e6869d17be
commit d15b237100
3 files changed
+171

No files matched your search

+122
View File
@@ -0,0 +1,122 @@
/**
* 교대 날개벽 배근 삽도 (2026-11-04, 시안 ③ 날개벽 카드, 교대날개벽철근.md §10): 전면도 · 배면도 (날개벽 방향 거리 d × 높이),
* 헌치 · 모서리 평면. 순수 문자열 함수, 철근은 배근 규칙의 경로(wing.ts paths, 교대 s · t · z)에서 그린다.
* 색: 연직 #0F5C66, 수평 #B4531F (등단면 실선 · 변화단면1 파선 · 변화단면2 점선), 헌치 · 모서리 #C0501A, WR6 · WR20 #6B3FA0.
*/
import type { AbutWingGeom, AbutWingRebar, WingBarPath, WingMark } from '../core/abut/rebar/wing';
import { balloon, CONC, MUTED, NO_PICK, opacity, text, WALL, type AbutRebarPick } from './abut-footing-rebar-svg';
export const ABUT_WING_COLORS = { vertical: '#0F5C66', horizontal: '#B4531F', haunch: '#C0501A', other: '#6B3FA0' } as const;
const C = ABUT_WING_COLORS;
const COLOR = (m: WingMark): string =>
['WR1', 'WR2', 'WR11', 'WR12'].includes(m) ? C.vertical : ['WR3', 'WR4', 'WR5', 'WR13', 'WR14', 'WR15'].includes(m) ? C.horizontal : m === 'WR6' || m === 'WR20' ? C.other : C.haunch;
const DASH: Partial<Record<WingMark, string>> = { WR4: '6 3', WR14: '6 3', WR5: '2 2', WR15: '2 2', WR10: '6 3', WR20: '2 2' };
/** Wing frame of a plan point: d along the wing from the axis line, depth from the front face toward the back. */
function frame(g: AbutWingGeom) {
const [ws, wt] = g.w;
const iw = g.inward[0] * ws + g.inward[1] * wt;
const nv = [g.inward[0] - iw * ws, g.inward[1] - iw * wt];
const T = Math.hypot(nv[0], nv[1]);
const n = [nv[0] / T, nv[1] / T];
return {
T,
d: (s: number, t: number) => (s - g.p0[0]) * ws + (t - g.p0[1]) * wt,
depth: (s: number, t: number) => (s - g.p0[0]) * n[0] + (t - g.p0[1]) * n[1],
};
}
/** 전면도 (face 'front', 바깥에서 봄) 또는 배면도 ('back', 흙 쪽에서 봄): 가로 d (왼쪽 교대 → 오른쪽 끝), 세로 높이. */
export function abutWingFaceSvg(g: AbutWingGeom, r: AbutWingRebar, face: 'front' | 'back', pick: AbutRebarPick = NO_PICK): string {
const f = frame(g);
const k = 0.075;
// 왼쪽 끝 = 벽체 전면 (변화단면2 수평철근이 벽체를 지나 전면 − c까지 감).
const dMin = -(g.wallFrontT + 150) / Math.abs(g.w[1]);
const zTopMax = g.topStart + g.slope * g.L;
const X = (d: number) => 60 + (d - dMin) * k;
const Y = (z: number) => 50 + (zTopMax + 100 - z) * k;
const parts: string[] = [];
const general = g.form === 'general' && g.heelD < g.L;
const outline = [
[g.wallBackD, g.footTop], [general ? g.heelD : g.L, g.footTop], [g.L, general ? g.bottomEnd : g.footTop], [g.L, zTopMax], [g.wallBackD, g.topStart + g.slope * g.wallBackD],
];
// 교대 단면 (벽체 · 흉벽, 날개벽 시작 쪽) · 저판.
parts.push(`<rect x="${X(dMin)}" y="${Y(g.topStart)}" width="${(g.wallBackD - dMin) * k}" height="${(g.topStart - g.footTop) * k}" fill="${WALL}" stroke="${MUTED}" stroke-dasharray="4 3"/>`);
parts.push(`<rect x="${X(dMin)}" y="${Y(g.footTop)}" width="${(g.heelD - dMin) * k}" height="${(g.footTop - g.zB68 + 200) * k}" fill="${CONC}" stroke="${MUTED}"/>`);
parts.push(`<polygon points="${outline.map(([d, z]) => `${X(d).toFixed(1)},${Y(z).toFixed(1)}`).join(' ')}" fill="${CONC}" stroke="${MUTED}"/>`);
parts.push(text(X((dMin + g.wallBackD) / 2), Y(g.topStart) - 6, '교대 단면', 'middle', MUTED, 10));
parts.push(`<line x1="${X(g.heelD)}" y1="${Y(g.footTop) + 4}" x2="${X(g.heelD)}" y2="${Y(g.footTop) + 40}" stroke="${MUTED}" stroke-dasharray="3 3"/>`, text(X(g.heelD), Y(g.footTop) + 52, '뒷굽 끝', 'middle', MUTED, 10));
const onFace = (p: WingBarPath) => {
const dep = p.pts.map(([s, t]) => f.depth(s, t));
const avg = dep.reduce((a, b) => a + b, 0) / dep.length;
return face === 'front' ? avg < f.T / 2 : avg >= f.T / 2;
};
const show: WingMark[] = face === 'front' ? ['WR11', 'WR12', 'WR13', 'WR14', 'WR15', 'WR6'] : ['WR1', 'WR2', 'WR3', 'WR4', 'WR5', 'WR6', 'WR9'];
for (const p of r.paths) {
if (!show.includes(p.mark) || !onFace(p)) continue;
const pts = p.pts.map(([s, t, z]) => `${X(f.d(s, t)).toFixed(1)},${Y(z).toFixed(1)}`).join(' ');
const w = ['WR1', 'WR2', 'WR11', 'WR12'].includes(p.mark) ? 0.8 : 1;
parts.push(`<polyline points="${pts}" fill="none" stroke="${COLOR(p.mark)}" stroke-width="${w}"${DASH[p.mark] ? ` stroke-dasharray="${DASH[p.mark]}"` : ''} opacity="${opacity(pick, [p.mark])}"/>`);
}
const v = r.vertical[face];
parts.push(text(X(g.L / 2), Y(g.footTop) + 70, `${face === 'front' ? '전면' : '배면'} 연직 (끝 → 교대) ${v.text}`, 'middle', MUTED, 10));
parts.push(text(X(g.L) + 6, Y(zTopMax - 400), `${r.levels[face].end}단`, 'start', MUTED, 10), text(X(g.wallBackD) + 6, Y(g.topStart - 400), `${r.levels[face].start}단`, 'start', MUTED, 10));
const vm = face === 'front' ? ['WR12', 'WR11'] : ['WR2', 'WR1'];
const hm = face === 'front' ? ['WR13', 'WR14', 'WR15'] : ['WR3', 'WR4', 'WR5'];
const top = 22;
parts.push(balloon(X(g.heelD - 300), top, vm[0], C.vertical, pick), balloon(X(g.heelD + 1500), top, vm[1], C.vertical, pick));
parts.push(balloon(X(g.L) + 30, Y(zTopMax - 200), hm[0], C.horizontal, pick), balloon(X(g.L) + 30, Y(g.seat + 400), hm[1], C.horizontal, pick), balloon(X(g.L) + 30, Y(g.footTop + 600), hm[2], C.horizontal, pick));
if (g.form === 'general') parts.push(balloon(X(g.L - 800), Y(g.bottomEnd) + 40, 'WR6', C.other, pick));
if (face === 'back' && g.haunch > 0) parts.push(balloon(X(g.wallBackD) - 4, top, 'WR9', C.haunch, pick));
const W = X(g.L) + 70;
const H = Y(g.zB68) + 80;
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="날개벽 ${face === 'front' ? '전면도' : '배면도'}">${parts.join('')}</svg>`;
}
/** 헌치 · 모서리 평면 (교대 s · t, 날개벽이 교대와 만나는 모서리 둘레): 헌치 WR7-1 · 7-2 · 7-3과 WR10의 띠별 한 단, WR9 점. */
export function abutWingCornerSvg(g: AbutWingGeom, r: AbutWingRebar, pick: AbutRebarPick = NO_PICK): string {
const f = frame(g);
// 보는 범위: 모서리 둘레 ± 3 m.
const cornerT = g.wallBackT;
const sC = g.p0[0] + g.inward[0];
const span = 3200;
const sMin = sC - span;
const sMax = sC + span;
const tMin = cornerT - span;
const tMax = Math.min(g.wallFrontT, cornerT + span);
const k = 0.12;
const flip = g.inward[0] < 0 ? -1 : 1; // 날개벽이 왼쪽에 오도록
const X = (s: number) => 40 + (flip > 0 ? s - sMin : sMax - s) * k;
const Y = (t: number) => 30 + (tMax - t) * k;
const parts: string[] = [];
// 교대 (벽체 · 흉벽 배면 위쪽이 교대) · 날개벽 띠.
parts.push(`<rect x="${Math.min(X(sMin), X(sMax))}" y="${Y(tMax)}" width="${(sMax - sMin) * k}" height="${(tMax - g.wallBackT) * k}" fill="${WALL}" stroke="${MUTED}"/>`);
parts.push(`<line x1="${Math.min(X(sMin), X(sMax))}" y1="${Y(g.parapetBackT)}" x2="${Math.max(X(sMin), X(sMax))}" y2="${Y(g.parapetBackT)}" stroke="${MUTED}" stroke-dasharray="4 3"/>`);
const P = (d: number, dep: number) => {
const [ws, wt] = g.w;
const iw = g.inward[0] * ws + g.inward[1] * wt;
const n = [(g.inward[0] - iw * ws) / f.T, (g.inward[1] - iw * wt) / f.T];
return [g.p0[0] + d * ws + dep * n[0], g.p0[1] + d * wt + dep * n[1]];
};
const dEnd = Math.min(g.L, (cornerT - tMin) / Math.abs(g.w[1]));
const wing = [P(0, 0), P(dEnd, 0), P(dEnd, f.T), P(0, f.T)];
parts.push(`<polygon points="${wing.map(([s, t]) => `${X(s).toFixed(1)},${Y(t).toFixed(1)}`).join(' ')}" fill="${CONC}" stroke="${MUTED}"/>`);
parts.push(text(X(P(dEnd * 0.7, f.T / 2)[0]), Y(P(dEnd * 0.7, f.T / 2)[1]), '날개벽', 'middle', MUTED, 10));
parts.push(text(Math.min(X(sMin), X(sMax)) + 6, Y(g.wallBackT) - 6, '벽체 배면 (흉벽 배면 점선)', 'start', MUTED, 10));
// 한 단씩: 각 띠의 첫 단, WR10 첫 단 · 흉벽 구간 첫 단.
const firstOf = (mark: WingMark) => r.paths.find((p) => p.mark === mark);
const wr10Up = r.paths.find((p) => p.mark === 'WR10' && p.pts[0][2] > g.bracketTop);
for (const p of [firstOf('WR7-1'), firstOf('WR7-2'), firstOf('WR7-3'), firstOf('WR10'), wr10Up, firstOf('WR3'), firstOf('WR13')]) {
if (!p) continue;
const pts = p.pts.map(([s, t]) => `${X(s).toFixed(1)},${Y(t).toFixed(1)}`).join(' ');
parts.push(`<polyline points="${pts}" fill="none" stroke="${COLOR(p.mark)}" stroke-width="1.6"${DASH[p.mark] ? ` stroke-dasharray="${DASH[p.mark]}"` : ''} opacity="${opacity(pick, [p.mark])}"/>`);
}
for (const p of r.paths.filter((q) => q.mark === 'WR9')) parts.push(`<circle cx="${X(p.pts[1][0]).toFixed(1)}" cy="${Y(p.pts[1][1]).toFixed(1)}" r="3" fill="${C.haunch}" opacity="${opacity(pick, ['WR9'])}"/>`);
const wr7 = firstOf('WR7-3');
const at = wr7 ? [X(wr7.pts[2][0]), Y(wr7.pts[2][1])] : [X(sC), Y(cornerT)];
parts.push(balloon(at[0] + 40, at[1] + 40, 'WR7', C.haunch, pick), balloon(at[0] + 80, at[1] + 40, 'WR10', C.haunch, pick), balloon(at[0] + 120, at[1] + 40, 'WR9', C.haunch, pick));
parts.push(text(40, Y(tMin) + 18, `헌치 ${Math.round(g.haunch)} · WR7 띠: 위 ${r.haunchLevels['WR7-1']} · 가운데 ${r.haunchLevels['WR7-2']} · 아래 ${r.haunchLevels['WR7-3']}단 · 날개벽 두께 ${Math.round(f.T)}, 수평철근 사이 WR20`, 'start', MUTED, 10));
const W = (sMax - sMin) * k + 80;
const H = Y(tMin) + 30;
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>`;
}