432 lines
14 KiB
Python
432 lines
14 KiB
Python
from dataclasses import dataclass, field
|
|
from typing import Optional
|
|
import math
|
|
import cadquery as Cq
|
|
from nhf.parts.springs import TorsionSpring
|
|
from nhf import Role
|
|
import nhf.utils
|
|
|
|
TOL = 1e-6
|
|
|
|
@dataclass(frozen=True)
|
|
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
|
|
|
|
@property
|
|
def joint_height(self):
|
|
return 2 * 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("dirX")
|
|
)
|
|
(
|
|
result
|
|
.polyline([(0, 0, 0), (0, 1, 0)], forConstruction=True)
|
|
.tag("dirY")
|
|
)
|
|
return result
|
|
|
|
def add_constraints(self,
|
|
assembly: Cq.Assembly,
|
|
parent: str,
|
|
child: str,
|
|
offset: int = 0):
|
|
angle = offset * self.tooth_angle
|
|
(
|
|
assembly
|
|
.constrain(f"{parent}?mate", f"{child}?mate", "Plane")
|
|
.constrain(f"{parent}?dirX", f"{child}?dirX",
|
|
"Axis", param=angle)
|
|
.constrain(f"{parent}?dirY", f"{child}?dirX",
|
|
"Axis", param=90 - angle)
|
|
)
|
|
|
|
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)
|
|
)
|
|
result = (
|
|
Cq.Assembly()
|
|
.addS(obj1, name="obj1", role=Role.PARENT)
|
|
.addS(obj2, name="obj2", role=Role.CHILD)
|
|
)
|
|
self.add_constraints(
|
|
result,
|
|
parent="obj1",
|
|
child="obj2",
|
|
offset=offset)
|
|
return result.solve()
|
|
|
|
@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
|
|
"""
|
|
spring: TorsionSpring = field(default_factory=lambda: TorsionSpring(
|
|
mass=float('nan'),
|
|
radius=10.0,
|
|
thickness=2.0,
|
|
height=15.0,
|
|
tail_length=35.0,
|
|
right_handed=False,
|
|
))
|
|
|
|
# Radius limit for rotating components
|
|
radius_track: float = 40
|
|
radius_rider: float = 38
|
|
track_disk_height: float = 10
|
|
rider_disk_height: float = 8
|
|
|
|
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
|
|
|
|
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
|
|
|
|
|
|
def __post_init__(self):
|
|
assert self.radius_track > self.groove_radius_outer
|
|
assert self.radius_rider > self.groove_radius_outer > self.groove_radius_inner + self.groove_inner_gap
|
|
assert self.groove_radius_inner > self.spring.radius > self.radius_axle
|
|
assert self.spring.height > self.groove_depth, "Groove is too deep"
|
|
assert self.groove_depth < self.spring.height - self.spring.thickness * 2
|
|
if self.rider_n_slots == 1:
|
|
assert self.rider_slot_span == 0.0, "Non-zero span is impossible with multiple riders"
|
|
|
|
@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)
|
|
|
|
def _slot_polygon(self, flip: bool=False):
|
|
r1 = self.spring.radius_inner
|
|
r2 = self.spring.radius
|
|
flip = flip != self.spring.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.spring.radius
|
|
l = self.spring.tail_length
|
|
if self.spring.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 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.spring.radius, mode='s')
|
|
)
|
|
spring_hole_profile = (
|
|
Cq.Sketch()
|
|
.circle(self.radius_track)
|
|
.circle(self.spring.radius, 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))))
|
|
)
|
|
result.faces("<Z").tag("bot")
|
|
# 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.spring.radius, 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.spring.radius_inner)
|
|
.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 self.rider_n_slots <= 1:
|
|
theta_step = 0
|
|
elif 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: int = 0, deflection: float = 0):
|
|
rider = self.rider()
|
|
track = self.track()
|
|
spring = self.spring.assembly(deflection=deflection)
|
|
result = (
|
|
Cq.Assembly()
|
|
.addS(spring, name="spring", role=Role.DAMPING)
|
|
.addS(track, name="track", role=Role.PARENT)
|
|
.addS(rider, name="rider", role=Role.CHILD)
|
|
)
|
|
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)
|
|
)
|