AnasSarkiz/NEMA14_RP2040
A NEMA motor assembly concept with a controller board, four proposed M3 screws and spacers, motor lead splices, and a JST 4-pin connector harness.
- Version
- 1.13.0
- License
- ISC
- Stars
- 0
scripts/check-assembly-fit.py
"""Check the actual assembly path and proposed hardware against supplier geometry.
This nominal CAD check does not qualify the motor rear attachment or hardware.
"""
import hashlib,itertools,json,math,pathlib
import cadquery as cq
root=pathlib.Path.cwd();j=json.loads((root/'assembly.circuit.json').read_text());native=(root/'artifacts/board.circuit.json').read_bytes();names={e['source_component_id']:e['name'] for e in j if e['type']=='source_component'};dnp={e['source_component_id'] for e in j if e['type']=='pcb_component' and e.get('do_not_place')};parts={};cache={};bounds={};conservative=[]
assert native==(root/'index.circuit.json').read_bytes(), 'Saved routed board differs from the published baseline'
harness=json.loads((root/'artifacts/top-assembly/harness-definition.json').read_text())
assert harness['board_sha256']==hashlib.sha256(native).hexdigest()
catalog=json.loads((root/'src/jlcpcb-catalog.json').read_text())['parts']
def bb(s):
b=s.BoundingBox();return [b.xmin,b.ymin,b.zmin,b.xmax,b.ymax,b.zmax]
def broad(a,b):return all(a[i+3]>=b[i] and b[i+3]>=a[i] for i in range(3))
def lower(a,b):return math.sqrt(sum(max(a[i]-b[i+3],b[i]-a[i+3],0)**2 for i in range(3)))
def add(name,s):parts[name]=s;bounds[name]=bb(s)
for e in j:
if e['type']!='cad_component' or not e.get('model_step_url') or e.get('source_component_id') in dnp:continue
name=names[e['source_component_id']];url=e['model_step_url'];p=root/'imports/supplier'/url.split('/imports/supplier/')[-1]
expected=next(q['sha256']['step'] for q in catalog.values() if str(p.relative_to(root)) in q['modelFiles'])
assert hashlib.sha256(p.read_bytes()).hexdigest()==expected, 'Supplier model changed: '+name
if str(p) not in cache:cache[str(p)]=cq.importers.importStep(str(p)).val()
s=cache[str(p)]
if not s.isValid():
assert name in ['J_PD','J_DATA'];b=bb(s);s=cq.Workplane('XY').box(*(b[i+3]-b[i] for i in range(3))).val().translate(tuple((b[i+3]+b[i])/2 for i in range(3)));conservative.append(name)
o=e.get('model_origin_position',dict(x=0,y=0,z=0));s=s.translate(tuple(-o[k] for k in 'xyz'))
for a,v in [('x',(1,0,0)),('y',(0,1,0)),('z',(0,0,1))]:
if e['rotation'][a]:s=s.rotate((0,0,0),v,e['rotation'][a])
s=s.translate(tuple(e['position'][k] for k in 'xyz'));add(name,s)
def from_plan(plan):
t=plan['type']
if t=='colorize':return from_plan(plan['shape'])
if t=='translate':return from_plan(plan['shape']).translate(tuple(plan['vector']))
if t=='rotate':
shape=from_plan(plan['shape'])
for angle,axis in zip(plan['angles'],[(1,0,0),(0,1,0),(0,0,1)]):
if angle:shape=shape.rotate((0,0,0),axis,math.degrees(angle))
return shape
if t=='sphere':return cq.Workplane('XY').sphere(plan['radius']).val()
if t=='cylinder':return cq.Workplane('XY').circle(plan['radius']).extrude(plan['height']).val().translate((0,0,-plan['height']/2))
if t=='cuboid':return cq.Workplane('XY').box(*plan['size']).val()
if t in ['union','subtract']:
shapes=[from_plan(q) for q in plan['shapes']];shape=shapes[0]
for other in shapes[1:]:shape=shape.fuse(other) if t=='union' else shape.cut(other)
return shape
raise AssertionError('Unreviewed mechanical CAD operation: '+t)
head_names=[];spacer_names=[]
for e in j:
if e['type']!='cad_component':continue
name=names.get(e.get('source_component_id'),'')
if not name.startswith('PROPOSED_'):continue
assert e['rotation']==dict(x=0,y=0,z=0) and e['model_unit_to_mm_scale_factor']==1
x,y,z=(e['position'][a] for a in 'xyz');assert (x,y) in set(itertools.product([-13,13],repeat=2)) and z==0
shape=from_plan(e['model_jscad']).translate((x,y,z))
if name.startswith('PROPOSED_M3_SCREW'):
head_names.append(name);expected=[x-2,y-2,-5.2,x+2,y+2,3.8]
else:
assert name.startswith('PROPOSED_SPACER');spacer_names.append(name);expected=[x-2.5,y-2.5,-6.8,x+2.5,y+2.5,-.8]
assert all(abs(a-b)<1e-5 for a,b in zip(bb(shape),expected)), 'Actual assembly hardware envelope changed'
add(name,shape)
assert len(head_names)==len(spacer_names)==4
for x,y in itertools.product([-13,13],repeat=2):
add(f'DRIVER_{x}_{y}',cq.Workplane('XY').circle(3).extrude(25).val().translate((x,y,3.8)))
# Body envelopes are over-approximations, not a rear-endcap drawing.
add('MOTOR_BODY_ENVELOPE',cq.Workplane('XY').box(35.2,35.2,28.2).val().translate((0,0,-20.9)))
pcb=cq.Workplane('XY').box(35,35,1.6).val()
for x,y in itertools.product([-13,13],repeat=2):pcb=pcb.cut(cq.Workplane('XY').circle(1.6).extrude(3,both=True).val().translate((x,y,0)))
add('PCB',pcb)
m={'mounts':[[x,y] for x,y in itertools.product([-13,13],repeat=2)]}
for name in ['J_PD','J_DATA']:
b=bounds[name];x,y,z=(b[0]+b[3])/2,(b[1]+b[4])/2,b[5]
add(name+'_PLUG_ENVELOPE',cq.Workplane('XY').box(10,7,30).val().translate((x,y,z+14.5)))
plug=next(e for e in j if e['type']=='cad_component' and names.get(e.get('source_component_id'))=='JST_SHR_04V_S_PLUG')
shape=from_plan(plug['model_jscad'])
for angle,axis in zip([plug['rotation'][k] for k in 'xyz'],[(1,0,0),(0,1,0),(0,0,1)]):
if angle:shape=shape.rotate((0,0,0),axis,angle)
shape=shape.translate(tuple(plug['position'][k] for k in 'xyz'))
add('JST_MATING_HOUSING',shape)
checks=[];collisions=[]
for name in head_names+spacer_names+['JST_MATING_HOUSING']:
for other in parts:
if name==other or (name=='JST_MATING_HOUSING' and other=='J_MOTOR'):continue
if name.startswith('PROPOSED_SPACER') and other=='MOTOR_BODY_ENVELOPE':continue # intended contact; retention explicitly unverified
if name.startswith('PROPOSED_M3_SCREW') and other.startswith('DRIVER'):continue
if broad(bounds[name],bounds[other]):
v=max(0,parts[name].intersect(parts[other]).Volume())
if v>1e-5:collisions.append({'part':name,'other':other,'intersection_mm3':v})
minimum=math.inf;segments=[]
for wire in harness['wires']:
for index,(a,b) in enumerate(zip(wire['path'],wire['path'][1:])):
radius=wire['segment_radii_mm'][index]
aa=[a[k] for k in 'xyz'];ab=[b[k] for k in 'xyz'];edge=cq.Edge.makeLine(cq.Vector(*aa),cq.Vector(*ab));eb=bb(edge)
segments.append((wire['pin'],index,edge,eb,radius))
for other,s in parts.items():
# Only the short factory feedthrough enters the simplified case envelope.
if other=='MOTOR_BODY_ENVELOPE' and index==0:
assert abs(aa[0])<3 and 17<aa[1]<18 and ab[1]>18
continue
lo=lower(eb,bounds[other])
if lo>radius+1:continue
d=edge.distance(s);assert d+1e-5>=lo,'OCCT distance contradicts bounding-box lower bound'
minimum=min(minimum,d-radius)
if d<radius-1e-5:collisions.append({'pin':wire['pin'],'wire_segment':index,'other':other,'centreline_distance_mm':d,'wire_radius_mm':radius})
splice=wire['splice'];a,b=splice['from'],splice['to'];edge=cq.Edge.makeLine(cq.Vector(*(a[k] for k in 'xyz')),cq.Vector(*(b[k] for k in 'xyz')))
segments.append((wire['pin'],'splice',edge,bb(edge),splice['radius']))
for other,s in parts.items():
if lower(bb(edge),bounds[other])>splice['radius']+1:continue
if edge.distance(s)<splice['radius']-1e-5:collisions.append({'splice_pin':wire['pin'],'other':other})
for (pin,i,a,ab,ar),(pin2,i2,b,bb2,br) in itertools.combinations(segments,2):
if pin==pin2 or lower(ab,bb2)>=ar+br:continue
if a.distance(b)<ar+br-1e-5:collisions.append({'wire_to_wire':[pin,i,pin2,i2]})
report={'passed':not collisions,'board_sha256':hashlib.sha256(native).hexdigest(),'assembly_sha256':hashlib.sha256((root/'assembly.circuit.json').read_bytes()).hexdigest(),'fitted_supplier_STEP_parts':len([e for e in j if e['type']=='cad_component' and e.get('model_step_url') and e.get('source_component_id') not in dnp]),'conservative_USB_STEP_envelopes':conservative,'wire_entry_holes_checked_against_actual_exposed_housing':True,'wire_segments':len(segments),'nearby_wire_envelope_min_clearance_mm':None if minimum==math.inf else minimum,'collisions':collisions,'PCB_records_preserved':j[:len(json.loads(native))]==json.loads(native),'actual_hardware_JSCAD_geometry_and_placements_checked':True,'screw_centres_mm':m['mounts'],'screw_shaft_to_spacer_bore_radial_clearance_mm':.1,'spacer_to_PCB_surface_gap_mm':0,'spacer_to_model_motor_surface_gap_mm':0,'motor_rear_attachment_verified':False,'physical_assembly_tested':False,'limitations':['Rear retention/thread engagement, rear shaft and encoder magnet/airgap remain unverified.','Factory wire diameter and actual splice procurement require a sample; the drawing specifies bare 300 +/-10 mm leads but no gauge.','Only the exposed stock housing outline is rendered; the concealed inserted stem and contact/latch tolerances are not modeled.','Defined plug/tool/head dimensions are procurement bounds; real parts require qualification.','Bottom encoder and its capacitors are DNP and excluded from fitted-part collision checks.']}
(root/'artifacts/top-assembly/assembly-fit-verification.json').write_text(json.dumps(report,indent=2)+'\n');print(json.dumps(report,indent=2));raise SystemExit(bool(collisions))