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.
- kicad_sch_api/__init__.py +68 -3
- kicad_sch_api/cli/__init__.py +45 -0
- kicad_sch_api/cli/base.py +302 -0
- kicad_sch_api/cli/bom.py +164 -0
- kicad_sch_api/cli/erc.py +229 -0
- kicad_sch_api/cli/export_docs.py +289 -0
- kicad_sch_api/cli/kicad_to_python.py +169 -0
- kicad_sch_api/cli/netlist.py +94 -0
- kicad_sch_api/cli/types.py +43 -0
- kicad_sch_api/collections/__init__.py +36 -0
- kicad_sch_api/collections/base.py +604 -0
- kicad_sch_api/collections/components.py +1623 -0
- kicad_sch_api/collections/junctions.py +206 -0
- kicad_sch_api/collections/labels.py +508 -0
- kicad_sch_api/collections/wires.py +292 -0
- kicad_sch_api/core/__init__.py +37 -2
- kicad_sch_api/core/collections/__init__.py +5 -0
- kicad_sch_api/core/collections/base.py +248 -0
- kicad_sch_api/core/component_bounds.py +34 -7
- kicad_sch_api/core/components.py +213 -52
- kicad_sch_api/core/config.py +110 -15
- kicad_sch_api/core/connectivity.py +692 -0
- kicad_sch_api/core/exceptions.py +175 -0
- kicad_sch_api/core/factories/__init__.py +5 -0
- kicad_sch_api/core/factories/element_factory.py +278 -0
- kicad_sch_api/core/formatter.py +60 -9
- kicad_sch_api/core/geometry.py +94 -5
- kicad_sch_api/core/junctions.py +26 -75
- kicad_sch_api/core/labels.py +324 -0
- kicad_sch_api/core/managers/__init__.py +30 -0
- kicad_sch_api/core/managers/base.py +76 -0
- kicad_sch_api/core/managers/file_io.py +246 -0
- kicad_sch_api/core/managers/format_sync.py +502 -0
- kicad_sch_api/core/managers/graphics.py +580 -0
- kicad_sch_api/core/managers/hierarchy.py +661 -0
- kicad_sch_api/core/managers/metadata.py +271 -0
- kicad_sch_api/core/managers/sheet.py +492 -0
- kicad_sch_api/core/managers/text_elements.py +537 -0
- kicad_sch_api/core/managers/validation.py +476 -0
- kicad_sch_api/core/managers/wire.py +410 -0
- kicad_sch_api/core/nets.py +305 -0
- kicad_sch_api/core/no_connects.py +252 -0
- kicad_sch_api/core/parser.py +194 -970
- kicad_sch_api/core/parsing_utils.py +63 -0
- kicad_sch_api/core/pin_utils.py +103 -9
- kicad_sch_api/core/schematic.py +1328 -1079
- kicad_sch_api/core/texts.py +316 -0
- kicad_sch_api/core/types.py +159 -23
- kicad_sch_api/core/wires.py +27 -75
- kicad_sch_api/exporters/__init__.py +10 -0
- kicad_sch_api/exporters/python_generator.py +610 -0
- kicad_sch_api/exporters/templates/default.py.jinja2 +65 -0
- kicad_sch_api/geometry/__init__.py +38 -0
- kicad_sch_api/geometry/font_metrics.py +22 -0
- kicad_sch_api/geometry/routing.py +211 -0
- kicad_sch_api/geometry/symbol_bbox.py +608 -0
- kicad_sch_api/interfaces/__init__.py +17 -0
- kicad_sch_api/interfaces/parser.py +76 -0
- kicad_sch_api/interfaces/repository.py +70 -0
- kicad_sch_api/interfaces/resolver.py +117 -0
- kicad_sch_api/parsers/__init__.py +14 -0
- kicad_sch_api/parsers/base.py +145 -0
- kicad_sch_api/parsers/elements/__init__.py +22 -0
- kicad_sch_api/parsers/elements/graphics_parser.py +564 -0
- kicad_sch_api/parsers/elements/label_parser.py +216 -0
- kicad_sch_api/parsers/elements/library_parser.py +165 -0
- kicad_sch_api/parsers/elements/metadata_parser.py +58 -0
- kicad_sch_api/parsers/elements/sheet_parser.py +352 -0
- kicad_sch_api/parsers/elements/symbol_parser.py +485 -0
- kicad_sch_api/parsers/elements/text_parser.py +250 -0
- kicad_sch_api/parsers/elements/wire_parser.py +242 -0
- kicad_sch_api/parsers/registry.py +155 -0
- kicad_sch_api/parsers/utils.py +80 -0
- kicad_sch_api/symbols/__init__.py +18 -0
- kicad_sch_api/symbols/cache.py +467 -0
- kicad_sch_api/symbols/resolver.py +361 -0
- kicad_sch_api/symbols/validators.py +504 -0
- kicad_sch_api/utils/logging.py +555 -0
- kicad_sch_api/utils/logging_decorators.py +587 -0
- kicad_sch_api/utils/validation.py +16 -22
- kicad_sch_api/validation/__init__.py +25 -0
- kicad_sch_api/validation/erc.py +171 -0
- kicad_sch_api/validation/erc_models.py +203 -0
- kicad_sch_api/validation/pin_matrix.py +243 -0
- kicad_sch_api/validation/validators.py +391 -0
- kicad_sch_api/wrappers/__init__.py +14 -0
- kicad_sch_api/wrappers/base.py +89 -0
- kicad_sch_api/wrappers/wire.py +198 -0
- kicad_sch_api-0.5.1.dist-info/METADATA +540 -0
- kicad_sch_api-0.5.1.dist-info/RECORD +114 -0
- kicad_sch_api-0.5.1.dist-info/entry_points.txt +4 -0
- {kicad_sch_api-0.3.0.dist-info → kicad_sch_api-0.5.1.dist-info}/top_level.txt +1 -0
- mcp_server/__init__.py +34 -0
- mcp_server/example_logging_integration.py +506 -0
- mcp_server/models.py +252 -0
- mcp_server/server.py +357 -0
- mcp_server/tools/__init__.py +32 -0
- mcp_server/tools/component_tools.py +516 -0
- mcp_server/tools/connectivity_tools.py +532 -0
- mcp_server/tools/consolidated_tools.py +1216 -0
- mcp_server/tools/pin_discovery.py +333 -0
- mcp_server/utils/__init__.py +38 -0
- mcp_server/utils/logging.py +127 -0
- mcp_server/utils.py +36 -0
- kicad_sch_api/core/manhattan_routing.py +0 -430
- kicad_sch_api/core/simple_manhattan.py +0 -228
- kicad_sch_api/core/wire_routing.py +0 -380
- kicad_sch_api-0.3.0.dist-info/METADATA +0 -483
- kicad_sch_api-0.3.0.dist-info/RECORD +0 -31
- kicad_sch_api-0.3.0.dist-info/entry_points.txt +0 -2
- {kicad_sch_api-0.3.0.dist-info → kicad_sch_api-0.5.1.dist-info}/WHEEL +0 -0
- {kicad_sch_api-0.3.0.dist-info → kicad_sch_api-0.5.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Enhanced junction management with IndexRegistry integration.
|
|
3
|
+
|
|
4
|
+
Provides JunctionCollection using BaseCollection infrastructure with
|
|
5
|
+
position-based queries and validation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import uuid as uuid_module
|
|
10
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
11
|
+
|
|
12
|
+
from ..core.types import Junction, Point
|
|
13
|
+
from .base import BaseCollection, IndexSpec, ValidationLevel
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class JunctionCollection(BaseCollection[Junction]):
|
|
19
|
+
"""
|
|
20
|
+
Junction collection with position-based queries.
|
|
21
|
+
|
|
22
|
+
Inherits from BaseCollection for UUID indexing and adds junction-specific
|
|
23
|
+
position-based search capabilities.
|
|
24
|
+
|
|
25
|
+
Features:
|
|
26
|
+
- Fast UUID lookup via IndexRegistry
|
|
27
|
+
- Position-based junction queries
|
|
28
|
+
- Lazy index rebuilding
|
|
29
|
+
- Batch mode support
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
junctions: Optional[List[Junction]] = None,
|
|
35
|
+
validation_level: ValidationLevel = ValidationLevel.NORMAL,
|
|
36
|
+
):
|
|
37
|
+
"""
|
|
38
|
+
Initialize junction collection.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
junctions: Initial list of junctions
|
|
42
|
+
validation_level: Validation level for operations
|
|
43
|
+
"""
|
|
44
|
+
super().__init__(validation_level=validation_level)
|
|
45
|
+
|
|
46
|
+
# Add initial junctions
|
|
47
|
+
if junctions:
|
|
48
|
+
with self.batch_mode():
|
|
49
|
+
for junction in junctions:
|
|
50
|
+
super().add(junction)
|
|
51
|
+
|
|
52
|
+
logger.debug(f"JunctionCollection initialized with {len(self)} junctions")
|
|
53
|
+
|
|
54
|
+
# BaseCollection abstract method implementations
|
|
55
|
+
def _get_item_uuid(self, item: Junction) -> str:
|
|
56
|
+
"""Extract UUID from junction."""
|
|
57
|
+
return item.uuid
|
|
58
|
+
|
|
59
|
+
def _create_item(self, **kwargs) -> Junction:
|
|
60
|
+
"""Create a new junction (not typically used directly)."""
|
|
61
|
+
raise NotImplementedError("Use add() method to create junctions")
|
|
62
|
+
|
|
63
|
+
def _get_index_specs(self) -> List[IndexSpec]:
|
|
64
|
+
"""Get index specifications for junction collection."""
|
|
65
|
+
return [
|
|
66
|
+
IndexSpec(
|
|
67
|
+
name="uuid",
|
|
68
|
+
key_func=lambda j: j.uuid,
|
|
69
|
+
unique=True,
|
|
70
|
+
description="UUID index for fast lookups",
|
|
71
|
+
),
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
# Junction-specific add method
|
|
75
|
+
def add(
|
|
76
|
+
self,
|
|
77
|
+
position: Union[Point, Tuple[float, float]],
|
|
78
|
+
diameter: float = 0,
|
|
79
|
+
color: Tuple[int, int, int, int] = (0, 0, 0, 0),
|
|
80
|
+
uuid: Optional[str] = None,
|
|
81
|
+
) -> str:
|
|
82
|
+
"""
|
|
83
|
+
Add a junction to the collection.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
position: Junction position
|
|
87
|
+
diameter: Junction diameter (0 is KiCAD default)
|
|
88
|
+
color: RGBA color tuple (0,0,0,0 is default)
|
|
89
|
+
uuid: Optional UUID (auto-generated if not provided)
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
UUID of the created junction
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
ValueError: If UUID already exists
|
|
96
|
+
"""
|
|
97
|
+
# Generate UUID if not provided
|
|
98
|
+
if uuid is None:
|
|
99
|
+
uuid = str(uuid_module.uuid4())
|
|
100
|
+
else:
|
|
101
|
+
# Check for duplicate
|
|
102
|
+
self._ensure_indexes_current()
|
|
103
|
+
if self._index_registry.has_key("uuid", uuid):
|
|
104
|
+
raise ValueError(f"Junction with UUID '{uuid}' already exists")
|
|
105
|
+
|
|
106
|
+
# Convert position
|
|
107
|
+
if isinstance(position, tuple):
|
|
108
|
+
position = Point(position[0], position[1])
|
|
109
|
+
|
|
110
|
+
# Create junction
|
|
111
|
+
junction = Junction(uuid=uuid, position=position, diameter=diameter, color=color)
|
|
112
|
+
|
|
113
|
+
# Add to collection
|
|
114
|
+
super().add(junction)
|
|
115
|
+
|
|
116
|
+
logger.debug(f"Added junction at {position}, UUID={uuid}")
|
|
117
|
+
return uuid
|
|
118
|
+
|
|
119
|
+
# Position-based queries
|
|
120
|
+
def get_at_position(
|
|
121
|
+
self, position: Union[Point, Tuple[float, float]], tolerance: float = 0.01
|
|
122
|
+
) -> Optional[Junction]:
|
|
123
|
+
"""
|
|
124
|
+
Find junction at or near a specific position.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
position: Position to search
|
|
128
|
+
tolerance: Distance tolerance for matching
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
Junction if found, None otherwise
|
|
132
|
+
"""
|
|
133
|
+
if isinstance(position, tuple):
|
|
134
|
+
position = Point(position[0], position[1])
|
|
135
|
+
|
|
136
|
+
for junction in self._items:
|
|
137
|
+
if junction.position.distance_to(position) <= tolerance:
|
|
138
|
+
return junction
|
|
139
|
+
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
def get_by_point(
|
|
143
|
+
self, point: Union[Point, Tuple[float, float]], tolerance: float = 0.01
|
|
144
|
+
) -> List[Junction]:
|
|
145
|
+
"""
|
|
146
|
+
Find all junctions near a point.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
point: Point to search near
|
|
150
|
+
tolerance: Distance tolerance
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
List of junctions near the point
|
|
154
|
+
"""
|
|
155
|
+
if isinstance(point, tuple):
|
|
156
|
+
point = Point(point[0], point[1])
|
|
157
|
+
|
|
158
|
+
matching_junctions = []
|
|
159
|
+
for junction in self._items:
|
|
160
|
+
if junction.position.distance_to(point) <= tolerance:
|
|
161
|
+
matching_junctions.append(junction)
|
|
162
|
+
|
|
163
|
+
return matching_junctions
|
|
164
|
+
|
|
165
|
+
# Statistics
|
|
166
|
+
def get_statistics(self) -> Dict[str, Any]:
|
|
167
|
+
"""
|
|
168
|
+
Get junction collection statistics.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
Dictionary with junction statistics
|
|
172
|
+
"""
|
|
173
|
+
if not self._items:
|
|
174
|
+
base_stats = super().get_statistics()
|
|
175
|
+
base_stats.update({
|
|
176
|
+
"total_junctions": 0,
|
|
177
|
+
"avg_diameter": 0,
|
|
178
|
+
"positions": [],
|
|
179
|
+
"unique_diameters": 0,
|
|
180
|
+
"unique_colors": 0,
|
|
181
|
+
})
|
|
182
|
+
return base_stats
|
|
183
|
+
|
|
184
|
+
avg_diameter = sum(j.diameter for j in self._items) / len(self._items)
|
|
185
|
+
positions = [(j.position.x, j.position.y) for j in self._items]
|
|
186
|
+
|
|
187
|
+
base_stats = super().get_statistics()
|
|
188
|
+
base_stats.update({
|
|
189
|
+
"total_junctions": len(self._items),
|
|
190
|
+
"avg_diameter": avg_diameter,
|
|
191
|
+
"positions": positions,
|
|
192
|
+
"unique_diameters": len(set(j.diameter for j in self._items)),
|
|
193
|
+
"unique_colors": len(set(j.color for j in self._items)),
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
return base_stats
|
|
197
|
+
|
|
198
|
+
# Compatibility methods
|
|
199
|
+
@property
|
|
200
|
+
def modified(self) -> bool:
|
|
201
|
+
"""Check if collection has been modified (compatibility)."""
|
|
202
|
+
return self.is_modified
|
|
203
|
+
|
|
204
|
+
def mark_saved(self) -> None:
|
|
205
|
+
"""Mark collection as saved (reset modified flag)."""
|
|
206
|
+
self.mark_clean()
|
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Enhanced label management with IndexRegistry integration.
|
|
3
|
+
|
|
4
|
+
Provides LabelElement wrapper and LabelCollection using BaseCollection
|
|
5
|
+
infrastructure with text indexing and position-based queries.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import uuid as uuid_module
|
|
10
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
11
|
+
|
|
12
|
+
from ..core.types import Label, Point
|
|
13
|
+
from ..utils.validation import SchematicValidator, ValidationError, ValidationIssue
|
|
14
|
+
from .base import BaseCollection, IndexSpec, ValidationLevel
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class LabelElement:
|
|
20
|
+
"""
|
|
21
|
+
Enhanced wrapper for schematic label elements.
|
|
22
|
+
|
|
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}>"
|
|
182
|
+
|
|
183
|
+
def __repr__(self) -> str:
|
|
184
|
+
"""Detailed representation for debugging."""
|
|
185
|
+
return f"LabelElement(text='{self.text}', pos={self.position}, rotation={self.rotation})"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class LabelCollection(BaseCollection[LabelElement]):
|
|
189
|
+
"""
|
|
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
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
def __init__(
|
|
204
|
+
self,
|
|
205
|
+
labels: Optional[List[Label]] = None,
|
|
206
|
+
validation_level: ValidationLevel = ValidationLevel.NORMAL,
|
|
207
|
+
):
|
|
208
|
+
"""
|
|
209
|
+
Initialize label collection.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
labels: Initial list of label data
|
|
213
|
+
validation_level: Validation level for operations
|
|
214
|
+
"""
|
|
215
|
+
super().__init__(validation_level=validation_level)
|
|
216
|
+
|
|
217
|
+
# Manual text index (non-unique - multiple labels can have same text)
|
|
218
|
+
self._text_index: Dict[str, List[LabelElement]] = {}
|
|
219
|
+
|
|
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)
|
|
227
|
+
|
|
228
|
+
logger.debug(f"LabelCollection initialized with {len(self)} labels")
|
|
229
|
+
|
|
230
|
+
# BaseCollection abstract method implementations
|
|
231
|
+
def _get_item_uuid(self, item: LabelElement) -> str:
|
|
232
|
+
"""Extract UUID from label element."""
|
|
233
|
+
return item.uuid
|
|
234
|
+
|
|
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
|
|
251
|
+
def add(
|
|
252
|
+
self,
|
|
253
|
+
text: str,
|
|
254
|
+
position: Union[Point, Tuple[float, float]],
|
|
255
|
+
rotation: float = 0.0,
|
|
256
|
+
size: float = 1.27,
|
|
257
|
+
justify_h: str = "left",
|
|
258
|
+
justify_v: str = "bottom",
|
|
259
|
+
uuid: Optional[str] = None,
|
|
260
|
+
) -> LabelElement:
|
|
261
|
+
"""
|
|
262
|
+
Add a label to the collection.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
text: Label text (net name)
|
|
266
|
+
position: Label position
|
|
267
|
+
rotation: Label rotation in degrees
|
|
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)
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
LabelElement wrapper for the created label
|
|
275
|
+
|
|
276
|
+
Raises:
|
|
277
|
+
ValueError: If UUID already exists or text is empty
|
|
278
|
+
"""
|
|
279
|
+
# Validate text
|
|
280
|
+
if not text or not text.strip():
|
|
281
|
+
raise ValueError("Label text cannot be empty")
|
|
282
|
+
|
|
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
|
|
293
|
+
if isinstance(position, tuple):
|
|
294
|
+
position = Point(position[0], position[1])
|
|
295
|
+
|
|
296
|
+
# Create label data
|
|
297
|
+
label_data = Label(
|
|
298
|
+
uuid=uuid,
|
|
299
|
+
text=text.strip(),
|
|
300
|
+
position=position,
|
|
301
|
+
rotation=rotation,
|
|
302
|
+
size=size,
|
|
303
|
+
justify_h=justify_h,
|
|
304
|
+
justify_v=justify_v,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
# Create label element wrapper
|
|
308
|
+
label_element = LabelElement(label_data, self)
|
|
309
|
+
|
|
310
|
+
# Add to collection
|
|
311
|
+
super().add(label_element)
|
|
312
|
+
|
|
313
|
+
# Add to text index
|
|
314
|
+
self._add_to_text_index(label_element)
|
|
315
|
+
|
|
316
|
+
logger.debug(f"Added label '{text}' at {position}, UUID={uuid}")
|
|
317
|
+
return label_element
|
|
318
|
+
|
|
319
|
+
# Remove operation (override to update text index)
|
|
320
|
+
def remove(self, uuid: str) -> bool:
|
|
321
|
+
"""
|
|
322
|
+
Remove label by UUID.
|
|
323
|
+
|
|
324
|
+
Args:
|
|
325
|
+
uuid: Label UUID to remove
|
|
326
|
+
|
|
327
|
+
Returns:
|
|
328
|
+
True if label was removed, False if not found
|
|
329
|
+
"""
|
|
330
|
+
# Get label before removing
|
|
331
|
+
label = self.get(uuid)
|
|
332
|
+
if not label:
|
|
333
|
+
return False
|
|
334
|
+
|
|
335
|
+
# Remove from text index
|
|
336
|
+
self._remove_from_text_index(label)
|
|
337
|
+
|
|
338
|
+
# Remove from base collection
|
|
339
|
+
result = super().remove(uuid)
|
|
340
|
+
|
|
341
|
+
if result:
|
|
342
|
+
logger.info(f"Removed label '{label.text}'")
|
|
343
|
+
|
|
344
|
+
return result
|
|
345
|
+
|
|
346
|
+
# Text-based queries
|
|
347
|
+
def get_by_text(self, text: str) -> List[LabelElement]:
|
|
348
|
+
"""
|
|
349
|
+
Find all labels with specific text.
|
|
350
|
+
|
|
351
|
+
Args:
|
|
352
|
+
text: Text to search for
|
|
353
|
+
|
|
354
|
+
Returns:
|
|
355
|
+
List of labels with matching text
|
|
356
|
+
"""
|
|
357
|
+
return self._text_index.get(text, [])
|
|
358
|
+
|
|
359
|
+
def filter_by_text_pattern(self, pattern: str) -> List[LabelElement]:
|
|
360
|
+
"""
|
|
361
|
+
Find labels with text containing a pattern.
|
|
362
|
+
|
|
363
|
+
Args:
|
|
364
|
+
pattern: Text pattern to search for (case-insensitive)
|
|
365
|
+
|
|
366
|
+
Returns:
|
|
367
|
+
List of labels with matching text
|
|
368
|
+
"""
|
|
369
|
+
pattern_lower = pattern.lower()
|
|
370
|
+
matching = []
|
|
371
|
+
|
|
372
|
+
for label in self._items:
|
|
373
|
+
if pattern_lower in label.text.lower():
|
|
374
|
+
matching.append(label)
|
|
375
|
+
|
|
376
|
+
return matching
|
|
377
|
+
|
|
378
|
+
# Position-based queries
|
|
379
|
+
def get_at_position(
|
|
380
|
+
self, position: Union[Point, Tuple[float, float]], tolerance: float = 0.01
|
|
381
|
+
) -> Optional[LabelElement]:
|
|
382
|
+
"""
|
|
383
|
+
Find label at or near a specific position.
|
|
384
|
+
|
|
385
|
+
Args:
|
|
386
|
+
position: Position to search
|
|
387
|
+
tolerance: Distance tolerance for matching
|
|
388
|
+
|
|
389
|
+
Returns:
|
|
390
|
+
Label if found, None otherwise
|
|
391
|
+
"""
|
|
392
|
+
if isinstance(position, tuple):
|
|
393
|
+
position = Point(position[0], position[1])
|
|
394
|
+
|
|
395
|
+
for label in self._items:
|
|
396
|
+
if label.position.distance_to(position) <= tolerance:
|
|
397
|
+
return label
|
|
398
|
+
|
|
399
|
+
return None
|
|
400
|
+
|
|
401
|
+
def get_near_point(
|
|
402
|
+
self, point: Union[Point, Tuple[float, float]], radius: float
|
|
403
|
+
) -> List[LabelElement]:
|
|
404
|
+
"""
|
|
405
|
+
Find all labels within radius of a point.
|
|
406
|
+
|
|
407
|
+
Args:
|
|
408
|
+
point: Center point
|
|
409
|
+
radius: Search radius
|
|
410
|
+
|
|
411
|
+
Returns:
|
|
412
|
+
List of labels within radius
|
|
413
|
+
"""
|
|
414
|
+
if isinstance(point, tuple):
|
|
415
|
+
point = Point(point[0], point[1])
|
|
416
|
+
|
|
417
|
+
matching = []
|
|
418
|
+
for label in self._items:
|
|
419
|
+
if label.position.distance_to(point) <= radius:
|
|
420
|
+
matching.append(label)
|
|
421
|
+
|
|
422
|
+
return matching
|
|
423
|
+
|
|
424
|
+
# Validation
|
|
425
|
+
def validate_all(self) -> List[ValidationIssue]:
|
|
426
|
+
"""
|
|
427
|
+
Validate all labels in collection.
|
|
428
|
+
|
|
429
|
+
Returns:
|
|
430
|
+
List of validation issues found
|
|
431
|
+
"""
|
|
432
|
+
all_issues = []
|
|
433
|
+
|
|
434
|
+
for label in self._items:
|
|
435
|
+
issues = label.validate()
|
|
436
|
+
all_issues.extend(issues)
|
|
437
|
+
|
|
438
|
+
return all_issues
|
|
439
|
+
|
|
440
|
+
# Statistics
|
|
441
|
+
def get_statistics(self) -> Dict[str, Any]:
|
|
442
|
+
"""
|
|
443
|
+
Get label collection statistics.
|
|
444
|
+
|
|
445
|
+
Returns:
|
|
446
|
+
Dictionary with label statistics
|
|
447
|
+
"""
|
|
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()
|