abse/museair-usb

USB-C–powered ESP32-C3 controller board with AP63203 buck conversion, AP2112K 3.3V regulation, I²C SEN62 sensor interface, status LED, buttons, UART/I²C headers, and extensive decoupling.

Version
0.5.7
License
unset
Stars
0

scripts/generate-horizontal-enclosure.py

from pathlib import Path
exec((Path(__file__).resolve().parent/'horizontal-cad-support.py').read_text())
exec((Path(__file__).resolve().parent/'horizontal-layout.py').read_text())
from OCP.BRepBuilderAPI import BRepBuilderAPI_GTransform
from OCP.gp import gp_GTrsf,gp_Mat,gp_XYZ,gp_Ax1
R=np.array([[0,-1,0],[0,0,1],[-1,0,0]],float)
def transform(s):
 t=gp_Trsf();t.SetValues(0,-1,0,-6,0,0,1,3.8,-1,0,0,20.2);return BRepBuilderAPI_Transform(s,t,True).Shape()
def native(name):
 r=STEPControl_Reader();r.ReadFile(str(ROOT/'mechanical'/name));r.TransferRoots();return r.OneShape()
def air(p,y,h):
 # Source XY profiles become world XZ, extrusion runs toward back.
 s=extrude(p,0,h);t=gp_Trsf();t.SetValues(0,-1,0,-6,0,0,1,y,-1,0,0,20.2);return BRepBuilderAPI_Transform(s,t,True).Shape()
panel_z=json.loads((OUT/'display-audio-mechanical.json').read_text()).get('panel_bottom_z_mm',9) if (OUT/'display-audio-mechanical.json').exists() else 9
outer=rounded(-40,-28,40,28,4)
inner=outer.buffer(-2)
base=cut(extrude(outer,3,42),extrude(inner,5,42));lid=extrude(outer,45,2)
# Rounded USB pocket and integral close-fitting lip; shifted socket is
# nearly flush with the shared surround face coordinate.
def usb_profile(profile,x,depth):
 shape=extrude(profile,0,depth)
 t=gp_Trsf();t.SetValues(0,0,1,x,1,0,0,0,0,1,0,0)
 return BRepBuilderAPI_Transform(shape,t,True).Shape()
pocket=rounded(7.5,35.3,19.5,44.1,3)
socket_opening=rounded(8.73,37.45,18.27,42.15,1.2)
base=cut(base,usb_profile(pocket,35,8))
# Integral rounded return and 1.2 mm face; no separate loose bezel.
usb_surround=usb_profile(pocket.buffer(.8).difference(pocket),USB_SURROUND_FACE_X_MM-1.2,40-(USB_SURROUND_FACE_X_MM-1.2))
usb_surround=fuse(usb_surround,usb_profile(pocket.difference(socket_opening),USB_SURROUND_FACE_X_MM-1.2,1.2))
base=fuse(base,usb_surround)
closure=[(-36.8,-24.8),(36.2,-25.7),(-37.4,24.8),(36.2,25.7)]
for x,y in closure:
 base=fuse(base,cylinder(x,y,5,2.2,40));base=cut(base,cylinder(x,y,36,.8,10));lid=cut(lid,cylinder(x,y,44,1.2,5));lid=cut(lid,cylinder(x,y,45.4,2.2,2))
# Internal flex relief below the right lid-thread seat; outer wall stays closed.
base=cut(base,block(33.7,19,panel_z+8.5,4,6.8,7.4))
mounts=[(-y+PCB_X_SHIFT_MM,x) for x in [-18,18] for y in [-24,19]]
# Four internal wall ledges support the PCB; nothing attaches it to the lid.
# Bosses stay above the sensor's z=33 upper envelope.
def side_gusset(x,y,edge,reach):
 # Triangle in XZ: tall root at the wall, tip under the bridge.
 coords=[(edge,10),(edge,33.6),(reach,33.6),(edge,10)]
 w=BRepBuilderAPI_MakePolygon()
 for xx,zz in coords[:-1]:w.Add(gp_Pnt(xx,y-1,zz))
 w.Close()
 return BRepPrimAPI_MakePrism(BRepBuilderAPI_MakeFace(w.Wire()).Face(),gp_Vec(0,2,0)).Shape()
for x,y in mounts:
 edge=-38 if x<0 else 38
 support=cylinder(x,y,33.6,3.1,3.4)
 # Thicker side-wall bridges avoid the front air ports.
 support=fuse(support,block(min(x,edge),y-3.1,33.6,abs(edge-x),6.2,3.4))
 # Front webs stop outside the sensor body; rear webs reach the boss.
 reach=(-28.2 if x<0 else 28.2) if y<0 else x
 for yy in [y-1.9,y+1.9]:support=fuse(support,side_gusset(x,yy,edge,reach))
 # Keep the existing speaker and display tail clear of the new webs.
 if x<0 and y>0:
  t=gp_Trsf();t.SetValues(0,0,1,-37.4,1,0,0,3,0,1,0,20.5)
  speaker_space=BRepBuilderAPI_Transform(cylinder(0,0,0,14.5,6.3),t,True).Shape()
  support=cut(support,speaker_space)
 if x>0 and y>0:support=cut(support,block(33.7,17,25.9,4,8.8,6.5))
 support=cut(support,cylinder(x,y,34,1.65,3.2))
 base=fuse(base,support)
# Keep the flex relief open after adding the new gussets.
base=cut(base,block(33.7,19,panel_z+8.5,4,6.8,7.4))
# BuyDisplay panel: recess for 69.43 x 24.38 x 2.1 mm body,
# 65.14 x 22.28 mm visible opening on the positive-Y wall.
base=cut(base,block(-35.965,25.05,panel_z-.25,69.93,2.55,24.88))
base=cut(base,block(-33.57,27.35,panel_z+1.05,65.14,3,22.28))
# Integrated rear retaining ribs support the panel with nominal 0.25 mm adhesive.
# The right ribs leave the side-tail exit and U-turn completely open.
base=fuse(base,block(-37.3,24.25,panel_z+1,4,.8,22.4))
base=fuse(base,block(-37.3,24.25,panel_z+1,1.3,1.85,22.4))
for z,h in [(panel_z+1,7.5),(panel_z+16,7.4)]:
 base=fuse(base,block(32,24.25,z,5.3,.8,h))
 base=fuse(base,block(36,24.25,z,1.3,1.85,h))
# Preserve the blind lid-thread seats through the integrated ribs.
for x,y in closure:base=cut(base,cylinder(x,y,36,.8,10))
# Acoustic grille faces left; sensor air paths remain on negative Y.
for yy in [-6,-3,0,3,6,9,12]:
 for zz in [11,14,17,20,23,26,29]:
  if (yy-3)**2+(zz-20)**2<=110:
   hole=cylinder(0,0,0,.8,5);t=gp_Trsf();t.SetValues(0,0,-1,-36,1,0,0,yy,0,1,0,zz)
   base=cut(base,BRepBuilderAPI_Transform(hole,t,True).Shape())
# Internal adhesive/foam-supported speaker rim, no external mounting screws.
# CMS-28588N-L152A: 28 mm frame, 5.5 +/-0.2 mm deep; reserve 6 mm.
# 1 mm rim gasket seats the front at x=-37; rear fingers retain the frame.
for yy in [-12.4,17.2]:base=fuse(base,block(-38,yy,17.5,5.85,1.2,5))
for yy in [-11.4,16.2]:base=fuse(base,block(-32.35,yy,17.5,.75,1.2,5))
base=fuse(base,block(-38,0,5,6.4,6,1.3))
sensor=transform(native('sen62-assembly-pose.step'))
# Compact low base: a 2mm frame under the unchanged sensor pose.
carrier=extrude(rounded(-29,-22.3,29,1.5,1),5.4,2)
carrier=cut(carrier,block(-25,-18.3,5.3,50,15.8,2.3))
# Low side guides and front corner stops allow insertion from the rear.
for x in [-28.85,27.95]:
 carrier=fuse(carrier,block(x,-16,7.3,.9,7,2.3))
 carrier=fuse(carrier,block(x,-22.3,7.3,.9,.5,2.3))
for x in [-18,18]:
 carrier=fuse(carrier,block(x-2.5,-.5,5.4,5,6.9,2))
 carrier=cut(carrier,cylinder(x,4,5.2,1.2,4))
 # Flush feet let the sensor slide over them before the screws go in.
 base=fuse(base,cylinder(x,4,5,3.1,.4))
 nut=Polygon([(x+2.1/np.cos(np.pi/6)*np.cos(a),4+2.1/np.cos(np.pi/6)*np.sin(a)) for a in np.linspace(0,2*np.pi,7)[:-1]])
 # Standard M2 hex nut pocket; floor remains closed with 0.6mm backing.
 base=cut(base,extrude(nut,3.6,1.9))
# Stock nylon ties pass under the base in open-bottom channels.
strap_x=[-1.7,7.1]
for x in strap_x:
 carrier=cut(carrier,block(x-1.4,-23,5.3,2.8,25.3,1.55))
 carrier=cut(carrier,block(x-1.4,.05,5.3,2.8,1.3,4.5))
save(carrier,'carrier-rigid',[75,177,184,255])
# Short upright clips rise directly from the base; no corner return arms.
# Existing sensor recesses remain unchanged. Material/tolerance tests required.
def xz_prism(profile,y,width):
 shape=extrude(profile,0,width)
 t=gp_Trsf();t.SetValues(1,0,0,0,0,0,-1,y+width,0,1,0,0)
 return BRepBuilderAPI_Transform(shape,t,True).Shape()
for sign in [-1,1]:
 def mirrored(coords):return Polygon([(sign*x,z) for x,z in coords])
 # Broad tapered foot and rounded concave root; upper web remains flexible.
 stem=mirrored([(27.9,7.3),(29.1,7.3),(29.1,7.7),(28.6,8.5),
                (28.6,14.1),(28,14.1),(28,8.5),(27.9,8)])
 stem=stem.buffer(.3,quad_segs=12).buffer(-.3,quad_segs=12)
 carrier=fuse(carrier,xz_prism(stem,-9.4,2.6))
 # Existing shallow recess: 0.3mm engagement with sloped downward lead-in.
 hook=mirrored([(28.15,12.5),(27.3,12.5),(27.3,13.3),
                (28,14.1),(28.15,14.1)])
 nose=xz_prism(hook,-8.5,1.3)
 plan=Polygon([(sign*x,y) for x,y in [(28.2,-8.5),(27.3,-8.5),
              (27.3,-7.7),(28,-7.2),(28.2,-7.2)]])
 nose=BRepAlgoAPI_Common(nose,extrude(plan,12.4,1.8)).Shape()
 carrier=fuse(carrier,nose)
 # Small outward release pad at the tip, integral with the upright web.
 carrier=fuse(carrier,block(28.5 if sign>0 else -29.1,-8.9,13.3,.6,1.6,.8))
source=trimesh.load(ROOT/'mechanical/sen62-carrier.stl');section=source.section(plane_origin=[0,0,-28.3],plane_normal=[0,0,1]);ports=[]
for loop in section.discrete:
 p=Polygon(loop[:,:2]);a,b,c,d=p.bounds
 if p.area>50 and a>-13 and c<13:ports.append(p)
assert len(ports)==3
seals=trimesh.Scene();areas=[]
for p in ports:
 base=cut(base,air(p,-29.5,8));ring=p.buffer(1.0).difference(p)
 # Integral short collars carry the seals; the holder has no tall duct wall.
 base=fuse(base,air(ring,-26,2.5))
 # Only the rear foam ring is physical: the former front ring overlapped
 # the integral collar and created coincident faces in the 3D viewer.
 for y,h in [(-23.5,2)]:seals.add_geometry(mesh(air(ring,y,h),[92,101,110,255]))
 areas.append({'port':'outlet' if p.centroid.y>0 else 'inlet','area_mm2':p.area})
seals.export(OUT/'models/gaskets.glb')
# Narrow adjustable nylon straps, front paths kept outside the sealed apertures.
straps=trimesh.Scene()
for x in strap_x:
 # Flat 2.5mm-wide, 1.1mm-thick nylon tie envelope; apertures stay clear.
 for part in [block(x-1.25,-23.5,5.55,2.5,24.75,1.1),
              block(x-1.25,-23.5,5.55,2.5,1.1,28.75),
              block(x-1.25,-23.5,33.2,2.5,24.75,1.1),
              block(x-1.25,.15,5.55,2.5,1.1,28.75)]:
  straps.add_geometry(mesh(part,[220,218,203,255]))
straps.export(OUT/'models/ties.glb')
feet=trimesh.Scene()
for x,y in [(-32,-20),(32,-20),(-32,20),(32,20)]:feet.add_geometry(mesh(cylinder(x,y,0,4,3),[25,27,29,255]))
feet.export(OUT/'models/feet.glb')
# Floating snap-retained cap rests on the 7.5 mm switch actuator.
# Lid/cap separate assets; nominal 0.60 mm head/lid gap allows 0.25 mm travel.
lid=cut(lid,cylinder(-.7+PCB_X_SHIFT_MM,12,44,.7,4));lid=cut(lid,cylinder(8+PCB_X_SHIFT_MM,0,44,3.1,4));lid=cut(lid,cylinder(-14+PCB_X_SHIFT_MM,18.5,44,3.0734/2,4))
bx=8+PCB_X_SHIFT_MM
button=fuse(cylinder(bx,0,44,2.8,3.6),cylinder(bx,0,47.6,4,.8))
button=fuse(button,cylinder(bx,0,44,3.4,.6))
button=cut(button,cylinder(bx,0,43.9,2.1,2.2))
# Three compliant snap fingers; cavity ceiling contacts actuator at z=46.1.
for angle in [0,120,240]:
 slot=block(bx,-.3,43.9,4,.6,2.2)
 t=gp_Trsf();t.SetRotation(gp_Ax1(gp_Pnt(bx,0,0),gp_Dir(0,0,1)),np.deg2rad(angle))
 button=cut(button,BRepBuilderAPI_Transform(slot,t,True).Shape())
# No printed light pipe. Leave the manufacturer's panel hole for PLP1-200.
for shape,name,color in [(base,'case-base',[37,43,47,255]),(lid,'case-lid',[46,52,56,255]),(carrier,'carrier',[30,158,167,255]),(sensor,'sensor',[175,183,187,255]),(button,'ask-button',[30,158,167,255])]:save(shape,name,color)
# Prefer the preserved native-viewer PCB, including its original materials.
native_pcb=ROOT/'mechanical/horizontal/reference/pcb-native-world.glb'
if native_pcb.exists():
 import shutil
 shutil.copyfile(native_pcb,OUT/'models/pcb.glb')
else:
 # Correct the exporter’s documented X-mirror and Y/Z exchange, then rotate PCB.
 pcb=trimesh.load(str(Path(os.environ.get('MUSEAIR_IMPORTED_PCB_GLB',str(ROOT/'mechanical/horizontal/reference/pcb-imported-display.glb')))),force='scene')
 # U3 is supplied separately as the sensor assembly asset, not duplicated on PCB.
 for node in list(pcb.graph.nodes_geometry):
  if node.startswith('U3_'):pcb.delete_geometry(pcb.graph[node][1])
 restore=np.array([[-1,0,0,0],[0,0,1,0],[0,1,0,0],[0,0,0,1]],float)
 pose=np.array([[0,-1,0,PCB_X_SHIFT_MM],[1,0,0,0],[0,0,1,37.8],[0,0,0,1]],float)
 pcb.apply_transform(pose@restore)
 # Exporter flattens custom GLB materials; keep the exact corrected ASK asset.
 for node in list(pcb.graph.nodes_geometry):
  if node.split('_')[0]=='SW1':pcb.delete_geometry(pcb.graph[node][1])
 ask=trimesh.load(ROOT/'models/AskSwitch75mm.glb',force='scene')
 seat=np.eye(4);seat[:3,3]=[0,-8,.8]
 for node in ask.graph.nodes_geometry:
  tf,g=ask.graph[node];m=ask.geometry[g].copy();m.apply_transform(pose@seat@tf)
  pcb.add_geometry(m,node_name='SW1_'+node)
 pcb.export(OUT/'models/pcb.glb')
# Internal M2 fasteners, insulating washers, captive insert envelopes.
hardware=trimesh.Scene();pcb_hardware=trimesh.Scene();holder_hardware=trimesh.Scene();lid_hardware=trimesh.Scene();mounting_inserts=trimesh.Scene()
def screw_head(x,y,z):
 s=cylinder(x,y,z,1.9,1.6)
 s=cut(s,block(x-1.3,y-.3,z+1.05,2.6,.6,.65))
 return cut(s,block(x-.3,y-1.3,z+1.05,.6,2.6,.65))
def add(part,shape,color):
 part.add_geometry(mesh(shape,color));hardware.add_geometry(mesh(shape,color))
def insert(x,y,z,h):return cut(cylinder(x,y,z,1.6,h),cylinder(x,y,z-.1,.85,h+.2))
for x,y in mounts:
 # M2 x 5: under-head z=39.1, tip z=34.1, insert z=34..37.
 add(pcb_hardware,cylinder(x,y,34.1,1,5),[195,199,203,255])
 add(pcb_hardware,screw_head(x,y,39.1),[195,199,203,255])
 add(pcb_hardware,cut(cylinder(x,y,38.6,2,.5),cylinder(x,y,38.5,1.1,.7)),[220,219,204,255])
 add(mounting_inserts,insert(x,y,34,3),[184,139,65,255])
for x,y in closure:
 add(lid_hardware,cylinder(x,y,37.4,1,8),[195,199,203,255])
 add(lid_hardware,screw_head(x,y,45.4),[195,199,203,255])
for x in [-18,18]:
 # M2x4 + 0.5mm washer through 2mm foot into standard 1.6mm M2 nut.
 add(holder_hardware,cylinder(x,4,3.9,1,4),[195,199,203,255])
 add(holder_hardware,screw_head(x,4,7.9),[195,199,203,255])
 add(holder_hardware,cut(cylinder(x,4,7.4,2,.5),cylinder(x,4,7.3,1.1,.7)),[220,219,204,255])
 hexagon=Polygon([(x+2/np.cos(np.pi/6)*np.cos(a),4+2/np.cos(np.pi/6)*np.sin(a)) for a in np.linspace(0,2*np.pi,7)[:-1]])
 add(mounting_inserts,cut(extrude(hexagon,3.7,1.6),cylinder(x,4,3.6,.85,1.9)),[184,139,65,255])
mounting_inserts.export(OUT/'models/mounting-inserts.glb')
hardware.export(OUT/'models/hardware.glb')
pcb_hardware.export(OUT/'models/pcb-hardware.glb')
holder_hardware.export(OUT/'models/holder-hardware.glb')
lid_hardware.export(OUT/'models/enclosure-hardware.glb')
collision=volume(BRepAlgoAPI_Common(sensor,carrier).Shape());assert collision<.01,collision
assert all(p['area_mm2']>=(148 if p['port']=='outlet' else 56) for p in areas)
audit={'body_mm':[80,56,44],'desk_height_mm':47,'sensor_to_pcb_gap_mm':4,'sensor_carrier_intersection_mm3':collision,'ports':areas,'pcb_mounts_xy':mounts,'source_pcb_modified':False,'revision':'prototype upright snap clips with broad rounded roots','pcb_mounting':'four 3.4mm-thick side-wall bridges with paired triangular gussets; front gussets stay outside sensor body','holder_mounting':'58x28.7mm low frame; 58.2mm including snap release tabs; two bottom screws; side clips flex during sensor slide; physical retention untested','rf_keepout_physical_validation_required':True,'physical_fit_verified':False,'strap_paths':'prototype adjustable straps; retention and sealing require prototype checks','component_models':'65 imported PCB component CAD models; canonical exporter mirror corrected','sensor_pose':{'rotation':R.tolist(),'translation':[-6,3.8,20.2]}}
(OUT/'fit-audit.json').write_text(json.dumps(audit,indent=2));print(json.dumps(audit,indent=2))