"""Ringborke: authored surfaces, weighted skeleton and portable animation clips. Blender Z up, -Y forward. Run in a fresh background Blender process. The existing Phase 4 sources remain intact. No downloaded assets are used. """ from pathlib import Path import bpy, bmesh, math, random, json, hashlib from mathutils import Vector, Quaternion from math import sin, cos, pi P=Path(__file__).resolve().parents[2] OUT=P/'assets/characters'; OUT.mkdir(exist_ok=True) SRC=Path(__file__).resolve().parent random.seed(41) bpy.ops.wm.read_factory_settings(use_empty=True) S=.82 def coord(v): return Vector((v[0]*1.06,v[1],v[2]*.82 if v[2]<.8 else v[2]-.144))*S parts=[] def material(name, color, rough=.85, bark=False, emission=False): m=bpy.data.materials.new('RB_'+name);m.diffuse_color=(*color,1);m.use_nodes=True n=m.node_tree.nodes;l=m.node_tree.links;b=n.get('Principled BSDF');b.inputs['Base Color'].default_value=(*color,1);b.inputs['Roughness'].default_value=rough if emission: b.inputs['Emission Color'].default_value=(*color,1);b.inputs['Emission Strength'].default_value=.8 else: v=n.new('ShaderNodeVertexColor');v.layer_name='SurfaceColor';l.new(v.outputs['Color'],b.inputs['Base Color']) if name=='Cutwood': for suffix,target in [('albedo','Base Color'),('roughness','Roughness'),('normal','Normal')]: tex=n.new('ShaderNodeTexImage');tex.image=bpy.data.images.load(str(P/('assets/materials/phase11/ringborke-cut-'+suffix+'-4k.png')),check_existing=True) tex.image.colorspace_settings.name='sRGB' if suffix=='albedo' else 'Non-Color' if suffix=='normal': normal=n.new('ShaderNodeNormalMap');normal.inputs['Strength'].default_value=.6;l.new(tex.outputs['Color'],normal.inputs['Color']);l.new(normal.outputs['Normal'],b.inputs[target]) else:l.new(tex.outputs['Color'],b.inputs[target]) if bark: t=n.new('ShaderNodeTexImage');t.image=bpy.data.images.load(str(P/'assets/materials/phase10/oak-height-4k.png'),check_existing=True);t.image.colorspace_settings.name='Non-Color' bump=n.new('ShaderNodeBump');bump.inputs['Strength'].default_value=.4;bump.inputs['Distance'].default_value=.035;l.new(t.outputs['Color'],bump.inputs['Height']);l.new(bump.outputs['Normal'],b.inputs['Normal']) return m M={ 'bark':material('Bark',(.18,.105,.055),bark=True), 'heart':material('Heartwood',(.38,.23,.115),bark=True), 'cut':material('Cutwood',(.55,.35,.17)), 'dark':material('Crevice',(.052,.035,.018)), 'moss':material('Moss',(.18,.28,.052)), 'leaf':material('Leaf',(.31,.40,.085),.72), 'flower':material('Petal',(.65,.26,.36),.65), 'eye':material('Saplight',(.35,.85,.06),.5,emission=True)} def mesh(name, verts, faces, kind, bone, uv=None, colors=None, weights=None, smooth=True): me=bpy.data.meshes.new(name);me.from_pydata([coord(v) for v in verts],[],faces);me.update() ob=bpy.data.objects.new(name,me);bpy.context.collection.objects.link(ob);me.materials.append(M[kind]);parts.append(ob) for f in me.polygons:f.use_smooth=smooth tex=me.uv_layers.new(name='UVMap');col=me.color_attributes.new(name='SurfaceColor',type='FLOAT_COLOR',domain='CORNER') base=M[kind].diffuse_color[:3] for f in me.polygons: for li in f.loop_indices: i=me.loops[li].vertex_index;v=verts[i] tex.data[li].uv=uv[i] if uv else (v[0]*.7+v[1]*.3,v[2]*.8) fac=.89+.11*sin(v[0]*19+v[2]*8)+.035*cos(v[1]*71+v[2]*32) c=colors[i] if colors else tuple(max(.004,x*fac) for x in base) col.data[li].color=(*c,1) groups={} for i in range(len(verts)): ws=weights[i] if weights else {bone:1.0} total=sum(ws.values()) for key,w in ws.items(): if key not in groups:groups[key]=ob.vertex_groups.new(name=key) groups[key].add([i],w/total,'REPLACE') return ob def mixweights(t,bones): if len(bones)==1:return {bones[0]:1} t=max(0,min(.999999,t))*(len(bones)-1);i=int(t);w=t-i return {bones[i]:1-w,bones[i+1]:w} def tube(name,points,radii,kind,bones,sides=16,steps=32,flatten=1,groove=.08): """Closed organic sweep, smooth interpolated centerline, longitudinal grain relief.""" points=[Vector(x) for x in points];vs=[];uv=[];ws=[];loops=[];fs=[] def point(t): a=min(len(points)-2,int(t*(len(points)-1)));u=t*(len(points)-1)-a p0=points[max(a-1,0)];p1=points[a];p2=points[a+1];p3=points[min(a+2,len(points)-1)] return .5*((2*p1)+(-p0+p2)*u+(2*p0-5*p1+4*p2-p3)*u*u+(-p0+3*p1-3*p2+p3)*u*u*u),radii[a]*(1-u)+radii[a+1]*u for j in range(steps+1): t=j/steps;p,r=point(t);tan=(point(min(1,t+.002))[0]-point(max(0,t-.002))[0]).normalized();u=tan.cross(Vector((0,1,0))).normalized();v=tan.cross(u) loop=[] for k in range(sides): a=2*pi*k/sides;mod=1+groove*(sin(a*7+t*3)+.4*sin(a*13-t*9)) vs.append(p+r*mod*(u*cos(a)+v*sin(a)*flatten));uv.append((k/sides,t*1.7));ws.append(mixweights(t,bones));loop.append(len(vs)-1) loops.append(loop) for a,b in zip(loops,loops[1:]): for k in range(sides):q=(k+1)%sides;fs.append((a[k],a[q],b[q],b[k])) fs.extend([tuple(reversed(loops[0])),tuple(loops[-1])]) return mesh(name,vs,fs,kind,bones[0],uv,weights=ws) def ell(name,c,r,kind,bone,segments=20,rings=10): vs=[(c[0],c[1],c[2]+r[2])];fs=[] for j in range(1,rings): th=pi*j/rings for k in range(segments): a=2*pi*k/segments;f=1+.045*sin(a*5+j*1.7) vs.append((c[0]+r[0]*sin(th)*cos(a)*f,c[1]+r[1]*sin(th)*sin(a)*f,c[2]+r[2]*cos(th))) vs.append((c[0],c[1],c[2]-r[2]));end=len(vs)-1 for k in range(segments):fs.append((0,1+k,1+(k+1)%segments)) for j in range(rings-2): for k in range(segments):a=1+j*segments+k;b=1+j*segments+(k+1)%segments;fs.append((a,a+segments,b+segments,b)) for k in range(segments):fs.append((end,end-1-k,end-1-(k+1)%segments)) return mesh(name,vs,fs,kind,bone) def shield(name,c,rx,rz,depth,bone,angle=0): # Thick irregular cut log: no raised concentric torus ornaments. n=96;radial=26;vs=[];uv=[];cols=[];fs=[] def world(x,y,z):return(c[0]+x*cos(angle)-z*sin(angle),c[1]+y,c[2]+x*sin(angle)+z*cos(angle)) vs.append(world(0,-depth*.54,0));uv.append((.5,.5));cols.append((.42,.27,.135)) for j in range(1,radial+1): r=j/radial for k in range(n): a=2*pi*k/n;edge=1+.038*sin(5*a+1.1)+.028*sin(9*a-.4) rings=r*12+.10*sin(3*a+r*7)+.07*sin(7*a-r*9) grain=abs(sin(rings*pi));groove=max(0,(.22-grain)/.22) # Four narrow radial checks, restricted to the edge of the heartwood. crack=max(max(0,1-abs(math.atan2(sin(a-q),cos(a-q)))/.025) for q in [.35,2.1,3.9,5.6])*max(0,(r-.53)/.47) y=-depth*.55+.018*r*r+.008*groove+.028*crack vs.append(world(rx*r*edge*cos(a),y,rz*r*edge*sin(a)));uv.append((.5+.5*r*cos(a),.5+.5*r*sin(a))) val=(.91-.22*groove-.60*crack)*(1-.12*r) cols.append(tuple(v*val for v in (.53,.35,.185))) for k in range(n):fs.append((0,1+(k+1)%n,1+k)) for j in range(radial-1): for k in range(n):a=1+j*n+k;b=1+j*n+(k+1)%n;fs.append((a,b,b+n,a+n)) front=len(vs)-n;back=len(vs) for k in range(n): a=2*pi*k/n;e=1+.038*sin(5*a+1.1)+.028*sin(9*a-.4);vs.append(world(rx*.95*e*cos(a),depth*.5,rz*.95*e*sin(a)));uv.append((k/n,0));cols.append((.13,.08,.038)) for k in range(n):q=(k+1)%n;fs.append((front+k,front+q,back+q,back+k)) fs.append(tuple(back+k for k in reversed(range(n)))) ob=mesh(name,vs,[tuple(reversed(f)) for f in fs],'cut',bone,uv,cols) # Continuous chipped bark mantle around the log; longitudinal ridges, not beads. vv=[];ff=[];tt=[] profile=[(.94,-.56),(1.025,-.49),(1.07,-.18),(1.045,.36),(.94,.63),(.87,.42)] for j,(radius,y) in enumerate(profile): for k in range(n): a=2*pi*k/n;edge=1+.038*sin(5*a+1.1)+.028*sin(9*a-.4) ridge=1+.028*sin(a*19+.4*j)+.015*sin(a*31-.2*j) vv.append(world(rx*radius*edge*ridge*cos(a),depth*y+.008*sin(a*13),rz*radius*edge*ridge*sin(a))) tt.append((k/n*2,j/5)) for j in range(len(profile)): for k in range(n): q=(k+1)%n;h=(j+1)%len(profile);ff.append((j*n+k,j*n+q,h*n+q,h*n+k)) mesh(name+'_BarkMantle',vv,[tuple(reversed(f)) for f in ff],'bark',bone,tt) return ob def leaf(name,start,end,width,bone,kind='leaf'): a=Vector(start);b=Vector(end);d=b-a;u=d.cross(Vector((0,1,0))).normalized();v=d.normalized().cross(u);vs=[];fs=[] for side in [-1,1]: for j in range(9): t=j/8;w=width*sin(pi*t)**.7 for k in [-1,0,1]:vs.append(a+d*t+u*w*k+v*(sin(pi*t)*(.025+.028*(1-abs(k))))+Vector((0,side*.003,0))) for layer in range(2): for j in range(8): for k in range(2): i=layer*27+j*3+k;quad=(i,i+1,i+4,i+3);fs.append(quad if layer else tuple(reversed(quad))) for j in range(8): i=j*3;fs.extend([(i,i+3,i+30,i+27),(i+2,i+29,i+32,i+5)]) fs.extend([(0,27,28,29,2,1),(24,25,26,53,52,51)]) return mesh(name,vs,fs,kind,bone) # Thick connected trunk and flowing, softly weighted fibres around moving joints. tube('Trunk',[(0,.08,.70),(-.02,.08,1.03),(0,.05,1.42),(0,.06,1.69)],[.28,.36,.46,.30],'bark',['Pelvis','Spine','Chest'],32,52,flatten=.77) for k in range(11): a=k*2*pi/11 tube('Trunk_Rib_%02d'%k,[(.23*cos(a),.08+.22*sin(a),.73),(.30*cos(a+.12),.03+.29*sin(a+.12),1.08),(.41*cos(a-.10),.03+.31*sin(a-.10),1.42),(.22*cos(a),.07+.19*sin(a),1.73)],[.055,.075,.09,.038],'heart' if k%3==0 else 'bark',['Pelvis','Spine','Chest'],10,30,groove=.14) # Chest shield is recessed between shoulders; the face remains visible above it. shield('Breast_Heart',(.05,-.365,1.28),.33,.38,.14,'Chest',-.14) # Neck and carved angular mask, eyebrows and an actual dark recessed face. tube('Neck',[(0,.07,1.58),(0,-.02,1.86),(.01,-.06,2.05)],[.21,.24,.19],'bark',['Chest','Head'],20,24,flatten=.8) ell('Face_Recess',(0,-.23,1.90),(.22,.095,.18),'dark','Head') for s,side in [(-1,'L'),(1,'R')]: tube('Brow_'+side,[(s*.025,-.332,1.96),(s*.125,-.329,2.025),(s*.24,-.23,2.035)],[.068,.064,.045],'heart',['Head'],8,14,flatten=.7) tube('Cheek_'+side,[(s*.22,-.245,1.98),(s*.21,-.318,1.88),(s*.12,-.31,1.79)],[.065,.045,.035],'bark',['Head'],8,14) ell('Eye_'+side,(s*.105,-.326,1.934),(.052,.019,.018),'eye','Head',16,8) for s,side in [(-1,'L'),(1,'R')]: vs=[(s*.01,-.34,1.98),(s*.115,-.39,2.04),(s*.25,-.25,2.065),(s*.235,-.30,1.975),(s*.09,-.36,1.967), (s*.01,-.29,1.98),(s*.115,-.30,2.04),(s*.25,-.18,2.065),(s*.235,-.23,1.975),(s*.09,-.28,1.967)] fs=[(0,1,2,3,4),(9,8,7,6,5)]+[(k,(k+1)%5,(k+1)%5+5,k+5) for k in range(5)] mesh('CarvedBrow_'+side,vs,fs if s>0 else [tuple(reversed(f)) for f in fs],'bark','Head',smooth=False) tube('Nose_Ridge',[(0,-.335,2.01),(0,-.38,1.91),(0,-.35,1.83)],[.038,.051,.028],'heart',['Head'],8,14) # Crown: tapered broken wood spires and a large asymmetrical rear stump. shield('Back_Crown',(-.10,.07,1.95),.26,.29,.28,'Chest',-.30) for k in range(5): a=k*pi*.4 tube('Crown_Branch_%d'%k,[(.16*cos(a),.11+.17*sin(a),1.95),(.28*cos(a),.13+.22*sin(a),2.16+(.06 if k%2 else 0)),(.38*cos(a),.16+.27*sin(a),2.29+(k%2)*.11)],[.075,.045,.009],'bark',['Head'],10,16) for s,side in [(-1,'L'),(1,'R')]: arm=['UpperArm.'+side,'Forearm.'+side,'Hand.'+side] tube('Arm_Fibres_'+side,[(s*.39,.04,1.65),(s*.68,.035,1.55),(s*.85,-.045,1.20),(s*.87,-.20,.85),(s*.89,-.24,.68)],[.22,.27,.21,.21,.18],'bark',arm,24,44,flatten=.86) for k in range(5): a=2*pi*k/5 tube('Arm_Rib_'+side+str(k),[(s*.50+.15*cos(a),.04+.15*sin(a),1.62),(s*.78+.16*cos(a),-.02+.16*sin(a),1.28),(s*.88+.15*cos(a),-.17+.15*sin(a),.88)],[.055,.048,.035],'heart' if k==1 else 'bark',arm[:2],9,24) shield('Shoulder_'+side,(s*.63,-.17,1.60),.36 if s<0 else .30,.39 if s<0 else .33,.20,arm[0],s*-.16) shield('Forearm_Plate_'+side,(s*.91,-.355,.97),.19,.25,.15,arm[1],s*.16) ell('Palm_'+side,(s*.88,-.25,.70),(.22,.17,.16),'bark',arm[2]) for finger in range(3): x=s*.88+(finger-1)*.12 tube('Finger_'+side+str(finger),[(x,-.30,.69),(x+s*.035,-.37,.55),(x+s*.03,-.40,.46)],[.065,.052,.027],'heart',[arm[2],'Finger%d.%s'%(finger,side)],10,16) tube('Thumb_'+side,[(s*.76,-.21,.73),(s*.68,-.35,.65),(s*.71,-.42,.60)],[.075,.053,.026],'bark',[arm[2],'Thumb.'+side],10,16) leg=['Thigh.'+side,'Shin.'+side,'Foot.'+side] tube('Leg_'+side,[(s*.24,.07,.86),(s*.34,.035,.64),(s*.42,-.025,.38),(s*.43,-.025,.16)],[.22,.23,.18,.15],'bark',leg,24,42,flatten=.87) for k in range(4): a=2*pi*k/4 tube('Leg_Ridge_'+side+str(k),[(s*.25+.14*cos(a),.05+.13*sin(a),.84),(s*.40+.16*cos(a),.02+.14*sin(a),.43),(s*.46+.14*cos(a),-.04+.12*sin(a),.14)],[.06,.05,.035],'heart' if k==3 else 'bark',leg,9,24) shield('Knee_'+side,(s*.39,-.19,.43),.14,.16,.08,leg[1],s*.22) for toe in range(3): x=s*.43+(toe-1)*.13 tube('Toe_'+side+str(toe),[(s*.43,-.03,.19),(x,-.20,.10),(x+(toe-1)*.03,-.40,.045)],[.095,.081,.020],'heart',[leg[2],'Toe.'+side],12,22) tube('Heel_'+side,[(s*.43,.035,.20),(s*.49,.18,.11),(s*.55,.23,.04)],[.095,.064,.015],'bark',[leg[2]],10,14) # Distinct layered back plates: tangential, following the torso rather than floating discs. for row in range(4): for k in range(7): a=.1+k*pi/6;z=.90+row*.20;r=.31+.07*sin(row/3*pi) pts=[(r*.84*cos(a),.08+r*.72*sin(a),z-.13),((r+.045)*cos(a),.08+(r+.045)*.75*sin(a),z),((r-.035)*cos(a),.08+(r-.035)*.75*sin(a),z+.19)] tube('Back_Plate_%d_%d'%(row,k),pts,[.05,.105,.015],'bark',['Spine' if row<2 else 'Chest'],8,16,flatten=.42,groove=.12) # Moss grows in upper seams. Small leaf clusters make porous growth, never a green body ball. for j in range(190): zone=j%3 if zone==0: s=-1 if j%2 else 1;c=(s*(.45+random.random()*.25),-.15+random.random()*.25,(1.97 if s<0 else 1.92)+random.random()*.055);bone='UpperArm.'+('L' if s<0 else 'R') elif zone==1: a=random.uniform(.15,pi-.15);c=(.34*cos(a),.1+.28*sin(a),random.uniform(1.06,1.65));bone='Chest' else:c=(random.uniform(-.22,.22),random.uniform(-.03,.25),2.225+random.random()*.04);bone='Head' ell('Moss_%03d'%j,c,(.045+random.random()*.025,.036,.019),'moss',bone,8,4) # Small overlapping foliage breaks up the shoulder and torso seams. for j in range(84): s=-1 if j%2 else 1 if j<52: x=s*random.uniform(.40,.82);y=random.uniform(-.23,.13);z=1.96 if s<0 else 1.91;bone='UpperArm.'+('L' if s<0 else 'R') else: x=s*random.uniform(.29,.39);y=-.27;z=random.uniform(.98,1.56);bone='Spine' if z<1.2 else 'Chest' leaf('SeamLeaf_%02d'%j,(x,y,z),(x+s*.065,y-.045,z+random.uniform(.035,.095)),random.uniform(.025,.048),bone,'moss') for j in range(32): s=-1 if j%2 else 1;bone='Head' if j<14 else 'Chest';z=2.15 if j<14 else 1.49;x=s*(.19+random.random()*.14);y=.11 if j<14 else .24 leaf('Sprout_%02d'%j,(x,y,z),(x+s*random.uniform(.08,.19),y-.07,z+random.uniform(.10,.24)),random.uniform(.03,.055),bone) for j,(x,y,z,bone) in enumerate([(-.46,-.21,1.93,'UpperArm.L'),(.54,-.23,1.83,'UpperArm.R'),(-.23,-.28,1.41,'Chest'),(.26,-.29,1.57,'Chest')]): for k in range(5): a=2*pi*k/5;leaf('Blossom_%d_%d'%(j,k),(x+.007*cos(a),y,z+.007*sin(a)),(x+.045*cos(a),y-.015,z+.045*sin(a)),.014,bone,'flower') ell('BlossomHeart'+str(j),(x,y-.014,z),(.009,.009,.009),'cut',bone,8,4) # Weld closed leaf tips and consistently orient every disconnected solid before skinning. # BMesh preserves UV corners, colours and deform groups during this cleanup. for ob in parts: bm=bmesh.new();bm.from_mesh(ob.data) bmesh.ops.remove_doubles(bm,verts=list(bm.verts),dist=.0000005) bmesh.ops.triangulate(bm,faces=list(bm.faces)) bmesh.ops.dissolve_degenerate(bm,edges=list(bm.edges),dist=.0000001) bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces)) if bm.calc_volume(signed=True)<0:bmesh.ops.reverse_faces(bm,faces=list(bm.faces)) bm.to_mesh(ob.data);bm.free();ob.data.update() # Skeleton, anatomically placed hinges. Flexible fibre meshes blend adjacent joints. bpy.ops.object.armature_add(enter_editmode=True);rig=bpy.context.object;rig.name='RingborkeRig';ed=rig.data.edit_bones;ed.remove(ed[0]);defs={} def bone(name,head,tail,parent=None): b=ed.new(name);b.head=coord(head);b.tail=coord(tail) if parent:b.parent=ed[parent] defs[name]=(head,tail,parent) bone('Root',(0,0,0),(0,0,.2));bone('Pelvis',(0,.06,.72),(0,.05,1.02),'Root');bone('Spine',(0,.05,1.02),(0,.05,1.38),'Pelvis');bone('Chest',(0,.05,1.38),(0,.02,1.72),'Spine');bone('Head',(0,.02,1.72),(0,-.02,2.15),'Chest') for s,side in [(-1,'L'),(1,'R')]: bone('UpperArm.'+side,(s*.47,.04,1.63),(s*.84,-.04,1.19),'Chest');bone('Forearm.'+side,(s*.84,-.04,1.19),(s*.88,-.24,.73),'UpperArm.'+side);bone('Hand.'+side,(s*.88,-.24,.73),(s*.88,-.35,.59),'Forearm.'+side) for f in range(3): x=s*.88+(f-1)*.12;bone('Finger%d.%s'%(f,side),(x,-.32,.66),(x+s*.03,-.40,.46),'Hand.'+side) bone('Thumb.'+side,(s*.76,-.21,.73),(s*.71,-.42,.60),'Hand.'+side) bone('Thigh.'+side,(s*.25,.06,.79),(s*.41,-.02,.42),'Pelvis');bone('Shin.'+side,(s*.41,-.02,.42),(s*.43,-.02,.15),'Thigh.'+side);bone('Foot.'+side,(s*.43,-.02,.15),(s*.43,-.25,.07),'Shin.'+side);bone('Toe.'+side,(s*.43,-.25,.07),(s*.43,-.43,.045),'Foot.'+side) bpy.ops.object.mode_set(mode='OBJECT') for ob in parts: mod=ob.modifiers.new('Weighted living wood','ARMATURE');mod.object=rig;ob.parent=rig # Bake explicit character poses into editable actions at 30fps. Global axes are # converted to each bone's rest basis. Imported engine clips need no Python. clips={'idle':(3.2,True),'start':(.4,False),'walk':(1.4,True),'stop':(.4,False),'attack_a':(1.25,False),'attack_b':(1.25,False),'hit':(.48,False),'ability':(1.8,False),'death':(2.1,False)} def curve(t,keys): for i in range(len(keys)-1): a,x=keys[i];b,y=keys[i+1] if t<=b: u=max(0,(t-a)/(b-a));u=u*u*(3-2*u);return x*(1-u)+y*u return keys[-1][1] def animate(name,t,dur): rotations={};locs={} def rot(b,x=0,y=0,z=0):rotations[b]=(x,y,z) if name=='idle': q=sin(2*pi*t/dur);rot('Spine',.012*q);rot('Chest',-.018*q,0,.009*q);rot('Head',.016*q,0,-.015*q) for s,side in [(-1,'L'),(1,'R')]:rot('Forearm.'+side,.013*q,0,s*.007*q) elif name in ('walk','start','stop'): fade=1 if name=='walk' else sin(pi*.5*t/dur) if name=='start' else cos(pi*.5*t/dur) phase=t/1.4*2*pi;roll=sin(phase)*fade rot('Pelvis',.015,0,.035*roll);rot('Spine',-.045*fade,0,-.05*roll);rot('Chest',.018*fade,0,.025*roll);rot('Head',.035*fade,0,-.02*roll) for s,side in [(-1,'L'),(1,'R')]: q=sin(phase+(0 if s<0 else pi));lift=max(0,q);rot('Thigh.'+side,.34*q*fade);rot('Shin.'+side,-.40*lift*fade);rot('Foot.'+side,(-.34*q+.40*lift)*fade);rot('Toe.'+side,.12*max(0,-q)*fade) rot('UpperArm.'+side,-.13*q*fade,0,s*.02*fade);rot('Forearm.'+side,-.045*fade-.025*lift) locs['Root']=(0,0,(-.012+.012*cos(phase*2))*fade) elif name.startswith('attack'): # Contact is at normalized attack progress 0.72, aligned by runtime to damage. q=curve(t/dur,[(0,0),(.32,-.35),(.54,-.55),(.72,1),(.80,.85),(1,0)]) left=name=='attack_a';side='L' if left else 'R';other='R' if left else 'L';s=-1 if left else 1 rot('Spine',-.11*q,0,s*.09*q);rot('Chest',-.10*q,0,s*.14*q);rot('Head',.09*q,0,-s*.10*q) rot('UpperArm.'+side,-.88*q,(-.14 if left else .08)*q,s*.13*q);rot('Forearm.'+side,-.20*q);rot('Hand.'+side,.16*q);rot('UpperArm.'+other,.11*q,0,-s*.10*q) for f in range(3):rot('Finger%d.%s'%(f,side),-.25*max(0,q)) rot('Thigh.'+side,.06*q);rot('Shin.'+side,-.06*q);locs['Root']=(0,-.04*q,-.015*max(0,q)) elif name=='hit': q=curve(t,[(0,0),(.08,1),(.22,.5),(.48,0)]);rot('Spine',.12*q,0,-.08*q);rot('Head',.13*q,0,.06*q);rot('Forearm.L',-.10*q);rot('Forearm.R',-.08*q) elif name=='ability': q=curve(t,[(0,0),(.35,.5),(.75,1),(1.15,.85),(1.8,0)]);rot('Spine',.08*q);rot('Chest',.10*q);rot('Head',-.15*q) for s,side in [(-1,'L'),(1,'R')]:rot('UpperArm.'+side,-.22*q,0,s*.38*q);rot('Forearm.'+side,-.32*q);rot('Hand.'+side,.15*q) elif name=='death': q=curve(t,[(0,0),(.2,.05),(.7,.38),(1.30,1),(1.55,.93),(2.1,1)]) rot('Root',-.95*q,0,.13*q);locs['Root']=(0,-.15*q,-.12*q);rot('Pelvis',-.22*q);rot('Spine',-.24*q);rot('Head',-.20*q) for s,side in [(-1,'L'),(1,'R')]:rot('UpperArm.'+side,-.32*q,0,s*.17*q);rot('Forearm.'+side,-.50*q);rot('Thigh.'+side,.26*q);rot('Shin.'+side,-.44*q) for pb in rig.pose.bones: pb.rotation_mode='QUATERNION';r=rotations.get(pb.name,(0,0,0));q=Quaternion((0,0,1),r[2])@Quaternion((0,1,0),r[1])@Quaternion((1,0,0),r[0]);basis=pb.bone.matrix_local.to_quaternion();pb.rotation_quaternion=basis.inverted()@q@basis pb.location=pb.bone.matrix_local.to_3x3().inverted()@(Vector(locs.get(pb.name,(0,0,0)))*S) for name,(dur,loop) in clips.items(): rig.animation_data_clear();action=bpy.data.actions.new(name);action.use_fake_user=True;rig.animation_data_create();rig.animation_data.action=action for frame in range(round(dur*30)+1): animate(name,frame/30,dur) for pb in rig.pose.bones:pb.keyframe_insert('rotation_quaternion',frame=frame);pb.keyframe_insert('location',frame=frame) action['loop']=loop rig.animation_data_clear() rig.animation_data_create() rig.animation_data.action=bpy.data.actions['idle'] for pb in rig.pose.bones:pb.rotation_quaternion=Quaternion();pb.location=(0,0,0) bpy.context.scene.render.fps=30;bpy.context.scene.frame_set(0) # Save the editable source with separate parts and material nodes before export joining. bpy.ops.wm.save_as_mainfile(filepath=str(SRC/'ringborke.blend')) bpy.ops.file.make_paths_relative() bpy.ops.wm.save_as_mainfile(filepath=str(SRC/'ringborke.blend')) bpy.ops.object.select_all(action='DESELECT') for ob in parts:ob.select_set(True) bpy.context.view_layer.objects.active=parts[0];bpy.ops.object.join();body=bpy.context.object;body.name='RingborkeBody' # One skinned mesh; material surfaces and bone weights remain separate and portable. body.select_set(True);rig.select_set(True);bpy.context.view_layer.objects.active=rig bpy.ops.export_scene.gltf(filepath=str(OUT/'ringborke.glb'),export_format='GLB',use_selection=True,export_animations=True,export_animation_mode='ACTIONS',export_force_sampling=True,export_frame_range=False,export_anim_slide_to_zero=True,export_skins=True,export_all_influences=False,export_def_bones=True,export_anim_single_armature=True,export_materials='EXPORT',export_yup=True) raw=(OUT/'ringborke.glb').read_bytes();import struct doc=json.loads(raw[20:20+struct.unpack_from('