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.
Files changed (75) hide show
  1. enginehall-2.0.0.dist-info/METADATA +511 -0
  2. enginehall-2.0.0.dist-info/RECORD +75 -0
  3. enginehall-2.0.0.dist-info/WHEEL +4 -0
  4. enginehall-2.0.0.dist-info/entry_points.txt +4 -0
  5. enginehall-2.0.0.dist-info/licenses/LICENSE +51 -0
  6. pcba/THIRD_PARTY_NOTICES.md +72 -0
  7. pcba/__init__.py +6 -0
  8. pcba/adapters/__init__.py +10 -0
  9. pcba/adapters/altium_bridge/MANIFEST.json +5 -0
  10. pcba/adapters/altium_bridge/__init__.py +1 -0
  11. pcba/adapters/altium_bridge/bridge.pas +3342 -0
  12. pcba/adapters/altium_drive.py +1160 -0
  13. pcba/adapters/altium_house.py +591 -0
  14. pcba/adapters/altium_ops.py +858 -0
  15. pcba/adapters/altium_outjob.py +247 -0
  16. pcba/adapters/altium_uia.py +304 -0
  17. pcba/adapters/altium_vcs.py +197 -0
  18. pcba/adapters/altium_win32.py +457 -0
  19. pcba/adapters/cubemx_drive.py +248 -0
  20. pcba/baseline.py +151 -0
  21. pcba/changes.py +81 -0
  22. pcba/cli.py +891 -0
  23. pcba/components.py +323 -0
  24. pcba/config.py +180 -0
  25. pcba/data/components/generic.json +61 -0
  26. pcba/discover.py +350 -0
  27. pcba/doctor.py +131 -0
  28. pcba/house.py +123 -0
  29. pcba/mcp/__init__.py +5 -0
  30. pcba/mcp/altium_server.py +1358 -0
  31. pcba/mcp/protocol.py +224 -0
  32. pcba/mcp/server.py +332 -0
  33. pcba/model/__init__.py +38 -0
  34. pcba/model/design.py +232 -0
  35. pcba/model/finding.py +148 -0
  36. pcba/model/pinmap.py +200 -0
  37. pcba/parsers/__init__.py +0 -0
  38. pcba/parsers/altium/__init__.py +37 -0
  39. pcba/parsers/altium/_sheet.py +293 -0
  40. pcba/parsers/altium/geometry.py +103 -0
  41. pcba/parsers/altium/netlist.py +302 -0
  42. pcba/parsers/altium/pinmap.py +336 -0
  43. pcba/parsers/altium/project.py +249 -0
  44. pcba/parsers/altium/records.py +227 -0
  45. pcba/parsers/firmware/__init__.py +5 -0
  46. pcba/parsers/firmware/index.py +250 -0
  47. pcba/parsers/ioc/__init__.py +49 -0
  48. pcba/parsers/ioc/mcu_db.py +180 -0
  49. pcba/parsers/ioc/reader.py +182 -0
  50. pcba/parsers/ioc/validate.py +192 -0
  51. pcba/parsers/kicad/__init__.py +103 -0
  52. pcba/parsers/kicad/board.py +269 -0
  53. pcba/parsers/kicad/kicad_cli.py +190 -0
  54. pcba/parsers/kicad/pinmap.py +90 -0
  55. pcba/parsers/kicad/schematic.py +517 -0
  56. pcba/parsers/kicad/sexpr.py +136 -0
  57. pcba/publish.py +269 -0
  58. pcba/report/__init__.py +7 -0
  59. pcba/report/json_report.py +39 -0
  60. pcba/report/sarif.py +128 -0
  61. pcba/report/text.py +304 -0
  62. pcba/requirements.py +320 -0
  63. pcba/review.py +177 -0
  64. pcba/rules/__init__.py +312 -0
  65. pcba/rules/cross_domain.py +496 -0
  66. pcba/rules/kicad_board.py +775 -0
  67. pcba/rules/kicad_common.py +167 -0
  68. pcba/rules/kicad_components.py +603 -0
  69. pcba/rules/kicad_schematic.py +621 -0
  70. pcba/rules/locate.py +22 -0
  71. pcba/rules/schematic.py +123 -0
  72. pcba/rules/signals.py +149 -0
  73. pcba/rules/spice.py +59 -0
  74. pcba/rules/stm32.py +155 -0
  75. 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