ecadlib 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (199) hide show
  1. ecadlib/__init__.py +3 -0
  2. ecadlib/__main__.py +5 -0
  3. ecadlib/a365_sync.py +637 -0
  4. ecadlib/agent.py +1407 -0
  5. ecadlib/agent_queue.py +780 -0
  6. ecadlib/agent_watch.py +732 -0
  7. ecadlib/altium365.py +385 -0
  8. ecadlib/altium_dblib_sync.py +530 -0
  9. ecadlib/altium_export.py +209 -0
  10. ecadlib/altium_forms.py +768 -0
  11. ecadlib/altium_lib.py +1089 -0
  12. ecadlib/altium_menu.py +1751 -0
  13. ecadlib/altium_pkg.py +396 -0
  14. ecadlib/altium_project.py +1095 -0
  15. ecadlib/app.py +1458 -0
  16. ecadlib/approvals.py +395 -0
  17. ecadlib/assets/demo_bom.csv +8 -0
  18. ecadlib/assets/kicad_action_plugin.py +607 -0
  19. ecadlib/assets/kicad_reread.py +60 -0
  20. ecadlib/assets/pcblib_template.json.gz +0 -0
  21. ecadlib/bom_inputs.py +339 -0
  22. ecadlib/bom_triage.py +519 -0
  23. ecadlib/cad_model.py +528 -0
  24. ecadlib/cfb.py +171 -0
  25. ecadlib/cli.py +185 -0
  26. ecadlib/clihelp.py +149 -0
  27. ecadlib/cliutil.py +119 -0
  28. ecadlib/completion.py +173 -0
  29. ecadlib/costs.py +276 -0
  30. ecadlib/crosscheck.py +412 -0
  31. ecadlib/datasheet.py +2168 -0
  32. ecadlib/dedupe.py +572 -0
  33. ecadlib/detect.py +170 -0
  34. ecadlib/doctor.py +450 -0
  35. ecadlib/ds_route.py +541 -0
  36. ecadlib/eagle_export.py +1409 -0
  37. ecadlib/easy_setup.py +338 -0
  38. ecadlib/easyeda_convert.py +386 -0
  39. ecadlib/easyeda_pro_export.py +972 -0
  40. ecadlib/eval/__init__.py +0 -0
  41. ecadlib/eval/baselines/kicad-v1.json +404 -0
  42. ecadlib/eval/build_set.py +299 -0
  43. ecadlib/eval/cli.py +119 -0
  44. ecadlib/eval/gate.py +358 -0
  45. ecadlib/eval/golden/demo.txt +379 -0
  46. ecadlib/eval/golden/specs.txt +3120 -0
  47. ecadlib/eval/golden/testpack.txt +463 -0
  48. ecadlib/eval/golden.py +446 -0
  49. ecadlib/eval/replay/kicad-v1.json.gz +0 -0
  50. ecadlib/eval/runner.py +456 -0
  51. ecadlib/eval/score.py +179 -0
  52. ecadlib/eval/sets/kicad-v1.json +630 -0
  53. ecadlib/firstrun.py +400 -0
  54. ecadlib/footprint_adv.py +1006 -0
  55. ecadlib/footprint_bench.py +218 -0
  56. ecadlib/footprint_check.py +562 -0
  57. ecadlib/footprint_gen.py +582 -0
  58. ecadlib/footprint_rules.py +1092 -0
  59. ecadlib/forge.py +267 -0
  60. ecadlib/fp_share.py +95 -0
  61. ecadlib/fp_tidy.py +389 -0
  62. ecadlib/fsutil.py +62 -0
  63. ecadlib/house_style.py +1051 -0
  64. ecadlib/httpcache.py +124 -0
  65. ecadlib/hub.py +1490 -0
  66. ecadlib/hub_copy.py +161 -0
  67. ecadlib/hub_tools.py +79 -0
  68. ecadlib/kicad_dblib.py +257 -0
  69. ecadlib/kicad_export.py +837 -0
  70. ecadlib/kicad_import.py +913 -0
  71. ecadlib/kicad_index.py +160 -0
  72. ecadlib/kicad_menu.py +193 -0
  73. ecadlib/kicad_pcm.py +475 -0
  74. ecadlib/launcher.py +156 -0
  75. ecadlib/librarian.py +963 -0
  76. ecadlib/lifecycle.py +691 -0
  77. ecadlib/llm/__init__.py +276 -0
  78. ecadlib/llm/cli.py +70 -0
  79. ecadlib/llm/openai_compat.py +214 -0
  80. ecadlib/llm/profiles.py +245 -0
  81. ecadlib/llm/rails.py +570 -0
  82. ecadlib/llm/run.py +126 -0
  83. ecadlib/llm/sampling.py +49 -0
  84. ecadlib/mcp_server.py +723 -0
  85. ecadlib/messages.py +419 -0
  86. ecadlib/migrate.py +407 -0
  87. ecadlib/notify.py +329 -0
  88. ecadlib/package_schema.json +1238 -0
  89. ecadlib/package_specs/CP_EIA-3216-18_Kemet-A.json +27 -0
  90. ecadlib/package_specs/C_0402_1005Metric.json +22 -0
  91. ecadlib/package_specs/C_0805_2012Metric.json +22 -0
  92. ecadlib/package_specs/DFN-8-1EP_3x3mm_P0.5mm_EP1.66x2.38mm.json +40 -0
  93. ecadlib/package_specs/D_SMA.json +27 -0
  94. ecadlib/package_specs/D_SMB.json +27 -0
  95. ecadlib/package_specs/D_SOD-123.json +43 -0
  96. ecadlib/package_specs/D_SOD-323.json +43 -0
  97. ecadlib/package_specs/LED_0603_1608Metric.json +23 -0
  98. ecadlib/package_specs/LED_0805_2012Metric.json +23 -0
  99. ecadlib/package_specs/LQFP-48_7x7mm_P0.5mm.json +31 -0
  100. ecadlib/package_specs/L_0805_2012Metric.json +22 -0
  101. ecadlib/package_specs/MSOP-8_3x3mm_P0.65mm.json +31 -0
  102. ecadlib/package_specs/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm.json +39 -0
  103. ecadlib/package_specs/QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm.json +37 -0
  104. ecadlib/package_specs/R_0402_1005Metric.json +22 -0
  105. ecadlib/package_specs/R_0603_1608Metric.json +22 -0
  106. ecadlib/package_specs/R_0805_2012Metric.json +22 -0
  107. ecadlib/package_specs/R_1206_3216Metric.json +22 -0
  108. ecadlib/package_specs/SOIC-16_3.9x9.9mm_P1.27mm.json +32 -0
  109. ecadlib/package_specs/SOIC-8_3.9x4.9mm_P1.27mm.json +32 -0
  110. ecadlib/package_specs/SOT-223-3_TabPin2.json +56 -0
  111. ecadlib/package_specs/SOT-23-5.json +57 -0
  112. ecadlib/package_specs/SOT-23-6.json +62 -0
  113. ecadlib/package_specs/SOT-23.json +48 -0
  114. ecadlib/package_specs/TSSOP-16_4.4x5mm_P0.65mm.json +31 -0
  115. ecadlib/package_specs/VQFN-16-1EP_3x3mm_P0.5mm_EP1.68x1.68mm.json +39 -0
  116. ecadlib/package_specs/advanced/BGA-100_11.0x11.0mm_Layout10x10_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  117. ecadlib/package_specs/advanced/BGA-121_9.0x9.0mm_Layout11x11_P0.8mm_Ball0.4mm_Pad0.35mm_NSMD.json +26 -0
  118. ecadlib/package_specs/advanced/BGA-144_7.0x7.0mm_Layout13x13_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +29 -0
  119. ecadlib/package_specs/advanced/BGA-169_11.0x11.0mm_Layout13x13_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +23 -0
  120. ecadlib/package_specs/advanced/BGA-256_11.0x11.0mm_Layout20x20_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +26 -0
  121. ecadlib/package_specs/advanced/BGA-256_17.0x17.0mm_Layout16x16_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  122. ecadlib/package_specs/advanced/BGA-324_15.0x15.0mm_Layout18x18_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  123. ecadlib/package_specs/advanced/BGA-36_3.396x3.466mm_Layout6x6_P0.4mm_Ball0.25mm_Pad0.2mm_NSMD.json +24 -0
  124. ecadlib/package_specs/advanced/BGA-672_27.0x27.0mm_Layout26x26_P1.0mm_Ball0.6mm_Pad0.5mm_NSMD.json +32 -0
  125. ecadlib/package_specs/advanced/BGA-68_5.0x5.0mm_Layout9x9_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +30 -0
  126. ecadlib/package_specs/advanced/BGA-90_8.0x13.0mm_Layout2x3x15_P0.8mm.json +26 -0
  127. ecadlib/package_specs/advanced/DFN-8-1EP_3x3mm_P0.5mm_EP1.65x2.38mm_ThermalVias.json +60 -0
  128. ecadlib/package_specs/advanced/Example_Custom_LPad_Slot.json +24 -0
  129. ecadlib/package_specs/advanced/JST_GH_SM04B-GHS-TB_1x04-1MP_P1.25mm_Horizontal.json +49 -0
  130. ecadlib/package_specs/advanced/JST_PH_B4B-PH-K_1x04_P2.00mm_Vertical.json +33 -0
  131. ecadlib/package_specs/advanced/JST_PH_S4B-PH-SM4-TB_1x04-1MP_P2.00mm_Horizontal.json +55 -0
  132. ecadlib/package_specs/advanced/JST_SH_SM04B-SRSS-TB_1x04-1MP_P1.00mm_Horizontal.json +49 -0
  133. ecadlib/package_specs/advanced/JST_XH_B2B-XH-A_1x02_P2.50mm_Vertical.json +34 -0
  134. ecadlib/package_specs/advanced/JST_XH_B4B-XH-A_1x04_P2.50mm_Vertical.json +34 -0
  135. ecadlib/package_specs/advanced/JST_XH_B6B-XH-A_1x06_P2.50mm_Vertical.json +33 -0
  136. ecadlib/package_specs/advanced/LFBGA-144_10x10mm_Layout12x12_P0.8mm.json +23 -0
  137. ecadlib/package_specs/advanced/Molex_KK-254_AE-6410-04A_1x04_P2.54mm_Vertical.json +34 -0
  138. ecadlib/package_specs/advanced/Molex_PicoBlade_53398-0471_1x04-1MP_P1.25mm_Vertical.json +54 -0
  139. ecadlib/package_specs/advanced/PinHeader_1x05_P1.00mm_Vertical_SMD_Pin1Left.json +14 -0
  140. ecadlib/package_specs/advanced/PinHeader_1x05_P1.27mm_Vertical_SMD_Pin1Left.json +14 -0
  141. ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical.json +12 -0
  142. ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical_SMD_Pin1Left.json +14 -0
  143. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical.json +12 -0
  144. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Left.json +25 -0
  145. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Right.json +14 -0
  146. ecadlib/package_specs/advanced/PinHeader_1x10_P1.00mm_Vertical.json +15 -0
  147. ecadlib/package_specs/advanced/PinHeader_2x05_P1.00mm_Vertical_SMD.json +13 -0
  148. ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical.json +18 -0
  149. ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical_SMD.json +24 -0
  150. ecadlib/package_specs/advanced/PinHeader_2x05_P2.00mm_Vertical_SMD.json +13 -0
  151. ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical.json +12 -0
  152. ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical_SMD.json +24 -0
  153. ecadlib/package_specs/advanced/PinHeader_2x20_P2.54mm_Vertical.json +12 -0
  154. ecadlib/package_specs/advanced/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm_ThermalVias.json +87 -0
  155. ecadlib/package_specs/advanced/UFBGA-132_7x7mm_Layout12x12_P0.5mm.json +43 -0
  156. ecadlib/package_specs/advanced/USB_C_Receptacle_HRO_TYPE-C-31-M-12.json +218 -0
  157. ecadlib/package_specs/standard.json.gz +0 -0
  158. ecadlib/parity.py +1204 -0
  159. ecadlib/parts_export.py +154 -0
  160. ecadlib/partview.py +215 -0
  161. ecadlib/plugins.py +231 -0
  162. ecadlib/providers.py +226 -0
  163. ecadlib/rebuild.py +471 -0
  164. ecadlib/release_check.py +437 -0
  165. ecadlib/reports.py +274 -0
  166. ecadlib/resolve.py +556 -0
  167. ecadlib/review_cli.py +292 -0
  168. ecadlib/rules.py +979 -0
  169. ecadlib/rules_infer.py +1205 -0
  170. ecadlib/sexpr.py +149 -0
  171. ecadlib/sheetsafe.py +65 -0
  172. ecadlib/sim_models.py +1087 -0
  173. ecadlib/sourcing.py +1161 -0
  174. ecadlib/stdparts.py +755 -0
  175. ecadlib/step_gen.py +929 -0
  176. ecadlib/symbol_layout.py +798 -0
  177. ecadlib/team.py +558 -0
  178. ecadlib/team_setup.py +288 -0
  179. ecadlib/term.py +420 -0
  180. ecadlib/term_views.py +64 -0
  181. ecadlib/testpack.py +334 -0
  182. ecadlib/ui/__init__.py +1 -0
  183. ecadlib/ui/__main__.py +3 -0
  184. ecadlib/ui/server.py +1029 -0
  185. ecadlib/ui/static/hairline.js +2 -0
  186. ecadlib/ui/static/index.html +2098 -0
  187. ecadlib/ui/static/setup.html +294 -0
  188. ecadlib/upkeep.py +717 -0
  189. ecadlib/upstream.py +573 -0
  190. ecadlib/userconfig.py +257 -0
  191. ecadlib/visual_check.py +1603 -0
  192. ecadlib/ws_status.py +203 -0
  193. ecadlib-0.3.0.dist-info/METADATA +2245 -0
  194. ecadlib-0.3.0.dist-info/RECORD +199 -0
  195. ecadlib-0.3.0.dist-info/WHEEL +5 -0
  196. ecadlib-0.3.0.dist-info/entry_points.txt +2 -0
  197. ecadlib-0.3.0.dist-info/licenses/LICENSE +21 -0
  198. ecadlib-0.3.0.dist-info/licenses/NOTICE +53 -0
  199. ecadlib-0.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1092 @@
1
+ """The footprint rule book, as code: naming, metadata, layers, pads, text, 3D model, pin 1 and connectivity.
2
+
3
+ ecadlib check path/to/*.kicad_mod geometry (footprint_check) + every rule below
4
+ ecadlib check --summary path/to/Lib.pretty/ counts per rule, for running over a whole library
5
+
6
+ docs/footprint-rulebook.md is the human version: each rule id below has a section there saying what it asks, why,
7
+ and where it comes from (KLC, IPC-7351B, Altium's IPC wizard, Bogatin). footprint_check keeps the copper, silk
8
+ clearance, courtyard enclosure, numbering-direction and BGA rules; this module adds what a librarian checks next.
9
+
10
+ check(text, filename=None, symbol_pins=None) -> [(level, rule, message)], like footprint_check.check (level is
11
+ "error" or "warning"). filename lets the name rules compare the footprint name with its file. symbol_pins
12
+ ({number: name} or a list of numbers) adds the connectivity and polarity rules for the symbol it is paired with.
13
+ check_pcblib(fp) runs the Altium-side rules on one footprint read back by altium_lib.read_pcblib.
14
+
15
+ Rule ids (RULES) and their levels:
16
+ name the name follows KLC (F2/F3) or IPC-7351 and says what the copper does: pin count, EP / MP count,
17
+ pitch, body size, chip size code, BGA layout and pad, ball count, _ThermalVias
18
+ meta footprint name = file name = Value; Reference REF**; a description and keywords (KLC F9.1)
19
+ attr SMD parts are "smd", THT parts "through_hole", real parts are not excluded from the position file (F6.1/F7.1)
20
+ origin SMD: origin on the body / land centre (F6.2); THT: origin on pin 1 (F7.2)
21
+ layers SMD pads F.Cu + F.Mask + F.Paste; THT pads all copper + both masks, no paste; no silk on pads (F6.3/F7.4)
22
+ padshape SMD pads rounded rectangles, radius <= 25 % of the short side and <= 0.25 mm (F6.3, IPC-7351C);
23
+ THT pin 1 rectangular, other pins round or oval, on polarized parts (F7.3)
24
+ tht annular ring >= 0.15 mm (F7.5, IPC-2221), slot clearance, no zero drills
25
+ vias thermal vias carry the exposed pad's number (F4.3), 0.2-0.3 mm drill, no paste over vias >= 0.3 mm (F4.4)
26
+ text reference on F.SilkS 1.0 mm / 0.15 mm; value and ${REFERENCE} on F.Fab, 0.5-1.0 mm, ~15 % stroke (F5.1/F5.2)
27
+ silktext silkscreen text 0.5-2.0 mm and off the pads, the designator off the body; also the Altium .Designator
28
+ model a 3D model reference, named after the footprint, STEP, scale 1 / rotation 0, a portable path (F9.3)
29
+ margin courtyard clearance around pads and body: 0.25 mm (0.15 under 1.5 mm), 0.5 connectors / crystals /
30
+ canned capacitors, 1.0 BGA (F5.3)
31
+ polarity pin 1 / polarity is marked on the silkscreen and on the fab layer (F5.1, F5.2): the drawing is not
32
+ the same when turned 180 degrees
33
+ graphics no duplicate graphic elements on one layer (F5.4)
34
+ connect (with symbol_pins) every symbol pin has a pad and every numbered pad a pin; an exposed pad is wired
35
+ pinmap (with symbol_pins) diode / LED cathode on pad 1, polarized capacitor + on pad 1 (KiCad's symbols)
36
+ altium (check_pcblib) SMD pads on Top Layer, THT on Multi-Layer, courtyard on its mechanical layer, an
37
+ assembly outline, .Designator, a 3D body or a height
38
+ """
39
+ import math, re, sys
40
+ from pathlib import Path
41
+
42
+ from . import footprint_check as FC, sexpr
43
+
44
+ EPS = 0.005
45
+ RULES = {
46
+ "name": "Footprint name follows KLC / IPC-7351 and matches the copper",
47
+ "meta": "Name, file name and Value agree; Reference REF**; description and keywords",
48
+ "attr": "Footprint type: SMD / through-hole, not excluded from the position file",
49
+ "origin": "Origin on the body centre (SMD) or on pin 1 (THT)",
50
+ "layers": "Pad layers: SMD F.Cu/F.Mask/F.Paste, THT all copper and both masks, no paste, no silk",
51
+ "padshape": "SMD pads are rounded rectangles; THT pin 1 is rectangular on polarized parts",
52
+ "tht": "Annular ring and slot clearance",
53
+ "vias": "Thermal vias share the exposed pad's number, 0.2-0.3 mm drill, no paste on them",
54
+ "text": "Reference, value and ${REFERENCE} on the right layers at the right size",
55
+ "silktext": "Silkscreen text 0.5-2.0 mm high and clear of copper pads; the designator outside the body",
56
+ "model": "3D model reference named after the footprint, STEP, unscaled, portable path",
57
+ "margin": "Courtyard clearance for the part class",
58
+ "polarity": "Pin 1 / polarity visible on silkscreen and fab",
59
+ "graphics": "No duplicate graphic elements",
60
+ "connect": "Symbol pins and pads match one to one; exposed pads are wired",
61
+ "pinmap": "Cathode / + on pad 1, as KiCad's symbols expect",
62
+ "altium": "Altium layers: Top Layer / Multi-Layer pads, courtyard and assembly layers, designator, 3D body",
63
+ }
64
+
65
+ # rule-book errors the release check holds a part for (ecadlib's own and converted footprints; KiCad library parts
66
+ # get them as warnings). pinmap holds every part: the symbol-to-footprint pairing is ours either way.
67
+ RELEASE_HOLDS = ("name", "layers", "attr", "tht", "vias", "silktext", "pinmap")
68
+
69
+ # KLC F3.1: imperial <-> metric chip codes, and the nominal body (length x width) the metric code names
70
+ CHIP_CODES = {"01005": "0402", "0201": "0603", "0402": "1005", "0603": "1608", "0805": "2012", "1008": "2520",
71
+ "1206": "3216", "1210": "3225", "1806": "4516", "1812": "4532", "1825": "4564", "2010": "5025",
72
+ "2512": "6332", "2920": "7451"}
73
+ CHIP_BODY = {"0402": (0.4, 0.2), "0603": (0.6, 0.3), "1005": (1.0, 0.5), "1608": (1.6, 0.8), "2012": (2.0, 1.25),
74
+ "2520": (2.5, 2.0), "3216": (3.2, 1.6), "3225": (3.2, 2.5), "4516": (4.5, 1.6), "4532": (4.5, 3.2),
75
+ "4564": (4.5, 6.4), "5025": (5.0, 2.5), "6332": (6.3, 3.2), "7451": (7.4, 5.1)}
76
+ POLAR_PREFIX = re.compile(r"^(D|LED|CP|Diode|LED_RGB)_", re.I) # two-terminal parts that have a pin 1 that matters
77
+ NONPOLAR_PREFIX = re.compile(r"^(R|C|L|F|Fuse|FB|Ferrite|Inductor|Resistor|Crystal|Jumper|NetTie|SolderJumper|"
78
+ r"MountingHole|TestPoint|Fiducial|Varistor|RV|Bead|Shunt|R_Array|Wire|Pad|Mounting|"
79
+ r"Hole|Symbol|Logo|Kelvin|PCB|Altium|Spark)[_-]", re.I)
80
+ CONNECTOR = re.compile(r"(^|_)(Connector|PinHeader|PinSocket|Pin_Header|JST|Molex|USB|TerminalBlock|Hirose|TE_|"
81
+ r"Samtec|Phoenix|Wuerth_.*Conn|IDC|DSUB|HDMI|RJ\d|SD|MicroSD|Barrel|BarrelJack|FFC|FPC|"
82
+ r"Harwin|AMASS|XT\d|Stocko|Amphenol|Jack|Plug|Socket|Header|Conn)", re.I)
83
+ CRYSTAL = re.compile(r"(^|_)(Crystal|Oscillator|Resonator|XTAL|HC49)", re.I)
84
+ CANNED = re.compile(r"^CP_Elec|^C_Elec|^CP_Radial|^C_Radial", re.I)
85
+ BGA_NAME = re.compile(r"(BGA|WLCSP|CSP|WLP|DSBGA|AQFN)", re.I)
86
+ VIRTUAL = re.compile(r"^(MountingHole|TestPoint|Fiducial|NetTie|SolderJumper|Jumper|Symbol|Logo|Kelvin|"
87
+ r"SolderWire|Wire|Pad|Altium|PCB|Spark|Hole|Mounting)[_-]?", re.I)
88
+ MODEL_SUFFIXES = re.compile(r"(_ThermalVias\d*|_ThermalVia|_HandSolder(ing)?|_Pad[\d.x]+mm(_HandSolder)?|"
89
+ r"_Mask[\d.x]+mm|_TopVias|_ViaInPad|_Hand_Soldering|_CircularHoles|_LongPads|"
90
+ r"_SMDSolder|_Drill[\d.]+mm|_[MNL])$")
91
+ CATHODE = {"k", "c", "cathode", "kathode", "-", "neg", "n"}
92
+ ANODE = {"a", "anode", "+", "pos", "p"}
93
+
94
+
95
+ def _f(v):
96
+ return float(v)
97
+
98
+
99
+ def _name(tree):
100
+ return str(tree[1]) if len(tree) > 1 and not isinstance(tree[1], list) else ""
101
+
102
+
103
+ def _prop(tree, key):
104
+ """(value, layer, size, thickness) of a KiCad 8+ (property "Reference" ...) or a KiCad 6/7 (fp_text reference ...)."""
105
+ for node in list(sexpr.find(tree, "property")) + list(sexpr.find(tree, "fp_text")):
106
+ k = node[1] if node[0] == "property" else node[1]
107
+ if node[0] == "property" and str(k).lower() != key:
108
+ continue
109
+ if node[0] == "fp_text" and k != key:
110
+ continue
111
+ val = node[2] if len(node) > 2 and not isinstance(node[2], list) else ""
112
+ return val, _layer(node), *_font(node)
113
+ return None
114
+
115
+
116
+ def _user_texts(tree):
117
+ return [(n[2], _layer(n), *_font(n)) for n in sexpr.find(tree, "fp_text") if len(n) > 2 and n[1] == "user"]
118
+
119
+
120
+ def _layer(node):
121
+ return (sexpr.first(node, "layer") or [None, None])[1]
122
+
123
+
124
+ def _font(node):
125
+ font = sexpr.first(sexpr.first(node, "effects") or [], "font") or []
126
+ size, thick = sexpr.first(font, "size"), sexpr.first(font, "thickness")
127
+ return (_f(size[1]) if size else None, _f(thick[1]) if thick else None)
128
+
129
+
130
+ def _raw_pads(tree):
131
+ """Per pad: number, type, shape, centre, unrotated size, rotation, layers, drill (w, h) or None, rratio, props."""
132
+ out = []
133
+ for p in sexpr.find(tree, "pad"):
134
+ at, size = sexpr.first(p, "at"), sexpr.first(p, "size")
135
+ drill = sexpr.first(p, "drill")
136
+ dw = dh = None
137
+ if drill:
138
+ vals = [a for a in drill[1:] if not isinstance(a, list)]
139
+ oval = bool(vals) and vals[0] == "oval"
140
+ nums = [_f(v) for v in vals if v != "oval"]
141
+ if nums:
142
+ dw = nums[0]
143
+ dh = nums[1] if oval and len(nums) > 1 else dw
144
+ rr = sexpr.first(p, "roundrect_rratio")
145
+ out.append({"num": str(p[1]), "type": p[2], "shape": p[3] if len(p) > 3 else "rect",
146
+ "x": _f(at[1]), "y": _f(at[2]), "rot": _f(at[3]) if len(at) > 3 else 0.0,
147
+ "w": _f(size[1]), "h": _f(size[2]), "layers": [str(l) for l in (sexpr.first(p, "layers") or ["l"])[1:]],
148
+ "drill": (dw, dh) if dw is not None else None, "rratio": _f(rr[1]) if rr else None,
149
+ "prop": (sexpr.first(p, "property") or [None, None])[1]})
150
+ return out
151
+
152
+
153
+ def _bbox_pads(pads):
154
+ if not pads:
155
+ return None
156
+ return (min(p["x"] - p["w"] / 2 for p in pads), min(p["y"] - p["h"] / 2 for p in pads),
157
+ max(p["x"] + p["w"] / 2 for p in pads), max(p["y"] + p["h"] / 2 for p in pads))
158
+
159
+
160
+ def _union(*boxes):
161
+ boxes = [b for b in boxes if b]
162
+ if not boxes:
163
+ return None
164
+ return (min(b[0] for b in boxes), min(b[1] for b in boxes), max(b[2] for b in boxes), max(b[3] for b in boxes))
165
+
166
+
167
+ # ------------------------------------------------------------------ what the copper says
168
+ def survey(text_or_tree):
169
+ """Facts the rules (and the name parser) compare against: pins, exposed pads, mounting pads, pitch, body, vias."""
170
+ tree = sexpr.parse(text_or_tree) if isinstance(text_or_tree, str) else text_or_tree
171
+ rects = FC._pad_rects(tree)
172
+ raw = _raw_pads(tree)
173
+ cu = [p for p in rects if p["copper"]]
174
+ nums = [p["num"] for p in cu if p["num"]]
175
+ mount = FC.mount_rx(nums)
176
+ sig = [p for p in cu if p["num"] and not mount.fullmatch(p["num"])]
177
+ vias = [p for p, r in zip(rects, raw) if p["copper"] and r["drill"] and max(r["drill"]) <= 0.5 and
178
+ p["w"] <= 1.0 and p["h"] <= 1.0 and any(_inside(p, q) for q in sig if q is not p and not q["drill"]
179
+ and q["w"] * q["h"] > 1.5 * p["w"] * p["h"])]
180
+ land = [p for p in sig if p not in vias]
181
+ areas = sorted(p["w"] * p["h"] for p in land)
182
+ typical = areas[len(areas) // 2] if areas else 0
183
+ others = lambda p: [q for q in land if q is not p]
184
+ ep = [p for p in land if len(land) > 2 and p["w"] * p["h"] > 3 * typical and _interior(p, others(p))]
185
+ ep_nums = {p["num"] for p in ep}
186
+ only_ep = {n for n in ep_nums if all(q in ep or q in vias for q in sig if q["num"] == n)}
187
+ pins = sorted({p["num"] for p in sig} - only_ep, key=_natural)
188
+ fab, _ = FC._graphics(tree, "F.Fab")
189
+ fb = FC._bbox(fab)
190
+ pitch = _pitches([p for p in land if p not in ep])
191
+ big_nums = {n for n in {p["num"] for p in land} if all(q["w"] * q["h"] > 3 * typical for q in land if q["num"] == n)} \
192
+ if len(land) > 2 else set()
193
+ coords = {(round(p["x"], 3), round(p["y"], 3)) for p in land if p not in ep}
194
+ spans = {round(abs(a[k] - b[k]), 3) for a in coords for b in coords for k in (0, 1) if abs(a[k] - b[k]) > EPS}
195
+ smd = [r for r in raw if r["type"] == "smd"]
196
+ tht = [r for r in raw if r["type"] == "thru_hole"]
197
+ return {"tree": tree, "rects": rects, "raw": raw, "copper": cu, "signal": sig, "pins": pins, "ep": ep,
198
+ "ep_nums": only_ep, "vias": vias, "mount": sorted({p["num"] for p in cu if p["num"] and mount.fullmatch(p["num"])}),
199
+ "pitches": pitch, "big_nums": big_nums, "coords": coords, "spans": spans, "fab_box": fb, "pad_box": _bbox_pads(cu), "smd": smd, "tht": tht,
200
+ "npth": [r for r in raw if r["type"] == "np_thru_hole"], "name": _name(tree)}
201
+
202
+
203
+ def _natural(s):
204
+ return [int(t) if t.isdigit() else t for t in re.split(r"(\d+)", str(s))]
205
+
206
+
207
+ def _inside(p, q):
208
+ return abs(p["x"] - q["x"]) <= q["w"] / 2 + EPS and abs(p["y"] - q["y"]) <= q["h"] / 2 + EPS
209
+
210
+
211
+ def _interior(p, others):
212
+ """An exposed pad sits between the other pads: its centre is inside their bounding box, not on its edge."""
213
+ if not others:
214
+ return False
215
+ b = _bbox_pads(others)
216
+ return b[0] + EPS < p["x"] < b[2] - EPS and b[1] + EPS < p["y"] < b[3] - EPS
217
+
218
+
219
+ def _pitches(pads):
220
+ """Steps between neighbouring pads of a row or a column, and between the distinct pad positions along each axis
221
+ (staggered and alternating rows: their pitch is the step along the row, not within one side), to 0.001 mm."""
222
+ steps = set()
223
+ for k in ("x", "y"):
224
+ vals = sorted({round(p[k], 4) for p in pads})
225
+ steps |= {round(b - a, 3) for a, b in zip(vals, vals[1:]) if b - a > EPS}
226
+ for k, other in (("x", "y"), ("y", "x")):
227
+ rows = {}
228
+ for p in pads:
229
+ rows.setdefault(round(p[other], 2), []).append(p[k])
230
+ for vals in rows.values():
231
+ vals = sorted(set(round(v, 4) for v in vals))
232
+ steps |= {round(b - a, 3) for a, b in zip(vals, vals[1:]) if b - a > EPS}
233
+ return steps
234
+
235
+
236
+ # ------------------------------------------------------------------ names
237
+ NUM = r"(\d+(?:\.\d+)?)"
238
+ # package names that carry a number of their own (SOT-23, TO-220F, SOD-123): the pin count, if any, comes after it
239
+ NUMBERED_PKG = re.compile(r"^((?:TSOT|SOT|TO|SOD|SC|DO|MO|HVSOF|SuperSOT|PowerDI|TDSON|TSDSON|DPAK|D2PAK|SMPA?|"
240
+ r"PG-TO|PG-HSOF|PG-DSO|PG-TSDSON)-\d{1,3}[A-Za-z]*)(?:-(\d+)(?:-(\d+))?(?:-(\d+)EP)?)?(?:_|$)")
241
+
242
+
243
+ def parse_name(name):
244
+ """What a footprint name claims, as a dict: style ("klc" / "ipc"), family, pins, ep, mp, sh, pitch (tuple),
245
+ body (x, y), ep_size, chip (imperial, metric), layout (cols, rows), balls, pad, thermal_vias. Unknown -> {}."""
246
+ out = {}
247
+ ipc = _parse_ipc(name)
248
+ if ipc:
249
+ return ipc
250
+ m = re.match(r"^(R|C|L|LED|D|CP|F|FB|Fuse|Inductor)_(\d{4,5})_(\d{4})Metric(?:_|$)", name)
251
+ if m:
252
+ out.update(style="klc", family="chip", prefix=m.group(1), chip=(m.group(2), m.group(3)), pins=2)
253
+ else:
254
+ m = re.match(r"^(R|C|L|LED|D|CP)_(\d{4})Metric(?:_|$)", name)
255
+ if m:
256
+ out.update(style="klc", family="chip", prefix=m.group(1), chip=(None, m.group(2)), pins=2)
257
+ m = NUMBERED_PKG.match(name) or re.match(r"^([A-Za-z][A-Za-z0-9]*(?:-[A-Za-z][A-Za-z0-9]*)*)-(\d+)(?:-(\d+))?"
258
+ r"(?:-(\d+)EP)?(?:_|$)", name)
259
+ if m and not out and m.group(2) and len(m.group(2)) <= 3 and not re.match(r"^\d{4}", m.group(2)):
260
+ out.update(style="klc", family=m.group(1), pins=int(m.group(2)))
261
+ if m.group(3) and int(m.group(3)) > int(m.group(2)):
262
+ out["pins_with_gaps"] = int(m.group(3)) # KLC F2.4: MSOP-12-16 (12 pads, numbered up to 16)
263
+ if m.group(4):
264
+ out["ep"] = int(m.group(4))
265
+ m = re.search(r"_(\d+)x(\d+)(?:-(\d+)MP)?(?:-(\d+)SH)?_P" + NUM + r"mm", name)
266
+ if m and (CONNECTOR.search(name) or "PinHeader" in name or "PinSocket" in name):
267
+ out.update(style="klc", family=out.get("family") or "connector", layout=(int(m.group(1)), int(m.group(2))),
268
+ pins=int(m.group(1)) * int(m.group(2)))
269
+ if m.group(3):
270
+ out["mp"] = int(m.group(3))
271
+ if m.group(4):
272
+ out["sh"] = int(m.group(4))
273
+ m = re.search(r"_P" + NUM + r"(?:x" + NUM + r")?mm", name)
274
+ if m:
275
+ out["pitch"] = tuple(float(v) for v in m.groups() if v)
276
+ m = re.search(r"Layout(\d+)x(\d+)(?:_|$)", name)
277
+ if m:
278
+ out["layout"] = (int(m.group(1)), int(m.group(2)))
279
+ if out.get("family") and not out.get("layout") and out.get("pins") is not None:
280
+ m = re.search(r"-\d+(?:-\d+EP)?_" + NUM + r"x" + NUM + r"(?:x" + NUM + r")?mm", name)
281
+ if m:
282
+ out["body"] = (float(m.group(1)), float(m.group(2)))
283
+ elif out.get("layout") and BGA_NAME.search(name):
284
+ m = re.search(r"_" + NUM + r"x" + NUM + r"(?:x" + NUM + r")?mm_Layout", name)
285
+ if m:
286
+ out["body"] = (float(m.group(1)), float(m.group(2)))
287
+ m = re.search(r"_EP" + NUM + r"x" + NUM + r"mm", name)
288
+ if m:
289
+ out["ep_size"] = (float(m.group(1)), float(m.group(2)))
290
+ m = re.search(r"_Pad" + NUM + r"mm", name)
291
+ if m and BGA_NAME.search(name):
292
+ out["pad"] = float(m.group(1))
293
+ if re.search(r"_ThermalVias?(?:\d*)(_|$)", name):
294
+ out["thermal_vias"] = True
295
+ if out:
296
+ out.setdefault("style", "klc")
297
+ return out
298
+
299
+
300
+ def _parse_ipc(name):
301
+ """IPC-7351 land pattern names (what Altium's IPC Compliant Footprint Wizard writes). Dimensions are in
302
+ hundredths of a mm (chip size codes in tenths), the suffix is the density: N nominal, M most, L least."""
303
+ n = name.upper()
304
+ m = re.match(r"^(RES|CAP|IND|LED|DIO|FUS|BEAD)C(P)?(\d{2})(\d{2})X(\d{2,3})([NML])$", n)
305
+ if m:
306
+ return {"style": "ipc", "family": "chip", "pins": 2, "body": (int(m.group(3)) / 10, int(m.group(4)) / 10),
307
+ "height": int(m.group(5)) / 100, "density": m.group(6), "prefix": m.group(1) + (m.group(2) or "")}
308
+ m = re.match(r"^(SOIC|SOP|SSOP|TSSOP|MSOP|TSOP|SOT|SOD|QFP|SOJ|PLCC|SON\d*)(\d{2,3})P(\d{3,4})X(\d{3,4})(?:X(\d{2,3}))?-(\d+)([NML])$", n)
309
+ if m and m.group(1) in ("QFP", "PLCC") and m.group(5):
310
+ return {"style": "ipc", "family": m.group(1), "pitch": (int(m.group(2)) / 100,), "span": (int(m.group(3)) / 100, int(m.group(4)) / 100),
311
+ "height": int(m.group(5)) / 100, "pins": int(m.group(6)), "density": m.group(7)}
312
+ if m and not m.group(5):
313
+ return {"style": "ipc", "family": m.group(1), "pitch": (int(m.group(2)) / 100,), "span": (int(m.group(3)) / 100,),
314
+ "height": int(m.group(4)) / 100, "pins": int(m.group(6)), "density": m.group(7)}
315
+ m = re.match(r"^(QFN|DFN|SON|LGA|LCC|PQFN|VQFN|WQFN|UQFN|QFNS|SONS)(\d{2,3})P(\d{3,4})X(\d{3,4})X(\d{2,3})-(\d+)([NML])$", n)
316
+ if m:
317
+ return {"style": "ipc", "family": m.group(1), "pitch": (int(m.group(2)) / 100,),
318
+ "body": (int(m.group(3)) / 100, int(m.group(4)) / 100), "height": int(m.group(5)) / 100,
319
+ "pins_total": int(m.group(6)), "density": m.group(7)} # IPC counts the thermal pad as a pin
320
+ m = re.match(r"^(?:BGA|CSP|WLCSP)(\d+)C(\d{2,3})P(\d+)X(\d+)_(\d{3,4})X(\d{3,4})X(\d{2,3})([NML])?$", n)
321
+ if m:
322
+ return {"style": "ipc", "family": "BGA", "balls": int(m.group(1)), "pitch": (int(m.group(2)) / 100,),
323
+ "layout": (int(m.group(3)), int(m.group(4))), "body": (int(m.group(5)) / 100, int(m.group(6)) / 100),
324
+ "height": int(m.group(7)) / 100}
325
+ return {}
326
+
327
+
328
+ def _close(a, b, tol):
329
+ return abs(a - b) <= tol
330
+
331
+
332
+ def _name_rules(s, add):
333
+ name = s["name"]
334
+ claim = parse_name(name)
335
+ if not claim:
336
+ return claim
337
+ pins, eps, mps = s["pins"], s["ep_nums"], s["mount"]
338
+ fam = claim.get("family", "")
339
+ bga = fam == "BGA" or bool(BGA_NAME.search(name)) and claim.get("layout")
340
+ # pin count: KLC counts uniquely numbered pads without the exposed pad(s); IPC counts the thermal pad too
341
+ if claim.get("pins_total") is not None:
342
+ have = len(pins) + len(eps)
343
+ if have != claim["pins_total"]:
344
+ add("error", "name", f"the name says {claim['pins_total']} pins (IPC counts the thermal pad), the footprint "
345
+ f"has {len(pins)} pins + {len(eps)} exposed pad(s)")
346
+ elif claim.get("pins") is not None and not bga:
347
+ want = claim["pins"]
348
+ top = max((int(p) for p in pins if p.isdigit()), default=0)
349
+ if claim.get("pins_with_gaps") and top != claim["pins_with_gaps"] and len(pins) != claim["pins_with_gaps"]:
350
+ add("warning", "name", f"the name says pins are numbered up to {claim['pins_with_gaps']} (KLC F2.4), "
351
+ f"the highest pad number is {top}")
352
+ leads = [n for n in pins if n not in s["big_nums"]] # without tabs (TO-252-3_TabPin4: 3 leads + tab)
353
+ ok = want in (len(pins), len(leads), top) or (claim.get("ep") and not eps and len(pins) - claim["ep"] == want)
354
+ if fam == "chip":
355
+ if len(s["signal"]) != 2:
356
+ add("error", "name", f"a chip footprint has 2 pads, this one has {len(s['signal'])}")
357
+ elif not ok:
358
+ add("error", "name", f"the name says {want} pins, the footprint has {len(pins)} numbered pads "
359
+ f"({FC._ranges(pins)}){' plus exposed pad ' + ', '.join(sorted(eps)) if eps else ''}")
360
+ if claim.get("ep") is not None and claim["ep"] != len(eps):
361
+ add("error" if eps else "warning", "name", f"the name says {claim['ep']} exposed pad(s), the footprint has "
362
+ f"{len(eps) or 'none that could be told apart'}")
363
+ elif claim.get("ep") is None and eps and claim.get("style") == "klc" and claim.get("pins") is not None \
364
+ and not bga and fam not in ("chip", "connector") and not CONNECTOR.search(name):
365
+ add("warning", "name", f"the footprint has an exposed pad ({', '.join(sorted(eps))}) but the name has no "
366
+ f"'-{len(eps)}EP' field (KLC F2.1)")
367
+ if claim.get("mp") is not None and claim["mp"] != len([m for m in mps if m.upper().startswith("MP") or m == ""]) \
368
+ and claim["mp"] != len(mps):
369
+ add("error", "name", f"the name says {claim['mp']} mounting pad(s) (MP), the footprint has {len(mps)}")
370
+ # pitch
371
+ tiny_row = claim.get("layout") and min(claim["layout"]) == 1 and max(claim["layout"]) <= 2 and len(claim["layout"]) == 2 \
372
+ and claim["layout"][1] == 1
373
+ if claim.get("pitch") and s["pitches"] and not bga and not tiny_row:
374
+ for p in claim["pitch"]:
375
+ if any(_close(p, st, 0.011) for st in s["pitches"]):
376
+ continue
377
+ if len(s["coords"]) <= 4 and any(_close(p, st, 0.011) for st in s["spans"]):
378
+ continue # 4-terminal shunts, axial parts: P is the outer pitch
379
+ near = min(s["pitches"], key=lambda st: abs(st - p))
380
+ add("error" if abs(near - p) > 0.11 else "warning", "name",
381
+ f"the name says pitch {p:g} mm, the pads are {near:g} mm apart")
382
+ if bga:
383
+ balls = [p for p in s["signal"] if FC.BALL.match(p["num"])]
384
+ if claim.get("pitch") and balls:
385
+ steps = _pitches(balls)
386
+ for p in claim["pitch"]:
387
+ if steps and not any(_close(p, st, 0.011) for st in steps):
388
+ add("error", "name", f"the name says ball pitch {p:g} mm, the balls are {min(steps):g} mm apart")
389
+ if claim.get("layout") and balls:
390
+ px = min(claim["pitch"]) if claim.get("pitch") else min(_pitches(balls) or {1})
391
+ py = max(claim["pitch"]) if claim.get("pitch") else px
392
+ span = lambda k, pt: round((max(p[k] for p in balls) - min(p[k] for p in balls)) / pt) + 1
393
+ cols, rows = span("x", px), span("y", py)
394
+ if (cols, rows) != tuple(claim["layout"]) and (rows, cols) != tuple(claim["layout"]):
395
+ cols, rows = span("x", py), span("y", px)
396
+ if (cols, rows) != tuple(claim["layout"]) and (rows, cols) != tuple(claim["layout"]):
397
+ add("error", "name", f"the name says a {claim['layout'][0]}x{claim['layout'][1]} ball layout, the "
398
+ f"balls form {cols}x{rows}")
399
+ want = claim.get("balls") if claim.get("balls") is not None else claim.get("pins")
400
+ if want is not None and balls and len({p["num"] for p in balls}) != want:
401
+ add("error", "name", f"the name says {want} balls, the footprint has {len({p['num'] for p in balls})}")
402
+ if claim.get("pad") and balls:
403
+ d = min(min(p["w"], p["h"]) for p in balls)
404
+ if not _close(d, claim["pad"], 0.011):
405
+ add("error", "name", f"the name says {claim['pad']:g} mm pads, the balls' lands are {d:g} mm")
406
+ # body size against the fab outline (nominal body)
407
+ if claim.get("body") and s["fab_box"]:
408
+ fb = s["fab_box"]
409
+ bw, bh = fb[2] - fb[0], fb[3] - fb[1]
410
+ x, y = claim["body"]
411
+ tol = lambda v: max(0.1, 0.05 * v)
412
+ if not ((_close(bw, x, tol(x)) and _close(bh, y, tol(y))) or (_close(bw, y, tol(y)) and _close(bh, x, tol(x)))):
413
+ add("warning", "name", f"the name says a {x:g} x {y:g} mm body, the fab outline is {bw:.2f} x {bh:.2f} mm")
414
+ if claim.get("chip"):
415
+ imp, met = claim["chip"]
416
+ if imp and CHIP_CODES.get(imp) and CHIP_CODES[imp] != met:
417
+ add("error", "name", f"imperial {imp} is metric {CHIP_CODES[imp]}, not {met} (KLC F3.1)")
418
+ body = CHIP_BODY.get(met)
419
+ if body and s["fab_box"]:
420
+ fb = s["fab_box"]
421
+ bw, bh = sorted((fb[2] - fb[0], fb[3] - fb[1]), reverse=True)
422
+ # the length is the size code; widths vary by part type (an 0805 inductor is often 0.9-1.0 mm wide)
423
+ if not (_close(bw, body[0], max(0.1, 0.2 * body[0])) and 0.5 * body[1] - 0.05 <= bh <= 1.3 * body[1] + 0.05):
424
+ add("warning", "name", f"metric {met} is a {body[0]:g} x {body[1]:g} mm body, the fab outline is "
425
+ f"{bw:.2f} x {bh:.2f} mm")
426
+ if claim.get("ep_size") and s["ep"]:
427
+ b = _bbox_pads([p for p in s["ep"]])
428
+ ew, eh = b[2] - b[0], b[3] - b[1]
429
+ x, y = claim["ep_size"]
430
+ ok = lambda a, b: _close(a, b, max(0.05, 0.1 * b))
431
+ if not ((ok(ew, x) and ok(eh, y)) or (ok(ew, y) and ok(eh, x))) and "Mask" not in name:
432
+ add("warning", "name", f"the name says a {x:g} x {y:g} mm exposed pad, its copper is {ew:.2f} x {eh:.2f} mm")
433
+ if claim.get("thermal_vias") and not s["vias"]:
434
+ add("warning", "name", "the name says _ThermalVias but there are no vias in the exposed pad")
435
+ elif s["vias"] and not claim.get("thermal_vias") and claim.get("style") == "klc":
436
+ add("warning", "name", f"{len(s['vias'])} thermal vias but no _ThermalVias in the name (KLC F4.4)")
437
+ return claim
438
+
439
+
440
+ def _meta_rules(tree, s, filename, add):
441
+ name = s["name"]
442
+ if not name:
443
+ add("error", "meta", "the footprint has no name")
444
+ return
445
+ if filename:
446
+ stem = Path(filename).name
447
+ stem = stem[: -len(".kicad_mod")] if stem.endswith(".kicad_mod") else Path(stem).stem
448
+ safe = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", name).rstrip(". ")
449
+ if stem not in (name, safe):
450
+ add("error", "meta", f"footprint name '{name}' is not its file name '{stem}': KiCad loads it by file name, "
451
+ "Altium by the name, so the two tools would link different footprints")
452
+ if re.search(r"\s", name) or re.search(r'[<>:"/\\|?*]', name):
453
+ add("warning", "meta", f"footprint name '{name}' has spaces or characters files and Altium refs cannot carry")
454
+ ref = _prop(tree, "reference")
455
+ val = _prop(tree, "value")
456
+ if ref is None:
457
+ add("error", "meta", "no Reference field")
458
+ elif ref[0] not in ("REF**", "REF*", "${REFERENCE}"):
459
+ add("warning", "meta", f"Reference field is '{ref[0]}' (KLC F9.1: REF**)")
460
+ if val is None:
461
+ add("warning", "meta", "no Value field")
462
+ elif val[0] != name:
463
+ add("warning", "meta", f"Value '{val[0]}' is not the footprint name (KLC F9.1)")
464
+ descr = sexpr.first(tree, "descr")
465
+ if not descr or not str(descr[1]).strip():
466
+ add("warning", "meta", "no description (KLC F9.1: device information and a datasheet link)")
467
+ tags = sexpr.first(tree, "tags")
468
+ if not tags or not str(tags[1]).strip():
469
+ add("warning", "meta", "no keywords / tags (KLC F9.1)")
470
+
471
+
472
+ def _attr_rules(tree, s, add):
473
+ attr = sexpr.first(tree, "attr") or []
474
+ flags = {str(a) for a in attr[1:] if not isinstance(a, list)}
475
+ virtual = bool(VIRTUAL.match(s["name"])) or not s["copper"]
476
+ if virtual:
477
+ return
478
+ if s["tht"]:
479
+ if "through_hole" not in flags and "smd" not in flags:
480
+ add("warning", "attr", "a through-hole footprint should have the 'Through hole' type (KLC F7.1)")
481
+ elif s["smd"]:
482
+ if "smd" not in flags:
483
+ add("error", "attr", "an SMD footprint without the 'SMD' type is left out of the pick-and-place file "
484
+ "(KLC F6.1): the assembler would not place it")
485
+ if "exclude_from_pos_files" in flags and s["smd"] and not s["tht"]:
486
+ add("error", "attr", "a real SMD part is excluded from the position files (KLC F6.1)")
487
+ if "exclude_from_bom" in flags:
488
+ add("warning", "attr", "the footprint is excluded from the BOM (KLC F6.1: only for non-parts)")
489
+ if "board_only" in flags:
490
+ add("warning", "attr", "'Not in schematic' is set (KLC F6.1)")
491
+
492
+
493
+ def _origin_rules(s, add):
494
+ if not s["copper"] or VIRTUAL.match(s["name"]):
495
+ return
496
+ if s["tht"] and not s["smd"]:
497
+ ones = [r for r in s["raw"] if r["num"] in ("1", "A1") and r["type"] == "thru_hole"]
498
+ if ones and not any(abs(r["x"]) < EPS and abs(r["y"]) < EPS for r in ones) and not CONNECTOR.search(s["name"]) \
499
+ and not re.search(r"_Horizontal|TO-|Bridge|Transformer|Relay|Potentiometer|Switch", s["name"]):
500
+ add("warning", "origin", f"a through-hole footprint's origin should be pin 1 (KLC F7.2); pin 1 is at "
501
+ f"({ones[0]['x']:.3f}, {ones[0]['y']:.3f})")
502
+ return
503
+ if s["tht"]:
504
+ return # hybrid parts: the datasheet's datum
505
+ boxes = [b for b in (s["pad_box"], s["fab_box"]) if b]
506
+ size = max(max(b[2] - b[0], b[3] - b[1]) for b in boxes)
507
+ tol = max(0.05, 0.03 * size)
508
+ centres = [((b[0] + b[2]) / 2, (b[1] + b[3]) / 2) for b in boxes]
509
+ if all(math.hypot(*c) > tol for c in centres) and not CONNECTOR.search(s["name"]):
510
+ cx, cy = centres[-1]
511
+ add("warning", "origin", f"the origin is {math.hypot(cx, cy):.2f} mm from the body centre ({cx:.2f}, {cy:.2f}); "
512
+ "pick-and-place uses it (KLC F6.2)")
513
+
514
+
515
+ def _layer_rules(s, add):
516
+ paste_only = [r for r in s["raw"] if r["type"] in ("smd", "connect") and not any(l.endswith(".Cu") for l in r["layers"])
517
+ and any("Paste" in l for l in r["layers"])]
518
+ bad_smd, nomask, nopaste, silk, tht_paste, tht_mask = [], [], [], [], [], []
519
+ for r in s["raw"]:
520
+ L = set(r["layers"])
521
+ if any(l.endswith("SilkS") or l.endswith("Silkscreen") for l in L):
522
+ silk.append(r["num"] or "(unnumbered)")
523
+ if r["type"] == "smd":
524
+ cu = {l for l in L if l.endswith(".Cu")}
525
+ if not cu:
526
+ continue
527
+ side = "F" if "F.Cu" in cu else "B"
528
+ if len(cu) > 1 or "*.Cu" in cu:
529
+ bad_smd.append(r["num"])
530
+ spreader = side == "B" and not any("Paste" in l for l in L) and any( # KLC F4.4: B.Cu under thermal vias
531
+ q["num"] == r["num"] and "F.Cu" in q["layers"] for q in s["raw"])
532
+ if f"{side}.Mask" not in L and "*.Mask" not in L and not spreader and not _opened(r, s["raw"], side + ".Mask"):
533
+ nomask.append(r) # (a separate mask aperture over it is a mask-defined pad: fine)
534
+ if f"{side}.Paste" not in L and "*.Paste" not in L and r["num"] and not spreader and \
535
+ not _opened(r, s["raw"], side + ".Paste"):
536
+ nopaste.append(r)
537
+ elif r["type"] == "thru_hole":
538
+ if any(abs(v["x"] - r["x"]) < EPS and abs(v["y"] - r["y"]) < EPS for v in s["vias"]) or (
539
+ r["drill"] and max(r["drill"]) <= 0.5 and any(q["type"] == "smd" and q["num"] == r["num"] for q in s["raw"])):
540
+ continue # thermal vias may be tented (KLC F4.4)
541
+ if any("Paste" in l for l in L):
542
+ tht_paste.append(r["num"])
543
+ masks = {l for l in L if l.endswith(".Mask")}
544
+ if not ("*.Mask" in masks or {"F.Mask", "B.Mask"} <= masks):
545
+ tht_mask.append(r["num"])
546
+ if bad_smd:
547
+ add("error", "layers", f"SMD pads {FC._ranges(bad_smd)} are on more than one copper layer")
548
+ if nomask:
549
+ add("error", "layers", f"SMD pads {FC._ranges([r['num'] for r in nomask])} have no solder-mask opening: "
550
+ "mask would cover the copper")
551
+ if nopaste:
552
+ ep = {p["num"] for p in s["ep"]}
553
+ sig = [r["num"] for r in nopaste if r["num"] not in ep]
554
+ if sig:
555
+ add("error", "layers", f"SMD pads {FC._ranges(sig)} have no paste: the stencil leaves them unsoldered")
556
+ elif not paste_only:
557
+ add("error", "layers", f"exposed pad {', '.join(sorted({r['num'] for r in nopaste}))} has no paste and no "
558
+ "paste windows")
559
+ if silk:
560
+ add("error", "layers", f"pads {FC._ranges(silk)} are on a silkscreen layer (KLC F7.4)")
561
+ if tht_paste:
562
+ add("warning", "layers", f"through-hole pads {FC._ranges(tht_paste)} have paste (KLC F7.4: only for pin-in-paste)")
563
+ if tht_mask:
564
+ add("error", "layers", f"through-hole pads {FC._ranges(tht_mask)} are not open in both solder masks (KLC F7.4)")
565
+
566
+
567
+ def _opened(r, raw, layer):
568
+ """Another pad (an aperture, a pad of the same number) has `layer` over part of this pad."""
569
+ star = "*." + layer.split(".")[1]
570
+ dims = lambda p: (p["h"], p["w"]) if round(p["rot"] / 90) % 2 else (p["w"], p["h"])
571
+ rw, rh = dims(r)
572
+ for q in raw:
573
+ if q is r or not (layer in q["layers"] or star in q["layers"]):
574
+ continue
575
+ if q["num"] and q["num"] == r["num"]:
576
+ return True
577
+ qw, qh = dims(q)
578
+ if abs(q["x"] - r["x"]) < (qw + rw) / 2 - EPS and abs(q["y"] - r["y"]) < (qh + rh) / 2 - EPS:
579
+ return True
580
+ return False
581
+
582
+
583
+ def _polarized(s, claim):
584
+ name = s["name"]
585
+ if VIRTUAL.match(name) or NONPOLAR_PREFIX.match(name):
586
+ return False
587
+ if POLAR_PREFIX.match(name):
588
+ return True
589
+ return len(s["pins"]) >= 3 or bool(CONNECTOR.search(name) and len(s["pins"]) >= 2)
590
+
591
+
592
+ def _padshape_rules(s, claim, add):
593
+ ep = {id(p) for p in s["ep"]}
594
+ rect_pads = []
595
+ big_r = []
596
+ land = {(round(p["x"], 4), round(p["y"], 4), p["num"]) for p in s["signal"] if id(p) not in ep}
597
+ for r in s["raw"]:
598
+ if r["type"] != "smd" or not r["num"] or (round(r["x"], 4), round(r["y"], 4), r["num"]) not in land:
599
+ continue
600
+ if r["shape"] == "rect":
601
+ rect_pads.append(r["num"])
602
+ if r["shape"] == "roundrect" and r["rratio"] is not None:
603
+ rad = r["rratio"] * min(r["w"], r["h"])
604
+ if r["rratio"] > 0.25 + 1e-6 or rad > 0.25 + EPS:
605
+ big_r.append((r["num"], r["rratio"], rad))
606
+ if rect_pads and len(rect_pads) == len(land):
607
+ add("warning", "padshape", "SMD pads are sharp rectangles; KLC F6.3 / IPC-7351C use rounded rectangles "
608
+ "(radius 25 % of the short side, at most 0.25 mm)")
609
+ if big_r:
610
+ n, rr, rad = big_r[0]
611
+ add("warning", "padshape", f"pad {n} corner radius {rad:.3f} mm (ratio {rr:g}); IPC-7351C: <= 25 % of the "
612
+ f"short side and <= 0.25 mm{f' ({len(big_r)} pads)' if len(big_r) > 1 else ''}")
613
+ tht = [r for r in s["tht"] if r["num"] and not FC.mount_rx([r["num"]]).fullmatch(r["num"])]
614
+ if len({r["num"] for r in tht}) >= 2 and _polarized(s, claim) and not s["smd"]:
615
+ ones = [r for r in tht if r["num"] in ("1", "A1")]
616
+ if ones and not any(r["shape"] in ("rect", "roundrect") for r in ones):
617
+ add("warning", "padshape", "pin 1 of a polarized through-hole part should be the rectangular pad (KLC F7.3)")
618
+ squares = sorted({r["num"] for r in tht if r["shape"] in ("rect",) and r["num"] not in ("1", "A1")}, key=_natural)
619
+ if ones and squares and any(r["shape"] in ("rect", "roundrect") for r in ones):
620
+ add("warning", "padshape", f"pads {FC._ranges(squares)} are rectangular too: only pin 1 should be (KLC F7.3)")
621
+
622
+
623
+ def _tht_rules(s, add):
624
+ thin, slot, zero = [], [], []
625
+ for r in s["tht"]:
626
+ if not r["drill"]:
627
+ continue
628
+ dw, dh = r["drill"]
629
+ if not dw:
630
+ zero.append(r["num"])
631
+ continue
632
+ if r["shape"] == "custom":
633
+ continue
634
+ w, h = r["w"], r["h"]
635
+ ring = min((w - dw) / 2, (h - dh) / 2) if dw != dh else (min(w, h) - dw) / 2
636
+ if ring < 0.15 - EPS:
637
+ thin.append((ring, r["num"]))
638
+ if dw != dh and min(dw, dh) > 0 and max(dw, dh) / min(dw, dh) < 1.0 + 1e-6:
639
+ slot.append(r["num"])
640
+ if zero:
641
+ add("error", "tht", f"through-hole pads {FC._ranges(zero)} have a zero drill")
642
+ if thin:
643
+ ring, n = min(thin)
644
+ lvl = "error" if ring < 0.05 else "warning"
645
+ add(lvl, "tht", f"annular ring {max(ring, 0):.3f} mm on pad {n}{f' and {len(thin) - 1} more' if len(thin) > 1 else ''} "
646
+ "(KLC F7.5 / IPC-2221: >= 0.15 mm)")
647
+
648
+
649
+ def _via_rules(s, add):
650
+ if not s["vias"]:
651
+ return
652
+ pads = {id(p) for p in s["vias"]}
653
+ for v in s["vias"]:
654
+ hosts = [q for q in s["signal"] if id(q) not in pads and _inside(v, q) and not q["drill"]]
655
+ host = hosts[0] if hosts else None # (hosts: copper the via sits in)
656
+ if host and all(v["num"] != q["num"] for q in hosts):
657
+ add("error", "vias", f"a thermal via at ({v['x']:.2f}, {v['y']:.2f}) is numbered '{v['num']}', its pad "
658
+ f"'{host['num']}': connected copper must share a number (KLC F4.3)")
659
+ break
660
+ drills = [r["drill"][0] for r in s["raw"] if r["drill"] and any(abs(r["x"] - v["x"]) < EPS and abs(r["y"] - v["y"]) < EPS
661
+ for v in s["vias"])]
662
+ off = [d for d in drills if not 0.2 - EPS <= d <= 0.3 + EPS]
663
+ if off:
664
+ add("warning", "vias", f"thermal via drill {min(off):g}-{max(off):g} mm (KLC F4.4: 0.2-0.3 mm unless the "
665
+ "datasheet says otherwise)")
666
+ windows = [r for r in s["raw"] if any("Paste" in l for l in r["layers"]) and r["type"] in ("smd", "connect")]
667
+ for v, d in zip(s["vias"], drills):
668
+ if d >= 0.3 - EPS and any(abs(v["x"] - w["x"]) < w["w"] / 2 - EPS and abs(v["y"] - w["y"]) < w["h"] / 2 - EPS
669
+ for w in windows):
670
+ add("warning", "vias", f"paste covers a {d:g} mm thermal via at ({v['x']:.2f}, {v['y']:.2f}): solder wicks "
671
+ "into it (KLC F4.4)")
672
+ break
673
+
674
+
675
+ def _text_rules(tree, s, add):
676
+ ref = _prop(tree, "reference")
677
+ if ref:
678
+ _, layer, size, thick = ref
679
+ if layer not in ("F.SilkS", "B.SilkS", "F.Silkscreen", "B.Silkscreen"):
680
+ add("warning", "text", f"the reference is on {layer}, not the silkscreen (KLC F5.1)")
681
+ elif size and (abs(size - 1.0) > 0.01 or (thick and abs(thick - 0.15) > 0.01)):
682
+ add("warning", "text", f"reference text {size:g} mm / {thick or 0:g} mm (KLC F5.1: 1.00 mm, 0.15 mm stroke)")
683
+ val = _prop(tree, "value")
684
+ if val and val[1] not in ("F.Fab", "B.Fab"):
685
+ add("warning", "text", f"the value is on {val[1]}, not F.Fab (KLC F5.2)")
686
+ fab_ref = [t for t in _user_texts(tree) if t[0] in ("${REFERENCE}", "%R") and t[1] in ("F.Fab", "B.Fab")]
687
+ if not fab_ref and s["copper"] and not VIRTUAL.match(s["name"]):
688
+ add("warning", "text", "no ${REFERENCE} on F.Fab: the assembly drawing has no designators (KLC F5.2)")
689
+ for t in fab_ref[:1] + ([val] if val and val[1] in ("F.Fab", "B.Fab") else []):
690
+ _, _, size, thick = t
691
+ if size and not 0.25 - EPS <= size <= 1.0 + EPS:
692
+ add("warning", "text", f"fab text {size:g} mm (KLC F5.2: 0.5-1.0 mm)")
693
+ break
694
+ if size and thick and not 0.10 <= thick / size <= 0.2 + 1e-6:
695
+ add("warning", "text", f"fab text stroke {thick:g} mm is {thick / size:.0%} of its {size:g} mm size (KLC F5.2: ~15 %)")
696
+ break
697
+
698
+
699
+ SILK = ("F.SilkS", "B.SilkS", "F.Silkscreen", "B.Silkscreen")
700
+ SILK_TEXT = (0.5, 2.0) # mm: below this a board house cannot print it, above it the text swamps the part
701
+ NOMINAL_REF = 3 # characters a designator shows on a board ("U12"), whatever the library string says
702
+
703
+
704
+ def text_box(x, y, size, n, rot=0.0, just=0.5):
705
+ """Box (x0, y0, x1, y1) of n stroke characters of height size whose anchor (x, y) sits at fraction just
706
+ (0 left, 0.5 centre, 1 right) of the width and at the middle of the height, rotated by a multiple of 90 degrees."""
707
+ from .altium_lib import CHAR_W
708
+ w = CHAR_W * size * n
709
+ x0, x1, y0, y1 = -w * just, w * (1 - just), -size / 2, size / 2
710
+ if round(rot / 90) % 2:
711
+ x0, x1, y0, y1 = y0, y1, x0, x1
712
+ if round(rot / 90) % 4 in (1, 2):
713
+ x0, x1 = -x1, -x0
714
+ if round(rot / 90) % 4 in (2, 3):
715
+ y0, y1 = -y1, -y0
716
+ return (x + x0, y + y0, x + x1, y + y1)
717
+
718
+
719
+ def _boxes_overlap(a, b, gap=0.0):
720
+ return a[0] < b[2] - gap and b[0] < a[2] - gap and a[1] < b[3] - gap and b[1] < a[3] - gap
721
+
722
+
723
+ def _silk_texts(tree):
724
+ """Visible silkscreen texts: (kind, layer, size, box) with the box in KiCad coordinates (y down)."""
725
+ out = []
726
+ for n in list(sexpr.find(tree, "property")) + list(sexpr.find(tree, "fp_text")):
727
+ layer, at = _layer(n), sexpr.first(n, "at")
728
+ eff = sexpr.first(n, "effects") or []
729
+ hidden = "hide" in n or "hide" in eff or any(isinstance(c, list) and c[0] == "hide" and c[1:2] != ["no"]
730
+ for c in list(n) + list(eff))
731
+ if layer not in SILK or not at or hidden:
732
+ continue
733
+ kind = (str(n[1]) if n[0] == "fp_text" else str(n[1]).lower())
734
+ val = str(n[2]) if len(n) > 2 and not isinstance(n[2], list) else ""
735
+ if kind not in ("reference", "value", "user"):
736
+ continue
737
+ size = _font(n)[0] or 1.0
738
+ ref = kind == "reference" or val in ("${REFERENCE}", "%R")
739
+ just = sexpr.first(eff, "justify") or []
740
+ frac = 0.0 if "left" in just else 1.0 if "right" in just else 0.5
741
+ rot = _f(at[3]) if len(at) > 3 and at[3] != "unlocked" else 0.0
742
+ out.append(("reference" if ref else kind, layer, size,
743
+ text_box(_f(at[1]), _f(at[2]), size, NOMINAL_REF if ref else max(len(val), 1), rot, frac)))
744
+ return out
745
+
746
+
747
+ def _silktext_rules(tree, s, add):
748
+ lo, hi = SILK_TEXT
749
+ for kind, layer, size, box in _silk_texts(tree):
750
+ if not lo - EPS <= size <= hi + EPS:
751
+ add("error", "silktext", f"{kind} text on {layer} is {size:g} mm high (readable silkscreen text is "
752
+ f"{lo:g}-{hi:g} mm; KLC F5.1 uses 1.0 mm)")
753
+ side = "F" if layer.startswith("F.") else "B"
754
+ hit = [p["num"] or "?" for p in s["rects"] if (p["copper"] or p["hole"]) and
755
+ any(l in (f"{side}.Cu", "*.Cu") for l in p["layers"]) and
756
+ _boxes_overlap(box, (p["x"] - p["w"] / 2, p["y"] - p["h"] / 2, p["x"] + p["w"] / 2, p["y"] + p["h"] / 2), 0.01)]
757
+ if hit:
758
+ add("error", "silktext", f"{kind} text on {layer} lies over pad{'s' if len(hit) > 1 else ''} "
759
+ f"{FC._ranges(hit)}: the board house clips it off the copper (KLC F5.1)")
760
+ fb = s["fab_box"]
761
+ if kind == "reference" and fb and not hit and _boxes_overlap(box, fb, 0.01) and s["copper"]:
762
+ add("warning", "silktext", f"the designator on {layer} sits over the part body: it is hidden once the part "
763
+ "is placed (KLC F5.1 puts it outside the body)")
764
+ if any(kind == "reference" for kind, *_ in _silk_texts(tree)) or not s["copper"]:
765
+ return
766
+ # no designator on silk: the Altium export still puts .Designator on Top Overlay where the reference was
767
+ from . import altium_lib as AL
768
+ texts = [(t, x, y, h, rot, True) for t, x, y, h, _, rot, layer in AL.texts_from_kicad(tree) if layer == AL.TOP_OVERLAY]
769
+ flip = lambda b: (b[0], -b[3], b[2], -b[1])
770
+ pads = [(p["num"] or "?", flip((p["x"] - p["w"] / 2, p["y"] - p["h"] / 2, p["x"] + p["w"] / 2, p["y"] + p["h"] / 2)),
771
+ None if any(l == "*.Cu" for l in p["layers"]) else "F.Cu" in p["layers"]) for p in s["rects"] if p["copper"]]
772
+ body = flip(s["fab_box"]) if s["fab_box"] else flip(s["pad_box"]) if s["pad_box"] else None
773
+ for lvl, msg in overlay_text_issues(texts, pads, body):
774
+ add(lvl, "silktext", f"in the Altium export: {msg}")
775
+
776
+
777
+ def _model_rules(tree, s, add, require=False):
778
+ models = list(sexpr.find(tree, "model"))
779
+ if not models:
780
+ if s["copper"] and not VIRTUAL.match(s["name"]):
781
+ add("error" if require else "warning", "model", "no 3D model reference (KLC F9.3: every real part has one, "
782
+ "even before the model exists)")
783
+ return
784
+ m = models[0]
785
+ path = str(m[1])
786
+ fname = re.split(r"[/\\]", path)[-1]
787
+ stem, ext = (fname.rsplit(".", 1) + [""])[:2]
788
+ base = MODEL_SUFFIXES.sub("", s["name"])
789
+ while MODEL_SUFFIXES.search(base):
790
+ base = MODEL_SUFFIXES.sub("", base)
791
+ if stem not in (s["name"], base) and not s["name"].startswith(stem) and not base.startswith(stem):
792
+ add("warning", "model", f"3D model '{fname}' is not named after the footprint (KLC F9.3)")
793
+ if ext.lower() not in ("step", "stp", "wrl"):
794
+ add("error", "model", f"3D model '{fname}' is not a STEP or VRML file")
795
+ elif ext.lower() == "wrl":
796
+ add("warning", "model", f"3D model '{fname}' is VRML; KLC F9.3 references the .step (Altium cannot use .wrl)")
797
+ if re.match(r"^([A-Za-z]:[/\\]|/|~)", path):
798
+ add("error", "model", f"3D model path '{path}' is absolute: it breaks on every other machine (use "
799
+ "${KICADn_3DMODEL_DIR}, ${KIPRJMOD} or a library variable)")
800
+ for key, want in (("scale", 1.0), ("rotate", 0.0)):
801
+ node = sexpr.first(sexpr.first(m, key) or [], "xyz")
802
+ if node and any(abs(_f(v) - want) > 1e-6 for v in node[1:4]):
803
+ add("warning", "model", f"3D model {key} is {' '.join(node[1:4])} (KLC F9.3: {'1 1 1' if want else '0 0 0'})")
804
+ off = sexpr.first(sexpr.first(m, "offset") or [], "xyz")
805
+ if off and any(abs(_f(v)) > 1e-6 for v in off[1:3]):
806
+ add("warning", "model", f"3D model offset {' '.join(off[1:4])} mm (KLC F9.3: the model is aligned in the file)")
807
+
808
+
809
+ def _margin_rules(tree, s, add):
810
+ cb = sexpr.courtyard_bbox(tree)
811
+ if not cb or not s["copper"]:
812
+ return
813
+ ext = _union(_bbox_pads([p for p in s["rects"] if p["copper"] or p["hole"]]), s["fab_box"])
814
+ name = s["name"]
815
+ w, h = ext[2] - ext[0], ext[3] - ext[1]
816
+ if BGA_NAME.search(name) and any(FC.BALL.match(p["num"]) for p in s["signal"]):
817
+ need, why = 1.0, "BGA"
818
+ elif CONNECTOR.search(name):
819
+ need, why = 0.5, "connector"
820
+ elif CRYSTAL.search(name):
821
+ need, why = 0.5, "crystal"
822
+ elif CANNED.search(name):
823
+ need, why = 0.5, "canned capacitor"
824
+ elif min(w, h) < 1.5 or (s["fab_box"] and min(s["fab_box"][2] - s["fab_box"][0], s["fab_box"][3] - s["fab_box"][1]) < 1.0):
825
+ need, why = 0.15, "part under 1.5 mm"
826
+ else:
827
+ need, why = 0.25, "default"
828
+ gap = min(ext[0] - cb[0], ext[1] - cb[1], cb[2] - ext[2], cb[3] - ext[3])
829
+ words = " ".join(str((sexpr.first(tree, k) or ["", ""])[1]) for k in ("descr", "tags"))
830
+ if re.search(r"density[- ]C\b|least density|IPC-7351B?\s+\w+\s+density\s+C", words, re.I) or re.search(r"_?L$", name) \
831
+ and parse_name(name).get("density") == "L":
832
+ need, why = 0.1, "IPC density C (least)" # IPC-7351B's least-material courtyard excess
833
+ floor = {"BGA": 0.25, "connector": 0.25, "crystal": 0.25, "canned capacitor": 0.25}.get(why, need)
834
+ if -EPS <= gap < floor - 0.011: # (below zero: footprint_check's enclosure rule reports it)
835
+ add("warning", "margin", f"courtyard is {gap:.2f} mm from the pads / body ({why}: KLC F5.3 asks {need:g} mm)")
836
+
837
+
838
+ def _sym(segs, polys, tol=0.02):
839
+ """True when a layer's drawing is the same turned 180 degrees about the origin (so it cannot show pin 1)."""
840
+ keys = lambda a, b: (round(a[0] / tol), round(a[1] / tol), round(b[0] / tol), round(b[1] / tol))
841
+ have = set()
842
+ for s in segs:
843
+ have.add(keys((s[0], s[1]), (s[2], s[3])))
844
+ have.add(keys((s[2], s[3]), (s[0], s[1])))
845
+ near = lambda k: any((k[0] + i, k[1] + j, k[2] + m, k[3] + n) in have for i in (-1, 0, 1) for j in (-1, 0, 1)
846
+ for m in (-1, 0, 1) for n in (-1, 0, 1))
847
+ for s in segs:
848
+ if not near(keys((-s[0], -s[1]), (-s[2], -s[3]))):
849
+ return False
850
+ fills = [((sum(x for x, _ in p) / len(p)), (sum(y for _, y in p) / len(p))) for p, f in polys if f]
851
+ for cx, cy in fills:
852
+ if not any(math.hypot(cx + dx, cy + dy) < 0.05 for dx, dy in fills):
853
+ return False
854
+ return True
855
+
856
+
857
+ def _polarity_rules(tree, s, claim, add):
858
+ if not _polarized(s, claim) or not s["copper"]:
859
+ return
860
+ if BGA_NAME.search(s["name"]) and claim.get("layout"):
861
+ return # footprint_check's BGA rules mark A1
862
+ silk, spolys = FC._graphics(tree, "F.SilkS")
863
+ fab, fpolys = FC._graphics(tree, "F.Fab")
864
+ ones = [p for p in s["signal"] if p["num"] in ("1", "A1")]
865
+ centred = ones and all(abs(p["x"]) < EPS and abs(p["y"]) < EPS for p in ones)
866
+ if centred:
867
+ return
868
+ if not silk and not spolys:
869
+ add("warning", "polarity", "no silkscreen at all: pin 1 / polarity is not marked (KLC F5.1)")
870
+ elif _sym(silk, spolys):
871
+ add("warning", "polarity", "the silkscreen is the same turned 180 degrees: pin 1 / polarity is not marked "
872
+ "(KLC F5.1)")
873
+ if fab and _sym(fab, fpolys):
874
+ add("warning", "polarity", "the fab outline is the same turned 180 degrees: chamfer the pin-1 corner "
875
+ "(KLC F5.2: 1 mm or 25 % of the body)")
876
+
877
+
878
+ def _graphics_rules(tree, add):
879
+ seen = {}
880
+ dup = {}
881
+ for kind in ("fp_line", "fp_rect", "fp_circle", "fp_arc", "fp_poly"):
882
+ for g in sexpr.find(tree, kind):
883
+ layer = _layer(g)
884
+ pts = []
885
+ for k in ("start", "mid", "end", "center"):
886
+ n = sexpr.first(g, k)
887
+ if n:
888
+ pts.append((round(_f(n[1]), 3), round(_f(n[2]), 3)))
889
+ pts += [(round(_f(xy[1]), 3), round(_f(xy[2]), 3)) for xy in sexpr.find(sexpr.first(g, "pts") or [], "xy")]
890
+ key = (kind, layer, tuple(sorted(pts)))
891
+ if key in seen:
892
+ dup[layer] = dup.get(layer, 0) + 1
893
+ seen[key] = True
894
+ for layer, n in sorted(dup.items()):
895
+ add("warning", "graphics", f"{n} duplicate graphic element(s) on {layer} (KLC F5.4)")
896
+
897
+
898
+ # ------------------------------------------------------------------ connectivity
899
+ def _pin_map(symbol_pins):
900
+ if isinstance(symbol_pins, dict):
901
+ return {str(k): str(v or "") for k, v in symbol_pins.items()}
902
+ return {str(k): "" for k in symbol_pins}
903
+
904
+
905
+ def connect_rules(s, symbol_pins, add):
906
+ """Symbol pins vs pads (rules connect and pinmap)."""
907
+ pins = _pin_map(symbol_pins)
908
+ pads = {p["num"] for p in s["copper"] if p["num"]}
909
+ mount = set(s["mount"])
910
+ numbered = pads - mount
911
+ missing = sorted(set(pins) - pads, key=_natural)
912
+ extra = sorted(numbered - set(pins), key=_natural)
913
+ if missing:
914
+ hint = ""
915
+ if s["ep_nums"] and any(re.fullmatch(r"(EP|PAD|TAB|THERMAL|GND_?PAD|EPAD|PAD\d*)", m, re.I) for m in missing):
916
+ hint = f" (the exposed pad is numbered {', '.join(sorted(s['ep_nums'], key=_natural))}: number the symbol pin the same)"
917
+ add("error", "connect", f"symbol pins without a pad: {FC._ranges(missing)}{hint}")
918
+ ep_extra = [n for n in extra if n in s["ep_nums"]]
919
+ other = [n for n in extra if n not in s["ep_nums"]]
920
+ if other:
921
+ add("error", "connect", f"pads without a symbol pin: {FC._ranges(other)} (they would be left unconnected)")
922
+ if ep_extra:
923
+ add("error", "connect", f"exposed pad {', '.join(ep_extra)} has no symbol pin: it would float. It is the part's "
924
+ "heat path and usually its ground return (datasheet; Bogatin: every return path engineered)")
925
+ # polarity: KiCad's D / LED symbols have the cathode on pin 1, CP_ has + on pin 1
926
+ name = s["name"]
927
+ if re.match(r"^(D|LED)_", name) and len(pins) == 2 and {"1", "2"} <= set(pins):
928
+ p1, p2 = pins["1"].strip().lower(), pins["2"].strip().lower()
929
+ if p1 in ANODE and p2 in CATHODE:
930
+ add("error", "pinmap", f"the symbol puts the anode on pin 1 but {name.split('_')[0]} footprints put the cathode "
931
+ "on pad 1 (the marked end): the part would be placed backwards")
932
+ if re.match(r"^CP_", name) and len(pins) == 2 and {"1", "2"} <= set(pins):
933
+ p1 = pins["1"].strip().lower()
934
+ if p1 in ("-", "neg", "n", "minus", "k", "cathode"):
935
+ add("error", "pinmap", "the symbol puts - on pin 1 but polarized-capacitor footprints mark + on pad 1: the "
936
+ "capacitor would be placed backwards")
937
+
938
+
939
+ # ------------------------------------------------------------------ driver
940
+ def check(text, filename=None, symbol_pins=None, require_model=False, geometry=True):
941
+ """footprint_check (geometry) + the rule book. -> [(level, rule, message)]."""
942
+ issues = list(FC.check(text)) if geometry else []
943
+ add = lambda lvl, rule, msg: issues.append((lvl, rule, msg))
944
+ tree = sexpr.parse(text)
945
+ s = survey(tree)
946
+ claim = _name_rules(s, add)
947
+ _meta_rules(tree, s, filename, add)
948
+ _attr_rules(tree, s, add)
949
+ _origin_rules(s, add)
950
+ _layer_rules(s, add)
951
+ _padshape_rules(s, claim, add)
952
+ _tht_rules(s, add)
953
+ _via_rules(s, add)
954
+ _text_rules(tree, s, add)
955
+ _silktext_rules(tree, s, add)
956
+ _model_rules(tree, s, add, require=require_model)
957
+ _margin_rules(tree, s, add)
958
+ _polarity_rules(tree, s, claim, add)
959
+ _graphics_rules(tree, add)
960
+ if symbol_pins is not None:
961
+ connect_rules(s, symbol_pins, add)
962
+ return issues
963
+
964
+
965
+ # ------------------------------------------------------------------ Altium
966
+ def overlay_text_issues(texts, pads, body=None):
967
+ """Altium overlay text, y up, in mm: texts [(string, x, y, height, rotation, on top)] anchored at the bottom-left
968
+ corner as Altium anchors stroke text; pads [(number, (x0, y0, x1, y1), on top / None for Multi-Layer)].
969
+ -> [(level, message)]: height outside SILK_TEXT, text over a pad, the designator over the body."""
970
+ from .altium_lib import CHAR_W
971
+ lo, hi = SILK_TEXT
972
+ out = []
973
+ for name, x, y, h, rot, on_top in texts:
974
+ if not lo - EPS <= h <= hi + EPS:
975
+ out.append(("error", f"overlay text '{name}' is {h:g} mm high (readable silkscreen text is {lo:g}-{hi:g} mm)"))
976
+ a = math.radians(rot)
977
+ corners = [(u * math.cos(a) - v * math.sin(a), u * math.sin(a) + v * math.cos(a))
978
+ for u in (0, CHAR_W * h * (NOMINAL_REF if name == ".Designator" else len(name))) for v in (0, h)]
979
+ box = (x + min(c[0] for c in corners), y + min(c[1] for c in corners),
980
+ x + max(c[0] for c in corners), y + max(c[1] for c in corners))
981
+ hit = [n for n, b, side in pads if side in (None, on_top) and _boxes_overlap(box, b, 0.01)]
982
+ if hit:
983
+ out.append(("error", f"overlay text '{name}' lies over pad{'s' if len(hit) > 1 else ''} {FC._ranges(hit)}"))
984
+ elif name == ".Designator" and body and _boxes_overlap(box, body, 0.01):
985
+ out.append(("warning", "the .Designator sits over the part body: it is hidden once the part is placed"))
986
+ return out
987
+
988
+
989
+ def pcblib_text_issues(fp):
990
+ """overlay_text_issues on one footprint read back by altium_lib.read_pcblib; the body is the box through the
991
+ pad centres. -> [(level, message)]"""
992
+ from . import altium_lib as AL
993
+ pads = [r for r in fp["records"] if r["type"] == "pad"]
994
+ top = lambda layer: layer in (AL.TOP, AL.TOP_OVERLAY)
995
+ boxes = []
996
+ for p in pads:
997
+ w, h = AL.to_mm(p["w"]), AL.to_mm(p["h"])
998
+ if round((p.get("rot") or 0) / 90) % 2:
999
+ w, h = h, w
1000
+ x, y = AL.to_mm(p["x"]), AL.to_mm(p["y"])
1001
+ boxes.append((p["designator"] or "?", (x - w / 2, y - h / 2, x + w / 2, y + h / 2),
1002
+ None if p["layer"] == AL.MULTI else top(p["layer"])))
1003
+ texts = [(t.get("text") or "?", AL.to_mm(t["x"]), AL.to_mm(t["y"]), AL.to_mm(t["h"]), t.get("rot") or 0.0,
1004
+ top(t["layer"])) for t in fp["records"] if t["type"] == "text" and t.get("layer") in (AL.TOP_OVERLAY, AL.BOTTOM_OVERLAY)]
1005
+ ctr = [(AL.to_mm(p["x"]), AL.to_mm(p["y"])) for p in pads]
1006
+ body = (min(c[0] for c in ctr), min(c[1] for c in ctr), max(c[0] for c in ctr), max(c[1] for c in ctr)) if ctr else None
1007
+ return overlay_text_issues(texts, boxes, body)
1008
+
1009
+
1010
+ def check_pcblib(fp, rules=None, pin1=None):
1011
+ """Altium-side rules on one footprint from altium_lib.read_pcblib: [(level, "altium", message)]."""
1012
+ from . import altium_lib as AL, rules as RU
1013
+ rules = rules or RU.DEFAULTS
1014
+ out = []
1015
+ add = lambda lvl, msg: out.append((lvl, "altium", msg))
1016
+ recs = fp["records"]
1017
+ pads = [r for r in recs if r["type"] == "pad"]
1018
+ crt = RU.layer_id(rules["layers"]["courtyard"])
1019
+ asm = RU.layer_id(rules["layers"]["assembly"])
1020
+ if not pads:
1021
+ add("error", "no pads")
1022
+ smd_wrong = [p["designator"] for p in pads if not p["hole"] and p["layer"] not in (AL.TOP, AL.BOTTOM)]
1023
+ tht_wrong = [p["designator"] for p in pads if p["hole"] and p["plated"] and p["layer"] != AL.MULTI]
1024
+ if smd_wrong:
1025
+ add("error", f"SMD pads {FC._ranges(smd_wrong)} are not on Top Layer / Bottom Layer")
1026
+ if tht_wrong:
1027
+ add("error", f"plated through-hole pads {FC._ranges(tht_wrong)} are not on Multi-Layer")
1028
+ graphics = lambda layer: [r for r in recs if r["type"] in ("track", "arc", "region") and r.get("layer") == layer]
1029
+ if not graphics(crt):
1030
+ add("error", f"no courtyard on {rules['layers']['courtyard']}")
1031
+ if not graphics(asm):
1032
+ add("warning", f"no assembly outline on {rules['layers']['assembly']}")
1033
+ texts = [r.get("text", "") for r in recs if r["type"] == "text"]
1034
+ if ".Designator" not in texts:
1035
+ add("warning", "no .Designator special string")
1036
+ for lvl, msg in pcblib_text_issues(fp):
1037
+ add(lvl, msg)
1038
+ if not any(r["type"] == "body" for r in recs) and not fp["params"].get("HEIGHT"):
1039
+ add("warning", "no 3D body and no height")
1040
+ nums = {p["designator"] for p in pads}
1041
+ if not set(pin1 or ("1", "A1")) & nums and not any(FC.BALL.match(n) for n in nums):
1042
+ add("error", "no pin 1 pad")
1043
+ if re.search(r'[<>:"/\\|?*]', fp["name"]):
1044
+ add("warning", f"footprint name '{fp['name']}' has characters Windows file names cannot carry")
1045
+ return out
1046
+
1047
+
1048
+ def summary(results):
1049
+ """{(level, rule): count} and the number of files with an error, over [(path, issues)]."""
1050
+ counts, bad = {}, 0
1051
+ for _, issues in results:
1052
+ for lvl, rule, _ in issues:
1053
+ counts[(lvl, rule)] = counts.get((lvl, rule), 0) + 1
1054
+ bad += any(l == "error" for l, _, _ in issues)
1055
+ return counts, bad
1056
+
1057
+
1058
+ def main(argv=None):
1059
+ import argparse
1060
+ ap = argparse.ArgumentParser(prog="ecadlib check", description="Check KiCad footprints against the ecadlib rule "
1061
+ "book (docs/footprint-rulebook.md): geometry, naming, layers, text, 3D, pin 1")
1062
+ ap.add_argument("files", nargs="+", help=".kicad_mod files or .pretty folders")
1063
+ ap.add_argument("--geometry-only", action="store_true", help="only footprint_check's copper / silk / courtyard rules")
1064
+ ap.add_argument("--summary", action="store_true", help="counts per rule instead of every finding")
1065
+ ap.add_argument("--errors", action="store_true", help="print errors only")
1066
+ a = ap.parse_args(argv)
1067
+ files = []
1068
+ for f in a.files:
1069
+ p = Path(f)
1070
+ files += sorted(p.glob("*.kicad_mod")) if p.is_dir() else [p]
1071
+ results, bad = [], 0
1072
+ for f in files:
1073
+ text = f.read_text(encoding="utf-8")
1074
+ issues = FC.check(text) if a.geometry_only else check(text, filename=f.name)
1075
+ if a.errors:
1076
+ issues = [i for i in issues if i[0] == "error"]
1077
+ results.append((f, issues))
1078
+ bad += any(l == "error" for l, _, _ in issues)
1079
+ if not a.summary:
1080
+ print(f"{'OK ' if not issues else 'WARN' if all(l == 'warning' for l, _, _ in issues) else 'FAIL'} {f}")
1081
+ for lvl, rule, msg in issues:
1082
+ print(f" {lvl:<7} {rule:<9} {msg}")
1083
+ if a.summary:
1084
+ counts, nbad = summary(results)
1085
+ print(f"{len(files)} footprints, {nbad} with errors, {sum(1 for _, i in results if not i)} with no findings")
1086
+ for (lvl, rule), n in sorted(counts.items(), key=lambda kv: (-kv[1], kv[0])):
1087
+ print(f" {n:>6} {lvl:<7} {rule}")
1088
+ return 1 if bad else 0
1089
+
1090
+
1091
+ if __name__ == "__main__":
1092
+ sys.exit(main())