#!/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]) shell = cad["shell_outer_including_root_and_lip"]["bounds"] near([shell[2], 29.337-shell[4]], [3.29762024, -3.87]) flange = cad["front_flange_exposed_faces"]["bounds"] near([29.337-flange[4]], [literal("flange_y")]) near([flange[0]+41.095, flange[3]+41.095], [41.57, 72.37]) 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 'panel_hangers' not in SCAD and 'panel_mount_' not in SCAD assert 'part=="panel"' not in SCAD and not (ROOT/"panel-default.stl").exists() 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)) closure = datum("closure") assert closure[2] <= -0.5 and closure[3] < closure[4] if not defines: near(closure, [33,81,-0.5,20.82,21.6976,17.578,0.25]) return datum("stack"), datum("engagement"), datum("connector") stack, engagement, connector = run() near(connector, [-0.47, 1.93, 2.4, 21.07, 31.9976, 18.078, 25, 3.6, 5, 1.66098]) # 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) run(["panel_t=0.8"], valid=False) run(["panel_flange_gap=-0.1"], valid=False) run(["panel_bottom_above_module=-0.1"], valid=False) run(["external_fastener_clearance=5"], valid=False) run(["panel_rib_w=8"], valid=False) run(["seam_clearance=0"], valid=False) run(["shell_margin=0.1"], valid=False) run(["panel_bottom_above_module=2"], valid=False) print("PASS actual SCAD default/raised-carrier/taller-module datums, stud engagement and nozzle-web rejection fixtures") def installation_check(): """CGAL intersection of actual lid with continuous vertical envelope sweeps.""" with tempfile.TemporaryDirectory(prefix="case-install-") as temp: for mode, defines in [ ("installation-check", []), ("installation-check", ["carrier_boss_h=7", "module_gap=12", "lid_above_carrier=28", "panel_t=3"]), ("installation-check", ["seam_clearance=0.4", "shell_margin=0.8", "end_clearance=1.5"]), ("installation-blocked", []), ]: path = Path(temp)/"sweep.stl" command = ["openscad", "-o", str(path), "-D", f'part="{mode}"', "-D", "emboss=false"] 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=180) assert result.returncode == 0 and "ERROR:" not in result.stderr, result.stderr if mode == "installation-check": report = mesh_check(path, 1) near([report["volume_mm3"]], [1]) else: # Negative control: unremoved post envelopes MUST hit the panel. text = path.read_text() zs = [float(z) for z in re.findall(r"\bvertex\s+[-+\deE.]+\s+[-+\deE.]+\s+([-+\deE.]+)", text)] assert max(zs) > 80, "Fixture failed to detect installed posts" print("PASS continuous vertical installation sweep", mode, defines) def variant_check(): fixtures = [ ('lid', ['carrier_boss_h=7', 'module_gap=12', 'lid_above_carrier=28', 'panel_t=3']), ('base', ['carrier_boss_h=7', 'module_gap=12', 'lid_above_carrier=28', 'panel_t=3']), ('lid', ['db_panel=false']), ('base', ['daughter_supports=false', 'side_extra=14', 'm3_insert_d=4.4']), ] with tempfile.TemporaryDirectory(prefix='case-variant-') as temp: for part, defines in fixtures: path = Path(temp)/'variant.stl' command = ['openscad', '-o', str(path), '-D', f'part="{part}"', '-D', 'emboss=false'] 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=180) assert result.returncode == 0 and 'ERROR:' not in result.stderr, result.stderr mesh_check(path, 1) print('PASS parameter CGAL mesh', part, defines) def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--source", action="store_true", help="also check saved KiCad/STEP registration") parser.add_argument("--installation", action="store_true", help="CGAL continuous vertical sweep and blocked-post control") parser.add_argument('--variants', action='store_true', help='CGAL parameter mesh smoke tests') args = parser.parse_args() if args.variants: variant_check() if args.installation: installation_check() 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), ("plungers-default.stl", 3)]] for report, high in zip(reports, [[94,83.4,21.6976], [94,83.4,22.75], [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()