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.

Shell connectivity → node adjacency (edge graph) Mesh: *ELEMENT_SHELL (quad loop + tri) E1E2 E3E4 E5 123 456 789 E5 written as 6 3 9 0 → zero dropped → triangle consecutive pairs + wrap Graph: undirected edges, no diagonals 123 456 789 dashed: tri edge 3–9 (E5) absent: diagonals 1–5, 2–4, 5–9, 6–8 deg(5)=4 interior · deg(1)=2 boundary

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.

For a quick sanity read on a real model, compare the degree histogram against mesh topology: on a closed shell surface every node should have degree ≥ 2, boundary nodes sit at degree 2–3, and interior quad nodes reach degree 4. A node with degree 1 almost always means a dropped wrap-around edge or a mis-parsed triangular shell.
Do not reuse the solid-element rule here. In *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

SymptomLikely causeFix
Node 0 appears in adjacencyTriangular shell padding not filteredDrop zero IDs before building the loop
Degree sum exceeds element-edge countDiagonals added (all-pairs rule)Use consecutive pairs with (i+1) % k
Boundary nodes reported in the interiorWrap-around edge missingInclude the (last, first) pair
Part ID treated as a nodeWrong field offset on the shell cardRead nodes from fields 3–6, not 2–5
Isolated nodes missing from outputAdjacency seeded only from elementsInitialize 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.