← All Pull Requests

Preserve InstaPCB constraints in KiCad rules and derive legal via minima #2

Open Draft opened by John Lauer 2026-09-10

The InstaPCB 2L KiCad file omits checks promised by its source profile, and its quoted minimum via pair violates the independently stated annular-ring minimum. Preserve the source values while deriving a legal via combination, carry the explicit 0.15 mm pad-pair limit, and export documented KiCad constraint syntax with copper/edge coverage, separate via/PTH rings and hole spacing.

Validation: eight source-contract regression tests pass. Current generator and record were re-read before editing. This draft has NOT been loaded into native KiCad with project rules. The shared DRC endpoint transports only the board, preventing that project-level check. Native negative-control fixtures and KiCad/Fusion/Altium parity are required before release.

Scope: focused candidate for the current 2L project. Altium pair-specific rules, 4L pad-pair source audit and the existing universal Fusion 0.15 mm clamp remain open. The board-setup JSON is explicitly described as an adapter contract rather than a native .kicad_pro file. Test with python3 tools/test_constraints.py. No installed bridge or shared production profile was replaced.

Diff Skip to comments

--- a/tools/generate.py+++ b/tools/generate.py@@ -1,571 +1,562 @@⋯ 63 unchanged lines ⋯     drill = via.get("drillMm")     if drill is None and rec.get("minDrill"):         drill = rec["minDrill"]["mm"]+    if drill is not None and rec.get("minDrill"):+        drill = max(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)+    # Each field is an independent minimum, not a guaranteed legal pair.+    ring = (rec.get("annularRing") or {}).get("viaMm")+    if drill is not None and ring is not None:+        required = round(drill + 2 * ring, 4)+        pad = max(pad or 0, required)     return drill, pad  ⋯ 293 unchanged lines ⋯ # ---------------------------------------------------------------- KiCad  def gen_kicad_dru(vendor, rec):+    """Export source constraints for KiCad 9/10; native DRC qualification is separate."""     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))",-        "  (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'\"))",-            "",-        ]+    out = ["(version 1)", "", "# " + description_text(vendor, rec).replace("\n", "\n# "), ""]++    def rule(name, kind, minimum, condition=None, layer=None):+        if minimum is None:+            return+        rows = [f'(rule "{name}"', f'  (constraint {kind} (min {fmm(minimum)}mm))']+        if layer:+            rows.append(f'  (layer "{layer}")')+        if condition:+            rows.append(f'  (condition "{condition}")')+        rows[-1] += ")"+        out.extend(rows + [""])++    # Unconditional electrical clearance covers tracks, arcs, pads, vias and zones.+    # KiCad evaluates clearance on different nets; do not add type filters here.+    rule("min-track-width", "track_width", tw)+    rule("min-clearance", "clearance", sp)+    # Later specific rules override earlier general rules in KiCad.+    pp = g(rec, "minPadSpacing")+    if pp is not None:+        rule("min-pad-clearance", "clearance", max(sp, pp), "A.Type == 'Pad' && B.Type == 'Pad'")+    rule("min-hole", "hole_size", dr)+    rule("min-via-hole", "hole_size", via_d, "A.Type == 'Via'")+    rule("min-via-diameter", "via_diameter", via_p, "A.Type == 'Via'")+    rule("min-via-annular-width", "annular_width", g(rec, "annularRing", "viaMm"), "A.Type == 'Via'")+    rule("min-pad-annular-width", "annular_width", g(rec, "annularRing", "padMm"), "A.Type == 'Pad' && A.Pad_Type == 'Through-hole'")+    rule("min-edge-clearance", "edge_clearance", g(rec, "copperToBoardEdge"))+    rule("min-hole-to-hole", "hole_to_hole", g(rec, "holeToHole"))+    rule("min-silk-thickness", "text_thickness", g(rec, "silkLineWidth"), "A.Type == 'Text' || A.Type == 'Text Box'", "*.SilkS")     return "\n".join(out)  ⋯ 6 unchanged lines ⋯         "boardSetupConstraints": {             "minTrackWidthMm": rec["minTraceWidth"]["mm"],             "minClearanceMm": rec["minSpacing"]["mm"],+            "minPadClearanceMm": g(rec, "minPadSpacing"),             "minViaDrillMm": via_d,             "minViaDiameterMm": via_p,             "minHoleMm": rec["minDrill"]["mm"],⋯ 4 unchanged lines ⋯             "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.",+        "notes": "Apply as Board Setup, Constraints. Apply the matching .kicad_dru alongside the board and project with the same stem. This JSON is an adapter contract, not a native .kicad_pro file. Separate pad/via rules and pad-pair clearance need custom rules. Confirm loaded source hashes and native negative-control findings before claiming enforcement.",         "sources": rec.get("sources", []),     }     return json.dumps(setup, indent=2) + "\n"⋯ 124 unchanged lines ⋯  if __name__ == "__main__":     main()+--- a/tools/test_constraints.py+++ b/tools/test_constraints.py@@ -0,0 +1,28 @@+"""Source-contract regression tests; not a substitute for native DRC execution."""+import unittest,importlib.util,json,copy+from pathlib import Path+spec=importlib.util.spec_from_file_location('rules',Path(__file__).with_name('generate.py'));g=importlib.util.module_from_spec(spec);spec.loader.exec_module(g)+BASE=Path(__file__).resolve().parents[1]+REC=json.loads((BASE/'rules/records/adom-instapcb.json').read_text())['records'][0]+class Constraints(unittest.TestCase):+ def test_declared_via_minima_are_not_a_legal_pair(self):+  d,p=g.effective_via(REC);self.assertAlmostEqual(d,.15);self.assertAlmostEqual(p,.35);self.assertEqual(REC['minVia']['padMm'],.25)+ def test_ring_grows_with_drill(self):+  r=copy.deepcopy(REC);r['minVia']['drillMm']=.4;d,p=g.effective_via(r);self.assertGreaterEqual((p-d)/2+.00000001,r['annularRing']['viaMm'])+ def test_global_drill_minimum_cannot_be_weakened(self):+  r=copy.deepcopy(REC);r['minDrill']['mm']=.5;self.assertEqual(g.effective_via(r),(.5,.7))+ def test_no_ring_claim_when_source_absent(self):+  r=copy.deepcopy(REC);r.pop('annularRing');self.assertEqual(g.effective_via(r),(.15,.25))+ def test_scope_covers_non_track_copper_and_edges(self):+  text=g.gen_kicad_dru('Adom InstaPCB',REC)+  self.assertIn('(rule "min-clearance"\n  (constraint clearance (min 0.1mm)))',text)+  self.assertIn('(rule "min-edge-clearance"\n  (constraint edge_clearance (min 0.2mm)))',text)+  self.assertGreater(text.index('min-pad-clearance'),text.index('min-clearance'))+ def test_native_constraint_names_and_separate_ring_limits(self):+  t=g.gen_kicad_dru('Adom InstaPCB',REC);self.assertNotIn('(constraint hole ',t);self.assertIn('hole_size',t);self.assertIn('(constraint annular_width (min 0.18mm))',t);self.assertIn('(constraint annular_width (min 0.1mm))',t);self.assertIn('(constraint hole_to_hole (min 0.2mm))',t)+ def test_absent_pad_pair_source_does_not_invent_fab_requirement(self):+  r=copy.deepcopy(REC);r.pop('minPadSpacing');self.assertNotIn('min-pad-clearance',g.gen_kicad_dru('Other process',r))+ def test_board_setup_exports_sourced_pair_minimum(self):+  v=json.loads(g.gen_kicad_board_setup('Adom InstaPCB',REC));self.assertEqual(v['boardSetupConstraints']['minPadClearanceMm'],.15);self.assertIn('not a native .kicad_pro',v['notes'])+if __name__=='__main__':unittest.main(verbosity=2)+--- a/rules/records/adom-instapcb.json+++ b/rules/records/adom-instapcb.json@@ -1,57 +1,164 @@⋯ 5 unchanged lines ⋯     {       "process": "standard",       "layerCount": 2,-      "minTraceWidth": {"mm": 0.10, "mil": 3.94, "condition": "1 oz copper"},-      "minSpacing": {"mm": 0.10, "mil": 3.94, "condition": "1 oz copper, different nets"},-      "minDrill": {"mm": 0.15, "mil": 5.91},-      "minVia": {"drillMm": 0.15, "drillMil": 5.91, "padMm": 0.25, "padMil": 9.84},-      "annularRing": {"viaMm": 0.10, "viaMil": 3.94, "padMm": 0.18, "padMil": 7.09, "note": "0.25 mm or above preferred"},-      "copperToBoardEdge": {"mm": 0.20, "mil": 7.87},-      "holeToHole": {"mm": 0.20, "mil": 7.87},-      "maskExpansion": {"mm": 0.038, "mil": 1.50},+      "minTraceWidth": {+        "mm": 0.1,+        "mil": 3.94,+        "condition": "1 oz copper"+      },+      "minSpacing": {+        "mm": 0.1,+        "mil": 3.94,+        "condition": "1 oz copper, different nets"+      },+      "minDrill": {+        "mm": 0.15,+        "mil": 5.91+      },+      "minVia": {+        "drillMm": 0.15,+        "drillMil": 5.91,+        "padMm": 0.25,+        "padMil": 9.84+      },+      "annularRing": {+        "viaMm": 0.1,+        "viaMil": 3.94,+        "padMm": 0.18,+        "padMil": 7.09,+        "note": "0.25 mm or above preferred"+      },+      "copperToBoardEdge": {+        "mm": 0.2,+        "mil": 7.87+      },+      "holeToHole": {+        "mm": 0.2,+        "mil": 7.87+      },+      "maskExpansion": {+        "mm": 0.038,+        "mil": 1.5+      },       "maskSliver": null,       "silkLineWidth": null,       "silkToCopper": null,-      "minCopperClearance": {"mm": 0.10, "mil": 3.94, "condition": "different nets"},-      "boardThicknessOptionsMm": [1.6],-      "copperWeightOptionsOz": [1],+      "minCopperClearance": {+        "mm": 0.1,+        "mil": 3.94,+        "condition": "different nets"+      },+      "boardThicknessOptionsMm": [+        1.6+      ],+      "copperWeightOptionsOz": [+        1+      ],       "surfaceFinishes": [],       "maxBoardSize": null,-      "drillRange": {"minMm": 0.15, "maxMm": 6.3, "note": "holes with diameter >= 6.3 mm are CNC routed from a smaller drilled hole; min non-plated hole 0.50 mm"},+      "drillRange": {+        "minMm": 0.15,+        "maxMm": 6.3,+        "note": "holes with diameter >= 6.3 mm are CNC routed from a smaller drilled hole; min non-plated hole 0.50 mm"+      },       "notes": "House baseline of the Adom InstaPCB fab service. Preferred values in the seed file: default class preferred track 0.2 mm, power class preferred 0.3 mm; preferred via hole 0.2 mm (0.25 mm in the shipped file). Min via allowed on 2-layer.",       "sources": [-        {"url": "adom/fusion-bridge addin/AdomBridge/resources/dru/Adom-InstaPCB-2-Layer.edru", "dateRead": "2026-09-03", "kind": "bundled-house-rule-file"}-      ]+        {+          "url": "adom/fusion-bridge addin/AdomBridge/resources/dru/Adom-InstaPCB-2-Layer.edru",+          "dateRead": "2026-09-03",+          "kind": "bundled-house-rule-file"+        }+      ],+      "minPadSpacing": {+        "mm": 0.15,+        "mil": 5.91,+        "condition": "different-net pad pairs; Copper Clearance builtin_ruleid=3 and mdPadPad in house 2L EDRU",+        "sources": [+          {+            "url": "adom/fusion-bridge addin/AdomBridge/resources/dru/Adom-InstaPCB-2-Layer.edru",+            "dateRead": "2026-09-09",+            "kind": "bundled-house-rule-file"+          }+        ]+      }     },     {       "process": "standard",       "layerCount": 4,-      "minTraceWidth": {"mm": 0.09, "mil": 3.54, "condition": "1 oz copper, multilayer"},-      "minSpacing": {"mm": 0.09, "mil": 3.54, "condition": "1 oz copper, multilayer, different nets; 3 mil acceptable in BGA fan-outs"},-      "minDrill": {"mm": 0.15, "mil": 5.91},-      "minVia": {"drillMm": 0.15, "drillMil": 5.91, "padMm": 0.25, "padMil": 9.84},-      "annularRing": {"viaMm": 0.10, "viaMil": 3.94, "padMm": 0.18, "padMil": 7.09, "note": "0.25 mm or above preferred"},-      "copperToBoardEdge": {"mm": 0.20, "mil": 7.87},-      "holeToHole": {"mm": 0.20, "mil": 7.87},-      "maskExpansion": {"mm": 0.038, "mil": 1.50},+      "minTraceWidth": {+        "mm": 0.09,+        "mil": 3.54,+        "condition": "1 oz copper, multilayer"+      },+      "minSpacing": {+        "mm": 0.09,+        "mil": 3.54,+        "condition": "1 oz copper, multilayer, different nets; 3 mil acceptable in BGA fan-outs"+      },+      "minDrill": {+        "mm": 0.15,+        "mil": 5.91+      },+      "minVia": {+        "drillMm": 0.15,+        "drillMil": 5.91,+        "padMm": 0.25,+        "padMil": 9.84+      },+      "annularRing": {+        "viaMm": 0.1,+        "viaMil": 3.94,+        "padMm": 0.18,+        "padMil": 7.09,+        "note": "0.25 mm or above preferred"+      },+      "copperToBoardEdge": {+        "mm": 0.2,+        "mil": 7.87+      },+      "holeToHole": {+        "mm": 0.2,+        "mil": 7.87+      },+      "maskExpansion": {+        "mm": 0.038,+        "mil": 1.5+      },       "maskSliver": null,       "silkLineWidth": null,       "silkToCopper": null,-      "minCopperClearance": {"mm": 0.09, "mil": 3.54, "condition": "different nets"},-      "boardThicknessOptionsMm": [1.6],-      "copperWeightOptionsOz": [1],+      "minCopperClearance": {+        "mm": 0.09,+        "mil": 3.54,+        "condition": "different nets"+      },+      "boardThicknessOptionsMm": [+        1.6+      ],+      "copperWeightOptionsOz": [+        1+      ],       "surfaceFinishes": [],       "maxBoardSize": null,-      "drillRange": {"minMm": 0.15, "maxMm": 6.3, "note": "holes with diameter >= 6.3 mm are CNC routed from a smaller drilled hole; min non-plated hole 0.50 mm"},+      "drillRange": {+        "minMm": 0.15,+        "maxMm": 6.3,+        "note": "holes with diameter >= 6.3 mm are CNC routed from a smaller drilled hole; min non-plated hole 0.50 mm"+      },       "notes": "BGA pad diameter >= 0.25 mm. BGA pad to trace clearance min 0.09 mm. Vias can be placed within BGA pads using filled and plated-over vias.",       "sources": [-        {"url": "adom/fusion-bridge addin/AdomBridge/resources/dru/Adom-InstaPCB-4-Layer.edru", "dateRead": "2026-09-03", "kind": "bundled-house-rule-file"}+        {+          "url": "adom/fusion-bridge addin/AdomBridge/resources/dru/Adom-InstaPCB-4-Layer.edru",+          "dateRead": "2026-09-03",+          "kind": "bundled-house-rule-file"+        }       ]     }   ] }+

Comments

No comments yet.

Log in to comment.