feat(rebar): 단면·폐합 section with the top-row and side-row chains and bar-group bubbles

- Top row (C1–C8) spacing along the 교축 from chainDims, between the coping's
  two long edges (±B/2).
- Side rows (C12·C13) along the EL, from the coping top EL down to the lowest
  side bar.
- One bubble per bar group cut at the station, with a leader to its bars.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-10-26 15:05:37 +09:00
1 parent 41c1ecd340
commit d5bf02e36b
3 files changed
+77 -19

No files matched your search

+1 -1
View File
@@ -1107,7 +1107,7 @@ function renderCopingDrawings(box: HTMLDivElement, pier: Pier): void {
const label = el('label', '단면 위치 s (m, 코핑 시작에서) ');
label.appendChild(station);
wrap.appendChild(label);
fig.innerHTML = copingSectionSvg(rebar3d.records, at);
fig.innerHTML = copingSectionSvg(rebar3d.records, at, pier.model.copingRefTop, pier.model.copingB * 1000);
fig.appendChild(el('figcaption', `단면 · 폐합 구성 · s = ${at} m에서 자른 철근 (C9–C11 폐합 포함)`, 'muted small'));
}
wrap.appendChild(fig);
+70 -13
View File
@@ -168,10 +168,12 @@ export function copingFrontSvg(records: BarRecord[], lengthMm: number, columnSm
}
/**
* 단면 · 폐합 구성 at the station s0 (m): the bars that cross it, seen along the coping length.
* A bar crossing s0 is drawn at the crossing point (n, EL); a bar along the 교축 at s0 is drawn as its line.
* 단면 · 폐합 구성 at the station s0 (m): the bars that cross it, seen along the coping length (승인 2026-10-26).
* x = 교축 (n, mm, centre 0), y = EL (mm, down). Dimensions: the top row spacing (3, chainDims over the crossings of the
* top row), the side-row spacing (4, from the coping top EL down to the lowest side bar), and the bubbles (3) with the
* bar-group labels, one per group.
*/
export function copingSectionSvg(records: BarRecord[], s0: number): string {
export function copingSectionSvg(records: BarRecord[], s0: number, copingTopEl?: number, copingWidthMm?: number): string {
const crossings: { name: string; x: number; y: number; group: keyof typeof COLOR }[] = [];
const bars = records.filter(isCoping);
const els = bars.flatMap((r) => r.pts.map((p) => p[1]));
@@ -187,24 +189,79 @@ export function copingSectionSvg(records: BarRecord[], s0: number): string {
crossings.push({ name: r.name, x: a[2] + (b[2] - a[2]) * t, y: a[1] + (b[1] - a[1]) * t, group: copingBarGroup(r.name) });
}
}
// The same bar is recorded in several pieces, so the same spot can be crossed more than once: draw each spot once,
// and write each bar name once (at its first crossing), so the labels do not pile up.
// The same bar is recorded in several pieces, so draw each spot once.
const seen = new Set<string>();
const named = new Set<string>();
const unique = crossings.filter((c) => {
const key = `${Math.round(c.x * 1e4)}|${Math.round(c.y * 1e4)}|${c.name}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
const Ymm = (el: number) => (top - el) * 1000;
const xs = unique.map((c) => c.x * 1000);
const ys = unique.map((c) => Ymm(c.y));
const minX = Math.min(...xs, 0);
const maxX = Math.max(...xs, 0);
const parts: string[] = unique.map((c) => {
const dot = `<circle cx="${c.x * 1000}" cy="${(top - c.y) * 1000}" r="${c.group === 'step' ? 22 : 30}" fill="${COLOR[c.group]}"></circle>`;
if (named.has(c.name)) return dot;
named.add(c.name);
return `${dot}<text x="${c.x * 1000 + 60}" y="${(top - c.y) * 1000 - 60}" font-size="120" fill="#18212B">${c.name}</text>`;
});
return `<svg viewBox="${minX - 800} -800 ${maxX - minX + 1600} ${(top - Math.min(...els)) * 1000 + 1600}" width="100%" style="max-width: 560px; height: auto" role="img" aria-label="코핑 배근 단면 · 폐합 구성 at s = ${s0} m">${parts.join('')}</svg>`;
const minY = Math.min(...ys, 0);
const maxY = Math.max(...ys, 0);
const parts: string[] = [];
for (const c of unique) {
parts.push(`<circle cx="${c.x * 1000}" cy="${Ymm(c.y)}" r="${c.group === 'step' ? 22 : 30}" fill="${COLOR[c.group]}"></circle>`);
}
// 3: the top row (C1–C8) along the 교축, its spacing from chainDims over the crossing positions.
const topRow = unique.filter((c) => c.group === 'main' && Ymm(c.y) <= minY + 150);
const topStarts = [...new Set(topRow.map((c) => Math.round(c.x * 1000)))].sort((a, b) => a - b);
const halfW = (copingWidthMm ?? 3100) / 2;
const topChain = chainDims(topStarts, -halfW, halfW);
const dimTop = minY - 600;
for (const d of topChain) {
parts.push(`<line x1="${d.from}" y1="${dimTop}" x2="${d.to}" y2="${dimTop}" stroke="#18212B" stroke-width="8"></line><text x="${(d.from + d.to) / 2}" y="${dimTop - 90}" text-anchor="middle" font-size="160" fill="#18212B">${d.text}</text>`);
}
// 4: the side rows (C12·C13) along the EL, from the coping top EL down to the lowest side bar.
const side = unique.filter((c) => c.group === 'side' && c.x < 0);
const sideLevels = [...new Set(side.map((c) => Math.round(Ymm(c.y))))].sort((a, b) => a - b);
const startY = copingTopEl !== undefined ? Ymm(copingTopEl) : minY;
const endY = sideLevels.length ? sideLevels[sideLevels.length - 1] : maxY;
const sideChain = chainDims(sideLevels, startY, endY);
const dimSide = minX - 700;
for (const d of sideChain) {
parts.push(`<line x1="${dimSide}" y1="${d.from}" x2="${dimSide}" y2="${d.to}" stroke="#18212B" stroke-width="8"></line><text x="${dimSide - 90}" y="${(d.from + d.to) / 2}" text-anchor="end" font-size="160" fill="#18212B" dominant-baseline="middle">${d.text}</text>`);
}
// 3 (bubbles): one bubble per bar group, with a leader to the centre of the group's crossings.
const groupLabels: { label: string; names: string[] }[] = [
{ label: 'C1–6', names: ['C1', 'C2', 'C3', 'C4', 'C5', 'C6'] },
{ label: 'C12·13', names: ['C12', 'C13'] },
{ label: 'C9', names: ['C9'] },
{ label: 'C10', names: ['C10'] },
{ label: 'C11', names: ['C11'] },
{ label: 'C7·8', names: ['C7', 'C8'] },
{ label: 'C14', names: ['C14'] },
{ label: 'C15', names: ['C15'] },
{ label: 'C16', names: ['C16'] },
];
for (const g of groupLabels) {
const hit = unique.filter((c) => g.names.includes(c.name));
if (hit.length === 0) continue;
const gx = hit.reduce((s, c) => s + c.x * 1000, 0) / hit.length;
const gy = hit.reduce((s, c) => s + Ymm(c.y), 0) / hit.length;
const cy = gy < (minY + maxY) / 2 ? minY - 1500 : maxY + 1100;
const cx = gx < 0 ? minX - 1400 : maxX + 1400;
const bx = gy < (minY + maxY) / 2 ? gx : cx;
const by = gy < (minY + maxY) / 2 ? cy : gy;
parts.push(`<line x1="${bx}" y1="${by}" x2="${gx}" y2="${gy}" stroke="#55606E" stroke-width="6"></line>`);
parts.push(`<circle cx="${bx}" cy="${by}" r="200" fill="#FFFFFF" stroke="${COLOR[copyGroup(g.names[0])]}" stroke-width="10"></circle><text x="${bx}" y="${by + 70}" text-anchor="middle" font-size="170" fill="#18212B">${g.label}</text>`);
}
const vx = Math.min(minX, dimSide - 900) - 1500;
const vy = minY - 2600;
const vw = Math.max(maxX, 0) - vx + 1800;
const vh = maxY - vy + 1900;
return `<svg viewBox="${vx} ${vy} ${vw} ${vh}" width="100%" style="max-width: 600px; height: auto" role="img" aria-label="코핑 배근 단면 · 폐합 구성 at s = ${s0} m">${parts.join('')}</svg>`;
}
function copyGroup(name: string): keyof typeof COLOR {
return copingBarGroup(name);
}
+6 -5
View File
@@ -34,11 +34,12 @@ describe('코핑 배근 정면 · 단면 (승인 2026-10-26)', () => {
});
describe('단면 · 폐합 글자 겹침 (승인 2026-10-26)', () => {
it('writes each bar name once, and each spot once', () => {
const svg = copingSectionSvg(scene.records, 6);
const labels = (svg.match(/<text /g) ?? []).length;
const names = new Set((svg.match(/font-size="120"[^>]*>(C\d+)</g) ?? []).map((s) => s.replace(/.*>/, '')));
expect(labels).toBe(names.size);
it('writes each bubble label once', () => {
const svg = copingSectionSvg(scene.records, 6, model.copingRefTop, model.copingB * 1000);
// A bubble is drawn for each group that is cut at this station, and only once.
for (const label of ['C1–6', 'C12·13', 'C10', 'C7·8', 'C14', 'C16']) {
expect(svg.split(`>${label}<`).length - 1).toBe(1);
}
});
});