kicad-sch-api 0.4.1__py3-none-any.whl → 0.5.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.
Files changed (66) hide show
  1. kicad_sch_api/__init__.py +67 -2
  2. kicad_sch_api/cli/kicad_to_python.py +169 -0
  3. kicad_sch_api/collections/__init__.py +23 -8
  4. kicad_sch_api/collections/base.py +369 -59
  5. kicad_sch_api/collections/components.py +1376 -187
  6. kicad_sch_api/collections/junctions.py +129 -289
  7. kicad_sch_api/collections/labels.py +391 -287
  8. kicad_sch_api/collections/wires.py +202 -316
  9. kicad_sch_api/core/__init__.py +37 -2
  10. kicad_sch_api/core/component_bounds.py +34 -12
  11. kicad_sch_api/core/components.py +146 -7
  12. kicad_sch_api/core/config.py +25 -12
  13. kicad_sch_api/core/connectivity.py +692 -0
  14. kicad_sch_api/core/exceptions.py +175 -0
  15. kicad_sch_api/core/factories/element_factory.py +3 -1
  16. kicad_sch_api/core/formatter.py +24 -7
  17. kicad_sch_api/core/geometry.py +94 -5
  18. kicad_sch_api/core/managers/__init__.py +4 -0
  19. kicad_sch_api/core/managers/base.py +76 -0
  20. kicad_sch_api/core/managers/file_io.py +3 -1
  21. kicad_sch_api/core/managers/format_sync.py +3 -2
  22. kicad_sch_api/core/managers/graphics.py +3 -2
  23. kicad_sch_api/core/managers/hierarchy.py +661 -0
  24. kicad_sch_api/core/managers/metadata.py +4 -2
  25. kicad_sch_api/core/managers/sheet.py +52 -14
  26. kicad_sch_api/core/managers/text_elements.py +3 -2
  27. kicad_sch_api/core/managers/validation.py +3 -2
  28. kicad_sch_api/core/managers/wire.py +112 -54
  29. kicad_sch_api/core/parsing_utils.py +63 -0
  30. kicad_sch_api/core/pin_utils.py +103 -9
  31. kicad_sch_api/core/schematic.py +343 -29
  32. kicad_sch_api/core/types.py +79 -7
  33. kicad_sch_api/exporters/__init__.py +10 -0
  34. kicad_sch_api/exporters/python_generator.py +610 -0
  35. kicad_sch_api/exporters/templates/default.py.jinja2 +65 -0
  36. kicad_sch_api/geometry/__init__.py +15 -3
  37. kicad_sch_api/geometry/routing.py +211 -0
  38. kicad_sch_api/parsers/elements/label_parser.py +30 -8
  39. kicad_sch_api/parsers/elements/symbol_parser.py +255 -83
  40. kicad_sch_api/utils/logging.py +555 -0
  41. kicad_sch_api/utils/logging_decorators.py +587 -0
  42. kicad_sch_api/utils/validation.py +16 -22
  43. kicad_sch_api/wrappers/__init__.py +14 -0
  44. kicad_sch_api/wrappers/base.py +89 -0
  45. kicad_sch_api/wrappers/wire.py +198 -0
  46. kicad_sch_api-0.5.1.dist-info/METADATA +540 -0
  47. kicad_sch_api-0.5.1.dist-info/RECORD +114 -0
  48. kicad_sch_api-0.5.1.dist-info/entry_points.txt +4 -0
  49. {kicad_sch_api-0.4.1.dist-info → kicad_sch_api-0.5.1.dist-info}/top_level.txt +1 -0
  50. mcp_server/__init__.py +34 -0
  51. mcp_server/example_logging_integration.py +506 -0
  52. mcp_server/models.py +252 -0
  53. mcp_server/server.py +357 -0
  54. mcp_server/tools/__init__.py +32 -0
  55. mcp_server/tools/component_tools.py +516 -0
  56. mcp_server/tools/connectivity_tools.py +532 -0
  57. mcp_server/tools/consolidated_tools.py +1216 -0
  58. mcp_server/tools/pin_discovery.py +333 -0
  59. mcp_server/utils/__init__.py +38 -0
  60. mcp_server/utils/logging.py +127 -0
  61. mcp_server/utils.py +36 -0
  62. kicad_sch_api-0.4.1.dist-info/METADATA +0 -491
  63. kicad_sch_api-0.4.1.dist-info/RECORD +0 -87
  64. kicad_sch_api-0.4.1.dist-info/entry_points.txt +0 -2
  65. {kicad_sch_api-0.4.1.dist-info → kicad_sch_api-0.5.1.dist-info}/WHEEL +0 -0
  66. {kicad_sch_api-0.4.1.dist-info → kicad_sch_api-0.5.1.dist-info}/licenses/LICENSE +0 -0
@@ -382,21 +382,43 @@ def get_component_bounding_box(
382
382
  # Calculate symbol bounding box
383
383
  symbol_bbox = SymbolBoundingBoxCalculator.calculate_bounding_box(symbol, include_properties)
384
384
 
385
- # Transform to world coordinates
386
- # TODO: Handle component rotation in the future
387
- # NOTE: Currently assumes 0° rotation. For rotated components, bounding box
388
- # would need to be recalculated after applying rotation matrix. This is a
389
- # known limitation but doesn't affect most use cases since components are
390
- # typically placed without rotation, and routing avoids components regardless.
391
- # Priority: LOW - Would improve accuracy for rotated component placement validation
385
+ # Transform to world coordinates with rotation
386
+ # Apply rotation matrix to bounding box corners, then find new min/max
387
+ import math
388
+
389
+ angle_rad = math.radians(component.rotation)
390
+ cos_a = math.cos(angle_rad)
391
+ sin_a = math.sin(angle_rad)
392
+
393
+ # Get all 4 corners of the symbol bounding box
394
+ corners = [
395
+ (symbol_bbox.min_x, symbol_bbox.min_y), # Bottom-left
396
+ (symbol_bbox.max_x, symbol_bbox.min_y), # Bottom-right
397
+ (symbol_bbox.max_x, symbol_bbox.max_y), # Top-right
398
+ (symbol_bbox.min_x, symbol_bbox.max_y), # Top-left
399
+ ]
400
+
401
+ # Rotate each corner using standard 2D rotation matrix
402
+ rotated_corners = []
403
+ for x, y in corners:
404
+ rotated_x = x * cos_a - y * sin_a
405
+ rotated_y = x * sin_a + y * cos_a
406
+ rotated_corners.append((rotated_x, rotated_y))
407
+
408
+ # Find min/max of rotated corners
409
+ rotated_xs = [x for x, y in rotated_corners]
410
+ rotated_ys = [y for x, y in rotated_corners]
411
+
392
412
  world_bbox = BoundingBox(
393
- component.position.x + symbol_bbox.min_x,
394
- component.position.y + symbol_bbox.min_y,
395
- component.position.x + symbol_bbox.max_x,
396
- component.position.y + symbol_bbox.max_y,
413
+ component.position.x + min(rotated_xs),
414
+ component.position.y + min(rotated_ys),
415
+ component.position.x + max(rotated_xs),
416
+ component.position.y + max(rotated_ys),
397
417
  )
398
418
 
399
- logger.debug(f"Component {component.reference} world bbox: {world_bbox}")
419
+ logger.debug(
420
+ f"Component {component.reference} at {component.rotation}° world bbox: {world_bbox}"
421
+ )
400
422
  return world_bbox
401
423
 
402
424
 
@@ -12,6 +12,7 @@ from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple, Union
12
12
  from ..library.cache import SymbolDefinition, get_symbol_cache
13
13
  from ..utils.validation import SchematicValidator, ValidationError, ValidationIssue
14
14
  from .collections import BaseCollection
15
+ from .exceptions import LibraryError
15
16
  from .ic_manager import ICManager
16
17
  from .types import Point, SchematicPin, SchematicSymbol
17
18
 
@@ -106,8 +107,29 @@ class Component:
106
107
 
107
108
  @rotation.setter
108
109
  def rotation(self, value: float):
109
- """Set component rotation."""
110
- self._data.rotation = float(value) % 360
110
+ """Set component rotation (must be 0, 90, 180, or 270 degrees).
111
+
112
+ KiCad only supports these four rotation angles for components.
113
+
114
+ Args:
115
+ value: Rotation angle in degrees (0, 90, 180, or 270)
116
+
117
+ Raises:
118
+ ValueError: If rotation is not 0, 90, 180, or 270
119
+ """
120
+ # Normalize rotation to 0-360 range
121
+ normalized = float(value) % 360
122
+
123
+ # KiCad only accepts 0, 90, 180, or 270 degrees
124
+ VALID_ROTATIONS = {0, 90, 180, 270}
125
+ if normalized not in VALID_ROTATIONS:
126
+ raise ValueError(
127
+ f"Component rotation must be 0, 90, 180, or 270 degrees. "
128
+ f"Got {value}° (normalized to {normalized}°). "
129
+ f"KiCad does not support arbitrary rotation angles."
130
+ )
131
+
132
+ self._data.rotation = normalized
111
133
  self._collection._mark_modified()
112
134
 
113
135
  @property
@@ -273,12 +295,13 @@ class ComponentCollection(BaseCollection[Component]):
273
295
  Optimized for schematics with hundreds or thousands of components.
274
296
  """
275
297
 
276
- def __init__(self, components: List[SchematicSymbol] = None):
298
+ def __init__(self, components: List[SchematicSymbol] = None, parent_schematic=None):
277
299
  """
278
300
  Initialize component collection.
279
301
 
280
302
  Args:
281
303
  components: Initial list of component data
304
+ parent_schematic: Reference to parent Schematic object (for hierarchy context)
282
305
  """
283
306
  # Initialize base collection
284
307
  super().__init__([], collection_name="components")
@@ -288,6 +311,9 @@ class ComponentCollection(BaseCollection[Component]):
288
311
  self._lib_id_index: Dict[str, List[Component]] = {}
289
312
  self._value_index: Dict[str, List[Component]] = {}
290
313
 
314
+ # Store reference to parent schematic for hierarchy context
315
+ self._parent_schematic = parent_schematic
316
+
291
317
  # Add initial components
292
318
  if components:
293
319
  for comp_data in components:
@@ -301,6 +327,7 @@ class ComponentCollection(BaseCollection[Component]):
301
327
  position: Optional[Union[Point, Tuple[float, float]]] = None,
302
328
  footprint: Optional[str] = None,
303
329
  unit: int = 1,
330
+ rotation: float = 0.0,
304
331
  component_uuid: Optional[str] = None,
305
332
  **properties,
306
333
  ) -> Component:
@@ -314,6 +341,7 @@ class ComponentCollection(BaseCollection[Component]):
314
341
  position: Component position (auto-placed if None)
315
342
  footprint: Component footprint
316
343
  unit: Unit number for multi-unit components (1-based)
344
+ rotation: Component rotation in degrees (0, 90, 180, 270)
317
345
  component_uuid: Specific UUID for component (auto-generated if None)
318
346
  **properties: Additional component properties
319
347
 
@@ -322,6 +350,7 @@ class ComponentCollection(BaseCollection[Component]):
322
350
 
323
351
  Raises:
324
352
  ValidationError: If component data is invalid
353
+ LibraryError: If the KiCAD symbol library is not found
325
354
  """
326
355
  # Validate lib_id
327
356
  validator = SchematicValidator()
@@ -356,6 +385,28 @@ class ComponentCollection(BaseCollection[Component]):
356
385
  f"Component {reference} position snapped to grid: ({position.x:.3f}, {position.y:.3f})"
357
386
  )
358
387
 
388
+ # Normalize and validate rotation
389
+ rotation = rotation % 360
390
+
391
+ # KiCad only accepts 0, 90, 180, or 270 degrees
392
+ VALID_ROTATIONS = {0, 90, 180, 270}
393
+ if rotation not in VALID_ROTATIONS:
394
+ raise ValidationError(
395
+ f"Component rotation must be 0, 90, 180, or 270 degrees. "
396
+ f"Got {rotation}°. KiCad does not support arbitrary rotation angles."
397
+ )
398
+
399
+ # Check if parent schematic has hierarchy context set
400
+ # If so, add hierarchy_path to properties for proper KiCad instance paths
401
+ if self._parent_schematic and hasattr(self._parent_schematic, '_hierarchy_path'):
402
+ if self._parent_schematic._hierarchy_path:
403
+ properties = dict(properties) # Make a copy to avoid modifying caller's dict
404
+ properties['hierarchy_path'] = self._parent_schematic._hierarchy_path
405
+ logger.debug(
406
+ f"Setting hierarchy_path for component {reference}: "
407
+ f"{self._parent_schematic._hierarchy_path}"
408
+ )
409
+
359
410
  # Create component data
360
411
  component_data = SchematicSymbol(
361
412
  uuid=component_uuid if component_uuid else str(uuid.uuid4()),
@@ -365,14 +416,24 @@ class ComponentCollection(BaseCollection[Component]):
365
416
  value=value,
366
417
  footprint=footprint,
367
418
  unit=unit,
419
+ rotation=rotation,
368
420
  properties=properties,
369
421
  )
370
422
 
371
423
  # Get symbol definition and update pins
372
424
  symbol_cache = get_symbol_cache()
373
425
  symbol_def = symbol_cache.get_symbol(lib_id)
374
- if symbol_def:
375
- component_data.pins = symbol_def.pins.copy()
426
+ if not symbol_def:
427
+ # Provide helpful error message with library name
428
+ library_name = lib_id.split(":")[0] if ":" in lib_id else "unknown"
429
+ raise LibraryError(
430
+ f"Symbol '{lib_id}' not found in KiCAD libraries. "
431
+ f"Please verify the library name '{library_name}' and symbol name are correct. "
432
+ f"Common libraries include: Device, Connector_Generic, Regulator_Linear, RF_Module",
433
+ field="lib_id",
434
+ value=lib_id
435
+ )
436
+ component_data.pins = symbol_def.pins.copy()
376
437
 
377
438
  # Create component wrapper
378
439
  component = Component(component_data, self)
@@ -448,11 +509,25 @@ class ComponentCollection(BaseCollection[Component]):
448
509
  Remove component by reference.
449
510
 
450
511
  Args:
451
- reference: Component reference to remove
512
+ reference: Component reference to remove (e.g., "R1")
452
513
 
453
514
  Returns:
454
- True if component was removed
515
+ True if component was removed, False if not found
516
+
517
+ Raises:
518
+ TypeError: If reference is not a string
519
+
520
+ Examples:
521
+ sch.components.remove("R1")
522
+ sch.components.remove("C2")
523
+
524
+ Note:
525
+ For removing by UUID or component object, use remove_by_uuid() or remove_component()
526
+ respectively. This maintains a clear, simple API contract.
455
527
  """
528
+ if not isinstance(reference, str):
529
+ raise TypeError(f"reference must be a string, not {type(reference).__name__}")
530
+
456
531
  component = self._reference_index.get(reference)
457
532
  if not component:
458
533
  return False
@@ -466,6 +541,70 @@ class ComponentCollection(BaseCollection[Component]):
466
541
  logger.info(f"Removed component: {reference}")
467
542
  return True
468
543
 
544
+ def remove_by_uuid(self, component_uuid: str) -> bool:
545
+ """
546
+ Remove component by UUID.
547
+
548
+ Args:
549
+ component_uuid: Component UUID to remove
550
+
551
+ Returns:
552
+ True if component was removed, False if not found
553
+
554
+ Raises:
555
+ TypeError: If UUID is not a string
556
+ """
557
+ if not isinstance(component_uuid, str):
558
+ raise TypeError(f"component_uuid must be a string, not {type(component_uuid).__name__}")
559
+
560
+ if component_uuid not in self._uuid_index:
561
+ return False
562
+
563
+ component = self._items[self._uuid_index[component_uuid]]
564
+
565
+ # Remove from component-specific indexes
566
+ self._remove_from_indexes(component)
567
+
568
+ # Remove from base collection
569
+ super().remove(component_uuid)
570
+
571
+ logger.info(f"Removed component by UUID: {component_uuid}")
572
+ return True
573
+
574
+ def remove_component(self, component: "Component") -> bool:
575
+ """
576
+ Remove component by component object.
577
+
578
+ Args:
579
+ component: Component object to remove
580
+
581
+ Returns:
582
+ True if component was removed, False if not found
583
+
584
+ Raises:
585
+ TypeError: If component is not a Component instance
586
+
587
+ Examples:
588
+ comp = sch.components.get("R1")
589
+ sch.components.remove_component(comp)
590
+ """
591
+ if not isinstance(component, Component):
592
+ raise TypeError(
593
+ f"component must be a Component instance, not {type(component).__name__}"
594
+ )
595
+
596
+ if component.uuid not in self._uuid_index:
597
+ return False
598
+
599
+ # Remove from component-specific indexes
600
+ self._remove_from_indexes(component)
601
+
602
+ # Remove from base collection
603
+ super().remove(component.uuid)
604
+
605
+ logger.info(f"Removed component: {component.reference}")
606
+ return True
607
+
469
608
  def get(self, reference: str) -> Optional[Component]:
470
609
  """Get component by reference."""
471
610
  return self._reference_index.get(reference)
@@ -174,35 +174,48 @@ class KiCADConfig:
174
174
  return name.lower() not in self.no_title_block_names
175
175
 
176
176
  def get_property_position(
177
- self, property_name: str, component_pos: Tuple[float, float], offset_index: int = 0
177
+ self, property_name: str, component_pos: Tuple[float, float], offset_index: int = 0, component_rotation: float = 0
178
178
  ) -> Tuple[float, float, float]:
179
179
  """
180
- Calculate property position relative to component.
180
+ Calculate property position relative to component, accounting for component rotation.
181
+
182
+ Args:
183
+ property_name: Name of the property (Reference, Value, etc.)
184
+ component_pos: (x, y) position of component
185
+ offset_index: Stacking offset for multiple properties
186
+ component_rotation: Rotation of the component in degrees (0, 90, 180, 270)
181
187
 
182
188
  Returns:
183
189
  Tuple of (x, y, rotation) for the property
184
190
  """
191
+ import math
192
+
185
193
  x, y = component_pos
186
194
 
195
+ # Get base offsets (for 0° rotation)
187
196
  if property_name == "Reference":
188
- return (x + self.properties.reference_x, y + self.properties.reference_y, 0)
197
+ dx, dy = self.properties.reference_x, self.properties.reference_y
189
198
  elif property_name == "Value":
190
- return (x + self.properties.value_x, y + self.properties.value_y, 0)
199
+ dx, dy = self.properties.value_x, self.properties.value_y
191
200
  elif property_name == "Footprint":
192
201
  # Footprint positioned to left of component, rotated 90 degrees
193
- return (x - 1.778, y, self.properties.footprint_rotation) # Exact match for reference
202
+ return (x - 1.778, y, self.properties.footprint_rotation)
194
203
  elif property_name in ["Datasheet", "Description"]:
195
204
  # Hidden properties at component center
196
205
  return (x, y, 0)
197
206
  else:
198
207
  # Other properties stacked vertically below
199
- return (
200
- x + self.properties.reference_x,
201
- y
202
- + self.properties.value_y
203
- + (self.properties.hidden_property_offset * offset_index),
204
- 0,
205
- )
208
+ dx = self.properties.reference_x
209
+ dy = self.properties.value_y + (self.properties.hidden_property_offset * offset_index)
210
+
211
+ # Apply rotation transform to offsets
212
+ # Text stays at 0° rotation (readable), but position rotates around component
213
+ # KiCad uses clockwise rotation, so negate the angle
214
+ rotation_rad = math.radians(-component_rotation)
215
+ dx_rotated = dx * math.cos(rotation_rad) - dy * math.sin(rotation_rad)
216
+ dy_rotated = dx * math.sin(rotation_rad) + dy * math.cos(rotation_rad)
217
+
218
+ return (x + dx_rotated, y + dy_rotated, 0)
206
219
 
207
220
 
208
221
  # Global configuration instance