akcli-kicad 0.15.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 (191) hide show
  1. akcli/__init__.py +46 -0
  2. akcli/__main__.py +7 -0
  3. akcli/_vendor/__init__.py +0 -0
  4. akcli/_vendor/jlc2kicadlib/LICENSE +9 -0
  5. akcli/_vendor/jlc2kicadlib/PROVENANCE.md +23 -0
  6. akcli/_vendor/jlc2kicadlib/__init__.py +7 -0
  7. akcli/_vendor/jlc2kicadlib/_http.py +49 -0
  8. akcli/_vendor/jlc2kicadlib/_kmt.py +339 -0
  9. akcli/_vendor/jlc2kicadlib/footprint/__init__.py +0 -0
  10. akcli/_vendor/jlc2kicadlib/footprint/footprint.py +181 -0
  11. akcli/_vendor/jlc2kicadlib/footprint/footprint_handlers.py +617 -0
  12. akcli/_vendor/jlc2kicadlib/footprint/model3d.py +255 -0
  13. akcli/_vendor/jlc2kicadlib/helper.py +29 -0
  14. akcli/_vendor/jlc2kicadlib/symbol/__init__.py +0 -0
  15. akcli/_vendor/jlc2kicadlib/symbol/symbol.py +234 -0
  16. akcli/_vendor/jlc2kicadlib/symbol/symbol_handlers.py +469 -0
  17. akcli/adapters/__init__.py +1 -0
  18. akcli/adapters/dts.py +241 -0
  19. akcli/adapters/pinout_md.py +162 -0
  20. akcli/arrange.py +372 -0
  21. akcli/bom_policy.py +129 -0
  22. akcli/calc/__init__.py +28 -0
  23. akcli/calc/analog.py +148 -0
  24. akcli/calc/codes.py +132 -0
  25. akcli/calc/convert.py +86 -0
  26. akcli/calc/electrical.py +177 -0
  27. akcli/calc/eseries.py +236 -0
  28. akcli/calc/ic.py +169 -0
  29. akcli/calc/interface.py +68 -0
  30. akcli/calc/opsmap.py +141 -0
  31. akcli/calc/pcb.py +254 -0
  32. akcli/calc/power.py +209 -0
  33. akcli/calc/protection.py +107 -0
  34. akcli/calc/registry.py +113 -0
  35. akcli/calc/regulator.py +110 -0
  36. akcli/calc/rf.py +296 -0
  37. akcli/calc/sensor.py +151 -0
  38. akcli/calc/si.py +74 -0
  39. akcli/checks/__init__.py +1 -0
  40. akcli/checks/_rails.py +57 -0
  41. akcli/checks/bom.py +421 -0
  42. akcli/checks/contract.py +275 -0
  43. akcli/checks/diff.py +517 -0
  44. akcli/checks/erc.py +541 -0
  45. akcli/checks/geom.py +157 -0
  46. akcli/checks/intent.py +452 -0
  47. akcli/checks/layout.py +508 -0
  48. akcli/checks/libsync.py +193 -0
  49. akcli/checks/nets.py +101 -0
  50. akcli/checks/pairs.py +158 -0
  51. akcli/checks/pinmap.py +288 -0
  52. akcli/checks/power.py +322 -0
  53. akcli/checks/schpcb.py +153 -0
  54. akcli/cli.py +235 -0
  55. akcli/commands/__init__.py +7 -0
  56. akcli/commands/_shared.py +440 -0
  57. akcli/commands/calc.py +207 -0
  58. akcli/commands/capabilities.py +270 -0
  59. akcli/commands/checks.py +546 -0
  60. akcli/commands/doc.py +219 -0
  61. akcli/commands/doctor.py +225 -0
  62. akcli/commands/drawing.py +1388 -0
  63. akcli/commands/fab.py +116 -0
  64. akcli/commands/jlc.py +838 -0
  65. akcli/commands/library.py +322 -0
  66. akcli/commands/log.py +75 -0
  67. akcli/commands/query.py +729 -0
  68. akcli/commands/release.py +302 -0
  69. akcli/commands/render.py +79 -0
  70. akcli/commands/review.py +687 -0
  71. akcli/commands/sim.py +758 -0
  72. akcli/commands/view.py +96 -0
  73. akcli/config.py +281 -0
  74. akcli/drivers/__init__.py +1 -0
  75. akcli/drivers/altium_live/__init__.py +1 -0
  76. akcli/drivers/altium_live/bridge.py +289 -0
  77. akcli/drivers/jlc2kicad.py +170 -0
  78. akcli/drivers/kicad_cli.py +185 -0
  79. akcli/drivers/pdftotext.py +63 -0
  80. akcli/errors.py +313 -0
  81. akcli/exporters.py +142 -0
  82. akcli/fab.py +379 -0
  83. akcli/groupframe.py +140 -0
  84. akcli/journal.py +157 -0
  85. akcli/kicad_escape.py +99 -0
  86. akcli/libtable.py +578 -0
  87. akcli/model.py +325 -0
  88. akcli/netbuild.py +584 -0
  89. akcli/netdiff.py +265 -0
  90. akcli/ops.py +1301 -0
  91. akcli/parts/__init__.py +18 -0
  92. akcli/parts/bom_jlc.py +932 -0
  93. akcli/parts/datasheet.py +241 -0
  94. akcli/parts/easyeda.py +433 -0
  95. akcli/parts/search.py +548 -0
  96. akcli/parts/value_parse.py +196 -0
  97. akcli/readers/__init__.py +1 -0
  98. akcli/readers/_cfbf.py +314 -0
  99. akcli/readers/altium_pcb.py +150 -0
  100. akcli/readers/altium_pcb_bin.py +230 -0
  101. akcli/readers/altium_prj.py +93 -0
  102. akcli/readers/altium_records.py +192 -0
  103. akcli/readers/altium_sch.py +503 -0
  104. akcli/readers/altium_schlib.py +134 -0
  105. akcli/readers/footprint_lib.py +311 -0
  106. akcli/readers/gerber.py +254 -0
  107. akcli/readers/kicad.py +810 -0
  108. akcli/readers/kicad_lib.py +400 -0
  109. akcli/readers/sexpr.py +339 -0
  110. akcli/relink.py +310 -0
  111. akcli/render_art.py +312 -0
  112. akcli/render_svg.py +337 -0
  113. akcli/report.py +488 -0
  114. akcli/review/__init__.py +83 -0
  115. akcli/review/detectors/__init__.py +8 -0
  116. akcli/review/detectors/domain/__init__.py +8 -0
  117. akcli/review/detectors/domain/usb.py +104 -0
  118. akcli/review/detectors/emc/__init__.py +10 -0
  119. akcli/review/detectors/emc/diffpair.py +95 -0
  120. akcli/review/detectors/emc/edge.py +112 -0
  121. akcli/review/detectors/emc/planes.py +122 -0
  122. akcli/review/detectors/emc/protection.py +97 -0
  123. akcli/review/detectors/emc/stitching.py +88 -0
  124. akcli/review/detectors/gerber.py +198 -0
  125. akcli/review/detectors/pcb/__init__.py +3 -0
  126. akcli/review/detectors/pcb/decap.py +98 -0
  127. akcli/review/detectors/pcb/routing.py +67 -0
  128. akcli/review/detectors/pcb/thermal.py +170 -0
  129. akcli/review/detectors/pcb/trace_width.py +78 -0
  130. akcli/review/detectors/signal/__init__.py +6 -0
  131. akcli/review/detectors/signal/crystal.py +166 -0
  132. akcli/review/detectors/signal/divider.py +250 -0
  133. akcli/review/detectors/signal/opamp.py +198 -0
  134. akcli/review/detectors/signal/power_protect.py +279 -0
  135. akcli/review/detectors/signal/protection.py +79 -0
  136. akcli/review/detectors/signal/rc_filter.py +72 -0
  137. akcli/review/detectors/validation/__init__.py +3 -0
  138. akcli/review/detectors/validation/enable_pin.py +71 -0
  139. akcli/review/detectors/validation/i2c_pullup.py +169 -0
  140. akcli/review/detectors/validation/vdomain.py +131 -0
  141. akcli/review/diff.py +73 -0
  142. akcli/review/engine.py +111 -0
  143. akcli/review/facts.py +340 -0
  144. akcli/review/geometry.py +258 -0
  145. akcli/review/propose.py +237 -0
  146. akcli/review/tables.py +146 -0
  147. akcli/review/testbench.py +246 -0
  148. akcli/review/topo.py +314 -0
  149. akcli/review/tree.py +75 -0
  150. akcli/review/validate.py +169 -0
  151. akcli/safety.py +127 -0
  152. akcli/schemas/__init__.py +8 -0
  153. akcli/schemas/datasheet-facts.schema.json +54 -0
  154. akcli/schemas/diff.schema.json +98 -0
  155. akcli/schemas/doc.schema.json +79 -0
  156. akcli/schemas/draw-result.schema.json +62 -0
  157. akcli/schemas/findings.schema.json +100 -0
  158. akcli/schemas/netlist.schema.json +50 -0
  159. akcli/schemas/ops.capabilities.json +122 -0
  160. akcli/schemas/ops.schema.json +484 -0
  161. akcli/schemas/pinmap.schema.json +40 -0
  162. akcli/schemas/proposals.schema.json +62 -0
  163. akcli/schemas/schematic.schema.json +82 -0
  164. akcli/schemas/sim.schema.json +119 -0
  165. akcli/sim/__init__.py +24 -0
  166. akcli/sim/assertions.py +448 -0
  167. akcli/sim/builtin.lib +119 -0
  168. akcli/sim/deck.py +574 -0
  169. akcli/sim/engine.py +359 -0
  170. akcli/sim/models.py +614 -0
  171. akcli/sim/wave.py +92 -0
  172. akcli/units.py +68 -0
  173. akcli/webui/__init__.py +17 -0
  174. akcli/webui/calc.html +1593 -0
  175. akcli/webui/hub.html +226 -0
  176. akcli/webui/live.html +961 -0
  177. akcli/webui/server.py +807 -0
  178. akcli/writers/__init__.py +1 -0
  179. akcli/writers/connectivity.py +581 -0
  180. akcli/writers/footprint_mod.py +86 -0
  181. akcli/writers/geometry.py +221 -0
  182. akcli/writers/instances.py +289 -0
  183. akcli/writers/kicad.py +2089 -0
  184. akcli/writers/lib_cache.py +536 -0
  185. akcli/writers/sexpr_writer.py +200 -0
  186. akcli_kicad-0.15.0.dist-info/METADATA +219 -0
  187. akcli_kicad-0.15.0.dist-info/RECORD +191 -0
  188. akcli_kicad-0.15.0.dist-info/WHEEL +5 -0
  189. akcli_kicad-0.15.0.dist-info/entry_points.txt +2 -0
  190. akcli_kicad-0.15.0.dist-info/licenses/LICENSE +21 -0
  191. akcli_kicad-0.15.0.dist-info/top_level.txt +1 -0
akcli/__init__.py ADDED
@@ -0,0 +1,46 @@
1
+ """akcli — read Altium .SchDoc and KiCad .kicad_sch, run checks, draw KiCad.
2
+
3
+ Zero-runtime-dependency Python package (stdlib only). This top-level module exposes
4
+ the package version and the analysis/protocol version constants used across the CLI.
5
+
6
+ Name cascade (LOCKED): PyPI dist = ``akcli-kicad`` (the ``akcli`` name is
7
+ taken on PyPI by an unrelated project); import package = ``akcli``; CLI =
8
+ ``akcli``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from importlib.metadata import PackageNotFoundError, version as _pkg_version
14
+
15
+
16
+ def _resolve_version() -> str:
17
+ """Report the version of the code actually running, dev checkout or install.
18
+
19
+ A co-located ``pyproject.toml`` (``project.name == "akcli-kicad"``) means we are
20
+ executing FROM the source tree; that file is the freshest truth and WINS —
21
+ an editable or older install otherwise pins stale metadata, so bumping the
22
+ working tree would not move ``akcli --version`` and a real update reads as
23
+ "unchanged" (the exact confusion the 0.7.0/design-integrity overlap caused).
24
+ An installed wheel has no such sibling pyproject, so it falls through to the
25
+ dist metadata. Last-resort ``"0.0.0"`` only if neither source is readable.
26
+ """
27
+ try:
28
+ import tomllib
29
+ from pathlib import Path
30
+
31
+ pyproject = Path(__file__).resolve().parents[2] / "pyproject.toml"
32
+ with pyproject.open("rb") as fh:
33
+ project = tomllib.load(fh).get("project", {})
34
+ if project.get("name") == "akcli-kicad" and project.get("version"):
35
+ return project["version"]
36
+ except Exception:
37
+ pass
38
+ try:
39
+ return _pkg_version("akcli-kicad")
40
+ except PackageNotFoundError:
41
+ return "0.0.0"
42
+
43
+
44
+ __version__ = _resolve_version()
45
+
46
+ __all__ = ["__version__"]
akcli/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """``python -m akcli`` entry point (thin shim over :func:`cli.main`)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .cli import main
6
+
7
+ raise SystemExit(main())
File without changes
@@ -0,0 +1,9 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright © 2021 TousstNicolas
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,23 @@
1
+ # Provenance — vendored JLC2KiCadLib
2
+
3
+ - Upstream: https://github.com/TousstNicolas/JLC2KiCad_lib
4
+ - License: MIT (see `LICENSE` in this directory, preserved verbatim)
5
+ - Vendored commit: `48d36032108d64b0f59755234681f1ce8bc98d46`
6
+ - Files taken: `helper.py`, `footprint/{footprint,footprint_handlers,model3d}.py`,
7
+ `symbol/{symbol,symbol_handlers}.py`. The upstream CLI entry
8
+ (`JLC2KiCadLib.py`) is NOT vendored — akcli drives `create_footprint` /
9
+ `create_symbol` directly from `drivers/jlc2kicad.py`.
10
+
11
+ ## Local modifications (kept minimal, all import-level)
12
+
13
+ 1. `import requests` → `from .. import _http as requests` (stdlib urllib shim;
14
+ keeps akcli zero-dependency).
15
+ 2. `from KicadModTree import ...` → `from .._kmt import ...`. **KicadModTree is
16
+ GPLv3+ and is deliberately not vendored or imported**; `_kmt.py` is an
17
+ original, clean-room implementation of just the API surface these files use,
18
+ written against the KiCad footprint *file format* (it emits the modern
19
+ `(footprint ...)` s-expression dialect rather than the legacy `(module ...)`
20
+ output of KicadModTree 1.x).
21
+
22
+ `_http.py` and `_kmt.py` are original akcli code (project MIT license), not
23
+ upstream files.
@@ -0,0 +1,7 @@
1
+ """Vendored JLC2KiCadLib (MIT, (c) 2021 TousstNicolas) — see LICENSE + PROVENANCE.md.
2
+
3
+ Only the conversion core is vendored (footprint/ + symbol/ + helper.py). Its two
4
+ upstream dependencies are NOT vendored: ``requests`` is replaced by the stdlib
5
+ shim ``_http`` and the GPLv3 ``KicadModTree`` by the clean-room ``_kmt`` writer
6
+ (implemented from the KiCad footprint file format, not from KicadModTree code).
7
+ """
@@ -0,0 +1,49 @@
1
+ """Stdlib drop-in for the tiny slice of ``requests`` the vendored code uses.
2
+
3
+ Original akcli code (project MIT license) — NOT an upstream JLC2KiCadLib file.
4
+ Surface: ``get(url, headers=...)`` returning an object with ``.status_code`` and
5
+ ``.content``, plus ``codes.ok``. A module-level ``opener`` is injectable so
6
+ tests run fully offline (same pattern as :mod:`akcli.parts.search`).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import urllib.error
12
+ import urllib.request
13
+ from dataclasses import dataclass
14
+ from types import SimpleNamespace
15
+
16
+ # EasyEDA payloads are small JSON / STEP files; cap reads defensively.
17
+ _MAX_BYTES = 64 * 1024 * 1024
18
+ _TIMEOUT_S = 30.0
19
+
20
+ #: Injectable transport: any object with ``open(request, *, timeout=...)``.
21
+ opener = None
22
+
23
+ codes = SimpleNamespace(ok=200)
24
+
25
+
26
+ @dataclass
27
+ class Response:
28
+ status_code: int
29
+ content: bytes
30
+
31
+ @property
32
+ def text(self) -> str:
33
+ return self.content.decode("utf-8", "replace")
34
+
35
+
36
+ def get(url: str, headers: dict | None = None, timeout: float = _TIMEOUT_S) -> Response:
37
+ req = urllib.request.Request(url, headers=dict(headers or {}))
38
+ op = opener or urllib.request.build_opener()
39
+ try:
40
+ with op.open(req, timeout=timeout) as resp:
41
+ body = resp.read(_MAX_BYTES + 1)
42
+ if len(body) > _MAX_BYTES:
43
+ return Response(status_code=502, content=b"")
44
+ status = getattr(resp, "status", None) or resp.getcode() or 200
45
+ return Response(status_code=int(status), content=body)
46
+ except urllib.error.HTTPError as exc: # non-2xx: report the code, never raise
47
+ return Response(status_code=int(exc.code), content=b"")
48
+ except (urllib.error.URLError, OSError, TimeoutError):
49
+ return Response(status_code=599, content=b"")
@@ -0,0 +1,339 @@
1
+ """Clean-room mini footprint writer replacing the GPLv3 ``KicadModTree``.
2
+
3
+ Original akcli code (project MIT license) — NOT an upstream JLC2KiCadLib file,
4
+ and NOT derived from KicadModTree's source: it implements, from the public
5
+ KiCad footprint *file format*, exactly the API surface the vendored
6
+ JLC2KiCadLib modules use (class names/kwargs are the interface those modules
7
+ call; the serialization below is the modern ``(footprint ...)`` dialect KiCad
8
+ 7-10 read natively, not KicadModTree 1.x's legacy ``(module ...)`` output).
9
+
10
+ Coordinate/unit contract: geometry values arrive in **mm** (the vendored
11
+ handlers convert EasyEDA mils to mm). ``Model.at`` arrives in the legacy
12
+ KicadModTree convention of **inches** and is serialized as an
13
+ ``(offset (xyz ...))`` in mm (x25.4).
14
+
15
+ Dialect: the serializer emits the **KiCad 6** footprint dialect
16
+ (``(layer ...) (width ...)`` on graphic items, version ``20211014``). Every
17
+ KiCad from 6 through 10 reads it natively — and, decisively, so does **Altium
18
+ Designer's Import Wizard** (File » Import Wizard » KiCad Design Files), whose
19
+ KiCad support is pinned to 6.0x: the same converted library becomes an Altium
20
+ ``SchLib``/``PcbLib`` without any extra tool.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import math
26
+
27
+ __all__ = [
28
+ "Arc", "Circle", "Footprint", "KicadFileHandler", "Line", "Model", "Pad",
29
+ "Polygon", "RectFill", "RectLine", "Text", "Translation", "Vector2D",
30
+ ]
31
+
32
+
33
+ # --------------------------------------------------------------------------- #
34
+ # small geometry helper (used by the vendored SVG-arc math)
35
+ # --------------------------------------------------------------------------- #
36
+ class Vector2D:
37
+ def __init__(self, x, y=None):
38
+ if y is None: # Vector2D((x, y)) / Vector2D([x, y]) / Vector2D(other)
39
+ x, y = x[0], x[1]
40
+ self.x = float(x)
41
+ self.y = float(y)
42
+
43
+ def __getitem__(self, i):
44
+ return (self.x, self.y)[i]
45
+
46
+ def __add__(self, other):
47
+ return Vector2D(self.x + other[0], self.y + other[1])
48
+
49
+ def __mul__(self, k):
50
+ return Vector2D(self.x * k, self.y * k)
51
+
52
+ __rmul__ = __mul__
53
+
54
+ def rotate(self, degrees):
55
+ r = math.radians(degrees)
56
+ c, s = math.cos(r), math.sin(r)
57
+ return Vector2D(self.x * c - self.y * s, self.x * s + self.y * c)
58
+
59
+ def distance_to(self, other):
60
+ return math.hypot(self.x - other[0], self.y - other[1])
61
+
62
+
63
+ def _xy(pt) -> tuple[float, float]:
64
+ return (float(pt[0]), float(pt[1]))
65
+
66
+
67
+ def _fmt(v: float) -> str:
68
+ s = f"{float(v):.6f}".rstrip("0").rstrip(".")
69
+ return s if s not in ("-0", "") else "0"
70
+
71
+
72
+ def _q(s: str) -> str:
73
+ return '"' + str(s).replace("\\", "\\\\").replace('"', '\\"') + '"'
74
+
75
+
76
+ # --------------------------------------------------------------------------- #
77
+ # nodes (constructor kwargs == the surface the vendored handlers call)
78
+ # --------------------------------------------------------------------------- #
79
+ class Line:
80
+ def __init__(self, start, end, width, layer):
81
+ self.start, self.end = _xy(start), _xy(end)
82
+ self.width, self.layer = float(width), layer
83
+
84
+
85
+ class Arc:
86
+ def __init__(self, start, end, center, width, layer):
87
+ self.start, self.end, self.center = _xy(start), _xy(end), _xy(center)
88
+ self.width, self.layer = float(width), layer
89
+
90
+
91
+ class Circle:
92
+ def __init__(self, center, radius, width, layer):
93
+ self.center, self.radius = _xy(center), float(radius)
94
+ self.width, self.layer = float(width), layer
95
+
96
+
97
+ class Polygon:
98
+ def __init__(self, nodes, layer=None, width=0.0):
99
+ self.nodes = [_xy(p) for p in nodes] # materialize (callers pass zip())
100
+ self.layer, self.width = layer, float(width)
101
+
102
+
103
+ class RectLine:
104
+ def __init__(self, start, end, width, layer):
105
+ self.start, self.end = _xy(start), _xy(end)
106
+ self.width, self.layer = float(width), layer
107
+
108
+
109
+ class RectFill:
110
+ def __init__(self, start, end, layer):
111
+ self.start, self.end, self.layer = _xy(start), _xy(end), layer
112
+
113
+
114
+ class Text:
115
+ def __init__(self, type, text, at, layer): # noqa: A002 - upstream kwarg name
116
+ self.type, self.text = type, str(text)
117
+ self.at, self.layer = _xy(at), layer
118
+
119
+
120
+ class Pad:
121
+ TYPE_THT = "thru_hole"
122
+ TYPE_SMT = "smd"
123
+ TYPE_NPTH = "np_thru_hole"
124
+ SHAPE_CIRCLE = "circle"
125
+ SHAPE_OVAL = "oval"
126
+ SHAPE_RECT = "rect"
127
+ SHAPE_CUSTOM = "custom"
128
+ LAYERS_THT = ["*.Cu", "*.Mask"]
129
+ LAYERS_SMT = ["F.Cu", "F.Paste", "F.Mask"]
130
+ LAYERS_NPTH = ["*.Cu", "*.Mask"]
131
+
132
+ def __init__(self, number, type, shape, at, size, layers, # noqa: A002
133
+ rotation=0.0, drill=None, primitives=""):
134
+ self.number = str(number)
135
+ self.type = type
136
+ self.shape = shape
137
+ self.at = _xy(at)
138
+ self.size = _xy(size) if isinstance(size, (list, tuple)) else (float(size), float(size))
139
+ self.rotation = float(rotation or 0.0)
140
+ self.drill = drill
141
+ self.layers = list(layers)
142
+ self.primitives = list(primitives) if primitives else []
143
+
144
+
145
+ class Model:
146
+ def __init__(self, filename, at, rotate, scale=(1.0, 1.0, 1.0)):
147
+ # upstream sometimes pre-quotes path-variable filenames — strip that.
148
+ self.filename = str(filename).strip('"')
149
+ self.at = tuple(float(v) for v in at) # legacy inches
150
+ self.rotate = tuple(float(v) for v in rotate)
151
+ self.scale = tuple(float(v) for v in scale)
152
+
153
+
154
+ class Translation:
155
+ """Container that offsets everything inside it (the only transform used)."""
156
+
157
+ def __init__(self, x, y):
158
+ self.offset = (float(x), float(y))
159
+ self.children: list = []
160
+
161
+ def append(self, node) -> None:
162
+ self.children.append(node)
163
+
164
+
165
+ class Footprint:
166
+ def __init__(self, name):
167
+ self.name = str(name).strip('"') # upstream passes a pre-quoted name
168
+ self.description = ""
169
+ self.tags = ""
170
+ self.attribute = "smd"
171
+ self.children: list = []
172
+
173
+ def setDescription(self, s): # noqa: N802 - upstream API name
174
+ self.description = str(s)
175
+
176
+ def setTags(self, s): # noqa: N802
177
+ self.tags = str(s)
178
+
179
+ def setAttribute(self, s): # noqa: N802
180
+ self.attribute = str(s)
181
+
182
+ def append(self, node) -> None:
183
+ self.children.append(node)
184
+
185
+ def insert(self, node) -> None:
186
+ """Re-parent all current children under ``node`` (Translation wrap)."""
187
+ if isinstance(node, Translation):
188
+ node.children = self.children
189
+ self.children = [node]
190
+ else:
191
+ self.children.append(node)
192
+
193
+ def getAllChilds(self): # noqa: N802
194
+ out = []
195
+ stack = list(self.children)
196
+ while stack:
197
+ n = stack.pop()
198
+ out.append(n)
199
+ stack.extend(getattr(n, "children", []) or [])
200
+ return out
201
+
202
+
203
+ # --------------------------------------------------------------------------- #
204
+ # serialization — modern (footprint ...) s-expression
205
+ # --------------------------------------------------------------------------- #
206
+ def _shift(pt, off):
207
+ return (pt[0] + off[0], pt[1] + off[1])
208
+
209
+
210
+ def _arc_mid(start, end, center):
211
+ """Point on the arc halfway from start to end (clockwise, +Y-down plane).
212
+
213
+ The vendored handler normalizes SVG sweep so the arc always runs clockwise
214
+ (screen sense, +Y down) from ``start`` to ``end`` around ``center``.
215
+ """
216
+ a0 = math.atan2(start[1] - center[1], start[0] - center[0])
217
+ a1 = math.atan2(end[1] - center[1], end[0] - center[0])
218
+ r = math.hypot(start[0] - center[0], start[1] - center[1])
219
+ sweep = (a1 - a0) % (2 * math.pi) # CW in y-down == increasing atan2 angle
220
+ am = a0 + sweep / 2.0
221
+ return (center[0] + r * math.cos(am), center[1] + r * math.sin(am))
222
+
223
+
224
+ def _gtail(layer, width):
225
+ """KiCad-6 graphic-item tail: ``(layer "X") (width W)`` (no stroke node)."""
226
+ return f"(layer {_q(layer)}) (width {_fmt(width)})"
227
+
228
+
229
+ def _serialize_node(node, off, out: list[str]) -> None:
230
+ ind = "\t"
231
+ if isinstance(node, Translation):
232
+ noff = (off[0] + node.offset[0], off[1] + node.offset[1])
233
+ for child in node.children:
234
+ _serialize_node(child, noff, out)
235
+ return
236
+ if isinstance(node, Line):
237
+ s, e = _shift(node.start, off), _shift(node.end, off)
238
+ out.append(f"{ind}(fp_line (start {_fmt(s[0])} {_fmt(s[1])}) "
239
+ f"(end {_fmt(e[0])} {_fmt(e[1])}) {_gtail(node.layer, node.width)})")
240
+ elif isinstance(node, RectLine) or isinstance(node, RectFill):
241
+ s, e = _shift(node.start, off), _shift(node.end, off)
242
+ width = getattr(node, "width", 0.0)
243
+ fill = "none" if isinstance(node, RectLine) else "solid"
244
+ out.append(f"{ind}(fp_rect (start {_fmt(s[0])} {_fmt(s[1])}) "
245
+ f"(end {_fmt(e[0])} {_fmt(e[1])}) {_gtail(node.layer, width)} "
246
+ f"(fill {fill}))")
247
+ elif isinstance(node, Arc):
248
+ s, e = _shift(node.start, off), _shift(node.end, off)
249
+ c = _shift(node.center, off)
250
+ m = _arc_mid(s, e, c)
251
+ out.append(f"{ind}(fp_arc (start {_fmt(s[0])} {_fmt(s[1])}) "
252
+ f"(mid {_fmt(m[0])} {_fmt(m[1])}) (end {_fmt(e[0])} {_fmt(e[1])}) "
253
+ f"{_gtail(node.layer, node.width)})")
254
+ elif isinstance(node, Circle):
255
+ c = _shift(node.center, off)
256
+ out.append(f"{ind}(fp_circle (center {_fmt(c[0])} {_fmt(c[1])}) "
257
+ f"(end {_fmt(c[0] + node.radius)} {_fmt(c[1])}) "
258
+ f"{_gtail(node.layer, node.width)} (fill none))")
259
+ elif isinstance(node, Polygon):
260
+ pts = " ".join(f"(xy {_fmt(p[0] + off[0])} {_fmt(p[1] + off[1])})"
261
+ for p in node.nodes)
262
+ layer = node.layer or "F.Fab"
263
+ out.append(f"{ind}(fp_poly (pts {pts}) {_gtail(layer, node.width)} "
264
+ f"(fill solid))")
265
+ elif isinstance(node, Text):
266
+ a = _shift(node.at, off)
267
+ out.append(f"{ind}(fp_text {node.type} {_q(node.text)} "
268
+ f"(at {_fmt(a[0])} {_fmt(a[1])}) (layer {_q(node.layer)}) "
269
+ f"(effects (font (size 1 1) (thickness 0.15))))")
270
+ elif isinstance(node, Pad):
271
+ a = _shift(node.at, off)
272
+ at = (f"(at {_fmt(a[0])} {_fmt(a[1])}"
273
+ + (f" {_fmt(node.rotation)}" if node.rotation else "") + ")")
274
+ parts = [f"(pad {_q(node.number)} {node.type} {node.shape} {at} "
275
+ f"(size {_fmt(node.size[0])} {_fmt(node.size[1])})"]
276
+ drill = node.drill
277
+ if drill and node.type in (Pad.TYPE_THT, Pad.TYPE_NPTH):
278
+ if isinstance(drill, (list, tuple)):
279
+ parts.append(f"(drill oval {_fmt(drill[0])} {_fmt(drill[1])})")
280
+ elif float(drill) > 0:
281
+ parts.append(f"(drill {_fmt(drill)})")
282
+ parts.append("(layers " + " ".join(_q(layer) for layer in node.layers) + ")")
283
+ if node.shape == Pad.SHAPE_CUSTOM and node.primitives:
284
+ prims = []
285
+ for prim in node.primitives:
286
+ if isinstance(prim, Polygon):
287
+ pts = " ".join(f"(xy {_fmt(p[0])} {_fmt(p[1])})" for p in prim.nodes)
288
+ prims.append(f"(gr_poly (pts {pts}) (width 0) (fill yes))")
289
+ parts.append("(options (clearance outline) (anchor circle)) "
290
+ "(primitives " + " ".join(prims) + ")")
291
+ out.append(ind + " ".join(parts) + ")")
292
+ elif isinstance(node, Model):
293
+ o = tuple(v * 25.4 for v in node.at) # legacy inches -> mm offset
294
+ out.append(
295
+ f"{ind}(model {_q(node.filename)}\n"
296
+ f"{ind}\t(offset (xyz {_fmt(o[0])} {_fmt(o[1])} {_fmt(o[2])}))\n"
297
+ f"{ind}\t(scale (xyz {_fmt(node.scale[0])} {_fmt(node.scale[1])} "
298
+ f"{_fmt(node.scale[2])}))\n"
299
+ f"{ind}\t(rotate (xyz {_fmt(node.rotate[0])} {_fmt(node.rotate[1])} "
300
+ f"{_fmt(node.rotate[2])}))\n"
301
+ f"{ind})"
302
+ )
303
+ # unknown nodes are ignored (none exist in the vendored call surface)
304
+
305
+
306
+ class KicadFileHandler:
307
+ def __init__(self, footprint: Footprint):
308
+ self.footprint = footprint
309
+
310
+ def serialize(self) -> str:
311
+ fp = self.footprint
312
+ lines = [
313
+ f"(footprint {_q(fp.name)}",
314
+ "\t(version 20211014)",
315
+ "\t(generator akcli)",
316
+ '\t(layer "F.Cu")',
317
+ f"\t(descr {_q(fp.description)})",
318
+ f"\t(tags {_q(fp.tags)})",
319
+ f"\t(attr {fp.attribute})",
320
+ ]
321
+ body: list[str] = []
322
+ for child in fp.children:
323
+ _serialize_node(child, (0.0, 0.0), body)
324
+ # KiCad expects graphics/text before pads before models; keep a stable
325
+ # grouped order for diffability.
326
+ def _rank(line: str) -> int:
327
+ if line.lstrip("\t").startswith("(pad"):
328
+ return 1
329
+ if line.lstrip("\t").startswith("(model"):
330
+ return 2
331
+ return 0
332
+ body.sort(key=_rank)
333
+ lines.extend(body)
334
+ lines.append(")")
335
+ return "\n".join(lines) + "\n"
336
+
337
+ def writeFile(self, path) -> None: # noqa: N802 - upstream API name
338
+ with open(path, "w", encoding="utf-8") as fh:
339
+ fh.write(self.serialize())
File without changes
@@ -0,0 +1,181 @@
1
+ import json
2
+ import logging
3
+ import os
4
+ from dataclasses import dataclass
5
+
6
+ from .. import _http as requests
7
+ from .._kmt import Footprint, KicadFileHandler, Pad, Text, Translation
8
+
9
+ from .. import helper
10
+ from .footprint_handlers import handlers, mil2mm
11
+
12
+
13
+ @dataclass
14
+ class FootprintInfo:
15
+ max_X: float = -10000
16
+ max_Y: float = -10000
17
+ min_X: float = 10000
18
+ min_Y: float = 10000
19
+ footprint_name: str = ""
20
+ output_dir: str = ""
21
+ footprint_lib: str = ""
22
+ model_base_variable: str = ""
23
+ model_dir: str = ""
24
+ origin: tuple = (0, 0)
25
+ models: str = ""
26
+
27
+
28
+ def create_footprint(
29
+ footprint_component_uuid,
30
+ component_id,
31
+ footprint_lib,
32
+ output_dir,
33
+ model_base_variable,
34
+ model_dir,
35
+ skip_existing,
36
+ models,
37
+ ):
38
+ logging.info("Creating footprint ...")
39
+
40
+ (
41
+ footprint_name,
42
+ datasheet_link,
43
+ footprint_shape,
44
+ translation,
45
+ ) = get_footprint_info(footprint_component_uuid)
46
+
47
+ if skip_existing and os.path.isfile(
48
+ os.path.join(output_dir, footprint_lib, footprint_name + ".kicad_mod")
49
+ ):
50
+ logging.info(f"Footprint {footprint_name} already exists, skipping.")
51
+ return f"{footprint_lib}:{footprint_name}", datasheet_link
52
+
53
+ # init kicad footprint
54
+ kicad_mod = Footprint(f'"{footprint_name}"')
55
+ kicad_mod.setDescription(f"{footprint_name} footprint") # TODO Set real description
56
+ kicad_mod.setTags(f"{footprint_name} footprint {component_id}")
57
+
58
+ footprint_info = FootprintInfo(
59
+ footprint_name=footprint_name,
60
+ output_dir=output_dir,
61
+ footprint_lib=footprint_lib,
62
+ model_base_variable=model_base_variable,
63
+ model_dir=model_dir,
64
+ origin=translation,
65
+ models=models,
66
+ )
67
+
68
+ # for each line in data : use the appropriate handler
69
+ for line in footprint_shape:
70
+ args = [i for i in line.split("~")] # split and remove empty string in list
71
+ model = args[0]
72
+ logging.debug(args)
73
+ if model not in handlers:
74
+ logging.warning(f"footprint : model not in handler : {model}")
75
+ else:
76
+ handlers.get(model)(args[1:], kicad_mod, footprint_info)
77
+
78
+ if any(
79
+ isinstance(child, Pad) and child.type == Pad.TYPE_THT
80
+ for child in kicad_mod.getAllChilds()
81
+ ):
82
+ kicad_mod.setAttribute("through_hole")
83
+ else:
84
+ kicad_mod.setAttribute("smd")
85
+
86
+ kicad_mod.insert(Translation(-mil2mm(translation[0]), -mil2mm(translation[1])))
87
+
88
+ # Translate the footprint max and min values to the origin
89
+ footprint_info.max_X -= mil2mm(translation[0])
90
+ footprint_info.max_Y -= mil2mm(translation[1])
91
+ footprint_info.min_X -= mil2mm(translation[0])
92
+ footprint_info.min_Y -= mil2mm(translation[1])
93
+
94
+ # set general values
95
+ kicad_mod.append(
96
+ Text(
97
+ type="reference",
98
+ text="REF**",
99
+ at=[
100
+ (footprint_info.min_X + footprint_info.max_X) / 2,
101
+ footprint_info.min_Y - 2,
102
+ ],
103
+ layer="F.SilkS",
104
+ )
105
+ )
106
+ kicad_mod.append(
107
+ Text(
108
+ type="user",
109
+ text="${REFERENCE}",
110
+ at=[
111
+ (footprint_info.min_X + footprint_info.max_X) / 2,
112
+ (footprint_info.min_Y + footprint_info.max_Y) / 2,
113
+ ],
114
+ layer="F.Fab",
115
+ )
116
+ )
117
+ kicad_mod.append(
118
+ Text(
119
+ type="value",
120
+ text=footprint_name,
121
+ at=[
122
+ (footprint_info.min_X + footprint_info.max_X) / 2,
123
+ footprint_info.max_Y + 2,
124
+ ],
125
+ layer="F.Fab",
126
+ )
127
+ )
128
+
129
+ if not os.path.exists(f"{output_dir}/{footprint_lib}"):
130
+ os.makedirs(f"{output_dir}/{footprint_lib}")
131
+
132
+ # output kicad model
133
+ file_handler = KicadFileHandler(kicad_mod)
134
+ file_handler.writeFile(f"{output_dir}/{footprint_lib}/{footprint_name}.kicad_mod")
135
+ logging.info(f"Created '{output_dir}/{footprint_lib}/{footprint_name}.kicad_mod'")
136
+
137
+ # return the datasheet link and footprint name to be linked with the symbol
138
+ return (f"{footprint_lib}:{footprint_name}", datasheet_link)
139
+
140
+
141
+ def get_footprint_info(footprint_component_uuid):
142
+ # fetch the component data from easyeda library
143
+ response = requests.get(
144
+ f"https://easyeda.com/api/components/{footprint_component_uuid}",
145
+ headers={"User-Agent": helper.get_user_agent()},
146
+ )
147
+
148
+ if response.status_code == requests.codes.ok:
149
+ data = json.loads(response.content.decode())
150
+ else:
151
+ logging.error(
152
+ "create_footprint error. Requests returned with error code "
153
+ f"{response.status_code}"
154
+ )
155
+ return ("", None, "", (0, 0))
156
+
157
+ footprint_shape = data["result"]["dataStr"]["shape"]
158
+ x = data["result"]["dataStr"]["head"]["x"]
159
+ y = data["result"]["dataStr"]["head"]["y"]
160
+ try:
161
+ datasheet_link = data["result"]["dataStr"]["head"]["c_para"]["link"]
162
+ except KeyError:
163
+ datasheet_link = ""
164
+ logging.warning("Could not retrieve datasheet link from EASYEDA")
165
+
166
+ footprint_name = (
167
+ data["result"]["title"]
168
+ .replace(" ", "_")
169
+ .replace("/", "_")
170
+ .replace("(", "_")
171
+ .replace(")", "_")
172
+ )
173
+
174
+ if not footprint_name:
175
+ footprint_name = "NoName"
176
+ logging.warning(
177
+ "Could not retrieve components information from EASYEDA, default name "
178
+ "'NoName'."
179
+ )
180
+
181
+ return (footprint_name, datasheet_link, footprint_shape, (x, y))