← All Pull Requests

KiCad: use valid hole_size constraint (55-file parser regression) #3

Closed Draft opened by Josh Aberdeen 2026-11-01

Scope: KiCad rule syntax only; no fabrication limits changed

The published generator emits (constraint hole (min ...)) in all 55 generated .kicad_dru files. Native KiCad 10 custom-rule parsing rejects every file with unknown constraint type 'hole'. The valid KiCad keyword is hole_size.

This draft makes exactly one source-code line change in tools/generate.py:

  • hole → hole_size

It changes no canonical vendor record, no minimum clearance, no drill dimension and no policy, and includes no regenerated production rule files. Its merge cannot silently publish or substitute a new PCBWay manufacturing profile. It is intentionally narrow and can be cherry-picked into the existing broader adom/pcb-design-rules draft PR #2 (global ID 98), if maintainers prefer one PR.

Reproduction

  • Before: 55/55 generated KiCad DRUs invalid at parser.
  • Scratch regenerated with only the one-line change: 55 valid, 0 invalid, 0 parser errors using the native ADOM KiCad bridge parser (KiCad 10.0.7).
  • Source rules/records/pcbway.json and every other canonical fab record unchanged.
  • Independent scratch validation log retained in PCB Design Studio alongside upstream issue #6.

NOT a complete manufacturing fix

This repair only makes the published generator emit syntactically loadable files. It does not prove KiCad enforces the source's annular-width, hole-to-hole, layer/copper-weight, mask, silk, or clearance-precedence requirements. ADOM's existing draft PR #2, issues #2, #4, #5 and #6 cover larger generator and manufacturer-source gaps. Those must be resolved with native DRC negative-control coupons and full source reconciliation before regenerated production DRUs are published.

The related UMCU21 project remains at Stage 2 Rules HOLD, with Stage 3 locked; no PCBWay production release is authorised.

Draft / review requested — do not merge this into a rules release without considering overlap with existing PR #2.

Diff Skip to comments

--- a/tools/generate.py+++ b/tools/generate.py@@ -1,573 +1,574 @@-#!/usr/bin/env python3-"""Generate EDA rule files from the canonical records in rules/records/.--Canonical unit is mm; mil values are derived (1 mil = 0.0254 mm) and rounded-to 2 decimals. Every generated file carries its source URLs and read dates.--Outputs:-  rules/index.json-  fusion/<slug>[-<process>]-<n>L.dru   (classic EAGLE text)-  fusion/<slug>[-<process>]-<n>L.edru  (Fusion/EAGLE 9 XML, seed-compatible)-  kicad/<slug>[-<process>]-<n>L.kicad_dru-  kicad/<slug>[-<process>]-<n>L.board-setup.json-  altium/<slug>[-<process>]-<n>L.RUL-And rewrites the comparison table in README.md between the TABLE markers.-"""--import json-import pathlib-import re-import sys--ROOT = pathlib.Path(__file__).resolve().parent.parent-MIL = 0.0254---def mil(mm):-    return round(mm / MIL, 2)---def fmm(mm):-    if mm is None:-        return "n/a"-    s = f"{mm:.4f}".rstrip("0").rstrip(".")-    return s---def slugify(s):-    return re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")---def file_slug(vendor_slug, process, layers):-    p = "" if process == "standard" else f"-{process}"-    return f"{vendor_slug}{p}-{layers}L"---def pad_pad_spacing(rec):-    sp = rec["minSpacing"]["mm"]-    return max(sp, 0.15)---def g(rec, key, sub="mm"):-    v = rec.get(key)-    if not v:-        return None-    return v.get(sub)---LAYER_SETUPS = {2: "(1*16)", 4: "(1*2*15*16)", 6: "(1*2*3*14*15*16)", 8: "(1*2*3*4*13*14*15*16)"}---def effective_via(rec):-    """Return (drillMm, padMm or None), deriving pad from drill + 2x annular ring."""-    via = rec.get("minVia") or {}-    drill = via.get("drillMm")-    if drill is None and rec.get("minDrill"):-        drill = rec["minDrill"]["mm"]-    pad = via.get("padMm") if via else None-    if pad is None and drill is not None and rec.get("annularRing"):-        pad = round(drill + 2 * rec["annularRing"]["viaMm"], 4)-    return drill, pad---def description_text(vendor, rec):-    tw = rec["minTraceWidth"]-    sp = rec["minSpacing"]-    via_d, via_p = effective_via(rec)-    lines = [-        f"{vendor} {rec['layerCount']}-layer design rules ({rec['process']} process)",-        "",-        f"Min trace width: {fmm(tw['mm'])} mm ({tw['mil']} mil){' - ' + tw['condition'] if tw.get('condition') else ''}",-        f"Min spacing: {fmm(sp['mm'])} mm ({sp['mil']} mil){' - ' + sp['condition'] if sp.get('condition') else ''}",-        f"Min drill: {fmm(rec['minDrill']['mm'])} mm ({rec['minDrill']['mil']} mil)",-        f"Min via: {fmm(via_d)} mm hole" + (f" / {fmm(via_p)} mm pad" if via_p else ""),-    ]-    if rec.get("annularRing"):-        ar = rec["annularRing"]-        lines.append(f"Annular ring: via {fmm(ar['viaMm'])} mm" + (f", pad {fmm(ar['padMm'])} mm" if ar.get("padMm") else ""))-    if rec.get("copperToBoardEdge"):-        lines.append(f"Copper to board edge: {fmm(rec['copperToBoardEdge']['mm'])} mm")-    if rec.get("holeToHole"):-        lines.append(f"Hole to hole: {fmm(rec['holeToHole']['mm'])} mm")-    if rec.get("drillRange"):-        dr = rec["drillRange"]-        lines.append(f"Drill range: {fmm(dr['minMm'])} mm to {fmm(dr['maxMm'])} mm")-    if rec.get("boardThicknessOptionsMm"):-        lines.append("Board thickness options: " + ", ".join(f"{t} mm" for t in rec["boardThicknessOptionsMm"]))-    if rec.get("copperWeightOptionsOz"):-        lines.append("Copper weight options: " + ", ".join(f"{w} oz" for w in rec["copperWeightOptionsOz"]))-    if rec.get("surfaceFinishes"):-        lines.append("Surface finishes: " + ", ".join(rec["surfaceFinishes"]))-    if rec.get("maxBoardSize"):-        mb = rec["maxBoardSize"]-        lines.append(f"Max board size: {fmm(mb['xMm'])} x {fmm(mb['yMm'])} mm")-    if rec.get("notes"):-        lines += ["", rec["notes"]]-    lines += ["", "Sources:"]-    for s in rec.get("sources", []):-        lines.append(f"- {s['url']} (read {s['dateRead']})")-    return "\n".join(lines)---# ---------------------------------------------------------------- EAGLE .edru--def gen_edru(vendor, rec):-    tw = rec["minTraceWidth"]["mm"]-    sp = rec["minSpacing"]["mm"]-    dr = rec["minDrill"]["mm"]-    pp = pad_pad_spacing(rec)-    c2e = g(rec, "copperToBoardEdge") or 0.2-    h2h = g(rec, "holeToHole") or 0.2-    arv = (g(rec, "annularRing", "viaMm")) or 0.1-    arp = (g(rec, "annularRing", "padMm")) or 0.18-    layers = rec["layerCount"]-    name = file_slug(slugify(vendor), rec["process"], layers)--    def clearance_rules():-        rows = []-        combos = [-            ("is_wire_polygon", "is_wire_polygon"),-            ("is_wire_polygon", "is_pad_smd"),-            ("is_wire_polygon", "is_via"),-            ("is_pad_smd", "is_pad_smd"),-            ("is_pad_smd", "is_via"),-            ("is_via", "is_via"),-        ]-        same = [-            ("is_smd", "is_pad"),-            ("is_smd", "is_via"),-            ("is_smd", "is_smd"),-        ]-        prio = 1-        for i, (a, b) in enumerate(combos):-            v = pp if (a, b) == ("is_pad_smd", "is_pad_smd") else sp-            pv = v if (a, b) == ("is_pad_smd", "is_pad_smd") else max(sp * 2, v)-            rows.append(f' <rule type="Copper Clearance" builtin_ruleid="{i}" enabled="yes" priority="{prio}" onescope="{a}" otherscope="{b}" value="{fmm(v)}mm" preferredvalue="{fmm(pv)}mm" samesignal="no" name="copper-clearance-{i+1}"/>')-            prio += 1-        for j, (a, b) in enumerate(same):-            rows.append(f' <rule type="Copper Clearance" builtin_ruleid="{6+j}" enabled="yes" priority="{prio}" onescope="{a}" otherscope="{b}" value="{fmm(sp)}mm" preferredvalue="{fmm(max(sp*2, sp))}mm" samesignal="yes" name="copper-clearance-{7+j}"/>')-            prio += 1-        return "\n".join(rows)--    rules_xml = f'''<?xml version="1.0" encoding="utf-8"?>-<!DOCTYPE designrules SYSTEM "eagle.dtd">-<eagle version="9.7.0">-<designrules name="{name}.edru">-<classes>-<class number="0" name="default">-</class>-<class number="1" name="pwr">-</class>-</classes>-<rules>-<rulecategory type="Copper Clearance" manualdrc="yes" livedrc="yes"/>- <rulecategory type="Keepout Clearance" manualdrc="yes" livedrc="yes"/>- <rulecategory type="Solder-Mask Clearance" manualdrc="yes" livedrc="yes"/>- <rulecategory type="Dimension Clearance" manualdrc="yes" livedrc="yes"/>- <rulecategory type="Drill Distance" manualdrc="yes" livedrc="yes"/>- <rulecategory type="Restrict Clearance" manualdrc="yes" livedrc="yes"/>- <rulecategory type="Minimum Drill Size" manualdrc="yes" livedrc="yes"/>- <rulecategory type="Minimum Copper Width" manualdrc="yes" livedrc="yes"/>- <rulecategory type="Acute Angle" manualdrc="yes" livedrc="yes"/>- <rulecategory type="Air-Wire" manualdrc="yes" livedrc="yes"/>- <rulecategory type="Small Gap" manualdrc="yes" livedrc="yes"/>- <rulecategory type="Wire Stub" manualdrc="yes" livedrc="no"/>- <rulecategory type="Matched Lengths" manualdrc="yes" livedrc="yes"/>- <rulecategory type="Via Restrict Clearance" manualdrc="yes" livedrc="yes"/>- <rule type="Minimum Copper Width" enabled="yes" priority="0" onescope="classes=1" value="{fmm(tw)}mm" preferredvalue="{fmm(max(tw*3, 0.3))}mm" check_polywidth="no" name="Trace size for Power"/>- <rule type="Minimum Copper Width" builtin_ruleid="14" enabled="yes" priority="1" value="{fmm(tw)}mm" preferredvalue="{fmm(max(tw*2, 0.2))}mm" check_polywidth="no" name="minimum-copper-width-1"/>- <rule type="Minimum Drill Size" enabled="yes" priority="0" onescope="is_via" value="{fmm(dr)}mm" preferredvalue="{fmm(max(dr*2, 0.2))}mm" name="Min Via hole size on {layers}-layer"/>- <rule type="Minimum Drill Size" enabled="no" priority="1" onescope="all" value="0.5mm" preferredvalue="0.5mm" name="Min Non-plated holes"/>- <rule type="Minimum Drill Size" builtin_ruleid="15" enabled="yes" priority="2" value="{fmm(dr)}mm" preferredvalue="{fmm(max(dr*2, 0.25))}mm" name="minimum-drill-size-1"/>- <rule type="Acute Angle" builtin_ruleid="16" enabled="no" priority="0" name="acute-angle-1"/>- <rule type="Wire Stub" builtin_ruleid="17" enabled="yes" priority="0" name="wire-stub-1"/>- <rule type="Air-Wire" builtin_ruleid="19" enabled="yes" priority="0" name="air-wire-1"/>- <rule type="Matched Lengths" builtin_ruleid="20" enabled="yes" priority="0" tolerance="10mm" gapfactor="2.5" name="matched-lengths-1"/>- <rule type="Copper Clearance" enabled="no" priority="0" onescope="all" otherscope="all" value="{fmm(max(sp,0.25))}mm" preferredvalue="{fmm(max(sp,0.25))}mm" samesignal="yes" bothscope="all" name="Same-net track spacing"/>-{clearance_rules()}- <rule type="Dimension Clearance" builtin_ruleid="9" enabled="yes" priority="0" value="{fmm(c2e)}mm" preferredvalue="{fmm(max(c2e, 0.4))}mm" name="dimension-clearance-1"/>- <rule type="Via Restrict Clearance" builtin_ruleid="21" enabled="yes" priority="0" value="0mil" preferredvalue="0mil" name="via-restrict-clearance-1"/>- <rule type="Restrict Clearance" builtin_ruleid="10" enabled="yes" priority="0" value="0mil" preferredvalue="0mil" name="restrict-clearance-1"/>- <rule type="Small Gap" builtin_ruleid="18" enabled="yes" priority="0" name="small-gap-1"/>- <rule type="Solder-Mask Clearance" builtin_ruleid="11" enabled="no" priority="0" value="0mil" preferredvalue="0mil" check_name="yes" check_value="no" name="solder-mask-clearance-1"/>- <rule type="Drill Distance" builtin_ruleid="12" enabled="yes" priority="0" value="{fmm(h2h)}mm" preferredvalue="{fmm(h2h)}mm" name="drill-distance-1"/>- <rule type="Keepout Clearance" builtin_ruleid="13" enabled="yes" priority="0" value="0mil" preferredvalue="0mil" name="keepout-clearance-1"/>- </rules>-<param name="mdWireWire" value="{fmm(sp)}mm"/>-<param name="mdWirePad" value="{fmm(sp)}mm"/>-<param name="mdWireVia" value="{fmm(sp)}mm"/>-<param name="mdPadPad" value="{fmm(pp)}mm"/>-<param name="mdPadVia" value="{fmm(sp)}mm"/>-<param name="mdViaVia" value="{fmm(sp)}mm"/>-<param name="mdSmdPad" value="{fmm(sp)}mm"/>-<param name="mdSmdVia" value="{fmm(sp)}mm"/>-<param name="mdSmdSmd" value="{fmm(sp)}mm"/>-<param name="mdCopperDimension" value="{fmm(c2e)}mm"/>-<param name="mdDrill" value="{fmm(h2h)}mm"/>-<param name="msWidth" value="{fmm(tw)}mm"/>-<param name="msDrill" value="{fmm(dr)}mm"/>-<param name="dpMaxLengthDifference" value="10mm"/>-<param name="dpGapFactor" value="2.5"/>-<description language="en">{description_text(vendor, rec)}-</description>-<param name="layerSetup" value="{LAYER_SETUPS[layers]}"/>-<param name="mtCopper" value="{' '.join(['0.035mm'] * 16)}"/>-<param name="mtIsolate" value="1.6mm 0.15mm 0.2mm 0.15mm 0.2mm 0.15mm 0.2mm 0.15mm 0.2mm 0.15mm 0.2mm 0.15mm 0.2mm 0.15mm 0.2mm"/>-<param name="mdViaViaSameLayer" value="6mil"/>-<param name="mnLayersViaInSmd" value="2"/>-<param name="mdSmdStop" value="0mil"/>-<param name="msMicroVia" value="0.3mm"/>-<param name="msBlindViaRatio" value="0.5"/>-<param name="rvPadTop" value="0"/>-<param name="rvPadInner" value="0"/>-<param name="rvPadBottom" value="0"/>-<param name="rvViaOuter" value="0"/>-<param name="rvViaInner" value="0"/>-<param name="rvMicroViaOuter" value="0"/>-<param name="rvMicroViaInner" value="0"/>-<param name="rlMinPadTop" value="{fmm(arp)}mm"/>-<param name="rlMaxPadTop" value="80mil"/>-<param name="rlMinPadInner" value="{fmm(arp)}mm"/>-<param name="rlMaxPadInner" value="20mil"/>-<param name="rlMinPadBottom" value="{fmm(arp)}mm"/>-<param name="rlMaxPadBottom" value="80mil"/>-<param name="rlMinViaOuter" value="{fmm(arv)}mm"/>-<param name="rlMaxViaOuter" value="20mil"/>-<param name="rlMinViaInner" value="{fmm(arv)}mm"/>-<param name="rlMaxViaInner" value="20mil"/>-<param name="rlMinMicroViaOuter" value="{fmm(arv)}mm"/>-<param name="rlMaxMicroViaOuter" value="20mil"/>-<param name="rlMinMicroViaInner" value="{fmm(arv)}mm"/>-<param name="rlMaxMicroViaInner" value="20mil"/>-<param name="psTop" value="-1"/>-<param name="psBottom" value="-1"/>-<param name="psFirst" value="-1"/>-<param name="psElongationLong" value="100"/>-<param name="psElongationOffset" value="100"/>-<param name="mvStopFrame" value="0"/>-<param name="mvCreamFrame" value="0"/>-<param name="mlMinStopFrame" value="0.038mm"/>-<param name="mlMaxStopFrame" value="4mil"/>-<param name="mlMinCreamFrame" value="0mil"/>-<param name="mlMaxCreamFrame" value="0mil"/>-<param name="mlViaStopLimit" value="0.5mm"/>-<param name="srRoundness" value="0"/>-<param name="srMinRoundness" value="0mil"/>-<param name="srMaxRoundness" value="0mil"/>-<param name="slThermalIsolate" value="10mil"/>-<param name="slThermalsForVias" value="0"/>-<param name="checkAngle" value="0"/>-<param name="checkRestrict" value="1"/>-<param name="checkStop" value="0"/>-<param name="checkValues" value="0"/>-<param name="checkNames" value="1"/>-<param name="checkWireStubs" value="1"/>-<param name="checkPolygonWidth" value="0"/>-<param name="useDiameter" value="13"/>-<param name="removeAnnularRing" value="0"/>-<param name="maxErrors" value="50"/>- </designrules>-</eagle>-'''-    return rules_xml---# ---------------------------------------------------------------- EAGLE .dru--def gen_dru(vendor, rec):-    tw = rec["minTraceWidth"]-    sp = rec["minSpacing"]-    dr = rec["minDrill"]-    via = rec["minVia"]-    c2e = g(rec, "copperToBoardEdge") or 0.2-    h2h = g(rec, "holeToHole") or 0.2-    arv = (g(rec, "annularRing", "viaMm")) or 0.1-    arp = (g(rec, "annularRing", "padMm")) or 0.18-    layers = rec["layerCount"]-    pp = pad_pad_spacing(rec)-    desc = description_text(vendor, rec).replace("\n", " | ")-    lines = [-        f"description[{desc}]",-        "",-        "layerSetup = " + LAYER_SETUPS[layers],-        "mtCopper = " + " ".join(["0.035mm"] * 16),-        "mtIsolate = 1.6mm 0.15mm 0.2mm 0.15mm 0.2mm 0.15mm 0.2mm 0.15mm 0.2mm 0.15mm 0.2mm 0.15mm 0.2mm 0.15mm 0.2mm",-        f"mdWireWire = {mil(sp['mm'])}mil",-        f"mdWirePad = {mil(sp['mm'])}mil",-        f"mdWireVia = {mil(sp['mm'])}mil",-        f"mdPadPad = {mil(pp)}mil",-        f"mdPadVia = {mil(sp['mm'])}mil",-        f"mdViaVia = {mil(sp['mm'])}mil",-        f"mdSmdPad = {mil(sp['mm'])}mil",-        f"mdSmdVia = {mil(sp['mm'])}mil",-        f"mdSmdSmd = {mil(sp['mm'])}mil",-        f"mdCopperDimension = {mil(c2e)}mil",-        f"mdDrill = {mil(h2h)}mil",-        f"msWidth = {mil(tw['mm'])}mil",-        f"msDrill = {mil(dr['mm'])}mil",-        "mdViaViaSameLayer = 6mil",-        "mdSmdStop = 0mil",-        "msMicroVia = 12mil",-        "msBlindViaRatio = 0.5",-        "rvPadTop = 0",-        "rvPadInner = 0",-        "rvPadBottom = 0",-        "rvViaOuter = 0",-        "rvViaInner = 0",-        "rvMicroViaOuter = 0",-        "rvMicroViaInner = 0",-        f"rlMinPadTop = {mil(arp)}mil",-        "rlMaxPadTop = 80mil",-        f"rlMinPadInner = {mil(arp)}mil",-        "rlMaxPadInner = 20mil",-        f"rlMinPadBottom = {mil(arp)}mil",-        "rlMaxPadBottom = 80mil",-        f"rlMinViaOuter = {mil(arv)}mil",-        "rlMaxViaOuter = 20mil",-        f"rlMinViaInner = {mil(arv)}mil",-        "rlMaxViaInner = 20mil",-        f"rlMinMicroViaOuter = {mil(arv)}mil",-        "rlMaxMicroViaOuter = 20mil",-        f"rlMinMicroViaInner = {mil(arv)}mil",-        "rlMaxMicroViaInner = 20mil",-        "psTop = -1",-        "psBottom = -1",-        "psFirst = -1",-        "psElongationLong = 100",-        "psElongationOffset = 100",-        "mvStopFrame = 0",-        "mvCreamFrame = 0",-        "mlMinStopFrame = 1.5mil",-        "mlMaxStopFrame = 4mil",-        "mlMinCreamFrame = 0mil",-        "mlMaxCreamFrame = 0mil",-        "mlViaStopLimit = 20mil",-        "srRoundness = 0",-        "srMinRoundness = 0mil",-        "srMaxRoundness = 0mil",-        "slThermalIsolate = 10mil",-        "slThermalsForVias = 0",-        "dpMaxLengthDifference = 400mil",-        "dpGapFactor = 2.5",-        "checkAngle = 0",-        "checkRestrict = 1",-        "checkStop = 0",-        "checkValues = 0",-        "checkNames = 1",-        "checkWireStubs = 1",-        "checkPolygonWidth = 0",-        "useDiameter = 13",-        "maxErrors = 50",-        "",-    ]-    return "\n".join(lines)---# ---------------------------------------------------------------- KiCad--def gen_kicad_dru(vendor, rec):-    tw = rec["minTraceWidth"]["mm"]-    sp = rec["minSpacing"]["mm"]-    dr = rec["minDrill"]["mm"]-    via_d, via_p = effective_via(rec)-    c2e = g(rec, "copperToBoardEdge")-    silk = g(rec, "silkLineWidth")-    mask = g(rec, "maskExpansion")-    layers = rec["layerCount"]-    desc = description_text(vendor, rec)-    out = [-        "(version 1)",-        "",-        "# " + desc.replace("\n", "\n# "),-        "",-        '(rule "min-track-width"',-        f"  (constraint track_width (min {fmm(tw)}mm))",-        f"  (layer \"{'*.Cu' if layers == 2 else '*.Cu'}\")",-        "  (condition \"A.type == 'track'\"))",-        "",-        '(rule "min-clearance"',-        f"  (constraint clearance (min {fmm(sp)}mm))",-        "  )" if vendor == "JLCPCB" else "  (condition \"A.type == 'track' && B.type == 'track'\"))",-        "",-        '(rule "min-hole"',-        f"  (constraint hole (min {fmm(dr)}mm))",-        "  (condition \"A.type == 'pad' || A.type == 'via'\"))",-        "",-    ]-    if via_p:-        out += [-            '(rule "min-via-diameter"',-            f"  (constraint via_diameter (min {fmm(via_p)}mm))",-            "  (condition \"A.type == 'via'\"))",-            "",-        ]-    if c2e:-        out += [-            '(rule "min-edge-clearance"',-            f"  (constraint edge_clearance (min {fmm(c2e)}mm))",-            "  (condition \"A.type == 'track'\"))",-            "",-        ]-    if silk:-        out += [-            '(rule "min-silk-thickness"',-            f"  (constraint text_thickness (min {fmm(silk)}mm))",-            "  (layer \"*.SilkS\")",-            "  (condition \"A.type == 'text' || A.type == 'graphic'\"))",-            "",-        ]-    return "\n".join(out)---def gen_kicad_board_setup(vendor, rec):-    via_d, via_p = effective_via(rec)-    setup = {-        "vendor": vendor,-        "process": rec["process"],-        "layerCount": rec["layerCount"],-        "boardSetupConstraints": {-            "minTrackWidthMm": rec["minTraceWidth"]["mm"],-            "minClearanceMm": rec["minSpacing"]["mm"],-            "minViaDrillMm": via_d,-            "minViaDiameterMm": via_p,-            "minHoleMm": rec["minDrill"]["mm"],-            "copperEdgeClearanceMm": g(rec, "copperToBoardEdge"),-            "holeToHoleMm": g(rec, "holeToHole"),-            "annularRingViaMm": g(rec, "annularRing", "viaMm"),-            "annularRingPadMm": g(rec, "annularRing", "padMm"),-            "silkMinLineWidthMm": g(rec, "silkLineWidth"),-            "maskExpansionMm": g(rec, "maskExpansion"),-        },-        "notes": "Apply as Board Setup, Constraints. The matching .kicad_dru file carries the same limits as custom rules; the two are redundant on purpose so both the GUI constraints and kicad-cli DRC enforce them.",-        "sources": rec.get("sources", []),-    }-    if rec.get("silkscreenPolicy"):-        setup["silkscreenPolicy"] = rec["silkscreenPolicy"]-    return json.dumps(setup, indent=2) + "\n"---# ---------------------------------------------------------------- Altium .RUL--def gen_rul(vendor, rec):-    tw = rec["minTraceWidth"]-    sp = rec["minSpacing"]-    dr = rec["minDrill"]-    via_d, via_p = effective_via(rec)-    c2e = g(rec, "copperToBoardEdge")-    arv = g(rec, "annularRing", "viaMm")-    rules = [-        ("Width", "MinTraceWidth", "MinWidth=%gmm|PreferredWidth=%gmm|MaxWidth=25.4mm" % (tw["mm"], tw["mm"] * 2)),-        ("Clearance", "MinClearance", "Clearance=%gmm" % sp["mm"]),-    ]-    if via_p:-        rules.append(("RoutingViaStyle", "MinVia", "PreferredViaWidth=%gmm|PreferredViaHoleWidth=%gmm|MinViaWidth=%gmm|MaxViaWidth=6.35mm|MinViaHoleWidth=%gmm|MaxViaHoleWidth=6.35mm" % (via_p, via_d, via_p, via_d)))-    rules.append(("HoleSize", "MinHole", "HoleWidth=%gmm" % dr["mm"]))-    if arv:-        rules.append(("AnnularRing", "MinAnnularRing", "MinAnnularRing=%gmm" % arv))-    if c2e:-        rules.append(("BoardOutlineClearance", "CopperToBoardEdge", "Clearance=%gmm" % c2e))-    out = ["[Design Rules]", f"RuleCount={len(rules)}", ""]-    for i, (kind, name, attrs) in enumerate(rules, 1):-        out += [-            f"[Rule{i}]",-            f"RuleKind={kind}",-            f"RuleName={name}",-            "Scope1=All",-            "Scope2=",-            f"Attribute={attrs}",-            "",-        ]-    return "\n".join(out)---# ---------------------------------------------------------------- table--def mm_mil(v):-    if not v:-        return "n/p"-    return f"{fmm(v['mm'])} / {v['mil']}"---def gen_table(records):-    header = "| Vendor | Process | Layers | Min trace mm / mil | Min space mm / mil | Min drill mm / mil | Min via drill / pad mm | Annular ring via / pad mm | Cu to edge mm | Hole to hole mm | Source (read 2026-09-03) |"-    sep = "|---|---|---|---|---|---|---|---|---|---|---|---|"-    rows = [header, sep]-    order = {v: i for i, v in enumerate([-        "3rd party fab", "JLCPCB", "PCBWay", "OSH Park", "Seeed Fusion",-        "Eurocircuits", "Aisler", "Elecrow", "Advanced Circuits", "Sierra Circuits",-        "MacroFab", "NextPCB", "ALLPCB",-    ])}-    for r in sorted(records, key=lambda r: (order.get(r["vendor"], 99), r["process"], r["layerCount"])):-        via = r["minVia"]-        ar = r.get("annularRing") or {}-        src = r["sources"][0]["url"] if r.get("sources") else ""-        via_d, via_p = effective_via(r)-        rows.append(-            "| {vendor} | {proc} | {layers} | {tw} | {sp} | {dr} | {via} | {ar} | {c2e} | {h2h} | {src} |".format(-                vendor=r["vendor"], proc=r["process"], layers=r["layerCount"],-                tw=mm_mil(r.get("minTraceWidth")), sp=mm_mil(r.get("minSpacing")),-                dr=mm_mil(r.get("minDrill")),-                via=(f"{fmm(via_d)} / {fmm(via_p)}" if via_d else "n/p"),-                ar=(f"{fmm(ar.get('viaMm'))} / {fmm(ar.get('padMm')) if ar.get('padMm') else 'n/p'}" if ar and ar.get("viaMm") else "n/p"),-                c2e=(fmm(g(r, "copperToBoardEdge")) if g(r, "copperToBoardEdge") else "n/p"),-                h2h=(fmm(g(r, "holeToHole")) if g(r, "holeToHole") else "n/p"),-                src=src,-            ))-    return "\n".join(rows)---# ---------------------------------------------------------------- main--def main():-    rec_files = sorted((ROOT / "rules" / "records").glob("*.json"))-    if not rec_files:-        sys.exit("no records found")-    records = []-    index = {"generatedBy": "tools/generate.py", "unitNote": "mm primary, mil secondary (1 mil = 0.0254 mm)", "vendors": []}-    for rf in rec_files:-        data = json.loads(rf.read_text())-        for rec in data["records"]:-            rec = dict(rec)-            rec["vendor"] = data["vendor"]-            rec["vendorSlug"] = data["vendorSlug"]-            records.append(rec)-            vs = file_slug(data["vendorSlug"], rec["process"], rec["layerCount"])-            gen_ok = all(rec.get(k) for k in ("minTraceWidth", "minSpacing", "minDrill"))-            if gen_ok:-                (ROOT / "fusion" / f"{vs}.edru").write_text(gen_edru(data["vendor"], rec))-                (ROOT / "fusion" / f"{vs}.dru").write_text(gen_dru(data["vendor"], rec))-                (ROOT / "kicad" / f"{vs}.kicad_dru").write_text(gen_kicad_dru(data["vendor"], rec))-                (ROOT / "kicad" / f"{vs}.board-setup.json").write_text(gen_kicad_board_setup(data["vendor"], rec))-                (ROOT / "altium" / f"{vs}.RUL").write_text(gen_rul(data["vendor"], rec))-            entry = {-                "vendor": data["vendor"], "vendorSlug": data["vendorSlug"],-                "process": rec["process"], "layerCount": rec["layerCount"],-                "filesGenerated": gen_ok,-                "files": {-                    "record": f"rules/records/{data['vendorSlug']}.json",-                    "fusionEdru": f"fusion/{vs}.edru",-                    "fusionDru": f"fusion/{vs}.dru",-                    "kicadDru": f"kicad/{vs}.kicad_dru",-                    "kicadBoardSetup": f"kicad/{vs}.board-setup.json",-                    "altiumRul": f"altium/{vs}.RUL",-                },-                "sources": rec.get("sources", []),-            }-            if not gen_ok:-                entry["note"] = "No generated rule files: the vendor does not publish one or more of min trace width, min spacing, min drill for this process/layer count."-            index["vendors"].append(entry)-    (ROOT / "rules" / "index.json").write_text(json.dumps(index, indent=2) + "\n")--    table = gen_table(records)-    readme = (ROOT / "README.md").read_text()-    new, n = re.subn(-        r"<!-- TABLE:BEGIN -->.*?<!-- TABLE:END -->",-        "<!-- TABLE:BEGIN -->\n" + table + "\n<!-- TABLE:END -->",-        readme, flags=re.S)-    if n:-        (ROOT / "README.md").write_text(new)-    print(f"generated {len(records)} record sets across {len(rec_files)} vendors")-+#!/usr/bin/env python3
+"""Generate EDA rule files from the canonical records in rules/records/.
+
+Canonical unit is mm; mil values are derived (1 mil = 0.0254 mm) and rounded
+to 2 decimals. Every generated file carries its source URLs and read dates.
+
+Outputs:
+  rules/index.json
+  fusion/<slug>[-<process>]-<n>L.dru   (classic EAGLE text)
+  fusion/<slug>[-<process>]-<n>L.edru  (Fusion/EAGLE 9 XML, seed-compatible)
+  kicad/<slug>[-<process>]-<n>L.kicad_dru
+  kicad/<slug>[-<process>]-<n>L.board-setup.json
+  altium/<slug>[-<process>]-<n>L.RUL
+And rewrites the comparison table in README.md between the TABLE markers.
+"""
+
+import json
+import pathlib
+import re
+import sys
+
+ROOT = pathlib.Path(__file__).resolve().parent.parent
+MIL = 0.0254
+
+
+def mil(mm):
+    return round(mm / MIL, 2)
+
+
+def fmm(mm):
+    if mm is None:
+        return "n/a"
+    s = f"{mm:.4f}".rstrip("0").rstrip(".")
+    return s
+
+
+def slugify(s):
+    return re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")
+
+
+def file_slug(vendor_slug, process, layers):
+    p = "" if process == "standard" else f"-{process}"
+    return f"{vendor_slug}{p}-{layers}L"
+
+
+def pad_pad_spacing(rec):
+    sp = rec["minSpacing"]["mm"]
+    return max(sp, 0.15)
+
+
+def g(rec, key, sub="mm"):
+    v = rec.get(key)
+    if not v:
+        return None
+    return v.get(sub)
+
+
+LAYER_SETUPS = {2: "(1*16)", 4: "(1*2*15*16)", 6: "(1*2*3*14*15*16)", 8: "(1*2*3*4*13*14*15*16)"}
+
+
+def effective_via(rec):
+    """Return (drillMm, padMm or None), deriving pad from drill + 2x annular ring."""
+    via = rec.get("minVia") or {}
+    drill = via.get("drillMm")
+    if drill is None and rec.get("minDrill"):
+        drill = rec["minDrill"]["mm"]
+    pad = via.get("padMm") if via else None
+    if pad is None and drill is not None and rec.get("annularRing"):
+        pad = round(drill + 2 * rec["annularRing"]["viaMm"], 4)
+    return drill, pad
+
+
+def description_text(vendor, rec):
+    tw = rec["minTraceWidth"]
+    sp = rec["minSpacing"]
+    via_d, via_p = effective_via(rec)
+    lines = [
+        f"{vendor} {rec['layerCount']}-layer design rules ({rec['process']} process)",
+        "",
+        f"Min trace width: {fmm(tw['mm'])} mm ({tw['mil']} mil){' - ' + tw['condition'] if tw.get('condition') else ''}",
+        f"Min spacing: {fmm(sp['mm'])} mm ({sp['mil']} mil){' - ' + sp['condition'] if sp.get('condition') else ''}",
+        f"Min drill: {fmm(rec['minDrill']['mm'])} mm ({rec['minDrill']['mil']} mil)",
+        f"Min via: {fmm(via_d)} mm hole" + (f" / {fmm(via_p)} mm pad" if via_p else ""),
+    ]
+    if rec.get("annularRing"):
+        ar = rec["annularRing"]
+        lines.append(f"Annular ring: via {fmm(ar['viaMm'])} mm" + (f", pad {fmm(ar['padMm'])} mm" if ar.get("padMm") else ""))
+    if rec.get("copperToBoardEdge"):
+        lines.append(f"Copper to board edge: {fmm(rec['copperToBoardEdge']['mm'])} mm")
+    if rec.get("holeToHole"):
+        lines.append(f"Hole to hole: {fmm(rec['holeToHole']['mm'])} mm")
+    if rec.get("drillRange"):
+        dr = rec["drillRange"]
+        lines.append(f"Drill range: {fmm(dr['minMm'])} mm to {fmm(dr['maxMm'])} mm")
+    if rec.get("boardThicknessOptionsMm"):
+        lines.append("Board thickness options: " + ", ".join(f"{t} mm" for t in rec["boardThicknessOptionsMm"]))
+    if rec.get("copperWeightOptionsOz"):
+        lines.append("Copper weight options: " + ", ".join(f"{w} oz" for w in rec["copperWeightOptionsOz"]))
+    if rec.get("surfaceFinishes"):
+        lines.append("Surface finishes: " + ", ".join(rec["surfaceFinishes"]))
+    if rec.get("maxBoardSize"):
+        mb = rec["maxBoardSize"]
+        lines.append(f"Max board size: {fmm(mb['xMm'])} x {fmm(mb['yMm'])} mm")
+    if rec.get("notes"):
+        lines += ["", rec["notes"]]
+    lines += ["", "Sources:"]
+    for s in rec.get("sources", []):
+        lines.append(f"- {s['url']} (read {s['dateRead']})")
+    return "\n".join(lines)
+
+
+# ---------------------------------------------------------------- EAGLE .edru
+
+def gen_edru(vendor, rec):
+    tw = rec["minTraceWidth"]["mm"]
+    sp = rec["minSpacing"]["mm"]
+    dr = rec["minDrill"]["mm"]
+    pp = pad_pad_spacing(rec)
+    c2e = g(rec, "copperToBoardEdge") or 0.2
+    h2h = g(rec, "holeToHole") or 0.2
+    arv = (g(rec, "annularRing", "viaMm")) or 0.1
+    arp = (g(rec, "annularRing", "padMm")) or 0.18
+    layers = rec["layerCount"]
+    name = file_slug(slugify(vendor), rec["process"], layers)
+
+    def clearance_rules():
+        rows = []
+        combos = [
+            ("is_wire_polygon", "is_wire_polygon"),
+            ("is_wire_polygon", "is_pad_smd"),
+            ("is_wire_polygon", "is_via"),
+            ("is_pad_smd", "is_pad_smd"),
+            ("is_pad_smd", "is_via"),
+            ("is_via", "is_via"),
+        ]
+        same = [
+            ("is_smd", "is_pad"),
+            ("is_smd", "is_via"),
+            ("is_smd", "is_smd"),
+        ]
+        prio = 1
+        for i, (a, b) in enumerate(combos):
+            v = pp if (a, b) == ("is_pad_smd", "is_pad_smd") else sp
+            pv = v if (a, b) == ("is_pad_smd", "is_pad_smd") else max(sp * 2, v)
+            rows.append(f' <rule type="Copper Clearance" builtin_ruleid="{i}" enabled="yes" priority="{prio}" onescope="{a}" otherscope="{b}" value="{fmm(v)}mm" preferredvalue="{fmm(pv)}mm" samesignal="no" name="copper-clearance-{i+1}"/>')
+            prio += 1
+        for j, (a, b) in enumerate(same):
+            rows.append(f' <rule type="Copper Clearance" builtin_ruleid="{6+j}" enabled="yes" priority="{prio}" onescope="{a}" otherscope="{b}" value="{fmm(sp)}mm" preferredvalue="{fmm(max(sp*2, sp))}mm" samesignal="yes" name="copper-clearance-{7+j}"/>')
+            prio += 1
+        return "\n".join(rows)
+
+    rules_xml = f'''<?xml version="1.0" encoding="utf-8"?>
+<!DOCTYPE designrules SYSTEM "eagle.dtd">
+<eagle version="9.7.0">
+<designrules name="{name}.edru">
+<classes>
+<class number="0" name="default">
+</class>
+<class number="1" name="pwr">
+</class>
+</classes>
+<rules>
+<rulecategory type="Copper Clearance" manualdrc="yes" livedrc="yes"/>
+ <rulecategory type="Keepout Clearance" manualdrc="yes" livedrc="yes"/>
+ <rulecategory type="Solder-Mask Clearance" manualdrc="yes" livedrc="yes"/>
+ <rulecategory type="Dimension Clearance" manualdrc="yes" livedrc="yes"/>
+ <rulecategory type="Drill Distance" manualdrc="yes" livedrc="yes"/>
+ <rulecategory type="Restrict Clearance" manualdrc="yes" livedrc="yes"/>
+ <rulecategory type="Minimum Drill Size" manualdrc="yes" livedrc="yes"/>
+ <rulecategory type="Minimum Copper Width" manualdrc="yes" livedrc="yes"/>
+ <rulecategory type="Acute Angle" manualdrc="yes" livedrc="yes"/>
+ <rulecategory type="Air-Wire" manualdrc="yes" livedrc="yes"/>
+ <rulecategory type="Small Gap" manualdrc="yes" livedrc="yes"/>
+ <rulecategory type="Wire Stub" manualdrc="yes" livedrc="no"/>
+ <rulecategory type="Matched Lengths" manualdrc="yes" livedrc="yes"/>
+ <rulecategory type="Via Restrict Clearance" manualdrc="yes" livedrc="yes"/>
+ <rule type="Minimum Copper Width" enabled="yes" priority="0" onescope="classes=1" value="{fmm(tw)}mm" preferredvalue="{fmm(max(tw*3, 0.3))}mm" check_polywidth="no" name="Trace size for Power"/>
+ <rule type="Minimum Copper Width" builtin_ruleid="14" enabled="yes" priority="1" value="{fmm(tw)}mm" preferredvalue="{fmm(max(tw*2, 0.2))}mm" check_polywidth="no" name="minimum-copper-width-1"/>
+ <rule type="Minimum Drill Size" enabled="yes" priority="0" onescope="is_via" value="{fmm(dr)}mm" preferredvalue="{fmm(max(dr*2, 0.2))}mm" name="Min Via hole size on {layers}-layer"/>
+ <rule type="Minimum Drill Size" enabled="no" priority="1" onescope="all" value="0.5mm" preferredvalue="0.5mm" name="Min Non-plated holes"/>
+ <rule type="Minimum Drill Size" builtin_ruleid="15" enabled="yes" priority="2" value="{fmm(dr)}mm" preferredvalue="{fmm(max(dr*2, 0.25))}mm" name="minimum-drill-size-1"/>
+ <rule type="Acute Angle" builtin_ruleid="16" enabled="no" priority="0" name="acute-angle-1"/>
+ <rule type="Wire Stub" builtin_ruleid="17" enabled="yes" priority="0" name="wire-stub-1"/>
+ <rule type="Air-Wire" builtin_ruleid="19" enabled="yes" priority="0" name="air-wire-1"/>
+ <rule type="Matched Lengths" builtin_ruleid="20" enabled="yes" priority="0" tolerance="10mm" gapfactor="2.5" name="matched-lengths-1"/>
+ <rule type="Copper Clearance" enabled="no" priority="0" onescope="all" otherscope="all" value="{fmm(max(sp,0.25))}mm" preferredvalue="{fmm(max(sp,0.25))}mm" samesignal="yes" bothscope="all" name="Same-net track spacing"/>
+{clearance_rules()}
+ <rule type="Dimension Clearance" builtin_ruleid="9" enabled="yes" priority="0" value="{fmm(c2e)}mm" preferredvalue="{fmm(max(c2e, 0.4))}mm" name="dimension-clearance-1"/>
+ <rule type="Via Restrict Clearance" builtin_ruleid="21" enabled="yes" priority="0" value="0mil" preferredvalue="0mil" name="via-restrict-clearance-1"/>
+ <rule type="Restrict Clearance" builtin_ruleid="10" enabled="yes" priority="0" value="0mil" preferredvalue="0mil" name="restrict-clearance-1"/>
+ <rule type="Small Gap" builtin_ruleid="18" enabled="yes" priority="0" name="small-gap-1"/>
+ <rule type="Solder-Mask Clearance" builtin_ruleid="11" enabled="no" priority="0" value="0mil" preferredvalue="0mil" check_name="yes" check_value="no" name="solder-mask-clearance-1"/>
+ <rule type="Drill Distance" builtin_ruleid="12" enabled="yes" priority="0" value="{fmm(h2h)}mm" preferredvalue="{fmm(h2h)}mm" name="drill-distance-1"/>
+ <rule type="Keepout Clearance" builtin_ruleid="13" enabled="yes" priority="0" value="0mil" preferredvalue="0mil" name="keepout-clearance-1"/>
+ </rules>
+<param name="mdWireWire" value="{fmm(sp)}mm"/>
+<param name="mdWirePad" value="{fmm(sp)}mm"/>
+<param name="mdWireVia" value="{fmm(sp)}mm"/>
+<param name="mdPadPad" value="{fmm(pp)}mm"/>
+<param name="mdPadVia" value="{fmm(sp)}mm"/>
+<param name="mdViaVia" value="{fmm(sp)}mm"/>
+<param name="mdSmdPad" value="{fmm(sp)}mm"/>
+<param name="mdSmdVia" value="{fmm(sp)}mm"/>
+<param name="mdSmdSmd" value="{fmm(sp)}mm"/>
+<param name="mdCopperDimension" value="{fmm(c2e)}mm"/>
+<param name="mdDrill" value="{fmm(h2h)}mm"/>
+<param name="msWidth" value="{fmm(tw)}mm"/>
+<param name="msDrill" value="{fmm(dr)}mm"/>
+<param name="dpMaxLengthDifference" value="10mm"/>
+<param name="dpGapFactor" value="2.5"/>
+<description language="en">{description_text(vendor, rec)}
+</description>
+<param name="layerSetup" value="{LAYER_SETUPS[layers]}"/>
+<param name="mtCopper" value="{' '.join(['0.035mm'] * 16)}"/>
+<param name="mtIsolate" value="1.6mm 0.15mm 0.2mm 0.15mm 0.2mm 0.15mm 0.2mm 0.15mm 0.2mm 0.15mm 0.2mm 0.15mm 0.2mm 0.15mm 0.2mm"/>
+<param name="mdViaViaSameLayer" value="6mil"/>
+<param name="mnLayersViaInSmd" value="2"/>
+<param name="mdSmdStop" value="0mil"/>
+<param name="msMicroVia" value="0.3mm"/>
+<param name="msBlindViaRatio" value="0.5"/>
+<param name="rvPadTop" value="0"/>
+<param name="rvPadInner" value="0"/>
+<param name="rvPadBottom" value="0"/>
+<param name="rvViaOuter" value="0"/>
+<param name="rvViaInner" value="0"/>
+<param name="rvMicroViaOuter" value="0"/>
+<param name="rvMicroViaInner" value="0"/>
+<param name="rlMinPadTop" value="{fmm(arp)}mm"/>
+<param name="rlMaxPadTop" value="80mil"/>
+<param name="rlMinPadInner" value="{fmm(arp)}mm"/>
+<param name="rlMaxPadInner" value="20mil"/>
+<param name="rlMinPadBottom" value="{fmm(arp)}mm"/>
+<param name="rlMaxPadBottom" value="80mil"/>
+<param name="rlMinViaOuter" value="{fmm(arv)}mm"/>
+<param name="rlMaxViaOuter" value="20mil"/>
+<param name="rlMinViaInner" value="{fmm(arv)}mm"/>
+<param name="rlMaxViaInner" value="20mil"/>
+<param name="rlMinMicroViaOuter" value="{fmm(arv)}mm"/>
+<param name="rlMaxMicroViaOuter" value="20mil"/>
+<param name="rlMinMicroViaInner" value="{fmm(arv)}mm"/>
+<param name="rlMaxMicroViaInner" value="20mil"/>
+<param name="psTop" value="-1"/>
+<param name="psBottom" value="-1"/>
+<param name="psFirst" value="-1"/>
+<param name="psElongationLong" value="100"/>
+<param name="psElongationOffset" value="100"/>
+<param name="mvStopFrame" value="0"/>
+<param name="mvCreamFrame" value="0"/>
+<param name="mlMinStopFrame" value="0.038mm"/>
+<param name="mlMaxStopFrame" value="4mil"/>
+<param name="mlMinCreamFrame" value="0mil"/>
+<param name="mlMaxCreamFrame" value="0mil"/>
+<param name="mlViaStopLimit" value="0.5mm"/>
+<param name="srRoundness" value="0"/>
+<param name="srMinRoundness" value="0mil"/>
+<param name="srMaxRoundness" value="0mil"/>
+<param name="slThermalIsolate" value="10mil"/>
+<param name="slThermalsForVias" value="0"/>
+<param name="checkAngle" value="0"/>
+<param name="checkRestrict" value="1"/>
+<param name="checkStop" value="0"/>
+<param name="checkValues" value="0"/>
+<param name="checkNames" value="1"/>
+<param name="checkWireStubs" value="1"/>
+<param name="checkPolygonWidth" value="0"/>
+<param name="useDiameter" value="13"/>
+<param name="removeAnnularRing" value="0"/>
+<param name="maxErrors" value="50"/>
+ </designrules>
+</eagle>
+'''
+    return rules_xml
+
+
+# ---------------------------------------------------------------- EAGLE .dru
+
+def gen_dru(vendor, rec):
+    tw = rec["minTraceWidth"]
+    sp = rec["minSpacing"]
+    dr = rec["minDrill"]
+    via = rec["minVia"]
+    c2e = g(rec, "copperToBoardEdge") or 0.2
+    h2h = g(rec, "holeToHole") or 0.2
+    arv = (g(rec, "annularRing", "viaMm")) or 0.1
+    arp = (g(rec, "annularRing", "padMm")) or 0.18
+    layers = rec["layerCount"]
+    pp = pad_pad_spacing(rec)
+    desc = description_text(vendor, rec).replace("\n", " | ")
+    lines = [
+        f"description[{desc}]",
+        "",
+        "layerSetup = " + LAYER_SETUPS[layers],
+        "mtCopper = " + " ".join(["0.035mm"] * 16),
+        "mtIsolate = 1.6mm 0.15mm 0.2mm 0.15mm 0.2mm 0.15mm 0.2mm 0.15mm 0.2mm 0.15mm 0.2mm 0.15mm 0.2mm 0.15mm 0.2mm",
+        f"mdWireWire = {mil(sp['mm'])}mil",
+        f"mdWirePad = {mil(sp['mm'])}mil",
+        f"mdWireVia = {mil(sp['mm'])}mil",
+        f"mdPadPad = {mil(pp)}mil",
+        f"mdPadVia = {mil(sp['mm'])}mil",
+        f"mdViaVia = {mil(sp['mm'])}mil",
+        f"mdSmdPad = {mil(sp['mm'])}mil",
+        f"mdSmdVia = {mil(sp['mm'])}mil",
+        f"mdSmdSmd = {mil(sp['mm'])}mil",
+        f"mdCopperDimension = {mil(c2e)}mil",
+        f"mdDrill = {mil(h2h)}mil",
+        f"msWidth = {mil(tw['mm'])}mil",
+        f"msDrill = {mil(dr['mm'])}mil",
+        "mdViaViaSameLayer = 6mil",
+        "mdSmdStop = 0mil",
+        "msMicroVia = 12mil",
+        "msBlindViaRatio = 0.5",
+        "rvPadTop = 0",
+        "rvPadInner = 0",
+        "rvPadBottom = 0",
+        "rvViaOuter = 0",
+        "rvViaInner = 0",
+        "rvMicroViaOuter = 0",
+        "rvMicroViaInner = 0",
+        f"rlMinPadTop = {mil(arp)}mil",
+        "rlMaxPadTop = 80mil",
+        f"rlMinPadInner = {mil(arp)}mil",
+        "rlMaxPadInner = 20mil",
+        f"rlMinPadBottom = {mil(arp)}mil",
+        "rlMaxPadBottom = 80mil",
+        f"rlMinViaOuter = {mil(arv)}mil",
+        "rlMaxViaOuter = 20mil",
+        f"rlMinViaInner = {mil(arv)}mil",
+        "rlMaxViaInner = 20mil",
+        f"rlMinMicroViaOuter = {mil(arv)}mil",
+        "rlMaxMicroViaOuter = 20mil",
+        f"rlMinMicroViaInner = {mil(arv)}mil",
+        "rlMaxMicroViaInner = 20mil",
+        "psTop = -1",
+        "psBottom = -1",
+        "psFirst = -1",
+        "psElongationLong = 100",
+        "psElongationOffset = 100",
+        "mvStopFrame = 0",
+        "mvCreamFrame = 0",
+        "mlMinStopFrame = 1.5mil",
+        "mlMaxStopFrame = 4mil",
+        "mlMinCreamFrame = 0mil",
+        "mlMaxCreamFrame = 0mil",
+        "mlViaStopLimit = 20mil",
+        "srRoundness = 0",
+        "srMinRoundness = 0mil",
+        "srMaxRoundness = 0mil",
+        "slThermalIsolate = 10mil",
+        "slThermalsForVias = 0",
+        "dpMaxLengthDifference = 400mil",
+        "dpGapFactor = 2.5",
+        "checkAngle = 0",
+        "checkRestrict = 1",
+        "checkStop = 0",
+        "checkValues = 0",
+        "checkNames = 1",
+        "checkWireStubs = 1",
+        "checkPolygonWidth = 0",
+        "useDiameter = 13",
+        "maxErrors = 50",
+        "",
+    ]
+    return "\n".join(lines)
+
+
+# ---------------------------------------------------------------- KiCad
+
+def gen_kicad_dru(vendor, rec):
+    tw = rec["minTraceWidth"]["mm"]
+    sp = rec["minSpacing"]["mm"]
+    dr = rec["minDrill"]["mm"]
+    via_d, via_p = effective_via(rec)
+    c2e = g(rec, "copperToBoardEdge")
+    silk = g(rec, "silkLineWidth")
+    mask = g(rec, "maskExpansion")
+    layers = rec["layerCount"]
+    desc = description_text(vendor, rec)
+    out = [
+        "(version 1)",
+        "",
+        "# " + desc.replace("\n", "\n# "),
+        "",
+        '(rule "min-track-width"',
+        f"  (constraint track_width (min {fmm(tw)}mm))",
+        f"  (layer \"{'*.Cu' if layers == 2 else '*.Cu'}\")",
+        "  (condition \"A.type == 'track'\"))",
+        "",
+        '(rule "min-clearance"',
+        f"  (constraint clearance (min {fmm(sp)}mm))",
+        "  )" if vendor == "JLCPCB" else "  (condition \"A.type == 'track' && B.type == 'track'\"))",
+        "",
+        '(rule "min-hole"',
+        f"  (constraint hole_size (min {fmm(dr)}mm))",
+        "  (condition \"A.type == 'pad' || A.type == 'via'\"))",
+        "",
+    ]
+    if via_p:
+        out += [
+            '(rule "min-via-diameter"',
+            f"  (constraint via_diameter (min {fmm(via_p)}mm))",
+            "  (condition \"A.type == 'via'\"))",
+            "",
+        ]
+    if c2e:
+        out += [
+            '(rule "min-edge-clearance"',
+            f"  (constraint edge_clearance (min {fmm(c2e)}mm))",
+            "  (condition \"A.type == 'track'\"))",
+            "",
+        ]
+    if silk:
+        out += [
+            '(rule "min-silk-thickness"',
+            f"  (constraint text_thickness (min {fmm(silk)}mm))",
+            "  (layer \"*.SilkS\")",
+            "  (condition \"A.type == 'text' || A.type == 'graphic'\"))",
+            "",
+        ]
+    return "\n".join(out)
+
+
+def gen_kicad_board_setup(vendor, rec):
+    via_d, via_p = effective_via(rec)
+    setup = {
+        "vendor": vendor,
+        "process": rec["process"],
+        "layerCount": rec["layerCount"],
+        "boardSetupConstraints": {
+            "minTrackWidthMm": rec["minTraceWidth"]["mm"],
+            "minClearanceMm": rec["minSpacing"]["mm"],
+            "minViaDrillMm": via_d,
+            "minViaDiameterMm": via_p,
+            "minHoleMm": rec["minDrill"]["mm"],
+            "copperEdgeClearanceMm": g(rec, "copperToBoardEdge"),
+            "holeToHoleMm": g(rec, "holeToHole"),
+            "annularRingViaMm": g(rec, "annularRing", "viaMm"),
+            "annularRingPadMm": g(rec, "annularRing", "padMm"),
+            "silkMinLineWidthMm": g(rec, "silkLineWidth"),
+            "maskExpansionMm": g(rec, "maskExpansion"),
+        },
+        "notes": "Apply as Board Setup, Constraints. The matching .kicad_dru file carries the same limits as custom rules; the two are redundant on purpose so both the GUI constraints and kicad-cli DRC enforce them.",
+        "sources": rec.get("sources", []),
+    }
+    if rec.get("silkscreenPolicy"):
+        setup["silkscreenPolicy"] = rec["silkscreenPolicy"]
+    return json.dumps(setup, indent=2) + "\n"
+
+
+# ---------------------------------------------------------------- Altium .RUL
+
+def gen_rul(vendor, rec):
+    tw = rec["minTraceWidth"]
+    sp = rec["minSpacing"]
+    dr = rec["minDrill"]
+    via_d, via_p = effective_via(rec)
+    c2e = g(rec, "copperToBoardEdge")
+    arv = g(rec, "annularRing", "viaMm")
+    rules = [
+        ("Width", "MinTraceWidth", "MinWidth=%gmm|PreferredWidth=%gmm|MaxWidth=25.4mm" % (tw["mm"], tw["mm"] * 2)),
+        ("Clearance", "MinClearance", "Clearance=%gmm" % sp["mm"]),
+    ]
+    if via_p:
+        rules.append(("RoutingViaStyle", "MinVia", "PreferredViaWidth=%gmm|PreferredViaHoleWidth=%gmm|MinViaWidth=%gmm|MaxViaWidth=6.35mm|MinViaHoleWidth=%gmm|MaxViaHoleWidth=6.35mm" % (via_p, via_d, via_p, via_d)))
+    rules.append(("HoleSize", "MinHole", "HoleWidth=%gmm" % dr["mm"]))
+    if arv:
+        rules.append(("AnnularRing", "MinAnnularRing", "MinAnnularRing=%gmm" % arv))
+    if c2e:
+        rules.append(("BoardOutlineClearance", "CopperToBoardEdge", "Clearance=%gmm" % c2e))
+    out = ["[Design Rules]", f"RuleCount={len(rules)}", ""]
+    for i, (kind, name, attrs) in enumerate(rules, 1):
+        out += [
+            f"[Rule{i}]",
+            f"RuleKind={kind}",
+            f"RuleName={name}",
+            "Scope1=All",
+            "Scope2=",
+            f"Attribute={attrs}",
+            "",
+        ]
+    return "\n".join(out)
+
+
+# ---------------------------------------------------------------- table
+
+def mm_mil(v):
+    if not v:
+        return "n/p"
+    return f"{fmm(v['mm'])} / {v['mil']}"
+
+
+def gen_table(records):
+    header = "| Vendor | Process | Layers | Min trace mm / mil | Min space mm / mil | Min drill mm / mil | Min via drill / pad mm | Annular ring via / pad mm | Cu to edge mm | Hole to hole mm | Source (read 2026-09-03) |"
+    sep = "|---|---|---|---|---|---|---|---|---|---|---|---|"
+    rows = [header, sep]
+    order = {v: i for i, v in enumerate([
+        "3rd party fab", "JLCPCB", "PCBWay", "OSH Park", "Seeed Fusion",
+        "Eurocircuits", "Aisler", "Elecrow", "Advanced Circuits", "Sierra Circuits",
+        "MacroFab", "NextPCB", "ALLPCB",
+    ])}
+    for r in sorted(records, key=lambda r: (order.get(r["vendor"], 99), r["process"], r["layerCount"])):
+        via = r["minVia"]
+        ar = r.get("annularRing") or {}
+        src = r["sources"][0]["url"] if r.get("sources") else ""
+        via_d, via_p = effective_via(r)
+        rows.append(
+            "| {vendor} | {proc} | {layers} | {tw} | {sp} | {dr} | {via} | {ar} | {c2e} | {h2h} | {src} |".format(
+                vendor=r["vendor"], proc=r["process"], layers=r["layerCount"],
+                tw=mm_mil(r.get("minTraceWidth")), sp=mm_mil(r.get("minSpacing")),
+                dr=mm_mil(r.get("minDrill")),
+                via=(f"{fmm(via_d)} / {fmm(via_p)}" if via_d else "n/p"),
+                ar=(f"{fmm(ar.get('viaMm'))} / {fmm(ar.get('padMm')) if ar.get('padMm') else 'n/p'}" if ar and ar.get("viaMm") else "n/p"),
+                c2e=(fmm(g(r, "copperToBoardEdge")) if g(r, "copperToBoardEdge") else "n/p"),
+                h2h=(fmm(g(r, "holeToHole")) if g(r, "holeToHole") else "n/p"),
+                src=src,
+            ))
+    return "\n".join(rows)
+
+
+# ---------------------------------------------------------------- main
+
+def main():
+    rec_files = sorted((ROOT / "rules" / "records").glob("*.json"))
+    if not rec_files:
+        sys.exit("no records found")
+    records = []
+    index = {"generatedBy": "tools/generate.py", "unitNote": "mm primary, mil secondary (1 mil = 0.0254 mm)", "vendors": []}
+    for rf in rec_files:
+        data = json.loads(rf.read_text())
+        for rec in data["records"]:
+            rec = dict(rec)
+            rec["vendor"] = data["vendor"]
+            rec["vendorSlug"] = data["vendorSlug"]
+            records.append(rec)
+            vs = file_slug(data["vendorSlug"], rec["process"], rec["layerCount"])
+            gen_ok = all(rec.get(k) for k in ("minTraceWidth", "minSpacing", "minDrill"))
+            if gen_ok:
+                (ROOT / "fusion" / f"{vs}.edru").write_text(gen_edru(data["vendor"], rec))
+                (ROOT / "fusion" / f"{vs}.dru").write_text(gen_dru(data["vendor"], rec))
+                (ROOT / "kicad" / f"{vs}.kicad_dru").write_text(gen_kicad_dru(data["vendor"], rec))
+                (ROOT / "kicad" / f"{vs}.board-setup.json").write_text(gen_kicad_board_setup(data["vendor"], rec))
+                (ROOT / "altium" / f"{vs}.RUL").write_text(gen_rul(data["vendor"], rec))
+            entry = {
+                "vendor": data["vendor"], "vendorSlug": data["vendorSlug"],
+                "process": rec["process"], "layerCount": rec["layerCount"],
+                "filesGenerated": gen_ok,
+                "files": {
+                    "record": f"rules/records/{data['vendorSlug']}.json",
+                    "fusionEdru": f"fusion/{vs}.edru",
+                    "fusionDru": f"fusion/{vs}.dru",
+                    "kicadDru": f"kicad/{vs}.kicad_dru",
+                    "kicadBoardSetup": f"kicad/{vs}.board-setup.json",
+                    "altiumRul": f"altium/{vs}.RUL",
+                },
+                "sources": rec.get("sources", []),
+            }
+            if not gen_ok:
+                entry["note"] = "No generated rule files: the vendor does not publish one or more of min trace width, min spacing, min drill for this process/layer count."
+            index["vendors"].append(entry)
+    (ROOT / "rules" / "index.json").write_text(json.dumps(index, indent=2) + "\n")
+
+    table = gen_table(records)
+    readme = (ROOT / "README.md").read_text()
+    new, n = re.subn(
+        r"<!-- TABLE:BEGIN -->.*?<!-- TABLE:END -->",
+        "<!-- TABLE:BEGIN -->\n" + table + "\n<!-- TABLE:END -->",
+        readme, flags=re.S)
+    if n:
+        (ROOT / "README.md").write_text(new)
+    print(f"generated {len(records)} record sets across {len(rec_files)} vendors")
+
+
+if __name__ == "__main__":
+    main()
 -if __name__ == "__main__":-    main()

Comments

No comments yet.

Log in to comment.