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
@@ -1,8 +1,9 @@
1
1
  """
2
- Main Schematic class for KiCAD schematic manipulation.
2
+ Refactored Schematic class using composition with specialized managers.
3
3
 
4
- This module provides the primary interface for loading, modifying, and saving
5
- KiCAD schematic files with exact format preservation and professional features.
4
+ This module provides the same interface as the original Schematic class but uses
5
+ composition with specialized manager classes for better separation of concerns
6
+ and maintainability.
6
7
  """
7
8
 
8
9
  import logging
@@ -19,6 +20,16 @@ from .components import ComponentCollection
19
20
  from .formatter import ExactFormatter
20
21
  from .junctions import JunctionCollection
21
22
  from .labels import LabelCollection
23
+ from .managers import (
24
+ FileIOManager,
25
+ FormatSyncManager,
26
+ GraphicsManager,
27
+ MetadataManager,
28
+ SheetManager,
29
+ TextElementManager,
30
+ ValidationManager,
31
+ WireManager,
32
+ )
22
33
  from .nets import NetCollection
23
34
  from .no_connects import NoConnectCollection
24
35
  from .parser import SExpressionParser
@@ -46,7 +57,7 @@ logger = logging.getLogger(__name__)
46
57
 
47
58
  class Schematic:
48
59
  """
49
- Professional KiCAD schematic manipulation class.
60
+ Professional KiCAD schematic manipulation class with manager-based architecture.
50
61
 
51
62
  Features:
52
63
  - Exact format preservation
@@ -55,9 +66,10 @@ class Schematic:
55
66
  - Comprehensive validation
56
67
  - Performance optimization for large schematics
57
68
  - AI agent integration via MCP
69
+ - Modular architecture with specialized managers
58
70
 
59
71
  This class provides a modern, intuitive API while maintaining exact compatibility
60
- with KiCAD's native file format.
72
+ with KiCAD's native file format through specialized manager classes.
61
73
  """
62
74
 
63
75
  def __init__(
@@ -67,7 +79,7 @@ class Schematic:
67
79
  name: Optional[str] = None,
68
80
  ):
69
81
  """
70
- Initialize schematic object.
82
+ Initialize schematic object with manager-based architecture.
71
83
 
72
84
  Args:
73
85
  schematic_data: Parsed schematic data
@@ -78,13 +90,13 @@ class Schematic:
78
90
  self._data = schematic_data or self._create_empty_schematic_data()
79
91
  self._file_path = Path(file_path) if file_path else None
80
92
  self._original_content = self._data.get("_original_content", "")
81
- self.name = name or "simple_circuit" # Store project name
93
+ self.name = name or "simple_circuit"
82
94
 
83
95
  # Initialize parser and formatter
84
96
  self._parser = SExpressionParser(preserve_format=True)
85
- self._parser.project_name = self.name # Pass project name to parser
97
+ self._parser.project_name = self.name
86
98
  self._formatter = ExactFormatter()
87
- self._validator = SchematicValidator()
99
+ self._legacy_validator = SchematicValidator() # Keep for compatibility
88
100
 
89
101
  # Initialize component collection
90
102
  component_symbols = [
@@ -187,13 +199,48 @@ class Schematic:
187
199
  label_type=LabelType(label_dict.get("label_type", "local")),
188
200
  rotation=label_dict.get("rotation", 0.0),
189
201
  size=label_dict.get("size", 1.27),
190
- shape=HierarchicalLabelShape(label_dict.get("shape")) if label_dict.get("shape") else None,
202
+ shape=(
203
+ HierarchicalLabelShape(label_dict.get("shape"))
204
+ if label_dict.get("shape")
205
+ else None
206
+ ),
191
207
  )
192
208
  labels.append(label)
193
209
  self._labels = LabelCollection(labels)
194
210
 
195
- # Initialize hierarchical labels collection (filter from labels)
196
- hierarchical_labels = [label for label in labels if label.label_type == LabelType.HIERARCHICAL]
211
+ # Initialize hierarchical labels collection (from both labels array and hierarchical_labels array)
212
+ hierarchical_labels = [
213
+ label for label in labels if label.label_type == LabelType.HIERARCHICAL
214
+ ]
215
+
216
+ # Also load from hierarchical_labels data if present
217
+ hierarchical_label_data = self._data.get("hierarchical_labels", [])
218
+ for hlabel_dict in hierarchical_label_data:
219
+ if isinstance(hlabel_dict, dict):
220
+ # Convert dict to Label object
221
+ position = hlabel_dict.get("position", {"x": 0, "y": 0})
222
+ if isinstance(position, dict):
223
+ pos = Point(position["x"], position["y"])
224
+ elif isinstance(position, (list, tuple)):
225
+ pos = Point(position[0], position[1])
226
+ else:
227
+ pos = position
228
+
229
+ hlabel = Label(
230
+ uuid=hlabel_dict.get("uuid", str(uuid.uuid4())),
231
+ position=pos,
232
+ text=hlabel_dict.get("text", ""),
233
+ label_type=LabelType.HIERARCHICAL,
234
+ rotation=hlabel_dict.get("rotation", 0.0),
235
+ size=hlabel_dict.get("size", 1.27),
236
+ shape=(
237
+ HierarchicalLabelShape(hlabel_dict.get("shape"))
238
+ if hlabel_dict.get("shape")
239
+ else None
240
+ ),
241
+ )
242
+ hierarchical_labels.append(hlabel)
243
+
197
244
  self._hierarchical_labels = LabelCollection(hierarchical_labels)
198
245
 
199
246
  # Initialize no-connect collection
@@ -232,6 +279,16 @@ class Schematic:
232
279
  nets.append(net)
233
280
  self._nets = NetCollection(nets)
234
281
 
282
+ # Initialize specialized managers
283
+ self._file_io_manager = FileIOManager()
284
+ self._format_sync_manager = FormatSyncManager(self._data)
285
+ self._graphics_manager = GraphicsManager(self._data)
286
+ self._metadata_manager = MetadataManager(self._data)
287
+ self._sheet_manager = SheetManager(self._data)
288
+ self._text_element_manager = TextElementManager(self._data)
289
+ self._wire_manager = WireManager(self._data, self._wires, self._components)
290
+ self._validation_manager = ValidationManager(self._data, self._components, self._wires)
291
+
235
292
  # Track modifications for save optimization
236
293
  self._modified = False
237
294
  self._last_save_time = None
@@ -244,7 +301,7 @@ class Schematic:
244
301
  f"Schematic initialized with {len(self._components)} components, {len(self._wires)} wires, "
245
302
  f"{len(self._junctions)} junctions, {len(self._texts)} texts, {len(self._labels)} labels, "
246
303
  f"{len(self._hierarchical_labels)} hierarchical labels, {len(self._no_connects)} no-connects, "
247
- f"and {len(self._nets)} nets"
304
+ f"and {len(self._nets)} nets with managers initialized"
248
305
  )
249
306
 
250
307
  @classmethod
@@ -267,8 +324,9 @@ class Schematic:
267
324
 
268
325
  logger.info(f"Loading schematic: {file_path}")
269
326
 
270
- parser = SExpressionParser(preserve_format=True)
271
- schematic_data = parser.parse_file(file_path)
327
+ # Use FileIOManager for loading
328
+ file_io_manager = FileIOManager()
329
+ schematic_data = file_io_manager.load_schematic(file_path)
272
330
 
273
331
  load_time = time.time() - start_time
274
332
  logger.info(f"Loaded schematic in {load_time:.3f}s")
@@ -311,8 +369,10 @@ class Schematic:
311
369
  "junctions": [],
312
370
  "labels": [],
313
371
  "nets": [],
314
- "lib_symbols": [], # Empty list for blank schematic
372
+ "lib_symbols": {}, # Empty dict for blank schematic
315
373
  "symbol_instances": [],
374
+ "sheet_instances": [],
375
+ "embedded_fonts": "no",
316
376
  }
317
377
  else:
318
378
  schematic_data = cls._create_empty_schematic_data()
@@ -385,6 +445,7 @@ class Schematic:
385
445
  or self._hierarchical_labels._modified
386
446
  or self._no_connects._modified
387
447
  or self._nets._modified
448
+ or self._format_sync_manager.is_dirty()
388
449
  )
389
450
 
390
451
  @property
@@ -412,13 +473,11 @@ class Schematic:
412
473
  """Collection of all electrical nets in the schematic."""
413
474
  return self._nets
414
475
 
415
- # Pin positioning methods (migrated from circuit-synth)
476
+ # Pin positioning methods (delegated to WireManager)
416
477
  def get_component_pin_position(self, reference: str, pin_number: str) -> Optional[Point]:
417
478
  """
418
479
  Get the absolute position of a component pin.
419
480
 
420
- Migrated from circuit-synth with enhanced logging for verification.
421
-
422
481
  Args:
423
482
  reference: Component reference (e.g., "R1")
424
483
  pin_number: Pin number to find (e.g., "1", "2")
@@ -426,20 +485,7 @@ class Schematic:
426
485
  Returns:
427
486
  Absolute position of the pin, or None if not found
428
487
  """
429
- from .pin_utils import get_component_pin_position
430
-
431
- # Find the component
432
- component = None
433
- for comp in self._components:
434
- if comp.reference == reference:
435
- component = comp
436
- break
437
-
438
- if not component:
439
- logger.warning(f"Component {reference} not found")
440
- return None
441
-
442
- return get_component_pin_position(component, pin_number)
488
+ return self._wire_manager.get_component_pin_position(reference, pin_number)
443
489
 
444
490
  def list_component_pins(self, reference: str) -> List[Tuple[str, Point]]:
445
491
  """
@@ -451,22 +497,9 @@ class Schematic:
451
497
  Returns:
452
498
  List of (pin_number, absolute_position) tuples
453
499
  """
454
- from .pin_utils import list_component_pins
455
-
456
- # Find the component
457
- component = None
458
- for comp in self._components:
459
- if comp.reference == reference:
460
- component = comp
461
- break
462
-
463
- if not component:
464
- logger.warning(f"Component {reference} not found")
465
- return []
500
+ return self._wire_manager.list_component_pins(reference)
466
501
 
467
- return list_component_pins(component)
468
-
469
- # File operations
502
+ # File operations (delegated to FileIOManager)
470
503
  def save(self, file_path: Optional[Union[str, Path]] = None, preserve_format: bool = True):
471
504
  """
472
505
  Save schematic to file.
@@ -495,31 +528,26 @@ class Schematic:
495
528
  if errors:
496
529
  raise ValidationError("Cannot save schematic with validation errors", errors)
497
530
 
498
- # Update data structure with current component, wire, and junction state
531
+ # Sync collection state back to data structure (critical for save)
499
532
  self._sync_components_to_data()
500
533
  self._sync_wires_to_data()
501
534
  self._sync_junctions_to_data()
535
+ self._sync_texts_to_data()
536
+ self._sync_labels_to_data()
537
+ self._sync_hierarchical_labels_to_data()
538
+ self._sync_no_connects_to_data()
539
+ self._sync_nets_to_data()
502
540
 
503
- # Write file
504
- if preserve_format and self._original_content:
505
- # Use format-preserving writer
506
- sexp_data = self._parser._schematic_data_to_sexp(self._data)
507
- content = self._formatter.format_preserving_write(sexp_data, self._original_content)
508
- else:
509
- # Standard formatting
510
- sexp_data = self._parser._schematic_data_to_sexp(self._data)
511
- content = self._formatter.format(sexp_data)
512
-
513
- # Ensure directory exists
514
- file_path.parent.mkdir(parents=True, exist_ok=True)
541
+ # Ensure FileIOManager's parser has the correct project name
542
+ self._file_io_manager._parser.project_name = self.name
515
543
 
516
- # Write to file
517
- with open(file_path, "w", encoding="utf-8") as f:
518
- f.write(content)
544
+ # Use FileIOManager for saving
545
+ self._file_io_manager.save_schematic(self._data, file_path, preserve_format)
519
546
 
520
547
  # Update state
521
548
  self._modified = False
522
549
  self._components._modified = False
550
+ self._format_sync_manager.clear_dirty_flags()
523
551
  self._last_save_time = time.time()
524
552
 
525
553
  save_time = time.time() - start_time
@@ -534,121 +562,22 @@ class Schematic:
534
562
  Create a backup of the current schematic file.
535
563
 
536
564
  Args:
537
- suffix: Suffix to add to backup filename
565
+ suffix: Backup file suffix
538
566
 
539
567
  Returns:
540
568
  Path to backup file
541
569
  """
542
- if not self._file_path:
543
- raise ValidationError("Cannot backup - no file path set")
544
-
545
- backup_path = self._file_path.with_suffix(self._file_path.suffix + suffix)
546
-
547
- if self._file_path.exists():
548
- import shutil
549
-
550
- shutil.copy2(self._file_path, backup_path)
551
- logger.info(f"Created backup: {backup_path}")
552
-
553
- return backup_path
554
-
555
- # Validation and analysis
556
- def validate(self) -> List[ValidationIssue]:
557
- """
558
- Validate the schematic for errors and issues.
559
-
560
- Returns:
561
- List of validation issues found
562
- """
563
- # Sync current state to data for validation
564
- self._sync_components_to_data()
565
-
566
- # Use validator to check schematic
567
- issues = self._validator.validate_schematic_data(self._data)
568
-
569
- # Add component-level validation
570
- component_issues = self._components.validate_all()
571
- issues.extend(component_issues)
572
-
573
- return issues
574
-
575
- # Focused helper functions for specific KiCAD sections
576
- def add_lib_symbols_section(self, lib_symbols: Dict[str, Any]):
577
- """Add or update lib_symbols section with specific symbol definitions."""
578
- self._data["lib_symbols"] = lib_symbols
579
- self._modified = True
580
-
581
- def add_instances_section(self, instances: Dict[str, Any]):
582
- """Add instances section for component placement tracking."""
583
- self._data["instances"] = instances
584
- self._modified = True
585
-
586
- def add_sheet_instances_section(self, sheet_instances: List[Dict]):
587
- """Add sheet_instances section for hierarchical design."""
588
- self._data["sheet_instances"] = sheet_instances
589
- self._modified = True
590
-
591
- def set_paper_size(self, paper: str):
592
- """Set paper size (A4, A3, etc.)."""
593
- self._data["paper"] = paper
594
- self._modified = True
595
-
596
- def set_version_info(
597
- self, version: str, generator: str = "eeschema", generator_version: str = "9.0"
598
- ):
599
- """Set version and generator information."""
600
- self._data["version"] = version
601
- self._data["generator"] = generator
602
- self._data["generator_version"] = generator_version
603
- self._modified = True
570
+ if self._file_path is None:
571
+ raise ValidationError("Cannot backup schematic with no file path")
604
572
 
605
- def copy_metadata_from(self, source_schematic: "Schematic"):
606
- """Copy all metadata from another schematic (version, generator, paper, etc.)."""
607
- metadata_fields = [
608
- "version",
609
- "generator",
610
- "generator_version",
611
- "paper",
612
- "uuid",
613
- "title_block",
614
- ]
615
- for field in metadata_fields:
616
- if field in source_schematic._data:
617
- self._data[field] = source_schematic._data[field]
618
- self._modified = True
619
-
620
- def get_summary(self) -> Dict[str, Any]:
621
- """Get summary information about the schematic."""
622
- component_stats = self._components.get_statistics()
623
-
624
- return {
625
- "file_path": str(self._file_path) if self._file_path else None,
626
- "version": self.version,
627
- "uuid": self.uuid,
628
- "title": self.title_block.get("title", ""),
629
- "component_count": len(self._components),
630
- "modified": self.modified,
631
- "last_save": self._last_save_time,
632
- "component_stats": component_stats,
633
- "performance": {
634
- "operation_count": self._operation_count,
635
- "avg_operation_time_ms": round(
636
- (
637
- (self._total_operation_time / self._operation_count * 1000)
638
- if self._operation_count > 0
639
- else 0
640
- ),
641
- 2,
642
- ),
643
- },
644
- }
573
+ return self._file_io_manager.create_backup(self._file_path, suffix)
645
574
 
646
- # Wire and connection management (basic implementation)
575
+ # Wire operations (delegated to WireManager)
647
576
  def add_wire(
648
577
  self, start: Union[Point, Tuple[float, float]], end: Union[Point, Tuple[float, float]]
649
578
  ) -> str:
650
579
  """
651
- Add a wire connection.
580
+ Add a wire connection between two points.
652
581
 
653
582
  Args:
654
583
  start: Start point
@@ -657,489 +586,149 @@ class Schematic:
657
586
  Returns:
658
587
  UUID of created wire
659
588
  """
660
- if isinstance(start, tuple):
661
- start = Point(start[0], start[1])
662
- if isinstance(end, tuple):
663
- end = Point(end[0], end[1])
664
-
665
- # Use the wire collection to add the wire
666
- wire_uuid = self._wires.add(start=start, end=end)
589
+ wire_uuid = self._wire_manager.add_wire(start, end)
590
+ self._format_sync_manager.mark_dirty("wire", "add", {"uuid": wire_uuid})
667
591
  self._modified = True
668
-
669
- logger.debug(f"Added wire: {start} -> {end}")
670
592
  return wire_uuid
671
593
 
672
594
  def remove_wire(self, wire_uuid: str) -> bool:
673
- """Remove wire by UUID."""
674
- # Remove from wire collection
675
- removed_from_collection = self._wires.remove(wire_uuid)
676
-
677
- # Also remove from data structure for consistency
678
- wires = self._data.get("wires", [])
679
- removed_from_data = False
680
- for i, wire in enumerate(wires):
681
- if wire.get("uuid") == wire_uuid:
682
- del wires[i]
683
- removed_from_data = True
684
- break
685
-
686
- if removed_from_collection or removed_from_data:
595
+ """
596
+ Remove a wire by UUID.
597
+
598
+ Args:
599
+ wire_uuid: UUID of wire to remove
600
+
601
+ Returns:
602
+ True if wire was removed, False if not found
603
+ """
604
+ removed = self._wires.remove(wire_uuid)
605
+ if removed:
606
+ self._format_sync_manager.remove_wire_from_data(wire_uuid)
687
607
  self._modified = True
688
- logger.debug(f"Removed wire: {wire_uuid}")
689
- return True
690
- return False
608
+ return removed
691
609
 
692
- # Label management
693
- def add_hierarchical_label(
610
+ def auto_route_pins(
694
611
  self,
695
- text: str,
696
- position: Union[Point, Tuple[float, float]],
697
- shape: HierarchicalLabelShape = HierarchicalLabelShape.INPUT,
698
- rotation: float = 0.0,
699
- size: float = 1.27,
700
- ) -> str:
612
+ component1_ref: str,
613
+ pin1_number: str,
614
+ component2_ref: str,
615
+ pin2_number: str,
616
+ routing_strategy: str = "direct",
617
+ ) -> List[str]:
701
618
  """
702
- Add a hierarchical label.
619
+ Auto-route between two component pins.
703
620
 
704
621
  Args:
705
- text: Label text
706
- position: Label position
707
- shape: Label shape/direction
708
- rotation: Text rotation in degrees
709
- size: Font size
622
+ component1_ref: First component reference
623
+ pin1_number: First component pin number
624
+ component2_ref: Second component reference
625
+ pin2_number: Second component pin number
626
+ routing_strategy: Routing strategy ("direct", "orthogonal", "manhattan")
710
627
 
711
628
  Returns:
712
- UUID of created hierarchical label
629
+ List of wire UUIDs created
713
630
  """
714
- if isinstance(position, tuple):
715
- position = Point(position[0], position[1])
716
-
717
- label = Label(
718
- uuid=str(uuid.uuid4()),
719
- position=position,
720
- text=text,
721
- label_type=LabelType.HIERARCHICAL,
722
- rotation=rotation,
723
- size=size,
724
- shape=shape,
725
- )
726
-
727
- if "hierarchical_labels" not in self._data:
728
- self._data["hierarchical_labels"] = []
729
-
730
- self._data["hierarchical_labels"].append(
731
- {
732
- "uuid": label.uuid,
733
- "position": {"x": label.position.x, "y": label.position.y},
734
- "text": label.text,
735
- "shape": label.shape.value,
736
- "rotation": label.rotation,
737
- "size": label.size,
738
- }
631
+ wire_uuids = self._wire_manager.auto_route_pins(
632
+ component1_ref, pin1_number, component2_ref, pin2_number, routing_strategy
739
633
  )
634
+ for wire_uuid in wire_uuids:
635
+ self._format_sync_manager.mark_dirty("wire", "add", {"uuid": wire_uuid})
740
636
  self._modified = True
741
-
742
- logger.debug(f"Added hierarchical label: {text} at {position}")
743
- return label.uuid
744
-
745
- def remove_hierarchical_label(self, label_uuid: str) -> bool:
746
- """Remove hierarchical label by UUID."""
747
- labels = self._data.get("hierarchical_labels", [])
748
- for i, label in enumerate(labels):
749
- if label.get("uuid") == label_uuid:
750
- del labels[i]
751
- self._modified = True
752
- logger.debug(f"Removed hierarchical label: {label_uuid}")
753
- return True
754
- return False
637
+ return wire_uuids
755
638
 
756
639
  def add_wire_to_pin(
757
- self, start_point: Union[Point, Tuple[float, float]], component_ref: str, pin_number: str
640
+ self, start: Union[Point, Tuple[float, float]], component_ref: str, pin_number: str
758
641
  ) -> Optional[str]:
759
642
  """
760
- Draw a wire from a start point to a component pin.
643
+ Add wire from arbitrary position to component pin.
761
644
 
762
645
  Args:
763
- start_point: Starting point of the wire
764
- component_ref: Reference of the target component (e.g., "R1")
765
- pin_number: Pin number on the component (e.g., "1")
646
+ start: Start position
647
+ component_ref: Component reference
648
+ pin_number: Pin number
766
649
 
767
650
  Returns:
768
- UUID of created wire, or None if pin position cannot be determined
651
+ Wire UUID or None if pin not found
769
652
  """
770
- from .pin_utils import get_component_pin_position
771
-
772
- # Find the component
773
- component = self.components.get(component_ref)
774
- if not component:
775
- logger.warning(f"Component {component_ref} not found")
653
+ pin_pos = self.get_component_pin_position(component_ref, pin_number)
654
+ if pin_pos is None:
776
655
  return None
777
656
 
778
- # Get the pin position
779
- pin_position = get_component_pin_position(component, pin_number)
780
- if not pin_position:
781
- logger.warning(f"Could not determine position of pin {pin_number} on {component_ref}")
782
- return None
783
-
784
- # Create the wire
785
- return self.add_wire(start_point, pin_position)
657
+ return self.add_wire(start, pin_pos)
786
658
 
787
659
  def add_wire_between_pins(
788
660
  self, component1_ref: str, pin1_number: str, component2_ref: str, pin2_number: str
789
661
  ) -> Optional[str]:
790
662
  """
791
- Draw a wire between two component pins.
792
-
793
- Args:
794
- component1_ref: Reference of the first component (e.g., "R1")
795
- pin1_number: Pin number on the first component (e.g., "1")
796
- component2_ref: Reference of the second component (e.g., "R2")
797
- pin2_number: Pin number on the second component (e.g., "2")
798
-
799
- Returns:
800
- UUID of created wire, or None if either pin position cannot be determined
801
- """
802
- from .pin_utils import get_component_pin_position
803
-
804
- # Find both components
805
- component1 = self.components.get(component1_ref)
806
- component2 = self.components.get(component2_ref)
807
-
808
- if not component1:
809
- logger.warning(f"Component {component1_ref} not found")
810
- return None
811
- if not component2:
812
- logger.warning(f"Component {component2_ref} not found")
813
- return None
814
-
815
- # Get both pin positions
816
- pin1_position = get_component_pin_position(component1, pin1_number)
817
- pin2_position = get_component_pin_position(component2, pin2_number)
818
-
819
- if not pin1_position:
820
- logger.warning(f"Could not determine position of pin {pin1_number} on {component1_ref}")
821
- return None
822
- if not pin2_position:
823
- logger.warning(f"Could not determine position of pin {pin2_number} on {component2_ref}")
824
- return None
825
-
826
- # Create the wire
827
- return self.add_wire(pin1_position, pin2_position)
828
-
829
- def get_component_pin_position(self, component_ref: str, pin_number: str) -> Optional[Point]:
830
- """
831
- Get the absolute position of a component pin.
663
+ Add wire between two component pins.
832
664
 
833
665
  Args:
834
- component_ref: Reference of the component (e.g., "R1")
835
- pin_number: Pin number on the component (e.g., "1")
666
+ component1_ref: First component reference
667
+ pin1_number: First component pin number
668
+ component2_ref: Second component reference
669
+ pin2_number: Second component pin number
836
670
 
837
671
  Returns:
838
- Absolute position of the pin, or None if not found
672
+ Wire UUID or None if either pin not found
839
673
  """
840
- from .pin_utils import get_component_pin_position
674
+ pin1_pos = self.get_component_pin_position(component1_ref, pin1_number)
675
+ pin2_pos = self.get_component_pin_position(component2_ref, pin2_number)
841
676
 
842
- component = self.components.get(component_ref)
843
- if not component:
677
+ if pin1_pos is None or pin2_pos is None:
844
678
  return None
845
679
 
846
- return get_component_pin_position(component, pin_number)
680
+ return self.add_wire(pin1_pos, pin2_pos)
847
681
 
848
- # Wire routing and connectivity methods
849
- def auto_route_pins(
850
- self,
851
- comp1_ref: str,
852
- pin1_num: str,
853
- comp2_ref: str,
854
- pin2_num: str,
855
- routing_mode: str = "direct",
856
- clearance: float = 2.54,
682
+ def connect_pins_with_wire(
683
+ self, component1_ref: str, pin1_number: str, component2_ref: str, pin2_number: str
857
684
  ) -> Optional[str]:
858
685
  """
859
- Auto route between two pins with configurable routing strategies.
860
-
861
- All positions are snapped to KiCAD's 1.27mm grid for exact electrical connections.
862
-
863
- Args:
864
- comp1_ref: First component reference (e.g., 'R1')
865
- pin1_num: First component pin number (e.g., '1')
866
- comp2_ref: Second component reference (e.g., 'R2')
867
- pin2_num: Second component pin number (e.g., '2')
868
- routing_mode: Routing strategy:
869
- - "direct": Direct connection through components (default)
870
- - "manhattan": Manhattan routing with obstacle avoidance
871
- clearance: Clearance from obstacles in mm (for manhattan mode)
872
-
873
- Returns:
874
- UUID of created wire, or None if routing failed
875
- """
876
- from .wire_routing import route_pins_direct, snap_to_kicad_grid
877
-
878
- # Get pin positions
879
- pin1_pos = self.get_component_pin_position(comp1_ref, pin1_num)
880
- pin2_pos = self.get_component_pin_position(comp2_ref, pin2_num)
881
-
882
- if not pin1_pos or not pin2_pos:
883
- return None
884
-
885
- # Ensure positions are grid-snapped
886
- pin1_pos = snap_to_kicad_grid(pin1_pos)
887
- pin2_pos = snap_to_kicad_grid(pin2_pos)
888
-
889
- # Choose routing strategy
890
- if routing_mode.lower() == "manhattan":
891
- # Manhattan routing with obstacle avoidance
892
- from .simple_manhattan import auto_route_with_manhattan
893
-
894
- # Get component objects
895
- comp1 = self.components.get(comp1_ref)
896
- comp2 = self.components.get(comp2_ref)
897
-
898
- if not comp1 or not comp2:
899
- logger.warning(f"Component not found: {comp1_ref} or {comp2_ref}")
900
- return None
901
-
902
- return auto_route_with_manhattan(
903
- self,
904
- comp1,
905
- pin1_num,
906
- comp2,
907
- pin2_num,
908
- avoid_components=None, # Avoid all other components
909
- clearance=clearance,
910
- )
911
- else:
912
- # Default direct routing - just connect the pins
913
- return self.add_wire(pin1_pos, pin2_pos)
914
-
915
- def are_pins_connected(
916
- self, comp1_ref: str, pin1_num: str, comp2_ref: str, pin2_num: str
917
- ) -> bool:
918
- """
919
- Detect when two pins are connected via wire routing.
686
+ Connect two component pins with a wire (alias for add_wire_between_pins).
920
687
 
921
688
  Args:
922
- comp1_ref: First component reference (e.g., 'R1')
923
- pin1_num: First component pin number (e.g., '1')
924
- comp2_ref: Second component reference (e.g., 'R2')
925
- pin2_num: Second component pin number (e.g., '2')
689
+ component1_ref: First component reference
690
+ pin1_number: First component pin number
691
+ component2_ref: Second component reference
692
+ pin2_number: Second component pin number
926
693
 
927
694
  Returns:
928
- True if pins are connected via wires, False otherwise
695
+ Wire UUID or None if either pin not found
929
696
  """
930
- from .wire_routing import are_pins_connected
931
-
932
- return are_pins_connected(self, comp1_ref, pin1_num, comp2_ref, pin2_num)
933
-
934
- # Legacy method names for compatibility
935
- def connect_pins_with_wire(
936
- self, component1_ref: str, pin1_number: str, component2_ref: str, pin2_number: str
937
- ) -> Optional[str]:
938
- """Legacy alias for add_wire_between_pins."""
939
697
  return self.add_wire_between_pins(component1_ref, pin1_number, component2_ref, pin2_number)
940
698
 
699
+ # Text and label operations (delegated to TextElementManager)
941
700
  def add_label(
942
701
  self,
943
702
  text: str,
944
703
  position: Union[Point, Tuple[float, float]],
945
- rotation: float = 0.0,
946
- size: float = 1.27,
704
+ effects: Optional[Dict[str, Any]] = None,
705
+ rotation: float = 0,
706
+ size: Optional[float] = None,
947
707
  uuid: Optional[str] = None,
948
708
  ) -> str:
949
709
  """
950
- Add a local label.
710
+ Add a text label to the schematic.
951
711
 
952
712
  Args:
953
- text: Label text
713
+ text: Label text content
954
714
  position: Label position
955
- rotation: Text rotation in degrees
956
- size: Font size
957
- uuid: Optional UUID (auto-generated if None)
715
+ effects: Text effects (size, font, etc.)
716
+ rotation: Label rotation in degrees (default 0)
717
+ size: Text size override (default from effects)
718
+ uuid: Specific UUID for label (auto-generated if None)
958
719
 
959
720
  Returns:
960
721
  UUID of created label
961
722
  """
962
- if isinstance(position, tuple):
963
- position = Point(position[0], position[1])
964
-
965
- import uuid as uuid_module
966
-
967
- label = Label(
968
- uuid=uuid if uuid else str(uuid_module.uuid4()),
969
- position=position,
970
- text=text,
971
- label_type=LabelType.LOCAL,
972
- rotation=rotation,
973
- size=size,
974
- )
975
-
976
- if "labels" not in self._data:
977
- self._data["labels"] = []
978
-
979
- self._data["labels"].append(
980
- {
981
- "uuid": label.uuid,
982
- "position": {"x": label.position.x, "y": label.position.y},
983
- "text": label.text,
984
- "rotation": label.rotation,
985
- "size": label.size,
986
- }
987
- )
723
+ # Use the new labels collection instead of manager
724
+ if size is None:
725
+ size = 1.27 # Default size
726
+ label = self._labels.add(text, position, rotation=rotation, size=size, label_uuid=uuid)
727
+ self._sync_labels_to_data() # Sync immediately
728
+ self._format_sync_manager.mark_dirty("label", "add", {"uuid": label.uuid})
988
729
  self._modified = True
989
-
990
- logger.debug(f"Added local label: {text} at {position}")
991
730
  return label.uuid
992
731
 
993
- def remove_label(self, label_uuid: str) -> bool:
994
- """Remove local label by UUID."""
995
- labels = self._data.get("labels", [])
996
- for i, label in enumerate(labels):
997
- if label.get("uuid") == label_uuid:
998
- del labels[i]
999
- self._modified = True
1000
- logger.debug(f"Removed local label: {label_uuid}")
1001
- return True
1002
- return False
1003
-
1004
- def add_sheet(
1005
- self,
1006
- name: str,
1007
- filename: str,
1008
- position: Union[Point, Tuple[float, float]],
1009
- size: Union[Point, Tuple[float, float]],
1010
- stroke_width: float = 0.1524,
1011
- stroke_type: str = "solid",
1012
- exclude_from_sim: bool = False,
1013
- in_bom: bool = True,
1014
- on_board: bool = True,
1015
- project_name: str = "",
1016
- page_number: str = "2",
1017
- uuid: Optional[str] = None,
1018
- ) -> str:
1019
- """
1020
- Add a hierarchical sheet.
1021
-
1022
- Args:
1023
- name: Sheet name (displayed above sheet)
1024
- filename: Sheet filename (.kicad_sch file)
1025
- position: Sheet position (top-left corner)
1026
- size: Sheet size (width, height)
1027
- stroke_width: Border line width
1028
- stroke_type: Border line type
1029
- exclude_from_sim: Exclude from simulation
1030
- in_bom: Include in BOM
1031
- on_board: Include on board
1032
- project_name: Project name for instances
1033
- page_number: Page number for instances
1034
- uuid: Optional UUID (auto-generated if None)
1035
-
1036
- Returns:
1037
- UUID of created sheet
1038
- """
1039
- if isinstance(position, tuple):
1040
- position = Point(position[0], position[1])
1041
- if isinstance(size, tuple):
1042
- size = Point(size[0], size[1])
1043
-
1044
- import uuid as uuid_module
1045
-
1046
- sheet = Sheet(
1047
- uuid=uuid if uuid else str(uuid_module.uuid4()),
1048
- position=position,
1049
- size=size,
1050
- name=name,
1051
- filename=filename,
1052
- exclude_from_sim=exclude_from_sim,
1053
- in_bom=in_bom,
1054
- on_board=on_board,
1055
- stroke_width=stroke_width,
1056
- stroke_type=stroke_type,
1057
- )
1058
-
1059
- if "sheets" not in self._data:
1060
- self._data["sheets"] = []
1061
-
1062
- self._data["sheets"].append(
1063
- {
1064
- "uuid": sheet.uuid,
1065
- "position": {"x": sheet.position.x, "y": sheet.position.y},
1066
- "size": {"width": sheet.size.x, "height": sheet.size.y},
1067
- "name": sheet.name,
1068
- "filename": sheet.filename,
1069
- "exclude_from_sim": sheet.exclude_from_sim,
1070
- "in_bom": sheet.in_bom,
1071
- "on_board": sheet.on_board,
1072
- "dnp": sheet.dnp,
1073
- "fields_autoplaced": sheet.fields_autoplaced,
1074
- "stroke_width": sheet.stroke_width,
1075
- "stroke_type": sheet.stroke_type,
1076
- "fill_color": sheet.fill_color,
1077
- "pins": [], # Sheet pins added separately
1078
- "project_name": project_name,
1079
- "page_number": page_number,
1080
- }
1081
- )
1082
- self._modified = True
1083
-
1084
- logger.debug(f"Added hierarchical sheet: {name} ({filename}) at {position}")
1085
- return sheet.uuid
1086
-
1087
- def add_sheet_pin(
1088
- self,
1089
- sheet_uuid: str,
1090
- name: str,
1091
- pin_type: str = "input",
1092
- position: Union[Point, Tuple[float, float]] = (0, 0),
1093
- rotation: float = 0,
1094
- size: float = 1.27,
1095
- justify: str = "right",
1096
- uuid: Optional[str] = None,
1097
- ) -> str:
1098
- """
1099
- Add a pin to a hierarchical sheet.
1100
-
1101
- Args:
1102
- sheet_uuid: UUID of the sheet to add pin to
1103
- name: Pin name (NET1, NET2, etc.)
1104
- pin_type: Pin type (input, output, bidirectional, etc.)
1105
- position: Pin position relative to sheet
1106
- rotation: Pin rotation in degrees
1107
- size: Font size for pin label
1108
- justify: Text justification (left, right, center)
1109
- uuid: Optional UUID (auto-generated if None)
1110
-
1111
- Returns:
1112
- UUID of created sheet pin
1113
- """
1114
- if isinstance(position, tuple):
1115
- position = Point(position[0], position[1])
1116
-
1117
- import uuid as uuid_module
1118
-
1119
- pin_uuid = uuid if uuid else str(uuid_module.uuid4())
1120
-
1121
- # Find the sheet in the data
1122
- sheets = self._data.get("sheets", [])
1123
- for sheet in sheets:
1124
- if sheet.get("uuid") == sheet_uuid:
1125
- # Add pin to the sheet's pins list
1126
- pin_data = {
1127
- "uuid": pin_uuid,
1128
- "name": name,
1129
- "pin_type": pin_type,
1130
- "position": {"x": position.x, "y": position.y},
1131
- "rotation": rotation,
1132
- "size": size,
1133
- "justify": justify,
1134
- }
1135
- sheet["pins"].append(pin_data)
1136
- self._modified = True
1137
-
1138
- logger.debug(f"Added sheet pin: {name} ({pin_type}) to sheet {sheet_uuid}")
1139
- return pin_uuid
1140
-
1141
- raise ValueError(f"Sheet with UUID '{sheet_uuid}' not found")
1142
-
1143
732
  def add_text(
1144
733
  self,
1145
734
  text: str,
@@ -1147,49 +736,30 @@ class Schematic:
1147
736
  rotation: float = 0.0,
1148
737
  size: float = 1.27,
1149
738
  exclude_from_sim: bool = False,
739
+ effects: Optional[Dict[str, Any]] = None,
1150
740
  ) -> str:
1151
741
  """
1152
- Add a text element.
742
+ Add free text annotation to the schematic.
1153
743
 
1154
744
  Args:
1155
745
  text: Text content
1156
746
  position: Text position
1157
747
  rotation: Text rotation in degrees
1158
- size: Font size
1159
- exclude_from_sim: Exclude from simulation
748
+ size: Text size
749
+ exclude_from_sim: Whether to exclude from simulation
750
+ effects: Text effects
1160
751
 
1161
752
  Returns:
1162
- UUID of created text element
753
+ UUID of created text
1163
754
  """
1164
- if isinstance(position, tuple):
1165
- position = Point(position[0], position[1])
1166
-
1167
- text_element = Text(
1168
- uuid=str(uuid.uuid4()),
1169
- position=position,
1170
- text=text,
1171
- rotation=rotation,
1172
- size=size,
1173
- exclude_from_sim=exclude_from_sim,
1174
- )
1175
-
1176
- if "texts" not in self._data:
1177
- self._data["texts"] = []
1178
-
1179
- self._data["texts"].append(
1180
- {
1181
- "uuid": text_element.uuid,
1182
- "position": {"x": text_element.position.x, "y": text_element.position.y},
1183
- "text": text_element.text,
1184
- "rotation": text_element.rotation,
1185
- "size": text_element.size,
1186
- "exclude_from_sim": text_element.exclude_from_sim,
1187
- }
755
+ # Use the new texts collection instead of manager
756
+ text_elem = self._texts.add(
757
+ text, position, rotation=rotation, size=size, exclude_from_sim=exclude_from_sim
1188
758
  )
759
+ self._sync_texts_to_data() # Sync immediately
760
+ self._format_sync_manager.mark_dirty("text", "add", {"uuid": text_elem.uuid})
1189
761
  self._modified = True
1190
-
1191
- logger.debug(f"Added text: '{text}' at {position}")
1192
- return text_element.uuid
762
+ return text_elem.uuid
1193
763
 
1194
764
  def add_text_box(
1195
765
  self,
@@ -1198,44 +768,42 @@ class Schematic:
1198
768
  size: Union[Point, Tuple[float, float]],
1199
769
  rotation: float = 0.0,
1200
770
  font_size: float = 1.27,
1201
- margins: Tuple[float, float, float, float] = (0.9525, 0.9525, 0.9525, 0.9525),
1202
- stroke_width: float = 0.0,
771
+ margins: Optional[Tuple[float, float, float, float]] = None,
772
+ stroke_width: Optional[float] = None,
1203
773
  stroke_type: str = "solid",
1204
774
  fill_type: str = "none",
1205
775
  justify_horizontal: str = "left",
1206
776
  justify_vertical: str = "top",
1207
777
  exclude_from_sim: bool = False,
778
+ effects: Optional[Dict[str, Any]] = None,
779
+ stroke: Optional[Dict[str, Any]] = None,
1208
780
  ) -> str:
1209
781
  """
1210
- Add a text box element.
782
+ Add a text box with border to the schematic.
1211
783
 
1212
784
  Args:
1213
785
  text: Text content
1214
- position: Text box position (top-left corner)
1215
- size: Text box size (width, height)
786
+ position: Top-left position
787
+ size: Box size (width, height)
1216
788
  rotation: Text rotation in degrees
1217
- font_size: Font size
1218
- margins: Margins (top, right, bottom, left)
1219
- stroke_width: Border line width
1220
- stroke_type: Border line type
1221
- fill_type: Fill type (none, solid, etc.)
1222
- justify_horizontal: Horizontal text alignment
1223
- justify_vertical: Vertical text alignment
1224
- exclude_from_sim: Exclude from simulation
789
+ font_size: Text font size
790
+ margins: Box margins (top, bottom, left, right)
791
+ stroke_width: Border stroke width
792
+ stroke_type: Border stroke type (solid, dash, etc.)
793
+ fill_type: Fill type (none, outline, background)
794
+ justify_horizontal: Horizontal justification
795
+ justify_vertical: Vertical justification
796
+ exclude_from_sim: Whether to exclude from simulation
797
+ effects: Text effects (legacy)
798
+ stroke: Border stroke settings (legacy)
1225
799
 
1226
800
  Returns:
1227
- UUID of created text box element
801
+ UUID of created text box
1228
802
  """
1229
- if isinstance(position, tuple):
1230
- position = Point(position[0], position[1])
1231
- if isinstance(size, tuple):
1232
- size = Point(size[0], size[1])
1233
-
1234
- text_box = TextBox(
1235
- uuid=str(uuid.uuid4()),
803
+ text_box_uuid = self._text_element_manager.add_text_box(
804
+ text=text,
1236
805
  position=position,
1237
806
  size=size,
1238
- text=text,
1239
807
  rotation=rotation,
1240
808
  font_size=font_size,
1241
809
  margins=margins,
@@ -1245,236 +813,366 @@ class Schematic:
1245
813
  justify_horizontal=justify_horizontal,
1246
814
  justify_vertical=justify_vertical,
1247
815
  exclude_from_sim=exclude_from_sim,
816
+ effects=effects,
817
+ stroke=stroke,
1248
818
  )
1249
-
1250
- if "text_boxes" not in self._data:
1251
- self._data["text_boxes"] = []
1252
-
1253
- self._data["text_boxes"].append(
1254
- {
1255
- "uuid": text_box.uuid,
1256
- "position": {"x": text_box.position.x, "y": text_box.position.y},
1257
- "size": {"width": text_box.size.x, "height": text_box.size.y},
1258
- "text": text_box.text,
1259
- "rotation": text_box.rotation,
1260
- "font_size": text_box.font_size,
1261
- "margins": text_box.margins,
1262
- "stroke_width": text_box.stroke_width,
1263
- "stroke_type": text_box.stroke_type,
1264
- "fill_type": text_box.fill_type,
1265
- "justify_horizontal": text_box.justify_horizontal,
1266
- "justify_vertical": text_box.justify_vertical,
1267
- "exclude_from_sim": text_box.exclude_from_sim,
1268
- }
1269
- )
819
+ self._format_sync_manager.mark_dirty("text_box", "add", {"uuid": text_box_uuid})
1270
820
  self._modified = True
821
+ return text_box_uuid
1271
822
 
1272
- logger.debug(f"Added text box: '{text}' at {position} size {size}")
1273
- return text_box.uuid
1274
-
1275
- def add_image(
823
+ def add_hierarchical_label(
1276
824
  self,
825
+ text: str,
1277
826
  position: Union[Point, Tuple[float, float]],
1278
- data: str,
1279
- scale: float = 1.0,
1280
- uuid: Optional[str] = None,
827
+ shape: str = "input",
828
+ rotation: float = 0.0,
829
+ size: float = 1.27,
830
+ effects: Optional[Dict[str, Any]] = None,
1281
831
  ) -> str:
1282
832
  """
1283
- Add an image element.
833
+ Add a hierarchical label for sheet connections.
1284
834
 
1285
835
  Args:
1286
- position: Image position
1287
- data: Base64-encoded image data
1288
- scale: Image scale factor (default 1.0)
1289
- uuid: Optional UUID (auto-generated if None)
836
+ text: Label text
837
+ position: Label position
838
+ shape: Shape type (input, output, bidirectional, tri_state, passive)
839
+ rotation: Label rotation in degrees (default 0)
840
+ size: Label text size (default 1.27)
841
+ effects: Text effects
1290
842
 
1291
843
  Returns:
1292
- UUID of created image element
844
+ UUID of created hierarchical label
1293
845
  """
1294
- if isinstance(position, tuple):
1295
- position = Point(position[0], position[1])
1296
-
1297
- from .types import Image
846
+ # Use the hierarchical_labels collection
847
+ hlabel = self._hierarchical_labels.add(text, position, rotation=rotation, size=size)
848
+ self._sync_hierarchical_labels_to_data() # Sync immediately
849
+ self._format_sync_manager.mark_dirty("hierarchical_label", "add", {"uuid": hlabel.uuid})
850
+ self._modified = True
851
+ return hlabel.uuid
1298
852
 
1299
- import uuid as uuid_module
853
+ def add_global_label(
854
+ self,
855
+ text: str,
856
+ position: Union[Point, Tuple[float, float]],
857
+ shape: str = "input",
858
+ effects: Optional[Dict[str, Any]] = None,
859
+ ) -> str:
860
+ """
861
+ Add a global label for project-wide connections.
1300
862
 
1301
- image = Image(
1302
- uuid=uuid if uuid else str(uuid_module.uuid4()),
1303
- position=position,
1304
- data=data,
1305
- scale=scale,
863
+ Args:
864
+ text: Label text
865
+ position: Label position
866
+ shape: Shape type
867
+ effects: Text effects
868
+
869
+ Returns:
870
+ UUID of created global label
871
+ """
872
+ label_uuid = self._text_element_manager.add_global_label(text, position, shape, effects)
873
+ self._format_sync_manager.mark_dirty("global_label", "add", {"uuid": label_uuid})
874
+ self._modified = True
875
+ return label_uuid
876
+
877
+ def remove_label(self, label_uuid: str) -> bool:
878
+ """
879
+ Remove a label by UUID.
880
+
881
+ Args:
882
+ label_uuid: UUID of label to remove
883
+
884
+ Returns:
885
+ True if label was removed, False if not found
886
+ """
887
+ removed = self._labels.remove(label_uuid)
888
+ if removed:
889
+ self._sync_labels_to_data() # Sync immediately
890
+ self._format_sync_manager.mark_dirty("label", "remove", {"uuid": label_uuid})
891
+ self._modified = True
892
+ return removed
893
+
894
+ def remove_hierarchical_label(self, label_uuid: str) -> bool:
895
+ """
896
+ Remove a hierarchical label by UUID.
897
+
898
+ Args:
899
+ label_uuid: UUID of hierarchical label to remove
900
+
901
+ Returns:
902
+ True if hierarchical label was removed, False if not found
903
+ """
904
+ removed = self._hierarchical_labels.remove(label_uuid)
905
+ if removed:
906
+ self._sync_hierarchical_labels_to_data() # Sync immediately
907
+ self._format_sync_manager.mark_dirty(
908
+ "hierarchical_label", "remove", {"uuid": label_uuid}
909
+ )
910
+ self._modified = True
911
+ return removed
912
+
913
+ # Sheet operations (delegated to SheetManager)
914
+ def add_sheet(
915
+ self,
916
+ name: str,
917
+ filename: str,
918
+ position: Union[Point, Tuple[float, float]],
919
+ size: Union[Point, Tuple[float, float]],
920
+ stroke_width: Optional[float] = None,
921
+ stroke_type: str = "solid",
922
+ project_name: Optional[str] = None,
923
+ page_number: Optional[str] = None,
924
+ uuid: Optional[str] = None,
925
+ ) -> str:
926
+ """
927
+ Add a hierarchical sheet to the schematic.
928
+
929
+ Args:
930
+ name: Sheet name/title
931
+ filename: Referenced schematic filename
932
+ position: Sheet position (top-left corner)
933
+ size: Sheet size (width, height)
934
+ stroke_width: Border stroke width
935
+ stroke_type: Border stroke type (solid, dashed, etc.)
936
+ project_name: Project name for this sheet
937
+ page_number: Page number for this sheet
938
+ uuid: Optional UUID for the sheet
939
+
940
+ Returns:
941
+ UUID of created sheet
942
+ """
943
+ sheet_uuid = self._sheet_manager.add_sheet(
944
+ name,
945
+ filename,
946
+ position,
947
+ size,
948
+ uuid_str=uuid,
949
+ stroke_width=stroke_width,
950
+ stroke_type=stroke_type,
951
+ project_name=project_name,
952
+ page_number=page_number,
1306
953
  )
954
+ self._format_sync_manager.mark_dirty("sheet", "add", {"uuid": sheet_uuid})
955
+ self._modified = True
956
+ return sheet_uuid
1307
957
 
1308
- if "images" not in self._data:
1309
- self._data["images"] = []
958
+ def add_sheet_pin(
959
+ self,
960
+ sheet_uuid: str,
961
+ name: str,
962
+ pin_type: str,
963
+ position: Union[Point, Tuple[float, float]],
964
+ rotation: float = 0,
965
+ justify: str = "left",
966
+ uuid: Optional[str] = None,
967
+ ) -> str:
968
+ """
969
+ Add a pin to a hierarchical sheet.
1310
970
 
1311
- self._data["images"].append(
1312
- {
1313
- "uuid": image.uuid,
1314
- "position": {"x": image.position.x, "y": image.position.y},
1315
- "data": image.data,
1316
- "scale": image.scale,
1317
- }
971
+ Args:
972
+ sheet_uuid: UUID of the sheet to add pin to
973
+ name: Pin name
974
+ pin_type: Pin type (input, output, bidirectional, etc.)
975
+ position: Pin position
976
+ rotation: Pin rotation in degrees
977
+ justify: Text justification
978
+ uuid: Optional UUID for the pin
979
+
980
+ Returns:
981
+ UUID of created sheet pin
982
+ """
983
+ pin_uuid = self._sheet_manager.add_sheet_pin(
984
+ sheet_uuid, name, pin_type, position, rotation, justify, uuid_str=uuid
1318
985
  )
986
+ self._format_sync_manager.mark_dirty("sheet", "modify", {"uuid": sheet_uuid})
1319
987
  self._modified = True
988
+ return pin_uuid
1320
989
 
1321
- logger.debug(f"Added image at {position} with {len(data)} bytes of data")
1322
- return image.uuid
990
+ def remove_sheet(self, sheet_uuid: str) -> bool:
991
+ """
992
+ Remove a sheet by UUID.
993
+
994
+ Args:
995
+ sheet_uuid: UUID of sheet to remove
996
+
997
+ Returns:
998
+ True if sheet was removed, False if not found
999
+ """
1000
+ removed = self._sheet_manager.remove_sheet(sheet_uuid)
1001
+ if removed:
1002
+ self._format_sync_manager.mark_dirty("sheet", "remove", {"uuid": sheet_uuid})
1003
+ self._modified = True
1004
+ return removed
1323
1005
 
1006
+ # Graphics operations (delegated to GraphicsManager)
1324
1007
  def add_rectangle(
1325
1008
  self,
1326
1009
  start: Union[Point, Tuple[float, float]],
1327
1010
  end: Union[Point, Tuple[float, float]],
1328
- stroke_width: float = 0.0,
1329
- stroke_type: str = "default",
1330
- fill_type: str = "none"
1011
+ stroke_width: float = 0.127,
1012
+ stroke_type: str = "solid",
1013
+ fill_type: str = "none",
1014
+ stroke_color: Optional[Tuple[int, int, int, float]] = None,
1015
+ fill_color: Optional[Tuple[int, int, int, float]] = None,
1331
1016
  ) -> str:
1332
1017
  """
1333
- Add a graphical rectangle element.
1018
+ Add a rectangle to the schematic.
1334
1019
 
1335
1020
  Args:
1336
- start: Rectangle start point (top-left)
1337
- end: Rectangle end point (bottom-right)
1338
- stroke_width: Border line width
1339
- stroke_type: Border line type (default, solid, dash, dot, etc.)
1340
- fill_type: Fill type (none, solid, etc.)
1021
+ start: Top-left corner position
1022
+ end: Bottom-right corner position
1023
+ stroke_width: Line width
1024
+ stroke_type: Line type (solid, dashed, etc.)
1025
+ fill_type: Fill type (none, background, etc.)
1026
+ stroke_color: Stroke color as (r, g, b, a)
1027
+ fill_color: Fill color as (r, g, b, a)
1341
1028
 
1342
1029
  Returns:
1343
- UUID of created rectangle element
1030
+ UUID of created rectangle
1344
1031
  """
1345
- if isinstance(start, tuple):
1346
- start = Point(start[0], start[1])
1347
- if isinstance(end, tuple):
1348
- end = Point(end[0], end[1])
1032
+ # Convert individual parameters to stroke/fill dicts
1033
+ stroke = {"width": stroke_width, "type": stroke_type}
1034
+ if stroke_color:
1035
+ stroke["color"] = stroke_color
1349
1036
 
1350
- from .types import SchematicRectangle
1037
+ fill = {"type": fill_type}
1038
+ if fill_color:
1039
+ fill["color"] = fill_color
1351
1040
 
1352
- rectangle = SchematicRectangle(
1353
- uuid=str(uuid.uuid4()),
1354
- start=start,
1355
- end=end,
1356
- stroke_width=stroke_width,
1357
- stroke_type=stroke_type,
1358
- fill_type=fill_type
1359
- )
1360
-
1361
- if "rectangles" not in self._data:
1362
- self._data["rectangles"] = []
1363
-
1364
- self._data["rectangles"].append({
1365
- "uuid": rectangle.uuid,
1366
- "start": {"x": rectangle.start.x, "y": rectangle.start.y},
1367
- "end": {"x": rectangle.end.x, "y": rectangle.end.y},
1368
- "stroke_width": rectangle.stroke_width,
1369
- "stroke_type": rectangle.stroke_type,
1370
- "fill_type": rectangle.fill_type
1371
- })
1041
+ rect_uuid = self._graphics_manager.add_rectangle(start, end, stroke, fill)
1042
+ self._format_sync_manager.mark_dirty("rectangle", "add", {"uuid": rect_uuid})
1372
1043
  self._modified = True
1044
+ return rect_uuid
1045
+
1046
+ def remove_rectangle(self, rect_uuid: str) -> bool:
1047
+ """
1048
+ Remove a rectangle by UUID.
1373
1049
 
1374
- logger.debug(f"Added rectangle: {start} to {end}")
1375
- return rectangle.uuid
1050
+ Args:
1051
+ rect_uuid: UUID of rectangle to remove
1376
1052
 
1377
- def set_title_block(
1053
+ Returns:
1054
+ True if removed, False if not found
1055
+ """
1056
+ removed = self._graphics_manager.remove_rectangle(rect_uuid)
1057
+ if removed:
1058
+ self._format_sync_manager.mark_dirty("rectangle", "remove", {"uuid": rect_uuid})
1059
+ self._modified = True
1060
+ return removed
1061
+
1062
+ def add_image(
1378
1063
  self,
1379
- title: str = "",
1380
- date: str = "",
1381
- rev: str = "",
1382
- company: str = "",
1383
- comments: Optional[Dict[int, str]] = None,
1384
- ):
1064
+ position: Union[Point, Tuple[float, float]],
1065
+ scale: float = 1.0,
1066
+ data: Optional[str] = None,
1067
+ ) -> str:
1385
1068
  """
1386
- Set title block information.
1069
+ Add an image to the schematic.
1387
1070
 
1388
1071
  Args:
1389
- title: Schematic title
1390
- date: Creation/revision date
1391
- rev: Revision number
1392
- company: Company name
1393
- comments: Numbered comments (1, 2, 3, etc.)
1394
- """
1395
- if comments is None:
1396
- comments = {}
1072
+ position: Image position
1073
+ scale: Image scale factor
1074
+ data: Base64 encoded image data
1397
1075
 
1398
- self._data["title_block"] = {
1399
- "title": title,
1400
- "date": date,
1401
- "rev": rev,
1402
- "company": company,
1403
- "comments": comments,
1404
- }
1076
+ Returns:
1077
+ UUID of created image
1078
+ """
1079
+ image_uuid = self._graphics_manager.add_image(position, scale, data)
1080
+ self._format_sync_manager.mark_dirty("image", "add", {"uuid": image_uuid})
1405
1081
  self._modified = True
1082
+ return image_uuid
1083
+
1084
+ def draw_bounding_box(
1085
+ self,
1086
+ bbox,
1087
+ stroke_width: float = 0.127,
1088
+ stroke_color: str = "black",
1089
+ stroke_type: str = "solid",
1090
+ ) -> str:
1091
+ """
1092
+ Draw a bounding box rectangle around the given bounding box.
1093
+
1094
+ Args:
1095
+ bbox: BoundingBox object with min_x, min_y, max_x, max_y
1096
+ stroke_width: Line width
1097
+ stroke_color: Line color
1098
+ stroke_type: Line type
1099
+
1100
+ Returns:
1101
+ UUID of created rectangle
1102
+ """
1103
+ # Convert bounding box to rectangle coordinates
1104
+ start = (bbox.min_x, bbox.min_y)
1105
+ end = (bbox.max_x, bbox.max_y)
1406
1106
 
1407
- logger.debug(f"Set title block: {title} rev {rev}")
1107
+ return self.add_rectangle(start, end, stroke_width=stroke_width, stroke_type=stroke_type)
1408
1108
 
1409
1109
  def draw_bounding_box(
1410
1110
  self,
1411
1111
  bbox: "BoundingBox",
1412
- stroke_width: float = 0,
1413
- stroke_color: str = None,
1414
- stroke_type: str = "default",
1415
- exclude_from_sim: bool = False,
1112
+ stroke_width: float = 0.127,
1113
+ stroke_color: Optional[str] = None,
1114
+ stroke_type: str = "solid",
1416
1115
  ) -> str:
1417
1116
  """
1418
- Draw a component bounding box as a visual rectangle using KiCAD rectangle graphics.
1117
+ Draw a single bounding box as a rectangle.
1419
1118
 
1420
1119
  Args:
1421
1120
  bbox: BoundingBox to draw
1422
- stroke_width: Line width for the rectangle (0 = thin, 1 = 1mm, etc.)
1423
- stroke_color: Color name ('red', 'blue', 'green', etc.) or None for default
1424
- stroke_type: Stroke type - KiCAD supports: 'default', 'solid', 'dash', 'dot', 'dash_dot', 'dash_dot_dot'
1425
- exclude_from_sim: Exclude from simulation
1121
+ stroke_width: Line width
1122
+ stroke_color: Line color name (red, green, blue, etc.) or None
1123
+ stroke_type: Line type (solid, dashed, etc.)
1426
1124
 
1427
1125
  Returns:
1428
- UUID of created rectangle element
1126
+ UUID of created rectangle
1429
1127
  """
1430
- # Import BoundingBox type
1431
1128
  from .component_bounds import BoundingBox
1432
1129
 
1433
- rect_uuid = str(uuid.uuid4())
1434
-
1435
- # Create rectangle data structure in KiCAD dictionary format
1436
- stroke_data = {"width": stroke_width, "type": stroke_type}
1437
-
1438
- # Add color if specified
1130
+ # Convert color name to RGBA tuple if provided
1131
+ stroke_rgba = None
1439
1132
  if stroke_color:
1440
- stroke_data["color"] = stroke_color
1441
-
1442
- rectangle_data = {
1443
- "uuid": rect_uuid,
1444
- "start": {"x": bbox.min_x, "y": bbox.min_y},
1445
- "end": {"x": bbox.max_x, "y": bbox.max_y},
1446
- "stroke": stroke_data,
1447
- "fill": {"type": "none"},
1448
- }
1449
-
1450
- # Add to schematic data
1451
- if "graphics" not in self._data:
1452
- self._data["graphics"] = []
1133
+ # Simple color name to RGB mapping
1134
+ color_map = {
1135
+ "red": (255, 0, 0, 1.0),
1136
+ "green": (0, 255, 0, 1.0),
1137
+ "blue": (0, 0, 255, 1.0),
1138
+ "yellow": (255, 255, 0, 1.0),
1139
+ "cyan": (0, 255, 255, 1.0),
1140
+ "magenta": (255, 0, 255, 1.0),
1141
+ "black": (0, 0, 0, 1.0),
1142
+ "white": (255, 255, 255, 1.0),
1143
+ }
1144
+ stroke_rgba = color_map.get(stroke_color.lower(), (0, 255, 0, 1.0))
1453
1145
 
1454
- self._data["graphics"].append(rectangle_data)
1455
- self._modified = True
1146
+ # Add rectangle using the manager
1147
+ rect_uuid = self.add_rectangle(
1148
+ start=(bbox.min_x, bbox.min_y),
1149
+ end=(bbox.max_x, bbox.max_y),
1150
+ stroke_width=stroke_width,
1151
+ stroke_type=stroke_type,
1152
+ stroke_color=stroke_rgba,
1153
+ )
1456
1154
 
1457
- logger.debug(f"Drew bounding box rectangle: {bbox}")
1155
+ logger.debug(f"Drew bounding box: {bbox}")
1458
1156
  return rect_uuid
1459
1157
 
1460
1158
  def draw_component_bounding_boxes(
1461
1159
  self,
1462
1160
  include_properties: bool = False,
1463
- stroke_width: float = 0.254,
1464
- stroke_color: str = "red",
1465
- stroke_type: str = "default",
1161
+ stroke_width: float = 0.127,
1162
+ stroke_color: str = "green",
1163
+ stroke_type: str = "solid",
1466
1164
  ) -> List[str]:
1467
1165
  """
1468
- Draw bounding boxes for all components in the schematic.
1166
+ Draw bounding boxes for all components.
1469
1167
 
1470
1168
  Args:
1471
- include_properties: Include space for Reference/Value labels
1472
- stroke_width: Line width for rectangles
1473
- stroke_color: Color for rectangles
1474
- stroke_type: Stroke type for rectangles
1169
+ include_properties: Whether to include properties in bounding box
1170
+ stroke_width: Line width
1171
+ stroke_color: Line color
1172
+ stroke_type: Line type
1475
1173
 
1476
1174
  Returns:
1477
- List of UUIDs for created rectangle elements
1175
+ List of rectangle UUIDs created
1478
1176
  """
1479
1177
  from .component_bounds import get_component_bounding_box
1480
1178
 
@@ -1488,67 +1186,157 @@ class Schematic:
1488
1186
  logger.info(f"Drew {len(uuids)} component bounding boxes")
1489
1187
  return uuids
1490
1188
 
1491
- # Library management
1492
- @property
1493
- def libraries(self) -> "LibraryManager":
1494
- """Access to library management."""
1495
- if not hasattr(self, "_library_manager"):
1496
- from ..library.manager import LibraryManager
1497
-
1498
- self._library_manager = LibraryManager(self)
1499
- return self._library_manager
1500
-
1501
- # Utility methods
1502
- def clear(self):
1503
- """Clear all components, wires, and other elements."""
1504
- self._data["components"] = []
1505
- self._data["wires"] = []
1506
- self._data["junctions"] = []
1507
- self._data["labels"] = []
1508
- self._components = ComponentCollection()
1189
+ # Metadata operations (delegated to MetadataManager)
1190
+ def set_title_block(
1191
+ self,
1192
+ title: str = "",
1193
+ date: str = "",
1194
+ rev: str = "",
1195
+ company: str = "",
1196
+ comments: Optional[Dict[int, str]] = None,
1197
+ ) -> None:
1198
+ """
1199
+ Set title block information.
1200
+
1201
+ Args:
1202
+ title: Schematic title
1203
+ date: Date
1204
+ rev: Revision
1205
+ company: Company name
1206
+ comments: Comment fields (1-9)
1207
+ """
1208
+ self._metadata_manager.set_title_block(title, date, rev, company, comments)
1209
+ self._format_sync_manager.mark_dirty("title_block", "update")
1509
1210
  self._modified = True
1510
- logger.info("Cleared schematic")
1511
1211
 
1512
- def clone(self, new_name: Optional[str] = None) -> "Schematic":
1513
- """Create a copy of this schematic."""
1514
- import copy
1212
+ def set_paper_size(self, paper: str) -> None:
1213
+ """
1214
+ Set paper size for the schematic.
1215
+
1216
+ Args:
1217
+ paper: Paper size (A4, A3, etc.)
1218
+ """
1219
+ self._metadata_manager.set_paper_size(paper)
1220
+ self._format_sync_manager.mark_dirty("paper", "update")
1221
+ self._modified = True
1222
+
1223
+ # Validation (enhanced with ValidationManager)
1224
+ def validate(self) -> List[ValidationIssue]:
1225
+ """
1226
+ Perform comprehensive schematic validation.
1227
+
1228
+ Returns:
1229
+ List of validation issues found
1230
+ """
1231
+ # Use the new ValidationManager for comprehensive validation
1232
+ manager_issues = self._validation_manager.validate_schematic()
1515
1233
 
1516
- cloned_data = copy.deepcopy(self._data)
1234
+ # Also run legacy validator for compatibility
1235
+ try:
1236
+ legacy_issues = self._legacy_validator.validate_schematic_data(self._data)
1237
+ except Exception as e:
1238
+ logger.warning(f"Legacy validator failed: {e}")
1239
+ legacy_issues = []
1517
1240
 
1518
- if new_name:
1519
- cloned_data["title_block"]["title"] = new_name
1520
- cloned_data["uuid"] = str(uuid.uuid4()) # New UUID for clone
1241
+ # Combine issues (remove duplicates based on message)
1242
+ all_issues = manager_issues + legacy_issues
1243
+ unique_issues = []
1244
+ seen_messages = set()
1521
1245
 
1522
- return Schematic(cloned_data)
1246
+ for issue in all_issues:
1247
+ if issue.message not in seen_messages:
1248
+ unique_issues.append(issue)
1249
+ seen_messages.add(issue.message)
1523
1250
 
1524
- # Performance optimization
1525
- def rebuild_indexes(self):
1526
- """Rebuild internal indexes for performance."""
1527
- # This would rebuild component indexes, etc.
1528
- logger.info("Rebuilt schematic indexes")
1251
+ return unique_issues
1529
1252
 
1530
- def get_performance_stats(self) -> Dict[str, Any]:
1531
- """Get performance statistics."""
1532
- cache_stats = get_symbol_cache().get_performance_stats()
1253
+ def get_validation_summary(self) -> Dict[str, Any]:
1254
+ """
1255
+ Get validation summary statistics.
1256
+
1257
+ Returns:
1258
+ Summary dictionary with counts and severity
1259
+ """
1260
+ issues = self.validate()
1261
+ return self._validation_manager.get_validation_summary(issues)
1533
1262
 
1263
+ # Statistics and information
1264
+ def get_statistics(self) -> Dict[str, Any]:
1265
+ """Get comprehensive schematic statistics."""
1534
1266
  return {
1535
- "schematic": {
1267
+ "components": len(self._components),
1268
+ "wires": len(self._wires),
1269
+ "junctions": len(self._junctions),
1270
+ "text_elements": self._text_element_manager.get_text_statistics(),
1271
+ "graphics": self._graphics_manager.get_graphics_statistics(),
1272
+ "sheets": self._sheet_manager.get_sheet_statistics(),
1273
+ "performance": {
1536
1274
  "operation_count": self._operation_count,
1537
- "total_operation_time_s": round(self._total_operation_time, 3),
1538
- "avg_operation_time_ms": round(
1539
- (
1540
- (self._total_operation_time / self._operation_count * 1000)
1541
- if self._operation_count > 0
1542
- else 0
1543
- ),
1544
- 2,
1545
- ),
1275
+ "total_operation_time": self._total_operation_time,
1276
+ "modified": self.modified,
1277
+ "last_save_time": self._last_save_time,
1546
1278
  },
1547
- "components": self._components.get_statistics(),
1548
- "symbol_cache": cache_stats,
1549
1279
  }
1550
1280
 
1551
1281
  # Internal methods
1282
+ @staticmethod
1283
+ def _create_empty_schematic_data() -> Dict[str, Any]:
1284
+ """Create empty schematic data structure."""
1285
+ return {
1286
+ "version": "20250114",
1287
+ "generator": "eeschema",
1288
+ "generator_version": "9.0",
1289
+ "paper": "A4",
1290
+ "lib_symbols": {},
1291
+ "symbol": [],
1292
+ "wire": [],
1293
+ "junction": [],
1294
+ "label": [],
1295
+ "hierarchical_label": [],
1296
+ "global_label": [],
1297
+ "text": [],
1298
+ "sheet": [],
1299
+ "rectangle": [],
1300
+ "circle": [],
1301
+ "arc": [],
1302
+ "polyline": [],
1303
+ "image": [],
1304
+ "symbol_instances": [],
1305
+ "sheet_instances": [],
1306
+ "embedded_fonts": "no",
1307
+ "components": [],
1308
+ "wires": [],
1309
+ "junctions": [],
1310
+ "labels": [],
1311
+ "nets": [],
1312
+ }
1313
+
1314
+ # Context manager support for atomic operations
1315
+ def __enter__(self):
1316
+ """Enter atomic operation context."""
1317
+ # Create backup for rollback
1318
+ if self._file_path and self._file_path.exists():
1319
+ self._backup_path = self._file_io_manager.create_backup(
1320
+ self._file_path, ".atomic_backup"
1321
+ )
1322
+ return self
1323
+
1324
+ def __exit__(self, exc_type, exc_val, exc_tb):
1325
+ """Exit atomic operation context."""
1326
+ if exc_type is not None:
1327
+ # Exception occurred - rollback if possible
1328
+ if hasattr(self, "_backup_path") and self._backup_path.exists():
1329
+ logger.warning("Exception in atomic operation - rolling back")
1330
+ # Restore from backup
1331
+ restored_data = self._file_io_manager.load_schematic(self._backup_path)
1332
+ self._data = restored_data
1333
+ self._modified = True
1334
+ else:
1335
+ # Success - clean up backup
1336
+ if hasattr(self, "_backup_path") and self._backup_path.exists():
1337
+ self._backup_path.unlink()
1338
+
1339
+ # Internal sync methods (migrated from original implementation)
1552
1340
  def _sync_components_to_data(self):
1553
1341
  """Sync component collection state back to data structure."""
1554
1342
  self._data["components"] = [comp._data.__dict__ for comp in self._components]
@@ -1559,112 +1347,23 @@ class Schematic:
1559
1347
 
1560
1348
  for comp in self._components:
1561
1349
  if comp.lib_id and comp.lib_id not in lib_symbols:
1562
- logger.debug(f"🔧 SCHEMATIC: Processing component {comp.lib_id}")
1563
-
1564
1350
  # Get the actual symbol definition
1565
1351
  symbol_def = cache.get_symbol(comp.lib_id)
1352
+
1566
1353
  if symbol_def:
1567
- logger.debug(f"🔧 SCHEMATIC: Loaded symbol {comp.lib_id}")
1568
- lib_symbols[comp.lib_id] = self._convert_symbol_to_kicad_format(
1569
- symbol_def, comp.lib_id
1570
- )
1571
-
1572
- # Check if this symbol extends another symbol using multiple methods
1573
- extends_parent = None
1574
-
1575
- # Method 1: Check raw_kicad_data
1576
- if hasattr(symbol_def, "raw_kicad_data") and symbol_def.raw_kicad_data:
1577
- extends_parent = self._check_symbol_extends(symbol_def.raw_kicad_data)
1578
- logger.debug(
1579
- f"🔧 SCHEMATIC: Checked raw_kicad_data for {comp.lib_id}, extends: {extends_parent}"
1580
- )
1581
-
1582
- # Method 2: Check raw_data attribute
1583
- if not extends_parent and hasattr(symbol_def, "__dict__"):
1584
- for attr_name, attr_value in symbol_def.__dict__.items():
1585
- if attr_name == "raw_data":
1586
- logger.debug(
1587
- f"🔧 SCHEMATIC: Checking raw_data for extends: {type(attr_value)}"
1588
- )
1589
- extends_parent = self._check_symbol_extends(attr_value)
1590
- if extends_parent:
1591
- logger.debug(
1592
- f"🔧 SCHEMATIC: Found extends in raw_data: {extends_parent}"
1593
- )
1594
-
1595
- # Method 3: Check the extends attribute directly
1596
- if not extends_parent and hasattr(symbol_def, "extends"):
1597
- extends_parent = symbol_def.extends
1598
- logger.debug(f"🔧 SCHEMATIC: Found extends attribute: {extends_parent}")
1599
-
1600
- if extends_parent:
1601
- # Load the parent symbol too
1602
- parent_lib_id = f"{comp.lib_id.split(':')[0]}:{extends_parent}"
1603
- logger.debug(f"🔧 SCHEMATIC: Loading parent symbol: {parent_lib_id}")
1604
-
1605
- if parent_lib_id not in lib_symbols:
1606
- parent_symbol_def = cache.get_symbol(parent_lib_id)
1607
- if parent_symbol_def:
1608
- lib_symbols[parent_lib_id] = self._convert_symbol_to_kicad_format(
1609
- parent_symbol_def, parent_lib_id
1610
- )
1611
- logger.debug(
1612
- f"🔧 SCHEMATIC: Successfully loaded parent symbol: {parent_lib_id} for {comp.lib_id}"
1613
- )
1614
- else:
1615
- logger.warning(
1616
- f"🔧 SCHEMATIC: Failed to load parent symbol: {parent_lib_id}"
1617
- )
1618
- else:
1619
- logger.debug(
1620
- f"🔧 SCHEMATIC: Parent symbol {parent_lib_id} already loaded"
1621
- )
1622
- else:
1623
- logger.debug(f"🔧 SCHEMATIC: No extends found for {comp.lib_id}")
1624
- else:
1625
- # Fallback for unknown symbols
1626
- logger.warning(
1627
- f"🔧 SCHEMATIC: Failed to load symbol {comp.lib_id}, using fallback"
1628
- )
1629
- lib_symbols[comp.lib_id] = {"definition": "basic"}
1354
+ converted_symbol = self._convert_symbol_to_kicad_format(symbol_def, comp.lib_id)
1355
+ lib_symbols[comp.lib_id] = converted_symbol
1630
1356
 
1631
1357
  self._data["lib_symbols"] = lib_symbols
1632
1358
 
1633
- # Debug: Log the final lib_symbols structure
1634
- logger.debug(f"🔧 FINAL: lib_symbols contains {len(lib_symbols)} symbols:")
1635
- for sym_id in lib_symbols.keys():
1636
- logger.debug(f"🔧 FINAL: - {sym_id}")
1637
- # Check if this symbol has extends
1638
- sym_data = lib_symbols[sym_id]
1639
- if isinstance(sym_data, list) and len(sym_data) > 2:
1640
- for item in sym_data[1:]:
1641
- if isinstance(item, list) and len(item) >= 2:
1642
- if item[0] == sexpdata.Symbol("extends"):
1643
- logger.debug(f"🔧 FINAL: - {sym_id} extends {item[1]}")
1644
- break
1645
-
1646
- def _check_symbol_extends(self, symbol_data: Any) -> Optional[str]:
1647
- """Check if symbol extends another symbol and return parent name."""
1648
- logger.debug(f"🔧 EXTENDS: Checking symbol data type: {type(symbol_data)}")
1359
+ # Update sheet instances
1360
+ if not self._data["sheet_instances"]:
1361
+ self._data["sheet_instances"] = [{"path": "/", "page": "1"}]
1649
1362
 
1650
- if not isinstance(symbol_data, list):
1651
- logger.debug(f"🔧 EXTENDS: Not a list, returning None")
1652
- return None
1653
-
1654
- logger.debug(f"🔧 EXTENDS: Checking {len(symbol_data)} items for extends directive")
1655
-
1656
- for i, item in enumerate(symbol_data[1:], 1):
1657
- logger.debug(
1658
- f"🔧 EXTENDS: Item {i}: {type(item)} - {item if not isinstance(item, list) else f'list[{len(item)}]'}"
1659
- )
1660
- if isinstance(item, list) and len(item) >= 2:
1661
- if item[0] == sexpdata.Symbol("extends"):
1662
- parent_name = str(item[1]).strip('"')
1663
- logger.debug(f"🔧 EXTENDS: Found extends directive: {parent_name}")
1664
- return parent_name
1665
-
1666
- logger.debug(f"🔧 EXTENDS: No extends directive found")
1667
- return None
1363
+ # Remove symbol_instances section - instances are stored within each symbol in lib_symbols
1364
+ # This matches KiCAD's format where instances are part of the symbol definition
1365
+ if "symbol_instances" in self._data:
1366
+ del self._data["symbol_instances"]
1668
1367
 
1669
1368
  def _sync_wires_to_data(self):
1670
1369
  """Sync wire collection state back to data structure."""
@@ -1695,156 +1394,153 @@ class Schematic:
1695
1394
 
1696
1395
  self._data["junctions"] = junction_data
1697
1396
 
1698
- def _convert_symbol_to_kicad_format(
1699
- self, symbol: "SymbolDefinition", lib_id: str
1700
- ) -> Dict[str, Any]:
1701
- """Convert SymbolDefinition to KiCAD lib_symbols format using raw parsed data."""
1702
- # If we have raw KiCAD data from the library file, use it directly
1703
- if hasattr(symbol, "raw_kicad_data") and symbol.raw_kicad_data:
1704
- return self._convert_raw_symbol_data(symbol.raw_kicad_data, lib_id)
1705
-
1706
- # Fallback: create basic symbol structure
1707
- return {
1708
- "pin_numbers": {"hide": "yes"},
1709
- "pin_names": {"offset": 0},
1710
- "exclude_from_sim": "no",
1711
- "in_bom": "yes",
1712
- "on_board": "yes",
1713
- "properties": {
1714
- "Reference": {
1715
- "value": symbol.reference_prefix,
1716
- "at": [2.032, 0, 90],
1717
- "effects": {"font": {"size": [1.27, 1.27]}},
1718
- },
1719
- "Value": {
1720
- "value": symbol.reference_prefix,
1721
- "at": [0, 0, 90],
1722
- "effects": {"font": {"size": [1.27, 1.27]}},
1723
- },
1724
- "Footprint": {
1725
- "value": "",
1726
- "at": [-1.778, 0, 90],
1727
- "effects": {"font": {"size": [1.27, 1.27]}, "hide": "yes"},
1728
- },
1729
- "Datasheet": {
1730
- "value": getattr(symbol, "Datasheet", None)
1731
- or getattr(symbol, "datasheet", None)
1732
- or "~",
1733
- "at": [0, 0, 0],
1734
- "effects": {"font": {"size": [1.27, 1.27]}, "hide": "yes"},
1735
- },
1736
- "Description": {
1737
- "value": getattr(symbol, "Description", None)
1738
- or getattr(symbol, "description", None)
1739
- or "Resistor",
1740
- "at": [0, 0, 0],
1741
- "effects": {"font": {"size": [1.27, 1.27]}, "hide": "yes"},
1397
+ def _sync_texts_to_data(self):
1398
+ """Sync text collection state back to data structure."""
1399
+ text_data = []
1400
+ for text_element in self._texts:
1401
+ text_dict = {
1402
+ "uuid": text_element.uuid,
1403
+ "text": text_element.text,
1404
+ "position": {"x": text_element.position.x, "y": text_element.position.y},
1405
+ "rotation": text_element.rotation,
1406
+ "size": text_element.size,
1407
+ "exclude_from_sim": text_element.exclude_from_sim,
1408
+ }
1409
+ text_data.append(text_dict)
1410
+
1411
+ self._data["texts"] = text_data
1412
+
1413
+ def _sync_labels_to_data(self):
1414
+ """Sync label collection state back to data structure."""
1415
+ label_data = []
1416
+ for label_element in self._labels:
1417
+ label_dict = {
1418
+ "uuid": label_element.uuid,
1419
+ "text": label_element.text,
1420
+ "position": {"x": label_element.position.x, "y": label_element.position.y},
1421
+ "rotation": label_element.rotation,
1422
+ "size": label_element.size,
1423
+ }
1424
+ label_data.append(label_dict)
1425
+
1426
+ self._data["labels"] = label_data
1427
+
1428
+ def _sync_hierarchical_labels_to_data(self):
1429
+ """Sync hierarchical label collection state back to data structure."""
1430
+ hierarchical_label_data = []
1431
+ for hlabel_element in self._hierarchical_labels:
1432
+ hlabel_dict = {
1433
+ "uuid": hlabel_element.uuid,
1434
+ "text": hlabel_element.text,
1435
+ "position": {"x": hlabel_element.position.x, "y": hlabel_element.position.y},
1436
+ "rotation": hlabel_element.rotation,
1437
+ "size": hlabel_element.size,
1438
+ }
1439
+ hierarchical_label_data.append(hlabel_dict)
1440
+
1441
+ self._data["hierarchical_labels"] = hierarchical_label_data
1442
+
1443
+ def _sync_no_connects_to_data(self):
1444
+ """Sync no-connect collection state back to data structure."""
1445
+ no_connect_data = []
1446
+ for no_connect_element in self._no_connects:
1447
+ no_connect_dict = {
1448
+ "uuid": no_connect_element.uuid,
1449
+ "position": {
1450
+ "x": no_connect_element.position.x,
1451
+ "y": no_connect_element.position.y,
1742
1452
  },
1743
- },
1744
- "embedded_fonts": "no",
1745
- }
1453
+ }
1454
+ no_connect_data.append(no_connect_dict)
1455
+
1456
+ self._data["no_connects"] = no_connect_data
1457
+
1458
+ def _sync_nets_to_data(self):
1459
+ """Sync net collection state back to data structure."""
1460
+ net_data = []
1461
+ for net_element in self._nets:
1462
+ net_dict = {
1463
+ "name": net_element.name,
1464
+ "components": net_element.components,
1465
+ "wires": net_element.wires,
1466
+ "labels": net_element.labels,
1467
+ }
1468
+ net_data.append(net_dict)
1746
1469
 
1747
- def _convert_raw_symbol_data(self, raw_data: List, lib_id: str) -> Dict[str, Any]:
1748
- """Convert raw parsed KiCAD symbol data to dictionary format for S-expression generation."""
1749
- import copy
1470
+ self._data["nets"] = net_data
1750
1471
 
1751
- import sexpdata
1472
+ def _convert_symbol_to_kicad_format(self, symbol_def, lib_id: str):
1473
+ """Convert symbol definition to KiCAD format."""
1474
+ # Use raw data if available, but fix the symbol name to use full lib_id
1475
+ if hasattr(symbol_def, "raw_kicad_data") and symbol_def.raw_kicad_data:
1476
+ raw_data = symbol_def.raw_kicad_data
1752
1477
 
1753
- # Make a copy and fix symbol name and string/symbol issues
1754
- modified_data = copy.deepcopy(raw_data)
1478
+ # Check if raw data already contains instances with project info
1479
+ project_refs_found = []
1755
1480
 
1756
- # Replace the symbol name with the full lib_id
1757
- if len(modified_data) >= 2:
1758
- modified_data[1] = lib_id # Change 'R' to 'Device:R'
1481
+ def find_project_refs(data, path="root"):
1482
+ if isinstance(data, list):
1483
+ for i, item in enumerate(data):
1484
+ if hasattr(item, "__str__") and str(item) == "project":
1485
+ if i < len(data) - 1:
1486
+ project_refs_found.append(f"{path}[{i}] = '{data[i+1]}'")
1487
+ elif isinstance(item, list):
1488
+ find_project_refs(item, f"{path}[{i}]")
1759
1489
 
1760
- # Fix extends directive to use full lib_id
1761
- logger.debug(f"🔧 CONVERT: Processing {len(modified_data)} items for {lib_id}")
1762
- for i, item in enumerate(modified_data[1:], 1):
1763
- if isinstance(item, list) and len(item) >= 2:
1764
- logger.debug(
1765
- f"🔧 CONVERT: Item {i}: {item[0]} = {item[1] if len(item) > 1 else 'N/A'}"
1766
- )
1767
- if item[0] == sexpdata.Symbol("extends"):
1768
- # Convert bare symbol name to full lib_id
1769
- parent_name = str(item[1]).strip('"')
1770
- parent_lib_id = f"{lib_id.split(':')[0]}:{parent_name}"
1771
- modified_data[i][1] = parent_lib_id
1772
- logger.debug(
1773
- f"🔧 CONVERT: Fixed extends directive: {parent_name} -> {parent_lib_id}"
1774
- )
1775
- break
1776
-
1777
- # Fix string/symbol conversion issues in pin definitions
1778
- self._fix_symbol_strings_recursively(modified_data)
1779
-
1780
- return modified_data
1781
-
1782
- def _fix_symbol_strings_recursively(self, data):
1783
- """Recursively fix string/symbol issues in parsed S-expression data."""
1784
- import sexpdata
1785
-
1786
- if isinstance(data, list):
1787
- for i, item in enumerate(data):
1788
- if isinstance(item, list):
1789
- # Check for pin definitions that need fixing
1790
- if len(item) >= 3 and item[0] == sexpdata.Symbol("pin"):
1791
- # Fix pin type and shape - ensure they are symbols not strings
1792
- if isinstance(item[1], str):
1793
- item[1] = sexpdata.Symbol(item[1]) # pin type: "passive" -> passive
1794
- if len(item) >= 3 and isinstance(item[2], str):
1795
- item[2] = sexpdata.Symbol(item[2]) # pin shape: "line" -> line
1796
-
1797
- # Recursively process nested lists
1798
- self._fix_symbol_strings_recursively(item)
1799
- elif isinstance(item, str):
1800
- # Fix common KiCAD keywords that should be symbols
1801
- if item in ["yes", "no", "default", "none", "left", "right", "center"]:
1802
- data[i] = sexpdata.Symbol(item)
1803
-
1804
- return data
1490
+ find_project_refs(raw_data)
1805
1491
 
1806
- @staticmethod
1807
- def _create_empty_schematic_data() -> Dict[str, Any]:
1808
- """Create empty schematic data structure."""
1492
+ # Make a copy and fix the symbol name (index 1) to use full lib_id
1493
+ if isinstance(raw_data, list) and len(raw_data) > 1:
1494
+ fixed_data = raw_data.copy()
1495
+ fixed_data[1] = lib_id # Replace short name with full lib_id
1496
+
1497
+ # Also fix any project references in instances to use current project name
1498
+ self._fix_symbol_project_references(fixed_data)
1499
+
1500
+ return fixed_data
1501
+ else:
1502
+ return raw_data
1503
+
1504
+ # Fallback: create basic symbol structure
1809
1505
  return {
1810
- "version": "20250114",
1811
- "generator": "eeschema",
1812
- "generator_version": "9.0",
1813
- "uuid": str(uuid.uuid4()),
1814
- "paper": "A4",
1815
- "components": [],
1816
- "wires": [],
1817
- "junctions": [],
1818
- "labels": [],
1819
- "nets": [],
1820
- "lib_symbols": {},
1821
- "sheet_instances": [{"path": "/", "page": "1"}],
1822
- "symbol_instances": [],
1823
- "embedded_fonts": "no",
1506
+ "lib_id": lib_id,
1507
+ "symbol": symbol_def.name if hasattr(symbol_def, "name") else lib_id.split(":")[-1],
1824
1508
  }
1825
1509
 
1826
- # Context manager support for atomic operations
1827
- def __enter__(self):
1828
- """Enter atomic operation context."""
1829
- # Create backup for potential rollback
1830
- if self._file_path and self._file_path.exists():
1831
- self._backup_path = self.backup(".atomic_backup")
1832
- return self
1833
-
1834
- def __exit__(self, exc_type, exc_val, exc_tb):
1835
- """Exit atomic operation context."""
1836
- if exc_type is not None:
1837
- # Exception occurred - rollback if possible
1838
- if hasattr(self, "_backup_path") and self._backup_path.exists():
1839
- logger.warning("Exception in atomic operation - rolling back")
1840
- # Restore from backup
1841
- restored_data = self._parser.parse_file(self._backup_path)
1842
- self._data = restored_data
1843
- self._modified = True
1844
- else:
1845
- # Success - clean up backup
1846
- if hasattr(self, "_backup_path") and self._backup_path.exists():
1847
- self._backup_path.unlink()
1510
+ def _fix_symbol_project_references(self, symbol_data):
1511
+ """Fix project references in symbol instances to use current project name."""
1512
+ if not isinstance(symbol_data, list):
1513
+ return
1514
+
1515
+ # Recursively search for instances sections and update project names
1516
+ for i, element in enumerate(symbol_data):
1517
+ if isinstance(element, list):
1518
+ # Check if this is an instances section
1519
+ if (
1520
+ len(element) > 0
1521
+ and hasattr(element[0], "__str__")
1522
+ and str(element[0]) == "instances"
1523
+ ):
1524
+ # Look for project references within instances
1525
+ self._update_project_in_instances(element)
1526
+ else:
1527
+ # Recursively check nested lists
1528
+ self._fix_symbol_project_references(element)
1529
+
1530
+ def _update_project_in_instances(self, instances_element):
1531
+ """Update project name in instances element."""
1532
+ if not isinstance(instances_element, list):
1533
+ return
1534
+
1535
+ for i, element in enumerate(instances_element):
1536
+ if isinstance(element, list) and len(element) >= 2:
1537
+ # Check if this is a project element: ['project', 'old_name', ...]
1538
+ if hasattr(element[0], "__str__") and str(element[0]) == "project":
1539
+ old_name = element[1]
1540
+ element[1] = self.name # Replace with current schematic name
1541
+ else:
1542
+ # Recursively check nested elements
1543
+ self._update_project_in_instances(element)
1848
1544
 
1849
1545
  def __str__(self) -> str:
1850
1546
  """String representation."""