enginehall 2.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.
- enginehall-2.0.0.dist-info/METADATA +511 -0
- enginehall-2.0.0.dist-info/RECORD +75 -0
- enginehall-2.0.0.dist-info/WHEEL +4 -0
- enginehall-2.0.0.dist-info/entry_points.txt +4 -0
- enginehall-2.0.0.dist-info/licenses/LICENSE +51 -0
- pcba/THIRD_PARTY_NOTICES.md +72 -0
- pcba/__init__.py +6 -0
- pcba/adapters/__init__.py +10 -0
- pcba/adapters/altium_bridge/MANIFEST.json +5 -0
- pcba/adapters/altium_bridge/__init__.py +1 -0
- pcba/adapters/altium_bridge/bridge.pas +3342 -0
- pcba/adapters/altium_drive.py +1160 -0
- pcba/adapters/altium_house.py +591 -0
- pcba/adapters/altium_ops.py +858 -0
- pcba/adapters/altium_outjob.py +247 -0
- pcba/adapters/altium_uia.py +304 -0
- pcba/adapters/altium_vcs.py +197 -0
- pcba/adapters/altium_win32.py +457 -0
- pcba/adapters/cubemx_drive.py +248 -0
- pcba/baseline.py +151 -0
- pcba/changes.py +81 -0
- pcba/cli.py +891 -0
- pcba/components.py +323 -0
- pcba/config.py +180 -0
- pcba/data/components/generic.json +61 -0
- pcba/discover.py +350 -0
- pcba/doctor.py +131 -0
- pcba/house.py +123 -0
- pcba/mcp/__init__.py +5 -0
- pcba/mcp/altium_server.py +1358 -0
- pcba/mcp/protocol.py +224 -0
- pcba/mcp/server.py +332 -0
- pcba/model/__init__.py +38 -0
- pcba/model/design.py +232 -0
- pcba/model/finding.py +148 -0
- pcba/model/pinmap.py +200 -0
- pcba/parsers/__init__.py +0 -0
- pcba/parsers/altium/__init__.py +37 -0
- pcba/parsers/altium/_sheet.py +293 -0
- pcba/parsers/altium/geometry.py +103 -0
- pcba/parsers/altium/netlist.py +302 -0
- pcba/parsers/altium/pinmap.py +336 -0
- pcba/parsers/altium/project.py +249 -0
- pcba/parsers/altium/records.py +227 -0
- pcba/parsers/firmware/__init__.py +5 -0
- pcba/parsers/firmware/index.py +250 -0
- pcba/parsers/ioc/__init__.py +49 -0
- pcba/parsers/ioc/mcu_db.py +180 -0
- pcba/parsers/ioc/reader.py +182 -0
- pcba/parsers/ioc/validate.py +192 -0
- pcba/parsers/kicad/__init__.py +103 -0
- pcba/parsers/kicad/board.py +269 -0
- pcba/parsers/kicad/kicad_cli.py +190 -0
- pcba/parsers/kicad/pinmap.py +90 -0
- pcba/parsers/kicad/schematic.py +517 -0
- pcba/parsers/kicad/sexpr.py +136 -0
- pcba/publish.py +269 -0
- pcba/report/__init__.py +7 -0
- pcba/report/json_report.py +39 -0
- pcba/report/sarif.py +128 -0
- pcba/report/text.py +304 -0
- pcba/requirements.py +320 -0
- pcba/review.py +177 -0
- pcba/rules/__init__.py +312 -0
- pcba/rules/cross_domain.py +496 -0
- pcba/rules/kicad_board.py +775 -0
- pcba/rules/kicad_common.py +167 -0
- pcba/rules/kicad_components.py +603 -0
- pcba/rules/kicad_schematic.py +621 -0
- pcba/rules/locate.py +22 -0
- pcba/rules/schematic.py +123 -0
- pcba/rules/signals.py +149 -0
- pcba/rules/spice.py +59 -0
- pcba/rules/stm32.py +155 -0
- pcba/simulation.py +143 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Validate an `.ioc` against a manifest of things a regeneration must not silently change.
|
|
2
|
+
|
|
3
|
+
CubeMX rewrites the whole file on every code-gen. A stray GUI click, a pack update, or a
|
|
4
|
+
merge can move a pin, drop a label, or retune the clock tree, and the diff is 900 lines of
|
|
5
|
+
churn in which nobody spots it. The manifest pins down what matters.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import math
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from pcba.parsers.ioc.reader import IocError, collect_pins, peripherals
|
|
16
|
+
|
|
17
|
+
# Every section the manifest may contain. A typo'd section name used to disable its check
|
|
18
|
+
# silently and still report "validation passed" — a validator that lies is worse than no
|
|
19
|
+
# validator, because it is trusted.
|
|
20
|
+
#
|
|
21
|
+
# Keys starting with `_` are comments. JSON has no comment syntax and manifests are
|
|
22
|
+
# hand-written, so the convention is worth supporting explicitly. It does not weaken the
|
|
23
|
+
# guard: a misspelled real section (`gpio_label`) has no underscore and is still caught.
|
|
24
|
+
KNOWN_SECTIONS = frozenset({"expect", "peripherals", "gpio_labels", "can"})
|
|
25
|
+
KNOWN_CAN_KEYS = frozenset({"instance", "bitrate_bps", "sample_point_pct", "kernel_clock_hz"})
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _sections(manifest: dict[str, Any]) -> set[str]:
|
|
29
|
+
return {k for k in manifest if not k.startswith("_")}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def check_can_timing(values: dict[str, str], spec: dict[str, Any]) -> list[str]:
|
|
33
|
+
"""Verify the CAN bit timing the `.ioc` actually encodes.
|
|
34
|
+
|
|
35
|
+
bitrate = f_kernel / (prescaler * (1 + seg1 + seg2))
|
|
36
|
+
sample point = (1 + seg1) / (1 + seg1 + seg2)
|
|
37
|
+
|
|
38
|
+
Trap: **the `.ioc` does not store the prescaler.** CubeMX writes only the segments plus
|
|
39
|
+
its own computed `CalculateBaudRateNominal` and `CalculateTimeQuantumNominal` (in ns);
|
|
40
|
+
the prescaler first appears in the generated `fdcan.c`. So recover it from the time
|
|
41
|
+
quantum:
|
|
42
|
+
|
|
43
|
+
prescaler = f_kernel * tq_ns / 1e9
|
|
44
|
+
|
|
45
|
+
and cross-check the result against CubeMX's own stored bitrate. A mismatch means the
|
|
46
|
+
`.ioc` is internally inconsistent — usually a hand-edit.
|
|
47
|
+
"""
|
|
48
|
+
errors: list[str] = []
|
|
49
|
+
instance = str(spec.get("instance", "FDCAN1"))
|
|
50
|
+
family = "FDCAN" if instance.startswith("FDCAN") else instance.rstrip("0123456789")
|
|
51
|
+
|
|
52
|
+
clock_raw = spec.get("kernel_clock_hz") or values.get(f"RCC.{family}Freq_Value")
|
|
53
|
+
try:
|
|
54
|
+
clock = int(str(clock_raw))
|
|
55
|
+
seg1 = int(values[f"{instance}.NominalTimeSeg1"])
|
|
56
|
+
seg2 = int(values[f"{instance}.NominalTimeSeg2"])
|
|
57
|
+
except (KeyError, TypeError, ValueError):
|
|
58
|
+
return [f"{instance}: kernel clock or time segments missing from the .ioc"]
|
|
59
|
+
|
|
60
|
+
quanta = 1 + seg1 + seg2
|
|
61
|
+
if quanta <= 1:
|
|
62
|
+
return [f"{instance}: nonsensical segments (seg1={seg1}, seg2={seg2})"]
|
|
63
|
+
|
|
64
|
+
# Sample point needs no prescaler at all — always checkable.
|
|
65
|
+
sample = 100.0 * (1 + seg1) / quanta
|
|
66
|
+
|
|
67
|
+
prescaler: int | None = None
|
|
68
|
+
try:
|
|
69
|
+
if f"{instance}.NominalPrescaler" in values:
|
|
70
|
+
prescaler = int(values[f"{instance}.NominalPrescaler"])
|
|
71
|
+
elif f"{instance}.CalculateTimeQuantumNominal" in values:
|
|
72
|
+
quantum_ns = float(values[f"{instance}.CalculateTimeQuantumNominal"])
|
|
73
|
+
prescaler = round(clock * quantum_ns / 1e9)
|
|
74
|
+
except ValueError:
|
|
75
|
+
return [f"{instance}: prescaler or time quantum is not numeric"]
|
|
76
|
+
|
|
77
|
+
stored = values.get(f"{instance}.CalculateBaudRateNominal")
|
|
78
|
+
stored_bitrate: float | None = None
|
|
79
|
+
if stored:
|
|
80
|
+
try:
|
|
81
|
+
stored_bitrate = float(stored)
|
|
82
|
+
except ValueError:
|
|
83
|
+
errors.append(f"{instance}: CalculateBaudRateNominal is not numeric: {stored!r}")
|
|
84
|
+
bitrate: float | None = None
|
|
85
|
+
if prescaler and prescaler > 0:
|
|
86
|
+
bitrate = clock / (prescaler * quanta)
|
|
87
|
+
if stored_bitrate is not None and abs(bitrate - stored_bitrate) > 1.0:
|
|
88
|
+
errors.append(
|
|
89
|
+
f"{instance}: .ioc is inconsistent — segments and time quantum give "
|
|
90
|
+
f"{bitrate:g} bit/s but CalculateBaudRateNominal says {stored}"
|
|
91
|
+
)
|
|
92
|
+
elif stored_bitrate is not None:
|
|
93
|
+
bitrate = stored_bitrate
|
|
94
|
+
|
|
95
|
+
wanted_bitrate = spec.get("bitrate_bps")
|
|
96
|
+
if wanted_bitrate is not None:
|
|
97
|
+
if bitrate is None:
|
|
98
|
+
errors.append(f"{instance}: cannot determine bitrate from the .ioc")
|
|
99
|
+
elif abs(bitrate - float(str(wanted_bitrate))) > 1.0:
|
|
100
|
+
errors.append(f"{instance}: bitrate is {bitrate:g} bit/s, expected {wanted_bitrate}")
|
|
101
|
+
|
|
102
|
+
wanted_sample = spec.get("sample_point_pct")
|
|
103
|
+
if wanted_sample is not None and abs(sample - float(str(wanted_sample))) > 0.1:
|
|
104
|
+
errors.append(f"{instance}: sample point is {sample:.1f} %, expected {wanted_sample}")
|
|
105
|
+
return errors
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def load_manifest(path: Path) -> dict[str, Any]:
|
|
109
|
+
try:
|
|
110
|
+
manifest = json.loads(path.read_text(encoding="utf-8"))
|
|
111
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
112
|
+
raise IocError(f"cannot read manifest {path}: {exc}") from exc
|
|
113
|
+
if not isinstance(manifest, dict):
|
|
114
|
+
raise IocError(f"{path}: manifest must be a JSON object")
|
|
115
|
+
|
|
116
|
+
unknown = _sections(manifest) - KNOWN_SECTIONS
|
|
117
|
+
if unknown:
|
|
118
|
+
raise IocError(
|
|
119
|
+
f"{path}: unknown section(s) {', '.join(sorted(unknown))}. "
|
|
120
|
+
f"Known: {', '.join(sorted(KNOWN_SECTIONS))} (keys starting with '_' are comments). "
|
|
121
|
+
"Refusing to run — an ignored typo would report a pass having checked nothing."
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
expected_types = {
|
|
125
|
+
"expect": dict,
|
|
126
|
+
"peripherals": list,
|
|
127
|
+
"gpio_labels": dict,
|
|
128
|
+
"can": dict,
|
|
129
|
+
}
|
|
130
|
+
for section, expected_type in expected_types.items():
|
|
131
|
+
if section in manifest and not isinstance(manifest[section], expected_type):
|
|
132
|
+
raise IocError(f"{path}: section '{section}' must be a JSON {expected_type.__name__}")
|
|
133
|
+
|
|
134
|
+
if "peripherals" in manifest and not all(
|
|
135
|
+
isinstance(name, str) for name in manifest["peripherals"]
|
|
136
|
+
):
|
|
137
|
+
raise IocError(f"{path}: section 'peripherals' must contain only strings")
|
|
138
|
+
|
|
139
|
+
if "can" in manifest:
|
|
140
|
+
unknown_can = {k for k in manifest["can"] if not k.startswith("_")} - KNOWN_CAN_KEYS
|
|
141
|
+
if unknown_can:
|
|
142
|
+
raise IocError(f"{path}: unknown key(s) in 'can': {', '.join(sorted(unknown_can))}")
|
|
143
|
+
instance = manifest["can"].get("instance")
|
|
144
|
+
if instance is not None and not isinstance(instance, str):
|
|
145
|
+
raise IocError(f"{path}: 'can.instance' must be a string")
|
|
146
|
+
for key in ("bitrate_bps", "sample_point_pct", "kernel_clock_hz"):
|
|
147
|
+
if key not in manifest["can"]:
|
|
148
|
+
continue
|
|
149
|
+
try:
|
|
150
|
+
value = float(str(manifest["can"][key]))
|
|
151
|
+
except ValueError as exc:
|
|
152
|
+
raise IocError(f"{path}: 'can.{key}' must be numeric") from exc
|
|
153
|
+
if not math.isfinite(value):
|
|
154
|
+
raise IocError(f"{path}: 'can.{key}' must be finite")
|
|
155
|
+
return manifest
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def validate(values: dict[str, str], manifest: dict[str, Any]) -> list[str]:
|
|
159
|
+
"""Return a list of human-readable errors. Empty list means the `.ioc` matches."""
|
|
160
|
+
errors: list[str] = []
|
|
161
|
+
|
|
162
|
+
for key, expected in dict(manifest.get("expect", {})).items():
|
|
163
|
+
actual = values.get(key)
|
|
164
|
+
if actual != str(expected):
|
|
165
|
+
errors.append(f"{key}: expected {expected!r}, found {actual!r}")
|
|
166
|
+
|
|
167
|
+
enabled = set(peripherals(values))
|
|
168
|
+
for name in list(manifest.get("peripherals", [])):
|
|
169
|
+
if name not in enabled:
|
|
170
|
+
errors.append(f"peripheral {name} is not enabled")
|
|
171
|
+
|
|
172
|
+
actual_labels = {
|
|
173
|
+
pin: fields["GPIO_Label"]
|
|
174
|
+
for pin, fields in collect_pins(values).items()
|
|
175
|
+
if "GPIO_Label" in fields
|
|
176
|
+
}
|
|
177
|
+
for pin, label in dict(manifest.get("gpio_labels", {})).items():
|
|
178
|
+
if actual_labels.get(pin) != label:
|
|
179
|
+
errors.append(f"{pin}: expected label {label!r}, found {actual_labels.get(pin)!r}")
|
|
180
|
+
|
|
181
|
+
# Two pins sharing a label is always a bug, manifest or not. This used to be nested
|
|
182
|
+
# inside `if gpio_labels:`, so a project that declared no labels silently lost the
|
|
183
|
+
# check — exactly the project most likely to have a duplicate.
|
|
184
|
+
seen: dict[str, str] = {}
|
|
185
|
+
for pin, label in sorted(actual_labels.items()):
|
|
186
|
+
if label in seen:
|
|
187
|
+
errors.append(f"duplicate label {label!r} on {seen[label]} and {pin}")
|
|
188
|
+
seen[label] = pin
|
|
189
|
+
|
|
190
|
+
if "can" in manifest:
|
|
191
|
+
errors.extend(check_can_timing(values, dict(manifest["can"])))
|
|
192
|
+
return errors
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""KiCad project reading: schematic hierarchy, board, and KiCad's own checks.
|
|
2
|
+
|
|
3
|
+
Read-only and stdlib only. `pcbnew` is never imported: it exists only inside KiCad's bundled
|
|
4
|
+
Python, and a review must run in CI. `kicad-cli` and `ngspice` are optional external tools; when
|
|
5
|
+
they are missing, the checks that need them are reported as not checked.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from pcba import components as component_data
|
|
13
|
+
from pcba import requirements as project_requirements
|
|
14
|
+
from pcba import simulation
|
|
15
|
+
from pcba.model.design import Design
|
|
16
|
+
from pcba.parsers.kicad import board as board_reader
|
|
17
|
+
from pcba.parsers.kicad import kicad_cli
|
|
18
|
+
from pcba.parsers.kicad import schematic as schematic_reader
|
|
19
|
+
from pcba.parsers.kicad.pinmap import detect_part, detect_parts, extract
|
|
20
|
+
from pcba.parsers.kicad.schematic import KicadError, find_root
|
|
21
|
+
from pcba.parsers.kicad.sexpr import SexprError
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def board_file(project_file: Path | None, root_schematic: Path | None) -> Path | None:
|
|
25
|
+
for anchor in (project_file, root_schematic):
|
|
26
|
+
if anchor is not None and anchor.with_suffix(".kicad_pcb").is_file():
|
|
27
|
+
return anchor.with_suffix(".kicad_pcb")
|
|
28
|
+
folder = (project_file or root_schematic).parent if (project_file or root_schematic) else None
|
|
29
|
+
boards = sorted(folder.glob("*.kicad_pcb")) if folder else []
|
|
30
|
+
return boards[0] if len(boards) == 1 else None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_design(project: Path, review_root: Path, *, tools: bool = True) -> Design:
|
|
34
|
+
"""Read the KiCad project at `project` (a .kicad_pro, root .kicad_sch, .kicad_pcb or folder).
|
|
35
|
+
|
|
36
|
+
Unreadable parts become notes, not exceptions: a board without a readable schematic still
|
|
37
|
+
gets its board checks. A malformed requirements or component file is an error, because the
|
|
38
|
+
review would otherwise check against promises it silently ignored.
|
|
39
|
+
"""
|
|
40
|
+
project = Path(project)
|
|
41
|
+
folder = project if project.is_dir() else project.parent
|
|
42
|
+
design = Design(root=str(folder.resolve()))
|
|
43
|
+
if project.suffix == ".kicad_pcb":
|
|
44
|
+
project_file, root_sch = find_root(folder)
|
|
45
|
+
board_path: Path | None = project
|
|
46
|
+
elif project.suffix in (".kicad_pro", ".kicad_sch"):
|
|
47
|
+
project_file = project.with_suffix(".kicad_pro") if project.with_suffix(".kicad_pro").is_file() else None
|
|
48
|
+
root_sch = project.with_suffix(".kicad_sch") if project.with_suffix(".kicad_sch").is_file() else None
|
|
49
|
+
board_path = board_file(project_file, root_sch)
|
|
50
|
+
else:
|
|
51
|
+
project_file, root_sch = find_root(folder)
|
|
52
|
+
board_path = board_file(project_file, root_sch)
|
|
53
|
+
design.project_file = str(project_file.resolve()) if project_file else None
|
|
54
|
+
|
|
55
|
+
if root_sch is not None:
|
|
56
|
+
try:
|
|
57
|
+
design.schematic = schematic_reader.read(project_file or root_sch)
|
|
58
|
+
design.notes += design.schematic.notes
|
|
59
|
+
except KicadError as exc:
|
|
60
|
+
design.notes.append(f"cannot read the KiCad schematic: {exc}")
|
|
61
|
+
else:
|
|
62
|
+
design.notes.append("no KiCad schematic found; schematic rules will not run")
|
|
63
|
+
if board_path is not None:
|
|
64
|
+
try:
|
|
65
|
+
design.board = board_reader.read(board_path)
|
|
66
|
+
except KicadError as exc:
|
|
67
|
+
design.notes.append(f"cannot read the KiCad board: {exc}")
|
|
68
|
+
else:
|
|
69
|
+
design.notes.append("no .kicad_pcb found; board rules will not run")
|
|
70
|
+
|
|
71
|
+
path = project_requirements.find(review_root, folder)
|
|
72
|
+
design.requirements = project_requirements.load(path)
|
|
73
|
+
design.components = component_data.load(design.requirements.databases, design.requirements.assign)
|
|
74
|
+
|
|
75
|
+
if tools:
|
|
76
|
+
design.external.update(kicad_cli.run_all(
|
|
77
|
+
Path(design.schematic.root_file) if design.schematic else None,
|
|
78
|
+
Path(design.board.path) if design.board else None,
|
|
79
|
+
))
|
|
80
|
+
base = path.parent if path else folder
|
|
81
|
+
design.external["spice"] = simulation.run_all(design.requirements.simulations, base, Path(review_root))
|
|
82
|
+
else:
|
|
83
|
+
# `enginehall inspect`: say what a review would run, without running it.
|
|
84
|
+
cli, how = kicad_cli.find()
|
|
85
|
+
for name, needs in (("erc", design.schematic), ("netlist", design.schematic), ("drc", design.board)):
|
|
86
|
+
design.external[name] = {"ran": False, "planned": bool(cli and needs),
|
|
87
|
+
"reason": how if cli is None else "not run by inspect"}
|
|
88
|
+
spice, how = simulation.find()
|
|
89
|
+
design.external["spice"] = {"ran": False, "planned": bool(spice and design.requirements.simulations),
|
|
90
|
+
"reason": how if spice is None else "not run by inspect"}
|
|
91
|
+
return design
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
__all__ = [
|
|
95
|
+
"KicadError",
|
|
96
|
+
"SexprError",
|
|
97
|
+
"board_file",
|
|
98
|
+
"detect_part",
|
|
99
|
+
"detect_parts",
|
|
100
|
+
"extract",
|
|
101
|
+
"find_root",
|
|
102
|
+
"load_design",
|
|
103
|
+
]
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Read a `.kicad_pcb` board: footprints, pads, tracks, vias, zones, outline, texts, 3D models.
|
|
2
|
+
|
|
3
|
+
Two net forms exist and both are read:
|
|
4
|
+
|
|
5
|
+
- KiCad 9 and older keep a numbered table at the top (`(net 7 "GND")`) and name nets on pads,
|
|
6
|
+
tracks and vias by number (`(net 7)`); zones add `(net_name "GND")`.
|
|
7
|
+
- KiCad 10 has no table: every item names its net directly (`(net "GND")`).
|
|
8
|
+
|
|
9
|
+
Pad and footprint-graphic coordinates in the file are relative to their footprint, in the
|
|
10
|
+
footprint's unrotated frame; tracks, vias, zones and board graphics are absolute. A footprint at
|
|
11
|
+
angle a (degrees, counter-clockwise as seen on screen, y down) puts a relative point (x, y) at
|
|
12
|
+
(X + x cos a + y sin a, Y - x sin a + y cos a). A footprint on the back side is stored already
|
|
13
|
+
flipped, so the same transform applies.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import math
|
|
19
|
+
import re
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from pcba.model.design import Board, Footprint, Graphic, Pad, Track, Via
|
|
23
|
+
from pcba.parsers.kicad.schematic import KicadError
|
|
24
|
+
from pcba.parsers.kicad.sexpr import Node, SexprError, load
|
|
25
|
+
|
|
26
|
+
Point = tuple[float, float]
|
|
27
|
+
_SEGMENTS_PER_CIRCLE = 32
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def transform(x: float, y: float, fx: float, fy: float, angle: float) -> Point:
|
|
31
|
+
"""Absolute board position of a footprint-relative point (see the module docstring)."""
|
|
32
|
+
rad = math.radians(angle)
|
|
33
|
+
cos, sin = math.cos(rad), math.sin(rad)
|
|
34
|
+
return (round(fx + x * cos + y * sin, 6), round(fy - x * sin + y * cos, 6))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _xy(node: Node | None) -> Point | None:
|
|
38
|
+
if node is None:
|
|
39
|
+
return None
|
|
40
|
+
atoms = node.atoms()
|
|
41
|
+
return (float(atoms[0]), float(atoms[1])) if len(atoms) >= 2 else None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _arc_points(start: Point, mid: Point, end: Point) -> list[Point]:
|
|
45
|
+
"""A three-point arc as a polyline (the chord when the points are collinear)."""
|
|
46
|
+
(x1, y1), (x2, y2), (x3, y3) = start, mid, end
|
|
47
|
+
d = 2 * (x1 * (y2 - y3) + x2 * (y3 - y1) + x3 * (y1 - y2))
|
|
48
|
+
if abs(d) < 1e-12:
|
|
49
|
+
return [start, end]
|
|
50
|
+
ux = ((x1 * x1 + y1 * y1) * (y2 - y3) + (x2 * x2 + y2 * y2) * (y3 - y1) + (x3 * x3 + y3 * y3) * (y1 - y2)) / d
|
|
51
|
+
uy = ((x1 * x1 + y1 * y1) * (x3 - x2) + (x2 * x2 + y2 * y2) * (x1 - x3) + (x3 * x3 + y3 * y3) * (x2 - x1)) / d
|
|
52
|
+
r = math.hypot(x1 - ux, y1 - uy)
|
|
53
|
+
a1, a2, a3 = (math.atan2(y - uy, x - ux) for x, y in (start, mid, end))
|
|
54
|
+
|
|
55
|
+
def ccw(a: float, b: float) -> float:
|
|
56
|
+
return (b - a) % (2 * math.pi)
|
|
57
|
+
|
|
58
|
+
sweep = ccw(a1, a3)
|
|
59
|
+
if ccw(a1, a2) > sweep: # the mid point is on the other way round
|
|
60
|
+
sweep -= 2 * math.pi
|
|
61
|
+
steps = max(2, int(abs(sweep) / (2 * math.pi) * _SEGMENTS_PER_CIRCLE) + 1)
|
|
62
|
+
return [(ux + r * math.cos(a1 + sweep * i / steps), uy + r * math.sin(a1 + sweep * i / steps))
|
|
63
|
+
for i in range(steps + 1)]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _circle_points(center: Point, edge: Point) -> list[Point]:
|
|
67
|
+
r = math.hypot(edge[0] - center[0], edge[1] - center[1])
|
|
68
|
+
return [(center[0] + r * math.cos(2 * math.pi * i / _SEGMENTS_PER_CIRCLE),
|
|
69
|
+
center[1] + r * math.sin(2 * math.pi * i / _SEGMENTS_PER_CIRCLE)) for i in range(_SEGMENTS_PER_CIRCLE)]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _shape_points(node: Node) -> tuple[str, list[Point]] | None:
|
|
73
|
+
"""(kind, points) of a gr_*/fp_* graphic in its own frame, or None if it is not a shape."""
|
|
74
|
+
kind = node.head.split("_", 1)[-1]
|
|
75
|
+
if kind == "line":
|
|
76
|
+
a, b = _xy(node.child("start")), _xy(node.child("end"))
|
|
77
|
+
return ("line", [a, b]) if a and b else None
|
|
78
|
+
if kind == "arc":
|
|
79
|
+
a, m, b = _xy(node.child("start")), _xy(node.child("mid")), _xy(node.child("end"))
|
|
80
|
+
return ("arc", _arc_points(a, m, b)) if a and m and b else None
|
|
81
|
+
if kind == "circle":
|
|
82
|
+
c, e = _xy(node.child("center")), _xy(node.child("end"))
|
|
83
|
+
return ("circle", _circle_points(c, e)) if c and e else None
|
|
84
|
+
if kind == "rect":
|
|
85
|
+
a, b = _xy(node.child("start")), _xy(node.child("end"))
|
|
86
|
+
if not (a and b):
|
|
87
|
+
return None
|
|
88
|
+
return "rect", [a, (b[0], a[1]), b, (a[0], b[1])]
|
|
89
|
+
if kind == "poly":
|
|
90
|
+
pts = node.child("pts")
|
|
91
|
+
points = [_xy(xy) for xy in pts.children("xy")] if pts is not None else []
|
|
92
|
+
return ("poly", [p for p in points if p]) if points else None
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _text_hidden(node: Node) -> bool:
|
|
97
|
+
if node.flag("hide"):
|
|
98
|
+
return True
|
|
99
|
+
effects = node.child("effects")
|
|
100
|
+
return bool(effects is not None and effects.flag("hide"))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _text_size(node: Node) -> float:
|
|
104
|
+
effects = node.child("effects")
|
|
105
|
+
font = effects.child("font") if effects is not None else None
|
|
106
|
+
size = font.child("size") if font is not None else None
|
|
107
|
+
atoms = size.atoms() if size is not None else []
|
|
108
|
+
return float(atoms[0]) if atoms else 1.0
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class _Nets:
|
|
112
|
+
"""Resolves `(net ...)` on an item, in either file form."""
|
|
113
|
+
|
|
114
|
+
def __init__(self, root: Node) -> None:
|
|
115
|
+
self.table: dict[str, str] = {}
|
|
116
|
+
for net in root.children("net"):
|
|
117
|
+
atoms = net.atoms()
|
|
118
|
+
if len(atoms) >= 2:
|
|
119
|
+
self.table[atoms[0]] = atoms[1]
|
|
120
|
+
self.numbered = bool(self.table)
|
|
121
|
+
|
|
122
|
+
def of(self, item: Node) -> str | None:
|
|
123
|
+
net = item.child("net")
|
|
124
|
+
if net is None:
|
|
125
|
+
name = item.value("net_name")
|
|
126
|
+
return name or None
|
|
127
|
+
atoms = net.atoms()
|
|
128
|
+
if not atoms:
|
|
129
|
+
return None
|
|
130
|
+
if len(atoms) >= 2:
|
|
131
|
+
return atoms[1] or None
|
|
132
|
+
if self.numbered and re.fullmatch(r"\d+", atoms[0]):
|
|
133
|
+
return self.table.get(atoms[0]) or item.value("net_name") or None
|
|
134
|
+
return atoms[0] or None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def read(path: Path | str) -> Board:
|
|
138
|
+
path = Path(path)
|
|
139
|
+
try:
|
|
140
|
+
root = load(path)
|
|
141
|
+
except (OSError, SexprError) as exc:
|
|
142
|
+
raise KicadError(f"cannot read {path.name}: {exc}") from exc
|
|
143
|
+
if root.head != "kicad_pcb":
|
|
144
|
+
raise KicadError(f"{path.name} is not a KiCad board")
|
|
145
|
+
version = int(float(root.value("version", "0") or 0))
|
|
146
|
+
nets = _Nets(root)
|
|
147
|
+
default_mask = 0.0
|
|
148
|
+
setup = root.child("setup")
|
|
149
|
+
if setup is not None:
|
|
150
|
+
default_mask = setup.number("pad_to_mask_clearance", 0.0) or 0.0
|
|
151
|
+
board = Board(path=str(path.resolve()), version=version)
|
|
152
|
+
board.nets = {name for name in nets.table.values() if name}
|
|
153
|
+
|
|
154
|
+
for fp in root.children("footprint"):
|
|
155
|
+
board.footprints.append(_footprint(fp, nets, default_mask))
|
|
156
|
+
for item in root.children("segment"):
|
|
157
|
+
a, b = _xy(item.child("start")), _xy(item.child("end"))
|
|
158
|
+
if a and b:
|
|
159
|
+
board.tracks.append(Track(a, b, item.number("width", 0.0) or 0.0, item.value("layer", "") or "",
|
|
160
|
+
nets.of(item), line=item.line))
|
|
161
|
+
for item in root.children("arc"):
|
|
162
|
+
a, b = _xy(item.child("start")), _xy(item.child("end"))
|
|
163
|
+
if a and b:
|
|
164
|
+
board.tracks.append(Track(a, b, item.number("width", 0.0) or 0.0, item.value("layer", "") or "",
|
|
165
|
+
nets.of(item), arc=True, line=item.line))
|
|
166
|
+
for item in root.children("via"):
|
|
167
|
+
at = item.xy("at")
|
|
168
|
+
if at:
|
|
169
|
+
layers = item.child("layers")
|
|
170
|
+
board.vias.append(Via(at[0], at[1], item.number("size", 0.0) or 0.0, item.number("drill", 0.0) or 0.0,
|
|
171
|
+
tuple(layers.atoms()) if layers is not None else (), nets.of(item), line=item.line))
|
|
172
|
+
for item in root.children("zone"):
|
|
173
|
+
polygon = item.child("polygon")
|
|
174
|
+
pts = polygon.child("pts") if polygon is not None else None
|
|
175
|
+
layers = item.child("layers")
|
|
176
|
+
board.zones.append({
|
|
177
|
+
"net": nets.of(item),
|
|
178
|
+
"layers": (tuple(layers.atoms()) if layers is not None
|
|
179
|
+
else ((item.value("layer"),) if item.value("layer") else ())),
|
|
180
|
+
"keepout": item.child("keepout") is not None,
|
|
181
|
+
"polygon": [p for p in (_xy(xy) for xy in pts.children("xy")) if p] if pts is not None else [],
|
|
182
|
+
"line": item.line,
|
|
183
|
+
})
|
|
184
|
+
for item in root:
|
|
185
|
+
if not isinstance(item, Node) or not item.head.startswith("gr_"):
|
|
186
|
+
continue
|
|
187
|
+
layer = item.value("layer", "") or ""
|
|
188
|
+
if item.head == "gr_text":
|
|
189
|
+
at = item.xy("at") or (0.0, 0.0, 0.0)
|
|
190
|
+
board.graphics.append(Graphic(layer, "text", ((at[0], at[1]),), text=item[1] if len(item) > 1 else "",
|
|
191
|
+
size=_text_size(item), hidden=_text_hidden(item), line=item.line))
|
|
192
|
+
continue
|
|
193
|
+
shape = _shape_points(item)
|
|
194
|
+
if shape:
|
|
195
|
+
board.graphics.append(Graphic(layer, shape[0], tuple(shape[1]), line=item.line))
|
|
196
|
+
for fp in board.footprints:
|
|
197
|
+
for pad in fp.pads:
|
|
198
|
+
if pad.net:
|
|
199
|
+
board.nets.add(pad.net)
|
|
200
|
+
board.nets |= {t.net for t in board.tracks if t.net} | {v.net for v in board.vias if v.net}
|
|
201
|
+
return board
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _footprint(fp: Node, nets: _Nets, default_mask: float) -> Footprint:
|
|
205
|
+
at = fp.xy("at") or (0.0, 0.0, 0.0)
|
|
206
|
+
fx, fy, angle = at
|
|
207
|
+
properties: dict[str, str] = {}
|
|
208
|
+
graphics: list[Graphic] = []
|
|
209
|
+
for prop in fp.children("property"):
|
|
210
|
+
atoms = prop.atoms()
|
|
211
|
+
if len(atoms) >= 2:
|
|
212
|
+
properties[atoms[0]] = atoms[1]
|
|
213
|
+
for text in fp.children("fp_text"):
|
|
214
|
+
atoms = text.atoms()
|
|
215
|
+
if len(atoms) >= 2 and atoms[0] in ("reference", "value"):
|
|
216
|
+
properties.setdefault(atoms[0].capitalize(), atoms[1])
|
|
217
|
+
ref = properties.get("Reference", "")
|
|
218
|
+
fp_mask = fp.number("solder_mask_margin")
|
|
219
|
+
footprint = Footprint(
|
|
220
|
+
ref=ref, value=properties.get("Value", ""), lib=fp[1] if len(fp) > 1 and isinstance(fp[1], str) else "",
|
|
221
|
+
layer=fp.value("layer", "F.Cu") or "F.Cu", x=fx, y=fy, rotation=angle,
|
|
222
|
+
attributes=tuple(fp.child("attr").atoms()) if fp.child("attr") is not None else (),
|
|
223
|
+
properties=properties, line=fp.line,
|
|
224
|
+
)
|
|
225
|
+
for text in list(fp.children("property")) + list(fp.children("fp_text")):
|
|
226
|
+
layer = text.value("layer")
|
|
227
|
+
position = text.xy("at")
|
|
228
|
+
if not layer or position is None:
|
|
229
|
+
continue
|
|
230
|
+
atoms = text.atoms()
|
|
231
|
+
content = atoms[1] if len(atoms) >= 2 else ""
|
|
232
|
+
graphics.append(Graphic(layer, "text", (transform(position[0], position[1], fx, fy, angle),), text=content,
|
|
233
|
+
size=_text_size(text), hidden=_text_hidden(text), ref=ref, line=text.line))
|
|
234
|
+
for item in fp:
|
|
235
|
+
if not isinstance(item, Node) or not item.head.startswith("fp_") or item.head == "fp_text":
|
|
236
|
+
continue
|
|
237
|
+
shape = _shape_points(item)
|
|
238
|
+
if shape:
|
|
239
|
+
points = tuple(transform(x, y, fx, fy, angle) for x, y in shape[1])
|
|
240
|
+
graphics.append(Graphic(item.value("layer", "") or "", shape[0], points, ref=ref, line=item.line))
|
|
241
|
+
footprint.graphics = graphics
|
|
242
|
+
for pad in fp.children("pad"):
|
|
243
|
+
atoms = pad.atoms()
|
|
244
|
+
position = pad.xy("at") or (0.0, 0.0, 0.0)
|
|
245
|
+
size = pad.child("size")
|
|
246
|
+
dims = [float(a) for a in size.atoms()[:2]] if size is not None else [0.0, 0.0]
|
|
247
|
+
while len(dims) < 2:
|
|
248
|
+
dims.append(dims[0] if dims else 0.0)
|
|
249
|
+
drill_node = pad.child("drill")
|
|
250
|
+
drill = 0.0
|
|
251
|
+
if drill_node is not None:
|
|
252
|
+
numbers = [a for a in drill_node.atoms() if re.fullmatch(r"-?[\d.]+", a)]
|
|
253
|
+
drill = float(numbers[0]) if numbers else 0.0
|
|
254
|
+
layers = pad.child("layers")
|
|
255
|
+
pad_mask = pad.number("solder_mask_margin")
|
|
256
|
+
x, y = transform(position[0], position[1], fx, fy, angle)
|
|
257
|
+
footprint.pads.append(Pad(
|
|
258
|
+
ref=ref, number=atoms[0] if atoms else "", kind=atoms[1] if len(atoms) > 1 else "",
|
|
259
|
+
shape=atoms[2] if len(atoms) > 2 else "", x=x, y=y, w=dims[0], h=dims[1],
|
|
260
|
+
rotation=position[2], layers=tuple(layers.atoms()) if layers is not None else (),
|
|
261
|
+
net=nets.of(pad), drill=drill,
|
|
262
|
+
mask_margin=pad_mask if pad_mask is not None else (fp_mask if fp_mask is not None else default_mask),
|
|
263
|
+
line=pad.line,
|
|
264
|
+
))
|
|
265
|
+
for model in fp.children("model"):
|
|
266
|
+
atoms = model.atoms()
|
|
267
|
+
if atoms and not model.flag("hide"):
|
|
268
|
+
footprint.models.append(atoms[0])
|
|
269
|
+
return footprint
|