189 lines
9.1 KiB
Python
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()
|