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