From 3e2a8d514af961bf162f7279243f236146e7e2ca Mon Sep 17 00:00:00 2001 From: euler Date: Sun, 9 Aug 2026 01:08:07 -0500 Subject: [PATCH 1/5] gdstk backend: close API gaps so the tutorials run Running the tutorial notebooks under GLAYOUT_BACKEND=gdstk fails almost immediately: 4 of 14 pass. Two reasons, one on each side. The notebooks import Component, rectangle, boolean and cell straight from gdsfactory instead of glayout.backend, so they bypass the backend selection entirely. Under the default backend both names resolve to the same class and nobody notices; under gdstk, gdsfactory's add_ref() gets a gdstk Component and rejects it. Symbols glayout.backend does not re-export (text_freetype, array) are left on gdsfactory. The gdstk ComponentReference/Component are also missing pieces of the gdsfactory surface that glayout's own cells and the tutorials use: movex/movey took a bare delta; move() already accepted destination= ref.name read-only, but cells label placements (ref.name = "pfet_2") ref.x / ref.y had xmin/xmax/ymin/ymax but not the centre accessors write_gds required a filename and ignored gdsdir= Component.show absent ref.name stores the label on the reference rather than renaming the target cell, which would rename every other placement of it too. Tutorial notebooks under gdstk: 4/14 -> 10/14. The three BJT tutorials still fail (Component indexing, add_ref(columns=)) and are untouched here. Notebooks under the default gdsfactory backend are unaffected: the imports resolve to the same objects. --- src/glayout/backend/_gdstk.py | 78 +++++++++++++++++-- .../BJT_tutorials/test_bjt_gdsfactory.ipynb | 7 +- tutorial/GLayout_Cmirror.ipynb | 4 +- tutorial/GLayout_Introduction.ipynb | 4 +- tutorial/GLayout_Via.ipynb | 4 +- tutorial/glayout_tutorial_5T_OTA_part1.ipynb | 5 +- tutorial/glayout_tutorial_5T_OTA_part2.ipynb | 10 ++- tutorial/glayout_tutorial_FVF_part1.ipynb | 10 ++- tutorial/glayout_tutorial_FVF_part2.ipynb | 5 +- tutorial/glayout_tutorial_INV_part1.ipynb | 12 +-- tutorial/glayout_tutorial_INV_part2.ipynb | 5 +- 11 files changed, 110 insertions(+), 34 deletions(-) diff --git a/src/glayout/backend/_gdstk.py b/src/glayout/backend/_gdstk.py index 6345d668..0db83afa 100644 --- a/src/glayout/backend/_gdstk.py +++ b/src/glayout/backend/_gdstk.py @@ -29,6 +29,7 @@ # cell_decorator_settings, .activate()). Extra fields are allowed so callers # can pass arbitrary pdk-specific config. from pydantic import BaseModel, ConfigDict # noqa: E402 +import os as _os class _GdsWriteSettings(BaseModel): @@ -264,6 +265,8 @@ def __init__(self, parent: "Component", gref: Optional[gdstk.Reference] = None): self._ref = gref # owner is the Component this reference has been added to (not the target) self.owner: Optional["Component"] = None + # a label for this placement; falls back to the target cell's name + self._name: Optional[str] = None # `info` is used by some cells to attach netlist / hierarchy metadata. self.info: dict = {} @@ -294,14 +297,31 @@ def x_reflection(self, value: bool) -> None: self._ref.x_reflection = bool(value) # --- movement (mutate + return self) ---------------------------------- - def movex(self, dx: float = 0.0) -> "ComponentReference": + def movex( + self, + origin: float = 0.0, + destination: Optional[float] = None, + ) -> "ComponentReference": + """Move along x, mirroring ``move``'s calling conventions: + - movex(dx) — translate by dx + - movex(destination=x) — translate by x + - movex(origin=x0, + destination=x1) — translate by x1-x0 + """ + dx = float(origin) if destination is None else float(destination) - float(origin) ox, oy = self.origin - self.origin = (ox + float(dx), oy) + self.origin = (ox + dx, oy) return self - def movey(self, dy: float = 0.0) -> "ComponentReference": + def movey( + self, + origin: float = 0.0, + destination: Optional[float] = None, + ) -> "ComponentReference": + """Move along y. See :meth:`movex` for the calling conventions.""" + dy = float(origin) if destination is None else float(destination) - float(origin) ox, oy = self.origin - self.origin = (ox, oy + float(dy)) + self.origin = (ox, oy + dy) return self def move( @@ -404,6 +424,16 @@ def center(self) -> Coord: (x0, y0), (x1, y1) = self.bbox return ((x0 + x1) / 2.0, (y0 + y1) / 2.0) + @property + def x(self) -> float: + """Centre x. gdsfactory exposes this on references, not just on cells.""" + return self.center[0] + + @property + def y(self) -> float: + """Centre y. Counterpart of :attr:`x`.""" + return self.center[1] + @property def xmin(self) -> float: return self.bbox[0][0] @property @@ -415,7 +445,15 @@ def ymax(self) -> float: return self.bbox[1][1] @property def name(self) -> str: - return self.parent.name + return self._name if self._name is not None else self.parent.name + + @name.setter + def name(self, value: str) -> None: + # gdsfactory lets callers label a placement without touching the cell it + # points at (``ref.name = "pfet_2"``), and cells and tutorials do exactly + # that. Keep it on the reference: renaming the parent here would rename + # every other reference to the same cell too. + self._name = str(value) def __repr__(self) -> str: return f"ComponentReference(parent={self.parent.name!r}, origin={self.origin}, rotation={self.rotation})" @@ -454,6 +492,21 @@ def name(self) -> str: def name(self, value: str) -> None: self._cell.name = str(value) + def show(self, *args, **kwargs) -> None: + """Open the layout in KLayout, like gdsfactory's Component.show(). + + Writes a temp .gds and hands it to klive when reachable. Tutorials + call this for interactive viewing, so it must stay quiet headless. + """ + import tempfile + path = _os.path.join(tempfile.gettempdir(), f"{self.name}.gds") + self.write_gds(path) + try: + from gdsfactory.show import show as _gf_show # type: ignore + _gf_show(path) + except Exception: + pass + def __repr__(self) -> str: return f"Component(name={self.name!r}, ports={list(self.ports)}, refs={len(self._references)})" @@ -726,7 +779,20 @@ def visit(cell: gdstk.Cell) -> None: visit(self._cell) return order - def write_gds(self, filename: str, unit: float = 1e-6, precision: float = 1e-9) -> str: + def write_gds( + self, + filename: Optional[str] = None, + unit: float = 1e-6, + precision: float = 1e-9, + gdsdir: Optional[str] = None, + ) -> str: + # Match gdsfactory's write_gds(gdspath=None, gdsdir=None): the path + # may be omitted (defaults to ".gds") and a directory may be + # given on its own. Tutorials use both call styles. + if filename is None: + filename = f"{self.name}.gds" + if gdsdir is not None: + filename = str(_os.path.join(str(gdsdir), str(filename))) lib = gdstk.Library(unit=unit, precision=precision) used_names: set[str] = set() for cell in self._collect_cells(): diff --git a/tutorial/BJT_tutorials/test_bjt_gdsfactory.ipynb b/tutorial/BJT_tutorials/test_bjt_gdsfactory.ipynb index c878bdc0..687bb63b 100644 --- a/tutorial/BJT_tutorials/test_bjt_gdsfactory.ipynb +++ b/tutorial/BJT_tutorials/test_bjt_gdsfactory.ipynb @@ -47,9 +47,10 @@ "source": [ "from glayout import MappedPDK, sky130 , gf180\n", "#from gdsfactory.cell import cell\n", - "from gdsfactory import Component\n", - "from gdsfactory.geometry import boolean\n", - "from gdsfactory.components import text_freetype, rectangle, array, rectangular_ring" + "from glayout.backend import Component\n", + "from glayout.backend import boolean\n", + "from glayout.backend import rectangle, rectangular_ring\n", + "from gdsfactory.components import text_freetype, array" ] }, { diff --git a/tutorial/GLayout_Cmirror.ipynb b/tutorial/GLayout_Cmirror.ipynb index 112f8c31..14aa2be9 100644 --- a/tutorial/GLayout_Cmirror.ipynb +++ b/tutorial/GLayout_Cmirror.ipynb @@ -37,7 +37,7 @@ "from glayout.util.comp_utils import move, movex, movey, align_comp_to_port, evaluate_bbox, prec_center\n", "from glayout.routing.straight_route import straight_route\n", "from glayout.routing.c_route import c_route\n", - "from gdsfactory import Component\n", + "from glayout.backend import Component\n", "import gdstk\n", "import svgutils.transform as sg\n", "import IPython.display\n", @@ -103,7 +103,7 @@ "from glayout.util.comp_utils import evaluate_bbox, prec_center\n", "from glayout.routing.straight_route import straight_route\n", "from glayout.routing.c_route import c_route\n", - "from gdsfactory import Component\n" + "from glayout.backend import Component\n" ] }, { diff --git a/tutorial/GLayout_Introduction.ipynb b/tutorial/GLayout_Introduction.ipynb index a779df12..74011d53 100644 --- a/tutorial/GLayout_Introduction.ipynb +++ b/tutorial/GLayout_Introduction.ipynb @@ -193,8 +193,8 @@ "metadata": {}, "outputs": [], "source": [ - "from gdsfactory import Component\n", - "from gdsfactory.components import rectangle\n", + "from glayout.backend import Component\n", + "from glayout.backend import rectangle\n", "\n", "def makeMet1Rectangle(pdk, length):\n", " met1 = pdk.get_glayer(\"met1\")\n", diff --git a/tutorial/GLayout_Via.ipynb b/tutorial/GLayout_Via.ipynb index e2e30c23..7278a011 100644 --- a/tutorial/GLayout_Via.ipynb +++ b/tutorial/GLayout_Via.ipynb @@ -104,8 +104,8 @@ "outputs": [], "source": [ "from glayout import sky130, gf180\n", - "from gdsfactory import Component\n", - "from gdsfactory.components import rectangle" + "from glayout.backend import Component\n", + "from glayout.backend import rectangle" ] }, { diff --git a/tutorial/glayout_tutorial_5T_OTA_part1.ipynb b/tutorial/glayout_tutorial_5T_OTA_part1.ipynb index d66f7bd7..6a0536b8 100644 --- a/tutorial/glayout_tutorial_5T_OTA_part1.ipynb +++ b/tutorial/glayout_tutorial_5T_OTA_part1.ipynb @@ -136,8 +136,9 @@ "outputs": [], "source": [ "from glayout import MappedPDK, sky130, gf180\n", - "from gdsfactory import Component\n", - "from gdsfactory.components import text_freetype, rectangle\n", + "from glayout.backend import Component\n", + "from glayout.backend import rectangle\n", + "from gdsfactory.components import text_freetype\n", "\n", "from glayout import nmos, pmos\n", "from glayout import via_stack\n", diff --git a/tutorial/glayout_tutorial_5T_OTA_part2.ipynb b/tutorial/glayout_tutorial_5T_OTA_part2.ipynb index 1d14a4f5..16c9a1dc 100644 --- a/tutorial/glayout_tutorial_5T_OTA_part2.ipynb +++ b/tutorial/glayout_tutorial_5T_OTA_part2.ipynb @@ -95,8 +95,9 @@ "source": [ "from glayout import MappedPDK, sky130 , gf180\n", "#from gdsfactory.cell import cell\n", - "from gdsfactory import Component\n", - "from gdsfactory.components import text_freetype, rectangle" + "from glayout.backend import Component\n", + "from glayout.backend import rectangle\n", + "from gdsfactory.components import text_freetype" ] }, { @@ -236,8 +237,9 @@ "fivet_ota_code_string = \"\"\"\n", "from glayout import MappedPDK, sky130 , gf180\n", "# from gdsfactory.cell import cell\n", - "from gdsfactory import Component\n", - "from gdsfactory.components import text_freetype, rectangle\n", + "from glayout.backend import Component\n", + "from glayout.backend import rectangle\n", + "from gdsfactory.components import text_freetype\n", "\n", "from glayout import nmos, pmos\n", "from glayout import via_stack\n", diff --git a/tutorial/glayout_tutorial_FVF_part1.ipynb b/tutorial/glayout_tutorial_FVF_part1.ipynb index 3808aed2..a07cb1b2 100644 --- a/tutorial/glayout_tutorial_FVF_part1.ipynb +++ b/tutorial/glayout_tutorial_FVF_part1.ipynb @@ -171,8 +171,9 @@ "source": [ "from glayout import MappedPDK, sky130 , gf180\n", "#from gdsfactory.cell import cell\n", - "from gdsfactory import Component\n", - "from gdsfactory.components import text_freetype, rectangle" + "from glayout.backend import Component\n", + "from glayout.backend import rectangle\n", + "from gdsfactory.components import text_freetype" ] }, { @@ -863,8 +864,9 @@ "fvf_code_string = \"\"\"\n", "from glayout import MappedPDK, sky130 , gf180\n", "# from gdsfactory.cell import cell\n", - "from gdsfactory import Component\n", - "from gdsfactory.components import text_freetype, rectangle\n", + "from glayout.backend import Component\n", + "from glayout.backend import rectangle\n", + "from gdsfactory.components import text_freetype\n", "\n", "from glayout import nmos, pmos\n", "from glayout import via_stack\n", diff --git a/tutorial/glayout_tutorial_FVF_part2.ipynb b/tutorial/glayout_tutorial_FVF_part2.ipynb index 1ba93304..0c34ddb6 100644 --- a/tutorial/glayout_tutorial_FVF_part2.ipynb +++ b/tutorial/glayout_tutorial_FVF_part2.ipynb @@ -113,8 +113,9 @@ "source": [ "from glayout import MappedPDK, sky130 , gf180\n", "#from gdsfactory.cell import cell\n", - "from gdsfactory import Component\n", - "from gdsfactory.components import text_freetype, rectangle" + "from glayout.backend import Component\n", + "from glayout.backend import rectangle\n", + "from gdsfactory.components import text_freetype" ] }, { diff --git a/tutorial/glayout_tutorial_INV_part1.ipynb b/tutorial/glayout_tutorial_INV_part1.ipynb index eb3160d5..1569b0db 100644 --- a/tutorial/glayout_tutorial_INV_part1.ipynb +++ b/tutorial/glayout_tutorial_INV_part1.ipynb @@ -164,8 +164,9 @@ "source": [ "from glayout import MappedPDK, sky130 , gf180\n", "#from gdsfactory.cell import cell\n", - "from gdsfactory import Component\n", - "from gdsfactory.components import text_freetype, rectangle" + "from glayout.backend import Component\n", + "from glayout.backend import rectangle\n", + "from gdsfactory.components import text_freetype" ] }, { @@ -803,9 +804,10 @@ "source": [ "inv_code_string = \"\"\"\n", "from glayout import MappedPDK, sky130 , gf180\n", - "from gdsfactory.cell import cell\n", - "from gdsfactory import Component\n", - "from gdsfactory.components import text_freetype, rectangle\n", + "from glayout.backend import cell\n", + "from glayout.backend import Component\n", + "from glayout.backend import rectangle\n", + "from gdsfactory.components import text_freetype\n", "\n", "from glayout import nmos, pmos\n", "from glayout import via_stack\n", diff --git a/tutorial/glayout_tutorial_INV_part2.ipynb b/tutorial/glayout_tutorial_INV_part2.ipynb index bec0ebc9..7aa84f0d 100644 --- a/tutorial/glayout_tutorial_INV_part2.ipynb +++ b/tutorial/glayout_tutorial_INV_part2.ipynb @@ -105,8 +105,9 @@ "outputs": [], "source": [ "from glayout import MappedPDK, sky130 , gf180\n", - "from gdsfactory import Component\n", - "from gdsfactory.components import text_freetype, rectangle\n", + "from glayout.backend import Component\n", + "from glayout.backend import rectangle\n", + "from gdsfactory.components import text_freetype\n", "\n", "import gdsfactory as gf\n", "gf.clear_cache()" From 029390a1ffed86c8b8f45ad91a7a28ac9bfbbbc1 Mon Sep 17 00:00:00 2001 From: euler Date: Sun, 9 Aug 2026 21:04:25 -0500 Subject: [PATCH 2/5] gdstk backend: snap to the PDK's grid, not to 1 nm Every vertex of a gdstk-generated layout lands off-grid on gf180. The DRC reports it on all of them -- 1301 violations on the LIF cell used to check this, split across contact_OFFGRID x348, via1_OFFGRID x276, metal1_OFFGRID x252, metal2_OFFGRID x132 and comp_OFFGRID x60. snap_to_grid() took a bare `nm: int = 1` default. gdsfactory's version reads the pitch from the active PDK instead: nm = int(get_grid_size() * 1000 * grid_factor) which is 5 nm on gf180. Rounding a 5 nm process to 1 nm produces values like 10.246 where the process wants 10.245, and every one of them is a violation. Two pieces were missing. Pdk.activate() was a no-op, so nothing recorded which PDK was active; and grid_size kept the class default of 0.001 because gdsfactory used to fill it in from its own PDK database on activate. The real pitch is already in gds_write_settings.precision (5e-9 m on both gf180 and sky130), so activate() now derives grid_size from it and registers the PDK for snap_to_grid to read. `nm=` still overrides when a caller wants a specific pitch. After: 0 of 1228 vertices off-grid, same as the gdsfactory backend, and the 1301 OFFGRID violations are gone. snap_to_2xgrid(10.2463) returns 10.25 on both backends now. DRC on diff_pair, current_mirror_nfet and transmission_gate under gdsfactory is unchanged. --- src/glayout/backend/_gdstk.py | 43 +++++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/src/glayout/backend/_gdstk.py b/src/glayout/backend/_gdstk.py index 0db83afa..3a22126a 100644 --- a/src/glayout/backend/_gdstk.py +++ b/src/glayout/backend/_gdstk.py @@ -44,6 +44,10 @@ class _CellDecoratorSettings(BaseModel): cache: bool = False +# The PDK whose grid snap_to_grid() should use. Set by Pdk.activate(). +_ACTIVE_PDK: Optional["Pdk"] = None + + class Pdk(BaseModel): """Minimal shim for gdsfactory.pdk.Pdk. Holds enough state for MappedPDK to function.""" @@ -53,15 +57,29 @@ class Pdk(BaseModel): name: str layers: Optional[dict] = None default_decorator: Optional[Any] = None - grid_size: float = 0.001 # microns; matches gdsfactory default + grid_size: float = 0.001 # microns; corrected in activate() from precision gds_write_settings: _GdsWriteSettings = _GdsWriteSettings() cell_decorator_settings: _CellDecoratorSettings = _CellDecoratorSettings() def activate(self) -> None: - """No-op. gdsfactory's activate() registered the PDK in a global - registry; that registry is a gdsfactory concern and isn't needed - once gdsfactory is out of the import graph.""" - return None + """Register this PDK as the active one. + + gdsfactory kept a global registry so that helpers like snap_to_grid + could look up the process grid. Most of that registry is a gdsfactory + concern, but the grid is not: snapping to the wrong pitch puts every + vertex off-grid, and the DRC reports it on all of them. + """ + # gdsfactory filled grid_size in from its PDK database here. Without + # that database the class default (1 nm) survives, and snapping a 5 nm + # process to 1 nm puts every vertex off-grid -- the DRC then flags + # comp, metal, contact and via alike. precision already carries the + # real pitch, so derive it rather than duplicating the number. + precision_um = float(self.gds_write_settings.precision) * 1e6 + if precision_um > 0 and self.grid_size != precision_um: + object.__setattr__(self, "grid_size", precision_um) + + global _ACTIVE_PDK + _ACTIVE_PDK = self def validate_layers(self, layers_required) -> None: """Mimics gdsfactory.pdk.Pdk.validate_layers — raise if any named @@ -836,16 +854,23 @@ def Polygon(points, layer=(0, 0), datatype=None) -> gdstk.Polygon: # --------------------------------------------------------------------------- -def snap_to_grid(x, nm: int = 1): - """Snap `x` (in micrometers) to an `nm`-nanometer grid. +def snap_to_grid(x, nm: Optional[int] = None, grid_factor: int = 1): + """Snap `x` (in micrometers) to the active PDK's grid. + + Mirrors gdsfactory.snap.snap_to_grid, which reads the grid from the active + PDK rather than assuming one: gf180 is on 5 nm, and snapping it to 1 nm + leaves every vertex off-grid (the DRC then flags comp, metal, contact and + via alike). `nm` overrides the lookup when a caller needs a specific pitch. - Matches gdsfactory.snap.snap_to_grid semantics used in this repo. Accepts scalars or iterables. """ if x is None: return None if isinstance(x, (list, tuple)): - return type(x)(snap_to_grid(v, nm) for v in x) + return type(x)(snap_to_grid(v, nm, grid_factor) for v in x) + if nm is None: + grid_um = _ACTIVE_PDK.grid_size if _ACTIVE_PDK is not None else 0.001 + nm = max(1, int(round(grid_um * 1000.0 * grid_factor))) return round(float(x) * 1000.0 / nm) * nm / 1000.0 From d8000773ed201eafb63ede61988edda4037d3f37 Mon Sep 17 00:00:00 2001 From: euler Date: Sun, 9 Aug 2026 22:08:24 -0500 Subject: [PATCH 3/5] gdstk backend: fix move() and the order of get_ports_list move(destination=) translated relative to the bbox centre instead of (0,0), which is gdsfactory's default. c_route places its extension rectangles with move(destination=...) followed by relative movex, so the route shifted by half a rectangle: the LIF neuron came out 32.4 um wide instead of 29.9, with 54 extra M2.2a violations. get_ports_list returned dict order; gdsfactory sorts clockwise (west, north, east, south). Cells look ports up by substring, so the order changes which port gets routed. With both, neurona.ipynb gives 1168.6 um2 and 31 violations on either backend, identical to gdsfactory. --- src/glayout/backend/_gdstk.py | 84 +++++++++++++++++++++++++++-------- 1 file changed, 66 insertions(+), 18 deletions(-) diff --git a/src/glayout/backend/_gdstk.py b/src/glayout/backend/_gdstk.py index 3a22126a..279d11d1 100644 --- a/src/glayout/backend/_gdstk.py +++ b/src/glayout/backend/_gdstk.py @@ -267,6 +267,41 @@ def _as_layer(layer) -> Layer: # --------------------------------------------------------------------------- +def _sort_ports_clockwise(ports: list) -> list: + """Order ports the way gdsfactory's select_ports() does. + + select_ports defaults to clockwise=True, so ports come out bucketed by + orientation and swept west, north, east, south -- west sorted south to + north, north west to east, east north to south, south east to west. + + The order is not cosmetic. Cells and notebooks pick ports out of + get_ports_list() by substring, e.g. + + next(p for p in cap.get_ports_list() if "bottom_met" in p.name) + + and a different order hands back a different port: on a 10x15 mimcap that + is array_row0_col0_bottom_met_W at (-4.250,-6.400) rather than + bottom_met_W at (-5.600,0.000), so the route lands somewhere else. + """ + buckets = {"E": [], "N": [], "W": [], "S": []} + for p in ports: + angle = (p.orientation or 0) % 360 + if angle <= 45 or angle >= 315: + buckets["E"].append(p) + elif 45 <= angle <= 135: + buckets["N"].append(p) + elif 135 <= angle <= 225: + buckets["W"].append(p) + else: + buckets["S"].append(p) + + buckets["W"].sort(key=lambda p: +p.center[1]) # south to north + buckets["N"].sort(key=lambda p: +p.center[0]) # west to east + buckets["E"].sort(key=lambda p: -p.center[1]) # north to south + buckets["S"].sort(key=lambda p: -p.center[0]) # east to west + return buckets["W"] + buckets["N"] + buckets["E"] + buckets["S"] + + class ComponentReference: """Wraps a `gdstk.Reference`. Exposes transform mutation and transformed views of the parent component's ports/bbox.""" @@ -347,24 +382,37 @@ def move( origin: Optional[Coord] = None, destination: Optional[Coord] = None, ) -> "ComponentReference": - """Move this reference. Two calling conventions: - - move((dx, dy)) — translate by offset - - move(destination=(x, y)) — move so the ref's center lands at (x, y) - - move(origin=(x0, y0), - destination=(x1, y1)) — translate by (x1-x0, y1-y0) + """Translate this reference by (destination - origin). + + `origin` defaults to (0, 0), NOT to the reference's centre -- so + move(destination=(x, y)) is a plain translation by (x, y), the same as + move((x, y)). That is gdsfactory's signature, and the difference is not + academic: c_route places its extension rectangles with + + e1_extension.move(destination=edge1.center) + e1_extension.movex(0 - evaluate_bbox(e1_extension)[0]) + + i.e. an absolute-looking call followed by relative nudges. Centring the + bbox on the destination instead injects an offset of half the + rectangle, and the route walks off: on the LIF neuron the met2 return + path ran to x=-8.125 instead of -1.250, widening the cell 8% and + raising 54 M2.2a violations that gdsfactory never produces. + + Either endpoint may be a Port (or anything exposing `.center`), which + is how callers route to a port without unpacking it. """ - if destination is None and origin is not None and not isinstance(origin, ComponentReference): - # single-arg form: treat as offset - return self.movex(origin[0]).movey(origin[1]) + def _coord(v): + c = getattr(v, "center", None) + return (float(v[0]), float(v[1])) if c is None else (float(c[0]), float(c[1])) + if destination is None: - return self - if origin is None: - # move by (destination - current center) - cx, cy = self.center - dx, dy = destination[0] - cx, destination[1] - cy - else: - dx, dy = destination[0] - origin[0], destination[1] - origin[1] - return self.movex(dx).movey(dy) + if origin is None: + return self + # single-arg form: move((dx, dy)) is a translation by that offset + destination, origin = origin, (0.0, 0.0) + ox, oy = _coord((0.0, 0.0) if origin is None else origin) + dx, dy = _coord(destination) + return self.movex(dx - ox).movey(dy - oy) def rotate(self, angle_deg: float, center: Coord = (0.0, 0.0)) -> "ComponentReference": # rotate the reference's placement about `center` @@ -431,7 +479,7 @@ def get_ports_list(self, prefix: str = "", **filters) -> list[Port]: if skip: continue out.append(p) - return out + return _sort_ports_clockwise(out) @property def bbox(self) -> tuple[Coord, Coord]: @@ -628,7 +676,7 @@ def get_ports_list(self, prefix: str = "", **filters) -> list[Port]: out.append(p.copy(name=f"{prefix}{name}")) else: out.append(p) - return out + return _sort_ports_clockwise(out) # --- geometry --------------------------------------------------------- def add_polygon(self, points, layer: Optional[Layer] = None) -> gdstk.Polygon: From 3a7dc67eed746e4012a91616cb23b3f424b925e5 Mon Sep 17 00:00:00 2001 From: euler Date: Wed, 12 Aug 2026 21:30:04 -0500 Subject: [PATCH 4/5] gdstk backend: add_ports takes a dict, and Component indexes by port Two gaps that show up running the team's primitives notebook. add_ports iterated its argument assuming a sequence of Port, but gdsfactory also accepts a name->port mapping and glayout passes ref.ports straight in. Iterating that yields the names, so resistor() died with "'str' object has no attribute 'name'". And __getitem__ was missing. bjt indexes the component to read a port's width, and without it the error is "'Component' object is not subscriptable", which says nothing about the port it was after. Added on Component and ComponentReference, with the KeyError listing the ports that do exist. With these, resistor, pnp and npn build on the gdstk backend. --- src/glayout/backend/_gdstk.py | 37 ++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/src/glayout/backend/_gdstk.py b/src/glayout/backend/_gdstk.py index 279d11d1..f2dc5565 100644 --- a/src/glayout/backend/_gdstk.py +++ b/src/glayout/backend/_gdstk.py @@ -462,6 +462,21 @@ def ports(self) -> dict[str, Port]: for name, p in self.parent.ports.items() } + def __getitem__(self, key: str) -> Port: + """comp["port_name"], como en gdsfactory. + + No es azucar: primitivos como bjt indexan el componente para leer el + ancho de un puerto, y sin esto el error que sale ("Component object is + not subscriptable") no dice nada del puerto que buscaba. + """ + try: + return self.ports[key] + except KeyError: + raise KeyError( + f"no port {key!r} on {getattr(self, 'name', self)!r}; " + f"hay {sorted(self.ports)[:8]}..." + ) from None + def get_ports_list(self, prefix: str = "", **filters) -> list[Port]: """Filter ports. `prefix` filters to names starting with that prefix (matches gdsfactory.component.Component.get_ports_list). Extra @@ -649,9 +664,14 @@ def add_port( def add_ports( self, - ports: Iterable[Port], + ports: Union[Iterable[Port], "dict[str, Port]"], prefix: str = "", ) -> "Component": + # gdsfactory accepts either a sequence of ports or a name->port mapping, + # and glayout passes `ref.ports` -- a dict -- straight through. Iterating + # that yields the names, so `p.name` blows up with a str. + if hasattr(ports, "values"): + ports = list(ports.values()) for p in ports: new_name = f"{prefix}{p.name}" if prefix else p.name np = p.copy(name=new_name) @@ -661,6 +681,21 @@ def add_ports( self.ports[new_name] = np return self + def __getitem__(self, key: str) -> Port: + """comp["port_name"], como en gdsfactory. + + No es azucar: primitivos como bjt indexan el componente para leer el + ancho de un puerto, y sin esto el error que sale ("Component object is + not subscriptable") no dice nada del puerto que buscaba. + """ + try: + return self.ports[key] + except KeyError: + raise KeyError( + f"no port {key!r} on {getattr(self, 'name', self)!r}; " + f"hay {sorted(self.ports)[:8]}..." + ) from None + def get_ports_list(self, prefix: str = "", **filters) -> list[Port]: out: list[Port] = [] for name, p in self.ports.items(): From e6144e080e9617860ead8d9684f16fe96ab39f30 Mon Sep 17 00:00:00 2001 From: euler Date: Mon, 24 Aug 2026 07:44:37 -0500 Subject: [PATCH 5/5] gdstk backend: add_ref takes columns/rows/spacing `test_bjt_gdsfactory` lays its contact rings out with `add_ref(reference, columns=, rows=, spacing=)`, which gdsfactory accepts and this backend did not, so the notebook died on a TypeError partway through. gdstk.Reference has the same notion natively, so the array stays one reference rather than becoming rows*columns of them. Verified in CI on the fork: with this, the tutorial suite runs 14/14 on gdstk. Without it, 13/14 -- that notebook the only failure. --- src/glayout/backend/_gdstk.py | 30 ++++++++++++++++++++++++++++-- tests/test_gdstk_backend.py | 22 ++++++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/glayout/backend/_gdstk.py b/src/glayout/backend/_gdstk.py index f2dc5565..7080e781 100644 --- a/src/glayout/backend/_gdstk.py +++ b/src/glayout/backend/_gdstk.py @@ -601,8 +601,34 @@ def unlock(self) -> "Component": return self # --- add / << ---------------------------------------------------------- - def add_ref(self, component: "Component", alias: Optional[str] = None) -> ComponentReference: - ref = component.ref() + def add_ref( + self, + component: "Component", + alias: Optional[str] = None, + columns: int = 1, + rows: int = 1, + spacing: Optional[tuple] = None, + ) -> ComponentReference: + """Reference `component`, optionally as a repeated array. + + gdsfactory accepts columns/rows/spacing here and the BJT tutorial + lays its contact rings out that way. gdstk has the same notion + natively, so the array stays one reference rather than becoming + rows*columns of them. + """ + if columns != 1 or rows != 1: + if spacing is None: + raise ValueError( + "add_ref: spacing is required when columns or rows > 1" + ) + gref = gdstk.Reference( + component._cell, origin=(0.0, 0.0), + columns=int(columns), rows=int(rows), + spacing=(float(spacing[0]), float(spacing[1])), + ) + ref = ComponentReference(component, gref) + else: + ref = component.ref() self.add(ref) return ref diff --git a/tests/test_gdstk_backend.py b/tests/test_gdstk_backend.py index 6bb4f310..80a1606e 100644 --- a/tests/test_gdstk_backend.py +++ b/tests/test_gdstk_backend.py @@ -30,6 +30,28 @@ def test_component_api(self): c.add_port(name="p1", center=(0, 0), width=1, orientation=0, layer=(1, 0)) self.assertIn("p1", c.ports) + def test_add_ref_array(self): + """add_ref(columns=, rows=, spacing=) lays out a repeated reference. + + `test_bjt_gdsfactory` builds its contact rings this way. Without it + the notebook dies on a TypeError halfway through. + """ + from glayout.backend import Component, rectangle + c = Component("array_probe") + unit = rectangle(size=(1, 1), layer=(1, 0)) + ref = c.add_ref(unit, columns=3, rows=2, spacing=(2, 2)) + self.assertIsNotNone(ref) + # 3 columns at pitch 2 span 1 + 2*2 = 5; 2 rows span 1 + 2 = 3. + (x0, y0), (x1, y1) = c.bbox + self.assertAlmostEqual(x1 - x0, 5.0) + self.assertAlmostEqual(y1 - y0, 3.0) + + def test_add_ref_array_needs_spacing(self): + from glayout.backend import Component, rectangle + c = Component("array_probe_nospacing") + with self.assertRaises(ValueError): + c.add_ref(rectangle(size=(1, 1), layer=(1, 0)), columns=2) + def test_primitives_build(self): from glayout.pdk.sky130_mapped.sky130_mapped import sky130_mapped_pdk as pdk from glayout.primitives.via_gen import via_stack, via_array