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,972 @@
1
+ """Librarian plan -> an EasyEDA Pro device library (.elibz): symbols, footprints and devices, read back pad by pad.
2
+
3
+ python -m ecadlib.easyeda_pro_export plan.json -o out/easyeda [--name ecadlib] [--kicad out/kicad]
4
+
5
+ The library is built the way kicad-export builds its own (the same parts, symbols and footprints; kicad-export runs
6
+ first, or --kicad names a folder it already wrote) and written natively, so nothing goes through an importer:
7
+
8
+ <lib>.elibz a zip: device.json (symbols, footprints and devices by uuid) + SYMBOL/<uuid>.esym +
9
+ FOOTPRINT/<uuid>.efoo, in EasyEDA Pro's line-per-record JSON format (the V2 format of .epro
10
+ projects and .elibz libraries; EasyEDA Pro opens it with File > Open / Import > EasyEDA Pro)
11
+ <lib>.3dshapes/ the STEP models (EasyEDA links 3D models from its cloud library by uuid, which a file
12
+ cannot carry: upload them in the 3D model manager and attach them to the footprints)
13
+ easyeda_export.json what was written, what had to be approximated and every difference found on reading back
14
+
15
+ Format, as EasyEDA Pro writes it and KiCad 8's importer reads it (units: footprints in mil, symbols in 10 mil, y up):
16
+ PAD ["PAD", id, 0, net, layer (1 top, 2 bottom, 12 multi-layer), number, x, y, angle, hole (null | ["ROUND", w, h]
17
+ | ["SLOT", w, h]), shape (["RECT", w, h, corner % of the shorter side] | ["ELLIPSE", w, h] | ["OVAL", w, h]
18
+ | ["POLY", path]), special [], offset x, offset y, hole angle relative to the pad, plated, pad type,
19
+ top / bottom solder mask expansion, top / bottom paste expansion (null = the board's rule;
20
+ -3937.008 mil = no opening, as EasyEDA's own libraries mark it), connect mode, spokes...]
21
+ POLY ["POLY", id, 0, net, layer, width, path, locked] FILL ["FILL", id, 0, net, layer, width, 0, [paths], 0]
22
+ path [x, y, "L", x, y, ..., "ARC", sweep (+ = counter-clockwise), x, y, ...] or ["CIRCLE", cx, cy, r]
23
+ PIN ["PIN", id, shown, electric, x, y (connection end), length, rotation (as KiCad's pin angle), color, shape, 0]
24
+ with ATTR children NAME, NUMBER and "Pin Type" (IN / OUT / BI)
25
+
26
+ Two independent readers check the result: this module's own reader turns every footprint back into a .kicad_mod and
27
+ ecadlib.parity compares it with the source pad by pad (number, position, size, shape, corner radius, drill, slot, side,
28
+ paste, solder mask and their margins, paste / mask windows, courtyard), and every symbol pin by pin; then, when a
29
+ KiCad 8 or newer is installed, KiCad's own EasyEDA Pro importer reads the .elibz and parity compares its footprints
30
+ too (KiCad's importer ignores mask / paste expansions and plating, so those stay with the first reader)."""
31
+ import argparse, json, math, re, shutil, sys, tempfile, uuid, zipfile
32
+ from pathlib import Path
33
+
34
+ from . import cad_model as CM, eagle_export as EG, kicad_export as X, parity as PY, sexpr
35
+
36
+ MIL = 0.0254 # mm per mil (footprints)
37
+ SYM = 0.254 # mm per symbol unit (10 mil)
38
+ NO_OPENING = -3937.008 # an expansion of -100 mm: no paste / mask opening at all
39
+ EDITOR = "2.2.36.7" # the EasyEDA Pro release whose files this mirrors
40
+ FP_LAYER = {"cu_f": 1, "cu_b": 2, "silk_f": 3, "silk_b": 4, "mask_f": 5, "mask_b": 6, "paste_f": 7, "paste_b": 8,
41
+ "fab_f": 9, "fab_b": 10, "edge": 11, "doc": 13, "crtyd_f": 48, "crtyd_b": 48}
42
+ KI_LAYER = {1: "F.Cu", 2: "B.Cu", 3: "F.SilkS", 4: "B.SilkS", 5: "F.Mask", 6: "B.Mask", 7: "F.Paste", 8: "B.Paste",
43
+ 9: "F.Fab", 10: "B.Fab", 11: "Edge.Cuts", 13: "Dwgs.User", 48: "F.CrtYd", 49: "Cmts.User"}
44
+ LAYER_NAMES = [(1, "TOP", "Top Layer"), (2, "BOTTOM", "Bottom Layer"), (3, "TOP_SILK", "Top Silkscreen Layer"),
45
+ (4, "BOT_SILK", "Bottom Silkscreen Layer"), (5, "TOP_SOLDER_MASK", "Top Solder Mask Layer"),
46
+ (6, "BOT_SOLDER_MASK", "Bottom Solder Mask Layer"), (7, "TOP_PASTE_MASK", "Top Paste Mask Layer"),
47
+ (8, "BOT_PASTE_MASK", "Bottom Paste Mask Layer"), (9, "TOP_ASSEMBLY", "Top Assembly Layer"),
48
+ (10, "BOT_ASSEMBLY", "Bottom Assembly Layer"), (11, "OUTLINE", "Board Outline Layer"),
49
+ (12, "MULTI", "Multi-Layer"), (13, "DOCUMENT", "Document Layer"), (14, "MECHANICAL", "Mechanical Layer"),
50
+ (47, "HOLE", "Hole Layer"), (48, "COMPONENT_SHAPE", "Component Shape Layer"),
51
+ (49, "COMPONENT_MARKING", "Component Marking Layer")]
52
+ ELECTRIC = {"input": (1, "IN"), "output": (2, "OUT"), "bidirectional": (3, "BI")} # the rest: undefined
53
+ PIN_SHAPE = {"inverted": 2, "clock": 1, "inverted_clock": 2}
54
+ FIELD_MAP = {"MPN": "Manufacturer Part", "Manufacturer Part Number": "Manufacturer Part", "Manufacturer": "Manufacturer",
55
+ "Datasheet": "Datasheet"}
56
+ SKIP_FIELDS = EG.SKIP_FIELDS | {"Description", "Reference"}
57
+ TAGS = {"parent_tag": [], "child_tag": []}
58
+
59
+
60
+ def uid(*parts):
61
+ """A stable 32-hex uuid (the same plan writes the same file)."""
62
+ return uuid.uuid5(uuid.NAMESPACE_URL, "ecadlib-easyeda:" + "/".join(parts)).hex
63
+
64
+
65
+ def m(v):
66
+ return round(v / MIL, 6) + 0.0
67
+
68
+
69
+ def s(v):
70
+ return round(v / SYM, 4) + 0.0
71
+
72
+
73
+ class Ids:
74
+ def __init__(self):
75
+ self.n = 0
76
+
77
+ def __call__(self):
78
+ self.n += 1
79
+ return f"e{self.n}"
80
+
81
+
82
+ # ---------------------------------------------------------------- footprint paths (mil, y up)
83
+ def _pt(p):
84
+ return [m(p[0]), m(p[1])]
85
+
86
+
87
+ def path_poly(pts, closed=True):
88
+ out = _pt(pts[0]) + ["L"]
89
+ for p in pts[1:] + (pts[:1] if closed else []):
90
+ out += _pt(p)
91
+ return out
92
+
93
+
94
+ def path_circle(c, r):
95
+ """A full circle as EasyEDA's CIRCLE path. Not two half arcs: KiCad's EasyEDA Pro importer reads arcs in a pad
96
+ outline as strokes only, so a round custom pad would lose its copper there."""
97
+ return ["CIRCLE"] + _pt(c) + [m(r)]
98
+
99
+
100
+ def path_round_rect(cx, cy, w, h, r, a):
101
+ """A w x h rectangle with corner radius r (exact arcs) centred on (cx, cy), turned by a, counter-clockwise."""
102
+ hw, hh = w / 2, h / 2
103
+ r = max(0.0, min(r, hw, hh))
104
+ T = lambda u, v: (lambda q: (cx + q[0], cy + q[1]))(CM.rot(u, v, a))
105
+ if r <= 1e-9:
106
+ return path_poly([T(hw, -hh), T(hw, hh), T(-hw, hh), T(-hw, -hh)])
107
+ seq = [((hw, -hh + r), None), ((hw, hh - r), None), ((hw - r, hh), 90), ((-hw + r, hh), None), ((-hw, hh - r), 90),
108
+ ((-hw, -hh + r), None), ((-hw + r, -hh), 90), ((hw - r, -hh), None), ((hw, -hh + r), 90)]
109
+ out = _pt(T(*seq[0][0]))
110
+ for (u, v), arc in seq[1:]:
111
+ if abs(u) < 1e-12 and abs(v) < 1e-12:
112
+ continue
113
+ out += (["ARC", arc] if arc else ["L"]) + _pt(T(u, v))
114
+ return _tidy(out)
115
+
116
+
117
+ def _tidy(path):
118
+ """Drop zero-length "L" steps a radius equal to a half side leaves (an oval's straight edge of length 0)."""
119
+ out, i, last = [], 0, None
120
+ while i < len(path):
121
+ t = path[i]
122
+ if isinstance(t, str):
123
+ k = 2 if t == "ARC" else 1
124
+ nxt = path[i + k:i + k + 2]
125
+ if t == "L" and last and len(nxt) == 2 and abs(nxt[0] - last[0]) < 1e-6 and abs(nxt[1] - last[1]) < 1e-6:
126
+ i += k + 2
127
+ continue
128
+ out += path[i:i + k] + nxt
129
+ last = nxt
130
+ i += k + 2
131
+ else:
132
+ out += path[i:i + 2]
133
+ last = path[i:i + 2]
134
+ i += 2
135
+ return out
136
+
137
+
138
+ def shape_path(shape, x, y, w, h, a, rratio=0.0):
139
+ if shape == "circle" or shape == "oval" and abs(w - h) < 1e-9:
140
+ return path_circle((x, y), w / 2)
141
+ r = min(w, h) / 2 if shape == "oval" else rratio * min(w, h) if shape == "roundrect" else 0.0
142
+ return path_round_rect(x, y, w, h, r, a)
143
+
144
+
145
+ def part_path(part):
146
+ if part[0] == "circle":
147
+ return path_circle(part[1], part[2])
148
+ if len(part) > 2 and part[2] > 0: # KiCad draws the outline too: the polygon grows by half its width
149
+ seg = CM.grown(part[1], part[2] / 2)
150
+ out = _pt(seg[0][0])
151
+ for i, (p, sweep) in enumerate(seg):
152
+ nxt = seg[(i + 1) % len(seg)][0]
153
+ out += (["ARC", round(sweep, 6)] if sweep else ["L"]) + _pt(nxt)
154
+ return _tidy(out)
155
+ return path_poly(part[1])
156
+
157
+
158
+ # ---------------------------------------------------------------- footprint writer
159
+ def _expansion(on, margin):
160
+ return NO_OPENING if not on else None if margin is None else m(margin)
161
+
162
+
163
+ def _pad_shape(p):
164
+ w, h = p["w"], p["h"]
165
+ if p["shape"] == "circle":
166
+ return ["ELLIPSE", m(w), m(w)]
167
+ if p["shape"] == "oval":
168
+ return ["OVAL", m(w), m(h)] if abs(w - h) > 1e-9 else ["ELLIPSE", m(w), m(w)]
169
+ if p["shape"] == "roundrect":
170
+ return ["RECT", m(w), m(h), round(p["rratio"] * 100, 4)]
171
+ return ["RECT", m(w), m(h), 0]
172
+
173
+
174
+ def _inside_poly(pt, poly):
175
+ x, y, inside = pt[0], pt[1], False
176
+ for (x1, y1), (x2, y2) in zip(poly, poly[1:] + poly[:1]):
177
+ if (y1 > y) != (y2 > y) and x < x1 + (y - y1) * (x2 - x1) / (y2 - y1):
178
+ inside = not inside
179
+ return inside
180
+
181
+
182
+ def _custom_shapes(p, notes):
183
+ """A custom pad -> (pad outline path, [extra copper paths]): the biggest primitive is the pad; the anchor goes
184
+ when that primitive covers it; anything else is copper next to the pad (EasyEDA pads are one outline)."""
185
+ parts = list(p["parts"])
186
+ anchor = ("circle", (p["x"], p["y"]), p["w"] / 2) if p.get("anchor", "circle") == "circle" else \
187
+ ("poly", CM.round_rect(p["x"], p["y"], p["w"], p["h"], 0.0, p["rot"]), 0.0)
188
+ area = lambda q: math.pi * q[2] ** 2 if q[0] == "circle" else abs(sum(a[0] * b[1] - b[0] * a[1] for a, b in zip(q[1], q[1][1:] + q[1][:1]))) / 2
189
+ if not parts:
190
+ return part_path(anchor), []
191
+ main = max(parts, key=area)
192
+ rest = [q for q in parts if q is not main]
193
+ apts = CM.disc(anchor[1], anchor[2], 30.0) if anchor[0] == "circle" else anchor[1]
194
+ covered = main[0] == "poly" and all(_inside_poly(q, main[1]) or any(math.hypot(q[0] - v[0], q[1] - v[1]) < 1e-6 for v in main[1]) for q in apts) \
195
+ or main[0] == "circle" and all(math.hypot(q[0] - main[1][0], q[1] - main[1][1]) <= main[2] + 1e-6 for q in apts)
196
+ if not covered:
197
+ rest.insert(0, anchor)
198
+ if rest:
199
+ notes.append(f"pad {p['num']}: custom pad with {len(rest) + 1} shapes written as the pad plus "
200
+ f"{len(rest)} copper fill{'s' if len(rest) > 1 else ''} overlapping it")
201
+ return part_path(main), [part_path(q) for q in rest]
202
+
203
+
204
+ def footprint_lines(fp, title, designator="U?"):
205
+ """A cad_model footprint -> (.efoo lines, approximations)."""
206
+ ids, approx = Ids(), []
207
+ lines = [["DOCTYPE", "FOOTPRINT", "1.8"], ["HEAD", {"editorVersion": EDITOR, "importFlag": 0}],
208
+ ["CANVAS", 0, 0, "mm", 10, 10, 0.03937, 0.03937, 1, 1, 0, 0, 5]]
209
+ lines += [["LAYER", n_, k, name, 3, "#ffffff", 1, "#7f7f7f", 1] for n_, k, name in LAYER_NAMES]
210
+ lines += [["ACTIVE_LAYER", 1]]
211
+ body = []
212
+ for p in fp["pads"]:
213
+ if p["kind"] == "window":
214
+ for lay in p["layers"]:
215
+ kind, side = lay.split("_")
216
+ layer = {"mask_f": 5, "mask_b": 6, "paste_f": 7, "paste_b": 8}[lay]
217
+ if p["shape"] == "custom" and p["parts"]:
218
+ paths = [shape_path("circle" if p.get("anchor", "circle") == "circle" else "rect", p["x"], p["y"], p["w"], p["h"], p["rot"])]
219
+ paths += [part_path(q) for q in p["parts"]]
220
+ else:
221
+ paths = [shape_path(p["shape"], p["x"], p["y"], p["w"], p["h"], p["rot"], p["rratio"])]
222
+ for path in paths: # one record each: nested outlines in one FILL would be holes
223
+ body.append(["FILL", ids(), 0, "", layer, 0, 0, [path], 0])
224
+ continue
225
+ x, y, a = p["x"], p["y"], p["rot"]
226
+ hole_a = 0.0 # the slot's own angle, needed once the pad angle is folded in
227
+ hole, layer, plated = None, 1 if p["side"] == "F" else 2, 1
228
+ mask = _expansion(p["mask"], p.get("mask_margin"))
229
+ paste = _expansion(p.get("paste") and p["kind"] == "smd", p.get("paste_margin"))
230
+ extra = []
231
+ if p["kind"] in ("tht", "npth"):
232
+ layer = 12
233
+ dx, dy = p["drill"]
234
+ hole = ["ROUND", m(dx), m(dx)] if abs(dx - dy) < 1e-9 else ["SLOT", m(dx), m(dy)]
235
+ plated = 1 if p["kind"] == "tht" else 0
236
+ if p["kind"] == "npth":
237
+ d = max(p["w"], p["h"], p["drill"][0], p["drill"][1])
238
+ shape = ["ELLIPSE", m(d), m(d)]
239
+ elif p["shape"] == "custom":
240
+ outline, extra = _custom_shapes(p, approx)
241
+ shape, a, hole_a = ["POLY", outline], 0.0, a
242
+ elif p["kind"] == "tht" and p["offset"] != (0.0, 0.0): # copper off the hole: the copper outline itself
243
+ ox, oy = CM.rot(*p["offset"], a)
244
+ shape = ["POLY", shape_path(p["shape"], x + ox, y + oy, p["w"], p["h"], a, p["rratio"])]
245
+ a, hole_a = 0.0, a
246
+ else:
247
+ shape = _pad_shape(p)
248
+ for note in p.get("approx") or []:
249
+ approx.append(f"pad {p['num']}: {note}")
250
+ if p["kind"] == "tht" and hole[0] == "ROUND" and shape[0] == "POLY":
251
+ pass
252
+ top_mask, bot_mask = (mask, mask) if layer == 12 else (mask, None) if layer == 1 else (None, mask)
253
+ top_paste, bot_paste = (paste, None) if layer == 1 else (None, paste) if layer == 2 else (NO_OPENING, NO_OPENING)
254
+ pid = ids()
255
+ body.append(["PAD", pid, 0, "", layer, p["num"], m(x), m(y), round(a % 360, 4) + 0.0, hole, shape, [], 0, 0,
256
+ round(hole_a % 360, 4) + 0.0, plated, 0, top_mask, bot_mask, top_paste, bot_paste, 0, None, None, None, None, []])
257
+ for path in extra:
258
+ body.append(["FILL", ids(), 0, "", 1 if layer != 2 else 2, 0, 0, [path], 0])
259
+ if layer == 12:
260
+ body.append(["FILL", ids(), 0, "", 2, 0, 0, [path], 0])
261
+ for g in fp["graphics"]:
262
+ layer = FP_LAYER.get(g["layer"])
263
+ if not layer:
264
+ continue
265
+ w = m(g["w"])
266
+ if g["kind"] == "line":
267
+ body.append(["POLY", ids(), 0, "", layer, w, _pt(g["a"]) + ["L"] + _pt(g["b"]), 0])
268
+ elif g["kind"] == "arc":
269
+ body.append(["POLY", ids(), 0, "", layer, w, _pt(g["a"]) + ["ARC", round(g["sweep"], 4)] + _pt(g["b"]), 0])
270
+ elif g["kind"] == "circle":
271
+ if g["fill"]:
272
+ body.append(["FILL", ids(), 0, "", layer, w, 0, [["CIRCLE", m(g["c"][0]), m(g["c"][1]), m(g["r"] + g["w"] / 2)]], 0])
273
+ else:
274
+ body.append(["POLY", ids(), 0, "", layer, w, ["CIRCLE", m(g["c"][0]), m(g["c"][1]), m(g["r"])], 0])
275
+ elif g["kind"] == "poly" and len(g["pts"]) > 2:
276
+ if g["fill"]:
277
+ body.append(["FILL", ids(), 0, "", layer, w, 0, [path_poly(g["pts"])], 0])
278
+ else:
279
+ body.append(["POLY", ids(), 0, "", layer, w, path_poly(g["pts"]), 0])
280
+ rx, ry = fp["texts"].get("reference", (0.0, 1.5))
281
+ body.append(["ATTR", ids(), 0, "", 3, None, None, "Footprint", title, 0, 0, "default", 45, 6, 0, 0, 3, 0, 0, 0, 0, 0])
282
+ body.append(["ATTR", ids(), 0, "", 3, m(rx), m(ry), "Designator", designator, 0, 1, "default", 39.37, 5.9, 0, 0, 5, 0, 0, 0, 0, 0])
283
+ return lines + body, approx
284
+
285
+
286
+ # ---------------------------------------------------------------- symbol writer
287
+ def pin_name(name):
288
+ """KiCad ~{X} overbars -> X# (EasyEDA pin names are plain text; # is the usual active-low mark)."""
289
+ return CM.pin_text(name, lambda t: t + "#")
290
+
291
+
292
+ def symbol_lines(cs, title, designator):
293
+ """A cad_model symbol -> (.esym lines, approximations)."""
294
+ ids, approx = Ids(), []
295
+ lines = [["DOCTYPE", "SYMBOL", "1.1"], None, ["LINESTYLE", "st1", None, None, None, None],
296
+ ["FONTSTYLE", "st2", None, None, None, None, None, None, None, None, 0, 0]]
297
+ common = cs["units"].get(0, {"pins": [], "shapes": []})
298
+ units = sorted(u for u in cs["units"] if u > 0) or [1]
299
+ lost_types = set()
300
+ if any(pin_name(p["name"]) != p["name"] and p["name"] != "~" for u in cs["units"].values() for p in u["pins"]):
301
+ approx.append("pin name overbars written as a trailing # (EasyEDA pin names are plain text)")
302
+ for i, u in enumerate(units):
303
+ d = cs["units"].get(u, {"pins": [], "shapes": []})
304
+ pins, shapes = common["pins"] + d["pins"], common["shapes"] + d["shapes"]
305
+ xs = [p["x"] for p in pins] + [q[0] for sh in shapes for q in _shape_pts(sh)]
306
+ ys = [p["y"] for p in pins] + [q[1] for sh in shapes for q in _shape_pts(sh)]
307
+ bbox = [s(min(xs)), s(max(ys)), s(max(xs)), s(min(ys))] if xs else [0, 0, 0, 0]
308
+ lines.append(["PART", f"{title}.{i + 1}", {"BBOX": bbox}])
309
+ if i == 0:
310
+ lines.append(["ATTR", ids(), "", "Symbol", title, 0, 0, None, None, 0, "st2", 0])
311
+ lines.append(["ATTR", ids(), "", "Designator", designator, 0, 0, None, None, 0, "st2", 0])
312
+ for sh in shapes:
313
+ lines += _sym_shape(sh, ids)
314
+ for p in pins:
315
+ el, ptype = ELECTRIC.get(p["type"], (0, None))
316
+ if p["type"] not in ELECTRIC and p["type"] != "no_connect":
317
+ lost_types.add(p["type"])
318
+ pid = ids()
319
+ lines.append(["PIN", pid, 0 if p["hidden"] else 1, el, s(p["x"]), s(p["y"]), s(p["length"]), int(round(p["angle"])) % 360,
320
+ None, PIN_SHAPE.get(p["shape"], 0), 0])
321
+ lines.append(["ATTR", ids(), pid, "NAME", pin_name(p["name"]), 0, 0 if cs["pin_names_hidden"] else 1, None, None, 0, "st2", 0])
322
+ lines.append(["ATTR", ids(), pid, "NUMBER", p["num"], 0, 0 if cs["pin_numbers_hidden"] else 1, None, None, 0, "st2", 0])
323
+ if ptype:
324
+ lines.append(["ATTR", ids(), pid, "Pin Type", ptype, 0, 0, None, None, 0, "st2", 0])
325
+ if p["type"] == "no_connect":
326
+ lines.append(["ATTR", ids(), pid, "NO_CONNECT", "yes", 0, 0, None, None, 0, "st2", 0])
327
+ if lost_types:
328
+ approx.append(f"pin types {', '.join(sorted(lost_types))} written as EasyEDA's undefined type "
329
+ "(EasyEDA pins are input, output, bidirectional or undefined)")
330
+ lines[1] = ["HEAD", {"symbolType": 2, "originX": 0, "originY": 0, "version": EDITOR, "maxId": ids.n}]
331
+ return lines, approx
332
+
333
+
334
+ def _shape_pts(sh):
335
+ k = sh["kind"]
336
+ if k == "rect":
337
+ return [sh["a"], sh["b"]]
338
+ if k == "poly":
339
+ return sh["pts"]
340
+ if k == "circle":
341
+ return [(sh["c"][0] - sh["r"], sh["c"][1] - sh["r"]), (sh["c"][0] + sh["r"], sh["c"][1] + sh["r"])]
342
+ if k == "arc":
343
+ return [sh["a"], sh["b"]]
344
+ return []
345
+
346
+
347
+ def _sym_shape(sh, ids):
348
+ k = sh["kind"]
349
+ if k == "rect":
350
+ return [["RECT", ids(), s(sh["a"][0]), s(sh["a"][1]), s(sh["b"][0]), s(sh["b"][1]), 0, 0, 0, "st1", 0]]
351
+ if k == "poly" and len(sh["pts"]) > 1:
352
+ pts = [v for q in sh["pts"] for v in (s(q[0]), s(q[1]))]
353
+ return [["POLY", ids(), pts, 0, "st1", 0]]
354
+ if k == "circle":
355
+ return [["CIRCLE", ids(), s(sh["c"][0]), s(sh["c"][1]), s(sh["r"]), "st1", 0]]
356
+ if k == "arc":
357
+ mid = CM.arc_mid(sh["a"], sh["c"], sh["sweep"])
358
+ return [["ARC", ids(), s(sh["a"][0]), s(sh["a"][1]), s(mid[0]), s(mid[1]), s(sh["b"][0]), s(sh["b"][1]), "st1", 0]]
359
+ if k == "text" and sh["text"].strip():
360
+ return [["TEXT", ids(), s(sh["at"][0]), s(sh["at"][1]), sh.get("angle", 0.0), sh["text"], "st2", 0]]
361
+ return []
362
+
363
+
364
+ # ---------------------------------------------------------------- library
365
+ def device_attrs(fields, sym_id, fp_id, designator, value):
366
+ attrs = {"Symbol": sym_id or "", "Footprint": fp_id or "", "Name": value, "Designator": designator,
367
+ "Add into BOM": "yes", "Convert to PCB": "yes"}
368
+ for k, v in fields.items():
369
+ if k in SKIP_FIELDS or not str(v).strip() or str(v) == "~":
370
+ continue
371
+ if re.fullmatch(r"(?i)lcsc( part( (number|#))?)?|lcsc_?pn|jlcpcb part #?", k):
372
+ attrs["Supplier"], attrs["Supplier Part"] = "LCSC", str(v)
373
+ continue
374
+ attrs[FIELD_MAP.get(k, k)] = str(v)
375
+ return attrs
376
+
377
+
378
+ def _jl(lines):
379
+ return "\n".join(json.dumps(l, ensure_ascii=False, separators=(",", ":")) for l in lines) + "\n"
380
+
381
+
382
+ def write_elibz(path, symbols, footprints, devices):
383
+ """symbols / footprints: {uuid: (title, lines)}; devices: {uuid: (title, description, attributes)}."""
384
+ meta = {
385
+ "symbols": {u: {"source": "", "desc": "", "tags": TAGS, "custom_tags": "", "title": t, "version": "", "type": 2}
386
+ for u, (t, _) in symbols.items()},
387
+ "footprints": {u: {"source": "", "desc": "", "tags": TAGS, "custom_tags": "", "title": t, "version": "", "type": 4}
388
+ for u, (t, _) in footprints.items()},
389
+ "devices": {u: {"source": "", "description": d, "tags": TAGS, "custom_tags": "", "title": t, "version": "",
390
+ "images": [], "attributes": a} for u, (t, d, a) in devices.items()},
391
+ }
392
+ def put(z, name, text): # a fixed date: the same library is the same file (clean git diffs)
393
+ info = zipfile.ZipInfo(name, (2020, 1, 1, 0, 0, 0))
394
+ info.compress_type = zipfile.ZIP_DEFLATED
395
+ z.writestr(info, text)
396
+
397
+ with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as z:
398
+ put(z, "device.json", json.dumps(meta, ensure_ascii=False, indent=1))
399
+ for u, (_, lines) in symbols.items():
400
+ put(z, f"SYMBOL/{u}.esym", _jl(lines))
401
+ for u, (_, lines) in footprints.items():
402
+ put(z, f"FOOTPRINT/{u}.efoo", _jl(lines))
403
+
404
+
405
+ # ---------------------------------------------------------------- reader
406
+ def read_elibz(path):
407
+ """An .elibz / .epro -> {"symbols": {title: lines}, "footprints": {title: lines}, "devices": {title: attrs}}."""
408
+ with zipfile.ZipFile(path) as z:
409
+ names = z.namelist()
410
+ meta_name = next(n_ for n_ in ("device.json", "project.json", "footprint.json", "symbol.json") if n_ in names)
411
+ meta = json.loads(z.read(meta_name).decode("utf-8-sig"))
412
+ out = {"symbols": {}, "footprints": {}, "devices": {}, "ids": {}}
413
+ for kind, folder, ext in (("symbols", "SYMBOL", "esym"), ("footprints", "FOOTPRINT", "efoo")):
414
+ for u, d in (meta.get(kind) or {}).items():
415
+ fn = f"{folder}/{u}.{ext}"
416
+ if fn in names:
417
+ out[kind][d.get("display_title") or d["title"]] = parse_lines(z.read(fn).decode("utf-8-sig"))
418
+ out["ids"][u] = d.get("display_title") or d["title"]
419
+ for u, d in (meta.get("devices") or {}).items():
420
+ out["devices"][d.get("display_title") or d["title"]] = dict(d.get("attributes") or {}, _description=d.get("description", ""))
421
+ return out
422
+
423
+
424
+ def parse_lines(text):
425
+ return [json.loads(l) for l in text.splitlines() if l.strip()]
426
+
427
+
428
+ def path_parts(path):
429
+ """A path -> [(points (mm, y up), closed-able, is_circle (cx, cy, r) or None)] with arcs as points."""
430
+ out, cur, prev, i = [], None, None, 0
431
+ toks = list(path)
432
+ while i < len(toks):
433
+ t = toks[i]
434
+ if isinstance(t, str):
435
+ if t == "CIRCLE":
436
+ cx, cy, r = (float(v) * MIL for v in toks[i + 1:i + 4])
437
+ out.append((CM.disc((cx, cy), r, 2.0), (cx, cy, r)))
438
+ i += 4
439
+ cur = None
440
+ continue
441
+ if t == "R":
442
+ x0, y0, w, h, ang = (float(v) for v in toks[i + 1:i + 6])
443
+ cr = float(toks[i + 6]) if i + 6 < len(toks) and not isinstance(toks[i + 6], str) else 0.0
444
+ cx, cy = CM.rot(w / 2, -h / 2, ang)
445
+ out.append((CM.round_rect((x0 + cx) * MIL, (y0 + cy) * MIL, w * MIL, h * MIL, cr * MIL, ang, 2.0), None))
446
+ i += 7 if cr else 6
447
+ cur = None
448
+ continue
449
+ if t in ("ARC", "CARC"):
450
+ sweep = float(toks[i + 1])
451
+ end = (float(toks[i + 2]) * MIL, float(toks[i + 3]) * MIL)
452
+ c = _arc_centre(prev, end, sweep)
453
+ cur += CM.arc_points(prev, c, sweep, 2.0)[1:]
454
+ prev = end
455
+ cur[-1] = end
456
+ i += 4
457
+ continue
458
+ if t == "L":
459
+ i += 1
460
+ while i + 1 < len(toks) and not isinstance(toks[i], str):
461
+ prev = (float(toks[i]) * MIL, float(toks[i + 1]) * MIL)
462
+ cur.append(prev)
463
+ i += 2
464
+ continue
465
+ i += 1
466
+ continue
467
+ prev = (float(toks[i]) * MIL, float(toks[i + 1]) * MIL)
468
+ cur = [prev]
469
+ out.append((cur, None))
470
+ i += 2
471
+ return out
472
+
473
+
474
+ def _arc_centre(a, b, sweep):
475
+ """Centre of the arc from a to b turning `sweep` degrees (+ = counter-clockwise, y up)."""
476
+ dx, dy = b[0] - a[0], b[1] - a[1]
477
+ d = math.hypot(dx, dy)
478
+ mx, my = (a[0] + b[0]) / 2, (a[1] + b[1]) / 2
479
+ h = d / 2 / math.tan(math.radians(sweep) / 2)
480
+ return mx - dy / d * h, my + dx / d * h
481
+
482
+
483
+ def _k(p):
484
+ """(x, y) mm y up -> KiCad text."""
485
+ return f"{p[0]:.6f} {-p[1] + 0.0:.6f}"
486
+
487
+
488
+ def footprint_to_kicad(lines, name):
489
+ """.efoo lines -> .kicad_mod text, mask / paste expansions and all: the first reader."""
490
+ out = [f'(footprint "{name}" (version 20221018) (generator ecadlib_easyeda_reader) (layer "F.Cu")']
491
+ pads, fills, polys = [], [], []
492
+ owner = None # copper fills right after a polygon pad: the rest of that pad's copper
493
+ for l in lines:
494
+ if not l:
495
+ continue
496
+ if l[0] == "FILL" and owner and l[4] in (1, 2):
497
+ fills.append((l, owner))
498
+ continue
499
+ owner = l[1] if l[0] == "PAD" and l[10] and l[10][0] == "POLY" else None
500
+ if l[0] == "PAD":
501
+ pads.append(l)
502
+ elif l[0] == "FILL":
503
+ fills.append((l, None))
504
+ elif l[0] == "POLY":
505
+ polys.append(l)
506
+ elif l[0] == "ATTR" and l[7] == "Designator" and isinstance(l[5], (int, float)):
507
+ out.append(f' (fp_text reference "REF**" (at {_k((l[5] * MIL, l[6] * MIL))}) (layer "F.SilkS") '
508
+ '(effects (font (size 1 1) (thickness 0.15))))')
509
+ custom = {}
510
+ for l in pads:
511
+ _, pid, _, _, layer, num, x, y, ang, hole, shape = l[:11]
512
+ plated, mask = l[15], [l[17], l[18]]
513
+ paste = [l[19], l[20]]
514
+ x, y = x * MIL, y * MIL
515
+ if hole:
516
+ kind = "thru_hole" if plated else "np_thru_hole"
517
+ side_mask = mask[0]
518
+ layers = '"*.Cu" "*.Mask"' if side_mask != NO_OPENING else '"*.Cu"'
519
+ if not plated:
520
+ layers = '"*.Mask"' if side_mask != NO_OPENING else ""
521
+ else:
522
+ kind = "smd"
523
+ sd = "F" if layer == 1 else "B"
524
+ i_ = 0 if layer == 1 else 1
525
+ side_mask = mask[i_]
526
+ layers = f'"{sd}.Cu"' + (f' "{sd}.Mask"' if mask[i_] != NO_OPENING else "") + (f' "{sd}.Paste"' if paste[i_] != NO_OPENING else "")
527
+ extra = ""
528
+ if side_mask not in (None, NO_OPENING):
529
+ extra += f" (solder_mask_margin {side_mask * MIL:.6f})"
530
+ if not hole and paste[i_] not in (None, NO_OPENING):
531
+ extra += f" (solder_paste_margin {paste[i_] * MIL:.6f})"
532
+ drill = ""
533
+ if hole:
534
+ dx, dy = hole[1] * MIL, hole[2] * MIL
535
+ rel = (l[14] or 0) if isinstance(l[14], (int, float)) else 0
536
+ if abs(((rel + 45) % 180) - 45) >= 45:
537
+ dx, dy = dy, dx
538
+ drill = f" (drill {dx:.6f})" if hole[0] == "ROUND" or abs(dx - dy) < 1e-9 else f" (drill oval {dx:.6f} {dy:.6f})"
539
+ sh = shape[0]
540
+ if sh == "POLY":
541
+ prims = path_parts(shape[1])
542
+ custom[pid] = (x, y, layer, [])
543
+ body = " ".join(_prim(q, x, y) for q in prims)
544
+ out.append(f' (pad "{num}" {kind} custom (at {_k((x, y))} 0) (size @@{pid}size@@){drill} (layers {layers}){extra} '
545
+ f'(options (clearance outline) (anchor rect)) (primitives {body} @@{pid}@@))')
546
+ custom[pid][3].extend(q[0] for q in prims)
547
+ continue
548
+ w, h = shape[1] * MIL, shape[2] * MIL
549
+ if sh == "ELLIPSE":
550
+ kshape = "circle" if abs(w - h) < 1e-9 else "oval"
551
+ elif sh == "OVAL":
552
+ kshape = "oval"
553
+ elif sh == "RECT" and len(shape) > 3 and shape[3]:
554
+ kshape = f"roundrect (roundrect_rratio {shape[3] / 100:.6f})"
555
+ else:
556
+ kshape = "rect"
557
+ kind_shape = kshape.split(" ")[0]
558
+ rr = kshape[len(kind_shape):]
559
+ out.append(f' (pad "{num}" {kind} {kind_shape} (at {_k((x, y))} {ang:g}) (size {w:.6f} {h:.6f}){drill}'
560
+ f' (layers {layers}){rr}{extra})')
561
+ for l, own in fills:
562
+ layer, paths = l[4], l[7]
563
+ if paths and not isinstance(paths[0], list):
564
+ paths = [paths]
565
+ for path in paths:
566
+ for pts, circle in path_parts(path):
567
+ if own in custom:
568
+ if layer == 1 or custom[own][2] == 2: # a through-hole pad's extras come once per side
569
+ custom[own][3].append(pts)
570
+ continue
571
+ lay = KI_LAYER.get(layer)
572
+ if lay:
573
+ out.append(f' (fp_poly (pts {" ".join(f"(xy {_k(q)})" for q in pts)}) (stroke (width 0) (type solid)) '
574
+ f'(fill solid) (layer "{lay}"))')
575
+ for l in polys:
576
+ layer, width, path = l[4], l[5] * MIL, l[6]
577
+ lay = KI_LAYER.get(layer)
578
+ if not lay:
579
+ continue
580
+ for pts, circle in path_parts(path):
581
+ if circle:
582
+ out.append(f' (fp_circle (center {_k(circle[:2])}) (end {_k((circle[0] + circle[2], circle[1]))}) '
583
+ f'(stroke (width {width:.6f}) (type solid)) (fill none) (layer "{lay}"))')
584
+ continue
585
+ for a, b in zip(pts, pts[1:]):
586
+ out.append(f' (fp_line (start {_k(a)}) (end {_k(b)}) (stroke (width {width:.6f}) (type solid)) (layer "{lay}"))')
587
+ text = "\n".join(out) + "\n)\n"
588
+ for pid, (x, y, _, shapes) in custom.items(): # the anchor: the box round the copper, centred on the pad
589
+ first = len(path_parts(next(l[10][1] for l in pads if l[1] == pid)))
590
+ more = shapes[first:]
591
+ w = 2 * max(abs(q[0] - x) for pts in shapes for q in pts)
592
+ h = 2 * max(abs(q[1] - y) for pts in shapes for q in pts)
593
+ text = text.replace(f" @@{pid}@@", "".join(" " + _prim((q, None), x, y) for q in more))
594
+ text = text.replace(f"@@{pid}size@@", f"{w:.6f} {h:.6f}")
595
+ return text
596
+
597
+
598
+ def _prim(q, x, y):
599
+ pts, circle = q
600
+ if circle:
601
+ return (f"(gr_circle (center {_k((circle[0] - x, circle[1] - y))}) (end {_k((circle[0] - x + circle[2], circle[1] - y))}) "
602
+ "(width 0) (fill yes))")
603
+ return f'(gr_poly (pts {" ".join(f"(xy {_k((p[0] - x, p[1] - y))})" for p in pts)}) (width 0) (fill yes))'
604
+
605
+
606
+ def symbol_pins(lines):
607
+ """.esym lines -> {number: {"name", "type", "x", "y", "angle", "length", "hidden", "unit"}} (mm, y up)."""
608
+ pins, attrs, unit, units = {}, {}, 0, {}
609
+ for l in lines:
610
+ if l[0] == "PART":
611
+ unit += 1
612
+ elif l[0] == "PIN":
613
+ units[l[1]] = (unit, l)
614
+ elif l[0] == "ATTR" and l[2]:
615
+ attrs.setdefault(l[2], {})[l[3]] = l[4]
616
+ for pid, (u, l) in units.items():
617
+ a = attrs.get(pid, {})
618
+ typ = {"IN": "input", "OUT": "output", "BI": "bidirectional"}.get(a.get("Pin Type"), "unspecified")
619
+ if "NO_CONNECT" in a:
620
+ typ = "no_connect"
621
+ pins[str(a.get("NUMBER", ""))] = {"name": a.get("NAME", ""), "type": typ, "x": l[4] * SYM, "y": l[5] * SYM,
622
+ "angle": float(l[7]) % 360, "length": l[6] * SYM, "hidden": not l[2], "unit": u,
623
+ "electric": l[3]}
624
+ return pins
625
+
626
+
627
+ def symbol_attrs(lines):
628
+ return {l[3]: l[4] for l in lines if l[0] == "ATTR" and not l[2]}
629
+
630
+
631
+ # ---------------------------------------------------------------- compare
632
+ def compare_footprint(src_text, efoo_lines, approx=()):
633
+ """Source .kicad_mod vs the written .efoo read back (as a .kicad_mod): parity, pad by pad."""
634
+ back = footprint_to_kicad(efoo_lines, "back")
635
+ k = EG.kicad_canon(src_text)
636
+ a = PY.kicad_footprint(back)
637
+ k["models"] = a["models"] = []
638
+ a["bodies"] = a["embedded"] = 0
639
+ a["pads"] = EG.order_pads(a["pads"])
640
+ _note_approx(a, approx)
641
+ copper = _adopt_polygon_pads(k, a, src_text, back)
642
+ diffs = PY.compare_footprints(k, a) + copper
643
+ if any(d["level"] == "error" and EG._mask_paste(d) for d in diffs):
644
+ diffs = EG._check_cover(diffs, _openings(src_text), _openings(back), "EasyEDA")
645
+ return EG._relabel(diffs, "EasyEDA")
646
+
647
+
648
+ def _openings(text):
649
+ return CM.openings(CM.footprint(text))
650
+
651
+
652
+ def _copper_cover(src_text, back_text, importer=False):
653
+ """Pad copper per side, source vs read back, on a fine grid -> diffs (none when the copper is the same).
654
+ importer: the read-back is KiCad's EasyEDA Pro importer, which makes extra copper fills footprint drawings rather
655
+ than pad copper and every hole plated, so filled copper drawings count on both sides and a plated pad where the
656
+ source has a non-plated hole is left out."""
657
+ src, back = CM.footprint(src_text), CM.footprint(back_text)
658
+ npth = [(p["x"], p["y"]) for p in src["pads"] if p["kind"] == "npth"]
659
+
660
+ def drawn(fp, side):
661
+ if not importer:
662
+ return []
663
+ out = []
664
+ for g in fp["graphics"]:
665
+ if g["layer"] == f"cu_{side.lower()}" and g.get("fill"):
666
+ if g["kind"] == "poly":
667
+ out.append(CM.grown_points(g["pts"], g["w"] / 2) if g["w"] > 0 else g["pts"])
668
+ elif g["kind"] == "circle":
669
+ out.append(CM.disc(g["c"], g["r"] + g["w"] / 2))
670
+ return out
671
+
672
+ def hole(p):
673
+ return importer and any(abs(p["x"] - x) < 1e-3 and abs(p["y"] - y) < 1e-3 for x, y in npth)
674
+
675
+ out = []
676
+ for side in ("F", "B"):
677
+ want = [q for p in src["pads"] if p["kind"] == "tht" or p["kind"] == "smd" and p["side"] == side for q in CM.pad_polys(p)]
678
+ got = [q for p in back["pads"] if (p["kind"] == "tht" and not hole(p)) or p["kind"] == "smd" and p["side"] == side
679
+ for q in _back_polys(p)]
680
+ same, diff, total = CM.same_cover(want + drawn(src, side), got + drawn(back, side), rel=0.01)
681
+ if not same:
682
+ PY._d(out, "error", f"{side}.Cu pads", "copper", f"{total} mm2", f"{diff} mm2 differs", "pad copper covered by one and not the other")
683
+ return out
684
+
685
+
686
+ def _adopt_polygon_pads(k, a, src_text, back_text):
687
+ """Pads written as an EasyEDA polygon (KiCad custom pads, copper off the hole) carry no KiCad anchor or offset:
688
+ when the copper itself is the same (checked on a fine grid), the read-back pad takes the source's anchor, size
689
+ and offset so the rest of the comparison (position, drill, mask, paste, exposed pad) runs as for any pad."""
690
+ poly = [p for p in a["pads"] if p["shape"] == "custom"]
691
+ if not poly:
692
+ return []
693
+ diffs = _copper_cover(src_text, back_text)
694
+ if diffs:
695
+ return diffs
696
+ left = list(k["pads"])
697
+ for p in poly:
698
+ q = min((c for c in left if c["num"] == p["num"]), key=lambda c: math.hypot(c["x"] - p["x"], c["y"] - p["y"]), default=None)
699
+ if q is None or math.hypot(q["x"] - p["x"], q["y"] - p["y"]) > 1e-3:
700
+ continue
701
+ left.remove(q)
702
+ for key in ("shape", "w", "h", "rot", "corner", "offset", "anchor", "outline"):
703
+ if key in q:
704
+ p[key] = q[key]
705
+ else:
706
+ p.pop(key, None)
707
+ return []
708
+
709
+
710
+ def _note_approx(fp, approx):
711
+ names = {re.match(r"pad (\S+):", x).group(1) for x in approx if re.match(r"pad (\S+):", x)}
712
+ for p in fp["pads"]:
713
+ if p["num"] in names:
714
+ p["approx"].append("written approximately")
715
+
716
+
717
+ SETTLED = ("custom anchor", "size/rotation", "custom outline", "shape", "copper offset from hole")
718
+
719
+
720
+ def _settle_custom(diffs, src_text, back_text, importer=False):
721
+ """EasyEDA polygon pads have no KiCad anchor (size, rotation, anchor kind): those pads are compared by their copper
722
+ instead, side by side on a fine grid; copper that differs is an error."""
723
+ if not any(d["field"] in SETTLED for d in diffs):
724
+ return diffs
725
+ src, back = CM.footprint(src_text), CM.footprint(back_text)
726
+ bad = _copper_cover(src_text, back_text, importer)
727
+ diffs += bad
728
+ custom = {p["num"] for p in src["pads"] if p["shape"] == "custom"} | {p["num"] for p in back["pads"] if p["shape"] == "custom"}
729
+ if not bad:
730
+ for d in diffs:
731
+ num = d["what"][4:] if d["what"].startswith("pad ") else None
732
+ if d["level"] == "error" and num in custom and d["field"] in SETTLED:
733
+ d["level"] = "warn"
734
+ d["msg"] += " (a polygon pad: its copper is the same)"
735
+ return diffs
736
+
737
+
738
+ def _back_polys(p):
739
+ if p["shape"] == "custom": # the reader's anchor is a 0.01 mm dot: the primitives are the pad
740
+ return [q[1] if q[0] == "poly" else CM.disc(q[1], q[2]) for q in p["parts"]] or CM.pad_polys(p)
741
+ return CM.pad_polys(p)
742
+
743
+
744
+ def check_footprint(kicad_text, name="part"):
745
+ """One .kicad_mod written as an EasyEDA Pro footprint and read back: the parity diffs ([release] easyeda)."""
746
+ lines, approx = footprint_lines(CM.footprint(kicad_text), name)
747
+ return compare_footprint(kicad_text, parse_lines(_jl(lines)), approx)
748
+
749
+
750
+ def compare_symbol(cs, pins):
751
+ """A cad_model symbol vs the written .esym's pins read back."""
752
+ out = []
753
+ kp = {p["num"]: p for u in sorted(cs["units"]) for p in cs["units"][u]["pins"]}
754
+ if len(kp) != len(pins):
755
+ PY._d(out, "error", "symbol", "pin count", len(kp), len(pins))
756
+ for num, p in sorted(kp.items()):
757
+ e = pins.get(num)
758
+ if not e:
759
+ PY._d(out, "error", f"pin {num}", "missing", p["name"], "missing")
760
+ continue
761
+ if pin_name(p["name"]) != e["name"]:
762
+ PY._d(out, "error", f"pin {num}", "name", p["name"], e["name"])
763
+ want = p["type"] if p["type"] in ELECTRIC or p["type"] == "no_connect" else "unspecified"
764
+ if want != e["type"]:
765
+ PY._d(out, "error", f"pin {num}", "electrical type", p["type"], e["type"])
766
+ if abs(p["x"] - e["x"]) > 1e-3 or abs(p["y"] - e["y"]) > 1e-3:
767
+ PY._d(out, "error", f"pin {num}", "position", (p["x"], p["y"]), (round(e["x"], 4), round(e["y"], 4)), "mm, y up")
768
+ if PY._angle_diff(p["angle"], e["angle"], 360.0) > 0.01:
769
+ PY._d(out, "error", f"pin {num}", "direction", p["angle"], e["angle"], "degrees")
770
+ if abs(p["length"] - e["length"]) > 1e-3:
771
+ PY._d(out, "error", f"pin {num}", "length", p["length"], e["length"], "mm")
772
+ if p["hidden"] != e["hidden"]:
773
+ PY._d(out, "error", f"pin {num}", "hidden", p["hidden"], e["hidden"])
774
+ return EG._relabel(out, "EasyEDA")
775
+
776
+
777
+ def compare_device(attrs, want):
778
+ out = []
779
+ for k, v in want.items():
780
+ if attrs.get(k) != v:
781
+ PY._d(out, "error", "device", k, v, attrs.get(k))
782
+ return EG._relabel(out, "EasyEDA")
783
+
784
+
785
+ def compare_kicad_reread(src_text, reread_text, approx=()):
786
+ """What KiCad's EasyEDA Pro importer made of a footprint vs the source. That importer reads pads' copper and
787
+ holes only (every pad opens mask and paste, every hole is plated, copper off a hole is centred, polygon pads get
788
+ a dot anchor and extra copper fills become drawings, and EasyEDA has no courtyard layer for it to map), so mask,
789
+ paste, NPTH, copper offsets, anchors and the courtyard are left to the first reader."""
790
+ diffs = EG.compare_kicad_reread(src_text, reread_text, approx)
791
+ keep = []
792
+ src = {p["num"] for p in CM.footprint(src_text)["pads"] if p["kind"] == "npth" or p["shape"] == "custom" or
793
+ p["kind"] == "tht" and p["offset"] != (0.0, 0.0)}
794
+ for d in diffs:
795
+ num = d["what"][4:] if d["what"].startswith("pad ") else None
796
+ if num is not None and num in src and d["field"] in ("side", "shape", "size/rotation", "custom anchor",
797
+ "custom outline", "copper offset from hole"):
798
+ continue
799
+ if d["field"] == "side" and d["kicad"] == "NPTH" or d["what"] == "courtyard": # every hole plated; no
800
+ continue # courtyard layer
801
+ keep.append(d)
802
+ keep = _settle_custom(keep, src_text, reread_text, importer=True) if any(d["field"] in ("shape", "size/rotation") for d in keep) else keep
803
+ for d in keep:
804
+ d["msg"] = d["msg"].replace(" / EasyEDA ", " / KiCad's importer ").replace(" / Altium ", " / KiCad's importer ")
805
+ return keep
806
+
807
+
808
+ # ---------------------------------------------------------------- driver
809
+ def export(plan, out, name="ecadlib", include_held=False, libs=(), http=None, log=print, rules=None, kicad_dir=None,
810
+ second_reader=True):
811
+ """Write <lib>.elibz for plan.json into `out`; returns the report dict (also easyeda_export.json)."""
812
+ out = Path(out)
813
+ out.mkdir(parents=True, exist_ok=True)
814
+ lib = X.kname(name)
815
+ rep = {"plan": str(plan), "library": lib, "file": f"{lib}.elibz", "parts": [], "packages": {}, "errors": [],
816
+ "warnings": [], "models": {}, "kicad_reread": None}
817
+ with tempfile.TemporaryDirectory() as tmp:
818
+ kdir, krep = EG._kicad_side(plan, tmp, name, include_held, libs, http, rules, kicad_dir, lambda *a, **k: None)
819
+ rep["skipped"] = krep.get("skipped", [])
820
+ rep["errors"] += [f"kicad-export: {e}" for e in krep.get("errors", [])]
821
+ symbols, footprints, devices, fp_ids, sources, titles = {}, {}, {}, {}, {}, set()
822
+ for row, sym, fptext in EG.plan_parts(kdir, krep):
823
+ part = {"part": row["part"], "device": None, "symbol": None, "footprint": None, "notes": list(row.get("notes") or [])}
824
+ fields = {}
825
+ cs = CM.symbol(sym) if sym is not None else None
826
+ if cs is not None:
827
+ fields = dict(cs["props"])
828
+ ref = (fields.get("Reference") or "U").rstrip("?0123456789") or "U"
829
+ fp_id = None
830
+ if fptext is not None:
831
+ fpn = row["footprint"]
832
+ if fpn not in fp_ids:
833
+ fp = CM.footprint(fptext)
834
+ lines, approx = footprint_lines(fp, fpn, ref + "?")
835
+ fp_ids[fpn] = uid("footprint", fpn)
836
+ footprints[fp_ids[fpn]] = (fpn, lines)
837
+ sources[fpn] = (fptext, approx)
838
+ rep["packages"][fpn] = {"approximations": approx, "notes": fp["notes"], "diffs": []}
839
+ fp_id = fp_ids[fpn]
840
+ part["footprint"] = fpn
841
+ sym_id = None
842
+ if cs is not None:
843
+ title = row["symbol_name"]
844
+ while title in titles:
845
+ title += "_"
846
+ titles.add(title)
847
+ lines, approx = symbol_lines(cs, title, ref + "?")
848
+ sym_id = uid("symbol", title)
849
+ symbols[sym_id] = (title, lines)
850
+ part["symbol"] = title
851
+ part["approximations"] = approx + cs["notes"]
852
+ attrs = device_attrs(fields, sym_id, fp_id, ref + "?", fields.get("Value") or title)
853
+ devices[uid("device", title)] = (title, fields.get("Description") or fields.get("ki_description") or "", attrs)
854
+ part["device"] = title
855
+ part["_sym"], part["_attrs"] = cs, attrs
856
+ rep["parts"].append(part)
857
+ elibz = out / f"{lib}.elibz"
858
+ write_elibz(elibz, symbols, footprints, devices)
859
+ EG.copy_models(kdir / f"{krep['library']}.3dshapes", out / f"{lib}.3dshapes", rep, lib)
860
+ if (kdir / "NOTICE.txt").is_file():
861
+ shutil.copyfile(kdir / "NOTICE.txt", out / "NOTICE.txt")
862
+ elif (out / "NOTICE.txt").is_file(): # an earlier export's notice; this library has no CC-BY-SA content
863
+ (out / "NOTICE.txt").unlink()
864
+ # ---- read back what was written
865
+ back = read_elibz(elibz)
866
+ for fpn, (src, approx) in sources.items():
867
+ lines = back["footprints"].get(fpn)
868
+ if lines is None:
869
+ rep["errors"].append(f"footprint {fpn} missing from the written .elibz")
870
+ continue
871
+ diffs = compare_footprint(src, lines, approx)
872
+ rep["packages"][fpn]["diffs"] = diffs
873
+ rep["errors"] += [f"footprint {fpn}: {d['msg']}" for d in diffs if d["level"] == "error"]
874
+ for part in rep["parts"]:
875
+ cs, attrs = part.pop("_sym", None), part.pop("_attrs", None)
876
+ if cs is None:
877
+ continue
878
+ lines = back["symbols"].get(part["symbol"])
879
+ dev = back["devices"].get(part["device"])
880
+ if lines is None or dev is None:
881
+ rep["errors"].append(f"symbol / device {part['symbol']} missing from the written .elibz")
882
+ continue
883
+ diffs = compare_symbol(cs, symbol_pins(lines))
884
+ want = dict(attrs)
885
+ want["Symbol"] = part["symbol"]
886
+ want["Footprint"] = part["footprint"] or ""
887
+ got = dict(dev)
888
+ got["Symbol"] = back["ids"].get(dev.get("Symbol"), "")
889
+ got["Footprint"] = back["ids"].get(dev.get("Footprint"), "")
890
+ diffs += compare_device(got, want)
891
+ if part["footprint"]:
892
+ pads = {p[5] for p in back["footprints"].get(part["footprint"], []) if p and p[0] == "PAD"}
893
+ lost = sorted(n_ for n_ in symbol_pins(lines) if n_ not in pads)
894
+ if lost:
895
+ PY._d(diffs, "error", "device", "pins without a pad", ", ".join(lost), "no pad")
896
+ diffs[-1]["msg"] = diffs[-1]["msg"].replace(" / Altium ", " / EasyEDA ")
897
+ part["diffs"] = diffs
898
+ rep["errors"] += [f"device {part['device']}: {d['msg']}" for d in diffs if d["level"] == "error"]
899
+ if second_reader and sources:
900
+ rr = EG.kicad_reread(elibz, Path(tmp) / "reread")
901
+ if rr is not None:
902
+ rep["kicad_reread"] = {"kicad": rr.get("kicad"), "error": rr.get("error"), "checked": 0, "mismatched": 0}
903
+ if not rr.get("error"):
904
+ for fpn, (src, approx) in sources.items():
905
+ got = rr["footprints"].get(fpn)
906
+ if got is None:
907
+ rep["errors"].append(f"KiCad's EasyEDA Pro importer did not load footprint {fpn}"
908
+ + (f" ({rr['errors'][fpn]})" if fpn in (rr.get("errors") or {}) else ""))
909
+ continue
910
+ diffs = compare_kicad_reread(src, got, approx)
911
+ rep["kicad_reread"]["checked"] += 1
912
+ errs = [d for d in diffs if d["level"] == "error"]
913
+ rep["kicad_reread"]["mismatched"] += bool(errs)
914
+ rep["packages"][fpn]["kicad_reread"] = diffs
915
+ rep["errors"] += [f"footprint {fpn} (KiCad's importer): {d['msg']}" for d in errs]
916
+ for fpn, pk in rep["packages"].items():
917
+ rep["warnings"] += [f"{fpn}: {a}" for a in pk["approximations"]]
918
+ for part in rep["parts"]:
919
+ rep["warnings"] += [f"{part['part']}: {a}" for a in part.get("approximations") or []]
920
+ if rep["models"]:
921
+ rep["warnings"].append("3D models are not inside the .elibz (EasyEDA links them from its cloud library): "
922
+ f"upload the STEP files in {lib}.3dshapes and attach them to the footprints")
923
+ rep["counts"] = {"devices": len(devices), "footprints": len(footprints), "symbols": len(symbols)}
924
+ EG.handoff(out, rep, "easyeda", {f: EG.model_files(src, rep["models"]) for f, (src, _) in sources.items()})
925
+ (out / "easyeda_export.json").write_text(json.dumps(rep, indent=2, default=str) + "\n")
926
+ return rep
927
+
928
+
929
+ def main(argv=None):
930
+ ap = argparse.ArgumentParser(description="Write a librarian plan's library as an EasyEDA Pro device library (.elibz)")
931
+ ap.add_argument("plan", help="librarian plan.json (model paths are relative to its folder)")
932
+ ap.add_argument("-o", "--out", default="out/easyeda")
933
+ ap.add_argument("--name", default="ecadlib", help="library name (default %(default)s)")
934
+ ap.add_argument("--kicad", help="a kicad-export folder of the same plan to convert (default: export to a temporary folder)")
935
+ ap.add_argument("--lib", action="append", default=[], help="local KiCad library dir for local: symbols (repeatable)")
936
+ ap.add_argument("--include-held", action="store_true", help="also export parts the release check held")
937
+ ap.add_argument("--cache", default="out/http_cache")
938
+ ap.add_argument("--offline", action="store_true", help="use the HTTP cache only")
939
+ ap.add_argument("--rules", help="library rules file (default: ecadlib.toml found from the plan)")
940
+ ap.add_argument("--no-kicad-check", action="store_true", help="skip the second read-back with KiCad's EasyEDA Pro importer")
941
+ ap.add_argument("--json", action="store_true", help="print the report as JSON")
942
+ a = ap.parse_args(argv)
943
+ from .httpcache import CachedHTTP
944
+ rep = export(a.plan, a.out, a.name, a.include_held, a.lib, CachedHTTP(a.cache, offline=a.offline, timeout=60),
945
+ rules=a.rules, kicad_dir=a.kicad, second_reader=not a.no_kicad_check)
946
+ if a.json:
947
+ print(json.dumps(rep, default=str))
948
+ return 1 if rep["errors"] else 0
949
+ for p in rep["parts"]:
950
+ print(f" {p['part']:<28} {('device ' + p['device']) if p['device'] else 'no symbol':<34} "
951
+ f"{('footprint ' + p['footprint']) if p['footprint'] else 'no footprint'}")
952
+ for s_ in rep.get("skipped", []):
953
+ print(f" skipped {s_['part']}: {s_['why'][:120]}")
954
+ for w in rep["warnings"]:
955
+ print(f" approx {w}")
956
+ for e in rep["errors"]:
957
+ print(f" ERROR {e}")
958
+ rr = rep.get("kicad_reread")
959
+ second = (f"; KiCad {rr['kicad']} importer re-read {rr['checked']} footprints, {rr['mismatched']} differ" if rr and rr.get("kicad") and not rr.get("error")
960
+ else "; KiCad 8+ not available for a second read" if rr is None or "cannot read" in str(rr.get("error"))
961
+ else f"; KiCad re-read failed: {rr.get('error')}")
962
+ out = Path(a.out)
963
+ print(f"{rep['counts']['devices']} devices, {rep['counts']['footprints']} footprints -> {out / rep['file']}: "
964
+ f"read back {'with differences' if rep['errors'] else 'the same as the KiCad library'}{second}")
965
+ print("import it in EasyEDA Pro with File > Import > EasyEDA Pro; "
966
+ f"{len(rep['models'])} STEP models in {out / (rep['library'] + '.3dshapes')} to attach "
967
+ f"(steps in {out / 'INSTALL.md'}; all of it in {out / rep['bundle']})")
968
+ return 1 if rep["errors"] else 0
969
+
970
+
971
+ if __name__ == "__main__":
972
+ sys.exit(main())