466 lines
17 KiB
JavaScript
466 lines
17 KiB
JavaScript
(function (root, factory) {
|
|
const helpers = factory();
|
|
if (typeof module === 'object' && module.exports) {
|
|
module.exports = helpers;
|
|
}
|
|
root.MxpicCanvasHelpers = helpers;
|
|
})(typeof window !== 'undefined' ? window : globalThis, function () {
|
|
const FORGE_COMPONENT_LABEL = 'generate with mxpic_forge';
|
|
const FORGE_COMPONENT_TYPE = 'generate_with_forge';
|
|
const ELEMENT_COMPONENTS = {
|
|
Port: {
|
|
name: 'Port',
|
|
elementType: 'port',
|
|
ports: {
|
|
port: { x: 0, y: 0, a: 0, width: 0.5 }
|
|
}
|
|
},
|
|
Anchor: {
|
|
name: 'Anchor',
|
|
elementType: 'anchor',
|
|
ports: {
|
|
left: { x: -20, y: 0, a: 180, width: 0.5 },
|
|
right: { x: 20, y: 0, a: 0, width: 0.5 }
|
|
}
|
|
}
|
|
};
|
|
|
|
const DEFAULT_FORGE_ARGUMENTS = {
|
|
function_name: 'straight',
|
|
component_name: '',
|
|
pdk: 'Silterra/EMO1_2ML_CU_Al_RDL',
|
|
layer: 'WG_CORE',
|
|
length: 100,
|
|
width: 0.5,
|
|
radius: 10,
|
|
gap: 0.2,
|
|
spacing: 10,
|
|
angle: 0,
|
|
wavelength: 1310,
|
|
port_count: 2,
|
|
include_heater: false,
|
|
include_electrical_ports: false,
|
|
notes: ''
|
|
};
|
|
|
|
const FALLBACK_TECHNOLOGY_MANIFEST = {
|
|
routing_types: ['euler_bend', 'standard_bend'],
|
|
defaults: {
|
|
xsection: 'strip',
|
|
family: 'optical',
|
|
width: 0.45,
|
|
radius: 10,
|
|
routing_type: 'euler_bend'
|
|
},
|
|
xsections: {
|
|
strip: { family: 'optical', default_width: 0.45, default_radius: 10 },
|
|
rib_low: { family: 'optical', default_width: 0.45, default_radius: 10 },
|
|
metal_1: { family: 'electrical', default_width: 5, default_radius: 10 },
|
|
metal_2: { family: 'electrical', default_width: 5, default_radius: 10 }
|
|
}
|
|
};
|
|
|
|
const createForgeArguments = (overrides) => ({
|
|
...DEFAULT_FORGE_ARGUMENTS,
|
|
...(overrides || {})
|
|
});
|
|
|
|
const getTechnologyManifest = (manifest) => manifest || FALLBACK_TECHNOLOGY_MANIFEST;
|
|
|
|
const getXsectionInfo = (xsection, manifest) => {
|
|
const technology = getTechnologyManifest(manifest);
|
|
return (technology.xsections && technology.xsections[xsection]) || technology.xsections.strip || {};
|
|
};
|
|
|
|
const createRouteSettings = (manifest, overrides) => {
|
|
const technology = getTechnologyManifest(manifest);
|
|
const defaults = technology.defaults || FALLBACK_TECHNOLOGY_MANIFEST.defaults;
|
|
const xsection = (overrides && overrides.xsection) || defaults.xsection || 'strip';
|
|
const xsectionInfo = getXsectionInfo(xsection, technology);
|
|
const family = (overrides && overrides.family) || xsectionInfo.family || defaults.family || 'optical';
|
|
return {
|
|
xsection,
|
|
family,
|
|
width: Number((overrides && overrides.width) ?? xsectionInfo.default_width ?? defaults.width ?? 0.45),
|
|
radius: Number((overrides && overrides.radius) ?? xsectionInfo.default_radius ?? defaults.radius ?? 10),
|
|
routing_type: (overrides && overrides.routing_type) || defaults.routing_type || 'euler_bend',
|
|
widthEdited: Boolean(overrides && overrides.widthEdited)
|
|
};
|
|
};
|
|
|
|
const updateRouteField = (route, key, value, manifest) => {
|
|
const current = createRouteSettings(manifest, route);
|
|
const numericFields = new Set(['width', 'radius']);
|
|
const nextValue = numericFields.has(key) ? Number(value || 0) : value;
|
|
return {
|
|
...current,
|
|
[key]: nextValue,
|
|
widthEdited: key === 'width' ? true : current.widthEdited
|
|
};
|
|
};
|
|
|
|
const updateRouteXsection = (route, xsection, manifest) => {
|
|
const technology = getTechnologyManifest(manifest);
|
|
const current = createRouteSettings(technology, route);
|
|
const xsectionInfo = getXsectionInfo(xsection, technology);
|
|
const next = {
|
|
...current,
|
|
xsection,
|
|
family: xsectionInfo.family || current.family
|
|
};
|
|
if (!current.widthEdited) {
|
|
next.width = Number(xsectionInfo.default_width ?? current.width);
|
|
}
|
|
next.radius = Number(xsectionInfo.default_radius ?? current.radius);
|
|
return next;
|
|
};
|
|
|
|
const routeStyleForSettings = (route, selected) => {
|
|
const settings = createRouteSettings(null, route);
|
|
const palette = {
|
|
strip: '#38bdf8',
|
|
rib_low: '#22c55e',
|
|
metal_1: '#f59e0b',
|
|
metal_2: '#f97316'
|
|
};
|
|
const electrical = settings.family === 'electrical';
|
|
const strokeWidth = electrical ? 3.5 : 2.4;
|
|
return {
|
|
type: electrical ? 'step' : 'smoothstep',
|
|
style: {
|
|
stroke: palette[settings.xsection] || palette.strip,
|
|
strokeWidth: selected ? strokeWidth + 1.2 : strokeWidth,
|
|
strokeDasharray: electrical ? '8 5' : undefined,
|
|
filter: selected ? 'drop-shadow(0 0 5px rgba(255,255,255,0.45))' : undefined
|
|
}
|
|
};
|
|
};
|
|
|
|
const isForgeComponent = (componentName) => componentName === FORGE_COMPONENT_LABEL || componentName === FORGE_COMPONENT_TYPE;
|
|
|
|
const normalizeAngle = (angle) => {
|
|
const value = Number(angle);
|
|
if (!Number.isFinite(value)) return 0;
|
|
let normalized = ((value % 360) + 360) % 360;
|
|
if (normalized > 180) normalized -= 360;
|
|
return Object.is(normalized, -0) ? 0 : normalized;
|
|
};
|
|
|
|
const portSideFromAngle = (angle) => {
|
|
const normalized = normalizeAngle(angle);
|
|
if (normalized === 0) return 'right';
|
|
if (normalized === 180 || normalized === -180) return 'left';
|
|
if (normalized === 90) return 'top';
|
|
if (normalized === -90) return 'bottom';
|
|
return Math.abs(normalized) < 90 ? 'right' : 'left';
|
|
};
|
|
|
|
const roundPercent = (value) => Number(value.toFixed(3));
|
|
|
|
const scaledPercent = (value, min, max, invert) => {
|
|
if (!Number.isFinite(value) || !Number.isFinite(min) || !Number.isFinite(max) || min === max) return null;
|
|
const ratio = (value - min) / (max - min);
|
|
const visualRatio = invert ? 1 - ratio : ratio;
|
|
return roundPercent(15 + visualRatio * 70);
|
|
};
|
|
|
|
const fallbackPercent = (index, count) => {
|
|
if (count <= 1) return 50;
|
|
return roundPercent(15 + (index / (count - 1)) * 70);
|
|
};
|
|
|
|
const buildSideHandles = (ports, side) => {
|
|
const vertical = side === 'left' || side === 'right';
|
|
const coordinate = vertical ? 'y' : 'x';
|
|
const values = ports.map(port => Number(port.info[coordinate])).filter(Number.isFinite);
|
|
const min = values.length ? Math.min(...values) : null;
|
|
const max = values.length ? Math.max(...values) : null;
|
|
|
|
return ports.map((port, index) => {
|
|
const physicalPercent = scaledPercent(Number(port.info[coordinate]), min, max, vertical);
|
|
const percent = physicalPercent == null ? fallbackPercent(index, ports.length) : physicalPercent;
|
|
const percentValue = `${percent}%`;
|
|
const style = vertical
|
|
? { top: percentValue, transform: side === 'left' ? 'translate(-50%, -50%)' : 'translate(50%, -50%)' }
|
|
: { left: percentValue, transform: side === 'top' ? 'translate(-50%, -50%)' : 'translate(-50%, 50%)' };
|
|
|
|
return {
|
|
name: port.name,
|
|
position: side,
|
|
style,
|
|
port: port.info
|
|
};
|
|
});
|
|
};
|
|
|
|
const buildPortHandles = (ports) => {
|
|
const grouped = { left: [], right: [], top: [], bottom: [] };
|
|
Object.entries(ports || {}).forEach(([name, info]) => {
|
|
if (name === 'a0' || name === 'b0') return;
|
|
const side = portSideFromAngle(info && info.a);
|
|
grouped[side].push({ name, info: info || {} });
|
|
});
|
|
|
|
Object.values(grouped).forEach(sidePorts => {
|
|
sidePorts.sort((a, b) => {
|
|
const sideA = portSideFromAngle(a.info.a);
|
|
const vertical = sideA === 'left' || sideA === 'right';
|
|
const primary = vertical ? Number(b.info.y || 0) - Number(a.info.y || 0) : Number(a.info.x || 0) - Number(b.info.x || 0);
|
|
return primary || a.name.localeCompare(b.name);
|
|
});
|
|
});
|
|
|
|
return [
|
|
...buildSideHandles(grouped.left, 'left'),
|
|
...buildSideHandles(grouped.right, 'right'),
|
|
...buildSideHandles(grouped.top, 'top'),
|
|
...buildSideHandles(grouped.bottom, 'bottom')
|
|
];
|
|
};
|
|
|
|
const toYamlScalar = (value) => {
|
|
if (value === null || value === undefined) return '""';
|
|
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
|
|
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
|
const numericValue = Number(value);
|
|
if (typeof value === 'string' && value.trim() !== '' && Number.isFinite(numericValue) && String(numericValue) === value.trim()) {
|
|
return value.trim();
|
|
}
|
|
return JSON.stringify(String(value));
|
|
};
|
|
|
|
const buildSettingsYaml = (settings, indent) => {
|
|
const pad = ' '.repeat(indent);
|
|
const entries = Object.entries(settings || {});
|
|
if (entries.length === 0) return `${pad}{}`;
|
|
return entries.map(([key, value]) => `${pad}${key}: ${toYamlScalar(value)}`).join('\n');
|
|
};
|
|
|
|
const buildInstanceYaml = ({ instanceName, componentName, componentPath, position, rotation, forgeArguments }) => {
|
|
const forge = isForgeComponent(componentName);
|
|
const componentValue = forge ? FORGE_COMPONENT_TYPE : componentPath;
|
|
const settings = forge ? createForgeArguments(forgeArguments) : null;
|
|
const settingsYaml = forge ? `\n settings:\n${buildSettingsYaml(settings, 6)}` : '\n settings:\n length:';
|
|
|
|
return ` ${instanceName}:
|
|
component: ${componentValue}
|
|
x: ${Number(position.x || 0).toFixed(1)}
|
|
y: ${Number(position.y || 0).toFixed(1)}
|
|
rotation: ${Number(rotation || 0).toFixed(1)}
|
|
mirror: false${settingsYaml}`;
|
|
};
|
|
|
|
const buildInstancesYaml = ({ nodes, resolveComponentPath }) => {
|
|
return (nodes || [])
|
|
.filter(node => node.data && node.data.componentName && !node.data.elementType)
|
|
.map(node => {
|
|
const data = node.data;
|
|
const componentName = data.componentName || '';
|
|
const componentPath = isForgeComponent(componentName)
|
|
? FORGE_COMPONENT_TYPE
|
|
: (resolveComponentPath ? resolveComponentPath(componentName) : componentName);
|
|
|
|
return buildInstanceYaml({
|
|
instanceName: data.componentDisplayName || node.id,
|
|
componentName,
|
|
componentPath,
|
|
position: node.position || { x: 0, y: 0 },
|
|
rotation: data.rotation || 0,
|
|
forgeArguments: data.forgeArguments
|
|
});
|
|
})
|
|
.join('\n\n');
|
|
};
|
|
|
|
const getNodePortName = (node) => {
|
|
const name = node && node.data && (node.data.portName || node.data.componentDisplayName || node.data.label);
|
|
return name || (node && node.id) || 'port';
|
|
};
|
|
|
|
const isPortElementNode = (node) => node && (node.data && node.data.elementType === 'port' || node.id === 'page-port' || node.type === 'portNode');
|
|
const isElementNode = (node) => node && node.data && (node.data.elementType === 'port' || node.data.elementType === 'anchor');
|
|
|
|
const buildElementPorts = (elementType, data) => {
|
|
const element = ELEMENT_COMPONENTS[elementType === 'anchor' ? 'Anchor' : 'Port'];
|
|
if (!element) return {};
|
|
if (element.elementType === 'port') {
|
|
return {
|
|
port: {
|
|
x: 0,
|
|
y: 0,
|
|
a: Number((data && (data.angle ?? data.a)) ?? 0),
|
|
width: Number((data && data.width) || 0.5)
|
|
}
|
|
};
|
|
}
|
|
return JSON.parse(JSON.stringify(element.ports));
|
|
};
|
|
|
|
const buildPageComponentPorts = (port, nodes) => {
|
|
const portNodes = (nodes || []).filter(isPortElementNode);
|
|
if (portNodes.length > 0) {
|
|
return portNodes.reduce((ports, node) => {
|
|
const data = node.data || {};
|
|
ports[getNodePortName(node)] = {
|
|
x: Number((node.position && node.position.x) || 0),
|
|
y: Number((node.position && node.position.y) || 0),
|
|
a: Number(data.angle ?? data.a ?? 0),
|
|
width: Number(data.width || 0.5)
|
|
};
|
|
return ports;
|
|
}, {});
|
|
}
|
|
if (!port) return {};
|
|
return {
|
|
port: {
|
|
x: Number(port.x || 0),
|
|
y: Number(port.y || 0),
|
|
a: Number(port.a || 0),
|
|
width: Number(port.width || 0.5)
|
|
}
|
|
};
|
|
};
|
|
|
|
const buildCanvasPortsYaml = (nodes, fallbackPort) => {
|
|
const ports = buildPageComponentPorts(fallbackPort, nodes);
|
|
const entries = Object.entries(ports);
|
|
if (entries.length === 0) return 'ports: []';
|
|
const sourceNodes = new Map((nodes || []).filter(isPortElementNode).map(node => [getNodePortName(node), node]));
|
|
const lines = entries.map(([name, info]) => {
|
|
const data = (sourceNodes.get(name) && sourceNodes.get(name).data) || {};
|
|
const description = data.description ? `\n description: ${toYamlScalar(data.description)}` : '';
|
|
return `- name: ${name}
|
|
${data.layer ? `layer: ${data.layer}` : 'layer: WG_CORE'}
|
|
x: ${Number(info.x || 0).toFixed(1)}
|
|
y: ${Number(info.y || 0).toFixed(1)}
|
|
angle: ${Number(info.a || 0).toFixed(1)}
|
|
width: ${Number(info.width || 0.5)}${description}`;
|
|
});
|
|
return `ports:\n${lines.join('\n')}`;
|
|
};
|
|
|
|
const buildPortsYaml = (port) => buildCanvasPortsYaml([], port);
|
|
|
|
const buildElementsYaml = (nodes) => {
|
|
const elementNodes = (nodes || []).filter(isElementNode);
|
|
if (elementNodes.length === 0) return 'elements: {}';
|
|
const lines = elementNodes.map(node => {
|
|
const data = node.data || {};
|
|
const name = data.componentDisplayName || data.portName || node.id;
|
|
const angle = data.elementType === 'port' ? data.angle : data.rotation;
|
|
return ` ${name}:
|
|
type: ${data.elementType}
|
|
x: ${Number((node.position && node.position.x) || 0).toFixed(1)}
|
|
y: ${Number((node.position && node.position.y) || 0).toFixed(1)}
|
|
angle: ${Number(angle || 0).toFixed(1)}
|
|
layer: ${data.layer || 'WG_CORE'}
|
|
width: ${Number(data.width || 0.5)}
|
|
description: ${toYamlScalar(data.description || '')}`;
|
|
});
|
|
return `elements:\n${lines.join('\n')}`;
|
|
};
|
|
|
|
const buildBundlesYaml = (page, manifest) => {
|
|
const { nodes = [], edges = [] } = page || {};
|
|
const nodeMap = {};
|
|
nodes.forEach(n => { nodeMap[n.id] = n; });
|
|
|
|
let linksYaml = '';
|
|
if (edges.length > 0) {
|
|
const linkLines = edges.map(edge => {
|
|
const sourceNode = nodeMap[edge.source];
|
|
const targetNode = nodeMap[edge.target];
|
|
const sourceName = sourceNode ? (sourceNode.data.componentDisplayName || sourceNode.id) : edge.source;
|
|
const targetName = targetNode ? (targetNode.data.componentDisplayName || targetNode.id) : edge.target;
|
|
const fromPort = edge.sourceHandle || 'unknown';
|
|
const toPort = edge.targetHandle || 'unknown';
|
|
const route = createRouteSettings(manifest, edge.data && edge.data.route);
|
|
return ` - from: ${sourceName}:${fromPort}
|
|
to: ${targetName}:${toPort}
|
|
xsection: ${route.xsection}
|
|
family: ${route.family}
|
|
width: ${Number(route.width)}
|
|
radius: ${Number(route.radius)}
|
|
routing_type: ${route.routing_type}`;
|
|
});
|
|
linksYaml = linkLines.join('\n');
|
|
}
|
|
|
|
return `# 3. Bundles (Grouped links for multi-bus/parallel routing)
|
|
bundles:
|
|
output_bus:
|
|
routing_type: euler_bend
|
|
links:
|
|
${linksYaml}`;
|
|
};
|
|
|
|
const getNodeCenter = (node) => {
|
|
if (!node) return null;
|
|
return {
|
|
x: Number((node.position && node.position.x) || 0),
|
|
y: Number((node.position && node.position.y) || 0)
|
|
};
|
|
};
|
|
|
|
const orientation = (a, b, c) => {
|
|
const value = (b.y - a.y) * (c.x - b.x) - (b.x - a.x) * (c.y - b.y);
|
|
if (Math.abs(value) < 1e-9) return 0;
|
|
return value > 0 ? 1 : 2;
|
|
};
|
|
|
|
const segmentsIntersect = (p1, q1, p2, q2) => {
|
|
if (!p1 || !q1 || !p2 || !q2) return false;
|
|
const o1 = orientation(p1, q1, p2);
|
|
const o2 = orientation(p1, q1, q2);
|
|
const o3 = orientation(p2, q2, p1);
|
|
const o4 = orientation(p2, q2, q1);
|
|
return o1 !== o2 && o3 !== o4;
|
|
};
|
|
|
|
const findSameFamilyRouteCrossing = (candidateEdge, existingEdges, nodeMap, manifest) => {
|
|
const candidateRoute = createRouteSettings(manifest, candidateEdge.data && candidateEdge.data.route);
|
|
const candidateStart = getNodeCenter(nodeMap[candidateEdge.source]);
|
|
const candidateEnd = getNodeCenter(nodeMap[candidateEdge.target]);
|
|
for (const edge of existingEdges || []) {
|
|
if (!edge || edge.id === candidateEdge.id) continue;
|
|
if (edge.source === candidateEdge.source || edge.source === candidateEdge.target || edge.target === candidateEdge.source || edge.target === candidateEdge.target) continue;
|
|
const route = createRouteSettings(manifest, edge.data && edge.data.route);
|
|
if (route.family !== candidateRoute.family) continue;
|
|
const start = getNodeCenter(nodeMap[edge.source]);
|
|
const end = getNodeCenter(nodeMap[edge.target]);
|
|
if (segmentsIntersect(candidateStart, candidateEnd, start, end)) {
|
|
return { conflictEdge: edge, family: route.family };
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
return {
|
|
FORGE_COMPONENT_LABEL,
|
|
FORGE_COMPONENT_TYPE,
|
|
ELEMENT_COMPONENTS,
|
|
DEFAULT_FORGE_ARGUMENTS,
|
|
FALLBACK_TECHNOLOGY_MANIFEST,
|
|
createForgeArguments,
|
|
createRouteSettings,
|
|
updateRouteField,
|
|
updateRouteXsection,
|
|
routeStyleForSettings,
|
|
findSameFamilyRouteCrossing,
|
|
isForgeComponent,
|
|
normalizeAngle,
|
|
portSideFromAngle,
|
|
buildPortHandles,
|
|
buildElementPorts,
|
|
buildInstanceYaml,
|
|
buildInstancesYaml,
|
|
buildPageComponentPorts,
|
|
buildCanvasPortsYaml,
|
|
buildBundlesYaml,
|
|
buildPortsYaml,
|
|
buildElementsYaml,
|
|
buildSettingsYaml,
|
|
toYamlScalar
|
|
};
|
|
});
|