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 block | Element family | Nodes per element | Connectivity columns (1-based) |
|---|---|---|---|
*ELEMENT_SOLID | TET4 / HEX8 / PENTA6 | 4 / 8 / 6 | 3–10 (up to 8 nodes, trailing blanks allowed) |
*ELEMENT_SHELL | TRIA3 / QUAD4 | 3 / 4 | 3–6 |
*ELEMENT_BEAM | BEAM (2-node) | 2 | 3–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.
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 exactly0..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 both101and201if the shell and solid share that edge.
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.*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
| Step | Action | Check |
|---|---|---|
| 1 | Read *NODE into nodes dict | All referenced IDs present, or placeholders logged |
| 2 | Dispatch each element block by keyword | Family label matches block type |
| 3 | Split data lines with [,\s]+, drop empties | Connectivity length matches family (4/8/6, 3/4, 2) |
| 4 | Mint indices via shared vertex() | Shared IDs collapse to one index |
| 5 | Build adjacency and edge_meta | Edge 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.