Back to Soybert 3D
SOURCE / PINNED RELEASE

Made of little things.

Soybert 3D

Release
4bf80e758af1…
Author-recorded commit
298fa2d0e8b1…
License
LICENSE
Author’s source reference
nostr://npub1n8ga89w8h6tvwamxusfyzexw8gjy84yxu9rxgnmk955cxtml4ujswzxydd/wss%3A%2F%2Fgit.napplet.soy%2F/n-9021e1b6a96

Archive hash verified: 7083223a53bbd320…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

scripts/build-soybert.py
"""Run inside Blender (5.1). Builds only the Soybert collection and exports the rig."""
import bpy, math, json, base64, struct, random
from pathlib import Path
from mathutils import Vector, Matrix

ROOT = Path('/Users/schlaus/workspace/napplets/soybert-3d')
# Preserve unrelated scene work. Replace only our generated collection.
for action in list(bpy.data.actions):
    if action.get('soybert_generated'):
        bpy.data.actions.remove(action)
old = bpy.data.collections.get('Soybert')
if old:
    for o in list(old.objects): bpy.data.objects.remove(o, do_unlink=True)
    bpy.data.collections.remove(old)
coll = bpy.data.collections.new('Soybert')
bpy.context.scene.collection.children.link(coll)
for o in bpy.context.scene.objects:
    if o.name in ('Cube','Camera','Light'): o.hide_set(True); o.hide_render=True

def own(o):
    for c in list(o.users_collection): c.objects.unlink(o)
    coll.objects.link(o)
    return o

def material(name, color, roughness=.55, metallic=0):
    m=bpy.data.materials.get(name) or bpy.data.materials.new(name)
    m.diffuse_color=(*color,1)
    m.use_nodes=True
    p=m.node_tree.nodes.get('Principled BSDF')
    p.inputs['Base Color'].default_value=(*color,1)
    p.inputs['Roughness'].default_value=roughness
    p.inputs['Metallic'].default_value=metallic
    return m
# Material values are linear. Warm ivory remains bright under studio lighting.
ivory=material('Tofu · warm ivory',(.91,.77,.51),.48)
cream=material('Tofu · milk highlights',(1,.91,.70),.42)
brown=material('Face · cocoa',(.045,.016,.008),.23)
white=material('Eye · softbox reflections',(1,.99,.9),.18)
pink=material('Cheeks · strawberry',(.95,.23,.27),.65)
blush=material('Cheeks · hatch',(.65,.085,.10),.6)
pore=material('Tofu · pores',(.70,.46,.22),.9)
wood=material('Shotgun · walnut',(.25,.085,.025),.5)
woodlight=material('Shotgun · honey grain',(.46,.18,.054),.48)
steel=material('Shotgun · graphite',(.055,.061,.068),.34,.6)
edge=material('Shotgun · edge metal',(.18,.19,.20),.28,.7)
black=material('Shotgun · dark bore',(.009,.011,.012),.85)
parts=[]
def finish(o,name,mat,bone):
    own(o); o.name=name; o.data.materials.append(mat)
    for p in o.data.polygons: p.use_smooth=True
    o['bone']=bone; parts.append(o)
    return o

def ell(name,loc,scale,mat=ivory,bone='body'):
    bpy.ops.mesh.primitive_uv_sphere_add(segments=20,ring_count=12,location=loc)
    o=bpy.context.object; o.scale=scale
    return finish(o,name,mat,bone)

def box(name,loc,dims,radius,mat=ivory,bone='body'):
    bpy.ops.mesh.primitive_cube_add(size=1,location=loc)
    o=bpy.context.object; o.scale=dims
    bpy.ops.object.transform_apply(location=False,rotation=False,scale=True)
    bevel=o.modifiers.new('Soft rounded edges','BEVEL'); bevel.width=radius; bevel.segments=6
    bpy.ops.object.modifier_apply(modifier=bevel.name)
    finish(o,name,mat,bone)
    weighted=o.modifiers.new('Weighted corner normals','WEIGHTED_NORMAL'); weighted.keep_sharp=True; weighted.weight=50
    return o

def tube(name,points,radius,mat,bone='body'):
    data=bpy.data.curves.new(name,'CURVE'); data.dimensions='3D'; data.resolution_u=12
    data.bevel_depth=radius; data.bevel_resolution=3
    sp=data.splines.new('POLY'); sp.points.add(len(points)-1)
    for p,co in zip(sp.points,points): p.co=(*co,1)
    o=bpy.data.objects.new(name,data); coll.objects.link(o)
    bpy.ops.object.select_all(action='DESELECT'); o.select_set(True); bpy.context.view_layer.objects.active=o
    bpy.ops.object.convert(target='MESH')
    return finish(bpy.context.object,name,mat,bone)

def rod(name,a,b,radius,mat,bone='shotgun'):
    v=Vector(b)-Vector(a)
    bpy.ops.mesh.primitive_cylinder_add(vertices=24,radius=radius,depth=v.length,location=(Vector(a)+Vector(b))/2)
    o=bpy.context.object; o.rotation_euler=v.to_track_quat('Z','Y').to_euler()
    bevel=o.modifiers.new('Rounded ends','BEVEL'); bevel.width=.018; bevel.segments=3
    bpy.ops.object.modifier_apply(modifier=bevel.name)
    return finish(o,name,mat,bone)

box('Tofu body',(0,0,1.65),(2.16,1.3,2.19),.24)
# A subtle top inset echoes the white rim in the illustration.
box('Cream top',(0,0,2.716),(1.88,1.06,.065),.03,cream)
for side in (-1,1):
    x=.44*side
    ell('Bright cocoa eye', (x,-.645,1.98),(.145,.072,.183),brown)
    ell('Eye catchlight',(x-.035,-.706,2.042),(.043,.02,.047),white)
    ell('Small eye sparkle',(x+.042,-.709,1.923),(.018,.01,.019),white)
    ell('Rosy cheek',(.70*side,-.65,1.665),(.236,.034,.147),pink)
    for off in (-.054,.054):
        tube('Blush dash',[(.70*side+off-.024,-.687,1.64),(.70*side+off+.02,-.687,1.70)],.014,blush)
# U-shaped extruded smile with rounded ends.
smile=[]
for i in range(25):
    t=math.pi+math.pi*i/24
    smile.append((.205*math.cos(t),-.686,1.79+.145*math.sin(t)))
tube('Happy smile',smile,.038,brown)
for pt in (smile[0],smile[-1]): ell('Smile end',pt,(.038,.038,.038),brown)
# Deterministic small pores on front, top, sides and back.
rng=random.Random(19)
for x,z,s in [(-.84,2.36,.045),(.82,2.36,.033),(-.25,2.47,.03),(.56,2.55,.042),(-.87,1.07,.035),(.87,1.3,.045),(.63,.78,.03),(-.39,.73,.036),(.26,2.39,.024)]:
    ell('Front pore',(x,-.649,z),(s,.007,s*.73),pore)
for i in range(11):
    x=rng.uniform(-.79,.79); y=rng.uniform(-.43,.43); s=rng.uniform(.026,.05)
    ell('Top pore',(x,y,2.754),(s,s*.7,.006),pore)
for side in (-1,1):
    for i in range(9):
        y=rng.uniform(-.36,.36); z=rng.uniform(.84,2.42); s=rng.uniform(.025,.05)
        ell('Side pore',(1.077*side,y,z),(.007,s*.75,s),pore)
for i in range(12):
    x=rng.uniform(-.79,.79); z=rng.uniform(.85,2.4); s=rng.uniform(.024,.046)
    ell('Back pore',(x,.648,z),(s,.007,s*.76),pore)
# Feet and independent leg bones.
for side,label in ((-1,'L'),(1,'R')):
    x=.52*side
    ell('Leg '+label,(x,0,.48),(.195,.20,.31),ivory,'leg_'+label)
    ell('Foot '+label,(x,-.14,.18),(.32,.39,.18),cream,'leg_'+label)
# Shotgun: pump-action shotgun angled across the chest.
origin=Vector((-.08,-1.08,1.11)); d=Vector((.96,-.28,-.025)).normalized()
up=Vector((0,0,1)); across=Vector((-d.y,d.x,0))
def gp(t,z=0): return origin+d*t+up*z
stock=box('Walnut stock',gp(-.91,-.025),(.80,.23,.34),.065,wood,'shotgun')
stock.rotation_euler[2]=math.atan2(d.y,d.x)
cap=box('Rubber butt pad',gp(-1.29,-.025),(.09,.25,.36),.035,steel,'shotgun'); cap.rotation_euler[2]=math.atan2(d.y,d.x)
receiver=box('Shotgun receiver',gp(-.22),(.75,.28,.32),.08,steel,'shotgun'); receiver.rotation_euler[2]=math.atan2(d.y,d.x)
rod('Upper barrel',gp(.08,.095),gp(1.34,.095),.095,steel)
rod('Magazine tube',gp(.10,-.085),gp(1.14,-.085),.071,edge)
rod('Wooden pump',gp(.36,-.09),gp(.94,-.09),.133,woodlight,'pump')
for i in range(7):
    t=.38+i*.085
    rod('Pump groove',gp(t,-.09),gp(t+.022,-.09),.137,wood,'pump')
rod('Muzzle rim',gp(1.32,.095),gp(1.39,.095),.118,edge)
rod('Recessed muzzle',gp(1.39,.095),gp(1.396,.095),.082,black)
# Rounded trigger loop.
pts=[gp(-.35+.20*math.cos(t),-.19+.145*math.sin(t)) for t in [i*2*math.pi/32 for i in range(33)]]
tube('Trigger guard',pts,.035,steel,'shotgun')
# Hands cradle the grip and pump.
ell('Grip mitten',gp(-.52,-.18),(.22,.22,.22),cream,'hand_grip')
ell('Pump mitten',gp(.64,-.22),(.23,.20,.21),cream,'hand_pump')
ell('Grip thumb',gp(-.46,-.04)-across*.16,(.095,.10,.12),cream,'hand_grip')
ell('Pump thumb',gp(.67,-.08)-across*.16,(.10,.10,.11),cream,'hand_pump')
# Slim pale grain lines across stock.
for z in (-.10,0,.10):
    tube('Wood grain',[gp(-1.19,z)+across*-.12,gp(-.68,z+.025)+across*-.12],.009,woodlight,'shotgun')

# Rig. All bones point down Blender -Y, which becomes +Z in the browser.
rigdata=bpy.data.armatures.new('Soybert skeleton'); rig=bpy.data.objects.new('Soybert Rig',rigdata); coll.objects.link(rig)
bpy.ops.object.select_all(action='DESELECT'); rig.select_set(True); bpy.context.view_layer.objects.active=rig
bpy.ops.object.mode_set(mode='EDIT')
bones=[('root',None,(0,0,0)),('body','root',(0,0,1.35)),('leg_L','root',(-.52,0,.53)),('leg_R','root',(.52,0,.53)),('shotgun','body',tuple(origin)),('pump','shotgun',tuple(gp(.65,-.09))),('hand_grip','shotgun',tuple(gp(-.52,-.18))),('hand_pump','pump',tuple(gp(.64,-.22)))]
for name,parent,pos in bones:
    b=rigdata.edit_bones.new(name); b.head=pos; b.tail=Vector(pos)+Vector((0,-.28,0))
    if parent: b.parent=rigdata.edit_bones[parent]
bpy.ops.object.mode_set(mode='OBJECT')
rig.show_in_front=True
bpy.context.view_layer.update()
# Bake evaluated normals and world coordinates into an efficient inlined export before skinning.
materials=[]; matmap={}; buckets={}
for obj in parts:
    m=obj.data.materials[0]
    if m.name not in matmap:
        matmap[m.name]=len(materials)
        p=m.node_tree.nodes.get('Principled BSDF')
        materials.append({'name':m.name,'color':list(m.diffuse_color[:3]),'roughness':p.inputs['Roughness'].default_value,'metalness':p.inputs['Metallic'].default_value})
    mid=matmap[m.name]; buckets.setdefault(mid,[]).append(obj)
positions=[]; normals=[]; skin=[]; indices=[]; groups=[]
deps=bpy.context.evaluated_depsgraph_get()
for mid,objects in buckets.items():
    start=len(indices)
    for obj in objects:
        evaluated=obj.evaluated_get(deps); mesh=evaluated.to_mesh(); mesh.calc_loop_triangles()
        world=obj.matrix_world; normalmat=world.to_3x3().inverted().transposed(); cache={}
        boneidx=[b[0] for b in bones].index(obj['bone'])
        for tri in mesh.loop_triangles:
            for li in tri.loops:
                loop=mesh.loops[li]; p=world@mesh.vertices[loop.vertex_index].co
                n=(normalmat@mesh.corner_normals[li].vector).normalized()
                key=(loop.vertex_index,tuple(round(v,5) for v in n))
                if key not in cache:
                    cache[key]=len(skin)
                    positions.extend((p.x,p.z,-p.y)); normals.extend((n.x,n.z,-n.y)); skin.append(boneidx)
                indices.append(cache[key])
        evaluated.to_mesh_clear()
    groups.append({'start':start,'count':len(indices)-start,'material':mid})
def packed(values,fmt): return base64.b64encode(struct.pack('<'+fmt*len(values),*values)).decode()
def yup(v): return [v[0],v[2],-v[1]]
model={'version':1,'materials':materials,'groups':groups,'positions':packed(positions,'f'),'normals':packed(normals,'f'),'skin':packed(skin,'B'),'indices':packed(indices,'I'),'bones':[],'muzzle':yup(gp(1.42,.095)),'barrelDirection':yup(d),'vertexCount':len(skin),'triangleCount':len(indices)//3}
for name,parent,pos in bones:
    parentpos=next((b[2] for b in bones if b[0]==parent),(0,0,0))
    model['bones'].append({'name':name,'parent':parent,'position':yup(Vector(pos)-Vector(parentpos))})
(ROOT/'src/assets/soybert-rig.json').write_text(json.dumps(model,separators=(',',':')))
# Bind each piece rigidly to its articulated bone, retaining editable separate meshes.
for obj in parts:
    vg=obj.vertex_groups.new(name=obj['bone']); vg.add(list(range(len(obj.data.vertices))),1,'REPLACE')
    mod=obj.modifiers.new('Soybert skin','ARMATURE'); mod.object=rig
    obj.parent=rig

# Author reusable actions on the Blender armature. Bone local Y is world -Y.
scene=bpy.context.scene; scene.render.fps=30
rig.animation_data_create()
for title,duration in [('Idle',60),('Walk',24),('Jump',30),('Shoot',18),('Celebrate',90)]:
    action=bpy.data.actions.new(title); action['soybert_generated']=True; action.use_fake_user=True; rig.animation_data.action=action
    for frame in range(1,duration+2,3):
        t=(frame-1)/duration; phase=t*math.tau
        for pb in rig.pose.bones:
            pb.rotation_mode='XYZ'; pb.location=(0,0,0); pb.rotation_euler=(0,0,0); pb.scale=(1,1,1)
        body=rig.pose.bones['body']
        if title=='Idle':
            body.location.z=.022*math.sin(phase); body.rotation_euler.y=.018*math.sin(phase)
        elif title=='Walk':
            body.location.z=.09*abs(math.sin(phase)); body.rotation_euler.y=.065*math.sin(phase); body.rotation_euler.x=.14
            rig.pose.bones['leg_L'].rotation_euler.x=.95*math.sin(phase)
            rig.pose.bones['leg_R'].rotation_euler.x=-.95*math.sin(phase)
        elif title=='Jump':
            rig.pose.bones['root'].location.z=1.15*math.sin(math.pi*t)
            rig.pose.bones['leg_L'].rotation_euler.x=-.28*math.sin(math.pi*t)
            rig.pose.bones['leg_R'].rotation_euler.x=.24*math.sin(math.pi*t)
        elif title=='Shoot':
            kick=max(0,1-t*4)
            rig.pose.bones['shotgun'].location.y=-.17*kick
            body.rotation_euler.x=-.08*kick
            rig.pose.bones['pump'].location.y=-.20*math.sin(math.pi*min(1,max(0,(t-.2)/.8)))
        else:
            body.location.z=.13*abs(math.sin(phase*3)); body.rotation_euler.y=.15*math.sin(phase*3)
            rig.pose.bones['root'].rotation_euler.z=phase
        for pb in rig.pose.bones:
            for path in ('location','rotation_euler','scale'): pb.keyframe_insert(data_path=path,frame=frame,group=pb.name)
    track=rig.animation_data.nla_tracks.new(); track.name=title
    strip=track.strips.new(title,1,action); track.mute=True
rig.animation_data.action=None
for pb in rig.pose.bones: pb.location=(0,0,0); pb.rotation_euler=(0,0,0); pb.scale=(1,1,1)
scene.frame_set(1); scene.frame_end=90
# A studio camera and lights for the editable asset.
def aim(o,point): o.rotation_euler=(Vector(point)-o.location).to_track_quat('-Z','Y').to_euler()
bpy.ops.object.camera_add(location=(4,-7,3.6)); cam=own(bpy.context.object); cam.name='Soybert portrait camera'; aim(cam,(0,0,1.4)); cam.data.type='ORTHO'; cam.data.ortho_scale=4.6; scene.camera=cam
for name,loc,power,size in [('Key',(-3,-4,6),650,5),('Fill',(4,-1,4),450,4),('Rim',(0,3,5),800,3)]:
    bpy.ops.object.light_add(type='AREA',location=loc); lamp=own(bpy.context.object); lamp.name=name; lamp.data.energy=power; lamp.data.shape='DISK'; lamp.data.size=size; aim(lamp,(0,0,1.3))
scene.world.color=(.15,.17,.14)
scene.render.engine='CYCLES'; scene.cycles.samples=32
scene.render.resolution_x=900; scene.render.resolution_y=900; scene.render.resolution_percentage=100
scene.render.film_transparent=True
# Save the editable source, and export only rigged character parts.
bpy.ops.object.select_all(action='DESELECT')
for obj in parts+[rig]: obj.select_set(True)
bpy.context.view_layer.objects.active=rig
bpy.ops.wm.save_as_mainfile(filepath=str(ROOT/'art/soybert.blend'))
bpy.ops.export_scene.gltf(filepath=str(ROOT/'art/soybert.glb'),export_format='GLB',use_selection=True,export_animations=True,export_animation_mode='ACTIONS',export_nla_strips=True)
# Leave the open viewport looking at the character.
for screen in bpy.data.screens:
    for area in screen.areas:
        if area.type=='VIEW_3D':
            area.spaces.active.region_3d.view_distance=5.8
            area.spaces.active.region_3d.view_location=Vector((0,0,1.4))
            area.spaces.active.region_3d.view_rotation=cam.rotation_euler.to_quaternion()
            area.spaces.active.shading.type='MATERIAL'
result={'vertices':len(skin),'triangles':len(indices)//3,'bones':len(bones),'meshes':len(parts),'actions':[a.name for a in bpy.data.actions],'blend':str(ROOT/'art/soybert.blend'),'glb':str(ROOT/'art/soybert.glb')}