3. Handle multiple element types in one mesh file

Why mixed element blocks break naive parsers

A real LS-DYNA keyword file rarely contains a single element family. A typical crash or impact model mixes *ELEMENT_SOLID (tetrahedra, hexahedra, pentahedra), *ELEMENT_SHELL (triangles and quads), and *ELEMENT_BEAM (2-node line elements) in the same deck. Each block has a different number of nodes per element and a different column layout, so a parser that assumes a fixed connectivity width will silently truncate or misalign data. The goal of this case is to extend the parser so that every block is read with its own connectivity width, and all elements are merged into one graph whose node indices are consistent regardless of which block a node first appeared in.

Connectivity widths and the unified node index

The critical design decision is to separate LS-DYNA node IDs (arbitrary integers, often sparse) from graph node indices (dense, 0-based, contiguous). Every element block references nodes by LS-DYNA ID; the graph must reference them by dense index. A single global dictionary id_to_index performs this mapping, and it must be shared across all blocks so that a node shared between a shell and a solid resolves to the same graph vertex.

Keyword blockElement familyNodes per elementConnectivity columns (1-based)
*ELEMENT_SOLIDTET4 / HEX8 / PENTA64 / 8 / 63–10 (up to 8 nodes, trailing blanks allowed)
*ELEMENT_SHELLTRIA3 / QUAD43 / 43–6
*ELEMENT_BEAMBEAM (2-node)23–4

LS-DYNA uses fixed-width, comma-or-space-delimited fields. The robust approach is to split each data line on commas and whitespace, then take the first field as the element ID and the following fields as node IDs, discarding empty trailing fields. This avoids hard-coding column positions and tolerates the trailing blanks that LS-DYNA writes for under-populated solid lines.

Parser implementation

The parser below reads the whole deck, dispatches on the active keyword, and accumulates elements into a single list of (element_id, family, node_ids) tuples. Node coordinates are collected from *NODE blocks. After reading, a single pass builds the dense index and the adjacency structure.

import re
from collections import defaultdict

ELEMENT_KEYWORDS = {
    "*ELEMENT_SOLID": "solid",
    "*ELEMENT_SHELL": "shell",
    "*ELEMENT_BEAM":  "beam",
}

def parse_lsdyna(path):
    nodes = {}                 # lsdyna_id -> (x, y, z)
    elements = []              # (elem_id, family, [node_ids])
    active = None

    with open(path, "r", encoding="utf-8", errors="ignore") as fh:
        for raw in fh:
            line = raw.rstrip("\n")
            stripped = line.strip()
            if not stripped or stripped.startswith("$"):
                continue
            if stripped.startswith("*"):
                key = stripped.split()[0].upper()
                active = key
                continue

            fields = [f for f in re.split(r"[,\s]+", stripped) if f]
            if active == "*NODE":
                nid = int(fields[0])
                x, y, z = (float(v) for v in fields[1:4])
                nodes[nid] = (x, y, z)
            elif active in ELEMENT_KEYWORDS:
                eid = int(fields[0])
                conn = [int(v) for v in fields[1:] if v]
                elements.append((eid, ELEMENT_KEYWORDS[active], conn))

    return nodes, elements

def build_unified_graph(nodes, elements):
    id_to_index = {}
    index_to_id = []
    coords = []

    def vertex(nid):
        if nid not in id_to_index:
            id_to_index[nid] = len(index_to_id)
            index_to_id.append(nid)
            coords.append(nodes.get(nid, (0.0, 0.0, 0.0)))
        return id_to_index[nid]

    adjacency = defaultdict(set)
    edge_meta = {}             # (u, v) -> set of element ids

    for eid, family, conn in elements:
        idx = [vertex(n) for n in conn]
        for i in range(len(idx)):
            for j in range(i + 1, len(idx)):
                u, v = idx[i], idx[j]
                adjacency[u].add(v)
                adjacency[v].add(u)
                edge_meta.setdefault((min(u, v), max(u, v)), set()).add(eid)

    return {
        "id_to_index": id_to_index,
        "index_to_id": index_to_id,
        "coords": coords,
        "adjacency": {k: sorted(v) for k, v in adjacency.items()},
        "edge_meta": edge_meta,
    }

Two properties matter for correctness. First, vertex() is the only place a new index is minted, so a node referenced by a shell and later by a solid receives exactly one index. Second, edge_meta records which element IDs contributed each edge; this is what lets you later distinguish a beam edge from a shell edge that happens to connect the same two nodes.

How the blocks merge into one graph

The diagram below shows a small mixed deck: a quad shell, a tetrahedron, and a beam that share nodes. The shell and solid share two nodes, and the beam attaches to one shell node and one solid node. All three blocks resolve through the same id_to_index map, so the shared nodes collapse to single vertices in the merged graph.

Source blocks (LS-DYNA IDs) *ELEMENT_SHELL 101: 11 12 13 14 102: 14 13 15 16 QUAD4, 4 nodes *ELEMENT_SOLID 201: 13 14 21 22 TET4, 4 nodes shares 13, 14 with shell *ELEMENT_BEAM 301: 12 21 BEAM, 2 nodes vertex() id_to_index 11 → 0 12 → 1 13 → 2 14 → 3 15 → 4 16 → 5 21 → 6 22 → 7 shared IDs map to one index Unified graph (dense indices) 0 1 2 3 4 5 6 7 shell edge solid edge beam edge

Note that node 3 (LS-DYNA ID 14) is shared by the shell and the solid, and node 1 (ID 12) is shared by the shell and the beam. In the merged graph these are single vertices, so the adjacency of vertex 3 contains both shell neighbors and solid neighbors. This is exactly the behavior you want for downstream analysis such as connected-component detection or graph partitioning.

Verification and failure symptoms

Run the parser on a small deck and check three invariants before trusting the graph:

  • Index density: len(index_to_id) == len(id_to_index) and indices are exactly 0..N-1.
  • Shared-node collapse: for the example above, id_to_index[14] must equal the index used by both element 101 and element 201.
  • Edge provenance: edge_meta[(2,3)] should contain both 101 and 201 if the shell and solid share that edge.
A common failure is treating a solid line with trailing blanks as having fewer nodes than it does. If you split on commas only, a line like 201,13,14,21,22,,,, yields empty fields that must be dropped, not counted. Conversely, if you split on whitespace only, a comma-separated deck will produce a single field. The regex split [,\s]+ with empty-field filtering handles both conventions.
If a node ID appears in an element block but not in any *NODE block, vertex() still creates an index and assigns a placeholder coordinate (0,0,0). This keeps the graph topologically complete; flag such nodes separately if coordinate accuracy matters for your downstream task.

Quick reference for the mixed-block workflow

StepActionCheck
1Read *NODE into nodes dictAll referenced IDs present, or placeholders logged
2Dispatch each element block by keywordFamily label matches block type
3Split data lines with [,\s]+, drop emptiesConnectivity length matches family (4/8/6, 3/4, 2)
4Mint indices via shared vertex()Shared IDs collapse to one index
5Build adjacency and edge_metaEdge provenance lists all contributing element IDs

With this structure in place, the graph is independent of block order and element family, and every downstream step—adjacency queries, GraphML export, or partitioning—can operate on a single consistent vertex set.