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,1006 @@
1
+ """Harder footprints for footprint_gen: ball grids, pin headers, connectors and odd shapes.
2
+
3
+ package_type (package_schema.json), all built by build() below and reached through footprint_gen.build():
4
+
5
+ bga BGA / LGA / CSP / WLCSP: rows x cols balls at pitch (pitch_y), lettered A, B, ... from the top
6
+ skipping I O Q S X Z (JEDEC JEP95), then AA, AB, ...; numbered 1.. from the left. Missing balls from
7
+ `depopulated` (names or ranges "E5:H8") or a `ball_map` (one string per row, "-" or "." = no ball).
8
+ Land: the datasheet's land_diameter, else IPC-7351B from the nominal ball size (collapsing balls
9
+ shrink 25 % from 0.55 mm up, 20 % from 0.25 mm, 15 % below; non-collapsing grow 5 %; LGA lands
10
+ = terminal). NSMD (copper = land, mask opening 0.05 bigger all round) or SMD (copper 0.05 bigger,
11
+ mask opening = land). Courtyard = body + 2.0 / 1.0 / 0.5 mm (density A / B / C, KLC uses 1.0).
12
+ pin_header 1 or 2 columns (cols) of rows pins at pitch. Through-hole: pin 1 at the origin, square pad 1,
13
+ round others (KiCad's drill/pad table per pitch unless drill / land_diameter are given). SMD:
14
+ pads from `land` (or KiCad's table), single-row SMD headers alternate left/right from pin 1.
15
+ connector any connector from the datasheet's recommended PCB layout, as a pad_table (below): signal pads,
16
+ mounting pads (MP), shield tabs (SH), NPTH locating holes, slots, keep-outs. THT pads without a
17
+ size get KiCad's connector rule (pad = drill + 1.0, no wider than pitch - 0.8 across the row).
18
+ custom the escape hatch for odd shapes: a pad_table with polygon pads, slots, paste split into windows
19
+ and thermal vias for big pads.
20
+
21
+ pad_table: the dimension drawing as a table, in the drawing's own frame so an LLM can copy it without arithmetic:
22
+ unit (mm | mil | in), y_axis (up | down), view (top | bottom: bottom views are mirrored), origin (what the
23
+ drawing's 0,0 is: center | pin1 | datum), center_at (the package centre in drawing coordinates when the origin is
24
+ not the centre; default the middle of the pads), footprint_origin (center | pin1; default pin1 when there are
25
+ through-hole pins, as KiCad does), default_pad, pads[], arrays[] (a row of equal pads: count, pitch dx/dy),
26
+ body (rectangle) and keepouts[]. Everything is converted to mm, KiCad's y-down top view and the chosen origin.
27
+
28
+ check_pad_table() checks a table before it becomes copper: units (mils typed as mm), pad count against pin_count,
29
+ duplicate / missing numbers, overlapping pads, pitch regularity along each row, mirror symmetry (an odd pad out in
30
+ an otherwise symmetric pattern is usually a misread number), pin 1 at the end of its row and at 0,0 when the
31
+ drawing says the origin is pin 1, drill vs pad (annular ring). Errors stop build(); warnings land in the report.
32
+
33
+ Courtyard for connector / custom / pin_header: the union of every pad and the body (and the lead span of SMD
34
+ headers), each grown by the courtyard excess (0.5 mm for connectors, KLC F5.3) and rounded out to 0.01 mm, traced
35
+ as one rectilinear outline (falls back to the bounding box when the pieces don't touch).
36
+ """
37
+ import math, re
38
+
39
+ UNIT = {"mm": 1.0, "mil": 0.0254, "in": 25.4}
40
+ JEDEC_SKIP = set("IOQSXZ")
41
+ LETTERS = [c for c in "ABCDEFGHIJKLMNOPQRSTUVWXYZ" if c not in JEDEC_SKIP] # 20 row letters
42
+ ALL_LETTERS = list("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
43
+ # Pads that are not pins. KiCad's library names them MP (mounting) and SH (shield), holes "" (kicad-footprints f8440fa:
44
+ # 4400+ MP / SH pads; no MH / MTG / MOUNT / MECH / NPTH, kept because datasheet tables use them). Never a bare letter:
45
+ # H and S (and G, R, T) name BGA ball rows, audio-jack contacts (S = sleeve), rotary-encoder switch pins (S1, S2) and
46
+ # edge-connector fingers (H), all of them pins.
47
+ MOUNT = re.compile(r"^((MP|MH|SH|MTG|MOUNT|MECH|NPTH)\d*)?$", re.I)
48
+ PIN_ROLES = ("pin", "thermal") # numbered pads that are pins
49
+ BGA_EXCESS = {"A": 2.0, "B": 1.0, "C": 0.5}
50
+ CONN_EXCESS = {"A": 0.5, "B": 0.5, "C": 0.5} # KLC F5.3: connectors 0.5 mm
51
+ CUSTOM_EXCESS = {"A": 0.5, "B": 0.25, "C": 0.1}
52
+ NSMD_MASK = 0.05 # NSMD mask opening this much bigger than the land, all round
53
+ SMD_GROW = 0.05 # SMD-defined: copper this much bigger, mask = land
54
+ TH_RING, TH_RING_MIN, TH_PAD_GAP = 0.5, 0.15, 0.8 # KiCad connector generator: annular ring, minimum, pad-to-pad
55
+ # KiCad pin header tables: pitch -> (drill, pad diameter) for through-hole, (pad length, width, centre) for SMD
56
+ HEADER_TH = {2.54: (1.0, 1.7), 2.0: (0.8, 1.35), 1.27: (0.65, 1.0), 1.0: (0.5, 0.85)}
57
+ HEADER_SMD = {2.54: (3.15, 1.0, 2.525), 2.0: (2.58, 1.0, 2.085), 1.27: (2.4, 0.74, 1.95), 1.0: (2.0, 0.5, 1.65)}
58
+ HEADER_SMD1 = {2.54: (2.51, 1.0, 1.655), 2.0: (2.35, 0.85, 1.175), 1.27: (3.0, 0.65, 1.5), 1.0: (1.75, 0.6, 0.875)} # 1 row
59
+ GRID = 0.01
60
+ # IPC-7351B land approximation for collapsing BGA balls (nominal ball -> nominal land, mm): 25 % smaller from 0.55 mm,
61
+ # 20 % from 0.25 mm, 15 % below. Source: IPC-7351B section 14 (BGA), table "Collapsing ball land approximation", as
62
+ # reproduced in maker app notes; not re-checked online (ipc.org and the usual mirrors are blocked here). KiCad's
63
+ # library agrees where it names both sizes (Ball0.25 -> Pad0.2, Ball0.3 -> 0.25, Ball0.5 -> 0.4); it has no 0.17 mm ball.
64
+ IPC_BGA_COLLAPSING = ((0.75, 0.55), (0.65, 0.50), (0.60, 0.45), (0.55, 0.40), (0.50, 0.40), (0.45, 0.35), (0.40, 0.30),
65
+ (0.35, 0.30), (0.30, 0.25), (0.25, 0.20), (0.20, 0.17), (0.17, 0.15), (0.15, 0.13))
66
+
67
+
68
+ def _F():
69
+ from . import footprint_gen
70
+ return footprint_gen
71
+
72
+
73
+ def _dim(v):
74
+ return _F().dim(v)
75
+
76
+
77
+ def _r4(x):
78
+ return round(x + 0.0, 4)
79
+
80
+
81
+ # ---------------------------------------------------------------- BGA naming
82
+ def row_name(i, jedec=True):
83
+ """0 -> A, 19 -> Y, 20 -> AA (JEDEC JEP95: I O Q S X Z are never used as row letters)."""
84
+ L = LETTERS if jedec else ALL_LETTERS
85
+ if i < len(L):
86
+ return L[i]
87
+ i -= len(L)
88
+ return L[i // len(L)] + L[i % len(L)]
89
+
90
+
91
+ def ball_index(name, jedec=True, rows=200):
92
+ """'AB12' -> (row, col) zero-based, or None."""
93
+ m = re.fullmatch(r"([A-Z]{1,2})(\d+)", str(name).strip().upper())
94
+ if not m:
95
+ return None
96
+ names = {row_name(i, jedec): i for i in range(rows)}
97
+ return (names[m.group(1)], int(m.group(2)) - 1) if m.group(1) in names else None
98
+
99
+
100
+ def bga_balls(spec):
101
+ """[(name, row, col)] of the populated balls, row-major from A1."""
102
+ rows, cols = spec["rows"], spec["cols"]
103
+ if not all(isinstance(v, int) and not isinstance(v, bool) and 1 <= v <= 200 for v in (rows, cols)):
104
+ raise ValueError(f"rows / cols must be whole numbers from 1 to 200 (got {rows!r} x {cols!r})")
105
+ jedec = spec.get("row_letters", "jedec") == "jedec"
106
+ if spec.get("ball_map"):
107
+ bm = spec["ball_map"]
108
+ if len(bm) != rows or any(len(r.replace(" ", "")) != cols for r in bm):
109
+ raise ValueError(f"ball_map must have {rows} rows of {cols} characters (one per column, '-' or '.' = no ball); "
110
+ f"got {len(bm)} rows of {sorted({len(r.replace(' ', '')) for r in bm})}")
111
+ have = {(i, j) for i, r in enumerate(bm) for j, ch in enumerate(r.replace(" ", "")) if ch not in "-._0"}
112
+ else:
113
+ have = {(i, j) for i in range(rows) for j in range(cols)}
114
+ for item in spec.get("depopulated") or []:
115
+ a, _, b = str(item).partition(":")
116
+ ia, ib = ball_index(a, jedec, rows), ball_index(b or a, jedec, rows)
117
+ if ia is None or ib is None or max(ia[0], ib[0]) >= rows or max(ia[1], ib[1]) >= cols:
118
+ raise ValueError(f"depopulated {item!r}: not a ball of a {rows} x {cols} grid "
119
+ f"(rows {row_name(0, jedec)}..{row_name(rows - 1, jedec)}, columns 1..{cols})")
120
+ for i in range(min(ia[0], ib[0]), max(ia[0], ib[0]) + 1):
121
+ for j in range(min(ia[1], ib[1]), max(ia[1], ib[1]) + 1):
122
+ have.discard((i, j))
123
+ return [(f"{row_name(i, jedec)}{j + 1}", i, j) for i, j in sorted(have)]
124
+
125
+
126
+ def ipc_bga_land(ball, ball_type="collapsing"):
127
+ """IPC-7351B land diameter for a nominal ball (or LGA terminal) diameter. Returns (land, rule text)."""
128
+ if ball_type == "lga":
129
+ return round(ball, 3), "LGA land = terminal size"
130
+ if ball_type == "non_collapsing":
131
+ d = ball * 1.05
132
+ return round(math.floor(d / 0.01 + 0.5) * 0.01, 2), "non-collapsing ball: land 5 % larger"
133
+ red = 0.25 if ball >= 0.55 - 1e-9 else 0.20 if ball >= 0.25 - 1e-9 else 0.15
134
+ hit = next((land for b, land in IPC_BGA_COLLAPSING if abs(b - ball) < 1e-6), None)
135
+ if hit is not None: # a ball size the IPC table lists: its land
136
+ return hit, f"collapsing ball {ball:g} mm: land {round(red * 100)} % smaller (IPC-7351B table)"
137
+ g = 0.05 if ball >= 0.25 - 1e-9 else 0.01 # between table sizes: the table's rounding (0.05 mm; 0.01 mm
138
+ d = (math.floor(ball * (1 - red) / g + 0.5) if g > 0.01 else math.ceil(round(ball * (1 - red) / g, 6))) * g # up below 0.25)
139
+ return round(d, 3), f"collapsing ball {ball:g} mm: land {round(red * 100)} % smaller"
140
+
141
+
142
+ # ---------------------------------------------------------------- geometry helpers
143
+ def _rot(x, y, deg):
144
+ a = math.radians(deg)
145
+ return x * math.cos(a) + y * math.sin(a), -x * math.sin(a) + y * math.cos(a) # KiCad: CCW on screen, y down
146
+
147
+
148
+ def pad_bbox(p):
149
+ """(x1, y1, x2, y2) of a pad's copper (rotation and polygons included)."""
150
+ if p.get("poly"):
151
+ xs, ys = [q[0] for q in p["poly"]], [q[1] for q in p["poly"]]
152
+ return min(xs), min(ys), max(xs), max(ys)
153
+ w, h, rot = p["w"], p["h"], p.get("rot", 0) % 180
154
+ if abs(rot - 90) < 1e-6:
155
+ w, h = h, w
156
+ elif rot:
157
+ c, s = abs(math.cos(math.radians(rot))), abs(math.sin(math.radians(rot)))
158
+ w, h = w * c + h * s, w * s + h * c
159
+ return p["x"] - w / 2, p["y"] - h / 2, p["x"] + w / 2, p["y"] + h / 2
160
+
161
+
162
+ def _pt_in_poly(x, y, pts):
163
+ inside = False
164
+ for (x1, y1), (x2, y2) in zip(pts, pts[1:] + pts[:1]):
165
+ if (y1 > y) != (y2 > y) and x < x1 + (y - y1) * (x2 - x1) / (y2 - y1):
166
+ inside = not inside
167
+ return inside
168
+
169
+
170
+ def _seg_dist(px, py, a, b):
171
+ (x1, y1), (x2, y2) = a, b
172
+ dx, dy = x2 - x1, y2 - y1
173
+ t = 0 if dx == dy == 0 else max(0, min(1, ((px - x1) * dx + (py - y1) * dy) / (dx * dx + dy * dy)))
174
+ return math.hypot(px - x1 - t * dx, py - y1 - t * dy)
175
+
176
+
177
+ def _pad_dist(px, py, p):
178
+ """Distance from a point to a pad's copper outline (0 inside)."""
179
+ if p.get("poly"):
180
+ if _pt_in_poly(px, py, p["poly"]):
181
+ return 0.0
182
+ return min(_seg_dist(px, py, a, b) for a, b in zip(p["poly"], p["poly"][1:] + p["poly"][:1]))
183
+ x1, y1, x2, y2 = pad_bbox(p)
184
+ w, h = x2 - x1, y2 - y1
185
+ r = min(w, h) / 2 if p["shape"] in ("circle", "oval") else _corner(p) if p["shape"] == "roundrect" else 0.0
186
+ dx = max(abs(px - p["x"]) - (w / 2 - r), 0)
187
+ dy = max(abs(py - p["y"]) - (h / 2 - r), 0)
188
+ return max(math.hypot(dx, dy) - r, 0.0)
189
+
190
+
191
+ def _corner(p):
192
+ return min(0.25, 0.25 * min(p["w"], p["h"]))
193
+
194
+
195
+ def _interior_point(pts):
196
+ """A point well inside a polygon (centroid when it is inside, else the best of a grid)."""
197
+ cx, cy = sum(x for x, _ in pts) / len(pts), sum(y for _, y in pts) / len(pts)
198
+ edge = lambda x, y: min(_seg_dist(x, y, a, b) for a, b in zip(pts, pts[1:] + pts[:1]))
199
+ best = (edge(cx, cy), cx, cy) if _pt_in_poly(cx, cy, pts) else (-1, cx, cy)
200
+ xs, ys = [x for x, _ in pts], [y for _, y in pts]
201
+ for i in range(1, 24):
202
+ for j in range(1, 24):
203
+ x = min(xs) + (max(xs) - min(xs)) * i / 24
204
+ y = min(ys) + (max(ys) - min(ys)) * j / 24
205
+ if _pt_in_poly(x, y, pts):
206
+ d = edge(x, y)
207
+ if d > best[0] * 1.5:
208
+ best = (d, x, y)
209
+ if best[0] <= 0:
210
+ raise ValueError("polygon pad has no inside (check its points)")
211
+ return best
212
+
213
+
214
+ def _poly_area(pts):
215
+ return abs(sum(a[0] * b[1] - b[0] * a[1] for a, b in zip(pts, pts[1:] + pts[:1]))) / 2
216
+
217
+
218
+ def _rup(x):
219
+ return round(math.ceil(round(x / GRID, 6)) * GRID, 2)
220
+
221
+
222
+ def _rdn(x):
223
+ return round(math.floor(round(x / GRID, 6)) * GRID, 2)
224
+
225
+
226
+ def union_outline(rects):
227
+ """Outer outline of the union of axis-aligned rectangles (holes filled) as a list of corner points, or None when
228
+ the rectangles form more than one island."""
229
+ xs = sorted({v for r in rects for v in (r[0], r[2])})
230
+ ys = sorted({v for r in rects for v in (r[1], r[3])})
231
+ nx, ny = len(xs) - 1, len(ys) - 1
232
+ cov = [[False] * ny for _ in range(nx)]
233
+ for x1, y1, x2, y2 in rects:
234
+ i1, i2, j1, j2 = xs.index(x1), xs.index(x2), ys.index(y1), ys.index(y2)
235
+ for i in range(i1, i2):
236
+ for j in range(j1, j2):
237
+ cov[i][j] = True
238
+ # flood the outside (one cell of margin) so enclosed holes count as filled
239
+ out = set()
240
+ stack = [(-1, -1)]
241
+ while stack:
242
+ i, j = stack.pop()
243
+ if (i, j) in out or not (-1 <= i <= nx and -1 <= j <= ny):
244
+ continue
245
+ if 0 <= i < nx and 0 <= j < ny and cov[i][j]:
246
+ continue
247
+ out.add((i, j))
248
+ stack += [(i + 1, j), (i - 1, j), (i, j + 1), (i, j - 1)]
249
+ filled = lambda i, j: 0 <= i < nx and 0 <= j < ny and (i, j) not in out
250
+ cells = [(i, j) for i in range(nx) for j in range(ny) if filled(i, j)]
251
+ seen, stack = set(), [cells[0]]
252
+ while stack: # one island?
253
+ c = stack.pop()
254
+ if c in seen or not filled(*c):
255
+ continue
256
+ seen.add(c)
257
+ stack += [(c[0] + 1, c[1]), (c[0] - 1, c[1]), (c[0], c[1] + 1), (c[0], c[1] - 1)]
258
+ if len(seen) != len(cells):
259
+ return None
260
+ edges = {} # directed boundary edges, filled cell on the right (y down)
261
+ for i, j in cells:
262
+ if not filled(i, j - 1):
263
+ edges.setdefault((xs[i], ys[j]), []).append((xs[i + 1], ys[j]))
264
+ if not filled(i + 1, j):
265
+ edges.setdefault((xs[i + 1], ys[j]), []).append((xs[i + 1], ys[j + 1]))
266
+ if not filled(i, j + 1):
267
+ edges.setdefault((xs[i + 1], ys[j + 1]), []).append((xs[i], ys[j + 1]))
268
+ if not filled(i - 1, j):
269
+ edges.setdefault((xs[i], ys[j + 1]), []).append((xs[i], ys[j]))
270
+ start = min(edges)
271
+ loop, cur, prev_dir = [start], start, None
272
+ total = sum(len(v) for v in edges.values())
273
+ for _ in range(total):
274
+ cands = edges[cur]
275
+ if len(cands) > 1 and prev_dir: # pinch point: keep turning right (stay on this island)
276
+ cands.sort(key=lambda q: _turn(prev_dir, (q[0] - cur[0], q[1] - cur[1])))
277
+ nxt = cands.pop(0)
278
+ prev_dir = (nxt[0] - cur[0], nxt[1] - cur[1])
279
+ cur = nxt
280
+ if cur == start:
281
+ break
282
+ loop.append(cur)
283
+ pts = [] # drop collinear corners
284
+ for k, p in enumerate(loop):
285
+ a, b = loop[k - 1], loop[(k + 1) % len(loop)]
286
+ if (p[0] - a[0]) * (b[1] - p[1]) - (p[1] - a[1]) * (b[0] - p[0]) != 0:
287
+ pts.append(p)
288
+ return pts
289
+
290
+
291
+ def _turn(d0, d1):
292
+ """Sort key at a pinch point: right turn (towards the filled side, y down) first, then straight, then left."""
293
+ sg = lambda v: ((v[0] > 0) - (v[0] < 0), (v[1] > 0) - (v[1] < 0))
294
+ a, b = sg(d0), sg(d1)
295
+ cross = a[0] * b[1] - a[1] * b[0]
296
+ return 0 if cross > 0 else 1 if cross == 0 else 2
297
+
298
+
299
+ # ---------------------------------------------------------------- pad table -> pads
300
+ def _num(v):
301
+ return "" if v is None else str(v)
302
+
303
+
304
+ def _expand_table(t):
305
+ """pad_table rows + arrays -> list of raw pad dicts (drawing units, drawing frame)."""
306
+ base = dict(t.get("default_pad") or {})
307
+ rows = []
308
+ for i, p in enumerate(t.get("pads") or []):
309
+ rows.append(dict(base, **{k: v for k, v in p.items() if v is not None}, _src=f"pads[{i}]"))
310
+ for i, a in enumerate(t.get("arrays") or []):
311
+ step = a.get("number_step", 1)
312
+ first = a.get("first_number", 1)
313
+ for k in range(a["count"]):
314
+ p = dict(base, **{kk: v for kk, v in a.items() if v is not None and kk not in
315
+ ("count", "dx", "dy", "first_number", "number_step", "prefix", "x", "y")})
316
+ p["number"] = f"{a.get('prefix', '')}{first + k * step}"
317
+ p["x"], p["y"] = a["x"] + k * a.get("dx", 0), a["y"] + k * a.get("dy", 0)
318
+ p["_src"] = f"arrays[{i}] #{k + 1}"
319
+ rows.append(p)
320
+ return rows
321
+
322
+
323
+ def _role(p, mount=MOUNT):
324
+ if p.get("role"):
325
+ return p["role"]
326
+ if p.get("type") == "npth":
327
+ return "npth"
328
+ n = _num(p.get("number"))
329
+ if not n:
330
+ return "npth" if p.get("type") == "npth" else "mount"
331
+ if mount.fullmatch(n):
332
+ return "shield" if n.upper().startswith("SH") else "mount"
333
+ return "pin"
334
+
335
+
336
+ def table_layout(spec):
337
+ """pad_table -> (pads in mm / KiCad frame, body rect or None, keepouts, info). Pads keep their source row (_src)."""
338
+ t = spec["pad_table"]
339
+ f = UNIT[t.get("unit", "mm")]
340
+ raw = _expand_table(t)
341
+ if not raw:
342
+ raise ValueError("pad_table has no pads")
343
+ for p in raw: # polygon pads: position and extent from their points
344
+ if p.get("polygon"):
345
+ xs, ys = [v["x"] for v in p["polygon"]], [v["y"] for v in p["polygon"]]
346
+ p.setdefault("x", (min(xs) + max(xs)) / 2)
347
+ p.setdefault("y", (min(ys) + max(ys)) / 2)
348
+ p["_ext"] = (min(xs), min(ys), max(xs), max(ys))
349
+ elif "x" not in p or "y" not in p:
350
+ raise ValueError(f"{p['_src']} (pad {p.get('number')}): no x/y")
351
+ sx =-1 if t.get("view", "top") == "bottom" else 1
352
+ sy = -1 if t.get("y_axis", "up") == "up" else 1
353
+ if t.get("center_at"):
354
+ cx, cy = t["center_at"]["x"], t["center_at"]["y"]
355
+ elif t.get("origin", "center") == "center":
356
+ cx = cy = 0.0
357
+ else: # the middle of the pads' extent
358
+ ext = [p.get("_ext") or (p["x"] - (p.get("w") or p.get("d") or 0) / 2, p["y"] - (p.get("h") or p.get("d") or 0) / 2,
359
+ p["x"] + (p.get("w") or p.get("d") or 0) / 2, p["y"] + (p.get("h") or p.get("d") or 0) / 2)
360
+ for p in raw]
361
+ cx = (min(e[0] for e in ext) + max(e[2] for e in ext)) / 2
362
+ cy = (min(e[1] for e in ext) + max(e[3] for e in ext)) / 2
363
+ mount = MOUNT # H1, S2 .. are pins (balls, contacts), MP / SH are not
364
+ X = lambda x: _r4(sx * (x - cx) * f)
365
+ Y = lambda y: _r4(sy * (y - cy) * f)
366
+ pads = []
367
+ for p in raw:
368
+ kind = {"smd": "smd", "tht": "thru_hole", "npth": "np_thru_hole"}[p.get("type") or ("tht" if p.get("drill") else "smd")]
369
+ role = _role(dict(p, type={"thru_hole": "tht", "np_thru_hole": "npth"}.get(kind, kind)), mount)
370
+ q = {"num": "" if kind == "np_thru_hole" else _num(p.get("number")), "kind": kind, "role": role,
371
+ "x": X(p["x"]), "y": Y(p["y"]), "rot": (p.get("rotation", 0) or 0) * sx, "_src": p["_src"]}
372
+ if p.get("polygon"):
373
+ pts = [(X(v["x"]), Y(v["y"])) for v in p["polygon"]]
374
+ if len(pts) < 3:
375
+ raise ValueError(f"{p['_src']}: a polygon pad needs at least 3 points")
376
+ xs, ys = [a for a, _ in pts], [b for _, b in pts]
377
+ q.update(shape="custom", poly=pts, w=_r4(max(xs) - min(xs)), h=_r4(max(ys) - min(ys)), rot=0)
378
+ else:
379
+ d = p.get("d")
380
+ w, h = (d, d) if d else (p.get("w"), p.get("h"))
381
+ drill = p.get("drill")
382
+ if (w is None or h is None) and kind == "np_thru_hole" and drill:
383
+ w = h = drill
384
+ if (w is None or h is None) and kind == "thru_hole" and drill:
385
+ q["auto_size"] = True
386
+ w = h = drill # sized later by tht_pad_size (needs the row direction)
387
+ if w is None or h is None:
388
+ raise ValueError(f"{p['_src']} (pad {p.get('number')}): no size (give w and h, or d)")
389
+ q.update(w=_r4(w * f), h=_r4(h * f), shape=p.get("shape") or ("circle" if d else "roundrect" if kind == "smd"
390
+ else "circle" if abs(w - h) < 1e-9 else "oval"))
391
+ q["_auto_shape"] = not p.get("shape")
392
+ if q["shape"] == "polygon":
393
+ raise ValueError(f"{p['_src']}: shape polygon needs polygon points")
394
+ if q["shape"] == "circle" and abs(q["w"] - q["h"]) > 1e-9:
395
+ q["shape"] = "oval"
396
+ if kind != "smd":
397
+ dr = p.get("drill")
398
+ if not dr:
399
+ raise ValueError(f"{p['_src']} (pad {p.get('number')}): a {p.get('type')} pad needs a drill")
400
+ q["drill"] = (_r4(dr * f), _r4((p.get("drill_h") or dr) * f))
401
+ for k in ("mask_margin", "paste_margin"):
402
+ if p.get(k) is not None:
403
+ q[k] = _r4(p[k] * f)
404
+ q["paste"] = p.get("paste", "none" if kind != "smd" else "full")
405
+ for k in ("paste_coverage", "paste_grid", "thermal_vias"):
406
+ if p.get(k) is not None:
407
+ q[k] = p[k]
408
+ if q.get("thermal_vias"): # vias in mm by default (as before); unit mil / in / table
409
+ tv = dict(q["thermal_vias"])
410
+ u = tv.pop("unit", "mm")
411
+ g = f if u == "table" else UNIT[u]
412
+ q["thermal_vias"] = {k: (_r4(v * g) if k in ("drill", "diameter", "pitch", "pitch_y") else v) for k, v in tv.items()}
413
+ pads.append(q)
414
+ body = None
415
+ if t.get("body"):
416
+ b = t["body"]
417
+ xa, xb, ya, yb = X(b["x1"]), X(b["x2"]), Y(b["y1"]), Y(b["y2"])
418
+ body = (min(xa, xb), min(ya, yb), max(xa, xb), max(ya, yb))
419
+ elif spec.get("body_length") and spec.get("body_width"):
420
+ bx, by = _dim(spec["body_length"])[1] / 2, _dim(spec["body_width"])[1] / 2
421
+ body = (-bx, -by, bx, by)
422
+ keepouts = []
423
+ for k in t.get("keepouts") or []:
424
+ if k.get("polygon"):
425
+ pts = [(X(v["x"]), Y(v["y"])) for v in k["polygon"]]
426
+ else:
427
+ xa, xb, ya, yb = X(k["x1"]), X(k["x2"]), Y(k["y1"]), Y(k["y2"])
428
+ pts = [(min(xa, xb), min(ya, yb)), (max(xa, xb), min(ya, yb)), (max(xa, xb), max(ya, yb)), (min(xa, xb), max(ya, yb))]
429
+ keepouts.append({"pts": pts, "layers": k.get("layers") or ["F.Cu"], "rules": k.get("rules") or ["tracks", "vias", "pads", "copperpour"]})
430
+ return pads, body, keepouts, {"unit": t.get("unit", "mm"), "factor": f, "center": (cx, cy)}
431
+
432
+
433
+ def tht_pad_size(pads, pitch=None):
434
+ """KiCad's connector rule for through-hole pads given only a drill: drill + 2 x 0.5 mm, but no wider than
435
+ pitch - 0.8 mm across the row (then oval), never less than a 0.15 mm ring."""
436
+ pins = [p for p in pads if p["kind"] == "thru_hole"]
437
+ for p in pins:
438
+ if not p.pop("auto_size", False):
439
+ continue
440
+ d = max(p["drill"])
441
+ full = _r4(d + 2 * TH_RING)
442
+ near = sorted(((abs(q["x"] - p["x"]), abs(q["y"] - p["y"])) for q in pins if q is not p),
443
+ key=lambda v: math.hypot(*v))
444
+ w = h = full
445
+ if near:
446
+ dx, dy = near[0]
447
+ gap = pitch or math.hypot(dx, dy)
448
+ lim = _r4(max(gap - TH_PAD_GAP, d + 2 * TH_RING_MIN))
449
+ if dx >= dy and lim < full:
450
+ w = lim
451
+ elif dy > dx and lim < full:
452
+ h = lim
453
+ if round((p.get("rot") or 0) / 90) % 2: # w / h are in the pad's own frame: KiCad turns the pad by rot
454
+ w, h = h, w
455
+ p["w"], p["h"] = w, h
456
+ p["shape"] = "circle" if abs(w - h) < 1e-9 else "oval"
457
+ for p in pins: # KiCad / KLC: pin 1 of a through-hole connector is (rounded) square
458
+ if p["num"] == "1" and p["role"] == "pin" and p.pop("_auto_shape", False):
459
+ p["shape"] = "roundrect"
460
+
461
+
462
+ # ---------------------------------------------------------------- consistency checks on a table
463
+ def check_pad_table(spec, pads=None):
464
+ """Consistency checks on a pad table (mm, KiCad frame). Returns [{level, rule, message}]."""
465
+ out = []
466
+ add = lambda lvl, rule, msg: out.append({"level": lvl, "rule": rule, "message": msg})
467
+ t = spec.get("pad_table") or {}
468
+ if pads is None:
469
+ pads = table_layout(spec)[0]
470
+ tht_pad_size(pads, spec.get("pitch"))
471
+ unit = t.get("unit", "mm")
472
+ raw = _expand_table(t) if t else []
473
+ vals = [abs(v) for p in raw for k, v in p.items() if k in ("x", "y", "w", "h", "d", "drill") and isinstance(v, (int, float))]
474
+ if raw and unit == "mm" and vals and max(vals) > 60:
475
+ add("error", "units", f"pad_table values up to {max(vals):g} with unit mm: that looks like mils (set unit to mil)")
476
+ sizes = [abs(v) for p in raw for k, v in p.items() if k in ("w", "h", "d", "drill") and isinstance(v, (int, float))]
477
+ if raw and unit == "mil" and sizes and max(sizes) < 4:
478
+ add("warning", "units", f"pad_table unit is mil but no pad or hole is bigger than {max(sizes):g} mil "
479
+ f"({max(sizes) * 0.0254:.3f} mm): is the table in mm?")
480
+ pins = [p for p in pads if p["role"] in PIN_ROLES]
481
+ nums = [p["num"] for p in pins]
482
+ distinct = sorted(set(nums), key=lambda s: (len(s), s))
483
+ if spec.get("pin_count") and len(distinct) != spec["pin_count"]:
484
+ add("error", "count", f"pin_count is {spec['pin_count']} but the table has {len(distinct)} numbered pins "
485
+ f"(mounting pads, shield tabs and holes not counted)")
486
+ ints = sorted(int(n) for n in distinct if n.isdigit())
487
+ if ints and len(ints) == len(distinct) and ints[0] >= 1:
488
+ missing = sorted(set(range(1, ints[-1] + 1)) - set(ints))
489
+ if missing:
490
+ add("warning", "numbers", f"pin numbers skip {', '.join(map(str, missing[:10]))}{' ...' if len(missing) > 10 else ''}")
491
+ same = [set(map(str, g)) for g in spec.get("same_net") or []]
492
+ for n in sorted({n for n in nums if nums.count(n) > 1}):
493
+ locs = [p for p in pins if p["num"] == n]
494
+ if all(abs(q["x"] - locs[0]["x"]) < 1e-6 and abs(q["y"] - locs[0]["y"]) < 1e-6 for q in locs[1:]):
495
+ add("error", "numbers", f"pad {n} is listed twice at the same place")
496
+ if not ({"1", "A1"} & set(nums)):
497
+ add("error", "pin1", "no pad numbered 1 (or A1)")
498
+ # overlaps between different pads
499
+ cu = [p for p in pads if p["kind"] != "np_thru_hole"]
500
+ for i, a in enumerate(cu):
501
+ for b in cu[i + 1:]:
502
+ if a["num"] and a["num"] == b["num"]:
503
+ continue
504
+ if any({a["num"], b["num"]} <= g for g in same):
505
+ continue
506
+ ax1, ay1, ax2, ay2 = pad_bbox(a)
507
+ bx1, by1, bx2, by2 = pad_bbox(b)
508
+ if min(ax2, bx2) - max(ax1, bx1) > 0.005 and min(ay2, by2) - max(ay1, by1) > 0.005:
509
+ if min(_pad_dist(b["x"], b["y"], a), _pad_dist(a["x"], a["y"], b)) == 0 or not (a.get("poly") or b.get("poly")):
510
+ add("error", "overlap", f"pads {a['num'] or '(hole)'} ({a['_src']}) and {b['num'] or '(hole)'} ({b['_src']}) overlap: "
511
+ "check their x/y (list same-net pads in same_net)")
512
+ # drill vs pad
513
+ for p in pads:
514
+ if p["kind"] == "thru_hole":
515
+ ring = min(p["w"] - p["drill"][0], p["h"] - p["drill"][1]) / 2
516
+ if ring <= 0:
517
+ add("error", "drill", f"pad {p['num']} ({p['_src']}): drill {p['drill'][0]:g} is not smaller than the pad")
518
+ elif ring < TH_RING_MIN - 1e-6:
519
+ add("warning", "drill", f"pad {p['num']} ({p['_src']}): annular ring {ring:.3f} mm < {TH_RING_MIN} mm")
520
+ # pitch regularity along rows and columns of equal pads
521
+ row_pitches = []
522
+ for axis in (0, 1):
523
+ key = "y" if axis == 0 else "x"
524
+ along = "x" if axis == 0 else "y"
525
+ groups = {}
526
+ for p in pins:
527
+ groups.setdefault((round(p[key], 2), round(p["w"], 3), round(p["h"], 3)), []).append(p)
528
+ for g in groups.values():
529
+ g = sorted({round(p[along], 4): p for p in g}.values(), key=lambda p: p[along])
530
+ if len(g) < 3:
531
+ continue
532
+ steps = [round(b[along] - a[along], 4) for a, b in zip(g, g[1:])]
533
+ counts = {}
534
+ for st in steps:
535
+ counts[round(st, 2)] = counts.get(round(st, 2), 0) + 1
536
+ sp = spec.get("pitch")
537
+ if sp and any(abs(st - sp) <= 0.011 for st in steps):
538
+ base = sp # the spec's pitch, when this row uses it
539
+ else:
540
+ base, cnt = max(counts.items(), key=lambda kv: (kv[1], -kv[0]))
541
+ if cnt < 2:
542
+ continue # no clear pitch in this row
543
+ off = lambda st: abs(st - round(st / base) * base)
544
+ bad = [(a, b, st) for (a, b), st in zip(zip(g, g[1:]), steps)
545
+ if (st < 1.5 * base and abs(st - base) > 0.011) or (st >= 1.5 * base and 0.011 < off(st) < 0.1 * base)]
546
+ for a, b, st in bad[:3]:
547
+ add("warning", "pitch", f"pads {a['num']} and {b['num']} are {st:g} mm apart but the row pitch is {base:g} mm "
548
+ f"({b['_src']}): misread coordinate?")
549
+ row_pitches.append((base, g[0]["num"], g[-1]["num"]))
550
+ if spec.get("pitch") and row_pitches and not any(abs(b - spec["pitch"]) <= 0.011 for b, _, _ in row_pitches):
551
+ b, a, z = max(row_pitches, key=lambda r: r[0])
552
+ add("warning", "pitch", f"no row of pads has the spec's {spec['pitch']:g} mm pitch (pads {a}..{z}: {b:g} mm)")
553
+ # symmetry about the package centre
554
+ cu_all = [p for p in pads if p["role"] != "npth"]
555
+ if len(cu_all) >= 4 and t:
556
+ best = None # mirrored about the package centre (0, 0 before any origin shift)
557
+ for name, fx, fy in (("left/right", -1, 1), ("top/bottom", 1, -1), ("180 deg", -1, -1)):
558
+ if all(abs(fx * p["x"] - p["x"]) < 0.02 and abs(fy * p["y"] - p["y"]) < 0.02 for p in cu_all):
559
+ continue # every pad on the mirror line: says nothing
560
+ odd = []
561
+ for p in cu_all:
562
+ if not any(abs(q["x"] - fx * p["x"]) < 0.02 and abs(q["y"] - fy * p["y"]) < 0.02 and
563
+ abs(pad_bbox(q)[2] - pad_bbox(q)[0] - (pad_bbox(p)[2] - pad_bbox(p)[0])) < 0.02 and
564
+ abs(pad_bbox(q)[3] - pad_bbox(q)[1] - (pad_bbox(p)[3] - pad_bbox(p)[1])) < 0.02 for q in cu_all):
565
+ odd.append(p)
566
+ if best is None or len(odd) < len(best[1]):
567
+ best = (name, odd)
568
+ name, odd = best
569
+ if odd and len(odd) <= max(2, len(cu_all) // 10):
570
+ add("warning", "symmetry", f"the pads are {name} symmetric except " +
571
+ ", ".join(f"pad {p['num'] or '(hole)'} at ({p['x']:g}, {p['y']:g}) ({p['_src']})" for p in odd[:4]) +
572
+ ": probably a misread number")
573
+ elif odd:
574
+ add("info", "symmetry", "the pad pattern is not symmetric about the package centre (normal for many connectors; "
575
+ "if this part is symmetric, check center_at / origin)")
576
+ # pin 1 location
577
+ p1 = next((p for p in pins if p["num"] in ("1", "A1")), None)
578
+ if p1 is not None:
579
+ row = [p for p in pins if abs(p["y"] - p1["y"]) < 0.01]
580
+ col = [p for p in pins if abs(p["x"] - p1["x"]) < 0.01]
581
+ ends = lambda g, k: abs(p1[k] - min(q[k] for q in g)) < 0.01 or abs(p1[k] - max(q[k] for q in g)) < 0.01
582
+ if (len(row) >= 3 and not ends(row, "x")) and (len(col) >= 3 and not ends(col, "y")):
583
+ add("warning", "pin1", f"pad {p1['num']} is in the middle of its row: pin 1 is normally at an end (check numbering)")
584
+ if t.get("origin") == "pin1":
585
+ src = next(r for r in raw if _num(r.get("number")) == p1["num"])
586
+ if abs(src["x"]) > 1e-6 or abs(src["y"]) > 1e-6:
587
+ add("error", "pin1", f"the drawing's origin is pin 1 but pad {p1['num']} is at ({src['x']:g}, {src['y']:g}) "
588
+ f"{unit}: origin or pad coordinates misread")
589
+ return out
590
+
591
+
592
+ # ---------------------------------------------------------------- S-expression output
593
+ def _n(x):
594
+ return _F().n(x)
595
+
596
+
597
+ def _q(s):
598
+ return _F().q(s)
599
+
600
+
601
+ def pad_sexpr(p):
602
+ kind, shape = p["kind"], p["shape"]
603
+ at = f"(at {_n(p['x'])} {_n(p['y'])}{' ' + _n(p['rot']) if p.get('rot') else ''})"
604
+ if kind == "smd":
605
+ lay = ["F.Cu"] + (["F.Paste"] if p.get("paste") == "full" else []) + ["F.Mask"]
606
+ elif p.get("via"):
607
+ lay = ["*.Cu"]
608
+ else:
609
+ lay = ["*.Cu", "*.Mask"]
610
+ if p.get("layers"):
611
+ lay = p["layers"]
612
+ s = f' (pad {_q(p["num"])} {kind} {shape} {at}'
613
+ if shape == "custom":
614
+ ax, ay, size = p["anchor"]
615
+ s = f' (pad {_q(p["num"])} {kind} custom (at {_n(ax)} {_n(ay)}) (size {_n(size)} {_n(size)})'
616
+ else:
617
+ s += f' (size {_n(p["w"])} {_n(p["h"])})'
618
+ if p.get("drill"):
619
+ dw, dh = p["drill"]
620
+ s += f" (drill {_n(dw)})" if abs(dw - dh) < 1e-9 else f" (drill oval {_n(dw)} {_n(dh)})"
621
+ s += " (layers " + " ".join(_q(l) for l in lay) + ")"
622
+ if shape == "roundrect":
623
+ s += f" (roundrect_rratio {_n(min(0.25, 0.25 / min(p['w'], p['h'])))})"
624
+ if p.get("mask_margin") is not None:
625
+ s += f" (solder_mask_margin {_n(p['mask_margin'])})"
626
+ if p.get("paste_margin") is not None:
627
+ s += f" (solder_paste_margin {_n(p['paste_margin'])})"
628
+ if shape == "custom":
629
+ ax, ay, _ = p["anchor"]
630
+ pts = " ".join(f"(xy {_n(x - ax)} {_n(y - ay)})" for x, y in p["poly"])
631
+ s += f"\n (options (clearance outline) (anchor circle))\n (primitives (gr_poly (pts {pts}) (width 0) (fill yes)))"
632
+ return s + ")"
633
+
634
+
635
+ def _paste_windows(p, cov=0.65, grid=None):
636
+ """Split a big pad's paste into windows of about 1.2 mm (KLC F6.3 style), coverage ~cov of the copper."""
637
+ x1, y1, x2, y2 = pad_bbox(p)
638
+ w, h = x2 - x1, y2 - y1
639
+ gx = (grid[0] if isinstance(grid, list) else grid) if grid else max(1, min(6, math.ceil(w / 1.2 - 1e-9)))
640
+ gy = (grid[1] if isinstance(grid, list) else grid) if grid else max(1, min(6, math.ceil(h / 1.2 - 1e-9)))
641
+ area = _poly_area(p["poly"]) if p.get("poly") else w * h
642
+ sw, sh = w * math.sqrt(cov) / gx, h * math.sqrt(cov) / gy
643
+ wins = []
644
+ for i in range(gx):
645
+ for j in range(gy):
646
+ cx, cy = x1 + (i + 0.5) * w / gx, y1 + (j + 0.5) * h / gy
647
+ if p.get("poly"): # keep windows wholly inside the polygon
648
+ corners = [(cx + a * sw / 2, cy + b * sh / 2) for a in (-1, 1) for b in (-1, 1)]
649
+ if not all(_pt_in_poly(x, y, p["poly"]) and _pad_dist(x, y, p) == 0 for x, y in corners):
650
+ continue
651
+ wins.append((_r4(cx), _r4(cy), round(sw, 3), round(sh, 3)))
652
+ if p.get("poly") and wins: # scale up to reach the coverage over the polygon
653
+ k = min(math.sqrt(cov * area / sum(a[2] * a[3] for a in wins)), w / gx / sw * 0.95, h / gy / sh * 0.95)
654
+ trial = [(x, y, round(a * k, 3), round(b * k, 3)) for x, y, a, b in wins]
655
+ if all(all(_pt_in_poly(x + s * a / 2, y + t * b / 2, p["poly"]) for s in (-1, 1) for t in (-1, 1)) for x, y, a, b in trial):
656
+ wins = trial
657
+ return wins, (gx, gy)
658
+
659
+
660
+ def _thermal_vias(p, tv, notes=None):
661
+ """A grid of vias (same number as the pad) centred on the pad (polygon pads: on their anchor); vias that would
662
+ stick out of the copper are left out and listed in notes."""
663
+ skipped = []
664
+ x1, y1, x2, y2 = pad_bbox(p)
665
+ nx, ny = tv.get("nx", 2), tv.get("ny", 2)
666
+ pitch = tv.get("pitch")
667
+ dx = pitch or (x2 - x1) / nx
668
+ dy = tv.get("pitch_y") or pitch or (y2 - y1) / ny
669
+ out = []
670
+ for i in range(nx):
671
+ for j in range(ny):
672
+ x, y = p["x"] + (i - (nx - 1) / 2) * dx, p["y"] + (j - (ny - 1) / 2) * dy
673
+ d = tv.get("diameter", tv["drill"] + 0.3)
674
+ ring = [(x + d / 2 * math.cos(k * math.pi / 4), y + d / 2 * math.sin(k * math.pi / 4)) for k in range(8)]
675
+ if not all(_pad_dist(a, b, p) <= 1e-6 for a, b in ring + [(x, y)]): # (a via may touch the edge)
676
+ skipped.append((round(x, 3), round(y, 3)))
677
+ continue
678
+ out.append({"num": p["num"], "kind": "thru_hole", "shape": "circle", "x": _r4(x), "y": _r4(y), "w": d, "h": d,
679
+ "drill": (tv["drill"], tv["drill"]), "via": True, "role": "thermal_via", "_src": p["_src"]})
680
+ if skipped and notes is not None:
681
+ notes.append(f"pad {p['num']}: {len(skipped)} of {nx * ny} thermal vias left out (outside the copper at {skipped[:4]})")
682
+ return out
683
+
684
+
685
+ def _clip_any(a, b, pads, c, min_len=0.2):
686
+ """Pieces of the segment a-b that stay at least c from every pad (sampled every 0.01 mm)."""
687
+ L = math.hypot(b[0] - a[0], b[1] - a[1])
688
+ n = max(2, int(L / 0.01))
689
+ lo_x, hi_x, lo_y, hi_y = min(a[0], b[0]) - c, max(a[0], b[0]) + c, min(a[1], b[1]) - c, max(a[1], b[1]) + c
690
+ pads = [p for p in pads if not (pad_bbox(p)[0] > hi_x or pad_bbox(p)[2] < lo_x or pad_bbox(p)[1] > hi_y or pad_bbox(p)[3] < lo_y)]
691
+ ok = []
692
+ for k in range(n + 1):
693
+ t = k / n
694
+ x, y = a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t
695
+ ok.append(all(_pad_dist(x, y, p) >= c for p in pads))
696
+ pieces, start = [], None
697
+ for k, v in enumerate(ok + [False]):
698
+ if v and start is None:
699
+ start = k
700
+ if not v and start is not None:
701
+ t0, t1 = start / n, (k - 1) / n
702
+ if (t1 - t0) * L >= min_len:
703
+ pieces.append(((a[0] + (b[0] - a[0]) * t0, a[1] + (b[1] - a[1]) * t0),
704
+ (a[0] + (b[0] - a[0]) * t1, a[1] + (b[1] - a[1]) * t1)))
705
+ start = None
706
+ return [((_r4(p[0]), _r4(p[1])), (_r4(q[0]), _r4(q[1]))) for p, q in pieces]
707
+
708
+
709
+ def _pin1_mark(p1, pads, box, c):
710
+ """A filled silk triangle next to pin 1, outside the silk outline when possible, pointing at the pad."""
711
+ from .footprint_check import _point_in_poly
712
+ x1, y1, x2, y2 = pad_bbox(p1)
713
+ s, depth = 0.24, 0.33
714
+ cands = []
715
+ for d in ((-1, 0), (0, -1), (0, 1), (1, 0)):
716
+ for push in (True, False):
717
+ if d[0]:
718
+ edge = x1 if d[0] < 0 else x2
719
+ ax = edge + d[0] * (c + 0.02)
720
+ if push and box:
721
+ ax = min(ax, box[0] - 0.15) if d[0] < 0 else max(ax, box[2] + 0.15)
722
+ tri = [(ax, p1["y"]), (ax + d[0] * depth, p1["y"] - s), (ax + d[0] * depth, p1["y"] + s)]
723
+ else:
724
+ edge = y1 if d[1] < 0 else y2
725
+ ay = edge + d[1] * (c + 0.02)
726
+ if push and box:
727
+ ay = min(ay, box[1] - 0.15) if d[1] < 0 else max(ay, box[3] + 0.15)
728
+ tri = [(p1["x"], ay), (p1["x"] - s, ay + d[1] * depth), (p1["x"] + s, ay + d[1] * depth)]
729
+ cands.append((0 if push else 1, math.hypot(tri[0][0] - p1["x"], tri[0][1] - p1["y"]), len(cands), tri))
730
+ for *_, tri in sorted(cands):
731
+ segs = list(zip(tri, tri[1:] + tri[:1]))
732
+ clear = all(min(_pad_dist(x, y, p) for p in pads) >= c
733
+ for a, b in segs for x, y in ((a[0] + (b[0] - a[0]) * k / 20, a[1] + (b[1] - a[1]) * k / 20) for k in range(21)))
734
+ if clear and not any(_point_in_poly(p["x"], p["y"], tri) for p in pads):
735
+ return [(_r4(x), _r4(y)) for x, y in tri]
736
+ return None
737
+
738
+
739
+ def _emit(spec, pads, body, rep, excess, extra_rects=(), keepouts=(), corner_mark=None, courtyard_union=True):
740
+ """Common writer: fab (body + pin-1 chamfer), silk (body outline cut back from the pads + pin-1 mark),
741
+ courtyard (union outline or rectangle), pads, keep-out zones. Returns kicad_mod text; fills rep."""
742
+ F = _F()
743
+ copper = pads
744
+ p1 = next((p for p in pads if p["num"] in ("1", "A1") and p["role"] in PIN_ROLES), None) or \
745
+ next((p for p in pads if p["role"] in PIN_ROLES), pads[0])
746
+ # ---- courtyard
747
+ rects = [pad_bbox(p) for p in pads] + ([body] if body else []) + list(extra_rects)
748
+ grown = [(_rdn(r[0] - excess), _rdn(r[1] - excess), _rup(r[2] + excess), _rup(r[3] + excess)) for r in rects]
749
+ bb = (min(r[0] for r in grown), min(r[1] for r in grown), max(r[2] for r in grown), max(r[3] for r in grown))
750
+ outline = union_outline(grown) if courtyard_union and len(grown) > 1 else None
751
+ if outline is None or len(outline) <= 4:
752
+ outline = None
753
+ rep["courtyard"] = (round(bb[2] - bb[0], 2), round(bb[3] - bb[1], 2))
754
+ rep["courtyard_bbox"] = tuple(round(v, 2) for v in bb)
755
+ rep["courtyard_shape"] = f"union outline, {len(outline)} corners" if outline else "rectangle"
756
+ attr = "through_hole" if any(p["kind"] == "thru_hole" and p["role"] in PIN_ROLES and not p.get("via") for p in pads) \
757
+ else "smd" # KiCad: THT shield / mounting pads alone don't make it THT
758
+ density = rep["density"]
759
+ tags = " ".join(filter(None, [spec["package_type"], rep.get("ipc_family", ""), f"IPC-7351B density-{density}"]))
760
+ top, bot = bb[1], bb[3]
761
+ out = [f'(footprint {_q(spec["name"])} (version 20221018) (generator "ecadlib_footprint_gen")',
762
+ ' (layer "F.Cu")',
763
+ f' (descr {_q(rep["descr"])})',
764
+ f' (tags {_q(tags)})',
765
+ f' (attr {attr})',
766
+ F.text("reference", "REF**", _r4((bb[0] + bb[2]) / 2), _r4(top - 0.7), "F.SilkS"),
767
+ F.text("value", spec["name"], _r4((bb[0] + bb[2]) / 2), _r4(bot + 0.7), "F.Fab")]
768
+ if body:
769
+ bx, by = (body[2] - body[0]) / 2, (body[3] - body[1]) / 2
770
+ out.append(F.text("user", "${REFERENCE}", _r4((body[0] + body[2]) / 2), _r4((body[1] + body[3]) / 2), "F.Fab",
771
+ size=math.floor(100 * min(1.0, max(0.25, 0.5 * max(bx, by)))) / 100))
772
+ # fab: body with the corner nearest pin 1 chamfered
773
+ ch = min(1.0, 0.25 * min(body[2] - body[0], body[3] - body[1]))
774
+ corners = [(body[0], body[1]), (body[2], body[1]), (body[2], body[3]), (body[0], body[3])]
775
+ k = min(range(4), key=lambda i: math.hypot(corners[i][0] - p1["x"], corners[i][1] - p1["y"]))
776
+ pts = []
777
+ for i in range(4):
778
+ if i != k:
779
+ pts.append(corners[i]); continue
780
+ prev, nxt = corners[i - 1], corners[(i + 1) % 4]
781
+ u = lambda a, b: ((b[0] - a[0]) / (math.hypot(b[0] - a[0], b[1] - a[1]) or 1), (b[1] - a[1]) / (math.hypot(b[0] - a[0], b[1] - a[1]) or 1))
782
+ d1, d2 = u(corners[i], prev), u(corners[i], nxt)
783
+ pts += [(corners[i][0] + d1[0] * ch, corners[i][1] + d1[1] * ch), (corners[i][0] + d2[0] * ch, corners[i][1] + d2[1] * ch)]
784
+ out.append(F.poly([(_r4(x), _r4(y)) for x, y in pts], "F.Fab", F.FAB_W))
785
+ # ---- silk: body outline pushed out 0.11 mm, cut back from every pad (and hole) by 0.2 mm + half the line
786
+ c = F.SILK_PAD_CLEAR + F.SILK_W / 2
787
+ silk_box = None
788
+ if body:
789
+ o = F.SILK_FAB_OFFSET
790
+ silk_box = (body[0] - o, body[1] - o, body[2] + o, body[3] + o)
791
+ sb = silk_box
792
+ box = [((sb[0], sb[1]), (sb[2], sb[1])), ((sb[2], sb[1]), (sb[2], sb[3])), ((sb[2], sb[3]), (sb[0], sb[3])), ((sb[0], sb[3]), (sb[0], sb[1]))]
793
+ holes = [dict(p, shape="circle", w=p["drill"][0], h=p["drill"][1]) for p in pads if p["kind"] == "np_thru_hole"]
794
+ for a, b in box:
795
+ for s, e in _clip_any(a, b, copper + holes, c):
796
+ out.append(F.line(s[0], s[1], e[0], e[1], "F.SilkS", F.SILK_W))
797
+ if corner_mark is not None: # BGA: KiCad-style triangle outside the A1 corner of the silk
798
+ out.append(F.poly(corner_mark, "F.SilkS", F.SILK_W, fill="solid"))
799
+ else:
800
+ tri = _pin1_mark(p1, copper, silk_box, c)
801
+ if tri:
802
+ out.append(F.poly(tri, "F.SilkS", F.SILK_W, fill="solid"))
803
+ else:
804
+ rep.setdefault("notes", []).append("no room for a pin-1 mark on the silkscreen")
805
+ # ---- courtyard
806
+ if outline:
807
+ out.append(F.poly(outline, "F.CrtYd", F.CRT_W))
808
+ else:
809
+ out.append(F.rect(bb[0], bb[1], bb[2], bb[3], "F.CrtYd", F.CRT_W))
810
+ # ---- pads, paste windows
811
+ for p in pads:
812
+ out.append(pad_sexpr(p))
813
+ for (x, y, w, h) in p.get("windows", []):
814
+ out.append(F.pad("", x, y, w, h, layers=("F.Paste",)))
815
+ for k in keepouts:
816
+ rules = set(k["rules"])
817
+ ko = " ".join(f"({r} {'not_allowed' if r in rules else 'allowed'})" for r in ("tracks", "vias", "pads", "copperpour", "footprints"))
818
+ pts = " ".join(f"(xy {_n(x)} {_n(y)})" for x, y in k["pts"])
819
+ lay = " ".join(_q(l) for l in k["layers"])
820
+ out.append(f' (zone (net 0) (net_name "") (layers {lay}) (hatch edge 0.5) (connect_pads (clearance 0)) (min_thickness 0.25)'
821
+ f' (keepout {ko}) (fill (thermal_gap 0.5) (thermal_bridge_width 0.5)) (polygon (pts {pts})))')
822
+ if spec.get("model_3d"):
823
+ out.append(f' (model {_q(spec["model_3d"])} (offset (xyz 0 0 0)) (scale (xyz 1 1 1)) (rotate (xyz 0 0 0)))')
824
+ out.append(")")
825
+ return "\n".join(out) + "\n"
826
+
827
+
828
+ def _finish_pads(pads, notes=None):
829
+ """Custom-pad anchors, paste windows and thermal vias."""
830
+ out = []
831
+ for p in pads:
832
+ if p.get("poly"):
833
+ d, ax, ay = _interior_point(p["poly"])
834
+ p["anchor"] = (_r4(ax), _r4(ay), round(max(0.05, min(1.6 * d, 1.0)), 3))
835
+ p["x"], p["y"] = _r4(ax), _r4(ay)
836
+ if p["kind"] == "smd" and p.get("paste") == "grid":
837
+ wins, g = _paste_windows(p, p.get("paste_coverage", 0.65), p.get("paste_grid"))
838
+ p["windows"] = wins
839
+ out.append(p)
840
+ if p.get("thermal_vias"):
841
+ out += _thermal_vias(p, p["thermal_vias"], notes)
842
+ return out
843
+
844
+
845
+ # ---------------------------------------------------------------- builders
846
+ def _bga(spec, density):
847
+ F = _F()
848
+ rows, cols, e = spec["rows"], spec["cols"], spec["pitch"]
849
+ ey = spec.get("pitch_y") or e
850
+ balls = bga_balls(spec)
851
+ if spec.get("pin_count") and spec["pin_count"] != len(balls):
852
+ raise ValueError(f"pin_count is {spec['pin_count']} but the {rows} x {cols} grid minus depopulated balls leaves "
853
+ f"{len(balls)} balls (check depopulated / ball_map)")
854
+ bt = spec.get("ball_type", "collapsing")
855
+ if spec.get("land_diameter"):
856
+ land, rule = _dim(spec["land_diameter"])[1], "datasheet land"
857
+ else:
858
+ if not spec.get("ball_diameter"):
859
+ raise ValueError("bga needs ball_diameter (or the datasheet's land_diameter)")
860
+ land, rule = ipc_bga_land(_dim(spec["ball_diameter"])[1], bt)
861
+ style = spec.get("land_style", "NSMD")
862
+ cu, mask = (land, NSMD_MASK) if style == "NSMD" else (round(land + 2 * SMD_GROW, 3), -SMD_GROW)
863
+ if cu >= min(e, ey) - 0.1: # the copper, which SMD-defined lands make bigger than the land
864
+ raise ValueError(f"land {land:g} mm{f' (copper {cu:g} mm, SMD-defined)' if cu != land else ''} leaves less than "
865
+ f"0.1 mm between pads at {min(e, ey):g} mm pitch: check ball_diameter / pitch (mils?)")
866
+ pads = []
867
+ for name, i, j in balls:
868
+ p = {"num": name, "kind": "smd", "shape": "circle", "x": _r4((j - (cols - 1) / 2) * e), "y": _r4((i - (rows - 1) / 2) * ey),
869
+ "w": cu, "h": cu, "role": "pin", "paste": "full", "mask_margin": mask, "_src": name}
870
+ if style == "SMD":
871
+ p["paste_margin"] = -SMD_GROW
872
+ pads.append(p)
873
+ bx, by = _dim(spec["body_length"])[1] / 2, _dim(spec["body_width"])[1] / 2
874
+ gx, gy = (cols - 1) * e / 2, (rows - 1) * ey / 2
875
+ if gx + land / 2 > bx or gy + land / 2 > by:
876
+ raise ValueError(f"the {rows} x {cols} grid at {e:g} mm pitch ({2 * gx:.2f} x {2 * gy:.2f} mm) doesn't fit the "
877
+ f"{2 * bx:g} x {2 * by:g} mm body: rows/cols swapped or pitch misread?")
878
+ body = (-bx, -by, bx, by)
879
+ o = F.SILK_FAB_OFFSET
880
+ sx, sy = bx + o, by + o
881
+ mark = [(-sx, -sy), (-sx - 0.5, -sy), (-sx, -sy - 0.5)]
882
+ nominal = f"{rows}x{cols} grid, {len(balls)} balls, pitch {e:g}{'/' + format(ey, 'g') if ey != e else ''} mm"
883
+ rep = {"name": spec["name"], "type": "bga", "density": density, "ipc_family": "bga", "land": land, "land_rule": rule,
884
+ "land_style": style, "copper": cu, "balls": len(balls), "grid": (rows, cols),
885
+ "Z": round(2 * gx + cu, 4), "G": round(min(e, ey) - cu, 4), "X": cu}
886
+ rep["descr"] = (f"{spec.get('description', spec['name'])} | {nominal}, {style} land {land:g} mm ({rule})"
887
+ + (f" | src {spec['source']['datasheet']}" if spec.get("source", {}).get("datasheet") else "")
888
+ + " | generated by ecadlib (prototype - review before use)")
889
+ txt = _emit(spec, pads, body, rep, spec.get("courtyard_excess", BGA_EXCESS[density]), corner_mark=mark, courtyard_union=False)
890
+ return txt, rep, pads
891
+
892
+
893
+ def _header(spec, density):
894
+ cols, rows, e = spec.get("cols", 1), spec["rows"], spec["pitch"]
895
+ if cols not in (1, 2):
896
+ raise ValueError("pin_header: cols is 1 or 2 (use connector with a pad_table for anything else)")
897
+ n = rows * cols
898
+ if spec.get("pin_count") and spec["pin_count"] != n:
899
+ raise ValueError(f"pin_count is {spec['pin_count']} but {cols} x {rows} = {n}")
900
+ key = min(HEADER_TH, key=lambda k: abs(k - e))
901
+ pads, extra = [], []
902
+ if spec.get("mount", "tht") == "tht":
903
+ drill = _dim(spec["drill"])[1] if spec.get("drill") else HEADER_TH[key][0] if abs(key - e) < 0.01 else None
904
+ dia = _dim(spec["land_diameter"])[1] if spec.get("land_diameter") else HEADER_TH[key][1] if abs(key - e) < 0.01 else None
905
+ if drill is None:
906
+ raise ValueError(f"no default drill for a {e:g} mm pitch header: give drill (and land_diameter)")
907
+ if dia is None:
908
+ dia = round(min(drill + 2 * TH_RING, e - 0.5), 2)
909
+ for k in range(n):
910
+ r, c = divmod(k, cols)
911
+ pads.append({"num": str(k + 1), "kind": "thru_hole", "shape": "rect" if k == 0 else "circle", "x": _r4(c * e),
912
+ "y": _r4(r * e), "w": dia, "h": dia, "drill": (drill, drill), "role": "pin", "_src": f"pin {k + 1}"})
913
+ cx, cy = (cols - 1) * e / 2, (rows - 1) * e / 2
914
+ family = "pin_header_tht"
915
+ else:
916
+ land = spec.get("land")
917
+ if land:
918
+ ll, lw, lc = land["pad_length"], land["pad_width"], land["pad_center"]
919
+ elif abs(key - e) < 0.01 and key in (HEADER_SMD1 if cols == 1 else HEADER_SMD):
920
+ ll, lw, lc = (HEADER_SMD1 if cols == 1 else HEADER_SMD)[key] # single-row headers have their own land
921
+ else:
922
+ raise ValueError(f"no default SMD land for a {e:g} mm header: give land (pad_length, pad_width, pad_center)")
923
+ for k in range(n):
924
+ if cols == 2:
925
+ r, c = divmod(k, 2)
926
+ side = -1 if c == 0 else 1
927
+ else: # single row SMD: alternate sides, pin 1 on pin1_side
928
+ r = k
929
+ side = (-1 if k % 2 == 0 else 1) * (1 if spec.get("pin1_side", "left") == "left" else -1)
930
+ pads.append({"num": str(k + 1), "kind": "smd", "shape": "rect", "x": _r4(side * lc),
931
+ "y": _r4((r - (rows - 1) / 2) * e), "w": ll, "h": lw, "role": "pin", "paste": "full", "_src": f"pin {k + 1}"})
932
+ cx, cy = 0.0, 0.0
933
+ family = "pin_header_smd"
934
+ if spec.get("lead_span"):
935
+ ls = _dim(spec["lead_span"])[1] / 2
936
+ extra.append((-ls, -(rows - 1) * e / 2 - lw / 2, ls, (rows - 1) * e / 2 + lw / 2))
937
+ bl = _dim(spec["body_length"])[1] if spec.get("body_length") else cols * e if spec.get("mount", "tht") == "tht" else cols * e
938
+ bw = _dim(spec["body_width"])[1] if spec.get("body_width") else rows * e
939
+ body = (_r4(cx - bl / 2), _r4(cy - bw / 2), _r4(cx + bl / 2), _r4(cy + bw / 2))
940
+ rep = {"name": spec["name"], "type": "pin_header", "density": density, "ipc_family": family}
941
+ rep["descr"] = (f"{spec.get('description', spec['name'])} | {cols}x{rows} pins, pitch {e:g} mm"
942
+ + (f", drill {pads[0]['drill'][0]:g} mm, pad {pads[0]['w']:g} mm" if pads[0].get("drill") else
943
+ f", pads {pads[0]['w']:g}x{pads[0]['h']:g} mm") + " | generated by ecadlib (prototype - review before use)")
944
+ txt = _emit(spec, pads, body, rep, spec.get("courtyard_excess", CONN_EXCESS[density]), extra_rects=extra)
945
+ return txt, rep, pads
946
+
947
+
948
+ def table_geometry(spec):
949
+ """pad_table -> (pads, body, keepouts, info, problems, notes) in the footprint's frame (origin applied, polygon
950
+ anchors, paste windows and vias added). Raises ValueError when check_pad_table finds an error."""
951
+ pads, body, keepouts, info = table_layout(spec)
952
+ tht_pad_size(pads, spec.get("pitch"))
953
+ problems = check_pad_table(spec, pads)
954
+ errs = [p for p in problems if p["level"] == "error"]
955
+ if errs:
956
+ raise ValueError("the pad table doesn't add up: " + "; ".join(p["message"] for p in errs))
957
+ t = spec["pad_table"]
958
+ has_th = any(p["kind"] == "thru_hole" and p["role"] in PIN_ROLES for p in pads)
959
+ if t.get("footprint_origin", "pin1" if has_th else "center") == "pin1":
960
+ p1 = next((p for p in pads if p["num"] in ("1", "A1") and p["role"] in PIN_ROLES), None)
961
+ if p1 is None:
962
+ raise ValueError("footprint_origin pin1 but there is no pad 1")
963
+ ox, oy = p1["x"], p1["y"]
964
+ for p in pads:
965
+ p["x"], p["y"] = _r4(p["x"] - ox), _r4(p["y"] - oy)
966
+ if p.get("poly"):
967
+ p["poly"] = [(_r4(x - ox), _r4(y - oy)) for x, y in p["poly"]]
968
+ if body:
969
+ body = (_r4(body[0] - ox), _r4(body[1] - oy), _r4(body[2] - ox), _r4(body[3] - oy))
970
+ for k in keepouts:
971
+ k["pts"] = [(_r4(x - ox), _r4(y - oy)) for x, y in k["pts"]]
972
+ notes = []
973
+ pads = _finish_pads(pads, notes)
974
+ return pads, body, keepouts, info, problems, notes
975
+
976
+
977
+ def _table(spec, density):
978
+ pads, body, keepouts, info, problems, notes = table_geometry(spec)
979
+ t_ = spec["package_type"]
980
+ exc = spec.get("courtyard_excess", (CONN_EXCESS if t_ == "connector" else CUSTOM_EXCESS)[density])
981
+ pins = len({p["num"] for p in pads if p["role"] in PIN_ROLES})
982
+ rep = {"name": spec["name"], "type": t_, "density": density, "ipc_family": t_, "land_source": "datasheet",
983
+ "table_checks": [p for p in problems if p["level"] != "error"],
984
+ "units": f"{info['unit']} (x{info['factor']:g} to mm)", "notes": notes}
985
+ rep["descr"] = (f"{spec.get('description', spec['name'])} | {pins} pins"
986
+ + (f", {sum(1 for p in pads if p['role'] in ('mount', 'shield'))} mounting/shield pads" if any(p['role'] in ('mount', 'shield') for p in pads) else "")
987
+ + (f" | src {spec['source']['datasheet']}" if spec.get("source", {}).get("datasheet") else "")
988
+ + " | land pattern from the datasheet drawing | generated by ecadlib (prototype - review before use)")
989
+ txt = _emit(spec, pads, body, rep, exc, keepouts=keepouts)
990
+ return txt, rep, pads
991
+
992
+
993
+ BUILDERS = {"bga": _bga, "pin_header": _header, "connector": _table, "custom": _table}
994
+
995
+
996
+ def build(spec, density=None):
997
+ """(kicad_mod text, report) for the advanced package types."""
998
+ density = density or spec.get("density", "B")
999
+ txt, rep, pads = BUILDERS[spec["package_type"]](spec, density)
1000
+ rep["pads"] = [{"num": p["num"], "x": p["x"], "y": p["y"], "w": p["w"], "h": p["h"], "kind": p["kind"], "shape": p["shape"],
1001
+ **({"drill": p["drill"]} if p.get("drill") else {}), "role": p["role"]}
1002
+ for p in pads if not p.get("via")]
1003
+ from . import footprint_check
1004
+ same = [list(map(str, g)) for g in spec.get("same_net") or []]
1005
+ rep["checks"] = [{"level": l, "rule": r_, "message": m} for l, r_, m in footprint_check.check(txt, same_net=same)]
1006
+ return txt, rep