import { layout } from './stores/layout.svelte.js'; import { isEpcType, isInductionType, isSpurType, isCurvedType, isRectConveyanceType, isExtendoType, isPhotoeyeType, getSymbolGroup, EPC_CONFIG, INDUCTION_CONFIG, PHOTOEYE_CONFIG, EXTENDO_CONFIG, LABEL_CONFIG, CONVEYANCE_STYLE, getCurveGeometry } from './symbols.js'; import { serializeSymbol, deserializeSymbol } from './serialization.js'; import { parseConveyanceLabel } from './label-utils.js'; import { buildIgnitionViewJson } from './ignition-view.js'; import type { PlacedSymbol } from './types.js'; /** 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 = LABEL_CONFIG.fontSize; 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(LABEL_CONFIG.fontSize, availH / textLines.length); if (fontSize < LABEL_CONFIG.minFontSize) 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 * LABEL_CONFIG.baselineOffset; 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`); } /** Declarative tag-path rules: order matters (longer/more-specific patterns first). * Each rule maps a label suffix pattern to the Ignition folder path. */ const TAG_PATH_RULES: Array<{ pattern: RegExp; path: string }> = [ // VFD / EPC { pattern: /_VFD\d*$/i, path: 'VFD/APF' }, { pattern: /_EPC\d*$/i, path: 'VFD/APF' }, // Sensors { pattern: /_TPE\d*$/i, path: 'Sensor/Tracking' }, { pattern: /_LPE\d*$/i, path: 'Sensor/Long_Range' }, { pattern: /_JPE\d*$/i, path: 'Sensor/Jam' }, { pattern: /_FPE\d*$/i, path: 'Sensor/Full' }, { pattern: /_PS\d*$/i, path: 'Sensor/Pressure' }, { pattern: /_BDS\d+_[RS]$/i, path: 'Sensor/Tracking' }, { pattern: /_TS\d+_[RS]$/i, path: 'Sensor/Tracking' }, // Network nodes { pattern: /_FIOM\d*$/i, path: 'Network_Node/FIO' }, { pattern: /_FIOH\d*$/i, path: 'Network_Node/HUB' }, { pattern: /_SIO\d*$/i, path: 'Network_Node/SIO' }, { pattern: /_DPM\d*$/i, path: 'Network_Node/DPM' }, // Station — order matters: longer patterns first { pattern: /_SS\d+_S(?:T)?PB$/i, path: 'Station/Start_Stop' }, { pattern: /_CH\d*_EN\d*_PB$/i, path: 'Station/Jam_Reset_Chute_Bank' }, { pattern: /_JR\d*_PB$/i, path: 'Station/Jam_Reset' }, { pattern: /_S\d+_PB$/i, path: 'Station/Start' }, // Beacon { pattern: /_BCN\d*$/i, path: 'Beacon' }, // Solenoid (including DIV*_SOL) { pattern: /_SOL\d*$/i, path: 'Solenoid' }, // Diverter (DIV*_LS) { pattern: /_DIV\d+_LS\d*$/i, path: 'Diverter' }, // PDP { pattern: /^PDP\d*/i, path: 'PDP' }, ]; /** Build Ignition tag path from device label and MCM name. * Format: System/{MCM}/{Category}/{SubCategory}/{Label} * Mappings derived from Excel device manifest suffixes. */ function getIgnitionTagPath(label: string, mcm: string): string | null { if (!label) return null; if (/^MCM\d*/i.test(label)) return null; for (const rule of TAG_PATH_RULES) { if (rule.pattern.test(label)) return `System/${mcm}/${rule.path}/${label}`; } return null; } /** Build Ignition data attributes for a symbol group element */ function getIgnitionAttrs(label: string): string { const mcm = layout.currentMcm || 'MCM01'; const tagPath = getIgnitionTagPath(label, mcm); let attrs = ` data-color="#000000" data-state="OFF" data-priority="No Alarms"`; if (tagPath) attrs += ` data-tagpath="${tagPath}"`; return attrs; } /** 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 '); } async function buildSvgString(): Promise { const lines: string[] = [ '', `', ` `, ]; // Overlay types render LAST (on top) so they're clickable in SCADA const OVERLAY_IDS = new Set([ 'photoeye', 'photoeye_v', 'fio_sio_fioh', 'fio_sio_fioh_v', 'dpm', 'dpm_v', 'pdp', 'pdp_v', 'mcm', 'mcm_v', 'beacon', 'beacon_v', 'solenoid', 'solenoid_v', 'jam_reset', 'jam_reset_v', 'start', 'start_v', 'start_stop', 'start_stop_v', 'chute_enable', 'chute_enable_v', 'package_release', 'package_release_v', 'pressure_sensor', 'pressure_sensor_v', 'ip_camera', 'ip_camera_v', 'diverter', 'diverter_v', 'epc', 'epc_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 igAttrs = getIgnitionAttrs(label); const idAttr = `id="${label}" inkscape:label="${label}"${igAttrs}`; // 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 = EXTENDO_CONFIG.bracketWidthRatio * EXTENDO_CONFIG.defaultWidth; const p = EXTENDO_CONFIG.points; const x = sym.x, y = sym.y, w = sym.w, h = sym.h; const pts = [ [x + bracketW * p.tabTopRight.x, y + h * p.tabTopRight.y], [x + bracketW * p.bracketTopRight.x, y + h * p.bracketTopRight.y], [x + bracketW * p.beltTop.x, y + h * p.beltTop.y], [x + w, y + h * p.beltTop.y], [x + w, y + h * p.beltBottom.y], [x + bracketW * p.beltBottom.x, y + h * p.beltBottom.y], [x + bracketW * p.bracketBottomRight.x, y + h * p.bracketBottomRight.y], [x + bracketW * p.bracketBottomLeft.x, y + h * p.bracketBottomLeft.y], [x + bracketW * p.notchBottom.x, y + h * p.notchBottom.y], [x, y + h * p.farLeftBottom.y], [x, y + h * p.farLeftTop.y], [x + bracketW * p.tabTopLeft.x, y + h * p.tabTopLeft.y], ]; 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(''); return lines.join('\n'); } export async function exportSVG() { const svgStr = await buildSvgString(); const mcmName = layout.currentMcm || 'export'; downloadBlob(new Blob([svgStr], { type: 'image/svg+xml' }), `${mcmName}_Detailed_View.svg`); } /** Convert SVG element to Ignition JSON format */ function svgElementToIgnition(el: Element): Record | null { const tag = el.tagName.toLowerCase(); if (tag === 'defs' || tag === 'sodipodi:namedview') return null; const obj: Record = {}; if (tag === 'g') { obj.type = 'group'; obj.name = el.getAttribute('id') || el.getAttribute('inkscape:label') || 'group'; if (el.getAttribute('id')) obj.id = el.getAttribute('id'); if (el.getAttribute('transform')) obj.transform = el.getAttribute('transform'); // Convert children const children: Record[] = []; for (const child of Array.from(el.children)) { const c = svgElementToIgnition(child); if (c) children.push(c); } if (children.length) obj.elements = children; // Ignition metadata from data-* attributes if (el.getAttribute('data-color')) obj.color = el.getAttribute('data-color'); if (el.getAttribute('data-state')) obj.state = el.getAttribute('data-state'); if (el.getAttribute('data-priority')) obj.priority = el.getAttribute('data-priority'); if (el.getAttribute('data-tagpath')) obj.tagpaths = [el.getAttribute('data-tagpath')]; } else if (tag === 'rect') { obj.type = 'rect'; obj.name = el.getAttribute('id') || 'rect'; if (el.getAttribute('id')) obj.id = el.getAttribute('id'); for (const attr of ['x', 'y', 'width', 'height', 'rx', 'ry']) { if (el.getAttribute(attr)) obj[attr] = el.getAttribute(attr); } if (el.getAttribute('transform')) obj.transform = el.getAttribute('transform'); addFillStroke(obj, el); // Ignition metadata if (el.getAttribute('data-color')) obj.color = el.getAttribute('data-color'); if (el.getAttribute('data-state')) obj.state = el.getAttribute('data-state'); if (el.getAttribute('data-priority')) obj.priority = el.getAttribute('data-priority'); if (el.getAttribute('data-tagpath')) obj.tagpaths = [el.getAttribute('data-tagpath')]; } else if (tag === 'path') { obj.type = 'path'; obj.name = el.getAttribute('id') || 'path'; if (el.getAttribute('id')) obj.id = el.getAttribute('id'); obj.d = el.getAttribute('d') || ''; if (el.getAttribute('transform')) obj.transform = el.getAttribute('transform'); addFillStroke(obj, el); if (el.getAttribute('data-color')) obj.color = el.getAttribute('data-color'); if (el.getAttribute('data-state')) obj.state = el.getAttribute('data-state'); if (el.getAttribute('data-priority')) obj.priority = el.getAttribute('data-priority'); if (el.getAttribute('data-tagpath')) obj.tagpaths = [el.getAttribute('data-tagpath')]; } else if (tag === 'text') { obj.type = 'text'; obj.name = el.getAttribute('id') || 'text'; obj.text = el.textContent || ''; for (const attr of ['x', 'y']) { if (el.getAttribute(attr)) obj[attr] = el.getAttribute(attr); } if (el.getAttribute('text-anchor')) obj.textAnchor = el.getAttribute('text-anchor'); if (el.getAttribute('transform')) obj.transform = el.getAttribute('transform'); // Parse inline style to Ignition style object const styleStr = el.getAttribute('style'); if (styleStr) { const styleObj: Record = {}; let fillColor = '#000000'; for (const part of styleStr.split(';')) { const [k, v] = part.split(':').map(s => s.trim()); if (k === 'font-family') styleObj.fontFamily = v; else if (k === 'font-weight') styleObj.fontWeight = v; else if (k === 'font-size') styleObj.fontSize = v; else if (k === 'fill') fillColor = v; } if (Object.keys(styleObj).length) obj.style = styleObj; obj.fill = { paint: fillColor }; } else { if (el.getAttribute('fill')) obj.fill = { paint: el.getAttribute('fill') }; if (el.getAttribute('font-size')) obj.fontSize = el.getAttribute('font-size'); } } else if (tag === 'polyline') { obj.type = 'polyline'; obj.name = el.getAttribute('id') || 'polyline'; if (el.getAttribute('points')) obj.points = el.getAttribute('points'); addFillStroke(obj, el); if (el.getAttribute('transform')) obj.transform = el.getAttribute('transform'); } else if (tag === 'circle') { obj.type = 'circle'; obj.name = el.getAttribute('id') || 'circle'; for (const attr of ['cx', 'cy', 'r']) { if (el.getAttribute(attr)) obj[attr] = el.getAttribute(attr); } addFillStroke(obj, el); if (el.getAttribute('transform')) obj.transform = el.getAttribute('transform'); } else if (tag === 'polygon') { obj.type = 'polygon'; obj.name = el.getAttribute('id') || 'polygon'; if (el.getAttribute('points')) obj.points = el.getAttribute('points'); addFillStroke(obj, el); if (el.getAttribute('transform')) obj.transform = el.getAttribute('transform'); } else { return null; // skip unknown elements } return obj; } /** Extract fill/stroke from SVG element (handles both attributes and style) */ function addFillStroke(obj: Record, el: Element) { const styleStr = el.getAttribute('style'); if (styleStr) { const styles: Record = {}; for (const part of styleStr.split(';')) { const [k, v] = part.split(':').map(s => s.trim()); if (k && v) styles[k] = v; } const fill: Record = {}; if (styles.fill) fill.paint = styles.fill; else if (el.getAttribute('fill')) fill.paint = el.getAttribute('fill')!; if (styles['fill-opacity']) fill.opacity = styles['fill-opacity']; if (fill.paint) obj.fill = fill; const stroke: Record = {}; if (styles.stroke) stroke.paint = styles.stroke; if (styles['stroke-width']) stroke.width = styles['stroke-width']; if (styles['stroke-dasharray']) stroke.dasharray = styles['stroke-dasharray']; if (styles['stroke-opacity']) stroke.opacity = styles['stroke-opacity']; if (stroke.paint) obj.stroke = stroke; } else { if (el.getAttribute('fill')) { const fill: Record = { paint: el.getAttribute('fill')! }; if (el.getAttribute('fill-opacity')) fill.opacity = el.getAttribute('fill-opacity')!; obj.fill = fill; } if (el.getAttribute('stroke')) { const stroke: Record = { paint: el.getAttribute('stroke')! }; if (el.getAttribute('stroke-width')) stroke.width = el.getAttribute('stroke-width')!; obj.stroke = stroke; } } // fill="none" → paint: "transparent" if (obj.fill?.paint === 'none') obj.fill.paint = 'transparent'; if (obj.stroke?.paint === 'none') obj.stroke.paint = 'transparent'; } /** Build Ignition ia.shapes.svg component data */ async function buildIgnitionComponent(): Promise> { const svgStr = await buildSvgString(); const doc = new DOMParser().parseFromString(svgStr, 'image/svg+xml'); const svgEl = doc.documentElement; const elements: Record[] = []; for (const child of Array.from(svgEl.children)) { if (child.nodeType === 8) continue; const converted = svgElementToIgnition(child); if (converted) elements.push(converted); } const mcmName = layout.currentMcm || 'export'; return { type: 'ia.shapes.svg', version: 0, props: { viewBox: svgEl.getAttribute('viewBox') || `0 0 ${layout.canvasW} ${layout.canvasH}`, elements, }, meta: { name: `${mcmName}_Detailed_View` }, position: { width: 1, height: 1 }, custom: {}, }; } /** Export as Ignition SCADA JSON file (download) */ export async function exportIgnitionJSON() { const component = await buildIgnitionComponent(); const mcmName = layout.currentMcm || 'export'; downloadBlob( new Blob([JSON.stringify([component], null, 2)], { type: 'application/json' }), `${mcmName}_Detailed_View.json` ); } /** Deploy directly to Ignition project directory */ export async function deployToIgnition() { const component = await buildIgnitionComponent(); const projectName = layout.ignitionProject || 'CDW5_SCADA'; const viewName = layout.ignitionViewName || layout.currentMcm || 'MCM01'; const viewPath = layout.ignitionViewPath || 'DetailedView'; const viewData = buildIgnitionViewJson(viewName, component); const viewJson = JSON.stringify(viewData, null, 2); // Compute SHA-256 signature of view.json for Ignition resource integrity const viewBytes = new TextEncoder().encode(viewJson); const hashBuffer = await crypto.subtle.digest('SHA-256', viewBytes); const hashArray = Array.from(new Uint8Array(hashBuffer)); const signature = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); const resourceJson = JSON.stringify({ scope: 'G', version: 1, restricted: false, overridable: true, files: ['view.json'], attributes: { lastModificationSignature: signature, lastModification: { actor: 'admin', timestamp: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'), }, }, }, null, 2); try { const resp = await fetch('/api/deploy-ignition', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ projectName, viewName, viewPath, viewJson, resourceJson }), }); const result = await resp.json(); if (result.ok) { // Trigger project rescan via gateway scan endpoint try { await fetch('/api/ignition-scan', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ projectName }), }); } catch { /* scan is best-effort */ } alert(`Deployed to Ignition!\n${result.path}\n\nClose and reopen the project in Designer to see the view.`); } else { alert(`Deploy failed: ${result.error}`); } } catch (err) { alert(`Deploy failed: ${err instanceof Error ? err.message : String(err)}`); } } /** 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); }); }