Source code for taperable_helix.helix

from dataclasses import dataclass
from math import cos, degrees, pi, sin
from typing import Callable, Optional, Tuple


[docs]@dataclass class HelixLocation: radius: Optional[float] = None """radius of helix if none h.radius""" horz_offset: float = 0 """horizontal offset added to radius then x and y calculated""" vert_offset: float = 0 """vertical added to z of radius"""
[docs]@dataclass class Helix: """This class represents a taperable Helix. The required attributes are radius, pitch and height. Thse attributes create simple single line helix. But the primary purpose for Helix is to create a set of helical "wires" using non-zero values for taper_rpos, horz_offset and vert_offset to define solid helixes that can taper at each end to a point. This is useful for creating internal and external threads for nuts and bolts. This is accomplished by invoking helix() multiple times with same radius, pitch, taper_rpos, inset_offset, first_t, and last_t. But with different HelixLocation radius, horz_offset and vert_offset. Each returned function will then generate a helix defining an edge of the thread. The edges can be used to make faces and subsequently a solid of the thread. This can then be combined with the "core" objects which the threads are "attached" using a "union" operator. """ radius: float """radius of the basic helix.""" pitch: float """pitch of the helix per revolution. I.e the distance between the height of a single "turn" of the helix. """ height: float """height of the cyclinder containing the helix.""" taper_out_rpos: float = 0 """taper_out_rpos is a decimal number with an inclusive range of 0..1 such that (taper_out_rpos * t_range) defines the t value where tapering out ends, it begins at t == first_t. A ValueError exception is raised if taper_out_rpos < 0 or > 1 or taper_out_rpos > taper_in_rpos. Default is 0 which is no out taper. """ taper_in_rpos: float = 1 """taper_in_rpos: is a decimal number with an inclusive range of 0..1 such that (taper_in_rpos * t_range) defines the t value where tapering in begins, it ends at t == last_t. A ValueError exception is raised if taper_out_rpos < 0 or > 1 or taper_out_rpos > taper_in_rpos. Default is 1 which is no in taper. """ inset_offset: float = 0 """inset_offset: the helix will start at z = inset_offset and will end at z = height - (2 * inset_offset). Default 0. """ first_t: float = 0 """first_t is the first t value passed to the returned function. Default 0""" last_t: float = 1 """last_t is the last t value passed to the returned function. Default 1"""
[docs] def helix( self, hl: Optional[HelixLocation] = None ) -> Callable[[float], Tuple[float, float, float]]: """This function returns a Function that is used to generates points on a helix. It takes an optional HelixLocation which refines the location of the final helix when its tapered. If HelixLocation is None then the radius is Helix.radius and horz_offset and vert_offset will be 0. If its not None HelixLocation.radius maybe None, in which case Helix.radius will be used. and HelixLocation.horz_offset will be added to the radius and used to calculate x and y. The HelixLocation.vert_offset will be added to z. This function returns a function, f. The funciton f that takes one parameter, an inclusive value between first_t and last_t. We then define t_range=last_t-first_t and the rel_height=(last_t-t)/t_range. The rel_height is the relative position along the "z-axis" which is used to calculate function functions returned tuple(x, y, z) for a point on the helix. Credit: Adam Urbanczyk from cadquery [forum post](https://groups.google.com/g/cadquery/c/5kVRpECcxAU/m/7no7_ja6AAAJ) :param hl: Defines a refinded location when the helix is tapered :returns: A function which is passed "t", an inclusive value between first_t and last_t and returns a 3D point (x, y, z) on the helix as a function of t. """ if self.taper_out_rpos > self.taper_in_rpos: raise ValueError( f"taper_out_rpos:{self.taper_out_rpos} > taper_in_rpos:{self.taper_in_rpos}" ) if self.taper_out_rpos < 0 or self.taper_out_rpos > 1: raise ValueError( f"taper_out_rpos:{self.taper_out_rpos} should be >= 0 and <= 1" ) if self.taper_in_rpos < 0 or self.taper_in_rpos > 1: raise ValueError( f"taper_in_rpos:{self.taper_in_rpos} should be >= 0 and <= 1" ) # Being "Tricky" to be flexible if hl is None: hl = HelixLocation(self.radius) elif hl.radius is None: hl.radius = self.radius # Reduce the height by 2 * inset_offset. Threads start at inset_offset # and end at height - inset_offset helix_height: float = self.height - (2 * self.inset_offset) # The number or revolutions of the helix within the helix_height # set to 1 if pitch or helix_height is 0 turns: float = ( self.pitch / helix_height if self.pitch != 0 and helix_height != 0 else 1 ) # With this DISABLED points t_range will be negative # when self.last_t < self.first_t. This causes rel_height in "f" # to be negative and as a consequence # the values # generated by "f" will always be the same order. # See test_helix_backwards. # # If we ENABLE the code below and swap the order # if self.last_t < self.first_t then test_helix_backwards will # fail because the order of points in the resulting # array will be in reversed order. # # if self.last_t < self.first_t: # self.last_t, self.first_t = self.first_t, self.last_t t_range: float = self.last_t - self.first_t taper_out_range: float = t_range * self.taper_out_rpos taper_out_ends: float = ( self.first_t + taper_out_range if taper_out_range > 0 else min(self.first_t, self.last_t) ) taper_in_range: float = t_range * (1 - self.taper_in_rpos) taper_in_starts: float = ( self.last_t - taper_in_range if taper_in_range > 0 else max(self.first_t, self.last_t) ) # print(f"helix: ft={self.first_t:.4f} lt={self.last_t:.4f} tr={t_range:.4f}") # print(f"helix: tor={taper_out_range:.4f} toe={taper_out_ends:.4f}") # print(f"helix: tir={taper_in_range:.4f} tis={taper_in_starts:.4f}") def func(t: float) -> Tuple[float, float, float]: """ Return a tuple(x, y, z) :param t: A value between self.first_t .. self.last_t inclusive """ # This if statement is needed to satisfy mypy this is already # guaranteed in helix() above so the if statement is always # False. Furthermore this means the raise line is untestable. # Hopefully this can be fixed in the future. if (hl is None) or (hl.radius is None): raise ValueError("hl or hl.radius is None, should never happen") taper_angle: float toffset: float = t - self.first_t rel_height: float = toffset / t_range if t_range != 0 else 0 # print(f"f: t={t:.4f}") # print(f"f: tor={taper_out_range:.4f} toe={taper_out_ends:.4f}") # print(f"f: tir={taper_in_range:.4f} tis={taper_in_starts:.4f}") if t < taper_out_ends: # Taper out from a point, taper_scale will be between 0 and 1 # This code path is used when t < taper_out and this helix # will smoothly taper from a point as taper angle starts at 0 # and increases to p/2. # print(f"f: out t={t:.4f} < taper_out_ends:{taper_out_ends}") taper_angle = pi / 2 * (t - self.first_t) / taper_out_range elif t <= taper_in_starts: # No tapering, taper_scale == 1 # print(f"f: no t={t:.4f} >= taper_out_ends:{taper_out_ends} <= taper_in_starts:{taper_in_starts}") taper_angle = pi / 2 else: # This code path is used when t > taper_in_starts and the this helix # will smoothly taper to a point as taper angle starts at p/2 # and decrease to 0. # print(f"f: in t={t:.4f} > taper_in_starts:{taper_in_starts}") taper_angle = pi / 2 * (self.last_t - t) / taper_in_range # print(f"taper_angle={taper_angle}") taper_scale: float = sin(taper_angle) r: float = hl.radius + (hl.horz_offset * taper_scale) a: float = (2 * pi / turns) * rel_height x: float = r * sin(-a) y: float = r * cos(a) z: float = ( (helix_height * (rel_height if self.pitch != 0 else 1)) + (hl.vert_offset * taper_scale) + self.inset_offset ) result: Tuple[float, float, float] = (x, y, z) # print(f"f: tpa={degrees(taper_angle):.4f} tps={taper_scale:.4f} r={r:.4f} a={degrees(a):.4f}") # print(f"f: t={t:.4f} toffset={toffset:.4f} rh={rel_height:.4f} result={result}") return result return func