traceforge-pcb 1.0.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 (219) hide show
  1. traceforge/__init__.py +3 -0
  2. traceforge/__main__.py +3 -0
  3. traceforge/acp_server.py +570 -0
  4. traceforge/agent.py +680 -0
  5. traceforge/altium/__init__.py +6 -0
  6. traceforge/altium/cfb.py +70 -0
  7. traceforge/altium/cli.py +60 -0
  8. traceforge/altium/model.py +163 -0
  9. traceforge/altium/outputs.py +24 -0
  10. traceforge/altium/pcb.py +301 -0
  11. traceforge/altium/project.py +328 -0
  12. traceforge/altium/schematic.py +400 -0
  13. traceforge/altium/tools.py +166 -0
  14. traceforge/approval.py +219 -0
  15. traceforge/attachments.py +346 -0
  16. traceforge/background.py +253 -0
  17. traceforge/bench/__init__.py +5 -0
  18. traceforge/bench/adapters.py +337 -0
  19. traceforge/bench/checks.py +410 -0
  20. traceforge/bench/cli.py +121 -0
  21. traceforge/bench/design.py +439 -0
  22. traceforge/bench/kicad.py +99 -0
  23. traceforge/bench/partscheck.py +159 -0
  24. traceforge/bench/review.py +100 -0
  25. traceforge/bench/runner.py +226 -0
  26. traceforge/bench/score.py +204 -0
  27. traceforge/bench/tasks.py +102 -0
  28. traceforge/chat.py +1185 -0
  29. traceforge/checks.py +328 -0
  30. traceforge/checks_sides.py +118 -0
  31. traceforge/cli.py +2224 -0
  32. traceforge/clipboard.py +107 -0
  33. traceforge/config.py +299 -0
  34. traceforge/context.py +276 -0
  35. traceforge/design/__init__.py +1 -0
  36. traceforge/design/constraints.py +397 -0
  37. traceforge/design/generate.py +1789 -0
  38. traceforge/design/outline.py +905 -0
  39. traceforge/design/schematic_layout.py +932 -0
  40. traceforge/design/schematic_lint.py +329 -0
  41. traceforge/design/schematic_route.py +207 -0
  42. traceforge/design/sheets.py +854 -0
  43. traceforge/design/sides.py +328 -0
  44. traceforge/design/spec.py +462 -0
  45. traceforge/design/specsheet.py +2351 -0
  46. traceforge/design/specsheet_render.py +183 -0
  47. traceforge/desktop/__init__.py +109 -0
  48. traceforge/desktop/keys.py +90 -0
  49. traceforge/desktop/project.py +443 -0
  50. traceforge/desktop/review.py +20 -0
  51. traceforge/desktop/server.py +1277 -0
  52. traceforge/desktop/static/app.css +223 -0
  53. traceforge/desktop/static/app.js +683 -0
  54. traceforge/desktop/static/highlight.js +77 -0
  55. traceforge/desktop/static/index.html +89 -0
  56. traceforge/desktop/static/md.js +90 -0
  57. traceforge/desktop/static/viewer.js +213 -0
  58. traceforge/desktop/symbols.py +212 -0
  59. traceforge/desktop/update.py +65 -0
  60. traceforge/diagnostics.py +67 -0
  61. traceforge/errata.py +219 -0
  62. traceforge/fab.py +108 -0
  63. traceforge/fab_layers.py +44 -0
  64. traceforge/features/__init__.py +6 -0
  65. traceforge/features/candidates.py +117 -0
  66. traceforge/features/cli.py +200 -0
  67. traceforge/features/package.py +433 -0
  68. traceforge/features/revdiff.py +224 -0
  69. traceforge/features/sarif.py +145 -0
  70. traceforge/features/viewer.py +265 -0
  71. traceforge/firmware/__init__.py +3 -0
  72. traceforge/firmware/cli.py +174 -0
  73. traceforge/firmware/drivers.py +372 -0
  74. traceforge/firmware/drivers_data.py +256 -0
  75. traceforge/firmware/mcus.py +177 -0
  76. traceforge/firmware/netlist.py +55 -0
  77. traceforge/firmware/pinmap.py +296 -0
  78. traceforge/firmware/scaffold.py +466 -0
  79. traceforge/firmware/toolchain.py +262 -0
  80. traceforge/hooks.py +237 -0
  81. traceforge/kicad/__init__.py +0 -0
  82. traceforge/kicad/cli.py +519 -0
  83. traceforge/kicad/diffpair.py +174 -0
  84. traceforge/kicad/edgegeom.py +191 -0
  85. traceforge/kicad/edit.py +405 -0
  86. traceforge/kicad/escape.py +1410 -0
  87. traceforge/kicad/formats.py +1073 -0
  88. traceforge/kicad/impedance.py +320 -0
  89. traceforge/kicad/install.py +75 -0
  90. traceforge/kicad/libraries.py +490 -0
  91. traceforge/kicad/models3d.py +942 -0
  92. traceforge/kicad/outputs.py +81 -0
  93. traceforge/kicad/pcb.py +787 -0
  94. traceforge/kicad/planes.py +1242 -0
  95. traceforge/kicad/project.py +136 -0
  96. traceforge/kicad/route.py +2698 -0
  97. traceforge/kicad/routegeom.py +164 -0
  98. traceforge/kicad/schematic.py +268 -0
  99. traceforge/kicad/sides.py +34 -0
  100. traceforge/kicad/stackup.py +424 -0
  101. traceforge/kicad_plugin/__init__.py +12 -0
  102. traceforge/kicad_plugin/actions.py +92 -0
  103. traceforge/kicad_plugin/cli.py +66 -0
  104. traceforge/kicad_plugin/client.py +86 -0
  105. traceforge/kicad_plugin/context.py +257 -0
  106. traceforge/kicad_plugin/discovery.py +81 -0
  107. traceforge/kicad_plugin/install.py +203 -0
  108. traceforge/kicad_plugin/overlay.py +60 -0
  109. traceforge/kicad_plugin/payload/action_plugin.py +65 -0
  110. traceforge/kicad_plugin/payload/ipc_main.py +42 -0
  111. traceforge/kicad_plugin/payload/plugin.json +90 -0
  112. traceforge/kicad_plugin/payload/requirements.txt +1 -0
  113. traceforge/kicad_plugin/views.py +202 -0
  114. traceforge/langchain.py +57 -0
  115. traceforge/local.py +580 -0
  116. traceforge/mcp_server.py +304 -0
  117. traceforge/models.py +310 -0
  118. traceforge/next_steps.py +110 -0
  119. traceforge/parts/__init__.py +52 -0
  120. traceforge/parts/alternates.py +488 -0
  121. traceforge/parts/bom.py +423 -0
  122. traceforge/parts/brief.py +705 -0
  123. traceforge/parts/catalog.py +297 -0
  124. traceforge/parts/cli_sourcing.py +156 -0
  125. traceforge/parts/data/duty_rates.yaml +37 -0
  126. traceforge/parts/data/hs_codes.yaml +103 -0
  127. traceforge/parts/datasheet.py +278 -0
  128. traceforge/parts/fx.py +100 -0
  129. traceforge/parts/model.py +185 -0
  130. traceforge/parts/net.py +187 -0
  131. traceforge/parts/offers.py +46 -0
  132. traceforge/parts/packages.py +397 -0
  133. traceforge/parts/pinout.py +714 -0
  134. traceforge/parts/providers/__init__.py +17 -0
  135. traceforge/parts/providers/base.py +100 -0
  136. traceforge/parts/providers/jlc.py +364 -0
  137. traceforge/parts/providers/keyed.py +661 -0
  138. traceforge/parts/report.py +508 -0
  139. traceforge/parts/supply.py +330 -0
  140. traceforge/parts/verify.py +455 -0
  141. traceforge/parts/web.py +575 -0
  142. traceforge/plans.py +70 -0
  143. traceforge/preferences.py +127 -0
  144. traceforge/prices.py +130 -0
  145. traceforge/prices.yaml +28 -0
  146. traceforge/prompts/__init__.py +54 -0
  147. traceforge/prompts/system.md +103 -0
  148. traceforge/providers.py +764 -0
  149. traceforge/remote/__init__.py +33 -0
  150. traceforge/remote/client.py +422 -0
  151. traceforge/remote/oidc.py +240 -0
  152. traceforge/remote/orgs.py +510 -0
  153. traceforge/remote/server.py +690 -0
  154. traceforge/remote/store.py +100 -0
  155. traceforge/review_changes.py +187 -0
  156. traceforge/routing.py +99 -0
  157. traceforge/runtime.py +435 -0
  158. traceforge/sessions.py +324 -0
  159. traceforge/sexpr.py +279 -0
  160. traceforge/sim/__init__.py +17 -0
  161. traceforge/sim/acmath.py +234 -0
  162. traceforge/sim/circuit.py +499 -0
  163. traceforge/sim/cli.py +162 -0
  164. traceforge/sim/engine.py +172 -0
  165. traceforge/sim/ltspice.py +175 -0
  166. traceforge/sim/measure.py +123 -0
  167. traceforge/sim/models.py +76 -0
  168. traceforge/sim/project.py +218 -0
  169. traceforge/sim/rawfile.py +123 -0
  170. traceforge/sim/results.py +298 -0
  171. traceforge/sim/tune.py +172 -0
  172. traceforge/skill_commands.py +165 -0
  173. traceforge/skill_library/circuit-simulation/SKILL.md +30 -0
  174. traceforge/skill_library/compliance-review/SKILL.md +51 -0
  175. traceforge/skill_library/design-review/SKILL.md +47 -0
  176. traceforge/skill_library/errata-hunt/SKILL.md +30 -0
  177. traceforge/skill_library/firmware/SKILL.md +26 -0
  178. traceforge/skill_library/power-budget/SKILL.md +27 -0
  179. traceforge/skill_library/release-package/SKILL.md +29 -0
  180. traceforge/skill_library/sourcing-brief/SKILL.md +24 -0
  181. traceforge/skill_library/thermal-review/SKILL.md +15 -0
  182. traceforge/skills.py +131 -0
  183. traceforge/thermal.py +557 -0
  184. traceforge/tools/__init__.py +48 -0
  185. traceforge/tools/agent_tools.py +213 -0
  186. traceforge/tools/base.py +303 -0
  187. traceforge/tools/batch.py +155 -0
  188. traceforge/tools/connections.py +245 -0
  189. traceforge/tools/files.py +777 -0
  190. traceforge/tools/firmware_tools.py +218 -0
  191. traceforge/tools/kicad_tools.py +1072 -0
  192. traceforge/tools/models3d_tools.py +141 -0
  193. traceforge/tools/parts_tools.py +473 -0
  194. traceforge/tools/processes.py +246 -0
  195. traceforge/tools/review_tools.py +178 -0
  196. traceforge/tools/sim_tools.py +230 -0
  197. traceforge/tui.py +620 -0
  198. traceforge/updates.py +124 -0
  199. traceforge/validate/__init__.py +77 -0
  200. traceforge/validate/builtins.py +1091 -0
  201. traceforge/validate/circuits.py +1240 -0
  202. traceforge/validate/config.py +410 -0
  203. traceforge/validate/default.yaml +113 -0
  204. traceforge/validate/design.py +191 -0
  205. traceforge/validate/explain.py +112 -0
  206. traceforge/validate/grounding.py +169 -0
  207. traceforge/validate/ipc2221.py +350 -0
  208. traceforge/validate/multilayer.py +805 -0
  209. traceforge/validate/netmodel.py +261 -0
  210. traceforge/validate/partcheck.py +472 -0
  211. traceforge/validate/report.py +280 -0
  212. traceforge/validate/review.py +322 -0
  213. traceforge/validate/runner.py +662 -0
  214. traceforge/workspace.py +171 -0
  215. traceforge_pcb-1.0.0.dist-info/METADATA +463 -0
  216. traceforge_pcb-1.0.0.dist-info/RECORD +219 -0
  217. traceforge_pcb-1.0.0.dist-info/WHEEL +4 -0
  218. traceforge_pcb-1.0.0.dist-info/entry_points.txt +2 -0
  219. traceforge_pcb-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,70 @@
1
+ """Read Altium files: OLE compound documents (via ``olefile``) and their record streams."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import struct
6
+ from pathlib import Path
7
+
8
+
9
+ class AltiumError(Exception):
10
+ """The file is not a readable Altium document."""
11
+
12
+
13
+ def open_cfb(path: str | Path):
14
+ try:
15
+ import olefile
16
+ except ImportError as exc: # pragma: no cover - depends on the environment
17
+ raise AltiumError(
18
+ "Reading Altium files needs the 'olefile' package: pip install 'traceforge-pcb[altium]'"
19
+ ) from exc
20
+ path = Path(path)
21
+ try:
22
+ return olefile.OleFileIO(str(path))
23
+ except Exception as exc:
24
+ raise AltiumError(f"{path.name} is not an Altium (OLE compound) document: {exc}") from exc
25
+
26
+
27
+ def read_stream(ole, name: str) -> bytes:
28
+ parts = name.split("/")
29
+ if not ole.exists(parts):
30
+ raise AltiumError(f"stream {name!r} not found")
31
+ return ole.openstream(parts).read()
32
+
33
+
34
+ def decode(raw: bytes) -> str:
35
+ try:
36
+ return raw.decode("utf-8")
37
+ except UnicodeDecodeError:
38
+ return raw.decode("cp1252", errors="replace")
39
+
40
+
41
+ def parse_properties(raw: bytes) -> dict[str, str]:
42
+ """``|KEY=VALUE|KEY=VALUE`` text. Keys are upper-cased; ``%UTF8%KEY`` wins over ``KEY``."""
43
+ text = raw.split(b"\0", 1)[0]
44
+ props: dict[str, str] = {}
45
+ utf8: dict[str, str] = {}
46
+ for part in text.split(b"|"):
47
+ if b"=" not in part:
48
+ continue
49
+ key, _, val = part.partition(b"=")
50
+ name = key.decode("ascii", "replace").strip().upper()
51
+ if name.startswith("%UTF8%"):
52
+ utf8[name[6:]] = val.decode("utf-8", "replace")
53
+ else:
54
+ props.setdefault(name, decode(val))
55
+ props.update(utf8)
56
+ return props
57
+
58
+
59
+ def length_prefixed(data: bytes, offset: int = 0):
60
+ """Yield (flag, payload) for ``int32 length`` records; the top byte of the length is a flag."""
61
+ n = len(data)
62
+ while offset + 4 <= n:
63
+ (raw,) = struct.unpack_from("<I", data, offset)
64
+ size = raw & 0xFFFFFF
65
+ flag = raw >> 24
66
+ offset += 4
67
+ if offset + size > n:
68
+ raise AltiumError("truncated record stream")
69
+ yield flag, data[offset : offset + size]
70
+ offset += size
@@ -0,0 +1,60 @@
1
+ """``traceforge altium info|netlist|bom|convert``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+
11
+ def add_parser(sub) -> None:
12
+ p = sub.add_parser("altium", help="read Altium Designer / CircuitMaker projects")
13
+ p.add_argument("action", choices=["info", "netlist", "bom", "convert", "pin"])
14
+ p.add_argument("project", nargs="?", help=".PrjPcb, .SchDoc or folder (default: workspace)")
15
+ p.add_argument("--pin", help="with action 'pin': U1.14")
16
+ p.add_argument("--out", help="output file (netlist, bom) or folder (convert)")
17
+ p.add_argument("--json", action="store_true")
18
+
19
+
20
+ def cmd_altium(args: argparse.Namespace) -> int:
21
+ from traceforge.altium import AltiumError, load_project
22
+ from traceforge.altium.outputs import convert
23
+ from traceforge.altium.project import bom_csv, clean_pin_name, kicad_netlist
24
+
25
+ target = Path(args.project) if args.project else Path(args.workspace)
26
+ try:
27
+ project = load_project(target)
28
+ except AltiumError as exc:
29
+ print(f"error: {exc}", file=sys.stderr)
30
+ return 1
31
+ if args.action == "info":
32
+ data = project.summary()
33
+ print(json.dumps(data, indent=1))
34
+ return 0
35
+ if args.action == "pin":
36
+ ref, _, number = (args.pin or args.project or "").partition(".")
37
+ found = project.find_pin(ref, number)
38
+ if not found:
39
+ print(f"error: no pin {args.pin!r}", file=sys.stderr)
40
+ return 1
41
+ comp, pin = found
42
+ net = project.net_of(ref, pin.number)
43
+ print(
44
+ f"{ref}.{pin.number} ({clean_pin_name(pin.name)}, {pin.electrical}) of {comp.comment}: {net}"
45
+ )
46
+ if net:
47
+ others = [f"{r}.{n}" for r, n in project.netlist.nets[net] if (r, n) != (ref, number)]
48
+ print("also on this net: " + ", ".join(others))
49
+ return 0
50
+ if args.action == "convert":
51
+ out = args.out or "outputs/altium"
52
+ print(json.dumps(convert(project, out), indent=1))
53
+ return 0
54
+ text = kicad_netlist(project) if args.action == "netlist" else bom_csv(project)
55
+ if args.out:
56
+ Path(args.out).write_text(text, encoding="utf-8")
57
+ print(f"wrote {args.out}")
58
+ else:
59
+ sys.stdout.write(text)
60
+ return 0
@@ -0,0 +1,163 @@
1
+ """Convert an Altium project to the models Traceforge uses for KiCad designs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from traceforge.altium.project import _KICAD_PIN_TYPE, AltiumProject, _alias, clean_pin_name
8
+ from traceforge.kicad.pcb import Board, Footprint, Pad, Track, Via
9
+ from traceforge.kicad.schematic import Schematic, SchLabel, SchSymbol
10
+ from traceforge.validate.netmodel import DesignModel, Part, PinDef, SymbolInfo
11
+
12
+
13
+ def part_fields(params: dict[str, str]) -> dict[str, str]:
14
+ """Parameters as KiCad-style fields, with ``MPN`` and ``LCSC`` aliases the parts tools read."""
15
+ fields = {k: v for k, v in params.items() if not k.startswith("_") and v}
16
+ for name, pattern in (
17
+ ("MPN", r"^(mpn|manufacturer.?part|mfr.?part|part.?number)"),
18
+ ("LCSC", r"lcsc"),
19
+ ):
20
+ if name not in fields:
21
+ found = _alias(fields, pattern)
22
+ if found:
23
+ fields[name] = found
24
+ return fields
25
+
26
+
27
+ def to_design_model(project: AltiumProject) -> DesignModel:
28
+ """Parts with pin definitions plus nets: what ``kicad_connections`` and the checks use."""
29
+ parts: dict[str, Part] = {}
30
+ for ref, comp in project.components().items():
31
+ symbol = SymbolInfo(
32
+ name=comp.lib_ref,
33
+ pins={
34
+ p.number: PinDef(
35
+ p.number, clean_pin_name(p.name), _KICAD_PIN_TYPE.get(p.electrical, "passive")
36
+ )
37
+ for p in comp.pins
38
+ },
39
+ description=comp.description,
40
+ )
41
+ parts[ref] = Part(
42
+ ref=ref,
43
+ value=comp.comment,
44
+ footprint=comp.footprint,
45
+ lib_id=comp.lib_ref,
46
+ fields=part_fields(comp.params),
47
+ symbol=symbol,
48
+ sheet=comp.sheet,
49
+ )
50
+ return DesignModel(parts, dict(project.netlist.nets), to_board(project), "altium")
51
+
52
+
53
+ def to_schematic(project: AltiumProject) -> Schematic:
54
+ """A :class:`Schematic` (symbols, labels, counts) built from the Altium sheets."""
55
+ symbols: list[SchSymbol] = []
56
+ labels: list[SchLabel] = []
57
+ for sheet in project.sheets:
58
+ for comp in sheet.components:
59
+ if comp.is_power or not comp.designator:
60
+ continue
61
+ symbols.append(
62
+ SchSymbol(
63
+ lib_id=comp.lib_ref,
64
+ reference=comp.designator,
65
+ value=comp.comment,
66
+ footprint=comp.footprint,
67
+ unit=int(comp.params.get("_part", "1") or 1),
68
+ x=comp.x,
69
+ y=comp.y,
70
+ angle=0.0,
71
+ uuid=f"{sheet.name}:{comp.designator}",
72
+ fields=part_fields(comp.params),
73
+ sheet=sheet.name,
74
+ )
75
+ )
76
+ for text, (x, y) in sheet.net_labels:
77
+ labels.append(
78
+ SchLabel("label", text, x / 100000 * 0.254, y / 100000 * 0.254, sheet.name)
79
+ )
80
+ for text, (x, y) in sheet.power_ports:
81
+ labels.append(
82
+ SchLabel("global_label", text, x / 100000 * 0.254, y / 100000 * 0.254, sheet.name)
83
+ )
84
+ first = project.sheets[0].path if project.sheets else project.root
85
+ return Schematic(
86
+ path=Path(first),
87
+ version="altium",
88
+ symbols=symbols,
89
+ labels=labels,
90
+ wires=sum(len(s.wires) for s in project.sheets),
91
+ no_connects=sum(s.no_erc for s in project.sheets),
92
+ junctions=sum(len(s.junctions) for s in project.sheets),
93
+ sheets=[s.name for s in project.sheets],
94
+ lib_ids=sorted({s.lib_id for s in symbols}),
95
+ title=project.name,
96
+ generator="altium",
97
+ )
98
+
99
+
100
+ def to_board(project: AltiumProject) -> Board | None:
101
+ """A :class:`Board` from the ``.PcbDoc`` (Altium's Y axis points up, KiCad's down)."""
102
+ pcb = project.pcb
103
+ if pcb is None:
104
+ return None
105
+ footprints: list[Footprint] = []
106
+ for comp in pcb.components:
107
+ pads = [
108
+ Pad(
109
+ number=p.designator,
110
+ kind="thru_hole" if p.hole > 0 else "smd",
111
+ shape="rect",
112
+ x=p.x,
113
+ y=-p.y,
114
+ width=p.size_x,
115
+ height=p.size_y,
116
+ layers=["*.Cu"] if p.hole > 0 else ["F.Cu" if comp.layer == "Top" else "B.Cu"],
117
+ net=p.net,
118
+ drill=p.hole,
119
+ )
120
+ for p in comp.pads
121
+ ]
122
+ footprints.append(
123
+ Footprint(
124
+ lib_id=comp.footprint,
125
+ reference=comp.designator,
126
+ value=comp.comment or comp.lib_ref,
127
+ x=comp.x,
128
+ y=-comp.y,
129
+ angle=comp.rotation,
130
+ layer="F.Cu" if comp.layer == "Top" else "B.Cu",
131
+ pads=pads,
132
+ )
133
+ )
134
+
135
+ def cu(name: str) -> str:
136
+ if name == "Top Layer" or name == pcb.layers[0]:
137
+ return "F.Cu"
138
+ if name == "Bottom Layer" or name == pcb.layers[-1]:
139
+ return "B.Cu"
140
+ return name
141
+
142
+ box = pcb.outline_bbox()
143
+ return Board(
144
+ path=pcb.path,
145
+ version="altium",
146
+ thickness=1.6,
147
+ copper_layers=[cu(n) for n in pcb.layers],
148
+ nets=dict(enumerate(pcb.nets)),
149
+ footprints=footprints,
150
+ tracks=[
151
+ Track(t.x1, -t.y1, t.x2, -t.y2, t.width, cu(t.layer), t.net)
152
+ for t in pcb.tracks
153
+ if t.layer in pcb.layers
154
+ ],
155
+ vias=[
156
+ Via(v.x, -v.y, v.diameter, v.hole, [cu(v.start), cu(v.end)], v.net) for v in pcb.vias
157
+ ],
158
+ zones=[],
159
+ outline=(box[0], -box[3], box[2], -box[1]) if box else None,
160
+ texts=[],
161
+ rules={},
162
+ generator="altium",
163
+ )
@@ -0,0 +1,24 @@
1
+ """Write netlist, BOM and the KiCad hand-off files of an Altium project."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from traceforge.altium.project import AltiumProject, bom_csv, kicad_netlist
8
+
9
+ KICAD_IMPORT_NOTE = (
10
+ "KiCad's own importer (File > Import > Non-KiCad Project, KiCad 8+; Altium support is in "
11
+ "the GUI only, kicad-cli has no import) gives a full .kicad_sch/.kicad_pcb with graphics. "
12
+ "This conversion writes the connectivity: a KiCad netlist (.net) and a BOM. In the PCB "
13
+ "editor use File > Import > Netlist after the footprints exist in a KiCad library."
14
+ )
15
+
16
+
17
+ def convert(project: AltiumProject, out_dir: str | Path) -> dict:
18
+ out = Path(out_dir)
19
+ out.mkdir(parents=True, exist_ok=True)
20
+ net = out / f"{project.name}.net"
21
+ bom = out / f"{project.name}_bom.csv"
22
+ net.write_text(kicad_netlist(project), encoding="utf-8")
23
+ bom.write_text(bom_csv(project), encoding="utf-8")
24
+ return {"netlist": str(net), "bom": str(bom), "note": KICAD_IMPORT_NOTE}
@@ -0,0 +1,301 @@
1
+ """Altium ``.PcbDoc`` parser: components, pads, nets, tracks, vias, outline and layer stack."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import struct
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+
10
+ from traceforge.altium.cfb import AltiumError, length_prefixed, open_cfb, parse_properties
11
+
12
+ MM_PER_MIL = 0.0254
13
+ UNIT = 10000 # binary coordinates: 1/10000 mil
14
+ _NO_NET = 0xFFFF
15
+
16
+
17
+ def layer_name(layer_id: int, names: dict[int, str] | None = None) -> str:
18
+ if names and layer_id in names:
19
+ return names[layer_id]
20
+ if layer_id == 1:
21
+ return "Top Layer"
22
+ if layer_id == 32:
23
+ return "Bottom Layer"
24
+ if 2 <= layer_id <= 31:
25
+ return f"Mid-Layer {layer_id - 1}"
26
+ fixed = {
27
+ 33: "Top Overlay",
28
+ 34: "Bottom Overlay",
29
+ 35: "Top Paste",
30
+ 36: "Bottom Paste",
31
+ 37: "Top Solder",
32
+ 38: "Bottom Solder",
33
+ 55: "Drill Guide",
34
+ 56: "Keep-Out Layer",
35
+ 73: "Drill Drawing",
36
+ 74: "Multi-Layer",
37
+ }
38
+ if layer_id in fixed:
39
+ return fixed[layer_id]
40
+ if 39 <= layer_id <= 54:
41
+ return f"Internal Plane {layer_id - 38}"
42
+ if 57 <= layer_id <= 72:
43
+ return f"Mechanical {layer_id - 56}"
44
+ return f"Layer {layer_id}"
45
+
46
+
47
+ def _mm(text: str) -> float:
48
+ """``4550mil`` / ``12.5mm`` / bare number (mil) to mm."""
49
+ m = re.match(r"\s*(-?[\d.]+(?:[eE][-+]?\d+)?)\s*(mil|mm|in)?", text or "")
50
+ if not m:
51
+ return 0.0
52
+ v = float(m.group(1))
53
+ unit = m.group(2) or "mil"
54
+ return v * {"mil": MM_PER_MIL, "mm": 1.0, "in": 25.4}[unit]
55
+
56
+
57
+ @dataclass
58
+ class PcbPad:
59
+ designator: str
60
+ x: float
61
+ y: float
62
+ size_x: float
63
+ size_y: float
64
+ hole: float
65
+ layer: str
66
+ net: str = ""
67
+ component: int = -1 # index into AltPcb.components, -1 for free pads
68
+ plated: bool = True
69
+
70
+
71
+ @dataclass
72
+ class PcbComponent:
73
+ designator: str
74
+ footprint: str
75
+ layer: str
76
+ x: float
77
+ y: float
78
+ rotation: float
79
+ comment: str = ""
80
+ description: str = ""
81
+ lib_ref: str = ""
82
+ pads: list[PcbPad] = field(default_factory=list)
83
+
84
+
85
+ @dataclass
86
+ class PcbTrack:
87
+ x1: float
88
+ y1: float
89
+ x2: float
90
+ y2: float
91
+ width: float
92
+ layer: str
93
+ net: str
94
+
95
+
96
+ @dataclass
97
+ class PcbVia:
98
+ x: float
99
+ y: float
100
+ diameter: float
101
+ hole: float
102
+ start: str
103
+ end: str
104
+ net: str
105
+
106
+
107
+ @dataclass
108
+ class AltPcb:
109
+ path: Path
110
+ components: list[PcbComponent]
111
+ nets: list[str]
112
+ free_pads: list[PcbPad]
113
+ tracks: list[PcbTrack]
114
+ vias: list[PcbVia]
115
+ layers: list[str] # copper and enabled layers in stack order
116
+ outline: list[tuple[float, float]]
117
+ counts: dict[str, int] = field(default_factory=dict)
118
+
119
+ def tracks_per_layer(self) -> dict[str, int]:
120
+ out: dict[str, int] = {}
121
+ for t in self.tracks:
122
+ out[t.layer] = out.get(t.layer, 0) + 1
123
+ return dict(sorted(out.items()))
124
+
125
+ def outline_bbox(self) -> tuple[float, float, float, float] | None:
126
+ if not self.outline:
127
+ return None
128
+ xs = [p[0] for p in self.outline]
129
+ ys = [p[1] for p in self.outline]
130
+ return (min(xs), min(ys), max(xs), max(ys))
131
+
132
+ def pad_nets(self) -> dict[str, list[tuple[str, str]]]:
133
+ nets: dict[str, list[tuple[str, str]]] = {}
134
+ for comp in self.components:
135
+ for pad in comp.pads:
136
+ if pad.net:
137
+ nets.setdefault(pad.net, []).append((comp.designator, pad.designator))
138
+ return nets
139
+
140
+
141
+ def _records(ole, storage: str):
142
+ name = [storage, "Data"]
143
+ if not ole.exists(name):
144
+ return b""
145
+ return ole.openstream(name).read()
146
+
147
+
148
+ def _text_records(ole, storage: str) -> list[dict[str, str]]:
149
+ return [parse_properties(p) for _, p in length_prefixed(_records(ole, storage))]
150
+
151
+
152
+ def _sub(data: bytes, pos: int):
153
+ (size,) = struct.unpack_from("<I", data, pos)
154
+ pos += 4
155
+ return data[pos : pos + size], pos + size
156
+
157
+
158
+ def _pstr(block: bytes) -> str:
159
+ if not block:
160
+ return ""
161
+ return block[1 : 1 + block[0]].decode("cp1252", "replace")
162
+
163
+
164
+ def _i32(b: bytes, off: int) -> int:
165
+ return struct.unpack_from("<i", b, off)[0]
166
+
167
+
168
+ def parse_pcb(path: str | Path) -> AltPcb:
169
+ path = Path(path)
170
+ ole = open_cfb(path)
171
+ try:
172
+ if not ole.exists(["Board6", "Data"]):
173
+ raise AltiumError(f"{path.name}: no Board6 storage (not a PCB document)")
174
+ board = _text_records(ole, "Board6")
175
+ board = board[0] if board else {}
176
+ nets = [r.get("NAME", "") for r in _text_records(ole, "Nets6")]
177
+ comp_rows = _text_records(ole, "Components6")
178
+ pads_raw = _records(ole, "Pads6")
179
+ tracks_raw = _records(ole, "Tracks6")
180
+ vias_raw = _records(ole, "Vias6")
181
+ counts = {}
182
+ for name in ("Arcs6", "Fills6", "Regions6", "Polygons6", "Texts6"):
183
+ data = _records(ole, name)
184
+ counts[name.rstrip("6").lower()] = (
185
+ sum(1 for _ in length_prefixed(data)) if name == "Polygons6" else 0
186
+ )
187
+ finally:
188
+ ole.close()
189
+
190
+ names: dict[int, str] = {}
191
+ for key, val in board.items():
192
+ m = re.fullmatch(r"LAYER(\d+)NAME", key)
193
+ if m:
194
+ names[int(m.group(1))] = val
195
+ layers: list[str] = []
196
+ cur, seen = 1, set()
197
+ while cur and cur not in seen and cur in names: # copper stack: LAYERnNEXT chain from top
198
+ seen.add(cur)
199
+ layers.append(names[cur])
200
+ cur = int(float(board.get(f"LAYER{cur}NEXT", "0") or 0))
201
+
202
+ def net(idx: int) -> str:
203
+ return nets[idx] if idx != _NO_NET and idx < len(nets) else ""
204
+
205
+ components = [
206
+ PcbComponent(
207
+ designator=r.get("SOURCEDESIGNATOR", ""),
208
+ footprint=r.get("PATTERN", ""),
209
+ layer="Bottom" if r.get("LAYER", "TOP").upper().startswith("BOTTOM") else "Top",
210
+ x=_mm(r.get("X", "")),
211
+ y=_mm(r.get("Y", "")),
212
+ rotation=float(r.get("ROTATION", "0").strip() or 0),
213
+ description=r.get("SOURCEDESCRIPTION", ""),
214
+ lib_ref=r.get("SOURCELIBREFERENCE", ""),
215
+ comment=r.get("COMMENT", ""),
216
+ )
217
+ for r in comp_rows
218
+ ]
219
+ free: list[PcbPad] = []
220
+ pos = 0
221
+ while pos < len(pads_raw):
222
+ if pads_raw[pos] != 2:
223
+ raise AltiumError("unexpected record in Pads6")
224
+ pos += 1
225
+ name_b, pos = _sub(pads_raw, pos)
226
+ for _ in range(3):
227
+ _, pos = _sub(pads_raw, pos)
228
+ main, pos = _sub(pads_raw, pos)
229
+ _, pos = _sub(pads_raw, pos) # size and shape per layer
230
+ if len(main) < 61:
231
+ continue
232
+ comp_idx = struct.unpack_from("<H", main, 7)[0]
233
+ pad = PcbPad(
234
+ designator=_pstr(name_b),
235
+ x=_i32(main, 13) / UNIT * MM_PER_MIL,
236
+ y=_i32(main, 17) / UNIT * MM_PER_MIL,
237
+ size_x=_i32(main, 21) / UNIT * MM_PER_MIL,
238
+ size_y=_i32(main, 25) / UNIT * MM_PER_MIL,
239
+ hole=_i32(main, 45) / UNIT * MM_PER_MIL,
240
+ layer=layer_name(main[0], names),
241
+ net=net(struct.unpack_from("<H", main, 3)[0]),
242
+ component=comp_idx if comp_idx != _NO_NET and comp_idx < len(components) else -1,
243
+ plated=bool(main[60]),
244
+ )
245
+ if pad.component >= 0:
246
+ components[pad.component].pads.append(pad)
247
+ else:
248
+ free.append(pad)
249
+
250
+ tracks: list[PcbTrack] = []
251
+ pos = 0
252
+ while pos < len(tracks_raw):
253
+ pos += 1 # record type 4
254
+ block, pos = _sub(tracks_raw, pos)
255
+ if len(block) < 33:
256
+ continue
257
+ tracks.append(
258
+ PcbTrack(
259
+ x1=_i32(block, 13) / UNIT * MM_PER_MIL,
260
+ y1=_i32(block, 17) / UNIT * MM_PER_MIL,
261
+ x2=_i32(block, 21) / UNIT * MM_PER_MIL,
262
+ y2=_i32(block, 25) / UNIT * MM_PER_MIL,
263
+ width=_i32(block, 29) / UNIT * MM_PER_MIL,
264
+ layer=layer_name(block[0], names),
265
+ net=net(struct.unpack_from("<H", block, 3)[0]),
266
+ )
267
+ )
268
+ vias: list[PcbVia] = []
269
+ pos = 0
270
+ while pos < len(vias_raw):
271
+ pos += 1 # record type 3
272
+ block, pos = _sub(vias_raw, pos)
273
+ if len(block) < 31:
274
+ continue
275
+ vias.append(
276
+ PcbVia(
277
+ x=_i32(block, 13) / UNIT * MM_PER_MIL,
278
+ y=_i32(block, 17) / UNIT * MM_PER_MIL,
279
+ diameter=_i32(block, 21) / UNIT * MM_PER_MIL,
280
+ hole=_i32(block, 25) / UNIT * MM_PER_MIL,
281
+ start=layer_name(block[29], names),
282
+ end=layer_name(block[30], names),
283
+ net=net(struct.unpack_from("<H", block, 3)[0]),
284
+ )
285
+ )
286
+ outline: list[tuple[float, float]] = []
287
+ i = 0
288
+ while f"VX{i}" in board and f"VY{i}" in board:
289
+ outline.append((_mm(board[f"VX{i}"]), _mm(board[f"VY{i}"])))
290
+ i += 1
291
+ return AltPcb(
292
+ path=path,
293
+ components=components,
294
+ nets=nets,
295
+ free_pads=free,
296
+ tracks=tracks,
297
+ vias=vias,
298
+ layers=layers,
299
+ outline=outline,
300
+ counts=counts,
301
+ )