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.
- ecadlib/__init__.py +3 -0
- ecadlib/__main__.py +5 -0
- ecadlib/a365_sync.py +637 -0
- ecadlib/agent.py +1407 -0
- ecadlib/agent_queue.py +780 -0
- ecadlib/agent_watch.py +732 -0
- ecadlib/altium365.py +385 -0
- ecadlib/altium_dblib_sync.py +530 -0
- ecadlib/altium_export.py +209 -0
- ecadlib/altium_forms.py +768 -0
- ecadlib/altium_lib.py +1089 -0
- ecadlib/altium_menu.py +1751 -0
- ecadlib/altium_pkg.py +396 -0
- ecadlib/altium_project.py +1095 -0
- ecadlib/app.py +1458 -0
- ecadlib/approvals.py +395 -0
- ecadlib/assets/demo_bom.csv +8 -0
- ecadlib/assets/kicad_action_plugin.py +607 -0
- ecadlib/assets/kicad_reread.py +60 -0
- ecadlib/assets/pcblib_template.json.gz +0 -0
- ecadlib/bom_inputs.py +339 -0
- ecadlib/bom_triage.py +519 -0
- ecadlib/cad_model.py +528 -0
- ecadlib/cfb.py +171 -0
- ecadlib/cli.py +185 -0
- ecadlib/clihelp.py +149 -0
- ecadlib/cliutil.py +119 -0
- ecadlib/completion.py +173 -0
- ecadlib/costs.py +276 -0
- ecadlib/crosscheck.py +412 -0
- ecadlib/datasheet.py +2168 -0
- ecadlib/dedupe.py +572 -0
- ecadlib/detect.py +170 -0
- ecadlib/doctor.py +450 -0
- ecadlib/ds_route.py +541 -0
- ecadlib/eagle_export.py +1409 -0
- ecadlib/easy_setup.py +338 -0
- ecadlib/easyeda_convert.py +386 -0
- ecadlib/easyeda_pro_export.py +972 -0
- ecadlib/eval/__init__.py +0 -0
- ecadlib/eval/baselines/kicad-v1.json +404 -0
- ecadlib/eval/build_set.py +299 -0
- ecadlib/eval/cli.py +119 -0
- ecadlib/eval/gate.py +358 -0
- ecadlib/eval/golden/demo.txt +379 -0
- ecadlib/eval/golden/specs.txt +3120 -0
- ecadlib/eval/golden/testpack.txt +463 -0
- ecadlib/eval/golden.py +446 -0
- ecadlib/eval/replay/kicad-v1.json.gz +0 -0
- ecadlib/eval/runner.py +456 -0
- ecadlib/eval/score.py +179 -0
- ecadlib/eval/sets/kicad-v1.json +630 -0
- ecadlib/firstrun.py +400 -0
- ecadlib/footprint_adv.py +1006 -0
- ecadlib/footprint_bench.py +218 -0
- ecadlib/footprint_check.py +562 -0
- ecadlib/footprint_gen.py +582 -0
- ecadlib/footprint_rules.py +1092 -0
- ecadlib/forge.py +267 -0
- ecadlib/fp_share.py +95 -0
- ecadlib/fp_tidy.py +389 -0
- ecadlib/fsutil.py +62 -0
- ecadlib/house_style.py +1051 -0
- ecadlib/httpcache.py +124 -0
- ecadlib/hub.py +1490 -0
- ecadlib/hub_copy.py +161 -0
- ecadlib/hub_tools.py +79 -0
- ecadlib/kicad_dblib.py +257 -0
- ecadlib/kicad_export.py +837 -0
- ecadlib/kicad_import.py +913 -0
- ecadlib/kicad_index.py +160 -0
- ecadlib/kicad_menu.py +193 -0
- ecadlib/kicad_pcm.py +475 -0
- ecadlib/launcher.py +156 -0
- ecadlib/librarian.py +963 -0
- ecadlib/lifecycle.py +691 -0
- ecadlib/llm/__init__.py +276 -0
- ecadlib/llm/cli.py +70 -0
- ecadlib/llm/openai_compat.py +214 -0
- ecadlib/llm/profiles.py +245 -0
- ecadlib/llm/rails.py +570 -0
- ecadlib/llm/run.py +126 -0
- ecadlib/llm/sampling.py +49 -0
- ecadlib/mcp_server.py +723 -0
- ecadlib/messages.py +419 -0
- ecadlib/migrate.py +407 -0
- ecadlib/notify.py +329 -0
- ecadlib/package_schema.json +1238 -0
- ecadlib/package_specs/CP_EIA-3216-18_Kemet-A.json +27 -0
- ecadlib/package_specs/C_0402_1005Metric.json +22 -0
- ecadlib/package_specs/C_0805_2012Metric.json +22 -0
- ecadlib/package_specs/DFN-8-1EP_3x3mm_P0.5mm_EP1.66x2.38mm.json +40 -0
- ecadlib/package_specs/D_SMA.json +27 -0
- ecadlib/package_specs/D_SMB.json +27 -0
- ecadlib/package_specs/D_SOD-123.json +43 -0
- ecadlib/package_specs/D_SOD-323.json +43 -0
- ecadlib/package_specs/LED_0603_1608Metric.json +23 -0
- ecadlib/package_specs/LED_0805_2012Metric.json +23 -0
- ecadlib/package_specs/LQFP-48_7x7mm_P0.5mm.json +31 -0
- ecadlib/package_specs/L_0805_2012Metric.json +22 -0
- ecadlib/package_specs/MSOP-8_3x3mm_P0.65mm.json +31 -0
- ecadlib/package_specs/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm.json +39 -0
- ecadlib/package_specs/QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm.json +37 -0
- ecadlib/package_specs/R_0402_1005Metric.json +22 -0
- ecadlib/package_specs/R_0603_1608Metric.json +22 -0
- ecadlib/package_specs/R_0805_2012Metric.json +22 -0
- ecadlib/package_specs/R_1206_3216Metric.json +22 -0
- ecadlib/package_specs/SOIC-16_3.9x9.9mm_P1.27mm.json +32 -0
- ecadlib/package_specs/SOIC-8_3.9x4.9mm_P1.27mm.json +32 -0
- ecadlib/package_specs/SOT-223-3_TabPin2.json +56 -0
- ecadlib/package_specs/SOT-23-5.json +57 -0
- ecadlib/package_specs/SOT-23-6.json +62 -0
- ecadlib/package_specs/SOT-23.json +48 -0
- ecadlib/package_specs/TSSOP-16_4.4x5mm_P0.65mm.json +31 -0
- ecadlib/package_specs/VQFN-16-1EP_3x3mm_P0.5mm_EP1.68x1.68mm.json +39 -0
- ecadlib/package_specs/advanced/BGA-100_11.0x11.0mm_Layout10x10_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-121_9.0x9.0mm_Layout11x11_P0.8mm_Ball0.4mm_Pad0.35mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-144_7.0x7.0mm_Layout13x13_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +29 -0
- ecadlib/package_specs/advanced/BGA-169_11.0x11.0mm_Layout13x13_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +23 -0
- ecadlib/package_specs/advanced/BGA-256_11.0x11.0mm_Layout20x20_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-256_17.0x17.0mm_Layout16x16_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-324_15.0x15.0mm_Layout18x18_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
- ecadlib/package_specs/advanced/BGA-36_3.396x3.466mm_Layout6x6_P0.4mm_Ball0.25mm_Pad0.2mm_NSMD.json +24 -0
- ecadlib/package_specs/advanced/BGA-672_27.0x27.0mm_Layout26x26_P1.0mm_Ball0.6mm_Pad0.5mm_NSMD.json +32 -0
- ecadlib/package_specs/advanced/BGA-68_5.0x5.0mm_Layout9x9_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +30 -0
- ecadlib/package_specs/advanced/BGA-90_8.0x13.0mm_Layout2x3x15_P0.8mm.json +26 -0
- ecadlib/package_specs/advanced/DFN-8-1EP_3x3mm_P0.5mm_EP1.65x2.38mm_ThermalVias.json +60 -0
- ecadlib/package_specs/advanced/Example_Custom_LPad_Slot.json +24 -0
- ecadlib/package_specs/advanced/JST_GH_SM04B-GHS-TB_1x04-1MP_P1.25mm_Horizontal.json +49 -0
- ecadlib/package_specs/advanced/JST_PH_B4B-PH-K_1x04_P2.00mm_Vertical.json +33 -0
- ecadlib/package_specs/advanced/JST_PH_S4B-PH-SM4-TB_1x04-1MP_P2.00mm_Horizontal.json +55 -0
- ecadlib/package_specs/advanced/JST_SH_SM04B-SRSS-TB_1x04-1MP_P1.00mm_Horizontal.json +49 -0
- ecadlib/package_specs/advanced/JST_XH_B2B-XH-A_1x02_P2.50mm_Vertical.json +34 -0
- ecadlib/package_specs/advanced/JST_XH_B4B-XH-A_1x04_P2.50mm_Vertical.json +34 -0
- ecadlib/package_specs/advanced/JST_XH_B6B-XH-A_1x06_P2.50mm_Vertical.json +33 -0
- ecadlib/package_specs/advanced/LFBGA-144_10x10mm_Layout12x12_P0.8mm.json +23 -0
- ecadlib/package_specs/advanced/Molex_KK-254_AE-6410-04A_1x04_P2.54mm_Vertical.json +34 -0
- ecadlib/package_specs/advanced/Molex_PicoBlade_53398-0471_1x04-1MP_P1.25mm_Vertical.json +54 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P1.00mm_Vertical_SMD_Pin1Left.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P1.27mm_Vertical_SMD_Pin1Left.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical_SMD_Pin1Left.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Left.json +25 -0
- ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Right.json +14 -0
- ecadlib/package_specs/advanced/PinHeader_1x10_P1.00mm_Vertical.json +15 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P1.00mm_Vertical_SMD.json +13 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical.json +18 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical_SMD.json +24 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P2.00mm_Vertical_SMD.json +13 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical_SMD.json +24 -0
- ecadlib/package_specs/advanced/PinHeader_2x20_P2.54mm_Vertical.json +12 -0
- ecadlib/package_specs/advanced/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm_ThermalVias.json +87 -0
- ecadlib/package_specs/advanced/UFBGA-132_7x7mm_Layout12x12_P0.5mm.json +43 -0
- ecadlib/package_specs/advanced/USB_C_Receptacle_HRO_TYPE-C-31-M-12.json +218 -0
- ecadlib/package_specs/standard.json.gz +0 -0
- ecadlib/parity.py +1204 -0
- ecadlib/parts_export.py +154 -0
- ecadlib/partview.py +215 -0
- ecadlib/plugins.py +231 -0
- ecadlib/providers.py +226 -0
- ecadlib/rebuild.py +471 -0
- ecadlib/release_check.py +437 -0
- ecadlib/reports.py +274 -0
- ecadlib/resolve.py +556 -0
- ecadlib/review_cli.py +292 -0
- ecadlib/rules.py +979 -0
- ecadlib/rules_infer.py +1205 -0
- ecadlib/sexpr.py +149 -0
- ecadlib/sheetsafe.py +65 -0
- ecadlib/sim_models.py +1087 -0
- ecadlib/sourcing.py +1161 -0
- ecadlib/stdparts.py +755 -0
- ecadlib/step_gen.py +929 -0
- ecadlib/symbol_layout.py +798 -0
- ecadlib/team.py +558 -0
- ecadlib/team_setup.py +288 -0
- ecadlib/term.py +420 -0
- ecadlib/term_views.py +64 -0
- ecadlib/testpack.py +334 -0
- ecadlib/ui/__init__.py +1 -0
- ecadlib/ui/__main__.py +3 -0
- ecadlib/ui/server.py +1029 -0
- ecadlib/ui/static/hairline.js +2 -0
- ecadlib/ui/static/index.html +2098 -0
- ecadlib/ui/static/setup.html +294 -0
- ecadlib/upkeep.py +717 -0
- ecadlib/upstream.py +573 -0
- ecadlib/userconfig.py +257 -0
- ecadlib/visual_check.py +1603 -0
- ecadlib/ws_status.py +203 -0
- ecadlib-0.3.0.dist-info/METADATA +2245 -0
- ecadlib-0.3.0.dist-info/RECORD +199 -0
- ecadlib-0.3.0.dist-info/WHEEL +5 -0
- ecadlib-0.3.0.dist-info/entry_points.txt +2 -0
- ecadlib-0.3.0.dist-info/licenses/LICENSE +21 -0
- ecadlib-0.3.0.dist-info/licenses/NOTICE +53 -0
- 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())
|