kicad-sch-api 0.1.7__py3-none-any.whl → 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.
@@ -0,0 +1,430 @@
1
+ """
2
+ Manhattan routing with obstacle avoidance for KiCAD schematics.
3
+
4
+ This module implements grid-based pathfinding algorithms for routing wires
5
+ around component obstacles while maintaining Manhattan (orthogonal) constraints
6
+ and perfect KiCAD grid alignment.
7
+
8
+ Based on research into EDA routing algorithms and adapted for schematic capture.
9
+ """
10
+
11
+ import heapq
12
+ import logging
13
+ from dataclasses import dataclass, field
14
+ from enum import Enum
15
+ from typing import Dict, List, Optional, Set, Tuple
16
+
17
+ from .component_bounds import BoundingBox, check_path_collision, get_component_bounding_box
18
+ from .types import Point, SchematicSymbol
19
+ from .wire_routing import snap_to_kicad_grid
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class RoutingStrategy(Enum):
25
+ """Routing strategy options for different optimization goals."""
26
+
27
+ SHORTEST = "shortest" # Minimize Manhattan distance
28
+ CLEARANCE = "clearance" # Maximize obstacle clearance
29
+ AESTHETIC = "aesthetic" # Balance distance, turns, and clearance
30
+ DIRECT = "direct" # Fallback to direct routing
31
+
32
+
33
+ @dataclass
34
+ class GridPoint:
35
+ """A point on the routing grid."""
36
+
37
+ x: int
38
+ y: int
39
+
40
+ def __hash__(self) -> int:
41
+ return hash((self.x, self.y))
42
+
43
+ def __eq__(self, other) -> bool:
44
+ return isinstance(other, GridPoint) and self.x == other.x and self.y == other.y
45
+
46
+ def to_world_point(self, grid_size: float = 1.27) -> Point:
47
+ """Convert grid coordinates to world coordinates."""
48
+ return Point(self.x * grid_size, self.y * grid_size)
49
+
50
+ @classmethod
51
+ def from_world_point(cls, point: Point, grid_size: float = 1.27) -> "GridPoint":
52
+ """Convert world coordinates to grid coordinates."""
53
+ return cls(x=round(point.x / grid_size), y=round(point.y / grid_size))
54
+
55
+ def manhattan_distance(self, other: "GridPoint") -> int:
56
+ """Calculate Manhattan distance to another grid point."""
57
+ return abs(self.x - other.x) + abs(self.y - other.y)
58
+
59
+
60
+ @dataclass
61
+ class PathNode:
62
+ """Node for A* pathfinding algorithm."""
63
+
64
+ position: GridPoint
65
+ g_cost: float # Cost from start
66
+ h_cost: float # Heuristic cost to goal
67
+ parent: Optional["PathNode"] = None
68
+
69
+ @property
70
+ def f_cost(self) -> float:
71
+ """Total cost for A* (g + h)."""
72
+ return self.g_cost + self.h_cost
73
+
74
+ def __lt__(self, other: "PathNode") -> bool:
75
+ """For priority queue ordering."""
76
+ return self.f_cost < other.f_cost
77
+
78
+
79
+ @dataclass
80
+ class RoutingGrid:
81
+ """Grid representation for Manhattan routing."""
82
+
83
+ grid_size: float = 1.27 # KiCAD standard grid in mm
84
+ obstacles: Set[GridPoint] = field(default_factory=set)
85
+ boundaries: Optional[BoundingBox] = None
86
+ clearance_map: Dict[GridPoint, float] = field(default_factory=dict)
87
+
88
+ def is_valid_point(self, point: GridPoint) -> bool:
89
+ """Check if a grid point is valid for routing."""
90
+ # Check if point is an obstacle
91
+ if point in self.obstacles:
92
+ return False
93
+
94
+ # Check boundaries if defined
95
+ if self.boundaries:
96
+ world_point = point.to_world_point(self.grid_size)
97
+ if not self.boundaries.contains_point(world_point):
98
+ return False
99
+
100
+ return True
101
+
102
+ def get_neighbors(self, point: GridPoint) -> List[GridPoint]:
103
+ """Get valid neighboring points (4-connected Manhattan)."""
104
+ neighbors = []
105
+ for dx, dy in [(0, 1), (1, 0), (0, -1), (-1, 0)]: # Up, Right, Down, Left
106
+ neighbor = GridPoint(point.x + dx, point.y + dy)
107
+ if self.is_valid_point(neighbor):
108
+ neighbors.append(neighbor)
109
+ return neighbors
110
+
111
+ def get_clearance_cost(self, point: GridPoint) -> float:
112
+ """Get clearance-based cost for routing strategies."""
113
+ return self.clearance_map.get(point, 0.0)
114
+
115
+
116
+ class ManhattanRouter:
117
+ """Manhattan routing engine with obstacle avoidance."""
118
+
119
+ def __init__(self, grid_size: float = 1.27, default_clearance: float = 1.27):
120
+ """
121
+ Initialize Manhattan router.
122
+
123
+ Args:
124
+ grid_size: Grid spacing in mm (KiCAD standard: 1.27mm)
125
+ default_clearance: Default clearance from obstacles in mm
126
+ """
127
+ self.grid_size = grid_size
128
+ self.default_clearance = default_clearance
129
+
130
+ def route_between_points(
131
+ self,
132
+ start: Point,
133
+ end: Point,
134
+ obstacles: List[BoundingBox],
135
+ strategy: RoutingStrategy = RoutingStrategy.AESTHETIC,
136
+ clearance: Optional[float] = None,
137
+ ) -> Optional[List[Point]]:
138
+ """
139
+ Route between two points avoiding obstacles.
140
+
141
+ Args:
142
+ start: Starting point in world coordinates
143
+ end: Ending point in world coordinates
144
+ obstacles: List of obstacle bounding boxes
145
+ strategy: Routing strategy to use
146
+ clearance: Obstacle clearance (uses default if None)
147
+
148
+ Returns:
149
+ List of waypoints for the route, or None if no route found
150
+ """
151
+ if clearance is None:
152
+ clearance = self.default_clearance
153
+
154
+ logger.debug(f"Routing from {start} to {end} with strategy {strategy.value}")
155
+
156
+ # Check if direct path is clear
157
+ if not check_path_collision(start, end, obstacles, clearance):
158
+ logger.debug("Direct path is clear, using direct routing")
159
+ return [start, end]
160
+
161
+ # Grid-snap the start and end points
162
+ start_snapped = snap_to_kicad_grid(start, self.grid_size)
163
+ end_snapped = snap_to_kicad_grid(end, self.grid_size)
164
+
165
+ # Build routing grid
166
+ routing_grid = self._build_routing_grid(obstacles, clearance, start_snapped, end_snapped)
167
+
168
+ # Convert to grid coordinates
169
+ start_grid = GridPoint.from_world_point(start_snapped, self.grid_size)
170
+ end_grid = GridPoint.from_world_point(end_snapped, self.grid_size)
171
+
172
+ # Find path using A*
173
+ path_grid = self._find_path_astar(start_grid, end_grid, routing_grid, strategy)
174
+
175
+ if not path_grid:
176
+ logger.warning(f"No path found from {start} to {end}")
177
+ return None
178
+
179
+ # Convert back to world coordinates
180
+ path_world = [point.to_world_point(self.grid_size) for point in path_grid]
181
+
182
+ # Optimize path
183
+ optimized_path = self._optimize_path(path_world)
184
+
185
+ logger.debug(f"Found path with {len(optimized_path)} waypoints")
186
+ return optimized_path
187
+
188
+ def _build_routing_grid(
189
+ self, obstacles: List[BoundingBox], clearance: float, start: Point, end: Point
190
+ ) -> RoutingGrid:
191
+ """Build routing grid with obstacles and clearance information."""
192
+ # Calculate grid bounds that encompass all obstacles plus start/end
193
+ min_x = min(start.x, end.x)
194
+ max_x = max(start.x, end.x)
195
+ min_y = min(start.y, end.y)
196
+ max_y = max(start.y, end.y)
197
+
198
+ for obstacle in obstacles:
199
+ min_x = min(min_x, obstacle.min_x - clearance)
200
+ max_x = max(max_x, obstacle.max_x + clearance)
201
+ min_y = min(min_y, obstacle.min_y - clearance)
202
+ max_y = max(max_y, obstacle.max_y + clearance)
203
+
204
+ # Add margin for routing
205
+ margin = clearance * 2
206
+ grid_bounds = BoundingBox(min_x - margin, min_y - margin, max_x + margin, max_y + margin)
207
+
208
+ # Create grid and mark obstacles
209
+ grid = RoutingGrid(self.grid_size, boundaries=grid_bounds)
210
+
211
+ for obstacle in obstacles:
212
+ self._mark_obstacle_on_grid(grid, obstacle, clearance)
213
+
214
+ return grid
215
+
216
+ def _mark_obstacle_on_grid(self, grid: RoutingGrid, obstacle: BoundingBox, clearance: float):
217
+ """Mark obstacle area on routing grid."""
218
+ # Expand obstacle by clearance
219
+ expanded_obstacle = obstacle.expand(clearance)
220
+
221
+ # Convert to grid coordinates
222
+ min_grid_x = int((expanded_obstacle.min_x) // self.grid_size) - 1
223
+ max_grid_x = int((expanded_obstacle.max_x) // self.grid_size) + 1
224
+ min_grid_y = int((expanded_obstacle.min_y) // self.grid_size) - 1
225
+ max_grid_y = int((expanded_obstacle.max_y) // self.grid_size) + 1
226
+
227
+ # Mark obstacle points
228
+ for gx in range(min_grid_x, max_grid_x + 1):
229
+ for gy in range(min_grid_y, max_grid_y + 1):
230
+ grid_point = GridPoint(gx, gy)
231
+ world_point = grid_point.to_world_point(self.grid_size)
232
+
233
+ if expanded_obstacle.contains_point(world_point):
234
+ grid.obstacles.add(grid_point)
235
+ else:
236
+ # Calculate clearance cost for points near obstacles
237
+ distance_to_obstacle = self._distance_to_bbox(world_point, obstacle)
238
+ if distance_to_obstacle < clearance * 2:
239
+ # Inverse cost - closer to obstacle = higher cost
240
+ cost = max(0, clearance * 2 - distance_to_obstacle)
241
+ grid.clearance_map[grid_point] = cost
242
+
243
+ def _distance_to_bbox(self, point: Point, bbox: BoundingBox) -> float:
244
+ """Calculate minimum distance from point to bounding box."""
245
+ dx = max(bbox.min_x - point.x, 0, point.x - bbox.max_x)
246
+ dy = max(bbox.min_y - point.y, 0, point.y - bbox.max_y)
247
+ return (dx * dx + dy * dy) ** 0.5
248
+
249
+ def _find_path_astar(
250
+ self, start: GridPoint, goal: GridPoint, grid: RoutingGrid, strategy: RoutingStrategy
251
+ ) -> Optional[List[GridPoint]]:
252
+ """Find path using A* algorithm with strategy-specific costs."""
253
+ if start == goal:
254
+ return [start]
255
+
256
+ # Priority queue for open set
257
+ open_set = []
258
+ open_dict: Dict[GridPoint, PathNode] = {}
259
+ closed_set: Set[GridPoint] = set()
260
+
261
+ # Initialize start node
262
+ start_node = PathNode(
263
+ position=start, g_cost=0, h_cost=self._heuristic_cost(start, goal, strategy)
264
+ )
265
+
266
+ heapq.heappush(open_set, start_node)
267
+ open_dict[start] = start_node
268
+
269
+ while open_set:
270
+ # Get node with lowest f_cost
271
+ current = heapq.heappop(open_set)
272
+ del open_dict[current.position]
273
+ closed_set.add(current.position)
274
+
275
+ # Check if we reached the goal
276
+ if current.position == goal:
277
+ return self._reconstruct_path(current)
278
+
279
+ # Check neighbors
280
+ for neighbor_pos in grid.get_neighbors(current.position):
281
+ if neighbor_pos in closed_set:
282
+ continue
283
+
284
+ # Calculate costs
285
+ move_cost = self._movement_cost(current.position, neighbor_pos, grid, strategy)
286
+ tentative_g = current.g_cost + move_cost
287
+
288
+ # Check if this path to neighbor is better
289
+ if neighbor_pos in open_dict:
290
+ neighbor_node = open_dict[neighbor_pos]
291
+ if tentative_g < neighbor_node.g_cost:
292
+ neighbor_node.g_cost = tentative_g
293
+ neighbor_node.parent = current
294
+ else:
295
+ # Add new node to open set
296
+ neighbor_node = PathNode(
297
+ position=neighbor_pos,
298
+ g_cost=tentative_g,
299
+ h_cost=self._heuristic_cost(neighbor_pos, goal, strategy),
300
+ parent=current,
301
+ )
302
+ heapq.heappush(open_set, neighbor_node)
303
+ open_dict[neighbor_pos] = neighbor_node
304
+
305
+ # No path found
306
+ return None
307
+
308
+ def _heuristic_cost(
309
+ self, point: GridPoint, goal: GridPoint, strategy: RoutingStrategy
310
+ ) -> float:
311
+ """Calculate heuristic cost based on strategy."""
312
+ manhattan_dist = point.manhattan_distance(goal)
313
+
314
+ if strategy == RoutingStrategy.SHORTEST:
315
+ return float(manhattan_dist)
316
+ elif strategy == RoutingStrategy.CLEARANCE:
317
+ return float(manhattan_dist) * 0.5 # Lower weight on distance
318
+ elif strategy == RoutingStrategy.AESTHETIC:
319
+ return float(manhattan_dist) * 0.8 # Balanced weight
320
+ else: # DIRECT fallback
321
+ return float(manhattan_dist)
322
+
323
+ def _movement_cost(
324
+ self,
325
+ from_point: GridPoint,
326
+ to_point: GridPoint,
327
+ grid: RoutingGrid,
328
+ strategy: RoutingStrategy,
329
+ ) -> float:
330
+ """Calculate cost of moving between two adjacent grid points."""
331
+ base_cost = 1.0 # Base movement cost
332
+
333
+ if strategy == RoutingStrategy.SHORTEST:
334
+ return base_cost
335
+
336
+ elif strategy == RoutingStrategy.CLEARANCE:
337
+ # Higher cost for points near obstacles
338
+ clearance_cost = grid.get_clearance_cost(to_point)
339
+ return base_cost + clearance_cost * 2.0
340
+
341
+ elif strategy == RoutingStrategy.AESTHETIC:
342
+ # Balance clearance with reasonable cost
343
+ clearance_cost = grid.get_clearance_cost(to_point)
344
+ return base_cost + clearance_cost * 0.5
345
+
346
+ else: # DIRECT fallback
347
+ return base_cost
348
+
349
+ def _reconstruct_path(self, goal_node: PathNode) -> List[GridPoint]:
350
+ """Reconstruct path from goal node back to start."""
351
+ path = []
352
+ current = goal_node
353
+
354
+ while current:
355
+ path.append(current.position)
356
+ current = current.parent
357
+
358
+ path.reverse()
359
+ return path
360
+
361
+ def _optimize_path(self, path: List[Point]) -> List[Point]:
362
+ """Optimize path by removing unnecessary waypoints."""
363
+ if len(path) < 3:
364
+ return path
365
+
366
+ optimized = [path[0]] # Always keep start point
367
+
368
+ for i in range(1, len(path) - 1):
369
+ prev_point = optimized[-1]
370
+ current_point = path[i]
371
+ next_point = path[i + 1]
372
+
373
+ # Check if we can skip current point (collinear points)
374
+ if not self._are_collinear(prev_point, current_point, next_point):
375
+ optimized.append(current_point)
376
+
377
+ optimized.append(path[-1]) # Always keep end point
378
+ return optimized
379
+
380
+ def _are_collinear(self, p1: Point, p2: Point, p3: Point, tolerance: float = 0.01) -> bool:
381
+ """Check if three points are collinear (on the same line)."""
382
+ # For Manhattan routing, points are collinear if they form a straight horizontal or vertical line
383
+ horizontal = abs(p1.y - p2.y) < tolerance and abs(p2.y - p3.y) < tolerance
384
+ vertical = abs(p1.x - p2.x) < tolerance and abs(p2.x - p3.x) < tolerance
385
+ return horizontal or vertical
386
+
387
+
388
+ def route_around_obstacles(
389
+ start: Point,
390
+ end: Point,
391
+ components: List[SchematicSymbol],
392
+ strategy: RoutingStrategy = RoutingStrategy.AESTHETIC,
393
+ clearance: float = 1.27,
394
+ grid_size: float = 1.27,
395
+ ) -> Optional[List[Point]]:
396
+ """
397
+ High-level function to route between two points avoiding component obstacles.
398
+
399
+ Args:
400
+ start: Starting point in world coordinates
401
+ end: Ending point in world coordinates
402
+ components: List of components to avoid
403
+ strategy: Routing strategy to use
404
+ clearance: Minimum clearance from components
405
+ grid_size: Grid spacing for routing
406
+
407
+ Returns:
408
+ List of waypoints for the route, or None if no route found
409
+ """
410
+ logger.debug(f"🛣️ Manhattan routing: {start} → {end}, strategy={strategy.value}")
411
+
412
+ # Get component bounding boxes
413
+ obstacles = []
414
+ for component in components:
415
+ bbox = get_component_bounding_box(component, include_properties=False)
416
+ obstacles.append(bbox)
417
+ logger.debug(f" Obstacle: {component.reference} at {bbox}")
418
+
419
+ # Create router and find path
420
+ router = ManhattanRouter(grid_size, clearance)
421
+ path = router.route_between_points(start, end, obstacles, strategy, clearance)
422
+
423
+ if path:
424
+ logger.debug(f" ✅ Route found with {len(path)} waypoints")
425
+ for i, point in enumerate(path):
426
+ logger.debug(f" {i}: {point}")
427
+ else:
428
+ logger.warning(f" ❌ No route found")
429
+
430
+ return path