Files
ESP32_Serial_Swiss_Army_Knife/hardware/case/validate.py
T

189 lines
9.1 KiB
Python

#!/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()