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.
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.
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.
- Sanity-check the edge count. For a pure quad mesh,
shell edges scannedshould equal2 × 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. - 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.
- 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 infree_edges.csvis the element you expect. - 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
| Symptom | Likely cause | Fix |
|---|---|---|
| Free-edge count equals total edge count | Element-type filter excludes all shells, or connectivity returns empty lists | Print elem.type for the first few elements; align SHELL_TYPES with the deck |
| Free-edge count is zero on an open panel | Canonicalisation applied to already-canonical keys, or edges compared as ordered pairs | Verify the min/max step is present and that the key is a tuple, not a string |
| Every interior edge reported as free | Each element processed in isolation without a shared edge_map | Ensure edge_map is created once in the driver and passed into the classifier |
| CSV opens with blank first column | Excel locale interpreting the file as semicolon-delimited | Keep newline="" and UTF-8; import explicitly as comma-delimited |
| Non-manifold edges silently dropped | Classifier only keeps len == 1 | Add 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.
| # | Check | Pass criterion | Record |
|---|---|---|---|
| 1 | Edge count matches 2 × shell count (quad mesh) | Within 1% | scanned / expected |
| 2 | Free-edge count matches intended perimeter | Within one edge length per boundary | found / expected |
| 3 | Every free-edge cluster traced to a geometric feature | 100% classified as intended or defect | cluster id → verdict |
| 4 | Non-manifold edges logged separately | Zero unlogged | count |
| 5 | Node CSV contains no duplicates | Unique rows only | row count vs set size |
| 6 | Reports archived with model revision | Filename includes revision tag | path |
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.