kicad-sch-api 0.3.5__py3-none-any.whl → 0.4.0__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.

Potentially problematic release.


This version of kicad-sch-api might be problematic. Click here for more details.

Files changed (47) hide show
  1. kicad_sch_api/collections/__init__.py +2 -2
  2. kicad_sch_api/collections/base.py +5 -7
  3. kicad_sch_api/collections/components.py +24 -12
  4. kicad_sch_api/collections/junctions.py +31 -43
  5. kicad_sch_api/collections/labels.py +19 -27
  6. kicad_sch_api/collections/wires.py +17 -18
  7. kicad_sch_api/core/components.py +5 -0
  8. kicad_sch_api/core/formatter.py +3 -1
  9. kicad_sch_api/core/labels.py +2 -2
  10. kicad_sch_api/core/managers/__init__.py +26 -0
  11. kicad_sch_api/core/managers/file_io.py +243 -0
  12. kicad_sch_api/core/managers/format_sync.py +501 -0
  13. kicad_sch_api/core/managers/graphics.py +579 -0
  14. kicad_sch_api/core/managers/metadata.py +268 -0
  15. kicad_sch_api/core/managers/sheet.py +454 -0
  16. kicad_sch_api/core/managers/text_elements.py +536 -0
  17. kicad_sch_api/core/managers/validation.py +474 -0
  18. kicad_sch_api/core/managers/wire.py +346 -0
  19. kicad_sch_api/core/nets.py +1 -1
  20. kicad_sch_api/core/no_connects.py +5 -3
  21. kicad_sch_api/core/parser.py +75 -41
  22. kicad_sch_api/core/schematic.py +779 -1083
  23. kicad_sch_api/core/texts.py +1 -1
  24. kicad_sch_api/core/types.py +1 -4
  25. kicad_sch_api/geometry/font_metrics.py +3 -1
  26. kicad_sch_api/geometry/symbol_bbox.py +40 -21
  27. kicad_sch_api/interfaces/__init__.py +1 -1
  28. kicad_sch_api/interfaces/parser.py +1 -1
  29. kicad_sch_api/interfaces/repository.py +1 -1
  30. kicad_sch_api/interfaces/resolver.py +1 -1
  31. kicad_sch_api/parsers/__init__.py +2 -2
  32. kicad_sch_api/parsers/base.py +7 -10
  33. kicad_sch_api/parsers/label_parser.py +7 -7
  34. kicad_sch_api/parsers/registry.py +4 -2
  35. kicad_sch_api/parsers/symbol_parser.py +5 -10
  36. kicad_sch_api/parsers/wire_parser.py +2 -2
  37. kicad_sch_api/symbols/__init__.py +1 -1
  38. kicad_sch_api/symbols/cache.py +9 -12
  39. kicad_sch_api/symbols/resolver.py +20 -26
  40. kicad_sch_api/symbols/validators.py +188 -137
  41. {kicad_sch_api-0.3.5.dist-info → kicad_sch_api-0.4.0.dist-info}/METADATA +1 -1
  42. kicad_sch_api-0.4.0.dist-info/RECORD +67 -0
  43. kicad_sch_api-0.3.5.dist-info/RECORD +0 -58
  44. {kicad_sch_api-0.3.5.dist-info → kicad_sch_api-0.4.0.dist-info}/WHEEL +0 -0
  45. {kicad_sch_api-0.3.5.dist-info → kicad_sch_api-0.4.0.dist-info}/entry_points.txt +0 -0
  46. {kicad_sch_api-0.3.5.dist-info → kicad_sch_api-0.4.0.dist-info}/licenses/LICENSE +0 -0
  47. {kicad_sch_api-0.3.5.dist-info → kicad_sch_api-0.4.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,536 @@
1
+ """
2
+ Text Element Manager for KiCAD schematic text operations.
3
+
4
+ Handles all text-related elements including labels, hierarchical labels,
5
+ global labels, text annotations, and text boxes while managing positioning
6
+ and validation.
7
+ """
8
+
9
+ import logging
10
+ import uuid
11
+ from typing import Any, Dict, List, Optional, Tuple, Union
12
+
13
+ from ..types import Point
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class TextElementManager:
19
+ """
20
+ Manages text elements and labeling in KiCAD schematics.
21
+
22
+ Responsible for:
23
+ - Label creation and management (normal, hierarchical, global)
24
+ - Text annotation placement
25
+ - Text box management
26
+ - Text effect and styling
27
+ - Position validation and adjustment
28
+ """
29
+
30
+ def __init__(self, schematic_data: Dict[str, Any]):
31
+ """
32
+ Initialize TextElementManager.
33
+
34
+ Args:
35
+ schematic_data: Reference to schematic data
36
+ """
37
+ self._data = schematic_data
38
+
39
+ def add_label(
40
+ self,
41
+ text: str,
42
+ position: Union[Point, Tuple[float, float]],
43
+ effects: Optional[Dict[str, Any]] = None,
44
+ uuid_str: Optional[str] = None,
45
+ rotation: float = 0,
46
+ size: Optional[float] = None,
47
+ ) -> str:
48
+ """
49
+ Add a text label to the schematic.
50
+
51
+ Args:
52
+ text: Label text content
53
+ position: Label position
54
+ effects: Text effects (size, font, etc.)
55
+ uuid_str: Optional UUID, generated if not provided
56
+ rotation: Label rotation in degrees (default 0)
57
+ size: Text size override (default from effects)
58
+
59
+ Returns:
60
+ UUID of created label
61
+ """
62
+ if isinstance(position, tuple):
63
+ position = Point(position[0], position[1])
64
+
65
+ if uuid_str is None:
66
+ uuid_str = str(uuid.uuid4())
67
+
68
+ if effects is None:
69
+ effects = self._get_default_text_effects()
70
+
71
+ # Override size if provided
72
+ if size is not None:
73
+ effects = dict(effects) # Make a copy
74
+ if "font" not in effects:
75
+ effects["font"] = {}
76
+ effects["font"]["size"] = [size, size]
77
+
78
+ label_data = {
79
+ "uuid": uuid_str,
80
+ "text": text,
81
+ "at": [position.x, position.y, rotation], # KiCAD format: [x, y, rotation]
82
+ "effects": effects,
83
+ }
84
+
85
+ # Add to schematic data
86
+ if "label" not in self._data:
87
+ self._data["label"] = []
88
+ self._data["label"].append(label_data)
89
+
90
+ logger.debug(f"Added label '{text}' at {position}")
91
+ return uuid_str
92
+
93
+ def add_hierarchical_label(
94
+ self,
95
+ text: str,
96
+ position: Union[Point, Tuple[float, float]],
97
+ shape: str = "input",
98
+ effects: Optional[Dict[str, Any]] = None,
99
+ uuid_str: Optional[str] = None,
100
+ ) -> str:
101
+ """
102
+ Add a hierarchical label (for sheet connections).
103
+
104
+ Args:
105
+ text: Label text
106
+ position: Label position
107
+ shape: Shape type (input, output, bidirectional, tri_state, passive)
108
+ effects: Text effects
109
+ uuid_str: Optional UUID
110
+
111
+ Returns:
112
+ UUID of created hierarchical label
113
+ """
114
+ if isinstance(position, tuple):
115
+ position = Point(position[0], position[1])
116
+
117
+ if uuid_str is None:
118
+ uuid_str = str(uuid.uuid4())
119
+
120
+ if effects is None:
121
+ effects = self._get_default_text_effects()
122
+
123
+ valid_shapes = ["input", "output", "bidirectional", "tri_state", "passive"]
124
+ if shape not in valid_shapes:
125
+ logger.warning(f"Invalid hierarchical label shape: {shape}. Using 'input'")
126
+ shape = "input"
127
+
128
+ label_data = {
129
+ "uuid": uuid_str,
130
+ "text": text,
131
+ "shape": shape,
132
+ "at": [position.x, position.y, 0],
133
+ "effects": effects,
134
+ }
135
+
136
+ # Add to schematic data
137
+ if "hierarchical_label" not in self._data:
138
+ self._data["hierarchical_label"] = []
139
+ self._data["hierarchical_label"].append(label_data)
140
+
141
+ logger.debug(f"Added hierarchical label '{text}' ({shape}) at {position}")
142
+ return uuid_str
143
+
144
+ def add_global_label(
145
+ self,
146
+ text: str,
147
+ position: Union[Point, Tuple[float, float]],
148
+ shape: str = "input",
149
+ effects: Optional[Dict[str, Any]] = None,
150
+ uuid_str: Optional[str] = None,
151
+ ) -> str:
152
+ """
153
+ Add a global label (for project-wide connections).
154
+
155
+ Args:
156
+ text: Label text
157
+ position: Label position
158
+ shape: Shape type
159
+ effects: Text effects
160
+ uuid_str: Optional UUID
161
+
162
+ Returns:
163
+ UUID of created global label
164
+ """
165
+ if isinstance(position, tuple):
166
+ position = Point(position[0], position[1])
167
+
168
+ if uuid_str is None:
169
+ uuid_str = str(uuid.uuid4())
170
+
171
+ if effects is None:
172
+ effects = self._get_default_text_effects()
173
+
174
+ valid_shapes = ["input", "output", "bidirectional", "tri_state", "passive"]
175
+ if shape not in valid_shapes:
176
+ logger.warning(f"Invalid global label shape: {shape}. Using 'input'")
177
+ shape = "input"
178
+
179
+ label_data = {
180
+ "uuid": uuid_str,
181
+ "text": text,
182
+ "shape": shape,
183
+ "at": [position.x, position.y, 0],
184
+ "effects": effects,
185
+ }
186
+
187
+ # Add to schematic data
188
+ if "global_label" not in self._data:
189
+ self._data["global_label"] = []
190
+ self._data["global_label"].append(label_data)
191
+
192
+ logger.debug(f"Added global label '{text}' ({shape}) at {position}")
193
+ return uuid_str
194
+
195
+ def add_text(
196
+ self,
197
+ text: str,
198
+ position: Union[Point, Tuple[float, float]],
199
+ effects: Optional[Dict[str, Any]] = None,
200
+ uuid_str: Optional[str] = None,
201
+ ) -> str:
202
+ """
203
+ Add free text annotation.
204
+
205
+ Args:
206
+ text: Text content
207
+ position: Text position
208
+ effects: Text effects
209
+ uuid_str: Optional UUID
210
+
211
+ Returns:
212
+ UUID of created text
213
+ """
214
+ if isinstance(position, tuple):
215
+ position = Point(position[0], position[1])
216
+
217
+ if uuid_str is None:
218
+ uuid_str = str(uuid.uuid4())
219
+
220
+ if effects is None:
221
+ effects = self._get_default_text_effects()
222
+
223
+ text_data = {
224
+ "uuid": uuid_str,
225
+ "text": text,
226
+ "at": [position.x, position.y, 0],
227
+ "effects": effects,
228
+ }
229
+
230
+ # Add to schematic data
231
+ if "text" not in self._data:
232
+ self._data["text"] = []
233
+ self._data["text"].append(text_data)
234
+
235
+ logger.debug(f"Added text '{text}' at {position}")
236
+ return uuid_str
237
+
238
+ def add_text_box(
239
+ self,
240
+ text: str,
241
+ position: Union[Point, Tuple[float, float]],
242
+ size: Union[Point, Tuple[float, float]],
243
+ rotation: float = 0.0,
244
+ font_size: float = 1.27,
245
+ margins: Optional[Tuple[float, float, float, float]] = None,
246
+ stroke_width: Optional[float] = None,
247
+ stroke_type: str = "solid",
248
+ fill_type: str = "none",
249
+ justify_horizontal: str = "left",
250
+ justify_vertical: str = "top",
251
+ exclude_from_sim: bool = False,
252
+ effects: Optional[Dict[str, Any]] = None,
253
+ stroke: Optional[Dict[str, Any]] = None,
254
+ uuid_str: Optional[str] = None,
255
+ ) -> str:
256
+ """
257
+ Add a text box with border.
258
+
259
+ Args:
260
+ text: Text content
261
+ position: Top-left position
262
+ size: Box size (width, height)
263
+ rotation: Text rotation in degrees
264
+ font_size: Text font size
265
+ margins: Box margins (top, bottom, left, right)
266
+ stroke_width: Border stroke width
267
+ stroke_type: Border stroke type (solid, dash, etc.)
268
+ fill_type: Fill type (none, outline, background)
269
+ justify_horizontal: Horizontal justification
270
+ justify_vertical: Vertical justification
271
+ exclude_from_sim: Whether to exclude from simulation
272
+ effects: Text effects (legacy, overrides font_size and justify if provided)
273
+ stroke: Border stroke settings (legacy, overrides stroke_width/type if provided)
274
+ uuid_str: Optional UUID
275
+
276
+ Returns:
277
+ UUID of created text box
278
+ """
279
+ if isinstance(position, tuple):
280
+ position = Point(position[0], position[1])
281
+ if isinstance(size, tuple):
282
+ size = Point(size[0], size[1])
283
+
284
+ if uuid_str is None:
285
+ uuid_str = str(uuid.uuid4())
286
+
287
+ if margins is None:
288
+ margins = (0.9525, 0.9525, 0.9525, 0.9525)
289
+
290
+ if stroke_width is None:
291
+ stroke_width = 0
292
+
293
+ # Build text_box_data matching parser format
294
+ text_box_data = {
295
+ "uuid": uuid_str,
296
+ "text": text,
297
+ "exclude_from_sim": exclude_from_sim,
298
+ "position": {"x": position.x, "y": position.y},
299
+ "rotation": rotation,
300
+ "size": {"width": size.x, "height": size.y},
301
+ "margins": margins,
302
+ "stroke_width": stroke_width,
303
+ "stroke_type": stroke_type,
304
+ "fill_type": fill_type,
305
+ "font_size": font_size,
306
+ "justify_horizontal": justify_horizontal,
307
+ "justify_vertical": justify_vertical,
308
+ }
309
+
310
+ # Add to schematic data (note: plural "text_boxes")
311
+ if "text_boxes" not in self._data:
312
+ self._data["text_boxes"] = []
313
+ self._data["text_boxes"].append(text_box_data)
314
+
315
+ logger.debug(f"Added text box '{text}' at {position}, size {size}")
316
+ return uuid_str
317
+
318
+ def remove_label(self, uuid_str: str) -> bool:
319
+ """
320
+ Remove a label by UUID.
321
+
322
+ Args:
323
+ uuid_str: UUID of label to remove
324
+
325
+ Returns:
326
+ True if label was removed, False if not found
327
+ """
328
+ return self._remove_text_element_by_uuid("label", uuid_str)
329
+
330
+ def remove_hierarchical_label(self, uuid_str: str) -> bool:
331
+ """
332
+ Remove a hierarchical label by UUID.
333
+
334
+ Args:
335
+ uuid_str: UUID of label to remove
336
+
337
+ Returns:
338
+ True if label was removed, False if not found
339
+ """
340
+ return self._remove_text_element_by_uuid("hierarchical_label", uuid_str)
341
+
342
+ def remove_global_label(self, uuid_str: str) -> bool:
343
+ """
344
+ Remove a global label by UUID.
345
+
346
+ Args:
347
+ uuid_str: UUID of label to remove
348
+
349
+ Returns:
350
+ True if label was removed, False if not found
351
+ """
352
+ return self._remove_text_element_by_uuid("global_label", uuid_str)
353
+
354
+ def remove_text(self, uuid_str: str) -> bool:
355
+ """
356
+ Remove a text element by UUID.
357
+
358
+ Args:
359
+ uuid_str: UUID of text to remove
360
+
361
+ Returns:
362
+ True if text was removed, False if not found
363
+ """
364
+ return self._remove_text_element_by_uuid("text", uuid_str)
365
+
366
+ def remove_text_box(self, uuid_str: str) -> bool:
367
+ """
368
+ Remove a text box by UUID.
369
+
370
+ Args:
371
+ uuid_str: UUID of text box to remove
372
+
373
+ Returns:
374
+ True if text box was removed, False if not found
375
+ """
376
+ return self._remove_text_element_by_uuid("text_boxes", uuid_str)
377
+
378
+ def get_labels_at_position(
379
+ self, position: Union[Point, Tuple[float, float]], tolerance: float = 1.0
380
+ ) -> List[Dict[str, Any]]:
381
+ """
382
+ Get all labels near a position.
383
+
384
+ Args:
385
+ position: Search position
386
+ tolerance: Position tolerance
387
+
388
+ Returns:
389
+ List of matching label data
390
+ """
391
+ if isinstance(position, tuple):
392
+ position = Point(position[0], position[1])
393
+
394
+ matches = []
395
+ for label_type in ["label", "hierarchical_label", "global_label"]:
396
+ labels = self._data.get(label_type, [])
397
+ for label in labels:
398
+ label_pos = Point(label["at"][0], label["at"][1])
399
+ if label_pos.distance_to(position) <= tolerance:
400
+ matches.append(
401
+ {
402
+ "type": label_type,
403
+ "data": label,
404
+ "uuid": label.get("uuid"),
405
+ "text": label.get("text"),
406
+ "position": label_pos,
407
+ }
408
+ )
409
+
410
+ return matches
411
+
412
+ def update_text_effects(self, uuid_str: str, effects: Dict[str, Any]) -> bool:
413
+ """
414
+ Update text effects for any text element.
415
+
416
+ Args:
417
+ uuid_str: UUID of text element
418
+ effects: New text effects
419
+
420
+ Returns:
421
+ True if updated, False if not found
422
+ """
423
+ for text_type in ["label", "hierarchical_label", "global_label", "text", "text_boxes"]:
424
+ elements = self._data.get(text_type, [])
425
+ for element in elements:
426
+ if element.get("uuid") == uuid_str:
427
+ element["effects"] = effects
428
+ logger.debug(f"Updated text effects for {text_type} {uuid_str}")
429
+ return True
430
+
431
+ logger.warning(f"Text element not found for UUID: {uuid_str}")
432
+ return False
433
+
434
+ def list_all_text_elements(self) -> Dict[str, List[Dict[str, Any]]]:
435
+ """
436
+ Get all text elements in the schematic.
437
+
438
+ Returns:
439
+ Dictionary with text element types and their data
440
+ """
441
+ result = {}
442
+ for text_type in ["label", "hierarchical_label", "global_label", "text", "text_boxes"]:
443
+ elements = self._data.get(text_type, [])
444
+ result[text_type] = [
445
+ {
446
+ "uuid": elem.get("uuid"),
447
+ "text": elem.get("text"),
448
+ "position": (
449
+ Point(elem["at"][0], elem["at"][1])
450
+ if "at" in elem
451
+ else (
452
+ Point(elem["position"]["x"], elem["position"]["y"])
453
+ if "position" in elem
454
+ else None
455
+ )
456
+ ),
457
+ "data": elem,
458
+ }
459
+ for elem in elements
460
+ ]
461
+
462
+ return result
463
+
464
+ def get_text_statistics(self) -> Dict[str, Any]:
465
+ """
466
+ Get statistics about text elements.
467
+
468
+ Returns:
469
+ Dictionary with text element statistics
470
+ """
471
+ stats = {}
472
+ total_elements = 0
473
+
474
+ for text_type in ["label", "hierarchical_label", "global_label", "text", "text_boxes"]:
475
+ count = len(self._data.get(text_type, []))
476
+ stats[text_type] = count
477
+ total_elements += count
478
+
479
+ stats["total_text_elements"] = total_elements
480
+ return stats
481
+
482
+ def _remove_text_element_by_uuid(self, element_type: str, uuid_str: str) -> bool:
483
+ """Remove text element by UUID from specified type."""
484
+ elements = self._data.get(element_type, [])
485
+ for i, element in enumerate(elements):
486
+ if element.get("uuid") == uuid_str:
487
+ del elements[i]
488
+ logger.debug(f"Removed {element_type}: {uuid_str}")
489
+ return True
490
+ return False
491
+
492
+ def _get_default_text_effects(self) -> Dict[str, Any]:
493
+ """Get default text effects configuration."""
494
+ return {"font": {"size": [1.27, 1.27], "thickness": 0.254}, "justify": ["left"]}
495
+
496
+ def validate_text_positions(self) -> List[str]:
497
+ """
498
+ Validate text element positions for overlaps and readability.
499
+
500
+ Returns:
501
+ List of validation warnings
502
+ """
503
+ warnings = []
504
+ all_elements = []
505
+
506
+ # Collect all text elements with positions
507
+ for text_type in ["label", "hierarchical_label", "global_label", "text", "text_boxes"]:
508
+ elements = self._data.get(text_type, [])
509
+ for element in elements:
510
+ if "at" in element:
511
+ position = Point(element["at"][0], element["at"][1])
512
+ elif "position" in element:
513
+ position = Point(element["position"]["x"], element["position"]["y"])
514
+ else:
515
+ continue
516
+ all_elements.append(
517
+ {
518
+ "type": text_type,
519
+ "position": position,
520
+ "text": element.get("text", ""),
521
+ "uuid": element.get("uuid"),
522
+ }
523
+ )
524
+
525
+ # Check for overlapping elements
526
+ overlap_threshold = 2.0 # Minimum distance
527
+ for i, elem1 in enumerate(all_elements):
528
+ for elem2 in all_elements[i + 1 :]:
529
+ distance = elem1["position"].distance_to(elem2["position"])
530
+ if distance < overlap_threshold:
531
+ warnings.append(
532
+ f"Text elements '{elem1['text']}' and '{elem2['text']}' "
533
+ f"are very close ({distance:.2f} units apart)"
534
+ )
535
+
536
+ return warnings