"""Regression check: compare two model fingerprints (fingerprint.py output) object by object. usage: python compare_fingerprints.py [] Tolerances: bbox 0.011 mm (values rounded to 0.01), curve length 0.002 mm, volume 1e-6 relative. Everything else (layer, type, visibility, colour, face count, solidity, user strings) must be equal. Exit code 0 = identical within tolerance. """ import json import sys from collections import Counter from pathlib import Path def diff_obj(a, b): out = [] for k in sorted(set(a) | set(b)): va, vb = a.get(k), b.get(k) if k == "bbox" and va and vb: if max(abs(x - y) for x, y in zip(va, vb)) > 0.011: out.append((k, va, vb)) elif k == "length" and va is not None and vb is not None: if abs(va - vb) > 0.002: out.append((k, va, vb)) elif k == "volume" and isinstance(va, float) and isinstance(vb, float): if abs(va - vb) > 1e-6 * max(abs(va), 1.0): out.append((k, va, vb)) elif k == "user": for uk in sorted(set(va or {}) | set(vb or {})): if (va or {}).get(uk) != (vb or {}).get(uk): out.append((f"user.{uk}", (va or {}).get(uk), (vb or {}).get(uk))) elif va != vb: out.append((k, va, vb)) return out def is_fast(fp): return any(o.get("volume") == "skipped" for o in fp["objects"].values()) def compare(base, cand): # A fast fingerprint skips pipe volumes, and Rhino then returns pipe bounding boxes without the pipe radius # (2026-10-12: B9 top 28350.87 fast vs 28358.87 full on the same file) -> only compare like with like. if is_fast(base) != is_fast(cand): raise ValueError("fingerprint modes differ (FAST vs full): compare fingerprints made with the same mode") A, B = base["objects"], cand["objects"] only_a, only_b = sorted(set(A) - set(B)), sorted(set(B) - set(A)) diffs = {oid: d for oid in sorted(set(A) & set(B)) if (d := diff_obj(A[oid], B[oid]))} fields = Counter(f for d in diffs.values() for f, _, _ in d) layer_diff = {k: (base["layers"].get(k), cand["layers"].get(k)) for k in set(base["layers"]) | set(cand["layers"]) if base["layers"].get(k) != cand["layers"].get(k)} rep = {"baseline": base["model"], "candidate": cand["model"], "count": [base["count"], cand["count"]], "units": [base["units"], cand["units"]], "tolerance": [base["tolerance"], cand["tolerance"]], "only_in_baseline": only_a, "only_in_candidate": only_b, "objects_with_diffs": len(diffs), "diff_fields": dict(fields), "layer_diffs": layer_diff, "samples": {oid: d[:6] for oid, d in list(diffs.items())[:25]}} rep["identical"] = (not only_a and not only_b and not diffs and not layer_diff and rep["units"][0] == rep["units"][1]) return rep if __name__ == "__main__": base = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) cand = json.loads(Path(sys.argv[2]).read_text(encoding="utf-8")) rep = compare(base, cand) if len(sys.argv) > 3: Path(sys.argv[3]).write_text(json.dumps(rep, ensure_ascii=False, indent=1), encoding="utf-8") brief = {k: rep[k] for k in ("count", "units", "tolerance", "objects_with_diffs", "diff_fields", "identical")} brief["only_in_baseline"], brief["only_in_candidate"] = rep["only_in_baseline"][:10], rep["only_in_candidate"][:10] brief["layer_diffs"] = rep["layer_diffs"] print(json.dumps(brief, ensure_ascii=False, indent=1)) for oid, d in list(rep["samples"].items())[:8]: print(oid, d) sys.exit(0 if rep["identical"] else 1)