"""Preserve authored actions; solve floor clearance for the transferred head/tail shape."""
import bpy,sys,json,math,numpy as np
from pathlib import Path
from mathutils import Vector,Matrix
P=Path('/opt/creature-lib-20261017');kind=sys.argv[sys.argv.index('--')+1];D=P/'realistic'/kind;variant='v8' if kind=='fox' and (D/'tail-v7.glb').exists() else 'v6'
bpy.ops.wm.read_factory_settings(use_empty=True);bpy.context.scene.render.fps=30
bpy.ops.import_scene.gltf(filepath=str(D/('tail-v7.glb' if variant=='v8' else 'planted-v5.glb')))
rig=next(o for o in bpy.context.scene.objects if o.type=='ARMATURE');meshes=[o for o in bpy.context.scene.objects if o.type=='MESH' and any(m.type=='ARMATURE' for m in o.modifiers)]
for t in list(rig.animation_data.nla_tracks):rig.animation_data.nla_tracks.remove(t)
entry=json.loads((D/'entry-v5.json').read_text());actions=list(bpy.data.actions);logs={}
# Only transfer extremities, never treat importer's hidden bone-shape Icosphere as anatomy.
selections={}
for name in ['Head','Tail1']:
 selections[name]=[]
 for o in meshes:
  vg=o.vertex_groups.get(name)
  if vg:
   ids=[v.index for v in o.data.vertices if any(g.group==vg.index and g.weight>.85 for g in v.groups)]
   if ids:selections[name].append((o,ids))
def points():
 out={}
 for o in meshes:
  ev=o.evaluated_get(bpy.context.evaluated_depsgraph_get());me=ev.to_mesh();a=np.empty(len(me.vertices)*3,dtype=np.float64);me.vertices.foreach_get('co',a);a=a.reshape(-1,3);mat=np.array(ev.matrix_world);out[o]=a@mat[:3,:3].T+mat[:3,3];ev.to_mesh_clear()
 return out
def mats_to_channels(mats):
 out={}
 for pb in rig.pose.bones:
  kw={'parent_matrix':mats[pb.parent.name],'parent_matrix_local':pb.parent.bone.matrix_local} if pb.parent else {}
  out[pb.name]=pb.bone.convert_local_to_pose(mats[pb.name],pb.bone.matrix_local,invert=True,**kw).decompose()
 return out
angles=[];rots=[]
for x in range(-90,91,10):
 for y in range(-90,91,10):
  if x*x+y*y>100**2:continue
  R=Matrix.Rotation(math.radians(y),3,'Y')@Matrix.Rotation(math.radians(x),3,'X');angles.append((x,y));rots.append(np.array(R))
rots=np.array(rots);zrows=rots[:,2,:];cost=np.array([x*x+y*y for x,y in angles]);inverse=rig.matrix_world.inverted()
for source in actions:
 name=source.name;start,end=map(round,source.frame_range);baked=[];log=[]
 for frame in range(start,end+1):
  rig.animation_data.action=source;rig.animation_data.action_slot=source.slots[0];bpy.context.scene.frame_set(frame);bpy.context.view_layer.update();changes={}
  for bone in ['Tail1','Head']:
   if bone=='Head' and name!='Death':continue
   if bone=='Tail1' and kind not in ['fox','dog','horse']:continue
   if not selections[bone]:continue
   ps=points();cloud=np.concatenate([ps[o][ids] for o,ids in selections[bone]])
   if cloud[:,2].min()>=-.002:continue
   pb=rig.pose.bones[bone];world=rig.matrix_world@pb.matrix;pivot=world.translation.copy();relative=cloud-np.array(pivot)
   mins=(zrows@relative.T).min(axis=1)+pivot.z
   valid=mins>=.003
   score=np.where(valid,cost,1e6+np.maximum(0,.003-mins)*1e7+cost)
   idx=int(np.argmin(score));x,y=angles[idx]
   # Refine amount continuously along the selected swing direction, reducing grid snapping.
   if valid[idx]:
    low=0.;high=1.
    for _ in range(12):
     t=(low+high)/2;R=Matrix.Rotation(math.radians(y*t),3,'Y')@Matrix.Rotation(math.radians(x*t),3,'X');mn=(relative@np.array(R)[2,:]).min()+pivot.z
     if mn>=.003:high=t
     else:low=t
    x*=high;y*=high
   R=(Matrix.Rotation(math.radians(y),4,'Y')@Matrix.Rotation(math.radians(x),4,'X'))
   pb.matrix=inverse@Matrix.Translation(pivot)@R@Matrix.Translation(-pivot)@world;bpy.context.view_layer.update();changes[bone]={'swing_degrees':[x,y],'before_min':float(cloud[:,2].min())}
  # A dead animal rests on the lowest part of its real shape, not the donor's silhouette.
  ps=points();minimum=min(a[:,2].min() for a in ps.values());lift=max(0,-minimum+.002) if name=='Death' else 0.
  if lift>.0005:
   shift=inverse.to_3x3()@Vector((0,0,lift));mats={b.name:b.matrix.copy() for b in rig.pose.bones}
   for m in mats.values():m.translation+=shift
   changes['root_lift_m']=float(lift)
  else:mats={b.name:b.matrix.copy() for b in rig.pose.bones}
  baked.append(mats_to_channels(mats));log.append({'frame':frame,**changes})
 source.name='Prior_'+name;new=bpy.data.actions.new(name);rig.animation_data.action=new
 for idx,frame in enumerate(range(start,end+1)):
  for pb in rig.pose.bones:
   loc,rot,sca=baked[idx][pb.name];pb.rotation_mode='QUATERNION';pb.location=loc;pb.rotation_quaternion=rot;pb.scale=sca
   pb.keyframe_insert('location',frame=frame,group=pb.name);pb.keyframe_insert('rotation_quaternion',frame=frame,group=pb.name);pb.keyframe_insert('scale',frame=frame,group=pb.name)
 bpy.data.actions.remove(source);logs[name]=log;print('CLEARANCE',kind,name,'frames',len(log),'rootlift',max(x.get('root_lift_m',0) for x in log),flush=True)
rig.animation_data.action=bpy.data.actions[entry['clips']['idle']];rig.animation_data.action_slot=rig.animation_data.action.slots[0];bpy.context.scene.frame_set(0);bpy.context.view_layer.update()
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)
out=D/('clearance-'+variant+'.glb');assert not out.exists()
bpy.ops.export_scene.gltf(filepath=str(out),export_format='GLB',use_selection=True,export_yup=True,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_image_format='JPEG',export_jpeg_quality=88)
entry['file']='../realistic/'+kind+'/clearance-'+variant+'.glb';(D/('entry-'+variant+'.json')).write_text(json.dumps(entry,indent=2));(D/('clearance-'+variant+'.json')).write_text(json.dumps(logs,indent=2))
