← All Pull Requests

PCBWay 4L 1oz sourced rules and KiCad export correctness (DRAFT; supersedes #4/#5 content; reconciles #2 generator; merge blocked) #6

Closed Draft opened by Josh Aberdeen 2026-11-01

DRAFT, NOT FOR MERGE. Merge is blocked by the items under "Merge blockers". This proposes source files only. No regenerated production .kicad_dru files are included.

What this changes (3 files)

  • tools/generate.py: KiCad export for every vendor. Uses hole_size (not hole, which KiCad 10 rejects in all 55 published files). Every emitted rule is an explicit (severity error), because hole-to-hole and silk were reported as non-gating warnings. Via and PTH annular rings are separate rules: via A.Type == 'Via', PTH pad A.Type == 'Pad' && A.Pad_Type == 'Through-hole'. Hole-to-hole uses the record value. Inner hole-to-copper (hole_clearance, no layer qualifier) and silk height (text_height) are emitted only when the record carries them. The rest of the generator is main, unchanged.
  • rules/records/pcbway.json: PCBWay standard 4-layer 1 oz record only (see values below).
  • tools/test_pcbway_4l.py: 10 source-conformance tests on the generated 4-layer rules.

Relation to open drafts: this supersedes the content of #4 (syntax-only, one line; included here) and #5 (record values; included here). It reconciles the generator functions of #2 (global 98). #2's InstaPCB record and its test_constraints.py are not included and remain in #2. Note: #2's exact-text assertions for min-clearance and min-edge-clearance were updated here to include the explicit (severity error) line. That is an intentional change to #2's test contract.

Sources (archived 2026-11-02, SHA-256 of archived bytes)

Values (mm): track 0.127; clearance 0.1524 (both outer 1 oz values, applied to all layers); hole 0.2; via ring 0.15; PTH component ring 0.254; via pad 0.5 (derived 0.2 + 2 x 0.15); hole-to-hole 0.45 (all pairs); inner hole-to-copper 0.1778; copper-to-edge 0.25; silk width 0.15, height 0.8.

Source disagreements (recorded, stricter value chosen, no weaker value taken)

  • PTH ring: capabilities 10 mil (0.254) vs tolerances 0.20 -> 0.254.
  • Hole spacing: capabilities 16 mil (0.4064 PTH) and 11 mil (0.2794 via) vs limited-holes page 0.45 PTH and 0.30 via -> 0.45 for all pairs. The via-to-via 0.30 is looser; applying 0.45 is conservative and over-restrictive for via arrays.
  • Drill: capabilities 0.15 (surcharge below 0.2) vs mobile 0.2 (surcharge below 0.3) -> 0.2 satisfies both.
  • Via annular ring: 0.15 on both pages (agreement).

Verified (evidence attached by hash in the review folder)

  • Parser: all 57 generated .kicad_dru files (55 vendor + 2 InstaPCB outputs) pass KiCad 10 custom-rule parsing: 57/57 valid, 0 errors. SHA-256 of validation record: d0548e8a70ee3b77f4af003a688fa00f30a5b64c3b6e5b6cda0b468733b7c5e7.
  • Tests: test_constraints.py 8/8 (from #2 on the reconciled tree) and test_pcbway_4l.py 10/10.
  • Native DRC on coupons (KiCad 10 via bridge, zone refill on). Violating coupon cpn_v4 (sha256 of .kicad_pcb e2d4ff74...): named error categories fired for via annular, PTH annular, hole-to-inner-copper, clearance (track and pad pairs), copper edge, hole-to-hole (x2), via hole size, silk thickness, silk height, track width, via diameter. Compliant coupon cpn_v4c (sha256 25722c7a...): zero rule-named violations. Evidence: drc-cpn_v4-reconciled.json (db118c4a...), drc-cpn_v4c-reconciled.json (9ade2f0c...).
  • Generated 4-layer file sha256 0a78e98068efa90f8c69f61b918945c4211123bc21e957ad5885154562f9890b.

Merge blockers (explicit; do not merge until resolved)

  1. Pad-to-zone clearance is NOT verified. The coupon pad 0.10 mm from a filled pour produced no violation, even with an explicit filled polygon. The pour may not be evaluated in this DRC setting. Needs a reviewed explanation or fix before this is claimed.
  2. Solder-mask bridge is NOT verified. Setting board.design_settings.rules.solder_mask_min_width to 0.1016 did not produce a violation at a 0.08 mm gap. solder_mask_bridge is not a custom-rule constraint.
  3. Netclass-conditioned rules: none are emitted. Not tested. Issue #5 (ADOM) notes unconditioned clearance can reduce a stricter zone or netclass spacing. This record has no netclass-specific limits.
  4. Board-side rules check treats the project .kicad_dru as present-only: a deliberately invalid file passed. This is outside this PR and should go to issue #6.
  5. Maintainers must regenerate outputs after merge. Every emitted rule gains (severity error), so all published files change.
  6. The record is single-source for the 0.45 mm hole spacing and the 0.254 mm ring beyond the capabilities page; the limited-holes page is the official PCBWay 2025 source. Needs maintainer confirmation.

🤖 Generated with Claude Code

Diff Skip to comments (2)

--- a/tools/generate.py+++ b/tools/generate.py@@ -1,573 +1,565 @@⋯ 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))",-        "  )" 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'\"))",-            "",-        ]+    out = ["(version 1)", "", "# " + description_text(vendor, rec).replace("\n", "\n# "), ""]++    def rule(name, kind, minimum, condition=None, layer=None, severity=None):+        if minimum is None:+            return+        rows = [f'(rule "{name}"', f'  (severity {severity or "error"})', 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"))+    # KiCad reports hole_to_hole as a warning unless the rule states severity: a warning does not fail a board+    rule("min-hole-to-hole", "hole_to_hole", g(rec, "holeToHole"), severity="error")+    rule("min-hole-to-inner-copper", "hole_clearance", g(rec, "minHoleToInnerCopper", "mm"))+    rule("min-silk-height", "text_height", g(rec, "silkMinHeightMm", "mm"), "A.Type == 'Text' || A.Type == 'Text Box'", "*.SilkS")+    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", []),     }-    if rec.get("silkscreenPolicy"):-        setup["silkscreenPolicy"] = rec["silkscreenPolicy"]     return json.dumps(setup, indent=2) + "\n"  ⋯ 122 unchanged lines ⋯  if __name__ == "__main__":     main()+--- a/tools/test_pcbway_4l.py+++ b/tools/test_pcbway_4l.py@@ -0,0 +1,50 @@+"""PCBWay standard 4-layer 1 oz: sourced limits must appear in the generated KiCad rules, each as an explicit error.+Source archive SHA-256 values are the ones recorded in the PCBDS r7 proof. Syntax-only passes are NOT accepted as proof: see DRC coupon evidence."""+import unittest, importlib.util, json, re+from pathlib import Path+BASE = Path(__file__).resolve().parents[1]+spec = importlib.util.spec_from_file_location('rules', BASE / 'tools' / 'generate.py')+g = importlib.util.module_from_spec(spec); spec.loader.exec_module(g)+REC = [r for r in json.loads((BASE / 'rules/records/pcbway.json').read_text())['records'] if r['process'] == 'standard' and r['layerCount'] == 4][0]+TXT = g.gen_kicad_dru('PCBWay', REC)+STD_SHA = '694ce30266e8c0bde8173d590c3ac71e6d928eb730fad3555e6a1dd298e0a980'+LIM_SHA = '9cebdeed859c3c61d4e104185aa5e6a54fb1a5c6ac5864591fbd5993479145e1'+TOL_SHA = '51142afe99832cc0ee41af19bf7d4c552a2658da466f04850523fab09bb66948'+def rule(name):+    m = re.search(r'\(rule "%s"\n(.*?)(?:\n\n|\Z)' % re.escape(name), TXT, flags=re.S)+    return m.group(1) if m else None+class PCBWay4L(unittest.TestCase):+    def test_no_invalid_keyword_or_layer_qualifier(self):+        self.assertNotIn('(constraint hole ', TXT)+        self.assertNotIn('(layer "inner")', TXT)+    def test_every_rule_is_explicit_error(self):+        blocks = re.findall(r'\(rule "[^"]+"\n(.*?)(?:\n\n|\Z)', TXT, flags=re.S)+        self.assertTrue(blocks)+        for b in blocks: self.assertIn('(severity error)', b)+    def test_track_and_spacing_outer_1oz_applied_globally(self):+        self.assertIn('(constraint track_width (min 0.127mm))', rule('min-track-width'))+        self.assertIn('(constraint clearance (min 0.1524mm))', rule('min-clearance'))+    def test_via_and_pth_annular_rings_are_separate(self):+        self.assertIn("A.Type == 'Via'", rule('min-via-annular-width'))+        self.assertIn('(constraint annular_width (min 0.15mm))', rule('min-via-annular-width'))+        self.assertIn("A.Pad_Type == 'Through-hole'", rule('min-pad-annular-width'))+        self.assertIn('(constraint annular_width (min 0.254mm))', rule('min-pad-annular-width'))+    def test_hole_spacing_at_least_0_45mm_for_all_pairs(self):+        self.assertIn('(constraint hole_to_hole (min 0.45mm))', rule('min-hole-to-hole'))+        self.assertIsNone(re.search(r'hole_to_hole[^\n]*condition', TXT))+    def test_inner_hole_to_copper_7mil_is_emitted(self):+        self.assertIn('(constraint hole_clearance (min 0.1778mm))', rule('min-hole-to-inner-copper'))+    def test_silk_height_and_width(self):+        self.assertIn('(constraint text_height (min 0.8mm))', rule('min-silk-height'))+        self.assertIn('(constraint text_thickness (min 0.15mm))', rule('min-silk-thickness'))+    def test_copper_edge_and_drill_and_via_diameter(self):+        self.assertIn('(constraint edge_clearance (min 0.25mm))', rule('min-edge-clearance'))+        self.assertIn('(constraint hole_size (min 0.2mm))', rule('min-hole'))+        self.assertIn('(constraint via_diameter (min 0.5mm))', rule('min-via-diameter'))+    def test_record_carries_source_archive_hashes(self):+        shas = [s.get('archiveSha256') for s in REC.get('sources', [])]+        for need in (STD_SHA, LIM_SHA, TOL_SHA): self.assertIn(need, shas)+    def test_pth_ring_is_strictest_of_sources(self):+        self.assertGreaterEqual(REC['annularRing']['padMm'], 0.254)  # tolerances page 0.20 is weaker and is not used+if __name__ == '__main__': unittest.main(verbosity=2)+--- a/rules/records/pcbway.json+++ b/rules/records/pcbway.json@@ -1,616 +1,653 @@⋯ 105 unchanged lines ⋯       "process": "standard",       "layerCount": 4,       "minTraceWidth": {-        "mm": 0.1016,-        "mil": 4.0,-        "condition": "inner layer, 1oz (35um) copper, before compensation; inner 0.5oz: 4mil, 2oz: 6mil, 3oz: 8mil. Outer layer 1oz: 5mil"-      },-      "minSpacing": {         "mm": 0.127,         "mil": 5.0,-        "condition": "inner layer, 1oz (35um) copper, before compensation; inner 0.5oz: 5mil (4/4mil class), 2oz: 8mil, 3oz: 11mil. Outer layer 1oz: 6mil"+        "condition": "4L 1oz: outer conventional 5mil (0.127) applied to every layer; inner 4mil (0.1016) is looser, not used. Sources: capabilities table (std sha 694ce30266e8); mobile page suggests 6mil. "       },+      "minSpacing": {+        "mm": 0.1524,+        "mil": 6.0,+        "condition": "4L 1oz: outer conventional 6mil (0.1524) applied to every layer; inner 5mil (0.127) looser, not used. Sources: capabilities table (std sha 694ce30266e8); mobile page suggests 6mil."+      },       "minDrill": {         "mm": 0.2,         "mil": 7.87⋯ 1 unchanged line ⋯       "minVia": {         "drillMm": 0.2,         "drillMil": 7.87,-        "padMm": 0.454,-        "padMil": 17.87+        "padMm": 0.5,+        "padMil": 19.69,+        "note": "derived: drill 0.2 + 2 x 0.15 via ring (vendor page does not publish a via pad)"       },       "annularRing": {-        "viaMm": 0.127,-        "viaMil": 5.0,-        "note": "via, 1oz outer copper; component hole 1oz: 10mil"+        "viaMm": 0.15,+        "viaMil": 6.0,+        "padMm": 0.254,+        "padMil": 10.0,+        "note": "viaMm: pads with vias, 0.15mm (6mil): capabilities page AND manufacturing-tolerances page (0.15 via ring) agree. padMm: 1oz outer component-hole ring 10mil (0.254) from the capabilities table; the manufacturing-tolerances page gives 0.20 for the PTH ring, which is WEAKER and not used. Source disagreement recorded in notes."       },       "copperToBoardEdge": {         "mm": 0.25,⋯ 1 unchanged line ⋯         "condition": "line to board edge, CNC routing"       },       "holeToHole": {-        "mm": 0.2794,-        "mil": 11.0,-        "condition": "via hole to via hole, via diameter 0.45mm or less; component holes: 16mil (0.4064mm)"+        "mm": 0.45,+        "mil": 17.72,+        "condition": "different-net plated holes: 0.45mm (limited_holes_spacing page, last updated 2025-06-25) - stricter than the capabilities table (16mil = 0.4064 PTH; 11mil = 0.2794 via). Via-to-via 0.30mm on that page is looser than 0.45 and is not used separately: 0.45 applies to all pairs (conservative)."       },       "maskExpansion": {         "mm": 0.0508,⋯ 49 unchanged lines ⋯         {           "url": "https://www.pcbway.com/capabilities.html",           "dateRead": "2026-09-03"+        },+        {+          "url": "https://www.pcbway.com/capabilities.html",+          "dateRead": "2026-11-02",+          "archiveSha256": "694ce30266e8c0bde8173d590c3ac71e6d928eb730fad3555e6a1dd298e0a980",+          "note": "observational archive; page footer '© 2024 PCBWay' only; no vendor revision date"+        },+        {+          "url": "https://www.pcbway.com/helpcenter/Engineering_Questions/limited_holes_spacing.html",+          "dateRead": "2026-11-02",+          "archiveSha256": "9cebdeed859c3c61d4e104185aa5e6a54fb1a5c6ac5864591fbd5993479145e1",+          "note": "official PCBWay Engineering Questions page; states last updated 2025-06-25"+        },+        {+          "url": "https://www.pcbway.com/pcb_prototype/PCB_Manufacturing_tolerances.html",+          "dateRead": "2026-11-02",+          "archiveSha256": "51142afe99832cc0ee41af19bf7d4c552a2658da466f04850523fab09bb66948",+          "note": "official PCBWay tolerances page; PTH ring 0.20 and via ring 0.15 (PTH ring weaker than capabilities table, not used)"+        },+        {+          "url": "https://m.pcbway.com/capabilities.html",+          "dateRead": "2026-11-02",+          "archiveSha256": "d7be6170136dad3df83304c5e34dab85775f7df80e632d8de8eae85969da3357",+          "note": "mobile capabilities page (summary only): drill 0.2 min, trace/spacing 4mil min with 6mil suggested, annular 0.15"         }-      ]+      ],+      "minHoleToInnerCopper": {+        "mm": 0.1778,+        "mil": 7.0,+        "condition": "4-layer inner-layer isolation ring (hole to inner layer conductor, before compensation) 7mil, per capabilities table 'Minimum isolation ring of Inner layer' 4L >= 7mil (archive sha 694ce302...)"+      },+      "silkMinHeightMm": {+        "mm": 0.8,+        "condition": "minimum legend character height 0.8mm (width:height 1:5), per capabilities page silkscreen row (archive sha 694ce302...)"+      }     },     {       "process": "standard",⋯ 412 unchanged lines ⋯     "https://www.pcbway.com/advanced-pcb.html"   ] }+

Comments

Josh Aberdeen · 9d ago

PCB Design Studio commissioning review: please do not treat the source-only PR as an installed KiCad rules fix.

The UMCU21 native ADOM AI Flow (v0.2.30) is at Stage 2 Rules. Its supported rules use command reads only the DEFAULT-branch rules package from this wiki page, specifically the published KiCad file for the chosen vendor/layers. The currently served PCBWay 4-layer file is still invalid; native KiCad rejects its unsupported hole constraint. We confirmed the PR #6 diff changes only tools/generate.py, tools/test_pcbway_4l.py and rules/records/pcbway.json. A source-only merge would not update the served kicad/pcbway-4L.kicad_dru, so UMCU21 would remain blocked.

Required normal publication contract (review, not override):

  1. Source-conformant canonical PCBWay 4L standard 1 oz record, generator logic and exactly corresponding generated KiCad 4L rules plus board-setup JSON must be reviewed and published together. Regenerated rules/index.json must resolve that exact file. Capture SHA-256 before and after publisher readback.
  2. Independently verify the default-branch served rules bytes equal tested generated bytes (the currently tested candidate SHA-256 begins 0a78e980...), with real KiCad 10 parser and source-bound DRC violating/compliant coupons. All vendor packages changed by generator regeneration require source-vs-output verification; avoid weakening unrelated vendor constraints or shipping unverifiable bulk changes.
  3. Source-specific safety requirements for the standard 4L/1 oz PCBWay configuration remain mandatory: source-backed widths, spacing, drill, separate via/PTH rings, hole/inner copper clearance, copper edge, hole-to-hole and silk limits. No fallback to a weaker published 4/5-mil global limit, no ignoring KiCad parser errors, and no native run.json edits.
  4. ADOM generic rules check presence-only bug is independently tracked at https://wiki.adom.inc/adom/adom-aiflow/issues/120; a mere successful native presence check must NOT count as KiCad or fabrication validation.
  5. Avoid blocking the PRE-BOARD Rules stage for tests requiring actual board geometry. We commissioned append-only PostgreSQL hard controls under PCB Design Studio M067: pad/zone, netclass/zone and final hole-to-inner-copper checks become mandatory at actual board Stage 16; PCBWay stackup/board-thickness verification at Stage 17; mask expansion/bridge and final silkscreen geometry at Stage 22. Those tests are not deemed passed, cannot be forgotten, and block the due stage until independently proved. Generator correctness and current-stage supplier limits are NOT deferred.
  6. Reconcile with existing draft PR #2, #4 and #5 rather than silently overwriting their changes.

Engineering evidence and machine contracts: https://github.com/Unclefungussz/PCB-Design-Studio-/pull/162 . Issue: https://wiki.adom.inc/adom/pcb-design-rules/issues/6 .

This is a request for a source-matched, published and verifiable ADOM rules package, not permission to merge an unqualified PR. Stage 2 remains HOLD until actual default-branch readback, native rules use and independent acceptance pass.

Josh Aberdeen · 9d ago

Superseded by #7 (global 380). Closing this draft because it contains an unconditional clearance rule (min 0.1524 mm). Measured in KiCad 10 DRC: that rule overrides the Default netclass clearance (0.2 mm), so a 0.18 mm gap passes that should fail. #7 removes the rule and carries the 0.1524 mm floor as board minimum clearance, which tested correctly against netclasses (see #7 body). No other content of #6 is lost: #7 contains the same generator, test and record changes plus the generated 4-layer outputs.

Log in to comment.