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.

@@ -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
@@ -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(f"Added multi-unit IC: {reference_prefix} ({lib_id}) with {len(unit_components)} units")
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 == 'properties' and isinstance(value, dict):
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 ['properties']:
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()