"""Specialized Matplotlib viewers for Buffalo Panel GUI."""
# pyright: reportUnknownMemberType=false
# pyright: reportUnknownVariableType=false
# pyright: reportUnknownArgumentType=false
from __future__ import annotations
from contextlib import suppress
from typing import Any, cast
import matplotlib.tri as mtri
import numpy as np
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.figure import Figure
from mpl_toolkits.axes_grid1 import ( # type: ignore[import-untyped]
make_axes_locatable,
)
from PyQt6 import QtWidgets
from buffalo_panel.app.gui.internal.view_logic import (
ViewState,
expand_limits_to_aspect,
generate_field_grid,
get_adaptive_field_resolution,
get_default_view_limits,
get_field_surface_exclusion_radius,
interpolate_surface_nodes,
is_far_from_geometry_panels,
is_outside_body,
)
from buffalo_panel.post import FieldPoints2D
from buffalo_panel.type_aliases import FloatArray
[docs]
class MplCanvas(FigureCanvas):
"""Base interactive canvas for aerodynamic plots."""
def __init__(
self,
parent: QtWidgets.QWidget | None = None,
width: float = 5,
height: float = 4,
dpi: int = 100,
) -> None:
self.fig = Figure(figsize=(width, height), dpi=dpi)
self.ax = self.fig.add_subplot(111)
super().__init__(self.fig) # type: ignore[no-untyped-call]
self.setParent(parent)
self.ax.set_aspect("equal", adjustable="datalim")
self.ax.grid(True, linestyle="--", alpha=0.6)
self.toolbar: Any | None = None
[docs]
class BaseViewer(MplCanvas):
"""Base class for viewers with common rendering lifecycle."""
def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
super().__init__(parent)
self.view_state = ViewState()
[docs]
def prepare_axes(self) -> None:
"""Clear and standardize axes before rendering."""
self.ax.clear()
self.ax.set_autoscale_on(False)
self.ax.set_aspect("equal", adjustable="datalim")
self.ax.grid(True, linestyle="--", alpha=0.6)
[docs]
def apply_view(self) -> None:
"""Sync the physical axes with the internal ViewState."""
self.ax.set_xlim(self.view_state.xlim)
self.ax.set_ylim(self.view_state.ylim)
# Force layout update to ensure aspect ratio is honored
self.fig.canvas.draw()
self.flush_events() # type: ignore[no-untyped-call]
[docs]
class GeometryViewer(BaseViewer):
"""Handles body previews and freestream indicators."""
def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
super().__init__(parent)
self._arrow: Any = None
self._ref_line: Any = None
self._label: Any = None
[docs]
def render(
self,
bodies_data: list[dict[str, Any]],
alpha: float,
units: dict[str, str],
) -> None:
"""Render the geometry preview and freestream indicator."""
self.ax.clear()
self.ax.set_visible(True)
self.ax.set_autoscale_on(True)
all_x, all_y = [], []
for body in bodies_data:
x, y = body["x"], body["y"]
self.ax.plot(x, y, label=body["id"], linewidth=1.5)
self.ax.fill(x, y, alpha=0.15)
all_x.extend(x)
all_y.extend(y)
self.ax.set_xlabel(f"x [{units.get('length', 'm')}]")
self.ax.set_ylabel(f"y [{units.get('length', 'm')}]")
self.ax.set_aspect("equal", adjustable="datalim")
self.ax.grid(True, linestyle="--", alpha=0.6)
if all_x:
self.ax.legend(loc="upper right")
self.ax.relim()
self.ax.autoscale_view()
self.ax.apply_aspect()
self.ax.set_autoscale_on(False)
self._draw_freestream_arrow(alpha)
self.draw() # type: ignore[no-untyped-call]
self.flush_events() # type: ignore[no-untyped-call]
def _draw_freestream_arrow(self, alpha: float) -> None:
with suppress(Exception):
for artist in [self._arrow, self._ref_line, self._label]:
if artist:
artist.remove()
xlim, ylim = self.ax.get_xlim(), self.ax.get_ylim()
dx, dy = xlim[1] - xlim[0], ylim[1] - ylim[0]
arrow_l = 0.2 * dx
x_tail, y_pos = xlim[0] + 0.05 * dx, ylim[0] + 0.05 * dy
rad = np.deg2rad(alpha)
u, v = np.cos(rad), np.sin(rad)
self._arrow = self.ax.quiver(
x_tail,
y_pos,
u,
v,
width=0.005,
scale=1.0 / arrow_l,
scale_units="xy",
zorder=15,
)
self._label = self.ax.text(
x_tail + 0.5 * arrow_l * u,
y_pos + 0.5 * arrow_l * v + 0.02 * dy,
r"$U_\infty$",
fontsize=14,
ha="center",
zorder=16,
)
(self._ref_line,) = self.ax.plot(
[x_tail, x_tail + arrow_l],
[y_pos, y_pos],
color="gray",
linestyle="--",
zorder=14,
)
[docs]
class SurfaceViewer(BaseViewer):
"""Handles surface distributions and interactive cursors."""
def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
super().__init__(parent)
self.ax.set_aspect("auto")
[docs]
def render(
self, x: Any, y: Any, label: str, invert_y: bool = False
) -> None:
"""Render a one-dimensional surface quantity distribution."""
self.ax.clear()
self.ax.set_visible(True)
self.ax.set_aspect("auto")
self.ax.plot(x, y, "b-o", markersize=3)
self.ax.set_xlabel("$x/c$")
self.ax.set_ylabel(label)
if invert_y:
self.ax.invert_yaxis()
self.ax.grid(True, linestyle="--", alpha=0.6)
# Pad to match FlowViewer width
divider = make_axes_locatable(self.ax)
divider.append_axes("right", size="5%", pad=0.1).set_visible(False)
self.draw() # type: ignore[no-untyped-call]
self.flush_events() # type: ignore[no-untyped-call]
[docs]
class FlowViewer(BaseViewer):
"""Handles streamlines and pressure contours with ViewState awareness."""
def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
super().__init__(parent)
self.colorbar: Any | None = None
self._colorbar_axes: Any | None = None
[docs]
def render(
self, results: Any, mode: str, density: float, view_state: ViewState
) -> None:
"""Render the active flow-field view for the current viewport."""
self.view_state = view_state
ax = self.ax
# 1. Clean up and layout sync
if self.colorbar:
self.colorbar.remove()
self.colorbar = None
for other_ax in self.fig.axes[:]:
if other_ax is not ax:
self.fig.delaxes(other_ax)
self._colorbar_axes = None
ax.clear()
ax.set_visible(True)
ax.set_autoscale_on(True)
ax.set_aspect("equal", adjustable="datalim")
self.fig.canvas.draw()
cax = self._create_colorbar_axes() if mode != "Streamlines" else None
# 2. Limit Calculation
xlim, ylim = view_state.xlim, view_state.ylim
bbox = ax.get_window_extent()
xlim, ylim = expand_limits_to_aspect(
xlim, ylim, bbox.width, bbox.height
)
ax.set_xlim(xlim)
ax.set_ylim(ylim)
ax.set_autoscale_on(False)
# 3. Grid Generation
geom = results.geometry
default_xlim, default_ylim = get_default_view_limits(geom.x, geom.y)
resolution = get_adaptive_field_resolution(
default_xlim,
default_ylim,
xlim,
ylim,
)
x_field, y_field = generate_field_grid(
xlim,
ylim,
resolution=resolution,
)
outside = is_outside_body(geom.x, geom.y, x_field, y_field)
panel_clear = is_far_from_geometry_panels(
geom.x,
geom.y,
x_field,
y_field,
min_distance=get_field_surface_exclusion_radius(
xlim,
ylim,
resolution,
),
)
outside = np.logical_and(outside, panel_clear)
# 4. Mode-specific drawing
if mode == "Streamlines":
self._draw_streamlines(
results, geom, x_field, y_field, outside, density
)
else:
self._draw_contours(
results, geom, x_field, y_field, outside, density, cax
)
# 5. Body overlay
ax.fill(
geom.x,
geom.y,
facecolor="lightgray",
edgecolor="black",
linewidth=1,
zorder=10,
)
ax.set_xlabel("$x/c$")
ax.set_ylabel("$y/c$")
ax.grid(True, linestyle="--", alpha=0.6)
self.draw() # type: ignore[no-untyped-call]
self.flush_events() # type: ignore[no-untyped-call]
def _create_colorbar_axes(self) -> Any:
"""Create a colorbar axes that does not resize the main plot."""
position = self.ax.get_position()
pad = 0.012
width = 0.025
cax = self.fig.add_axes((
position.x1 + pad,
position.y0,
width,
position.height,
))
self._colorbar_axes = cax
return cax
def _draw_streamlines(
self, results: Any, geom: Any, x_f: Any, y_f: Any, out: Any, dens: float
) -> None:
pts = FieldPoints2D.from_inputs(x_f[out], y_f[out])
u_out, v_out = results.velocity_at(pts)
tangent_velocity = getattr(results.surface, "tangent_velocity", None)
normal_velocity = getattr(results.surface, "normal_velocity", None)
supports_surface_velocity = getattr(
results.surface,
"supports_surface_velocity",
tangent_velocity is not None and normal_velocity is not None,
)
x_all = np.asarray(x_f[out], dtype=np.float64)
y_all = np.asarray(y_f[out], dtype=np.float64)
u_all = np.asarray(u_out, dtype=np.float64)
v_all = np.asarray(v_out, dtype=np.float64)
if supports_surface_velocity:
u_s = tangent_velocity * geom.s_x + normal_velocity * geom.n_x
v_s = tangent_velocity * geom.s_y + normal_velocity * geom.n_y
x_all = np.concatenate([x_all, results.surface.x])
y_all = np.concatenate([y_all, results.surface.y])
u_all = np.concatenate([u_all, u_s])
v_all = np.concatenate([v_all, v_s])
tri = mtri.Triangulation(x_all, y_all)
u_full = mtri.LinearTriInterpolator(tri, u_all)(x_f, y_f)
v_full = mtri.LinearTriInterpolator(tri, v_all)(x_f, y_f)
self.ax.streamplot(
x_f,
y_f,
u_full,
v_full,
color="cornflowerblue",
density=dens,
linewidth=1,
)
def _draw_contours(
self,
results: Any,
geom: Any,
x_f: Any,
y_f: Any,
out: Any,
dens: float,
cax: Any,
) -> None:
pts = FieldPoints2D.from_inputs(x_f[out], y_f[out])
cp_out = results.pressure_coefficient_at(pts)
surface_cp = getattr(results.surface, "cp", None)
supports_surface_pressure = getattr(
results.surface,
"supports_surface_pressure",
surface_cp is not None,
)
x_all = np.asarray(x_f[out], dtype=np.float64)
y_all = np.asarray(y_f[out], dtype=np.float64)
cp_all = np.asarray(cp_out, dtype=np.float64)
if supports_surface_pressure:
surface_cp_array = cast(FloatArray, surface_cp)
cp_nodes = interpolate_surface_nodes(
results.surface.x,
results.surface.y,
surface_cp_array,
geom.x,
geom.y,
)
x_all = np.concatenate([x_all, results.surface.x, geom.x])
y_all = np.concatenate([y_all, results.surface.y, geom.y])
cp_all = np.concatenate([cp_all, surface_cp_array, cp_nodes])
finite_cp = cp_all[np.isfinite(cp_all)]
if finite_cp.size == 0:
return
vmin = np.floor(np.min(finite_cp) * 10.0) / 10.0
vmax = np.ceil(np.max(finite_cp) * 10.0) / 10.0
if np.isclose(vmin, vmax):
vmin -= 0.1
vmax += 0.1
n_levels = int(dens * 10)
cont = self.ax.tricontourf(
x_all,
y_all,
cp_all,
levels=np.linspace(vmin, vmax, n_levels + 1),
cmap="RdBu_r",
)
self.colorbar = self.fig.colorbar(cont, cax=cax)
self.colorbar.set_label("$c_p$")