2. Convert a single solid element block to an undirected graph

From one *ELEMENT_SOLID block to an undirected graph

This case takes a single *ELEMENT_SOLID block that has already been parsed into memory and turns it into an undirected graph. The graph is stored as a Python dictionary adjacency list: each key is a mesh node ID, and each value is a set of node IDs that share at least one solid element with that node. The task is deliberately narrow — one element block, one element type, one output structure — so that the conversion logic is fully visible and independently testable before mixed element types are introduced.

The key modeling decision is that the graph is undirected. In a solid element, connectivity is symmetric: if node 101 and node 205 belong to the same hexahedron, then 101 is a neighbor of 205 and 205 is a neighbor of 101. Storing each pair once and inserting it in both directions keeps the adjacency list consistent and avoids the classic bug where a graph is traversed in one direction only. A second decision is that adjacency is defined by co-membership in an element, not by geometric proximity. Two nodes that are physically close but belong to different, unconnected elements must not be linked.

Input data and the element block contract

Assume the parser from the previous stage has produced two structures: a node table mapping node ID to coordinates, and a list of solid elements, each with an element ID and an ordered connectivity tuple. For an 8-node hexahedron (*ELEMENT_SOLID with the default solid element formulation), the connectivity has eight entries. The conversion below does not care about the geometric order of those eight nodes; it only needs the set of distinct node IDs in each element.

# Example input already extracted from the .k file (teaching data)
nodes = {
    101: (0.0, 0.0, 0.0),
    102: (1.0, 0.0, 0.0),
    103: (1.0, 1.0, 0.0),
    104: (0.0, 1.0, 0.0),
    105: (0.0, 0.0, 1.0),
    106: (1.0, 0.0, 1.0),
    107: (1.0, 1.0, 1.0),
    108: (0.0, 1.0, 1.0),
    201: (2.0, 0.0, 0.0),
    202: (2.0, 1.0, 0.0),
    203: (2.0, 0.0, 1.0),
    204: (2.0, 1.0, 1.0),
}

# Each element: (element_id, (n1, n2, ..., n8))
solid_elements = [
    (1, (101, 102, 103, 104, 105, 106, 107, 108)),
    (2, (102, 201, 202, 103, 106, 203, 204, 107)),
]

Element 1 is a unit cube. Element 2 shares the four nodes 102, 103, 106, 107 with element 1, which is exactly the situation that produces cross-element edges. This shared face is the part of the example that distinguishes a correct graph from a naive one: nodes 102 and 107 are not in the same element as 201, yet they become connected to 201 through element 2.

Building the adjacency list

The conversion iterates over elements, and for each element connects every node to every other node in that element. Because the graph is undirected, each pair is inserted in both directions. Using a set per node makes insertion idempotent, so shared nodes and repeated pairs do not create duplicate edges.

def build_solid_adjacency(solid_elements):
    """Return {node_id: set(neighbor_ids)} for one *ELEMENT_SOLID block."""
    adjacency = {}

    for elem_id, conn in solid_elements:
        # Deduplicate while preserving nothing about order: only membership matters.
        unique_nodes = list(dict.fromkeys(conn))

        # Ensure every node in the block has an entry, even if isolated later.
        for n in unique_nodes:
            adjacency.setdefault(n, set())

        # Connect each unordered pair exactly once, then mirror it.
        for i in range(len(unique_nodes)):
            for j in range(i + 1, len(unique_nodes)):
                a, b = unique_nodes[i], unique_nodes[j]
                adjacency[a].add(b)
                adjacency[b].add(a)

    return adjacency

adjacency = build_solid_adjacency(solid_elements)

Two implementation details matter for correctness. First, dict.fromkeys(conn) removes duplicate node IDs inside a single element; a malformed or degenerate element that repeats a node would otherwise create a self-loop or a spurious edge. Second, the inner loop starts at i + 1, so each unordered pair is generated once and then written in both directions. This is what makes the result undirected without doubling the pair enumeration.

What the resulting graph looks like

For the teaching data above, the adjacency list has twelve keys, one per node. Nodes on the shared face (102, 103, 106, 107) have the highest degree because they belong to both elements. Corner nodes of element 1 that are not shared (101, 104, 105, 108) have degree 3, since each connects to the other seven nodes of element 1 but to nothing in element 2. The two elements are joined through the shared face, so the whole structure is a single connected component.

Element block topology 101 102 103 104 105 106 107 108 201 202 203 204 Element 1 (hex) Element 2 (hex) shared face: 102-103-106-107 Undirected adjacency graph 102 106 107 103 101 104 105 108 201 202 203 204 Amber nodes lie on the shared face and have the highest degree. Blue edges come from element 1; green edges come from element 2.

The figure makes the two-level structure explicit. On the left, the two hexahedra share the face 102-103-106-107. On the right, the same connectivity is redrawn as a graph: the shared nodes are amber and carry edges from both elements, while the exclusive corner nodes are blue or green and carry edges from only one element. The graph is a single connected component because the shared face provides the bridge.

Verification and expected output

Before trusting the adjacency list, check a few invariants that are cheap to compute and catch most conversion errors.

  • Key coverage: every node ID appearing in any element must be a key in the dictionary. For the example, len(adjacency) == 12.
  • Symmetry: for every node a and neighbor b, a in adjacency[b] must hold. This is the direct test that the graph is undirected.
  • No self-loops: a not in adjacency[a] for all a.
  • Degree sanity: a node in exactly one hexahedron has degree 7 (it connects to the other seven nodes of that element). A node on the shared face of two hexahedra has degree 11 in this example, because it connects to seven nodes in element 1 and seven in element 2, minus the three shared-face neighbors counted twice.
def check_undirected(adjacency):
    for a, neighbors in adjacency.items():
        assert a not in neighbors, f"self-loop at {a}"
        for b in neighbors:
            assert a in adjacency.get(b, set()), f"asymmetric edge {a}-{b}"

check_undirected(adjacency)

# Expected degrees for the teaching data
expected_degrees = {
    101: 7, 104: 7, 105: 7, 108: 7,          # element 1 only
    201: 7, 202: 7, 203: 7, 204: 7,          # element 2 only
    102: 11, 103: 11, 106: 11, 107: 11,      # shared face
}
for node, deg in expected_degrees.items():
    assert len(adjacency[node]) == deg, (node, len(adjacency[node]), deg)

If the symmetry assertion fails, the usual cause is that the mirror insertion adjacency[b].add(a) was omitted, producing a directed graph that happens to look correct when printed. If a shared-face node has degree 7 instead of 11, the second element was not processed, or its connectivity was truncated. If a node is missing from the dictionary entirely, the setdefault initialization was skipped for an element whose nodes never appear elsewhere.

Keep the adjacency list as dict[node_id, set[node_id]] during construction. Sets make the undirected insertion idempotent and keep the symmetry check trivial. Convert to sorted lists only at export time, when a deterministic ordering is needed for serialization or comparison.
Do not infer adjacency from coordinate distance. Two nodes at the same location but in disconnected element blocks are not graph neighbors, and merging them by position silently changes the topology. The graph must reflect element connectivity exactly as written in the *ELEMENT_SOLID block.

Reusable conversion checklist

StepActionPass condition
1Read each solid element's connectivity tupleEight node IDs per hexahedron, no missing entries
2Deduplicate node IDs within the elementNo repeated ID inside one element
3Initialize an empty set for every nodeAll element nodes present as keys
4Insert each unordered pair in both directionsSymmetry check passes
5Verify degrees against element membershipExclusive nodes degree 7, shared-face nodes degree 11
6Confirm a single connected componentBFS from any node reaches all 12 nodes

The output of this case is a single dictionary that fully describes the undirected connectivity of one solid element block. It is the direct input for downstream graph operations such as connected-component labeling, degree statistics, and shortest-path queries, and it establishes the adjacency convention that later cases extend to mixed element types.