"""Assembly orchestrator for the yapCAD constraint-based assembly system.
This module provides the main Assembly class that manages parts, mates, and
constraints, and orchestrates the constraint solving and validation process.
The Assembly class is the primary user-facing interface for creating and
validating assemblies in yapCAD. It coordinates between the datum, mate, and
constraint subsystems to ensure assemblies meet design intent.
Key Features:
- Add parts with initial transforms
- Define mate relationships between parts
- Add design constraints to validate intent
- Solve mate constraints to compute part positions
- Validate all design constraints
- Export to URDF, Blender rigs, or other formats
- Integration with existing kinematic_chain.py transforms
Example:
>>> from yapcad.assembly import Assembly, PartDefinition, Datum, DatumType
>>> from yapcad.assembly import Mate, MateType, Constraint, ConstraintType
>>> import numpy as np
>>>
>>> # Create part definitions
>>> motor = PartDefinition("DDSM115_MOTOR")
>>> motor.add_datum(Datum("motor_axis", DatumType.AXIS,
... origin=(0, 0, 0), direction=(0, 1, 0)))
>>> motor.add_datum(Datum("stator_face", DatumType.PLANE,
... origin=(0, 10, 0), normal=(0, 1, 0)))
>>>
>>> bracket = PartDefinition("WHEEL_BRACKET")
>>> bracket.add_datum(Datum("bore_axis", DatumType.AXIS,
... origin=(0, 0, 0), direction=(0, 0, 1)))
>>> bracket.add_datum(Datum("motor_interface", DatumType.PLANE,
... origin=(0, 0, 5), normal=(0, 0, 1)))
>>>
>>> # Create assembly
>>> assembly = Assembly("wheel_module")
>>> assembly.add_part(motor, name="motor_1")
>>> assembly.add_part(bracket, name="bracket_1")
>>>
>>> # Add mates to position parts
>>> assembly.add_mate(Mate("mount_mate", MateType.FLUSH,
... part1="bracket_1", datum1="motor_interface",
... part2="motor_1", datum2="stator_face"))
>>> assembly.add_mate(Mate("axis_mate", MateType.CONCENTRIC,
... part1="bracket_1", datum1="bore_axis",
... part2="motor_1", datum2="motor_axis"))
>>>
>>> # Add design constraints
>>> assembly.add_constraint(Constraint(
... "motor_tangent", ConstraintType.TANGENT_TO_CIRCLE,
... part="motor_1", datum="motor_axis",
... center=(0, 0, 0), radius=124.5,
... description="Motor axis must be tangent to chassis for rolling motion"
... ))
>>>
>>> # Validate assembly
>>> result = assembly.validate()
>>> if result.is_valid:
... print("Assembly is valid!")
... motor_transform = assembly.transforms["motor_1"]
... else:
... for error in result.failed_constraints:
... print(f"ERROR: {error}")
... for warning in result.warnings:
... print(f"WARNING: {warning}")
See Also:
- datum.py: Datum feature definitions
- mate.py: Mate constraint definitions
- constraint.py: Design constraint definitions
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Tuple, Any, Union
import numpy as np
import json
from pathlib import Path
# These will be imported from sibling modules once they exist
# For now, we define placeholder types for the implementation
try:
from .datum import PartDefinition, Datum, DatumType
from .mate import Mate, MateType
from .constraint import Constraint, ConstraintResult, ConstraintType
except ImportError:
# Fallback for development - these should be defined in separate modules
PartDefinition = Any
Datum = Any
DatumType = Any
Mate = Any
MateType = Any
Constraint = Any
ConstraintResult = Any
ConstraintType = Any
[docs]
class AssemblyError(Exception):
"""Exception raised when assembly operations fail.
This exception is raised for various assembly-related errors such as:
- Invalid part names or references
- Missing datums on parts
- Invalid mate configurations
- Constraint validation failures
- Solver convergence failures
Attributes:
message: Description of the error
assembly_name: Name of the assembly where error occurred
part_name: Name of the part involved (if applicable)
constraint_name: Name of the constraint that failed (if applicable)
"""
def __init__(self, message: str, assembly_name: str = None,
part_name: str = None, constraint_name: str = None):
self.message = message
self.assembly_name = assembly_name
self.part_name = part_name
self.constraint_name = constraint_name
super().__init__(self._format_message())
def _format_message(self) -> str:
"""Format a detailed error message."""
parts = []
if self.assembly_name:
parts.append(f"Assembly '{self.assembly_name}'")
if self.part_name:
parts.append(f"Part '{self.part_name}'")
if self.constraint_name:
parts.append(f"Constraint '{self.constraint_name}'")
if parts:
return f"{': '.join(parts)}: {self.message}"
return self.message
[docs]
@dataclass
class AssemblyValidationResult:
"""Result of validating an assembly's constraints.
Contains the complete validation status including which constraints
passed, failed, or produced warnings, along with detailed diagnostic
information for each constraint.
Attributes:
is_valid: True if all constraints are satisfied, False otherwise
constraint_results: Dictionary mapping constraint name to ConstraintResult
failed_constraints: List of names of constraints that failed
warnings: List of warning messages from constraint validation
Example:
>>> result = assembly.validate()
>>> if not result.is_valid:
... print(f"Found {len(result.failed_constraints)} failures")
... for name in result.failed_constraints:
... cr = result.constraint_results[name]
... print(f" {name}: {cr.message}")
"""
is_valid: bool
constraint_results: Dict[str, ConstraintResult] = field(default_factory=dict)
failed_constraints: List[str] = field(default_factory=list)
warnings: List[str] = field(default_factory=list)
def __str__(self) -> str:
"""Human-readable summary of validation results."""
if self.is_valid:
return f"Assembly valid: {len(self.constraint_results)} constraints satisfied"
else:
n_failed = len(self.failed_constraints)
n_warnings = len(self.warnings)
return f"Assembly invalid: {n_failed} failed, {n_warnings} warnings"
[docs]
def report(self) -> str:
"""Generate detailed validation report."""
lines = []
lines.append("=" * 70)
lines.append("Assembly Validation Report")
lines.append("=" * 70)
lines.append("")
if self.is_valid:
lines.append("STATUS: VALID - All constraints satisfied")
else:
lines.append(f"STATUS: INVALID - {len(self.failed_constraints)} constraints failed")
lines.append("")
lines.append(f"Total constraints: {len(self.constraint_results)}")
lines.append(f"Passed: {len(self.constraint_results) - len(self.failed_constraints)}")
lines.append(f"Failed: {len(self.failed_constraints)}")
lines.append(f"Warnings: {len(self.warnings)}")
lines.append("")
# Failed constraints
if self.failed_constraints:
lines.append("FAILED CONSTRAINTS:")
lines.append("-" * 70)
for name in self.failed_constraints:
result = self.constraint_results[name]
lines.append(f" [{name}]")
lines.append(f" {result.error_message}")
if hasattr(result, 'error_value') and result.error_value > 0:
lines.append(f" Error: {result.error_value:.3f}")
lines.append("")
# Warnings
if self.warnings:
lines.append("WARNINGS:")
lines.append("-" * 70)
for warning in self.warnings:
lines.append(f" - {warning}")
lines.append("")
# Passed constraints
passed = [name for name in self.constraint_results
if name not in self.failed_constraints]
if passed:
lines.append("PASSED CONSTRAINTS:")
lines.append("-" * 70)
for name in passed:
result = self.constraint_results[name]
lines.append(f" [OK] {name}")
lines.append("")
lines.append("=" * 70)
return "\n".join(lines)
[docs]
class Assembly:
"""Main assembly orchestrator for the yapCAD constraint system.
The Assembly class manages a collection of parts with their transforms,
mate relationships that position parts, and design constraints that
validate assembly intent.
Workflow:
1. Create assembly: `assembly = Assembly("my_assembly")`
2. Add parts: `assembly.add_part(part_def, name="part_1")`
3. Add mates: `assembly.add_mate(mate)`
4. Add constraints: `assembly.add_constraint(constraint)`
5. Validate: `result = assembly.validate()`
The assembly can also import transforms from existing kinematic chains
and export to various formats (URDF, Blender, etc.).
Attributes:
name: Unique identifier for this assembly
parts: Dictionary mapping part name to PartDefinition
transforms: Dictionary mapping part name to 4x4 transform matrix
mates: List of Mate objects defining part relationships
constraints: List of Constraint objects to validate
Integration with yapCAD:
- Transforms are 4x4 numpy arrays compatible with yapcad.xform.Matrix
- Can import transforms from kinematic_chain.py JSON exports
- Datums transform correctly through coordinate systems
Example:
>>> # Create assembly
>>> assembly = Assembly("robot_wheel")
>>>
>>> # Add parts
>>> assembly.add_part(motor_def, name="motor")
>>> assembly.add_part(bracket_def, name="bracket")
>>>
>>> # Define relationships
>>> assembly.add_mate(Mate("align", MateType.CONCENTRIC,
... "bracket", "hole", "motor", "shaft"))
>>>
>>> # Validate design intent
>>> assembly.add_constraint(Constraint(
... "tangent", ConstraintType.TANGENT_TO_CIRCLE,
... "motor", "axis", center=(0,0,0), radius=124.5
... ))
>>>
>>> # Check if valid
>>> result = assembly.validate()
>>> if not result.is_valid:
... assembly.validate_and_raise() # Raises AssemblyError
"""
def __init__(self, name: str):
"""Initialize a new assembly.
Args:
name: Unique identifier for this assembly
"""
self.name = name
self.parts: Dict[str, PartDefinition] = {}
self.transforms: Dict[str, np.ndarray] = {}
self.mates: List[Mate] = []
self.constraints: List[Constraint] = []
self._solved = False
[docs]
def add_part(self, part: PartDefinition, name: str = None,
transform: np.ndarray = None) -> None:
"""Add a part to the assembly.
Args:
part: PartDefinition with datum features
name: Name for this part instance (default: part.name)
transform: Initial 4x4 transform matrix (default: identity)
Raises:
AssemblyError: If part name already exists in assembly
Example:
>>> motor = PartDefinition("DDSM115")
>>> assembly.add_part(motor, name="motor_1")
>>> # Or with initial position
>>> T = np.eye(4)
>>> T[0:3, 3] = [100, 0, 0] # Translate 100mm in X
>>> assembly.add_part(motor, name="motor_2", transform=T)
"""
part_name = name if name is not None else part.name
if part_name in self.parts:
raise AssemblyError(
f"Part name '{part_name}' already exists in assembly",
assembly_name=self.name,
part_name=part_name
)
self.parts[part_name] = part
self.transforms[part_name] = (
transform.copy() if transform is not None else np.eye(4)
)
self._solved = False
[docs]
def add_mate(self, mate: Mate) -> None:
"""Add a mate relationship between two parts.
Mates define geometric relationships between datum features on
different parts. They are used to position parts relative to
each other.
Args:
mate: Mate object defining the relationship
Raises:
AssemblyError: If referenced parts or datums don't exist,
or if mate is invalid for the given datums
Example:
>>> # Align motor shaft with bracket hole
>>> assembly.add_mate(Mate(
... "shaft_alignment", MateType.CONCENTRIC,
... part1="bracket", datum1="bore_axis",
... part2="motor", datum2="shaft_axis"
... ))
"""
# Validate parts exist
if mate.part1 not in self.parts:
raise AssemblyError(
f"Part '{mate.part1}' not found in assembly",
assembly_name=self.name,
part_name=mate.part1
)
if mate.part2 not in self.parts:
raise AssemblyError(
f"Part '{mate.part2}' not found in assembly",
assembly_name=self.name,
part_name=mate.part2
)
# Validate datums exist
part1 = self.parts[mate.part1]
part2 = self.parts[mate.part2]
if mate.datum1 not in part1.datums:
raise AssemblyError(
f"Datum '{mate.datum1}' not found on part '{mate.part1}'",
assembly_name=self.name,
part_name=mate.part1
)
if mate.datum2 not in part2.datums:
raise AssemblyError(
f"Datum '{mate.datum2}' not found on part '{mate.part2}'",
assembly_name=self.name,
part_name=mate.part2
)
# Validate mate is compatible with datum types
d1 = part1.get_datum(mate.datum1)
d2 = part2.get_datum(mate.datum2)
issues = mate.validate(d1, d2)
if issues:
raise AssemblyError(
f"Invalid mate '{mate.name}': {'; '.join(issues)}",
assembly_name=self.name
)
self.mates.append(mate)
self._solved = False
[docs]
def add_constraint(self, constraint: Constraint) -> None:
"""Add a design constraint to validate assembly intent.
Constraints validate high-level design requirements that go beyond
simple positioning. They ensure the assembly meets its intended
functional requirements.
Args:
constraint: Constraint object to validate
Raises:
AssemblyError: If referenced part or datum doesn't exist
Example:
>>> # Ensure motor axis is tangent to chassis
>>> assembly.add_constraint(Constraint(
... "motor_tangent", ConstraintType.TANGENT_TO_CIRCLE,
... part="motor_1", datum="motor_axis",
... center=(0, 0, 0), radius=124.5,
... description="Motor must be tangent for rolling motion"
... ))
"""
if constraint.part not in self.parts:
raise AssemblyError(
f"Part '{constraint.part}' not found in assembly",
assembly_name=self.name,
part_name=constraint.part,
constraint_name=constraint.name
)
part = self.parts[constraint.part]
if constraint.datum not in part.datums:
raise AssemblyError(
f"Datum '{constraint.datum}' not found on part '{constraint.part}'",
assembly_name=self.name,
part_name=constraint.part,
constraint_name=constraint.name
)
self.constraints.append(constraint)
[docs]
def validate(self) -> AssemblyValidationResult:
"""Validate all design constraints against current part transforms.
Evaluates each constraint in the assembly and returns a comprehensive
validation result. This does not solve mates - it validates the
current state of the assembly.
Returns:
AssemblyValidationResult with detailed status
Example:
>>> result = assembly.validate()
>>> if result.is_valid:
... print("Assembly is valid!")
... else:
... print(result.report())
"""
constraint_results = {}
failed_constraints = []
warnings = []
for constraint in self.constraints:
# Get datum in world coordinates
datum_world = self.get_transformed_datum(
constraint.part, constraint.datum
)
# Evaluate constraint
result = constraint.evaluate(datum_world)
constraint_results[constraint.name] = result
# Track failures and warnings
if not result.passed:
if constraint.severity == "error":
failed_constraints.append(constraint.name)
elif constraint.severity == "warning":
warnings.append(f"{constraint.name}: {result.error_message}")
is_valid = len(failed_constraints) == 0
return AssemblyValidationResult(
is_valid=is_valid,
constraint_results=constraint_results,
failed_constraints=failed_constraints,
warnings=warnings
)
[docs]
def validate_and_raise(self) -> None:
"""Validate assembly and raise AssemblyError if invalid.
Convenience method for strict validation where you want an
exception on any constraint failure.
Raises:
AssemblyError: If any constraint fails
Example:
>>> try:
... assembly.validate_and_raise()
... print("Assembly is valid, proceeding...")
... except AssemblyError as e:
... print(f"Invalid assembly: {e}")
"""
result = self.validate()
if not result.is_valid:
# Create detailed error message
lines = [f"Assembly '{self.name}' validation failed:"]
for name in result.failed_constraints:
cr = result.constraint_results[name]
lines.append(f" - {name}: {cr.message}")
raise AssemblyError(
"\n".join(lines),
assembly_name=self.name
)
[docs]
def get_degrees_of_freedom(self) -> Dict[str, int]:
"""Calculate remaining degrees of freedom for each part after mates.
Analyzes the mate constraints to determine how many degrees of
freedom remain unconstrained for each part. A fully constrained
part has 0 DOF.
Returns:
Dictionary mapping part name to DOF count (0-6)
Note:
This is a simplified implementation. A full implementation would
analyze the constraint graph to detect over/under-constrained
situations and redundant constraints.
Example:
>>> dof = assembly.get_degrees_of_freedom()
>>> for part, dof_count in dof.items():
... if dof_count > 0:
... print(f"{part} has {dof_count} DOF remaining")
"""
# Each part starts with 6 DOF (3 translation, 3 rotation)
dof = {name: 6 for name in self.parts}
# This is a simplified approximation
# A full implementation would build the constraint Jacobian
for mate in self.mates:
# Estimate DOF removed by each mate type
if mate.mate_type == MateType.COINCIDENT:
# Removes 3 DOF (position locked)
dof[mate.part2] = max(0, dof[mate.part2] - 3)
elif mate.mate_type == MateType.CONCENTRIC:
# Removes 2 DOF (can slide and rotate along axis)
dof[mate.part2] = max(0, dof[mate.part2] - 2)
elif mate.mate_type == MateType.FLUSH:
# Removes 3 DOF (orientation locked, can slide in plane)
dof[mate.part2] = max(0, dof[mate.part2] - 3)
elif mate.mate_type == MateType.PARALLEL:
# Removes 2 DOF (2 rotation axes locked)
dof[mate.part2] = max(0, dof[mate.part2] - 2)
elif mate.mate_type == MateType.PERPENDICULAR:
# Removes 1 DOF (1 rotation axis locked)
dof[mate.part2] = max(0, dof[mate.part2] - 1)
return dof
[docs]
def export_to_urdf(self, output_path: Union[str, Path]) -> None:
"""Export assembly to URDF format for ROS integration.
URDF (Unified Robot Description Format) is used by ROS for robot
kinematics and dynamics. This export enables simulation and control
of yapCAD assemblies in ROS.
Args:
output_path: Path where URDF file will be written
Note:
This is currently a stub implementation. A full implementation
would generate proper URDF XML with links, joints, and meshes.
Example:
>>> assembly.export_to_urdf("robot.urdf")
>>> # Use in ROS: roslaunch robot_description display.launch
"""
# STUB: Full implementation would generate URDF XML
# including links, joints, inertial properties, collision geometry
raise NotImplementedError(
"URDF export not yet implemented. "
"See ROS URDF documentation for format details."
)
[docs]
def export_to_blender_rig(self, output_path: Union[str, Path]) -> None:
"""Export assembly to Blender armature rig for animation.
Creates a Blender Python script that sets up an armature with
bones positioned according to the assembly transforms. Useful
for visualizing kinematics and creating animations.
Args:
output_path: Path where Blender Python script will be written
Note:
This is currently a stub implementation. A full implementation
would generate a Blender Python script that creates bones,
constraints, and animations.
Example:
>>> assembly.export_to_blender_rig("robot_rig.py")
>>> # In Blender: File > Import > Python Script > robot_rig.py
"""
# STUB: Full implementation would generate Blender Python script
# creating armature with bones, constraints, and IK chains
raise NotImplementedError(
"Blender rig export not yet implemented. "
"See Blender Python API documentation for bpy.types.Armature."
)
def __repr__(self) -> str:
"""String representation of assembly."""
return (
f"Assembly(name='{self.name}', "
f"parts={len(self.parts)}, "
f"mates={len(self.mates)}, "
f"constraints={len(self.constraints)})"
)
[docs]
def report(self) -> str:
"""Generate a comprehensive human-readable assembly report.
Returns:
Multi-line string with assembly status and validation results
Example:
>>> print(assembly.report())
Assembly: robot_wheel
====================================
Parts: 2
- motor_1 (DDSM115_MOTOR)
- bracket_1 (WHEEL_BRACKET)
Mates: 2
- shaft_alignment (CONCENTRIC)
- mount_surface (FLUSH)
Constraints: 1
- motor_tangent (TANGENT_TO_CIRCLE)
Validation: VALID
"""
lines = []
lines.append(f"Assembly: {self.name}")
lines.append("=" * 60)
# Parts section
lines.append(f"Parts: {len(self.parts)}")
for name, part in self.parts.items():
lines.append(f" - {name} ({part.name})")
lines.append("")
# Mates section
lines.append(f"Mates: {len(self.mates)}")
for mate in self.mates:
lines.append(
f" - {mate.name} ({mate.mate_type.value}): "
f"{mate.part1}.{mate.datum1} <-> {mate.part2}.{mate.datum2}"
)
lines.append("")
# Constraints section
lines.append(f"Constraints: {len(self.constraints)}")
for constraint in self.constraints:
lines.append(
f" - {constraint.name} ({constraint.constraint_type.value}): "
f"{constraint.part}.{constraint.datum}"
)
lines.append("")
# Validation section
try:
result = self.validate()
lines.append("Validation:")
lines.append("-" * 60)
if result.is_valid:
lines.append(" STATUS: VALID")
else:
lines.append(f" STATUS: INVALID ({len(result.failed_constraints)} failures)")
for name in result.failed_constraints:
cr = result.constraint_results[name]
lines.append(f" FAILED: {name} - {cr.message}")
if result.warnings:
lines.append(f" WARNINGS: {len(result.warnings)}")
for warning in result.warnings:
lines.append(f" - {warning}")
except Exception as e:
lines.append(f"Validation: ERROR - {e}")
lines.append("")
lines.append("=" * 60)
return "\n".join(lines)