4. Build node adjacency from element connectivity for shell elements
Why shell connectivity needs its own adjacency rule
A shell element in LS-DYNA is defined by a node list on its *ELEMENT_SHELL card, typically four nodes (quad) or three nodes (triangular shell). Unlike a solid element, where every node pair inside the element is a genuine volumetric neighbor, a shell's four nodes form a closed loop around the element face. The graph you want is the edge graph of the shell mesh: two nodes are adjacent if they share a shell edge, i.e. they are consecutive in the element's node list (including the wrap-around pair). Connecting every node to every other node in a quad would add the two diagonals, which do not exist as mesh edges and would corrupt any downstream use of the graph — boundary detection, edge-based smoothing, or graph partitioning.
So the rule for this case is: for each shell element with node list [n1, n2, n3, (n4)], add undirected edges (n1,n2), (n2,n3), (n3,n4), (n4,n1) for a quad, and (n1,n2), (n2,n3), (n3,n1) for a triangle. Duplicate edges are expected — interior edges are shared by two elements — and must be collapsed so the adjacency list contains each neighbor once.
Input model and parsing assumptions
Use a small, self-contained LS-DYNA keyword deck so the result is checkable by hand. The deck below is a 2×2 quad patch (four shell elements, nine nodes) plus one triangular shell sharing an edge with the patch, which exercises both the quad and tri branches and produces a known boundary.
*KEYWORD
*NODE
1 0.0 0.0 0.0
2 1.0 0.0 0.0
3 2.0 0.0 0.0
4 0.0 1.0 0.0
5 1.0 1.0 0.0
6 2.0 1.0 0.0
7 0.0 2.0 0.0
8 1.0 2.0 0.0
9 2.0 2.0 0.0
*ELEMENT_SHELL
1 1 1 2 5 4
2 1 2 3 6 5
3 1 4 5 8 7
4 1 5 6 9 8
5 1 6 3 9 0
*END
Two parsing details matter here. First, LS-DYNA fixed-format cards are column-based, but the free-format reader used below splits on whitespace, which is valid for decks written in free format (as above). Second, a triangular shell is written as a quad with a trailing zero in the fourth node field — element 5 has 9 0 at the end, so the parser must drop zero node IDs rather than treating 0 as a node. The second field on each shell card is the part ID (1 here); it is not a node and must be skipped.
Building the adjacency list
The implementation keeps a dict[int, set[int]] so duplicate edges collapse automatically, and it seeds every node from *NODE so isolated nodes still appear with an empty neighbor set.
from collections import defaultdict
def parse_k_file(path):
nodes, shells = {}, []
section = None
with open(path) as f:
for raw in f:
line = raw.strip()
if not line or line.startswith("$"):
continue
if line.startswith("*"):
section = line.upper()
continue
fields = line.split()
if section == "*NODE":
nodes[int(fields[0])] = tuple(float(v) for v in fields[1:4])
elif section == "*ELEMENT_SHELL":
# fields: eid, pid, n1, n2, n3, n4
conn = [int(v) for v in fields[2:6]]
conn = [n for n in conn if n != 0] # drop tri padding
shells.append((int(fields[0]), conn))
return nodes, shells
def shell_adjacency(nodes, shells):
adj = {nid: set() for nid in nodes}
for eid, conn in shells:
k = len(conn)
if k not in (3, 4):
raise ValueError(f"element {eid}: unsupported shell node count {k}")
for i in range(k):
a, b = conn[i], conn[(i + 1) % k] # wrap-around closes the loop
adj.setdefault(a, set()).add(b)
adj.setdefault(b, set()).add(a)
return adj
nodes, shells = parse_k_file("patch.k")
adj = shell_adjacency(nodes, shells)
for nid in sorted(adj):
print(nid, sorted(adj[nid]))
Expected output for the deck above (derived directly from the connectivity, not from a tool run):
1 [2, 4]
2 [1, 3, 5]
3 [2, 6, 9]
4 [1, 5, 7]
5 [2, 4, 6, 8]
6 [3, 5, 9]
7 [4, 8]
8 [5, 7, 9]
9 [3, 6, 8]
Note what the wrap-around produces: node 5 is interior and gets four neighbors, while node 1 gets only two. The diagonals (1,5) and (2,4) are absent — that is the correctness signal for shell adjacency, and it is exactly what a naive "all pairs in the element" rule would get wrong.
Verifying the graph represents shell connectivity
Do not eyeball the output — assert the properties that distinguish a correct shell edge graph from a wrong one. The checks below are cheap and catch the three realistic failure modes: diagonals added, wrap-around edge dropped, and zero-padding treated as a node.
def verify(nodes, shells, adj):
# 1. every node present, symmetric edges
assert set(adj) == set(nodes)
for a, nbrs in adj.items():
for b in nbrs:
assert a in adj[b], f"asymmetric edge {a}-{b}"
# 2. edge count: unique undirected edges == sum over elements of k
# (each element contributes k edges; shared edges counted twice)
expected = sum(len(c) for _, c in shells)
actual = sum(len(v) for v in adj.values())
assert actual == expected, (actual, expected)
# 3. no diagonals: for each quad, opposite corners must NOT be adjacent
# unless some other element legitimately connects them
for eid, c in shells:
if len(c) == 4:
for a, b in ((c[0], c[2]), (c[1], c[3])):
shared = any(
a in cc and b in cc and eid != e
for e, cc in shells
)
if not shared:
assert b not in adj[a], f"diagonal {a}-{b} in element {eid}"
# 4. no node 0 leaked in
assert 0 not in adj
return True
verify(nodes, shells, adj) # raises AssertionError on failure
Check 2 is the strongest single test: the total degree sum must equal the number of element edges, because each element contributes exactly k edges and every edge is stored twice (once per endpoint). If you accidentally add diagonals, the sum inflates by 2 per quad; if you drop the wrap-around pair, it deflates by 2 per quad. Check 3 isolates the diagonal case per element so the error message names the offending element.
*ELEMENT_SOLID (8-node hex) every node pair inside the element is a valid volumetric neighbor, so all-pairs is correct. Applying all-pairs to a quad shell inserts the two diagonals and silently changes the graph's edge set — downstream boundary extraction will then report interior nodes as boundary nodes.Failure symptoms and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
Node 0 appears in adjacency | Triangular shell padding not filtered | Drop zero IDs before building the loop |
| Degree sum exceeds element-edge count | Diagonals added (all-pairs rule) | Use consecutive pairs with (i+1) % k |
| Boundary nodes reported in the interior | Wrap-around edge missing | Include the (last, first) pair |
| Part ID treated as a node | Wrong field offset on the shell card | Read nodes from fields 3–6, not 2–5 |
| Isolated nodes missing from output | Adjacency seeded only from elements | Initialize adj from the *NODE block |
With the parser, the consecutive-pair rule, and the four assertions in place, the adjacency list is a faithful edge graph of the shell mesh: it preserves every shared edge, collapses duplicates, excludes diagonals, and keeps isolated nodes visible for later graph export.