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
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Component bounding box calculations for Manhattan routing.
|
|
3
|
+
|
|
4
|
+
Adapted from circuit-synth's proven symbol geometry logic for accurate
|
|
5
|
+
KiCAD component bounds calculation and collision detection.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import math
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import List, Optional, Tuple
|
|
12
|
+
|
|
13
|
+
from ..library.cache import get_symbol_cache
|
|
14
|
+
from .types import Point, SchematicSymbol
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class BoundingBox:
|
|
21
|
+
"""Axis-aligned bounding box (adapted from circuit-synth BBox)."""
|
|
22
|
+
|
|
23
|
+
min_x: float
|
|
24
|
+
min_y: float
|
|
25
|
+
max_x: float
|
|
26
|
+
max_y: float
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def width(self) -> float:
|
|
30
|
+
"""Get bounding box width."""
|
|
31
|
+
return self.max_x - self.min_x
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def height(self) -> float:
|
|
35
|
+
"""Get bounding box height."""
|
|
36
|
+
return self.max_y - self.min_y
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def center(self) -> Point:
|
|
40
|
+
"""Get bounding box center point."""
|
|
41
|
+
return Point((self.min_x + self.max_x) / 2, (self.min_y + self.max_y) / 2)
|
|
42
|
+
|
|
43
|
+
def contains_point(self, point: Point) -> bool:
|
|
44
|
+
"""Check if point is inside this bounding box."""
|
|
45
|
+
return self.min_x <= point.x <= self.max_x and self.min_y <= point.y <= self.max_y
|
|
46
|
+
|
|
47
|
+
def overlaps(self, other: "BoundingBox") -> bool:
|
|
48
|
+
"""Check if this bounding box overlaps with another."""
|
|
49
|
+
return not (
|
|
50
|
+
self.max_x < other.min_x # this right < other left
|
|
51
|
+
or self.min_x > other.max_x # this left > other right
|
|
52
|
+
or self.max_y < other.min_y # this bottom < other top
|
|
53
|
+
or self.min_y > other.max_y
|
|
54
|
+
) # this top > other bottom
|
|
55
|
+
|
|
56
|
+
def expand(self, margin: float) -> "BoundingBox":
|
|
57
|
+
"""Return expanded bounding box with margin."""
|
|
58
|
+
return BoundingBox(
|
|
59
|
+
self.min_x - margin, self.min_y - margin, self.max_x + margin, self.max_y + margin
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def __repr__(self) -> str:
|
|
63
|
+
return f"BoundingBox(min_x={self.min_x:.2f}, min_y={self.min_y:.2f}, max_x={self.max_x:.2f}, max_y={self.max_y:.2f})"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class SymbolBoundingBoxCalculator:
|
|
67
|
+
"""
|
|
68
|
+
Calculate accurate bounding boxes for KiCAD symbols.
|
|
69
|
+
|
|
70
|
+
Adapted from circuit-synth SymbolBoundingBoxCalculator for compatibility
|
|
71
|
+
with kicad-sch-api's symbol cache system.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
# KiCAD default dimensions (from circuit-synth)
|
|
75
|
+
DEFAULT_TEXT_HEIGHT = 2.54 # 100 mils
|
|
76
|
+
DEFAULT_PIN_LENGTH = 2.54 # 100 mils
|
|
77
|
+
DEFAULT_PIN_NAME_OFFSET = 0.508 # 20 mils
|
|
78
|
+
DEFAULT_PIN_NUMBER_SIZE = 1.27 # 50 mils
|
|
79
|
+
DEFAULT_PIN_TEXT_WIDTH_RATIO = 2.0 # Width to height ratio for pin text
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def calculate_bounding_box(cls, symbol, include_properties: bool = True) -> BoundingBox:
|
|
83
|
+
"""
|
|
84
|
+
Calculate accurate bounding box from SymbolDefinition.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
symbol: SymbolDefinition from symbol cache
|
|
88
|
+
include_properties: Whether to include space for Reference/Value labels
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
BoundingBox with accurate dimensions
|
|
92
|
+
"""
|
|
93
|
+
if not symbol:
|
|
94
|
+
logger.warning("Symbol is None, using default bounding box")
|
|
95
|
+
return BoundingBox(-2.54, -2.54, 2.54, 2.54)
|
|
96
|
+
|
|
97
|
+
min_x = float("inf")
|
|
98
|
+
min_y = float("inf")
|
|
99
|
+
max_x = float("-inf")
|
|
100
|
+
max_y = float("-inf")
|
|
101
|
+
|
|
102
|
+
# Process pins
|
|
103
|
+
for pin in symbol.pins:
|
|
104
|
+
pin_bounds = cls._get_pin_bounds(pin)
|
|
105
|
+
if pin_bounds:
|
|
106
|
+
p_min_x, p_min_y, p_max_x, p_max_y = pin_bounds
|
|
107
|
+
min_x = min(min_x, p_min_x)
|
|
108
|
+
min_y = min(min_y, p_min_y)
|
|
109
|
+
max_x = max(max_x, p_max_x)
|
|
110
|
+
max_y = max(max_y, p_max_y)
|
|
111
|
+
|
|
112
|
+
# Process graphics from raw KiCAD data
|
|
113
|
+
if hasattr(symbol, "raw_kicad_data") and symbol.raw_kicad_data:
|
|
114
|
+
graphics_bounds = cls._extract_graphics_bounds(symbol.raw_kicad_data)
|
|
115
|
+
for g_min_x, g_min_y, g_max_x, g_max_y in graphics_bounds:
|
|
116
|
+
min_x = min(min_x, g_min_x)
|
|
117
|
+
min_y = min(min_y, g_min_y)
|
|
118
|
+
max_x = max(max_x, g_max_x)
|
|
119
|
+
max_y = max(max_y, g_max_y)
|
|
120
|
+
|
|
121
|
+
# Fallback for known symbols if no bounds found
|
|
122
|
+
if min_x == float("inf") or max_x == float("-inf"):
|
|
123
|
+
if "Device:R" in symbol.lib_id:
|
|
124
|
+
# Standard resistor dimensions
|
|
125
|
+
min_x, min_y, max_x, max_y = -1.016, -2.54, 1.016, 2.54
|
|
126
|
+
else:
|
|
127
|
+
# Default fallback
|
|
128
|
+
min_x, min_y, max_x, max_y = -2.54, -2.54, 2.54, 2.54
|
|
129
|
+
|
|
130
|
+
# Add margin for safety
|
|
131
|
+
margin = 0.254 # 10 mils
|
|
132
|
+
min_x -= margin
|
|
133
|
+
min_y -= margin
|
|
134
|
+
max_x += margin
|
|
135
|
+
max_y += margin
|
|
136
|
+
|
|
137
|
+
# Include space for properties if requested
|
|
138
|
+
if include_properties:
|
|
139
|
+
property_width = 10.0 # Conservative estimate
|
|
140
|
+
property_height = cls.DEFAULT_TEXT_HEIGHT
|
|
141
|
+
|
|
142
|
+
# Reference above, Value/Footprint below
|
|
143
|
+
min_y -= 5.0 + property_height
|
|
144
|
+
max_y += 10.0 + property_height
|
|
145
|
+
|
|
146
|
+
# Extend horizontally for property text
|
|
147
|
+
center_x = (min_x + max_x) / 2
|
|
148
|
+
min_x = min(min_x, center_x - property_width / 2)
|
|
149
|
+
max_x = max(max_x, center_x + property_width / 2)
|
|
150
|
+
|
|
151
|
+
return BoundingBox(min_x, min_y, max_x, max_y)
|
|
152
|
+
|
|
153
|
+
@classmethod
|
|
154
|
+
def _get_pin_bounds(cls, pin) -> Optional[Tuple[float, float, float, float]]:
|
|
155
|
+
"""Calculate pin bounds including labels."""
|
|
156
|
+
x, y = pin.position.x, pin.position.y
|
|
157
|
+
length = getattr(pin, "length", cls.DEFAULT_PIN_LENGTH)
|
|
158
|
+
rotation = getattr(pin, "rotation", 0)
|
|
159
|
+
|
|
160
|
+
# Calculate pin endpoint
|
|
161
|
+
angle_rad = math.radians(rotation)
|
|
162
|
+
end_x = x + length * math.cos(angle_rad)
|
|
163
|
+
end_y = y + length * math.sin(angle_rad)
|
|
164
|
+
|
|
165
|
+
# Start with pin line bounds
|
|
166
|
+
min_x = min(x, end_x)
|
|
167
|
+
min_y = min(y, end_y)
|
|
168
|
+
max_x = max(x, end_x)
|
|
169
|
+
max_y = max(y, end_y)
|
|
170
|
+
|
|
171
|
+
# Add space for pin name
|
|
172
|
+
pin_name = getattr(pin, "name", "")
|
|
173
|
+
if pin_name and pin_name != "~":
|
|
174
|
+
name_width = len(pin_name) * cls.DEFAULT_TEXT_HEIGHT * cls.DEFAULT_PIN_TEXT_WIDTH_RATIO
|
|
175
|
+
|
|
176
|
+
# Adjust bounds based on pin orientation
|
|
177
|
+
if rotation == 0: # Right
|
|
178
|
+
max_x = end_x + name_width
|
|
179
|
+
elif rotation == 180: # Left
|
|
180
|
+
min_x = end_x - name_width
|
|
181
|
+
elif rotation == 90: # Up
|
|
182
|
+
max_y = end_y + name_width
|
|
183
|
+
elif rotation == 270: # Down
|
|
184
|
+
min_y = end_y - name_width
|
|
185
|
+
|
|
186
|
+
# Add margin for pin number
|
|
187
|
+
pin_number = getattr(pin, "number", "")
|
|
188
|
+
if pin_number:
|
|
189
|
+
margin = cls.DEFAULT_PIN_NUMBER_SIZE * 1.5
|
|
190
|
+
min_x -= margin
|
|
191
|
+
min_y -= margin
|
|
192
|
+
max_x += margin
|
|
193
|
+
max_y += margin
|
|
194
|
+
|
|
195
|
+
return (min_x, min_y, max_x, max_y)
|
|
196
|
+
|
|
197
|
+
@classmethod
|
|
198
|
+
def _extract_graphics_bounds(cls, raw_data) -> List[Tuple[float, float, float, float]]:
|
|
199
|
+
"""Extract graphics bounds from raw KiCAD symbol data."""
|
|
200
|
+
bounds_list = []
|
|
201
|
+
|
|
202
|
+
if not isinstance(raw_data, list):
|
|
203
|
+
return bounds_list
|
|
204
|
+
|
|
205
|
+
# Look through symbol sub-definitions for graphics
|
|
206
|
+
for item in raw_data[1:]: # Skip symbol name
|
|
207
|
+
if isinstance(item, list) and len(item) > 0:
|
|
208
|
+
# Check for symbol unit definitions like "R_0_1"
|
|
209
|
+
if hasattr(item[0], "value") and item[0].value == "symbol":
|
|
210
|
+
bounds_list.extend(cls._extract_unit_graphics_bounds(item))
|
|
211
|
+
|
|
212
|
+
return bounds_list
|
|
213
|
+
|
|
214
|
+
@classmethod
|
|
215
|
+
def _extract_unit_graphics_bounds(cls, unit_data) -> List[Tuple[float, float, float, float]]:
|
|
216
|
+
"""Extract graphics bounds from symbol unit definition."""
|
|
217
|
+
bounds_list = []
|
|
218
|
+
|
|
219
|
+
for item in unit_data[1:]: # Skip unit name
|
|
220
|
+
if isinstance(item, list) and len(item) > 0 and hasattr(item[0], "value"):
|
|
221
|
+
element_type = item[0].value
|
|
222
|
+
|
|
223
|
+
if element_type == "rectangle":
|
|
224
|
+
bounds = cls._extract_rectangle_bounds(item)
|
|
225
|
+
if bounds:
|
|
226
|
+
bounds_list.append(bounds)
|
|
227
|
+
elif element_type == "circle":
|
|
228
|
+
bounds = cls._extract_circle_bounds(item)
|
|
229
|
+
if bounds:
|
|
230
|
+
bounds_list.append(bounds)
|
|
231
|
+
elif element_type == "polyline":
|
|
232
|
+
bounds = cls._extract_polyline_bounds(item)
|
|
233
|
+
if bounds:
|
|
234
|
+
bounds_list.append(bounds)
|
|
235
|
+
elif element_type == "arc":
|
|
236
|
+
bounds = cls._extract_arc_bounds(item)
|
|
237
|
+
if bounds:
|
|
238
|
+
bounds_list.append(bounds)
|
|
239
|
+
|
|
240
|
+
return bounds_list
|
|
241
|
+
|
|
242
|
+
@classmethod
|
|
243
|
+
def _extract_rectangle_bounds(cls, rect_data) -> Optional[Tuple[float, float, float, float]]:
|
|
244
|
+
"""Extract bounds from rectangle definition."""
|
|
245
|
+
try:
|
|
246
|
+
start_point = None
|
|
247
|
+
end_point = None
|
|
248
|
+
|
|
249
|
+
for item in rect_data[1:]:
|
|
250
|
+
if isinstance(item, list) and len(item) >= 3:
|
|
251
|
+
if hasattr(item[0], "value") and item[0].value == "start":
|
|
252
|
+
start_point = (float(item[1]), float(item[2]))
|
|
253
|
+
elif hasattr(item[0], "value") and item[0].value == "end":
|
|
254
|
+
end_point = (float(item[1]), float(item[2]))
|
|
255
|
+
|
|
256
|
+
if start_point and end_point:
|
|
257
|
+
min_x = min(start_point[0], end_point[0])
|
|
258
|
+
min_y = min(start_point[1], end_point[1])
|
|
259
|
+
max_x = max(start_point[0], end_point[0])
|
|
260
|
+
max_y = max(start_point[1], end_point[1])
|
|
261
|
+
return (min_x, min_y, max_x, max_y)
|
|
262
|
+
|
|
263
|
+
except (ValueError, IndexError) as e:
|
|
264
|
+
logger.warning(f"Error parsing rectangle: {e}")
|
|
265
|
+
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
@classmethod
|
|
269
|
+
def _extract_circle_bounds(cls, circle_data) -> Optional[Tuple[float, float, float, float]]:
|
|
270
|
+
"""Extract bounds from circle definition."""
|
|
271
|
+
try:
|
|
272
|
+
center = None
|
|
273
|
+
radius = 0
|
|
274
|
+
|
|
275
|
+
for item in circle_data[1:]:
|
|
276
|
+
if isinstance(item, list) and len(item) >= 3:
|
|
277
|
+
if hasattr(item[0], "value") and item[0].value == "center":
|
|
278
|
+
center = (float(item[1]), float(item[2]))
|
|
279
|
+
elif isinstance(item, list) and len(item) >= 2:
|
|
280
|
+
if hasattr(item[0], "value") and item[0].value == "radius":
|
|
281
|
+
radius = float(item[1])
|
|
282
|
+
|
|
283
|
+
if center and radius > 0:
|
|
284
|
+
cx, cy = center
|
|
285
|
+
return (cx - radius, cy - radius, cx + radius, cy + radius)
|
|
286
|
+
|
|
287
|
+
except (ValueError, IndexError) as e:
|
|
288
|
+
logger.warning(f"Error parsing circle: {e}")
|
|
289
|
+
|
|
290
|
+
return None
|
|
291
|
+
|
|
292
|
+
@classmethod
|
|
293
|
+
def _extract_polyline_bounds(cls, poly_data) -> Optional[Tuple[float, float, float, float]]:
|
|
294
|
+
"""Extract bounds from polyline definition."""
|
|
295
|
+
coordinates = []
|
|
296
|
+
|
|
297
|
+
try:
|
|
298
|
+
for item in poly_data[1:]:
|
|
299
|
+
if isinstance(item, list) and len(item) > 0:
|
|
300
|
+
if hasattr(item[0], "value") and item[0].value == "pts":
|
|
301
|
+
for pt_item in item[1:]:
|
|
302
|
+
if isinstance(pt_item, list) and len(pt_item) >= 3:
|
|
303
|
+
if hasattr(pt_item[0], "value") and pt_item[0].value == "xy":
|
|
304
|
+
coordinates.append((float(pt_item[1]), float(pt_item[2])))
|
|
305
|
+
|
|
306
|
+
if coordinates:
|
|
307
|
+
min_x = min(coord[0] for coord in coordinates)
|
|
308
|
+
min_y = min(coord[1] for coord in coordinates)
|
|
309
|
+
max_x = max(coord[0] for coord in coordinates)
|
|
310
|
+
max_y = max(coord[1] for coord in coordinates)
|
|
311
|
+
return (min_x, min_y, max_x, max_y)
|
|
312
|
+
|
|
313
|
+
except (ValueError, IndexError) as e:
|
|
314
|
+
logger.warning(f"Error parsing polyline: {e}")
|
|
315
|
+
|
|
316
|
+
return None
|
|
317
|
+
|
|
318
|
+
@classmethod
|
|
319
|
+
def _extract_arc_bounds(cls, arc_data) -> Optional[Tuple[float, float, float, float]]:
|
|
320
|
+
"""Extract bounds from arc definition (simplified approach)."""
|
|
321
|
+
try:
|
|
322
|
+
start = None
|
|
323
|
+
mid = None
|
|
324
|
+
end = None
|
|
325
|
+
|
|
326
|
+
for item in arc_data[1:]:
|
|
327
|
+
if isinstance(item, list) and len(item) >= 3:
|
|
328
|
+
if hasattr(item[0], "value"):
|
|
329
|
+
if item[0].value == "start":
|
|
330
|
+
start = (float(item[1]), float(item[2]))
|
|
331
|
+
elif item[0].value == "mid":
|
|
332
|
+
mid = (float(item[1]), float(item[2]))
|
|
333
|
+
elif item[0].value == "end":
|
|
334
|
+
end = (float(item[1]), float(item[2]))
|
|
335
|
+
|
|
336
|
+
if start and end:
|
|
337
|
+
# Simple approach: use bounding box of start/mid/end points
|
|
338
|
+
points = [start, end]
|
|
339
|
+
if mid:
|
|
340
|
+
points.append(mid)
|
|
341
|
+
|
|
342
|
+
min_x = min(p[0] for p in points)
|
|
343
|
+
min_y = min(p[1] for p in points)
|
|
344
|
+
max_x = max(p[0] for p in points)
|
|
345
|
+
max_y = max(p[1] for p in points)
|
|
346
|
+
return (min_x, min_y, max_x, max_y)
|
|
347
|
+
|
|
348
|
+
except (ValueError, IndexError) as e:
|
|
349
|
+
logger.warning(f"Error parsing arc: {e}")
|
|
350
|
+
|
|
351
|
+
return None
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def get_component_bounding_box(
|
|
355
|
+
component: SchematicSymbol, include_properties: bool = True
|
|
356
|
+
) -> BoundingBox:
|
|
357
|
+
"""
|
|
358
|
+
Get component bounding box in world coordinates.
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
component: The schematic component
|
|
362
|
+
include_properties: Whether to include space for Reference/Value labels
|
|
363
|
+
|
|
364
|
+
Returns:
|
|
365
|
+
BoundingBox in world coordinates
|
|
366
|
+
"""
|
|
367
|
+
# Get symbol definition
|
|
368
|
+
cache = get_symbol_cache()
|
|
369
|
+
symbol = cache.get_symbol(component.lib_id)
|
|
370
|
+
|
|
371
|
+
if not symbol:
|
|
372
|
+
logger.warning(f"Symbol not found for {component.lib_id}")
|
|
373
|
+
# Return default size centered at component position
|
|
374
|
+
default_size = 5.08 # 4 grid units
|
|
375
|
+
return BoundingBox(
|
|
376
|
+
component.position.x - default_size / 2,
|
|
377
|
+
component.position.y - default_size / 2,
|
|
378
|
+
component.position.x + default_size / 2,
|
|
379
|
+
component.position.y + default_size / 2,
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
# Calculate symbol bounding box
|
|
383
|
+
symbol_bbox = SymbolBoundingBoxCalculator.calculate_bounding_box(symbol, include_properties)
|
|
384
|
+
|
|
385
|
+
# Transform to world coordinates
|
|
386
|
+
# TODO: Handle component rotation in the future
|
|
387
|
+
world_bbox = BoundingBox(
|
|
388
|
+
component.position.x + symbol_bbox.min_x,
|
|
389
|
+
component.position.y + symbol_bbox.min_y,
|
|
390
|
+
component.position.x + symbol_bbox.max_x,
|
|
391
|
+
component.position.y + symbol_bbox.max_y,
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
logger.debug(f"Component {component.reference} world bbox: {world_bbox}")
|
|
395
|
+
return world_bbox
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def get_schematic_component_bboxes(components: List[SchematicSymbol]) -> List[BoundingBox]:
|
|
399
|
+
"""Get bounding boxes for all components in a schematic."""
|
|
400
|
+
return [get_component_bounding_box(comp) for comp in components]
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def check_path_collision(
|
|
404
|
+
start: Point, end: Point, obstacles: List[BoundingBox], clearance: float = 1.27
|
|
405
|
+
) -> bool:
|
|
406
|
+
"""
|
|
407
|
+
Check if a straight line path collides with any obstacle bounding boxes.
|
|
408
|
+
|
|
409
|
+
Args:
|
|
410
|
+
start: Starting point
|
|
411
|
+
end: Ending point
|
|
412
|
+
obstacles: List of obstacle bounding boxes
|
|
413
|
+
clearance: Minimum clearance from obstacles (default: 1 grid unit)
|
|
414
|
+
|
|
415
|
+
Returns:
|
|
416
|
+
True if path collides with any obstacle
|
|
417
|
+
"""
|
|
418
|
+
# Expand obstacles by clearance
|
|
419
|
+
expanded_obstacles = [obs.expand(clearance) for obs in obstacles]
|
|
420
|
+
|
|
421
|
+
# Check if line segment intersects any expanded obstacle
|
|
422
|
+
for bbox in expanded_obstacles:
|
|
423
|
+
if _line_intersects_bbox(start, end, bbox):
|
|
424
|
+
logger.debug(f"Path collision detected with obstacle {bbox}")
|
|
425
|
+
return True
|
|
426
|
+
|
|
427
|
+
return False
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _line_intersects_bbox(start: Point, end: Point, bbox: BoundingBox) -> bool:
|
|
431
|
+
"""
|
|
432
|
+
Check if line segment intersects bounding box using line-box intersection.
|
|
433
|
+
|
|
434
|
+
Uses efficient line-box intersection algorithm.
|
|
435
|
+
"""
|
|
436
|
+
# Get line direction
|
|
437
|
+
dx = end.x - start.x
|
|
438
|
+
dy = end.y - start.y
|
|
439
|
+
|
|
440
|
+
# Handle degenerate case (point)
|
|
441
|
+
if dx == 0 and dy == 0:
|
|
442
|
+
return bbox.contains_point(start)
|
|
443
|
+
|
|
444
|
+
# Calculate intersection parameters using slab method
|
|
445
|
+
if dx == 0:
|
|
446
|
+
# Vertical line
|
|
447
|
+
if start.x < bbox.min_x or start.x > bbox.max_x:
|
|
448
|
+
return False
|
|
449
|
+
t_min = (bbox.min_y - start.y) / dy if dy != 0 else float("-inf")
|
|
450
|
+
t_max = (bbox.max_y - start.y) / dy if dy != 0 else float("inf")
|
|
451
|
+
if t_min > t_max:
|
|
452
|
+
t_min, t_max = t_max, t_min
|
|
453
|
+
elif dy == 0:
|
|
454
|
+
# Horizontal line
|
|
455
|
+
if start.y < bbox.min_y or start.y > bbox.max_y:
|
|
456
|
+
return False
|
|
457
|
+
t_min = (bbox.min_x - start.x) / dx
|
|
458
|
+
t_max = (bbox.max_x - start.x) / dx
|
|
459
|
+
if t_min > t_max:
|
|
460
|
+
t_min, t_max = t_max, t_min
|
|
461
|
+
else:
|
|
462
|
+
# General case
|
|
463
|
+
t_min_x = (bbox.min_x - start.x) / dx
|
|
464
|
+
t_max_x = (bbox.max_x - start.x) / dx
|
|
465
|
+
if t_min_x > t_max_x:
|
|
466
|
+
t_min_x, t_max_x = t_max_x, t_min_x
|
|
467
|
+
|
|
468
|
+
t_min_y = (bbox.min_y - start.y) / dy
|
|
469
|
+
t_max_y = (bbox.max_y - start.y) / dy
|
|
470
|
+
if t_min_y > t_max_y:
|
|
471
|
+
t_min_y, t_max_y = t_max_y, t_min_y
|
|
472
|
+
|
|
473
|
+
t_min = max(t_min_x, t_min_y)
|
|
474
|
+
t_max = min(t_max_x, t_max_y)
|
|
475
|
+
|
|
476
|
+
# Check if intersection is within line segment [0, 1]
|
|
477
|
+
return t_min <= t_max and t_min <= 1.0 and t_max >= 0.0
|
kicad_sch_api/core/components.py
CHANGED
|
@@ -11,8 +11,8 @@ from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple, Union
|
|
|
11
11
|
|
|
12
12
|
from ..library.cache import SymbolDefinition, get_symbol_cache
|
|
13
13
|
from ..utils.validation import SchematicValidator, ValidationError, ValidationIssue
|
|
14
|
-
from .types import Point, SchematicPin, SchematicSymbol
|
|
15
14
|
from .ic_manager import ICManager
|
|
15
|
+
from .types import Point, SchematicPin, SchematicSymbol
|
|
16
16
|
|
|
17
17
|
logger = logging.getLogger(__name__)
|
|
18
18
|
|
|
@@ -292,6 +292,7 @@ class ComponentCollection:
|
|
|
292
292
|
position: Optional[Union[Point, Tuple[float, float]]] = None,
|
|
293
293
|
footprint: Optional[str] = None,
|
|
294
294
|
unit: int = 1,
|
|
295
|
+
component_uuid: Optional[str] = None,
|
|
295
296
|
**properties,
|
|
296
297
|
) -> Component:
|
|
297
298
|
"""
|
|
@@ -304,6 +305,7 @@ class ComponentCollection:
|
|
|
304
305
|
position: Component position (auto-placed if None)
|
|
305
306
|
footprint: Component footprint
|
|
306
307
|
unit: Unit number for multi-unit components (1-based)
|
|
308
|
+
component_uuid: Specific UUID for component (auto-generated if None)
|
|
307
309
|
**properties: Additional component properties
|
|
308
310
|
|
|
309
311
|
Returns:
|
|
@@ -335,9 +337,19 @@ class ComponentCollection:
|
|
|
335
337
|
elif isinstance(position, tuple):
|
|
336
338
|
position = Point(position[0], position[1])
|
|
337
339
|
|
|
340
|
+
# Always snap component position to KiCAD grid (1.27mm = 50mil)
|
|
341
|
+
from .geometry import snap_to_grid
|
|
342
|
+
|
|
343
|
+
snapped_pos = snap_to_grid((position.x, position.y), grid_size=1.27)
|
|
344
|
+
position = Point(snapped_pos[0], snapped_pos[1])
|
|
345
|
+
|
|
346
|
+
logger.debug(
|
|
347
|
+
f"Component {reference} position snapped to grid: ({position.x:.3f}, {position.y:.3f})"
|
|
348
|
+
)
|
|
349
|
+
|
|
338
350
|
# Create component data
|
|
339
351
|
component_data = SchematicSymbol(
|
|
340
|
-
uuid=str(uuid.uuid4()),
|
|
352
|
+
uuid=component_uuid if component_uuid else str(uuid.uuid4()),
|
|
341
353
|
lib_id=lib_id,
|
|
342
354
|
position=position,
|
|
343
355
|
reference=reference,
|
|
@@ -404,12 +416,10 @@ class ComponentCollection:
|
|
|
404
416
|
|
|
405
417
|
# Create IC manager for this multi-unit component
|
|
406
418
|
ic_manager = ICManager(lib_id, reference_prefix, position, self)
|
|
407
|
-
|
|
419
|
+
|
|
408
420
|
# Generate all unit components
|
|
409
421
|
unit_components = ic_manager.generate_components(
|
|
410
|
-
value=value,
|
|
411
|
-
footprint=footprint,
|
|
412
|
-
properties=properties
|
|
422
|
+
value=value, footprint=footprint, properties=properties
|
|
413
423
|
)
|
|
414
424
|
|
|
415
425
|
# Add all units to the collection
|
|
@@ -418,8 +428,10 @@ class ComponentCollection:
|
|
|
418
428
|
self._add_to_indexes(component)
|
|
419
429
|
|
|
420
430
|
self._modified = True
|
|
421
|
-
logger.info(
|
|
422
|
-
|
|
431
|
+
logger.info(
|
|
432
|
+
f"Added multi-unit IC: {reference_prefix} ({lib_id}) with {len(unit_components)} units"
|
|
433
|
+
)
|
|
434
|
+
|
|
423
435
|
return ic_manager
|
|
424
436
|
|
|
425
437
|
def remove(self, reference: str) -> bool:
|
|
@@ -536,11 +548,11 @@ class ComponentCollection:
|
|
|
536
548
|
for component in matching:
|
|
537
549
|
# Update basic properties and handle special cases
|
|
538
550
|
for key, value in updates.items():
|
|
539
|
-
if key ==
|
|
551
|
+
if key == "properties" and isinstance(value, dict):
|
|
540
552
|
# Handle properties dictionary specially
|
|
541
553
|
for prop_name, prop_value in value.items():
|
|
542
554
|
component.set_property(prop_name, str(prop_value))
|
|
543
|
-
elif hasattr(component, key) and key not in [
|
|
555
|
+
elif hasattr(component, key) and key not in ["properties"]:
|
|
544
556
|
setattr(component, key, value)
|
|
545
557
|
else:
|
|
546
558
|
# Add as custom property
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Configuration constants and settings for KiCAD schematic API.
|
|
4
|
+
|
|
5
|
+
This module centralizes all magic numbers and configuration values
|
|
6
|
+
to make them easily configurable and maintainable.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any, Dict, Tuple
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class PropertyOffsets:
|
|
15
|
+
"""Standard property positioning offsets relative to component position."""
|
|
16
|
+
|
|
17
|
+
reference_x: float = 2.54 # Reference label X offset
|
|
18
|
+
reference_y: float = -1.2701 # Reference label Y offset (above) - exact match
|
|
19
|
+
value_x: float = 2.54 # Value label X offset
|
|
20
|
+
value_y: float = 1.2699 # Value label Y offset (below) - exact match
|
|
21
|
+
footprint_rotation: float = 90 # Footprint property rotation
|
|
22
|
+
hidden_property_offset: float = 1.27 # Y spacing for hidden properties
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class GridSettings:
|
|
27
|
+
"""Standard KiCAD grid and spacing settings."""
|
|
28
|
+
|
|
29
|
+
standard_grid: float = 1.27 # Standard 50mil grid in mm
|
|
30
|
+
component_spacing: float = 2.54 # Standard component spacing (100mil)
|
|
31
|
+
unit_spacing: float = 12.7 # Multi-unit IC spacing
|
|
32
|
+
power_offset: Tuple[float, float] = (25.4, 0.0) # Power unit offset
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class SheetSettings:
|
|
37
|
+
"""Hierarchical sheet positioning settings."""
|
|
38
|
+
|
|
39
|
+
name_offset_y: float = -0.7116 # Sheetname position offset (above)
|
|
40
|
+
file_offset_y: float = 0.5846 # Sheetfile position offset (below)
|
|
41
|
+
default_stroke_width: float = 0.1524
|
|
42
|
+
default_stroke_type: str = "solid"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class ToleranceSettings:
|
|
47
|
+
"""Tolerance values for various operations."""
|
|
48
|
+
|
|
49
|
+
position_tolerance: float = 0.1 # Point matching tolerance
|
|
50
|
+
wire_segment_min: float = 0.001 # Minimum wire segment length
|
|
51
|
+
coordinate_precision: float = 0.01 # Coordinate comparison precision
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class DefaultValues:
|
|
56
|
+
"""Default values for various operations."""
|
|
57
|
+
|
|
58
|
+
project_name: str = "untitled"
|
|
59
|
+
stroke_width: float = 0.0
|
|
60
|
+
font_size: float = 1.27
|
|
61
|
+
pin_name_size: float = 1.27
|
|
62
|
+
pin_number_size: float = 1.27
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class KiCADConfig:
|
|
66
|
+
"""Central configuration class for KiCAD schematic API."""
|
|
67
|
+
|
|
68
|
+
def __init__(self):
|
|
69
|
+
self.properties = PropertyOffsets()
|
|
70
|
+
self.grid = GridSettings()
|
|
71
|
+
self.sheet = SheetSettings()
|
|
72
|
+
self.tolerance = ToleranceSettings()
|
|
73
|
+
self.defaults = DefaultValues()
|
|
74
|
+
|
|
75
|
+
# Names that should not generate title_block (for backward compatibility)
|
|
76
|
+
# Include test schematic names to maintain reference compatibility
|
|
77
|
+
self.no_title_block_names = {
|
|
78
|
+
"untitled",
|
|
79
|
+
"blank schematic",
|
|
80
|
+
"",
|
|
81
|
+
"single_resistor",
|
|
82
|
+
"two_resistors",
|
|
83
|
+
"single_wire",
|
|
84
|
+
"single_label",
|
|
85
|
+
"single_hierarchical_sheet",
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
def should_add_title_block(self, name: str) -> bool:
|
|
89
|
+
"""Determine if a schematic name should generate a title block."""
|
|
90
|
+
if not name:
|
|
91
|
+
return False
|
|
92
|
+
return name.lower() not in self.no_title_block_names
|
|
93
|
+
|
|
94
|
+
def get_property_position(
|
|
95
|
+
self, property_name: str, component_pos: Tuple[float, float], offset_index: int = 0
|
|
96
|
+
) -> Tuple[float, float, float]:
|
|
97
|
+
"""
|
|
98
|
+
Calculate property position relative to component.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
Tuple of (x, y, rotation) for the property
|
|
102
|
+
"""
|
|
103
|
+
x, y = component_pos
|
|
104
|
+
|
|
105
|
+
if property_name == "Reference":
|
|
106
|
+
return (x + self.properties.reference_x, y + self.properties.reference_y, 0)
|
|
107
|
+
elif property_name == "Value":
|
|
108
|
+
return (x + self.properties.value_x, y + self.properties.value_y, 0)
|
|
109
|
+
elif property_name == "Footprint":
|
|
110
|
+
# Footprint positioned to left of component, rotated 90 degrees
|
|
111
|
+
return (x - 1.778, y, self.properties.footprint_rotation) # Exact match for reference
|
|
112
|
+
elif property_name in ["Datasheet", "Description"]:
|
|
113
|
+
# Hidden properties at component center
|
|
114
|
+
return (x, y, 0)
|
|
115
|
+
else:
|
|
116
|
+
# Other properties stacked vertically below
|
|
117
|
+
return (
|
|
118
|
+
x + self.properties.reference_x,
|
|
119
|
+
y
|
|
120
|
+
+ self.properties.value_y
|
|
121
|
+
+ (self.properties.hidden_property_offset * offset_index),
|
|
122
|
+
0,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# Global configuration instance
|
|
127
|
+
config = KiCADConfig()
|