""" 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.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=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, segment1_width=15.0, segment2_width=21.0, ) 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() @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=30, y=80), Hole(x=30, y=0), Hole(x=30, y=-80), Hole(x=-30, y=80), Hole(x=-30, y=0), Hole(x=-30, y=-80), ]) 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) @submodel(name="panel") 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.STRUCTURE, material=self.material) ) for hole in panel.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()