kicad-sch-api 0.3.5__py3-none-any.whl → 0.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. kicad_sch_api/__init__.py +2 -2
  2. kicad_sch_api/cli/__init__.py +45 -0
  3. kicad_sch_api/cli/base.py +302 -0
  4. kicad_sch_api/cli/bom.py +164 -0
  5. kicad_sch_api/cli/erc.py +229 -0
  6. kicad_sch_api/cli/export_docs.py +289 -0
  7. kicad_sch_api/cli/netlist.py +94 -0
  8. kicad_sch_api/cli/types.py +43 -0
  9. kicad_sch_api/collections/__init__.py +2 -2
  10. kicad_sch_api/collections/base.py +5 -7
  11. kicad_sch_api/collections/components.py +24 -12
  12. kicad_sch_api/collections/junctions.py +31 -43
  13. kicad_sch_api/collections/labels.py +19 -27
  14. kicad_sch_api/collections/wires.py +17 -18
  15. kicad_sch_api/core/collections/__init__.py +5 -0
  16. kicad_sch_api/core/collections/base.py +248 -0
  17. kicad_sch_api/core/component_bounds.py +5 -0
  18. kicad_sch_api/core/components.py +67 -45
  19. kicad_sch_api/core/config.py +85 -3
  20. kicad_sch_api/core/factories/__init__.py +5 -0
  21. kicad_sch_api/core/factories/element_factory.py +276 -0
  22. kicad_sch_api/core/formatter.py +3 -1
  23. kicad_sch_api/core/junctions.py +26 -75
  24. kicad_sch_api/core/labels.py +29 -53
  25. kicad_sch_api/core/managers/__init__.py +26 -0
  26. kicad_sch_api/core/managers/file_io.py +244 -0
  27. kicad_sch_api/core/managers/format_sync.py +501 -0
  28. kicad_sch_api/core/managers/graphics.py +579 -0
  29. kicad_sch_api/core/managers/metadata.py +269 -0
  30. kicad_sch_api/core/managers/sheet.py +454 -0
  31. kicad_sch_api/core/managers/text_elements.py +536 -0
  32. kicad_sch_api/core/managers/validation.py +475 -0
  33. kicad_sch_api/core/managers/wire.py +352 -0
  34. kicad_sch_api/core/nets.py +38 -43
  35. kicad_sch_api/core/no_connects.py +33 -55
  36. kicad_sch_api/core/parser.py +75 -1731
  37. kicad_sch_api/core/schematic.py +951 -1192
  38. kicad_sch_api/core/texts.py +28 -55
  39. kicad_sch_api/core/types.py +60 -22
  40. kicad_sch_api/core/wires.py +27 -75
  41. kicad_sch_api/geometry/font_metrics.py +3 -1
  42. kicad_sch_api/geometry/symbol_bbox.py +40 -21
  43. kicad_sch_api/interfaces/__init__.py +1 -1
  44. kicad_sch_api/interfaces/parser.py +1 -1
  45. kicad_sch_api/interfaces/repository.py +1 -1
  46. kicad_sch_api/interfaces/resolver.py +1 -1
  47. kicad_sch_api/parsers/__init__.py +2 -2
  48. kicad_sch_api/parsers/base.py +7 -10
  49. kicad_sch_api/parsers/elements/__init__.py +22 -0
  50. kicad_sch_api/parsers/elements/graphics_parser.py +564 -0
  51. kicad_sch_api/parsers/elements/label_parser.py +194 -0
  52. kicad_sch_api/parsers/elements/library_parser.py +165 -0
  53. kicad_sch_api/parsers/elements/metadata_parser.py +58 -0
  54. kicad_sch_api/parsers/elements/sheet_parser.py +352 -0
  55. kicad_sch_api/parsers/elements/symbol_parser.py +313 -0
  56. kicad_sch_api/parsers/elements/text_parser.py +250 -0
  57. kicad_sch_api/parsers/elements/wire_parser.py +242 -0
  58. kicad_sch_api/parsers/registry.py +4 -2
  59. kicad_sch_api/parsers/utils.py +80 -0
  60. kicad_sch_api/symbols/__init__.py +1 -1
  61. kicad_sch_api/symbols/cache.py +9 -12
  62. kicad_sch_api/symbols/resolver.py +20 -26
  63. kicad_sch_api/symbols/validators.py +188 -137
  64. kicad_sch_api/validation/__init__.py +25 -0
  65. kicad_sch_api/validation/erc.py +171 -0
  66. kicad_sch_api/validation/erc_models.py +203 -0
  67. kicad_sch_api/validation/pin_matrix.py +243 -0
  68. kicad_sch_api/validation/validators.py +391 -0
  69. {kicad_sch_api-0.3.5.dist-info → kicad_sch_api-0.4.1.dist-info}/METADATA +17 -9
  70. kicad_sch_api-0.4.1.dist-info/RECORD +87 -0
  71. kicad_sch_api/core/manhattan_routing.py +0 -430
  72. kicad_sch_api/core/simple_manhattan.py +0 -228
  73. kicad_sch_api/core/wire_routing.py +0 -380
  74. kicad_sch_api/parsers/label_parser.py +0 -254
  75. kicad_sch_api/parsers/symbol_parser.py +0 -227
  76. kicad_sch_api/parsers/wire_parser.py +0 -99
  77. kicad_sch_api-0.3.5.dist-info/RECORD +0 -58
  78. {kicad_sch_api-0.3.5.dist-info → kicad_sch_api-0.4.1.dist-info}/WHEEL +0 -0
  79. {kicad_sch_api-0.3.5.dist-info → kicad_sch_api-0.4.1.dist-info}/entry_points.txt +0 -0
  80. {kicad_sch_api-0.3.5.dist-info → kicad_sch_api-0.4.1.dist-info}/licenses/LICENSE +0 -0
  81. {kicad_sch_api-0.3.5.dist-info → kicad_sch_api-0.4.1.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
@@ -16,9 +17,20 @@ import sexpdata
16
17
  from ..library.cache import get_symbol_cache
17
18
  from ..utils.validation import SchematicValidator, ValidationError, ValidationIssue
18
19
  from .components import ComponentCollection
20
+ from .factories import ElementFactory
19
21
  from .formatter import ExactFormatter
20
22
  from .junctions import JunctionCollection
21
23
  from .labels import LabelCollection
24
+ from .managers import (
25
+ FileIOManager,
26
+ FormatSyncManager,
27
+ GraphicsManager,
28
+ MetadataManager,
29
+ SheetManager,
30
+ TextElementManager,
31
+ ValidationManager,
32
+ WireManager,
33
+ )
22
34
  from .nets import NetCollection
23
35
  from .no_connects import NoConnectCollection
24
36
  from .parser import SExpressionParser
@@ -38,6 +50,7 @@ from .types import (
38
50
  TitleBlock,
39
51
  Wire,
40
52
  WireType,
53
+ point_from_dict_or_tuple,
41
54
  )
42
55
  from .wires import WireCollection
43
56
 
@@ -46,7 +59,7 @@ logger = logging.getLogger(__name__)
46
59
 
47
60
  class Schematic:
48
61
  """
49
- Professional KiCAD schematic manipulation class.
62
+ Professional KiCAD schematic manipulation class with manager-based architecture.
50
63
 
51
64
  Features:
52
65
  - Exact format preservation
@@ -55,9 +68,10 @@ class Schematic:
55
68
  - Comprehensive validation
56
69
  - Performance optimization for large schematics
57
70
  - AI agent integration via MCP
71
+ - Modular architecture with specialized managers
58
72
 
59
73
  This class provides a modern, intuitive API while maintaining exact compatibility
60
- with KiCAD's native file format.
74
+ with KiCAD's native file format through specialized manager classes.
61
75
  """
62
76
 
63
77
  def __init__(
@@ -67,7 +81,7 @@ class Schematic:
67
81
  name: Optional[str] = None,
68
82
  ):
69
83
  """
70
- Initialize schematic object.
84
+ Initialize schematic object with manager-based architecture.
71
85
 
72
86
  Args:
73
87
  schematic_data: Parsed schematic data
@@ -78,13 +92,13 @@ class Schematic:
78
92
  self._data = schematic_data or self._create_empty_schematic_data()
79
93
  self._file_path = Path(file_path) if file_path else None
80
94
  self._original_content = self._data.get("_original_content", "")
81
- self.name = name or "simple_circuit" # Store project name
95
+ self.name = name or "simple_circuit"
82
96
 
83
97
  # Initialize parser and formatter
84
98
  self._parser = SExpressionParser(preserve_format=True)
85
- self._parser.project_name = self.name # Pass project name to parser
99
+ self._parser.project_name = self.name
86
100
  self._formatter = ExactFormatter()
87
- self._validator = SchematicValidator()
101
+ self._legacy_validator = SchematicValidator() # Keep for compatibility
88
102
 
89
103
  # Initialize component collection
90
104
  component_symbols = [
@@ -95,143 +109,55 @@ class Schematic:
95
109
 
96
110
  # Initialize wire collection
97
111
  wire_data = self._data.get("wires", [])
98
- wires = []
99
- for wire_dict in wire_data:
100
- if isinstance(wire_dict, dict):
101
- # Convert dict to Wire object
102
- points = []
103
- for point_data in wire_dict.get("points", []):
104
- if isinstance(point_data, dict):
105
- points.append(Point(point_data["x"], point_data["y"]))
106
- elif isinstance(point_data, (list, tuple)):
107
- points.append(Point(point_data[0], point_data[1]))
108
- else:
109
- points.append(point_data)
110
-
111
- wire = Wire(
112
- uuid=wire_dict.get("uuid", str(uuid.uuid4())),
113
- points=points,
114
- wire_type=WireType(wire_dict.get("wire_type", "wire")),
115
- stroke_width=wire_dict.get("stroke_width", 0.0),
116
- stroke_type=wire_dict.get("stroke_type", "default"),
117
- )
118
- wires.append(wire)
112
+ wires = ElementFactory.create_wires_from_list(wire_data)
119
113
  self._wires = WireCollection(wires)
120
114
 
121
115
  # Initialize junction collection
122
116
  junction_data = self._data.get("junctions", [])
123
- junctions = []
124
- for junction_dict in junction_data:
125
- if isinstance(junction_dict, dict):
126
- # Convert dict to Junction object
127
- position = junction_dict.get("position", {"x": 0, "y": 0})
128
- if isinstance(position, dict):
129
- pos = Point(position["x"], position["y"])
130
- elif isinstance(position, (list, tuple)):
131
- pos = Point(position[0], position[1])
132
- else:
133
- pos = position
134
-
135
- junction = Junction(
136
- uuid=junction_dict.get("uuid", str(uuid.uuid4())),
137
- position=pos,
138
- diameter=junction_dict.get("diameter", 0),
139
- color=junction_dict.get("color", (0, 0, 0, 0)),
140
- )
141
- junctions.append(junction)
117
+ junctions = ElementFactory.create_junctions_from_list(junction_data)
142
118
  self._junctions = JunctionCollection(junctions)
143
119
 
144
120
  # Initialize text collection
145
121
  text_data = self._data.get("texts", [])
146
- texts = []
147
- for text_dict in text_data:
148
- if isinstance(text_dict, dict):
149
- # Convert dict to Text object
150
- position = text_dict.get("position", {"x": 0, "y": 0})
151
- if isinstance(position, dict):
152
- pos = Point(position["x"], position["y"])
153
- elif isinstance(position, (list, tuple)):
154
- pos = Point(position[0], position[1])
155
- else:
156
- pos = position
157
-
158
- text = Text(
159
- uuid=text_dict.get("uuid", str(uuid.uuid4())),
160
- position=pos,
161
- text=text_dict.get("text", ""),
162
- rotation=text_dict.get("rotation", 0.0),
163
- size=text_dict.get("size", 1.27),
164
- exclude_from_sim=text_dict.get("exclude_from_sim", False),
165
- )
166
- texts.append(text)
122
+ texts = ElementFactory.create_texts_from_list(text_data)
167
123
  self._texts = TextCollection(texts)
168
124
 
169
125
  # Initialize label collection
170
126
  label_data = self._data.get("labels", [])
171
- labels = []
172
- for label_dict in label_data:
173
- if isinstance(label_dict, dict):
174
- # Convert dict to Label object
175
- position = label_dict.get("position", {"x": 0, "y": 0})
176
- if isinstance(position, dict):
177
- pos = Point(position["x"], position["y"])
178
- elif isinstance(position, (list, tuple)):
179
- pos = Point(position[0], position[1])
180
- else:
181
- pos = position
182
-
183
- label = Label(
184
- uuid=label_dict.get("uuid", str(uuid.uuid4())),
185
- position=pos,
186
- text=label_dict.get("text", ""),
187
- label_type=LabelType(label_dict.get("label_type", "local")),
188
- rotation=label_dict.get("rotation", 0.0),
189
- size=label_dict.get("size", 1.27),
190
- shape=HierarchicalLabelShape(label_dict.get("shape")) if label_dict.get("shape") else None,
191
- )
192
- labels.append(label)
127
+ labels = ElementFactory.create_labels_from_list(label_data)
193
128
  self._labels = LabelCollection(labels)
194
129
 
195
- # Initialize hierarchical labels collection (filter from labels)
196
- hierarchical_labels = [label for label in labels if label.label_type == LabelType.HIERARCHICAL]
130
+ # Initialize hierarchical labels collection (from both labels array and hierarchical_labels array)
131
+ hierarchical_labels = [
132
+ label for label in labels if label.label_type == LabelType.HIERARCHICAL
133
+ ]
134
+
135
+ # Also load from hierarchical_labels data if present
136
+ hierarchical_label_data = self._data.get("hierarchical_labels", [])
137
+ hierarchical_labels.extend(ElementFactory.create_labels_from_list(hierarchical_label_data))
138
+
197
139
  self._hierarchical_labels = LabelCollection(hierarchical_labels)
198
140
 
199
141
  # Initialize no-connect collection
200
142
  no_connect_data = self._data.get("no_connects", [])
201
- no_connects = []
202
- for no_connect_dict in no_connect_data:
203
- if isinstance(no_connect_dict, dict):
204
- # Convert dict to NoConnect object
205
- position = no_connect_dict.get("position", {"x": 0, "y": 0})
206
- if isinstance(position, dict):
207
- pos = Point(position["x"], position["y"])
208
- elif isinstance(position, (list, tuple)):
209
- pos = Point(position[0], position[1])
210
- else:
211
- pos = position
212
-
213
- no_connect = NoConnect(
214
- uuid=no_connect_dict.get("uuid", str(uuid.uuid4())),
215
- position=pos,
216
- )
217
- no_connects.append(no_connect)
143
+ no_connects = ElementFactory.create_no_connects_from_list(no_connect_data)
218
144
  self._no_connects = NoConnectCollection(no_connects)
219
145
 
220
146
  # Initialize net collection
221
147
  net_data = self._data.get("nets", [])
222
- nets = []
223
- for net_dict in net_data:
224
- if isinstance(net_dict, dict):
225
- # Convert dict to Net object
226
- net = Net(
227
- name=net_dict.get("name", ""),
228
- components=net_dict.get("components", []),
229
- wires=net_dict.get("wires", []),
230
- labels=net_dict.get("labels", []),
231
- )
232
- nets.append(net)
148
+ nets = ElementFactory.create_nets_from_list(net_data)
233
149
  self._nets = NetCollection(nets)
234
150
 
151
+ # Initialize specialized managers
152
+ self._file_io_manager = FileIOManager()
153
+ self._format_sync_manager = FormatSyncManager(self._data)
154
+ self._graphics_manager = GraphicsManager(self._data)
155
+ self._metadata_manager = MetadataManager(self._data)
156
+ self._sheet_manager = SheetManager(self._data)
157
+ self._text_element_manager = TextElementManager(self._data)
158
+ self._wire_manager = WireManager(self._data, self._wires, self._components)
159
+ self._validation_manager = ValidationManager(self._data, self._components, self._wires)
160
+
235
161
  # Track modifications for save optimization
236
162
  self._modified = False
237
163
  self._last_save_time = None
@@ -244,7 +170,7 @@ class Schematic:
244
170
  f"Schematic initialized with {len(self._components)} components, {len(self._wires)} wires, "
245
171
  f"{len(self._junctions)} junctions, {len(self._texts)} texts, {len(self._labels)} labels, "
246
172
  f"{len(self._hierarchical_labels)} hierarchical labels, {len(self._no_connects)} no-connects, "
247
- f"and {len(self._nets)} nets"
173
+ f"and {len(self._nets)} nets with managers initialized"
248
174
  )
249
175
 
250
176
  @classmethod
@@ -267,8 +193,9 @@ class Schematic:
267
193
 
268
194
  logger.info(f"Loading schematic: {file_path}")
269
195
 
270
- parser = SExpressionParser(preserve_format=True)
271
- schematic_data = parser.parse_file(file_path)
196
+ # Use FileIOManager for loading
197
+ file_io_manager = FileIOManager()
198
+ schematic_data = file_io_manager.load_schematic(file_path)
272
199
 
273
200
  load_time = time.time() - start_time
274
201
  logger.info(f"Loaded schematic in {load_time:.3f}s")
@@ -279,10 +206,10 @@ class Schematic:
279
206
  def create(
280
207
  cls,
281
208
  name: str = "Untitled",
282
- version: str = "20250114",
283
- generator: str = "eeschema",
284
- generator_version: str = "9.0",
285
- paper: str = "A4",
209
+ version: str = None,
210
+ generator: str = None,
211
+ generator_version: str = None,
212
+ paper: str = None,
286
213
  uuid: str = None,
287
214
  ) -> "Schematic":
288
215
  """
@@ -290,15 +217,23 @@ class Schematic:
290
217
 
291
218
  Args:
292
219
  name: Schematic name
293
- version: KiCAD version (default: "20250114")
294
- generator: Generator name (default: "eeschema")
295
- generator_version: Generator version (default: "9.0")
296
- paper: Paper size (default: "A4")
220
+ version: KiCAD version (default from config)
221
+ generator: Generator name (default from config)
222
+ generator_version: Generator version (default from config)
223
+ paper: Paper size (default from config)
297
224
  uuid: Specific UUID (auto-generated if None)
298
225
 
299
226
  Returns:
300
227
  New empty Schematic object
301
228
  """
229
+ # Apply config defaults for None values
230
+ from .config import config
231
+
232
+ version = version or config.file_format.version_default
233
+ generator = generator or config.file_format.generator_default
234
+ generator_version = generator_version or config.file_format.generator_version_default
235
+ paper = paper or config.paper.default
236
+
302
237
  # Special handling for blank schematic test case to match reference exactly
303
238
  if name == "Blank Schematic":
304
239
  schematic_data = {
@@ -311,8 +246,10 @@ class Schematic:
311
246
  "junctions": [],
312
247
  "labels": [],
313
248
  "nets": [],
314
- "lib_symbols": [], # Empty list for blank schematic
249
+ "lib_symbols": {}, # Empty dict for blank schematic
315
250
  "symbol_instances": [],
251
+ "sheet_instances": [],
252
+ "embedded_fonts": "no",
316
253
  }
317
254
  else:
318
255
  schematic_data = cls._create_empty_schematic_data()
@@ -385,6 +322,7 @@ class Schematic:
385
322
  or self._hierarchical_labels._modified
386
323
  or self._no_connects._modified
387
324
  or self._nets._modified
325
+ or self._format_sync_manager.is_dirty()
388
326
  )
389
327
 
390
328
  @property
@@ -412,13 +350,11 @@ class Schematic:
412
350
  """Collection of all electrical nets in the schematic."""
413
351
  return self._nets
414
352
 
415
- # Pin positioning methods (migrated from circuit-synth)
353
+ # Pin positioning methods (delegated to WireManager)
416
354
  def get_component_pin_position(self, reference: str, pin_number: str) -> Optional[Point]:
417
355
  """
418
356
  Get the absolute position of a component pin.
419
357
 
420
- Migrated from circuit-synth with enhanced logging for verification.
421
-
422
358
  Args:
423
359
  reference: Component reference (e.g., "R1")
424
360
  pin_number: Pin number to find (e.g., "1", "2")
@@ -426,20 +362,7 @@ class Schematic:
426
362
  Returns:
427
363
  Absolute position of the pin, or None if not found
428
364
  """
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)
365
+ return self._wire_manager.get_component_pin_position(reference, pin_number)
443
366
 
444
367
  def list_component_pins(self, reference: str) -> List[Tuple[str, Point]]:
445
368
  """
@@ -451,22 +374,9 @@ class Schematic:
451
374
  Returns:
452
375
  List of (pin_number, absolute_position) tuples
453
376
  """
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
377
+ return self._wire_manager.list_component_pins(reference)
462
378
 
463
- if not component:
464
- logger.warning(f"Component {reference} not found")
465
- return []
466
-
467
- return list_component_pins(component)
468
-
469
- # File operations
379
+ # File operations (delegated to FileIOManager)
470
380
  def save(self, file_path: Optional[Union[str, Path]] = None, preserve_format: bool = True):
471
381
  """
472
382
  Save schematic to file.
@@ -495,31 +405,26 @@ class Schematic:
495
405
  if errors:
496
406
  raise ValidationError("Cannot save schematic with validation errors", errors)
497
407
 
498
- # Update data structure with current component, wire, and junction state
408
+ # Sync collection state back to data structure (critical for save)
499
409
  self._sync_components_to_data()
500
410
  self._sync_wires_to_data()
501
411
  self._sync_junctions_to_data()
412
+ self._sync_texts_to_data()
413
+ self._sync_labels_to_data()
414
+ self._sync_hierarchical_labels_to_data()
415
+ self._sync_no_connects_to_data()
416
+ self._sync_nets_to_data()
502
417
 
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)
418
+ # Ensure FileIOManager's parser has the correct project name
419
+ self._file_io_manager._parser.project_name = self.name
515
420
 
516
- # Write to file
517
- with open(file_path, "w", encoding="utf-8") as f:
518
- f.write(content)
421
+ # Use FileIOManager for saving
422
+ self._file_io_manager.save_schematic(self._data, file_path, preserve_format)
519
423
 
520
424
  # Update state
521
425
  self._modified = False
522
426
  self._components._modified = False
427
+ self._format_sync_manager.clear_dirty_flags()
523
428
  self._last_save_time = time.time()
524
429
 
525
430
  save_time = time.time() - start_time
@@ -534,121 +439,22 @@ class Schematic:
534
439
  Create a backup of the current schematic file.
535
440
 
536
441
  Args:
537
- suffix: Suffix to add to backup filename
442
+ suffix: Backup file suffix
538
443
 
539
444
  Returns:
540
445
  Path to backup file
541
446
  """
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.
447
+ if self._file_path is None:
448
+ raise ValidationError("Cannot backup schematic with no file path")
559
449
 
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
450
+ return self._file_io_manager.create_backup(self._file_path, suffix)
595
451
 
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
604
-
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
- }
645
-
646
- # Wire and connection management (basic implementation)
452
+ # Wire operations (delegated to WireManager)
647
453
  def add_wire(
648
454
  self, start: Union[Point, Tuple[float, float]], end: Union[Point, Tuple[float, float]]
649
455
  ) -> str:
650
456
  """
651
- Add a wire connection.
457
+ Add a wire connection between two points.
652
458
 
653
459
  Args:
654
460
  start: Start point
@@ -657,442 +463,383 @@ class Schematic:
657
463
  Returns:
658
464
  UUID of created wire
659
465
  """
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)
466
+ wire_uuid = self._wire_manager.add_wire(start, end)
467
+ self._format_sync_manager.mark_dirty("wire", "add", {"uuid": wire_uuid})
667
468
  self._modified = True
668
-
669
- logger.debug(f"Added wire: {start} -> {end}")
670
469
  return wire_uuid
671
470
 
672
471
  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:
472
+ """
473
+ Remove a wire by UUID.
474
+
475
+ Args:
476
+ wire_uuid: UUID of wire to remove
477
+
478
+ Returns:
479
+ True if wire was removed, False if not found
480
+ """
481
+ removed = self._wires.remove(wire_uuid)
482
+ if removed:
483
+ self._format_sync_manager.remove_wire_from_data(wire_uuid)
687
484
  self._modified = True
688
- logger.debug(f"Removed wire: {wire_uuid}")
689
- return True
690
- return False
485
+ return removed
691
486
 
692
- # Label management
693
- def add_hierarchical_label(
487
+ def auto_route_pins(
694
488
  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:
489
+ component1_ref: str,
490
+ pin1_number: str,
491
+ component2_ref: str,
492
+ pin2_number: str,
493
+ routing_strategy: str = "direct",
494
+ ) -> List[str]:
701
495
  """
702
- Add a hierarchical label.
496
+ Auto-route between two component pins.
703
497
 
704
498
  Args:
705
- text: Label text
706
- position: Label position
707
- shape: Label shape/direction
708
- rotation: Text rotation in degrees
709
- size: Font size
499
+ component1_ref: First component reference
500
+ pin1_number: First component pin number
501
+ component2_ref: Second component reference
502
+ pin2_number: Second component pin number
503
+ routing_strategy: Routing strategy ("direct", "orthogonal", "manhattan")
710
504
 
711
505
  Returns:
712
- UUID of created hierarchical label
506
+ List of wire UUIDs created
713
507
  """
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
- }
508
+ wire_uuids = self._wire_manager.auto_route_pins(
509
+ component1_ref, pin1_number, component2_ref, pin2_number, routing_strategy
739
510
  )
511
+ for wire_uuid in wire_uuids:
512
+ self._format_sync_manager.mark_dirty("wire", "add", {"uuid": wire_uuid})
740
513
  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
514
+ return wire_uuids
755
515
 
756
516
  def add_wire_to_pin(
757
- self, start_point: Union[Point, Tuple[float, float]], component_ref: str, pin_number: str
517
+ self, start: Union[Point, Tuple[float, float]], component_ref: str, pin_number: str
758
518
  ) -> Optional[str]:
759
519
  """
760
- Draw a wire from a start point to a component pin.
520
+ Add wire from arbitrary position to component pin.
761
521
 
762
522
  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")
523
+ start: Start position
524
+ component_ref: Component reference
525
+ pin_number: Pin number
766
526
 
767
527
  Returns:
768
- UUID of created wire, or None if pin position cannot be determined
528
+ Wire UUID or None if pin not found
769
529
  """
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")
776
- return None
777
-
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}")
530
+ pin_pos = self.get_component_pin_position(component_ref, pin_number)
531
+ if pin_pos is None:
782
532
  return None
783
533
 
784
- # Create the wire
785
- return self.add_wire(start_point, pin_position)
534
+ return self.add_wire(start, pin_pos)
786
535
 
787
536
  def add_wire_between_pins(
788
537
  self, component1_ref: str, pin1_number: str, component2_ref: str, pin2_number: str
789
538
  ) -> Optional[str]:
790
539
  """
791
- Draw a wire between two component pins.
540
+ Add wire between two component pins.
792
541
 
793
542
  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")
543
+ component1_ref: First component reference
544
+ pin1_number: First component pin number
545
+ component2_ref: Second component reference
546
+ pin2_number: Second component pin number
798
547
 
799
548
  Returns:
800
- UUID of created wire, or None if either pin position cannot be determined
549
+ Wire UUID or None if either pin not found
801
550
  """
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)
551
+ pin1_pos = self.get_component_pin_position(component1_ref, pin1_number)
552
+ pin2_pos = self.get_component_pin_position(component2_ref, pin2_number)
818
553
 
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}")
554
+ if pin1_pos is None or pin2_pos is None:
824
555
  return None
825
556
 
826
- # Create the wire
827
- return self.add_wire(pin1_position, pin2_position)
557
+ return self.add_wire(pin1_pos, pin2_pos)
828
558
 
829
- def get_component_pin_position(self, component_ref: str, pin_number: str) -> Optional[Point]:
559
+ def connect_pins_with_wire(
560
+ self, component1_ref: str, pin1_number: str, component2_ref: str, pin2_number: str
561
+ ) -> Optional[str]:
830
562
  """
831
- Get the absolute position of a component pin.
563
+ Connect two component pins with a wire (alias for add_wire_between_pins).
832
564
 
833
565
  Args:
834
- component_ref: Reference of the component (e.g., "R1")
835
- pin_number: Pin number on the component (e.g., "1")
566
+ component1_ref: First component reference
567
+ pin1_number: First component pin number
568
+ component2_ref: Second component reference
569
+ pin2_number: Second component pin number
836
570
 
837
571
  Returns:
838
- Absolute position of the pin, or None if not found
572
+ Wire UUID or None if either pin not found
839
573
  """
840
- from .pin_utils import get_component_pin_position
841
-
842
- component = self.components.get(component_ref)
843
- if not component:
844
- return None
845
-
846
- return get_component_pin_position(component, pin_number)
574
+ return self.add_wire_between_pins(component1_ref, pin1_number, component2_ref, pin2_number)
847
575
 
848
- # Wire routing and connectivity methods
849
- def auto_route_pins(
576
+ # Text and label operations (delegated to TextElementManager)
577
+ def add_label(
850
578
  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,
857
- ) -> Optional[str]:
579
+ text: str,
580
+ position: Union[Point, Tuple[float, float]],
581
+ effects: Optional[Dict[str, Any]] = None,
582
+ rotation: float = 0,
583
+ size: Optional[float] = None,
584
+ uuid: Optional[str] = None,
585
+ ) -> str:
858
586
  """
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.
587
+ Add a text label to the schematic.
862
588
 
863
589
  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)
590
+ text: Label text content
591
+ position: Label position
592
+ effects: Text effects (size, font, etc.)
593
+ rotation: Label rotation in degrees (default 0)
594
+ size: Text size override (default from effects)
595
+ uuid: Specific UUID for label (auto-generated if None)
872
596
 
873
597
  Returns:
874
- UUID of created wire, or None if routing failed
598
+ UUID of created label
875
599
  """
876
- from .wire_routing import route_pins_direct, snap_to_kicad_grid
600
+ # Use the new labels collection instead of manager
601
+ if size is None:
602
+ size = 1.27 # Default size
603
+ label = self._labels.add(text, position, rotation=rotation, size=size, label_uuid=uuid)
604
+ self._sync_labels_to_data() # Sync immediately
605
+ self._format_sync_manager.mark_dirty("label", "add", {"uuid": label.uuid})
606
+ self._modified = True
607
+ return label.uuid
877
608
 
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)
609
+ def add_text(
610
+ self,
611
+ text: str,
612
+ position: Union[Point, Tuple[float, float]],
613
+ rotation: float = 0.0,
614
+ size: float = 1.27,
615
+ exclude_from_sim: bool = False,
616
+ effects: Optional[Dict[str, Any]] = None,
617
+ ) -> str:
618
+ """
619
+ Add free text annotation to the schematic.
881
620
 
882
- if not pin1_pos or not pin2_pos:
883
- return None
621
+ Args:
622
+ text: Text content
623
+ position: Text position
624
+ rotation: Text rotation in degrees
625
+ size: Text size
626
+ exclude_from_sim: Whether to exclude from simulation
627
+ effects: Text effects
884
628
 
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)
629
+ Returns:
630
+ UUID of created text
631
+ """
632
+ # Use the new texts collection instead of manager
633
+ text_elem = self._texts.add(
634
+ text, position, rotation=rotation, size=size, exclude_from_sim=exclude_from_sim
635
+ )
636
+ self._sync_texts_to_data() # Sync immediately
637
+ self._format_sync_manager.mark_dirty("text", "add", {"uuid": text_elem.uuid})
638
+ self._modified = True
639
+ return text_elem.uuid
914
640
 
915
- def are_pins_connected(
916
- self, comp1_ref: str, pin1_num: str, comp2_ref: str, pin2_num: str
917
- ) -> bool:
641
+ def add_text_box(
642
+ self,
643
+ text: str,
644
+ position: Union[Point, Tuple[float, float]],
645
+ size: Union[Point, Tuple[float, float]],
646
+ rotation: float = 0.0,
647
+ font_size: float = 1.27,
648
+ margins: Optional[Tuple[float, float, float, float]] = None,
649
+ stroke_width: Optional[float] = None,
650
+ stroke_type: str = "solid",
651
+ fill_type: str = "none",
652
+ justify_horizontal: str = "left",
653
+ justify_vertical: str = "top",
654
+ exclude_from_sim: bool = False,
655
+ effects: Optional[Dict[str, Any]] = None,
656
+ stroke: Optional[Dict[str, Any]] = None,
657
+ ) -> str:
918
658
  """
919
- Detect when two pins are connected via wire routing.
659
+ Add a text box with border to the schematic.
920
660
 
921
661
  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')
662
+ text: Text content
663
+ position: Top-left position
664
+ size: Box size (width, height)
665
+ rotation: Text rotation in degrees
666
+ font_size: Text font size
667
+ margins: Box margins (top, bottom, left, right)
668
+ stroke_width: Border stroke width
669
+ stroke_type: Border stroke type (solid, dash, etc.)
670
+ fill_type: Fill type (none, outline, background)
671
+ justify_horizontal: Horizontal justification
672
+ justify_vertical: Vertical justification
673
+ exclude_from_sim: Whether to exclude from simulation
674
+ effects: Text effects (legacy)
675
+ stroke: Border stroke settings (legacy)
926
676
 
927
677
  Returns:
928
- True if pins are connected via wires, False otherwise
678
+ UUID of created text box
929
679
  """
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
- return self.add_wire_between_pins(component1_ref, pin1_number, component2_ref, pin2_number)
680
+ text_box_uuid = self._text_element_manager.add_text_box(
681
+ text=text,
682
+ position=position,
683
+ size=size,
684
+ rotation=rotation,
685
+ font_size=font_size,
686
+ margins=margins,
687
+ stroke_width=stroke_width,
688
+ stroke_type=stroke_type,
689
+ fill_type=fill_type,
690
+ justify_horizontal=justify_horizontal,
691
+ justify_vertical=justify_vertical,
692
+ exclude_from_sim=exclude_from_sim,
693
+ effects=effects,
694
+ stroke=stroke,
695
+ )
696
+ self._format_sync_manager.mark_dirty("text_box", "add", {"uuid": text_box_uuid})
697
+ self._modified = True
698
+ return text_box_uuid
940
699
 
941
- def add_label(
700
+ def add_hierarchical_label(
942
701
  self,
943
702
  text: str,
944
703
  position: Union[Point, Tuple[float, float]],
704
+ shape: str = "input",
945
705
  rotation: float = 0.0,
946
706
  size: float = 1.27,
947
- uuid: Optional[str] = None,
707
+ effects: Optional[Dict[str, Any]] = None,
948
708
  ) -> str:
949
709
  """
950
- Add a local label.
710
+ Add a hierarchical label for sheet connections.
951
711
 
952
712
  Args:
953
713
  text: Label text
954
714
  position: Label position
955
- rotation: Text rotation in degrees
956
- size: Font size
957
- uuid: Optional UUID (auto-generated if None)
715
+ shape: Shape type (input, output, bidirectional, tri_state, passive)
716
+ rotation: Label rotation in degrees (default 0)
717
+ size: Label text size (default 1.27)
718
+ effects: Text effects
958
719
 
959
720
  Returns:
960
- UUID of created label
721
+ UUID of created hierarchical label
961
722
  """
962
- if isinstance(position, tuple):
963
- position = Point(position[0], position[1])
964
-
965
- import uuid as uuid_module
723
+ # Use the hierarchical_labels collection
724
+ hlabel = self._hierarchical_labels.add(text, position, rotation=rotation, size=size)
725
+ self._sync_hierarchical_labels_to_data() # Sync immediately
726
+ self._format_sync_manager.mark_dirty("hierarchical_label", "add", {"uuid": hlabel.uuid})
727
+ self._modified = True
728
+ return hlabel.uuid
966
729
 
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
- )
730
+ def add_global_label(
731
+ self,
732
+ text: str,
733
+ position: Union[Point, Tuple[float, float]],
734
+ shape: str = "input",
735
+ effects: Optional[Dict[str, Any]] = None,
736
+ ) -> str:
737
+ """
738
+ Add a global label for project-wide connections.
975
739
 
976
- if "labels" not in self._data:
977
- self._data["labels"] = []
740
+ Args:
741
+ text: Label text
742
+ position: Label position
743
+ shape: Shape type
744
+ effects: Text effects
978
745
 
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
- )
746
+ Returns:
747
+ UUID of created global label
748
+ """
749
+ label_uuid = self._text_element_manager.add_global_label(text, position, shape, effects)
750
+ self._format_sync_manager.mark_dirty("global_label", "add", {"uuid": label_uuid})
988
751
  self._modified = True
989
-
990
- logger.debug(f"Added local label: {text} at {position}")
991
- return label.uuid
752
+ return label_uuid
992
753
 
993
754
  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
755
+ """
756
+ Remove a label by UUID.
757
+
758
+ Args:
759
+ label_uuid: UUID of label to remove
760
+
761
+ Returns:
762
+ True if label was removed, False if not found
763
+ """
764
+ removed = self._labels.remove(label_uuid)
765
+ if removed:
766
+ self._sync_labels_to_data() # Sync immediately
767
+ self._format_sync_manager.mark_dirty("label", "remove", {"uuid": label_uuid})
768
+ self._modified = True
769
+ return removed
770
+
771
+ def remove_hierarchical_label(self, label_uuid: str) -> bool:
772
+ """
773
+ Remove a hierarchical label by UUID.
774
+
775
+ Args:
776
+ label_uuid: UUID of hierarchical label to remove
777
+
778
+ Returns:
779
+ True if hierarchical label was removed, False if not found
780
+ """
781
+ removed = self._hierarchical_labels.remove(label_uuid)
782
+ if removed:
783
+ self._sync_hierarchical_labels_to_data() # Sync immediately
784
+ self._format_sync_manager.mark_dirty(
785
+ "hierarchical_label", "remove", {"uuid": label_uuid}
786
+ )
787
+ self._modified = True
788
+ return removed
1003
789
 
790
+ # Sheet operations (delegated to SheetManager)
1004
791
  def add_sheet(
1005
792
  self,
1006
793
  name: str,
1007
794
  filename: str,
1008
795
  position: Union[Point, Tuple[float, float]],
1009
796
  size: Union[Point, Tuple[float, float]],
1010
- stroke_width: float = 0.1524,
797
+ stroke_width: Optional[float] = None,
1011
798
  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",
799
+ project_name: Optional[str] = None,
800
+ page_number: Optional[str] = None,
1017
801
  uuid: Optional[str] = None,
1018
802
  ) -> str:
1019
803
  """
1020
- Add a hierarchical sheet.
804
+ Add a hierarchical sheet to the schematic.
1021
805
 
1022
806
  Args:
1023
- name: Sheet name (displayed above sheet)
1024
- filename: Sheet filename (.kicad_sch file)
807
+ name: Sheet name/title
808
+ filename: Referenced schematic filename
1025
809
  position: Sheet position (top-left corner)
1026
810
  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)
811
+ stroke_width: Border stroke width
812
+ stroke_type: Border stroke type (solid, dashed, etc.)
813
+ project_name: Project name for this sheet
814
+ page_number: Page number for this sheet
815
+ uuid: Optional UUID for the sheet
1035
816
 
1036
817
  Returns:
1037
818
  UUID of created sheet
1038
819
  """
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,
820
+ sheet_uuid = self._sheet_manager.add_sheet(
821
+ name,
822
+ filename,
823
+ position,
824
+ size,
825
+ uuid_str=uuid,
1055
826
  stroke_width=stroke_width,
1056
827
  stroke_type=stroke_type,
828
+ project_name=project_name,
829
+ page_number=page_number,
1057
830
  )
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
- )
831
+ self._format_sync_manager.mark_dirty("sheet", "add", {"uuid": sheet_uuid})
1082
832
  self._modified = True
1083
-
1084
- logger.debug(f"Added hierarchical sheet: {name} ({filename}) at {position}")
1085
- return sheet.uuid
833
+ return sheet_uuid
1086
834
 
1087
835
  def add_sheet_pin(
1088
836
  self,
1089
837
  sheet_uuid: str,
1090
838
  name: str,
1091
- pin_type: str = "input",
1092
- position: Union[Point, Tuple[float, float]] = (0, 0),
839
+ pin_type: str,
840
+ position: Union[Point, Tuple[float, float]],
1093
841
  rotation: float = 0,
1094
- size: float = 1.27,
1095
- justify: str = "right",
842
+ justify: str = "left",
1096
843
  uuid: Optional[str] = None,
1097
844
  ) -> str:
1098
845
  """
@@ -1100,381 +847,209 @@ class Schematic:
1100
847
 
1101
848
  Args:
1102
849
  sheet_uuid: UUID of the sheet to add pin to
1103
- name: Pin name (NET1, NET2, etc.)
850
+ name: Pin name
1104
851
  pin_type: Pin type (input, output, bidirectional, etc.)
1105
- position: Pin position relative to sheet
852
+ position: Pin position
1106
853
  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)
854
+ justify: Text justification
855
+ uuid: Optional UUID for the pin
1110
856
 
1111
857
  Returns:
1112
858
  UUID of created sheet pin
1113
859
  """
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")
860
+ pin_uuid = self._sheet_manager.add_sheet_pin(
861
+ sheet_uuid, name, pin_type, position, rotation, justify, uuid_str=uuid
862
+ )
863
+ self._format_sync_manager.mark_dirty("sheet", "modify", {"uuid": sheet_uuid})
864
+ self._modified = True
865
+ return pin_uuid
1142
866
 
1143
- def add_text(
1144
- self,
1145
- text: str,
1146
- position: Union[Point, Tuple[float, float]],
1147
- rotation: float = 0.0,
1148
- size: float = 1.27,
1149
- exclude_from_sim: bool = False,
1150
- ) -> str:
867
+ def remove_sheet(self, sheet_uuid: str) -> bool:
1151
868
  """
1152
- Add a text element.
869
+ Remove a sheet by UUID.
1153
870
 
1154
871
  Args:
1155
- text: Text content
1156
- position: Text position
1157
- rotation: Text rotation in degrees
1158
- size: Font size
1159
- exclude_from_sim: Exclude from simulation
872
+ sheet_uuid: UUID of sheet to remove
1160
873
 
1161
874
  Returns:
1162
- UUID of created text element
875
+ True if sheet was removed, False if not found
1163
876
  """
1164
- if isinstance(position, tuple):
1165
- position = Point(position[0], position[1])
877
+ removed = self._sheet_manager.remove_sheet(sheet_uuid)
878
+ if removed:
879
+ self._format_sync_manager.mark_dirty("sheet", "remove", {"uuid": sheet_uuid})
880
+ self._modified = True
881
+ return removed
1166
882
 
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
- }
1188
- )
1189
- self._modified = True
1190
-
1191
- logger.debug(f"Added text: '{text}' at {position}")
1192
- return text_element.uuid
1193
-
1194
- def add_text_box(
883
+ # Graphics operations (delegated to GraphicsManager)
884
+ def add_rectangle(
1195
885
  self,
1196
- text: str,
1197
- position: Union[Point, Tuple[float, float]],
1198
- size: Union[Point, Tuple[float, float]],
1199
- rotation: float = 0.0,
1200
- 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,
886
+ start: Union[Point, Tuple[float, float]],
887
+ end: Union[Point, Tuple[float, float]],
888
+ stroke_width: float = 0.127,
1203
889
  stroke_type: str = "solid",
1204
890
  fill_type: str = "none",
1205
- justify_horizontal: str = "left",
1206
- justify_vertical: str = "top",
1207
- exclude_from_sim: bool = False,
891
+ stroke_color: Optional[Tuple[int, int, int, float]] = None,
892
+ fill_color: Optional[Tuple[int, int, int, float]] = None,
1208
893
  ) -> str:
1209
894
  """
1210
- Add a text box element.
895
+ Add a rectangle to the schematic.
1211
896
 
1212
897
  Args:
1213
- text: Text content
1214
- position: Text box position (top-left corner)
1215
- size: Text box size (width, height)
1216
- 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
898
+ start: Top-left corner position
899
+ end: Bottom-right corner position
900
+ stroke_width: Line width
901
+ stroke_type: Line type (solid, dashed, etc.)
902
+ fill_type: Fill type (none, background, etc.)
903
+ stroke_color: Stroke color as (r, g, b, a)
904
+ fill_color: Fill color as (r, g, b, a)
1225
905
 
1226
906
  Returns:
1227
- UUID of created text box element
907
+ UUID of created rectangle
1228
908
  """
1229
- if isinstance(position, tuple):
1230
- position = Point(position[0], position[1])
1231
- if isinstance(size, tuple):
1232
- size = Point(size[0], size[1])
909
+ # Convert individual parameters to stroke/fill dicts
910
+ stroke = {"width": stroke_width, "type": stroke_type}
911
+ if stroke_color:
912
+ stroke["color"] = stroke_color
1233
913
 
1234
- text_box = TextBox(
1235
- uuid=str(uuid.uuid4()),
1236
- position=position,
1237
- size=size,
1238
- text=text,
1239
- rotation=rotation,
1240
- font_size=font_size,
1241
- margins=margins,
1242
- stroke_width=stroke_width,
1243
- stroke_type=stroke_type,
1244
- fill_type=fill_type,
1245
- justify_horizontal=justify_horizontal,
1246
- justify_vertical=justify_vertical,
1247
- exclude_from_sim=exclude_from_sim,
1248
- )
914
+ fill = {"type": fill_type}
915
+ if fill_color:
916
+ fill["color"] = fill_color
1249
917
 
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
- )
918
+ rect_uuid = self._graphics_manager.add_rectangle(start, end, stroke, fill)
919
+ self._format_sync_manager.mark_dirty("rectangle", "add", {"uuid": rect_uuid})
1270
920
  self._modified = True
921
+ return rect_uuid
1271
922
 
1272
- logger.debug(f"Added text box: '{text}' at {position} size {size}")
1273
- return text_box.uuid
923
+ def remove_rectangle(self, rect_uuid: str) -> bool:
924
+ """
925
+ Remove a rectangle by UUID.
926
+
927
+ Args:
928
+ rect_uuid: UUID of rectangle to remove
929
+
930
+ Returns:
931
+ True if removed, False if not found
932
+ """
933
+ removed = self._graphics_manager.remove_rectangle(rect_uuid)
934
+ if removed:
935
+ self._format_sync_manager.mark_dirty("rectangle", "remove", {"uuid": rect_uuid})
936
+ self._modified = True
937
+ return removed
1274
938
 
1275
939
  def add_image(
1276
940
  self,
1277
941
  position: Union[Point, Tuple[float, float]],
1278
- data: str,
1279
942
  scale: float = 1.0,
1280
- uuid: Optional[str] = None,
943
+ data: Optional[str] = None,
1281
944
  ) -> str:
1282
945
  """
1283
- Add an image element.
946
+ Add an image to the schematic.
1284
947
 
1285
948
  Args:
1286
949
  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)
950
+ scale: Image scale factor
951
+ data: Base64 encoded image data
1290
952
 
1291
953
  Returns:
1292
- UUID of created image element
954
+ UUID of created image
1293
955
  """
1294
- if isinstance(position, tuple):
1295
- position = Point(position[0], position[1])
1296
-
1297
- from .types import Image
1298
-
1299
- import uuid as uuid_module
1300
-
1301
- image = Image(
1302
- uuid=uuid if uuid else str(uuid_module.uuid4()),
1303
- position=position,
1304
- data=data,
1305
- scale=scale,
1306
- )
1307
-
1308
- if "images" not in self._data:
1309
- self._data["images"] = []
1310
-
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
- }
1318
- )
956
+ image_uuid = self._graphics_manager.add_image(position, scale, data)
957
+ self._format_sync_manager.mark_dirty("image", "add", {"uuid": image_uuid})
1319
958
  self._modified = True
959
+ return image_uuid
1320
960
 
1321
- logger.debug(f"Added image at {position} with {len(data)} bytes of data")
1322
- return image.uuid
1323
-
1324
- def add_rectangle(
961
+ def draw_bounding_box(
1325
962
  self,
1326
- start: Union[Point, Tuple[float, float]],
1327
- end: Union[Point, Tuple[float, float]],
1328
- stroke_width: float = 0.0,
1329
- stroke_type: str = "default",
1330
- fill_type: str = "none"
963
+ bbox,
964
+ stroke_width: float = 0.127,
965
+ stroke_color: str = "black",
966
+ stroke_type: str = "solid",
1331
967
  ) -> str:
1332
968
  """
1333
- Add a graphical rectangle element.
969
+ Draw a bounding box rectangle around the given bounding box.
1334
970
 
1335
971
  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.)
972
+ bbox: BoundingBox object with min_x, min_y, max_x, max_y
973
+ stroke_width: Line width
974
+ stroke_color: Line color
975
+ stroke_type: Line type
1341
976
 
1342
977
  Returns:
1343
- UUID of created rectangle element
1344
- """
1345
- if isinstance(start, tuple):
1346
- start = Point(start[0], start[1])
1347
- if isinstance(end, tuple):
1348
- end = Point(end[0], end[1])
1349
-
1350
- from .types import SchematicRectangle
1351
-
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
- })
1372
- self._modified = True
1373
-
1374
- logger.debug(f"Added rectangle: {start} to {end}")
1375
- return rectangle.uuid
1376
-
1377
- def set_title_block(
1378
- self,
1379
- title: str = "",
1380
- date: str = "",
1381
- rev: str = "",
1382
- company: str = "",
1383
- comments: Optional[Dict[int, str]] = None,
1384
- ):
978
+ UUID of created rectangle
1385
979
  """
1386
- Set title block information.
980
+ # Convert bounding box to rectangle coordinates
981
+ start = (bbox.min_x, bbox.min_y)
982
+ end = (bbox.max_x, bbox.max_y)
1387
983
 
1388
- 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 = {}
1397
-
1398
- self._data["title_block"] = {
1399
- "title": title,
1400
- "date": date,
1401
- "rev": rev,
1402
- "company": company,
1403
- "comments": comments,
1404
- }
1405
- self._modified = True
1406
-
1407
- logger.debug(f"Set title block: {title} rev {rev}")
984
+ return self.add_rectangle(start, end, stroke_width=stroke_width, stroke_type=stroke_type)
1408
985
 
1409
986
  def draw_bounding_box(
1410
987
  self,
1411
988
  bbox: "BoundingBox",
1412
- stroke_width: float = 0,
1413
- stroke_color: str = None,
1414
- stroke_type: str = "default",
1415
- exclude_from_sim: bool = False,
989
+ stroke_width: float = 0.127,
990
+ stroke_color: Optional[str] = None,
991
+ stroke_type: str = "solid",
1416
992
  ) -> str:
1417
993
  """
1418
- Draw a component bounding box as a visual rectangle using KiCAD rectangle graphics.
994
+ Draw a single bounding box as a rectangle.
1419
995
 
1420
996
  Args:
1421
997
  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
998
+ stroke_width: Line width
999
+ stroke_color: Line color name (red, green, blue, etc.) or None
1000
+ stroke_type: Line type (solid, dashed, etc.)
1426
1001
 
1427
1002
  Returns:
1428
- UUID of created rectangle element
1003
+ UUID of created rectangle
1429
1004
  """
1430
- # Import BoundingBox type
1431
1005
  from .component_bounds import BoundingBox
1432
1006
 
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
1007
+ # Convert color name to RGBA tuple if provided
1008
+ stroke_rgba = None
1439
1009
  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"] = []
1010
+ # Simple color name to RGB mapping
1011
+ color_map = {
1012
+ "red": (255, 0, 0, 1.0),
1013
+ "green": (0, 255, 0, 1.0),
1014
+ "blue": (0, 0, 255, 1.0),
1015
+ "yellow": (255, 255, 0, 1.0),
1016
+ "cyan": (0, 255, 255, 1.0),
1017
+ "magenta": (255, 0, 255, 1.0),
1018
+ "black": (0, 0, 0, 1.0),
1019
+ "white": (255, 255, 255, 1.0),
1020
+ }
1021
+ stroke_rgba = color_map.get(stroke_color.lower(), (0, 255, 0, 1.0))
1453
1022
 
1454
- self._data["graphics"].append(rectangle_data)
1455
- self._modified = True
1023
+ # Add rectangle using the manager
1024
+ rect_uuid = self.add_rectangle(
1025
+ start=(bbox.min_x, bbox.min_y),
1026
+ end=(bbox.max_x, bbox.max_y),
1027
+ stroke_width=stroke_width,
1028
+ stroke_type=stroke_type,
1029
+ stroke_color=stroke_rgba,
1030
+ )
1456
1031
 
1457
- logger.debug(f"Drew bounding box rectangle: {bbox}")
1032
+ logger.debug(f"Drew bounding box: {bbox}")
1458
1033
  return rect_uuid
1459
1034
 
1460
1035
  def draw_component_bounding_boxes(
1461
1036
  self,
1462
1037
  include_properties: bool = False,
1463
- stroke_width: float = 0.254,
1464
- stroke_color: str = "red",
1465
- stroke_type: str = "default",
1038
+ stroke_width: float = 0.127,
1039
+ stroke_color: str = "green",
1040
+ stroke_type: str = "solid",
1466
1041
  ) -> List[str]:
1467
1042
  """
1468
- Draw bounding boxes for all components in the schematic.
1043
+ Draw bounding boxes for all components.
1469
1044
 
1470
1045
  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
1046
+ include_properties: Whether to include properties in bounding box
1047
+ stroke_width: Line width
1048
+ stroke_color: Line color
1049
+ stroke_type: Line type
1475
1050
 
1476
1051
  Returns:
1477
- List of UUIDs for created rectangle elements
1052
+ List of rectangle UUIDs created
1478
1053
  """
1479
1054
  from .component_bounds import get_component_bounding_box
1480
1055
 
@@ -1488,67 +1063,157 @@ class Schematic:
1488
1063
  logger.info(f"Drew {len(uuids)} component bounding boxes")
1489
1064
  return uuids
1490
1065
 
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()
1066
+ # Metadata operations (delegated to MetadataManager)
1067
+ def set_title_block(
1068
+ self,
1069
+ title: str = "",
1070
+ date: str = "",
1071
+ rev: str = "",
1072
+ company: str = "",
1073
+ comments: Optional[Dict[int, str]] = None,
1074
+ ) -> None:
1075
+ """
1076
+ Set title block information.
1077
+
1078
+ Args:
1079
+ title: Schematic title
1080
+ date: Date
1081
+ rev: Revision
1082
+ company: Company name
1083
+ comments: Comment fields (1-9)
1084
+ """
1085
+ self._metadata_manager.set_title_block(title, date, rev, company, comments)
1086
+ self._format_sync_manager.mark_dirty("title_block", "update")
1509
1087
  self._modified = True
1510
- logger.info("Cleared schematic")
1511
1088
 
1512
- def clone(self, new_name: Optional[str] = None) -> "Schematic":
1513
- """Create a copy of this schematic."""
1514
- import copy
1089
+ def set_paper_size(self, paper: str) -> None:
1090
+ """
1091
+ Set paper size for the schematic.
1092
+
1093
+ Args:
1094
+ paper: Paper size (A4, A3, etc.)
1095
+ """
1096
+ self._metadata_manager.set_paper_size(paper)
1097
+ self._format_sync_manager.mark_dirty("paper", "update")
1098
+ self._modified = True
1515
1099
 
1516
- cloned_data = copy.deepcopy(self._data)
1100
+ # Validation (enhanced with ValidationManager)
1101
+ def validate(self) -> List[ValidationIssue]:
1102
+ """
1103
+ Perform comprehensive schematic validation.
1104
+
1105
+ Returns:
1106
+ List of validation issues found
1107
+ """
1108
+ # Use the new ValidationManager for comprehensive validation
1109
+ manager_issues = self._validation_manager.validate_schematic()
1517
1110
 
1518
- if new_name:
1519
- cloned_data["title_block"]["title"] = new_name
1520
- cloned_data["uuid"] = str(uuid.uuid4()) # New UUID for clone
1111
+ # Also run legacy validator for compatibility
1112
+ try:
1113
+ legacy_issues = self._legacy_validator.validate_schematic_data(self._data)
1114
+ except Exception as e:
1115
+ logger.warning(f"Legacy validator failed: {e}")
1116
+ legacy_issues = []
1521
1117
 
1522
- return Schematic(cloned_data)
1118
+ # Combine issues (remove duplicates based on message)
1119
+ all_issues = manager_issues + legacy_issues
1120
+ unique_issues = []
1121
+ seen_messages = set()
1523
1122
 
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")
1123
+ for issue in all_issues:
1124
+ if issue.message not in seen_messages:
1125
+ unique_issues.append(issue)
1126
+ seen_messages.add(issue.message)
1529
1127
 
1530
- def get_performance_stats(self) -> Dict[str, Any]:
1531
- """Get performance statistics."""
1532
- cache_stats = get_symbol_cache().get_performance_stats()
1128
+ return unique_issues
1533
1129
 
1130
+ def get_validation_summary(self) -> Dict[str, Any]:
1131
+ """
1132
+ Get validation summary statistics.
1133
+
1134
+ Returns:
1135
+ Summary dictionary with counts and severity
1136
+ """
1137
+ issues = self.validate()
1138
+ return self._validation_manager.get_validation_summary(issues)
1139
+
1140
+ # Statistics and information
1141
+ def get_statistics(self) -> Dict[str, Any]:
1142
+ """Get comprehensive schematic statistics."""
1534
1143
  return {
1535
- "schematic": {
1144
+ "components": len(self._components),
1145
+ "wires": len(self._wires),
1146
+ "junctions": len(self._junctions),
1147
+ "text_elements": self._text_element_manager.get_text_statistics(),
1148
+ "graphics": self._graphics_manager.get_graphics_statistics(),
1149
+ "sheets": self._sheet_manager.get_sheet_statistics(),
1150
+ "performance": {
1536
1151
  "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
- ),
1152
+ "total_operation_time": self._total_operation_time,
1153
+ "modified": self.modified,
1154
+ "last_save_time": self._last_save_time,
1546
1155
  },
1547
- "components": self._components.get_statistics(),
1548
- "symbol_cache": cache_stats,
1549
1156
  }
1550
1157
 
1551
1158
  # Internal methods
1159
+ @staticmethod
1160
+ def _create_empty_schematic_data() -> Dict[str, Any]:
1161
+ """Create empty schematic data structure."""
1162
+ return {
1163
+ "version": "20250114",
1164
+ "generator": "eeschema",
1165
+ "generator_version": "9.0",
1166
+ "paper": "A4",
1167
+ "lib_symbols": {},
1168
+ "symbol": [],
1169
+ "wire": [],
1170
+ "junction": [],
1171
+ "label": [],
1172
+ "hierarchical_label": [],
1173
+ "global_label": [],
1174
+ "text": [],
1175
+ "sheet": [],
1176
+ "rectangle": [],
1177
+ "circle": [],
1178
+ "arc": [],
1179
+ "polyline": [],
1180
+ "image": [],
1181
+ "symbol_instances": [],
1182
+ "sheet_instances": [],
1183
+ "embedded_fonts": "no",
1184
+ "components": [],
1185
+ "wires": [],
1186
+ "junctions": [],
1187
+ "labels": [],
1188
+ "nets": [],
1189
+ }
1190
+
1191
+ # Context manager support for atomic operations
1192
+ def __enter__(self):
1193
+ """Enter atomic operation context."""
1194
+ # Create backup for rollback
1195
+ if self._file_path and self._file_path.exists():
1196
+ self._backup_path = self._file_io_manager.create_backup(
1197
+ self._file_path, ".atomic_backup"
1198
+ )
1199
+ return self
1200
+
1201
+ def __exit__(self, exc_type, exc_val, exc_tb):
1202
+ """Exit atomic operation context."""
1203
+ if exc_type is not None:
1204
+ # Exception occurred - rollback if possible
1205
+ if hasattr(self, "_backup_path") and self._backup_path.exists():
1206
+ logger.warning("Exception in atomic operation - rolling back")
1207
+ # Restore from backup
1208
+ restored_data = self._file_io_manager.load_schematic(self._backup_path)
1209
+ self._data = restored_data
1210
+ self._modified = True
1211
+ else:
1212
+ # Success - clean up backup
1213
+ if hasattr(self, "_backup_path") and self._backup_path.exists():
1214
+ self._backup_path.unlink()
1215
+
1216
+ # Internal sync methods (migrated from original implementation)
1552
1217
  def _sync_components_to_data(self):
1553
1218
  """Sync component collection state back to data structure."""
1554
1219
  self._data["components"] = [comp._data.__dict__ for comp in self._components]
@@ -1559,112 +1224,23 @@ class Schematic:
1559
1224
 
1560
1225
  for comp in self._components:
1561
1226
  if comp.lib_id and comp.lib_id not in lib_symbols:
1562
- logger.debug(f"🔧 SCHEMATIC: Processing component {comp.lib_id}")
1563
-
1564
1227
  # Get the actual symbol definition
1565
1228
  symbol_def = cache.get_symbol(comp.lib_id)
1229
+
1566
1230
  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"}
1231
+ converted_symbol = self._convert_symbol_to_kicad_format(symbol_def, comp.lib_id)
1232
+ lib_symbols[comp.lib_id] = converted_symbol
1630
1233
 
1631
1234
  self._data["lib_symbols"] = lib_symbols
1632
1235
 
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)}")
1649
-
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
1236
+ # Update sheet instances
1237
+ if not self._data["sheet_instances"]:
1238
+ self._data["sheet_instances"] = [{"path": "/", "page": "1"}]
1665
1239
 
1666
- logger.debug(f"🔧 EXTENDS: No extends directive found")
1667
- return None
1240
+ # Remove symbol_instances section - instances are stored within each symbol in lib_symbols
1241
+ # This matches KiCAD's format where instances are part of the symbol definition
1242
+ if "symbol_instances" in self._data:
1243
+ del self._data["symbol_instances"]
1668
1244
 
1669
1245
  def _sync_wires_to_data(self):
1670
1246
  """Sync wire collection state back to data structure."""
@@ -1695,156 +1271,339 @@ class Schematic:
1695
1271
 
1696
1272
  self._data["junctions"] = junction_data
1697
1273
 
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)
1274
+ def _sync_texts_to_data(self):
1275
+ """Sync text collection state back to data structure."""
1276
+ text_data = []
1277
+ for text_element in self._texts:
1278
+ text_dict = {
1279
+ "uuid": text_element.uuid,
1280
+ "text": text_element.text,
1281
+ "position": {"x": text_element.position.x, "y": text_element.position.y},
1282
+ "rotation": text_element.rotation,
1283
+ "size": text_element.size,
1284
+ "exclude_from_sim": text_element.exclude_from_sim,
1285
+ }
1286
+ text_data.append(text_dict)
1287
+
1288
+ self._data["texts"] = text_data
1289
+
1290
+ def _sync_labels_to_data(self):
1291
+ """Sync label collection state back to data structure."""
1292
+ label_data = []
1293
+ for label_element in self._labels:
1294
+ label_dict = {
1295
+ "uuid": label_element.uuid,
1296
+ "text": label_element.text,
1297
+ "position": {"x": label_element.position.x, "y": label_element.position.y},
1298
+ "rotation": label_element.rotation,
1299
+ "size": label_element.size,
1300
+ }
1301
+ label_data.append(label_dict)
1302
+
1303
+ self._data["labels"] = label_data
1304
+
1305
+ def _sync_hierarchical_labels_to_data(self):
1306
+ """Sync hierarchical label collection state back to data structure."""
1307
+ hierarchical_label_data = []
1308
+ for hlabel_element in self._hierarchical_labels:
1309
+ hlabel_dict = {
1310
+ "uuid": hlabel_element.uuid,
1311
+ "text": hlabel_element.text,
1312
+ "position": {"x": hlabel_element.position.x, "y": hlabel_element.position.y},
1313
+ "rotation": hlabel_element.rotation,
1314
+ "size": hlabel_element.size,
1315
+ }
1316
+ hierarchical_label_data.append(hlabel_dict)
1317
+
1318
+ self._data["hierarchical_labels"] = hierarchical_label_data
1319
+
1320
+ def _sync_no_connects_to_data(self):
1321
+ """Sync no-connect collection state back to data structure."""
1322
+ no_connect_data = []
1323
+ for no_connect_element in self._no_connects:
1324
+ no_connect_dict = {
1325
+ "uuid": no_connect_element.uuid,
1326
+ "position": {
1327
+ "x": no_connect_element.position.x,
1328
+ "y": no_connect_element.position.y,
1329
+ },
1330
+ }
1331
+ no_connect_data.append(no_connect_dict)
1332
+
1333
+ self._data["no_connects"] = no_connect_data
1334
+
1335
+ def _sync_nets_to_data(self):
1336
+ """Sync net collection state back to data structure."""
1337
+ net_data = []
1338
+ for net_element in self._nets:
1339
+ net_dict = {
1340
+ "name": net_element.name,
1341
+ "components": net_element.components,
1342
+ "wires": net_element.wires,
1343
+ "labels": net_element.labels,
1344
+ }
1345
+ net_data.append(net_dict)
1346
+
1347
+ self._data["nets"] = net_data
1348
+
1349
+ def _convert_symbol_to_kicad_format(self, symbol_def, lib_id: str):
1350
+ """Convert symbol definition to KiCAD format."""
1351
+ # Use raw data if available, but fix the symbol name to use full lib_id
1352
+ if hasattr(symbol_def, "raw_kicad_data") and symbol_def.raw_kicad_data:
1353
+ raw_data = symbol_def.raw_kicad_data
1354
+
1355
+ # Check if raw data already contains instances with project info
1356
+ project_refs_found = []
1357
+
1358
+ def find_project_refs(data, path="root"):
1359
+ if isinstance(data, list):
1360
+ for i, item in enumerate(data):
1361
+ if hasattr(item, "__str__") and str(item) == "project":
1362
+ if i < len(data) - 1:
1363
+ project_refs_found.append(f"{path}[{i}] = '{data[i+1]}'")
1364
+ elif isinstance(item, list):
1365
+ find_project_refs(item, f"{path}[{i}]")
1366
+
1367
+ find_project_refs(raw_data)
1368
+
1369
+ # Make a copy and fix the symbol name (index 1) to use full lib_id
1370
+ if isinstance(raw_data, list) and len(raw_data) > 1:
1371
+ fixed_data = raw_data.copy()
1372
+ fixed_data[1] = lib_id # Replace short name with full lib_id
1373
+
1374
+ # Also fix any project references in instances to use current project name
1375
+ self._fix_symbol_project_references(fixed_data)
1376
+
1377
+ return fixed_data
1378
+ else:
1379
+ return raw_data
1705
1380
 
1706
1381
  # Fallback: create basic symbol structure
1707
1382
  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"},
1742
- },
1743
- },
1744
- "embedded_fonts": "no",
1383
+ "lib_id": lib_id,
1384
+ "symbol": symbol_def.name if hasattr(symbol_def, "name") else lib_id.split(":")[-1],
1745
1385
  }
1746
1386
 
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
1750
-
1751
- import sexpdata
1752
-
1753
- # Make a copy and fix symbol name and string/symbol issues
1754
- modified_data = copy.deepcopy(raw_data)
1755
-
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'
1759
-
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
1387
+ def _fix_symbol_project_references(self, symbol_data):
1388
+ """Fix project references in symbol instances to use current project name."""
1389
+ if not isinstance(symbol_data, list):
1390
+ return
1391
+
1392
+ # Recursively search for instances sections and update project names
1393
+ for i, element in enumerate(symbol_data):
1394
+ if isinstance(element, list):
1395
+ # Check if this is an instances section
1396
+ if (
1397
+ len(element) > 0
1398
+ and hasattr(element[0], "__str__")
1399
+ and str(element[0]) == "instances"
1400
+ ):
1401
+ # Look for project references within instances
1402
+ self._update_project_in_instances(element)
1403
+ else:
1404
+ # Recursively check nested lists
1405
+ self._fix_symbol_project_references(element)
1406
+
1407
+ def _update_project_in_instances(self, instances_element):
1408
+ """Update project name in instances element."""
1409
+ if not isinstance(instances_element, list):
1410
+ return
1411
+
1412
+ for i, element in enumerate(instances_element):
1413
+ if isinstance(element, list) and len(element) >= 2:
1414
+ # Check if this is a project element: ['project', 'old_name', ...]
1415
+ if hasattr(element[0], "__str__") and str(element[0]) == "project":
1416
+ old_name = element[1]
1417
+ element[1] = self.name # Replace with current schematic name
1418
+ else:
1419
+ # Recursively check nested elements
1420
+ self._update_project_in_instances(element)
1805
1421
 
1806
- @staticmethod
1807
- def _create_empty_schematic_data() -> Dict[str, Any]:
1808
- """Create empty schematic data structure."""
1809
- 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",
1824
- }
1422
+ # ============================================================================
1423
+ # Export Methods (using kicad-cli)
1424
+ # ============================================================================
1825
1425
 
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
1426
+ def run_erc(self, **kwargs):
1427
+ """
1428
+ Run Electrical Rule Check (ERC) on this schematic.
1833
1429
 
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()
1430
+ This requires the schematic to be saved first.
1431
+
1432
+ Args:
1433
+ **kwargs: Arguments passed to cli.erc.run_erc()
1434
+ - output_path: Path for ERC report
1435
+ - format: 'json' or 'report'
1436
+ - severity: 'all', 'error', 'warning', 'exclusions'
1437
+ - units: 'mm', 'in', 'mils'
1438
+
1439
+ Returns:
1440
+ ErcReport with violations and summary
1441
+
1442
+ Example:
1443
+ >>> report = sch.run_erc()
1444
+ >>> if report.has_errors():
1445
+ ... print(f"Found {report.error_count} errors")
1446
+ """
1447
+ from kicad_sch_api.cli.erc import run_erc
1448
+
1449
+ if not self._file_path:
1450
+ raise ValueError("Schematic must be saved before running ERC")
1451
+
1452
+ # Save first to ensure file is up-to-date
1453
+ self.save()
1454
+
1455
+ return run_erc(self._file_path, **kwargs)
1456
+
1457
+ def export_netlist(self, format="kicadsexpr", **kwargs):
1458
+ """
1459
+ Export netlist from this schematic.
1460
+
1461
+ This requires the schematic to be saved first.
1462
+
1463
+ Args:
1464
+ format: Netlist format (default: 'kicadsexpr')
1465
+ - kicadsexpr: KiCad S-expression (default)
1466
+ - kicadxml: KiCad XML
1467
+ - spice: SPICE netlist
1468
+ - spicemodel: SPICE with models
1469
+ - cadstar, orcadpcb2, pads, allegro
1470
+ **kwargs: Arguments passed to cli.netlist.export_netlist()
1471
+
1472
+ Returns:
1473
+ Path to generated netlist file
1474
+
1475
+ Example:
1476
+ >>> netlist = sch.export_netlist(format='spice')
1477
+ >>> print(f"Netlist: {netlist}")
1478
+ """
1479
+ from kicad_sch_api.cli.netlist import export_netlist
1480
+
1481
+ if not self._file_path:
1482
+ raise ValueError("Schematic must be saved before exporting netlist")
1483
+
1484
+ # Save first to ensure file is up-to-date
1485
+ self.save()
1486
+
1487
+ return export_netlist(self._file_path, format=format, **kwargs)
1488
+
1489
+ def export_bom(self, **kwargs):
1490
+ """
1491
+ Export Bill of Materials (BOM) from this schematic.
1492
+
1493
+ This requires the schematic to be saved first.
1494
+
1495
+ Args:
1496
+ **kwargs: Arguments passed to cli.bom.export_bom()
1497
+ - output_path: Path for BOM file
1498
+ - fields: List of fields to export
1499
+ - group_by: Fields to group by
1500
+ - exclude_dnp: Exclude Do-Not-Populate components
1501
+ - And many more options...
1502
+
1503
+ Returns:
1504
+ Path to generated BOM file
1505
+
1506
+ Example:
1507
+ >>> bom = sch.export_bom(
1508
+ ... fields=['Reference', 'Value', 'Footprint', 'MPN'],
1509
+ ... group_by=['Value', 'Footprint'],
1510
+ ... exclude_dnp=True,
1511
+ ... )
1512
+ """
1513
+ from kicad_sch_api.cli.bom import export_bom
1514
+
1515
+ if not self._file_path:
1516
+ raise ValueError("Schematic must be saved before exporting BOM")
1517
+
1518
+ # Save first to ensure file is up-to-date
1519
+ self.save()
1520
+
1521
+ return export_bom(self._file_path, **kwargs)
1522
+
1523
+ def export_pdf(self, **kwargs):
1524
+ """
1525
+ Export schematic as PDF.
1526
+
1527
+ This requires the schematic to be saved first.
1528
+
1529
+ Args:
1530
+ **kwargs: Arguments passed to cli.export_docs.export_pdf()
1531
+ - output_path: Path for PDF file
1532
+ - theme: Color theme
1533
+ - black_and_white: B&W export
1534
+ - And more options...
1535
+
1536
+ Returns:
1537
+ Path to generated PDF file
1538
+
1539
+ Example:
1540
+ >>> pdf = sch.export_pdf(theme='Kicad Classic')
1541
+ """
1542
+ from kicad_sch_api.cli.export_docs import export_pdf
1543
+
1544
+ if not self._file_path:
1545
+ raise ValueError("Schematic must be saved before exporting PDF")
1546
+
1547
+ # Save first to ensure file is up-to-date
1548
+ self.save()
1549
+
1550
+ return export_pdf(self._file_path, **kwargs)
1551
+
1552
+ def export_svg(self, **kwargs):
1553
+ """
1554
+ Export schematic as SVG.
1555
+
1556
+ This requires the schematic to be saved first.
1557
+
1558
+ Args:
1559
+ **kwargs: Arguments passed to cli.export_docs.export_svg()
1560
+ - output_dir: Output directory
1561
+ - theme: Color theme
1562
+ - black_and_white: B&W export
1563
+ - And more options...
1564
+
1565
+ Returns:
1566
+ List of paths to generated SVG files
1567
+
1568
+ Example:
1569
+ >>> svgs = sch.export_svg()
1570
+ >>> for svg in svgs:
1571
+ ... print(f"Generated: {svg}")
1572
+ """
1573
+ from kicad_sch_api.cli.export_docs import export_svg
1574
+
1575
+ if not self._file_path:
1576
+ raise ValueError("Schematic must be saved before exporting SVG")
1577
+
1578
+ # Save first to ensure file is up-to-date
1579
+ self.save()
1580
+
1581
+ return export_svg(self._file_path, **kwargs)
1582
+
1583
+ def export_dxf(self, **kwargs):
1584
+ """
1585
+ Export schematic as DXF.
1586
+
1587
+ This requires the schematic to be saved first.
1588
+
1589
+ Args:
1590
+ **kwargs: Arguments passed to cli.export_docs.export_dxf()
1591
+
1592
+ Returns:
1593
+ List of paths to generated DXF files
1594
+
1595
+ Example:
1596
+ >>> dxfs = sch.export_dxf()
1597
+ """
1598
+ from kicad_sch_api.cli.export_docs import export_dxf
1599
+
1600
+ if not self._file_path:
1601
+ raise ValueError("Schematic must be saved before exporting DXF")
1602
+
1603
+ # Save first to ensure file is up-to-date
1604
+ self.save()
1605
+
1606
+ return export_dxf(self._file_path, **kwargs)
1848
1607
 
1849
1608
  def __str__(self) -> str:
1850
1609
  """String representation."""