gokul/macro-six-usbc

USB-powered ATmega32U4 controller board with protected/switched 5 V power, six tactile keys, rotary encoder, reset/boot/ISP interfaces, power LED, and six daisy-chained SK6812 RGB LEDs driven through MOSFETs.

Version
0.1.6
License
MIT
Stars
0

enclosure/build.py

"""Generate the enclosure from the unchanged release PCB. Dimensions in mm."""
from pathlib import Path
import hashlib
import json
import math
import cadquery as cq
import numpy as np
import trimesh
from hardware import add_hardware

ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "enclosure" / "exports"
OUT.mkdir(parents=True, exist_ok=True)
board_path = ROOT / "outputs/release/board.circuit.json"
board_bytes = board_path.read_bytes()
j = json.loads(board_bytes)
board = next(e for e in j if e["type"] == "pcb_board")
sources = {e["source_component_id"]: e for e in j if e["type"] == "source_component"}
mounts = [(e["x"], e["y"]) for e in j if e["type"] == "pcb_hole" and e.get("pcb_component_id") is None and e.get("hole_diameter") == 3.2]
keys = [(e["position"]["x"], e["position"]["y"]) for e in j if e["type"] == "cad_component" and sources[e["source_component_id"]]["name"] in [f"SW{i}" for i in range(1,7)]]

P = dict(board_bottom=7.0, board_thickness=1.6, floor=2.4, wall=2.6,
         board_edge_gap=0.6, seam=10.6, lid_bottom=17.6, lid_top=20.0,
         plate_top_above_pcb=5.0, plate_thickness=1.5,
         switch_aperture=14.1, keycap_aperture=20.5,
         assumed_keycap_width=18.5, assumed_keycap_height=11.0,
         assumed_keycap_skirt_above_pcb=10.5, switch_travel=4.0,
         knob_diameter=18.0, usb_opening_width=17.0)
pcb_top = P["board_bottom"] + P["board_thickness"]
plate_top = pcb_top + P["plate_top_above_pcb"]
plate_bottom = plate_top - P["plate_thickness"]
inner_w, inner_h = board["width"] + 1.2, board["height"] + 1.2
outer_w, outer_h = inner_w + 5.2, inner_h + 5.2
plate_posts = [(-44,18),(-44,-18),(-5,0),(18,0),(24,-28)]

def rect(w,h,z,height,x=0,y=0,r=0):
    s = cq.Workplane("XY").box(w,h,height,centered=(True,True,False)).translate((x,y,z))
    return s.edges("|Z").fillet(r) if r else s

def cyl(x,y,z,r,h):
    return cq.Workplane("XY").circle(r).extrude(h).translate((x,y,z))

def outer(z,h): return rect(outer_w,outer_h,z,h,r=5)
def cavity(z,h): return rect(inner_w,inner_h,z,h,r=2.4)

def side_openings(s):
    # Wide rear opening admits the USB cable overmould through the recessed wall.
    s = s.cut(rect(P["usb_opening_width"],15,5,11,15,36,r=0.7))
    # Recessed thumb pocket gives access to the existing left actuator.
    return s.cut(rect(18,17,6,20,-51,0,r=1.0))

base = outer(0,P["seam"]).cut(cavity(P["floor"],30))
for x,y in mounts:
    base = base.union(cyl(x,y,P["floor"],3.5,P["board_bottom"]-P["floor"]))
    # Blind M3 pilot; screw tips cannot emerge through the bottom.
    base = base.cut(cyl(x,y,1.2,1.3,P["board_bottom"]+1))
base = side_openings(base)

upper = outer(P["seam"],P["lid_top"]-P["seam"]).cut(cavity(P["seam"]-0.1,P["lid_bottom"]-P["seam"]+0.1))
for x,y in mounts:
    upper = upper.union(cyl(x,y,pcb_top,3.5,P["lid_bottom"]-pcb_top+0.1))
    upper = upper.cut(cyl(x,y,pcb_top-0.1,1.7,15))
# The northeast mounting hole is next to C_X1; retain an open-sided spacer foot.
upper=upper.cut(rect(3.2,1.6,pcb_top-.1,2.6,50,26))
for x,y in keys:
    upper = upper.cut(rect(P["keycap_aperture"],P["keycap_aperture"],P["lid_bottom"]-0.1,4,x,y,r=0.8))
    # RGB apertures join the key openings as small notches, avoiding thin webs.
    upper = upper.cut(cyl(x+10.5,y+5,P["lid_bottom"]-0.1,1.5,4))
upper = upper.cut(cyl(43,0,P["lid_bottom"]-0.1,4.3,4))
for x,y in [(36,-19),(45,-19)]:
    upper = upper.cut(cyl(x,y,P["lid_bottom"]-0.1,1.6,4))
for x,y in plate_posts:
    upper = upper.union(cyl(x,y,plate_top,2.5,P["lid_bottom"]-plate_top+0.2))
    upper = upper.cut(cyl(x,y,plate_top-0.1,1.1,8))
upper = side_openings(upper)

plate = rect(109.6,67.6,plate_bottom,P["plate_thickness"],r=3)
for x,y in keys:
    plate = plate.cut(rect(P["switch_aperture"],P["switch_aperture"],plate_bottom-0.1,2,x,y))
    plate = plate.cut(rect(4.2,4.2,plate_bottom-0.1,2,x+10.5,y+5))
plate = plate.cut(rect(16,20,plate_bottom-0.1,2,43,0))
plate = plate.cut(rect(18,17,plate_bottom-0.1,2,-51,0))
for x,y in mounts: plate = plate.cut(cyl(x,y,plate_bottom-0.1,3.7,2))
for x,y in [(36,-19),(45,-19)]:
    plate = plate.cut(cyl(x,y,plate_bottom-0.1,1.6,2))
plate = plate.cut(rect(9,6,plate_bottom-0.1,2,38,-27))
for x,y in plate_posts:
    plate = plate.union(cyl(x,y,plate_bottom-2,3.5,2.1))
    plate = plate.cut(cyl(x,y,plate_bottom-2.1,1.1,4))
    # Bottom-open captive M2 hex nut, 4 mm nominal across flats.
    nut = cq.Workplane("XY").polygon(6,4.2/math.cos(math.pi/6)).extrude(1.8).translate((x,y,plate_bottom-2-.1))
    plate = plate.cut(nut)

coupon = rect(76,23,0,1.5,r=1)
for i,a in enumerate([13.9,14.0,14.1,14.2]):
    coupon = coupon.cut(rect(a,a,-0.1,2,-28.5+i*19,0))

parts = {"bottom":base, "top":upper, "switch-plate":plate, "switch-fit-coupon":coupon}
validation = {"pcb_sha256":hashlib.sha256(board_bytes).hexdigest(), "parameters_mm":P,
              "outer_dimensions_mm":[outer_w,outer_h,P["lid_top"]],
              "mounts_xy":mounts,"switch_centers_xy":keys,
              "plate_mounts_xy":plate_posts,"parts":{},"clashes":[]}

assembly = cq.Assembly(name="Macro6-enclosure")
scene = trimesh.Scene()
colors = {"bottom":[32,39,48,255],"top":[47,59,73,255],"switch-plate":[104,126,145,255]}

def mesh(s):
    v,f=s.val().tessellate(0.06,0.15)
    return trimesh.Trimesh(vertices=[a.toTuple() for a in v],faces=f,process=True)

for name,s in parts.items():
    shape=s.val()
    cq.exporters.export(s,str(OUT/(name+".step")))
    # All STLs are oriented for printing with their largest flat face on the bed.
    print_s=s.rotate((0,0,0),(1,0,0),180).translate((0,0,P["lid_top"])) if name=="top" else s.rotate((0,0,0),(1,0,0),180).translate((0,0,plate_top)) if name=="switch-plate" else s
    cq.exporters.export(print_s,str(OUT/(name+".stl")),tolerance=0.06,angularTolerance=0.15)
    m=trimesh.load(OUT/(name+".stl"),force="mesh")
    validation["parts"][name]={"valid_brep":shape.isValid(),"solids":len(shape.Solids()),
        "volume_mm3":round(shape.Volume(),3),"stl_watertight":bool(m.is_watertight),
        "print_bounds_mm":m.bounds.tolist()}
    assert shape.isValid() and len(shape.Solids())==1 and m.is_watertight,name
    if name!="switch-fit-coupon":
        assembly.add(s,name=name,color=cq.Color(*(c/255 for c in colors[name][:3])))
        tm=mesh(s);tm.visual.face_colors=colors[name];scene.add_geometry(tm,node_name=name)

for a,b in [("bottom","top"),("top","switch-plate"),("bottom","switch-plate")]:
    vol=parts[a].intersect(parts[b]).val().Volume()
    validation["clashes"].append({"parts":[a,b],"intersection_mm3":round(vol,7)})
    assert vol<1e-5,(a,b,vol)

# Unchanged PCB reference, including the actual outline and holes.
pcb=cq.Workplane("XY").polyline([(p["x"],p["y"]) for p in board["outline"]]).close().extrude(board["thickness"]).translate((0,0,P["board_bottom"]))
for e in j:
    if e["type"]=="pcb_hole" and e.get("hole_shape")=="circle":
        pcb=pcb.cut(cyl(e["x"],e["y"],P["board_bottom"]-.1,e["hole_diameter"]/2,1.8))
for name in ["bottom","top","switch-plate"]:
    v=parts[name].intersect(pcb).val().Volume()
    validation["clashes"].append({"parts":[name,"PCB"],"intersection_mm3":round(v,7)})
    assert v<1e-5,(name,"PCB",v)
assembly.add(pcb,name="PCB-reference",color=cq.Color(0.05,0.30,0.20))
pm=mesh(pcb);pm.visual.face_colors=[20,86,62,255];scene.add_geometry(pm,node_name="PCB-reference")
assembly.save(str(OUT/"enclosure-assembly.step"))

# Supplier meshes are references only. They are not included in the print STLs.
for e in j:
    if e["type"]!="cad_component":continue
    name=sources[e["source_component_id"]]["name"]
    model_name="SW6" if name in [f"SW{i}" for i in range(1,7)] else name
    if name=="Q_MAIN":model_name="Q_RGB"
    if name=="SW_RESET":model_name="SW_BOOT"
    if name.startswith("U_RGB"):model_name="U_RGB6"
    path=ROOT/"work/enclosure-models"/(model_name+".obj")
    if not path.exists():continue
    tm=trimesh.load(path,force="mesh",process=False)
    origin=e.get("model_origin_position",{})
    tm.apply_translation([-origin.get(k,0) for k in ["x","y","z"]])
    tm.apply_transform(trimesh.transformations.rotation_matrix(math.radians(e["rotation"].get("z",0)),[0,0,1]))
    pos=e["position"];tm.apply_translation([pos["x"],pos["y"],pcb_top])
    tm.visual.face_colors=[155,163,171,255] if name in [f"SW{i}" for i in range(1,7)] else [188,194,198,255]
    scene.add_geometry(tm,node_name=name+"-supplier-model")

# Styled reference keycaps and knob retain the reviewed external clearance envelope.
from appearance import make_appearance
appearance_parts=make_appearance(keys,pcb_top)
for part in appearance_parts:
    solid=part["solid"]
    assembly.add(solid,name=part["name"],color=cq.Color(*(v/255 for v in part["color"][:3])))
    tm=mesh(solid)
    tm.visual=trimesh.visual.TextureVisuals(material=trimesh.visual.material.PBRMaterial(
        baseColorFactor=part["color"],metallicFactor=part.get("metallic",0),
        roughnessFactor=part.get("roughness",.65)))
    scene.add_geometry(tm,node_name=part["name"])
for x,y in keys:
    sweep=rect(P["assumed_keycap_width"],P["assumed_keycap_width"],
               pcb_top+P["assumed_keycap_skirt_above_pcb"]-P["switch_travel"],
               P["assumed_keycap_height"]+P["switch_travel"],x,y,r=1)
    assert upper.intersect(sweep).val().Volume()<1e-5
    assert plate.intersect(sweep).val().Volume()<1e-5
hardware_nodes=add_hardware({"assembly":assembly,"scene":scene,"mounts":mounts,"plate_mounts":plate_posts})
assembly.save(str(OUT/"enclosure-complete.step"))
scene.export(str(OUT/"enclosure-preview.glb"))
# Immutable GLB assets preserve material colors in the public assembly viewer.
viewer=ROOT/"enclosure/viewer"
viewer.mkdir(exist_ok=True)
for asset_name,node_names in {
    "bottom":["bottom"],"top":["top"],"switch-plate":["switch-plate"],
    "keycaps-reference":[p["name"] for p in appearance_parts if p["asset"]=="keycaps-reference"],
    "knob-reference":[p["name"] for p in appearance_parts if p["asset"]=="knob-reference"],
    "hardware":hardware_nodes,
}.items():
    asset_scene=trimesh.Scene()
    for node_name in node_names:
        placement,geometry=scene.graph[node_name]
        asset_mesh=scene.geometry[geometry].copy()
        asset_mesh.apply_transform(placement)
        # Bake placement into vertices: some exporters do not support GLB node.matrix.
        asset_mesh.apply_translation([0,0,-(P["board_bottom"]+P["board_thickness"]/2)])
        asset_scene.add_geometry(asset_mesh,node_name=node_name)
    asset_scene.export(str(viewer/(asset_name+".glb")))
exploded=scene.copy()
for node in list(exploded.graph.nodes_geometry):
    matrix,geom=exploded.graph[node]
    dz=45 if node=="top" or node.startswith("keycap-") or node.startswith("knob-reference") else 23 if node=="switch-plate" else 10 if node!="bottom" else 0
    matrix=matrix.copy();matrix[2,3]+=dz
    exploded.graph.update(frame_to=node,matrix=matrix,geometry=geom)
exploded.export(str(OUT/"enclosure-exploded.glb"))

validation["keycap_full_travel_clearance_pass"]=True
validation["physical_fit_verified"]=False
validation["assumptions"]=["1U MX keycaps within 18.5 mm square envelope; profile unselected",
    "Plate top 5.0 mm above PCB from supplier flange underside; confirm with fit coupon",
    "M3 pilots require tapping or suitable plastic thread-forming screws",
    "M3x16 outer closure screws and M2x10 plate screws with captive M2 nuts; verify lengths on prototype",
    "Knob is a nominal 18 mm envelope; actual knob unselected",
    "USB overmould and power actuator access require physical checks"]
(OUT/"validation.json").write_text(json.dumps(validation,indent=2)+"\n")
assert board_path.read_bytes()==board_bytes,"PCB was modified"
print(json.dumps(validation,indent=2))