champions-of-luneraria/tools/meshy/build_machine.py
Martin Stickelbroeck aa77d0e7fb U5.5 Glasballiste: fünfte Meshy-Figur (g06) als Maschine
Laufende Balliste ohne Arme: statisches Meshy-Modell aus Vorder- und
Rückansicht, Skelett und Clips der Phase-19-Figur per Messpunkten
angepasst (build_machine.py), starre Bindung mit Knochenbereichen.
54 Credits. Stresstest 36 Glasballisten gegen Phase 19: Mid gleich,
Karte 117,8 -> 132,8 fps. Tests grün, Fingerabdrücke unverändert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:46:56 +02:00

258 lines
10 KiB
Python

"""Builds a non-humanoid figure of the Unity port from a static Meshy model and the rig and clips of
the figure it replaces (U5.5: the Glasballiste, a walking ballista; Meshy's auto rig only takes
humanoids). Source of truth: the untouched Meshy model, the rig file extracted from the Godot
reference (armature and actions only) and tools/meshy/<id>/figure.json.
- the model: stray islands removed, scaled to the figure's width, standing on the ground,
centred over its feet;
- the old rig fitted to it by landmarks (leg spread, hip and weapon height, bolt tip, back of
the stock, bow tips): every bone group is moved to the matching part of the new model, the
location keys of the clips follow the overall scale;
- rigid skinning: every vertex follows its nearest bone (a machine of hard parts);
- textures, glow, faction palette and lane copy as for the Meshy figures (build_figure.py).
Standalone: blender --background --python tools/meshy/build_machine.py -- tools/meshy/g06/figure.json [--save out.blend]
"""
import importlib.util
import json
import sys
from pathlib import Path
import bpy
import numpy as np
from mathutils import Matrix, Vector
_spec = importlib.util.spec_from_file_location('lune_build_figure', str(Path(__file__).resolve().parent / 'build_figure.py'))
bf = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(bf)
def keep_main_islands(mesh, share):
"""Removes parts that hang free in the air (Meshy left a loose crystal tip): connected
components (seam copies joined by position) below a share of all vertices."""
import bmesh
data = mesh.data
count = len(data.vertices)
parent = list(range(count))
def find(i):
while parent[i] != i:
parent[i] = parent[parent[i]]
i = parent[i]
return i
def union(a, b):
ra, rb = find(a), find(b)
if ra != rb:
parent[ra] = rb
for poly in data.polygons:
verts = list(poly.vertices)
for v in verts[1:]:
union(verts[0], v)
seen = {}
for v in data.vertices:
key = tuple(round(c, 5) for c in v.co)
if key in seen:
union(seen[key], v.index)
else:
seen[key] = v.index
sizes = {}
for i in range(count):
root = find(i)
sizes[root] = sizes.get(root, 0) + 1
drop = {i for i in range(count) if sizes[find(i)] < share * count}
bm = bmesh.new()
bm.from_mesh(data)
bm.verts.ensure_lookup_table()
bmesh.ops.delete(bm, geom=[bm.verts[i] for i in sorted(drop)], context='VERTS')
bm.to_mesh(data)
bm.free()
return len(drop)
def place(mesh, width):
"""Uniform scale to the figure's width; feet on the ground, centred over them."""
data = mesh.data
co = np.array([v.co[:] for v in data.vertices])
scale = width / (co[:, 0].max() - co[:, 0].min())
co *= scale
low = co[:, 2].min()
feet = co[co[:, 2] < low + 0.08 * (co[:, 2].max() - low)]
offset = np.array([feet[:, 0].mean(), feet[:, 1].mean(), low])
co -= offset
data.vertices.foreach_set('co', co.ravel())
data.update()
return co, scale
def landmarks(co):
height = co[:, 2].max()
low = co[co[:, 2] < 0.35 * height]
reach = np.abs(co[:, 0]).max()
bow = co[np.abs(co[:, 0]) > 0.85 * reach]
weapon_z = float(bow[:, 2].mean())
top = co[co[:, 2] > weapon_z - 0.12 * height]
return {'height': float(height), 'leg_x': float(np.abs(low[:, 0]).mean()), 'weapon_z': weapon_z,
'tip_y': float(co[:, 1].min()), 'back_y': float(top[:, 1].max()), 'bow_x': float(reach), 'bow_y': float(bow[:, 1].mean())}
def fit_rig(arm, old, new):
"""Moves every bone group of the old rig onto the matching part of the new model."""
s = new['weapon_z'] / old['weapon_z']
hip_old, hip_new = old['hip_z'], new['weapon_z'] * old['hip_z'] / old['weapon_z']
front = new['tip_y'] / old['tip_y']
back = new['back_y'] / old['back_y']
def weapon(p):
y = p.y * (front if p.y < 0 else back)
return Vector((p.x * s, y, p.z - old['weapon_z'] + new['weapon_z']))
def legs(p):
return Vector((p.x * new['leg_x'] / old['leg_x'], p.y * s, p.z * hip_new / hip_old))
def spine(p):
t = (p.z - hip_old) / (old['weapon_z'] - hip_old)
return Vector((p.x * s, p.y * s, hip_new + t * (new['weapon_z'] - hip_new)))
def limb(p, tip):
q = weapon(p)
if tip:
q.x = np.sign(p.x) * new['bow_x']
q.y = new['bow_y']
else:
q.x = p.x * new['bow_x'] / old['bow_x']
return q
bpy.context.view_layer.objects.active = arm
bpy.ops.object.mode_set(mode='EDIT')
for bone in arm.data.edit_bones:
name = bone.name
head, tail = bone.head.copy(), bone.tail.copy()
if name.startswith(('Thigh', 'Shin', 'Tarsus', 'Foot', 'Pelvis')):
bone.head, bone.tail = legs(head), legs(tail)
elif name == 'Spine':
bone.head, bone.tail = spine(head), spine(tail)
elif name.startswith('LimbTip'):
bone.head, bone.tail = limb(head, False), limb(tail, True)
elif name.startswith('Limb'):
bone.head, bone.tail = limb(head, False), limb(tail, False)
elif name != 'Root':
bone.head, bone.tail = weapon(head), weapon(tail)
bpy.ops.object.mode_set(mode='OBJECT')
return s
def fcurves(action):
if hasattr(action, 'layers') and len(action.layers):
for layer in action.layers:
for strip in layer.strips:
for bag in strip.channelbags:
yield from bag.fcurves
elif hasattr(action, 'fcurves'):
yield from action.fcurves
def scale_locations(action, s):
for curve in fcurves(action):
if curve.data_path.endswith('location'):
for key in curve.keyframe_points:
key.co.y *= s
key.handle_left.y *= s
key.handle_right.y *= s
def rigid_skin(mesh, arm, regions):
"""Every vertex follows its nearest deforming bone (distance to the bone segment). A bone with
a region (figure.json "bone_regions", e.g. the bolt only in front of the bow) takes only
vertices inside it, so a long rail bone does not carry the whole stock."""
bones = [b for b in arm.data.bones if b.use_deform and b.name != 'Root']
co = np.array([v.co[:] for v in mesh.data.vertices])
best = np.full(len(co), np.inf)
owner = np.zeros(len(co), dtype=int)
for index, bone in enumerate(bones):
a = np.array((arm.matrix_world @ bone.head_local)[:])
b = np.array((arm.matrix_world @ bone.tail_local)[:])
ab = b - a
t = np.clip(((co - a) @ ab) / max(ab @ ab, 1e-9), 0.0, 1.0)
d = np.linalg.norm(co - (a + t[:, None] * ab), axis=1)
region = regions.get(bone.name, {})
for axis, name in enumerate('xyz'):
if name + '_max' in region:
d[co[:, axis] > region[name + '_max']] = np.inf
if name + '_min' in region:
d[co[:, axis] < region[name + '_min']] = np.inf
closer = d < best
best[closer] = d[closer]
owner[closer] = index
counts = {}
for index, bone in enumerate(bones):
members = np.nonzero(owner == index)[0].tolist()
group = mesh.vertex_groups.new(name=bone.name)
if members:
group.add(members, 1.0, 'REPLACE')
counts[bone.name] = len(members)
modifier = mesh.modifiers.new('Armature', 'ARMATURE')
modifier.object = arm
mesh.parent = arm
mesh.matrix_parent_inverse = Matrix.Identity(4)
return counts
def build(figure_path, source_root, unity_root):
figure = json.loads(figure_path.read_text())
bpy.ops.wm.read_factory_settings(use_empty=True)
scene = bpy.context.scene
scene.render.fps = bf.FPS
scene.render.fps_base = 1.0
scene.unit_settings.system = 'METRIC'
scene.unit_settings.scale_length = 1.0
objects = bf.import_glb(source_root / figure['model'])
mesh = next(o for o in objects if o.type == 'MESH')
for obj in objects:
if obj is not mesh:
bpy.data.objects.remove(obj, do_unlink=True)
bpy.ops.object.select_all(action='DESELECT')
mesh.select_set(True)
bpy.context.view_layer.objects.active = mesh
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
dropped = keep_main_islands(mesh, figure.get('min_island_share', 0.01))
co, model_scale = place(mesh, figure['width'])
new = landmarks(co)
with bpy.data.libraries.load(str(source_root / figure['rig_blend'])) as (src, dst):
dst.objects = [figure['armature']]
dst.actions = list(src.actions)
arm = dst.objects[0]
scene.collection.objects.link(arm)
if arm.animation_data:
arm.animation_data.action = None
for action in list(bpy.data.actions):
if action.name not in figure['clips']:
bpy.data.actions.remove(action)
s = fit_rig(arm, figure['rig_landmarks'], new)
for action in bpy.data.actions:
scale_locations(action, s)
action.use_fake_user = True
mesh.name = mesh.data.name = figure['id'].capitalize() + 'Body'
weights = rigid_skin(mesh, arm, figure.get('bone_regions', {}))
material = mesh.material_slots[0].material
maps = bf.textures(material, str(unity_root / figure['textures']), figure['glow'], figure.get('palette', {}))
material.name = figure['material']
for node in list(material.node_tree.nodes):
if node.type not in ('BSDF_PRINCIPLED', 'OUTPUT_MATERIAL'):
material.node_tree.nodes.remove(node)
for image in list(bpy.data.images):
bpy.data.images.remove(image)
lod = bf.lane_lod(mesh, figure['lod']) if 'lod' in figure else None
scene.frame_set(0)
clips = {a.name: {'seconds': round((a.frame_range[1] - a.frame_range[0]) / bf.FPS, 4), 'loop': bool(a.get('loop', False)),
'contact_seconds': float(a.get('contact_seconds', -1.0))} for a in bpy.data.actions}
return {'model_scale': round(model_scale, 4), 'dropped_vertices': dropped, 'landmarks': {k: round(v, 4) for k, v in new.items()},
'rig_scale': round(s, 4), 'weights': weights, 'clips': clips, 'textures': maps, 'lod': lod}
if __name__ in ('__main__', '__lune_generator__'):
bf.main(build, globals())