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,582 @@
1
+ """Parametric IPC-7351B-style footprint generator -> KiCad .kicad_mod (S-expression).
2
+
3
+ Supported package_type: chip (2-terminal R/C/L), molded, gullwing (dual-row SOIC/SOP/TSSOP/SOT), qfp, qfn (+EP);
4
+ and, in footprint_adv.py: bga (BGA/LGA/CSP), pin_header, connector and custom (explicit pad table from the
5
+ datasheet drawing: polygon pads, slots, mounting pads, keep-outs).
6
+ Input: JSON matching package_schema.json (the contract an LLM fills from a datasheet drawing).
7
+
8
+ IPC-7351B land-pattern equations (all mm; implemented in ipc_land()):
9
+
10
+ Z = Lmin + 2*Jt + sqrt(CL^2 + F^2 + P^2) outer pad-to-pad extent (toe)
11
+ G = Smax - 2*Jh - sqrt(CS^2 + F^2 + P^2) inner gap between opposing pads (heel)
12
+ X = Wmin + 2*Js + sqrt(CW^2 + F^2 + P^2) pad width (side)
13
+
14
+ L = terminal-to-terminal span (lead span E for gull-wing, body length for chip/QFN)
15
+ S = inner span between terminals: Smin = Lmin - 2*Tmax, Smax = Lmax - 2*Tmin
16
+ (IPC uses an RMS tolerance for S: CS = sqrt(CL^2 + 2*CT^2) and pulls Smin/Smax
17
+ inward by (Stol - CS)/2, so stacked worst cases do not over-shrink the heel)
18
+ W = terminal width; C* = max-min tolerance of each dimension
19
+ Jt/Jh/Js = toe/heel/side solder fillet goals for the density level (FILLETS table)
20
+ F = PCB fabrication tolerance, P = part placement tolerance
21
+ Pad length = (Z - G)/2, pad centre = (Z + G)/4 from origin.
22
+ Courtyard = bounding box of (pads U body) + courtyard excess (density dependent).
23
+
24
+ Output targets KiCad file format version 20221018 (KiCad 7) which KiCad 8/9 load
25
+ natively and upgrade on save. (KiCad's own library is now at 20260206 / KiCad 10.)
26
+ """
27
+ import argparse, json, math, re, sys
28
+ from pathlib import Path
29
+
30
+ # IPC-7351B fillet goals per density A/B/C (most/nominal/least):
31
+ # (Jz, Jg, Js, courtyard excess, round-off)
32
+ # Jz is the goal at the outer end of the land (sets Z), Jg at the inner end (sets G), Js at the side (sets X).
33
+ # For most families Jz is the toe and Jg the heel; for inward-L molded parts it is the other way round.
34
+ # Values are the ones KiCad builds its library with (kicad-footprint-generator src/kilibs/config/ipc_configs/
35
+ # ipc_7351b.yaml and ipc7351B_2terminal.yaml); docs/footprint-rules.md lists the sources per family.
36
+ FILLETS = {
37
+ # Table 3-5: rectangular end-cap chips >= 0603 (1608)
38
+ "chip": {"A": (0.55, 0.00, 0.05, 0.50, 0.05), "B": (0.35, 0.00, 0.00, 0.25, 0.05), "C": (0.15, 0.00, -0.05, 0.10, 0.05)},
39
+ # Table 3-6: chips < 0603 (0402, 0201) - smaller toe, 0.15 courtyard, 0.02 round-off
40
+ "chip_small": {"A": (0.30, 0.00, 0.05, 0.20, 0.02), "B": (0.20, 0.00, 0.00, 0.15, 0.02), "C": (0.10, 0.00, -0.05, 0.10, 0.02)},
41
+ # Table 3-2: gull-wing, pitch >= 0.625 mm (SOIC, SOT-23, SOT-223, TSSOP-0.65)
42
+ "gullwing": {"A": (0.55, 0.45, 0.05, 0.50, 0.05), "B": (0.35, 0.35, 0.03, 0.25, 0.05), "C": (0.15, 0.25, 0.01, 0.10, 0.05)},
43
+ # Table 3-3: gull-wing, pitch < 0.625 mm (side fillet goes negative)
44
+ "gullwing_fine": {"A": (0.55, 0.45, 0.01, 0.50, 0.05), "B": (0.35, 0.35, -0.02, 0.25, 0.05), "C": (0.15, 0.25, -0.04, 0.10, 0.05)},
45
+ # Table 3-15: QFN / DFN / SON with a toe fillet (no heel, negative side)
46
+ "qfn": {"A": (0.40, 0.00, -0.04, 0.50, 0.05), "B": (0.30, 0.00, -0.04, 0.25, 0.05), "C": (0.20, 0.00, -0.04, 0.10, 0.05)},
47
+ # Table 3-13: inward-L / molded body (tantalum, SMA/SMB/SMC, SOD-123F): big fillet at the outer
48
+ # (heel) end, small at the inner (toe) end under the body
49
+ "molded": {"A": (0.80, 0.25, 0.01, 0.50, 0.05), "B": (0.50, 0.15, -0.05, 0.25, 0.05), "C": (0.20, 0.07, -0.10, 0.10, 0.05)},
50
+ # IPC-7351 (2005) Table 3-7: MELF / MiniMELF cylindrical end caps (no 7351B table in KiCad)
51
+ "melf": {"A": (0.60, 0.20, 0.10, 0.50, 0.05), "B": (0.40, 0.10, 0.05, 0.25, 0.05), "C": (0.20, 0.02, 0.01, 0.10, 0.05)},
52
+ # IPC-7352 flat lead (outward flat ribbon leads)
53
+ "flat": {"A": (0.30, 0.00, 0.05, 0.20, 0.05), "B": (0.20, 0.00, 0.00, 0.15, 0.05), "C": (0.10, 0.00, 0.00, 0.10, 0.05)},
54
+ }
55
+ FAB_TOL, PLACE_TOL = 0.10, 0.05 # F and P as KiCad's generator uses them
56
+ MIN_PAD_GAP = 0.2 # minimum copper gap between adjacent pads (solder-mask web)
57
+ MIN_EP_CLEAR = 0.2 # exposed pad to signal pad (KLC F6.3); also KiCad's minimum heel gap / 2
58
+ SILK_W, FAB_W, CRT_W, SILK_PAD_CLEAR = 0.12, 0.10, 0.05, 0.2
59
+ SILK_CLEAR_SMALL, SILK_MIN_LEN = 0.1, 0.2 # KiCad falls back to 0.1 clearance when 0.2 leaves no line >= 0.2 long
60
+ SILK_FAB_OFFSET = 0.11 # silk outline sits this far outside the fab body (KiCad generator default)
61
+
62
+
63
+ # ---------------------------------------------------------------- dimensions & IPC math
64
+ def dim(d):
65
+ """Normalise a schema dimension to (min, nom, max)."""
66
+ if d is None:
67
+ return None
68
+ if isinstance(d, (int, float)):
69
+ return (d, d, d)
70
+ if "min" in d and "max" in d:
71
+ return (d["min"], d.get("nom", (d["min"] + d["max"]) / 2), d["max"])
72
+ t = d.get("tol", 0.0) # a one-sided min or max still wins over nom +- tol
73
+ return (d.get("min", d["nom"] - t), d["nom"], d.get("max", d["nom"] + t))
74
+
75
+
76
+ def rup(x, g=0.01):
77
+ return math.ceil(round(x / g, 6)) * g
78
+
79
+
80
+ def rdn(x, g=0.01):
81
+ return math.floor(round(x / g, 6)) * g
82
+
83
+
84
+ def rnd(x, g):
85
+ """Round to the nearest multiple of g, the way KiCad's generator does (Python round)."""
86
+ return round(round(x / g) * g, 4)
87
+
88
+
89
+ def ipc_land(L, T, W, fillet, F=FAB_TOL, P=PLACE_TOL, S=None, min_gap=None):
90
+ """L,T,W(,S) = (min,nom,max) tuples. Returns dict(Z,G,X,pad_len,pad_ctr) - see module docstring.
91
+ S, when the drawing gives the inner span directly, is used as is (no RMS adjustment).
92
+ min_gap: the heel gap G is never allowed below this (EP clearance; KiCad's gull-wing 0.4 mm floor)."""
93
+ jz, jg, js, _, base = fillet
94
+ CL, CW = L[2] - L[0], W[2] - W[0]
95
+ if S is not None:
96
+ smin, smax = S[0], S[2]
97
+ cs = smax - smin
98
+ else:
99
+ CT = T[2] - T[0]
100
+ smin, smax = L[0] - 2 * T[2], L[2] - 2 * T[0]
101
+ cs = math.sqrt(CL ** 2 + 2 * CT ** 2) # RMS tolerance of S
102
+ adj = ((smax - smin) - cs) / 2
103
+ smin, smax = smin + adj, smax - adj
104
+ Z = rnd(L[0] + 2 * jz + math.sqrt(CL ** 2 + F ** 2 + P ** 2), base)
105
+ G = rnd(smax - 2 * jg - math.sqrt(cs ** 2 + F ** 2 + P ** 2), base)
106
+ X = rnd(W[0] + 2 * js + math.sqrt(CW ** 2 + F ** 2 + P ** 2), base)
107
+ G = max(G, 0.0, min_gap or 0.0)
108
+ return {"Z": Z, "G": G, "X": X, "pad_len": round((Z - G) / 2, 4), "pad_ctr": round((Z + G) / 4, 4),
109
+ "Smin": smin, "Smax": smax}
110
+
111
+
112
+ # ---------------------------------------------------------------- S-expression writer
113
+ def n(x):
114
+ s = f"{x:.4f}".rstrip("0").rstrip(".")
115
+ return "0" if s in ("-0", "") else s
116
+
117
+
118
+ def q(s):
119
+ s = re.sub(r"\s+", " ", str(s)).strip() # one line: KiCad's lexer doesn't take raw newlines in strings
120
+ return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
121
+
122
+
123
+ def line(x1, y1, x2, y2, layer, w):
124
+ return f' (fp_line (start {n(x1)} {n(y1)}) (end {n(x2)} {n(y2)}) (stroke (width {n(w)}) (type solid)) (layer {q(layer)}))'
125
+
126
+
127
+ def rect(x1, y1, x2, y2, layer, w):
128
+ return f' (fp_rect (start {n(x1)} {n(y1)}) (end {n(x2)} {n(y2)}) (stroke (width {n(w)}) (type solid)) (fill none) (layer {q(layer)}))'
129
+
130
+
131
+ def poly(pts, layer, w, fill="none"):
132
+ xy = " ".join(f"(xy {n(x)} {n(y)})" for x, y in pts)
133
+ return f' (fp_poly (pts {xy}) (stroke (width {n(w)}) (type solid)) (fill {fill}) (layer {q(layer)}))'
134
+
135
+
136
+ def text(kind, val, x, y, layer, size=1.0, hide=False):
137
+ th = round(size * 0.15, 3)
138
+ return (f' (fp_text {kind} {q(val)} (at {n(x)} {n(y)}) (layer {q(layer)}){" hide" if hide else ""}\n'
139
+ f' (effects (font (size {n(size)} {n(size)}) (thickness {n(th)}))))')
140
+
141
+
142
+ def pad(num, x, y, w, h, shape="roundrect", layers=("F.Cu", "F.Paste", "F.Mask"), extra=""):
143
+ lay = " ".join(q(l) for l in layers)
144
+ rr = f" (roundrect_rratio {n(min(0.25, 0.25 / min(w, h)))})" if shape == "roundrect" else ""
145
+ return f' (pad {q(num)} smd {shape} (at {n(x)} {n(y)}) (size {n(w)} {n(h)}) (layers {lay}){rr}{extra})'
146
+
147
+
148
+ # ---------------------------------------------------------------- package builders
149
+ def family(spec):
150
+ """IPC-7351B lead family (FILLETS key) for a spec: `lead_type` overrides the default per package_type."""
151
+ t = spec["package_type"]
152
+ if spec.get("ipc_family") in FILLETS: # forced (ecadlib.stdparts: KiCad's force_small_pitch_ipc_definition)
153
+ return spec["ipc_family"]
154
+ lt = spec.get("lead_type")
155
+ if t == "chip":
156
+ if lt in ("molded", "melf", "flat"):
157
+ return lt
158
+ return "chip_small" if dim(spec["body_length"])[1] < 1.55 else "chip"
159
+ if t == "molded":
160
+ return lt if lt in ("melf", "flat") else "molded"
161
+ if lt and (t in ("qfn", "qfp") or (t == "gullwing" and lt != "flat")):
162
+ raise ValueError(f"lead_type {lt!r} doesn't apply to a {t} package"
163
+ + (" (only \"flat\" does)" if t == "gullwing" else ""))
164
+ if t == "gullwing":
165
+ if lt == "flat":
166
+ return "flat"
167
+ pitch = spec.get("pitch") or _min_pitch(spec)
168
+ return "gullwing" if pitch is None or pitch >= 0.625 else "gullwing_fine"
169
+ if t == "qfn":
170
+ return "qfn"
171
+ if t == "qfp":
172
+ return "gullwing" if spec["pitch"] >= 0.625 else "gullwing_fine"
173
+ raise ValueError(f"unsupported package_type {t}")
174
+
175
+
176
+ def _min_pitch(spec):
177
+ pos = spec.get("pin_positions") or []
178
+ best = None
179
+ for side in ("L", "R"):
180
+ ys = sorted(p["offset"] for p in pos if p["side"] == side)
181
+ for a, b in zip(ys, ys[1:]):
182
+ best = b - a if best is None else min(best, b - a)
183
+ return best
184
+
185
+
186
+ def _two_terminal(spec, fil):
187
+ L = dim(spec["body_length"])
188
+ W = dim(spec.get("terminal_width") or spec["body_width"])
189
+ r = ipc_land(L, dim(spec.get("terminal_length")), W, fil, S=dim(spec.get("inner_span")))
190
+ pads = [("1", -r["pad_ctr"], 0, r["pad_len"], r["X"]), ("2", r["pad_ctr"], 0, r["pad_len"], r["X"])]
191
+ return pads, (L[1] / 2, dim(spec["body_width"])[1] / 2), {k: r[k] for k in ("Z", "G", "X")}
192
+
193
+
194
+ def _gullwing(spec, fil):
195
+ T, bw, span = dim(spec["terminal_length"]), dim(spec["terminal_width"]), dim(spec["lead_span"])
196
+ ep = spec.get("exposed_pad")
197
+ min_gap = (dim(ep["length"])[1] if ep else 0) + 2 * MIN_EP_CLEAR # KiCad: G >= EP + 0.4, so >= 0.4 without EP
198
+ r = ipc_land(span, T, bw, fil, min_gap=min_gap)
199
+ pos = spec.get("pin_positions")
200
+ if not pos: # standard dual row: 1..n/2 down the left, then up the right
201
+ e, per = spec["pitch"], spec["pin_count"] // 2
202
+ pos = [{"number": str(i + 1), "side": "L", "offset": (i - (per - 1) / 2) * e} for i in range(per)]
203
+ pos += [{"number": str(per + i + 1), "side": "R", "offset": ((per - 1) / 2 - i) * e} for i in range(per)]
204
+ pitch = _min_pitch({"pin_positions": pos})
205
+ X = min(r["X"], rdn(pitch - MIN_PAD_GAP)) if pitch else r["X"]
206
+ pads = [(str(p["number"]), (-1 if p["side"] == "L" else 1) * r["pad_ctr"], p["offset"], r["pad_len"], X) for p in pos]
207
+ rep = {"Z": r["Z"], "G": r["G"], "X": X, "X_unclamped": r["X"]}
208
+ tab = spec.get("tab")
209
+ if tab: # SOT-223 / DPAK style wide tab on one side
210
+ tr = ipc_land(span, dim(tab.get("length") or spec["terminal_length"]), dim(tab["width"]), fil, min_gap=min_gap)
211
+ sgn = -1 if tab.get("side", "R") == "L" else 1
212
+ pads.append((str(tab["number"]), sgn * tr["pad_ctr"], tab.get("offset", 0.0), tr["pad_len"], tr["X"]))
213
+ rep["tab"] = {"Z": tr["Z"], "G": tr["G"], "X": tr["X"]}
214
+ body_x, body_y = dim(spec["body_width"])[1] / 2, dim(spec["body_length"])[1] / 2 # E1 across, D along the rows
215
+ return pads, (body_x, body_y), rep
216
+
217
+
218
+ def _qfn(spec, fil):
219
+ """Quad (or dual) rows: QFN/DFN toes sit at the body edge; QFP toes at the lead span."""
220
+ npins, e = spec["pin_count"], spec["pitch"]
221
+ body_x, body_y, T, bw = dim(spec["body_length"]), dim(spec["body_width"]), dim(spec["terminal_length"]), dim(spec["terminal_width"])
222
+ nlr = spec.get("pins_left_right", npins // 4)
223
+ ntb = spec.get("pins_top_bottom", (npins - 2 * nlr) // 2)
224
+ span_x = dim(spec["lead_span"]) if spec.get("lead_span") else body_x
225
+ span_y = dim(spec.get("lead_span_y") or spec["lead_span"]) if spec.get("lead_span") else body_y
226
+ ep = spec.get("exposed_pad") # heels pulled back to keep MIN_EP_CLEAR to the EP (KLC F6.3)
227
+ gx = dim(ep["length"])[1] + 2 * MIN_EP_CLEAR if ep else None
228
+ gy = dim(ep["width"])[1] + 2 * MIN_EP_CLEAR if ep else None
229
+ rx = ipc_land(span_x, T, bw, fil, min_gap=gx) # left/right pads
230
+ ry = ipc_land(span_y, T, bw, fil, min_gap=gy) # top/bottom pads
231
+ X = min(rx["X"], rdn(e - MIN_PAD_GAP))
232
+ pads, k = [], 1
233
+ for i in range(nlr): # left, top->bottom
234
+ pads.append((str(k), -rx["pad_ctr"], (i - (nlr - 1) / 2) * e, rx["pad_len"], X)); k += 1
235
+ for i in range(ntb): # bottom, left->right
236
+ pads.append((str(k), (i - (ntb - 1) / 2) * e, ry["pad_ctr"], X, ry["pad_len"])); k += 1
237
+ for i in range(nlr): # right, bottom->top
238
+ pads.append((str(k), rx["pad_ctr"], ((nlr - 1) / 2 - i) * e, rx["pad_len"], X)); k += 1
239
+ for i in range(ntb): # top, right->left
240
+ pads.append((str(k), ((ntb - 1) / 2 - i) * e, -ry["pad_ctr"], X, ry["pad_len"])); k += 1
241
+ return pads, (body_x[1] / 2, body_y[1] / 2), {"Z": rx["Z"], "G": rx["G"], "X": X, "Zy": ry["Z"], "Gy": ry["G"]}
242
+
243
+
244
+ def corner_r(num, w, h):
245
+ """Corner radius of a pad as pad() draws it: roundrect 25 % of the short side, capped at 0.25; EP is a plain rect."""
246
+ return 0.0 if num is None else min(0.25 * min(w, h), 0.25)
247
+
248
+
249
+ def _clip(seg, pads, c, min_len=SILK_MIN_LEN):
250
+ """Cut an axis-aligned silk segment wherever its centre line comes within c of a pad's real (rounded)
251
+ outline, as KiCad measures it. pads as (num, x, y, w, h). Pieces shorter than min_len are dropped."""
252
+ (x1, y1), (x2, y2) = seg
253
+ horiz = abs(y1 - y2) < 1e-9
254
+ a, b = (min(x1, x2), max(x1, x2)) if horiz else (min(y1, y2), max(y1, y2))
255
+ keep = [(a, b)]
256
+ for num, px, py, w, h in pads:
257
+ r = corner_r(num, w, h)
258
+ along, across = (px, py) if horiz else (py, px)
259
+ half_along, half_across = (w / 2, h / 2) if horiz else (h / 2, w / 2)
260
+ e = max(abs((y1 if horiz else x1) - across) - (half_across - r), 0) # distance past the straight edge
261
+ if e >= r + c:
262
+ continue
263
+ half = half_along - r + math.sqrt((r + c) ** 2 - e ** 2)
264
+ lo, hi = along - half, along + half
265
+ keep = [part for s, e_ in keep for part in ((s, min(e_, lo)), (max(s, hi), e_)) if part[1] - part[0] > 1e-6]
266
+ keep = [(s, e) for s, e in keep if e - s >= min_len - 1e-6]
267
+ return [((round(s, 4), y1), (round(e, 4), y1)) if horiz else ((x1, round(s, 4)), (x1, round(e, 4))) for s, e in keep]
268
+
269
+
270
+ def _apply_land(spec, pads):
271
+ """Replace the IPC-computed signal pads with the datasheet's recommended land (the tab keeps its IPC size)."""
272
+ land = spec["land"]
273
+ ll, lw, lc = land["pad_length"], land["pad_width"], land["pad_center"]
274
+ lcy = land.get("pad_center_y", lc)
275
+ n_sig = len(pads) - (1 if spec.get("tab") else 0)
276
+ out = []
277
+ for i, (num, x, y, w, h) in enumerate(pads):
278
+ if i >= n_sig:
279
+ out.append((num, x, y, w, h))
280
+ elif abs(x) >= abs(y) or w >= h: # left/right row: length runs along X
281
+ out.append((num, math.copysign(lc, x), y, ll, lw))
282
+ else: # top/bottom row (QFN/QFP)
283
+ out.append((num, x, math.copysign(lcy, y), lw, ll))
284
+ return out
285
+
286
+
287
+ def sanity(spec):
288
+ """Catch the usual datasheet-reading mistakes before they turn into a plausible-looking but wrong footprint.
289
+ Returns a list of plain-language problems (empty when the numbers hang together)."""
290
+ errs, t = [], spec["package_type"]
291
+ nom = lambda k: dim(spec[k])[1] if spec.get(k) is not None else None
292
+ for k in ("body_length", "body_width", "lead_span", "terminal_length", "terminal_width", "inner_span", "pitch"):
293
+ v = spec.get(k)
294
+ lo, _, hi = dim(v) if v is not None and k != "pitch" else (v, v, v)
295
+ if v is None:
296
+ continue
297
+ if lo > hi:
298
+ errs.append(f"{k}: min {lo} is bigger than max {hi}")
299
+ if hi > 60:
300
+ errs.append(f"{k} = {hi}: that looks like mils or 0.01 mm units; the spec is in millimetres")
301
+ if errs:
302
+ return errs
303
+ L, W, T = nom("body_length"), nom("body_width"), nom("terminal_length")
304
+ if t in ("chip", "molded") and T and not spec.get("inner_span") and 2 * dim(spec["terminal_length"])[0] >= dim(spec["body_length"])[2]:
305
+ errs.append(f"terminal_length {T} is half the body length or more: it should be one end-cap/foot, not the whole part")
306
+ if t in ("gullwing", "qfp"):
307
+ span = nom("lead_span")
308
+ if span is not None and span < W + 0.1: # leads must stick out past the plastic
309
+ errs.append(f"lead_span {span} is not bigger than body_width {round(W, 3)}: lead_span is the tip-to-tip size "
310
+ "across the leads (E), body_width the plastic (E1); are they swapped?")
311
+ if span is not None and T and T >= span / 2:
312
+ errs.append(f"terminal_length {T} is half the lead span or more")
313
+ n = spec.get("pin_count")
314
+ if t == "gullwing" and n and not spec.get("pin_positions") and n % 2:
315
+ errs.append(f"pin_count {n} is odd: a dual-row part needs an even count, or list the pads in pin_positions "
316
+ "(as for SOT-23)")
317
+ if t in ("qfn", "qfp") and n:
318
+ nlr = spec.get("pins_left_right", n // 4)
319
+ ntb = spec.get("pins_top_bottom", (n - 2 * nlr) // 2)
320
+ if 2 * nlr + 2 * ntb != n:
321
+ errs.append(f"pin_count {n} doesn't equal 2 x {nlr} (left/right) + 2 x {ntb} (top/bottom); "
322
+ "set pins_left_right / pins_top_bottom")
323
+ if t in ("gullwing", "qfp", "qfn") and spec.get("pitch") and spec.get("pin_count") and not spec.get("pin_positions"):
324
+ n = spec["pin_count"]
325
+ per = n // 2 if t == "gullwing" else spec.get("pins_left_right", n // 4)
326
+ row = (per - 1) * spec["pitch"]
327
+ if per > 1 and row >= (L if t == "gullwing" else max(L, W)):
328
+ errs.append(f"{per} pins at {spec['pitch']} mm pitch need {row:.2f} mm, more than the {L} mm body: check pitch and pin_count")
329
+ tw = spec.get("terminal_width")
330
+ if tw and dim(tw)[0] >= spec["pitch"]:
331
+ errs.append(f"terminal_width {dim(tw)[0]} is not smaller than the {spec['pitch']} mm pitch")
332
+ if t == "qfn" and T and T >= min(L, W) / 2:
333
+ errs.append(f"terminal_length {T} reaches the middle of the {L} x {W} body")
334
+ ep = spec.get("exposed_pad")
335
+ bx, by = (W, L) if t == "gullwing" else (L, W) # EP length is along X: gullwing X is body_width (E1)
336
+ if ep and (dim(ep["length"])[1] >= bx or dim(ep["width"])[1] >= by):
337
+ errs.append("the exposed pad is as big as the body: check its length/width")
338
+ elif ep and t == "qfn" and T:
339
+ tmin = dim(spec["terminal_length"])[0]
340
+ tb = spec.get("pins_top_bottom", 1)
341
+ if dim(ep["length"])[1] + 2 * tmin > dim(spec["body_length"])[2] or (
342
+ tb and dim(ep["width"])[1] + 2 * tmin > dim(spec["body_width"])[2]):
343
+ errs.append("the exposed pad would overlap the terminals: check its size against the body and terminal_length")
344
+ return errs
345
+
346
+
347
+ ADVANCED = ("bga", "pin_header", "connector", "custom")
348
+
349
+
350
+ def build(spec, density=None):
351
+ """Return (kicad_mod_text, report_dict)."""
352
+ if spec["package_type"] in ADVANCED:
353
+ from . import footprint_adv
354
+ return footprint_adv.build(spec, density)
355
+ problems = sanity(spec)
356
+ if problems:
357
+ raise ValueError("the package numbers don't add up: " + "; ".join(problems))
358
+ density = density or spec.get("density", "B")
359
+ t = spec["package_type"]
360
+ fam = family(spec)
361
+ fil = FILLETS[fam][density]
362
+ rep = {"name": spec["name"], "type": t, "density": density, "ipc_family": fam}
363
+ if t in ("chip", "molded"):
364
+ pads, (bx, by), r = _two_terminal(spec, fil)
365
+ elif t == "gullwing":
366
+ pads, (bx, by), r = _gullwing(spec, fil)
367
+ else: # qfn, qfp
368
+ pads, (bx, by), r = _qfn(spec, fil)
369
+ rep.update(r)
370
+ want = spec.get("pin_count")
371
+ if want and len(pads) != want:
372
+ raise ValueError(f"pin_count is {want} but the layout gives {len(pads)} pads "
373
+ "(count every pad except an exposed pad; a tab counts)")
374
+ short = [p for p in pads if min(p[3], p[4]) < 0.1]
375
+ if short:
376
+ why = "the exposed pad leaves no room for the pads" if spec.get("exposed_pad") else "the lead dimensions leave no room"
377
+ raise ValueError(f"{why}: pad {short[0][0]} would be {min(short[0][3], short[0][4]):.2f} mm (check the drawing)")
378
+ if spec.get("land"): # the datasheet's recommended land pattern wins over IPC
379
+ pads = _apply_land(spec, pads)
380
+ lw, ll, lc = spec["land"]["pad_width"], spec["land"]["pad_length"], spec["land"]["pad_center"]
381
+ rep.update({"Z": round(2 * lc + ll, 4), "G": round(2 * lc - ll, 4), "X": lw, "land_source": "datasheet"})
382
+ polarized = bool(spec.get("polarized"))
383
+ two_term = t in ("chip", "molded") or (t == "gullwing" and len(pads) == 2)
384
+
385
+ ep = spec.get("exposed_pad")
386
+ ep_lines = []
387
+ if ep:
388
+ ew, eh = dim(ep["length"])[1], dim(ep["width"])[1]
389
+ epnum = str(ep.get("number") or (spec.get("pin_count") or len(pads)) + 1) # pin_positions specs may omit pin_count
390
+ lr = (lambda p: abs(p[1]) > abs(p[2])) if t in ("qfn", "qfp") else (lambda p: True) # gullwing: all pads are L/R
391
+ gaps = [abs(p[1]) - p[3] / 2 - ew / 2 for p in pads if lr(p)] # left/right rows vs EP
392
+ gaps += [abs(p[2]) - p[4] / 2 - eh / 2 for p in pads if not lr(p)] # qfn top/bottom rows vs EP
393
+ if gaps and min(gaps) < MIN_PAD_GAP - 1e-6:
394
+ rep["warning"] = f"EP to pad gap {min(gaps):.3f} < {MIN_PAD_GAP}"
395
+ ep_lines.append(pad(epnum, 0, 0, ew, eh, shape="rect", layers=("F.Cu", "F.Mask")))
396
+ cov = ep.get("paste_coverage", 0.65) # KLC F6.3: 50-80 %, 65 % recommended
397
+ grid = ep.get("paste_grid") # split into windows of about 1.2 mm or less
398
+ gx = grid or max(1, min(5, math.ceil(ew / 1.2 - 1e-9)))
399
+ gy = grid or max(1, min(5, math.ceil(eh / 1.2 - 1e-9)))
400
+ sw, sh = ew * math.sqrt(cov) / gx, eh * math.sqrt(cov) / gy
401
+ for i in range(gx):
402
+ for j in range(gy):
403
+ cx, cy = (i - (gx - 1) / 2) * ew / gx, (j - (gy - 1) / 2) * eh / gy
404
+ ep_lines.append(pad("", round(cx, 4), round(cy, 4), round(sw, 3), round(sh, 3), layers=("F.Paste",)))
405
+ rep["EP"] = (ew, eh, f"paste {gx}x{gy} @ {cov:.0%}")
406
+ copper = pads + ([(None, 0, 0, dim(ep["length"])[1], dim(ep["width"])[1])] if ep else [])
407
+
408
+ # ---- courtyard: union(pads, body) + excess, rounded up to the 0.01 mm grid (KLC F5.3)
409
+ exc = fil[3]
410
+ px = max([abs(p[1]) + p[3] / 2 for p in pads] + [bx])
411
+ py = max([abs(p[2]) + p[4] / 2 for p in pads] + [by])
412
+ cx, cy = rup(px + exc), rup(py + exc)
413
+ rep["courtyard"] = (round(2 * cx, 2), round(2 * cy, 2))
414
+
415
+ out = [f'(footprint {q(spec["name"])} (version 20221018) (generator "ecadlib_footprint_gen")',
416
+ ' (layer "F.Cu")',
417
+ f' (descr {q(descr(spec, rep))})',
418
+ f' (tags {q(" ".join(filter(None, [t, fam, "polarized" if polarized else "", "IPC-7351B density-" + density])))})',
419
+ ' (attr smd)',
420
+ text("reference", "REF**", 0, -cy - 0.7, "F.SilkS"),
421
+ text("value", spec["name"], 0, cy + 0.7, "F.Fab"),
422
+ text("user", "${REFERENCE}", 0, 0, "F.Fab", size=math.floor(100 * min(1.0, max(0.25, 0.5 * max(bx, by)))) / 100)]
423
+
424
+ # ---- fab: body outline; pin-1 / polarity chamfer on everything except plain 2-terminal parts (KLC F5.2)
425
+ if two_term and not polarized:
426
+ out.append(rect(-bx, -by, bx, by, "F.Fab", FAB_W))
427
+ else:
428
+ ch = min(1.0, 0.25 * min(2 * bx, 2 * by))
429
+ if two_term: # KiCad chip LED/diode marker: 0.3 .. 1.0
430
+ ch = max(ch, min(0.3, bx, by))
431
+ out.append(poly([(-bx + ch, -by), (bx, -by), (bx, by), (-bx, by), (-bx, -by + ch)], "F.Fab", FAB_W))
432
+
433
+ # ---- silk: body outline pushed out by SILK_FAB_OFFSET, cut back to SILK_PAD_CLEAR from every pad (KLC F5.1)
434
+ c = SILK_PAD_CLEAR + SILK_W / 2
435
+ sx, sy = bx + SILK_FAB_OFFSET, by + SILK_FAB_OFFSET
436
+ p1 = next((p for p in pads if p[0] == "1"), None)
437
+ if p1 is None: # e.g. A/K numbering: mark the first pad,
438
+ p1 = pads[0] # the checker reports the missing "1"
439
+ rep["warning"] = (rep.get("warning", "") + "; " if rep.get("warning") else "") + \
440
+ f"no pad numbered 1; KiCad numbers pins 1, 2, ... (diodes: 1 = cathode), marked pad {p1[0]} as pin 1"
441
+ if two_term and polarized:
442
+ # KiCad LED/diode style: a bracket that closes on the pin-1 (cathode / +) side
443
+ yl = round(max(sy, max(abs(p[2]) + p[4] / 2 for p in pads) + c), 4)
444
+ xl = -round(max(sx, -(p1[1] - p1[3] / 2) + c), 4)
445
+ xe = max(bx, max(p[1] for p in pads))
446
+ out += [line(xl, -yl, xl, yl, "F.SilkS", SILK_W), line(xl, -yl, xe, -yl, "F.SilkS", SILK_W),
447
+ line(xl, yl, xe, yl, "F.SilkS", SILK_W)]
448
+ silk_y = yl
449
+ else:
450
+ box = [((-sx, -sy), (sx, -sy)), ((sx, -sy), (sx, sy)), ((sx, sy), (-sx, sy)), ((-sx, sy), (-sx, -sy))]
451
+ segs = [s for b in box for s in _clip(b, copper, c)]
452
+ if not segs: # tiny part (0402): KiCad's small-part clearance
453
+ c = SILK_CLEAR_SMALL + SILK_W / 2
454
+ segs = [s for b in box for s in _clip(b, copper, c)]
455
+ out += [line(a[0], a[1], b[0], b[1], "F.SilkS", SILK_W) for a, b in segs]
456
+ silk_y = max((abs(v[1]) for sg in segs for v in sg), default=0.0)
457
+ if not two_term: # pin-1 triangle just above pad 1
458
+ if t == "qfn" and spec.get("pins_top_bottom", 1) != 0:
459
+ ax, ay = -sx, -sy
460
+ else:
461
+ ax, ay = p1[1], p1[2] - p1[4] / 2 - c
462
+ out.append(pin1_triangle(ax, ay))
463
+
464
+ if silk_y + SILK_W / 2 > cy: # silk outline past the courtyard (density C):
465
+ out[5] = text("reference", "REF**", 0, -round(silk_y + SILK_W / 2 + 0.7, 4), "F.SilkS") # keep REF** off it
466
+ out.append(rect(-cx, -cy, cx, cy, "F.CrtYd", CRT_W))
467
+ out += [pad(*p) for p in pads] + ep_lines
468
+ if spec.get("model_3d"):
469
+ out.append(f' (model {q(spec["model_3d"])} (offset (xyz 0 0 0)) (scale (xyz 1 1 1)) (rotate (xyz 0 0 0)))')
470
+ out.append(")")
471
+ text_out = "\n".join(out) + "\n"
472
+ rep["pads"] = [{"num": p[0], "x": p[1], "y": p[2], "w": p[3], "h": p[4]} for p in pads]
473
+ from . import footprint_check
474
+ rep["checks"] = [{"level": l, "rule": r_, "message": m}
475
+ for l, r_, m in footprint_check.check(text_out, allow_duplicate_pads=[str(spec["tab"]["number"])] if spec.get("tab") else None)]
476
+ return text_out, rep
477
+
478
+
479
+ def altium_footprint(spec, density=None):
480
+ """Altium footprint dict (altium_lib's writer model) for any spec, from the same .kicad_mod geometry:
481
+ altium_lib.footprint_from_spec handles SMD, through-hole (round, square, slots), NPTH holes, polygon pads (anchor
482
+ pad + copper / mask / paste regions) and keep-outs. Returns (footprint, notes)."""
483
+ from . import altium_lib as AL
484
+ fp, rep = AL.footprint_from_spec(spec, density)
485
+ return fp, rep.get("altium_notes", [])
486
+
487
+
488
+ def pin1_triangle(x, y):
489
+ """Filled silk triangle pointing down at (x, y) - the KiCad-library pin-1 convention."""
490
+ return poly([(x, y), (x - 0.24, y - 0.33), (x + 0.24, y - 0.33)], "F.SilkS", SILK_W, fill="solid")
491
+
492
+
493
+ def descr(spec, r):
494
+ s = spec.get("description", spec["name"])
495
+ s += f" | IPC-7351B {r['ipc_family']} density {r['density']}: Z={r['Z']:.2f} G={r['G']:.2f} X={r['X']:.2f}"
496
+ if spec.get("source", {}).get("datasheet"):
497
+ s += f" | src {spec['source']['datasheet']}"
498
+ return s + " | generated by ecadlib (prototype - review before use)"
499
+
500
+
501
+ _VALIDATOR = []
502
+
503
+
504
+ def validate(spec):
505
+ """Raise jsonschema.ValidationError (the best match, as jsonschema.validate does) when the spec breaks the schema.
506
+ The validator is built once: jsonschema.validate re-checks the whole schema on every call (~80 ms)."""
507
+ import jsonschema # a runtime dependency: never skip validation silently
508
+ if not _VALIDATOR:
509
+ schema = json.loads((Path(__file__).with_name("package_schema.json")).read_text())
510
+ cls = jsonschema.validators.validator_for(schema)
511
+ cls.check_schema(schema)
512
+ _VALIDATOR.append(cls(schema))
513
+ err = jsonschema.exceptions.best_match(_VALIDATOR[0].iter_errors(spec))
514
+ if err is not None:
515
+ raise err
516
+
517
+
518
+ # ---------------------------------------------------------------- comparison vs a reference footprint
519
+ def compare(mine_path, ref_text):
520
+ from . import sexpr
521
+ a, b = sexpr.parse(Path(mine_path).read_text()), sexpr.parse(ref_text)
522
+ pa = {p["number"]: p for p in sexpr.pads(a) if p["number"]}
523
+ pb = {p["number"]: p for p in sexpr.pads(b) if p["number"]}
524
+ rows = []
525
+ for k in sorted(set(pa) & set(pb), key=lambda s: int(s) if s.isdigit() else 999):
526
+ x, y = pa[k], pb[k]
527
+ rows.append({"pad": k, "mine": (x["x"], x["y"], x["w"], x["h"]), "ref": (y["x"], y["y"], y["w"], y["h"]),
528
+ "d_pos": (round(x["x"] - y["x"], 4), round(x["y"] - y["y"], 4)),
529
+ "d_size": (round(x["w"] - y["w"], 4), round(x["h"] - y["h"], 4))})
530
+ return rows, sexpr.courtyard_bbox(a), sexpr.courtyard_bbox(b)
531
+
532
+
533
+ def main(argv=None):
534
+ ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
535
+ sub = ap.add_subparsers(dest="cmd", required=True)
536
+ g = sub.add_parser("gen", help="generate .kicad_mod from package JSON files")
537
+ g.add_argument("specs", nargs="+")
538
+ g.add_argument("-o", "--out", default="out/footprints/ecadlib.pretty")
539
+ g.add_argument("--density", choices="ABC")
540
+ g.add_argument("--rules", help="library rules file: [footprint] density (default: ecadlib.toml found from here)")
541
+ c = sub.add_parser("compare", help="compare a generated footprint's pads with a reference (path or URL)")
542
+ c.add_argument("mine")
543
+ c.add_argument("ref")
544
+ a = ap.parse_args(argv)
545
+
546
+ if a.cmd == "gen":
547
+ from . import rules as RU
548
+ outdir = Path(a.out); outdir.mkdir(parents=True, exist_ok=True)
549
+ rules = RU.load(a.rules, start=outdir)
550
+ for sp in a.specs:
551
+ spec = json.loads(Path(sp).read_text())
552
+ validate(spec)
553
+ txt, rep = build(spec, RU.density_for(spec, rules, a.density))
554
+ from . import sexpr
555
+ sexpr.parse(txt) # sanity: balanced S-expression
556
+ (outdir / f"{spec['name']}.kicad_mod").write_text(txt, encoding="utf-8", newline="\n")
557
+ p1 = rep["pads"][0]
558
+ zgx = f"Z={rep['Z']:.2f} G={rep['G']:.2f} X={rep['X']:.2f}" if "G" in rep else f"{len(rep['pads'])} pads"
559
+ print(f"{spec['name']:<44} {rep['ipc_family']:<13} {zgx} "
560
+ f"pad1 {p1['w']:.3f}x{p1['h']:.3f} @({p1['x']:.3f},{p1['y']:.3f}) "
561
+ f"crtyd {rep['courtyard'][0]:.2f}x{rep['courtyard'][1]:.2f}"
562
+ + (f" WARN {rep['warning']}" if "warning" in rep else "")
563
+ + "".join(f"\n {c['level']}: {c['message']}" for c in rep.get("table_checks", []) if c["level"] != "info"))
564
+ else:
565
+ if a.ref.startswith("http"):
566
+ from .httpcache import CachedHTTP
567
+ r = CachedHTTP(min_interval=0.5).get(a.ref)
568
+ if not r.ok:
569
+ sys.exit(f"fetch failed: {r.error or r.status}")
570
+ ref = r.text
571
+ else:
572
+ ref = Path(a.ref).read_text()
573
+ rows, ca, cb = compare(a.mine, ref)
574
+ print(f"{'pad':>4} {'mine (x,y,w,h)':>30} {'kicad (x,y,w,h)':>30} d_pos d_size")
575
+ for r in rows[:4] + (rows[-1:] if len(rows) > 4 else []):
576
+ print(f"{r['pad']:>4} {str(r['mine']):>30} {str(r['ref']):>30} {r['d_pos']} {r['d_size']}")
577
+ if ca and cb:
578
+ print(f"courtyard mine {ca[2]-ca[0]:.2f}x{ca[3]-ca[1]:.2f} kicad {cb[2]-cb[0]:.2f}x{cb[3]-cb[1]:.2f}")
579
+
580
+
581
+ if __name__ == "__main__":
582
+ main()