Skip to content

visualdynamics.core.geometry

geometry

Test and model geometry: nodes, coordinate systems, elements and element groups.

Node coordinates are stored in the global cartesian frame, in meters once their unit is known. A geometry read from a file that does not declare units (an sdynpy .npz, an exodus file, a UNV file without dataset 164) keeps the file's coordinates as they are, and length_unit is None until define_units() is called. Element types are identified by the FE descriptor codes of UFF dataset 2412.

Classes:

Name Description
View

The direction a geometry is viewed from in 3-D: where the eye is,

Geometry

Nodes, coordinate systems, elements and element groups.

Functions:

Name Description
element_family

Which family a 2412 element code belongs to.

placed

A point given in a coordinate system, converted to global cartesian.

written_in

The inverse of placed: a global cartesian point in the

to_global

Node coordinates given in each node's own placement system,

to_local

The inverse of to_global, for a writer that gives each node in

reconcile_type

The descriptor that matches the number of nodes an element has,

face_corners

How many corners a face element is drawn with: three for a

solid_faces

The faces of a solid cell with this many nodes, each face as

deck_to_si

A finite element deck's element group properties converted to SI

implausible_materials

A sentence for each material whose modulus or density, in SI, is

Classes

View

View(eye: ArrayLike = (1.0, 1.0, 1.0), up: ArrayLike = (0.0, 0.0, 1.0))

The direction a geometry is viewed from in 3-D: where the eye is, seen from the model's center, and which way is up on screen.

A view is only a direction. Every view is fitted to what it shows, so there is no distance or zoom to store. Set on a geometry, it is the view the app opens the geometry and everything drawn on it in, the view Reset View returns to, and the view of the report's scenes and exported figures. A model built with Y up then opens upright without its nodes being rotated. Raises ValueError for a zero or non-finite direction, or an up along the line of sight.

Parameters:

Name Type Description Default
eye sequence of 3 floats

The direction from the model toward the eye, in the global frame; any length.

(1, 1, 1)
up sequence of 3 floats

Which way is up on screen. Only its part across the line of sight counts, so it need not be exactly perpendicular to eye.

(0, 0, 1)

Examples:

A model built with Y up, seen from the front, above and to the right:

>>> geometry.view = View(eye=(1, 1, -1), up=(0, 1, 0))

Methods:

Name Description
basis

(right, up) on screen, as unit vectors in the global frame.

Source code in src/visualdynamics/core/geometry.py
def __init__(self, eye: ArrayLike = (1.0, 1.0, 1.0),
             up: ArrayLike = (0.0, 0.0, 1.0)) -> None:
    # kept as written, so a view reads back the way it was stated;
    # every use goes through `_unit`
    self.eye: tuple[float, ...] = tuple(
        float(v) for v in np.asarray(eye, dtype=float).reshape(3))
    self.up: tuple[float, ...] = tuple(
        float(v) for v in np.asarray(up, dtype=float).reshape(3))
    eye_length = float(np.linalg.norm(self.eye))
    if not np.isfinite(eye_length) or eye_length == 0.0:
        raise ValueError('a view needs a direction to look from')
    up_length = float(np.linalg.norm(self.up))
    if not np.isfinite(up_length) or up_length == 0.0:
        raise ValueError('a view needs a direction for up')
    # the part of `up` across the line of sight is what the screen
    # shows as up; along it, there is none to show
    if np.linalg.norm(np.cross(self.eye, self.up)) < \
            1e-9 * eye_length * up_length:
        raise ValueError('up cannot lie along the line of sight')
Methods:
basis
basis() -> tuple[tuple[float, ...], tuple[float, ...]]

(right, up) on screen, as unit vectors in the global frame.

Right is the line of sight crossed with up, and up is up made perpendicular to the line of sight.

Source code in src/visualdynamics/core/geometry.py
def basis(self) -> tuple[tuple[float, ...], tuple[float, ...]]:
    """``(right, up)`` on screen, as unit vectors in the global frame.

    Right is the line of sight crossed with up, and up is `up` made
    perpendicular to the line of sight.
    """
    eye, up = self._unit()
    return (tuple(float(v) for v in np.cross(up, eye)),
            tuple(float(v) for v in up))

Geometry

Geometry(node_id: Ids, node_xyz: ArrayLike, node_def_cs: ArrayLike | None = None, node_disp_cs: ArrayLike | None = None, node_color: ArrayLike | None = None, cs_id: Ids | None = None, cs_name: Sequence[str] | None = None, cs_type: ArrayLike | None = None, cs_matrix: ArrayLike | None = None, elem_id: Ids | None = None, elem_type: ArrayLike | None = None, elem_color: ArrayLike | None = None, elem_conn: Sequence[ArrayLike] | None = None, elem_group: Ids | None = None, group_id: Ids | None = None, group_name: Sequence[str] | None = None, length_unit: str | None = None, group_properties: Mapping[int, Any] | None = None, from_deck: bool = False, mass_unit: str | None = None)

Nodes, coordinate systems, elements and element groups.

The parameters are array-likes with the names of the attributes below; elem_conn holds one integer array of node ids per element. Coordinates are in the global cartesian frame, in meters once length_unit is declared. Without coordinate systems the geometry gets one, the global cartesian system with id 1. An element whose descriptor does not match its node count is corrected (reconcile_type), and an element group holding more than one element family is split (split_groups_by_family). Raises ValueError when the result is not consistent (validate).

The arrays below hold the data; nodes, coordinate_systems, elements and groups are views of them, which is how the project tree lists a geometry and usually the easiest way for a script to use one. A view is not a copy, so writing through a row writes into the geometry.

There are no separate tracelines. A line drawn through nodes is an element group of two-node line elements with no properties: add_beams creates one, and drawn_lines returns them for the file formats that store tracelines separately. The same element group becomes part of the structure once it is given a section.

Attributes: node_id: Every node's id. Unique, because elements and placement refer to nodes by id. node_xyz: (nodes, 3) coordinates, in meters once length_unit is declared and the file's numbers before that. node_def_cs: The coordinate system each node is placed in. node_disp_cs: The coordinate system each node is measured in, which is the system a shape's values at that node are in. node_color: Palette index per node. cs_id: Coordinate system ids. Unique, for the same reason as node ids. cs_name: A name per system, often empty. cs_type: 0 cartesian, 1 cylindrical, 2 spherical (CS_TYPES). cs_matrix: (systems, 4, 3): three direction rows, then the origin, so cs_matrix[i, 3] is the origin of system i. elem_id: Element ids. They are only labels; nothing refers to them. elem_type: UFF dataset 2412 descriptor code per element (ELEMENT_TYPES names them and says how each is drawn). elem_color: Palette index per element. elem_conn: One array of node ids per element. elem_group: The id of the element group each element belongs to. group_id: The declared element groups. Unique, since elements refer to them by id. group_name: A name per element group, such as 'wing' or 'arm front left'. This is where a mesh records that a region is a different part from its neighbor, and fem.Model.from_geometry reads a member's section from it. group_properties: {element group id: fem.GroupProperties}, the material and thickness or section of each element group that has them. length_unit: The unit of the coordinates, or None while it has not been declared, in which case they are the file's own numbers and nothing has been scaled. mass_properties: The reference point, mass and inertia for the rigid-body shapes (core.rigid.MassProperties), or None. view: The direction the geometry opens in 3-D (View), or None for DEFAULT_VIEW. solids: The solid shapes of the parts before they are meshed (core.solids). mates: Where element groups' surfaces are joined, by rigid links or springs made when a model is built (core.mates).

Methods:

Name Description
validate

Check that the geometry is consistent, and raise ValueError

node_index

Positions of the given node ids in the node arrays.

contains_nodes

Boolean array: which of node_ids this geometry defines.

missing_dofs

The DOF strings whose node this geometry does not define.

suggest_mass_properties

Mass properties with the centroid of the nodes as the reference

define_units

Declare the length unit of the coordinates, converting them to SI.

undefine_units

Remove the length unit and restore the original coordinates; returns self.

dof_direction

A DOF's direction in global coordinates, from the coordinate

add_node

Add a node at xyz and return its id.

add_nodes

Add nodes at xyz, numbered from one more than the highest id.

add_coordinate_system

Add a coordinate system and return its id.

place_coordinate_system

Rotate a coordinate system, move it, or both, as the 3-D

add_beams

Add a chain of two-node line elements through the given nodes,

mixed_groups

{element group id: families} for every element group that

split_groups_by_family

Split every element group that holds more than one element family.

attach_drawn_lines

Add drawn lines read from a format that stores them separately

is_drawn_line

Whether an element group is a drawn line.

drawn_lines

The drawn lines, in the form used by file formats that store

group_of

The name of the element group an element belongs to, or '' when

elements_in

The ids of the elements in an element group, given by id or name.

nodes_in

The ids of the nodes used by the elements of these element

add_group

Declare an element group and return its id.

add_element

Add an element and return its row index.

add_elements

Add many elements at once, numbered from one more than the

renumber_node

Give a node a new id, and update the elements that use it.

renumber_group

Give an element group a new id, and update its elements.

renumber_coordinate_system

Give a coordinate system a new id, and update the nodes that use it.

delete_nodes

Remove nodes, and the elements that use them.

coincident_nodes

{node: the node it coincides with} for every node within

duplicate_elements

{element id: the element it duplicates} for every solid

merge_duplicate_elements

Remove duplicate solid elements (duplicate_elements).

node_spacing

The mesh's finest spacing: the shortest nonzero distance between

coincident_spots

The places where a merge would make one node.

merge_folds

Why this merge would be refused, or None.

merge_collapses

The ids of the line elements this merge would remove.

merge_coincident_nodes

Merge nodes that are at one point into one node.

align_reach

The default reach of Align Meshes: half the mesh size of the

align_moves

The moves Align Meshes would make, each [node, onto]: a node

align_folds

Why these moves would be refused, or None.

align_meshes

Move the nodes of the coarser piece onto the nodes of the finer

delete_coordinate_systems

Remove coordinate systems, and move the nodes that used them to

delete_groups

Remove element groups with their elements, their properties, and

merge_refusal

Why these element groups cannot be merged, or None when they can.

merge_groups

Merge several element groups into the first one (Merge Element

delete_elements

Remove elements by id. Ids that are not present are ignored.

save

Write the geometry to a file of its own.

load

Read a geometry written by save.

plot

Draw the geometry's nodes and elements.

plot_dofs

Draw this geometry with a labeled arrow at every DOF where

plot_channels

Draw this geometry with a labeled arrow at each channel's DOF

Attributes:

Name Type Description
opening_view View

The view the geometry opens in 3-D: its own view, or

num_nodes int

How many nodes the geometry defines.

nodes EntityView

Every node: ids, xyz, colors, placement_systems and

coordinate_systems EntityView

Every coordinate system: ids, names, types and matrices

elements EntityView

Every element: ids, types, colors, groups and nodes

groups EntityView

Every element group: ids and names (an EntityView).

extent tuple[ndarray, ndarray]

(min_xyz, max_xyz) of the nodes, in meters once units are defined.

units_defined bool

Whether the length unit of the coordinates has been declared.

Source code in src/visualdynamics/core/geometry.py
def __init__(self, node_id: Ids, node_xyz: ArrayLike,
             node_def_cs: ArrayLike | None = None,
             node_disp_cs: ArrayLike | None = None,
             node_color: ArrayLike | None = None,
             cs_id: Ids | None = None,
             cs_name: Sequence[str] | None = None,
             cs_type: ArrayLike | None = None,
             cs_matrix: ArrayLike | None = None,
             elem_id: Ids | None = None,
             elem_type: ArrayLike | None = None,
             elem_color: ArrayLike | None = None,
             elem_conn: Sequence[ArrayLike] | None = None,
             elem_group: Ids | None = None,
             group_id: Ids | None = None,
             group_name: Sequence[str] | None = None,
             length_unit: str | None = None,
             group_properties: Mapping[int, Any] | None = None,
             from_deck: bool = False,
             mass_unit: str | None = None) -> None:
    # coordinates are taken as given; length_unit records what they are
    # in (None = undefined, values are the file's raw numbers)
    self.length_unit: str | None = length_unit
    # a finite element deck's properties are its own numbers, in the
    # consistent system its length and mass units make, until those
    # are declared (`define_units`); mass_unit records the mass unit
    # declared with the length
    self.from_deck: bool = bool(from_deck)
    self.mass_unit: str | None = mass_unit if from_deck else None
    n = len(node_id)
    self.node_id: IdArray = _ids(node_id, 'node ids', unique=True)
    self.node_xyz: NDArray[np.float64] = np.asarray(
        node_xyz, dtype=np.float64).reshape(n, 3)
    self.node_def_cs: IdArray = self._default(node_def_cs, n, 1)
    self.node_disp_cs: IdArray = self._default(node_disp_cs, n, 1)
    self.node_color: NDArray[np.int64] = self._default(node_color, n, 1)

    if cs_id is None:
        cs_id, cs_name, cs_type = [1], [''], [0]
        cs_matrix = np.vstack([np.eye(3), np.zeros(3)])[np.newaxis]
    c = len(cs_id)
    self.cs_id: IdArray = _ids(cs_id, 'coordinate system ids', unique=True)
    self.cs_name: list[str] = (list(cs_name) if cs_name is not None
                               else [''] * c)
    self.cs_type: NDArray[np.int64] = self._default(cs_type, c, 0)
    self.cs_matrix: NDArray[np.float64] = (
        np.asarray(cs_matrix, dtype=np.float64).reshape(c, 4, 3)
        if cs_matrix is not None
        else np.tile(np.vstack([np.eye(3), np.zeros(3)]), (c, 1, 1)))


    e = len(elem_conn) if elem_conn is not None else 0
    self.elem_id: IdArray = (
        _ids(elem_id, 'element ids') if elem_id is not None
        else self._default(None, e, None, arange=True))
    self.elem_type: NDArray[np.int64] = self._default(elem_type, e, 0)
    self.elem_color: NDArray[np.int64] = self._default(elem_color, e, 1)
    # Which element group each element belongs to, and what the element groups are
    # called. This is how a mesh says "these elements are the wing and
    # those are the tail": exodus calls them element blocks, and it is
    # the only place a file records that a region is made of something
    # different from its neighbor. Read without keeping it, a file of
    # two element groups comes back as one anonymous group on the way
    # out.
    self.elem_group: IdArray = (
        _ids(elem_group, 'element group ids') if elem_group is not None
        else self._default(None, e, 1))
    found = (np.unique(self.elem_group) if e
             else np.array([], dtype=np.int64))
    self.group_id: IdArray = (
        _ids(group_id, 'element group ids', unique=True)
        if group_id is not None else found)
    self.group_name: list[str] = (list(group_name) if group_name is not None
                                  else [''] * len(self.group_id))
    # What each element group is made of, when the geometry is a model in
    # waiting: {element group id: fem.GroupProperties} — a material and a
    # thickness for an element group of plates, a material and a section for
    # an element group of beams. This is how every finite element format
    # states a structure, one property set per element group of one
    # element type, and it is what `fem.Model.from_geometry` builds
    # from (Brandon, 2026-09-25). Held here rather than in the
    # model so it is saved with the geometry and comes back. The
    # values are the FE module's own classes; this module only
    # carries them.
    self.group_properties: dict[int, Any] = (
        {int(k): v for k, v in group_properties.items()}
        if group_properties else {})
    self.elem_conn: list[IdArray] = [
        np.asarray(c, dtype=np.int64) for c in (elem_conn or [])]
    # a source states both what an element is and which nodes it
    # joins, and the two can contradict each other — a four-node
    # element carrying a triangle's descriptor drew as a triangle,
    # a node short (Brandon, 2026-09-20). The nodes are the
    # element; `reconcile_type` mends the label. Here rather than
    # in each reader, so every source is read the same way.
    if len(self.elem_type) == len(self.elem_conn):
        self.elem_type = np.array(
            [reconcile_type(int(code), len(conn))
             for code, conn in zip(self.elem_type, self.elem_conn)],
            dtype=np.int64) if len(self.elem_conn) else self.elem_type
    #: the reference point, mass and inertia the rigid-body view
    #: sets, riding the geometry the way averaging rides a time
    #: history (`core.rigid`); None until set or adopted
    self.mass_properties: MassProperties | None = None
    #: how it opens in 3-D (`View`); None opens on `DEFAULT_VIEW`
    self.view: View | None = None
    #: the shapes its parts are made of before they are meshed — blocks,
    #: cylinders and booleans of them (`core.solids`, 2026-10-11); not
    #: elements, and read by nothing that solves or draws a mesh
    self.solids: list = []
    #: where element groups' surfaces are joined (`core.mates.MatedSet`):
    #: definitions the model builder makes links of, kept in step as
    #: nodes and element groups are renumbered, merged or deleted
    self.mates: list = []
    # one element group, one family (2026-09-30): a source that mixes them —
    # UNV 2412 and the sdynpy layout carry no element groups at all, so every
    # element arrives in one — is split on arrival rather than
    # refused, the same way a mislabeled type is mended above. Here
    # rather than in each reader, so every source is read the same way.
    self.split_groups_by_family()
    self.validate()
Attributes
opening_view property
opening_view: View

The view the geometry opens in 3-D: its own view, or DEFAULT_VIEW (from +X+Y+Z, Z up) when it has none.

num_nodes property
num_nodes: int

How many nodes the geometry defines.

nodes property
nodes: EntityView

Every node: ids, xyz, colors, placement_systems and displacement_systems (an EntityView).

coordinate_systems property
coordinate_systems: EntityView

Every coordinate system: ids, names, types and matrices (an EntityView).

elements property
elements: EntityView

Every element: ids, types, colors, groups and nodes (an EntityView).

groups property
groups: EntityView

Every element group: ids and names (an EntityView).

An element group does not store its elements. Which elements are in one is read from elem_group (elements_in), so moving an element to another element group is an edit to the element.

extent property
extent: tuple[ndarray, ndarray]

(min_xyz, max_xyz) of the nodes, in meters once units are defined.

units_defined property
units_defined: bool

Whether the length unit of the coordinates has been declared. False until define_units is called.

Methods:
validate
validate() -> None

Check that the geometry is consistent, and raise ValueError naming the first problem found.

Node, coordinate system and element group ids must be unique, every element's nodes must exist, every element must name an element group the geometry has, every element type must be in ELEMENT_TYPES, and each element group must hold one element family.

Source code in src/visualdynamics/core/geometry.py
def validate(self) -> None:
    """Check that the geometry is consistent, and raise ValueError
    naming the first problem found.

    Node, coordinate system and element group ids must be unique,
    every element's nodes must exist, every element must name an
    element group the geometry has, every element type must be in
    `ELEMENT_TYPES`, and each element group must hold one element
    family.
    """
    # An id that other data points at has to mean one thing:
    # connectivity names nodes by id, and a node names the systems it
    # is placed and measured in. Element ids are labels — nothing
    # refers to them — so they are not held to this.
    # the same helper the constructor uses, so a duplicate introduced
    # by an edit is refused in the same words as one handed in
    for label, values in (('node ids', self.node_id),
                          ('coordinate system ids', self.cs_id)):
        _ids(values, label, unique=True)
    known = set(self.node_id.tolist())
    for i, conn in enumerate(self.elem_conn):
        missing = set(conn.tolist()) - known
        if missing:
            raise ValueError(
                f"element {i} references unknown node ids {sorted(missing)}")
    if len(self.group_name) != len(self.group_id):
        raise ValueError(
            f'{len(self.group_name)} element group names for '
            f'{len(self.group_id)} element groups')
    _ids(self.group_id, 'element group ids', unique=True)
    stray = set(np.unique(self.elem_group).tolist()) - set(
        self.group_id.tolist())
    if stray:
        raise ValueError(
            'elements name element groups the geometry does not have: '
            + ', '.join(str(b) for b in sorted(stray)))
    for code in np.unique(self.elem_type) if len(self.elem_type) else []:
        if int(code) not in ELEMENT_TYPES:
            raise ValueError(f"Unknown element type code {int(code)}")
    # one element group, one family (2026-09-30): an element group's properties are
    # one kind, and the tree lists an element group under its family
    mixed = self.mixed_groups()
    if mixed:
        raise ValueError(
            'an element group holds one element family; '
            + ', '.join(f'element group {b} holds {" and ".join(fams)}'
                        for b, fams in mixed.items())
            + ' — split_groups_by_family() sorts them out')
node_index
node_index(node_ids: Ids) -> ndarray

Positions of the given node ids in the node arrays.

Every id must be one the geometry has (contains_nodes asks without raising).

Parameters:

Name Type Description Default
node_ids int or sequence of int

The identifiers, one or many.

required

Returns:

Type Description
ndarray

Each identifier's row in the node arrays.

Raises:

Type Description
KeyError

Naming the ids the geometry does not have.

Source code in src/visualdynamics/core/geometry.py
def node_index(self, node_ids: Ids) -> np.ndarray:
    """Positions of the given node ids in the node arrays.

    Every id must be one the geometry has (`contains_nodes` asks
    without raising).

    Parameters
    ----------
    node_ids : int or sequence of int
        The identifiers, one or many.

    Returns
    -------
    numpy.ndarray
        Each identifier's row in the node arrays.

    Raises
    ------
    KeyError
        Naming the ids the geometry does not have.
    """
    ids = np.asarray(node_ids)
    order = np.argsort(self.node_id)
    # searchsorted returns where a missing id *would* go: a
    # neighbor's row, or one past the end. Without the comparison
    # below a missing id came back as some other node's row.
    pos = np.searchsorted(self.node_id, ids, sorter=order)
    rows = order[np.minimum(pos, max(order.size - 1, 0))] if order.size else pos
    missing = np.atleast_1d(ids)[np.atleast_1d(
        self.node_id[rows] != ids if order.size else np.ones(ids.shape, bool))]
    if missing.size:
        shown = ', '.join(str(i) for i in missing[:5].tolist())
        raise KeyError(f'the geometry has no node {shown}')
    return rows
contains_nodes
contains_nodes(node_ids: Ids) -> ndarray

Boolean array: which of node_ids this geometry defines.

Parameters:

Name Type Description Default
node_ids int or sequence of int

The identifiers, one or many.

required

Returns:

Type Description
ndarray

A boolean per identifier: whether the geometry has it.

Source code in src/visualdynamics/core/geometry.py
def contains_nodes(self, node_ids: Ids) -> np.ndarray:
    """Boolean array: which of `node_ids` this geometry defines.

    Parameters
    ----------
    node_ids : int or sequence of int
        The identifiers, one or many.

    Returns
    -------
    numpy.ndarray
        A boolean per identifier: whether the geometry has it.
    """
    return np.isin(np.asarray(node_ids), self.node_id)
missing_dofs
missing_dofs(dofs: Sequence[str]) -> list[str]

The DOF strings whose node this geometry does not define.

Parameters:

Name Type Description Default
dofs sequence of str

The degrees of freedom to check, such as '101Z+'.

required

Returns:

Type Description
list of str

The DOFs whose node the geometry does not have, including any DOF that names no node. A data object with such DOFs is not compatible with this geometry.

Source code in src/visualdynamics/core/geometry.py
def missing_dofs(self, dofs: Sequence[str]) -> list[str]:
    """The DOF strings whose node this geometry does not define.

    Parameters
    ----------
    dofs : sequence of str
        The degrees of freedom to check, such as '101Z+'.

    Returns
    -------
    list of str
        The DOFs whose node the geometry does not have, including
        any DOF that names no node. A data object with such DOFs
        is not compatible with this geometry.
    """
    from .data import parse_dof

    nodes = [parse_dof(dof)[0] for dof in dofs]
    known = self.contains_nodes([-1 if n is None else n for n in nodes])
    return [dof for dof, present in zip(dofs, known) if not present]
suggest_mass_properties
suggest_mass_properties() -> MassProperties

Mass properties with the centroid of the nodes as the reference point and no mass, which give unit rigid-body shapes about the middle of the model.

The rigid-body panel opens with these values, and generate_rigid_body_modes uses them when nothing has been set. They are the settings the virtual point transformation needs most often. Raises ValueError when the geometry has no nodes.

Returns:

Type Description
MassProperties

The centroid, unscaled.

Source code in src/visualdynamics/core/geometry.py
def suggest_mass_properties(self) -> MassProperties:
    """Mass properties with the centroid of the nodes as the reference
    point and no mass, which give unit rigid-body shapes about the
    middle of the model.

    The rigid-body panel opens with these values, and
    `generate_rigid_body_modes` uses them when nothing has been set.
    They are the settings the virtual point transformation needs most
    often. Raises ValueError when the geometry has no nodes.

    Returns
    -------
    MassProperties
        The centroid, unscaled.
    """
    from .rigid import MassProperties

    if self.num_nodes == 0:
        raise ValueError('the geometry has no nodes to take the '
                         'centroid of')
    return MassProperties(tuple(self.node_xyz.mean(axis=0)))
define_units
define_units(length_unit: str, mass_unit: str | None = None) -> Geometry

Declare the length unit of the coordinates, converting them to SI.

The node coordinates, the coordinate system origins and the solids' lengths are converted. Declaring again reinterprets the original values instead of scaling twice, so a wrong unit can be corrected.

A geometry read from a finite element deck (from_deck) also holds the deck's property values, in the consistent system its length unit, a mass unit and seconds make: a thickness is a length, a modulus a force per area, a density a mass per volume, a point mass a mass, a spring a force per length (or, about an axis, a force times a length). Those are converted with the coordinates (deck_to_si). The mass unit is mass_unit, or the one a deck in that length is written with when it is omitted (DECK_MASS_UNITS: tonnes with millimeters, lbf·s²/in with inches). A material whose converted modulus or density is not a structural material's is warned about, since that is what a wrong mass unit looks like.

Parameters:

Name Type Description Default
length_unit str

The unit the coordinates are in, such as 'm' or 'in'.

required
mass_unit str

The mass unit of a deck's properties, such as 'tonne'.

None

Returns:

Type Description
Geometry

Self, converted to SI in place.

Source code in src/visualdynamics/core/geometry.py
def define_units(self, length_unit: str,
                 mass_unit: str | None = None) -> Geometry:
    """Declare the length unit of the coordinates, converting them to SI.

    The node coordinates, the coordinate system origins and the
    solids' lengths are converted. Declaring again reinterprets the
    original values instead of scaling twice, so a wrong unit can be
    corrected.

    A geometry read from a finite element deck (`from_deck`) also
    holds the deck's property values, in the consistent system its
    length unit, a mass unit and seconds make: a thickness is a
    length, a modulus a force per area, a density a mass per volume,
    a point mass a mass, a spring a force per length (or, about an
    axis, a force times a length). Those are converted with the
    coordinates (`deck_to_si`). The mass unit is `mass_unit`, or the
    one a deck in that length is written with when it is omitted
    (`DECK_MASS_UNITS`: tonnes with millimeters, lbf·s²/in with
    inches). A material whose converted modulus or density is not a
    structural material's is warned about, since that is what a
    wrong mass unit looks like.

    Parameters
    ----------
    length_unit : str
        The unit the coordinates are in, such as 'm' or 'in'.
    mass_unit : str, optional
        The mass unit of a deck's properties, such as 'tonne'.

    Returns
    -------
    Geometry
        Self, converted to SI in place.
    """
    if self.from_deck:
        mass_unit = mass_unit or DECK_MASS_UNITS.get(length_unit, 'kg')
        si_transform(mass_unit, 'mass')       # refuse before changing
        raw = (self.group_properties if self.length_unit is None
               else deck_to_si(self.group_properties, self.length_unit,
                               self.mass_unit, inverse=True))
        self.group_properties = deck_to_si(raw, length_unit, mass_unit)
        self.mass_unit = mass_unit
    elif mass_unit is not None:
        raise ValueError('only a geometry read from a finite element '
                         'deck carries a mass unit')
    scale, _ = si_transform(length_unit, 'length')
    raw = (self.node_xyz if self.length_unit is None
           else from_si(self.node_xyz, self.length_unit, 'length'))
    self.node_xyz = raw * scale
    origins = self.cs_matrix[:, 3, :]
    raw_origins = (origins if self.length_unit is None
                   else from_si(origins, self.length_unit, 'length'))
    self.cs_matrix[:, 3, :] = raw_origins * scale
    # the solids' lengths follow the coordinates (their angles do not)
    raw_scale = (1.0 if self.length_unit is None
                 else 1.0 / si_transform(self.length_unit, 'length')[0])
    self.solids = [s.scaled(raw_scale * scale) for s in self.solids]
    self.length_unit = length_unit
    if self.from_deck:
        notes = implausible_materials(self.group_properties)
        if notes:
            warnings.warn(
                f'{"; ".join(notes)} — is the deck in another unit '
                f'system than {length_unit} and {self.mass_unit}?',
                stacklevel=2)
    return self
undefine_units
undefine_units() -> Geometry

Remove the length unit and restore the original coordinates; returns self.

Source code in src/visualdynamics/core/geometry.py
def undefine_units(self) -> Geometry:
    """Remove the length unit and restore the original coordinates; returns self."""
    if self.length_unit is None:
        return self
    self.node_xyz = from_si(self.node_xyz, self.length_unit, 'length')
    self.cs_matrix[:, 3, :] = from_si(self.cs_matrix[:, 3, :],
                                      self.length_unit, 'length')
    self.solids = [s.scaled(1.0 / si_transform(self.length_unit, 'length')[0])
                   for s in self.solids]
    if self.from_deck:
        self.group_properties = deck_to_si(
            self.group_properties, self.length_unit, self.mass_unit,
            inverse=True)
        self.mass_unit = None
    self.length_unit = None
    return self
dof_direction
dof_direction(dof: str) -> ndarray | None

A DOF's direction in global coordinates, from the coordinate system its node is measured in.

'101X+' at a node whose node_disp_cs is rotated is not global X. The report's table of control channels uses this to show which global axis each channel is nearest and how far it is from it. A rotational DOF points along its axis, as the DOF arrows draw it. A node whose system is not in the geometry is taken to be in the global system.

Parameters:

Name Type Description Default
dof str

The DOF, such as '101X+' or '1313RZ-'.

required

Returns:

Type Description
ndarray or None

A unit vector, or None when the DOF names no node this geometry has, has no axis (a node number alone), or is at a node measured in a cylindrical or spherical system, whose local axes depend on the node's position and are not computed here.

Source code in src/visualdynamics/core/geometry.py
def dof_direction(self, dof: str) -> np.ndarray | None:
    """A DOF's direction in global coordinates, from the coordinate
    system its node is measured in.

    '101X+' at a node whose `node_disp_cs` is rotated is not global
    X. The report's table of control channels uses this to show which
    global axis each channel is nearest and how far it is from it. A
    rotational DOF points along its axis, as the DOF arrows draw it.
    A node whose system is not in the geometry is taken to be in the
    global system.

    Parameters
    ----------
    dof : str
        The DOF, such as '101X+' or '1313RZ-'.

    Returns
    -------
    numpy.ndarray or None
        A unit vector, or None when the DOF names no node this
        geometry has, has no axis (a node number alone), or is at a
        node measured in a cylindrical or spherical system, whose
        local axes depend on the node's position and are not
        computed here.
    """
    # The report's control-channel table reads which global axis a
    # channel is nearest and how far off it is (Brandon, 2026-09-19).
    from .data import parse_dof

    node, direction = parse_dof(dof)
    direction = str(direction).upper()
    letter, sign = direction.lstrip('R')[:1], direction.lstrip('R')[1:]
    axis = {'X': 0, 'Y': 1, 'Z': 2}.get(letter)
    if node is None or axis is None or sign not in ('', '+', '-'):
        return None
    rows = np.flatnonzero(self.node_id == int(node))
    if not rows.size:
        return None
    frames = np.flatnonzero(self.cs_id == self.node_disp_cs[rows[0]])
    if frames.size:
        if int(self.cs_type[frames[0]]) != 0:
            return None
        vector = np.asarray(self.cs_matrix[frames[0], axis], dtype=float)
    else:
        # a frame the table does not list is the global one: a
        # geometry with no systems at all measures every node in it
        vector = np.eye(3)[axis]
    norm = float(np.linalg.norm(vector))
    if not norm:
        return None
    return (-1.0 if sign == '-' else 1.0) * vector / norm
add_node
add_node(xyz: ArrayLike, node_id: int | None = None, color: int = 1, def_cs: int | None = None, disp_cs: int | None = None) -> int

Add a node at xyz and return its id.

Raises ValueError if a node with node_id already exists.

Parameters:

Name Type Description Default
xyz array_like

The node's coordinates in the global cartesian frame, in meters once units are defined. They are stored as given.

required
node_id int

Its identifier. One more than the highest node id when omitted.

None
color int

Its display color index.

1
def_cs int

The coordinate system the node is placed in, which a writer uses to give its position. xyz is not converted from it. The first coordinate system when omitted.

None
disp_cs int

The coordinate system displacements at the node are measured in. The first coordinate system when omitted.

None

Returns:

Type Description
int

The node's identifier.

Source code in src/visualdynamics/core/geometry.py
def add_node(self, xyz: ArrayLike, node_id: int | None = None,
             color: int = 1, def_cs: int | None = None,
             disp_cs: int | None = None) -> int:
    """Add a node at `xyz` and return its id.

    Raises ValueError if a node with `node_id` already exists.

    Parameters
    ----------
    xyz : array_like
        The node's coordinates in the global cartesian frame, in
        meters once units are defined. They are stored as given.
    node_id : int, optional
        Its identifier. One more than the highest node id when
        omitted.
    color : int, default 1
        Its display color index.
    def_cs : int, optional
        The coordinate system the node is placed in, which a writer
        uses to give its position. `xyz` is not converted from it.
        The first coordinate system when omitted.
    disp_cs : int, optional
        The coordinate system displacements at the node are
        measured in. The first coordinate system when omitted.

    Returns
    -------
    int
        The node's identifier.
    """
    node_id = self._next_id(self.node_id) if node_id is None else int(node_id)
    if node_id in self.node_id:
        raise ValueError(f'node {node_id} already exists')
    default_cs = int(self.cs_id[0]) if len(self.cs_id) else 1
    self.node_id = np.append(self.node_id, node_id)
    self.node_xyz = np.vstack([self.node_xyz,
                               np.asarray(xyz, dtype=np.float64)])
    self.node_color = np.append(self.node_color, int(color))
    self.node_def_cs = np.append(
        self.node_def_cs, default_cs if def_cs is None else int(def_cs))
    self.node_disp_cs = np.append(
        self.node_disp_cs, default_cs if disp_cs is None else int(disp_cs))
    return node_id
add_nodes
add_nodes(xyz: ArrayLike) -> ndarray

Add nodes at xyz, numbered from one more than the highest id.

This is add_node for many nodes at once, with the defaults of add_node. Each array is copied once instead of once per node, so adding the thousands of nodes of a meshed plane is fast.

Parameters:

Name Type Description Default
xyz array_like

(n, 3) coordinates in the global cartesian frame, in meters once units are defined.

required

Returns:

Type Description
ndarray

The new nodes' identifiers, in order.

Source code in src/visualdynamics/core/geometry.py
def add_nodes(self, xyz: ArrayLike) -> np.ndarray:
    """Add nodes at `xyz`, numbered from one more than the highest id.

    This is `add_node` for many nodes at once, with the defaults of
    `add_node`. Each array is copied once instead of once per node,
    so adding the thousands of nodes of a meshed plane is fast.

    Parameters
    ----------
    xyz : array_like
        (n, 3) coordinates in the global cartesian frame, in meters
        once units are defined.

    Returns
    -------
    ndarray
        The new nodes' identifiers, in order.
    """
    xyz = np.asarray(xyz, dtype=np.float64).reshape(-1, 3)
    first = self._next_id(self.node_id)
    ids = np.arange(first, first + len(xyz), dtype=np.int64)
    default_cs = int(self.cs_id[0]) if len(self.cs_id) else 1
    self.node_id = np.concatenate([self.node_id, ids])
    self.node_xyz = np.vstack([self.node_xyz, xyz])
    self.node_color = np.concatenate(
        [self.node_color, np.ones(len(xyz), dtype=self.node_color.dtype)])
    self.node_def_cs = np.concatenate(
        [self.node_def_cs, np.full(len(xyz), default_cs,
                                   dtype=self.node_def_cs.dtype)])
    self.node_disp_cs = np.concatenate(
        [self.node_disp_cs, np.full(len(xyz), default_cs,
                                    dtype=self.node_disp_cs.dtype)])
    return ids
add_coordinate_system
add_coordinate_system(origin: ArrayLike = (0.0, 0.0, 0.0), rotation: ArrayLike | None = None, cs_id: int | None = None, name: str = '', cs_type: int = 0) -> int

Add a coordinate system and return its id.

Raises ValueError if a system with cs_id already exists.

Parameters:

Name Type Description Default
origin array_like

The system's origin, in meters once units are defined.

(0, 0, 0)
rotation array_like

A 3x3 matrix whose rows are the system's axes. Identity when omitted.

None
cs_id int

Its identifier. One more than the highest id when omitted.

None
name str

What to call it.

''
cs_type int

0 cartesian, 1 cylindrical, 2 spherical.

0

Returns:

Type Description
int

The coordinate system's identifier.

Source code in src/visualdynamics/core/geometry.py
def add_coordinate_system(self, origin: ArrayLike = (0.0, 0.0, 0.0),
                          rotation: ArrayLike | None = None,
                          cs_id: int | None = None, name: str = '',
                          cs_type: int = 0) -> int:
    """Add a coordinate system and return its id.

    Raises ValueError if a system with `cs_id` already exists.

    Parameters
    ----------
    origin : array_like, default (0, 0, 0)
        The system's origin, in meters once units are defined.
    rotation : array_like, optional
        A 3x3 matrix whose rows are the system's axes. Identity when
        omitted.
    cs_id : int, optional
        Its identifier. One more than the highest id when omitted.
    name : str, optional
        What to call it.
    cs_type : int, default 0
        0 cartesian, 1 cylindrical, 2 spherical.

    Returns
    -------
    int
        The coordinate system's identifier.
    """
    cs_id = self._next_id(self.cs_id) if cs_id is None else int(cs_id)
    if cs_id in self.cs_id:
        raise ValueError(f'coordinate system {cs_id} already exists')
    rotation = np.eye(3) if rotation is None else np.asarray(rotation)
    matrix = np.vstack([rotation, np.asarray(origin, dtype=np.float64)])
    self.cs_id = np.append(self.cs_id, cs_id)
    self.cs_type = np.append(self.cs_type, int(cs_type))
    self.cs_name = [*self.cs_name, str(name)]
    self.cs_matrix = np.concatenate([self.cs_matrix, matrix[np.newaxis]])
    return cs_id
place_coordinate_system
place_coordinate_system(cs_id: int, *, origin: ArrayLike | None = None, angles: ArrayLike | None = None, rotation: ArrayLike | None = None) -> None

Rotate a coordinate system, move it, or both, as the 3-D view's rings and arrows do.

Raises KeyError for an unknown cs_id, and ValueError when both angles and rotation are given.

Parameters:

Name Type Description Default
cs_id int

Which coordinate system.

required
origin array_like

The new origin, in the geometry's stored length unit (meters once its units are declared). Unchanged when omitted.

None
angles array_like

The rotation as angles in degrees about the fixed global X, then Y, then Z axes (rotate.frame_from_angles), the same angles the view's angle box and the box panel use. (0, 0, 0) removes any rotation. Unchanged when omitted.

None
rotation array_like

The rotation as a 3x3 matrix whose rows are the system's axes, instead of angles.

None

Returns:

Type Description
None
Source code in src/visualdynamics/core/geometry.py
def place_coordinate_system(self, cs_id: int, *,
                            origin: ArrayLike | None = None,
                            angles: ArrayLike | None = None,
                            rotation: ArrayLike | None = None) -> None:
    """Rotate a coordinate system, move it, or both, as the 3-D
    view's rings and arrows do.

    Raises KeyError for an unknown `cs_id`, and ValueError when both
    `angles` and `rotation` are given.

    Parameters
    ----------
    cs_id : int
        Which coordinate system.
    origin : array_like, optional
        The new origin, in the geometry's stored length unit (meters
        once its units are declared). Unchanged when omitted.
    angles : array_like, optional
        The rotation as angles in degrees about the fixed global X,
        then Y, then Z axes (`rotate.frame_from_angles`), the same
        angles the view's angle box and the box panel use.
        (0, 0, 0) removes any rotation. Unchanged when omitted.
    rotation : array_like, optional
        The rotation as a 3x3 matrix whose rows are the system's
        axes, instead of `angles`.

    Returns
    -------
    None
    """
    from ..rotate import frame_from_angles

    rows = np.flatnonzero(self.cs_id == int(cs_id))
    if not len(rows):
        raise KeyError(f'no coordinate system {int(cs_id)}')
    if angles is not None and rotation is not None:
        raise ValueError('give the turn as angles or as a rotation, '
                         'not both')
    row = int(rows[0])
    if angles is not None:
        self.cs_matrix[row, :3] = frame_from_angles(angles)[:3]
    elif rotation is not None:
        self.cs_matrix[row, :3] = np.asarray(rotation, dtype=np.float64)
    if origin is not None:
        self.cs_matrix[row, 3] = np.asarray(origin, dtype=np.float64)
add_beams
add_beams(node_ids: Ids, group: int | None = None, color: int = 1, elem_type: int = 21) -> int

Add a chain of two-node line elements through the given nodes, in order, and return the element group's id.

This is how a drawn line (a traceline in some file formats) is stored, and it becomes a run of beams once the element group is given a section. Raises ValueError for fewer than two nodes, an unknown node, or an elem_type that is not a two-node line.

Parameters:

Name Type Description Default
node_ids sequence of int

The nodes the line runs through, in order; two at least.

required
group int

The element group the segments are added to. A new, unnamed element group when omitted; a drawn line is its own element group, named for the line.

None
color int

The display color index of every segment.

1
elem_type int

The two-node line code (ELEMENT_TYPES): a beam, unless the source says rod, pipe or rigid bar.

21

Returns:

Type Description
int

The element group the segments are in.

Source code in src/visualdynamics/core/geometry.py
def add_beams(self, node_ids: Ids, group: int | None = None,
              color: int = 1, elem_type: int = 21) -> int:
    """Add a chain of two-node line elements through the given nodes,
    in order, and return the element group's id.

    This is how a drawn line (a traceline in some file formats) is
    stored, and it becomes a run of beams once the element group is
    given a section. Raises ValueError for fewer than two nodes, an
    unknown node, or an `elem_type` that is not a two-node line.

    Parameters
    ----------
    node_ids : sequence of int
        The nodes the line runs through, in order; two at least.
    group : int, optional
        The element group the segments are added to. A new, unnamed
        element group when omitted; a drawn line is its own element
        group, named for the line.
    color : int, default 1
        The display color index of every segment.
    elem_type : int, default 21
        The two-node line code (`ELEMENT_TYPES`): a beam, unless the
        source says rod, pipe or rigid bar.

    Returns
    -------
    int
        The element group the segments are in.
    """
    # what a traceline was, before tracelines were stored as
    # element groups (2026-09-30)
    nodes = [int(n) for n in node_ids]
    if len(nodes) < 2:
        raise ValueError('a line needs at least two nodes')
    unknown = set(nodes) - set(self.node_id.tolist())
    if unknown:
        raise ValueError(f'unknown nodes {sorted(unknown)}')
    if ELEMENT_TYPES.get(int(elem_type), (None, 0, None))[1:] != (2, 'line'):
        raise ValueError(f'{elem_type} is not a two-node line element type')
    from itertools import pairwise

    group = self.add_group() if group is None else int(group)
    for a, b in pairwise(nodes):
        self.add_element([a, b], elem_type=int(elem_type), color=color,
                         group=group)
    return group
mixed_groups
mixed_groups() -> dict[int, list[str]]

{element group id: families} for every element group that holds more than one element family.

It is empty for any geometry that passes validate.

Returns:

Type Description
dict of int to list of str
Source code in src/visualdynamics/core/geometry.py
def mixed_groups(self) -> dict[int, list[str]]:
    """``{element group id: families}`` for every element group that
    holds more than one element family.

    It is empty for any geometry that passes `validate`.

    Returns
    -------
    dict of int to list of str
    """
    found: dict[int, list[str]] = {}
    for code, group in zip(self.elem_type, self.elem_group):
        if int(code) not in ELEMENT_TYPES:
            continue                 # validate names the stray code
        family = element_family(int(code))
        families = found.setdefault(int(group), [])
        if family not in families:
            families.append(family)
    return {b: f for b, f in found.items() if len(f) > 1}
split_groups_by_family
split_groups_by_family() -> dict[int, list[int]]

Split every element group that holds more than one element family.

The group keeps its first family, and each other family is moved into a new element group named with the old name and the family, such as 'wing quads', with the old group's properties. A file without element groups (UNV dataset 2412, the sdynpy layout) puts every element in one group, so this is applied when such a file is read.

Returns:

Type Description
dict of int to list of int

{old element group: [the new element groups made from it]}, empty when no group was mixed.

Source code in src/visualdynamics/core/geometry.py
def split_groups_by_family(self) -> dict[int, list[int]]:
    """Split every element group that holds more than one element family.

    The group keeps its first family, and each other family is moved
    into a new element group named with the old name and the
    family, such as 'wing quads', with the old group's properties. A
    file without element groups (UNV
    dataset 2412, the sdynpy layout) puts every element in one group,
    so this is applied when such a file is read.

    Returns
    -------
    dict of int to list of int
        ``{old element group: [the new element groups made from it]}``,
        empty when no group was mixed.
    """
    # Split on import rather than show one element group under two
    # families and edit it twice (Brandon, 2026-09-30).
    made: dict[int, list[int]] = {}
    for group, families in self.mixed_groups().items():
        row = int(np.flatnonzero(self.group_id == group)[0])
        base = self.group_name[row]
        for family in families[1:]:
            label = FAMILY_LABELS[family].lower()
            new = self.add_group(f'{base} {label}'.strip())
            rows = [i for i, (code, b) in enumerate(
                zip(self.elem_type, self.elem_group))
                if int(b) == group and element_family(int(code)) == family]
            self.elem_group[rows] = new
            # the same property card made both shapes, so the split
            # carries it (2026-10-26: a deck's PSHELL on quads and
            # triangles left the triangles with nothing)
            if group in self.group_properties:
                self.group_properties[new] = self.group_properties[group]
            made.setdefault(group, []).append(new)
    return made
attach_drawn_lines
attach_drawn_lines(lines: Sequence[tuple[str, int, Sequence[Ids]]]) -> list[int]

Add drawn lines read from a format that stores them separately from elements, each as its own element group of two-node line elements with no properties.

This is the reading side of drawn_lines.

Parameters:

Name Type Description Default
lines sequence of (name, color, chains)

One entry per line: its name, its color index, and its runs, each a sequence of node ids in drawing order (a UNV line that lifts the pen has several).

required

Returns:

Type Description
list of int

The element groups made, one per line.

Source code in src/visualdynamics/core/geometry.py
def attach_drawn_lines(self, lines: Sequence[tuple[str, int, Sequence[Ids]]]
                       ) -> list[int]:
    """Add drawn lines read from a format that stores them separately
    from elements, each as its own element group of two-node line
    elements with no properties.

    This is the reading side of `drawn_lines`.

    Parameters
    ----------
    lines : sequence of (name, color, chains)
        One entry per line: its name, its color index, and its runs,
        each a sequence of node ids in drawing order (a UNV line that
        lifts the pen has several).

    Returns
    -------
    list of int
        The element groups made, one per line.
    """
    groups = []
    for name, color, chains in lines:
        group = self.add_group(str(name))
        for chain in chains:
            self.add_beams(chain, group=group, color=int(color))
        groups.append(group)
    return groups
is_drawn_line
is_drawn_line(group: int) -> bool

Whether an element group is a drawn line.

It is when every element in it is a two-node line element and the element group has no properties. The app and every file writer use this one test. An empty element group is not a drawn line.

Parameters:

Name Type Description Default
group int

The element group's identifier.

required

Returns:

Type Description
bool
Source code in src/visualdynamics/core/geometry.py
def is_drawn_line(self, group: int) -> bool:
    """Whether an element group is a drawn line.

    It is when every element in it is a two-node line element and the
    element group has no properties. The app and every file writer
    use this one test. An empty element group is not a drawn line.

    Parameters
    ----------
    group : int
        The element group's identifier.

    Returns
    -------
    bool
    """
    # the one test, on screen and in every file (PLAN.md "Geometry by
    # element family")
    group = int(group)
    if group in self.group_properties:
        return False
    rows = np.flatnonzero(self.elem_group == group)
    if not len(rows):
        return False
    return all(ELEMENT_TYPES.get(int(self.elem_type[r]),
                                 (None, 0, None))[1:] == (2, 'line')
               for r in rows)
drawn_lines
drawn_lines() -> list[dict[str, Any]]

The drawn lines, in the form used by file formats that store tracelines separately from elements.

One dict {'group', 'name', 'color', 'chains'} per drawn-line element group (is_drawn_line), in element group order. The chains are the group's segments taken in element order, each a list of node ids, with a new chain wherever a segment does not start where the last one ended. An element group read from one polyline gives that polyline back, and one read from a UNV line that lifts the pen gives its runs back under the one element group. The color is that of the group's first element.

Returns:

Type Description
list of dict
Source code in src/visualdynamics/core/geometry.py
def drawn_lines(self) -> list[dict[str, Any]]:
    """The drawn lines, in the form used by file formats that store
    tracelines separately from elements.

    One dict `{'group', 'name', 'color', 'chains'}` per drawn-line
    element group (`is_drawn_line`), in element group order. The
    chains are the group's segments taken in element order, each a
    list of node ids, with a new chain wherever a segment does not
    start where the last one ended. An element group read from one
    polyline gives that polyline back, and one read from a UNV line
    that lifts the pen gives its runs back under the one element
    group. The color is that of the group's first element.

    Returns
    -------
    list of dict
    """
    out = []
    for row, group in enumerate(self.group_id):
        group = int(group)
        if not self.is_drawn_line(group):
            continue
        rows = np.flatnonzero(self.elem_group == group)
        chains: list[list[int]] = []
        for r in rows:
            a, b = (int(n) for n in self.elem_conn[r][:2])
            if chains and chains[-1][-1] == a:
                chains[-1].append(b)
            else:
                chains.append([a, b])
        out.append({'group': group, 'name': self.group_name[row],
                    'color': int(self.elem_color[rows[0]]),
                    'chains': chains})
    return out
group_of
group_of(elem_id: int) -> str

The name of the element group an element belongs to, or '' when that group is not declared.

Parameters:

Name Type Description Default
elem_id int

Which element.

required

Returns:

Type Description
str

The name of the element group it belongs to.

Source code in src/visualdynamics/core/geometry.py
def group_of(self, elem_id: int) -> str:
    """The name of the element group an element belongs to, or '' when
    that group is not declared.

    Parameters
    ----------
    elem_id : int
        Which element.

    Returns
    -------
    str
        The name of the element group it belongs to.
    """
    row = int(np.flatnonzero(self.elem_id == int(elem_id))[0])
    group = int(self.elem_group[row])
    where = np.flatnonzero(self.group_id == group)
    return self.group_name[int(where[0])] if len(where) else ''
elements_in
elements_in(group: int | str) -> list[int]

The ids of the elements in an element group, given by id or name.

A name that no element group has gives an empty list; with two groups of one name, the first is used.

Parameters:

Name Type Description Default
group int or str

An element group, by identifier or by name.

required

Returns:

Type Description
list of int

The identifiers of the elements it holds.

Source code in src/visualdynamics/core/geometry.py
def elements_in(self, group: int | str) -> list[int]:
    """The ids of the elements in an element group, given by id or name.

    A name that no element group has gives an empty list; with two
    groups of one name, the first is used.

    Parameters
    ----------
    group : int or str
        An element group, by identifier or by name.

    Returns
    -------
    list of int
        The identifiers of the elements it holds.
    """
    if isinstance(group, str):
        where = [i for i, name in enumerate(self.group_name)
                 if name == group]
        if not where:
            return []
        group = int(self.group_id[where[0]])
    return [int(self.elem_id[i])
            for i in np.flatnonzero(self.elem_group == int(group))]
nodes_in
nodes_in(groups: Ids) -> list[int]

The ids of the nodes used by the elements of these element groups, in ascending order. These are the nodes a merge limited to those groups may change.

Parameters:

Name Type Description Default
groups sequence of int

Element groups, by identifier.

required

Returns:

Type Description
list of int

The node identifiers.

Raises:

Type Description
ValueError

When a group is not in the geometry.

Source code in src/visualdynamics/core/geometry.py
def nodes_in(self, groups: Ids) -> list[int]:
    """The ids of the nodes used by the elements of these element
    groups, in ascending order. These are the nodes a merge limited to
    those groups may change.

    Parameters
    ----------
    groups : sequence of int
        Element groups, by identifier.

    Returns
    -------
    list of int
        The node identifiers.

    Raises
    ------
    ValueError
        When a group is not in the geometry.
    """
    groups = [int(g) for g in groups]
    missing = [g for g in groups if g not in self.group_id.tolist()]
    if missing:
        raise ValueError(f'no element group {missing[0]}')
    return sorted({int(n) for k in np.flatnonzero(
        np.isin(self.elem_group, groups)) for n in self.elem_conn[k]})
add_group
add_group(name: str = '', group_id: int | None = None) -> int

Declare an element group and return its id.

An empty element group is allowed: exodus files contain empty ones, and an element group has to exist before an element can be put in it. Raises ValueError if group_id is already used.

Parameters:

Name Type Description Default
name str

What to call the element group.

''
group_id int

Its identifier. One more than the highest id when omitted.

None

Returns:

Type Description
int

The element group's identifier.

Source code in src/visualdynamics/core/geometry.py
def add_group(self, name: str = '', group_id: int | None = None) -> int:
    """Declare an element group and return its id.

    An empty element group is allowed: exodus files contain empty
    ones, and an element group has to exist before an element can be
    put in it. Raises ValueError if `group_id` is already used.

    Parameters
    ----------
    name : str, optional
        What to call the element group.
    group_id : int, optional
        Its identifier. One more than the highest id when omitted.

    Returns
    -------
    int
        The element group's identifier.
    """
    group_id = (self._next_id(self.group_id) if group_id is None
                else int(group_id))
    if group_id in self.group_id:
        raise ValueError(f'element group {group_id} already exists')
    self.group_id = np.append(self.group_id, group_id)
    self.group_name.append(str(name))
    return group_id
add_element
add_element(node_ids: Ids, elem_type: int | None = None, color: int = 1, group: int | None = None) -> int

Add an element and return its row index.

The element id is one more than the highest element id. Without elem_type, the type is chosen from the node count: 2 nodes a beam (21), 3 a triangle (41), 4 a quadrilateral (44); any other count needs elem_type. Raises ValueError for an unknown node or type, or for a group that holds a different element family.

Parameters:

Name Type Description Default
node_ids int or sequence of int

The nodes the element connects, in order.

required
elem_type int

The element type code. Inferred from the node count when omitted.

None
color int

Its display color index.

1
group int

The element group it belongs to, which is declared if it does not exist yet. When omitted, the first element group that holds this element's family (or is empty), otherwise a new one. An element group holds one family, so a beam added to a mesh of plates goes in an element group of its own.

None

Returns:

Type Description
int

The element's row index in the element arrays (not its id).

Source code in src/visualdynamics/core/geometry.py
def add_element(self, node_ids: Ids, elem_type: int | None = None,
                color: int = 1, group: int | None = None) -> int:
    """Add an element and return its row index.

    The element id is one more than the highest element id. Without
    `elem_type`, the type is chosen from the node count: 2 nodes a
    beam (21), 3 a triangle (41), 4 a quadrilateral (44); any other
    count needs `elem_type`. Raises ValueError for an unknown node or
    type, or for a group that holds a different element family.

    Parameters
    ----------
    node_ids : int or sequence of int
        The nodes the element connects, in order.
    elem_type : int, optional
        The element type code. Inferred from the node
        count when omitted.
    color : int, default 1
        Its display color index.
    group : int, optional
        The element group it belongs to, which is declared if it does
        not exist yet. When omitted, the first element group that
        holds this element's family (or is empty), otherwise a new
        one. An element group holds one family, so a beam added to a
        mesh of plates goes in an element group of its own.

    Returns
    -------
    int
        The element's row index in the element arrays (not its id).
    """
    # one element group, one family (2026-09-30)
    nodes = np.asarray([int(n) for n in node_ids], dtype=np.int64)
    unknown = set(nodes.tolist()) - set(self.node_id.tolist())
    if unknown:
        raise ValueError(f'unknown nodes {sorted(unknown)}')
    if elem_type is None:
        elem_type = {2: 21, 3: 41, 4: 44}.get(len(nodes))
        if elem_type is None:
            raise ValueError(
                f'no default element type for {len(nodes)} nodes; '
                'give elem_type')
    if int(elem_type) not in ELEMENT_TYPES:
        raise ValueError(f'unknown element type {elem_type}')
    family = element_family(int(elem_type))
    if group is None:
        held_by = {int(b): element_family(int(code))
                   for code, b in zip(self.elem_type, self.elem_group)}
        group = next((b for b in self.group_id.tolist()
                      if held_by.get(int(b), family) == family), None)
        if group is None:
            group = self.add_group()
    group = int(group)
    # one element group, one family: the element group's properties are one kind
    rows = np.flatnonzero(self.elem_group == group)
    if len(rows):
        held = element_family(int(self.elem_type[rows[0]]))
        if held != family:
            raise ValueError(
                f'element group {group} holds {held}; a {family[:-1]} goes in '
                'an element group of its own')
    self.elem_id = np.append(self.elem_id, self._next_id(self.elem_id))
    self.elem_type = np.append(self.elem_type, int(elem_type))
    self.elem_color = np.append(self.elem_color, int(color))
    if group not in self.group_id.tolist():
        self.group_id = np.append(self.group_id, group)
        self.group_name.append('')
    self.elem_group = np.append(self.elem_group, group)
    self.elem_conn.append(nodes)
    return len(self.elem_conn) - 1
add_elements
add_elements(connectivity: Sequence[Ids], elem_types: Ids, groups: Ids) -> None

Add many elements at once, numbered from one more than the highest element id, with color 1.

The nodes and types are checked once for all of them, and an element group may hold only one element family, as in add_element. If any check fails, ValueError is raised and nothing is added.

Parameters:

Name Type Description Default
connectivity sequence of sequence of int

Each element's nodes, in order.

required
elem_types sequence of int

Each element's type code.

required
groups sequence of int

The element group each belongs to; an element group that does not exist yet is declared, with no name.

required

Returns:

Type Description
None
Source code in src/visualdynamics/core/geometry.py
def add_elements(self, connectivity: Sequence[Ids],
                 elem_types: Ids, groups: Ids) -> None:
    """Add many elements at once, numbered from one more than the
    highest element id, with color 1.

    The nodes and types are checked once for all of them, and an
    element group may hold only one element family, as in
    `add_element`. If any check fails, ValueError is raised and
    nothing is added.

    Parameters
    ----------
    connectivity : sequence of sequence of int
        Each element's nodes, in order.
    elem_types : sequence of int
        Each element's type code.
    groups : sequence of int
        The element group each belongs to; an element group that does
        not exist yet is declared, with no name.

    Returns
    -------
    None
    """
    conn = [np.asarray([int(n) for n in element], dtype=np.int64)
            for element in connectivity]
    if not conn:
        return
    used = set(np.concatenate(conn).tolist())
    unknown = used - set(self.node_id.tolist())
    if unknown:
        raise ValueError(f'unknown nodes {sorted(unknown)[:10]}')
    types = [int(t) for t in elem_types]
    wrong = sorted(set(types) - set(ELEMENT_TYPES))
    if wrong:
        raise ValueError(f'unknown element type {wrong[0]}')
    groups = [int(b) for b in groups]
    # one element group, one family — the rule `add_element` keeps one element
    # at a time, kept here for many at once. Its absence let a plate
    # joined by name into an element group of bricks make one element group of both
    # (Brandon, 2026-10-02); checked before anything is appended, so
    # a refused call leaves the geometry as it was
    coming: dict[int, set[str]] = {}
    for group, code in zip(groups, types):
        coming.setdefault(group, set()).add(element_family(code))
    for group, families in coming.items():
        held = {element_family(int(code)) for code
                in self.elem_type[self.elem_group == group]}
        if len(held | families) > 1:
            raise ValueError(
                f'element group {group} would hold '
                f'{" and ".join(sorted(held | families))}: an element group holds '
                'one element family')
    for group in dict.fromkeys(groups):
        if group not in self.group_id.tolist():
            self.group_id = np.append(self.group_id, group)
            self.group_name.append('')
    first = self._next_id(self.elem_id)
    self.elem_id = np.concatenate(
        [self.elem_id, np.arange(first, first + len(conn))]).astype(np.int64)
    self.elem_type = np.concatenate([self.elem_type, types]).astype(np.int64)
    self.elem_color = np.concatenate(
        [self.elem_color, np.ones(len(conn))]).astype(np.int64)
    self.elem_group = np.concatenate([self.elem_group, groups]).astype(np.int64)
    self.elem_conn.extend(conn)
renumber_node
renumber_node(row: int, node_id: int) -> None

Give a node a new id, and update the elements that use it.

Elements refer to nodes by id, so the connectivity is updated too; otherwise every element at the node would lose it. Raises ValueError if another node already has the id.

Parameters:

Name Type Description Default
row int

Which node, by row.

required
node_id int

Its new identifier.

required

Returns:

Type Description
None
Source code in src/visualdynamics/core/geometry.py
def renumber_node(self, row: int, node_id: int) -> None:
    """Give a node a new id, and update the elements that use it.

    Elements refer to nodes by id, so the connectivity is updated
    too; otherwise every element at the node would lose it. Raises
    ValueError if another node already has the id.

    Parameters
    ----------
    row : int
        Which node, by row.
    node_id : int
        Its new identifier.

    Returns
    -------
    None
    """
    node_id = int(node_id)
    clash = np.flatnonzero(self.node_id == node_id)
    if len(clash) and clash[0] != row:
        raise ValueError(f'node {node_id} already exists')
    old = int(self.node_id[row])
    self.node_id[row] = node_id
    for conn in self.elem_conn:
        conn[conn == old] = node_id
    self._mates_follow(nodes={old: node_id})
renumber_group
renumber_group(row: int, group_id: int) -> None

Give an element group a new id, and update its elements.

An element refers to its element group by id, so the elements are updated too; otherwise they would refer to an element group that no longer exists. Raises ValueError if another element group already has the id.

Parameters:

Name Type Description Default
row int

Which element group, by row.

required
group_id int

Its new identifier.

required

Returns:

Type Description
None
Source code in src/visualdynamics/core/geometry.py
def renumber_group(self, row: int, group_id: int) -> None:
    """Give an element group a new id, and update its elements.

    An element refers to its element group by id, so the elements are
    updated too; otherwise they would refer to an element group that
    no longer exists. Raises ValueError if another element group
    already has the id.

    Parameters
    ----------
    row : int
        Which element group, by row.
    group_id : int
        Its new identifier.

    Returns
    -------
    None
    """
    group_id = int(group_id)
    clash = np.flatnonzero(self.group_id == group_id)
    if len(clash) and clash[0] != row:
        raise ValueError(f'element group {group_id} already exists')
    old = int(self.group_id[row])
    self.group_id[row] = group_id
    self.elem_group[self.elem_group == old] = group_id
    self._mates_follow(groups={old: group_id})
renumber_coordinate_system
renumber_coordinate_system(row: int, cs_id: int) -> None

Give a coordinate system a new id, and update the nodes that use it.

Both the placement and the displacement systems of the nodes are updated. Raises ValueError if another system already has the id.

Parameters:

Name Type Description Default
row int

Which system, by row.

required
cs_id int

Its new identifier.

required

Returns:

Type Description
None
Source code in src/visualdynamics/core/geometry.py
def renumber_coordinate_system(self, row: int, cs_id: int) -> None:
    """Give a coordinate system a new id, and update the nodes that use it.

    Both the placement and the displacement systems of the nodes are
    updated. Raises ValueError if another system already has the id.

    Parameters
    ----------
    row : int
        Which system, by row.
    cs_id : int
        Its new identifier.

    Returns
    -------
    None
    """
    cs_id = int(cs_id)
    clash = np.flatnonzero(self.cs_id == cs_id)
    if len(clash) and clash[0] != row:
        raise ValueError(f'coordinate system {cs_id} already exists')
    old = int(self.cs_id[row])
    self.cs_id[row] = cs_id
    for name in ('node_def_cs', 'node_disp_cs'):
        references = getattr(self, name)
        references[references == old] = cs_id
delete_nodes
delete_nodes(node_ids: Ids) -> dict[str, int]

Remove nodes, and the elements that use them.

An element that uses a deleted node is deleted too. Ids that are not present are ignored.

Parameters:

Name Type Description Default
node_ids int or sequence of int

The identifiers, one or many.

required

Returns:

Type Description
dict of str to int

'nodes' and 'elements', the number of each removed.

Source code in src/visualdynamics/core/geometry.py
def delete_nodes(self, node_ids: Ids) -> dict[str, int]:
    """Remove nodes, and the elements that use them.

    An element that uses a deleted node is deleted too. Ids that are
    not present are ignored.

    Parameters
    ----------
    node_ids : int or sequence of int
        The identifiers, one or many.

    Returns
    -------
    dict of str to int
        'nodes' and 'elements', the number of each removed.
    """
    wanted = {int(node) for node in node_ids}
    keep = ~np.isin(self.node_id, list(wanted))
    removed_nodes = int((~keep).sum())
    if not removed_nodes:
        return {'nodes': 0, 'elements': 0}

    orphan_elements = [int(self.elem_id[i])
                       for i, conn in enumerate(self.elem_conn)
                       if wanted & {int(n) for n in conn}]
    self.delete_elements(orphan_elements)

    for name in ('node_id', 'node_def_cs', 'node_disp_cs', 'node_color'):
        setattr(self, name, getattr(self, name)[keep])
    self.node_xyz = self.node_xyz[keep]
    self._mates_follow(nodes={node: None for node in wanted})
    self.validate()
    return {'nodes': removed_nodes, 'elements': len(orphan_elements)}
coincident_nodes
coincident_nodes(tolerance: float, nodes: Ids | None = None) -> dict[int, int]

{node: the node it coincides with} for every node within tolerance of another.

Each node is mapped to the lowest id in its group, which is the node a merge keeps. Groups are chained: if a is within tolerance of b and b of c, all three are one point.

Parameters:

Name Type Description Default
tolerance float

How close two nodes must be to be one point, in the stored length unit: meters once units are defined.

required
nodes sequence of int

Only these nodes are considered, by id; a node outside them is not merged and nothing is merged into it, however close. Every node when omitted.

None

Returns:

Type Description
dict of int to int

Only the nodes that would be removed, each mapped to the node it becomes.

Source code in src/visualdynamics/core/geometry.py
def coincident_nodes(self, tolerance: float,
                     nodes: Ids | None = None) -> dict[int, int]:
    """``{node: the node it coincides with}`` for every node within
    `tolerance` of another.

    Each node is mapped to the lowest id in its group, which is the
    node a merge keeps. Groups are chained: if a is within tolerance
    of b and b of c, all three are one point.

    Parameters
    ----------
    tolerance : float
        How close two nodes must be to be one point, in the stored
        length unit: meters once units are defined.
    nodes : sequence of int, optional
        Only these nodes are considered, by id; a node outside them
        is not merged and nothing is merged into it, however close.
        Every node when omitted.

    Returns
    -------
    dict of int to int
        Only the nodes that would be removed, each mapped to the node
        it becomes.
    """
    from scipy.spatial import cKDTree

    rows = (np.arange(len(self.node_id)) if nodes is None
            else np.flatnonzero(np.isin(self.node_id,
                                        [int(n) for n in nodes])))
    if len(rows) < 2:
        return {}
    pairs = cKDTree(self.node_xyz[rows]).query_pairs(float(tolerance))
    parent = list(range(len(rows)))

    def root(i: int) -> int:
        while parent[i] != i:
            parent[i] = parent[parent[i]]
            i = parent[i]
        return i

    for i, j in pairs:
        a, b = root(i), root(j)
        if a != b:
            parent[max(a, b)] = min(a, b)
    groups: dict[int, list[int]] = {}
    for i in range(len(rows)):
        groups.setdefault(root(i), []).append(i)
    out = {}
    for members in groups.values():
        if len(members) < 2:
            continue
        ids = sorted(int(self.node_id[rows[i]]) for i in members)
        for other in ids[1:]:
            out[other] = ids[0]
    return out
duplicate_elements
duplicate_elements(groups: Ids | None = None) -> dict[int, int]

{element id: the element it duplicates} for every solid element of the same type and on the same nodes as an earlier element, such as two bricks filling one cell.

The earlier element (in element order) is the one that stays. Only solids are checked: two beams on one line can be two members, and a plate on another plate's nodes is how a doubler or a layer is modeled, but two solids are never meant to fill one cell. Two element groups that overlap and share their nodes in the overlap (the bars of an X cross-section) put two elements in every cell there, which doubles the stiffness and mass of the region.

Parameters:

Name Type Description Default
groups sequence of int

Only the elements of these element groups, by id; every element when omitted.

None

Returns:

Type Description
dict of int to int

Each element that fills a cell another element already fills, mapped to that element.

Source code in src/visualdynamics/core/geometry.py
def duplicate_elements(self, groups: Ids | None = None) -> dict[int, int]:
    """``{element id: the element it duplicates}`` for every solid
    element of the same type and on the same nodes as an earlier
    element, such as two bricks filling one cell.

    The earlier element (in element order) is the one that stays.
    Only solids are checked: two beams on one line can be two
    members, and a plate on another plate's nodes is how a doubler or
    a layer is modeled, but two solids are never meant to fill one
    cell. Two element groups that overlap and share their nodes in
    the overlap (the bars of an X cross-section) put two elements in
    every cell there, which doubles the stiffness and mass of the
    region.

    Parameters
    ----------
    groups : sequence of int, optional
        Only the elements of these element groups, by id; every
        element when omitted.

    Returns
    -------
    dict of int to int
        Each element that fills a cell another element already
        fills, mapped to that element.
    """
    # The X cross-section's overlap drew every face of each pair as
    # interior, so the middle of the X disappeared from the view
    # (Brandon, 2026-10-02).
    wanted = None if groups is None else {int(g) for g in groups}
    first: dict[tuple, int] = {}
    out: dict[int, int] = {}
    for k, conn in enumerate(self.elem_conn):
        if element_family(int(self.elem_type[k])) not in SOLID_FAMILIES:
            continue
        if wanted is not None and int(self.elem_group[k]) not in wanted:
            continue
        key = (int(self.elem_type[k]), tuple(sorted(int(n) for n in conn)))
        eid = int(self.elem_id[k])
        if key in first:
            out[eid] = first[key]
        else:
            first[key] = eid
    return out
merge_duplicate_elements
merge_duplicate_elements(groups: Ids | None = None) -> int

Remove duplicate solid elements (duplicate_elements).

The later element of each pair is removed, and the earlier one stays in its element group.

Parameters:

Name Type Description Default
groups sequence of int

Only the elements of these element groups, by id; every element when omitted.

None

Returns:

Type Description
int

How many elements were removed.

Source code in src/visualdynamics/core/geometry.py
def merge_duplicate_elements(self, groups: Ids | None = None) -> int:
    """Remove duplicate solid elements (`duplicate_elements`).

    The later element of each pair is removed, and the earlier one
    stays in its element group.

    Parameters
    ----------
    groups : sequence of int, optional
        Only the elements of these element groups, by id; every
        element when omitted.

    Returns
    -------
    int
        How many elements were removed.
    """
    going = self.duplicate_elements(groups)
    if going:
        self.delete_elements(list(going))
    return len(going)
node_spacing
node_spacing(groups: Ids | None = None) -> float | None

The mesh's finest spacing: the shortest nonzero distance between two nodes of one element, such as a brick's shortest edge or half an edge of a quad8.

Merge Coincident Nodes sets its default tolerance to a quarter of this value. Nodes of parts meshed separately are usually much closer than that where the parts meet, and no two nodes of one element are.

Parameters:

Name Type Description Default
groups sequence of int

Only these element groups' elements, by id; every element when omitted.

None

Returns:

Type Description
float or None

The spacing, in the stored length unit; None when no element has two nodes at different positions.

Source code in src/visualdynamics/core/geometry.py
def node_spacing(self, groups: Ids | None = None) -> float | None:
    """The mesh's finest spacing: the shortest nonzero distance between
    two nodes of one element, such as a brick's shortest edge or half
    an edge of a quad8.

    Merge Coincident Nodes sets its default tolerance to a quarter of
    this value. Nodes of parts meshed separately are usually much
    closer than that where the parts meet, and no two nodes of one
    element are.

    Parameters
    ----------
    groups : sequence of int, optional
        Only these element groups' elements, by id; every element
        when omitted.

    Returns
    -------
    float or None
        The spacing, in the stored length unit; None when no element
        has two nodes at different positions.
    """
    # the quarter is Brandon's, 2026-10-11
    rows = (range(len(self.elem_conn)) if groups is None
            else np.flatnonzero(np.isin(self.elem_group,
                                        [int(g) for g in groups])))
    return self._spacing(rows)
coincident_spots
coincident_spots(tolerance: float, groups: Ids | None = None, exclude: Ids | None = None) -> list[list[int]]

The places where a merge would make one node.

Each place is a list of the node ids that coincide there, in ascending order, so the first is the node that stays. Merge Coincident Nodes marks these places in the view before anything is merged, so some of them can be left out.

Parameters:

Name Type Description Default
tolerance float

How close two nodes must be to be one, in the stored length unit: meters once units are defined.

required
groups sequence of int

Only the nodes used by these element groups' elements, by id; every node when omitted.

None
exclude sequence of int

Nodes left out, by id: they are not merged and nothing is merged into them.

None

Returns:

Type Description
list of list of int

One list per place, sorted by the node each keeps.

Source code in src/visualdynamics/core/geometry.py
def coincident_spots(self, tolerance: float, groups: Ids | None = None,
                     exclude: Ids | None = None) -> list[list[int]]:
    """The places where a merge would make one node.

    Each place is a list of the node ids that coincide there, in
    ascending order, so the first is the node that stays. Merge
    Coincident Nodes marks these places in the view before anything
    is merged, so some of them can be left out.

    Parameters
    ----------
    tolerance : float
        How close two nodes must be to be one, in the stored length
        unit: meters once units are defined.
    groups : sequence of int, optional
        Only the nodes used by these element groups' elements, by id;
        every node when omitted.
    exclude : sequence of int, optional
        Nodes left out, by id: they are not merged and nothing is
        merged into them.

    Returns
    -------
    list of list of int
        One list per place, sorted by the node each keeps.
    """
    # marked a place at a time, for a person to leave some out
    # (Brandon, 2026-10-11)
    mapping = self.coincident_nodes(
        tolerance, self._merge_candidates(groups, exclude))
    spots: dict[int, list[int]] = {}
    for going, kept in mapping.items():
        spots.setdefault(int(kept), [int(kept)]).append(int(going))
    return [sorted(nodes) for _kept, nodes in sorted(spots.items())]
merge_folds
merge_folds(tolerance: float, groups: Ids | None = None, exclude: Ids | None = None) -> str | None

Why this merge would be refused, or None.

Returns the message merge_coincident_nodes raises when the tolerance would fold an element onto itself, so a panel can show it before the merge is applied.

Parameters:

Name Type Description Default
tolerance float

As merge_coincident_nodes.

required
groups sequence of int

As merge_coincident_nodes.

None
exclude sequence of int

As merge_coincident_nodes.

None

Returns:

Type Description
str or None
Source code in src/visualdynamics/core/geometry.py
def merge_folds(self, tolerance: float, groups: Ids | None = None,
                exclude: Ids | None = None) -> str | None:
    """Why this merge would be refused, or None.

    Returns the message `merge_coincident_nodes` raises when the
    tolerance would fold an element onto itself, so a panel can show
    it before the merge is applied.

    Parameters
    ----------
    tolerance : float
        As `merge_coincident_nodes`.
    groups : sequence of int, optional
        As `merge_coincident_nodes`.
    exclude : sequence of int, optional
        As `merge_coincident_nodes`.

    Returns
    -------
    str or None
    """
    return self._folding(self.coincident_nodes(
        tolerance, self._merge_candidates(groups, exclude)), tolerance)
merge_collapses
merge_collapses(tolerance: float, groups: Ids | None = None, exclude: Ids | None = None) -> list[int]

The ids of the line elements this merge would remove.

These are the line elements (a rigid link, a spring, a beam) whose nodes would all become one node, so they no longer join anything. Merging a joint built with rigid links removes the links.

Parameters:

Name Type Description Default
tolerance float

As merge_coincident_nodes.

required
groups sequence of int

As merge_coincident_nodes.

None
exclude sequence of int

As merge_coincident_nodes.

None

Returns:

Type Description
list of int
Source code in src/visualdynamics/core/geometry.py
def merge_collapses(self, tolerance: float, groups: Ids | None = None,
                    exclude: Ids | None = None) -> list[int]:
    """The ids of the line elements this merge would remove.

    These are the line elements (a rigid link, a spring, a beam)
    whose nodes would all become one node, so they no longer join
    anything. Merging a joint built with rigid links removes the
    links.

    Parameters
    ----------
    tolerance : float
        As `merge_coincident_nodes`.
    groups : sequence of int, optional
        As `merge_coincident_nodes`.
    exclude : sequence of int, optional
        As `merge_coincident_nodes`.

    Returns
    -------
    list of int
    """
    # The merge used to be refused for folding such links (Brandon,
    # 2026-10-11).
    return self._collapsed(self.coincident_nodes(
        tolerance, self._merge_candidates(groups, exclude)))
merge_coincident_nodes
merge_coincident_nodes(tolerance: float, groups: Ids | None = None, exclude: Ids | None = None) -> dict[str, int]

Merge nodes that are at one point into one node.

Nodes within tolerance of each other (coincident_nodes) become the one with the lowest id: every element that uses one of them is changed to use that node, and the others are removed. Plates are only connected where they share nodes, so this is what connects a model built from separate planes at its edges.

The merge is refused with ValueError, naming the element, when the tolerance would fold an element onto itself (two of its own nodes within the tolerance), since that means the tolerance is larger than the mesh. A line element whose nodes would all become one, such as a rigid link across the gap the merge closes, is removed instead (merge_collapses). Solid elements the merge leaves on exactly the same nodes fill one cell twice, and the duplicates are removed (merge_duplicate_elements).

With groups, only those element groups are merged: the nodes their elements use are the candidates, so two groups picked together are joined to each other and to nothing else, and only their duplicate elements are removed. A merge over the whole geometry joins every part to every part it touches, which is usually more than intended; the app offers the merge on element groups only.

Parameters:

Name Type Description Default
tolerance float

How close two nodes must be to be one, in the stored length unit: meters once units are defined.

required
groups sequence of int

The element groups whose nodes are merged, by id. Every node of the geometry when omitted.

None
exclude sequence of int

Nodes left out of the merge, by id: they are not merged and nothing is merged into them. In the app, these are the places that were unpicked (coincident_spots).

None

Returns:

Type Description
dict of str to int

'merged', the number of nodes removed; 'into', the number of nodes they became; 'duplicates', the number of elements removed for filling a cell another already filled; and 'links', the number of line elements removed because their nodes became one.

Source code in src/visualdynamics/core/geometry.py
def merge_coincident_nodes(self, tolerance: float,
                           groups: Ids | None = None,
                           exclude: Ids | None = None) -> dict[str, int]:
    """Merge nodes that are at one point into one node.

    Nodes within `tolerance` of each other (`coincident_nodes`)
    become the one with the lowest id: every element that uses one of
    them is changed to use that node, and the others are removed.
    Plates are only connected where they share nodes, so this is what
    connects a model built from separate planes at its edges.

    The merge is refused with ValueError, naming the element, when
    the tolerance would fold an element onto itself (two of its own
    nodes within the tolerance), since that means the tolerance is
    larger than the mesh. A line element whose nodes would all become
    one, such as a rigid link across the gap the merge closes, is
    removed instead (`merge_collapses`). Solid elements the merge
    leaves on exactly the same nodes fill one cell twice, and the
    duplicates are removed (`merge_duplicate_elements`).

    With `groups`, only those element groups are merged: the nodes
    their elements use are the candidates, so two groups picked
    together are joined to each other and to nothing else, and only
    their duplicate elements are removed. A merge over the whole
    geometry joins every part to every part it touches, which is
    usually more than intended; the app offers the merge on element
    groups only.

    Parameters
    ----------
    tolerance : float
        How close two nodes must be to be one, in the stored length
        unit: meters once units are defined.
    groups : sequence of int, optional
        The element groups whose nodes are merged, by id. Every node
        of the geometry when omitted.
    exclude : sequence of int, optional
        Nodes left out of the merge, by id: they are not merged and
        nothing is merged into them. In the app, these are the
        places that were unpicked (`coincident_spots`).

    Returns
    -------
    dict of str to int
        'merged', the number of nodes removed; 'into', the number of
        nodes they became; 'duplicates', the number of elements
        removed for filling a cell another already filled; and
        'links', the number of line elements removed because their
        nodes became one.
    """
    # The app offers the merge on element groups only (Brandon,
    # 2026-10-11).
    mapping = self.coincident_nodes(
        tolerance, self._merge_candidates(groups, exclude))
    if not mapping:
        duplicates = self.merge_duplicate_elements(groups)
        return {'merged': 0, 'into': 0, 'duplicates': duplicates,
                'links': 0}
    refusal = self._folding(mapping, tolerance)
    if refusal is not None:
        raise ValueError(refusal)
    links = self._collapsed(mapping)
    self.delete_elements(links)
    self.elem_conn = [np.asarray([mapping.get(int(n), int(n))
                                  for n in conn], dtype=np.int64)
                      for conn in self.elem_conn]
    keep = ~np.isin(self.node_id, list(mapping))
    for name in ('node_id', 'node_def_cs', 'node_disp_cs', 'node_color'):
        setattr(self, name, getattr(self, name)[keep])
    self.node_xyz = self.node_xyz[keep]
    self._mates_follow(nodes=mapping)
    duplicates = self.merge_duplicate_elements(groups)
    self.validate()
    return {'merged': len(mapping), 'into': len(set(mapping.values())),
            'duplicates': duplicates, 'links': len(links)}
align_reach
align_reach(groups: Ids | None = None) -> float | None

The default reach of Align Meshes: half the mesh size of the coarsest piece.

A piece is a set of elements connected by shared nodes. With this reach no node moves more than half an element of its own mesh. Parts with a gap between them need a larger reach, by the size of the gap.

Parameters:

Name Type Description Default
groups sequence of int

The element groups, by id; every element when omitted.

None

Returns:

Type Description
float or None

In the stored length unit; None when no element has a nonzero size.

Source code in src/visualdynamics/core/geometry.py
def align_reach(self, groups: Ids | None = None) -> float | None:
    """The default reach of Align Meshes: half the mesh size of the
    coarsest piece.

    A piece is a set of elements connected by shared nodes. With this
    reach no node moves more than half an element of its own mesh.
    Parts with a gap between them need a larger reach, by the size of
    the gap.

    Parameters
    ----------
    groups : sequence of int, optional
        The element groups, by id; every element when omitted.

    Returns
    -------
    float or None
        In the stored length unit; None when no element has a
        nonzero size.
    """
    spacings = [self._typical_spacing(rows) for rows in self._parts(groups)]
    spacings = [s for s in spacings if s]
    return 0.5 * max(spacings) if spacings else None
align_moves
align_moves(reach: float, groups: Ids | None = None, exclude: Ids | None = None) -> list[list[int]]

The moves Align Meshes would make, each [node, onto]: a node of a coarser piece and the node of a finer piece it would move onto, so the two are at one point and can be merged with Merge Coincident Nodes.

A piece is a set of elements connected by shared nodes. Two pieces meshed at different sizes cannot be matched node for node, since their node counts differ, but each node of the coarser piece can be moved onto the nearest node of the finer one. No node moves more than reach. The rules:

  • Only nodes on the outside of the pieces move or are moved onto.
  • A node moves once, and onto a node no other node moves onto.
  • A node already at another node's position is a place to merge, not a move.
  • The nearest pairs are taken first. As a result, a washer's lower face moves onto the frame under it, and its upper face, whose nearest frame nodes are already taken, stays.
  • Of two pieces with the same mesh size, the later piece moves.
  • A move that would turn an element inside out is left out.

Parameters:

Name Type Description Default
reach float

How far a node may move, in the stored length unit.

required
groups sequence of int

The element groups whose pieces are aligned, by id; every element when omitted.

None
exclude sequence of int

Nodes left out, by id: they are not moved and nothing is moved onto them. In the app, these are the moves that were unpicked.

None

Returns:

Type Description
list of [int, int]

Sorted by the node moved.

Source code in src/visualdynamics/core/geometry.py
def align_moves(self, reach: float, groups: Ids | None = None,
                exclude: Ids | None = None) -> list[list[int]]:
    """The moves Align Meshes would make, each ``[node, onto]``: a node
    of a coarser piece and the node of a finer piece it would move
    onto, so the two are at one point and can be merged with Merge
    Coincident Nodes.

    A piece is a set of elements connected by shared nodes. Two
    pieces meshed at different sizes cannot be matched node for node,
    since their node counts differ, but each node of the coarser
    piece can be moved onto the nearest node of the finer one. No
    node moves more than `reach`. The rules:

    - Only nodes on the outside of the pieces move or are moved onto.
    - A node moves once, and onto a node no other node moves onto.
    - A node already at another node's position is a place to merge,
      not a move.
    - The nearest pairs are taken first. As a result, a washer's
      lower face moves onto the frame under it, and its upper face,
      whose nearest frame nodes are already taken, stays.
    - Of two pieces with the same mesh size, the later piece moves.
    - A move that would turn an element inside out is left out.

    Parameters
    ----------
    reach : float
        How far a node may move, in the stored length unit.
    groups : sequence of int, optional
        The element groups whose pieces are aligned, by id; every
        element when omitted.
    exclude : sequence of int, optional
        Nodes left out, by id: they are not moved and nothing is
        moved onto them. In the app, these are the moves that were
        unpicked.

    Returns
    -------
    list of [int, int]
        Sorted by the node moved.
    """
    # Brandon, 2026-10-11, for the Frame-Wing, whose wing is meshed at
    # an eighth of an inch on a frame meshed at a twelfth.
    from scipy.spatial import cKDTree

    parts = self._parts(groups)
    if len(parts) < 2:
        return []
    left_out = {int(n) for n in exclude or ()}
    skins = [sorted(self._skin(rows) - left_out) for rows in parts]
    spacing = [self._typical_spacing(rows) or 0.0 for rows in parts]
    low, high = self.extent if self.num_nodes else (np.zeros(3), np.zeros(3))
    # already one point: a place to merge, not a move
    touching = 1e-9 * float(np.linalg.norm(high - low) or 1.0)
    found = []
    for a, movers in enumerate(skins):
        for b, targets in enumerate(skins):
            finer = spacing[b] < spacing[a] or (spacing[b] == spacing[a]
                                                and b < a)
            if a == b or not finer or not movers or not targets:
                continue
            tree = cKDTree(self.node_xyz[self.node_index(targets)])
            distance, nearest = tree.query(
                self.node_xyz[self.node_index(movers)],
                distance_upper_bound=float(reach))
            for mover, d, j in zip(movers, distance, nearest):
                if touching < d <= reach:
                    found.append((float(d), mover, targets[int(j)]))
    moves, moving, onto = [], set(), set()
    for _d, mover, target in sorted(found):
        if (mover in moving or mover in onto or target in onto
                or target in moving):
            continue
        moves.append([mover, target])
        moving.add(mover)
        onto.add(target)
    return sorted(self._unfolded(moves))
align_folds
align_folds(moves) -> str | None

Why these moves would be refused, or None.

Returns the message align_meshes raises when a move would turn an element inside out (a solid's corner volume or a plate's normal changing sign), so a panel can show it before the moves are applied.

Parameters:

Name Type Description Default
moves sequence of [int, int]

As align_moves gives them.

required

Returns:

Type Description
str or None
Source code in src/visualdynamics/core/geometry.py
def align_folds(self, moves) -> str | None:
    """Why these moves would be refused, or None.

    Returns the message `align_meshes` raises when a move would turn
    an element inside out (a solid's corner volume or a plate's
    normal changing sign), so a panel can show it before the moves
    are applied.

    Parameters
    ----------
    moves : sequence of [int, int]
        As `align_moves` gives them.

    Returns
    -------
    str or None
    """
    if not moves:
        return None
    moved = {int(a): int(b) for a, b in moves}
    after = self.node_xyz.copy()
    rows = self.node_index(list(moved))
    after[rows] = self.node_xyz[self.node_index(list(moved.values()))]
    # the elements a move touches, found a node count at a time: a
    # loop over every element took a third of a second on the
    # Frame-Wing, on every click in the pane
    touched = []
    by_count: dict[int, list[int]] = {}
    for k, conn in enumerate(self.elem_conn):
        by_count.setdefault(len(conn), []).append(k)
    names = np.fromiter(moved, dtype=np.int64)
    for count, ks in by_count.items():
        conns = np.asarray([self.elem_conn[k] for k in ks]).reshape(len(ks), count)
        hit = np.isin(conns, names).any(axis=1)
        touched.extend(np.asarray(ks)[hit].tolist())
    touched.sort()
    # their rows in one lookup: `node_index` sorts every id each call
    flat = self.node_index(np.concatenate(
        [self.elem_conn[k] for k in touched])) if touched else []
    starts = np.cumsum([0] + [len(self.elem_conn[k]) for k in touched])
    for i, k in enumerate(touched):
        conn = self.elem_conn[k]
        index = flat[starts[i]:starts[i + 1]]
        if _turned(int(self.elem_type[k]), self.node_xyz[index],
                   after[index]):
            return (f'moving node {next(int(n) for n in conn if int(n) in moved)}'
                    f' turns element {int(self.elem_id[k])} inside out; '
                    'leave that move out or use a smaller reach')
    return None
align_meshes
align_meshes(reach: float, groups: Ids | None = None, exclude: Ids | None = None) -> dict[str, float]

Move the nodes of the coarser piece onto the nodes of the finer piece where they meet (align_moves), so Merge Coincident Nodes can join them.

This is for parts meshed separately at sizes that do not match: it makes their nodes coincide at their mating faces or where they overlap. Nothing is merged and no node is removed. A move that would turn an element inside out is left out by align_moves; the call raises ValueError, naming the element, only when the nodes left out with exclude make one of the other moves do so (align_folds).

Parameters:

Name Type Description Default
reach float

How far a node may move, in the stored length unit: meters once units are defined.

required
groups sequence of int

The element groups whose pieces are aligned, by id; every element of the geometry when omitted.

None
exclude sequence of int

Nodes left out, by id: neither moved nor moved onto.

None

Returns:

Type Description
dict

'moved', how many nodes moved; 'farthest', the longest move, in the stored length unit.

Source code in src/visualdynamics/core/geometry.py
def align_meshes(self, reach: float, groups: Ids | None = None,
                 exclude: Ids | None = None) -> dict[str, float]:
    """Move the nodes of the coarser piece onto the nodes of the finer
    piece where they meet (`align_moves`), so Merge Coincident Nodes
    can join them.

    This is for parts meshed separately at sizes that do not match:
    it makes their nodes coincide at their mating faces or where they
    overlap. Nothing is merged and no node is removed. A move that
    would turn an element inside out is left out by `align_moves`;
    the call raises ValueError, naming the element, only when the
    nodes left out with `exclude` make one of the other moves do so
    (`align_folds`).

    Parameters
    ----------
    reach : float
        How far a node may move, in the stored length unit: meters
        once units are defined.
    groups : sequence of int, optional
        The element groups whose pieces are aligned, by id; every
        element of the geometry when omitted.
    exclude : sequence of int, optional
        Nodes left out, by id: neither moved nor moved onto.

    Returns
    -------
    dict
        'moved', how many nodes moved; 'farthest', the longest move,
        in the stored length unit.
    """
    moves = self.align_moves(reach, groups, exclude)
    if not moves:
        return {'moved': 0, 'farthest': 0.0}
    refusal = self.align_folds(moves)
    if refusal is not None:
        raise ValueError(refusal)
    rows = self.node_index([a for a, _b in moves])
    targets = self.node_xyz[self.node_index([b for _a, b in moves])]
    farthest = float(np.linalg.norm(targets - self.node_xyz[rows],
                                    axis=1).max())
    self.node_xyz[rows] = targets
    return {'moved': len(moves), 'farthest': farthest}
delete_coordinate_systems
delete_coordinate_systems(cs_ids: Ids) -> dict[str, int]

Remove coordinate systems, and move the nodes that used them to the first remaining system.

Ids that are not present are ignored. Removing every coordinate system is refused with ValueError, since every node needs one.

Parameters:

Name Type Description Default
cs_ids int or sequence of int

The identifiers, one or many.

required

Returns:

Type Description
dict of str to int

'coordinate_systems', the number removed, and 'nodes_reassigned', the number of node placement and displacement references moved to the remaining system.

Source code in src/visualdynamics/core/geometry.py
def delete_coordinate_systems(self, cs_ids: Ids) -> dict[str, int]:
    """Remove coordinate systems, and move the nodes that used them to
    the first remaining system.

    Ids that are not present are ignored. Removing every coordinate
    system is refused with ValueError, since every node needs one.

    Parameters
    ----------
    cs_ids : int or sequence of int
        The identifiers, one or many.

    Returns
    -------
    dict of str to int
        'coordinate_systems', the number removed, and
        'nodes_reassigned', the number of node placement and
        displacement references moved to the remaining system.
    """
    wanted = {int(cs) for cs in cs_ids}
    keep = ~np.isin(self.cs_id, list(wanted))
    if keep.sum() == 0:
        raise ValueError('a geometry needs at least one coordinate system')
    removed = int((~keep).sum())
    if not removed:
        return {'coordinate_systems': 0, 'nodes_reassigned': 0}

    fallback = int(self.cs_id[keep][0])
    reassigned = 0
    for name in ('node_def_cs', 'node_disp_cs'):
        array = getattr(self, name)
        stale = np.isin(array, list(wanted))
        reassigned += int(stale.sum())
        array[stale] = fallback
    self.cs_id = self.cs_id[keep]
    self.cs_type = self.cs_type[keep]
    self.cs_matrix = self.cs_matrix[keep]
    self.cs_name = [name for name, k in zip(self.cs_name, keep) if k]
    return {'coordinate_systems': removed, 'nodes_reassigned': reassigned}
delete_groups
delete_groups(group_ids: Ids, keep: Ids = ()) -> dict[str, int]

Remove element groups with their elements, their properties, and the nodes no element outside them uses.

A node that an element of another element group also uses stays, so a neighboring part is not cut along the nodes the two share. Ids that are not present are ignored. To combine element groups instead, use merge_groups.

Parameters:

Name Type Description Default
group_ids int or sequence of int

The identifiers, one or many.

required
keep sequence of int

Nodes that stay whatever uses them (delete_elements).

()

Returns:

Type Description
dict of str to int

'groups', 'elements' and 'nodes', the number of each removed.

Source code in src/visualdynamics/core/geometry.py
def delete_groups(self, group_ids: Ids, keep: Ids = ()) -> dict[str, int]:
    """Remove element groups with their elements, their properties, and
    the nodes no element outside them uses.

    A node that an element of another element group also uses stays,
    so a neighboring part is not cut along the nodes the two share.
    Ids that are not present are ignored. To combine element groups
    instead, use `merge_groups`.

    Parameters
    ----------
    group_ids : int or sequence of int
        The identifiers, one or many.
    keep : sequence of int, optional
        Nodes that stay whatever uses them (`delete_elements`).

    Returns
    -------
    dict of str to int
        'groups', 'elements' and 'nodes', the number of each removed.
    """
    # Deleting a part deletes the part (Brandon, 2026-09-27). It used
    # to move a deleted element group's elements into the first
    # element group left, which made a delete a merge.
    wanted = {int(group) for group in group_ids}
    staying = ~np.isin(self.group_id, list(wanted))
    removed = int((~staying).sum())
    if not removed:
        return {'groups': 0, 'elements': 0, 'nodes': 0}
    doomed = [int(e) for e, group in zip(self.elem_id, self.elem_group)
              if int(group) in wanted]
    gone = self.delete_elements(doomed, nodes=True, keep=keep)
    self.group_name = [name for name, k in zip(self.group_name, staying) if k]
    for group in wanted:                 # a deleted element group's properties go with it
        self.group_properties.pop(group, None)
    self.group_id = self.group_id[staying]
    self._mates_follow(groups={group: None for group in wanted})
    return {'groups': removed, 'elements': len(doomed),
            'nodes': gone['nodes']}
merge_refusal
merge_refusal(group_ids: Ids) -> str | None

Why these element groups cannot be merged, or None when they can.

There must be at least two, all in the geometry, holding the same element types and with the same properties. A merged element group has one material and one thickness or section, so merging groups that differ would change the model without saying so.

Parameters:

Name Type Description Default
group_ids sequence of int

The element groups, by identifier.

required

Returns:

Type Description
str or None
Source code in src/visualdynamics/core/geometry.py
def merge_refusal(self, group_ids: Ids) -> str | None:
    """Why these element groups cannot be merged, or None when they can.

    There must be at least two, all in the geometry, holding the same
    element types and with the same properties. A merged element group
    has one material and one thickness or section, so merging groups
    that differ would change the model without saying so.

    Parameters
    ----------
    group_ids : sequence of int
        The element groups, by identifier.

    Returns
    -------
    str or None
    """
    ids = list(dict.fromkeys(int(b) for b in group_ids))
    if len(ids) < 2:
        return 'select two element groups or more to merge'
    missing = [b for b in ids if b not in self.group_id.tolist()]
    if missing:
        return f'no element group {missing[0]}'
    kinds = {frozenset(int(t) for t in
                       self.elem_type[self.elem_group == group])
             for group in ids}
    if len(kinds) > 1:
        return 'the element groups hold different element types'
    held = [self.group_properties.get(group) for group in ids]
    if any(other != held[0] for other in held[1:]):
        return ('the element groups differ in material, thickness or section')
    return None
merge_groups
merge_groups(group_ids: Ids) -> dict[str, int]

Merge several element groups into the first one (Merge Element Groups).

The elements of the others are moved into the first group, and the others are removed with their properties. Raises ValueError with the reason from merge_refusal when the groups cannot be merged.

Parameters:

Name Type Description Default
group_ids sequence of int

The element groups; the first keeps its id, name and properties.

required

Returns:

Type Description
dict of str to int

'into', the id of the element group kept; 'groups', how many were merged into it; 'elements', how many elements moved.

Source code in src/visualdynamics/core/geometry.py
def merge_groups(self, group_ids: Ids) -> dict[str, int]:
    """Merge several element groups into the first one (Merge Element
    Groups).

    The elements of the others are moved into the first group, and
    the others are removed with their properties. Raises ValueError
    with the reason from `merge_refusal` when the groups cannot be
    merged.

    Parameters
    ----------
    group_ids : sequence of int
        The element groups; the first keeps its id, name and properties.

    Returns
    -------
    dict of str to int
        'into', the id of the element group kept; 'groups', how many
        were merged into it; 'elements', how many elements moved.
    """
    # Merge Element Groups (Brandon, 2026-09-27)
    reason = self.merge_refusal(group_ids)
    if reason is not None:
        raise ValueError(reason)
    ids = list(dict.fromkeys(int(b) for b in group_ids))
    into, others = ids[0], set(ids[1:])
    moved = np.isin(self.elem_group, list(others))
    self.elem_group[moved] = into
    keep = ~np.isin(self.group_id, list(others))
    self.group_name = [name for name, k in zip(self.group_name, keep) if k]
    for group in others:
        self.group_properties.pop(group, None)
    self.group_id = self.group_id[keep]
    self._mates_follow(groups={group: into for group in others})
    return {'into': into, 'groups': len(others),
            'elements': int(moved.sum())}
delete_elements
delete_elements(elem_ids: Ids, nodes: bool = False, keep: Ids = ()) -> dict[str, int]

Remove elements by id. Ids that are not present are ignored.

With nodes, the nodes used only by these elements are removed too, which is what deleting an element group (delete_groups) and deleting elements in the app do. A node another element uses stays, and so does a node in keep: the project keeps the nodes its data is measured or solved at (Project.delete_elements), so redrawing a test geometry's panels leaves its measurement points. nodes is off by default because the merge removes the links it collapses and then remaps their nodes.

Parameters:

Name Type Description Default
elem_ids int or sequence of int

The identifiers, one or many.

required
nodes bool

Also remove the nodes only these elements used.

False
keep sequence of int

Nodes never removed that way.

()

Returns:

Type Description
dict of str to int

'elements', the number removed, and with nodes, 'nodes', the number of nodes removed.

Source code in src/visualdynamics/core/geometry.py
def delete_elements(self, elem_ids: Ids, nodes: bool = False,
                    keep: Ids = ()) -> dict[str, int]:
    """Remove elements by id. Ids that are not present are ignored.

    With `nodes`, the nodes used only by these elements are removed
    too, which is what deleting an element group (`delete_groups`)
    and deleting elements in the app do. A node another element uses
    stays, and so does a node in `keep`: the project keeps the nodes
    its data is measured or solved at (`Project.delete_elements`), so
    redrawing a test geometry's panels leaves its measurement points.
    `nodes` is off by default because the merge removes the links it
    collapses and then remaps their nodes.

    Parameters
    ----------
    elem_ids : int or sequence of int
        The identifiers, one or many.
    nodes : bool, default False
        Also remove the nodes only these elements used.
    keep : sequence of int, optional
        Nodes never removed that way.

    Returns
    -------
    dict of str to int
        'elements', the number removed, and with `nodes`, 'nodes',
        the number of nodes removed.
    """
    # Without `nodes`, a part's bricks deleted from the Hexes row left
    # every node of the part in place (Brandon, 2026-10-12).
    if nodes:
        doomed = {int(e) for e in np.atleast_1d(elem_ids)}
        inside, outside = set(), set()
        for element, conn in zip(self.elem_id, self.elem_conn):
            (inside if int(element) in doomed else outside).update(
                int(n) for n in conn)
        report = self.delete_elements(sorted(doomed))
        spared = {int(n) for n in np.atleast_1d(keep)}
        gone = self.delete_nodes(sorted(inside - outside - spared))
        return {**report, 'nodes': gone['nodes']}
    rows = self._rows_for(elem_ids, self.elem_id)
    keep = np.ones(len(self.elem_id), dtype=bool)
    keep[rows] = False
    # rebuilt once: deleting row by row shifted every later row each
    # time, quadratic, and meshing a part again deletes its whole mesh
    self.elem_conn = [conn for conn, kept in zip(self.elem_conn, keep) if kept]
    self.elem_id = self.elem_id[keep]
    self.elem_type = self.elem_type[keep]
    self.elem_color = self.elem_color[keep]
    self.elem_group = self.elem_group[keep]
    # An element group whose last element has gone is still an element group: exodus
    # files carry empty ones, and forgetting the name would lose on a
    # round trip exactly what element groups were added to keep.
    return {'elements': len(rows)}
save
save(path: str | PathLike) -> None

Write the geometry to a file of its own.

Parameters:

Name Type Description Default
path str or PathLike

Where to write it.

required

Returns:

Type Description
None
Source code in src/visualdynamics/core/geometry.py
def save(self, path: str | os.PathLike) -> None:
    """Write the geometry to a file of its own.

    Parameters
    ----------
    path : str or os.PathLike
        Where to write it.

    Returns
    -------
    None
    """
    from ..io import native
    native.save(self, path)
load classmethod
load(path: str | PathLike) -> Geometry

Read a geometry written by save.

Raises TypeError when the file holds something other than a geometry.

Source code in src/visualdynamics/core/geometry.py
@classmethod
def load(cls, path: str | os.PathLike) -> Geometry:
    """Read a geometry written by `save`.

    Raises TypeError when the file holds something other than a
    geometry.
    """
    from ..io import native
    obj = native.load(path)
    if not isinstance(obj, cls):
        raise TypeError(f"{path} does not contain a geometry")
    return obj
plot
plot(unit_system: UnitSystem | None = None, **kwargs: Any) -> Any

Draw the geometry's nodes and elements.

Parameters:

Name Type Description Default
unit_system UnitSystem

Units to draw in.

None
**kwargs Any

Passed through to the scene.

{}

Returns:

Type Description
object

The plot widget or plotter.

Source code in src/visualdynamics/core/geometry.py
def plot(self, unit_system: UnitSystem | None = None,
         **kwargs: Any) -> Any:
    """Draw the geometry's nodes and elements.

    Parameters
    ----------
    unit_system : UnitSystem, optional
        Units to draw in.
    **kwargs
        Passed through to the scene.

    Returns
    -------
    object
        The plot widget or plotter.
    """
    from ..viz.geometry import plot_geometry
    return plot_geometry(self, unit_system=unit_system, **kwargs)
plot_dofs
plot_dofs(source: Any, quantity: str, unit_system: UnitSystem | None = None, **kwargs: Any) -> Any

Draw this geometry with a labeled arrow at every DOF where source has quantity, as the app's DOF arrows do.

Parameters:

Name Type Description Default
source DataArray

The object whose degrees of freedom are drawn.

required
quantity str

Which quantity's DOFs to show, such as 'acceleration'.

required
unit_system UnitSystem

Units to draw in.

None
**kwargs Any

Passed through to the scene.

{}

Returns:

Type Description
object

The plotter the scene is in.

Source code in src/visualdynamics/core/geometry.py
def plot_dofs(self, source: Any, quantity: str,
              unit_system: UnitSystem | None = None,
              **kwargs: Any) -> Any:
    """Draw this geometry with a labeled arrow at every DOF where
    `source` has `quantity`, as the app's DOF arrows do.

    Parameters
    ----------
    source : DataArray
        The object whose degrees of freedom are drawn.
    quantity : str
        Which quantity's DOFs to show, such as 'acceleration'.
    unit_system : UnitSystem, optional
        Units to draw in.
    **kwargs
        Passed through to the scene.

    Returns
    -------
    object
        The plotter the scene is in.
    """
    from ..viz.geometry import plot_dofs
    return plot_dofs(self, source, quantity, unit_system=unit_system,
                     **kwargs)
plot_channels
plot_channels(table: Any, role: str = 'all', unit_system: UnitSystem | None = None, **kwargs: Any) -> Any

Draw this geometry with a labeled arrow at each channel's DOF in a channel table, as the app does when the two are selected together.

A channel with no node or direction yet, or at a node this geometry does not have, has no arrow.

Parameters:

Name Type Description Default
table ChannelTable

The channels to draw.

required
role str

'reference', 'response' or 'monitor' to draw only the channels in that role, 'control' for the channels with Control checked, or 'all' for every channel.

'all'
unit_system UnitSystem

Units to draw in.

None
**kwargs Any

Passed through to the scene.

{}

Returns:

Type Description
object

The plotter the scene is in.

Source code in src/visualdynamics/core/geometry.py
def plot_channels(self, table: Any, role: str = 'all',
                  unit_system: UnitSystem | None = None,
                  **kwargs: Any) -> Any:
    """Draw this geometry with a labeled arrow at each channel's DOF
    in a channel table, as the app does when the two are selected
    together.

    A channel with no node or direction yet, or at a node this
    geometry does not have, has no arrow.

    Parameters
    ----------
    table : ChannelTable
        The channels to draw.
    role : str, default 'all'
        'reference', 'response' or 'monitor' to draw only the
        channels in that role, 'control' for the channels with
        Control checked, or 'all' for every channel.
    unit_system : UnitSystem, optional
        Units to draw in.
    **kwargs
        Passed through to the scene.

    Returns
    -------
    object
        The plotter the scene is in.
    """
    # Brandon, 2026-10-22: a channel table and a geometry selected
    # together draw the table's DOFs, picked by role.
    from ..viz.geometry import plot_channels
    return plot_channels(self, table, role, unit_system=unit_system,
                         **kwargs)

Functions:

element_family

element_family(code: int) -> str

Which family a 2412 element code belongs to.

Parameters:

Name Type Description Default
code int

The element type code.

required

Returns:

Type Description
str

One of FAMILIES, or 'points' or 'pyramids' (RARE_FAMILIES).

Source code in src/visualdynamics/core/geometry.py
def element_family(code: int) -> str:
    """Which family a 2412 element code belongs to.

    Parameters
    ----------
    code : int
        The element type code.

    Returns
    -------
    str
        One of `FAMILIES`, or 'points' or 'pyramids' (`RARE_FAMILIES`).
    """
    _name, count, shape = ELEMENT_TYPES[int(code)]
    if shape == 'line':
        return 'beams'
    if shape == 'point':
        return 'points'
    if shape == 'face':
        return _FACE_FAMILIES[count]
    return _VOLUME_FAMILIES[count]

placed

placed(local: ArrayLike, kind: int, matrix: ArrayLike) -> ndarray

A point given in a coordinate system, converted to global cartesian.

kind is the system's CS_TYPES code and matrix its (4, 3) rows: three directions, then the origin. A cylindrical system gives (r, theta, z) and a spherical one (r, theta from +z, phi), with the angles in degrees in both, which is the convention of the universal file, Nastran's CORD2C and CORD2S, and sdynpy.

Source code in src/visualdynamics/core/geometry.py
def placed(local: ArrayLike, kind: int, matrix: ArrayLike) -> np.ndarray:
    """A point given in a coordinate system, converted to global cartesian.

    `kind` is the system's `CS_TYPES` code and `matrix` its `(4, 3)`
    rows: three directions, then the origin. A cylindrical system gives
    (r, theta, z) and a spherical one (r, theta from +z, phi), with the
    angles in degrees in both, which is the convention of the universal
    file, Nastran's CORD2C and CORD2S, and sdynpy.
    """
    local = np.asarray(local, dtype=float)
    matrix = np.asarray(matrix, dtype=float)
    if int(kind) == 1:
        r, theta, z = local
        theta = np.radians(theta)
        local = np.array([r * np.cos(theta), r * np.sin(theta), z])
    elif int(kind) == 2:
        r, theta, phi = local
        theta, phi = np.radians(theta), np.radians(phi)
        local = np.array([r * np.sin(theta) * np.cos(phi),
                          r * np.sin(theta) * np.sin(phi),
                          r * np.cos(theta)])
    return matrix[3] + local @ matrix[:3]

written_in

written_in(point: ArrayLike, kind: int, matrix: ArrayLike) -> ndarray

The inverse of placed: a global cartesian point in the coordinates of a system, so a file can be written with its nodes in the systems they were read in.

Source code in src/visualdynamics/core/geometry.py
def written_in(point: ArrayLike, kind: int, matrix: ArrayLike) -> np.ndarray:
    """The inverse of `placed`: a global cartesian point in the
    coordinates of a system, so a file can be written with its nodes in
    the systems they were read in."""
    point = np.asarray(point, dtype=float)
    matrix = np.asarray(matrix, dtype=float)
    local = (point - matrix[3]) @ matrix[:3].T
    if int(kind) == 1:
        x, y, z = local
        return np.array([np.hypot(x, y), np.degrees(np.arctan2(y, x)), z])
    if int(kind) == 2:
        x, y, z = local
        r = float(np.sqrt(x * x + y * y + z * z))
        theta = np.degrees(np.arccos(z / r)) if r else 0.0
        return np.array([r, theta, np.degrees(np.arctan2(y, x))])
    return local

to_global

to_global(node_xyz: ArrayLike, node_def_cs: ArrayLike, cs_id: Ids, cs_type: ArrayLike, cs_matrix: ArrayLike) -> ndarray

Node coordinates given in each node's own placement system, converted to global cartesian, which is what Geometry stores.

A file gives each node's position in the coordinate system the node is placed in, its def_cs. Reading those numbers as global would put the node in the wrong place. A node whose system is not among the given systems, or is the global system, keeps its coordinates as written.

Parameters:

Name Type Description Default
node_xyz array - like

(nodes, 3) as the source wrote them.

required
node_def_cs array - like

The frame each node is placed in.

required
cs_id array - like

The frames themselves, as Geometry holds them.

required
cs_type array - like

The frames themselves, as Geometry holds them.

required
cs_matrix array - like

The frames themselves, as Geometry holds them.

required

Returns:

Type Description
ndarray

(nodes, 3) in the global cartesian frame.

Source code in src/visualdynamics/core/geometry.py
def to_global(node_xyz: ArrayLike, node_def_cs: ArrayLike, cs_id: Ids,
              cs_type: ArrayLike, cs_matrix: ArrayLike) -> np.ndarray:
    """Node coordinates given in each node's own placement system,
    converted to global cartesian, which is what `Geometry` stores.

    A file gives each node's position in the coordinate system the node
    is placed in, its `def_cs`. Reading those numbers as global would put
    the node in the wrong place. A node whose system is not among the
    given systems, or is the global system, keeps its coordinates as
    written.

    Parameters
    ----------
    node_xyz : array-like
        `(nodes, 3)` as the source wrote them.
    node_def_cs : array-like
        The frame each node is placed in.
    cs_id, cs_type, cs_matrix : array-like
        The frames themselves, as `Geometry` holds them.

    Returns
    -------
    numpy.ndarray
        `(nodes, 3)` in the global cartesian frame.
    """
    # A geometry with local placement systems drew with every node in
    # the wrong place while the file's numbers were read as global
    # (Brandon, 2026-09-20).
    xyz = np.asarray(node_xyz, dtype=float).reshape(-1, 3)
    frames = _frames(cs_id, cs_type, cs_matrix)
    out = xyz.copy()
    for k, code in enumerate(np.asarray(node_def_cs).ravel()[:len(xyz)]):
        found = frames.get(int(code))
        if found is not None:
            out[k] = placed(xyz[k], found[0], found[1])
    return out

to_local

to_local(node_xyz: ArrayLike, node_def_cs: ArrayLike, cs_id: Ids, cs_type: ArrayLike, cs_matrix: ArrayLike) -> ndarray

The inverse of to_global, for a writer that gives each node in the coordinate system it is placed in.

Source code in src/visualdynamics/core/geometry.py
def to_local(node_xyz: ArrayLike, node_def_cs: ArrayLike, cs_id: Ids,
             cs_type: ArrayLike, cs_matrix: ArrayLike) -> np.ndarray:
    """The inverse of `to_global`, for a writer that gives each node in
    the coordinate system it is placed in."""
    xyz = np.asarray(node_xyz, dtype=float).reshape(-1, 3)
    frames = _frames(cs_id, cs_type, cs_matrix)
    out = xyz.copy()
    for k, code in enumerate(np.asarray(node_def_cs).ravel()[:len(xyz)]):
        found = frames.get(int(code))
        if found is not None:
            out[k] = written_in(xyz[k], found[0], found[1])
    return out

reconcile_type

reconcile_type(code: int, node_count: int) -> int

The descriptor that matches the number of nodes an element has, when the file's descriptor and node count disagree.

A file gives both, and they can contradict each other. The node count is taken as correct, and a descriptor with the wrong count is replaced by the one in the same family with that many nodes: 41 (a plane-stress triangle) with four nodes becomes 44, the plane-stress quadrilateral. Dataset 2412 numbers each family within one decade, triangles before quadrilaterals, which is how the replacement is found. When no descriptor in the family fits, the original is kept, since an element that cannot be identified is better kept as written than renamed to a guess.

Parameters:

Name Type Description Default
code int

The descriptor the file gave.

required
node_count int

How many nodes the file actually listed for it.

required

Returns:

Type Description
int

The descriptor to use.

Source code in src/visualdynamics/core/geometry.py
def reconcile_type(code: int, node_count: int) -> int:
    """The descriptor that matches the number of nodes an element has,
    when the file's descriptor and node count disagree.

    A file gives both, and they can contradict each other. The node
    count is taken as correct, and a descriptor with the wrong count is
    replaced by the one in the same family with that many nodes: 41 (a
    plane-stress triangle) with four nodes becomes 44, the plane-stress
    quadrilateral. Dataset 2412 numbers each family within one decade,
    triangles before quadrilaterals, which is how the replacement is
    found. When no descriptor in the family fits, the original is kept,
    since an element that cannot be identified is better kept as written
    than renamed to a guess.

    Parameters
    ----------
    code : int
        The descriptor the file gave.
    node_count : int
        How many nodes the file actually listed for it.

    Returns
    -------
    int
        The descriptor to use.
    """
    # A file wrote a four-node element as descriptor 41, and it drew as a
    # triangle with the fourth node dropped (Brandon, 2026-09-20).
    code = int(code)
    known = ELEMENT_TYPES.get(code)
    if known is None or node_count < 1 or known[1] == node_count:
        return code
    family, render = code // 10, known[2]
    for other, (_name, count, kind) in ELEMENT_TYPES.items():
        if other // 10 == family and kind == render and count == node_count:
            return other
    return code

face_corners

face_corners(code: int) -> int

How many corners a face element is drawn with: three for a triangle and four for a quadrilateral, whatever family its descriptor names and however many nodes it has.

It is read from the name in ELEMENT_TYPES and not from the node count, because a nine-node cubic triangle and a nine-node quadrilateral have the same count.

Parameters:

Name Type Description Default
code int

The element's descriptor.

required

Returns:

Type Description
int

3 or 4.

Source code in src/visualdynamics/core/geometry.py
def face_corners(code: int) -> int:
    """How many corners a face element is drawn with: three for a
    triangle and four for a quadrilateral, whatever family its descriptor
    names and however many nodes it has.

    It is read from the name in `ELEMENT_TYPES` and not from the node
    count, because a nine-node cubic triangle and a nine-node
    quadrilateral have the same count.

    Parameters
    ----------
    code : int
        The element's descriptor.

    Returns
    -------
    int
        3 or 4.
    """
    # by name, not node count (Brandon, 2026-09-20)
    name = ELEMENT_TYPES[int(code)][0]
    return 3 if name.startswith('tri') else 4

solid_faces

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

The faces of a solid cell with this many nodes, each face as positions in the cell's node list.

A higher-order cell is drawn by its corner nodes, which come first in its node list. An unknown node count gives an empty tuple.

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/core/geometry.py
def solid_faces(node_count: int) -> tuple[tuple[int, ...], ...]:
    """The faces of a solid cell with this many nodes, each face as
    positions in the cell's node list.

    A higher-order cell is drawn by its corner nodes, which come first
    in its node list. An unknown node count gives an empty tuple.

    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 ()

deck_to_si

deck_to_si(properties: Mapping[int, Any], length_unit: str, mass_unit: str, inverse: bool = False) -> dict[int, Any]

A finite element deck's element group properties converted to SI from the consistent system length_unit, mass_unit and seconds make (or back, with inverse).

Force is mass times length per second squared, so a modulus scales by mass/length, a density by mass/length³, a thickness by length, a point mass by mass, a spring along an axis (force per length) by mass and a spring about one (force times length per radian) by mass·length². A beam section scales by its powers of length. Names and directions are kept.

Source code in src/visualdynamics/core/geometry.py
def deck_to_si(properties: Mapping[int, Any], length_unit: str,
               mass_unit: str, inverse: bool = False) -> dict[int, Any]:
    """A finite element deck's element group properties converted to SI
    from the consistent system `length_unit`, `mass_unit` and seconds
    make (or back, with `inverse`).

    Force is mass times length per second squared, so a modulus scales by
    mass/length, a density by mass/length³, a thickness by length, a point
    mass by mass, a spring along an axis (force per length) by mass and a
    spring about one (force times length per radian) by mass·length². A
    beam section scales by its powers of length. Names and directions are
    kept.
    """
    from dataclasses import replace

    length = si_transform(length_unit, 'length')[0]
    mass = si_transform(mass_unit, 'mass')[0]
    power = -1.0 if inverse else 1.0
    stress, density = (mass / length) ** power, (mass / length ** 3) ** power
    lengths, masses = length ** power, mass ** power
    turning = (mass * length ** 2) ** power
    out = {}
    for group, props in properties.items():
        changes = {}
        if props.material is not None:
            m = props.material
            changes['material'] = replace(
                m, youngs_modulus=m.youngs_modulus * stress,
                density=m.density * density,
                modulus_of_rigidity=(None if m.modulus_of_rigidity is None
                                     else m.modulus_of_rigidity * stress))
        if props.thickness is not None:
            changes['thickness'] = props.thickness * lengths
        if props.section is not None:
            sec = props.section
            changes['section'] = replace(
                sec, area=sec.area * lengths ** 2, iy=sec.iy * lengths ** 4,
                iz=sec.iz * lengths ** 4, j=sec.j * lengths ** 4,
                dimensions=tuple(d * lengths for d in sec.dimensions))
        if props.mass is not None:
            changes['mass'] = props.mass * masses
        if props.stiffness is not None:
            changes['stiffness'] = tuple(
                None if k is None else k * (masses if axis < 3 else turning)
                for axis, k in enumerate(props.stiffness))
        out[group] = replace(props, **changes) if changes else props
    return out

implausible_materials

implausible_materials(properties: Mapping[int, Any]) -> list[str]

A sentence for each material whose modulus or density, in SI, is outside what a structural material has (PLAUSIBLE_MODULUS, PLAUSIBLE_DENSITY).

Source code in src/visualdynamics/core/geometry.py
def implausible_materials(properties: Mapping[int, Any]) -> list[str]:
    """A sentence for each material whose modulus or density, in SI, is
    outside what a structural material has (`PLAUSIBLE_MODULUS`,
    `PLAUSIBLE_DENSITY`)."""
    notes, seen = [], set()
    for props in properties.values():
        m = props.material
        if m is None or m.name in seen:
            continue
        seen.add(m.name)
        low, high = PLAUSIBLE_MODULUS
        bad = []
        if not low <= m.youngs_modulus <= high:
            bad.append(f'E = {m.youngs_modulus:.3g} Pa')
        low, high = PLAUSIBLE_DENSITY
        if not low <= m.density <= high:
            bad.append(f'density {m.density:.3g} kg/m³')
        if bad:
            notes.append(f'{m.name}: {", ".join(bad)}')
    return notes