1299 lines
50 KiB
JavaScript
1299 lines
50 KiB
JavaScript
/*
|
|
* 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) {
|
|
// Build the helper API once, then expose it to both browser and Node test environments.
|
|
const helpers = factory();
|
|
if (typeof module === 'object' && module.exports) {
|
|
module.exports = helpers;
|
|
}
|
|
root.MxpicCanvasHelpers = helpers;
|
|
})(typeof window !== 'undefined' ? window : globalThis, function () {
|
|
// Label used by the canvas to represent generated mxpic_forge components.
|
|
const FORGE_COMPONENT_LABEL = 'generate with mxpic_forge';
|
|
// Serialized component type used when saving mxpic_forge-generated components.
|
|
const FORGE_COMPONENT_TYPE = 'generate_with_forge';
|
|
// Fallback visual size for PDK components without explicit metadata.
|
|
const DEFAULT_COMPONENT_BOX_SIZE = { width: 132, height: 82 };
|
|
// Default editable canvas dimensions in micrometers.
|
|
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 PORT_LABEL_MIN_CHARS = 5;
|
|
const PORT_LABEL_CHAR_WIDTH = 7;
|
|
const PORT_LABEL_HORIZONTAL_PADDING = 12;
|
|
// Anchor body width used in the canvas visual representation.
|
|
const ANCHOR_NODE_WIDTH = 16;
|
|
// Default spacing between repeated anchor or port pins.
|
|
const DEFAULT_ELEMENT_PITCH = 10;
|
|
// Defines built-in port and anchor element metadata before per-node expansion.
|
|
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: 0, a: 180, width: 0.5 },
|
|
b1: { x: 0, y: 0, a: 0, width: 0.5 }
|
|
}
|
|
}
|
|
};
|
|
|
|
|
|
|
|
// Defines local primitive components that do not require PDK lookup.
|
|
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' }
|
|
}
|
|
};
|
|
|
|
// Default parameters sent when creating a component through mxpic_forge.
|
|
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: ''
|
|
};
|
|
|
|
// Fallback routing technology data used when the backend manifest is unavailable.
|
|
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 }
|
|
}
|
|
};
|
|
|
|
// Merge user edits with default mxpic_forge arguments for saving and generation.
|
|
const createForgeArguments = (overrides) => ({
|
|
...DEFAULT_FORGE_ARGUMENTS,
|
|
...(overrides || {})
|
|
});
|
|
|
|
// Return a manifest object, falling back to bundled defaults when needed.
|
|
const getTechnologyManifest = (manifest) => manifest || FALLBACK_TECHNOLOGY_MANIFEST;
|
|
|
|
// Look up width, radius, and family defaults for a routing cross-section.
|
|
const getXsectionInfo = (xsection, manifest) => {
|
|
const technology = getTechnologyManifest(manifest);
|
|
return (technology.xsections && technology.xsections[xsection]) || technology.xsections.strip || {};
|
|
};
|
|
|
|
// Normalize route settings so every edge has xsection, family, width, radius, and bend type.
|
|
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)
|
|
};
|
|
};
|
|
|
|
// Apply a single route-field edit while preserving route defaults and width override state.
|
|
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
|
|
};
|
|
};
|
|
|
|
// Switch an edge cross-section and refresh dependent routing defaults.
|
|
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;
|
|
};
|
|
|
|
// Convert route settings into React Flow edge styling for canvas display.
|
|
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
|
|
}
|
|
};
|
|
};
|
|
|
|
// Check whether a component name refers to the mxpic_forge generator placeholder.
|
|
const isForgeComponent = (componentName) => componentName === FORGE_COMPONENT_LABEL || componentName === FORGE_COMPONENT_TYPE;
|
|
// Check whether a component is one of the built-in primitive canvas elements.
|
|
const isBasicComponent = (componentName) => Boolean(BASIC_COMPONENTS[componentName]);
|
|
|
|
// Merge primitive component defaults with user-entered values.
|
|
const createBasicSettings = (componentName, overrides) => ({
|
|
...(BASIC_COMPONENTS[componentName] ? BASIC_COMPONENTS[componentName].settings : {}),
|
|
...(overrides || {})
|
|
});
|
|
|
|
// Normalize angles into the -180 to 180 degree range used by port logic.
|
|
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;
|
|
};
|
|
|
|
// Map a port angle to the canvas side where its handle should appear.
|
|
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';
|
|
};
|
|
|
|
// Round handle percentages so saved and rendered positions stay stable.
|
|
const roundPercent = (value) => Number(value.toFixed(3));
|
|
|
|
// Generate even fallback spacing for handles when no exact position is available.
|
|
const fallbackPercent = (index, count) => {
|
|
if (count <= 1) return 50;
|
|
return roundPercent(15 + (index / (count - 1)) * 70);
|
|
};
|
|
|
|
// Accept only finite positive numeric values from metadata or user input.
|
|
const positiveNumber = (value) => {
|
|
const number = Number(value);
|
|
return Number.isFinite(number) && number > 0 ? number : null;
|
|
};
|
|
|
|
// Resolve component visual dimensions from metadata with a safe fallback.
|
|
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
|
|
};
|
|
};
|
|
|
|
// Select the physical component to place when a library category is dragged.
|
|
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;
|
|
};
|
|
|
|
// Resolve valid canvas dimensions from saved project metadata.
|
|
const normalizeCanvasSize = (size) => ({
|
|
width: positiveNumber(size && size.width) || DEFAULT_CANVAS_SIZE.width,
|
|
height: positiveNumber(size && size.height) || DEFAULT_CANVAS_SIZE.height
|
|
});
|
|
|
|
// Keep dragged or pasted nodes inside the active canvas bounds.
|
|
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))
|
|
};
|
|
};
|
|
|
|
// Rotate or mirror a component-box corner for layout bounds calculation.
|
|
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
|
|
};
|
|
};
|
|
|
|
// Round layout-bound values for stable preview and export metadata.
|
|
const roundBoundsValue = (value) => Number(value.toFixed(6));
|
|
|
|
// Calculate the bounding rectangle containing all visible canvas nodes.
|
|
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 }
|
|
};
|
|
};
|
|
|
|
// Calculate the visual footprint of a user canvas when placed as a component.
|
|
const calculateCompositeBoxSize = (pageOrNodes, fallback) => {
|
|
const page = Array.isArray(pageOrNodes) ? { nodes: pageOrNodes } : (pageOrNodes || {});
|
|
const nodes = Array.isArray(page.nodes) ? page.nodes : [];
|
|
const fallbackSize = normalizeBoxSize({ box_size: fallback }, DEFAULT_COMPONENT_BOX_SIZE);
|
|
const points = [];
|
|
const addPoint = (x, y) => {
|
|
const px = Number(x);
|
|
const py = Number(y);
|
|
if (Number.isFinite(px) && Number.isFinite(py)) {
|
|
points.push({ x: px, y: py });
|
|
}
|
|
};
|
|
|
|
Object.values(buildPageComponentPorts(page.port, nodes)).forEach(port => {
|
|
addPoint(port.x, port.y);
|
|
});
|
|
|
|
nodes.forEach(node => {
|
|
if (!node || node.type !== 'rotatableNode' || node.data?.elementType || !node.position) return;
|
|
const box = normalizeBoxSize({ box_size: node.data?.boxSize }, fallbackSize);
|
|
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);
|
|
addPoint(origin.x + transformed.x, origin.y + transformed.y);
|
|
});
|
|
});
|
|
|
|
if (points.length < 2) return fallbackSize;
|
|
const minX = Math.min(...points.map(point => point.x));
|
|
const maxX = Math.max(...points.map(point => point.x));
|
|
const minY = Math.min(...points.map(point => point.y));
|
|
const maxY = Math.max(...points.map(point => point.y));
|
|
return {
|
|
width: Math.max(1, roundMeasureValue(maxX - minX)),
|
|
height: Math.max(1, roundMeasureValue(maxY - minY))
|
|
};
|
|
};
|
|
|
|
// Round ruler measurements for compact display.
|
|
const roundMeasureValue = (value) => Number(value.toFixed(3));
|
|
|
|
// Convert pointer coordinates into valid ruler measurement points.
|
|
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) };
|
|
};
|
|
|
|
// Build distance, delta, and midpoint values for the ruler overlay.
|
|
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)}`
|
|
};
|
|
};
|
|
|
|
// Derive compact symbol dimensions for drawing component previews.
|
|
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)
|
|
};
|
|
};
|
|
|
|
// Apply node rotation and mirror transforms to a component port definition.
|
|
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)
|
|
};
|
|
};
|
|
|
|
// Create ordered React Flow handles for all ports on a single visual side.
|
|
const clampPercent = (value) => roundPercent(Math.min(100, Math.max(0, value)));
|
|
|
|
const createCoordinateMetrics = (ports, boxSize) => {
|
|
const width = positiveNumber(boxSize && boxSize.width);
|
|
const height = positiveNumber(boxSize && boxSize.height);
|
|
if (!width || !height) return null;
|
|
const values = { x: [], y: [] };
|
|
ports.forEach(port => {
|
|
const info = port && port.info;
|
|
const x = Number(info && info.x);
|
|
const y = Number(info && info.y);
|
|
if (Number.isFinite(x)) values.x.push(x);
|
|
if (Number.isFinite(y)) values.y.push(y);
|
|
});
|
|
return { width, height, values };
|
|
};
|
|
|
|
const coordinateAxisMode = (axisValues, size) => {
|
|
if (!axisValues || axisValues.length === 0 || !size) return 'fallback';
|
|
const min = Math.min(...axisValues);
|
|
const max = Math.max(...axisValues);
|
|
const epsilon = Math.max(0.001, size * 0.001);
|
|
if (Math.abs(max - min) <= epsilon) {
|
|
return Math.abs(max) <= epsilon ? 'centered' : 'fallback';
|
|
}
|
|
if (min < -epsilon && max <= size / 2 + epsilon && min >= -size / 2 - epsilon) {
|
|
return 'centered';
|
|
}
|
|
if (min >= -epsilon && max <= size + epsilon) {
|
|
return 'positive';
|
|
}
|
|
return 'fallback';
|
|
};
|
|
|
|
const coordinatePercent = (info, axis, metrics) => {
|
|
if (!metrics) return null;
|
|
const value = Number(info && info[axis]);
|
|
if (!Number.isFinite(value)) return null;
|
|
const size = axis === 'x' ? metrics.width : metrics.height;
|
|
const mode = coordinateAxisMode(metrics.values[axis], size);
|
|
if (mode === 'centered') {
|
|
return axis === 'x'
|
|
? clampPercent(50 + (value / size) * 100)
|
|
: clampPercent(50 - (value / size) * 100);
|
|
}
|
|
if (mode === 'positive') {
|
|
return axis === 'x'
|
|
? clampPercent((value / size) * 100)
|
|
: clampPercent(100 - (value / size) * 100);
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const buildSideHandles = (ports, side, metrics) => {
|
|
const vertical = side === 'left' || side === 'right';
|
|
|
|
return ports.map((port, index) => {
|
|
const explicitPercent = Number(port.info && port.info.handlePercent);
|
|
const exactPercent = coordinatePercent(port.info, vertical ? 'y' : 'x', metrics);
|
|
const percent = Number.isFinite(explicitPercent)
|
|
? explicitPercent
|
|
: exactPercent !== null
|
|
? exactPercent
|
|
: fallbackPercent(index, ports.length);
|
|
const percentValue = `${percent}%`;
|
|
const style = vertical
|
|
? {
|
|
left: side === 'left' ? 0 : '100%',
|
|
right: 'auto',
|
|
top: percentValue,
|
|
bottom: 'auto',
|
|
transform: 'translate(-50%, -50%)'
|
|
}
|
|
: {
|
|
left: percentValue,
|
|
right: 'auto',
|
|
top: side === 'top' ? 0 : '100%',
|
|
bottom: 'auto',
|
|
transform: 'translate(-50%, -50%)'
|
|
};
|
|
|
|
return {
|
|
name: port.name,
|
|
position: side,
|
|
style,
|
|
port: port.info
|
|
};
|
|
});
|
|
};
|
|
|
|
// Group transformed ports into canvas handles with side and position styling.
|
|
const buildPortHandles = (ports, transform) => {
|
|
const options = transform || {};
|
|
const grouped = { left: [], right: [], top: [], bottom: [] };
|
|
const allPorts = [];
|
|
Object.entries(ports || {}).forEach(([name, info]) => {
|
|
if (name === 'a0' || name === 'b0') return;
|
|
const transformedInfo = transformPortInfo(info, options);
|
|
const side = portSideFromAngle(transformedInfo.a);
|
|
const port = { name, info: transformedInfo };
|
|
grouped[side].push(port);
|
|
allPorts.push(port);
|
|
});
|
|
const metrics = createCoordinateMetrics(allPorts, options.boxSize);
|
|
|
|
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', metrics),
|
|
...buildSideHandles(grouped.right, 'right', metrics),
|
|
...buildSideHandles(grouped.top, 'top', metrics),
|
|
...buildSideHandles(grouped.bottom, 'bottom', metrics)
|
|
];
|
|
};
|
|
|
|
// Serialize primitive JavaScript values into YAML-friendly scalar text.
|
|
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));
|
|
};
|
|
|
|
// Convert canvas Y coordinates into layout Y coordinates.
|
|
const canvasToLayoutY = (value) => -Number(value || 0);
|
|
// Convert layout Y coordinates back into canvas Y coordinates.
|
|
const layoutToCanvasY = (value) => -Number(value || 0);
|
|
|
|
// Serialize nested component settings into YAML blocks.
|
|
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');
|
|
};
|
|
|
|
// Serialize one component instance into saved layout YAML.
|
|
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}`;
|
|
};
|
|
|
|
// Serialize all component nodes on a page into the instances YAML section.
|
|
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');
|
|
};
|
|
|
|
// Resolve the display/export name for a port-like node.
|
|
const getNodePortName = (node) => {
|
|
const name = node && node.data && (node.data.portName || node.data.componentDisplayName || node.data.label);
|
|
return name || (node && node.id) || 'port';
|
|
};
|
|
|
|
const pinRoleFromElementPortName = (elementType, portName) => {
|
|
const name = String(portName || '');
|
|
if (elementType === 'anchor') {
|
|
const anchorMatch = name.match(/^([ab])(\d+)$/);
|
|
return anchorMatch ? `${anchorMatch[1]}${anchorMatch[2]}` : name;
|
|
}
|
|
const portMatch = name.match(/^port_(\d+)$/);
|
|
return portMatch ? `io${portMatch[1]}` : 'io1';
|
|
};
|
|
|
|
const defaultElementPinName = (elementName, role) => `${elementName}_${role}`;
|
|
|
|
const getElementPinName = (node, portName) => {
|
|
const data = (node && node.data) || {};
|
|
const elementType = data.elementType === 'anchor' ? 'anchor' : 'port';
|
|
const elementName = getNodePortName(node);
|
|
const role = pinRoleFromElementPortName(elementType, portName);
|
|
return (data.pinNames && data.pinNames[role]) || defaultElementPinName(elementName, role);
|
|
};
|
|
|
|
const buildElementPinEntries = (node) => {
|
|
const data = (node && node.data) || {};
|
|
const elementType = data.elementType === 'anchor' ? 'anchor' : 'port';
|
|
return Object.keys(buildElementPorts(elementType, data)).map(portName => {
|
|
const role = pinRoleFromElementPortName(elementType, portName);
|
|
return { role, name: getElementPinName(node, portName) };
|
|
});
|
|
};
|
|
|
|
// Detect standalone port nodes that become top-level layout ports.
|
|
const isPortElementNode = (node) => node && (node.data && node.data.elementType === 'port' || node.id === 'page-port' || node.type === 'portNode');
|
|
// Detect built-in port or anchor nodes for element YAML export.
|
|
const isElementNode = (node) => node && node.data && (node.data.elementType === 'port' || node.data.elementType === 'anchor');
|
|
|
|
// Clamp repeated port counts to a supported positive integer.
|
|
const normalizePortNumber = (value) => {
|
|
const number = Math.floor(Number(value));
|
|
return Number.isFinite(number) ? Math.max(1, number) : 1;
|
|
};
|
|
|
|
// Resolve repeated port spacing with the default pitch fallback.
|
|
const normalizePitch = (value) => {
|
|
const number = Number(value);
|
|
return Number.isFinite(number) ? Math.max(0, number) : DEFAULT_ELEMENT_PITCH;
|
|
};
|
|
|
|
// Calculate the centered offset for one repeated port index.
|
|
const elementPortOffset = (index, count, pitch) => ((count - 1) / 2 - index) * pitch;
|
|
|
|
// Keep Basic line-like components at twice the 10 px canvas port-circle size.
|
|
const BASIC_LINE_COMPONENT_HEIGHT = 20;
|
|
|
|
// Keep Basic line-like components visually slim with a stable canvas hit area.
|
|
const basicLineComponentHeight = () => BASIC_LINE_COMPONENT_HEIGHT;
|
|
|
|
// Keep bend components readable by using the radius-25 footprint as the
|
|
// minimum canvas size, while still allowing larger radii to grow.
|
|
const BASIC_BEND_MIN_CANVAS_RADIUS = 25;
|
|
|
|
// Normalize bend radius into a positive canvas footprint dimension.
|
|
const basicBendRadiusSize = (radius) => {
|
|
const numericRadius = Number(radius || 0);
|
|
return Number.isFinite(numericRadius) && numericRadius > 0
|
|
? Math.max(BASIC_BEND_MIN_CANVAS_RADIUS, numericRadius)
|
|
: BASIC_BEND_MIN_CANVAS_RADIUS;
|
|
};
|
|
|
|
// Grow port and anchor visual bodies so repeated port circles do not overlap.
|
|
const buildElementBoxSize = (data) => {
|
|
const portNumber = normalizePortNumber(data && data.portNumber);
|
|
const pitch = normalizePitch(data && data.pitch);
|
|
const handleClearance = Math.max(pitch, 14);
|
|
const portDisplayName = String((data && (data.portName || data.componentDisplayName || data.label)) || 'port');
|
|
const portWidth = Math.max(
|
|
PORT_NODE_SIZE,
|
|
PORT_LABEL_HORIZONTAL_PADDING + Math.max(PORT_LABEL_MIN_CHARS, portDisplayName.length) * PORT_LABEL_CHAR_WIDTH
|
|
);
|
|
return {
|
|
width: data && data.elementType === 'anchor' ? ANCHOR_NODE_WIDTH : portWidth,
|
|
height: Math.max(PORT_NODE_SIZE, PORT_NODE_SIZE + Math.max(0, portNumber - 1) * handleClearance)
|
|
};
|
|
};
|
|
|
|
// Expand port and anchor definitions into one or more named physical ports.
|
|
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
|
|
}
|
|
};
|
|
}
|
|
const entries = [];
|
|
Array.from({ length: portNumber }, (_, index) => {
|
|
const y = 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);
|
|
};
|
|
|
|
// Generate port metadata for built-in primitive components.
|
|
const buildBasicComponentPorts = (componentName, settings) => {
|
|
const values = createBasicSettings(componentName, settings);
|
|
const length = Number(values.length || 0);
|
|
const radius = Number(values.radius || 10);
|
|
const bendSize = basicBendRadiusSize(radius);
|
|
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: bendSize / 2, a: 180, width, xsection, description: 'Optical power input' },
|
|
b1: { x: bendSize / 2, y: 0, 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 * bendSize, 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 {};
|
|
};
|
|
|
|
// Generate visual metadata and port definitions for primitive components.
|
|
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 bendSize = basicBendRadiusSize(radius);
|
|
const boxSize = componentName === 'waveguide'
|
|
? [Math.max(length, 10), basicLineComponentHeight(width)]
|
|
: componentName === 'taper'
|
|
? [Math.max(length, 10), basicLineComponentHeight(width, width2)]
|
|
: componentName === '180 bend'
|
|
? [bendSize, bendSize * 2]
|
|
: [bendSize, bendSize];
|
|
return {
|
|
name: componentName,
|
|
foundry: 'mxpic',
|
|
process: 'basic nazca',
|
|
ports: buildBasicComponentPorts(componentName, values),
|
|
box_size: boxSize,
|
|
settings: values
|
|
};
|
|
};
|
|
|
|
// Export standalone Port pins as the outward-facing pin angle.
|
|
const externalPortAngle = (angle) => normalizeAngle(Number(angle ?? 0) + 180);
|
|
|
|
// Convert standalone port nodes into page-level layout pins.
|
|
const buildPageComponentPins = (port, nodes) => {
|
|
const portNodes = (nodes || []).filter(isPortElementNode);
|
|
if (portNodes.length > 0) {
|
|
return portNodes.reduce((pins, 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 = getElementPinName(node, portName);
|
|
const point = getNodePortCanvasPoint(node, portName) || {
|
|
x: Number((node.position && node.position.x) || 0),
|
|
y: Number((node.position && node.position.y) || 0)
|
|
};
|
|
pins[exportName] = {
|
|
element: baseName,
|
|
pin: pinRoleFromElementPortName('port', portName),
|
|
x: Number(point.x || 0),
|
|
y: Number(point.y || 0),
|
|
a: externalPortAngle(portInfo.a ?? data.angle ?? data.a ?? 0),
|
|
width: Number(portInfo.width || data.width || 0.5)
|
|
};
|
|
});
|
|
return pins;
|
|
}, {});
|
|
}
|
|
if (!port) return {};
|
|
return {
|
|
port_io1: {
|
|
element: 'port',
|
|
pin: 'io1',
|
|
x: Number(port.x || 0),
|
|
y: Number(port.y || 0),
|
|
a: externalPortAngle(port.a || 0),
|
|
width: Number(port.width || 0.5)
|
|
}
|
|
};
|
|
};
|
|
|
|
// Backward-compatible helper name for callers that still use the old JS API.
|
|
const buildPageComponentPorts = buildPageComponentPins;
|
|
|
|
// Serialize standalone canvas pins into a layout pins YAML section.
|
|
const buildCanvasPinsYaml = (nodes, fallbackPort) => {
|
|
const pins = buildPageComponentPins(fallbackPort, nodes);
|
|
const entries = Object.entries(pins);
|
|
if (entries.length === 0) return 'pins: []';
|
|
const sourceNodes = new Map((nodes || []).filter(isPortElementNode).map(node => [getNodePortName(node), node]));
|
|
const lines = entries.map(([name, info]) => {
|
|
const data = (sourceNodes.get(info.element) && sourceNodes.get(info.element).data) || {};
|
|
const description = data.description ? `\n description: ${toYamlScalar(data.description)}` : '';
|
|
return `- name: ${name}
|
|
${data.layer ? `layer: ${data.layer}` : 'layer: WG_CORE'}
|
|
element: ${info.element}
|
|
pin: ${info.pin}
|
|
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 `pins:\n${lines.join('\n')}`;
|
|
};
|
|
|
|
const buildCanvasPortsYaml = buildCanvasPinsYaml;
|
|
|
|
// Maintain legacy single-port YAML export behavior for older callers.
|
|
const buildPortsYaml = (port) => buildCanvasPinsYaml([], port);
|
|
|
|
// Serialize built-in port and anchor nodes into layout element metadata.
|
|
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);
|
|
const pinLines = buildElementPinEntries(node)
|
|
.map(pin => ` - name: ${pin.name}\n role: ${pin.role}`)
|
|
.join('\n');
|
|
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)}
|
|
pin_number: ${portNumber}
|
|
pitch: ${Number(pitch)}
|
|
layer: ${data.layer || 'WG_CORE'}
|
|
width: ${Number(data.width || 0.5)}
|
|
description: ${toYamlScalar(data.description || '')}
|
|
pins:
|
|
${pinLines}`;
|
|
});
|
|
return `elements:\n${lines.join('\n')}`;
|
|
};
|
|
|
|
const finiteNumberOrNull = (value) => {
|
|
const number = Number(value);
|
|
return Number.isFinite(number) ? number : null;
|
|
};
|
|
|
|
const getRouteEndpointWidth = (node, handleId) => {
|
|
if (!node || !node.data) return null;
|
|
const dataWidth = finiteNumberOrNull(node.data.width);
|
|
if (dataWidth !== null) return dataWidth;
|
|
const ports = node.data.ports || {};
|
|
const portWidth = ports[handleId] ? finiteNumberOrNull(ports[handleId].width) : null;
|
|
return portWidth;
|
|
};
|
|
|
|
// Serialize canvas links into routed bundle YAML including route settings and bend points.
|
|
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 = 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)}
|
|
xsection: ${route.xsection}
|
|
family: ${route.family}
|
|
width: ${Number(routeWidth)}
|
|
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(routeWidth)}
|
|
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}`;
|
|
};
|
|
|
|
// Return the center point of a node when a more precise port point is unavailable.
|
|
const getNodeCenter = (node) => {
|
|
if (!node) return null;
|
|
return {
|
|
x: Number((node.position && node.position.x) || 0),
|
|
y: Number((node.position && node.position.y) || 0)
|
|
};
|
|
};
|
|
|
|
// Resolve the exact canvas coordinate of a named node port or anchor pin.
|
|
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 data = node.data || {};
|
|
const transformedInfo = transformPortInfo(portInfo, {
|
|
rotation: data.angle ?? data.a ?? data.rotation ?? 0,
|
|
flip: Boolean(data.flip),
|
|
flop: Boolean(data.flop)
|
|
});
|
|
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))
|
|
};
|
|
};
|
|
|
|
// Convert handle percent strings into numeric ratios for endpoint lookup.
|
|
const percentValue = (value, fallback = 50) => {
|
|
if (typeof value !== 'string') return fallback;
|
|
const number = Number(value.replace('%', ''));
|
|
return Number.isFinite(number) ? number : fallback;
|
|
};
|
|
|
|
// Resolve a route endpoint from an edge handle and its connected node.
|
|
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;
|
|
};
|
|
|
|
// Return the editable route polyline points for crossing checks and YAML export.
|
|
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);
|
|
};
|
|
|
|
// Check two routed polylines for any segment crossing.
|
|
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;
|
|
};
|
|
|
|
// Classify point ordering for the line-segment intersection test.
|
|
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;
|
|
};
|
|
|
|
// Detect whether two line segments cross each other.
|
|
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;
|
|
};
|
|
|
|
// Normalize equivalent route xsection names before same-type crossing checks.
|
|
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;
|
|
};
|
|
|
|
// Find an existing same-type route that would cross a candidate edge.
|
|
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;
|
|
};
|
|
|
|
// Backward-compatible alias for same-type route crossing validation.
|
|
const findSameFamilyRouteCrossing = findSameTypeRouteCrossing;
|
|
|
|
// Expose the helper functions consumed by canvas.html and the Node-based tests.
|
|
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,
|
|
calculateCompositeBoxSize,
|
|
createRulerMeasurement,
|
|
createComponentSymbolMetrics,
|
|
transformPortInfo,
|
|
getNodePortCanvasPoint,
|
|
buildPortHandles,
|
|
buildElementPorts,
|
|
buildElementPinEntries,
|
|
getElementPinName,
|
|
buildElementBoxSize,
|
|
buildBasicComponentPorts,
|
|
getBasicComponentMetadata,
|
|
buildInstanceYaml,
|
|
buildInstancesYaml,
|
|
buildPageComponentPorts,
|
|
buildPageComponentPins,
|
|
buildCanvasPinsYaml,
|
|
buildCanvasPortsYaml,
|
|
buildBundlesYaml,
|
|
buildPortsYaml,
|
|
buildElementsYaml,
|
|
buildSettingsYaml,
|
|
toYamlScalar
|
|
};
|
|
});
|