/* * Description: Shared browser helper library for canvas geometry, component metadata normalization, route styling, YAML export, and validation. * Inside functions: createForgeArguments, getTechnologyManifest, getXsectionInfo, createRouteSettings, updateRouteField, updateRouteXsection, routeStyleForSettings, isForgeComponent, isBasicComponent, createBasicSettings, normalizeAngle, portSideFromAngle, roundPercent, fallbackPercent, positiveNumber, normalizeBoxSize, chooseCategoryComponent, normalizeCanvasSize, clampPositionToCanvas, transformBoxCorner * Developer : Qin Yue @ 2026 * Organization : OptiHK Limited */ (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 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', elementType: 'port', ports: { port: { x: 0, y: 0, a: 0, width: 0.5 } } }, Anchor: { name: 'Anchor', elementType: 'anchor', ports: { 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 } } } }; const BASIC_COMPONENTS = { waveguide: { name: 'waveguide', category: 'basic', settings: { length: 100, width: 0.5, xsection: 'strip' } }, '90 bend': { name: '90 bend', category: 'basic', settings: { radius: 10, width: 0.5, xsection: 'strip' } }, '180 bend': { name: '180 bend', category: 'basic', settings: { radius: 10, width: 0.5, xsection: 'strip' } }, circle: { name: 'circle', category: 'basic', settings: { radius: 10, width: 0.5, xsection: 'strip' } }, cricle: { name: 'cricle', category: 'basic', hidden: true, settings: { radius: 10, width: 0.5, xsection: 'strip' } }, taper: { name: 'taper', category: 'basic', settings: { length: 50, width1: 0.5, width2: 1, xsection: 'strip' } } }; 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 isBasicComponent = (componentName) => Boolean(BASIC_COMPONENTS[componentName]); const createBasicSettings = (componentName, overrides) => ({ ...(BASIC_COMPONENTS[componentName] ? BASIC_COMPONENTS[componentName].settings : {}), ...(overrides || {}) }); 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 fallbackPercent = (index, count) => { if (count <= 1) return 50; return roundPercent(15 + (index / (count - 1)) * 70); }; const positiveNumber = (value) => { const number = Number(value); return Number.isFinite(number) && number > 0 ? number : null; }; const normalizeBoxSize = (metadata, fallback) => { const fallbackSize = fallback || DEFAULT_COMPONENT_BOX_SIZE; const raw = metadata && (metadata.box_size || metadata.box_sz || metadata.boxSize); let width = null; let height = null; if (Array.isArray(raw)) { width = positiveNumber(raw[0]); height = positiveNumber(raw[1]); } else if (raw && typeof raw === 'object') { width = positiveNumber(raw.width ?? raw.w ?? raw.x); height = positiveNumber(raw.height ?? raw.h ?? raw.y); } return { width: width || fallbackSize.width, height: height || fallbackSize.height }; }; const chooseCategoryComponent = (dragName, availableComponents, categoryName) => { const available = Array.isArray(availableComponents) ? availableComponents.filter(Boolean) : []; if (dragName && !isForgeComponent(dragName)) return dragName; const physicalComponent = available.find(component => !isForgeComponent(component)); return physicalComponent || dragName || available[0] || categoryName; }; const normalizeCanvasSize = (size) => ({ width: positiveNumber(size && size.width) || DEFAULT_CANVAS_SIZE.width, height: positiveNumber(size && size.height) || DEFAULT_CANVAS_SIZE.height }); const clampPositionToCanvas = (position, canvasSize, boxSize) => { const size = normalizeCanvasSize(canvasSize); const box = normalizeBoxSize({ box_size: [boxSize && boxSize.width, boxSize && boxSize.height] }); const maxX = Math.max(0, size.width - box.width); const maxY = Math.max(0, size.height - box.height); return { x: Math.min(maxX, Math.max(0, Number(position && position.x) || 0)), y: Math.min(maxY, Math.max(0, Number(position && position.y) || 0)) }; }; const transformBoxCorner = (corner, transform) => { const options = transform || {}; let x = Number(corner && corner.x) || 0; let y = Number(corner && corner.y) || 0; if (options.flop) x = -x; if (options.flip) y = -y; const rotation = Number(options.rotation || 0); if (!rotation) return { x, y }; const radians = rotation * Math.PI / 180; const cos = Math.cos(radians); const sin = Math.sin(radians); return { x: x * cos - y * sin, y: x * sin + y * cos }; }; const roundBoundsValue = (value) => Number(value.toFixed(6)); const calculateLayoutBounds = (pageOrNodes) => { const page = Array.isArray(pageOrNodes) ? { nodes: pageOrNodes } : (pageOrNodes || {}); const nodes = Array.isArray(page.nodes) ? page.nodes : []; const points = []; nodes.forEach(node => { if (!node || !node.position || !node.data || !node.data.componentName || node.data.elementType) return; const box = normalizeBoxSize({ box_size: node.data.boxSize }, DEFAULT_COMPONENT_BOX_SIZE); const origin = { x: Number(node.position.x) || 0, y: Number(node.position.y) || 0 }; [ { x: 0, y: 0 }, { x: box.width, y: 0 }, { x: box.width, y: box.height }, { x: 0, y: box.height } ].forEach(corner => { const transformed = transformBoxCorner(corner, node.data); points.push({ x: origin.x + transformed.x, y: origin.y + transformed.y }); }); }); if (points.length === 0) { const size = normalizeCanvasSize(page.canvasSize || DEFAULT_CANVAS_SIZE); points.push({ x: 0, y: 0 }, { x: size.width, y: size.height }); } const minX = roundBoundsValue(Math.min(...points.map(point => point.x))); const maxX = roundBoundsValue(Math.max(...points.map(point => point.x))); const minY = roundBoundsValue(Math.min(...points.map(point => point.y))); const maxY = roundBoundsValue(Math.max(...points.map(point => point.y))); return { minX, minY, maxX, maxY, width: Math.max(1, maxX - minX), height: Math.max(1, maxY - minY), bottomLeft: { x: minX, y: minY }, topRight: { x: maxX, y: maxY } }; }; const roundMeasureValue = (value) => Number(value.toFixed(3)); const normalizeMeasurePoint = (point) => { const x = Number(point && point.x); const y = Number(point && point.y); if (!Number.isFinite(x) || !Number.isFinite(y)) return null; return { x: roundMeasureValue(x), y: roundMeasureValue(y) }; }; const createRulerMeasurement = (startPoint, endPoint) => { const start = normalizeMeasurePoint(startPoint); const end = normalizeMeasurePoint(endPoint); if (!start || !end) return null; const dx = roundMeasureValue(end.x - start.x); const dy = roundMeasureValue(end.y - start.y); const distance = roundMeasureValue(Math.hypot(dx, dy)); const midpoint = { x: roundMeasureValue((start.x + end.x) / 2), y: roundMeasureValue((start.y + end.y) / 2) }; return { start, end, dx, dy, distance, midpoint, label: `${distance.toFixed(3)} um dx ${dx.toFixed(3)} dy ${dy.toFixed(3)}` }; }; const createComponentSymbolMetrics = (boxSize) => { const size = normalizeBoxSize({ box_size: [boxSize && boxSize.width, boxSize && boxSize.height] }); const widthRatio = size.width >= 400 ? 0.95 : 0.9; return { width: roundMeasureValue(size.width * widthRatio), height: roundMeasureValue(size.height * 0.68) }; }; const transformPortInfo = (info, transform) => { const source = info || {}; const options = transform || {}; let x = Number(source.x || 0); let y = Number(source.y || 0); let angle = Number(source.a || 0); if (options.flip) { y = -y; angle = -angle; } if (options.flop) { x = -x; angle = 180 - angle; } const rotation = Number(options.rotation || 0); if (rotation) { const radians = rotation * Math.PI / 180; const cos = Math.cos(radians); const sin = Math.sin(radians); const nextX = x * cos - y * sin; const nextY = x * sin + y * cos; x = nextX; y = nextY; angle += rotation; } return { ...source, x, y, a: normalizeAngle(angle) }; }; const buildSideHandles = (ports, side) => { const vertical = side === 'left' || side === 'right'; return ports.map((port, index) => { const percent = fallbackPercent(index, ports.length); 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, transform) => { const grouped = { left: [], right: [], top: [], bottom: [] }; Object.entries(ports || {}).forEach(([name, info]) => { if (name === 'a0' || name === 'b0') return; const transformedInfo = transformPortInfo(info, transform); const side = portSideFromAngle(transformedInfo.a); grouped[side].push({ name, info: transformedInfo }); }); 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 canvasToLayoutY = (value) => -Number(value || 0); const layoutToCanvasY = (value) => -Number(value || 0); 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, flip, flop, forgeArguments, basicArguments }) => { const forge = isForgeComponent(componentName); const basic = isBasicComponent(componentName); const componentValue = forge ? FORGE_COMPONENT_TYPE : (basic ? componentName : componentPath); const settings = forge ? createForgeArguments(forgeArguments) : null; const settingsYaml = forge ? `\n settings:\n${buildSettingsYaml(settings, 6)}` : basic ? `\n settings:\n${buildSettingsYaml(createBasicSettings(componentName, basicArguments), 6)}` : '\n settings:\n length:'; return ` ${instanceName}: component: ${componentValue} x: ${Number(position.x || 0).toFixed(1)} y: ${canvasToLayoutY(position.y).toFixed(1)} rotation: ${Number(rotation || 0).toFixed(1)} flip: ${flip ? 1 : 0} flop: ${flop ? 1 : 0} mirror: ${flip ? 'true' : '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, flip: Boolean(data.flip), flop: Boolean(data.flop), forgeArguments: data.forgeArguments, basicArguments: data.basicArguments }); }) .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 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 } }; } 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)); }; const buildBasicComponentPorts = (componentName, settings) => { const values = createBasicSettings(componentName, settings); const length = Number(values.length || 0); const radius = Number(values.radius || 10); const width = Number(values.width ?? values.width1 ?? 0.5); const xsection = values.xsection || values.xs || 'strip'; if (componentName === 'waveguide') { return { a1: { x: 0, y: 0, a: 180, width, xsection, description: 'Optical power input' }, b1: { x: length, y: 0, a: 0, width, xsection, description: 'Optical power output' } }; } if (componentName === '90 bend') { return { a1: { x: 0, y: 0, a: 180, width, xsection, description: 'Optical power input' }, b1: { x: radius, y: radius, a: 90, width, xsection, description: 'Optical power output' } }; } if (componentName === '180 bend') { return { a1: { x: 0, y: 0, a: 180, width, xsection, description: 'Optical power input' }, b1: { x: 0, y: 2 * radius, a: 180, width, xsection, description: 'Optical power output' } }; } if (componentName === 'cricle' || componentName === 'circle') { return { a1: { x: radius, y: 0, a: 180, width, xsection, description: 'Optical power input' }, b1: { x: radius, y: 0, a: 180, width, xsection, description: 'Optical power output' } }; } if (componentName === 'taper') { return { a1: { x: 0, y: 0, a: 180, width: Number(values.width1 || width), xsection, description: 'Optical power input' }, b1: { x: length, y: 0, a: 0, width: Number(values.width2 || width), xsection, description: 'Optical power output' } }; } return {}; }; const getBasicComponentMetadata = (componentName, settings) => { if (!isBasicComponent(componentName)) return null; const values = createBasicSettings(componentName, settings); const length = Number(values.length || 0); const radius = Number(values.radius || 10); const width = Number(values.width ?? values.width1 ?? 0.5); const width2 = Number(values.width2 ?? width); const boxSize = componentName === 'waveguide' ? [Math.max(length, 10), Math.max(width * 4, 4)] : componentName === 'taper' ? [Math.max(length, 10), Math.max(width, width2) * 10 + 18] : componentName === '180 bend' ? [radius, radius * 2] : [radius, radius]; return { name: componentName, foundry: 'mxpic', process: 'basic nazca', ports: buildBasicComponentPorts(componentName, values), box_size: boxSize, settings: values }; }; const buildPageComponentPorts = (port, nodes) => { const portNodes = (nodes || []).filter(isPortElementNode); if (portNodes.length > 0) { return portNodes.reduce((ports, node) => { const data = node.data || {}; 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; }, {}); } 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: ${canvasToLayoutY(info.y).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; 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 || '')}`; }); 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); 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)} xsection: ${route.xsection} family: ${route.family} width: ${Number(route.width)} radius: ${Number(route.radius)} routing_type: ${route.routing_type}${pointsYaml}`; } 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}${pointsYaml}`; }); 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 getNodePortCanvasPoint = (node, portName) => { if (!node) return null; 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')) { 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; 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 percentValue = (value, fallback = 50) => { if (typeof value !== 'string') return fallback; const number = Number(value.replace('%', '')); return Number.isFinite(number) ? number : fallback; }; const getEdgeEndpointPoint = (edge, nodeMap, endpoint) => { const nodeId = endpoint === 'source' ? edge.source : edge.target; const handleId = endpoint === 'source' ? edge.sourceHandle : edge.targetHandle; const node = nodeMap[nodeId]; if (!node) return null; const pinPoint = getNodePortCanvasPoint(node, handleId); if (pinPoint) return pinPoint; const ports = node.data && node.data.ports; if (ports && handleId) { const handles = buildPortHandles(ports, { rotation: (node.data && node.data.rotation) || 0, flip: Boolean(node.data && node.data.flip), flop: Boolean(node.data && node.data.flop) }); const handle = handles.find(item => item.name === handleId); if (handle) { 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') { y += componentSize.height * percentValue(handle.style && handle.style.top) / 100; } else if (handle.position === 'right') { x += componentSize.width; y += componentSize.height * percentValue(handle.style && handle.style.top) / 100; } else if (handle.position === 'top') { x += componentSize.width * percentValue(handle.style && handle.style.left) / 100; } else { x += componentSize.width * percentValue(handle.style && handle.style.left) / 100; y += componentSize.height; } return { x: Number(x.toFixed(3)), y: Number(y.toFixed(3)) }; } } return null; }; const getEdgeRoutePoints = (edge, nodeMap) => { const explicitPoints = edge && edge.data && Array.isArray(edge.data.points) ? edge.data.points : []; if (explicitPoints.length >= 2) { const points = explicitPoints .map(point => ({ x: Number(point && point.x), y: Number(point && point.y) })) .filter(point => Number.isFinite(point.x) && Number.isFinite(point.y)); if (!Boolean(edge.data && edge.data.freeRoute) && points.length >= 2) { const sourcePoint = getEdgeEndpointPoint(edge, nodeMap, 'source'); const targetPoint = getEdgeEndpointPoint(edge, nodeMap, 'target'); if (sourcePoint) points[0] = sourcePoint; if (targetPoint) points[points.length - 1] = targetPoint; } return points; } return [getNodeCenter(nodeMap[edge.source]), getNodeCenter(nodeMap[edge.target])].filter(Boolean); }; const routeSegmentsIntersect = (pointsA, pointsB) => { for (let i = 0; i < pointsA.length - 1; i += 1) { for (let j = 0; j < pointsB.length - 1; j += 1) { if (segmentsIntersect(pointsA[i], pointsA[i + 1], pointsB[j], pointsB[j + 1])) { return true; } } } return false; }; 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 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 (routeTypeKey(route) !== candidateType) continue; const points = getEdgeRoutePoints(edge, nodeMap); if (routeSegmentsIntersect(candidatePoints, points)) { return { conflictEdge: edge, xsection: route.xsection }; } } return null; }; const findSameFamilyRouteCrossing = findSameTypeRouteCrossing; return { FORGE_COMPONENT_LABEL, FORGE_COMPONENT_TYPE, DEFAULT_COMPONENT_BOX_SIZE, DEFAULT_CANVAS_SIZE, PORT_NODE_SIZE, DEFAULT_ELEMENT_PITCH, ELEMENT_COMPONENTS, BASIC_COMPONENTS, DEFAULT_FORGE_ARGUMENTS, FALLBACK_TECHNOLOGY_MANIFEST, canvasToLayoutY, layoutToCanvasY, createForgeArguments, createRouteSettings, updateRouteField, updateRouteXsection, routeStyleForSettings, findSameTypeRouteCrossing, findSameFamilyRouteCrossing, isForgeComponent, isBasicComponent, createBasicSettings, normalizeAngle, portSideFromAngle, normalizeBoxSize, chooseCategoryComponent, normalizeCanvasSize, clampPositionToCanvas, calculateLayoutBounds, createRulerMeasurement, createComponentSymbolMetrics, transformPortInfo, getNodePortCanvasPoint, buildPortHandles, buildElementPorts, buildElementBoxSize, buildBasicComponentPorts, getBasicComponentMetadata, buildInstanceYaml, buildInstancesYaml, buildPageComponentPorts, buildCanvasPortsYaml, buildBundlesYaml, buildPortsYaml, buildElementsYaml, buildSettingsYaml, toYamlScalar }; });