kicad-sch-api 0.3.1__py3-none-any.whl → 0.3.4__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.

@@ -0,0 +1,595 @@
1
+ """
2
+ symbol_bbox.py
3
+
4
+ Calculate accurate bounding boxes for KiCad symbols based on their graphical elements.
5
+ This ensures proper spacing and collision detection in schematic layouts.
6
+
7
+ Migrated from circuit-synth to kicad-sch-api for better architectural separation.
8
+ """
9
+
10
+ import logging
11
+ import math
12
+ import os
13
+ from typing import Any, Dict, List, Optional, Tuple
14
+
15
+ from .font_metrics import (
16
+ DEFAULT_PIN_LENGTH,
17
+ DEFAULT_PIN_NAME_OFFSET,
18
+ DEFAULT_PIN_NUMBER_SIZE,
19
+ DEFAULT_PIN_TEXT_WIDTH_RATIO,
20
+ DEFAULT_TEXT_HEIGHT,
21
+ )
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class SymbolBoundingBoxCalculator:
27
+ """Calculate the actual bounding box of a symbol from its graphical elements."""
28
+
29
+ # Re-export font metrics as class constants for backward compatibility
30
+ DEFAULT_TEXT_HEIGHT = DEFAULT_TEXT_HEIGHT
31
+ DEFAULT_PIN_LENGTH = DEFAULT_PIN_LENGTH
32
+ DEFAULT_PIN_NAME_OFFSET = DEFAULT_PIN_NAME_OFFSET
33
+ DEFAULT_PIN_NUMBER_SIZE = DEFAULT_PIN_NUMBER_SIZE
34
+ DEFAULT_PIN_TEXT_WIDTH_RATIO = DEFAULT_PIN_TEXT_WIDTH_RATIO
35
+
36
+ @classmethod
37
+ def calculate_bounding_box(
38
+ cls,
39
+ symbol_data: Dict[str, Any],
40
+ include_properties: bool = True,
41
+ hierarchical_labels: Optional[List[Dict[str, Any]]] = None,
42
+ pin_net_map: Optional[Dict[str, str]] = None,
43
+ ) -> Tuple[float, float, float, float]:
44
+ """
45
+ Calculate the actual bounding box of a symbol from its graphical elements.
46
+
47
+ Args:
48
+ symbol_data: Dictionary containing symbol definition from KiCad library
49
+ include_properties: Whether to include space for Reference/Value labels
50
+ hierarchical_labels: List of hierarchical labels attached to this symbol
51
+ pin_net_map: Optional mapping of pin numbers to net names (for accurate label sizing)
52
+
53
+ Returns:
54
+ Tuple of (min_x, min_y, max_x, max_y) in mm
55
+
56
+ Raises:
57
+ ValueError: If symbol data is invalid or bounding box cannot be calculated
58
+ """
59
+ if not symbol_data:
60
+ raise ValueError("Symbol data is None or empty")
61
+
62
+ import sys
63
+ # Reduced logging frequency - only log if DEBUG environment variable is set
64
+ debug_enabled = os.getenv("CIRCUIT_SYNTH_DEBUG", "").lower() == "true"
65
+ if debug_enabled:
66
+ print(f"\n=== CALCULATING BOUNDING BOX ===", file=sys.stderr, flush=True)
67
+ print(f"include_properties={include_properties}", file=sys.stderr, flush=True)
68
+
69
+ min_x = float("inf")
70
+ min_y = float("inf")
71
+ max_x = float("-inf")
72
+ max_y = float("-inf")
73
+
74
+ # Process main symbol shapes (handle both 'shapes' and 'graphics' keys)
75
+ shapes = symbol_data.get("shapes", []) or symbol_data.get("graphics", [])
76
+ print(f"Processing {len(shapes)} main shapes", file=sys.stderr, flush=True)
77
+ for shape in shapes:
78
+ shape_bounds = cls._get_shape_bounds(shape)
79
+ if shape_bounds:
80
+ s_min_x, s_min_y, s_max_x, s_max_y = shape_bounds
81
+ min_x = min(min_x, s_min_x)
82
+ min_y = min(min_y, s_min_y)
83
+ max_x = max(max_x, s_max_x)
84
+ max_y = max(max_y, s_max_y)
85
+
86
+ # Process pins (including their labels)
87
+ pins = symbol_data.get("pins", [])
88
+ print(f"Processing {len(pins)} main pins", file=sys.stderr, flush=True)
89
+ for pin in pins:
90
+ pin_bounds = cls._get_pin_bounds(pin, pin_net_map)
91
+ if pin_bounds:
92
+ p_min_x, p_min_y, p_max_x, p_max_y = pin_bounds
93
+ min_x = min(min_x, p_min_x)
94
+ min_y = min(min_y, p_min_y)
95
+ max_x = max(max_x, p_max_x)
96
+ max_y = max(max_y, p_max_y)
97
+
98
+ # Process sub-symbols
99
+ sub_symbols = symbol_data.get("sub_symbols", [])
100
+ for sub in sub_symbols:
101
+ # Sub-symbols can have their own shapes and pins (handle both 'shapes' and 'graphics' keys)
102
+ sub_shapes = sub.get("shapes", []) or sub.get("graphics", [])
103
+ for shape in sub_shapes:
104
+ shape_bounds = cls._get_shape_bounds(shape)
105
+ if shape_bounds:
106
+ s_min_x, s_min_y, s_max_x, s_max_y = shape_bounds
107
+ min_x = min(min_x, s_min_x)
108
+ min_y = min(min_y, s_min_y)
109
+ max_x = max(max_x, s_max_x)
110
+ max_y = max(max_y, s_max_y)
111
+
112
+ sub_pins = sub.get("pins", [])
113
+ for pin in sub_pins:
114
+ pin_bounds = cls._get_pin_bounds(pin, pin_net_map)
115
+ if pin_bounds:
116
+ p_min_x, p_min_y, p_max_x, p_max_y = pin_bounds
117
+ min_x = min(min_x, p_min_x)
118
+ min_y = min(min_y, p_min_y)
119
+ max_x = max(max_x, p_max_x)
120
+ max_y = max(max_y, p_max_y)
121
+
122
+ # Check if we found any geometry
123
+ if min_x == float("inf") or max_x == float("-inf"):
124
+ raise ValueError(f"No valid geometry found in symbol data")
125
+
126
+ print(f"After geometry processing: ({min_x:.2f}, {min_y:.2f}) to ({max_x:.2f}, {max_y:.2f})", file=sys.stderr, flush=True)
127
+ print(f" Width: {max_x - min_x:.2f}, Height: {max_y - min_y:.2f}", file=sys.stderr, flush=True)
128
+
129
+ # Add small margin for text that might extend beyond shapes
130
+ margin = 0.254 # 10 mils
131
+ min_x -= margin
132
+ min_y -= margin
133
+ max_x += margin
134
+ max_y += margin
135
+
136
+ # Include space for component properties (Reference, Value, Footprint)
137
+ if include_properties:
138
+ # Use adaptive spacing based on component dimensions
139
+ component_width = max_x - min_x
140
+ component_height = max_y - min_y
141
+
142
+ # Adaptive property width: minimum 10mm or 80% of component width
143
+ property_width = max(10.0, component_width * 0.8)
144
+ property_height = cls.DEFAULT_TEXT_HEIGHT
145
+
146
+ # Adaptive vertical spacing: minimum 5mm or 10% of component height
147
+ vertical_spacing_above = max(5.0, component_height * 0.1)
148
+ vertical_spacing_below = max(10.0, component_height * 0.15)
149
+
150
+ # Reference label above
151
+ min_y -= vertical_spacing_above + property_height
152
+
153
+ # Value and Footprint labels below
154
+ max_y += vertical_spacing_below + property_height
155
+
156
+ # Extend horizontally for property text
157
+ center_x = (min_x + max_x) / 2
158
+ min_x = min(min_x, center_x - property_width / 2)
159
+ max_x = max(max_x, center_x + property_width / 2)
160
+
161
+ logger.debug(
162
+ f"Calculated bounding box: ({min_x:.2f}, {min_y:.2f}) to ({max_x:.2f}, {max_y:.2f})"
163
+ )
164
+
165
+ print(f"FINAL BBOX: ({min_x:.2f}, {min_y:.2f}) to ({max_x:.2f}, {max_y:.2f})", file=sys.stderr, flush=True)
166
+ print(f" Width: {max_x - min_x:.2f}, Height: {max_y - min_y:.2f}", file=sys.stderr, flush=True)
167
+ print("=" * 50 + "\n", file=sys.stderr, flush=True)
168
+
169
+ return (min_x, min_y, max_x, max_y)
170
+
171
+ @classmethod
172
+ def calculate_placement_bounding_box(
173
+ cls,
174
+ symbol_data: Dict[str, Any],
175
+ ) -> Tuple[float, float, float, float]:
176
+ """
177
+ Calculate bounding box for PLACEMENT purposes - excludes pin labels.
178
+
179
+ This method calculates a tighter bounding box that only includes:
180
+ - Component body (shapes/graphics)
181
+ - Pin endpoints (without label text)
182
+ - Small margin for component properties
183
+
184
+ Pin label text is excluded because it extends arbitrarily far based on
185
+ text length and would cause incorrect spacing in text-flow placement.
186
+
187
+ Args:
188
+ symbol_data: Dictionary containing symbol definition from KiCad library
189
+
190
+ Returns:
191
+ Tuple of (min_x, min_y, max_x, max_y) in mm
192
+
193
+ Raises:
194
+ ValueError: If symbol data is invalid or bounding box cannot be calculated
195
+ """
196
+ if not symbol_data:
197
+ raise ValueError("Symbol data is None or empty")
198
+
199
+ import sys
200
+ print(f"\n=== CALCULATING PLACEMENT BOUNDING BOX (NO PIN LABELS) ===", file=sys.stderr, flush=True)
201
+
202
+ min_x = float("inf")
203
+ min_y = float("inf")
204
+ max_x = float("-inf")
205
+ max_y = float("-inf")
206
+
207
+ # Process main symbol shapes (handle both 'shapes' and 'graphics' keys)
208
+ shapes = symbol_data.get("shapes", []) or symbol_data.get("graphics", [])
209
+ print(f"Processing {len(shapes)} main shapes", file=sys.stderr, flush=True)
210
+ for shape in shapes:
211
+ shape_bounds = cls._get_shape_bounds(shape)
212
+ if shape_bounds:
213
+ s_min_x, s_min_y, s_max_x, s_max_y = shape_bounds
214
+ min_x = min(min_x, s_min_x)
215
+ min_y = min(min_y, s_min_y)
216
+ max_x = max(max_x, s_max_x)
217
+ max_y = max(max_y, s_max_y)
218
+
219
+ # Process pins WITHOUT labels (just pin endpoints)
220
+ pins = symbol_data.get("pins", [])
221
+ print(f"Processing {len(pins)} main pins (NO LABELS)", file=sys.stderr, flush=True)
222
+ for pin in pins:
223
+ pin_bounds = cls._get_pin_bounds_no_labels(pin)
224
+ if pin_bounds:
225
+ p_min_x, p_min_y, p_max_x, p_max_y = pin_bounds
226
+ min_x = min(min_x, p_min_x)
227
+ min_y = min(min_y, p_min_y)
228
+ max_x = max(max_x, p_max_x)
229
+ max_y = max(max_y, p_max_y)
230
+
231
+ # Process sub-symbols
232
+ sub_symbols = symbol_data.get("sub_symbols", [])
233
+ for sub in sub_symbols:
234
+ # Sub-symbols can have their own shapes and pins
235
+ sub_shapes = sub.get("shapes", []) or sub.get("graphics", [])
236
+ for shape in sub_shapes:
237
+ shape_bounds = cls._get_shape_bounds(shape)
238
+ if shape_bounds:
239
+ s_min_x, s_min_y, s_max_x, s_max_y = shape_bounds
240
+ min_x = min(min_x, s_min_x)
241
+ min_y = min(min_y, s_min_y)
242
+ max_x = max(max_x, s_max_x)
243
+ max_y = max(max_y, s_max_y)
244
+
245
+ sub_pins = sub.get("pins", [])
246
+ for pin in sub_pins:
247
+ pin_bounds = cls._get_pin_bounds_no_labels(pin)
248
+ if pin_bounds:
249
+ p_min_x, p_min_y, p_max_x, p_max_y = pin_bounds
250
+ min_x = min(min_x, p_min_x)
251
+ min_y = min(min_y, p_min_y)
252
+ max_x = max(max_x, p_max_x)
253
+ max_y = max(max_y, p_max_y)
254
+
255
+ # Check if we found any geometry
256
+ if min_x == float("inf") or max_x == float("-inf"):
257
+ raise ValueError(f"No valid geometry found in symbol data")
258
+
259
+ print(f"After geometry processing: ({min_x:.2f}, {min_y:.2f}) to ({max_x:.2f}, {max_y:.2f})", file=sys.stderr, flush=True)
260
+ print(f" Width: {max_x - min_x:.2f}, Height: {max_y - min_y:.2f}", file=sys.stderr, flush=True)
261
+
262
+ # Add small margin for visual spacing
263
+ margin = 0.635 # 25mil margin (reduced from 50mil)
264
+ min_x -= margin
265
+ min_y -= margin
266
+ max_x += margin
267
+ max_y += margin
268
+
269
+ # Add minimal space for component properties (Reference above, Value below)
270
+ # Use adaptive spacing based on component height for better visual hierarchy
271
+ component_height = max_y - min_y
272
+ property_spacing = max(3.0, component_height * 0.15) # Adaptive: minimum 3mm or 15% of height
273
+ property_height = 1.27 # Reduced from 2.54mm
274
+ min_y -= property_spacing + property_height # Reference above
275
+ max_y += property_spacing + property_height # Value below
276
+
277
+ print(f"FINAL PLACEMENT BBOX: ({min_x:.2f}, {min_y:.2f}) to ({max_x:.2f}, {max_y:.2f})", file=sys.stderr, flush=True)
278
+ print(f" Width: {max_x - min_x:.2f}, Height: {max_y - min_y:.2f}", file=sys.stderr, flush=True)
279
+ print("=" * 50 + "\n", file=sys.stderr, flush=True)
280
+
281
+ return (min_x, min_y, max_x, max_y)
282
+
283
+ @classmethod
284
+ def calculate_visual_bounding_box(
285
+ cls, symbol_data: Dict[str, Any], pin_net_map: Optional[Dict[str, str]] = None
286
+ ) -> Tuple[float, float, float, float]:
287
+ """
288
+ Calculate bounding box for visual/debug drawing (includes pin labels, no property spacing).
289
+
290
+ This shows the actual component geometry including pin labels.
291
+ Use this for drawing bounding boxes on schematics.
292
+
293
+ Args:
294
+ symbol_data: Dictionary containing symbol definition
295
+ pin_net_map: Optional mapping of pin numbers to net names (for accurate label sizing)
296
+
297
+ Returns:
298
+ Tuple of (min_x, min_y, max_x, max_y) in mm
299
+ """
300
+ # Initialize bounds
301
+ min_x = float("inf")
302
+ min_y = float("inf")
303
+ max_x = float("-inf")
304
+ max_y = float("-inf")
305
+
306
+ # Process main symbol shapes
307
+ shapes = symbol_data.get("shapes", []) or symbol_data.get("graphics", [])
308
+ for shape in shapes:
309
+ shape_bounds = cls._get_shape_bounds(shape)
310
+ if shape_bounds:
311
+ s_min_x, s_min_y, s_max_x, s_max_y = shape_bounds
312
+ min_x = min(min_x, s_min_x)
313
+ min_y = min(min_y, s_min_y)
314
+ max_x = max(max_x, s_max_x)
315
+ max_y = max(max_y, s_max_y)
316
+
317
+ # Process pins WITH labels to get accurate visual bounds
318
+ pins = symbol_data.get("pins", [])
319
+ for pin in pins:
320
+ pin_bounds = cls._get_pin_bounds(pin, pin_net_map)
321
+ if pin_bounds:
322
+ p_min_x, p_min_y, p_max_x, p_max_y = pin_bounds
323
+ min_x = min(min_x, p_min_x)
324
+ min_y = min(min_y, p_min_y)
325
+ max_x = max(max_x, p_max_x)
326
+ max_y = max(max_y, p_max_y)
327
+
328
+ # Process sub-symbols
329
+ sub_symbols = symbol_data.get("sub_symbols", [])
330
+ for sub in sub_symbols:
331
+ sub_shapes = sub.get("shapes", []) or sub.get("graphics", [])
332
+ for shape in sub_shapes:
333
+ shape_bounds = cls._get_shape_bounds(shape)
334
+ if shape_bounds:
335
+ s_min_x, s_min_y, s_max_x, s_max_y = shape_bounds
336
+ min_x = min(min_x, s_min_x)
337
+ min_y = min(min_y, s_min_y)
338
+ max_x = max(max_x, s_max_x)
339
+ max_y = max(max_y, s_max_y)
340
+
341
+ sub_pins = sub.get("pins", [])
342
+ for pin in sub_pins:
343
+ pin_bounds = cls._get_pin_bounds(pin, pin_net_map)
344
+ if pin_bounds:
345
+ p_min_x, p_min_y, p_max_x, p_max_y = pin_bounds
346
+ min_x = min(min_x, p_min_x)
347
+ min_y = min(min_y, p_min_y)
348
+ max_x = max(max_x, p_max_x)
349
+ max_y = max(max_y, p_max_y)
350
+
351
+ # Check if we found any geometry
352
+ if min_x == float("inf") or max_x == float("-inf"):
353
+ raise ValueError(f"No valid geometry found in symbol data")
354
+
355
+ # Add only a tiny margin for visibility (no property spacing)
356
+ margin = 0.254 # 10mil minimal margin
357
+ min_x -= margin
358
+ min_y -= margin
359
+ max_x += margin
360
+ max_y += margin
361
+
362
+ return (min_x, min_y, max_x, max_y)
363
+
364
+ @classmethod
365
+ def get_symbol_dimensions(
366
+ cls, symbol_data: Dict[str, Any], include_properties: bool = True, pin_net_map: Optional[Dict[str, str]] = None
367
+ ) -> Tuple[float, float]:
368
+ """
369
+ Get the width and height of a symbol.
370
+
371
+ Args:
372
+ symbol_data: Dictionary containing symbol definition
373
+ include_properties: Whether to include space for Reference/Value labels
374
+ pin_net_map: Optional mapping of pin numbers to net names
375
+
376
+ Returns:
377
+ Tuple of (width, height) in mm
378
+ """
379
+ min_x, min_y, max_x, max_y = cls.calculate_bounding_box(
380
+ symbol_data, include_properties, pin_net_map=pin_net_map
381
+ )
382
+ width = max_x - min_x
383
+ height = max_y - min_y
384
+ return (width, height)
385
+
386
+ @classmethod
387
+ def _get_shape_bounds(
388
+ cls, shape: Dict[str, Any]
389
+ ) -> Optional[Tuple[float, float, float, float]]:
390
+ """Get bounding box for a graphical shape."""
391
+ shape_type = shape.get("shape_type", "")
392
+
393
+ if shape_type == "rectangle":
394
+ start = shape.get("start", [0, 0])
395
+ end = shape.get("end", [0, 0])
396
+ return (
397
+ min(start[0], end[0]),
398
+ min(start[1], end[1]),
399
+ max(start[0], end[0]),
400
+ max(start[1], end[1]),
401
+ )
402
+
403
+ elif shape_type == "circle":
404
+ center = shape.get("center", [0, 0])
405
+ radius = shape.get("radius", 0)
406
+ return (
407
+ center[0] - radius,
408
+ center[1] - radius,
409
+ center[0] + radius,
410
+ center[1] + radius,
411
+ )
412
+
413
+ elif shape_type == "arc":
414
+ # For arcs, we need to consider start, mid, and end points
415
+ start = shape.get("start", [0, 0])
416
+ mid = shape.get("mid", [0, 0])
417
+ end = shape.get("end", [0, 0])
418
+
419
+ # Simple approach: use bounding box of all three points
420
+ # More accurate would be to calculate the actual arc bounds
421
+ min_x = min(start[0], mid[0], end[0])
422
+ min_y = min(start[1], mid[1], end[1])
423
+ max_x = max(start[0], mid[0], end[0])
424
+ max_y = max(start[1], mid[1], end[1])
425
+
426
+ return (min_x, min_y, max_x, max_y)
427
+
428
+ elif shape_type == "polyline":
429
+ points = shape.get("points", [])
430
+ if not points:
431
+ return None
432
+
433
+ min_x = min(p[0] for p in points)
434
+ min_y = min(p[1] for p in points)
435
+ max_x = max(p[0] for p in points)
436
+ max_y = max(p[1] for p in points)
437
+
438
+ return (min_x, min_y, max_x, max_y)
439
+
440
+ elif shape_type == "text":
441
+ # Text bounding box estimation
442
+ at = shape.get("at", [0, 0])
443
+ text = shape.get("text", "")
444
+ # Rough estimation: each character is about 1.27mm wide
445
+ text_width = len(text) * cls.DEFAULT_TEXT_HEIGHT * 0.6
446
+ text_height = cls.DEFAULT_TEXT_HEIGHT
447
+
448
+ return (
449
+ at[0] - text_width / 2,
450
+ at[1] - text_height / 2,
451
+ at[0] + text_width / 2,
452
+ at[1] + text_height / 2,
453
+ )
454
+
455
+ return None
456
+
457
+ @classmethod
458
+ def _get_pin_bounds(
459
+ cls, pin: Dict[str, Any], pin_net_map: Optional[Dict[str, str]] = None
460
+ ) -> Optional[Tuple[float, float, float, float]]:
461
+ """Get bounding box for a pin including its labels."""
462
+ import sys
463
+
464
+ # Handle both formats: 'at' array or separate x/y/orientation
465
+ if "at" in pin:
466
+ at = pin.get("at", [0, 0])
467
+ x, y = at[0], at[1]
468
+ angle = at[2] if len(at) > 2 else 0
469
+ else:
470
+ # Handle the format from symbol cache
471
+ x = pin.get("x", 0)
472
+ y = pin.get("y", 0)
473
+ angle = pin.get("orientation", 0)
474
+
475
+ length = pin.get("length", cls.DEFAULT_PIN_LENGTH)
476
+
477
+ # Calculate pin endpoint based on angle
478
+ angle_rad = math.radians(angle)
479
+ end_x = x + length * math.cos(angle_rad)
480
+ end_y = y + length * math.sin(angle_rad)
481
+
482
+ # Start with pin line bounds
483
+ min_x = min(x, end_x)
484
+ min_y = min(y, end_y)
485
+ max_x = max(x, end_x)
486
+ max_y = max(y, end_y)
487
+
488
+ # Add space for pin name and number
489
+ pin_name = pin.get("name", "")
490
+ pin_number = pin.get("number", "")
491
+
492
+ # Use net name for label sizing if available (hierarchical labels show net names, not pin names)
493
+ # If no net name match, use minimal fallback to avoid oversized bounding boxes
494
+ if pin_net_map and pin_number in pin_net_map:
495
+ label_text = pin_net_map[pin_number]
496
+ print(f" PIN {pin_number}: ✅ USING NET '{label_text}' (len={len(label_text)}), at=({x:.2f}, {y:.2f}), angle={angle}", file=sys.stderr, flush=True)
497
+ else:
498
+ # No net match - use minimal size (3 chars) instead of potentially long pin name
499
+ label_text = "XXX" # 3-character placeholder for unmatched pins
500
+ print(f" PIN {pin_number}: ⚠️ NO MATCH, using minimal fallback (pin name was '{pin_name}'), at=({x:.2f}, {y:.2f})", file=sys.stderr, flush=True)
501
+
502
+ if label_text and label_text != "~": # ~ means no name
503
+ # Calculate text dimensions
504
+ # For horizontal text: width = char_count * char_width
505
+ name_width = (
506
+ len(label_text)
507
+ * cls.DEFAULT_TEXT_HEIGHT
508
+ * cls.DEFAULT_PIN_TEXT_WIDTH_RATIO
509
+ )
510
+ # For vertical text: height = char_count * char_height (characters stack vertically)
511
+ name_height = len(label_text) * cls.DEFAULT_TEXT_HEIGHT
512
+
513
+ print(f" label_width={name_width:.2f}, label_height={name_height:.2f} (len={len(label_text)})", file=sys.stderr, flush=True)
514
+
515
+ # Adjust bounds based on pin orientation
516
+ # Labels are placed at PIN ENDPOINT with offset, extending AWAY from the component
517
+ # Pin angle indicates where the pin points (into component)
518
+ # Apply KiCad's standard pin name offset (0.508mm / 20 mils)
519
+ offset = cls.DEFAULT_PIN_NAME_OFFSET
520
+
521
+ if angle == 0: # Pin points right - label extends LEFT from endpoint
522
+ label_x = end_x - offset - name_width
523
+ print(f" Angle 0 (Right pin): min_x {min_x:.2f} -> {label_x:.2f} (offset={offset:.3f})", file=sys.stderr, flush=True)
524
+ min_x = min(min_x, label_x)
525
+ elif angle == 180: # Pin points left - label extends RIGHT from endpoint
526
+ label_x = end_x + offset + name_width
527
+ print(f" Angle 180 (Left pin): max_x {max_x:.2f} -> {label_x:.2f} (offset={offset:.3f})", file=sys.stderr, flush=True)
528
+ max_x = max(max_x, label_x)
529
+ elif angle == 90: # Pin points up - label extends DOWN from endpoint
530
+ label_y = end_y - offset - name_height
531
+ print(f" Angle 90 (Up pin): min_y {min_y:.2f} -> {label_y:.2f} (offset={offset:.3f})", file=sys.stderr, flush=True)
532
+ min_y = min(min_y, label_y)
533
+ elif angle == 270: # Pin points down - label extends UP from endpoint
534
+ label_y = end_y + offset + name_height
535
+ print(f" Angle 270 (Down pin): max_y {max_y:.2f} -> {label_y:.2f} (offset={offset:.3f})", file=sys.stderr, flush=True)
536
+ max_y = max(max_y, label_y)
537
+
538
+ # Pin numbers are typically placed near the component body
539
+ if pin_number:
540
+ num_width = (
541
+ len(pin_number)
542
+ * cls.DEFAULT_PIN_NUMBER_SIZE
543
+ * cls.DEFAULT_PIN_TEXT_WIDTH_RATIO
544
+ )
545
+ # Add some space for the pin number
546
+ margin = (
547
+ cls.DEFAULT_PIN_NUMBER_SIZE * 1.5
548
+ ) # Increase margin for better spacing
549
+ min_x -= margin
550
+ min_y -= margin
551
+ max_x += margin
552
+ max_y += margin
553
+
554
+ print(f" Pin bounds: ({min_x:.2f}, {min_y:.2f}) to ({max_x:.2f}, {max_y:.2f})", file=sys.stderr, flush=True)
555
+ return (min_x, min_y, max_x, max_y)
556
+
557
+ @classmethod
558
+ def _get_pin_bounds_no_labels(
559
+ cls, pin: Dict[str, Any]
560
+ ) -> Optional[Tuple[float, float, float, float]]:
561
+ """Get bounding box for a pin WITHOUT labels - for placement calculations only."""
562
+ import sys
563
+
564
+ # Handle both formats: 'at' array or separate x/y/orientation
565
+ if "at" in pin:
566
+ at = pin.get("at", [0, 0])
567
+ x, y = at[0], at[1]
568
+ angle = at[2] if len(at) > 2 else 0
569
+ else:
570
+ # Handle the format from symbol cache
571
+ x = pin.get("x", 0)
572
+ y = pin.get("y", 0)
573
+ angle = pin.get("orientation", 0)
574
+
575
+ length = pin.get("length", cls.DEFAULT_PIN_LENGTH)
576
+
577
+ # Calculate pin endpoint based on angle
578
+ angle_rad = math.radians(angle)
579
+ end_x = x + length * math.cos(angle_rad)
580
+ end_y = y + length * math.sin(angle_rad)
581
+
582
+ # Only include the pin line itself - NO labels
583
+ min_x = min(x, end_x)
584
+ min_y = min(y, end_y)
585
+ max_x = max(x, end_x)
586
+ max_y = max(y, end_y)
587
+
588
+ # Add small margin for pin graphics (circles, etc)
589
+ margin = 0.5 # Small margin for pin endpoint graphics
590
+ min_x -= margin
591
+ min_y -= margin
592
+ max_x += margin
593
+ max_y += margin
594
+
595
+ return (min_x, min_y, max_x, max_y)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kicad-sch-api
3
- Version: 0.3.1
3
+ Version: 0.3.4
4
4
  Summary: Professional KiCAD schematic manipulation library with exact format preservation
5
5
  Author-email: Circuit-Synth <shane@circuit-synth.com>
6
6
  Maintainer-email: Circuit-Synth <shane@circuit-synth.com>
@@ -1,31 +1,34 @@
1
- kicad_sch_api/__init__.py,sha256=J2JWDT18vzJzY1Qo59oRkz8gQu2LdEPYmyzcCFYLRZ8,2919
1
+ kicad_sch_api/__init__.py,sha256=87qLx-VMQTBHDVq_CiXW-6wyKtvLFc3AsKQaoLKsIbs,2919
2
2
  kicad_sch_api/cli.py,sha256=ZzmwzfHEvPgGfCiQBU4G2LBAyRtMNiBRoY21pivJSYc,7621
3
3
  kicad_sch_api/py.typed,sha256=e4ldqxwpY7pNDG1olbvj4HSKr8sZ9vxgA_2ek8xXn-Q,70
4
4
  kicad_sch_api/core/__init__.py,sha256=ur_KeYBlGKl-e1hLpLdxAhGV2A-PCCGkcqd0r6KSeBA,566
5
5
  kicad_sch_api/core/component_bounds.py,sha256=BFYJYULyzs5it2hN7bHTimyS9Vet4dxsMklRStob-F4,17509
6
6
  kicad_sch_api/core/components.py,sha256=tXRL18GObl2u94wl5jP-1ID56s_UD9F1gQ_iRIyZ_Kw,25290
7
7
  kicad_sch_api/core/config.py,sha256=itw0j3DeIEHaFVf8p3mfAS1SP6jclBwvMv7NPdkThE4,4309
8
- kicad_sch_api/core/formatter.py,sha256=cTvQ9kNIYr86viPtBnMlJLAn627ciwYznJduTgsBR18,20807
8
+ kicad_sch_api/core/formatter.py,sha256=zzZi0f06C1YWUy5l0WFS9G4KRTEzmAY3rFK3XGocvCo,22185
9
9
  kicad_sch_api/core/geometry.py,sha256=27SgN0padLbQuTi8MV6UUCp6Pyaiv8V9gmYDOhfwny8,2947
10
10
  kicad_sch_api/core/ic_manager.py,sha256=Kg0HIOMU-TGXiIkrnwcHFQ1Kfv_3rW2U1cwBKJsKopc,7219
11
11
  kicad_sch_api/core/junctions.py,sha256=Ay6BsWX_DLs-wB0eMA2CytKKq0N8Ja41ZubJWpAqNgM,6122
12
12
  kicad_sch_api/core/manhattan_routing.py,sha256=t_T2u0zsQB-a8dTijFmY-qFq-oDt2qDebYyXzD_pBWI,15989
13
- kicad_sch_api/core/parser.py,sha256=eX70sATlGMeZP-TM9Izhx0gGQ7DGAYBDVF3aHjRSpTM,61489
13
+ kicad_sch_api/core/parser.py,sha256=UY_GNX1yHd3xgTVqZ9TZe1u94q4YZBo-NibsSH8Jy44,94983
14
14
  kicad_sch_api/core/pin_utils.py,sha256=XGEow3HzBTyT8a0B_ZC8foMvwzYaENSaqTUwDW1rz24,5417
15
- kicad_sch_api/core/schematic.py,sha256=U9-wrhuGtgRqZJfc76Dj-g1_ZTjrT8R9LmfX-BIBH8w,61201
15
+ kicad_sch_api/core/schematic.py,sha256=B2n0tf3HyyVzTJOPABpzJGVbd5yBAsI9CE5OVZnSCoI,62027
16
16
  kicad_sch_api/core/simple_manhattan.py,sha256=CvIHvwmfABPF-COzhblYxEgRoR_R_eD-lmBFHHjDuMI,7241
17
- kicad_sch_api/core/types.py,sha256=UmqIvEx_Pd3B9jhvtmgZxx4SAjHUeOZBOEc8VtRILZs,13716
17
+ kicad_sch_api/core/types.py,sha256=D8VGvE7N2nj-xqnWSnTl98WaAbWh6JhQsn-pZCiLFfE,13974
18
18
  kicad_sch_api/core/wire_routing.py,sha256=G-C7S-ntQxwuu1z3OaaYlkURXwKE4r4xmhbbi6cvvaI,12830
19
19
  kicad_sch_api/core/wires.py,sha256=608t9oH4UzppdGgNgUd-ABK6T-ahyETZwhO_-CuKFO8,8319
20
20
  kicad_sch_api/discovery/__init__.py,sha256=qSuCsnC-hVtaLYE8fwd-Gea6JKwEVGPQ-hSNDNJYsIU,329
21
21
  kicad_sch_api/discovery/search_index.py,sha256=KgQT8ipT9OU6ktUwhDZ37Mao0Cba0fJOsxUk9m8ZKbY,15856
22
+ kicad_sch_api/geometry/__init__.py,sha256=hTBXkn8mZZCjzDIrtPv67QsnCYB77L67JjthQgEIX7o,716
23
+ kicad_sch_api/geometry/font_metrics.py,sha256=qqnfBuRqiLQDnGkk64rKzdyvuSNU0uBfdp0TKEgzXds,831
24
+ kicad_sch_api/geometry/symbol_bbox.py,sha256=5oMVmmimyqF4ITj8wS9yeU3jXdpqG0XhCIvAHlYr9Rg,24688
22
25
  kicad_sch_api/library/__init__.py,sha256=NG9UTdcpn25Bl9tPsYs9ED7bvpaVPVdtLMbnxkQkOnU,250
23
26
  kicad_sch_api/library/cache.py,sha256=7na88grl465WHwUOGuOzYrrWwjsMBXhXVtxhnaJ9GBY,33208
24
27
  kicad_sch_api/utils/__init__.py,sha256=1V_yGgI7jro6MUc4Pviux_WIeJ1wmiYFID186SZwWLQ,277
25
28
  kicad_sch_api/utils/validation.py,sha256=XlWGRZJb3cOPYpU9sLQQgC_NASwbi6W-LCN7PzUmaPY,15626
26
- kicad_sch_api-0.3.1.dist-info/licenses/LICENSE,sha256=Em65Nvte1G9MHc0rHqtYuGkCPcshD588itTa358J6gs,1070
27
- kicad_sch_api-0.3.1.dist-info/METADATA,sha256=xFLLtl5pIDqCTvvSyWz8Y1hFMdev4NpEKkq6IPmFMzo,17183
28
- kicad_sch_api-0.3.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
29
- kicad_sch_api-0.3.1.dist-info/entry_points.txt,sha256=VWKsFi2Jv7G_tmio3cNVhhIBfv_OZFaKa-T_ED84lc8,57
30
- kicad_sch_api-0.3.1.dist-info/top_level.txt,sha256=n0ex4gOJ1b_fARowcGqRzyOGZcHRhc5LZa6_vVgGxcI,14
31
- kicad_sch_api-0.3.1.dist-info/RECORD,,
29
+ kicad_sch_api-0.3.4.dist-info/licenses/LICENSE,sha256=Em65Nvte1G9MHc0rHqtYuGkCPcshD588itTa358J6gs,1070
30
+ kicad_sch_api-0.3.4.dist-info/METADATA,sha256=4RHjg7LWStLaT3wRrAkmDhBTLR5l-TRYoc2eXP5zZHs,17183
31
+ kicad_sch_api-0.3.4.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
32
+ kicad_sch_api-0.3.4.dist-info/entry_points.txt,sha256=VWKsFi2Jv7G_tmio3cNVhhIBfv_OZFaKa-T_ED84lc8,57
33
+ kicad_sch_api-0.3.4.dist-info/top_level.txt,sha256=n0ex4gOJ1b_fARowcGqRzyOGZcHRhc5LZa6_vVgGxcI,14
34
+ kicad_sch_api-0.3.4.dist-info/RECORD,,