from nhf import Material, Role from nhf.build import Model, target, assembly, TargetKind import nhf.utils import math from typing import Optional from dataclasses import dataclass, field from enum import Enum import cadquery as Cq class AttachPoint(Enum): DOVETAIL_IN = 1 DOVETAIL_OUT = 2 NONE = 3 # Inset slot for front surface attachment j SLOT = 4 @dataclass class Crown(Model): facets: int = 5 # Lower circumference base_circ: float = 538.0 # Upper circumference, at the middle tilt_circ: float = 640.0 front_base_circ: float = (640.0 + 538.0) / 2 # Total height height: float = 120.0 # Front guard has a wing that inserts into the side guards. front_wing_angle: float = 9.0 front_wing_dh: float = 40.0 front_wing_height: float = 20.0 margin: float = 10.0 thickness: float = 0.4 # 26 Gauge side_guard_thickness: float = 15.0 side_guard_channel_radius: float = 90 side_guard_channel_height: float = 10 side_guard_hole_height: float = 15.0 side_guard_hole_diam: float = 1.5 side_guard_dovetail_height: float = 30.0 side_guard_slot_width: float = 22.0 side_guard_slot_angle: float = 18.0 # brass insert thickness slot_thickness: float = 2.0 slot_width: float = 20.0 slot_tilt: float = 60 material: Material = Material.METAL_BRASS material_side: Material = Material.PLASTIC_PLA def __post_init__(self): super().__init__(name="crown") assert self.tilt_circ > self.base_circ assert self.facet_width_upper / 2 > self.height / 2, "Top angle must be > 90 degrees" assert self.side_guard_channel_radius > self.radius_lower assert self.front_wing_angle < 180 / self.facets assert self.front_wing_dh + self.front_wing_height < self.height @property def facet_width_lower(self): return self.base_circ / self.facets @property def facet_width_upper(self): return self.tilt_circ / self.facets @property def radius_lower(self): return self.base_circ / (2 * math.pi) @property def radius_middle(self): return self.tilt_circ / (2 * math.pi) @property def radius_upper(self): return (self.tilt_circ + (self.tilt_circ - self.base_circ)) / (2 * math.pi) @property def radius_lower_front(self): return self.front_base_circ / (2 * math.pi) @property def radius_middle_front(self): return self.radius_lower_front + (self.radius_middle - self.radius_lower) @property def radius_upper_front(self): return self.radius_lower_front + (self.radius_upper - self.radius_lower) def profile_base(self) -> Cq.Sketch: # Generate the pentagonal shape dx_l = self.facet_width_lower dx_u = self.facet_width_upper dy = self.height return ( Cq.Sketch() .polygon([ (dx_l/2, 0), (dx_u/2, dy/2), (0, dy), (-dx_u/2, dy/2), (-dx_l/2, 0), ]) ) @target(name="side", kind=TargetKind.DXF) def profile_side(self) -> Cq.Sketch: dy = self.facet_width_upper * 0.1 x_side = self.facet_width_upper y_tip = self.height - self.margin eye = ( Cq.Sketch() .segment( (0, y_tip), (dy, y_tip - dy), ) .segment( (0, y_tip), (-dy, y_tip - dy), ) .bezier([ (dy, y_tip - dy), (0, y_tip - dy/2), (0, y_tip - dy/2), (-dy, y_tip - dy), ]) .assemble() ) return ( self.profile_base() .boolean(eye, mode='s') ) @target(name="dot", kind=TargetKind.DXF) def profile_dot(self) -> Cq.Sketch: return ( Cq.Sketch() .circle(self.margin / 2) ) @target(name="front", kind=TargetKind.DXF) def profile_front(self) -> Cq.Sketch: dx_l = self.facet_width_lower dx_u = self.facet_width_upper dy = self.height window_length = dy / 5 window_height = self.margin / 2 window = ( Cq.Sketch() .rect(window_length, window_height) ) window_p1 = Cq.Location.from2d( dx_u/2 - self.margin - window_length * 0.4, dy/2 + self.margin/2, math.degrees(math.atan2(dy/2, -dx_u/2)), ) window_p2 = Cq.Location.from2d( dx_l/2 - self.margin + window_length * 0.15, window_length/2 + self.margin, math.degrees(math.atan2(dy/2, (dx_u-dx_l)/2)), ) # Carve the scale z = dy * 1/64 # "Pen" Thickness scale_pan_x = dx_l / 2 * 0.6 scale_pan_y = dy / 2 * 0.7 pan_dx = dx_l * 1/4 pan_dy = dy * 1/16 scale_pan = ( Cq.Sketch() .arc( (- pan_dx/2, pan_dy), (0, 0), (+ pan_dx/2, pan_dy), ) .segment( (+pan_dx/2, pan_dy), (+pan_dx/2 - z, pan_dy), ) .arc( (-pan_dx/2 + z, pan_dy), (0, z), (+pan_dx/2 - z, pan_dy), ) .segment( (-pan_dx/2, pan_dy), (-pan_dx/2 + z, pan_dy), ) .assemble() ) loc_scale_pan = Cq.Location.from2d(scale_pan_x, scale_pan_y) loc_scale_pan2 = Cq.Location.from2d(-scale_pan_x, scale_pan_y) scale_base_y = dy / 2 * 0.36 scale_base_x = dx_l / 10 assert scale_base_y < scale_pan_y assert scale_base_x < scale_pan_x scale_body = ( Cq.Sketch() .arc( (scale_pan_x, scale_pan_y), (0, scale_base_y), (-scale_pan_x, scale_pan_y), ) .segment( (-scale_pan_x, scale_pan_y), (-scale_pan_x+z, scale_pan_y+z), ) .arc( (scale_pan_x - z, scale_pan_y+z), (0, scale_base_y + z), (-scale_pan_x + z, scale_pan_y+z), ) .segment( (scale_pan_x, scale_pan_y), (scale_pan_x-z, scale_pan_y+z), ) .assemble() .polygon([ (scale_base_x, scale_base_y + z/2), (scale_base_x, self.margin), (scale_base_x-z, self.margin), (scale_base_x-z, scale_base_y-z), (-scale_base_x+z, scale_base_y-z), (-scale_base_x+z, self.margin), (-scale_base_x, self.margin), (-scale_base_x, scale_base_y + z/2), ], mode='a') ) # Needle needle_y_top = dy - self.margin needle_y_mid = dy * 0.7 needle_dx = scale_base_x * 2 y_shoulder = needle_y_mid - z * 2 needle = ( Cq.Sketch() .segment( (0, needle_y_mid), (z, y_shoulder), ) .segment( (z, y_shoulder), (z, scale_base_y), ) .segment( (z, scale_base_y), (-z, scale_base_y), ) .segment( (-z, y_shoulder), (-z, scale_base_y), ) .segment( (-z, y_shoulder), (0, needle_y_mid), ) .assemble() ) z2 = z * 2 y1 = needle_y_mid + z2 needle_head = ( Cq.Sketch() .segment( (z, needle_y_mid), (z, y1), ) .segment( (-z, needle_y_mid), (-z, y1), ) # Outer edge .bezier([ (0, needle_y_top), (0, (needle_y_top + needle_y_mid)/2), (needle_dx, (needle_y_top + needle_y_mid)/2), (z, needle_y_mid), ]) .bezier([ (0, needle_y_top), (0, (needle_y_top + needle_y_mid)/2), (-needle_dx, (needle_y_top + needle_y_mid)/2), (-z, needle_y_mid), ]) # Inner edge .bezier([ (0, needle_y_top - z2), (0, (needle_y_top + needle_y_mid)/2), (needle_dx-z2*2, (needle_y_top + needle_y_mid)/2), (z, y1), ]) .bezier([ (0, needle_y_top - z2), (0, (needle_y_top + needle_y_mid)/2), (-needle_dx+z2*2, (needle_y_top + needle_y_mid)/2), (-z, y1), ]) .assemble() ) return ( self.profile_base() .boolean(window.moved(window_p1), mode='s') .boolean(window.moved(window_p1.flip_x()), mode='s') .boolean(window.moved(window_p2), mode='s') .boolean(window.moved(window_p2.flip_x()), mode='s') .boolean(scale_pan.moved(loc_scale_pan), mode='s') .boolean(scale_pan.moved(loc_scale_pan2), mode='s') .boolean(scale_body, mode='s') .boolean(needle, mode='s') .boolean(needle_head, mode='s') .clean() ) @target(name="side-guard", kind=TargetKind.DXF) def profile_side_guard(self) -> Cq.Sketch: dx = self.facet_width_lower / 2 dy = self.height # Main control points p_mid = Cq.Location.from2d(0, 0.5 * dy) p_mid_v = Cq.Location.from2d(10/57 * dx, 0) p_top1 = Cq.Location.from2d(0.408 * dx, 5/24 * dy) p_top1_v = Cq.Location.from2d(0.13 * dx, 0) p_top2 = Cq.Location.from2d(0.737 * dx, 0.255 * dy) p_top2_c1 = p_top2 * Cq.Location.from2d(-0.105 * dx, 0.033 * dy) p_top2_c2 = p_top2 * Cq.Location.from2d(-0.053 * dx, -0.09 * dy) p_top3 = Cq.Location.from2d(0.929 * dx, 0.145 * dy) p_top3_v = Cq.Location.from2d(0.066 * dx, 0.033 * dy) p_top4 = Cq.Location.from2d(0.85 * dx, 0.374 * dy) p_top4_v = Cq.Location.from2d(-0.053 * dx, 0.008 * dy) p_top5 = Cq.Location.from2d(0.54 * dx, 0.349 * dy) p_top5_c1 = p_top5 * Cq.Location.from2d(0.103 * dx, 0.017 * dy) p_top5_c2 = p_top5 * Cq.Location.from2d(0.158 * dx, 0.034 * dy) p_base_c = Cq.Location.from2d(1.245 * dx, 0.55 * dy) p_base = Cq.Location.from2d(dx, 0) bezier_groups = [ [ p_base, p_base_c, p_top5_c2, p_top5, ], [ p_top5, p_top5_c1, p_top4 * p_top4_v, p_top4, ], [ p_top4, p_top4 * p_top4_v.inverse.scale(4), p_top3 * p_top3_v, p_top3, ], [ p_top3, p_top3 * p_top3_v.inverse, p_top2_c2, p_top2, ], [ p_top2, p_top2_c1, p_top1 * p_top1_v, p_top1, ], [ p_top1, p_top1 * p_top1_v.inverse, p_mid * p_mid_v, p_mid, ], ] sketch = ( Cq.Sketch() .segment( p_base.to2d_pos(), p_base.flip_x().to2d_pos(), ) ) for bezier_group in bezier_groups: sketch = ( sketch .bezier([p.to2d_pos() for p in bezier_group]) .bezier([p.flip_x().to2d_pos() for p in bezier_group]) ) return sketch.assemble() def side_guard_dovetail(self) -> Cq.Solid: """ Generates a dovetail coupling for the side guard """ dx = self.side_guard_thickness / 2 wire = Cq.Wire.makePolygon([ (dx * 0.5, 0), (dx * 0.7, dx), (-dx * 0.7, dx), (-dx * 0.5, 0), ], close=True) return Cq.Solid.extrudeLinear( wire, [], (0,0,dx + self.side_guard_dovetail_height), ).moved((0, 0, -dx)) def side_guard_frontal_slot(self) -> Cq.Workplane: angle = 360 / self.facets inner_d = self.thickness / 2 - self.slot_thickness / 2 outer_d = self.thickness / 2 + self.slot_thickness / 2 outer = Cq.Solid.makeCone( radius1=self.radius_lower_front + outer_d, radius2=self.radius_upper_front + outer_d, height=self.height, angleDegrees=angle, ) inner = Cq.Solid.makeCone( radius1=self.radius_lower_front + inner_d, radius2=self.radius_upper_front + inner_d, height=self.height, angleDegrees=angle, ) shell = ( outer.cut(inner) .rotate((0,0,0), (0,0,1), -angle/2) ) # Generate the sector intersector intersector = Cq.Solid.makeCylinder( radius=self.radius_upper + self.side_guard_thickness, height=self.front_wing_height, angleDegrees=self.front_wing_angle, ).moved(Cq.Location(0,0,self.front_wing_dh,0,0,-self.front_wing_angle/2)) return shell * intersector def side_guard( self, attach_left: AttachPoint, attach_right: AttachPoint, ) -> Cq.Workplane: """ Constructs the side guard using a cone. Via Gauss's Theorema Egregium, the surface of the cone can be deformed into a plane. """ angle_span = 360 / self.facets outer = Cq.Solid.makeCone( radius1=self.radius_lower + self.side_guard_thickness, radius2=self.radius_upper + self.side_guard_thickness, height=self.height, angleDegrees=angle_span, ) inner = Cq.Solid.makeCone( radius1=self.radius_lower, radius2=self.radius_upper, height=self.height, angleDegrees=angle_span, ) shell = (outer - inner).rotate((0,0,0), (0,0,1), -angle_span/2) dx = math.sin(math.radians(angle_span / 2)) * (self.radius_middle + self.side_guard_thickness) profile = ( Cq.Workplane('YZ') .polyline([ (0, self.height), (-dx, self.height / 2), (-dx, 0), (dx, 0), (dx, self.height / 2), ]) .close() .extrude(self.radius_upper + self.side_guard_thickness) .val() ) #channel = ( # Cq.Solid.makeCylinder( # radius=self.side_guard_channel_radius + 1.0, # height=self.side_guard_channel_height, # ) - Cq.Solid.makeCylinder( # radius=self.side_guard_channel_radius, # height=self.side_guard_channel_height, # ) #) result = shell * profile# - channel # Create the downward slots for sign in [-1, 1]: slot_box = Cq.Solid.makeBox( length=self.height, width=self.slot_width, height=self.slot_thickness, ).moved( Cq.Location(-self.slot_thickness,-self.slot_width/2, -self.slot_thickness/2) ) # keyhole for threads to stay in place slot_cyl = Cq.Solid.makeCylinder( radius=self.slot_thickness/2, height=self.height, pnt=(0,0,self.slot_thickness/2), dir=(1,0,0), ) slot = slot_box + slot_cyl slot = slot.moved( Cq.Location.rot2d(sign * self.side_guard_slot_angle) * Cq.Location(self.radius_lower + self.side_guard_thickness/2, 0, 0) * Cq.Location(0,0,0,0,-180 + self.slot_tilt,0) ) result = result - slot radius_attach = self.radius_lower + self.side_guard_thickness / 2 # tilt the dovetail by radius differential angle_tilt = math.degrees(math.atan2(self.radius_middle - self.radius_lower, self.height / 2)) dovetail = self.side_guard_dovetail() loc_dovetail_left = Cq.Location.rot2d(angle_span / 2) * Cq.Location(radius_attach, 0, 0, 0, angle_tilt, 0) loc_dovetail_right = Cq.Location.rot2d(-angle_span / 2) * Cq.Location(radius_attach, 0, 0, 0, angle_tilt, 0) angle_slot = 180 / self.facets - self.front_wing_angle / 2 match attach_left: case AttachPoint.DOVETAIL_IN: loc_dovetail_left *= Cq.Location.rot2d(180) result = result - dovetail.moved(loc_dovetail_left) case AttachPoint.DOVETAIL_OUT: result = result + dovetail.moved(loc_dovetail_left) case AttachPoint.SLOT: result = result - self.side_guard_frontal_slot().moved(Cq.Location.rot2d(angle_slot)) case AttachPoint.NONE: pass match attach_right: case AttachPoint.DOVETAIL_IN: result = result - dovetail.moved(loc_dovetail_right) case AttachPoint.DOVETAIL_OUT: loc_dovetail_right *= Cq.Location.rot2d(180) result = result + dovetail.moved(loc_dovetail_right) case AttachPoint.SLOT: result = result - self.side_guard_frontal_slot().moved(Cq.Location.rot2d(-angle_slot)) case AttachPoint.NONE: pass # Remove parts below the horizontal cut_h = self.radius_lower result -= Cq.Solid.makeCylinder( radius=self.radius_lower + self.side_guard_thickness, height=cut_h).moved((0,0,-cut_h)) return result @target(name="side_guard_1") def side_guard_1(self) -> Cq.Workplane: return self.side_guard( attach_left=AttachPoint.SLOT, attach_right=AttachPoint.DOVETAIL_IN, ) @target(name="side_guard_2") def side_guard_2(self) -> Cq.Workplane: return self.side_guard( attach_left=AttachPoint.DOVETAIL_OUT, attach_right=AttachPoint.DOVETAIL_IN, ) @target(name="side_guard_3") def side_guard_3(self) -> Cq.Workplane: return self.side_guard( attach_left=AttachPoint.DOVETAIL_OUT, attach_right=AttachPoint.DOVETAIL_IN, ) @target(name="side_guard_4") def side_guard_4(self) -> Cq.Workplane: return self.side_guard( attach_left=AttachPoint.DOVETAIL_OUT, attach_right=AttachPoint.SLOT, ) def front_surrogate(self) -> Cq.Workplane: """ Create a surrogate cylindrical section structure for the front since we cannot bend extrusions """ angle = 360 / 5 outer = Cq.Solid.makeCone( radius1=self.radius_lower_front + self.thickness, radius2=self.radius_upper_front + self.thickness, height=self.height, angleDegrees=angle, ) inner = Cq.Solid.makeCone( radius1=self.radius_lower_front, radius2=self.radius_upper_front, height=self.height, angleDegrees=angle, ) shell = ( outer.cut(inner) .rotate((0,0,0), (0,0,1), -angle/2) ) dx = math.sin(math.radians(angle / 2)) * self.radius_middle_front profile = ( Cq.Workplane('YZ') .polyline([ (0, self.height), (-dx, self.height / 2), (-dx, 0), (dx, 0), (dx, self.height / 2), ]) .close() .extrude(self.radius_upper_front + self.side_guard_thickness) .val() ) return shell * profile def assembly(self) -> Cq.Assembly: """ New assembly using conformal mapping on the cone. """ side_guards = [ self.side_guard_1(), self.side_guard_2(), self.side_guard_3(), self.side_guard_4(), ] a = Cq.Assembly() for i,side_guard in enumerate(side_guards): angle = -(i+1) * 360 / self.facets a = a.addS( side_guard, name=f"side-{i}", material=self.material_side, loc=Cq.Location(rz=angle) ) a.addS( self.front_surrogate(), name="front", material=self.material, ) return a def old_assembly(self) -> Cq.Assembly: front = ( Cq.Workplane('XY') .placeSketch(self.profile_front()) .extrude(self.thickness) ) side = ( Cq.Workplane('XY') .placeSketch(self.profile_side()) .extrude(self.thickness) ) side_guard = ( Cq.Workplane('XY') .placeSketch(self.profile_side_guard()) .extrude(self.thickness) ) assembly = ( Cq.Assembly() .addS( front, name="front", material=self.material, role=Role.DECORATION, ) ) for i, pos in enumerate([-2, -1, 1, 2]): x = self.facet_width_upper * pos assembly = ( assembly .addS( side, name=f"side{i}", material=self.material, role=Role.DECORATION, loc=Cq.Location.from2d(x, 0), ) .addS( side_guard, name=f"guard{i}", material=self.material, role=Role.DECORATION, loc=Cq.Location(x, 0, self.thickness), ) ) return assembly