diff --git a/backend/__pycache__/database.cpython-39.pyc b/backend/__pycache__/database.cpython-39.pyc index e2b00e6..d2c03ce 100644 Binary files a/backend/__pycache__/database.cpython-39.pyc and b/backend/__pycache__/database.cpython-39.pyc differ diff --git a/backend/__pycache__/gds_builder.cpython-39.pyc b/backend/__pycache__/gds_builder.cpython-39.pyc index 16b82fc..e10d147 100644 Binary files a/backend/__pycache__/gds_builder.cpython-39.pyc and b/backend/__pycache__/gds_builder.cpython-39.pyc differ diff --git a/backend/__pycache__/layout_preview.cpython-39.pyc b/backend/__pycache__/layout_preview.cpython-39.pyc index 008915a..237a54b 100644 Binary files a/backend/__pycache__/layout_preview.cpython-39.pyc and b/backend/__pycache__/layout_preview.cpython-39.pyc differ diff --git a/backend/__pycache__/pdk_access.cpython-39.pyc b/backend/__pycache__/pdk_access.cpython-39.pyc index 2113cae..4e3d088 100644 Binary files a/backend/__pycache__/pdk_access.cpython-39.pyc and b/backend/__pycache__/pdk_access.cpython-39.pyc differ diff --git a/backend/__pycache__/pdk_registry.cpython-39.pyc b/backend/__pycache__/pdk_registry.cpython-39.pyc index 33473b9..83bcec1 100644 Binary files a/backend/__pycache__/pdk_registry.cpython-39.pyc and b/backend/__pycache__/pdk_registry.cpython-39.pyc differ diff --git a/backend/__pycache__/routed_layout_preview.cpython-39.pyc b/backend/__pycache__/routed_layout_preview.cpython-39.pyc index 2caad5e..9f2c8c7 100644 Binary files a/backend/__pycache__/routed_layout_preview.cpython-39.pyc and b/backend/__pycache__/routed_layout_preview.cpython-39.pyc differ diff --git a/backend/__pycache__/technology_manifest.cpython-39.pyc b/backend/__pycache__/technology_manifest.cpython-39.pyc index 72b64b4..4ac04b5 100644 Binary files a/backend/__pycache__/technology_manifest.cpython-39.pyc and b/backend/__pycache__/technology_manifest.cpython-39.pyc differ diff --git a/database/_exports/29af00af64d84428b19f37ddc03eec46/mxpic_project_1.gds b/database/_exports/29af00af64d84428b19f37ddc03eec46/mxpic_project_1.gds new file mode 100644 index 0000000..d3a1188 Binary files /dev/null and b/database/_exports/29af00af64d84428b19f37ddc03eec46/mxpic_project_1.gds differ diff --git a/database/_exports/2c8f8cfc00964701ab5efb3f25e63323/mxpic_project_1.gds b/database/_exports/2c8f8cfc00964701ab5efb3f25e63323/mxpic_project_1.gds new file mode 100644 index 0000000..e96a96f Binary files /dev/null and b/database/_exports/2c8f8cfc00964701ab5efb3f25e63323/mxpic_project_1.gds differ diff --git a/database/_exports/c951f0317ebf4c9b903025b481290f8f/mxpic_project_1.gds b/database/_exports/c951f0317ebf4c9b903025b481290f8f/mxpic_project_1.gds new file mode 100644 index 0000000..2cce5b5 Binary files /dev/null and b/database/_exports/c951f0317ebf4c9b903025b481290f8f/mxpic_project_1.gds differ diff --git a/database/_exports/d61ec6d8ecd74bd790e512e1196f4515/mxpic_project_1.gds b/database/_exports/d61ec6d8ecd74bd790e512e1196f4515/mxpic_project_1.gds new file mode 100644 index 0000000..f45ea97 Binary files /dev/null and b/database/_exports/d61ec6d8ecd74bd790e512e1196f4515/mxpic_project_1.gds differ diff --git a/database/admin/layout/mxpic_project_1/mxpic_project_1.svg b/database/admin/layout/mxpic_project_1/mxpic_project_1.svg index 96ba2e6..518873c 100644 --- a/database/admin/layout/mxpic_project_1/mxpic_project_1.svg +++ b/database/admin/layout/mxpic_project_1/mxpic_project_1.svg @@ -1,174 +1,393 @@ - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/database/admin/layout/mxpic_project_1/mxpic_project_1.yml b/database/admin/layout/mxpic_project_1/mxpic_project_1.yml index 0e6c351..c0b6f47 100644 --- a/database/admin/layout/mxpic_project_1/mxpic_project_1.yml +++ b/database/admin/layout/mxpic_project_1/mxpic_project_1.yml @@ -13,14 +13,31 @@ type: project version: "1.0.0" # 1. External Ports (How this cell connects to the outside world) -ports: [] +ports: +- name: port + layer: WG_CORE + x: 50.0 + y: -150.0 + angle: 0.0 + width: 0.5 # 2. Instances (The sub-components dropped onto this canvas) instances: + EC_1: + component: Silterra/EMO1_2ML_CU_Al_RDL/primitives/edge_couplers/EC_SiN400_1310_1p0dB_L635_A0_QY_202604 + x: 0.0 + y: -2660.0 + rotation: 180.0 + flip: 0 + flop: 0 + mirror: false + settings: + length: + MMI_1: component: Silterra/EMO1_2ML_CU_Al_RDL/primitives/multimode_interferometers/1x2MMI_1310nm_TE_Silterra_202603_ZKY_v2 - x: 476.0 - y: -2453.2 + x: 936.8 + y: -2358.5 rotation: 0.0 flip: 0 flop: 0 @@ -30,8 +47,8 @@ instances: MMI_2: component: Silterra/EMO1_2ML_CU_Al_RDL/primitives/multimode_interferometers/1x2MMI_1310nm_TE_Silterra_202603_ZKY_v2 - x: 723.1 - y: -2241.8 + x: 1089.2 + y: -2247.3 rotation: 0.0 flip: 0 flop: 0 @@ -41,8 +58,8 @@ instances: MMI_3: component: Silterra/EMO1_2ML_CU_Al_RDL/primitives/multimode_interferometers/1x2MMI_1310nm_TE_Silterra_202603_ZKY_v2 - x: 438.9 - y: -2230.2 + x: 1096.8 + y: -2598.0 rotation: 0.0 flip: 0 flop: 0 @@ -52,8 +69,63 @@ instances: MMI_4: component: Silterra/EMO1_2ML_CU_Al_RDL/primitives/multimode_interferometers/1x2MMI_1310nm_TE_Silterra_202603_ZKY_v2 - x: 750.9 - y: -2468.7 + x: 735.0 + y: -2541.1 + rotation: 90.0 + flip: 0 + flop: 0 + mirror: false + settings: + length: + + MMI_5: + component: Silterra/EMO1_2ML_CU_Al_RDL/primitives/multimode_interferometers/1x2MMI_1310nm_TE_Silterra_202603_ZKY_v2 + x: 1086.5 + y: -2097.1 + rotation: 0.0 + flip: 0 + flop: 0 + mirror: false + settings: + length: + + EC_2: + component: Silterra/EMO1_2ML_CU_Al_RDL/primitives/edge_couplers/EC_SiN400_1310_1p0dB_L635_A0_QY_202604 + x: 0.0 + y: -2825.7 + rotation: 180.0 + flip: 0 + flop: 0 + mirror: false + settings: + length: + + MMI_6: + component: Silterra/EMO1_2ML_CU_Al_RDL/primitives/multimode_interferometers/1x2MMI_1310nm_TE_Silterra_202603_ZKY_v2 + x: 913.7 + y: -2537.3 + rotation: 90.0 + flip: 0 + flop: 0 + mirror: false + settings: + length: + + MMI_7: + component: Silterra/EMO1_2ML_CU_Al_RDL/primitives/multimode_interferometers/1x2MMI_1310nm_TE_Silterra_202603_ZKY_v2 + x: 1027.2 + y: -2736.7 + rotation: 0.0 + flip: 0 + flop: 0 + mirror: false + settings: + length: + + MMI_8: + component: Silterra/EMO1_2ML_CU_Al_RDL/primitives/multimode_interferometers/1x2MMI_1310nm_TE_Silterra_202603_ZKY_v2 + x: 842.6 + y: -2941.6 rotation: 0.0 flip: 0 flop: 0 @@ -62,11 +134,23 @@ instances: length: elements: + port: + type: port + x: 50.0 + y: -150.0 + angle: 0.0 + port_number: 1 + pitch: 10 + layer: WG_CORE + width: 0.5 + description: "" anchor_1: type: anchor - x: 421.9 - y: -2624.7 + x: 732.7 + y: -2824.7 angle: 0.0 + port_number: 5 + pitch: 10 layer: WG_CORE width: 0.5 description: "" @@ -76,22 +160,64 @@ bundles: output_bus: routing_type: euler_bend links: - - from: MMI_2:a1 - to: MMI_1:b1 + - from: MMI_1:b1 + to: MMI_2:a1 xsection: strip family: optical width: 0.45 radius: 10 routing_type: euler_bend - - from: anchor_1:right + - from: MMI_1:b2 + to: MMI_3:a1 + xsection: strip + family: optical + width: 0.45 + radius: 10 + routing_type: euler_bend + - from: EC_1:a1 + to: anchor_1:a1 + xsection: strip + family: optical + width: 0.45 + radius: 10 + routing_type: euler_bend + - from: anchor_1:b1 to: MMI_4:a1 xsection: strip family: optical width: 0.45 radius: 10 routing_type: euler_bend - - from: anchor_1:left - to: MMI_3:a1 + - from: MMI_4:b2 + to: MMI_1:a1 + xsection: strip + family: optical + width: 0.45 + radius: 10 + routing_type: euler_bend + - from: MMI_5:a1 + to: MMI_4:b1 + xsection: strip + family: optical + width: 0.45 + radius: 10 + routing_type: euler_bend + - from: EC_2:a1 + to: anchor_1:a2 + xsection: strip + family: optical + width: 0.45 + radius: 10 + routing_type: euler_bend + - from: anchor_1:b2 + to: MMI_6:a1 + xsection: strip + family: optical + width: 0.45 + radius: 10 + routing_type: euler_bend + - from: MMI_8:b1 + to: MMI_7:a1 xsection: strip family: optical width: 0.45 diff --git a/database/engineer/layout/mxpic_project_1/.project.json b/database/engineer/layout/mxpic_project_1/.project.json new file mode 100644 index 0000000..4f868b9 --- /dev/null +++ b/database/engineer/layout/mxpic_project_1/.project.json @@ -0,0 +1,4 @@ +{ + "name": "mxpic_project_1", + "technology": "Silterra/EMO1_2ML_CU_Al_RDL" +} \ No newline at end of file diff --git a/database/engineer/layout/mxpic_project_1/mxpic_project_1.svg b/database/engineer/layout/mxpic_project_1/mxpic_project_1.svg new file mode 100644 index 0000000..ba8ac45 --- /dev/null +++ b/database/engineer/layout/mxpic_project_1/mxpic_project_1.svg @@ -0,0 +1,87 @@ + + + + + + + + + +a1 +b1 +b2 +a0 +b0 + + + + + + +a1 +b1 +b2 +a0 +b0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/database/engineer/layout/mxpic_project_1/mxpic_project_1.yml b/database/engineer/layout/mxpic_project_1/mxpic_project_1.yml new file mode 100644 index 0000000..a4bc821 --- /dev/null +++ b/database/engineer/layout/mxpic_project_1/mxpic_project_1.yml @@ -0,0 +1,55 @@ +# ============================================= +# mxPIC Cell/Project Definition File +# ============================================= +schema_version: "2.0.0" +kind: cell +coordinate_system: gds_y_up +canvas_size: + width: 5000 + height: 5000 +project: mxpic_project_1 +name: mxpic_project_1 +type: project +version: "1.0.0" + +# 1. External Ports (How this cell connects to the outside world) +ports: [] + +# 2. Instances (The sub-components dropped onto this canvas) +instances: + MMI_1: + component: Silterra/EMO1_2ML_CU_Al_RDL/primitives/multimode_interferometers/1x2MMI_1310nm_TE_Silterra_202603_ZKY_v2 + x: 1511.5 + y: -2531.5 + rotation: 0.0 + flip: 0 + flop: 0 + mirror: false + settings: + length: + + MMI_2: + component: Silterra/EMO1_2ML_CU_Al_RDL/primitives/multimode_interferometers/1x2MMI_1310nm_TE_Silterra_202603_ZKY_v2 + x: 1716.4 + y: -2293.8 + rotation: 0.0 + flip: 0 + flop: 0 + mirror: false + settings: + length: + +elements: {} + +# 3. Bundles (Grouped links for multi-bus/parallel routing) +bundles: + output_bus: + routing_type: euler_bend + links: + - from: MMI_2:a1 + to: MMI_1:b2 + xsection: strip + family: optical + width: 0.45 + radius: 10 + routing_type: euler_bend \ No newline at end of file diff --git a/database/mxpic_data.db b/database/mxpic_data.db index 6c8560a..8788761 100644 Binary files a/database/mxpic_data.db and b/database/mxpic_data.db differ diff --git a/frontend/canvas-helpers.js b/frontend/canvas-helpers.js index 3821dc6..cbed004 100644 --- a/frontend/canvas-helpers.js +++ b/frontend/canvas-helpers.js @@ -10,6 +10,8 @@ const DEFAULT_COMPONENT_BOX_SIZE = { width: 132, height: 82 }; const DEFAULT_CANVAS_SIZE = { width: 5000, height: 5000 }; const PORT_NODE_SIZE = 30; + const ANCHOR_NODE_WIDTH = 8; + const DEFAULT_ELEMENT_PITCH = 10; const ELEMENT_COMPONENTS = { Port: { name: 'Port', @@ -22,8 +24,8 @@ name: 'Anchor', elementType: 'anchor', ports: { - left: { x: 0, y: -PORT_NODE_SIZE / 2, a: 180, width: 0.5 }, - right: { x: PORT_NODE_SIZE, y: -PORT_NODE_SIZE / 2, a: 0, width: 0.5 } + a1: { x: 0, y: -PORT_NODE_SIZE / 2, a: 180, width: 0.5 }, + b1: { x: 0, y: -PORT_NODE_SIZE / 2, a: 0, width: 0.5 } } } }; @@ -514,19 +516,64 @@ 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 normalizePortNumber = (value) => { + const number = Math.floor(Number(value)); + return Number.isFinite(number) ? Math.max(1, number) : 1; + }; + + const normalizePitch = (value) => { + const number = Number(value); + return Number.isFinite(number) ? Math.max(0, number) : DEFAULT_ELEMENT_PITCH; + }; + + const elementPortOffset = (index, count, pitch) => ((count - 1) / 2 - index) * pitch; + + const buildElementBoxSize = (data) => { + const portNumber = normalizePortNumber(data && data.portNumber); + const pitch = normalizePitch(data && data.pitch); + const handleClearance = Math.max(pitch, 14); + return { + width: data && data.elementType === 'anchor' ? ANCHOR_NODE_WIDTH : PORT_NODE_SIZE, + height: Math.max(PORT_NODE_SIZE, PORT_NODE_SIZE + Math.max(0, portNumber - 1) * handleClearance) + }; + }; + const buildElementPorts = (elementType, data) => { const element = ELEMENT_COMPONENTS[elementType === 'anchor' ? 'Anchor' : 'Port']; if (!element) return {}; + const portNumber = normalizePortNumber(data && data.portNumber); + const pitch = normalizePitch(data && data.pitch); + const width = Number((data && data.width) || 0.5); if (element.elementType === 'port') { + if (portNumber > 1) { + return Object.fromEntries(Array.from({ length: portNumber }, (_, index) => [ + `port_${index + 1}`, + { + x: 0, + y: elementPortOffset(index, portNumber, pitch), + a: Number((data && (data.angle ?? data.a)) ?? 0), + width + } + ])); + } return { port: { x: 0, y: 0, a: Number((data && (data.angle ?? data.a)) ?? 0), - width: Number((data && data.width) || 0.5) + width } }; } + if (portNumber > 1) { + const entries = []; + Array.from({ length: portNumber }, (_, index) => { + const y = -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 }]); + }); + return Object.fromEntries(entries); + } return JSON.parse(JSON.stringify(element.ports)); }; @@ -598,12 +645,24 @@ 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) - }; + const baseName = getNodePortName(node); + const elementPorts = buildElementPorts('port', data); + const entries = Object.entries(elementPorts); + entries.forEach(([portName, portInfo]) => { + const exportName = entries.length === 1 + ? baseName + : `${baseName}_${portName.replace(/^port_/, '')}`; + const point = getNodePortCanvasPoint(node, portName) || { + x: Number((node.position && node.position.x) || 0), + y: Number((node.position && node.position.y) || 0) + }; + ports[exportName] = { + x: Number(point.x || 0), + y: Number(point.y || 0), + a: Number(portInfo.a ?? data.angle ?? data.a ?? 0), + width: Number(portInfo.width || data.width || 0.5) + }; + }); return ports; }, {}); } @@ -645,11 +704,15 @@ const data = node.data || {}; const name = data.componentDisplayName || data.portName || node.id; const angle = data.elementType === 'port' ? data.angle : data.rotation; + const portNumber = normalizePortNumber(data.portNumber); + const pitch = normalizePitch(data.pitch); return ` ${name}: type: ${data.elementType} x: ${Number((node.position && node.position.x) || 0).toFixed(1)} y: ${canvasToLayoutY((node.position && node.position.y) || 0).toFixed(1)} angle: ${Number(angle || 0).toFixed(1)} + port_number: ${portNumber} + pitch: ${Number(pitch)} layer: ${data.layer || 'WG_CORE'} width: ${Number(data.width || 0.5)} description: ${toYamlScalar(data.description || '')}`; @@ -718,7 +781,28 @@ ${linksYaml}`; const x = Number((node.position && node.position.x) || 0); const y = Number((node.position && node.position.y) || 0); if (node.type === 'portNode' || (node.data && node.data.elementType === 'port')) { - return { x: roundMeasureValue(x), y: roundMeasureValue(y) }; + const ports = buildElementPorts('port', node.data); + const portInfo = ports && portName ? ports[portName] : ports.port; + if (!portInfo) return { x: roundMeasureValue(x), y: roundMeasureValue(y) }; + const transformedInfo = transformPortInfo(portInfo, { rotation: 0 }); + return { + x: roundMeasureValue(x + Number(transformedInfo.x || 0)), + y: roundMeasureValue(y - Number(transformedInfo.y || 0)) + }; + } + if (node.type === 'anchorNode' || (node.data && node.data.elementType === 'anchor')) { + const ports = buildElementPorts('anchor', node.data); + const portInfo = ports && portName ? ports[portName] : null; + if (!portInfo) return null; + const transformedInfo = transformPortInfo(portInfo, { + rotation: (node.data && node.data.rotation) || 0, + flip: Boolean(node.data && node.data.flip), + flop: Boolean(node.data && node.data.flop) + }); + return { + x: roundMeasureValue(x + Number(transformedInfo.x || 0)), + y: roundMeasureValue(y - Number(transformedInfo.y || 0)) + }; } const ports = node.data && node.data.ports; const portInfo = ports && portName ? ports[portName] : null; @@ -758,7 +842,9 @@ ${linksYaml}`; }); const handle = handles.find(item => item.name === handleId); if (handle) { - const componentSize = normalizeBoxSize({ box_size: node.data && node.data.boxSize }, DEFAULT_COMPONENT_BOX_SIZE); + const componentSize = node.data && node.data.elementType + ? buildElementBoxSize(node.data) + : normalizeBoxSize({ box_size: node.data && node.data.boxSize }, DEFAULT_COMPONENT_BOX_SIZE); let x = Number((node.position && node.position.x) || 0); let y = Number((node.position && node.position.y) || 0); if (handle.position === 'left') { @@ -825,14 +911,23 @@ ${linksYaml}`; return o1 !== o2 && o3 !== o4; }; + const routeTypeKey = (route) => { + const xsection = String((route && route.xsection) || '').trim().toLowerCase(); + if (xsection === 'metal1') return 'metal_1'; + if (xsection === 'metal2') return 'metal_2'; + if (xsection === 'rib') return 'rib_low'; + return xsection; + }; + const findSameTypeRouteCrossing = (candidateEdge, existingEdges, nodeMap, manifest) => { const candidateRoute = createRouteSettings(manifest, candidateEdge.data && candidateEdge.data.route); + const candidateType = routeTypeKey(candidateRoute); const candidatePoints = getEdgeRoutePoints(candidateEdge, nodeMap); 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.xsection !== candidateRoute.xsection) continue; + if (routeTypeKey(route) !== candidateType) continue; const points = getEdgeRoutePoints(edge, nodeMap); if (routeSegmentsIntersect(candidatePoints, points)) { return { conflictEdge: edge, xsection: route.xsection }; @@ -849,6 +944,7 @@ ${linksYaml}`; DEFAULT_COMPONENT_BOX_SIZE, DEFAULT_CANVAS_SIZE, PORT_NODE_SIZE, + DEFAULT_ELEMENT_PITCH, ELEMENT_COMPONENTS, BASIC_COMPONENTS, DEFAULT_FORGE_ARGUMENTS, @@ -878,6 +974,7 @@ ${linksYaml}`; getNodePortCanvasPoint, buildPortHandles, buildElementPorts, + buildElementBoxSize, buildBasicComponentPorts, getBasicComponentMetadata, buildInstanceYaml, diff --git a/frontend/canvas.html b/frontend/canvas.html index bd3d579..b906cff 100644 --- a/frontend/canvas.html +++ b/frontend/canvas.html @@ -1433,6 +1433,7 @@ DEFAULT_COMPONENT_BOX_SIZE, DEFAULT_CANVAS_SIZE, PORT_NODE_SIZE, + DEFAULT_ELEMENT_PITCH, ELEMENT_COMPONENTS, BASIC_COMPONENTS, createForgeArguments, @@ -1446,6 +1447,7 @@ calculateLayoutBounds, buildPortHandles, buildElementPorts, + buildElementBoxSize, getBasicComponentMetadata, buildInstancesYaml, buildPageComponentPorts, @@ -1703,10 +1705,29 @@ const PortNode = ({ id, data, selected }) => { const angle = data.angle ?? 0; - const handleId = data.portName || data.componentDisplayName || 'port'; + const ports = buildElementPorts('port', data); + const elementSize = buildElementBoxSize(data); + const localHandlePorts = Object.fromEntries( + Object.entries(ports).map(([name, info]) => [name, { ...info, a: 0 }]) + ); + const portHandles = useMemo( + () => buildPortHandles(localHandlePorts, { rotation: 0 }), + [localHandlePorts] + ); + const handlePositionMap = { + left: Position.Left, + right: Position.Right, + top: Position.Top, + bottom: Position.Bottom + }; + const baseHandleStyle = { + background: 'var(--accent)', + width: 8, + height: 8 + }; return (
P - - + {portHandles.map((portHandle) => ( + + + + + ))}
); }; const AnchorNode = memo(({ id, data, selected }) => { const updateNodeInternals = useUpdateNodeInternals(); - const ports = data.ports || buildElementPorts('anchor'); + const anchorRotation = data.rotation || 0; + const anchorVisualRotation = -Number(anchorRotation || 0); + const ports = buildElementPorts('anchor', data); + const elementSize = buildElementBoxSize(data); + const localAnchorHandlePorts = Object.fromEntries( + Object.entries(ports).map(([name, info]) => [name, { ...info, a: name.startsWith('a') || name.startsWith('left') ? 180 : 0 }]) + ); const portHandles = useMemo( - () => buildPortHandles(ports, { rotation: data.rotation || 0, flip: Boolean(data.flip), flop: Boolean(data.flop) }), - [ports, data.rotation, data.flip, data.flop] + () => buildPortHandles(localAnchorHandlePorts, { rotation: 0, flip: Boolean(data.flip), flop: Boolean(data.flop) }), + [localAnchorHandlePorts, data.flip, data.flop] + ); + const anchorDirectionHandles = useMemo( + () => new Map(buildPortHandles(localAnchorHandlePorts, { rotation: Number(anchorRotation || 0), flip: Boolean(data.flip), flop: Boolean(data.flop) }).map(handle => [handle.name, handle.position])), + [localAnchorHandlePorts, anchorRotation, data.flip, data.flop] ); const handlePositionMap = { left: Position.Left, @@ -1742,6 +1777,32 @@ border: '1px solid var(--bg-main)', borderRadius: '50%' }; + const anchorPortVisualSide = (portName) => { + const name = String(portName || ''); + return name.startsWith('a') || name.startsWith('left') ? 'left' : 'right'; + }; + const anchorPortVisualTop = (portName) => { + const match = String(portName || '').match(/(\d+)$/); + const index = match ? Math.max(1, Number(match[1])) : 1; + const portCount = Math.max(1, Math.floor(Number(data.portNumber || 1))); + if (portCount <= 1) return elementSize.height / 2; + const travel = Math.max(0, elementSize.height - baseHandleStyle.height); + return baseHandleStyle.height / 2 + ((index - 1) / (portCount - 1)) * travel; + }; + const anchorHandleVisualStyle = (portHandle, zIndex) => { + const visualSide = anchorPortVisualSide(portHandle.name); + const localLeft = visualSide === 'left' ? 0 : elementSize.width; + const localTop = anchorPortVisualTop(portHandle.name); + return { + ...baseHandleStyle, + zIndex, + left: localLeft, + top: localTop, + right: 'auto', + bottom: 'auto', + transform: 'translate(-50%, -50%)' + }; + }; useEffect(() => { updateNodeInternals(id); @@ -1750,9 +1811,9 @@ return (
A {portHandles.map((portHandle) => ( ))} @@ -1814,9 +1876,25 @@
)); + const routeDirectionVector = (direction) => { + if (direction === 'left') return { x: -1, y: 0 }; + if (direction === 'right') return { x: 1, y: 0 }; + if (direction === 'top') return { x: 0, y: -1 }; + if (direction === 'bottom') return { x: 0, y: 1 }; + return null; + }; + const directionToReactFlowPosition = (direction) => { + if (direction === 'left') return Position.Left; + if (direction === 'right') return Position.Right; + if (direction === 'top') return Position.Top; + if (direction === 'bottom') return Position.Bottom; + return undefined; + }; + const ParallelRouteEdge = memo(({ id, sourceX, sourceY, targetX, targetY, markerEnd, style, selected, data }) => { const offset = Number(data?.parallelOffset || 0); - let rawPoints = Array.isArray(data?.points) && data.points.length >= 2 + const hasExplicitPoints = Array.isArray(data?.points) && data.points.length >= 2; + let rawPoints = hasExplicitPoints ? data.points.map(point => ({ x: Number(point.x), y: Number(point.y) })).filter(point => Number.isFinite(point.x) && Number.isFinite(point.y)) : [{ x: sourceX, y: sourceY }, { x: targetX, y: targetY }]; if (!data?.freeRoute && rawPoints.length >= 2) { @@ -1828,6 +1906,20 @@ { x: Number(targetPoint.x), y: Number(targetPoint.y) } ]; } + const sourceVector = routeDirectionVector(data?.sourceDirection); + const targetVector = routeDirectionVector(data?.targetDirection); + if (!hasExplicitPoints && (sourceVector || targetVector) && rawPoints.length >= 2) { + const stubLength = Math.min(48, Math.max(18, Math.hypot(targetX - sourceX, targetY - sourceY) / 4)); + const directedPoints = [rawPoints[0]]; + if (sourceVector) { + directedPoints.push({ x: rawPoints[0].x + sourceVector.x * stubLength, y: rawPoints[0].y + sourceVector.y * stubLength }); + } + if (targetVector) { + directedPoints.push({ x: rawPoints[1].x + targetVector.x * stubLength, y: rawPoints[1].y + targetVector.y * stubLength }); + } + directedPoints.push(rawPoints[1]); + rawPoints = directedPoints; + } const firstPoint = rawPoints[0] || { x: sourceX, y: sourceY }; const lastPoint = rawPoints[rawPoints.length - 1] || { x: targetX, y: targetY }; const dx = lastPoint.x - firstPoint.x; @@ -2543,7 +2635,7 @@ if (selectedNode) { setLocalX(selectedNode.position.x.toFixed(3)); setLocalY(selectedNode.position.y.toFixed(3)); - const rot = selectedNode.id === 'page-port' + const rot = selectedNode.id === 'page-port' || selectedNode.type === 'portNode' || selectedNode.data?.elementType === 'port' ? (selectedNode.data?.angle ?? 0) : (selectedNode.data?.rotation ?? 0); setLocalRotation(rot.toFixed(3)); @@ -2722,12 +2814,19 @@ const updatePortField = (key, value, type = 'text') => { if (!selectedNode) return; - const nextValue = type === 'number' ? Number(value || 0) : value; + let nextValue = type === 'number' ? Number(value || 0) : value; + if (key === 'portNumber') nextValue = Math.max(1, Math.floor(nextValue || 1)); + if (key === 'pitch') nextValue = Math.max(0, Number(nextValue || 0)); const dataUpdate = { [key]: nextValue }; if (key === 'portName') { dataUpdate.componentDisplayName = value || selectedNode.data?.componentDisplayName; dataUpdate.label = value || selectedNode.data?.label; } + if (key === 'portNumber' || key === 'pitch' || key === 'width') { + const nextData = { ...selectedNode.data, ...dataUpdate }; + dataUpdate.ports = buildElementPorts(selectedNode.data?.elementType === 'anchor' ? 'anchor' : 'port', nextData); + dataUpdate.boxSize = buildElementBoxSize(nextData); + } onUpdateNode(selectedNode.id, { data: dataUpdate }); }; @@ -2819,7 +2918,7 @@ updateRotation(selectedNode.id, val, selectedNode.type === 'portNode' || selectedNode.data?.elementType === 'port'); setLocalRotation(val.toFixed(3)); } else if (selectedNode) { - const rot = selectedNode.id === 'page-port' + const rot = selectedNode.id === 'page-port' || selectedNode.type === 'portNode' || selectedNode.data?.elementType === 'port' ? (selectedNode.data?.angle ?? 0) : (selectedNode.data?.rotation ?? 0); setLocalRotation(rot.toFixed(3)); @@ -2893,6 +2992,24 @@ value={selectedNode.data?.width ?? 0.5} onChange={(event) => updatePortField('width', event.target.value, 'number')} /> +

+ + updatePortField('portNumber', event.target.value, 'number')} + /> +

+ + updatePortField('pitch', event.target.value, 'number')} + /> )} @@ -2914,6 +3031,24 @@ value={selectedNode.data?.description || ''} onChange={(event) => onUpdateNode(selectedNode.id, { data: { description: event.target.value } })} /> +

+ + updatePortField('portNumber', event.target.value, 'number')} + /> +

+ + updatePortField('pitch', event.target.value, 'number')} + /> )} @@ -3473,8 +3608,18 @@ style: { width: activeCanvasSize.width, height: activeCanvasSize.height, zIndex: -1, pointerEvents: 'none' } }, ...currentNodes, ...freeRouteEndpointNodes, ...rulerNodes]; }, [activePage, currentNodes, activeCanvasSize, freeRouteEndpointNodes, rulerNodes]); + const getAnchorHandleRouteDirection = useCallback((node, handleId) => { + if (!node || !handleId || !(node.type === 'anchorNode' || node.data?.elementType === 'anchor')) return null; + const handles = buildPortHandles(buildElementPorts('anchor', node.data), { + rotation: Number(node.data?.rotation || 0), + flip: Boolean(node.data?.flip), + flop: Boolean(node.data?.flop) + }); + return handles.find(handle => handle.name === handleId)?.position || null; + }, []); const renderEdges = useMemo(() => { const groups = new Map(); + const nodeMap = Object.fromEntries(currentNodes.map(node => [node.id, node])); currentEdges.forEach(edge => { const sourceEndpoint = `${edge.source}:${edge.sourceHandle || ''}`; const targetEndpoint = `${edge.target}:${edge.targetHandle || ''}`; @@ -3487,11 +3632,22 @@ const targetEndpoint = `${edge.target}:${edge.targetHandle || ''}`; const key = [sourceEndpoint, targetEndpoint].sort().join('<>'); const group = groups.get(key) || []; - if (group.length <= 1 && !(edge.data && Array.isArray(edge.data.points) && edge.data.points.length >= 2)) return edge; + const sourceDirection = getAnchorHandleRouteDirection(nodeMap[edge.source], edge.sourceHandle); + const targetDirection = getAnchorHandleRouteDirection(nodeMap[edge.target], edge.targetHandle); + const usesAnchorDirection = Boolean(sourceDirection || targetDirection); + const hasRoutePoints = edge.data && Array.isArray(edge.data.points) && edge.data.points.length >= 2; + const directionalEdge = usesAnchorDirection + ? { + ...edge, + sourcePosition: directionToReactFlowPosition(sourceDirection), + targetPosition: directionToReactFlowPosition(targetDirection) + } + : edge; + if (group.length <= 1 && !hasRoutePoints) return directionalEdge; const index = group.indexOf(edge.id); const offset = (index - (group.length - 1) / 2) * 18; return { - ...edge, + ...directionalEdge, type: 'parallelRoute', data: { ...(edge.data || {}), @@ -3500,7 +3656,7 @@ }; }); return [...separatedEdges, ...rulerEdges]; - }, [currentEdges, rulerEdges]); + }, [currentEdges, currentNodes, getAnchorHandleRouteDirection, rulerEdges]); const [projectCompositeMap, setProjectCompositeMap] = useState({}); const [standaloneComposites, setStandaloneComposites] = useState([]); @@ -3748,7 +3904,15 @@ return { ...p, nodes: p.nodes.map(node => { - if (node.id !== nodeId || node.type !== 'rotatableNode' || node.data?.elementType) return node; + if (node.id !== nodeId) return node; + if (node.type === 'portNode' || node.data?.elementType === 'port') { + return { ...node, data: { ...node.data, angle: normalizeAngle(Number(node.data?.angle || 0) + 90) } }; + } + if (node.type === 'anchorNode' || node.data?.elementType === 'anchor') { + const rotation = normalizeAngle(Number(node.data?.rotation || 0) + 90); + return { ...node, data: { ...node.data, rotation } }; + } + if (node.type !== 'rotatableNode') return node; const rotation = normalizeAngle(Number(node.data?.rotation || 0) + 90); return { ...node, data: { ...node.data, rotation } }; }) @@ -3759,12 +3923,14 @@ const getSpaceRotationTarget = useCallback(() => { if (spaceRotateNodeIdRef.current) return spaceRotateNodeIdRef.current; const selectedSpaceNode = selectedNode; - if (!selectedSpaceNode || selectedSpaceNode.type !== 'rotatableNode' || selectedSpaceNode.data?.elementType) return null; + if (!selectedSpaceNode) return null; + if (selectedSpaceNode.type !== 'rotatableNode' && selectedSpaceNode.type !== 'portNode' && selectedSpaceNode.type !== 'anchorNode') return null; return selectedSpaceNode.id; }, [selectedNode]); const onNodeMouseDown = useCallback((event, node) => { - if (event.button !== 0 || node.type !== 'rotatableNode' || node.data?.elementType) return; + if (event.button !== 0) return; + if (node.type !== 'rotatableNode' && node.type !== 'portNode' && node.type !== 'anchorNode') return; spaceRotateNodeIdRef.current = node.id; }, []); @@ -4061,6 +4227,89 @@ return names; }, []); + const getAvailableComponentsForLoadedComponent = useCallback((componentName) => { + if (!library || !componentName || isForgeComponent(componentName) || isBasicComponent(componentName)) return undefined; + const componentEntries = collectComponentNames(library); + const matchedComponent = componentEntries.find(component => component.name === componentName); + if (!matchedComponent) return undefined; + const sameCategoryComponents = componentEntries + .filter(component => component.category === matchedComponent.category) + .map(component => component.name) + .filter(Boolean); + return Array.from(new Set([FORGE_COMPONENT_LABEL, ...sameCategoryComponents, componentName])); + }, [library, collectComponentNames]); + + const buildElementNodesFromYaml = useCallback((doc, usesGdsYUp, nodeNameMap = {}) => { + const nodes = []; + Object.entries(doc.elements || {}).forEach(([elementName, element]) => { + if (!element || typeof element !== 'object') return; + const elementType = element.type === 'anchor' ? 'anchor' : (element.type === 'port' ? 'port' : ''); + if (!elementType) return; + if (elementType === 'port' && elementName === 'port' && Array.isArray(doc.ports) && doc.ports.length > 0) { + return; + } + const portNumberValue = Math.floor(Number(element.port_number ?? element.portNumber ?? 1)); + const portNumber = Number.isFinite(portNumberValue) ? Math.max(1, portNumberValue) : 1; + const pitchValue = Number(element.pitch ?? DEFAULT_ELEMENT_PITCH); + const pitch = Number.isFinite(pitchValue) ? Math.max(0, pitchValue) : DEFAULT_ELEMENT_PITCH; + const widthValue = Number(element.width ?? 0.5); + const width = Number.isFinite(widthValue) ? widthValue : 0.5; + const xValue = Number(element.x || 0); + const yValue = Number(element.y || 0); + const x = Number.isFinite(xValue) ? xValue : 0; + const y = Number.isFinite(yValue) ? yValue : 0; + const baseData = { + label: elementName, + componentDisplayName: elementName, + elementType, + width, + portNumber, + pitch, + layer: element.layer || 'WG_CORE', + description: element.description || '', + boxSize: buildElementBoxSize({ elementType, portNumber, pitch }) + }; + const nodeId = `element-${elementName}-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`; + nodeNameMap[elementName] = nodeId; + if (elementType === 'port') { + const angle = Number(element.angle ?? element.a ?? 0); + nodes.push({ + id: nodeId, + type: 'portNode', + position: { + x, + y: usesGdsYUp ? layoutToCanvasY(y) : y, + }, + data: { + ...baseData, + portName: elementName, + angle: Number.isFinite(angle) ? angle : 0, + ports: buildElementPorts('port', { angle: Number.isFinite(angle) ? angle : 0, width, portNumber, pitch }) + }, + }); + return; + } + const rotation = Number(element.angle ?? element.rotation ?? 0); + nodes.push({ + id: nodeId, + type: 'anchorNode', + position: { + x, + y: usesGdsYUp ? layoutToCanvasY(y) : y, + }, + data: { + ...baseData, + componentName: 'Anchor', + category: null, + rotation: Number.isFinite(rotation) ? rotation : 0, + hideIcon: true, + ports: buildElementPorts('anchor', { portNumber, pitch, width }) + }, + }); + }); + return nodes; + }, []); + useEffect(() => { const input = document.getElementById('open-yaml-input'); if (!input) return; @@ -4072,8 +4321,8 @@ const text = await file.text(); const doc = jsyaml.load(text); const usesGdsYUp = doc.coordinate_system === 'gds_y_up'; - if (!doc.instances) { - alert('no instances found'); + if (!doc.instances && !doc.elements) { + alert('no instances or elements found'); return; } @@ -4081,14 +4330,18 @@ const newEdges = []; const nodeNameMap = {}; const isProject = doc.type === 'project'; + if (!isProject) { + nodeNameMap.port = 'page-port'; + } - for (const [instName, inst] of Object.entries(doc.instances)) { + for (const [instName, inst] of Object.entries(doc.instances || {})) { const compPath = inst.component || ''; const compName = compPath.split('/').pop(); const instIsForge = isForgeComponent(compPath) || isForgeComponent(compName); const instIsBasic = isBasicComponent(compPath) || isBasicComponent(compName); const displayCompName = instIsForge ? FORGE_COMPONENT_LABEL : (instIsBasic ? compPath : compName); const basicMetadata = instIsBasic ? getBasicComponentMetadata(displayCompName, inst.settings) : null; + const loadedAvailableComponents = getAvailableComponentsForLoadedComponent(displayCompName); let category = ''; if (!isProject && displayCompName && library && !instIsForge) { @@ -4124,7 +4377,7 @@ flop: toBooleanFlag(inst.flop), componentDisplayName: instName, type: isProject ? 'composite' : undefined, - availableComponents: instIsForge ? [FORGE_COMPONENT_LABEL] : undefined, + availableComponents: instIsForge ? [FORGE_COMPONENT_LABEL] : loadedAvailableComponents, ports: instIsBasic ? (basicMetadata?.ports || {}) : (instIsForge ? {} : undefined), boxSize: instIsBasic && basicMetadata ? normalizeBoxSize(basicMetadata) : undefined, forgeArguments: instIsForge ? createForgeArguments(inst.settings) : undefined, @@ -4132,6 +4385,7 @@ }, }); } + newNodes.push(...buildElementNodesFromYaml(doc, usesGdsYUp, nodeNameMap)); if (!isProject) { const links = doc.bundles?.output_bus?.links; @@ -4198,7 +4452,7 @@ if (isProject) { setProjectCompositeMap(prev => ({ ...prev, - [newPageName]: [...(prev[newPageName] || []), ...Object.keys(doc.instances)] + [newPageName]: [...(prev[newPageName] || []), ...Object.keys(doc.instances || {})] })); } else { setStandaloneComposites(prev => { @@ -4208,7 +4462,7 @@ if (library) { const compTree = {}; - for (const inst of Object.values(doc.instances)) { + for (const inst of Object.values(doc.instances || {})) { const compPath = inst.component || ''; const compName = compPath.split('/').pop(); if (isForgeComponent(compPath) || isForgeComponent(compName)) continue; @@ -4240,7 +4494,7 @@ input.addEventListener('change', handleFile); return () => input.removeEventListener('change', handleFile); - }, [library, technologyManifest, makeFreeRouteEdge]); + }, [library, technologyManifest, makeFreeRouteEdge, buildElementNodesFromYaml, getAvailableComponentsForLoadedComponent]); useEffect(() => { setProjectCompositeMap(prev => { @@ -4307,6 +4561,7 @@ } ]; const edges = []; + nodeNameMap.port = 'page-port'; Object.entries(doc.instances || {}).forEach(([instName, inst]) => { const compPath = inst.component || ''; @@ -4315,6 +4570,7 @@ const instIsBasic = isBasicComponent(compPath) || isBasicComponent(compName); const displayCompName = instIsForge ? FORGE_COMPONENT_LABEL : (instIsBasic ? compPath : compName); const basicMetadata = instIsBasic ? getBasicComponentMetadata(displayCompName, inst.settings) : null; + const loadedAvailableComponents = getAvailableComponentsForLoadedComponent(displayCompName); const nodeId = `node-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`; nodeNameMap[instName] = nodeId; nodes.push({ @@ -4332,7 +4588,7 @@ flip: toBooleanFlag(inst.flip ?? inst.mirror), flop: toBooleanFlag(inst.flop), componentDisplayName: instName, - availableComponents: instIsForge ? [FORGE_COMPONENT_LABEL] : undefined, + availableComponents: instIsForge ? [FORGE_COMPONENT_LABEL] : loadedAvailableComponents, ports: instIsBasic ? (basicMetadata?.ports || {}) : (instIsForge ? {} : undefined), boxSize: instIsBasic && basicMetadata ? normalizeBoxSize(basicMetadata) : undefined, forgeArguments: instIsForge ? createForgeArguments(inst.settings) : undefined, @@ -4340,6 +4596,7 @@ }, }); }); + nodes.push(...buildElementNodesFromYaml(doc, usesGdsYUp, nodeNameMap)); const links = doc.bundles?.output_bus?.links; if (links) { @@ -4422,7 +4679,7 @@ }; loadProject(); - }, [library, currentProjectName, loadTechnologyManifest, toBooleanFlag, makeFreeRouteEdge]); + }, [library, currentProjectName, loadTechnologyManifest, toBooleanFlag, makeFreeRouteEdge, buildElementNodesFromYaml, getAvailableComponentsForLoadedComponent]); useEffect(() => { if (activePage && activePage.type !== 'layoutPreview' && reactFlowInstance) { @@ -4900,7 +5157,9 @@ const position = clampPositionToCanvas( reactFlowInstance.screenToFlowPosition({ x: event.clientX, y: event.clientY }), activePage?.canvasSize || activeCanvasSize, - parsedData.type === 'element' ? { width: PORT_NODE_SIZE, height: PORT_NODE_SIZE } : DEFAULT_COMPONENT_BOX_SIZE + parsedData.type === 'element' + ? buildElementBoxSize({ elementType: parsedData.elementType === 'anchor' ? 'anchor' : 'port', portNumber: 1, pitch: DEFAULT_ELEMENT_PITCH }) + : DEFAULT_COMPONENT_BOX_SIZE ); if (parsedData.type === 'basic') { const componentName = parsedData.componentName || parsedData.name; @@ -4946,14 +5205,18 @@ elementType: 'port', angle: 0, width: 0.5, + portNumber: 1, + pitch: DEFAULT_ELEMENT_PITCH, layer: 'WG_CORE', - description: '' + description: '', + boxSize: buildElementBoxSize({ elementType: 'port', portNumber: 1, pitch: DEFAULT_ELEMENT_PITCH }), + ports: buildElementPorts('port', { angle: 0, width: 0.5, portNumber: 1, pitch: DEFAULT_ELEMENT_PITCH }) }, } : { id: Date.now().toString(), type: 'anchorNode', - position: clampPositionToCanvas(position, activePage?.canvasSize || activeCanvasSize, { width: PORT_NODE_SIZE, height: PORT_NODE_SIZE }), + position: clampPositionToCanvas(position, activePage?.canvasSize || activeCanvasSize, buildElementBoxSize({ elementType: 'anchor', portNumber: 1, pitch: DEFAULT_ELEMENT_PITCH })), data: { label: elementName, componentName: 'Anchor', @@ -4962,11 +5225,13 @@ category: null, rotation: 0, width: 0.5, + portNumber: 1, + pitch: DEFAULT_ELEMENT_PITCH, layer: 'WG_CORE', description: '', hideIcon: true, - boxSize: { width: PORT_NODE_SIZE, height: PORT_NODE_SIZE }, - ports: buildElementPorts('anchor') + boxSize: buildElementBoxSize({ elementType: 'anchor', portNumber: 1, pitch: DEFAULT_ELEMENT_PITCH }), + ports: buildElementPorts('anchor', { portNumber: 1, pitch: DEFAULT_ELEMENT_PITCH, width: 0.5 }) }, }; setPages(prev => prev.map(p => { diff --git a/tests/canvas-helpers.test.js b/tests/canvas-helpers.test.js index dba05d7..ae1944d 100644 --- a/tests/canvas-helpers.test.js +++ b/tests/canvas-helpers.test.js @@ -142,22 +142,22 @@ assert.strictEqual( ); assert.deepStrictEqual( { - left: helpers.buildElementPorts('anchor').left.a, - right: helpers.buildElementPorts('anchor').right.a, + a1: helpers.buildElementPorts('anchor').a1.a, + b1: helpers.buildElementPorts('anchor').b1.a, }, - { left: 180, right: 0 }, - 'Anchor objects should default to 180 degree left port and 0 degree right port' + { a1: 180, b1: 0 }, + 'Anchor objects should default to a1 for the left port and b1 for the right port' ); assert.deepStrictEqual( { - left: helpers.buildElementPorts('anchor').left, - right: helpers.buildElementPorts('anchor').right, + a1: helpers.buildElementPorts('anchor').a1, + b1: helpers.buildElementPorts('anchor').b1, }, { - left: { x: 0, y: -15, a: 180, width: 0.5 }, - right: { x: 30, y: -15, a: 0, width: 0.5 } + a1: { x: 0, y: -15, a: 180, width: 0.5 }, + b1: { x: 0, y: -15, a: 0, width: 0.5 } }, - 'Anchor ports should sit on the left and right edges of a port-sized circle' + 'Anchor a/b port pairs should share coordinates and keep opposite directions' ); assert.deepStrictEqual( helpers.buildBasicComponentPorts('waveguide', { length: 120, width: 0.6 }).b1, @@ -300,7 +300,46 @@ const elementNodes = [ assert.deepStrictEqual(helpers.buildElementPorts('port', { angle: 90, width: 0.8 }), { port: { x: 0, y: 0, a: 90, width: 0.8 } }); -assert.deepStrictEqual(Object.keys(helpers.buildElementPorts('anchor')), ['left', 'right']); +assert.deepStrictEqual(Object.keys(helpers.buildElementPorts('anchor')), ['a1', 'b1']); +assert.deepStrictEqual(Object.keys(helpers.buildElementPorts('port', { portNumber: 3, pitch: 10 })), ['port_1', 'port_2', 'port_3']); +assert.deepStrictEqual(helpers.buildElementPorts('port', { portNumber: 3, pitch: 10 }).port_1, { x: 0, y: 10, a: 0, width: 0.5 }); +assert.deepStrictEqual(helpers.buildElementPorts('port', { portNumber: 3 }).port_1, { x: 0, y: 10, a: 0, width: 0.5 }); +assert.deepStrictEqual(Object.keys(helpers.buildElementPorts('anchor', { portNumber: 2, pitch: 12 })), ['a1', 'b1', 'a2', 'b2']); +assert.deepStrictEqual(helpers.buildElementPorts('anchor', { portNumber: 2, pitch: 12 }).b2, { x: 0, y: -21, a: 0, width: 0.5 }); +assert.deepStrictEqual(helpers.buildElementPorts('anchor', { portNumber: 2, pitch: 12 }).a2, { x: 0, y: -21, a: 180, width: 0.5 }); +assert.deepStrictEqual( + helpers.getNodePortCanvasPoint({ + id: 'anchor-rotated', + type: 'anchorNode', + position: { x: 100, y: 200 }, + data: { + elementType: 'anchor', + rotation: 90, + portNumber: 1, + pitch: 10, + ports: helpers.buildElementPorts('anchor', { portNumber: 1, pitch: 10 }) + } + }, 'a1'), + { x: 115, y: 200 }, + 'Anchor port endpoint coordinates should rotate with the anchor body' +); +assert.deepStrictEqual(helpers.buildElementBoxSize({ portNumber: 1 }), { width: 30, height: 30 }); +assert.deepStrictEqual(helpers.buildElementBoxSize({ elementType: 'anchor', portNumber: 1 }), { width: 8, height: 30 }); +assert.deepStrictEqual(helpers.buildElementBoxSize({ elementType: 'anchor', portNumber: 4, pitch: 10 }), { width: 8, height: 72 }); +assert.deepStrictEqual(helpers.buildElementBoxSize({ portNumber: 4, pitch: 10 }), { width: 30, height: 72 }); +assert.deepStrictEqual( + helpers.buildPageComponentPorts(null, [{ + id: 'port-array', + type: 'portNode', + position: { x: 100, y: 200 }, + data: { componentDisplayName: 'array', elementType: 'port', portNumber: 3, pitch: 10, width: 0.6 } + }]), + { + array_1: { x: 100, y: 190, a: 0, width: 0.6 }, + array_2: { x: 100, y: 200, a: 0, width: 0.6 }, + array_3: { x: 100, y: 210, a: 0, width: 0.6 } + } +); const canvasPortsYaml = helpers.buildCanvasPortsYaml(elementNodes); assert(canvasPortsYaml.includes('name: in0')); @@ -314,6 +353,8 @@ assert(elementsYaml.includes('type: port')); assert(elementsYaml.includes('anchor_1:')); assert(elementsYaml.includes('type: anchor')); assert(elementsYaml.includes('y: -20.0')); +assert(elementsYaml.includes('port_number: 1')); +assert(elementsYaml.includes('pitch: 10')); const instancesWithoutElements = helpers.buildInstancesYaml({ nodes: elementNodes, @@ -484,7 +525,20 @@ const edgeD = { target: 'f', data: { route: { xsection: 'rib_low', family: 'optical' } } }; +const edgeE = { + id: 'edge-metal-alias', + source: 'e', + target: 'f', + data: { route: { xsection: 'metal1', family: 'electrical' } } +}; +const edgeF = { + id: 'edge-metal-underscore', + source: 'a', + target: 'b', + data: { route: { xsection: 'metal_1', family: 'electrical' } } +}; assert.strictEqual(helpers.findSameTypeRouteCrossing(edgeB, [edgeA], crossingNodes).conflictEdge.id, 'edge-a-b'); assert.strictEqual(helpers.findSameTypeRouteCrossing(edgeC, [edgeA], crossingNodes), null); assert.strictEqual(helpers.findSameTypeRouteCrossing(edgeD, [edgeA], crossingNodes), null); +assert.strictEqual(helpers.findSameTypeRouteCrossing(edgeE, [edgeF], crossingNodes).conflictEdge.id, 'edge-metal-underscore'); assert.strictEqual(helpers.findSameFamilyRouteCrossing(edgeB, [edgeA], crossingNodes).conflictEdge.id, 'edge-a-b'); diff --git a/tests/layout-backend-static.test.js b/tests/layout-backend-static.test.js index f95c142..7e6dd20 100644 --- a/tests/layout-backend-static.test.js +++ b/tests/layout-backend-static.test.js @@ -85,6 +85,24 @@ assert( 'Build GDS should not silently fall back to unrouted gdstk when links are present' ); +const routerDir = path.resolve(root, '..', 'mxpic_router', 'mxpic_router'); +if (fs.existsSync(routerDir)) { + const routerLoaderPy = fs.readFileSync(path.join(routerDir, 'eda_loader.py'), 'utf8'); + const routerBuilderPy = fs.readFileSync(path.join(routerDir, 'builder.py'), 'utf8'); + assert( + routerLoaderPy.includes('port_number: int = 1') && + routerLoaderPy.includes('pitch: float = 10.0') && + routerLoaderPy.includes('port_number=_int(element.get("port_number"'), + 'mxpic_router loader should parse multi-port anchor metadata from exported elements' + ); + assert( + routerBuilderPy.includes('for index in range(port_number):') && + routerBuilderPy.includes('a{index + 1}') && + routerBuilderPy.includes('b{index + 1}'), + 'mxpic_router builder should register aN/bN pins for multi-port anchors' + ); +} + assert( serverPy.includes('def scoped_pdk_root_for_project') && serverPy.includes('read_project_meta(project_name).get("technology")') && diff --git a/tests/layout-ui-wiring.test.js b/tests/layout-ui-wiring.test.js index a96b069..91fc057 100644 --- a/tests/layout-ui-wiring.test.js +++ b/tests/layout-ui-wiring.test.js @@ -196,8 +196,51 @@ assert( 'holding a component and pressing Space should rotate it by 90 degrees' ); assert( - canvasHtml.includes('getSpaceRotationTarget') && canvasHtml.includes('selectedSpaceNode'), - 'Space rotation should also use the currently selected component when no mouse-hold target is active' + canvasHtml.includes('getSpaceRotationTarget') && + canvasHtml.includes('selectedSpaceNode') && + canvasHtml.includes('node.type !== \'rotatableNode\' && node.type !== \'portNode\' && node.type !== \'anchorNode\'') && + canvasHtml.includes('node.type === \'portNode\' || node.data?.elementType === \'port\'') && + canvasHtml.includes('angle: normalizeAngle(Number(node.data?.angle || 0) + 90)'), + 'Space rotation should also rotate selected Port and Anchor elements' +); +assert( + canvasHtml.includes('const anchorRotation = data.rotation || 0') && + canvasHtml.includes('const anchorVisualRotation = -Number(anchorRotation || 0)') && + canvasHtml.includes('transform: `rotate(${anchorVisualRotation}deg)`') && + canvasHtml.includes('buildPortHandles(localAnchorHandlePorts, { rotation: 0') && + canvasHtml.includes('anchorDirectionHandles') && + canvasHtml.includes('rotation: Number(anchorRotation || 0)') && + canvasHtml.includes('anchorHandleVisualStyle(portHandle') && + canvasHtml.includes('anchorPortVisualSide') && + canvasHtml.includes('portHandle.name') && + canvasHtml.includes('visualSide === \'left\' ? 0 : elementSize.width') && + canvasHtml.includes('anchorPortVisualTop') && + canvasHtml.includes('(index - 1) / (portCount - 1)') && + canvasHtml.includes('elementSize.height - baseHandleStyle.height') && + canvasHtml.includes('localLeft') && + canvasHtml.includes('localTop') && + canvasHtml.includes('handlePositionMap[anchorDirectionHandles.get(portHandle.name) || portHandle.position]') && + canvasHtml.includes('getAnchorHandleRouteDirection') && + canvasHtml.includes('rotation: Number(node.data?.rotation || 0)') && + canvasHtml.includes('directionToReactFlowPosition') && + canvasHtml.includes('sourcePosition: directionToReactFlowPosition(sourceDirection)') && + canvasHtml.includes('targetPosition: directionToReactFlowPosition(targetDirection)') && + !canvasHtml.includes('type: \'parallelRoute\',\n data: {\n ...(edge.data || {}),\n parallelOffset: offset,\n sourceDirection,\n targetDirection') && + !canvasHtml.includes('rotatedAnchorHandlePositions'), + 'Anchor port circles should split into side columns and spread across the full anchor body while built-in rectangular links use rotated directions' +); +assert( + canvasHtml.includes('Port Number') && + canvasHtml.includes('Pitch') && + canvasHtml.includes('portNumber') && + canvasHtml.includes('pitch') && + canvasHtml.includes('DEFAULT_ELEMENT_PITCH') && + canvasHtml.includes('buildElementBoxSize') && + canvasHtml.includes('height: elementSize.height') && + canvasHtml.includes('elementType: \'anchor\'') && + canvasHtml.includes('pitch: DEFAULT_ELEMENT_PITCH') && + canvasHtml.includes('ports: buildElementPorts'), + 'Port and Anchor inspectors should expose port number and pitch, default to 10 um pitch, and grow in height' ); assert( canvasHtml.includes('const componentIndexesByPrefixRef = useRef({});') && diff --git a/tests/project-load-static.test.js b/tests/project-load-static.test.js index 7d764f3..ea04204 100644 --- a/tests/project-load-static.test.js +++ b/tests/project-load-static.test.js @@ -17,3 +17,27 @@ assert( canvasHtml.includes('layoutToCanvasY'), 'loading saved layout YAML should convert GDS/layout Y coordinates back to canvas coordinates' ); +assert( + canvasHtml.includes('buildElementNodesFromYaml'), + 'project loading should rebuild saved anchor/port element nodes from YAML elements' +); +assert( + canvasHtml.includes('Object.entries(doc.elements || {})'), + 'project loading should read doc.elements, not only doc.instances' +); +assert( + canvasHtml.includes('nodeNameMap[elementName] = nodeId'), + 'loaded element names should be registered so saved links can reconnect to anchors and ports' +); +assert( + canvasHtml.includes('getAvailableComponentsForLoadedComponent'), + 'project loading should reconstruct PDK component selection options for saved instances' +); +assert( + canvasHtml.includes('availableComponents: instIsForge ? [FORGE_COMPONENT_LABEL] : loadedAvailableComponents'), + 'loaded PDK instances should keep availableComponents so the right panel can show the PDK selector' +); +assert( + canvasHtml.includes('Array.from(new Set([FORGE_COMPONENT_LABEL, ...sameCategoryComponents'), + 'loaded PDK selector choices should include forge and same-category library components' +);