From 2bdae6df01a6ff5a7bbcd9b56ec886a674d228bf Mon Sep 17 00:00:00 2001 From: Leni Aniva Date: Sun, 21 Jul 2024 18:45:13 -0700 Subject: [PATCH] feat: Linear actuator in joint (preliminary) --- nhf/parts/box.py | 8 +++ nhf/touhou/houjuu_nue/electronics.py | 88 +++++++++++++++++++++++++--- nhf/touhou/houjuu_nue/joints.py | 61 ++++++++++++++++--- nhf/touhou/houjuu_nue/wing.py | 11 +++- poetry.lock | 2 +- pyproject.toml | 1 + 6 files changed, 155 insertions(+), 16 deletions(-) diff --git a/nhf/parts/box.py b/nhf/parts/box.py index 603eb2d..3498f6d 100644 --- a/nhf/parts/box.py +++ b/nhf/parts/box.py @@ -50,6 +50,8 @@ class MountingBox(Model): centred: Tuple[bool, bool] = (False, True) generate_side_tags: bool = True + # Generate tags on the opposite side + generate_reverse_tags: bool = False # Determines the position of side tags flip_y: bool = False @@ -81,9 +83,13 @@ class MountingBox(Model): .extrude(self.thickness) ) plane = result.copyWorkplane(Cq.Workplane('XY')).workplane(offset=self.thickness) + reverse_plane = result.copyWorkplane(Cq.Workplane('XY')) for i, hole in enumerate(self.holes): tag = hole.tag if hole.tag else f"conn{i}" plane.moveTo(hole.x, hole.y).tagPlane(tag) + if self.generate_reverse_tags: + rev_tag = hole.tag + "_rev" if hole.tag else f"conn{i}_rev" + reverse_plane.moveTo(hole.x, hole.y).tagPlane(rev_tag, '-Z') if self.generate_side_tags: result.faces("Z").val().Center()).tagPlane("left") @@ -102,6 +108,8 @@ class MountingBox(Model): ) for i in range(len(self.holes)): result.markPlane(f"box?conn{i}") + if self.generate_reverse_tags: + result.markPlane(f"box?conn{i}_rev") if self.generate_side_tags: ( result diff --git a/nhf/touhou/houjuu_nue/electronics.py b/nhf/touhou/houjuu_nue/electronics.py index 90d4574..26e9386 100644 --- a/nhf/touhou/houjuu_nue/electronics.py +++ b/nhf/touhou/houjuu_nue/electronics.py @@ -2,12 +2,14 @@ Electronic components """ from dataclasses import dataclass -from typing import Optional +from typing import Optional, Tuple +import math import cadquery as Cq from nhf.materials import Role from nhf.parts.item import Item from nhf.parts.fasteners import FlatHeadBolt, HexNut import nhf.utils +import scipy.optimize as SO @dataclass(frozen=True) class LinearActuator(Item): @@ -47,6 +49,7 @@ class LinearActuator(Item): return self.segment1_length + self.segment2_length + self.front_hole_ext + self.back_hole_ext def generate(self, pos: float=0) -> Cq.Assembly: + assert -1e-6 <= pos <= 1 + 1e-6 stroke_x = pos * self.stroke_length front = ( Cq.Workplane('XZ') @@ -204,10 +207,37 @@ class MountingBracket(Item): return result -LINEAR_ACTUATOR_SHOULDER = LinearActuator( +LINEAR_ACTUATOR_50 = LinearActuator( + mass=34.0, + stroke_length=50, + # FIXME: Measure + front_hole_ext=6, + back_hole_ext=6, + segment1_length=50, + segment2_length=50, +) +LINEAR_ACTUATOR_30 = LinearActuator( mass=34.0, stroke_length=30, ) +LINEAR_ACTUATOR_21 = LinearActuator( + # FIXME: Measure + mass=0.0, + stroke_length=21, + front_hole_ext=4, + back_hole_ext=4, + segment1_length=75/2, + segment2_length=75/2, +) +LINEAR_ACTUATOR_10 = LinearActuator( + # FIXME: Measure + mass=0.0, + stroke_length=10, + front_hole_ext=4.5/2, + back_hole_ext=4.5/2, + segment1_length=30.0, + segment2_length=30.0, +) LINEAR_ACTUATOR_HEX_NUT = HexNut( mass=0.8, diam_thread=4, @@ -224,18 +254,61 @@ LINEAR_ACTUATOR_BOLT = FlatHeadBolt( ) LINEAR_ACTUATOR_BRACKET = MountingBracket() -@dataclass(frozen=True) -class LinearActuatorAssembly: +@dataclass +class Flexor: + """ + Actuator assembly which flexes, similar to biceps + """ + motion_span: float - # FIXME: Measure - actuator: LinearActuator = LINEAR_ACTUATOR_SHOULDER + actuator: LinearActuator = LINEAR_ACTUATOR_30 nut: HexNut = LINEAR_ACTUATOR_HEX_NUT bolt: FlatHeadBolt = LINEAR_ACTUATOR_BOLT bracket: MountingBracket = LINEAR_ACTUATOR_BRACKET + # FIXME: Add a compression spring so the serviceable distances are not as fixed + mount_loc_r: float = float('nan') + mount_loc_angle: float = float('nan') + + def __post_init__(self): + d_open = self.actuator.conn_length + self.actuator.stroke_length + d_closed = self.actuator.conn_length + theta = math.radians(self.motion_span) + + def target(args): + r, phi = args + e1 = d_open * d_open - 2 * r * r * (1 - math.cos(theta + phi)) + e2 = d_closed * d_closed - 2 * r * r * (1 - math.cos(phi)) + return [e1, e2] + + self.mount_loc_r, phi = SO.fsolve(target, [self.actuator.conn_length, theta]) + self.mount_loc_angle = math.degrees(theta + phi) + + @property + def mount_height(self): + return self.bracket.hole_to_side_ext + @property + def min_serviceable_distance(self): + return self.bracket.hole_to_side_ext * 2 + self.actuator.conn_length + @property + def max_serviceable_distance(self): + return self.min_serviceable_distance + self.actuator.stroke_length + + def target_length_at_angle( + self, + angle: float = 0.0 + ) -> float: + # law of cosines + r = self.mount_loc_r + th = math.radians(self.mount_loc_angle - angle) + d2 = 2 * r * r * (1 - math.cos(th)) + return math.sqrt(d2) + + def add_to( self, a: Cq.Assembly, + target_length: float, tag_prefix: Optional[str] = None, tag_hole_front: Optional[str] = None, tag_hole_back: Optional[str] = None, @@ -244,6 +317,7 @@ class LinearActuatorAssembly: Adds the necessary mechanical components to this assembly. Does not invoke `a.solve()`. """ + pos = (target_length - self.actuator.conn_length) / self.actuator.stroke_length if tag_prefix: tag_prefix = tag_prefix + "_" name_actuator = f"{tag_prefix}actuator" @@ -255,7 +329,7 @@ class LinearActuatorAssembly: name_nut_back = f"{tag_prefix}back_nut" ( a - .add(self.actuator.assembly(), name=name_actuator) + .add(self.actuator.assembly(pos=pos), name=name_actuator) .add(self.bracket.assembly(), name=name_bracket_front) .add(self.bolt.assembly(), name=name_bolt_front) .add(self.nut.assembly(), name=name_nut_front) diff --git a/nhf/touhou/houjuu_nue/joints.py b/nhf/touhou/houjuu_nue/joints.py index a545d48..cbf600b 100644 --- a/nhf/touhou/houjuu_nue/joints.py +++ b/nhf/touhou/houjuu_nue/joints.py @@ -8,6 +8,7 @@ from nhf.parts.springs import TorsionSpring from nhf.parts.fasteners import FlatHeadBolt, HexNut, ThreaddedKnob from nhf.parts.joints import TorsionJoint, HirthJoint from nhf.parts.box import Hole, MountingBox, box_with_centre_holes +from nhf.touhou.houjuu_nue.electronics import Flexor import nhf.utils TOL = 1e-6 @@ -878,10 +879,15 @@ class ElbowJoint(Model): angle_neutral: float = 30.0 + flexor: Flexor = None + flexor_offset_angle: float = 30.0 + flexor_mount_rot: float = 95.0 + def __post_init__(self): assert self.child_arm_radius > self.disk_joint.radius_housing assert self.parent_arm_radius > self.disk_joint.radius_housing self.disk_joint.tongue_length = self.child_arm_radius - self.disk_joint.radius_disk - self.lip_thickness / 2 + self.flexor = Flexor(motion_span=self.motion_span) @property def total_thickness(self): @@ -897,15 +903,40 @@ class ElbowJoint(Model): axle is at position 0, and parent direction is -X """ return Cq.Location.from2d(-self.parent_arm_radius, 0, 0) - def child_arm_loc(self, flip: bool = False) -> Cq.Location: + def child_arm_loc(self, flip: bool = False, angle: float = 0.0) -> Cq.Location: """ 2d Location of the centre of the arm surface on the child side, assuming axle is at position 0, and parent direction is -X Set `flip=True` to indicate that the joint is supposed to be installed upside down """ - result = Cq.Location.rot2d(self.angle_neutral) * Cq.Location.from2d(self.child_arm_radius, 0, 180) + result = Cq.Location.rot2d(self.angle_neutral + angle) * Cq.Location.from2d(self.child_arm_radius, 0, 180) return result.flip_y() if flip else result + def actuator_mount(self) -> Cq.Workplane: + holes = [ + Hole(x=0, y=0, tag="mount"), + ] + mbox = MountingBox( + length=self.disk_joint.total_thickness, + width=self.disk_joint.total_thickness, + thickness=self.lip_thickness, + holes=holes, + hole_diam=self.hole_diam, + centred=(True, True), + generate_side_tags=False, + ) + return mbox.generate() + def actuator_mount_loc(self, child: bool) -> Cq.Location: + # Orientes the hole surface so it faces +X + loc_thickness = Cq.Location((-self.lip_thickness, 0, 0), (0, 1, 0), 90) + # Moves the hole so the axle of the mount is perpendicular to it + loc_mount = Cq.Location.from2d(self.flexor.mount_height, 0) * Cq.Location.rot2d(180) + loc_mount_orient = Cq.Location.rot2d(self.flexor_mount_rot * (-1 if child else 1)) + # Moves the hole to be some distance apart from 0 + loc_span = Cq.Location.from2d(self.flexor.mount_loc_r, 0) + r = 0 if child else self.flexor.mount_loc_angle + loc_rot = Cq.Location.rot2d(r + self.flexor_offset_angle) + return loc_rot * loc_span * loc_mount_orient * loc_mount * loc_thickness def lip(self) -> Cq.Workplane: holes = [ @@ -924,6 +955,7 @@ class ElbowJoint(Model): hole_diam=self.hole_diam, centred=(True, True), generate_side_tags=False, + generate_reverse_tags=True, ) return mbox.generate() @@ -946,8 +978,9 @@ class ElbowJoint(Model): loc_lip.inverse * loc_cut_rel * loc_disk) result = ( Cq.Assembly() - .add(self.lip().cut(disk_cut), name="lip", loc=loc_lip) - .add(self.disk_joint.disk(), name="disk", loc=loc_disk) + .add(self.disk_joint.disk(), name="disk") + .add(self.lip().cut(disk_cut), name="lip", loc=loc_disk.inverse * loc_lip) + .add(self.actuator_mount(), name="act", loc=self.actuator_mount_loc(child=True)) ) return result @@ -986,16 +1019,19 @@ class ElbowJoint(Model): -self.disk_joint.tongue_span / 2 + self.angle_neutral ) lip_dz = self.lip_thickness + loc_net_housing = axial_offset * housing_loc result = ( Cq.Assembly() + .add(housing, name="housing") .add(self.lip(), name="lip", loc= + loc_net_housing.inverse * Cq.Location((0, 0, 0), (0, 1, 0), 180) * Cq.Location((-lip_dz, 0, 0), (1, 0, 0), 90) * Cq.Location((0, 0, 0), (0, 1, 0), 90)) - .add(housing, name="housing", - loc=axial_offset * housing_loc) .add(connector, name="connector", - loc=axial_offset) + loc=loc_net_housing.inverse * axial_offset) + .add(self.actuator_mount(), + name="act", loc=self.actuator_mount_loc(child=False)) #.constrain("housing", "Fixed") #.constrain("connector", "Fixed") #.solve() @@ -1021,6 +1057,17 @@ class ElbowJoint(Model): disk="child/disk", angle=angle, ) + if self.flexor: + target_length = self.flexor.target_length_at_angle( + angle=angle, + ) + self.flexor.add_to( + result, + target_length=target_length, + tag_hole_back="parent_upper/act?mount", + tag_hole_front="child/act?mount", + tag_dir="parent_lower?mate", + ) return result.solve() if __name__ == '__main__': diff --git a/nhf/touhou/houjuu_nue/wing.py b/nhf/touhou/houjuu_nue/wing.py index 6f510e0..6941bee 100644 --- a/nhf/touhou/houjuu_nue/wing.py +++ b/nhf/touhou/houjuu_nue/wing.py @@ -795,7 +795,16 @@ class WingProfile(Model): fastener_pos: float = 0.0, ) -> Cq.Assembly(): if parts is None: - parts = ["root", "s0", "shoulder", "s1", "elbow", "s2", "wrist", "s3"] + parts = [ + "root", + "s0", + "shoulder", + "s1", + "elbow", + "s2", + "wrist", + "s3", + ] result = ( Cq.Assembly() ) diff --git a/poetry.lock b/poetry.lock index 88d30ec..0ea1413 100644 --- a/poetry.lock +++ b/poetry.lock @@ -842,4 +842,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "3403086281e26faefd12217e6dec4c0696e3468c5a9d8c952f8d988857aafba0" +content-hash = "6fc2644e7778ba22f8f5f2bcb2ca54f03b325f62c8a3fcd1c265a17561d874b8" diff --git a/pyproject.toml b/pyproject.toml index c24f3d8..7bc47b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ colorama = "^0.4.6" # cadquery dependency multimethod = "^1.12" +scipy = "^1.14.0" [build-system] requires = ["poetry-core"]