abse/smartwatch-flex-top-only
Three-panel flex smartwatch with two parallel R3.9 bends, all components flat-top, low-profile JST connector harnesses, split heartbeat optical windows and screw-retained PCB mounting. Modeled 39 × 50.5 × 14.5mm prototype; 100% routing, zero native DRC and shorts. Hardware qualification pending.
- Version
- 0.2.20
- License
- unset
- Stars
- 0
scripts/prepare-plug-in-harnesses.py
"""Explicit ACH harness CAD, real connector pitch, wire OD and routed lengths."""
from pathlib import Path
import base64,json,math
import numpy as np
import trimesh
from trimesh.visual.material import PBRMaterial
root=Path(__file__).resolve().parent.parent
wire_diameter=.38
wire_pitch=.45
minimum_bend=1.425
def box(ext,center,color):
m=trimesh.creation.box(extents=ext,transform=trimesh.transformations.translation_matrix(center))
m.visual=trimesh.visual.TextureVisuals(material=PBRMaterial(baseColorFactor=color,roughnessFactor=.5));return m
def export(name,scene):
data=scene.export(file_type='glb');(root/f'mechanical/{name}.glb').write_bytes(data)
(root/f'mechanical/{name}.ts').write_text('export default "data:model/gltf-binary;base64,'+base64.b64encode(data).decode()+'"\n')
def fillet(points):
p=np.asarray(points,float);out=[p[0]];radii=[]
trims=np.zeros(len(p))
for i in range(1,len(p)-1):
a=p[i-1]-p[i];b=p[i+1]-p[i]
theta=math.acos(np.clip(np.dot(a,b)/np.linalg.norm(a)/np.linalg.norm(b),-1,1))
if abs(theta-math.pi)>1e-5:trims[i]=minimum_bend/math.tan(theta/2)
for i in range(len(p)-1):
assert trims[i]+trims[i+1]<np.linalg.norm(p[i+1]-p[i])-.01,('Adjacent fillets overlap',i,trims[i],trims[i+1])
for i in range(1,len(p)-1):
a=(p[i-1]-p[i]);b=(p[i+1]-p[i]);la=np.linalg.norm(a);lb=np.linalg.norm(b);a/=la;b/=lb
theta=math.acos(np.clip(np.dot(a,b),-1,1))
if abs(theta-math.pi)<1e-5:out.append(p[i]);continue
tangent=minimum_bend/math.tan(theta/2)
assert tangent<min(la,lb)-.05,('Bend legs too short',i,la,lb,tangent)
start=p[i]+a*tangent;end=p[i]+b*tangent
center=p[i]+(a+b)/np.linalg.norm(a+b)*minimum_bend/math.sin(theta/2)
u=(start-center)/minimum_bend;v=(end-center)/minimum_bend
angle=math.acos(np.clip(np.dot(u,v),-1,1));normal=np.cross(u,v);normal/=np.linalg.norm(normal)
count=max(6,math.ceil(angle*minimum_bend/.10));out.append(start)
for j in range(1,count+1):
t=angle*j/count;out.append(center+minimum_bend*(u*math.cos(t)+np.cross(normal,u)*math.sin(t)))
radii.append(minimum_bend)
out.append(p[-1]);return np.array(out),radii
def sweep(points,side0,color,offset,start_pitch=1.2,end_pitch=.45):
# Parallel-transport wire spacing through bends; the two wires remain
# side-by-side along Y when passing up the narrow case/PCB edge corridor.
dense=[np.asarray(points[0])]
for a,b in zip(points[:-1],points[1:]):
nstep=max(1,math.ceil(np.linalg.norm(b-a)/.15))
dense.extend(a+(b-a)*j/nstep for j in range(1,nstep+1))
points=np.asarray(dense);along=np.r_[0,np.cumsum(np.linalg.norm(np.diff(points,axis=0),axis=1))]
tangents=np.zeros_like(points)
tangents[0]=points[1]-points[0];tangents[-1]=points[-1]-points[-2]
tangents[1:-1]=points[2:]-points[:-2];tangents/=np.linalg.norm(tangents,axis=1)[:,None]
side=np.array(side0,float);centers=[];sides=[]
for i,t in enumerate(tangents):
if i:
prev=tangents[i-1];axis=np.cross(prev,t);sn=np.linalg.norm(axis);cs=np.clip(np.dot(prev,t),-1,1)
if sn>1e-8:
axis/=sn;side=side*cs+np.cross(axis,side)*sn+axis*np.dot(axis,side)*(1-cs)
side-=t*np.dot(side,t);side/=np.linalg.norm(side)
bank=None
if points[i,2]>-.95 and abs(t[0])>.05 and abs(t[2])<.1:
bank=np.cross(t,[0,0,1])
elif abs(points[i,0])>16.8:
bank=np.cross([1,0,0],t)
if np.linalg.norm(bank)<1e-6:bank=np.array([0,0,1.])
elif points[i,2]>-.95:
bank=np.cross(t,[0,0,1])
if bank is not None and np.linalg.norm(bank)>1e-6:
bank-=t*np.dot(bank,t);bank/=np.linalg.norm(bank)
if np.dot(bank,side)<0:bank=-bank
theta=math.acos(np.clip(np.dot(bank,side),-1,1))
angle=min(theta,.8*(along[i]-along[i-1])) if i else 0
sign=np.sign(np.dot(np.cross(side,bank),t))
side=side*math.cos(angle)+np.cross(t,side)*math.sin(angle*sign)
side/=np.linalg.norm(side)
pitch=wire_pitch+(start_pitch-wire_pitch)*max(0,1-max(0,along[i]-1.5)/3)
pitch+=(end_pitch-wire_pitch)*max(0,1-(along[-1]-along[i])/3)
sides.append(side.copy());centers.append(points[i]+side*offset*pitch/wire_pitch)
centers=np.array(centers);sides=np.array(sides);verts=[];faces=[];n=12
for center,side,t in zip(centers,sides,tangents):
up=np.cross(t,side)
verts.extend(center+wire_diameter/2*(side*math.cos(j*2*math.pi/n)+up*math.sin(j*2*math.pi/n)) for j in range(n))
for i in range(len(centers)-1):
for j in range(n):
a=i*n+j;b=i*n+(j+1)%n;c=(i+1)*n+(j+1)%n;d=(i+1)*n+j
faces.extend([(a,b,c),(a,c,d)])
verts.extend([centers[0],centers[-1]])
for j in range(n):faces.extend([(len(verts)-2,(j+1)%n,j),(len(verts)-1,(len(centers)-1)*n+j,(len(centers)-1)*n+(j+1)%n)])
m=trimesh.Trimesh(verts,faces,process=False);m.fix_normals()
m.visual=trimesh.visual.TextureVisuals(material=PBRMaterial(baseColorFactor=color,roughnessFactor=.8))
return m,centers
placement=json.loads((root/'flex-placement.json').read_text())
headers={n:[p['pcbX'],-p['pcbY']-47.19159126281738,-7.5] for n,p in placement.items() if n in ['J3','J4','J7','J8']}
starts={n:[v[0],v[1]-2.30,-8.2] for n,v in headers.items()}
definitions=[
('battery-harness','BATTERY_CABLE','J3','BATTERY_MAXIMUM_ENVELOPE',
['BAT','GND'],[starts['J3'],[-14.95,-16.6,-8.5],[-18.02,-16.6,-8.5],[-18.02,-16.6,-3.6],[-18.02,-13.5,-3.6],[-9.76,-13.5,-3.6]]),
('haptic-harness','HAPTIC_CABLE','J4','MOTOR_INLINE_CONNECTOR',
['MOTOR_P','MOTOR_N'],[starts['J4'],[-14.3,10.0,-8.5],[-18.02,10.0,-8.5],[-18.02,10.0,-2.15],[-18.02,6.4,-2.15],[-13.7,6.4,-2.15],[-13.7,.4,-2.15]]),
('battery-ntc-harness','BATTERY_NTC_CABLE','J7','BATTERY_TEMPERATURE_PROBE',
['NTC','GND'],[starts['J7'],[14.95,-16.6,-8.5],[18.02,-16.6,-8.5],[18.02,-16.6,-2.0],[16.3,-13.5,-2.0],[16.3,-10.5,-2.0],[16.3,-1.85,-1.58]]),
('coil-ntc-harness','COIL_NTC_CABLE','J8','COIL_TEMPERATURE_PROBE',
['QI_TS','GND'],[starts['J8'],[-14.0,-9.0,-8.1],[-17.5,-9.0,-8.1],[-18.02,2.0,-8.1],[-18.02,2.0,-4.5],[-18.02,-15.0,-4.5],[-12.8,-15.0,-6.45]]),
]
manifest=[]
for file,name,ref,target,nets,points in definitions:
scene=trimesh.Scene();header=headers[ref]
# ACHR-02V-S: 2.6 x 3.5 x 1.2mm. Fully mated header+housing envelope
# is 4.2 x 4.3 x 1.4mm, already reserved by the purchased PCB component.
scene.add_geometry(box([2.6,3.5,1.2],[header[0],header[1]+.15,-8.2],[245,240,225,255]),geom_name='ACHR_02V_S_MATED_HOUSING')
points.insert(0,[header[0],header[1]-.8,-8.2])
path,radii=fillet(points)
wire_lengths=[]
for i,color in enumerate([[190,45,40,255],[30,35,40,255]]):
m,centers=sweep(path,[1,0,0],color,(i-.5)*wire_pitch,end_pitch=1.25 if name=='HAPTIC_CABLE' else .45)
scene.add_geometry(m,geom_name=f'{name}_PIN_{i+1}_{nets[i]}')
wire_lengths.append(float(np.linalg.norm(np.diff(centers,axis=0),axis=1).sum()))
export(file,scene)
manifest.append({'file':file,'name':name,'from':f'.PCB_{ref}','to':'.'+target,'pcbRef':ref,
'headerMPN':'BM02B-ACHSS-GAN-ETF(LF)(SN)','headerJlcpcb':'C5118738','housingMPN':'ACHR-02V-S',
'crimpContactMPN':'SACH-003G-P0.2B','wireSpec':'AWG32, insulation OD 0.38mm (manufacturer-permitted minimum)',
'wireOuterDiameterMM':wire_diameter,'contactPitchMM':1.2,'housingEnvelopeMM':[2.6,3.5,1.2],
'matedEnvelopeMM':[4.2,4.3,1.4],'routingBendRadiusMM':minimum_bend,'routingWirePitchMM':wire_pitch,
'pcbHeaderCenterMM':header,'factoryPartEntryMM':points[-1],'wirePathMM':path.tolist(),
'wireLengthsInModelMM':wire_lengths,'pinMap':{'1':nets[0],'2':nets[1]},
'termination':'Crimped ACH at PCB end; haptic harness ends in Molex 510210200/500588000, mating with the motor factory leads crimped into 510470200/501338000. Other peripherals are supplied prewired and crimp-terminated. No cable is hand-soldered during assembly.'})
probes=trimesh.Scene()
reference=root/'mechanical'
for filename in ['battery-thermistor','coil-thermistor']:
source=trimesh.load(reference/('reference-'+filename+'.glb'),force='scene')
for node in source.graph.nodes_geometry:
T,k=source.graph[node];m=source.geometry[k].copy();m.apply_transform(T)
if filename=='coil-thermistor':
# Rotate the known 4x3mm insulated NCP03XH103F05RL assembly:
# active chip stays on ferrite; package clears the battery by 0.1mm.
m.apply_transform(np.array([[0,-1,0,-9.3],[1,0,0,-18.0],[0,0,1,1.290000457763672],[0,0,0,1]]))
probes.add_geometry(m,geom_name=('BATTERY_TEMPERATURE_PROBE' if filename=='battery-thermistor' else 'COIL_TEMPERATURE_PROBE')+'_'+node)
export('temperature-probes',probes)
inline=trimesh.Scene()
inline.add_geometry(box([4.45,9.4,3.5],[-13.7,-4.1,-2.15],[240,235,220,255]),geom_name='PICO_BLADE_MATED_MAXIMUM_CLEARANCE_ENVELOPE')
export('motor-inline-connector',inline)
factory=trimesh.Scene();lengths=[]
curve=np.array([[-13.7,-12.15-1.2*math.sin(a),-4.9-1.2*math.cos(a)] for a in np.linspace(0,math.pi,61)])
curve=np.vstack([curve,[-13.7,-8.6,-2.15]])
for i,color in enumerate([[190,45,40,255],[40,70,175,255]]):
# 0.50 mm maximum wire envelope for the AWG32 UL3302 factory leads.
saved_diameter=wire_diameter;saved_pitch=wire_pitch;wire_diameter=.5;wire_pitch=1.25
m,centers=sweep(curve,[1,0,0],color,(i-.5)*wire_pitch,start_pitch=1.25,end_pitch=1.25)
wire_diameter=saved_diameter;wire_pitch=saved_pitch
factory.add_geometry(m,geom_name=f'MOTOR_FACTORY_PIN_{i+1}')
lengths.append(float(np.linalg.norm(np.diff(centers,axis=0),axis=1).sum()))
export('motor-factory-leads',factory)
assert max(lengths)+1.5<10, 'Factory leads must fit even the motor 12-2mm minimum, including crimp strip allowance'
manifest.append({'file':'motor-factory-leads','name':'MOTOR_FACTORY_LEADS','from':'.RELOCATED_HAPTIC_MOTOR_ENVELOPE','to':'.MOTOR_INLINE_CONNECTOR',
'pcbRef':None,'wireOuterDiameterEnvelopeMM':.5,'wireSpec':'Existing motor AWG32 UL3302 factory wires; trim/strip/crimp, no solder splice.',
'factoryLeadLengthRangeMM':[10,14],'modeledFreeLeadLengthsMM':lengths,'crimpStripAllowanceMM':1.5,
'motorNeckEntryMM':curve[0].tolist(),'inlineWireEntryMM':curve[-1].tolist(),'factoryPartEntryMM':curve[0].tolist(),
'pinMap':{'1':'MOTOR_P','2':'MOTOR_N'},'plugHousingMPN':'510470200','plugHousingJlcpcb':'C505086','plugContactMPN':'501338000','plugContactJlcpcb':'C565244',
'receptacleHousingMPN':'510210200','receptacleHousingJlcpcb':'C293613','receptacleContactMPN':'500588000','receptacleContactJlcpcb':'C293573',
'manufacturerMatedNominalMM':[4.25,9,3.3],'reservedMatedEnvelopeMM':[4.45,9.4,3.5],
'reference':'Molex 510470000-SD sheet 2 mated dimensions; Vybronics VC0720B015F rev 3 page 13 factory AWG32 UL3302 leads 12 ±2mm.'})
(root/'mechanical/harness-manifest.json').write_text(json.dumps(manifest,indent=2)+'\n')
print('Prepared',len(manifest),'harnesses with explicit pin maps and dimensioned ACH housings.')