"""Delete small disconnected debris; retain only near-attached head details.
Uses welded topology so UV/material seams do not count as detached islands.
No largest-component-only shortcut: a large detached limb fails the gate.
"""
import json,sys
from pathlib import Path

def clean(meshes,rig):
 import bpy,bmesh,numpy as np
 from mathutils.kdtree import KDTree
 from mathutils import Vector
 from mathutils.bvhtree import BVHTree
 points=[o.matrix_world@v.co for o in meshes for v in o.data.vertices]
 height=max(p.z for p in points)-min(p.z for p in points)
 head=rig.matrix_world@rig.data.bones['Head'].head_local
 records=[]; failures=[]
 # Join all visible skin meshes before connectivity analysis, preserving UVs.
 bpy.ops.object.select_all(action='DESELECT')
 for o in meshes:o.select_set(True)
 bpy.context.view_layer.objects.active=meshes[0]
 if len(meshes)>1:bpy.ops.object.join()
 o=meshes[0];bm=bmesh.new();bm.from_mesh(o.data)
 bmesh.ops.remove_doubles(bm,verts=list(bm.verts),dist=height*1e-6)
 unseen=set(bm.verts);groups=[]
 while unseen:
  seed=unseen.pop();group={seed};stack=[seed]
  while stack:
   v=stack.pop()
   for e in v.link_edges:
    n=e.other_vert(v)
    if n in unseen:unseen.remove(n);group.add(n);stack.append(n)
  groups.append(group)
 groups.sort(key=lambda g:sum(f.calc_area() for f in {f for v in g for f in v.link_faces}),reverse=True);main=groups[0];bodyfaces={f for v in main for f in v.link_faces};bodyarea=sum(f.calc_area() for f in bodyfaces)
 kd=KDTree(len(main))
 for i,v in enumerate(main):kd.insert(o.matrix_world@v.co,i)
 kd.balance();removed=[]
 mainverts=list(main);indices={v:i for i,v in enumerate(mainverts)}
 surface=BVHTree.FromPolygons([o.matrix_world@v.co for v in mainverts],[[indices[v] for v in f.verts] for f in bodyfaces],all_triangles=False)
 def inside(p):
  # Deterministic ray parity, offset after each hit to avoid double-counting edges.
  direction=Vector((.873,.331,.357)).normalized();origin=Vector(p);hits=0
  for _ in range(100):
   hit,normal,index,dist=surface.ray_cast(origin,direction,height*10)
   if hit is None:return hits%2==1
   hits+=1;origin=hit+direction*height*1e-6
  return False
 for g in groups[1:]:
  faces={f for v in g for f in v.link_faces};area=sum(f.calc_area() for f in faces)
  pts=np.array([list(o.matrix_world@v.co) for v in g]);span=float(np.linalg.norm(pts.max(0)-pts.min(0)))
  center=pts.mean(0);eigen=np.linalg.eigvalsh(np.cov(pts.T)) if len(pts)>3 else np.zeros(3)
  thin=bool(eigen[0]<max(eigen[-1],1e-12)*.002)
  bodydist=min(surface.find_nearest(Vector(p))[3] for p in pts)
  head_distance=float(np.linalg.norm(center-np.array(head)))
  head_detail=head_distance<height*.35 and bodydist<height*.025
  small=area<bodyarea*.008 or span<height*.04
  shard=thin and area<bodyarea*.025 and span<height*.3
  enclosed=all(inside(p) or surface.find_nearest(Vector(p))[3]<height*.0005 for p in pts) if not head_detail and not small and not shard else False
  action='remove-enclosed-shell' if enclosed else 'keep-head-detail' if head_detail else 'remove' if small or shard else 'FAIL-detached'
  record={'vertices':len(g),'faces':len(faces),'area_fraction':area/max(bodyarea,1e-12),'span_m':span,'thin':thin,'body_gap_m':bodydist,'head_distance_m':head_distance,'enclosed':enclosed,'enclosure_tolerance_m':height*.0005,'action':action};records.append(record)
  if action.startswith('remove'):removed.extend(g)
  elif action.startswith('FAIL'):failures.append(record)
 if removed:bmesh.ops.delete(bm,geom=removed,context='VERTS')
 loose=[v for v in bm.verts if not v.link_faces]
 if loose:bmesh.ops.delete(bm,geom=loose,context='VERTS')
 bm.to_mesh(o.data);bm.free();o.data.update()
 return {'pass':not failures,'height_m':height,'components_before':len(groups),'removed_vertices':len(removed),'components':records,'remaining_detached_non_head':len(failures),'head_exception':'within 0.35 height of Head and 0.025 height of body surface','scope':'Disconnected islands and small thin disconnected shards, not fused membranes'},[o]

def main():
 import bpy
 src,out,report=map(Path,sys.argv[sys.argv.index('--')+1:]);assert not out.exists()
 bpy.ops.wm.read_factory_settings(use_empty=True);bpy.context.scene.render.fps=30;bpy.ops.import_scene.gltf(filepath=str(src))
 rig=next(o for o in bpy.context.scene.objects if o.type=='ARMATURE');rig.data.pose_position='REST'
 meshes=[o for o in bpy.context.scene.objects if o.type=='MESH' and any(m.type=='ARMATURE' for m in o.modifiers)]
 data,meshes=clean(meshes,rig);report.write_text(json.dumps(data,indent=2))
 if not data['pass']:raise ValueError('Large detached body island remains')
 rig.data.pose_position='POSE';bpy.ops.object.select_all(action='DESELECT')
 for o in bpy.context.scene.objects:
  if o.type in ['ARMATURE','EMPTY'] or o in meshes:o.select_set(True)
 bpy.ops.export_scene.gltf(filepath=str(out),export_format='GLB',use_selection=True,export_yup=True,export_image_format='JPEG',export_jpeg_quality=86,export_animations=True,export_animation_mode='ACTIONS',export_anim_single_armature=True,export_force_sampling=True,export_optimize_animation_size=True,export_influence_nb=4,export_all_influences=False,export_leaf_bone=False,export_attributes=True)
 print(json.dumps(data))
if __name__=='__main__':main()
