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/parity.py
ADDED
|
@@ -0,0 +1,1204 @@
|
|
|
1
|
+
"""KiCad <-> Altium parity: read back BOTH libraries from the files actually written and compare them part by part.
|
|
2
|
+
|
|
3
|
+
ecadlib parity out/librarian/plan.json plan mode: models/KiCad/*.kicad_mod vs .PcbLib,
|
|
4
|
+
.SchLib pins vs the KiCad pads
|
|
5
|
+
ecadlib parity out/librarian/plan.json --kicad out/kicad [--name mylib] [--altium D:/libs/ecadlib]
|
|
6
|
+
export mode: kicad-export's .kicad_sym / .pretty /
|
|
7
|
+
.3dshapes vs the Altium files; with --altium also the
|
|
8
|
+
.kicad_dbl SQLite rows vs the DbLib rows
|
|
9
|
+
ecadlib parity pair X.kicad_mod X.PcbLib one footprint (or Lib.kicad_sym:Name X.SchLib)
|
|
10
|
+
ecadlib parity survey [--quick] every generator / standard-table footprint, both ways
|
|
11
|
+
[--json] [-o REPORT_STEM] [--no-pyaltiumlib]
|
|
12
|
+
|
|
13
|
+
What is compared (per part):
|
|
14
|
+
footprint pad count and numbers; per pad position, size, rotation, shape (round / rect / rounded rect with its
|
|
15
|
+
corner %, custom polygon outline), side (top / bottom SMD, plated through-hole, NPTH), drill (diameter,
|
|
16
|
+
slot length and slot direction), paste and solder mask (on / off, manual margins); paste / mask windows
|
|
17
|
+
without copper (exposed-pad paste); the exposed pad (size, paste windows, coverage); courtyard extent;
|
|
18
|
+
3D model presence (export mode: the KiCad (model ...) file must exist next to the library)
|
|
19
|
+
symbol pin count, numbers, names (KiCad ~{X} = Altium X\\ overline), electrical type; pin -> pad mapping
|
|
20
|
+
(pins without a pad in one tool but not the other)
|
|
21
|
+
fields MPN, Manufacturer, Value, Description, Datasheet: KiCad symbol properties vs the Altium values (the DbLib
|
|
22
|
+
row with --altium, else the plan parameters altium365 push releases); footprint link names
|
|
23
|
+
database (--kicad with a .kicad_dbl and --altium with a DbLib) the same parts, each row naming the same
|
|
24
|
+
symbol / footprint and the same fields
|
|
25
|
+
pyaltiumlib (when installed) re-reads every .PcbLib as an independent second reader: designators, positions,
|
|
26
|
+
sizes and holes must agree with ecadlib's reader.
|
|
27
|
+
|
|
28
|
+
Conventions, handled once here (_mm / _a2k / canon_pad): everything is compared in KiCad's frame, millimetres, y down.
|
|
29
|
+
Altium stores 1 mil = 10000 internal units (1 unit = 2.54 nm) with y up; angles are counter-clockwise on screen in both
|
|
30
|
+
tools, so rotations compare as they are. A pad's (w, h, rotation) is reduced to rotation in [0, 90) degrees (a pad is
|
|
31
|
+
the same after 180 degrees, and w x h at r equals h x w at r + 90); round pads drop their rotation. Slots compare by
|
|
32
|
+
length, width and axis direction (mod 180). Tolerances are TOL (mm, degrees, percent).
|
|
33
|
+
|
|
34
|
+
Levels: `error` is a real difference (the release check holds the part, `[release] parity = "hold"`); `warn` is a
|
|
35
|
+
documented approximation of the converters (a curved custom pad drawn as its bounding rectangle, a trapezoid as a
|
|
36
|
+
rectangle, ...): reported, not held."""
|
|
37
|
+
import argparse, json, math, re, sys
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
|
|
40
|
+
from . import altium_lib as AL, sexpr
|
|
41
|
+
|
|
42
|
+
TOL = {"position": 0.001, "size": 0.001, "drill": 0.001, "margin": 0.001, "rotation": 0.01, "corner": 1.0,
|
|
43
|
+
"courtyard": 0.005, "window": 0.002, "outline": 0.005, "coverage": 0.5}
|
|
44
|
+
MODES = ("hold", "warn", "off")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------- units and conventions (the one place)
|
|
48
|
+
def _mm(u):
|
|
49
|
+
"""Altium internal units -> mm."""
|
|
50
|
+
return AL.to_mm(u)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _a2k(x, y):
|
|
54
|
+
"""Altium point (internal units, y up) -> KiCad frame (mm, y down)."""
|
|
55
|
+
return _mm(x), -_mm(y)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _f(v):
|
|
59
|
+
return float(v)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def canon_pad(w, h, rot, round_=False):
|
|
63
|
+
"""(w, h, rotation) of a pad outline -> one representation: rotation in [0, 90), w and h swapped when that took
|
|
64
|
+
90 degrees off. A round pad (circle) has no rotation."""
|
|
65
|
+
rot = round(float(rot) % 180.0, 6) % 180.0
|
|
66
|
+
if rot >= 90.0:
|
|
67
|
+
w, h, rot = h, w, rot - 90.0
|
|
68
|
+
if rot > 90.0 - 1e-6:
|
|
69
|
+
w, h, rot = h, w, 0.0
|
|
70
|
+
if round_ and abs(w - h) < 1e-9:
|
|
71
|
+
rot = 0.0
|
|
72
|
+
return w, h, rot
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _angle_diff(a, b, period=180.0):
|
|
76
|
+
d = abs(a - b) % period
|
|
77
|
+
return min(d, period - d)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _arc_bbox(cx, cy, r, a0, a1):
|
|
81
|
+
"""Points (x, y) bounding a counter-clockwise arc from a0 to a1 degrees (exact: ends plus axis extremes)."""
|
|
82
|
+
span = (a1 - a0) % 360 or 360
|
|
83
|
+
angs = [a0, a0 + span] + [k * 90.0 for k in range(-4, 9) if a0 < k * 90.0 < a0 + span]
|
|
84
|
+
return [(cx + r * math.cos(math.radians(a)), cy + r * math.sin(math.radians(a))) for a in angs]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _bbox(pts):
|
|
88
|
+
pts = list(pts)
|
|
89
|
+
if not pts:
|
|
90
|
+
return None
|
|
91
|
+
xs, ys = [p[0] for p in pts], [p[1] for p in pts]
|
|
92
|
+
return (min(xs), min(ys), max(xs), max(ys))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _rot_pts(cx, cy, w, h, rot):
|
|
96
|
+
"""Corners of a w x h rectangle at (cx, cy) rotated counter-clockwise on screen, KiCad frame (y down)."""
|
|
97
|
+
a = math.radians(rot)
|
|
98
|
+
out = []
|
|
99
|
+
for u, v in ((-w / 2, -h / 2), (w / 2, -h / 2), (w / 2, h / 2), (-w / 2, h / 2)):
|
|
100
|
+
out.append((cx + u * math.cos(a) + v * math.sin(a), cy - u * math.sin(a) + v * math.cos(a)))
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _fmt(v):
|
|
105
|
+
if isinstance(v, float):
|
|
106
|
+
return f"{v:.4f}".rstrip("0").rstrip(".") if abs(v) < 1e6 else f"{v:g}"
|
|
107
|
+
if isinstance(v, tuple):
|
|
108
|
+
return "(" + ", ".join(_fmt(x) for x in v) + ")"
|
|
109
|
+
return "-" if v is None or v == "" else str(v)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------- KiCad side
|
|
113
|
+
def _kicad_drill(p):
|
|
114
|
+
"""-> ((width, height) or None, copper offset from the hole in the pad frame (KiCad, y down))."""
|
|
115
|
+
d = sexpr.first(p, "drill")
|
|
116
|
+
if not d:
|
|
117
|
+
return None, (0.0, 0.0)
|
|
118
|
+
vals = [_f(v) for v in d[1:] if isinstance(v, str) and v != "oval"]
|
|
119
|
+
off = sexpr.first(d, "offset")
|
|
120
|
+
off = (_f(off[1]), _f(off[2])) if off else (0.0, 0.0)
|
|
121
|
+
if not vals:
|
|
122
|
+
return None, off
|
|
123
|
+
return (vals[0], vals[1] if len(vals) > 1 else vals[0]), off
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _turn(v, rot):
|
|
127
|
+
"""A pad-frame vector (KiCad frame, y down) turned with the pad (counter-clockwise on screen) -> footprint frame."""
|
|
128
|
+
a, (u, w) = math.radians(rot), v
|
|
129
|
+
return (u * math.cos(a) + w * math.sin(a), -u * math.sin(a) + w * math.cos(a))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _custom_bbox_k(p, x, y, w, h, rot):
|
|
133
|
+
"""Bounding box (KiCad frame) of a custom pad's anchor plus all its primitives (stroke widths included), and whether
|
|
134
|
+
a primitive is curved (kicad_import then writes this box as a rectangle)."""
|
|
135
|
+
xs, ys, curved = [-w / 2, w / 2], [-h / 2, h / 2], False
|
|
136
|
+
for g in (sexpr.first(p, "primitives") or [])[1:]:
|
|
137
|
+
if not isinstance(g, list):
|
|
138
|
+
continue
|
|
139
|
+
hw = _f((sexpr.first(g, "width") or sexpr.first(sexpr.first(g, "stroke") or [], "width") or [None, 0])[1]) / 2
|
|
140
|
+
curved |= g[0] in ("gr_arc", "gr_circle", "gr_curve")
|
|
141
|
+
if g[0] == "gr_circle":
|
|
142
|
+
c, e = sexpr.first(g, "center"), sexpr.first(g, "end")
|
|
143
|
+
r = math.hypot(_f(e[1]) - _f(c[1]), _f(e[2]) - _f(c[2]))
|
|
144
|
+
pts = [(_f(c[1]) - r, _f(c[2]) - r), (_f(c[1]) + r, _f(c[2]) + r)]
|
|
145
|
+
elif sexpr.first(g, "pts"):
|
|
146
|
+
pts = [(_f(c[1]), _f(c[2])) for c in sexpr.find(sexpr.first(g, "pts"), "xy")]
|
|
147
|
+
else:
|
|
148
|
+
pts = [(_f(c[1]), _f(c[2])) for c in (sexpr.first(g, k) for k in ("start", "mid", "end")) if c]
|
|
149
|
+
xs += [px + s * hw for px, _ in pts for s in (-1, 1)]
|
|
150
|
+
ys += [py + s * hw for _, py in pts for s in (-1, 1)]
|
|
151
|
+
corners = [(u, v) for u in (min(xs), max(xs)) for v in (min(ys), max(ys))]
|
|
152
|
+
return _bbox((x + dx, y + dy) for dx, dy in (_turn(c, rot) for c in corners)), curved
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _custom_parts_k(p, x, y, rot):
|
|
156
|
+
"""Bounding boxes (KiCad frame) of a KiCad custom pad's filled primitives (polygons, rectangles, circles, lines; their
|
|
157
|
+
stroke width included), or None when it has curves or unfilled outlines (the converters approximate those)."""
|
|
158
|
+
boxes = []
|
|
159
|
+
for g in (sexpr.first(p, "primitives") or [])[1:]:
|
|
160
|
+
if not isinstance(g, list):
|
|
161
|
+
continue
|
|
162
|
+
hw = _f((sexpr.first(g, "width") or sexpr.first(sexpr.first(g, "stroke") or [], "width") or [None, 0])[1]) / 2
|
|
163
|
+
fill = sexpr.first(g, "fill")
|
|
164
|
+
filled = (fill is None and g[0] == "gr_poly") or bool(fill and fill[1] in ("yes", "solid"))
|
|
165
|
+
if g[0] == "gr_line":
|
|
166
|
+
pts = [(_f(c[1]), _f(c[2])) for c in (sexpr.first(g, "start"), sexpr.first(g, "end"))]
|
|
167
|
+
elif g[0] == "gr_poly" and filled and sexpr.first(g, "pts"):
|
|
168
|
+
pts = [(_f(c[1]), _f(c[2])) for c in sexpr.find(sexpr.first(g, "pts"), "xy")]
|
|
169
|
+
elif g[0] == "gr_rect" and filled:
|
|
170
|
+
(x1, y1), (x2, y2) = [(_f(c[1]), _f(c[2])) for c in (sexpr.first(g, "start"), sexpr.first(g, "end"))]
|
|
171
|
+
pts = [(x1, y1), (x2, y1), (x2, y2), (x1, y2)]
|
|
172
|
+
elif g[0] == "gr_circle" and filled:
|
|
173
|
+
c, e = sexpr.first(g, "center"), sexpr.first(g, "end")
|
|
174
|
+
r = math.hypot(_f(e[1]) - _f(c[1]), _f(e[2]) - _f(c[2]))
|
|
175
|
+
pts = [(_f(c[1]) + r * math.cos(k * math.pi / 2), _f(c[2]) + r * math.sin(k * math.pi / 2)) for k in range(4)]
|
|
176
|
+
else:
|
|
177
|
+
return None
|
|
178
|
+
box = []
|
|
179
|
+
for u, v in pts:
|
|
180
|
+
for du, dv in ((-hw, -hw), (hw, hw), (-hw, hw), (hw, -hw)):
|
|
181
|
+
dx, dy = _turn((u + du, v + dv), rot)
|
|
182
|
+
box.append((x + dx, y + dy))
|
|
183
|
+
boxes.append(_bbox(box))
|
|
184
|
+
return boxes
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _custom_outline_k(p, x, y, rot):
|
|
188
|
+
"""Bounding box of all of a custom pad's primitives (see _custom_parts_k), or None."""
|
|
189
|
+
boxes = _custom_parts_k(p, x, y, rot)
|
|
190
|
+
return None if boxes is None else _bbox([c for b in boxes if b for c in (b[:2], b[2:])])
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _filled(g):
|
|
194
|
+
from .kicad_import import _filled as f
|
|
195
|
+
return f(g)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def kicad_footprint(text):
|
|
199
|
+
"""A .kicad_mod -> canonical footprint dict (KiCad frame, mm)."""
|
|
200
|
+
t = sexpr.parse(text) if isinstance(text, str) else text
|
|
201
|
+
out = {"name": str(t[1]), "pads": [], "windows": [], "courtyard": None, "models": [], "notes": [], "approx": []}
|
|
202
|
+
for p in sexpr.find(t, "pad"):
|
|
203
|
+
num, kind, shape = str(p[1]), p[2], p[3]
|
|
204
|
+
at, size = sexpr.first(p, "at"), sexpr.first(p, "size")
|
|
205
|
+
x, y = _f(at[1]), _f(at[2])
|
|
206
|
+
rot = _f(at[3]) if len(at) > 3 else 0.0
|
|
207
|
+
w, h = _f(size[1]), _f(size[2])
|
|
208
|
+
layers = [str(l) for l in (sexpr.first(p, "layers") or ["layers"])[1:]]
|
|
209
|
+
cu = [l for l in layers if l.endswith(".Cu")]
|
|
210
|
+
if kind in ("smd", "connect") and not cu: # paste / mask window without copper, on each of its layers
|
|
211
|
+
kinds = ["paste" if "Paste" in l else "mask" for side in ("F", "B") for l in (f"{side}.Paste", f"{side}.Mask")
|
|
212
|
+
if l in layers or "*." + l[2:] in layers or "F&B." + l[2:] in layers]
|
|
213
|
+
parts = _custom_parts_k(p, x, y, rot) if shape == "custom" else []
|
|
214
|
+
for what in kinds:
|
|
215
|
+
if shape == "custom" and parts is None: # curves: one bounding rectangle
|
|
216
|
+
box, _ = _custom_bbox_k(p, x, y, w, h, rot)
|
|
217
|
+
out["windows"].append((what, box))
|
|
218
|
+
out["approx"].append(f"{what} window with curves written as its bounding rectangle")
|
|
219
|
+
else: # the anchor, then each primitive (Altium: one region each)
|
|
220
|
+
out["windows"] += [(what, b) for b in [_bbox(_rot_pts(x, y, w, h, rot))] + parts]
|
|
221
|
+
continue
|
|
222
|
+
rr = sexpr.first(p, "roundrect_rratio")
|
|
223
|
+
kshape = {"circle": "round", "oval": "round"}.get(shape, shape)
|
|
224
|
+
corner = 0.0
|
|
225
|
+
if shape == "roundrect":
|
|
226
|
+
corner = min(100.0, _f(rr[1]) * 200) if rr else 0.0
|
|
227
|
+
kshape = "roundrect" if corner >= 0.5 else "rect"
|
|
228
|
+
pad = {"num": num, "x": x, "y": y, "shape": kshape, "corner": corner, "approx": []}
|
|
229
|
+
if sexpr.first(p, "chamfer") and sexpr.first(p, "chamfer_ratio") and _f(sexpr.first(p, "chamfer_ratio")[1]) > 0:
|
|
230
|
+
pad["approx"].append("chamfered corners")
|
|
231
|
+
if shape == "trapezoid":
|
|
232
|
+
pad["approx"].append("trapezoid")
|
|
233
|
+
pad["shape"] = "rect"
|
|
234
|
+
if shape == "custom":
|
|
235
|
+
pad["anchor"] = (sexpr.first(sexpr.first(p, "options") or [], "anchor") or [None, "circle"])[1]
|
|
236
|
+
pad["outline"] = _custom_outline_k(p, x, y, rot)
|
|
237
|
+
if pad["outline"] is None: # curves: kicad_import writes the bounding rectangle of anchor + primitives
|
|
238
|
+
pad["approx"].append("custom pad with curves written as its bounding rectangle")
|
|
239
|
+
if kind == "np_thru_hole":
|
|
240
|
+
pad["side"] = "NPTH"
|
|
241
|
+
elif kind == "thru_hole":
|
|
242
|
+
pad["side"] = "THT"
|
|
243
|
+
else:
|
|
244
|
+
pad["side"] = "F" if "F.Cu" in cu or "*.Cu" in cu else "B"
|
|
245
|
+
drill, offset = _kicad_drill(p)
|
|
246
|
+
if drill:
|
|
247
|
+
dmin, dmax = min(drill), max(drill)
|
|
248
|
+
ang = None if dmax - dmin < 1e-9 else (rot + (0.0 if drill[0] >= drill[1] else 90.0)) % 180.0
|
|
249
|
+
pad["drill"] = (dmin, dmax, ang)
|
|
250
|
+
else:
|
|
251
|
+
pad["drill"] = None
|
|
252
|
+
pad["offset"] = _turn(offset, rot) # copper centre - (at): the hole stays at (at)
|
|
253
|
+
if not drill: # no hole (SMD): the offset only moves the copper
|
|
254
|
+
pad["x"], pad["y"] = x + pad["offset"][0], y + pad["offset"][1]
|
|
255
|
+
pad["offset"] = (0.0, 0.0)
|
|
256
|
+
pad["w"], pad["h"], pad["rot"] = canon_pad(w, h, rot, pad["shape"] == "round")
|
|
257
|
+
if pad["side"] in ("F", "B"):
|
|
258
|
+
pad["paste"] = any("Paste" in l for l in layers)
|
|
259
|
+
pad["mask"] = any("Mask" in l for l in layers)
|
|
260
|
+
mm_, pm = sexpr.first(p, "solder_mask_margin"), sexpr.first(p, "solder_paste_margin")
|
|
261
|
+
pad["mask_margin"] = _f(mm_[1]) if mm_ else None
|
|
262
|
+
pad["paste_margin"] = _f(pm[1]) if pm and pad.get("paste") else None
|
|
263
|
+
if sexpr.first(p, "solder_paste_margin_ratio") and _f(sexpr.first(p, "solder_paste_margin_ratio")[1]):
|
|
264
|
+
pad["approx"].append("paste margin ratio")
|
|
265
|
+
out["pads"].append(pad)
|
|
266
|
+
for kind in ("fp_rect", "fp_poly", "fp_circle"): # filled footprint graphics on mask / paste: windows too
|
|
267
|
+
for g in sexpr.find(t, kind):
|
|
268
|
+
lay = (sexpr.first(g, "layer") or [None, ""])[1]
|
|
269
|
+
if lay not in ("F.Mask", "B.Mask", "F.Paste", "B.Paste") or not (_filled(g) or kind == "fp_poly" and not sexpr.first(g, "fill")):
|
|
270
|
+
continue
|
|
271
|
+
if kind == "fp_poly":
|
|
272
|
+
q = [(_f(a[1]), _f(a[2])) for a in sexpr.find(sexpr.first(g, "pts") or [], "xy")]
|
|
273
|
+
elif kind == "fp_circle":
|
|
274
|
+
c, e = sexpr.first(g, "center"), sexpr.first(g, "end")
|
|
275
|
+
r = math.hypot(_f(e[1]) - _f(c[1]), _f(e[2]) - _f(c[2]))
|
|
276
|
+
q = [(_f(c[1]) - r, _f(c[2]) - r), (_f(c[1]) + r, _f(c[2]) + r)]
|
|
277
|
+
else:
|
|
278
|
+
q = [(_f(c[1]), _f(c[2])) for c in (sexpr.first(g, "start"), sexpr.first(g, "end"))]
|
|
279
|
+
if _bbox(q):
|
|
280
|
+
out["windows"].append(("mask" if lay.endswith("Mask") else "paste", _bbox(q)))
|
|
281
|
+
out["copper"] = [] # footprint-level copper graphics (bounding boxes)
|
|
282
|
+
for kind in ("fp_line", "fp_rect", "fp_poly", "fp_circle"):
|
|
283
|
+
for g in sexpr.find(t, kind):
|
|
284
|
+
if (sexpr.first(g, "layer") or [None, None])[1] not in ("F.Cu", "B.Cu"):
|
|
285
|
+
continue
|
|
286
|
+
if kind == "fp_poly":
|
|
287
|
+
q = [(_f(a[1]), _f(a[2])) for a in sexpr.find(sexpr.first(g, "pts") or [], "xy")]
|
|
288
|
+
elif kind == "fp_circle":
|
|
289
|
+
c, e = sexpr.first(g, "center"), sexpr.first(g, "end")
|
|
290
|
+
r = math.hypot(_f(e[1]) - _f(c[1]), _f(e[2]) - _f(c[2]))
|
|
291
|
+
q = [(_f(c[1]) - r, _f(c[2]) - r), (_f(c[1]) + r, _f(c[2]) + r)]
|
|
292
|
+
else:
|
|
293
|
+
q = [(_f(c[1]), _f(c[2])) for c in (sexpr.first(g, "start"), sexpr.first(g, "end"))]
|
|
294
|
+
sw = sexpr.first(sexpr.first(g, "stroke") or [], "width") or sexpr.first(g, "width")
|
|
295
|
+
hw = _f(sw[1]) / 2 if sw else 0.0
|
|
296
|
+
b = _bbox(q)
|
|
297
|
+
if b:
|
|
298
|
+
out["copper"].append((b[0] - hw, b[1] - hw, b[2] + hw, b[3] + hw))
|
|
299
|
+
pts = []
|
|
300
|
+
for kind in ("fp_line", "fp_rect", "fp_poly", "fp_circle", "fp_arc"):
|
|
301
|
+
for g in sexpr.find(t, kind):
|
|
302
|
+
if (sexpr.first(g, "layer") or [None, None])[1] != "F.CrtYd":
|
|
303
|
+
continue
|
|
304
|
+
if kind == "fp_poly":
|
|
305
|
+
pts += [(_f(a[1]), _f(a[2])) for a in sexpr.find(sexpr.first(g, "pts") or [], "xy")]
|
|
306
|
+
elif kind == "fp_circle":
|
|
307
|
+
c, e = sexpr.first(g, "center") or sexpr.first(g, "start"), sexpr.first(g, "end")
|
|
308
|
+
r = math.hypot(_f(e[1]) - _f(c[1]), _f(e[2]) - _f(c[2]))
|
|
309
|
+
pts += [(_f(c[1]) - r, _f(c[2]) - r), (_f(c[1]) + r, _f(c[2]) + r)]
|
|
310
|
+
elif kind == "fp_arc" and sexpr.first(g, "mid"):
|
|
311
|
+
from .kicad_import import _arc3
|
|
312
|
+
s, m, e = ((_f(q[1]), _f(q[2])) for q in (sexpr.first(g, k) for k in ("start", "mid", "end")))
|
|
313
|
+
try:
|
|
314
|
+
pts += _arc_bbox(*_arc3(s, m, e))
|
|
315
|
+
except ValueError:
|
|
316
|
+
pts += [s, e]
|
|
317
|
+
else:
|
|
318
|
+
s, e = sexpr.first(g, "start"), sexpr.first(g, "end")
|
|
319
|
+
if s and e:
|
|
320
|
+
pts += [(_f(s[1]), _f(s[2])), (_f(e[1]), _f(e[2]))]
|
|
321
|
+
out["courtyard"] = _bbox(pts)
|
|
322
|
+
out["models"] = [str(m[1]) for m in sexpr.find(t, "model")]
|
|
323
|
+
return out
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def kicad_symbol(sym):
|
|
327
|
+
"""A KiCad symbol tree (one `symbol` node, `extends` already flattened) -> canonical symbol dict."""
|
|
328
|
+
props = {str(p[1]): str(p[2]) for p in sexpr.find(sym, "property")}
|
|
329
|
+
pins = {}
|
|
330
|
+
for u in sexpr.find(sym, "symbol"):
|
|
331
|
+
parts = str(u[1]).rsplit("_", 2)
|
|
332
|
+
style = int(parts[-1]) if len(parts) == 3 and parts[-1].isdigit() else 1
|
|
333
|
+
if style > 1: # De Morgan alternate: the normal view is compared
|
|
334
|
+
continue
|
|
335
|
+
for p in sexpr.find(u, "pin"):
|
|
336
|
+
num = str((sexpr.first(p, "number") or ["", ""])[1])
|
|
337
|
+
name = str((sexpr.first(p, "name") or ["", ""])[1])
|
|
338
|
+
hidden = "hide" in p[1:] or bool(sexpr.first(p, "hide") and _yes(sexpr.first(p, "hide")))
|
|
339
|
+
pins.setdefault(num, {"name": name, "type": str(p[1]), "hidden": hidden})
|
|
340
|
+
return {"name": str(sym[1]), "props": props, "pins": pins}
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _yes(node):
|
|
344
|
+
return len(node) < 2 or node[1] == "yes"
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def kicad_pin_name(name):
|
|
348
|
+
"""KiCad pin name -> the Altium spelling it is written as (overbars, '~' = no name)."""
|
|
349
|
+
from .kicad_import import _pin_text
|
|
350
|
+
s = _pin_text(name)
|
|
351
|
+
return "" if s in ("~", "") else s
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def kicad_type(t):
|
|
355
|
+
"""KiCad electrical type -> Altium's pin electrical type number (altium_lib.ETYPE), as kicad_import writes it."""
|
|
356
|
+
from .kicad_import import KTYPE
|
|
357
|
+
return AL.ETYPE[KTYPE.get(t, "passive")]
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
# ---------------------------------------------------------------- Altium side
|
|
361
|
+
def altium_footprint(fp, courtyard=AL.MECH15):
|
|
362
|
+
"""One footprint from altium_lib.read_pcblib -> canonical footprint dict (KiCad frame, mm)."""
|
|
363
|
+
from .kicad_export import _custom_pads
|
|
364
|
+
recs = fp["records"]
|
|
365
|
+
custom, used = _custom_pads(recs)
|
|
366
|
+
out = {"name": fp["name"], "pads": [], "windows": [], "courtyard": None, "bodies": 0, "embedded": 0, "notes": []}
|
|
367
|
+
for r in recs:
|
|
368
|
+
if r["type"] != "pad":
|
|
369
|
+
continue
|
|
370
|
+
x, y = _a2k(r["x"], r["y"])
|
|
371
|
+
w, h = _mm(r["w"]), _mm(r["h"])
|
|
372
|
+
shape = {1: "round", 2: "rect", 9: "roundrect", 3: "octagonal"}.get(r["shape"], f"shape {r['shape']}")
|
|
373
|
+
corner = float(r["corner"] or 0) if shape == "roundrect" else 0.0
|
|
374
|
+
if shape == "roundrect" and corner < 0.5:
|
|
375
|
+
shape = "rect"
|
|
376
|
+
pad = {"num": r["designator"], "x": x, "y": y, "shape": shape, "corner": corner, "approx": []}
|
|
377
|
+
if id(r) in custom:
|
|
378
|
+
pad["anchor"] = "circle" if r["shape"] == 1 else "rect"
|
|
379
|
+
pad["shape"] = "custom"
|
|
380
|
+
pts = [_a2k(px, py) for poly in custom[id(r)] for px, py in poly]
|
|
381
|
+
pad["outline"] = _bbox(pts)
|
|
382
|
+
if r["layer"] == AL.MULTI:
|
|
383
|
+
pad["side"] = "THT" if r["plated"] else "NPTH"
|
|
384
|
+
else:
|
|
385
|
+
pad["side"] = "F" if r["layer"] == AL.TOP else "B" if r["layer"] == AL.BOTTOM else f"layer {r['layer']}"
|
|
386
|
+
if r["hole"]:
|
|
387
|
+
hole = _mm(r["hole"])
|
|
388
|
+
slot = _mm(r["slot"]) if r["hole_shape"] == 2 and r["slot"] > r["hole"] else hole
|
|
389
|
+
ang = None if slot - hole < 1e-9 else (r["rot"] + r["hole_rot"]) % 180.0
|
|
390
|
+
pad["drill"] = (hole, slot, ang)
|
|
391
|
+
ox, oy = r.get("offset") or (0, 0)
|
|
392
|
+
pad["offset"] = _turn((_mm(ox), -_mm(oy)), r["rot"])
|
|
393
|
+
if r["hole_shape"] == 1:
|
|
394
|
+
pad["approx"].append("square hole")
|
|
395
|
+
else:
|
|
396
|
+
pad["drill"] = None
|
|
397
|
+
pad["offset"] = (0.0, 0.0)
|
|
398
|
+
pad["w"], pad["h"], pad["rot"] = canon_pad(w, h, r["rot"], pad["shape"] == "round")
|
|
399
|
+
closed = lambda mode, v: mode == 2 and v is not None and v <= -max(r["w"], r["h"]) // 2
|
|
400
|
+
if pad["side"] in ("F", "B"):
|
|
401
|
+
pad["paste"] = not closed(r["paste_mode"], r["paste"])
|
|
402
|
+
pad["paste_margin"] = _mm(r["paste"]) if pad["paste"] and r["paste_mode"] == 2 and r["paste"] else None
|
|
403
|
+
else:
|
|
404
|
+
pad["paste_margin"] = None
|
|
405
|
+
pad["mask"] = not closed(r["mask_mode"], r["mask"])
|
|
406
|
+
pad["mask_margin"] = _mm(r["mask"]) if pad["mask"] and r["mask_mode"] == 2 else None
|
|
407
|
+
out["pads"].append(pad)
|
|
408
|
+
win_layers = {AL.TOP_PASTE: "paste", AL.BOTTOM_PASTE: "paste", AL.TOP_SOLDER: "mask", AL.BOTTOM_SOLDER: "mask"}
|
|
409
|
+
cy, out["copper"] = [], []
|
|
410
|
+
for r in recs:
|
|
411
|
+
if id(r) in used:
|
|
412
|
+
continue
|
|
413
|
+
t, layer = r["type"], r.get("layer")
|
|
414
|
+
if layer in (AL.TOP, AL.BOTTOM) and t in ("region", "fill", "track"): # copper that is not part of a pad
|
|
415
|
+
q = [_a2k(px, py) for px, py in r["pts"]] if t == "region" else [_a2k(r["x1"], r["y1"]), _a2k(r["x2"], r["y2"])]
|
|
416
|
+
hw = _mm(r.get("w") or 0) / 2 if t == "track" else 0.0
|
|
417
|
+
b = _bbox(q)
|
|
418
|
+
out["copper"].append((b[0] - hw, b[1] - hw, b[2] + hw, b[3] + hw))
|
|
419
|
+
if t == "fill" and layer in win_layers:
|
|
420
|
+
a, b = _a2k(r["x1"], r["y1"]), _a2k(r["x2"], r["y2"])
|
|
421
|
+
out["windows"].append((win_layers[layer], _bbox([a, b])))
|
|
422
|
+
elif t == "region" and layer in win_layers:
|
|
423
|
+
out["windows"].append((win_layers[layer], _bbox(_a2k(px, py) for px, py in r["pts"])))
|
|
424
|
+
if layer != courtyard:
|
|
425
|
+
continue
|
|
426
|
+
if t == "track":
|
|
427
|
+
cy += [_a2k(r["x1"], r["y1"]), _a2k(r["x2"], r["y2"])]
|
|
428
|
+
elif t == "arc":
|
|
429
|
+
cy += [_a2k(px, py) for px, py in _arc_bbox(r["cx"], r["cy"], r["r"], r["a0"], r["a1"])]
|
|
430
|
+
elif t == "region":
|
|
431
|
+
cy += [_a2k(px, py) for px, py in r["pts"]]
|
|
432
|
+
elif t == "fill":
|
|
433
|
+
cy += [_a2k(r["x1"], r["y1"]), _a2k(r["x2"], r["y2"])]
|
|
434
|
+
out["courtyard"] = _bbox(cy)
|
|
435
|
+
out["bodies"] = sum(1 for r in recs if r["type"] == "body")
|
|
436
|
+
out["embedded"] = len(fp.get("models") or [])
|
|
437
|
+
return out
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def altium_symbol(entry):
|
|
441
|
+
"""One symbol from altium_lib.read_schlib -> canonical symbol dict."""
|
|
442
|
+
recs = entry["records"]
|
|
443
|
+
net = {int(r["OWNERINDEX"]): r.get("TEXT", "") for r in recs if r.get("RECORD") == "41" and r.get("NAME") == "HiddenNetName"
|
|
444
|
+
and str(r.get("OWNERINDEX", "")).lstrip("-").isdigit()}
|
|
445
|
+
pins, params, link = {}, {}, None
|
|
446
|
+
for i, r in enumerate(recs):
|
|
447
|
+
if r.get("RECORD") == "2" and not r.get("mode"):
|
|
448
|
+
hidden = bool(r["conglomerate"] & 0x04)
|
|
449
|
+
pins.setdefault(r["num"], {"name": r["name"], "type": r["etype"], "hidden": hidden, "net": net.get(i) if hidden else None})
|
|
450
|
+
elif r.get("RECORD") == "41" and r.get("NAME") not in (None, "HiddenNetName", "Comment"):
|
|
451
|
+
params[r["NAME"]] = r.get("TEXT", "")
|
|
452
|
+
elif r.get("RECORD") == "45" and str(r.get("MODELTYPE", "")).upper() == "PCBLIB":
|
|
453
|
+
link = r.get("MODELNAME")
|
|
454
|
+
desc = recs[0].get("COMPONENTDESCRIPTION", "") if recs else ""
|
|
455
|
+
return {"name": entry["name"], "pins": pins, "params": params, "footprint": link, "description": desc}
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def pyaltiumlib_pads(path):
|
|
459
|
+
"""[(designator, x, y, w, h, hole mm)] read by pyaltiumlib (KiCad frame), or None when it is not installed."""
|
|
460
|
+
try:
|
|
461
|
+
import logging
|
|
462
|
+
logging.getLogger("pyaltiumlib").setLevel(logging.CRITICAL)
|
|
463
|
+
import pyaltiumlib
|
|
464
|
+
except ImportError:
|
|
465
|
+
return None
|
|
466
|
+
parts = pyaltiumlib.read(str(path)).Parts
|
|
467
|
+
out = []
|
|
468
|
+
for r in parts[0].Records if parts else []:
|
|
469
|
+
if type(r).__name__ != "PcbPad":
|
|
470
|
+
continue
|
|
471
|
+
mil = 0.0254
|
|
472
|
+
out.append((str(r.designator), float(r.location.x) * mil, float(r.location.y) * mil, # pyaltiumlib: y down
|
|
473
|
+
abs(float(r.size_top.x)) * mil, abs(float(r.size_top.y)) * mil, float(r.hole_size or 0) * mil))
|
|
474
|
+
return out
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
# ---------------------------------------------------------------- comparisons
|
|
478
|
+
def _d(out, level, what, field, k, a, note=""):
|
|
479
|
+
msg = f"{what} {field}: KiCad {_fmt(k)} / Altium {_fmt(a)}" + (f" ({note})" if note else "")
|
|
480
|
+
out.append({"level": level, "what": what, "field": field, "kicad": k, "altium": a, "msg": msg})
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _near(a, b, tol):
|
|
484
|
+
return a is not None and b is not None and abs(a - b) <= tol
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _pair_pads(kp, ap):
|
|
488
|
+
"""Match pads by number, then by position within a number. -> ([(k, a)], [k only], [a only])."""
|
|
489
|
+
key = lambda p: (p["num"], round(p["x"], 3), round(p["y"], 3))
|
|
490
|
+
by_k, by_a = {}, {}
|
|
491
|
+
for p in sorted(kp, key=key):
|
|
492
|
+
by_k.setdefault(p["num"], []).append(p)
|
|
493
|
+
for p in sorted(ap, key=key):
|
|
494
|
+
by_a.setdefault(p["num"], []).append(p)
|
|
495
|
+
pairs, konly, aonly = [], [], []
|
|
496
|
+
for n in sorted(set(by_k) | set(by_a)):
|
|
497
|
+
ks, as_ = by_k.get(n, []), by_a.get(n, [])
|
|
498
|
+
rest = list(as_)
|
|
499
|
+
for k in ks: # nearest unmatched Altium pad with that number
|
|
500
|
+
if not rest:
|
|
501
|
+
konly.append(k); continue
|
|
502
|
+
j = min(range(len(rest)), key=lambda i: math.hypot(rest[i]["x"] - k["x"], rest[i]["y"] - k["y"]))
|
|
503
|
+
pairs.append((k, rest.pop(j)))
|
|
504
|
+
aonly += rest
|
|
505
|
+
return pairs, konly, aonly
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def compare_pad(k, a, out):
|
|
509
|
+
what = f"pad {k['num'] or '(unnumbered)'}"
|
|
510
|
+
curved = k["shape"] == "custom" and k.get("outline") is None and a["shape"] != "custom"
|
|
511
|
+
if not curved and not (_near(k["x"], a["x"], TOL["position"]) and _near(k["y"], a["y"], TOL["position"])):
|
|
512
|
+
_d(out, "error", what, "position", (k["x"], k["y"]), (a["x"], a["y"]), "mm, KiCad frame")
|
|
513
|
+
if k["side"] != a["side"]:
|
|
514
|
+
_d(out, "error", what, "side", k["side"], a["side"])
|
|
515
|
+
ko, ao = k.get("offset") or (0.0, 0.0), a.get("offset") or (0.0, 0.0)
|
|
516
|
+
if abs(ko[0] - ao[0]) > TOL["position"] or abs(ko[1] - ao[1]) > TOL["position"]:
|
|
517
|
+
_d(out, "error", what, "copper offset from hole", ko, ao, "mm, KiCad frame")
|
|
518
|
+
kd, ad = k.get("drill"), a.get("drill")
|
|
519
|
+
if bool(kd) != bool(ad):
|
|
520
|
+
_d(out, "error", what, "drill", kd, ad)
|
|
521
|
+
elif kd:
|
|
522
|
+
if not (_near(kd[0], ad[0], TOL["drill"]) and _near(kd[1], ad[1], TOL["drill"])):
|
|
523
|
+
_d(out, "error", what, "drill", kd[:2], ad[:2], "width, length mm")
|
|
524
|
+
elif (kd[2] is None) != (ad[2] is None) or kd[2] is not None and _angle_diff(kd[2], ad[2]) > TOL["rotation"]:
|
|
525
|
+
_d(out, "error", what, "slot direction", kd[2], ad[2], "degrees")
|
|
526
|
+
npth = k["side"] == "NPTH" and a["side"] == "NPTH"
|
|
527
|
+
kshape, ashape = k["shape"], a["shape"]
|
|
528
|
+
approx = k["approx"] + a["approx"]
|
|
529
|
+
if kshape == "custom" and k.get("outline") is None and ashape != "custom":
|
|
530
|
+
return # curves: the bounding rectangle (kicad_import); reported once per footprint
|
|
531
|
+
if kshape != ashape and not npth:
|
|
532
|
+
_d(out, "warn" if approx else "error", what, "shape", kshape, ashape, ", ".join(approx))
|
|
533
|
+
if not approx:
|
|
534
|
+
return
|
|
535
|
+
elif kshape == "roundrect" and abs(k["corner"] - a["corner"]) > TOL["corner"]:
|
|
536
|
+
_d(out, "error", what, "corner radius", k["corner"], a["corner"], "% of the shorter side / 2")
|
|
537
|
+
if kshape == "custom" and ashape == "custom":
|
|
538
|
+
if k.get("anchor") != a.get("anchor"):
|
|
539
|
+
_d(out, "error", what, "custom anchor", k.get("anchor"), a.get("anchor"))
|
|
540
|
+
ko, ao = k.get("outline"), a.get("outline")
|
|
541
|
+
if ko and ao and any(abs(p - q) > TOL["outline"] for p, q in zip(ko, ao)):
|
|
542
|
+
_d(out, "error", what, "custom outline", ko, ao, "bounding box mm")
|
|
543
|
+
size_k, size_a = (k["w"], k["h"], k["rot"]), (a["w"], a["h"], a["rot"])
|
|
544
|
+
if not (_near(k["w"], a["w"], TOL["size"]) and _near(k["h"], a["h"], TOL["size"]) and
|
|
545
|
+
_angle_diff(k["rot"], a["rot"], 90.0) <= TOL["rotation"]):
|
|
546
|
+
if not npth: # NPTH: Altium's pad is the hole (no copper); the drill is compared
|
|
547
|
+
_d(out, "warn" if approx else "error", what, "size/rotation", size_k, size_a, "w, h mm, rotation deg")
|
|
548
|
+
if k["side"] in ("F", "B") and a["side"] in ("F", "B"):
|
|
549
|
+
if k.get("paste") != a.get("paste"):
|
|
550
|
+
_d(out, "error", what, "paste", "on" if k.get("paste") else "off", "on" if a.get("paste") else "off")
|
|
551
|
+
elif k.get("paste") and abs((k.get("paste_margin") or 0) - (a.get("paste_margin") or 0)) > TOL["margin"]:
|
|
552
|
+
_d(out, "error", what, "paste margin", k.get("paste_margin"), a.get("paste_margin"), "mm")
|
|
553
|
+
if not npth:
|
|
554
|
+
if k["mask"] != a["mask"]:
|
|
555
|
+
_d(out, "error", what, "solder mask", "open" if k["mask"] else "tented", "open" if a["mask"] else "tented")
|
|
556
|
+
elif k["mask"] and abs((k.get("mask_margin") or 0) - (a.get("mask_margin") or 0)) > TOL["margin"]:
|
|
557
|
+
_d(out, "error", what, "mask margin", k.get("mask_margin"), a.get("mask_margin"), "mm")
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def exposed_pad(fp):
|
|
561
|
+
"""The exposed pad: the biggest SMD pad over the footprint origin, with the paste windows inside it."""
|
|
562
|
+
cands = [p for p in fp["pads"] if p["side"] in ("F", "B") and not p.get("drill")
|
|
563
|
+
and abs(p["x"]) <= p["w"] / 2 + 1e-9 and abs(p["y"]) <= p["h"] / 2 + 1e-9 and len(fp["pads"]) > 2]
|
|
564
|
+
if not cands:
|
|
565
|
+
return None
|
|
566
|
+
ep = max(cands, key=lambda p: p["w"] * p["h"])
|
|
567
|
+
inside = [b for k, b in fp["windows"] if k == "paste" and b and ep["x"] - ep["w"] / 2 - 1e-6 <= (b[0] + b[2]) / 2 <= ep["x"] + ep["w"] / 2 + 1e-6
|
|
568
|
+
and ep["y"] - ep["h"] / 2 - 1e-6 <= (b[1] + b[3]) / 2 <= ep["y"] + ep["h"] / 2 + 1e-6]
|
|
569
|
+
area = sum((b[2] - b[0]) * (b[3] - b[1]) for b in inside)
|
|
570
|
+
return {"num": ep["num"], "w": ep["w"], "h": ep["h"], "windows": len(inside),
|
|
571
|
+
"coverage": round(100.0 * area / (ep["w"] * ep["h"]), 2) if inside else (100.0 if ep.get("paste") else 0.0)}
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def copper_extent(fp):
|
|
575
|
+
"""Bounding box of all copper: pads (custom outlines included) and footprint copper graphics."""
|
|
576
|
+
pts = []
|
|
577
|
+
for p in fp["pads"]:
|
|
578
|
+
if p.get("side") == "NPTH":
|
|
579
|
+
continue
|
|
580
|
+
pts += _rot_pts(p["x"], p["y"], p["w"], p["h"], p["rot"])
|
|
581
|
+
o = p.get("outline")
|
|
582
|
+
if o:
|
|
583
|
+
pts += [(o[0], o[1]), (o[2], o[3])]
|
|
584
|
+
for b in fp.get("copper") or []:
|
|
585
|
+
pts += [(b[0], b[1]), (b[2], b[3])]
|
|
586
|
+
return _bbox(pts)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def compare_footprints(k, a, export=False, model_ok=None):
|
|
590
|
+
"""Canonical KiCad and Altium footprints -> diffs. export: the KiCad side is a kicad-export library, so its 3D
|
|
591
|
+
model must be there (model_ok: whether each (model ...) path resolves to a file)."""
|
|
592
|
+
out = []
|
|
593
|
+
approx = list(k.get("approx", [])) + [f"pad {p['num'] or '(unnumbered)'}: {x}" for p in k["pads"] for x in p["approx"]]
|
|
594
|
+
lvl = "warn" if approx else "error" # windows / exposed pad of a footprint with approximated shapes
|
|
595
|
+
for x in approx:
|
|
596
|
+
_d(out, "warn", "approximation", "shape", x, "as written", "kicad_import")
|
|
597
|
+
pairs, konly, aonly = _pair_pads(k["pads"], a["pads"])
|
|
598
|
+
if len(k["pads"]) != len(a["pads"]):
|
|
599
|
+
_d(out, "error", "footprint", "pad count", len(k["pads"]), len(a["pads"]))
|
|
600
|
+
for p in konly:
|
|
601
|
+
_d(out, "error", f"pad {p['num'] or '(unnumbered)'}", "missing", "present", "missing", f"at {_fmt((p['x'], p['y']))}")
|
|
602
|
+
for p in aonly:
|
|
603
|
+
_d(out, "error", f"pad {p['num'] or '(unnumbered)'}", "missing", "missing", "present", f"at {_fmt((p['x'], p['y']))}")
|
|
604
|
+
for kp, ap in pairs:
|
|
605
|
+
compare_pad(kp, ap, out)
|
|
606
|
+
if k.get("copper") or a.get("copper"): # footprint copper next to / under pads: Altium regions belong to no pad
|
|
607
|
+
ke, ae = copper_extent(k), copper_extent(a)
|
|
608
|
+
same = ke and ae and all(abs(p - q) <= TOL["outline"] for p, q in zip(ke, ae))
|
|
609
|
+
for d in out:
|
|
610
|
+
if same and d["level"] == "error" and d["field"] in ("shape", "custom outline", "custom anchor"):
|
|
611
|
+
d["level"] = "warn"
|
|
612
|
+
d["msg"] += " (pad or footprint copper: the copper itself is the same, extent " + _fmt(ke) + ")"
|
|
613
|
+
kw = sorted((w, tuple(round(v, 3) for v in b)) for w, b in k["windows"] if b)
|
|
614
|
+
aw = sorted((w, tuple(round(v, 3) for v in b)) for w, b in a["windows"] if b)
|
|
615
|
+
if len(kw) != len(aw):
|
|
616
|
+
_d(out, lvl, "footprint", "paste/mask windows", len(kw), len(aw), "windows without copper")
|
|
617
|
+
else: # pair each KiCad window with the nearest Altium one of its kind
|
|
618
|
+
left = list(aw)
|
|
619
|
+
for wk, bk in kw:
|
|
620
|
+
same = [x for x in left if x[0] == wk] or left
|
|
621
|
+
best = min(same, key=lambda x: max(abs(p - q) for p, q in zip(bk, x[1])))
|
|
622
|
+
left.remove(best)
|
|
623
|
+
if best[0] != wk or any(abs(p - q) > TOL["window"] for p, q in zip(bk, best[1])):
|
|
624
|
+
_d(out, lvl, f"{wk} window", "extent", bk, best[1], "x0, y0, x1, y1 mm")
|
|
625
|
+
ek, ea = exposed_pad(k), exposed_pad(a)
|
|
626
|
+
if bool(ek) != bool(ea):
|
|
627
|
+
_d(out, lvl, "exposed pad", "presence", ek and ek["num"], ea and ea["num"])
|
|
628
|
+
elif ek and (ek["windows"] != ea["windows"] or abs(ek["coverage"] - ea["coverage"]) > TOL["coverage"]):
|
|
629
|
+
_d(out, lvl, "exposed pad", "paste", f"{ek['windows']} windows {ek['coverage']}%", f"{ea['windows']} windows {ea['coverage']}%")
|
|
630
|
+
ck, ca = k["courtyard"], a["courtyard"]
|
|
631
|
+
if bool(ck) != bool(ca):
|
|
632
|
+
_d(out, "error", "courtyard", "presence", ck, ca)
|
|
633
|
+
elif ck and any(abs(p - q) > TOL["courtyard"] for p, q in zip(ck, ca)):
|
|
634
|
+
_d(out, "error", "courtyard", "extent", ck, ca, "x0, y0, x1, y1 mm")
|
|
635
|
+
has_a = a["bodies"] > 0 or a["embedded"] > 0
|
|
636
|
+
if export:
|
|
637
|
+
ok = [m for m, good in zip(k["models"], model_ok or [True] * len(k["models"])) if good]
|
|
638
|
+
if bool(ok) != has_a:
|
|
639
|
+
_d(out, "error", "3D model", "presence", (ok or ["missing" if not k["models"] else "file missing"])[0],
|
|
640
|
+
f"{a['bodies']} body" + ("" if a["bodies"] == 1 else "ies") if has_a else "none")
|
|
641
|
+
elif k["models"] and not has_a:
|
|
642
|
+
_d(out, "error", "3D model", "presence", k["models"][0], "none")
|
|
643
|
+
return out
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def compare_symbols(k, a):
|
|
647
|
+
out = []
|
|
648
|
+
kn, an = set(k["pins"]), set(a["pins"])
|
|
649
|
+
if len(kn) != len(an):
|
|
650
|
+
_d(out, "error", "symbol", "pin count", len(kn), len(an))
|
|
651
|
+
for n in sorted(kn - an):
|
|
652
|
+
_d(out, "error", f"pin {n}", "missing", k["pins"][n]["name"], "missing")
|
|
653
|
+
for n in sorted(an - kn):
|
|
654
|
+
_d(out, "error", f"pin {n}", "missing", "missing", a["pins"][n]["name"])
|
|
655
|
+
for n in sorted(kn & an):
|
|
656
|
+
kp, ap = k["pins"][n], a["pins"][n]
|
|
657
|
+
kname = kicad_pin_name(kp["name"])
|
|
658
|
+
names = {ap["name"]} | ({kicad_pin_name(ap["net"]), ap["net"]} if ap.get("net") else set())
|
|
659
|
+
if kname not in names:
|
|
660
|
+
_d(out, "error", f"pin {n}", "name", kp["name"], ap["name"])
|
|
661
|
+
kt = kicad_type(kp["type"])
|
|
662
|
+
stacked = kp["hidden"] and kp["type"] == "passive" and ap["type"] == AL.ETYPE["power"] # KLC S4.3 (kicad_export)
|
|
663
|
+
if kt != ap["type"] and not stacked:
|
|
664
|
+
from .kicad_export import ETYPE_K
|
|
665
|
+
_d(out, "error", f"pin {n}", "electrical type", kp["type"], ETYPE_K.get(ap["type"], ap["type"]))
|
|
666
|
+
return out
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def compare_pin_pad(k_pins, k_pads, a_pins, a_pads):
|
|
670
|
+
"""Pins that have a pad in one tool but not in the other (mechanical pads may have no pin)."""
|
|
671
|
+
out = []
|
|
672
|
+
kmiss, amiss = set(k_pins) - set(k_pads), set(a_pins) - set(a_pads)
|
|
673
|
+
for n in sorted(kmiss ^ amiss): # missing in both: the release check's `pins` rule says so
|
|
674
|
+
_d(out, "error", f"pin {n}", "pad", "no pad" if n in kmiss else "pad", "no pad" if n in amiss else "pad",
|
|
675
|
+
"pin -> pad mapping differs")
|
|
676
|
+
return out
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
KEY_FIELDS = ("Value", "Description", "Manufacturer", "MPN", "Datasheet")
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def kicad_fields(props, rules=None):
|
|
683
|
+
"""KiCad symbol properties -> {key field: value}, honouring the rules' house names."""
|
|
684
|
+
from . import rules as RU
|
|
685
|
+
names = (rules or RU.DEFAULTS)["parameters"]["names"] or {}
|
|
686
|
+
col = {"Value": "Value", "Description": "Description", "Datasheet": "Datasheet",
|
|
687
|
+
"Manufacturer": names.get("Manufacturer", "Manufacturer"), "MPN": names.get("Manufacturer Part Number", "MPN")}
|
|
688
|
+
return {k: str(props.get(c, "") or "").strip() for k, c in col.items()}
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def altium_fields(row, rules=None):
|
|
692
|
+
"""A DbLib row (or the plan's parameters as one) -> {key field: value}."""
|
|
693
|
+
from . import rules as RU
|
|
694
|
+
p = lambda k: RU.param_name(k, rules)
|
|
695
|
+
get = lambda *ks: next((str(row.get(k) or "").strip() for k in ks if str(row.get(k) or "").strip()), "")
|
|
696
|
+
return {"Value": get(p("Value"), "Value", "Comment"), "Description": get("Description"),
|
|
697
|
+
"Manufacturer": get(p("Manufacturer"), "Manufacturer"),
|
|
698
|
+
"MPN": get(p("Manufacturer Part Number"), "Manufacturer Part Number"),
|
|
699
|
+
"Datasheet": get(p("ComponentLink2URL"), "ComponentLink2URL", p("Datasheet"), "Datasheet")}
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def plan_row(item):
|
|
703
|
+
row = {"Part Number": item["name"], "Description": item.get("description", ""), "Comment": item.get("comment", "")}
|
|
704
|
+
row.update({p["name"]: str(p["value"]) for p in item.get("parameters", [])})
|
|
705
|
+
return row
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def compare_fields(kf, af, what="fields"):
|
|
709
|
+
out = []
|
|
710
|
+
for f in KEY_FIELDS:
|
|
711
|
+
if kf.get(f, "") != af.get(f, ""):
|
|
712
|
+
_d(out, "error", what, f, kf.get(f, ""), af.get(f, ""))
|
|
713
|
+
return out
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
# ---------------------------------------------------------------- per part
|
|
717
|
+
def _courtyard_layer(rules):
|
|
718
|
+
from . import rules as RU
|
|
719
|
+
return RU.layer_id(((rules or RU.DEFAULTS).get("layers") or {}).get("courtyard", "Mechanical15"))
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def footprint_pair(kicad_text, pcblib_fp, rules=None, export=False, model_ok=None):
|
|
723
|
+
"""Diffs between one .kicad_mod text and one read_pcblib footprint."""
|
|
724
|
+
return compare_footprints(kicad_footprint(kicad_text), altium_footprint(pcblib_fp, _courtyard_layer(rules)), export, model_ok)
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def release_diffs(kicad_text, pcblib_fp, schlib_syms=None, rules=None):
|
|
728
|
+
"""What release_check runs (plan mode, files already read): footprint parity plus SchLib pins -> KiCad pads."""
|
|
729
|
+
k, a = kicad_footprint(kicad_text), altium_footprint(pcblib_fp, _courtyard_layer(rules))
|
|
730
|
+
out = compare_footprints(k, a)
|
|
731
|
+
if schlib_syms:
|
|
732
|
+
pins = altium_symbol(schlib_syms[0])["pins"]
|
|
733
|
+
out += compare_pin_pad(pins, {p["num"] for p in k["pads"]}, pins, {p["num"] for p in a["pads"]})
|
|
734
|
+
return out
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def _second_reader(path, out):
|
|
738
|
+
"""pyaltiumlib re-reads the .PcbLib; its pads must agree with ecadlib's reader."""
|
|
739
|
+
try:
|
|
740
|
+
theirs = pyaltiumlib_pads(path)
|
|
741
|
+
except Exception as e: # its own parser failing is a finding, not a crash
|
|
742
|
+
_d(out, "error", "pyaltiumlib", "read", "-", f"{type(e).__name__}: {e}")
|
|
743
|
+
return False
|
|
744
|
+
if theirs is None:
|
|
745
|
+
return None
|
|
746
|
+
raw = [r for r in AL.read_pcblib(path)[0]["records"] if r["type"] == "pad"]
|
|
747
|
+
if len(theirs) != len(raw):
|
|
748
|
+
_d(out, "error", "pyaltiumlib", "pad count", len(raw), len(theirs), "ecadlib reader vs pyaltiumlib")
|
|
749
|
+
return False
|
|
750
|
+
for (d, x, y, w, h, hole), r in zip(theirs, raw):
|
|
751
|
+
mx, my = _a2k(r["x"], r["y"])
|
|
752
|
+
if d != r["designator"] or abs(x - mx) > 0.01 or abs(y - my) > 0.01 or abs(w - _mm(r["w"])) > 0.01 \
|
|
753
|
+
or abs(h - _mm(r["h"])) > 0.01 or abs(hole - _mm(r["hole"])) > 0.01:
|
|
754
|
+
_d(out, "error", f"pad {d}", "pyaltiumlib", (r["designator"], mx, my, _mm(r["w"]), _mm(r["h"]), _mm(r["hole"])),
|
|
755
|
+
(d, x, y, w, h, hole), "ecadlib reader vs pyaltiumlib")
|
|
756
|
+
return False
|
|
757
|
+
return True
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def _rel(base, p):
|
|
761
|
+
if not p:
|
|
762
|
+
return None
|
|
763
|
+
p = Path(p)
|
|
764
|
+
return p if p.is_absolute() else Path(base) / p
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
class KiCadLib:
|
|
768
|
+
"""A kicad-export folder: <lib>.kicad_sym, <lib>.pretty/, <lib>.3dshapes/."""
|
|
769
|
+
|
|
770
|
+
def __init__(self, folder, name=None):
|
|
771
|
+
self.dir = Path(folder)
|
|
772
|
+
syms = sorted(self.dir.glob("*.kicad_sym"))
|
|
773
|
+
if name is None:
|
|
774
|
+
if len(syms) != 1:
|
|
775
|
+
raise ValueError(f"{folder}: {len(syms)} .kicad_sym files; pass --name")
|
|
776
|
+
name = syms[0].stem
|
|
777
|
+
self.name = name
|
|
778
|
+
f = self.dir / f"{name}.kicad_sym"
|
|
779
|
+
self.symbols = {}
|
|
780
|
+
if f.is_file():
|
|
781
|
+
t = sexpr.parse(f.read_text(encoding="utf-8"))
|
|
782
|
+
self.symbols = {str(s[1]): s for s in sexpr.find(t, "symbol")}
|
|
783
|
+
self.pretty = self.dir / f"{name}.pretty"
|
|
784
|
+
|
|
785
|
+
def footprint(self, fp_name):
|
|
786
|
+
f = self.pretty / f"{fp_name}.kicad_mod"
|
|
787
|
+
return f.read_text(encoding="utf-8") if f.is_file() else None
|
|
788
|
+
|
|
789
|
+
def model_ok(self, path):
|
|
790
|
+
p = str(path)
|
|
791
|
+
if "${KIPRJMOD}" in p:
|
|
792
|
+
return (self.dir / p.replace("${KIPRJMOD}/", "").replace("${KIPRJMOD}", "")).is_file()
|
|
793
|
+
return Path(p).is_file()
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def check_item(item, base=".", rules=None, kicad=None, altium_rows=None, second_reader=False):
|
|
797
|
+
"""Parity of one plan item -> {"part", "result" (match | mismatch | skipped), "diffs", "checked"}.
|
|
798
|
+
kicad: a KiCadLib (export mode) or None (plan mode: the plan's .kicad_mod). altium_rows: {part: DbLib row}."""
|
|
799
|
+
from . import kicad_export as X, release_check as RC
|
|
800
|
+
name = item["name"]
|
|
801
|
+
res = {"part": name, "result": "match", "diffs": [], "checked": []}
|
|
802
|
+
diffs = res["diffs"]
|
|
803
|
+
if item.get("a365") and item.get("status") in ("existing", "deleted_in_workspace"):
|
|
804
|
+
return dict(res, result="skipped", why="in the Altium 365 workspace (no files here)")
|
|
805
|
+
fp_f, sym_f = _rel(base, (item.get("footprint") or {}).get("file")), _rel(base, (item.get("symbol") or {}).get("file"))
|
|
806
|
+
fps = AL.read_pcblib(fp_f) if fp_f and fp_f.is_file() else None
|
|
807
|
+
syms = AL.read_schlib(sym_f)[1] if sym_f and sym_f.is_file() else None
|
|
808
|
+
a_fp = altium_footprint(fps[0], _courtyard_layer(rules)) if fps else None
|
|
809
|
+
a_sym = altium_symbol(syms[0]) if syms else None
|
|
810
|
+
k_fp = k_sym = None
|
|
811
|
+
if kicad is None:
|
|
812
|
+
kmod = RC._kicad_mod(item, base, fp_f) if fp_f else None
|
|
813
|
+
if kmod:
|
|
814
|
+
k_fp = kicad_footprint(kmod.read_text(encoding="utf-8"))
|
|
815
|
+
res["kicad_footprint"] = str(kmod)
|
|
816
|
+
else:
|
|
817
|
+
sname = X.kname(name)
|
|
818
|
+
sym = kicad.symbols.get(sname)
|
|
819
|
+
if sym is not None:
|
|
820
|
+
k_sym = kicad_symbol(sym)
|
|
821
|
+
res["kicad_symbol"] = f"{kicad.name}:{sname}"
|
|
822
|
+
fref = (item.get("footprint") or {}).get("ref")
|
|
823
|
+
if fref:
|
|
824
|
+
text = kicad.footprint(X.kname(fref))
|
|
825
|
+
if text is not None:
|
|
826
|
+
k_fp = kicad_footprint(text)
|
|
827
|
+
res["kicad_footprint"] = f"{kicad.name}:{X.kname(fref)}"
|
|
828
|
+
if k_sym is None and a_sym:
|
|
829
|
+
_d(diffs, "error", "symbol", "presence", "missing", a_sym["name"], f"not in {kicad.name}.kicad_sym")
|
|
830
|
+
if k_fp is None and a_fp:
|
|
831
|
+
_d(diffs, "error", "footprint", "presence", "missing", a_fp["name"], f"not in {kicad.name}.pretty")
|
|
832
|
+
if k_fp and a_fp:
|
|
833
|
+
model_ok = [kicad.model_ok(m) for m in k_fp["models"]] if kicad is not None else None
|
|
834
|
+
diffs += compare_footprints(k_fp, a_fp, kicad is not None, model_ok)
|
|
835
|
+
res["checked"].append(f"footprint ({len(a_fp['pads'])} pads)")
|
|
836
|
+
if second_reader and fp_f:
|
|
837
|
+
ok = _second_reader(fp_f, diffs)
|
|
838
|
+
if ok:
|
|
839
|
+
res["checked"].append("pyaltiumlib")
|
|
840
|
+
if k_sym and a_sym:
|
|
841
|
+
diffs += compare_symbols(k_sym, a_sym)
|
|
842
|
+
res["checked"].append(f"symbol ({len(a_sym['pins'])} pins)")
|
|
843
|
+
kf = k_sym["props"].get("Footprint", "")
|
|
844
|
+
want = f"{kicad.name}:{X.kname(a_fp['name'])}" if a_fp else ""
|
|
845
|
+
if a_fp and kf != want:
|
|
846
|
+
_d(diffs, "error", "symbol", "footprint link", kf, want)
|
|
847
|
+
if a_sym.get("footprint") and a_fp and AL.lib_name(a_sym["footprint"]) != AL.lib_name(a_fp["name"]):
|
|
848
|
+
_d(diffs, "error", "Altium symbol", "footprint link", a_fp["name"], a_sym["footprint"], "SchLib link vs PcbLib name")
|
|
849
|
+
row = (altium_rows or {}).get(name) or plan_row(item)
|
|
850
|
+
diffs += compare_fields(kicad_fields(k_sym["props"], rules), altium_fields(row, rules))
|
|
851
|
+
res["checked"].append("fields (" + ("DbLib row" if (altium_rows or {}).get(name) else "plan parameters") + ")")
|
|
852
|
+
if a_sym and (k_fp or a_fp):
|
|
853
|
+
k_pins = k_sym["pins"] if k_sym else a_sym["pins"]
|
|
854
|
+
diffs += compare_pin_pad(k_pins, {p["num"] for p in (k_fp or a_fp)["pads"]}, a_sym["pins"],
|
|
855
|
+
{p["num"] for p in (a_fp or k_fp)["pads"]})
|
|
856
|
+
res["checked"].append("pin -> pad")
|
|
857
|
+
if not res["checked"]:
|
|
858
|
+
res["result"], res["why"] = "skipped", "nothing to compare (" + ", ".join(
|
|
859
|
+
w for w, v in (("no KiCad footprint", k_fp), ("no .PcbLib", a_fp), ("no .SchLib", a_sym)) if not v) + ")"
|
|
860
|
+
elif any(d["level"] == "error" for d in diffs):
|
|
861
|
+
res["result"] = "mismatch"
|
|
862
|
+
return res
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
# ---------------------------------------------------------------- databases: .kicad_dbl + SQLite vs .DbLib
|
|
866
|
+
def kicad_db_rows(folder, lib):
|
|
867
|
+
"""{Part ID: row} from <lib>.sqlite (kicad-dblib), or None when there is none."""
|
|
868
|
+
import sqlite3
|
|
869
|
+
f = Path(folder) / f"{lib}.sqlite"
|
|
870
|
+
if not f.is_file() or not (Path(folder) / f"{lib}.kicad_dbl").is_file():
|
|
871
|
+
return None
|
|
872
|
+
out = {}
|
|
873
|
+
con = sqlite3.connect(f"file:{f}?mode=ro", uri=True)
|
|
874
|
+
try:
|
|
875
|
+
for (t,) in con.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"):
|
|
876
|
+
cur = con.execute(f'SELECT * FROM "{t}"')
|
|
877
|
+
cols = [c[0] for c in cur.description]
|
|
878
|
+
for r in cur:
|
|
879
|
+
row = dict(zip(cols, ("" if v is None else str(v) for v in r)))
|
|
880
|
+
out[row.get("Part ID", "")] = dict(row, _table=t)
|
|
881
|
+
finally:
|
|
882
|
+
con.close()
|
|
883
|
+
return out
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def altium_db_rows(folder):
|
|
887
|
+
"""{Part Number: row} from the DbLib's parts table (<name>.csv written next to it by dblib-sync, else the
|
|
888
|
+
.xlsx), or None when the folder has no .DbLib / .SVNDbLib."""
|
|
889
|
+
import csv
|
|
890
|
+
folder = Path(folder)
|
|
891
|
+
libs = sorted(folder.glob("*.DbLib")) + sorted(folder.glob("*.SVNDbLib"))
|
|
892
|
+
if not libs:
|
|
893
|
+
return None
|
|
894
|
+
stem = libs[0].stem
|
|
895
|
+
out = {}
|
|
896
|
+
if (folder / f"{stem}.csv").is_file():
|
|
897
|
+
with open(folder / f"{stem}.csv", encoding="utf-8-sig", newline="") as fh:
|
|
898
|
+
for r in csv.DictReader(fh):
|
|
899
|
+
out[r.get("Part Number", "")] = r
|
|
900
|
+
elif (folder / f"{stem}.xlsx").is_file():
|
|
901
|
+
from .altium_dblib_sync import read_tables
|
|
902
|
+
for t, (_, rows) in read_tables(folder / f"{stem}.xlsx").items():
|
|
903
|
+
for r in rows:
|
|
904
|
+
out[r.get("Part Number", "")] = dict(r, Table=t)
|
|
905
|
+
return out
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def compare_databases(kicad_rows, altium_rows, kicad, altium_dir, rules=None):
|
|
909
|
+
"""The KiCad database library and the Altium DbLib must list the same parts, each naming a symbol and footprint
|
|
910
|
+
that exist and correspond, with the same key fields."""
|
|
911
|
+
from . import kicad_export as X
|
|
912
|
+
out = []
|
|
913
|
+
for n in sorted(set(kicad_rows) - set(altium_rows)):
|
|
914
|
+
_d(out, "error", f"part {n}", "database row", "present", "missing")
|
|
915
|
+
for n in sorted(set(altium_rows) - set(kicad_rows)):
|
|
916
|
+
_d(out, "error", f"part {n}", "database row", "missing", "present")
|
|
917
|
+
for n in sorted(set(kicad_rows) & set(altium_rows)):
|
|
918
|
+
k, a = kicad_rows[n], altium_rows[n]
|
|
919
|
+
what = f"part {n}"
|
|
920
|
+
sym, fp = k.get("Symbol", ""), k.get("Footprint", "")
|
|
921
|
+
if sym.split(":", 1)[-1] not in kicad.symbols:
|
|
922
|
+
_d(out, "error", what, "KiCad Symbol", sym, a.get("Library Ref", ""), f"not in {kicad.name}.kicad_sym")
|
|
923
|
+
if fp and kicad.footprint(fp.split(":", 1)[-1]) is None:
|
|
924
|
+
_d(out, "error", what, "KiCad Footprint", fp, a.get("Footprint Ref", ""), f"not in {kicad.name}.pretty")
|
|
925
|
+
if fp.split(":", 1)[-1] != X.kname(a.get("Footprint Ref", "")):
|
|
926
|
+
_d(out, "error", what, "footprint", fp, a.get("Footprint Ref", ""))
|
|
927
|
+
for col, ref in (("Library Path", "Library Ref"), ("Footprint Path", "Footprint Ref")):
|
|
928
|
+
f = Path(altium_dir) / str(a.get(col, "")).replace("\\", "/")
|
|
929
|
+
if a.get(col) and not f.is_file():
|
|
930
|
+
_d(out, "error", what, col, "-", a.get(col), "file missing")
|
|
931
|
+
elif a.get(col):
|
|
932
|
+
names = {s["name"] for s in AL.read_schlib(f)[1]} if f.suffix.lower() == ".schlib" else {p["name"] for p in AL.read_pcblib(f)}
|
|
933
|
+
if AL.lib_name(a.get(ref, "")) not in names:
|
|
934
|
+
_d(out, "error", what, ref, "-", a.get(ref), f"not in {f.name}")
|
|
935
|
+
kf = {f: k.get(c, "") for f, c in zip(KEY_FIELDS, ("Value", "Description", "Manufacturer", "MPN", "Datasheet"))}
|
|
936
|
+
kf.update({f: v for f, v in kicad_fields(k, rules).items() if v})
|
|
937
|
+
out += compare_fields(kf, altium_fields(a, rules), what)
|
|
938
|
+
return out
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
# ---------------------------------------------------------------- plans
|
|
942
|
+
def check_plan(plan, kicad_dir=None, lib=None, altium_dir=None, rules=None, second_reader=None, include_held=False, log=None):
|
|
943
|
+
"""Every part of a plan -> report dict (`parts`, `database`, `counts`). kicad_dir: a kicad-export folder (export mode).
|
|
944
|
+
altium_dir: a dblib-sync folder (its DbLib rows are the Altium field values, and the two databases are compared)."""
|
|
945
|
+
from .rules import resolve
|
|
946
|
+
plan = Path(plan)
|
|
947
|
+
base = plan.parent
|
|
948
|
+
rules = resolve(rules, base)
|
|
949
|
+
items = json.loads(plan.read_text(encoding="utf-8"))
|
|
950
|
+
kicad = KiCadLib(kicad_dir, lib) if kicad_dir else None
|
|
951
|
+
a_rows = altium_db_rows(altium_dir) if altium_dir else None
|
|
952
|
+
if second_reader is None:
|
|
953
|
+
second_reader = True
|
|
954
|
+
rep = {"plan": str(plan), "mode": "export" if kicad else "plan", "kicad": str(kicad_dir or ""), "altium": str(altium_dir or ""),
|
|
955
|
+
"tolerances": TOL, "parts": [], "database": []}
|
|
956
|
+
for it in sorted(items, key=lambda i: str(i.get("name", ""))):
|
|
957
|
+
if kicad and it.get("status") == "held" and not include_held:
|
|
958
|
+
rep["parts"].append({"part": it["name"], "result": "skipped", "why": "held (kicad-export skips it)", "diffs": [], "checked": []})
|
|
959
|
+
continue
|
|
960
|
+
try:
|
|
961
|
+
r = check_item(it, base, rules, kicad, a_rows, second_reader)
|
|
962
|
+
except Exception as e: # one unreadable part must not stop the run
|
|
963
|
+
r = {"part": it["name"], "result": "mismatch", "checked": [], "diffs": []}
|
|
964
|
+
_d(r["diffs"], "error", "files", "read", "-", f"{type(e).__name__}: {e}")
|
|
965
|
+
rep["parts"].append(r)
|
|
966
|
+
if log:
|
|
967
|
+
log(f" {r['result']:<9} {r['part']:<28} " + ("; ".join(d["msg"] for d in r["diffs"] if d["level"] == "error")[:140]
|
|
968
|
+
or r.get("why") or ", ".join(r["checked"])))
|
|
969
|
+
if kicad and a_rows is not None:
|
|
970
|
+
k_rows = kicad_db_rows(kicad_dir, kicad.name)
|
|
971
|
+
if k_rows is None:
|
|
972
|
+
_d(rep["database"], "warn", "database", "KiCad", "no .kicad_dbl / .sqlite", f"{len(a_rows)} DbLib rows", "run ecadlib kicad-dblib")
|
|
973
|
+
else:
|
|
974
|
+
rep["database"] = compare_databases(k_rows, a_rows, kicad, altium_dir, rules)
|
|
975
|
+
rep["database_rows"] = {"kicad": len(k_rows), "altium": len(a_rows)}
|
|
976
|
+
rep["counts"] = {s: sum(p["result"] == s for p in rep["parts"]) for s in ("match", "mismatch", "skipped")}
|
|
977
|
+
rep["counts"]["database_errors"] = sum(d["level"] == "error" for d in rep["database"])
|
|
978
|
+
rep["ok"] = not rep["counts"]["mismatch"] and not rep["counts"]["database_errors"]
|
|
979
|
+
return rep
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
def write_report(rep, stem):
|
|
983
|
+
stem = Path(stem)
|
|
984
|
+
stem.parent.mkdir(parents=True, exist_ok=True)
|
|
985
|
+
stem.with_suffix(".json").write_text(json.dumps(rep, indent=2, default=str) + "\n", encoding="utf-8")
|
|
986
|
+
from . import messages as M
|
|
987
|
+
c = rep["counts"]
|
|
988
|
+
where = Path(str(rep.get("plan") or "")).parent.name
|
|
989
|
+
checked = lambda p: ", ".join(p["checked"]).replace("pyaltiumlib", "a second Altium reader").replace("pin -> pad", "pins to pads")
|
|
990
|
+
md = [f"# KiCad and Altium match{': ' + where if where else ''}", "",
|
|
991
|
+
f"**{c['match']} {'part matches' if c['match'] == 1 else 'parts match'}, {c['mismatch']} differ{'s' if c['mismatch'] == 1 else ''}.**"
|
|
992
|
+
+ (f" {c['skipped']} not compared." if c["skipped"] else "")
|
|
993
|
+
+ (f" Database rows: {M.plural(c['database_errors'], 'difference')}." if rep.get("database_rows") else ""), ""]
|
|
994
|
+
md += ["Parts whose KiCad and Altium files differ are held until they match.",
|
|
995
|
+
"**Next:** fill the library again so both are written from the same source; report it if they still differ.", ""] \
|
|
996
|
+
if c["mismatch"] else ["The KiCad and Altium files of every compared part read back the same.", ""]
|
|
997
|
+
bad = [p for p in rep["parts"] if p["result"] == "mismatch"]
|
|
998
|
+
if bad:
|
|
999
|
+
md += [f"## Differ ({len(bad)})", ""]
|
|
1000
|
+
for p in bad:
|
|
1001
|
+
md += [f"### {p['part']}", ""]
|
|
1002
|
+
md += [f"- {e['text']}" for e in M.explain_all("parity: " + d["msg"] for d in p["diffs"] if d["level"] == "error")]
|
|
1003
|
+
md.append("")
|
|
1004
|
+
good = [p for p in rep["parts"] if p["result"] == "match"]
|
|
1005
|
+
if good:
|
|
1006
|
+
md += [f"## Match ({len(good)})", "", "| Part | Compared |", "|---|---|"]
|
|
1007
|
+
md += [f"| {p['part']} | {checked(p) or '-'} |" for p in good] + [""]
|
|
1008
|
+
skip = [p for p in rep["parts"] if p["result"] not in ("match", "mismatch")]
|
|
1009
|
+
if skip:
|
|
1010
|
+
md += [f"## Not compared ({len(skip)})", ""]
|
|
1011
|
+
md += [f"- **{p['part']}**: {M.clean(p.get('why') or '-').replace('kicad-export skips it', 'not exported to KiCad')}" for p in skip] + [""]
|
|
1012
|
+
warns = [(p["part"], d["msg"]) for p in rep["parts"] for d in p["diffs"] if d["level"] == "warn"]
|
|
1013
|
+
if warns:
|
|
1014
|
+
md += ["## Approximations (not held)", ""] + [f"- **{n}**: {M.clean(m)}" for n, m in warns] + [""]
|
|
1015
|
+
if rep["database"]:
|
|
1016
|
+
md += ["## Database rows", ""] + [f"- {M.clean(d['msg'])}" for d in rep["database"]] + [""]
|
|
1017
|
+
md += ["## Details", "", f"Compared in {rep['mode']} mode. Every number is in the machine-readable report next to this file (.json)."]
|
|
1018
|
+
stem.with_suffix(".md").write_text("\n".join(md) + "\n", encoding="utf-8")
|
|
1019
|
+
return stem
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
# ---------------------------------------------------------------- survey: everything the generators make
|
|
1023
|
+
def survey(quick=False, log=None, work=None, kicad_dir=None):
|
|
1024
|
+
"""Build every package spec (classic + advanced footprint_adv families) and every standard-table footprint as
|
|
1025
|
+
KiCad and Altium files, read both back and compare. Paths: footprint_gen -> altium_lib (approval / `altium_lib
|
|
1026
|
+
pcblib`), footprint_gen / stdparts -> kicad_import (the librarian), and .PcbLib -> kicad_export (the KiCad export of
|
|
1027
|
+
a part without a .kicad_mod). -> {"checked", "mismatched", "diffs": {name: [msg]}, "by_path": {...}}."""
|
|
1028
|
+
import tempfile
|
|
1029
|
+
from . import footprint_gen as F, kicad_export as X, kicad_import as K, stdparts as SP
|
|
1030
|
+
here = Path(__file__).with_name("package_specs")
|
|
1031
|
+
specs = [(p.stem, json.loads(p.read_text())) for p in sorted(here.glob("*.json")) + sorted((here / "advanced").glob("*.json"))]
|
|
1032
|
+
names = sorted(SP.packages())
|
|
1033
|
+
if quick:
|
|
1034
|
+
names = names[::25]
|
|
1035
|
+
rep = {"checked": 0, "mismatched": 0, "warned": 0, "diffs": {}, "by_path": {}}
|
|
1036
|
+
tmp = tempfile.TemporaryDirectory() if work is None else None
|
|
1037
|
+
work = Path(work or tmp.name)
|
|
1038
|
+
|
|
1039
|
+
def one(label, path, text, fp_dict):
|
|
1040
|
+
f = work / f"{len(rep['diffs'])}_{rep['checked']}.PcbLib"
|
|
1041
|
+
AL.write_pcblib(f, [fp_dict])
|
|
1042
|
+
afp = AL.read_pcblib(f)[0]
|
|
1043
|
+
diffs = footprint_pair(text, afp)
|
|
1044
|
+
if path == "kicad-lib": # library .kicad_mod files name models the converter never sees: no 3D check
|
|
1045
|
+
diffs = [d for d in diffs if d["what"] != "3D model"]
|
|
1046
|
+
back = X.footprint_from_pcblib(f, afp["name"], []) # PcbLib -> KiCad export (no .kicad_mod)
|
|
1047
|
+
diffs += [dict(d, msg="export: " + d["msg"]) for d in compare_footprints(kicad_footprint(back), altium_footprint(afp))
|
|
1048
|
+
if d["what"] != "3D model"]
|
|
1049
|
+
_second_reader(f, diffs)
|
|
1050
|
+
st = rep["by_path"].setdefault(path, {"checked": 0, "mismatched": 0})
|
|
1051
|
+
st["checked"] += 1
|
|
1052
|
+
rep["checked"] += 1
|
|
1053
|
+
errs = [d["msg"] for d in diffs if d["level"] == "error"]
|
|
1054
|
+
if errs:
|
|
1055
|
+
st["mismatched"] += 1
|
|
1056
|
+
rep["mismatched"] += 1
|
|
1057
|
+
rep["diffs"][f"{path}:{label}"] = errs
|
|
1058
|
+
if log:
|
|
1059
|
+
log(f" MISMATCH {path}:{label}: {'; '.join(errs)[:200]}")
|
|
1060
|
+
elif diffs:
|
|
1061
|
+
rep["warned"] += 1
|
|
1062
|
+
f.unlink()
|
|
1063
|
+
|
|
1064
|
+
try:
|
|
1065
|
+
for f in sorted(Path(kicad_dir).rglob("*.kicad_mod")) if kicad_dir else []:
|
|
1066
|
+
text = f.read_text(encoding="utf-8", errors="replace")
|
|
1067
|
+
try:
|
|
1068
|
+
fp, _ = K.convert_footprint(text, f.stem, "kicad")
|
|
1069
|
+
except Exception as e: # the librarian reports these as unconvertible; not a parity question
|
|
1070
|
+
rep.setdefault("unconverted", {})[f.stem] = f"{type(e).__name__}: {e}"
|
|
1071
|
+
continue
|
|
1072
|
+
one(f"{f.parent.name}/{f.stem}", "kicad-lib", text, fp)
|
|
1073
|
+
if kicad_dir:
|
|
1074
|
+
return rep
|
|
1075
|
+
for stem, spec in specs:
|
|
1076
|
+
text, _ = F.build(spec)
|
|
1077
|
+
fp, _ = AL.footprint_from_spec(spec)
|
|
1078
|
+
one(stem, "generator", text, fp)
|
|
1079
|
+
fp2, _ = K.convert_footprint(text, spec["name"], "spec")
|
|
1080
|
+
one(stem, "librarian", text, fp2)
|
|
1081
|
+
for n in names:
|
|
1082
|
+
text, _ = SP.build(n)
|
|
1083
|
+
fp, _ = K.convert_footprint(text, n.split(":")[-1], "standard")
|
|
1084
|
+
one(n, "standard", text, fp)
|
|
1085
|
+
finally:
|
|
1086
|
+
if tmp:
|
|
1087
|
+
tmp.cleanup()
|
|
1088
|
+
return rep
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
# ---------------------------------------------------------------- CLI
|
|
1092
|
+
def _print(rep, out):
|
|
1093
|
+
c = rep["counts"]
|
|
1094
|
+
for p in rep["parts"]:
|
|
1095
|
+
print(f" {p['result']:<9} {p['part']:<28} {', '.join(p['checked']) or p.get('why', '')}", file=out)
|
|
1096
|
+
for d in p["diffs"]:
|
|
1097
|
+
print(f" {'MISMATCH' if d['level'] == 'error' else 'approx'} {d['msg']}", file=out)
|
|
1098
|
+
for d in rep["database"]:
|
|
1099
|
+
print(f" database {d['msg']}", file=out)
|
|
1100
|
+
print(f"{c['match']} match, {c['mismatch']} mismatch, {c['skipped']} skipped"
|
|
1101
|
+
+ (f"; database rows {rep['database_rows']['kicad']} KiCad / {rep['database_rows']['altium']} Altium, "
|
|
1102
|
+
f"{c['database_errors']} differences" if rep.get("database_rows") else ""), file=out)
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
def print_result(rep, stem, plan, t=None):
|
|
1106
|
+
"""Terminal view: a row per part (KiCad and Altium match / differ, with the first difference in plain words;
|
|
1107
|
+
-v: what was compared and every difference), database rows, then the summary."""
|
|
1108
|
+
from . import messages as M, term as TM
|
|
1109
|
+
t = t or TM.Term()
|
|
1110
|
+
rows = []
|
|
1111
|
+
for p in rep["parts"]:
|
|
1112
|
+
errs = [d for d in p["diffs"] if d["level"] == "error"]
|
|
1113
|
+
st = {"match": "ready", "mismatch": "held"}.get(p["result"], "skipped")
|
|
1114
|
+
word = {"match": "match", "mismatch": "differ"}.get(p["result"], p["result"])
|
|
1115
|
+
first = M.explain(errs[0]["msg"])["text"] + (f" (+{len(errs) - 1} more)" if len(errs) > 1 else "") if errs else ""
|
|
1116
|
+
rows.append([t.status(st, word), p["part"], first or (t.dim(", ".join(p["checked"])) if t.verbose else p.get("why", ""))])
|
|
1117
|
+
if t.verbose:
|
|
1118
|
+
rows += [["", "", ("" if d["level"] == "error" else "approx: ") + M.explain(d["msg"])["text"]] for d in p["diffs"][1 if errs else 0:]]
|
|
1119
|
+
t.table(rows)
|
|
1120
|
+
for d in rep["database"]:
|
|
1121
|
+
t.write(f" {t.status('held', 'database')} {M.clean(d['msg'])}")
|
|
1122
|
+
c = rep["counts"]
|
|
1123
|
+
head = (f"KiCad and Altium: {c['match']} match, {c['mismatch']} differ" + (f", {c['skipped']} skipped" if c["skipped"] else "")
|
|
1124
|
+
+ (f"; database rows {rep['database_rows']['kicad']} KiCad / {rep['database_rows']['altium']} Altium, "
|
|
1125
|
+
f"{c['database_errors']} differences" if rep.get("database_rows") else ""))
|
|
1126
|
+
nxt = [(f"ecadlib parity {plan} -v", "every difference, part by part")] if c["mismatch"] else []
|
|
1127
|
+
t.summary(head, written=[(str(stem.with_suffix(".md")), "parity report")], next=nxt)
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
def main(argv=None):
|
|
1131
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
1132
|
+
if argv[:1] == ["survey"]:
|
|
1133
|
+
ap = argparse.ArgumentParser(prog="ecadlib parity survey", description="Compare KiCad and Altium output of every generator "
|
|
1134
|
+
"package spec and standard-table footprint")
|
|
1135
|
+
ap.add_argument("survey")
|
|
1136
|
+
ap.add_argument("--quick", action="store_true", help="every 25th standard-table footprint only")
|
|
1137
|
+
ap.add_argument("--kicad-footprints", metavar="DIR", help="instead: every .kicad_mod under DIR (a kicad-footprints "
|
|
1138
|
+
"checkout) as the librarian converts it")
|
|
1139
|
+
ap.add_argument("--json", action="store_true")
|
|
1140
|
+
a = ap.parse_args(argv)
|
|
1141
|
+
rep = survey(a.quick, None if a.json else print, kicad_dir=a.kicad_footprints)
|
|
1142
|
+
if a.json:
|
|
1143
|
+
print(json.dumps(rep, indent=2))
|
|
1144
|
+
else:
|
|
1145
|
+
for p, st in rep["by_path"].items():
|
|
1146
|
+
print(f" {p:<10} {st['checked']:>4} footprints, {st['mismatched']} mismatched")
|
|
1147
|
+
print(f"{rep['checked']} footprints compared, {rep['mismatched']} mismatched, {rep['warned']} with approximations only")
|
|
1148
|
+
return 1 if rep["mismatched"] else 0
|
|
1149
|
+
if argv[:1] == ["pair"]:
|
|
1150
|
+
ap = argparse.ArgumentParser(prog="ecadlib parity pair", description="Compare one KiCad file with one Altium file")
|
|
1151
|
+
ap.add_argument("pair")
|
|
1152
|
+
ap.add_argument("kicad", help="X.kicad_mod, or Lib.kicad_sym:Name")
|
|
1153
|
+
ap.add_argument("altium", help="X.PcbLib or X.SchLib")
|
|
1154
|
+
ap.add_argument("--json", action="store_true")
|
|
1155
|
+
a = ap.parse_args(argv)
|
|
1156
|
+
if a.altium.lower().endswith(".pcblib"):
|
|
1157
|
+
diffs = footprint_pair(Path(a.kicad).read_text(encoding="utf-8"), AL.read_pcblib(a.altium)[0])
|
|
1158
|
+
_second_reader(a.altium, diffs)
|
|
1159
|
+
else:
|
|
1160
|
+
f, _, nm = a.kicad.rpartition(":") if a.kicad.count(":") and not Path(a.kicad).is_file() else (a.kicad, "", "")
|
|
1161
|
+
syms = {str(s[1]): s for s in sexpr.find(sexpr.parse(Path(f).read_text(encoding="utf-8")), "symbol")}
|
|
1162
|
+
sym = syms.get(nm) if nm else next(iter(syms.values()), None)
|
|
1163
|
+
if sym is None:
|
|
1164
|
+
sys.exit(f"{a.kicad}: symbol not found")
|
|
1165
|
+
diffs = compare_symbols(kicad_symbol(sym), altium_symbol(AL.read_schlib(a.altium)[1][0]))
|
|
1166
|
+
if a.json:
|
|
1167
|
+
print(json.dumps(diffs, indent=2, default=str))
|
|
1168
|
+
else:
|
|
1169
|
+
for d in diffs:
|
|
1170
|
+
print(f" {'MISMATCH' if d['level'] == 'error' else 'approx'} {d['msg']}")
|
|
1171
|
+
print("match" if not any(d["level"] == "error" for d in diffs) else "mismatch")
|
|
1172
|
+
return 1 if any(d["level"] == "error" for d in diffs) else 0
|
|
1173
|
+
ap = argparse.ArgumentParser(prog="ecadlib parity", description="Read back the KiCad and the Altium library of a plan and "
|
|
1174
|
+
"compare them part by part (pads, drills, courtyard, 3D, pins, fields, database rows)",
|
|
1175
|
+
epilog="also: ecadlib parity pair X.kicad_mod X.PcbLib | ecadlib parity survey [--quick]")
|
|
1176
|
+
ap.add_argument("plan", help="librarian / altium365 plan.json")
|
|
1177
|
+
ap.add_argument("--kicad", help="kicad-export folder (default: compare the plan's own .kicad_mod files)")
|
|
1178
|
+
ap.add_argument("--name", help="KiCad library name in that folder (default: its one .kicad_sym)")
|
|
1179
|
+
ap.add_argument("--altium", help="dblib-sync folder: its DbLib rows give the Altium field values; with --kicad the "
|
|
1180
|
+
".kicad_dbl rows are compared with them")
|
|
1181
|
+
ap.add_argument("--include-held", action="store_true", help="export mode: also parts the release check held")
|
|
1182
|
+
ap.add_argument("--no-pyaltiumlib", action="store_true", help="skip the independent pyaltiumlib re-read")
|
|
1183
|
+
ap.add_argument("--rules", help="library rules file (default: ecadlib.toml found from the plan)")
|
|
1184
|
+
ap.add_argument("-o", "--out", help="report stem (.md and .json); default <plan dir>/parity")
|
|
1185
|
+
ap.add_argument("--json", action="store_true", help="print the report as JSON")
|
|
1186
|
+
a = ap.parse_args(argv)
|
|
1187
|
+
if not Path(a.plan).is_file():
|
|
1188
|
+
print(f"ecadlib parity: {a.plan}: no such plan", file=sys.stderr)
|
|
1189
|
+
return 2
|
|
1190
|
+
try:
|
|
1191
|
+
rep = check_plan(a.plan, a.kicad, a.name, a.altium, a.rules, not a.no_pyaltiumlib, a.include_held)
|
|
1192
|
+
except (ValueError, OSError) as e:
|
|
1193
|
+
print(f"ecadlib parity: {e}", file=sys.stderr)
|
|
1194
|
+
return 2
|
|
1195
|
+
stem = write_report(rep, a.out or Path(a.plan).parent / "parity")
|
|
1196
|
+
if a.json:
|
|
1197
|
+
print(json.dumps(dict(rep, report=str(stem.with_suffix(".md"))), indent=2, default=str))
|
|
1198
|
+
else:
|
|
1199
|
+
print_result(rep, stem, a.plan)
|
|
1200
|
+
return 0 if rep["ok"] else 1
|
|
1201
|
+
|
|
1202
|
+
|
|
1203
|
+
if __name__ == "__main__":
|
|
1204
|
+
sys.exit(main())
|