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
@@ -1,28 +1,31 @@
1
1
  """
2
- Component collection with specialized indexing and component-specific operations.
2
+ Enhanced component management with IndexRegistry integration.
3
3
 
4
- Extends the base IndexedCollection to provide component-specific features like
5
- reference indexing, lib_id grouping, and automatic reference generation.
4
+ This module provides the Component wrapper and ComponentCollection using the new
5
+ BaseCollection infrastructure with centralized index management, lazy rebuilding,
6
+ and batch mode support.
6
7
  """
7
8
 
8
9
  import logging
9
10
  import uuid
10
- from typing import Any, Dict, List, Optional, Tuple, Union
11
+ from typing import Any, Dict, Iterator, List, Optional, Tuple, Union
11
12
 
12
- from ..core.types import Point, SchematicSymbol
13
- from ..library.cache import get_symbol_cache
14
- from ..utils.validation import SchematicValidator, ValidationError
15
- from .base import IndexedCollection
13
+ from ..core.ic_manager import ICManager
14
+ from ..core.types import Point, PinInfo, SchematicPin, SchematicSymbol
15
+ from ..library.cache import SymbolDefinition, get_symbol_cache
16
+ from ..utils.validation import SchematicValidator, ValidationError, ValidationIssue
17
+ from .base import BaseCollection, IndexSpec, ValidationLevel
16
18
 
17
19
  logger = logging.getLogger(__name__)
18
20
 
19
21
 
20
22
  class Component:
21
23
  """
22
- Enhanced wrapper for schematic components with modern API.
24
+ Enhanced wrapper for schematic components.
23
25
 
24
26
  Provides intuitive access to component properties, pins, and operations
25
- while maintaining exact format preservation for professional use.
27
+ while maintaining exact format preservation. All property modifications
28
+ automatically notify the parent collection for tracking.
26
29
  """
27
30
 
28
31
  def __init__(self, symbol_data: SchematicSymbol, parent_collection: "ComponentCollection"):
@@ -31,30 +34,39 @@ class Component:
31
34
 
32
35
  Args:
33
36
  symbol_data: Underlying symbol data
34
- parent_collection: Parent collection for updates
37
+ parent_collection: Parent collection for modification tracking
35
38
  """
36
39
  self._data = symbol_data
37
40
  self._collection = parent_collection
38
41
  self._validator = SchematicValidator()
39
42
 
43
+ # Core properties with validation
40
44
  @property
41
45
  def uuid(self) -> str:
42
- """Component UUID."""
46
+ """Component UUID (read-only)."""
43
47
  return self._data.uuid
44
48
 
45
49
  @property
46
50
  def reference(self) -> str:
47
- """Component reference (e.g., 'R1')."""
51
+ """Component reference designator (e.g., 'R1', 'U2')."""
48
52
  return self._data.reference
49
53
 
50
54
  @reference.setter
51
55
  def reference(self, value: str):
52
- """Set component reference with validation."""
56
+ """
57
+ Set component reference with validation and duplicate checking.
58
+
59
+ Args:
60
+ value: New reference designator
61
+
62
+ Raises:
63
+ ValidationError: If reference format is invalid or already exists
64
+ """
53
65
  if not self._validator.validate_reference(value):
54
66
  raise ValidationError(f"Invalid reference format: {value}")
55
67
 
56
68
  # Check for duplicates in parent collection
57
- if self._collection.get_by_reference(value) is not None:
69
+ if self._collection.get(value) is not None:
58
70
  raise ValidationError(f"Reference {value} already exists")
59
71
 
60
72
  old_ref = self._data.reference
@@ -65,28 +77,41 @@ class Component:
65
77
 
66
78
  @property
67
79
  def value(self) -> str:
68
- """Component value (e.g., '10k')."""
80
+ """Component value (e.g., '10k', '100nF')."""
69
81
  return self._data.value
70
82
 
71
83
  @value.setter
72
84
  def value(self, value: str):
73
85
  """Set component value."""
86
+ old_value = self._data.value
74
87
  self._data.value = value
88
+ self._collection._update_value_index(self, old_value, value)
75
89
  self._collection._mark_modified()
76
90
 
77
91
  @property
78
- def lib_id(self) -> str:
79
- """Component library ID (e.g., 'Device:R')."""
80
- return self._data.lib_id
92
+ def footprint(self) -> Optional[str]:
93
+ """Component footprint (e.g., 'Resistor_SMD:R_0603_1608Metric')."""
94
+ return self._data.footprint
95
+
96
+ @footprint.setter
97
+ def footprint(self, value: Optional[str]):
98
+ """Set component footprint."""
99
+ self._data.footprint = value
100
+ self._collection._mark_modified()
81
101
 
82
102
  @property
83
103
  def position(self) -> Point:
84
- """Component position."""
104
+ """Component position in schematic (mm)."""
85
105
  return self._data.position
86
106
 
87
107
  @position.setter
88
108
  def position(self, value: Union[Point, Tuple[float, float]]):
89
- """Set component position."""
109
+ """
110
+ Set component position.
111
+
112
+ Args:
113
+ value: Position as Point or (x, y) tuple
114
+ """
90
115
  if isinstance(value, tuple):
91
116
  value = Point(value[0], value[1])
92
117
  self._data.position = value
@@ -94,106 +119,425 @@ class Component:
94
119
 
95
120
  @property
96
121
  def rotation(self) -> float:
97
- """Component rotation in degrees."""
122
+ """Component rotation in degrees (0, 90, 180, or 270)."""
98
123
  return self._data.rotation
99
124
 
100
125
  @rotation.setter
101
126
  def rotation(self, value: float):
102
- """Set component rotation."""
103
- self._data.rotation = value
127
+ """
128
+ Set component rotation.
129
+
130
+ KiCad only supports 0, 90, 180, or 270 degree rotations.
131
+
132
+ Args:
133
+ value: Rotation angle in degrees
134
+
135
+ Raises:
136
+ ValueError: If rotation is not 0, 90, 180, or 270
137
+ """
138
+ # Normalize rotation to 0-360 range
139
+ normalized = float(value) % 360
140
+
141
+ # KiCad only accepts specific angles
142
+ VALID_ROTATIONS = {0, 90, 180, 270}
143
+ if normalized not in VALID_ROTATIONS:
144
+ raise ValueError(
145
+ f"Component rotation must be 0, 90, 180, or 270 degrees. "
146
+ f"Got {value}° (normalized to {normalized}°). "
147
+ f"KiCad does not support arbitrary rotation angles."
148
+ )
149
+
150
+ self._data.rotation = normalized
104
151
  self._collection._mark_modified()
105
152
 
106
153
  @property
107
- def footprint(self) -> Optional[str]:
108
- """Component footprint."""
109
- return self._data.footprint
154
+ def lib_id(self) -> str:
155
+ """Library identifier (e.g., 'Device:R')."""
156
+ return self._data.lib_id
110
157
 
111
- @footprint.setter
112
- def footprint(self, value: Optional[str]):
113
- """Set component footprint."""
114
- self._data.footprint = value
115
- self._collection._mark_modified()
158
+ @property
159
+ def library(self) -> str:
160
+ """Library name (e.g., 'Device' from 'Device:R')."""
161
+ return self._data.library
162
+
163
+ @property
164
+ def symbol_name(self) -> str:
165
+ """Symbol name within library (e.g., 'R' from 'Device:R')."""
166
+ return self._data.symbol_name
167
+
168
+ # Properties dictionary
169
+ @property
170
+ def properties(self) -> Dict[str, str]:
171
+ """Dictionary of all component properties."""
172
+ return self._data.properties
173
+
174
+ def get_property(self, name: str, default: Optional[str] = None) -> Optional[str]:
175
+ """
176
+ Get property value by name.
177
+
178
+ Args:
179
+ name: Property name
180
+ default: Default value if property doesn't exist
181
+
182
+ Returns:
183
+ Property value or default
184
+ """
185
+ return self._data.properties.get(name, default)
186
+
187
+ def set_property(self, name: str, value: str):
188
+ """
189
+ Set property value with validation.
190
+
191
+ Args:
192
+ name: Property name
193
+ value: Property value
116
194
 
117
- def get_property(self, name: str) -> Optional[str]:
118
- """Get component property value."""
119
- return self._data.properties.get(name)
195
+ Raises:
196
+ ValidationError: If name or value are not strings
197
+ """
198
+ if not isinstance(name, str) or not isinstance(value, str):
199
+ raise ValidationError("Property name and value must be strings")
120
200
 
121
- def set_property(self, name: str, value: str) -> None:
122
- """Set component property value."""
123
201
  self._data.properties[name] = value
124
202
  self._collection._mark_modified()
203
+ logger.debug(f"Set property {self.reference}.{name} = {value}")
204
+
205
+ def remove_property(self, name: str) -> bool:
206
+ """
207
+ Remove property by name.
208
+
209
+ Args:
210
+ name: Property name to remove
211
+
212
+ Returns:
213
+ True if property was removed, False if it didn't exist
214
+ """
215
+ if name in self._data.properties:
216
+ del self._data.properties[name]
217
+ self._collection._mark_modified()
218
+ return True
219
+ return False
220
+
221
+ # Pin access
222
+ @property
223
+ def pins(self) -> List[SchematicPin]:
224
+ """List of component pins."""
225
+ return self._data.pins
226
+
227
+ def get_pin(self, pin_number: str) -> Optional[SchematicPin]:
228
+ """
229
+ Get pin by number.
230
+
231
+ Args:
232
+ pin_number: Pin number to find
233
+
234
+ Returns:
235
+ SchematicPin if found, None otherwise
236
+ """
237
+ return self._data.get_pin(pin_number)
238
+
239
+ def get_pin_position(self, pin_number: str) -> Optional[Point]:
240
+ """
241
+ Get absolute position of a pin.
242
+
243
+ Calculates the pin position accounting for component position,
244
+ rotation, and mirroring.
245
+
246
+ Args:
247
+ pin_number: Pin number to find position for
248
+
249
+ Returns:
250
+ Absolute pin position in schematic coordinates, or None if pin not found
251
+ """
252
+ return self._data.get_pin_position(pin_number)
253
+
254
+ # Component state flags
255
+ @property
256
+ def in_bom(self) -> bool:
257
+ """Whether component appears in bill of materials."""
258
+ return self._data.in_bom
259
+
260
+ @in_bom.setter
261
+ def in_bom(self, value: bool):
262
+ """Set BOM inclusion flag."""
263
+ self._data.in_bom = bool(value)
264
+ self._collection._mark_modified()
265
+
266
+ @property
267
+ def on_board(self) -> bool:
268
+ """Whether component appears on PCB."""
269
+ return self._data.on_board
270
+
271
+ @on_board.setter
272
+ def on_board(self, value: bool):
273
+ """Set board inclusion flag."""
274
+ self._data.on_board = bool(value)
275
+ self._collection._mark_modified()
276
+
277
+ # Utility methods
278
+ def move(self, x: float, y: float):
279
+ """
280
+ Move component to absolute position.
281
+
282
+ Args:
283
+ x: X coordinate in mm
284
+ y: Y coordinate in mm
285
+ """
286
+ self.position = Point(x, y)
287
+
288
+ def translate(self, dx: float, dy: float):
289
+ """
290
+ Translate component by offset.
291
+
292
+ Args:
293
+ dx: X offset in mm
294
+ dy: Y offset in mm
295
+ """
296
+ current = self.position
297
+ self.position = Point(current.x + dx, current.y + dy)
298
+
299
+ def rotate(self, angle: float):
300
+ """
301
+ Rotate component by angle (cumulative).
302
+
303
+ Args:
304
+ angle: Rotation angle in degrees (will be normalized to 0/90/180/270)
305
+ """
306
+ self.rotation = (self.rotation + angle) % 360
307
+
308
+ def align_pin(
309
+ self, pin_number: str, target_position: Union[Point, Tuple[float, float]]
310
+ ) -> None:
311
+ """
312
+ Move component so that the specified pin is at the target position.
313
+
314
+ This adjusts the component's position to align a specific pin with the
315
+ target coordinates while maintaining the component's current rotation.
316
+ Useful for aligning existing components in horizontal signal flows.
317
+
318
+ Args:
319
+ pin_number: Pin number to align (e.g., "1", "2")
320
+ target_position: Desired position for the pin (Point or (x, y) tuple)
321
+
322
+ Raises:
323
+ ValueError: If pin_number doesn't exist in the component
324
+
325
+ Example:
326
+ # Move resistor so pin 2 is at (150, 100)
327
+ r1 = sch.components.get("R1")
328
+ r1.align_pin("2", (150, 100))
329
+
330
+ # Align capacitor pin 1 on same horizontal line
331
+ c1 = sch.components.get("C1")
332
+ c1.align_pin("1", (200, 100)) # Same Y as resistor pin 2
333
+ """
334
+ from ..core.geometry import calculate_position_for_pin
335
+
336
+ # Get symbol definition to find the pin's local position
337
+ symbol_def = self.get_symbol_definition()
338
+ if not symbol_def:
339
+ raise ValueError(f"Symbol definition not found for {self.reference} ({self.lib_id})")
340
+
341
+ # Find the pin in the symbol definition
342
+ pin_def = None
343
+ for pin in symbol_def.pins:
344
+ if pin.number == pin_number:
345
+ pin_def = pin
346
+ break
347
+
348
+ if not pin_def:
349
+ available_pins = [p.number for p in symbol_def.pins]
350
+ raise ValueError(
351
+ f"Pin '{pin_number}' not found in component {self.reference} ({self.lib_id}). "
352
+ f"Available pins: {', '.join(available_pins)}"
353
+ )
354
+
355
+ logger.debug(
356
+ f"Aligning {self.reference} pin {pin_number} "
357
+ f"(local position: {pin_def.position}) to target {target_position}"
358
+ )
359
+
360
+ # Calculate new component position
361
+ new_position = calculate_position_for_pin(
362
+ pin_local_position=pin_def.position,
363
+ desired_pin_position=target_position,
364
+ rotation=self.rotation,
365
+ mirror=None, # TODO: Add mirror support when needed
366
+ grid_size=1.27,
367
+ )
368
+
369
+ old_position = self.position
370
+ self.position = new_position
371
+
372
+ logger.info(
373
+ f"Aligned {self.reference} pin {pin_number} to {target_position}: "
374
+ f"moved from {old_position} to {new_position}"
375
+ )
376
+
377
+ def copy_properties_from(self, other: "Component"):
378
+ """
379
+ Copy all properties from another component.
380
+
381
+ Args:
382
+ other: Component to copy properties from
383
+ """
384
+ for name, value in other.properties.items():
385
+ self.set_property(name, value)
386
+
387
+ def get_symbol_definition(self) -> Optional[SymbolDefinition]:
388
+ """
389
+ Get the symbol definition from library cache.
390
+
391
+ Returns:
392
+ SymbolDefinition if found, None otherwise
393
+ """
394
+ cache = get_symbol_cache()
395
+ return cache.get_symbol(self.lib_id)
396
+
397
+ def update_from_library(self) -> bool:
398
+ """
399
+ Update component pins and metadata from library definition.
400
+
401
+ Returns:
402
+ True if update successful, False if symbol not found
403
+ """
404
+ symbol_def = self.get_symbol_definition()
405
+ if not symbol_def:
406
+ return False
407
+
408
+ # Update pins
409
+ self._data.pins = symbol_def.pins.copy()
410
+
411
+ # Warn if reference prefix doesn't match
412
+ if not self.reference.startswith(symbol_def.reference_prefix):
413
+ logger.warning(
414
+ f"Reference {self.reference} doesn't match expected prefix {symbol_def.reference_prefix}"
415
+ )
416
+
417
+ self._collection._mark_modified()
418
+ return True
419
+
420
+ def validate(self) -> List[ValidationIssue]:
421
+ """
422
+ Validate this component.
423
+
424
+ Returns:
425
+ List of validation issues (empty if valid)
426
+ """
427
+ return self._validator.validate_component(self._data.__dict__)
428
+
429
+ def to_dict(self) -> Dict[str, Any]:
430
+ """
431
+ Convert component to dictionary representation.
432
+
433
+ Returns:
434
+ Dictionary with component data
435
+ """
436
+ return {
437
+ "reference": self.reference,
438
+ "lib_id": self.lib_id,
439
+ "value": self.value,
440
+ "footprint": self.footprint,
441
+ "position": {"x": self.position.x, "y": self.position.y},
442
+ "rotation": self.rotation,
443
+ "properties": self.properties.copy(),
444
+ "in_bom": self.in_bom,
445
+ "on_board": self.on_board,
446
+ "pin_count": len(self.pins),
447
+ }
448
+
449
+ def __str__(self) -> str:
450
+ """String representation for display."""
451
+ return f"<Component {self.reference}: {self.lib_id} = '{self.value}' @ {self.position}>"
125
452
 
126
453
  def __repr__(self) -> str:
127
- """Detailed representation."""
454
+ """Detailed representation for debugging."""
128
455
  return (
129
456
  f"Component(ref='{self.reference}', lib_id='{self.lib_id}', "
130
457
  f"value='{self.value}', pos={self.position}, rotation={self.rotation})"
131
458
  )
132
459
 
133
460
 
134
- class ComponentCollection(IndexedCollection[Component]):
461
+ class ComponentCollection(BaseCollection[Component]):
135
462
  """
136
- Collection class for efficient component management.
137
-
138
- Extends IndexedCollection with component-specific features:
139
- - Reference-based indexing for fast component lookup
140
- - Lib_id grouping for filtering by component type
141
- - Value indexing for filtering by component value
142
- - Automatic reference generation
143
- - Component validation and conflict detection
463
+ Collection class for efficient component management using IndexRegistry.
464
+
465
+ Provides fast lookup, filtering, and bulk operations with lazy index rebuilding
466
+ and batch mode support. Uses centralized IndexRegistry for managing all indexes
467
+ (UUID, reference, lib_id, value).
144
468
  """
145
469
 
146
- def __init__(self, components: Optional[List[SchematicSymbol]] = None):
470
+ def __init__(
471
+ self,
472
+ components: Optional[List[SchematicSymbol]] = None,
473
+ parent_schematic=None,
474
+ validation_level: ValidationLevel = ValidationLevel.NORMAL,
475
+ ):
147
476
  """
148
477
  Initialize component collection.
149
478
 
150
479
  Args:
151
480
  components: Initial list of component data
481
+ parent_schematic: Reference to parent Schematic (for hierarchy context)
482
+ validation_level: Validation level for operations
152
483
  """
153
- self._reference_index: Dict[str, Component] = {}
484
+ # Initialize base collection with validation level
485
+ super().__init__(validation_level=validation_level)
486
+
487
+ # Store parent schematic reference for hierarchy context
488
+ self._parent_schematic = parent_schematic
489
+
490
+ # Manual indexes for special cases not handled by IndexRegistry
491
+ # (These are maintained separately for complex operations)
154
492
  self._lib_id_index: Dict[str, List[Component]] = {}
155
493
  self._value_index: Dict[str, List[Component]] = {}
156
494
 
157
- # Initialize base collection
158
- wrapped_components = []
495
+ # Add initial components
159
496
  if components:
160
- wrapped_components = [Component(comp_data, self) for comp_data in components]
497
+ with self.batch_mode():
498
+ for comp_data in components:
499
+ component = Component(comp_data, self)
500
+ super().add(component)
501
+ self._add_to_manual_indexes(component)
161
502
 
162
- super().__init__(wrapped_components)
503
+ logger.debug(f"ComponentCollection initialized with {len(self)} components")
163
504
 
164
- # Abstract method implementations
505
+ # BaseCollection abstract method implementations
165
506
  def _get_item_uuid(self, item: Component) -> str:
166
507
  """Extract UUID from component."""
167
508
  return item.uuid
168
509
 
169
510
  def _create_item(self, **kwargs) -> Component:
170
- """Create a new component with given parameters."""
171
- # This will be called by add() methods in subclasses
172
- raise NotImplementedError("Use add() method instead")
173
-
174
- def _build_additional_indexes(self) -> None:
175
- """Build component-specific indexes."""
176
- # Clear existing indexes
177
- self._reference_index.clear()
178
- self._lib_id_index.clear()
179
- self._value_index.clear()
180
-
181
- # Rebuild indexes from current items
182
- for component in self._items:
183
- # Reference index
184
- self._reference_index[component.reference] = component
511
+ """
512
+ Create a new component (not typically used directly).
185
513
 
186
- # Lib_id index
187
- if component.lib_id not in self._lib_id_index:
188
- self._lib_id_index[component.lib_id] = []
189
- self._lib_id_index[component.lib_id].append(component)
514
+ Use add() method instead for proper component creation.
515
+ """
516
+ raise NotImplementedError("Use add() method to create components")
190
517
 
191
- # Value index
192
- if component.value not in self._value_index:
193
- self._value_index[component.value] = []
194
- self._value_index[component.value].append(component)
518
+ def _get_index_specs(self) -> List[IndexSpec]:
519
+ """
520
+ Get index specifications for component collection.
195
521
 
196
- # Component-specific methods
522
+ Returns:
523
+ List of IndexSpec for UUID and reference indexes
524
+ """
525
+ return [
526
+ IndexSpec(
527
+ name="uuid",
528
+ key_func=lambda c: c.uuid,
529
+ unique=True,
530
+ description="UUID index for fast lookups",
531
+ ),
532
+ IndexSpec(
533
+ name="reference",
534
+ key_func=lambda c: c.reference,
535
+ unique=True,
536
+ description="Reference designator index (R1, U2, etc.)",
537
+ ),
538
+ ]
539
+
540
+ # Component-specific add method
197
541
  def add(
198
542
  self,
199
543
  lib_id: str,
@@ -202,6 +546,7 @@ class ComponentCollection(IndexedCollection[Component]):
202
546
  position: Optional[Union[Point, Tuple[float, float]]] = None,
203
547
  footprint: Optional[str] = None,
204
548
  unit: int = 1,
549
+ rotation: float = 0.0,
205
550
  component_uuid: Optional[str] = None,
206
551
  **properties,
207
552
  ) -> Component:
@@ -215,6 +560,7 @@ class ComponentCollection(IndexedCollection[Component]):
215
560
  position: Component position (auto-placed if None)
216
561
  footprint: Component footprint
217
562
  unit: Unit number for multi-unit components (1-based)
563
+ rotation: Component rotation in degrees (0, 90, 180, 270)
218
564
  component_uuid: Specific UUID for component (auto-generated if None)
219
565
  **properties: Additional component properties
220
566
 
@@ -223,6 +569,7 @@ class ComponentCollection(IndexedCollection[Component]):
223
569
 
224
570
  Raises:
225
571
  ValidationError: If component data is invalid
572
+ LibraryError: If symbol library not found
226
573
  """
227
574
  # Validate lib_id
228
575
  validator = SchematicValidator()
@@ -239,7 +586,7 @@ class ComponentCollection(IndexedCollection[Component]):
239
586
 
240
587
  # Check for duplicate reference
241
588
  self._ensure_indexes_current()
242
- if reference in self._reference_index:
589
+ if self._index_registry.has_key("reference", reference):
243
590
  raise ValidationError(f"Reference {reference} already exists")
244
591
 
245
592
  # Set default position if not provided
@@ -254,181 +601,1023 @@ class ComponentCollection(IndexedCollection[Component]):
254
601
  snapped_pos = snap_to_grid((position.x, position.y), grid_size=1.27)
255
602
  position = Point(snapped_pos[0], snapped_pos[1])
256
603
 
257
- # Generate UUID if not provided
258
- if component_uuid is None:
259
- component_uuid = str(uuid.uuid4())
604
+ logger.debug(
605
+ f"Component {reference} position snapped to grid: ({position.x:.3f}, {position.y:.3f})"
606
+ )
260
607
 
261
- # Create SchematicSymbol data
262
- symbol_data = SchematicSymbol(
263
- uuid=component_uuid,
608
+ # Normalize and validate rotation
609
+ rotation = rotation % 360
610
+ VALID_ROTATIONS = {0, 90, 180, 270}
611
+ if rotation not in VALID_ROTATIONS:
612
+ raise ValidationError(
613
+ f"Component rotation must be 0, 90, 180, or 270 degrees. "
614
+ f"Got {rotation}°. KiCad does not support arbitrary rotation angles."
615
+ )
616
+
617
+ # Add hierarchy_path if parent schematic has hierarchy context
618
+ if self._parent_schematic and hasattr(self._parent_schematic, "_hierarchy_path"):
619
+ if self._parent_schematic._hierarchy_path:
620
+ properties = dict(properties)
621
+ properties["hierarchy_path"] = self._parent_schematic._hierarchy_path
622
+ logger.debug(
623
+ f"Setting hierarchy_path for component {reference}: "
624
+ f"{self._parent_schematic._hierarchy_path}"
625
+ )
626
+
627
+ # Create component data
628
+ component_data = SchematicSymbol(
629
+ uuid=component_uuid if component_uuid else str(uuid.uuid4()),
264
630
  lib_id=lib_id,
631
+ position=position,
265
632
  reference=reference,
266
633
  value=value,
267
- position=position,
268
- rotation=0.0,
269
- unit=unit,
270
- in_bom=True,
271
- on_board=True,
272
634
  footprint=footprint,
273
- properties=properties.copy(),
635
+ unit=unit,
636
+ rotation=rotation,
637
+ properties=properties,
274
638
  )
275
639
 
640
+ # Get symbol definition and update pins
641
+ from ..core.exceptions import LibraryError
642
+
643
+ symbol_cache = get_symbol_cache()
644
+ symbol_def = symbol_cache.get_symbol(lib_id)
645
+ if not symbol_def:
646
+ library_name = lib_id.split(":")[0] if ":" in lib_id else "unknown"
647
+ raise LibraryError(
648
+ f"Symbol '{lib_id}' not found in KiCAD libraries. "
649
+ f"Please verify the library name '{library_name}' and symbol name are correct. "
650
+ f"Common libraries include: Device, Connector_Generic, Regulator_Linear, RF_Module",
651
+ field="lib_id",
652
+ value=lib_id,
653
+ )
654
+ component_data.pins = symbol_def.pins.copy()
655
+
276
656
  # Create component wrapper
277
- component = Component(symbol_data, self)
657
+ component = Component(component_data, self)
658
+
659
+ # Add to collection (includes IndexRegistry)
660
+ super().add(component)
661
+
662
+ # Add to manual indexes (lib_id, value)
663
+ self._add_to_manual_indexes(component)
664
+
665
+ logger.info(f"Added component: {reference} ({lib_id})")
666
+ return component
667
+
668
+ def add_with_pin_at(
669
+ self,
670
+ lib_id: str,
671
+ pin_number: str,
672
+ pin_position: Union[Point, Tuple[float, float]],
673
+ reference: Optional[str] = None,
674
+ value: str = "",
675
+ rotation: float = 0.0,
676
+ footprint: Optional[str] = None,
677
+ unit: int = 1,
678
+ component_uuid: Optional[str] = None,
679
+ **properties,
680
+ ) -> Component:
681
+ """
682
+ Add component positioned by a specific pin location.
683
+
684
+ Instead of specifying the component's center position, this method allows
685
+ you to specify where a particular pin should be placed. This is extremely
686
+ useful for aligning components in horizontal signal flows without manual
687
+ offset calculations.
688
+
689
+ Args:
690
+ lib_id: Library identifier (e.g., "Device:R", "Device:C")
691
+ pin_number: Pin number to position (e.g., "1", "2")
692
+ pin_position: Desired position for the specified pin
693
+ reference: Component reference (auto-generated if None)
694
+ value: Component value
695
+ rotation: Component rotation in degrees (0, 90, 180, 270)
696
+ footprint: Component footprint
697
+ unit: Unit number for multi-unit components (1-based)
698
+ component_uuid: Specific UUID for component (auto-generated if None)
699
+ **properties: Additional component properties
700
+
701
+ Returns:
702
+ Newly created Component with the specified pin at pin_position
703
+
704
+ Raises:
705
+ ValidationError: If component data is invalid
706
+ LibraryError: If symbol library not found
707
+ ValueError: If pin_number doesn't exist in the component
708
+
709
+ Example:
710
+ # Place resistor with pin 2 at (150, 100)
711
+ r1 = sch.components.add_with_pin_at(
712
+ lib_id="Device:R",
713
+ pin_number="2",
714
+ pin_position=(150, 100),
715
+ value="10k"
716
+ )
717
+
718
+ # Place capacitor with pin 1 aligned on same horizontal line
719
+ c1 = sch.components.add_with_pin_at(
720
+ lib_id="Device:C",
721
+ pin_number="1",
722
+ pin_position=(200, 100), # Same Y as resistor pin 2
723
+ value="100nF"
724
+ )
725
+ """
726
+ from ..core.exceptions import LibraryError
727
+ from ..core.geometry import calculate_position_for_pin
728
+
729
+ # Get symbol definition to find the pin's local position
730
+ symbol_cache = get_symbol_cache()
731
+ symbol_def = symbol_cache.get_symbol(lib_id)
732
+ if not symbol_def:
733
+ library_name = lib_id.split(":")[0] if ":" in lib_id else "unknown"
734
+ raise LibraryError(
735
+ f"Symbol '{lib_id}' not found in KiCAD libraries. "
736
+ f"Please verify the library name '{library_name}' and symbol name are correct. "
737
+ f"Common libraries include: Device, Connector_Generic, Regulator_Linear, RF_Module",
738
+ field="lib_id",
739
+ value=lib_id,
740
+ )
741
+
742
+ # Find the pin in the symbol definition
743
+ pin_def = None
744
+ for pin in symbol_def.pins:
745
+ if pin.number == pin_number:
746
+ pin_def = pin
747
+ break
748
+
749
+ if not pin_def:
750
+ available_pins = [p.number for p in symbol_def.pins]
751
+ raise ValueError(
752
+ f"Pin '{pin_number}' not found in symbol '{lib_id}'. "
753
+ f"Available pins: {', '.join(available_pins)}"
754
+ )
755
+
756
+ logger.debug(
757
+ f"Pin {pin_number} found at local position ({pin_def.position.x}, {pin_def.position.y})"
758
+ )
759
+
760
+ # Calculate component position that will place the pin at the desired location
761
+ component_position = calculate_position_for_pin(
762
+ pin_local_position=pin_def.position,
763
+ desired_pin_position=pin_position,
764
+ rotation=rotation,
765
+ mirror=None, # TODO: Add mirror support when needed
766
+ grid_size=1.27,
767
+ )
768
+
769
+ logger.info(
770
+ f"Calculated component position ({component_position.x}, {component_position.y}) "
771
+ f"to place pin {pin_number} at ({pin_position if isinstance(pin_position, Point) else Point(*pin_position)})"
772
+ )
278
773
 
279
- # Add to collection using base class method
280
- return super().add(component)
774
+ # Use the regular add() method with the calculated position
775
+ return self.add(
776
+ lib_id=lib_id,
777
+ reference=reference,
778
+ value=value,
779
+ position=component_position,
780
+ footprint=footprint,
781
+ unit=unit,
782
+ rotation=rotation,
783
+ component_uuid=component_uuid,
784
+ **properties,
785
+ )
281
786
 
282
- def get_by_reference(self, reference: str) -> Optional[Component]:
787
+ def add_ic(
788
+ self,
789
+ lib_id: str,
790
+ reference_prefix: str,
791
+ position: Optional[Union[Point, Tuple[float, float]]] = None,
792
+ value: str = "",
793
+ footprint: Optional[str] = None,
794
+ layout_style: str = "vertical",
795
+ **properties,
796
+ ) -> ICManager:
283
797
  """
284
- Get component by reference.
798
+ Add a multi-unit IC with automatic unit placement.
285
799
 
286
800
  Args:
287
- reference: Component reference to find
801
+ lib_id: Library identifier for the IC (e.g., "74xx:7400")
802
+ reference_prefix: Base reference (e.g., "U1" → U1A, U1B, etc.)
803
+ position: Base position for auto-layout (auto-placed if None)
804
+ value: IC value (defaults to symbol name)
805
+ footprint: IC footprint
806
+ layout_style: Layout algorithm ("vertical", "grid", "functional")
807
+ **properties: Common properties for all units
808
+
809
+ Returns:
810
+ ICManager object for position overrides and management
811
+
812
+ Example:
813
+ ic = sch.components.add_ic("74xx:7400", "U1", position=(100, 100))
814
+ ic.place_unit(1, position=(150, 80)) # Override Gate A position
815
+ """
816
+ # Set default position if not provided
817
+ if position is None:
818
+ position = self._find_available_position()
819
+ elif isinstance(position, tuple):
820
+ position = Point(position[0], position[1])
821
+
822
+ # Set default value to symbol name if not provided
823
+ if not value:
824
+ value = lib_id.split(":")[-1] # "74xx:7400" → "7400"
825
+
826
+ # Create IC manager for this multi-unit component
827
+ ic_manager = ICManager(lib_id, reference_prefix, position, self)
828
+
829
+ # Generate all unit components
830
+ unit_components = ic_manager.generate_components(
831
+ value=value, footprint=footprint, properties=properties
832
+ )
833
+
834
+ # Add all units to the collection using batch mode for performance
835
+ with self.batch_mode():
836
+ for component_data in unit_components:
837
+ component = Component(component_data, self)
838
+ super().add(component)
839
+ self._add_to_manual_indexes(component)
840
+
841
+ logger.info(
842
+ f"Added multi-unit IC: {reference_prefix} ({lib_id}) with {len(unit_components)} units"
843
+ )
844
+
845
+ return ic_manager
846
+
847
+ # Remove operations
848
+ def remove(self, reference: str) -> bool:
849
+ """
850
+ Remove component by reference designator.
851
+
852
+ Args:
853
+ reference: Component reference to remove (e.g., "R1")
854
+
855
+ Returns:
856
+ True if component was removed, False if not found
857
+
858
+ Raises:
859
+ TypeError: If reference is not a string
860
+ """
861
+ if not isinstance(reference, str):
862
+ raise TypeError(f"reference must be a string, not {type(reference).__name__}")
863
+
864
+ self._ensure_indexes_current()
865
+
866
+ # Get component from reference index
867
+ ref_idx = self._index_registry.get("reference", reference)
868
+ if ref_idx is None:
869
+ return False
870
+
871
+ component = self._items[ref_idx]
872
+
873
+ # Remove from manual indexes
874
+ self._remove_from_manual_indexes(component)
875
+
876
+ # Remove from base collection (UUID index)
877
+ super().remove(component.uuid)
878
+
879
+ logger.info(f"Removed component: {reference}")
880
+ return True
881
+
882
+ def remove_by_uuid(self, component_uuid: str) -> bool:
883
+ """
884
+ Remove component by UUID.
885
+
886
+ Args:
887
+ component_uuid: Component UUID to remove
888
+
889
+ Returns:
890
+ True if component was removed, False if not found
891
+
892
+ Raises:
893
+ TypeError: If UUID is not a string
894
+ """
895
+ if not isinstance(component_uuid, str):
896
+ raise TypeError(f"component_uuid must be a string, not {type(component_uuid).__name__}")
897
+
898
+ # Get component from UUID index
899
+ component = self.get_by_uuid(component_uuid)
900
+ if not component:
901
+ return False
902
+
903
+ # Remove from manual indexes
904
+ self._remove_from_manual_indexes(component)
905
+
906
+ # Remove from base collection
907
+ super().remove(component_uuid)
908
+
909
+ logger.info(f"Removed component by UUID: {component_uuid}")
910
+ return True
911
+
912
+ def remove_component(self, component: Component) -> bool:
913
+ """
914
+ Remove component by component object.
915
+
916
+ Args:
917
+ component: Component object to remove
918
+
919
+ Returns:
920
+ True if component was removed, False if not found
921
+
922
+ Raises:
923
+ TypeError: If component is not a Component instance
924
+ """
925
+ if not isinstance(component, Component):
926
+ raise TypeError(
927
+ f"component must be a Component instance, not {type(component).__name__}"
928
+ )
929
+
930
+ # Check if component exists
931
+ if component.uuid not in self:
932
+ return False
933
+
934
+ # Remove from manual indexes
935
+ self._remove_from_manual_indexes(component)
936
+
937
+ # Remove from base collection
938
+ super().remove(component.uuid)
939
+
940
+ logger.info(f"Removed component: {component.reference}")
941
+ return True
942
+
943
+ # Lookup methods
944
+ def get(self, reference: str) -> Optional[Component]:
945
+ """
946
+ Get component by reference designator.
947
+
948
+ Args:
949
+ reference: Component reference (e.g., "R1")
288
950
 
289
951
  Returns:
290
952
  Component if found, None otherwise
291
953
  """
292
954
  self._ensure_indexes_current()
293
- return self._reference_index.get(reference)
955
+ ref_idx = self._index_registry.get("reference", reference)
956
+ if ref_idx is not None:
957
+ return self._items[ref_idx]
958
+ return None
959
+
960
+ def get_by_uuid(self, component_uuid: str) -> Optional[Component]:
961
+ """
962
+ Get component by UUID.
294
963
 
295
- def get_by_lib_id(self, lib_id: str) -> List[Component]:
964
+ Args:
965
+ component_uuid: Component UUID
966
+
967
+ Returns:
968
+ Component if found, None otherwise
296
969
  """
297
- Get all components with a specific lib_id.
970
+ return super().get(component_uuid)
971
+
972
+ # Filter and search methods
973
+ def filter(self, **criteria) -> List[Component]:
974
+ """
975
+ Filter components by various criteria.
976
+
977
+ Supported criteria:
978
+ lib_id: Filter by library ID (exact match)
979
+ value: Filter by value (exact match)
980
+ value_pattern: Filter by value pattern (contains)
981
+ reference_pattern: Filter by reference pattern (regex)
982
+ footprint: Filter by footprint (exact match)
983
+ in_area: Filter by area (tuple of (x1, y1, x2, y2))
984
+ has_property: Filter components that have a specific property
298
985
 
299
986
  Args:
300
- lib_id: Library ID to search for
987
+ **criteria: Filter criteria
301
988
 
302
989
  Returns:
303
990
  List of matching components
304
991
  """
305
- self._ensure_indexes_current()
306
- return self._lib_id_index.get(lib_id, []).copy()
992
+ results = list(self._items)
993
+
994
+ # Apply filters
995
+ if "lib_id" in criteria:
996
+ lib_id = criteria["lib_id"]
997
+ results = [c for c in results if c.lib_id == lib_id]
998
+
999
+ if "value" in criteria:
1000
+ value = criteria["value"]
1001
+ results = [c for c in results if c.value == value]
1002
+
1003
+ if "value_pattern" in criteria:
1004
+ pattern = criteria["value_pattern"].lower()
1005
+ results = [c for c in results if pattern in c.value.lower()]
1006
+
1007
+ if "reference_pattern" in criteria:
1008
+ import re
1009
+
1010
+ pattern = re.compile(criteria["reference_pattern"])
1011
+ results = [c for c in results if pattern.match(c.reference)]
307
1012
 
308
- def get_by_value(self, value: str) -> List[Component]:
1013
+ if "footprint" in criteria:
1014
+ footprint = criteria["footprint"]
1015
+ results = [c for c in results if c.footprint == footprint]
1016
+
1017
+ if "in_area" in criteria:
1018
+ x1, y1, x2, y2 = criteria["in_area"]
1019
+ results = [c for c in results if x1 <= c.position.x <= x2 and y1 <= c.position.y <= y2]
1020
+
1021
+ if "has_property" in criteria:
1022
+ prop_name = criteria["has_property"]
1023
+ results = [c for c in results if prop_name in c.properties]
1024
+
1025
+ return results
1026
+
1027
+ def filter_by_type(self, component_type: str) -> List[Component]:
309
1028
  """
310
- Get all components with a specific value.
1029
+ Filter components by type prefix.
311
1030
 
312
1031
  Args:
313
- value: Component value to search for
1032
+ component_type: Type prefix (e.g., 'R' for resistors, 'C' for capacitors)
314
1033
 
315
1034
  Returns:
316
1035
  List of matching components
317
1036
  """
318
- self._ensure_indexes_current()
319
- return self._value_index.get(value, []).copy()
1037
+ return [
1038
+ c for c in self._items if c.symbol_name.upper().startswith(component_type.upper())
1039
+ ]
320
1040
 
321
- def _generate_reference(self, lib_id: str) -> str:
1041
+ def in_area(self, x1: float, y1: float, x2: float, y2: float) -> List[Component]:
322
1042
  """
323
- Generate a unique reference for a component.
1043
+ Get components within rectangular area.
324
1044
 
325
1045
  Args:
326
- lib_id: Library ID to generate reference for
1046
+ x1, y1: Top-left corner
1047
+ x2, y2: Bottom-right corner
327
1048
 
328
1049
  Returns:
329
- Unique reference string
1050
+ List of components in area
330
1051
  """
331
- # Extract base reference from lib_id
332
- if ":" in lib_id:
333
- base_ref = lib_id.split(":")[-1]
334
- else:
335
- base_ref = lib_id
336
-
337
- # Map common component types to standard prefixes
338
- ref_prefixes = {
339
- "R": "R",
340
- "Resistor": "R",
341
- "C": "C",
342
- "Capacitor": "C",
343
- "L": "L",
344
- "Inductor": "L",
345
- "D": "D",
346
- "Diode": "D",
347
- "Q": "Q",
348
- "Transistor": "Q",
349
- "U": "U",
350
- "IC": "U",
351
- "Amplifier": "U",
352
- "J": "J",
353
- "Connector": "J",
354
- "SW": "SW",
355
- "Switch": "SW",
356
- "F": "F",
357
- "Fuse": "F",
358
- "TP": "TP",
359
- "TestPoint": "TP",
360
- }
1052
+ return self.filter(in_area=(x1, y1, x2, y2))
361
1053
 
362
- prefix = ref_prefixes.get(base_ref, "U")
1054
+ def near_point(
1055
+ self, point: Union[Point, Tuple[float, float]], radius: float
1056
+ ) -> List[Component]:
1057
+ """
1058
+ Get components within radius of a point.
363
1059
 
364
- # Ensure indexes are current before checking
365
- self._ensure_indexes_current()
1060
+ Args:
1061
+ point: Center point (Point or (x, y) tuple)
1062
+ radius: Search radius in mm
366
1063
 
367
- # Find next available number
368
- counter = 1
369
- while f"{prefix}{counter}" in self._reference_index:
370
- counter += 1
1064
+ Returns:
1065
+ List of components within radius
1066
+ """
1067
+ if isinstance(point, tuple):
1068
+ point = Point(point[0], point[1])
371
1069
 
372
- return f"{prefix}{counter}"
1070
+ results = []
1071
+ for component in self._items:
1072
+ if component.position.distance_to(point) <= radius:
1073
+ results.append(component)
1074
+ return results
373
1075
 
374
- def _find_available_position(self) -> Point:
1076
+ def find_pins_by_name(
1077
+ self, reference: str, name_pattern: str, case_sensitive: bool = False
1078
+ ) -> Optional[List[str]]:
375
1079
  """
376
- Find an available position for a new component.
1080
+ Find pin numbers matching a name pattern.
1081
+
1082
+ Supports both exact matches and wildcard patterns (e.g., "CLK*", "*IN*").
1083
+ By default, matching is case-insensitive for maximum flexibility.
1084
+
1085
+ Args:
1086
+ reference: Component reference designator (e.g., "R1", "U2")
1087
+ name_pattern: Name pattern to search for (e.g., "VCC", "CLK", "OUT", "CLK*", "*IN*")
1088
+ case_sensitive: If False (default), matching is case-insensitive
377
1089
 
378
1090
  Returns:
379
- Available position point
1091
+ List of matching pin numbers (e.g., ["1", "2"]), or None if component not found
1092
+
1093
+ Raises:
1094
+ ValueError: If name_pattern is empty
1095
+
1096
+ Example:
1097
+ # Find all clock pins
1098
+ pins = sch.components.find_pins_by_name("U1", "CLK*")
1099
+ # Returns: ["5", "10"] (whatever the clock pins are numbered)
1100
+
1101
+ # Find power pins
1102
+ pins = sch.components.find_pins_by_name("U1", "VCC")
1103
+ # Returns: ["1", "20"] for a common IC
380
1104
  """
381
- # Start at a reasonable position and check for conflicts
382
- base_x, base_y = 100.0, 100.0
383
- spacing = 25.4 # 1 inch spacing
1105
+ import fnmatch
384
1106
 
385
- # Check existing positions to avoid overlap
386
- used_positions = {(comp.position.x, comp.position.y) for comp in self._items}
1107
+ logger.debug(f"[PIN_DISCOVERY] find_pins_by_name() called for {reference}")
1108
+ logger.debug(f"[PIN_DISCOVERY] Pattern: '{name_pattern}' (case_sensitive={case_sensitive})")
387
1109
 
388
- # Find first available position in a grid pattern
389
- for row in range(10): # Check up to 10 rows
390
- for col in range(10): # Check up to 10 columns
391
- x = base_x + col * spacing
392
- y = base_y + row * spacing
393
- if (x, y) not in used_positions:
394
- return Point(x, y)
1110
+ if not name_pattern:
1111
+ raise ValueError("name_pattern cannot be empty")
395
1112
 
396
- # Fallback to random position if grid is full
397
- return Point(base_x + len(self._items) * spacing, base_y)
1113
+ # Step 1: Get component
1114
+ component = self.get(reference)
1115
+ if not component:
1116
+ logger.warning(f"[PIN_DISCOVERY] Component not found: {reference}")
1117
+ return None
398
1118
 
399
- def _update_reference_index(self, old_reference: str, new_reference: str) -> None:
1119
+ logger.debug(f"[PIN_DISCOVERY] Found component {reference} ({component.lib_id})")
1120
+
1121
+ # Step 2: Get symbol definition
1122
+ symbol_def = component.get_symbol_definition()
1123
+ if not symbol_def:
1124
+ logger.warning(
1125
+ f"[PIN_DISCOVERY] Symbol definition not found for {reference} ({component.lib_id})"
1126
+ )
1127
+ return None
1128
+
1129
+ logger.debug(
1130
+ f"[PIN_DISCOVERY] Symbol has {len(symbol_def.pins)} total pins to search"
1131
+ )
1132
+
1133
+ # Step 3: Match pins by name
1134
+ matching_pins = []
1135
+ search_pattern = name_pattern if case_sensitive else name_pattern.lower()
1136
+
1137
+ for pin in symbol_def.pins:
1138
+ pin_name = pin.name if case_sensitive else pin.name.lower()
1139
+
1140
+ # Use fnmatch for wildcard matching
1141
+ if fnmatch.fnmatch(pin_name, search_pattern):
1142
+ logger.debug(
1143
+ f"[PIN_DISCOVERY] Pin {pin.number} ({pin.name}) matches pattern '{name_pattern}'"
1144
+ )
1145
+ matching_pins.append(pin.number)
1146
+
1147
+ logger.info(
1148
+ f"[PIN_DISCOVERY] Found {len(matching_pins)} pins matching '{name_pattern}' "
1149
+ f"in {reference}: {matching_pins}"
1150
+ )
1151
+ return matching_pins
1152
+
1153
+ def find_pins_by_type(
1154
+ self, reference: str, pin_type: Union[str, "PinType"]
1155
+ ) -> Optional[List[str]]:
400
1156
  """
401
- Update reference index when component reference changes.
1157
+ Find pin numbers by electrical type.
1158
+
1159
+ Returns all pins of a specific electrical type (e.g., all inputs, all power pins).
1160
+
1161
+ Args:
1162
+ reference: Component reference designator (e.g., "R1", "U2")
1163
+ pin_type: Electrical type filter. Can be:
1164
+ - String: "input", "output", "passive", "power_in", "power_out", etc.
1165
+ - PinType enum value
1166
+
1167
+ Returns:
1168
+ List of matching pin numbers, or None if component not found
1169
+
1170
+ Example:
1171
+ # Find all input pins
1172
+ pins = sch.components.find_pins_by_type("U1", "input")
1173
+ # Returns: ["1", "2", "3"]
1174
+
1175
+ # Find all power pins
1176
+ pins = sch.components.find_pins_by_type("U1", "power_in")
1177
+ # Returns: ["20", "40"] for a common IC
1178
+ """
1179
+ from ..core.types import PinType
1180
+
1181
+ logger.debug(f"[PIN_DISCOVERY] find_pins_by_type() called for {reference}")
1182
+
1183
+ # Normalize pin_type to PinType enum
1184
+ if isinstance(pin_type, str):
1185
+ try:
1186
+ pin_type_enum = PinType(pin_type)
1187
+ logger.debug(f"[PIN_DISCOVERY] Type filter: {pin_type}")
1188
+ except ValueError:
1189
+ logger.error(f"[PIN_DISCOVERY] Invalid pin type: {pin_type}")
1190
+ raise ValueError(
1191
+ f"Invalid pin type: {pin_type}. "
1192
+ f"Must be one of: {', '.join(pt.value for pt in PinType)}"
1193
+ )
1194
+ else:
1195
+ pin_type_enum = pin_type
1196
+ logger.debug(f"[PIN_DISCOVERY] Type filter: {pin_type_enum.value}")
1197
+
1198
+ # Step 1: Get component
1199
+ component = self.get(reference)
1200
+ if not component:
1201
+ logger.warning(f"[PIN_DISCOVERY] Component not found: {reference}")
1202
+ return None
1203
+
1204
+ logger.debug(f"[PIN_DISCOVERY] Found component {reference} ({component.lib_id})")
1205
+
1206
+ # Step 2: Get symbol definition
1207
+ symbol_def = component.get_symbol_definition()
1208
+ if not symbol_def:
1209
+ logger.warning(
1210
+ f"[PIN_DISCOVERY] Symbol definition not found for {reference} ({component.lib_id})"
1211
+ )
1212
+ return None
1213
+
1214
+ logger.debug(
1215
+ f"[PIN_DISCOVERY] Symbol has {len(symbol_def.pins)} total pins to filter"
1216
+ )
1217
+
1218
+ # Step 3: Filter pins by type
1219
+ matching_pins = []
1220
+ for pin in symbol_def.pins:
1221
+ if pin.pin_type == pin_type_enum:
1222
+ logger.debug(
1223
+ f"[PIN_DISCOVERY] Pin {pin.number} ({pin.name}) is type {pin_type_enum.value}"
1224
+ )
1225
+ matching_pins.append(pin.number)
1226
+
1227
+ logger.info(
1228
+ f"[PIN_DISCOVERY] Found {len(matching_pins)} pins of type '{pin_type_enum.value}' "
1229
+ f"in {reference}: {matching_pins}"
1230
+ )
1231
+ return matching_pins
1232
+
1233
+ def get_pins_info(self, reference: str) -> Optional[List[PinInfo]]:
1234
+ """
1235
+ Get comprehensive pin information for a component.
1236
+
1237
+ Returns all pins for the specified component with complete metadata
1238
+ including electrical type, shape, absolute position (accounting for
1239
+ rotation and mirroring), and orientation.
402
1240
 
403
1241
  Args:
404
- old_reference: Previous reference
405
- new_reference: New reference
1242
+ reference: Component reference designator (e.g., "R1", "U2")
1243
+
1244
+ Returns:
1245
+ List of PinInfo objects with complete pin metadata, or None if component not found
1246
+
1247
+ Raises:
1248
+ LibraryError: If component's symbol library is not available
1249
+
1250
+ Example:
1251
+ pins = sch.components.get_pins_info("U1")
1252
+ if pins:
1253
+ for pin in pins:
1254
+ print(f"Pin {pin.number}: {pin.name} @ {pin.position}")
1255
+ print(f" Electrical type: {pin.electrical_type.value}")
1256
+ print(f" Shape: {pin.shape.value}")
406
1257
  """
407
- if old_reference in self._reference_index:
408
- component = self._reference_index.pop(old_reference)
409
- self._reference_index[new_reference] = component
1258
+ logger.debug(f"[PIN_DISCOVERY] get_pins_info() called for reference: {reference}")
1259
+
1260
+ # Step 1: Find the component
1261
+ component = self.get(reference)
1262
+ if not component:
1263
+ logger.warning(f"[PIN_DISCOVERY] Component not found: {reference}")
1264
+ return None
1265
+
1266
+ logger.debug(f"[PIN_DISCOVERY] Found component {reference} ({component.lib_id})")
1267
+
1268
+ # Step 2: Get symbol definition from library cache
1269
+ symbol_def = component.get_symbol_definition()
1270
+ if not symbol_def:
1271
+ from ..core.exceptions import LibraryError
1272
+
1273
+ lib_id = component.lib_id
1274
+ logger.error(
1275
+ f"[PIN_DISCOVERY] Symbol library not found for component {reference}: {lib_id}"
1276
+ )
1277
+ raise LibraryError(
1278
+ f"Symbol '{lib_id}' not found in KiCAD libraries. "
1279
+ f"Please verify the library name and symbol name are correct.",
1280
+ field="lib_id",
1281
+ value=lib_id,
1282
+ )
1283
+
1284
+ logger.debug(
1285
+ f"[PIN_DISCOVERY] Retrieved symbol definition for {reference}: "
1286
+ f"{len(symbol_def.pins)} pins"
1287
+ )
410
1288
 
411
- # Bulk operations for performance
1289
+ # Step 3: Build PinInfo list with absolute positions
1290
+ pins_info = []
1291
+ for pin in symbol_def.pins:
1292
+ logger.debug(
1293
+ f"[PIN_DISCOVERY] Processing pin {pin.number} ({pin.name}) "
1294
+ f"in local coords: {pin.position}"
1295
+ )
1296
+
1297
+ # Get absolute position accounting for component rotation
1298
+ absolute_position = component.get_pin_position(pin.number)
1299
+ if not absolute_position:
1300
+ logger.warning(
1301
+ f"[PIN_DISCOVERY] Could not calculate position for pin {pin.number} on {reference}"
1302
+ )
1303
+ continue
1304
+
1305
+ logger.debug(
1306
+ f"[PIN_DISCOVERY] Pin {pin.number} absolute position: {absolute_position}"
1307
+ )
1308
+
1309
+ # Create PinInfo with absolute position
1310
+ pin_info = PinInfo(
1311
+ number=pin.number,
1312
+ name=pin.name,
1313
+ position=absolute_position,
1314
+ electrical_type=pin.pin_type,
1315
+ shape=pin.pin_shape,
1316
+ length=pin.length,
1317
+ orientation=pin.rotation, # Note: pin rotation in symbol space
1318
+ uuid=f"{component.uuid}:{pin.number}", # Composite UUID
1319
+ )
1320
+
1321
+ logger.debug(
1322
+ f"[PIN_DISCOVERY] Created PinInfo for pin {pin.number}: "
1323
+ f"type={pin_info.electrical_type.value}, shape={pin_info.shape.value}"
1324
+ )
1325
+
1326
+ pins_info.append(pin_info)
1327
+
1328
+ logger.info(
1329
+ f"[PIN_DISCOVERY] Successfully retrieved {len(pins_info)} pins for {reference}"
1330
+ )
1331
+ return pins_info
1332
+
1333
+ # Bulk operations
412
1334
  def bulk_update(self, criteria: Dict[str, Any], updates: Dict[str, Any]) -> int:
413
1335
  """
414
- Perform bulk update on components matching criteria.
1336
+ Update multiple components matching criteria.
415
1337
 
416
1338
  Args:
417
- criteria: Criteria for selecting components
418
- updates: Updates to apply
1339
+ criteria: Filter criteria (same as filter method)
1340
+ updates: Dictionary of property updates
419
1341
 
420
1342
  Returns:
421
1343
  Number of components updated
1344
+
1345
+ Example:
1346
+ # Update all 10k resistors to 1% tolerance
1347
+ count = sch.components.bulk_update(
1348
+ criteria={'value': '10k'},
1349
+ updates={'properties': {'Tolerance': '1%'}}
1350
+ )
1351
+ """
1352
+ matching = self.filter(**criteria)
1353
+
1354
+ for component in matching:
1355
+ for key, value in updates.items():
1356
+ if key == "properties" and isinstance(value, dict):
1357
+ # Handle properties dictionary specially
1358
+ for prop_name, prop_value in value.items():
1359
+ component.set_property(prop_name, str(prop_value))
1360
+ elif hasattr(component, key) and key not in ["properties"]:
1361
+ setattr(component, key, value)
1362
+ else:
1363
+ # Add as custom property
1364
+ component.set_property(key, str(value))
1365
+
1366
+ if matching:
1367
+ self._mark_modified()
1368
+
1369
+ logger.info(f"Bulk updated {len(matching)} components")
1370
+ return len(matching)
1371
+
1372
+ # Sorting
1373
+ def sort_by_reference(self):
1374
+ """Sort components by reference designator (in-place)."""
1375
+ self._items.sort(key=lambda c: c.reference)
1376
+ self._index_registry.mark_dirty()
1377
+
1378
+ def sort_by_position(self, by_x: bool = True):
1379
+ """
1380
+ Sort components by position (in-place).
1381
+
1382
+ Args:
1383
+ by_x: If True, sort by X then Y; if False, sort by Y then X
1384
+ """
1385
+ if by_x:
1386
+ self._items.sort(key=lambda c: (c.position.x, c.position.y))
1387
+ else:
1388
+ self._items.sort(key=lambda c: (c.position.y, c.position.x))
1389
+ self._index_registry.mark_dirty()
1390
+
1391
+ # Validation
1392
+ def validate_all(self) -> List[ValidationIssue]:
422
1393
  """
423
- matching_components = self.filter(**criteria)
1394
+ Validate all components in collection.
424
1395
 
425
- for component in matching_components:
426
- for attr, value in updates.items():
427
- if hasattr(component, attr):
428
- setattr(component, attr, value)
1396
+ Returns:
1397
+ List of validation issues found
1398
+ """
1399
+ all_issues = []
1400
+ validator = SchematicValidator()
429
1401
 
430
- self._mark_modified()
431
- self._mark_indexes_dirty()
1402
+ # Validate individual components
1403
+ for component in self._items:
1404
+ issues = component.validate()
1405
+ all_issues.extend(issues)
1406
+
1407
+ # Validate collection-level rules (e.g., duplicate references)
1408
+ self._ensure_indexes_current()
1409
+ references = [c.reference for c in self._items]
1410
+ if len(references) != len(set(references)):
1411
+ # Find duplicates
1412
+ seen = set()
1413
+ duplicates = set()
1414
+ for ref in references:
1415
+ if ref in seen:
1416
+ duplicates.add(ref)
1417
+ seen.add(ref)
1418
+
1419
+ for ref in duplicates:
1420
+ all_issues.append(
1421
+ ValidationIssue(
1422
+ category="reference", message=f"Duplicate reference: {ref}", level="error"
1423
+ )
1424
+ )
1425
+
1426
+ return all_issues
1427
+
1428
+ # Statistics
1429
+ def get_statistics(self) -> Dict[str, Any]:
1430
+ """
1431
+ Get collection statistics.
1432
+
1433
+ Returns:
1434
+ Dictionary with component statistics
1435
+ """
1436
+ lib_counts = {}
1437
+ value_counts = {}
1438
+
1439
+ for component in self._items:
1440
+ # Count by library
1441
+ lib = component.library
1442
+ lib_counts[lib] = lib_counts.get(lib, 0) + 1
1443
+
1444
+ # Count by value
1445
+ value = component.value
1446
+ if value:
1447
+ value_counts[value] = value_counts.get(value, 0) + 1
1448
+
1449
+ # Get base statistics and extend
1450
+ base_stats = super().get_statistics()
1451
+ base_stats.update(
1452
+ {
1453
+ "unique_references": len(self._items), # After rebuild, should equal item_count
1454
+ "libraries_used": len(lib_counts),
1455
+ "library_breakdown": lib_counts,
1456
+ "most_common_values": sorted(
1457
+ value_counts.items(), key=lambda x: x[1], reverse=True
1458
+ )[:10],
1459
+ }
1460
+ )
1461
+
1462
+ return base_stats
1463
+
1464
+ # Collection interface
1465
+ def __getitem__(self, key: Union[int, str]) -> Component:
1466
+ """
1467
+ Get component by index, UUID, or reference.
1468
+
1469
+ Args:
1470
+ key: Integer index, UUID string, or reference string
1471
+
1472
+ Returns:
1473
+ Component at the specified location
1474
+
1475
+ Raises:
1476
+ KeyError: If UUID or reference not found
1477
+ IndexError: If index out of range
1478
+ TypeError: If key is invalid type
1479
+ """
1480
+ if isinstance(key, int):
1481
+ # Integer index
1482
+ return self._items[key]
1483
+ elif isinstance(key, str):
1484
+ # Try reference first (most common)
1485
+ component = self.get(key)
1486
+ if component is not None:
1487
+ return component
1488
+
1489
+ # Try UUID
1490
+ component = self.get_by_uuid(key)
1491
+ if component is not None:
1492
+ return component
1493
+
1494
+ raise KeyError(f"Component not found: {key}")
1495
+ else:
1496
+ raise TypeError(f"Invalid key type: {type(key).__name__}")
1497
+
1498
+ def __contains__(self, item: Union[str, Component]) -> bool:
1499
+ """
1500
+ Check if reference, UUID, or component exists in collection.
1501
+
1502
+ Args:
1503
+ item: Reference string, UUID string, or Component instance
1504
+
1505
+ Returns:
1506
+ True if item exists, False otherwise
1507
+ """
1508
+ if isinstance(item, str):
1509
+ # Check reference or UUID
1510
+ return self.get(item) is not None or self.get_by_uuid(item) is not None
1511
+ elif isinstance(item, Component):
1512
+ # Check by UUID
1513
+ return item.uuid in self
1514
+ else:
1515
+ return False
1516
+
1517
+ # Internal helper methods
1518
+ def _add_to_manual_indexes(self, component: Component):
1519
+ """Add component to manual indexes (lib_id, value)."""
1520
+ # Add to lib_id index (non-unique)
1521
+ lib_id = component.lib_id
1522
+ if lib_id not in self._lib_id_index:
1523
+ self._lib_id_index[lib_id] = []
1524
+ self._lib_id_index[lib_id].append(component)
1525
+
1526
+ # Add to value index (non-unique)
1527
+ value = component.value
1528
+ if value:
1529
+ if value not in self._value_index:
1530
+ self._value_index[value] = []
1531
+ self._value_index[value].append(component)
1532
+
1533
+ def _remove_from_manual_indexes(self, component: Component):
1534
+ """Remove component from manual indexes (lib_id, value)."""
1535
+ # Remove from lib_id index
1536
+ lib_id = component.lib_id
1537
+ if lib_id in self._lib_id_index:
1538
+ self._lib_id_index[lib_id].remove(component)
1539
+ if not self._lib_id_index[lib_id]:
1540
+ del self._lib_id_index[lib_id]
1541
+
1542
+ # Remove from value index
1543
+ value = component.value
1544
+ if value and value in self._value_index:
1545
+ self._value_index[value].remove(component)
1546
+ if not self._value_index[value]:
1547
+ del self._value_index[value]
1548
+
1549
+ def _update_reference_index(self, old_ref: str, new_ref: str):
1550
+ """
1551
+ Update reference index when component reference changes.
1552
+
1553
+ This marks the index as dirty so it will be rebuilt with the new reference.
1554
+ """
1555
+ self._index_registry.mark_dirty()
1556
+ logger.debug(f"Reference index marked dirty: {old_ref} -> {new_ref}")
1557
+
1558
+ def _update_value_index(self, component: Component, old_value: str, new_value: str):
1559
+ """Update value index when component value changes."""
1560
+ # Remove from old value
1561
+ if old_value and old_value in self._value_index:
1562
+ self._value_index[old_value].remove(component)
1563
+ if not self._value_index[old_value]:
1564
+ del self._value_index[old_value]
1565
+
1566
+ # Add to new value
1567
+ if new_value:
1568
+ if new_value not in self._value_index:
1569
+ self._value_index[new_value] = []
1570
+ self._value_index[new_value].append(component)
1571
+
1572
+ def _generate_reference(self, lib_id: str) -> str:
1573
+ """
1574
+ Generate unique reference for component.
1575
+
1576
+ Args:
1577
+ lib_id: Library identifier to determine prefix
1578
+
1579
+ Returns:
1580
+ Generated reference (e.g., "R1", "U2")
1581
+ """
1582
+ # Get reference prefix from symbol definition
1583
+ symbol_cache = get_symbol_cache()
1584
+ symbol_def = symbol_cache.get_symbol(lib_id)
1585
+ prefix = symbol_def.reference_prefix if symbol_def else "U"
1586
+
1587
+ # Ensure indexes are current
1588
+ self._ensure_indexes_current()
1589
+
1590
+ # Find next available number
1591
+ counter = 1
1592
+ while self._index_registry.has_key("reference", f"{prefix}{counter}"):
1593
+ counter += 1
1594
+
1595
+ return f"{prefix}{counter}"
1596
+
1597
+ def _find_available_position(self) -> Point:
1598
+ """
1599
+ Find an available position for automatic placement.
1600
+
1601
+ Uses simple grid layout algorithm.
1602
+
1603
+ Returns:
1604
+ Point for component placement
1605
+ """
1606
+ # Simple grid placement - could be enhanced with collision detection
1607
+ grid_size = 10.0 # 10mm grid
1608
+ max_per_row = 10
1609
+
1610
+ row = len(self._items) // max_per_row
1611
+ col = len(self._items) % max_per_row
1612
+
1613
+ return Point(col * grid_size, row * grid_size)
1614
+
1615
+ # Compatibility methods for legacy Schematic integration
1616
+ @property
1617
+ def modified(self) -> bool:
1618
+ """Check if collection has been modified (compatibility)."""
1619
+ return self.is_modified
432
1620
 
433
- logger.info(f"Bulk updated {len(matching_components)} components")
434
- return len(matching_components)
1621
+ def mark_saved(self) -> None:
1622
+ """Mark collection as saved (reset modified flag)."""
1623
+ self.mark_clean()