Routing nchor added

This commit is contained in:
2026-05-29 21:51:57 +08:00
parent 1215bf978a
commit 07ee7f9dd7
22 changed files with 3230 additions and 426 deletions
+77 -11
View File
@@ -122,6 +122,36 @@ def cell_svg_path(project_name, cell_name):
return os.path.join(project_root(project_name), f"{safe_name(cell_name, 'canvas_1')}.svg")
def cell_routes_path(project_name, cell_name):
return os.path.join(project_root(project_name), f"{safe_name(cell_name, 'canvas_1')}.routes.yml")
def write_route_points_sidecar(yaml_content, output_path):
layout = yaml.safe_load(yaml_content) or {}
routes = {}
for bundle_name, bundle in (layout.get("bundles") or {}).items():
saved_links = []
for link in bundle.get("links") or []:
points = link.get("points") or []
if not points:
continue
saved_links.append({
"from": link.get("from"),
"to": link.get("to"),
"xsection": link.get("xsection"),
"width": link.get("width"),
"radius": link.get("radius"),
"points": points,
})
if saved_links:
routes[bundle_name] = {"links": saved_links}
if routes:
with open(output_path, 'w', encoding='utf-8') as file:
yaml.safe_dump({"routes": routes}, file, sort_keys=False)
elif os.path.exists(output_path):
os.remove(output_path)
def project_gds_path(project_name):
return os.path.join(project_root(project_name), f"{safe_name(project_name, 'project_1')}.gds")
@@ -145,6 +175,28 @@ def current_pdk_registry():
return PdkRegistry(current_pdk_root(), prefer_full_gds=prefer_full_gds_for_session(session))
def scoped_pdk_root_for_project(project_name):
base_root = current_pdk_root()
project = safe_name(project_name, '')
if not project:
return base_root
technology_id = read_project_meta(project).get("technology") or ""
if "/" not in technology_id:
return base_root
foundry, technology = technology_id.split("/", 1)
scoped_root = os.path.abspath(os.path.join(base_root, foundry, technology))
if scoped_root == base_root or not scoped_root.startswith(base_root + os.sep):
return base_root
return scoped_root if os.path.isdir(scoped_root) else base_root
def pdk_root_for_request_project():
project = request.args.get('project')
return scoped_pdk_root_for_project(project) if project else current_pdk_root()
def project_meta_path(project_name):
return os.path.join(project_root(project_name), ".project.json")
@@ -172,10 +224,11 @@ def ensure_project_path(project_name):
# ... [Keep countSpaces and buildTree exactly as they are] ...
def findComps(baseDir):
def findComps(baseDir, path_root=None):
"""Scan component folders, return map of paths -> component info."""
compMap = {}
refDir = baseDir
path_root = os.path.abspath(path_root or baseDir)
for root, dirs, files in os.walk(baseDir):
ymlFiles = [f for f in files if f.endswith('.yml')]
if ymlFiles:
@@ -191,7 +244,8 @@ def findComps(baseDir):
compMap[parts + (compName,)] = {
'folder': compName,
'yml': ymlFiles[0],
'category': category # Save the category to the map
'category': category, # Save the category to the map
'path': os.path.relpath(root, path_root).replace(os.sep, '/')
}
dirs.clear()
return compMap
@@ -214,7 +268,8 @@ def addCompsToTree(compMap):
"__type__": "component",
"__name__": compName,
"__yml__": compItem['yml'],
"__category__": compItem['category'] # Inject category into the tree
"__category__": compItem['category'], # Inject category into the tree
"__path__": compItem.get('path')
})
return fresh_tree
@@ -258,6 +313,15 @@ def readCompYaml(compName, comps_root=None):
return yaml.safe_load(f)
return None
def find_component_dir(component_name, comps_root=None):
search_root = comps_root or current_pdk_root()
for root, dirs, files in os.walk(search_root):
if os.path.basename(root) == component_name:
dirs.clear()
return root, files
return None, []
# --- AUTHENTICATION & PAGE ROUTES ---
@app.route('/')
def home():
@@ -588,13 +652,17 @@ def save_layout():
project = safe_name(data.get('project'), 'project_1')
cell = safe_name(data.get('cell'), 'canvas_1')
content = data.get('content', '')
create_preview = bool(data.get('preview', True))
save_path = cell_file_path(project, cell)
os.makedirs(os.path.dirname(save_path), exist_ok=True)
with open(save_path, 'w', encoding='utf-8') as f:
f.write(content)
write_route_points_sidecar(content, cell_routes_path(project, cell))
svg_path = None
if create_preview:
svg_path = cell_svg_path(project, cell)
if layout_has_links(content):
create_routed_layout_svg(
@@ -615,7 +683,7 @@ def save_layout():
"cell": cell,
"path": save_path,
"svg_path": svg_path,
"svg_url": url_for('get_layout_svg', project_name=project, cell_name=cell)
"svg_url": url_for('get_layout_svg', project_name=project, cell_name=cell) if svg_path else None
}), 200
except Exception as e:
@@ -716,10 +784,10 @@ def get_project_gds(project_name, filename):
@login_required_json
def getLib():
"""Get library structure."""
comps_root = current_pdk_root()
comps_root = pdk_root_for_request_project()
fresh_tree = {}
if os.path.isdir(comps_root):
compMap = findComps(comps_root)
compMap = findComps(comps_root, current_pdk_root())
fresh_tree = addCompsToTree(compMap)
return jsonify(fresh_tree)
@@ -729,7 +797,7 @@ def getLib():
@login_required_json
def getComp(component_name):
"""Return component YAML data."""
data = readCompYaml(component_name, current_pdk_root())
data = readCompYaml(component_name, pdk_root_for_request_project())
if data is None:
return jsonify({"error": "Component not found"}), 404
return jsonify(data)
@@ -738,14 +806,12 @@ def getComp(component_name):
@login_required_json
def getCompImg(component_name):
"""Return first image in component folder."""
for root, dirs, files in os.walk(current_pdk_root()):
if os.path.basename(root) == component_name:
dirs.clear()
root, files = find_component_dir(component_name, pdk_root_for_request_project())
if root:
for ext in ('.png', '.jpg', '.jpeg', '.svg'):
for f in files:
if f.lower().endswith(ext):
return send_from_directory(root, f)
break
return jsonify({"error": "No image found"}), 404
if __name__ == '__main__':
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 214 KiB

@@ -3,6 +3,10 @@
# =============================================
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
@@ -13,35 +17,41 @@ ports:
- name: port
layer: WG_CORE
x: 50.0
y: 150.0
y: -150.0
angle: 0.0
width: 0.5
# 2. Instances (The sub-components dropped onto this canvas)
instances:
component_1:
component: Silterra/EMO1_2ML_CU_Al_RDL/primitives/multimode_interferometers/2x2MMI_1310nm_TE_Silterra_202603_ZKY
x: 300.0
y: 440.0
component: Silterra/EMO1_2ML_CU_Al_RDL/primitives/multimode_interferometers/1x2MMI_1310nm_TE_Silterra_202603_ZKY_v2
x: 100.0
y: -2290.0
rotation: 0.0
flip: 0
flop: 0
mirror: false
settings:
length:
component_4:
component: Silterra/EMO1_2ML_CU_Al_RDL/primitives/multimode_interferometers/1x2MMI_1310nm_TE_Silterra_202603_ZKY_v2
x: 100.0
y: -1970.0
rotation: 0.0
flip: 0
flop: 0
mirror: false
settings:
length:
component_2:
component: Silterra/EMO1_2ML_CU_Al_RDL/composites/Mach_Zender_modulators/MZM_1600G_L3000_GSSG_TRAIL_TypeA2_QY_v1_20260303
x: 820.0
y: 250.0
rotation: 0.0
mirror: false
settings:
length:
component_3:
component: Silterra/EMO1_2ML_CU_Al_RDL/composites/Mach_Zender_modulators/MZM_1600G_L3000_GSSG_TRAIL_TypeA2_QY_v1_20260303
x: 820.0
y: 660.0
component: Silterra/EMO1_2ML_CU_Al_RDL/primitives/multimode_interferometers/1x2MMI_1310nm_TE_Silterra_202603_ZKY_v2
x: 100.0
y: -2560.0
rotation: 0.0
flip: 0
flop: 0
mirror: false
settings:
length:
@@ -50,7 +60,23 @@ elements:
port:
type: port
x: 50.0
y: 150.0
y: -150.0
angle: 0.0
layer: WG_CORE
width: 0.5
description: ""
anchor_1:
type: anchor
x: 120.0
y: -2150.0
angle: 0.0
layer: WG_CORE
width: 0.5
description: ""
anchor_2:
type: anchor
x: 130.0
y: -2430.0
angle: 0.0
layer: WG_CORE
width: 0.5
@@ -61,15 +87,29 @@ bundles:
output_bus:
routing_type: euler_bend
links:
- from: component_2:g2b
to: component_1:b1
- from: anchor_1:right
to: component_4:b2
xsection: strip
family: optical
width: 0.45
radius: 10
routing_type: euler_bend
- from: anchor_1:left
to: component_1:a1
xsection: strip
family: optical
width: 0.45
radius: 10
routing_type: euler_bend
- from: component_1:b2
to: component_3:g2b
to: anchor_2:right
xsection: strip
family: optical
width: 0.45
radius: 10
routing_type: euler_bend
- from: anchor_2:left
to: component_2:a1
xsection: strip
family: optical
width: 0.45
Binary file not shown.
+463 -35
View File
@@ -7,6 +7,9 @@
})(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 ELEMENT_COMPONENTS = {
Port: {
name: 'Port',
@@ -19,11 +22,44 @@
name: 'Anchor',
elementType: 'anchor',
ports: {
left: { x: -20, y: 0, a: 180, width: 0.5 },
right: { x: 20, y: 0, a: 0, width: 0.5 }
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 }
}
}
};
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',
@@ -137,6 +173,12 @@
};
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);
@@ -157,28 +199,206 @@
const roundPercent = (value) => Number(value.toFixed(3));
const scaledPercent = (value, min, max, invert) => {
if (!Number.isFinite(value) || !Number.isFinite(min) || !Number.isFinite(max) || min === max) return null;
const ratio = (value - min) / (max - min);
const visualRatio = invert ? 1 - ratio : ratio;
return roundPercent(15 + visualRatio * 70);
};
const fallbackPercent = (index, count) => {
if (count <= 1) return 50;
return roundPercent(15 + (index / (count - 1)) * 70);
};
const 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';
const coordinate = vertical ? 'y' : 'x';
const values = ports.map(port => Number(port.info[coordinate])).filter(Number.isFinite);
const min = values.length ? Math.min(...values) : null;
const max = values.length ? Math.max(...values) : null;
return ports.map((port, index) => {
const physicalPercent = scaledPercent(Number(port.info[coordinate]), min, max, vertical);
const percent = physicalPercent == null ? fallbackPercent(index, ports.length) : physicalPercent;
const percent = fallbackPercent(index, ports.length);
const percentValue = `${percent}%`;
const style = vertical
? { top: percentValue, transform: side === 'left' ? 'translate(-50%, -50%)' : 'translate(50%, -50%)' }
@@ -193,12 +413,13 @@
});
};
const buildPortHandles = (ports) => {
const buildPortHandles = (ports, transform) => {
const grouped = { left: [], right: [], top: [], bottom: [] };
Object.entries(ports || {}).forEach(([name, info]) => {
if (name === 'a0' || name === 'b0') return;
const side = portSideFromAngle(info && info.a);
grouped[side].push({ name, info: info || {} });
const transformedInfo = transformPortInfo(info, transform);
const side = portSideFromAngle(transformedInfo.a);
grouped[side].push({ name, info: transformedInfo });
});
Object.values(grouped).forEach(sidePorts => {
@@ -229,6 +450,9 @@
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 || {});
@@ -236,18 +460,25 @@
return entries.map(([key, value]) => `${pad}${key}: ${toYamlScalar(value)}`).join('\n');
};
const buildInstanceYaml = ({ instanceName, componentName, componentPath, position, rotation, forgeArguments }) => {
const buildInstanceYaml = ({ instanceName, componentName, componentPath, position, rotation, flip, flop, forgeArguments, basicArguments }) => {
const forge = isForgeComponent(componentName);
const componentValue = forge ? FORGE_COMPONENT_TYPE : componentPath;
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)}` : '\n settings:\n length:';
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: ${Number(position.y || 0).toFixed(1)}
y: ${canvasToLayoutY(position.y).toFixed(1)}
rotation: ${Number(rotation || 0).toFixed(1)}
mirror: false${settingsYaml}`;
flip: ${flip ? 1 : 0}
flop: ${flop ? 1 : 0}
mirror: ${flip ? 'true' : 'false'}${settingsYaml}`;
};
const buildInstancesYaml = ({ nodes, resolveComponentPath }) => {
@@ -266,7 +497,10 @@
componentPath,
position: node.position || { x: 0, y: 0 },
rotation: data.rotation || 0,
forgeArguments: data.forgeArguments
flip: Boolean(data.flip),
flop: Boolean(data.flop),
forgeArguments: data.forgeArguments,
basicArguments: data.basicArguments
});
})
.join('\n\n');
@@ -296,6 +530,69 @@
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) {
@@ -332,7 +629,7 @@
return `- name: ${name}
${data.layer ? `layer: ${data.layer}` : 'layer: WG_CORE'}
x: ${Number(info.x || 0).toFixed(1)}
y: ${Number(info.y || 0).toFixed(1)}
y: ${canvasToLayoutY(info.y).toFixed(1)}
angle: ${Number(info.a || 0).toFixed(1)}
width: ${Number(info.width || 0.5)}${description}`;
});
@@ -351,7 +648,7 @@
return ` ${name}:
type: ${data.elementType}
x: ${Number((node.position && node.position.x) || 0).toFixed(1)}
y: ${Number((node.position && node.position.y) || 0).toFixed(1)}
y: ${canvasToLayoutY((node.position && node.position.y) || 0).toFixed(1)}
angle: ${Number(angle || 0).toFixed(1)}
layer: ${data.layer || 'WG_CORE'}
width: ${Number(data.width || 0.5)}
@@ -375,13 +672,27 @@
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}`;
routing_type: ${route.routing_type}${pointsYaml}`;
});
linksYaml = linkLines.join('\n');
}
@@ -402,6 +713,103 @@ ${linksYaml}`;
};
};
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')) {
return { x: roundMeasureValue(x), y: roundMeasureValue(y) };
}
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 = 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;
@@ -417,41 +825,61 @@ ${linksYaml}`;
return o1 !== o2 && o3 !== o4;
};
const findSameFamilyRouteCrossing = (candidateEdge, existingEdges, nodeMap, manifest) => {
const findSameTypeRouteCrossing = (candidateEdge, existingEdges, nodeMap, manifest) => {
const candidateRoute = createRouteSettings(manifest, candidateEdge.data && candidateEdge.data.route);
const candidateStart = getNodeCenter(nodeMap[candidateEdge.source]);
const candidateEnd = getNodeCenter(nodeMap[candidateEdge.target]);
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.family !== candidateRoute.family) continue;
const start = getNodeCenter(nodeMap[edge.source]);
const end = getNodeCenter(nodeMap[edge.target]);
if (segmentsIntersect(candidateStart, candidateEnd, start, end)) {
return { conflictEdge: edge, family: route.family };
if (route.xsection !== candidateRoute.xsection) 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,
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,
buildBasicComponentPorts,
getBasicComponentMetadata,
buildInstanceYaml,
buildInstancesYaml,
buildPageComponentPorts,
+1764 -239
View File
File diff suppressed because it is too large Load Diff
+11 -7
View File
@@ -10,7 +10,7 @@ assert(
'canvas.html should use buildInstancesYaml for layout instance export'
);
assert(
canvasHtml.includes('buildCanvasPortsYaml(activePage.nodes)'),
canvasHtml.includes('buildCanvasPortsYaml(page.nodes)'),
'canvas.html should export ports from active canvas port nodes'
);
assert(
@@ -22,16 +22,20 @@ assert(
'project layout export should not filter out regular PDK instances'
);
assert(
canvasHtml.includes('Elements: {'),
'library tree should add an Elements folder'
canvasHtml.includes('Cells: cellEntries') &&
canvasHtml.includes('Basic: basicEntries') &&
canvasHtml.includes('PDK: library || {}'),
'library tree should expose top-level Cells, Basic, and PDK groups'
);
assert(
canvasHtml.includes("__name__: 'Port'") && canvasHtml.includes("__name__: 'Anchor'"),
'Elements folder should expose Port and Anchor as separate virtual components'
'Basic folder should expose Port and Anchor as separate virtual components'
);
assert(
canvasHtml.includes('const isElementComponentGrid = isComponentGrid && entries.every(([, childData]) => childData.__element__ === true);'),
'Elements folder should bypass category-card grouping and render separate virtual component leaves'
canvasHtml.includes('isDirectLeafGrid') &&
canvasHtml.includes('childData.__cell__ === true || childData.__element__ === true || childData.__basic__ === true') &&
canvasHtml.includes('<div className="category-grid">'),
'Cells and Basic folders should bypass category grouping and render direct draggable leaves in a 2D grid'
);
assert(
canvasHtml.includes('element-card-icon port-icon') && canvasHtml.includes('element-card-icon anchor-icon'),
@@ -46,7 +50,7 @@ assert(
'virtual elements should not show PDK or generate_with_forge component selection'
);
assert(
canvasHtml.includes('buildElementsYaml(activePage.nodes)'),
canvasHtml.includes('buildElementsYaml(page.nodes)'),
'canvas layout export should include an elements section'
);
assert(
+262 -3
View File
@@ -21,9 +21,169 @@ assert.strictEqual(handles.find(handle => handle.name === 'a1').style.top, '15%'
assert.strictEqual(handles.find(handle => handle.name === 'a2').style.top, '85%');
assert.strictEqual(handles.find(handle => handle.name === 'ep2b').style.left, '50%');
const uniformLeftHandles = helpers.buildPortHandles({
p_top: { x: -10, y: 300, a: 180 },
p_mid: { x: -10, y: 20, a: 180 },
p_bottom: { x: -10, y: -5, a: 180 },
});
assert.deepStrictEqual(
uniformLeftHandles.map(handle => handle.style.top),
['15%', '50%', '85%'],
'ports on the same side should be uniformly spaced after sorting'
);
assert.deepStrictEqual(
helpers.normalizeBoxSize({ box_size: [946, 75] }),
{ width: 946, height: 75 },
'component box size should load from YAML box_size arrays'
);
assert.deepStrictEqual(
helpers.normalizeBoxSize({ box_size: ['946.0', '75.0'] }),
{ width: 946, height: 75 },
'component box size should accept numeric strings from YAML/JSON metadata'
);
assert.deepStrictEqual(
helpers.normalizeBoxSize({ box_sz: { width: 1200, height: 85 } }),
{ width: 1200, height: 85 },
'component box size should also accept box_sz objects'
);
assert.strictEqual(
helpers.PORT_NODE_SIZE,
30,
'Port and Anchor virtual elements should use the same 30 um canvas footprint'
);
assert.deepStrictEqual(
helpers.calculateLayoutBounds({
nodes: [{
position: { x: 100, y: 200 },
data: { componentName: 'rotated_component', boxSize: { width: 50, height: 20 }, rotation: 90 }
}]
}),
{
minX: 80,
minY: 200,
maxX: 100,
maxY: 250,
width: 20,
height: 50,
bottomLeft: { x: 80, y: 200 },
topRight: { x: 100, y: 250 }
},
'layout preview bounds should use component box_size and rotation to find device corners'
);
assert.strictEqual(
helpers.chooseCategoryComponent('generate with mxpic_forge', [
'generate with mxpic_forge',
'EC_SiN400_1310_0p5dB_L935_A0_QY_202604'
], 'edge_couplers'),
'EC_SiN400_1310_0p5dB_L935_A0_QY_202604',
'dropping an EC category should prefer the real PDK component so its YAML box_size is loaded'
);
assert.deepStrictEqual(
helpers.clampPositionToCanvas({ x: 4990, y: 5010 }, { width: 5000, height: 5000 }, { width: 946, height: 75 }),
{ x: 4054, y: 4925 },
'component drag position should keep the full component box inside the canvas boundary'
);
const rulerMeasurement = helpers.createRulerMeasurement({ x: 10, y: 20 }, { x: 40, y: 60 });
assert.deepStrictEqual(
rulerMeasurement,
{
start: { x: 10, y: 20 },
end: { x: 40, y: 60 },
dx: 30,
dy: 40,
distance: 50,
midpoint: { x: 25, y: 40 },
label: '50.000 um dx 30.000 dy 40.000'
},
'ruler measurement should calculate point-to-point distance in canvas um coordinates'
);
assert.strictEqual(
helpers.createRulerMeasurement({ x: 1 }, null),
null,
'ruler measurement should wait until both points are available'
);
assert.deepStrictEqual(
helpers.createComponentSymbolMetrics({ width: 946, height: 75 }),
{ width: 898.7, height: 51 },
'large edge-coupler symbols should scale close to the YAML box width instead of being capped near 300 um'
);
assert.deepStrictEqual(
helpers.createComponentSymbolMetrics({ width: 132, height: 82 }),
{ width: 118.8, height: 55.76 },
'default symbols should still scale proportionally inside normal component boxes'
);
const rotatedHandles = helpers.buildPortHandles({
left_port: { x: -50, y: 0, a: 180 },
top_port: { x: 0, y: 20, a: 90 },
}, { rotation: 180 });
assert.strictEqual(
rotatedHandles.find(handle => handle.name === 'left_port').position,
'right',
'rotating a component should rotate the React Flow handle side'
);
assert.strictEqual(
rotatedHandles.find(handle => handle.name === 'top_port').position,
'bottom',
'rotating a component should rotate vertical port handle sides'
);
const args = helpers.createForgeArguments();
assert(Object.keys(args).length >= 10);
assert.strictEqual(helpers.isForgeComponent('generate with mxpic_forge'), true);
assert.strictEqual(helpers.isBasicComponent('waveguide'), true);
assert.strictEqual(helpers.isBasicComponent('circle'), true);
assert.strictEqual(helpers.isBasicComponent('cricle'), true);
assert.strictEqual(
helpers.buildElementPorts('port').port.a,
0,
'Port objects should default to 0 degree angle'
);
assert.deepStrictEqual(
{
left: helpers.buildElementPorts('anchor').left.a,
right: helpers.buildElementPorts('anchor').right.a,
},
{ left: 180, right: 0 },
'Anchor objects should default to 180 degree left port and 0 degree right port'
);
assert.deepStrictEqual(
{
left: helpers.buildElementPorts('anchor').left,
right: helpers.buildElementPorts('anchor').right,
},
{
left: { x: 0, y: -15, a: 180, width: 0.5 },
right: { x: 30, y: -15, a: 0, width: 0.5 }
},
'Anchor ports should sit on the left and right edges of a port-sized circle'
);
assert.deepStrictEqual(
helpers.buildBasicComponentPorts('waveguide', { length: 120, width: 0.6 }).b1,
{ x: 120, y: 0, a: 0, width: 0.6, xsection: 'strip', description: 'Optical power output' },
'basic waveguide ports should be generated from editable settings'
);
assert.deepStrictEqual(
helpers.getBasicComponentMetadata('waveguide', { length: 120, width: 0.5 }).box_size,
[120, 4],
'basic waveguide symbol should use a narrow default height'
);
assert.deepStrictEqual(
helpers.getBasicComponentMetadata('90 bend', { radius: 15 }).box_size,
[15, 15],
'90 bend symbol should be square with side length equal to radius'
);
assert.deepStrictEqual(
helpers.getBasicComponentMetadata('180 bend', { radius: 15 }).box_size,
[15, 30],
'180 bend symbol should be one radius wide and two radii tall'
);
assert.deepStrictEqual(
helpers.getBasicComponentMetadata('taper', { length: 80, width1: 0.4, width2: 1.2 }).ports.a1.description,
'Optical power input',
'basic component metadata should include human-readable port descriptions'
);
const yaml = helpers.buildInstanceYaml({
instanceName: 'component_1',
@@ -31,14 +191,32 @@ const yaml = helpers.buildInstanceYaml({
componentPath: 'ignored/path',
position: { x: 12.34, y: -5 },
rotation: 90,
flip: true,
flop: true,
forgeArguments: { function_name: 'mmi1x2', length: 25.5, include_heater: true }
});
assert(yaml.includes('component: generate_with_forge'));
assert(yaml.includes('flip: 1'));
assert(yaml.includes('flop: 1'));
assert(yaml.includes('function_name: "mmi1x2"'));
assert(yaml.includes('length: 25.5'));
assert(yaml.includes('include_heater: true'));
const basicYaml = helpers.buildInstanceYaml({
instanceName: 'wg_1',
componentName: 'waveguide',
componentPath: 'ignored',
position: { x: 0, y: 0 },
rotation: 0,
flip: false,
flop: false,
basicArguments: { length: 88, width: 0.7, xsection: 'strip' }
});
assert(basicYaml.includes('component: waveguide'));
assert(basicYaml.includes('length: 88'));
assert(basicYaml.includes('width: 0.7'));
const projectInstancesYaml = helpers.buildInstancesYaml({
nodes: [
{
@@ -74,7 +252,7 @@ assert(projectInstancesYaml.includes('component: canvas_1'));
const pagePortsYaml = helpers.buildPortsYaml({ x: 50, y: 150, a: 90 });
assert(pagePortsYaml.includes('- name: port'));
assert(pagePortsYaml.includes('x: 50.0'));
assert(pagePortsYaml.includes('y: 150.0'));
assert(pagePortsYaml.includes('y: -150.0'));
assert(pagePortsYaml.includes('angle: 90.0'));
const componentPorts = helpers.buildPageComponentPorts({ x: 12, y: -6, a: 180 });
@@ -128,12 +306,14 @@ const canvasPortsYaml = helpers.buildCanvasPortsYaml(elementNodes);
assert(canvasPortsYaml.includes('name: in0'));
assert(canvasPortsYaml.includes('description: "input port"'));
assert(canvasPortsYaml.includes('width: 0.7'));
assert(canvasPortsYaml.includes('y: -20.0'));
const elementsYaml = helpers.buildElementsYaml(elementNodes);
assert(elementsYaml.includes('in0:'));
assert(elementsYaml.includes('type: port'));
assert(elementsYaml.includes('anchor_1:'));
assert(elementsYaml.includes('type: anchor'));
assert(elementsYaml.includes('y: -20.0'));
const instancesWithoutElements = helpers.buildInstancesYaml({
nodes: elementNodes,
@@ -142,6 +322,7 @@ const instancesWithoutElements = helpers.buildInstancesYaml({
assert(!instancesWithoutElements.includes('anchor_1:'));
assert(!instancesWithoutElements.includes('in0:'));
assert(instancesWithoutElements.includes('component_1:'));
assert(instancesWithoutElements.includes('y: -60.0'));
const multiPortComponentPorts = helpers.buildPageComponentPorts(null, elementNodes);
assert.deepStrictEqual(multiPortComponentPorts.in0, { x: 10, y: 20, a: 180, width: 0.7 });
@@ -193,13 +374,80 @@ const routeYaml = helpers.buildBundlesYaml({
target: 'b',
sourceHandle: 'out',
targetHandle: 'in',
data: { route: { xsection: 'metal_1', family: 'electrical', width: 5, radius: 20, routing_type: 'standard_bend' } }
data: {
route: { xsection: 'metal_1', family: 'electrical', width: 5, radius: 20, routing_type: 'standard_bend' },
points: [{ x: 0, y: 0 }, { x: 40, y: 20 }]
}
}]
}, technologyManifest);
assert(routeYaml.includes('xsection: metal_1'));
assert(routeYaml.includes('family: electrical'));
assert(routeYaml.includes('radius: 20'));
assert(routeYaml.includes('routing_type: standard_bend'));
assert(routeYaml.includes('points:'));
assert(routeYaml.includes('x: 40.0'));
assert(routeYaml.includes('y: -20.0'));
const anchoredRouteYaml = helpers.buildBundlesYaml({
nodes: [
{
id: 'src-node',
type: 'rotatableNode',
position: { x: 10, y: 20 },
data: {
componentDisplayName: 'src_inst',
boxSize: [100, 40],
ports: { out: { x: -10, y: 0, a: 180 } }
}
},
{
id: 'dst-node',
type: 'rotatableNode',
position: { x: 120, y: 20 },
data: {
componentDisplayName: 'dst_inst',
boxSize: [100, 40],
ports: { in: { x: 10, y: 0, a: 0 } }
}
}
],
edges: [{
id: 'edge-src-dst',
source: 'src-node',
target: 'dst-node',
sourceHandle: 'out',
targetHandle: 'in',
data: {
route: { xsection: 'strip', family: 'optical', width: 0.45, radius: 10, routing_type: 'euler_bend' },
points: [{ x: 0, y: 0 }, { x: 80, y: 0 }, { x: 80, y: 60 }]
}
}]
}, technologyManifest);
assert(anchoredRouteYaml.includes('from: src_inst:out'));
assert(anchoredRouteYaml.includes('to: dst_inst:in'));
assert(anchoredRouteYaml.includes('x: 0.0'));
assert(anchoredRouteYaml.includes('y: -20.0'));
assert(anchoredRouteYaml.includes('x: 130.0'));
const freeRouteYaml = helpers.buildBundlesYaml({
nodes: [],
edges: [{
id: 'route-free-1',
source: '__free_route_route-free-1_start__',
target: '__free_route_route-free-1_end__',
data: {
freeRoute: true,
route: { xsection: 'strip', family: 'optical', width: 0.45, radius: 10, routing_type: 'euler_bend' },
points: [{ x: 10, y: 20 }, { x: 80, y: 20 }, { x: 80, y: 120 }]
}
}]
}, technologyManifest);
assert(freeRouteYaml.includes('id: "route-free-1"'));
assert(!freeRouteYaml.includes('from:'));
assert(!freeRouteYaml.includes('to:'));
assert(freeRouteYaml.includes('points:'));
assert(freeRouteYaml.includes('x: 80.0'));
assert(freeRouteYaml.includes('y: -120.0'));
const edgeA = {
id: 'edge-a-b',
@@ -227,5 +475,16 @@ const crossingNodes = {
e: { position: { x: 0, y: 100 } },
f: { position: { x: 100, y: 0 } }
};
edgeA.data.route.xsection = 'strip';
edgeB.data.route.xsection = 'strip';
edgeC.data.route.xsection = 'metal_1';
const edgeD = {
id: 'edge-g-h',
source: 'e',
target: 'f',
data: { route: { xsection: 'rib_low', family: 'optical' } }
};
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.findSameFamilyRouteCrossing(edgeB, [edgeA], crossingNodes).conflictEdge.id, 'edge-a-b');
assert.strictEqual(helpers.findSameFamilyRouteCrossing(edgeC, [edgeA], crossingNodes), null);
+23
View File
@@ -30,6 +30,12 @@ assert(
serverPy.includes('create_routed_layout_svg'),
'save-layout route should use routed preview generation when links exist'
);
assert(
serverPy.includes('cell_routes_path') &&
serverPy.includes('write_route_points_sidecar') &&
serverPy.includes('.routes.yml'),
'save-layout should write a sidecar route-points file for manually drawn link guidance'
);
assert(
serverPy.includes('svg_url'),
'save-layout response should include an svg_url for the new layout tab'
@@ -78,3 +84,20 @@ assert(
gdsBuilderPy.includes('_cells_have_links') && gdsBuilderPy.includes('Routed Build GDS requires mxpic_router'),
'Build GDS should not silently fall back to unrouted gdstk when links are present'
);
assert(
serverPy.includes('def scoped_pdk_root_for_project') &&
serverPy.includes('read_project_meta(project_name).get("technology")') &&
serverPy.includes('os.path.join(base_root, foundry, technology)'),
'backend should resolve a project-scoped PDK root from selected foundry/technology'
);
assert(
serverPy.includes('request.args.get(\'project\')') &&
serverPy.includes('scoped_pdk_root_for_project(project)'),
'library/component APIs should accept ?project= and search inside the selected technology folder'
);
assert(
serverPy.includes('__path__') &&
serverPy.includes('os.path.relpath(root, path_root)'),
'library tree leaves should preserve component paths relative to the role PDK root'
);
+281 -2
View File
@@ -4,11 +4,16 @@ const path = require('path');
const root = path.resolve(__dirname, '..');
const canvasHtml = fs.readFileSync(path.join(root, 'frontend', 'canvas.html'), 'utf8');
const canvasHelpers = fs.readFileSync(path.join(root, 'frontend', 'canvas-helpers.js'), 'utf8');
assert(
canvasHtml.includes('Build GDS'),
'Project Tree header should include a Build GDS button'
);
assert(
canvasHtml.includes('Save YAML for all canvases') && canvasHtml.includes('handleSaveProjectLayouts'),
'Project Tree should include a save button that writes YAML for all canvases'
);
assert(
canvasHtml.includes('/api/build-gds'),
'Build GDS button should call the backend build-gds API'
@@ -70,6 +75,280 @@ assert(
'route editor should offer standard_bend as a routing type'
);
assert(
canvasHtml.includes('findSameFamilyRouteCrossing'),
'canvas should validate same-family route crossings'
canvasHtml.includes('findSameTypeRouteCrossing'),
'canvas should validate same-xsection route crossings'
);
assert(
canvasHtml.includes('link-mode-tabs') &&
canvasHtml.includes('link-mode-summary') &&
canvasHtml.includes('link-mode-menu') &&
canvasHtml.includes('currentLinkXsection') &&
canvasHtml.includes('setCurrentLinkXsection') &&
canvasHtml.includes("['strip', 'rib_low', 'metal_1', 'metal_2']"),
'canvas should expose a collapsed route-type selector for new links'
);
assert(
canvasHtml.includes('handleBasicConnection') &&
canvasHtml.includes('onConnect={handleBasicConnection}') &&
canvasHtml.includes('nodesConnectable={true}') &&
canvasHtml.includes('connectionMode="loose"') &&
canvasHtml.includes('data: { route }') &&
canvasHtml.includes('addEdge(candidate, p.edges)'),
'canvas should use React Flow native pin-to-pin connections for new links'
);
assert(
!canvasHtml.includes('linkDraft') &&
!canvasHtml.includes('routingMode') &&
!canvasHtml.includes('toggleRoutingMode') &&
!canvasHtml.includes('handleLinkCanvasMouseDown') &&
!canvasHtml.includes('handleLinkCanvasMouseMove') &&
!canvasHtml.includes('finalizeLinkDraft') &&
!canvasHtml.includes('__link_draft_edge__') &&
!canvasHtml.includes('Routing mode: click anywhere to start a point route.'),
'current interactive point-link drawing mode should be removed from the canvas'
);
assert(
canvasHtml.includes('<Handle') &&
canvasHtml.includes('type="source"') &&
canvasHtml.includes('type="target"') &&
!canvasHtml.includes('nodesConnectable={false}'),
'component and port handles should remain connectable for basic pin linking'
);
assert(
canvasHtml.includes("selectable: true") &&
canvasHtml.includes("type: 'parallelRoute'") &&
canvasHtml.includes('hasRoutePoints') &&
canvasHtml.includes("hasRoutePoints ? 'parallelRoute' : view.type") &&
canvasHtml.includes('data-route-edge-id') &&
canvasHtml.includes('handleRouteEdgeMouseDown') &&
canvasHtml.includes('selectEdgeById') &&
canvasHtml.includes('vectorEffect="non-scaling-stroke"') &&
canvasHtml.includes('onEdgeMouseDown={handleReactFlowEdgeMouseDown}'),
'saved manual routes should remain selectable through a zoom-independent custom SVG hit path and React Flow edge mouse handling'
);
assert(
canvasHtml.includes('panOnDrag={false}'),
'left mouse drag should not pan the canvas'
);
assert(
canvasHtml.includes('selectionOnDrag={true}') &&
canvasHtml.includes('selectionMode={FULL_SELECTION_MODE}') &&
canvasHtml.includes('SelectionMode'),
'left mouse drag should draw a full-coverage selection area instead of panning'
);
assert(
canvasHtml.includes('const path = points.length >= 2') &&
canvasHtml.includes('points.slice(1).map(point => `L ${point.x},${point.y}`)') &&
canvasHtml.includes('position: { x: first.x - 6, y: first.y - 6 }') &&
canvasHtml.includes('position: { x: last.x - 6, y: last.y - 6 }'),
'free routes should render all saved point-to-point line segments and keep hidden endpoints aligned'
);
assert(
!canvasHtml.includes('buildOrthogonalPoints') &&
!canvasHtml.includes('buildManhattanRoutePoints') &&
!canvasHtml.includes('findNearestPort') &&
!canvasHtml.includes('linkPreviewSnapPort'),
'custom link drawing geometry helpers should be deleted from the current canvas code'
);
const routePointNodeBlock = canvasHtml.slice(
canvasHtml.indexOf('const RulerPointNode'),
canvasHtml.indexOf('const RulerMeasurementNode')
);
assert(
routePointNodeBlock.includes('<Handle') &&
routePointNodeBlock.includes('type="source"') &&
routePointNodeBlock.includes('type="target"') &&
routePointNodeBlock.includes('id="route"'),
'route point nodes should expose hidden source and target handles so route edges can attach and render'
);
assert(
canvasHtml.includes('build-progress') && canvasHtml.includes('buildProgress'),
'Build Layout and Build GDS should show progress while running'
);
assert(
canvasHtml.includes('port-name-label'),
'component nodes should render visible port name labels'
);
assert(
canvasHtml.includes('multiSelectionKeyCode="Shift"'),
'ReactFlow should allow shift multi-select for links'
);
assert(
canvasHtml.includes("deleteKeyCode={['Backspace', 'Delete']}"),
'ReactFlow should allow selected links to be deleted with the Delete key'
);
assert(
canvasHtml.includes('selectedEdges'),
'canvas should track multiple selected links for batch route editing'
);
assert(
canvasHtml.includes('__mixed__'),
'multi-link route editor should show mixed values as --'
);
assert(
canvasHtml.includes('Flip X') && canvasHtml.includes('Mirror Y'),
'component inspector should expose flip/flop controls'
);
assert(
canvasHtml.includes('onNodeMouseDown') && canvasHtml.includes('spaceRotateNodeIdRef'),
'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'
);
assert(
canvasHtml.includes('normalizeAngle,') && canvasHtml.includes('normalizeAngle(Number(node.data?.rotation || 0) + 90)'),
'Space rotation should import normalizeAngle before using it'
);
assert(
canvasHtml.includes('component-floating-label') && canvasHtml.includes('component-visual-body'),
'component labels should float outside the rotated body'
);
assert(
canvasHtml.includes('--floating-label-bg') && canvasHtml.includes('--port-label-bg') && canvasHtml.includes('--mini-button-bg'),
'theme variables should keep labels, port chips, and header buttons readable in light and dark modes'
);
assert(
canvasHtml.includes('className="site-nav-actions"') &&
canvasHtml.includes('className="canvas-toolbar"') &&
canvasHtml.includes('grid-snap-label') &&
canvasHtml.includes('body.light-mode .canvas-toolbar'),
'dashboard/logout should move to a site-level top-right action group and the canvas toolbar should keep readable grid text in light mode'
);
assert(
canvasHtml.includes('body.light-mode .component-floating-label') &&
canvasHtml.includes('body.light-mode .port-name-label') &&
canvasHtml.includes('body.light-mode .mini-btn'),
'light mode should override dark translucent label and button surfaces'
);
assert(
canvasHtml.includes('Canvas Size') && canvasHtml.includes('canvasSize') && canvasHtml.includes('DEFAULT_CANVAS_SIZE'),
'project tree should expose a canvas size control with a 5000 um default'
);
assert(
canvasHtml.includes('CanvasBoundaryNode') && canvasHtml.includes('canvas-boundary-node'),
'canvas should render a bold boundary rectangle in flow coordinates'
);
assert(
canvasHtml.includes('renderNodes') && canvasHtml.includes('nodeExtent={canvasNodeExtent}'),
'ReactFlow should render the boundary node and constrain draggable nodes to the canvas extent'
);
assert(
canvasHtml.includes('minZoom={0.02}') && canvasHtml.includes('defaultViewport={{ x: 80, y: 80, zoom: 0.12 }}'),
'large 5000 um canvases should zoom out far enough to fit on one screen'
);
assert(
canvasHtml.includes('onWheel={handleWheel}') &&
canvasHtml.includes('calculateLayoutBounds(activePage)') &&
canvasHtml.includes('layoutBounds') &&
canvasHtml.includes('stageWidth') &&
canvasHtml.includes('stageHeight'),
'layout preview should mouse-wheel zoom and size 100% from calculated box_size layout bounds'
);
assert(
canvasHtml.includes('reactFlowInstance.fitBounds') &&
canvasHtml.includes('width: activeCanvasSize.width') &&
canvasHtml.includes('height: activeCanvasSize.height'),
'switching canvases should fit the full canvas boundary instead of resetting to 100% zoom'
);
assert(
canvasHtml.includes('boxSize') && canvasHtml.includes('normalizeBoxSize'),
'component metadata box_size should drive rendered component box dimensions'
);
assert(
canvasHtml.includes('chooseCategoryComponent') && canvasHtml.includes('[id, data.ports, data.componentName, data.boxSize]'),
'category drops and metadata refreshes should remeasure components with YAML box sizes'
);
assert(
canvasHtml.includes('Background color="#334155" gap={10} size={1}'),
'default grid spacing should be 10 um'
);
assert(
canvasHtml.includes('Ruler') &&
canvasHtml.includes('rulerMode') &&
canvasHtml.includes('onPaneClick={handleRulerPaneClick}') &&
canvasHtml.includes('onNodeClick={handleRulerPaneClick}') &&
canvasHtml.includes('onPaneMouseMove={handleRulerMouseMove}'),
'canvas should expose a ruler mode controlled from the top toolbar, allow measuring on component bodies, and preview to the mouse'
);
assert(
canvasHtml.includes('createRulerMeasurement') &&
canvasHtml.includes('rulerPointNode') &&
canvasHtml.includes('rulerMeasurementNode') &&
canvasHtml.includes('rulerPreviewPoint') &&
canvasHtml.includes('strokeDasharray: rulerPreviewMeasurement ? undefined') &&
canvasHtml.includes('renderEdges'),
'ruler mode should render temporary point nodes, a live solid preview edge, and a measurement label'
);
assert(
canvasHtml.includes('Box Size') &&
canvasHtml.includes('selectedNodeBoxSize') &&
canvasHtml.includes('box-size-readout'),
'component inspector should show the selected component YAML box size'
);
assert(
canvasHtml.includes('coordinate-grid'),
'selected component coordinates should be displayed horizontally in the right panel'
);
assert(
canvasHtml.includes('selectedPositionNodes.length > 1') &&
canvasHtml.includes('const delta = val - Number') &&
canvasHtml.includes('selectedNodes={selectedNodes}'),
'multi-selected components should move together when editing X or Y in the inspector'
);
assert(
canvasHtml.includes('portInfo.description') || canvasHtml.includes('port.description'),
'port information should append optional human-readable port descriptions'
);
assert(
canvasHtml.includes('BASIC_COMPONENTS') &&
canvasHelpers.includes('BASIC_COMPONENTS') &&
canvasHelpers.includes('waveguide') &&
canvasHelpers.includes('90 bend') &&
canvasHelpers.includes('180 bend') &&
canvasHelpers.includes('circle') &&
canvasHelpers.includes('taper'),
'component library should expose basic Nazca primitives'
);
assert(
canvasHtml.includes('Cells: cellEntries') &&
canvasHtml.includes('Basic: basicEntries') &&
canvasHtml.includes('PDK: library || {}'),
'component library should keep top-level Cells, Basic, and PDK folders'
);
assert(
canvasHtml.includes('isDirectLeafGrid ? (') &&
canvasHtml.includes('<div className="category-grid">') &&
canvasHtml.includes("isUserCell ? 'compact-tree-card' : ''"),
'Basic, Port, and Anchor entries should render as consistent 2D cards instead of compact list rows'
);
assert(
canvasHtml.includes('ParallelRouteEdge') &&
canvasHtml.includes('parallelOffset') &&
canvasHtml.includes("type: 'parallelRoute'") &&
canvasHtml.includes('edgeTypes={edgeTypes}'),
'overlapped links should render with separated parallel edge paths'
);
assert(
canvasHtml.includes('data: { route, points: routePoints }') &&
canvasHtml.includes('normalizeRoutePoints(link.points'),
'manual link route points should be stored on edges and restored from saved YAML'
);
assert(
canvasHtml.includes('createComponentSymbolMetrics') &&
!canvasHtml.includes('Math.min(128') &&
!canvasHtml.includes('Math.min(64'),
'component icon/symbol dimensions should scale with component box size without old hard caps'
);
assert(
canvasHtml.includes('/api/library?project=') &&
canvasHtml.includes('/api/component/${encodeURIComponent(componentName)}?project=') &&
canvasHtml.includes('/api/component/${encodeURIComponent(compName)}?project='),
'canvas should pass project context when loading library and component metadata'
);
assert(
canvasHtml.includes('/api/component/${encodeURIComponent(componentData.name)}/image?project=') &&
canvasHtml.includes('return obj.__path__.split'),
'component images and saved component paths should use technology-scoped library metadata'
);
+19
View File
@@ -0,0 +1,19 @@
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const root = path.resolve(__dirname, '..');
const canvasHtml = fs.readFileSync(path.join(root, 'frontend', 'canvas.html'), 'utf8');
assert(
canvasHtml.includes('loadedProjectPage'),
'project loading should reuse the saved project canvas instead of creating a duplicate empty project tab'
);
assert(
canvasHtml.includes('nonProjectPages'),
'project composite map should exclude the saved project page itself'
);
assert(
canvasHtml.includes('layoutToCanvasY'),
'loading saved layout YAML should convert GDS/layout Y coordinates back to canvas coordinates'
);