AnasSarkiz/magnetic-shutter-remote-r8

A USB‑C rechargeable battery-powered ESP32‑C3 wireless controller with Li‑ion charging, protected 3.3‑V buck‑boost regulation, thermal/safety monitoring, status LEDs, buttons, programming UART, crystal clock, and 2.4‑GHz antenna matching network.

Version
0.4.0-prototype
License
unset
Stars
0

scripts/review-r3-exports.py

"""Read original Gerbers and all Excellon features with independent readers.

Coordinates are board XY millimetres, +X right/+Y up. This program never writes
manufacturing geometry. Comparison bounds cover decimal serialization and curve
approximation only, not fabrication tolerances or manufacturing-rule exceptions.
"""
import argparse
import hashlib
import importlib.util
import json
import math
from dataclasses import dataclass
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent))
from curved_geometry import brep_polygon, arc_polygon
from zipfile import ZipFile

from gerbonara import GerberFile
from gerbonara import graphic_primitives as gp
from gerber.cam import FileSettings
from gerber.excellon import DrillSlot, loads
from shapely.affinity import rotate, translate
from shapely.geometry import LineString, Point, box
from shapely.geometry.base import BaseGeometry
from shapely.ops import unary_union, polygonize

QUANTIZATION_MM = 0.0001
COPPER_COMPARE_MM = 0.00002
EXPECTED_FILES = {
    "F_Cu.gbr", "B_Cu.gbr", "F_Paste.gbr", "B_Paste.gbr",
    "F_Mask.gbr", "B_Mask.gbr", "F_SilkScreen.gbr", "B_SilkScreen.gbr",
    "Edge_Cuts.gbr", "F_Fab.gbr", "drill-L1-L2.drl", "drill_npth.drl",
}


@dataclass
class GeometryComparison:
    expected: BaseGeometry
    actual: BaseGeometry
    label: str


@dataclass
class DrillReview:
    source: str
    expected: list
    label: str


@dataclass
class ExportReviewPaths:
    archive_path: Path
    circuit_path: Path
    output_directory: Path


def primitive_geometry(primitive):
    if isinstance(primitive, gp.Circle):
        return Point(primitive.x, primitive.y).buffer(primitive.r, quad_segs=256)
    if isinstance(primitive, gp.Line):
        return LineString([(primitive.x1, primitive.y1),
                           (primitive.x2, primitive.y2)]).buffer(
                               primitive.width / 2, quad_segs=256)
    if isinstance(primitive, gp.Rectangle):
        rectangle = box(-primitive.w / 2, -primitive.h / 2,
                        primitive.w / 2, primitive.h / 2)
        return translate(rotate(rectangle, primitive.rotation * 180 / math.pi,
                                origin=(0, 0)), primitive.x, primitive.y)
    if isinstance(primitive, gp.ArcPoly):
        return arc_polygon(primitive)
    raise ValueError(f"Unsupported Gerber primitive: {type(primitive).__name__}")


def copper_geometry(gerber_file):
    union = Point().buffer(0)
    for obj in gerber_file.objects:
        for primitive in obj.to_primitives():
            geometry = primitive_geometry(primitive)
            union = union.union(geometry) if primitive.polarity_dark else union.difference(geometry)
    return union


def compare_geometry(comparison):
    expected, actual, label = comparison.expected, comparison.actual, comparison.label
    if expected.is_empty != actual.is_empty:
        raise ValueError(f"Missing or unexpected geometry in {label}")
    missing = expected.difference(actual.buffer(COPPER_COMPARE_MM))
    extra = actual.difference(expected.buffer(COPPER_COMPARE_MM))
    if not missing.is_empty or not extra.is_empty:
        raise ValueError(f"{label} does not match source copper: missing={missing.area}, extra={extra.area} mm2")
    return {"source_area_mm2": expected.area, "export_area_mm2": actual.area,
            "comparison_bound_mm": COPPER_COMPARE_MM,
            "source_and_export_cover_each_other": True}


def expected_slot(hole):
    width, height = hole["hole_width"], hole["hole_height"]
    span = abs(height - width)
    line = LineString([(0, -span/2), (0, span/2)] if height >= width
                      else [(-span/2, 0), (span/2, 0)])
    return translate(rotate(line, hole.get("ccw_rotation", 0), origin=(0, 0)),
                     hole["x"], hole["y"]), min(width, height)


def read_drills(review):
    source, expected, label = review.source, review.expected, review.label
    if not source.startswith("M48\n"):
        raise ValueError(f"{label}: missing leading Excellon M48 header")
    drill = loads(source, settings=FileSettings(
        units="metric", format=(3, 4), zeros="leading", notation="absolute"))
    if drill.units != "metric" or len(drill.hits) != len(expected):
        raise ValueError(f"{label}: drill units or count differs from source ({len(drill.hits)} versus {len(expected)})")
    unmatched = expected.copy()
    measurements = []
    for hit in drill.hits:
        if isinstance(hit, DrillSlot):
            centerline = LineString([hit.start, hit.end])
            slots = [hole for hole in unmatched if hole["type"] == "pcb_plated_hole" and hole.get("shape") == "pill"]
            if not slots:
                raise ValueError(f"{label}: unexpected slot")
            hole = min(slots, key=lambda hole: centerline.hausdorff_distance(expected_slot(hole)[0]))
            expected_centerline, diameter = expected_slot(hole)
            position_error = centerline.hausdorff_distance(expected_centerline)
            kind = "slot"
            position = [(hit.start[0] + hit.end[0])/2, (hit.start[1] + hit.end[1])/2]
            total_length = math.dist(hit.start, hit.end) + hit.tool.diameter
            if hit.tool.diameter < 0.5 or total_length < 2 * hit.tool.diameter:
                raise ValueError(f"{label}: slot fails selected two-layer minimum width/aspect rule")
        else:
            rounds = [hole for hole in unmatched if hole["type"] != "pcb_plated_hole" or hole.get("shape") == "circle"]
            if not rounds:
                raise ValueError(f"{label}: unexpected round drill")
            hole = min(rounds, key=lambda hole: math.dist(hit.position, (hole["x"], hole["y"])))
            diameter = hole["hole_diameter"]
            position_error = math.dist(hit.position, (hole["x"], hole["y"]))
            kind = "round"
            position = hit.position
            total_length = hit.tool.diameter
            minimum = 0.5 if hole["type"] == "pcb_hole" else 0.2
            if hit.tool.diameter < minimum:
                raise ValueError(f"{label}: round drill smaller than selected {minimum} mm minimum")
        diameter_error = abs(hit.tool.diameter - diameter)
        if max(position_error, diameter_error) > QUANTIZATION_MM:
            raise ValueError(f"{label}: {hole[hole['type']+'_id']} drill geometry differs from source")
        unmatched.remove(hole)
        measurements.append({"source_id": hole[hole["type"]+"_id"], "kind": kind,
                             "position_mm": position, "diameter_mm": hit.tool.diameter,
                             "length_mm": total_length, "position_error_mm": position_error,
                             "diameter_error_mm": diameter_error})
    if unmatched:
        raise ValueError(f"{label}: missing drill features")
    return measurements


def inspect_exports(paths):
    archive_path, circuit_path, output_directory = paths.archive_path, paths.circuit_path, paths.output_directory
    circuit = json.loads(circuit_path.read_text())
    errors = [obj for obj in circuit if obj["type"].endswith("_error")]
    traces = [obj for obj in circuit if obj["type"] == "pcb_trace"]
    if errors or not traces:
        raise ValueError("Cannot validate fabrication from an erroneous or unrouted circuit")
    components = [obj for obj in circuit if obj["type"] == "pcb_component"]
    if len(components) != 37 or any(obj["layer"] != "top" for obj in components):
        raise ValueError("Expected all 37 fitted components on top")
    module_spec = importlib.util.spec_from_file_location("r3_manufacturing_audit", Path(__file__).with_name("manufacturing-audit.py"))
    module = importlib.util.module_from_spec(module_spec)
    import sys
    sys.modules[module_spec.name] = module
    module_spec.loader.exec_module(module)
    copper, drills, widths = module.make_features(circuit)
    source_audit = module.audit(copper, drills, widths)
    if source_audit["failures"]:
        raise ValueError("Source copper still fails classified manufacturing rules")
    output_directory.mkdir(parents=True, exist_ok=True)
    with ZipFile(archive_path) as archive:
        if set(archive.namelist()) != EXPECTED_FILES:
            raise ValueError(f"Export file coverage differs: {archive.namelist()}")
        layers = {}
        report = {"archive_sha256": hashlib.sha256(archive_path.read_bytes()).hexdigest(),
                  "circuit_sha256": hashlib.sha256(circuit_path.read_bytes()).hexdigest(),
                  "readers": ["Gerbonara 1.5.0 (Gerber)", "pcb-tools 0.1.6 (all Excellon rounds/G85 slots)"],
                  "layers": {}, "drills": {}, "design_errors": 0,
                  "manufacturing_failures": 0, "top_components": len(components)}
        for name in sorted(EXPECTED_FILES):
            source = archive.read(name).decode()
            if name.endswith(".gbr"):
                layer = GerberFile.from_string(source)
                layers[name] = layer
                report["layers"][name] = {"objects": len(layer.objects), "bounds_mm": layer.bounding_box()}
                (output_directory / (Path(name).stem + ".svg")).write_text(str(layer.to_svg(margin=1)))
            else:
                expected = [obj for obj in circuit if obj["type"] == "pcb_hole"] if "npth" in name else [obj for obj in circuit if obj["type"] in ("pcb_via", "pcb_plated_hole")]
                report["drills"][name] = read_drills(DrillReview(source, expected, name))
        for side, layer_name in [("top", "F_Cu.gbr"), ("bottom", "B_Cu.gbr")]:
            expected = unary_union([feature.geometry for feature in copper if feature.layer == side])
            actual = copper_geometry(layers[layer_name])
            report["layers"][layer_name]["copper_match"] = compare_geometry(GeometryComparison(expected, actual, side))
            if actual.intersects(box(-18, -28, 18, -22.9)):
                raise ValueError(f"{side} exported copper enters antenna keepout")
        outline = layers["Edge_Cuts.gbr"]
        centre_lines = []
        for obj in outline.objects:
            for primitive in obj.to_primitives():
                if not isinstance(primitive, gp.Line):
                    raise ValueError("Unsupported outline primitive")
                centre_lines.append(LineString([(primitive.x1, primitive.y1), (primitive.x2, primitive.y2)]))
        outline_polygons = list(polygonize(unary_union(centre_lines)))
        if len(outline_polygons) != 1 or not outline_polygons[0].is_valid:
            raise ValueError("Board outline is not one closed valid loop")
        board_outline = outline_polygons[0]
        if any(abs(a-b) > QUANTIZATION_MM for a,b in zip(board_outline.bounds, [-18,-28,18,28])):
            raise ValueError("Exported board outline differs from 36 by 56 mm source")
        report["outline"] = {"bounds_mm": board_outline.bounds, "closed_loops": 1,
                             "area_mm2": board_outline.area, "corner_radius_mm": 2}
        for side, paste_name in [("top", "F_Paste.gbr"), ("bottom", "B_Paste.gbr")]:
            # Stencil apertures are explicit, independently qualified source
            # features. They need not equal copper lands (e.g. paste reduction).
            expected = unary_union([module.pad(obj) for obj in circuit if obj["type"] == "pcb_solder_paste" and obj["layer"] == side])
            actual = copper_geometry(layers[paste_name])
            report["layers"][paste_name]["paste_match"] = compare_geometry(GeometryComparison(expected, actual, paste_name))
            report["layers"][paste_name]["aperture_count"] = sum(obj["type"] == "pcb_solder_paste" and obj["layer"] == side for obj in circuit)
        for side, mask_name in [("top", "F_Mask.gbr"), ("bottom", "B_Mask.gbr")]:
            expected = unary_union([feature.geometry for feature in copper if feature.layer == side and feature.kind == "smt"])
            actual = copper_geometry(layers[mask_name])
            missing = expected.difference(actual.buffer(COPPER_COMPARE_MM))
            if not missing.is_empty:
                raise ValueError(f"{mask_name}: covered SMT land")
            report["layers"][mask_name]["all_smt_lands_open"] = True
        for name in ["F_SilkScreen.gbr", "B_SilkScreen.gbr"]:
            geometry = copper_geometry(layers[name])
            outside = geometry.difference(board_outline)
            report["layers"][name]["outside_outline_area_mm2"] = outside.area
        report["scope"] = "All 12 files parsed; both copper layers geometrically matched; all PTH/NPTH drills and slots matched; outline, stencil and SMT mask coverage checked. Silk overhang is measured separately. Physical assembly/process acceptance remains pending."
        return report


if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("archive", type=Path)
    parser.add_argument("circuit", type=Path)
    parser.add_argument("--output-directory", type=Path, required=True)
    args = parser.parse_args()
    result = inspect_exports(ExportReviewPaths(args.archive, args.circuit, args.output_directory))
    (args.output_directory / "readback.json").write_text(json.dumps(result, indent=2)+"\n")
    print("All 12 fabrication files independently parsed and checked against the routed source")