seveibar/pd-dual-stepper-controller

Bottom-side PCB silkscreen artwork consisting of embedded mirrored TS Circuit SVG logos, with via-clearance-aware lower-board placement.

Version
0.1.0
License
unset
Stars
0

scripts/audit-exported-clearances.py

"""Check NPTH clearances in the delivered Gerbers and Excellon, not just JSON."""
import sys
import math
import hashlib
import json
import re
import zipfile
from pathlib import Path
from shapely.geometry import Point, Polygon
from gerber_geometry import gerber_geometry

root = Path(__file__).resolve().parents[1]
folder=root / (sys.argv[1] if len(sys.argv)>1 else 'dist/dual')
circuit_file = folder / 'circuit.json'
archive = folder / 'gerbers.zip'
circuit = json.loads(circuit_file.read_text())
rules = json.loads((root / 'engineering/jlcpcb-clearance-rules.json').read_text())['minimumMm']
npths = [e for e in circuit if e['type'] == 'pcb_hole']
board = next(e for e in circuit if e['type'] == 'pcb_board')
outline = Polygon([(p['x'], p['y']) for p in board['outline']])
checks = []
with zipfile.ZipFile(archive) as z:
    drill = z.read('drill_npth.drl').decode()
    assert re.search(r'^METRIC$', drill, re.M) and 'G90' in drill
    tools = {int(n): float(d) for n, d in re.findall(r'^T(\d+)C([\d.]+)', drill, re.M)}
    selected = None; actual = []
    for line in drill.splitlines():
        if match := re.fullmatch(r'T(\d+)', line):
            selected = tools[int(match[1])]
        elif match := re.fullmatch(r'X(-?[\d.]+)Y(-?[\d.]+)', line):
            assert selected is not None
            actual.append((float(match[1]), float(match[2]), selected))
    expected = [(round(e['x'], 4), round(e['y'], 4), round(e['hole_diameter'], 6)) for e in npths]
    assert sorted(actual) == sorted(expected), 'Delivered NPTH drill positions/sizes differ from PCB'
    # Verify the eight connector retaining slots survived the Gerber/Excellon
    # exporter with supplier widths, lengths, positions, and orientation.
    plated = z.read('drill-L1-L4.drl').decode()
    ptools = {int(n): float(d) for n,d in re.findall(r'^T(\d+)C([\d.]+)', plated, re.M)}
    selected = None; start = None; slots = []
    for line in plated.splitlines():
        if match := re.fullmatch(r'T(\d+)', line):selected = ptools[int(match[1])]
        elif match := re.fullmatch(r'X(-?[\d.]+)Y(-?[\d.]+)', line):start = (float(match[1]),float(match[2]))
        elif match := re.fullmatch(r'G85X(-?[\d.]+)Y(-?[\d.]+)', line):
            assert start is not None and selected is not None
            end = (float(match[1]),float(match[2]))
            slots.append(((start[0]+end[0])/2,(start[1]+end[1])/2,
                          selected,math.dist(start,end)+selected))
    usb_sources = {e['source_component_id'] for e in circuit if e['type']=='source_component' and e['name'] in ['J_USB','J_MOTOR_USB']}
    usb_pcbs = {e['pcb_component_id'] for e in circuit if e['type']=='pcb_component' and e['source_component_id'] in usb_sources}
    expected_slots = [e for e in circuit if e['type']=='pcb_plated_hole' and e.get('pcb_component_id') in usb_pcbs]
    assert len(slots)==len(expected_slots)==8
    for e in expected_slots:
        assert e['shape']=='pill' and e.get('ccw_rotation',0)%180==0
        # This exporter rounds G85 endpoints to 0.001 mm (starts: 0.0001 mm).
        match = [a for a in slots if math.dist(a[:2],(e['x'],e['y']))<.0005]
        assert len(match)==1
        assert abs(match[0][2]-e['hole_width'])<.00001 and abs(match[0][3]-e['hole_height'])<.0006

    for layer, filename in [('top', 'F_Cu.gbr'), ('inner1', 'In1_Cu.gbr'),
                            ('inner2', 'In2_Cu.gbr'), ('bottom', 'B_Cu.gbr')]:
        copper = gerber_geometry(z.read(filename).decode())
        for e in npths:
            hole = Point(e['x'], e['y']).buffer(e['hole_diameter'] / 2, quad_segs=64)
            gap = copper.distance(hole)
            checks.append({'hole': e['pcb_hole_id'], 'layer': layer,
                           'distanceMm': gap, 'requiredMm': rules['npthToCopper']})
        edge_gap = copper.distance(outline.boundary)
        assert outline.covers(copper) and edge_gap >= rules['copperToBoardEdge'] - 1e-6
        checks.append({'boardEdge': True, 'layer': layer, 'distanceMm': edge_gap,
                       'requiredMm': rules['copperToBoardEdge']})
violations = [c for c in checks if c['distanceMm'] < c['requiredMm'] - 1e-6]
report = {'circuitJsonSha256': hashlib.sha256(circuit_file.read_bytes()).hexdigest(),
          'gerbersSha256': hashlib.sha256(archive.read_bytes()).hexdigest(),
          'npthDrillsVerified': len(actual), 'usbPlatedSlotsVerified': len(expected_slots), 'checks': checks, 'violations': violations}
(root / 'reports/exported-clearances.json').write_text(json.dumps(report, indent=2) + '\n')
print(json.dumps(report, indent=2))
assert not violations, 'Exported copper fails clearance checks'
, drill, re.M) and 'G90' in drill\n tools = {int(n): float(d) for n, d in re.findall(r'^T(\\d+)C([\\d.]+)', drill, re.M)}\n selected = None; actual = []\n for line in drill.splitlines():\n if match := re.fullmatch(r'T(\\d+)', line):\n selected = tools[int(match[1])]\n elif match := re.fullmatch(r'X(-?[\\d.]+)Y(-?[\\d.]+)', line):\n assert selected is not None\n actual.append((float(match[1]), float(match[2]), selected))\n expected = [(round(e['x'], 4), round(e['y'], 4), round(e['hole_diameter'], 6)) for e in npths]\n assert sorted(actual) == sorted(expected), 'Delivered NPTH drill positions/sizes differ from PCB'\n # Verify the eight connector retaining slots survived the Gerber/Excellon\n # exporter with supplier widths, lengths, positions, and orientation.\n plated = z.read('drill-L1-L4.drl').decode()\n ptools = {int(n): float(d) for n,d in re.findall(r'^T(\\d+)C([\\d.]+)', plated, re.M)}\n selected = None; start = None; slots = []\n for line in plated.splitlines():\n if match := re.fullmatch(r'T(\\d+)', line):selected = ptools[int(match[1])]\n elif match := re.fullmatch(r'X(-?[\\d.]+)Y(-?[\\d.]+)', line):start = (float(match[1]),float(match[2]))\n elif match := re.fullmatch(r'G85X(-?[\\d.]+)Y(-?[\\d.]+)', line):\n assert start is not None and selected is not None\n end = (float(match[1]),float(match[2]))\n slots.append(((start[0]+end[0])/2,(start[1]+end[1])/2,\n selected,math.dist(start,end)+selected))\n usb_sources = {e['source_component_id'] for e in circuit if e['type']=='source_component' and e['name'] in ['J_USB','J_MOTOR_USB']}\n usb_pcbs = {e['pcb_component_id'] for e in circuit if e['type']=='pcb_component' and e['source_component_id'] in usb_sources}\n expected_slots = [e for e in circuit if e['type']=='pcb_plated_hole' and e.get('pcb_component_id') in usb_pcbs]\n assert len(slots)==len(expected_slots)==8\n for e in expected_slots:\n assert e['shape']=='pill' and e.get('ccw_rotation',0)%180==0\n # This exporter rounds G85 endpoints to 0.001 mm (starts: 0.0001 mm).\n match = [a for a in slots if math.dist(a[:2],(e['x'],e['y']))\u003c.0005]\n assert len(match)==1\n assert abs(match[0][2]-e['hole_width'])\u003c.00001 and abs(match[0][3]-e['hole_height'])\u003c.0006\n\n for layer, filename in [('top', 'F_Cu.gbr'), ('inner1', 'In1_Cu.gbr'),\n ('inner2', 'In2_Cu.gbr'), ('bottom', 'B_Cu.gbr')]:\n copper = gerber_geometry(z.read(filename).decode())\n for e in npths:\n hole = Point(e['x'], e['y']).buffer(e['hole_diameter'] / 2, quad_segs=64)\n gap = copper.distance(hole)\n checks.append({'hole': e['pcb_hole_id'], 'layer': layer,\n 'distanceMm': gap, 'requiredMm': rules['npthToCopper']})\n edge_gap = copper.distance(outline.boundary)\n assert outline.covers(copper) and edge_gap \u003e= rules['copperToBoardEdge'] - 1e-6\n checks.append({'boardEdge': True, 'layer': layer, 'distanceMm': edge_gap,\n 'requiredMm': rules['copperToBoardEdge']})\nviolations = [c for c in checks if c['distanceMm'] \u003c c['requiredMm'] - 1e-6]\nreport = {'circuitJsonSha256': hashlib.sha256(circuit_file.read_bytes()).hexdigest(),\n 'gerbersSha256': hashlib.sha256(archive.read_bytes()).hexdigest(),\n 'npthDrillsVerified': len(actual), 'usbPlatedSlotsVerified': len(expected_slots), 'checks': checks, 'violations': violations}\n(root / 'reports/exported-clearances.json').write_text(json.dumps(report, indent=2) + '\\n')\nprint(json.dumps(report, indent=2))\nassert not violations, 'Exported copper fails clearance checks'\n","content_mimetype":"application/octet-stream","created_at":"2026-10-04T04:38:26.752Z"}; window.SSR_PACKAGE_RELEASES = [{"package_release_id":"77d2ebb4-f605-4d23-93f3-c6e1df08619c","package_id":"f7672220-8927-4259-bd55-84a156551ad8","version":"0.1.0","github_commit_sha":null,"is_locked":false,"is_latest":true,"ready_to_build":true,"is_pr_preview":false,"created_at":"2026-10-04T04:35:59.971Z","circuit_json_build_error":null,"circuit_json_build_error_last_updated_at":null,"has_transpiled":false,"transpilation_error":null,"transpilation_logs":null,"transpilation_in_progress":false,"transpilation_started_at":null,"transpilation_completed_at":null,"transpilation_is_stale":true,"transpilation_display_status":"pending","fs_sha":"md5-e439a6ce75d5ed9a64d956cf43f2b46e","circuit_json_build_logs":null,"circuit_json_build_in_progress":false,"circuit_json_build_started_at":null,"circuit_json_build_completed_at":null,"circuit_json_build_is_stale":true,"circuit_json_build_display_status":"pending","image_generation_error":null,"image_generation_error_last_updated_at":null,"image_generation_logs":null,"image_generation_in_progress":false,"image_generation_started_at":null,"image_generation_completed_at":null,"image_generation_is_stale":true,"image_generation_display_status":"pending","pcb_preview_image_url":"https://api.tscircuit.com/package_files/view?package_release_id=77d2ebb4-f605-4d23-93f3-c6e1df08619c\u0026file_path=dist%2Findex%2Fpcb.svg","sch_preview_image_url":"https://api.tscircuit.com/package_files/view?package_release_id=77d2ebb4-f605-4d23-93f3-c6e1df08619c\u0026file_path=dist%2Findex%2Fschematic.svg","cad_preview_image_url":"https://api.tscircuit.com/package_files/view?package_release_id=77d2ebb4-f605-4d23-93f3-c6e1df08619c\u0026file_path=dist%2Findex%2F3d.png","package_release_website_url":null,"display_status":"pending","latest_package_build_id":"981ab188-d500-44d1-be1e-cca10046e315","user_code_job_started_at":"2026-10-04T04:39:54.562Z","user_code_job_completed_at":"2026-10-04T04:51:20.341Z","user_code_job_error":null,"user_code_job_completed_logs":null,"user_code_job_log_stream_url":"https://usercode.tscircuit.com/user_code_jobs/view_log_stream?user_code_job_id=d627c6f6905fdc104d642def744f04578a40e57b10aa39bb76ebb03391d53635","ai_review_requested":false,"github_pr_number":null,"github_pr_title":null,"github_branch_name":null}]; window.SSR_PACKAGE_BUILDS = []; window.SSR_PACKAGE_ROUTE = {"author":"seveibar","packageName":"pd-dual-stepper-controller","packageNameWithScope":"seveibar/pd-dual-stepper-controller","version":null,"isLegacyRoute":false,"kind":"file","filePath":"scripts/audit-exported-clearances.py"};