diff --git a/.gitignore b/.gitignore index 34a1581..72e4a77 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,14 @@ fp-info-cache # FreeCAD backup files *.FCBak + +# Python stuff +.venv/ +__pycache__/ +*.egg-info/ + +# Case model exports +*.stl +*.stp +*.step +*.gltf diff --git a/README.md b/README.md index 71eb15a..560b932 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ with support for many codecs, and a wide array of features. The PCB is designed with [KiCAD 9.0](https://www.kicad.org/), and there is an unfinished 3D-printable case designed with -[FreeCAD 1.0](https://www.freecad.org/). +[build123d](https://github.com/gumyr/build123d). All designs are available under the CERN-OHL-S v2 license. diff --git a/case/README.md b/case/README.md new file mode 100644 index 0000000..f7f1ea6 --- /dev/null +++ b/case/README.md @@ -0,0 +1,88 @@ +# Echo R1 Case + +The Echo R1 case is designed with [build123d](https://github.com/gumyr/build123d), +a Python-based parametric BREP CAD package using the OpenCASCADE +geometry kernel. The case can be exported to STEP or STL files for +3D printing or to edit in other CAD applications. + +## Installation + +CAD models are produced by running the `echoplayer-case` script. +This needs to be installed first; the recommended way is to set +up a Python virtual environment (venv) and perform an editable +install, which allows you to modify and run the script in place. + +This can be done from the shell / command line like this: + +``` +python3 -m venv .venv +source .venv/bin/activate +pip install -U pip +pip install -e . +``` + +## Exporting + +To export STLs, run: + +``` +echoplayer-case --r1-rev1 --export out +``` + +You can also output STEP (or GLTF) files with the `--export-format` option: + +``` +echoplayer-case --r1-rev1 --export out --export-format stp +``` + +STEP is recommended if you want to make changes in another BREP +CAD package. STLs are meshes and only suitable for 3D printing. + +The `--export DIR` option will write one file per unique part +to the output directory `DIR`. Some parts, like buttons, will +require multiple copies produced. At some point this will be +properly documented in BOM data for the player, but for now +you need to figure it out from looking at the render. + +## Visualizing + +If making edits to the code, you probably want to see what you are +doing to the model. There is built-in support for +[ocp_vscode](https://github.com/bernhard-42/vscode-ocp-cad-viewer), +which can be run either from VSCode or as a standalone server that +is accessed by a browser. + +To start the standalone server: + +``` +python3 -m ocp_vscode +``` + +Navigate to `http://localhost:3939/viewer` in a browser and then +display the model with: + +``` +echoplayer-case --r1-rev1 --ocp-vscode +``` + +Passing the `--show-datums` option will include datum points and +planes in the visualization. + +## Development + +If you are going to be making any major changes to the Python code, +you should install development dependencies and run checks with `tox`. +Run the following command in a venv: + +``` +pip install -e .[dev] +``` + +Usage of `tox` is very simple: + +``` +tox r -e mypy # type check +tox r -e ruff # lint +tox r -e test # test build of the case +tox p # run all in parallel +``` diff --git a/case/pyproject.toml b/case/pyproject.toml new file mode 100644 index 0000000..25b94a6 --- /dev/null +++ b/case/pyproject.toml @@ -0,0 +1,41 @@ +[project] +name = "echoplayer-case" +version = "0.1" +authors = [ { name = "Aidan MacDonald", email = "amachronic@protonmail.com" } ] +requires-python = ">=3.11" +dependencies = [ + "ocp_vscode == 2.8.9", + "build123d == 0.9.1", +] + +[project.optional-dependencies] +dev = [ + "tox >= 4.27", +] + +[project.scripts] +echoplayer-case = "echoplayer.main:main" + +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[tool.tox] +requires = ["tox >= 4.27"] +env_list = ["mypy", "ruff", "test"] + +[tool.tox.env.mypy] +description = "run type checker" +deps = ["mypy >= 1.16.1"] +commands = [["mypy", "src"]] + +[tool.tox.env.ruff] +description = "run linter" +deps = ["ruff >= 0.12.1"] +commands = [["ruff", "check", "src"]] + +[tool.tox.env.test] +description = "test case build" +commands = [ + ["echoplayer-case", "--test", "--r1-rev1"], +] diff --git a/case/src/echoplayer/__init__.py b/case/src/echoplayer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/case/src/echoplayer/main.py b/case/src/echoplayer/main.py new file mode 100644 index 0000000..68d4fe9 --- /dev/null +++ b/case/src/echoplayer/main.py @@ -0,0 +1,144 @@ +import argparse +import pathlib +import sys +from typing import Any + +from .utils import Object, datum_transform + + +def gen_ocp_objlist(objects: list[Object], + show_datums: bool = False) -> dict[str, Any]: + tree = {} + + for o in objects: + om: dict[str, Any] = {} + + if not o.renderable: + continue + + if o.compound: + om["body"] = o.compound + + if o.datums and show_datums: + datums: list[dict[str, Any]] = [] + + for n, dt in o.datums.datums.items(): + datums.append({n: datum_transform(dt, o.datums_xform)}) + + om["datums"] = datums + + if len(om) == 0: + continue + elif len(om) == 1: + om = next(iter(om.values())) + + tree[o.name] = om + + return tree + +def ocp_vscode_show(objects: list[Object], + show_datums: bool = False): + from ocp_vscode import Camera, set_defaults, show # type: ignore + + objlist = gen_ocp_objlist(objects, show_datums=show_datums) + + set_defaults(reset_camera=Camera.KEEP, axes=True, axes0=True, grid=True) + show(objlist, names=["root"]) + +def export_objects(objects: list[Object], + output_dir: pathlib.Path, + output_format: str, + output_prefix: str, + export_all: bool = False): + from build123d import ( + export_gltf, + export_step, + export_stl, + ) + + exporters = { + "gltf": export_gltf, + "step": export_step, + "stp": export_step, + "stl": export_stl, + } + + output_dir.mkdir(parents=True, exist_ok=True) + + for obj in objects: + if not obj.exportable or obj.compound is None: + continue + if not obj.manufacturable and not export_all: + continue + + file_name = output_dir / f"{output_prefix}-{obj.name}.{output_format}" + + print(f"Writing {file_name}") + exporters[output_format](obj.compound, file_name) # type: ignore + +def show_warning(): + print(""" +/!\\ WARNING /!\\ + +This case is incomplete and of DRAFT quality only! +""") + +def main(): + parser = argparse.ArgumentParser() + + case_group = parser.add_argument_group(title="Case variant selection") + case_group = case_group.add_mutually_exclusive_group(required=True) + case_group.add_argument("--r1-rev1", action="store_true", + help="R1-Rev1 / R1-Rev1.1") + + viz_group = parser.add_argument_group(title="Visualization") + viz_group.add_argument("--ocp-vscode", action="store_true", + help="Visualize with ocp_vscode") + viz_group.add_argument("--show-datums", action="store_true", default=False, + help="Show datum points in visualization") + + export_group = parser.add_argument_group(title="Exporting") + export_group.add_argument("--export", metavar="DIR", + type=pathlib.Path, + help="Export model files") + export_group.add_argument("--export-format", metavar="FMT", default="stl", + choices=("stl", "step", "stp", "gltf"), + help="Set export format (default: .stl)") + export_group.add_argument("--export-all", action="store_true", + help="Also export mockup objects that are not manufacturable") + + test_group = parser.add_argument_group() + test_group.add_argument("--test", action="store_true", + help=argparse.SUPPRESS) + + args = parser.parse_args() + + print("Building model...") + + if args.r1_rev1: + from . import r1_rev1 + + if args.export: + show_warning() + + objects = r1_rev1.build() + file_prefix = "echoplayer-r1-rev1" + else: + assert False, "Unknown case type" + + if args.test: + print("Success!") + sys.exit(0) + + if args.ocp_vscode: + ocp_vscode_show(objects, show_datums=args.show_datums) + + if args.export: + export_objects(objects, + output_dir = args.export, + output_format = args.export_format, + output_prefix = file_prefix, + export_all = args.export_all) + +if __name__ == '__main__': + pass diff --git a/case/src/echoplayer/r1_rev1.py b/case/src/echoplayer/r1_rev1.py new file mode 100644 index 0000000..e5db5e2 --- /dev/null +++ b/case/src/echoplayer/r1_rev1.py @@ -0,0 +1,1913 @@ +#!/usr/bin/env python3 + +import dataclasses +import itertools +import math as m +from collections.abc import Callable +from copy import copy +from dataclasses import dataclass +from build123d import ( + Align, + Axis, + Box, + Circle, + Color, + Compound, + CounterSinkHole, + Cylinder, + Line, + Plane, + Pos, + Rectangle, + RectangleRounded, + Sketch, + Vector, + extrude, + chamfer, + fillet, + loft, + make_face, +) + +from .utils import Object, DatumSet, plane_at + + +@dataclass +class LcdParams: + module_width: float + module_height: float + module_thickness: float + module_side_clearance: float + module_front_clearance: float + module_back_clearance: float + module_support_thickness: float + module_support_gap_size: float + cover_thickness: float + cover_support_size: float + cover_back_clearance: float + cover_side_clearance: float + bezel_size: float + + @property + def module_pocket_width(self) -> float: + return self.module_width + self.module_side_clearance*2 + + @property + def module_pocket_height(self) -> float: + return self.module_height + self.module_side_clearance*2 + + @property + def module_pocket_depth(self) -> float: + return (self.module_thickness + + self.module_front_clearance + + self.module_back_clearance) + + @property + def cover_pocket_width(self) -> float: + return (self.module_pocket_width + + self.cover_support_size*2 + + self.cover_side_clearance*2) + + @property + def cover_pocket_height(self) -> float: + return (self.module_pocket_height + + self.cover_support_size + + self.cover_side_clearance) + + @property + def cover_pocket_depth(self) -> float: + return self.cover_thickness + self.cover_back_clearance + + @property + def cover_width(self) -> float: + return self.cover_pocket_width - self.cover_side_clearance*2 + + @property + def cover_height(self) -> float: + return self.cover_pocket_height - self.cover_side_clearance*2 + +@dataclass +class HoleParams: + d: float + x: float + y: float + +@dataclass +class ButtonPosParams: + x: float + y: float + +@dataclass +class PcbParams: + width: float + height: float + thickness: float + + clearance_top: float + clearance_bottom: float + clearance_front: float + + edge_clearance_front: float + + lo_jack_dx: float + hp_jack_dx: float + usbc_conn_dx: float + card_conn_dx: float + + vol_button_dx: float + vol_up_button_dy: float + vol_dn_button_dy: float + power_button_dx: float + power_button_dy: float + hold_sw_pos1_dx: float + hold_sw_pos2_dx: float + + buttons: dict[str, ButtonPosParams] + holes: dict[str, HoleParams] + + @property + def hold_sw_center_dx(self) -> float: + return (self.hold_sw_pos1_dx + self.hold_sw_pos2_dx) / 2 + +@dataclass +class DpadButtonParams: + width: float + height: float + center_to_origin_dist: float + center_to_top_dist: float + edge_to_diagonal_dist: float + +@dataclass +class ContactDomeParams: + height: float + tip_height: float + tip_diameter: float + rim_diameter: float + travel: float + +@dataclass +class Params: + lcd: LcdParams + pcb: PcbParams + + outer_depth: float + + wall_thickness_top: float + wall_thickness_bottom: float + wall_thickness_front: float + wall_thickness_back: float + wall_thickness_side: float + + h3_support_diameter: float + volume_support_diameter: float + h5_support_diameter: float + dpad_support_diameter: float + + debug_header_dx: float + debug_header_width: float + debug_header_height: float + debug_header_clearance_side: float + + jack_diameter: float + jack_diameter_clearance: float + + usbc_width: float + usbc_height: float + usbc_flat_side_length: float + usbc_clearance: float + + card_width: float + card_height: float + card_corner_radius: float + card_clearance: float + card_slot_dz: float + + support_screw_diameter: float + support_screw_head_diameter: float + support_heat_insert_diameter: float + support_screw_depth: float + + face_button_diameter: dict[str, float] + dpad_button: DpadButtonParams + + contact_dome: ContactDomeParams + face_button_lip_size: float + face_button_lip_height: float + face_button_case_clearance: float + face_button_dome_tip_clearance: float + + startsel_button_smd_height: float + startsel_button_outer_height: float + + side_pcb_button_body_width: float + side_pcb_button_body_height: float + side_pcb_button_body_depth: float + side_pcb_button_presser_width: float + side_pcb_button_presser_height: float + side_pcb_button_presser_depth: float + + side_button_width: float + side_button_height: float + side_button_corner_radius: float + side_button_clearance: float + side_button_inner_clearance: float + side_button_lip_pocket_depth: float + + side_button_presser_width: float + side_button_presser_height: float + side_button_presser_length: float + side_button_lip_size: float + side_button_lip_chamfer_size: float + side_button_lip_extra_length: float + side_button_outer_extra_length: float + + side_button_support_dz: float + side_button_support_pcb_clearance: float + side_button_support_width: float + side_button_support_depth: float + + battery_dx: float + battery_dy: float + battery_dz: float + battery_width: float + battery_height: float + battery_thickness: float + + bconn_dx: float + bconn_dy: float + bconn_width: float + bconn_height: float + bconn_depth: float + + batt_spring_dist: float + batt_spring_tolerance: float + + battbox_thickness: float + battbox_depth: float + battbox_clearance_xz: float + battbox_clearance_y: float + bconn_clearance: float + + battframe_thickness: float + battframe_wall_height: float + battframe_support_diameter: float + battframe_support_hole_diameter: float + battframe_support_above_hole_diameter: float + battframe_support_z_clearance: float + battframe_connector_y_clearance: float + + lshell_wall_clearance: float + lshell_shadowline_depth: float + lshell_cornersquare_thickness: float + lshell_cornersquare_diameter: float + lshell_cornersquare_y_extend: float + lshell_debugheader_side_clearance: float + lshell_debugheader_top_clearance: float + + @property + def pcb_attachment_offset_dx(self) -> float: + return (self.inner_width - self.pcb.width)/2 + + @property + def inner_width(self) -> float: + return self.outer_width - self.wall_thickness_side*2 + + @property + def inner_height(self) -> float: + return (self.pcb.height + + self.pcb.clearance_top + + self.pcb.clearance_bottom) + + @property + def inner_origin_dx(self) -> float: + return self.wall_thickness_side + + @property + def inner_origin_dy(self) -> float: + return self.wall_thickness_bottom + + @property + def outer_width(self) -> float: + return self.lcd.cover_pocket_width + self.lcd.bezel_size*2 + + @property + def outer_height(self) -> float: + return (self.inner_height + + self.wall_thickness_bottom + + self.wall_thickness_top) + + @property + def jack_slot_radius(self) -> float: + return self.jack_diameter/2 + self.jack_diameter_clearance + + @property + def jack_slot_dz(self) -> float: + return self.jack_diameter/2 + + @property + def usbc_corner_radius(self) -> float: + return (self.usbc_height - self.usbc_flat_side_length) / 2 + + @property + def usbc_slot_width(self) -> float: + return self.usbc_width + self.usbc_clearance*2 + + @property + def usbc_slot_height(self) -> float: + return self.usbc_height + self.usbc_clearance*2 + + @property + def usbc_slot_corner_radius(self) -> float: + return self.usbc_corner_radius + self.usbc_clearance + + @property + def usbc_slot_dz(self) -> float: + return self.usbc_height/2 + + @property + def card_slot_width(self) -> float: + return self.card_width + self.card_clearance*2 + + @property + def card_slot_height(self) -> float: + return self.card_height + self.card_clearance*2 + + @property + def card_slot_corner_radius(self) -> float: + return self.card_corner_radius + self.card_clearance + + @property + def side_button_lip_width(self) -> float: + return self.side_button_width + 2*(self.side_button_lip_size + self.side_button_clearance) + + @property + def side_button_lip_height(self) -> float: + return self.side_button_height + 2*(self.side_button_lip_size + self.side_button_clearance) + + +def get_params() -> Params: + abxy_button_diameter = 7 + startsel_button_diameter = 4 + + dpad_center_x = 27.5 + dpad_center_y = 25.5 + dpad_button_to_center_dist = 10 + + return Params( + lcd = LcdParams( + module_width = 50.9, + module_height = 45.8, + module_thickness = 2.4, + module_side_clearance = 0.5, + module_front_clearance = 0.4, + module_back_clearance = 0.2, + module_support_thickness = 1.4, + module_support_gap_size = 5.0, + cover_thickness = 1.0, + cover_support_size = 3.5, + cover_back_clearance = 0.1, + cover_side_clearance = 0.2, + bezel_size = 1.5, + ), + pcb = PcbParams( + width = 55, + height = 95, + thickness = 1.6, + clearance_top = 0.8, + clearance_bottom = 0.2, + clearance_front = 3, + edge_clearance_front = 0.6, + lo_jack_dx = 5, + hp_jack_dx = 15, + usbc_conn_dx = 27.5, + card_conn_dx = 46.9, + vol_button_dx = 51.15, + vol_up_button_dy = 77.5, + vol_dn_button_dy = 62.5, + power_button_dx = 7.5, + power_button_dy = 91.15, + hold_sw_pos1_dx = 16.7, + hold_sw_pos2_dx = 18.3, + buttons = { + "a": ButtonPosParams(x=49.0, y=35.0), + "b": ButtonPosParams(x=40.0, y=41.0), + "x": ButtonPosParams(x= 6.0, y=35.0), + "y": ButtonPosParams(x=15.0, y=41.0), + "start": ButtonPosParams(x=42.8, y= 5.5), + "select": ButtonPosParams(x=12.2, y= 5.5), + "dpad_up": ButtonPosParams(x=dpad_center_x, + y=dpad_center_y + dpad_button_to_center_dist), + "dpad_down": ButtonPosParams(x=dpad_center_x, + y=dpad_center_y - dpad_button_to_center_dist), + "dpad_left": ButtonPosParams(x=dpad_center_x - dpad_button_to_center_dist, + y=dpad_center_y), + "dpad_right": ButtonPosParams(x=dpad_center_x + dpad_button_to_center_dist, + y=dpad_center_y), + }, + holes = { + "dpad": HoleParams(d=4.0, x=dpad_center_x, y=dpad_center_y), + "volume": HoleParams(d=4.0, x=51.0, y=70.00), + "h3": HoleParams(d=2.2, x= 4.0, y=86.00), + "h5": HoleParams(d=2.2, x=35.0, y= 2.00), + "h6": HoleParams(d=2.2, x=13.0, y=31.75) + }, + ), + wall_thickness_top = 2, + wall_thickness_bottom = 2, + wall_thickness_front = 2, + wall_thickness_back = 2, + wall_thickness_side = 2.4, + outer_depth = 21.1, + h3_support_diameter = 7, + volume_support_diameter = 8, + h5_support_diameter = 4.8, + dpad_support_diameter = 6, + debug_header_dx = 0.73, + debug_header_height = 2.5, + debug_header_width = 15.24, + debug_header_clearance_side = 0.5, + jack_diameter = 5, + jack_diameter_clearance = 0.5, + usbc_width = 8.94, + usbc_height = 3.26, + usbc_flat_side_length = 0.7, + usbc_clearance = 0.5, + card_width = 12, + card_height = 1.8, + card_corner_radius = 0.5, + card_clearance = 0.5, + card_slot_dz = 1, + support_screw_diameter = 2, + support_screw_head_diameter = 3.8, + support_heat_insert_diameter = 3, + support_screw_depth = 3, + face_button_diameter = { + "a": abxy_button_diameter, + "b": abxy_button_diameter, + "x": abxy_button_diameter, + "y": abxy_button_diameter, + "start": startsel_button_diameter, + "select": startsel_button_diameter, + }, + dpad_button = DpadButtonParams( + width = 9, + height = 8.5, + center_to_origin_dist = dpad_button_to_center_dist, + center_to_top_dist = 3.5, + edge_to_diagonal_dist = 2, + ), + contact_dome = ContactDomeParams( + height = 5, + tip_height = 2.2, + tip_diameter = 3, + rim_diameter = 5, + travel = 2, + ), + face_button_lip_size = 1, + face_button_lip_height = 1.4, + face_button_case_clearance = 0.25, + face_button_dome_tip_clearance = 0.1, + startsel_button_smd_height = 2.4, # includes case clearance + startsel_button_outer_height = 1.5, + side_pcb_button_body_width = 4.7, + side_pcb_button_body_height = 2.3, + side_pcb_button_body_depth = 1.9, + side_pcb_button_presser_width = 1.8, + side_pcb_button_presser_height = 1.2, + side_pcb_button_presser_depth = 0.8, + side_button_width = 10, + side_button_height = 2.5, + side_button_corner_radius = 1, + side_button_clearance = 0.3, + side_button_inner_clearance = 0.5, + # Lip pocket depth minus extra length needs + # to be >= PCB button face to PCB edge distance + side_button_lip_pocket_depth = 1, + side_button_presser_width = 4, + side_button_presser_height = 1.5, + # Presser length needs to be >= A+B where + # A = PCB button face to PCB edge distance + # B = PCB button travel distance + side_button_presser_length = 1, + side_button_lip_size = 0.5, + side_button_lip_chamfer_size = 0.5, + # Extra length added to lip to increase part thickness + side_button_lip_extra_length = 0.2, + # Must be >= PCB button travel distance + side_button_outer_extra_length = 1.0, + side_button_support_dz = 0.3, + side_button_support_pcb_clearance = 0.3, + side_button_support_width = 15, + side_button_support_depth = 6, + + battery_dx = 17.2, + battery_dy = 28.7, + battery_dz = 2.8, + battery_width = 34.3, + battery_height = 53.5, + battery_thickness = 5.8, + bconn_dx = 39.5, + bconn_dy = 24.4, + bconn_width = 9, + bconn_height = 3.5, + bconn_depth = 6.8, + batt_spring_dist = 0.8, + batt_spring_tolerance = 0.2, + battbox_thickness = 2, + battbox_depth = 3, + battbox_clearance_xz = 0.4, + battbox_clearance_y = 0.1, + bconn_clearance = 0.8, + battframe_thickness = 1, + battframe_wall_height = 2, + battframe_support_diameter = 3.8, + battframe_support_hole_diameter = 2.2, + battframe_support_above_hole_diameter = 10, + battframe_support_z_clearance = 0.2, + battframe_connector_y_clearance = 2, + lshell_wall_clearance = 0.3, + lshell_shadowline_depth = 1.0, + lshell_cornersquare_diameter = 6, + lshell_cornersquare_y_extend = 3, + lshell_cornersquare_thickness = 2.5, + lshell_debugheader_top_clearance = 0.3, + lshell_debugheader_side_clearance = 0.3, + ) + +def get_pcb_datums(params: Params) -> DatumSet: + ds = DatumSet() + + ds.add_box("board", + dimensions = (params.pcb.width, + params.pcb.height, + params.pcb.thickness), + alignment = (-1, -1, -1)) + + ds.add_point("back_origin", + origin = ds.box_point("board", align = (-1, -1, -1))) + + ds.add_point("front_origin", + origin = ds.box_point("board", align = (-1, -1, 1))) + + for h_name, h_params in params.pcb.holes.items(): + ds.add_point(f"hole_{h_name}_pos", + origin = ds.front_origin, + dX = h_params.x, + dY = h_params.y) + + ds.add_plane("debug_header_right", + plane = ds.board_right, + dX = -(params.debug_header_dx - + params.debug_header_clearance_side)) + + ds.add_plane("debug_header_left", + plane = ds.debug_header_right, + dX = -(params.debug_header_width + + params.debug_header_clearance_side*2)) + + for b_name, b_params in params.pcb.buttons.items(): + ds.add_point(f"button_{b_name}_pos", + origin = ds.front_origin, + dX = b_params.x, + dY = b_params.y) + + ds.add_point("button_vol_up_pos", + origin = ds.back_origin, + dX = params.pcb.vol_button_dx, + dY = params.pcb.vol_up_button_dy) + + side_button_zoffset = -(params.side_button_lip_height/2 + + params.side_button_inner_clearance + + params.side_button_support_dz) + + ds.add_point("button_vol_up_press_pos", + origin = ds.button_vol_up_pos, + dX = (params.side_pcb_button_body_height + + params.side_pcb_button_presser_height), + dZ = side_button_zoffset) + + ds.add_point("button_vol_up_support_pos", + origin = ds.button_vol_up_pos, + dX = (params.side_pcb_button_body_height + + params.side_pcb_button_presser_height), + dZ = -params.side_button_support_dz) + + ds.add_point("button_vol_dn_pos", + origin = ds.back_origin, + dX = params.pcb.vol_button_dx, + dY = params.pcb.vol_dn_button_dy) + + ds.add_point("button_vol_dn_press_pos", + origin = ds.button_vol_dn_pos, + dX = (params.side_pcb_button_body_height + + params.side_pcb_button_presser_height), + dZ = side_button_zoffset) + + ds.add_point("button_vol_dn_support_pos", + origin = ds.button_vol_dn_pos, + dX = (params.side_pcb_button_body_height + + params.side_pcb_button_presser_height), + dZ = -params.side_button_support_dz) + + ds.add_point("button_power_pos", + origin = ds.back_origin, + dX = params.pcb.power_button_dx, + dY = params.pcb.power_button_dy) + + ds.add_point("button_power_press_pos", + origin = ds.button_power_pos, + dY = (params.side_pcb_button_body_height + + params.side_pcb_button_presser_height), + dZ = side_button_zoffset) + + ds.add_point("button_power_support_pos", + origin = ds.button_power_pos, + dY = (params.side_pcb_button_body_height + + params.side_pcb_button_presser_height), + dZ = -params.side_button_support_dz) + + # NOTE: while not part of the PCB there's no better place for this + ds.add_point("battery_origin", + origin = ds.back_origin, + dX = params.battery_dx, + dY = params.battery_dy, + dZ = -params.battery_dz) + + ds.add_box("battery", + origin = ds.battery_origin, + dimensions = (params.battery_width, + params.battery_height, + params.battery_thickness), + alignment = (-1, -1, 1)) + + ds.add_point("bconn_origin", + origin = ds.back_origin, + dX = params.bconn_dx, + dY = params.bconn_dy) + + ds.add_box("bconn", + origin = ds.bconn_origin, + dimensions = (params.bconn_width, + params.bconn_height, + params.bconn_depth), + alignment = (-1, -1, 1)) + + return ds + +def get_upper_shell_datums(params: Params, + pcb_ds: DatumSet) -> DatumSet: + ds = DatumSet() + + ds.add_box("outer_wall", + dimensions = (params.outer_width, + params.outer_height, + params.outer_depth - params.wall_thickness_back), + alignment = (-1, -1, -1)) + + ds.add_point("outer_origin", + origin = ds.box_point("outer_wall", align = (-1, -1, -1))) + + ds.add_point("lcd_cover_pocket_origin", + origin = ds.box_point("outer_wall", align = (0, 1, 1)), + dY = -params.lcd.bezel_size) + + ds.add_box("lcd_cover_pocket", + origin = ds.lcd_cover_pocket_origin, + dimensions = (params.lcd.cover_pocket_width, + params.lcd.cover_pocket_height, + params.lcd.cover_pocket_depth), + alignment = (0, 1, 1)) + + ds.add_point("lcd_module_pocket_origin", + origin = ds.lcd_cover_pocket_origin, + dY = -(params.lcd.cover_support_size + params.lcd.cover_side_clearance), + dZ = -params.lcd.cover_pocket_depth) + + ds.add_box("lcd_module_pocket", + origin = ds.lcd_module_pocket_origin, + dimensions = (params.lcd.module_pocket_width, + params.lcd.module_pocket_height, + params.lcd.module_pocket_depth), + alignment = (0, 1, 1)) + + ds.add_point("lcd_support_gap_pocket_origin", + origin = ds.box_point("lcd_module_pocket", align = (0, -1, -1))) + + ds.add_box("lcd_support_gap_pocket", + origin = ds.lcd_support_gap_pocket_origin, + dimensions = (params.lcd.module_pocket_width, + params.lcd.module_support_gap_size, + params.lcd.module_support_thickness), + alignment = (0, -1, 1)) + + ds.add_plane("lcd_support_back", + plane = ds.lcd_support_gap_pocket_back, + X = ds.outer_origin.X, + Y = ds.outer_origin.Y) + + ds.add_point("inner_origin", + origin = ds.outer_origin, + dX = params.inner_origin_dx, + dY = params.inner_origin_dy) + + ds.add_box("inner_wall", + origin = ds.inner_origin, + dimensions = (params.inner_width, + params.inner_height, + ds.lcd_support_back.origin.Z - ds.inner_origin.Z), + alignment = (-1, -1, -1)) + + ds.add_point("pcb_attachment", + origin = ds.inner_origin, + dX = params.pcb_attachment_offset_dx, + dY = params.pcb.clearance_bottom, + Z = ds.lcd_support_back.origin.Z - params.pcb.clearance_front) + + ds.add_reference("pcb", pcb_ds, + Pos(ds.pcb_attachment - pcb_ds.front_origin)) + + rs = [ + "hole_h3_pos", + "hole_h5_pos", + "hole_volume_pos", + "hole_dpad_pos", + "button_a_pos", + "button_b_pos", + "button_x_pos", + "button_y_pos", + "button_start_pos", + "button_select_pos", + "button_dpad_up_pos", + "button_dpad_down_pos", + "button_dpad_left_pos", + "button_dpad_right_pos", + "debug_header_left", + "debug_header_right", + "back_origin", + "front_origin", + "battery_origin", + "battery_front", + "battery_back", + "battery_left", + "battery_right", + "battery_top", + "battery_bottom", + "bconn_origin", + "bconn_back", + "bconn_left", + "bconn_right", + ] + + for r in rs: + ds.add_alias(f"pcb_{r}", r, "pcb") + + ds.add_plane("bottom_slot_plane", + plane = ds.outer_wall_bottom, + X = ds.pcb_back_origin.X, + Z = ds.pcb_back_origin.Z) + + ds.add_plane("lower_inner_wall_front", + plane = ds.outer_wall_front, + dZ = -params.wall_thickness_front) + + return ds + +def get_lower_shell_datums(params: Params, + ushell_ds: DatumSet) -> DatumSet: + ds = DatumSet() + + ds.add_box("plate", + dimensions = (ushell_ds.box_dimension("outer_wall", "x"), + ushell_ds.box_dimension("outer_wall", "y"), + params.wall_thickness_back), + alignment = (-1, -1, -1)) + + ds.add_point("ushell_attachment", + origin = ds.box_point("plate", align = (-1, -1, 1))) + + ds.add_reference("ushell", ushell_ds, + Pos(ds.ushell_attachment - ushell_ds.outer_origin)) + + return ds + +def get_battery_frame_datums(params: Params, + ushell_ds: DatumSet) -> DatumSet: + ds = DatumSet() + + ds.add_point("ushell_attachment", + dX = params.battbox_thickness, + dY = params.battbox_thickness, + dZ = params.battframe_wall_height) + + pt = ushell_ds.pcb.box_point("battery", align = (-1, -1, 1)) + ds.add_reference("ushell", ushell_ds, + Pos(ds.ushell_attachment - pt)) + + ds.add_alias("volume_pos", "pcb_hole_volume_pos", "ushell") + ds.add_alias("dpad_pos", "pcb_hole_dpad_pos", "ushell") + + return ds + + +def make_dpad_arrow_face(width: float, + height: float, + center_to_origin_dist: float, + center_to_top_dist: float, + edge_to_diagonal_dist: float, + edge_offset: float|None = None): + hsqrt2 = m.sqrt(2) / 2 + + if edge_offset: + width += edge_offset*2 + height += edge_offset*2 + center_to_origin_dist += edge_offset + center_to_top_dist += edge_offset + edge_to_diagonal_dist -= edge_offset * hsqrt2 / (1 + 2*hsqrt2) + + top_y = center_to_top_dist + top_x = width / 2 + mid_y = top_x - center_to_origin_dist + (2 * edge_to_diagonal_dist * hsqrt2) + bot_y = top_y - height + bot_x = top_x - (mid_y - bot_y) + + verts = [ + (+top_x, top_y), + (+top_x, mid_y), + (+bot_x, bot_y), + (-bot_x, bot_y), + (-top_x, mid_y), + (-top_x, top_y), + (+top_x, top_y), # extra for wraparound + ] + + return make_face([Line(v, vn) for v, vn in zip(verts, verts[1:])]) + +def make_side_button_face(width: float, + height: float, + corner_radius: float, + edge_offset: float|None = None): + if edge_offset: + width += edge_offset*2 + height += edge_offset*2 + corner_radius += edge_offset + + return RectangleRounded(width, height, corner_radius) + +def make_side_button_inner_face(width: float, + height: float, + chamfer_size: float, + edge_offset: float|None = None): + if edge_offset: + width += edge_offset*2 + height += edge_offset*2 + chamfer_size += edge_offset + + face = Rectangle(width, height) + return chamfer(face.vertices(), chamfer_size) + + + +def make_upper_shell(params: Params, datums: DatumSet) -> Compound: + # Flag for LCD cover / no cover + has_lcd_cover = True + + # Flag to use heat inserts vs. tapped holes for screws + has_heat_inserts = True + + # Shell outer surface + shell = ( + Pos(datums.outer_origin) * + Box( + datums.box_dimension("outer_wall", "x"), + datums.box_dimension("outer_wall", "y"), + datums.box_dimension("outer_wall", "z"), + align = Align.MIN, + ) + ) + + # LCD cover pocket + lcd_cover_pocket = ( + datums.lcd_cover_pocket_back * + Box( + datums.box_dimension("lcd_cover_pocket", "x"), + datums.box_dimension("lcd_cover_pocket", "y"), + datums.box_dimension("lcd_cover_pocket", "z"), + align = (Align.CENTER, Align.MAX, Align.MIN), + ) + ) + + # LCD module pocket + lcd_module_pocket_depth = datums.box_dimension("lcd_module_pocket", "z") + if not has_lcd_cover: + lcd_module_pocket_depth += datums.box_dimension("lcd_cover_pocket", "z") + + lcd_module_pocket = ( + datums.lcd_module_pocket_back * + Box( + datums.box_dimension("lcd_module_pocket", "x"), + datums.box_dimension("lcd_module_pocket", "y"), + lcd_module_pocket_depth, + align = (Align.CENTER, Align.MAX, Align.MIN), + ) + ) + + # Gap to slide the LCD module through during assembly + lcd_support_gap_pocket = ( + Pos(datums.lcd_support_gap_pocket_origin) * + Box( + datums.box_dimension("lcd_support_gap_pocket", "x"), + datums.box_dimension("lcd_support_gap_pocket", "y"), + datums.box_dimension("lcd_support_gap_pocket", "z"), + align = (Align.CENTER, Align.MIN, Align.MAX) + ) + ) + + # Main section of inner pocket, up to the LCD support + main_inner_pocket = ( + Pos(datums.inner_origin) * + Box( + datums.box_dimension("inner_wall", "x"), + datums.box_dimension("inner_wall", "y"), + datums.box_dimension("inner_wall", "z"), + align = Align.MIN, + ) + ) + + # Supports on the upper half of the PCB + upper_pcb_supports = [] + upper_hole_data = [ + ("h3", datums.inner_wall_left, Align.MIN), + ("volume", datums.inner_wall_right, Align.MAX), + ] + + for h_name, wall, xalign in upper_hole_data: + h_origin = datums.point(f"pcb_hole_{h_name}_pos") + s_diam = getattr(params, f"{h_name}_support_diameter") + + xs = abs(wall.origin.X - h_origin.X) + s_diam/2 + ys = s_diam + zs = datums.lcd_support_back.origin.Z - h_origin.Z + + h_origin.X = wall.origin.X + + upper_pcb_supports.append( + Pos(h_origin) * + Box( + xs, ys, zs, + align = (xalign, Align.CENTER, Align.MIN) + ) + ) + + # Slot for debug header + debug_header_slot = ( + plane_at(datums.inner_wall_top, + x = datums.pcb_debug_header_right.origin.X) * + Box( + datums.pcb_debug_header_right.origin.X - datums.pcb_debug_header_left.origin.X, + datums.lcd_support_back.origin.Z - datums.outer_wall_back.origin.Z, + params.wall_thickness_top, + align = (Align.MAX, Align.MIN, Align.MAX) + ) + ) + + # Audio jack slots + jack_slot = ( + datums.bottom_slot_plane * + Pos(Y = -params.jack_slot_dz) * + Cylinder( + params.jack_slot_radius, + params.wall_thickness_bottom, + align = (Align.CENTER, Align.CENTER, Align.MAX), + ) + ) + + hp_jack_slot = Pos(X = params.pcb.hp_jack_dx) * copy(jack_slot) + lo_jack_slot = Pos(X = params.pcb.lo_jack_dx) * copy(jack_slot) + + # USB-C port slot + usbc_slot = ( + datums.bottom_slot_plane * + Pos(X = params.pcb.usbc_conn_dx, + Y = -params.usbc_slot_dz) * + extrude(RectangleRounded(width = params.usbc_slot_width, + height = params.usbc_slot_height, + radius = params.usbc_slot_corner_radius), + amount = -params.wall_thickness_bottom) + ) + + # Memory card slot + card_slot = ( + datums.bottom_slot_plane * + Pos(X = params.pcb.card_conn_dx, + Y = -params.card_slot_dz) * + extrude(RectangleRounded(width = params.card_slot_width, + height = params.card_slot_height, + radius = params.card_slot_corner_radius), + amount = -params.wall_thickness_bottom) + ) + + # Make the front wall a reasonable thickness + lower_inner_pocket_maxdepth = (datums.outer_wall_front.origin.Z - + datums.lcd_support_back.origin.Z) + assert lower_inner_pocket_maxdepth > params.wall_thickness_front + + lower_inner_pocket_pcb_edge_offset_x = params.pcb_attachment_offset_dx + params.pcb.edge_clearance_front + lower_inner_pocket_pcb_edge_offset_y = params.pcb.clearance_bottom + params.pcb.edge_clearance_front + + lower_inner_pocket_width = params.inner_width + lower_inner_pocket_width -= lower_inner_pocket_pcb_edge_offset_x*2 + + lower_inner_pocket_height = datums.lcd_support_gap_pocket_bottom.origin.Y - datums.inner_origin.Y + lower_inner_pocket_height -= lower_inner_pocket_pcb_edge_offset_y + + lower_inner_pocket_depth = lower_inner_pocket_maxdepth - params.wall_thickness_front + + lower_inner_pocket = ( + plane_at(datums.lcd_support_back, + projected_origin = datums.inner_origin) * + Pos(X = lower_inner_pocket_pcb_edge_offset_x, + Y = lower_inner_pocket_pcb_edge_offset_y) * + Box(lower_inner_pocket_width, + lower_inner_pocket_height, + lower_inner_pocket_depth, + align = Align.MIN) + ) + + # Extend the edge supports up to the PCB front face + lower_pcb_edge_support = ( + plane_at(datums.lcd_support_back, + projected_origin = datums.inner_origin) * + Box( + params.inner_width, + datums.lcd_support_gap_pocket_bottom.origin.Y - datums.inner_origin.Y, + datums.lcd_support_back.origin.Z - datums.pcb_front_origin.Z, + align = (Align.MIN, Align.MIN, Align.MAX) + ) + ) + + lower_pcb_edge_support -= (Pos(Z = -lower_inner_pocket_depth) * + copy(lower_inner_pocket)) + + # Add supports in lower region + lower_pcb_supports = [] + lower_hole_data = [ + ("dpad", Cylinder, None, None), + ("h5", Box, datums.inner_wall_bottom, Align.MIN), + ] + + for h_name, shape_class, wall, yalign in lower_hole_data: + h_origin = datums.point(f"pcb_hole_{h_name}_pos") + s_diam = getattr(params, f"{h_name}_support_diameter") + zs = datums.lower_inner_wall_front.origin.Z - datums.pcb_hole_dpad_pos.Z + + if shape_class is Cylinder: + lower_pcb_supports.append( + plane_at(datums.lower_inner_wall_front, + projected_origin = h_origin) * + Cylinder( + radius = s_diam/2, + height = zs, + align = (Align.CENTER, Align.CENTER, Align.MAX), + ) + ) + else: + assert yalign is not None + + xs = s_diam + ys = h_origin.Y - wall.origin.Y + s_diam/2 + + h_origin.Y = wall.origin.Y + + lower_pcb_supports.append( + plane_at(datums.lower_inner_wall_front, + projected_origin = h_origin) * + Box( + xs, ys, zs, + align = (Align.CENTER, yalign, Align.MAX), + ) + ) + + # Add holes to supports for screws / heat inserts + if has_heat_inserts: + support_hole_diameter = params.support_heat_insert_diameter + else: + support_hole_diameter = params.support_screw_diameter + + support_hole_depth = params.support_screw_depth + + support_holes = [] + support_hole_positions = [ + datums.pcb_hole_h3_pos, + datums.pcb_hole_h5_pos, + datums.pcb_hole_dpad_pos, + datums.pcb_hole_volume_pos, + ] + + for h_origin in support_hole_positions: + support_holes.append( + Pos(h_origin) * + Cylinder( + radius = support_hole_diameter/2, + height = support_hole_depth, + align = (Align.CENTER, Align.CENTER, Align.MIN) + ) + ) + + # Cut holes for face buttons + face_button_holes = [] + + for b_name in params.pcb.buttons: + diam = params.face_button_diameter.get(b_name) + if diam is None: + continue + + face_button_holes.append( + plane_at(datums.outer_wall_front, + projected_origin = datums.point(f"pcb_button_{b_name}_pos")) * + Cylinder( + radius = diam/2, + height = params.wall_thickness_front, + align = (Align.CENTER, Align.CENTER, Align.MAX) + ) + ) + + # Cut holes for d-pad buttons + dpad_button_face = make_dpad_arrow_face(**dataclasses.asdict(params.dpad_button)) + dpad_button_data = [ + ("dpad_up", 0), + ("dpad_left", 90), + ("dpad_down", 180), + ("dpad_right", 270), + ] + + for b_name, rotation in dpad_button_data: + face_button_holes.append( + plane_at(datums.outer_wall_front, + projected_origin = datums.point(f"pcb_button_{b_name}_pos")) * + extrude( + dpad_button_face.rotate(Axis.Z, rotation), + amount = -params.wall_thickness_front, + ) + ) + + # Cut holes for side buttons (volume and power) + side_button_face = make_side_button_face(params.side_button_width, + params.side_button_height, + params.side_button_corner_radius, + params.side_button_clearance) + side_button_face = side_button_face.rotate(Axis.X, -90) + side_button_hole = extrude(side_button_face, amount = 10) + + # Pocket for lip to allow clearance for assembly + side_button_lip_face = Rectangle(params.side_button_lip_width + params.side_button_inner_clearance*2, + params.side_button_lip_height + params.side_button_inner_clearance*4/3) + side_button_lip_face = Pos(Y = -params.side_button_inner_clearance/3) * side_button_lip_face + side_button_lip_face = side_button_lip_face.rotate(Axis.X, -90) + + wall_dist_vol = abs(datums.pcb.button_vol_up_press_pos.X - datums.inner_wall_right.origin.X) + pcb_dist_vol = abs(datums.pcb.button_vol_up_press_pos.X - datums.pcb.board_right.origin.X) + wall_dist_pwr = abs(datums.pcb.button_power_press_pos.Y - datums.inner_wall_top.origin.Y) + pcb_dist_pwr = abs(datums.pcb.button_power_press_pos.Y - datums.pcb.board_top.origin.Y) + side_button_data = [ + ("vol_up", -90, wall_dist_vol, pcb_dist_vol), + ("vol_dn", -90, wall_dist_vol, pcb_dist_vol), + ("power", 0, wall_dist_pwr, pcb_dist_pwr), + ] + + side_button_holes = [] + side_button_supports = [] + for b_name, rotation, wall_dist, pcb_dist in side_button_data: + pos = Pos(datums.pcb.get_point(f"button_{b_name}_press_pos")) + hole = side_button_hole + extrude(side_button_lip_face, + amount=wall_dist + params.side_button_lip_pocket_depth) + hole = pos * hole.rotate(Axis.Z, rotation) + side_button_holes.append(hole) + + support_size = wall_dist - pcb_dist - params.side_button_support_pcb_clearance + pos = Pos(datums.pcb.get_point(f"button_{b_name}_support_pos")) + if rotation == 0: + pos = Pos(Y = wall_dist) * pos + support = Box(params.side_button_support_width, + support_size, + params.side_button_support_depth, + align = (Align.CENTER, Align.MAX, Align.MIN)) + else: + pos = Pos(X = wall_dist) * pos + support = Box(support_size, + params.side_button_support_width, + params.side_button_support_depth, + align = (Align.MAX, Align.CENTER, Align.MIN)) + + support = pos * support + + side_button_supports.append(support) + + # Cut screw holes for mounting back plate + corner_hole = CounterSinkHole( + params.support_screw_diameter/2, + params.support_screw_head_diameter/2, + params.wall_thickness_side, + ) + + corner_holes = [] + for corner_x, corner_y in ((0, 0), (0, 1), (1, 0), (1, 1)): + adj_y = params.lshell_wall_clearance + params.lshell_cornersquare_diameter/2 + + pos = datums.inner_origin + pos += Vector(X = -params.wall_thickness_side, + Y = adj_y) + pos += Vector(X = (params.inner_width + params.wall_thickness_side*2) * corner_x, + Y = (params.inner_height - adj_y*2) * corner_y, + Z = params.lshell_cornersquare_diameter/2) + + hole = corner_hole.rotate(Axis.Y, corner_x*180 - 90) + + corner_holes.append(Pos(pos) * hole) + + # CSG to generate case + if has_lcd_cover: + shell -= lcd_cover_pocket + + shell -= itertools.chain( + face_button_holes, + side_button_holes, + corner_holes, + [ + lcd_module_pocket, + lcd_support_gap_pocket, + main_inner_pocket, + debug_header_slot, + hp_jack_slot, + lo_jack_slot, + usbc_slot, + card_slot, + lower_inner_pocket, + ], + ) + + shell += itertools.chain( + upper_pcb_supports, + lower_pcb_supports, + side_button_supports, + [ + lower_pcb_edge_support, + ] + ) + + shell -= support_holes + + return shell + + +def make_lower_shell(params: Params, datums: DatumSet) -> Compound: + shell = Box( + datums.box_dimension("plate", "x"), + datums.box_dimension("plate", "y"), + datums.box_dimension("plate", "z"), + align = Align.MIN, + ) + + # Shadow line to minimize visibility of seam with front shell + pos = datums.ushell.inner_origin.project_to_plane(datums.plate_front) + shell += ( + Pos(X = pos.X + params.lshell_wall_clearance, + Y = pos.Y + params.lshell_wall_clearance, + Z = datums.box_dimension("plate", "z")) * + Box( + datums.ushell.box_dimension("inner_wall", "x") - params.lshell_wall_clearance*2, + datums.ushell.box_dimension("inner_wall", "y") - params.lshell_wall_clearance*2, + params.lshell_shadowline_depth, + align = Align.MIN, + ) + ) + + # Plate for covering debug header + pos = datums.ushell.pcb.debug_header_left.origin.project_to_plane(datums.plate_front) + shell += ( + Pos(X = pos.X, Z = pos.Z) * + Pos(X = params.lshell_debugheader_side_clearance, + Y = datums.ushell.box_dimension("outer_wall", "y")) * + Box( + (params.debug_header_width + + params.debug_header_clearance_side*2 - + params.lshell_debugheader_side_clearance*2), + (params.wall_thickness_top + + params.lshell_wall_clearance), + (datums.ushell.pcb_front_origin.Z - + datums.plate_front.origin.Z - + params.lshell_debugheader_top_clearance), + align = (Align.MIN, Align.MAX, Align.MIN) + ) + ) + + # Battery box + battbox = ( + Pos(datums.ushell.pcb_battery_origin.project_to_plane(datums.plate_front)) * + Pos(X = -params.battbox_thickness, + Y = -params.battbox_thickness) * + Box( + datums.ushell.box_dimension("pcb_battery", "x") + params.battbox_thickness*2, + datums.ushell.box_dimension("pcb_battery", "y") + params.battbox_thickness*2, + params.battbox_depth, + align = Align.MIN, + ) + ) + + # Space for the battery + battbox_hole = ( + Pos(datums.ushell.pcb_battery_origin.project_to_plane(datums.ushell.pcb_battery_back)) * + Pos(X = -params.battbox_clearance_xz, + Y = -params.battbox_clearance_y, + Z = -params.battbox_clearance_xz) * + Box( + datums.ushell.box_dimension("pcb_battery", "x") + params.battbox_clearance_xz*2, + datums.ushell.box_dimension("pcb_battery", "y") + params.battbox_clearance_y, + datums.ushell.box_dimension("pcb_battery", "z") + params.battbox_clearance_xz*2, + align = Align.MIN, + ) + ) + + shell -= battbox_hole + battbox -= battbox_hole + + # Battery connector + bconn_to_batt_dist_y = (datums.ushell.pcb.battery_bottom.origin.Y - + datums.ushell.pcb.bconn_top.origin.Y) + + # Specified spring compression is 0.8mm +/- 0.2mm + assert abs(bconn_to_batt_dist_y - params.batt_spring_dist) <= params.batt_spring_tolerance + + battbox -= ( + Pos(datums.ushell.pcb_bconn_origin.project_to_plane(datums.ushell.pcb_bconn_back)) * + Pos(X = -params.bconn_clearance, + Y = -params.bconn_clearance, + Z = -params.bconn_clearance) * + Box( + datums.ushell.pcb.box_dimension("bconn", "x") + params.bconn_clearance*2, + datums.ushell.pcb.box_dimension("bconn", "y") + params.bconn_clearance + bconn_to_batt_dist_y - params.battbox_clearance_y, + datums.ushell.pcb.box_dimension("bconn", "z") + params.bconn_clearance*2, + align = Align.MIN, + ) + ) + + # Add squares on the corners to hold mounting screws + squares = [] + for corner_x, corner_y in ((0, 0), (0, 1), (1, 0), (1, 1)): + square = Box(params.lshell_cornersquare_thickness, + params.lshell_cornersquare_diameter + params.lshell_cornersquare_y_extend, + params.lshell_cornersquare_diameter, + align = Align.MIN) + square -= ( + Pos(X = corner_x * params.lshell_cornersquare_thickness, + Y = params.lshell_cornersquare_diameter/2, + Z = params.lshell_cornersquare_diameter/2) * ( + Cylinder(params.support_heat_insert_diameter/2, + params.lshell_cornersquare_thickness, + align = (Align.CENTER, Align.CENTER, + Align.MIN if corner_x == 0 else Align.MAX)) + .rotate(Axis.Y, 90) + ) + ) + + adj_x = params.lshell_cornersquare_thickness + params.lshell_wall_clearance*2 + adj_y = params.lshell_wall_clearance*2 + + pos = datums.ushell.inner_origin.project_to_plane(datums.plate_front) + pos += Vector(X = (params.inner_width - adj_x) * corner_x, + Y = (params.inner_height - adj_y) * corner_y) + pos += Vector(X = params.lshell_wall_clearance, + Y = params.lshell_wall_clearance) + + msquare = square + if corner_y > 0: + msquare = square.mirror(Plane.XZ) + + squares.append(Pos(pos) * msquare) + + shell += itertools.chain( + squares, + battbox, + ) + + return shell + +def make_battery_frame(params: Params, datums: DatumSet) -> Compound: + thickness = params.battframe_thickness + params.battframe_wall_height + + # Main body supporting the battery + frame = Box( + datums.ushell.box_dimension("pcb_battery", "x") + params.battbox_thickness*2, + datums.ushell.box_dimension("pcb_battery", "y") + params.battbox_thickness*2, + thickness, + align = Align.MIN, + ) + + frame -= ( + Pos(X = params.battbox_thickness, + Y = params.battbox_thickness) * + Box( + datums.ushell.box_dimension("pcb_battery", "x") + params.battbox_clearance_xz, + datums.ushell.box_dimension("pcb_battery", "y") + params.battbox_clearance_y, + params.battframe_wall_height, + align = Align.MIN, + ) + ) + + # Avoid volume up/down buttons + for pos in (datums.ushell.pcb.button_vol_up_pos, datums.ushell.pcb.button_vol_dn_pos): + frame -= ( + Pos(X = pos.X - params.bconn_clearance, + Y = pos.Y) * + Box( + params.side_pcb_button_body_height + params.bconn_clearance*2, + params.side_pcb_button_body_width + params.bconn_clearance*2, + thickness, + align = (Align.MIN, Align.CENTER, Align.MIN) + ) + ) + + # Support columns + support_length = datums.ushell.pcb_front_origin.Z - params.battframe_support_z_clearance + table = ( + (datums.dpad_pos, 0), + (datums.volume_pos, params.battframe_wall_height), + ) + + for pos, zoffset in table: + loc = Pos(X = pos.X, Y = pos.Y, Z = zoffset) + frame += loc * ( + Pos(Y = params.battframe_support_diameter/4) * + Box( + params.battframe_support_diameter, + params.battframe_support_diameter/2, + thickness - zoffset, + align = (Align.CENTER, Align.CENTER, Align.MIN), + ) + + Cylinder( + params.battframe_support_diameter/2, + support_length - zoffset, + align = (Align.CENTER, Align.CENTER, Align.MIN), + ) + ) + + # add this to volume support for pressing the PCB down + if zoffset != 0: + frame += loc * Cylinder( + params.battframe_support_diameter/2 + 0.6, + support_length - zoffset - 1.4, + align = (Align.CENTER, Align.CENTER, Align.MIN), + ) + + frame -= loc * Cylinder( + params.battframe_support_hole_diameter/2, + support_length, + align = (Align.CENTER, Align.CENTER, Align.MIN), + ) + + if zoffset > 0: + frame -= loc * Pos(Z = -zoffset) * Box( + params.battframe_support_above_hole_diameter, + params.battframe_support_above_hole_diameter, + zoffset, + align = (Align.CENTER, Align.CENTER, Align.MIN), + ) + + # Battery connector + bconn_to_batt_dist_y = (datums.ushell.pcb.battery_bottom.origin.Y - + datums.ushell.pcb.bconn_top.origin.Y) + + frame -= ( + Pos(datums.ushell.pcb_bconn_origin.project_to_plane(datums.ushell.pcb_bconn_back)) * + Pos(X = -params.bconn_clearance, + Y = -params.bconn_clearance, + Z = -params.bconn_clearance) * + Box( + datums.ushell.pcb.box_dimension("bconn", "x") + params.bconn_clearance*2, + (datums.ushell.pcb.box_dimension("bconn", "y") + + params.bconn_clearance + bconn_to_batt_dist_y + + params.battframe_connector_y_clearance), + datums.ushell.pcb.box_dimension("bconn", "z") + params.bconn_clearance*2, + align = Align.MIN, + ) + ) + + return frame + + +def mkface_dpad(params: DpadButtonParams, + clearance: float): + def fn(offset: float): + return make_dpad_arrow_face(edge_offset = offset - clearance, + **dataclasses.asdict(params)) + + return fn + +def mkface_circle(diameter: float, + clearance: float): + def fn(offset: float): + return Circle(radius=(diameter + offset)/2 - clearance) + + return fn + +def make_dome_button( + mkface: Callable[[float], Sketch], + dome_height: float, + dome_tip_height: float, + dome_tip_hole_diameter: float, + dome_rim_diameter: float, + travel: float, + enclosed_height: float, + enclosure_thickness: float, + lip_size: float, + lip_height: float, + edge_fillet_radius: float = 0, +) -> Compound: + # Origin for part + oz = -dome_tip_height + + # Compute available height inside the enclosure + inside_height = enclosed_height - dome_height + dome_tip_height + + # Divide height between the lip and the lofted section + loft_height = inside_height - lip_height + + # Button which extends out of the enclosure + btn_face = Pos(Z = oz + inside_height) * mkface(0) + btn_height = enclosure_thickness + travel + btn_part = extrude(btn_face, amount = btn_height) + + # Round off sharp-edged profiles to reduce ballooning at the corners + elist = list(btn_part.edges().filter_by(Axis.Z)) + if edge_fillet_radius > 0 and len(elist) > 1: + btn_part = fillet(elist, edge_fillet_radius) + + # Lip inside the enclosure, matching the button profile + lip_face = Pos(Z = oz + loft_height) * mkface(lip_size) + lip_part = extrude(lip_face, amount = lip_height) + + # Press face which contacts the dome; lofted to the lip profile + press_face = Pos(Z = oz) * Circle(radius = dome_rim_diameter/2) + press_part = loft([press_face, lip_face]) + + # Hole for the dome tip + tip_hole = Cylinder( + radius = dome_tip_hole_diameter/2, + height = dome_tip_height, + align = (Align.CENTER, Align.CENTER, Align.MAX) + ) + + # Assemble part + return btn_part + lip_part + press_part - tip_hole + +def make_startselect_button( + mkface: Callable[[float], Sketch], + smd_button_height: float, + button_height: float, + enclosed_height: float, + enclosure_thickness: float, + lip_size: float, +) -> Compound: + # Part origin + oz = smd_button_height + + # Compute available height inside the enclosure + inside_height = enclosed_height - smd_button_height + + # Button which extends out of the enclosure + btn_face = Pos(Z = oz + inside_height) * mkface(0) + btn_height = enclosure_thickness + button_height + btn_part = extrude(btn_face, amount = btn_height) + + # Press face with lip matching the button profile + press_face = Pos(Z = oz) * mkface(lip_size) + press_part = extrude(press_face, amount = inside_height) + + # Assemble part + return btn_part + press_part + + +def make_dome_buttons(params: Params, upper_shell_datums: DatumSet) -> list[Object]: + objects: list[Object] = [] + + dome_tip_hole_diameter = (params.contact_dome.tip_diameter + + params.face_button_dome_tip_clearance*2) + face_button_enclosed_height = ( + upper_shell_datums.lower_inner_wall_front.origin.Z - + upper_shell_datums.pcb_front_origin.Z + ) + + dome_args = { + "dome_height": params.contact_dome.height, + "dome_tip_height": params.contact_dome.tip_height, + "dome_tip_hole_diameter": dome_tip_hole_diameter, + "dome_rim_diameter": params.contact_dome.rim_diameter, + "travel": params.contact_dome.travel, + "enclosed_height": face_button_enclosed_height, + "enclosure_thickness": params.wall_thickness_front, + "lip_size": params.face_button_lip_size, + "lip_height": params.face_button_lip_height, + } + + # Create the D-pad arrow buttons + mkface_dpad_fn = mkface_dpad(params = params.dpad_button, + clearance = params.face_button_case_clearance) + dpad_button = make_dome_button(mkface_dpad_fn, **dome_args) + + objects.append(Object( + name = "button-dpad", + compound = dpad_button, + renderable = False, + )) + + # Now handle the ABXY buttons + circ_dome_button_diameter = copy(params.face_button_diameter) + del circ_dome_button_diameter["start"] + del circ_dome_button_diameter["select"] + + mkface_circ_fn = { + bdiam: mkface_circle(diameter = bdiam, + clearance = params.face_button_case_clearance) + for bdiam in set(circ_dome_button_diameter.values()) + } + + circ_button = { + bdiam: make_dome_button(mkface = mkface_circ, **dome_args) + for bdiam, mkface_circ in mkface_circ_fn.items() + } + + # Start/select buttons use a different design + startsel_button_diameter = { + "start": params.face_button_diameter["start"], + "select": params.face_button_diameter["select"], + } + + mkface_startsel_fn = { + bdiam: mkface_circle(diameter = bdiam, + clearance = params.face_button_case_clearance) + for bdiam in set(startsel_button_diameter.values()) + } + + startsel_args = { + "smd_button_height": params.startsel_button_smd_height, + "button_height": params.startsel_button_outer_height, + "enclosed_height": face_button_enclosed_height, + "enclosure_thickness": params.wall_thickness_front, + "lip_size": params.face_button_lip_size, + } + + startsel_button = { + bdiam: make_startselect_button(mkface = mkface_startsel, **startsel_args) + for bdiam, mkface_startsel in mkface_startsel_fn.items() + } + + for diam, button in itertools.chain(circ_button.items(), startsel_button.items()): + objects.append(Object( + name = f"button-dome-circular-{diam}mm", + compound = button, + renderable = False, + )) + + # Generate renderable buttons + dome_button_data = [ + ("a", 0), + ("b", 0), + ("x", 0), + ("y", 0), + ("start", 0), + ("select", 0), + ("dpad_up", 0), + ("dpad_left", 90), + ("dpad_down", 180), + ("dpad_right", 270), + ] + + for bname, rot_angle in dome_button_data: + if bname in "abxy": + button = circ_button[params.face_button_diameter[bname]] + elif bname in ["start", "select"]: + button = startsel_button[params.face_button_diameter[bname]] + else: + button = dpad_button + + button = button.rotate(Axis.Z, rot_angle) + button = button.translate(upper_shell_datums.point(f"pcb_button_{bname}_pos")) + + if bname not in ["start", "select"]: + button = button.translate(Vector(0, 0, params.contact_dome.height)) + + button.name = "button-" + bname.replace("_", "-") + button.color = Color(0.6, 0.6, 0.6, 1) + objects.append(Object( + name = button.name, + compound = button, + exportable = False, + )) + + return objects + + +def make_side_button(params: Params, + wall_dist: float, + wall_thickness: float) -> Compound: + # Part origin is at the button's press_pos + + # Extrude lip which rests on inner surface of case + lip_face = make_side_button_inner_face(params.side_button_lip_width, + params.side_button_lip_height, + params.side_button_lip_chamfer_size) + lip_length = wall_dist - params.side_button_presser_length + lip_length += params.side_button_lip_extra_length + lip_length += params.side_button_presser_length + part = ( + extrude(lip_face, lip_length) + .rotate(Axis.X, -90) + ) + + # Extrude the outer button face which passes through the case wall + outer_face = make_side_button_face(params.side_button_width, + params.side_button_height, + params.side_button_corner_radius) + outer_length = wall_thickness - params.side_button_lip_extra_length + outer_length += params.side_button_outer_extra_length + part += ( + Pos(Y = lip_length) * + extrude(outer_face, outer_length) + .rotate(Axis.X, -90) + ) + + return part + + +def make_pcb(params: PcbParams, + datums: DatumSet) -> Compound: + pcb = Box( + datums.box_dimension("board", "x"), + datums.box_dimension("board", "y"), + datums.box_dimension("board", "z"), + align = Align.MIN + ) + + holes = [] + for h_params in params.holes.values(): + holes.append( + Pos(X = h_params.x, Y = h_params.y) * + Cylinder( + radius = h_params.d/2, + height = params.thickness, + align = (Align.CENTER, Align.CENTER, Align.MIN), + ) + ) + + pcb -= holes + return pcb + + +def make_battery(params: Params) -> Compound: + batt = ( + Pos(X = params.battery_thickness/2) * + Box( + params.battery_width - params.battery_thickness, + params.battery_height, + params.battery_thickness, + align = (Align.MIN, Align.MIN, Align.MAX) + ) + ) + + side = Cylinder(params.battery_thickness/2, + params.battery_height, + align = (Align.MIN, Align.MAX, Align.MAX), + rotation = (90, 0, 0)) + + batt += side + batt += Pos(X = params.battery_width - params.battery_thickness) * side + + return batt + +def make_battery_connector(params: Params) -> Compound: + bconn = Box(params.bconn_width, + params.bconn_height, + params.bconn_depth, + align = (Align.MIN, Align.MIN, Align.MAX)) + + return bconn + +def make_side_pcb_button(params: Params) -> Compound: + body = Box(params.side_pcb_button_body_width, + params.side_pcb_button_body_height, + params.side_pcb_button_body_depth, + align = (Align.CENTER, Align.MIN, Align.MAX)) + + body += ( + Pos(Y = params.side_pcb_button_body_height, + Z = -params.side_pcb_button_body_depth/2) * + Box(params.side_pcb_button_presser_width, + params.side_pcb_button_presser_height, + params.side_pcb_button_presser_depth, + align = (Align.CENTER, Align.MIN, Align.CENTER)) + ) + + return body + +def build() -> list[Object]: + objects: list[Object] = [] + params = get_params() + pcb_ds = get_pcb_datums(params) + ushell_ds = get_upper_shell_datums(params, pcb_ds) + lshell_ds = get_lower_shell_datums(params, ushell_ds) + bframe_ds = get_battery_frame_datums(params, ushell_ds) + + upper_shell = make_upper_shell(params, ushell_ds) + upper_shell.color = Color(0.8, 0.8, 0.8, 1) + objects.append(Object( + name = "upper-shell", + compound = upper_shell, + datums = ushell_ds, + )) + + lshell_loc = lshell_ds.get_ref("ushell").loc.inverse() + lower_shell = lshell_loc * make_lower_shell(params, lshell_ds) + lower_shell.color = Color(0.7, 0.5, 0.5, 1) + objects.append(Object( + name = "lower-shell", + datums = lshell_ds, + datums_xform = lshell_loc, + compound = lower_shell, + )) + + bframe_loc = bframe_ds.get_ref("ushell").loc.inverse() + bframe = bframe_loc * make_battery_frame(params, bframe_ds) + bframe.color = Color(0.5, 0.5, 0.7, 1) + objects.append(Object( + name = "battery-frame", + compound = bframe, + )) + + objects += make_dome_buttons(params, ushell_ds) + + pcb = Pos(ushell_ds.pcb_back_origin) * make_pcb(params.pcb, pcb_ds) + pcb.color = Color(0.2, 0.8, 0.2, 1) + objects.append(Object( + name = "pcb", + datums = pcb_ds, + datums_xform = ushell_ds.get_ref("pcb").loc, + compound = pcb, + manufacturable = False, + )) + + batt = Pos(ushell_ds.pcb_battery_origin) * make_battery(params) + batt.color = Color(0.15, 0.15, 0.15, 1) + objects.append(Object( + name = "battery", + compound = batt, + manufacturable = False, + )) + + bconn = Pos(ushell_ds.pcb_bconn_origin) * make_battery_connector(params) + bconn.color = Color(0.15, 0.15, 0.4, 1) + objects.append(Object( + name = "battery-connector", + compound = bconn, + manufacturable = False, + )) + + + side_pcb_button = make_side_pcb_button(params) + + wall_dist_vol = abs(ushell_ds.pcb.button_vol_up_press_pos.X - ushell_ds.inner_wall_right.origin.X) + wall_dist_pwr = abs(ushell_ds.pcb.button_power_press_pos.Y - ushell_ds.inner_wall_top.origin.Y) + + side_vol_button = make_side_button(params, wall_dist_vol, params.wall_thickness_side) + side_pwr_button = make_side_button(params, wall_dist_pwr, params.wall_thickness_top) + + table = ( + ("volume-up", -90, "vol_up", side_vol_button, False, True), + ("volume-down", -90, "vol_dn", side_vol_button, False, True), + ("volume", 0, "vol_up", side_vol_button, True, False), + ("power", 0, "power", side_pwr_button, True, True), + ) + + for name, angle, dname, body, _, rendered in table: + if not rendered: + continue + + pcb_pos = ushell_ds.pcb.get_point(f"button_{dname}_pos") + pcb_btn = Pos(pcb_pos) * side_pcb_button.rotate(Axis.Z, angle) + pcb_btn.color = Color(0.6, 0.6, 0.6, 1) + objects.append(Object( + name = f"pcb-button-{name}", + compound = pcb_btn, + manufacturable = False, + )) + + for name, angle, dname, body, exported, rendered in table: + press_pos = ushell_ds.pcb.get_point(f"button_{dname}_press_pos") + btn = Pos(press_pos) * body.rotate(Axis.Z, angle) + btn.color = Color(0.2, 0.2, 0.2, 1) + objects.append(Object( + name = f"button-{name}", + compound = btn, + exportable = exported, + renderable = rendered, + )) + + return objects diff --git a/case/src/echoplayer/utils.py b/case/src/echoplayer/utils.py new file mode 100644 index 0000000..60a77c1 --- /dev/null +++ b/case/src/echoplayer/utils.py @@ -0,0 +1,479 @@ +from build123d import Axis, Compound, Location, Plane, Vector +from typing import Any, Optional, TypeAlias, Union, overload +from dataclasses import dataclass, field as dataclass_field +from copy import copy + +PlaneLike: TypeAlias = Plane +VectorLike: TypeAlias = Vector | tuple[float, float, float] + +def plane_at(plane: Plane, + *, + origin: Optional[VectorLike] = None, + projected_origin: Optional[VectorLike] = None, + x: Optional[float] = None, + y: Optional[float] = None, + z: Optional[float] = None, + dx: float = 0, + dy: float = 0, + dz: float = 0) -> Plane: + """ + Return a plane parallel to the input plane, centered at the given origin + """ + + if isinstance(plane, str): + gplane = getattr(Plane, plane) + assert isinstance(gplane, Plane) + elif isinstance(plane, Plane): + gplane = plane + else: + raise TypeError(f"bad plane {type(plane)}") + + if projected_origin: + origin = Vector(projected_origin).project_to_plane(gplane) + else: + origin = Vector(origin or gplane.origin) + + origin.X = x or origin.X + origin.Y = y or origin.Y + origin.Z = z or origin.Z + origin.X += dx + origin.Y += dy + origin.Z += dz + + return Plane(origin=origin, x_dir=gplane.x_dir, z_dir=gplane.z_dir) + + +def point_at(origin: VectorLike = (0, 0, 0), + *, + x: Optional[float] = None, + y: Optional[float] = None, + z: Optional[float] = None, + dx: float = 0, + dy: float = 0, + dz: float = 0) -> Vector: + """ + Return a point + """ + pt = Vector(origin) + pt.X = x if x else pt.X + pt.Y = y if y else pt.Y + pt.Z = z if z else pt.Z + pt.X += dx + pt.Y += dy + pt.Z += dz + return pt + +Datum: TypeAlias = Union[Vector, Axis, Plane] + +# build123d is unbelievably inconsistent with 3D math... + +def datum_pos(d: Datum) -> Vector: + if isinstance(d, Vector): + return d + if isinstance(d, Axis): + return d.position + if isinstance(d, Plane): + return d.origin + + raise TypeError(type(d)) + +def datum_setpos(d: Datum, p: Vector) -> None: + if isinstance(d, Vector): + d.X = p.X + d.Y = p.Y + d.Z = p.Z + elif isinstance(d, Axis): + d.position = p + elif isinstance(d, Plane): + d.origin = p + else: + raise TypeError(type(d)) + +def datum_loc(d: Datum) -> Location: + if isinstance(d, Vector): + return Location(d) + if isinstance(d, Axis): + return d.location + if isinstance(d, Plane): + return d.location + + raise TypeError(type(d)) + +@overload +def datum_transform(d: None, xform: Location) -> None: ... +@overload +def datum_transform(d: Vector, xform: Location) -> Vector: ... +@overload +def datum_transform(d: Axis, xform: Location) -> Axis: ... +@overload +def datum_transform(d: Plane, xform: Location) -> Plane: ... + +def datum_transform(d: Optional[Datum], xform: Location) -> Optional[Datum]: + if d is None: + return None + if isinstance(d, Vector): + return d + xform.position + if isinstance(d, Axis): + return copy(d).located(xform) + if isinstance(d, Plane): + return copy(d).move(xform) + + raise TypeError(type(d)) + +class DatumSetRef: + ref: "DatumSet" + loc: Location + + def __init__(self, ref: "DatumSet", loc: Location): + self.ref = ref + self.loc = loc + + def get_datum(self, name: str) -> Optional[Datum]: + return datum_transform(self.ref.get_datum(name), self.loc) + + def get_point(self, name: str) -> Vector: + return datum_transform(self.ref.get_point(name), self.loc) + + def get_axis(self, name: str) -> Axis: + return datum_transform(self.ref.get_axis(name), self.loc) + + def get_plane(self, name: str) -> Plane: + return datum_transform(self.ref.get_plane(name), self.loc) + + def get_ref(self, name: str) -> "DatumSetRef": + subref = self.ref.get_ref(name) + return DatumSetRef(subref.ref, self.loc * subref.loc) + + def box_dimension(self, name_prefix: str, axis: str) -> float: + return self.ref.box_dimension(name_prefix, axis) + + def box_point(self, name_prefix: str, align: VectorLike) -> Vector: + return datum_transform(self.ref.box_point(name_prefix, align), self.loc) + + def __getattr__(self, name: str) -> Any: + datum = self.get_datum(name) + if datum is not None: + return datum + + if name in self.ref.refs: + return self.get_ref(name) + + raise AttributeError(name) + +class DatumSet: + """ + A DatumSet is a container for named reference features (datums) sharing + a common coordinate system. + + Datums are independent of model geometry and can be used to help simplify + the construction of complex objects. Datums are points, lines, or planes, + corresponding to the build123d types Vector, Axis, or Plane, respectively. + Datum objects can be computed directly or they can be derived from faces, + edges, or vertexes. + + All datums within the same DatumSet are assumed to lie within a shared + coordinate system -- this makes it possible to compare arbitrary datums + within the set and apply rigid transforms to DatumSets. + + It is possible to attach one DatumSet to another using a transform, and + then reference datums in the attached DatumSet by adding aliases in the + parent DatumSet. The referenced datums will be transformed automatically. + This can be done recursively, allowing DatumSets to describe assemblies + involving multiple objects. + """ + + datums: dict[str, Datum] + aliases: dict[str, tuple[str, str]] + refs: dict[str, DatumSetRef] + + @staticmethod + def __refname(ref: Optional[str]): + if ref is None: + return "" + + if len(ref) == 0: + raise ValueError("DatumSet refname must not be an empty string") + + return ref + + def __init__(self): + self.datums = {} + self.aliases = {} + self.refs = { DatumSet.__refname(None) : DatumSetRef(self, Location()) } + + def add_point(self, + name: str, + origin: Optional[VectorLike] = None, + *, + X: Optional[float] = None, + Y: Optional[float] = None, + Z: Optional[float] = None, + dX: float = 0, + dY: float = 0, + dZ: float = 0) -> None: + if origin is None: + origin = Vector() + else: + origin = Vector(origin) + + return self.add_datum(name, origin, X=X, Y=Y, Z=Z, dX=dX, dY=dY, dZ=dZ) + + def add_axis(self, + name: str, + axis: Axis, + *, + origin: Optional[VectorLike] = None, + X: Optional[float] = None, + Y: Optional[float] = None, + Z: Optional[float] = None, + dX: float = 0, + dY: float = 0, + dZ: float = 0) -> None: + return self.add_datum(name, axis, origin=origin, X=X, Y=Y, Z=Z, dX=dX, dY=dY, dZ=dZ) + + def add_plane(self, + name: str, + plane: Plane, + *, + projected_origin: Optional[VectorLike] = None, + origin: Optional[VectorLike] = None, + X: Optional[float] = None, + Y: Optional[float] = None, + Z: Optional[float] = None, + dX: float = 0, + dY: float = 0, + dZ: float = 0) -> None: + if projected_origin is not None: + if origin is not None: + raise ValueError("use only one of origin and projected_origin") + + origin = Vector(projected_origin).project_to_plane(plane) + + return self.add_datum(name, plane, origin=origin, X=X, Y=Y, Z=Z, dX=dX, dY=dY, dZ=dZ) + + def add_datum(self, + name: str, + datum: Datum, + *, + origin: Optional[VectorLike] = None, + X: Optional[float] = None, + Y: Optional[float] = None, + Z: Optional[float] = None, + dX: float = 0, + dY: float = 0, + dZ: float = 0) -> None: + if name in self.datums: + raise ValueError(f"Datum \"{name}\" already exists") + if name in self.aliases: + raise ValueError(f"Datum \"{name}\" shadows existing alias") + if name in self.refs: + raise ValueError(f"Datum \"{name}\" shadows datum set reference") + + if origin is not None: + origin = Vector(origin) + else: + origin = datum_pos(datum) + + origin.X = (X or origin.X) + dX + origin.Y = (Y or origin.Y) + dY + origin.Z = (Z or origin.Z) + dZ + + ldatum = copy(datum) + datum_setpos(ldatum, origin) + + self.datums[name] = ldatum + + def add_reference(self, + ref: str, + datums: "DatumSet", + transform: Location = Location()) -> None: + refname = DatumSet.__refname(ref) + if refname in self.datums: + raise ValueError(f"DatumSet reference \"{refname}\" shadows existing datum") + if refname in self.aliases: + raise ValueError(f"DatumSet reference \"{refname}\" shadows existing alias") + if refname in self.refs: + raise ValueError(f"DatumSet reference \"{refname}\" already exists") + + self.refs[refname] = DatumSetRef(datums, copy(transform)) + + def add_alias(self, + newname: str, + name: str, + ref: Optional[str] = None) -> None: + if newname in self.datums: + raise ValueError(f"Alias \"{newname}\" shadows existing datum") + if newname in self.refs: + raise ValueError(f"Alias \"{newname}\" shadows datum set reference") + if newname in self.aliases: + raise ValueError(f"Alias \"{newname}\" already exists") + + refname = DatumSet.__refname(ref) + if refname not in self.refs: + raise ValueError(f"Reference \"{refname}\" does not exist") + if self.refs[refname].get_datum(name) is None: + raise ValueError(f"Datum \"{name}\" not found in reference \"{refname}\"") + + self.aliases[newname] = (refname, name) + + def get_datum(self, name: str) -> Optional[Datum]: + datum = self.datums.get(name) + if datum is not None: + return copy(datum) + + alias = self.aliases.get(name) + if alias is not None: + refname, othername = alias + return self.get_ref(refname).get_datum(othername) + + return None + + def get_point(self, name: str) -> Vector: + datum = self.get_datum(name) + if datum is None: + raise KeyError(name) + if type(datum) is not Vector: + raise TypeError(f"{name} is not a point") + + return datum + + def get_axis(self, name: str) -> Axis: + datum = self.get_datum(name) + if datum is None: + raise KeyError(name) + if type(datum) is not Axis: + raise TypeError(f"{name} is not an axis") + + return datum + + def get_plane(self, name: str) -> Plane: + datum = self.get_datum(name) + if datum is None: + raise KeyError(name) + if type(datum) is not Plane: + raise TypeError(f"{name} is not a plane") + + return datum + + def get_ref(self, name: str) -> DatumSetRef: + return self.refs[name] + + def point(self, name: str) -> Vector: + return self.get_point(name) + + def plane(self, name: str) -> Plane: + return self.get_plane(name) + + def __getattr__(self, name: str) -> Any: + datum = self.get_datum(name) + if datum is not None: + return datum + + ref = self.refs.get(name) + if ref is not None: + return ref + + raise AttributeError(name) + + def add_box( + self, + name_prefix: str, + dimensions: VectorLike, + origin: VectorLike = (0, 0, 0), + alignment: VectorLike = (0, 0, 0), + ): + origin_t = tuple(origin) + alignment_t = tuple(alignment) + dimensions_t = tuple(dimensions) + axis_mapping = { + "front": (2, "z", 1), + "back": (2, "z", -1), + "top": (1, "y", 1), + "bottom": (1, "y", -1), + "right": (0, "x", 1), + "left": (0, "x", -1), + } + + base_planes = { + "xy": Plane.XY, + "xz": Plane.XZ, + "yz": Plane.YZ, + } + + for name_suffix in axis_mapping: + name = f"{name_prefix}_{name_suffix}" + index, coord, sign = axis_mapping[name_suffix] + plane = base_planes["xyz".replace(coord, "")] + + if alignment_t[index] == 0: + offset = sign * dimensions_t[index] / 2 + elif sign * alignment_t[index] < 0: + offset = sign * dimensions_t[index] + else: + offset = 0 + + args = [origin_t[0], origin_t[1], origin_t[2]] + args[index] += offset + self.add_plane(name, plane=plane, origin=Vector(*args)) + + def box_dimension(self, name_prefix: str, axis: str) -> float: + axis_mapping = { + "x": ("left", "right"), + "y": ("bottom", "top"), + "z": ("back", "front"), + } + + side0, side1 = axis_mapping[axis] + plane0 = self.get_plane(f"{name_prefix}_{side0}") + plane1 = self.get_plane(f"{name_prefix}_{side1}") + + return (getattr(plane1.origin, axis.upper()) - + getattr(plane0.origin, axis.upper())) + + def box_point(self, name_prefix: str, align: VectorLike) -> Vector: + align_t = tuple(align) + out_t = [0, 0, 0] + axis_mapping = ( + ("x", "left", "right"), + ("y", "bottom", "top"), + ("z", "back", "front"), + ) + + for index in range(3): + axis, side0, side1 = axis_mapping[index] + plane0 = self.get_plane(f"{name_prefix}_{side0}") + plane1 = self.get_plane(f"{name_prefix}_{side1}") + + if align_t[index] == 0: + coord = (getattr(plane0.origin, axis.upper()) + + getattr(plane1.origin, axis.upper())) / 2 + elif align_t[index] < 0: + coord = getattr(plane0.origin, axis.upper()) + else: + coord = getattr(plane1.origin, axis.upper()) + + out_t[index] = coord + + return Vector(*out_t) + + +@dataclass +class Object: + # Name of the object for rendering and exporting + name: str + + # Compound and datum points + compound: Optional[Compound] = None + datums: Optional[DatumSet] = None + datums_xform: Location = dataclass_field(default_factory=Location) + + # Control whether the object is used for rendering, exporting, or both. + # This is used for instanced objects like buttons which have only one + # model to export but need to appear multiple times for rendering. + renderable: bool = True + exportable: bool = True + + # Flag for manufacturable objects; false for render-only mockups. + # Mockup objects are still exportable because they may be useful + # to import to another CAD package. + manufacturable: bool = True diff --git a/r1-rev1/case/Echo-R1-Rev1.FCStd b/r1-rev1/case/Echo-R1-Rev1.FCStd deleted file mode 100644 index 421df49..0000000 Binary files a/r1-rev1/case/Echo-R1-Rev1.FCStd and /dev/null differ diff --git a/r1-rev1/docs/bom.ods b/r1-rev1/docs/bom.ods index bda31bf..296cb34 100644 Binary files a/r1-rev1/docs/bom.ods and b/r1-rev1/docs/bom.ods differ diff --git a/r1-rev1/pcb/echo-r1.kicad_pcb b/r1-rev1/pcb/echo-r1.kicad_pcb index b791cd3..d884db3 100644 --- a/r1-rev1/pcb/echo-r1.kicad_pcb +++ b/r1-rev1/pcb/echo-r1.kicad_pcb @@ -40120,8 +40120,8 @@ (uuid "17b1ace7-bc1d-4f94-825a-34986e154769") ) (gr_rect - (start 135.2 70.3) - (end 169.5 123.8) + (start 138.2 70.3) + (end 172.5 123.8) (stroke (width 0.1) (type dash) @@ -40268,7 +40268,7 @@ ) ) (gr_text "BATTERY" - (at 168 123.5 0) + (at 171 123.5 0) (layer "B.Fab") (uuid "e7281f06-d356-4819-8d04-1ed8c63c7887") (effects diff --git a/r1-rev1/pcb/power_supply.kicad_sch b/r1-rev1/pcb/power_supply.kicad_sch index 4c3749c..00c19a9 100644 --- a/r1-rev1/pcb/power_supply.kicad_sch +++ b/r1-rev1/pcb/power_supply.kicad_sch @@ -2160,18 +2160,6 @@ ) (uuid d67f5db8-31f4-4872-8897-47e18538154f) ) - (text "TODO: height TBD\nprobably 4.6mm\nor 6.0mm" - (exclude_from_sim no) - (at 43.434 63.754 0) - (effects - (font - (size 1.27 1.27) - (thickness 0.254) - (bold yes) - ) - ) - (uuid "0ebeb752-4e22-4f2a-a2ef-bea61e351ab7") - ) (text "3.3V REGULATOR" (exclude_from_sim no) (at 96.774 85.344 0) @@ -4498,7 +4486,7 @@ (justify left) ) ) - (property "Value" "GT-BTP30003-xxxA-16A" + (property "Value" "GT-BTP30003-0680A-16A" (at 73.152 53.34 0) (effects (font @@ -4516,7 +4504,7 @@ (hide yes) ) ) - (property "Datasheet" "https://www.lcsc.com/datasheet/lcsc_datasheet_2409091552_G-Switch-GT-BTP30003-0460A-016A_C41378737.pdf" + (property "Datasheet" "https://www.lcsc.com/datasheet/C41378741.pdf" (at 60.96 60.96 0) (effects (font @@ -4534,7 +4522,7 @@ (hide yes) ) ) - (property "LCSC#" "C41378737" + (property "LCSC#" "C41378741" (at 60.96 60.96 0) (effects (font