Source code for kicad_sch_api.geometry.symbol_bbox

"""
symbol_bbox.py

Calculate accurate bounding boxes for KiCad symbols based on their graphical elements.
This ensures proper spacing and collision detection in schematic layouts.

Migrated from circuit-synth to kicad-sch-api for better architectural separation.
"""

import logging
import math
import os
from typing import Any, Dict, List, Optional, Tuple

from .font_metrics import (
    DEFAULT_PIN_LENGTH,
    DEFAULT_PIN_NAME_OFFSET,
    DEFAULT_PIN_NUMBER_SIZE,
    DEFAULT_PIN_TEXT_WIDTH_RATIO,
    DEFAULT_TEXT_HEIGHT,
)

logger = logging.getLogger(__name__)


[docs] class SymbolBoundingBoxCalculator: """Calculate the actual bounding box of a symbol from its graphical elements.""" # Re-export font metrics as class constants for backward compatibility DEFAULT_TEXT_HEIGHT = DEFAULT_TEXT_HEIGHT DEFAULT_PIN_LENGTH = DEFAULT_PIN_LENGTH DEFAULT_PIN_NAME_OFFSET = DEFAULT_PIN_NAME_OFFSET DEFAULT_PIN_NUMBER_SIZE = DEFAULT_PIN_NUMBER_SIZE DEFAULT_PIN_TEXT_WIDTH_RATIO = DEFAULT_PIN_TEXT_WIDTH_RATIO
[docs] @classmethod def calculate_bounding_box( cls, symbol_data: Dict[str, Any], include_properties: bool = True, hierarchical_labels: Optional[List[Dict[str, Any]]] = None, pin_net_map: Optional[Dict[str, str]] = None, ) -> Tuple[float, float, float, float]: """ Calculate the actual bounding box of a symbol from its graphical elements. Args: symbol_data: Dictionary containing symbol definition from KiCad library include_properties: Whether to include space for Reference/Value labels hierarchical_labels: List of hierarchical labels attached to this symbol pin_net_map: Optional mapping of pin numbers to net names (for accurate label sizing) Returns: Tuple of (min_x, min_y, max_x, max_y) in mm Raises: ValueError: If symbol data is invalid or bounding box cannot be calculated """ if not symbol_data: raise ValueError("Symbol data is None or empty") # Use proper logging instead of print statements logger.debug("=== CALCULATING BOUNDING BOX ===") logger.debug(f"include_properties={include_properties}") min_x = float("inf") min_y = float("inf") max_x = float("-inf") max_y = float("-inf") # Process main symbol shapes (handle both 'shapes' and 'graphics' keys) shapes = symbol_data.get("shapes", []) or symbol_data.get("graphics", []) logger.debug(f"Processing {len(shapes)} main shapes") for shape in shapes: shape_bounds = cls._get_shape_bounds(shape) if shape_bounds: s_min_x, s_min_y, s_max_x, s_max_y = shape_bounds min_x = min(min_x, s_min_x) min_y = min(min_y, s_min_y) max_x = max(max_x, s_max_x) max_y = max(max_y, s_max_y) # Process pins (including their labels) pins = symbol_data.get("pins", []) logger.debug(f"Processing {len(pins)} main pins") for pin in pins: pin_bounds = cls._get_pin_bounds(pin, pin_net_map) if pin_bounds: p_min_x, p_min_y, p_max_x, p_max_y = pin_bounds min_x = min(min_x, p_min_x) min_y = min(min_y, p_min_y) max_x = max(max_x, p_max_x) max_y = max(max_y, p_max_y) # Process sub-symbols sub_symbols = symbol_data.get("sub_symbols", []) for sub in sub_symbols: # Sub-symbols can have their own shapes and pins (handle both 'shapes' and 'graphics' keys) sub_shapes = sub.get("shapes", []) or sub.get("graphics", []) for shape in sub_shapes: shape_bounds = cls._get_shape_bounds(shape) if shape_bounds: s_min_x, s_min_y, s_max_x, s_max_y = shape_bounds min_x = min(min_x, s_min_x) min_y = min(min_y, s_min_y) max_x = max(max_x, s_max_x) max_y = max(max_y, s_max_y) sub_pins = sub.get("pins", []) for pin in sub_pins: pin_bounds = cls._get_pin_bounds(pin, pin_net_map) if pin_bounds: p_min_x, p_min_y, p_max_x, p_max_y = pin_bounds min_x = min(min_x, p_min_x) min_y = min(min_y, p_min_y) max_x = max(max_x, p_max_x) max_y = max(max_y, p_max_y) # Check if we found any geometry if min_x == float("inf") or max_x == float("-inf"): raise ValueError(f"No valid geometry found in symbol data") logger.debug( f"After geometry processing: ({min_x:.2f}, {min_y:.2f}) to ({max_x:.2f}, {max_y:.2f})" ) logger.debug(f" Width: {max_x - min_x:.2f}, Height: {max_y - min_y:.2f}") # Add small margin for text that might extend beyond shapes margin = 0.254 # 10 mils min_x -= margin min_y -= margin max_x += margin max_y += margin # Include space for component properties (Reference, Value, Footprint) if include_properties: # Use adaptive spacing based on component dimensions component_width = max_x - min_x component_height = max_y - min_y # Adaptive property width: minimum 10mm or 80% of component width property_width = max(10.0, component_width * 0.8) property_height = cls.DEFAULT_TEXT_HEIGHT # Adaptive vertical spacing: minimum 5mm or 10% of component height vertical_spacing_above = max(5.0, component_height * 0.1) vertical_spacing_below = max(10.0, component_height * 0.15) # Reference label above min_y -= vertical_spacing_above + property_height # Value and Footprint labels below max_y += vertical_spacing_below + property_height # Extend horizontally for property text center_x = (min_x + max_x) / 2 min_x = min(min_x, center_x - property_width / 2) max_x = max(max_x, center_x + property_width / 2) logger.debug( f"Calculated bounding box: ({min_x:.2f}, {min_y:.2f}) to ({max_x:.2f}, {max_y:.2f})" ) logger.debug(f"FINAL BBOX: ({min_x:.2f}, {min_y:.2f}) to ({max_x:.2f}, {max_y:.2f})") logger.debug(f" Width: {max_x - min_x:.2f}, Height: {max_y - min_y:.2f}") logger.debug("=" * 50) return (min_x, min_y, max_x, max_y)
[docs] @classmethod def calculate_placement_bounding_box( cls, symbol_data: Dict[str, Any], ) -> Tuple[float, float, float, float]: """ Calculate bounding box for PLACEMENT purposes - excludes pin labels. This method calculates a tighter bounding box that only includes: - Component body (shapes/graphics) - Pin endpoints (without label text) - Small margin for component properties Pin label text is excluded because it extends arbitrarily far based on text length and would cause incorrect spacing in text-flow placement. Args: symbol_data: Dictionary containing symbol definition from KiCad library Returns: Tuple of (min_x, min_y, max_x, max_y) in mm Raises: ValueError: If symbol data is invalid or bounding box cannot be calculated """ if not symbol_data: raise ValueError("Symbol data is None or empty") logger.debug("=== CALCULATING PLACEMENT BOUNDING BOX (NO PIN LABELS) ===") min_x = float("inf") min_y = float("inf") max_x = float("-inf") max_y = float("-inf") # Process main symbol shapes (handle both 'shapes' and 'graphics' keys) shapes = symbol_data.get("shapes", []) or symbol_data.get("graphics", []) logger.debug(f"Processing {len(shapes)} main shapes") for shape in shapes: shape_bounds = cls._get_shape_bounds(shape) if shape_bounds: s_min_x, s_min_y, s_max_x, s_max_y = shape_bounds min_x = min(min_x, s_min_x) min_y = min(min_y, s_min_y) max_x = max(max_x, s_max_x) max_y = max(max_y, s_max_y) # Process pins WITHOUT labels (just pin endpoints) pins = symbol_data.get("pins", []) logger.debug(f"Processing {len(pins)} main pins (NO LABELS)") for pin in pins: pin_bounds = cls._get_pin_bounds_no_labels(pin) if pin_bounds: p_min_x, p_min_y, p_max_x, p_max_y = pin_bounds min_x = min(min_x, p_min_x) min_y = min(min_y, p_min_y) max_x = max(max_x, p_max_x) max_y = max(max_y, p_max_y) # Process sub-symbols sub_symbols = symbol_data.get("sub_symbols", []) for sub in sub_symbols: # Sub-symbols can have their own shapes and pins sub_shapes = sub.get("shapes", []) or sub.get("graphics", []) for shape in sub_shapes: shape_bounds = cls._get_shape_bounds(shape) if shape_bounds: s_min_x, s_min_y, s_max_x, s_max_y = shape_bounds min_x = min(min_x, s_min_x) min_y = min(min_y, s_min_y) max_x = max(max_x, s_max_x) max_y = max(max_y, s_max_y) sub_pins = sub.get("pins", []) for pin in sub_pins: pin_bounds = cls._get_pin_bounds_no_labels(pin) if pin_bounds: p_min_x, p_min_y, p_max_x, p_max_y = pin_bounds min_x = min(min_x, p_min_x) min_y = min(min_y, p_min_y) max_x = max(max_x, p_max_x) max_y = max(max_y, p_max_y) # Check if we found any geometry if min_x == float("inf") or max_x == float("-inf"): raise ValueError(f"No valid geometry found in symbol data") logger.debug( f"After geometry processing: ({min_x:.2f}, {min_y:.2f}) to ({max_x:.2f}, {max_y:.2f})" ) logger.debug(f" Width: {max_x - min_x:.2f}, Height: {max_y - min_y:.2f}") # Add small margin for visual spacing margin = 0.635 # 25mil margin (reduced from 50mil) min_x -= margin min_y -= margin max_x += margin max_y += margin # Add minimal space for component properties (Reference above, Value below) # Use adaptive spacing based on component height for better visual hierarchy component_height = max_y - min_y property_spacing = max( 3.0, component_height * 0.15 ) # Adaptive: minimum 3mm or 15% of height property_height = 1.27 # Reduced from 2.54mm min_y -= property_spacing + property_height # Reference above max_y += property_spacing + property_height # Value below logger.debug( f"FINAL PLACEMENT BBOX: ({min_x:.2f}, {min_y:.2f}) to ({max_x:.2f}, {max_y:.2f})" ) logger.debug(f" Width: {max_x - min_x:.2f}, Height: {max_y - min_y:.2f}") logger.debug("=" * 50) return (min_x, min_y, max_x, max_y)
[docs] @classmethod def calculate_visual_bounding_box( cls, symbol_data: Dict[str, Any], pin_net_map: Optional[Dict[str, str]] = None ) -> Tuple[float, float, float, float]: """ Calculate bounding box for visual/debug drawing (includes pin labels, no property spacing). This shows the actual component geometry including pin labels. Use this for drawing bounding boxes on schematics. Args: symbol_data: Dictionary containing symbol definition pin_net_map: Optional mapping of pin numbers to net names (for accurate label sizing) Returns: Tuple of (min_x, min_y, max_x, max_y) in mm """ # Initialize bounds min_x = float("inf") min_y = float("inf") max_x = float("-inf") max_y = float("-inf") # Process main symbol shapes shapes = symbol_data.get("shapes", []) or symbol_data.get("graphics", []) for shape in shapes: shape_bounds = cls._get_shape_bounds(shape) if shape_bounds: s_min_x, s_min_y, s_max_x, s_max_y = shape_bounds min_x = min(min_x, s_min_x) min_y = min(min_y, s_min_y) max_x = max(max_x, s_max_x) max_y = max(max_y, s_max_y) # Process pins WITH labels to get accurate visual bounds pins = symbol_data.get("pins", []) for pin in pins: pin_bounds = cls._get_pin_bounds(pin, pin_net_map) if pin_bounds: p_min_x, p_min_y, p_max_x, p_max_y = pin_bounds min_x = min(min_x, p_min_x) min_y = min(min_y, p_min_y) max_x = max(max_x, p_max_x) max_y = max(max_y, p_max_y) # Process sub-symbols sub_symbols = symbol_data.get("sub_symbols", []) for sub in sub_symbols: sub_shapes = sub.get("shapes", []) or sub.get("graphics", []) for shape in sub_shapes: shape_bounds = cls._get_shape_bounds(shape) if shape_bounds: s_min_x, s_min_y, s_max_x, s_max_y = shape_bounds min_x = min(min_x, s_min_x) min_y = min(min_y, s_min_y) max_x = max(max_x, s_max_x) max_y = max(max_y, s_max_y) sub_pins = sub.get("pins", []) for pin in sub_pins: pin_bounds = cls._get_pin_bounds(pin, pin_net_map) if pin_bounds: p_min_x, p_min_y, p_max_x, p_max_y = pin_bounds min_x = min(min_x, p_min_x) min_y = min(min_y, p_min_y) max_x = max(max_x, p_max_x) max_y = max(max_y, p_max_y) # Check if we found any geometry if min_x == float("inf") or max_x == float("-inf"): raise ValueError(f"No valid geometry found in symbol data") # Add only a tiny margin for visibility (no property spacing) margin = 0.254 # 10mil minimal margin min_x -= margin min_y -= margin max_x += margin max_y += margin return (min_x, min_y, max_x, max_y)
[docs] @classmethod def get_symbol_dimensions( cls, symbol_data: Dict[str, Any], include_properties: bool = True, pin_net_map: Optional[Dict[str, str]] = None, ) -> Tuple[float, float]: """ Get the width and height of a symbol. Args: symbol_data: Dictionary containing symbol definition include_properties: Whether to include space for Reference/Value labels pin_net_map: Optional mapping of pin numbers to net names Returns: Tuple of (width, height) in mm """ min_x, min_y, max_x, max_y = cls.calculate_bounding_box( symbol_data, include_properties, pin_net_map=pin_net_map ) width = max_x - min_x height = max_y - min_y return (width, height)
@classmethod def _get_shape_bounds( cls, shape: Dict[str, Any] ) -> Optional[Tuple[float, float, float, float]]: """Get bounding box for a graphical shape.""" shape_type = shape.get("shape_type", "") if shape_type == "rectangle": start = shape.get("start", [0, 0]) end = shape.get("end", [0, 0]) return ( min(start[0], end[0]), min(start[1], end[1]), max(start[0], end[0]), max(start[1], end[1]), ) elif shape_type == "circle": center = shape.get("center", [0, 0]) radius = shape.get("radius", 0) return ( center[0] - radius, center[1] - radius, center[0] + radius, center[1] + radius, ) elif shape_type == "arc": # For arcs, we need to consider start, mid, and end points start = shape.get("start", [0, 0]) mid = shape.get("mid", [0, 0]) end = shape.get("end", [0, 0]) # Simple approach: use bounding box of all three points # More accurate would be to calculate the actual arc bounds min_x = min(start[0], mid[0], end[0]) min_y = min(start[1], mid[1], end[1]) max_x = max(start[0], mid[0], end[0]) max_y = max(start[1], mid[1], end[1]) return (min_x, min_y, max_x, max_y) elif shape_type == "polyline": points = shape.get("points", []) if not points: return None min_x = min(p[0] for p in points) min_y = min(p[1] for p in points) max_x = max(p[0] for p in points) max_y = max(p[1] for p in points) return (min_x, min_y, max_x, max_y) elif shape_type == "text": # Text bounding box estimation at = shape.get("at", [0, 0]) text = shape.get("text", "") # Rough estimation: each character is about 1.27mm wide text_width = len(text) * cls.DEFAULT_TEXT_HEIGHT * 0.6 text_height = cls.DEFAULT_TEXT_HEIGHT return ( at[0] - text_width / 2, at[1] - text_height / 2, at[0] + text_width / 2, at[1] + text_height / 2, ) return None @classmethod def _get_pin_bounds( cls, pin: Dict[str, Any], pin_net_map: Optional[Dict[str, str]] = None ) -> Optional[Tuple[float, float, float, float]]: """Get bounding box for a pin including its labels.""" # Handle both formats: 'at' array or separate x/y/orientation if "at" in pin: at = pin.get("at", [0, 0]) x, y = at[0], at[1] angle = at[2] if len(at) > 2 else 0 else: # Handle the format from symbol cache x = pin.get("x", 0) y = pin.get("y", 0) angle = pin.get("orientation", 0) length = pin.get("length", cls.DEFAULT_PIN_LENGTH) # Calculate pin endpoint based on angle angle_rad = math.radians(angle) end_x = x + length * math.cos(angle_rad) end_y = y + length * math.sin(angle_rad) # Start with pin line bounds min_x = min(x, end_x) min_y = min(y, end_y) max_x = max(x, end_x) max_y = max(y, end_y) # Add space for pin name and number pin_name = pin.get("name", "") pin_number = pin.get("number", "") # Use net name for label sizing if available (hierarchical labels show net names, not pin names) # If no net name match, use minimal fallback to avoid oversized bounding boxes if pin_net_map and pin_number in pin_net_map: label_text = pin_net_map[pin_number] logger.debug( f" PIN {pin_number}: βœ… USING NET '{label_text}' (len={len(label_text)}), at=({x:.2f}, {y:.2f}), angle={angle}" ) else: # No net match - use minimal size (3 chars) instead of potentially long pin name label_text = "XXX" # 3-character placeholder for unmatched pins logger.debug( f" PIN {pin_number}: ⚠️ NO MATCH, using minimal fallback (pin name was '{pin_name}'), at=({x:.2f}, {y:.2f})" ) if label_text and label_text != "~": # ~ means no name # Calculate text dimensions # For horizontal text: width = char_count * char_width name_width = ( len(label_text) * cls.DEFAULT_TEXT_HEIGHT * cls.DEFAULT_PIN_TEXT_WIDTH_RATIO ) # For vertical text: height = char_count * char_height (characters stack vertically) name_height = len(label_text) * cls.DEFAULT_TEXT_HEIGHT logger.debug( f" label_width={name_width:.2f}, label_height={name_height:.2f} (len={len(label_text)})" ) # Adjust bounds based on pin orientation # Labels are placed at PIN ENDPOINT with offset, extending AWAY from the component # Pin angle indicates where the pin points (into component) # Apply KiCad's standard pin name offset (0.508mm / 20 mils) offset = cls.DEFAULT_PIN_NAME_OFFSET if angle == 0: # Pin points right - label extends LEFT from endpoint label_x = end_x - offset - name_width logger.debug( f" Angle 0 (Right pin): min_x {min_x:.2f} -> {label_x:.2f} (offset={offset:.3f})" ) min_x = min(min_x, label_x) elif angle == 180: # Pin points left - label extends RIGHT from endpoint label_x = end_x + offset + name_width logger.debug( f" Angle 180 (Left pin): max_x {max_x:.2f} -> {label_x:.2f} (offset={offset:.3f})" ) max_x = max(max_x, label_x) elif angle == 90: # Pin points up - label extends DOWN from endpoint label_y = end_y - offset - name_height logger.debug( f" Angle 90 (Up pin): min_y {min_y:.2f} -> {label_y:.2f} (offset={offset:.3f})" ) min_y = min(min_y, label_y) elif angle == 270: # Pin points down - label extends UP from endpoint label_y = end_y + offset + name_height logger.debug( f" Angle 270 (Down pin): max_y {max_y:.2f} -> {label_y:.2f} (offset={offset:.3f})" ) max_y = max(max_y, label_y) # Pin numbers are typically placed near the component body if pin_number: num_width = ( len(pin_number) * cls.DEFAULT_PIN_NUMBER_SIZE * cls.DEFAULT_PIN_TEXT_WIDTH_RATIO ) # Add some space for the pin number margin = cls.DEFAULT_PIN_NUMBER_SIZE * 1.5 # Increase margin for better spacing min_x -= margin min_y -= margin max_x += margin max_y += margin logger.debug(f" Pin bounds: ({min_x:.2f}, {min_y:.2f}) to ({max_x:.2f}, {max_y:.2f})") return (min_x, min_y, max_x, max_y) @classmethod def _get_pin_bounds_no_labels( cls, pin: Dict[str, Any] ) -> Optional[Tuple[float, float, float, float]]: """Get bounding box for a pin WITHOUT labels - for placement calculations only.""" # Handle both formats: 'at' array or separate x/y/orientation if "at" in pin: at = pin.get("at", [0, 0]) x, y = at[0], at[1] angle = at[2] if len(at) > 2 else 0 else: # Handle the format from symbol cache x = pin.get("x", 0) y = pin.get("y", 0) angle = pin.get("orientation", 0) length = pin.get("length", cls.DEFAULT_PIN_LENGTH) # Calculate pin endpoint based on angle angle_rad = math.radians(angle) end_x = x + length * math.cos(angle_rad) end_y = y + length * math.sin(angle_rad) # Only include the pin line itself - NO labels min_x = min(x, end_x) min_y = min(y, end_y) max_x = max(x, end_x) max_y = max(y, end_y) # Add small margin for pin graphics (circles, etc) margin = 0.5 # Small margin for pin endpoint graphics min_x -= margin min_y -= margin max_x += margin max_y += margin return (min_x, min_y, max_x, max_y)