"""
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)