From 07cee1c151478004ea9b1710cf606d435f533496 Mon Sep 17 00:00:00 2001 From: igurielidze Date: Mon, 30 Mar 2026 18:23:19 +0400 Subject: [PATCH] Add internal labels for conveyance symbols (canvas + SVG export) Parse labels like UL17_22_VFD into stacked text: "UL" / "17-22". Bold black Arial, targets 14px but auto-scales down to fit with consistent padding. Strips _VFD suffix, splits prefix from numbers. If full text doesn't fit, strips the letter prefix. Co-Authored-By: Claude Opus 4.6 (1M context) --- svelte-app/src/lib/canvas/renderer.ts | 96 +++++++++++++++++++++++---- svelte-app/src/lib/export.ts | 35 ++++++++++ 2 files changed, 119 insertions(+), 12 deletions(-) diff --git a/svelte-app/src/lib/canvas/renderer.ts b/svelte-app/src/lib/canvas/renderer.ts index febadf3..2114b3f 100644 --- a/svelte-app/src/lib/canvas/renderer.ts +++ b/svelte-app/src/lib/canvas/renderer.ts @@ -522,6 +522,72 @@ function drawSymbolBody(ctx: CanvasRenderingContext2D, sym: PlacedSymbol): boole return true; } +/** Parse a conveyance label like "UL17_22_VFD" into display lines. + * Strips known suffixes (_VFD, _VFD1, etc.), splits prefix from numbers, + * replaces underscores with dashes in the number part. */ +function parseConveyanceLabel(label: string): { lines: string[]; stripped: string[] } { + // Strip known device suffixes + let core = label.replace(/_?VFD\d*$/i, ''); + if (core === label) core = label; // no suffix found, use as-is + + // Split into letter prefix and rest + const m = core.match(/^([A-Za-z]+)(.*)$/); + if (m) { + const prefix = m[1]; + const nums = m[2].replace(/_/g, '-').replace(/^-/, ''); + if (nums) { + return { lines: [prefix, nums], stripped: [nums] }; + } + return { lines: [prefix], stripped: [prefix] }; + } + return { lines: [core], stripped: [core] }; +} + +/** Draw label inside a conveyance symbol — black bold text, auto-sized to fit */ +function drawConveyanceLabel(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) { + if (!sym.label) return; + const { lines, stripped } = parseConveyanceLabel(sym.label); + const pad = 2; + const availW = sym.w - pad * 2; + const availH = sym.h - pad * 2; + const cx = sym.x + sym.w / 2; + const cy = sym.y + sym.h / 2; + + ctx.fillStyle = '#000000'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + + // Try full lines first, then stripped (prefix removed) if it doesn't fit + for (const tryLines of [lines, stripped]) { + // Target font size: 14px, but scale down to fit + let fontSize = 14; + const lineCount = tryLines.length; + const totalTextH = () => fontSize * lineCount + (lineCount - 1) * 1; + + // Scale down if too tall + while (totalTextH() > availH && fontSize > 4) fontSize -= 0.5; + + // Scale down if too wide + ctx.font = `bold ${fontSize}px Arial`; + let maxW = Math.max(...tryLines.map(l => ctx.measureText(l).width)); + while (maxW > availW && fontSize > 4) { + fontSize -= 0.5; + ctx.font = `bold ${fontSize}px Arial`; + maxW = Math.max(...tryLines.map(l => ctx.measureText(l).width)); + } + + if (fontSize >= 4) { + ctx.font = `bold ${fontSize}px Arial`; + const lineH = fontSize; + const startY = cy - (lineCount - 1) * lineH / 2; + for (let i = 0; i < tryLines.length; i++) { + ctx.fillText(tryLines[i], cx, startY + i * lineH); + } + return; + } + } +} + function drawSymbolOverlays(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) { const cx = sym.x + sym.w / 2; const isSelected = layout.selectedIds.has(sym.id); @@ -576,20 +642,26 @@ function drawSymbolOverlays(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) { strokeOutline(ctx, sym, 0); } - // Label: centered inside symbol if large enough, above if too small + // Label rendering if (sym.label) { - ctx.fillStyle = THEME.label.color; - ctx.font = THEME.label.font; - const metrics = ctx.measureText(sym.label); - const textH = 3; // approximate font height - if (sym.w >= metrics.width + 4 && sym.h >= textH + 4) { - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText(sym.label, cx, sym.y + sym.h / 2); + const isConveyance = isRectConveyanceType(sym.symbolId) || isExtendoType(sym.symbolId) + || isCurvedType(sym.symbolId) || isSpurType(sym.symbolId) || isInductionType(sym.symbolId); + if (isConveyance) { + drawConveyanceLabel(ctx, sym); } else { - ctx.textAlign = 'center'; - ctx.textBaseline = 'bottom'; - ctx.fillText(sym.label, cx, sym.y + THEME.label.offsetY); + ctx.fillStyle = THEME.label.color; + ctx.font = THEME.label.font; + const metrics = ctx.measureText(sym.label); + const textH = 3; + if (sym.w >= metrics.width + 4 && sym.h >= textH + 4) { + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(sym.label, cx, sym.y + sym.h / 2); + } else { + ctx.textAlign = 'center'; + ctx.textBaseline = 'bottom'; + ctx.fillText(sym.label, cx, sym.y + THEME.label.offsetY); + } } } } diff --git a/svelte-app/src/lib/export.ts b/svelte-app/src/lib/export.ts index a4fd85c..aa6a6bf 100644 --- a/svelte-app/src/lib/export.ts +++ b/svelte-app/src/lib/export.ts @@ -3,6 +3,36 @@ import { isEpcType, isInductionType, isSpurType, isCurvedType, isRectConveyanceT import { deserializeSymbol } from './serialization.js'; import type { PlacedSymbol } from './types.js'; +/** Parse conveyance label into display lines — same logic as renderer */ +function parseConveyanceLabel(label: string): { lines: string[]; stripped: string[] } { + let core = label.replace(/_?VFD\d*$/i, ''); + if (core === label) core = label; + const m = core.match(/^([A-Za-z]+)(.*)$/); + if (m) { + const prefix = m[1]; + const nums = m[2].replace(/_/g, '-').replace(/^-/, ''); + if (nums) return { lines: [prefix, nums], stripped: [nums] }; + return { lines: [prefix], stripped: [prefix] }; + } + return { lines: [core], stripped: [core] }; +} + +/** Emit conveyance label text element inside the shape */ +function emitConveyanceLabel(lines: string[], sym: PlacedSymbol, outerTransform: string) { + if (!sym.label) return; + const { lines: textLines } = parseConveyanceLabel(sym.label); + const cx = sym.x + sym.w / 2; + const cy = sym.y + sym.h / 2; + const fontSize = Math.min(14, (sym.h - 4) / textLines.length); + if (fontSize < 4) return; + const lineH = fontSize; + const startY = cy - (textLines.length - 1) * lineH / 2; + const tAttr = outerTransform ? ` transform="${outerTransform}"` : ''; + for (let i = 0; i < textLines.length; i++) { + lines.push(` ${textLines[i]}`); + } +} + function downloadBlob(blob: Blob, filename: string) { const url = URL.createObjectURL(blob); const a = document.createElement('a'); @@ -65,6 +95,7 @@ export async function exportSVG() { const pts = INDUCTION_CONFIG.arrowPoints.map(([xf, yf]) => [sym.x + xf * hw, sym.y + yf * sym.h] as const); const d = `M ${sym.x + sym.w},${stripTopY} L ${pts[0][0]},${stripTopY} ${pts.map(([px, py]) => `L ${px},${py}`).join(' ')} L ${pts[5][0]},${stripBottomY} L ${sym.x + sym.w},${stripBottomY} Z`; lines.push(` `); + emitConveyanceLabel(lines, sym as PlacedSymbol, outerTransform); } else if (isCurvedType(sym.symbolId)) { const angle = sym.curveAngle || 90; const { arcCx, arcCy, outerR, innerR } = getCurveGeometry(sym.symbolId, sym.x, sym.y, sym.w, sym.h); @@ -82,12 +113,15 @@ export async function exportSVG() { 'Z', ].join(' '); lines.push(` `); + emitConveyanceLabel(lines, sym as PlacedSymbol, outerTransform); } else if (isSpurType(sym.symbolId)) { const w2 = sym.w2 ?? sym.w; const d = `M ${sym.x},${sym.y} L ${sym.x + w2},${sym.y} L ${sym.x + sym.w},${sym.y + sym.h} L ${sym.x},${sym.y + sym.h} Z`; lines.push(` `); + emitConveyanceLabel(lines, sym as PlacedSymbol, outerTransform); } else if (isRectConveyanceType(sym.symbolId)) { lines.push(` `); + emitConveyanceLabel(lines, sym as PlacedSymbol, outerTransform); } else if (isExtendoType(sym.symbolId)) { const bracketW = 10.6 / 31.07 * 73; const x = sym.x, y = sym.y, w = sym.w, h = sym.h; @@ -107,6 +141,7 @@ export async function exportSVG() { ]; const d = `M ${pts[0][0]},${pts[0][1]} ` + pts.slice(1).map(p => `L ${p[0]},${p[1]}`).join(' ') + ' Z'; lines.push(` `); + emitConveyanceLabel(lines, sym as PlacedSymbol, outerTransform); } else if (isPhotoeyeType(sym.symbolId)) { const { leftCap, rightCap } = PHOTOEYE_CONFIG; const x = sym.x, y = sym.y, w = sym.w, h = sym.h;