diff --git a/src/view/abut-footing-rebar-svg.ts b/src/view/abut-footing-rebar-svg.ts
index a31e699..54cee13 100644
--- a/src/view/abut-footing-rebar-svg.ts
+++ b/src/view/abut-footing-rebar-svg.ts
@@ -6,26 +6,26 @@
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';
+export const INK = '#18212b';
+export const MUTED = '#55606e';
+export const CONC = '#eef1f4';
+export const WALL = '#d9dee4';
-const text = (x: number, y: number, s: string, anchor = 'middle', fill = INK, size = 11) =>
+export const text = (x: number, y: number, s: string, anchor = 'middle', fill = INK, size = 11) =>
`${s}`;
/** Selected bars (철근표 번호) and 선택만 보기. An empty selection shows every bar. */
export type AbutRebarPick = { marks: ReadonlySet; only: boolean };
-const NO_PICK: AbutRebarPick = { marks: new Set(), only: false };
+export 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 {
+export 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 {
+export 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;
diff --git a/src/view/abut-wall-rebar-svg.ts b/src/view/abut-wall-rebar-svg.ts
new file mode 100644
index 0000000..79673e2
--- /dev/null
+++ b/src/view/abut-wall-rebar-svg.ts
@@ -0,0 +1,117 @@
+/**
+ * 교대 벽체 배근 삽도 (2026-11-04, 시안 ③ 벽체 카드, 교대벽체철근.md §9): 교대축 직각 단면 (구간 1), 교대축 단면 (계단).
+ * 순수 문자열 함수. 철근은 배근 규칙의 경로(wall.ts paths)에서 그린다. 번호 풍선(data-pick)과 선택은 저판 삽도와 같다.
+ * 색: 연직 #0F5C66, 수평 #B4531F, 간격재 #6B3FA0, 계단 상면 W9 · W10 #C0501A (W10 파선).
+ */
+import type { AbutWallGeom, AbutWallRebar, WallBarPath, WallMark } from '../core/abut/rebar/wall';
+import { balloon, CONC, MUTED, NO_PICK, opacity, text, WALL, type AbutRebarPick } from './abut-footing-rebar-svg';
+
+export const ABUT_WALL_COLORS = { vertical: '#0F5C66', horizontal: '#B4531F', tie: '#6B3FA0', step: '#C0501A' } as const;
+const C = ABUT_WALL_COLORS;
+const COLOR: Record = {
+ W1: C.vertical, W3: C.vertical, W4: C.vertical, W11: C.vertical,
+ W2: C.horizontal, W5: C.horizontal, W6: C.horizontal, W8: C.horizontal,
+ W7: C.tie, W9: C.step, W10: C.step,
+};
+const DASH: Partial> = { W4: '6 3', W7: '3 3', W10: '6 3' };
+
+/** 교대축 직각 단면 at 구간 1 (IP3 끝): t 가로 (왼쪽 배면 → 오른쪽 전면), z 세로. */
+export function abutWallSectionSvg(g: AbutWallGeom, r: AbutWallRebar, pick: AbutRebarPick = NO_PICK): string {
+ const seg = g.segments.find((s) => s.no === 1) ?? g.segments[0];
+ const uMid = (seg.from + seg.to) / 2;
+ const k = 0.1;
+ const x0 = 70;
+ const zTop = g.HF + seg.H;
+ const zMax = Math.max(zTop + r.ld.H19, g.bracketTop[0], g.bracketTop[1]) + 150;
+ const X = (t: number) => x0 + (t - g.xb) * k;
+ const Y = (z: number) => 30 + (zMax - z) * k;
+ const parts: string[] = [];
+ const pad = 600;
+ // 저판 (위 부분) · 벽체 · 교좌면 뒤 흉벽 · 브라켓 (점선).
+ parts.push(``);
+ parts.push(``);
+ const bt = Math.min(g.bracketTop[0], g.bracketTop[1]);
+ parts.push(``);
+ parts.push(text((X(g.xb) + X(0)) / 2, Y(bt) - 6, '흉벽 · 브라켓', 'middle', MUTED, 10));
+ parts.push(text((X(0) + X(g.xf)) / 2, Y(zTop) - 6, `교좌면 (구간 1)`, 'middle', MUTED, 10));
+ const line = (p: WallBarPath, w = 1.6) =>
+ ``;
+ const dot = (p: WallBarPath) => {
+ const [, t, z] = p.pts[0];
+ return ``;
+ };
+ // Bars across the section (in the t-z plane): the first of each mark in 구간 1.
+ const first = (mark: WallMark) => r.paths.find((p) => p.mark === mark && p.pts[1][0] >= seg.from && p.pts[1][0] < seg.to);
+ for (const mark of ['W7', 'W1', 'W3', 'W4', 'W2'] as WallMark[]) {
+ if (mark === 'W7') {
+ for (const p of r.paths.filter((q) => q.mark === 'W7' && Math.abs(q.pts[1][0] - (first('W7')?.pts[1][0] ?? NaN)) < 1e-6)) parts.push(line(p, 1.1));
+ } else {
+ const p = first(mark);
+ if (p) parts.push(line(p, mark === 'W4' ? 1.3 : 1.8));
+ }
+ }
+ // Bars along the axis that pass the middle of 구간 1: dots.
+ const along = r.paths.filter((p) => ['W5', 'W6', 'W9', 'W10'].includes(p.mark) && Math.min(p.pts[0][0], p.pts[1][0]) <= uMid && Math.max(p.pts[0][0], p.pts[1][0]) >= uMid);
+ for (const p of along) parts.push(dot(p));
+ // 치수: 전면 수평 단 (구간 1), 상면 W9 간격.
+ const lv = r.levels.find((l) => l.no === seg.no);
+ if (lv) {
+ const xd = X(g.xf) + 14;
+ parts.push(``);
+ parts.push(`${r.settings.c} · ${lv.text}`);
+ }
+ if (r.x.W9.length > 1) parts.push(text((X(r.x.W9[0]) + X(r.x.W9[r.x.W9.length - 1])) / 2, Y(zTop) + 22, `W9 · W10 ${r.x.W9.length - 1}@${Math.round(r.x.W9[0] - r.x.W9[1])}`, 'middle', C.step, 10));
+ // 번호 풍선.
+ const yMid = Y(g.HF + seg.H / 2);
+ parts.push(balloon(X(g.xf) + 60, yMid - 40, 'W1', C.vertical, pick), balloon(X(g.xf) + 60, yMid + 10, 'W5', C.horizontal, pick));
+ parts.push(balloon(X(g.xb) - 40, yMid - 60, 'W3', C.vertical, pick), balloon(X(g.xb) - 40, yMid - 10, 'W4', C.vertical, pick), balloon(X(g.xb) - 40, yMid + 40, 'W6', C.horizontal, pick));
+ parts.push(balloon((X(0) + X(g.xf)) / 2 - 20, Y(zTop) - 34, 'W2', C.horizontal, pick), balloon((X(0) + X(g.xf)) / 2 + 20, Y(zTop) - 34, 'W9', C.step, pick), balloon((X(0) + X(g.xf)) / 2 + 60, Y(zTop) - 34, 'W10', C.step, pick));
+ parts.push(balloon((X(g.xb) + X(g.xf)) / 2, Y(g.HF + 300), 'W7', C.tie, pick));
+ const W = X(g.xf + pad) + 40;
+ const H = Y(0) + 10;
+ return ``;
+}
+
+/** 교대축 단면 (계단): u 가로 (왼쪽 IP3 = 구간 1 → 오른쪽 IP1, 시안과 같은 좌우), z 세로. 전면 철근 (W1 · W5) · 계단 상면 · 끝면. */
+export function abutWallElevationSvg(g: AbutWallGeom, r: AbutWallRebar, pick: AbutRebarPick = NO_PICK): string {
+ const k = 0.045;
+ const kz = 0.08;
+ const x0 = 70;
+ const zMax = g.HF + Math.max(...g.segments.map((s) => s.H)) + 300;
+ const X = (u: number) => x0 + (g.L - u) * k;
+ const Y = (z: number) => 56 + (zMax - z) * kz;
+ const parts: string[] = [];
+ parts.push(``);
+ let outline = `M${X(0).toFixed(1)},${Y(g.HF).toFixed(1)}`;
+ for (const s of g.segments) outline += ` L${X(s.from).toFixed(1)},${Y(g.HF + s.H).toFixed(1)} L${X(s.to).toFixed(1)},${Y(g.HF + s.H).toFixed(1)}`;
+ outline += ` L${X(g.L).toFixed(1)},${Y(g.HF).toFixed(1)} Z`;
+ parts.push(``);
+ for (const s of g.segments) parts.push(text((X(s.from) + X(s.to)) / 2, Y(g.HF + s.H) - 8, `구간 ${s.no} · ${Math.round(s.H)}`, 'middle', MUTED, 10));
+ const seg = (mark: WallMark, a: [number, number], b: [number, number], w: number) =>
+ ``;
+ const front = r.x.W1;
+ const w11Front = Math.max(...r.paths.filter((p) => p.mark === 'W11').map((p) => p.pts[1][1]));
+ for (const p of r.paths) {
+ const [u0, t0, z0] = p.pts[0];
+ const [u1, , z1] = p.pts[p.pts.length - 1];
+ if (p.mark === 'W1') parts.push(seg('W1', [p.pts[1][0], p.pts[1][2]], [u1, z1], 0.7));
+ else if (p.mark === 'W5') parts.push(seg('W5', [u0, z0], [u1, z1], 0.8));
+ else if ((p.mark === 'W9' || p.mark === 'W10') && Math.abs(t0 - r.x.W9[0]) < 1e-6) parts.push(seg(p.mark, [u0, z0], [u1, z1], 2));
+ else if (p.mark === 'W11' && Math.abs(t0 - w11Front) < 1e-6) parts.push(seg('W11', [p.pts[1][0], p.pts[1][2]], [u1, z1], 2));
+ else if (p.mark === 'W8') parts.push(``);
+ }
+ // 단 치수 (양끝).
+ const low = r.levels.find((l) => l.no === 1);
+ const high = r.levels.find((l) => l.no === g.segments.length);
+ const lvText = [low, high && g.segments.length > 1 ? high : undefined].filter((l) => l).map((l) => `구간 ${l!.no} 수평 ${l!.text}`).join(' ');
+ parts.push(text((X(0) + X(g.L)) / 2, Y(0) + 30, lvText, 'middle', MUTED, 10));
+ parts.push(text((X(0) + X(g.L)) / 2, Y(g.HF / 2), `저판 (연직철근 갈고리는 B6 · B8 위) · 전면 W1 x ${Math.round(front * 10) / 10}`, 'middle', MUTED, 10));
+ parts.push(text(X(g.L), Y(0) + 14, 'IP3 (구간 1)', 'start', MUTED, 10), text(X(0), Y(0) + 14, 'IP1', 'end', MUTED, 10));
+ const top = 20;
+ parts.push(balloon(X(g.L * 0.85), top, 'W9', C.step, pick), balloon(X(g.L * 0.5), top, 'W10', C.step, pick));
+ parts.push(balloon(X(g.L * 0.3), top, 'W1', C.vertical, pick), balloon(X(g.L * 0.65), top, 'W5', C.horizontal, pick));
+ parts.push(balloon(X(0) + 34, Y(g.HF + 1200), 'W11', C.vertical, pick), balloon(X(0) + 34, Y(g.HF + 600), 'W8', C.horizontal, pick));
+ const W = X(0) + 60;
+ const H = Y(0) + 40;
+ return ``;
+}
diff --git a/tests/abut-rebar-wall.test.ts b/tests/abut-rebar-wall.test.ts
index f80184d..9483d5b 100644
--- a/tests/abut-rebar-wall.test.ts
+++ b/tests/abut-rebar-wall.test.ts
@@ -154,3 +154,23 @@ describe('③ 벽체 배근 입력 저장', () => {
expect(() => checkStoredAbutments([{ ...rec, rebar: { wall: { sL: 'x' } } as never }])).toThrow('벽체 배근');
});
});
+
+import { abutWallElevationSvg, abutWallSectionSvg } from '../src/view/abut-wall-rebar-svg';
+
+describe('③ 벽체 삽도', () => {
+ it('단면: 구간 1 단 치수 "100 · 17@125=2.125 · 147", W9 · W10 간격, 번호 풍선', () => {
+ const svg = abutWallSectionSvg(geom, A1);
+ expect(svg).toContain('100 · 17@125=2.125 · 147');
+ expect(svg).toContain('W9 · W10 5@250');
+ for (const m of ['W1', 'W2', 'W3', 'W4', 'W5', 'W6', 'W7', 'W9', 'W10']) expect(svg).toContain(`data-pick="${m}"`);
+ });
+
+ it('계단 단면: 구간별 높이, 양끝 단 치수, W8 · W11 풍선; 선택하면 나머지는 흐리게 / 숨김', () => {
+ const svg = abutWallElevationSvg(geom, A1);
+ expect(svg).toContain('구간 1 · 2372');
+ expect(svg).toContain('19@125=2.375 · 181');
+ for (const m of ['W8', 'W11']) expect(svg).toContain(`data-pick="${m}"`);
+ expect(abutWallElevationSvg(geom, A1, { marks: new Set(['W9']), only: false })).toContain('opacity="0.15"');
+ expect(abutWallElevationSvg(geom, A1, { marks: new Set(['W9']), only: true })).toContain('opacity="0"');
+ });
+});
diff --git a/verify/dump/abut-wall-svg.dump.ts b/verify/dump/abut-wall-svg.dump.ts
new file mode 100644
index 0000000..703a69f
--- /dev/null
+++ b/verify/dump/abut-wall-svg.dump.ts
@@ -0,0 +1,28 @@
+// 교대 A1 벽체 배근 삽도 2장을 SVG로 저장한다 (PNG 확인: python3 -I ../verify/svg_to_png.py