# ----------------------------------------------------------------------------- # Description: Backend integration wrapper for project GDS generation and build result handling. # Inside functions: build_project_gds, _build_with_mxpic_router, _load_project_cells, _ordered_cell_names, _cells_have_links, _cells_have_elements, _build_with_gdstk, _import_public_gds, _build_with_nazca, _build_nazca_element_cells, _build_nazca_element_cell, _element_port_offset, _safe_cell_name, _library_cell_by_name, _int, _number # Developer : Qin Yue @ 2026 # Organization : OptiHK Limited # ----------------------------------------------------------------------------- import math import os import sys from dataclasses import dataclass, field from typing import Dict, List import yaml from pdk_registry import PdkRegistry @dataclass class BuildResult: """Container for GDS build output paths, status details, and engine metadata.""" output_path: str engine: str cells_built: List[str] = field(default_factory=list) warnings: List[str] = field(default_factory=list) def build_project_gds( project_dir: str, output_path: str, pdk_public_root: str, technology_manifest_path: str = None, prefer_full_gds: bool = False, ) -> 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") # Prefer the routed builder whenever it is available because it understands # bundle links, anchor connections, and PDK-aware routing rules. try: return _build_with_mxpic_router( project_dir, output_path, pdk_public_root, technology_manifest_path, prefer_full_gds, ) except ImportError as router_error: if _cells_have_links(cells): raise RuntimeError( "Routed Build GDS requires mxpic_router, nazca, and mxpic_forge when layout links are present. " f"Router import failed: {router_error}" ) from router_error # Placement-only projects can still be exported with local GDS engines when # the routed builder is not installed. registry = PdkRegistry(pdk_public_root, prefer_full_gds=prefer_full_gds) os.makedirs(os.path.dirname(output_path), exist_ok=True) # Port/Anchor elements are logical pin-bearing devices. Use Nazca for these # layouts so each element can be a placed cell with local nd.Pin metadata. if _cells_have_elements(cells): try: return _build_with_nazca(cells, output_path, registry) except ImportError as nazca_error: raise RuntimeError( "Build GDS with Port/Anchor elements requires Nazca so element cells can preserve nd.Pin metadata. " f"Nazca import failed: {nazca_error}" ) from nazca_error # gdstk is the preferred fallback for placement-only projects without # Port/Anchor element pin metadata; Nazca remains a secondary fallback for # environments where gdstk is not installed. 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 _build_with_mxpic_router( project_dir: str, output_path: str, pdk_root: str, technology_manifest_path: str, prefer_full_gds: bool, ) -> BuildResult: """Delegate routed project GDS generation to the external mxpic_router package.""" # mxpic_router lives beside this repository during local development, so add # that sibling checkout to sys.path only when it exists. router_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "mxpic_router")) if os.path.isdir(router_root) and router_root not in sys.path: sys.path.insert(0, router_root) from mxpic_router import build_project_gds as build_routed_project_gds result = build_routed_project_gds( project_dir=project_dir, output_path=output_path, pdk_root=pdk_root, technology_manifest_path=technology_manifest_path, prefer_full_gds=prefer_full_gds, ) return BuildResult( output_path=result.get("output_path", output_path), engine=result.get("engine", "mxpic_router"), cells_built=result.get("cells_built", []), warnings=result.get("warnings", []), ) def _load_project_cells(project_dir: str) -> Dict[str, dict]: """Load saved cell YAML documents from a project directory.""" 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]: """Order cells so dependencies are built before cells that reference them.""" 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 _cells_have_links(cells: Dict[str, dict]) -> bool: """Detect whether any saved cell contains bundle links that require routed building.""" for data in cells.values(): for bundle in (data.get("bundles") or {}).values(): if bundle.get("links"): return True return False def _cells_have_elements(cells: Dict[str, dict]) -> bool: """Detect whether any saved cell contains Port/Anchor element objects.""" for data in cells.values(): elements = data.get("elements") or {} for element in elements.values(): if str((element or {}).get("type") or "").lower() in {"port", "anchor"}: return True return False def _build_with_gdstk(cells: Dict[str, dict], output_path: str, registry: PdkRegistry) -> BuildResult: """Assemble a project GDS with gdstk when Nazca or routed building is unavailable.""" import gdstk library = gdstk.Library() built_cells = {} warnings = [] # Build composite cells before project cells so project-level references can # reuse cells already inserted into the same GDS library. 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: # External components are resolved through the active PDK # registry and imported as references. 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): """Import public PDK GDS geometry into the output library.""" 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}") # Avoid adding duplicate cell names when multiple instances reference the # same imported PDK component. 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: """Assemble a project GDS with Nazca cells and PDK component placements.""" import nazca as nd warnings = [] built_cells = {} ordered_names = _ordered_cell_names(cells) # Nazca cells are built in dependency order and then the final project cell # is exported as the top-level GDS. for cell_name in ordered_names: data = cells[cell_name] element_cells = _build_nazca_element_cells(nd, cell_name, data.get("elements") or {}, built_cells) 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) for element_name, element_cell in element_cells.items(): element = (data.get("elements") or {}).get(element_name) or {} x = _number(element.get("x")) y = _number(element.get("y")) rotation = _number(element.get("angle")) element_cell.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 _build_nazca_element_cells(nd, parent_cell_name: str, elements: dict, existing_cells: dict) -> Dict[str, object]: """Build reusable Nazca cells for built-in Port and Anchor element objects.""" element_cells = {} known_cells = dict(existing_cells or {}) for element_name, element in (elements or {}).items(): element_type = str((element or {}).get("type") or "").lower() if element_type not in {"port", "anchor"}: continue raw_cell_name = f"{parent_cell_name}_{element_name}_{element_type}" cell_name = _safe_cell_name(raw_cell_name, {**known_cells, **element_cells}) element_cell = _build_nazca_element_cell(nd, cell_name, {**(element or {}), "name": str(element_name)}) element_cells[str(element_name)] = element_cell return element_cells def _build_nazca_element_cell(nd, cell_name: str, element: dict): """Create one local Port or Anchor cell whose pins are placed in local coordinates.""" element = element or {} element_type = str(element.get("type") or "").lower() width = _number(element.get("width"), 0.5) port_number = max(1, _int(element.get("pin_number", element.get("pinNumber", element.get("port_number", element.get("portNumber")))), 1)) pitch = _number(element.get("pitch"), 10.0) with nd.Cell(name=cell_name) as element_cell: if element_type == "port": for index, pin_name in enumerate(_element_pin_names(cell_name, element, port_number, "port")): y = 0.0 if port_number == 1 else _element_port_offset(index, port_number, pitch) nd.Pin(pin_name, width=width).put(0.0, y, 180.0) return element_cell anchor_pin_names = _element_pin_names(cell_name, element, port_number, "anchor") for index in range(port_number): y = -15.0 + _element_port_offset(index, port_number, pitch) nd.Pin(anchor_pin_names[index * 2], width=width).put(0.0, y, 180.0) nd.Pin(anchor_pin_names[index * 2 + 1], width=width).put(0.0, y, 0.0) return element_cell def _element_port_offset(index: int, count: int, pitch: float) -> float: """Return the local y offset for one repeated Port/Anchor pin.""" return ((max(1, count) - 1) / 2 - index) * pitch def _element_pin_names(cell_name: str, element: dict, count: int, element_type: str) -> List[str]: """Return local element pin names, using YAML overrides when present.""" raw_pins = [pin for pin in (element.get("pins") or []) if isinstance(pin, dict)] default_base = _safe_pin_name(str(element.get("name") or cell_name).replace("_port", "").replace("_anchor", "")) roles = [] if element_type == "port": roles = [f"io{index + 1}" for index in range(count)] else: for index in range(count): roles.extend([f"a{index + 1}", f"b{index + 1}"]) by_role = {str(pin.get("role") or ""): str(pin.get("name") or "") for pin in raw_pins} by_index = [str(pin.get("name") or "") for pin in raw_pins] names = [] for index, role in enumerate(roles): name = by_role.get(role) or (by_index[index] if index < len(by_index) else "") or f"{default_base}_{role}" names.append(_safe_pin_name(name)) return names def _safe_pin_name(name: str) -> str: """Generate a backend-safe pin name for local Nazca element pins.""" return "".join(ch if ch.isalnum() or ch == "_" else "_" for ch in str(name or "pin")) def _safe_cell_name(name: str, existing: dict) -> str: """Generate a backend-safe unique cell name for GDS/Nazca libraries.""" 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): """Find a cell object in a loaded layout library by name.""" for cell in library.cells: if cell.name == name: return cell return None def _int(value, default=0) -> int: """Convert integer YAML values with a stable default.""" try: if value is None or value == "": return default return int(float(value)) except (TypeError, ValueError): return default def _number(value, default=0.0) -> float: """Convert numeric YAML values to floats with a stable default.""" try: if value is None or value == "": return default return float(value) except (TypeError, ValueError): return default