From e6e9e13cf2e9865761c854d6f243e774671db505 Mon Sep 17 00:00:00 2001 From: PotatoMaxwell Date: Thu, 28 May 2026 17:53:41 +0800 Subject: [PATCH] Updated --- .../__pycache__/gds_builder.cpython-39.pyc | Bin 0 -> 5498 bytes .../__pycache__/layout_preview.cpython-39.pyc | Bin 0 -> 4663 bytes .../__pycache__/pdk_registry.cpython-39.pyc | Bin 0 -> 3200 bytes .../technology_manifest.cpython-39.pyc | Bin 0 -> 1313 bytes backend/gds_builder.py | 158 ++++ backend/layout_preview.py | 131 ++++ backend/pdk_registry.py | 87 +++ backend/server.py | 116 ++- backend/technology_manifest.py | 28 + .../admin/layout/mxpic_project_1/canvas_1.gds | Bin 0 -> 2098 bytes .../admin/layout/mxpic_project_1/canvas_1.svg | 41 + .../admin/layout/mxpic_project_1/canvas_1.yml | 61 ++ .../mxpic_project_1/mxpic_project_1.gds | Bin 0 -> 1730 bytes .../mxpic_project_1/mxpic_project_1.svg | 45 ++ .../mxpic_project_1/mxpic_project_1.yml | 67 +- database/mxpic_data.db | Bin 28672 -> 36864 bytes frontend/canvas-helpers.js | 170 ++++ frontend/canvas.html | 727 ++++++++++++++---- .../EMO1_2ML_CU_Al_RDL/technology.yml | 73 ++ tests/canvas-helpers.test.js | 84 ++ tests/layout-backend-static.test.js | 66 ++ tests/layout-ui-wiring.test.js | 75 ++ 22 files changed, 1743 insertions(+), 186 deletions(-) create mode 100644 backend/__pycache__/gds_builder.cpython-39.pyc create mode 100644 backend/__pycache__/layout_preview.cpython-39.pyc create mode 100644 backend/__pycache__/pdk_registry.cpython-39.pyc create mode 100644 backend/__pycache__/technology_manifest.cpython-39.pyc create mode 100644 backend/gds_builder.py create mode 100644 backend/layout_preview.py create mode 100644 backend/pdk_registry.py create mode 100644 backend/technology_manifest.py create mode 100644 database/admin/layout/mxpic_project_1/canvas_1.gds create mode 100644 database/admin/layout/mxpic_project_1/canvas_1.svg create mode 100644 database/admin/layout/mxpic_project_1/canvas_1.yml create mode 100644 database/admin/layout/mxpic_project_1/mxpic_project_1.gds create mode 100644 database/admin/layout/mxpic_project_1/mxpic_project_1.svg create mode 100644 mxpic/PDKs/Silterra/EMO1_2ML_CU_Al_RDL/technology.yml create mode 100644 tests/layout-backend-static.test.js create mode 100644 tests/layout-ui-wiring.test.js diff --git a/backend/__pycache__/gds_builder.cpython-39.pyc b/backend/__pycache__/gds_builder.cpython-39.pyc new file mode 100644 index 0000000000000000000000000000000000000000..41a07ae285ac1cf967b864cc6f37a9200b4dcb5a GIT binary patch literal 5498 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 BuildResult: + """Build a hierarchical project GDS from saved cell YAML files.""" + cells = _load_project_cells(project_dir) + if not cells: + raise ValueError("No saved cell YAML files found for this project") + + registry = PdkRegistry(pdk_public_root) + os.makedirs(os.path.dirname(output_path), exist_ok=True) + + try: + return _build_with_gdstk(cells, output_path, registry) + except ImportError as gdstk_error: + try: + return _build_with_nazca(cells, output_path, registry) + except ImportError as nazca_error: + raise RuntimeError( + "Build GDS requires either gdstk or a working nazca installation. " + f"gdstk import failed: {gdstk_error}. nazca import failed: {nazca_error}" + ) from nazca_error + + +def _load_project_cells(project_dir: str) -> Dict[str, dict]: + cells = {} + for filename in sorted(os.listdir(project_dir)): + if not filename.lower().endswith((".yml", ".yaml")): + continue + path = os.path.join(project_dir, filename) + with open(path, "r", encoding="utf-8") as file: + data = yaml.safe_load(file) or {} + cell_name = str(data.get("name") or os.path.splitext(filename)[0]) + cells[cell_name] = data + return cells + + +def _ordered_cell_names(cells: Dict[str, dict]) -> List[str]: + composites = [name for name, data in cells.items() if data.get("type") != "project"] + projects = [name for name, data in cells.items() if data.get("type") == "project"] + return composites + projects + + +def _build_with_gdstk(cells: Dict[str, dict], output_path: str, registry: PdkRegistry) -> BuildResult: + import gdstk + + library = gdstk.Library() + built_cells = {} + warnings = [] + + for cell_name in _ordered_cell_names(cells): + data = cells[cell_name] + gds_cell = library.new_cell(_safe_cell_name(cell_name, built_cells)) + built_cells[cell_name] = gds_cell + for instance_name, instance in (data.get("instances") or {}).items(): + component = str(instance.get("component") or "") + x = _number(instance.get("x")) + y = _number(instance.get("y")) + rotation = math.radians(_number(instance.get("rotation"))) + child = built_cells.get(component) + if child is None: + asset = registry.resolve(component) + if not asset.gds_path: + warnings.append(f"Missing GDS for {instance_name}: {component}") + continue + child = _import_public_gds(gdstk, library, asset.gds_path) + gds_cell.add(gdstk.Reference(child, origin=(x, y), rotation=rotation)) + + library.write_gds(output_path) + return BuildResult( + output_path=output_path, + engine="gdstk", + cells_built=list(built_cells.keys()), + warnings=warnings, + ) + + +def _import_public_gds(gdstk, library, gds_path: str): + source = gdstk.read_gds(gds_path) + top_cells = source.top_level() + if not top_cells: + raise ValueError(f"No top-level cell found in {gds_path}") + for source_cell in source.cells: + if _library_cell_by_name(library, source_cell.name) is None: + library.add(source_cell) + return top_cells[0] + + +def _build_with_nazca(cells: Dict[str, dict], output_path: str, registry: PdkRegistry) -> BuildResult: + import nazca as nd + + warnings = [] + built_cells = {} + ordered_names = _ordered_cell_names(cells) + for cell_name in ordered_names: + data = cells[cell_name] + with nd.Cell(cell_name) as current_cell: + for instance_name, instance in (data.get("instances") or {}).items(): + component = str(instance.get("component") or "") + x = _number(instance.get("x")) + y = _number(instance.get("y")) + rotation = _number(instance.get("rotation")) + if component in built_cells: + built_cells[component].put(x, y, rotation) + continue + asset = registry.resolve(component) + if not asset.gds_path: + warnings.append(f"Missing GDS for {instance_name}: {component}") + continue + loaded = nd.load_gds(asset.gds_path) + loaded.put(x, y, rotation) + built_cells[cell_name] = current_cell + + top_name = ordered_names[-1] + nd.export_gds(built_cells[top_name], filename=output_path) + return BuildResult(output_path=output_path, engine="nazca", cells_built=ordered_names, warnings=warnings) + + +def _safe_cell_name(name: str, existing: dict) -> str: + base = "".join(ch if ch.isalnum() or ch in "._$" else "_" for ch in str(name)) or "cell" + candidate = base + counter = 1 + used = {cell.name for cell in existing.values()} + while candidate in used: + counter += 1 + candidate = f"{base}_{counter}" + return candidate + + +def _library_cell_by_name(library, name: str): + for cell in library.cells: + if cell.name == name: + return cell + return None + + +def _number(value, default=0.0) -> float: + try: + if value is None or value == "": + return default + return float(value) + except (TypeError, ValueError): + return default diff --git a/backend/layout_preview.py b/backend/layout_preview.py new file mode 100644 index 0000000..7dd1e60 --- /dev/null +++ b/backend/layout_preview.py @@ -0,0 +1,131 @@ +import os +from typing import Dict, Optional + +import yaml + + +def create_layout_svg_from_gds(yaml_content: str, output_path: str, pdk_registry, project_dir: str = None) -> str: + """Create an SVG preview by placing real public _BB.gds cells from layout YAML.""" + layout = yaml.safe_load(yaml_content) or {} + try: + return _create_with_gdstk(layout, output_path, pdk_registry, project_dir) + except ImportError as gdstk_error: + try: + return _create_with_nazca(layout, output_path, pdk_registry, project_dir) + except ImportError as nazca_error: + raise RuntimeError( + "Layout SVG requires GDS geometry support. Install gdstk, or fix nazca dependencies. " + f"gdstk import failed: {gdstk_error}. nazca import failed: {nazca_error}" + ) from nazca_error + + +def _create_with_gdstk(layout: dict, output_path: str, pdk_registry, project_dir: Optional[str]) -> str: + import gdstk + + library = gdstk.Library() + cell_cache = {} + top = _build_gdstk_cell(gdstk, library, layout, pdk_registry, project_dir, cell_cache) + os.makedirs(os.path.dirname(output_path), exist_ok=True) + top.write_svg(output_path) + return output_path + + +def _build_gdstk_cell(gdstk, library, layout: dict, pdk_registry, project_dir: Optional[str], cell_cache: Dict): + cell_name = _safe_cell_name(layout.get("name") or "layout", library) + top = library.new_cell(cell_name) + + for instance_name, instance in (layout.get("instances") or {}).items(): + component = str(instance.get("component") or "") + x = _number(instance.get("x")) + y = _number(instance.get("y")) + rotation = _number(instance.get("rotation")) * 3.141592653589793 / 180 + child = _resolve_child_cell(gdstk, library, component, pdk_registry, project_dir, cell_cache) + if child is None: + raise FileNotFoundError(f"Unable to resolve _BB.gds for instance {instance_name}: {component}") + top.add(gdstk.Reference(child, origin=(x, y), rotation=rotation)) + + return top + + +def _resolve_child_cell(gdstk, library, component: str, pdk_registry, project_dir: Optional[str], cell_cache: Dict): + if component in cell_cache: + return cell_cache[component] + + local_layout = _load_local_layout(component, project_dir) + if local_layout is not None: + child = _build_gdstk_cell(gdstk, library, local_layout, pdk_registry, project_dir, cell_cache) + cell_cache[component] = child + return child + + asset = pdk_registry.resolve(component) + if not asset.gds_path: + return None + child = _import_gds_cell(gdstk, library, asset.gds_path) + cell_cache[component] = child + return child + + +def _import_gds_cell(gdstk, library, gds_path: str): + source = gdstk.read_gds(gds_path) + top_cells = source.top_level() + if not top_cells: + raise ValueError(f"No top-level cell found in {gds_path}") + for source_cell in source.cells: + if _library_cell_by_name(library, source_cell.name) is None: + library.add(source_cell) + return top_cells[0] + + +def _create_with_nazca(layout: dict, output_path: str, pdk_registry, project_dir: Optional[str]) -> str: + import nazca as nd + + png_path = os.path.splitext(output_path)[0] + ".gds" + with nd.Cell(str(layout.get("name") or "layout")) as top: + for instance_name, instance in (layout.get("instances") or {}).items(): + component = str(instance.get("component") or "") + asset = pdk_registry.resolve(component) + if not asset.gds_path: + raise FileNotFoundError(f"Unable to resolve _BB.gds for instance {instance_name}: {component}") + loaded = nd.load_gds(asset.gds_path) + loaded.put(_number(instance.get("x")), _number(instance.get("y")), _number(instance.get("rotation"))) + nd.export_gds(top, filename=png_path) + raise RuntimeError( + "Nazca can build the placed GDS, but SVG preview export requires gdstk in this backend." + ) + + +def _load_local_layout(component: str, project_dir: Optional[str]) -> Optional[dict]: + if not project_dir or "/" in component or "\\" in component or component == "generate_with_forge": + return None + for ext in (".yml", ".yaml"): + path = os.path.join(project_dir, f"{component}{ext}") + if os.path.exists(path): + with open(path, "r", encoding="utf-8") as file: + return yaml.safe_load(file) or {} + return None + + +def _safe_cell_name(name, library) -> str: + base = "".join(ch if ch.isalnum() or ch in "._$" else "_" for ch in str(name)) or "layout" + candidate = base + counter = 1 + while _library_cell_by_name(library, candidate) is not None: + counter += 1 + candidate = f"{base}_{counter}" + return candidate + + +def _library_cell_by_name(library, name: str): + for cell in library.cells: + if cell.name == name: + return cell + return None + + +def _number(value, default=0.0) -> float: + try: + if value is None or value == "": + return default + return float(value) + except (TypeError, ValueError): + return default diff --git a/backend/pdk_registry.py b/backend/pdk_registry.py new file mode 100644 index 0000000..3c634c6 --- /dev/null +++ b/backend/pdk_registry.py @@ -0,0 +1,87 @@ +import os +from dataclasses import dataclass +from typing import Optional + +import yaml + + +@dataclass +class PdkAsset: + component: str + yaml_path: Optional[str] = None + gds_path: Optional[str] = None + metadata: Optional[dict] = None + + +class PdkRegistry: + """Resolve public PDK component names to metadata and public GDS assets.""" + + def __init__(self, public_root: str): + self.public_root = os.path.abspath(public_root) + self._asset_cache = {} + + def resolve(self, component: str) -> PdkAsset: + key = (component or "").strip().replace("\\", "/").strip("/") + if not key: + return PdkAsset(component=component) + if key in self._asset_cache: + return self._asset_cache[key] + + yaml_path = self._find_yaml(key) + gds_path = self._find_gds(key, yaml_path) + metadata = self._load_yaml(yaml_path) if yaml_path else None + asset = PdkAsset(component=component, yaml_path=yaml_path, gds_path=gds_path, metadata=metadata) + self._asset_cache[key] = asset + return asset + + def _find_yaml(self, key: str) -> Optional[str]: + direct = os.path.join(self.public_root, *key.split("/")) + candidates = [] + if direct.lower().endswith((".yml", ".yaml")): + candidates.append(direct) + else: + name = key.split("/")[-1] + candidates.append(os.path.join(direct, f"{name}.yml")) + candidates.append(os.path.join(direct, f"{name}.yaml")) + + for candidate in candidates: + if self._inside_root(candidate) and os.path.exists(candidate): + return os.path.abspath(candidate) + + name = key.split("/")[-1] + for root, dirs, files in os.walk(self.public_root): + if os.path.basename(root) == name: + for filename in files: + if filename.lower().endswith((".yml", ".yaml")): + return os.path.join(root, filename) + dirs.clear() + return None + + def _find_gds(self, key: str, yaml_path: Optional[str]) -> Optional[str]: + search_dir = os.path.dirname(yaml_path) if yaml_path else os.path.join(self.public_root, *key.split("/")) + name = key.split("/")[-1] + candidates = [ + os.path.join(search_dir, f"{name}_BB.gds"), + os.path.join(search_dir, f"{name}.gds"), + ] + for candidate in candidates: + if self._inside_root(candidate) and os.path.exists(candidate): + return os.path.abspath(candidate) + if os.path.isdir(search_dir): + for filename in sorted(os.listdir(search_dir)): + if filename.lower().endswith("_bb.gds"): + return os.path.join(search_dir, filename) + for filename in sorted(os.listdir(search_dir)): + if filename.lower().endswith(".gds"): + return os.path.join(search_dir, filename) + return None + + def _load_yaml(self, yaml_path: Optional[str]) -> Optional[dict]: + if not yaml_path: + return None + with open(yaml_path, "r", encoding="utf-8") as file: + return yaml.safe_load(file) or {} + + def _inside_root(self, path: str) -> bool: + target = os.path.abspath(path) + return target == self.public_root or target.startswith(self.public_root + os.sep) diff --git a/backend/server.py b/backend/server.py index 5b1c49d..d430cea 100644 --- a/backend/server.py +++ b/backend/server.py @@ -10,18 +10,29 @@ from flask import Flask, jsonify, send_from_directory, request, redirect, url_fo from werkzeug.security import check_password_hash import database from flask import Response +from gds_builder import build_project_gds +from layout_preview import create_layout_svg_from_gds +from pdk_registry import PdkRegistry +from technology_manifest import TechnologyManifestError, read_technology_manifest # --- Path Configurations --- BASE_DIR = os.path.dirname(os.path.abspath(__file__)) FRONTEND_DIR = os.path.join(BASE_DIR, '..', 'frontend') +REPO_ROOT = os.path.abspath(os.path.join(BASE_DIR, '..', '..')) +PDK_PUBLIC_ROOT = os.path.abspath(os.environ.get( + 'MXPIC_PDK_PUBLIC_ROOT', + os.path.join(REPO_ROOT, 'opt_pdk_public', 'foundries') +)) +EDA_PDK_ROOT = os.path.abspath(os.path.join(BASE_DIR, '..', 'mxpic', 'PDKs')) YML_PATH = os.path.join(BASE_DIR, '..', 'mxpic', 'PDKs', 'Silterra', 'directories.yaml') -COMPS_ROOT = os.path.join(BASE_DIR, '..', 'mxpic', 'PDKs', 'Silterra') +COMPS_ROOT = os.environ.get('MXPIC_COMPONENT_ROOT', PDK_PUBLIC_ROOT) # Define where your new icons folder is located (adjust if it's placed elsewhere) ICONS_DIR = os.path.join(BASE_DIR, 'icons') #build layout save path DATABASE_ROOT = os.path.abspath(os.path.join(BASE_DIR, '..', 'database')) +PDK_REGISTRY = PdkRegistry(PDK_PUBLIC_ROOT) app = Flask(__name__, template_folder=FRONTEND_DIR, static_folder=FRONTEND_DIR) @@ -101,6 +112,14 @@ def cell_file_path(project_name, cell_name): return os.path.join(project_root(project_name), f"{safe_name(cell_name, 'canvas_1')}.yml") +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 project_gds_path(project_name): + return os.path.join(project_root(project_name), f"{safe_name(project_name, 'project_1')}.gds") + + def project_meta_path(project_name): return os.path.join(project_root(project_name), ".project.json") @@ -118,6 +137,14 @@ def write_project_meta(project_name, meta): with open(project_meta_path(project_name), 'w', encoding='utf-8') as f: json.dump(meta, f, indent=2) + +def ensure_project_path(project_name): + layout_root = os.path.abspath(user_layout_root()) + target = os.path.abspath(project_root(project_name)) + if target != layout_root and not target.startswith(layout_root + os.sep): + raise ValueError("Invalid project path") + return target + # ... [Keep countSpaces and buildTree exactly as they are] ... def findComps(baseDir): @@ -271,7 +298,7 @@ def health_check(): def list_technologies(): """List technology choices from mxpic/PDKs//.""" technologies = [] - pdks_root = os.path.join(BASE_DIR, '..', 'mxpic', 'PDKs') + pdks_root = EDA_PDK_ROOT if os.path.isdir(pdks_root): for foundry in sorted(os.listdir(pdks_root)): foundry_path = os.path.join(pdks_root, foundry) @@ -290,6 +317,20 @@ def list_technologies(): return jsonify({"technologies": technologies}) +@app.route('/api/technologies///manifest', methods=['GET']) +@login_required_json +def get_technology_manifest(foundry, technology): + try: + manifest = read_technology_manifest( + EDA_PDK_ROOT, + safe_name(foundry, ''), + safe_name(technology, '') + ) + return jsonify({"manifest": manifest}) + except TechnologyManifestError as e: + return jsonify({"error": str(e)}), 404 + + @app.route('/api/profile', methods=['GET', 'PATCH']) @login_required_json def account_profile(): @@ -526,16 +567,83 @@ def save_layout(): with open(save_path, 'w', encoding='utf-8') as f: f.write(content) - record_action('layout.save', project=project, cell=cell, detail={"bytes": len(content)}) + svg_path = cell_svg_path(project, cell) + create_layout_svg_from_gds(content, svg_path, pdk_registry=PDK_REGISTRY, project_dir=project_root(project)) + + record_action('layout.save', project=project, cell=cell, detail={"bytes": len(content), "svg": svg_path}) return jsonify({ "message": "successfully saved", "project": project, "cell": cell, - "path": save_path + "path": save_path, + "svg_path": svg_path, + "svg_url": url_for('get_layout_svg', project_name=project, cell_name=cell) }), 200 except Exception as e: return jsonify({"error": str(e)}), 500 + + +@app.route('/api/projects//cells//layout.svg') +@login_required_json +def get_layout_svg(project_name, cell_name): + try: + project_dir = ensure_project_path(project_name) + svg_path = os.path.abspath(cell_svg_path(project_name, cell_name)) + if not svg_path.startswith(project_dir + os.sep): + return jsonify({"error": "Invalid SVG path"}), 400 + if not os.path.exists(svg_path): + return jsonify({"error": "Layout SVG not found"}), 404 + return no_cache_response(send_from_directory(os.path.dirname(svg_path), os.path.basename(svg_path), mimetype='image/svg+xml')) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + + +@app.route('/api/build-gds', methods=['POST']) +@login_required_json +def build_gds(): + data = request.get_json(silent=True) or {} + project = safe_name(data.get('project'), 'project_1') + try: + project_dir = ensure_project_path(project) + if not os.path.isdir(project_dir): + return jsonify({"error": "Project not found"}), 404 + output_path = project_gds_path(project) + result = build_project_gds(project_dir, output_path, PDK_PUBLIC_ROOT) + record_action('gds.build', project=project, detail={ + "path": result.output_path, + "engine": result.engine, + "warnings": result.warnings + }) + return jsonify({ + "message": "GDS built", + "project": project, + "path": result.output_path, + "gds_url": url_for('get_project_gds', project_name=project, filename=os.path.basename(result.output_path)), + "engine": result.engine, + "cells_built": result.cells_built, + "warnings": result.warnings + }) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@app.route('/api/projects//gds/') +@login_required_json +def get_project_gds(project_name, filename): + try: + project_dir = ensure_project_path(project_name) + safe_filename = safe_name(filename, f"{safe_name(project_name, 'project_1')}.gds") + if not safe_filename.lower().endswith('.gds'): + return jsonify({"error": "Invalid GDS filename"}), 400 + gds_path = os.path.abspath(os.path.join(project_dir, safe_filename)) + if not gds_path.startswith(project_dir + os.sep): + return jsonify({"error": "Invalid GDS path"}), 400 + if not os.path.exists(gds_path): + return jsonify({"error": "GDS not found"}), 404 + return send_from_directory(project_dir, safe_filename, as_attachment=True) + except ValueError as e: + return jsonify({"error": str(e)}), 400 diff --git a/backend/technology_manifest.py b/backend/technology_manifest.py new file mode 100644 index 0000000..cdacc01 --- /dev/null +++ b/backend/technology_manifest.py @@ -0,0 +1,28 @@ +import os + +import yaml + + +class TechnologyManifestError(Exception): + pass + + +def technology_manifest_path(pdks_root: str, foundry: str, technology: str) -> str: + base = os.path.abspath(pdks_root) + path = os.path.abspath(os.path.join(base, foundry, technology, "technology.yml")) + if not path.startswith(base + os.sep): + raise TechnologyManifestError("Invalid technology path") + return path + + +def read_technology_manifest(pdks_root: str, foundry: str, technology: str) -> dict: + path = technology_manifest_path(pdks_root, foundry, technology) + if not os.path.exists(path): + raise TechnologyManifestError("technology manifest not generated; run mxpic_forge technology export workflow") + with open(path, "r", encoding="utf-8") as file: + manifest = yaml.safe_load(file) or {} + if not isinstance(manifest.get("xsections"), dict): + raise TechnologyManifestError("technology manifest is missing xsections") + if not isinstance(manifest.get("defaults"), dict): + raise TechnologyManifestError("technology manifest is missing defaults") + return manifest diff --git a/database/admin/layout/mxpic_project_1/canvas_1.gds b/database/admin/layout/mxpic_project_1/canvas_1.gds new file mode 100644 index 0000000000000000000000000000000000000000..4b649bf9409e5b855f543f695bb500b3c3bb3b21 GIT binary patch literal 2098 zcmbW2O-NKx9L3MPdGj1(lCi>+#*ZXwhI;yDCUtZnDUp2{l(Q&ZJZ3~H2(gByFQOJL z5`v6~q+Ga5awCK(MD_u-3>Phg2wJ$XXfaR=)0w&5{~hm)3-1W6e#5!v{Lj1hy?GA? zH0=z04z1)TT=2nzMzpQ}4Xmwh5^S(e?^)6-=~Pj?(WVMnhI4Rjo^Po1@sK|5rHnys)EwmUlk z-3=I9G<^z2nFe6L*UA3YTWrNMf=1tcNw|IwkuE88y}Xd0UYAeL`k`F{%f4LBQ@=RR zNSEa6P~|f>uLB2B&wGTA_vT6SNSEYulzHk|ePoG#$;<6{dv+T|kT&;@*)-L39 zNxrUl{!lad2ijOUY9>4djs|kNBwsI@e_N8vOTMnn#DptHx9?r5tvU#3Va`d&?Wi0GT#?v?BjWu^LHhI zwo#aUQAweTyhpr$oIhYx(EG>3-#?jGUN{GrU{o3Bg1MFrT;@ zyO(KNqWCm&@o9-orm^f-x0n_xc5T8%(?Z3sjinz}ik%jpMlRavCe!!{^7E`RVr&0y z#d)l`;z}1>Eh(0*UKF#Gr-qsrfcCVST=7j@Z?*}Sa+e6~<9B`62ioPpP)K<{;XgP9 z=kQ_S8!X~8CoAFj@(!g{MlyaOmQYVLAO8~P#+#Fu2NJovT7shATaRJ%#|D$Jgw6NO ZUssW>|M&gn&pY%zci_IdobT0j`~xA@6ZQZA literal 0 HcmV?d00001 diff --git a/database/admin/layout/mxpic_project_1/canvas_1.svg b/database/admin/layout/mxpic_project_1/canvas_1.svg new file mode 100644 index 0000000..de15775 --- /dev/null +++ b/database/admin/layout/mxpic_project_1/canvas_1.svg @@ -0,0 +1,41 @@ + + + + + + + + + + +a0 +b0 +a1 +b1 + + + + + + + + + +a1 +a2 +b1 +b2 +a0 +b0 + + + + + + + + \ No newline at end of file diff --git a/database/admin/layout/mxpic_project_1/canvas_1.yml b/database/admin/layout/mxpic_project_1/canvas_1.yml new file mode 100644 index 0000000..53a2753 --- /dev/null +++ b/database/admin/layout/mxpic_project_1/canvas_1.yml @@ -0,0 +1,61 @@ +# ============================================= +# mxPIC Cell/Project Definition File +# ============================================= +schema_version: "2.0.0" +kind: cell +project: mxpic_project_1 +name: canvas_1 +type: composite +version: "1.0.0" + +# 1. External Ports (How this cell connects to the outside world) +ports: +- name: port + layer: WG_CORE + x: 0.0 + y: 0.0 + angle: 0.0 + width: 0.5 + +# 2. Instances (The sub-components dropped onto this canvas) +instances: + component_5: + component: Silterra/EMO1_2ML_CU_Al_RDL/primitives/bendings/Si_EUB_1310_H220_w2000_L50_QY_202604 + x: 570.0 + y: 320.0 + rotation: 0.0 + mirror: false + settings: + length: + + component_6: + component: Silterra/EMO1_2ML_CU_Al_RDL/primitives/multimode_interferometers/2x2MMI_1310nm_TE_Silterra_202603_ZKY + x: 244.0 + y: 257.0 + rotation: 0.0 + mirror: false + settings: + length: + +elements: + port: + type: port + x: 0.0 + y: 0.0 + angle: 0.0 + layer: WG_CORE + width: 0.5 + description: "" + +# 3. Bundles (Grouped links for multi-bus/parallel routing) +bundles: + output_bus: + routing_type: euler_bend + links: + - from: component_5:a1 + to: component_6:b2 + xsection: strip + family: optical + width: 0.45 + radius: 10 + routing_type: euler_bend \ No newline at end of file diff --git a/database/admin/layout/mxpic_project_1/mxpic_project_1.gds b/database/admin/layout/mxpic_project_1/mxpic_project_1.gds new file mode 100644 index 0000000000000000000000000000000000000000..126b13a5bdc7f38aea559e98edd768884d4f77bc GIT binary patch literal 1730 zcmcgt&ubGw6#n){#vrRCp;&}eytRnX$+lX15fuwc?Lpd#?Pb`+Dv>mXZP1F~P0&L@ za#DKGn^zG8{RevTpckRn_Mk;-1&>0;`H^8;aJnmq3(GgWeczim?|p9;1g?7>Wyi%s zIV09f2^zaV zEB}A)AE^3yTOdbd{Ybwf6el}v9&(gKROf2G2P`;oavSIA80Bb92-nFuo=229@2?H{M-60hHEx_nFY1!NP#H8!WBc`Y!!A-`SM> z1h)sZ-QdoqjSuI>XP__u=T;{{v7fRV6yGiROFuCegOZ=1*iYFFitjeQQA`^C6TVF| zx?9Bu$e=IX@$Zk6Z>Y~@iz`(D=(yE1-6{p?xYe|{rdpZiESViMXDRd0N66xGt6pQR p_|ENxT3cNC>fR1Y$v;3&d+uioe&eIA!C&6%!KZTxSLEaS_zlUuMMD4p literal 0 HcmV?d00001 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..35c69f8 --- /dev/null +++ b/database/admin/layout/mxpic_project_1/mxpic_project_1.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + +a0 +b0 +a1 +b1 + + + + + + + + + +a1 +a2 +b1 +b2 +a0 +b0 + + + + + + + + \ 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 94576c5..086024c 100644 --- a/database/admin/layout/mxpic_project_1/mxpic_project_1.yml +++ b/database/admin/layout/mxpic_project_1/mxpic_project_1.yml @@ -9,67 +9,34 @@ type: project version: "1.0.0" # 1. External Ports (How this cell connects to the outside world) -ports: -- name: port_3 - layer: WG_CORE - x: 359.0 - y: 447.0 - angle: 0.0 - width: 0.5 -- name: component_4 - layer: WG_CORE - x: 366.0 - y: 615.0 - angle: 0.0 - width: 0.5 +ports: [] # 2. Instances (The sub-components dropped onto this canvas) instances: - component_2: - component: EMO1_2ML_CU_Al_RDL/composite/Mach_Zender_modulators/MZM_1600G_L3000_GSSG_TRAIL_TypeA2_QY_v1_20260303 - x: 799.0 - y: 420.0 + canvas_1: + component: canvas_1 + x: 390.0 + y: 290.0 rotation: 0.0 mirror: false settings: length: -elements: - anchor_1: - type: anchor - x: 479.0 - y: 503.0 - angle: 0.0 - layer: WG_CORE - width: 0.5 - description: "" - port_3: - type: port - x: 359.0 - y: 447.0 - angle: 0.0 - layer: WG_CORE - width: 0.5 - description: "" - component_4: - type: port - x: 366.0 - y: 615.0 - angle: 0.0 - layer: WG_CORE - width: 0.5 - description: "" + component_7: + component: Silterra/EMO1_2ML_CU_Al_RDL/primitives/multimode_interferometers/2x2MMI_1310nm_TE_Silterra_202603_ZKY + x: 840.0 + y: 290.0 + rotation: 0.0 + mirror: false + settings: + length: + +elements: {} # 3. Bundles (Grouped links for multi-bus/parallel routing) bundles: output_bus: routing_type: euler_bend links: - - from: anchor_1:right - to: component_2:s1b - - from: anchor_1:left - to: port_3:port_3 - - from: component_2:s1b - to: component_2:s1b - - from: component_2:g2b - to: component_4:component_4 \ No newline at end of file + - from: component_7:a1 + to: canvas_1:port \ No newline at end of file diff --git a/database/mxpic_data.db b/database/mxpic_data.db index b4eb8c0c2095c05f30691019879a170d7a9f53c2..1c0ede1ef08b5e72fe278b58e14c84453a5374f6 100644 GIT binary patch literal 36864 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 b@!0$m=s+?gsKJUlxlBlIr$D4jzM}mf-u>9| delta 84 zcmZozz|`=7ae_1}4+8@O`$PqMMxKocQ|zUTIT@M78B2>(i;9_8fl`e8zkwtJJOA&^ md;xFyH~;qM6krx%^_ ({ ...DEFAULT_FORGE_ARGUMENTS, ...(overrides || {}) }); + const getTechnologyManifest = (manifest) => manifest || FALLBACK_TECHNOLOGY_MANIFEST; + + const getXsectionInfo = (xsection, manifest) => { + const technology = getTechnologyManifest(manifest); + return (technology.xsections && technology.xsections[xsection]) || technology.xsections.strip || {}; + }; + + const createRouteSettings = (manifest, overrides) => { + const technology = getTechnologyManifest(manifest); + const defaults = technology.defaults || FALLBACK_TECHNOLOGY_MANIFEST.defaults; + const xsection = (overrides && overrides.xsection) || defaults.xsection || 'strip'; + const xsectionInfo = getXsectionInfo(xsection, technology); + const family = (overrides && overrides.family) || xsectionInfo.family || defaults.family || 'optical'; + return { + xsection, + family, + width: Number((overrides && overrides.width) ?? xsectionInfo.default_width ?? defaults.width ?? 0.45), + radius: Number((overrides && overrides.radius) ?? xsectionInfo.default_radius ?? defaults.radius ?? 10), + routing_type: (overrides && overrides.routing_type) || defaults.routing_type || 'euler_bend', + widthEdited: Boolean(overrides && overrides.widthEdited) + }; + }; + + const updateRouteField = (route, key, value, manifest) => { + const current = createRouteSettings(manifest, route); + const numericFields = new Set(['width', 'radius']); + const nextValue = numericFields.has(key) ? Number(value || 0) : value; + return { + ...current, + [key]: nextValue, + widthEdited: key === 'width' ? true : current.widthEdited + }; + }; + + const updateRouteXsection = (route, xsection, manifest) => { + const technology = getTechnologyManifest(manifest); + const current = createRouteSettings(technology, route); + const xsectionInfo = getXsectionInfo(xsection, technology); + const next = { + ...current, + xsection, + family: xsectionInfo.family || current.family + }; + if (!current.widthEdited) { + next.width = Number(xsectionInfo.default_width ?? current.width); + } + next.radius = Number(xsectionInfo.default_radius ?? current.radius); + return next; + }; + + const routeStyleForSettings = (route, selected) => { + const settings = createRouteSettings(null, route); + const palette = { + strip: '#38bdf8', + rib_low: '#22c55e', + metal_1: '#f59e0b', + metal_2: '#f97316' + }; + const electrical = settings.family === 'electrical'; + const strokeWidth = electrical ? 3.5 : 2.4; + return { + type: electrical ? 'step' : 'smoothstep', + style: { + stroke: palette[settings.xsection] || palette.strip, + strokeWidth: selected ? strokeWidth + 1.2 : strokeWidth, + strokeDasharray: electrical ? '8 5' : undefined, + filter: selected ? 'drop-shadow(0 0 5px rgba(255,255,255,0.45))' : undefined + } + }; + }; + const isForgeComponent = (componentName) => componentName === FORGE_COMPONENT_LABEL || componentName === FORGE_COMPONENT_TYPE; const normalizeAngle = (angle) => { @@ -272,12 +360,93 @@ return `elements:\n${lines.join('\n')}`; }; + const buildBundlesYaml = (page, manifest) => { + const { nodes = [], edges = [] } = page || {}; + const nodeMap = {}; + nodes.forEach(n => { nodeMap[n.id] = n; }); + + let linksYaml = ''; + if (edges.length > 0) { + const linkLines = edges.map(edge => { + const sourceNode = nodeMap[edge.source]; + const targetNode = nodeMap[edge.target]; + const sourceName = sourceNode ? (sourceNode.data.componentDisplayName || sourceNode.id) : edge.source; + const targetName = targetNode ? (targetNode.data.componentDisplayName || targetNode.id) : edge.target; + const fromPort = edge.sourceHandle || 'unknown'; + const toPort = edge.targetHandle || 'unknown'; + const route = createRouteSettings(manifest, edge.data && edge.data.route); + 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}`; + }); + linksYaml = linkLines.join('\n'); + } + + return `# 3. Bundles (Grouped links for multi-bus/parallel routing) +bundles: + output_bus: + routing_type: euler_bend + links: +${linksYaml}`; + }; + + const getNodeCenter = (node) => { + if (!node) return null; + return { + x: Number((node.position && node.position.x) || 0), + y: Number((node.position && node.position.y) || 0) + }; + }; + + const orientation = (a, b, c) => { + const value = (b.y - a.y) * (c.x - b.x) - (b.x - a.x) * (c.y - b.y); + if (Math.abs(value) < 1e-9) return 0; + return value > 0 ? 1 : 2; + }; + + const segmentsIntersect = (p1, q1, p2, q2) => { + if (!p1 || !q1 || !p2 || !q2) return false; + const o1 = orientation(p1, q1, p2); + const o2 = orientation(p1, q1, q2); + const o3 = orientation(p2, q2, p1); + const o4 = orientation(p2, q2, q1); + return o1 !== o2 && o3 !== o4; + }; + + const findSameFamilyRouteCrossing = (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]); + 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 }; + } + } + return null; + }; + return { FORGE_COMPONENT_LABEL, FORGE_COMPONENT_TYPE, ELEMENT_COMPONENTS, DEFAULT_FORGE_ARGUMENTS, + FALLBACK_TECHNOLOGY_MANIFEST, createForgeArguments, + createRouteSettings, + updateRouteField, + updateRouteXsection, + routeStyleForSettings, + findSameFamilyRouteCrossing, isForgeComponent, normalizeAngle, portSideFromAngle, @@ -287,6 +456,7 @@ buildInstancesYaml, buildPageComponentPorts, buildCanvasPortsYaml, + buildBundlesYaml, buildPortsYaml, buildElementsYaml, buildSettingsYaml, diff --git a/frontend/canvas.html b/frontend/canvas.html index d40586d..eb8ecde 100644 --- a/frontend/canvas.html +++ b/frontend/canvas.html @@ -18,44 +18,48 @@ @@ -773,7 +978,14 @@ buildInstancesYaml, buildPageComponentPorts, buildCanvasPortsYaml, - buildElementsYaml + buildElementsYaml, + buildBundlesYaml: buildRouteBundlesYaml, + createRouteSettings, + updateRouteField, + updateRouteXsection, + routeStyleForSettings, + findSameFamilyRouteCrossing, + FALLBACK_TECHNOLOGY_MANIFEST } = window.MxpicCanvasHelpers; @@ -1010,6 +1222,61 @@ ); }; + const LayoutSvgPreview = ({ page }) => { + const [layoutScale, setLayoutScale] = useState(100); + const normalizedScale = Math.min(800, Math.max(10, Number(layoutScale) || 100)); + + const updateScale = (value) => { + setLayoutScale(Math.min(800, Math.max(10, Number(value) || 100))); + }; + + return ( +
+
+ + +
+
+
+
+ {`${page.name} +
+
+
+
+ ); + }; + const EditableCanvasTabName = ({ page, active, onRename }) => { const [value, setValue] = useState(page.name); @@ -1412,7 +1679,7 @@ return null; }; - const LeftPanel = ({ projectTreeItems, library, treeKey, expanded, onToggle, treeRef, width, onOpenComposite, onOpenProject, onSelectInstance, onRenameCanvas, onDeleteCanvas, projectExpanded, onProjectToggle, projectTreeRef, projectTreeKey }) => { + const LeftPanel = ({ projectTreeItems, library, treeKey, expanded, onToggle, treeRef, width, onOpenComposite, onOpenProject, onSelectInstance, onRenameCanvas, onDeleteCanvas, onBuildGds, buildGdsBusy, projectExpanded, onProjectToggle, projectTreeRef, projectTreeKey }) => { const [projectPanelHeight, setProjectPanelHeight] = useState(270); const [resizingProjectPanel, setResizingProjectPanel] = useState(false); const leftPanelRef = useRef(null); @@ -1452,9 +1719,14 @@
Project Tree - +
+ + +
{projectTreeItems && projectTreeItems.length > 0 ? ( @@ -1500,7 +1772,7 @@ ); }; - const RightPanel = ({ selectedNode, width, onRenameComponent, onUpdateNode }) => { + const RightPanel = ({ selectedNode, selectedEdge, technologyManifest, width, onRenameComponent, onUpdateNode, onUpdateEdgeRoute }) => { const [componentData, setComponentData] = useState(null); const [loading, setLoading] = useState(false); const [enlarged, setEnlarged] = useState(null); @@ -1595,6 +1867,63 @@ const selectedIsPort = selectedNode && (selectedNode.type === 'portNode' || selectedNode.data?.elementType === 'port'); const selectedIsAnchor = selectedNode?.data?.elementType === 'anchor'; + if (selectedEdge) { + const route = createRouteSettings(technologyManifest, selectedEdge.data?.route); + const xsections = Object.keys((technologyManifest || FALLBACK_TECHNOLOGY_MANIFEST).xsections || {}); + const routingTypes = (technologyManifest || FALLBACK_TECHNOLOGY_MANIFEST).routing_types || ['euler_bend', 'standard_bend']; + return ( + + ); + } + const updateForgeArgument = (key, value, type) => { if (!selectedNode) return; let nextValue = value; @@ -2086,14 +2415,18 @@ const [gridSnap, setGridSnap] = useState(false); const [themeMode, setThemeMode] = useState(() => localStorage.getItem('mxpic-theme') || 'dark'); const [logs, setLogs] = useState([{ time: new Date().toLocaleTimeString(), message: 'Editor ready.' }]); + const [buildGdsBusy, setBuildGdsBusy] = useState(false); + const [projectTechnology, setProjectTechnology] = useState(''); + const [technologyManifest, setTechnologyManifest] = useState(FALLBACK_TECHNOLOGY_MANIFEST); const [clipboard, setClipboard] = useState({ nodes: [] }); const initializedRef = useRef(false); const activePage = useMemo(() => pages.find(p => p.id === activePageId) || null, [pages, activePageId]); - const currentNodes = activePage ? activePage.nodes : []; - const currentEdges = activePage ? activePage.edges : []; + const currentNodes = activePage && Array.isArray(activePage.nodes) ? activePage.nodes : []; + const currentEdges = activePage && Array.isArray(activePage.edges) ? activePage.edges : []; + const selectedEdge = useMemo(() => currentEdges.find(edge => edge.selected) || null, [currentEdges]); const [projectCompositeMap, setProjectCompositeMap] = useState({}); const [standaloneComposites, setStandaloneComposites] = useState([]); @@ -2108,6 +2441,30 @@ setLogs(prev => [...prev.slice(-80), { time: new Date().toLocaleTimeString(), message }]); }, []); + const loadTechnologyManifest = useCallback(async (technologyId) => { + if (!technologyId || !technologyId.includes('/')) { + setTechnologyManifest(FALLBACK_TECHNOLOGY_MANIFEST); + return FALLBACK_TECHNOLOGY_MANIFEST; + } + const [foundry, technology] = technologyId.split('/'); + try { + const response = await fetch(`/api/technologies/${encodeURIComponent(foundry)}/${encodeURIComponent(technology)}/manifest`); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + addLog(data.error || 'Technology manifest not available; using fallback route defaults.'); + setTechnologyManifest(FALLBACK_TECHNOLOGY_MANIFEST); + return FALLBACK_TECHNOLOGY_MANIFEST; + } + const manifest = data.manifest || FALLBACK_TECHNOLOGY_MANIFEST; + setTechnologyManifest(manifest); + return manifest; + } catch (error) { + addLog('Technology manifest load failed: ' + error.message); + setTechnologyManifest(FALLBACK_TECHNOLOGY_MANIFEST); + return FALLBACK_TECHNOLOGY_MANIFEST; + } + }, [addLog]); + const componentDataCacheRef = useRef(new Map()); const loadComponentMetadata = useCallback(async (componentName) => { @@ -2195,9 +2552,14 @@ if (!activePageId) return; setPages(prev => prev.map(p => { if (p.id !== activePageId) return p; - return { ...p, edges: applyEdgeChanges(changes, p.edges) }; + const styledEdges = applyEdgeChanges(changes, p.edges).map(edge => { + const route = createRouteSettings(technologyManifest, edge.data?.route); + const view = routeStyleForSettings(route, edge.selected); + return { ...edge, type: view.type, style: view.style, data: { ...edge.data, route } }; + }); + return { ...p, edges: styledEdges }; })); - }, [activePageId]); + }, [activePageId, technologyManifest]); const handleUpdateNode = useCallback((nodeId, update) => { if (!activePageId) return; @@ -2224,6 +2586,31 @@ })); }, [activePageId]); + const handleUpdateEdgeRoute = useCallback((edgeId, nextRoute) => { + if (!activePageId) return; + setPages(prev => prev.map(p => { + if (p.id !== activePageId) return p; + const nodeMap = Object.fromEntries(p.nodes.map(node => [node.id, node])); + let rejected = false; + const nextEdges = p.edges.map(edge => { + if (edge.id !== edgeId) return edge; + const route = createRouteSettings(technologyManifest, nextRoute); + const view = routeStyleForSettings(route, edge.selected); + const candidate = { ...edge, type: view.type, style: view.style, data: { ...edge.data, route } }; + const conflict = findSameFamilyRouteCrossing(candidate, p.edges, nodeMap, technologyManifest); + if (conflict) { + const source = nodeMap[conflict.conflictEdge.source]?.data?.componentDisplayName || conflict.conflictEdge.source; + const target = nodeMap[conflict.conflictEdge.target]?.data?.componentDisplayName || conflict.conflictEdge.target; + addLog(`Route update rejected: ${route.family} route crosses ${source} to ${target}.`); + rejected = true; + return edge; + } + return candidate; + }); + return rejected ? p : { ...p, edges: nextEdges }; + })); + }, [activePageId, technologyManifest, addLog]); + const handleCopy = useCallback(() => { if (!activePage) return; const selectedNodes = activePage.nodes.filter(n => n.selected); @@ -2436,14 +2823,17 @@ const sourceId = nodeNameMap[fromInst]; const targetId = nodeNameMap[toInst]; if (sourceId && targetId) { + const route = createRouteSettings(technologyManifest, link); + const view = routeStyleForSettings(route, false); newEdges.push({ id: `edge-${sourceId}-${fromPort}-${targetId}-${toPort}`, source: sourceId, target: targetId, sourceHandle: fromPort, targetHandle: toPort, - type: 'smoothstep', - style: { stroke: 'var(--accent)', strokeWidth: 2 }, + type: view.type, + style: view.style, + data: { route }, }); } } @@ -2524,7 +2914,7 @@ input.addEventListener('change', handleFile); return () => input.removeEventListener('change', handleFile); - }, [library]); + }, [library, technologyManifest]); useEffect(() => { setProjectCompositeMap(prev => { @@ -2570,7 +2960,7 @@ return category; }; - const pageFromYaml = (cellName, content) => { + const pageFromYaml = (cellName, content, manifest) => { const doc = jsyaml.load(content) || {}; const firstPort = Array.isArray(doc.ports) ? doc.ports[0] : null; const pagePort = firstPort @@ -2627,14 +3017,17 @@ const sourceId = nodeNameMap[fromInst]; const targetId = nodeNameMap[toInst]; if (!sourceId || !targetId) return; + const route = createRouteSettings(manifest, link); + const view = routeStyleForSettings(route, false); edges.push({ id: `edge-${sourceId}-${fromPort}-${targetId}-${toPort}`, source: sourceId, target: targetId, sourceHandle: fromPort, targetHandle: toPort, - type: 'smoothstep', - style: { stroke: 'var(--accent)', strokeWidth: 2 }, + type: view.type, + style: view.style, + data: { route }, }); }); } @@ -2654,6 +3047,8 @@ try { const response = await fetch(`/api/projects/${encodeURIComponent(currentProjectName)}`); if (!response.ok) { + setProjectTechnology(''); + setTechnologyManifest(FALLBACK_TECHNOLOGY_MANIFEST); setPages([projectPage]); setActivePageId(projectPage.id); setProjectCompositeMap({ [currentProjectName]: [] }); @@ -2661,7 +3056,10 @@ } const data = await response.json(); - const cellPages = (data.cells || []).map(cell => pageFromYaml(cell.name, cell.content)); + const technology = data.technology || ''; + setProjectTechnology(technology); + const manifest = await loadTechnologyManifest(technology); + const cellPages = (data.cells || []).map(cell => pageFromYaml(cell.name, cell.content, manifest)); setPages([projectPage, ...cellPages]); setActivePageId(projectPage.id); setProjectCompositeMap({ [currentProjectName]: cellPages.map(page => page.name) }); @@ -2672,6 +3070,8 @@ }); setCompositeTrees(nextTrees); } catch (error) { + setProjectTechnology(''); + setTechnologyManifest(FALLBACK_TECHNOLOGY_MANIFEST); setPages([projectPage]); setActivePageId(projectPage.id); setProjectCompositeMap({ [currentProjectName]: [] }); @@ -2679,7 +3079,7 @@ }; loadProject(); - }, [library, currentProjectName]); + }, [library, currentProjectName, loadTechnologyManifest]); useEffect(() => { if (activePage && reactFlowInstance) { @@ -3241,9 +3641,26 @@ if (!activePageId) return; setPages(prev => prev.map(p => { if (p.id !== activePageId) return p; - return { ...p, edges: addEdge({ ...connection, type: 'smoothstep', style: { stroke: 'var(--accent)', strokeWidth: 2 } }, p.edges) }; + const route = createRouteSettings(technologyManifest); + const view = routeStyleForSettings(route, false); + const candidate = { + ...connection, + id: `edge-${connection.source}-${connection.sourceHandle || 'port'}-${connection.target}-${connection.targetHandle || 'port'}-${Date.now()}`, + type: view.type, + style: view.style, + data: { route }, + }; + const nodeMap = Object.fromEntries(p.nodes.map(node => [node.id, node])); + const conflict = findSameFamilyRouteCrossing(candidate, p.edges, nodeMap, technologyManifest); + if (conflict) { + const source = nodeMap[conflict.conflictEdge.source]?.data?.componentDisplayName || conflict.conflictEdge.source; + const target = nodeMap[conflict.conflictEdge.target]?.data?.componentDisplayName || conflict.conflictEdge.target; + addLog(`Connection rejected: ${route.family} route crosses ${source} to ${target}.`); + return p; + } + return { ...p, edges: addEdge(candidate, p.edges) }; })); - }, [activePageId]); + }, [activePageId, technologyManifest, addLog]); const expandAll = useCallback(() => { if (treeContainerRef.current) { @@ -3399,35 +3816,55 @@ }; }, [pages, library]); - const buildBundlesYaml = (page) => { - const { nodes, edges } = page; - const nodeMap = {}; - nodes.forEach(n => { nodeMap[n.id] = n; }); + const buildBundlesYaml = useCallback((page) => { + return buildRouteBundlesYaml(page, technologyManifest); + }, [technologyManifest]); - let linksYaml = ''; - if (edges.length > 0) { - const linkLines = edges.map(edge => { - const sourceNode = nodeMap[edge.source]; - const targetNode = nodeMap[edge.target]; - const sourceName = sourceNode ? (sourceNode.data.componentDisplayName || sourceNode.id) : edge.source; - const targetName = targetNode ? (targetNode.data.componentDisplayName || targetNode.id) : edge.target; - const fromPort = edge.sourceHandle || 'unknown'; - const toPort = edge.targetHandle || 'unknown'; - return ` - from: ${sourceName}:${fromPort}\n to: ${targetName}:${toPort}`; - }); - linksYaml = linkLines.join('\n'); + const validateRouteCrossings = useCallback((page) => { + if (!page || !Array.isArray(page.edges)) return true; + const nodeMap = Object.fromEntries((page.nodes || []).map(node => [node.id, node])); + for (const edge of page.edges) { + const conflict = findSameFamilyRouteCrossing(edge, page.edges, nodeMap, technologyManifest); + if (conflict) { + const route = createRouteSettings(technologyManifest, edge.data?.route); + const source = nodeMap[edge.source]?.data?.componentDisplayName || edge.source; + const target = nodeMap[edge.target]?.data?.componentDisplayName || edge.target; + const conflictSource = nodeMap[conflict.conflictEdge.source]?.data?.componentDisplayName || conflict.conflictEdge.source; + const conflictTarget = nodeMap[conflict.conflictEdge.target]?.data?.componentDisplayName || conflict.conflictEdge.target; + addLog(`Build blocked: ${route.family} route ${source} to ${target} crosses ${conflictSource} to ${conflictTarget}.`); + return false; + } } + return true; + }, [technologyManifest, addLog]); - return `# 3. Bundles (Grouped links for multi-bus/parallel routing) -bundles: - output_bus: - routing_type: euler_bend - links: -${linksYaml}`; - }; + const openLayoutPreview = useCallback((cellName, svgUrl) => { + if (!cellName || !svgUrl) return; + const layoutTabId = `layout-${currentProjectName}-${cellName}`; + setPages(prev => { + const existing = prev.find(page => page.id === layoutTabId); + if (existing) { + return prev.map(page => page.id === layoutTabId + ? { ...page, name: `${cellName}:layout`, type: 'layoutPreview', svgUrl, nodes: [], edges: [], isClosed: false } + : page + ); + } + return prev.concat({ + id: layoutTabId, + name: `${cellName}:layout`, + type: 'layoutPreview', + svgUrl, + nodes: [], + edges: [], + isClosed: false + }); + }); + setActivePageId(layoutTabId); + }, [currentProjectName]); const handleBuildLayout = useCallback(async () => { if (!activePage) return; + if (!validateRouteCrossings(activePage)) return; const header = `# ============================================= # mxPIC Cell/Project Definition File # ============================================= @@ -3483,10 +3920,41 @@ ${bundlesBlock}`; const result = await response.json(); addLog('Successfully saved: ' + result.path); + if (result.svg_url) { + openLayoutPreview(activePage.name, result.svg_url); + } } catch (err) { addLog('Save error: ' + err.message); } - }, [activePage, library, buildBundlesYaml, findComponentPath, currentProjectName, addLog]); + }, [activePage, library, buildBundlesYaml, findComponentPath, currentProjectName, addLog, openLayoutPreview, validateRouteCrossings]); + + const handleBuildGds = useCallback(async () => { + if (buildGdsBusy) return; + const invalidPage = pages.find(page => page.type !== 'layoutPreview' && !validateRouteCrossings(page)); + if (invalidPage) return; + setBuildGdsBusy(true); + try { + const response = await fetch('/api/build-gds', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ project: currentProjectName }), + }); + const result = await response.json().catch(() => ({})); + if (!response.ok) { + const detail = result.error ? `: ${result.error}` : ` (HTTP ${response.status})`; + addLog(`Build GDS failed${detail}`); + return; + } + const warningText = result.warnings && result.warnings.length > 0 + ? ` (${result.warnings.length} warnings)` + : ''; + addLog(`GDS built with ${result.engine}: ${result.path}${warningText}`); + } catch (err) { + addLog(`Build GDS network error: ${err.message}. Check that the Flask server is running from the same host and Python environment.`); + } finally { + setBuildGdsBusy(false); + } + }, [buildGdsBusy, currentProjectName, addLog, pages, validateRouteCrossings]); const onNodeDoubleClick = useCallback((event, node) => { if (node.data?.type === 'composite') { @@ -3505,6 +3973,8 @@ ${bundlesBlock}`; onSelectInstance={selectInstanceInPage} onRenameCanvas={renameCanvas} onDeleteCanvas={deleteCanvas} + onBuildGds={handleBuildGds} + buildGdsBusy={buildGdsBusy} projectExpanded={projectExpanded} onProjectToggle={handleProjectToggle} projectTreeRef={projectTreeContainerRef} @@ -3560,49 +4030,39 @@ ${bundlesBlock}`;
- {activePage && ( + {activePage && activePage.type !== 'layoutPreview' && ( )} - - - - + {activePage && activePage.type === 'layoutPreview' ? ( + + ) : ( + + + + + )}
{logs.map((entry, index) => ( @@ -3614,9 +4074,12 @@ ${bundlesBlock}`;
); diff --git a/mxpic/PDKs/Silterra/EMO1_2ML_CU_Al_RDL/technology.yml b/mxpic/PDKs/Silterra/EMO1_2ML_CU_Al_RDL/technology.yml new file mode 100644 index 0000000..a05fcd7 --- /dev/null +++ b/mxpic/PDKs/Silterra/EMO1_2ML_CU_Al_RDL/technology.yml @@ -0,0 +1,73 @@ +schema_version: 1.0.0 +foundry: Silterra +technology: EMO1_2ML_CU_Al_RDL +source_class: mxpic.foundries.Silterra.EOM1_2ML_CU_RDL +constants: + STD_SMWG_WIDTH: 0.45 + SLAB_GROWTH: 2 + W_METAL_MIN: 5 + SPACING_HEATER_MIN: 2 + SPACING_METAL_MIN: 4 + W_HEATER_MIN: 3 +layers: + WG_HM: {layer: 275, datatype: 0} + WG_STRIP: {layer: 101, datatype: 251} + WG_LOWRIB: {layer: 100, datatype: 90} + WG_HIGHRIB: {layer: 232, datatype: 0} + HEATER: {layer: 29, datatype: 30} + CT_SI: {layer: 268, datatype: 0} + CT_GE: {layer: 35, datatype: 0} + UTV: {layer: 172, datatype: 0} + RDL_VIA: {layer: 194, datatype: 0} + UTM: {layer: 173, datatype: 0} + UTM2: {layer: 197, datatype: 0} + RDL_MET: {layer: 195, datatype: 0} + PAD_ELE: {layer: 100, datatype: 170} + PAD_OPTICAL: {layer: 100, datatype: 160} + PAD_AL: {layer: 145, datatype: 0} + WG_N: {layer: 263, datatype: 0} + SiN_Rib_WG: {layer: 63, datatype: 30} + SSIN0: {layer: 283, datatype: 0} + SSIN1: {layer: 289, datatype: 0} + SSIN2: {layer: 290, datatype: 0} + SSIN3: {layer: 291, datatype: 0} +routing_types: + - euler_bend + - standard_bend +defaults: + xsection: strip + family: optical + width: 0.45 + radius: 10 + routing_type: euler_bend +xsections: + strip: + family: optical + default_width: 0.45 + default_radius: 10 + layers: + - {layer: WG_HM, growx: 0, growy: 0} + - {layer: WG_STRIP, growx: 4, growy: 4} + rib_low: + family: optical + default_width: 0.45 + default_radius: 10 + layers: + - {layer: WG_HM, growx: 0, growy: 0} + - {layer: WG_SRIB, growx: 3, growy: 3} + - {layer: WG_STRIP, leftedge: [-0.5, -3], rightedge: [-0.5, -3.5]} + - {layer: WG_STRIP, leftedge: [0.5, 3.5], rightedge: [0.5, 3]} + metal_1: + family: electrical + default_width: 5 + default_radius: 10 + layers: + - {layer: UTM, growx: 0, growy: 0} + - {layer: SSIN0, growx: 2.5, growy: 2.5} + metal_2: + family: electrical + default_width: 5 + default_radius: 10 + layers: + - {layer: UTM2, growx: 0, growy: 0} + - {layer: SSIN1, growx: 2.5, growy: 2.5} diff --git a/tests/canvas-helpers.test.js b/tests/canvas-helpers.test.js index fd735d0..d5ced1e 100644 --- a/tests/canvas-helpers.test.js +++ b/tests/canvas-helpers.test.js @@ -145,3 +145,87 @@ assert(instancesWithoutElements.includes('component_1:')); const multiPortComponentPorts = helpers.buildPageComponentPorts(null, elementNodes); assert.deepStrictEqual(multiPortComponentPorts.in0, { x: 10, y: 20, a: 180, width: 0.7 }); + +const technologyManifest = { + defaults: { xsection: 'strip', width: 0.45, radius: 10, routing_type: 'euler_bend' }, + xsections: { + strip: { family: 'optical', default_width: 0.45 }, + rib_low: { family: 'optical', default_width: 0.5 }, + metal_1: { family: 'electrical', default_width: 5 }, + metal_2: { family: 'electrical', default_width: 6 } + }, + routing_types: ['euler_bend', 'standard_bend'] +}; + +const routeDefaults = helpers.createRouteSettings(technologyManifest); +assert.deepStrictEqual(routeDefaults, { + xsection: 'strip', + family: 'optical', + width: 0.45, + radius: 10, + routing_type: 'euler_bend', + widthEdited: false +}); + +const metalRoute = helpers.updateRouteXsection(routeDefaults, 'metal_1', technologyManifest); +assert.strictEqual(metalRoute.family, 'electrical'); +assert.strictEqual(metalRoute.width, 5); + +const manuallyEditedWidth = helpers.updateRouteField(routeDefaults, 'width', 0.62, technologyManifest); +const changedXsection = helpers.updateRouteXsection(manuallyEditedWidth, 'rib_low', technologyManifest); +assert.strictEqual(changedXsection.width, 0.62); +assert.strictEqual(changedXsection.family, 'optical'); + +const styledStrip = helpers.routeStyleForSettings({ xsection: 'strip', family: 'optical' }, false); +const styledMetal = helpers.routeStyleForSettings({ xsection: 'metal_1', family: 'electrical' }, true); +assert.notStrictEqual(styledStrip.style.stroke, styledMetal.style.stroke); +assert(styledMetal.style.strokeDasharray, 'electrical routes should use a visibly different line treatment'); +assert(styledMetal.style.strokeWidth > styledStrip.style.strokeWidth); + +const routeYaml = helpers.buildBundlesYaml({ + nodes: [ + { id: 'a', data: { componentDisplayName: 'inst_a' } }, + { id: 'b', data: { componentDisplayName: 'inst_b' } } + ], + edges: [{ + id: 'edge-a-b', + source: 'a', + target: 'b', + sourceHandle: 'out', + targetHandle: 'in', + data: { route: { xsection: 'metal_1', family: 'electrical', width: 5, radius: 20, routing_type: 'standard_bend' } } + }] +}, technologyManifest); +assert(routeYaml.includes('xsection: metal_1')); +assert(routeYaml.includes('family: electrical')); +assert(routeYaml.includes('radius: 20')); +assert(routeYaml.includes('routing_type: standard_bend')); + +const edgeA = { + id: 'edge-a-b', + source: 'a', + target: 'b', + data: { route: { family: 'optical' } } +}; +const edgeB = { + id: 'edge-c-d', + source: 'c', + target: 'd', + data: { route: { family: 'optical' } } +}; +const edgeC = { + id: 'edge-e-f', + source: 'e', + target: 'f', + data: { route: { family: 'electrical' } } +}; +const crossingNodes = { + a: { position: { x: 0, y: 0 } }, + b: { position: { x: 100, y: 100 } }, + c: { position: { x: 0, y: 100 } }, + d: { position: { x: 100, y: 0 } }, + e: { position: { x: 0, y: 100 } }, + f: { position: { x: 100, y: 0 } } +}; +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 new file mode 100644 index 0000000..96d6d4e --- /dev/null +++ b/tests/layout-backend-static.test.js @@ -0,0 +1,66 @@ +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +const root = path.resolve(__dirname, '..'); +const backendDir = path.join(root, 'backend'); +const serverPy = fs.readFileSync(path.join(backendDir, 'server.py'), 'utf8'); + +assert( + fs.existsSync(path.join(backendDir, 'layout_preview.py')), + 'backend/layout_preview.py should generate SVG previews from saved layout YAML' +); +assert( + fs.existsSync(path.join(backendDir, 'pdk_registry.py')), + 'backend/pdk_registry.py should resolve public PDK YAML/GDS assets' +); +assert( + fs.existsSync(path.join(backendDir, 'gds_builder.py')), + 'backend/gds_builder.py should build hierarchical GDS from saved project YAML' +); +assert( + serverPy.includes('create_layout_svg_from_gds'), + 'save-layout route should create a GDS-derived layout SVG preview' +); +assert( + serverPy.includes('svg_url'), + 'save-layout response should include an svg_url for the new layout tab' +); +assert( + serverPy.includes("@app.route('/api/projects//cells//layout.svg')"), + 'server should expose a route for saved cell SVG previews' +); +assert( + serverPy.includes("@app.route('/api/build-gds'"), + 'server should expose a Build GDS API route' +); +assert( + serverPy.includes("@app.route('/api/technologies///manifest'"), + 'server should expose a technology manifest API route' +); +assert( + fs.existsSync(path.join(backendDir, 'technology_manifest.py')), + 'backend/technology_manifest.py should read generated technology manifests' +); + +const techManifestPath = path.join(root, 'mxpic', 'PDKs', 'Silterra', 'EMO1_2ML_CU_Al_RDL', 'technology.yml'); +assert( + fs.existsSync(techManifestPath), + 'Silterra technology.yml should be generated into the EDA PDK folder' +); +const techManifest = fs.readFileSync(techManifestPath, 'utf8'); +for (const xsection of ['strip', 'rib_low', 'metal_1', 'metal_2']) { + assert(techManifest.includes(`${xsection}:`), `technology.yml should include ${xsection}`); +} +assert(techManifest.includes('family: optical'), 'technology.yml should classify optical xsections'); +assert(techManifest.includes('family: electrical'), 'technology.yml should classify electrical xsections'); + +const layoutPreviewPy = fs.readFileSync(path.join(backendDir, 'layout_preview.py'), 'utf8'); +assert( + layoutPreviewPy.includes('read_gds') || layoutPreviewPy.includes('load_gds'), + 'layout_preview.py should load public _BB.gds geometry, not draw only schematic boxes' +); +assert( + layoutPreviewPy.includes('_BB.gds') || layoutPreviewPy.includes('gds_path'), + 'layout_preview.py should resolve public GDS assets for placed components' +); diff --git a/tests/layout-ui-wiring.test.js b/tests/layout-ui-wiring.test.js new file mode 100644 index 0000000..2b6a8d0 --- /dev/null +++ b/tests/layout-ui-wiring.test.js @@ -0,0 +1,75 @@ +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('Build GDS'), + 'Project Tree header should include a Build GDS button' +); +assert( + canvasHtml.includes('/api/build-gds'), + 'Build GDS button should call the backend build-gds API' +); +assert( + canvasHtml.includes(':layout'), + 'Build Layout should open an SVG preview tab named like canvas_1:layout' +); +assert( + canvasHtml.includes('svg_url'), + 'Build Layout should use the backend svg_url response' +); +assert( + canvasHtml.includes('layoutPreview'), + 'canvas pages should support a layoutPreview tab type' +); +assert( + canvasHtml.includes('LayoutSvgPreview'), + 'layout preview tabs should use the auto-scaling SVG viewer' +); +assert( + canvasHtml.includes('layoutScale'), + 'layout SVG preview should expose an editable scale value' +); +assert( + canvasHtml.includes('objectFit: \'contain\''), + '100% layout preview scale should fit the full SVG within the screen' +); +assert( + canvasHtml.includes('className="build-gds-btn"'), + 'Build GDS should use a dedicated polished button class' +); +assert( + canvasHtml.includes('buildGdsBusy'), + 'Build GDS should expose an in-progress state to prevent duplicate requests' +); +assert( + canvasHtml.includes('Build GDS network error'), + 'Build GDS fetch failures should produce a specific network diagnostic' +); +assert( + canvasHtml.includes('className="build-layout-btn"'), + 'Build Layout should use the polished primary action class' +); +assert( + canvasHtml.includes('Route Editor'), + 'Selecting an edge should expose a route editor' +); +assert( + canvasHtml.includes('selectedEdge'), + 'canvas should track selected edges separately from selected nodes' +); +assert( + canvasHtml.includes('technologyManifest'), + 'canvas should load the selected technology manifest' +); +assert( + canvasHtml.includes('standard_bend'), + 'route editor should offer standard_bend as a routing type' +); +assert( + canvasHtml.includes('findSameFamilyRouteCrossing'), + 'canvas should validate same-family route crossings' +);