ecadlib 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (199) hide show
  1. ecadlib/__init__.py +3 -0
  2. ecadlib/__main__.py +5 -0
  3. ecadlib/a365_sync.py +637 -0
  4. ecadlib/agent.py +1407 -0
  5. ecadlib/agent_queue.py +780 -0
  6. ecadlib/agent_watch.py +732 -0
  7. ecadlib/altium365.py +385 -0
  8. ecadlib/altium_dblib_sync.py +530 -0
  9. ecadlib/altium_export.py +209 -0
  10. ecadlib/altium_forms.py +768 -0
  11. ecadlib/altium_lib.py +1089 -0
  12. ecadlib/altium_menu.py +1751 -0
  13. ecadlib/altium_pkg.py +396 -0
  14. ecadlib/altium_project.py +1095 -0
  15. ecadlib/app.py +1458 -0
  16. ecadlib/approvals.py +395 -0
  17. ecadlib/assets/demo_bom.csv +8 -0
  18. ecadlib/assets/kicad_action_plugin.py +607 -0
  19. ecadlib/assets/kicad_reread.py +60 -0
  20. ecadlib/assets/pcblib_template.json.gz +0 -0
  21. ecadlib/bom_inputs.py +339 -0
  22. ecadlib/bom_triage.py +519 -0
  23. ecadlib/cad_model.py +528 -0
  24. ecadlib/cfb.py +171 -0
  25. ecadlib/cli.py +185 -0
  26. ecadlib/clihelp.py +149 -0
  27. ecadlib/cliutil.py +119 -0
  28. ecadlib/completion.py +173 -0
  29. ecadlib/costs.py +276 -0
  30. ecadlib/crosscheck.py +412 -0
  31. ecadlib/datasheet.py +2168 -0
  32. ecadlib/dedupe.py +572 -0
  33. ecadlib/detect.py +170 -0
  34. ecadlib/doctor.py +450 -0
  35. ecadlib/ds_route.py +541 -0
  36. ecadlib/eagle_export.py +1409 -0
  37. ecadlib/easy_setup.py +338 -0
  38. ecadlib/easyeda_convert.py +386 -0
  39. ecadlib/easyeda_pro_export.py +972 -0
  40. ecadlib/eval/__init__.py +0 -0
  41. ecadlib/eval/baselines/kicad-v1.json +404 -0
  42. ecadlib/eval/build_set.py +299 -0
  43. ecadlib/eval/cli.py +119 -0
  44. ecadlib/eval/gate.py +358 -0
  45. ecadlib/eval/golden/demo.txt +379 -0
  46. ecadlib/eval/golden/specs.txt +3120 -0
  47. ecadlib/eval/golden/testpack.txt +463 -0
  48. ecadlib/eval/golden.py +446 -0
  49. ecadlib/eval/replay/kicad-v1.json.gz +0 -0
  50. ecadlib/eval/runner.py +456 -0
  51. ecadlib/eval/score.py +179 -0
  52. ecadlib/eval/sets/kicad-v1.json +630 -0
  53. ecadlib/firstrun.py +400 -0
  54. ecadlib/footprint_adv.py +1006 -0
  55. ecadlib/footprint_bench.py +218 -0
  56. ecadlib/footprint_check.py +562 -0
  57. ecadlib/footprint_gen.py +582 -0
  58. ecadlib/footprint_rules.py +1092 -0
  59. ecadlib/forge.py +267 -0
  60. ecadlib/fp_share.py +95 -0
  61. ecadlib/fp_tidy.py +389 -0
  62. ecadlib/fsutil.py +62 -0
  63. ecadlib/house_style.py +1051 -0
  64. ecadlib/httpcache.py +124 -0
  65. ecadlib/hub.py +1490 -0
  66. ecadlib/hub_copy.py +161 -0
  67. ecadlib/hub_tools.py +79 -0
  68. ecadlib/kicad_dblib.py +257 -0
  69. ecadlib/kicad_export.py +837 -0
  70. ecadlib/kicad_import.py +913 -0
  71. ecadlib/kicad_index.py +160 -0
  72. ecadlib/kicad_menu.py +193 -0
  73. ecadlib/kicad_pcm.py +475 -0
  74. ecadlib/launcher.py +156 -0
  75. ecadlib/librarian.py +963 -0
  76. ecadlib/lifecycle.py +691 -0
  77. ecadlib/llm/__init__.py +276 -0
  78. ecadlib/llm/cli.py +70 -0
  79. ecadlib/llm/openai_compat.py +214 -0
  80. ecadlib/llm/profiles.py +245 -0
  81. ecadlib/llm/rails.py +570 -0
  82. ecadlib/llm/run.py +126 -0
  83. ecadlib/llm/sampling.py +49 -0
  84. ecadlib/mcp_server.py +723 -0
  85. ecadlib/messages.py +419 -0
  86. ecadlib/migrate.py +407 -0
  87. ecadlib/notify.py +329 -0
  88. ecadlib/package_schema.json +1238 -0
  89. ecadlib/package_specs/CP_EIA-3216-18_Kemet-A.json +27 -0
  90. ecadlib/package_specs/C_0402_1005Metric.json +22 -0
  91. ecadlib/package_specs/C_0805_2012Metric.json +22 -0
  92. ecadlib/package_specs/DFN-8-1EP_3x3mm_P0.5mm_EP1.66x2.38mm.json +40 -0
  93. ecadlib/package_specs/D_SMA.json +27 -0
  94. ecadlib/package_specs/D_SMB.json +27 -0
  95. ecadlib/package_specs/D_SOD-123.json +43 -0
  96. ecadlib/package_specs/D_SOD-323.json +43 -0
  97. ecadlib/package_specs/LED_0603_1608Metric.json +23 -0
  98. ecadlib/package_specs/LED_0805_2012Metric.json +23 -0
  99. ecadlib/package_specs/LQFP-48_7x7mm_P0.5mm.json +31 -0
  100. ecadlib/package_specs/L_0805_2012Metric.json +22 -0
  101. ecadlib/package_specs/MSOP-8_3x3mm_P0.65mm.json +31 -0
  102. ecadlib/package_specs/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm.json +39 -0
  103. ecadlib/package_specs/QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm.json +37 -0
  104. ecadlib/package_specs/R_0402_1005Metric.json +22 -0
  105. ecadlib/package_specs/R_0603_1608Metric.json +22 -0
  106. ecadlib/package_specs/R_0805_2012Metric.json +22 -0
  107. ecadlib/package_specs/R_1206_3216Metric.json +22 -0
  108. ecadlib/package_specs/SOIC-16_3.9x9.9mm_P1.27mm.json +32 -0
  109. ecadlib/package_specs/SOIC-8_3.9x4.9mm_P1.27mm.json +32 -0
  110. ecadlib/package_specs/SOT-223-3_TabPin2.json +56 -0
  111. ecadlib/package_specs/SOT-23-5.json +57 -0
  112. ecadlib/package_specs/SOT-23-6.json +62 -0
  113. ecadlib/package_specs/SOT-23.json +48 -0
  114. ecadlib/package_specs/TSSOP-16_4.4x5mm_P0.65mm.json +31 -0
  115. ecadlib/package_specs/VQFN-16-1EP_3x3mm_P0.5mm_EP1.68x1.68mm.json +39 -0
  116. ecadlib/package_specs/advanced/BGA-100_11.0x11.0mm_Layout10x10_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  117. ecadlib/package_specs/advanced/BGA-121_9.0x9.0mm_Layout11x11_P0.8mm_Ball0.4mm_Pad0.35mm_NSMD.json +26 -0
  118. ecadlib/package_specs/advanced/BGA-144_7.0x7.0mm_Layout13x13_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +29 -0
  119. ecadlib/package_specs/advanced/BGA-169_11.0x11.0mm_Layout13x13_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +23 -0
  120. ecadlib/package_specs/advanced/BGA-256_11.0x11.0mm_Layout20x20_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +26 -0
  121. ecadlib/package_specs/advanced/BGA-256_17.0x17.0mm_Layout16x16_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  122. ecadlib/package_specs/advanced/BGA-324_15.0x15.0mm_Layout18x18_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  123. ecadlib/package_specs/advanced/BGA-36_3.396x3.466mm_Layout6x6_P0.4mm_Ball0.25mm_Pad0.2mm_NSMD.json +24 -0
  124. ecadlib/package_specs/advanced/BGA-672_27.0x27.0mm_Layout26x26_P1.0mm_Ball0.6mm_Pad0.5mm_NSMD.json +32 -0
  125. ecadlib/package_specs/advanced/BGA-68_5.0x5.0mm_Layout9x9_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +30 -0
  126. ecadlib/package_specs/advanced/BGA-90_8.0x13.0mm_Layout2x3x15_P0.8mm.json +26 -0
  127. ecadlib/package_specs/advanced/DFN-8-1EP_3x3mm_P0.5mm_EP1.65x2.38mm_ThermalVias.json +60 -0
  128. ecadlib/package_specs/advanced/Example_Custom_LPad_Slot.json +24 -0
  129. ecadlib/package_specs/advanced/JST_GH_SM04B-GHS-TB_1x04-1MP_P1.25mm_Horizontal.json +49 -0
  130. ecadlib/package_specs/advanced/JST_PH_B4B-PH-K_1x04_P2.00mm_Vertical.json +33 -0
  131. ecadlib/package_specs/advanced/JST_PH_S4B-PH-SM4-TB_1x04-1MP_P2.00mm_Horizontal.json +55 -0
  132. ecadlib/package_specs/advanced/JST_SH_SM04B-SRSS-TB_1x04-1MP_P1.00mm_Horizontal.json +49 -0
  133. ecadlib/package_specs/advanced/JST_XH_B2B-XH-A_1x02_P2.50mm_Vertical.json +34 -0
  134. ecadlib/package_specs/advanced/JST_XH_B4B-XH-A_1x04_P2.50mm_Vertical.json +34 -0
  135. ecadlib/package_specs/advanced/JST_XH_B6B-XH-A_1x06_P2.50mm_Vertical.json +33 -0
  136. ecadlib/package_specs/advanced/LFBGA-144_10x10mm_Layout12x12_P0.8mm.json +23 -0
  137. ecadlib/package_specs/advanced/Molex_KK-254_AE-6410-04A_1x04_P2.54mm_Vertical.json +34 -0
  138. ecadlib/package_specs/advanced/Molex_PicoBlade_53398-0471_1x04-1MP_P1.25mm_Vertical.json +54 -0
  139. ecadlib/package_specs/advanced/PinHeader_1x05_P1.00mm_Vertical_SMD_Pin1Left.json +14 -0
  140. ecadlib/package_specs/advanced/PinHeader_1x05_P1.27mm_Vertical_SMD_Pin1Left.json +14 -0
  141. ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical.json +12 -0
  142. ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical_SMD_Pin1Left.json +14 -0
  143. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical.json +12 -0
  144. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Left.json +25 -0
  145. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Right.json +14 -0
  146. ecadlib/package_specs/advanced/PinHeader_1x10_P1.00mm_Vertical.json +15 -0
  147. ecadlib/package_specs/advanced/PinHeader_2x05_P1.00mm_Vertical_SMD.json +13 -0
  148. ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical.json +18 -0
  149. ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical_SMD.json +24 -0
  150. ecadlib/package_specs/advanced/PinHeader_2x05_P2.00mm_Vertical_SMD.json +13 -0
  151. ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical.json +12 -0
  152. ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical_SMD.json +24 -0
  153. ecadlib/package_specs/advanced/PinHeader_2x20_P2.54mm_Vertical.json +12 -0
  154. ecadlib/package_specs/advanced/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm_ThermalVias.json +87 -0
  155. ecadlib/package_specs/advanced/UFBGA-132_7x7mm_Layout12x12_P0.5mm.json +43 -0
  156. ecadlib/package_specs/advanced/USB_C_Receptacle_HRO_TYPE-C-31-M-12.json +218 -0
  157. ecadlib/package_specs/standard.json.gz +0 -0
  158. ecadlib/parity.py +1204 -0
  159. ecadlib/parts_export.py +154 -0
  160. ecadlib/partview.py +215 -0
  161. ecadlib/plugins.py +231 -0
  162. ecadlib/providers.py +226 -0
  163. ecadlib/rebuild.py +471 -0
  164. ecadlib/release_check.py +437 -0
  165. ecadlib/reports.py +274 -0
  166. ecadlib/resolve.py +556 -0
  167. ecadlib/review_cli.py +292 -0
  168. ecadlib/rules.py +979 -0
  169. ecadlib/rules_infer.py +1205 -0
  170. ecadlib/sexpr.py +149 -0
  171. ecadlib/sheetsafe.py +65 -0
  172. ecadlib/sim_models.py +1087 -0
  173. ecadlib/sourcing.py +1161 -0
  174. ecadlib/stdparts.py +755 -0
  175. ecadlib/step_gen.py +929 -0
  176. ecadlib/symbol_layout.py +798 -0
  177. ecadlib/team.py +558 -0
  178. ecadlib/team_setup.py +288 -0
  179. ecadlib/term.py +420 -0
  180. ecadlib/term_views.py +64 -0
  181. ecadlib/testpack.py +334 -0
  182. ecadlib/ui/__init__.py +1 -0
  183. ecadlib/ui/__main__.py +3 -0
  184. ecadlib/ui/server.py +1029 -0
  185. ecadlib/ui/static/hairline.js +2 -0
  186. ecadlib/ui/static/index.html +2098 -0
  187. ecadlib/ui/static/setup.html +294 -0
  188. ecadlib/upkeep.py +717 -0
  189. ecadlib/upstream.py +573 -0
  190. ecadlib/userconfig.py +257 -0
  191. ecadlib/visual_check.py +1603 -0
  192. ecadlib/ws_status.py +203 -0
  193. ecadlib-0.3.0.dist-info/METADATA +2245 -0
  194. ecadlib-0.3.0.dist-info/RECORD +199 -0
  195. ecadlib-0.3.0.dist-info/WHEEL +5 -0
  196. ecadlib-0.3.0.dist-info/entry_points.txt +2 -0
  197. ecadlib-0.3.0.dist-info/licenses/LICENSE +21 -0
  198. ecadlib-0.3.0.dist-info/licenses/NOTICE +53 -0
  199. ecadlib-0.3.0.dist-info/top_level.txt +1 -0
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())