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 { serializeSymbol, 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 inside a — inherits outer transform from group */ function emitConveyanceLabelInner(lines: string[], sym: PlacedSymbol) { if (!sym.label) return; const { lines: textLines } = parseConveyanceLabel(sym.label); let labelCx: number, labelCy: number, availH: number; let textRotDeg = 0; // additional rotation for the text 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; // Tangent rotation (same as canvas renderer) let textRotRad = -midAngleRad + Math.PI / 2; // Check readability with outer rotation const symRotRad = ((sym.rotation || 0) * Math.PI) / 180; let worldAngle = (textRotRad + symRotRad) % (2 * Math.PI); if (worldAngle < 0) worldAngle += 2 * Math.PI; if (worldAngle > Math.PI / 2 && worldAngle < Math.PI * 3 / 2) textRotRad += Math.PI; // Mirror: label follows the mirrored shape naturally textRotDeg = (textRotRad * 180) / Math.PI; } else if (isSpurType(sym.symbolId)) { const w2 = sym.w2 ?? sym.w; const fontSize = 14; const th = fontSize * textLines.length; const optCy = Math.min(sym.h - th / 2 - 1, sym.h * 0.55); const textTop = optCy - th / 2; const rightEdge = w2 + (Math.max(0, textTop) / sym.h) * (sym.w - w2); // Estimate text width (~8px per char at 14px bold) const estTextW = Math.max(...textLines.map(l => l.length * 8)); labelCx = sym.x + Math.max(estTextW / 2, rightEdge - estTextW / 2); labelCy = sym.y + optCy; 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; } // For non-curved: check readability and flip if needed let needsMirrorFix = false; if (!isCurvedType(sym.symbolId)) { const rot = ((sym.rotation || 0) % 360 + 360) % 360; const effectiveAngle = sym.mirrored ? ((360 - rot) % 360) : rot; if (effectiveAngle > 90 && effectiveAngle < 270) textRotDeg = 180; needsMirrorFix = !!sym.mirrored; } const fontSize = Math.min(14, availH / textLines.length); if (fontSize < 4) return; const lineH = fontSize; // Build transform: counter-mirror then rotate for readability let transformParts: string[] = []; if (needsMirrorFix) transformParts.push(`translate(${labelCx},${labelCy}) scale(-1,1) translate(${-labelCx},${-labelCy})`); if (textRotDeg) transformParts.push(`rotate(${textRotDeg.toFixed(1)},${labelCx},${labelCy})`); const rotAttr = transformParts.length ? ` transform="${transformParts.join(' ')}"` : ''; 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); } export function exportJSON() { const data = { symbols: layout.symbols.map(s => serializeSymbol(s)), gridSize: layout.gridSize, minSpacing: layout.minSpacing, canvasW: layout.canvasW, canvasH: layout.canvasH, }; const mcmName = layout.currentMcm || 'export'; downloadBlob(new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }), `${mcmName}_layout.json`); } /** 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(` `); lines.push(` `); emitConveyanceLabelInner(lines, sym as PlacedSymbol); lines.push(` `); } 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(` `); lines.push(` `); emitConveyanceLabelInner(lines, sym as PlacedSymbol); lines.push(` `); } 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(` `); lines.push(` `); emitConveyanceLabelInner(lines, sym as PlacedSymbol); lines.push(` `); } else if (isRectConveyanceType(sym.symbolId)) { lines.push(` `); lines.push(` `); emitConveyanceLabelInner(lines, sym as PlacedSymbol); lines.push(` `); } 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(` `); lines.push(` `); emitConveyanceLabelInner(lines, sym as PlacedSymbol); lines.push(` `); } 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); } } } // Embed layout data as metadata for re-import const layoutData = { symbols: layout.symbols.filter(s => !s.hidden && !layout.hiddenGroups.has(getSymbolGroup(s.symbolId))).map(s => serializeSymbol(s)), gridSize: layout.gridSize, minSpacing: layout.minSpacing, canvasW: layout.canvasW, canvasH: layout.canvasH, }; lines.push(` `); 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 loadLayoutSVG(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = (ev) => { try { const svgText = ev.target!.result as string; const match = svgText.match(//); if (!match) throw new Error('No layout data found in SVG. Only SVGs exported from this tool can be imported.'); const data = JSON.parse(match[1]); layout.pushUndo(); if (data.gridSize) layout.gridSize = data.gridSize; if (data.minSpacing) layout.minSpacing = data.minSpacing; if (data.canvasW) layout.canvasW = data.canvasW; if (data.canvasH) layout.canvasH = data.canvasH; 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); }); } 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); }); }