cosplay: Touhou/Houjuu Nue #4

Open
aniva wants to merge 189 commits from touhou/houjuu-nue into main
4 changed files with 186 additions and 51 deletions
Showing only changes of commit 5eeeace852 - Show all commits

View File

@ -1,6 +1,7 @@
from dataclasses import dataclass from dataclasses import dataclass
import math import math
import cadquery as Cq import cadquery as Cq
from typing import Optional
from nhf import Item, Role from nhf import Item, Role
import nhf.utils import nhf.utils
@ -127,3 +128,35 @@ class HexNut(Item):
.regularPolygon(r=self.radius, n=6) .regularPolygon(r=self.radius, n=6)
._faces ._faces
) )
@dataclass(frozen=True)
class Washer(Item):
diam_thread: float
diam_outer: float
thickness: float
material_name: Optional[float] = None
def __post_init__(self):
assert self.diam_outer > self.diam_thread
@property
def name(self):
suffix = (" " + self.material_name) if self.material_name else ""
return f"Washer M{int(self.diam_thread)}{suffix}"
@property
def role(self) -> Role:
return Role.CONNECTION
def generate(self) -> Cq.Workplane:
result = (
Cq.Workplane('XY')
.cylinder(
radius=self.diam_outer/2,
height=self.thickness,
)
.faces(">Z")
.hole(self.diam_thread)
)
result.faces("<Z").tag("bot")
result.faces(">Z").tag("top")
return result

View File

@ -547,3 +547,9 @@ class ElectronicBoard(Model):
) )
) )
return result.solve() return result.solve()
@dataclass(frozen=True)
class LightStrip:
width: float = 10.0
height: float = 4.5

View File

@ -6,7 +6,7 @@ from nhf import Material, Role
from nhf.build import Model, target, assembly from nhf.build import Model, target, assembly
from nhf.parts.box import MountingBox from nhf.parts.box import MountingBox
from nhf.parts.springs import TorsionSpring from nhf.parts.springs import TorsionSpring
from nhf.parts.fasteners import FlatHeadBolt, HexNut, ThreaddedKnob from nhf.parts.fasteners import FlatHeadBolt, HexNut, ThreaddedKnob, Washer
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 ( from nhf.touhou.houjuu_nue.electronics import (
@ -67,6 +67,28 @@ ELBOW_TORSION_SPRING = TorsionSpring(
right_handed=False, right_handed=False,
) )
ELBOW_AXLE_BOLT = FlatHeadBolt(
mass=0.0,
diam_head=6.87,
height_head=3.06,
diam_thread=4.0,
height_thread=20.0,
)
ELBOW_AXLE_WASHER = Washer(
mass=0.0,
diam_outer=8.96,
diam_thread=4.0,
thickness=1.02,
material_name="Nylon"
)
ELBOW_AXLE_HEX_NUT = HexNut(
mass=0.0,
diam_thread=4.0,
pitch=0.7,
thickness=3.6, # or 2.64 for metal
width=6.89,
)
@dataclass @dataclass
class RootJoint(Model): class RootJoint(Model):
""" """
@ -390,12 +412,13 @@ class ShoulderJoint(Model):
angle_neutral: float = -15.0 angle_neutral: float = -15.0
angle_max_deflection: float = 65.0 angle_max_deflection: float = 65.0
spool_radius: float = 12.0 spool_radius_diff: float = 2.0
spool_groove_depth: float = 1.0 # All the heights here are mirrored for the bottom as well
spool_base_height: float = 3.0 spool_cap_height: float = 3.0
spool_height: float = 5.0 spool_core_height: float = 2.0
spool_cap_height: float = 1.0
spool_groove_inset: float = 3.0 spool_line_thickness: float = 1.2
spool_groove_radius: float = 10.0
flip: bool = False flip: bool = False
actuator: LinearActuator = LINEAR_ACTUATOR_21 actuator: LinearActuator = LINEAR_ACTUATOR_21
@ -403,11 +426,17 @@ class ShoulderJoint(Model):
def __post_init__(self): def __post_init__(self):
assert self.parent_lip_length * 2 < self.height assert self.parent_lip_length * 2 < self.height
assert self.child_guard_ext > self.torsion_joint.radius_rider assert self.child_guard_ext > self.torsion_joint.radius_rider
assert self.spool_groove_depth < self.spool_radius < self.torsion_joint.radius_rider - self.child_core_thickness assert self.spool_groove_radius < self.spool_inner_radius < self.spool_outer_radius
assert self.spool_base_height > self.spool_groove_depth
assert self.child_lip_height < self.height assert self.child_lip_height < self.height
assert self.draft_length <= self.actuator.stroke_length assert self.draft_length <= self.actuator.stroke_length
@property
def spool_outer_radius(self):
return self.torsion_joint.radius_rider - self.child_core_thickness
@property
def spool_inner_radius(self):
return self.spool_outer_radius - self.spool_radius_diff
@property @property
def radius(self): def radius(self):
return self.torsion_joint.radius return self.torsion_joint.radius
@ -417,7 +446,7 @@ class ShoulderJoint(Model):
""" """
Amount of wires that need to draft on the spool Amount of wires that need to draft on the spool
""" """
return (self.spool_radius - self.spool_groove_depth / 2) * math.radians(self.angle_max_deflection) return self.spool_inner_radius * math.radians(self.angle_max_deflection)
@property @property
def draft_height(self): def draft_height(self):
@ -444,16 +473,20 @@ class ShoulderJoint(Model):
def _max_contraction_angle(self) -> float: def _max_contraction_angle(self) -> float:
return 180 - self.angle_max_deflection + self.angle_neutral return 180 - self.angle_max_deflection + self.angle_neutral
def _contraction_cut_geometry(self, parent: bool = False, mirror: bool=False) -> Cq.Solid: def _contraction_cut_angle(self) -> float:
"""
Generates a cylindrical sector which cuts away overlapping regions of the child and parent
"""
aspect = self.child_guard_width / self.parent_arm_width aspect = self.child_guard_width / self.parent_arm_width
theta = math.radians(self._max_contraction_angle) theta = math.radians(self._max_contraction_angle)
#theta_p = math.atan(math.sin(theta) / (math.cos(theta) + aspect)) #theta_p = math.atan(math.sin(theta) / (math.cos(theta) + aspect))
theta_p = math.atan2(math.sin(theta), math.cos(theta) + aspect) theta_p = math.atan2(math.sin(theta), math.cos(theta) + aspect)
angle = math.degrees(theta_p) angle = math.degrees(theta_p)
assert 0 <= angle <= 90 assert 0 <= angle <= 90
return angle
def _contraction_cut_geometry(self, parent: bool = False, mirror: bool=False) -> Cq.Solid:
"""
Generates a cylindrical sector which cuts away overlapping regions of the child and parent
"""
angle = self._contraction_cut_angle()
# outer radius of the cut, overestimated # outer radius of the cut, overestimated
cut_radius = math.sqrt(self.child_guard_width ** 2 + self.parent_arm_width ** 2) cut_radius = math.sqrt(self.child_guard_width ** 2 + self.parent_arm_width ** 2)
span = 180 span = 180
@ -550,37 +583,39 @@ class ShoulderJoint(Model):
joint = self.torsion_joint joint = self.torsion_joint
return self.height - 2 * joint.total_height + 2 * joint.rider_disk_height return self.height - 2 * joint.total_height + 2 * joint.rider_disk_height
def _spool(self) -> Cq.Workplane: def _spool(self) -> Cq.Compound:
""" """
Generates the spool piece which holds the line in tension Generates the spool piece which holds the line in tension
""" """
t = self.spool_groove_depth t = self.spool_line_thickness
radius_core_inner = self.torsion_joint.radius_rider - self.child_core_thickness
spindle = Cq.Solid.makeCone( spindle = Cq.Solid.makeCone(
radius1=self.spool_radius, radius1=self.spool_inner_radius,
radius2=radius_core_inner, radius2=self.spool_outer_radius,
height=self.spool_height, height=self.spool_core_height,
) )
cap = Cq.Solid.makeCylinder( cap = Cq.Solid.makeCylinder(
radius=radius_core_inner, radius=self.spool_outer_radius,
height=self.spool_cap_height height=self.spool_cap_height
).located(Cq.Location((0,0,self.spool_height))) ).moved(Cq.Location((0,0,self.spool_core_height)))
hole_x = radius_core_inner - self.spool_groove_inset cut_height = self.spool_cap_height + self.spool_core_height
slot = Cq.Solid.makeBox( cut_hole = Cq.Solid.makeCylinder(
length=t, radius=t / 2,
height=cut_height,
).moved(Cq.Location((self.spool_groove_radius, 0, 0)))
cut_slot = Cq.Solid.makeBox(
length=self.spool_outer_radius - self.spool_groove_radius,
width=t, width=t,
height=self.spool_base_height, height=self.spool_core_height,
).located(Cq.Location((hole_x, -t/2, 0))) ).moved(Cq.Location((self.spool_groove_radius, -t/2, 0)))
hole = Cq.Solid.makeBox( cut_centre_hole = Cq.Solid.makeCylinder(
length=t,
width=t,
height=self.spool_height + self.spool_base_height,
).located(Cq.Location((hole_x, -t/2, 0)))
centre_hole = Cq.Solid.makeCylinder(
radius=self.torsion_joint.radius_axle, radius=self.torsion_joint.radius_axle,
height=self.spool_height + self.spool_base_height, height=cut_height,
)
top = spindle.fuse(cap).cut(cut_hole, cut_centre_hole, cut_slot)
return (
top
.fuse(top.located(Cq.Location((0,0,0), (1,0, 0), 180)))
) )
return spindle.fuse(cap).cut(slot, hole, centre_hole)
@target(name="child") @target(name="child")
def child(self) -> Cq.Assembly: def child(self) -> Cq.Assembly:
@ -611,6 +646,14 @@ class ShoulderJoint(Model):
.assemble() .assemble()
.circle(radius_core_inner, mode='s') .circle(radius_core_inner, mode='s')
) )
angle_line_span = -self.angle_neutral + self.angle_max_deflection + 90
angle_line = 180 - angle_line_span
# leave space for the line to rotate
spool_cut = Cq.Solid.makeCylinder(
radius=joint.radius_rider * 2,
height=self.spool_core_height * 2,
angleDegrees=angle_line_span,
).moved(Cq.Location((0,0,-self.spool_core_height), (0,0,1), angle_line))
lip_extension = ( lip_extension = (
Cq.Solid.makeBox( Cq.Solid.makeBox(
length=self.child_lip_ext - self.child_guard_ext, length=self.child_lip_ext - self.child_guard_ext,
@ -676,7 +719,8 @@ class ShoulderJoint(Model):
.toPending() .toPending()
.extrude(dh * 2) .extrude(dh * 2)
.translate(Cq.Vector(0, 0, -dh)) .translate(Cq.Vector(0, 0, -dh))
.union(core_guard) .cut(spool_cut)
.union(core_guard, tol=TOL)
) )
assert self.child_lip_width / 2 <= joint.radius_rider assert self.child_lip_width / 2 <= joint.radius_rider
sign = 1 if self.flip else -1 sign = 1 if self.flip else -1
@ -695,8 +739,8 @@ class ShoulderJoint(Model):
loc_rotate = Cq.Location((0, 0, 0), (1, 0, 0), 180) loc_rotate = Cq.Location((0, 0, 0), (1, 0, 0), 180)
loc_axis_rotate_bot = Cq.Location((0, 0, 0), (0, 0, 1), self.axis_rotate_bot + self.angle_neutral) loc_axis_rotate_bot = Cq.Location((0, 0, 0), (0, 0, 1), self.axis_rotate_bot + self.angle_neutral)
loc_axis_rotate_top = Cq.Location((0, 0, 0), (0, 0, 1), self.axis_rotate_top + self.angle_neutral) loc_axis_rotate_top = Cq.Location((0, 0, 0), (0, 0, 1), self.axis_rotate_top + self.angle_neutral)
spool_dz = self.height / 2 - self.torsion_joint.total_height spool_dz = 0
spool_angle = 180 + self.angle_neutral spool_angle = -self.angle_neutral
loc_spool_flip = Cq.Location((0,0,0),(0,1,0),180) if self.flip else Cq.Location() loc_spool_flip = Cq.Location((0,0,0),(0,1,0),180) if self.flip else Cq.Location()
result = ( result = (
Cq.Assembly() Cq.Assembly()
@ -848,6 +892,7 @@ class DiskJoint(Model):
housing_thickness: float = 4.0 housing_thickness: float = 4.0
disk_thickness: float = 8.0 disk_thickness: float = 8.0
tongue_thickness: float = 10.0
# Amount by which the wall carves in # Amount by which the wall carves in
wall_inset: float = 2.0 wall_inset: float = 2.0
@ -868,6 +913,9 @@ class DiskJoint(Model):
generate_inner_wall: bool = False generate_inner_wall: bool = False
axle_bolt: FlatHeadBolt = ELBOW_AXLE_BOLT
axle_washer: Washer = ELBOW_AXLE_WASHER
axle_hex_nut: HexNut = ELBOW_AXLE_HEX_NUT
def __post_init__(self): def __post_init__(self):
super().__init__(name="disk-joint") super().__init__(name="disk-joint")
@ -876,6 +924,11 @@ class DiskJoint(Model):
assert self.housing_upper_carve_offset > 0 assert self.housing_upper_carve_offset > 0
assert self.spring_tail_hole_height > self.spring.thickness assert self.spring_tail_hole_height > self.spring.thickness
assert self.tongue_thickness <= self.total_thickness
assert self.axle_bolt.diam_thread == self.axle_washer.diam_thread
assert self.axle_bolt.diam_thread == self.axle_hex_nut.diam_thread
assert self.axle_bolt.height_thread > self.total_thickness, "Bolt is not long enough"
@property @property
def neutral_movement_angle(self) -> Optional[float]: def neutral_movement_angle(self) -> Optional[float]:
@ -932,10 +985,21 @@ class DiskJoint(Model):
@target(name="disk") @target(name="disk")
def disk(self) -> Cq.Workplane: def disk(self) -> Cq.Workplane:
radius_tongue = self.radius_disk + self.tongue_length radius_tongue = self.radius_disk + self.tongue_length
tongue = ( outer_tongue = (
Cq.Solid.makeCylinder(
height=self.tongue_thickness,
radius=radius_tongue,
angleDegrees=self.tongue_span,
).cut(Cq.Solid.makeCylinder(
height=self.tongue_thickness,
radius=self.radius_housing,
))
.moved(Cq.Location((0,0,(self.disk_thickness - self.tongue_thickness) / 2)))
)
inner_tongue = (
Cq.Solid.makeCylinder( Cq.Solid.makeCylinder(
height=self.disk_thickness, height=self.disk_thickness,
radius=radius_tongue, radius=self.radius_housing,
angleDegrees=self.tongue_span, angleDegrees=self.tongue_span,
).cut(Cq.Solid.makeCylinder( ).cut(Cq.Solid.makeCylinder(
height=self.disk_thickness, height=self.disk_thickness,
@ -949,7 +1013,8 @@ class DiskJoint(Model):
radius=self.radius_disk, radius=self.radius_disk,
centered=(True, True, False) centered=(True, True, False)
) )
.union(tongue, tol=TOL) .union(inner_tongue, tol=TOL)
.union(outer_tongue, tol=TOL)
.copyWorkplane(Cq.Workplane('XY')) .copyWorkplane(Cq.Workplane('XY'))
.cylinder( .cylinder(
height=self.disk_thickness, height=self.disk_thickness,
@ -995,6 +1060,7 @@ class DiskJoint(Model):
)) ))
) )
result.faces(">Z").tag("mate") result.faces(">Z").tag("mate")
result.faces("<Z").tag("bot")
result.faces(">Z").workplane().tagPlane("dirX", direction="+X") result.faces(">Z").workplane().tagPlane("dirX", direction="+X")
result = result.cut( result = result.cut(
self self
@ -1028,6 +1094,7 @@ class DiskJoint(Model):
) )
theta = math.radians(carve_angle) theta = math.radians(carve_angle)
result.faces("<Z").tag("mate") result.faces("<Z").tag("mate")
result.faces(">Z").tag("top")
p_xy = result.copyWorkplane(Cq.Workplane('XY')) p_xy = result.copyWorkplane(Cq.Workplane('XY'))
p_xy.tagPlane("dirX", direction="+X") p_xy.tagPlane("dirX", direction="+X")
p_xy.tagPlane("dir", direction=(math.cos(theta), math.sin(theta), 0)) p_xy.tagPlane("dir", direction=(math.cos(theta), math.sin(theta), 0))
@ -1063,6 +1130,8 @@ class DiskJoint(Model):
housing_upper: str, housing_upper: str,
disk: str, disk: str,
angle: float = 0.0, angle: float = 0.0,
fasteners: bool = True,
fastener_prefix: str = "fastener",
) -> Cq.Assembly: ) -> Cq.Assembly:
assert 0 <= angle <= self.movement_angle assert 0 <= angle <= self.movement_angle
deflection = angle - (self.spring.angle_neutral - self.spring_angle_at_0) deflection = angle - (self.spring.angle_neutral - self.spring_angle_at_0)
@ -1084,6 +1153,16 @@ class DiskJoint(Model):
#.constrain(f"{housing_lower}?dirX", f"{disk}?dir", "Axis", param=angle) #.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}?dirY", f"{disk}?dir", "Axis", param=angle - 90)
) )
if fasteners:
tag_bolt = f"{fastener_prefix}_bolt"
tag_nut = f"{fastener_prefix}_nut"
(
assembly
.add(self.axle_bolt.assembly(), name=tag_bolt)
.add(self.axle_hex_nut.assembly(), name=tag_nut)
.constrain(f"{housing_lower}?bot", f"{tag_nut}?bot", "Plane")
.constrain(f"{housing_upper}?top", f"{tag_bolt}?root", "Plane", param=0)
)
return ( return (
assembly assembly
) )
@ -1136,7 +1215,9 @@ class ElbowJoint(Model):
lip_thickness: float = 5.0 lip_thickness: float = 5.0
lip_length: float = 60.0 lip_length: float = 60.0
hole_pos: list[float] = field(default_factory=lambda: [12, 24]) # Carve which allows light to go through
lip_side_depression_width: float = 10.0
hole_pos: list[float] = field(default_factory=lambda: [15, 24])
parent_arm_width: float = 10.0 parent_arm_width: float = 10.0
# Angle of the beginning of the parent arm # Angle of the beginning of the parent arm
parent_arm_angle: float = 180.0 parent_arm_angle: float = 180.0
@ -1254,6 +1335,17 @@ class ElbowJoint(Model):
Hole(x=-sign * x, tag=f"conn_bot{i}") Hole(x=-sign * x, tag=f"conn_bot{i}")
] ]
] ]
def post(sketch: Cq.Sketch) -> Cq.Sketch:
y_outer = self.disk_joint.total_thickness / 2
y_inner = self.disk_joint.tongue_thickness / 2
y = (y_outer + y_inner) / 2
width = self.lip_side_depression_width
height = y_outer - y_inner
return (
sketch
.push([(0, y), (0, -y)])
.rect(width, height, mode='s')
)
mbox = MountingBox( mbox = MountingBox(
length=self.lip_length, length=self.lip_length,
width=self.disk_joint.total_thickness, width=self.disk_joint.total_thickness,
@ -1263,6 +1355,7 @@ class ElbowJoint(Model):
centred=(True, True), centred=(True, True),
generate_side_tags=False, generate_side_tags=False,
generate_reverse_tags=True, generate_reverse_tags=True,
profile_callback=post,
) )
return mbox.generate() return mbox.generate()
@ -1332,7 +1425,7 @@ class ElbowJoint(Model):
def parent_joint_upper(self, generate_mount: bool=False, generate_tags=True): def parent_joint_upper(self, generate_mount: bool=False, generate_tags=True):
axial_offset = Cq.Location((self.parent_arm_radius, 0, 0)) axial_offset = Cq.Location((self.parent_arm_radius, 0, 0))
housing_dz = self.disk_joint.housing_upper_dz housing_dz = self.disk_joint.housing_upper_dz
conn_h = self.disk_joint.total_thickness conn_h = self.disk_joint.tongue_thickness
conn_w = self.parent_arm_width conn_w = self.parent_arm_width
connector = ( connector = (
Cq.Solid.makeBox( Cq.Solid.makeBox(

View File

@ -19,6 +19,7 @@ from nhf.touhou.houjuu_nue.electronics import (
LINEAR_ACTUATOR_21, LINEAR_ACTUATOR_21,
LINEAR_ACTUATOR_50, LINEAR_ACTUATOR_50,
ElectronicBoard, ElectronicBoard,
LightStrip,
ELECTRONIC_MOUNT_HEXNUT, ELECTRONIC_MOUNT_HEXNUT,
) )
import nhf.utils import nhf.utils
@ -52,6 +53,8 @@ class WingProfile(Model):
spacer_thickness: float = 25.4 / 4 spacer_thickness: float = 25.4 / 4
rod_width: float = 10.0 rod_width: float = 10.0
light_strip: LightStrip = LightStrip()
shoulder_joint: ShoulderJoint = field(default_factory=lambda: ShoulderJoint( shoulder_joint: ShoulderJoint = field(default_factory=lambda: ShoulderJoint(
)) ))
shoulder_angle_bias: float = 0.0 shoulder_angle_bias: float = 0.0
@ -370,9 +373,8 @@ class WingProfile(Model):
) )
@submodel(name="spacer-s0-shoulder-act") @submodel(name="spacer-s0-shoulder-act")
def spacer_s0_shoulder_act(self) -> MountingBox: def spacer_s0_shoulder_act(self) -> MountingBox:
dx = self.shoulder_joint.draft_height
return MountingBox( return MountingBox(
holes=[Hole(x=dx), Hole(x=-dx)], holes=[Hole(x=0)],
hole_diam=self.shoulder_joint.actuator.back_hole_diam, hole_diam=self.shoulder_joint.actuator.back_hole_diam,
length=self.root_height, length=self.root_height,
width=10.0, width=10.0,
@ -616,16 +618,17 @@ class WingProfile(Model):
Hole(sign * x, tag=tag) Hole(sign * x, tag=tag)
for x, tag in joint.hole_loc_tags() for x, tag in joint.hole_loc_tags()
] ]
tongue_thickness = joint.disk_joint.tongue_thickness
carve_width = joint.lip_side_depression_width
assert carve_width >= self.light_strip.width
carve_height = (segment_thickness - tongue_thickness) / 2
assert carve_height >= self.light_strip.height
def carve_sides(profile): def carve_sides(profile):
dy = (segment_thickness + joint.total_thickness) / 4 dy = (segment_thickness + tongue_thickness) / 4
return ( return (
profile profile
.push([(0,-dy), (0,dy)]) .push([(0,-dy), (0,dy)])
.rect( .rect(carve_width, carve_height, mode='s')
joint.parent_arm_width,
(segment_thickness - joint.total_thickness) / 2,
mode='s',
)
) )
# FIXME: Carve out the sides so light can pass through # FIXME: Carve out the sides so light can pass through
mbox = MountingBox( mbox = MountingBox(