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
#!/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')