"""Read-only saved-scene QA. Run Blender --background FINAL.blend --python this.py. Does not save the scene or validate electrical, thermal, optical or human safety. """ import bpy,bmesh,json,math,hashlib from pathlib import Path from collections import Counter from mathutils import Vector from mathutils.bvhtree import BVHTree ROOT=Path(__file__).resolve().parents[1] EXPECTED={'E01':{'heater':2,'heater_backing':2,'temperature_sensor':4,'haptic_module':2},'E02':{'led_cartridge':2,'led_package':24},'E03':{'electrode_placeholder':4,'electrode_backing':2},'E04':{'heater':2,'heater_backing':2,'temperature_sensor':4,'haptic_module':2}} CONTROL={'controller_back':1,'controller_front':1,'battery_envelope':1,'control_pcb':1,'display_window':1,'button':1,'keyed_connector':1,'charge_port':1,'liner_connector':1,'external_cable':1,'internal_harness':2} RAY_DIRECTIONS=[Vector(x).normalized() for x in [(1,.371,.217),(-.231,1,.413),(.173,-.317,1)]] def point_inside_votes(bvh,point): votes=[] for direction in RAY_DIRECTIONS: origin=point.copy();crossings=0 for _ in range(256): hit,normal,index,distance=bvh.ray_cast(origin,direction,100) if hit is None:break crossings+=1;origin=hit+direction*1e-5 else:raise RuntimeError('ray parity did not converge') votes.append(crossings%2==1) return votes for col in bpy.data.collections:col.hide_viewport=False bpy.context.view_layer.update();deps=bpy.context.evaluated_depsgraph_get() blend=Path(bpy.data.filepath) result={'blender_version':bpy.app.version_string,'blend_file':blend.name,'blend_sha256':hashlib.sha256(blend.read_bytes()).hexdigest(),'unit_mm':100,'scope':'Evaluated object topology and nearest-surface geometric clearance against the corresponding static mannequin. Mesh vertices checked in full; curves sampled at up to roughly 3000 vertices. Curve cap duplicates welded at 1e-7 model units in the QA copy only, matching export. All changes occur in memory; source blend is never saved.','limitations':['Controller and electronic components are mechanical envelopes, not manufactured assemblies.','Nearest-face signed distance is a static geometry diagnostic, not collision volume, contact pressure, electrical path or human safety verification.','Geometric pass does not establish heating, optical dose, pulse output, charging interlock or independent protection.'],'models':[],'errors':[]} for cid in EXPECTED: for fid in ['F','M']: code=cid+'_'+fid;objs=[o for o in bpy.data.objects if o.get('concept_id')==code] counts=Counter(o.get('component_role') or 'untagged' for o in objs) heads=[o for o in objs if o.get('component_role')=='mannequin' and '可编辑网格' in o.name] entry={'id':code,'roles':dict(counts),'expected_module_counts':{**CONTROL,**EXPECTED[cid]},'module_counts_match':True,'parts':[]} for role,n in entry['expected_module_counts'].items(): if counts[role]!=n:entry['module_counts_match']=False;result['errors'].append(f'{code}: {role} count {counts[role]} != {n}') if len(heads)!=1: result['errors'].append(f'{code}: expected one head mesh, found {len(heads)}');result['models'].append(entry);continue if cid=='E03': entry['electrode_metadata_output_locked']=all(o.get('output_enabled') is False for o in objs if o.get('component_role')=='electrode_placeholder') if not entry['electrode_metadata_output_locked']:result['errors'].append(f'{code}: an electrode placeholder lacks output-disabled metadata') head=heads[0];bvh=BVHTree.FromPolygons([v.co for v in head.data.vertices],[list(p.vertices) for p in head.data.polygons]);inv=head.matrix_world.inverted() hb=bmesh.new();hb.from_mesh(head.data);entry['head_nonmanifold_edges']=sum(not e.is_manifold for e in hb.edges);hb.free() if entry['head_nonmanifold_edges']:result['errors'].append(f'{code}: head not closed, parity validation invalid') for ob in objs: role=ob.get('component_role') if role in ['mannequin','display'] or ob.type not in ['MESH','CURVE']:continue ev=ob.evaluated_get(deps);me=ev.to_mesh();mx=inv@ob.matrix_world;coords=[mx@v.co for v in me.vertices] bm=bmesh.new();bm.from_mesh(me) if ob.type=='CURVE':bmesh.ops.remove_doubles(bm,verts=list(bm.verts),dist=1e-7) part={'name':ob.name,'role':role,'vertices':len(coords),'faces':len(me.polygons),'finite':all(math.isfinite(t) for v in coords for t in v),'boundary_edges':sum(e.is_boundary for e in bm.edges),'nonmanifold_edges':sum(not e.is_manifold for e in bm.edges),'zero_area_faces':sum(f.calc_area()<1e-12 for f in bm.faces)};bm.free() if not coords:result['errors'].append(f'{ob.name}: empty evaluated geometry');entry['parts'].append(part);ev.to_mesh_clear();continue part['bbox_mm']=[[round(f(v[i] for v in coords)*100,3) for i in range(3)] for f in [min,max]] part['max_edge_mm']=round(max(((coords[e.vertices[0]]-coords[e.vertices[1]]).length for e in me.edges),default=0)*100,3) stride=1 if ob.type=='MESH' else max(1,len(coords)//3000);ds=[];candidates=[];confirmed=[];disagreements=0 for v in coords[::stride]: p,n,_,_=bvh.find_nearest(v) if p is None:result['errors'].append(f'{ob.name}: no nearest head point');continue distance=(v-p).dot(n)*100;ds.append(distance) if distance<-.2: candidates.append(distance);votes=point_inside_votes(bvh,v) if not(all(votes) or not any(votes)):disagreements+=1 if sum(votes)>=2:confirmed.append(distance) part.update(clearance_vertices_checked=len(ds),clearance_sampling_stride=stride,min_signed_clearance_mm=round(min(ds),4) if ds else None,nearest_normal_candidates_over_0_2mm=len(candidates),ray_parity_disagreements=disagreements,vertices_over_0_2mm_inside=len(confirmed),min_signed_clearance_confirmed_inside_mm=round(min(confirmed),4) if confirmed else None) entry['parts'].append(part);ev.to_mesh_clear() result['models'].append(entry) print('QA_MODEL',code,'parts',len(entry['parts']),'inside',sum(p.get('vertices_over_0_2mm_inside',0) for p in entry['parts']),'nonmanifold',sum(p['nonmanifold_edges'] for p in entry['parts']),flush=True) parts=[p for m in result['models'] for p in m['parts']] summary={'models':len(result['models']),'parts':len(parts),'all_finite':all(p['finite'] for p in parts),'all_module_counts_match':all(m['module_counts_match'] for m in result['models']),'nonmanifold_edges':sum(p['nonmanifold_edges'] for p in parts),'zero_area_faces':sum(p['zero_area_faces'] for p in parts),'nearest_normal_candidates_over_0_2mm':sum(p.get('nearest_normal_candidates_over_0_2mm',0) for p in parts),'ray_parity_disagreements':sum(p.get('ray_parity_disagreements',0) for p in parts),'vertices_over_0_2mm_inside':sum(p.get('vertices_over_0_2mm_inside',0) for p in parts),'min_signed_clearance_mm':min((p['min_signed_clearance_mm'] for p in parts if p.get('min_signed_clearance_mm') is not None),default=None)} summary['pass']=len(result['models'])==8 and bool(parts) and summary['all_finite'] and summary['all_module_counts_match'] and summary['nonmanifold_edges']==0 and summary['zero_area_faces']==0 and summary['vertices_over_0_2mm_inside']==0 and summary['ray_parity_disagreements']==0 and not result['errors'] result['inside_confirmation_method']='Nearest-normal negative distances over 0.2mm are candidates only. Each candidate is checked by three non-axis-aligned ray parity tests against a verified closed head mesh. Any direction disagreement prevents a pass. The initial nearest-normal-only report is preserved separately, and candidate counts/distances remain in this report. No threshold was loosened.' result['summary']=summary (ROOT/'source/geometry_qa.json').write_text(json.dumps(result,ensure_ascii=False,indent=2)+'\n') print(json.dumps(summary,ensure_ascii=False),flush=True) if not summary['pass']:print('QA_REQUIRES_REVIEW',flush=True)