- Right-click context menu: "Hide" option to hide individual symbols - "Show All Hidden" appears in context menu when anything is hidden - Top visibility bar with toggle chips for each device group - Hidden symbols are excluded from rendering, hit testing, and SVG export Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
226 lines
10 KiB
TypeScript
226 lines
10 KiB
TypeScript
import { layout } from './stores/layout.svelte.js';
|
|
import { isEpcType, isInductionType, isSpurType, isCurvedType, getSymbolGroup, EPC_CONFIG, INDUCTION_CONFIG, getCurveGeometry } from './symbols.js';
|
|
import { deserializeSymbol } from './serialization.js';
|
|
import type { PlacedSymbol } from './types.js';
|
|
|
|
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[] = [
|
|
'<?xml version="1.0" encoding="UTF-8" standalone="no"?>',
|
|
`<svg width="${layout.canvasW}" height="${layout.canvasH}" viewBox="0 0 ${layout.canvasW} ${layout.canvasH}" version="1.1"`,
|
|
' xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"',
|
|
' xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape">',
|
|
` <rect width="${layout.canvasW}" height="${layout.canvasH}" fill="#ffffff" />`,
|
|
];
|
|
|
|
for (const sym of layout.symbols) {
|
|
if (sym.hidden || layout.hiddenGroups.has(getSymbolGroup(sym.symbolId))) continue;
|
|
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(` <path ${idAttr} d="${d}" fill="#000000"${outerTransform ? ` transform="${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(` <path ${idAttr} d="${d}" fill="#000000" stroke="#000000" stroke-width="0.5"${outerTransform ? ` transform="${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(` <path ${idAttr} d="${d}" fill="#000000" stroke="#000000" stroke-width="0.5"${outerTransform ? ` transform="${outerTransform}"` : ''} />`);
|
|
} 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 <g> 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(` <g ${idAttr} transform="${fullTransform}">`);
|
|
lines.push(` ${innerContent}`);
|
|
lines.push(' </g>');
|
|
} 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 <g> with id/label
|
|
const innerContent = serializeChildren(svgEl);
|
|
lines.push(` <g ${idAttr} transform="${baseTransform}">`);
|
|
lines.push(` ${innerContent}`);
|
|
lines.push(' </g>');
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to embed symbol:', sym.name, err);
|
|
}
|
|
}
|
|
}
|
|
|
|
lines.push('</svg>');
|
|
downloadBlob(new Blob([lines.join('\n')], { type: 'image/svg+xml' }), 'test_view.svg');
|
|
}
|
|
|
|
/** Emit EPC symbol — polyline + icon + right box, wrapped in <g> 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(` <polyline points="${points}" fill="none" stroke="#000000" stroke-width="${EPC_CONFIG.lineWidth}" />`);
|
|
}
|
|
|
|
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(` <g transform="${fullT}">`);
|
|
parts.push(` ${serializeChildren(g)}`);
|
|
parts.push(` </g>`);
|
|
} else {
|
|
parts.push(` <g transform="${iconTransform}">`);
|
|
parts.push(` ${serializeChildren(svgEl)}`);
|
|
parts.push(` </g>`);
|
|
}
|
|
} catch {
|
|
parts.push(` <rect x="${-lb.w}" y="${-lb.h / 2}" width="${lb.w}" height="${lb.h}" fill="#aaaaaa" stroke="#000000" stroke-width="0.3" transform="translate(${p0x},${p0y}) rotate(${lAngle.toFixed(2)})" />`);
|
|
}
|
|
|
|
// 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(` <rect x="0" y="${-rb.h / 2}" width="${rb.w}" height="${rb.h}" fill="#aaaaaa" stroke="#000000" stroke-width="0.3" transform="translate(${plx},${ply}) rotate(${rAngle.toFixed(2)})" />`);
|
|
}
|
|
|
|
lines.push(` <g id="${label}" inkscape:label="${label}"${tAttr}>`);
|
|
lines.push(parts.join('\n'));
|
|
lines.push(' </g>');
|
|
}
|
|
|
|
export function loadLayoutJSON(file: File): Promise<void> {
|
|
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);
|
|
});
|
|
}
|