180 lines
9.4 KiB
Python
180 lines
9.4 KiB
Python
"""Run with Blender MCP. Separate cartoon rain gear for the rounded v10 sheep."""
|
|
import bpy, bmesh, math, json, struct
|
|
from mathutils import Vector, Matrix
|
|
ROOT='D:/project/sunrise'
|
|
with bpy.data.libraries.load(ROOT+'/assets/characters/ivory-lamb-v10.blend',link=False) as (src,dst):
|
|
dst.scenes=src.scenes
|
|
reference=dst.scenes[0]
|
|
reference.view_layers[0].update()
|
|
scene=bpy.data.scenes.new('Cartoon rain gear')
|
|
bpy.context.window.scene=scene
|
|
|
|
def material(name,hexcolor):
|
|
mat=bpy.data.materials.new(name); mat.use_nodes=True
|
|
rgb=[int(hexcolor[i:i+2],16)/255 for i in (0,2,4)]
|
|
rgb=[v/12.92 if v<.04045 else ((v+.055)/1.055)**2.4 for v in rgb]
|
|
mat.diffuse_color=(*rgb,1)
|
|
nodes=mat.node_tree.nodes; nodes.clear()
|
|
out=nodes.new('ShaderNodeOutputMaterial'); node=nodes.new('ShaderNodeEmission')
|
|
node.inputs['Color'].default_value=(*rgb,1)
|
|
mat.node_tree.links.new(node.outputs[0],out.inputs['Surface'])
|
|
return mat
|
|
yellow=material('Raincoat yellow','E9BE69')
|
|
red=material('Umbrella red','BE6264')
|
|
cream=material('Umbrella ivory','F5EDDD')
|
|
ink=material('SheepV2_Ink_Gear','67564C')
|
|
|
|
def empty(name,parent=None):
|
|
obj=bpy.data.objects.new(name,None);scene.collection.objects.link(obj);obj.parent=parent
|
|
return obj
|
|
|
|
def mesh(name,vertices,faces,mat,parent):
|
|
data=bpy.data.meshes.new(name);data.from_pydata(vertices,[],faces);data.update()
|
|
obj=bpy.data.objects.new(name,data);scene.collection.objects.link(obj)
|
|
obj.parent=parent;data.materials.append(mat)
|
|
return obj
|
|
|
|
def stroke(name,points,radius,mat,parent):
|
|
curve=bpy.data.curves.new(name,'CURVE');curve.dimensions='3D'
|
|
curve.bevel_depth=radius;curve.bevel_resolution=3
|
|
spline=curve.splines.new('POLY');spline.points.add(len(points)-1)
|
|
for p,co in zip(spline.points,points):p.co=(*co,1)
|
|
obj=bpy.data.objects.new(name,curve);scene.collection.objects.link(obj);obj.parent=parent
|
|
curve.materials.append(mat)
|
|
bpy.ops.object.select_all(action='DESELECT');obj.select_set(True);bpy.context.view_layer.objects.active=obj
|
|
bpy.ops.object.convert(target='MESH')
|
|
return bpy.context.object
|
|
|
|
def sphere(name,location,scale,mat,parent):
|
|
bpy.ops.mesh.primitive_uv_sphere_add(segments=24,ring_count=12,location=location)
|
|
obj=bpy.context.object;obj.name=name;obj.scale=scale;obj.parent=parent
|
|
obj.data.materials.append(mat)
|
|
bpy.ops.object.transform_apply(location=False,rotation=False,scale=True)
|
|
return obj
|
|
|
|
umbrella=empty('Umbrella')
|
|
# The grip is in the raised right hoof; the tilted shaft centers the canopy above the head.
|
|
grip=Vector((.58,-.40,.82)); top=Vector((0,-.05,3.03))
|
|
stroke('Umbrella shaft',[grip,top],.022,ink,umbrella)
|
|
for sector in range(8):
|
|
vertices=[];faces=[]
|
|
for row in range(9):
|
|
theta=(math.pi/2)*row/8
|
|
for col in range(5):
|
|
a=(sector+col/4)*math.pi/4
|
|
vertices.append((1.28*math.sin(theta)*math.cos(a),-.05+1.28*math.sin(theta)*math.sin(a),2.53+.50*math.cos(theta)))
|
|
for row in range(8):
|
|
for col in range(4):
|
|
i=row*5+col;faces.append((i,i+1,i+6,i+5))
|
|
panel=mesh('Canopy panel',vertices,faces,red if sector%2==0 else cream,umbrella)
|
|
bpy.context.view_layer.objects.active=panel
|
|
solid=panel.modifiers.new('Canopy thickness','SOLIDIFY');solid.thickness=.018
|
|
bpy.ops.object.modifier_apply(modifier=solid.name)
|
|
a=sector*math.pi/4
|
|
stroke('Canopy rib',[(1.28*math.sin(t)*math.cos(a),-.05+1.28*math.sin(t)*math.sin(a),2.54+.50*math.cos(t)) for t in [i*math.pi/32 for i in range(17)]],.009,ink,umbrella)
|
|
stroke('Canopy rim',[(1.28*math.cos(a),-.05+1.28*math.sin(a),2.53) for a in [i*math.tau/96 for i in range(97)]],.016,ink,umbrella)
|
|
sphere('Umbrella tip',(0,-.05,3.055),(.04,.04,.07),ink,umbrella)
|
|
sphere('Grip',grip,(.052,.052,.12),red,umbrella)
|
|
|
|
coat=empty('Raincoat')
|
|
for joint_name in ('Body','ArmLeft','ArmRight'):
|
|
pivot=next(o for o in reference.objects if o.name.split('.')[0]==joint_name)
|
|
source=next(o for o in reference.objects if o.parent==pivot and o.type=='MESH' and not o.name.startswith('Preview') and not o.get('sheepOutline')==False)
|
|
data=source.data.copy(); bm=bmesh.new();bm.from_mesh(data)
|
|
dark=[f for f in bm.faces if 'Ink' in source.data.materials[f.material_index].name]
|
|
bmesh.ops.delete(bm,geom=dark,context='FACES')
|
|
if joint_name=='Body':
|
|
# Keep the torso component; do not turn the wool tail into a coat bubble.
|
|
unseen=set(bm.verts);components=[]
|
|
while unseen:
|
|
pending=[unseen.pop()];component=set(pending)
|
|
while pending:
|
|
v=pending.pop()
|
|
for edge in v.link_edges:
|
|
n=edge.other_vert(v)
|
|
if n in unseen:unseen.remove(n);component.add(n);pending.append(n)
|
|
components.append(component)
|
|
keep=max(components,key=len)
|
|
bmesh.ops.delete(bm,geom=[v for v in bm.verts if v not in keep],context='VERTS')
|
|
bm.to_mesh(data);bm.free()
|
|
data.materials.clear();data.materials.append(yellow)
|
|
anchor=empty('Coat'+joint_name,coat)
|
|
if joint_name!='Body':anchor.matrix_local=pivot.matrix_world.copy()
|
|
obj=bpy.data.objects.new('Raincoat '+joint_name,data);scene.collection.objects.link(obj);obj.parent=anchor
|
|
transform=source.matrix_world if joint_name=='Body' else pivot.matrix_world.inverted()@source.matrix_world
|
|
center=Vector((0,0,.68)) if joint_name=='Body' else Vector((.08 if joint_name=='ArmRight' else -.08,0,-.20))
|
|
for v in data.vertices:
|
|
v.co=center+(transform@v.co-center)*1.075
|
|
if joint_name=='Body':
|
|
# A short raincoat hem leaves both wool legs and hoof tips exposed.
|
|
bm=bmesh.new();bm.from_mesh(data)
|
|
bmesh.ops.bisect_plane(bm,geom=list(bm.verts)+list(bm.edges)+list(bm.faces),
|
|
plane_co=(0,0,.42),plane_no=(0,0,1),clear_inner=True)
|
|
boundary=[edge for edge in bm.edges if edge.is_boundary]
|
|
bmesh.ops.holes_fill(bm,edges=boundary,sides=0)
|
|
for v in bm.verts:
|
|
flare=1+.06*max(0,1-(v.co.z-.42)/.25)
|
|
v.co.x*=flare;v.co.y*=flare
|
|
bm.to_mesh(data);bm.free()
|
|
for p in data.polygons:p.material_index=0
|
|
# A folded hood behind the neck, plus simple front fasteners.
|
|
sphere('CoatFoldedHood',(0,.48,1.18),(.38,.20,.17),yellow,coat)
|
|
hood=empty('CoatHood',coat)
|
|
head_pivot=next(o for o in reference.objects if o.name.split('.')[0]=='Head')
|
|
hood.matrix_local=head_pivot.matrix_world.copy()
|
|
vertices=[];faces=[]
|
|
def hood_point(theta,phi):
|
|
x=.98*math.sin(theta)*math.cos(phi)
|
|
y=-.85*math.cos(theta)
|
|
z=.02+.96*math.sin(theta)*math.sin(phi)
|
|
z+=.12*math.exp(-(x/.22)**2-((theta-1.5)/.35)**2)*max(0,math.sin(phi))**8
|
|
return (x,y,z)
|
|
for row in range(17):
|
|
theta=1.12+(math.pi-1.12)*row/16
|
|
for col in range(49):vertices.append(hood_point(theta,col*math.tau/48))
|
|
for row in range(16):
|
|
for col in range(48):
|
|
i=row*49+col;faces.append((i,i+1,i+50,i+49))
|
|
hood_shell=mesh('Rain hood shell',vertices,faces,yellow,hood)
|
|
bpy.context.view_layer.objects.active=hood_shell
|
|
solid=hood_shell.modifiers.new('Hood fabric','SOLIDIFY');solid.thickness=.025
|
|
bpy.ops.object.modifier_apply(modifier=solid.name)
|
|
stroke('Hood opening rim',[hood_point(1.12,i*math.tau/96) for i in range(97)],.025,yellow,hood)
|
|
stroke('Coat placket',[(0,-.72,z) for z in (.47,.65,.90,1.07)],.012,ink,coat)
|
|
for z in (.54,.77,1.00):sphere('Coat button',(0,-.735,z),(.032,.022,.032),cream,coat)
|
|
|
|
for obj in scene.objects:
|
|
if obj.type!='MESH':continue
|
|
bm=bmesh.new();bm.from_mesh(obj.data);bmesh.ops.recalc_face_normals(bm,faces=list(bm.faces));bm.to_mesh(obj.data);bm.free()
|
|
for p in obj.data.polygons:p.use_smooth=True
|
|
obj.data.update()
|
|
old=obj.data.attributes.get('_outline_offset')
|
|
if old:obj.data.attributes.remove(old)
|
|
offsets=obj.data.attributes.new('_outline_offset','FLOAT_VECTOR','POINT')
|
|
for v,item in zip(obj.data.vertices,offsets.data):item.vector=v.normal*.012
|
|
|
|
def export(root,path):
|
|
bpy.ops.object.select_all(action='DESELECT')
|
|
root.select_set(True)
|
|
for obj in root.children_recursive:obj.select_set(True)
|
|
bpy.context.view_layer.objects.active=root
|
|
bpy.ops.export_scene.gltf(filepath=path,use_selection=True,use_active_scene=True,export_format='GLB',export_yup=True,export_extras=True,export_attributes=True)
|
|
data=open(path,'rb').read();size=struct.unpack_from('<I',data,12)[0]
|
|
doc=json.loads(data[20:20+size]);binary=data[20+size:]
|
|
# Repeated runs in a live Blender session must retain the runtime attachment names.
|
|
for node in doc.get('nodes',[]):
|
|
base=node.get('name','').split('.')[0]
|
|
if base in ('Umbrella','Raincoat','CoatBody','CoatArmLeft','CoatArmRight','CoatHood','CoatFoldedHood'):node['name']=base
|
|
doc.setdefault('extensionsUsed',[]).append('KHR_materials_unlit')
|
|
for mat in doc['materials']:
|
|
mat['pbrMetallicRoughness']={'baseColorFactor':mat.pop('emissiveFactor')+[1],'metallicFactor':0,'roughnessFactor':1}
|
|
mat.setdefault('extensions',{})['KHR_materials_unlit']={}
|
|
encoded=json.dumps(doc,separators=(',',':')).encode();encoded+=b' '*((-len(encoded))%4)
|
|
with open(path,'wb') as f:
|
|
f.write(struct.pack('<III',0x46546C67,2,20+len(encoded)+len(binary)))
|
|
f.write(struct.pack('<II',len(encoded),0x4E4F534A));f.write(encoded);f.write(binary)
|
|
export(umbrella,ROOT+'/public/characters/umbrella.glb')
|
|
export(coat,ROOT+'/public/characters/raincoat.glb')
|
|
bpy.data.libraries.write(ROOT+'/assets/characters/rain-gear.blend',{scene})
|
|
print('Umbrella and articulated raincoat exported')
|