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