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/make-sen62-mount.py

"""Derived SEN6x prototype retainer, using official STEP geometry.
Requires cadquery-ocp 8.0.1.0.0, numpy and matplotlib. No sensor hole is invented.
All delivered assembly files use PCB top-view XY, PCB underside Z=0.
"""
from pathlib import Path
import json,hashlib,struct,base64
from OCP.STEPControl import STEPControl_Reader,STEPControl_Writer,STEPControl_AsIs
from OCP.gp import gp_Pnt,gp_Trsf,gp_Ax2,gp_Dir
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox,BRepPrimAPI_MakeCylinder
from OCP.BRepAlgoAPI import BRepAlgoAPI_Common,BRepAlgoAPI_Fuse,BRepAlgoAPI_Cut
from OCP.BRepCheck import BRepCheck_Analyzer
from OCP.BRepGProp import BRepGProp
from OCP.GProp import GProp_GProps
from OCP.Bnd import Bnd_Box
from OCP.BRepBndLib import BRepBndLib
from OCP.BRepMesh import BRepMesh_IncrementalMesh
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_FACE,TopAbs_REVERSED,TopAbs_SOLID
from OCP.TopoDS import TopoDS
from OCP.TopLoc import TopLoc_Location
from OCP.BRep import BRep_Tool
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeVertex
from OCP.BRepExtrema import BRepExtrema_DistShapeShape
from OCP.BRep import BRep_Builder
from OCP.TopoDS import TopoDS_Compound
import numpy as np
root=Path(__file__).resolve().parent.parent;out=root/'mechanical'
gap=4.0
def read(fn):
 r=STEPControl_Reader();r.ReadFile(str(out/fn));r.TransferRoots();return r.OneShape()
def box(x,y,z,w,d,h):return BRepPrimAPI_MakeBox(gp_Pnt(x,y,z),w,d,h).Shape()
def move(s,values):
 t=gp_Trsf();t.SetValues(*values);return BRepBuilderAPI_Transform(s,t,True).Shape()
def volume(s):
 g=GProp_GProps();BRepGProp.VolumeProperties_s(s,g);return abs(g.Mass())
def bounds(s):
 b=Bnd_Box();BRepBndLib.AddOptimal_s(s,b,False,False);return [list(b.CornerMin().Coord()),list(b.CornerMax().Coord())]
def triangles(s):
 BRepMesh_IncrementalMesh(s,.15,False,.25,True).Perform();ex=TopExp_Explorer(s,TopAbs_FACE);ts=[]
 while ex.More():
  f=TopoDS.Face(ex.Current());loc=TopLoc_Location();t=BRep_Tool.Triangulation_s(f,loc)
  if t:
   for i in range(1,t.NbTriangles()+1):
    idx=list(t.Triangle(i).Get())
    if f.Orientation()==TopAbs_REVERSED:idx.reverse()
    ts.append([t.Node(n).Transformed(loc.Transformation()).Coord() for n in idx])
  ex.Next()
 return np.asarray(ts)
def write_step(s,name):
 w=STEPControl_Writer();w.Transfer(s,STEPControl_AsIs);w.Write(str(out/(name+'.step')))
def save(s,name):
 write_step(s,name)
 ts=triangles(s)
 with (out/(name+'.stl')).open('wb') as h:
  h.write(b'MuseAir v0.5, mm, PCB underside z=0'.ljust(80,b' '));h.write(struct.pack('<I',len(ts)))
  for tri in ts:
   a,b,c=tri;n=np.cross(b-a,c-a);l=np.linalg.norm(n);n=n/l if l else n
   h.write(struct.pack('<12fH',*n,*a,*b,*c,0))
 # Portable OBJ for external assembly CAD viewers, millimetres.
 with (out/(name+'.obj')).open('w') as h:
  h.write('# Sensirion geometry / MuseAir carrier; see MODEL-README.txt\n')
  for t in ts:
   for v in t:h.write('v '+' '.join(f'{q:.6f}' for q in v)+'\n')
  for i in range(len(ts)):h.write(f'f {3*i+1} {3*i+2} {3*i+3}\n')
 return ts
ret=read('SEN6x_Example_Retainer.STEP')
# Trim the unused long-end flange. All three air apertures remain; the
# free-state snap toes are relieved below for guided tie retention.
carrier=BRepAlgoAPI_Common(ret,box(-5,-10,-40,65.5,45.9,50)).Shape()
holes=[]
for x in [9.75,52.75]:
 for y in [-5.05,30.95]:
  pole=BRepPrimAPI_MakeCylinder(gp_Ax2(gp_Pnt(x,y,-26.3),gp_Dir(0,0,1)),2.8,29.3).Shape()
  carrier=BRepAlgoAPI_Fuse(carrier,pole).Shape()
  bore=BRepPrimAPI_MakeCylinder(gp_Ax2(gp_Pnt(x,y,-27),gp_Dir(0,0,1)),1.2,31).Shape()
  carrier=BRepAlgoAPI_Cut(carrier,bore).Shape()
  holes.append([round(y-12.95,6),round(x-33.75,6)])
sensor=move(read('PS_CD_SEN6x_D1.STEP'),[1,0,0,27.75,0,0,-1,12.95,0,1,0,-22.3])
# Vendor retainer frame -> PCB assembly frame. Proper rigid rotation (det +1).
frame=[0,1,0,-12.95,1,0,0,-33.75,0,0,-1,-26.3]
carrier=move(carrier,frame);sensor=move(sensor,frame)
# The vendor example is a free-state flex clip. For this printed carrier use
# a non-interfering guide cavity plus two nylon ties as the primary retention.
# Relief covers the maximum positive datasheet tolerances, with 0.1 mm extra
# lateral gap each side: L=55.8, W=25.9, H=21.8, referenced at closed face.
free_state_interference=volume(BRepAlgoAPI_Common(carrier,sensor).Shape())
max_envelope=box(-12.95,-33.9,-25.8,25.9,55.8,21.8)
carrier=BRepAlgoAPI_Cut(carrier,max_envelope).Shape()
for x in [-17,17]:
 for y in [-18,-6]:
  carrier=BRepAlgoAPI_Cut(carrier,box(x-1.5,y-1.6,-30,3,3.2,5)).Shape()
assert BRepCheck_Analyzer(carrier).IsValid()
ex=TopExp_Explorer(carrier,TopAbs_SOLID);nsolid=0
while ex.More():nsolid+=1;ex.Next()
assert nsolid==1,nsolid
assert set(map(tuple,holes))=={(-18.,-24.),(-18.,19.),(18.,-24.),(18.,19.)}
collision=volume(BRepAlgoAPI_Common(carrier,sensor).Shape())
max_collision=volume(BRepAlgoAPI_Common(carrier,max_envelope).Shape())
assert collision<1e-6 and max_collision<1e-6,(collision,max_collision)
tie_checks=[];tie_shapes=[]
for y in [-18,-6]:
 pieces=[box(-17,y-1.25,-3.65,34,2.5,1.1),box(-17,y-1.25,-30.4,34,2.5,1.1)]
 for x in [-17,17]:pieces.append(box(x-.55,y-1.25,-30.4,1.1,2.5,27.85))
 cv=sum(volume(BRepAlgoAPI_Common(carrier,t).Shape()) for t in pieces)
 sv=sum(volume(BRepAlgoAPI_Common(sensor,t).Shape()) for t in pieces)
 assert cv<1e-6 and sv<1e-6,(y,cv,sv)
 tie_shapes.extend(pieces)
 tie_checks.append({'center_y_mm':y,'carrier_intersection_mm3':cv,'sensor_intersection_mm3':sv,'straight_loop_perimeter_mm':123.5})
sensor_ts=save(sensor,'sen62-assembly-pose');carrier_ts=save(carrier,'sen62-carrier')
np.save(out/'sensor-triangles.npy',sensor_ts);np.save(out/'carrier-triangles.npy',carrier_ts)
# Embedded GLB replaces the rectangular preview with the official sensor
# mesh and its real carrier. tscircuit flips bottom models about Y.
def glb(meshes):
 payload=bytearray();views=[];accessors=[];primitives=[]
 def append(data,target):
  while len(payload)%4:payload.append(0)
  start=len(payload);payload.extend(data);views.append({'buffer':0,'byteOffset':start,'byteLength':len(data),'target':target});return len(views)-1
 for name,ts,material in meshes:
  own=ts.copy();own[:,:,0]*=-1;own[:,:,1]+=6;own[:,:,2]*=-1
  points,inv=np.unique(np.round(own.reshape(-1,3),6),axis=0,return_inverse=True)
  points=points.astype('<f4');faces=inv.reshape(-1,3).astype('<u4')
  normals=np.zeros_like(points)
  face_normals=np.cross(points[faces[:,1]]-points[faces[:,0]],points[faces[:,2]]-points[faces[:,0]])
  for i in range(3):np.add.at(normals,faces[:,i],face_normals)
  lengths=np.linalg.norm(normals,axis=1);normals/=np.maximum(lengths[:,None],1e-12)
  pa=len(accessors);pv=append(points.tobytes(),34962);accessors.append({'bufferView':pv,'componentType':5126,'count':len(points),'type':'VEC3','min':points.min(0).tolist(),'max':points.max(0).tolist()})
  na=len(accessors);nv=append(normals.astype('<f4').tobytes(),34962);accessors.append({'bufferView':nv,'componentType':5126,'count':len(points),'type':'VEC3'})
  ia=len(accessors);iv=append(faces.tobytes(),34963);accessors.append({'bufferView':iv,'componentType':5125,'count':faces.size,'type':'SCALAR'})
  primitives.append({'attributes':{'POSITION':pa,'NORMAL':na},'indices':ia,'material':material})
 doc={'asset':{'version':'2.0','generator':'MuseAir manufacturer-CAD mesh, millimetres'},'scene':0,'scenes':[{'nodes':[0]}],'nodes':[{'name':'SEN62 + 4 mm carrier + nylon tie paths','mesh':0}],
  'meshes':[{'primitives':primitives}],'materials':[
   {'name':'Sensor housing (illustrative colour)','pbrMetallicRoughness':{'baseColorFactor':[.76,.78,.79,1],'metallicFactor':0,'roughnessFactor':.65},'doubleSided':True},
   {'name':'Insulating carrier','pbrMetallicRoughness':{'baseColorFactor':[.04,.37,.42,1],'metallicFactor':0,'roughnessFactor':.8},'doubleSided':True},
   {'name':'Nylon tie path (buckle omitted)','pbrMetallicRoughness':{'baseColorFactor':[.09,.11,.13,1],'metallicFactor':0,'roughnessFactor':.8},'doubleSided':True}],
  'buffers':[{'byteLength':len(payload)}],'bufferViews':views,'accessors':accessors}
 text=json.dumps(doc,separators=(',',':')).encode();text+=b' '*((-len(text))%4);payload.extend(b'\0'*((-len(payload))%4))
 return struct.pack('<III',0x46546c67,2,12+8+len(text)+8+len(payload))+struct.pack('<II',len(text),0x4e4f534a)+text+struct.pack('<II',len(payload),0x004e4942)+payload
tie_ts=np.concatenate([triangles(t) for t in tie_shapes])
model=glb([('sensor',sensor_ts,0),('carrier',carrier_ts,1),('ties',tie_ts,2)])
(out/'sen62-assembly.glb').write_bytes(model)
(root/'models').mkdir(exist_ok=True)
(root/'models/SEN62AssemblyModel.ts').write_text('// Generated from official Sensirion CAD; see mechanical/MODEL-README.txt.\nexport const sen62AssemblyGlb = "data:model/gltf-binary;base64,'+base64.b64encode(model).decode()+'"\n')
# Native KiCad bottom models flip about X, so they use a different local
# frame from tscircuit. Both reconstruct exactly the same assembly pose.
compound=TopoDS_Compound();builder=BRep_Builder();builder.MakeCompound(compound)
for shape in [sensor,carrier,*tie_shapes]:
 builder.Add(compound,move(shape,[1,0,0,0,0,-1,0,-6,0,0,-1,0]))
write_step(compound,'sen62-assembly-native')
pcb_box=box(-23,-35,0,46,70,1.6)
assert volume(BRepAlgoAPI_Common(sensor,pcb_box).Shape())<1e-6
assert volume(BRepAlgoAPI_Common(carrier,pcb_box).Shape())<1e-6
assert abs(bounds(sensor)[1][2]+gap)<1e-5
assert abs(bounds(carrier)[1][2])<1e-5
report={'status':'pass','carrier_brep_valid':True,'carrier_solids':nsolid,
 'sensor_source':'Sensirion PS_CD_SEN6x_D1.STEP, September 2025',
 'retainer_source':'Sensirion SEN6x_Example_Retainer.STEP, May 2025',
 'pcb_underside_z_mm':0,'sensor_bounds_mm':bounds(sensor),'carrier_bounds_mm':bounds(carrier),
 'pcb_top_view_holes_mm':sorted(holes),'pcb_hole_diameter_mm':2.4,
 'post_outside_diameter_mm':5.6,'minimum_nominal_sensor_to_pcb_gap_mm':gap,
 'pcb_side_screws':'4 x nylon M2 x 35, nuts and insulating washers; no screw enters sensor',
 'carrier_sensor_intersection_mm3':collision,'maximum_body_envelope_intersection_mm3':max_collision,
 'vendor_free_state_clip_interference_mm3':free_state_interference,
 'retention':'2 x nylon ties through four 3 x 3.2 mm slots, centered at Y=-18 and -6; guided cradle with clearance relief',
 'tie_size_mm':[2.5,1.1,203],'tie_part_example':'Panduit PLT2M-C or equivalent nylon 2.5 x 1.1 mm, >=150 mm','tie_path_checks':tie_checks,
 'nominal_front_gap_mm':2,'nominal_base_thickness_mm':2,'sealing_foam':'Vendor 0.9 mm pattern provided for an enclosure; compression/gap must be adjusted and tested for enclosure design',
 'retainer_mating_geometry_retained':False,'retainer_guide_geometry_modified_for_clearance':True,'air_apertures_retained':True,
 'carrier_design_status':'prototype retainer; print/material compliance and sealing must be verified on real sensor',
 'physical_fit_tested':False,'firmware_tested':False,
 'embedded_preview':'Official sensor tessellation + revised carrier + illustrative nylon tie paths; GLB millimetres, rear housing face 4 mm below PCB',
 'manufacturer_datasheet_note':'SEN6x v0.92 December 2025 is preliminary',
 'sha256':{f.name:hashlib.sha256(f.read_bytes()).hexdigest() for f in out.iterdir() if f.is_file() and f.name!='model-validation.json'}}
(out/'model-validation.json').write_text(json.dumps(report,indent=2)+'\n')
print(json.dumps({k:report[k] for k in ['status','carrier_solids','sensor_bounds_mm','carrier_bounds_mm','carrier_sensor_intersection_mm3','pcb_top_view_holes_mm']},indent=2),flush=True)