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 = { 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(``); } // 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(`${d.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(`${d.text}`); } parts.push(`기둥 중심 (0)`); parts.push(`코핑 길이 ${lengthMm}`); // 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(``); parts.push(``); parts.push(`${c.label}`); } // 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(`${item.label}`); lx += 2600; } const vx = minX - 1500; const vy = minY - 3400; const vw = maxX - minX + 3000; const vh = legendY - vy + 400; return `${parts.join('')}`; } /** * 단면 · 폐합 구성 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(); 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(``); } // 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(`${d.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(`${d.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(``); parts.push(`${g.label}`); } 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 `${parts.join('')}`; } function copyGroup(name: string): keyof typeof COLOR { return copingBarGroup(name); }