import math import os from dataclasses import dataclass, field from typing import Dict, List import yaml from pdk_registry import PdkRegistry @dataclass class BuildResult: 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) -> 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