project
Ghost Hand
Public Unreviewedby John Lauer
A small battery gadget that keeps an automatic sink faucet running while you use the sink: it learns the faucet's timeout, re-arms with the shortest blip, and stops after 10 minutes or when picked up.
main
John Lauer
Rev 2: case refit to the placed board (DONE rev B), pin span 24 x 16 mm, build status
1cd53fe
1d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
#!/usr/bin/env python3
"""Ghost Hand rev 2, millimetres, CadQuery 2.8. Run this script to rebuild all CAD and checks."""
from pathlib import Path
import cadquery as cq
import math,json,itertools,hashlib,argparse
P=Path(__file__).resolve().parent
W,D,R=37.,32.,3.
CX,CY=13.,11.
FLOOR,WALL,SEAM,ROOF=1.,1.2,7.5,16.5
BOARD_Z,PITCH=11.,5.
PINS=[(0,0),(24,0),(0,16),(24,16)]
LEDS={'status':(11,7),'25':(11.3,15.5),'50':(14.5,15.5),'75':(17.7,15.5),'100':(20.9,15.5)}
SCREWS=[(-3.3,-2.4),(-3.4,21.),(29.4,1.),(29.4,21.)]
SOURCE=P.parent/'design/glb/ghost-hand-board.step'
COLOR={'base':(.58,.66,.65),'cover':(.89,.91,.85),'gasket':(.08,.22,.2),'foot':(.08,.22,.2),'usb_plug':(.12,.4,.37),'ir_window':(.25,.48,.47),'final_board':(.05,.3,.18),'battery_pouch':(.7,.72,.73),'battery_tape':(.85,.52,.12),'battery_jst_plug':(.94,.93,.84),'battery_lead_red':(.7,.03,.02),'battery_lead_black':(.025,.025,.025)}
def box(w,d,h,x=0,y=0,z=0):return cq.Workplane('XY').box(w,d,h,centered=(True,True,False)).translate((x,y,z))
def cyl(d,h,x,y,z):return cq.Workplane('XY').circle(d/2).extrude(h).translate((x,y,z))
def rr(w,d,r,h,z=0,x=CX,y=CY):return box(w,d,h,x,y,z).edges('|Z').fillet(r)
def ring(w,d,r,t,h,z):return rr(w,d,r,h,z).cut(rr(w-2*t,d-2*t,r-t,h+2,z-1))
def board_placement(s):
"""Board top frame, MP1=(0,0,0); nose toward -Y, pitched nose-up 5 degrees."""
return s.translate((0,-9,0)).rotate((0,0,0),(1,0,0),-PITCH).translate((0,9,BOARD_Z))
def xyz(x,y,z):
a=math.radians(PITCH);return (x,9+(y-9)*math.cos(a)+z*math.sin(a),BOARD_Z-(y-9)*math.sin(a)+z*math.cos(a))
def comp(shapes):return cq.Workplane().newObject([cq.Compound.makeCompound([s.val() for s in shapes])])
def board_source_placement(s):
"""KiCad STEP has bottom Z=0, top Z=1.6; XY already matches MP1."""
return board_placement(s.translate((0,0,-1.6)))
def board_model():
"""Require the real revision-B STEP; retain a small display board."""
if not SOURCE.exists():raise RuntimeError('Real board STEP required')
digest=hashlib.sha256(SOURCE.read_bytes()).hexdigest()
src=cq.importers.importStep(str(SOURCE));b=src.val().BoundingBox()
plates=[s for s in src.val().Solids() if abs(s.BoundingBox().xlen-30)<.1 and abs(s.BoundingBox().ylen-22)<.1 and abs(s.BoundingBox().zlen-1.6)<.12]
if not plates:raise RuntimeError('No 30 x 22 x 1.6 PCB in source')
bb=plates[0].BoundingBox()
if max(abs(bb.xmin+3),abs(bb.ymin+3),abs(bb.zmin),abs(bb.zmax-1.6))>.1:raise RuntimeError('Unexpected board frame')
pcb=cq.Workplane().newObject([plates[0]]).translate((0,0,-1.6))
meta={'source_exists':True,'source_sha256':digest,'mode':'real rev 2 board','interface':'rev B','source_bounds_mm':[b.xmin,b.xmax,b.ymin,b.ymax,b.zmin,b.zmax],'source_to_board_translation_mm':[0,0,-1.6],'real_solids':len(src.val().Solids()),'display':'small PCB and interface envelopes; full real STEP independently checked'}
return pcb,src.translate((0,0,-1.6)),meta
def build():
roof_tool=cq.Workplane('YZ').circle(120).extrude(100).translate((-37,CY,ROOF-120))
inner_roof=cq.Workplane('YZ').circle(119).extrude(100).translate((-37,CY,ROOF-120))
outer=rr(W,D,R,ROOF).intersect(roof_tool).edges('not |Z').fillet(.8)
inner=rr(W-2*WALL,D-2*WALL,R-WALL,ROOF-FLOOR-1,FLOOR).intersect(inner_roof)
shell=outer.cut(inner)
base=shell.intersect(box(100,100,SEAM,CX,CY));cover=shell.intersect(box(100,100,30,CX,CY,SEAM))
# Small outer compressed gasket and inner tongue, seam hard stops remain between channels.
tongue=ring(W-2.0,D-2.0,R-1.0,.65,1.0,SEAM-.2)
base=base.union(tongue)
cover=cover.cut(ring(W-1.7,D-1.7,R-.85,.95,1.05,SEAM))
gasket=ring(W-.45,D-.45,R-.225,.65,.6,SEAM-.3)
groove=ring(W-.35,D-.35,R-.175,.75,.6,SEAM-.3)
base=base.cut(groove);cover=cover.cut(groove)
# Cross-slotted 1.08 mm spring collets grip 1.2 mm machine-pin shanks.
for x,y in PINS:
bottom=-3.85;px,py,pz=xyz(x,y,bottom)
support=board_placement(cyl(3.2,2.21,x,y,bottom))
# Outboard posts keep the whole battery rectangle free, not merely its pin tips.
post_x=-3.0 if x==0 else 29.0
pillar=cyl(2.0,pz-FLOOR+.6,post_x,py,FLOOR-.05)
bridge=box(abs(post_x-x)+2.4,3.0,1.0,(post_x+x)/2,py,pz-.05)
bore=board_placement(cyl(1.08,2.6,x,y,bottom-.15))
lead=board_placement(cq.Workplane('XY').circle(.74).workplane(offset=-.3).circle(.54).loft().translate((x,y,-1.6)))
slots=board_placement(box(.5,3.6,1.8,x,y,-3.4).union(box(3.6,.5,1.8,x,y,-3.4)))
collet=support.union(bridge).union(pillar).cut(bore).cut(lead).cut(slots)
base=base.union(collet)
# Pack floor at 1.0, pack top 4.8. Pocket walls avoid all four collets.
battery=rr(26.02,19.75,.5,3.8,1,13,9)
# Separate guide ribs overlap the floor by 0.2 mm; avoid coplanar compound fuses.
for x,y,w,d in [(-.4,9,.3,12),(26.4,9,.3,12),(13,-1.2,20,.3),(5.5,19.2,5,.3),(21,19.2,5,.3)]:
base=base.union(box(w,d,1.2,x,y,.8))
# Four miniature inserts allow controlled gasket compression, unlike a service-fatigued snap.
screws=[];inserts=[];washers=[]
for x,y in SCREWS:
base=base.union(cyl(4.1,SEAM-1,x,y,1).intersect(outer))
cover=cover.union(cyl(4.1,8,x,y,SEAM).intersect(outer))
bore=cyl(1.8,15.5,x,y,0);head=cyl(3.5,1.55,x,y,0)
base=base.cut(bore).cut(head);cover=cover.cut(bore).cut(cyl(2.15,8.0,x,y,SEAM))
screws.append(cyl(1.6,14,x,y,1.25).union(cyl(3.2,1.42,x,y,-.17)))
inserts.append(cyl(2.5,2.5,x,y,13.0).cut(cyl(1.3,2.7,x,y,12.9)))
washers.append(cyl(3.4,.3,x,y,1.25).cut(cyl(1.6,.5,x,y,1.15)))
foot=ring(29,24,4,2,1.0,-.5)
base=base.cut(ring(29.2,24.2,4.1,2.2,.5,0))
# USB port follows the PCB pitch. A bonded collar seats against the metal can.
port=board_placement(box(9,10.4,4.8,31,8,-.8).edges('|X').fillet(1.1))
collar=board_placement(box(2.8,12.4,6.8,30.1,8,-1.8).edges('|X').fillet(1.5)).intersect(outer)
saddle=board_placement(box(1.0,10.6,6,18.5,8,1.9).union(box(7.5,1.1,6,23.1,2.9,1.9)).union(box(7.5,1.1,6,23.1,13.1,1.9))).intersect(outer)
cover=cover.union(collar).union(saddle).cut(port);base=base.cut(port)
usb_core=box(2.8,10.25,4.65,30.2,8,-.725).edges('|X').fillet(1.05)
cap=box(1,12,6,32,8,-1.4).edges('|X').fillet(1.5)
# Integral TPU ribbon curls toward a captured anchor above the base seam.
tether=box(.7,1.2,4.1,31.95,14,-1.4).union(box(1,5.5,.7,31.95,11.7,2.0)).union(box(2.4,1.2,.7,31.1,14,2.0))
lips=comp([box(.25,10.55,4.95,x,8,-.875).edges('|X').fillet(1.1) for x in (30.0,31.0)])
plug=board_placement(usb_core.union(cap).union(tether).union(lips))
cover=cover.cut(board_placement(tether));base=base.cut(board_placement(tether))
# Clear shared insert in a front rebate. Opaque barrier meets the insert's inner face.
window=box(21,1.0,3.6,11,-4.15,-.6).edges('|Y').fillet(.5)
rebate=board_placement(box(21.2,1.2,3.8,11,-4.15,-.7).edges('|Y').fillet(.5))
for x,w in [(6.0,8.3),(16.4,8.0)]:
aperture=board_placement(box(w,5,2.8,x,-5, -.2).edges('|Y').fillet(.5));cover=cover.cut(aperture);base=base.cut(aperture)
cover=cover.cut(rebate);base=base.cut(rebate)
barrier=board_placement(box(1.2,1.75,3.1,11.2,-2.775,0))
# Extend barrier to roof; keep its lower edge on the PCB top and clear the TX/RX bodies.
barrier=barrier.union(board_placement(box(1.2,3.2,8,11.2,-3.5,2.8)).intersect(outer)).cut(rebate)
cover=cover.union(barrier).cut(rebate)
pipes={}
for name,(x,y) in LEDS.items():
_,_,z=xyz(x,y,1.04)
# Cylindrical pipes follow the optical LED normal and terminate in the smooth roof plane.
pipe=board_placement(cyl(1.5,9,x,y,1.04)).intersect(outer)
well=board_placement(cyl(2.9,9,x,y,1.04)).intersect(outer)
hole=board_placement(cyl(1.48,10,x,y,1.03))
glue_land=board_placement(cyl(1.8,3.0,x,y,4.9))
hole=hole.union(glue_land)
cover=cover.union(well).cut(hole)
# A small separate glue land at the upper end; flush face sheds water.
pipes['lightpipe_'+name]=pipe
# Recut seam seals after adding bosses and port supports.
base=base.cut(groove);cover=cover.cut(groove).cut(tongue)
base=base.cut(ring(W+.1,D+.1,R+.05,.35,.3,6.3))
# Real underside 0402 beside MP1: notch only the outside edge of its collet ledge.
base=base.cut(board_placement(box(1.4,.9,.9,.1,1.7,-2.35)))
cover=cover.cut(base)
# Discard only micrometre-scale boolean slivers, never a functional disconnected part.
slivers=[a.Volume() for a in cover.val().Solids() if a.Volume()<1e-4]
(P/'boolean-slivers.json').write_text(json.dumps({'removed_cover_volumes_mm3':slivers,'limit_mm3':1e-4},indent=2))
cover=cq.Workplane().newObject([cq.Compound.makeCompound([a for a in cover.val().Solids() if a.Volume()>=1e-4])])
pcb,real,meta=board_model();(P/'board-source-review.json').write_text(json.dumps(meta,indent=2))
cover=cover.cut(board_placement(rr(30.4,22.4,3.2,2.0,-1.8,12,8)))
# Local J2 roof relief retains about 0.8 mm of roof without increasing the envelope.
cover=cover.cut(board_placement(box(6.4,7.4,4.95,6,15.5,0)))
# Remove the detached seam nib at the front-right boss; retain all load-bearing walls.
cover=cover.cut(box(.35,.4,.7,SCREWS[2][0]+.4,SCREWS[2][1]-1.95,SEAM-.15))
env={'pcb':board_placement(pcb)}
for k,(x,y) in enumerate(PINS):env['pin_'+str(k)]=board_placement(cyl(1.2,7.75,x,y,-4.95))
env['usb']=board_placement(box(7.35,8.94,3.26,23.925,8,0))
env['j2']=board_placement(box(6,7,4.8,6,15.5,0))
for k,x in enumerate([3.7,8.3,14.1,18.7]):env['ir_'+str(k)]=board_placement(box(4.6,3.5,2.4,x,-1.15,0))
for n,(x,y) in LEDS.items():env['led_'+n]=board_placement(box(2,2,.84,x,y,0))
# Conservative 1.2 mm underside envelope, excluding the pin collets.
bottom=box(25,17,1.2,12,8,-2.8)
for x,y in PINS:bottom=bottom.cut(cyl(3.5,2,x,y,-3))
env['bottom_parts']=board_placement(bottom)
env['top_parts']=board_placement(box(24,15,1.8,13,8.5,0))
# JST plug is a photo-derived 4.5 mm rear projection; cable exits behind it.
jplug=board_placement(box(5.8,4.5,3.5,6,21.25,.6))
parts={'base':base,'cover':cover,'gasket':gasket,'foot':foot,'usb_plug':plug,'ir_window':board_placement(window),**pipes,
'battery_pouch':battery,'battery_tape':box(22,6,.15,13,9,4.8),'battery_jst_plug':jplug,
'screws':comp(screws),'inserts':comp(inserts),'screw_seals':comp(washers)}
leads,route=make_leads();parts.update(leads);(P/'lead-route.json').write_text(json.dumps(route,indent=2))
# The display board is compact; true board STEP remains the board thread's source.
parts['battery_jst_plug']=parts['battery_jst_plug'].cut(leads['battery_lead_red']).cut(leads['battery_lead_black'])
parts['final_board']=comp([s for n,s in env.items() if n not in ('bottom_parts','top_parts')])
if real is not None:env['real_board']=board_placement(real)
return parts,env,meta
_lead_clearance_boxes={}
_lead_edges={}
def make_leads():
"""Two independently modelled 127.5 mm leads, staged spiral below the PCB then rear return."""
import numpy as np
from scipy.optimize import brentq
def path(label,scale):
dx=-.6 if label=='red' else .6; z=5.65 if label=='red' else 6.85
pts=[]
for t in np.linspace(0,4*math.pi,95):
r=1-t/(4*math.pi)*.32
pts.append((13+scale*r*math.cos(t),9+scale*.66*r*math.sin(t),z))
pts.extend([(21+dx,16,z),(19+dx,21,z),(17+dx,23,z+.2),(12+dx,24.25,z+1),(6+dx,23.25,10),(xyz(6+dx,23.5,2.0))])
return cq.Edge.makeSpline([cq.Vector(*p) for p in pts])
parts={};data={}
for label in ('red','black'):
scale=brentq(lambda s:path(label,s).Length()-127.5,6,12)
e=path(label,scale);wire=cq.Wire.assembleEdges([e])
_lead_edges['battery_lead_'+label]=e
# Exact trimmed-curve boxes padded by the tube radius conservatively bound the sweep.
ca=e._geomAdaptor();u0,u1=ca.FirstParameter(),ca.LastParameter()
bounds=[]
for k in range(128):
bb=e.trim(u0+(u1-u0)*k/128,u0+(u1-u0)*(k+1)/128).BoundingBox()
bounds.append((bb.xmin-.451,bb.xmax+.451,bb.ymin-.451,bb.ymax+.451,bb.zmin-.451,bb.zmax+.451))
_lead_clearance_boxes['battery_lead_'+label]=bounds
tube=cq.Workplane(cq.Plane(origin=e.startPoint(),normal=e.tangentAt(0))).circle(.45).sweep(cq.Workplane().newObject([wire]),isFrenet=True)
parts['battery_lead_'+label]=tube
data[label]={'length_mm':e.Length(),'insulation_OD_mm':.9,'samples':[e.positionAt(i/500).toTuple() for i in range(501)]}
data['note']='Installation pose, nominal 127.5 mm leads; 0.9 mm insulation OD is provisional. Pack lead exit photo-derived; verify actual received pack.'
return parts,data
def contact_shape(a,b,lead_boxes=None,lead_edge=None):
"""Exact boolean after bounding-box rejection; no geometry is simplified."""
def overlaps(x,y):
return x.xmin<=y.xmax and y.xmin<=x.xmax and x.ymin<=y.ymax and y.ymin<=x.ymax and x.zmin<=y.zmax and y.zmin<=x.zmax
ab=a.BoundingBox();bb=b.BoundingBox()
if not overlaps(ab,bb):return None
solids=[v for v in b.Solids() if overlaps(ab,v.BoundingBox())]
if lead_boxes:
def near(bb):
return any(q[0]<=bb.xmax and bb.xmin<=q[1] and q[2]<=bb.ymax and bb.ymin<=q[3] and q[4]<=bb.zmax and bb.zmin<=q[5] for q in lead_boxes)
solids=[v for v in solids if near(v.BoundingBox())]
if not solids:return None
candidate=cq.Compound.makeCompound(solids)
# An exact centreline distance larger than the padded radius proves the tube is separate.
if lead_edge is not None and lead_edge.distance(candidate)>.451:return None
return a.intersect(candidate)
def check(parts,env,meta):
# Pairwise physical parts, plus independent component envelopes against the case.
unexpected=[];intentional=[];count=0
allparts={**parts,**{'envelope_'+n:s for n,s in env.items() if n not in ('top_parts','real_board')}}
# Display board duplicates these explicit physical envelopes; exclude duplicate check pairings.
allparts.pop('final_board')
for (a,sa),(b,sb) in itertools.combinations(allparts.items(),2):
if a.startswith('envelope_') and b.startswith('envelope_'):continue
count+=1
if a in _lead_edges and b in _lead_edges and _lead_edges[a].distance(_lead_edges[b])>.902:contact=None
elif b in _lead_clearance_boxes:contact=contact_shape(sb.val(),sa.val(),_lead_clearance_boxes[b],_lead_edges[b])
else:contact=contact_shape(sa.val(),sb.val(),_lead_clearance_boxes.get(a),_lead_edges.get(a))
v=contact.Volume() if contact is not None else 0
if v<.008:continue
reason=None
if {a,b}=={'cover','usb_plug'}:reason='Two TPU sealing ribs compress 0.075 mm radially into the USB collar'
if {a,b}=={'cover','inserts'}:reason='Heat-set insert knurls displace the undersized 2.15 mm pilot hole'
if a=='cover' and b.startswith('lightpipe_'):reason='Designed 0.02 mm diametral pipe press fit; upper glue rebate separately clear'
if a=='base' and b.startswith('envelope_pin'):reason='Designed 0.12 mm diametral spring-collet interference'
if {a,b}=={'screws','screw_seals'}:reason='TPU washer compressed under screw head'
if {a,b}=={'inserts','screws'}:reason='Thread engagement, smooth cylindrical thread envelopes'
row={'a':a,'b':b,'volume_mm3':round(v,5)}
if reason:row['reason']=reason;intentional.append(row)
else:unexpected.append(row)
if 'real_board' in env:
print('Checking all mechanical parts against the full real board',flush=True)
for n in parts:
if n=='final_board':continue
print('Real board fit: '+n,flush=True)
contact=contact_shape(parts[n].val(),env['real_board'].val(),_lead_clearance_boxes.get(n),_lead_edges.get(n))
v=contact.Volume() if contact is not None else 0;count+=1
if v>.008:
row={'a':n,'b':'real_board','volume_mm3':v}
if n=='base':
pins=comp([v for k,v in env.items() if k.startswith('pin_')])
residual=contact.cut(pins.val()).Volume()
if residual>.008:row['outside_pin_grip_mm3']=residual;unexpected.append(row)
else:row['reason']='Designed collet contact, verified inside explicit pin envelopes';intentional.append(row)
else:unexpected.append(row)
density={'base':1.27,'cover':1.27,'gasket':1.2,'foot':1.2,'usb_plug':1.2,'screw_seals':1.2,'screws':7.85,'inserts':8.5}
mass={n:round(s.val().Volume()/1000*density.get(n,1.15),4) for n,s in parts.items() if not n.startswith('battery') and n!='final_board'}
mass['battery_complete']=4.65;mass['pcb_assembled_estimate']=2.4
validity={n:{'valid':s.val().isValid(),'solids':len(s.val().Solids())} for n,s in parts.items()}
report={'mode':meta['mode'],'board_source_sha256':meta['source_sha256'],'pairs_checked':count,'unexpected_intersections':unexpected,'intentional_intersections':intentional,'threshold_mm3':.008,'validity':validity,'mass_g':mass,'total_mass_g':round(sum(mass.values()),2),'shell_dimensions_mm':[37,32,16.5],'overall_dimensions_mm':[38,32,17],'ir_axis_counter_height_mm':xyz(6.0,-2.9,1.2)[2]+.5,'clearances_mm':{'pin_tip_to_battery':xyz(0,16,-4.95)[2]-4.8,'LED_pipe':.2,'lead_to_lead':_lead_edges['battery_lead_red'].distance(_lead_edges['battery_lead_black'])-.9,'pack_to_rear_pin_tip':xyz(0,16,-4.95)[2]-4.8,'pack_to_board_bottom':xyz(12,19,-1.6)[2]-4.95,'pack_to_bottom_parts':xyz(12,19,-2.8)[2]-4.95},'qualification':'CAD prototype; full supplied board STEP checked at recorded SHA256. Nominal pack dimensions; actual pouch swelling, wires, print fits and sealing need physical validation.'}
bounds=comp(list(parts.values())).val().BoundingBox()
report['overall_dimensions_mm']=[round(bounds.xlen,3),round(bounds.ylen,3),round(bounds.zlen,3)]
(P/'parameters.json').write_text(json.dumps({k:v for k,v in globals().items() if k.isupper() and isinstance(v,(int,float,list,tuple,dict))},indent=2))
(P/'interference.json').write_text(json.dumps(report,indent=2))
print(json.dumps({k:report[k] for k in ('mode','pairs_checked','unexpected_intersections','total_mass_g','ir_axis_counter_height_mm')},indent=2),flush=True)
return report
def export(parts):
for folder in ('step','stl','glb','renders','wiki-component'):(P/folder).mkdir(exist_ok=True)
ass=cq.Assembly(name='Ghost_Hand_rev_2');exp=cq.Assembly(name='Ghost_Hand_rev_2_exploded')
for n,s in parts.items():
c=COLOR.get(n,(.2,.75,.66) if n.startswith('lightpipe') else (.65,.65,.63))
cq.exporters.export(s,str(P/'step'/f'{n}.step'))
if n!='final_board' and not n.startswith('battery'):
out=ring(W-.45,D-.45,R-.225,.65,.8,0) if n=='gasket' else s
if n=='screw_seals':out=comp([cyl(3.4,.4,x,y,0).cut(cyl(1.6,.6,x,y,-.1)) for x,y in SCREWS])
cq.exporters.export(out,str(P/'stl'/f'{n}.stl'),tolerance=.035,angularTolerance=.12)
ass.add(s,name=n,color=cq.Color(*c))
dz=25 if n=='cover' else 32 if n.startswith('lightpipe') else 15 if n=='final_board' else 7 if n.startswith('battery') else -4 if n=='foot' else 10 if n=='gasket' else 0
exp.add(s.translate((7 if n=='usb_plug' else 0,-8 if n=='ir_window' else 0,dz)),name=n,color=cq.Color(*c))
ass.save(str(P/'step/ghost-hand-assembly.step'));exp.save(str(P/'step/ghost-hand-exploded.step'))
bat=cq.Assembly(name='Adafruit_1317')
for n,s in parts.items():
if n.startswith('battery'):bat.add(s.translate((-13,-9,-1)),name=n,color=cq.Color(*COLOR.get(n,(.6,.6,.6))))
bat.save(str(P/'wiki-component/adom-1317.step'))
def engrave():
from matplotlib.textpath import TextPath
from matplotlib.font_manager import FontProperties
font=FontProperties(family='DejaVu Sans')
lines=['<svg xmlns="http://www.w3.org/2000/svg" width="110mm" height="105mm" viewBox="0 0 110 105">','<title>Ghost Hand rev 2 engraving, millimetres</title>']
def label(t,x,y,h):
p=TextPath((0,0),t,size=h,prop=font)
path=' '.join('M'+' L'.join(f'{x+a:.4f},{y-b:.4f}' for a,b in poly)+' Z' for poly in p.to_polygons())
lines.append(f'<path fill="black" d="{path}"/>')
def face(n,x,y,w,h):
radius=R if n in ('top','bottom') else .8
lines.append(f'<g id="{n}" transform="translate({x} {y})"><rect width="{w}" height="{h}" rx="{radius}" fill="none" stroke="red" stroke-width=".05"/>')
face('top',5,5,37,32)
label('GHOST HAND',7,27,1.8)
label('STATUS',17,23,1.1)
# SVG top is viewed from above, back at the top. Pipe centres are board transformed XY.
for n,(x,y) in LEDS.items():
from scipy.optimize import brentq
def roof_error(h):
_,wy,wz=xyz(x,y,h)
return wz-(ROOF-120+math.sqrt(120**2-(wy-CY)**2))
px,py,_=xyz(x,y,brentq(roof_error,0,15));sx=px+5.5;sy=27-py
lines.append(f'<circle cx="{sx}" cy="{sy}" r=".75" fill="none" stroke="red" stroke-width=".05"/>')
if n!='status':label(n,sx-(1.0 if n!='100' else 1.3),sy+2.7,1.05)
lines.append('</g>');face('front',5,44,37,16.5);label('IR',17,9.5,1.4);lines.append('</g>')
face('right',50,5,32,16.5);label('USB-C 5V',4,13.4,1.5);lines.append('</g>')
face('bottom',50,30,37,32)
for t,y,h in [('Ghost Hand rev 2',7,1.5),('MIT',10,1.3),('Li-ion 3.7 V 150 mAh',14,1.4),('Stops after 10 min,',19,1.3),('when lifted, or at low battery',22,1.3)]:label(t,3,y,h)
lines.append('</g></svg>');(P/'laser_engrave.svg').write_text('\n'.join(lines))
if __name__=='__main__':
parts,env,meta=build();report=check(parts,env,meta);export(parts);engrave()
if report['unexpected_intersections'] or any(not v['valid'] for v in report['validity'].values()) or report['validity']['base']['solids']!=1 or report['validity']['cover']['solids']!=1:raise SystemExit('FIT CHECK FAILED: inspect interference.json')