Source code for kicad_sch_api.collections.labels

"""
Enhanced label management with IndexRegistry integration.

Provides LabelElement wrapper and LabelCollection using BaseCollection
infrastructure with text indexing and position-based queries.
"""

import logging
import uuid as uuid_module
from typing import Any, Dict, List, Optional, Tuple, Union

from ..core.types import Label, Point
from ..utils.validation import SchematicValidator, ValidationError, ValidationIssue
from .base import BaseCollection, IndexSpec, ValidationLevel

logger = logging.getLogger(__name__)


[docs] class LabelElement: """ Enhanced wrapper for schematic label elements. Provides intuitive access to label properties and operations while maintaining exact format preservation. All property modifications automatically notify the parent collection. """
[docs] def __init__(self, label_data: Label, parent_collection: "LabelCollection"): """ Initialize label element wrapper. Args: label_data: Underlying label data parent_collection: Parent collection for modification tracking """ self._data = label_data self._collection = parent_collection self._validator = SchematicValidator()
# Core properties with validation @property def uuid(self) -> str: """Label element UUID (read-only).""" return self._data.uuid @property def text(self) -> str: """Label text (net name).""" return self._data.text @text.setter def text(self, value: str): """ Set label text with validation. Args: value: New label text Raises: ValidationError: If text is empty """ if not isinstance(value, str) or not value.strip(): raise ValidationError("Label text cannot be empty") old_text = self._data.text self._data.text = value.strip() self._collection._update_text_index(old_text, self) self._collection._mark_modified() logger.debug(f"Updated label text: '{old_text}' -> '{value}'") @property def position(self) -> Point: """Label position in schematic.""" return self._data.position @position.setter def position(self, value: Union[Point, Tuple[float, float]]): """Set label position.""" if isinstance(value, tuple): value = Point(value[0], value[1]) elif not isinstance(value, Point): raise ValidationError(f"Position must be Point or tuple, got {type(value)}") self._data.position = value self._collection._mark_modified() @property def rotation(self) -> float: """Label rotation in degrees.""" return self._data.rotation @rotation.setter def rotation(self, value: float): """Set label rotation.""" self._data.rotation = float(value) self._collection._mark_modified() @property def size(self) -> float: """Label text size.""" return self._data.size @size.setter def size(self, value: float): """ Set label size with validation. Args: value: New text size Raises: ValidationError: If size is not positive """ if value <= 0: raise ValidationError(f"Label size must be positive, got {value}") self._data.size = float(value) self._collection._mark_modified() # Utility methods
[docs] def move(self, x: float, y: float): """Move label to absolute position.""" self.position = Point(x, y)
[docs] def translate(self, dx: float, dy: float): """Translate label by offset.""" current = self.position self.position = Point(current.x + dx, current.y + dy)
[docs] def rotate_by(self, angle: float): """Rotate label by angle (cumulative).""" self.rotation = (self.rotation + angle) % 360
[docs] def validate(self) -> List[ValidationIssue]: """ Validate this label element. Returns: List of validation issues (empty if valid) """ issues = [] # Validate text is not empty if not self.text or not self.text.strip(): issues.append( ValidationIssue(category="label", message="Label text is empty", level="error") ) # Validate size is positive if self.size <= 0: issues.append( ValidationIssue( category="label", message=f"Label size must be positive, got {self.size}", level="error", ) ) return issues
[docs] def to_dict(self) -> Dict[str, Any]: """ Convert label to dictionary representation. Returns: Dictionary with label data """ return { "text": self.text, "position": {"x": self.position.x, "y": self.position.y}, "rotation": self.rotation, "size": self.size, }
[docs] def __str__(self) -> str: """String representation for display.""" return f"<Label '{self.text}' @ {self.position}>"
[docs] def __repr__(self) -> str: """Detailed representation for debugging.""" return f"LabelElement(text='{self.text}', pos={self.position}, rotation={self.rotation})"
[docs] class LabelCollection(BaseCollection[LabelElement]): """ Label collection with text indexing and position queries. Inherits from BaseCollection for UUID indexing and adds label-specific functionality including text-based searches and filtering. Features: - Fast UUID lookup via IndexRegistry - Text-based label indexing - Position-based queries - Lazy index rebuilding - Batch mode support """
[docs] def __init__( self, labels: Optional[List[Label]] = None, validation_level: ValidationLevel = ValidationLevel.NORMAL, ): """ Initialize label collection. Args: labels: Initial list of label data validation_level: Validation level for operations """ super().__init__(validation_level=validation_level) # Manual text index (non-unique - multiple labels can have same text) self._text_index: Dict[str, List[LabelElement]] = {} # Add initial labels if labels: with self.batch_mode(): for label_data in labels: label_element = LabelElement(label_data, self) super().add(label_element) self._add_to_text_index(label_element) logger.debug(f"LabelCollection initialized with {len(self)} labels")
# BaseCollection abstract method implementations def _get_item_uuid(self, item: LabelElement) -> str: """Extract UUID from label element.""" return item.uuid def _create_item(self, **kwargs) -> LabelElement: """Create a new label (not typically used directly).""" raise NotImplementedError("Use add() method to create labels") def _get_index_specs(self) -> List[IndexSpec]: """Get index specifications for label collection.""" return [ IndexSpec( name="uuid", key_func=lambda l: l.uuid, unique=True, description="UUID index for fast lookups", ), ] # Label-specific add method
[docs] def add( self, text: str, position: Union[Point, Tuple[float, float]], rotation: float = 0.0, size: float = 1.27, justify_h: str = "left", justify_v: str = "bottom", uuid: Optional[str] = None, ) -> LabelElement: """ Add a label to the collection. Args: text: Label text (net name) position: Label position rotation: Label rotation in degrees size: Text size justify_h: Horizontal justification ("left", "right", "center") justify_v: Vertical justification ("top", "bottom", "center") uuid: Optional UUID (auto-generated if not provided) Returns: LabelElement wrapper for the created label Raises: ValueError: If UUID already exists or text is empty """ # Validate text if not text or not text.strip(): raise ValueError("Label text cannot be empty") # Generate UUID if not provided if uuid is None: uuid = str(uuid_module.uuid4()) else: # Check for duplicate self._ensure_indexes_current() if self._index_registry.has_key("uuid", uuid): raise ValueError(f"Label with UUID '{uuid}' already exists") # Convert position if isinstance(position, tuple): position = Point(position[0], position[1]) # Create label data label_data = Label( uuid=uuid, text=text.strip(), position=position, rotation=rotation, size=size, justify_h=justify_h, justify_v=justify_v, ) # Create label element wrapper label_element = LabelElement(label_data, self) # Add to collection super().add(label_element) # Add to text index self._add_to_text_index(label_element) logger.debug(f"Added label '{text}' at {position}, UUID={uuid}") return label_element
# Remove operation (override to update text index)
[docs] def remove(self, uuid: str) -> bool: """ Remove label by UUID. Args: uuid: Label UUID to remove Returns: True if label was removed, False if not found """ # Get label before removing label = self.get(uuid) if not label: return False # Remove from text index self._remove_from_text_index(label) # Remove from base collection result = super().remove(uuid) if result: logger.info(f"Removed label '{label.text}'") return result
# Text-based queries
[docs] def get_by_text(self, text: str) -> List[LabelElement]: """ Find all labels with specific text. Args: text: Text to search for Returns: List of labels with matching text """ return self._text_index.get(text, [])
[docs] def filter_by_text_pattern(self, pattern: str) -> List[LabelElement]: """ Find labels with text containing a pattern. Args: pattern: Text pattern to search for (case-insensitive) Returns: List of labels with matching text """ pattern_lower = pattern.lower() matching = [] for label in self._items: if pattern_lower in label.text.lower(): matching.append(label) return matching
# Position-based queries
[docs] def get_at_position( self, position: Union[Point, Tuple[float, float]], tolerance: float = 0.01 ) -> Optional[LabelElement]: """ Find label at or near a specific position. Args: position: Position to search tolerance: Distance tolerance for matching Returns: Label if found, None otherwise """ if isinstance(position, tuple): position = Point(position[0], position[1]) for label in self._items: if label.position.distance_to(position) <= tolerance: return label return None
[docs] def get_near_point( self, point: Union[Point, Tuple[float, float]], radius: float ) -> List[LabelElement]: """ Find all labels within radius of a point. Args: point: Center point radius: Search radius Returns: List of labels within radius """ if isinstance(point, tuple): point = Point(point[0], point[1]) matching = [] for label in self._items: if label.position.distance_to(point) <= radius: matching.append(label) return matching
# Validation
[docs] def validate_all(self) -> List[ValidationIssue]: """ Validate all labels in collection. Returns: List of validation issues found """ all_issues = [] for label in self._items: issues = label.validate() all_issues.extend(issues) return all_issues
# Statistics
[docs] def get_statistics(self) -> Dict[str, Any]: """ Get label collection statistics. Returns: Dictionary with label statistics """ if not self._items: base_stats = super().get_statistics() base_stats.update( { "total_labels": 0, "unique_texts": 0, "avg_size": 0, } ) return base_stats unique_texts = len(self._text_index) avg_size = sum(l.size for l in self._items) / len(self._items) base_stats = super().get_statistics() base_stats.update( { "total_labels": len(self._items), "unique_texts": unique_texts, "avg_size": avg_size, "text_distribution": { text: len(labels) for text, labels in self._text_index.items() }, } ) return base_stats
# Internal helper methods def _add_to_text_index(self, label: LabelElement): """Add label to text index.""" text = label.text if text not in self._text_index: self._text_index[text] = [] self._text_index[text].append(label) def _remove_from_text_index(self, label: LabelElement): """Remove label from text index.""" text = label.text if text in self._text_index: self._text_index[text].remove(label) if not self._text_index[text]: del self._text_index[text] def _update_text_index(self, old_text: str, label: LabelElement): """Update text index when label text changes.""" # Remove from old text if old_text in self._text_index: self._text_index[old_text].remove(label) if not self._text_index[old_text]: del self._text_index[old_text] # Add to new text new_text = label.text if new_text not in self._text_index: self._text_index[new_text] = [] self._text_index[new_text].append(label) # Compatibility methods @property def modified(self) -> bool: """Check if collection has been modified (compatibility).""" return self.is_modified
[docs] def mark_saved(self) -> None: """Mark collection as saved (reset modified flag).""" self.mark_clean()