kicad-sch-api 0.2.0__py3-none-any.whl → 0.2.2__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.
Potentially problematic release.
This version of kicad-sch-api might be problematic. Click here for more details.
- kicad_sch_api/__init__.py +6 -2
- kicad_sch_api/cli.py +67 -62
- kicad_sch_api/core/component_bounds.py +477 -0
- kicad_sch_api/core/components.py +22 -10
- kicad_sch_api/core/config.py +127 -0
- kicad_sch_api/core/formatter.py +190 -24
- kicad_sch_api/core/geometry.py +111 -0
- kicad_sch_api/core/ic_manager.py +43 -37
- kicad_sch_api/core/junctions.py +17 -22
- kicad_sch_api/core/manhattan_routing.py +430 -0
- kicad_sch_api/core/parser.py +587 -197
- kicad_sch_api/core/pin_utils.py +149 -0
- kicad_sch_api/core/schematic.py +683 -207
- kicad_sch_api/core/simple_manhattan.py +228 -0
- kicad_sch_api/core/types.py +44 -4
- kicad_sch_api/core/wire_routing.py +380 -0
- kicad_sch_api/core/wires.py +29 -25
- kicad_sch_api/discovery/__init__.py +1 -1
- kicad_sch_api/discovery/search_index.py +142 -107
- kicad_sch_api/library/cache.py +70 -62
- {kicad_sch_api-0.2.0.dist-info → kicad_sch_api-0.2.2.dist-info}/METADATA +212 -40
- kicad_sch_api-0.2.2.dist-info/RECORD +31 -0
- kicad_sch_api-0.2.0.dist-info/RECORD +0 -24
- {kicad_sch_api-0.2.0.dist-info → kicad_sch_api-0.2.2.dist-info}/WHEEL +0 -0
- {kicad_sch_api-0.2.0.dist-info → kicad_sch_api-0.2.2.dist-info}/entry_points.txt +0 -0
- {kicad_sch_api-0.2.0.dist-info → kicad_sch_api-0.2.2.dist-info}/licenses/LICENSE +0 -0
- {kicad_sch_api-0.2.0.dist-info → kicad_sch_api-0.2.2.dist-info}/top_level.txt +0 -0
kicad_sch_api/core/junctions.py
CHANGED
|
@@ -9,7 +9,7 @@ import logging
|
|
|
9
9
|
import uuid as uuid_module
|
|
10
10
|
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
11
11
|
|
|
12
|
-
from .types import
|
|
12
|
+
from .types import Junction, Point
|
|
13
13
|
|
|
14
14
|
logger = logging.getLogger(__name__)
|
|
15
15
|
|
|
@@ -35,10 +35,10 @@ class JunctionCollection:
|
|
|
35
35
|
self._junctions: List[Junction] = junctions or []
|
|
36
36
|
self._uuid_index: Dict[str, int] = {}
|
|
37
37
|
self._modified = False
|
|
38
|
-
|
|
38
|
+
|
|
39
39
|
# Build UUID index
|
|
40
40
|
self._rebuild_index()
|
|
41
|
-
|
|
41
|
+
|
|
42
42
|
logger.debug(f"JunctionCollection initialized with {len(self._junctions)} junctions")
|
|
43
43
|
|
|
44
44
|
def _rebuild_index(self):
|
|
@@ -64,7 +64,7 @@ class JunctionCollection:
|
|
|
64
64
|
position: Union[Point, Tuple[float, float]],
|
|
65
65
|
diameter: float = 0,
|
|
66
66
|
color: Tuple[int, int, int, int] = (0, 0, 0, 0),
|
|
67
|
-
uuid: Optional[str] = None
|
|
67
|
+
uuid: Optional[str] = None,
|
|
68
68
|
) -> str:
|
|
69
69
|
"""
|
|
70
70
|
Add a junction to the collection.
|
|
@@ -92,12 +92,7 @@ class JunctionCollection:
|
|
|
92
92
|
position = Point(position[0], position[1])
|
|
93
93
|
|
|
94
94
|
# Create junction
|
|
95
|
-
junction = Junction(
|
|
96
|
-
uuid=uuid,
|
|
97
|
-
position=position,
|
|
98
|
-
diameter=diameter,
|
|
99
|
-
color=color
|
|
100
|
-
)
|
|
95
|
+
junction = Junction(uuid=uuid, position=position, diameter=diameter, color=color)
|
|
101
96
|
|
|
102
97
|
# Add to collection
|
|
103
98
|
self._junctions.append(junction)
|
|
@@ -128,7 +123,9 @@ class JunctionCollection:
|
|
|
128
123
|
logger.debug(f"Removed junction: {uuid}")
|
|
129
124
|
return True
|
|
130
125
|
|
|
131
|
-
def get_at_position(
|
|
126
|
+
def get_at_position(
|
|
127
|
+
self, position: Union[Point, Tuple[float, float]], tolerance: float = 0.01
|
|
128
|
+
) -> Optional[Junction]:
|
|
132
129
|
"""
|
|
133
130
|
Find junction at or near a specific position.
|
|
134
131
|
|
|
@@ -145,10 +142,12 @@ class JunctionCollection:
|
|
|
145
142
|
for junction in self._junctions:
|
|
146
143
|
if junction.position.distance_to(position) <= tolerance:
|
|
147
144
|
return junction
|
|
148
|
-
|
|
145
|
+
|
|
149
146
|
return None
|
|
150
147
|
|
|
151
|
-
def get_by_point(
|
|
148
|
+
def get_by_point(
|
|
149
|
+
self, point: Union[Point, Tuple[float, float]], tolerance: float = 0.01
|
|
150
|
+
) -> List[Junction]:
|
|
152
151
|
"""
|
|
153
152
|
Find all junctions near a point.
|
|
154
153
|
|
|
@@ -172,21 +171,17 @@ class JunctionCollection:
|
|
|
172
171
|
def get_statistics(self) -> Dict[str, Any]:
|
|
173
172
|
"""Get junction collection statistics."""
|
|
174
173
|
if not self._junctions:
|
|
175
|
-
return {
|
|
176
|
-
|
|
177
|
-
"avg_diameter": 0,
|
|
178
|
-
"positions": []
|
|
179
|
-
}
|
|
180
|
-
|
|
174
|
+
return {"total_junctions": 0, "avg_diameter": 0, "positions": []}
|
|
175
|
+
|
|
181
176
|
avg_diameter = sum(j.diameter for j in self._junctions) / len(self._junctions)
|
|
182
177
|
positions = [(j.position.x, j.position.y) for j in self._junctions]
|
|
183
|
-
|
|
178
|
+
|
|
184
179
|
return {
|
|
185
180
|
"total_junctions": len(self._junctions),
|
|
186
181
|
"avg_diameter": avg_diameter,
|
|
187
182
|
"positions": positions,
|
|
188
183
|
"unique_diameters": len(set(j.diameter for j in self._junctions)),
|
|
189
|
-
"unique_colors": len(set(j.color for j in self._junctions))
|
|
184
|
+
"unique_colors": len(set(j.color for j in self._junctions)),
|
|
190
185
|
}
|
|
191
186
|
|
|
192
187
|
def clear(self):
|
|
@@ -203,4 +198,4 @@ class JunctionCollection:
|
|
|
203
198
|
|
|
204
199
|
def mark_saved(self):
|
|
205
200
|
"""Mark collection as saved (reset modified flag)."""
|
|
206
|
-
self._modified = False
|
|
201
|
+
self._modified = False
|
|
@@ -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
|