feat(rebar): bar-group bubbles and the length dimension on the coping 정면 (시안 ③)

- Nine bubbles (C1–6, C12·13, C9, C10, C11, C7·8, C14, C15, C16), each with a
  leader to the middle of its bars.
- The top dimension 300 · 18@250=4500 · 8@300=2400 · 18@250=4500 · 300, drawn
  only when it adds up to the coping length.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-10-26 14:45:16 +09:00
1 parent 32cf01a29f
commit a509d00532
2 files changed
+96 -5

No files matched your search

+82 -5
View File
@@ -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(`<polyline points="${pts}" fill="none" stroke="${COLOR[copingBarGroup(r.name)]}" stroke-width="10"></polyline>`);
}
parts.push(`<text x="${lengthMm / 2}" y="${-200}" text-anchor="middle" font-size="260" fill="#18212B">코핑 길이 ${lengthMm}</text>`);
const h = (top - bottom) * 1000;
return `<svg viewBox="-400 -700 ${lengthMm + 800} ${h + 1000}" width="100%" style="max-width: 760px; height: auto" role="img" aria-label="코핑 배근 정면">${parts.join('')}</svg>`;
// 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(`<line x1="${x}" y1="${dimY}" x2="${x2}" y2="${dimY}" stroke="#18212B" stroke-width="10"></line><text x="${(x + x2) / 2}" y="${dimY - 80}" text-anchor="middle" font-size="150" fill="#18212B">${d.text}</text>`);
x = x2;
}
}
parts.push(`<text x="${midX}" y="${minY - 1500}" text-anchor="middle" font-size="200" fill="#18212B">코핑 길이 ${lengthMm}</text>`);
// 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(`<line x1="${bx}" y1="${by}" x2="${gx}" y2="${gy}" stroke="#55606E" stroke-width="8"></line>`);
parts.push(`<circle cx="${bx}" cy="${by}" r="260" fill="#FFFFFF" stroke="${COLOR[copingBarGroup(hit[0].name)]}" stroke-width="12"></circle>`);
parts.push(`<text x="${bx}" y="${by + 90}" text-anchor="middle" font-size="210" fill="#18212B">${c.label}</text>`);
}
const vx = minX - 1500;
const vy = minY - 1900;
const vw = maxX - minX + 3000;
const vh = maxY - minY + 3400;
return `<svg viewBox="${vx} ${vy} ${vw} ${vh}" width="100%" style="max-width: 760px; height: auto" role="img" aria-label="코핑 배근 정면 · 번호 풍선 · 길이 치수">${parts.join('')}</svg>`;
}
/**