make the minimum Z feature height configurable for the case
This commit is contained in:
2
main.py
2
main.py
@@ -88,7 +88,7 @@ def _model(config: Config, as_assy: bool=False) -> cq.Workplane:
|
||||
battery = ldtek_battery.LdtekBattery()
|
||||
elif config.battery:
|
||||
assert False, f"unknown battery: {battery!r}"
|
||||
return case.case(phone, battery=battery, render_phone=config.render_phone, as_assy=as_assy)
|
||||
return case.case(phone, battery=battery, config=config, render_phone=config.render_phone, as_assy=as_assy)
|
||||
|
||||
_computedModels: dict[(bool,), cq.Workplane] = {}
|
||||
def model(config: Config, as_assy: bool=False) -> cq.Workplane:
|
||||
|
767
src/case.py
767
src/case.py
@@ -1,64 +1,393 @@
|
||||
import cadquery as cq
|
||||
|
||||
# thickness of case walls
|
||||
thickness = 1.5
|
||||
from config import Config
|
||||
|
||||
# how far into the xy plane to extend the part of the case which covers the front of the phone.
|
||||
# the top of the phone contains stuff we don't want to cover (camera); the bottom has more margin
|
||||
overhang_leftright = 2
|
||||
overhang_top = 3.5
|
||||
overhang_bot = 3.5
|
||||
# how much to smooth the overhangs, else the main opening in front appears rectangular
|
||||
# if set to the body radius minus the overhang, then this will appear perfectly symmetric with the body fillet itself.
|
||||
overhang_radius = 6.6
|
||||
class CaseOps:
|
||||
# thickness of case walls
|
||||
thickness = 1.5
|
||||
|
||||
# how large of cuts (radial) to make around each component
|
||||
camera_cut_margin = 1.0
|
||||
aux_cut_margin = 4.5
|
||||
usb_cut_margin = 6.0
|
||||
# how far into the xy plane to extend the part of the case which covers the front of the phone.
|
||||
# the top of the phone contains stuff we don't want to cover (camera); the bottom has more margin
|
||||
overhang_leftright = 2
|
||||
overhang_top = 3.5
|
||||
overhang_bot = 3.5
|
||||
# how much to smooth the overhangs, else the main opening in front appears rectangular
|
||||
# if set to the body radius minus the overhang, then this will appear perfectly symmetric with the body fillet itself.
|
||||
overhang_radius = 6.6
|
||||
|
||||
# x margin (how extra thick the buttons could be)
|
||||
button_seat_margin_x = 0.2
|
||||
# radial margin. how much the buttons are allowed to drift within the button mold.
|
||||
# N.B.: keep this >= button_inset_gap to keep the part easily printable
|
||||
button_seat_margin_yz = 1.5
|
||||
# how large of cuts (radial) to make around each component
|
||||
camera_cut_margin = 1.0
|
||||
aux_cut_margin = 4.5
|
||||
usb_cut_margin = 6.0
|
||||
|
||||
# make the walls near the buttons this much thinner (by cutting away the *exterior*
|
||||
button_inset_x = 0.4
|
||||
button_inset_margin_yz = 5.8
|
||||
# length of the segment we cut away entirely from the body bordering each button
|
||||
button_inset_gap = 1.4
|
||||
button_rocker_width = 1.4
|
||||
button_rocker_length = 3.0
|
||||
# x margin (how extra thick the buttons could be)
|
||||
button_seat_margin_x = 0.2
|
||||
# radial margin. how much the buttons are allowed to drift within the button mold.
|
||||
# N.B.: keep this >= button_inset_gap to keep the part easily printable
|
||||
button_seat_margin_yz = 1.5
|
||||
|
||||
# TODO: this should be relative to the bottom of the case, not the center
|
||||
battery_shift_y = 2.5
|
||||
# battery_strap_height = 0.3 #< keep near 1-2 layer heights
|
||||
# battery_strap_length = 3.0
|
||||
# # how big a gap to leave between the start of the battery cutout and the first strap
|
||||
# # should be distant enough to slip the battery through.
|
||||
# battery_strap_inset_y = 8.0
|
||||
battery_harness_height = 0.3 #< keep near 1-2 layer heights
|
||||
battery_harness_shell_rad = 3.85
|
||||
battery_harness_shell_thickness = 1.2
|
||||
battery_harness_margin_bottom = 22.3
|
||||
battery_harness_margin_top = 0.0
|
||||
# make the walls near the buttons this much thinner (by cutting away the *exterior*
|
||||
button_inset_x = 0.4
|
||||
button_inset_margin_yz = 5.8
|
||||
# length of the segment we cut away entirely from the body bordering each button
|
||||
button_inset_gap = 1.4
|
||||
button_rocker_width = 1.4
|
||||
button_rocker_length = 3.0
|
||||
|
||||
dark_gray = cq.Color(95/256, 87/256, 79/256)
|
||||
mid_gray = cq.Color(128/256, 118/256, 108/256)
|
||||
mid_light_gray = cq.Color(160/256, 150/256, 140/256)
|
||||
light_gray = cq.Color(192/256, 182/256, 172/256)
|
||||
yellow = cq.Color(255/256, 236/256, 39/256)
|
||||
orange = cq.Color(255/256, 192/256, 39/256)
|
||||
yellow_orange = cq.Color(255/256, 208/256, 49/256)
|
||||
# TODO: this should be relative to the bottom of the case, not the center
|
||||
battery_shift_y = 2.5
|
||||
# battery_strap_height = 0.3 #< keep near 1-2 layer heights
|
||||
# battery_strap_length = 3.0
|
||||
# # how big a gap to leave between the start of the battery cutout and the first strap
|
||||
# # should be distant enough to slip the battery through.
|
||||
# battery_strap_inset_y = 8.0
|
||||
battery_harness_height = 0.3 #< keep near 1-2 layer heights
|
||||
battery_harness_shell_rad = 3.85
|
||||
battery_harness_shell_thickness = 1.2
|
||||
battery_harness_margin_bottom = 22.3
|
||||
battery_harness_margin_top = 0.0
|
||||
|
||||
body_color = mid_light_gray
|
||||
case_color = yellow_orange
|
||||
dark_gray = cq.Color(95/256, 87/256, 79/256)
|
||||
mid_gray = cq.Color(128/256, 118/256, 108/256)
|
||||
mid_light_gray = cq.Color(160/256, 150/256, 140/256)
|
||||
light_gray = cq.Color(192/256, 182/256, 172/256)
|
||||
yellow = cq.Color(255/256, 236/256, 39/256)
|
||||
orange = cq.Color(255/256, 192/256, 39/256)
|
||||
yellow_orange = cq.Color(255/256, 208/256, 49/256)
|
||||
|
||||
def _thicken(
|
||||
solid: cq.Workplane,
|
||||
thickness: float = 1,
|
||||
combine: bool = False,
|
||||
body_color = mid_light_gray
|
||||
case_color = yellow_orange
|
||||
|
||||
def __init__(self, config: Config):
|
||||
self.battery_harness_height = max(self.battery_harness_height, config.min_feature_z)
|
||||
|
||||
def make_shell(self, body: cq.Workplane) -> cq.Workplane:
|
||||
p = self
|
||||
|
||||
shell = thicken(body, p.thickness)
|
||||
# a true fillet on the print-bed side of the print isn't possible (it's an overhang),
|
||||
# so turn that into a chamfer, which can be printed
|
||||
body_slice = body.faces("not |Z").edges(">>Z[-3]")
|
||||
body_slice_z = body_slice.vals()[0].BoundingBox().zmax
|
||||
body_max_z = body.faces(tag="body_back").vals()[0].BoundingBox().zmax
|
||||
body_thick = body_slice \
|
||||
.toPending().offset2D(p.thickness) \
|
||||
.extrude(body_max_z - body_slice_z + p.thickness, combine=False)
|
||||
body_thin = body_slice.toPending().consolidateWires() \
|
||||
.extrude(body_max_z - body_slice_z + p.thickness, combine=False)
|
||||
|
||||
sides = body_thick.cut(body_thin)
|
||||
backing = thicken(body_thick.faces(">Z"), -p.thickness, combine=False)
|
||||
shell = shell.union(sides).union(backing)
|
||||
|
||||
shell = shell.edges(">Z").chamfer(p.thickness)
|
||||
shell = shell.tag("shell")
|
||||
return shell
|
||||
|
||||
def _mask_to_main_body(
|
||||
self,
|
||||
case: cq.Workplane,
|
||||
feature: cq.Workplane,
|
||||
margin_top: float = 0,
|
||||
) -> cq.Workplane:
|
||||
# if we cut too far into the screen encasing, then we create an overhang which might not print well.
|
||||
# and if we cut into the backing of the case, that's not great either
|
||||
# instead, we accept a non-arch, and rely that the printer can bridge the top.
|
||||
shell_main_bbox = case.faces("|Y and >Y", tag="shell").vals()[0].BoundingBox()
|
||||
mask = cq.Workplane("XY").box(1000, 1000, shell_main_bbox.zlen + margin_top, centered=True) \
|
||||
.translate(shell_main_bbox.center) \
|
||||
.translate((0, 0, 0.5*margin_top))
|
||||
return feature.intersect(mask)
|
||||
|
||||
def front_cutaway(self, case: cq.Workplane) -> cq.Workplane:
|
||||
p = self
|
||||
# split the case into the front part, which covers the screen, and the rear
|
||||
split_case = case.faces("<<Z").workplane(offset=-p.thickness).split(keepTop=True, keepBottom=True)
|
||||
case_front, case_back = split_case.all()
|
||||
# front_face is a closed face capturing the full width/height of the case at z=0
|
||||
front_face = case_front.faces(">Z")
|
||||
# we only want to cutaway this smaller part of the front face
|
||||
front_face_cutaway = (front_face
|
||||
.intersect(front_face.translate((p.thickness + p.overhang_leftright, 0, 0)))
|
||||
.intersect(front_face.translate((-(p.thickness + p.overhang_leftright), 0, 0)))
|
||||
.intersect(front_face.translate((0, p.thickness + p.overhang_top, 0)))
|
||||
.intersect(front_face.translate((0, -p.thickness - p.overhang_bot, 0)))
|
||||
)
|
||||
# "extrude" the front_face_cutaway into something thick enough to actually cut out
|
||||
# N.B.: i think this logic isn't 100% correct
|
||||
return front_face_cutaway.faces("|Z").each(lambda f: f.thicken(-p.thickness), combine=False) \
|
||||
.edges("|Z").fillet(p.overhang_radius)
|
||||
# return front_face_cutaway.faces("|Z").each(lambda f: f.thicken(p.thickness).translate((0, 0, -p.thickness)), combine=False)
|
||||
|
||||
def camera_cutaway(self, camera: cq.Workplane) -> cq.Workplane:
|
||||
p = self
|
||||
# take the face which attaches the camera to the phone, and extrude that.
|
||||
# cq imprecision requires i extrude > thickness to get a margin that's actually recognized as a complete cut
|
||||
cutout = camera.faces("<Z").each(lambda f: f.thicken(3*p.thickness), combine=False)\
|
||||
.translate((0, 0, 2*p.thickness))
|
||||
cutout = cutout.faces("not |Z").each(lambda f: f.thicken(p.camera_cut_margin), combine=True)
|
||||
return cutout
|
||||
|
||||
def aux_cutaway(self, case: cq.Workplane, aux: cq.Workplane) -> cq.Workplane:
|
||||
p = self
|
||||
# extrude through the case
|
||||
cutout = thicken(aux.faces("<Y"), p.thickness, combine=True)
|
||||
# thicken
|
||||
cutout = thicken(cutout.faces("not |Y"), p.aux_cut_margin, combine=True)
|
||||
|
||||
# if we cut too far into the screen encasing, then we create an overhang which might not print well.
|
||||
shell_main_bbox = case.faces("|Y and <Y", tag="shell").vals()[0].BoundingBox()
|
||||
cutout_top = cutout.vals()[0].BoundingBox().zmin
|
||||
cutout = cutout.translate((0, 0, max(0, shell_main_bbox.zmin - cutout_top)))
|
||||
|
||||
cutout = self._mask_to_main_body(case, cutout)
|
||||
|
||||
return cutout
|
||||
|
||||
def usb_cutaway(self, case: cq.Workplane, usb: cq.Workplane) -> cq.Workplane:
|
||||
p = self
|
||||
inner_fillet = 1.8 #< want to carve out the inner fillet as well, for better access
|
||||
cutout = cq.Workplane("XZ").add(usb.edges(">Y")) \
|
||||
.toPending().offset2D(p.usb_cut_margin) \
|
||||
.extrude(p.thickness + inner_fillet) \
|
||||
.translate((0, p.thickness, 0))
|
||||
|
||||
cutout = self._mask_to_main_body(case, cutout, margin_top=p.thickness)
|
||||
|
||||
return cutout
|
||||
|
||||
# body_top = phone.faces(">>Y").vals()[0].BoundingBox().zmin
|
||||
# cutout_top = cutout.vals()[0].BoundingBox().zmin
|
||||
# cutout, = cutout.faces("<Z").workplane(offset=-3).split(keepBottom=True, keepTop=False).all()
|
||||
# return cutout.translate((0, 0, max(0, body_top - cutout_top)))
|
||||
|
||||
def button_seat_cutaway(self, phone: cq.Workplane, buttons: cq.Workplane) -> cq.Workplane:
|
||||
p = self
|
||||
# cutout = cq.Workplane("YZ").add(buttons.faces("<X")).edges() \
|
||||
# .toPending().offset2D(p.button_seat_margin_yz) \
|
||||
# .extrude(p.thickness)
|
||||
cutout = thicken(buttons, p.button_seat_margin_yz, combine=False)
|
||||
mask = buttons.faces(">X").box(100, 100, 100, centered=(True, True, True), combine=False) \
|
||||
.translate((-50 + p.button_seat_margin_x, 0, 0))
|
||||
cutout = cutout.intersect(mask)
|
||||
cutout = cutout.union(buttons)
|
||||
return cutout
|
||||
|
||||
def button_inset_cutaway(self, phone: cq.Workplane, buttons: cq.Workplane) -> cq.Workplane:
|
||||
p = self
|
||||
cutout = cq.Workplane("YZ").add(buttons.faces("<X")) \
|
||||
.edges() \
|
||||
.toPending().offset2D(p.button_inset_margin_yz) \
|
||||
.extrude(p.button_inset_x)
|
||||
# TODO: could be nice to fillet the button box
|
||||
cutout = cutout.translate((p.thickness-p.button_inset_x, 0, 0))
|
||||
return cutout
|
||||
|
||||
def button_gap_cutaway(self, phone: cq.Workplane, buttons: cq.Workplane) -> cq.Workplane:
|
||||
"""
|
||||
cut through the case just around the edge of the buttons, to give them a tab
|
||||
that more easily flexes.
|
||||
"""
|
||||
p = self
|
||||
buttons2d = buttons.faces("<X")
|
||||
buttons = cq.Workplane("YZ").add(buttons2d) \
|
||||
.edges().toPending().consolidateWires() \
|
||||
.extrude(p.thickness)
|
||||
buttons_with_buffer = cq.Workplane("YZ").add(buttons2d) \
|
||||
.edges().toPending().offset2D(p.button_inset_gap) \
|
||||
.extrude(p.thickness)
|
||||
cutout = buttons_with_buffer.cut(buttons)
|
||||
cutout_top = (cutout
|
||||
.cut(cutout.translate((0, p.button_inset_gap, 0)))
|
||||
.intersect(buttons_with_buffer.translate((0, -2*p.button_inset_gap, 0)))
|
||||
)
|
||||
cutout_bot = (cutout
|
||||
.cut(cutout.translate((0, -p.button_inset_gap, 0)))
|
||||
.intersect(buttons_with_buffer.translate((0, 2*p.button_inset_gap, 0)))
|
||||
)
|
||||
cutout = cutout_top.union(cutout_bot)
|
||||
return cutout
|
||||
|
||||
# def button_rockers(phone, buttons):
|
||||
# buttons2d = buttons.faces("<X")
|
||||
# buttons = cq.Workplane("YZ").add(buttons2d) \
|
||||
# .edges().toPending().consolidateWires() \
|
||||
# .extrude(button_rocker_width) \
|
||||
# .translate((thickness-button_inset_x, 0, 0))
|
||||
#
|
||||
# rocker_top = buttons.faces("<Y").workplane(offset=-button_rocker_length).split(keepTop=True)
|
||||
# rocker_top = rocker_top.edges("not <X").fillet(button_rocker_width * 2/3)
|
||||
# rocker_bot = buttons.faces(">Y").workplane(offset=-button_rocker_length).split(keepTop=True)
|
||||
# rocker_bot = rocker_bot.edges("not <X").fillet(button_rocker_width * 2/3)
|
||||
#
|
||||
# rockers = rocker_top.union(rocker_bot)
|
||||
# return rockers
|
||||
#
|
||||
# def button_bump(phone, button):
|
||||
# button2d = button.faces("<X")
|
||||
# bump = button2d.sphere(radius=button_rocker_width, angle3=180, direct=(0, 1, 0), combine=False)
|
||||
# bump = bump.translate((thickness-button_inset_x, 0, 0))
|
||||
# return bump
|
||||
|
||||
def button_column(
|
||||
self,
|
||||
phone: cq.Workplane,
|
||||
button: cq.Workplane,
|
||||
align: str = "center",
|
||||
) -> cq.Workplane:
|
||||
"""
|
||||
make a column on the exterior of the phone, so that pressing it in anywhere should activation the adjacent button.
|
||||
"""
|
||||
p = self
|
||||
button2d = button.faces("<X")
|
||||
column = button2d.box(p.button_rocker_width, p.button_rocker_length, 20, centered=(False, True, True), combine=False)
|
||||
column = column.translate((p.thickness-p.button_inset_x, 0, 0))
|
||||
|
||||
mask = thicken(phone.faces(">>X", tag="body"), 100, combine=False)
|
||||
column = column.intersect(mask)
|
||||
|
||||
column = column.edges(">X").chamfer(p.button_rocker_width * 3/4)
|
||||
|
||||
button_bbox = button.vals()[0].BoundingBox()
|
||||
if align == "up":
|
||||
column = column.translate((0, -0.5 * button_bbox.ylen + 2*p.button_inset_gap, 0))
|
||||
elif align == "down":
|
||||
column = column.translate((0, 0.5 * button_bbox.ylen - 2*p.button_inset_gap, 0))
|
||||
return column
|
||||
|
||||
def battery_cutaway(self, phone: cq.Workplane, battery: cq.Workplane) -> cq.Workplane:
|
||||
p = self
|
||||
body_back_bb = phone.faces(tag="body_back") \
|
||||
.vals()[0].BoundingBox()
|
||||
battery_bb = battery.faces(tag="body_front") \
|
||||
.vals()[0].BoundingBox()
|
||||
|
||||
# work with the box version, else we risk trying to print overhangs from the filleting
|
||||
battery = battery.solids(tag="body_box")
|
||||
|
||||
# align by corner
|
||||
battery = battery.translate((
|
||||
body_back_bb.xmin - battery_bb.xmin,
|
||||
body_back_bb.ymin - battery_bb.ymin,
|
||||
body_back_bb.zmin - battery_bb.zmin,
|
||||
))
|
||||
body_width = body_back_bb.xlen
|
||||
body_length = body_back_bb.ylen
|
||||
battery_width = battery_bb.xlen
|
||||
battery_length = battery_bb.ylen
|
||||
# shift to center of plane
|
||||
battery = battery.translate((
|
||||
0.5*(body_width - battery_width),
|
||||
0.5*(body_length - battery_length),
|
||||
0.0,
|
||||
))
|
||||
# shift to desired position
|
||||
battery = battery.translate((0, p.battery_shift_y, 0))
|
||||
return battery
|
||||
|
||||
# def battery_straps(phone, battery):
|
||||
# """
|
||||
# the battery can be secured into the case by thin "straps", or slats.
|
||||
#
|
||||
# the straps can be printed at a depth *narrower* than the battery:
|
||||
# the flexibility of the material should flex to accomodate the battery
|
||||
# while also keeping under tension to prevent the battery from moving.
|
||||
# """
|
||||
# strap_box = battery_cutaway(phone, battery)
|
||||
# strap_box = strap_box \
|
||||
# .translate((0, 0, thickness)) \
|
||||
# .faces("<Z") \
|
||||
# .each(lambda f: f.thicken(battery_strap_height), combine=False)
|
||||
#
|
||||
# strap_box = (strap_box
|
||||
# .intersect(strap_box.translate((0, battery_strap_inset_y, 0)))
|
||||
# .intersect(strap_box.translate((0, -battery_strap_inset_y, 0)))
|
||||
# )
|
||||
#
|
||||
# strap_top = strap_box.cut(strap_box.translate((0, battery_strap_length, 0)))
|
||||
# strap_bot = strap_box.cut(strap_box.translate((0, -battery_strap_length, 0)))
|
||||
# straps = strap_top.union(strap_bot)
|
||||
#
|
||||
# return straps
|
||||
|
||||
def battery_harness(
|
||||
self,
|
||||
phone: cq.Workplane,
|
||||
battery: cq.Workplane,
|
||||
) -> cq.Workplane:
|
||||
p = self
|
||||
harness_box = self.battery_cutaway(phone, battery)
|
||||
harness_bbox = harness_box.vals()[0].BoundingBox()
|
||||
|
||||
# tighten spacing by `shell_thickness` to ensure the meeting points between the shells is that thick, instead of a 0-thickness tangent.
|
||||
# actually, don't tighten it *that* much else there's too much tangential overlap in circles.
|
||||
# tightening it somewhere between 0 and 1.0*shell_thickness seems to be best.
|
||||
pattern_spacing = p.battery_harness_shell_rad*2 - p.battery_harness_shell_thickness*0.25
|
||||
|
||||
# the weird 2*int(0.5*foo) pattern guarantees the count to be odd, necessary for `center` to center an actual point and not just the pattern
|
||||
xCount=5 + 2*int(0.5*0.5*harness_bbox.xlen / pattern_spacing)
|
||||
yCount=5 + 2*int(0.5*0.5*harness_bbox.ylen / pattern_spacing)
|
||||
pattern_points = harness_box.faces("<Z").workplane().rarray(
|
||||
2*pattern_spacing,
|
||||
2*pattern_spacing,
|
||||
xCount=xCount,
|
||||
yCount=yCount,
|
||||
center=True,
|
||||
)
|
||||
|
||||
pattern_cylinders = pattern_points.cylinder(
|
||||
height=p.battery_harness_height,
|
||||
radius=p.battery_harness_shell_rad,
|
||||
direct=(0, 0, -1),
|
||||
centered=(True, True, False),
|
||||
combine=False,
|
||||
)
|
||||
pattern_cylinder_cuts = pattern_points.cylinder(
|
||||
height=p.battery_harness_height,
|
||||
radius=p.battery_harness_shell_rad-p.battery_harness_shell_thickness,
|
||||
direct=(0, 0, -1),
|
||||
centered=(True, True, False),
|
||||
combine=False,
|
||||
)
|
||||
pattern_shells = pattern_cylinders.cut(pattern_cylinder_cuts)
|
||||
harness = (
|
||||
# the pattern is tiled circles, with the center of every 3x3 tile removed.
|
||||
# equivalent to a 2x2 grid with one corner removed, tiled.
|
||||
pattern_shells.translate((pattern_spacing, pattern_spacing, 0))
|
||||
.union(pattern_shells.translate((pattern_spacing, 0, 0)))
|
||||
.union(pattern_shells.translate((0, pattern_spacing, 0)))
|
||||
# .union(pattern_shells.translate(( pattern_spacing, -pattern_spacing, 0)))
|
||||
# .union(pattern_shells.translate((-pattern_spacing, 0, 0)))
|
||||
# .union(pattern_shells.translate(( pattern_spacing, 0, 0)))
|
||||
# .union(pattern_shells.translate((-pattern_spacing, pattern_spacing, 0)))
|
||||
# .union(pattern_shells.translate(( 0, pattern_spacing, 0)))
|
||||
# .union(pattern_shells.translate(( pattern_spacing, pattern_spacing, 0)))
|
||||
).translate((harness_bbox.center.x, harness_bbox.center.y, 0))
|
||||
|
||||
circles_pattern_center_to_bottom = (
|
||||
(harness_bbox.ymax - p.battery_harness_margin_bottom) - 0.5*harness_bbox.center.y
|
||||
) / (p.battery_harness_shell_rad*2)
|
||||
# want the mesh pattern to end at a solid row, rather than anywhere in the middle.
|
||||
# shift to make the distance between center and bottom to be 3 rad + 4*rad*n.
|
||||
# a.k.a. (1.5 + 2*n)*circle_diam
|
||||
harness = harness.translate((
|
||||
0.0,
|
||||
(p.battery_harness_shell_rad*2) * (0.5-(circles_pattern_center_to_bottom % 2)),
|
||||
0.0,
|
||||
))
|
||||
|
||||
harness = harness.intersect(harness_box)
|
||||
# cut an opening at the bottom for e.g. USB plug
|
||||
harness = harness.intersect(harness_box.translate((0, -p.battery_harness_margin_bottom, 0)))
|
||||
# cut an opening at the top (optionally). puts less strain on the rest of the case, but doesn't hold the battery as snug.
|
||||
harness = harness.intersect(harness_box.translate((0, p.battery_harness_margin_top, 0)))
|
||||
harness = harness.translate((0, 0, p.thickness - p.battery_harness_height))
|
||||
return harness
|
||||
|
||||
def thicken(
|
||||
solid: cq.Workplane,
|
||||
thickness: float = 1,
|
||||
combine: bool = False,
|
||||
) -> cq.Workplane:
|
||||
"""
|
||||
dilate the solid in all dimensions by `thickness` and then subtract the original.
|
||||
@@ -70,295 +399,6 @@ def _thicken(
|
||||
# alternatively, to perform an inset, set thickness negative and use combine="cut"
|
||||
return solid.faces().each(lambda f: f.thicken(thickness), combine=combine)
|
||||
|
||||
def make_shell(body: cq.Workplane) -> cq.Workplane:
|
||||
shell = _thicken(body, thickness)
|
||||
# a true fillet on the print-bed side of the print isn't possible (it's an overhang),
|
||||
# so turn that into a chamfer, which can be printed
|
||||
body_slice = body.faces("not |Z").edges(">>Z[-3]")
|
||||
body_slice_z = body_slice.vals()[0].BoundingBox().zmax
|
||||
body_max_z = body.faces(tag="body_back").vals()[0].BoundingBox().zmax
|
||||
body_thick = body_slice \
|
||||
.toPending().offset2D(thickness) \
|
||||
.extrude(body_max_z - body_slice_z + thickness, combine=False)
|
||||
body_thin = body_slice.toPending().consolidateWires() \
|
||||
.extrude(body_max_z - body_slice_z + thickness, combine=False)
|
||||
|
||||
sides = body_thick.cut(body_thin)
|
||||
backing = _thicken(body_thick.faces(">Z"), -thickness, combine=False)
|
||||
shell = shell.union(sides).union(backing)
|
||||
|
||||
shell = shell.edges(">Z").chamfer(thickness)
|
||||
shell = shell.tag("shell")
|
||||
return shell
|
||||
|
||||
def _mask_to_main_body(
|
||||
case: cq.Workplane,
|
||||
feature: cq.Workplane,
|
||||
margin_top: float = 0,
|
||||
) -> cq.Workplane:
|
||||
# if we cut too far into the screen encasing, then we create an overhang which might not print well.
|
||||
# and if we cut into the backing of the case, that's not great either
|
||||
# instead, we accept a non-arch, and rely that the printer can bridge the top.
|
||||
shell_main_bbox = case.faces("|Y and >Y", tag="shell").vals()[0].BoundingBox()
|
||||
mask = cq.Workplane("XY").box(1000, 1000, shell_main_bbox.zlen + margin_top, centered=True) \
|
||||
.translate(shell_main_bbox.center) \
|
||||
.translate((0, 0, 0.5*margin_top))
|
||||
return feature.intersect(mask)
|
||||
|
||||
def front_cutaway(case: cq.Workplane) -> cq.Workplane:
|
||||
# split the case into the front part, which covers the screen, and the rear
|
||||
split_case = case.faces("<<Z").workplane(offset=-thickness).split(keepTop=True, keepBottom=True)
|
||||
case_front, case_back = split_case.all()
|
||||
# front_face is a closed face capturing the full width/height of the case at z=0
|
||||
front_face = case_front.faces(">Z")
|
||||
# we only want to cutaway this smaller part of the front face
|
||||
front_face_cutaway = (front_face
|
||||
.intersect(front_face.translate((thickness + overhang_leftright, 0, 0)))
|
||||
.intersect(front_face.translate((-(thickness + overhang_leftright), 0, 0)))
|
||||
.intersect(front_face.translate((0, thickness + overhang_top, 0)))
|
||||
.intersect(front_face.translate((0, -thickness - overhang_bot, 0)))
|
||||
)
|
||||
# "extrude" the front_face_cutaway into something thick enough to actually cut out
|
||||
# N.B.: i think this logic isn't 100% correct
|
||||
return front_face_cutaway.faces("|Z").each(lambda f: f.thicken(-thickness), combine=False) \
|
||||
.edges("|Z").fillet(overhang_radius)
|
||||
# return front_face_cutaway.faces("|Z").each(lambda f: f.thicken(thickness).translate((0, 0, -thickness)), combine=False)
|
||||
|
||||
def camera_cutaway(camera: cq.Workplane) -> cq.Workplane:
|
||||
# take the face which attaches the camera to the phone, and extrude that.
|
||||
# cq imprecision requires i extrude > thickness to get a margin that's actually recognized as a complete cut
|
||||
cutout = camera.faces("<Z").each(lambda f: f.thicken(3*thickness), combine=False)\
|
||||
.translate((0, 0, 2*thickness))
|
||||
cutout = cutout.faces("not |Z").each(lambda f: f.thicken(camera_cut_margin), combine=True)
|
||||
return cutout
|
||||
|
||||
def aux_cutaway(case, aux):
|
||||
# extrude through the case
|
||||
cutout = _thicken(aux.faces("<Y"), thickness, combine=True)
|
||||
# thicken
|
||||
cutout = _thicken(cutout.faces("not |Y"), aux_cut_margin, combine=True)
|
||||
|
||||
# if we cut too far into the screen encasing, then we create an overhang which might not print well.
|
||||
shell_main_bbox = case.faces("|Y and <Y", tag="shell").vals()[0].BoundingBox()
|
||||
cutout_top = cutout.vals()[0].BoundingBox().zmin
|
||||
cutout = cutout.translate((0, 0, max(0, shell_main_bbox.zmin - cutout_top)))
|
||||
|
||||
cutout = _mask_to_main_body(case, cutout)
|
||||
|
||||
return cutout
|
||||
|
||||
def usb_cutaway(case: cq.Workplane, usb: cq.Workplane) -> cq.Workplane:
|
||||
inner_fillet = 1.8 #< want to carve out the inner fillet as well, for better access
|
||||
cutout = cq.Workplane("XZ").add(usb.edges(">Y")) \
|
||||
.toPending().offset2D(usb_cut_margin) \
|
||||
.extrude(thickness + inner_fillet) \
|
||||
.translate((0, thickness, 0))
|
||||
|
||||
cutout = _mask_to_main_body(case, cutout, margin_top=thickness)
|
||||
|
||||
return cutout
|
||||
|
||||
# body_top = phone.faces(">>Y").vals()[0].BoundingBox().zmin
|
||||
# cutout_top = cutout.vals()[0].BoundingBox().zmin
|
||||
# cutout, = cutout.faces("<Z").workplane(offset=-3).split(keepBottom=True, keepTop=False).all()
|
||||
# return cutout.translate((0, 0, max(0, body_top - cutout_top)))
|
||||
|
||||
def button_seat_cutaway(phone: cq.Workplane, buttons: cq.Workplane) -> cq.Workplane:
|
||||
# cutout = cq.Workplane("YZ").add(buttons.faces("<X")).edges() \
|
||||
# .toPending().offset2D(button_seat_margin_yz) \
|
||||
# .extrude(thickness)
|
||||
cutout = _thicken(buttons, button_seat_margin_yz, combine=False)
|
||||
mask = buttons.faces(">X").box(100, 100, 100, centered=(True, True, True), combine=False) \
|
||||
.translate((-50 + button_seat_margin_x, 0, 0))
|
||||
cutout = cutout.intersect(mask)
|
||||
cutout = cutout.union(buttons)
|
||||
return cutout
|
||||
|
||||
def button_inset_cutaway(phone: cq.Workplane, buttons: cq.Workplane) -> cq.Workplane:
|
||||
cutout = cq.Workplane("YZ").add(buttons.faces("<X")) \
|
||||
.edges() \
|
||||
.toPending().offset2D(button_inset_margin_yz) \
|
||||
.extrude(button_inset_x)
|
||||
# TODO: could be nice to fillet the button box
|
||||
cutout = cutout.translate((thickness-button_inset_x, 0, 0))
|
||||
return cutout
|
||||
|
||||
def button_gap_cutaway(phone: cq.Workplane, buttons: cq.Workplane) -> cq.Workplane:
|
||||
"""
|
||||
cut through the case just around the edge of the buttons, to give them a tab
|
||||
that more easily flexes.
|
||||
"""
|
||||
buttons2d = buttons.faces("<X")
|
||||
buttons = cq.Workplane("YZ").add(buttons2d) \
|
||||
.edges().toPending().consolidateWires() \
|
||||
.extrude(thickness)
|
||||
buttons_with_buffer = cq.Workplane("YZ").add(buttons2d) \
|
||||
.edges().toPending().offset2D(button_inset_gap) \
|
||||
.extrude(thickness)
|
||||
cutout = buttons_with_buffer.cut(buttons)
|
||||
cutout_top = (cutout
|
||||
.cut(cutout.translate((0, button_inset_gap, 0)))
|
||||
.intersect(buttons_with_buffer.translate((0, -2*button_inset_gap, 0)))
|
||||
)
|
||||
cutout_bot = (cutout
|
||||
.cut(cutout.translate((0, -button_inset_gap, 0)))
|
||||
.intersect(buttons_with_buffer.translate((0, 2*button_inset_gap, 0)))
|
||||
)
|
||||
cutout = cutout_top.union(cutout_bot)
|
||||
return cutout
|
||||
|
||||
# def button_rockers(phone, buttons):
|
||||
# buttons2d = buttons.faces("<X")
|
||||
# buttons = cq.Workplane("YZ").add(buttons2d) \
|
||||
# .edges().toPending().consolidateWires() \
|
||||
# .extrude(button_rocker_width) \
|
||||
# .translate((thickness-button_inset_x, 0, 0))
|
||||
#
|
||||
# rocker_top = buttons.faces("<Y").workplane(offset=-button_rocker_length).split(keepTop=True)
|
||||
# rocker_top = rocker_top.edges("not <X").fillet(button_rocker_width * 2/3)
|
||||
# rocker_bot = buttons.faces(">Y").workplane(offset=-button_rocker_length).split(keepTop=True)
|
||||
# rocker_bot = rocker_bot.edges("not <X").fillet(button_rocker_width * 2/3)
|
||||
#
|
||||
# rockers = rocker_top.union(rocker_bot)
|
||||
# return rockers
|
||||
#
|
||||
# def button_bump(phone, button):
|
||||
# button2d = button.faces("<X")
|
||||
# bump = button2d.sphere(radius=button_rocker_width, angle3=180, direct=(0, 1, 0), combine=False)
|
||||
# bump = bump.translate((thickness-button_inset_x, 0, 0))
|
||||
# return bump
|
||||
|
||||
def button_column(
|
||||
phone: cq.Workplane,
|
||||
button: cq.Workplane,
|
||||
align: str = "center",
|
||||
) -> cq.Workplane:
|
||||
"""
|
||||
make a column on the exterior of the phone, so that pressing it in anywhere should activation the adjacent button.
|
||||
"""
|
||||
button2d = button.faces("<X")
|
||||
column = button2d.box(button_rocker_width, button_rocker_length, 20, centered=(False, True, True), combine=False)
|
||||
column = column.translate((thickness-button_inset_x, 0, 0))
|
||||
|
||||
mask = _thicken(phone.faces(">>X", tag="body"), 100, combine=False)
|
||||
column = column.intersect(mask)
|
||||
|
||||
column = column.edges(">X").chamfer(button_rocker_width * 3/4)
|
||||
|
||||
button_bbox = button.vals()[0].BoundingBox()
|
||||
if align == "up":
|
||||
column = column.translate((0, -0.5 * button_bbox.ylen + 2*button_inset_gap, 0))
|
||||
elif align == "down":
|
||||
column = column.translate((0, 0.5 * button_bbox.ylen - 2*button_inset_gap, 0))
|
||||
return column
|
||||
|
||||
def battery_cutaway(phone, battery):
|
||||
body_back_bb = phone.faces(tag="body_back") \
|
||||
.vals()[0].BoundingBox()
|
||||
battery_bb = battery.faces(tag="body_front") \
|
||||
.vals()[0].BoundingBox()
|
||||
|
||||
# work with the box version, else we risk trying to print overhangs from the filleting
|
||||
battery = battery.solids(tag="body_box")
|
||||
|
||||
# align by corner
|
||||
battery = battery.translate((
|
||||
body_back_bb.xmin - battery_bb.xmin,
|
||||
body_back_bb.ymin - battery_bb.ymin,
|
||||
body_back_bb.zmin - battery_bb.zmin,
|
||||
))
|
||||
body_width = body_back_bb.xlen
|
||||
body_length = body_back_bb.ylen
|
||||
battery_width = battery_bb.xlen
|
||||
battery_length = battery_bb.ylen
|
||||
# shift to center of plane
|
||||
battery = battery.translate((
|
||||
0.5*(body_width - battery_width),
|
||||
0.5*(body_length - battery_length),
|
||||
0.0,
|
||||
))
|
||||
# shift to desired position
|
||||
battery = battery.translate((0, battery_shift_y, 0))
|
||||
return battery
|
||||
|
||||
# def battery_straps(phone, battery):
|
||||
# """
|
||||
# the battery can be secured into the case by thin "straps", or slats.
|
||||
#
|
||||
# the straps can be printed at a depth *narrower* than the battery:
|
||||
# the flexibility of the material should flex to accomodate the battery
|
||||
# while also keeping under tension to prevent the battery from moving.
|
||||
# """
|
||||
# strap_box = battery_cutaway(phone, battery)
|
||||
# strap_box = strap_box \
|
||||
# .translate((0, 0, thickness)) \
|
||||
# .faces("<Z") \
|
||||
# .each(lambda f: f.thicken(battery_strap_height), combine=False)
|
||||
#
|
||||
# strap_box = (strap_box
|
||||
# .intersect(strap_box.translate((0, battery_strap_inset_y, 0)))
|
||||
# .intersect(strap_box.translate((0, -battery_strap_inset_y, 0)))
|
||||
# )
|
||||
#
|
||||
# strap_top = strap_box.cut(strap_box.translate((0, battery_strap_length, 0)))
|
||||
# strap_bot = strap_box.cut(strap_box.translate((0, -battery_strap_length, 0)))
|
||||
# straps = strap_top.union(strap_bot)
|
||||
#
|
||||
# return straps
|
||||
|
||||
def battery_harness(phone: cq.Workplane, battery: cq.Workplane) -> cq.Workplane:
|
||||
harness_box = battery_cutaway(phone, battery)
|
||||
harness_bbox = harness_box.vals()[0].BoundingBox()
|
||||
|
||||
# tighten spacing by `shell_thickness` to ensure the meeting points between the shells is that thick, instead of a 0-thickness tangent.
|
||||
# actually, don't tighten it *that* much else there's too much tangential overlap in circles.
|
||||
# tightening it somewhere between 0 and 1.0*shell_thickness seems to be best.
|
||||
pattern_spacing = battery_harness_shell_rad*2 - battery_harness_shell_thickness*0.25
|
||||
|
||||
# the weird 2*int(0.5*foo) pattern guarantees the count to be odd, necessary for `center` to center an actual point and not just the pattern
|
||||
xCount=5 + 2*int(0.5*0.5*harness_bbox.xlen / pattern_spacing)
|
||||
yCount=5 + 2*int(0.5*0.5*harness_bbox.ylen / pattern_spacing)
|
||||
pattern_points = harness_box.faces("<Z").workplane().rarray(
|
||||
2*pattern_spacing,
|
||||
2*pattern_spacing,
|
||||
xCount=xCount,
|
||||
yCount=yCount,
|
||||
center=True,
|
||||
)
|
||||
|
||||
pattern_cylinders = pattern_points.cylinder(height=battery_harness_height, radius=battery_harness_shell_rad, direct=(0, 0, -1), centered=(True, True, False), combine=False)
|
||||
pattern_cylinder_cuts = pattern_points.cylinder(height=battery_harness_height, radius=battery_harness_shell_rad-battery_harness_shell_thickness, direct=(0, 0, -1), centered=(True, True, False), combine=False)
|
||||
pattern_shells = pattern_cylinders.cut(pattern_cylinder_cuts)
|
||||
harness = (
|
||||
# the pattern is tiled circles, with the center of every 3x3 tile removed.
|
||||
# equivalent to a 2x2 grid with one corner removed, tiled.
|
||||
pattern_shells.translate((pattern_spacing, pattern_spacing, 0))
|
||||
.union(pattern_shells.translate((pattern_spacing, 0, 0)))
|
||||
.union(pattern_shells.translate((0, pattern_spacing, 0)))
|
||||
# .union(pattern_shells.translate(( pattern_spacing, -pattern_spacing, 0)))
|
||||
# .union(pattern_shells.translate((-pattern_spacing, 0, 0)))
|
||||
# .union(pattern_shells.translate(( pattern_spacing, 0, 0)))
|
||||
# .union(pattern_shells.translate((-pattern_spacing, pattern_spacing, 0)))
|
||||
# .union(pattern_shells.translate(( 0, pattern_spacing, 0)))
|
||||
# .union(pattern_shells.translate(( pattern_spacing, pattern_spacing, 0)))
|
||||
).translate((harness_bbox.center.x, harness_bbox.center.y, 0))
|
||||
|
||||
circles_pattern_center_to_bottom = ((harness_bbox.ymax - battery_harness_margin_bottom) - 0.5*harness_bbox.center.y) / (battery_harness_shell_rad*2)
|
||||
# want the mesh pattern to end at a solid row, rather than anywhere in the middle.
|
||||
# shift to make the distance between center and bottom to be 3 rad + 4*rad*n.
|
||||
# a.k.a. (1.5 + 2*n)*circle_diam
|
||||
harness = harness.translate(
|
||||
(0.0, (battery_harness_shell_rad*2) * (0.5-(circles_pattern_center_to_bottom % 2)), 0.0)
|
||||
)
|
||||
|
||||
harness = harness.intersect(harness_box)
|
||||
# cut an opening at the bottom for e.g. USB plug
|
||||
harness = harness.intersect(harness_box.translate((0, -battery_harness_margin_bottom, 0)))
|
||||
# cut an opening at the top (optionally). puts less strain on the rest of the case, but doesn't hold the battery as snug.
|
||||
harness = harness.intersect(harness_box.translate((0, battery_harness_margin_top, 0)))
|
||||
harness = harness.translate((0, 0, thickness - battery_harness_height))
|
||||
return harness
|
||||
|
||||
def orient_for_printing(case: cq.Workplane) -> cq.Workplane:
|
||||
"""
|
||||
rotate the case so that the back is at -z and the screen part is at +z.
|
||||
@@ -367,11 +407,14 @@ def orient_for_printing(case: cq.Workplane) -> cq.Workplane:
|
||||
return case.rotate((0, 0, 0), (1000, 0, 0), angleDegrees=180)
|
||||
|
||||
def case(
|
||||
phone: cq.Workplane,
|
||||
battery: cq.Workplane | None = None,
|
||||
render_phone: bool = False,
|
||||
as_assy: bool = True,
|
||||
phone: cq.Workplane,
|
||||
battery: cq.Workplane | None,
|
||||
config: Config,
|
||||
# TODO: lift these two params out a level: this function shouldn't care about toplevel rendering
|
||||
render_phone: bool = False,
|
||||
as_assy: bool = True,
|
||||
) -> cq.Workplane:
|
||||
ops = CaseOps(config)
|
||||
body = phone.solids(tag="body")
|
||||
power = phone.solids(tag="power")
|
||||
volume = phone.solids(tag="volume")
|
||||
@@ -379,28 +422,28 @@ def case(
|
||||
camera = phone.solids(tag="camera")
|
||||
usb = phone.solids(tag="usb")
|
||||
|
||||
case = make_shell(body)
|
||||
case = case.cut(front_cutaway(case))
|
||||
case = case.cut(aux_cutaway(case, aux))
|
||||
case = case.cut(camera_cutaway(camera))
|
||||
case = case.cut(usb_cutaway(case, usb))
|
||||
case = ops.make_shell(body)
|
||||
case = case.cut(ops.front_cutaway(case))
|
||||
case = case.cut(ops.aux_cutaway(case, aux))
|
||||
case = case.cut(ops.camera_cutaway(camera))
|
||||
case = case.cut(ops.usb_cutaway(case, usb))
|
||||
|
||||
case = case.cut(button_seat_cutaway(phone, volume))
|
||||
case = case.cut(button_seat_cutaway(phone, power))
|
||||
case = case.cut(button_gap_cutaway(phone, volume))
|
||||
case = case.cut(button_gap_cutaway(phone, power))
|
||||
case = case.cut(button_inset_cutaway(phone, volume))
|
||||
case = case.cut(button_inset_cutaway(phone, power))
|
||||
case = case.add(button_column(phone, volume, "up"))
|
||||
case = case.add(button_column(phone, volume, "down"))
|
||||
case = case.cut(ops.button_seat_cutaway(phone, volume))
|
||||
case = case.cut(ops.button_seat_cutaway(phone, power))
|
||||
case = case.cut(ops.button_gap_cutaway(phone, volume))
|
||||
case = case.cut(ops.button_gap_cutaway(phone, power))
|
||||
case = case.cut(ops.button_inset_cutaway(phone, volume))
|
||||
case = case.cut(ops.button_inset_cutaway(phone, power))
|
||||
case = case.add(ops.button_column(phone, volume, "up"))
|
||||
case = case.add(ops.button_column(phone, volume, "down"))
|
||||
# case = case.add(button_rockers(phone, volume))
|
||||
# case = case.add(button_bump(phone, power))
|
||||
case = case.add(button_column(phone, power))
|
||||
case = case.add(ops.button_column(phone, power))
|
||||
|
||||
if battery:
|
||||
case = case.cut(battery_cutaway(phone, battery))
|
||||
case = case.cut(ops.battery_cutaway(phone, battery))
|
||||
# case = case.add(battery_straps(phone, battery))
|
||||
case = case.add(battery_harness(phone, battery))
|
||||
case = case.add(ops.battery_harness(phone, battery))
|
||||
|
||||
# TODO: compress the case along the Z axis, to give a snugger fit (0.8mm is a good compression)
|
||||
|
||||
@@ -408,9 +451,9 @@ def case(
|
||||
phone = orient_for_printing(phone)
|
||||
|
||||
if as_assy:
|
||||
case = cq.Assembly(case, color=case_color)
|
||||
case = cq.Assembly(case, color=ops.case_color)
|
||||
if render_phone:
|
||||
case = case.add(phone, color=body_color)
|
||||
case = case.add(phone, color=ops.body_color)
|
||||
else:
|
||||
if render_phone:
|
||||
case = case.union(phone)
|
||||
|
@@ -6,7 +6,11 @@ DEFAULT_BATTERY = "ldtek"
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
min_feature_z: float = 0.0
|
||||
|
||||
battery: str = DEFAULT_BATTERY
|
||||
|
||||
# TODO: these should really be lifted out, as they don't impact the model
|
||||
render_phone: bool = False
|
||||
render_phone_only: bool = False
|
||||
|
||||
@@ -24,6 +28,10 @@ class Config:
|
||||
if battery is not None:
|
||||
self.battery = battery
|
||||
|
||||
min_feature_z = os.environ.get("CASE_MIN_FEATURE_Z_MM")
|
||||
if min_feature_z is not None:
|
||||
self.min_feature_z = float(min_feature_z)
|
||||
|
||||
render_phone = os.environ.get("CASE_RENDER_PHONE")
|
||||
if render_phone is not None:
|
||||
self.render_phone = render_phone not in ("", "0")
|
||||
|
Reference in New Issue
Block a user