updated with github #5
+93
-27
@@ -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,25 +652,29 @@ 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 = cell_svg_path(project, cell)
|
||||
if layout_has_links(content):
|
||||
create_routed_layout_svg(
|
||||
content,
|
||||
svg_path,
|
||||
pdk_root=current_pdk_root(),
|
||||
project_dir=project_root(project),
|
||||
technology_manifest_path=technology_manifest_path_for_project(project),
|
||||
prefer_full_gds=prefer_full_gds_for_session(session),
|
||||
)
|
||||
else:
|
||||
create_layout_svg_from_gds(content, svg_path, pdk_registry=current_pdk_registry(), project_dir=project_root(project))
|
||||
svg_path = None
|
||||
if create_preview:
|
||||
svg_path = cell_svg_path(project, cell)
|
||||
if layout_has_links(content):
|
||||
create_routed_layout_svg(
|
||||
content,
|
||||
svg_path,
|
||||
pdk_root=current_pdk_root(),
|
||||
project_dir=project_root(project),
|
||||
technology_manifest_path=technology_manifest_path_for_project(project),
|
||||
prefer_full_gds=prefer_full_gds_for_session(session),
|
||||
)
|
||||
else:
|
||||
create_layout_svg_from_gds(content, svg_path, pdk_registry=current_pdk_registry(), project_dir=project_root(project))
|
||||
|
||||
record_action('layout.save', project=project, cell=cell, detail={"bytes": len(content), "svg": svg_path})
|
||||
return jsonify({
|
||||
@@ -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()
|
||||
for ext in ('.png', '.jpg', '.jpeg', '.svg'):
|
||||
for f in files:
|
||||
if f.lower().endswith(ext):
|
||||
return send_from_directory(root, f)
|
||||
break
|
||||
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)
|
||||
return jsonify({"error": "No image found"}), 404
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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
@@ -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,
|
||||
|
||||
+1857
-332
File diff suppressed because it is too large
Load Diff
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
Reference in New Issue
Block a user