rushabhcodes/desk-fidget-counter

A desk knob assembly combining a circuit board with PETG-printed mechanical parts and vendor CAD-modeled hardware.

Version
5.6.1
License
MIT
Stars
0

mechanical/generate.py

#!/usr/bin/env python3
"""SPDX-License-Identifier: MIT. Mechanics E5 simplified assembly and printable solids.
All dimensions are millimetres, +Z up. No Z scaling of purchased parts.
"""
from pathlib import Path
import json, math
import manifold3d as m
import numpy as np
import trimesh
ROOT=Path(__file__).resolve().parent
P=json.loads((ROOT/'parameters.json').read_text())
TAU=2*math.pi

def cyl(r,h,z=0,x=0,y=0):return m.Manifold.cylinder(h,r,r,192).translate((x,y,z))
def cyl_y(r,start,end,z):return m.Manifold.cylinder(end-start,r,r,96).rotate((-90,0,0)).translate((0,start,z))
def box(size,center):return m.Manifold.cube(size,True).translate(center)
def ring(ro,ri,h,z=0):return cyl(ro,h,z)-cyl(ri,h+.2,z-.1)
def csk_hole(x,y,z,up=False):
 if up:return cyl(2.1,.45,z-.1,x,y)+m.Manifold.cylinder(.9,2.0,1.1,64).translate((x,y,z+.3))
 return cyl(2.1,.45,z-.35,x,y)+m.Manifold.cylinder(.9,1.1,2.0,64).translate((x,y,z-1.2))
def cut_all(s,holes):
 for h in holes:s-=h
 return s
def polar(r,a):return r*math.cos(a),r*math.sin(a)
def located(s,x,y,angle=0):return s.rotate((0,0,angle)).translate((x,y,0))
def radial_holes(s,r,z,h,d,offset=0):
 return cut_all(s,[cyl(d/2,h,z,*polar(r,offset+i*TAU/3)) for i in range(3)])

top=P['pcb_bottom_z']+P['pcb_thickness'];bearing=P['bearing'];bt=bearing['bottom_z'];bh=bearing['width'];grip_z=P['knob_bottom_z'];r=P['encoder']['track_radius'];half_track=P['encoder']['track_thickness']/2
mount=P['bearing_mount'];capture=P['pcb_capture'];capture_z=top+capture['compressed_pad_height']
lid_screws=P['chassis_screw_axes']
outer=P['base_diameter']/2
usb_access=P['usb_access']
base=cyl(outer,2)+ring(outer-.2,outer-2.2,7.2,2)
for x,y in P['mount_holes']:
 # Unplated locator pegs and lid pads capture the board; no tall PCB standoffs.
 base+=cyl(2.3,.6,2,x,y)+cyl(capture['peg_diameter']/2,2.2,2,x,y)
for x,y in lid_screws:
 base+=cyl(2.2,7.2,2,x,y);base-=cyl(.8,7.3,2,x,y)
# Individual solder wells retain 1.2mm floor beneath trimmed through-hole tails.
cj=json.loads((ROOT.parent/'dist/index/circuit.json').read_text())
assert all(c['layer']=='top' for c in cj if c['type']=='pcb_component'),'Every component must assemble from above'
for h in [c for c in cj if c['type']=='pcb_plated_hole']:
 x,y=h.get('x',0),h.get('y',0)
 w=max(h.get('outer_diameter',0),h.get('rect_pad_width',0),h.get('outer_width',0),1.2)+.6
 d=max(h.get('outer_diameter',0),h.get('rect_pad_height',0),h.get('outer_height',0),1.2)+.6
 base-=box((w,d,1.0),(x,y,1.7))
usb=box((usb_access['opening_width_mm'],usb_access['opening_length_mm'],usb_access['opening_top_z']-usb_access['opening_bottom_z']),
 (0,usb_access['opening_center_y'],(usb_access['opening_top_z']+usb_access['opening_bottom_z'])/2))
base-=usb
# The switch sits on an integral front PCB tab behind a captive printed cap.
button=P['front_button'];button_z=button['axis_z']
button_opening=box((button['opening_width_mm'],16,button['opening_top_z']-button['opening_bottom_z']),
 (0,-42,(button['opening_top_z']+button['opening_bottom_z'])/2))
base-=button_opening
# Recess only the actual PCB nose, retaining the outer wall wherever there is
# room. The 0.25mm side allowance clears the FR-4 without a large front opening.
board_outline=next(c['outline'] for c in cj if c['type']=='pcb_board')
front_pocket=m.CrossSection([[(q['x'],q['y']) for q in board_outline]]).offset(.25).extrude(1.8).translate((0,0,2.6))
base-=(front_pocket^box((24,16,3),(0,-40,3.5)))
# Open the narrow FR-4 band cleanly instead of retaining sub-nozzle wall slivers.
base-=box((11,12,1.8),(0,-42.5,3.5))
# Support the switch tab directly underneath the solder-free PCB underside.
base+=box((9,6,.6),(0,-38.6,2.3))
from button import build as build_button
button_surround,button_roof,button_cap=build_button(P)
base+=button_surround-front_pocket

lid=ring(outer-.35,27.3,1.2,9.2)
# The large hollow bearing post surrounds, rather than stacks over, the screen.
lid+=ring(mount['inner_contact_od']/2,27.3,bt-10.39,10.39)+ring(P['bearing_stem_od']/2,27.3,bh,bt)
for x,y in P['mount_holes']:lid+=cyl(2.0,P['lid_bottom_z']-capture_z,capture_z,x,y)
for x in [-6,6]:lid+=cyl(1.3,P['lid_bottom_z']-capture_z,capture_z,x,-37)
for x,y in lid_screws:
 lid-=cyl(1.1,2,9,x,y);lid-=csk_hole(x,y,10.4)
# Entry chamfer on the nominal 60mm push-fit post; no separate bearing clamp.
lid-=ring(30.2,29.7,.6,18.4)-m.Manifold.cylinder(.6,30,29.7,128).translate((0,0,18.4))
# The flush port keeps the plug body outside the lid; retain its full roof.
lid-=located(box((7.2,5.2,4),(0,0,8.6)),-23,10,P['encoder']['rotation_deg'])
lid+=button_roof


# The outer race seats against the grip shoulder; the optical skirt joins
# the upper web without crossing the stationary inner-race support.
cap=ring(42.4,P['bearing_pocket_id']/2,19-grip_z,grip_z)
cap+=ring(42.4,mount['outer_contact_id']/2,1.7,19)
cap+=ring(42.4,24.8,1.4,20.7)
for i in range(96):
 x,y=polar(42.45,i*TAU/96);cap-=cyl(.4,12,10.5,x,y)
# The optical track is printed with the grip, removing its adhesive joint.
encoder_skirt=ring(r+half_track,r-half_track,12.9,7.9)+ring(P['encoder']['bond_outer_radius'],r-half_track,1.4,19.44)
for i in range(48):encoder_skirt-=box((3,1.64,1.8),(r,0,8.6)).rotate((0,0,i*360/48))
cap+=encoder_skirt
# Lead-in on the open underside of the 75mm bearing pocket.
cap-=m.Manifold.cylinder(.6,37.8,37.5,128).translate((0,0,grip_z))-cyl(37.4,.8,grip_z-.1)
# Two base posts share the bezel/carrier screws and stay inside the PCB window.
screen_posts=P['screen_posts']
for x,y in screen_posts:
 base+=cyl(2.0,14.1,2,x,y);base-=cyl(.8,5.2,11,x,y)
cradle=ring(24,18.2,1.2,16.1)
for x,y in screen_posts:
 # Short radial ledges reach each pillar without loading rear LCD components.
 a=math.atan2(y,x);rr=math.hypot(x,y)
 cradle+=cyl(2.3,1.2,16.1,x,y)
 cradle-=cyl(1.1,1.4,16,x,y)
cradle-=box((5,16,1.4),(-20,0,16.7))
bezel_screws=P['bezel_screw_axes']
for x,y in bezel_screws:
 cradle+=cyl(2.2,3.6,17.3,x,y);cradle-=cyl(1.1,5,16,x,y)
# Keep both shared support pads inside the 48mm carrier envelope.
cradle=cradle^cyl(24,5,16)
bezel=ring(24,16.6,1.4,20.9)
# The retaining lip clears the active glass even with estimated stack errors.
relief=P['display']['glass_relief_mm']+.01
bezel-=cyl(19,relief,20.89)+box((5,15,relief),(-18.75,0,20.89+relief/2))
for x,y in bezel_screws:
 bezel-=cyl(1.1,1.6,20.8,x,y)
 bezel-=csk_hole(x,y,22.3)
parts={'base':base,'lid':lid,'knob':cap,'screen-carrier':cradle,'screen-bezel':bezel,'button-cap':button_cap}
for obsolete in ['outer-retainer','inner-retainer','bearing-clamp','encoder-skirt']:
 (ROOT/'stl'/f'{obsolete}.stl').unlink(missing_ok=True)
# Print alternatives individually on a small bed. These are calibration samples,
# never assembled product parts and never a substitute for measured race faces.
coupons={}
for name,pocket_delta,stem_delta in [('fit-coupon-tight',-.1,.1),('fit-coupon',0,0),('fit-coupon-loose',.2,-.2)]:
 pocket=P['bearing_pocket_id']+pocket_delta;stem=P['bearing_stem_od']+stem_delta
 parts[name]=ring(40,pocket/2,7)+ring(stem/2,27.3,7).translate((74,0,0))
 coupons[name]={'pocket_id_mm':round(pocket,3),'stem_od_mm':round(stem,3),'height_mm':7,'pocket_center_mm':[0,0],'stem_center_mm':[74,0]}
(ROOT/'fit-coupons.json').write_text(json.dumps(coupons,indent=2)+'\n')
(ROOT/'stl').mkdir(exist_ok=True);report={};poses={}
for name,s in parts.items():
 invert=name in ['knob','screen-bezel'];z=s.bounding_box()[5] if invert else s.bounding_box()[2]
 printed=s.rotate((180,0,0)).translate((0,0,z)) if invert else s.translate((0,0,-z))
 a=printed.to_mesh64();mesh=trimesh.Trimesh(np.asarray(a.vert_properties)[:,:3],np.asarray(a.tri_verts),process=True)
 assert mesh.is_watertight and mesh.volume>0,(name,'invalid solid')
 assert len(printed.decompose())==(2 if name.startswith('fit-coupon') else 1),(name,'disconnected print')
 mesh.export(ROOT/'stl'/f'{name}.stl');poses[name]={'inverted':invert,'z':z}
 report[name]={'watertight':True,'volume_mm3':round(mesh.volume,2),'bounds_mm':mesh.bounds.round(3).tolist()}
 print(name,'watertight',round(mesh.volume,1),flush=True)
(ROOT/'print-poses.json').write_text(json.dumps(poses,indent=2)+'\n')
# Use a full solid ring in place of slots to cover every possible rotor angle.
swept_skirt=ring(r+half_track,r-half_track,12.9,7.9)
moving=[('grip with integral optical track',cap),('swept skirt',swept_skirt)]
for name,fixed in [('base',base),('lid',lid),('cradle',cradle),('bezel',bezel)]:
 for mn,mov in moving:assert (mov^fixed).volume()<1e-4,(name,mn,'collision',(mov^fixed).volume())
# Manufacturer screen STEP transformed rigidly: tab -X, rear connector +X.
def from_mesh(mesh):
 q=m.Manifold(m.Mesh64(np.ascontiguousarray(mesh.vertices,dtype=np.float64),np.ascontiguousarray(mesh.faces,dtype=np.uint64)))
 assert str(q.status()).endswith('NoError'),str(q.status())
 return q
screen_mesh=trimesh.load(ROOT/'vendor/obj/screen19192.obj',force='mesh',process=True)
screen_mesh.apply_transform(np.diag([-1.,1.,-1.,1.]));screen_mesh.apply_translation([0,0,17.75])
lcd=from_mesh(screen_mesh)
from harness import build as build_harness
harness_meshes,harness_report=build_harness()
(ROOT/'harness.json').write_text(json.dumps(harness_report,indent=2)+'\n')
clearance_harness,_=build_harness(wire_od=P['stock_display_cable']['wire_od_check_mm'])
for name,printed in [('base',base),('lid',lid),('cradle',cradle),('bezel',bezel)]:
 assert (printed^lcd).volume()<1e-4,(name,'manufacturer screen collision',(printed^lcd).volume())
# PCB material must not intersect either part nested through its window.
board=next(c for c in cj if c['type']=='pcb_board')
cutout=next(c for c in cj if c['type']=='pcb_cutout')
polygon=lambda points:m.CrossSection([[(v['x'],v['y']) for v in points]])
outline=polygon(board['outline']).extrude(P['pcb_thickness']).translate((0,0,P['pcb_bottom_z']))
window=polygon(cutout['points']).extrude(2).translate((0,0,2.4))
pcb=outline-window
for x,y in P['mount_holes']:pcb-=cyl(1.35,2,2.4,x,y)
assert (base^pcb).volume()<1e-4,'Base must clear actual PCB material and NPTHs'
# Both posts must clear the window even with PCB registration, print and
# regular routed-contour tolerances. Bearing/SMT errors do not apply here.
f=P['fit_budget'];capture=P['pcb_capture']
pcb_play=(2.7+f['pcb_hole_diameter_error']-(capture['peg_diameter']-f['printed_fit_diameter_error']))/2
post_window_error=pcb_play+f['printed_surface_error']+f['pcb_cutout_edge_error']
for x,y in P['screen_posts']:
 assert (cyl(2.0+post_window_error,2,2.4,x,y)^pcb).volume()<1e-4,'Screen post must clear tolerance-expanded PCB window edge'
report['screen_post_to_window_tolerance_checked_mm']=round(post_window_error,4)

assert (pcb^lcd).volume()<1e-4
# A 12mm fingertip can depress the broad cap all the way to its nominal stop.
button_design=button['printed_cap']
finger_radius=button['finger_check_radius_mm'];pressed_face=button_design['face_front_y']+button_design['nominal_max_travel_mm']
finger=m.Manifold.sphere(finger_radius,96).translate((0,pressed_face-finger_radius,button_z))
for label,part in [('base',base),('lid',lid),('PCB',pcb),*moving]:
 assert (finger^part).volume()<1e-4,(label,'direct finger access collision',(finger^part).volume())
actuator_region=box((2.0,.8,1.2),(0,button['actuator_front_y']+.35,button_z))
# Top-side component and mated cable envelopes, including the full sensor arms.
# A continuous approach prism covers the entire maximum plug body from a
# conservative shoulder plane at the socket mouth to beyond every case part.
plug_start=usb_access['plug_shoulder_y'];plug_length=usb_access['plug_check_length_mm']
plug_gauge=box((usb_access['plug_width_mm'],plug_length,usb_access['plug_height_mm']),
 (0,plug_start+plug_length/2,usb_access['axis_z']))
assert plug_start+plug_length>outer+5
for label,part in [('base',base),('lid',lid),('PCB',pcb),('LCD',lcd),*moving]:
 assert (plug_gauge^part).volume()<1e-4,(label,'USB insertion clearance',(plug_gauge^part).volume())
fixed_hardware=[('USB plug approach gauge',plug_gauge),('programming header',box((13.9,4.5,6),(P['programming_connector'][0],P['programming_connector'][1]-.55,7.2))),('LCD manufacturer model',lcd),('PCB',pcb)]
for name,mesh in clearance_harness.items():fixed_hardware.append((name,from_mesh(mesh)))
for dx in [-2.125,2.125]:
 fixed_hardware.append(('sensor arm',located(box((1.25,4,5.7),(dx,0,7.05)),-23,10,P['encoder']['rotation_deg'])))
for name,fixed in fixed_hardware:
 for mn,mov in moving:assert (mov^fixed).volume()<1e-4,(name,mn,'hardware collision',(mov^fixed).volume())
for name,fixed in fixed_hardware:
 if name in ['PCB']:continue
 assert (lid^fixed).volume()<1e-4,(name,'chassis collision',(lid^fixed).volume())
# Verify actual package-model placements against the fixed and moving prints.
# SMT lead pads and PCB surface contact are intentional; tails fit solder wells.
package_checks=0
package_bodies=[]
for cad in [v for v in cj if v['type']=='cad_component' and v.get('model_obj_url')]:
 url=cad['model_obj_url'];filename=url.split('?')[0].split('/')[-1]
 path=ROOT/'vendor/obj'/filename
 if not path.exists():raise AssertionError(('Missing real package mesh',filename))
 solid_path=ROOT/'vendor/solid'/filename.replace('.obj','.ply')
 # Preserve the button STEP's distinct coincident seam edges.
 mesh=trimesh.load(solid_path if solid_path.exists() else path,force='mesh',process=filename!='b3u3000.obj')
 origin=cad.get('model_origin_position') or {'x':0,'y':0,'z':0}
 mesh.apply_translation([-origin.get(k,0) for k in ['x','y','z']])
 rotation=cad.get('rotation',{})
 mesh.apply_transform(trimesh.transformations.euler_matrix(*[math.radians(rotation.get(k,0)) for k in ['x','y','z']]))
 pos=cad['position'];mesh.apply_translation([pos['x'],pos['y'],pos['z']+P['pcb_bottom_z']+P['pcb_thickness']/2])
 # Supplier meshes can contain independently closed housing/lead solids. Union
 # each closed shell before checking to avoid treating internal faces as voids.
 solids=[]
 for shell in mesh.split(only_watertight=False):
  if not shell.is_watertight:raise AssertionError(('Open vendor shell',filename))
  solids.append(from_mesh(shell))
 physical=m.Manifold.batch_boolean(solids,m.OpType.Add)
 package_bodies.append((filename,physical))
 for wn,wire_mesh in clearance_harness.items():
  if wn.startswith('lcd-wire-'):assert (physical^from_mesh(wire_mesh)).volume()<1e-4,(filename,wn,'wire/package collision')
 assert (physical^plug_gauge).volume()<1e-4,(filename,'USB plug approach/package collision')
 if filename=='C165948.obj':
  assert abs(mesh.bounds[1,1]-usb_access['socket_face_y'])<.001,'USB socket must use its real CAD face datum'
  front=mesh.vertices[mesh.vertices[:,1]>mesh.bounds[1,1]-.0001]
  assert abs((front[:,2].min()+front[:,2].max())/2-usb_access['axis_z'])<.001,'Plug gauge must align with the real USB CAD axis'
 for name,printed in [('base',base),('lid',lid),('cradle',cradle),('bezel',bezel)]:
  assert (physical^printed).volume()<1e-4,(filename,name,'package/print collision',(physical^printed).volume())
 for name,mov in moving:
  assert (physical^mov).volume()<1e-4,(filename,name,'package/rotor collision',(physical^mov).volume())
 # Fingers may touch only the original SW1 actuator, never other packages.
 if filename!='b3u3000.obj':assert (physical^finger).volume()<1e-4,(filename,'finger access collision')
 else:
  assert abs(mesh.bounds[0,1]-button['actuator_front_y'])<.001,'Use real actuator datum'
  assert ((physical^finger)-actuator_region).volume()<1e-4,'Finger may contact only the actuator'
 package_checks+=1
wire_solids=[]
for name,mesh in clearance_harness.items():
 if not name.startswith('lcd-wire-'):continue
 wire=from_mesh(mesh)
 for pn,printed in [('base',base),('lid',lid),('cradle',cradle),('bezel',bezel),('PCB',pcb),('LCD module',lcd)]:
  assert (wire^printed).volume()<1e-4,(name,pn,'wire collision',(wire^printed).volume())
 for other_name,other in wire_solids:assert (wire^other).volume()<1e-5,(name,other_name,'wires intersect')
 wire_solids.append((name,wire))
report['purchased_package_models_checked']=package_checks
report['individual_wires_checked']=len(wire_solids)
from fit import evaluate as evaluate_fit
fit_report=evaluate_fit(P)
(ROOT/'fit-budget.json').write_text(json.dumps(fit_report,indent=2)+'\n')
# Full circular envelopes fill cosmetic grooves and screw holes, so a feature
# cannot hide a collision at another rotor angle. Analytic annular expansion
# gives the same stated radial/axial budget without tessellation-heavy sums.
def tolerance_probes(radius,dz):
 def expanded(ro,ri,height,z):return ring(ro+radius,ri-radius,height+2*dz,z-dz)
 swept_cap=expanded(42.4,P['bearing_pocket_id']/2,19-grip_z,grip_z)+expanded(42.4,mount['outer_contact_id']/2,1.7,19)+expanded(42.4,24.8,1.4,20.7)
 return [('grip',swept_cap),('swept skirt',expanded(r+half_track,r-half_track,12.9,7.9)),('skirt flange',expanded(P['encoder']['bond_outer_radius'],r-half_track,1.4,19.44))]
print_probes=tolerance_probes(P['fit_budget']['moving_xy_print_check'],P['fit_budget']['moving_z_print_check'])
pcb_probes=tolerance_probes(P['fit_budget']['moving_xy_pcb_check'],P['fit_budget']['moving_z_hardware_check'])
for name,fixed in [('base',base),('lid',lid),('cradle',cradle),('bezel',bezel)]:
 for mn,mov in print_probes:assert (mov^fixed).volume()<1e-4,(name,mn,'tolerance-envelope collision',(mov^fixed).volume())
for name,fixed in fixed_hardware+package_bodies:
 for mn,mov in pcb_probes:assert (mov^fixed).volume()<1e-4,(name,mn,'hardware tolerance-envelope collision',(mov^fixed).volume())
from bearing import build as build_bearing
_,bearing_solids,bearing_report=build_bearing(P)
for name,s in bearing_solids.items():
 for pn,printed in [('base',base),('lid',lid),('cradle',cradle),('bezel',bezel),*moving]:
  assert (s^printed).volume()<1e-4,(name,pn,'bearing/printed-part collision',(s^printed).volume())
# Two axial shoulders and two radial push-fit interfaces contact their own race.
contacts=[('inner lower',lid.translate((0,0,.01)),'bearing-inner-race'),
 ('outer upper',cap.translate((0,0,-.01)),'bearing-outer-race'),
 ('inner bore',lid.scale((1.0004,1.0004,1)),'bearing-inner-race'),
 ('outer pocket',cap.scale((.9998,.9998,1)),'bearing-outer-race')]
for label,contact,race in contacts:
 assert (contact^bearing_solids[race]).volume()>.1,(label,'bearing contact missing')
 for name,solid in bearing_solids.items():
  if name!=race:assert (contact^solid).volume()<1e-4,(label,'contacts incorrect bearing part',name)
report['tolerance_envelopes_checked']={'print_xy_mm':P['fit_budget']['moving_xy_print_check'],'hardware_xy_mm':P['fit_budget']['moving_xy_pcb_check'],'print_z_mm':P['fit_budget']['moving_z_print_check'],'hardware_z_mm':P['fit_budget']['moving_z_hardware_check'],'wire_od_mm':P['stock_display_cable']['wire_od_check_mm'],'full_rotor_sweep':True}
report['bearing_contacts_checked']=4
report['assembly_simplification']=P['assembly_simplification']
# Check a fingertip at full pretravel with PCB play/height and case-profile error.
# PCB and original switch housing move together; only its actuator may touch.
xy=fit_report['results']['PCB_maximum_in_plane_play_mm']
dz=P['fit_budget']['pcb_thickness_error']+P['fit_budget']['relative_print_z_error']
button_tolerance_checks=0
for dx in [-xy,xy]:
 for zshift in [-dz,dz]:
  shift=(dx,xy+P['fit_budget']['printed_surface_error'],zshift)
  f=finger.translate(shift)
  for name,s in [('base',base),('lid',lid),('PCB',pcb.translate(shift)),*print_probes]:
   assert (f^s).volume()<1e-4,(name,'button access at tolerance limit',(f^s).volume())
  for name,s in package_bodies:
   if name!='b3u3000.obj':assert (f^s.translate(shift)).volume()<1e-4,(name,'finger/package tolerance collision')
  button_tolerance_checks+=1
report['button_tolerance_access_checks']=button_tolerance_checks
button_checks=0
for travel in np.linspace(0,button_design['nominal_max_travel_mm'],13):
 pressed=button_cap.translate((0,float(travel),0))
 for name,fixed in [('base',base),('lid',lid),('PCB',pcb),('LCD',lcd),*moving]:
  assert (pressed^fixed).volume()<1e-4,(name,'printed cap travel collision',travel,(pressed^fixed).volume())
 for name,physical in package_bodies:
  # Omron drawing: housing front is 1.25mm from the footprint datum.
  # Only the projecting actuator moves; never exclude the fixed housing.
  if name=='b3u3000.obj':
   actuator=box((2,1.0,1.4),(0,-42.66,button_z))
   physical-=actuator
  assert (pressed^physical).volume()<1e-4,(name,'cap/fixed package collision',travel)
 button_checks+=1
available=button_design['rest_tip_y']+button_design['nominal_max_travel_mm']-button['actuator_front_y']
assert abs(available-button['datasheet_pretravel_mm']['max'])<1e-6
assert (button_cap.translate((0,button_design['nominal_max_travel_mm']+.05,0))^base).volume()>.01,'The side ears must meet the physical stop'
report['printed_button']={**button_design,'checked_travel_poses':button_checks,'nominal_switch_travel_at_stop_mm':round(available,3),'checks':'Nominal cap sweep, fixed housing clearance, stop engagement and fingertip access. Friction, switch tolerance and safe overtravel require a printed prototype; no actuation force or cycle life is certified.'}
report['front_button']=P['front_button']
report['direct_finger_access_checked']=True
report['separate_button_plunger']=True
report['button_projection_beyond_wall_mm']=round(-button_design['face_front_y']-(outer-.2),3)
report['pressed_button_projection_beyond_wall_mm']=round(-pressed_face-(outer-.2),3)
report['USB_access']={**usb_access,'outer_case_diameter_mm':P['base_diameter'],
 'socket_recess_from_floor_outer_edge_mm':round(outer-usb_access['socket_face_y'],4),
 'socket_projection_beyond_side_wall_mm':round(usb_access['socket_face_y']-(outer-.2),4),
 'PCB_front_edge_mm':max(q['y'] for q in board['outline']),
 'plug_body_to_PCB_axial_gap_mm':round(plug_start-max(q['y'] for q in board['outline']),4),
 'maximum_plug_approach_sweep_checked':True,'all_package_and_PCB_clearances_checked':True}

report['clearances']={'overall_height_mm':22.3,'previous_height_mm':47,'height_reduction_percent':round((47-22.3)/47*100,1),'grip_lower_edge_mm':10.8,'previous_grip_lower_edge_mm':22,'USB_plug_max_height_mm':usb_access['plug_height_mm'],'USB_plug_to_rotor_vertical_mm':round(grip_z-(usb_access['axis_z']+usb_access['plug_height_mm']/2),3),'bezel_to_grip_radial_mm':.8,'skirt_to_sensor_radial_each_side_mm':(3-P['encoder']['track_thickness'])/2,'PCB_bottom_z_mm':2.6,'LCD_connector_in_PCB_window':True,'bearing_id_mm':60,'bearing_od_mm':75,'bearing_width_mm':7,'bearing_stem_diametral_mm':round(bearing['id']-P['bearing_stem_od'],3),'bearing_outer_diametral_mm':round(P['bearing_pocket_id']-bearing['od'],3),'encoder_slot_count':48,'quadrature_edges_per_turn':192,'all_components_top_side':True,'solder_well_floor_remaining_mm':1.2}
report['scope']='Manufacturer screen/package models with user-authorized estimated bearing/cable details. Nominal contacts and full-rotation tolerance envelopes checked under fit-budget.json assumptions. Hardware operation and actual print capability are not measured.'
(ROOT/'geometry-check.json').write_text(json.dumps(report,indent=2)+'\n');print(json.dumps(report['clearances'],indent=2))