Files
Pier_v1/src/view/coping-rebar-svg.ts
T
ypshimandClaude Haiku 5.5 ec402e22d7 feat(rebar): 단면·폐합 at two fixed stations side by side (단부 0.3 m, first column centre)
- 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>
2026-10-26 15:15:47 +09:00

270 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}