Skip to content

layout.extraction — cropping and rasterizing

tempura/layout/extraction.py holds the geometric steps that turn raw layout polygons into device-aligned boolean gate masks: building an area of interest (AOI), cropping polygons to it, and rasterizing them onto a regular grid. It also provides the layout inspection plots.

Coordinate systems

Three coordinate systems pass through this module, and keeping them straight avoids most confusion:

System Set by Units
Original layout the source file / aoi_bbox layout units
Local AOI crop_polygons_to_aoi(...) layout units, shifted so the lower-left corner is the origin
Internal grid rasterization grid cells (one cell = one unit)

aoi_bbox is always given in the original layout coordinates; make_aoi_bbox_from_ranges(...) builds one from two coordinate ranges. Cropping clips every layer to that window and translates the result so its lower-left corner sits at \((0, 0)\).

Rasterizing at cell centers

rasterize_gate_vertices(...) samples each polygon at cell centers and returns boolean masks that share one \((n_y, n_x)\) shape and alignment. Sampling at centers (rather than corners) means a cell is "on" when the gate covers its center, which keeps adjacent gates from sharing edge cells. Masks are indexed [row, col] = [y, x].

The grid count for a span is snapped with a small decimal guard (_grid_count_from_span) so values such as 48.00000000000001 resolve to the intended integer cell count instead of rounding up spuriously.

Inspection plots

plot_layout_layers(...), plot_gate_stencil_layers(...), and plot_layout_interactive(...) draw the whole layout, the rasterized masks, or an interactive view. Drawing polygon outlines rather than filled regions keeps the overview fast on large layouts.

API

extraction

AOI extraction and mask generation helpers.

crop_polygons_to_aoi(polygons_by_layer, aoi_bbox, precision=1e-06)

Clip all layers to one AOI and return local-coordinate polygons.

Parameters:

Name Type Description Default
polygons_by_layer PolygonMap

Input polygons grouped by layer.

required
aoi_bbox BBoxXY

Global AOI bounds as (xmin, xmax, ymin, ymax).

required
precision float

Boolean-operation precision passed to gdstk.boolean.

1e-06

Returns:

Type Description
PolygonMap

New polygon mapping where each polygon is clipped to aoi_bbox and

PolygonMap

translated so the AOI lower-left corner becomes (0, 0).

Source code in tempura/layout/extraction.py
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
def crop_polygons_to_aoi(
    polygons_by_layer: PolygonMap,
    aoi_bbox: BBoxXY,
    precision: float = 1e-6,
) -> PolygonMap:
    """Clip all layers to one AOI and return local-coordinate polygons.

    Args:
        polygons_by_layer: Input polygons grouped by layer.
        aoi_bbox: Global AOI bounds as ``(xmin, xmax, ymin, ymax)``.
        precision: Boolean-operation precision passed to ``gdstk.boolean``.

    Returns:
        New polygon mapping where each polygon is clipped to ``aoi_bbox`` and
        translated so the AOI lower-left corner becomes ``(0, 0)``.
    """
    xmin, xmax, ymin, ymax = _validate_bbox(aoi_bbox, name="AOI bbox")
    clip_rect = gdstk.rectangle((xmin, ymin), (xmax, ymax))

    cropped: PolygonMap = {}
    for layer_name, polygons in polygons_by_layer.items():
        clipped = gdstk.boolean(polygons, clip_rect, "and", precision=precision)
        if not clipped:
            continue
        local_polygons: list[gdstk.Polygon] = []
        for polygon in clipped:
            points = np.asarray(polygon.points, dtype=float).copy()
            points[:, 0] -= xmin
            points[:, 1] -= ymin
            local_polygons.append(
                gdstk.Polygon(points, layer=polygon.layer, datatype=polygon.datatype)
            )
        cropped[layer_name] = local_polygons
    return cropped

make_aoi_bbox_from_ranges(x_range, y_range)

Build an AOI bbox from two two-point coordinate ranges.

Parameters:

Name Type Description Default
x_range Sequence[float]

Two x coordinates describing the lower and upper AOI bounds.

required
y_range Sequence[float]

Two y coordinates describing the lower and upper AOI bounds.

required

Returns:

Type Description
BBoxXY

Normalized (xmin, xmax, ymin, ymax) tuple with ascending bounds.

Source code in tempura/layout/extraction.py
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
def make_aoi_bbox_from_ranges(
    x_range: Sequence[float], y_range: Sequence[float]
) -> BBoxXY:
    """Build an AOI bbox from two two-point coordinate ranges.

    Args:
        x_range: Two x coordinates describing the lower and upper AOI bounds.
        y_range: Two y coordinates describing the lower and upper AOI bounds.

    Returns:
        Normalized ``(xmin, xmax, ymin, ymax)`` tuple with ascending bounds.
    """
    if len(x_range) != 2 or len(y_range) != 2:
        raise ValueError("x_range and y_range must each have exactly 2 values.")
    xmin, xmax = sorted((float(x_range[0]), float(x_range[1])))
    ymin, ymax = sorted((float(y_range[0]), float(y_range[1])))
    return _validate_bbox((xmin, xmax, ymin, ymax), name="AOI bbox")

plot_gate_stencil_layers(gate_stencils, *, roi_size, layer_order=None, title='Rasterized gate masks by layer', figsize=None, coordinate_scale=1.0, axis_unit=None, layer_thicknesses=None)

Plot one panel per source layer with that layer's gates overlaid.

Parameters:

Name Type Description Default
gate_stencils MasksByLayer

Rasterized masks grouped by source layer.

required
roi_size Sequence[float]

ROI size as (length, width) in the plotted units.

required
layer_order Sequence[str] | None

Optional explicit source-layer order.

None
title str

Figure title.

'Rasterized gate masks by layer'
figsize tuple[float, float] | None

Optional Matplotlib figure size.

None
coordinate_scale float

Multiplicative scale applied to displayed coordinates and optional thickness annotations.

1.0
axis_unit str | None

Optional axis-unit label appended to x/y labels.

None
layer_thicknesses Mapping[str, float] | None

Optional per-layer thickness values used in subplot titles.

None

Returns:

Type Description
'Figure'

Matplotlib figure containing one subplot per non-empty source layer.

Source code in tempura/layout/extraction.py
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
def plot_gate_stencil_layers(
    gate_stencils: MasksByLayer,
    *,
    roi_size: Sequence[float],
    layer_order: Sequence[str] | None = None,
    title: str = "Rasterized gate masks by layer",
    figsize: tuple[float, float] | None = None,
    coordinate_scale: float = 1.0,
    axis_unit: str | None = None,
    layer_thicknesses: Mapping[str, float] | None = None,
) -> "Figure":
    """Plot one panel per source layer with that layer's gates overlaid.

    Args:
        gate_stencils: Rasterized masks grouped by source layer.
        roi_size: ROI size as ``(length, width)`` in the plotted units.
        layer_order: Optional explicit source-layer order.
        title: Figure title.
        figsize: Optional Matplotlib figure size.
        coordinate_scale: Multiplicative scale applied to displayed
            coordinates and optional thickness annotations.
        axis_unit: Optional axis-unit label appended to x/y labels.
        layer_thicknesses: Optional per-layer thickness values used in subplot
            titles.

    Returns:
        Matplotlib figure containing one subplot per non-empty source layer.
    """
    import matplotlib.pyplot as plt

    roi_length = float(roi_size[0]) * float(coordinate_scale)
    roi_width = float(roi_size[1]) * float(coordinate_scale)
    ordered_layers = sorted(gate_stencils) if layer_order is None else list(layer_order)
    non_empty_by_layer: list[tuple[str, list[np.ndarray]]] = []
    for layer_name in ordered_layers:
        # Drop empty masks before plotting so the panel count reflects visible
        # gates, not source-layer bookkeeping.
        masks = [
            np.asarray(mask, dtype=bool)
            for mask in gate_stencils.get(layer_name, [])
            if np.asarray(mask).any()
        ]
        if masks:
            non_empty_by_layer.append((layer_name, masks))
    if not non_empty_by_layer:
        raise ValueError("No non-empty stencils were available to plot.")

    ncols = len(non_empty_by_layer)
    if figsize is None:
        figsize = (3.6 * ncols, 3.6)
    fig, axes = plt.subplots(
        1,
        ncols,
        figsize=figsize,
        sharex=True,
        sharey=True,
    )
    axes = np.atleast_1d(axes)
    color_cycle = plt.rcParams["axes.prop_cycle"].by_key().get("color", [])

    for idx, (ax, (layer_name, masks)) in enumerate(
        zip(axes, non_empty_by_layer, strict=False)
    ):
        # The grayscale image shows the union of all gates in the source layer;
        # contours below preserve the individual component boundaries.
        union_mask = np.zeros_like(masks[0], dtype=bool)
        for mask in masks:
            union_mask |= mask
        ax.imshow(
            union_mask,
            origin="lower",
            cmap="Greys",
            interpolation="nearest",
            vmin=0,
            vmax=1,
            extent=(0.0, roi_length, 0.0, roi_width),
        )

        if union_mask.shape[0] > 1 and union_mask.shape[1] > 1:
            # Contours are drawn at cell centers to match the rasterization
            # convention used by build_device and the solver mesh.
            x_step = roi_length / union_mask.shape[1]
            y_step = roi_width / union_mask.shape[0]
            x_values = (np.arange(union_mask.shape[1], dtype=float) + 0.5) * x_step
            y_values = (np.arange(union_mask.shape[0], dtype=float) + 0.5) * y_step
            outline_color = (
                color_cycle[idx % len(color_cycle)] if color_cycle else "#1f77b4"
            )
            for mask in masks:
                ax.contour(
                    x_values,
                    y_values,
                    np.asarray(mask, dtype=float),
                    levels=[0.5],
                    colors=[outline_color],
                    linewidths=0.8,
                    alpha=0.85,
                )

        gate_count = len(masks)
        gate_label = "gate" if gate_count == 1 else "gates"
        title_parts = [layer_name]
        if layer_thicknesses is not None and layer_name in layer_thicknesses:
            thickness = float(layer_thicknesses[layer_name]) * float(coordinate_scale)
            unit_suffix = f" {axis_unit}" if axis_unit else ""
            title_parts.append(f"t = {thickness:.0f}{unit_suffix}")
        title_parts.append(f"{gate_count} {gate_label}")
        ax.set_title("\n".join(title_parts))
        x_label = "x" if axis_unit is None else f"x ({axis_unit})"
        y_label = "y" if axis_unit is None else f"y ({axis_unit})"
        ax.set_xlabel(x_label)
        if idx == 0:
            ax.set_ylabel(y_label)
        ax.set_aspect("equal", adjustable="box")
        ax.grid(False)

    fig.suptitle(title)
    fig.tight_layout(rect=(0.0, 0.0, 1.0, 0.95))
    return fig

plot_layout_file_interactive(layout_path, *, layers=None, exclude_layers=None, title=None, filled=True)

Load a layout file and return an interactive figure for its polygons.

Parameters:

Name Type Description Default
layout_path str | Path

Filesystem path to the layout file.

required
layers Sequence[str] | None

Optional subset of layers to include.

None
exclude_layers Sequence[str] | None

Optional subset of layers to exclude.

None
title str | None

Optional figure title. Defaults to "{filename} layout".

None
filled bool

Whether to render filled polygons or outlines only.

True

Returns:

Type Description
'go.Figure'

Plotly figure for the polygons loaded from layout_path.

Source code in tempura/layout/extraction.py
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
def plot_layout_file_interactive(
    layout_path: str | Path,
    *,
    layers: Sequence[str] | None = None,
    exclude_layers: Sequence[str] | None = None,
    title: str | None = None,
    filled: bool = True,
) -> "go.Figure":
    """Load a layout file and return an interactive figure for its polygons.

    Args:
        layout_path: Filesystem path to the layout file.
        layers: Optional subset of layers to include.
        exclude_layers: Optional subset of layers to exclude.
        title: Optional figure title. Defaults to ``"{filename} layout"``.
        filled: Whether to render filled polygons or outlines only.

    Returns:
        Plotly figure for the polygons loaded from ``layout_path``.
    """
    from tempura.layout.readers import load_layout_data

    path = Path(layout_path)
    layout_data = load_layout_data(path)
    if title is None:
        title = f"{path.name} layout"
    return plot_layout_interactive(
        layout_data.polygons_by_layer,
        layers=layers,
        exclude_layers=exclude_layers,
        title=title,
        filled=filled,
    )

plot_layout_interactive(polygons_by_layer, layers=None, exclude_layers=None, title='Layout Layers (interactive)', *, filled=True)

Return an interactive Plotly figure for layer inspection.

Parameters:

Name Type Description Default
polygons_by_layer PolygonMap

Polygons grouped by layer name.

required
layers Sequence[str] | None

Optional explicit layer order to render.

None
exclude_layers Sequence[str] | None

Optional layer names to omit from the figure.

None
title str

Figure title.

'Layout Layers (interactive)'
filled bool

Whether to render filled polygons or outlines only.

True

Returns:

Type Description
'go.Figure'

Plotly figure containing one trace per polygon with shared layer-based

'go.Figure'

legend entries.

Source code in tempura/layout/extraction.py
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
def plot_layout_interactive(
    polygons_by_layer: PolygonMap,
    layers: Sequence[str] | None = None,
    exclude_layers: Sequence[str] | None = None,
    title: str = "Layout Layers (interactive)",
    *,
    filled: bool = True,
) -> "go.Figure":
    """Return an interactive Plotly figure for layer inspection.

    Args:
        polygons_by_layer: Polygons grouped by layer name.
        layers: Optional explicit layer order to render.
        exclude_layers: Optional layer names to omit from the figure.
        title: Figure title.
        filled: Whether to render filled polygons or outlines only.

    Returns:
        Plotly figure containing one trace per polygon with shared layer-based
        legend entries.
    """
    import matplotlib.colors as mcolors
    import matplotlib.pyplot as plt
    import plotly.graph_objects as go

    layer_order = sorted(polygons_by_layer) if layers is None else list(layers)
    if exclude_layers is not None:
        excluded = {str(layer_name) for layer_name in exclude_layers}
        layer_order = [
            layer_name for layer_name in layer_order if layer_name not in excluded
        ]
    fig = go.Figure()
    color_cycle = plt.rcParams["axes.prop_cycle"].by_key().get("color", [])

    for idx, layer_name in enumerate(layer_order):
        # Layer order controls both color assignment and legend order. Missing
        # explicit layers are ignored so callers can reuse one preferred order
        # across several related layouts.
        if layer_name not in polygons_by_layer:
            continue
        base_color = color_cycle[idx % len(color_cycle)] if color_cycle else "#1f77b4"
        rgba = mcolors.to_rgba(base_color, alpha=0.4)
        fill_rgba = (
            f"rgba({int(rgba[0] * 255)}, {int(rgba[1] * 255)}, "
            f"{int(rgba[2] * 255)}, {rgba[3]})"
        )
        line_rgba = (
            f"rgba({int(rgba[0] * 255)}, {int(rgba[1] * 255)}, "
            f"{int(rgba[2] * 255)}, 0.9)"
        )
        show_legend = True
        for polygon in polygons_by_layer[layer_name]:
            # Plotly needs closed line loops for filled polygons. gdstk stores
            # many polygons without repeating the first point at the end.
            points = np.asarray(polygon.points, dtype=float)
            if len(points) == 0:
                continue
            if not np.allclose(points[0], points[-1]):
                points = np.vstack([points, points[0]])
            fig.add_trace(
                go.Scatter(
                    x=points[:, 0].tolist(),
                    y=points[:, 1].tolist(),
                    mode="lines",
                    fill="toself" if filled else None,
                    name=layer_name,
                    legendgroup=layer_name,
                    showlegend=show_legend,
                    line={"color": line_rgba, "width": 1},
                    fillcolor=fill_rgba if filled else "rgba(0, 0, 0, 0)",
                )
            )
            show_legend = False

    fig.update_layout(
        title=title,
        legend_title_text="Layers",
        legend={"groupclick": "togglegroup"},
        template="plotly_white",
        height=760,
        autosize=True,
    )
    fig.update_yaxes(scaleanchor="x", scaleratio=1)
    return fig

plot_layout_layers(polygons_by_layer, *, layers=None, exclude_layers=None, title=None, filled=True, coordinate_scale=1.0, axis_unit=None)

Plot layout polygons with Matplotlib using one shared set of axes.

Parameters:

Name Type Description Default
polygons_by_layer PolygonMap

Polygons grouped by layer name.

required
layers Sequence[str] | None

Optional subset of layers to include.

None
exclude_layers Sequence[str] | None

Optional subset of layers to exclude.

None
title str | None

Optional axis title.

None
filled bool

Whether to render filled polygons or outlines only.

True
coordinate_scale float

Multiplicative scale applied to plotted coordinates.

1.0
axis_unit str | None

Optional axis-unit label appended to x/y labels.

None

Returns:

Type Description
'Figure'

Matplotlib figure containing all selected layers on one axis.

Source code in tempura/layout/extraction.py
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
def plot_layout_layers(
    polygons_by_layer: PolygonMap,
    *,
    layers: Sequence[str] | None = None,
    exclude_layers: Sequence[str] | None = None,
    title: str | None = None,
    filled: bool = True,
    coordinate_scale: float = 1.0,
    axis_unit: str | None = None,
) -> "Figure":
    """Plot layout polygons with Matplotlib using one shared set of axes.

    Args:
        polygons_by_layer: Polygons grouped by layer name.
        layers: Optional subset of layers to include.
        exclude_layers: Optional subset of layers to exclude.
        title: Optional axis title.
        filled: Whether to render filled polygons or outlines only.
        coordinate_scale: Multiplicative scale applied to plotted coordinates.
        axis_unit: Optional axis-unit label appended to x/y labels.

    Returns:
        Matplotlib figure containing all selected layers on one axis.
    """
    import matplotlib.pyplot as plt

    selected_layers = sorted(polygons_by_layer)
    if layers is not None:
        allowed = set(layers)
        selected_layers = [layer for layer in selected_layers if layer in allowed]
    if exclude_layers is not None:
        excluded = set(exclude_layers)
        selected_layers = [layer for layer in selected_layers if layer not in excluded]
    if not selected_layers:
        raise ValueError("No layout layers were available to plot.")

    figure, ax = plt.subplots(figsize=(7.2, 5.8))
    color_cycle = plt.rcParams["axes.prop_cycle"].by_key().get("color", [])

    for layer_idx, layer_name in enumerate(selected_layers):
        layer_color = (
            color_cycle[layer_idx % len(color_cycle)] if color_cycle else "#1f77b4"
        )
        label_added = False
        for polygon in polygons_by_layer.get(layer_name, []):
            # Add one legend entry per layer even when that layer contains many
            # disconnected polygons.
            points = np.asarray(polygon.points, dtype=float)
            if len(points) == 0:
                continue
            points = points.copy()
            points[:, :2] *= float(coordinate_scale)
            if not np.allclose(points[0], points[-1]):
                points = np.vstack([points, points[0]])
            if filled:
                ax.fill(
                    points[:, 0],
                    points[:, 1],
                    linewidth=0.8,
                    alpha=0.35,
                    color=layer_color,
                    label=layer_name if not label_added else None,
                )
            else:
                ax.plot(
                    points[:, 0],
                    points[:, 1],
                    linewidth=0.9,
                    color=layer_color,
                    label=layer_name if not label_added else None,
                )
            label_added = True

    x_label = "x" if axis_unit is None else f"x ({axis_unit})"
    y_label = "y" if axis_unit is None else f"y ({axis_unit})"
    ax.set_xlabel(x_label)
    ax.set_ylabel(y_label)
    ax.set_aspect("equal", adjustable="box")
    ax.grid(False)
    if title is not None:
        ax.set_title(title)
    if selected_layers:
        ax.legend(loc="upper right", frameon=False, fontsize="small")
    figure.tight_layout()
    return figure

polygons_to_vertices(polygons_by_layer)

Convert polygons grouped by layer into nested Python lists.

Parameters:

Name Type Description Default
polygons_by_layer PolygonMap

Polygons grouped by layer name.

required

Returns:

Type Description
VerticesByLayer

Plain nested lists of vertex coordinates grouped the same way as the

VerticesByLayer

input mapping.

Source code in tempura/layout/extraction.py
103
104
105
106
107
108
109
110
111
112
113
114
115
116
def polygons_to_vertices(polygons_by_layer: PolygonMap) -> VerticesByLayer:
    """Convert polygons grouped by layer into nested Python lists.

    Args:
        polygons_by_layer: Polygons grouped by layer name.

    Returns:
        Plain nested lists of vertex coordinates grouped the same way as the
        input mapping.
    """
    return {
        layer_name: [polygon.points.tolist() for polygon in polygons]
        for layer_name, polygons in polygons_by_layer.items()
    }

rasterize_gate_vertices(gate_vertices_by_layer, *, dx, dy, roi_bbox)

Rasterize gate polygons into boolean masks on a regular grid.

All polygons are sampled on the same cell-centered ROI grid. The returned masks use NumPy row-major ordering, so array indices are [y, x] while polygon coordinates are (x, y).

Parameters:

Name Type Description Default
gate_vertices_by_layer VerticesByLayer

Layer-to-polygons mapping where each polygon is an ordered sequence of (x, y) vertices.

required
dx float

Positive cell spacing along x.

required
dy float

Positive cell spacing along y.

required
roi_bbox BBoxXY

Rasterization bounds as (xmin, xmax, ymin, ymax). Every layer is rasterized on this shared grid so masks align.

required

Returns:

Type Description
MasksByLayer

Mapping from layer name to one or more boolean masks. Each mask has

MasksByLayer

shape (ny, nx) and True means the cell center lies inside the

MasksByLayer

polygon.

Raises:

Type Description
ValueError

If dx, dy, or roi_bbox are invalid.

Source code in tempura/layout/extraction.py
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
def rasterize_gate_vertices(
    gate_vertices_by_layer: VerticesByLayer,
    *,
    dx: float,
    dy: float,
    roi_bbox: BBoxXY,
) -> MasksByLayer:
    """Rasterize gate polygons into boolean masks on a regular grid.

    All polygons are sampled on the same cell-centered ROI grid. The returned
    masks use NumPy row-major ordering, so array indices are ``[y, x]`` while
    polygon coordinates are ``(x, y)``.

    Args:
        gate_vertices_by_layer: Layer-to-polygons mapping where each polygon is
            an ordered sequence of ``(x, y)`` vertices.
        dx: Positive cell spacing along x.
        dy: Positive cell spacing along y.
        roi_bbox: Rasterization bounds as ``(xmin, xmax, ymin, ymax)``. Every
            layer is rasterized on this shared grid so masks align.

    Returns:
        Mapping from layer name to one or more boolean masks. Each mask has
        shape ``(ny, nx)`` and ``True`` means the cell center lies inside the
        polygon.

    Raises:
        ValueError: If ``dx``, ``dy``, or ``roi_bbox`` are invalid.
    """

    from matplotlib.path import Path as MplPath

    # Validate grid spacing early to avoid divide-by-zero and invalid grids.
    if dx <= 0 or dy <= 0:
        raise ValueError("dx and dy must be positive.")

    roi_xmin, roi_xmax, roi_ymin, roi_ymax = _validate_bbox(roi_bbox, name="roi_bbox")
    roi_nx = _grid_count_from_span(roi_xmax - roi_xmin, dx)
    roi_ny = _grid_count_from_span(roi_ymax - roi_ymin, dy)
    # Sample at cell centers so the rasterized masks align with the device-grid
    # convention used throughout the electrostatics pipeline.
    roi_xs = roi_xmin + (np.arange(roi_nx) + 0.5) * dx
    roi_ys = roi_ymin + (np.arange(roi_ny) + 0.5) * dy
    roi_grid_x, roi_grid_y = np.meshgrid(roi_xs, roi_ys)
    roi_sample_points = np.column_stack([roi_grid_x.ravel(), roi_grid_y.ravel()])

    # Accumulate per-layer masks; omit layers with no valid polygons.
    masks_by_layer: MasksByLayer = {}
    for layer_name, polygons in gate_vertices_by_layer.items():
        # Collect masks for each polygon in the current layer.
        layer_masks: list[np.ndarray] = []
        for vertices in polygons:
            # Normalize vertex data and skip degenerate polygons.
            points = np.asarray(vertices, dtype=float)
            if len(points) < 3:
                continue
            # Rasterize on the shared ROI grid to preserve global alignment.
            mask = (
                MplPath(points).contains_points(roi_sample_points).reshape(roi_ny, roi_nx)
            )
            # Append the rasterized mask for this polygon.
            layer_masks.append(mask)
        if layer_masks:
            # Keep only layers that have at least one valid mask.
            masks_by_layer[layer_name] = layer_masks
    return masks_by_layer