pcb-crossing-optimizer 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
crossing_analyzer.py ADDED
@@ -0,0 +1,1333 @@
1
+ """Crossing analyzer for SKiDL-generated netlists.
2
+
3
+ Detects trace crossings across component layers and computes optimal
4
+ pin orderings for reorderable connectors to minimize or eliminate
5
+ crossings for single-layer routing.
6
+
7
+ Algorithm: Sugiyama-style barycenter sweep with virtual node insertion
8
+ for long edges spanning non-adjacent layers.
9
+
10
+ Usage (CLI):
11
+ python crossing_analyzer.py <netlist.net> --layers "J1 | R1,C1 | J2" --reorderable J2
12
+
13
+ Programmatic usage from SKiDL scripts:
14
+ from crossing_analyzer import PinColumn, sweep_optimize, format_multilayer_report
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import re
21
+ import sys
22
+ from dataclasses import dataclass, field
23
+ from itertools import combinations
24
+ from pathlib import Path
25
+ from typing import Optional
26
+
27
+
28
+ # =========================================================================
29
+ # Data model
30
+ # =========================================================================
31
+
32
+ @dataclass
33
+ class PinColumn:
34
+ """A connector with pins in physical order (top-to-bottom or left-to-right).
35
+
36
+ pin_order is a list of pin IDs (strings) in physical position order.
37
+ Position 0 is the first physical pin.
38
+ """
39
+ ref: str
40
+ pin_order: list[str]
41
+
42
+ def position_of(self, pin_id: str) -> int:
43
+ return self.pin_order.index(pin_id)
44
+
45
+
46
+ @dataclass
47
+ class LayerEdge:
48
+ """An edge between pins on components in adjacent layers."""
49
+ net_name: str
50
+ source_ref: str
51
+ source_pin: str
52
+ target_ref: str
53
+ target_pin: str
54
+
55
+
56
+ @dataclass
57
+ class LayerPairCrossing:
58
+ """Two edges between adjacent layers that cross."""
59
+ edge_a: LayerEdge
60
+ edge_b: LayerEdge
61
+
62
+
63
+ @dataclass
64
+ class LayerPairReport:
65
+ """Crossing report for one pair of adjacent layers."""
66
+ source_layer_idx: int
67
+ target_layer_idx: int
68
+ source_refs: list[str]
69
+ target_refs: list[str]
70
+ crossing_count: int
71
+ crossings: list[LayerPairCrossing]
72
+
73
+
74
+ @dataclass
75
+ class MultilayerReport:
76
+ """Full crossing analysis across all layer pairs."""
77
+ total_crossings: int
78
+ total_crossings_after: int
79
+ layer_pair_reports: list[LayerPairReport]
80
+ original_orders: dict[str, list[str]]
81
+ optimized_orders: dict[str, list[str]]
82
+ iterations: int
83
+
84
+
85
+ @dataclass
86
+ class PinAssignment:
87
+ """A single pin's assignment in a footprint plan."""
88
+ pin: str
89
+ net: Optional[str] # None = NC
90
+ status: str # "locked" | "optimized" | "unmatched"
91
+ routes_to: Optional[str] = None # e.g. "J2.1" for optimized pins
92
+
93
+
94
+ @dataclass
95
+ class FootprintPlan:
96
+ """Result of a plan-footprint analysis."""
97
+ target_ref: str
98
+ pin_map: list[PinAssignment]
99
+ crossings_before: int
100
+ crossings_after: int
101
+ iterations: int
102
+ passive_reorderings: dict[str, list[str]]
103
+
104
+
105
+ # =========================================================================
106
+ # Formatting helpers
107
+ # =========================================================================
108
+
109
+ def _format_pin_ref(ref: str, pin: str) -> str:
110
+ """Format a pin reference for display, hiding virtual node internals."""
111
+ if ref.startswith("_virt_"):
112
+ return "[pass-through]"
113
+ return f"{ref}.{pin}"
114
+
115
+
116
+ # =========================================================================
117
+ # Core analysis
118
+ # =========================================================================
119
+
120
+ def layer_global_positions(
121
+ components: list[PinColumn],
122
+ ) -> dict[tuple[str, str], int]:
123
+ """Map (ref, pin) to a global position index across a layer.
124
+
125
+ Components are laid out sequentially: if component A has 3 pins and
126
+ component B has 2 pins, B's first pin is at position 3.
127
+ """
128
+ pos = 0
129
+ result: dict[tuple[str, str], int] = {}
130
+ for comp in components:
131
+ for pin in comp.pin_order:
132
+ result[(comp.ref, pin)] = pos
133
+ pos += 1
134
+ return result
135
+
136
+
137
+ def count_layer_pair_crossings(
138
+ source_layer: list[PinColumn],
139
+ target_layer: list[PinColumn],
140
+ edges: list[LayerEdge],
141
+ ) -> list[LayerPairCrossing]:
142
+ """Count crossings between edges connecting two adjacent layers.
143
+
144
+ Two edges (i->j) and (k->l) cross iff (i < k and j > l) or
145
+ (i > k and j < l), where i,k are positions on the source layer
146
+ and j,l are positions on the target layer.
147
+ """
148
+ source_pos = layer_global_positions(source_layer)
149
+ target_pos = layer_global_positions(target_layer)
150
+
151
+ # Filter to edges that have valid positions in both layers
152
+ valid_edges: list[LayerEdge] = []
153
+ for e in edges:
154
+ sk = (e.source_ref, e.source_pin)
155
+ tk = (e.target_ref, e.target_pin)
156
+ if sk in source_pos and tk in target_pos:
157
+ valid_edges.append(e)
158
+
159
+ crossings: list[LayerPairCrossing] = []
160
+ for a, b in combinations(valid_edges, 2):
161
+ si = source_pos[(a.source_ref, a.source_pin)]
162
+ sk = source_pos[(b.source_ref, b.source_pin)]
163
+ tj = target_pos[(a.target_ref, a.target_pin)]
164
+ tl = target_pos[(b.target_ref, b.target_pin)]
165
+ if (si < sk and tj > tl) or (si > sk and tj < tl):
166
+ crossings.append(LayerPairCrossing(a, b))
167
+ return crossings
168
+
169
+
170
+ def extract_layer_pair_edges(
171
+ nets: dict[str, list[tuple[str, str]]],
172
+ source_refs: set[str],
173
+ target_refs: set[str],
174
+ ) -> list[LayerEdge]:
175
+ """Extract edges between two sets of components from parsed net data.
176
+
177
+ A net produces edges if it has pins on components in both the source
178
+ and target sets.
179
+ """
180
+ edges: list[LayerEdge] = []
181
+ for net_name, nodes in nets.items():
182
+ source_pins = [(ref, pin) for ref, pin in nodes if ref in source_refs]
183
+ target_pins = [(ref, pin) for ref, pin in nodes if ref in target_refs]
184
+ for sref, spin in source_pins:
185
+ for tref, tpin in target_pins:
186
+ edges.append(LayerEdge(
187
+ net_name=net_name,
188
+ source_ref=sref, source_pin=spin,
189
+ target_ref=tref, target_pin=tpin,
190
+ ))
191
+ return edges
192
+
193
+
194
+ def _compute_multilayer_barycenters(
195
+ component_ref: str,
196
+ adjacent_layer: list[PinColumn],
197
+ edges: list[LayerEdge],
198
+ ) -> dict[str, float]:
199
+ """Compute barycenter for each pin of a component based on its
200
+ connections to an adjacent layer.
201
+
202
+ Works regardless of whether the component is on the source or target
203
+ side of the edges.
204
+ """
205
+ adj_pos = layer_global_positions(adjacent_layer)
206
+
207
+ pin_positions: dict[str, list[int]] = {}
208
+ for edge in edges:
209
+ if edge.source_ref == component_ref:
210
+ pin = edge.source_pin
211
+ adj_key = (edge.target_ref, edge.target_pin)
212
+ elif edge.target_ref == component_ref:
213
+ pin = edge.target_pin
214
+ adj_key = (edge.source_ref, edge.source_pin)
215
+ else:
216
+ continue
217
+
218
+ if adj_key in adj_pos:
219
+ pin_positions.setdefault(pin, []).append(adj_pos[adj_key])
220
+
221
+ barycenters: dict[str, float] = {}
222
+ for pin, positions in pin_positions.items():
223
+ barycenters[pin] = sum(positions) / len(positions)
224
+
225
+ return barycenters
226
+
227
+
228
+ def _reorder_pins_by_barycenter(
229
+ component: PinColumn,
230
+ barycenters: dict[str, float],
231
+ ) -> PinColumn:
232
+ """Reorder a component's pins by their barycenter values.
233
+
234
+ Pins without a barycenter (unconnected to the adjacent layer)
235
+ keep their relative order and are placed at the end.
236
+ """
237
+ connected = [p for p in component.pin_order if p in barycenters]
238
+ unconnected = [p for p in component.pin_order if p not in barycenters]
239
+ connected.sort(key=lambda p: barycenters[p])
240
+ return PinColumn(ref=component.ref, pin_order=connected + unconnected)
241
+
242
+
243
+ def _reorder_components_in_layer(
244
+ layer: list[PinColumn],
245
+ adjacent_layer: list[PinColumn],
246
+ edges: list[LayerEdge],
247
+ ) -> list[PinColumn]:
248
+ """Reorder components within a layer by their aggregate barycenter.
249
+
250
+ Each component's aggregate barycenter is the average of all its
251
+ pin barycenters relative to the adjacent layer.
252
+ """
253
+ adj_pos = layer_global_positions(adjacent_layer)
254
+
255
+ comp_barycenters: dict[str, float] = {}
256
+ for comp in layer:
257
+ positions: list[int] = []
258
+ for edge in edges:
259
+ if edge.source_ref == comp.ref:
260
+ adj_key = (edge.target_ref, edge.target_pin)
261
+ elif edge.target_ref == comp.ref:
262
+ adj_key = (edge.source_ref, edge.source_pin)
263
+ else:
264
+ continue
265
+ if adj_key in adj_pos:
266
+ positions.append(adj_pos[adj_key])
267
+
268
+ if positions:
269
+ comp_barycenters[comp.ref] = sum(positions) / len(positions)
270
+ else:
271
+ comp_barycenters[comp.ref] = float("inf")
272
+
273
+ return sorted(layer, key=lambda c: comp_barycenters[c.ref])
274
+
275
+
276
+ def _build_component_layer_map(
277
+ layers: list[list[PinColumn]],
278
+ ) -> dict[str, int]:
279
+ """Map component ref -> layer index."""
280
+ result: dict[str, int] = {}
281
+ for i, layer in enumerate(layers):
282
+ for comp in layer:
283
+ result[comp.ref] = i
284
+ return result
285
+
286
+
287
+ def _expand_with_virtual_nodes(
288
+ layers: list[list[PinColumn]],
289
+ nets: dict[str, list[tuple[str, str]]],
290
+ ) -> tuple[list[list[PinColumn]], list[list[LayerEdge]], set[str]]:
291
+ """Insert virtual nodes for long edges spanning non-adjacent layers.
292
+
293
+ For a net connecting components in layers 0 and 2, a virtual pin is
294
+ inserted in layer 1 so the sweep can account for all routing paths.
295
+
296
+ Only pins present in the layers' pin_order lists are considered,
297
+ so caller exclusions are automatically respected.
298
+
299
+ Returns:
300
+ expanded_layers: layers with virtual PinColumn added where needed.
301
+ layer_pair_edges: pre-computed edge list for each adjacent pair.
302
+ virtual_refs: set of virtual component refs (always reorderable).
303
+ """
304
+ comp_layer = _build_component_layer_map(layers)
305
+ n_layers = len(layers)
306
+
307
+ # Build set of active (ref, pin) from the layer data
308
+ active_pins: set[tuple[str, str]] = set()
309
+ for layer in layers:
310
+ for comp in layer:
311
+ for pin in comp.pin_order:
312
+ active_pins.add((comp.ref, pin))
313
+
314
+ # Collect virtual pins needed per intermediate layer
315
+ virt_pins_per_layer: dict[int, list[str]] = {}
316
+ virt_counter = 0
317
+
318
+ # Build edge lists for each layer pair
319
+ pair_edges: list[list[LayerEdge]] = [[] for _ in range(n_layers - 1)]
320
+
321
+ for net_name, nodes in nets.items():
322
+ # Group nodes by layer, filtering to active pins only
323
+ nodes_by_layer: dict[int, list[tuple[str, str]]] = {}
324
+ for ref, pin in nodes:
325
+ if ref not in comp_layer:
326
+ continue
327
+ if (ref, pin) not in active_pins:
328
+ continue
329
+ li = comp_layer[ref]
330
+ nodes_by_layer.setdefault(li, []).append((ref, pin))
331
+
332
+ # For each pair of layer groups, create edges
333
+ sorted_layers = sorted(nodes_by_layer.keys())
334
+ for a_idx in range(len(sorted_layers)):
335
+ for b_idx in range(a_idx + 1, len(sorted_layers)):
336
+ src_li = sorted_layers[a_idx]
337
+ tgt_li = sorted_layers[b_idx]
338
+ gap = tgt_li - src_li
339
+
340
+ for sref, spin in nodes_by_layer[src_li]:
341
+ for tref, tpin in nodes_by_layer[tgt_li]:
342
+ if gap == 1:
343
+ # Direct edge: no virtual nodes needed
344
+ pair_edges[src_li].append(
345
+ LayerEdge(net_name, sref, spin, tref, tpin)
346
+ )
347
+ else:
348
+ # Long edge: create virtual pins in intermediate layers
349
+ chain: list[tuple[str, str]] = [(sref, spin)]
350
+ for mid_li in range(src_li + 1, tgt_li):
351
+ vref = f"_virt_L{mid_li}"
352
+ vid = f"_v{virt_counter}"
353
+ virt_counter += 1
354
+ virt_pins_per_layer.setdefault(mid_li, []).append(vid)
355
+ chain.append((vref, vid))
356
+ chain.append((tref, tpin))
357
+
358
+ # Create edge segments along the chain
359
+ for seg in range(len(chain) - 1):
360
+ sr, sp = chain[seg]
361
+ tr, tp = chain[seg + 1]
362
+ edge_li = src_li + seg
363
+ pair_edges[edge_li].append(
364
+ LayerEdge(net_name, sr, sp, tr, tp)
365
+ )
366
+
367
+ # Build expanded layers with virtual PinColumns
368
+ expanded: list[list[PinColumn]] = []
369
+ virtual_refs: set[str] = set()
370
+ for i, layer in enumerate(layers):
371
+ new_layer = [
372
+ PinColumn(ref=c.ref, pin_order=list(c.pin_order)) for c in layer
373
+ ]
374
+ if i in virt_pins_per_layer:
375
+ vref = f"_virt_L{i}"
376
+ virtual_refs.add(vref)
377
+ new_layer.append(PinColumn(ref=vref, pin_order=virt_pins_per_layer[i]))
378
+ expanded.append(new_layer)
379
+
380
+ return expanded, pair_edges, virtual_refs
381
+
382
+
383
+ def sweep_optimize(
384
+ layers: list[list[PinColumn]],
385
+ reorderable_refs: set[str],
386
+ nets: dict[str, list[tuple[str, str]]],
387
+ max_iterations: int = 10,
388
+ ) -> MultilayerReport:
389
+ """Minimize crossings across all layer pairs using Sugiyama-style
390
+ barycenter sweep.
391
+
392
+ Handles long edges (nets spanning non-adjacent layers) by inserting
393
+ virtual nodes in intermediate layers so the sweep accounts for all
394
+ routing paths.
395
+
396
+ Args:
397
+ layers: List of layers, each layer is a list of PinColumn objects.
398
+ layers[0] is the leftmost/topmost layer.
399
+ reorderable_refs: Set of component refs whose pin order can change.
400
+ nets: Parsed netlist data (net_name -> [(ref, pin), ...]).
401
+ max_iterations: Maximum forward+backward sweep iterations.
402
+
403
+ Returns:
404
+ MultilayerReport with crossing counts before/after and optimized orders.
405
+ """
406
+ # Save original orders before any modification
407
+ original_orders: dict[str, list[str]] = {}
408
+ for layer in layers:
409
+ for comp in layer:
410
+ original_orders[comp.ref] = list(comp.pin_order)
411
+
412
+ # Expand layers with virtual nodes for long edges
413
+ layers, layer_pair_edges, virtual_refs = _expand_with_virtual_nodes(layers, nets)
414
+
415
+ # Virtual nodes are always reorderable
416
+ all_reorderable = reorderable_refs | virtual_refs
417
+
418
+ # Count initial crossings
419
+ def total_crossing_count() -> int:
420
+ total = 0
421
+ for i, edges in enumerate(layer_pair_edges):
422
+ crossings = count_layer_pair_crossings(
423
+ layers[i], layers[i + 1], edges,
424
+ )
425
+ total += len(crossings)
426
+ return total
427
+
428
+ initial_total = total_crossing_count()
429
+ best_total = initial_total
430
+ iterations = 0
431
+
432
+ for iteration in range(max_iterations):
433
+ improved = False
434
+
435
+ # Forward sweep: fix layer i, optimize layer i+1
436
+ for i in range(len(layers) - 1):
437
+ edges = layer_pair_edges[i]
438
+ new_layer: list[PinColumn] = []
439
+ for comp in layers[i + 1]:
440
+ if comp.ref in all_reorderable:
441
+ bc = _compute_multilayer_barycenters(
442
+ comp.ref, layers[i], edges,
443
+ )
444
+ new_layer.append(_reorder_pins_by_barycenter(comp, bc))
445
+ else:
446
+ new_layer.append(comp)
447
+ layers[i + 1] = _reorder_components_in_layer(
448
+ new_layer, layers[i], edges,
449
+ )
450
+
451
+ # Backward sweep: fix layer i+1, optimize layer i
452
+ for i in range(len(layers) - 2, -1, -1):
453
+ edges = layer_pair_edges[i]
454
+ new_layer = []
455
+ for comp in layers[i]:
456
+ if comp.ref in all_reorderable:
457
+ bc = _compute_multilayer_barycenters(
458
+ comp.ref, layers[i + 1], edges,
459
+ )
460
+ new_layer.append(_reorder_pins_by_barycenter(comp, bc))
461
+ else:
462
+ new_layer.append(comp)
463
+ layers[i] = _reorder_components_in_layer(
464
+ new_layer, layers[i + 1], edges,
465
+ )
466
+
467
+ current_total = total_crossing_count()
468
+ iterations = iteration + 1
469
+
470
+ if current_total < best_total:
471
+ best_total = current_total
472
+ improved = True
473
+
474
+ if not improved:
475
+ break
476
+
477
+ # Build layer pair reports for final state (filter out virtual refs from display)
478
+ pair_reports: list[LayerPairReport] = []
479
+ for i, edges in enumerate(layer_pair_edges):
480
+ crossings = count_layer_pair_crossings(
481
+ layers[i], layers[i + 1], edges,
482
+ )
483
+ pair_reports.append(LayerPairReport(
484
+ source_layer_idx=i,
485
+ target_layer_idx=i + 1,
486
+ source_refs=[c.ref for c in layers[i] if c.ref not in virtual_refs],
487
+ target_refs=[c.ref for c in layers[i + 1] if c.ref not in virtual_refs],
488
+ crossing_count=len(crossings),
489
+ crossings=crossings,
490
+ ))
491
+
492
+ # Collect optimized orders (real components only)
493
+ optimized_orders: dict[str, list[str]] = {}
494
+ for layer in layers:
495
+ for comp in layer:
496
+ if comp.ref not in virtual_refs:
497
+ optimized_orders[comp.ref] = list(comp.pin_order)
498
+
499
+ return MultilayerReport(
500
+ total_crossings=initial_total,
501
+ total_crossings_after=best_total,
502
+ layer_pair_reports=pair_reports,
503
+ original_orders=original_orders,
504
+ optimized_orders=optimized_orders,
505
+ iterations=iterations,
506
+ )
507
+
508
+
509
+ # =========================================================================
510
+ # KiCad netlist parser (.net S-expression format)
511
+ # =========================================================================
512
+
513
+ def _tokenize_sexp(text: str) -> list[str]:
514
+ """Tokenize an S-expression into a flat list of tokens."""
515
+ tokens: list[str] = []
516
+ i = 0
517
+ while i < len(text):
518
+ c = text[i]
519
+ if c in " \t\n\r":
520
+ i += 1
521
+ elif c == "(":
522
+ tokens.append("(")
523
+ i += 1
524
+ elif c == ")":
525
+ tokens.append(")")
526
+ i += 1
527
+ elif c == '"':
528
+ # Quoted string
529
+ j = i + 1
530
+ while j < len(text) and text[j] != '"':
531
+ if text[j] == "\\":
532
+ j += 1 # skip escaped char
533
+ j += 1
534
+ tokens.append(text[i + 1 : j])
535
+ i = j + 1
536
+ else:
537
+ # Unquoted atom
538
+ j = i
539
+ while j < len(text) and text[j] not in " \t\n\r()\"":
540
+ j += 1
541
+ tokens.append(text[i:j])
542
+ i = j
543
+ return tokens
544
+
545
+
546
+ def _parse_sexp(tokens: list[str], pos: int = 0) -> tuple:
547
+ """Parse tokenized S-expression into nested tuples."""
548
+ if tokens[pos] == "(":
549
+ items = []
550
+ pos += 1
551
+ while tokens[pos] != ")":
552
+ item, pos = _parse_sexp(tokens, pos)
553
+ items.append(item)
554
+ return tuple(items), pos + 1
555
+ else:
556
+ return tokens[pos], pos + 1
557
+
558
+
559
+ def _find_nodes(sexp, tag: str):
560
+ """Recursively find all sub-expressions starting with the given tag."""
561
+ results = []
562
+ if isinstance(sexp, tuple):
563
+ if len(sexp) > 0 and sexp[0] == tag:
564
+ results.append(sexp)
565
+ for child in sexp:
566
+ results.extend(_find_nodes(child, tag))
567
+ return results
568
+
569
+
570
+ def parse_netlist(filepath: str) -> dict:
571
+ """Parse a KiCad .net file, returning components and net connectivity.
572
+
573
+ Returns:
574
+ {
575
+ "components": {ref: {"value": str, "part": str}},
576
+ "nets": {net_name: [(ref, pin_id), ...]},
577
+ }
578
+ """
579
+ text = Path(filepath).read_text(encoding="utf-8")
580
+ tokens = _tokenize_sexp(text)
581
+ tree, _ = _parse_sexp(tokens)
582
+
583
+ components: dict[str, dict] = {}
584
+ for comp in _find_nodes(tree, "comp"):
585
+ ref = value = part = None
586
+ for item in comp:
587
+ if isinstance(item, tuple):
588
+ if item[0] == "ref" and len(item) > 1:
589
+ ref = item[1]
590
+ elif item[0] == "value" and len(item) > 1:
591
+ value = item[1]
592
+ elif item[0] == "libsource":
593
+ for sub in item:
594
+ if isinstance(sub, tuple) and sub[0] == "part" and len(sub) > 1:
595
+ part = sub[1]
596
+ if ref:
597
+ components[ref] = {"value": value, "part": part}
598
+
599
+ nets: dict[str, list[tuple[str, str]]] = {}
600
+ for net in _find_nodes(tree, "net"):
601
+ net_name = None
602
+ nodes = []
603
+ for item in net:
604
+ if isinstance(item, tuple):
605
+ if item[0] == "name" and len(item) > 1:
606
+ net_name = item[1]
607
+ elif item[0] == "node":
608
+ ref = pin = None
609
+ for sub in item:
610
+ if isinstance(sub, tuple):
611
+ if sub[0] == "ref" and len(sub) > 1:
612
+ ref = sub[1]
613
+ elif sub[0] == "pin" and len(sub) > 1:
614
+ pin = sub[1]
615
+ if ref and pin:
616
+ nodes.append((ref, pin))
617
+ if net_name:
618
+ nets[net_name] = nodes
619
+
620
+ return {"components": components, "nets": nets}
621
+
622
+
623
+ def infer_pin_order(ref: str, nets: dict[str, list[tuple[str, str]]]) -> list[str]:
624
+ """Infer pin order for a component by collecting all pin IDs seen in the
625
+ netlist and sorting them numerically (with non-numeric pins like 'SH' last)."""
626
+ pins: set[str] = set()
627
+ for nodes in nets.values():
628
+ for node_ref, pin in nodes:
629
+ if node_ref == ref:
630
+ pins.add(pin)
631
+
632
+ def sort_key(p: str):
633
+ try:
634
+ return (0, int(p))
635
+ except ValueError:
636
+ return (1, p)
637
+
638
+ return sorted(pins, key=sort_key)
639
+
640
+
641
+ # =========================================================================
642
+ # Report formatting
643
+ # =========================================================================
644
+
645
+ def format_multilayer_report(
646
+ report: MultilayerReport,
647
+ nets: dict[str, list[tuple[str, str]]],
648
+ ) -> str:
649
+ """Format a MultilayerReport as human-readable text."""
650
+ lines: list[str] = []
651
+ lines.append("Multi-Layer Crossing Analysis")
652
+ lines.append("=" * 60)
653
+ lines.append("")
654
+ lines.append(f"Total crossings (before): {report.total_crossings}")
655
+ lines.append(f"Total crossings (after): {report.total_crossings_after}")
656
+ lines.append(f"Sweep iterations: {report.iterations}")
657
+ lines.append("")
658
+
659
+ for pair_report in report.layer_pair_reports:
660
+ src = ", ".join(r for r in pair_report.source_refs if not r.startswith("_virt_"))
661
+ tgt = ", ".join(r for r in pair_report.target_refs if not r.startswith("_virt_"))
662
+ src_label = src or "[pass-through]"
663
+ tgt_label = tgt or "[pass-through]"
664
+ lines.append(
665
+ f"Layer {pair_report.source_layer_idx} [{src_label}] -> "
666
+ f"Layer {pair_report.target_layer_idx} [{tgt_label}]"
667
+ )
668
+ lines.append(f" Crossings: {pair_report.crossing_count}")
669
+ for i, cp in enumerate(pair_report.crossings, 1):
670
+ a_src = _format_pin_ref(cp.edge_a.source_ref, cp.edge_a.source_pin)
671
+ a_tgt = _format_pin_ref(cp.edge_a.target_ref, cp.edge_a.target_pin)
672
+ b_src = _format_pin_ref(cp.edge_b.source_ref, cp.edge_b.source_pin)
673
+ b_tgt = _format_pin_ref(cp.edge_b.target_ref, cp.edge_b.target_pin)
674
+ lines.append(
675
+ f" {i}. {cp.edge_a.net_name} "
676
+ f"({a_src} -> {a_tgt}) X "
677
+ f"{cp.edge_b.net_name} "
678
+ f"({b_src} -> {b_tgt})"
679
+ )
680
+ lines.append("")
681
+
682
+ # Show reordering recommendations
683
+ changed = {
684
+ ref for ref in report.optimized_orders
685
+ if report.optimized_orders[ref] != report.original_orders.get(ref)
686
+ }
687
+ if changed:
688
+ lines.append("Recommended pin reorderings:")
689
+ for ref in sorted(changed):
690
+ original = ", ".join(report.original_orders[ref])
691
+ optimized = ", ".join(report.optimized_orders[ref])
692
+ lines.append(f" {ref}: [{original}] -> [{optimized}]")
693
+ else:
694
+ lines.append("No reordering needed.")
695
+
696
+ if report.total_crossings_after > 0:
697
+ lines.append("")
698
+ lines.append(
699
+ "WARNING: Not all crossings can be eliminated by reordering alone."
700
+ )
701
+ lines.append(
702
+ "Remaining crossings will require vias or a second routing layer."
703
+ )
704
+
705
+ return "\n".join(lines)
706
+
707
+
708
+ def report_to_dict(
709
+ report: MultilayerReport,
710
+ nets: dict[str, list[tuple[str, str]]],
711
+ ) -> dict:
712
+ """Convert a MultilayerReport to a JSON-serializable dict."""
713
+ layer_pairs = []
714
+ for pr in report.layer_pair_reports:
715
+ crossings = []
716
+ for cp in pr.crossings:
717
+ crossings.append({
718
+ "edge_a": {
719
+ "net": cp.edge_a.net_name,
720
+ "source": _format_pin_ref(cp.edge_a.source_ref, cp.edge_a.source_pin),
721
+ "target": _format_pin_ref(cp.edge_a.target_ref, cp.edge_a.target_pin),
722
+ },
723
+ "edge_b": {
724
+ "net": cp.edge_b.net_name,
725
+ "source": _format_pin_ref(cp.edge_b.source_ref, cp.edge_b.source_pin),
726
+ "target": _format_pin_ref(cp.edge_b.target_ref, cp.edge_b.target_pin),
727
+ },
728
+ })
729
+ layer_pairs.append({
730
+ "source_layer": pr.source_layer_idx,
731
+ "target_layer": pr.target_layer_idx,
732
+ "source_refs": [r for r in pr.source_refs if not r.startswith("_virt_")],
733
+ "target_refs": [r for r in pr.target_refs if not r.startswith("_virt_")],
734
+ "crossing_count": pr.crossing_count,
735
+ "crossings": crossings,
736
+ })
737
+
738
+ reorderings = {}
739
+ for ref in sorted(report.optimized_orders):
740
+ orig = report.original_orders.get(ref, [])
741
+ opt = report.optimized_orders[ref]
742
+ if orig != opt:
743
+ reorderings[ref] = {"original": orig, "optimized": opt}
744
+
745
+ return {
746
+ "total_crossings_before": report.total_crossings,
747
+ "total_crossings_after": report.total_crossings_after,
748
+ "iterations": report.iterations,
749
+ "layer_pairs": layer_pairs,
750
+ "reorderings": reorderings,
751
+ }
752
+
753
+
754
+ # =========================================================================
755
+ # Footprint planning
756
+ # =========================================================================
757
+
758
+ def parse_pin_locks(lock_args: list[str]) -> dict[str, str | None]:
759
+ """Parse --lock arguments into a pin-to-net mapping.
760
+
761
+ Each arg is 'PIN=NET' or 'PIN=NC'.
762
+ Returns dict mapping pin ID to net name (or None for NC).
763
+ """
764
+ locks: dict[str, str | None] = {}
765
+ for arg in lock_args:
766
+ if "=" not in arg:
767
+ raise ValueError(
768
+ f"Invalid --lock format: '{arg}'. Expected PIN=NET or PIN=NC."
769
+ )
770
+ pin, net = arg.split("=", 1)
771
+ pin = pin.strip()
772
+ net = net.strip()
773
+ if not pin:
774
+ raise ValueError(f"Empty pin in --lock: '{arg}'")
775
+ if net.upper() == "NC":
776
+ locks[pin] = None
777
+ else:
778
+ locks[pin] = net
779
+ return locks
780
+
781
+
782
+ def _find_primary_route(
783
+ target_ref: str,
784
+ target_pin: str,
785
+ net_name: str,
786
+ nets: dict[str, list[tuple[str, str]]],
787
+ ) -> str | None:
788
+ """Find the primary non-target endpoint for a net (for 'routes to' display)."""
789
+ nodes = nets.get(net_name, [])
790
+ for ref, pin in nodes:
791
+ if ref != target_ref and not ref.startswith("TP"):
792
+ return f"{ref}.{pin}"
793
+ return None
794
+
795
+
796
+ def build_pin_map(
797
+ target_ref: str,
798
+ all_pins: list[str],
799
+ locks: dict[str, str | None],
800
+ optimized_signal_pins: list[str],
801
+ pin_to_net: dict[str, str],
802
+ nets: dict[str, list[tuple[str, str]]],
803
+ unmatched_mode: str,
804
+ ) -> list[PinAssignment]:
805
+ """Build the final pin map by merging locked, optimized, and unmatched pins.
806
+
807
+ Args:
808
+ target_ref: Component ref (for routes_to lookup).
809
+ all_pins: All pin IDs in physical position order.
810
+ locks: Pin-to-net locks from --lock (None = NC).
811
+ optimized_signal_pins: Pin IDs in sweep-optimized order (signal pins only).
812
+ pin_to_net: Mapping of pin ID to net name (from netlist).
813
+ nets: Full net connectivity for routes_to lookup.
814
+ unmatched_mode: 'start', 'end', or 'split'.
815
+
816
+ Returns:
817
+ List of PinAssignment in physical position order.
818
+ """
819
+ # Categorize pins
820
+ locked_pins = set(locks.keys())
821
+ signal_pins = set(optimized_signal_pins)
822
+ unmatched_pins = [
823
+ p for p in all_pins
824
+ if p not in locked_pins and p not in signal_pins
825
+ ]
826
+
827
+ # Build the assignment slots
828
+ n = len(all_pins)
829
+ result: list[PinAssignment | None] = [None] * n
830
+ pin_to_idx = {p: i for i, p in enumerate(all_pins)}
831
+
832
+ # 1. Place locked pins at their positions
833
+ for pin, net in locks.items():
834
+ if pin in pin_to_idx:
835
+ idx = pin_to_idx[pin]
836
+ result[idx] = PinAssignment(
837
+ pin=pin, net=net, status="locked",
838
+ routes_to=_find_primary_route(target_ref, pin, net, nets) if net else None,
839
+ )
840
+
841
+ # 2. Collect open slots (not locked)
842
+ open_slots = [i for i in range(n) if result[i] is None]
843
+
844
+ # 3. Place unmatched pins per mode, filling from the open slots
845
+ if unmatched_mode == "start":
846
+ unmatched_slots = open_slots[:len(unmatched_pins)]
847
+ signal_slots = open_slots[len(unmatched_pins):]
848
+ elif unmatched_mode == "split":
849
+ half = len(unmatched_pins) // 2
850
+ unmatched_start = unmatched_pins[:half]
851
+ unmatched_end = unmatched_pins[half:]
852
+ signal_slot_count = len(open_slots) - len(unmatched_pins)
853
+ unmatched_slots = open_slots[:len(unmatched_start)] + open_slots[len(unmatched_start) + signal_slot_count:]
854
+ signal_slots = open_slots[len(unmatched_start):len(unmatched_start) + signal_slot_count]
855
+ # Rebuild unmatched_pins to match the split order
856
+ unmatched_pins = unmatched_start + unmatched_end
857
+ else: # "end" (default)
858
+ signal_slots = open_slots[:len(optimized_signal_pins)]
859
+ unmatched_slots = open_slots[len(optimized_signal_pins):]
860
+
861
+ # 4. Place optimized signal pins in sweep order
862
+ for sig_pin, slot_idx in zip(optimized_signal_pins, signal_slots):
863
+ net = pin_to_net.get(sig_pin)
864
+ result[slot_idx] = PinAssignment(
865
+ pin=all_pins[slot_idx], net=net, status="optimized",
866
+ routes_to=_find_primary_route(target_ref, sig_pin, net, nets) if net else None,
867
+ )
868
+
869
+ # 5. Place unmatched pins
870
+ for um_pin, slot_idx in zip(unmatched_pins, unmatched_slots):
871
+ net = pin_to_net.get(um_pin)
872
+ result[slot_idx] = PinAssignment(
873
+ pin=all_pins[slot_idx], net=net, status="unmatched",
874
+ routes_to=None,
875
+ )
876
+
877
+ # Fill any remaining Nones (shouldn't happen but defensive)
878
+ for i in range(n):
879
+ if result[i] is None:
880
+ result[i] = PinAssignment(pin=all_pins[i], net=None, status="unmatched")
881
+
882
+ return result
883
+
884
+
885
+ def plan_footprint(
886
+ target_ref: str,
887
+ target_pins: list[str],
888
+ anchor_layers: list[list[PinColumn]],
889
+ nets: dict[str, list[tuple[str, str]]],
890
+ locks: dict[str, str | None],
891
+ unmatched: str = "end",
892
+ exclude_nets: set[str] | None = None,
893
+ ) -> FootprintPlan:
894
+ """Compute an optimal pin map for a custom footprint.
895
+
896
+ Args:
897
+ target_ref: Ref of the component being designed.
898
+ target_pins: All pin IDs in physical position order.
899
+ anchor_layers: Fixed components organized in layers (outermost first).
900
+ nets: Parsed netlist connectivity.
901
+ locks: Pin-to-net locks (from parse_pin_locks).
902
+ unmatched: Placement mode for unconnected pins.
903
+ exclude_nets: Net names to exclude from analysis.
904
+
905
+ Returns:
906
+ FootprintPlan with complete pin map proposal.
907
+ """
908
+ exclude = exclude_nets or set()
909
+
910
+ # Filter nets
911
+ filtered_nets = {
912
+ name: nodes for name, nodes in nets.items()
913
+ if name not in exclude
914
+ }
915
+
916
+ # Build pin-to-net mapping for the target component
917
+ pin_to_net: dict[str, str] = {}
918
+ for net_name, nodes in filtered_nets.items():
919
+ for ref, pin in nodes:
920
+ if ref == target_ref:
921
+ pin_to_net[pin] = net_name
922
+
923
+ # Identify locked, signal, and unmatched pins
924
+ locked_pins = set(locks.keys())
925
+ signal_pins = [
926
+ p for p in target_pins
927
+ if p not in locked_pins and p in pin_to_net
928
+ ]
929
+ # Unmatched: not locked and not connected to any analyzed net
930
+ # (will be placed by build_pin_map)
931
+
932
+ # Build layers for sweep: anchor_layers + [target with signal pins only]
933
+ # Detect passives: 2-pin components in anchor layers are reorderable
934
+ reorderable_refs = {target_ref}
935
+ for layer in anchor_layers:
936
+ for comp in layer:
937
+ if len(comp.pin_order) == 2:
938
+ reorderable_refs.add(comp.ref)
939
+
940
+ target_column = PinColumn(ref=target_ref, pin_order=list(signal_pins))
941
+ all_layers = list(anchor_layers) + [[target_column]]
942
+
943
+ # Run sweep
944
+ report = sweep_optimize(all_layers, reorderable_refs, filtered_nets)
945
+
946
+ # Extract optimized signal pin order from sweep result
947
+ optimized_signal_order = report.optimized_orders.get(target_ref, signal_pins)
948
+
949
+ # Collect passive reorderings
950
+ passive_reorderings: dict[str, list[str]] = {}
951
+ for ref, order in report.optimized_orders.items():
952
+ if ref != target_ref and order != report.original_orders.get(ref):
953
+ passive_reorderings[ref] = order
954
+
955
+ # Build final pin map
956
+ pin_map = build_pin_map(
957
+ target_ref=target_ref,
958
+ all_pins=target_pins,
959
+ locks=locks,
960
+ optimized_signal_pins=optimized_signal_order,
961
+ pin_to_net=pin_to_net,
962
+ nets=filtered_nets,
963
+ unmatched_mode=unmatched,
964
+ )
965
+
966
+ return FootprintPlan(
967
+ target_ref=target_ref,
968
+ pin_map=pin_map,
969
+ crossings_before=report.total_crossings,
970
+ crossings_after=report.total_crossings_after,
971
+ iterations=report.iterations,
972
+ passive_reorderings=passive_reorderings,
973
+ )
974
+
975
+
976
+ def format_footprint_plan(plan: FootprintPlan) -> str:
977
+ """Format a FootprintPlan as human-readable text."""
978
+ lines: list[str] = []
979
+ total = len(plan.pin_map)
980
+ lines.append(f"Footprint Pin Map Proposal for {plan.target_ref} ({total} positions)")
981
+ lines.append("=" * 60)
982
+ lines.append("")
983
+
984
+ # Group by status
985
+ locked = [a for a in plan.pin_map if a.status == "locked"]
986
+ optimized = [a for a in plan.pin_map if a.status == "optimized"]
987
+ unmatched = [a for a in plan.pin_map if a.status == "unmatched"]
988
+
989
+ if locked:
990
+ lines.append("Locked pins:")
991
+ for a in locked:
992
+ net_str = a.net if a.net else "NC"
993
+ lines.append(f" {a.pin:>3}: {net_str}")
994
+ lines.append("")
995
+
996
+ if optimized:
997
+ lines.append("Optimized signal assignment:")
998
+ for a in optimized:
999
+ net_str = a.net if a.net else "NC"
1000
+ route = f" (routes to {a.routes_to})" if a.routes_to else ""
1001
+ lines.append(f" {a.pin:>3}: {net_str:<20}{route}")
1002
+ lines.append("")
1003
+
1004
+ if unmatched:
1005
+ lines.append("Unmatched pins:")
1006
+ for a in unmatched:
1007
+ net_str = a.net if a.net else "NC"
1008
+ lines.append(f" {a.pin:>3}: {net_str}")
1009
+ lines.append("")
1010
+
1011
+ if plan.passive_reorderings:
1012
+ lines.append("Passive reorderings:")
1013
+ for ref in sorted(plan.passive_reorderings):
1014
+ order = ", ".join(plan.passive_reorderings[ref])
1015
+ lines.append(f" {ref}: [{order}]")
1016
+ lines.append("")
1017
+
1018
+ lines.append(
1019
+ f"Crossings: {plan.crossings_before} before -> "
1020
+ f"{plan.crossings_after} after ({plan.iterations} iterations)"
1021
+ )
1022
+
1023
+ if plan.crossings_after > 0:
1024
+ lines.append("")
1025
+ lines.append(
1026
+ "WARNING: Not all crossings can be eliminated by reordering alone."
1027
+ )
1028
+ lines.append(
1029
+ "Remaining crossings will require vias or a second routing layer."
1030
+ )
1031
+
1032
+ return "\n".join(lines)
1033
+
1034
+
1035
+ def plan_to_dict(plan: FootprintPlan) -> dict:
1036
+ """Convert a FootprintPlan to a JSON-serializable dict."""
1037
+ pin_map = []
1038
+ for a in plan.pin_map:
1039
+ entry: dict = {
1040
+ "pin": a.pin,
1041
+ "net": a.net,
1042
+ "status": a.status,
1043
+ }
1044
+ if a.routes_to:
1045
+ entry["routes_to"] = a.routes_to
1046
+ pin_map.append(entry)
1047
+
1048
+ return {
1049
+ "target": plan.target_ref,
1050
+ "total_pins": len(plan.pin_map),
1051
+ "crossings_before": plan.crossings_before,
1052
+ "crossings_after": plan.crossings_after,
1053
+ "iterations": plan.iterations,
1054
+ "pin_map": pin_map,
1055
+ "passive_reorderings": plan.passive_reorderings,
1056
+ }
1057
+
1058
+
1059
+ # =========================================================================
1060
+ # CLI entry point
1061
+ # =========================================================================
1062
+
1063
+ def _cmd_analyze(args):
1064
+ """Handle the 'analyze' subcommand (original behavior)."""
1065
+ if not Path(args.netlist).exists():
1066
+ print(f"Error: file not found: {args.netlist}")
1067
+ sys.exit(1)
1068
+
1069
+ # Parse exclusions into a set of (ref, pin) tuples
1070
+ exclude_set: set[tuple[str, str]] = set()
1071
+ for exc in args.exclude:
1072
+ if ":" not in exc:
1073
+ print(f"Error: exclusion must be REF:PIN format, got '{exc}'")
1074
+ sys.exit(1)
1075
+ ref, pin = exc.split(":", 1)
1076
+ exclude_set.add((ref, pin))
1077
+
1078
+ # Parse netlist
1079
+ data = parse_netlist(args.netlist)
1080
+
1081
+ verbose = not args.quiet and not args.json_output
1082
+
1083
+ if exclude_set and verbose:
1084
+ excluded_str = ", ".join(f"{r}:{p}" for r, p in sorted(exclude_set))
1085
+ print(f"(Excluding pins: {excluded_str})")
1086
+ print()
1087
+
1088
+ # Parse layer specification: "J1 | R1,R2,C1 | J2"
1089
+ layer_specs = [
1090
+ [ref.strip() for ref in group.split(",")]
1091
+ for group in args.layers.split("|")
1092
+ ]
1093
+
1094
+ # Validate all refs exist
1095
+ all_refs = [ref for group in layer_specs for ref in group]
1096
+ for ref in all_refs:
1097
+ if ref not in data["components"]:
1098
+ print(f"Error: component '{ref}' not found in netlist.")
1099
+ print(f"Available: {', '.join(sorted(data['components'].keys()))}")
1100
+ sys.exit(1)
1101
+
1102
+ # Build reorderable set
1103
+ reorderable_refs = set(args.reorderable)
1104
+ if not reorderable_refs and verbose:
1105
+ print("Warning: no --reorderable refs specified; no optimization possible.")
1106
+
1107
+ # Build layer PinColumn lists, applying exclusions
1108
+ layers: list[list[PinColumn]] = []
1109
+ for group in layer_specs:
1110
+ layer: list[PinColumn] = []
1111
+ for ref in group:
1112
+ pins = [
1113
+ p for p in infer_pin_order(ref, data["nets"])
1114
+ if (ref, p) not in exclude_set
1115
+ ]
1116
+ layer.append(PinColumn(ref=ref, pin_order=pins))
1117
+ layers.append(layer)
1118
+
1119
+ # Print layer summary
1120
+ if verbose:
1121
+ print("Layer assignment:")
1122
+ for i, layer in enumerate(layers):
1123
+ refs = ", ".join(c.ref for c in layer)
1124
+ pins = sum(len(c.pin_order) for c in layer)
1125
+ print(f" Layer {i}: [{refs}] ({pins} pins)")
1126
+ print()
1127
+
1128
+ # Run sweep
1129
+ report = sweep_optimize(layers, reorderable_refs, data["nets"])
1130
+
1131
+ if args.json_output:
1132
+ print(json.dumps(report_to_dict(report, data["nets"]), indent=2))
1133
+ elif not args.quiet:
1134
+ print(format_multilayer_report(report, data["nets"]))
1135
+
1136
+ sys.exit(0 if report.total_crossings_after == 0 else 1)
1137
+
1138
+
1139
+ def _cmd_plan_footprint(args):
1140
+ """Handle the 'plan-footprint' subcommand."""
1141
+ if not Path(args.netlist).exists():
1142
+ print(f"Error: file not found: {args.netlist}")
1143
+ sys.exit(1)
1144
+
1145
+ data = parse_netlist(args.netlist)
1146
+
1147
+ # Validate target component
1148
+ if args.target not in data["components"]:
1149
+ print(f"Error: target component '{args.target}' not found in netlist.")
1150
+ print(f"Available: {', '.join(sorted(data['components'].keys()))}")
1151
+ sys.exit(1)
1152
+
1153
+ # Parse --anchors: "J2,U1 | R1,R2,C1"
1154
+ anchor_layers: list[list[PinColumn]] = []
1155
+ for group_str in args.anchors.split("|"):
1156
+ layer: list[PinColumn] = []
1157
+ for ref in group_str.split(","):
1158
+ ref = ref.strip()
1159
+ if not ref:
1160
+ continue
1161
+ if ref not in data["components"]:
1162
+ print(f"Error: anchor component '{ref}' not found in netlist.")
1163
+ print(f"Available: {', '.join(sorted(data['components'].keys()))}")
1164
+ sys.exit(1)
1165
+ pins = infer_pin_order(ref, data["nets"])
1166
+ layer.append(PinColumn(ref=ref, pin_order=pins))
1167
+ if layer:
1168
+ anchor_layers.append(layer)
1169
+
1170
+ # Parse locks
1171
+ try:
1172
+ locks = parse_pin_locks(args.lock or [])
1173
+ except ValueError as e:
1174
+ print(f"Error: {e}")
1175
+ sys.exit(1)
1176
+
1177
+ # Infer target pin count from component or default to max pin in netlist
1178
+ target_pins = infer_pin_order(args.target, data["nets"])
1179
+ # Include locked pins that may not appear in the netlist (e.g. NC pins)
1180
+ for pin in locks:
1181
+ if pin not in target_pins:
1182
+ target_pins.append(pin)
1183
+ # Re-sort
1184
+ def _sort_key(p: str):
1185
+ try:
1186
+ return (0, int(p))
1187
+ except ValueError:
1188
+ return (1, p)
1189
+ target_pins.sort(key=_sort_key)
1190
+
1191
+ exclude_nets = set(args.exclude_nets or [])
1192
+ verbose = not args.quiet and not args.json_output
1193
+
1194
+ if verbose:
1195
+ print(f"Planning footprint for {args.target} ({len(target_pins)} pins)")
1196
+ print(f"Anchor layers: {args.anchors}")
1197
+ if locks:
1198
+ locked_str = ", ".join(
1199
+ f"{p}={'NC' if n is None else n}"
1200
+ for p, n in sorted(locks.items(), key=lambda x: _sort_key(x[0]))
1201
+ )
1202
+ print(f"Locked pins: {locked_str}")
1203
+ if exclude_nets:
1204
+ print(f"Excluded nets: {', '.join(sorted(exclude_nets))}")
1205
+ print()
1206
+
1207
+ plan = plan_footprint(
1208
+ target_ref=args.target,
1209
+ target_pins=target_pins,
1210
+ anchor_layers=anchor_layers,
1211
+ nets=data["nets"],
1212
+ locks=locks,
1213
+ unmatched=args.unmatched,
1214
+ exclude_nets=exclude_nets,
1215
+ )
1216
+
1217
+ if args.json_output:
1218
+ print(json.dumps(plan_to_dict(plan), indent=2))
1219
+ elif not args.quiet:
1220
+ print(format_footprint_plan(plan))
1221
+
1222
+ sys.exit(0 if plan.crossings_after == 0 else 1)
1223
+
1224
+
1225
+ def main():
1226
+ import argparse
1227
+
1228
+ parser = argparse.ArgumentParser(
1229
+ description="Analyze trace crossings in a KiCad netlist.",
1230
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1231
+ )
1232
+ subparsers = parser.add_subparsers(dest="command")
1233
+
1234
+ # --- analyze subcommand (default) ---
1235
+ analyze = subparsers.add_parser(
1236
+ "analyze",
1237
+ help="Analyze crossings between component layers.",
1238
+ epilog=(
1239
+ "Example: crossing-analyzer analyze net.net "
1240
+ "--layers 'J1 | R1,C1 | J2' --reorderable J2"
1241
+ ),
1242
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1243
+ )
1244
+ analyze.add_argument("netlist", help="Path to a KiCad .net file generated by SKiDL")
1245
+ analyze.add_argument(
1246
+ "--layers", type=str, required=True,
1247
+ help=(
1248
+ "Layer specification: components per layer separated by |. "
1249
+ "Multiple components in a layer separated by commas. "
1250
+ "Example: 'J1 | R1,R2,C1 | J2'"
1251
+ ),
1252
+ )
1253
+ analyze.add_argument(
1254
+ "--reorderable", nargs="*", default=[], metavar="REF",
1255
+ help="Components whose pin order can be changed.",
1256
+ )
1257
+ analyze.add_argument(
1258
+ "--exclude", nargs="*", default=[], metavar="REF:PIN",
1259
+ help="Exclude pins from analysis (e.g. J1:SH).",
1260
+ )
1261
+ analyze.add_argument(
1262
+ "--json", action="store_true", dest="json_output",
1263
+ help="Output results as JSON instead of human-readable text.",
1264
+ )
1265
+ analyze.add_argument(
1266
+ "--quiet", "-q", action="store_true",
1267
+ help="Suppress all output; exit code 0 = no crossings, 1 = crossings remain.",
1268
+ )
1269
+ analyze.set_defaults(func=_cmd_analyze)
1270
+
1271
+ # --- plan-footprint subcommand ---
1272
+ plan = subparsers.add_parser(
1273
+ "plan-footprint",
1274
+ help="Compute an optimal pin map for a custom footprint.",
1275
+ epilog=(
1276
+ "Example: crossing-analyzer plan-footprint net.net "
1277
+ "--target J1 --anchors 'J2,U1 | R1,R2,C1' "
1278
+ "--lock 1=NC 2=NC 3=GND_EARLY_A"
1279
+ ),
1280
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1281
+ )
1282
+ plan.add_argument("netlist", help="Path to a KiCad .net file generated by SKiDL")
1283
+ plan.add_argument(
1284
+ "--target", required=True, metavar="REF",
1285
+ help="Component ref whose footprint is being designed.",
1286
+ )
1287
+ plan.add_argument(
1288
+ "--anchors", required=True,
1289
+ help=(
1290
+ "Fixed components organized in layers separated by |. "
1291
+ "Example: 'J2,U1 | R1,R2,R3,C1'"
1292
+ ),
1293
+ )
1294
+ plan.add_argument(
1295
+ "--lock", nargs="*", default=[], metavar="PIN=NET",
1296
+ help="Lock pins to specific nets. Use PIN=NC for no-connect. Example: 1=NC 3=GND_EARLY_A",
1297
+ )
1298
+ plan.add_argument(
1299
+ "--unmatched", choices=["start", "end", "split"], default="end",
1300
+ help="Where to place unmatched pins: start, end (default), or split.",
1301
+ )
1302
+ plan.add_argument(
1303
+ "--exclude-nets", nargs="*", default=[], metavar="NET",
1304
+ help="Net names to exclude from analysis (e.g. GND).",
1305
+ )
1306
+ plan.add_argument(
1307
+ "--json", action="store_true", dest="json_output",
1308
+ help="Output results as JSON instead of human-readable text.",
1309
+ )
1310
+ plan.add_argument(
1311
+ "--quiet", "-q", action="store_true",
1312
+ help="Suppress all output; exit code 0 = no crossings, 1 = crossings remain.",
1313
+ )
1314
+ plan.set_defaults(func=_cmd_plan_footprint)
1315
+
1316
+ # Backward compatibility: if first arg is not a known subcommand,
1317
+ # assume "analyze" mode (legacy CLI: crossing-analyzer file.net --layers ...)
1318
+ known_commands = {"analyze", "plan-footprint", "-h", "--help"}
1319
+ argv = sys.argv[1:]
1320
+ if argv and argv[0] not in known_commands:
1321
+ argv = ["analyze"] + argv
1322
+
1323
+ args = parser.parse_args(argv)
1324
+
1325
+ if args.command is None:
1326
+ parser.print_help()
1327
+ sys.exit(0)
1328
+
1329
+ args.func(args)
1330
+
1331
+
1332
+ if __name__ == "__main__":
1333
+ main()