breakneck 0.2.1__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.
breakneck/__init__.py ADDED
File without changes
breakneck/base.py ADDED
@@ -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"
breakneck/cli.py ADDED
@@ -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()
@@ -0,0 +1,370 @@
1
+ from collections import defaultdict
2
+
3
+ import kipy
4
+ import kipy.board_types as kbt
5
+ import kipy.common_types
6
+ import kipy.geometry
7
+ import kipy.util.units
8
+ import numpy as np
9
+ import shapely.geometry as sg
10
+
11
+ from breakneck.base import Coords2D
12
+
13
+ OpenShape = kbt.BoardSegment | kbt.BoardArc
14
+ ClosedShape = kbt.BoardRectangle | kbt.BoardCircle | kbt.BoardPolygon
15
+
16
+ OpenShapes = list[OpenShape]
17
+ ClosedShapes = list[ClosedShape]
18
+
19
+
20
+ def vector2_as_coords(point: kipy.geometry.Vector2) -> Coords2D:
21
+ """
22
+ Convert a point to coordinates in millimeters with y-up orientation.
23
+ """
24
+ # Shapely uses y-up coordinates
25
+ return Coords2D(point.x, -point.y)
26
+
27
+
28
+ def coords_as_vector2(coords: Coords2D) -> kipy.geometry.Vector2:
29
+ """
30
+ Convert coordinates in millimeters with y-up orientation to a point.
31
+ """
32
+ return kipy.geometry.Vector2.from_xy(coords.x, -coords.y)
33
+
34
+
35
+ def track_as_linestring(track: kbt.Track | kbt.ArcTrack) -> sg.LineString:
36
+ if isinstance(track, kbt.Track):
37
+ return sg.LineString(
38
+ [vector2_as_coords(track.start).as_tuple(), vector2_as_coords(track.end)]
39
+ )
40
+ else:
41
+ return arc_as_linestring(track)
42
+
43
+
44
+ def _polyline_as_coords(polyline: kipy.geometry.PolyLine) -> list[Coords2D]:
45
+ return [vector2_as_coords(node.point) for node in polyline.nodes]
46
+
47
+
48
+ def _polyline_as_tuples(polyline: kipy.geometry.PolyLine) -> list[tuple[int, int]]:
49
+ return [coords.as_tuple() for coords in _polyline_as_coords(polyline)]
50
+
51
+
52
+ def polygon_as_shapely_polygon(polygon: kipy.geometry.PolygonWithHoles) -> sg.Polygon:
53
+ print("converting")
54
+ coords = _polyline_as_tuples(polygon.outline)
55
+ holes = [_polyline_as_tuples(hole) for hole in polygon.holes]
56
+ return sg.Polygon(coords, holes)
57
+
58
+
59
+ def board_polygon_as_polygons(
60
+ polygon: kbt.BoardPolygon,
61
+ ) -> list[sg.Polygon]:
62
+ polygons: list[sg.Polygon] = []
63
+ for poly in polygon.polygons:
64
+ polygons.append(polygon_as_shapely_polygon(poly))
65
+ return polygons
66
+
67
+
68
+ def board_segment_as_linestring(
69
+ segment: kbt.BoardSegment,
70
+ ) -> sg.LineString:
71
+ return sg.LineString(
72
+ [vector2_as_coords(segment.start), vector2_as_coords(segment.end)]
73
+ )
74
+
75
+
76
+ def board_segments_as_linestrings(
77
+ boardsegments: list[kbt.BoardSegment],
78
+ ) -> list[sg.LineString]:
79
+ return [board_segment_as_linestring(bs) for bs in boardsegments]
80
+
81
+
82
+ def board_rectangle_as_polygon(
83
+ rectangle: kbt.BoardRectangle,
84
+ ) -> sg.Polygon:
85
+ # Get corner coordinates
86
+ top_left = rectangle.top_left
87
+ bottom_right = rectangle.bottom_right
88
+ coords = [
89
+ (top_left.x, -top_left.y),
90
+ (bottom_right.x, -top_left.y),
91
+ (bottom_right.x, -bottom_right.y),
92
+ (top_left.x, -bottom_right.y),
93
+ ]
94
+ return sg.Polygon(coords)
95
+
96
+
97
+ def arc_as_linestring(
98
+ arc: kipy.common_types.Arc | kbt.ArcTrack,
99
+ ) -> sg.LineString:
100
+ # Get the start and end points of the arc
101
+ start = vector2_as_coords(arc.start)
102
+ mid = vector2_as_coords(arc.mid)
103
+
104
+ # Calculate the center of the arc
105
+ arc_center = arc.center()
106
+
107
+ if arc_center is None:
108
+ # Return an empty line string for degenerate arcs
109
+ return sg.LineString([])
110
+
111
+ center = vector2_as_coords(arc_center)
112
+
113
+ # Calculate the radius of the arc
114
+ radius = int(arc.radius())
115
+
116
+ # Determine if the arc is CW or CCW
117
+ cross_product = (start.x - center.x) * (mid.y - center.y) - (start.y - center.y) * (
118
+ mid.x - center.x
119
+ )
120
+
121
+ # Calculate the start and end angles of the arc
122
+ start_angle = arc.start_angle()
123
+ end_angle = arc.end_angle()
124
+ assert start_angle is not None
125
+ assert end_angle is not None
126
+
127
+ # kipy coordinates are y-down but we use y-up. Adjust angles accordingly
128
+ start_angle = -start_angle
129
+ end_angle = -end_angle
130
+
131
+ if cross_product > 0: # Counterclockwise
132
+ if end_angle < start_angle:
133
+ end_angle += 2 * np.pi
134
+ else: # Clockwise
135
+ if end_angle > start_angle:
136
+ end_angle -= 2 * np.pi
137
+
138
+ # We want points at every degree
139
+
140
+ # Calculate the angle of the arc
141
+ angle = abs(end_angle - start_angle)
142
+
143
+ num_points = int(np.degrees(angle)) + 1
144
+
145
+ angles = np.linspace(start_angle, end_angle, num_points)
146
+
147
+ arc_points = [
148
+ Coords2D(int(center.x + radius * np.cos(a)), int(center.y + radius * np.sin(a)))
149
+ for a in angles
150
+ ]
151
+
152
+ return sg.LineString(arc_points)
153
+
154
+
155
+ def board_circle_as_polygon(
156
+ circle: kbt.BoardCircle, num_points: int = 360
157
+ ) -> sg.Polygon:
158
+ # Get the center of the circle
159
+ center = vector2_as_coords(circle.center)
160
+
161
+ # Get the radius of the circle
162
+ radius = int(circle.radius())
163
+
164
+ # We want points at every degree
165
+ angles = np.linspace(0, 2 * np.pi, num_points)
166
+
167
+ # Calculate the points of the circle
168
+ circle_points = [
169
+ Coords2D(int(center.x + radius * np.cos(a)), int(center.y + radius * np.sin(a)))
170
+ for a in angles
171
+ ]
172
+
173
+ # Create the circle
174
+ return sg.Polygon(circle_points)
175
+
176
+
177
+ def polyline_as_shapely(polyline: kipy.geometry.PolyLine) -> sg.LineString:
178
+ return sg.LineString(_polyline_as_tuples(polyline))
179
+
180
+
181
+ def closed_shapes_as_polygon(shapes: ClosedShapes) -> list[sg.Polygon]:
182
+ geometries = []
183
+ for shape in shapes:
184
+ if isinstance(shape, kbt.BoardRectangle):
185
+ geometries.append(board_rectangle_as_polygon(shape))
186
+ elif isinstance(shape, kbt.BoardCircle):
187
+ geometries.append(board_circle_as_polygon(shape))
188
+ elif isinstance(shape, kbt.BoardPolygon):
189
+ geometries.extend(board_polygon_as_polygons(shape))
190
+ return geometries
191
+
192
+
193
+ def open_shapes_as_polygon(shapes: OpenShapes, tol_nm: int) -> list[sg.Polygon]:
194
+ """
195
+ Convert a list of open shapes to a list of shapely polygons
196
+ """
197
+ chains = _chain_shapes(shapes, tol_nm)
198
+ polygons = []
199
+ for chain in chains:
200
+ coords = []
201
+ for shape in chain:
202
+ if isinstance(shape, kbt.BoardSegment):
203
+ coords.append(vector2_as_coords(shape.start).as_tuple())
204
+ elif isinstance(shape, kbt.BoardArc):
205
+ coords.append(vector2_as_coords(shape.start).as_tuple())
206
+ coords.extend(arc_as_linestring(shape).coords[1:-1])
207
+ else:
208
+ raise ValueError(f"Shape {type(shape)} not supported")
209
+ coords.append(coords[0])
210
+ polygons.append(sg.Polygon(coords))
211
+ return polygons
212
+
213
+
214
+ def _get_endpoints(shape: kbt.BoardShape, tol_nm: int) -> tuple[Coords2D, Coords2D]:
215
+ """Return the start and end Coords2D of a BoardSegment."""
216
+
217
+ if isinstance(shape, kbt.BoardSegment) or isinstance(shape, kbt.BoardArc):
218
+ start = Coords2D(shape.start.x, shape.start.y).as_tol(tol_nm)
219
+ end = Coords2D(shape.end.x, shape.end.y).as_tol(tol_nm)
220
+ else:
221
+ raise ValueError(f"Shape {type(shape)} not supported")
222
+
223
+ return start, end
224
+
225
+
226
+ def build_shape_adjacency_graph(
227
+ shapes: OpenShapes, tol_nm: int
228
+ ) -> tuple[
229
+ dict[Coords2D, list[Coords2D]],
230
+ dict[Coords2D, OpenShapes],
231
+ ]:
232
+ """Build an adjacency graph for non-closed KiPy board shapes.
233
+
234
+ The adjacency dict maps Coords2D to a list of connected Coords2D.
235
+ The shape_map dict maps Coords2D to a list of connected BoardShapes.
236
+ """
237
+ adjacency: dict[Coords2D, list[Coords2D]] = defaultdict(list)
238
+ shape_map: dict[Coords2D, list[kbt.BoardSegment | kbt.BoardArc]] = defaultdict(list)
239
+
240
+ for shape in shapes:
241
+ assert isinstance(shape, kbt.BoardSegment) or isinstance(shape, kbt.BoardArc)
242
+ start, end = _get_endpoints(shape, tol_nm)
243
+
244
+ adjacency[start].append(end)
245
+ adjacency[end].append(start)
246
+ shape_map[start].append(shape)
247
+ shape_map[end].append(shape)
248
+
249
+ return adjacency, shape_map
250
+
251
+
252
+ def _reverse_segment(segment: kbt.BoardSegment) -> None:
253
+ segment.start, segment.end = segment.end, segment.start
254
+
255
+
256
+ def _reverse_arc(arc: kbt.BoardArc) -> None:
257
+ arc.start, arc.end = arc.end, arc.start
258
+ arc.start_angle, arc.end_angle = arc.end_angle, arc.start_angle
259
+
260
+
261
+ def _reverse_shape(shape: kbt.BoardShape) -> None:
262
+ if isinstance(shape, kbt.BoardSegment):
263
+ _reverse_segment(shape)
264
+ elif isinstance(shape, kbt.BoardArc):
265
+ _reverse_arc(shape)
266
+ else:
267
+ raise ValueError(f"Shape {type(shape)} not supported")
268
+
269
+
270
+ def _extract_chain(
271
+ shape: OpenShape,
272
+ shapes: OpenShapes,
273
+ visited_shapes: set[OpenShape],
274
+ tol_nm: int,
275
+ ) -> OpenShapes:
276
+ """Extract a chain of connected shapes from a starting point."""
277
+ ordered_shapes = []
278
+
279
+ visited_shapes.add(shape)
280
+ chain_start, end = _get_endpoints(shape, tol_nm)
281
+ ordered_shapes.append(shape)
282
+
283
+ while True:
284
+ # Find a connected shape: start or end point should equal to
285
+ # the end point of the last shape
286
+
287
+ found = False
288
+
289
+ for next_shape in shapes:
290
+ if next_shape in visited_shapes:
291
+ continue
292
+
293
+ next_start, next_end = _get_endpoints(next_shape, tol_nm)
294
+
295
+ if end == next_start:
296
+ ordered_shapes.append(next_shape)
297
+ visited_shapes.add(next_shape)
298
+ end = next_end
299
+ found = True
300
+ break
301
+ elif end == next_end:
302
+ _reverse_shape(next_shape)
303
+ ordered_shapes.append(next_shape)
304
+ visited_shapes.add(next_shape)
305
+ end = next_start
306
+ found = True
307
+ break
308
+
309
+ if not found:
310
+ break
311
+
312
+ return ordered_shapes
313
+
314
+
315
+ def _chain_shapes(shapes: OpenShapes, tol_nm: int) -> list[OpenShapes]:
316
+ """Convert a list of unordered BoardShapes to chains of shapes."""
317
+
318
+ # Courtyards are so simple that a graph is not needed
319
+
320
+ # Start traversing the shapes
321
+ ordered_chains: list[OpenShapes] = []
322
+ visited_shapes = set()
323
+
324
+ for shape in shapes:
325
+ if shape in visited_shapes:
326
+ continue
327
+ chain = _extract_chain(shape, shapes, visited_shapes, tol_nm)
328
+ if chain:
329
+ ordered_chains.append(chain)
330
+
331
+ return ordered_chains
332
+
333
+
334
+ def _chain_as_polygon(chain: OpenShapes) -> sg.Polygon:
335
+ coords = []
336
+ for shape in chain:
337
+ if isinstance(shape, kbt.BoardSegment):
338
+ coords.append(vector2_as_coords(shape.start).as_tuple())
339
+ elif isinstance(shape, kbt.BoardArc):
340
+ coords.append(vector2_as_coords(shape.start).as_tuple())
341
+ coords.extend(arc_as_linestring(shape).coords[1:-1])
342
+ else:
343
+ raise ValueError(f"Shape {type(shape)} not supported")
344
+ coords.append(coords[0])
345
+ return sg.Polygon(coords)
346
+
347
+
348
+ def as_polygons(shapes: list[kbt.BoardShape], tol_nm: int) -> list[sg.Polygon]:
349
+ """Convert a list of BoardShapes to a list of shapely Polygons."""
350
+ closed_shapes: ClosedShapes = []
351
+ open_shapes: OpenShapes = []
352
+
353
+ for shape in shapes:
354
+ if isinstance(shape, kbt.BoardRectangle) or isinstance(shape, kbt.BoardCircle):
355
+ closed_shapes.append(shape)
356
+ elif isinstance(shape, kbt.BoardPolygon):
357
+ closed_shapes.append(shape)
358
+ elif isinstance(shape, kbt.BoardSegment) or isinstance(shape, kbt.BoardArc):
359
+ open_shapes.append(shape)
360
+ else:
361
+ raise ValueError(f"Shape {type(shape)} not supported")
362
+
363
+ polygons = closed_shapes_as_polygon(closed_shapes)
364
+
365
+ chains = _chain_shapes(open_shapes, tol_nm)
366
+
367
+ for chain in chains:
368
+ polygons.append(_chain_as_polygon(chain))
369
+
370
+ return polygons
breakneck/footprint.py ADDED
@@ -0,0 +1,234 @@
1
+ from collections.abc import Sequence
2
+
3
+ import kipy.board
4
+ import kipy.board_types
5
+ import shapely
6
+ import shapely.geometry as sg
7
+ from kipy.board import BoardLayer as BL
8
+ from kipy.proto.board import board_types_pb2
9
+ from loguru import logger
10
+
11
+ import breakneck.base
12
+ import breakneck.conversions
13
+ import breakneck.track
14
+ from breakneck.base import Coords2D
15
+
16
+
17
+ def get_courtyard_shapes(
18
+ footprint: kipy.board_types.FootprintInstance,
19
+ layer: board_types_pb2.BoardLayer.ValueType,
20
+ ) -> list[kipy.board_types.BoardShape]:
21
+ """
22
+ Get the front or back courtyard shapes of a footprint
23
+ """
24
+ return [sh for sh in footprint.definition.shapes if sh.layer == layer]
25
+
26
+
27
+ def get_courtyard_polygons(
28
+ footprint: kipy.board_types.FootprintInstance,
29
+ ) -> tuple[sg.Polygon, sg.Polygon]:
30
+ """Get the front and back courtyard polygons of a footprint."""
31
+
32
+ f_crtyd_shapes = get_courtyard_shapes(footprint, BL.BL_F_CrtYd)
33
+ f_poly = shapely.union_all(breakneck.conversions.as_polygons(f_crtyd_shapes, 1000))
34
+ if f_poly.is_empty:
35
+ f_poly = sg.Polygon()
36
+ assert isinstance(f_poly, sg.Polygon)
37
+ b_crtyd_shapes = get_courtyard_shapes(footprint, BL.BL_B_CrtYd)
38
+ b_poly = shapely.union_all(breakneck.conversions.as_polygons(b_crtyd_shapes, 1000))
39
+ if b_poly.is_empty:
40
+ b_poly = sg.Polygon()
41
+ assert isinstance(b_poly, sg.Polygon)
42
+
43
+ return f_poly, b_poly
44
+
45
+
46
+ class BNFootprint:
47
+ def __init__(self, ref: str, footprint: kipy.board_types.FootprintInstance):
48
+ self.ref = ref
49
+ self.footprint = footprint
50
+ self.front_courtyard, self.back_courtyard = get_courtyard_polygons(footprint)
51
+ self.is_tht = False
52
+ pads = self.footprint.definition.pads
53
+ non_gnd_pads = [p for p in pads if p.net.name != "GND"]
54
+ if any(
55
+ [p for p in non_gnd_pads if p.pad_type == kipy.board_types.PadType.PT_PTH]
56
+ ):
57
+ self.is_tht = True
58
+ self.nets = list(set([p.net.name for p in pads]))
59
+ self._buffered_courtyards: dict[
60
+ tuple[int, breakneck.base.Sides], sg.Polygon
61
+ ] = {}
62
+
63
+ def buffer_courtyard(
64
+ self,
65
+ buffer_width_nm: int,
66
+ side: breakneck.base.Sides,
67
+ ) -> sg.Polygon:
68
+ if (buffer_width_nm, side) in self._buffered_courtyards:
69
+ return self._buffered_courtyards[(buffer_width_nm, side)]
70
+
71
+ if side == breakneck.base.Sides.front:
72
+ courtyard = self.front_courtyard
73
+ elif side == breakneck.base.Sides.back:
74
+ courtyard = self.back_courtyard
75
+ elif side == breakneck.base.Sides.both:
76
+ courtyard = shapely.union_all([self.front_courtyard, self.back_courtyard])
77
+ assert isinstance(courtyard, sg.Polygon)
78
+
79
+ buffered_courtyard = courtyard.buffer(
80
+ buffer_width_nm, cap_style="round", join_style="round", quad_segs=90
81
+ )
82
+
83
+ self._buffered_courtyards[(buffer_width_nm, side)] = buffered_courtyard
84
+
85
+ return buffered_courtyard
86
+
87
+ def _is_likely_origin(
88
+ self,
89
+ track: kipy.board_types.Track | kipy.board_types.ArcTrack,
90
+ linestring: sg.LineString,
91
+ ) -> bool:
92
+ """
93
+ Check if a track is likely to originate from the footprint.
94
+
95
+ A track is likely to originate from the footprint if it crosses the respective couryard and shares a net with a pad.
96
+ """
97
+ if track.net.name not in self.nets:
98
+ return False
99
+
100
+ if self.is_tht:
101
+ courtyard = shapely.union_all([self.front_courtyard, self.back_courtyard])
102
+ assert isinstance(courtyard, sg.Polygon)
103
+ elif track.layer == BL.BL_F_Cu:
104
+ courtyard = self.front_courtyard
105
+ elif track.layer == BL.BL_B_Cu:
106
+ courtyard = self.back_courtyard
107
+ else:
108
+ return False
109
+
110
+ if courtyard.intersects(linestring):
111
+ return True
112
+
113
+ return False
114
+
115
+ def _sort_cut_points(
116
+ self, cut_points: list[sg.Point], linestring: sg.LineString
117
+ ) -> list[sg.Point]:
118
+ """
119
+ Sort cut points along the linestring.
120
+ """
121
+ return sorted(
122
+ cut_points,
123
+ key=lambda p: linestring.line_locate_point(p),
124
+ )
125
+
126
+ def break_tracks(
127
+ self,
128
+ tracks: Sequence[kipy.board_types.Track | kipy.board_types.ArcTrack],
129
+ bounding_box_width: int,
130
+ neck_length: int,
131
+ padding: int = 10000,
132
+ ) -> tuple[
133
+ list[kipy.board_types.Track | kipy.board_types.ArcTrack],
134
+ list[kipy.board_types.Track | kipy.board_types.ArcTrack],
135
+ ]:
136
+ """
137
+ Break tracks crossing the courtyards of this footprint.
138
+ """
139
+
140
+ items_to_remove = []
141
+ items_to_create = []
142
+
143
+ # Get the bounding box hits for tracks
144
+
145
+ front_bounding_box = self.front_courtyard.envelope.buffer(bounding_box_width)
146
+ back_bounding_box = self.back_courtyard.envelope.buffer(bounding_box_width)
147
+
148
+ front_track_tree = breakneck.track.TrackTree(
149
+ [t for t in tracks if t.layer == BL.BL_F_Cu]
150
+ )
151
+ back_track_tree = breakneck.track.TrackTree(
152
+ [t for t in tracks if t.layer == BL.BL_B_Cu]
153
+ )
154
+ front_hits = front_track_tree.intersects(front_bounding_box)
155
+ back_hits = back_track_tree.intersects(back_bounding_box)
156
+ if self.is_tht:
157
+ back_hits += back_track_tree.intersects(front_bounding_box)
158
+ front_hits += front_track_tree.intersects(back_bounding_box)
159
+
160
+ hits = front_hits + back_hits
161
+
162
+ logger.debug(f"Found {len(hits)} tracks crossing the courtyards.")
163
+
164
+ # If there are no hits, return early
165
+
166
+ if not hits:
167
+ return items_to_remove, items_to_create
168
+
169
+ bb_tracks, linestrings = zip(*hits)
170
+ for track, linestring in zip(bb_tracks, linestrings):
171
+ assert isinstance(linestring, sg.LineString)
172
+ assert isinstance(track, kipy.board_types.Track) or isinstance(
173
+ track, kipy.board_types.ArcTrack
174
+ )
175
+ if self.is_tht:
176
+ side = breakneck.base.Sides.both
177
+ elif track.layer == BL.BL_F_Cu:
178
+ side = breakneck.base.Sides.front
179
+ elif track.layer == BL.BL_B_Cu:
180
+ side = breakneck.base.Sides.back
181
+ else:
182
+ raise ValueError(f"Unexpected track layer: {track.layer}")
183
+
184
+ if self._is_likely_origin(track, linestring):
185
+ # Find possible cut points at the neck length
186
+ neck_buffer = self.buffer_courtyard(
187
+ neck_length + track.width // 2 + padding, side
188
+ )
189
+ cut_points = neck_buffer.boundary.intersection(linestring)
190
+ logger.debug(f"Cut points: {cut_points}")
191
+ else:
192
+ # Find possible cut points at the courtyard boundary
193
+ cut_points = self.buffer_courtyard(
194
+ track.width // 2 + padding, side
195
+ ).boundary.intersection(linestring)
196
+
197
+ # Order the found points along the track
198
+ track_points = [] # crossing points
199
+ if not cut_points.is_empty:
200
+ if isinstance(cut_points, sg.Point):
201
+ points = [Coords2D(int(cut_points.x), int(cut_points.y))]
202
+ elif isinstance(cut_points, sg.MultiPoint):
203
+ points = [Coords2D(int(p.x), int(p.y)) for p in cut_points.geoms]
204
+ else:
205
+ raise ValueError(f"Unexpected geometry type: {type(cut_points)}")
206
+ track_points.extend(points)
207
+ if not track_points:
208
+ continue
209
+
210
+ logger.debug(f"Track points: {track_points}")
211
+
212
+ new_tracks = breakneck.track.break_track(track, track_points)
213
+ items_to_remove.append(track)
214
+ items_to_create.extend(new_tracks)
215
+
216
+ return items_to_remove, items_to_create
217
+
218
+
219
+ def get_bn_footprints(
220
+ footprints: Sequence[kipy.board_types.FootprintInstance],
221
+ ) -> list[BNFootprint,]:
222
+ """Get BNFootprint objects of all footprints on a board."""
223
+
224
+ fpcs: list[BNFootprint] = []
225
+
226
+ for fp in footprints:
227
+ fpcs.append(
228
+ BNFootprint(
229
+ ref=fp.reference_field.text.value,
230
+ footprint=fp,
231
+ )
232
+ )
233
+
234
+ return fpcs
@@ -0,0 +1,32 @@
1
+ import pytest
2
+ import shapely.geometry as sg
3
+ from polyfinder import find_polygons
4
+
5
+
6
+ def test_find_polygons():
7
+ segments = [
8
+ # First ring
9
+ sg.LineString([(0, 0), (1, 0)]),
10
+ sg.LineString([(1, 0), (1, 1)]),
11
+ sg.LineString([(1, 1), (0, 1)]),
12
+ sg.LineString([(0, 1), (0, 0)]),
13
+ # Second ring
14
+ sg.LineString([(2, 2), (3, 2)]),
15
+ sg.LineString([(3, 2), (3, 3)]),
16
+ sg.LineString([(3, 3), (2, 3)]),
17
+ sg.LineString([(2, 3), (2, 2)]),
18
+ ]
19
+
20
+ linear_rings = find_polygons(segments)
21
+ expected_rings = [
22
+ sg.LinearRing([(0, 0), (1, 0), (1, 1), (0, 1), (0, 0)]),
23
+ sg.LinearRing([(2, 2), (3, 2), (3, 3), (2, 3), (2, 2)]),
24
+ ]
25
+
26
+ assert len(linear_rings) == len(expected_rings)
27
+ for ring, expected_ring in zip(linear_rings, expected_rings):
28
+ assert ring.equals(expected_ring)
29
+
30
+
31
+ if __name__ == "__main__":
32
+ pytest.main()
breakneck/track.py ADDED
@@ -0,0 +1,222 @@
1
+ import math
2
+ import uuid
3
+ from collections.abc import Iterable, Sequence
4
+
5
+ import kipy.board
6
+ import kipy.board_types
7
+ import kipy.common_types
8
+ import kipy.proto.board.board_types_pb2 as board_types_pb2
9
+ import kipy.proto.common.types.base_types_pb2 as base_types_pb2
10
+ import shapely
11
+ import shapely.geometry as sg
12
+
13
+ import breakneck.conversions
14
+ import breakneck.footprint
15
+ from breakneck.base import Coords2D
16
+
17
+
18
+ def round_track_width(width_nm: int, tol_nm=10000) -> int:
19
+ return tol_nm * int(round(width_nm / tol_nm))
20
+
21
+
22
+ def get_unique_track_widths(
23
+ tracks: Iterable[kipy.board_types.Track | kipy.board_types.ArcTrack], tol_nm=10000
24
+ ) -> list[int]:
25
+ """Get tolerance-rounded unique track widths from a list of tracks."""
26
+ track_widths = set()
27
+
28
+ for track in tracks:
29
+ track_widths.add(round_track_width(track.width, tol_nm))
30
+
31
+ return sorted(list(track_widths))
32
+
33
+
34
+ def get_max_track_width(
35
+ tracks: Iterable[kipy.board_types.Track | kipy.board_types.ArcTrack],
36
+ ) -> int:
37
+ return max([track.width for track in tracks])
38
+
39
+
40
+ def break_track_segment(
41
+ track: kipy.board_types.Track, points: list[Coords2D]
42
+ ) -> list[kipy.board_types.Track]:
43
+ """
44
+ Break a track segment at points
45
+ """
46
+
47
+ if not points:
48
+ raise ValueError("No points to break track")
49
+
50
+ start = breakneck.conversions.vector2_as_coords(track.start)
51
+ end = breakneck.conversions.vector2_as_coords(track.end)
52
+
53
+ current = start
54
+
55
+ new_tracks = []
56
+
57
+ even = False
58
+ for point in points + [end]:
59
+ new_track_proto = board_types_pb2.Track(
60
+ id=base_types_pb2.KIID(value=str(uuid.uuid4())),
61
+ )
62
+ new_track = kipy.board_types.Track(new_track_proto)
63
+
64
+ new_track.start = breakneck.conversions.coords_as_vector2(current)
65
+ new_track.end = breakneck.conversions.coords_as_vector2(point)
66
+
67
+ new_track.width = track.width
68
+ new_track.net = track.net
69
+ new_track.layer = track.layer
70
+
71
+ # bump even segment widths by 1 nm down
72
+ if even:
73
+ new_track.width -= 1
74
+ even = not even
75
+
76
+ if (abs(new_track.start.x - new_track.end.x) > 10) or (
77
+ abs(new_track.start.y - new_track.end.y) > 10
78
+ ):
79
+ new_tracks.append(new_track)
80
+
81
+ current = point
82
+
83
+ return new_tracks
84
+
85
+
86
+ def _calculate_angle(center: Coords2D, point: Coords2D) -> float:
87
+ return math.atan2(point.y - center.y, point.x - center.x)
88
+
89
+
90
+ def _calculate_midpoint(
91
+ center: Coords2D,
92
+ start_angle: float,
93
+ end_angle: float,
94
+ radius: float,
95
+ clockwise: bool,
96
+ ) -> Coords2D:
97
+ """
98
+ Calculate the midpoint of an arc
99
+ """
100
+ if clockwise:
101
+ if start_angle < end_angle:
102
+ start_angle += 2 * math.pi
103
+ else:
104
+ if end_angle < start_angle:
105
+ end_angle += 2 * math.pi
106
+ mid_angle = (start_angle + end_angle) / 2
107
+ return Coords2D(
108
+ int(center.x + radius * math.cos(mid_angle)),
109
+ int(center.y + radius * math.sin(mid_angle)),
110
+ )
111
+
112
+
113
+ def _is_arc_clockwise(
114
+ start: Coords2D,
115
+ mid: Coords2D,
116
+ end: Coords2D,
117
+ center: Coords2D,
118
+ ) -> bool:
119
+ """
120
+ Determine if an arc is clockwise or counterclockwise
121
+ """
122
+ # Calculate vectors from center to start, mid, and end points
123
+ vector_start = (start.x - center.x, start.y - center.y)
124
+ vector_mid = (mid.x - center.x, mid.y - center.y)
125
+ vector_end = (end.x - center.x, end.y - center.y)
126
+
127
+ # Calculate cross product of vectors (start to mid) and (mid to end)
128
+ cross_product = (vector_mid[0] - vector_start[0]) * (
129
+ vector_end[1] - vector_mid[1]
130
+ ) - (vector_mid[1] - vector_start[1]) * (vector_end[0] - vector_mid[0])
131
+
132
+ # If cross product is negative, the arc is clockwise
133
+ return cross_product < 0
134
+
135
+
136
+ def break_arc_track(
137
+ track: kipy.board_types.ArcTrack, points: list[Coords2D]
138
+ ) -> list[kipy.board_types.Track]:
139
+ """
140
+ Break an arc track at points
141
+ """
142
+
143
+ if not points:
144
+ raise ValueError("No points to break track")
145
+
146
+ start = breakneck.conversions.vector2_as_coords(track.start)
147
+ mid = breakneck.conversions.vector2_as_coords(track.mid)
148
+ end = breakneck.conversions.vector2_as_coords(track.end)
149
+ center_vector = track.center()
150
+ assert center_vector is not None
151
+ center = breakneck.conversions.vector2_as_coords(center_vector)
152
+ radius = int(track.radius())
153
+ clockwise = _is_arc_clockwise(start, mid, end, center)
154
+
155
+ current = start
156
+ new_tracks = []
157
+
158
+ even = False
159
+ for point in points + [end]:
160
+ start_angle = _calculate_angle(center, current)
161
+ end_angle = _calculate_angle(center, point)
162
+ mid_point = _calculate_midpoint(
163
+ center, start_angle, end_angle, radius, clockwise
164
+ )
165
+ new_track_proto = board_types_pb2.Arc(
166
+ id=base_types_pb2.KIID(value=str(uuid.uuid4())),
167
+ )
168
+
169
+ new_track = kipy.board_types.ArcTrack(new_track_proto)
170
+ new_track.start = breakneck.conversions.coords_as_vector2(current)
171
+ new_track.mid = breakneck.conversions.coords_as_vector2(mid_point)
172
+ new_track.end = breakneck.conversions.coords_as_vector2(point)
173
+ new_track.width = track.width
174
+ new_track.net = track.net
175
+ new_track.layer = track.layer
176
+
177
+ # bump even segment widths down by 1 nm
178
+ if even:
179
+ new_track.width -= 1
180
+ even = not even
181
+
182
+ if (abs(new_track.start.x - new_track.end.x) > 10) or (
183
+ abs(new_track.start.y - new_track.end.y) > 10
184
+ ):
185
+ new_tracks.append(new_track)
186
+ current = point
187
+
188
+ return new_tracks
189
+
190
+
191
+ def break_track(
192
+ track: kipy.board_types.Track | kipy.board_types.ArcTrack, points: list[Coords2D]
193
+ ) -> Sequence[kipy.board_types.Track | kipy.board_types.ArcTrack]:
194
+ """
195
+ Break a track at points
196
+ """
197
+ if isinstance(track, kipy.board_types.ArcTrack):
198
+ return break_arc_track(track, points)
199
+ else:
200
+ return break_track_segment(track, points)
201
+
202
+
203
+ class TrackTree:
204
+ def __init__(
205
+ self, tracks: Iterable[kipy.board_types.Track | kipy.board_types.ArcTrack]
206
+ ):
207
+ self._tracks = list(tracks)
208
+ self._track_linestrings = [
209
+ breakneck.conversions.track_as_linestring(track) for track in self._tracks
210
+ ]
211
+ self._track_tree = shapely.STRtree(self._track_linestrings)
212
+
213
+ def intersects(
214
+ self, courtyard: shapely.geometry.Polygon
215
+ ) -> list[tuple[kipy.board_types.Track | kipy.board_types.ArcTrack, sg.LineString]]:
216
+ """
217
+ Return tracks that intersect with the given courtyard
218
+ """
219
+ intersect_indices = self._track_tree.query(courtyard, predicate="intersects")
220
+ return [
221
+ (self._tracks[i], self._track_linestrings[i]) for i in intersect_indices
222
+ ]
@@ -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,12 @@
1
+ breakneck/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ breakneck/base.py,sha256=QPDxDuz4vRSmW81amH5kV8ZrJK3Mmql_hl2tUH4tHX0,1012
3
+ breakneck/cli.py,sha256=zM5xIgWMQhRGw7HypJM0IRX0AH5GqTN28uTQrq6ugYI,8763
4
+ breakneck/conversions.py,sha256=NppAcBkEG6LnriU-a-hsCpUWFL4iLlJlYVR7NnLuh3M,11400
5
+ breakneck/footprint.py,sha256=21eUeuQMTEIT1CZ890DVWXamLhRZOlIL3QpAxNGRKH8,8298
6
+ breakneck/test_polyfinder.py,sha256=TPBpf0eIuweTB4XwgXK39cz91e-KLA0PzmqkdQTjOgo,914
7
+ breakneck/track.py,sha256=QA4fuBk8uKJ046kuRX4zfmZ3cQ8i7WZo4m7KOaiJ4uQ,6747
8
+ breakneck-0.2.1.dist-info/METADATA,sha256=IIj9ysuY7VBQOd3ZN_JGs0Lt6Gq_pfG694LnhYvq0j0,4528
9
+ breakneck-0.2.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
10
+ breakneck-0.2.1.dist-info/entry_points.txt,sha256=REziUZZL6n8Z4dGEJKRzEMAFpa3v5g6Afzrswg1sRkQ,49
11
+ breakneck-0.2.1.dist-info/licenses/LICENSE,sha256=K5Wr24wTv2iYs_vlVUMgUvSAR9nrHjk4FE0xVHUndQA,1065
12
+ breakneck-0.2.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ breakneck = breakneck.cli:main
@@ -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.