#!/usr/bin/env python3 """Read-only source registration and ASCII STL topology checks; no packages added. Use /usr/bin/python3 for optional installed KiCad bindings. This is not a full solid collision, slicer, strength, RF, or physical-fit test. """ import argparse import ast from collections import defaultdict import hashlib import json import os from pathlib import Path import re import subprocess import tempfile ROOT = Path(__file__).resolve().parent PCB = ROOT.parent / "PCB" SCAD = (ROOT / "enclosure.scad").read_text() def literal(name): match = re.search(r"^" + re.escape(name) + r"\s*=\s*([^;]+);", SCAD, re.M) assert match, name return ast.literal_eval(match.group(1)) def near(a, b): assert len(a) == len(b) and all(abs(x-y) < 1e-5 for x, y in zip(a, b)), (a, b) def source_check(): import pcbnew as p # installed KiCad, not required for mesh-only checking board = p.LoadBoard(str(PCB / "serial-carrier.kicad_pcb")) fps = {f.GetReference(): f for f in board.GetFootprints()} def local(v): return (p.ToMM(v.x)-100, p.ToMM(v.y)-100) def holes(ref): return sorted(local(q.GetPosition()) for q in fps[ref].Pads() if q.GetAttribute() == p.PAD_ATTRIB_NPTH) near([p.ToMM(board.GetDesignSettings().GetBoardThickness())], [literal("carrier_t")]) for ref, xy in {"U1": [8, 23], "U2": [43, 26.670], "DS1": [43.97, 36]}.items(): near(local(fps[ref].GetPosition()), xy) assert fps[ref].GetOrientationDegrees() == 0 assert not holes("U1"), "ESP32 has no supplied mounting holes; retain header support" for ref, diameter in [("H1", 3.2), ("H2", 3.2), ("H3", 3.2), ("H4", 3.2), ("U2", 3), ("DS1", 2)]: for pad in fps[ref].Pads(): if pad.GetAttribute() == p.PAD_ATTRIB_NPTH: near([p.ToMM(pad.GetDrillSize().x), p.ToMM(pad.GetDrillSize().y)], [diameter]*2) for ref, xy in zip(["H1", "H2", "H3", "H4"], literal("carrier_holes")): near(holes(ref)[0], xy) for ref, key in [("U2", "rs_holes"), ("DS1", "oled_holes")]: for actual, expected in zip(holes(ref), sorted(literal(key))): near(actual, expected) assert len(holes(ref)) == len(literal(key)) for ref, xy in zip(["SW1", "SW2", "SW3"], literal("button_centres")): origin = local(fps[ref].GetPosition()) near([origin[0]+3.25, origin[1]+2.25], xy) assert fps[ref].GetOrientationDegrees() == 0 edges = {tuple(round(v, 6) for v in local(d.GetStart())) for d in board.GetDrawings() if d.GetLayer() == p.Edge_Cuts} assert edges == {tuple(v) for v in literal("board_outline")} # Measured embedded U1 F.Fab lines: mouth widths/position are source authority. mouth_lines = [] for item in fps["U1"].GraphicalItems(): if isinstance(item, p.PCB_SHAPE) and item.GetLayer() == p.F_Fab: a, b = local(item.GetStart()), local(item.GetEnd()) if abs(a[1]-77.705) < 1e-5 and abs(b[1]-77.705) < 1e-5: mouth_lines.append((a, b)) measured = sorted((a[0]+b[0])/2 for a, b in mouth_lines if abs(abs(a[0]-b[0])-8.87) < 1e-5) near(measured, literal("usb_centres")) near([literal("usb_mouth_y")], [77.705]) cad = json.loads((PCB / "reference/db9-cad/measurements.json").read_text()) near([axis["location"][0]+43-1.905 for axis in cad["front_lock_axes"]], literal("lock_x")) near(cad["shell_outer_including_root_and_lip"]["dimensions"], [16.878038, 5.8, 9.1]) for axis in cad["front_lock_axes"]: near([axis["location"][2]-1.57], [6.27762024]) print("PASS native PCB outline, thickness, placements, NPTH centres, buttons, USB mouths, STEP lock/shell data") def mesh_check(path, expected_components): text = path.read_text() # OpenSCAD 2021 default ASCII STL, fail on other format vertices = [tuple(map(float, xyz)) for xyz in re.findall( r"\bvertex\s+([-+\deE.]+)\s+([-+\deE.]+)\s+([-+\deE.]+)", text)] assert vertices and len(vertices) % 3 == 0, path triangles = [tuple(vertices[i:i+3]) for i in range(0, len(vertices), 3)] edges = defaultdict(list) neighbours = defaultdict(set) volume = 0 for idx, (a, b, c) in enumerate(triangles): assert len({a, b, c}) == 3, (path, "degenerate triangle", idx) u, v = [b[i]-a[i] for i in range(3)], [c[i]-a[i] for i in range(3)] cross = (u[1]*v[2]-u[2]*v[1], u[2]*v[0]-u[0]*v[2], u[0]*v[1]-u[1]*v[0]) assert sum(q*q for q in cross) > 1e-18, (path, "zero area", idx) volume += sum(a[i]*cross[i] for i in range(3))/6 for start, end in [(a, b), (b, c), (c, a)]: edges[tuple(sorted((start, end)))].append((idx, start, end)) for edge, uses in edges.items(): assert len(uses) == 2, (path, "nonmanifold/open edge", edge, len(uses)) assert uses[0][1:] == uses[1][1:][::-1], (path, "inconsistent winding", edge) i, j = uses[0][0], uses[1][0] neighbours[i].add(j) neighbours[j].add(i) remaining, components = set(range(len(triangles))), 0 while remaining: stack = [remaining.pop()] components += 1 while stack: for n in neighbours[stack.pop()]: if n in remaining: remaining.remove(n) stack.append(n) assert components == expected_components, (path, "components", components) assert volume > 0, (path, "inverted/nonpositive volume", volume) low = [min(v[i] for v in vertices) for i in range(3)] high = [max(v[i] for v in vertices) for i in range(3)] assert abs(low[2]) < 1e-4, (path, "not on print bed", low) return {"file": path.name, "triangles": len(triangles), "components": components, "closed_consistently_wound_edges": True, "volume_mm3": round(volume, 3), "bounds_mm": [low, high], "sha256": hashlib.sha256(path.read_bytes()).hexdigest()} def geometry_check(): """Evaluate actual SCAD expressions, including negative stud/web fixtures.""" assert 'module spacers(' not in SCAD and 'part=="spacers"' not in SCAD assert not (ROOT/"spacers-default.stl").exists(), "Obsolete printable spacers must not ship" assert 'brass_standoff_h' not in SCAD, "No brass height below carrier" assert 'box(10,19,carrier_top' not in SCAD, "RF overlay must not return" assert 'box(12.045,14.745,module_bottom,17.63,6.89,esp_t)' in SCAD with tempfile.TemporaryDirectory(prefix="case-check-") as temp: def run(defines=(), valid=True): command = ["openscad", "-o", str(Path(temp)/"check.csg"), "-D", 'part="check"'] for define in defines: command.extend(["-D", define]) result = subprocess.run(command+[str(ROOT/"enclosure.scad")], env={**os.environ, "QT_QPA_PLATFORM": "offscreen"}, capture_output=True, text=True, timeout=30) output = result.stdout+result.stderr if not valid: assert "ERROR: Assertion" in output, output return assert result.returncode == 0 and "ERROR:" not in output, output def datum(label): match = re.search(r'ECHO: "CHECK '+label+r'", (\[[^\n]+\])', output) assert match, output return ast.literal_eval(match.group(1)) return datum("stack"), datum("engagement") stack, engagement = run() # Independent expected defaults, not values copied from exported meshes. near(stack, [7.4, 9, 19, 20.57, 35, 37.4, 15, 13.3, 15.6, 26.8476, 21.07, 19.6, 26.6, 25.6]) near(engagement, [2.4, 2.4]) raised, _ = run(["carrier_boss_h=7"]) near(raised, [value+(0 if i == 13 else 2) for i, value in enumerate(stack)]) taller, _ = run(["module_gap=12", "lid_above_carrier=28"]) near(taller, [value+(2 if i in [2,3,4,5,9,10,11,12,13] else 0) for i, value in enumerate(stack)]) run(["brass_m3_stud_l=1.6"], valid=False) run(["brass_m2_stud_l=5"], valid=False) run(["wall=0.8"], valid=False) print("PASS actual SCAD default/raised-carrier/taller-module datums, stud engagement and nozzle-web rejection fixtures") def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--source", action="store_true", help="also check saved KiCad/STEP registration") args = parser.parse_args() geometry_check() if args.source: source_check() reports = [mesh_check(ROOT / name, n) for name, n in [ ("base-default.stl", 1), ("lid-default.stl", 1), ("panel-default.stl", 1), ("plungers-default.stl", 3)]] for report, high in zip(reports, [[94,83.4,15], [94,83.4,22.75], [46,0,2], [28,4,25.6]]): near(report["bounds_mm"][1], high) # catch stale exports of the old stack print(json.dumps(reports, indent=2)) print("PASS edge-manifold topology, winding, component counts, positive total volumes and print-bed Z") print("Does not prove absence of self-intersections, physical fit, strength or printability.") if __name__ == "__main__": main()