Source code for kicad_sch_api.core.geometry

"""
Geometry utilities for KiCAD schematic manipulation.

Provides coordinate transformation, pin positioning, and geometric calculations
migrated from circuit-synth for improved maintainability.
"""

import logging
import math
from typing import Optional, Tuple, Union

from .types import Point

logger = logging.getLogger(__name__)


[docs] def snap_to_grid(position: Tuple[float, float], grid_size: float = 2.54) -> Tuple[float, float]: """ Snap a position to the nearest grid point. Args: position: (x, y) coordinate grid_size: Grid size in mm (default 2.54mm = 0.1 inch) Returns: Grid-aligned (x, y) coordinate """ x, y = position aligned_x = round(x / grid_size) * grid_size aligned_y = round(y / grid_size) * grid_size return (aligned_x, aligned_y)
[docs] def points_equal(p1: Point, p2: Point, tolerance: float = 0.01) -> bool: """ Check if two points are equal within tolerance. Args: p1: First point p2: Second point tolerance: Distance tolerance Returns: True if points are equal within tolerance """ return abs(p1.x - p2.x) < tolerance and abs(p1.y - p2.y) < tolerance
[docs] def distance_between_points(p1: Tuple[float, float], p2: Tuple[float, float]) -> float: """ Calculate distance between two points. Args: p1: First point (x, y) p2: Second point (x, y) Returns: Distance between points """ return math.sqrt((p2[0] - p1[0]) ** 2 + (p2[1] - p1[1]) ** 2)
[docs] def apply_transformation( point: Tuple[float, float], origin: Point, rotation: float, mirror: Optional[str] = None, ) -> Tuple[float, float]: """ Apply rotation and mirroring transformation to a point. Migrated from circuit-synth for accurate pin position calculation. CRITICAL: Symbol coordinates use normal Y-axis (+Y is up), but schematic coordinates use inverted Y-axis (+Y is down). We must negate Y from symbol space before applying transformations. Args: point: Point to transform (x, y) relative to origin in SYMBOL space origin: Component origin point in SCHEMATIC space rotation: Rotation in degrees (0, 90, 180, 270) mirror: Mirror axis ("x" or "y" or None) Returns: Transformed absolute position (x, y) in SCHEMATIC space """ x, y = point logger.debug(f"Transforming point ({x}, {y}) with rotation={rotation}°, mirror={mirror}") # CRITICAL: Negate Y to convert from symbol space (normal Y) to schematic space (inverted Y) # This must happen BEFORE rotation/mirroring y = -y logger.debug(f"After Y-axis inversion (symbol→schematic): ({x}, {y})") # Apply mirroring if mirror == "x": x = -x logger.debug(f"After X mirror: ({x}, {y})") elif mirror == "y": y = -y logger.debug(f"After Y mirror: ({x}, {y})") # Apply rotation if rotation == 90: x, y = -y, x logger.debug(f"After 90° rotation: ({x}, {y})") elif rotation == 180: x, y = -x, -y logger.debug(f"After 180° rotation: ({x}, {y})") elif rotation == 270: x, y = y, -x logger.debug(f"After 270° rotation: ({x}, {y})") # Translate to absolute position final_x = origin.x + x final_y = origin.y + y logger.debug(f"Final absolute position: ({final_x}, {final_y})") return (final_x, final_y)
[docs] def calculate_position_for_pin( pin_local_position: Union[Point, Tuple[float, float]], desired_pin_position: Union[Point, Tuple[float, float]], rotation: float = 0.0, mirror: Optional[str] = None, grid_size: float = 1.27, ) -> Point: """ Calculate component position needed to place a specific pin at a desired location. This is the inverse of get_pin_position() - given where you want a pin to be, it calculates where the component center needs to be placed. Useful for aligning components by their pins rather than their centers, which is essential for clean horizontal signal flows without unnecessary wire jogs. Args: pin_local_position: Pin position in symbol space (from symbol definition) desired_pin_position: Where you want the pin to be in schematic space rotation: Component rotation in degrees (0, 90, 180, 270) mirror: Mirror axis ("x" or "y" or None) - currently unused grid_size: Grid size for snapping result (default 1.27mm = 50mil) Returns: Component position that will place the pin at desired_pin_position Example: >>> # Place resistor so pin 2 is at (150, 100) >>> pin_pos = Point(0, -3.81) # Pin 2 local position from symbol >>> comp_pos = calculate_position_for_pin(pin_pos, (150, 100)) >>> # Now add component at comp_pos, and pin 2 will be at (150, 100) Note: The result is automatically snapped to the KiCAD grid for proper connectivity. This function matches the behavior of SchematicSymbol.get_pin_position(). """ # Convert inputs to proper types if isinstance(pin_local_position, Point): pin_x, pin_y = pin_local_position.x, pin_local_position.y else: pin_x, pin_y = pin_local_position if isinstance(desired_pin_position, Point): target_x, target_y = desired_pin_position.x, desired_pin_position.y else: target_x, target_y = desired_pin_position logger.debug( f"Calculating component position for pin at local ({pin_x}, {pin_y}) " f"to reach target ({target_x}, {target_y}) with rotation={rotation}Β°" ) # Apply the same transformation that get_pin_position() uses # This is a standard 2D rotation matrix (NO Y-axis inversion) angle_rad = math.radians(rotation) cos_a = math.cos(angle_rad) sin_a = math.sin(angle_rad) # Calculate rotated offset (same as get_pin_position) rotated_x = pin_x * cos_a - pin_y * sin_a rotated_y = pin_x * sin_a + pin_y * cos_a logger.debug(f"Pin offset after rotation: ({rotated_x:.3f}, {rotated_y:.3f})") # Calculate component origin # Since: target = component + rotated_offset # Therefore: component = target - rotated_offset component_x = target_x - rotated_x component_y = target_y - rotated_y logger.debug( f"Calculated component position (before grid snap): ({component_x:.3f}, {component_y:.3f})" ) # Snap to grid for proper KiCAD connectivity snapped_x, snapped_y = snap_to_grid((component_x, component_y), grid_size=grid_size) logger.debug(f"Final component position (after grid snap): ({snapped_x:.3f}, {snapped_y:.3f})") return Point(snapped_x, snapped_y)