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,404 +1,508 @@
1
1
  """
2
- Label collection with specialized indexing and label-specific operations.
2
+ Enhanced label management with IndexRegistry integration.
3
3
 
4
- Extends the base IndexedCollection to provide label-specific features like
5
- text indexing, position-based queries, and label type classification.
4
+ Provides LabelElement wrapper and LabelCollection using BaseCollection
5
+ infrastructure with text indexing and position-based queries.
6
6
  """
7
7
 
8
8
  import logging
9
9
  import uuid as uuid_module
10
10
  from typing import Any, Dict, List, Optional, Tuple, Union
11
11
 
12
- from ..core.types import Point
13
- from .base import IndexedCollection
12
+ from ..core.types import Label, Point
13
+ from ..utils.validation import SchematicValidator, ValidationError, ValidationIssue
14
+ from .base import BaseCollection, IndexSpec, ValidationLevel
14
15
 
15
16
  logger = logging.getLogger(__name__)
16
17
 
17
18
 
18
- class Label:
19
- """Label data structure."""
19
+ class LabelElement:
20
+ """
21
+ Enhanced wrapper for schematic label elements.
20
22
 
21
- def __init__(
22
- self,
23
- uuid: str,
24
- text: str,
25
- position: Point,
26
- rotation: float = 0.0,
27
- label_type: str = "label",
28
- effects: Optional[Dict[str, Any]] = None,
29
- ):
30
- self.uuid = uuid
31
- self.text = text
32
- self.position = position
33
- self.rotation = rotation
34
- self.label_type = label_type # "label", "global_label", "hierarchical_label"
35
- self.effects = effects or {}
23
+ Provides intuitive access to label properties and operations
24
+ while maintaining exact format preservation. All property
25
+ modifications automatically notify the parent collection.
26
+ """
27
+
28
+ def __init__(self, label_data: Label, parent_collection: "LabelCollection"):
29
+ """
30
+ Initialize label element wrapper.
31
+
32
+ Args:
33
+ label_data: Underlying label data
34
+ parent_collection: Parent collection for modification tracking
35
+ """
36
+ self._data = label_data
37
+ self._collection = parent_collection
38
+ self._validator = SchematicValidator()
39
+
40
+ # Core properties with validation
41
+ @property
42
+ def uuid(self) -> str:
43
+ """Label element UUID (read-only)."""
44
+ return self._data.uuid
45
+
46
+ @property
47
+ def text(self) -> str:
48
+ """Label text (net name)."""
49
+ return self._data.text
50
+
51
+ @text.setter
52
+ def text(self, value: str):
53
+ """
54
+ Set label text with validation.
55
+
56
+ Args:
57
+ value: New label text
58
+
59
+ Raises:
60
+ ValidationError: If text is empty
61
+ """
62
+ if not isinstance(value, str) or not value.strip():
63
+ raise ValidationError("Label text cannot be empty")
64
+
65
+ old_text = self._data.text
66
+ self._data.text = value.strip()
67
+ self._collection._update_text_index(old_text, self)
68
+ self._collection._mark_modified()
69
+ logger.debug(f"Updated label text: '{old_text}' -> '{value}'")
70
+
71
+ @property
72
+ def position(self) -> Point:
73
+ """Label position in schematic."""
74
+ return self._data.position
75
+
76
+ @position.setter
77
+ def position(self, value: Union[Point, Tuple[float, float]]):
78
+ """Set label position."""
79
+ if isinstance(value, tuple):
80
+ value = Point(value[0], value[1])
81
+ elif not isinstance(value, Point):
82
+ raise ValidationError(f"Position must be Point or tuple, got {type(value)}")
83
+
84
+ self._data.position = value
85
+ self._collection._mark_modified()
86
+
87
+ @property
88
+ def rotation(self) -> float:
89
+ """Label rotation in degrees."""
90
+ return self._data.rotation
91
+
92
+ @rotation.setter
93
+ def rotation(self, value: float):
94
+ """Set label rotation."""
95
+ self._data.rotation = float(value)
96
+ self._collection._mark_modified()
97
+
98
+ @property
99
+ def size(self) -> float:
100
+ """Label text size."""
101
+ return self._data.size
102
+
103
+ @size.setter
104
+ def size(self, value: float):
105
+ """
106
+ Set label size with validation.
107
+
108
+ Args:
109
+ value: New text size
110
+
111
+ Raises:
112
+ ValidationError: If size is not positive
113
+ """
114
+ if value <= 0:
115
+ raise ValidationError(f"Label size must be positive, got {value}")
116
+
117
+ self._data.size = float(value)
118
+ self._collection._mark_modified()
119
+
120
+ # Utility methods
121
+ def move(self, x: float, y: float):
122
+ """Move label to absolute position."""
123
+ self.position = Point(x, y)
124
+
125
+ def translate(self, dx: float, dy: float):
126
+ """Translate label by offset."""
127
+ current = self.position
128
+ self.position = Point(current.x + dx, current.y + dy)
129
+
130
+ def rotate_by(self, angle: float):
131
+ """Rotate label by angle (cumulative)."""
132
+ self.rotation = (self.rotation + angle) % 360
133
+
134
+ def validate(self) -> List[ValidationIssue]:
135
+ """
136
+ Validate this label element.
137
+
138
+ Returns:
139
+ List of validation issues (empty if valid)
140
+ """
141
+ issues = []
142
+
143
+ # Validate text is not empty
144
+ if not self.text or not self.text.strip():
145
+ issues.append(
146
+ ValidationIssue(
147
+ category="label",
148
+ message="Label text is empty",
149
+ level="error"
150
+ )
151
+ )
152
+
153
+ # Validate size is positive
154
+ if self.size <= 0:
155
+ issues.append(
156
+ ValidationIssue(
157
+ category="label",
158
+ message=f"Label size must be positive, got {self.size}",
159
+ level="error"
160
+ )
161
+ )
162
+
163
+ return issues
164
+
165
+ def to_dict(self) -> Dict[str, Any]:
166
+ """
167
+ Convert label to dictionary representation.
168
+
169
+ Returns:
170
+ Dictionary with label data
171
+ """
172
+ return {
173
+ "text": self.text,
174
+ "position": {"x": self.position.x, "y": self.position.y},
175
+ "rotation": self.rotation,
176
+ "size": self.size,
177
+ }
178
+
179
+ def __str__(self) -> str:
180
+ """String representation for display."""
181
+ return f"<Label '{self.text}' @ {self.position}>"
36
182
 
37
183
  def __repr__(self) -> str:
38
- return f"Label(text='{self.text}', pos={self.position}, type='{self.label_type}')"
184
+ """Detailed representation for debugging."""
185
+ return f"LabelElement(text='{self.text}', pos={self.position}, rotation={self.rotation})"
39
186
 
40
187
 
41
- class LabelCollection(IndexedCollection[Label]):
188
+ class LabelCollection(BaseCollection[LabelElement]):
42
189
  """
43
- Collection class for efficient label management.
44
-
45
- Extends IndexedCollection with label-specific features:
46
- - Text indexing for finding labels by text content
47
- - Position indexing for spatial queries
48
- - Type classification (local, global, hierarchical)
49
- - Net name management
50
- - Connectivity analysis support
190
+ Label collection with text indexing and position queries.
191
+
192
+ Inherits from BaseCollection for UUID indexing and adds label-specific
193
+ functionality including text-based searches and filtering.
194
+
195
+ Features:
196
+ - Fast UUID lookup via IndexRegistry
197
+ - Text-based label indexing
198
+ - Position-based queries
199
+ - Lazy index rebuilding
200
+ - Batch mode support
51
201
  """
52
202
 
53
- def __init__(self, labels: Optional[List[Label]] = None):
203
+ def __init__(
204
+ self,
205
+ labels: Optional[List[Label]] = None,
206
+ validation_level: ValidationLevel = ValidationLevel.NORMAL,
207
+ ):
54
208
  """
55
209
  Initialize label collection.
56
210
 
57
211
  Args:
58
- labels: Initial list of labels
212
+ labels: Initial list of label data
213
+ validation_level: Validation level for operations
59
214
  """
60
- self._text_index: Dict[str, List[Label]] = {}
61
- self._position_index: Dict[Tuple[float, float], List[Label]] = {}
62
- self._type_index: Dict[str, List[Label]] = {}
215
+ super().__init__(validation_level=validation_level)
63
216
 
64
- super().__init__(labels)
217
+ # Manual text index (non-unique - multiple labels can have same text)
218
+ self._text_index: Dict[str, List[LabelElement]] = {}
65
219
 
66
- # Abstract method implementations
67
- def _get_item_uuid(self, item: Label) -> str:
68
- """Extract UUID from label."""
69
- return item.uuid
220
+ # Add initial labels
221
+ if labels:
222
+ with self.batch_mode():
223
+ for label_data in labels:
224
+ label_element = LabelElement(label_data, self)
225
+ super().add(label_element)
226
+ self._add_to_text_index(label_element)
70
227
 
71
- def _create_item(self, **kwargs) -> Label:
72
- """Create a new label with given parameters."""
73
- # This will be called by add() methods
74
- raise NotImplementedError("Use add() method instead")
228
+ logger.debug(f"LabelCollection initialized with {len(self)} labels")
75
229
 
76
- def _build_additional_indexes(self) -> None:
77
- """Build label-specific indexes."""
78
- # Clear existing indexes
79
- self._text_index.clear()
80
- self._position_index.clear()
81
- self._type_index.clear()
230
+ # BaseCollection abstract method implementations
231
+ def _get_item_uuid(self, item: LabelElement) -> str:
232
+ """Extract UUID from label element."""
233
+ return item.uuid
82
234
 
83
- # Rebuild indexes from current items
84
- for label in self._items:
85
- # Text index
86
- text_key = label.text.lower() # Case-insensitive
87
- if text_key not in self._text_index:
88
- self._text_index[text_key] = []
89
- self._text_index[text_key].append(label)
90
-
91
- # Position index
92
- pos_key = (label.position.x, label.position.y)
93
- if pos_key not in self._position_index:
94
- self._position_index[pos_key] = []
95
- self._position_index[pos_key].append(label)
96
-
97
- # Type index
98
- if label.label_type not in self._type_index:
99
- self._type_index[label.label_type] = []
100
- self._type_index[label.label_type].append(label)
101
-
102
- # Label-specific methods
235
+ def _create_item(self, **kwargs) -> LabelElement:
236
+ """Create a new label (not typically used directly)."""
237
+ raise NotImplementedError("Use add() method to create labels")
238
+
239
+ def _get_index_specs(self) -> List[IndexSpec]:
240
+ """Get index specifications for label collection."""
241
+ return [
242
+ IndexSpec(
243
+ name="uuid",
244
+ key_func=lambda l: l.uuid,
245
+ unique=True,
246
+ description="UUID index for fast lookups",
247
+ ),
248
+ ]
249
+
250
+ # Label-specific add method
103
251
  def add(
104
252
  self,
105
253
  text: str,
106
254
  position: Union[Point, Tuple[float, float]],
107
- label_type: str = "label",
108
255
  rotation: float = 0.0,
109
- effects: Optional[Dict[str, Any]] = None,
110
- label_uuid: Optional[str] = None,
111
- ) -> Label:
256
+ size: float = 1.27,
257
+ justify_h: str = "left",
258
+ justify_v: str = "bottom",
259
+ uuid: Optional[str] = None,
260
+ ) -> LabelElement:
112
261
  """
113
- Add a new label to the collection.
262
+ Add a label to the collection.
114
263
 
115
264
  Args:
116
- text: Label text content
265
+ text: Label text (net name)
117
266
  position: Label position
118
- label_type: Type of label ("label", "global_label", "hierarchical_label")
119
267
  rotation: Label rotation in degrees
120
- effects: Label effects (font, size, etc.)
121
- label_uuid: Specific UUID for label (auto-generated if None)
268
+ size: Text size
269
+ justify_h: Horizontal justification ("left", "right", "center")
270
+ justify_v: Vertical justification ("top", "bottom", "center")
271
+ uuid: Optional UUID (auto-generated if not provided)
122
272
 
123
273
  Returns:
124
- Newly created Label
274
+ LabelElement wrapper for the created label
125
275
 
126
276
  Raises:
127
- ValueError: If invalid parameters provided
277
+ ValueError: If UUID already exists or text is empty
128
278
  """
129
279
  # Validate text
130
- if not text.strip():
280
+ if not text or not text.strip():
131
281
  raise ValueError("Label text cannot be empty")
132
282
 
133
- # Convert tuple to Point if needed
283
+ # Generate UUID if not provided
284
+ if uuid is None:
285
+ uuid = str(uuid_module.uuid4())
286
+ else:
287
+ # Check for duplicate
288
+ self._ensure_indexes_current()
289
+ if self._index_registry.has_key("uuid", uuid):
290
+ raise ValueError(f"Label with UUID '{uuid}' already exists")
291
+
292
+ # Convert position
134
293
  if isinstance(position, tuple):
135
294
  position = Point(position[0], position[1])
136
295
 
137
- # Validate label type
138
- valid_types = ["label", "global_label", "hierarchical_label"]
139
- if label_type not in valid_types:
140
- raise ValueError(f"Invalid label type: {label_type}. Must be one of {valid_types}")
141
-
142
- # Generate UUID if not provided
143
- if label_uuid is None:
144
- label_uuid = str(uuid_module.uuid4())
145
-
146
- # Create label
147
- label = Label(
148
- uuid=label_uuid,
149
- text=text,
296
+ # Create label data
297
+ label_data = Label(
298
+ uuid=uuid,
299
+ text=text.strip(),
150
300
  position=position,
151
301
  rotation=rotation,
152
- label_type=label_type,
153
- effects=effects or {},
302
+ size=size,
303
+ justify_h=justify_h,
304
+ justify_v=justify_v,
154
305
  )
155
306
 
156
- # Add to collection using base class method
157
- return super().add(label)
158
-
159
- def get_labels_by_text(self, text: str, case_sensitive: bool = False) -> List[Label]:
160
- """
161
- Get all labels with specific text.
307
+ # Create label element wrapper
308
+ label_element = LabelElement(label_data, self)
162
309
 
163
- Args:
164
- text: Text to search for
165
- case_sensitive: Whether search should be case sensitive
310
+ # Add to collection
311
+ super().add(label_element)
166
312
 
167
- Returns:
168
- List of matching labels
169
- """
170
- self._ensure_indexes_current()
313
+ # Add to text index
314
+ self._add_to_text_index(label_element)
171
315
 
172
- if case_sensitive:
173
- return [label for label in self._items if label.text == text]
174
- else:
175
- text_key = text.lower()
176
- return self._text_index.get(text_key, []).copy()
316
+ logger.debug(f"Added label '{text}' at {position}, UUID={uuid}")
317
+ return label_element
177
318
 
178
- def get_labels_at_position(
179
- self, position: Union[Point, Tuple[float, float]], tolerance: float = 0.0
180
- ) -> List[Label]:
319
+ # Remove operation (override to update text index)
320
+ def remove(self, uuid: str) -> bool:
181
321
  """
182
- Get all labels at a specific position.
322
+ Remove label by UUID.
183
323
 
184
324
  Args:
185
- position: Position to search at
186
- tolerance: Position tolerance for matching
325
+ uuid: Label UUID to remove
187
326
 
188
327
  Returns:
189
- List of labels at the position
190
- """
191
- self._ensure_indexes_current()
192
-
193
- if isinstance(position, Point):
194
- pos_key = (position.x, position.y)
195
- else:
196
- pos_key = position
197
-
198
- if tolerance == 0.0:
199
- # Exact match
200
- return self._position_index.get(pos_key, []).copy()
201
- else:
202
- # Tolerance-based search
203
- matching_labels = []
204
- target_x, target_y = pos_key
205
-
206
- for label in self._items:
207
- dx = abs(label.position.x - target_x)
208
- dy = abs(label.position.y - target_y)
209
- distance = (dx**2 + dy**2) ** 0.5
210
-
211
- if distance <= tolerance:
212
- matching_labels.append(label)
213
-
214
- return matching_labels
215
-
216
- def get_labels_by_type(self, label_type: str) -> List[Label]:
328
+ True if label was removed, False if not found
217
329
  """
218
- Get all labels of a specific type.
330
+ # Get label before removing
331
+ label = self.get(uuid)
332
+ if not label:
333
+ return False
219
334
 
220
- Args:
221
- label_type: Type of labels to find
335
+ # Remove from text index
336
+ self._remove_from_text_index(label)
222
337
 
223
- Returns:
224
- List of labels of the specified type
225
- """
226
- self._ensure_indexes_current()
227
- return self._type_index.get(label_type, []).copy()
338
+ # Remove from base collection
339
+ result = super().remove(uuid)
228
340
 
229
- def get_net_names(self) -> List[str]:
230
- """
231
- Get all unique net names from labels.
341
+ if result:
342
+ logger.info(f"Removed label '{label.text}'")
232
343
 
233
- Returns:
234
- List of unique net names
235
- """
236
- return list(set(label.text for label in self._items))
344
+ return result
237
345
 
238
- def get_labels_for_net(self, net_name: str, case_sensitive: bool = False) -> List[Label]:
346
+ # Text-based queries
347
+ def get_by_text(self, text: str) -> List[LabelElement]:
239
348
  """
240
- Get all labels for a specific net.
349
+ Find all labels with specific text.
241
350
 
242
351
  Args:
243
- net_name: Net name to search for
244
- case_sensitive: Whether search should be case sensitive
352
+ text: Text to search for
245
353
 
246
354
  Returns:
247
- List of labels for the net
355
+ List of labels with matching text
248
356
  """
249
- return self.get_labels_by_text(net_name, case_sensitive)
357
+ return self._text_index.get(text, [])
250
358
 
251
- def find_labels_in_region(
252
- self, min_x: float, min_y: float, max_x: float, max_y: float
253
- ) -> List[Label]:
359
+ def filter_by_text_pattern(self, pattern: str) -> List[LabelElement]:
254
360
  """
255
- Find all labels within a rectangular region.
361
+ Find labels with text containing a pattern.
256
362
 
257
363
  Args:
258
- min_x: Minimum X coordinate
259
- min_y: Minimum Y coordinate
260
- max_x: Maximum X coordinate
261
- max_y: Maximum Y coordinate
364
+ pattern: Text pattern to search for (case-insensitive)
262
365
 
263
366
  Returns:
264
- List of labels in the region
367
+ List of labels with matching text
265
368
  """
266
- matching_labels = []
369
+ pattern_lower = pattern.lower()
370
+ matching = []
267
371
 
268
372
  for label in self._items:
269
- if min_x <= label.position.x <= max_x and min_y <= label.position.y <= max_y:
270
- matching_labels.append(label)
271
-
272
- return matching_labels
273
-
274
- def update_label_text(self, label_uuid: str, new_text: str) -> bool:
275
- """
276
- Update the text of an existing label.
277
-
278
- Args:
279
- label_uuid: UUID of label to update
280
- new_text: New text content
281
-
282
- Returns:
283
- True if label was updated, False if not found
284
-
285
- Raises:
286
- ValueError: If new text is empty
287
- """
288
- if not new_text.strip():
289
- raise ValueError("Label text cannot be empty")
373
+ if pattern_lower in label.text.lower():
374
+ matching.append(label)
290
375
 
291
- label = self.get(label_uuid)
292
- if not label:
293
- return False
294
-
295
- # Update text
296
- label.text = new_text
297
- self._mark_modified()
298
- self._mark_indexes_dirty()
299
-
300
- logger.debug(f"Updated label {label_uuid} text to '{new_text}'")
301
- return True
376
+ return matching
302
377
 
303
- def update_label_position(
304
- self, label_uuid: str, new_position: Union[Point, Tuple[float, float]]
305
- ) -> bool:
378
+ # Position-based queries
379
+ def get_at_position(
380
+ self, position: Union[Point, Tuple[float, float]], tolerance: float = 0.01
381
+ ) -> Optional[LabelElement]:
306
382
  """
307
- Update the position of an existing label.
383
+ Find label at or near a specific position.
308
384
 
309
385
  Args:
310
- label_uuid: UUID of label to update
311
- new_position: New position
386
+ position: Position to search
387
+ tolerance: Distance tolerance for matching
312
388
 
313
389
  Returns:
314
- True if label was updated, False if not found
390
+ Label if found, None otherwise
315
391
  """
316
- label = self.get(label_uuid)
317
- if not label:
318
- return False
319
-
320
- # Convert tuple to Point if needed
321
- if isinstance(new_position, tuple):
322
- new_position = Point(new_position[0], new_position[1])
392
+ if isinstance(position, tuple):
393
+ position = Point(position[0], position[1])
323
394
 
324
- # Update position
325
- label.position = new_position
326
- self._mark_modified()
327
- self._mark_indexes_dirty()
395
+ for label in self._items:
396
+ if label.position.distance_to(position) <= tolerance:
397
+ return label
328
398
 
329
- logger.debug(f"Updated label {label_uuid} position to {new_position}")
330
- return True
399
+ return None
331
400
 
332
- # Bulk operations
333
- def rename_net(self, old_name: str, new_name: str, case_sensitive: bool = False) -> int:
401
+ def get_near_point(
402
+ self, point: Union[Point, Tuple[float, float]], radius: float
403
+ ) -> List[LabelElement]:
334
404
  """
335
- Rename all labels for a net.
405
+ Find all labels within radius of a point.
336
406
 
337
407
  Args:
338
- old_name: Current net name
339
- new_name: New net name
340
- case_sensitive: Whether search should be case sensitive
408
+ point: Center point
409
+ radius: Search radius
341
410
 
342
411
  Returns:
343
- Number of labels renamed
344
-
345
- Raises:
346
- ValueError: If new name is empty
412
+ List of labels within radius
347
413
  """
348
- if not new_name.strip():
349
- raise ValueError("New net name cannot be empty")
350
-
351
- labels_to_rename = self.get_labels_by_text(old_name, case_sensitive)
352
-
353
- for label in labels_to_rename:
354
- label.text = new_name
414
+ if isinstance(point, tuple):
415
+ point = Point(point[0], point[1])
355
416
 
356
- if labels_to_rename:
357
- self._mark_modified()
358
- self._mark_indexes_dirty()
417
+ matching = []
418
+ for label in self._items:
419
+ if label.position.distance_to(point) <= radius:
420
+ matching.append(label)
359
421
 
360
- logger.info(f"Renamed {len(labels_to_rename)} labels from '{old_name}' to '{new_name}'")
361
- return len(labels_to_rename)
422
+ return matching
362
423
 
363
- def remove_labels_for_net(self, net_name: str, case_sensitive: bool = False) -> int:
424
+ # Validation
425
+ def validate_all(self) -> List[ValidationIssue]:
364
426
  """
365
- Remove all labels for a specific net.
366
-
367
- Args:
368
- net_name: Net name to remove labels for
369
- case_sensitive: Whether search should be case sensitive
427
+ Validate all labels in collection.
370
428
 
371
429
  Returns:
372
- Number of labels removed
430
+ List of validation issues found
373
431
  """
374
- labels_to_remove = self.get_labels_by_text(net_name, case_sensitive)
432
+ all_issues = []
375
433
 
376
- for label in labels_to_remove:
377
- self.remove(label.uuid)
434
+ for label in self._items:
435
+ issues = label.validate()
436
+ all_issues.extend(issues)
378
437
 
379
- logger.info(f"Removed {len(labels_to_remove)} labels for net '{net_name}'")
380
- return len(labels_to_remove)
438
+ return all_issues
381
439
 
382
- # Collection statistics
383
- def get_label_statistics(self) -> Dict[str, Any]:
440
+ # Statistics
441
+ def get_statistics(self) -> Dict[str, Any]:
384
442
  """
385
- Get label statistics for the collection.
443
+ Get label collection statistics.
386
444
 
387
445
  Returns:
388
446
  Dictionary with label statistics
389
447
  """
390
- stats = super().get_statistics()
391
-
392
- # Add label-specific statistics
393
- stats.update(
394
- {
395
- "unique_texts": len(self._text_index),
396
- "unique_positions": len(self._position_index),
397
- "label_types": {
398
- label_type: len(labels) for label_type, labels in self._type_index.items()
399
- },
400
- "net_count": len(self.get_net_names()),
401
- }
402
- )
403
-
404
- return stats
448
+ if not self._items:
449
+ base_stats = super().get_statistics()
450
+ base_stats.update({
451
+ "total_labels": 0,
452
+ "unique_texts": 0,
453
+ "avg_size": 0,
454
+ })
455
+ return base_stats
456
+
457
+ unique_texts = len(self._text_index)
458
+ avg_size = sum(l.size for l in self._items) / len(self._items)
459
+
460
+ base_stats = super().get_statistics()
461
+ base_stats.update({
462
+ "total_labels": len(self._items),
463
+ "unique_texts": unique_texts,
464
+ "avg_size": avg_size,
465
+ "text_distribution": {text: len(labels) for text, labels in self._text_index.items()},
466
+ })
467
+
468
+ return base_stats
469
+
470
+ # Internal helper methods
471
+ def _add_to_text_index(self, label: LabelElement):
472
+ """Add label to text index."""
473
+ text = label.text
474
+ if text not in self._text_index:
475
+ self._text_index[text] = []
476
+ self._text_index[text].append(label)
477
+
478
+ def _remove_from_text_index(self, label: LabelElement):
479
+ """Remove label from text index."""
480
+ text = label.text
481
+ if text in self._text_index:
482
+ self._text_index[text].remove(label)
483
+ if not self._text_index[text]:
484
+ del self._text_index[text]
485
+
486
+ def _update_text_index(self, old_text: str, label: LabelElement):
487
+ """Update text index when label text changes."""
488
+ # Remove from old text
489
+ if old_text in self._text_index:
490
+ self._text_index[old_text].remove(label)
491
+ if not self._text_index[old_text]:
492
+ del self._text_index[old_text]
493
+
494
+ # Add to new text
495
+ new_text = label.text
496
+ if new_text not in self._text_index:
497
+ self._text_index[new_text] = []
498
+ self._text_index[new_text].append(label)
499
+
500
+ # Compatibility methods
501
+ @property
502
+ def modified(self) -> bool:
503
+ """Check if collection has been modified (compatibility)."""
504
+ return self.is_modified
505
+
506
+ def mark_saved(self) -> None:
507
+ """Mark collection as saved (reset modified flag)."""
508
+ self.mark_clean()