Files
ESP32_Serial_Swiss_Army_Knife/hardware/case/validate.py
T

264 lines
13 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])
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()