import { layout } from './stores/layout.svelte.js'; import { isEpcType, isInductionType, isSpurType, isCurvedType, isRectConveyanceType, isExtendoType, isPhotoeyeType, getSymbolGroup, EPC_CONFIG, INDUCTION_CONFIG, PHOTOEYE_CONFIG, getCurveGeometry } from './symbols.js'; 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 — absolute coordinates, no transforms (Ignition compatible) */ function emitConveyanceLabel(lines: string[], sym: PlacedSymbol, _outerTransform: string) { if (!sym.label) return; const { lines: textLines } = parseConveyanceLabel(sym.label); // Compute label center position based on shape type let labelCx: number, labelCy: number, availH: number; if (isCurvedType(sym.symbolId)) { const angle = sym.curveAngle || 90; const { arcCx, arcCy, outerR, innerR, bandW } = getCurveGeometry(sym.symbolId, sym.x, sym.y, sym.w, sym.h); const midR = (outerR + innerR) / 2; const midAngleRad = ((angle / 2) * Math.PI) / 180; labelCx = arcCx + midR * Math.cos(midAngleRad); labelCy = arcCy - midR * Math.sin(midAngleRad); availH = bandW - 4; } else if (isSpurType(sym.symbolId)) { const w2 = sym.w2 ?? sym.w; labelCx = sym.x + (w2 + sym.w) / 4; labelCy = sym.y + sym.h / 2; availH = sym.h - 4; } else if (isInductionType(sym.symbolId)) { const stripTopY = sym.y + sym.h * INDUCTION_CONFIG.stripTopFrac; const stripBottomY = sym.y + sym.h * INDUCTION_CONFIG.stripBottomFrac; labelCx = sym.x + (INDUCTION_CONFIG.headWidth + sym.w) / 2; labelCy = (stripTopY + stripBottomY) / 2; availH = stripBottomY - stripTopY - 4; } else { labelCx = sym.x + sym.w / 2; labelCy = sym.y + sym.h / 2; availH = sym.h - 4; } const fontSize = Math.min(14, availH / textLines.length); if (fontSize < 4) return; const lineH = fontSize; // Emit each line at absolute position — y is baseline, offset by 0.35*fontSize to center visually for (let i = 0; i < textLines.length; i++) { const dy = -(textLines.length - 1) * lineH / 2 + i * lineH; const y = labelCy + dy + fontSize * 0.35; lines.push(` ${textLines[i]}`); } } function downloadBlob(blob: Blob, filename: string) { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); } /** Serialize child elements of an SVG, stripping xmlns added by XMLSerializer */ function serializeChildren(parent: Element): string { return Array.from(parent.children) .map(el => new XMLSerializer().serializeToString(el) .replace(/ xmlns="http:\/\/www\.w3\.org\/2000\/svg"/g, '')) .join('\n '); } export async function exportSVG() { const lines: string[] = [ '', `', ` `, ]; // Overlay types render on top of base conveyance const OVERLAY_IDS = new Set([ 'photoeye', 'photoeye_v', 'fio_sio_fioh', 'fio_sio_fioh_v', 'dpm', 'dpm_v', 'pdp', 'pdp_v', 'mcm', 'mcm_v', ]); const visible = layout.symbols.filter(s => !s.hidden && !layout.hiddenGroups.has(getSymbolGroup(s.symbolId))); const baseSymbols = visible.filter(s => !OVERLAY_IDS.has(s.symbolId)); const overlaySymbols = visible.filter(s => OVERLAY_IDS.has(s.symbolId)); for (const sym of [...baseSymbols, ...overlaySymbols]) { const rot = sym.rotation || 0; const mirrored = sym.mirrored || false; const cx = sym.x + sym.w / 2; const cy = sym.y + sym.h / 2; const label = sym.label || sym.name; const idAttr = `id="${label}" inkscape:label="${label}"`; // Build outer transform (rotation + mirror) const outerParts: string[] = []; if (rot) outerParts.push(`rotate(${rot},${cx},${cy})`); if (mirrored) outerParts.push(`translate(${cx},0) scale(-1,1) translate(${-cx},0)`); const outerTransform = outerParts.join(' '); if (isEpcType(sym.symbolId)) { await emitEpc(lines, sym as PlacedSymbol, label, outerTransform); } else if (isInductionType(sym.symbolId)) { const hw = INDUCTION_CONFIG.headWidth; const stripTopY = sym.y + sym.h * INDUCTION_CONFIG.stripTopFrac; const stripBottomY = sym.y + sym.h * INDUCTION_CONFIG.stripBottomFrac; 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); const sweepRad = (angle * Math.PI) / 180; const outerEndX = arcCx + outerR * Math.cos(sweepRad); const outerEndY = arcCy - outerR * Math.sin(sweepRad); const innerEndX = arcCx + innerR * Math.cos(sweepRad); const innerEndY = arcCy - innerR * Math.sin(sweepRad); const largeArc = angle > 180 ? 1 : 0; const d = [ `M ${arcCx + outerR},${arcCy}`, `A ${outerR},${outerR} 0 ${largeArc},0 ${outerEndX},${outerEndY}`, `L ${innerEndX},${innerEndY}`, `A ${innerR},${innerR} 0 ${largeArc},1 ${arcCx + innerR},${arcCy}`, '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; const pts = [ [x + bracketW * 0.44, y + h * 0.085], [x + bracketW, y + h * 0.085], [x + bracketW, y + h * 0.222], [x + w, y + h * 0.222], [x + w, y + h * 0.780], [x + bracketW, y + h * 0.780], [x + bracketW, y + h * 0.917], [x + bracketW * 0.44, y + h * 0.916], [x + bracketW * 0.34, y + h * 0.985], [x, y + h * 0.980], [x, y + h * 0.017], [x + bracketW * 0.34, y + h * 0.016], ]; 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; const pts = [ [x + leftCap, y + h * 0.42], [x + leftCap, y + h * 0.248], [x, y + h * 0.05], [x, y + h * 0.948], [x + leftCap, y + h * 0.744], [x + leftCap, y + h * 0.585], [x + w - rightCap, y + h * 0.585], [x + w - rightCap, y + h * 0.826], [x + w, y + h * 0.826], [x + w, y + h * 0.181], [x + w - rightCap, y + h * 0.181], [x + w - rightCap, y + h * 0.42], ]; const d = `M ${pts[0][0]},${pts[0][1]} ` + pts.slice(1).map(p => `L ${p[0]},${p[1]}`).join(' ') + ' Z'; lines.push(` `); } else { // Regular SVG symbol try { const svgText = await (await fetch(sym.file)).text(); const doc = new DOMParser().parseFromString(svgText, 'image/svg+xml'); const svgEl = doc.documentElement; if (svgEl.querySelector('parsererror')) { console.error('SVG parse error for:', sym.file); continue; } const vb = svgEl.getAttribute('viewBox'); const [vbX, vbY, vbW, vbH] = vb ? vb.split(/[\s,]+/).map(Number) : [0, 0, sym.w, sym.h]; const sx = sym.w / vbW; const sy = sym.h / vbH; // Base positioning transform let baseTransform = `translate(${sym.x},${sym.y}) scale(${sx.toFixed(6)},${sy.toFixed(6)}) translate(${-vbX},${-vbY})`; if (mirrored) baseTransform = `translate(${cx},0) scale(-1,1) translate(${-cx},0) ${baseTransform}`; if (rot) baseTransform = `rotate(${rot},${cx},${cy}) ${baseTransform}`; const children = Array.from(svgEl.children); if (children.length === 1 && children[0].tagName === 'g') { // SVG has a wrapper — keep it, put id/label on it, compose transforms const g = children[0]; const gTransform = g.getAttribute('transform') || ''; const fullTransform = gTransform ? `${baseTransform} ${gTransform}` : baseTransform; const innerContent = serializeChildren(g); lines.push(` `); lines.push(` ${innerContent}`); lines.push(' '); } else if (children.length === 1) { // Single element, no group — put id/label/transform directly on it const el = children[0].cloneNode(true) as Element; const elTransform = el.getAttribute('transform'); el.setAttribute('transform', elTransform ? `${baseTransform} ${elTransform}` : baseTransform); el.setAttribute('id', label); let s = new XMLSerializer().serializeToString(el) .replace(/ xmlns="http:\/\/www\.w3\.org\/2000\/svg"/g, ''); // Inject inkscape:label after the tag name const firstSpace = s.indexOf(' '); s = s.slice(0, firstSpace) + ` inkscape:label="${label}"` + s.slice(firstSpace); lines.push(` ${s}`); } else { // Multiple children without a group — wrap in with id/label const innerContent = serializeChildren(svgEl); lines.push(` `); lines.push(` ${innerContent}`); lines.push(' '); } } catch (err) { console.error('Failed to embed symbol:', sym.name, err); } } } lines.push(''); const mcmName = layout.currentMcm || 'export'; downloadBlob(new Blob([lines.join('\n')], { type: 'image/svg+xml' }), `${mcmName}_Detailed_View.svg`); } /** Emit EPC symbol — polyline + icon + right box, wrapped in with id/label */ async function emitEpc(lines: string[], sym: PlacedSymbol, label: string, outerTransform: string) { const waypoints = sym.epcWaypoints || EPC_CONFIG.defaultWaypoints; const ox = sym.x; const oy = sym.y; const parts: string[] = []; const tAttr = outerTransform ? ` transform="${outerTransform}"` : ''; // Polyline if (waypoints.length >= 2) { const points = waypoints.map(wp => `${ox + wp.x},${oy + wp.y}`).join(' '); parts.push(` `); } if (waypoints.length >= 2) { // Left icon const lb = EPC_CONFIG.leftBox; const p0x = ox + waypoints[0].x, p0y = oy + waypoints[0].y; const p1x = ox + waypoints[1].x, p1y = oy + waypoints[1].y; const lAngle = Math.atan2(p1y - p0y, p1x - p0x) * 180 / Math.PI; try { const svgText = await (await fetch(EPC_CONFIG.iconFile)).text(); const doc = new DOMParser().parseFromString(svgText, 'image/svg+xml'); const svgEl = doc.documentElement; const vb = svgEl.getAttribute('viewBox'); const [vbX, vbY, vbW, vbH] = vb ? vb.split(/[\s,]+/).map(Number) : [0, 0, lb.w, lb.h]; const sx = lb.w / vbW; const sy = lb.h / vbH; const iconTransform = `translate(${p0x},${p0y}) rotate(${lAngle.toFixed(2)}) translate(${-lb.w},${-lb.h / 2}) scale(${sx.toFixed(6)},${sy.toFixed(6)}) translate(${-vbX},${-vbY})`; const children = Array.from(svgEl.children); if (children.length === 1 && children[0].tagName === 'g') { const g = children[0]; const gT = g.getAttribute('transform') || ''; const fullT = gT ? `${iconTransform} ${gT}` : iconTransform; parts.push(` `); parts.push(` ${serializeChildren(g)}`); parts.push(` `); } else { parts.push(` `); parts.push(` ${serializeChildren(svgEl)}`); parts.push(` `); } } catch { parts.push(` `); } // Right box const last = waypoints[waypoints.length - 1]; const prev = waypoints[waypoints.length - 2]; const plx = ox + last.x, ply = oy + last.y; const ppx = ox + prev.x, ppy = oy + prev.y; const rAngle = Math.atan2(ply - ppy, plx - ppx) * 180 / Math.PI; const rb = EPC_CONFIG.rightBox; parts.push(` `); } lines.push(` `); lines.push(parts.join('\n')); lines.push(' '); } export function loadLayoutJSON(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = (ev) => { try { const data = JSON.parse(ev.target!.result as string); layout.pushUndo(); if (data.gridSize) layout.gridSize = data.gridSize; if (data.minSpacing) layout.minSpacing = data.minSpacing; layout.symbols = []; layout.nextId = 1; for (const s of data.symbols) { layout.symbols.push(deserializeSymbol(s, layout.nextId++)); } layout.markDirty(); layout.saveMcmState(); resolve(); } catch (err) { reject(err); } }; reader.readAsText(file); }); }