"""Generated texture mesh -> matched CC0 rig. Candidate only, never overwrites a delivered GLB."""
import bpy,bmesh,sys,json,math,numpy as np
from pathlib import Path
from mathutils import Vector,Matrix
from mathutils.kdtree import KDTree
D=Path(sys.argv[sys.argv.index('--')+1]);kind=sys.argv[sys.argv.index('--')+2];P=D
sys.path.insert(0,str(Path(__file__).parent))
bpy.ops.wm.read_factory_settings(use_empty=True);bpy.context.scene.render.fps=30
bpy.ops.import_scene.gltf(filepath=str(D/'raw.glb'))
real=next(o for o in bpy.context.scene.objects if o.type=='MESH')
coords=np.array([list(real.matrix_world@v.co) for v in real.data.vertices]);cen=coords[:,:2].mean(axis=0);eigval,eigvec=np.linalg.eigh(np.cov(coords[:,:2].T));forward=eigvec[:,-1]
headxy=coords[coords[:,2]>np.percentile(coords[:,2],96),:2].mean(axis=0)
if np.dot(headxy-cen,forward)<0:forward=-forward
ang=-math.pi/2-math.atan2(forward[1],forward[0]);rot=Matrix.Rotation(ang,4,'Z');real.parent=None;real.matrix_world=Matrix.Identity(4)
for v,co in zip(real.data.vertices,coords):v.co=rot@Vector(co)
entry=json.loads((D/'entry.json').read_text());height=entry['height_m']
lo=Vector([min(v.co[i] for v in real.data.vertices) for i in range(3)]);hi=Vector([max(v.co[i] for v in real.data.vertices) for i in range(3)])
scale=height/(hi.z-lo.z);offset=Vector((-(lo.x+hi.x)/2,-(lo.y+hi.y)/2,-lo.z))
for v in real.data.vertices:v.co=(v.co+offset)*scale
real.name='Realistic'+kind.title();real.data.name=real.name+'Mesh'
for p in real.data.polygons:p.use_smooth=True
for im in bpy.data.images:
 if im.type=='IMAGE' and im.size[0]>1024:im.scale(1024,1024);im.pack()
# Generated metal maps sometimes label fur as solid metal. Fur is dielectric.
for mat in real.data.materials:
 if mat and mat.use_nodes:
  bs=mat.node_tree.nodes.get('Principled BSDF')
  if bs:
   for socket,value in [('Metallic',0.),('Roughness',.86)]:
    for link in list(bs.inputs[socket].links):mat.node_tree.links.remove(link)
    bs.inputs[socket].default_value=value
bm=bmesh.new();bm.from_mesh(real.data);bad=[f for f in bm.faces if f.calc_area()<1e-14]
if bad:bmesh.ops.delete(bm,geom=bad,context='FACES_ONLY');bm.to_mesh(real.data)
bm.free()
bpy.ops.import_scene.gltf(filepath=str(D/'source.glb'))
rig=next(o for o in bpy.context.scene.objects if o.type=='ARMATURE');donors=[o for o in bpy.context.scene.objects if o.type=='MESH' and o!=real and (any(m.type=='ARMATURE' for m in o.modifiers) or o.parent_type=='BONE')]
rig.data.pose_position='REST';rig.animation_data.action=None
for t in rig.animation_data.nla_tracks:t.mute=True
bpy.context.view_layer.update()
pts=[];weights=[]
for donor in donors:
 for v in donor.data.vertices:
  pts.append(donor.matrix_world@v.co)
  ww={donor.vertex_groups[g.group].name:g.weight for g in v.groups if g.weight>1e-6}
  if not ww:ww={donor.parent_bone if donor.parent_type=='BONE' else 'Head':1.}
  weights.append(ww)
# Four paw clusters, split in front/back by low-foot centre instead of torso bbox.
def paws(vs):
 low=vs[vs[:,2]<height*(.055 if kind in ['stag','deer','fox'] else .22)];split=(np.percentile(low[:,1],10)+np.percentile(low[:,1],90))/2;xsplit=0 if kind in ['stag','deer','fox'] else (np.percentile(low[:,0],10)+np.percentile(low[:,0],90))/2
 output={}
 for xs in [-1,1]:
  for ys in [-1,1]:
   sub=low[((low[:,0]-xsplit)*xs>0)&((low[:,1]-split)*ys>0)]
   if len(sub)<3:raise ValueError('Cannot separate four paws: '+str((xs,ys,len(sub))))
   if kind not in ['stag','deer','fox']:sub=sub[sub[:,2]<sub[:,2].min()+height*.055]
   output[f'{xs},{ys}']=sub.mean(axis=0).tolist()
 return output

# Resolve the forward-axis sign against the actual donor surface, not the
# highest point (a bull's withers can be higher than its head).
d=paws(np.array([list(v) for v in pts]));donorkd=KDTree(len(pts))
for i,p in enumerate(pts):donorkd.insert(p,i)
donorkd.balance();base=np.array([list(v.co) for v in real.data.vertices]);fits=[]
for sign in (1,-1):
 candidate=base.copy();candidate[:,:2]*=sign;r=paws(candidate)
 rf=np.mean([r[f'{x},-1'][1] for x in [-1,1]]);rb=np.mean([r[f'{x},1'][1] for x in [-1,1]]);df=np.mean([d[f'{x},-1'][1] for x in [-1,1]]);db=np.mean([d[f'{x},1'][1] for x in [-1,1]])
 along=(db-df)/(rb-rf);shift=df-along*rf
 rx=[np.mean([r[f'{x},{y}'][0] for y in [-1,1]]) for x in [-1,1]];dx=[np.mean([d[f'{x},{y}'][0] for y in [-1,1]]) for x in [-1,1]];across=(dx[1]-dx[0])/(rx[1]-rx[0]);xshift=np.mean(dx)-np.mean(rx)*across
 candidate[:,1]=candidate[:,1]*along+shift
 blend=np.clip((.60*height-candidate[:,2])/(.30*height),0,1);candidate[:,0]=candidate[:,0]*(1+blend*(across-1))+blend*xshift
 distances=[donorkd.find(Vector(p))[2] for p in candidate[::max(1,len(candidate)//2200)]]
 score=float(np.mean(np.minimum(distances,np.percentile(distances,95))))
 fits.append(dict(sign=sign,score=score,coords=candidate,paws=r,along=along,shift=shift,across=across,xshift=xshift))
best=min(fits,key=lambda f:f['score']);r=best['paws'];along=best['along'];shift=best['shift'];across=best['across'];xshift=best['xshift']
for v,co in zip(real.data.vertices,best['coords']):v.co=co
orientation_scores={str(f['sign']):f['score'] for f in fits}
from fragments import clean
fragment_fit,_=clean([real],rig)
(D/'fragments-fit.json').write_text(json.dumps(fragment_fit,indent=2))
if not fragment_fit['pass']:raise ValueError('Detached body islands before fitting paws')
# A gap-safe reference deliberately staggers the legs. Match each foot to the
# donor separately before skin transfer; global body scaling cannot undo that.
matched=paws(np.array([list(v.co) for v in real.data.vertices]));paw_shifts={}
for key in matched:
 delta=Vector(d[key])-Vector(matched[key]);delta.z=0;paw_shifts[key]=delta
split_y=(np.mean([matched[f'{x},-1'][1] for x in [-1,1]])+np.mean([matched[f'{x},1'][1] for x in [-1,1]]))/2
# Preserve each paw as a whole even when its toes cross the centre plane.
# A global left/right coordinate split stretches those toes into sheets.
low={v.index for v in real.data.vertices if v.co.z<height*.25};adj={i:set() for i in low}
for edge in real.data.edges:
 a,b=edge.vertices
 if a in low and b in low:adj[a].add(b);adj[b].add(a)
groups=[];pending=set(low)
while pending:
 i=pending.pop();group={i};stack=[i]
 while stack:
  for j in adj[stack.pop()]:
   if j in pending:pending.remove(j);group.add(j);stack.append(j)
 if len(group)>10 and min(real.data.vertices[i].co.z for i in group)<height*.12:groups.append(group)
leg_ids={};centres={}
for group in groups:
 center=sum((real.data.vertices[i].co for i in group),Vector())/len(group)
 key=f'{1 if center.x>0 else -1},{1 if center.y>split_y else -1}'
 if key in centres and (centres[key]-center).length>height*.12:raise ValueError('Ambiguous lower-leg regions: '+key)
 centres[key]=center
 for i in group:leg_ids[i]=key
if len(centres)!=4:raise ValueError('Need four disconnected lower-leg regions before binding')
for v in real.data.vertices:
 if v.index in leg_ids:delta=paw_shifts[leg_ids[v.index]]
 else:
  warpweights={k:1/max((v.co-c).length,height*.04)**6 for k,c in centres.items()};den=sum(warpweights.values())
  delta=sum((paw_shifts[k]*w/den for k,w in warpweights.items()),Vector())
 blend=max(0.,min(1.,(.48*height-v.co.z)/(.18*height)));blend=blend*blend*(3-2*blend)
 v.co+=delta*blend

# A raised bull/horse tail must not determine the scale of its whole body.
tailbones=[b for b in rig.data.bones if b.name.lower().startswith('tail')]
rootbone=next((b for b in tailbones if b.parent not in tailbones),None)
if rootbone:
 rootpoint=rig.matrix_world@rootbone.head_local
 last=next((b for b in tailbones if not any(c in tailbones for c in b.children)),None)
 if last and (rig.matrix_world@last.tail_local).z<rootpoint.z-height*.2:
  bodytop=max(v.co.z for v in real.data.vertices if v.co.y<rootpoint.y+height*.04)
  if bodytop<height*.97:
   for v in real.data.vertices:v.co.z*=height/bodytop
from tail_pose import conform
tail_pose=conform(real,rig,height)
(D/'tail-pose.json').write_text(json.dumps(tail_pose,indent=2))
kd=KDTree(len(pts))
for i,p in enumerate(pts):kd.insert(p,i)
kd.balance();bonepos={b.name:{'head':rig.matrix_world@b.head_local,'tail':rig.matrix_world@b.tail_local} for b in rig.data.bones}
for vg in list(real.vertex_groups):real.vertex_groups.remove(vg)
for b in rig.data.bones:real.vertex_groups.new(name=b.name)
diststats=[];rigid_antlers=0
rigid_attr=real.data.attributes.new('_HEAD_RIGID','FLOAT','POINT')
for v in real.data.vertices:
 nearest=kd.find_n(v.co,8);ww={};den=0
 for p,idx,dist in nearest:
  fac=1/max(dist,height*.008)**2;den+=fac
  for name,w in weights[idx].items():ww[name]=ww.get(name,0)+w*fac
 ww={k:w/den for k,w in ww.items()}
 # Prevent cross-leg influence, especially at narrow hocks and under the belly.
 if v.co.z<height*.47:
  side='.L' if v.co.x>0 else '.R'
  side_candidates={k:w for k,w in ww.items() if not k.endswith(('.L','.R')) or k.endswith(side)}
  if sum(side_candidates.values())>.1:ww=side_candidates
 # Every antler vertex, including its root, follows Head rigidly.
 if kind in ['stag','bull','cow'] and v.co.z>height*(.755 if kind=='stag' else .85) and (kind=='stag' or (v.co-bonepos['Head']['head']).length<height*.30):ww={'Head':1.};rigid_antlers+=1;rigid_attr.data[v.index].value=1.
 # Tail corridor behind rump, isolated from leg joints. Short deer tails have no such corridor.
 tail=bonepos.get('Tail1')
 if kind in ['fox','dog','horse'] and tail:
  root=tail['head'];limit=root.y+height*.06
  if v.co.y>limit and abs(v.co.x)<height*.20:
   blend=min(1.,max(0.,(v.co.y-limit)/(height*.08)));ww={k:w*(1-blend) for k,w in ww.items()};ww['Tail1']=ww.get('Tail1',0)+blend
 top=[(k,w) for k,w in sorted(ww.items(),key=lambda x:-x[1]) if k in real.vertex_groups and w>1e-6][:4];total=sum(w for _,w in top)
 if not total:raise ValueError('Unweighted vertex')
 for name,w in top:real.vertex_groups[name].add([v.index],w/total,'REPLACE')
 diststats.append(nearest[0][2])
mod=real.modifiers.new('QuadrupedSkin','ARMATURE');mod.object=rig
world=real.matrix_world.copy();real.parent=rig;real.matrix_world=world
for donor in list(bpy.context.scene.objects):
 if donor.type=='MESH' and donor!=real:bpy.data.objects.remove(donor,do_unlink=True)
rig.data.pose_position='POSE';actions={a.name:a for a in bpy.data.actions};rig.animation_data.action=actions['Idle'];rig.animation_data.action_slot=actions['Idle'].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','MESH','EMPTY']:o.select_set(True)
version='initial';out=D/'bound.glb';assert not out.exists(),out
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)
entry.update(id=entry['id'],file=out.name,style='realistic',tint=[],speeds={})
(D/'bound-entry.json').write_text(json.dumps(entry,indent=2));(D/'fit.json').write_text(json.dumps({'rotation_z':ang,'orientation_scores':orientation_scores,'orientation_sign':best['sign'],'scale':scale,'paws_before':r,'individual_paw_shifts':{k:list(v) for k,v in paw_shifts.items()},'donor_paws':d,'length_scale':along,'side_scale':across,'forward_shift':shift,'surface_distance_p50':float(np.percentile(diststats,50)),'surface_distance_p95':float(np.percentile(diststats,95)),'antler_vertices_rigid':rigid_antlers,'bones':{n:{k:list(v) for k,v in val.items()} for n,val in bonepos.items()}},indent=2));print('BOUND',kind,out.stat().st_size,'fit',along,across,'distance',np.percentile(diststats,[50,95]),flush=True)
