4. Duplicate Node and Element Detection wit
Objective and Detection Strategy
This case builds a PRIMER script that scans a loaded model for two distinct defect classes — coincident nodes within a user-defined tolerance, and elements that share identical connectivity — and writes a merge proposal file. The source model is never modified: the script only reads topology and emits a proposal that a reviewer can inspect before any merge is applied. This separation matters because automated merging is destructive and irreversible in a live session; a proposal file gives you an audit trail and a chance to reject false positives.
The detection logic rests on two independent passes:
- Node proximity pass. For every node pair within a spatial bucket, compute the Euclidean distance. If the distance is less than or equal to the tolerance, the pair is a duplicate candidate. Bucketing by rounded coordinates avoids an O(n²) full comparison on large models.
- Element connectivity pass. For each element, build a canonical key from its sorted node IDs. Elements sharing an identical key are duplicates by connectivity, regardless of node coordinates.
The two passes answer different questions. The node pass catches modelling artefacts such as split seams or imported geometry that produced coincident grids. The element pass catches genuinely redundant elements — often the residue of a copy/paste or a re-meshed patch that was never deleted. A merge proposal should report both, because a coincident node pair with no shared element is a different remediation than a fully duplicated element.
Reference Convention and Tolerance Choice
All distances are computed in the model's working units. Before running, confirm the unit system in the PRIMER session; a tolerance of 0.01 means 0.01 mm in a millimetre model but 0.01 m in a metre model — a thousandfold difference in physical meaning. A practical starting tolerance is one to two orders of magnitude below the smallest intended feature size. For a typical automotive body panel meshed at 5 mm, a tolerance of 0.05 is a reasonable first pass; tighten to 0.01 if the proposal returns an implausible number of pairs.
Script Structure
The script is organised as a single entry function with three helpers: collect_nodes, find_duplicate_nodes, and find_duplicate_elements. The proposal writer is kept separate so the detection logic can be unit-tested against a small fixture model without touching the file system.
# duplicate_detect.py — PRIMER batch script (read-only detection)
# Usage: primer -b duplicate_detect.py -- --tol 0.05 --out merge_proposal.csv
import sys
import csv
import math
TOL_DEFAULT = 0.05
BUCKET_DECIMALS = 3 # rounding used only for spatial bucketing
def collect_nodes(model):
"""Return {node_id: (x, y, z)} for all nodes in the active model."""
nodes = {}
for nid in model.nodes.ids():
x, y, z = model.nodes.coord(nid)
nodes[nid] = (float(x), float(y), float(z))
return nodes
def _bucket_key(coord, tol):
# Cell size = tol so that any pair within tol lands in the same or
# an adjacent cell; we check the 27-cell neighbourhood.
return tuple(int(math.floor(c / tol)) for c in coord)
def find_duplicate_nodes(nodes, tol):
"""Yield (id_a, id_b, distance) for node pairs within tol."""
grid = {}
for nid, coord in nodes.items():
grid.setdefault(_bucket_key(coord, tol), []).append(nid)
seen = set()
for cell, ids in grid.items():
# gather this cell plus 26 neighbours
cx, cy, cz = cell
candidates = []
for dx in (-1, 0, 1):
for dy in (-1, 0, 1):
for dz in (-1, 0, 1):
candidates.extend(grid.get((cx + dx, cy + dy, cz + dz), []))
for i, a in enumerate(ids):
ax, ay, az = nodes[a]
for b in candidates:
if a == b:
continue
pair = (a, b) if a < b else (b, a)
if pair in seen:
continue
bx, by, bz = nodes[b]
d = math.sqrt((ax - bx) ** 2 + (ay - by) ** 2 + (az - bz) ** 2)
if d <= tol:
seen.add(pair)
yield pair[0], pair[1], d
def find_duplicate_elements(model):
"""Yield (elem_a, elem_b, node_key) for elements with identical connectivity."""
by_key = {}
for eid in model.elements.ids():
nids = tuple(sorted(model.elements.node_ids(eid)))
by_key.setdefault(nids, []).append(eid)
for key, eids in by_key.items():
if len(eids) < 2:
continue
eids.sort()
for i in range(len(eids) - 1):
yield eids[i], eids[i + 1], key
def write_proposal(path, node_pairs, elem_pairs):
with open(path, "w", newline="") as fh:
w = csv.writer(fh)
w.writerow(["record_type", "keep_id", "merge_id", "metric", "detail"])
for a, b, d in node_pairs:
w.writerow(["NODE", a, b, f"{d:.6f}", "distance"])
for a, b, key in elem_pairs:
w.writerow(["ELEMENT", a, b, len(key), "shared_nodes"])
def main(argv):
tol = TOL_DEFAULT
out = "merge_proposal.csv"
for i, arg in enumerate(argv):
if arg == "--tol" and i + 1 < len(argv):
tol = float(argv[i + 1])
elif arg == "--out" and i + 1 < len(argv):
out = argv[i + 1]
model = primer.active_model()
nodes = collect_nodes(model)
node_pairs = list(find_duplicate_nodes(nodes, tol))
elem_pairs = list(find_duplicate_elements(model))
write_proposal(out, node_pairs, elem_pairs)
print(f"nodes={len(nodes)} node_pairs={len(node_pairs)} "
f"elem_pairs={len(elem_pairs)} tol={tol} out={out}")
if __name__ == "__main__":
main(sys.argv[1:])
The keep_id column always holds the lower ID of the pair. This is a deterministic convention, not a claim that the lower ID is the "correct" node — it simply makes the proposal stable across runs so two reviewers comparing files see identical rows. The reviewer decides which side survives.
How the Two Passes Interact
The diagram below shows the data flow from the loaded model through both detection passes into the single proposal file, and highlights the read-only boundary that keeps the source model untouched.
Running and Verifying
Launch the script through the batch harness established earlier, passing the tolerance and output path as arguments:
primer -b duplicate_detect.py -- --tol 0.05 --out merge_proposal.csv
The console line reports the node count, the number of node pairs, the number of element pairs, and the tolerance used. These four numbers are your first verification signal. A model with 40,000 nodes returning zero node pairs at a 0.05 tolerance is plausible for a clean mesh; returning several thousand pairs suggests either a genuine seam problem or a unit mismatch. Cross-check the node count against the model summary in PRIMER before trusting the pair counts.
Open the CSV and confirm the header is exactly record_type,keep_id,merge_id,metric,detail. Each NODE row carries the measured distance in the metric column; each ELEMENT row carries the shared node count. Sort by metric descending on the node rows to see the pairs closest to the tolerance limit — these are the ones most likely to be false positives and should be reviewed first.
Interpreting the Proposal
| Observation | Likely cause | Recommended action |
|---|---|---|
| Many NODE pairs, distances clustered just under tol | Mesh transition zone or tolerance too loose | Tighten tolerance and re-run; exclude transition region |
| NODE pairs with distance near zero, no ELEMENT rows | Split seam from imported geometry | Merge nodes, then re-check free edges |
| ELEMENT rows sharing all nodes | Duplicated element from copy/paste | Delete the higher-ID element |
| ELEMENT rows sharing a subset of nodes | Overlapping elements, not true duplicates | Reject — investigate manually |
| Zero pairs at expected tolerance | Unit mismatch or wrong model loaded | Verify units and active model before concluding clean |
Deliverable: Merge Proposal Review Sheet
The CSV is the machine output; the review sheet is what a checker signs off on. Populate one row per accepted or rejected proposal group, not per pair, to keep the sheet readable:
| Group | Type | Pair count | Min metric | Max metric | Decision | Reviewer note |
|---|---|---|---|---|---|---|
| G-01 | NODE | 14 | 0.0001 | 0.008 | Accept | Seam along rocker, confirmed split |
| G-02 | NODE | 6 | 0.041 | 0.049 | Reject | Transition zone, intentional offset |
| G-03 | ELEMENT | 3 | 4 | 4 | Accept | Quad duplicates, delete higher IDs |
Record the tolerance and model revision in the sheet header so the proposal can be reproduced. Once the sheet is signed, the accepted groups are applied in a controlled session — the detection script itself never performs the merge, which is the whole point of separating proposal from execution.