3. Scripted Free-Edge Detection and CSV Report Generation

Task scope and data model

Free edges in a shell mesh are element edges that belong to exactly one shell element. They are not automatically defects: a genuine panel boundary, a stiffener cut-out, or an open flange all produce free edges. What matters for review is that the free-edge set is complete and traceable, so an engineer can compare it against the intended geometry and decide whether each edge is a legitimate boundary or an unintended tear. This case builds a PRIMER script that extracts that topology and writes two CSV files: one listing free-edge segments with their owning element and node pair, and one listing the unique nodes involved.

The extraction rests on a simple invariant. For every shell element, PRIMER exposes its connectivity as an ordered node list. Each consecutive node pair (plus the closing pair) defines an edge. If we canonicalise each edge as an unordered node pair — (min(n1,n2), max(n1,n2)) — then an edge shared by two elements appears twice, and a free edge appears once. Counting occurrences of canonical keys is therefore sufficient to classify every edge, and it is independent of element numbering, node ordering within an element, and the direction in which the element was created.

Canonicalisation is the whole trick. Comparing raw ordered pairs would miss shared edges whenever two adjacent elements list their common nodes in opposite order, which is extremely common in imported meshes.

Script structure

The script is organised as four functions plus a driver, so each stage can be tested in isolation inside the PRIMER scripting console before the full run. The example below uses the PRIMER Python scripting interface; the API calls shown (primer.model, element iteration, node coordinate lookup) are the stable entry points used for model interrogation. If your installation exposes a different accessor name for the current model, substitute it at the single point marked in collect_shell_edges.

import csv
import os
from collections import defaultdict

# ---------- configuration ----------
MODEL_PATH   = r"C:\work\panel_assy.model"
OUT_DIR      = r"C:\work\reports"
EDGE_CSV     = os.path.join(OUT_DIR, "free_edges.csv")
NODE_CSV     = os.path.join(OUT_DIR, "free_edge_nodes.csv")
SHELL_TYPES  = {"CQUAD4", "CTRIA3", "CQUAD8", "CTRIA6"}   # adjust to your solver deck

# ---------- stage 1: gather edges ----------
def collect_shell_edges(model):
    """Return {canonical_edge: [ (elem_id, n1, n2), ... ]} for all shell elements."""
    edge_map = defaultdict(list)
    for elem in model.elements:                 # <-- single accessor point
        if elem.type not in SHELL_TYPES:
            continue
        nodes = list(elem.nodes)                # ordered connectivity
        if len(nodes) < 3:
            continue
        for i in range(len(nodes)):
            a = nodes[i]
            b = nodes[(i + 1) % len(nodes)]
            key = (a, b) if a < b else (b, a)
            edge_map[key].append((elem.id, a, b))
    return edge_map

# ---------- stage 2: classify ----------
def find_free_edges(edge_map):
    """An edge is free when exactly one shell element claims it."""
    return {k: v[0] for k, v in edge_map.items() if len(v) == 1}

# ---------- stage 3: write reports ----------
def write_edge_csv(free_edges, path):
    with open(path, "w", newline="", encoding="utf-8") as fh:
        w = csv.writer(fh)
        w.writerow(["edge_id", "elem_id", "node_a", "node_b"])
        for idx, (key, (eid, a, b)) in enumerate(sorted(free_edges.items()), start=1):
            w.writerow([idx, eid, a, b])

def write_node_csv(free_edges, path):
    nodes = set()
    for (a, b) in free_edges:
        nodes.add(a); nodes.add(b)
    with open(path, "w", newline="", encoding="utf-8") as fh:
        w = csv.writer(fh)
        w.writerow(["node_id"])
        for nid in sorted(nodes):
            w.writerow([nid])
    return len(nodes)

# ---------- driver ----------
def main():
    os.makedirs(OUT_DIR, exist_ok=True)
    model = primer.model.open(MODEL_PATH)
    edge_map   = collect_shell_edges(model)
    free_edges = find_free_edges(edge_map)
    write_edge_csv(free_edges, EDGE_CSV)
    n_nodes = write_node_csv(free_edges, NODE_CSV)
    print(f"shell edges scanned : {len(edge_map)}")
    print(f"free edges found    : {len(free_edges)}")
    print(f"unique free nodes   : {n_nodes}")
    print(f"reports written to  : {OUT_DIR}")

if __name__ == "__main__":
    main()

Two design choices are worth calling out. First, collect_shell_edges filters by element type rather than assuming every element in the model is a shell; solid, beam, and rigid elements would otherwise inject spurious edges. Second, the free-edge record keeps the original ordered pair (a, b) alongside the canonical key, so the CSV preserves the element's own traversal direction — useful when the downstream reviewer wants to reconstruct the boundary loop in order.

How the classification behaves

The diagram below shows the decision path the script follows for a single edge, and how the same canonical key accumulates claims from adjacent elements. It also shows the two output artefacts and what each one is used for downstream.

Shell elements CQUAD4 / CTRIA3 ordered node list e.g. [101, 102, 118, 117] filter by element type Canonicalise edges (a,b) → (min, max) (101,102) → (101,102) (102,101) → (101,102) order-independent key Count claims key → [elem ids] (101,102) → [E1, E2] (117,118) → [E1] len(claims) decides Classify len == 2 → shared len == 1 → FREE len > 2 → non-manifold flag for review free_edges.csv edge_id, elem_id, node_a, node_b 1, 1042, 117, 118 2, 1042, 118, 119 3, 1057, 201, 202 one row per free-edge segment, ordered pair preserved free_edge_nodes.csv node_id 117 118 119 unique, sorted, for node-level review Non-manifold edges len(claims) > 2 means three or more shells share one edge. Not a free edge — but a real topology defect worth logging. extend the script to emit a third CSV Invariant: a free edge is claimed by exactly one shell element, regardless of node ordering or element numbering. Example values are illustrative; counts depend on the model under test.

Running and verifying the extraction

Run the script from the PRIMER scripting console with the model closed or with a read-only session, so the extraction cannot be confused by unsaved edits. The console prints four lines: total shell edges scanned, free edges found, unique free nodes, and the output directory. Use these as the first verification gate.

  1. Sanity-check the edge count. For a pure quad mesh, shell edges scanned should equal 2 × number_of_quads (each quad contributes four edges, each shared edge counted twice). A large mismatch usually means the element-type filter is wrong for your deck.
  2. Check the free-edge count against geometry. A closed shell surface (e.g. a full cylinder) should report zero free edges. An open panel should report a count consistent with its perimeter divided by the average edge length.
  3. Spot-check a known boundary. Pick one node you know sits on a real panel edge, confirm it appears in free_edge_nodes.csv, and confirm its owning element in free_edges.csv is the element you expect.
  4. Confirm no duplicates. The node CSV is built from a set, so duplicate node rows indicate a bug in the writer, not in the topology logic.

Typical failure symptoms

SymptomLikely causeFix
Free-edge count equals total edge countElement-type filter excludes all shells, or connectivity returns empty listsPrint elem.type for the first few elements; align SHELL_TYPES with the deck
Free-edge count is zero on an open panelCanonicalisation applied to already-canonical keys, or edges compared as ordered pairsVerify the min/max step is present and that the key is a tuple, not a string
Every interior edge reported as freeEach element processed in isolation without a shared edge_mapEnsure edge_map is created once in the driver and passed into the classifier
CSV opens with blank first columnExcel locale interpreting the file as semicolon-delimitedKeep newline="" and UTF-8; import explicitly as comma-delimited
Non-manifold edges silently droppedClassifier only keeps len == 1Add a second pass for len > 2 and write a separate review CSV

Deliverable: free-edge review checklist

The following checklist is the artefact a reviewer uses after the script has run. It is deliberately short — the point is to force an explicit decision on every free-edge cluster rather than to re-describe the script.

#CheckPass criterionRecord
1Edge count matches 2 × shell count (quad mesh)Within 1%scanned / expected
2Free-edge count matches intended perimeterWithin one edge length per boundaryfound / expected
3Every free-edge cluster traced to a geometric feature100% classified as intended or defectcluster id → verdict
4Non-manifold edges logged separatelyZero unloggedcount
5Node CSV contains no duplicatesUnique rows onlyrow count vs set size
6Reports archived with model revisionFilename includes revision tagpath
Do not treat a non-zero free-edge count as a failure. The script's job is to make the topology visible and traceable; the engineering judgement about whether each edge is intended belongs to the reviewer, and the checklist above is where that judgement is recorded.

With the two CSVs in hand, downstream automation can consume the node list directly — for example, to drive a boundary-condition assignment or to compare against a reference model's free-edge set. Because the extraction is deterministic and order-independent, repeated runs on the same model revision produce byte-identical reports, which makes diffing across revisions reliable.