diff --git a/frontend/canvas-helpers.js b/frontend/canvas-helpers.js index 16cdde1..93fa7fd 100644 --- a/frontend/canvas-helpers.js +++ b/frontend/canvas-helpers.js @@ -22,6 +22,7 @@ const DEFAULT_CANVAS_SIZE = { width: 5000, height: 5000 }; // Base visual diameter and hit area used for port and anchor handles. const PORT_NODE_SIZE = 30; + const FREE_WIRES_BUNDLE_GROUP = 'free_wires'; const PORT_LABEL_MIN_CHARS = 5; const PORT_LABEL_CHAR_WIDTH = 7; const PORT_LABEL_HORIZONTAL_PADDING = 12; @@ -137,6 +138,26 @@ return (technology.xsections && technology.xsections[xsection]) || technology.xsections.strip || {}; }; + const cleanBundleGroupName = (value) => String(value ?? '') + .trim() + .replace(/\s+/g, '_') + .replace(/[^A-Za-z0-9_.-]/g, '_') + .replace(/_+/g, '_') + .replace(/^[._-]+|[._-]+$/g, ''); + + const normalizeBundleGroupName = (value, fallback = FREE_WIRES_BUNDLE_GROUP) => { + const cleaned = cleanBundleGroupName(value); + if (cleaned) return cleaned; + const fallbackText = fallback === null || fallback === undefined ? '' : String(fallback); + return cleanBundleGroupName(fallbackText) || (fallbackText === '' ? '' : FREE_WIRES_BUNDLE_GROUP); + }; + + const freeWireBundleGroupName = (xsection, defaultXsection) => { + const defaultName = normalizeBundleGroupName(defaultXsection || FALLBACK_TECHNOLOGY_MANIFEST.defaults.xsection || 'strip', 'strip'); + const currentName = normalizeBundleGroupName(xsection || defaultXsection || defaultName, defaultName); + return currentName === defaultName ? FREE_WIRES_BUNDLE_GROUP : `${FREE_WIRES_BUNDLE_GROUP}_${currentName}`; + }; + // Normalize route settings so every edge has xsection, family, width, radius, and bend type. const createRouteSettings = (manifest, overrides) => { const technology = getTechnologyManifest(manifest); @@ -150,6 +171,7 @@ 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', + bundle_group: (overrides && (overrides.bundle_group ?? overrides.bundleGroup)) || '', widthEdited: Boolean(overrides && overrides.widthEdited) }; }; @@ -809,7 +831,8 @@ } const entries = []; Array.from({ length: portNumber }, (_, index) => { - const y = elementPortOffset(index, portNumber, pitch); + const defaultSingleAnchor = portNumber === 1; + const y = defaultSingleAnchor ? -PORT_NODE_SIZE / 2 : elementPortOffset(index, portNumber, pitch); entries.push([`a${index + 1}`, { x: 0, y, a: 180, width }]); entries.push([`b${index + 1}`, { x: 0, y, a: 0, width }]); }); @@ -1004,54 +1027,76 @@ ${pinLines}`; 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 = sourceNode && sourceNode.data && sourceNode.data.elementType - ? getElementPinName(sourceNode, edge.sourceHandle) - : edge.sourceHandle || 'unknown'; - const toPort = targetNode && targetNode.data && targetNode.data.elementType - ? getElementPinName(targetNode, edge.targetHandle) - : edge.targetHandle || 'unknown'; - const route = createRouteSettings(manifest, edge.data && edge.data.route); - const routeWidth = getRouteEndpointWidth(sourceNode, edge.sourceHandle) - ?? getRouteEndpointWidth(targetNode, edge.targetHandle) - ?? route.width; - const storedPoints = Array.isArray(edge.data && edge.data.points) ? edge.data.points : []; - const points = storedPoints.length >= 2 ? getEdgeRoutePoints(edge, nodeMap) : []; - const pointsYaml = points.length > 0 - ? `\n points:\n${points.map(point => ` - x: ${Number(point.x || 0).toFixed(1)}\n y: ${canvasToLayoutY(point.y).toFixed(1)}`).join('\n')}` - : ''; - const isFreeRoute = Boolean(edge.data && edge.data.freeRoute) || (!sourceNode && !targetNode && points.length >= 2); - if (isFreeRoute) { - return ` - id: ${toYamlScalar(edge.id)} + const groups = new Map(); + let primaryFreeWireXsection = ''; + const freeWireGroupForRoute = (route) => { + const xsectionName = normalizeBundleGroupName(route.xsection, 'strip'); + if (!primaryFreeWireXsection) { + primaryFreeWireXsection = xsectionName; + return FREE_WIRES_BUNDLE_GROUP; + } + return xsectionName === primaryFreeWireXsection + ? FREE_WIRES_BUNDLE_GROUP + : `${FREE_WIRES_BUNDLE_GROUP}_${xsectionName}`; + }; + + edges.forEach(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 = sourceNode && sourceNode.data && sourceNode.data.elementType + ? getElementPinName(sourceNode, edge.sourceHandle) + : edge.sourceHandle || 'unknown'; + const toPort = targetNode && targetNode.data && targetNode.data.elementType + ? getElementPinName(targetNode, edge.targetHandle) + : edge.targetHandle || 'unknown'; + const route = createRouteSettings(manifest, edge.data && edge.data.route); + const routeWidth = getRouteEndpointWidth(sourceNode, edge.sourceHandle) + ?? getRouteEndpointWidth(targetNode, edge.targetHandle) + ?? route.width; + const storedPoints = Array.isArray(edge.data && edge.data.points) ? edge.data.points : []; + const points = storedPoints.length >= 2 ? getEdgeRoutePoints(edge, nodeMap) : []; + const pointsYaml = points.length > 0 + ? `\n points:\n${points.map(point => ` - x: ${Number(point.x || 0).toFixed(1)}\n y: ${canvasToLayoutY(point.y).toFixed(1)}`).join('\n')}` + : ''; + const isFreeRoute = Boolean(edge.data && edge.data.freeRoute) || (!sourceNode && !targetNode && points.length >= 2); + const linkYaml = isFreeRoute + ? ` - id: ${toYamlScalar(edge.id)} xsection: ${route.xsection} family: ${route.family} width: ${Number(routeWidth)} radius: ${Number(route.radius)} - routing_type: ${route.routing_type}${pointsYaml}`; - } - return ` - from: ${sourceName}:${fromPort} + routing_type: ${route.routing_type}${pointsYaml}` + : ` - from: ${sourceName}:${fromPort} to: ${targetName}:${toPort} xsection: ${route.xsection} family: ${route.family} width: ${Number(routeWidth)} radius: ${Number(route.radius)} routing_type: ${route.routing_type}${pointsYaml}`; - }); - linksYaml = linkLines.join('\n'); - } + const routeGroupName = normalizeBundleGroupName(route.bundle_group, ''); + const groupName = routeGroupName || freeWireGroupForRoute(route); + if (!groups.has(groupName)) { + groups.set(groupName, { + xsection: route.xsection, + family: route.family, + routing_type: route.routing_type, + links: [] + }); + } + groups.get(groupName).links.push(linkYaml); + }); + + const groupsYaml = Array.from(groups.entries()).map(([groupName, group]) => ` ${groupName}: + xsection: ${group.xsection} + family: ${group.family} + routing_type: ${group.routing_type} + links: +${group.links.join('\n')}`).join('\n'); return `# 3. Bundles (Grouped links for multi-bus/parallel routing) -bundles: - output_bus: - routing_type: euler_bend - links: -${linksYaml}`; +bundles:${groupsYaml ? `\n${groupsYaml}` : ' {}'}`; }; // Return the center point of a node when a more precise port point is unavailable. @@ -1252,6 +1297,7 @@ ${linksYaml}`; BASIC_COMPONENTS, DEFAULT_FORGE_ARGUMENTS, FALLBACK_TECHNOLOGY_MANIFEST, + FREE_WIRES_BUNDLE_GROUP, canvasToLayoutY, layoutToCanvasY, createForgeArguments, @@ -1259,6 +1305,8 @@ ${linksYaml}`; updateRouteField, updateRouteXsection, routeStyleForSettings, + normalizeBundleGroupName, + freeWireBundleGroupName, findSameTypeRouteCrossing, findSameFamilyRouteCrossing, isForgeComponent, diff --git a/frontend/canvas.html b/frontend/canvas.html index 9135751..2e83cf9 100644 --- a/frontend/canvas.html +++ b/frontend/canvas.html @@ -1580,6 +1580,9 @@ Organization : OptiHK Limited updateRouteField, updateRouteXsection, routeStyleForSettings, + FREE_WIRES_BUNDLE_GROUP, + normalizeBundleGroupName, + freeWireBundleGroupName, findSameTypeRouteCrossing, createRulerMeasurement, createComponentSymbolMetrics, @@ -1589,6 +1592,15 @@ Organization : OptiHK Limited const FULL_SELECTION_MODE = SelectionMode && SelectionMode.Full ? SelectionMode.Full : 'full'; + const forEachBundleLink = (doc, callback) => { + Object.entries(doc.bundles || {}).forEach(([bundleName, bundleData]) => { + const bundle = bundleData && typeof bundleData === 'object' ? bundleData : {}; + const links = bundle.links; + if (!links) return; + const linkArray = Array.isArray(links) ? links : [links]; + linkArray.forEach(link => callback(bundleName, bundle, link || {})); + }); + }; const iconPromiseCache = {}; // Loads and caches category icons so repeated library renders do not refetch the same image. @@ -2841,12 +2853,13 @@ Organization : OptiHK Limited }; // Renders editable properties for selected nodes, ports, anchors, and routes. - const RightPanel = ({ selectedNode, selectedNodes = [], selectedEdge, selectedEdges = [], technologyManifest, projectName, compositeNames = [], width, onRenameComponent, onUpdateNode, onUpdateEdgeRoute }) => { + const RightPanel = ({ selectedNode, selectedNodes = [], selectedEdge, selectedEdges = [], bundleGroupOptions = [], technologyManifest, projectName, compositeNames = [], width, onRenameComponent, onUpdateNode, onUpdateEdgeRoute }) => { const [componentData, setComponentData] = useState(null); const [loading, setLoading] = useState(false); const [enlarged, setEnlarged] = useState(null); const [editingComponentName, setEditingComponentName] = useState(false); const [tempComponentName, setTempComponentName] = useState(''); + const [newBundleGroupName, setNewBundleGroupName] = useState(''); const [localX, setLocalX] = useState(''); const [localY, setLocalY] = useState(''); const [localRotation, setLocalRotation] = useState(''); @@ -3046,9 +3059,51 @@ Organization : OptiHK Limited family: mixedValue('family'), width: mixedValue('width'), radius: mixedValue('radius'), - routing_type: mixedValue('routing_type') + routing_type: mixedValue('routing_type'), + bundle_group: mixedValue('bundle_group') }; const routingTypes = (technologyManifest || FALLBACK_TECHNOLOGY_MANIFEST).routing_types || ['euler_bend', 'standard_bend']; + const routeManifestDefaults = (technologyManifest || FALLBACK_TECHNOLOGY_MANIFEST).defaults || {}; + const selectedRouteXsection = route.xsection === '__mixed__' ? firstRoute.xsection : route.xsection; + const selectedRouteFamily = route.family === '__mixed__' ? firstRoute.family : route.family; + const compatibleFreeWireGroup = (xsection) => { + const freeWireForXsection = bundleGroupOptions.find(option => ( + option.name === FREE_WIRES_BUNDLE_GROUP && option.xsection === xsection + )); + return freeWireForXsection + ? FREE_WIRES_BUNDLE_GROUP + : freeWireBundleGroupName(xsection, routeManifestDefaults.xsection || 'strip'); + }; + const freeWireOptionName = compatibleFreeWireGroup(selectedRouteXsection); + const compatibleBundleGroupOptions = bundleGroupOptions + .filter(option => option.xsection === selectedRouteXsection) + .map(option => ({ ...option, name: normalizeBundleGroupName(option.name, freeWireOptionName) })); + if (!compatibleBundleGroupOptions.some(option => option.name === freeWireOptionName)) { + compatibleBundleGroupOptions.unshift({ + name: freeWireOptionName, + xsection: selectedRouteXsection, + family: selectedRouteFamily + }); + } + const selectedBundleGroupName = route.bundle_group === '__mixed__' + ? '__mixed__' + : normalizeBundleGroupName(route.bundle_group, freeWireOptionName); + const selectedBundleGroupOption = compatibleBundleGroupOptions.find(option => option.name === selectedBundleGroupName) || compatibleBundleGroupOptions[0]; + const bundleGroupOptionColor = (option) => routeStyleForSettings({ xsection: option.xsection, family: option.family }, false).style.stroke; + const selectedBundleGroupColor = selectedBundleGroupOption ? bundleGroupOptionColor(selectedBundleGroupOption) : routeStyleForSettings(route, false).style.stroke; + const onAddBundleGroup = () => { + if (route.xsection === '__mixed__') return; + const sanitizedName = normalizeBundleGroupName(newBundleGroupName, ''); + if (!sanitizedName) return; + const collidesWithOtherXsection = bundleGroupOptions.some(option => ( + option.name === sanitizedName && option.xsection !== selectedRouteXsection + )); + const finalName = collidesWithOtherXsection + ? `${sanitizedName}_${normalizeBundleGroupName(selectedRouteXsection, 'route')}` + : sanitizedName; + onUpdateEdgeRoute(selectedEdgeIds, currentRoute => updateRouteField(currentRoute, 'bundle_group', finalName, technologyManifest)); + setNewBundleGroupName(''); + }; return (