-
Notifications
You must be signed in to change notification settings - Fork 13
Expand file tree
/
Copy pathcheck_connected.py
More file actions
724 lines (620 loc) · 29.9 KB
/
check_connected.py
File metadata and controls
724 lines (620 loc) · 29.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
"""
Connectivity Checker - Verify that tracks form fully connected routes from source to target pads.
"""
import sys
import argparse
import math
import fnmatch
from typing import List, Dict, Set, Tuple, Optional
from collections import defaultdict
from kicad_parser import parse_kicad_pcb, Segment, Via, Pad, PCBData, Zone
from net_queries import expand_pad_layers
def point_in_polygon(x: float, y: float, polygon: List[Tuple[float, float]]) -> bool:
"""Check if a point (x, y) is inside a polygon using ray casting algorithm.
Args:
x, y: Point coordinates
polygon: List of (x, y) vertices defining the polygon
Returns:
True if point is inside the polygon
"""
n = len(polygon)
if n < 3:
return False
inside = False
j = n - 1
for i in range(n):
xi, yi = polygon[i]
xj, yj = polygon[j]
# Check if ray from point crosses this edge
if ((yi > y) != (yj > y)) and (x < (xj - xi) * (y - yi) / (yj - yi) + xi):
inside = not inside
j = i
return inside
def matches_any_pattern(name: str, patterns: List[str]) -> bool:
"""Check if a net name matches any of the given patterns (fnmatch style)."""
for pattern in patterns:
if fnmatch.fnmatch(name, pattern):
return True
return False
from geometry_utils import UnionFind, point_key
def points_match(x1: float, y1: float, x2: float, y2: float, tolerance: float = 0.02) -> bool:
"""Check if two points are within tolerance."""
return abs(x1 - x2) < tolerance and abs(y1 - y2) < tolerance
class SpatialIndex:
"""Grid-based spatial index for fast proximity queries."""
def __init__(self, cell_size: float = 1.0):
self.cell_size = cell_size
self.grid = defaultdict(list) # (gx, gy, layer) -> list of (x, y, point_id, size)
def _cell(self, x: float, y: float) -> Tuple[int, int]:
return (int(x // self.cell_size), int(y // self.cell_size))
def add(self, x: float, y: float, layer: str, point_id: int, size: float):
gx, gy = self._cell(x, y)
self.grid[(gx, gy, layer)].append((x, y, point_id, size))
def query_nearby(self, x: float, y: float, layer: str, radius: float):
"""Return all points within radius on the same layer."""
gx, gy = self._cell(x, y)
# Check neighboring cells based on radius
cells_to_check = int(radius / self.cell_size) + 1
results = []
for dx in range(-cells_to_check, cells_to_check + 1):
for dy in range(-cells_to_check, cells_to_check + 1):
for pt in self.grid.get((gx + dx, gy + dy, layer), []):
results.append(pt)
return results
class SegmentIndex:
"""Grid-based spatial index for segments."""
def __init__(self, cell_size: float = 1.0):
self.cell_size = cell_size
self.grid = defaultdict(list) # (gx, gy, layer) -> list of (seg, seg_start_id)
def _cells_for_segment(self, seg) -> List[Tuple[int, int]]:
"""Return all grid cells that a segment passes through."""
x1, y1, x2, y2 = seg.start_x, seg.start_y, seg.end_x, seg.end_y
gx1, gy1 = int(min(x1, x2) // self.cell_size), int(min(y1, y2) // self.cell_size)
gx2, gy2 = int(max(x1, x2) // self.cell_size), int(max(y1, y2) // self.cell_size)
cells = []
for gx in range(gx1, gx2 + 1):
for gy in range(gy1, gy2 + 1):
cells.append((gx, gy))
return cells
def add(self, seg, seg_start_id: int):
for gx, gy in self._cells_for_segment(seg):
self.grid[(gx, gy, seg.layer)].append((seg, seg_start_id))
def query_at(self, x: float, y: float, layer: str):
"""Return all segments that might contain point (x, y) on layer."""
gx, gy = int(x // self.cell_size), int(y // self.cell_size)
return self.grid.get((gx, gy, layer), [])
def point_on_segment(px: float, py: float, x1: float, y1: float, x2: float, y2: float, tolerance: float = 0.02) -> bool:
"""Check if point (px, py) lies on the segment from (x1, y1) to (x2, y2) within tolerance.
This detects T-junctions where a track endpoint meets another track mid-segment.
"""
# Quick bounding box check first (with tolerance)
min_x, max_x = (min(x1, x2) - tolerance, max(x1, x2) + tolerance)
min_y, max_y = (min(y1, y2) - tolerance, max(y1, y2) + tolerance)
if not (min_x <= px <= max_x and min_y <= py <= max_y):
return False
# Calculate distance from point to line segment
# Vector from segment start to end
dx = x2 - x1
dy = y2 - y1
# Handle degenerate case (zero-length segment)
seg_len_sq = dx * dx + dy * dy
if seg_len_sq < 1e-10:
return points_match(px, py, x1, y1, tolerance)
# Project point onto line, clamped to segment
t = max(0, min(1, ((px - x1) * dx + (py - y1) * dy) / seg_len_sq))
# Closest point on segment
closest_x = x1 + t * dx
closest_y = y1 + t * dy
# Check if point is within tolerance of the closest point on segment
dist_sq = (px - closest_x) ** 2 + (py - closest_y) ** 2
return dist_sq <= tolerance * tolerance
def check_net_connectivity(net_id: int, segments: List[Segment], vias: List[Via],
pads: List[Pad], zones: List[Zone] = None,
tolerance: float = 0.02,
verbose: bool = False) -> Dict:
"""Check connectivity for a single net.
Args:
net_id: The net ID being checked
segments: Track segments belonging to this net
vias: Vias belonging to this net
pads: Pads belonging to this net
zones: Zones (power planes) belonging to this net
tolerance: Connection tolerance in mm
verbose: If True, include detailed debug info
Returns dict with:
- connected: bool - whether all pads are connected
- num_components: int - number of disconnected components
- pad_components: dict mapping pad location to component id
- disconnected_pads: list of pad locations not connected to the main component
- debug_info: dict with detailed component info (when verbose=True)
"""
if zones is None:
zones = []
uf = UnionFind()
# Detect all copper layers from segments, vias, and pads
copper_layer_set = set()
for seg in segments:
if seg.layer.endswith('.Cu'):
copper_layer_set.add(seg.layer)
for via in vias:
if via.layers:
for layer in via.layers:
if layer.endswith('.Cu'):
copper_layer_set.add(layer)
for pad in pads:
for layer in pad.layers:
# Skip wildcards like "*.Cu" - they don't represent actual layers
if layer.endswith('.Cu') and not layer.startswith('*'):
copper_layer_set.add(layer)
for zone in zones:
if zone.layer.endswith('.Cu'):
copper_layer_set.add(zone.layer)
# Sort layers: F.Cu first, then In*.Cu in order, then B.Cu last
def layer_sort_key(layer):
if layer == 'F.Cu':
return (0, 0)
elif layer == 'B.Cu':
return (2, 0)
elif layer.startswith('In') and layer.endswith('.Cu'):
try:
num = int(layer[2:-3]) # Extract number from 'InX.Cu'
return (1, num)
except ValueError:
return (1, 999)
else:
return (3, 0)
all_copper_layers = sorted(copper_layer_set, key=layer_sort_key)
if not all_copper_layers:
all_copper_layers = ['F.Cu', 'In1.Cu', 'In2.Cu', 'B.Cu'] # Fallback
# Collect all points with their actual coordinates and size info
# Each point: (x, y, layer, point_id, size, type, extra_info)
all_points = []
point_id = 0
point_info = {} # Maps point_id to descriptive info
# Add segment endpoints
for seg_idx, seg in enumerate(segments):
start_id = point_id
all_points.append((seg.start_x, seg.start_y, seg.layer, start_id, seg.width))
point_info[start_id] = ('segment_start', seg_idx, seg.layer, seg.start_x, seg.start_y)
point_id += 1
end_id = point_id
all_points.append((seg.end_x, seg.end_y, seg.layer, end_id, seg.width))
point_info[end_id] = ('segment_end', seg_idx, seg.layer, seg.end_x, seg.end_y)
point_id += 1
# Connect segment's own endpoints
uf.union(start_id, end_id)
# Add vias - they connect all layers at one location
for via_idx, via in enumerate(vias):
if via.layers:
if 'F.Cu' in via.layers and 'B.Cu' in via.layers:
via_layers = all_copper_layers
else:
via_layers = via.layers
else:
via_layers = all_copper_layers
via_size = getattr(via, 'size', 0.6) # Default via size if not available
via_ids = []
for layer in via_layers:
all_points.append((via.x, via.y, layer, point_id, via_size))
point_info[point_id] = ('via', via_idx, layer, via.x, via.y)
via_ids.append(point_id)
point_id += 1
# Connect all via layers together
for vid in via_ids[1:]:
uf.union(via_ids[0], vid)
# Add pads (use a reasonable default size for pads)
# Through-hole pads span multiple layers and should connect them (like vias)
pad_ids = []
pad_locations = []
copper_layers = set(all_copper_layers)
for pad_idx, pad in enumerate(pads):
# Expand wildcard layers like "*.Cu" to actual copper layers
expanded_layers = expand_pad_layers(pad.layers, all_copper_layers)
this_pad_ids = [] # Track all layer points for this pad
for layer in expanded_layers:
if layer not in copper_layers:
continue
pad_size = 0.4 # Default pad connection tolerance
all_points.append((pad.global_x, pad.global_y, layer, point_id, pad_size))
point_info[point_id] = ('pad', pad_idx, layer, pad.global_x, pad.global_y, pad.component_ref)
pad_ids.append(point_id)
pad_locations.append((pad.global_x, pad.global_y, layer, pad.component_ref))
this_pad_ids.append(point_id)
point_id += 1
# Connect all layers of this pad together (through-hole pads act like vias)
for pid in this_pad_ids[1:]:
uf.union(this_pad_ids[0], pid)
# Connect points through zones (power planes)
# All points on the same layer that are inside the same zone are connected
for zone in zones:
zone_layer = zone.layer
# Find all points on this zone's layer
points_on_layer = [(x, y, layer, pid, size) for x, y, layer, pid, size in all_points
if layer == zone_layer]
# Find which points are inside the zone polygon
points_in_zone = []
for x, y, layer, pid, size in points_on_layer:
if point_in_polygon(x, y, zone.polygon):
points_in_zone.append(pid)
# Connect all points inside this zone together
if len(points_in_zone) > 1:
for pid in points_in_zone[1:]:
uf.union(points_in_zone[0], pid)
# Build spatial index for points (use 1mm grid cells)
point_index = SpatialIndex(cell_size=1.0)
for x, y, layer, pid, size in all_points:
point_index.add(x, y, layer, pid, size)
# Connect all points that are within tolerance on the same layer
# Use spatial index for O(n) average instead of O(n²)
max_tolerance = 1.0 # Maximum possible tolerance (size/4 capped at reasonable value)
for x1, y1, l1, id1, size1 in all_points:
# Query nearby points on same layer
for x2, y2, id2, size2 in point_index.query_nearby(x1, y1, l1, max_tolerance):
if id2 <= id1: # Avoid duplicate checks
continue
# Use max(size1, size2) / 4, but at least the minimum tolerance
point_tolerance = max(max(size1, size2) / 4, tolerance)
if points_match(x1, y1, x2, y2, point_tolerance):
uf.union(id1, id2)
# Build spatial index for segments
seg_index = SegmentIndex(cell_size=1.0)
for seg_idx, seg in enumerate(segments):
seg_start_id = seg_idx * 2
seg_index.add(seg, seg_start_id)
# Check for T-junctions: points that lie on the middle of a segment (same layer)
# Use spatial index for O(n) average instead of O(n × m)
for px, py, player, pid, psize in all_points:
# Query segments that might contain this point
for seg, seg_start_id in seg_index.query_at(px, py, player):
seg_end_id = seg_start_id + 1
# Skip if this point IS one of the segment's endpoints
if pid == seg_start_id or pid == seg_end_id:
continue
# Check if point lies on this segment
seg_tolerance = max(seg.width / 2, tolerance)
if point_on_segment(px, py, seg.start_x, seg.start_y, seg.end_x, seg.end_y, seg_tolerance):
# Connect this point to the segment (via one of its endpoints)
uf.union(pid, seg_start_id)
# Check if all pads are in the same component
if not pad_ids:
return {
'connected': True,
'num_components': 0,
'pad_components': {},
'disconnected_pads': [],
'message': 'No pads found for this net',
'debug_info': None
}
pad_roots = [uf.find(pid) for pid in pad_ids]
unique_roots = set(pad_roots)
# Find which pads are disconnected from the main group
root_counts = defaultdict(int)
for root in pad_roots:
root_counts[root] += 1
main_root = max(root_counts.keys(), key=lambda r: root_counts[r])
disconnected = []
seen_pads = set() # Track (x, y, component_ref) to avoid duplicates for through-hole pads
for pid, loc in zip(pad_ids, pad_locations):
if uf.find(pid) != main_root:
# For through-hole pads, only report once (not per layer)
pad_key = (round(loc[0], 4), round(loc[1], 4), loc[3]) # (x, y, component_ref)
if pad_key not in seen_pads:
seen_pads.add(pad_key)
disconnected.append(loc)
# Build debug info if verbose
debug_info = None
if verbose and len(unique_roots) > 1:
# Group all points by component
components = defaultdict(list)
for pt in all_points:
x, y, layer, pid, size = pt
root = uf.find(pid)
info = point_info.get(pid, ('unknown',))
components[root].append((x, y, layer, info))
# For each component, find boundary points (potential disconnection points)
component_summaries = {}
for root, points in components.items():
by_layer = defaultdict(list)
for x, y, layer, info in points:
by_layer[layer].append((x, y, info))
# Summarize component
summary = {
'layers': list(by_layer.keys()),
'points_by_layer': {l: len(pts) for l, pts in by_layer.items()},
'has_pads': any(info[0] == 'pad' for _, _, _, info in points),
'has_vias': any(info[0] == 'via' for _, _, _, info in points),
'sample_points': [(x, y, layer, info[0]) for x, y, layer, info in points[:10]]
}
component_summaries[root] = summary
debug_info = {
'components': component_summaries,
'component_points': dict(components), # Raw points by component for gap analysis
'main_root': main_root,
'all_points': all_points,
'point_info': point_info,
'segments': segments,
'vias': vias
}
return {
'connected': len(unique_roots) == 1,
'num_components': len(unique_roots),
'pad_components': {loc: uf.find(pid) for pid, loc in zip(pad_ids, pad_locations)},
'disconnected_pads': disconnected,
'message': None,
'debug_info': debug_info
}
def find_gap_between_components(debug_info: Dict, tolerance: float) -> Optional[Dict]:
"""Analyze debug info to find where the gap is between disconnected components.
Returns information about the likely disconnection point.
"""
if not debug_info:
return None
component_summaries = debug_info['components']
component_points = debug_info.get('component_points', {})
all_points = debug_info['all_points']
segments = debug_info['segments']
vias = debug_info['vias']
if len(component_points) < 2:
return None
# Find the closest points between different components
min_dist = float('inf')
gap_info = None
roots = list(component_points.keys())
for i, root1 in enumerate(roots):
for root2 in roots[i+1:]:
for pt1 in component_points[root1]:
x1, y1, l1, info1 = pt1[0], pt1[1], pt1[2], pt1[3]
for pt2 in component_points[root2]:
x2, y2, l2, info2 = pt2[0], pt2[1], pt2[2], pt2[3]
# Only compare points on the same layer (that's where connections happen)
if l1 != l2:
continue
dist = math.sqrt((x1 - x2) ** 2 + (y1 - y2) ** 2)
if dist < min_dist:
min_dist = dist
info1_type = info1[0] if isinstance(info1, tuple) else info1
info2_type = info2[0] if isinstance(info2, tuple) else info2
gap_info = {
'distance': dist,
'point1': (x1, y1, l1, info1_type),
'point2': (x2, y2, l2, info2_type),
'component1_root': root1,
'component2_root': root2
}
# Also find if there are points on different layers at the same position
# (missing via situation)
for root1 in roots:
for root2 in roots:
if root1 >= root2:
continue
for pt1 in component_points[root1]:
x1, y1, l1, info1 = pt1[0], pt1[1], pt1[2], pt1[3]
for pt2 in component_points[root2]:
x2, y2, l2, info2 = pt2[0], pt2[1], pt2[2], pt2[3]
if l1 == l2:
continue
if abs(x1 - x2) < tolerance and abs(y1 - y2) < tolerance:
# Points at same position but different layers and not connected
info1_type = info1[0] if isinstance(info1, tuple) else info1
info2_type = info2[0] if isinstance(info2, tuple) else info2
return {
'type': 'missing_via',
'position': (x1, y1),
'layers': [l1, l2],
'point1_type': info1_type,
'point2_type': info2_type,
'message': f"Gap at ({x1:.3f}, {y1:.3f}): {info1_type} on {l1} not connected to {info2_type} on {l2}"
}
if gap_info:
gap_info['type'] = 'gap_on_layer'
gap_info['message'] = (
f"Nearest gap: {gap_info['distance']:.3f}mm on {gap_info['point1'][2]} between "
f"{gap_info['point1'][3]} at ({gap_info['point1'][0]:.3f}, {gap_info['point1'][1]:.3f}) and "
f"{gap_info['point2'][3]} at ({gap_info['point2'][0]:.3f}, {gap_info['point2'][1]:.3f})"
)
return gap_info
def run_connectivity_check(pcb_file: str, net_patterns: Optional[List[str]] = None,
tolerance: float = 0.02, quiet: bool = False,
verbose: bool = False, component: Optional[str] = None,
routed_only: bool = False) -> List[Dict]:
"""Run connectivity checks on the PCB file.
Args:
pcb_file: Path to the KiCad PCB file
net_patterns: Optional list of net name patterns (fnmatch style) to check.
tolerance: Minimum connection tolerance in mm (default: 0.02mm)
quiet: If True, only print a summary line unless there are issues
verbose: If True, show detailed info about where breaks are
component: Optional component reference to filter nets (e.g., "U1")
routed_only: If True, only check routed nets (skip unrouted net detection)
Returns:
List of connectivity issues found
"""
if quiet and (net_patterns or component):
# Print a brief summary line in quiet mode
desc = ', '.join(net_patterns) if net_patterns else f"component {component}"
print(f"Checking {desc} for connectivity...", end=" ", flush=True)
elif not quiet:
print(f"Loading {pcb_file}...")
pcb_data = parse_kicad_pcb(pcb_file)
if not quiet:
total_pads = sum(len(pads) for pads in pcb_data.pads_by_net.values())
zone_info = f", {len(pcb_data.zones)} zones" if pcb_data.zones else ""
print(f"Found {len(pcb_data.segments)} segments, {len(pcb_data.vias)} vias, {total_pads} pads{zone_info}")
# Group segments by net
segments_by_net = defaultdict(list)
for seg in pcb_data.segments:
segments_by_net[seg.net_id].append(seg)
# Group vias by net
vias_by_net = defaultdict(list)
for via in pcb_data.vias:
vias_by_net[via.net_id].append(via)
# Group zones by net
zones_by_net = defaultdict(list)
for zone in pcb_data.zones:
zones_by_net[zone.net_id].append(zone)
# Use existing pads_by_net from pcb_data
pads_by_net = pcb_data.pads_by_net
# Filter by component if specified
component_net_ids = None
if component:
component_net_ids = set()
for net_id, pads in pads_by_net.items():
for pad in pads:
if pad.component_ref == component:
component_net_ids.add(net_id)
break
if not quiet:
print(f"Found {len(component_net_ids)} nets on component {component}")
# Determine which nets to check
nets_to_check = []
for net_id, net_info in pcb_data.nets.items():
# Filter by component if specified
if component_net_ids is not None and net_id not in component_net_ids:
continue
if net_patterns:
if matches_any_pattern(net_info.name, net_patterns):
nets_to_check.append((net_id, net_info.name))
elif component_net_ids is not None:
# Component specified but no patterns - check all component nets with segments or pads
if net_id in segments_by_net or net_id in pads_by_net:
nets_to_check.append((net_id, net_info.name))
else:
# Only check nets that have both segments and pads
if net_id in segments_by_net and net_id in pads_by_net:
nets_to_check.append((net_id, net_info.name))
# Find unrouted nets (pads but no segments) unless routed_only
unrouted_nets = []
if not routed_only:
for net_id, net_info in pcb_data.nets.items():
# Skip power nets (GND, VCC, etc.) - they're often connected via zones
if net_info.name in ('', 'GND', 'VCC', '+5V', '+3V3', '+3.3V'):
continue
# Filter by component if specified
if component_net_ids is not None and net_id not in component_net_ids:
continue
# Filter by pattern if specified
if net_patterns and not matches_any_pattern(net_info.name, net_patterns):
continue
# Check if net has pads but no segments
has_pads = net_id in pads_by_net and len(pads_by_net[net_id]) >= 2
has_segments = net_id in segments_by_net
has_zones = net_id in zones_by_net
if has_pads and not has_segments and not has_zones:
unrouted_nets.append((net_id, net_info.name, len(pads_by_net[net_id])))
if not quiet:
if net_patterns and component:
print(f"Checking {len(nets_to_check)} nets on {component} matching: {net_patterns}")
elif net_patterns:
print(f"Checking {len(nets_to_check)} nets matching: {net_patterns}")
elif component:
print(f"Checking {len(nets_to_check)} nets on component {component}")
else:
print(f"Checking {len(nets_to_check)} routed nets")
issues = []
# Report unrouted nets as issues
for net_id, net_name, num_pads in unrouted_nets:
issues.append({
'net_id': net_id,
'net_name': net_name,
'num_segments': 0,
'num_vias': 0,
'num_pads': num_pads,
'num_components': num_pads, # Each pad is its own component
'disconnected_pads': [],
'unrouted': True,
'message': f'Unrouted net with {num_pads} pads'
})
for net_id, net_name in nets_to_check:
segments = segments_by_net.get(net_id, [])
vias = vias_by_net.get(net_id, [])
pads = pads_by_net.get(net_id, [])
zones = zones_by_net.get(net_id, [])
result = check_net_connectivity(net_id, segments, vias, pads, zones, tolerance, verbose=verbose)
if not result['connected']:
issue = {
'net_id': net_id,
'net_name': net_name,
'num_segments': len(segments),
'num_vias': len(vias),
'num_pads': len(pads),
'num_components': result['num_components'],
'disconnected_pads': result['disconnected_pads'],
'message': result.get('message'),
'debug_info': result.get('debug_info')
}
# Analyze the gap
if verbose and result.get('debug_info'):
gap = find_gap_between_components(result['debug_info'], tolerance)
if gap:
issue['gap_info'] = gap
issues.append(issue)
# Report results
if quiet:
if issues:
print(f"FAILED ({len(issues)} issues)")
else:
print("OK")
return issues
# Print detailed results (always for non-quiet, or when issues in quiet mode)
if not quiet or issues:
print("\n" + "=" * 60 if not quiet else "=" * 60)
if issues:
# Separate unrouted from connectivity issues
unrouted_issues = [i for i in issues if i.get('unrouted')]
connectivity_issues = [i for i in issues if not i.get('unrouted')]
print(f"FOUND {len(issues)} ISSUES:\n")
if unrouted_issues:
print(f" Unrouted nets ({len(unrouted_issues)}):")
for issue in unrouted_issues:
print(f" {issue['net_name']} ({issue['num_pads']} pads)")
print()
if connectivity_issues:
print(f" Connectivity issues ({len(connectivity_issues)}):")
for issue in connectivity_issues:
print(f"\n {issue['net_name']} (net {issue['net_id']}):")
print(f" Segments: {issue['num_segments']}, Vias: {issue['num_vias']}, Pads: {issue['num_pads']}")
print(f" Disconnected components: {issue['num_components']}")
if issue['disconnected_pads']:
print(f" Disconnected pads:")
for loc in issue['disconnected_pads'][:5]:
print(f" ({loc[0]:.2f}, {loc[1]:.2f}) on {loc[2]} [{loc[3]}]")
if len(issue['disconnected_pads']) > 5:
print(f" ... and {len(issue['disconnected_pads']) - 5} more")
if issue.get('gap_info'):
gap = issue['gap_info']
print(f" Break location: {gap['message']}")
if verbose and gap.get('type') == 'gap_on_layer':
debug = issue.get('debug_info')
if debug:
# Show component details
for root, summary in debug['components'].items():
is_main = root == debug['main_root']
print(f" Component {'(main)' if is_main else '(disconnected)'}: "
f"layers={summary['layers']}, "
f"has_pads={summary['has_pads']}, has_vias={summary['has_vias']}")
if verbose:
print(f" Points by layer: {summary['points_by_layer']}")
if issue.get('message'):
print(f" Note: {issue['message']}")
else:
print("ALL NETS FULLY CONNECTED!")
print("=" * 60)
return issues
if __name__ == "__main__":
parser = argparse.ArgumentParser(description='Check PCB for track connectivity (disconnected routes)')
parser.add_argument('pcb', help='Input PCB file')
parser.add_argument('--nets', '-n', nargs='+', default=None,
help='Net name patterns to check (fnmatch wildcards supported, e.g., "*lvds*")')
parser.add_argument('--component', '-C',
help='Check all nets connected to this component (e.g., U1)')
parser.add_argument('--tolerance', '-t', type=float, default=0.02,
help='Minimum connection tolerance in mm (default: 0.02)')
parser.add_argument('--quiet', '-q', action='store_true',
help='Only print a summary line unless there are issues')
parser.add_argument('--verbose', '-v', action='store_true',
help='Show detailed break location info for disconnected nets')
parser.add_argument('--routed-only', '-r', action='store_true',
help='Only check routed nets (skip unrouted net detection)')
args = parser.parse_args()
issues = run_connectivity_check(args.pcb, args.nets, args.tolerance, args.quiet, args.verbose, args.component, args.routed_only)
sys.exit(1 if issues else 0)