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 +0 -0
- breakneck/base.py +48 -0
- breakneck/cli.py +305 -0
- breakneck/conversions.py +370 -0
- breakneck/footprint.py +234 -0
- breakneck/test_polyfinder.py +32 -0
- breakneck/track.py +222 -0
- breakneck-0.2.1.dist-info/METADATA +92 -0
- breakneck-0.2.1.dist-info/RECORD +12 -0
- breakneck-0.2.1.dist-info/WHEEL +4 -0
- breakneck-0.2.1.dist-info/entry_points.txt +2 -0
- breakneck-0.2.1.dist-info/licenses/LICENSE +21 -0
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()
|
breakneck/conversions.py
ADDED
|
@@ -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
|
+

|
|
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,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.
|