diff --git a/main.py b/main.py index 00a596d..a147b47 100755 --- a/main.py +++ b/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: diff --git a/src/case.py b/src/case.py index 7c5f522..7740846 100644 --- a/src/case.py +++ b/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") + # 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(" cq.Workplane: + p = self + # extrude through the case + cutout = thicken(aux.faces(" 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(" cq.Workplane: + p = self + # cutout = cq.Workplane("YZ").add(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(" 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("Y").workplane(offset=-button_rocker_length).split(keepTop=True) + # rocker_bot = rocker_bot.edges("not 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", 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(" 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(" 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") - # 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(" 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(" cq.Workplane: - # cutout = cq.Workplane("YZ").add(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(" 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("Y").workplane(offset=-button_rocker_length).split(keepTop=True) -# rocker_bot = rocker_bot.edges("not 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", 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(" 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(" 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) diff --git a/src/config.py b/src/config.py index 7592447..7498fc1 100644 --- a/src/config.py +++ b/src/config.py @@ -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")