Cosplay/nhf/touhou/shiki_eiki/crown.py

753 lines
25 KiB
Python

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
assert self.slot_phi < 2 * math.pi / self.facets
@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)
@property
def slot_r0(self):
return self.radius_lower + self.thickness / 2
@property
def slot_r1(self):
return self.radius_upper + self.thickness / 2
@property
def slot_h0(self) -> float:
"""
Phantom height formed by similar triangle, i.e. h0 in
(h0 + h) / r2 = h0 / r1
"""
rat = self.slot_r0 / (self.slot_r1 - self.slot_r0)
return self.height * rat
@property
def slot_outer_h0(self):
rat = (self.slot_r0 + self.side_guard_thickness) / (self.slot_r1 - self.slot_r0)
return self.height * rat
@property
def slot_theta(self) -> float:
"""
Cone tilt, related to other quantities by
h0 = r1 * cot theta
"""
h = self.height
return math.atan(self.slot_r0 / (self.height + self.slot_h0))
@property
def slot_phi(self) -> float:
"""
When a slice of the crown is expanded (via Gauss's Theorema Egregium),
it does not form a full circle. phi is the angle of one of the slices.
Note that on the cone itself, the angular slice is `2 pi / n` which `n`
is the number of sides.
"""
arc = self.slot_r0 * math.pi * 2 / self.facets
rho = self.slot_h0 / math.cos(self.slot_theta)
return arc / rho
def profile_base(self) -> Cq.Sketch:
# Generate a conical pentagonal shape
y0 = self.slot_h0 / math.cos(self.slot_theta)
yh = (self.height/2 + self.slot_h0) / math.cos(self.slot_theta)
yq = (self.height*3/4 + self.slot_h0) / math.cos(self.slot_theta)
y1 = (self.height + self.slot_h0) / math.cos(self.slot_theta)
phi2 = self.slot_phi / 2
return (
Cq.Sketch()
.segment(
(y0 * math.sin(phi2), y0 * (-1 + math.cos(phi2))),
(yh * math.sin(phi2), -y0 + yh * math.cos(phi2)),
)
.arc(
(yh * math.sin(phi2), -y0 + yh * math.cos(phi2)),
(yq * math.sin(phi2/2), -y0 + yq * math.cos(phi2/2)),
(0, y1 - y0),
)
.arc(
(-yh * math.sin(phi2), -y0 + yh * math.cos(phi2)),
(-yq * math.sin(phi2/2), -y0 + yq * math.cos(phi2/2)),
(0, y1 - y0),
)
.segment(
(-y0 * math.sin(phi2), y0 * (-1 + math.cos(phi2))),
(-yh * math.sin(phi2), -y0 + yh * math.cos(phi2)),
)
.arc(
(y0 * math.sin(phi2), -y0 + y0 * math.cos(phi2)),
(0, 0),
(-y0 * math.sin(phi2), y0 * (-1 + math.cos(phi2))),
)
.assemble()
)
@target(name="eye", kind=TargetKind.DXF)
def profile_eye(self) -> Cq.Sketch:
"""
deprecated
"""
dy = self.facet_width_upper * 0.1
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 eye
@target(name="dot", kind=TargetKind.DXF)
def profile_dot(self) -> Cq.Sketch:
return (
Cq.Sketch()
.circle(self.margin / 2)
)
def profile_front_wing(self, mirror: bool) -> Cq.Sketch:
"""
These two wings help the front profile attach
"""
hw = self.front_wing_height / math.cos(self.slot_theta)
hw0 = (self.front_wing_dh + self.slot_h0) / math.cos(self.slot_theta)
hw1 = hw0 + hw
y0 = self.slot_h0 / math.cos(self.slot_theta)
# Calculate angle of wing analogously to `this.slot_phi`. This arc's
# radius is hw0.
wing_arc = self.slot_r0 * math.radians(self.front_wing_angle)
phi_w = wing_arc / hw0
sign = -1 if mirror else 1
phi2 = self.slot_phi / 2
return (
Cq.Sketch()
.segment(
(sign * hw0 * math.sin(phi2), -y0 + hw0 * math.cos(phi2)),
(sign * hw1 * math.sin(phi2), -y0 + hw1 * math.cos(phi2)),
)
.segment(
(sign * hw0 * math.sin(phi2+phi_w), -y0 + hw0 * math.cos(phi2+phi_w)),
(sign * hw1 * math.sin(phi2+phi_w), -y0 + hw1 * math.cos(phi2+phi_w)),
)
.arc(
(sign * hw0 * math.sin(phi2), -y0 + hw0 * math.cos(phi2)),
(sign * hw0 * math.sin(phi2+phi_w/2), -y0 + hw0 * math.cos(phi2+phi_w/2)),
(sign * hw0 * math.sin(phi2+phi_w), -y0 + hw0 * math.cos(phi2+phi_w)),
)
.arc(
(sign * hw1 * math.sin(phi2), -y0 + hw1 * math.cos(phi2)),
(sign * hw1 * math.sin(phi2+phi_w/2), -y0 + hw1 * math.cos(phi2+phi_w/2)),
(sign * hw1 * math.sin(phi2+phi_w), -y0 + hw1 * math.cos(phi2+phi_w)),
)
.assemble()
)
@target(name="front", kind=TargetKind.DXF)
def profile_front(self) -> Cq.Sketch:
"""
Front profile slots into holes on the side guards
"""
profile_base = (
self.profile_base()
.boolean(self.profile_front_wing(False), mode='a')
.boolean(self.profile_front_wing(True), mode='a')
)
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) * 0.95),
)
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/32 # "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 (
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.5 * dx, 0.55 * dy)
y0 = self.slot_outer_h0 / math.cos(self.slot_theta)
phi2 = self.slot_phi / 2
p_base = Cq.Location.from2d(y0 * math.sin(phi2), -y0 + y0 * math.cos(phi2))
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()
.arc(
p_base.to2d_pos(),
(0, 0),
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", angularTolerance=0.01)
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", angularTolerance=0.01)
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", angularTolerance=0.01)
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", angularTolerance=0.01)
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