cosplay: Touhou/Houjuu Nue #4
|
@ -50,6 +50,8 @@ class MountingBox(Model):
|
||||||
centred: Tuple[bool, bool] = (False, True)
|
centred: Tuple[bool, bool] = (False, True)
|
||||||
|
|
||||||
generate_side_tags: bool = True
|
generate_side_tags: bool = True
|
||||||
|
# Generate tags on the opposite side
|
||||||
|
generate_reverse_tags: bool = False
|
||||||
|
|
||||||
# Determines the position of side tags
|
# Determines the position of side tags
|
||||||
flip_y: bool = False
|
flip_y: bool = False
|
||||||
|
@ -81,9 +83,13 @@ class MountingBox(Model):
|
||||||
.extrude(self.thickness)
|
.extrude(self.thickness)
|
||||||
)
|
)
|
||||||
plane = result.copyWorkplane(Cq.Workplane('XY')).workplane(offset=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):
|
for i, hole in enumerate(self.holes):
|
||||||
tag = hole.tag if hole.tag else f"conn{i}"
|
tag = hole.tag if hole.tag else f"conn{i}"
|
||||||
plane.moveTo(hole.x, hole.y).tagPlane(tag)
|
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:
|
if self.generate_side_tags:
|
||||||
result.faces("<Y").workplane(origin=result.vertices("<X and <Y and >Z").val().Center()).tagPlane("left")
|
result.faces("<Y").workplane(origin=result.vertices("<X and <Y and >Z").val().Center()).tagPlane("left")
|
||||||
|
@ -102,6 +108,8 @@ class MountingBox(Model):
|
||||||
)
|
)
|
||||||
for i in range(len(self.holes)):
|
for i in range(len(self.holes)):
|
||||||
result.markPlane(f"box?conn{i}")
|
result.markPlane(f"box?conn{i}")
|
||||||
|
if self.generate_reverse_tags:
|
||||||
|
result.markPlane(f"box?conn{i}_rev")
|
||||||
if self.generate_side_tags:
|
if self.generate_side_tags:
|
||||||
(
|
(
|
||||||
result
|
result
|
||||||
|
|
|
@ -2,12 +2,14 @@
|
||||||
Electronic components
|
Electronic components
|
||||||
"""
|
"""
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Optional
|
from typing import Optional, Tuple
|
||||||
|
import math
|
||||||
import cadquery as Cq
|
import cadquery as Cq
|
||||||
from nhf.materials import Role
|
from nhf.materials import Role
|
||||||
from nhf.parts.item import Item
|
from nhf.parts.item import Item
|
||||||
from nhf.parts.fasteners import FlatHeadBolt, HexNut
|
from nhf.parts.fasteners import FlatHeadBolt, HexNut
|
||||||
import nhf.utils
|
import nhf.utils
|
||||||
|
import scipy.optimize as SO
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class LinearActuator(Item):
|
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
|
return self.segment1_length + self.segment2_length + self.front_hole_ext + self.back_hole_ext
|
||||||
|
|
||||||
def generate(self, pos: float=0) -> Cq.Assembly:
|
def generate(self, pos: float=0) -> Cq.Assembly:
|
||||||
|
assert -1e-6 <= pos <= 1 + 1e-6
|
||||||
stroke_x = pos * self.stroke_length
|
stroke_x = pos * self.stroke_length
|
||||||
front = (
|
front = (
|
||||||
Cq.Workplane('XZ')
|
Cq.Workplane('XZ')
|
||||||
|
@ -204,10 +207,37 @@ class MountingBracket(Item):
|
||||||
return result
|
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,
|
mass=34.0,
|
||||||
stroke_length=30,
|
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(
|
LINEAR_ACTUATOR_HEX_NUT = HexNut(
|
||||||
mass=0.8,
|
mass=0.8,
|
||||||
diam_thread=4,
|
diam_thread=4,
|
||||||
|
@ -224,18 +254,61 @@ LINEAR_ACTUATOR_BOLT = FlatHeadBolt(
|
||||||
)
|
)
|
||||||
LINEAR_ACTUATOR_BRACKET = MountingBracket()
|
LINEAR_ACTUATOR_BRACKET = MountingBracket()
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass
|
||||||
class LinearActuatorAssembly:
|
class Flexor:
|
||||||
|
"""
|
||||||
|
Actuator assembly which flexes, similar to biceps
|
||||||
|
"""
|
||||||
|
motion_span: float
|
||||||
|
|
||||||
# FIXME: Measure
|
actuator: LinearActuator = LINEAR_ACTUATOR_30
|
||||||
actuator: LinearActuator = LINEAR_ACTUATOR_SHOULDER
|
|
||||||
nut: HexNut = LINEAR_ACTUATOR_HEX_NUT
|
nut: HexNut = LINEAR_ACTUATOR_HEX_NUT
|
||||||
bolt: FlatHeadBolt = LINEAR_ACTUATOR_BOLT
|
bolt: FlatHeadBolt = LINEAR_ACTUATOR_BOLT
|
||||||
bracket: MountingBracket = LINEAR_ACTUATOR_BRACKET
|
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(
|
def add_to(
|
||||||
self,
|
self,
|
||||||
a: Cq.Assembly,
|
a: Cq.Assembly,
|
||||||
|
target_length: float,
|
||||||
tag_prefix: Optional[str] = None,
|
tag_prefix: Optional[str] = None,
|
||||||
tag_hole_front: Optional[str] = None,
|
tag_hole_front: Optional[str] = None,
|
||||||
tag_hole_back: 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
|
Adds the necessary mechanical components to this assembly. Does not
|
||||||
invoke `a.solve()`.
|
invoke `a.solve()`.
|
||||||
"""
|
"""
|
||||||
|
pos = (target_length - self.actuator.conn_length) / self.actuator.stroke_length
|
||||||
if tag_prefix:
|
if tag_prefix:
|
||||||
tag_prefix = tag_prefix + "_"
|
tag_prefix = tag_prefix + "_"
|
||||||
name_actuator = f"{tag_prefix}actuator"
|
name_actuator = f"{tag_prefix}actuator"
|
||||||
|
@ -255,7 +329,7 @@ class LinearActuatorAssembly:
|
||||||
name_nut_back = f"{tag_prefix}back_nut"
|
name_nut_back = f"{tag_prefix}back_nut"
|
||||||
(
|
(
|
||||||
a
|
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.bracket.assembly(), name=name_bracket_front)
|
||||||
.add(self.bolt.assembly(), name=name_bolt_front)
|
.add(self.bolt.assembly(), name=name_bolt_front)
|
||||||
.add(self.nut.assembly(), name=name_nut_front)
|
.add(self.nut.assembly(), name=name_nut_front)
|
||||||
|
|
|
@ -8,6 +8,7 @@ from nhf.parts.springs import TorsionSpring
|
||||||
from nhf.parts.fasteners import FlatHeadBolt, HexNut, ThreaddedKnob
|
from nhf.parts.fasteners import FlatHeadBolt, HexNut, ThreaddedKnob
|
||||||
from nhf.parts.joints import TorsionJoint, HirthJoint
|
from nhf.parts.joints import TorsionJoint, HirthJoint
|
||||||
from nhf.parts.box import Hole, MountingBox, box_with_centre_holes
|
from nhf.parts.box import Hole, MountingBox, box_with_centre_holes
|
||||||
|
from nhf.touhou.houjuu_nue.electronics import Flexor
|
||||||
import nhf.utils
|
import nhf.utils
|
||||||
|
|
||||||
TOL = 1e-6
|
TOL = 1e-6
|
||||||
|
@ -878,10 +879,15 @@ class ElbowJoint(Model):
|
||||||
|
|
||||||
angle_neutral: float = 30.0
|
angle_neutral: float = 30.0
|
||||||
|
|
||||||
|
flexor: Flexor = None
|
||||||
|
flexor_offset_angle: float = 30.0
|
||||||
|
flexor_mount_rot: float = 95.0
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
assert self.child_arm_radius > self.disk_joint.radius_housing
|
assert self.child_arm_radius > self.disk_joint.radius_housing
|
||||||
assert self.parent_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.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
|
@property
|
||||||
def total_thickness(self):
|
def total_thickness(self):
|
||||||
|
@ -897,15 +903,40 @@ class ElbowJoint(Model):
|
||||||
axle is at position 0, and parent direction is -X
|
axle is at position 0, and parent direction is -X
|
||||||
"""
|
"""
|
||||||
return Cq.Location.from2d(-self.parent_arm_radius, 0, 0)
|
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
|
2d Location of the centre of the arm surface on the child side, assuming
|
||||||
axle is at position 0, and parent direction is -X
|
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
|
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
|
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:
|
def lip(self) -> Cq.Workplane:
|
||||||
holes = [
|
holes = [
|
||||||
|
@ -924,6 +955,7 @@ class ElbowJoint(Model):
|
||||||
hole_diam=self.hole_diam,
|
hole_diam=self.hole_diam,
|
||||||
centred=(True, True),
|
centred=(True, True),
|
||||||
generate_side_tags=False,
|
generate_side_tags=False,
|
||||||
|
generate_reverse_tags=True,
|
||||||
)
|
)
|
||||||
return mbox.generate()
|
return mbox.generate()
|
||||||
|
|
||||||
|
@ -946,8 +978,9 @@ class ElbowJoint(Model):
|
||||||
loc_lip.inverse * loc_cut_rel * loc_disk)
|
loc_lip.inverse * loc_cut_rel * loc_disk)
|
||||||
result = (
|
result = (
|
||||||
Cq.Assembly()
|
Cq.Assembly()
|
||||||
.add(self.lip().cut(disk_cut), name="lip", loc=loc_lip)
|
.add(self.disk_joint.disk(), name="disk")
|
||||||
.add(self.disk_joint.disk(), name="disk", loc=loc_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
|
return result
|
||||||
|
|
||||||
|
@ -986,16 +1019,19 @@ class ElbowJoint(Model):
|
||||||
-self.disk_joint.tongue_span / 2 + self.angle_neutral
|
-self.disk_joint.tongue_span / 2 + self.angle_neutral
|
||||||
)
|
)
|
||||||
lip_dz = self.lip_thickness
|
lip_dz = self.lip_thickness
|
||||||
|
loc_net_housing = axial_offset * housing_loc
|
||||||
result = (
|
result = (
|
||||||
Cq.Assembly()
|
Cq.Assembly()
|
||||||
|
.add(housing, name="housing")
|
||||||
.add(self.lip(), name="lip", loc=
|
.add(self.lip(), name="lip", loc=
|
||||||
|
loc_net_housing.inverse *
|
||||||
Cq.Location((0, 0, 0), (0, 1, 0), 180) *
|
Cq.Location((0, 0, 0), (0, 1, 0), 180) *
|
||||||
Cq.Location((-lip_dz, 0, 0), (1, 0, 0), 90) *
|
Cq.Location((-lip_dz, 0, 0), (1, 0, 0), 90) *
|
||||||
Cq.Location((0, 0, 0), (0, 1, 0), 90))
|
Cq.Location((0, 0, 0), (0, 1, 0), 90))
|
||||||
.add(housing, name="housing",
|
|
||||||
loc=axial_offset * housing_loc)
|
|
||||||
.add(connector, name="connector",
|
.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("housing", "Fixed")
|
||||||
#.constrain("connector", "Fixed")
|
#.constrain("connector", "Fixed")
|
||||||
#.solve()
|
#.solve()
|
||||||
|
@ -1021,6 +1057,17 @@ class ElbowJoint(Model):
|
||||||
disk="child/disk",
|
disk="child/disk",
|
||||||
angle=angle,
|
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()
|
return result.solve()
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|
|
@ -795,7 +795,16 @@ class WingProfile(Model):
|
||||||
fastener_pos: float = 0.0,
|
fastener_pos: float = 0.0,
|
||||||
) -> Cq.Assembly():
|
) -> Cq.Assembly():
|
||||||
if parts is None:
|
if parts is None:
|
||||||
parts = ["root", "s0", "shoulder", "s1", "elbow", "s2", "wrist", "s3"]
|
parts = [
|
||||||
|
"root",
|
||||||
|
"s0",
|
||||||
|
"shoulder",
|
||||||
|
"s1",
|
||||||
|
"elbow",
|
||||||
|
"s2",
|
||||||
|
"wrist",
|
||||||
|
"s3",
|
||||||
|
]
|
||||||
result = (
|
result = (
|
||||||
Cq.Assembly()
|
Cq.Assembly()
|
||||||
)
|
)
|
||||||
|
|
|
@ -842,4 +842,4 @@ files = [
|
||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "2.0"
|
lock-version = "2.0"
|
||||||
python-versions = "^3.10"
|
python-versions = "^3.10"
|
||||||
content-hash = "3403086281e26faefd12217e6dec4c0696e3468c5a9d8c952f8d988857aafba0"
|
content-hash = "6fc2644e7778ba22f8f5f2bcb2ca54f03b325f62c8a3fcd1c265a17561d874b8"
|
||||||
|
|
|
@ -14,6 +14,7 @@ colorama = "^0.4.6"
|
||||||
|
|
||||||
# cadquery dependency
|
# cadquery dependency
|
||||||
multimethod = "^1.12"
|
multimethod = "^1.12"
|
||||||
|
scipy = "^1.14.0"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry-core"]
|
requires = ["poetry-core"]
|
||||||
|
|
Loading…
Reference in New Issue