Cosplay/nhf/parts/joints.py

512 lines
16 KiB
Python

from dataclasses import dataclass
from typing import Optional
import math
import cadquery as Cq
import nhf.parts.springs as springs
from nhf import Role
import nhf.utils
TOL = 1e-6
@dataclass
class HirthJoint:
"""
A Hirth joint attached to a cylindrical base
"""
# r
radius: float = 60
# r_i
radius_inner: float = 40
base_height: float = 20
n_tooth: float = 16
# h_o
tooth_height: float = 16
def __post_init__(self):
# Ensures tangent doesn't blow up
assert self.n_tooth >= 5
assert self.radius > self.radius_inner
@property
def tooth_angle(self):
return 360 / self.n_tooth
@property
def total_height(self):
return self.base_height + self.tooth_height
def generate(self, is_mated=False, tol=0.01):
"""
is_mated: If set to true, rotate the teeth so they line up at 0 degrees.
FIXME: Mate is not exact when number of tooth is low
"""
phi = math.radians(self.tooth_angle)
alpha = 2 * math.atan(self.radius / self.tooth_height * math.tan(phi/2))
#alpha = math.atan(self.radius * math.radians(180 / self.n_tooth) / self.tooth_height)
gamma = math.radians(90 / self.n_tooth)
# Tooth half height
l = self.radius * math.cos(gamma)
a = self.radius * math.sin(gamma)
t = a / math.tan(alpha / 2)
beta = math.asin(t / l)
dx = self.tooth_height * math.tan(alpha / 2)
profile = (
Cq.Workplane('YZ')
.polyline([
(0, 0),
(dx, self.tooth_height),
(-dx, self.tooth_height),
])
.close()
.extrude(-self.radius)
.val()
.rotate((0, 0, 0), (0, 1, 0), math.degrees(beta))
.moved(Cq.Location((0, 0, self.base_height)))
)
core = Cq.Solid.makeCylinder(
radius=self.radius_inner,
height=self.tooth_height,
pnt=(0, 0, self.base_height),
)
angle_offset = self.tooth_angle / 2 if is_mated else 0
result = (
Cq.Workplane('XY')
.cylinder(
radius=self.radius,
height=self.base_height + self.tooth_height,
centered=(True, True, False))
.faces(">Z")
.tag("bore")
.cut(core)
.polarArray(
radius=self.radius,
startAngle=angle_offset,
angle=360,
count=self.n_tooth)
.cutEach(
lambda loc: profile.moved(loc),
)
)
(
result
.polyline([
(0, 0, self.base_height),
(0, 0, self.base_height + self.tooth_height)
], forConstruction=True)
.tag("mate")
)
(
result
.polyline([(0, 0, 0), (1, 0, 0)], forConstruction=True)
.tag("dir")
)
return result
def assembly(self, offset: int = 1):
"""
Generate an example assembly
"""
tab = (
Cq.Workplane('XY')
.box(100, 10, 2, centered=False)
)
obj1 = (
self.generate()
.faces(tag="bore")
.cboreHole(
diameter=10,
cboreDiameter=20,
cboreDepth=3)
.union(tab)
)
obj2 = (
self.generate(is_mated=True)
.union(tab)
)
angle = offset * self.tooth_angle
result = (
Cq.Assembly()
.add(obj1, name="obj1", color=Role.PARENT.color)
.add(obj2, name="obj2", color=Role.CHILD.color)
.constrain("obj1", "Fixed")
.constrain("obj1?mate", "obj2?mate", "Plane")
.constrain("obj1?dir", "obj2?dir", "Axis", param=angle)
.solve()
)
return result
def comma_joint(radius=30,
shaft_radius=10,
height=10,
flange=10,
flange_thickness=25,
n_serration=16,
serration_angle_offset=0,
serration_height=5,
serration_inner_radius=20,
serration_theta=2 * math.pi / 48,
serration_tilt=-30,
right_handed=False):
"""
Produces a "o_" shaped joint, with serrations to accomodate a torsion spring
"""
assert flange_thickness <= radius
flange_poly = [
(0, radius - flange_thickness),
(0, radius),
(flange + radius, radius),
(flange + radius, radius - flange_thickness)
]
if right_handed:
flange_poly = [(x, -y) for x,y in flange_poly]
sketch = (
Cq.Sketch()
.circle(radius)
.polygon(flange_poly, mode='a')
.circle(shaft_radius, mode='s')
)
serration_poly = [
(0, 0), (radius, 0),
(radius, radius * math.tan(serration_theta))
]
serration = (
Cq.Workplane('XY')
.sketch()
.polygon(serration_poly)
.circle(radius, mode='i')
.circle(serration_inner_radius, mode='s')
.finalize()
.extrude(serration_height)
.translate(Cq.Vector((-serration_inner_radius, 0, height)))
.rotate(
axisStartPoint=(0, 0, 0),
axisEndPoint=(0, 0, height),
angleDegrees=serration_tilt)
.val()
)
serrations = (
Cq.Workplane('XY')
.polarArray(radius=serration_inner_radius,
startAngle=0+serration_angle_offset,
angle=360+serration_angle_offset,
count=n_serration)
.eachpoint(lambda loc: serration.located(loc))
)
result = (
Cq.Workplane()
.add(sketch)
.extrude(height)
.union(serrations)
.clean()
)
result.polyline([
(0, 0, height - serration_height),
(0, 0, height + serration_height)],
forConstruction=True).tag("serrated")
result.polyline([
(0, radius, 0),
(flange + radius, radius, 0)],
forConstruction=True).tag("tail")
result.faces('>X').tag("tail_end")
return result
def comma_assembly():
joint1 = comma_joint()
joint2 = comma_joint()
spring = springs.torsion_spring()
result = (
Cq.Assembly()
.add(joint1, name="joint1", color=Cq.Color(0.8,0.8,0.5,0.3))
.add(joint2, name="joint2", color=Cq.Color(0.8,0.8,0.5,0.3))
.add(spring, name="spring", color=Cq.Color(0.5,0.5,0.5,1))
.constrain("joint1?serrated", "spring?bot", "Plane")
.constrain("joint2?serrated", "spring?top", "Plane")
.constrain("joint1?tail", "FixedAxis", (1, 0, 0))
.constrain("joint2?tail", "FixedAxis", (-1, 0, 0))
.solve()
)
return result
@dataclass
class TorsionJoint:
"""
This jonit consists of a rider puck on a track puck. IT is best suited if
the radius has to be small and vertical space is abundant.
The rider part consists of:
1. A cylinderical base
2. A annular extrusion with the same radius as the base, but with slots
carved in
3. An annular rider
The track part consists of:
1. A cylindrical base
2. A slotted annular extrusion where the slot allows the spring to rest
3. An outer and an inner annuli which forms a track the rider can move on
"""
# Radius limit for rotating components
radius_track: float = 40
radius_rider: float = 38
track_disk_height: float = 10
rider_disk_height: float = 8
radius_spring: float = 15
radius_axle: float = 6
# If true, cover the spring hole. May make it difficult to insert the spring
# considering the stiffness of torsion spring steel.
spring_hole_cover_track: bool = False
spring_hole_cover_rider: bool = False
# Also used for the height of the hole for the spring
spring_thickness: float = 2
spring_height: float = 15
spring_tail_length: float = 35
groove_radius_outer: float = 35
groove_radius_inner: float = 20
# Gap on inner groove to ease movement
groove_inner_gap: float = 0.2
groove_depth: float = 5
rider_gap: float = 1
rider_n_slots: float = 4
# Degrees of the first and last rider slots
rider_slot_begin: float = 0
rider_slot_span: float = 90
right_handed: bool = False
def __post_init__(self):
assert self.radius_track > self.groove_radius_outer
assert self.radius_rider > self.groove_radius_outer
assert self.groove_radius_outer > self.groove_radius_inner + self.groove_inner_gap
assert self.groove_radius_inner > self.radius_spring
assert self.spring_height > self.groove_depth, "Groove is too deep"
assert self.radius_spring > self.radius_axle
@property
def total_height(self):
"""
Total height counting from bottom to top
"""
return self.track_disk_height + self.rider_disk_height + self.spring_height
@property
def radius(self):
"""
Maximum radius of this joint
"""
return max(self.radius_rider, self.radius_track)
@property
def _radius_spring_internal(self):
return self.radius_spring - self.spring_thickness
def _slot_polygon(self, flip: bool=False):
r1 = self.radius_spring - self.spring_thickness
r2 = self.radius_spring
flip = flip != self.right_handed
if flip:
r1 = -r1
r2 = -r2
return [
(0, r2),
(self.spring_tail_length, r2),
(self.spring_tail_length, r1),
(0, r1),
]
def _directrix(self, height, theta=0):
c, s = math.cos(theta), math.sin(theta)
r2 = self.radius_spring
l = self.spring_tail_length
if self.right_handed:
r2 = -r2
# This is (0, r2) and (l, r2) transformed by right handed rotation
# matrix `[[c, -s], [s, c]]`
return [
(-s * r2, c * r2, height),
(c * l - s * r2, s * l + c * r2, height),
]
def spring(self):
return springs.torsion_spring(
radius=self.radius_spring,
height=self.spring_height,
thickness=self.spring_thickness,
tail_length=self.spring_tail_length,
right_handed=self.right_handed,
)
def track(self):
# TODO: Cover outer part of track only. Can we do this?
groove_profile = (
Cq.Sketch()
.circle(self.radius_track)
.circle(self.groove_radius_outer, mode='s')
.circle(self.groove_radius_inner, mode='a')
.circle(self.radius_spring, mode='s')
)
spring_hole_profile = (
Cq.Sketch()
.circle(self.radius_track)
.circle(self.radius_spring, mode='s')
)
slot_height = self.spring_thickness
if not self.spring_hole_cover_track:
slot_height += self.groove_depth
slot = (
Cq.Workplane('XY')
.sketch()
.polygon(self._slot_polygon(flip=False))
.finalize()
.extrude(slot_height)
.val()
)
result = (
Cq.Workplane('XY')
.cylinder(
radius=self.radius_track,
height=self.track_disk_height,
centered=(True, True, False))
.faces('>Z')
.tag("spring")
.placeSketch(spring_hole_profile)
.extrude(self.spring_thickness)
# If the spring hole profile is not simply connected, this workplane
# will have to be created from the `spring-mate` face.
.faces('>Z')
.placeSketch(groove_profile)
.extrude(self.groove_depth)
.faces('>Z')
.hole(self.radius_axle * 2)
.cut(slot.moved(Cq.Location((0, 0, self.track_disk_height))))
)
# Insert directrix
result.polyline(self._directrix(self.track_disk_height),
forConstruction=True).tag("dir")
return result
def rider(self, rider_slot_begin=None, reverse_directrix_label=False):
if not rider_slot_begin:
rider_slot_begin = self.rider_slot_begin
def slot(loc):
wire = Cq.Wire.makePolygon(self._slot_polygon(flip=False))
face = Cq.Face.makeFromWires(wire)
return face.located(loc)
wall_profile = (
Cq.Sketch()
.circle(self.radius_rider, mode='a')
.circle(self.radius_spring, mode='s')
.parray(
r=0,
a1=rider_slot_begin,
da=self.rider_slot_span,
n=self.rider_n_slots)
.each(slot, mode='s')
#.circle(self._radius_wall, mode='a')
)
contact_profile = (
Cq.Sketch()
.circle(self.groove_radius_outer, mode='a')
.circle(self.groove_radius_inner + self.groove_inner_gap, mode='s')
)
if not self.spring_hole_cover_rider:
contact_profile = (
contact_profile
.parray(
r=0,
a1=rider_slot_begin,
da=self.rider_slot_span,
n=self.rider_n_slots)
.each(slot, mode='s')
.reset()
)
#.circle(self._radius_wall, mode='a')
middle_height = self.spring_height - self.groove_depth - self.rider_gap - self.spring_thickness
result = (
Cq.Workplane('XY')
.cylinder(
radius=self.radius_rider,
height=self.rider_disk_height,
centered=(True, True, False))
.faces('>Z')
.tag("spring")
.workplane()
.placeSketch(wall_profile)
.extrude(middle_height)
.faces(tag="spring")
.workplane()
# The top face might not be in one piece.
.workplane(offset=middle_height)
.placeSketch(contact_profile)
.extrude(self.groove_depth + self.rider_gap)
.faces(tag="spring")
.workplane()
.circle(self._radius_spring_internal)
.extrude(self.spring_height)
.faces("<Z")
.workplane()
.hole(self.radius_axle * 2)
)
theta_begin = -math.radians(rider_slot_begin)
theta_span = math.radians(self.rider_slot_span)
if abs(math.remainder(self.rider_slot_span, 360)) < TOL:
theta_step = theta_span / self.rider_n_slots
else:
theta_step = theta_span / (self.rider_n_slots - 1)
for i in range(self.rider_n_slots):
theta = theta_begin - i * theta_step
j = self.rider_n_slots - i - 1 if reverse_directrix_label else i
result.polyline(self._directrix(self.rider_disk_height, theta),
forConstruction=True).tag(f"dir{j}")
return result
def rider_track_assembly(self, directrix=0):
rider = self.rider()
track = self.track()
spring = self.spring()
result = (
Cq.Assembly()
.add(spring, name="spring", color=Role.DAMPING.color)
.add(track, name="track", color=Role.PARENT.color)
.add(rider, name="rider", color=Role.CHILD.color)
)
TorsionJoint.add_constraints(
result,
rider="rider", track="track", spring="spring",
directrix=directrix)
return result.solve()
@staticmethod
def add_constraints(assembly: Cq.Assembly,
spring: str,
rider: Optional[str] = None,
track: Optional[str] = None,
directrix: int = 0):
"""
Add the necessary constraints to a RT assembly
"""
if track:
(
assembly
.constrain(f"{track}?spring", f"{spring}?top", "Plane")
.constrain(f"{track}?dir", f"{spring}?dir_top",
"Axis", param=0)
)
if rider:
(
assembly
.constrain(f"{rider}?spring", f"{spring}?bot", "Plane")
.constrain(f"{rider}?dir{directrix}", f"{spring}?dir_bot",
"Axis", param=0)
)