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 `${parts.join('')}`; + + // 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 `${parts.join('')}`; } /** 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'); + }); +});