diff --git a/backend/server.py b/backend/server.py
index c529b71..943e7d5 100644
--- a/backend/server.py
+++ b/backend/server.py
@@ -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__':
diff --git a/database/_exports/2a2617c458a6447294fd56502bfb85f9/mxpic_project_1.gds b/database/_exports/2a2617c458a6447294fd56502bfb85f9/mxpic_project_1.gds
new file mode 100644
index 0000000..3016058
Binary files /dev/null and b/database/_exports/2a2617c458a6447294fd56502bfb85f9/mxpic_project_1.gds differ
diff --git a/database/_exports/2c153dd7b8da4479881e75b3b1cb98a9/mxpic_project_1.gds b/database/_exports/2c153dd7b8da4479881e75b3b1cb98a9/mxpic_project_1.gds
new file mode 100644
index 0000000..e07f9ec
Binary files /dev/null and b/database/_exports/2c153dd7b8da4479881e75b3b1cb98a9/mxpic_project_1.gds differ
diff --git a/database/_exports/30072f5d25b84836bb1ca80544508ded/mxpic_project_1.gds b/database/_exports/30072f5d25b84836bb1ca80544508ded/mxpic_project_1.gds
new file mode 100644
index 0000000..de0eb3e
Binary files /dev/null and b/database/_exports/30072f5d25b84836bb1ca80544508ded/mxpic_project_1.gds differ
diff --git a/database/_exports/6cc901a2613e4efdad749b2eb16f121f/mxpic_project_1.gds b/database/_exports/6cc901a2613e4efdad749b2eb16f121f/mxpic_project_1.gds
new file mode 100644
index 0000000..bd92f93
Binary files /dev/null and b/database/_exports/6cc901a2613e4efdad749b2eb16f121f/mxpic_project_1.gds differ
diff --git a/database/_exports/6eef05775fa44443bcd6fe0cafc0720f/mxpic_project_1.gds b/database/_exports/6eef05775fa44443bcd6fe0cafc0720f/mxpic_project_1.gds
new file mode 100644
index 0000000..6f61183
Binary files /dev/null and b/database/_exports/6eef05775fa44443bcd6fe0cafc0720f/mxpic_project_1.gds differ
diff --git a/database/_exports/741a0bab3cd24a8f9073814b57d4217e/mxpic_project_1.gds b/database/_exports/741a0bab3cd24a8f9073814b57d4217e/mxpic_project_1.gds
new file mode 100644
index 0000000..e5408f9
Binary files /dev/null and b/database/_exports/741a0bab3cd24a8f9073814b57d4217e/mxpic_project_1.gds differ
diff --git a/database/_exports/b384ef2ad08a44e8a8eec397082b360a/mxpic_project_1.gds b/database/_exports/b384ef2ad08a44e8a8eec397082b360a/mxpic_project_1.gds
new file mode 100644
index 0000000..db2f8b4
Binary files /dev/null and b/database/_exports/b384ef2ad08a44e8a8eec397082b360a/mxpic_project_1.gds differ
diff --git a/database/_exports/ba459cd32b6b492f82fba88d429062b2/mxpic_project_1.gds b/database/_exports/ba459cd32b6b492f82fba88d429062b2/mxpic_project_1.gds
new file mode 100644
index 0000000..b2b1294
Binary files /dev/null and b/database/_exports/ba459cd32b6b492f82fba88d429062b2/mxpic_project_1.gds differ
diff --git a/database/_exports/e7c67c80bc78400e897ce22c2bcb178c/mxpic_project_1.gds b/database/_exports/e7c67c80bc78400e897ce22c2bcb178c/mxpic_project_1.gds
new file mode 100644
index 0000000..41be835
Binary files /dev/null and b/database/_exports/e7c67c80bc78400e897ce22c2bcb178c/mxpic_project_1.gds differ
diff --git a/database/_exports/e8f94d1099c94ef7b8b3444d7577f10a/mxpic_project_1.gds b/database/_exports/e8f94d1099c94ef7b8b3444d7577f10a/mxpic_project_1.gds
new file mode 100644
index 0000000..aa4c470
Binary files /dev/null and b/database/_exports/e8f94d1099c94ef7b8b3444d7577f10a/mxpic_project_1.gds differ
diff --git a/database/_exports/f4a64d4f87684b0897157fde62cb85be/mxpic_project_1.gds b/database/_exports/f4a64d4f87684b0897157fde62cb85be/mxpic_project_1.gds
new file mode 100644
index 0000000..c79fa56
Binary files /dev/null and b/database/_exports/f4a64d4f87684b0897157fde62cb85be/mxpic_project_1.gds differ
diff --git a/database/admin/layout/mxpic_project_1/mxpic_project_1.svg b/database/admin/layout/mxpic_project_1/mxpic_project_1.svg
new file mode 100644
index 0000000..39e4fad
--- /dev/null
+++ b/database/admin/layout/mxpic_project_1/mxpic_project_1.svg
@@ -0,0 +1,161 @@
+
+
\ No newline at end of file
diff --git a/database/admin/layout/mxpic_project_1/mxpic_project_1.yml b/database/admin/layout/mxpic_project_1/mxpic_project_1.yml
index da29e37..b021f2a 100644
--- a/database/admin/layout/mxpic_project_1/mxpic_project_1.yml
+++ b/database/admin/layout/mxpic_project_1/mxpic_project_1.yml
@@ -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
diff --git a/database/mxpic_data.db b/database/mxpic_data.db
index 37b007d..9d36722 100644
Binary files a/database/mxpic_data.db and b/database/mxpic_data.db differ
diff --git a/frontend/canvas-helpers.js b/frontend/canvas-helpers.js
index d0e3482..3821dc6 100644
--- a/frontend/canvas-helpers.js
+++ b/frontend/canvas-helpers.js
@@ -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,
diff --git a/frontend/canvas.html b/frontend/canvas.html
index cd49e5d..63eec67 100644
--- a/frontend/canvas.html
+++ b/frontend/canvas.html
@@ -38,6 +38,13 @@
--shadow: rgba(0, 0, 0, 0.36);
--surface-highlight: rgba(255, 255, 255, 0.045);
--focus-ring: rgba(69, 214, 200, 0.22);
+ --floating-label-bg: rgba(15, 23, 36, 0.96);
+ --floating-label-border: rgba(142, 169, 198, 0.34);
+ --floating-label-shadow: 0 10px 24px rgba(0, 0, 0, 0.36), 0 0 0 1px rgba(69, 214, 200, 0.08);
+ --port-label-bg: rgba(13, 21, 33, 0.96);
+ --port-label-text: #cbd6e3;
+ --mini-button-bg: linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.02)), #101a29;
+ --mini-button-text: #d5dfec;
}
body.light-mode {
@@ -60,6 +67,13 @@
--shadow: rgba(18, 32, 51, 0.12);
--surface-highlight: rgba(255, 255, 255, 0.72);
--focus-ring: rgba(8, 127, 115, 0.18);
+ --floating-label-bg: rgba(255, 255, 255, 0.94);
+ --floating-label-border: rgba(30, 48, 69, 0.18);
+ --floating-label-shadow: 0 10px 22px rgba(18, 32, 51, 0.14), 0 0 0 1px rgba(8, 127, 115, 0.08);
+ --port-label-bg: rgba(255, 255, 255, 0.96);
+ --port-label-text: #334155;
+ --mini-button-bg: linear-gradient(180deg, #ffffff, #eef5f8);
+ --mini-button-text: #17263a;
}
.left-block {
@@ -524,6 +538,7 @@
overflow: auto;
padding: 58px 18px 18px;
box-sizing: border-box;
+ overscroll-behavior: contain;
}
.layout-preview-scroll-area {
@@ -539,6 +554,7 @@
display: flex;
align-items: center;
justify-content: center;
+ background: rgba(255, 255, 255, 0.02);
}
.layout-preview-image {
@@ -620,9 +636,9 @@
}
.mini-btn {
- background: linear-gradient(180deg, var(--surface-highlight), transparent), var(--input-bg);
+ background: var(--mini-button-bg);
border: 1px solid var(--border);
- color: var(--text-muted);
+ color: var(--mini-button-text);
border-radius: 8px;
cursor: pointer;
height: 32px;
@@ -639,6 +655,67 @@
transform: translateY(-1px);
}
+ body.light-mode .mini-btn {
+ background: var(--mini-button-bg);
+ border-color: rgba(30, 48, 69, 0.18);
+ color: var(--mini-button-text);
+ box-shadow: 0 6px 14px rgba(18, 32, 51, 0.08);
+ }
+
+ .site-nav-actions {
+ position: fixed;
+ top: 14px;
+ right: 16px;
+ z-index: 80;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: rgba(13, 22, 38, 0.92);
+ box-shadow: 0 16px 34px var(--shadow);
+ backdrop-filter: blur(14px);
+ }
+
+ body.light-mode .site-nav-actions {
+ background: rgba(255, 255, 255, 0.94);
+ border-color: rgba(30, 48, 69, 0.16);
+ }
+
+ .canvas-toolbar {
+ position: absolute;
+ top: 15px;
+ right: 15px;
+ z-index: 10;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 12px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: rgba(13, 22, 38, 0.9);
+ box-shadow: 0 16px 34px var(--shadow);
+ backdrop-filter: blur(14px);
+ }
+
+ body.light-mode .canvas-toolbar {
+ background: rgba(255, 255, 255, 0.96);
+ border-color: rgba(30, 48, 69, 0.16);
+ }
+
+ .grid-snap-label {
+ font-size: 0.85em;
+ font-weight: 600;
+ color: var(--text-main);
+ user-select: none;
+ white-space: nowrap;
+ }
+
+ body.light-mode .grid-snap-label {
+ color: #102033;
+ }
+
.build-layout-btn {
position: absolute;
bottom: 20px;
@@ -814,6 +891,67 @@
right: 1px;
}
+ .element-card-icon.basic-icon {
+ border-radius: 4px;
+ overflow: hidden;
+ background: rgba(15, 23, 42, 0.92);
+ }
+
+ .element-card-icon.basic-icon::before {
+ content: '';
+ position: absolute;
+ left: 2px;
+ right: 2px;
+ top: 7px;
+ height: 2px;
+ border-radius: 999px;
+ background: var(--accent);
+ box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.14);
+ }
+
+ .coordinate-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 8px;
+ align-items: end;
+ }
+
+ .coordinate-grid label {
+ display: grid;
+ gap: 4px;
+ min-width: 0;
+ }
+
+ .port-info-list {
+ display: grid;
+ gap: 6px;
+ margin: 0 0 15px 0;
+ padding: 0;
+ list-style: none;
+ color: var(--text-muted);
+ }
+
+ .port-info-list li {
+ line-height: 1.45;
+ letter-spacing: 0.2px;
+ }
+
+ .save-project-btn {
+ border: 1px solid rgba(45, 212, 191, 0.45);
+ background: linear-gradient(180deg, rgba(45, 212, 191, 0.16), rgba(13, 148, 136, 0.12));
+ color: var(--text-main);
+ border-radius: 5px;
+ padding: 5px 8px;
+ font-size: 0.68rem;
+ font-weight: 700;
+ cursor: pointer;
+ }
+
+ .save-project-btn:disabled {
+ cursor: wait;
+ opacity: 0.66;
+ }
+
.category-card {
min-height: 94px;
}
@@ -945,6 +1083,327 @@
border-radius: 999px;
background: currentColor;
}
+
+ .link-mode-tabs {
+ position: relative;
+ display: inline-block;
+ border-radius: 8px;
+ border: 1px solid var(--border);
+ background: var(--input-bg);
+ }
+
+ .link-mode-summary {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ min-width: 118px;
+ height: 30px;
+ padding: 0 9px;
+ list-style: none;
+ cursor: pointer;
+ user-select: none;
+ }
+
+ .link-mode-summary::-webkit-details-marker {
+ display: none;
+ }
+
+ .link-mode-label {
+ font-size: 0.72rem;
+ color: var(--text-muted);
+ font-weight: 600;
+ }
+
+ .link-mode-current {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ margin-left: auto;
+ font-size: 0.72rem;
+ font-family: 'IBM Plex Mono', Consolas, Monaco, monospace;
+ }
+
+ .link-mode-current::before,
+ .link-mode-btn::before {
+ content: "";
+ width: 16px;
+ height: 3px;
+ border-radius: 999px;
+ background: currentColor;
+ }
+
+ .link-mode-menu {
+ position: absolute;
+ top: calc(100% + 6px);
+ right: 0;
+ z-index: 40;
+ min-width: 154px;
+ display: grid;
+ gap: 4px;
+ padding: 6px;
+ border-radius: 8px;
+ border: 1px solid var(--border);
+ background: var(--bg-card);
+ box-shadow: 0 18px 40px var(--shadow);
+ }
+
+ .link-mode-btn {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ height: 28px;
+ padding: 0 9px;
+ border: 1px solid transparent;
+ border-radius: 6px;
+ background: transparent;
+ color: var(--text-main);
+ font-size: 0.72rem;
+ font-family: 'IBM Plex Mono', Consolas, Monaco, monospace;
+ cursor: pointer;
+ text-align: left;
+ }
+
+ .link-mode-btn.active {
+ background: rgba(14, 165, 233, 0.18);
+ border-color: currentColor;
+ box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.05) inset;
+ }
+
+ .port-name-label {
+ position: absolute;
+ z-index: 4;
+ max-width: 58px;
+ padding: 1px 4px;
+ border-radius: 4px;
+ background: var(--port-label-bg);
+ border: 1px solid var(--floating-label-border);
+ color: var(--port-label-text);
+ box-shadow: 0 5px 12px rgba(0, 0, 0, 0.18);
+ font-size: 0.42rem;
+ line-height: 1.2;
+ font-family: 'IBM Plex Mono', Consolas, Monaco, monospace;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ pointer-events: none;
+ }
+
+ .build-progress {
+ position: absolute;
+ left: 50%;
+ top: 14px;
+ transform: translateX(-50%);
+ z-index: 20;
+ width: min(360px, calc(100% - 180px));
+ padding: 9px 12px;
+ border-radius: 8px;
+ border: 1px solid var(--border-strong);
+ background: rgba(11, 19, 32, 0.94);
+ box-shadow: 0 18px 38px var(--shadow);
+ backdrop-filter: blur(14px);
+ }
+
+ .build-progress-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 7px;
+ font-size: 0.72rem;
+ color: var(--text-main);
+ font-family: 'IBM Plex Mono', Consolas, Monaco, monospace;
+ }
+
+ .build-progress-track {
+ height: 7px;
+ border-radius: 999px;
+ overflow: hidden;
+ background: rgba(148, 163, 184, 0.18);
+ }
+
+ .build-progress-fill {
+ height: 100%;
+ border-radius: inherit;
+ background: linear-gradient(90deg, var(--accent), var(--accent-green));
+ transition: width 0.28s ease;
+ }
+
+ .component-node-shell {
+ position: relative;
+ min-width: 0;
+ max-width: none;
+ width: 132px;
+ min-height: 82px;
+ text-align: center;
+ font-family: 'IBM Plex Sans', sans-serif;
+ }
+
+ .component-visual-body {
+ min-height: 74px;
+ padding: 10px 15px;
+ border-radius: 6px;
+ background: var(--bg-card);
+ color: var(--text-main);
+ box-sizing: border-box;
+ transition: none;
+ transform-origin: center center;
+ }
+
+ .component-floating-label {
+ position: absolute;
+ left: 50%;
+ bottom: calc(100% + 7px);
+ transform: translateX(-50%);
+ z-index: 8;
+ min-width: 84px;
+ max-width: 160px;
+ padding: 3px 6px;
+ border-radius: 5px;
+ border: 1px solid var(--floating-label-border);
+ background: var(--floating-label-bg);
+ color: var(--text-main);
+ box-shadow: var(--floating-label-shadow);
+ pointer-events: none;
+ }
+
+ body.light-mode .component-floating-label {
+ background: var(--floating-label-bg);
+ border-color: var(--floating-label-border);
+ box-shadow: var(--floating-label-shadow);
+ }
+
+ body.light-mode .port-name-label {
+ background: var(--port-label-bg);
+ border-color: var(--floating-label-border);
+ color: var(--port-label-text);
+ box-shadow: 0 5px 12px rgba(18, 32, 51, 0.1);
+ }
+
+ .box-size-readout {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 6px;
+ margin: 10px 0 14px;
+ padding: 8px;
+ border-radius: 6px;
+ border: 1px solid var(--border);
+ background: var(--input-bg);
+ color: var(--text-main);
+ font-family: 'IBM Plex Mono', Consolas, Monaco, monospace;
+ font-size: 0.68rem;
+ }
+
+ .box-size-readout span {
+ color: var(--text-muted);
+ }
+
+ .component-floating-label strong,
+ .component-floating-label span {
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .component-floating-label strong {
+ font-size: 0.52rem;
+ font-weight: 650;
+ }
+
+ .component-floating-label span {
+ margin-top: 1px;
+ color: var(--text-muted);
+ font-size: 0.44rem;
+ }
+
+ .canvas-size-panel {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 8px;
+ padding: 10px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background:
+ linear-gradient(180deg, var(--surface-highlight), transparent 74%),
+ var(--input-bg);
+ }
+
+ .canvas-size-panel label {
+ display: block;
+ margin-bottom: 4px;
+ color: var(--text-muted);
+ font-size: 0.68rem;
+ font-family: 'IBM Plex Mono', Consolas, Monaco, monospace;
+ }
+
+ .canvas-size-title {
+ grid-column: 1 / -1;
+ color: var(--text-main);
+ font-size: 0.74rem;
+ font-weight: 700;
+ font-family: 'IBM Plex Mono', Consolas, Monaco, monospace;
+ }
+
+ .canvas-boundary-node {
+ width: 100%;
+ height: 100%;
+ box-sizing: border-box;
+ border: 4px solid rgba(69, 214, 200, 0.92);
+ background: rgba(69, 214, 200, 0.018);
+ box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.14), 0 0 22px rgba(69, 214, 200, 0.18);
+ pointer-events: none;
+ }
+
+ .ruler-point-node {
+ width: 12px;
+ height: 12px;
+ box-sizing: border-box;
+ border-radius: 50%;
+ border: 2px solid #f8fafc;
+ background: var(--accent-green);
+ box-shadow: 0 0 0 4px rgba(45, 212, 191, 0.18), 0 8px 18px rgba(0, 0, 0, 0.28);
+ pointer-events: none;
+ }
+
+ .ruler-measurement-node {
+ min-width: 148px;
+ padding: 5px 8px;
+ border-radius: 6px;
+ border: 1px solid rgba(45, 212, 191, 0.5);
+ background: rgba(9, 18, 28, 0.94);
+ color: #e2f7f3;
+ box-shadow: 0 10px 24px rgba(0, 0, 0, 0.3);
+ font: 600 0.62rem/1.35 'IBM Plex Mono', Consolas, Monaco, monospace;
+ text-align: center;
+ transform: translate(-50%, -50%);
+ pointer-events: none;
+ white-space: nowrap;
+ }
+
+ body.light-mode .ruler-measurement-node {
+ background: rgba(255, 255, 255, 0.96);
+ color: #12323a;
+ box-shadow: 0 10px 24px rgba(18, 32, 51, 0.14);
+ }
+
+ .ruler-status {
+ position: absolute;
+ left: 50%;
+ bottom: 24px;
+ transform: translateX(-50%);
+ z-index: 18;
+ padding: 6px 10px;
+ border-radius: 999px;
+ border: 1px solid rgba(45, 212, 191, 0.38);
+ background: rgba(9, 18, 28, 0.9);
+ color: #c8f7f0;
+ font: 600 0.68rem/1 'IBM Plex Mono', Consolas, Monaco, monospace;
+ pointer-events: none;
+ }
+
+ body.light-mode .ruler-status {
+ background: rgba(255, 255, 255, 0.94);
+ color: #0f4c54;
+ }
@@ -963,6 +1422,7 @@
addEdge,
Handle,
Position,
+ SelectionMode,
useUpdateNodeInternals,
applyNodeChanges,
applyEdgeChanges,
@@ -970,24 +1430,42 @@
const {
FORGE_COMPONENT_LABEL,
FORGE_COMPONENT_TYPE,
+ DEFAULT_COMPONENT_BOX_SIZE,
+ DEFAULT_CANVAS_SIZE,
+ PORT_NODE_SIZE,
ELEMENT_COMPONENTS,
+ BASIC_COMPONENTS,
createForgeArguments,
isForgeComponent,
+ isBasicComponent,
+ createBasicSettings,
+ normalizeBoxSize,
+ chooseCategoryComponent,
+ normalizeCanvasSize,
+ clampPositionToCanvas,
+ calculateLayoutBounds,
buildPortHandles,
buildElementPorts,
+ getBasicComponentMetadata,
buildInstancesYaml,
buildPageComponentPorts,
buildCanvasPortsYaml,
buildElementsYaml,
buildBundlesYaml: buildRouteBundlesYaml,
+ normalizeAngle,
createRouteSettings,
updateRouteField,
updateRouteXsection,
routeStyleForSettings,
- findSameFamilyRouteCrossing,
- FALLBACK_TECHNOLOGY_MANIFEST
+ findSameTypeRouteCrossing,
+ createRulerMeasurement,
+ createComponentSymbolMetrics,
+ FALLBACK_TECHNOLOGY_MANIFEST,
+ layoutToCanvasY
} = window.MxpicCanvasHelpers;
+ const FULL_SELECTION_MODE = SelectionMode && SelectionMode.Full ? SelectionMode.Full : 'full';
+
const iconPromiseCache = {};
function fetchIcon(category) {
@@ -1066,9 +1544,9 @@
src={src}
alt={category}
style={{
- maxWidth: '100%',
- maxHeight: '100%',
- objectFit: 'contain',
+ width: '100%',
+ height: '100%',
+ objectFit: 'fill',
pointerEvents: 'none',
}}
onError={(e) => {
@@ -1084,7 +1562,7 @@
const RotatableNode = memo(({ id, data, selected }) => {
const updateNodeInternals = useUpdateNodeInternals();
- const prevRotationRef = useRef(data.rotation);
+ const prevTransformRef = useRef(`${data.rotation || 0}:${data.flip ? 1 : 0}:${data.flop ? 1 : 0}`);
const updateNodeInternalsRef = useRef(updateNodeInternals);
useEffect(() => {
@@ -1092,15 +1570,16 @@
}, [updateNodeInternals]);
useEffect(() => {
- if (prevRotationRef.current !== data.rotation) {
+ const transformKey = `${data.rotation || 0}:${data.flip ? 1 : 0}:${data.flop ? 1 : 0}`;
+ if (prevTransformRef.current !== transformKey) {
updateNodeInternalsRef.current(id);
- prevRotationRef.current = data.rotation;
+ prevTransformRef.current = transformKey;
}
- }, [data.rotation, id]);
+ }, [data.rotation, data.flip, data.flop, id]);
useEffect(() => {
updateNodeInternalsRef.current(id);
- }, [id, data.ports, data.componentName]);
+ }, [id, data.ports, data.componentName, data.boxSize]);
const baseHandleStyle = {
width: 10, height: 10,
@@ -1114,53 +1593,68 @@
top: Position.Top,
bottom: Position.Bottom
};
- const portHandles = useMemo(() => buildPortHandles(data.ports), [data.ports]);
+ const portHandles = useMemo(
+ () => buildPortHandles(data.ports, { rotation: data.rotation || 0, flip: Boolean(data.flip), flop: Boolean(data.flop) }),
+ [data.ports, data.rotation, data.flip, data.flop]
+ );
+ const componentSize = normalizeBoxSize({ box_size: data.boxSize }, DEFAULT_COMPONENT_BOX_SIZE);
+ const isAnchorElement = data.elementType === 'anchor';
+ const visualSize = isAnchorElement ? { width: PORT_NODE_SIZE, height: PORT_NODE_SIZE } : componentSize;
+ const iconSize = createComponentSymbolMetrics(componentSize);
+ const portLabelStyle = (portHandle) => {
+ const base = { ...portHandle.style };
+ if (portHandle.position === 'left') {
+ return { ...base, right: 'calc(100% + 8px)', transform: 'translateY(-50%)', textAlign: 'right' };
+ }
+ if (portHandle.position === 'right') {
+ return { ...base, left: 'calc(100% + 8px)', transform: 'translateY(-50%)', textAlign: 'left' };
+ }
+ if (portHandle.position === 'top') {
+ return { ...base, bottom: 'calc(100% + 8px)', transform: 'translateX(-50%)', textAlign: 'center' };
+ }
+ return { ...base, top: 'calc(100% + 8px)', transform: 'translateX(-50%)', textAlign: 'center' };
+ };
return (
-
-
- {!data.hideIcon && data.category && (
-
-
-
- )}
-
-
- {data.componentDisplayName}
-
+
+
+
{data.componentDisplayName}
{data.componentName && data.componentName !== data.componentDisplayName && (
-
- {data.componentName}
+ {data.componentName}
+ )}
+
+
+ {isAnchorElement ? (
+
A
+ ) : (
+
+ {!data.hideIcon && data.category && (
+
+
+
+ )}
+ {!data.category &&
}
)}
@@ -1181,6 +1675,9 @@
title={portHandle.name}
style={{ ...baseHandleStyle, ...portHandle.style, zIndex: 5 }}
/>
+
+ {portHandle.name}
+
))}
@@ -1193,6 +1690,9 @@
prevProps.data.componentName === nextProps.data.componentName &&
prevProps.data.category === nextProps.data.category &&
prevProps.data.rotation === nextProps.data.rotation &&
+ prevProps.data.flip === nextProps.data.flip &&
+ prevProps.data.flop === nextProps.data.flop &&
+ prevProps.data.boxSize === nextProps.data.boxSize &&
prevProps.data.hideIcon === nextProps.data.hideIcon &&
prevProps.data.ports === nextProps.data.ports
);
@@ -1206,7 +1706,7 @@
const handleId = data.portName || data.componentDisplayName || 'port';
return (
{
+ const updateNodeInternals = useUpdateNodeInternals();
+ const ports = data.ports || buildElementPorts('anchor');
+ const portHandles = useMemo(
+ () => buildPortHandles(ports, { rotation: data.rotation || 0, flip: Boolean(data.flip), flop: Boolean(data.flop) }),
+ [ports, data.rotation, data.flip, data.flop]
+ );
+ const handlePositionMap = {
+ left: Position.Left,
+ right: Position.Right,
+ top: Position.Top,
+ bottom: Position.Bottom
+ };
+ const baseHandleStyle = {
+ width: 8,
+ height: 8,
+ background: 'var(--accent)',
+ border: '1px solid var(--bg-main)',
+ borderRadius: '50%'
+ };
+
+ useEffect(() => {
+ updateNodeInternals(id);
+ }, [id, data.ports, data.rotation, data.flip, data.flop, updateNodeInternals]);
+
+ return (
+
+ A
+ {portHandles.map((portHandle) => (
+
+
+
+
+ ))}
+
+ );
+ });
+
+ const CanvasBoundaryNode = memo(({ data }) => (
+
+ ));
+
+ const RulerPointNode = memo(({ data }) => {
+ const hiddenHandleStyle = {
+ width: 1,
+ height: 1,
+ opacity: 0,
+ border: 0,
+ background: 'transparent',
+ pointerEvents: 'none'
+ };
+ return (
+ <>
+
+
+
+ >
+ );
+ });
+
+ const RulerMeasurementNode = memo(({ data }) => (
+
+ {data.label}
+
+ ));
+
+ const ParallelRouteEdge = memo(({ id, sourceX, sourceY, targetX, targetY, markerEnd, style, selected, data }) => {
+ const offset = Number(data?.parallelOffset || 0);
+ let rawPoints = Array.isArray(data?.points) && data.points.length >= 2
+ ? data.points.map(point => ({ x: Number(point.x), y: Number(point.y) })).filter(point => Number.isFinite(point.x) && Number.isFinite(point.y))
+ : [{ x: sourceX, y: sourceY }, { x: targetX, y: targetY }];
+ if (!data?.freeRoute && rawPoints.length >= 2) {
+ const sourcePoint = data?.sourceSnap?.point || { x: sourceX, y: sourceY };
+ const targetPoint = data?.targetSnap?.point || { x: targetX, y: targetY };
+ rawPoints = [
+ { x: Number(sourcePoint.x), y: Number(sourcePoint.y) },
+ ...rawPoints.slice(1, -1),
+ { x: Number(targetPoint.x), y: Number(targetPoint.y) }
+ ];
+ }
+ const firstPoint = rawPoints[0] || { x: sourceX, y: sourceY };
+ const lastPoint = rawPoints[rawPoints.length - 1] || { x: targetX, y: targetY };
+ const dx = lastPoint.x - firstPoint.x;
+ const dy = lastPoint.y - firstPoint.y;
+ const length = Math.hypot(dx, dy) || 1;
+ const normalX = -dy / length;
+ const normalY = dx / length;
+ const points = rawPoints.map(point => ({
+ x: point.x + normalX * offset,
+ y: point.y + normalY * offset
+ }));
+ const path = points.length >= 2
+ ? `M ${points[0].x},${points[0].y} ${points.slice(1).map(point => `L ${point.x},${point.y}`).join(' ')}`
+ : `M ${sourceX},${sourceY} Q ${(sourceX + targetX) / 2 + normalX * offset},${(sourceY + targetY) / 2 + normalY * offset} ${targetX},${targetY}`;
+ const routeStyle = {
+ ...(style || {}),
+ fill: 'none',
+ strokeWidth: selected ? Number(style?.strokeWidth || 2.4) + 1.2 : style?.strokeWidth
+ };
+ return (
+
+
+
+
+ );
+ });
+
const LayoutSvgPreview = ({ page }) => {
const [layoutScale, setLayoutScale] = useState(100);
+ const previewBounds = useMemo(
+ () => page.layoutBounds || calculateLayoutBounds(page),
+ [page.layoutBounds, page.nodes, page.canvasSize]
+ );
const normalizedScale = Math.min(800, Math.max(10, Number(layoutScale) || 100));
+ const stageWidth = Math.max(1, previewBounds.width) * normalizedScale / 100;
+ const stageHeight = Math.max(1, previewBounds.height) * normalizedScale / 100;
const updateScale = (value) => {
setLayoutScale(Math.min(800, Math.max(10, Number(value) || 100)));
};
+ const handleWheel = (event) => {
+ event.preventDefault();
+ const direction = event.deltaY > 0 ? -1 : 1;
+ const step = event.shiftKey ? 5 : 15;
+ setLayoutScale(current => Math.min(800, Math.max(10, (Number(current) || 100) + direction * step)));
+ };
+
return (
@@ -1258,11 +1921,11 @@
%
-
+
![]()
{
+ if (isBasicElement) {
+ const dragData = JSON.stringify({
+ name: componentName,
+ type: 'basic',
+ componentName,
+ settings: createBasicSettings(componentName)
+ });
+ event.dataTransfer.setData('application/reactflow', dragData);
+ event.dataTransfer.setData('text/plain', dragData);
+ event.dataTransfer.effectAllowed = 'move';
+ return;
+ }
if (isVirtualElement) {
const dragData = JSON.stringify({
name: componentName,
@@ -1456,20 +2134,20 @@
return (
- {isVirtualElement && (
+ {(isVirtualElement || isBasicElement) && (
)}
- {!isUserCell && !isVirtualElement && (
+ {!isUserCell && !isVirtualElement && !isBasicElement && (
@@ -1485,6 +2163,9 @@
const hasChildren = entries.length > 0;
const isComponentGrid = hasChildren && entries.every(([, childData]) => isLibraryComponentLeaf(childData));
const isElementComponentGrid = isComponentGrid && entries.every(([, childData]) => childData.__element__ === true);
+ const isDirectLeafGrid = isComponentGrid && entries.every(([, childData]) => (
+ childData.__cell__ === true || childData.__element__ === true || childData.__basic__ === true
+ ));
const isCategoryGrid = hasChildren && entries.every(([, childData]) => {
const childEntries = Object.entries(childData || {});
return childEntries.length > 0 && childEntries.every(([, grandChild]) => isLibraryComponentLeaf(grandChild));
@@ -1499,7 +2180,13 @@
{hasChildren && (
- isCategoryGrid && !isElementComponentGrid ? (
+ isDirectLeafGrid ? (
+
+ {entries.map(([childName, childData]) => (
+
+ ))}
+
+ ) : isCategoryGrid && !isElementComponentGrid ? (
{entries.map(([childName, childData]) => (
@@ -1679,10 +2366,11 @@
return null;
};
- const LeftPanel = ({ projectTreeItems, library, treeKey, expanded, onToggle, treeRef, width, onOpenComposite, onOpenProject, onSelectInstance, onRenameCanvas, onDeleteCanvas, onBuildGds, buildGdsBusy, projectExpanded, onProjectToggle, projectTreeRef, projectTreeKey }) => {
+ const LeftPanel = ({ projectTreeItems, library, treeKey, expanded, onToggle, treeRef, width, onOpenComposite, onOpenProject, onSelectInstance, onRenameCanvas, onDeleteCanvas, onBuildGds, buildGdsBusy, onSaveProject, saveProjectBusy, projectExpanded, onProjectToggle, projectTreeRef, projectTreeKey, canvasSize, onCanvasSizeChange }) => {
const [projectPanelHeight, setProjectPanelHeight] = useState(270);
const [resizingProjectPanel, setResizingProjectPanel] = useState(false);
const leftPanelRef = useRef(null);
+ const size = normalizeCanvasSize(canvasSize);
useEffect(() => {
if (!resizingProjectPanel) return;
@@ -1720,6 +2408,9 @@
Project Tree
+
@@ -1729,6 +2420,29 @@
+
+
Canvas Size
+
+
+ onCanvasSizeChange && onCanvasSizeChange('width', event.target.value)}
+ />
+
+
+
+ onCanvasSizeChange && onCanvasSizeChange('height', event.target.value)}
+ />
+
+
{projectTreeItems && projectTreeItems.length > 0 ? (
projectTreeItems.map(item => {
if (item.type === 'project') {
@@ -1772,7 +2486,7 @@
);
};
- const RightPanel = ({ selectedNode, selectedEdge, technologyManifest, width, onRenameComponent, onUpdateNode, onUpdateEdgeRoute }) => {
+ const RightPanel = ({ selectedNode, selectedNodes = [], selectedEdge, selectedEdges = [], technologyManifest, projectName, width, onRenameComponent, onUpdateNode, onUpdateEdgeRoute }) => {
const [componentData, setComponentData] = useState(null);
const [loading, setLoading] = useState(false);
const [enlarged, setEnlarged] = useState(null);
@@ -1790,7 +2504,7 @@
return;
}
const compName = selectedNode?.data?.componentName;
- if (selectedNode?.data?.elementType) {
+ if (selectedNode?.data?.elementType || isBasicComponent(compName)) {
setComponentData(null);
setLoading(false);
return;
@@ -1808,13 +2522,14 @@
if (componentData && componentData.name === compName && componentData.nodeId === nodeId) return;
setLoading(true);
- fetch(`/api/component/${encodeURIComponent(compName)}`)
+ fetch(`/api/component/${encodeURIComponent(compName)}?project=${encodeURIComponent(projectName || '')}`)
.then(r => r.json())
.then(data => {
setComponentData({ ...data, nodeId: nodeId, componentDisplayName: selectedNode.data.componentDisplayName || data.name });
onUpdateNode(nodeId, {
data: {
ports: data.ports || {},
+ boxSize: normalizeBoxSize(data),
foundry: data.foundry || '',
process: data.process || ''
}
@@ -1822,7 +2537,7 @@
setLoading(false);
})
.catch(() => setLoading(false));
- }, [selectedNode?.id, selectedNode?.data?.componentName, selectedNode?.data?.componentDisplayName, onUpdateNode]);
+ }, [selectedNode?.id, selectedNode?.data?.componentName, selectedNode?.data?.componentDisplayName, projectName, onUpdateNode]);
useEffect(() => {
if (selectedNode) {
@@ -1835,11 +2550,25 @@
}
}, [selectedNode?.position.x, selectedNode?.position.y, selectedNode?.data?.rotation, selectedNode?.data?.angle, selectedNode?.id]);
+ const selectedPositionNodes = useMemo(
+ () => (selectedNodes.length > 0 ? selectedNodes : (selectedNode ? [selectedNode] : [])).filter(node => node && node.position),
+ [selectedNodes, selectedNode]
+ );
+
const updatePosition = useCallback((id, axis, value) => {
const val = parseFloat(value);
if (isNaN(val)) return;
+ if (selectedPositionNodes.length > 1 && selectedPositionNodes.some(node => node.id === id)) {
+ const baseNode = selectedPositionNodes.find(node => node.id === id) || selectedPositionNodes[0];
+ const delta = val - Number((baseNode.position && baseNode.position[axis]) || 0);
+ selectedPositionNodes.forEach(node => {
+ const currentValue = Number((node.position && node.position[axis]) || 0);
+ onUpdateNode(node.id, { position: { [axis]: currentValue + delta } });
+ });
+ return;
+ }
onUpdateNode(id, { position: { [axis]: val } });
- }, [onUpdateNode]);
+ }, [onUpdateNode, selectedPositionNodes]);
const updateRotation = useCallback((id, value, isPortNode = false) => {
const val = parseFloat(value);
@@ -1849,9 +2578,15 @@
onUpdateNode(id, { data: dataField });
}, [onUpdateNode]);
+ const toggleComponentTransform = useCallback((key) => {
+ if (!selectedNode) return;
+ onUpdateNode(selectedNode.id, { data: { [key]: !Boolean(selectedNode.data?.[key]) } });
+ }, [onUpdateNode, selectedNode]);
+
const formatPort = (port) => {
if (!port) return '-';
- return `x:${port.x ?? '?'}, y:${port.y ?? '?'}, a:${port.a ?? '?'}, w:${port.width ?? '?'}`;
+ const description = port.description ? ` ${port.description}` : '';
+ return `(${port.x ?? '?'}, ${port.y ?? '?'}, ${port.a ?? '?'})${description}`;
};
const currentComponentDisplayName = selectedNode?.data?.componentDisplayName || '';
@@ -1863,12 +2598,30 @@
const selectedIsVirtualElement = selectedNode?.data?.elementType === 'port' || selectedNode?.data?.elementType === 'anchor';
const canChooseComponent = !selectedIsVirtualElement && availableComponentsFromNode.length > 0;
const forgeSelected = isForgeComponent(selectedComponentName);
+ const basicSelected = isBasicComponent(selectedComponentName);
+ const basicMetadata = basicSelected ? getBasicComponentMetadata(selectedComponentName, selectedNode?.data?.basicArguments) : null;
+ const basicArguments = basicSelected ? createBasicSettings(selectedComponentName, selectedNode?.data?.basicArguments) : {};
const forgeArguments = createForgeArguments(selectedNode?.data?.forgeArguments);
const selectedIsPort = selectedNode && (selectedNode.type === 'portNode' || selectedNode.data?.elementType === 'port');
const selectedIsAnchor = selectedNode?.data?.elementType === 'anchor';
+ const selectedNodeBoxSize = selectedNode?.data?.componentName && !selectedNode?.data?.elementType
+ ? normalizeBoxSize({ box_size: selectedNode.data?.boxSize }, DEFAULT_COMPONENT_BOX_SIZE)
+ : null;
- if (selectedEdge) {
- const route = createRouteSettings(technologyManifest, selectedEdge.data?.route);
+ const selectedRouteEdges = selectedEdges.length > 0 ? selectedEdges : (selectedEdge ? [selectedEdge] : []);
+ if (selectedRouteEdges.length > 0) {
+ const routes = selectedRouteEdges.map(edge => createRouteSettings(technologyManifest, edge.data?.route));
+ const selectedEdgeIds = selectedRouteEdges.map(edge => edge.id);
+ const firstRoute = routes[0];
+ const mixedValue = (key) => routes.every(route => String(route[key]) === String(firstRoute[key])) ? firstRoute[key] : '__mixed__';
+ const route = {
+ ...firstRoute,
+ xsection: mixedValue('xsection'),
+ family: mixedValue('family'),
+ width: mixedValue('width'),
+ radius: mixedValue('radius'),
+ routing_type: mixedValue('routing_type')
+ };
const xsections = Object.keys((technologyManifest || FALLBACK_TECHNOLOGY_MANIFEST).xsections || {});
const routingTypes = (technologyManifest || FALLBACK_TECHNOLOGY_MANIFEST).routing_types || ['euler_bend', 'standard_bend'];
return (
@@ -1881,13 +2634,14 @@
Route Editor
- {route.family} / {route.xsection}
+ {selectedRouteEdges.length} selected / {route.family === '__mixed__' ? '--' : route.family}
-
-
-
Snap to Grid
+
+
+
+
+ Link
+
+ {currentLinkXsection}
+
+
+
+ {linkXsectionChoices.map(xsection => {
+ const route = createRouteSettings(technologyManifest, { xsection });
+ const color = routeStyleForSettings(route, false).style.stroke;
+ return (
+
+ );
+ })}
+
+
-
-
+ {buildProgress.active && (
+
+
+ {buildProgress.label}
+ {Math.round(buildProgress.value)}%
+
+
+
+ )}
+
+ {rulerMode && activePage?.type !== 'layoutPreview' && (
+
+ {rulerMeasurement
+ ? rulerMeasurement.label
+ : (rulerStartPoint && !rulerEndPoint ? 'Move mouse, click second point' : 'Click the first point')}
+
+ )}
+
{activePage && activePage.type !== 'layoutPreview' && (
- Build Layout
+ {buildLayoutBusy ? 'Building...' : 'Build Layout'}
)}
@@ -4052,24 +5556,42 @@ ${bundlesBlock}`;
) : (
-
+
)}
@@ -4083,8 +5605,11 @@ ${bundlesBlock}`;
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(''),
+ '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(
diff --git a/tests/canvas-helpers.test.js b/tests/canvas-helpers.test.js
index d5ced1e..dba05d7 100644
--- a/tests/canvas-helpers.test.js
+++ b/tests/canvas-helpers.test.js
@@ -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);
diff --git a/tests/layout-backend-static.test.js b/tests/layout-backend-static.test.js
index a898efa..f95c142 100644
--- a/tests/layout-backend-static.test.js
+++ b/tests/layout-backend-static.test.js
@@ -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'
+);
diff --git a/tests/layout-ui-wiring.test.js b/tests/layout-ui-wiring.test.js
index 2b6a8d0..c4b5b7a 100644
--- a/tests/layout-ui-wiring.test.js
+++ b/tests/layout-ui-wiring.test.js
@@ -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('
= 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(' 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('') &&
+ 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'
);
diff --git a/tests/project-load-static.test.js b/tests/project-load-static.test.js
new file mode 100644
index 0000000..7d764f3
--- /dev/null
+++ b/tests/project-load-static.test.js
@@ -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'
+);