diff --git a/src/view/coping-rebar-svg.ts b/src/view/coping-rebar-svg.ts
index 450d6c8..77b3b22 100644
--- a/src/view/coping-rebar-svg.ts
+++ b/src/view/coping-rebar-svg.ts
@@ -18,21 +18,98 @@ export function copingBarGroup(name: string): keyof typeof COLOR {
const isCoping = (r: BarRecord) => /^C\d/.test(r.name);
-/** 정면: the bars seen from the 교축 direction. */
+/** Bar groups called out with a bubble on the 정면 (시안 ③, 승인 2026-10-26). `where` is the side of the drawing. */
+const CALLOUTS: { label: string; names: string[]; where: 'left-top' | 'left' | 'left-bottom' | 'bottom' | 'bottom-right' | 'right-bottom' | 'right' | 'right-top' | 'top' }[] = [
+ { label: 'C1–6', names: ['C1', 'C2', 'C3', 'C4', 'C5', 'C6'], where: 'left-top' },
+ { label: 'C12·13', names: ['C12', 'C13'], where: 'left' },
+ { label: 'C9', names: ['C9'], where: 'bottom' },
+ { label: 'C10', names: ['C10'], where: 'bottom' },
+ { label: 'C11', names: ['C11'], where: 'bottom-right' },
+ { label: 'C7·8', names: ['C7', 'C8'], where: 'right-bottom' },
+ { label: 'C14', names: ['C14'], where: 'right' },
+ { label: 'C15', names: ['C15'], where: 'right-top' },
+ { label: 'C16', names: ['C16'], where: 'top' },
+];
+
+/** The 시안 길이 치수 along the top (mm); the parts sum to the coping length for P1 (300 + 4500 + 2400 + 4500 + 300 = 12000). */
+const TOP_DIMS: { text: string; len: number }[] = [
+ { text: '300', len: 300 },
+ { text: '18@250=4500', len: 4500 },
+ { text: '8@300=2400', len: 2400 },
+ { text: '18@250=4500', len: 4500 },
+ { text: '300', len: 300 },
+];
+
+/** 정면: the bars seen from the 교축 direction, with the bar-group bubbles and the length dimensions (시안 ③). */
export function copingFrontSvg(records: BarRecord[], lengthMm: number): string {
const bars = records.filter(isCoping);
const els = bars.flatMap((r) => r.pts.map((p) => p[1]));
const top = Math.max(...els);
const bottom = Math.min(...els);
const Y = (el: number) => (top - el) * 1000;
+ const X = (s: number) => s * 1000;
+ const xs = bars.flatMap((r) => r.pts.map((p) => X(p[0])));
+ const ys = bars.flatMap((r) => r.pts.map((p) => Y(p[1])));
+ const minX = Math.min(...xs);
+ const maxX = Math.max(...xs);
+ const minY = Math.min(...ys);
+ const maxY = Math.max(...ys);
+ const midX = (minX + maxX) / 2;
+ const midY = (minY + maxY) / 2;
const parts: string[] = [];
for (const r of bars) {
- const pts = r.pts.map((p) => `${p[0] * 1000},${Y(p[1])}`).join(' ');
+ const pts = r.pts.map((p) => `${X(p[0])},${Y(p[1])}`).join(' ');
parts.push(``);
}
- parts.push(`코핑 길이 ${lengthMm}`);
- const h = (top - bottom) * 1000;
- return ``;
+
+ // Top dimension line: the parts of the 시안, only when they add up to the coping length.
+ const dimTotal = TOP_DIMS.reduce((s, d) => s + d.len, 0);
+ const dimY = minY - 1100;
+ if (Math.abs(dimTotal - lengthMm) < 1) {
+ let x = minX;
+ for (const d of TOP_DIMS) {
+ const x2 = x + d.len;
+ parts.push(`${d.text}`);
+ x = x2;
+ }
+ }
+ parts.push(`코핑 길이 ${lengthMm}`);
+
+ // Bubbles: one per bar group, a leader from the bubble to the middle of the group's bars.
+ const at = (where: string): [number, number] => {
+ const left = minX - 900;
+ const right = maxX + 900;
+ const top2 = minY - 700;
+ const bot = maxY + 800;
+ switch (where) {
+ case 'left-top': return [left, top2];
+ case 'left': return [left, midY + 300];
+ case 'left-bottom': return [left, bot];
+ case 'bottom': return [midX, bot + 300];
+ case 'bottom-right': return [right - 1200, bot + 300];
+ case 'right-bottom': return [right, bot - 100];
+ case 'right': return [right, midY - 100];
+ case 'right-top': return [right, top2];
+ default: return [midX + 1200, top2 - 300];
+ }
+ };
+ for (const c of CALLOUTS) {
+ const hit = bars.filter((r) => c.names.includes(r.name));
+ if (hit.length === 0) continue;
+ const pts = hit.flatMap((r) => r.pts.map((p) => [X(p[0]), Y(p[1])] as [number, number]));
+ const gx = pts.reduce((s, p) => s + p[0], 0) / pts.length;
+ const gy = pts.reduce((s, p) => s + p[1], 0) / pts.length;
+ const [bx, by] = at(c.where);
+ parts.push(``);
+ parts.push(``);
+ parts.push(`${c.label}`);
+ }
+
+ const vx = minX - 1500;
+ const vy = minY - 1900;
+ const vw = maxX - minX + 3000;
+ const vh = maxY - minY + 3400;
+ return ``;
}
/**
diff --git a/tests/coping-rebar-svg.test.ts b/tests/coping-rebar-svg.test.ts
index 6a08fa8..4e405eb 100644
--- a/tests/coping-rebar-svg.test.ts
+++ b/tests/coping-rebar-svg.test.ts
@@ -41,3 +41,17 @@ describe('단면 · 폐합 글자 겹침 (승인 2026-10-26)', () => {
expect(labels).toBe(names.size);
});
});
+
+describe('정면 번호 풍선과 치수 (승인 2026-10-26, 시안 ③)', () => {
+ const svg = copingFrontSvg(scene.records, model.copingL * 1000);
+ it('has one bubble per bar group, with its label', () => {
+ for (const label of ['C1–6', 'C12·13', 'C9', 'C10', 'C11', 'C7·8', 'C14', 'C15', 'C16']) {
+ expect(svg).toContain(`>${label}<`);
+ }
+ });
+ it('writes the 시안 length dimension (300 · 4500 · 2400 · 4500 · 300 = 12000)', () => {
+ expect(svg).toContain('18@250=4500');
+ expect(svg).toContain('8@300=2400');
+ expect(svg).toContain('코핑 길이 12000');
+ });
+});