from nhf import Material, Role from nhf.build import Model, target, assembly, TargetKind import nhf.utils import math from dataclasses import dataclass, field from enum import Enum import cadquery as Cq class AttachPoint(Enum): DOVETAIL_IN = 1 DOVETAIL_OUT = 2 NONE = 3 @dataclass class Crown(Model): facets: int = 5 # Lower circumference base_circ: float = 538.0 # Upper circumference, at the middle tilt_circ: float = 640.0 # Total height height: float = 120.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 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 @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) 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(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 / 5 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 for i in [-2, -1, 0, 1, 2]: phi = i * (math.pi / 14) hole = Cq.Solid.makeCylinder( radius=self.side_guard_hole_diam / 2, height=self.radius_upper * 2, pnt=(0, 0, self.side_guard_hole_height), dir=(math.cos(phi), math.sin(phi), 0), ) result = result - hole 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) 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.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.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_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_IN, attach_right=AttachPoint.DOVETAIL_IN, ) 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 + self.thickness, radius2=self.radius_upper + self.thickness, height=self.height, angleDegrees=angle, ) inner = Cq.Solid.makeCone( radius1=self.radius_lower, radius2=self.radius_upper, 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 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() ) return shell * profile def assembly(self) -> Cq.Assembly: """ New assembly using conformal mapping on the cone. """ side_guard = self.side_guard_2() a = Cq.Assembly() for i in range(1,5): a = a.addS( side_guard, name=f"side-{i}", material=self.material_side, loc=Cq.Location(rz=i*360/5) ) 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