Source code for yapcad.assembly.constraint

"""Assembly constraint validation for yapCAD.

This module provides high-level design constraints that validate assembly intent
after mates have positioned parts. Constraints check that the resulting assembly
meets design requirements like tangency, radial orientation, and clearances.

Constraints differ from mates:
- Mates POSITION parts (determine transforms)
- Constraints VALIDATE the result (check design intent)

Example:
    from yapcad.assembly.constraint import (
        Constraint, ConstraintType, ConstraintResult
    )

    # Motor axis must be tangent to chassis (not radial)
    constraint = Constraint(
        name="wheel_axis_tangent",
        constraint_type=ConstraintType.TANGENT_TO_CIRCLE,
        part="DDSM115_MOTOR_1",
        datum="motor_axis",
        center=(0.0, 0.0, 0.0),
        radius=124.5,
        description="Motor axis tangent to wheel path for rolling motion"
    )

    # Evaluate against a datum in world coordinates
    result = constraint.evaluate(datum_world)
    if not result.passed:
        print(f"Constraint violated: {result.error_message}")
        print(f"Error: {result.error_value:.2f}°")
"""

from dataclasses import dataclass, field
from typing import Optional, Tuple, Callable, Any, Dict, TYPE_CHECKING
from enum import Enum
import math

if TYPE_CHECKING:
    from .datum import Datum, DatumType

# Import numpy with fallback
try:
    import numpy as np
    HAS_NUMPY = True
except ImportError:
    HAS_NUMPY = False
    # Provide minimal fallback for type hints
    np = None


[docs] class ConstraintType(Enum): """High-level design constraints that validate assembly intent. These constraint types capture common design requirements in mechanical assemblies that are difficult to express with low-level mates alone. Attributes: TANGENT_TO_CIRCLE: Axis is tangent (perpendicular to radial) to a circle. Used for wheels that must roll along a circular path. RADIAL_FROM_CENTER: Datum normal points toward (inward) or away from (outward) a center point. Used for features that must face the center or periphery of an assembly. FACING: Datum normal points in a specified global direction (+x, -y, +z, etc.). AT_RADIUS: Datum origin is at a specific radius from a center point. PARALLEL_TO: Datum direction is parallel to a reference direction. PERPENDICULAR_TO: Datum direction is perpendicular to a reference direction. """ # Directional constraints FACING = "facing" TANGENT_TO_CIRCLE = "tangent_to_circle" RADIAL_FROM_CENTER = "radial_from_center" PARALLEL_TO = "parallel_to" PERPENDICULAR_TO = "perpendicular_to" # Positional constraints AT_RADIUS = "at_radius" ON_CIRCLE = "on_circle" IN_PLANE = "in_plane" # Clearance constraints MIN_DISTANCE = "min_distance" NO_INTERFERENCE = "no_interference" # Custom constraints CUSTOM = "custom"
[docs] @dataclass class ConstraintResult: """Result of evaluating a design constraint. Attributes: passed: True if constraint is satisfied within tolerance error_value: Numeric measure of constraint violation (degrees or mm) error_message: Human-readable description of the result details: Additional information about the evaluation """ passed: bool error_value: float = 0.0 error_message: str = "" details: Dict[str, Any] = field(default_factory=dict) def __str__(self) -> str: """Format result as a readable string.""" status = "PASS" if self.passed else "FAIL" if self.error_value > 0: return f"[{status}] {self.error_message} (error: {self.error_value:.3f})" return f"[{status}] {self.error_message}"
[docs] @dataclass class Constraint: """A design constraint that validates assembly intent. Constraints are evaluated after mates have positioned all parts. They verify that the resulting assembly meets design requirements. Unlike mates (which determine part positions), constraints validate that the positions satisfy high-level design intent. Attributes: name: Unique identifier for this constraint constraint_type: Type of constraint to evaluate part: Name of the part containing the datum to check datum: Name of the datum feature to evaluate center: Reference center point for circular/radial constraints radius: Reference radius for circular constraints (mm) direction: Reference direction ("inward", "outward", "+x", "-z", etc.) axis: Reference axis vector for parallel/perpendicular constraints reference_datum: Reference datum for relative constraints plane_normal: Normal vector for plane constraints min_value: Minimum allowed value for range constraints max_value: Maximum allowed value for range constraints validator: Custom validation function for CUSTOM constraint type tolerance_deg: Angular tolerance in degrees (default: 1.0°) tolerance_mm: Linear tolerance in millimeters (default: 0.1mm) description: Human-readable description of design intent severity: "error", "warning", or "info" Examples: # Motor axis tangent to wheel path (perpendicular to radial) >>> tangent = Constraint( ... name="wheel_axis_tangent", ... constraint_type=ConstraintType.TANGENT_TO_CIRCLE, ... part="DDSM115_MOTOR_1", ... datum="motor_axis", ... center=(0.0, 0.0, 0.0), ... radius=124.5, ... tolerance_deg=2.0, ... description="Motor axis must be tangent for rolling motion" ... ) # Tire tread must face outward toward tube wall >>> radial = Constraint( ... name="tire_faces_outward", ... constraint_type=ConstraintType.RADIAL_FROM_CENTER, ... part="DDSM115_MOTOR_1", ... datum="tire_face", ... center=(0.0, 0.0, 0.0), ... direction="outward", ... tolerance_deg=5.0, ... description="Tire tread faces tube wall for traction" ... ) # Mounting face must face upward >>> facing = Constraint( ... name="mount_faces_up", ... constraint_type=ConstraintType.FACING, ... part="BRACKET", ... datum="mounting_face", ... direction="+z", ... tolerance_deg=1.0, ... description="Mounting face must be horizontal" ... ) # Part at specific radius from center >>> at_radius = Constraint( ... name="motor_at_wheel_radius", ... constraint_type=ConstraintType.AT_RADIUS, ... part="MOTOR", ... datum="motor_center", ... center=(0.0, 0.0, 0.0), ... radius=124.5, ... tolerance_mm=0.5, ... description="Motor positioned on wheel path circle" ... ) """ name: str constraint_type: ConstraintType # Primary datum reference part: str datum: str # Constraint-specific parameters center: Optional[Tuple[float, float, float]] = None radius: Optional[float] = None direction: Optional[str] = None axis: Optional[Tuple[float, float, float]] = None reference_datum: Optional[str] = None plane_normal: Optional[Tuple[float, float, float]] = None min_value: Optional[float] = None max_value: Optional[float] = None # For CUSTOM constraints validator: Optional[Callable[[Any], bool]] = None # Tolerances tolerance_deg: float = 1.0 tolerance_mm: float = 0.1 # Metadata description: str = "" severity: str = "error"
[docs] def evaluate(self, datum_world: 'Datum') -> ConstraintResult: """Evaluate this constraint against a datum in world coordinates. Args: datum_world: The datum feature transformed to world coordinates Returns: ConstraintResult indicating whether constraint is satisfied Raises: ValueError: If required parameters are missing ImportError: If numpy is required but not available """ if not HAS_NUMPY: return ConstraintResult( passed=False, error_message="NumPy required for constraint evaluation" ) if self.constraint_type == ConstraintType.TANGENT_TO_CIRCLE: return self._check_tangent_to_circle(datum_world) elif self.constraint_type == ConstraintType.RADIAL_FROM_CENTER: return self._check_radial(datum_world) elif self.constraint_type == ConstraintType.FACING: return self._check_facing(datum_world) elif self.constraint_type == ConstraintType.AT_RADIUS: return self._check_at_radius(datum_world) elif self.constraint_type == ConstraintType.PARALLEL_TO: return self._check_parallel(datum_world) elif self.constraint_type == ConstraintType.PERPENDICULAR_TO: return self._check_perpendicular(datum_world) elif self.constraint_type == ConstraintType.CUSTOM: if self.validator: try: passed = self.validator(datum_world) return ConstraintResult( passed=passed, error_message=f"Custom constraint '{self.name}'" ) except Exception as e: return ConstraintResult( passed=False, error_message=f"Custom validator error: {e}" ) else: return ConstraintResult( passed=False, error_message="CUSTOM constraint requires validator function" ) return ConstraintResult( passed=False, error_message=f"Unknown constraint type: {self.constraint_type}" )
def _check_tangent_to_circle(self, datum: 'Datum') -> ConstraintResult: """Check if an axis is tangent to a circle at a given radius. An axis is tangent when it is perpendicular to the radial direction from the center to the datum origin. This is used for wheels that must roll along a circular path rather than pointing radially inward/outward. Args: datum: Datum in world coordinates (must have direction or normal) Returns: ConstraintResult with angular error from tangency """ if self.center is None or self.radius is None: return ConstraintResult( passed=False, error_message="TANGENT_TO_CIRCLE requires center and radius" ) center = np.array(self.center) origin = np.array(datum.origin[:3]) # Vector from center to datum origin (project to XY plane for Z-axis circle) radial = origin - center radial[2] = 0.0 # Project to XY plane radial_norm = np.linalg.norm(radial) if radial_norm < 1e-10: return ConstraintResult( passed=False, error_message="Datum at center - cannot determine tangent direction" ) radial_unit = radial / radial_norm # Get the direction to check if hasattr(datum, 'direction') and datum.direction is not None: check_dir = np.array(datum.direction[:3]) elif hasattr(datum, 'normal') and datum.normal is not None: check_dir = np.array(datum.normal[:3]) else: return ConstraintResult( passed=False, error_message="Datum has no direction or normal vector" ) # Project direction to XY plane check_dir_xy = check_dir.copy() check_dir_xy[2] = 0.0 dir_norm = np.linalg.norm(check_dir_xy) if dir_norm < 1e-10: # Direction is purely vertical - perpendicular to XY, so tangent to any XY circle return ConstraintResult( passed=True, error_value=0.0, error_message="Direction is vertical (tangent to horizontal circle)", details={"radial_direction": radial_unit.tolist()} ) check_dir_unit = check_dir_xy / dir_norm # Tangent means perpendicular to radial (dot product = 0) dot = abs(np.dot(radial_unit, check_dir_unit)) angle_from_perpendicular = math.degrees(math.asin(min(1.0, dot))) passed = angle_from_perpendicular <= self.tolerance_deg return ConstraintResult( passed=passed, error_value=angle_from_perpendicular, error_message=f"Tangent angle error: {angle_from_perpendicular:.2f}° " f"(tolerance: {self.tolerance_deg}°)", details={ "radial_direction": radial_unit.tolist(), "axis_direction": check_dir_unit.tolist(), "dot_product": float(dot) } ) def _check_radial(self, datum: 'Datum') -> ConstraintResult: """Check if a datum normal faces radially inward or outward from center. This validates that a feature (like a tire tread or mounting face) points toward or away from a center point in the XY plane. Args: datum: Datum in world coordinates (must have normal or direction) Returns: ConstraintResult with angular error from radial alignment """ if self.center is None: return ConstraintResult( passed=False, error_message="RADIAL_FROM_CENTER requires center parameter" ) if self.direction not in ("inward", "outward"): return ConstraintResult( passed=False, error_message=f"direction must be 'inward' or 'outward', got '{self.direction}'" ) center = np.array(self.center) origin = np.array(datum.origin[:3]) # Radial direction (outward from center) radial = origin - center radial[2] = 0.0 # Project to XY plane for cylindrical radial radial_norm = np.linalg.norm(radial) if radial_norm < 1e-10: return ConstraintResult( passed=False, error_message="Datum at center - cannot determine radial direction" ) radial_unit = radial / radial_norm # Get the direction to check if hasattr(datum, 'normal') and datum.normal is not None: check_dir = np.array(datum.normal[:3]) elif hasattr(datum, 'direction') and datum.direction is not None: check_dir = np.array(datum.direction[:3]) else: return ConstraintResult( passed=False, error_message="Datum has no normal or direction vector" ) check_dir = check_dir / np.linalg.norm(check_dir) # Check alignment dot = np.dot(radial_unit, check_dir) if self.direction == "outward": # Normal should point same direction as radial (dot > 0) cos_tolerance = math.cos(math.radians(self.tolerance_deg)) satisfied = dot > cos_tolerance expected = "outward (+radial)" else: # inward # Normal should point opposite to radial (dot < 0) cos_tolerance = math.cos(math.radians(self.tolerance_deg)) satisfied = dot < -cos_tolerance expected = "inward (-radial)" angle_error = math.degrees(math.acos(min(1.0, abs(dot)))) if not satisfied: # Calculate actual angle from expected direction if self.direction == "outward" and dot < 0: angle_error = 180.0 - angle_error elif self.direction == "inward" and dot > 0: angle_error = 180.0 - angle_error return ConstraintResult( passed=satisfied, error_value=angle_error if not satisfied else 0.0, error_message=f"Radial alignment: dot={dot:.3f}, expected {expected}, " f"angle_error={angle_error:.2f}°", details={ "radial_direction": radial_unit.tolist(), "datum_direction": check_dir.tolist(), "dot_product": float(dot), "expected_direction": self.direction } ) def _check_facing(self, datum: 'Datum') -> ConstraintResult: """Check if a datum normal faces a specified global direction. Validates that a plane or surface normal points in a cardinal direction like +z (up), -y (back), etc. Args: datum: Datum in world coordinates (must have normal) Returns: ConstraintResult with angular error from target direction """ if not hasattr(datum, 'normal') or datum.normal is None: return ConstraintResult( passed=False, error_message="FACING requires datum with normal vector" ) normal = np.array(datum.normal[:3]) normal = normal / np.linalg.norm(normal) # Parse direction specification target = self._parse_direction(self.direction) if target is None: return ConstraintResult( passed=False, error_message=f"Invalid direction: {self.direction}" ) dot = np.dot(normal, target) angle = math.degrees(math.acos(min(1.0, max(-1.0, dot)))) satisfied = angle <= self.tolerance_deg return ConstraintResult( passed=satisfied, error_value=angle, error_message=f"Facing angle error: {angle:.2f}° from {self.direction} " f"(tolerance: {self.tolerance_deg}°)", details={ "datum_normal": normal.tolist(), "target_direction": target.tolist(), "dot_product": float(dot) } ) def _check_at_radius(self, datum: 'Datum') -> ConstraintResult: """Check if a datum origin is at a specified radius from center. Validates radial positioning in the XY plane (cylindrical coordinates). Args: datum: Datum in world coordinates Returns: ConstraintResult with distance error """ if self.center is None or self.radius is None: return ConstraintResult( passed=False, error_message="AT_RADIUS requires center and radius parameters" ) center = np.array(self.center) origin = np.array(datum.origin[:3]) # Distance in XY plane (cylindrical radius) delta = origin - center delta[2] = 0.0 actual_radius = np.linalg.norm(delta) error = abs(actual_radius - self.radius) satisfied = error <= self.tolerance_mm return ConstraintResult( passed=satisfied, error_value=error, error_message=f"Radius: {actual_radius:.3f}mm " f"(expected {self.radius:.3f}mm, error {error:.3f}mm, " f"tolerance: {self.tolerance_mm}mm)", details={ "actual_radius": float(actual_radius), "expected_radius": float(self.radius), "center": self.center } ) def _check_parallel(self, datum: 'Datum') -> ConstraintResult: """Check if a datum direction is parallel to a reference axis. Args: datum: Datum in world coordinates (must have direction or normal) Returns: ConstraintResult with angular error from parallel """ if self.axis is None: return ConstraintResult( passed=False, error_message="PARALLEL_TO requires axis parameter" ) ref_axis = np.array(self.axis[:3]) ref_axis = ref_axis / np.linalg.norm(ref_axis) if hasattr(datum, 'direction') and datum.direction is not None: check_dir = np.array(datum.direction[:3]) elif hasattr(datum, 'normal') and datum.normal is not None: check_dir = np.array(datum.normal[:3]) else: return ConstraintResult( passed=False, error_message="Datum has no direction or normal vector" ) check_dir = check_dir / np.linalg.norm(check_dir) # Parallel means dot product is +1 or -1 dot = abs(np.dot(ref_axis, check_dir)) angle = math.degrees(math.acos(min(1.0, dot))) satisfied = angle <= self.tolerance_deg return ConstraintResult( passed=satisfied, error_value=angle, error_message=f"Parallel angle error: {angle:.2f}° " f"(tolerance: {self.tolerance_deg}°)", details={ "reference_axis": ref_axis.tolist(), "datum_direction": check_dir.tolist(), "dot_product": float(dot) } ) def _check_perpendicular(self, datum: 'Datum') -> ConstraintResult: """Check if a datum direction is perpendicular to a reference axis. Args: datum: Datum in world coordinates (must have direction or normal) Returns: ConstraintResult with angular error from perpendicular """ if self.axis is None: return ConstraintResult( passed=False, error_message="PERPENDICULAR_TO requires axis parameter" ) ref_axis = np.array(self.axis[:3]) ref_axis = ref_axis / np.linalg.norm(ref_axis) if hasattr(datum, 'direction') and datum.direction is not None: check_dir = np.array(datum.direction[:3]) elif hasattr(datum, 'normal') and datum.normal is not None: check_dir = np.array(datum.normal[:3]) else: return ConstraintResult( passed=False, error_message="Datum has no direction or normal vector" ) check_dir = check_dir / np.linalg.norm(check_dir) # Perpendicular means dot product is 0 dot = abs(np.dot(ref_axis, check_dir)) angle_from_perpendicular = math.degrees(math.asin(min(1.0, dot))) satisfied = angle_from_perpendicular <= self.tolerance_deg return ConstraintResult( passed=satisfied, error_value=angle_from_perpendicular, error_message=f"Perpendicular angle error: {angle_from_perpendicular:.2f}° " f"(tolerance: {self.tolerance_deg}°)", details={ "reference_axis": ref_axis.tolist(), "datum_direction": check_dir.tolist(), "dot_product": float(dot) } ) def _parse_direction(self, direction: Optional[str]) -> Optional[np.ndarray]: """Parse a direction string into a unit vector. Args: direction: String like "+x", "-y", "+z", etc. Returns: Unit vector as numpy array, or None if invalid """ if direction is None: return None directions = { "+x": np.array([1.0, 0.0, 0.0]), "-x": np.array([-1.0, 0.0, 0.0]), "+y": np.array([0.0, 1.0, 0.0]), "-y": np.array([0.0, -1.0, 0.0]), "+z": np.array([0.0, 0.0, 1.0]), "-z": np.array([0.0, 0.0, -1.0]), } return directions.get(direction.lower())
# Helper functions for geometric calculations
[docs] def angle_between_vectors(v1: Tuple[float, float, float], v2: Tuple[float, float, float]) -> float: """Calculate angle between two vectors in degrees. Args: v1: First vector (x, y, z) v2: Second vector (x, y, z) Returns: Angle in degrees [0, 180] Example: >>> angle_between_vectors((1, 0, 0), (0, 1, 0)) 90.0 >>> angle_between_vectors((1, 0, 0), (1, 0, 0)) 0.0 """ if not HAS_NUMPY: raise ImportError("NumPy required for angle_between_vectors") a = np.array(v1) b = np.array(v2) a_norm = np.linalg.norm(a) b_norm = np.linalg.norm(b) if a_norm < 1e-10 or b_norm < 1e-10: return 0.0 a_unit = a / a_norm b_unit = b / b_norm dot = np.dot(a_unit, b_unit) # Clamp to [-1, 1] to handle numerical errors dot = max(-1.0, min(1.0, dot)) return math.degrees(math.acos(dot))
[docs] def distance_to_point(p1: Tuple[float, float, float], p2: Tuple[float, float, float]) -> float: """Calculate Euclidean distance between two points. Args: p1: First point (x, y, z) p2: Second point (x, y, z) Returns: Distance in same units as input Example: >>> distance_to_point((0, 0, 0), (3, 4, 0)) 5.0 """ if not HAS_NUMPY: raise ImportError("NumPy required for distance_to_point") a = np.array(p1) b = np.array(p2) return float(np.linalg.norm(b - a))
[docs] def dot_product(v1: Tuple[float, float, float], v2: Tuple[float, float, float]) -> float: """Calculate dot product of two vectors. Args: v1: First vector (x, y, z) v2: Second vector (x, y, z) Returns: Dot product (scalar) Example: >>> dot_product((1, 0, 0), (0, 1, 0)) 0.0 >>> dot_product((1, 2, 3), (1, 2, 3)) 14.0 """ if not HAS_NUMPY: raise ImportError("NumPy required for dot_product") a = np.array(v1) b = np.array(v2) return float(np.dot(a, b))
[docs] def is_tangent_to_circle(axis_origin: Tuple[float, float, float], axis_direction: Tuple[float, float, float], circle_center: Tuple[float, float, float], circle_radius: float, tolerance_deg: float = 1.0) -> bool: """Check if an axis is tangent to a circle in the XY plane. An axis is tangent when its direction is perpendicular to the radial vector from the circle center to the axis origin. Args: axis_origin: Point on the axis (x, y, z) axis_direction: Direction vector of the axis (x, y, z) circle_center: Center of the circle (x, y, z) circle_radius: Radius of the circle tolerance_deg: Angular tolerance in degrees Returns: True if axis is tangent within tolerance Example: >>> # Axis at (10, 0, 0) pointing in +y direction, tangent to origin circle >>> is_tangent_to_circle((10, 0, 0), (0, 1, 0), (0, 0, 0), 10.0) True """ if not HAS_NUMPY: raise ImportError("NumPy required for is_tangent_to_circle") center = np.array(circle_center) origin = np.array(axis_origin) direction = np.array(axis_direction) # Radial vector from center to axis origin (XY plane only) radial = origin - center radial[2] = 0.0 radial_norm = np.linalg.norm(radial) if radial_norm < 1e-10: return False # Axis passes through center radial_unit = radial / radial_norm # Project axis direction to XY plane direction_xy = direction.copy() direction_xy[2] = 0.0 dir_norm = np.linalg.norm(direction_xy) if dir_norm < 1e-10: return True # Vertical axis is tangent to horizontal circle direction_unit = direction_xy / dir_norm # Tangent means perpendicular to radial (dot product ≈ 0) dot = abs(np.dot(radial_unit, direction_unit)) angle_from_perpendicular = math.degrees(math.asin(min(1.0, dot))) return angle_from_perpendicular <= tolerance_deg
[docs] def is_radial_from_center(point: Tuple[float, float, float], normal: Tuple[float, float, float], center: Tuple[float, float, float], direction: str = "outward", tolerance_deg: float = 1.0) -> bool: """Check if a normal vector points radially from a center point. Args: point: Location of the datum (x, y, z) normal: Normal vector to check (x, y, z) center: Center point (x, y, z) direction: "outward" or "inward" tolerance_deg: Angular tolerance in degrees Returns: True if normal is radial within tolerance Example: >>> # Point at (10, 0, 0) with normal pointing in +x (outward from origin) >>> is_radial_from_center((10, 0, 0), (1, 0, 0), (0, 0, 0), "outward") True """ if not HAS_NUMPY: raise ImportError("NumPy required for is_radial_from_center") if direction not in ("inward", "outward"): raise ValueError("direction must be 'inward' or 'outward'") c = np.array(center) p = np.array(point) n = np.array(normal) # Radial vector (outward) radial = p - c radial[2] = 0.0 # Project to XY plane radial_norm = np.linalg.norm(radial) if radial_norm < 1e-10: return False # Point at center radial_unit = radial / radial_norm normal_unit = n / np.linalg.norm(n) dot = np.dot(radial_unit, normal_unit) cos_tolerance = math.cos(math.radians(tolerance_deg)) if direction == "outward": return dot > cos_tolerance else: # inward return dot < -cos_tolerance