Skip to content

visualdynamics.viz.geometry

geometry

PyVista scene construction for Geometry.

Coordinates are converted from stored SI to the display unit system at scene build time — switching unit systems rebuilds the scene, never the data.

Line elements are drawn with direct colors, grouped by color index, except when the scene is colored by value (see docs/colormap.md). An older note here warned that macOS VTK silently drops scalar-mapped line cells; that was measured against pyvista 0.48.4 / VTK 9.6.2 and does not reproduce — see the same doc for the numbers.

Functions:

Name Description
axis_unit_text

What the labeled axes say. A geometry whose units are undefined

display_points

(coordinates to draw, axis unit text) for a geometry.

solid_faces

The faces of a solid cell of this many corners, each as

geometry_scene

Build (or add to) a PyVista plotter showing the geometry.

print_plotter

An off-screen plotter for a figure size_in inches at dpi, and

annotate_scene

Scene annotations, each independently switchable.

shared_points

A vtkPoints every mesh can share, plus a writable numpy view of it.

dof_axis

0/1/2 for a DOF direction's axis — 'RZ-' is a Z, so blue.

dof_arrow_length

One length for every arrow on a plot: 12% of the geometry's

add_dof_arrows

Labeled arrows marking DOFs on the geometry.

add_coordinate_system

One coordinate system: three directions, named, with angles curved.

shared_scalars

A VTK point-data array every mesh can share, and a numpy view of it.

label_choices

labels in one shape: {kind: the ids to caption, or None}.

labels_fit

Would captioning this kind stay under ENTITY_LABEL_LIMIT?

label_spots

(positions, texts) captioning one kind of entity with its id.

add_geometry

Add one geometry's meshes to an existing plotter.

place_view

Turn the camera to view — a geometry's View, or the default

plot_geometry

Show the geometry interactively, or render to screenshot headlessly.

plot_dofs

The geometry with labeled arrows at every DOF source measures

Classes

Functions:

axis_unit_text

axis_unit_text(geometry: Geometry, unit_system: UnitSystem) -> str

What the labeled axes say. A geometry whose units are undefined says so rather than naming one.

Source code in src/visualdynamics/viz/geometry.py
def axis_unit_text(geometry: Geometry, unit_system: UnitSystem) -> str:
    """What the labeled axes say. A geometry whose units are undefined
    says so rather than naming one."""
    if not geometry.units_defined:
        return '[units undefined]'
    return f"[{unit_system.label_text('length')}]"

display_points

display_points(geometry: Geometry, unit_system: UnitSystem) -> tuple[ndarray, str]

(coordinates to draw, axis unit text) for a geometry.

A geometry whose units are undefined is drawn with the file's raw coordinates — converting them would silently scale unknown values — and its axes say so rather than naming a unit.

Source code in src/visualdynamics/viz/geometry.py
def display_points(geometry: Geometry,
                   unit_system: UnitSystem) -> tuple[np.ndarray, str]:
    """(coordinates to draw, axis unit text) for a geometry.

    A geometry whose units are undefined is drawn with the file's raw
    coordinates — converting them would silently scale unknown values — and
    its axes say so rather than naming a unit.
    """
    if not geometry.units_defined:
        return geometry.node_xyz, axis_unit_text(geometry, unit_system)
    return (unit_system.from_si(geometry.node_xyz, 'length'),
            axis_unit_text(geometry, unit_system))

solid_faces

solid_faces(node_count: int) -> tuple[tuple[int, ...], ...]

The faces of a solid cell of this many corners, each as positions into the cell's node list; a higher-order cell draws by its corners, which come first.

Parameters:

Name Type Description Default
node_count int

How many nodes the cell names.

required

Returns:

Type Description
tuple of tuple of int
Source code in src/visualdynamics/viz/geometry.py
def solid_faces(node_count: int) -> tuple[tuple[int, ...], ...]:
    """The faces of a solid cell of this many corners, each as
    positions into the cell's node list; a higher-order cell draws by
    its corners, which come first.

    Parameters
    ----------
    node_count : int
        How many nodes the cell names.

    Returns
    -------
    tuple of tuple of int
    """
    if node_count in (8, 20, 27):
        return _SOLID_FACES[8]
    if node_count in (6, 15, 24):
        return _SOLID_FACES[6]
    if node_count in (4, 10):
        return _SOLID_FACES[4]
    if node_count in (5, 13):                 # a pyramid: a quad and four
        return ((0, 3, 2, 1), (0, 1, 4), (1, 2, 4), (2, 3, 4), (3, 0, 4))
    return ()

geometry_scene

geometry_scene(geometry: Geometry, unit_system: UnitSystem | None = None, plotter: Any = None, node_size: float = 8.0, line_width: float = 2.0, show_edges: bool = True, opacity: float = 1.0, labels: Sequence[str] | None = None, off_screen: bool = False, theme: Any = None, components: Sequence[str] | None = None, scale: float = 1.0, bounds: bool = True, orientation: bool = True) -> Any

Build (or add to) a PyVista plotter showing the geometry.

theme is 'light', 'dark', or a colors dict; it sets the scene background and annotation color. components limits what is drawn to a subset of {'nodes', 'elements'} — selecting one in the project tree shows just that part. scale multiplies every size given in pixels — nodes, lines, the bounds' labels — for a render at print resolution (print_plotter). bounds and orientation are the labeled box and the corner triad (annotate_scene); a print figure may want neither. Returns the plotter; call .show() on it (or .screenshot() if off_screen).

Source code in src/visualdynamics/viz/geometry.py
def geometry_scene(geometry: Geometry, unit_system: UnitSystem | None = None,
                   plotter: Any = None, node_size: float = 8.0,
                   line_width: float = 2.0, show_edges: bool = True,
                   opacity: float = 1.0,
                   labels: Sequence[str] | None = None,
                   off_screen: bool = False, theme: Any = None,
                   components: Sequence[str] | None = None,
                   scale: float = 1.0, bounds: bool = True,
                   orientation: bool = True) -> Any:
    """Build (or add to) a PyVista plotter showing the geometry.

    `theme` is 'light', 'dark', or a colors dict; it sets the scene
    background and annotation color. `components` limits what is drawn to a
    subset of {'nodes', 'elements'} — selecting one in the
    project tree shows just that part. `scale` multiplies every size
    given in pixels — nodes, lines, the bounds' labels — for a render at
    print resolution (`print_plotter`). `bounds` and `orientation` are
    the labeled box and the corner triad (`annotate_scene`); a print
    figure may want neither. Returns the plotter; call .show() on it (or
    .screenshot() if off_screen).
    """
    import pyvista as pv

    us = unit_system or DEFAULT_SYSTEM
    colors = resolve_theme(theme)
    if plotter is None:
        plotter = pv.Plotter(off_screen=off_screen)
    plotter.set_background(colors['scene_background'])
    axis_unit = add_geometry(plotter, geometry, unit_system=us,
                             node_size=node_size * scale,
                             line_width=line_width * scale,
                             show_edges=show_edges, opacity=opacity,
                             labels=labels, components=components,
                             text_color=colors['scene_text'])
    annotate_scene(plotter, axis_unit, colors, bounds=bounds,
                   orientation=orientation, scale=scale)
    return plotter

print_plotter

print_plotter(size_in: tuple[float, float], dpi: float) -> tuple[Any, float]

An off-screen plotter for a figure size_in inches at dpi, and the scale every pixel size in it is to be drawn at.

A 3-D screenshot at print resolution came out with its labels at about 2 pt, and screenshot(scale=) dropped the point labels and swelled the axis triad (the band-average paper, 2026-09-26). Here the window is the printed size in pixels, the render window's DPI makes every text actor's points print as points, and the returned scale — how many print pixels a screen pixel becomes — is what the sizes VTK takes in pixels (nodes, lines, the bounds' labels) are multiplied by, so the figure prints as the screen shows it.

Parameters:

Name Type Description Default
size_in (float, float)

Width and height of the printed figure, in inches.

required
dpi float

Its resolution.

required

Returns:

Type Description
(Plotter, float)
Source code in src/visualdynamics/viz/geometry.py
def print_plotter(size_in: tuple[float, float], dpi: float) -> tuple[Any, float]:
    """An off-screen plotter for a figure `size_in` inches at `dpi`, and
    the scale every pixel size in it is to be drawn at.

    A 3-D screenshot at print resolution came out with its labels at
    about 2 pt, and ``screenshot(scale=)`` dropped the point labels and
    swelled the axis triad (the band-average paper, 2026-09-26). Here the
    window is the printed size in pixels, the render window's DPI makes
    every text actor's points print as points, and the returned scale —
    how many print pixels a screen pixel becomes — is what the sizes VTK
    takes in pixels (nodes, lines, the bounds' labels) are multiplied
    by, so the figure prints as the screen shows it.

    Parameters
    ----------
    size_in : (float, float)
        Width and height of the printed figure, in inches.
    dpi : float
        Its resolution.

    Returns
    -------
    (pyvista.Plotter, float)
    """
    import pyvista as pv

    scale = float(dpi) / SCREEN_DPI
    plotter = pv.Plotter(off_screen=True, window_size=(
        max(1, round(size_in[0] * dpi)), max(1, round(size_in[1] * dpi))))
    plotter.ren_win.SetDPI(round(VTK_DPI * scale))
    return plotter, scale

annotate_scene

annotate_scene(plotter: Any, axis_unit: str, colors: Mapping[str, str], bounds: bool = True, orientation: bool = True, scale: float = 1.0) -> None

Scene annotations, each independently switchable.

bounds is the labeled box drawn around the geometry; orientation is the small triad in the corner. The 3D view toolbar toggles them separately. scale other than one is a print render (print_plotter): the box's labels, sized in screen pixels, are set to print at the size the scene's other labels print (PRINT_BOUNDS_SIZE).

Source code in src/visualdynamics/viz/geometry.py
def annotate_scene(plotter: Any, axis_unit: str, colors: Mapping[str, str],
                   bounds: bool = True,
                   orientation: bool = True, scale: float = 1.0) -> None:
    """Scene annotations, each independently switchable.

    `bounds` is the labeled box drawn around the geometry; `orientation` is
    the small triad in the corner. The 3D view toolbar toggles them
    separately. `scale` other than one is a print render
    (`print_plotter`): the box's labels, sized in screen pixels, are set
    to print at the size the scene's other labels print
    (`PRINT_BOUNDS_SIZE`).
    """
    if bounds:
        box = plotter.show_bounds(xtitle=f'X {axis_unit}',
                                  ytitle=f'Y {axis_unit}',
                                  ztitle=f'Z {axis_unit}', grid='back',
                                  location='outer', color=colors['scene_text'],
                                  **_label_counts(plotter.bounds))
        if scale != 1.0 and hasattr(box, 'SetScreenSize'):
            # the box's labels are sized in screen pixels by the cube
            # axes' own screen size — not by the font size, not by the
            # render window's DPI (both measured, 2026-09-26) — so a
            # print render sets that, to print as the other labels do
            box.SetScreenSize(PRINT_BOUNDS_SIZE * scale)
    else:
        plotter.remove_bounds_axes()
    if orientation:
        plotter.add_axes(color=colors['scene_text'])
    else:
        plotter.hide_axes()

shared_points

shared_points(points: ArrayLike) -> tuple[Any, ndarray]

A vtkPoints every mesh can share, plus a writable numpy view of it.

Writing through the view and calling Modified() moves every mesh at once — the difference between one upload per frame and one per mesh.

Source code in src/visualdynamics/viz/geometry.py
def shared_points(points: ArrayLike) -> tuple[Any, np.ndarray]:
    """A vtkPoints every mesh can share, plus a writable numpy view of it.

    Writing through the view and calling Modified() moves every mesh at
    once — the difference between one upload per frame and one per mesh.
    """
    import pyvista as pv
    import vtk
    from vtkmodules.util.numpy_support import vtk_to_numpy

    vtk_points = vtk.vtkPoints()
    # copy: convert_array wraps the caller's buffer, and the animator needs
    # VTK's memory to be distinct from the base positions it writes from
    vtk_points.SetData(pv.convert_array(np.array(points, dtype=np.float64)))
    return vtk_points, vtk_to_numpy(vtk_points.GetData())

dof_axis

dof_axis(direction: str) -> int | None

0/1/2 for a DOF direction's axis — 'RZ-' is a Z, so blue.

Source code in src/visualdynamics/viz/geometry.py
def dof_axis(direction: str) -> int | None:
    """0/1/2 for a DOF direction's axis — 'RZ-' is a Z, so blue."""
    letter = str(direction).upper().lstrip('R')[:1]
    return {'X': 0, 'Y': 1, 'Z': 2}.get(letter)

dof_arrow_length

dof_arrow_length(points: ArrayLike, extent: float) -> float

One length for every arrow on a plot: 12% of the geometry's extent, shrunk to the closest spacing of the arrowed nodes so a dense set of arrows never overlaps its neighbors.

Source code in src/visualdynamics/viz/geometry.py
def dof_arrow_length(points: ArrayLike, extent: float) -> float:
    """One length for every arrow on a plot: 12% of the geometry's
    extent, shrunk to the closest spacing of the arrowed nodes so a
    dense set of arrows never overlaps its neighbors."""
    length = 0.12 * extent
    unique = np.unique(np.asarray(points, dtype=float), axis=0)
    if len(unique) > 1:
        from scipy.spatial import cKDTree

        # each node's nearest neighbor, by tree: the all-pairs table this
        # replaced was 600 MB at five thousand nodes (2026-09-27)
        distances, _index = cKDTree(unique).query(unique, k=2)
        nearest = float(distances[:, 1].min())
        if np.isfinite(nearest) and nearest > 0:
            length = min(length, 0.9 * nearest)
    return length

add_dof_arrows

add_dof_arrows(plotter: Any, geometry: Geometry, dofs: Sequence[str], unit_system: UnitSystem | None = None, incoming: bool = False, name: str | None = None) -> int

Labeled arrows marking DOFs on the geometry.

One arrow per DOF (built into one mesh per color, so ten thousand cost what ten do), colored by the axis it points along — X red, Y green, Z blue, the orientation marker's own convention. Response style starts at the node and points outward, label at the tip; incoming (forces) ends on the node instead, label at the base. All arrows share one length: 12% of the geometry's extent, shrunk to the closest node spacing so dense sets never overlap. DOFs at nodes the geometry does not have are skipped. Returns how many were drawn.

Source code in src/visualdynamics/viz/geometry.py
def add_dof_arrows(plotter: Any, geometry: Geometry, dofs: Sequence[str],
                   unit_system: UnitSystem | None = None,
                   incoming: bool = False,
                   name: str | None = None) -> int:
    """Labeled arrows marking DOFs on the geometry.

    One arrow per DOF (built into one mesh per color, so ten thousand
    cost what ten do), colored by the axis it points along — X red,
    Y green, Z blue, the orientation marker's own convention. Response
    style starts at the node and points outward, label at the tip;
    `incoming` (forces) ends on the node instead, label at the base.
    All arrows share one length: 12% of the geometry's extent, shrunk
    to the closest node spacing so dense sets never overlap. DOFs at
    nodes the geometry does not have are skipped. Returns how many
    were drawn.
    """
    import pyvista as pv

    from ..core.data import parse_dof

    points, _unit = display_points(geometry, unit_system)
    points = np.asarray(points, dtype=float)
    if not len(points):
        return 0
    rows = {int(node): i for i, node in enumerate(geometry.node_id)}
    center = points.mean(axis=0)
    extent = float(np.sqrt(((points - center) ** 2)
                           .sum(axis=1).max())) or 1.0
    entries = []
    for dof in dofs:
        node, direction = parse_dof(dof)
        vector = DOF_DIRECTIONS.get(str(direction).upper())
        axis = dof_axis(direction)
        if node is None or vector is None or axis is None \
                or int(node) not in rows:
            continue
        entries.append((points[rows[int(node)]],
                        np.asarray(vector, dtype=float), axis, dof))
    if not entries:
        return 0
    length = dof_arrow_length([entry[0] for entry in entries], extent)
    label_spots = {0: [], 1: [], 2: []}
    starts = {0: [], 1: [], 2: []}
    vectors = {0: [], 1: [], 2: []}
    for position, vector, axis, dof in entries:
        start = position - vector * length if incoming else position
        starts[axis].append(start)
        vectors[axis].append(vector)
        spot = (start - vector * length * 0.25 if incoming
                else position + vector * length * 1.25)
        label_spots[axis].append((spot, dof))
    # One mesh per axis color, every arrow of that color glyphed into
    # it, added without a render. An actor per arrow — each add a
    # synchronous render of the whole scene in the app — made a bare
    # geometry's arrows at every node quadratic, and a model of a few
    # thousand nodes hung the window for minutes (Brandon, 2026-09-27).
    for axis in (0, 1, 2):
        if not starts[axis]:
            continue
        base = pv.PolyData(np.asarray(starts[axis], dtype=float))
        base['direction'] = np.asarray(vectors[axis], dtype=float)
        plotter.add_mesh(
            base.glyph(orient='direction', scale=False, factor=length,
                       geom=pv.Arrow()),
            color=AXIS_COLORS[axis], render=False,
            name=None if name is None else f'{name}-arrows{axis}')
    # labels only while they can be read: past a few dozen arrows the
    # names overprint into noise, and the arrows alone say where
    if len(entries) <= DOF_LABEL_LIMIT:
        for axis, spots in label_spots.items():
            if not spots:
                continue
            plotter.add_point_labels(
                np.asarray([spot for spot, _dof in spots]),
                [dof for _spot, dof in spots],
                font_size=12, always_visible=True,
                text_color=AXIS_COLORS[axis], shape=None,
                fill_shape=False, show_points=False, render=False,
                name=None if name is None else f'{name}-labels{axis}')
    return len(entries)

add_coordinate_system

add_coordinate_system(plotter: Any, origin: ArrayLike, matrix: ArrayLike, cs_type: int, length: float, text_color: str = '#000000', label: str | None = None, name: str | None = None) -> None

One coordinate system: three directions, named, with angles curved.

label (the system's id) is written at the origin. Direction names come from the type — X/Y/Z, R/theta/Z, or R/theta/phi — and each is written at the tip of its own arrow, so a cylindrical system is tellable from a cartesian one at a glance rather than by consulting the table.

Given a name, every actor is named from it, so drawing again replaces the drawing instead of piling another one on top — which is what lets a frame be turned live without rebuilding the scene around it.

Source code in src/visualdynamics/viz/geometry.py
def add_coordinate_system(plotter: Any, origin: ArrayLike, matrix: ArrayLike,
                          cs_type: int, length: float,
                          text_color: str = '#000000',
                          label: str | None = None,
                          name: str | None = None) -> None:
    """One coordinate system: three directions, named, with angles curved.

    `label` (the system's id) is written at the origin. Direction names come
    from the type — X/Y/Z, R/theta/Z, or R/theta/phi — and each is written
    at the tip of its own arrow, so a cylindrical system is tellable from a
    cartesian one at a glance rather than by consulting the table.

    Given a `name`, every actor is named from it, so drawing again replaces
    the drawing instead of piling another one on top — which is what lets a
    frame be turned live without rebuilding the scene around it.
    """
    import pyvista as pv

    names = CS_AXIS_LABELS.get(int(cs_type), CS_AXIS_LABELS[0])
    arcs = CS_ARCS.get(int(cs_type), {})
    tips, tip_names = [], []

    def actor_name(part: str) -> str | None:
        return None if name is None else f'{name}-{part}'

    for axis, (direction, axis_label, color) in enumerate(
            zip(matrix[:3], names, AXIS_COLORS)):
        if axis in arcs:
            start, about = (matrix[i] for i in arcs[axis])
            points = _arc_points(origin, start, about, length * ARC_RADIUS)
            # the head takes the last stretch of the arc, so a curved
            # direction reaches as far as a straight one rather than past it
            head = ARROW_HEAD_LENGTH * length
            walked = np.cumsum(
                np.linalg.norm(np.diff(points[::-1], axis=0), axis=1))
            base = len(points) - 1 - int(np.searchsorted(walked, head)) - 1
            base = min(max(base, 0), len(points) - 2)
            plotter.add_mesh(
                pv.lines_from_points(points[:base + 1]).tube(
                    radius=ARROW_SHAFT_RADIUS * length, n_sides=16),
                color=color, name=actor_name(f'arc{axis}'))
            heading = points[-1] - points[base]
            plotter.add_mesh(
                pv.Cone(center=(points[base] + points[-1]) / 2,
                        direction=heading,
                        height=float(np.linalg.norm(heading)),
                        radius=ARROW_HEAD_RADIUS * length, resolution=20),
                color=color, name=actor_name(f'head{axis}'))
            heading = heading / np.linalg.norm(heading)
            tips.append(points[-1] + heading * length * 0.45)
        else:
            arrow = pv.Arrow(start=origin, direction=direction,
                             scale=length)
            plotter.add_mesh(arrow, color=color,
                             name=actor_name(f'axis{axis}'))
            tips.append(origin + direction * length * 1.2)
        tip_names.append(axis_label)

    if label is None:
        # not singled out: the shape of the arrows still says which type it
        # is, without writing over the model
        return
    plotter.add_point_labels(
        np.asarray(tips), tip_names, font_size=13, always_visible=True,
        text_color=text_color, shape=None, fill_shape=False,
        show_points=False, name=actor_name('names'))
    plotter.add_point_labels(
        origin[np.newaxis], [str(label)], font_size=14, always_visible=True,
        text_color=text_color, shape=None, fill_shape=False,
        show_points=False, name=actor_name('id'))

shared_scalars

shared_scalars(count: int, name: str) -> tuple[ndarray, Any]

A VTK point-data array every mesh can share, and a numpy view of it.

The color twin of shared_points: one array, written once per frame, recolors every mesh in the scene at once.

Source code in src/visualdynamics/viz/geometry.py
def shared_scalars(count: int, name: str) -> tuple[np.ndarray, Any]:
    """A VTK point-data array every mesh can share, and a numpy view of it.

    The color twin of `shared_points`: one array, written once per frame,
    recolors every mesh in the scene at once.
    """
    import pyvista as pv
    from vtkmodules.util.numpy_support import vtk_to_numpy

    array = pv.convert_array(np.zeros(count, dtype=np.float64))
    array.SetName(name)
    return vtk_to_numpy(array), array

label_choices

label_choices(labels: Sequence[str] | Mapping[str, Sequence[int] | None] | None) -> dict[str, Sequence[int] | None]

labels in one shape: {kind: the ids to caption, or None}.

Both spellings read naturally where they are used. A script says labels=['nodes'] and means the nodes that are drawn; the window, which knows exactly what the user picked, says labels={'groups': [3]}. None means "whatever this kind draws", which is the right answer for both.

Source code in src/visualdynamics/viz/geometry.py
def label_choices(labels: Sequence[str] | Mapping[str, Sequence[int] | None]
                  | None) -> dict[str, Sequence[int] | None]:
    """`labels` in one shape: {kind: the ids to caption, or None}.

    Both spellings read naturally where they are used. A script says
    `labels=['nodes']` and means *the nodes that are drawn*; the window,
    which knows exactly what the user picked, says
    `labels={'groups': [3]}`. None means "whatever this kind draws",
    which is the right answer for both.
    """
    if labels is None:
        return {}
    if isinstance(labels, Mapping):
        return dict(labels)
    return {str(kind): None for kind in labels}

labels_fit

labels_fit(geometry: Geometry, kind: str, chosen: Sequence[int] | None = None) -> bool

Would captioning this kind stay under ENTITY_LABEL_LIMIT?

Asked by the window before it turns labels on, so a selection too large to caption says so in the status bar rather than drawing nothing and leaving the user to wonder.

Source code in src/visualdynamics/viz/geometry.py
def labels_fit(geometry: Geometry, kind: str,
               chosen: Sequence[int] | None = None) -> bool:
    """Would captioning this kind stay under `ENTITY_LABEL_LIMIT`?

    Asked by the window before it turns labels on, so a selection too
    large to caption says so in the status bar rather than drawing
    nothing and leaving the user to wonder.
    """
    if chosen is not None:
        return len(chosen) <= ENTITY_LABEL_LIMIT
    counts = {'nodes': geometry.num_nodes,
              'coordinate_systems': len(geometry.cs_id),
              'elements': len(geometry.elem_conn),
              'groups': len(geometry.group_id)}
    return counts.get(kind, 0) <= ENTITY_LABEL_LIMIT

label_spots

label_spots(geometry: Geometry, points: ndarray, kind: str, chosen: Sequence[int] | None = None) -> tuple[ndarray, list[str]]

(positions, texts) captioning one kind of entity with its id.

points is the geometry's nodes as drawn, so a caption lands in the same units and the same frame as the thing it names. Everything but a node is captioned at the centroid of its own nodes: the middle of an element's corners, of an element group's elements. That is where a reader looks for the name of a shape, and it keeps the number off the vertices, which are already carrying node ids whenever both are shown.

chosen is the ids (nodes, coordinate systems, element groups) or indices (elements) to caption; None captions every one of the kind. Returns nothing past ENTITY_LABEL_LIMIT; see labels_fit.

Source code in src/visualdynamics/viz/geometry.py
def label_spots(geometry: Geometry, points: np.ndarray, kind: str,
                chosen: Sequence[int] | None = None
                ) -> tuple[np.ndarray, list[str]]:
    """(positions, texts) captioning one kind of entity with its id.

    `points` is the geometry's nodes as drawn, so a caption lands in the
    same units and the same frame as the thing it names. Everything but
    a node is captioned at the **centroid of its own nodes**: the middle
    of an element's corners, of an element group's
    elements. That is where a reader looks for the name of a shape, and
    it keeps the number off the vertices, which are already carrying
    node ids whenever both are shown.

    `chosen` is the ids (nodes, coordinate systems, element groups) or indices
    (elements) to caption; None captions every one of the
    kind. Returns nothing past `ENTITY_LABEL_LIMIT`; see `labels_fit`.
    """
    if not labels_fit(geometry, kind, chosen):
        return np.empty((0, 3)), []
    row_of = {int(node): row for row, node in enumerate(geometry.node_id)}

    def center(node_ids: Sequence[int]) -> np.ndarray | None:
        rows = [row_of[int(n)] for n in node_ids if int(n) in row_of]
        return points[rows].mean(axis=0) if rows else None

    spots: list[np.ndarray] = []
    texts: list[str] = []

    if kind == 'nodes':
        mask = (np.isin(geometry.node_id, list(chosen)) if chosen is not None
                else np.ones(geometry.num_nodes, bool))
        return points[mask], [str(int(i)) for i in geometry.node_id[mask]]

    if kind == 'elements':
        wanted = (list(chosen) if chosen is not None
                  else range(len(geometry.elem_conn)))
        for i in wanted:
            spot = center(geometry.elem_conn[int(i)])
            if spot is not None:
                spots.append(spot)
                texts.append(str(int(geometry.elem_id[int(i)])))

    elif kind == 'groups':
        # an element group holds no coordinates of its own: it is a label on
        # elements, so it is captioned in the middle of the elements
        # that carry its id
        wanted = ({int(b) for b in chosen} if chosen is not None
                  else {int(b) for b in geometry.group_id})
        groups = np.asarray(geometry.elem_group, dtype=np.int64)
        for row, group in enumerate(geometry.group_id):
            if int(group) not in wanted:
                continue
            members = np.flatnonzero(groups == int(group))
            nodes = [n for i in members for n in geometry.elem_conn[int(i)]]
            spot = center(nodes)
            if spot is not None:
                spots.append(spot)
                name = str(geometry.group_name[row]).strip()
                texts.append(name or str(int(group)))

    return (np.asarray(spots) if spots else np.empty((0, 3))), texts

add_geometry

add_geometry(plotter: Any, geometry: Geometry, unit_system: UnitSystem | None = None, node_size: float = 8.0, line_width: float = 2.0, show_edges: bool = True, opacity: float = 1.0, labels: Sequence[str] | Mapping[str, Sequence[int] | None] | None = None, components: Sequence[str] | None = None, color_override: Any = None, text_color: str = '#000000', entities: Mapping[str, Sequence[int]] | None = None, points_source: Any = None, meshes: list[Any] | None = None, scalars: Any = None, clim: tuple[float, float] | None = None) -> str

Add one geometry's meshes to an existing plotter.

color_override paints the whole geometry one color, which is how several geometries overlaid in one scene stay tellable apart. entities restricts drawing to specific items, as a dict with any of 'nodes' (node ids), 'coordinate_systems' (ids) and 'elements' (indices) — that is how a single node or element picked in the tree gets highlighted.

labels names the kinds to caption with their ids — any of 'nodes', 'coordinate_systems', 'elements', 'groups'. Captions follow entities when it restricts the drawing, so labeling a picked element names that one and not all of them, and a kind with more than ENTITY_LABEL_LIMIT of them is left uncaptioned (see labels_fit).

scalars is a VTK point-data array shared by every mesh, coloring the whole geometry by value instead of by the geometry's own color indices; clim fixes what the ends of the color map mean. One array serves all the meshes, so a frame writes it once. Returns the axis unit text.

Source code in src/visualdynamics/viz/geometry.py
def add_geometry(plotter: Any, geometry: Geometry,
                 unit_system: UnitSystem | None = None,
                 node_size: float = 8.0, line_width: float = 2.0,
                 show_edges: bool = True, opacity: float = 1.0,
                 labels: Sequence[str] |
                 Mapping[str, Sequence[int] | None] | None = None,
                 components: Sequence[str] | None = None,
                 color_override: Any = None, text_color: str = '#000000',
                 entities: Mapping[str, Sequence[int]] | None = None,
                 points_source: Any = None,
                 meshes: list[Any] | None = None, scalars: Any = None,
                 clim: tuple[float, float] | None = None) -> str:
    """Add one geometry's meshes to an existing plotter.

    `color_override` paints the whole geometry one color, which is how
    several geometries overlaid in one scene stay tellable apart.
    `entities` restricts drawing to specific items, as a dict with any of
    'nodes' (node ids), 'coordinate_systems' (ids)
    and 'elements' (indices) — that is how a single node or element picked
    in the tree gets highlighted.

    `labels` names the kinds to caption with their ids — any of 'nodes',
    'coordinate_systems', 'elements', 'groups'. Captions
    follow `entities` when it restricts the drawing, so labeling a
    picked element names that one and not all of them, and a kind with
    more than `ENTITY_LABEL_LIMIT` of them is left uncaptioned (see
    `labels_fit`).

    `scalars` is a VTK point-data array shared by every mesh, coloring the
    whole geometry by value instead of by the geometry's own color indices;
    `clim` fixes what the ends of the color map mean. One array serves all
    the meshes, so a frame writes it once. Returns the axis unit text.
    """
    import pyvista as pv

    us = unit_system or DEFAULT_SYSTEM
    points, axis_unit = display_points(geometry, us)
    if points_source is None:
        points_source, _ = shared_points(points)

    def new_mesh() -> Any:
        """An empty mesh sharing the scene's points, and their colors."""
        mesh = pv.PolyData()
        mesh.SetPoints(points_source)
        if scalars is not None:
            mesh.GetPointData().SetScalars(scalars)
        if meshes is not None:
            meshes.append(mesh)
        return mesh

    def paint(index: int) -> Any:
        return color_override if color_override else color_rgb(index)

    def painted(index: int) -> dict[str, Any]:
        """How to color one mesh: by value, or by its color index."""
        if scalars is None:
            return {'color': paint(index)}
        return {'scalars': scalars.GetName(), 'cmap': COLORMAP,
                'clim': clim, 'show_scalar_bar': False}

    picked = entities or {}
    # a caption follows what is drawn: told nothing more specific, a
    # kind is captioned over exactly the entities the pick restricted
    # it to, so labeling a picked element names that one alone
    wanted_labels = {kind: (picked.get(kind) if chosen is None else chosen)
                     for kind, chosen in label_choices(labels).items()}
    if components:
        draw = set(components)
    elif picked:
        draw = {kind for kind, values in picked.items() if values}
    else:
        draw = {'nodes', 'elements'}
    id_to_row = {int(i): r for r, i in enumerate(geometry.node_id)}

    def rows(node_ids: Sequence[int]) -> list[int]:
        return [id_to_row[int(i)] for i in node_ids]

    # Nodes, grouped by color
    wanted_nodes = picked.get('nodes')
    node_mask = (np.isin(geometry.node_id, list(wanted_nodes))
                 if wanted_nodes else np.ones(geometry.num_nodes, bool))
    for color in (np.unique(geometry.node_color[node_mask])
                  if 'nodes' in draw and node_mask.any() else []):
        mask = (geometry.node_color == color) & node_mask
        indices = np.flatnonzero(mask)
        mesh = new_mesh()
        mesh.verts = np.column_stack(
            [np.ones(len(indices), dtype=np.int64), indices]).ravel()
        plotter.add_mesh(mesh, **painted(color), point_size=node_size,
                         render_points_as_spheres=True, opacity=opacity)

    # Elements, split by render class and grouped by color (a drawn
    # line is an element group of two-node line elements since 2026-09-30, and
    # draws as its line elements do: direct color per group)
    faces, lines, cell_points = {}, {}, {}
    wanted_elements = picked.get('elements')
    element_indices = (list(wanted_elements) if wanted_elements is not None
                       else range(len(geometry.elem_conn)))
    elements = ([(geometry.elem_type[i], geometry.elem_color[i],
                  geometry.elem_conn[i]) for i in element_indices]
                if 'elements' in draw else [])
    skins: dict[int, dict[tuple[int, ...], list[int]]] = {}
    for code, color, conn in elements:
        _name, _nnodes, render = ELEMENT_TYPES[int(code)]
        if render == 'face':
            faces.setdefault(int(color), []).append(
                rows(conn[:face_corners(int(code))]))
        elif render == 'volume':
            # a solid draws as its skin: the faces of its cells that no
            # other cell of the color shares (2026-09-30; a polyline
            # through every corner drew the mesh's insides as a tangle)
            skin = skins.setdefault(int(color), {})
            for face in solid_faces(len(conn)):
                corners = rows([conn[i] for i in face])
                key = tuple(sorted(corners))
                if key in skin:
                    del skin[key]
                else:
                    skin[key] = corners
        elif render == 'line':
            lines.setdefault(int(color), []).append(rows(conn[:2]))
        elif render == 'point':
            cell_points.setdefault(int(color), []).extend(rows(conn))
    for color, skin in skins.items():
        faces.setdefault(color, []).extend(skin.values())
    for color, polys in faces.items():
        mesh = new_mesh()
        mesh.faces = _cells(polys)
        plotter.add_mesh(mesh, **painted(color), opacity=opacity,
                         specular=0.3, specular_power=25,
                         show_edges=show_edges)
    for color, polylines in lines.items():
        mesh = new_mesh()
        mesh.lines = _cells(polylines)
        plotter.add_mesh(mesh, **painted(color), line_width=line_width,
                         opacity=opacity)
    for color, rows_ in cell_points.items():
        mesh = new_mesh()
        indices = np.asarray(rows_, dtype=np.int64)
        mesh.verts = np.column_stack(
            [np.ones(len(indices), dtype=np.int64), indices]).ravel()
        plotter.add_mesh(mesh, **painted(color), point_size=node_size * 1.5,
                         render_points_as_spheres=True, opacity=opacity)

    if 'coordinate_systems' in draw:
        wanted_cs = picked.get('coordinate_systems')
        span = np.ptp(points, axis=0).max() if len(points) > 1 else 1.0
        length = 0.12 * (span or 1.0)
        for i, cs_id in enumerate(geometry.cs_id):
            if wanted_cs and int(cs_id) not in {int(c) for c in wanted_cs}:
                continue
            matrix = geometry.cs_matrix[i]
            origin = _display_length(matrix[3], geometry, us)
            # axis colors survive a color override — direction identity is
            # the whole point of drawing a triad. Names and the id appear
            # for a system singled out, where they are worth the clutter.
            named = 'coordinate_systems' in wanted_labels and labels_fit(
                geometry, 'coordinate_systems',
                wanted_labels['coordinate_systems'])
            add_coordinate_system(
                plotter, origin, matrix, geometry.cs_type[i], length,
                text_color=text_color,
                label=int(cs_id) if wanted_cs or named else None)

    for kind, chosen in wanted_labels.items():
        if kind == 'coordinate_systems':
            continue                  # the triad writes its own id, above
        spots, texts = label_spots(geometry, points, kind, chosen)
        if not len(spots):
            continue
        plotter.add_point_labels(spots, texts, font_size=14,
                                 always_visible=True, text_color=text_color,
                                 shape=None, fill_shape=False,
                                 show_points=False)
    return axis_unit

place_view

place_view(plotter: Any, view: Any = None, render: bool = True) -> None

Turn the camera to view — a geometry's View, or the default isometric when None — and fit what the plotter holds.

The one way a 3-D view of a geometry is opened: the app when it first shows one and on Reset View, the script windows, the animations and every exported figure, so a geometry opens the same way wherever it is drawn. The report's scenes open on the same view through View.basis.

Source code in src/visualdynamics/viz/geometry.py
def place_view(plotter: Any, view: Any = None, render: bool = True) -> None:
    """Turn the camera to `view` — a geometry's `View`, or the default
    isometric when None — and fit what the plotter holds.

    The one way a 3-D view of a geometry is opened: the app when it first
    shows one and on Reset View, the script windows, the animations and
    every exported figure, so a geometry opens the same way wherever it
    is drawn. The report's scenes open on the same view through
    `View.basis`.
    """
    from ..core.geometry import DEFAULT_VIEW

    view = view or DEFAULT_VIEW
    plotter.view_vector(view.eye, viewup=view.up, render=False)
    plotter.reset_camera(render=render)

plot_geometry

plot_geometry(geometry: Geometry, unit_system: UnitSystem | None = None, screenshot: str | None = None, theme: Any = None, show: bool = True, size_in: tuple[float, float] | None = None, dpi: float | None = None, **kwargs: Any) -> Any

Show the geometry interactively, or render to screenshot headlessly.

Shown, it comes up in the app's own 3-D pane — the labeled axes and the orientation triad are toggles on the bar over it, exactly as in the window. Returns the pane (its .plotter is the PyVista one), or the image array when rendering to a file. For print, size_in and dpi render the figure at its printed size (print_plotter), every label and line at the size the screen shows it.

Source code in src/visualdynamics/viz/geometry.py
def plot_geometry(geometry: Geometry, unit_system: UnitSystem | None = None,
                  screenshot: str | None = None, theme: Any = None,
                  show: bool = True, size_in: tuple[float, float] | None = None,
                  dpi: float | None = None, **kwargs: Any) -> Any:
    """Show the geometry interactively, or render to `screenshot` headlessly.

    Shown, it comes up in the app's own 3-D pane — the labeled axes and
    the orientation triad are toggles on the bar over it, exactly as in
    the window. Returns the pane (its `.plotter` is the PyVista one), or
    the image array when rendering to a file. For print, `size_in` and
    `dpi` render the figure at its printed size (`print_plotter`), every
    label and line at the size the screen shows it.
    """
    if screenshot is not None:
        if dpi is not None:
            plotter, scale = print_plotter(size_in or (6.0, 4.5), dpi)
            geometry_scene(geometry, unit_system=unit_system, theme=theme,
                           plotter=plotter, scale=scale, **kwargs)
        else:
            plotter = geometry_scene(geometry, unit_system=unit_system,
                                     theme=theme, off_screen=True, **kwargs)
        place_view(plotter, geometry.opening_view, render=False)
        img = plotter.screenshot(screenshot)
        plotter.close()
        return img
    from ..gui.windows import scene_window

    us = unit_system or DEFAULT_SYSTEM
    return scene_window(
        lambda plotter: geometry_scene(geometry, unit_system=us,
                                       plotter=plotter, theme=theme,
                                       **kwargs),
        theme=theme, axis_unit=axis_unit_text(geometry, us),
        title='Geometry', show=show, view=geometry.opening_view)

plot_dofs

plot_dofs(geometry: Geometry, source: Any, quantity: str, unit_system: UnitSystem | None = None, screenshot: str | None = None, theme: Any = None, show: bool = True, size_in: tuple[float, float] | None = None, dpi: float | None = None, **kwargs: Any) -> Any

The geometry with labeled arrows at every DOF source measures as quantity — the GUI's DOF-arrows toggle, from a script.

Forces end on their node with the label at the base, responses leave it with the label at the tip, exactly as the desktop draws them. source is a data object (or several). For print, size_in and dpi render at the printed size, as plot_geometry does.

Source code in src/visualdynamics/viz/geometry.py
def plot_dofs(geometry: Geometry, source: Any, quantity: str,
              unit_system: UnitSystem | None = None,
              screenshot: str | None = None, theme: Any = None,
              show: bool = True, size_in: tuple[float, float] | None = None,
              dpi: float | None = None, **kwargs: Any) -> Any:
    """The geometry with labeled arrows at every DOF `source` measures
    as `quantity` — the GUI's DOF-arrows toggle, from a script.

    Forces end on their node with the label at the base, responses
    leave it with the label at the tip, exactly as the desktop draws
    them. `source` is a data object (or several). For print, `size_in`
    and `dpi` render at the printed size, as `plot_geometry` does.
    """
    from ..core.report import EXCITATION_QUANTITIES, series_quantity_dofs

    us = unit_system or DEFAULT_SYSTEM
    series = ([('', source, None)] if not isinstance(source, (list, tuple))
              else [('', obj, None) for obj in source])
    dofs = series_quantity_dofs(series, quantity)
    def draw(plotter: Any, scale: float = 1.0) -> None:
        geometry_scene(geometry, unit_system=us, plotter=plotter,
                       theme=theme, scale=scale, **kwargs)
        add_dof_arrows(plotter, geometry, dofs, unit_system=us,
                       incoming=quantity in EXCITATION_QUANTITIES)

    if screenshot is not None:
        import pyvista as pv
        if dpi is not None:
            plotter, scale = print_plotter(size_in or (6.0, 4.5), dpi)
            draw(plotter, scale)
        else:
            plotter = pv.Plotter(off_screen=True)
            draw(plotter)
        place_view(plotter, geometry.opening_view, render=False)
        image = plotter.screenshot(str(screenshot))
        plotter.close()
        return image
    from ..gui.windows import scene_window

    return scene_window(draw, theme=theme,
                        axis_unit=axis_unit_text(geometry, us),
                        title=f'DOFs — {quantity}', show=show,
                        view=geometry.opening_view)