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,913 @@
1
+ """Official KiCad library footprints and symbols -> Altium .PcbLib / .SchLib (with 3D bodies).
2
+
3
+ python3 -m ecadlib.kicad_import batch examples/kicad_import_parts.json -o out/altium_models_kicad [--step] [--box]
4
+ python3 -m ecadlib.kicad_import footprint Package_TO_SOT_SMD:SOT-23 [--as NAME] [--height MM] [--step] [--box] -o DIR
5
+ python3 -m ecadlib.kicad_import symbol Transistor_FET:AO3401A [--as NAME] [--footprint REF] [--param K=V] -o DIR
6
+
7
+ A <Lib>:<Name> reference is fetched from gitlab.com/kicad/libraries (kicad-footprints, kicad-symbols with
8
+ KiCad 9+ per-symbol <Lib>.kicad_symdir/<Name>.kicad_sym files or a KiCad 7/8 <Lib>.kicad_sym, kicad-packages3D)
9
+ through httpcache.CachedHTTP. A local path works too: X.kicad_mod, Lib.kicad_sym:Name or X.kicad_sym.
10
+ Output: <out>/PcbLib/<name>.PcbLib and <out>/SchLib/<name>.SchLib, the layout `altium365 plan --models` reads.
11
+
12
+ Footprints: SMD, plated and non-plated through-hole pads (round and slotted drills), rect / roundrect / oval /
13
+ circle pads, custom pads as an anchor pad plus copper / mask / paste regions (curved primitives: bounding
14
+ rectangle), trapezoids as rectangles, per-pad mask margins and tented pads, pad rotation, fp_line / fp_rect /
15
+ fp_poly / fp_circle / fp_arc on silkscreen, fab (Mechanical 13), courtyard (Mechanical 15), F.Cu, F.Mask, F.Paste.
16
+ Reference / Value text become .Designator (Top Overlay from silk, Mechanical 13 from fab) / .Comment (Mechanical 13).
17
+ Not converted (listed in the notes): user text, zones other than keep-outs, drill offsets.
18
+ 3D: an extruded ComponentBody on Mechanical 13 over the fab outline (else the courtyard), its height from the
19
+ KiCad STEP model (max z after the model transform), else DEFAULT_HEIGHT, else --height. --step embeds the STEP
20
+ model itself (Library/Models, AltiumSharp layout and checksum) when the KiCad model has no offset or rotation.
21
+ Where no KiCad STEP is embedded and there is no KiCad model (or --step could not embed it), a package-shaped
22
+ STEP from step_gen is embedded instead of the box when the package family is known; --box keeps the box.
23
+ Symbols: pins (number, name, type, orientation, length, hidden, overbar names), rectangles, polylines, circles,
24
+ arcs, text, pin shapes (inverted, clock, active-low ...) as Altium pin symbols, multi-unit symbols as Altium
25
+ multi-part, De Morgan body styles as Altium display modes, `extends` chains; Value/Footprint/Datasheet/Description/MPN
26
+ as parameters.
27
+
28
+ KiCad's libraries are CC-BY-SA 4.0 with a design-use exception: converted files are not committed (.gitignore)."""
29
+ import argparse, json, math, re, sys
30
+ from pathlib import Path
31
+
32
+ from . import altium_lib as AL, sexpr
33
+ from .httpcache import CachedHTTP
34
+
35
+ GITLAB = "https://gitlab.com/kicad/libraries/{repo}/-/raw/master/{path}"
36
+ LAYERS = {"F.SilkS": AL.TOP_OVERLAY, "B.SilkS": AL.BOTTOM_OVERLAY, "F.Fab": AL.MECH13, "F.CrtYd": AL.MECH15,
37
+ "F.Cu": AL.TOP, "F.Mask": AL.TOP_SOLDER, "F.Paste": AL.TOP_PASTE}
38
+ # overall height (mm) when the KiCad model gives none: first regex matching the footprint name wins
39
+ DEFAULT_HEIGHT = [(r"^SOT-23", 1.2), (r"^SOT-223", 1.8), (r"^(LQFP|TQFP)", 1.6), (r"^(SOIC|SOP)", 1.75), (r"^(TSSOP|MSOP)", 1.2),
40
+ (r"QFN|DFN|WSON|SON", 0.8), (r"_0402_", 0.6), (r"_0603_", 0.9), (r"_0805_", 1.3), (r"_1206_", 1.6),
41
+ (r"^Crystal_SMD_3225", 1.0), (r"^Crystal_SMD_2016", 0.8), (r"^USB_C_Receptacle", 3.3), (r"^JST_SH_", 2.95),
42
+ (r"^PinHeader_.*P1.27mm.*SMD", 4.3), (r"^ESP32-S3-WROOM", 3.25)]
43
+ # package default heights (mm) for standard chip sizes when nothing better is known (no KiCad model, no datasheet): chip
44
+ # resistors at their nominal thickness (thick film, as step_gen), MLCCs at a conservative maximum for the size (a 0603
45
+ # MLCC is 0.8 mm nominal, up to 0.9 mm). Used with the label 'package default, verify': a warning, not a hold.
46
+ CHIP_HEIGHT = {"R": {"0201": 0.26, "0402": 0.35, "0603": 0.45, "0805": 0.5, "1206": 0.55, "1210": 0.55, "2010": 0.6, "2512": 0.6},
47
+ "C": {"0201": 0.33, "0402": 0.55, "0603": 0.9, "0805": 1.35, "1206": 1.8, "1210": 2.7}}
48
+ PKG_HEIGHT = [(r"(?i)\bSOT-?23(?![0-9])", 1.2, "SOT-23"), (r"(?i)\bSOT-?23-?[56]\b", 1.2, "SOT-23-5/6"), (r"(?i)\bSOD-?123\b", 1.35, "SOD-123"),
49
+ (r"(?i)\bSOD-?323\b", 1.1, "SOD-323"), (r"(?i)\bSOT-?323\b", 1.1, "SOT-323")]
50
+ CHIP_RE = re.compile(r"(?<![0-9])(0201|0402|0603|0805|1206|1210|2010|2512)(?![0-9])")
51
+
52
+
53
+ def package_default_height(names, kind=None):
54
+ """(height mm, label) for a standard package named in any of names (footprint name, KiCad ref, description ...),
55
+ or None. kind: 'R' / 'C' (CP counts as C) when the part's kind is known; else read from the names (R_ / RES / C_ /
56
+ CAP / resistor / capacitor)."""
57
+ text = " ".join(str(n) for n in names if n)
58
+ k = {"CP": "C"}.get(kind, kind)
59
+ if k not in CHIP_HEIGHT:
60
+ k = ("R" if re.search(r"(?i)(^|[\s:/_])(R|RES|RESC)[_ ]|resistor", text) else
61
+ "C" if re.search(r"(?i)(^|[\s:/_])(C|CAP|CAPC)[_ ]|capacitor|MLCC", text) else None)
62
+ m = CHIP_RE.search(text)
63
+ if k and m and m.group(1) in CHIP_HEIGHT[k]:
64
+ return CHIP_HEIGHT[k][m.group(1)], f"{m.group(1)} {'resistor' if k == 'R' else 'capacitor'}"
65
+ for rx, h, label in PKG_HEIGHT:
66
+ if re.search(rx, text):
67
+ return h, label
68
+ return None
69
+
70
+
71
+ KTYPE = {"input": "input", "output": "output", "bidirectional": "io", "tri_state": "tristate", "passive": "passive",
72
+ "free": "passive", "unspecified": "passive", "power_in": "power_in", "power_out": "power_out",
73
+ "open_collector": "open_collector", "open_emitter": "open_emitter", "no_connect": "nc"}
74
+ SIDE = {0: "right", 90: "top", 180: "left", 270: "bottom"}
75
+
76
+
77
+ # ---------------------------------------------------------------- fetching
78
+ def fetch(http, repo, path):
79
+ r = http.get(GITLAB.format(repo=repo, path=path))
80
+ if r.status == 404:
81
+ return None
82
+ if not r.ok:
83
+ raise IOError(f"{repo}/{path}: {r.error}")
84
+ return r.text
85
+
86
+
87
+ def load_footprint(ref, http):
88
+ """'Lib:Name' or a .kicad_mod path -> (text, source label)."""
89
+ if Path(ref).suffix == ".kicad_mod":
90
+ return Path(ref).read_text(encoding="utf-8"), Path(ref).name
91
+ lib, name = ref.split(":", 1)
92
+ text = fetch(http, "kicad-footprints", f"{lib}.pretty/{name}.kicad_mod")
93
+ if text is None:
94
+ raise FileNotFoundError(f"KiCad footprint {ref} not found")
95
+ return text, f"KiCad {ref}"
96
+
97
+
98
+ def load_symbol(ref, http, keep_quotes=False):
99
+ """'Lib:Name', 'path/Lib.kicad_sym:Name' or 'path/Name.kicad_sym' -> [symbol, parent, ...] (extends chain), label.
100
+ keep_quotes: parse with sexpr's Q strings so the symbols can be written back (kicad_export)."""
101
+ parse = lambda text: sexpr.parse(text, keep_quotes)
102
+ m = re.match(r"(.+\.kicad_sym)(?::(.+))?$", ref)
103
+ if m: # local file; parents from the same file or sibling files (symdir)
104
+ path = Path(m.group(1))
105
+ def get(name):
106
+ for f in (path, path.with_name(f"{name}.kicad_sym")):
107
+ if f.exists():
108
+ s = _find_symbol(parse(f.read_text(encoding="utf-8")), name)
109
+ if s:
110
+ return s
111
+ raise FileNotFoundError(f"symbol {name} not in {path}")
112
+ name = m.group(2) or [s[1] for s in sexpr.find(sexpr.parse(path.read_text(encoding="utf-8")), "symbol")][0]
113
+ label = path.name
114
+ else:
115
+ lib, name = ref.split(":", 1)
116
+ cache = {}
117
+ def get(name):
118
+ text = fetch(http, "kicad-symbols", f"{lib}.kicad_symdir/{name}.kicad_sym") # KiCad 9+
119
+ if text is None: # KiCad 7/8 single file
120
+ if "lib" not in cache:
121
+ cache["lib"] = fetch(http, "kicad-symbols", f"{lib}.kicad_sym")
122
+ text = cache["lib"]
123
+ s = _find_symbol(parse(text), name) if text else None
124
+ if not s:
125
+ raise FileNotFoundError(f"KiCad symbol {lib}:{name} not found")
126
+ return s
127
+ label = f"KiCad {ref}"
128
+ chain = [get(name)]
129
+ while sexpr.first(chain[-1], "extends") and len(chain) < 8:
130
+ chain.append(get(sexpr.first(chain[-1], "extends")[1]))
131
+ return chain, label
132
+
133
+
134
+ def _find_symbol(lib, name):
135
+ return next((s for s in sexpr.find(lib, "symbol") if s[1] == name), None)
136
+
137
+
138
+ # ---------------------------------------------------------------- geometry helpers
139
+ def _f(node, i=1):
140
+ return float(node[i])
141
+
142
+
143
+ def _xy(node): # KiCad footprint point -> Altium (y up)
144
+ return float(node[1]), -float(node[2])
145
+
146
+
147
+ def _rot(x, y, deg):
148
+ a = math.radians(deg)
149
+ return x * math.cos(a) - y * math.sin(a), x * math.sin(a) + y * math.cos(a)
150
+
151
+
152
+ def _rect_pts(cx, cy, w, h, deg=0.0):
153
+ return [(cx + x, cy + y) for x, y in (_rot(sx * w / 2, sy * h / 2, deg) for sx, sy in ((-1, -1), (1, -1), (1, 1), (-1, 1)))]
154
+
155
+
156
+ def _arc3(s, m, e):
157
+ """Arc through three points -> (cx, cy, r, a0, a1), counter-clockwise from a0 to a1 (degrees)."""
158
+ (ax, ay), (bx, by), (cx_, cy_) = s, m, e
159
+ d = 2 * (ax * (by - cy_) + bx * (cy_ - ay) + cx_ * (ay - by))
160
+ if abs(d) < 1e-12:
161
+ raise ValueError("degenerate arc")
162
+ ux = ((ax * ax + ay * ay) * (by - cy_) + (bx * bx + by * by) * (cy_ - ay) + (cx_ * cx_ + cy_ * cy_) * (ay - by)) / d
163
+ uy = ((ax * ax + ay * ay) * (cx_ - bx) + (bx * bx + by * by) * (ax - cx_) + (cx_ * cx_ + cy_ * cy_) * (bx - ax)) / d
164
+ ang = lambda x, y: math.degrees(math.atan2(y - uy, x - ux)) % 360
165
+ a0, a1 = ang(ax, ay), ang(cx_, cy_)
166
+ ccw = (bx - ax) * (cy_ - by) - (by - ay) * (cx_ - bx) > 0
167
+ return ux, uy, math.hypot(ax - ux, ay - uy), *((a0, a1) if ccw else (a1, a0))
168
+
169
+
170
+ def _arc_pts(s, m, e, step=10.0):
171
+ cx, cy, r, a0, a1 = _arc3(s, m, e)
172
+ span = (a1 - a0) % 360 or 360
173
+ n = max(2, int(span / step) + 1)
174
+ pts = [(cx + r * math.cos(math.radians(a0 + span * k / n)), cy + r * math.sin(math.radians(a0 + span * k / n))) for k in range(n + 1)]
175
+ return pts if math.dist(pts[0], s) < math.dist(pts[0], e) else pts[::-1]
176
+
177
+
178
+ def _pts(node, conv=_xy):
179
+ """(pts (xy ..) (arc (start)(mid)(end)) ...) -> point list, arcs expanded to segments."""
180
+ out = []
181
+ for c in node[1:]:
182
+ if c[0] == "xy":
183
+ out.append(conv(c))
184
+ elif c[0] == "arc":
185
+ s, m, e = (conv(sexpr.first(c, k)) for k in ("start", "mid", "end"))
186
+ out += _arc_pts(s, m, e)[0 if not out else 1:]
187
+ return out
188
+
189
+
190
+ def _hull(pts):
191
+ pts = sorted(set((round(x, 6), round(y, 6)) for x, y in pts))
192
+ if len(pts) < 3:
193
+ return pts
194
+ cross = lambda o, a, b: (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
195
+ lo, hi = [], []
196
+ for p in pts:
197
+ while len(lo) >= 2 and cross(lo[-2], lo[-1], p) <= 0:
198
+ lo.pop()
199
+ lo.append(p)
200
+ for p in reversed(pts):
201
+ while len(hi) >= 2 and cross(hi[-2], hi[-1], p) <= 0:
202
+ hi.pop()
203
+ hi.append(p)
204
+ return lo[:-1] + hi[:-1]
205
+
206
+
207
+ def _filled(g):
208
+ f = sexpr.first(g, "fill")
209
+ if not f or len(f) < 2:
210
+ return False
211
+ v = f[1] if isinstance(f[1], str) else (sexpr.first(f, "type") or [None, "none"])[1]
212
+ return v in ("yes", "solid", "background", "outline", "color")
213
+
214
+
215
+ def _width(g):
216
+ w = sexpr.first(sexpr.first(g, "stroke") or [], "width") or sexpr.first(g, "width")
217
+ return float(w[1]) if w else 0.0
218
+
219
+
220
+ def _note(notes, msg):
221
+ if msg not in notes:
222
+ notes.append(msg)
223
+
224
+
225
+ def _hidden(node): # KiCad 7 bare `hide` atom or KiCad 9 `(hide yes)`
226
+ h = sexpr.first(node, "hide")
227
+ return "hide" in node[1:] or bool(h and h[1:2] == ["yes"])
228
+
229
+
230
+ # ---------------------------------------------------------------- footprints
231
+ MALFORMED = (IndexError, TypeError, AttributeError, KeyError, OverflowError, ZeroDivisionError)
232
+
233
+
234
+ def convert_footprint(text, name=None, source="", notes=None):
235
+ """.kicad_mod text -> altium_lib footprint dict (mm, y up), no 3D body yet. Raises ValueError for a file that
236
+ does not parse or is missing what a footprint needs (a pad without a size, a line without an end ...)."""
237
+ try:
238
+ return _convert_footprint(text, name, source, notes)
239
+ except MALFORMED as e:
240
+ raise ValueError(f"malformed KiCad footprint {name or ''}: {type(e).__name__}: {e}".replace(" ", " ")) from e
241
+
242
+
243
+ def _convert_footprint(text, name=None, source="", notes=None):
244
+ notes = [] if notes is None else notes
245
+ t = sexpr.parse(text)
246
+ descr = (sexpr.first(t, "descr") or ["", ""])[1]
247
+ fp = {"name": name or t[1], "description": AL.clean("; ".join(x for x in (descr, f"from {source} (CC-BY-SA 4.0)" if source else "") if x)),
248
+ "height_mm": 0.0, "pads": [], "tracks": [], "fills": [], "regions": [], "arcs": [], "bodies": [], "texts": []}
249
+ for p in sexpr.find(t, "pad"):
250
+ _pad(fp, p, notes)
251
+ skipped = {}
252
+ for kind in ("fp_line", "fp_rect", "fp_poly", "fp_circle", "fp_arc"):
253
+ for g in sexpr.find(t, kind):
254
+ ln = (sexpr.first(g, "layer") or [None, None])[1]
255
+ layer = LAYERS.get(ln)
256
+ if layer is None:
257
+ skipped[ln] = skipped.get(ln, 0) + 1
258
+ continue
259
+ _graphic(fp, kind, g, layer, notes)
260
+ if skipped:
261
+ notes.append("graphics on unmapped layers dropped: " + ", ".join(f"{k} x{v}" for k, v in sorted(skipped.items(), key=str)))
262
+ _zones(fp, t, notes)
263
+ fp["texts"] = AL.texts_from_kicad(t)
264
+ if fp["texts"]:
265
+ des = next((t[6] for t in fp["texts"] if t[0] == ".Designator"), None)
266
+ notes.append(f"Reference -> .Designator ({'Top Overlay' if des == AL.TOP_OVERLAY else 'Mechanical 13'}), Value -> .Comment (Mechanical 13)")
267
+ for k in ("fp_text", "fp_text_box", "dimension", "image"):
268
+ n = len([g for g in sexpr.find(t, k) if k != "fp_text" or g[1] not in AL.KTEXT])
269
+ if n:
270
+ notes.append(f"{n} {'user ' if k == 'fp_text' else ''}{k} not converted")
271
+ return fp, t
272
+
273
+
274
+ def _offset(pts, d):
275
+ """Polygon grown by d (mm; negative shrinks), mitred corners. Fine for the small mask / paste margins of pads."""
276
+ if not d or len(pts) < 3:
277
+ return list(pts)
278
+ area = sum(a[0] * b[1] - b[0] * a[1] for a, b in zip(pts, pts[1:] + pts[:1]))
279
+ s = 1 if area > 0 else -1 # counter-clockwise: outward normal of edge (dx, dy) is (dy, -dx)
280
+ out, n = [], len(pts)
281
+ for i in range(n):
282
+ p0, p1, p2 = pts[i - 1], pts[i], pts[(i + 1) % n]
283
+ nrm = []
284
+ for a, b in ((p0, p1), (p1, p2)):
285
+ L = math.hypot(b[0] - a[0], b[1] - a[1]) or 1
286
+ nrm.append((s * (b[1] - a[1]) / L, -s * (b[0] - a[0]) / L))
287
+ bx, by = nrm[0][0] + nrm[1][0], nrm[0][1] + nrm[1][1]
288
+ k = (1 + nrm[0][0] * nrm[1][0] + nrm[0][1] * nrm[1][1]) or 1e-9
289
+ out.append((p1[0] + d * bx / k, p1[1] + d * by / k))
290
+ return out
291
+
292
+
293
+ def _custom_shapes(p, x, y, rot, edges=True):
294
+ """Filled primitives of a KiCad custom pad -> ([polygons], [tracks (x1, y1, x2, y2, w)]) in footprint mm, y up;
295
+ None when a primitive can't be drawn exactly (curves, arcs, unfilled outlines)."""
296
+ def tf(px, py): # pad frame (KiCad, y down) -> footprint, y up
297
+ dx, dy = _rot(px, -py, rot)
298
+ return x + dx, y + dy
299
+ polys, tracks = [], []
300
+ for g in (sexpr.first(p, "primitives") or [])[1:]:
301
+ w = _width(g)
302
+ filled = _filled(g) or (sexpr.first(g, "fill") is None and g[0] == "gr_poly")
303
+ if g[0] == "gr_line":
304
+ a, b = (tf(float(c[1]), float(c[2])) for c in (sexpr.first(g, "start"), sexpr.first(g, "end")))
305
+ tracks.append((*a, *b, w)); continue
306
+ if g[0] == "gr_poly" and filled and sexpr.first(g, "pts"):
307
+ pts = [tf(*c) for c in _pts(sexpr.first(g, "pts"), lambda c: (float(c[1]), float(c[2])))]
308
+ elif g[0] == "gr_rect" and filled:
309
+ (x1, y1), (x2, y2) = [(float(c[1]), float(c[2])) for c in (sexpr.first(g, "start"), sexpr.first(g, "end"))]
310
+ pts = [tf(*c) for c in ((x1, y1), (x2, y1), (x2, y2), (x1, y2))]
311
+ elif g[0] == "gr_circle" and filled:
312
+ (cx, cy), (ex, ey) = [(float(c[1]), float(c[2])) for c in (sexpr.first(g, "center"), sexpr.first(g, "end"))]
313
+ r = math.hypot(ex - cx, ey - cy)
314
+ pts = [tf(cx + r * math.cos(k * math.pi / 18), cy + r * math.sin(k * math.pi / 18)) for k in range(36)]
315
+ else:
316
+ return None
317
+ polys.append(_offset(pts, w / 2) if w else pts)
318
+ if w and edges: # stroke around the outline: exact edges, mitred corners
319
+ tracks += [(*a, *b, w) for a, b in zip(pts, pts[1:] + pts[:1])]
320
+ return polys, tracks
321
+
322
+
323
+ def _mask_margin(p):
324
+ m = sexpr.first(p, "solder_mask_margin")
325
+ return float(m[1]) if m else None
326
+
327
+
328
+ def _custom_regions(fp, pad, shapes, kind, layers, p, notes=None):
329
+ """Regions for a custom pad's shape: copper on the pad's copper layers, solder mask grown by the pad's mask margin
330
+ (0 when KiCad has none, as the anchor pad's manual expansion), paste by its paste margin. Altium gives primitives
331
+ that touch a pad in a footprint the pad's net on the board."""
332
+ polys, tracks = shapes
333
+ lay = set(layers)
334
+ cu = [AL.TOP, AL.BOTTOM] if kind != "smd" and lay & {"*.Cu", "F&B.Cu"} else \
335
+ [l for n, l in (("F.Cu", AL.TOP), ("B.Cu", AL.BOTTOM)) if n in lay]
336
+ mask = [l for n, l in (("F", AL.TOP_SOLDER), ("B", AL.BOTTOM_SOLDER)) if lay & {f"{n}.Mask", "*.Mask", "F&B.Mask"}]
337
+ paste = [l for n, l in (("F", AL.TOP_PASTE), ("B", AL.BOTTOM_PASTE)) if lay & {f"{n}.Paste", "*.Paste"}]
338
+ mm_, pm = _mask_margin(p) or 0.0, sexpr.first(p, "solder_paste_margin")
339
+ for layer in cu:
340
+ fp["regions"] += [(pts, layer) for pts in polys]
341
+ fp["tracks"] += [(*t, layer) for t in tracks]
342
+ for layer in mask:
343
+ fp["regions"] += [(_offset(pts, mm_), layer) for pts in polys]
344
+ for layer in paste:
345
+ fp["regions"] += [(_offset(pts, float(pm[1]) if pm else 0.0), layer) for pts in polys]
346
+ pad["mask"] = mm_ if mask else "none"
347
+ if notes is not None and kind != "smd" and len(cu) > 1: # Altium regions have no "all layers" (Multi-Layer is for pads and vias only)
348
+ _note(notes, f"pad {pad['designator']}: through-hole custom shape on top and bottom copper; inner layers get the anchor "
349
+ "pad with the hole (connected on every layer), not the full shape")
350
+ if tracks and (mask or paste):
351
+ fp["regions"] += [(_rect_pts((x1 + x2) / 2, (y1 + y2) / 2, math.hypot(x2 - x1, y2 - y1) + w, w,
352
+ math.degrees(math.atan2(y2 - y1, x2 - x1))), layer)
353
+ for x1, y1, x2, y2, w in tracks for layer in mask + paste]
354
+
355
+
356
+ def _zones(fp, t, notes):
357
+ """Rule areas -> Altium keep-out regions (all objects, all layers)."""
358
+ for z in sexpr.find(t, "zone"):
359
+ ko = sexpr.first(z, "keepout")
360
+ rules = {r[0]: r[1] for r in (ko or [])[1:] if isinstance(r, list)}
361
+ if not ko or "not_allowed" not in rules.values():
362
+ notes.append("zone without keep-out rules not converted"); continue
363
+ fp["regions"].append((_pts(sexpr.first(sexpr.first(z, "polygon"), "pts")), AL.KEEPOUT, 31))
364
+ notes.append("keep-out zone -> Keep-Out layer region (KiCad rules: " + ", ".join(f"{k} {v}" for k, v in rules.items()) + ")")
365
+
366
+
367
+ def _pad(fp, p, notes):
368
+ num, kind, shape = p[1], p[2], p[3]
369
+ at, size = sexpr.first(p, "at"), sexpr.first(p, "size")
370
+ x, y = _xy(at)
371
+ rot = float(at[3]) % 360 if len(at) > 3 else 0.0
372
+ w, h = _f(size), _f(size, 2)
373
+ layers = (sexpr.first(p, "layers") or ["layers"])[1:]
374
+ window = kind in ("smd", "connect") and not any(l.endswith(".Cu") for l in layers)
375
+ custom = _custom_shapes(p, x, y, rot, edges=not window) if shape == "custom" else None
376
+ if custom and window:
377
+ anchor = (sexpr.first(sexpr.first(p, "options") or [], "anchor") or [None, "circle"])[1]
378
+ polys, tracks = custom # a window with a custom shape: anchor + each primitive, on each layer
379
+ shapes = [_round_pts(x, y, w, h, rot) if anchor != "rect" else _rect_pts(x, y, w, h, rot)] + polys + [
380
+ _rect_pts((x1 + x2) / 2, (y1 + y2) / 2, math.hypot(x2 - x1, y2 - y1) + tw, tw, math.degrees(math.atan2(y2 - y1, x2 - x1)))
381
+ for x1, y1, x2, y2, tw in tracks]
382
+ wl = _window_layers(layers)
383
+ fp["regions"] += [(pts, layer) for layer in wl for pts in shapes]
384
+ if not wl:
385
+ _note(notes, f"pad {num}: no copper, paste or mask on {layers}; dropped")
386
+ return
387
+ if custom: # anchor pad + its shape as regions on the pad's copper, mask and paste
388
+ anchor = (sexpr.first(sexpr.first(p, "options") or [], "anchor") or [None, "circle"])[1]
389
+ n0 = len(fp["pads"])
390
+ _pad_core(fp, p, notes, num, kind, "rect" if anchor == "rect" else "circle", x, y, w, h, rot, layers)
391
+ if len(fp["pads"]) > n0:
392
+ _custom_regions(fp, fp["pads"][-1], custom, kind, layers, p, notes)
393
+ _note(notes, f"pad {num}: custom shape written as a {anchor} anchor pad plus regions on its copper, mask and paste layers")
394
+ return
395
+ if shape == "custom": # curves / arcs: bounding rectangle of anchor + primitives, in the pad frame
396
+ xs, ys = [-w / 2, w / 2], [-h / 2, h / 2]
397
+ for g in (sexpr.first(p, "primitives") or [])[1:]:
398
+ hw = _width(g) / 2
399
+ pts = []
400
+ if g[0] in ("gr_poly", "gr_line", "gr_rect", "gr_arc", "gr_curve"):
401
+ pts = _pts(sexpr.first(g, "pts"), lambda c: (float(c[1]), float(c[2]))) if sexpr.first(g, "pts") else \
402
+ [(float(c[1]), float(c[2])) for c in (sexpr.first(g, k) for k in ("start", "mid", "end")) if c]
403
+ elif g[0] == "gr_circle":
404
+ (cx, cy), (ex, ey) = [(float(c[1]), float(c[2])) for c in (sexpr.first(g, "center"), sexpr.first(g, "end"))]
405
+ r = math.hypot(ex - cx, ey - cy)
406
+ pts = [(cx - r, cy - r), (cx + r, cy + r)]
407
+ xs += [px + s * hw for px, _ in pts for s in (-1, 1)]; ys += [py + s * hw for _, py in pts for s in (-1, 1)]
408
+ ox, oy = (min(xs) + max(xs)) / 2, -(min(ys) + max(ys)) / 2
409
+ w, h = max(xs) - min(xs), max(ys) - min(ys)
410
+ dx, dy = _rot(ox, oy, rot)
411
+ x, y, shape = x + dx, y + dy, "rect"
412
+ _note(notes, f"pad {num}: custom shape (curved primitives) approximated by its bounding rectangle {w:.3f}x{h:.3f} mm")
413
+ elif shape == "trapezoid":
414
+ shape = "rect"; _note(notes, f"pad {num}: trapezoid written as rectangle")
415
+ _pad_core(fp, p, notes, num, kind, shape, x, y, w, h, rot, layers)
416
+
417
+
418
+ def _window_layers(layers):
419
+ """Paste / mask layers of a pad without copper -> Altium layers."""
420
+ return [l for n, l in (("F.Paste", AL.TOP_PASTE), ("F.Mask", AL.TOP_SOLDER), ("B.Paste", AL.BOTTOM_PASTE),
421
+ ("B.Mask", AL.BOTTOM_SOLDER)) if n in layers or "*." + n[2:] in layers or "F&B." + n[2:] in layers]
422
+
423
+
424
+ def _round_pts(cx, cy, w, h, deg=0.0, n=36):
425
+ """A circle (w == h) or stadium (oval) outline as a polygon, footprint frame (y up)."""
426
+ r = min(w, h) / 2
427
+ half = (max(w, h) - 2 * r) / 2
428
+ along = w >= h # the straight part runs along x (before rotation) when w > h
429
+ pts = []
430
+ for k in range(n):
431
+ a = 2 * math.pi * k / n
432
+ u, v = r * math.cos(a), r * math.sin(a)
433
+ if along:
434
+ u += half if u >= 0 else -half
435
+ else:
436
+ v += half if v >= 0 else -half
437
+ pts.append((cx + _rot(u, v, deg)[0], cy + _rot(u, v, deg)[1]))
438
+ return pts
439
+
440
+
441
+ def _pad_core(fp, p, notes, num, kind, shape, x, y, w, h, rot, layers):
442
+ if sexpr.first(p, "chamfer_ratio") and sexpr.first(p, "chamfer"):
443
+ _note(notes, f"pad {num}: chamfered corners written as {shape}")
444
+ rr = sexpr.first(p, "roundrect_rratio")
445
+ pad = {"designator": num, "x": x, "y": y, "w": w, "h": h, "rot": rot, "shape": {"oval": "round", "circle": "round"}.get(shape, shape),
446
+ "corner": min(100.0, float(rr[1]) * 200) if rr and shape == "roundrect" else 0, "paste": None}
447
+ if kind in ("thru_hole", "np_thru_hole"):
448
+ d = sexpr.first(p, "drill")
449
+ vals = [v for v in d[1:] if isinstance(v, str) and v != "oval"]
450
+ dw, dh = float(vals[0]), float(vals[1] if len(vals) > 1 else vals[0])
451
+ off = sexpr.first(d, "offset")
452
+ ox, oy = (float(off[1]), float(off[2])) if off else (0.0, 0.0)
453
+ pad.update(layer=AL.MULTI, plated=kind == "thru_hole", hole=min(dw, dh))
454
+ if ox or oy: # copper shifted from the hole, in the pad frame: Altium's offset from
455
+ pad["offset"] = (ox, -oy) # hole centre (y up), as KiCad's Altium importer reads it back
456
+ if kind == "np_thru_hole": # no copper ring on an NPTH: pad = hole
457
+ pad.update(w=dw, h=dh, shape="round", corner=0)
458
+ if abs(dw - dh) > 1e-9:
459
+ pad.update(hole_shape="slot", slot=max(dw, dh), hole_rot=0.0 if dw > dh else 90.0)
460
+ if rot % 90 == 0: # bake 90-degree steps in so pad and hole rotation are both axis-aligned
461
+ if rot % 180:
462
+ pad.update(w=pad["h"], h=pad["w"], hole_rot=90.0 - pad["hole_rot"])
463
+ if pad.get("offset"): # the offset turns with the pad (counter-clockwise, y up)
464
+ a, (u, v) = math.radians(rot), pad["offset"]
465
+ pad["offset"] = (u * math.cos(a) - v * math.sin(a), u * math.sin(a) + v * math.cos(a))
466
+ pad["rot"] = 0.0
467
+ else:
468
+ _note(notes, f"pad {num}: slot on a {rot:g} deg pad; hole rotation assumed relative to the pad")
469
+ masks = [l for l in layers if "Mask" in l]
470
+ if not masks: # tented both sides (e.g. thermal vias): close the mask opening
471
+ pad["mask"] = "none"
472
+ elif not {"B.Mask", "*.Mask", "F&B.Mask"} & set(masks) or not {"F.Mask", "*.Mask", "F&B.Mask"} & set(masks):
473
+ _note(notes, f"pad {num}: mask opening only on {masks} in KiCad, both sides in Altium")
474
+ if masks and _mask_margin(p) is not None:
475
+ pad["mask"] = _mask_margin(p)
476
+ fp["pads"].append(pad)
477
+ return
478
+ cu = [l for l in layers if l.endswith(".Cu")]
479
+ if not cu: # paste / mask aperture without copper: its shape on each of its layers
480
+ wl = _window_layers(layers)
481
+ if not wl:
482
+ _note(notes, f"pad {num}: no copper, paste or mask on {layers}; dropped"); return
483
+ for layer in wl:
484
+ if shape in ("circle", "oval"):
485
+ fp["regions"].append((_round_pts(x, y, w, h, rot), layer))
486
+ elif rot % 180 == 0 and layer in (AL.TOP_PASTE, AL.BOTTOM_PASTE):
487
+ fp["fills"].append((x - w / 2, y - h / 2, x + w / 2, y + h / 2, layer))
488
+ else:
489
+ fp["regions"].append((_rect_pts(x, y, w, h, rot), layer))
490
+ return
491
+ pad["layer"] = AL.TOP if "F.Cu" in cu else AL.BOTTOM
492
+ off = sexpr.first(sexpr.first(p, "drill") or [], "offset")
493
+ if off and (float(off[1]) or float(off[2])): # SMD shape offset: KiCad draws the copper at (at) + offset
494
+ dx, dy = _rot(float(off[1]), -float(off[2]), rot)
495
+ pad.update(x=x + dx, y=y + dy)
496
+ if pad["layer"] == AL.BOTTOM:
497
+ _note(notes, f"pad {num}: bottom-side SMD pad")
498
+ paste = "F.Paste" in layers or "B.Paste" in layers
499
+ pm = sexpr.first(p, "solder_paste_margin")
500
+ pad["paste"] = float(pm[1]) if paste and pm else None if paste else "none"
501
+ if not any("Mask" in l for l in layers):
502
+ pad["mask"] = "none"
503
+ elif _mask_margin(p) is not None:
504
+ pad["mask"] = _mask_margin(p)
505
+ fp["pads"].append(pad)
506
+
507
+
508
+ def _graphic(fp, kind, g, layer, notes=None):
509
+ w = _width(g)
510
+ if kind == "fp_circle":
511
+ (cx, cy), (ex, ey) = _xy(sexpr.first(g, "center")), _xy(sexpr.first(g, "end"))
512
+ r = math.hypot(ex - cx, ey - cy)
513
+ if _filled(g): # filled disc = arc of radius R/2 and width R
514
+ ro = r + w / 2
515
+ fp["arcs"].append((cx, cy, ro / 2, 0.0, 360.0, ro, layer))
516
+ else:
517
+ fp["arcs"].append((cx, cy, r, 0.0, 360.0, w, layer))
518
+ return
519
+ if kind == "fp_arc":
520
+ if sexpr.first(g, "mid"):
521
+ s_, m_, e_ = (_xy(sexpr.first(g, k)) for k in ("start", "mid", "end"))
522
+ try:
523
+ cx, cy, r, a0, a1 = _arc3(s_, m_, e_)
524
+ except ValueError: # zero-length or straight "arc" (KiCad's own library has some): a line or nothing
525
+ if s_ != e_:
526
+ fp["tracks"].append((*s_, *e_, w or 0.1, layer))
527
+ if notes is not None:
528
+ _note(notes, "degenerate arc (start, mid and end in a line) written as a line or dropped")
529
+ return
530
+ else: # KiCad 5: (start centre) (end arc start) (angle deg, clockwise on screen)
531
+ (cx, cy), (sx, sy) = _xy(sexpr.first(g, "start")), _xy(sexpr.first(g, "end"))
532
+ r, a = math.hypot(sx - cx, sy - cy), math.degrees(math.atan2(sy - cy, sx - cx))
533
+ ang = float(sexpr.first(g, "angle")[1])
534
+ a0, a1 = (a - ang, a) if ang > 0 else (a, a - ang)
535
+ fp["arcs"].append((cx, cy, r, a0 % 360, a1 % 360 or 360.0, w, layer))
536
+ return
537
+ if kind == "fp_poly":
538
+ pts = _pts(sexpr.first(g, "pts"))
539
+ else:
540
+ (x1, y1), (x2, y2) = _xy(sexpr.first(g, "start")), _xy(sexpr.first(g, "end"))
541
+ pts = [(x1, y1), (x2, y2)] if kind == "fp_line" else [(x1, y1), (x2, y1), (x2, y2), (x1, y2)]
542
+ if kind != "fp_line" and _filled(g):
543
+ fp["regions"].append((pts, layer))
544
+ if w: # stroke around the filled shape
545
+ fp["tracks"] += [(a[0], a[1], b[0], b[1], w, layer) for a, b in zip(pts, pts[1:] + pts[:1])]
546
+ else:
547
+ segs = zip(pts, pts[1:] + pts[:1]) if kind != "fp_line" else [pts]
548
+ fp["tracks"] += [(a[0], a[1], b[0], b[1], w or 0.1, layer) for a, b in segs]
549
+
550
+
551
+ # ---------------------------------------------------------------- 3D
552
+ STEP_PT = re.compile(r"CARTESIAN_POINT\s*\(\s*'[^']*'\s*,\s*\(\s*([-+0-9.Ee]+)\s*,\s*([-+0-9.Ee]+)\s*,\s*([-+0-9.Ee]+)\s*\)")
553
+
554
+
555
+ def model_info(t):
556
+ """First (model ...) of a footprint -> dict(path, offset mm, scale, rotate deg) or None."""
557
+ m = sexpr.first(t, "model")
558
+ if not m:
559
+ return None
560
+ xyz = lambda k, d: [float(v) for v in (sexpr.first(sexpr.first(m, k) or [], "xyz") or ["xyz", *d])[1:4]]
561
+ off = xyz("offset", (0, 0, 0))
562
+ if not sexpr.first(m, "offset") and sexpr.first(m, "at"): # KiCad 5: (at (xyz)) in inches
563
+ off = [v * 25.4 for v in xyz("at", (0, 0, 0))]
564
+ return {"path": m[1], "offset": off, "scale": xyz("scale", (1, 1, 1)), "rotate": xyz("rotate", (0, 0, 0))}
565
+
566
+
567
+ def model_url_path(path):
568
+ """'${KICAD9_3DMODEL_DIR}/Lib.3dshapes/X.wrl' -> 'Lib.3dshapes/X.step' in kicad-packages3D."""
569
+ m = re.search(r"([^/\\]+\.3dshapes)[/\\]([^/\\]+?)(?:\.wrl|\.step|\.stp)?$", path, re.I)
570
+ return f"{m.group(1)}/{m.group(2)}.step" if m else None
571
+
572
+
573
+ def step_extent(text, mi):
574
+ """Bounding box (xmin, ymin, zmin, xmax, ymax, zmax) in mm of a STEP file's points after KiCad's model transform
575
+ (scale, then rotate z, y, x by the negated angles, then offset)."""
576
+ unit = 25.4 if re.search(r"CONVERSION_BASED_UNIT\s*\(\s*'INCH'", text) else 1.0
577
+ (sx, sy, sz), (rx, ry, rz), off = mi["scale"], mi["rotate"], mi["offset"]
578
+ def tf(x, y, z):
579
+ x, y, z = x * sx, y * sy, z * sz
580
+ y, z = _rot(y, z, -rx); z, x = _rot(z, x, -ry); x, y = _rot(x, y, -rz)
581
+ return x + off[0], y + off[1], z + off[2]
582
+ pts = [tf(*(float(v) * unit for v in m.groups())) for m in STEP_PT.finditer(text)]
583
+ if not pts:
584
+ return None
585
+ return tuple(min(p[i] for p in pts) for i in range(3)) + tuple(max(p[i] for p in pts) for i in range(3))
586
+
587
+
588
+ def add_body(fp, t, http=None, height=None, step=False, notes=None, gen_step=False, spec=None, height_src=None, kind=None, names=()):
589
+ """3D body: the KiCad STEP model embedded (step=True, model without offset/rotation/scale), else with
590
+ gen_step=True a package-shaped STEP from step_gen (spec dict, else package_specs / the footprint name) when
591
+ there is no KiCad STEP to embed, else an extruded body over the fab outline (else courtyard, else pads).
592
+ height_src: the caller's height is part-specific and beats the default table (label for the notes).
593
+ kind / names: the part's kind ('R', 'C') and other names (KiCad ref, description) for package_default_height, the
594
+ last resort ('HEIGHT from package default ..., verify')."""
595
+ notes = [] if notes is None else notes
596
+ mi, text, src = model_info(t), None, None
597
+ if mi and http is not None and model_url_path(mi["path"]):
598
+ try:
599
+ text = fetch(http, "kicad-packages3D", model_url_path(mi["path"]))
600
+ if text is None:
601
+ notes.append(f"3D model {model_url_path(mi['path'])} not in kicad-packages3D")
602
+ except IOError as e: # blocked host / offline cache miss: the footprint is still good; the
603
+ notes.append(f"3D model {model_url_path(mi['path'])} not downloaded ({e}); body made here instead")
604
+ ext = step_extent(text, mi) if text else None
605
+ if ext:
606
+ h, src = round(ext[5], 3), "KiCad STEP model"
607
+ elif height and height_src:
608
+ h, src = height, height_src
609
+ else:
610
+ h = next((v for rx, v in DEFAULT_HEIGHT if re.search(rx, t[1])), None)
611
+ src = "default table" if h else None
612
+ if h is None and height:
613
+ h, src = height, "caller"
614
+ if h is None:
615
+ pd = package_default_height([t[1], *names], kind)
616
+ if pd:
617
+ h, src = pd[0], f"HEIGHT from package default ({pd[1]}), verify"
618
+ embeddable = bool(ext) and mi["rotate"] == [0, 0, 0] and mi["offset"] == [0, 0, 0] and mi["scale"] == [1, 1, 1]
619
+ if gen_step and not (step and embeddable) and (not ext or step):
620
+ from . import step_gen
621
+ try:
622
+ generic = src == "default table" # step_gen's per-family table is more specific (R vs C 0603)
623
+ data, info = step_gen.generate(spec or t[1], None if generic else h, name=fp["name"])
624
+ except ValueError as e:
625
+ notes.append(f"3D: no generated STEP ({e})")
626
+ else:
627
+ x0, y0, _, x1, y1, z1 = info["bbox"]
628
+ h, src = round(z1, 4), "step_gen table" if generic or not src else src # through-hole: the pin tops
629
+ fp["bodies"].append({"pts": [(x0, y0), (x1, y0), (x1, y1), (x0, y1)], "height": h,
630
+ "step": {"name": f"{AL.file_stem(fp['name'])}.step", "data": data.encode("ascii"), "rot": (0.0, 0.0, 0.0)}})
631
+ notes.append(f"3D: generated STEP ({info['family']} model from {'the package spec' if spec else 'package_specs / the footprint name'}"
632
+ f"), height {h} mm ({src})" + ("; KiCad STEP has an offset/rotation/scale" if ext else ""))
633
+ fp["height_mm"] = h
634
+ return fp
635
+ if not h:
636
+ raise ValueError(f"{t[1]}: no 3D height (no KiCad model, no DEFAULT_HEIGHT entry); pass --height")
637
+ if step and embeddable:
638
+ x0, y0, _, x1, y1, _ = ext
639
+ fp["bodies"].append({"pts": [(x0, y0), (x1, y0), (x1, y1), (x0, y1)], "height": h,
640
+ "step": {"name": Path(model_url_path(mi["path"])).name, "data": text.encode("utf-8"), "rot": (0.0, 0.0, 0.0)}})
641
+ notes.append(f"3D: embedded STEP {Path(model_url_path(mi['path'])).name}, height {h} mm")
642
+ else:
643
+ if step:
644
+ notes.append("3D: STEP not embedded (" + ("model has an offset/rotation/scale" if ext else "no STEP model") + "), extruded body instead")
645
+ outline = []
646
+ for layer in (AL.MECH13, AL.MECH15):
647
+ outline = [(x, y) for x1, y1, x2, y2, _, l in fp["tracks"] if l == layer for x, y in ((x1, y1), (x2, y2))]
648
+ outline += [pt for pts, l, *_ in fp["regions"] if l == layer for pt in pts]
649
+ outline += [(cx + r * math.cos(math.radians(a)), cy + r * math.sin(math.radians(a)))
650
+ for cx, cy, r, a0, a1, _, l in fp["arcs"] if l == layer for a in (a0, a1, (a0 + ((a1 - a0) % 360 or 360) / 2))]
651
+ if len(outline) >= 3:
652
+ kind = "fab" if layer == AL.MECH13 else "courtyard"
653
+ break
654
+ else:
655
+ outline, kind = [pt for p in fp["pads"] for pt in _rect_pts(p["x"], p["y"], p["w"], p["h"], p["rot"])], "pads"
656
+ fp["bodies"].append({"pts": _hull(outline), "height": h})
657
+ notes.append(f"3D: extruded body over the {kind} outline, height {h} mm ({src})")
658
+ fp["height_mm"] = h
659
+ return fp
660
+
661
+
662
+ # ---------------------------------------------------------------- symbols
663
+ def _u(v): # mm -> schematic units (10 mil)
664
+ return round(float(v) / 0.254, 5)
665
+
666
+
667
+ def _lw(w):
668
+ return 1 if w <= 0.3 else 2 if w <= 0.6 else 3
669
+
670
+
671
+ def _pin_text(s): # KiCad '~{RESET}' (KiCad 6: '~RESET~') overbar -> Altium 'R\E\S\E\T\'
672
+ if "~{" not in s and s != "~":
673
+ s = re.sub(r"~([^~]*)~?", lambda m: "~{" + m.group(1) + "}" if m.group(1) else "~", s)
674
+ return AL.overline(s)
675
+
676
+
677
+ def _props(chain):
678
+ out = {}
679
+ for s in reversed(chain): # parent first, derived symbols override
680
+ for p in sexpr.find(s, "property"):
681
+ out[p[1]] = p[2]
682
+ return out
683
+
684
+
685
+ def convert_symbol(chain, name=None, footprint=None, extra=(), pin_map=None, notes=None):
686
+ """See _convert_symbol. Raises ValueError for a symbol missing what it needs (a pin without (at ...), ...)."""
687
+ try:
688
+ return _convert_symbol(chain, name, footprint, extra, pin_map, notes)
689
+ except MALFORMED as e:
690
+ raise ValueError(f"malformed KiCad symbol {name or ''}: {type(e).__name__}: {e}".replace(" ", " ")) from e
691
+
692
+
693
+ def _convert_symbol(chain, name=None, footprint=None, extra=(), pin_map=None, notes=None):
694
+ """[symbol, parent, ...] -> altium_lib symbol dict. Units become Altium parts, De Morgan body styles
695
+ (`_U_2` sub-symbols) Altium display modes; unit 0 / style 0 items go into every part / mode."""
696
+ notes = [] if notes is None else notes
697
+ base, props = chain[-1], _props(chain)
698
+ if not props.get("Description") and props.get("ki_description"): # KiCad 7 keeps it in ki_description
699
+ props["Description"] = props["ki_description"]
700
+ hide_names = _hidden(sexpr.first(base, "pin_names") or [])
701
+ hide_nums = _hidden(sexpr.first(base, "pin_numbers") or [])
702
+ units = {}
703
+ for sub in sexpr.find(base, "symbol"):
704
+ u, style = (int(v) for v in sub[1].rsplit("_", 2)[1:])
705
+ style = int((sexpr.first(sub, "body_style") or [0, style])[1])
706
+ units.setdefault((u, style), []).extend(sub[2:])
707
+ n = max([u for u, _ in units if u > 0] or [1])
708
+ nm = max([st for _, st in units if st > 0] or [1])
709
+ prims, parts, modes, pins_at = [], [], [], {}
710
+ for (u, st), items in units.items():
711
+ for it in items:
712
+ if isinstance(it, list) and it[0] == "pin" and not _hidden(it):
713
+ at = sexpr.first(it, "at"); pins_at.setdefault((u, st, at[1], at[2]), True)
714
+ for u, st in sorted(units):
715
+ for it in units[(u, st)]:
716
+ if not isinstance(it, list):
717
+ continue
718
+ for part in (range(1, n + 1) if u == 0 else [u]):
719
+ for mode in (range(nm) if st == 0 else [st - 1]):
720
+ pr = _sym_prim(it, hide_names, hide_nums, pins_at, (u, st), pin_map or {}, notes)
721
+ if pr:
722
+ prims.append(pr); parts.append(part); modes.append(mode)
723
+ if n > 1:
724
+ notes.append(f"{n} units written as {n} Altium parts")
725
+ if nm > 1:
726
+ notes.append(f"De Morgan body style written as Altium display mode 1 ({nm} display modes)")
727
+ norm = [pr for pr, m in zip(prims, modes) if not m] # labels placed from the normal view
728
+ xs = [v for pr in norm if pr[0] != "pin" for v in _prim_xs(pr)[0]] + [pr[3] for pr in norm if pr[0] == "pin"]
729
+ ys = [v for pr in norm if pr[0] != "pin" for v in _prim_xs(pr)[1]] + [pr[4] for pr in norm if pr[0] == "pin"]
730
+ left, top, bot = min(xs or [0]), max(ys or [0]), min(ys or [0])
731
+ ref = props.get("Reference", "U")
732
+ keep = [("KiCad Footprint" if k == "Footprint" else k, props.get(k, "")) for k in ("Value", "Footprint", "Datasheet", "Description", "MPN")]
733
+ params = [(k, v) for k, v in keep + list(extra) if v and v != "~"]
734
+ params = list({k: (k, v) for k, v in params}.values()) # extra overrides
735
+ return {"name": name or chain[0][1], "description": props.get("Description", ""), "designator": ref if ref.endswith("?") else ref + "?",
736
+ "comment": "*", "footprint": footprint or (props.get("Footprint", "").split(":")[-1] or None), "prims": prims,
737
+ "prim_parts": parts, "parts": n, "prim_modes": modes, "modes": nm, "params": params, "label_at": ((left, top + 3), (left, bot - 8))}
738
+
739
+
740
+ def _prim_xs(pr):
741
+ k = pr[0]
742
+ if k == "rect":
743
+ return [pr[1], pr[3]], [pr[2], pr[4]]
744
+ if k in ("polyline", "polygon"):
745
+ return [p[0] for p in pr[1]], [p[1] for p in pr[1]]
746
+ if k in ("arc", "circle"):
747
+ return [pr[1] - pr[3], pr[1] + pr[3]], [pr[2] - pr[3], pr[2] + pr[3]]
748
+ return [pr[1]], [pr[2]]
749
+
750
+
751
+ def _sym_prim(it, hide_names, hide_nums, pins_at, unit, pin_map, notes):
752
+ k = it[0]
753
+ fill = (sexpr.first(sexpr.first(it, "fill") or [], "type") or [None, "none"])[1]
754
+ fill = {"background": "body", "outline": "line", "color": "body"}.get(fill)
755
+ lw = _lw(_width(it))
756
+ pt = lambda node: (_u(node[1]), _u(node[2]))
757
+ if k == "rectangle":
758
+ (x1, y1), (x2, y2) = pt(sexpr.first(it, "start")), pt(sexpr.first(it, "end"))
759
+ return ("rect", x1, y1, x2, y2, lw, fill)
760
+ if k in ("polyline", "bezier"):
761
+ pts = _pts(sexpr.first(it, "pts"), pt)
762
+ if k == "bezier":
763
+ _note(notes, "bezier drawn through its control points")
764
+ closed = len(pts) > 2 and pts[0] == pts[-1]
765
+ return ("polygon", pts[:-1] if closed else pts, lw, fill) if fill and len(pts) > 2 else ("polyline", pts, lw)
766
+ if k == "circle":
767
+ (cx, cy), r = pt(sexpr.first(it, "center")), _u(sexpr.first(it, "radius")[1])
768
+ return ("circle", cx, cy, r, lw, fill)
769
+ if k == "arc":
770
+ if sexpr.first(it, "mid"):
771
+ cx, cy, r, a0, a1 = _arc3(*(pt(sexpr.first(it, q)) for q in ("start", "mid", "end")))
772
+ else: # KiCad 6: (radius (at) (length) (angles a0 a1))
773
+ rad = sexpr.first(it, "radius")
774
+ (cx, cy), r = pt(sexpr.first(rad, "at")), _u(sexpr.first(rad, "length")[1])
775
+ a0, a1 = (float(v) for v in sexpr.first(rad, "angles")[1:3])
776
+ return ("arc", round(cx, 5), round(cy, 5), round(r, 5), round(a0 % 360, 3), round(a1 % 360 or 360, 3), lw)
777
+ if k == "text": # a symbol text's angle is in tenths of a degree (KiCad's parser: "LIB_TEXT
778
+ at = sexpr.first(it, "at") # is really decidegrees"), unlike every other (at ...)
779
+ return ("text", _u(at[1]), _u(at[2]), it[1], round(float(at[3]) / 900) % 4 if len(at) > 3 else 0)
780
+ if k == "pin":
781
+ etype, at = it[1], sexpr.first(it, "at")
782
+ x, y, a = float(at[1]), float(at[2]), int(float(at[3])) % 360 if len(at) > 3 else 0
783
+ length = float((sexpr.first(it, "length") or [0, 2.54])[1])
784
+ bx, by = x + length * math.cos(math.radians(a)), y + length * math.sin(math.radians(a)) # body end
785
+ num = (sexpr.first(it, "number") or ["", ""])[1]
786
+ name = _pin_text((sexpr.first(it, "name") or ["", ""])[1])
787
+ hidden, p = _hidden(it), {"num": pin_map.get(num, num), "name": name, "type": KTYPE.get(etype, "passive")}
788
+ shape = it[2] if isinstance(it[2], str) else "line"
789
+ if shape not in AL.PIN_SHAPE:
790
+ _note(notes, f"pin {num}: pin shape {shape} not converted")
791
+ elif shape != "line":
792
+ p["shape"] = shape
793
+ if shape == "edge_clock_high":
794
+ _note(notes, f"pin {num}: falling-edge clock written as clock + active-low input")
795
+ if hidden and (*unit, at[1], at[2]) in pins_at: # stacked on a visible pin: keep visible (Altium joins pins at one point)
796
+ p.update(show_name=False, show_num=False)
797
+ elif hidden:
798
+ p["hidden"] = True
799
+ if etype == "power_in":
800
+ p["net"] = name
801
+ else:
802
+ p.update(show_name=not hide_names and bool(name), show_num=not hide_nums)
803
+ gx, gy = round(_u(bx)), round(_u(by))
804
+ if abs(gx - _u(bx)) > 0.01 or abs(gy - _u(by)) > 0.01:
805
+ _note(notes, f"pin {num}: off the 10 mil grid, rounded")
806
+ if a not in SIDE:
807
+ raise ValueError(f"pin {num}: angle {a} not a multiple of 90")
808
+ return ("pin", p, SIDE[(a + 180) % 360], gx, gy, round(_u(length)))
809
+ if k not in ("property", "text_box", "body_style", "unit"):
810
+ _note(notes, f"symbol item {k} not converted")
811
+ return None
812
+
813
+
814
+ # ---------------------------------------------------------------- checks & driver
815
+ def pin_pad_check(sym, fp):
816
+ """Pin numbers without a pad and numbered pads without a pin (unnamed mechanical pads are ignored)."""
817
+ pins = {pr[1]["num"] for pr in sym["prims"] if pr[0] == "pin"}
818
+ pads = {p["designator"] for p in fp["pads"] if p["designator"]}
819
+ return sorted(pins - pads), sorted(pads - pins)
820
+
821
+
822
+ def import_part(spec, out, http, step=False, write=True, gen_step=True, layers=None):
823
+ """spec: {footprint, symbol, mpn?, footprint_as?, symbol_as?, height_mm?, pin_map?, note?} -> report dict.
824
+ gen_step: a step_gen model instead of an extruded box when there is no KiCad STEP to embed.
825
+ layers: the library rules' layers (rules.layers()); None = ecadlib's."""
826
+ rep = {"mpn": spec.get("mpn", ""), "footprint": spec["footprint"], "symbol": spec.get("symbol"), "notes": [], "ok": False}
827
+ try:
828
+ text, src = load_footprint(spec["footprint"], http)
829
+ fp, t = convert_footprint(text, spec.get("footprint_as"), src, rep["notes"])
830
+ add_body(fp, t, http, spec.get("height_mm"), step, rep["notes"], gen_step=gen_step)
831
+ rep["pcblib"] = str(AL.write_pcblib(Path(out) / "PcbLib" / f"{AL.file_stem(fp['name'])}.PcbLib", [fp], layers)) if write else None
832
+ rep["pads"] = len(fp["pads"])
833
+ if spec.get("symbol"):
834
+ chain, ssrc = load_symbol(spec["symbol"], http)
835
+ extra = [("MPN", spec["mpn"])] if spec.get("mpn") else []
836
+ sym = convert_symbol(chain, spec.get("symbol_as") or spec.get("mpn"), fp["name"], extra, spec.get("pin_map"), rep["notes"])
837
+ sym["description"] = sym["description"] or ssrc
838
+ rep["schlib"] = str(AL.write_schlib(Path(out) / "SchLib" / f"{AL.file_stem(sym['name'])}.SchLib", [sym])) if write else None
839
+ rep["pins"] = sum(pr[0] == "pin" for pr in sym["prims"])
840
+ if len(chain) > 1:
841
+ rep["notes"].append("symbol extends " + " -> ".join(s[1] for s in chain[1:]))
842
+ nopad, nopin = pin_pad_check(sym, fp)
843
+ if nopad:
844
+ raise ValueError(f"symbol pins without a footprint pad: {nopad}")
845
+ if nopin:
846
+ rep["notes"].append(f"pads without a symbol pin: {nopin}")
847
+ if spec.get("note"):
848
+ rep["notes"].append(spec["note"])
849
+ rep["ok"] = True
850
+ if write: # provenance for `upstream check-kicad` (never fails the part)
851
+ from .upstream import record
852
+ record(out, spec, rep, http, step, gen_step, layers)
853
+ except Exception as e: # report per part, keep going
854
+ rep["error"] = f"{type(e).__name__}: {e}"
855
+ return rep
856
+
857
+
858
+ def main(argv=None):
859
+ def opts(p, pre=""): # accepted before or after the subcommand
860
+ d = dict(default=argparse.SUPPRESS) if pre else {}
861
+ p.add_argument("-o", "--out", dest=pre + "out", **(d or {"default": "out/altium_models_kicad"}))
862
+ p.add_argument("--cache", dest=pre + "cache", **(d or {"default": "out/http_cache"}))
863
+ p.add_argument("--offline", dest=pre + "offline", action="store_true", help="only use the HTTP cache", **d)
864
+ p.add_argument("--step", dest=pre + "step", action="store_true", help="embed the KiCad STEP model instead of an extruded body", **d)
865
+ p.add_argument("--box", dest=pre + "box", action="store_true",
866
+ help="extruded box instead of a generated (step_gen) STEP model when no KiCad STEP is embedded", **d)
867
+ p.add_argument("--rules", dest=pre + "rules", help="library rules file: layers (default: ecadlib.toml found from -o)", **d)
868
+ ap = argparse.ArgumentParser(description="Convert KiCad library footprints/symbols to Altium PcbLib/SchLib")
869
+ opts(ap)
870
+ sub = ap.add_subparsers(dest="cmd", required=True)
871
+ b = sub.add_parser("batch", help="convert the parts listed in a JSON file")
872
+ b.add_argument("parts")
873
+ f = sub.add_parser("footprint", help="one footprint: Lib:Name or path.kicad_mod")
874
+ f.add_argument("ref"); f.add_argument("--as", dest="as_"); f.add_argument("--height", type=float, help="fallback height, mm")
875
+ s = sub.add_parser("symbol", help="one symbol: Lib:Name or path.kicad_sym[:Name]")
876
+ s.add_argument("ref"); s.add_argument("--as", dest="as_"); s.add_argument("--footprint", help="PcbLib footprint name to link")
877
+ s.add_argument("--param", action="append", default=[], help="extra parameter K=V")
878
+ for p in (b, f, s):
879
+ opts(p, "sub_")
880
+ a = ap.parse_args(argv)
881
+ for k in ("out", "cache", "offline", "step", "box", "rules"):
882
+ if hasattr(a, "sub_" + k):
883
+ setattr(a, k, getattr(a, "sub_" + k))
884
+ http = CachedHTTP(a.cache, offline=a.offline, timeout=60)
885
+ out = Path(a.out)
886
+ if a.cmd == "symbol":
887
+ notes = []
888
+ chain, src = load_symbol(a.ref, http)
889
+ sym = convert_symbol(chain, a.as_, a.footprint, [tuple(kv.split("=", 1)) for kv in a.param], None, notes)
890
+ (out / "SchLib").mkdir(parents=True, exist_ok=True)
891
+ path = AL.write_schlib(out / "SchLib" / f"{AL.file_stem(sym['name'])}.SchLib", [sym])
892
+ from .upstream import record
893
+ record(out, {"symbol": a.ref, "symbol_as": a.as_, "symbol_footprint": a.footprint, "params": a.param}, {"schlib": str(path), "notes": notes}, http)
894
+ print(f"{sym['name']}: {sum(pr[0] == 'pin' for pr in sym['prims'])} pins -> {path}" + "".join(f"\n {n}" for n in notes))
895
+ return
896
+ specs = json.loads(Path(a.parts).read_text(encoding="utf-8")) if a.cmd == "batch" else [{"footprint": a.ref, "footprint_as": a.as_, "height_mm": a.height}]
897
+ (out / "PcbLib").mkdir(parents=True, exist_ok=True); (out / "SchLib").mkdir(parents=True, exist_ok=True)
898
+ from . import rules as RU
899
+ lay = RU.layers(RU.load(getattr(a, "rules", None), start=out))
900
+ reps = [import_part(sp, out, http, a.step, gen_step=not a.box, layers=lay) for sp in specs]
901
+ for r in reps:
902
+ print(f"{'ok ' if r['ok'] else 'FAIL'} {r['mpn'] or r['footprint']:<26} {r.get('pads', '-'):>3} pads {r.get('pins', '-'):>3} pins "
903
+ f"{Path(r.get('pcblib') or '-').name} {Path(r.get('schlib') or '-').name}")
904
+ for n in r["notes"] + ([r["error"]] if r.get("error") else []):
905
+ print(f" {n}")
906
+ if a.cmd == "batch":
907
+ (out / "report.json").write_text(json.dumps(reps, indent=2), encoding="utf-8")
908
+ if not all(r["ok"] for r in reps):
909
+ sys.exit(f"{sum(not r['ok'] for r in reps)} of {len(reps)} failed")
910
+
911
+
912
+ if __name__ == "__main__":
913
+ main()