"""
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()