kicad-sch-api 0.4.0__py3-none-any.whl → 0.4.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

Files changed (57) hide show
  1. kicad_sch_api/__init__.py +2 -2
  2. kicad_sch_api/cli/__init__.py +45 -0
  3. kicad_sch_api/cli/base.py +302 -0
  4. kicad_sch_api/cli/bom.py +164 -0
  5. kicad_sch_api/cli/erc.py +229 -0
  6. kicad_sch_api/cli/export_docs.py +289 -0
  7. kicad_sch_api/cli/netlist.py +94 -0
  8. kicad_sch_api/cli/types.py +43 -0
  9. kicad_sch_api/core/collections/__init__.py +5 -0
  10. kicad_sch_api/core/collections/base.py +248 -0
  11. kicad_sch_api/core/component_bounds.py +5 -0
  12. kicad_sch_api/core/components.py +142 -47
  13. kicad_sch_api/core/config.py +85 -3
  14. kicad_sch_api/core/factories/__init__.py +5 -0
  15. kicad_sch_api/core/factories/element_factory.py +276 -0
  16. kicad_sch_api/core/formatter.py +22 -5
  17. kicad_sch_api/core/junctions.py +26 -75
  18. kicad_sch_api/core/labels.py +28 -52
  19. kicad_sch_api/core/managers/file_io.py +3 -2
  20. kicad_sch_api/core/managers/metadata.py +6 -5
  21. kicad_sch_api/core/managers/validation.py +3 -2
  22. kicad_sch_api/core/managers/wire.py +7 -1
  23. kicad_sch_api/core/nets.py +38 -43
  24. kicad_sch_api/core/no_connects.py +29 -53
  25. kicad_sch_api/core/parser.py +75 -1765
  26. kicad_sch_api/core/schematic.py +211 -148
  27. kicad_sch_api/core/texts.py +28 -55
  28. kicad_sch_api/core/types.py +59 -18
  29. kicad_sch_api/core/wires.py +27 -75
  30. kicad_sch_api/parsers/elements/__init__.py +22 -0
  31. kicad_sch_api/parsers/elements/graphics_parser.py +564 -0
  32. kicad_sch_api/parsers/elements/label_parser.py +194 -0
  33. kicad_sch_api/parsers/elements/library_parser.py +165 -0
  34. kicad_sch_api/parsers/elements/metadata_parser.py +58 -0
  35. kicad_sch_api/parsers/elements/sheet_parser.py +352 -0
  36. kicad_sch_api/parsers/elements/symbol_parser.py +313 -0
  37. kicad_sch_api/parsers/elements/text_parser.py +250 -0
  38. kicad_sch_api/parsers/elements/wire_parser.py +242 -0
  39. kicad_sch_api/parsers/utils.py +80 -0
  40. kicad_sch_api/validation/__init__.py +25 -0
  41. kicad_sch_api/validation/erc.py +171 -0
  42. kicad_sch_api/validation/erc_models.py +203 -0
  43. kicad_sch_api/validation/pin_matrix.py +243 -0
  44. kicad_sch_api/validation/validators.py +391 -0
  45. {kicad_sch_api-0.4.0.dist-info → kicad_sch_api-0.4.2.dist-info}/METADATA +17 -9
  46. kicad_sch_api-0.4.2.dist-info/RECORD +87 -0
  47. kicad_sch_api/core/manhattan_routing.py +0 -430
  48. kicad_sch_api/core/simple_manhattan.py +0 -228
  49. kicad_sch_api/core/wire_routing.py +0 -380
  50. kicad_sch_api/parsers/label_parser.py +0 -254
  51. kicad_sch_api/parsers/symbol_parser.py +0 -222
  52. kicad_sch_api/parsers/wire_parser.py +0 -99
  53. kicad_sch_api-0.4.0.dist-info/RECORD +0 -67
  54. {kicad_sch_api-0.4.0.dist-info → kicad_sch_api-0.4.2.dist-info}/WHEEL +0 -0
  55. {kicad_sch_api-0.4.0.dist-info → kicad_sch_api-0.4.2.dist-info}/entry_points.txt +0 -0
  56. {kicad_sch_api-0.4.0.dist-info → kicad_sch_api-0.4.2.dist-info}/licenses/LICENSE +0 -0
  57. {kicad_sch_api-0.4.0.dist-info → kicad_sch_api-0.4.2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,352 @@
1
+ """
2
+ Hierarchical sheet elements parser for KiCAD schematics.
3
+
4
+ Handles parsing and serialization of Hierarchical sheet elements.
5
+ """
6
+
7
+ import logging
8
+ from typing import Any, Dict, List, Optional
9
+
10
+ import sexpdata
11
+
12
+ from ...core.config import config
13
+ from ..base import BaseElementParser
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class SheetParser(BaseElementParser):
19
+ """Parser for Hierarchical sheet elements."""
20
+
21
+ def __init__(self):
22
+ """Initialize sheet parser."""
23
+ super().__init__("sheet")
24
+
25
+ def _parse_sheet(self, item: List[Any]) -> Optional[Dict[str, Any]]:
26
+ """Parse a hierarchical sheet."""
27
+ # Complex format with position, size, properties, pins, instances
28
+ sheet_data = {
29
+ "position": {"x": 0, "y": 0},
30
+ "size": {"width": 0, "height": 0},
31
+ "exclude_from_sim": False,
32
+ "in_bom": True,
33
+ "on_board": True,
34
+ "dnp": False,
35
+ "fields_autoplaced": True,
36
+ "stroke_width": 0.1524,
37
+ "stroke_type": "solid",
38
+ "fill_color": (0, 0, 0, 0.0),
39
+ "uuid": None,
40
+ "name": "Sheet",
41
+ "filename": "sheet.kicad_sch",
42
+ "pins": [],
43
+ "project_name": "",
44
+ "page_number": "2",
45
+ }
46
+
47
+ for elem in item[1:]:
48
+ if not isinstance(elem, list):
49
+ continue
50
+
51
+ elem_type = str(elem[0]) if isinstance(elem[0], sexpdata.Symbol) else None
52
+
53
+ if elem_type == "at":
54
+ if len(elem) >= 3:
55
+ sheet_data["position"] = {"x": float(elem[1]), "y": float(elem[2])}
56
+ elif elem_type == "size":
57
+ if len(elem) >= 3:
58
+ sheet_data["size"] = {"width": float(elem[1]), "height": float(elem[2])}
59
+ elif elem_type == "exclude_from_sim":
60
+ sheet_data["exclude_from_sim"] = str(elem[1]) == "yes" if len(elem) > 1 else False
61
+ elif elem_type == "in_bom":
62
+ sheet_data["in_bom"] = str(elem[1]) == "yes" if len(elem) > 1 else True
63
+ elif elem_type == "on_board":
64
+ sheet_data["on_board"] = str(elem[1]) == "yes" if len(elem) > 1 else True
65
+ elif elem_type == "dnp":
66
+ sheet_data["dnp"] = str(elem[1]) == "yes" if len(elem) > 1 else False
67
+ elif elem_type == "fields_autoplaced":
68
+ sheet_data["fields_autoplaced"] = str(elem[1]) == "yes" if len(elem) > 1 else True
69
+ elif elem_type == "stroke":
70
+ for stroke_elem in elem[1:]:
71
+ if isinstance(stroke_elem, list):
72
+ stroke_type = str(stroke_elem[0])
73
+ if stroke_type == "width" and len(stroke_elem) >= 2:
74
+ sheet_data["stroke_width"] = float(stroke_elem[1])
75
+ elif stroke_type == "type" and len(stroke_elem) >= 2:
76
+ sheet_data["stroke_type"] = str(stroke_elem[1])
77
+ elif elem_type == "fill":
78
+ for fill_elem in elem[1:]:
79
+ if isinstance(fill_elem, list) and str(fill_elem[0]) == "color":
80
+ if len(fill_elem) >= 5:
81
+ sheet_data["fill_color"] = (
82
+ int(fill_elem[1]),
83
+ int(fill_elem[2]),
84
+ int(fill_elem[3]),
85
+ float(fill_elem[4]),
86
+ )
87
+ elif elem_type == "uuid":
88
+ sheet_data["uuid"] = str(elem[1]) if len(elem) > 1 else None
89
+ elif elem_type == "property":
90
+ if len(elem) >= 3:
91
+ prop_name = str(elem[1])
92
+ prop_value = str(elem[2])
93
+ if prop_name == "Sheetname":
94
+ sheet_data["name"] = prop_value
95
+ elif prop_name == "Sheetfile":
96
+ sheet_data["filename"] = prop_value
97
+ elif elem_type == "pin":
98
+ # Parse sheet pin - reuse existing _parse_sheet_pin helper
99
+ pin_data = self._parse_sheet_pin_for_read(elem)
100
+ if pin_data:
101
+ sheet_data["pins"].append(pin_data)
102
+ elif elem_type == "instances":
103
+ # Parse instances for project name and page number
104
+ for inst_elem in elem[1:]:
105
+ if isinstance(inst_elem, list) and str(inst_elem[0]) == "project":
106
+ if len(inst_elem) >= 2:
107
+ sheet_data["project_name"] = str(inst_elem[1])
108
+ for path_elem in inst_elem[2:]:
109
+ if isinstance(path_elem, list) and str(path_elem[0]) == "path":
110
+ for page_elem in path_elem[1:]:
111
+ if isinstance(page_elem, list) and str(page_elem[0]) == "page":
112
+ sheet_data["page_number"] = (
113
+ str(page_elem[1]) if len(page_elem) > 1 else "2"
114
+ )
115
+
116
+ return sheet_data
117
+
118
+
119
+ def _parse_sheet_pin_for_read(self, item: List[Any]) -> Optional[Dict[str, Any]]:
120
+ """Parse a sheet pin (for reading during sheet parsing)."""
121
+ # Format: (pin "name" type (at x y rotation) (uuid ...) (effects ...))
122
+ if len(item) < 3:
123
+ return None
124
+
125
+ pin_data = {
126
+ "name": str(item[1]),
127
+ "pin_type": str(item[2]) if len(item) > 2 else "input",
128
+ "position": {"x": 0, "y": 0},
129
+ "rotation": 0,
130
+ "size": config.defaults.font_size,
131
+ "justify": "right",
132
+ "uuid": None,
133
+ }
134
+
135
+ for elem in item[3:]:
136
+ if not isinstance(elem, list):
137
+ continue
138
+
139
+ elem_type = str(elem[0]) if isinstance(elem[0], sexpdata.Symbol) else None
140
+
141
+ if elem_type == "at":
142
+ if len(elem) >= 3:
143
+ pin_data["position"] = {"x": float(elem[1]), "y": float(elem[2])}
144
+ if len(elem) >= 4:
145
+ pin_data["rotation"] = float(elem[3])
146
+ elif elem_type == "uuid":
147
+ pin_data["uuid"] = str(elem[1]) if len(elem) > 1 else None
148
+ elif elem_type == "effects":
149
+ for effect_elem in elem[1:]:
150
+ if isinstance(effect_elem, list):
151
+ effect_type = str(effect_elem[0])
152
+ if effect_type == "font":
153
+ for font_elem in effect_elem[1:]:
154
+ if isinstance(font_elem, list) and str(font_elem[0]) == "size":
155
+ if len(font_elem) >= 2:
156
+ pin_data["size"] = float(font_elem[1])
157
+ elif effect_type == "justify":
158
+ if len(effect_elem) >= 2:
159
+ pin_data["justify"] = str(effect_elem[1])
160
+
161
+ return pin_data
162
+
163
+
164
+ def _parse_sheet_instances(self, item: List[Any]) -> List[Dict[str, Any]]:
165
+ """Parse sheet_instances section."""
166
+ sheet_instances = []
167
+ for sheet_item in item[1:]: # Skip 'sheet_instances' header
168
+ if isinstance(sheet_item, list) and len(sheet_item) > 0:
169
+ sheet_data = {"path": "/", "page": "1"}
170
+ for element in sheet_item[1:]: # Skip element header
171
+ if isinstance(element, list) and len(element) >= 2:
172
+ key = (
173
+ str(element[0])
174
+ if isinstance(element[0], sexpdata.Symbol)
175
+ else str(element[0])
176
+ )
177
+ if key == "path":
178
+ sheet_data["path"] = element[1]
179
+ elif key == "page":
180
+ sheet_data["page"] = element[1]
181
+ sheet_instances.append(sheet_data)
182
+ return sheet_instances
183
+
184
+
185
+ def _sheet_to_sexp(self, sheet_data: Dict[str, Any], schematic_uuid: str) -> List[Any]:
186
+ """Convert hierarchical sheet to S-expression."""
187
+ sexp = [sexpdata.Symbol("sheet")]
188
+
189
+ # Add position
190
+ pos = sheet_data["position"]
191
+ x, y = pos["x"], pos["y"]
192
+ if isinstance(x, float) and x.is_integer():
193
+ x = int(x)
194
+ if isinstance(y, float) and y.is_integer():
195
+ y = int(y)
196
+ sexp.append([sexpdata.Symbol("at"), x, y])
197
+
198
+ # Add size
199
+ size = sheet_data["size"]
200
+ w, h = size["width"], size["height"]
201
+ sexp.append([sexpdata.Symbol("size"), w, h])
202
+
203
+ # Add basic properties
204
+ sexp.append(
205
+ [
206
+ sexpdata.Symbol("exclude_from_sim"),
207
+ sexpdata.Symbol("yes" if sheet_data.get("exclude_from_sim", False) else "no"),
208
+ ]
209
+ )
210
+ sexp.append(
211
+ [
212
+ sexpdata.Symbol("in_bom"),
213
+ sexpdata.Symbol("yes" if sheet_data.get("in_bom", True) else "no"),
214
+ ]
215
+ )
216
+ sexp.append(
217
+ [
218
+ sexpdata.Symbol("on_board"),
219
+ sexpdata.Symbol("yes" if sheet_data.get("on_board", True) else "no"),
220
+ ]
221
+ )
222
+ sexp.append(
223
+ [
224
+ sexpdata.Symbol("dnp"),
225
+ sexpdata.Symbol("yes" if sheet_data.get("dnp", False) else "no"),
226
+ ]
227
+ )
228
+ sexp.append(
229
+ [
230
+ sexpdata.Symbol("fields_autoplaced"),
231
+ sexpdata.Symbol("yes" if sheet_data.get("fields_autoplaced", True) else "no"),
232
+ ]
233
+ )
234
+
235
+ # Add stroke
236
+ stroke_width = sheet_data.get("stroke_width", 0.1524)
237
+ stroke_type = sheet_data.get("stroke_type", "solid")
238
+ stroke_sexp = [sexpdata.Symbol("stroke")]
239
+ stroke_sexp.append([sexpdata.Symbol("width"), stroke_width])
240
+ stroke_sexp.append([sexpdata.Symbol("type"), sexpdata.Symbol(stroke_type)])
241
+ sexp.append(stroke_sexp)
242
+
243
+ # Add fill
244
+ fill_color = sheet_data.get("fill_color", (0, 0, 0, 0.0))
245
+ fill_sexp = [sexpdata.Symbol("fill")]
246
+ fill_sexp.append(
247
+ [sexpdata.Symbol("color"), fill_color[0], fill_color[1], fill_color[2], fill_color[3]]
248
+ )
249
+ sexp.append(fill_sexp)
250
+
251
+ # Add UUID
252
+ if "uuid" in sheet_data:
253
+ sexp.append([sexpdata.Symbol("uuid"), sheet_data["uuid"]])
254
+
255
+ # Add sheet properties (name and filename)
256
+ name = sheet_data.get("name", "Sheet")
257
+ filename = sheet_data.get("filename", "sheet.kicad_sch")
258
+
259
+ # Sheetname property
260
+ from ...core.config import config
261
+
262
+ name_prop = [sexpdata.Symbol("property"), "Sheetname", name]
263
+ name_prop.append(
264
+ [sexpdata.Symbol("at"), x, round(y + config.sheet.name_offset_y, 4), 0]
265
+ ) # Above sheet
266
+ name_prop.append(
267
+ [
268
+ sexpdata.Symbol("effects"),
269
+ [sexpdata.Symbol("font"), [sexpdata.Symbol("size"), config.defaults.font_size, config.defaults.font_size]],
270
+ [sexpdata.Symbol("justify"), sexpdata.Symbol("left"), sexpdata.Symbol("bottom")],
271
+ ]
272
+ )
273
+ sexp.append(name_prop)
274
+
275
+ # Sheetfile property
276
+ file_prop = [sexpdata.Symbol("property"), "Sheetfile", filename]
277
+ file_prop.append(
278
+ [sexpdata.Symbol("at"), x, round(y + h + config.sheet.file_offset_y, 4), 0]
279
+ ) # Below sheet
280
+ file_prop.append(
281
+ [
282
+ sexpdata.Symbol("effects"),
283
+ [sexpdata.Symbol("font"), [sexpdata.Symbol("size"), config.defaults.font_size, config.defaults.font_size]],
284
+ [sexpdata.Symbol("justify"), sexpdata.Symbol("left"), sexpdata.Symbol("top")],
285
+ ]
286
+ )
287
+ sexp.append(file_prop)
288
+
289
+ # Add sheet pins if any
290
+ for pin in sheet_data.get("pins", []):
291
+ pin_sexp = self._sheet_pin_to_sexp(pin)
292
+ sexp.append(pin_sexp)
293
+
294
+ # Add instances
295
+ if schematic_uuid:
296
+ instances_sexp = [sexpdata.Symbol("instances")]
297
+ project_name = sheet_data.get("project_name", "")
298
+ page_number = sheet_data.get("page_number", "2")
299
+ project_sexp = [sexpdata.Symbol("project"), project_name]
300
+ path_sexp = [sexpdata.Symbol("path"), f"/{schematic_uuid}"]
301
+ path_sexp.append([sexpdata.Symbol("page"), page_number])
302
+ project_sexp.append(path_sexp)
303
+ instances_sexp.append(project_sexp)
304
+ sexp.append(instances_sexp)
305
+
306
+ return sexp
307
+
308
+
309
+ def _sheet_pin_to_sexp(self, pin_data: Dict[str, Any]) -> List[Any]:
310
+ """Convert sheet pin to S-expression."""
311
+ pin_sexp = [
312
+ sexpdata.Symbol("pin"),
313
+ pin_data["name"],
314
+ sexpdata.Symbol(pin_data.get("pin_type", "input")),
315
+ ]
316
+
317
+ # Add position
318
+ pos = pin_data["position"]
319
+ x, y = pos["x"], pos["y"]
320
+ rotation = pin_data.get("rotation", 0)
321
+ pin_sexp.append([sexpdata.Symbol("at"), x, y, rotation])
322
+
323
+ # Add UUID
324
+ if "uuid" in pin_data:
325
+ pin_sexp.append([sexpdata.Symbol("uuid"), pin_data["uuid"]])
326
+
327
+ # Add effects
328
+ size = pin_data.get("size", config.defaults.font_size)
329
+ effects = [sexpdata.Symbol("effects")]
330
+ font = [sexpdata.Symbol("font"), [sexpdata.Symbol("size"), size, size]]
331
+ effects.append(font)
332
+ justify = pin_data.get("justify", "right")
333
+ effects.append([sexpdata.Symbol("justify"), sexpdata.Symbol(justify)])
334
+ pin_sexp.append(effects)
335
+
336
+ return pin_sexp
337
+
338
+
339
+ def _sheet_instances_to_sexp(self, sheet_instances: List[Dict[str, Any]]) -> List[Any]:
340
+ """Convert sheet_instances to S-expression."""
341
+ sexp = [sexpdata.Symbol("sheet_instances")]
342
+ for sheet in sheet_instances:
343
+ # Create: (path "/" (page "1"))
344
+ sheet_sexp = [
345
+ sexpdata.Symbol("path"),
346
+ sheet.get("path", "/"),
347
+ [sexpdata.Symbol("page"), str(sheet.get("page", "1"))],
348
+ ]
349
+ sexp.append(sheet_sexp)
350
+ return sexp
351
+
352
+
@@ -0,0 +1,313 @@
1
+ """
2
+ Component symbol elements parser for KiCAD schematics.
3
+
4
+ Handles parsing and serialization of Component symbol elements.
5
+ """
6
+
7
+ import logging
8
+ import uuid
9
+ from typing import Any, Dict, List, Optional
10
+
11
+ import sexpdata
12
+
13
+ from ...core.types import Point
14
+ from ..base import BaseElementParser
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class SymbolParser(BaseElementParser):
20
+ """Parser for Component symbol elements."""
21
+
22
+ def __init__(self):
23
+ """Initialize symbol parser."""
24
+ super().__init__("symbol")
25
+
26
+ def _parse_symbol(self, item: List[Any]) -> Optional[Dict[str, Any]]:
27
+ """Parse a symbol (component) definition."""
28
+ try:
29
+ symbol_data = {
30
+ "lib_id": None,
31
+ "position": Point(0, 0),
32
+ "rotation": 0,
33
+ "uuid": None,
34
+ "reference": None,
35
+ "value": None,
36
+ "footprint": None,
37
+ "properties": {},
38
+ "pins": [],
39
+ "in_bom": True,
40
+ "on_board": True,
41
+ }
42
+
43
+ for sub_item in item[1:]:
44
+ if not isinstance(sub_item, list) or len(sub_item) == 0:
45
+ continue
46
+
47
+ element_type = (
48
+ str(sub_item[0]) if isinstance(sub_item[0], sexpdata.Symbol) else None
49
+ )
50
+
51
+ if element_type == "lib_id":
52
+ symbol_data["lib_id"] = sub_item[1] if len(sub_item) > 1 else None
53
+ elif element_type == "at":
54
+ if len(sub_item) >= 3:
55
+ symbol_data["position"] = Point(float(sub_item[1]), float(sub_item[2]))
56
+ if len(sub_item) > 3:
57
+ symbol_data["rotation"] = float(sub_item[3])
58
+ elif element_type == "uuid":
59
+ symbol_data["uuid"] = sub_item[1] if len(sub_item) > 1 else None
60
+ elif element_type == "property":
61
+ prop_data = self._parse_property(sub_item)
62
+ if prop_data:
63
+ prop_name = prop_data.get("name")
64
+ if prop_name == "Reference":
65
+ symbol_data["reference"] = prop_data.get("value")
66
+ elif prop_name == "Value":
67
+ symbol_data["value"] = prop_data.get("value")
68
+ elif prop_name == "Footprint":
69
+ symbol_data["footprint"] = prop_data.get("value")
70
+ else:
71
+ # Unescape quotes in property values when loading
72
+ prop_value = prop_data.get("value")
73
+ if prop_value:
74
+ prop_value = str(prop_value).replace('\\"', '"')
75
+ symbol_data["properties"][prop_name] = prop_value
76
+ elif element_type == "in_bom":
77
+ symbol_data["in_bom"] = sub_item[1] == "yes" if len(sub_item) > 1 else True
78
+ elif element_type == "on_board":
79
+ symbol_data["on_board"] = sub_item[1] == "yes" if len(sub_item) > 1 else True
80
+
81
+ return symbol_data
82
+
83
+ except Exception as e:
84
+ logger.warning(f"Error parsing symbol: {e}")
85
+ return None
86
+
87
+
88
+ def _parse_property(self, item: List[Any]) -> Optional[Dict[str, Any]]:
89
+ """Parse a property definition."""
90
+ if len(item) < 3:
91
+ return None
92
+
93
+ return {
94
+ "name": item[1] if len(item) > 1 else None,
95
+ "value": item[2] if len(item) > 2 else None,
96
+ }
97
+
98
+
99
+ def _symbol_to_sexp(self, symbol_data: Dict[str, Any], schematic_uuid: str = None) -> List[Any]:
100
+ """Convert symbol to S-expression."""
101
+ sexp = [sexpdata.Symbol("symbol")]
102
+
103
+ if symbol_data.get("lib_id"):
104
+ sexp.append([sexpdata.Symbol("lib_id"), symbol_data["lib_id"]])
105
+
106
+ # Add position and rotation (preserve original format)
107
+ pos = symbol_data.get("position", Point(0, 0))
108
+ rotation = symbol_data.get("rotation", 0)
109
+ # Format numbers as integers if they are whole numbers
110
+ x = int(pos.x) if pos.x == int(pos.x) else pos.x
111
+ y = int(pos.y) if pos.y == int(pos.y) else pos.y
112
+ r = int(rotation) if rotation == int(rotation) else rotation
113
+ # Always include rotation for format consistency with KiCAD
114
+ sexp.append([sexpdata.Symbol("at"), x, y, r])
115
+
116
+ # Add unit (required by KiCAD)
117
+ unit = symbol_data.get("unit", 1)
118
+ sexp.append([sexpdata.Symbol("unit"), unit])
119
+
120
+ # Add simulation and board settings (required by KiCAD)
121
+ sexp.append([sexpdata.Symbol("exclude_from_sim"), "no"])
122
+ sexp.append([sexpdata.Symbol("in_bom"), "yes" if symbol_data.get("in_bom", True) else "no"])
123
+ sexp.append(
124
+ [sexpdata.Symbol("on_board"), "yes" if symbol_data.get("on_board", True) else "no"]
125
+ )
126
+ sexp.append([sexpdata.Symbol("dnp"), "no"])
127
+ sexp.append([sexpdata.Symbol("fields_autoplaced"), "yes"])
128
+
129
+ if symbol_data.get("uuid"):
130
+ sexp.append([sexpdata.Symbol("uuid"), symbol_data["uuid"]])
131
+
132
+ # Add properties with proper positioning and effects
133
+ lib_id = symbol_data.get("lib_id", "")
134
+ is_power_symbol = "power:" in lib_id
135
+
136
+ if symbol_data.get("reference"):
137
+ # Power symbol references should be hidden by default
138
+ ref_hide = is_power_symbol
139
+ ref_prop = self._create_property_with_positioning(
140
+ "Reference", symbol_data["reference"], pos, 0, "left", hide=ref_hide
141
+ )
142
+ sexp.append(ref_prop)
143
+
144
+ if symbol_data.get("value"):
145
+ # Power symbol values need different positioning
146
+ if is_power_symbol:
147
+ val_prop = self._create_power_symbol_value_property(
148
+ symbol_data["value"], pos, lib_id
149
+ )
150
+ else:
151
+ val_prop = self._create_property_with_positioning(
152
+ "Value", symbol_data["value"], pos, 1, "left"
153
+ )
154
+ sexp.append(val_prop)
155
+
156
+ footprint = symbol_data.get("footprint")
157
+ if footprint is not None: # Include empty strings but not None
158
+ fp_prop = self._create_property_with_positioning(
159
+ "Footprint", footprint, pos, 2, "left", hide=True
160
+ )
161
+ sexp.append(fp_prop)
162
+
163
+ for prop_name, prop_value in symbol_data.get("properties", {}).items():
164
+ escaped_value = str(prop_value).replace('"', '\\"')
165
+ prop = self._create_property_with_positioning(
166
+ prop_name, escaped_value, pos, 3, "left", hide=True
167
+ )
168
+ sexp.append(prop)
169
+
170
+ # Add pin UUID assignments (required by KiCAD)
171
+ for pin in symbol_data.get("pins", []):
172
+ pin_uuid = str(uuid.uuid4())
173
+ # Ensure pin number is a string for proper quoting
174
+ pin_number = str(pin.number)
175
+ sexp.append([sexpdata.Symbol("pin"), pin_number, [sexpdata.Symbol("uuid"), pin_uuid]])
176
+
177
+ # Add instances section (required by KiCAD)
178
+ from ...core.config import config
179
+
180
+ # Get project name from config or properties
181
+ project_name = symbol_data.get("properties", {}).get("project_name")
182
+ if not project_name:
183
+ project_name = getattr(self, "project_name", config.defaults.project_name)
184
+
185
+ # CRITICAL FIX: Use the FULL hierarchy_path from properties if available
186
+ # For hierarchical schematics, this contains the complete path: /root_uuid/sheet_symbol_uuid/...
187
+ # This ensures KiCad can properly annotate components in sub-sheets
188
+ hierarchy_path = symbol_data.get("properties", {}).get("hierarchy_path")
189
+ if hierarchy_path:
190
+ # Use the full hierarchical path (includes root + all sheet symbols)
191
+ instance_path = hierarchy_path
192
+ logger.debug(
193
+ f"🔧 Using FULL hierarchy_path: {instance_path} for component {symbol_data.get('reference', 'unknown')}"
194
+ )
195
+ else:
196
+ # Fallback: use root_uuid or schematic_uuid for flat designs
197
+ root_uuid = (
198
+ symbol_data.get("properties", {}).get("root_uuid")
199
+ or schematic_uuid
200
+ or str(uuid.uuid4())
201
+ )
202
+ instance_path = f"/{root_uuid}"
203
+ logger.debug(
204
+ f"🔧 Using root UUID path: {instance_path} for component {symbol_data.get('reference', 'unknown')}"
205
+ )
206
+
207
+ logger.debug(
208
+ f"🔧 Component properties keys: {list(symbol_data.get('properties', {}).keys())}"
209
+ )
210
+ logger.debug(f"🔧 Using project name: '{project_name}'")
211
+
212
+ sexp.append(
213
+ [
214
+ sexpdata.Symbol("instances"),
215
+ [
216
+ sexpdata.Symbol("project"),
217
+ project_name,
218
+ [
219
+ sexpdata.Symbol("path"),
220
+ instance_path,
221
+ [sexpdata.Symbol("reference"), symbol_data.get("reference", "U?")],
222
+ [sexpdata.Symbol("unit"), symbol_data.get("unit", 1)],
223
+ ],
224
+ ],
225
+ ]
226
+ )
227
+
228
+ return sexp
229
+
230
+
231
+ def _create_property_with_positioning(
232
+ self,
233
+ prop_name: str,
234
+ prop_value: str,
235
+ component_pos: Point,
236
+ offset_index: int,
237
+ justify: str = "left",
238
+ hide: bool = False,
239
+ ) -> List[Any]:
240
+ """Create a property with proper positioning and effects like KiCAD."""
241
+ from ...core.config import config
242
+
243
+ # Calculate property position using configuration
244
+ prop_x, prop_y, rotation = config.get_property_position(
245
+ prop_name, (component_pos.x, component_pos.y), offset_index
246
+ )
247
+
248
+ # Build effects section based on hide status
249
+ effects = [
250
+ sexpdata.Symbol("effects"),
251
+ [sexpdata.Symbol("font"), [sexpdata.Symbol("size"), 1.27, 1.27]],
252
+ ]
253
+
254
+ # Only add justify for visible properties or Reference/Value
255
+ if not hide or prop_name in ["Reference", "Value"]:
256
+ effects.append([sexpdata.Symbol("justify"), sexpdata.Symbol(justify)])
257
+
258
+ if hide:
259
+ effects.append([sexpdata.Symbol("hide"), sexpdata.Symbol("yes")])
260
+
261
+ prop_sexp = [
262
+ sexpdata.Symbol("property"),
263
+ prop_name,
264
+ prop_value,
265
+ [
266
+ sexpdata.Symbol("at"),
267
+ round(prop_x, 4) if prop_x != int(prop_x) else int(prop_x),
268
+ round(prop_y, 4) if prop_y != int(prop_y) else int(prop_y),
269
+ rotation,
270
+ ],
271
+ effects,
272
+ ]
273
+
274
+ return prop_sexp
275
+
276
+
277
+ def _create_power_symbol_value_property(
278
+ self, value: str, component_pos: Point, lib_id: str
279
+ ) -> List[Any]:
280
+ """Create Value property for power symbols with correct positioning."""
281
+ # Power symbols have different value positioning based on type
282
+ if "GND" in lib_id:
283
+ # GND value goes below the symbol
284
+ prop_x = component_pos.x
285
+ prop_y = component_pos.y + 5.08 # Below GND symbol
286
+ elif "+3.3V" in lib_id or "VDD" in lib_id:
287
+ # Positive voltage values go below the symbol
288
+ prop_x = component_pos.x
289
+ prop_y = component_pos.y - 5.08 # Above symbol (negative offset)
290
+ else:
291
+ # Default power symbol positioning
292
+ prop_x = component_pos.x
293
+ prop_y = component_pos.y + 3.556
294
+
295
+ prop_sexp = [
296
+ sexpdata.Symbol("property"),
297
+ "Value",
298
+ value,
299
+ [
300
+ sexpdata.Symbol("at"),
301
+ round(prop_x, 4) if prop_x != int(prop_x) else int(prop_x),
302
+ round(prop_y, 4) if prop_y != int(prop_y) else int(prop_y),
303
+ 0,
304
+ ],
305
+ [
306
+ sexpdata.Symbol("effects"),
307
+ [sexpdata.Symbol("font"), [sexpdata.Symbol("size"), 1.27, 1.27]],
308
+ ],
309
+ ]
310
+
311
+ return prop_sexp
312
+
313
+