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.

Run the node pass with a deliberately tight tolerance first, inspect the count, then relax. A sudden jump in pair count as tolerance increases usually indicates a legitimate mesh transition zone rather than a defect, and those pairs should be excluded from the proposal.

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.

Duplicate Detection Data Flow — Read-Only Proposal Generation Active Model nodes + elements (read only) read-only boundary Node Proximity Pass spatial bucket grid 27-cell neighbourhood d ≤ tol Element Connectivity Pass sorted node-ID key group by identical key pairs within group Proposal Writer record_type keep_id / merge_id metric / detail merge_proposal.csv NODE rows ELEMENT rows Reviewer Gate — no merge applied automatically accept / reject each row before any topology change

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

ObservationLikely causeRecommended action
Many NODE pairs, distances clustered just under tolMesh transition zone or tolerance too looseTighten tolerance and re-run; exclude transition region
NODE pairs with distance near zero, no ELEMENT rowsSplit seam from imported geometryMerge nodes, then re-check free edges
ELEMENT rows sharing all nodesDuplicated element from copy/pasteDelete the higher-ID element
ELEMENT rows sharing a subset of nodesOverlapping elements, not true duplicatesReject — investigate manually
Zero pairs at expected toleranceUnit mismatch or wrong model loadedVerify units and active model before concluding clean
The element pass groups by exact sorted node IDs. Two elements that are geometrically coincident but reference different node IDs will not be flagged — that case belongs to the node pass, which will surface the coincident nodes and let you merge them first. Run the node pass, apply accepted node merges in a separate session, then re-run the element pass to catch the newly unified connectivity.

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:

GroupTypePair countMin metricMax metricDecisionReviewer note
G-01NODE140.00010.008AcceptSeam along rocker, confirmed split
G-02NODE60.0410.049RejectTransition zone, intentional offset
G-03ELEMENT344AcceptQuad 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.