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.
- traceforge/__init__.py +3 -0
- traceforge/__main__.py +3 -0
- traceforge/acp_server.py +570 -0
- traceforge/agent.py +680 -0
- traceforge/altium/__init__.py +6 -0
- traceforge/altium/cfb.py +70 -0
- traceforge/altium/cli.py +60 -0
- traceforge/altium/model.py +163 -0
- traceforge/altium/outputs.py +24 -0
- traceforge/altium/pcb.py +301 -0
- traceforge/altium/project.py +328 -0
- traceforge/altium/schematic.py +400 -0
- traceforge/altium/tools.py +166 -0
- traceforge/approval.py +219 -0
- traceforge/attachments.py +346 -0
- traceforge/background.py +253 -0
- traceforge/bench/__init__.py +5 -0
- traceforge/bench/adapters.py +337 -0
- traceforge/bench/checks.py +410 -0
- traceforge/bench/cli.py +121 -0
- traceforge/bench/design.py +439 -0
- traceforge/bench/kicad.py +99 -0
- traceforge/bench/partscheck.py +159 -0
- traceforge/bench/review.py +100 -0
- traceforge/bench/runner.py +226 -0
- traceforge/bench/score.py +204 -0
- traceforge/bench/tasks.py +102 -0
- traceforge/chat.py +1185 -0
- traceforge/checks.py +328 -0
- traceforge/checks_sides.py +118 -0
- traceforge/cli.py +2224 -0
- traceforge/clipboard.py +107 -0
- traceforge/config.py +299 -0
- traceforge/context.py +276 -0
- traceforge/design/__init__.py +1 -0
- traceforge/design/constraints.py +397 -0
- traceforge/design/generate.py +1789 -0
- traceforge/design/outline.py +905 -0
- traceforge/design/schematic_layout.py +932 -0
- traceforge/design/schematic_lint.py +329 -0
- traceforge/design/schematic_route.py +207 -0
- traceforge/design/sheets.py +854 -0
- traceforge/design/sides.py +328 -0
- traceforge/design/spec.py +462 -0
- traceforge/design/specsheet.py +2351 -0
- traceforge/design/specsheet_render.py +183 -0
- traceforge/desktop/__init__.py +109 -0
- traceforge/desktop/keys.py +90 -0
- traceforge/desktop/project.py +443 -0
- traceforge/desktop/review.py +20 -0
- traceforge/desktop/server.py +1277 -0
- traceforge/desktop/static/app.css +223 -0
- traceforge/desktop/static/app.js +683 -0
- traceforge/desktop/static/highlight.js +77 -0
- traceforge/desktop/static/index.html +89 -0
- traceforge/desktop/static/md.js +90 -0
- traceforge/desktop/static/viewer.js +213 -0
- traceforge/desktop/symbols.py +212 -0
- traceforge/desktop/update.py +65 -0
- traceforge/diagnostics.py +67 -0
- traceforge/errata.py +219 -0
- traceforge/fab.py +108 -0
- traceforge/fab_layers.py +44 -0
- traceforge/features/__init__.py +6 -0
- traceforge/features/candidates.py +117 -0
- traceforge/features/cli.py +200 -0
- traceforge/features/package.py +433 -0
- traceforge/features/revdiff.py +224 -0
- traceforge/features/sarif.py +145 -0
- traceforge/features/viewer.py +265 -0
- traceforge/firmware/__init__.py +3 -0
- traceforge/firmware/cli.py +174 -0
- traceforge/firmware/drivers.py +372 -0
- traceforge/firmware/drivers_data.py +256 -0
- traceforge/firmware/mcus.py +177 -0
- traceforge/firmware/netlist.py +55 -0
- traceforge/firmware/pinmap.py +296 -0
- traceforge/firmware/scaffold.py +466 -0
- traceforge/firmware/toolchain.py +262 -0
- traceforge/hooks.py +237 -0
- traceforge/kicad/__init__.py +0 -0
- traceforge/kicad/cli.py +519 -0
- traceforge/kicad/diffpair.py +174 -0
- traceforge/kicad/edgegeom.py +191 -0
- traceforge/kicad/edit.py +405 -0
- traceforge/kicad/escape.py +1410 -0
- traceforge/kicad/formats.py +1073 -0
- traceforge/kicad/impedance.py +320 -0
- traceforge/kicad/install.py +75 -0
- traceforge/kicad/libraries.py +490 -0
- traceforge/kicad/models3d.py +942 -0
- traceforge/kicad/outputs.py +81 -0
- traceforge/kicad/pcb.py +787 -0
- traceforge/kicad/planes.py +1242 -0
- traceforge/kicad/project.py +136 -0
- traceforge/kicad/route.py +2698 -0
- traceforge/kicad/routegeom.py +164 -0
- traceforge/kicad/schematic.py +268 -0
- traceforge/kicad/sides.py +34 -0
- traceforge/kicad/stackup.py +424 -0
- traceforge/kicad_plugin/__init__.py +12 -0
- traceforge/kicad_plugin/actions.py +92 -0
- traceforge/kicad_plugin/cli.py +66 -0
- traceforge/kicad_plugin/client.py +86 -0
- traceforge/kicad_plugin/context.py +257 -0
- traceforge/kicad_plugin/discovery.py +81 -0
- traceforge/kicad_plugin/install.py +203 -0
- traceforge/kicad_plugin/overlay.py +60 -0
- traceforge/kicad_plugin/payload/action_plugin.py +65 -0
- traceforge/kicad_plugin/payload/ipc_main.py +42 -0
- traceforge/kicad_plugin/payload/plugin.json +90 -0
- traceforge/kicad_plugin/payload/requirements.txt +1 -0
- traceforge/kicad_plugin/views.py +202 -0
- traceforge/langchain.py +57 -0
- traceforge/local.py +580 -0
- traceforge/mcp_server.py +304 -0
- traceforge/models.py +310 -0
- traceforge/next_steps.py +110 -0
- traceforge/parts/__init__.py +52 -0
- traceforge/parts/alternates.py +488 -0
- traceforge/parts/bom.py +423 -0
- traceforge/parts/brief.py +705 -0
- traceforge/parts/catalog.py +297 -0
- traceforge/parts/cli_sourcing.py +156 -0
- traceforge/parts/data/duty_rates.yaml +37 -0
- traceforge/parts/data/hs_codes.yaml +103 -0
- traceforge/parts/datasheet.py +278 -0
- traceforge/parts/fx.py +100 -0
- traceforge/parts/model.py +185 -0
- traceforge/parts/net.py +187 -0
- traceforge/parts/offers.py +46 -0
- traceforge/parts/packages.py +397 -0
- traceforge/parts/pinout.py +714 -0
- traceforge/parts/providers/__init__.py +17 -0
- traceforge/parts/providers/base.py +100 -0
- traceforge/parts/providers/jlc.py +364 -0
- traceforge/parts/providers/keyed.py +661 -0
- traceforge/parts/report.py +508 -0
- traceforge/parts/supply.py +330 -0
- traceforge/parts/verify.py +455 -0
- traceforge/parts/web.py +575 -0
- traceforge/plans.py +70 -0
- traceforge/preferences.py +127 -0
- traceforge/prices.py +130 -0
- traceforge/prices.yaml +28 -0
- traceforge/prompts/__init__.py +54 -0
- traceforge/prompts/system.md +103 -0
- traceforge/providers.py +764 -0
- traceforge/remote/__init__.py +33 -0
- traceforge/remote/client.py +422 -0
- traceforge/remote/oidc.py +240 -0
- traceforge/remote/orgs.py +510 -0
- traceforge/remote/server.py +690 -0
- traceforge/remote/store.py +100 -0
- traceforge/review_changes.py +187 -0
- traceforge/routing.py +99 -0
- traceforge/runtime.py +435 -0
- traceforge/sessions.py +324 -0
- traceforge/sexpr.py +279 -0
- traceforge/sim/__init__.py +17 -0
- traceforge/sim/acmath.py +234 -0
- traceforge/sim/circuit.py +499 -0
- traceforge/sim/cli.py +162 -0
- traceforge/sim/engine.py +172 -0
- traceforge/sim/ltspice.py +175 -0
- traceforge/sim/measure.py +123 -0
- traceforge/sim/models.py +76 -0
- traceforge/sim/project.py +218 -0
- traceforge/sim/rawfile.py +123 -0
- traceforge/sim/results.py +298 -0
- traceforge/sim/tune.py +172 -0
- traceforge/skill_commands.py +165 -0
- traceforge/skill_library/circuit-simulation/SKILL.md +30 -0
- traceforge/skill_library/compliance-review/SKILL.md +51 -0
- traceforge/skill_library/design-review/SKILL.md +47 -0
- traceforge/skill_library/errata-hunt/SKILL.md +30 -0
- traceforge/skill_library/firmware/SKILL.md +26 -0
- traceforge/skill_library/power-budget/SKILL.md +27 -0
- traceforge/skill_library/release-package/SKILL.md +29 -0
- traceforge/skill_library/sourcing-brief/SKILL.md +24 -0
- traceforge/skill_library/thermal-review/SKILL.md +15 -0
- traceforge/skills.py +131 -0
- traceforge/thermal.py +557 -0
- traceforge/tools/__init__.py +48 -0
- traceforge/tools/agent_tools.py +213 -0
- traceforge/tools/base.py +303 -0
- traceforge/tools/batch.py +155 -0
- traceforge/tools/connections.py +245 -0
- traceforge/tools/files.py +777 -0
- traceforge/tools/firmware_tools.py +218 -0
- traceforge/tools/kicad_tools.py +1072 -0
- traceforge/tools/models3d_tools.py +141 -0
- traceforge/tools/parts_tools.py +473 -0
- traceforge/tools/processes.py +246 -0
- traceforge/tools/review_tools.py +178 -0
- traceforge/tools/sim_tools.py +230 -0
- traceforge/tui.py +620 -0
- traceforge/updates.py +124 -0
- traceforge/validate/__init__.py +77 -0
- traceforge/validate/builtins.py +1091 -0
- traceforge/validate/circuits.py +1240 -0
- traceforge/validate/config.py +410 -0
- traceforge/validate/default.yaml +113 -0
- traceforge/validate/design.py +191 -0
- traceforge/validate/explain.py +112 -0
- traceforge/validate/grounding.py +169 -0
- traceforge/validate/ipc2221.py +350 -0
- traceforge/validate/multilayer.py +805 -0
- traceforge/validate/netmodel.py +261 -0
- traceforge/validate/partcheck.py +472 -0
- traceforge/validate/report.py +280 -0
- traceforge/validate/review.py +322 -0
- traceforge/validate/runner.py +662 -0
- traceforge/workspace.py +171 -0
- traceforge_pcb-1.0.0.dist-info/METADATA +463 -0
- traceforge_pcb-1.0.0.dist-info/RECORD +219 -0
- traceforge_pcb-1.0.0.dist-info/WHEEL +4 -0
- traceforge_pcb-1.0.0.dist-info/entry_points.txt +2 -0
- traceforge_pcb-1.0.0.dist-info/licenses/LICENSE +21 -0
traceforge/altium/cfb.py
ADDED
|
@@ -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
|
traceforge/altium/cli.py
ADDED
|
@@ -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}
|
traceforge/altium/pcb.py
ADDED
|
@@ -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
|
+
)
|