"""Thread profile generator for yapCAD."""
from __future__ import annotations
import math
from dataclasses import dataclass
from typing import Iterable, List
from yapcad.geom import point, epsilon
__all__ = [
"ThreadProfile",
"metric_profile",
"unified_profile",
"sample_thread_profile",
]
[docs]
@dataclass(frozen=True)
class ThreadProfile:
D_nominal: float
P_pitch: float
thread_angle: float = 60.0
crest_flat_ratio: float = 1.0 / 8.0
root_flat_ratio: float = 1.0 / 4.0
thread_depth_ratio: float = 0.54127
handedness: str = "right"
starts: int = 1
internal: bool = False
taper_ratio: float = 0.0
[docs]
def lead(self) -> float:
return self.P_pitch * max(1, self.starts)
[docs]
def metric_profile(d_nominal_mm: float, pitch_mm: float, *, strts: int = 1, internal: bool = False) -> ThreadProfile:
crest = 1.0 / 8.0
root = 1.0 / 4.0 if not internal else 1.0 / 8.0
return ThreadProfile(
D_nominal=d_nominal_mm,
P_pitch=pitch_mm,
crest_flat_ratio=crest,
root_flat_ratio=root,
thread_depth_ratio= 0.57, # based on metric g6 estimate
starts=strts,
internal=internal,
)
[docs]
def unified_profile(d_nominal_in: float, tpi: float, *, strts: int=1, internal: bool = False) -> ThreadProfile:
pitch = 25.4 / tpi
crest = 1.0 / 8.0
root = 1.0 / 4.0 if not internal else 1.0 / 8.0
depth_ratio = 0.64952 * pitch
return ThreadProfile(
D_nominal=d_nominal_in * 25.4,
P_pitch=pitch,
crest_flat_ratio=crest,
root_flat_ratio=root,
thread_depth_ratio=depth_ratio,
starts=strts,
internal=internal,
)
[docs]
def sample_thread_profile(
profile: ThreadProfile,
x_start: float,
x_end: float,
theta_deg: float,
*,
samples_per_pitch: int = 1,
) -> tuple[List[List[float]], int]:
if x_end <= x_start:
raise ValueError("x_end must be greater than x_start")
if profile.P_pitch <= 0:
raise ValueError("pitch must be positive")
lead = profile.lead()
points, wrap = _collect_breakpoints(profile, x_start-lead, x_end, samples_per_pitch)
offset = (theta_deg / 360.0) * lead
if profile.handedness.lower() == "left":
offset = -offset
sampled = []
z_min = x_start
z_max = x_end
depth = profile.thread_depth_ratio * profile.P_pitch
tdepth=lead*0.2
for i,base_z in enumerate(points):
leadin = False
leadout = False
blend = 0.0
shifted = base_z + offset
actual_z = shifted
if actual_z < z_min:
actual_z = z_min
elif actual_z > z_max:
actual_z = z_max
if actual_z < z_min+tdepth:
leadin = True
blend = (actual_z-z_min)/tdepth
elif actual_z > z_max-tdepth:
leadout = True
blend = (z_max - actual_z)/tdepth
base_major = (profile.D_nominal + profile.taper_ratio)/2.0
crest = base_major + depth
radius = _radius_at(profile, actual_z, base_z)
if leadin:
if profile.internal:
radius = radius*blend + (1.0-blend)*(base_major+depth)
else:
radius = radius*blend + (1.0-blend)*(base_major-depth)
elif leadout:
if profile.internal:
radius = radius*blend + (1.0-blend)*(base_major + depth)
else:
radius = radius*blend + (1.0-blend)*base_major
sampled.append(point(actual_z, radius))
return sampled, wrap
def _collect_breakpoints(profile: ThreadProfile, x_start: float, x_end: float, samples_per_pitch: int) -> tuple[List[float], int]:
pitch = profile.P_pitch
breakpoints = _pitch_breakpoints(profile)
extra = max(1, samples_per_pitch)
base_start = math.floor(x_start / pitch) - 1
base_end = math.ceil(x_end / pitch) + 1
xs = {x_start, x_end}
pitch_samples = set()
for idx in range(base_start, base_end + 1):
base = idx * pitch
for bp in breakpoints:
x = base + bp
if x_start - 1e-9 <= x <= x_end + 1e-9:
xs.add(min(max(x, x_start), x_end))
for seg in range(1, extra):
frac = seg / extra
x = base + frac * pitch
if x_start - 1e-9 <= x <= x_end + 1e-9:
xs.add(min(max(x, x_start), x_end))
# samples within single pitch starting at zero
for base in (0,):
for bp in breakpoints:
pitch_samples.add(base + bp)
for seg in range(1, extra):
frac = seg / extra
pitch_samples.add(base + frac * pitch)
pitch_samples.add(pitch)
wrap = max(1, len(sorted(pitch_samples)) - 1) * profile.starts
return sorted(xs), wrap
def _pitch_breakpoints(profile: ThreadProfile) -> Iterable[float]:
pitch = profile.P_pitch
crest_half = max(0.0, profile.crest_flat_ratio * pitch / 2.0)
root_half = max(0.0, profile.root_flat_ratio * pitch / 2.0)
flank = max(0.0, (pitch - 2 * crest_half - 2 * root_half) / 2.0)
return [
0.0,
crest_half,
crest_half + flank,
crest_half + flank + 2 * root_half,
pitch - crest_half,
pitch,
]
def _radius_at(profile: ThreadProfile, x_actual: float, x_effective: float) -> float:
pitch = profile.P_pitch
local = x_effective - math.floor(x_effective / pitch) * pitch
base_major = (profile.D_nominal + profile.taper_ratio * x_actual) / 2.0
depth = profile.thread_depth_ratio * pitch
if profile.internal:
# root = base_major
# crest = base_major + depth
root = base_major - depth
crest = base_major
else:
crest = base_major
root = max(crest - depth, 0.0)
crest_half = max(0.0, profile.crest_flat_ratio * pitch / 2.0)
root_half = max(0.0, profile.root_flat_ratio * pitch / 2.0)
flank = max(0.0, (pitch - 2 * crest_half - 2 * root_half) / 2.0)
x1 = crest_half
x2 = crest_half + flank
x3 = x2 + 2 * root_half
x4 = pitch - crest_half
if local <= x1:
return crest
if local <= x2:
t = (local - x1) / max(flank, epsilon)
return crest + t * (root - crest)
if local <= x3:
return root
if local <= x4:
t = (local - x3) / max(flank, epsilon)
return root + t * (crest - root)
return crest