Unveränderter Ausgangsstand inklusive Masterprompt und Konzeptbildern unter assets/. .godot/ und docs/screenshots/ bleiben laut .gitignore unversioniert; letztere sind unter ../backups gesichert. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
597 lines
28 KiB
Python
597 lines
28 KiB
Python
"""Funkenleser: compact weighted worker with tool-contact work clips.
|
|
Blender Z up, -Y forward. Run in a fresh background Blender process.
|
|
Replaces the Phase 4 worker presentation; gameplay values stay unchanged.
|
|
"""
|
|
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(52)
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
S = 1.0 # authored directly at ~1.15 m game height
|
|
|
|
def coord(v):
|
|
return Vector(v) * S
|
|
|
|
parts = []
|
|
|
|
def material(name, color, rough=.8, metal=0.0, emission=None, alpha=1.0, glass=False):
|
|
m = bpy.data.materials.new('AB_' + name)
|
|
m.diffuse_color = (*color, alpha)
|
|
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
|
|
b.inputs['Metallic'].default_value = metal
|
|
if alpha < 1.0 or glass:
|
|
b.inputs['Alpha'].default_value = alpha
|
|
m.blend_method = 'BLEND'
|
|
b.inputs['Transmission Weight'].default_value = .55 if glass else 0.0
|
|
b.inputs['IOR'].default_value = 1.45
|
|
if emission is not None:
|
|
b.inputs['Emission Color'].default_value = (*emission, 1)
|
|
b.inputs['Emission Strength'].default_value = 1.4
|
|
else:
|
|
v = n.new('ShaderNodeVertexColor')
|
|
v.layer_name = 'SurfaceColor'
|
|
l.new(v.outputs['Color'], b.inputs['Base Color'])
|
|
if name in ('Leather', 'Cloth', 'Clay'):
|
|
# Fine authored grain without external bitmaps.
|
|
noise = n.new('ShaderNodeTexNoise')
|
|
noise.inputs['Scale'].default_value = 48 if name != 'Clay' else 22
|
|
noise.inputs['Detail'].default_value = 3.2
|
|
bump = n.new('ShaderNodeBump')
|
|
bump.inputs['Strength'].default_value = .28 if name == 'Leather' else .16
|
|
bump.inputs['Distance'].default_value = .012
|
|
l.new(noise.outputs['Fac'], bump.inputs['Height'])
|
|
l.new(bump.outputs['Normal'], b.inputs['Normal'])
|
|
return m
|
|
|
|
M = {
|
|
'clay': material('Clay', (.36, .26, .18), .88),
|
|
'cloth': material('Cloth', (.16, .19, .23), .92),
|
|
'hood': material('Hood', (.12, .15, .14), .9),
|
|
'leather': material('Leather', (.30, .16, .08), .72),
|
|
'boot': material('Boot', (.10, .07, .05), .78),
|
|
'glove': material('Glove', (.32, .22, .12), .7),
|
|
'metal': material('Metal', (.42, .45, .48), .35, .85),
|
|
'brass': material('Brass', (.55, .40, .16), .4, .75),
|
|
'darkmetal': material('DarkMetal', (.12, .13, .14), .45, .7),
|
|
'glass': material('Glass', (.55, .78, .72), .12, 0.0, alpha=.38, glass=True),
|
|
'spark': material('Spark', (.55, .95, .40), .4, emission=(.45, .95, .35)),
|
|
'lamp': material('Lamp', (1.0, .88, .55), .3, emission=(1.0, .82, .40)),
|
|
'lens': material('Lens', (.35, .55, .50), .18, metal=.2),
|
|
'skin': material('Skin', (.48, .36, .26), .75),
|
|
}
|
|
|
|
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] * 1.4 + v[1] * .5, v[2] * 1.2)
|
|
fac = .92 + .08 * sin(v[0] * 31 + v[2] * 17) + .03 * cos(v[1] * 53)
|
|
c = colors[i] if colors else tuple(max(.01, 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=12, steps=20, flatten=1.0, groove=.04):
|
|
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, p1, p2, p3 = points[max(a - 1, 0)], points[a], points[a + 1], points[min(a + 2, len(points) - 1)]
|
|
p = .5 * ((2 * p1) + (-p0 + p2) * u + (2 * p0 - 5 * p1 + 4 * p2 - p3) * u * u + (-p0 + 3 * p1 - 3 * p2 + p3) * u * u * u)
|
|
return p, 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()
|
|
axis = Vector((1, 0, 0)) if abs(tan.y) > .94 else Vector((0, 1, 0))
|
|
u = tan.cross(axis).normalized()
|
|
v = tan.cross(u)
|
|
loop = []
|
|
for k in range(sides):
|
|
a = 2 * pi * k / sides
|
|
mod = 1 + groove * (sin(a * 5 + t * 4) + .3 * sin(a * 9 - t * 6))
|
|
vs.append(p + r * mod * (u * cos(a) + v * sin(a) * flatten))
|
|
uv.append((k / sides, t))
|
|
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=14, rings=8):
|
|
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 + .03 * sin(a * 4 + j)
|
|
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 box(name, c, size, kind, bone, rot_y=0.0, smooth=False):
|
|
sx, sy, sz = size[0] * .5, size[1] * .5, size[2] * .5
|
|
local = [(-sx, -sy, -sz), (sx, -sy, -sz), (sx, sy, -sz), (-sx, sy, -sz),
|
|
(-sx, -sy, sz), (sx, -sy, sz), (sx, sy, sz), (-sx, sy, sz)]
|
|
ca, sa = cos(rot_y), sin(rot_y)
|
|
vs = [(c[0] + x * ca - z * sa, c[1] + y, c[2] + x * sa + z * ca) for x, y, z in local]
|
|
faces = [(0, 1, 2, 3), (4, 7, 6, 5), (0, 4, 5, 1), (1, 5, 6, 2), (2, 6, 7, 3), (3, 7, 4, 0)]
|
|
return mesh(name, vs, faces, kind, bone, smooth=smooth)
|
|
|
|
def frame(name, c, w, h, depth, kind, bone):
|
|
"""Open rectangular frame for the spark reservoir."""
|
|
t = .018
|
|
box(name + '_T', (c[0], c[1], c[2] + h * .5), (w, depth, t), kind, bone)
|
|
box(name + '_B', (c[0], c[1], c[2] - h * .5), (w, depth, t), kind, bone)
|
|
box(name + '_L', (c[0] - w * .5, c[1], c[2]), (t, depth, h), kind, bone)
|
|
box(name + '_R', (c[0] + w * .5, c[1], c[2]), (t, depth, h), kind, bone)
|
|
|
|
def fabric(name, rows, kind, bones, depth=.012, folds=.008, cols=18):
|
|
"""Tailored closed panel: (height, half-width, front, curvature) rows.
|
|
|
|
The two surfaces, side welt and blended weights form one deforming garment;
|
|
folds are geometric, so the silhouette survives export without shader tricks.
|
|
"""
|
|
vs, fs, ws, uv = [], [], [], []
|
|
for back in (0, 1):
|
|
for j, (z, width, front, curve) in enumerate(rows):
|
|
t = j / (len(rows) - 1)
|
|
for k in range(cols + 1):
|
|
u = k / cols * 2 - 1
|
|
y = front + curve * u * u + folds * sin(u * pi * 3 + t) * sin(pi * t)
|
|
vs.append((u * width, y + back * depth, z + .008 * cos(u * pi) * t))
|
|
ws.append(mixweights(t, bones)); uv.append((k / cols, 1-t))
|
|
stride = cols + 1; layer = len(rows) * stride
|
|
for back in (0, 1):
|
|
for j in range(len(rows) - 1):
|
|
for k in range(cols):
|
|
a = back * layer + j * stride + k
|
|
q = (a, a+1, a+stride+1, a+stride)
|
|
fs.append(q if back else tuple(reversed(q)))
|
|
boundary = list(range(stride)) + [j*stride+cols for j in range(1,len(rows))]
|
|
boundary += list(range(layer-2,layer-stride-1,-1)) + [j*stride for j in range(len(rows)-2,0,-1)]
|
|
for a,b in zip(boundary, boundary[1:]+boundary[:1]):
|
|
fs.append((a,b,b+layer,a+layer))
|
|
return mesh(name, vs, fs, kind, bones[0], uv, weights=ws)
|
|
|
|
def boot(name, s, side):
|
|
"""Rounded last, broad sole and fitted ankle rather than stacked cubes."""
|
|
bone = 'Foot.' + side
|
|
ell(name, (s*.13,-.053,.071), (.087,.145,.068), 'boot', bone, 24, 10)
|
|
# A closed, flattened swept sole has a real broad contact surface.
|
|
vs, fs = [], []
|
|
for z, scale in [(0.006,.96),(.018,1),(.037,.97)]:
|
|
for k in range(32):
|
|
a = 2*pi*k/32
|
|
vs.append((s*.13 + .086*cos(a)*scale,-.055+.147*sin(a)*scale,z))
|
|
for j in range(2):
|
|
for k in range(32):
|
|
q=(k+1)%32;fs.append((j*32+k,j*32+q,(j+1)*32+q,(j+1)*32+k))
|
|
fs += [tuple(reversed(range(32))),tuple(range(64,96))]
|
|
mesh('Sole_'+side,vs,fs,'darkmetal',bone)
|
|
tube('Ankle_'+side,[(s*.13,-.01,.06),(s*.125,-.008,.14),(s*.12,-.003,.19)],[.068,.076,.071],'boot',['Foot.'+side,'Shin.'+side],18,14,flatten=.91,groove=.01)
|
|
tube('Cuff_'+side,[(s*.125,-.008,.151),(s*.12,-.003,.182)],[.080,.077],'leather',['Shin.'+side],18,6,flatten=.91,groove=.01)
|
|
for z in [.080,.099,.118]:
|
|
tube('Lace_'+side+str(z),[(s*.13-.04,-.141,z),(s*.13,-.153,z+.007),(s*.13+.04,-.141,z)],[.005,.006,.005],'leather',[bone],6,8,groove=0)
|
|
|
|
# ---- Body: stocky gatherer, readable silhouette from all sides ----
|
|
tube('Tunic',[(0,.015,.40),(0,.015,.51),(0,.018,.65),(0,.016,.77),(0,.01,.82)],[.175,.204,.206,.184,.119],'cloth',['Pelvis','Spine','Chest'],28,32,flatten=.77,groove=.018)
|
|
fabric('Apron',[(.735,.095,-.153,.021),(.70,.112,-.166,.019),(.63,.154,-.172,.026),(.54,.179,-.182,.033),(.45,.173,-.175,.031),(.36,.163,-.166,.021),(.285,.148,-.16,.017)],'leather',['Chest','Spine','Pelvis'],depth=.014,folds=.012)
|
|
for s, side in [(-1,'L'),(1,'R')]:
|
|
tube('ApronWelt_'+side,[(s*.094,-.155,.737),(s*.112,-.149,.70),(s*.154,-.148,.63),(s*.179,-.151,.54),(s*.173,-.146,.45),(s*.163,-.147,.36),(s*.148,-.147,.285)],[.005]*7,'boot',['Chest','Spine','Pelvis'],6,30,groove=0)
|
|
tube('Harness_'+side,[(s*.085,-.155,.728),(s*.13,-.115,.805),(s*.145,.002,.84),(s*.13,.14,.785),(s*.105,.179,.59)],[.026,.025,.025,.023,.022],'leather',['Chest'],12,28,flatten=.28,groove=0)
|
|
ell('ApronRivet_'+side,(s*.084,-.163,.716),(.009,.006,.009),'brass','Chest',10,5)
|
|
for j in range(8):
|
|
z=.31+j*.035;x=s*(.151+.020*sin((z-.31)*9))
|
|
tube('ApronStitch_'+side+str(j),[(x,-.152,z),(x,-.154,z+.009)],[.0017,.0017],'brass',['Pelvis' if z<.48 else 'Spine'],5,2,groove=0)
|
|
tube('ApronHem',[(-.148,-.147,.285),(-.07,-.17,.291),(0,-.163,.293),(.07,-.167,.291),(.148,-.147,.285)],[.006]*5,'boot',['Pelvis'],8,26,groove=0)
|
|
# Curved chest pocket and stitched flap conform to the belly.
|
|
pocket=fabric('ToolPocket',[(.535,.067,-.20,.012),(.50,.070,-.219,.016),(.455,.062,-.221,.014),(.443,.046,-.208,.010)],'leather',['Spine','Pelvis'],depth=.008,folds=.002,cols=12)
|
|
for v in pocket.data.vertices:v.co.x+=.058
|
|
tube('PocketLip',[(0,-.197,.535),(.055,-.208,.538),(.12,-.195,.535)],[.005,.006,.005],'boot',['Spine'],8,14,groove=0)
|
|
for s in [-1,1]:
|
|
tube('Belt_'+str(s),[(0,-.178,.545),(s*.18,-.119,.548),(s*.204,.045,.555),(s*.14,.166,.565),(0,.178,.565)],[.016]*5,'leather',['Spine'],12,25,flatten=.44,groove=0)
|
|
|
|
# Framed glass reservoir on the back for gathered sparks.
|
|
frame('ReservoirFrame', (0, .22, .72), .28, .34, .16, 'brass', 'Chest')
|
|
box('ReservoirGlass', (0, .22, .72), (.24, .12, .30), 'glass', 'Chest')
|
|
ell('ReservoirLiquid', (0, .20, .64), (.10, .05, .08), 'spark', 'Chest', 10, 6)
|
|
for k, z in enumerate([.58, .72, .86]):
|
|
box('ReservoirBrace_%d' % k, (0, .22, z), (.30, .17, .025), 'brass', 'Chest')
|
|
|
|
# Head: hood, lamp, goggles, recessed face.
|
|
ell('Neck', (0, .01, .875), (.075, .077, .088), 'skin', 'Head',20,10)
|
|
tube('Collar',[(0,.015,.818),(0,.008,.868)],[.112,.094],'hood',['Chest','Head'],24,10,flatten=.79,groove=.014)
|
|
ell('Head', (0, -.014, 1.008), (.127, .128, .167), 'skin', 'Head',24,14)
|
|
ell('Hood', (0, .040, 1.032), (.155, .154, .190), 'hood', 'Head',24,14)
|
|
ell('FaceRecess', (0, -.115, .997), (.112, .058, .12), 'boot', 'Head',24,12)
|
|
ell('Face', (0, -.14, 1.00), (.090, .051, .108), 'skin', 'Head',24,12)
|
|
ell('Chin', (0, -.140, .931), (.064, .043, .040), 'skin', 'Head',16,8)
|
|
for s, side in [(-1,'L'),(1,'R')]:
|
|
tube('HoodWelt_'+side,[(s*.03,-.100,.889),(s*.115,-.125,.956),(s*.127,-.139,1.041),(s*.096,-.119,1.115)],[.011,.017,.013,.012],'cloth',['Head'],10,18,groove=.015)
|
|
ell('Cheek_'+side,(s*.065,-.162,.986),(.038,.031,.045),'skin','Head',16,8)
|
|
for s, side in [(-1, 'L'), (1, 'R')]:
|
|
ell('GoggleSocket_' + side, (s * .07, -.15, 1.05), (.05, .03, .04), 'darkmetal', 'Head', 10, 6)
|
|
ell('GoggleLens_' + side, (s * .07, -.17, 1.05), (.038, .018, .03), 'lens', 'Head', 10, 6)
|
|
ell('GoggleGlint_' + side, (s * .055, -.185, 1.06), (.01, .006, .008), 'lamp', 'Head', 8, 4)
|
|
box('GoggleBridge', (0, -.16, 1.05), (.05, .03, .025), 'darkmetal', 'Head')
|
|
ell('Cap', (0, .020, 1.135), (.158, .162, .086), 'cloth', 'Head',28,12)
|
|
fabric('CapPeak',[(1.133,.135,-.072,.015),(1.125,.163,-.14,.009),(1.108,.164,-.196,.018),(1.102,.129,-.222,.024)],'hood',['Head'],depth=.013,folds=.001,cols=24)
|
|
tube('CapSeam',[(-.151,.022,1.153),(-.098,.018,1.197),(0,.010,1.221),(.101,.018,1.197),(.151,.022,1.153)],[.004]*5,'hood',['Head'],6,26,groove=0)
|
|
tube('LampHousing',[(0,-.101,1.17),(0,-.158,1.17)],[.045,.044],'brass',['Head'],20,8,groove=0)
|
|
ell('Lamp', (0, -.171, 1.17), (.034, .014, .034), 'lamp', 'Head', 20, 8)
|
|
ell('Nose',(0,-.188,1.006),(.021,.031,.030),'skin','Head',14,8)
|
|
tube('Mouth',[(-.030,-.185,.957),(0,-.188,.953),(.030,-.185,.957)],[.0025,.003,.0025],'boot',['Head'],6,10,groove=0)
|
|
|
|
# Arms, gloves, fingers and tools.
|
|
for s, side in [(-1, 'L'), (1, 'R')]:
|
|
arm = ['UpperArm.' + side, 'Forearm.' + side, 'Hand.' + side]
|
|
tube('Sleeve_' + side, [(s*.17,.01,.797),(s*.24,.00,.75),(s*.30,-.02,.64),(s*.33,-.075,.54),(s*.34,-.10,.478)], [.08,.088,.073,.072,.057], 'cloth', [arm[0], arm[1]], 18, 32,groove=.018)
|
|
ell('ElbowPatch_' + side, (s * .311, .014, .644), (.068, .048, .071), 'leather', arm[1], 14, 8)
|
|
tube('SleeveCuff_'+side,[(s*.332,-.083,.523),(s*.341,-.103,.482)],[.066,.062],'leather',[arm[1]],18,8,groove=.014)
|
|
ell('Glove_' + side, (s * .348, -.12, .44), (.067, .055, .064), 'glove', arm[2],20,10)
|
|
for finger in range(3):
|
|
z=.432-finger*.029; hx=s*(.345+finger*.006)
|
|
tube('Finger_%s%d' % (side, finger), [(hx+s*.047,-.119,z+.002),(hx+s*.050,-.151,z),(hx+s*.024,-.177,z-.006),(hx-s*.010,-.173,z-.012)], [.016,.018,.015,.010], 'glove', [arm[2], 'Finger%d.%s' % (finger, side)], 10, 14,groove=.014)
|
|
tube('Thumb_' + side, [(s*.306,-.109,.459),(s*.310,-.158,.438),(s*.341,-.174,.429)], [.026,.020,.014], 'glove', [arm[2], 'Thumb.' + side], 10, 14,groove=.014)
|
|
|
|
# Left: gathering fork. Right: slender probe.
|
|
tube('ForkHandle', [(-.35, -.14, .40), (-.38, -.22, .22), (-.40, -.26, .08)], [.025, .022, .02], 'leather', ['Hand.L'], 8, 12)
|
|
for tine in range(3):
|
|
x = -.40 + (tine - 1) * .045
|
|
tube('ForkTine_%d' % tine, [(x, -.26, .08), (x, -.30, -.02), (x, -.28, -.10)], [.014, .012, .004], 'metal', ['Hand.L'], 8, 10)
|
|
box('ForkGuard', (-.40, -.25, .10), (.14, .04, .03), 'brass', 'Hand.L')
|
|
tube('Probe', [(.35, -.14, .40), (.38, -.20, .24), (.40, -.22, .12)], [.018, .014, .008], 'metal', ['Hand.R'], 8, 10)
|
|
ell('ProbeTip', (.40, -.23, .10), (.018, .018, .02), 'spark', 'Hand.R', 8, 4)
|
|
|
|
# Legs and boots with cuffs and toe ridges.
|
|
for s, side in [(-1, 'L'), (1, 'R')]:
|
|
leg = ['Thigh.' + side, 'Shin.' + side, 'Foot.' + side]
|
|
tube('Trousers_' + side, [(s*.10,.01,.435),(s*.113,.005,.32),(s*.12,0,.24),(s*.13,-.01,.15)], [.095,.098,.078,.068], 'cloth', [leg[0],leg[1]], 18, 26,groove=.018)
|
|
ell('KneePatch_'+side,(s*.12,-.066,.25),(.058,.027,.067),'leather',leg[1],14,8)
|
|
boot('Boot_'+side,s,side)
|
|
|
|
# Weld, triangulate and orient closed solids before skinning.
|
|
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 for a compact gatherer.
|
|
bpy.ops.object.armature_add(enter_editmode=True)
|
|
rig = bpy.context.object
|
|
rig.name = 'ArbeiterRig'
|
|
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, .12))
|
|
bone('Pelvis', (0, .01, .40), (0, .02, .58), 'Root')
|
|
bone('Spine', (0, .02, .58), (0, .02, .74), 'Pelvis')
|
|
bone('Chest', (0, .02, .74), (0, .01, .88), 'Spine')
|
|
bone('Head', (0, .01, .88), (0, 0, 1.16), 'Chest')
|
|
for s, side in [(-1, 'L'), (1, 'R')]:
|
|
bone('UpperArm.' + side, (s * .20, .01, .78), (s * .30, -.02, .64), 'Chest')
|
|
bone('Forearm.' + side, (s * .30, -.02, .64), (s * .34, -.10, .48), 'UpperArm.' + side)
|
|
bone('Hand.' + side, (s * .34, -.10, .48), (s * .35, -.15, .38), 'Forearm.' + side)
|
|
for f in range(3):
|
|
x = s * .35 + (f - 1) * .035
|
|
bone('Finger%d.%s' % (f, side), (x, -.15, .40), (x + s * .01, -.19, .33), 'Hand.' + side)
|
|
bone('Thumb.' + side, (s * .30, -.10, .44), (s * .27, -.17, .37), 'Hand.' + side)
|
|
bone('Thigh.' + side, (s * .10, .01, .42), (s * .12, 0, .24), 'Pelvis')
|
|
bone('Shin.' + side, (s * .12, 0, .24), (s * .13, -.01, .08), 'Thigh.' + side)
|
|
bone('Foot.' + side, (s * .13, -.01, .08), (s * .13, -.16, .03), 'Shin.' + side)
|
|
bone('Toe.' + side, (s * .13, -.16, .03), (s * .13, -.20, .03), 'Foot.' + side)
|
|
bpy.ops.object.mode_set(mode='OBJECT')
|
|
for ob in parts:
|
|
mod = ob.modifiers.new('Weighted gatherer', 'ARMATURE')
|
|
mod.object = rig
|
|
ob.parent = rig
|
|
|
|
# Pose clips at 30 fps. Work cycle: strike (tool contact) → pickup → deposit → return.
|
|
clips = {
|
|
'idle': (2.8, True),
|
|
'start': (.35, False),
|
|
'walk': (1.1, True),
|
|
'stop': (.35, False),
|
|
'strike': (1.30, False),
|
|
'pickup': (1.15, False),
|
|
'deposit': (1.20, False),
|
|
'return': (.95, False),
|
|
'hit': (.42, False),
|
|
'death': (1.9, 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', .01 * q)
|
|
rot('Chest', -.014 * q, 0, .008 * q)
|
|
rot('Head', .02 * q, 0, -.012 * q)
|
|
for s, side in [(-1, 'L'), (1, 'R')]:
|
|
rot('Forearm.' + side, .02 * q, 0, s * .01 * q)
|
|
rot('Finger0.' + side, .04 * 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.1 * 2 * pi
|
|
roll = sin(phase) * fade
|
|
rot('Pelvis', .01, 0, .03 * roll)
|
|
rot('Spine', -.03 * fade, 0, -.04 * roll)
|
|
rot('Chest', .012 * fade, 0, .02 * roll)
|
|
rot('Head', .02 * fade, 0, -.015 * 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, .28 * q * fade)
|
|
rot('Shin.' + side, -.34 * lift * fade)
|
|
rot('Foot.' + side, (-.28 * q + .34 * lift) * fade)
|
|
rot('Toe.' + side, .10 * max(0, -q) * fade)
|
|
rot('UpperArm.' + side, -.08 * q * fade, 0, s * .03 * fade)
|
|
rot('Forearm.' + side, -.12 * fade - .04 * lift)
|
|
# Reservoir lag: chest sways against the carried load.
|
|
locs['Root'] = (0, 0, (-.008 + .008 * cos(phase * 2)) * fade)
|
|
elif name == 'strike':
|
|
# Contact at 0.62 — the fork tip reaches the ore at the damage frame.
|
|
q = curve(t / dur, [(0, 0), (.28, -.40), (.48, -.62), (.62, 1), (.72, .82), (1, 0)])
|
|
rot('Spine', -.14 * q, .08 * q, .06 * q)
|
|
rot('Chest', -.10 * q, .06 * q, .10 * q)
|
|
rot('Head', .12 * q, -.05 * q, -.06 * q)
|
|
rot('UpperArm.L', -.95 * q, -.10 * q, .12 * q)
|
|
rot('Forearm.L', -.35 * q)
|
|
rot('Hand.L', .22 * q)
|
|
rot('UpperArm.R', .18 * q, 0, -.08 * q)
|
|
rot('Forearm.R', -.15 * q)
|
|
for f in range(3):
|
|
rot('Finger%d.L' % f, -.30 * max(0, q))
|
|
rot('Thigh.L', .08 * q)
|
|
rot('Thigh.R', -.04 * q)
|
|
locs['Root'] = (0, -.03 * q, -.01 * max(0, q))
|
|
elif name == 'pickup':
|
|
q = curve(t / dur, [(0, 0), (.35, 1), (.70, .85), (1, 0)])
|
|
rot('Spine', .38 * q)
|
|
rot('Chest', .22 * q)
|
|
rot('Head', .18 * q)
|
|
rot('Thigh.L', .32 * q)
|
|
rot('Thigh.R', .28 * q)
|
|
rot('Shin.L', -.48 * q)
|
|
rot('Shin.R', -.42 * q)
|
|
rot('Foot.L', .20 * q)
|
|
rot('Foot.R', .18 * q)
|
|
rot('UpperArm.L', -.55 * q, 0, .18 * q)
|
|
rot('Forearm.L', -.40 * q)
|
|
rot('UpperArm.R', -.48 * q, 0, -.18 * q)
|
|
rot('Forearm.R', -.38 * q)
|
|
for s, side in [(-1, 'L'), (1, 'R')]:
|
|
for f in range(3):
|
|
rot('Finger%d.%s' % (f, side), -.45 * max(0, q))
|
|
locs['Root'] = (0, -.02 * q, -.08 * q)
|
|
elif name == 'deposit':
|
|
# Lift the gathered spark into the back reservoir.
|
|
q = curve(t / dur, [(0, 0), (.30, .7), (.55, 1), (.80, .6), (1, 0)])
|
|
rot('Spine', -.08 * q, -.25 * q, 0)
|
|
rot('Chest', -.05 * q, -.20 * q, 0)
|
|
rot('Head', -.10 * q, .15 * q, 0)
|
|
rot('UpperArm.R', -.70 * q, -.35 * q, -.25 * q)
|
|
rot('Forearm.R', -.90 * q)
|
|
rot('Hand.R', .40 * q)
|
|
rot('UpperArm.L', .15 * q, 0, .10 * q)
|
|
for f in range(3):
|
|
rot('Finger%d.R' % f, -.55 * max(0, q))
|
|
locs['Root'] = (0, .01 * q, .02 * q)
|
|
elif name == 'return':
|
|
# Step back to the station and settle into the ready stance.
|
|
q = curve(t / dur, [(0, 0), (.40, 1), (1, 0)])
|
|
rot('Spine', .06 * q, 0, -.04 * q)
|
|
rot('Thigh.L', -.18 * q)
|
|
rot('Thigh.R', .14 * q)
|
|
rot('Shin.L', .12 * q)
|
|
rot('Foot.L', -.10 * q)
|
|
rot('UpperArm.L', .10 * q)
|
|
rot('UpperArm.R', .08 * q)
|
|
rot('Head', .04 * q)
|
|
locs['Root'] = (0, .04 * q, 0)
|
|
elif name == 'hit':
|
|
q = curve(t, [(0, 0), (.07, 1), (.20, .45), (.42, 0)])
|
|
rot('Spine', .14 * q, 0, -.08 * q)
|
|
rot('Head', .15 * q, 0, .06 * q)
|
|
rot('Forearm.L', -.12 * q)
|
|
rot('Forearm.R', -.10 * q)
|
|
elif name == 'death':
|
|
q = curve(t, [(0, 0), (.18, .08), (.65, .42), (1.20, 1), (1.50, .92), (1.9, 1)])
|
|
rot('Root', -.85 * q, 0, .10 * q)
|
|
locs['Root'] = (0, -.12 * q, -.10 * q)
|
|
rot('Pelvis', -.18 * q)
|
|
rot('Spine', -.22 * q)
|
|
rot('Head', -.18 * q)
|
|
for s, side in [(-1, 'L'), (1, 'R')]:
|
|
rot('UpperArm.' + side, -.28 * q, 0, s * .14 * q)
|
|
rot('Forearm.' + side, -.40 * q)
|
|
rot('Thigh.' + side, .22 * q)
|
|
rot('Shin.' + side, -.38 * 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)
|
|
|
|
bpy.ops.wm.save_as_mainfile(filepath=str(SRC / 'arbeiter.blend'))
|
|
bpy.ops.file.make_paths_relative()
|
|
bpy.ops.wm.save_as_mainfile(filepath=str(SRC / 'arbeiter.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 = 'ArbeiterBody'
|
|
body.select_set(True)
|
|
rig.select_set(True)
|
|
bpy.context.view_layer.objects.active = rig
|
|
bpy.ops.export_scene.gltf(
|
|
filepath=str(OUT / 'arbeiter.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 / 'arbeiter.glb').read_bytes()
|
|
import struct
|
|
doc = json.loads(raw[20:20 + struct.unpack_from('<I', raw, 12)[0]])
|
|
triangles = sum(doc['accessors'][q['indices']]['count'] // 3 for m in doc['meshes'] for q in m['primitives'])
|
|
mins = [min(v.co[i] for v in body.data.vertices) for i in range(3)]
|
|
maxs = [max(v.co[i] for v in body.data.vertices) for i in range(3)]
|
|
record = {
|
|
'id': 'worker',
|
|
'role': 'worker',
|
|
'name': 'Funkenleser',
|
|
'resource_path': 'res://assets/characters/arbeiter.glb',
|
|
'triangles': triangles,
|
|
'mesh_parts': 1,
|
|
'parts': ['ArbeiterBody'],
|
|
'bounds_godot': {
|
|
'min': [mins[0], mins[2], -maxs[1]],
|
|
'max': [maxs[0], maxs[2], -mins[1]],
|
|
},
|
|
'skeletal': True,
|
|
'bones': len(defs),
|
|
'clips': list(clips),
|
|
'work_cycle': ['strike', 'pickup', 'deposit', 'return'],
|
|
'sha256': hashlib.sha256(raw).hexdigest(),
|
|
'source': 'assets-source/phase12/arbeiter.blend',
|
|
}
|
|
manifest = {'assets': [record]}
|
|
# Core presentation metadata for activation / operation / damage reactions.
|
|
manifest['cores'] = [
|
|
{'id': 'core_team0', 'activate_clip': 'activate', 'operate_clip': 'operate', 'damage_clip': 'damage'},
|
|
{'id': 'core_team1', 'activate_clip': 'activate', 'operate_clip': 'operate', 'damage_clip': 'damage'},
|
|
]
|
|
(P / 'assets/phase12_manifest.json').write_text(json.dumps(manifest, indent=2) + '\n')
|
|
(SRC / 'arbeiter-audit.json').write_text(json.dumps({
|
|
'record': record,
|
|
'exported_clips': [a.get('name') for a in doc.get('animations', [])],
|
|
'skins': len(doc.get('skins', [])),
|
|
'source_parts': len(parts),
|
|
'source_units': 'metres',
|
|
'seed': 52,
|
|
}, indent=2) + '\n')
|
|
print('ARBEITER_BUILD', triangles, 'triangles', len(defs), 'bones', len(doc.get('animations', [])), 'clips', flush=True)
|