Source code for yapcad.collision.thread_interface

"""Thread interface for screw thread engagement overlap.

This module defines the ThreadInterface class for representing
screw thread engagement regions where controlled overlap is expected.

Copyright (c) 2026 yapCAD contributors
License: MIT
"""

from __future__ import annotations

import math
from dataclasses import dataclass, field
from enum import Enum
from typing import Dict, List, Tuple, Any

from .interface import InterfaceVolume, InterfaceType, CompatibilityResult


[docs] class ThreadType(Enum): """Standard thread types. Attributes: ISO_METRIC: ISO metric thread (M2, M3, M4, etc.) ISO_METRIC_FINE: ISO metric fine pitch thread UNC: Unified National Coarse (imperial) UNF: Unified National Fine (imperial) ACME: Trapezoidal/ACME thread for power transmission CUSTOM: User-defined thread profile """ ISO_METRIC = "ISO" ISO_METRIC_FINE = "ISO_F" UNC = "UNC" UNF = "UNF" ACME = "ACME" CUSTOM = "CUSTOM"
[docs] class ThreadClass(Enum): """Thread fit classes per ISO 965-1. Defines the tolerance grade for thread fits, affecting clearance/interference between mating threads. Attributes: ISO_6H_6g: Standard fit (normal clearance) ISO_6G_6h: Close fit (reduced clearance) ISO_5H_4h: Tight fit (precision applications) """ ISO_6H_6g = "6H/6g" ISO_6G_6h = "6G/6h" ISO_5H_4h = "5H/4h"
# Standard ISO metric coarse thread pitches _METRIC_COARSE_PITCH: Dict[str, float] = { "M1.6": 0.35, "M2": 0.4, "M2.5": 0.45, "M3": 0.5, "M3.5": 0.6, "M4": 0.7, "M5": 0.8, "M6": 1.0, "M8": 1.25, "M10": 1.5, "M12": 1.75, "M14": 2.0, "M16": 2.0, "M20": 2.5, "M24": 3.0, "M30": 3.5, }
[docs] @dataclass class ThreadInterface(InterfaceVolume): """Interface volume for screw thread engagement. Defines the region where external threads (bolt/screw) engage with internal threads (tapped hole/nut). Compatible threads must have matching pitch and diameter. Attributes: pitch: Thread pitch in mm (distance between threads) major_diameter: Major (nominal) diameter in mm thread_type: Thread standard (ISO_METRIC, UNC, etc.) thread_class: Fit class (tolerance grade) engagement_length: Length of thread engagement in mm is_internal: True for tapped hole/nut, False for bolt/screw Derived Properties: minor_diameter: Root diameter of external thread pitch_diameter: Diameter where thread flanks meet thread_depth: Radial depth of thread profile Common ISO Metric Threads: M2: pitch=0.4mm, major=2.0mm M2.5: pitch=0.45mm, major=2.5mm M3: pitch=0.5mm, major=3.0mm M4: pitch=0.7mm, major=4.0mm M5: pitch=0.8mm, major=5.0mm M6: pitch=1.0mm, major=6.0mm Example: >>> bolt = ThreadInterface.from_metric_size("M3", is_internal=False) >>> hole = ThreadInterface.from_metric_size("M3", is_internal=True) >>> result = bolt.check_compatibility(hole) >>> print(result) # COMPATIBLE: Threads compatible: M3.0x0.5 """ pitch: float = 0.5 major_diameter: float = 3.0 thread_type: ThreadType = ThreadType.ISO_METRIC thread_class: ThreadClass = ThreadClass.ISO_6H_6g engagement_length: float = 6.0 is_internal: bool = False interface_type: InterfaceType = field(default=InterfaceType.THREAD, init=False) def __post_init__(self): """Set default description if not provided.""" if not self.description: kind = "internal" if self.is_internal else "external" self.description = f"Thread {kind}: M{self.major_diameter}x{self.pitch}" @property def minor_diameter(self) -> float: """Minor diameter (root of external thread). Approximation for ISO metric: D_minor = D_major - 1.0825 * pitch """ return self.major_diameter - 1.0825 * self.pitch @property def pitch_diameter(self) -> float: """Pitch diameter (where thread flanks meet). Approximation for ISO metric: D_pitch = D_major - 0.6495 * pitch """ return self.major_diameter - 0.6495 * self.pitch @property def thread_depth(self) -> float: """Thread depth (radial distance from major to minor diameter).""" return (self.major_diameter - self.minor_diameter) / 2.0
[docs] def get_bounding_cylinder(self) -> Tuple[float, float]: """Get cylindrical bounding volume (radius, height). Returns: Tuple of (radius, height) where radius is major_diameter/2 and height is engagement_length. """ radius = self.major_diameter / 2.0 return (radius, self.engagement_length)
[docs] def get_engagement_depth(self) -> float: """Engagement length is the depth for threads. Returns: Thread engagement length in mm """ return self.engagement_length
[docs] def check_compatibility(self, other: InterfaceVolume) -> CompatibilityResult: """Check if this thread can engage with another interface. Compatible conditions: 1. Other must be a ThreadInterface 2. One must be internal, one external 3. Major diameters must match (within 0.1% tolerance) 4. Pitches must match (within 0.1% tolerance) 5. Thread types should be compatible Args: other: Another interface volume to check against Returns: CompatibilityResult indicating if threads can properly engage """ if not isinstance(other, ThreadInterface): return CompatibilityResult( is_compatible=False, reason=f"Cannot thread with {type(other).__name__}" ) warnings = [] # Check internal/external pairing if self.is_internal == other.is_internal: kind = "internal" if self.is_internal else "external" return CompatibilityResult( is_compatible=False, reason=f"Both threads are {kind} - need one internal, one external" ) # Check diameter match (0.1% tolerance) dia_diff = abs(self.major_diameter - other.major_diameter) if dia_diff > self.major_diameter * 0.001: return CompatibilityResult( is_compatible=False, reason=f"Diameter mismatch: M{self.major_diameter} vs M{other.major_diameter}" ) # Check pitch match (0.1% tolerance) pitch_diff = abs(self.pitch - other.pitch) if pitch_diff > self.pitch * 0.001: return CompatibilityResult( is_compatible=False, reason=f"Pitch mismatch: {self.pitch}mm vs {other.pitch}mm" ) # Check thread type compatibility if self.thread_type != other.thread_type: # Some cross-compatibility exists (ISO metric variants) compatible_pairs = { (ThreadType.ISO_METRIC, ThreadType.ISO_METRIC_FINE), } pair = (self.thread_type, other.thread_type) if pair not in compatible_pairs and tuple(reversed(pair)) not in compatible_pairs: warnings.append( f"Thread type mismatch: {self.thread_type.value} vs {other.thread_type.value}" ) # Calculate overlap volume (annular thread region) engagement = min(self.engagement_length, other.engagement_length) inner_r = self.minor_diameter / 2.0 outer_r = self.major_diameter / 2.0 overlap_volume = math.pi * (outer_r**2 - inner_r**2) * engagement return CompatibilityResult( is_compatible=True, reason=f"Threads compatible: M{self.major_diameter}x{self.pitch}", warnings=warnings, overlap_volume=overlap_volume, required_clearance=0.0 # Thread fit class defines clearance )
[docs] @classmethod def from_metric_size( cls, size: str, engagement_length: float = None, is_internal: bool = False, name: str = None, part_name: str = "", **kwargs ) -> 'ThreadInterface': """Create ThreadInterface from metric size string. Args: size: Metric size string (e.g., "M3", "M2.5", "M4x0.5") engagement_length: Thread engagement in mm (default: 1.5 * diameter) is_internal: True for tapped hole name: Interface name (default: "thread_{size}") part_name: Name of the part this interface belongs to **kwargs: Additional arguments passed to constructor Returns: ThreadInterface configured for the specified size Raises: ValueError: If size string cannot be parsed Example: >>> bolt = ThreadInterface.from_metric_size("M3", is_internal=False) >>> fine_bolt = ThreadInterface.from_metric_size("M4x0.5") """ # Parse size string size_upper = size.upper() if "X" in size_upper: # Fine pitch specified: M4x0.5 parts = size_upper.split("X") major = float(parts[0].replace("M", "")) pitch = float(parts[1]) thread_type = ThreadType.ISO_METRIC_FINE else: # Coarse pitch from lookup table major = float(size_upper.replace("M", "")) pitch = _METRIC_COARSE_PITCH.get(size_upper) if pitch is None: # Interpolate for non-standard sizes pitch = 0.5 if major <= 3 else 0.7 if major <= 4 else major * 0.125 thread_type = ThreadType.ISO_METRIC if engagement_length is None: engagement_length = 1.5 * major if name is None: name = f"thread_{size_upper.replace('.', '_')}" return cls( name=name, part_name=part_name, pitch=pitch, major_diameter=major, thread_type=thread_type, engagement_length=engagement_length, is_internal=is_internal, **kwargs )
[docs] def to_dict(self) -> Dict[str, Any]: """Convert to dictionary for serialization. Returns: Dictionary with all thread parameters """ d = super().to_dict() d.update({ "pitch": self.pitch, "major_diameter": self.major_diameter, "minor_diameter": self.minor_diameter, "pitch_diameter": self.pitch_diameter, "thread_type": self.thread_type.value, "thread_class": self.thread_class.value, "engagement_length": self.engagement_length, "is_internal": self.is_internal, }) return d
[docs] def create_bolt_pattern_interfaces( pattern_name: str, bolt_part_name: str, hole_part_name: str, thread_size: str, positions: List[Tuple[float, float, float]], engagement_length: float = None ) -> List[ThreadInterface]: """Create interface volumes for a bolt pattern. Creates matching pairs of external (bolt) and internal (hole) thread interfaces at specified positions. Args: pattern_name: Base name for the bolt pattern bolt_part_name: Part containing the bolts hole_part_name: Part containing the tapped holes thread_size: Thread size (e.g., "M3", "M2.5") positions: List of (x, y, z) positions for each bolt engagement_length: Thread engagement in mm (default: from size) Returns: List of ThreadInterface objects (bolts and holes) Example: >>> positions = [(10, 0, 0), (-10, 0, 0), (0, 10, 0), (0, -10, 0)] >>> interfaces = create_bolt_pattern_interfaces( ... "mounting_bolts", ... "BRACKET", "BASE_PLATE", ... "M3", positions ... ) """ interfaces = [] for i, (x, y, z) in enumerate(positions): # Bolt (external thread) bolt = ThreadInterface.from_metric_size( thread_size, engagement_length=engagement_length, is_internal=False, name=f"{pattern_name}_bolt_{i+1}", part_name=bolt_part_name, center=(x, y, z), ) interfaces.append(bolt) # Tapped hole (internal thread) hole = ThreadInterface.from_metric_size( thread_size, engagement_length=engagement_length, is_internal=True, name=f"{pattern_name}_hole_{i+1}", part_name=hole_part_name, center=(x, y, z), ) interfaces.append(hole) return interfaces