kicad-sch-api 0.3.0__py3-none-any.whl → 0.5.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. kicad_sch_api/__init__.py +68 -3
  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/kicad_to_python.py +169 -0
  8. kicad_sch_api/cli/netlist.py +94 -0
  9. kicad_sch_api/cli/types.py +43 -0
  10. kicad_sch_api/collections/__init__.py +36 -0
  11. kicad_sch_api/collections/base.py +604 -0
  12. kicad_sch_api/collections/components.py +1623 -0
  13. kicad_sch_api/collections/junctions.py +206 -0
  14. kicad_sch_api/collections/labels.py +508 -0
  15. kicad_sch_api/collections/wires.py +292 -0
  16. kicad_sch_api/core/__init__.py +37 -2
  17. kicad_sch_api/core/collections/__init__.py +5 -0
  18. kicad_sch_api/core/collections/base.py +248 -0
  19. kicad_sch_api/core/component_bounds.py +34 -7
  20. kicad_sch_api/core/components.py +213 -52
  21. kicad_sch_api/core/config.py +110 -15
  22. kicad_sch_api/core/connectivity.py +692 -0
  23. kicad_sch_api/core/exceptions.py +175 -0
  24. kicad_sch_api/core/factories/__init__.py +5 -0
  25. kicad_sch_api/core/factories/element_factory.py +278 -0
  26. kicad_sch_api/core/formatter.py +60 -9
  27. kicad_sch_api/core/geometry.py +94 -5
  28. kicad_sch_api/core/junctions.py +26 -75
  29. kicad_sch_api/core/labels.py +324 -0
  30. kicad_sch_api/core/managers/__init__.py +30 -0
  31. kicad_sch_api/core/managers/base.py +76 -0
  32. kicad_sch_api/core/managers/file_io.py +246 -0
  33. kicad_sch_api/core/managers/format_sync.py +502 -0
  34. kicad_sch_api/core/managers/graphics.py +580 -0
  35. kicad_sch_api/core/managers/hierarchy.py +661 -0
  36. kicad_sch_api/core/managers/metadata.py +271 -0
  37. kicad_sch_api/core/managers/sheet.py +492 -0
  38. kicad_sch_api/core/managers/text_elements.py +537 -0
  39. kicad_sch_api/core/managers/validation.py +476 -0
  40. kicad_sch_api/core/managers/wire.py +410 -0
  41. kicad_sch_api/core/nets.py +305 -0
  42. kicad_sch_api/core/no_connects.py +252 -0
  43. kicad_sch_api/core/parser.py +194 -970
  44. kicad_sch_api/core/parsing_utils.py +63 -0
  45. kicad_sch_api/core/pin_utils.py +103 -9
  46. kicad_sch_api/core/schematic.py +1328 -1079
  47. kicad_sch_api/core/texts.py +316 -0
  48. kicad_sch_api/core/types.py +159 -23
  49. kicad_sch_api/core/wires.py +27 -75
  50. kicad_sch_api/exporters/__init__.py +10 -0
  51. kicad_sch_api/exporters/python_generator.py +610 -0
  52. kicad_sch_api/exporters/templates/default.py.jinja2 +65 -0
  53. kicad_sch_api/geometry/__init__.py +38 -0
  54. kicad_sch_api/geometry/font_metrics.py +22 -0
  55. kicad_sch_api/geometry/routing.py +211 -0
  56. kicad_sch_api/geometry/symbol_bbox.py +608 -0
  57. kicad_sch_api/interfaces/__init__.py +17 -0
  58. kicad_sch_api/interfaces/parser.py +76 -0
  59. kicad_sch_api/interfaces/repository.py +70 -0
  60. kicad_sch_api/interfaces/resolver.py +117 -0
  61. kicad_sch_api/parsers/__init__.py +14 -0
  62. kicad_sch_api/parsers/base.py +145 -0
  63. kicad_sch_api/parsers/elements/__init__.py +22 -0
  64. kicad_sch_api/parsers/elements/graphics_parser.py +564 -0
  65. kicad_sch_api/parsers/elements/label_parser.py +216 -0
  66. kicad_sch_api/parsers/elements/library_parser.py +165 -0
  67. kicad_sch_api/parsers/elements/metadata_parser.py +58 -0
  68. kicad_sch_api/parsers/elements/sheet_parser.py +352 -0
  69. kicad_sch_api/parsers/elements/symbol_parser.py +485 -0
  70. kicad_sch_api/parsers/elements/text_parser.py +250 -0
  71. kicad_sch_api/parsers/elements/wire_parser.py +242 -0
  72. kicad_sch_api/parsers/registry.py +155 -0
  73. kicad_sch_api/parsers/utils.py +80 -0
  74. kicad_sch_api/symbols/__init__.py +18 -0
  75. kicad_sch_api/symbols/cache.py +467 -0
  76. kicad_sch_api/symbols/resolver.py +361 -0
  77. kicad_sch_api/symbols/validators.py +504 -0
  78. kicad_sch_api/utils/logging.py +555 -0
  79. kicad_sch_api/utils/logging_decorators.py +587 -0
  80. kicad_sch_api/utils/validation.py +16 -22
  81. kicad_sch_api/validation/__init__.py +25 -0
  82. kicad_sch_api/validation/erc.py +171 -0
  83. kicad_sch_api/validation/erc_models.py +203 -0
  84. kicad_sch_api/validation/pin_matrix.py +243 -0
  85. kicad_sch_api/validation/validators.py +391 -0
  86. kicad_sch_api/wrappers/__init__.py +14 -0
  87. kicad_sch_api/wrappers/base.py +89 -0
  88. kicad_sch_api/wrappers/wire.py +198 -0
  89. kicad_sch_api-0.5.1.dist-info/METADATA +540 -0
  90. kicad_sch_api-0.5.1.dist-info/RECORD +114 -0
  91. kicad_sch_api-0.5.1.dist-info/entry_points.txt +4 -0
  92. {kicad_sch_api-0.3.0.dist-info → kicad_sch_api-0.5.1.dist-info}/top_level.txt +1 -0
  93. mcp_server/__init__.py +34 -0
  94. mcp_server/example_logging_integration.py +506 -0
  95. mcp_server/models.py +252 -0
  96. mcp_server/server.py +357 -0
  97. mcp_server/tools/__init__.py +32 -0
  98. mcp_server/tools/component_tools.py +516 -0
  99. mcp_server/tools/connectivity_tools.py +532 -0
  100. mcp_server/tools/consolidated_tools.py +1216 -0
  101. mcp_server/tools/pin_discovery.py +333 -0
  102. mcp_server/utils/__init__.py +38 -0
  103. mcp_server/utils/logging.py +127 -0
  104. mcp_server/utils.py +36 -0
  105. kicad_sch_api/core/manhattan_routing.py +0 -430
  106. kicad_sch_api/core/simple_manhattan.py +0 -228
  107. kicad_sch_api/core/wire_routing.py +0 -380
  108. kicad_sch_api-0.3.0.dist-info/METADATA +0 -483
  109. kicad_sch_api-0.3.0.dist-info/RECORD +0 -31
  110. kicad_sch_api-0.3.0.dist-info/entry_points.txt +0 -2
  111. {kicad_sch_api-0.3.0.dist-info → kicad_sch_api-0.5.1.dist-info}/WHEEL +0 -0
  112. {kicad_sch_api-0.3.0.dist-info → kicad_sch_api-0.5.1.dist-info}/licenses/LICENSE +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
@@ -13,18 +14,39 @@ from typing import Any, Dict, List, Optional, Tuple, Union
13
14
 
14
15
  import sexpdata
15
16
 
17
+ from ..collections import (
18
+ ComponentCollection,
19
+ JunctionCollection,
20
+ LabelCollection,
21
+ LabelElement,
22
+ WireCollection,
23
+ )
16
24
  from ..library.cache import get_symbol_cache
17
25
  from ..utils.validation import SchematicValidator, ValidationError, ValidationIssue
18
- from .components import ComponentCollection
26
+ from .factories import ElementFactory
19
27
  from .formatter import ExactFormatter
20
- from .junctions import JunctionCollection
28
+ from .managers import (
29
+ FileIOManager,
30
+ FormatSyncManager,
31
+ GraphicsManager,
32
+ HierarchyManager,
33
+ MetadataManager,
34
+ SheetManager,
35
+ TextElementManager,
36
+ ValidationManager,
37
+ WireManager,
38
+ )
39
+ from .nets import NetCollection
40
+ from .no_connects import NoConnectCollection
21
41
  from .parser import SExpressionParser
42
+ from .texts import TextCollection
22
43
  from .types import (
23
44
  HierarchicalLabelShape,
24
45
  Junction,
25
46
  Label,
26
47
  LabelType,
27
48
  Net,
49
+ NoConnect,
28
50
  Point,
29
51
  SchematicSymbol,
30
52
  Sheet,
@@ -33,15 +55,15 @@ from .types import (
33
55
  TitleBlock,
34
56
  Wire,
35
57
  WireType,
58
+ point_from_dict_or_tuple,
36
59
  )
37
- from .wires import WireCollection
38
60
 
39
61
  logger = logging.getLogger(__name__)
40
62
 
41
63
 
42
64
  class Schematic:
43
65
  """
44
- Professional KiCAD schematic manipulation class.
66
+ Professional KiCAD schematic manipulation class with manager-based architecture.
45
67
 
46
68
  Features:
47
69
  - Exact format preservation
@@ -50,9 +72,10 @@ class Schematic:
50
72
  - Comprehensive validation
51
73
  - Performance optimization for large schematics
52
74
  - AI agent integration via MCP
75
+ - Modular architecture with specialized managers
53
76
 
54
77
  This class provides a modern, intuitive API while maintaining exact compatibility
55
- with KiCAD's native file format.
78
+ with KiCAD's native file format through specialized manager classes.
56
79
  """
57
80
 
58
81
  def __init__(
@@ -62,7 +85,7 @@ class Schematic:
62
85
  name: Optional[str] = None,
63
86
  ):
64
87
  """
65
- Initialize schematic object.
88
+ Initialize schematic object with manager-based architecture.
66
89
 
67
90
  Args:
68
91
  schematic_data: Parsed schematic data
@@ -73,69 +96,73 @@ class Schematic:
73
96
  self._data = schematic_data or self._create_empty_schematic_data()
74
97
  self._file_path = Path(file_path) if file_path else None
75
98
  self._original_content = self._data.get("_original_content", "")
76
- self.name = name or "simple_circuit" # Store project name
99
+ self.name = name or "simple_circuit"
77
100
 
78
101
  # Initialize parser and formatter
79
102
  self._parser = SExpressionParser(preserve_format=True)
80
- self._parser.project_name = self.name # Pass project name to parser
103
+ self._parser.project_name = self.name
81
104
  self._formatter = ExactFormatter()
82
- self._validator = SchematicValidator()
105
+ self._legacy_validator = SchematicValidator() # Keep for compatibility
83
106
 
84
107
  # Initialize component collection
85
108
  component_symbols = [
86
109
  SchematicSymbol(**comp) if isinstance(comp, dict) else comp
87
110
  for comp in self._data.get("components", [])
88
111
  ]
89
- self._components = ComponentCollection(component_symbols)
112
+ self._components = ComponentCollection(component_symbols, parent_schematic=self)
90
113
 
91
114
  # Initialize wire collection
92
115
  wire_data = self._data.get("wires", [])
93
- wires = []
94
- for wire_dict in wire_data:
95
- if isinstance(wire_dict, dict):
96
- # Convert dict to Wire object
97
- points = []
98
- for point_data in wire_dict.get("points", []):
99
- if isinstance(point_data, dict):
100
- points.append(Point(point_data["x"], point_data["y"]))
101
- elif isinstance(point_data, (list, tuple)):
102
- points.append(Point(point_data[0], point_data[1]))
103
- else:
104
- points.append(point_data)
105
-
106
- wire = Wire(
107
- uuid=wire_dict.get("uuid", str(uuid.uuid4())),
108
- points=points,
109
- wire_type=WireType(wire_dict.get("wire_type", "wire")),
110
- stroke_width=wire_dict.get("stroke_width", 0.0),
111
- stroke_type=wire_dict.get("stroke_type", "default"),
112
- )
113
- wires.append(wire)
116
+ wires = ElementFactory.create_wires_from_list(wire_data)
114
117
  self._wires = WireCollection(wires)
115
118
 
116
119
  # Initialize junction collection
117
120
  junction_data = self._data.get("junctions", [])
118
- junctions = []
119
- for junction_dict in junction_data:
120
- if isinstance(junction_dict, dict):
121
- # Convert dict to Junction object
122
- position = junction_dict.get("position", {"x": 0, "y": 0})
123
- if isinstance(position, dict):
124
- pos = Point(position["x"], position["y"])
125
- elif isinstance(position, (list, tuple)):
126
- pos = Point(position[0], position[1])
127
- else:
128
- pos = position
129
-
130
- junction = Junction(
131
- uuid=junction_dict.get("uuid", str(uuid.uuid4())),
132
- position=pos,
133
- diameter=junction_dict.get("diameter", 0),
134
- color=junction_dict.get("color", (0, 0, 0, 0)),
135
- )
136
- junctions.append(junction)
121
+ junctions = ElementFactory.create_junctions_from_list(junction_data)
137
122
  self._junctions = JunctionCollection(junctions)
138
123
 
124
+ # Initialize text collection
125
+ text_data = self._data.get("texts", [])
126
+ texts = ElementFactory.create_texts_from_list(text_data)
127
+ self._texts = TextCollection(texts)
128
+
129
+ # Initialize label collection
130
+ label_data = self._data.get("labels", [])
131
+ labels = ElementFactory.create_labels_from_list(label_data)
132
+ self._labels = LabelCollection(labels)
133
+
134
+ # Initialize hierarchical labels collection (from both labels array and hierarchical_labels array)
135
+ hierarchical_labels = [
136
+ label for label in labels if label.label_type == LabelType.HIERARCHICAL
137
+ ]
138
+
139
+ # Also load from hierarchical_labels data if present
140
+ hierarchical_label_data = self._data.get("hierarchical_labels", [])
141
+ hierarchical_labels.extend(ElementFactory.create_labels_from_list(hierarchical_label_data))
142
+
143
+ self._hierarchical_labels = LabelCollection(hierarchical_labels)
144
+
145
+ # Initialize no-connect collection
146
+ no_connect_data = self._data.get("no_connects", [])
147
+ no_connects = ElementFactory.create_no_connects_from_list(no_connect_data)
148
+ self._no_connects = NoConnectCollection(no_connects)
149
+
150
+ # Initialize net collection
151
+ net_data = self._data.get("nets", [])
152
+ nets = ElementFactory.create_nets_from_list(net_data)
153
+ self._nets = NetCollection(nets)
154
+
155
+ # Initialize specialized managers
156
+ self._file_io_manager = FileIOManager()
157
+ self._format_sync_manager = FormatSyncManager(self._data)
158
+ self._graphics_manager = GraphicsManager(self._data)
159
+ self._hierarchy_manager = HierarchyManager(self._data)
160
+ self._metadata_manager = MetadataManager(self._data)
161
+ self._sheet_manager = SheetManager(self._data)
162
+ self._text_element_manager = TextElementManager(self._data)
163
+ self._wire_manager = WireManager(self._data, self._wires, self._components, self)
164
+ self._validation_manager = ValidationManager(self._data, self._components, self._wires)
165
+
139
166
  # Track modifications for save optimization
140
167
  self._modified = False
141
168
  self._last_save_time = None
@@ -144,8 +171,16 @@ class Schematic:
144
171
  self._operation_count = 0
145
172
  self._total_operation_time = 0.0
146
173
 
174
+ # Hierarchical design context (for child schematics)
175
+ self._parent_uuid: Optional[str] = None
176
+ self._sheet_uuid: Optional[str] = None
177
+ self._hierarchy_path: Optional[str] = None
178
+
147
179
  logger.debug(
148
- f"Schematic initialized with {len(self._components)} components, {len(self._wires)} wires, and {len(self._junctions)} junctions"
180
+ f"Schematic initialized with {len(self._components)} components, {len(self._wires)} wires, "
181
+ f"{len(self._junctions)} junctions, {len(self._texts)} texts, {len(self._labels)} labels, "
182
+ f"{len(self._hierarchical_labels)} hierarchical labels, {len(self._no_connects)} no-connects, "
183
+ f"and {len(self._nets)} nets with managers initialized"
149
184
  )
150
185
 
151
186
  @classmethod
@@ -168,8 +203,9 @@ class Schematic:
168
203
 
169
204
  logger.info(f"Loading schematic: {file_path}")
170
205
 
171
- parser = SExpressionParser(preserve_format=True)
172
- schematic_data = parser.parse_file(file_path)
206
+ # Use FileIOManager for loading
207
+ file_io_manager = FileIOManager()
208
+ schematic_data = file_io_manager.load_schematic(file_path)
173
209
 
174
210
  load_time = time.time() - start_time
175
211
  logger.info(f"Loaded schematic in {load_time:.3f}s")
@@ -180,10 +216,10 @@ class Schematic:
180
216
  def create(
181
217
  cls,
182
218
  name: str = "Untitled",
183
- version: str = "20250114",
184
- generator: str = "eeschema",
185
- generator_version: str = "9.0",
186
- paper: str = "A4",
219
+ version: str = None,
220
+ generator: str = None,
221
+ generator_version: str = None,
222
+ paper: str = None,
187
223
  uuid: str = None,
188
224
  ) -> "Schematic":
189
225
  """
@@ -191,15 +227,23 @@ class Schematic:
191
227
 
192
228
  Args:
193
229
  name: Schematic name
194
- version: KiCAD version (default: "20250114")
195
- generator: Generator name (default: "eeschema")
196
- generator_version: Generator version (default: "9.0")
197
- paper: Paper size (default: "A4")
230
+ version: KiCAD version (default from config)
231
+ generator: Generator name (default from config)
232
+ generator_version: Generator version (default from config)
233
+ paper: Paper size (default from config)
198
234
  uuid: Specific UUID (auto-generated if None)
199
235
 
200
236
  Returns:
201
237
  New empty Schematic object
202
238
  """
239
+ # Apply config defaults for None values
240
+ from .config import config
241
+
242
+ version = version or config.file_format.version_default
243
+ generator = generator or config.file_format.generator_default
244
+ generator_version = generator_version or config.file_format.generator_version_default
245
+ paper = paper or config.paper.default
246
+
203
247
  # Special handling for blank schematic test case to match reference exactly
204
248
  if name == "Blank Schematic":
205
249
  schematic_data = {
@@ -212,8 +256,10 @@ class Schematic:
212
256
  "junctions": [],
213
257
  "labels": [],
214
258
  "nets": [],
215
- "lib_symbols": [], # Empty list for blank schematic
259
+ "lib_symbols": {}, # Empty dict for blank schematic
216
260
  "symbol_instances": [],
261
+ "sheet_instances": [],
262
+ "embedded_fonts": "no",
217
263
  }
218
264
  else:
219
265
  schematic_data = cls._create_empty_schematic_data()
@@ -276,15 +322,114 @@ class Schematic:
276
322
  @property
277
323
  def modified(self) -> bool:
278
324
  """Whether schematic has been modified since last save."""
279
- return self._modified or self._components._modified
325
+ return (
326
+ self._modified
327
+ or self._components.modified
328
+ or self._wires.modified
329
+ or self._junctions.modified
330
+ or self._texts._modified
331
+ or self._labels.modified
332
+ or self._hierarchical_labels.modified
333
+ or self._no_connects._modified
334
+ or self._nets._modified
335
+ or self._format_sync_manager.is_dirty()
336
+ )
337
+
338
+ @property
339
+ def texts(self) -> TextCollection:
340
+ """Collection of all text elements in the schematic."""
341
+ return self._texts
342
+
343
+ @property
344
+ def labels(self) -> LabelCollection:
345
+ """Collection of all label elements in the schematic."""
346
+ return self._labels
347
+
348
+ @property
349
+ def hierarchical_labels(self) -> LabelCollection:
350
+ """Collection of all hierarchical label elements in the schematic."""
351
+ return self._hierarchical_labels
352
+
353
+ @property
354
+ def no_connects(self) -> NoConnectCollection:
355
+ """Collection of all no-connect elements in the schematic."""
356
+ return self._no_connects
357
+
358
+ @property
359
+ def nets(self) -> NetCollection:
360
+ """Collection of all electrical nets in the schematic."""
361
+ return self._nets
362
+
363
+ @property
364
+ def sheets(self):
365
+ """Sheet manager for hierarchical sheet operations."""
366
+ return self._sheet_manager
367
+
368
+ @property
369
+ def hierarchy(self):
370
+ """
371
+ Advanced hierarchy manager for complex hierarchical designs.
372
+
373
+ Provides features for:
374
+ - Sheet reuse tracking (sheets used multiple times)
375
+ - Cross-sheet signal tracking
376
+ - Sheet pin validation
377
+ - Hierarchy flattening
378
+ - Signal tracing through hierarchy
379
+ """
380
+ return self._hierarchy_manager
280
381
 
281
- # Pin positioning methods (migrated from circuit-synth)
382
+ def set_hierarchy_context(self, parent_uuid: str, sheet_uuid: str) -> None:
383
+ """
384
+ Set hierarchical context for this schematic (for child schematics in hierarchical designs).
385
+
386
+ This method configures a child schematic to be part of a hierarchical design.
387
+ Components added after this call will automatically have the correct hierarchical
388
+ instance path for proper annotation in KiCad.
389
+
390
+ Args:
391
+ parent_uuid: UUID of the parent schematic
392
+ sheet_uuid: UUID of the sheet instance in the parent schematic
393
+
394
+ Example:
395
+ >>> # Create parent schematic
396
+ >>> main = ksa.create_schematic("MyProject")
397
+ >>> parent_uuid = main.uuid
398
+ >>>
399
+ >>> # Add sheet to parent and get its UUID
400
+ >>> sheet_uuid = main.sheets.add_sheet(
401
+ ... name="Power Supply",
402
+ ... filename="power.kicad_sch",
403
+ ... position=(50, 50),
404
+ ... size=(100, 100),
405
+ ... project_name="MyProject"
406
+ ... )
407
+ >>>
408
+ >>> # Create child schematic with hierarchy context
409
+ >>> power = ksa.create_schematic("MyProject")
410
+ >>> power.set_hierarchy_context(parent_uuid, sheet_uuid)
411
+ >>>
412
+ >>> # Components added now will have correct hierarchical path
413
+ >>> vreg = power.components.add('Device:R', 'U1', 'AMS1117-3.3')
414
+
415
+ Note:
416
+ - This must be called BEFORE adding components to the child schematic
417
+ - Both parent and child schematics must use the same project name
418
+ - The hierarchical path will be: /{parent_uuid}/{sheet_uuid}
419
+ """
420
+ self._parent_uuid = parent_uuid
421
+ self._sheet_uuid = sheet_uuid
422
+ self._hierarchy_path = f"/{parent_uuid}/{sheet_uuid}"
423
+
424
+ logger.info(
425
+ f"Set hierarchy context: parent={parent_uuid}, sheet={sheet_uuid}, path={self._hierarchy_path}"
426
+ )
427
+
428
+ # Pin positioning methods (delegated to WireManager)
282
429
  def get_component_pin_position(self, reference: str, pin_number: str) -> Optional[Point]:
283
430
  """
284
431
  Get the absolute position of a component pin.
285
432
 
286
- Migrated from circuit-synth with enhanced logging for verification.
287
-
288
433
  Args:
289
434
  reference: Component reference (e.g., "R1")
290
435
  pin_number: Pin number to find (e.g., "1", "2")
@@ -292,20 +437,7 @@ class Schematic:
292
437
  Returns:
293
438
  Absolute position of the pin, or None if not found
294
439
  """
295
- from .pin_utils import get_component_pin_position
296
-
297
- # Find the component
298
- component = None
299
- for comp in self._components:
300
- if comp.reference == reference:
301
- component = comp
302
- break
303
-
304
- if not component:
305
- logger.warning(f"Component {reference} not found")
306
- return None
307
-
308
- return get_component_pin_position(component, pin_number)
440
+ return self._wire_manager.get_component_pin_position(reference, pin_number)
309
441
 
310
442
  def list_component_pins(self, reference: str) -> List[Tuple[str, Point]]:
311
443
  """
@@ -317,22 +449,62 @@ class Schematic:
317
449
  Returns:
318
450
  List of (pin_number, absolute_position) tuples
319
451
  """
320
- from .pin_utils import list_component_pins
452
+ return self._wire_manager.list_component_pins(reference)
321
453
 
322
- # Find the component
323
- component = None
324
- for comp in self._components:
325
- if comp.reference == reference:
326
- component = comp
327
- break
454
+ # Connectivity methods (delegated to WireManager)
455
+ def are_pins_connected(
456
+ self, component1_ref: str, pin1_number: str, component2_ref: str, pin2_number: str
457
+ ) -> bool:
458
+ """
459
+ Check if two pins are electrically connected.
460
+
461
+ Performs full connectivity analysis including connections through:
462
+ - Direct wires
463
+ - Junctions
464
+ - Labels (local/global/hierarchical)
465
+ - Power symbols
466
+ - Hierarchical sheets
467
+
468
+ Args:
469
+ component1_ref: First component reference (e.g., "R1")
470
+ pin1_number: First pin number
471
+ component2_ref: Second component reference (e.g., "R2")
472
+ pin2_number: Second pin number
473
+
474
+ Returns:
475
+ True if pins are electrically connected, False otherwise
476
+ """
477
+ return self._wire_manager.are_pins_connected(
478
+ component1_ref, pin1_number, component2_ref, pin2_number
479
+ )
480
+
481
+ def get_net_for_pin(self, component_ref: str, pin_number: str):
482
+ """
483
+ Get the electrical net connected to a specific pin.
484
+
485
+ Args:
486
+ component_ref: Component reference (e.g., "R1")
487
+ pin_number: Pin number
488
+
489
+ Returns:
490
+ Net object if pin is connected, None otherwise
491
+ """
492
+ return self._wire_manager.get_net_for_pin(component_ref, pin_number)
493
+
494
+ def get_connected_pins(self, component_ref: str, pin_number: str) -> List[Tuple[str, str]]:
495
+ """
496
+ Get all pins electrically connected to a specific pin.
328
497
 
329
- if not component:
330
- logger.warning(f"Component {reference} not found")
331
- return []
498
+ Args:
499
+ component_ref: Component reference (e.g., "R1")
500
+ pin_number: Pin number
332
501
 
333
- return list_component_pins(component)
502
+ Returns:
503
+ List of (reference, pin_number) tuples for all connected pins
504
+ """
505
+ return self._wire_manager.get_connected_pins(component_ref, pin_number)
334
506
 
335
- # File operations
507
+ # File operations (delegated to FileIOManager)
336
508
  def save(self, file_path: Optional[Union[str, Path]] = None, preserve_format: bool = True):
337
509
  """
338
510
  Save schematic to file.
@@ -361,31 +533,30 @@ class Schematic:
361
533
  if errors:
362
534
  raise ValidationError("Cannot save schematic with validation errors", errors)
363
535
 
364
- # Update data structure with current component, wire, and junction state
536
+ # Sync collection state back to data structure (critical for save)
365
537
  self._sync_components_to_data()
366
538
  self._sync_wires_to_data()
367
539
  self._sync_junctions_to_data()
540
+ self._sync_texts_to_data()
541
+ self._sync_labels_to_data()
542
+ self._sync_hierarchical_labels_to_data()
543
+ self._sync_no_connects_to_data()
544
+ self._sync_nets_to_data()
368
545
 
369
- # Write file
370
- if preserve_format and self._original_content:
371
- # Use format-preserving writer
372
- sexp_data = self._parser._schematic_data_to_sexp(self._data)
373
- content = self._formatter.format_preserving_write(sexp_data, self._original_content)
374
- else:
375
- # Standard formatting
376
- sexp_data = self._parser._schematic_data_to_sexp(self._data)
377
- content = self._formatter.format(sexp_data)
378
-
379
- # Ensure directory exists
380
- file_path.parent.mkdir(parents=True, exist_ok=True)
546
+ # Ensure FileIOManager's parser has the correct project name
547
+ self._file_io_manager._parser.project_name = self.name
381
548
 
382
- # Write to file
383
- with open(file_path, "w", encoding="utf-8") as f:
384
- f.write(content)
549
+ # Use FileIOManager for saving
550
+ self._file_io_manager.save_schematic(self._data, file_path, preserve_format)
385
551
 
386
552
  # Update state
387
553
  self._modified = False
388
- self._components._modified = False
554
+ self._components.mark_saved()
555
+ self._wires.mark_saved()
556
+ self._junctions.mark_saved()
557
+ self._labels.mark_saved()
558
+ self._hierarchical_labels.mark_saved()
559
+ self._format_sync_manager.clear_dirty_flags()
389
560
  self._last_save_time = time.time()
390
561
 
391
562
  save_time = time.time() - start_time
@@ -400,121 +571,75 @@ class Schematic:
400
571
  Create a backup of the current schematic file.
401
572
 
402
573
  Args:
403
- suffix: Suffix to add to backup filename
574
+ suffix: Backup file suffix
404
575
 
405
576
  Returns:
406
577
  Path to backup file
407
578
  """
408
- if not self._file_path:
409
- raise ValidationError("Cannot backup - no file path set")
410
-
411
- backup_path = self._file_path.with_suffix(self._file_path.suffix + suffix)
412
-
413
- if self._file_path.exists():
414
- import shutil
415
-
416
- shutil.copy2(self._file_path, backup_path)
417
- logger.info(f"Created backup: {backup_path}")
579
+ if self._file_path is None:
580
+ raise ValidationError("Cannot backup schematic with no file path")
418
581
 
419
- return backup_path
582
+ return self._file_io_manager.create_backup(self._file_path, suffix)
420
583
 
421
- # Validation and analysis
422
- def validate(self) -> List[ValidationIssue]:
423
- """
424
- Validate the schematic for errors and issues.
425
-
426
- Returns:
427
- List of validation issues found
584
+ def export_to_python(
585
+ self,
586
+ output_path: Union[str, Path],
587
+ template: str = 'default',
588
+ include_hierarchy: bool = True,
589
+ format_code: bool = True,
590
+ add_comments: bool = True
591
+ ) -> Path:
428
592
  """
429
- # Sync current state to data for validation
430
- self._sync_components_to_data()
431
-
432
- # Use validator to check schematic
433
- issues = self._validator.validate_schematic_data(self._data)
593
+ Export schematic to executable Python code.
434
594
 
435
- # Add component-level validation
436
- component_issues = self._components.validate_all()
437
- issues.extend(component_issues)
595
+ Generates Python code that uses kicad-sch-api to recreate this
596
+ schematic programmatically.
438
597
 
439
- return issues
440
-
441
- # Focused helper functions for specific KiCAD sections
442
- def add_lib_symbols_section(self, lib_symbols: Dict[str, Any]):
443
- """Add or update lib_symbols section with specific symbol definitions."""
444
- self._data["lib_symbols"] = lib_symbols
445
- self._modified = True
598
+ Args:
599
+ output_path: Output .py file path
600
+ template: Code template style ('minimal', 'default', 'verbose', 'documented')
601
+ include_hierarchy: Include hierarchical sheets
602
+ format_code: Format code with Black
603
+ add_comments: Add explanatory comments
446
604
 
447
- def add_instances_section(self, instances: Dict[str, Any]):
448
- """Add instances section for component placement tracking."""
449
- self._data["instances"] = instances
450
- self._modified = True
605
+ Returns:
606
+ Path to generated Python file
451
607
 
452
- def add_sheet_instances_section(self, sheet_instances: List[Dict]):
453
- """Add sheet_instances section for hierarchical design."""
454
- self._data["sheet_instances"] = sheet_instances
455
- self._modified = True
608
+ Raises:
609
+ CodeGenerationError: If code generation fails
456
610
 
457
- def set_paper_size(self, paper: str):
458
- """Set paper size (A4, A3, etc.)."""
459
- self._data["paper"] = paper
460
- self._modified = True
611
+ Example:
612
+ >>> sch = Schematic.load('circuit.kicad_sch')
613
+ >>> sch.export_to_python('circuit.py')
614
+ PosixPath('circuit.py')
461
615
 
462
- def set_version_info(
463
- self, version: str, generator: str = "eeschema", generator_version: str = "9.0"
464
- ):
465
- """Set version and generator information."""
466
- self._data["version"] = version
467
- self._data["generator"] = generator
468
- self._data["generator_version"] = generator_version
469
- self._modified = True
616
+ >>> sch.export_to_python('circuit.py',
617
+ ... template='verbose',
618
+ ... add_comments=True)
619
+ PosixPath('circuit.py')
620
+ """
621
+ from ..exporters.python_generator import PythonCodeGenerator
470
622
 
471
- def copy_metadata_from(self, source_schematic: "Schematic"):
472
- """Copy all metadata from another schematic (version, generator, paper, etc.)."""
473
- metadata_fields = [
474
- "version",
475
- "generator",
476
- "generator_version",
477
- "paper",
478
- "uuid",
479
- "title_block",
480
- ]
481
- for field in metadata_fields:
482
- if field in source_schematic._data:
483
- self._data[field] = source_schematic._data[field]
484
- self._modified = True
623
+ generator = PythonCodeGenerator(
624
+ template=template,
625
+ format_code=format_code,
626
+ add_comments=add_comments
627
+ )
485
628
 
486
- def get_summary(self) -> Dict[str, Any]:
487
- """Get summary information about the schematic."""
488
- component_stats = self._components.get_statistics()
629
+ generator.generate(
630
+ schematic=self,
631
+ include_hierarchy=include_hierarchy,
632
+ output_path=Path(output_path)
633
+ )
489
634
 
490
- return {
491
- "file_path": str(self._file_path) if self._file_path else None,
492
- "version": self.version,
493
- "uuid": self.uuid,
494
- "title": self.title_block.get("title", ""),
495
- "component_count": len(self._components),
496
- "modified": self.modified,
497
- "last_save": self._last_save_time,
498
- "component_stats": component_stats,
499
- "performance": {
500
- "operation_count": self._operation_count,
501
- "avg_operation_time_ms": round(
502
- (
503
- (self._total_operation_time / self._operation_count * 1000)
504
- if self._operation_count > 0
505
- else 0
506
- ),
507
- 2,
508
- ),
509
- },
510
- }
635
+ return Path(output_path)
511
636
 
512
- # Wire and connection management (basic implementation)
637
+ # Wire operations (delegated to WireManager)
513
638
  def add_wire(
514
639
  self, start: Union[Point, Tuple[float, float]], end: Union[Point, Tuple[float, float]]
515
640
  ) -> str:
516
641
  """
517
- Add a wire connection.
642
+ Add a wire connection between two points.
518
643
 
519
644
  Args:
520
645
  start: Start point
@@ -523,775 +648,681 @@ class Schematic:
523
648
  Returns:
524
649
  UUID of created wire
525
650
  """
526
- if isinstance(start, tuple):
527
- start = Point(start[0], start[1])
528
- if isinstance(end, tuple):
529
- end = Point(end[0], end[1])
530
-
531
- # Use the wire collection to add the wire
532
- wire_uuid = self._wires.add(start=start, end=end)
651
+ wire_uuid = self._wire_manager.add_wire(start, end)
652
+ self._format_sync_manager.mark_dirty("wire", "add", {"uuid": wire_uuid})
533
653
  self._modified = True
534
-
535
- logger.debug(f"Added wire: {start} -> {end}")
536
654
  return wire_uuid
537
655
 
538
656
  def remove_wire(self, wire_uuid: str) -> bool:
539
- """Remove wire by UUID."""
540
- # Remove from wire collection
541
- removed_from_collection = self._wires.remove(wire_uuid)
542
-
543
- # Also remove from data structure for consistency
544
- wires = self._data.get("wires", [])
545
- removed_from_data = False
546
- for i, wire in enumerate(wires):
547
- if wire.get("uuid") == wire_uuid:
548
- del wires[i]
549
- removed_from_data = True
550
- break
551
-
552
- if removed_from_collection or removed_from_data:
657
+ """
658
+ Remove a wire by UUID.
659
+
660
+ Args:
661
+ wire_uuid: UUID of wire to remove
662
+
663
+ Returns:
664
+ True if wire was removed, False if not found
665
+ """
666
+ removed = self._wires.remove(wire_uuid)
667
+ if removed:
668
+ self._format_sync_manager.remove_wire_from_data(wire_uuid)
553
669
  self._modified = True
554
- logger.debug(f"Removed wire: {wire_uuid}")
555
- return True
556
- return False
670
+ return removed
557
671
 
558
- # Label management
559
- def add_hierarchical_label(
672
+ def auto_route_pins(
560
673
  self,
561
- text: str,
562
- position: Union[Point, Tuple[float, float]],
563
- shape: HierarchicalLabelShape = HierarchicalLabelShape.INPUT,
564
- rotation: float = 0.0,
565
- size: float = 1.27,
566
- ) -> str:
674
+ component1_ref: str,
675
+ pin1_number: str,
676
+ component2_ref: str,
677
+ pin2_number: str,
678
+ routing_strategy: str = "direct",
679
+ ) -> List[str]:
567
680
  """
568
- Add a hierarchical label.
681
+ Auto-route between two component pins.
569
682
 
570
683
  Args:
571
- text: Label text
572
- position: Label position
573
- shape: Label shape/direction
574
- rotation: Text rotation in degrees
575
- size: Font size
684
+ component1_ref: First component reference
685
+ pin1_number: First component pin number
686
+ component2_ref: Second component reference
687
+ pin2_number: Second component pin number
688
+ routing_strategy: Routing strategy ("direct", "orthogonal", "manhattan")
576
689
 
577
690
  Returns:
578
- UUID of created hierarchical label
691
+ List of wire UUIDs created
579
692
  """
580
- if isinstance(position, tuple):
581
- position = Point(position[0], position[1])
582
-
583
- label = Label(
584
- uuid=str(uuid.uuid4()),
585
- position=position,
586
- text=text,
587
- label_type=LabelType.HIERARCHICAL,
588
- rotation=rotation,
589
- size=size,
590
- shape=shape,
591
- )
592
-
593
- if "hierarchical_labels" not in self._data:
594
- self._data["hierarchical_labels"] = []
595
-
596
- self._data["hierarchical_labels"].append(
597
- {
598
- "uuid": label.uuid,
599
- "position": {"x": label.position.x, "y": label.position.y},
600
- "text": label.text,
601
- "shape": label.shape.value,
602
- "rotation": label.rotation,
603
- "size": label.size,
604
- }
693
+ wire_uuids = self._wire_manager.auto_route_pins(
694
+ component1_ref, pin1_number, component2_ref, pin2_number, routing_strategy
605
695
  )
696
+ for wire_uuid in wire_uuids:
697
+ self._format_sync_manager.mark_dirty("wire", "add", {"uuid": wire_uuid})
606
698
  self._modified = True
607
-
608
- logger.debug(f"Added hierarchical label: {text} at {position}")
609
- return label.uuid
610
-
611
- def remove_hierarchical_label(self, label_uuid: str) -> bool:
612
- """Remove hierarchical label by UUID."""
613
- labels = self._data.get("hierarchical_labels", [])
614
- for i, label in enumerate(labels):
615
- if label.get("uuid") == label_uuid:
616
- del labels[i]
617
- self._modified = True
618
- logger.debug(f"Removed hierarchical label: {label_uuid}")
619
- return True
620
- return False
699
+ return wire_uuids
621
700
 
622
701
  def add_wire_to_pin(
623
- self, start_point: Union[Point, Tuple[float, float]], component_ref: str, pin_number: str
702
+ self, start: Union[Point, Tuple[float, float]], component_ref: str, pin_number: str
624
703
  ) -> Optional[str]:
625
704
  """
626
- Draw a wire from a start point to a component pin.
705
+ Add wire from arbitrary position to component pin.
627
706
 
628
707
  Args:
629
- start_point: Starting point of the wire
630
- component_ref: Reference of the target component (e.g., "R1")
631
- pin_number: Pin number on the component (e.g., "1")
708
+ start: Start position
709
+ component_ref: Component reference
710
+ pin_number: Pin number
632
711
 
633
712
  Returns:
634
- UUID of created wire, or None if pin position cannot be determined
713
+ Wire UUID or None if pin not found
635
714
  """
636
- from .pin_utils import get_component_pin_position
637
-
638
- # Find the component
639
- component = self.components.get(component_ref)
640
- if not component:
641
- logger.warning(f"Component {component_ref} not found")
642
- return None
643
-
644
- # Get the pin position
645
- pin_position = get_component_pin_position(component, pin_number)
646
- if not pin_position:
647
- logger.warning(f"Could not determine position of pin {pin_number} on {component_ref}")
715
+ pin_pos = self.get_component_pin_position(component_ref, pin_number)
716
+ if pin_pos is None:
648
717
  return None
649
718
 
650
- # Create the wire
651
- return self.add_wire(start_point, pin_position)
719
+ return self.add_wire(start, pin_pos)
652
720
 
653
721
  def add_wire_between_pins(
654
722
  self, component1_ref: str, pin1_number: str, component2_ref: str, pin2_number: str
655
723
  ) -> Optional[str]:
656
724
  """
657
- Draw a wire between two component pins.
725
+ Add wire between two component pins.
658
726
 
659
727
  Args:
660
- component1_ref: Reference of the first component (e.g., "R1")
661
- pin1_number: Pin number on the first component (e.g., "1")
662
- component2_ref: Reference of the second component (e.g., "R2")
663
- pin2_number: Pin number on the second component (e.g., "2")
728
+ component1_ref: First component reference
729
+ pin1_number: First component pin number
730
+ component2_ref: Second component reference
731
+ pin2_number: Second component pin number
664
732
 
665
733
  Returns:
666
- UUID of created wire, or None if either pin position cannot be determined
734
+ Wire UUID or None if either pin not found
667
735
  """
668
- from .pin_utils import get_component_pin_position
669
-
670
- # Find both components
671
- component1 = self.components.get(component1_ref)
672
- component2 = self.components.get(component2_ref)
673
-
674
- if not component1:
675
- logger.warning(f"Component {component1_ref} not found")
676
- return None
677
- if not component2:
678
- logger.warning(f"Component {component2_ref} not found")
679
- return None
680
-
681
- # Get both pin positions
682
- pin1_position = get_component_pin_position(component1, pin1_number)
683
- pin2_position = get_component_pin_position(component2, pin2_number)
736
+ pin1_pos = self.get_component_pin_position(component1_ref, pin1_number)
737
+ pin2_pos = self.get_component_pin_position(component2_ref, pin2_number)
684
738
 
685
- if not pin1_position:
686
- logger.warning(f"Could not determine position of pin {pin1_number} on {component1_ref}")
687
- return None
688
- if not pin2_position:
689
- logger.warning(f"Could not determine position of pin {pin2_number} on {component2_ref}")
739
+ if pin1_pos is None or pin2_pos is None:
690
740
  return None
691
741
 
692
- # Create the wire
693
- return self.add_wire(pin1_position, pin2_position)
742
+ return self.add_wire(pin1_pos, pin2_pos)
694
743
 
695
- def get_component_pin_position(self, component_ref: str, pin_number: str) -> Optional[Point]:
744
+ def connect_pins_with_wire(
745
+ self, component1_ref: str, pin1_number: str, component2_ref: str, pin2_number: str
746
+ ) -> Optional[str]:
696
747
  """
697
- Get the absolute position of a component pin.
748
+ Connect two component pins with a wire (alias for add_wire_between_pins).
698
749
 
699
750
  Args:
700
- component_ref: Reference of the component (e.g., "R1")
701
- pin_number: Pin number on the component (e.g., "1")
751
+ component1_ref: First component reference
752
+ pin1_number: First component pin number
753
+ component2_ref: Second component reference
754
+ pin2_number: Second component pin number
702
755
 
703
756
  Returns:
704
- Absolute position of the pin, or None if not found
757
+ Wire UUID or None if either pin not found
705
758
  """
706
- from .pin_utils import get_component_pin_position
707
-
708
- component = self.components.get(component_ref)
709
- if not component:
710
- return None
711
-
712
- return get_component_pin_position(component, pin_number)
759
+ return self.add_wire_between_pins(component1_ref, pin1_number, component2_ref, pin2_number)
713
760
 
714
- # Wire routing and connectivity methods
715
- def auto_route_pins(
761
+ # Text and label operations (delegated to TextElementManager)
762
+ def add_label(
716
763
  self,
717
- comp1_ref: str,
718
- pin1_num: str,
719
- comp2_ref: str,
720
- pin2_num: str,
721
- routing_mode: str = "direct",
722
- clearance: float = 2.54,
723
- ) -> Optional[str]:
764
+ text: str,
765
+ position: Optional[Union[Point, Tuple[float, float]]] = None,
766
+ pin: Optional[Tuple[str, str]] = None,
767
+ effects: Optional[Dict[str, Any]] = None,
768
+ rotation: Optional[float] = None,
769
+ size: Optional[float] = None,
770
+ uuid: Optional[str] = None,
771
+ ) -> str:
724
772
  """
725
- Auto route between two pins with configurable routing strategies.
726
-
727
- All positions are snapped to KiCAD's 1.27mm grid for exact electrical connections.
773
+ Add a text label to the schematic.
728
774
 
729
775
  Args:
730
- comp1_ref: First component reference (e.g., 'R1')
731
- pin1_num: First component pin number (e.g., '1')
732
- comp2_ref: Second component reference (e.g., 'R2')
733
- pin2_num: Second component pin number (e.g., '2')
734
- routing_mode: Routing strategy:
735
- - "direct": Direct connection through components (default)
736
- - "manhattan": Manhattan routing with obstacle avoidance
737
- clearance: Clearance from obstacles in mm (for manhattan mode)
776
+ text: Label text content
777
+ position: Label position (required if pin not provided)
778
+ pin: Pin to attach label to as (component_ref, pin_number) tuple (alternative to position)
779
+ effects: Text effects (size, font, etc.)
780
+ rotation: Label rotation in degrees (default 0, or auto-calculated if pin provided)
781
+ size: Text size override (default from effects)
782
+ uuid: Specific UUID for label (auto-generated if None)
738
783
 
739
784
  Returns:
740
- UUID of created wire, or None if routing failed
741
- """
742
- from .wire_routing import route_pins_direct, snap_to_kicad_grid
743
-
744
- # Get pin positions
745
- pin1_pos = self.get_component_pin_position(comp1_ref, pin1_num)
746
- pin2_pos = self.get_component_pin_position(comp2_ref, pin2_num)
747
-
748
- if not pin1_pos or not pin2_pos:
749
- return None
750
-
751
- # Ensure positions are grid-snapped
752
- pin1_pos = snap_to_kicad_grid(pin1_pos)
753
- pin2_pos = snap_to_kicad_grid(pin2_pos)
754
-
755
- # Choose routing strategy
756
- if routing_mode.lower() == "manhattan":
757
- # Manhattan routing with obstacle avoidance
758
- from .simple_manhattan import auto_route_with_manhattan
759
-
760
- # Get component objects
761
- comp1 = self.components.get(comp1_ref)
762
- comp2 = self.components.get(comp2_ref)
763
-
764
- if not comp1 or not comp2:
765
- logger.warning(f"Component not found: {comp1_ref} or {comp2_ref}")
766
- return None
767
-
768
- return auto_route_with_manhattan(
769
- self,
770
- comp1,
771
- pin1_num,
772
- comp2,
773
- pin2_num,
774
- avoid_components=None, # Avoid all other components
775
- clearance=clearance,
776
- )
777
- else:
778
- # Default direct routing - just connect the pins
779
- return self.add_wire(pin1_pos, pin2_pos)
780
-
781
- def are_pins_connected(
782
- self, comp1_ref: str, pin1_num: str, comp2_ref: str, pin2_num: str
783
- ) -> bool:
784
- """
785
- Detect when two pins are connected via wire routing.
786
-
787
- Args:
788
- comp1_ref: First component reference (e.g., 'R1')
789
- pin1_num: First component pin number (e.g., '1')
790
- comp2_ref: Second component reference (e.g., 'R2')
791
- pin2_num: Second component pin number (e.g., '2')
785
+ UUID of created label
792
786
 
793
- Returns:
794
- True if pins are connected via wires, False otherwise
787
+ Raises:
788
+ ValueError: If neither position nor pin is provided, or if pin is not found
795
789
  """
796
- from .wire_routing import are_pins_connected
797
-
798
- return are_pins_connected(self, comp1_ref, pin1_num, comp2_ref, pin2_num)
790
+ from .pin_utils import get_component_pin_info
791
+
792
+ # Validate arguments
793
+ if position is None and pin is None:
794
+ raise ValueError("Either position or pin must be provided")
795
+ if position is not None and pin is not None:
796
+ raise ValueError("Cannot provide both position and pin")
797
+
798
+ # Handle pin-based placement
799
+ justify_h = "left"
800
+ justify_v = "bottom"
801
+
802
+ if pin is not None:
803
+ component_ref, pin_number = pin
804
+
805
+ # Get component
806
+ component = self._components.get(component_ref)
807
+ if component is None:
808
+ raise ValueError(f"Component {component_ref} not found")
809
+
810
+ # Get pin position and rotation
811
+ pin_info = get_component_pin_info(component, pin_number)
812
+ if pin_info is None:
813
+ raise ValueError(f"Pin {pin_number} not found on component {component_ref}")
814
+
815
+ pin_position, pin_rotation = pin_info
816
+ position = pin_position
817
+
818
+ # Calculate label rotation if not explicitly provided
819
+ if rotation is None:
820
+ # Label should face away from component:
821
+ # Pin rotation indicates where pin points INTO the component
822
+ # Label should face OPPOSITE direction
823
+ rotation = (pin_rotation + 180) % 360
824
+ logger.info(
825
+ f"Auto-calculated label rotation: {rotation}° (pin rotation: {pin_rotation}°)"
826
+ )
799
827
 
800
- # Legacy method names for compatibility
801
- def connect_pins_with_wire(
802
- self, component1_ref: str, pin1_number: str, component2_ref: str, pin2_number: str
803
- ) -> Optional[str]:
804
- """Legacy alias for add_wire_between_pins."""
805
- return self.add_wire_between_pins(component1_ref, pin1_number, component2_ref, pin2_number)
828
+ # Calculate justification based on pin angle
829
+ # This determines which corner of the text is anchored to the pin position
830
+ if pin_rotation == 0: # Pin points right into component
831
+ justify_h = "left"
832
+ justify_v = "bottom"
833
+ elif pin_rotation == 90: # Pin points up into component
834
+ justify_h = "right"
835
+ justify_v = "bottom"
836
+ elif pin_rotation == 180: # Pin points left into component
837
+ justify_h = "right"
838
+ justify_v = "bottom"
839
+ elif pin_rotation == 270: # Pin points down into component
840
+ justify_h = "left"
841
+ justify_v = "bottom"
842
+ logger.info(f"Auto-calculated justification: {justify_h} {justify_v} (pin angle: {pin_rotation}°)")
843
+
844
+ # Use default rotation if still not set
845
+ if rotation is None:
846
+ rotation = 0
847
+
848
+ # Use the new labels collection instead of manager
849
+ if size is None:
850
+ size = 1.27 # Default size
851
+ label = self._labels.add(
852
+ text, position, rotation=rotation, size=size,
853
+ justify_h=justify_h, justify_v=justify_v, uuid=uuid
854
+ )
855
+ self._sync_labels_to_data() # Sync immediately
856
+ self._format_sync_manager.mark_dirty("label", "add", {"uuid": label.uuid})
857
+ self._modified = True
858
+ return label.uuid
806
859
 
807
- def add_label(
860
+ def add_text(
808
861
  self,
809
862
  text: str,
810
863
  position: Union[Point, Tuple[float, float]],
811
864
  rotation: float = 0.0,
812
865
  size: float = 1.27,
813
- uuid: Optional[str] = None,
866
+ exclude_from_sim: bool = False,
867
+ effects: Optional[Dict[str, Any]] = None,
814
868
  ) -> str:
815
869
  """
816
- Add a local label.
870
+ Add free text annotation to the schematic.
817
871
 
818
872
  Args:
819
- text: Label text
820
- position: Label position
873
+ text: Text content
874
+ position: Text position
821
875
  rotation: Text rotation in degrees
822
- size: Font size
823
- uuid: Optional UUID (auto-generated if None)
876
+ size: Text size
877
+ exclude_from_sim: Whether to exclude from simulation
878
+ effects: Text effects
824
879
 
825
880
  Returns:
826
- UUID of created label
881
+ UUID of created text
827
882
  """
828
- if isinstance(position, tuple):
829
- position = Point(position[0], position[1])
830
-
831
- import uuid as uuid_module
832
-
833
- label = Label(
834
- uuid=uuid if uuid else str(uuid_module.uuid4()),
835
- position=position,
836
- text=text,
837
- label_type=LabelType.LOCAL,
838
- rotation=rotation,
839
- size=size,
840
- )
841
-
842
- if "labels" not in self._data:
843
- self._data["labels"] = []
844
-
845
- self._data["labels"].append(
846
- {
847
- "uuid": label.uuid,
848
- "position": {"x": label.position.x, "y": label.position.y},
849
- "text": label.text,
850
- "rotation": label.rotation,
851
- "size": label.size,
852
- }
883
+ # Use the new texts collection instead of manager
884
+ text_elem = self._texts.add(
885
+ text, position, rotation=rotation, size=size, exclude_from_sim=exclude_from_sim
853
886
  )
887
+ self._sync_texts_to_data() # Sync immediately
888
+ self._format_sync_manager.mark_dirty("text", "add", {"uuid": text_elem.uuid})
854
889
  self._modified = True
890
+ return text_elem.uuid
855
891
 
856
- logger.debug(f"Added local label: {text} at {position}")
857
- return label.uuid
858
-
859
- def remove_label(self, label_uuid: str) -> bool:
860
- """Remove local label by UUID."""
861
- labels = self._data.get("labels", [])
862
- for i, label in enumerate(labels):
863
- if label.get("uuid") == label_uuid:
864
- del labels[i]
865
- self._modified = True
866
- logger.debug(f"Removed local label: {label_uuid}")
867
- return True
868
- return False
869
-
870
- def add_sheet(
892
+ def add_text_box(
871
893
  self,
872
- name: str,
873
- filename: str,
894
+ text: str,
874
895
  position: Union[Point, Tuple[float, float]],
875
896
  size: Union[Point, Tuple[float, float]],
876
- stroke_width: float = 0.1524,
897
+ rotation: float = 0.0,
898
+ font_size: float = 1.27,
899
+ margins: Optional[Tuple[float, float, float, float]] = None,
900
+ stroke_width: Optional[float] = None,
877
901
  stroke_type: str = "solid",
902
+ fill_type: str = "none",
903
+ justify_horizontal: str = "left",
904
+ justify_vertical: str = "top",
878
905
  exclude_from_sim: bool = False,
879
- in_bom: bool = True,
880
- on_board: bool = True,
881
- project_name: str = "",
882
- page_number: str = "2",
883
- uuid: Optional[str] = None,
906
+ effects: Optional[Dict[str, Any]] = None,
907
+ stroke: Optional[Dict[str, Any]] = None,
884
908
  ) -> str:
885
909
  """
886
- Add a hierarchical sheet.
910
+ Add a text box with border to the schematic.
887
911
 
888
912
  Args:
889
- name: Sheet name (displayed above sheet)
890
- filename: Sheet filename (.kicad_sch file)
891
- position: Sheet position (top-left corner)
892
- size: Sheet size (width, height)
893
- stroke_width: Border line width
894
- stroke_type: Border line type
895
- exclude_from_sim: Exclude from simulation
896
- in_bom: Include in BOM
897
- on_board: Include on board
898
- project_name: Project name for instances
899
- page_number: Page number for instances
900
- uuid: Optional UUID (auto-generated if None)
913
+ text: Text content
914
+ position: Top-left position
915
+ size: Box size (width, height)
916
+ rotation: Text rotation in degrees
917
+ font_size: Text font size
918
+ margins: Box margins (top, bottom, left, right)
919
+ stroke_width: Border stroke width
920
+ stroke_type: Border stroke type (solid, dash, etc.)
921
+ fill_type: Fill type (none, outline, background)
922
+ justify_horizontal: Horizontal justification
923
+ justify_vertical: Vertical justification
924
+ exclude_from_sim: Whether to exclude from simulation
925
+ effects: Text effects (legacy)
926
+ stroke: Border stroke settings (legacy)
901
927
 
902
928
  Returns:
903
- UUID of created sheet
929
+ UUID of created text box
904
930
  """
905
- if isinstance(position, tuple):
906
- position = Point(position[0], position[1])
907
- if isinstance(size, tuple):
908
- size = Point(size[0], size[1])
909
-
910
- import uuid as uuid_module
911
-
912
- sheet = Sheet(
913
- uuid=uuid if uuid else str(uuid_module.uuid4()),
931
+ text_box_uuid = self._text_element_manager.add_text_box(
932
+ text=text,
914
933
  position=position,
915
934
  size=size,
916
- name=name,
917
- filename=filename,
918
- exclude_from_sim=exclude_from_sim,
919
- in_bom=in_bom,
920
- on_board=on_board,
935
+ rotation=rotation,
936
+ font_size=font_size,
937
+ margins=margins,
921
938
  stroke_width=stroke_width,
922
939
  stroke_type=stroke_type,
940
+ fill_type=fill_type,
941
+ justify_horizontal=justify_horizontal,
942
+ justify_vertical=justify_vertical,
943
+ exclude_from_sim=exclude_from_sim,
944
+ effects=effects,
945
+ stroke=stroke,
923
946
  )
924
-
925
- if "sheets" not in self._data:
926
- self._data["sheets"] = []
927
-
928
- self._data["sheets"].append(
929
- {
930
- "uuid": sheet.uuid,
931
- "position": {"x": sheet.position.x, "y": sheet.position.y},
932
- "size": {"width": sheet.size.x, "height": sheet.size.y},
933
- "name": sheet.name,
934
- "filename": sheet.filename,
935
- "exclude_from_sim": sheet.exclude_from_sim,
936
- "in_bom": sheet.in_bom,
937
- "on_board": sheet.on_board,
938
- "dnp": sheet.dnp,
939
- "fields_autoplaced": sheet.fields_autoplaced,
940
- "stroke_width": sheet.stroke_width,
941
- "stroke_type": sheet.stroke_type,
942
- "fill_color": sheet.fill_color,
943
- "pins": [], # Sheet pins added separately
944
- "project_name": project_name,
945
- "page_number": page_number,
946
- }
947
- )
947
+ self._format_sync_manager.mark_dirty("text_box", "add", {"uuid": text_box_uuid})
948
948
  self._modified = True
949
+ return text_box_uuid
949
950
 
950
- logger.debug(f"Added hierarchical sheet: {name} ({filename}) at {position}")
951
- return sheet.uuid
952
-
953
- def add_sheet_pin(
951
+ def add_hierarchical_label(
954
952
  self,
955
- sheet_uuid: str,
956
- name: str,
957
- pin_type: str = "input",
958
- position: Union[Point, Tuple[float, float]] = (0, 0),
959
- rotation: float = 0,
953
+ text: str,
954
+ position: Union[Point, Tuple[float, float]],
955
+ shape: str = "input",
956
+ rotation: float = 0.0,
960
957
  size: float = 1.27,
961
- justify: str = "right",
962
- uuid: Optional[str] = None,
958
+ effects: Optional[Dict[str, Any]] = None,
963
959
  ) -> str:
964
960
  """
965
- Add a pin to a hierarchical sheet.
961
+ Add a hierarchical label for sheet connections.
966
962
 
967
963
  Args:
968
- sheet_uuid: UUID of the sheet to add pin to
969
- name: Pin name (NET1, NET2, etc.)
970
- pin_type: Pin type (input, output, bidirectional, etc.)
971
- position: Pin position relative to sheet
972
- rotation: Pin rotation in degrees
973
- size: Font size for pin label
974
- justify: Text justification (left, right, center)
975
- uuid: Optional UUID (auto-generated if None)
964
+ text: Label text
965
+ position: Label position
966
+ shape: Shape type (input, output, bidirectional, tri_state, passive)
967
+ rotation: Label rotation in degrees (default 0)
968
+ size: Label text size (default 1.27)
969
+ effects: Text effects
976
970
 
977
971
  Returns:
978
- UUID of created sheet pin
972
+ UUID of created hierarchical label
979
973
  """
980
- if isinstance(position, tuple):
981
- position = Point(position[0], position[1])
982
-
983
- import uuid as uuid_module
984
-
985
- pin_uuid = uuid if uuid else str(uuid_module.uuid4())
986
-
987
- # Find the sheet in the data
988
- sheets = self._data.get("sheets", [])
989
- for sheet in sheets:
990
- if sheet.get("uuid") == sheet_uuid:
991
- # Add pin to the sheet's pins list
992
- pin_data = {
993
- "uuid": pin_uuid,
994
- "name": name,
995
- "pin_type": pin_type,
996
- "position": {"x": position.x, "y": position.y},
997
- "rotation": rotation,
998
- "size": size,
999
- "justify": justify,
1000
- }
1001
- sheet["pins"].append(pin_data)
1002
- self._modified = True
1003
-
1004
- logger.debug(f"Added sheet pin: {name} ({pin_type}) to sheet {sheet_uuid}")
1005
- return pin_uuid
1006
-
1007
- raise ValueError(f"Sheet with UUID '{sheet_uuid}' not found")
974
+ # Use the hierarchical_labels collection
975
+ hlabel = self._hierarchical_labels.add(text, position, rotation=rotation, size=size)
976
+ self._sync_hierarchical_labels_to_data() # Sync immediately
977
+ self._format_sync_manager.mark_dirty("hierarchical_label", "add", {"uuid": hlabel.uuid})
978
+ self._modified = True
979
+ return hlabel.uuid
1008
980
 
1009
- def add_text(
981
+ def add_global_label(
1010
982
  self,
1011
983
  text: str,
1012
984
  position: Union[Point, Tuple[float, float]],
1013
- rotation: float = 0.0,
1014
- size: float = 1.27,
1015
- exclude_from_sim: bool = False,
985
+ shape: str = "input",
986
+ effects: Optional[Dict[str, Any]] = None,
1016
987
  ) -> str:
1017
988
  """
1018
- Add a text element.
989
+ Add a global label for project-wide connections.
1019
990
 
1020
991
  Args:
1021
- text: Text content
1022
- position: Text position
1023
- rotation: Text rotation in degrees
1024
- size: Font size
1025
- exclude_from_sim: Exclude from simulation
992
+ text: Label text
993
+ position: Label position
994
+ shape: Shape type
995
+ effects: Text effects
1026
996
 
1027
997
  Returns:
1028
- UUID of created text element
998
+ UUID of created global label
1029
999
  """
1030
- if isinstance(position, tuple):
1031
- position = Point(position[0], position[1])
1000
+ label_uuid = self._text_element_manager.add_global_label(text, position, shape, effects)
1001
+ self._format_sync_manager.mark_dirty("global_label", "add", {"uuid": label_uuid})
1002
+ self._modified = True
1003
+ return label_uuid
1032
1004
 
1033
- text_element = Text(
1034
- uuid=str(uuid.uuid4()),
1035
- position=position,
1036
- text=text,
1037
- rotation=rotation,
1038
- size=size,
1039
- exclude_from_sim=exclude_from_sim,
1040
- )
1005
+ def remove_label(self, label_uuid: str) -> bool:
1006
+ """
1007
+ Remove a label by UUID.
1041
1008
 
1042
- if "texts" not in self._data:
1043
- self._data["texts"] = []
1009
+ Args:
1010
+ label_uuid: UUID of label to remove
1044
1011
 
1045
- self._data["texts"].append(
1046
- {
1047
- "uuid": text_element.uuid,
1048
- "position": {"x": text_element.position.x, "y": text_element.position.y},
1049
- "text": text_element.text,
1050
- "rotation": text_element.rotation,
1051
- "size": text_element.size,
1052
- "exclude_from_sim": text_element.exclude_from_sim,
1053
- }
1054
- )
1055
- self._modified = True
1012
+ Returns:
1013
+ True if label was removed, False if not found
1014
+ """
1015
+ removed = self._labels.remove(label_uuid)
1016
+ if removed:
1017
+ self._sync_labels_to_data() # Sync immediately
1018
+ self._format_sync_manager.mark_dirty("label", "remove", {"uuid": label_uuid})
1019
+ self._modified = True
1020
+ return removed
1021
+
1022
+ def remove_hierarchical_label(self, label_uuid: str) -> bool:
1023
+ """
1024
+ Remove a hierarchical label by UUID.
1056
1025
 
1057
- logger.debug(f"Added text: '{text}' at {position}")
1058
- return text_element.uuid
1026
+ Args:
1027
+ label_uuid: UUID of hierarchical label to remove
1059
1028
 
1060
- def add_text_box(
1029
+ Returns:
1030
+ True if hierarchical label was removed, False if not found
1031
+ """
1032
+ removed = self._hierarchical_labels.remove(label_uuid)
1033
+ if removed:
1034
+ self._sync_hierarchical_labels_to_data() # Sync immediately
1035
+ self._format_sync_manager.mark_dirty(
1036
+ "hierarchical_label", "remove", {"uuid": label_uuid}
1037
+ )
1038
+ self._modified = True
1039
+ return removed
1040
+
1041
+ # Sheet operations (delegated to SheetManager)
1042
+ def add_sheet(
1061
1043
  self,
1062
- text: str,
1044
+ name: str,
1045
+ filename: str,
1063
1046
  position: Union[Point, Tuple[float, float]],
1064
1047
  size: Union[Point, Tuple[float, float]],
1065
- rotation: float = 0.0,
1066
- font_size: float = 1.27,
1067
- margins: Tuple[float, float, float, float] = (0.9525, 0.9525, 0.9525, 0.9525),
1068
- stroke_width: float = 0.0,
1048
+ stroke_width: Optional[float] = None,
1069
1049
  stroke_type: str = "solid",
1070
- fill_type: str = "none",
1071
- justify_horizontal: str = "left",
1072
- justify_vertical: str = "top",
1073
- exclude_from_sim: bool = False,
1050
+ project_name: Optional[str] = None,
1051
+ page_number: Optional[str] = None,
1052
+ uuid: Optional[str] = None,
1074
1053
  ) -> str:
1075
1054
  """
1076
- Add a text box element.
1055
+ Add a hierarchical sheet to the schematic.
1077
1056
 
1078
1057
  Args:
1079
- text: Text content
1080
- position: Text box position (top-left corner)
1081
- size: Text box size (width, height)
1082
- rotation: Text rotation in degrees
1083
- font_size: Font size
1084
- margins: Margins (top, right, bottom, left)
1085
- stroke_width: Border line width
1086
- stroke_type: Border line type
1087
- fill_type: Fill type (none, solid, etc.)
1088
- justify_horizontal: Horizontal text alignment
1089
- justify_vertical: Vertical text alignment
1090
- exclude_from_sim: Exclude from simulation
1058
+ name: Sheet name/title
1059
+ filename: Referenced schematic filename
1060
+ position: Sheet position (top-left corner)
1061
+ size: Sheet size (width, height)
1062
+ stroke_width: Border stroke width
1063
+ stroke_type: Border stroke type (solid, dashed, etc.)
1064
+ project_name: Project name for this sheet
1065
+ page_number: Page number for this sheet
1066
+ uuid: Optional UUID for the sheet
1091
1067
 
1092
1068
  Returns:
1093
- UUID of created text box element
1069
+ UUID of created sheet
1094
1070
  """
1095
- if isinstance(position, tuple):
1096
- position = Point(position[0], position[1])
1097
- if isinstance(size, tuple):
1098
- size = Point(size[0], size[1])
1099
-
1100
- text_box = TextBox(
1101
- uuid=str(uuid.uuid4()),
1102
- position=position,
1103
- size=size,
1104
- text=text,
1105
- rotation=rotation,
1106
- font_size=font_size,
1107
- margins=margins,
1071
+ sheet_uuid = self._sheet_manager.add_sheet(
1072
+ name,
1073
+ filename,
1074
+ position,
1075
+ size,
1076
+ uuid_str=uuid,
1108
1077
  stroke_width=stroke_width,
1109
1078
  stroke_type=stroke_type,
1110
- fill_type=fill_type,
1111
- justify_horizontal=justify_horizontal,
1112
- justify_vertical=justify_vertical,
1113
- exclude_from_sim=exclude_from_sim,
1079
+ project_name=project_name,
1080
+ page_number=page_number,
1114
1081
  )
1082
+ self._format_sync_manager.mark_dirty("sheet", "add", {"uuid": sheet_uuid})
1083
+ self._modified = True
1084
+ return sheet_uuid
1115
1085
 
1116
- if "text_boxes" not in self._data:
1117
- self._data["text_boxes"] = []
1118
-
1119
- self._data["text_boxes"].append(
1120
- {
1121
- "uuid": text_box.uuid,
1122
- "position": {"x": text_box.position.x, "y": text_box.position.y},
1123
- "size": {"width": text_box.size.x, "height": text_box.size.y},
1124
- "text": text_box.text,
1125
- "rotation": text_box.rotation,
1126
- "font_size": text_box.font_size,
1127
- "margins": text_box.margins,
1128
- "stroke_width": text_box.stroke_width,
1129
- "stroke_type": text_box.stroke_type,
1130
- "fill_type": text_box.fill_type,
1131
- "justify_horizontal": text_box.justify_horizontal,
1132
- "justify_vertical": text_box.justify_vertical,
1133
- "exclude_from_sim": text_box.exclude_from_sim,
1134
- }
1086
+ def add_sheet_pin(
1087
+ self,
1088
+ sheet_uuid: str,
1089
+ name: str,
1090
+ pin_type: str,
1091
+ edge: str,
1092
+ position_along_edge: float,
1093
+ uuid: Optional[str] = None,
1094
+ ) -> str:
1095
+ """
1096
+ Add a pin to a hierarchical sheet using edge-based positioning.
1097
+
1098
+ Args:
1099
+ sheet_uuid: UUID of the sheet to add pin to
1100
+ name: Pin name
1101
+ pin_type: Pin type (input, output, bidirectional, tri_state, passive)
1102
+ edge: Edge to place pin on ("right", "bottom", "left", "top")
1103
+ position_along_edge: Distance along edge from reference corner (mm)
1104
+ uuid: Optional UUID for the pin
1105
+
1106
+ Returns:
1107
+ UUID of created sheet pin
1108
+
1109
+ Edge positioning (clockwise from right):
1110
+ - "right": Pins face right (0°), position measured from top edge
1111
+ - "bottom": Pins face down (270°), position measured from left edge
1112
+ - "left": Pins face left (180°), position measured from bottom edge
1113
+ - "top": Pins face up (90°), position measured from left edge
1114
+
1115
+ Example:
1116
+ >>> # Sheet at (100, 100) with size (50, 40)
1117
+ >>> sch.add_sheet_pin(
1118
+ ... sheet_uuid=sheet_id,
1119
+ ... name="DATA_IN",
1120
+ ... pin_type="input",
1121
+ ... edge="left",
1122
+ ... position_along_edge=20 # 20mm from top on left edge
1123
+ ... )
1124
+ """
1125
+ pin_uuid = self._sheet_manager.add_sheet_pin(
1126
+ sheet_uuid, name, pin_type, edge, position_along_edge, uuid_str=uuid
1135
1127
  )
1128
+ self._format_sync_manager.mark_dirty("sheet", "modify", {"uuid": sheet_uuid})
1136
1129
  self._modified = True
1130
+ return pin_uuid
1131
+
1132
+ def remove_sheet(self, sheet_uuid: str) -> bool:
1133
+ """
1134
+ Remove a sheet by UUID.
1135
+
1136
+ Args:
1137
+ sheet_uuid: UUID of sheet to remove
1137
1138
 
1138
- logger.debug(f"Added text box: '{text}' at {position} size {size}")
1139
- return text_box.uuid
1139
+ Returns:
1140
+ True if sheet was removed, False if not found
1141
+ """
1142
+ removed = self._sheet_manager.remove_sheet(sheet_uuid)
1143
+ if removed:
1144
+ self._format_sync_manager.mark_dirty("sheet", "remove", {"uuid": sheet_uuid})
1145
+ self._modified = True
1146
+ return removed
1140
1147
 
1148
+ # Graphics operations (delegated to GraphicsManager)
1141
1149
  def add_rectangle(
1142
1150
  self,
1143
1151
  start: Union[Point, Tuple[float, float]],
1144
1152
  end: Union[Point, Tuple[float, float]],
1145
- stroke_width: float = 0.0,
1146
- stroke_type: str = "default",
1147
- fill_type: str = "none"
1153
+ stroke_width: float = 0.127,
1154
+ stroke_type: str = "solid",
1155
+ fill_type: str = "none",
1156
+ stroke_color: Optional[Tuple[int, int, int, float]] = None,
1157
+ fill_color: Optional[Tuple[int, int, int, float]] = None,
1148
1158
  ) -> str:
1149
1159
  """
1150
- Add a graphical rectangle element.
1160
+ Add a rectangle to the schematic.
1151
1161
 
1152
1162
  Args:
1153
- start: Rectangle start point (top-left)
1154
- end: Rectangle end point (bottom-right)
1155
- stroke_width: Border line width
1156
- stroke_type: Border line type (default, solid, dash, dot, etc.)
1157
- fill_type: Fill type (none, solid, etc.)
1163
+ start: Top-left corner position
1164
+ end: Bottom-right corner position
1165
+ stroke_width: Line width
1166
+ stroke_type: Line type (solid, dash, dash_dot, dash_dot_dot, dot, or default)
1167
+ fill_type: Fill type (none, background, etc.)
1168
+ stroke_color: Stroke color as (r, g, b, a)
1169
+ fill_color: Fill color as (r, g, b, a)
1158
1170
 
1159
1171
  Returns:
1160
- UUID of created rectangle element
1172
+ UUID of created rectangle
1161
1173
  """
1162
- if isinstance(start, tuple):
1163
- start = Point(start[0], start[1])
1164
- if isinstance(end, tuple):
1165
- end = Point(end[0], end[1])
1174
+ # Validate stroke_type
1175
+ valid_stroke_types = ["solid", "dash", "dash_dot", "dash_dot_dot", "dot", "default"]
1176
+ if stroke_type not in valid_stroke_types:
1177
+ raise ValueError(
1178
+ f"Invalid stroke_type '{stroke_type}'. "
1179
+ f"Must be one of: {', '.join(valid_stroke_types)}"
1180
+ )
1166
1181
 
1167
- from .types import SchematicRectangle
1182
+ # Convert individual parameters to stroke/fill dicts
1183
+ stroke = {"width": stroke_width, "type": stroke_type}
1184
+ if stroke_color:
1185
+ stroke["color"] = stroke_color
1168
1186
 
1169
- rectangle = SchematicRectangle(
1170
- uuid=str(uuid.uuid4()),
1171
- start=start,
1172
- end=end,
1173
- stroke_width=stroke_width,
1174
- stroke_type=stroke_type,
1175
- fill_type=fill_type
1176
- )
1187
+ fill = {"type": fill_type}
1188
+ if fill_color:
1189
+ fill["color"] = fill_color
1177
1190
 
1178
- if "rectangles" not in self._data:
1179
- self._data["rectangles"] = []
1180
-
1181
- self._data["rectangles"].append({
1182
- "uuid": rectangle.uuid,
1183
- "start": {"x": rectangle.start.x, "y": rectangle.start.y},
1184
- "end": {"x": rectangle.end.x, "y": rectangle.end.y},
1185
- "stroke_width": rectangle.stroke_width,
1186
- "stroke_type": rectangle.stroke_type,
1187
- "fill_type": rectangle.fill_type
1188
- })
1191
+ rect_uuid = self._graphics_manager.add_rectangle(start, end, stroke, fill)
1192
+ self._format_sync_manager.mark_dirty("rectangle", "add", {"uuid": rect_uuid})
1189
1193
  self._modified = True
1194
+ return rect_uuid
1195
+
1196
+ def remove_rectangle(self, rect_uuid: str) -> bool:
1197
+ """
1198
+ Remove a rectangle by UUID.
1190
1199
 
1191
- logger.debug(f"Added rectangle: {start} to {end}")
1192
- return rectangle.uuid
1200
+ Args:
1201
+ rect_uuid: UUID of rectangle to remove
1193
1202
 
1194
- def set_title_block(
1203
+ Returns:
1204
+ True if removed, False if not found
1205
+ """
1206
+ removed = self._graphics_manager.remove_rectangle(rect_uuid)
1207
+ if removed:
1208
+ self._format_sync_manager.mark_dirty("rectangle", "remove", {"uuid": rect_uuid})
1209
+ self._modified = True
1210
+ return removed
1211
+
1212
+ def add_image(
1195
1213
  self,
1196
- title: str = "",
1197
- date: str = "",
1198
- rev: str = "",
1199
- company: str = "",
1200
- comments: Optional[Dict[int, str]] = None,
1201
- ):
1214
+ position: Union[Point, Tuple[float, float]],
1215
+ scale: float = 1.0,
1216
+ data: Optional[str] = None,
1217
+ ) -> str:
1202
1218
  """
1203
- Set title block information.
1219
+ Add an image to the schematic.
1204
1220
 
1205
1221
  Args:
1206
- title: Schematic title
1207
- date: Creation/revision date
1208
- rev: Revision number
1209
- company: Company name
1210
- comments: Numbered comments (1, 2, 3, etc.)
1211
- """
1212
- if comments is None:
1213
- comments = {}
1222
+ position: Image position
1223
+ scale: Image scale factor
1224
+ data: Base64 encoded image data
1214
1225
 
1215
- self._data["title_block"] = {
1216
- "title": title,
1217
- "date": date,
1218
- "rev": rev,
1219
- "company": company,
1220
- "comments": comments,
1221
- }
1226
+ Returns:
1227
+ UUID of created image
1228
+ """
1229
+ image_uuid = self._graphics_manager.add_image(position, scale, data)
1230
+ self._format_sync_manager.mark_dirty("image", "add", {"uuid": image_uuid})
1222
1231
  self._modified = True
1232
+ return image_uuid
1233
+
1234
+ def draw_bounding_box(
1235
+ self,
1236
+ bbox,
1237
+ stroke_width: float = 0.127,
1238
+ stroke_color: str = "black",
1239
+ stroke_type: str = "solid",
1240
+ ) -> str:
1241
+ """
1242
+ Draw a bounding box rectangle around the given bounding box.
1243
+
1244
+ Args:
1245
+ bbox: BoundingBox object with min_x, min_y, max_x, max_y
1246
+ stroke_width: Line width
1247
+ stroke_color: Line color
1248
+ stroke_type: Line type
1249
+
1250
+ Returns:
1251
+ UUID of created rectangle
1252
+ """
1253
+ # Convert bounding box to rectangle coordinates
1254
+ start = (bbox.min_x, bbox.min_y)
1255
+ end = (bbox.max_x, bbox.max_y)
1223
1256
 
1224
- logger.debug(f"Set title block: {title} rev {rev}")
1257
+ return self.add_rectangle(start, end, stroke_width=stroke_width, stroke_type=stroke_type)
1225
1258
 
1226
1259
  def draw_bounding_box(
1227
1260
  self,
1228
1261
  bbox: "BoundingBox",
1229
- stroke_width: float = 0,
1230
- stroke_color: str = None,
1231
- stroke_type: str = "default",
1232
- exclude_from_sim: bool = False,
1262
+ stroke_width: float = 0.127,
1263
+ stroke_color: Optional[str] = None,
1264
+ stroke_type: str = "solid",
1233
1265
  ) -> str:
1234
1266
  """
1235
- Draw a component bounding box as a visual rectangle using KiCAD rectangle graphics.
1267
+ Draw a single bounding box as a rectangle.
1236
1268
 
1237
1269
  Args:
1238
1270
  bbox: BoundingBox to draw
1239
- stroke_width: Line width for the rectangle (0 = thin, 1 = 1mm, etc.)
1240
- stroke_color: Color name ('red', 'blue', 'green', etc.) or None for default
1241
- stroke_type: Stroke type - KiCAD supports: 'default', 'solid', 'dash', 'dot', 'dash_dot', 'dash_dot_dot'
1242
- exclude_from_sim: Exclude from simulation
1271
+ stroke_width: Line width
1272
+ stroke_color: Line color name (red, green, blue, etc.) or None
1273
+ stroke_type: Line type (solid, dashed, etc.)
1243
1274
 
1244
1275
  Returns:
1245
- UUID of created rectangle element
1276
+ UUID of created rectangle
1246
1277
  """
1247
- # Import BoundingBox type
1248
1278
  from .component_bounds import BoundingBox
1249
1279
 
1250
- rect_uuid = str(uuid.uuid4())
1251
-
1252
- # Create rectangle data structure in KiCAD dictionary format
1253
- stroke_data = {"width": stroke_width, "type": stroke_type}
1254
-
1255
- # Add color if specified
1280
+ # Convert color name to RGBA tuple if provided
1281
+ stroke_rgba = None
1256
1282
  if stroke_color:
1257
- stroke_data["color"] = stroke_color
1258
-
1259
- rectangle_data = {
1260
- "uuid": rect_uuid,
1261
- "start": {"x": bbox.min_x, "y": bbox.min_y},
1262
- "end": {"x": bbox.max_x, "y": bbox.max_y},
1263
- "stroke": stroke_data,
1264
- "fill": {"type": "none"},
1265
- }
1266
-
1267
- # Add to schematic data
1268
- if "graphics" not in self._data:
1269
- self._data["graphics"] = []
1283
+ # Simple color name to RGB mapping
1284
+ color_map = {
1285
+ "red": (255, 0, 0, 1.0),
1286
+ "green": (0, 255, 0, 1.0),
1287
+ "blue": (0, 0, 255, 1.0),
1288
+ "yellow": (255, 255, 0, 1.0),
1289
+ "cyan": (0, 255, 255, 1.0),
1290
+ "magenta": (255, 0, 255, 1.0),
1291
+ "black": (0, 0, 0, 1.0),
1292
+ "white": (255, 255, 255, 1.0),
1293
+ }
1294
+ stroke_rgba = color_map.get(stroke_color.lower(), (0, 255, 0, 1.0))
1270
1295
 
1271
- self._data["graphics"].append(rectangle_data)
1272
- self._modified = True
1296
+ # Add rectangle using the manager
1297
+ rect_uuid = self.add_rectangle(
1298
+ start=(bbox.min_x, bbox.min_y),
1299
+ end=(bbox.max_x, bbox.max_y),
1300
+ stroke_width=stroke_width,
1301
+ stroke_type=stroke_type,
1302
+ stroke_color=stroke_rgba,
1303
+ )
1273
1304
 
1274
- logger.debug(f"Drew bounding box rectangle: {bbox}")
1305
+ logger.debug(f"Drew bounding box: {bbox}")
1275
1306
  return rect_uuid
1276
1307
 
1277
1308
  def draw_component_bounding_boxes(
1278
1309
  self,
1279
1310
  include_properties: bool = False,
1280
- stroke_width: float = 0.254,
1281
- stroke_color: str = "red",
1282
- stroke_type: str = "default",
1311
+ stroke_width: float = 0.127,
1312
+ stroke_color: str = "green",
1313
+ stroke_type: str = "solid",
1283
1314
  ) -> List[str]:
1284
1315
  """
1285
- Draw bounding boxes for all components in the schematic.
1316
+ Draw bounding boxes for all components.
1286
1317
 
1287
1318
  Args:
1288
- include_properties: Include space for Reference/Value labels
1289
- stroke_width: Line width for rectangles
1290
- stroke_color: Color for rectangles
1291
- stroke_type: Stroke type for rectangles
1319
+ include_properties: Whether to include properties in bounding box
1320
+ stroke_width: Line width
1321
+ stroke_color: Line color
1322
+ stroke_type: Line type
1292
1323
 
1293
1324
  Returns:
1294
- List of UUIDs for created rectangle elements
1325
+ List of rectangle UUIDs created
1295
1326
  """
1296
1327
  from .component_bounds import get_component_bounding_box
1297
1328
 
@@ -1305,70 +1336,199 @@ class Schematic:
1305
1336
  logger.info(f"Drew {len(uuids)} component bounding boxes")
1306
1337
  return uuids
1307
1338
 
1308
- # Library management
1309
- @property
1310
- def libraries(self) -> "LibraryManager":
1311
- """Access to library management."""
1312
- if not hasattr(self, "_library_manager"):
1313
- from ..library.manager import LibraryManager
1314
-
1315
- self._library_manager = LibraryManager(self)
1316
- return self._library_manager
1317
-
1318
- # Utility methods
1319
- def clear(self):
1320
- """Clear all components, wires, and other elements."""
1321
- self._data["components"] = []
1322
- self._data["wires"] = []
1323
- self._data["junctions"] = []
1324
- self._data["labels"] = []
1325
- self._components = ComponentCollection()
1339
+ # Metadata operations (delegated to MetadataManager)
1340
+ def set_title_block(
1341
+ self,
1342
+ title: str = "",
1343
+ date: str = "",
1344
+ rev: str = "",
1345
+ company: str = "",
1346
+ comments: Optional[Dict[int, str]] = None,
1347
+ ) -> None:
1348
+ """
1349
+ Set title block information.
1350
+
1351
+ Args:
1352
+ title: Schematic title
1353
+ date: Date
1354
+ rev: Revision
1355
+ company: Company name
1356
+ comments: Comment fields (1-9)
1357
+ """
1358
+ self._metadata_manager.set_title_block(title, date, rev, company, comments)
1359
+ self._format_sync_manager.mark_dirty("title_block", "update")
1326
1360
  self._modified = True
1327
- logger.info("Cleared schematic")
1328
1361
 
1329
- def clone(self, new_name: Optional[str] = None) -> "Schematic":
1330
- """Create a copy of this schematic."""
1331
- import copy
1362
+ def set_paper_size(self, paper: str) -> None:
1363
+ """
1364
+ Set paper size for the schematic.
1332
1365
 
1333
- cloned_data = copy.deepcopy(self._data)
1366
+ Args:
1367
+ paper: Paper size (A4, A3, etc.)
1368
+ """
1369
+ self._metadata_manager.set_paper_size(paper)
1370
+ self._format_sync_manager.mark_dirty("paper", "update")
1371
+ self._modified = True
1372
+
1373
+ # Validation (enhanced with ValidationManager)
1374
+ def validate(self) -> List[ValidationIssue]:
1375
+ """
1376
+ Perform comprehensive schematic validation.
1377
+
1378
+ Returns:
1379
+ List of validation issues found
1380
+ """
1381
+ # Use the new ValidationManager for comprehensive validation
1382
+ manager_issues = self._validation_manager.validate_schematic()
1383
+
1384
+ # Also run legacy validator for compatibility
1385
+ try:
1386
+ legacy_issues = self._legacy_validator.validate_schematic_data(self._data)
1387
+ except Exception as e:
1388
+ logger.warning(f"Legacy validator failed: {e}")
1389
+ legacy_issues = []
1334
1390
 
1335
- if new_name:
1336
- cloned_data["title_block"]["title"] = new_name
1337
- cloned_data["uuid"] = str(uuid.uuid4()) # New UUID for clone
1391
+ # Combine issues (remove duplicates based on message)
1392
+ all_issues = manager_issues + legacy_issues
1393
+ unique_issues = []
1394
+ seen_messages = set()
1338
1395
 
1339
- return Schematic(cloned_data)
1396
+ for issue in all_issues:
1397
+ if issue.message not in seen_messages:
1398
+ unique_issues.append(issue)
1399
+ seen_messages.add(issue.message)
1340
1400
 
1341
- # Performance optimization
1342
- def rebuild_indexes(self):
1343
- """Rebuild internal indexes for performance."""
1344
- # This would rebuild component indexes, etc.
1345
- logger.info("Rebuilt schematic indexes")
1401
+ return unique_issues
1346
1402
 
1347
- def get_performance_stats(self) -> Dict[str, Any]:
1348
- """Get performance statistics."""
1349
- cache_stats = get_symbol_cache().get_performance_stats()
1403
+ def get_validation_summary(self) -> Dict[str, Any]:
1404
+ """
1405
+ Get validation summary statistics.
1350
1406
 
1407
+ Returns:
1408
+ Summary dictionary with counts and severity
1409
+ """
1410
+ issues = self.validate()
1411
+ return self._validation_manager.get_validation_summary(issues)
1412
+
1413
+ # Statistics and information
1414
+ def get_statistics(self) -> Dict[str, Any]:
1415
+ """Get comprehensive schematic statistics."""
1351
1416
  return {
1352
- "schematic": {
1417
+ "components": len(self._components),
1418
+ "wires": len(self._wires),
1419
+ "junctions": len(self._junctions),
1420
+ "text_elements": self._text_element_manager.get_text_statistics(),
1421
+ "graphics": self._graphics_manager.get_graphics_statistics(),
1422
+ "sheets": self._sheet_manager.get_sheet_statistics(),
1423
+ "performance": {
1353
1424
  "operation_count": self._operation_count,
1354
- "total_operation_time_s": round(self._total_operation_time, 3),
1355
- "avg_operation_time_ms": round(
1356
- (
1357
- (self._total_operation_time / self._operation_count * 1000)
1358
- if self._operation_count > 0
1359
- else 0
1360
- ),
1361
- 2,
1362
- ),
1425
+ "total_operation_time": self._total_operation_time,
1426
+ "modified": self.modified,
1427
+ "last_save_time": self._last_save_time,
1363
1428
  },
1364
- "components": self._components.get_statistics(),
1365
- "symbol_cache": cache_stats,
1366
1429
  }
1367
1430
 
1368
1431
  # Internal methods
1432
+ @staticmethod
1433
+ def _create_empty_schematic_data() -> Dict[str, Any]:
1434
+ """Create empty schematic data structure."""
1435
+ from uuid import uuid4
1436
+
1437
+ return {
1438
+ "version": "20250114",
1439
+ "generator": "eeschema",
1440
+ "generator_version": "9.0",
1441
+ "uuid": str(uuid4()),
1442
+ "paper": "A4",
1443
+ "lib_symbols": {},
1444
+ "symbol": [],
1445
+ "wire": [],
1446
+ "junction": [],
1447
+ "label": [],
1448
+ "hierarchical_label": [],
1449
+ "global_label": [],
1450
+ "text": [],
1451
+ "sheet": [],
1452
+ "rectangle": [],
1453
+ "circle": [],
1454
+ "arc": [],
1455
+ "polyline": [],
1456
+ "image": [],
1457
+ "symbol_instances": [],
1458
+ "sheet_instances": [],
1459
+ "embedded_fonts": "no",
1460
+ "components": [],
1461
+ "wires": [],
1462
+ "junctions": [],
1463
+ "labels": [],
1464
+ "nets": [],
1465
+ }
1466
+
1467
+ # Context manager support for atomic operations
1468
+ def __enter__(self):
1469
+ """Enter atomic operation context."""
1470
+ # Create backup for rollback
1471
+ if self._file_path and self._file_path.exists():
1472
+ self._backup_path = self._file_io_manager.create_backup(
1473
+ self._file_path, ".atomic_backup"
1474
+ )
1475
+ return self
1476
+
1477
+ def __exit__(self, exc_type, exc_val, exc_tb):
1478
+ """Exit atomic operation context."""
1479
+ if exc_type is not None:
1480
+ # Exception occurred - rollback if possible
1481
+ if hasattr(self, "_backup_path") and self._backup_path.exists():
1482
+ logger.warning("Exception in atomic operation - rolling back")
1483
+ # Restore from backup
1484
+ restored_data = self._file_io_manager.load_schematic(self._backup_path)
1485
+ self._data = restored_data
1486
+ self._modified = True
1487
+ else:
1488
+ # Success - clean up backup
1489
+ if hasattr(self, "_backup_path") and self._backup_path.exists():
1490
+ self._backup_path.unlink()
1491
+
1492
+ # Internal sync methods (migrated from original implementation)
1369
1493
  def _sync_components_to_data(self):
1370
1494
  """Sync component collection state back to data structure."""
1371
- self._data["components"] = [comp._data.__dict__ for comp in self._components]
1495
+ logger.debug("🔍 _sync_components_to_data: Syncing components to _data")
1496
+
1497
+ components_data = []
1498
+ for comp in self._components:
1499
+ # Start with base component data
1500
+ comp_dict = {k: v for k, v in comp._data.__dict__.items() if not k.startswith("_")}
1501
+
1502
+ # CRITICAL FIX: Explicitly preserve instances if user set them
1503
+ if hasattr(comp._data, "instances") and comp._data.instances:
1504
+ logger.debug(
1505
+ f" Component {comp._data.reference} has {len(comp._data.instances)} instance(s)"
1506
+ )
1507
+ comp_dict["instances"] = [
1508
+ {
1509
+ "project": (
1510
+ getattr(inst, "project", self.name)
1511
+ if hasattr(inst, "project")
1512
+ else self.name
1513
+ ),
1514
+ "path": inst.path, # PRESERVE exact path user set!
1515
+ "reference": inst.reference,
1516
+ "unit": inst.unit,
1517
+ }
1518
+ for inst in comp._data.instances
1519
+ ]
1520
+ logger.debug(
1521
+ f" Instance paths: {[inst.path for inst in comp._data.instances]}"
1522
+ )
1523
+ else:
1524
+ logger.debug(
1525
+ f" Component {comp._data.reference} has NO instances (will be generated by parser)"
1526
+ )
1527
+
1528
+ components_data.append(comp_dict)
1529
+
1530
+ self._data["components"] = components_data
1531
+ logger.debug(f" Synced {len(components_data)} components to _data")
1372
1532
 
1373
1533
  # Populate lib_symbols with actual symbol definitions used by components
1374
1534
  lib_symbols = {}
@@ -1376,112 +1536,23 @@ class Schematic:
1376
1536
 
1377
1537
  for comp in self._components:
1378
1538
  if comp.lib_id and comp.lib_id not in lib_symbols:
1379
- logger.debug(f"🔧 SCHEMATIC: Processing component {comp.lib_id}")
1380
-
1381
1539
  # Get the actual symbol definition
1382
1540
  symbol_def = cache.get_symbol(comp.lib_id)
1541
+
1383
1542
  if symbol_def:
1384
- logger.debug(f"🔧 SCHEMATIC: Loaded symbol {comp.lib_id}")
1385
- lib_symbols[comp.lib_id] = self._convert_symbol_to_kicad_format(
1386
- symbol_def, comp.lib_id
1387
- )
1388
-
1389
- # Check if this symbol extends another symbol using multiple methods
1390
- extends_parent = None
1391
-
1392
- # Method 1: Check raw_kicad_data
1393
- if hasattr(symbol_def, "raw_kicad_data") and symbol_def.raw_kicad_data:
1394
- extends_parent = self._check_symbol_extends(symbol_def.raw_kicad_data)
1395
- logger.debug(
1396
- f"🔧 SCHEMATIC: Checked raw_kicad_data for {comp.lib_id}, extends: {extends_parent}"
1397
- )
1398
-
1399
- # Method 2: Check raw_data attribute
1400
- if not extends_parent and hasattr(symbol_def, "__dict__"):
1401
- for attr_name, attr_value in symbol_def.__dict__.items():
1402
- if attr_name == "raw_data":
1403
- logger.debug(
1404
- f"🔧 SCHEMATIC: Checking raw_data for extends: {type(attr_value)}"
1405
- )
1406
- extends_parent = self._check_symbol_extends(attr_value)
1407
- if extends_parent:
1408
- logger.debug(
1409
- f"🔧 SCHEMATIC: Found extends in raw_data: {extends_parent}"
1410
- )
1411
-
1412
- # Method 3: Check the extends attribute directly
1413
- if not extends_parent and hasattr(symbol_def, "extends"):
1414
- extends_parent = symbol_def.extends
1415
- logger.debug(f"🔧 SCHEMATIC: Found extends attribute: {extends_parent}")
1416
-
1417
- if extends_parent:
1418
- # Load the parent symbol too
1419
- parent_lib_id = f"{comp.lib_id.split(':')[0]}:{extends_parent}"
1420
- logger.debug(f"🔧 SCHEMATIC: Loading parent symbol: {parent_lib_id}")
1421
-
1422
- if parent_lib_id not in lib_symbols:
1423
- parent_symbol_def = cache.get_symbol(parent_lib_id)
1424
- if parent_symbol_def:
1425
- lib_symbols[parent_lib_id] = self._convert_symbol_to_kicad_format(
1426
- parent_symbol_def, parent_lib_id
1427
- )
1428
- logger.debug(
1429
- f"🔧 SCHEMATIC: Successfully loaded parent symbol: {parent_lib_id} for {comp.lib_id}"
1430
- )
1431
- else:
1432
- logger.warning(
1433
- f"🔧 SCHEMATIC: Failed to load parent symbol: {parent_lib_id}"
1434
- )
1435
- else:
1436
- logger.debug(
1437
- f"🔧 SCHEMATIC: Parent symbol {parent_lib_id} already loaded"
1438
- )
1439
- else:
1440
- logger.debug(f"🔧 SCHEMATIC: No extends found for {comp.lib_id}")
1441
- else:
1442
- # Fallback for unknown symbols
1443
- logger.warning(
1444
- f"🔧 SCHEMATIC: Failed to load symbol {comp.lib_id}, using fallback"
1445
- )
1446
- lib_symbols[comp.lib_id] = {"definition": "basic"}
1543
+ converted_symbol = self._convert_symbol_to_kicad_format(symbol_def, comp.lib_id)
1544
+ lib_symbols[comp.lib_id] = converted_symbol
1447
1545
 
1448
1546
  self._data["lib_symbols"] = lib_symbols
1449
1547
 
1450
- # Debug: Log the final lib_symbols structure
1451
- logger.debug(f"🔧 FINAL: lib_symbols contains {len(lib_symbols)} symbols:")
1452
- for sym_id in lib_symbols.keys():
1453
- logger.debug(f"🔧 FINAL: - {sym_id}")
1454
- # Check if this symbol has extends
1455
- sym_data = lib_symbols[sym_id]
1456
- if isinstance(sym_data, list) and len(sym_data) > 2:
1457
- for item in sym_data[1:]:
1458
- if isinstance(item, list) and len(item) >= 2:
1459
- if item[0] == sexpdata.Symbol("extends"):
1460
- logger.debug(f"🔧 FINAL: - {sym_id} extends {item[1]}")
1461
- break
1462
-
1463
- def _check_symbol_extends(self, symbol_data: Any) -> Optional[str]:
1464
- """Check if symbol extends another symbol and return parent name."""
1465
- logger.debug(f"🔧 EXTENDS: Checking symbol data type: {type(symbol_data)}")
1548
+ # Update sheet instances
1549
+ if not self._data["sheet_instances"]:
1550
+ self._data["sheet_instances"] = [{"path": "/", "page": "1"}]
1466
1551
 
1467
- if not isinstance(symbol_data, list):
1468
- logger.debug(f"🔧 EXTENDS: Not a list, returning None")
1469
- return None
1470
-
1471
- logger.debug(f"🔧 EXTENDS: Checking {len(symbol_data)} items for extends directive")
1472
-
1473
- for i, item in enumerate(symbol_data[1:], 1):
1474
- logger.debug(
1475
- f"🔧 EXTENDS: Item {i}: {type(item)} - {item if not isinstance(item, list) else f'list[{len(item)}]'}"
1476
- )
1477
- if isinstance(item, list) and len(item) >= 2:
1478
- if item[0] == sexpdata.Symbol("extends"):
1479
- parent_name = str(item[1]).strip('"')
1480
- logger.debug(f"🔧 EXTENDS: Found extends directive: {parent_name}")
1481
- return parent_name
1482
-
1483
- logger.debug(f"🔧 EXTENDS: No extends directive found")
1484
- return None
1552
+ # Remove symbol_instances section - instances are stored within each symbol in lib_symbols
1553
+ # This matches KiCAD's format where instances are part of the symbol definition
1554
+ if "symbol_instances" in self._data:
1555
+ del self._data["symbol_instances"]
1485
1556
 
1486
1557
  def _sync_wires_to_data(self):
1487
1558
  """Sync wire collection state back to data structure."""
@@ -1512,163 +1583,341 @@ class Schematic:
1512
1583
 
1513
1584
  self._data["junctions"] = junction_data
1514
1585
 
1515
- def _convert_symbol_to_kicad_format(
1516
- self, symbol: "SymbolDefinition", lib_id: str
1517
- ) -> Dict[str, Any]:
1518
- """Convert SymbolDefinition to KiCAD lib_symbols format using raw parsed data."""
1519
- # If we have raw KiCAD data from the library file, use it directly
1520
- if hasattr(symbol, "raw_kicad_data") and symbol.raw_kicad_data:
1521
- return self._convert_raw_symbol_data(symbol.raw_kicad_data, lib_id)
1586
+ def _sync_texts_to_data(self):
1587
+ """Sync text collection state back to data structure."""
1588
+ text_data = []
1589
+ for text_element in self._texts:
1590
+ text_dict = {
1591
+ "uuid": text_element.uuid,
1592
+ "text": text_element.text,
1593
+ "position": {"x": text_element.position.x, "y": text_element.position.y},
1594
+ "rotation": text_element.rotation,
1595
+ "size": text_element.size,
1596
+ "exclude_from_sim": text_element.exclude_from_sim,
1597
+ }
1598
+ text_data.append(text_dict)
1599
+
1600
+ self._data["texts"] = text_data
1601
+
1602
+ def _sync_labels_to_data(self):
1603
+ """Sync label collection state back to data structure."""
1604
+ label_data = []
1605
+ for label_element in self._labels:
1606
+ label_dict = {
1607
+ "uuid": label_element.uuid,
1608
+ "text": label_element.text,
1609
+ "position": {"x": label_element.position.x, "y": label_element.position.y},
1610
+ "rotation": label_element.rotation,
1611
+ "size": label_element.size,
1612
+ "justify_h": label_element._data.justify_h,
1613
+ "justify_v": label_element._data.justify_v,
1614
+ }
1615
+ label_data.append(label_dict)
1616
+
1617
+ self._data["labels"] = label_data
1618
+
1619
+ def _sync_hierarchical_labels_to_data(self):
1620
+ """Sync hierarchical label collection state back to data structure."""
1621
+ hierarchical_label_data = []
1622
+ for hlabel_element in self._hierarchical_labels:
1623
+ hlabel_dict = {
1624
+ "uuid": hlabel_element.uuid,
1625
+ "text": hlabel_element.text,
1626
+ "position": {"x": hlabel_element.position.x, "y": hlabel_element.position.y},
1627
+ "rotation": hlabel_element.rotation,
1628
+ "size": hlabel_element.size,
1629
+ }
1630
+ hierarchical_label_data.append(hlabel_dict)
1631
+
1632
+ self._data["hierarchical_labels"] = hierarchical_label_data
1633
+
1634
+ def _sync_no_connects_to_data(self):
1635
+ """Sync no-connect collection state back to data structure."""
1636
+ no_connect_data = []
1637
+ for no_connect_element in self._no_connects:
1638
+ no_connect_dict = {
1639
+ "uuid": no_connect_element.uuid,
1640
+ "position": {
1641
+ "x": no_connect_element.position.x,
1642
+ "y": no_connect_element.position.y,
1643
+ },
1644
+ }
1645
+ no_connect_data.append(no_connect_dict)
1646
+
1647
+ self._data["no_connects"] = no_connect_data
1648
+
1649
+ def _sync_nets_to_data(self):
1650
+ """Sync net collection state back to data structure."""
1651
+ net_data = []
1652
+ for net_element in self._nets:
1653
+ net_dict = {
1654
+ "name": net_element.name,
1655
+ "components": net_element.components,
1656
+ "wires": net_element.wires,
1657
+ "labels": net_element.labels,
1658
+ }
1659
+ net_data.append(net_dict)
1660
+
1661
+ self._data["nets"] = net_data
1662
+
1663
+ def _convert_symbol_to_kicad_format(self, symbol_def, lib_id: str):
1664
+ """Convert symbol definition to KiCAD format."""
1665
+ # Use raw data if available, but fix the symbol name to use full lib_id
1666
+ if hasattr(symbol_def, "raw_kicad_data") and symbol_def.raw_kicad_data:
1667
+ raw_data = symbol_def.raw_kicad_data
1668
+
1669
+ # Check if raw data already contains instances with project info
1670
+ project_refs_found = []
1671
+
1672
+ def find_project_refs(data, path="root"):
1673
+ if isinstance(data, list):
1674
+ for i, item in enumerate(data):
1675
+ if hasattr(item, "__str__") and str(item) == "project":
1676
+ if i < len(data) - 1:
1677
+ project_refs_found.append(f"{path}[{i}] = '{data[i+1]}'")
1678
+ elif isinstance(item, list):
1679
+ find_project_refs(item, f"{path}[{i}]")
1680
+
1681
+ find_project_refs(raw_data)
1682
+
1683
+ # Make a copy and fix the symbol name (index 1) to use full lib_id
1684
+ if isinstance(raw_data, list) and len(raw_data) > 1:
1685
+ fixed_data = raw_data.copy()
1686
+ fixed_data[1] = lib_id # Replace short name with full lib_id
1687
+
1688
+ # Also fix any project references in instances to use current project name
1689
+ self._fix_symbol_project_references(fixed_data)
1690
+
1691
+ return fixed_data
1692
+ else:
1693
+ return raw_data
1522
1694
 
1523
1695
  # Fallback: create basic symbol structure
1524
1696
  return {
1525
- "pin_numbers": {"hide": "yes"},
1526
- "pin_names": {"offset": 0},
1527
- "exclude_from_sim": "no",
1528
- "in_bom": "yes",
1529
- "on_board": "yes",
1530
- "properties": {
1531
- "Reference": {
1532
- "value": symbol.reference_prefix,
1533
- "at": [2.032, 0, 90],
1534
- "effects": {"font": {"size": [1.27, 1.27]}},
1535
- },
1536
- "Value": {
1537
- "value": symbol.reference_prefix,
1538
- "at": [0, 0, 90],
1539
- "effects": {"font": {"size": [1.27, 1.27]}},
1540
- },
1541
- "Footprint": {
1542
- "value": "",
1543
- "at": [-1.778, 0, 90],
1544
- "effects": {"font": {"size": [1.27, 1.27]}, "hide": "yes"},
1545
- },
1546
- "Datasheet": {
1547
- "value": getattr(symbol, "Datasheet", None)
1548
- or getattr(symbol, "datasheet", None)
1549
- or "~",
1550
- "at": [0, 0, 0],
1551
- "effects": {"font": {"size": [1.27, 1.27]}, "hide": "yes"},
1552
- },
1553
- "Description": {
1554
- "value": getattr(symbol, "Description", None)
1555
- or getattr(symbol, "description", None)
1556
- or "Resistor",
1557
- "at": [0, 0, 0],
1558
- "effects": {"font": {"size": [1.27, 1.27]}, "hide": "yes"},
1559
- },
1560
- },
1561
- "embedded_fonts": "no",
1697
+ "lib_id": lib_id,
1698
+ "symbol": symbol_def.name if hasattr(symbol_def, "name") else lib_id.split(":")[-1],
1562
1699
  }
1563
1700
 
1564
- def _convert_raw_symbol_data(self, raw_data: List, lib_id: str) -> Dict[str, Any]:
1565
- """Convert raw parsed KiCAD symbol data to dictionary format for S-expression generation."""
1566
- import copy
1701
+ def _fix_symbol_project_references(self, symbol_data):
1702
+ """Fix project references in symbol instances to use current project name."""
1703
+ if not isinstance(symbol_data, list):
1704
+ return
1705
+
1706
+ # Recursively search for instances sections and update project names
1707
+ for i, element in enumerate(symbol_data):
1708
+ if isinstance(element, list):
1709
+ # Check if this is an instances section
1710
+ if (
1711
+ len(element) > 0
1712
+ and hasattr(element[0], "__str__")
1713
+ and str(element[0]) == "instances"
1714
+ ):
1715
+ # Look for project references within instances
1716
+ self._update_project_in_instances(element)
1717
+ else:
1718
+ # Recursively check nested lists
1719
+ self._fix_symbol_project_references(element)
1720
+
1721
+ def _update_project_in_instances(self, instances_element):
1722
+ """Update project name in instances element."""
1723
+ if not isinstance(instances_element, list):
1724
+ return
1725
+
1726
+ for i, element in enumerate(instances_element):
1727
+ if isinstance(element, list) and len(element) >= 2:
1728
+ # Check if this is a project element: ['project', 'old_name', ...]
1729
+ if hasattr(element[0], "__str__") and str(element[0]) == "project":
1730
+ old_name = element[1]
1731
+ element[1] = self.name # Replace with current schematic name
1732
+ else:
1733
+ # Recursively check nested elements
1734
+ self._update_project_in_instances(element)
1567
1735
 
1568
- import sexpdata
1736
+ # ============================================================================
1737
+ # Export Methods (using kicad-cli)
1738
+ # ============================================================================
1569
1739
 
1570
- # Make a copy and fix symbol name and string/symbol issues
1571
- modified_data = copy.deepcopy(raw_data)
1740
+ def run_erc(self, **kwargs):
1741
+ """
1742
+ Run Electrical Rule Check (ERC) on this schematic.
1572
1743
 
1573
- # Replace the symbol name with the full lib_id
1574
- if len(modified_data) >= 2:
1575
- modified_data[1] = lib_id # Change 'R' to 'Device:R'
1744
+ This requires the schematic to be saved first.
1576
1745
 
1577
- # Fix extends directive to use full lib_id
1578
- logger.debug(f"🔧 CONVERT: Processing {len(modified_data)} items for {lib_id}")
1579
- for i, item in enumerate(modified_data[1:], 1):
1580
- if isinstance(item, list) and len(item) >= 2:
1581
- logger.debug(
1582
- f"🔧 CONVERT: Item {i}: {item[0]} = {item[1] if len(item) > 1 else 'N/A'}"
1583
- )
1584
- if item[0] == sexpdata.Symbol("extends"):
1585
- # Convert bare symbol name to full lib_id
1586
- parent_name = str(item[1]).strip('"')
1587
- parent_lib_id = f"{lib_id.split(':')[0]}:{parent_name}"
1588
- modified_data[i][1] = parent_lib_id
1589
- logger.debug(
1590
- f"🔧 CONVERT: Fixed extends directive: {parent_name} -> {parent_lib_id}"
1591
- )
1592
- break
1593
-
1594
- # Fix string/symbol conversion issues in pin definitions
1595
- print(f"🔧 DEBUG: Before fix - checking for pin definitions...")
1596
- self._fix_symbol_strings_recursively(modified_data)
1597
- print(f"🔧 DEBUG: After fix - symbol strings fixed")
1598
-
1599
- return modified_data
1600
-
1601
- def _fix_symbol_strings_recursively(self, data):
1602
- """Recursively fix string/symbol issues in parsed S-expression data."""
1603
- import sexpdata
1604
-
1605
- if isinstance(data, list):
1606
- for i, item in enumerate(data):
1607
- if isinstance(item, list):
1608
- # Check for pin definitions that need fixing
1609
- if len(item) >= 3 and item[0] == sexpdata.Symbol("pin"):
1610
- print(
1611
- f"🔧 DEBUG: Found pin definition: {item[:3]} - types: {[type(x) for x in item[:3]]}"
1612
- )
1613
- # Fix pin type and shape - ensure they are symbols not strings
1614
- if isinstance(item[1], str):
1615
- print(f"🔧 DEBUG: Converting pin type '{item[1]}' to symbol")
1616
- item[1] = sexpdata.Symbol(item[1]) # pin type: "passive" -> passive
1617
- if len(item) >= 3 and isinstance(item[2], str):
1618
- print(f"🔧 DEBUG: Converting pin shape '{item[2]}' to symbol")
1619
- item[2] = sexpdata.Symbol(item[2]) # pin shape: "line" -> line
1620
-
1621
- # Recursively process nested lists
1622
- self._fix_symbol_strings_recursively(item)
1623
- elif isinstance(item, str):
1624
- # Fix common KiCAD keywords that should be symbols
1625
- if item in ["yes", "no", "default", "none", "left", "right", "center"]:
1626
- data[i] = sexpdata.Symbol(item)
1627
-
1628
- return data
1746
+ Args:
1747
+ **kwargs: Arguments passed to cli.erc.run_erc()
1748
+ - output_path: Path for ERC report
1749
+ - format: 'json' or 'report'
1750
+ - severity: 'all', 'error', 'warning', 'exclusions'
1751
+ - units: 'mm', 'in', 'mils'
1629
1752
 
1630
- @staticmethod
1631
- def _create_empty_schematic_data() -> Dict[str, Any]:
1632
- """Create empty schematic data structure."""
1633
- return {
1634
- "version": "20250114",
1635
- "generator": "eeschema",
1636
- "generator_version": "9.0",
1637
- "uuid": str(uuid.uuid4()),
1638
- "paper": "A4",
1639
- "components": [],
1640
- "wires": [],
1641
- "junctions": [],
1642
- "labels": [],
1643
- "nets": [],
1644
- "lib_symbols": {},
1645
- "sheet_instances": [{"path": "/", "page": "1"}],
1646
- "symbol_instances": [],
1647
- "embedded_fonts": "no",
1648
- }
1753
+ Returns:
1754
+ ErcReport with violations and summary
1649
1755
 
1650
- # Context manager support for atomic operations
1651
- def __enter__(self):
1652
- """Enter atomic operation context."""
1653
- # Create backup for potential rollback
1654
- if self._file_path and self._file_path.exists():
1655
- self._backup_path = self.backup(".atomic_backup")
1656
- return self
1756
+ Example:
1757
+ >>> report = sch.run_erc()
1758
+ >>> if report.has_errors():
1759
+ ... print(f"Found {report.error_count} errors")
1760
+ """
1761
+ from kicad_sch_api.cli.erc import run_erc
1657
1762
 
1658
- def __exit__(self, exc_type, exc_val, exc_tb):
1659
- """Exit atomic operation context."""
1660
- if exc_type is not None:
1661
- # Exception occurred - rollback if possible
1662
- if hasattr(self, "_backup_path") and self._backup_path.exists():
1663
- logger.warning("Exception in atomic operation - rolling back")
1664
- # Restore from backup
1665
- restored_data = self._parser.parse_file(self._backup_path)
1666
- self._data = restored_data
1667
- self._modified = True
1668
- else:
1669
- # Success - clean up backup
1670
- if hasattr(self, "_backup_path") and self._backup_path.exists():
1671
- self._backup_path.unlink()
1763
+ if not self._file_path:
1764
+ raise ValueError("Schematic must be saved before running ERC")
1765
+
1766
+ # Save first to ensure file is up-to-date
1767
+ self.save()
1768
+
1769
+ return run_erc(self._file_path, **kwargs)
1770
+
1771
+ def export_netlist(self, format="kicadsexpr", **kwargs):
1772
+ """
1773
+ Export netlist from this schematic.
1774
+
1775
+ This requires the schematic to be saved first.
1776
+
1777
+ Args:
1778
+ format: Netlist format (default: 'kicadsexpr')
1779
+ - kicadsexpr: KiCad S-expression (default)
1780
+ - kicadxml: KiCad XML
1781
+ - spice: SPICE netlist
1782
+ - spicemodel: SPICE with models
1783
+ - cadstar, orcadpcb2, pads, allegro
1784
+ **kwargs: Arguments passed to cli.netlist.export_netlist()
1785
+
1786
+ Returns:
1787
+ Path to generated netlist file
1788
+
1789
+ Example:
1790
+ >>> netlist = sch.export_netlist(format='spice')
1791
+ >>> print(f"Netlist: {netlist}")
1792
+ """
1793
+ from kicad_sch_api.cli.netlist import export_netlist
1794
+
1795
+ if not self._file_path:
1796
+ raise ValueError("Schematic must be saved before exporting netlist")
1797
+
1798
+ # Save first to ensure file is up-to-date
1799
+ self.save()
1800
+
1801
+ return export_netlist(self._file_path, format=format, **kwargs)
1802
+
1803
+ def export_bom(self, **kwargs):
1804
+ """
1805
+ Export Bill of Materials (BOM) from this schematic.
1806
+
1807
+ This requires the schematic to be saved first.
1808
+
1809
+ Args:
1810
+ **kwargs: Arguments passed to cli.bom.export_bom()
1811
+ - output_path: Path for BOM file
1812
+ - fields: List of fields to export
1813
+ - group_by: Fields to group by
1814
+ - exclude_dnp: Exclude Do-Not-Populate components
1815
+ - And many more options...
1816
+
1817
+ Returns:
1818
+ Path to generated BOM file
1819
+
1820
+ Example:
1821
+ >>> bom = sch.export_bom(
1822
+ ... fields=['Reference', 'Value', 'Footprint', 'MPN'],
1823
+ ... group_by=['Value', 'Footprint'],
1824
+ ... exclude_dnp=True,
1825
+ ... )
1826
+ """
1827
+ from kicad_sch_api.cli.bom import export_bom
1828
+
1829
+ if not self._file_path:
1830
+ raise ValueError("Schematic must be saved before exporting BOM")
1831
+
1832
+ # Save first to ensure file is up-to-date
1833
+ self.save()
1834
+
1835
+ return export_bom(self._file_path, **kwargs)
1836
+
1837
+ def export_pdf(self, **kwargs):
1838
+ """
1839
+ Export schematic as PDF.
1840
+
1841
+ This requires the schematic to be saved first.
1842
+
1843
+ Args:
1844
+ **kwargs: Arguments passed to cli.export_docs.export_pdf()
1845
+ - output_path: Path for PDF file
1846
+ - theme: Color theme
1847
+ - black_and_white: B&W export
1848
+ - And more options...
1849
+
1850
+ Returns:
1851
+ Path to generated PDF file
1852
+
1853
+ Example:
1854
+ >>> pdf = sch.export_pdf(theme='Kicad Classic')
1855
+ """
1856
+ from kicad_sch_api.cli.export_docs import export_pdf
1857
+
1858
+ if not self._file_path:
1859
+ raise ValueError("Schematic must be saved before exporting PDF")
1860
+
1861
+ # Save first to ensure file is up-to-date
1862
+ self.save()
1863
+
1864
+ return export_pdf(self._file_path, **kwargs)
1865
+
1866
+ def export_svg(self, **kwargs):
1867
+ """
1868
+ Export schematic as SVG.
1869
+
1870
+ This requires the schematic to be saved first.
1871
+
1872
+ Args:
1873
+ **kwargs: Arguments passed to cli.export_docs.export_svg()
1874
+ - output_dir: Output directory
1875
+ - theme: Color theme
1876
+ - black_and_white: B&W export
1877
+ - And more options...
1878
+
1879
+ Returns:
1880
+ List of paths to generated SVG files
1881
+
1882
+ Example:
1883
+ >>> svgs = sch.export_svg()
1884
+ >>> for svg in svgs:
1885
+ ... print(f"Generated: {svg}")
1886
+ """
1887
+ from kicad_sch_api.cli.export_docs import export_svg
1888
+
1889
+ if not self._file_path:
1890
+ raise ValueError("Schematic must be saved before exporting SVG")
1891
+
1892
+ # Save first to ensure file is up-to-date
1893
+ self.save()
1894
+
1895
+ return export_svg(self._file_path, **kwargs)
1896
+
1897
+ def export_dxf(self, **kwargs):
1898
+ """
1899
+ Export schematic as DXF.
1900
+
1901
+ This requires the schematic to be saved first.
1902
+
1903
+ Args:
1904
+ **kwargs: Arguments passed to cli.export_docs.export_dxf()
1905
+
1906
+ Returns:
1907
+ List of paths to generated DXF files
1908
+
1909
+ Example:
1910
+ >>> dxfs = sch.export_dxf()
1911
+ """
1912
+ from kicad_sch_api.cli.export_docs import export_dxf
1913
+
1914
+ if not self._file_path:
1915
+ raise ValueError("Schematic must be saved before exporting DXF")
1916
+
1917
+ # Save first to ensure file is up-to-date
1918
+ self.save()
1919
+
1920
+ return export_dxf(self._file_path, **kwargs)
1672
1921
 
1673
1922
  def __str__(self) -> str:
1674
1923
  """String representation."""