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:
1 parent
41c1ecd340
commit
d5bf02e36b
3 files changed
+77
-19
No files matched your search
+1
-1
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user