make the minimum Z feature height configurable for the case

This commit is contained in:
2025-04-08 05:54:46 +00:00
parent a2baab41e9
commit d033e1005f
3 changed files with 414 additions and 363 deletions

View File

@@ -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:

View File

@@ -1,101 +1,95 @@
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,
) -> cq.Workplane:
"""
dilate the solid in all dimensions by `thickness` and then subtract the original.
this implementation only behaves as expected if the solid has no cusps (i.e. is smoothed).
so for boxes, one should fillet the edges first (even if fillet'd with a trivial radius).
alternatively, shell in each direction iteratively with combine=True, and then cut.
e.g. select all |Z faces, shell with combine=True, then select all not |Z faces, shell with combine=True, then cut away the original.
"""
# alternatively, to perform an inset, set thickness negative and use combine="cut"
return solid.faces().each(lambda f: f.thicken(thickness), combine=combine)
body_color = mid_light_gray
case_color = yellow_orange
def make_shell(body: cq.Workplane) -> cq.Workplane:
shell = _thicken(body, thickness)
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(thickness) \
.extrude(body_max_z - body_slice_z + thickness, combine=False)
.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 + thickness, combine=False)
.extrude(body_max_z - body_slice_z + p.thickness, combine=False)
sides = body_thick.cut(body_thin)
backing = _thicken(body_thick.faces(">Z"), -thickness, combine=False)
backing = thicken(body_thick.faces(">Z"), -p.thickness, combine=False)
shell = shell.union(sides).union(backing)
shell = shell.edges(">Z").chamfer(thickness)
shell = shell.edges(">Z").chamfer(p.thickness)
shell = shell.tag("shell")
return shell
def _mask_to_main_body(
def _mask_to_main_body(
self,
case: cq.Workplane,
feature: cq.Workplane,
margin_top: float = 0,
) -> cq.Workplane:
) -> 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.
@@ -105,56 +99,60 @@ def _mask_to_main_body(
.translate((0, 0, 0.5*margin_top))
return feature.intersect(mask)
def front_cutaway(case: cq.Workplane) -> cq.Workplane:
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=-thickness).split(keepTop=True, keepBottom=True)
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((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)))
.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(-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)
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(camera: cq.Workplane) -> cq.Workplane:
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*thickness), combine=False)\
.translate((0, 0, 2*thickness))
cutout = cutout.faces("not |Z").each(lambda f: f.thicken(camera_cut_margin), combine=True)
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(case, aux):
def aux_cutaway(self, case: cq.Workplane, aux: cq.Workplane) -> cq.Workplane:
p = self
# extrude through the case
cutout = _thicken(aux.faces("<Y"), thickness, combine=True)
cutout = thicken(aux.faces("<Y"), p.thickness, combine=True)
# thicken
cutout = _thicken(cutout.faces("not |Y"), aux_cut_margin, combine=True)
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 = _mask_to_main_body(case, cutout)
cutout = self._mask_to_main_body(case, cutout)
return cutout
def usb_cutaway(case: cq.Workplane, usb: cq.Workplane) -> cq.Workplane:
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(usb_cut_margin) \
.extrude(thickness + inner_fillet) \
.translate((0, thickness, 0))
.toPending().offset2D(p.usb_cut_margin) \
.extrude(p.thickness + inner_fillet) \
.translate((0, p.thickness, 0))
cutout = _mask_to_main_body(case, cutout, margin_top=thickness)
cutout = self._mask_to_main_body(case, cutout, margin_top=p.thickness)
return cutout
@@ -163,96 +161,102 @@ def usb_cutaway(case: cq.Workplane, usb: cq.Workplane) -> cq.Workplane:
# 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:
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(button_seat_margin_yz) \
# .extrude(thickness)
cutout = _thicken(buttons, button_seat_margin_yz, combine=False)
# .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 + button_seat_margin_x, 0, 0))
.translate((-50 + p.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:
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(button_inset_margin_yz) \
.extrude(button_inset_x)
.toPending().offset2D(p.button_inset_margin_yz) \
.extrude(p.button_inset_x)
# TODO: could be nice to fillet the button box
cutout = cutout.translate((thickness-button_inset_x, 0, 0))
cutout = cutout.translate((p.thickness-p.button_inset_x, 0, 0))
return cutout
def button_gap_cutaway(phone: cq.Workplane, buttons: cq.Workplane) -> cq.Workplane:
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(thickness)
.extrude(p.thickness)
buttons_with_buffer = cq.Workplane("YZ").add(buttons2d) \
.edges().toPending().offset2D(button_inset_gap) \
.extrude(thickness)
.edges().toPending().offset2D(p.button_inset_gap) \
.extrude(p.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)))
.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, -button_inset_gap, 0)))
.intersect(buttons_with_buffer.translate((0, 2*button_inset_gap, 0)))
.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_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(
def button_column(
self,
phone: cq.Workplane,
button: cq.Workplane,
align: str = "center",
) -> cq.Workplane:
) -> 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(button_rocker_width, button_rocker_length, 20, centered=(False, True, True), combine=False)
column = column.translate((thickness-button_inset_x, 0, 0))
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)
mask = thicken(phone.faces(">>X", tag="body"), 100, combine=False)
column = column.intersect(mask)
column = column.edges(">X").chamfer(button_rocker_width * 3/4)
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*button_inset_gap, 0))
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*button_inset_gap, 0))
column = column.translate((0, 0.5 * button_bbox.ylen - 2*p.button_inset_gap, 0))
return column
def battery_cutaway(phone, battery):
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") \
@@ -278,42 +282,47 @@ def battery_cutaway(phone, battery):
0.0,
))
# shift to desired position
battery = battery.translate((0, battery_shift_y, 0))
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_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)
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 = battery_harness_shell_rad*2 - battery_harness_shell_thickness*0.25
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)
@@ -326,8 +335,20 @@ def battery_harness(phone: cq.Workplane, battery: cq.Workplane) -> cq.Workplane:
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_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.
@@ -343,22 +364,41 @@ def battery_harness(phone: cq.Workplane, battery: cq.Workplane) -> cq.Workplane:
# .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)
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, (battery_harness_shell_rad*2) * (0.5-(circles_pattern_center_to_bottom % 2)), 0.0)
)
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, -battery_harness_margin_bottom, 0)))
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, battery_harness_margin_top, 0)))
harness = harness.translate((0, 0, thickness - battery_harness_height))
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.
this implementation only behaves as expected if the solid has no cusps (i.e. is smoothed).
so for boxes, one should fillet the edges first (even if fillet'd with a trivial radius).
alternatively, shell in each direction iteratively with combine=True, and then cut.
e.g. select all |Z faces, shell with combine=True, then select all not |Z faces, shell with combine=True, then cut away the original.
"""
# alternatively, to perform an inset, set thickness negative and use combine="cut"
return solid.faces().each(lambda f: f.thicken(thickness), combine=combine)
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.
@@ -368,10 +408,13 @@ def orient_for_printing(case: cq.Workplane) -> cq.Workplane:
def case(
phone: cq.Workplane,
battery: cq.Workplane | None = None,
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)

View File

@@ -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")