"""Mathematical and geometric logic for GUI view management."""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
import numpy.typing as npt
from matplotlib.path import Path as MplPath
from buffalo_panel.post import FieldPoints2D
from buffalo_panel.post.internal.results import PanelSolution2D
[docs]
@dataclass(slots=True)
class ViewState:
"""Single source of truth for a plot viewport."""
xlim: tuple[float, float] = (0.0, 1.0)
ylim: tuple[float, float] = (0.0, 1.0)
force_home: bool = True
[docs]
@dataclass(slots=True)
class SurfaceProbeSample:
"""Interpolated upper and lower surface values at a single x-location."""
x: float
upper_value: float
lower_value: float
[docs]
def get_default_view_limits(
geometry_x: npt.NDArray[np.float64],
geometry_y: npt.NDArray[np.float64],
) -> tuple[tuple[float, float], tuple[float, float]]:
"""Return default plot limits centered on the geometry."""
if len(geometry_x) == 0:
return (0.0, 1.0), (0.0, 1.0)
x_min, x_max = np.min(geometry_x), np.max(geometry_x)
y_min, y_max = np.min(geometry_y), np.max(geometry_y)
x_mid = (x_min + x_max) / 2.0
y_mid = (y_min + y_max) / 2.0
chord = max(x_max - x_min, 1.0e-6)
return (x_mid - chord, x_mid + chord), (
y_mid - 0.5 * chord,
y_mid + 0.5 * chord,
)
[docs]
def expand_limits_to_aspect(
xlim: tuple[float, float],
ylim: tuple[float, float],
axes_width: float,
axes_height: float,
) -> tuple[tuple[float, float], tuple[float, float]]:
"""
Expand limits to match a physical aspect ratio.
This ensures that 'equal' aspect scaling doesn't cause unexpected
clipping or missing streamlines when the window is resized.
"""
if axes_width <= 0 or axes_height <= 0:
return xlim, ylim
ax_aspect = axes_height / axes_width
dx = xlim[1] - xlim[0]
dy = ylim[1] - ylim[0]
if dy / dx < ax_aspect:
target_dy = dx * ax_aspect
mid_y = (ylim[0] + ylim[1]) / 2
return xlim, (mid_y - target_dy / 2, mid_y + target_dy / 2)
target_dx = dy / ax_aspect
mid_x = (xlim[0] + xlim[1]) / 2
return (mid_x - target_dx / 2, mid_x + target_dx / 2), ylim
[docs]
def is_outside_body(
geometry_x: npt.NDArray[np.float64],
geometry_y: npt.NDArray[np.float64],
x_field: npt.NDArray[np.float64],
y_field: npt.NDArray[np.float64],
) -> npt.NDArray[np.bool_]:
"""Identify field points that are outside the geometry polygon."""
polygon = MplPath(np.column_stack((geometry_x, geometry_y)))
points = np.column_stack((x_field.ravel(), y_field.ravel()))
radius = _polygon_radius(geometry_x, geometry_y)
inside = np.asarray(
polygon.contains_points(points, radius=radius),
dtype=np.bool_,
)
return np.logical_not(inside.reshape(x_field.shape))
[docs]
def get_field_surface_exclusion_radius(
xlim: tuple[float, float],
ylim: tuple[float, float],
resolution: int,
) -> float:
"""Return a surface-exclusion radius based on the current grid spacing."""
if resolution <= 1:
return 0.0
dx = (xlim[1] - xlim[0]) / float(resolution - 1)
dy = (ylim[1] - ylim[0]) / float(resolution - 1)
return float(np.maximum(np.hypot(dx, dy), 0.005))
[docs]
def is_far_from_geometry_panels(
geometry_x: npt.NDArray[np.float64],
geometry_y: npt.NDArray[np.float64],
x_field: npt.NDArray[np.float64],
y_field: npt.NDArray[np.float64],
*,
min_distance: float,
) -> npt.NDArray[np.bool_]:
"""Identify field points that are at least ``min_distance`` from panels."""
if min_distance <= 0.0:
return np.ones_like(x_field, dtype=np.bool_)
point_x = x_field.ravel()
point_y = y_field.ravel()
min_distance_sq: npt.NDArray[np.float64] = np.full(
point_x.shape,
np.inf,
dtype=np.float64,
)
segment_x0 = geometry_x
segment_y0 = geometry_y
segment_x1 = np.roll(geometry_x, -1)
segment_y1 = np.roll(geometry_y, -1)
for x0, y0, x1, y1 in zip(
segment_x0,
segment_y0,
segment_x1,
segment_y1,
strict=True,
):
dx = float(x1 - x0)
dy = float(y1 - y0)
length_sq = dx * dx + dy * dy
if length_sq <= 0.0:
distance_sq = np.asarray(
(point_x - x0) ** 2 + (point_y - y0) ** 2,
dtype=np.float64,
)
else:
projection = ((point_x - x0) * dx + (point_y - y0) * dy) / length_sq
projection = np.clip(projection, 0.0, 1.0)
closest_x = x0 + projection * dx
closest_y = y0 + projection * dy
distance_sq = np.asarray(
(point_x - closest_x) ** 2 + (point_y - closest_y) ** 2,
dtype=np.float64,
)
min_distance_sq = np.asarray(
np.minimum(min_distance_sq, distance_sq),
dtype=np.float64,
)
return (min_distance_sq >= min_distance**2).reshape(x_field.shape)
[docs]
def generate_field_grid(
xlim: tuple[float, float],
ylim: tuple[float, float],
resolution: int = 150,
) -> tuple[npt.NDArray[np.float64], npt.NDArray[np.float64]]:
"""Generate a Cartesian mesh for field evaluation."""
x_grid = np.linspace(xlim[0], xlim[1], resolution)
y_grid = np.linspace(ylim[0], ylim[1], resolution)
# Cast to Any to avoid meshgrid return type ambiguities in some numpy
# versions.
return np.meshgrid(x_grid, y_grid) # type: ignore
[docs]
def get_adaptive_field_resolution(
default_xlim: tuple[float, float],
default_ylim: tuple[float, float],
xlim: tuple[float, float],
ylim: tuple[float, float],
*,
base_resolution: int = 150,
max_resolution: int = 450,
) -> int:
"""Scale field-grid resolution up when the current view is zoomed in."""
default_dx = max(default_xlim[1] - default_xlim[0], 1.0e-12)
default_dy = max(default_ylim[1] - default_ylim[0], 1.0e-12)
dx = max(xlim[1] - xlim[0], 1.0e-12)
dy = max(ylim[1] - ylim[0], 1.0e-12)
zoom_factor = max(default_dx / dx, default_dy / dy, 1.0)
scaled_resolution = int(np.ceil(base_resolution * np.sqrt(zoom_factor)))
return min(max_resolution, max(base_resolution, scaled_resolution))
[docs]
def interpolate_surface_nodes(
x_panels: npt.NDArray[np.float64],
y_panels: npt.NDArray[np.float64],
qty_panels: npt.NDArray[np.float64],
x_nodes: npt.NDArray[np.float64],
y_nodes: npt.NDArray[np.float64],
) -> npt.NDArray[np.float64]:
"""Interpolate panel-centered quantities onto boundary nodes."""
s_full = np.cumsum(
np.sqrt(
np.diff(x_panels, prepend=x_panels[0]) ** 2
+ np.diff(y_panels, prepend=y_panels[0]) ** 2
)
)
# If x_panels represents panel nodes (length N+1) and qty_panels are panel
# values (length N), we use the start of each panel for the xp argument.
# Otherwise assume x_panels are the locations where qty_panels live.
s_xp = s_full[:-1] if len(x_panels) == len(qty_panels) + 1 else s_full
s_nodes = np.cumsum(
np.sqrt(
np.diff(x_nodes, prepend=x_nodes[0]) ** 2
+ np.diff(y_nodes, prepend=y_nodes[0]) ** 2
)
)
return np.interp(s_nodes, s_xp, qty_panels)
[docs]
def sample_surface_quantity_at_x(
surface_x: npt.NDArray[np.float64],
surface_quantity: npt.NDArray[np.float64],
x_coord: float,
) -> SurfaceProbeSample | None:
"""Interpolate upper and lower surface values at one x-location."""
x_min = float(np.min(surface_x))
x_max = float(np.max(surface_x))
if x_coord < x_min or x_coord > x_max:
return None
mid = surface_x.size // 2
lower_x = surface_x[:mid]
lower_quantity = surface_quantity[:mid]
upper_x = surface_x[mid:]
upper_quantity = surface_quantity[mid:]
lower_order = np.argsort(lower_x)
upper_order = np.argsort(upper_x)
lower_value = float(
np.interp(
x_coord,
lower_x[lower_order],
lower_quantity[lower_order],
)
)
upper_value = float(
np.interp(
x_coord,
upper_x[upper_order],
upper_quantity[upper_order],
)
)
return SurfaceProbeSample(
x=x_coord,
upper_value=upper_value,
lower_value=lower_value,
)
[docs]
def build_flow_probe_lines(
results: PanelSolution2D,
*,
x_coord: float,
y_coord: float,
mode: str,
) -> list[str]:
"""Return the tooltip lines for one flow-field probe location."""
geom = results.geometry
# TODO: This needs to be scaled by simulation reference length
lines = [f"x: {x_coord:.4f}, y: {y_coord:.4f}"]
if point_is_inside_body(geom.x, geom.y, x_coord=x_coord, y_coord=y_coord):
return lines
target = FieldPoints2D.from_inputs(
np.array([x_coord], dtype=np.float64),
np.array([y_coord], dtype=np.float64),
)
if mode == "Streamlines":
u_val, v_val = results.velocity_at(target)
u_inf = results.freestream.speed
lines.append(
rf"$u/U_\infty$: {u_val[0] / u_inf:.4f}, "
rf"$v/U_\infty$: {v_val[0] / u_inf:.4f}"
)
else:
cp_val = results.pressure_coefficient_at(target)
lines.append(f"$c_p$: {cp_val[0]:.4f}")
return lines
[docs]
def point_is_inside_body(
geometry_x: npt.NDArray[np.float64],
geometry_y: npt.NDArray[np.float64],
*,
x_coord: float,
y_coord: float,
) -> bool:
"""Return whether a point lies inside or on the body boundary."""
polygon = MplPath(np.column_stack((geometry_x, geometry_y)))
return bool(
polygon.contains_point(
(x_coord, y_coord),
radius=_polygon_radius(geometry_x, geometry_y),
)
)
def _polygon_radius(
geometry_x: npt.NDArray[np.float64],
geometry_y: npt.NDArray[np.float64],
) -> float:
span = max(
float(np.ptp(geometry_x)),
float(np.ptp(geometry_y)),
1.0,
)
return 1.0e-12 * span