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/cad_model.py ADDED
@@ -0,0 +1,528 @@
1
+ """KiCad footprints and symbols read into plain shapes, for the writers of other tools (eagle_export, easyeda_pro_export).
2
+
3
+ Both Eagle / Fusion Electronics and EasyEDA Pro draw with y UP, so everything here is millimetres, y up, angles
4
+ counter-clockwise in degrees (KiCad footprints are y down; KiCad symbols are already y up).
5
+
6
+ footprint(text) -> {"name", "descr", "pads": [...], "graphics": [...], "texts": {"reference": (x, y), "value": (x, y)},
7
+ "models": [path, ...], "notes": [...]}
8
+ pad {"num", "kind": smd | tht | npth | window, "side": F | B (smd / window), "x", "y", "rot", "w", "h",
9
+ "shape": circle | rect | oval | roundrect | custom, "rratio" (roundrect: radius / shorter side),
10
+ "drill": (dx, dy) in the pad frame or None, "offset": (ox, oy) copper centre - hole in the pad frame,
11
+ "paste", "mask" (bool), "mask_margin", "paste_margin" (mm or None = the board's rule),
12
+ "parts": custom pads' copper ([("poly", [(x, y), ...], outline width) | ("circle", (cx, cy), r)] in the footprint frame),
13
+ "layers": a window's layers (paste_f, mask_f, ...), "approx": [what could not be kept exactly]}
14
+ graphic {"layer": role, "kind": line | arc | circle | poly, "w": stroke width, "fill": bool,
15
+ line: "a", "b"; arc: "a" (start), "b" (end), "c" (centre), "sweep" (degrees, + = counter-clockwise);
16
+ circle: "c", "r"; poly: "pts" (closed)}
17
+ roles cu_f cu_b silk_f silk_b fab_f fab_b crtyd_f crtyd_b mask_f mask_b paste_f paste_b edge doc
18
+
19
+ symbol(tree) -> {"name", "props": {...}, "units": {n: {"pins": [...], "shapes": [...]}}, "unit_count",
20
+ "pin_names_hidden", "pin_numbers_hidden", "name_offset", "notes"}
21
+ unit 0 holds what KiCad draws in every unit; De Morgan alternates (body style 2) are left out (noted).
22
+ pin {"num", "name", "type" (KiCad electrical type), "shape" (KiCad graphic style), "x", "y" (connection point),
23
+ "angle" (0 = the pin runs right from its connection point to the body), "length", "hidden"}
24
+ shape {"kind": rect | poly | circle | arc | text, "w", "fill": none | outline | background, rect: "a", "b";
25
+ poly: "pts", "closed"; circle: "c", "r"; arc: "a", "b", "c", "sweep"; text: "at", "text", "size", "angle"}"""
26
+ import math, re
27
+
28
+ from . import sexpr
29
+
30
+ LAYER_ROLE = {"F.Cu": "cu_f", "B.Cu": "cu_b", "F.SilkS": "silk_f", "B.SilkS": "silk_b", "F.Silkscreen": "silk_f",
31
+ "B.Silkscreen": "silk_b", "F.Fab": "fab_f", "B.Fab": "fab_b", "F.CrtYd": "crtyd_f", "B.CrtYd": "crtyd_b",
32
+ "F.Courtyard": "crtyd_f", "B.Courtyard": "crtyd_b", "F.Mask": "mask_f", "B.Mask": "mask_b",
33
+ "F.Paste": "paste_f", "B.Paste": "paste_b", "Edge.Cuts": "edge", "Dwgs.User": "doc", "Cmts.User": "doc",
34
+ "User.Drawings": "doc", "User.Comments": "doc"}
35
+ DEFAULT_STROKE = 0.1524 # KiCad draws a symbol stroke of 0 with the 6 mil default
36
+
37
+
38
+ def _f(v):
39
+ return float(v)
40
+
41
+
42
+ def yup(x, y):
43
+ """KiCad footprint point (y down) -> y up."""
44
+ return (_f(x), -_f(y) + 0.0)
45
+
46
+
47
+ def rot(u, v, a):
48
+ """(u, v) turned counter-clockwise by a degrees (y up)."""
49
+ r = math.radians(a)
50
+ c, s = math.cos(r), math.sin(r)
51
+ return (u * c - v * s, u * s + v * c)
52
+
53
+
54
+ def arc3(s, m, e):
55
+ """Arc through start, mid, end (y up) -> (centre, radius, sweep from start to end, + = counter-clockwise)."""
56
+ (ax, ay), (bx, by), (cx, cy) = s, m, e
57
+ d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by))
58
+ if abs(d) < 1e-12:
59
+ raise ValueError("degenerate arc")
60
+ ux = ((ax * ax + ay * ay) * (by - cy) + (bx * bx + by * by) * (cy - ay) + (cx * cx + cy * cy) * (ay - by)) / d
61
+ uy = ((ax * ax + ay * ay) * (cx - bx) + (bx * bx + by * by) * (ax - cx) + (cx * cx + cy * cy) * (bx - ax)) / d
62
+ a0, am, a1 = (math.degrees(math.atan2(py - uy, px - ux)) for px, py in (s, m, e))
63
+ ccw = (bx - ax) * (cy - by) - (by - ay) * (cx - bx) > 0
64
+ sweep = (a1 - a0) % 360 if ccw else -((a0 - a1) % 360)
65
+ if abs(sweep) < 1e-9:
66
+ sweep = 360.0 if ccw else -360.0
67
+ return (ux, uy), math.hypot(ax - ux, ay - uy), sweep
68
+
69
+
70
+ def arc_mid(a, c, sweep):
71
+ """The middle point of an arc from a around c by sweep degrees."""
72
+ return tuple(p + q for p, q in zip(c, rot(a[0] - c[0], a[1] - c[1], sweep / 2)))
73
+
74
+
75
+ def arc_points(a, c, sweep, step=10.0):
76
+ """Points along an arc (a included, end included), for shapes a format cannot draw as an arc."""
77
+ n = max(2, int(math.ceil(abs(sweep) / step)))
78
+ return [tuple(p + q for p, q in zip(c, rot(a[0] - c[0], a[1] - c[1], sweep * k / n))) for k in range(n + 1)]
79
+
80
+
81
+ def _stroke(g):
82
+ w = sexpr.first(sexpr.first(g, "stroke") or [], "width") or sexpr.first(g, "width")
83
+ return _f(w[1]) if w else 0.0
84
+
85
+
86
+ def _filled(g):
87
+ fl = sexpr.first(g, "fill")
88
+ if not fl or len(fl) < 2:
89
+ return g[0] in ("fp_poly", "gr_poly") and fl is None
90
+ v = fl[1] if isinstance(fl[1], str) else (sexpr.first(fl, "type") or [None, "none"])[1]
91
+ return v in ("yes", "solid", "outline", "background", "color")
92
+
93
+
94
+ def _xy(node):
95
+ return (_f(node[1]), _f(node[2]))
96
+
97
+
98
+ def capsule(a, b, w, step=15.0):
99
+ """A line of width w with round ends as one polygon (y up)."""
100
+ (ax, ay), (bx, by), r = a, b, w / 2
101
+ ang = math.degrees(math.atan2(by - ay, bx - ax))
102
+ pts = [(bx + r * math.cos(math.radians(ang + t)), by + r * math.sin(math.radians(ang + t)))
103
+ for t in [k * step - 90 for k in range(int(180 / step) + 1)]]
104
+ pts += [(ax + r * math.cos(math.radians(ang + t)), ay + r * math.sin(math.radians(ang + t)))
105
+ for t in [k * step + 90 for k in range(int(180 / step) + 1)]]
106
+ return pts
107
+
108
+
109
+ def round_rect(cx, cy, w, h, r, a=0.0, step=3.0):
110
+ """Outline of a w x h rectangle with corner radius r centred on (cx, cy), turned by a (y up)."""
111
+ r = max(0.0, min(r, w / 2, h / 2))
112
+ pts = []
113
+ for (sx, sy), start in (((1, 1), 0), ((-1, 1), 90), ((-1, -1), 180), ((1, -1), 270)):
114
+ ox, oy = sx * (w / 2 - r), sy * (h / 2 - r)
115
+ if r <= 1e-9:
116
+ pts.append((sx * w / 2, sy * h / 2))
117
+ continue
118
+ for k in range(int(90 / step) + 1):
119
+ t = math.radians(start + k * step)
120
+ pts.append((ox + r * math.cos(t), oy + r * math.sin(t)))
121
+ return [(cx + p[0], cy + p[1]) for p in (rot(u, v, a) for u, v in pts)]
122
+
123
+
124
+ # ---------------------------------------------------------------- footprints
125
+ def _graphic(g, layer, notes, frame=None):
126
+ """One fp_line / fp_rect / fp_circle / fp_arc / fp_poly (or a custom pad's gr_*) -> a graphic dict (y up).
127
+ frame: (x, y, rot) of a pad, for primitives in the pad's frame."""
128
+ def P(node):
129
+ x, y = yup(*node[1:3])
130
+ if frame:
131
+ u, v = rot(x, y, frame[2])
132
+ return (frame[0] + u, frame[1] + v)
133
+ return (x, y)
134
+ kind, w, fill = g[0][3:], _stroke(g), _filled(g)
135
+ if kind == "line":
136
+ return {"layer": layer, "kind": "line", "a": P(sexpr.first(g, "start")), "b": P(sexpr.first(g, "end")), "w": w, "fill": False}
137
+ if kind == "rect":
138
+ (x1, y1), (x2, y2) = _xy(sexpr.first(g, "start")), _xy(sexpr.first(g, "end"))
139
+ pts = [P(["xy", x, y]) for x, y in ((x1, y1), (x2, y1), (x2, y2), (x1, y2))]
140
+ return {"layer": layer, "kind": "poly", "pts": pts, "w": w, "fill": fill}
141
+ if kind == "circle":
142
+ c, e = P(sexpr.first(g, "center")), P(sexpr.first(g, "end"))
143
+ return {"layer": layer, "kind": "circle", "c": c, "r": math.hypot(e[0] - c[0], e[1] - c[1]), "w": w, "fill": fill}
144
+ if kind == "arc":
145
+ if sexpr.first(g, "mid"):
146
+ s, m, e = (P(sexpr.first(g, k)) for k in ("start", "mid", "end"))
147
+ c, r, sweep = arc3(s, m, e)
148
+ else: # KiCad 5 / 6 arcs: (start centre) (end arc start) (angle, clockwise on screen)
149
+ c, s = P(sexpr.first(g, "start")), P(sexpr.first(g, "end"))
150
+ sweep = -_f(sexpr.first(g, "angle")[1])
151
+ e = tuple(p + q for p, q in zip(c, rot(s[0] - c[0], s[1] - c[1], sweep)))
152
+ return {"layer": layer, "kind": "arc", "a": s, "b": e, "c": c, "sweep": sweep, "w": w, "fill": False}
153
+ if kind == "poly":
154
+ pts = []
155
+ for p in (sexpr.first(g, "pts") or [])[1:]:
156
+ if p[0] == "xy":
157
+ pts.append(P(p))
158
+ elif p[0] == "arc": # KiCad 7+: an arc inside a polygon outline
159
+ s, m, e = (P(sexpr.first(p, k)) for k in ("start", "mid", "end"))
160
+ c, _, sweep = arc3(s, m, e)
161
+ pts += arc_points(s, c, sweep)
162
+ return {"layer": layer, "kind": "poly", "pts": pts, "w": w, "fill": fill}
163
+ if kind == "curve": # Bezier: drawn through its control points
164
+ pts = [P(p) for p in (sexpr.first(g, "pts") or [])[1:] if p[0] == "xy"]
165
+ notes.append("Bezier curve drawn as lines through its control points")
166
+ return {"layer": layer, "kind": "poly", "pts": pts, "w": w, "fill": False, "open": True}
167
+ return None
168
+
169
+
170
+ def _custom_parts(p, x, y, a, notes):
171
+ """A custom pad's primitives -> copper parts in the footprint frame (y up)."""
172
+ parts = []
173
+ for g in (sexpr.first(p, "primitives") or [])[1:]:
174
+ if not isinstance(g, list):
175
+ continue
176
+ d = _graphic(["fp_" + g[0][3:]] + g[1:], "cu", notes, (x, y, a))
177
+ if not d:
178
+ continue
179
+ w = d["w"]
180
+ if d["kind"] == "poly" and len(d["pts"]) >= 3:
181
+ parts.append(("poly", d["pts"], w)) # w: the outline width, which grows the shape by w / 2
182
+ elif d["kind"] == "circle":
183
+ if d["fill"]:
184
+ parts.append(("circle", d["c"], d["r"] + w / 2))
185
+ else: # a ring: its outer edge
186
+ parts.append(("circle", d["c"], d["r"] + w / 2))
187
+ notes.append("custom pad ring drawn filled")
188
+ elif d["kind"] == "line" and w > 0:
189
+ parts.append(("poly", capsule(d["a"], d["b"], w), 0.0))
190
+ elif d["kind"] == "arc" and w > 0:
191
+ pts = arc_points(d["a"], d["c"], d["sweep"], 7.5)
192
+ parts += [("poly", capsule(p1, p2, w), 0.0) for p1, p2 in zip(pts, pts[1:])]
193
+ return parts
194
+
195
+
196
+ def footprint(text):
197
+ t = sexpr.parse(text) if isinstance(text, str) else text
198
+ notes = []
199
+ out = {"name": str(t[1]), "descr": str((sexpr.first(t, "descr") or [None, ""])[1]), "pads": [], "graphics": [],
200
+ "texts": {}, "models": [], "notes": notes, "attr": [str(a) for a in (sexpr.first(t, "attr") or [None])[1:]]}
201
+ fp_mask = sexpr.first(t, "solder_mask_margin")
202
+ fp_paste = sexpr.first(t, "solder_paste_margin")
203
+ for p in sexpr.find(t, "pad"):
204
+ num, kind, shape = str(p[1]), str(p[2]), str(p[3])
205
+ at, size = sexpr.first(p, "at"), sexpr.first(p, "size")
206
+ x, y = yup(at[1], at[2])
207
+ a = _f(at[3]) if len(at) > 3 else 0.0
208
+ w, h = _f(size[1]), _f(size[2])
209
+ layers = [str(l) for l in (sexpr.first(p, "layers") or ["layers"])[1:]]
210
+ cu = [l for l in layers if l.endswith(".Cu")]
211
+ pad = {"num": num, "x": x, "y": y, "rot": a % 360, "w": w, "h": h, "shape": shape, "rratio": 0.0, "drill": None,
212
+ "offset": (0.0, 0.0), "approx": [], "parts": [], "layers": []}
213
+ if kind in ("smd", "connect") and not cu:
214
+ pad["kind"] = "window"
215
+ for l in layers:
216
+ for side in ("F", "B"):
217
+ for what in ("Paste", "Mask"):
218
+ if l in (f"{side}.{what}", f"*.{what}", f"F&B.{what}"):
219
+ pad["layers"].append(f"{what.lower()}_{side.lower()}")
220
+ pad["layers"] = sorted(set(pad["layers"]))
221
+ else:
222
+ pad["kind"] = {"thru_hole": "tht", "np_thru_hole": "npth"}.get(kind, "smd")
223
+ pad["side"] = "B" if cu and all(l.startswith("B.") for l in cu) else "F"
224
+ pad["copper"] = bool(cu)
225
+ if shape == "roundrect":
226
+ rr = sexpr.first(p, "roundrect_rratio")
227
+ pad["rratio"] = min(0.5, _f(rr[1])) if rr else 0.0
228
+ if pad["rratio"] < 0.0025:
229
+ pad["shape"] = "rect"
230
+ if shape == "circle":
231
+ pad["h"] = pad["w"]
232
+ cr = sexpr.first(p, "chamfer_ratio")
233
+ if sexpr.first(p, "chamfer") and cr and _f(cr[1]) > 0:
234
+ pad["approx"].append("chamfered corners written square")
235
+ if shape == "trapezoid":
236
+ pad["shape"] = "rect"
237
+ pad["approx"].append("trapezoid written as a rectangle")
238
+ if shape == "custom":
239
+ pad["anchor"] = str((sexpr.first(sexpr.first(p, "options") or [], "anchor") or [None, "circle"])[1])
240
+ pad["parts"] = _custom_parts(p, x, y, a, pad["approx"])
241
+ d = sexpr.first(p, "drill")
242
+ if d:
243
+ vals = [_f(v) for v in d[1:] if isinstance(v, str) and v != "oval"]
244
+ if vals:
245
+ pad["drill"] = (vals[0], vals[1] if len(vals) > 1 else vals[0])
246
+ off = sexpr.first(d, "offset")
247
+ if off:
248
+ pad["offset"] = (_f(off[1]), -_f(off[2]) + 0.0)
249
+ if pad["kind"] == "smd" and pad["offset"] != (0.0, 0.0): # no hole: the offset only moves the copper
250
+ dx, dy = rot(*pad["offset"], a)
251
+ pad["x"], pad["y"], pad["offset"] = x + dx, y + dy, (0.0, 0.0)
252
+ pad["paste"] = any("Paste" in l for l in layers) and pad["kind"] in ("smd", "window")
253
+ pad["mask"] = any("Mask" in l for l in layers)
254
+ mm_ = sexpr.first(p, "solder_mask_margin") or fp_mask
255
+ pm = sexpr.first(p, "solder_paste_margin") or fp_paste
256
+ pad["mask_margin"] = _f(mm_[1]) if mm_ else None
257
+ pad["paste_margin"] = _f(pm[1]) if pm and pad["paste"] else None
258
+ if sexpr.first(p, "solder_paste_margin_ratio") and _f(sexpr.first(p, "solder_paste_margin_ratio")[1]):
259
+ pad["approx"].append("paste margin ratio not kept")
260
+ out["pads"].append(pad)
261
+ for kind in ("fp_line", "fp_rect", "fp_circle", "fp_arc", "fp_poly", "fp_curve"):
262
+ for g in sexpr.find(t, kind):
263
+ lay = str((sexpr.first(g, "layer") or [None, ""])[1])
264
+ role = LAYER_ROLE.get(lay)
265
+ if not role:
266
+ if lay:
267
+ notes.append(f"graphics on {lay} not written")
268
+ continue
269
+ try:
270
+ d = _graphic(g, role, notes)
271
+ except ValueError as e:
272
+ notes.append(f"{kind} on {lay} skipped: {e}")
273
+ continue
274
+ if d:
275
+ out["graphics"].append(d)
276
+ for f in list(sexpr.find(t, "fp_text")) + list(sexpr.find(t, "property")):
277
+ key = str(f[1]).lower()
278
+ at = sexpr.first(f, "at")
279
+ if key in ("reference", "value") and at:
280
+ out["texts"].setdefault(key, yup(at[1], at[2]))
281
+ if sexpr.find(t, "zone") and next(sexpr.find(t, "zone"), None):
282
+ notes.append("keep-out / copper zones not written")
283
+ out["models"] = [str(m[1]) for m in sexpr.find(t, "model")]
284
+ return out
285
+
286
+
287
+ # ---------------------------------------------------------------- symbols
288
+ def _hidden(node):
289
+ return "hide" in node[1:] or bool(sexpr.first(node, "hide") and (len(sexpr.first(node, "hide")) < 2 or sexpr.first(node, "hide")[1] == "yes"))
290
+
291
+
292
+ def _sym_fill(g):
293
+ fl = sexpr.first(g, "fill")
294
+ return str((sexpr.first(fl, "type") or [None, "none"])[1]) if fl else "none"
295
+
296
+
297
+ def _sym_shape(g, notes):
298
+ w = _stroke(g) or DEFAULT_STROKE
299
+ fill = _sym_fill(g)
300
+ if g[0] == "rectangle":
301
+ return {"kind": "rect", "a": _xy(sexpr.first(g, "start")), "b": _xy(sexpr.first(g, "end")), "w": w, "fill": fill}
302
+ if g[0] == "polyline":
303
+ pts = [_xy(p) for p in (sexpr.first(g, "pts") or [])[1:] if p[0] == "xy"]
304
+ return {"kind": "poly", "pts": pts, "closed": len(pts) > 2 and pts[0] == pts[-1], "w": w, "fill": fill}
305
+ if g[0] == "bezier":
306
+ pts = [_xy(p) for p in (sexpr.first(g, "pts") or [])[1:] if p[0] == "xy"]
307
+ notes.append("symbol Bezier drawn as lines through its control points")
308
+ return {"kind": "poly", "pts": pts, "closed": False, "w": w, "fill": "none"}
309
+ if g[0] == "circle":
310
+ return {"kind": "circle", "c": _xy(sexpr.first(g, "center")), "r": _f(sexpr.first(g, "radius")[1]), "w": w, "fill": fill}
311
+ if g[0] == "arc":
312
+ if sexpr.first(g, "mid"):
313
+ s, m, e = (_xy(sexpr.first(g, k)) for k in ("start", "mid", "end"))
314
+ c, r, sweep = arc3(s, m, e)
315
+ else: # KiCad 6: (radius (at) (length) (angles a0 a1)), counter-clockwise
316
+ rad = sexpr.first(g, "radius")
317
+ c, r = _xy(sexpr.first(rad, "at")), _f(sexpr.first(rad, "length")[1])
318
+ a0, a1 = (_f(v) for v in sexpr.first(rad, "angles")[1:3])
319
+ s = (c[0] + r * math.cos(math.radians(a0)), c[1] + r * math.sin(math.radians(a0)))
320
+ e = (c[0] + r * math.cos(math.radians(a1)), c[1] + r * math.sin(math.radians(a1)))
321
+ sweep = (a1 - a0) % 360 or 360.0
322
+ if sweep > 180: # KiCad 6 arcs are the short way round
323
+ sweep -= 360
324
+ return {"kind": "arc", "a": s, "b": e, "c": c, "sweep": sweep, "w": w, "fill": fill}
325
+ if g[0] == "text":
326
+ at = sexpr.first(g, "at")
327
+ size = sexpr.first(sexpr.first(sexpr.first(g, "effects") or [], "font") or [], "size")
328
+ return {"kind": "text", "at": (_f(at[1]), _f(at[2])), "angle": _f(at[3]) / (10 if _f(at[3]) >= 360 else 1) if len(at) > 3 else 0.0,
329
+ "text": str(g[1]), "size": _f(size[1]) if size else 1.27}
330
+ return None
331
+
332
+
333
+ def symbol(sym):
334
+ """A KiCad symbol tree (one `symbol` node, `extends` already flattened, as kicad_export writes it)."""
335
+ notes = []
336
+ props = {str(p[1]): str(p[2]) for p in sexpr.find(sym, "property")}
337
+ pos = {}
338
+ for p in sexpr.find(sym, "property"):
339
+ at = sexpr.first(p, "at")
340
+ if at:
341
+ pos[str(p[1])] = (_f(at[1]), _f(at[2]), _f(at[3]) if len(at) > 3 else 0.0, _hidden(sexpr.first(p, "effects") or []) or _hidden(p))
342
+ pn, pnum = sexpr.first(sym, "pin_names"), sexpr.first(sym, "pin_numbers")
343
+ off = sexpr.first(pn or [], "offset")
344
+ out = {"name": str(sym[1]), "props": props, "prop_at": pos, "units": {}, "notes": notes,
345
+ "pin_names_hidden": bool(pn and _hidden(pn)), "pin_numbers_hidden": bool(pnum and _hidden(pnum)),
346
+ "name_offset": _f(off[1]) if off else 0.508, "power": bool(sexpr.first(sym, "power"))}
347
+ alt = False
348
+ for u in sexpr.find(sym, "symbol"):
349
+ m = re.search(r"_(\d+)_(\d+)$", str(u[1]))
350
+ unit, style = (int(m.group(1)), int(m.group(2))) if m else (1, 1)
351
+ if style > 1:
352
+ alt = True
353
+ continue
354
+ d = out["units"].setdefault(unit, {"pins": [], "shapes": []})
355
+ for g in u[1:]:
356
+ if not isinstance(g, list) or not g:
357
+ continue
358
+ if g[0] == "pin":
359
+ at = sexpr.first(g, "at")
360
+ ln = sexpr.first(g, "length")
361
+ nm, nu = sexpr.first(g, "name") or ["name", ""], sexpr.first(g, "number") or ["number", ""]
362
+ d["pins"].append({"num": str(nu[1]), "name": str(nm[1]), "type": str(g[1]), "shape": str(g[2]),
363
+ "x": _f(at[1]), "y": _f(at[2]), "angle": (_f(at[3]) if len(at) > 3 else 0.0) % 360,
364
+ "length": _f(ln[1]) if ln else 2.54, "hidden": _hidden(g)})
365
+ else:
366
+ try:
367
+ s = _sym_shape(g, notes)
368
+ except (ValueError, TypeError, IndexError) as e:
369
+ notes.append(f"symbol {g[0]} skipped: {e}")
370
+ continue
371
+ if s:
372
+ d["shapes"].append(s)
373
+ if alt:
374
+ notes.append("De Morgan alternate body style not written (the normal style is)")
375
+ out["unit_count"] = max([k for k in out["units"] if k > 0] or [1])
376
+ return out
377
+
378
+
379
+ def pin_text(name, overbar):
380
+ """KiCad pin name with ~{...} overbars -> the target's overbar spelling (overbar(text) wraps one span)."""
381
+ if name in ("~", ""):
382
+ return ""
383
+ if "~{" not in name: # KiCad 6 '~RESET~' / '~RESET'
384
+ name = re.sub(r"~([^~]*)~?", lambda m: "~{" + m.group(1) + "}" if m.group(1) else "", name)
385
+ return re.sub(r"~\{([^}]*)\}", lambda m: overbar(m.group(1)), name)
386
+
387
+
388
+ # ---------------------------------------------------------------- layer coverage
389
+ def grown(pts, d):
390
+ """A polygon grown by d on every side, as a filled shape with an outline of width 2d is (round corners):
391
+ [(point, sweep)] where sweep is the arc from this point to the next (+ = counter-clockwise) or None for a line."""
392
+ if d <= 1e-12:
393
+ return [(p, None) for p in pts]
394
+ q = [p for i, p in enumerate(pts) if math.hypot(p[0] - pts[i - 1][0], p[1] - pts[i - 1][1]) > 1e-9]
395
+ if sum(a[0] * b[1] - b[0] * a[1] for a, b in zip(q, q[1:] + q[:1])) < 0:
396
+ q = q[::-1] # counter-clockwise: the outside is on the right of each edge
397
+ n = len(q)
398
+ normal = []
399
+ for i in range(n):
400
+ (x1, y1), (x2, y2) = q[i], q[(i + 1) % n]
401
+ L = math.hypot(x2 - x1, y2 - y1)
402
+ normal.append(((y2 - y1) / L, -(x2 - x1) / L))
403
+ out = []
404
+ for i in range(n):
405
+ a, b = normal[i - 1], normal[i]
406
+ p = q[i]
407
+ turn = math.degrees(math.atan2(a[0] * b[1] - a[1] * b[0], a[0] * b[0] + a[1] * b[1]))
408
+ if turn > 1e-6: # convex corner: an arc round the vertex
409
+ out.append(((p[0] + a[0] * d, p[1] + a[1] * d), turn))
410
+ out.append(((p[0] + b[0] * d, p[1] + b[1] * d), None))
411
+ else: # straight on or concave: where the two offset edges meet
412
+ k = d / max(1e-9, math.cos(math.radians(turn) / 2))
413
+ bx, by = a[0] + b[0], a[1] + b[1]
414
+ bl = math.hypot(bx, by) or 1.0
415
+ out.append(((p[0] + bx / bl * k, p[1] + by / bl * k), None))
416
+ return out
417
+
418
+
419
+ def grown_points(pts, d, step=5.0):
420
+ seg = grown(pts, d)
421
+ out = []
422
+ for i, (p, sweep) in enumerate(seg):
423
+ if sweep:
424
+ nxt = seg[(i + 1) % len(seg)][0]
425
+ c = _arc_c(p, nxt, sweep)
426
+ out += arc_points(p, c, sweep, step)[:-1]
427
+ else:
428
+ out.append(p)
429
+ return out
430
+
431
+
432
+ def _arc_c(a, b, sweep):
433
+ dx, dy = b[0] - a[0], b[1] - a[1]
434
+ dd = math.hypot(dx, dy)
435
+ h = dd / 2 / math.tan(math.radians(sweep) / 2)
436
+ return (a[0] + b[0]) / 2 - dy / dd * h, (a[1] + b[1]) / 2 + dx / dd * h
437
+
438
+
439
+ def disc(c, r, step=3.0):
440
+ k = max(8, int(round(360 / step)))
441
+ return [(c[0] + r * math.cos(2 * math.pi * i / k), c[1] + r * math.sin(2 * math.pi * i / k)) for i in range(k)]
442
+
443
+
444
+ def pad_polys(p, grow=0.0):
445
+ """A cad_model pad's copper (custom: anchor and parts) grown by `grow`, as polygons (y up)."""
446
+ x, y, w, h, a = p["x"], p["y"], p["w"], p["h"], p["rot"]
447
+ if p["kind"] == "tht" and p.get("offset") != (0.0, 0.0):
448
+ dx, dy = rot(*p["offset"], a)
449
+ x, y = x + dx, y + dy
450
+ if p["shape"] == "custom":
451
+ out = [disc((x, y), w / 2 + grow)] if p.get("anchor", "circle") == "circle" else [round_rect(x, y, w + 2 * grow, h + 2 * grow, grow, a)]
452
+ for part in p.get("parts", []):
453
+ if part[0] == "circle":
454
+ out.append(disc(part[1], part[2] + grow))
455
+ else:
456
+ d = (part[2] / 2 if len(part) > 2 else 0.0) + grow
457
+ out.append(grown_points(part[1], d) if d > 0 else part[1])
458
+ return out
459
+ r = {"circle": min(w, h) / 2, "oval": min(w, h) / 2, "roundrect": p.get("rratio", 0.0) * min(w, h)}.get(p["shape"], 0.0)
460
+ if p["kind"] == "npth" and p.get("drill"):
461
+ w = h = max(w, h)
462
+ r = w / 2
463
+ return [round_rect(x, y, w + 2 * grow, h + 2 * grow, r + grow if r or grow else 0.0, a)]
464
+
465
+
466
+ def openings(fp, frame=0.0):
467
+ """Mask and paste openings of a cad_model footprint per layer {"mask_F": [polys], ...}: every pad that opens the
468
+ layer (grown by its margin; mask openings with the board rule grown by `frame`) and every window. A pad-for-pad
469
+ independent picture of the layers, for checking a writer that splits openings between pads differently."""
470
+ out = {k: [] for k in ("mask_F", "mask_B", "paste_F", "paste_B")}
471
+ for p in fp["pads"]:
472
+ if p["kind"] == "window":
473
+ for lay in p["layers"]:
474
+ kind, side = lay.split("_")
475
+ out[f"{kind}_{side.upper()}"] += pad_polys(p)
476
+ continue
477
+ sides = ("F", "B") if p["kind"] in ("tht", "npth") else (p["side"],)
478
+ if p.get("mask"):
479
+ m = p.get("mask_margin")
480
+ for s in sides:
481
+ out[f"mask_{s}"] += pad_polys(p, frame if m is None else m)
482
+ if p.get("paste") and p["kind"] == "smd":
483
+ out[f"paste_{p['side']}"] += pad_polys(p, p.get("paste_margin") or 0.0)
484
+ for g in fp["graphics"]: # filled drawings on mask / paste open them too
485
+ lay = {"mask_f": "mask_F", "mask_b": "mask_B", "paste_f": "paste_F", "paste_b": "paste_B"}.get(g["layer"])
486
+ if lay and g.get("fill"):
487
+ if g["kind"] == "poly":
488
+ out[lay].append(grown_points(g["pts"], g["w"] / 2) if g["w"] > 0 else g["pts"])
489
+ elif g["kind"] == "circle":
490
+ out[lay].append(disc(g["c"], g["r"] + g["w"] / 2))
491
+ return out
492
+
493
+
494
+ def raster(polys, res=0.02):
495
+ """Cells (i, j) of a res grid covered by the union of the polygons (even-odd per polygon), as a set."""
496
+ cells = set()
497
+ for pts in polys:
498
+ if len(pts) < 3:
499
+ continue
500
+ ys = [p[1] for p in pts]
501
+ j0, j1 = math.floor(min(ys) / res), math.ceil(max(ys) / res)
502
+ n = len(pts)
503
+ for j in range(j0, j1 + 1):
504
+ yc = (j + 0.5) * res
505
+ xs = []
506
+ for k in range(n):
507
+ (x1, y1), (x2, y2) = pts[k], pts[(k + 1) % n]
508
+ if (y1 <= yc < y2) or (y2 <= yc < y1):
509
+ xs.append(x1 + (yc - y1) * (x2 - x1) / (y2 - y1))
510
+ xs.sort()
511
+ for a, b in zip(xs[::2], xs[1::2]):
512
+ for i in range(math.ceil(a / res - 0.5), math.floor(b / res - 0.5) + 1):
513
+ cells.add((i, j))
514
+ return cells
515
+
516
+
517
+ def same_cover(a, b, res=0.02, rel=0.03, floor=0.02):
518
+ """Whether two polygon sets cover the same area: the area in one and not the other is under rel of the
519
+ larger area (and over `floor` mm2). -> (same, differing mm2, total mm2)."""
520
+ # cells are only kept where a polygon is, so the grid is capped by the polygons' own area (~360000 cells), not
521
+ # by their spread: two small holes far apart keep a fine grid
522
+ area = sum((max(p[0] for p in q) - min(p[0] for p in q)) * (max(p[1] for p in q) - min(p[1] for p in q))
523
+ for q in list(a) + list(b) if q)
524
+ res = max(res, math.sqrt(area / 360000))
525
+ ca, cb = raster(a, res), raster(b, res)
526
+ diff = len(ca ^ cb) * res * res
527
+ total = max(len(ca), len(cb)) * res * res
528
+ return diff <= max(floor, rel * total), round(diff, 4), round(total, 4)