Skip to content

Viz

Visualization of solved results: field lines, the field-display models and the render targets a viewer draws.

femforge.viz

Visualization of solved results: field-line rendering, the field-display models and the composable render targets a viewer draws from a case.vtu.

Modules:

  • colormaps –

    ParaView's colormap presets, registered with matplotlib so every colormap name resolves through pyvista.

  • field_display –

    Field display state for a solved run: which array is colored, how it reduces to a scalar, and the color range that follows; pyvista is imported lazily inside the render half.

  • field_lines –

    3D magnetic field lines from a solved Elmer case.vtu: streamlines seeded through the domain and colored by |B|.

  • scene –

    Composable render targets: a Scene draws several Renderables in one plotter refresh, on the plotter thread.

Classes:

  • FieldLines –

    A Renderable for the solved B-field streamlines of one case.vtu.

  • FieldLineOptions –

    Typed options for the 3D field-line render.

  • SeedPlacement –

    Where the streamline seed points are placed within the domain bounding box.

  • Colormap –

    Perceptually-uniform matplotlib colormaps for the |B| magnitude scale.

  • OpacityTransfer –

    A pyvista opacity transfer function that maps the |B| scalar to per-element opacity; the _r variants reverse the ramp.

  • InterpolatorType –

    The cell-location strategy the streamline integrator uses (pyvista's interpolator_type).

  • StreamlineActor –

    The plotter actor names draw_streamlines registers, so toggling the overlay off is a single remove_actor.

  • StreamlineActors –

    The precomputed drawables for a field-line view, split from plotting so the expensive part runs off the UI thread.

  • Association –

    Whether a VTU array lives on mesh points or cells (pyvista's preference).

  • BodyPaint –

    Where a body's color comes from.

  • BodyStyle –

    A body's own look: where its color comes from, a solid color when it has one, and its opacity.

  • DisplayMode –

    How a field array is reduced to the value that drives the color.

  • DisplaySpec –

    The complete, serializable state of one field display: which array is colored, how it reduces, and how the colors map.

  • FieldInfo –

    One displayable VTU array: where it lives, how wide it is, and which display modes it supports.

  • FieldScene –

    One run's solved bodies as a recolorable field display.

  • SceneActor –

    The plotter actor names a FieldScene registers, so a toggle removes exactly one.

  • Renderable –

    Something a workbench run returns for display: it draws itself into plotter (on the plotter thread).

  • Scene –

    An ordered group of Renderables drawn into one plotter refresh: clear once, draw in order (later items on top), repaint once.

Functions:

  • select_flux_density_array –

    Pick the B array from a VTU's array names: the nodal form first (no cell→point projection needed), then the elemental "... e", then any name containing the phrase (case-insensitive).

  • seed_points –

    The (N, 3) streamline seed coordinates for the domain bounds and options (pure Python — unit-testable without a render context).

  • build_streamlines –

    Seed and integrate |B| streamlines through a dataset that already carries B as point data, without touching a plotter.

  • build_streamlines_from_vtu –

    Read a solved case.vtu and integrate its B-field streamlines into a drawable StreamlineActors — no plotter touched, safe off the UI thread.

  • prepare_streamline_source –

    Narrow a read grid to the integration region and put B on its points, returning the dataset and the B array name — the read-independent half of build_streamlines_from_vtu.

  • draw_streamlines –

    Draw a precomputed StreamlineActors into plotter without clearing or showing it, so it composes inside a Scene.

  • render_streamlines –

    One-shot: build streamlines through dataset and draw them, in a plotter of its own when plotter is None.

  • render_field_lines –

    Render the magnetic flux-density field lines from a solved case.vtu, headless to a PNG or in an interactive window.

  • field_infos –

    Every displayable array of a VTU as a FieldInfo, GeometryIds excluded, sorted by display name.

  • reduce_field –

    The scalar a display mode draws from one field array.

  • polar_angle –

    Each vector's XY direction as a cyclic coordinate in [0, 1] — atan2(y, x) mapped off [-pi, pi].

  • polar_rgba –

    Per-vector RGBA for DisplayMode.POLAR_XY: hue from the XY angle through a cyclic cmap, brightness and alpha from the sigmoid-scaled XY magnitude.

  • color_range –

    The (lo, hi) color limits spanning every scalar chunk's own range, from the finite values only; an empty selection gives (0.0, 1.0).

  • camera_needs_reset –

    Whether the camera has lost the model, with the box behind it or outside its view cone; a camera inside the box always keeps its view.

  • plotly_colorscale –

    A plotly colorscale from sampled RGB(A) rows: evenly-spaced stops paired with "rgb(r,g,b)" strings.

  • log_colorbar_ticks –

    Decade tick positions spanning a log color range, with their 1e-notation labels.

  • mesh3d_traces –

    One plotly Mesh3d trace as a plain JSON dict: a body's triangulated surface colored by intensity, or by color alone.

  • outline_traces –

    One plotly Scatter3d lines trace drawing every (N, 2, 3) segment, NaN-separated so the segments stay disjoint.

  • list_fields –

    Every displayable field of a VTU grid (GeometryIds excluded), sorted by display name.

  • split_by_body –

    The grid split into one block per named body, keyed by its entities.json name via the GeometryIds cell array.

  • feature_outline –

    The feature and boundary edges of a multiblock's outer surface — the black wireframe drawn over the field, the body outlines on a 2D slice — or None when the surface has none.

  • model_dim_of –

    2 for a flat (axisymmetric slice) grid with no Z extent, else 3.

  • sample_colormap –

    count evenly-spaced RGBA rows from a named colormap, resolved through pyvista's get_cmap_safe.

  • surface_triangles –

    A block's outer surface as (points, triangles): an (N, 3) coordinate array and an (M, 3) vertex-index array.

  • outline_segments –

    The feature edges as an (N, 2, 3) array of line segments.

Attributes:

  • Bounds –

    A pyvista axis-aligned bounding box, as (xmin, xmax, ymin, ymax, zmin, zmax).

  • Point3 –

    One 3D seed coordinate, as (x, y, z).

  • FLUX_DENSITY –

    The MagnetoDynamicsCalcFields name for B in its nodal form; the elemental form the solver writes by default appends " e".

  • FIELD_RENAMES (dict[str, str]) –

    Display-name overrides for solver array names that title-casing alone leaves cryptic.

  • SCALARS_ARRAY –

    The point/cell array every reduction is written to, so one mapper name serves every field and mode.

Bounds module-attribute

A pyvista axis-aligned bounding box, as (xmin, xmax, ymin, ymax, zmin, zmax).

Point3 module-attribute

Point3 = tuple[float, float, float]

One 3D seed coordinate, as (x, y, z).

FLUX_DENSITY module-attribute

FLUX_DENSITY = 'magnetic flux density'

The MagnetoDynamicsCalcFields name for B in its nodal form; the elemental form the solver writes by default appends " e".

FIELD_RENAMES module-attribute

FIELD_RENAMES: dict[str, str] = {'Jfix': 'Fixed Current Density'}

Display-name overrides for solver array names that title-casing alone leaves cryptic.

SCALARS_ARRAY module-attribute

SCALARS_ARRAY = 'display_scalars'

The point/cell array every reduction is written to, so one mapper name serves every field and mode.

FieldLines dataclass

FieldLines(vtu_path: str | Path, options: FieldLineOptions | None = None)

A Renderable for the solved B-field streamlines of one case.vtu.

VTU read + streamline integration run in __post_init__ (whichever worker thread constructs it); render only draws precomputed actors, keeping the plotter thread free.

Methods:

  • render –

    Draw the precomputed field lines into plotter.

Attributes:

vtu_path instance-attribute

vtu_path: str | Path

The solved case.vtu whose B field the lines follow.

options class-attribute instance-attribute

options: FieldLineOptions | None = None

The streamline options; None uses the defaults.

render

render(plotter: Plotter) -> None

Draw the precomputed field lines into plotter.

Source code in src/femforge/viz/field_lines.py
def render(self, plotter: pv.Plotter) -> None:  # pragma: no cover - needs pyvista (viz extra) + a VTK render context
    """Draw the precomputed field lines into ``plotter``."""
    assert self._actors is not None  # set in __post_init__

    options = self.options if self.options is not None else FieldLineOptions()
    draw_streamlines(self._actors, options, plotter)

FieldLineOptions pydantic-model

Bases: BaseModel

Typed options for the 3D field-line render.

Config:

  • use_attribute_docstrings: True
  • validate_assignment: True

Fields:

seed_placement pydantic-field

Where to seed the streamlines within the domain bounding box.

seed_grid pydantic-field

seed_grid: bool = True

Seed the streamlines on a grid (True) or at random points (False).

line_count pydantic-field

line_count: int = 12

Seed points per axis: CENTER_PLANE seeds line_count², VOLUME line_count³, GAP_LINE line_count.

max_length pydantic-field

max_length: float = 0.75

[-]: Fraction of the domain diagonal a streamline may integrate along, keeping the picture framed on the magnets.

colormap pydantic-field

colormap: Colormap | str | list[str] = Colormap.VIRIDIS

The |B| colormap: a Colormap member, any matplotlib or colorcet colormap name, or a list of colors interpolated across the scalar range.

invert pydantic-field

invert: bool = False

Reverse the colormap.

log_scale pydantic-field

log_scale: bool = False

Map |B| to colors logarithmically.

clim pydantic-field

clim: tuple[float, float] | None = None

[?]: The |B| color limits, or None for the lines' own range.

opacity_map pydantic-field

opacity_map: tuple[float, float] | None = None

[-]: The opacity at the low and high color limits, linear in the color mapping between them; it replaces an OpacityTransfer.

opacity pydantic-field

opacity: float | OpacityTransfer = 1.0

[-]: Line or tube opacity: a constant in [0, 1], or an OpacityTransfer that maps |B| to opacity like the color.

interpolator pydantic-field

The integrator's cell-location strategy. CELL (default) fixes the straight "spike" streamlines a point locator produces near the air↔magnet interface.

seed_fraction pydantic-field

seed_fraction: float = 0.6

[-]: Fraction of the bounding box, centered, that the seed grid spans, keeping seeds out of the outer air shell where B ≈ 0.

region_array pydantic-field

region_array: str | None = None

Name of a cell-data region-id array (e.g. Elmer's "GeometryIds") identifying each cell's body. Used with region_id and clip_to_region.

region_id pydantic-field

region_id: int | None = None

The single region id to confine integration to when clip_to_region is set (e.g. the air body id).

clip_to_region pydantic-field

clip_to_region: bool = False

Confine streamline integration to region_id (needs region_array and region_id), which keeps elemental B from averaging across the air↔magnet interface.

tube_radius pydantic-field

tube_radius: float | None = None

r_tube [m]: Streamline tube radius; None draws the streamlines as plain poly-lines.

initial_step_length pydantic-field

initial_step_length: float = 0.5

[-]: The integrator's initial step, in cell lengths; smaller values follow tight curves.

min_step_length pydantic-field

min_step_length: float = 0.01

[-]: The integrator's smallest step, in cell lengths.

max_step_length pydantic-field

max_step_length: float = 1.0

[-]: The integrator's largest step, in cell lengths.

show_outline pydantic-field

show_outline: bool = True

Draw the domain bounding-box outline for spatial context.

vectors pydantic-field

vectors: str | None = None

The 3-component array the lines follow; None picks the magnetic flux density.

SeedPlacement

Bases: StrEnum

Where the streamline seed points are placed within the domain bounding box.

Attributes:

  • CENTER_PLANE –

    A line_count × line_count grid on the mid-Z plane (the field-line cross-section).

  • VOLUME –

    A line_count³ grid filling the box — denser, fuller 3D coverage (the default).

  • GAP_LINE –

    A single line of line_count points along X through the center (e.g. across the Halbach gap).

CENTER_PLANE class-attribute instance-attribute

CENTER_PLANE = 'center_plane'

A line_count × line_count grid on the mid-Z plane (the field-line cross-section).

VOLUME class-attribute instance-attribute

VOLUME = 'volume'

A line_count³ grid filling the box — denser, fuller 3D coverage (the default).

GAP_LINE class-attribute instance-attribute

GAP_LINE = 'gap_line'

A single line of line_count points along X through the center (e.g. across the Halbach gap).

Colormap

Bases: StrEnum

Perceptually-uniform matplotlib colormaps for the |B| magnitude scale.

Attributes:

  • VIRIDIS –

    Dark blue through green to yellow.

  • PLASMA –

    Dark blue through magenta to yellow.

  • MAGMA –

    Black through red to pale yellow.

  • TURBO –

    A smooth rainbow from dark blue to dark red.

  • COOLWARM –

    A diverging map from blue through grey to red.

VIRIDIS class-attribute instance-attribute

VIRIDIS = 'viridis'

Dark blue through green to yellow.

PLASMA class-attribute instance-attribute

PLASMA = 'plasma'

Dark blue through magenta to yellow.

MAGMA class-attribute instance-attribute

MAGMA = 'magma'

Black through red to pale yellow.

TURBO class-attribute instance-attribute

TURBO = 'turbo'

A smooth rainbow from dark blue to dark red.

COOLWARM class-attribute instance-attribute

COOLWARM = 'coolwarm'

A diverging map from blue through grey to red.

OpacityTransfer

Bases: StrEnum

A pyvista opacity transfer function that maps the |B| scalar to per-element opacity; the _r variants reverse the ramp.

Attributes:

  • LINEAR –

    Opacity rises linearly with |B|.

  • LINEAR_R –

    Opacity falls linearly with |B|.

  • GEOM –

    Opacity rises geometrically with |B|.

  • GEOM_R –

    Opacity falls geometrically with |B|.

  • SIGMOID –

    Opacity follows a sigmoid in |B|, so weak-field lines fade out.

  • SIGMOID_R –

    Opacity follows a reversed sigmoid in |B|, so strong-field lines fade out.

LINEAR class-attribute instance-attribute

LINEAR = 'linear'

Opacity rises linearly with |B|.

LINEAR_R class-attribute instance-attribute

LINEAR_R = 'linear_r'

Opacity falls linearly with |B|.

GEOM class-attribute instance-attribute

GEOM = 'geom'

Opacity rises geometrically with |B|.

GEOM_R class-attribute instance-attribute

GEOM_R = 'geom_r'

Opacity falls geometrically with |B|.

SIGMOID class-attribute instance-attribute

SIGMOID = 'sigmoid'

Opacity follows a sigmoid in |B|, so weak-field lines fade out.

SIGMOID_R class-attribute instance-attribute

SIGMOID_R = 'sigmoid_r'

Opacity follows a reversed sigmoid in |B|, so strong-field lines fade out.

InterpolatorType

Bases: StrEnum

The cell-location strategy the streamline integrator uses (pyvista's interpolator_type).

Both integrate the same point vector field; this only changes how each step finds its containing cell.

Attributes:

  • CELL –

    Locate cells with a cell locator, the right default for this geometry.

  • POINT –

    Locate cells via the nearest mesh point (pyvista's default). Faster, but near interfaces/thin features it can grab the wrong

CELL class-attribute instance-attribute

CELL = 'cell'

Locate cells with a cell locator, the right default for this geometry.

It stays robust near the air↔magnet interface and thin air gaps, where a point locator mislocates a step into the wrong (magnet) cell and sends the streamline off as a straight "spike".

POINT class-attribute instance-attribute

POINT = 'point'

Locate cells via the nearest mesh point (pyvista's default). Faster, but near interfaces/thin features it can grab the wrong cell's vector and produce the spike streamlines.

StreamlineActor

Bases: StrEnum

The plotter actor names draw_streamlines registers, so toggling the overlay off is a single remove_actor.

Attributes:

  • LINES –

    The integrated streamlines.

  • OUTLINE –

    The domain's bounding-box outline.

LINES class-attribute instance-attribute

LINES = 'streamlines'

The integrated streamlines.

OUTLINE class-attribute instance-attribute

OUTLINE = 'streamlines-outline'

The domain's bounding-box outline.

StreamlineActors dataclass

StreamlineActors(streamlines: PolyData, b_name: str, outline: PolyData | None, magnitudes: list[NDArray[float64]])

The precomputed drawables for a field-line view, split from plotting so the expensive part runs off the UI thread.

Built by build_streamlines (worker-thread safe) and drawn by draw_streamlines (plotter thread only).

Attributes:

  • streamlines (PolyData) –

    The integrated streamlines, clipped to the integration region.

  • b_name (str) –

    The name of the B array the lines carry for coloring.

  • outline (PolyData | None) –

    The domain's bounding-box outline; None when show_outline is off.

  • magnitudes (list[NDArray[float64]]) –

    The integrated field's magnitudes over the integration region, one chunk per body, which set the lines' color range.

streamlines instance-attribute

streamlines: PolyData

The integrated streamlines, clipped to the integration region.

b_name instance-attribute

b_name: str

The name of the B array the lines carry for coloring.

outline instance-attribute

outline: PolyData | None

The domain's bounding-box outline; None when show_outline is off.

magnitudes instance-attribute

magnitudes: list[NDArray[float64]]

The integrated field's magnitudes over the integration region, one chunk per body, which set the lines' color range.

Association

Bases: StrEnum

Whether a VTU array lives on mesh points or cells (pyvista's preference).

Attributes:

  • POINT –

    One value per mesh point.

  • CELL –

    One value per mesh cell.

  • intensitymode (str) –

    The plotly Mesh3d.intensitymode name for this association.

POINT class-attribute instance-attribute

POINT = 'point'

One value per mesh point.

CELL class-attribute instance-attribute

CELL = 'cell'

One value per mesh cell.

intensitymode property

intensitymode: str

The plotly Mesh3d.intensitymode name for this association.

BodyPaint

Bases: StrEnum

Where a body's color comes from.

Attributes:

  • FIELD –

    The field colormap, by the displayed value.

  • COLORMAP –

    One color of the body colormap, spread evenly over every body that takes it.

  • SOLID –

    The body's own color.

FIELD class-attribute instance-attribute

FIELD = 'field'

The field colormap, by the displayed value.

COLORMAP class-attribute instance-attribute

COLORMAP = 'colormap'

One color of the body colormap, spread evenly over every body that takes it.

SOLID class-attribute instance-attribute

SOLID = 'solid'

The body's own color.

BodyStyle pydantic-model

Bases: BaseModel

A body's own look: where its color comes from, a solid color when it has one, and its opacity.

Config:

  • use_attribute_docstrings: True
  • frozen: True

Fields:

Validators:

  • _solid_has_color

body pydantic-field

body: str

The body's entities.json name.

paint pydantic-field

Where the body's color comes from.

color pydantic-field

color: str | None = None

Any color pyvista resolves (a name or a hex triplet); a SOLID body needs one, the others ignore it.

opacity pydantic-field

opacity: float = 1.0

[-]: The body's own opacity, multiplied by the display's.

DisplayMode

Bases: StrEnum

How a field array is reduced to the value that drives the color.

Attributes:

  • VALUE –

    The value itself.

  • ABSVALUE –

    The absolute value.

  • X –

    The x component.

  • Y –

    The y component.

  • Z –

    The z component.

  • MAGNITUDE –

    The vector magnitude.

  • POLAR_XY –

    The in-plane direction as hue, brightened by the in-plane magnitude.

  • label (str) –

    The mode's name as it reads in a picker.

VALUE class-attribute instance-attribute

VALUE = 'value'

The value itself.

ABSVALUE class-attribute instance-attribute

ABSVALUE = 'absolute_value'

The absolute value.

X class-attribute instance-attribute

X = 'x'

The x component.

Y class-attribute instance-attribute

Y = 'y'

The y component.

Z class-attribute instance-attribute

Z = 'z'

The z component.

MAGNITUDE class-attribute instance-attribute

MAGNITUDE = 'magnitude'

The vector magnitude.

POLAR_XY class-attribute instance-attribute

POLAR_XY = 'polar_xy'

The in-plane direction as hue, brightened by the in-plane magnitude.

label property

label: str

The mode's name as it reads in a picker.

DisplaySpec pydantic-model

Bases: BaseModel

The complete, serializable state of one field display: which array is colored, how it reduces, and how the colors map.

Config:

  • use_attribute_docstrings: True
  • validate_assignment: True

Fields:

field pydantic-field

field: str = ''

Name of the VTU array to color by; empty means "whichever the run offers first".

mode pydantic-field

How the array is reduced to the displayed value.

colormap pydantic-field

colormap: str = 'viridis'

Colormap name, resolved through pyvista's get_cmap_safe (matplotlib and colorcet names both work).

log_scale pydantic-field

log_scale: bool = False

Map the color range logarithmically; only the strictly positive values take part.

invert pydantic-field

invert: bool = False

Reverse the colormap.

clim pydantic-field

clim: tuple[float, float] | None = None

[?]: Pinned color limits in the displayed value's unit, or None to recompute them from the data on every apply.

robust pydantic-field

robust: bool = True

Clamp the automatic limits to the 2-98 percentiles so a few outlier cells cannot flatten the picture.

hidden_bodies pydantic-field

hidden_bodies: tuple[str, ...] = ()

Body names to hide, by their entities.json name.

outline pydantic-field

outline: bool = True

Draw the black feature-edge wireframe over the visible bodies.

opacity pydantic-field

opacity: float = 1.0

[-]: Multiplies every body's own opacity.

opacity_map pydantic-field

opacity_map: tuple[float, float] | None = None

[-]: The opacity at the low and high color limits, linear in the color mapping between them; None draws opacity throughout.

body_styles pydantic-field

body_styles: tuple[BodyStyle, ...] = ()

Bodies with a color source or an opacity of their own; the rest take the field colormap at full opacity.

body_colormap pydantic-field

body_colormap: str = 'batlow'

The colormap the COLORMAP bodies share, in body_styles order.

streamlines pydantic-field

streamlines: FieldLineOptions | None = None

Options for the field-line overlay, or None to draw none.

solid

solid() -> dict[str, str]

The color of every body not on the field colormap, by body: its own, or its share of the body colormap.

Source code in src/femforge/viz/field_display.py
def solid(self) -> dict[str, str]:
    """The color of every body not on the field colormap, by body: its own, or its share of the body colormap."""
    spread: list[str] = [style.body for style in self.body_styles if style.paint is BodyPaint.COLORMAP]
    shares: dict[str, str] = dict(zip(spread, spread_colors(self.body_colormap, len(spread)), strict=True)) if spread else {}
    colors: dict[str, str] = {}
    for style in self.body_styles:
        if style.paint is BodyPaint.COLORMAP:
            colors[style.body] = shares[style.body]
        elif style.paint is BodyPaint.SOLID and style.color is not None:
            colors[style.body] = style.color

    return colors

body_opacity

body_opacity(body: str) -> float

A body's drawn opacity: its own times the overall one.

Source code in src/femforge/viz/field_display.py
def body_opacity(self, body: str) -> float:
    """A body's drawn opacity: its own times the overall one."""
    own: float = next((style.opacity for style in self.body_styles if style.body == body), 1.0)
    return own * self.opacity

unmatched

unmatched(bodies: Iterable[str]) -> list[str]

The hidden_bodies and body_styles names missing from bodies, in spec order.

Source code in src/femforge/viz/field_display.py
def unmatched(self, bodies: Iterable[str]) -> list[str]:
    """The ``hidden_bodies`` and ``body_styles`` names missing from ``bodies``, in spec order."""
    present: set[str] = set(bodies)
    named: list[str] = [*self.hidden_bodies, *(style.body for style in self.body_styles)]
    return [name for name in dict.fromkeys(named) if name not in present]

fitted

fitted(fields: Sequence[FieldInfo]) -> DisplaySpec

This spec snapped to what a run actually offers: an unset field takes the first available one and a mode the field disallows falls back to its first allowed mode.

An unknown field name raises KeyError naming the available arrays.

Source code in src/femforge/viz/field_display.py
def fitted(self, fields: Sequence[FieldInfo]) -> DisplaySpec:
    """
    This spec snapped to what a run actually offers: an unset field takes the first available one and a mode the field disallows falls back to its first allowed mode.

    An unknown field name raises [`KeyError`][] naming the available arrays.
    """
    if not fields:
        raise KeyError("no displayable fields")

    by_name: dict[str, FieldInfo] = {info.name: info for info in fields}
    chosen: FieldInfo = fields[0] if not self.field else by_name[select_array(by_name, self.field)]

    allowed: tuple[DisplayMode, ...] = chosen.modes
    mode: DisplayMode = self.mode if self.mode in allowed else allowed[0]
    if chosen.name == self.field and mode is self.mode:
        return self

    return self.model_copy(update={"field": chosen.name, "mode": mode})

FieldInfo dataclass

FieldInfo(name: str, association: Association, components: int, dtype: str)

One displayable VTU array: where it lives, how wide it is, and which display modes it supports.

Attributes:

  • name (str) –

    The array's name in the VTU.

  • association (Association) –

    Whether the array lives on points or cells.

  • components (int) –

    The number of components per value: 1 for a scalar, 3 for a vector.

  • dtype (str) –

    The array's numpy dtype name.

  • display_name (str) –

    The array's name as it reads in a picker, with the FIELD_RENAMES overrides applied.

  • modes (tuple[DisplayMode, ...]) –

    The display modes this array supports, the default first: the vector set for a 3-component field, the scalar pair otherwise.

name instance-attribute

name: str

The array's name in the VTU.

association instance-attribute

association: Association

Whether the array lives on points or cells.

components instance-attribute

components: int

The number of components per value: 1 for a scalar, 3 for a vector.

dtype instance-attribute

dtype: str

The array's numpy dtype name.

display_name property

display_name: str

The array's name as it reads in a picker, with the FIELD_RENAMES overrides applied.

modes property

modes: tuple[DisplayMode, ...]

The display modes this array supports, the default first: the vector set for a 3-component field, the scalar pair otherwise.

FieldScene

FieldScene(*, grid: UnstructuredGrid, blocks: MultiBlock, fields: Sequence[FieldInfo], model_dim: int, run_id: str, bodies: Bodies | None = None)

One run's solved bodies as a recolorable field display.

It is a Renderable for the pyvista path and the trace source for the 2D plotly path; both read the same DisplaySpec.

Parameters:

  • grid (UnstructuredGrid) –

    The solved VTU grid.

  • blocks (MultiBlock) –

    The grid split into one block per named body.

  • fields (Sequence[FieldInfo]) –

    The displayable arrays.

  • model_dim (int) –

    2 for an axisymmetric slice, else 3.

  • run_id (str) –

    The run bundle's name.

  • bodies (Bodies | None, default: None ) –

    The named bodies from entities.json; None names none.

Methods:

  • from_run –

    Open a run bundle's VTU as a field scene, its bodies split and named from entities.json.

  • field_info –

    The FieldInfo for an array name, resolved the same way a field query resolves one.

  • apply –

    Write the spec's reduction into every block's display_scalars array and return the color limits it produced.

  • render –

    Draw the whole display into plotter: the composite body actor, then everything update owns; solid bodies an earlier scene left go first.

  • update –

    Recolor an already-rendered scene in place — the cheap path a panel calls on every knob change; field lines are re-integrated only when their options changed.

  • shown_blocks –

    The blocks of every body not in hidden, under their names.

  • integrated –

    The cached field lines when options only restyle them, None when they need integrating.

  • integrate_streamlines –

    Seed and integrate the field lines through this scene's grid — the compute half, safe off the UI thread.

  • region_magnitudes –

    The magnitudes of array name per body of a region (every body for None), the chunks a field-line range spans.

  • show_streamlines –

    Draw integrated field lines into plotter in the display's mapping, record their options as the drawn ones and keep the lines for a restyle.

  • lines_pinned –

    Whether field lines following vectors take the display's pinned range: it is pinned and shows that field's magnitude.

  • lines_title –

    The field lines' scalar bar title: the integrated field's name.

  • line_style –

    The field lines in the display's colormap, inversion, log scale and opacity map; a polar display leaves them as asked.

  • figure –

    The plotly figure dict for the 2D path: one Mesh3d per body (hidden ones legendonly) plus the outline, under an orthographic top-down camera.

Attributes:

Source code in src/femforge/viz/field_display.py
def __init__(
    self, *, grid: pv.UnstructuredGrid, blocks: pv.MultiBlock, fields: Sequence[FieldInfo], model_dim: int, run_id: str, bodies: Bodies | None = None
) -> None:  # pragma: no cover - needs pyvista (viz extra); proven by the gated integration test
    """Hold one run's solved grid, split into bodies, under a default display.

    Args:
        grid: The solved VTU grid.
        blocks: The grid split into one block per named body.
        fields: The displayable arrays.
        model_dim: 2 for an axisymmetric slice, else 3.
        run_id: The run bundle's name.
        bodies: The named bodies from ``entities.json``; None names none.
    """
    from femforge.run.entities import Bodies

    register_paraview()
    self.grid: pv.UnstructuredGrid = grid
    self.blocks: pv.MultiBlock = blocks
    self.fields: list[FieldInfo] = list(fields)
    self.model_dim: int = model_dim
    self.run_id: str = run_id
    self.bodies: Bodies = bodies if bodies is not None else Bodies([])
    self.spec: DisplaySpec = DisplaySpec().fitted(self.fields) if self.fields else DisplaySpec()
    self.clim: tuple[float, float] = (0.0, 1.0)
    self.data_range: tuple[float, float] = (0.0, 1.0)
    self._actor: pv.Actor | None = None
    self._styled: dict[str, tuple[str, float]] = {}
    """The ``(color, opacity)`` of every solid body actor the plotter holds."""
    self._lines: tuple[StreamlineActors, FieldLineOptions] | None = None
    """The last integrated field lines and the options they were integrated with, restyled without integrating again."""
    self.drawn_lines: FieldLineOptions | None = None
    """The styled options of the lines the plotter holds."""
    self._lines_bar: str | None = None
    """The title of the field lines' scalar bar the plotter holds."""

drawn_lines instance-attribute

drawn_lines: FieldLineOptions | None = None

The styled options of the lines the plotter holds.

from_run classmethod

from_run(run: Run) -> FieldScene

Open a run bundle's VTU as a field scene, its bodies split and named from entities.json.

Source code in src/femforge/viz/field_display.py
@classmethod
def from_run(cls, run: Run) -> FieldScene:  # pragma: no cover - needs pyvista (viz extra); proven by the gated integration test
    """Open a run bundle's VTU as a field scene, its bodies split and named from ``entities.json``."""
    import pyvista as pv

    if run.vtu_path is None:
        raise FileNotFoundError(f"{run.path}: no VTU output to display")

    bodies: Bodies = run.bodies
    if not len(bodies):
        raise FileNotFoundError(f"{run.path}: no named bodies ({BundleFile.ENTITIES} missing); re-solve the run so the bundle carries its entities")

    grid: pv.UnstructuredGrid = cast("pv.UnstructuredGrid", pv.read(str(run.vtu_path)))
    blocks: pv.MultiBlock = split_by_body(grid, bodies)
    fields: list[FieldInfo] = list_fields(grid)

    log.bind(run_id=run.path.name, bodies=len(blocks), fields=len(fields)).info("Field scene loaded.")
    return cls(grid=grid, blocks=blocks, fields=fields, model_dim=model_dim_of(grid), run_id=run.path.name, bodies=bodies)

field_info

field_info(name: str) -> FieldInfo

The FieldInfo for an array name, resolved the same way a field query resolves one.

Source code in src/femforge/viz/field_display.py
def field_info(self, name: str) -> FieldInfo:  # pragma: no cover - needs pyvista (viz extra); proven by the gated integration test
    """The [`FieldInfo`][] for an array name, resolved the same way a field query resolves one."""
    by_name: dict[str, FieldInfo] = {info.name: info for info in self.fields}
    return by_name[select_array(by_name, name)]

apply

apply(spec: DisplaySpec) -> tuple[float, float]

Write the spec's reduction into every block's display_scalars array and return the color limits it produced.

Source code in src/femforge/viz/field_display.py
def apply(self, spec: DisplaySpec) -> tuple[float, float]:  # pragma: no cover - needs pyvista (viz extra); proven by the gated integration test
    """Write the spec's reduction into every block's ``display_scalars`` array and return the color limits it produced."""
    import pyvista as pv

    fitted: DisplaySpec = spec.fitted(self.fields)
    info: FieldInfo = self.field_info(fitted.field)

    def raw_values(block: pv.DataSet) -> FloatArray:
        """One block's copy of the displayed array, zero-filled when the block never carried it."""
        arrays = block.point_data if info.association is Association.POINT else block.cell_data
        width: int = block.n_points if info.association is Association.POINT else block.n_cells
        if info.name not in arrays:
            return np.zeros((width, info.components), dtype=np.float64)

        return np.asarray(arrays[info.name], dtype=np.float64)

    names: list[str] = [str(key) for key in self.blocks.keys()]
    sources: list[FloatArray] = [raw_values(cast("pv.DataSet", self.blocks[name])) for name in names]
    reductions: list[AnyArray] = [reduce_field(values, fitted.mode) for values in sources]

    # Every body counts, each through its own range, whatever it is shown as: the scale belongs to the field, not the view.
    self.data_range = color_range(reductions, robust=False)
    clim: tuple[float, float] = fitted.clim if fitted.clim is not None else color_range(reductions, log_scale=fitted.log_scale, robust=fitted.robust)

    if fitted.mode is DisplayMode.POLAR_XY:
        cmap = pv.get_cmap_safe(fitted.colormap)  # pyright: ignore[reportUnknownMemberType]  # pyvista returns an untyped matplotlib Colormap

        def mapper(fractions: FloatArray) -> FloatArray:
            """RGBA rows for cyclic angle fractions, through the spec's colormap."""
            return np.asarray(cmap(fractions), dtype=np.float64)

        reductions = [polar_rgba(values, clim[0], clim[1], mapper) for values in sources]

    for name, scalars in zip(names, reductions, strict=True):
        block: pv.DataSet = cast("pv.DataSet", self.blocks[name])
        arrays = block.point_data if info.association is Association.POINT else block.cell_data
        arrays.set_scalars(scalars, name=SCALARS_ARRAY)  # pyright: ignore[reportUnknownMemberType]  # pyvista's set_scalars carries **kwargs: Unknown

    self.spec = fitted
    self.clim = clim
    return clim

render

render(plotter: Plotter) -> None

Draw the whole display into plotter: the composite body actor, then everything update owns; solid bodies an earlier scene left go first.

Source code in src/femforge/viz/field_display.py
def render(self, plotter: pv.Plotter) -> None:  # pragma: no cover - needs pyvista (viz extra) + a VTK render context; proven by the gated integration test
    """Draw the whole display into ``plotter``: the composite body actor, then everything [`update`][] owns; solid bodies an earlier scene left go first."""
    spec: DisplaySpec = self.spec
    _ = self.apply(spec)
    self._styled = {}
    self.drawn_lines = None

    actors = cast("Mapping[str, object]", plotter.renderer.actors)
    leftovers = [name for name in actors if name.startswith(SceneActor.STYLED.of(""))]
    for name in leftovers:
        _ = plotter.remove_actor(name)  # pyright: ignore[reportUnknownMemberType, reportArgumentType]  # pyvista removes by actor name, and its _Wrapped stub loses `self`

    self._actor = plotter.add_mesh(  # pyright: ignore[reportUnknownMemberType]
        self.blocks,
        scalars=SCALARS_ARRAY,
        show_edges=False,
        show_scalar_bar=False,
        name=str(SceneActor.BODIES),
    )
    # Translucent bodies sort per fragment only under depth peeling; without it a far body can paint over a near one.
    _ = cast("object", plotter.enable_depth_peeling())  # pyright: ignore[reportCallIssue, reportUnknownMemberType]  # pyvista's _Wrapped stub loses `self`
    self._redraw(plotter, spec)

update

update(plotter: Plotter, spec: DisplaySpec) -> None

Recolor an already-rendered scene in place — the cheap path a panel calls on every knob change; field lines are re-integrated only when their options changed.

Source code in src/femforge/viz/field_display.py
def update(self, plotter: pv.Plotter, spec: DisplaySpec) -> None:  # pragma: no cover - needs pyvista (viz extra) + a VTK render context; proven by the gated integration test
    """Recolor an already-rendered scene in place — the cheap path a panel calls on every knob change; field lines are re-integrated only when their options changed."""
    if self._actor is None:
        self.render(plotter)
        return

    self._redraw(plotter, spec)

shown_blocks

shown_blocks(hidden: Iterable[str]) -> MultiBlock

The blocks of every body not in hidden, under their names.

Source code in src/femforge/viz/field_display.py
def shown_blocks(self, hidden: Iterable[str]) -> pv.MultiBlock:  # pragma: no cover - needs pyvista (viz extra)
    """The blocks of every body not in ``hidden``, under their names."""
    import pyvista as pv

    skipped: set[str] = set(hidden)
    shown: pv.MultiBlock = pv.MultiBlock()
    for name in (str(key) for key in self.blocks.keys()):
        if name not in skipped:
            shown.append(self.blocks[name], name=name)

    return shown

integrated

integrated(options: FieldLineOptions) -> StreamlineActors | None

The cached field lines when options only restyle them, None when they need integrating.

Source code in src/femforge/viz/field_display.py
def integrated(self, options: FieldLineOptions) -> StreamlineActors | None:
    """The cached field lines when ``options`` only restyle them, None when they need integrating."""
    if self._lines is None or not integrates_like(self._lines[1], options):
        return None

    return self._lines[0]

integrate_streamlines

integrate_streamlines(options: FieldLineOptions) -> StreamlineActors

Seed and integrate the field lines through this scene's grid — the compute half, safe off the UI thread.

Source code in src/femforge/viz/field_display.py
def integrate_streamlines(self, options: FieldLineOptions) -> StreamlineActors:  # pragma: no cover - needs pyvista (viz extra); proven by the gated integration test
    """Seed and integrate the field lines through this scene's grid — the compute half, safe off the UI thread."""
    source, b_name = prepare_streamline_source(self.grid, options)
    actors: StreamlineActors = build_streamlines(source, b_name, options)
    return replace(actors, magnitudes=self.region_magnitudes(b_name, options.region_id))

region_magnitudes

region_magnitudes(name: str, region_id: int | None) -> list[FloatArray]

The magnitudes of array name per body of a region (every body for None), the chunks a field-line range spans.

Source code in src/femforge/viz/field_display.py
def region_magnitudes(self, name: str, region_id: int | None) -> list[FloatArray]:  # pragma: no cover - needs pyvista (viz extra); proven by the gated integration test
    """The magnitudes of array ``name`` per body of a region (every body for None), the chunks a field-line range spans."""

    chunks: list[FloatArray] = []
    for block_name in (str(key) for key in self.blocks.keys()):
        body = self.bodies.get(block_name)
        if region_id is not None and (body is None or body.mesh_id != region_id):
            continue

        block: pv.DataSet = cast("pv.DataSet", self.blocks[block_name])
        arrays = block.cell_data if name in block.cell_data else block.point_data
        if name in arrays:
            chunks.append(reduce_field(np.asarray(arrays[name]), DisplayMode.MAGNITUDE))

    return chunks

show_streamlines

show_streamlines(plotter: Plotter, actors: StreamlineActors, options: FieldLineOptions) -> None

Draw integrated field lines into plotter in the display's mapping, record their options as the drawn ones and keep the lines for a restyle.

Parameters:

  • plotter (Plotter) –

    The plotter to draw into.

  • actors (StreamlineActors) –

    The integrated field lines.

  • options (FieldLineOptions) –

    The options the lines were integrated with.

Source code in src/femforge/viz/field_display.py
def show_streamlines(
    self, plotter: pv.Plotter, actors: StreamlineActors, options: FieldLineOptions
) -> None:  # pragma: no cover - needs pyvista (viz extra) + a VTK render context; proven by the gated integration test
    """Draw integrated field lines into ``plotter`` in the display's mapping, record their options as the drawn ones and keep the lines for a restyle.

    Args:
        plotter: The plotter to draw into.
        actors: The integrated field lines.
        options: The options the lines were integrated with.
    """
    import pyvista as pv

    styled: FieldLineOptions = self.line_style(options, actors)
    draw_streamlines(actors, styled, plotter)
    self._lines = (actors, options)
    self.drawn_lines = styled
    self.spec = self.spec.model_copy(update={"streamlines": options})

    # The bar holds the mapper it was made with, and a redraw replaces the lines' actor, so it is made again every time.
    self._remove_lines_bar(plotter)
    drawn: object = plotter.renderer.actors.get(str(StreamlineActor.LINES))
    if not isinstance(drawn, pv.Actor):
        return

    self._lines_bar = self.lines_title(actors.b_name)
    _ = cast(
        "object",
        plotter.add_scalar_bar(  # pyright: ignore[reportCallIssue, reportUnknownMemberType]  # pyvista's _Wrapped stub loses `self`
            title=self._lines_bar,
            mapper=drawn.mapper,
            vertical=True,
            position_x=0.1,
            position_y=0.2,
            width=0.035,
            height=0.6,
        ),
    )

lines_pinned

lines_pinned(vectors: str) -> bool

Whether field lines following vectors take the display's pinned range: it is pinned and shows that field's magnitude.

Source code in src/femforge/viz/field_display.py
def lines_pinned(self, vectors: str) -> bool:
    """Whether field lines following ``vectors`` take the display's pinned range: it is pinned and shows that field's magnitude."""
    spec: DisplaySpec = self.spec
    return spec.clim is not None and spec.mode is DisplayMode.MAGNITUDE and spec.field == vectors

lines_title

lines_title(b_name: str) -> str

The field lines' scalar bar title: the integrated field's name.

Source code in src/femforge/viz/field_display.py
def lines_title(self, b_name: str) -> str:
    """The field lines' scalar bar title: the integrated field's name."""
    return f"{title(b_name)}{LINES_SUFFIX}"

line_style

line_style(options: FieldLineOptions, actors: StreamlineActors) -> FieldLineOptions

The field lines in the display's colormap, inversion, log scale and opacity map; a polar display leaves them as asked.

Their range is the display's pinned one while it shows their field's magnitude, else the automatic range of that magnitude over their own region, whatever the bodies show.

Source code in src/femforge/viz/field_display.py
def line_style(self, options: FieldLineOptions, actors: StreamlineActors) -> FieldLineOptions:
    """
    The field lines in the display's colormap, inversion, log scale and opacity map; a polar display leaves them as asked.

    Their range is the display's pinned one while it shows their field's magnitude, else the automatic range of that magnitude over their own region, whatever the bodies show.
    """
    spec: DisplaySpec = self.spec
    if spec.mode is DisplayMode.POLAR_XY:
        return options

    return options.model_copy(
        update={
            "colormap": spec.colormap,
            "invert": spec.invert,
            "log_scale": spec.log_scale,
            "opacity_map": spec.opacity_map,
            "clim": spec.clim if self.lines_pinned(actors.b_name) else color_range(actors.magnitudes, log_scale=spec.log_scale, robust=spec.robust),
        }
    )

figure

figure(spec: DisplaySpec) -> dict[str, object]

The plotly figure dict for the 2D path: one Mesh3d per body (hidden ones legendonly) plus the outline, under an orthographic top-down camera.

POLAR_XY falls back to the magnitude, a log scale plots log10 with decade ticks, and opacity_map draws as opacity.

Source code in src/femforge/viz/field_display.py
def figure(self, spec: DisplaySpec) -> dict[str, object]:  # pragma: no cover - needs pyvista (viz extra); proven by the gated integration test
    """The plotly figure dict for the 2D path: one ``Mesh3d`` per body (hidden ones ``legendonly``) plus the outline, under an orthographic top-down camera.

    ``POLAR_XY`` falls back to the magnitude, a log scale plots ``log10`` with decade ticks, and ``opacity_map`` draws as ``opacity``.
    """
    fitted: DisplaySpec = spec.fitted(self.fields)
    plotted: DisplaySpec = fitted if fitted.mode is not DisplayMode.POLAR_XY else fitted.model_copy(update={"mode": DisplayMode.MAGNITUDE})
    info: FieldInfo = self.field_info(plotted.field)
    lo, hi = self.apply(plotted)

    colorscale: Colorscale = plotly_colorscale(sample_colormap(plotted.colormap), invert=plotted.invert)
    colorbar: TraceDict = {"title": {"text": info.display_name}, **(log_colorbar(lo, hi) if plotted.log_scale else {})}
    cmin, cmax = (math.log10(lo), math.log10(hi)) if plotted.log_scale else (lo, hi)
    hovertemplate: str = f"{info.display_name}: %{{customdata:.4g}}<extra>%{{fullData.name}}</extra>"

    solid: dict[str, str] = plotted.solid()
    traces: list[TraceDict] = []
    scale_shown: bool = False
    for name in (str(key) for key in self.blocks.keys()):
        block: pv.DataSet = cast("pv.DataSet", self.blocks[name])
        surface: pv.PolyData = cast("pv.PolyData", cast("pv.PolyData", block.extract_surface(algorithm=None)).triangulate())
        points, triangles = surface_triangles(surface)
        visible: bool = name not in plotted.hidden_bodies
        color: str | None = solid.get(name)
        if color is not None:
            traces.extend(mesh3d_traces(points, triangles, None, name=name, color=color, opacity=plotted.body_opacity(name), visible=visible))
            continue

        arrays = surface.point_data if info.association is Association.POINT else surface.cell_data
        values: FloatArray = np.asarray(arrays[SCALARS_ARRAY], dtype=np.float64)
        intensity: FloatArray = log_intensity(values, lo) if plotted.log_scale else values

        traces.extend(
            mesh3d_traces(
                points,
                triangles,
                intensity,
                name=name,
                opacity=plotted.body_opacity(name),
                intensitymode=info.association,
                colorscale=colorscale,
                cmin=cmin,
                cmax=cmax,
                showscale=visible and not scale_shown,
                hovertemplate=hovertemplate,
                customdata=values,
                colorbar=colorbar,
                visible=visible,
            )
        )
        scale_shown = scale_shown or visible

    edges: pv.PolyData | None = feature_outline(self.shown_blocks(plotted.hidden_bodies)) if plotted.outline else None
    if edges is not None:
        traces.extend(outline_traces(outline_segments(edges)))

    return {"data": traces, "layout": self._layout()}

SceneActor

Bases: StrEnum

The plotter actor names a FieldScene registers, so a toggle removes exactly one.

Methods:

  • of –

    The actor name of one body under this prefix.

Attributes:

  • BODIES –

    The colored field over the shown bodies.

  • OUTLINE –

    The feature-edge outline of the bodies.

  • STYLED –

    The prefix of a styled body's own actor, followed by the body name.

BODIES class-attribute instance-attribute

BODIES = 'field-bodies'

The colored field over the shown bodies.

OUTLINE class-attribute instance-attribute

OUTLINE = 'field-outline'

The feature-edge outline of the bodies.

STYLED class-attribute instance-attribute

STYLED = 'field-styled'

The prefix of a styled body's own actor, followed by the body name.

of

of(body: str) -> str

The actor name of one body under this prefix.

Source code in src/femforge/viz/field_display.py
def of(self, body: str) -> str:
    """The actor name of one body under this prefix."""
    return f"{self}:{body}"

Renderable

Bases: Protocol

Something a workbench run returns for display: it draws itself into plotter (on the plotter thread).

Methods:

  • render –

    Draw into plotter, on the plotter thread.

render

render(plotter: Plotter) -> None

Draw into plotter, on the plotter thread.

Source code in src/femforge/viz/scene.py
def render(self, plotter: pv.Plotter) -> None:
    """Draw into ``plotter``, on the plotter thread."""
    ...

Scene

Scene(*items: Renderable)

An ordered group of Renderables drawn into one plotter refresh: clear once, draw in order (later items on top), repaint once.

Scene is itself a Renderable, so scenes can nest.

Methods:

  • add –

    Append a renderable to draw; returns self so calls chain.

  • render –

    Clear the plotter, draw every item in order, and repaint — once. Call on the plotter thread.

Source code in src/femforge/viz/scene.py
def __init__(self, *items: Renderable) -> None:
    """Hold ``items`` in drawing order."""
    self.items: list[Renderable] = list(items)

add

add(item: Renderable) -> Scene

Append a renderable to draw; returns self so calls chain.

Source code in src/femforge/viz/scene.py
def add(self, item: Renderable) -> Scene:
    """Append a renderable to draw; returns ``self`` so calls chain."""
    self.items.append(item)
    return self

render

render(plotter: Plotter) -> None

Clear the plotter, draw every item in order, and repaint — once. Call on the plotter thread.

Source code in src/femforge/viz/scene.py
def render(self, plotter: pv.Plotter) -> None:
    """Clear the plotter, draw every item in order, and repaint — once. Call on the plotter thread."""
    plotter.clear()
    for item in self.items:
        item.render(plotter)
    plotter.render()

select_flux_density_array

select_flux_density_array(names: Iterable[str]) -> str

Pick the B array from a VTU's array names: the nodal form first (no cell→point projection needed), then the elemental "... e", then any name containing the phrase (case-insensitive).

Raises KeyError naming the available arrays if none matches.

Source code in src/femforge/viz/field_lines.py
def select_flux_density_array(names: Iterable[str]) -> str:
    """
    Pick the B array from a VTU's array names: the nodal form first (no cell→point projection needed), then the elemental ``"... e"``, then any name containing the phrase (case-insensitive).

    Raises [`KeyError`][] naming the available arrays if none matches.
    """
    by_lower: dict[str, str] = {name.lower(): name for name in names}
    if FLUX_DENSITY in by_lower:
        return by_lower[FLUX_DENSITY]
    if f"{FLUX_DENSITY} e" in by_lower:
        return by_lower[f"{FLUX_DENSITY} e"]
    for lower, original in by_lower.items():
        if FLUX_DENSITY in lower:
            return original
    raise KeyError(f"no '{FLUX_DENSITY}' array in {sorted(by_lower.values())}; was MagnetoDynamicsCalcFields run?")

seed_points

seed_points(bounds: Bounds, options: FieldLineOptions) -> list[Point3]

The (N, 3) streamline seed coordinates for the domain bounds and options (pure Python — unit-testable without a render context).

The grid is centered and spans seed_fraction of each axis, keeping seeds out of the outer air shell where B ≈ 0.

Source code in src/femforge/viz/field_lines.py
def seed_points(bounds: Bounds, options: FieldLineOptions) -> list[Point3]:
    """
    The ``(N, 3)`` streamline seed coordinates for the domain ``bounds`` and ``options`` (pure Python — unit-testable without a render context).

    The grid is centered and spans ``seed_fraction`` of each axis, keeping seeds out of the outer air shell where B ≈ 0.
    """
    xmin, xmax, ymin, ymax, zmin, zmax = bounds

    cx, cy, cz = (xmin + xmax) / 2, (ymin + ymax) / 2, (zmin + zmax) / 2
    f: float = options.seed_fraction
    hx, hy, hz = (xmax - xmin) * f / 2, (ymax - ymin) * f / 2, (zmax - zmin) * f / 2

    n: int = options.line_count
    xs: list[float] = _seed_axis(cx, hx, n)
    ys: list[float] = _seed_axis(cy, hy, n)
    zs: list[float] = _seed_axis(cz, hz, n)

    dims: float = 3.0
    match options.seed_placement:
        case SeedPlacement.GAP_LINE:
            dims = 1.0
        case SeedPlacement.CENTER_PLANE:
            dims = 2.0
        case SeedPlacement.VOLUME:
            dims = 3.0

    # Reduce n to keep the n constant regardless of dim
    n = int(n ** (1.0 / dims))  # pyright: ignore[reportAny]
    if options.seed_grid:
        match options.seed_placement:
            case SeedPlacement.GAP_LINE:
                return [(x, cy, cz) for x in xs]
            case SeedPlacement.CENTER_PLANE:
                return [(x, y, cz) for y in ys for x in xs]
            case SeedPlacement.VOLUME:
                return [(x, y, z) for z in zs for y in ys for x in xs]
    else:
        match options.seed_placement:
            case SeedPlacement.GAP_LINE:
                return [(random.uniform(xmin, xmax), cy, cz) for _ in range(n)]
            case SeedPlacement.CENTER_PLANE:
                return [(random.uniform(xmin, xmax), random.uniform(ymin, ymax), cz) for _ in range(n * n)]
            case SeedPlacement.VOLUME:
                return [(random.uniform(xmin, xmax), random.uniform(ymin, ymax), random.uniform(zmin, zmax)) for _ in range(n * n * n)]

build_streamlines

build_streamlines(dataset: DataSet, b_name: str, options: FieldLineOptions | None = None) -> StreamlineActors

Seed and integrate |B| streamlines through a dataset that already carries B as point data, without touching a plotter.

Parameters:

  • dataset (DataSet) –

    The dataset carrying B on its points.

  • b_name (str) –

    The point array the lines follow.

  • options (FieldLineOptions | None, default: None ) –

    The field-line options; None takes the defaults.

Source code in src/femforge/viz/field_lines.py
def build_streamlines(
    dataset: pv.DataSet, b_name: str, options: FieldLineOptions | None = None
) -> StreamlineActors:  # pragma: no cover - needs pyvista (viz extra); proven by the gated integration tests
    """Seed and integrate |B| streamlines through a dataset that already carries B as **point** data, without touching a plotter.

    Args:
        dataset: The dataset carrying B on its points.
        b_name: The point array the lines follow.
        options: The field-line options; None takes the defaults.
    """
    import pyvista as pv

    opts: FieldLineOptions = options if options is not None else FieldLineOptions()
    bounds: Bounds = cast("Bounds", tuple(dataset.bounds))
    seeds: pv.PolyData = pv.PolyData(seed_points(bounds, opts))

    # Bound the integration length and clip to the domain: far from the magnets the air field is weak and
    # near-uniform, so unbounded streamlines run many domain-widths past the box. Limiting the arc length to
    # the domain diagonal and clipping the tubes to the bounds keeps the picture framed on the magnets.
    xmin, xmax, ymin, ymax, zmin, zmax = bounds
    dx, dy, dz = xmax - xmin, ymax - ymin, zmax - zmin
    max_length: float = math.sqrt(dx * dx + dy * dy + dz * dz) * opts.max_length
    streamlines: pv.PolyData = cast(
        "pv.PolyData",
        dataset.streamlines_from_source(
            seeds,
            vectors=b_name,
            integration_direction="both",
            max_length=max_length,
            initial_step_length=opts.initial_step_length,
            min_step_length=opts.min_step_length,
            max_step_length=opts.max_step_length,
            interpolator_type=opts.interpolator.value,
        ),
    )
    streamlines = trim_reversals(streamlines)

    # Tubes when a radius is given, else raw poly-lines (cheaper, the right look for a dense field). pyvista's
    # geometry methods carry `**kwargs: Unknown`, so pin the typed return with a cast and silence the member report.
    tubed: pv.PolyData = (
        cast("pv.PolyData", streamlines.tube(radius=opts.tube_radius))  # pyright: ignore[reportUnknownMemberType]
        if opts.tube_radius is not None
        else streamlines
    )
    clipped: pv.PolyData = cast("pv.PolyData", tubed.clip_box(bounds, invert=False))  # pyright: ignore[reportUnknownMemberType]
    outline: pv.PolyData | None = cast("pv.PolyData", dataset.outline()) if opts.show_outline else None
    magnitudes: np.typing.NDArray[np.float64] = np.linalg.norm(np.asarray(dataset.point_data[b_name], dtype=np.float64).reshape(dataset.n_points, -1), axis=1)
    return StreamlineActors(streamlines=clipped, b_name=b_name, outline=outline, magnitudes=[magnitudes])

build_streamlines_from_vtu

build_streamlines_from_vtu(vtu_path: str | Path, options: FieldLineOptions | None = None) -> StreamlineActors

Read a solved case.vtu and integrate its B-field streamlines into a drawable StreamlineActors — no plotter touched, safe off the UI thread.

Source code in src/femforge/viz/field_lines.py
def build_streamlines_from_vtu(vtu_path: str | Path, options: FieldLineOptions | None = None) -> StreamlineActors:  # pragma: no cover - needs pyvista (viz extra); proven by the gated integration test
    """Read a solved ``case.vtu`` and integrate its B-field streamlines into a drawable [`StreamlineActors`][] — no plotter touched, safe off the UI thread."""
    import pyvista as pv

    opts: FieldLineOptions = options if options is not None else FieldLineOptions()
    grid: pv.UnstructuredGrid = cast("pv.UnstructuredGrid", pv.read(str(vtu_path)))

    sampled, b_name = prepare_streamline_source(grid, opts)
    return build_streamlines(sampled, b_name, opts)

prepare_streamline_source

prepare_streamline_source(grid: UnstructuredGrid, options: FieldLineOptions) -> tuple[DataSet, str]

Narrow a read grid to the integration region and put B on its points, returning the dataset and the B array name — the read-independent half of build_streamlines_from_vtu.

Source code in src/femforge/viz/field_lines.py
def prepare_streamline_source(grid: pv.UnstructuredGrid, options: FieldLineOptions) -> tuple[pv.DataSet, str]:  # pragma: no cover - needs pyvista (viz extra); proven by the gated integration test
    """Narrow a read grid to the integration region and put B on its points, returning the dataset and the B array name — the read-independent half of [`build_streamlines_from_vtu`][]."""
    # Viz toggle: confine integration to one region (e.g. air) by thresholding to that body BEFORE projecting B to
    # points. This kills the "spike" streamlines: the cell->point average then never mixes across the air<->magnet
    # interface, so air-side nodes don't inherit the magnets' huge magnetization-aligned B.
    if options.clip_to_region and options.region_array is not None and options.region_id is not None and options.region_array in grid.cell_data:
        rid: float = float(options.region_id)
        grid = cast("pv.UnstructuredGrid", grid.threshold([rid, rid], scalars=options.region_array, preference="cell"))  # pyright: ignore[reportUnknownMemberType]

    b_name: str = options.vectors if options.vectors is not None else select_flux_density_array(list(grid.array_names))
    # A true nodal B is integrated as-is; the elemental "... e" form (cell data) is projected to points here.
    sampled: pv.DataSet = cast("pv.DataSet", grid.cell_data_to_point_data() if b_name in grid.cell_data else grid)
    return (sampled, b_name)

draw_streamlines

draw_streamlines(actors: StreamlineActors, options: FieldLineOptions, plotter: Plotter) -> None

Draw a precomputed StreamlineActors into plotter without clearing or showing it, so it composes inside a Scene.

Parameters:

  • actors (StreamlineActors) –

    The integrated lines; an empty set removes any lines drawn before.

  • options (FieldLineOptions) –

    The field-line options that style them.

  • plotter (Plotter) –

    The plotter to draw into, on its own thread.

Source code in src/femforge/viz/field_lines.py
def draw_streamlines(
    actors: StreamlineActors, options: FieldLineOptions, plotter: pv.Plotter
) -> None:  # pragma: no cover - needs pyvista (viz extra) + a VTK render context; proven by the gated integration tests
    """Draw a precomputed [`StreamlineActors`][] into ``plotter`` without clearing or showing it, so it composes inside a [`Scene`][femforge.viz.scene.Scene].

    Args:
        actors: The integrated lines; an empty set removes any lines drawn before.
        options: The field-line options that style them.
        plotter: The plotter to draw into, on its own thread.
    """
    if actors.streamlines.n_points == 0:
        _ = plotter.remove_actor(str(StreamlineActor.LINES))  # pyright: ignore[reportUnknownMemberType, reportArgumentType]  # pyvista removes by actor name, and its _Wrapped stub loses `self`
        return

    register_paraview()

    # The scalar bar title avoids a literal "|" -- VTK's off-screen text renderer crashes on a pipe glyph.
    # opacity: a constant float, or an OpacityTransfer name; pyvista types it as a Literal a StrEnum can't satisfy
    # statically even though it's valid at runtime -- hence the reportArgumentType ignore.
    actor = plotter.add_mesh(  # pyright: ignore[reportUnknownMemberType]
        actors.streamlines,
        scalars=actors.b_name,
        cmap=options.colormap,
        flip_scalars=options.invert,
        clim=options.clim,
        log_scale=options.log_scale,
        opacity=options.opacity,  # pyright: ignore[reportArgumentType]
        lighting=False,
        show_scalar_bar=False,
        scalar_bar_args={"title": "B (T)"},
        label=f"streamlines ({options.seed_placement.value})",
        name=str(StreamlineActor.LINES),
    )
    if options.opacity_map is not None:
        ramp_alpha(actor.mapper.lookup_table, *options.opacity_map)

    if actors.outline is not None:
        plotter.add_mesh(actors.outline, color="gray", name=str(StreamlineActor.OUTLINE))  # pyright: ignore[reportUnknownMemberType]

render_streamlines

render_streamlines(dataset: DataSet, b_name: str, options: FieldLineOptions | None = None, *, off_screen: bool = False, screenshot: str | Path | None = None, plotter: Plotter | None = None) -> None

One-shot: build streamlines through dataset and draw them, in a plotter of its own when plotter is None.

Parameters:

  • dataset (DataSet) –

    The dataset carrying B on its points.

  • b_name (str) –

    The point array the lines follow.

  • options (FieldLineOptions | None, default: None ) –

    The field-line options; None takes the defaults.

  • off_screen (bool, default: False ) –

    Render without a window.

  • screenshot (str | Path | None, default: None ) –

    The PNG to write when rendering off screen.

  • plotter (Plotter | None, default: None ) –

    A live plotter to add the lines to instead of opening one.

Source code in src/femforge/viz/field_lines.py
def render_streamlines(
    dataset: pv.DataSet,
    b_name: str,
    options: FieldLineOptions | None = None,
    *,
    off_screen: bool = False,
    screenshot: str | Path | None = None,
    plotter: pv.Plotter | None = None,
) -> None:  # pragma: no cover - needs pyvista (viz extra) + a VTK render context; proven by the gated integration tests
    """One-shot: build streamlines through ``dataset`` and draw them, in a plotter of its own when ``plotter`` is None.

    Args:
        dataset: The dataset carrying B on its points.
        b_name: The point array the lines follow.
        options: The field-line options; None takes the defaults.
        off_screen: Render without a window.
        screenshot: The PNG to write when rendering off screen.
        plotter: A live plotter to add the lines to instead of opening one.
    """
    import pyvista as pv

    opts: FieldLineOptions = options if options is not None else FieldLineOptions()
    actors: StreamlineActors = build_streamlines(dataset, b_name, opts)

    own_plotter: bool = plotter is None
    if plotter is None:
        plotter = pv.Plotter(off_screen=off_screen)

    draw_streamlines(actors, opts, plotter)

    if own_plotter:
        screenshot_arg: str | bool = str(screenshot) if screenshot is not None else False
        plotter.show(screenshot=screenshot_arg)  # pyright: ignore[reportUnknownMemberType]

render_field_lines

render_field_lines(vtu_path: str | Path, options: FieldLineOptions | None = None, *, off_screen: bool = False, screenshot: str | Path | None = None, plotter: Plotter | None = None) -> None

Render the magnetic flux-density field lines from a solved case.vtu, headless to a PNG or in an interactive window.

Parameters:

  • vtu_path (str | Path) –

    The solved case.vtu.

  • options (FieldLineOptions | None, default: None ) –

    The field-line options; None takes the defaults.

  • off_screen (bool, default: False ) –

    Render without a window.

  • screenshot (str | Path | None, default: None ) –

    The PNG to write when rendering off screen.

  • plotter (Plotter | None, default: None ) –

    A live plotter to add the lines to instead of opening one.

Source code in src/femforge/viz/field_lines.py
def render_field_lines(
    vtu_path: str | Path,
    options: FieldLineOptions | None = None,
    *,
    off_screen: bool = False,
    screenshot: str | Path | None = None,
    plotter: pv.Plotter | None = None,
) -> None:  # pragma: no cover - needs pyvista (viz extra) + a VTK render context; proven by the gated integration test
    """Render the magnetic flux-density field lines from a solved ``case.vtu``, headless to a PNG or in an interactive window.

    Args:
        vtu_path: The solved ``case.vtu``.
        options: The field-line options; None takes the defaults.
        off_screen: Render without a window.
        screenshot: The PNG to write when rendering off screen.
        plotter: A live plotter to add the lines to instead of opening one.
    """
    import pyvista as pv

    opts: FieldLineOptions = options if options is not None else FieldLineOptions()
    actors: StreamlineActors = build_streamlines_from_vtu(vtu_path, opts)

    own_plotter: bool = plotter is None
    if plotter is None:
        plotter = pv.Plotter(off_screen=off_screen)

    draw_streamlines(actors, opts, plotter)

    if own_plotter:
        screenshot_arg: str | bool = str(screenshot) if screenshot is not None else False
        plotter.show(screenshot=screenshot_arg)  # pyright: ignore[reportUnknownMemberType]

field_infos

field_infos(point_arrays: Mapping[str, AnyArray], cell_arrays: Mapping[str, AnyArray]) -> list[FieldInfo]

Every displayable array of a VTU as a FieldInfo, GeometryIds excluded, sorted by display name.

Source code in src/femforge/viz/field_display.py
def field_infos(point_arrays: Mapping[str, AnyArray], cell_arrays: Mapping[str, AnyArray]) -> list[FieldInfo]:
    """Every displayable array of a VTU as a [`FieldInfo`][], ``GeometryIds`` excluded, sorted by display name."""
    infos: list[FieldInfo] = []
    for association, arrays in ((Association.POINT, point_arrays), (Association.CELL, cell_arrays)):
        for name, values in arrays.items():
            if name == GEOMETRY_IDS_ARRAY:
                continue

            array: AnyArray = np.asarray(values)
            shape: tuple[int, ...] = array.shape
            components: int = shape[-1] if array.ndim > 1 else 1
            infos.append(FieldInfo(name=name, association=association, components=components, dtype=str(array.dtype)))

    return sorted(infos, key=lambda info: info.display_name)

reduce_field

reduce_field(values: AnyArray, mode: DisplayMode) -> FloatArray

The scalar a display mode draws from one field array.

VALUE passes the array through and POLAR_XY returns the XY magnitude (the quantity that sets the color range; the angle rides along as hue).

Source code in src/femforge/viz/field_display.py
def reduce_field(values: AnyArray, mode: DisplayMode) -> FloatArray:
    """
    The scalar a display mode draws from one field array.

    ``VALUE`` passes the array through and ``POLAR_XY`` returns the XY magnitude (the quantity that sets the color range; the angle rides along as hue).
    """
    array: FloatArray = np.asarray(values, dtype=np.float64)
    if mode in VECTOR_MODES and (array.ndim < 2 or array.shape[-1] != 3):
        raise ValueError(f"display mode {mode.value!r} needs 3-component vectors, got shape {array.shape}")

    match mode:
        case DisplayMode.VALUE:
            return array
        case DisplayMode.ABSVALUE:
            return np.abs(array)
        case DisplayMode.X:
            return array[..., 0]
        case DisplayMode.Y:
            return array[..., 1]
        case DisplayMode.Z:
            return array[..., 2]
        case DisplayMode.MAGNITUDE:
            return np.linalg.norm(array, axis=-1)
        case DisplayMode.POLAR_XY:
            return np.linalg.norm(array[..., :2], axis=-1)

polar_angle

polar_angle(values: AnyArray) -> FloatArray

Each vector's XY direction as a cyclic coordinate in [0, 1] — atan2(y, x) mapped off [-pi, pi].

Source code in src/femforge/viz/field_display.py
def polar_angle(values: AnyArray) -> FloatArray:
    """Each vector's XY direction as a cyclic coordinate in ``[0, 1]`` — ``atan2(y, x)`` mapped off ``[-pi, pi]``."""
    array: FloatArray = np.asarray(values, dtype=np.float64)
    if array.ndim < 2 or array.shape[-1] < 2:
        raise ValueError(f"polar angle needs at least 2 components, got shape {array.shape}")

    radians: FloatArray = np.arctan2(array[..., 1], array[..., 0])
    return (radians + np.pi) / (2.0 * np.pi)

polar_rgba

polar_rgba(values: AnyArray, lo: float, hi: float, cmap: Colormapper) -> Float32Array

Per-vector RGBA for DisplayMode.POLAR_XY: hue from the XY angle through a cyclic cmap, brightness and alpha from the sigmoid-scaled XY magnitude.

Parameters:

  • values (AnyArray) –

    The (N, 3) vectors.

  • lo (float) –

    The XY magnitude at the dark end.

  • hi (float) –

    The XY magnitude at the bright end.

  • cmap (Colormapper) –

    The cyclic colormap the XY angle maps through.

Source code in src/femforge/viz/field_display.py
def polar_rgba(values: AnyArray, lo: float, hi: float, cmap: Colormapper) -> Float32Array:
    """Per-vector RGBA for [`DisplayMode.POLAR_XY`][]: hue from the XY angle through a cyclic ``cmap``, brightness and alpha from the sigmoid-scaled XY magnitude.

    Args:
        values: The ``(N, 3)`` vectors.
        lo: The XY magnitude at the dark end.
        hi: The XY magnitude at the bright end.
        cmap: The cyclic colormap the XY angle maps through.
    """
    array: FloatArray = np.asarray(values, dtype=np.float64)
    magnitude: FloatArray = reduce_field(array, DisplayMode.POLAR_XY)

    # A degenerate range would divide by zero; a unit span leaves the magnitudes where they already are.
    span: float = hi - lo if hi > lo else 1.0
    normalized: FloatArray = np.clip((magnitude - lo) / span, 0.0, 1.0)
    sigmoid: FloatArray = 1.0 / (1.0 + np.exp(-POLAR_STEEPNESS * (normalized - 0.5)))
    scaled: FloatArray = POLAR_BLEND * normalized + (1.0 - POLAR_BLEND) * sigmoid

    hue: FloatArray = np.asarray(cmap(polar_angle(array)), dtype=np.float64)
    rgba: Float32Array = np.zeros((array.shape[0], 4), dtype=np.float32)
    rgba[..., 0:3] = np.clip(hue[..., :3] * scaled[..., np.newaxis], 0.0, 1.0)
    rgba[..., 3] = scaled
    return rgba

color_range

color_range(chunks: Iterable[AnyArray], *, log_scale: bool = False, robust: bool = True) -> tuple[float, float]

The (lo, hi) color limits spanning every scalar chunk's own range, from the finite values only; an empty selection gives (0.0, 1.0).

Parameters:

  • chunks (Iterable[AnyArray]) –

    The scalar arrays, one per body.

  • log_scale (bool, default: False ) –

    Keep only the strictly positive values and start at their 1st percentile.

  • robust (bool, default: True ) –

    Clamp each chunk to its 2-98 percentiles, so a small body keeps its range beside a large one.

Source code in src/femforge/viz/field_display.py
def color_range(chunks: Iterable[AnyArray], *, log_scale: bool = False, robust: bool = True) -> tuple[float, float]:
    """The ``(lo, hi)`` color limits spanning every scalar chunk's own range, from the finite values only; an empty selection gives ``(0.0, 1.0)``.

    Args:
        chunks: The scalar arrays, one per body.
        log_scale: Keep only the strictly positive values and start at their 1st percentile.
        robust: Clamp each chunk to its 2-98 percentiles, so a small body keeps its range beside a large one.
    """

    def bounds(chunk: AnyArray) -> tuple[float, float] | None:
        values: FloatArray = np.asarray(chunk, dtype=np.float64).ravel()
        finite: FloatArray = values[np.isfinite(values)]
        usable: FloatArray = finite[finite > 0.0] if log_scale else finite
        if usable.size == 0:
            return None

        if log_scale:
            return (float(np.percentile(usable, 1.0)), float(np.max(usable)))

        if robust:
            return (float(np.percentile(usable, 2.0)), float(np.percentile(usable, 98.0)))

        return (float(np.min(usable)), float(np.max(usable)))

    ranges: list[tuple[float, float]] = [found for found in (bounds(chunk) for chunk in chunks) if found is not None]
    if not ranges:
        return (0.0, 1.0)

    lo: float = min(low for low, _ in ranges)
    hi: float = max(high for _, high in ranges)

    def widened(low: float, high: float) -> tuple[float, float]:
        """A single-valued range nudged apart so the colormap has somewhere to map."""
        if high > low:
            return (low, high)

        if log_scale:
            return (low / 2.0, high * 2.0)

        pad: float = abs(high) if high != 0.0 else 1.0
        return (low - pad, high + pad)

    return widened(lo, hi)

camera_needs_reset

camera_needs_reset(position: Sequence[float], focal: Sequence[float], bounds: Sequence[float] | None, *, half_angle_deg: float = 30.0) -> bool

Whether the camera has lost the model, with the box behind it or outside its view cone; a camera inside the box always keeps its view.

Parameters:

  • position (Sequence[float]) –

    The camera position.

  • focal (Sequence[float]) –

    The camera's focal point.

  • bounds (Sequence[float] | None) –

    The model box as (xmin, xmax, ymin, ymax, zmin, zmax); missing or non-finite bounds always re-frame.

  • half_angle_deg (float, default: 30.0 ) –

    The half angle of the view cone some box corner must fall inside, in degrees.

Source code in src/femforge/viz/field_display.py
def camera_needs_reset(position: Sequence[float], focal: Sequence[float], bounds: Sequence[float] | None, *, half_angle_deg: float = 30.0) -> bool:
    """Whether the camera has lost the model, with the box behind it or outside its view cone; a camera inside the box always keeps its view.

    Args:
        position: The camera position.
        focal: The camera's focal point.
        bounds: The model box as ``(xmin, xmax, ymin, ymax, zmin, zmax)``; missing or non-finite bounds always re-frame.
        half_angle_deg: The half angle of the view cone some box corner must fall inside, in degrees.
    """
    if bounds is None:
        return True

    box: list[float] = [float(value) for value in bounds]
    if not all(math.isfinite(value) for value in box):
        return True

    eye: FloatArray = np.asarray([float(value) for value in position], dtype=np.float64)
    view: FloatArray = np.asarray([float(value) for value in focal], dtype=np.float64) - eye
    length: float = float(np.linalg.norm(view))
    if length == 0.0:
        return True

    xs, ys, zs = box[0:2], box[2:4], box[4:6]
    lower: FloatArray = np.asarray([xs[0], ys[0], zs[0]], dtype=np.float64)
    upper: FloatArray = np.asarray([xs[1], ys[1], zs[1]], dtype=np.float64)
    if bool(np.all((eye >= lower) & (eye <= upper))):
        return False

    corners: FloatArray = np.asarray([(x, y, z) for x in xs for y in ys for z in zs], dtype=np.float64)
    offsets: FloatArray = corners - eye
    along: FloatArray = offsets @ (view / length)
    if float(np.max(along)) <= 0.0:
        return True

    distances: FloatArray = np.linalg.norm(offsets, axis=1)
    cosines: FloatArray = np.divide(along, distances, out=np.zeros_like(along), where=distances > 0.0)
    return not bool(np.any(cosines >= math.cos(math.radians(half_angle_deg))))

plotly_colorscale

plotly_colorscale(samples: AnyArray, *, invert: bool = False) -> Colorscale

A plotly colorscale from sampled RGB(A) rows: evenly-spaced stops paired with "rgb(r,g,b)" strings.

Source code in src/femforge/viz/field_display.py
def plotly_colorscale(samples: AnyArray, *, invert: bool = False) -> Colorscale:
    """A plotly colorscale from sampled RGB(A) rows: evenly-spaced stops paired with ``"rgb(r,g,b)"`` strings."""
    rows: FloatArray = np.asarray(samples, dtype=np.float64)
    if rows.ndim != 2 or rows.shape[1] not in (3, 4):
        raise ValueError(f"colormap samples must be (N, 3) or (N, 4), got shape {rows.shape}")

    if rows.shape[0] < 2:
        raise ValueError(f"a colorscale needs at least 2 samples, got {rows.shape[0]}")

    ordered: FloatArray = rows[::-1] if invert else rows
    channels: IntArray = np.clip(ordered[:, :3] * 255.0, 0.0, 255.0).round().astype(np.int64)
    triplets: list[list[int]] = cast("list[list[int]]", channels.tolist())

    last: int = len(triplets) - 1
    scale: Colorscale = []
    for index, (red, green, blue) in enumerate(triplets):
        scale.append([index / last, f"rgb({red},{green},{blue})"])

    return scale

log_colorbar_ticks

log_colorbar_ticks(lo: float, hi: float) -> tuple[list[float], list[str]]

Decade tick positions spanning a log color range, with their 1e-notation labels.

Source code in src/femforge/viz/field_display.py
def log_colorbar_ticks(lo: float, hi: float) -> tuple[list[float], list[str]]:
    """Decade tick positions spanning a log color range, with their ``1e``-notation labels."""
    if lo <= 0.0 or hi <= 0.0:
        raise ValueError(f"log colorbar ticks need positive bounds, got ({lo}, {hi})")

    first: int = math.floor(math.log10(min(lo, hi)))
    last: int = math.ceil(math.log10(max(lo, hi)))
    exponents: list[int] = list(range(first, last + 1))
    return ([10.0**exponent for exponent in exponents], [f"1e{exponent}" for exponent in exponents])

mesh3d_traces

mesh3d_traces(points: FloatArray, triangles: IntArray, intensity: FloatArray | None, *, name: str, color: str | None = None, opacity: float = 1.0, intensitymode: Association = POINT, colorscale: Colorscale | None = None, cmin: float | None = None, cmax: float | None = None, showscale: bool = False, hovertemplate: str | None = None, customdata: FloatArray | None = None, colorbar: TraceDict | None = None, visible: bool = True) -> list[TraceDict]

One plotly Mesh3d trace as a plain JSON dict: a body's triangulated surface colored by intensity, or by color alone.

Parameters:

  • points (FloatArray) –

    The (N, 3) vertex coordinates.

  • triangles (IntArray) –

    The (M, 3) vertex indices of each triangle.

  • intensity (FloatArray | None) –

    The values the colorscale maps, per vertex or per triangle; None colors by color alone.

  • name (str) –

    The legend name.

  • color (str | None, default: None ) –

    A solid color for a body without an intensity.

  • opacity (float, default: 1.0 ) –

    The trace opacity.

  • intensitymode (Association, default: POINT ) –

    Whether intensity lives on the vertices or the triangles.

  • colorscale (Colorscale | None, default: None ) –

    The plotly colorscale intensity maps through.

  • cmin (float | None, default: None ) –

    The intensity at the low end of the colorscale.

  • cmax (float | None, default: None ) –

    The intensity at the high end of the colorscale.

  • showscale (bool, default: False ) –

    Draw the colorbar.

  • hovertemplate (str | None, default: None ) –

    The plotly hover text template.

  • customdata (FloatArray | None, default: None ) –

    Per-vertex values the hover template reads.

  • colorbar (TraceDict | None, default: None ) –

    The plotly colorbar settings.

  • visible (bool, default: True ) –

    Draw the body; a hidden one stays in the legend as legendonly.

Source code in src/femforge/viz/field_display.py
def mesh3d_traces(
    points: FloatArray,
    triangles: IntArray,
    intensity: FloatArray | None,
    *,
    name: str,
    color: str | None = None,
    opacity: float = 1.0,
    intensitymode: Association = Association.POINT,
    colorscale: Colorscale | None = None,
    cmin: float | None = None,
    cmax: float | None = None,
    showscale: bool = False,
    hovertemplate: str | None = None,
    customdata: FloatArray | None = None,
    colorbar: TraceDict | None = None,
    visible: bool = True,
) -> list[TraceDict]:
    """One plotly ``Mesh3d`` trace as a plain JSON dict: a body's triangulated surface colored by ``intensity``, or by ``color`` alone.

    Args:
        points: The ``(N, 3)`` vertex coordinates.
        triangles: The ``(M, 3)`` vertex indices of each triangle.
        intensity: The values the colorscale maps, per vertex or per triangle; None colors by ``color`` alone.
        name: The legend name.
        color: A solid color for a body without an intensity.
        opacity: The trace opacity.
        intensitymode: Whether ``intensity`` lives on the vertices or the triangles.
        colorscale: The plotly colorscale ``intensity`` maps through.
        cmin: The intensity at the low end of the colorscale.
        cmax: The intensity at the high end of the colorscale.
        showscale: Draw the colorbar.
        hovertemplate: The plotly hover text template.
        customdata: Per-vertex values the hover template reads.
        colorbar: The plotly colorbar settings.
        visible: Draw the body; a hidden one stays in the legend as ``legendonly``.
    """
    trace: TraceDict = {
        "type": "mesh3d",
        "name": name,
        "x": points[:, 0].tolist(),
        "y": points[:, 1].tolist(),
        "z": points[:, 2].tolist(),
        "i": triangles[:, 0].tolist(),
        "j": triangles[:, 1].tolist(),
        "k": triangles[:, 2].tolist(),
        "intensity": intensity.tolist() if intensity is not None else None,
        "intensitymode": intensitymode.intensitymode,
        "color": color,
        "opacity": opacity,
        "colorscale": colorscale,
        "cmin": cmin,
        "cmax": cmax,
        "showscale": showscale,
        "colorbar": colorbar,
        "flatshading": True,
        "hovertemplate": hovertemplate,
        "customdata": customdata.tolist() if customdata is not None else None,
        "visible": True if visible else "legendonly",
        "showlegend": True,
    }
    return [trace]

outline_traces

outline_traces(segments: FloatArray, *, name: str = 'outline', color: str = 'black') -> list[TraceDict]

One plotly Scatter3d lines trace drawing every (N, 2, 3) segment, NaN-separated so the segments stay disjoint.

Parameters:

  • segments (FloatArray) –

    The (N, 2, 3) line segments.

  • name (str, default: 'outline' ) –

    The trace name.

  • color (str, default: 'black' ) –

    The line color.

Source code in src/femforge/viz/field_display.py
def outline_traces(segments: FloatArray, *, name: str = "outline", color: str = "black") -> list[TraceDict]:
    """One plotly ``Scatter3d`` lines trace drawing every ``(N, 2, 3)`` segment, NaN-separated so the segments stay disjoint.

    Args:
        segments: The ``(N, 2, 3)`` line segments.
        name: The trace name.
        color: The line color.
    """
    if segments.size == 0:
        return []

    padded: FloatArray = np.full((segments.shape[0], 3, 3), np.nan, dtype=np.float64)
    padded[:, 0:2, :] = segments
    flat: FloatArray = padded.reshape(-1, 3)

    trace: TraceDict = {
        "type": "scatter3d",
        "mode": "lines",
        "name": name,
        "x": flat[:, 0].tolist(),
        "y": flat[:, 1].tolist(),
        "z": flat[:, 2].tolist(),
        "line": {"color": color, "width": 2},
        "hoverinfo": "skip",
        "showlegend": False,
    }
    return [trace]

list_fields

list_fields(grid: DataSet) -> list[FieldInfo]

Every displayable field of a VTU grid (GeometryIds excluded), sorted by display name.

Source code in src/femforge/viz/field_display.py
def list_fields(grid: pv.DataSet) -> list[FieldInfo]:  # pragma: no cover - needs pyvista (viz extra); proven by the gated integration test
    """Every displayable field of a VTU grid (``GeometryIds`` excluded), sorted by display name."""
    point_arrays: dict[str, AnyArray] = {name: np.asarray(grid.point_data[name]) for name in grid.point_data.keys()}
    cell_arrays: dict[str, AnyArray] = {name: np.asarray(grid.cell_data[name]) for name in grid.cell_data.keys()}
    return field_infos(point_arrays, cell_arrays)

split_by_body

split_by_body(grid: UnstructuredGrid, bodies: Bodies) -> MultiBlock

The grid split into one block per named body, keyed by its entities.json name via the GeometryIds cell array.

Source code in src/femforge/viz/field_display.py
def split_by_body(grid: pv.UnstructuredGrid, bodies: Bodies) -> pv.MultiBlock:  # pragma: no cover - needs pyvista (viz extra); proven by the gated integration test
    """The grid split into one block per named body, keyed by its ``entities.json`` name via the ``GeometryIds`` cell array."""
    import pyvista as pv

    ids: IntArray = np.asarray(grid.cell_data[GEOMETRY_IDS_ARRAY], dtype=np.int64)
    blocks: pv.MultiBlock = pv.MultiBlock()
    for body in bodies:
        mask = cast("np.typing.NDArray[np.bool_]", ids == body.mesh_id)
        if not bool(np.any(mask)):
            continue

        block = cast("pv.UnstructuredGrid", grid.extract_cells(mask))  # pyright: ignore[reportUnknownMemberType]  # pyvista's extract_cells index parameter is partially untyped
        blocks.append(block, name=body.name)

    return blocks

feature_outline

feature_outline(blocks: MultiBlock) -> PolyData | None

The feature and boundary edges of a multiblock's outer surface — the black wireframe drawn over the field, the body outlines on a 2D slice — or None when the surface has none.

Source code in src/femforge/viz/field_display.py
def feature_outline(blocks: pv.MultiBlock) -> pv.PolyData | None:  # pragma: no cover - needs pyvista (viz extra); proven by the gated integration test
    """The feature and boundary edges of a multiblock's outer surface — the black wireframe drawn over the field, the body outlines on a 2D slice — or None when the surface has none."""
    import pyvista as pv

    if blocks.is_empty:
        return None

    surface: object = blocks.extract_surface(algorithm=None)
    if not isinstance(surface, pv.PolyData) or surface.n_points == 0:
        return None

    edges: pv.PolyData = surface.extract_feature_edges(
        boundary_edges=True,
        feature_edges=True,
        manifold_edges=False,
        non_manifold_edges=False,
        feature_angle=FEATURE_ANGLE,
        clear_data=True,
    )
    if edges.n_points == 0:
        return None

    return edges

model_dim_of

model_dim_of(grid: DataSet) -> int

2 for a flat (axisymmetric slice) grid with no Z extent, else 3.

Source code in src/femforge/viz/field_display.py
def model_dim_of(grid: pv.DataSet) -> int:  # pragma: no cover - needs pyvista (viz extra); proven by the gated integration test
    """2 for a flat (axisymmetric slice) grid with no Z extent, else 3."""
    bounds: Sequence[float] = grid.bounds
    return 2 if bounds[5] - bounds[4] <= 0.0 else 3

sample_colormap

sample_colormap(name: str, count: int = 256) -> FloatArray

count evenly-spaced RGBA rows from a named colormap, resolved through pyvista's get_cmap_safe.

Source code in src/femforge/viz/field_display.py
def sample_colormap(name: str, count: int = 256) -> FloatArray:  # pragma: no cover - needs pyvista (viz extra); proven by the gated integration test
    """``count`` evenly-spaced RGBA rows from a named colormap, resolved through pyvista's ``get_cmap_safe``."""
    import pyvista as pv

    register_paraview()
    cmap = pv.get_cmap_safe(name)  # pyright: ignore[reportUnknownMemberType]  # pyvista returns an untyped matplotlib Colormap
    return np.asarray(cmap(np.linspace(0.0, 1.0, count)), dtype=np.float64)

surface_triangles

surface_triangles(block: DataSet) -> tuple[FloatArray, IntArray]

A block's outer surface as (points, triangles): an (N, 3) coordinate array and an (M, 3) vertex-index array.

Source code in src/femforge/viz/field_display.py
def surface_triangles(block: pv.DataSet) -> tuple[FloatArray, IntArray]:  # pragma: no cover - needs pyvista (viz extra); proven by the gated integration test
    """A block's outer surface as ``(points, triangles)``: an ``(N, 3)`` coordinate array and an ``(M, 3)`` vertex-index array."""
    surface: pv.PolyData = cast("pv.PolyData", cast("pv.PolyData", block.extract_surface(algorithm=None)).triangulate())
    # A triangulated PolyData's `faces` is a flat [3, a, b, c, ...] stream; the leading count column is dropped.
    faces: IntArray = np.asarray(surface.faces, dtype=np.int64).reshape(-1, 4)  # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType]  # pyvista's faces property is untyped
    return (np.asarray(surface.points, dtype=np.float64), faces[:, 1:])

outline_segments

outline_segments(edges: PolyData) -> FloatArray

The feature edges as an (N, 2, 3) array of line segments.

Source code in src/femforge/viz/field_display.py
def outline_segments(edges: pv.PolyData) -> FloatArray:  # pragma: no cover - needs pyvista (viz extra); proven by the gated integration test
    """The feature edges as an ``(N, 2, 3)`` array of line segments."""
    points: FloatArray = np.asarray(edges.points, dtype=np.float64)
    lines: IntArray = np.asarray(edges.lines, dtype=np.int64).reshape(-1, 3)  # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType]  # pyvista's lines property is untyped
    return points[lines[:, 1:]]