Cosplay/nhf/touhou/houjuu_nue/electronics.py

599 lines
18 KiB
Python

"""
Electronic components
"""
from dataclasses import dataclass, field
from typing import Optional, Tuple
import math
import cadquery as Cq
from nhf.build import Model, TargetKind, target, assembly, submodel
from nhf.materials import Role, Material
from nhf.parts.box import MountingBox, Hole
from nhf.parts.fibre import tension_fibre
from nhf.parts.item import Item
from nhf.parts.fasteners import FlatHeadBolt, HexNut
from nhf.parts.electronics import ArduinoUnoR3
from nhf.touhou.houjuu_nue.common import NUT_COMMON, BOLT_COMMON
import nhf.utils
@dataclass(frozen=True)
class LinearActuator(Item):
stroke_length: float
shaft_diam: float = 9.04
front_hole_ext: float = 4.41
front_hole_diam: float = 4.41
front_length: float = 9.55
front_width: float = 9.24
front_height: float = 5.98
segment1_length: float = 37.54
segment1_width: float = 15.95
segment1_height: float = 11.94
segment2_length: float = 37.37
segment2_width: float = 20.03
segment2_height: float = 15.03
back_hole_ext: float = 4.58
back_hole_diam: float = 4.18
back_length: float = 9.27
back_width: float = 10.16
back_height: float = 8.12
@property
def name(self) -> str:
return f"LinearActuator {self.stroke_length}mm"
@property
def role(self) -> Role:
return Role.MOTION
@property
def conn_length(self):
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, f"Illegal position: {pos}"
stroke_x = pos * self.stroke_length
front = (
Cq.Workplane('XZ')
.cylinder(
radius=self.front_width / 2,
height=self.front_height,
centered=True,
)
.box(
length=self.front_hole_ext,
width=self.front_width,
height=self.front_height,
combine=True,
centered=(False, True, True)
)
.copyWorkplane(Cq.Workplane('XZ'))
.cylinder(
radius=self.front_hole_diam / 2,
height=self.front_height,
centered=True,
combine='cut',
)
)
front.copyWorkplane(Cq.Workplane('XZ')).tagPlane('conn')
if stroke_x > 0:
shaft = (
Cq.Workplane('YZ')
.cylinder(
radius=self.shaft_diam / 2,
height=stroke_x,
centered=(True, True, False)
)
)
else:
shaft = None
segment1 = (
Cq.Workplane()
.box(
length=self.segment1_length,
height=self.segment1_width,
width=self.segment1_height,
centered=(False, True, True),
)
)
segment2 = (
Cq.Workplane()
.box(
length=self.segment2_length,
height=self.segment2_width,
width=self.segment2_height,
centered=(False, True, True),
)
)
back = (
Cq.Workplane('XZ')
.cylinder(
radius=self.back_width / 2,
height=self.back_height,
centered=True,
)
.box(
length=self.back_hole_ext,
width=self.back_width,
height=self.back_height,
combine=True,
centered=(False, True, True)
)
.copyWorkplane(Cq.Workplane('XZ'))
.cylinder(
radius=self.back_hole_diam / 2,
height=self.back_height,
centered=True,
combine='cut',
)
)
back.copyWorkplane(Cq.Workplane('XZ')).tagPlane('conn')
result = (
Cq.Assembly()
.add(front, name="front",
loc=Cq.Location((-self.front_hole_ext, 0, 0)))
.add(segment1, name="segment1",
loc=Cq.Location((stroke_x, 0, 0)))
.add(segment2, name="segment2",
loc=Cq.Location((stroke_x + self.segment1_length, 0, 0)))
.add(back, name="back",
loc=Cq.Location((stroke_x + self.segment1_length + self.segment2_length + self.back_hole_ext, 0, 0), (0, 1, 0), 180))
)
if shaft:
result.add(shaft, name="shaft")
return result
@dataclass(frozen=True)
class MountingBracket(Item):
"""
Mounting bracket for a linear actuator
"""
mass: float = 1.6
hole_diam: float = 4.0
width: float = 8.0
height: float = 12.20
thickness: float = 0.98
length: float = 13.00
hole_to_side_ext: float = 8.25
def __post_init__(self):
assert self.hole_to_side_ext - self.hole_diam / 2 > 0
@property
def name(self) -> str:
return f"MountingBracket M{int(self.hole_diam)}"
@property
def role(self) -> Role:
return Role.MOTION
def generate(self) -> Cq.Workplane:
result = (
Cq.Workplane('XY')
.box(
length=self.hole_to_side_ext,
width=self.width,
height=self.height,
centered=(False, True, True)
)
.copyWorkplane(Cq.Workplane('XY'))
.cylinder(
height=self.height,
radius=self.width / 2,
combine=True,
)
.copyWorkplane(Cq.Workplane('XY'))
.box(
length=2 * (self.hole_to_side_ext - self.thickness),
width=self.width,
height=self.height - self.thickness * 2,
combine='cut',
)
.copyWorkplane(Cq.Workplane('XY'))
.cylinder(
height=self.height,
radius=self.hole_diam / 2,
combine='cut'
)
.copyWorkplane(Cq.Workplane('YZ'))
.cylinder(
height=self.hole_to_side_ext * 2,
radius=self.hole_diam / 2,
combine='cut'
)
)
result.copyWorkplane(Cq.Workplane('YZ', origin=(self.hole_to_side_ext, 0, 0))).tagPlane("conn_side")
result.copyWorkplane(Cq.Workplane('XY', origin=(0, 0, self.height/2))).tagPlane("conn_top")
result.copyWorkplane(Cq.Workplane('YX', origin=(0, 0, -self.height/2))).tagPlane("conn_bot")
result.copyWorkplane(Cq.Workplane('XY')).tagPlane("conn_mid")
return result
@dataclass(frozen=True)
class BatteryBox18650(Item):
"""
A number of 18650 batteries in series
"""
mass: float = 17.4 + 68.80 * 3
length: float = 75.70
width_base: float = 61.46 - 18.48 - 20.18 * 2
battery_dist: float = 20.18
height: float = 19.66
# space from bottom to battery begin
thickness: float = 1.66
battery_diam: float = 18.48
battery_height: float = 68.80
n_batteries: int = 3
def __post_init__(self):
assert 2 * self.thickness < min(self.length, self.height)
@property
def name(self) -> str:
return f"BatteryBox 18650*{self.n_batteries}"
@property
def role(self) -> Role:
return Role.ELECTRONIC
def generate(self) -> Cq.Workplane:
width = self.width_base + self.battery_dist * (self.n_batteries - 1) + self.battery_diam
return (
Cq.Workplane('XY')
.box(
length=self.length,
width=width,
height=self.height,
centered=(True, True, False),
)
.copyWorkplane(Cq.Workplane('XY', origin=(0, 0, self.thickness)))
.box(
length=self.length - self.thickness*2,
width=width - self.thickness*2,
height=self.height - self.thickness,
centered=(True, True, False),
combine='cut',
)
.copyWorkplane(Cq.Workplane('XY', origin=(-self.battery_height/2, 0, self.thickness + self.battery_diam/2)))
.rarray(
xSpacing=1,
ySpacing=self.battery_dist,
xCount=1,
yCount=self.n_batteries,
center=True,
)
.cylinder(
radius=self.battery_diam/2,
height=self.battery_height,
direct=(1, 0, 0),
centered=(True, True, False),
combine=True,
)
)
LINEAR_ACTUATOR_50 = LinearActuator(
mass=40.8,
stroke_length=50,
shaft_diam=9.05,
front_hole_ext=4.32,
back_hole_ext=4.54,
segment1_length=57.35,
segment1_width=15.97,
segment1_height=11.95,
segment2_length=37.69,
segment2_width=19.97,
segment2_height=14.96,
front_length=9.40,
front_width=9.17,
front_height=6.12,
back_length=9.18,
back_width=10.07,
back_height=8.06,
)
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=34,
segment2_length=34,
)
LINEAR_ACTUATOR_10 = LinearActuator(
mass=41.3,
stroke_length=10,
front_hole_ext=4.02,
back_hole_ext=4.67,
segment1_length=13.29,
segment1_width=15.88,
segment1_height=12.07,
segment2_length=42.52,
segment2_width=20.98,
segment2_height=14.84,
)
LINEAR_ACTUATOR_HEX_NUT = HexNut(
mass=0.8,
diam_thread=4,
pitch=0.7,
thickness=4.16,
width=6.79,
)
LINEAR_ACTUATOR_BOLT = FlatHeadBolt(
mass=1.7,
diam_head=6.68,
height_head=2.98,
diam_thread=4.0,
height_thread=15.83,
)
LINEAR_ACTUATOR_BRACKET = MountingBracket()
BATTERY_BOX = BatteryBox18650()
# Acrylic hex nut
ELECTRONIC_MOUNT_HEXNUT = HexNut(
mass=0.8,
diam_thread=4,
pitch=0.7,
thickness=3.57,
width=6.81,
)
@dataclass(kw_only=True)
class Flexor:
"""
Actuator assembly which flexes, similar to biceps
"""
motion_span: float
arm_radius: Optional[float] = None
pos_smaller: bool = True
actuator: LinearActuator = LINEAR_ACTUATOR_50
nut: HexNut = LINEAR_ACTUATOR_HEX_NUT
bolt: FlatHeadBolt = LINEAR_ACTUATOR_BOLT
bracket: MountingBracket = LINEAR_ACTUATOR_BRACKET
# Length of line attached to the flexor
line_length: float = 0.0
line_thickness: float = 0.5
# By how much is the line permitted to slack. This reduces the effective stroke length
line_slack: float = 0.0
def __post_init__(self):
assert self.line_slack <= self.line_length, f"Insufficient length: {self.line_slack} >= {self.line_length}"
assert self.line_slack < self.actuator.stroke_length
@property
def mount_height(self):
return self.bracket.hole_to_side_ext
@property
def d_open(self):
return self.actuator.conn_length + self.actuator.stroke_length + self.line_length - self.line_slack
@property
def d_closed(self):
return self.actuator.conn_length + self.line_length
def open_pos(self) -> Tuple[float, float, float]:
r, phi, r_ = nhf.geometry.contraction_span_pos_from_radius(
d_open=self.d_open,
d_closed=self.d_closed,
theta=math.radians(self.motion_span),
r=self.arm_radius,
smaller=self.pos_smaller,
)
return r, math.degrees(phi), r_
def target_length_at_angle(
self,
angle: float = 0.0
) -> float:
"""
Length of the actuator at some angle
"""
assert 0 <= angle <= self.motion_span
r, phi, rp = self.open_pos()
th = math.radians(phi - angle)
result = math.sqrt(r * r + rp * rp - 2 * r * rp * math.cos(th))
#result = math.sqrt((r * math.cos(th) - rp) ** 2 + (r * math.sin(th)) ** 2)
assert self.d_closed -1e-6 <= result <= self.d_open + 1e-6,\
f"Illegal length: {result} not in [{self.d_closed}, {self.d_open}]"
return result
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,
tag_dir: Optional[str] = None):
"""
Adds the necessary mechanical components to this assembly. Does not
invoke `a.solve()`.
"""
draft = max(0, target_length - self.d_closed - self.line_length)
pos = draft / self.actuator.stroke_length
line_l = target_length - draft - self.actuator.conn_length
if tag_prefix:
tag_prefix = tag_prefix + "_"
else:
tag_prefix = ""
name_actuator = f"{tag_prefix}actuator"
name_bracket_front = f"{tag_prefix}bracket_front"
name_bracket_back = f"{tag_prefix}bracket_back"
name_bolt_front = f"{tag_prefix}front_bolt"
name_bolt_back = f"{tag_prefix}back_bolt"
name_nut_front = f"{tag_prefix}front_nut"
name_nut_back = f"{tag_prefix}back_nut"
(
a
.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)
.constrain(f"{name_bolt_front}?root", f"{name_bracket_front}?conn_top",
"Plane", param=0)
.constrain(f"{name_nut_front}?bot", f"{name_bracket_front}?conn_bot",
"Plane")
.add(self.bracket.assembly(), name=name_bracket_back)
.add(self.bolt.assembly(), name=name_bolt_back)
.add(self.nut.assembly(), name=name_nut_back)
.constrain(f"{name_actuator}/back?conn", f"{name_bracket_back}?conn_mid",
"Plane", param=0)
.constrain(f"{name_bolt_back}?root", f"{name_bracket_back}?conn_top",
"Plane", param=0)
.constrain(f"{name_nut_back}?bot", f"{name_bracket_back}?conn_bot",
"Plane")
)
if self.line_length == 0.0:
a.constrain(
f"{name_actuator}/front?conn",
f"{name_bracket_front}?conn_mid",
"Plane", param=0)
else:
(
a
.addS(tension_fibre(
length=line_l,
hole_diam=self.nut.diam_thread,
thickness=self.line_thickness,
), name="fibre", role=Role.CONNECTION)
.constrain(
f"{name_actuator}/front?conn",
"fibre?male",
"Plane"
)
.constrain(
f"{name_bracket_front}?conn_mid",
"fibre?female",
"Plane"
)
)
if tag_hole_front:
a.constrain(tag_hole_front, f"{name_bracket_front}?conn_side", "Plane")
if tag_hole_back:
a.constrain(tag_hole_back, f"{name_bracket_back}?conn_side", "Plane")
if tag_dir:
a.constrain(tag_dir, f"{name_bracket_front}?conn_mid", "Axis", param=0)
@dataclass
class ElectronicBoard(Model):
name: str = "electronic-board"
nut: HexNut = NUT_COMMON
bolt: FlatHeadBolt = BOLT_COMMON
length: float = 70.0
width: float = 170.0
mount_holes: list[Hole] = field(default_factory=lambda: [
Hole(x=25, y=75),
Hole(x=25, y=-75),
Hole(x=-25, y=75),
Hole(x=-25, y=-75),
])
panel_thickness: float = 25.4 / 16
mount_panel_thickness: float = 25.4 / 4
material: Material = Material.WOOD_BIRCH
@property
def mount_hole_diam(self) -> float:
return self.bolt.diam_thread
def __post_init__(self):
super().__init__(name=self.name)
def panel(self) -> MountingBox:
return MountingBox(
holes=self.mount_holes,
hole_diam=self.mount_hole_diam,
length=self.length,
width=self.width,
centred=(True, True),
thickness=self.panel_thickness,
generate_reverse_tags=True,
)
def assembly(self) -> Cq.Assembly:
panel = self.panel()
result = (
Cq.Assembly()
.addS(panel.generate(), name="panel",
role=Role.ELECTRONIC | Role.STRUCTURE, material=self.material)
)
for hole in self.mount_holes:
spacer_name = f"{hole.tag}_spacer"
bolt_name = f"{hole.tag}_bolt"
(
result
.add(self.nut.assembly(), name=spacer_name)
.add(self.bolt.assembly(), name=bolt_name)
.constrain(
f"{spacer_name}?top",
f"panel?{hole.rev_tag}",
"Plane"
)
.constrain(
f"{bolt_name}?root",
f"panel?{hole.tag}",
"Plane", param=0
)
)
return result.solve()
@dataclass
class ElectronicBoardBattery(ElectronicBoard):
name: str = "electronic-board-battery"
@submodel(name="panel")
def panel_out(self) -> MountingBox:
return self.panel()
@dataclass
class ElectronicBoardControl(ElectronicBoard):
name: str = "electronic-board-control"
controller_datum: Cq.Location = Cq.Location.from2d(-25,10, -90)
controller: ArduinoUnoR3 = ArduinoUnoR3()
def panel(self) -> MountingBox:
box = super().panel()
def transform(i, x, y):
pos = self.controller_datum * Cq.Location.from2d(x, self.controller.width - y)
x, y = pos.to2d_pos()
return Hole(
x=x, y=y,
diam=self.controller.hole_diam,
tag=f"controller_conn{i}",
)
box.holes = box.holes.copy() + [
transform(i, x, y)
for i, (x, y) in enumerate(self.controller.holes)
]
return box
@submodel(name="panel")
def panel_out(self) -> MountingBox:
return self.panel()
def assembly(self) -> Cq.Assembly:
result = super().assembly()
result.add(self.controller.assembly(), name="controller")
for i in range(len(self.controller.holes)):
result.constrain(f"controller?conn{i}", f"panel?controller_conn{i}", "Plane")
return result.solve()
@dataclass(frozen=True)
class LightStrip:
width: float = 10.0
height: float = 4.5