diff --git a/AGENTS.md b/AGENTS.md index a7ad8413..2b543492 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,13 +31,15 @@ saved configurations, generated solver inputs, and published notebooks are user- and target release. - Introduce the replacement before removing the old API. Keep the old path working with a `DeprecationWarning`, and test both paths during the transition. Warn in a released version before changing a default. -- Normally retain a deprecated public API for at least two feature releases and roughly 12 months after the first +- Normally retain a deprecated public API for at least two feature releases and roughly 3 months after the first released warning. Remove it only in a release designated for incompatible changes: a feature release before 1.0, or a major release after 1.0. Do not make intentional breaks in patch releases. - Announce deprecations and removals in release notes and update documentation and examples. Retain a safe, inexpensive compatibility alias longer when it imposes little maintenance cost. - If removal is not planned, describe the API as discouraged or soft deprecated without issuing a removal warning. Serious correctness or safety problems may justify a shorter transition when the change and migration are documented. + An owner-approved change that materially improves usability of customer-facing APIs or documentation may also use a + shorter transition, with the rationale, migration steps, and target release documented. ## Code Review Rules diff --git a/docs/api/fdtd.md b/docs/api/fdtd.md index 35df98c7..95392174 100644 --- a/docs/api/fdtd.md +++ b/docs/api/fdtd.md @@ -296,7 +296,8 @@ explicitly. ## Setup visualization -`plot_2d_static()` produces a static Matplotlib cross-section, like the Meep setup view. +`plot_2d()` produces a static Matplotlib cross-section, like the Meep setup view. +`plot_2d_static()` remains available with the same behavior and arguments. Materials are colored by their real refractive index at the source center wavelength, with a colorbar, physical-domain boundary, hatched PML regions, and source and monitor annotations where they intersect the slice. The plot uses the FDTD @@ -304,12 +305,25 @@ transfer mesh, including sidewalls and material priorities. All physical groups are shown by default; `show_groups` and `hide_groups` select physical group names. ```python -ax = sim.plot_2d_static() # XY at geometry midpoint -ax = sim.plot_2d_static(axis="z", position_um=0.11) # XY at z = 0.11 µm -ax = sim.plot_2d_static(axis="x", position_um=0, show_mesh=True) # YZ with mesh edges +ax = sim.plot_2d() # XY at geometry midpoint +ax = sim.plot_2d(axis="z", position_um=0.11) # XY at z = 0.11 µm +ax = sim.plot_2d(axis="x", position_um=0, show_mesh=True) # YZ with mesh edges ax.figure.savefig("cross_section.png", dpi=200, bbox_inches="tight") ``` +To overlay material boundaries on a field plot, pass its Matplotlib axes with +`outline_only=True`. Boundaries follow the selected slice and material priority, +including holes. Touching regions of the same material share one outline. +This mode preserves existing axes limits, labels, aspect and colorbars, and adds +no fills, setup annotations, legend or refractive-index colorbar. + +```python +sim.plot_2d_static(axis="y", position_um=0, ax=field_ax, outline_only=True) +``` + +The same option is available on `plot_2d()`. Group filters remain available; +`outline_only=True` cannot be combined with `show_mesh=True`. + The method returns a Matplotlib `Axes`. Pass `ax` to draw into an existing subplot, `cmap` to change the index colormap, `legend=False` to hide the legend, `aspect="auto"` to fill the axes, or `figsize=(8, 4)` to set the figure size. @@ -324,6 +338,11 @@ thickness uses the same per-axis alignment as the metadata estimate: z PML is rounded up to a multiple of 16 cells (for example, 20 becomes 32). Guard cells are not drawn as PML. Mesh edges describe the tetrahedral transfer mesh, rather than the solver's Yee grid. +Without mesh edges, adjacent cells are merged into material regions with +antialiased boundaries. Consecutive equally colored physical groups share a +fill to avoid seams at overlapping edges; their legend entries remain separate. +SVG and PDF exports retain vector material outlines; +`show_mesh=True` retains the individual mesh cells and rasterizes the overlay. The interactive setup views use the GDSFactory FDTD Three.js viewer and do not depend on PyVista. Smaller material groups are listed first, followed by the @@ -376,18 +395,26 @@ and total cell count (including PML). Grid counts use the same Python calculatio as `metadata.json`, preserving its rounding and per-axis alignment. These values are estimates; the solver backend is authoritative when it constructs the final grid. -### 2D plotting APIs and compatibility (0.9.0) - -Use `plot_2d_static()` for a Matplotlib `Axes` and export it with -`ax.figure.savefig(...)`. Use `plot_2d_interactive()` for an HTML `MeshViewer` -with `.html` and `.save()`. - -The original `plot_2d()` remains an alias for the interactive viewer, accepts -all original keywords including `height` and `zoom_to_cursor`, and still returns -`MeshViewer`. Starting with 0.9.0 it emits a `DeprecationWarning` directing callers -to the explicit methods. The alias will remain for at least two feature releases -and roughly 12 months after the first released warning. This change does not -switch its default behavior or remove the old API. +### 2D plotting migration (0.10.0) + +The next feature release, 0.10.0, intentionally changes `plot_2d()` from the +interactive HTML `MeshViewer` to a static Matplotlib `Axes`, making the standard +2D setup method produce the static preview. Static calls do not emit a deprecation +warning. `plot_2d_static()` remains a supported equivalent. + +To make setup examples easier to use, inspect and export, this default changes in +0.10.0 rather than following the normal two-feature-release, three-month transition. +Callers using `.html` or `.save()` should change +their call to `plot_2d_interactive()` to retain the previous interactive behavior. +Legacy `plot_2d(height=...)` or `plot_2d(zoom_to_cursor=...)` calls continue returning +the HTML viewer with a `DeprecationWarning`. Removal targets 0.12.0 or later, +after two feature releases and at least three months since the first released +warning in 0.10.0. If those conditions are not met, removal moves to a later feature +release. These viewer keywords cannot be mixed with static-only options +such as `ax` or `outline_only`. The explicit interactive method remains supported. +For static exports, use +`ax.figure.savefig(...)`; `ax`, `cmap`, `legend`, `aspect`, and `figsize` configure +the static view. This default change is intended for 0.10.0, not a 0.9.x patch. ## Sources diff --git a/src/gsim/fdtd/slice_viz.py b/src/gsim/fdtd/slice_viz.py index de308e3d..19a4e212 100644 --- a/src/gsim/fdtd/slice_viz.py +++ b/src/gsim/fdtd/slice_viz.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping, Sequence +from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Any, Literal @@ -10,24 +11,39 @@ import numpy as np from matplotlib import pyplot as plt from matplotlib.cm import ScalarMappable -from matplotlib.collections import PolyCollection +from matplotlib.collections import LineCollection, PolyCollection from matplotlib.colors import ListedColormap, Normalize +from matplotlib.patches import Patch +from matplotlib.path import Path as MatplotlibPath +from shapely import get_parts, union_all +from shapely.geometry import Polygon +from shapely.geometry.polygon import orient -from gsim.common.viz._matplotlib import add_bottom_legend from gsim.fdtd.mesh_sections import tetrahedron_section from gsim.fdtd.slice_overlay import draw_setup_overlay, pml_thickness_um from gsim.fdtd.viz import validate_slice if TYPE_CHECKING: from matplotlib.axes import Axes + from matplotlib.figure import Figure + from shapely.geometry.base import BaseGeometry from gsim.common.materials import MaterialSnapshot from gsim.fdtd.config import GaussianBeamConfig, PlaneMonitorConfig - from gsim.fdtd.models import MeshManifest + from gsim.fdtd.models import MeshGroup, MeshManifest from gsim.fdtd.visualization import VisualizationMixin from gsim.fdtd.viz import Axis +@dataclass +class _MaterialBatch: + """Consecutive equally colored groups that can share one material outline.""" + + groups: list[MeshGroup] + polygons: list[Any] + color: Any + + def _mesh_sections(mesh_path: Path, axis: int, position: float) -> dict[int, list[Any]]: """Read nanometer mesh coordinates and collect sections by physical tag.""" mesh = meshio.read(mesh_path) @@ -51,6 +67,134 @@ def _mesh_sections(mesh_path: Path, axis: int, position: float) -> dict[int, lis return sections +def _merge_sections(polygons: Sequence[Any]) -> BaseGeometry: + """Merge tetrahedron sections with a consistent intersection precision.""" + # Match the precision used by tetrahedron_section for shared intersections. + return union_all([Polygon(vertices) for vertices in polygons], grid_size=1e-12) + + +def _material_paths(polygons: Sequence[Any]) -> list[MatplotlibPath]: + """Merge mesh cells so only material boundaries are antialiased.""" + merged = _merge_sections(polygons) + paths = [] + for polygon in get_parts(merged): + if polygon.is_empty: + continue + # Opposite ring winding keeps holes transparent with nonzero path filling. + polygon = orient(polygon, sign=1) + rings = [polygon.exterior, *polygon.interiors] + paths.append( + MatplotlibPath.make_compound_path( + *[ + MatplotlibPath(np.asarray(ring.coords), closed=True) + for ring in rings + ] + ) + ) + return paths + + +def _plot_material_outlines( + sections: Mapping[int, list[Any]], + groups: Sequence[MeshGroup], + lower: Any, + upper: Any, + *, + axis: Axis, + position_um: float, + ax: Axes | None, + aspect: Literal["equal", "auto"], + figsize: tuple[float, float] | None, +) -> Axes: + """Draw visible material interfaces once, preserving supplied field axes.""" + created_axes = ax is None + if ax is None: + _, ax = plt.subplots(figsize=figsize, constrained_layout=True) + covered = union_all([]) + visible_materials: dict[str, list[BaseGeometry]] = {} + for group in reversed(groups): + polygons = sections.get(group.physical_tag, []) + if not polygons: + continue + region = _merge_sections(polygons) + visible = region.difference(covered) + if not visible.is_empty: + visible_materials.setdefault(group.material, []).append(visible) + covered = union_all([covered, region]) + # Merge touching regions of the same material, then deduplicate interfaces + # shared by different materials. Hidden lower-priority edges stay hidden. + boundaries = [union_all(regions).boundary for regions in visible_materials.values()] + linework = union_all(boundaries) + segments = [ + np.asarray(line.coords) for line in get_parts(linework) if not line.is_empty + ] + if segments: + zorder = ( + max( + (artist.get_zorder() for artist in [*ax.collections, *ax.images]), + default=1, + ) + + 1 + ) + ax.add_collection( + LineCollection( + segments, + colors="#444444", + linewidths=0.6, + zorder=zorder, + label="_nolegend_", + ), + autolim=False, + ) + if created_axes: + plane_axes = [index for index in range(3) if index != "xyz".index(axis)] + ax.set( + xlim=(lower[plane_axes[0]], upper[plane_axes[0]]), + ylim=(lower[plane_axes[1]], upper[plane_axes[1]]), + xlabel=f"{'xyz'[plane_axes[0]]} (µm)", + ylabel=f"{'xyz'[plane_axes[1]]} (µm)", + title=f"FDTD · {axis} = {position_um:g} µm", + aspect=aspect, + ) + return ax + + +def _add_slice_legend( + ax: Axes, + created_figure: Figure | None, + group_handles: Mapping[PolyCollection, list[Patch]], +) -> None: + """Retain each physical group's legend entry after combining its fill.""" + handles = [] + for handle in ax.get_legend_handles_labels()[0]: + if isinstance(handle, PolyCollection) and handle in group_handles: + handles.extend(group_handles[handle]) + else: + handles.append(handle) + if created_figure is not None: + unique_handles = {} + for handle in handles: + unique_handles.setdefault(str(handle.get_label()), handle) + if unique_handles: + created_figure.legend( + list(unique_handles.values()), + list(unique_handles), + loc="outside lower center", + ncols=min(4, len(unique_handles)), + fancybox=True, + framealpha=1, + ) + elif handles: + ax.legend( + handles, + [str(handle.get_label()) for handle in handles], + loc="upper center", + bbox_to_anchor=(0.5, -0.2), + ncols=min(4, len(handles)), + framealpha=1, + ) + + def plot_mesh_slice( mesh_path: Path, manifest: MeshManifest, @@ -60,6 +204,7 @@ def plot_mesh_slice( axis: Axis, position_um: float, show_mesh: bool = False, + outline_only: bool = False, show_groups: Sequence[str] | None = None, hide_groups: Sequence[str] = (), ax: Axes | None = None, @@ -74,6 +219,8 @@ def plot_mesh_slice( validate_slice(axis, position_um) if aspect not in {"equal", "auto"}: raise ValueError("aspect must be 'equal' or 'auto'") + if outline_only and show_mesh: + raise ValueError("outline_only=True cannot be combined with show_mesh=True") normal_axis = "xyz".index(axis) lower = np.asarray(manifest.domain_min_nm) / 1000 upper = np.asarray(manifest.domain_max_nm) / 1000 @@ -87,6 +234,23 @@ def plot_mesh_slice( {**manifest.volumes, **manifest.layers}.values(), key=lambda group: group.priority, ) + if outline_only: + return _plot_material_outlines( + sections, + [ + group + for group in groups + if group.name not in hide_groups + and (show_groups is None or group.name in show_groups) + ], + lower, + upper, + axis=axis, + position_um=position_um, + ax=ax, + aspect=aspect, + figsize=figsize, + ) values = [1.0, *(material.refractive_index for material in materials.values())] minimum, maximum = min(values), max(values) if minimum == maximum: @@ -100,6 +264,7 @@ def plot_mesh_slice( created_figure = None if ax is None: created_figure, ax = plt.subplots(figsize=figsize, constrained_layout=True) + batches: list[_MaterialBatch] = [] for group in groups: if group.name in hide_groups or ( show_groups is not None and group.name not in show_groups @@ -108,19 +273,43 @@ def plot_mesh_slice( polygons = sections.get(group.physical_tag, []) if not polygons: continue + color = colormap(normalization(materials[group.material].refractive_index)) + if not show_mesh and batches and batches[-1].color == color: + batches[-1].groups.append(group) + batches[-1].polygons.extend(polygons) + else: + batches.append(_MaterialBatch([group], list(polygons), color)) + group_handles = {} + for batch in batches: + labels = [f"{group.name} ({group.material})" for group in batch.groups] collection = PolyCollection( - polygons, - facecolors=[ - colormap(normalization(materials[group.material].refractive_index)) - ], + batch.polygons if show_mesh else [], + facecolors=[batch.color], edgecolors="#444444" if show_mesh else "none", linewidths=0.35 if show_mesh else 0, - antialiaseds=show_mesh, - label=f"{group.name} ({group.material})", - # Rasterize the mesh so vector exports (SVG, PDF) stay small. - rasterized=True, + antialiaseds=True, + label=", ".join(labels), + # Detailed mesh overlays stay compact; material outlines stay vector. + rasterized=show_mesh, ) + if not show_mesh: + # Shared edges must be antialiased once, including across groups. + paths = _material_paths(batch.polygons) + collection.set_verts_and_codes( + [path.vertices for path in paths], + # Matplotlib's stubs omit the outer sequence of code arrays. + [path.codes for path in paths], # ty: ignore[invalid-argument-type] + ) ax.add_collection(collection) + group_handles[collection] = [ + Patch( + facecolor=batch.color, + edgecolor="#444444" if show_mesh else "none", + linewidth=0.35 if show_mesh else 0, + label=label, + ) + for label in labels + ] plane_axes = [index for index in range(3) if index != normal_axis] draw_setup_overlay( ax, @@ -146,14 +335,5 @@ def plot_mesh_slice( label=f"Refractive index n at \u03bb={simulation.source.wavelength_um:g} µm", ) if legend: - if created_figure is not None: - add_bottom_legend(created_figure, ax) - else: - _, labels = ax.get_legend_handles_labels() - ax.legend( - loc="upper center", - bbox_to_anchor=(0.5, -0.2), - ncols=min(4, len(labels)), - framealpha=1, - ) + _add_slice_legend(ax, created_figure, group_handles) return ax diff --git a/src/gsim/fdtd/visualization.py b/src/gsim/fdtd/visualization.py index bf8af5c6..94f75935 100644 --- a/src/gsim/fdtd/visualization.py +++ b/src/gsim/fdtd/visualization.py @@ -7,7 +7,7 @@ from collections.abc import Sequence from pathlib import Path from tempfile import TemporaryDirectory -from typing import TYPE_CHECKING, Any, Literal, cast +from typing import TYPE_CHECKING, Any, Literal, cast, overload from gsim.fdtd.api import PortSource from gsim.fdtd.mesh import generate_mesh @@ -21,6 +21,7 @@ from gsim.common.pdk import ResolvedPassivePcell from gsim.fdtd.api import Geometry from gsim.fdtd.models import SimulationArtifacts + from gsim.fdtd.viz import MeshViewer class VisualizationMixin(RuntimeConfigMixin): @@ -49,6 +50,7 @@ def plot_2d_static( axis: Axis = "z", position_um: float | None = None, show_mesh: bool = False, + outline_only: bool = False, show_groups: Sequence[str] | None = None, hide_groups: Sequence[str] = (), ax: Axes | None = None, @@ -71,12 +73,19 @@ def plot_2d_static( Geometry is resolved afresh so edits to the setup are reflected. A temporary mesh is generated without writing run artifacts or requiring a selected source port. + + Use outline_only=True to draw material boundaries over field data on + ax, without fills, setup annotations, a legend or an index colorbar. + Supplied axes retain their limits, labels, title and aspect. Group + filters still apply; outline_only cannot be combined with show_mesh. """ from gsim.fdtd.slice_viz import plot_mesh_slice validate_slice(axis, position_um) if aspect not in {"equal", "auto"}: raise ValueError("aspect must be 'equal' or 'auto'") + if outline_only and show_mesh: + raise ValueError("outline_only=True cannot be combined with show_mesh=True") resolved = self._resolve_geometry() if isinstance(self.source, PortSource) and self.source.port is not None: self._selected_port_name() @@ -111,6 +120,7 @@ def plot_2d_static( axis=axis, position_um=position_um, show_mesh=show_mesh, + outline_only=outline_only, show_groups=show_groups, hide_groups=hide_groups, ax=ax, @@ -201,36 +211,119 @@ def plot_3d( use_pdk_styles=True, ) + @overload def plot_2d( self, *, + height: int, + zoom_to_cursor: bool | None = None, axis: Axis = "z", position_um: float | None = None, show_mesh: bool = False, show_groups: Sequence[str] | None = None, hide_groups: Sequence[str] = (), - zoom_to_cursor: bool = True, - height: int = 600, - ) -> Any: - """Show the HTML slice viewer through its deprecated original name. + ) -> MeshViewer: ... + + @overload + def plot_2d( + self, + *, + zoom_to_cursor: bool, + height: int | None = None, + axis: Axis = "z", + position_um: float | None = None, + show_mesh: bool = False, + show_groups: Sequence[str] | None = None, + hide_groups: Sequence[str] = (), + ) -> MeshViewer: ... - Use plot_2d_interactive() for the same viewer and plot_2d_static() for - a Matplotlib cross-section. All original keywords remain supported. + @overload + def plot_2d( + self, + *, + axis: Axis = "z", + position_um: float | None = None, + show_mesh: bool = False, + outline_only: bool = False, + show_groups: Sequence[str] | None = None, + hide_groups: Sequence[str] = (), + ax: Axes | None = None, + cmap: str = "Blues", + legend: bool = True, + aspect: Literal["equal", "auto"] = "equal", + figsize: tuple[float, float] | None = None, + ) -> Axes: ... + + def plot_2d( + self, + *, + axis: Axis = "z", + position_um: float | None = None, + show_mesh: bool = False, + outline_only: bool = False, + show_groups: Sequence[str] | None = None, + hide_groups: Sequence[str] = (), + ax: Axes | None = None, + cmap: str = "Blues", + legend: bool = True, + aspect: Literal["equal", "auto"] = "equal", + figsize: tuple[float, float] | None = None, + height: int | None = None, + zoom_to_cursor: bool | None = None, + ) -> Axes | MeshViewer: + """Plot a static Matplotlib cross-section, as with plot_2d_static(). + + Use plot_2d_interactive() for the HTML viewer with a movable slice. + Legacy height or zoom_to_cursor arguments retain the HTML viewer with + a DeprecationWarning; they cannot be combined with static-only options. + Their removal targets 0.12.0 or later, after at least three months since + the first released warning in 0.10.0. """ - warnings.warn( - "plot_2d() is deprecated; use plot_2d_interactive() for the HTML " - "viewer or plot_2d_static() for a Matplotlib cross-section.", - DeprecationWarning, - stacklevel=2, - ) - return self.plot_2d_interactive( + if height is not None or zoom_to_cursor is not None: + if ( + outline_only + or ax is not None + or cmap != "Blues" + or not legend + or aspect != "equal" + or figsize is not None + ): + raise TypeError( + "Legacy viewer keywords cannot be combined " + "with static plot options. " + "Use plot_2d_static() or plot_2d_interactive() explicitly." + ) + warnings.warn( + "The height and zoom_to_cursor viewer keywords " + "on plot_2d() are deprecated. " + "Use plot_2d_interactive() for the HTML viewer; " + "plot_2d() without viewer keywords now returns a Matplotlib Axes. " + "Removal targets 0.12.0 or later, after two feature releases and " + "at least three months since the first released warning in 0.10.0.", + DeprecationWarning, + stacklevel=2, + ) + return self.plot_2d_interactive( + axis=axis, + position_um=position_um, + show_mesh=show_mesh, + show_groups=show_groups, + hide_groups=hide_groups, + height=600 if height is None else height, + zoom_to_cursor=True if zoom_to_cursor is None else zoom_to_cursor, + ) + return self.plot_2d_static( axis=axis, position_um=position_um, show_mesh=show_mesh, + outline_only=outline_only, show_groups=show_groups, hide_groups=hide_groups, - zoom_to_cursor=zoom_to_cursor, - height=height, + ax=ax, + cmap=cmap, + legend=legend, + aspect=aspect, + figsize=figsize, ) def plot_2d_interactive( diff --git a/tests/fdtd/test_preview_validation.py b/tests/fdtd/test_preview_validation.py index 2b6c6fac..0961dcee 100644 --- a/tests/fdtd/test_preview_validation.py +++ b/tests/fdtd/test_preview_validation.py @@ -21,7 +21,7 @@ {"height": 500, "zoom_to_cursor": False}, ], ) -def test_legacy_plot_2d_preserves_html_viewer(tmp_path, options, monkeypatch): +def test_explicit_interactive_plot_keeps_viewer_options(tmp_path, options, monkeypatch): simulation = fdtd.Simulation() cast(Any, simulation)._last_artifacts = SimpleNamespace( mesh_path=_write_mesh(tmp_path / "mesh.msh") @@ -29,15 +29,14 @@ def test_legacy_plot_2d_preserves_html_viewer(tmp_path, options, monkeypatch): monkeypatch.setattr( simulation, "write", lambda _directory: simulation._last_artifacts ) - with pytest.warns(DeprecationWarning, match=r"plot_2d_interactive.*plot_2d_static"): - viewer = simulation.plot_2d( - axis="y", - position_um=0.25, - show_mesh=True, - show_groups=["core"], - hide_groups=["port_o2"], - **options, - ) + viewer = simulation.plot_2d_interactive( + axis="y", + position_um=0.25, + show_mesh=True, + show_groups=["core"], + hide_groups=["port_o2"], + **options, + ) assert isinstance(viewer, MeshViewer) assert '"mode":"mesh_slice"' in viewer.html assert '"axis":"y"' in viewer.html @@ -47,11 +46,11 @@ def test_legacy_plot_2d_preserves_html_viewer(tmp_path, options, monkeypatch): assert f'height="{options.get("height", 600)}"' in viewer._repr_html_() zoom = "true" if options.get("zoom_to_cursor", True) else "false" assert f'"zoomToCursor":{zoom}' in viewer.html - output = viewer.save(tmp_path / "legacy.html") + output = viewer.save(tmp_path / "interactive.html") assert output.read_text(encoding="utf8") == viewer.html -def test_legacy_plot_2d_keeps_default_slice_mode(tmp_path, monkeypatch): +def test_explicit_interactive_plot_keeps_default_slice_mode(tmp_path, monkeypatch): simulation = fdtd.Simulation() cast(Any, simulation)._last_artifacts = SimpleNamespace( mesh_path=_write_mesh(tmp_path / "mesh.msh") @@ -59,12 +58,80 @@ def test_legacy_plot_2d_keeps_default_slice_mode(tmp_path, monkeypatch): monkeypatch.setattr( simulation, "write", lambda _directory: simulation._last_artifacts ) - with pytest.warns(DeprecationWarning, match=r"plot_2d\(\) is deprecated"): - viewer = simulation.plot_2d() + viewer = simulation.plot_2d_interactive() assert isinstance(viewer, MeshViewer) assert '"mode":"slice"' in viewer.html +@pytest.mark.parametrize( + "options", + [ + {"height": 500}, + {"zoom_to_cursor": False}, + {"zoom_to_cursor": True}, + {"height": 500, "zoom_to_cursor": False}, + ], +) +@pytest.mark.parametrize("show_mesh", [False, True]) +def test_legacy_viewer_keywords_warn_and_keep_html_behavior( + tmp_path, monkeypatch, options, show_mesh +): + simulation = fdtd.Simulation() + cast(Any, simulation)._last_artifacts = SimpleNamespace( + mesh_path=_write_mesh(tmp_path / "mesh.msh") + ) + monkeypatch.setattr( + simulation, "write", lambda _directory: simulation._last_artifacts + ) + with pytest.warns(DeprecationWarning, match="plot_2d_interactive") as captured: + viewer = simulation.plot_2d( + axis="y", + position_um=0.25, + show_mesh=show_mesh, + show_groups=["core"], + hide_groups=["port_o2"], + **options, + ) + warning = str(captured[0].message) + assert "0.12.0 or later" in warning + assert "two feature releases" in warning + assert "at least three months" in warning + assert "first released warning in 0.10.0" in warning + assert isinstance(viewer, MeshViewer) + mode = "mesh_slice" if show_mesh else "slice" + assert f'"mode":"{mode}"' in viewer.html + assert '"axis":"y"' in viewer.html + assert '"positionUm":0.25' in viewer.html + assert '"showGroups":["core"]' in viewer.html + assert '"hideGroups":["port_o2"]' in viewer.html + assert f'height="{options.get("height", 600)}"' in viewer._repr_html_() + zoom = "true" if options.get("zoom_to_cursor", True) else "false" + assert f'"zoomToCursor":{zoom}' in viewer.html + + +@pytest.mark.parametrize( + "static_options", + [ + {"outline_only": True}, + {"ax": object()}, + {"cmap": "viridis"}, + {"legend": False}, + {"aspect": "auto"}, + {"figsize": (8, 3)}, + ], +) +def test_legacy_viewer_keywords_reject_static_options_before_meshing( + monkeypatch, static_options +): + from gsim.fdtd import visualization + + monkeypatch.setattr( + visualization, "generate_mesh", lambda *_args, **_kwargs: pytest.fail("meshed") + ) + with pytest.raises(TypeError, match="cannot be combined"): + fdtd.Simulation().plot_2d(height=500, **static_options) + + def test_static_preview_rejects_unknown_selected_port_before_meshing( fdtd_pdk_module, monkeypatch ): diff --git a/tests/fdtd/test_static_outlines.py b/tests/fdtd/test_static_outlines.py new file mode 100644 index 00000000..942c1c69 --- /dev/null +++ b/tests/fdtd/test_static_outlines.py @@ -0,0 +1,244 @@ +"""Analytic interfaces and field-axis preservation for outline-only previews.""" + +from __future__ import annotations + +from io import StringIO +from pathlib import Path + +import matplotlib as mpl + +mpl.use("Agg") + +import matplotlib.pyplot as plt +import numpy as np +import pytest +from matplotlib.collections import LineCollection +from matplotlib.colors import SymLogNorm +from matplotlib.figure import Figure + +from gsim import fdtd +from gsim.fdtd.models import MeshGroup, MeshManifest +from gsim.fdtd.slice_viz import plot_mesh_slice +from tests.fdtd.test_static_viz import _tetrahedron_view + + +@pytest.fixture(autouse=True) +def close_figures(): + yield + plt.close("all") + + +def _rectangle(xmin, ymin, xmax, ymax): + return np.asarray([(xmin, ymin), (xmax, ymin), (xmax, ymax), (xmin, ymax)]) + + +def _outline_view(monkeypatch, regions, **kwargs): + from gsim.fdtd import slice_viz + + groups = {} + sections = {} + for tag, (name, material, priority, polygons) in enumerate(regions, start=1): + groups[name] = MeshGroup(name, tag, material, priority) + sections[tag] = polygons + monkeypatch.setattr(slice_viz, "_mesh_sections", lambda *_args: sections) + manifest = MeshManifest({}, groups, {}, (0, 0, 0), (1000, 1000, 1000)) + return plot_mesh_slice( + Path("unused.msh"), + manifest, + {}, + fdtd.Simulation(), + axis="z", + position_um=0.5, + outline_only=True, + **kwargs, + ) + + +def _outline_length(ax): + return sum( + np.linalg.norm(np.diff(segment, axis=0), axis=1).sum() + for collection in ax.collections + for segment in collection.get_segments() + ) + + +@pytest.mark.parametrize("axis", ["x", "y", "z"]) +def test_outline_uses_actual_tetrahedron_section_without_setup_marks(tmp_path, axis): + ax = _tetrahedron_view(tmp_path, axis=axis, position_um=0.25, outline_only=True) + assert len(ax.figure.axes) == 1 + assert len(ax.collections) == 1 + assert isinstance(ax.collections[0], LineCollection) + assert np.asarray(ax.collections[0].get_facecolor()).size == 0 + assert not ax.patches + assert not ax.texts + assert ax.get_legend() is None + assert not ax.figure.legends + assert ax.get_xlim() == pytest.approx((0, 1)) + assert ax.get_ylim() == pytest.approx((0, 1)) + # The analytic cut has two legs of 0.75 um and a hypotenuse of 0.75 sqrt(2). + assert _outline_length(ax) == pytest.approx(1.5 + 0.75 * np.sqrt(2)) + output = StringIO() + figure = ax.figure + assert isinstance(figure, Figure) + figure.savefig(output, format="svg") + assert " image.get_zorder() + assert not ax.texts + assert not ax.patches + assert simulation._last_artifacts is None + + +@pytest.mark.parametrize("method", ["plot_2d", "plot_2d_static"]) +def test_outline_rejects_mesh_edges_before_meshing(monkeypatch, method): + from gsim.fdtd import visualization + + monkeypatch.setattr( + visualization, "generate_mesh", lambda *_args, **_kwargs: pytest.fail("meshed") + ) + with pytest.raises(ValueError, match=r"outline_only.*show_mesh"): + getattr(fdtd.Simulation(), method)(outline_only=True, show_mesh=True) diff --git a/tests/fdtd/test_static_rendering.py b/tests/fdtd/test_static_rendering.py new file mode 100644 index 00000000..f23a65f1 --- /dev/null +++ b/tests/fdtd/test_static_rendering.py @@ -0,0 +1,272 @@ +"""Rendered material-boundary, hole, and vector-export regression checks.""" + +from __future__ import annotations + +from io import StringIO +from types import SimpleNamespace +from typing import Any, cast + +import matplotlib as mpl + +mpl.use("Agg") + +import matplotlib.pyplot as plt +import numpy as np +import pytest +from matplotlib.backends.backend_agg import FigureCanvasAgg +from matplotlib.path import Path as MatplotlibPath + +from gsim import fdtd +from gsim.fdtd.models import MeshGroup, MeshManifest +from gsim.fdtd.slice_viz import plot_mesh_slice +from tests.fdtd.test_static_viz import _tetrahedron_view +from tests.fdtd.test_viz import _write_mesh + + +@pytest.fixture(autouse=True) +def close_figures(): + yield + plt.close("all") + + +def _ring_view(tmp_path, monkeypatch, *, show_mesh=False): + from gsim.fdtd import slice_viz + + polygons = [] + # A 4-by-4 ring with a 2-by-2 hole, plus a disconnected unit square. + cells = [ + (x, y) for x in range(4) for y in range(4) if not (1 <= x < 3 and 1 <= y < 3) + ] + cells.append((5, 0)) + for x, y in cells: + polygons.extend( + [ + np.asarray([(x, y), (x + 1, y), (x + 1, y + 1)]), + np.asarray([(x, y), (x + 1, y + 1), (x, y + 1)]), + ] + ) + monkeypatch.setattr(slice_viz, "_mesh_sections", lambda *_args: {2: polygons}) + simulation = fdtd.Simulation(domain={"pml_cells": 0}) + cast(Any, simulation)._resolved = SimpleNamespace(ports={}, layers={}) + manifest = MeshManifest( + {}, {"core": MeshGroup("core", 2, "Si", 1)}, {}, (0, 0, 0), (6000, 4000, 1000) + ) + return plot_mesh_slice( + _write_mesh(tmp_path / "mesh.msh"), + manifest, + {"Si": cast(Any, SimpleNamespace(refractive_index=3.5))}, + simulation, + axis="z", + position_um=0.5, + show_mesh=show_mesh, + legend=False, + ) + + +def _pixel_at(ax, x, y): + pixels = np.asarray(ax.figure.canvas.buffer_rgba()) + column, row = np.rint(ax.transData.transform((x, y))).astype(int) + return pixels[pixels.shape[0] - 1 - row, column, :3] + + +def test_material_regions_keep_holes_islands_and_shared_edges(tmp_path, monkeypatch): + ax = _ring_view(tmp_path, monkeypatch) + paths = ax.collections[0].get_paths() + assert len(paths) == 2 + area = 0 + ring_count = 0 + for path in paths: + starts = np.flatnonzero(path.codes == MatplotlibPath.MOVETO) + for ring in np.split(path.vertices, starts[1:]): + ring_count += 1 + area += ( + np.dot(ring[:, 0], np.roll(ring[:, 1], -1)) + - np.dot(ring[:, 1], np.roll(ring[:, 0], -1)) + ) / 2 + assert ring_count == 3 + assert area == pytest.approx(4 * 4 - 2 * 2 + 1) + + ax.figure.canvas.draw() + # Actual rendered pixels retain the hole and the gap between the islands. + np.testing.assert_array_equal(_pixel_at(ax, 2, 2), [255, 255, 255]) + np.testing.assert_array_equal(_pixel_at(ax, 4.5, 0.5), [255, 255, 255]) + material = _pixel_at(ax, 0.5, 0.25) + assert np.all(material < 200) + # Shared cell edges and the diagonal through a mesh cell have no white seams. + for point in [(1, 0.5), (0.5, 0.5), (5.5, 0.5)]: + np.testing.assert_array_equal(_pixel_at(ax, *point), material) + + +def test_sloping_material_boundary_has_antialiased_pixels(tmp_path): + ax = _tetrahedron_view( + tmp_path, axis="z", position_um=0.25, pml_cells=0, legend=False + ) + canvas = ax.figure.canvas + assert isinstance(canvas, FigureCanvasAgg) + canvas.draw() + # Inspect an interior crop crossing x+y=0.75, away from axes and overlays. + pixels = np.asarray(canvas.buffer_rgba()) + lower, upper = ax.transData.transform([(0.2, 0.2), (0.55, 0.55)]) + x0, y0 = np.floor(lower).astype(int) + x1, y1 = np.ceil(upper).astype(int) + crop = pixels[pixels.shape[0] - y1 : pixels.shape[0] - y0, x0:x1, :3] + fill = np.rint(np.asarray(ax.collections[0].get_facecolor())[0, :3] * 255) + blended = np.all(crop > fill + 1, axis=2) & np.all(crop < 254, axis=2) + assert np.count_nonzero(blended) > 10 + + +def test_material_outlines_preserve_a_narrow_gap(tmp_path, monkeypatch): + from gsim.fdtd import slice_viz + + gap = 2e-5 + left, right = 0.5 - gap / 2, 0.5 + gap / 2 + polygons = [ + np.asarray([(0, 0), (left, 0), (left, 1), (0, 1)]), + np.asarray([(right, 0), (1, 0), (1, 1), (right, 1)]), + ] + monkeypatch.setattr(slice_viz, "_mesh_sections", lambda *_args: {2: polygons}) + ax = _tetrahedron_view( + tmp_path, axis="z", position_um=0.25, pml_cells=0, legend=False + ) + vertices = sorted( + [np.asarray(path.vertices) for path in ax.collections[0].get_paths()], + key=lambda coordinates: coordinates[:, 0].min(), + ) + assert len(vertices) == 2 + assert vertices[0][:, 0].max() == pytest.approx(left, abs=1e-12) + assert vertices[1][:, 0].min() == pytest.approx(right, abs=1e-12) + assert vertices[1][:, 0].min() - vertices[0][:, 0].max() == pytest.approx(gap) + + +@pytest.mark.parametrize("show_mesh", [False, True]) +def test_svg_keeps_material_outlines_vector_and_mesh_overlay_compact( + tmp_path, monkeypatch, show_mesh +): + ax = _ring_view(tmp_path, monkeypatch, show_mesh=show_mesh) + ax.figure.axes[1].set_visible(False) # Exclude the rasterized colorbar. + output = StringIO() + ax.figure.savefig(output, format="svg") + assert (" 100 # Oxide covers the lower silicon. + assert _pixel_at(ax, 0.75, 0.5)[0] < 50 # Higher-priority silicon covers oxide. diff --git a/tests/fdtd/test_static_viz.py b/tests/fdtd/test_static_viz.py index e2d90567..a3b6844f 100644 --- a/tests/fdtd/test_static_viz.py +++ b/tests/fdtd/test_static_viz.py @@ -148,7 +148,7 @@ def test_outside_slice_has_clear_error(tmp_path, position): _tetrahedron_view(tmp_path, axis="z", position_um=position) -@pytest.mark.parametrize("method", ["plot_2d_static", "plot_2d_interactive"]) +@pytest.mark.parametrize("method", ["plot_2d", "plot_2d_static", "plot_2d_interactive"]) @pytest.mark.parametrize( "kwargs", [{"axis": "bad"}, {"position_um": float("nan")}, {"position_um": float("inf")}], @@ -158,13 +158,14 @@ def test_invalid_slice_is_rejected_before_geometry(method, kwargs): getattr(fdtd.Simulation(), method)(**kwargs) +@pytest.mark.parametrize("method", ["plot_2d", "plot_2d_static"]) def test_public_static_workflow_tracks_geometry_and_material_edits( - tmp_path, fdtd_pdk_module + tmp_path, fdtd_pdk_module, method ): simulation = fdtd.Simulation(pdk=fdtd_pdk_module, mesh_size_nm=750) simulation.geometry("straight") # A preview should work before choosing a source port or writing a job. - ax = simulation.plot_2d_static() + ax = getattr(simulation, method)() assert isinstance(ax, Axes) assert "z = 0.11 µm" in ax.get_title() assert simulation._last_artifacts is None @@ -174,7 +175,7 @@ def test_public_static_workflow_tracks_geometry_and_material_edits( simulation.materials(overrides={"Si": {"refractive_index": 2.5}}) simulation.geometry("straight", settings={"length": 3}) simulation.add_monitors(xy_name="field_xy") - updated = simulation.plot_2d_static(show_mesh=True) + updated = getattr(simulation, method)(show_mesh=True) assert simulation._last_artifacts is artifacts assert updated.get_xlim()[1] == pytest.approx(3 + 32 * 0.06) assert {text.get_text() for text in updated.texts} >= { @@ -219,20 +220,23 @@ def overlapping_mesh(path): simulation = fdtd.Simulation() cast(Any, simulation)._resolved = SimpleNamespace(ports={}, layers={}) high = MeshGroup("high", 2, "Si", 2) - low = replace(high, name="low", physical_tag=3, priority=1) + low = replace(high, name="low", physical_tag=3, priority=1, material="Oxide") manifest = MeshManifest( {}, {"high": high, "low": low}, {}, (0, 0, 0), (1000, 1000, 1000) ) ax = plot_mesh_slice( _write_mesh(tmp_path / "mesh.msh"), manifest, - {"Si": cast(Any, SimpleNamespace(refractive_index=3.5))}, + { + "Si": cast(Any, SimpleNamespace(refractive_index=3.5)), + "Oxide": cast(Any, SimpleNamespace(refractive_index=1.5)), + }, simulation, axis="z", position_um=0.25, ) assert [collection.get_label() for collection in ax.collections] == [ - "low (Si)", + "low (Oxide)", "high (Si)", ]