- The station input is removed. The section shows the 단부 (first shear station, from the 3D bar records) and the first column centre, side by side. - The 상면 간격 label is removed. Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
270 lines
14 KiB
TypeScript
270 lines
14 KiB
TypeScript
import type { BarRecord } from './rebar3d';
|
||
|
||
/**
|
||
* 코핑 배근 · 정면 and 단면·폐합 (승인 2026-10-26, 추천: 3D 철근 기록을 그대로 투영).
|
||
* 정면: x = the coping length (s), y = EL. 단면: x = 교축 (n, the local z), y = EL, at one station s0: the bars that cross it.
|
||
* Colours by bar group: C1–C8 main, C9–C11 closed loops, C12–C13 sides, C14 points, C15–C16 steps.
|
||
*/
|
||
const COLOR: Record<string, string> = { main: '#0F5C66', loop: '#6B3FA0', side: '#2E7D32', step: '#B4531F', other: '#6B7280' };
|
||
|
||
export function copingBarGroup(name: string): keyof typeof COLOR {
|
||
const n = Number(/^C(\d+)/.exec(name)?.[1]);
|
||
if (!Number.isFinite(n)) return 'other';
|
||
if (n <= 8) return 'main';
|
||
if (n <= 11) return 'loop';
|
||
if (n <= 13) return 'side';
|
||
return 'step';
|
||
}
|
||
|
||
const isCoping = (r: BarRecord) => /^C\d/.test(r.name);
|
||
|
||
/** 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' },
|
||
];
|
||
|
||
/**
|
||
* A dimension chain from bar positions (승인 2026-10-26): the gaps between the sorted positions, grouped where the gap
|
||
* repeats (n@pitch = total). A gap that does not repeat is written as its own length. All in mm.
|
||
*/
|
||
export function chainDims(positions: number[], startMm: number, endMm: number): { text: string; len: number; from: number; to: number }[] {
|
||
const pts = [...new Set([startMm, ...positions.map((p) => Math.round(p)), endMm])].sort((a, b) => a - b);
|
||
const gaps: { from: number; to: number; len: number }[] = [];
|
||
for (let i = 0; i + 1 < pts.length; i++) gaps.push({ from: pts[i], to: pts[i + 1], len: pts[i + 1] - pts[i] });
|
||
const out: { text: string; len: number; from: number; to: number }[] = [];
|
||
let i = 0;
|
||
while (i < gaps.length) {
|
||
let j = i;
|
||
while (j + 1 < gaps.length && Math.abs(gaps[j + 1].len - gaps[i].len) <= 1) j++;
|
||
const n = j - i + 1;
|
||
const total = gaps.slice(i, j + 1).reduce((s, g) => s + g.len, 0);
|
||
const pitch = Math.round(gaps[i].len);
|
||
out.push({ text: n === 1 ? `${pitch}` : `${n}@${pitch}=${total}`, len: total, from: gaps[i].from, to: gaps[j].to });
|
||
i = j + 1;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** The 단부 station (m): the first station of the 전단·띠 bars (C9–C11), i.e. the first shear position. */
|
||
export function copingEndStation(records: BarRecord[]): number {
|
||
const loops = records.filter((r) => /^C(9|10|11)$/.test(r.name));
|
||
return Math.min(...loops.flatMap((r) => r.pts.map((p) => p[0])));
|
||
}
|
||
|
||
/**
|
||
* Bar positions (mm, along the coping): one per bar, its mean s. A loop or a cross bar sits at one station, so its
|
||
* points share the same s apart from the bends; the bends are not positions of their own.
|
||
*/
|
||
function positionsOf(bars: BarRecord[]): number[] {
|
||
// The pieces of one station are up to ~130 mm long, so the positions within 160 mm of each other are one station
|
||
// (the closest real spacing in the chains is 250 mm).
|
||
const vals = bars.map((r) => (r.pts.reduce((s, p) => s + p[0], 0) / r.pts.length) * 1000).sort((x, y) => x - y);
|
||
const groups: number[][] = [];
|
||
for (const v of vals) {
|
||
const g = groups[groups.length - 1];
|
||
if (g && v - g[0] <= 160) g.push(v);
|
||
else groups.push([v]);
|
||
}
|
||
// The station is the lowest s of its pieces (the stirrup starts there, its pieces run on from it).
|
||
return groups.map((g) => Math.round(g[0]));
|
||
}
|
||
|
||
/** 정면: the bars seen from the 교축 direction, with the bar-group bubbles and the length dimensions (시안 ③). */
|
||
export function copingFrontSvg(records: BarRecord[], lengthMm: number, columnSm = 0): 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) => `${X(p[0])},${Y(p[1])}`).join(' ');
|
||
parts.push(`<polyline points="${pts}" fill="none" stroke="${COLOR[copingBarGroup(r.name)]}" stroke-width="10"></polyline>`);
|
||
}
|
||
|
||
// First chain (the top line): the 전단·띠 positions of C9 (@250) and C10 (@300) along the coping, ends to the bar extents.
|
||
const loops = bars.filter((r) => /^C(9|10|11)$/.test(r.name));
|
||
const topChain = chainDims(positionsOf(loops), 0, lengthMm);
|
||
const dimY = minY - 1500;
|
||
for (const d of topChain) {
|
||
parts.push(`<line x1="${d.from}" y1="${dimY}" x2="${d.to}" y2="${dimY}" stroke="#18212B" stroke-width="10"></line><text x="${(d.from + d.to) / 2}" y="${dimY - 110}" text-anchor="middle" font-size="220" fill="#18212B">${d.text}</text>`);
|
||
}
|
||
// Second chain (the lower line): the upper C14 bars, measured from the column centre (0).
|
||
const c14 = bars.filter((r) => r.name === 'C14');
|
||
const c14Top = Math.max(...c14.flatMap((r) => r.pts.map((p) => p[1])));
|
||
const c14Upper = c14.filter((r) => r.pts.every((p) => p[1] >= c14Top - 0.3));
|
||
const col = columnSm * 1000;
|
||
const lowerChain = chainDims(positionsOf(c14Upper).map((v) => v - col), minX - col, maxX - col);
|
||
const dimY2 = dimY - 900;
|
||
// The chain is computed from the column centre (0); it is drawn at the column's position on the coping (x = col).
|
||
for (const d of lowerChain) {
|
||
const x1 = d.from + col;
|
||
const x2 = d.to + col;
|
||
parts.push(`<line x1="${x1}" y1="${dimY2}" x2="${x2}" y2="${dimY2}" stroke="#18212B" stroke-width="10"></line><text x="${(x1 + x2) / 2}" y="${dimY2 - 110}" text-anchor="middle" font-size="220" fill="#18212B">${d.text}</text>`);
|
||
}
|
||
parts.push(`<line x1="${col}" y1="${dimY2 - 200}" x2="${col}" y2="${dimY2 + 200}" stroke="#B4531F" stroke-width="12"></line><text x="${col + 60}" y="${dimY2 + 320}" font-size="200" fill="#B4531F">기둥 중심 (0)</text>`);
|
||
parts.push(`<text x="${midX}" y="${minY - 3000}" text-anchor="middle" font-size="260" 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>`);
|
||
}
|
||
|
||
// Legend (시안): the bar groups and their line styles, under the drawing.
|
||
const LEGEND: { label: string; group: keyof typeof COLOR }[] = [
|
||
{ label: '주철근 (C1–C8)', group: 'main' },
|
||
{ label: '전단·띠 (C9–C11)', group: 'loop' },
|
||
{ label: '측면 수평 (C12–C13)', group: 'side' },
|
||
{ label: '직각·보조 · 계단부 (C14–C16)', group: 'step' },
|
||
];
|
||
const legendY = maxY + 2300;
|
||
let lx = minX;
|
||
for (const item of LEGEND) {
|
||
parts.push(`<line x1="${lx}" y1="${legendY}" x2="${lx + 500}" y2="${legendY}" stroke="${COLOR[item.group]}" stroke-width="12"></line><text x="${lx + 620}" y="${legendY + 70}" font-size="170" fill="#18212B">${item.label}</text>`);
|
||
lx += 2600;
|
||
}
|
||
|
||
const vx = minX - 1500;
|
||
const vy = minY - 3400;
|
||
const vw = maxX - minX + 3000;
|
||
const vh = legendY - vy + 400;
|
||
return `<svg viewBox="${vx} ${vy} ${vw} ${vh}" width="100%" style="max-width: 760px; height: auto" role="img" aria-label="코핑 배근 정면 · 번호 풍선 · 길이 치수">${parts.join('')}</svg>`;
|
||
}
|
||
|
||
/**
|
||
* 단면 · 폐합 구성 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, 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]));
|
||
const top = Math.max(...els);
|
||
for (const r of bars) {
|
||
for (let i = 0; i < r.pts.length - 1; i++) {
|
||
const a = r.pts[i];
|
||
const b = r.pts[i + 1];
|
||
const lo = Math.min(a[0], b[0]);
|
||
const hi = Math.max(a[0], b[0]);
|
||
if (s0 < lo || s0 > hi) continue;
|
||
const t = hi - lo < 1e-9 ? 0 : (s0 - a[0]) / (b[0] - a[0]);
|
||
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) });
|
||
}
|
||
}
|
||
// Only the top row (C1–C8) and the side rows (C12·C13) are shown in this section (승인 2026-10-26).
|
||
const shownGroups = new Set(['main', 'side']);
|
||
// The same bar is recorded in several pieces, so draw each spot once.
|
||
const seen = new Set<string>();
|
||
const unique = crossings.filter((c) => shownGroups.has(c.group)).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 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: 'C7·8', names: ['C7', 'C8'] },
|
||
{ label: 'C12·13', names: ['C12', 'C13'] },
|
||
];
|
||
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);
|
||
}
|