breakneck 0.2.1__tar.gz

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.
breakneck-0.2.1/.envrc ADDED
@@ -0,0 +1,3 @@
1
+ uv sync --frozen
2
+ export VIRTUAL_ENV=.venv
3
+ layout python3
@@ -0,0 +1,10 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
@@ -0,0 +1 @@
1
+ 3.13
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Hat Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,92 @@
1
+ Metadata-Version: 2.4
2
+ Name: breakneck
3
+ Version: 0.2.1
4
+ Summary: Stitching via visualization and manual forced neckdowns for KiCad
5
+ Project-URL: Homepage, https://github.com/hatlabs/breakneck
6
+ Author-email: Matti Airas <mairas@iki.fi>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: EDA,KiCad,PCB,breakneck,neckdown,stitching
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Scientific/Engineering :: Electronic Design Automation (EDA)
14
+ Classifier: Topic :: Utilities
15
+ Requires-Python: >=3.11
16
+ Requires-Dist: kicad-python>=0.2.0
17
+ Requires-Dist: loguru>=0.7.3
18
+ Requires-Dist: shapely>=2.0.7
19
+ Requires-Dist: svgpathtools>=1.6.1
20
+ Description-Content-Type: text/markdown
21
+
22
+ # Breakneck: Via Stitching and Manual Neckdown for KiCad Layouts
23
+
24
+ ## Introduction
25
+
26
+ ### Via Stitching
27
+
28
+ Ground via stitching is a common technique to reduce EMI and improve signal integrity in PCB designs. It involves placing GND vias next to signal vias to provide a return signal
29
+ path between reference ground planes and to minimize the loop area. This is important
30
+ for any signal with a high edge rate; nowadays basically every digital signal in a design.
31
+
32
+ Breakneck visualizes stitching via requirements by drawing lines between signal vias and their closest GND vias if the nearest GND via is further away than a specified distance. The user can then manually place the GND vias to meet the requirements. The lines are drawn on the User.Eco2 layer which should not be used for any other purpose (any existing content on this layer will be overwritten).
33
+
34
+ The screenshot below shows a design with routed signals lacking stitching vias. The yellow lines indicate the signal vias missing a GND via within a distance of 2mm.
35
+
36
+ ![Stitching Vias](https://github.com/hatlabs/breakneck/blob/main/stitching-via-screenshot.png?raw=true)
37
+
38
+ ### Manual Neckdown
39
+
40
+ **NB:** [This `kicad-python` bug](https://gitlab.com/kicad/code/kicad-python/-/issues/18) prevents manual neckdown from being properly used at the moment.
41
+
42
+ Neckdown, or its inverse, fanout, refers to narrowing down of PCB tracks and their clearances when routing tracks to
43
+ fine-pitch components such as BGAs or QFNs. KiCad does not provide a built-in feature to automatically neckdown tracks,
44
+ and while the KiCad Custom Rules are powerful, they do not support neckdowns. It is possible to define rules for
45
+ tracks intersecting footprint courtyards, but the rule applies to the entire length of the track segment, not just the
46
+ part that intersects the courtyard.
47
+
48
+ Breakneck is a Python script that communicates with KiCad and cuts tracks at a specified distance from the intersection with a footprint courtyard.
49
+ After these track cuts, custom rules apply to the expected track segments. The layout isn't otherwise modified.
50
+ Automatic re-healing of broken tracks is prevented by nudging the track widths by one nanometer, making adjacent
51
+ tracks different widths.
52
+
53
+ ## Installation
54
+
55
+ As a prerequisite, [install uv](https://docs.astral.sh/uv/getting-started/installation/).
56
+
57
+ To run breakneck without actually installing it:
58
+
59
+ ```
60
+ uvx breakneck <command> [options...]
61
+ ```
62
+
63
+ This actually works really fast and the only downside is that you have to type `uvx` every time. If you want to install breakneck, run:
64
+
65
+ ```
66
+ uv tool install breakneck
67
+ ```
68
+
69
+ ## Usage
70
+
71
+ **NOTE:** If you are running breakneck with `uvx`, prepend all breakneck commands below with `uvx`.
72
+
73
+ Run `breakneck -h` to see the available options.
74
+
75
+ `breakneck gndvia` draws stitching via lines on the User.Eco2 layer. The default distance is 2mm, but it can be changed with the `--distance` option.
76
+
77
+ It is possible to run `breakneck gndvia` repeatedly using `watch` to provide semi-real time updates while placing the GND vias:
78
+
79
+ ```bash
80
+ watch -n 1 breakneck gndvia
81
+ ```
82
+
83
+ **NOTE:** It turns out, every execution will bump the undo buffer, so until breakneck is made a bit smarter, it will become very difficult to undo any actual changes.
84
+
85
+ Breakneck has basic support for filtering the affected tracks and components by layer, netclass or selection.
86
+
87
+ ## Limitations
88
+
89
+ - Component classes are not supported due to API limitations.
90
+ - Grouped tracks and footprints are ignored due to API limitations. It is possible to enter a group and
91
+ run `breakneck --selection` to process the group members.
92
+ - Multiple ground layers are not supported in `gndvia`. If you have isolated board sections, you will have a lot of bogus lines to the closest regular GND via.
@@ -0,0 +1,71 @@
1
+ # Breakneck: Via Stitching and Manual Neckdown for KiCad Layouts
2
+
3
+ ## Introduction
4
+
5
+ ### Via Stitching
6
+
7
+ Ground via stitching is a common technique to reduce EMI and improve signal integrity in PCB designs. It involves placing GND vias next to signal vias to provide a return signal
8
+ path between reference ground planes and to minimize the loop area. This is important
9
+ for any signal with a high edge rate; nowadays basically every digital signal in a design.
10
+
11
+ Breakneck visualizes stitching via requirements by drawing lines between signal vias and their closest GND vias if the nearest GND via is further away than a specified distance. The user can then manually place the GND vias to meet the requirements. The lines are drawn on the User.Eco2 layer which should not be used for any other purpose (any existing content on this layer will be overwritten).
12
+
13
+ The screenshot below shows a design with routed signals lacking stitching vias. The yellow lines indicate the signal vias missing a GND via within a distance of 2mm.
14
+
15
+ ![Stitching Vias](https://github.com/hatlabs/breakneck/blob/main/stitching-via-screenshot.png?raw=true)
16
+
17
+ ### Manual Neckdown
18
+
19
+ **NB:** [This `kicad-python` bug](https://gitlab.com/kicad/code/kicad-python/-/issues/18) prevents manual neckdown from being properly used at the moment.
20
+
21
+ Neckdown, or its inverse, fanout, refers to narrowing down of PCB tracks and their clearances when routing tracks to
22
+ fine-pitch components such as BGAs or QFNs. KiCad does not provide a built-in feature to automatically neckdown tracks,
23
+ and while the KiCad Custom Rules are powerful, they do not support neckdowns. It is possible to define rules for
24
+ tracks intersecting footprint courtyards, but the rule applies to the entire length of the track segment, not just the
25
+ part that intersects the courtyard.
26
+
27
+ Breakneck is a Python script that communicates with KiCad and cuts tracks at a specified distance from the intersection with a footprint courtyard.
28
+ After these track cuts, custom rules apply to the expected track segments. The layout isn't otherwise modified.
29
+ Automatic re-healing of broken tracks is prevented by nudging the track widths by one nanometer, making adjacent
30
+ tracks different widths.
31
+
32
+ ## Installation
33
+
34
+ As a prerequisite, [install uv](https://docs.astral.sh/uv/getting-started/installation/).
35
+
36
+ To run breakneck without actually installing it:
37
+
38
+ ```
39
+ uvx breakneck <command> [options...]
40
+ ```
41
+
42
+ This actually works really fast and the only downside is that you have to type `uvx` every time. If you want to install breakneck, run:
43
+
44
+ ```
45
+ uv tool install breakneck
46
+ ```
47
+
48
+ ## Usage
49
+
50
+ **NOTE:** If you are running breakneck with `uvx`, prepend all breakneck commands below with `uvx`.
51
+
52
+ Run `breakneck -h` to see the available options.
53
+
54
+ `breakneck gndvia` draws stitching via lines on the User.Eco2 layer. The default distance is 2mm, but it can be changed with the `--distance` option.
55
+
56
+ It is possible to run `breakneck gndvia` repeatedly using `watch` to provide semi-real time updates while placing the GND vias:
57
+
58
+ ```bash
59
+ watch -n 1 breakneck gndvia
60
+ ```
61
+
62
+ **NOTE:** It turns out, every execution will bump the undo buffer, so until breakneck is made a bit smarter, it will become very difficult to undo any actual changes.
63
+
64
+ Breakneck has basic support for filtering the affected tracks and components by layer, netclass or selection.
65
+
66
+ ## Limitations
67
+
68
+ - Component classes are not supported due to API limitations.
69
+ - Grouped tracks and footprints are ignored due to API limitations. It is possible to enter a group and
70
+ run `breakneck --selection` to process the group members.
71
+ - Multiple ground layers are not supported in `gndvia`. If you have isolated board sections, you will have a lot of bogus lines to the closest regular GND via.
File without changes
@@ -0,0 +1,48 @@
1
+ from dataclasses import dataclass
2
+ from enum import Enum
3
+
4
+
5
+ @dataclass(frozen=True)
6
+ class Coords2D:
7
+ x: int
8
+ y: int
9
+
10
+ def __post_init__(self):
11
+ object.__setattr__(self, "x", int(self.x))
12
+ object.__setattr__(self, "y", int(self.y))
13
+
14
+ def __hash__(self):
15
+ return hash((self.x, self.y))
16
+
17
+ def __eq__(self, other):
18
+ return self.x == other.x and self.y == other.y
19
+
20
+ def __getitem__(self, idx):
21
+ if idx == 0:
22
+ return self.x
23
+ elif idx == 1:
24
+ return self.y
25
+ else:
26
+ raise IndexError("Index out of range")
27
+
28
+ def __iter__(self):
29
+ yield self.x
30
+ yield self.y
31
+
32
+ def __len__(self):
33
+ return 2
34
+
35
+ def __repr__(self):
36
+ return f"Coords({self.x}, {self.y})"
37
+
38
+ def as_tol(self, tol_nm):
39
+ return Coords2D(round(self.x / tol_nm), round(self.y / tol_nm))
40
+
41
+ def as_tuple(self):
42
+ return self.x, self.y
43
+
44
+
45
+ class Sides(str, Enum):
46
+ front = "front"
47
+ back = "back"
48
+ both = "both"
@@ -0,0 +1,305 @@
1
+ import argparse
2
+ from collections.abc import Sequence
3
+
4
+ import kipy.board
5
+ import kipy.board_types
6
+ import shapely.geometry
7
+ from kipy.board import BoardLayer as BL
8
+ from kipy.proto.board import board_types_pb2
9
+ from kipy.proto.common import base_types_pb2
10
+ from loguru import logger
11
+
12
+ import breakneck.conversions
13
+ import breakneck.footprint
14
+
15
+
16
+ def break_tracks(
17
+ board: kipy.board.Board,
18
+ footprints: Sequence[breakneck.footprint.BNFootprint],
19
+ tracks: Sequence[kipy.board_types.Track | kipy.board_types.ArcTrack],
20
+ neck_length: float = 10,
21
+ dry_run: bool = False,
22
+ ) -> None:
23
+ """
24
+ Break tracks crossing the courtyards of footprints on a board.
25
+ """
26
+ all_tracks = {t.id.value: t for t in tracks}
27
+ logger.debug("Getting all courtyards")
28
+
29
+ max_width = max(t.width for t in all_tracks.values())
30
+ remove_dict = {}
31
+ create_dict = {}
32
+
33
+ neck_length_nm = int(neck_length * 1000000) # mm to nm
34
+
35
+ for fpc in footprints:
36
+ logger.debug(f"Breaking tracks for {fpc.ref}")
37
+ items_to_remove, items_to_create = fpc.break_tracks(
38
+ list(all_tracks.values()), max_width, neck_length_nm
39
+ )
40
+
41
+ # remove items from all_tracks and create_list
42
+ for item in items_to_remove:
43
+ all_tracks.pop(item.id.value)
44
+ create_dict.pop(item.id.value, None)
45
+
46
+ # add new items to all_tracks and create_list
47
+ for item in items_to_create:
48
+ all_tracks[item.id.value] = item
49
+ create_dict[item.id.value] = item
50
+
51
+ # add items to remove_list
52
+ for item in items_to_remove:
53
+ remove_dict[item.id.value] = item
54
+
55
+ commit = board.begin_commit()
56
+ board.remove_items(list(remove_dict.values()))
57
+ board.create_items(list(create_dict.values()))
58
+ if dry_run:
59
+ board.drop_commit(commit)
60
+ else:
61
+ board.push_commit(commit)
62
+
63
+
64
+ def execute_cut(args):
65
+ kicad = kipy.KiCad()
66
+ board = kicad.get_board()
67
+
68
+ tracks = board.get_tracks()
69
+ footprints = board.get_footprints()
70
+
71
+ num_all_tracks = len(tracks)
72
+ num_all_footprints = len(footprints)
73
+
74
+ if args.selection:
75
+ items = board.get_selection()
76
+
77
+ if len(items) == 0:
78
+ logger.error("No items selected")
79
+ return
80
+
81
+ # get tracks from selection
82
+ sel_tracks = [
83
+ item
84
+ for item in items
85
+ if isinstance(item, kipy.board_types.Track)
86
+ or isinstance(item, kipy.board_types.ArcTrack)
87
+ ]
88
+
89
+ # get footprints from selection
90
+ sel_footprints = [
91
+ item
92
+ for item in items
93
+ if isinstance(item, kipy.board_types.FootprintInstance)
94
+ ]
95
+
96
+ # If selection has no tracks or footprints, use all tracks and footprints
97
+
98
+ if sel_tracks:
99
+ tracks = sel_tracks
100
+ if sel_footprints:
101
+ footprints = sel_footprints
102
+
103
+ bnfootprints = breakneck.footprint.get_bn_footprints(footprints)
104
+
105
+ if args.sides:
106
+ if args.sides == "front":
107
+ tracks = [t for t in tracks if t.layer == BL.BL_F_Cu]
108
+ bnfootprints = [
109
+ fpc for fpc in bnfootprints if not fpc.front_courtyard.is_empty
110
+ ]
111
+ elif args.sides == "back":
112
+ tracks = [t for t in tracks if t.layer == BL.BL_B_Cu]
113
+ bnfootprints = [
114
+ fpc for fpc in bnfootprints if not fpc.back_courtyard.is_empty
115
+ ]
116
+
117
+ if args.netclass:
118
+ nets = board.get_nets(args.netclass)
119
+ net_names = set(n.name for n in nets)
120
+ tracks = [t for t in tracks if t.net in net_names]
121
+
122
+ num_tracks = len(tracks)
123
+ num_footprints = len(bnfootprints)
124
+
125
+ logger.info(
126
+ f"Breaking tracks on {num_tracks}/{num_all_tracks} tracks "
127
+ f"and {num_footprints}/{num_all_footprints} footprints"
128
+ )
129
+
130
+ if num_tracks == 0:
131
+ logger.warning("No tracks to break")
132
+ return
133
+
134
+ if num_footprints == 0:
135
+ logger.warning("No footprints to break tracks on")
136
+ return
137
+
138
+ break_tracks(board, bnfootprints, tracks, dry_run=args.dry_run)
139
+
140
+
141
+ def execute_gndvia_check(args):
142
+ max_distance = args.distance * 1000000 # mm to nm
143
+
144
+ kicad = kipy.KiCad()
145
+ board = kicad.get_board()
146
+
147
+ vias = board.get_vias()
148
+
149
+ all_pads = board.get_pads()
150
+ gnd_tht_pads = [
151
+ pad
152
+ for pad in all_pads
153
+ if pad.net.name == "GND" and pad.proto.type == kipy.board_types.PadType.PT_PTH
154
+ ]
155
+
156
+ # We treat GND THT pads as vias as well
157
+ gnd_vias = [via for via in vias if via.net.name == "GND"] + gnd_tht_pads
158
+
159
+ gnd_via_coords = [
160
+ breakneck.conversions.vector2_as_coords(via.position) for via in gnd_vias
161
+ ]
162
+ gnd_via_points = [shapely.geometry.Point(coords) for coords in gnd_via_coords]
163
+
164
+ gnd_via_tree = shapely.STRtree(gnd_via_points)
165
+
166
+ non_gnd_vias = [via for via in vias if via.net.name != "GND"]
167
+ non_gnd_via_coords = [
168
+ breakneck.conversions.vector2_as_coords(via.position) for via in non_gnd_vias
169
+ ]
170
+ non_gnd_via_points = [
171
+ shapely.geometry.Point(coords) for coords in non_gnd_via_coords
172
+ ]
173
+
174
+ nearests_idxs = [gnd_via_tree.nearest(pt) for pt in non_gnd_via_points]
175
+
176
+ nearests = [gnd_via_points[idx] for idx in nearests_idxs]
177
+ distances = [
178
+ shapely.distance(nearest, non_gnd_via)
179
+ for nearest, non_gnd_via in zip(nearests, non_gnd_via_points)
180
+ ]
181
+
182
+ # Get those non-gnd vias that are more than 3000000 nm away from the nearest gnd via
183
+ lone_via_idxs = [
184
+ i for i, distance in enumerate(distances) if distance > max_distance
185
+ ]
186
+
187
+ new_lines = []
188
+ for idx in lone_via_idxs:
189
+ # Draw a line from the lone via to the nearest gnd via on layer BL.BL_Eco2_User
190
+ # with thickness 0.05mm = 50000 nm
191
+ ngvc = non_gnd_via_coords[idx]
192
+ gvc = gnd_via_coords[nearests_idxs[idx]]
193
+ start_proto_vector = base_types_pb2.Vector2(x_nm=ngvc[0], y_nm=-ngvc[1])
194
+ end_proto_vector = base_types_pb2.Vector2(x_nm=gvc[0], y_nm=-gvc[1])
195
+
196
+ shape = base_types_pb2.GraphicShape(
197
+ segment=base_types_pb2.GraphicSegmentAttributes(
198
+ start=start_proto_vector, end=end_proto_vector
199
+ )
200
+ )
201
+
202
+ board_graphic_shape = board_types_pb2.BoardGraphicShape(shape=shape)
203
+
204
+ segment = kipy.board_types.BoardSegment(proto=board_graphic_shape)
205
+ segment.layer = BL.BL_Eco2_User
206
+ segment.attributes.stroke.width = 100000
207
+ new_lines.append(segment)
208
+
209
+ shapes = board.get_shapes()
210
+ eco2_shapes = [shape for shape in shapes if shape.layer == BL.BL_Eco2_User]
211
+
212
+ commit = board.begin_commit()
213
+ board.remove_items(eco2_shapes)
214
+ board.create_items(new_lines)
215
+ if args.dry_run:
216
+ board.drop_commit(commit)
217
+ else:
218
+ board.push_commit(commit)
219
+
220
+
221
+ def parse_args():
222
+ parser = argparse.ArgumentParser()
223
+
224
+ parser.add_argument(
225
+ "--dry-run",
226
+ "-n",
227
+ action="store_true",
228
+ help="Do everything except commit the changes",
229
+ )
230
+
231
+ subparsers = parser.add_subparsers(dest="command", required=True)
232
+
233
+ cut_parser = subparsers.add_parser("cut", help="Add neckdowns to tracks")
234
+
235
+ cut_parser.add_argument(
236
+ "--selection", action="store_true", help="Use selected items"
237
+ )
238
+
239
+ cut_parser.add_argument(
240
+ "--sides",
241
+ choices=["front", "back", "both"],
242
+ default="both",
243
+ help="Side of the board to break tracks on",
244
+ )
245
+
246
+ cut_parser.add_argument(
247
+ "--length",
248
+ "-l",
249
+ type=float,
250
+ default=10,
251
+ help="Neckdown length outside of the courtyard, in mm",
252
+ )
253
+
254
+ cut_parser.add_argument("--netclass", type=str, help="Netclass to break tracks on")
255
+
256
+ gnd_via_parser = subparsers.add_parser("gndvia", help="Visualize GND via distances")
257
+
258
+ gnd_via_parser.add_argument(
259
+ "--distance",
260
+ "-d",
261
+ type=float,
262
+ default=2,
263
+ help="Maximum distance to GND via, in mm",
264
+ )
265
+
266
+ subparsers.add_parser("clear", help="Remove annotations from User.Eco2 layer")
267
+
268
+ return parser.parse_args()
269
+
270
+
271
+ def execute_clear(args):
272
+ kicad = kipy.KiCad()
273
+ board = kicad.get_board()
274
+
275
+ shapes = board.get_shapes()
276
+ eco2_shapes = [shape for shape in shapes if shape.layer == BL.BL_Eco2_User]
277
+
278
+ if not shapes:
279
+ logger.info("No shapes to clear")
280
+ return
281
+
282
+ commit = board.begin_commit()
283
+ board.remove_items(eco2_shapes)
284
+ if args.dry_run:
285
+ board.drop_commit(commit)
286
+ else:
287
+ board.push_commit(commit)
288
+
289
+
290
+ def main():
291
+ args = parse_args()
292
+
293
+ match args.command:
294
+ case "cut":
295
+ execute_cut(args)
296
+
297
+ case "gndvia":
298
+ execute_gndvia_check(args)
299
+
300
+ case "clear":
301
+ execute_clear(args)
302
+
303
+
304
+ if __name__ == "__main__":
305
+ main()