diff --git a/nhf/materials.py b/nhf/materials.py index 9408164..f87b796 100644 --- a/nhf/materials.py +++ b/nhf/materials.py @@ -43,7 +43,7 @@ ROLE_COLOR_MAP = { Role.PARENT: _color('blue4', 0.6), Role.CASING: _color('dodgerblue3', 0.6), Role.CHILD: _color('darkorange2', 0.6), - Role.DAMPING: _color('springgreen', 0.8), + Role.DAMPING: _color('springgreen', 1.0), Role.STRUCTURE: _color('gray', 0.4), Role.DECORATION: _color('lightseagreen', 0.4), Role.ELECTRONIC: _color('mediumorchid', 0.5), diff --git a/nhf/parts/joints.py b/nhf/parts/joints.py index 3ff76e9..594c2cb 100644 --- a/nhf/parts/joints.py +++ b/nhf/parts/joints.py @@ -1,8 +1,8 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Optional import math import cadquery as Cq -import nhf.parts.springs as springs +from nhf.parts.springs import TorsionSpring from nhf import Role import nhf.utils @@ -158,99 +158,6 @@ class HirthJoint: offset=offset) return result.solve() -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: """ @@ -268,6 +175,13 @@ class TorsionJoint: 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( + 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 @@ -275,7 +189,6 @@ class TorsionJoint: 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 @@ -283,12 +196,6 @@ class TorsionJoint: 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 @@ -301,23 +208,19 @@ class TorsionJoint: 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 + 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" @property def total_height(self): """ Total height counting from bottom to top """ - return self.track_disk_height + self.rider_disk_height + self.spring_height + return self.track_disk_height + self.rider_disk_height + self.spring.height @property def radius(self): @@ -326,28 +229,24 @@ class TorsionJoint: """ 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 + 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), + (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 = 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]]` @@ -356,16 +255,6 @@ class TorsionJoint: (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 = ( @@ -373,14 +262,14 @@ class TorsionJoint: .circle(self.radius_track) .circle(self.groove_radius_outer, mode='s') .circle(self.groove_radius_inner, mode='a') - .circle(self.radius_spring, mode='s') + .circle(self.spring.radius, mode='s') ) spring_hole_profile = ( Cq.Sketch() .circle(self.radius_track) - .circle(self.radius_spring, mode='s') + .circle(self.spring.radius, mode='s') ) - slot_height = self.spring_thickness + slot_height = self.spring.thickness if not self.spring_hole_cover_track: slot_height += self.groove_depth slot = ( @@ -400,7 +289,7 @@ class TorsionJoint: .faces('>Z') .tag("spring") .placeSketch(spring_hole_profile) - .extrude(self.spring_thickness) + .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') @@ -425,7 +314,7 @@ class TorsionJoint: wall_profile = ( Cq.Sketch() .circle(self.radius_rider, mode='a') - .circle(self.radius_spring, mode='s') + .circle(self.spring.radius, mode='s') .parray( r=0, a1=rider_slot_begin, @@ -451,7 +340,7 @@ class TorsionJoint: .reset() ) #.circle(self._radius_wall, mode='a') - middle_height = self.spring_height - self.groove_depth - self.rider_gap - self.spring_thickness + middle_height = self.spring.height - self.groove_depth - self.rider_gap - self.spring.thickness result = ( Cq.Workplane('XY') .cylinder( @@ -471,8 +360,8 @@ class TorsionJoint: .extrude(self.groove_depth + self.rider_gap) .faces(tag="spring") .workplane() - .circle(self._radius_spring_internal) - .extrude(self.spring_height) + .circle(self.spring.radius_inner) + .extrude(self.spring.height) .faces("Z").tag("top") - base.faces(" float: + return self.radius - self.thickness + + def torque_at(self, theta: float) -> float: + return self.torsion_rate * theta + + def generate(self, deflection: float = 0): + omega = self.angle_neutral + deflection + omega = -omega if self.right_handed else omega + base = ( + Cq.Workplane('XY') + .cylinder(height=self.height, radius=self.radius, + centered=(True, True, False)) + ) + base.faces(">Z").tag("top") + base.faces(" self.radius_axle assert self.housing_upper_carve_offset > 0 - 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=False, - ) - @property def neutral_movement_angle(self) -> Optional[float]: - a = self.spring_angle_neutral - self.spring_angle + a = self.spring.angle_neutral - self.spring_angle_at_0 if 0 <= a and a <= self.movement_angle: return a return None @@ -346,7 +342,7 @@ class DiskJoint(Model): """ Distance between the spring track and the outside of the upper housing """ - return self.housing_thickness + self.disk_thickness - self.spring_height + return self.housing_thickness + self.disk_thickness - self.spring.height @property def housing_upper_dz(self) -> float: @@ -355,42 +351,17 @@ class DiskJoint(Model): """ return self.total_thickness / 2 - self.housing_thickness - @property - def radius_spring_internal(self): - return self.radius_spring - self.spring_thickness - @target(name="disk") def disk(self) -> Cq.Workplane: cut = ( Cq.Solid.makeBox( - length=self.spring_tail_length, - width=self.spring_thickness, + length=self.spring.tail_length, + width=self.spring.thickness, height=self.disk_thickness, ) - .located(Cq.Location((0, self.radius_spring_internal, 0))) - .rotate((0, 0, 0), (0, 0, 1), self.spring_angle_shift) + .located(Cq.Location((0, self.spring.radius_inner, 0))) + .rotate((0, 0, 0), (0, 0, 1), self.spring_slot_offset) ) - result = ( - Cq.Workplane('XY') - .cylinder( - height=self.disk_thickness, - radius=self.radius_disk, - centered=(True, True, False) - ) - .copyWorkplane(Cq.Workplane('XY')) - .cylinder( - height=self.disk_thickness, - radius=self.radius_spring, - centered=(True, True, False), - combine='cut', - ) - .cut(cut) - ) - plane = result.copyWorkplane(Cq.Workplane('XY')) - plane.tagPlane("dir", direction="+X") - plane.workplane(offset=self.disk_thickness).tagPlane("mate_top") - result.copyWorkplane(Cq.Workplane('YX')).tagPlane("mate_bot") - radius_tongue = self.radius_disk + self.tongue_length tongue = ( Cq.Solid.makeCylinder( @@ -402,7 +373,29 @@ class DiskJoint(Model): radius=self.radius_disk, )) ) - result = result.union(tongue, tol=TOL) + result = ( + Cq.Workplane('XY') + .cylinder( + height=self.disk_thickness, + radius=self.radius_disk, + centered=(True, True, False) + ) + .union(tongue, tol=TOL) + .copyWorkplane(Cq.Workplane('XY')) + .cylinder( + height=self.disk_thickness, + radius=self.spring.radius, + centered=(True, True, False), + combine='cut', + ) + .cut(cut) + ) + plane = result.copyWorkplane(Cq.Workplane('XY')) + theta = math.radians(self.spring_slot_offset) + plane.tagPlane("dir", direction=(math.cos(theta), math.sin(theta), 0)) + plane.workplane(offset=self.disk_thickness).tagPlane("mate_top") + result.copyWorkplane(Cq.Workplane('YX')).tagPlane("mate_bot") + return result def wall(self) -> Cq.Compound: @@ -433,9 +426,6 @@ class DiskJoint(Model): ) result.faces(">Z").tag("mate") result.faces(">Z").workplane().tagPlane("dirX", direction="+X") - # two directional vectors are required to make the angle constrain - # unambiguous - result.faces(">Z").workplane().tagPlane("dirY", direction="+Y") result = result.cut( self .wall() @@ -447,16 +437,17 @@ class DiskJoint(Model): @target(name="housing-upper") def housing_upper(self) -> Cq.Workplane: + carve_angle = -(self.spring_angle_at_0 - self.spring_slot_offset) carve = ( Cq.Solid.makeCylinder( - radius=self.radius_spring, + radius=self.spring.radius, height=self.housing_thickness ).fuse(Cq.Solid.makeBox( - length=self.spring_tail_length, - width=self.spring_thickness, + length=self.spring.tail_length, + width=self.spring.thickness, height=self.housing_thickness - ).located(Cq.Location((0, -self.radius_spring, 0)))) - ).rotate((0, 0, 0), (0, 0, 1), self.spring_angle - self.spring_angle_shift) + ).located(Cq.Location((0, -self.spring.radius, 0)))) + ).rotate((0, 0, 0), (0, 0, 1), carve_angle) result = ( Cq.Workplane('XY') .cylinder( @@ -465,8 +456,11 @@ class DiskJoint(Model): centered=(True, True, False), ) ) + theta = math.radians(carve_angle) result.faces("Z").hole(self.radius_axle * 2) # tube which holds the spring interior @@ -490,28 +484,42 @@ class DiskJoint(Model): .cut(carve.located(Cq.Location((0, 0, -self.housing_upper_carve_offset)))) .union(wall, tol=TOL) ) - return result + return result.clean() def add_constraints(self, assembly: Cq.Assembly, housing_lower: str, housing_upper: str, disk: str, - angle: float, + angle: float = 0.0, ) -> Cq.Assembly: - return ( + deflection = angle - self.neutral_movement_angle + spring_name = disk.replace("/", "__Z") + "_spring" + ( assembly + .addS( + self.spring.generate(deflection=-deflection), + name=spring_name, + role=Role.DAMPING, + material=Material.STEEL_SPRING) .constrain(f"{disk}?mate_bot", f"{housing_lower}?mate", "Plane") .constrain(f"{disk}?mate_top", f"{housing_upper}?mate", "Plane") - .constrain(f"{housing_lower}?dirX", f"{housing_upper}?dir", "Axis", param=0) - .constrain(f"{housing_lower}?dirX", f"{disk}?dir", "Axis", param=angle) - .constrain(f"{housing_lower}?dirY", f"{disk}?dir", "Axis", param=angle - 90) + .constrain(f"{housing_lower}?dirX", f"{housing_upper}?dirX", "Axis", param=0) + .constrain(f"{housing_upper}?dir", f"{spring_name}?dir_top", "Axis", param=0) + .constrain(f"{spring_name}?dir_bot", f"{disk}?dir", "Axis", param=0) + .constrain(f"{disk}?mate_bot", f"{spring_name}?bot", "Plane", param=0) + #.constrain(f"{housing_lower}?dirX", f"{housing_upper}?dir", "Axis", param=0) + #.constrain(f"{housing_lower}?dirX", f"{disk}?dir", "Axis", param=angle) + #.constrain(f"{housing_lower}?dirY", f"{disk}?dir", "Axis", param=angle - 90) + ) + return ( + assembly ) def assembly(self, angle: Optional[float] = 0) -> Cq.Assembly: if angle is None: - angle = self.movement_angle + angle = self.neutral_movement_angle if angle is None: angle = 0 else: @@ -521,7 +529,7 @@ class DiskJoint(Model): .addS(self.disk(), name="disk", role=Role.CHILD) .addS(self.housing_lower(), name="housing_lower", role=Role.PARENT) .addS(self.housing_upper(), name="housing_upper", role=Role.CASING) - #.constrain("housing_lower", "Fixed") + .constrain("housing_lower", "Fixed") ) result = self.add_constraints( result, @@ -627,10 +635,15 @@ class ElbowJoint: .rotate((0,0,0), (0,0,1), 180-self.parent_arm_span / 2) ) housing = self.disk_joint.housing_upper() + housing_loc = Cq.Location( + (0, 0, housing_dz), + (0, 0, 1), + -self.disk_joint.tongue_span / 2 + ) result = ( self.parent_beam.beam() .add(housing, name="housing", - loc=axial_offset * Cq.Location((0, 0, housing_dz))) + loc=axial_offset * housing_loc) .add(connector, name="connector", loc=axial_offset) #.constrain("housing", "Fixed") diff --git a/nhf/touhou/houjuu_nue/wing.py b/nhf/touhou/houjuu_nue/wing.py index b520384..99fcde2 100644 --- a/nhf/touhou/houjuu_nue/wing.py +++ b/nhf/touhou/houjuu_nue/wing.py @@ -545,7 +545,8 @@ class WingProfile(Model): return result.solve() def assembly(self, - parts: Optional[list[str]] = None + parts: Optional[list[str]] = None, + angle_elbow_wrist: float = 0.0, ) -> Cq.Assembly(): if parts is None: parts = ["s0", "shoulder", "s1", "elbow", "s2", "wrist", "s3"] @@ -575,7 +576,7 @@ class WingProfile(Model): .constrain("s1/shoulder_bot?conn1", "shoulder/child/lip_bot?conn1", "Plane") ) if "elbow" in parts: - result.add(self.elbow_joint.assembly(), name="elbow") + result.add(self.elbow_joint.assembly(angle=angle_elbow_wrist), name="elbow") if "s1" in parts and "elbow" in parts: ( result @@ -595,24 +596,25 @@ class WingProfile(Model): .constrain("s2/elbow_bot?conn1", "elbow/child/bot?conn1", "Plane") ) if "wrist" in parts: - result.add(self.wrist_joint.assembly(), name="wrist") + result.add(self.wrist_joint.assembly(angle=angle_elbow_wrist), name="wrist") if "s2" in parts and "wrist" in parts: + # Mounted backwards to bend in other direction ( result - .constrain("s2/wrist_top?conn0", "wrist/parent_upper/top?conn0", "Plane") - .constrain("s2/wrist_top?conn1", "wrist/parent_upper/top?conn1", "Plane") - .constrain("s2/wrist_bot?conn0", "wrist/parent_upper/bot?conn0", "Plane") - .constrain("s2/wrist_bot?conn1", "wrist/parent_upper/bot?conn1", "Plane") + .constrain("s2/wrist_top?conn0", "wrist/parent_upper/bot?conn0", "Plane") + .constrain("s2/wrist_top?conn1", "wrist/parent_upper/bot?conn1", "Plane") + .constrain("s2/wrist_bot?conn0", "wrist/parent_upper/top?conn0", "Plane") + .constrain("s2/wrist_bot?conn1", "wrist/parent_upper/top?conn1", "Plane") ) if "s3" in parts: result.add(self.assembly_s3(), name="s3") if "s3" in parts and "wrist" in parts: ( result - .constrain("s3/wrist_top?conn0", "wrist/child/top?conn0", "Plane") - .constrain("s3/wrist_top?conn1", "wrist/child/top?conn1", "Plane") - .constrain("s3/wrist_bot?conn0", "wrist/child/bot?conn0", "Plane") - .constrain("s3/wrist_bot?conn1", "wrist/child/bot?conn1", "Plane") + .constrain("s3/wrist_top?conn0", "wrist/child/bot?conn0", "Plane") + .constrain("s3/wrist_top?conn1", "wrist/child/bot?conn1", "Plane") + .constrain("s3/wrist_bot?conn0", "wrist/child/top?conn0", "Plane") + .constrain("s3/wrist_bot?conn1", "wrist/child/top?conn1", "Plane") ) if len(parts) > 1: result.solve()