kicad-sch-api 0.3.0__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 (112) hide show
  1. kicad_sch_api/__init__.py +68 -3
  2. kicad_sch_api/cli/__init__.py +45 -0
  3. kicad_sch_api/cli/base.py +302 -0
  4. kicad_sch_api/cli/bom.py +164 -0
  5. kicad_sch_api/cli/erc.py +229 -0
  6. kicad_sch_api/cli/export_docs.py +289 -0
  7. kicad_sch_api/cli/kicad_to_python.py +169 -0
  8. kicad_sch_api/cli/netlist.py +94 -0
  9. kicad_sch_api/cli/types.py +43 -0
  10. kicad_sch_api/collections/__init__.py +36 -0
  11. kicad_sch_api/collections/base.py +604 -0
  12. kicad_sch_api/collections/components.py +1623 -0
  13. kicad_sch_api/collections/junctions.py +206 -0
  14. kicad_sch_api/collections/labels.py +508 -0
  15. kicad_sch_api/collections/wires.py +292 -0
  16. kicad_sch_api/core/__init__.py +37 -2
  17. kicad_sch_api/core/collections/__init__.py +5 -0
  18. kicad_sch_api/core/collections/base.py +248 -0
  19. kicad_sch_api/core/component_bounds.py +34 -7
  20. kicad_sch_api/core/components.py +213 -52
  21. kicad_sch_api/core/config.py +110 -15
  22. kicad_sch_api/core/connectivity.py +692 -0
  23. kicad_sch_api/core/exceptions.py +175 -0
  24. kicad_sch_api/core/factories/__init__.py +5 -0
  25. kicad_sch_api/core/factories/element_factory.py +278 -0
  26. kicad_sch_api/core/formatter.py +60 -9
  27. kicad_sch_api/core/geometry.py +94 -5
  28. kicad_sch_api/core/junctions.py +26 -75
  29. kicad_sch_api/core/labels.py +324 -0
  30. kicad_sch_api/core/managers/__init__.py +30 -0
  31. kicad_sch_api/core/managers/base.py +76 -0
  32. kicad_sch_api/core/managers/file_io.py +246 -0
  33. kicad_sch_api/core/managers/format_sync.py +502 -0
  34. kicad_sch_api/core/managers/graphics.py +580 -0
  35. kicad_sch_api/core/managers/hierarchy.py +661 -0
  36. kicad_sch_api/core/managers/metadata.py +271 -0
  37. kicad_sch_api/core/managers/sheet.py +492 -0
  38. kicad_sch_api/core/managers/text_elements.py +537 -0
  39. kicad_sch_api/core/managers/validation.py +476 -0
  40. kicad_sch_api/core/managers/wire.py +410 -0
  41. kicad_sch_api/core/nets.py +305 -0
  42. kicad_sch_api/core/no_connects.py +252 -0
  43. kicad_sch_api/core/parser.py +194 -970
  44. kicad_sch_api/core/parsing_utils.py +63 -0
  45. kicad_sch_api/core/pin_utils.py +103 -9
  46. kicad_sch_api/core/schematic.py +1328 -1079
  47. kicad_sch_api/core/texts.py +316 -0
  48. kicad_sch_api/core/types.py +159 -23
  49. kicad_sch_api/core/wires.py +27 -75
  50. kicad_sch_api/exporters/__init__.py +10 -0
  51. kicad_sch_api/exporters/python_generator.py +610 -0
  52. kicad_sch_api/exporters/templates/default.py.jinja2 +65 -0
  53. kicad_sch_api/geometry/__init__.py +38 -0
  54. kicad_sch_api/geometry/font_metrics.py +22 -0
  55. kicad_sch_api/geometry/routing.py +211 -0
  56. kicad_sch_api/geometry/symbol_bbox.py +608 -0
  57. kicad_sch_api/interfaces/__init__.py +17 -0
  58. kicad_sch_api/interfaces/parser.py +76 -0
  59. kicad_sch_api/interfaces/repository.py +70 -0
  60. kicad_sch_api/interfaces/resolver.py +117 -0
  61. kicad_sch_api/parsers/__init__.py +14 -0
  62. kicad_sch_api/parsers/base.py +145 -0
  63. kicad_sch_api/parsers/elements/__init__.py +22 -0
  64. kicad_sch_api/parsers/elements/graphics_parser.py +564 -0
  65. kicad_sch_api/parsers/elements/label_parser.py +216 -0
  66. kicad_sch_api/parsers/elements/library_parser.py +165 -0
  67. kicad_sch_api/parsers/elements/metadata_parser.py +58 -0
  68. kicad_sch_api/parsers/elements/sheet_parser.py +352 -0
  69. kicad_sch_api/parsers/elements/symbol_parser.py +485 -0
  70. kicad_sch_api/parsers/elements/text_parser.py +250 -0
  71. kicad_sch_api/parsers/elements/wire_parser.py +242 -0
  72. kicad_sch_api/parsers/registry.py +155 -0
  73. kicad_sch_api/parsers/utils.py +80 -0
  74. kicad_sch_api/symbols/__init__.py +18 -0
  75. kicad_sch_api/symbols/cache.py +467 -0
  76. kicad_sch_api/symbols/resolver.py +361 -0
  77. kicad_sch_api/symbols/validators.py +504 -0
  78. kicad_sch_api/utils/logging.py +555 -0
  79. kicad_sch_api/utils/logging_decorators.py +587 -0
  80. kicad_sch_api/utils/validation.py +16 -22
  81. kicad_sch_api/validation/__init__.py +25 -0
  82. kicad_sch_api/validation/erc.py +171 -0
  83. kicad_sch_api/validation/erc_models.py +203 -0
  84. kicad_sch_api/validation/pin_matrix.py +243 -0
  85. kicad_sch_api/validation/validators.py +391 -0
  86. kicad_sch_api/wrappers/__init__.py +14 -0
  87. kicad_sch_api/wrappers/base.py +89 -0
  88. kicad_sch_api/wrappers/wire.py +198 -0
  89. kicad_sch_api-0.5.1.dist-info/METADATA +540 -0
  90. kicad_sch_api-0.5.1.dist-info/RECORD +114 -0
  91. kicad_sch_api-0.5.1.dist-info/entry_points.txt +4 -0
  92. {kicad_sch_api-0.3.0.dist-info → kicad_sch_api-0.5.1.dist-info}/top_level.txt +1 -0
  93. mcp_server/__init__.py +34 -0
  94. mcp_server/example_logging_integration.py +506 -0
  95. mcp_server/models.py +252 -0
  96. mcp_server/server.py +357 -0
  97. mcp_server/tools/__init__.py +32 -0
  98. mcp_server/tools/component_tools.py +516 -0
  99. mcp_server/tools/connectivity_tools.py +532 -0
  100. mcp_server/tools/consolidated_tools.py +1216 -0
  101. mcp_server/tools/pin_discovery.py +333 -0
  102. mcp_server/utils/__init__.py +38 -0
  103. mcp_server/utils/logging.py +127 -0
  104. mcp_server/utils.py +36 -0
  105. kicad_sch_api/core/manhattan_routing.py +0 -430
  106. kicad_sch_api/core/simple_manhattan.py +0 -228
  107. kicad_sch_api/core/wire_routing.py +0 -380
  108. kicad_sch_api-0.3.0.dist-info/METADATA +0 -483
  109. kicad_sch_api-0.3.0.dist-info/RECORD +0 -31
  110. kicad_sch_api-0.3.0.dist-info/entry_points.txt +0 -2
  111. {kicad_sch_api-0.3.0.dist-info → kicad_sch_api-0.5.1.dist-info}/WHEEL +0 -0
  112. {kicad_sch_api-0.3.0.dist-info → kicad_sch_api-0.5.1.dist-info}/licenses/LICENSE +0 -0
@@ -7,7 +7,7 @@ migrated from circuit-synth for improved maintainability.
7
7
 
8
8
  import logging
9
9
  import math
10
- from typing import Optional, Tuple
10
+ from typing import Optional, Tuple, Union
11
11
 
12
12
  from .types import Point
13
13
 
@@ -71,20 +71,29 @@ def apply_transformation(
71
71
 
72
72
  Migrated from circuit-synth for accurate pin position calculation.
73
73
 
74
+ CRITICAL: Symbol coordinates use normal Y-axis (+Y is up), but schematic
75
+ coordinates use inverted Y-axis (+Y is down). We must negate Y from symbol
76
+ space before applying transformations.
77
+
74
78
  Args:
75
- point: Point to transform (x, y) relative to origin
76
- origin: Component origin point
79
+ point: Point to transform (x, y) relative to origin in SYMBOL space
80
+ origin: Component origin point in SCHEMATIC space
77
81
  rotation: Rotation in degrees (0, 90, 180, 270)
78
82
  mirror: Mirror axis ("x" or "y" or None)
79
83
 
80
84
  Returns:
81
- Transformed absolute position (x, y)
85
+ Transformed absolute position (x, y) in SCHEMATIC space
82
86
  """
83
87
  x, y = point
84
88
 
85
89
  logger.debug(f"Transforming point ({x}, {y}) with rotation={rotation}°, mirror={mirror}")
86
90
 
87
- # Apply mirroring first
91
+ # CRITICAL: Negate Y to convert from symbol space (normal Y) to schematic space (inverted Y)
92
+ # This must happen BEFORE rotation/mirroring
93
+ y = -y
94
+ logger.debug(f"After Y-axis inversion (symbol→schematic): ({x}, {y})")
95
+
96
+ # Apply mirroring
88
97
  if mirror == "x":
89
98
  x = -x
90
99
  logger.debug(f"After X mirror: ({x}, {y})")
@@ -109,3 +118,83 @@ def apply_transformation(
109
118
 
110
119
  logger.debug(f"Final absolute position: ({final_x}, {final_y})")
111
120
  return (final_x, final_y)
121
+
122
+
123
+ def calculate_position_for_pin(
124
+ pin_local_position: Union[Point, Tuple[float, float]],
125
+ desired_pin_position: Union[Point, Tuple[float, float]],
126
+ rotation: float = 0.0,
127
+ mirror: Optional[str] = None,
128
+ grid_size: float = 1.27,
129
+ ) -> Point:
130
+ """
131
+ Calculate component position needed to place a specific pin at a desired location.
132
+
133
+ This is the inverse of get_pin_position() - given where you want a pin to be,
134
+ it calculates where the component center needs to be placed.
135
+
136
+ Useful for aligning components by their pins rather than their centers, which
137
+ is essential for clean horizontal signal flows without unnecessary wire jogs.
138
+
139
+ Args:
140
+ pin_local_position: Pin position in symbol space (from symbol definition)
141
+ desired_pin_position: Where you want the pin to be in schematic space
142
+ rotation: Component rotation in degrees (0, 90, 180, 270)
143
+ mirror: Mirror axis ("x" or "y" or None) - currently unused
144
+ grid_size: Grid size for snapping result (default 1.27mm = 50mil)
145
+
146
+ Returns:
147
+ Component position that will place the pin at desired_pin_position
148
+
149
+ Example:
150
+ >>> # Place resistor so pin 2 is at (150, 100)
151
+ >>> pin_pos = Point(0, -3.81) # Pin 2 local position from symbol
152
+ >>> comp_pos = calculate_position_for_pin(pin_pos, (150, 100))
153
+ >>> # Now add component at comp_pos, and pin 2 will be at (150, 100)
154
+
155
+ Note:
156
+ The result is automatically snapped to the KiCAD grid for proper connectivity.
157
+ This function matches the behavior of SchematicSymbol.get_pin_position().
158
+ """
159
+ # Convert inputs to proper types
160
+ if isinstance(pin_local_position, Point):
161
+ pin_x, pin_y = pin_local_position.x, pin_local_position.y
162
+ else:
163
+ pin_x, pin_y = pin_local_position
164
+
165
+ if isinstance(desired_pin_position, Point):
166
+ target_x, target_y = desired_pin_position.x, desired_pin_position.y
167
+ else:
168
+ target_x, target_y = desired_pin_position
169
+
170
+ logger.debug(
171
+ f"Calculating component position for pin at local ({pin_x}, {pin_y}) "
172
+ f"to reach target ({target_x}, {target_y}) with rotation={rotation}°"
173
+ )
174
+
175
+ # Apply the same transformation that get_pin_position() uses
176
+ # This is a standard 2D rotation matrix (NO Y-axis inversion)
177
+ angle_rad = math.radians(rotation)
178
+ cos_a = math.cos(angle_rad)
179
+ sin_a = math.sin(angle_rad)
180
+
181
+ # Calculate rotated offset (same as get_pin_position)
182
+ rotated_x = pin_x * cos_a - pin_y * sin_a
183
+ rotated_y = pin_x * sin_a + pin_y * cos_a
184
+
185
+ logger.debug(f"Pin offset after rotation: ({rotated_x:.3f}, {rotated_y:.3f})")
186
+
187
+ # Calculate component origin
188
+ # Since: target = component + rotated_offset
189
+ # Therefore: component = target - rotated_offset
190
+ component_x = target_x - rotated_x
191
+ component_y = target_y - rotated_y
192
+
193
+ logger.debug(f"Calculated component position (before grid snap): ({component_x:.3f}, {component_y:.3f})")
194
+
195
+ # Snap to grid for proper KiCAD connectivity
196
+ snapped_x, snapped_y = snap_to_grid((component_x, component_y), grid_size=grid_size)
197
+
198
+ logger.debug(f"Final component position (after grid snap): ({snapped_x:.3f}, {snapped_y:.3f})")
199
+
200
+ return Point(snapped_x, snapped_y)
@@ -9,55 +9,34 @@ import logging
9
9
  import uuid as uuid_module
10
10
  from typing import Any, Dict, List, Optional, Tuple, Union
11
11
 
12
+ from .collections import BaseCollection
12
13
  from .types import Junction, Point
13
14
 
14
15
  logger = logging.getLogger(__name__)
15
16
 
16
17
 
17
- class JunctionCollection:
18
+ class JunctionCollection(BaseCollection[Junction]):
18
19
  """
19
20
  Professional junction collection with enhanced management features.
20
21
 
22
+ Inherits from BaseCollection for standard operations and adds junction-specific
23
+ functionality.
24
+
21
25
  Features:
22
- - Fast UUID-based lookup and indexing
26
+ - Fast UUID-based lookup and indexing (inherited)
23
27
  - Position-based junction queries
24
- - Bulk operations for performance
28
+ - Bulk operations for performance (inherited)
25
29
  - Validation and conflict detection
26
30
  """
27
31
 
28
- def __init__(self, junctions: Optional[List[Junction]] = None):
32
+ def __init__(self, junctions: Optional[List[Junction]] = None) -> None:
29
33
  """
30
34
  Initialize junction collection.
31
35
 
32
36
  Args:
33
37
  junctions: Initial list of junctions
34
38
  """
35
- self._junctions: List[Junction] = junctions or []
36
- self._uuid_index: Dict[str, int] = {}
37
- self._modified = False
38
-
39
- # Build UUID index
40
- self._rebuild_index()
41
-
42
- logger.debug(f"JunctionCollection initialized with {len(self._junctions)} junctions")
43
-
44
- def _rebuild_index(self):
45
- """Rebuild UUID index for fast lookups."""
46
- self._uuid_index = {junction.uuid: i for i, junction in enumerate(self._junctions)}
47
-
48
- def __len__(self) -> int:
49
- """Number of junctions in collection."""
50
- return len(self._junctions)
51
-
52
- def __iter__(self):
53
- """Iterate over junctions."""
54
- return iter(self._junctions)
55
-
56
- def __getitem__(self, uuid: str) -> Junction:
57
- """Get junction by UUID."""
58
- if uuid not in self._uuid_index:
59
- raise KeyError(f"Junction with UUID '{uuid}' not found")
60
- return self._junctions[self._uuid_index[uuid]]
39
+ super().__init__(junctions, collection_name="junctions")
61
40
 
62
41
  def add(
63
42
  self,
@@ -94,35 +73,12 @@ class JunctionCollection:
94
73
  # Create junction
95
74
  junction = Junction(uuid=uuid, position=position, diameter=diameter, color=color)
96
75
 
97
- # Add to collection
98
- self._junctions.append(junction)
99
- self._uuid_index[uuid] = len(self._junctions) - 1
100
- self._modified = True
76
+ # Add to collection using base class method
77
+ self._add_item(junction)
101
78
 
102
79
  logger.debug(f"Added junction at {position}, UUID={uuid}")
103
80
  return uuid
104
81
 
105
- def remove(self, uuid: str) -> bool:
106
- """
107
- Remove junction by UUID.
108
-
109
- Args:
110
- uuid: Junction UUID to remove
111
-
112
- Returns:
113
- True if junction was removed, False if not found
114
- """
115
- if uuid not in self._uuid_index:
116
- return False
117
-
118
- index = self._uuid_index[uuid]
119
- del self._junctions[index]
120
- self._rebuild_index()
121
- self._modified = True
122
-
123
- logger.debug(f"Removed junction: {uuid}")
124
- return True
125
-
126
82
  def get_at_position(
127
83
  self, position: Union[Point, Tuple[float, float]], tolerance: float = 0.01
128
84
  ) -> Optional[Junction]:
@@ -139,7 +95,7 @@ class JunctionCollection:
139
95
  if isinstance(position, tuple):
140
96
  position = Point(position[0], position[1])
141
97
 
142
- for junction in self._junctions:
98
+ for junction in self._items:
143
99
  if junction.position.distance_to(position) <= tolerance:
144
100
  return junction
145
101
 
@@ -162,40 +118,35 @@ class JunctionCollection:
162
118
  point = Point(point[0], point[1])
163
119
 
164
120
  matching_junctions = []
165
- for junction in self._junctions:
121
+ for junction in self._items:
166
122
  if junction.position.distance_to(point) <= tolerance:
167
123
  matching_junctions.append(junction)
168
124
 
169
125
  return matching_junctions
170
126
 
171
127
  def get_statistics(self) -> Dict[str, Any]:
172
- """Get junction collection statistics."""
173
- if not self._junctions:
174
- return {"total_junctions": 0, "avg_diameter": 0, "positions": []}
128
+ """Get junction collection statistics (extends base statistics)."""
129
+ base_stats = super().get_statistics()
130
+ if not self._items:
131
+ return {**base_stats, "total_junctions": 0, "avg_diameter": 0, "positions": []}
175
132
 
176
- avg_diameter = sum(j.diameter for j in self._junctions) / len(self._junctions)
177
- positions = [(j.position.x, j.position.y) for j in self._junctions]
133
+ avg_diameter = sum(j.diameter for j in self._items) / len(self._items)
134
+ positions = [(j.position.x, j.position.y) for j in self._items]
178
135
 
179
136
  return {
180
- "total_junctions": len(self._junctions),
137
+ **base_stats,
138
+ "total_junctions": len(self._items),
181
139
  "avg_diameter": avg_diameter,
182
140
  "positions": positions,
183
- "unique_diameters": len(set(j.diameter for j in self._junctions)),
184
- "unique_colors": len(set(j.color for j in self._junctions)),
141
+ "unique_diameters": len(set(j.diameter for j in self._items)),
142
+ "unique_colors": len(set(j.color for j in self._items)),
185
143
  }
186
144
 
187
- def clear(self):
188
- """Remove all junctions from collection."""
189
- self._junctions.clear()
190
- self._uuid_index.clear()
191
- self._modified = True
192
- logger.debug("Cleared all junctions")
193
-
194
145
  @property
195
146
  def modified(self) -> bool:
196
147
  """Check if collection has been modified."""
197
- return self._modified
148
+ return self.is_modified()
198
149
 
199
- def mark_saved(self):
150
+ def mark_saved(self) -> None:
200
151
  """Mark collection as saved (reset modified flag)."""
201
- self._modified = False
152
+ self.reset_modified_flag()
@@ -0,0 +1,324 @@
1
+ """
2
+ Label element management for KiCAD schematics.
3
+
4
+ This module provides collection classes for managing label elements,
5
+ featuring fast lookup, bulk operations, and validation.
6
+ """
7
+
8
+ import logging
9
+ import uuid
10
+ from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple, Union
11
+
12
+ from ..utils.validation import SchematicValidator, ValidationError, ValidationIssue
13
+ from .collections import BaseCollection
14
+ from .types import Label, Point
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class LabelElement:
20
+ """
21
+ Enhanced wrapper for schematic label elements with modern API.
22
+
23
+ Provides intuitive access to label properties and operations
24
+ while maintaining exact format preservation.
25
+ """
26
+
27
+ def __init__(self, label_data: Label, parent_collection: "LabelCollection"):
28
+ """
29
+ Initialize label element wrapper.
30
+
31
+ Args:
32
+ label_data: Underlying label data
33
+ parent_collection: Parent collection for updates
34
+ """
35
+ self._data = label_data
36
+ self._collection = parent_collection
37
+ self._validator = SchematicValidator()
38
+
39
+ # Core properties with validation
40
+ @property
41
+ def uuid(self) -> str:
42
+ """Label element UUID."""
43
+ return self._data.uuid
44
+
45
+ @property
46
+ def text(self) -> str:
47
+ """Label text (net name)."""
48
+ return self._data.text
49
+
50
+ @text.setter
51
+ def text(self, value: str):
52
+ """Set label text with validation."""
53
+ if not isinstance(value, str) or not value.strip():
54
+ raise ValidationError("Label text cannot be empty")
55
+ old_text = self._data.text
56
+ self._data.text = value.strip()
57
+ self._collection._update_text_index(old_text, self)
58
+ self._collection._mark_modified()
59
+
60
+ @property
61
+ def position(self) -> Point:
62
+ """Label position."""
63
+ return self._data.position
64
+
65
+ @position.setter
66
+ def position(self, value: Union[Point, Tuple[float, float]]):
67
+ """Set label position."""
68
+ if isinstance(value, tuple):
69
+ value = Point(value[0], value[1])
70
+ elif not isinstance(value, Point):
71
+ raise ValidationError(f"Position must be Point or tuple, got {type(value)}")
72
+ self._data.position = value
73
+ self._collection._mark_modified()
74
+
75
+ @property
76
+ def rotation(self) -> float:
77
+ """Label rotation in degrees."""
78
+ return self._data.rotation
79
+
80
+ @rotation.setter
81
+ def rotation(self, value: float):
82
+ """Set label rotation."""
83
+ self._data.rotation = float(value)
84
+ self._collection._mark_modified()
85
+
86
+ @property
87
+ def size(self) -> float:
88
+ """Label text size."""
89
+ return self._data.size
90
+
91
+ @size.setter
92
+ def size(self, value: float):
93
+ """Set label size with validation."""
94
+ if value <= 0:
95
+ raise ValidationError(f"Label size must be positive, got {value}")
96
+ self._data.size = float(value)
97
+ self._collection._mark_modified()
98
+
99
+ def validate(self) -> List[ValidationIssue]:
100
+ """Validate this label element."""
101
+ return self._validator.validate_label(self._data.__dict__)
102
+
103
+ def to_dict(self) -> Dict[str, Any]:
104
+ """Convert label element to dictionary representation."""
105
+ return {
106
+ "uuid": self.uuid,
107
+ "text": self.text,
108
+ "position": {"x": self.position.x, "y": self.position.y},
109
+ "rotation": self.rotation,
110
+ "size": self.size,
111
+ }
112
+
113
+ def __str__(self) -> str:
114
+ """String representation."""
115
+ return f"<Label '{self.text}' @ {self.position}>"
116
+
117
+
118
+ class LabelCollection(BaseCollection[LabelElement]):
119
+ """
120
+ Collection class for efficient label element management.
121
+
122
+ Inherits from BaseCollection for standard operations and adds label-specific
123
+ functionality including text-based indexing.
124
+
125
+ Provides fast lookup, filtering, and bulk operations for schematic label elements.
126
+ """
127
+
128
+ def __init__(self, labels: List[Label] = None):
129
+ """
130
+ Initialize label collection.
131
+
132
+ Args:
133
+ labels: Initial list of label data
134
+ """
135
+ # Initialize base collection
136
+ super().__init__([], collection_name="labels")
137
+
138
+ # Additional label-specific index
139
+ self._text_index: Dict[str, List[LabelElement]] = {}
140
+
141
+ # Add initial labels
142
+ if labels:
143
+ for label_data in labels:
144
+ self._add_to_indexes(LabelElement(label_data, self))
145
+
146
+ def add(
147
+ self,
148
+ text: str,
149
+ position: Union[Point, Tuple[float, float]],
150
+ rotation: float = 0.0,
151
+ size: float = 1.27,
152
+ label_uuid: Optional[str] = None,
153
+ ) -> LabelElement:
154
+ """
155
+ Add a new label element to the schematic.
156
+
157
+ Args:
158
+ text: Label text (net name)
159
+ position: Label position
160
+ rotation: Label rotation in degrees
161
+ size: Label text size
162
+ label_uuid: Specific UUID for label (auto-generated if None)
163
+
164
+ Returns:
165
+ Newly created LabelElement
166
+
167
+ Raises:
168
+ ValidationError: If label data is invalid
169
+ """
170
+ # Validate inputs
171
+ if not isinstance(text, str) or not text.strip():
172
+ raise ValidationError("Label text cannot be empty")
173
+
174
+ if isinstance(position, tuple):
175
+ position = Point(position[0], position[1])
176
+ elif not isinstance(position, Point):
177
+ raise ValidationError(f"Position must be Point or tuple, got {type(position)}")
178
+
179
+ if size <= 0:
180
+ raise ValidationError(f"Label size must be positive, got {size}")
181
+
182
+ # Generate UUID if not provided
183
+ if not label_uuid:
184
+ label_uuid = str(uuid.uuid4())
185
+
186
+ # Check for duplicate UUID
187
+ if label_uuid in self._uuid_index:
188
+ raise ValidationError(f"Label UUID {label_uuid} already exists")
189
+
190
+ # Create label data
191
+ label_data = Label(
192
+ uuid=label_uuid,
193
+ position=position,
194
+ text=text.strip(),
195
+ rotation=rotation,
196
+ size=size,
197
+ )
198
+
199
+ # Create wrapper and add to collection
200
+ label_element = LabelElement(label_data, self)
201
+ self._add_to_indexes(label_element)
202
+ self._mark_modified()
203
+
204
+ logger.debug(f"Added label: {label_element}")
205
+ return label_element
206
+
207
+ # get() method inherited from BaseCollection
208
+
209
+ def get_by_text(self, text: str) -> List[LabelElement]:
210
+ """Get all labels with the given text."""
211
+ return self._text_index.get(text, []).copy()
212
+
213
+ def remove(self, label_uuid: str) -> bool:
214
+ """
215
+ Remove label by UUID.
216
+
217
+ Args:
218
+ label_uuid: UUID of label to remove
219
+
220
+ Returns:
221
+ True if label was removed, False if not found
222
+ """
223
+ label_element = self.get(label_uuid)
224
+ if not label_element:
225
+ return False
226
+
227
+ # Remove from text index
228
+ text = label_element.text
229
+ if text in self._text_index:
230
+ self._text_index[text].remove(label_element)
231
+ if not self._text_index[text]:
232
+ del self._text_index[text]
233
+
234
+ # Remove using base class method
235
+ super().remove(label_uuid)
236
+
237
+ logger.debug(f"Removed label: {label_element}")
238
+ return True
239
+
240
+ def find_by_text(self, text: str, exact: bool = True) -> List[LabelElement]:
241
+ """
242
+ Find labels by text.
243
+
244
+ Args:
245
+ text: Text to search for
246
+ exact: If True, exact match; if False, substring match
247
+
248
+ Returns:
249
+ List of matching label elements
250
+ """
251
+ if exact:
252
+ return self._text_index.get(text, []).copy()
253
+ else:
254
+ matches = []
255
+ for label_element in self._items:
256
+ if text.lower() in label_element.text.lower():
257
+ matches.append(label_element)
258
+ return matches
259
+
260
+ def filter(self, predicate: Callable[[LabelElement], bool]) -> List[LabelElement]:
261
+ """
262
+ Filter labels by predicate function.
263
+
264
+ Args:
265
+ predicate: Function that returns True for labels to include
266
+
267
+ Returns:
268
+ List of labels matching predicate
269
+ """
270
+ return [label for label in self._items if predicate(label)]
271
+
272
+ def bulk_update(self, criteria: Callable[[LabelElement], bool], updates: Dict[str, Any]):
273
+ """
274
+ Update multiple labels matching criteria.
275
+
276
+ Args:
277
+ criteria: Function to select labels to update
278
+ updates: Dictionary of property updates
279
+ """
280
+ updated_count = 0
281
+ for label_element in self._items:
282
+ if criteria(label_element):
283
+ for prop, value in updates.items():
284
+ if hasattr(label_element, prop):
285
+ setattr(label_element, prop, value)
286
+ updated_count += 1
287
+
288
+ if updated_count > 0:
289
+ self._mark_modified()
290
+ logger.debug(f"Bulk updated {updated_count} label properties")
291
+
292
+ def clear(self):
293
+ """Remove all labels from collection."""
294
+ self._text_index.clear()
295
+ super().clear()
296
+
297
+ def _add_to_indexes(self, label_element: LabelElement):
298
+ """Add label to internal indexes (base + text index)."""
299
+ self._add_item(label_element)
300
+
301
+ # Add to text index
302
+ text = label_element.text
303
+ if text not in self._text_index:
304
+ self._text_index[text] = []
305
+ self._text_index[text].append(label_element)
306
+
307
+ def _update_text_index(self, old_text: str, label_element: LabelElement):
308
+ """Update text index when label text changes."""
309
+ # Remove from old text index
310
+ if old_text in self._text_index:
311
+ self._text_index[old_text].remove(label_element)
312
+ if not self._text_index[old_text]:
313
+ del self._text_index[old_text]
314
+
315
+ # Add to new text index
316
+ new_text = label_element.text
317
+ if new_text not in self._text_index:
318
+ self._text_index[new_text] = []
319
+ self._text_index[new_text].append(label_element)
320
+
321
+ # Collection interface methods - __len__, __iter__, __getitem__ inherited from BaseCollection
322
+ def __bool__(self) -> bool:
323
+ """Return True if collection has labels."""
324
+ return len(self._items) > 0
@@ -0,0 +1,30 @@
1
+ """
2
+ Schematic management modules for separating responsibilities.
3
+
4
+ This package contains specialized managers for different aspects of schematic
5
+ manipulation, enabling clean separation of concerns and better maintainability.
6
+ """
7
+
8
+ from .base import BaseManager
9
+ from .file_io import FileIOManager
10
+ from .format_sync import FormatSyncManager
11
+ from .graphics import GraphicsManager
12
+ from .hierarchy import HierarchyManager
13
+ from .metadata import MetadataManager
14
+ from .sheet import SheetManager
15
+ from .text_elements import TextElementManager
16
+ from .validation import ValidationManager
17
+ from .wire import WireManager
18
+
19
+ __all__ = [
20
+ "BaseManager",
21
+ "FileIOManager",
22
+ "FormatSyncManager",
23
+ "GraphicsManager",
24
+ "HierarchyManager",
25
+ "MetadataManager",
26
+ "SheetManager",
27
+ "TextElementManager",
28
+ "ValidationManager",
29
+ "WireManager",
30
+ ]