fix(assets): T-1090 — five fork bodies rebuilt; mesh+armature scale baked together
Root cause was double: (1) segment_body's apply_scale scaled fork MESH vertices but not each segment's embedded armature — the shared-skeleton compositor relocates segments by bone name, so internally-inconsistent segments exploded (child worst at 0.72x: head bone 0.35m above its mesh — detached heads, spider arms); (2) thin/heavy were stale high-poly artifacts from an older segmentation, missing seg_hips. Fix: apply_fork_scale bakes mesh AND embedded armature via transform_apply (edit-bone poking shears chains — first attempt proved it); new blender_rebuild_forks.py rebuilds exactly the five from the owned UBC Source exports. All five now 19 low-poly segments matching the healthy six. QA on the real compositor (idle+walk, front+side): 5/5 coherent; healthy controls unchanged. Q-060 answered at the extremes: 15/15 peasant-garment Surface Deform binds on the forks, zero shrinkwrap fallbacks, no bust-through — the 6-of-11 placeholder debt is paid (fork garment variants included). Follow-up filed: T-1094 (child/teen composite at adult height — pre-existing shared-skeleton normalization, not a regression). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
blender_rebuild_forks.py
|
||||
Usage: tooling/blender --background --python tooling/blender_rebuild_forks.py -- <source_dir> <output_base_dir> [body_type ...]
|
||||
|
||||
Rebuilds ONLY the fork body types (thin_m/f, heavy_m/f, child) through the same
|
||||
segmentation pipeline that produced the healthy six (blender_segment_body.py),
|
||||
using the T-1090 fix: the fork scale is applied to the mesh AND the embedded
|
||||
armature rest pose so each segment stays internally consistent with its own
|
||||
skeleton (required by the shared-skeleton compositor in character_visual.gd).
|
||||
|
||||
Sources (Source-tier Godot - UE exports):
|
||||
Regular_Male_FullBody.gltf + scale (0.82, 1.0, 0.88) -> thin_m
|
||||
Regular_Female_FullBody.gltf + scale (0.82, 1.0, 0.88) -> thin_f
|
||||
Regular_Male_FullBody.gltf + scale (1.20, 1.08, 1.0) -> heavy_m
|
||||
Regular_Female_FullBody.gltf + scale (1.20, 1.08, 1.0) -> heavy_f
|
||||
Teen_Male_FullBody.gltf + scale (0.75, 0.72, 0.75) -> child [gender-neutral]
|
||||
|
||||
The fork scale factors and source mapping are unchanged from
|
||||
blender_process_bodies.py — this driver reuses the same table so the healthy six
|
||||
are never touched. Output layout matches production: <out>/{body_type}/seg_*.glb.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, script_dir)
|
||||
from blender_segment_body import segment_body
|
||||
|
||||
# (gltf_filename, body_type_key, scale, source_label) — forks only.
|
||||
FORK_MANIFEST = [
|
||||
("Regular_Male_FullBody.gltf", "thin_m", (0.82, 1.0, 0.88), "Regular_Male_FullBody.gltf"),
|
||||
("Regular_Female_FullBody.gltf", "thin_f", (0.82, 1.0, 0.88), "Regular_Female_FullBody.gltf"),
|
||||
("Regular_Male_FullBody.gltf", "heavy_m", (1.20, 1.08, 1.0), "Regular_Male_FullBody.gltf"),
|
||||
("Regular_Female_FullBody.gltf", "heavy_f", (1.20, 1.08, 1.0), "Regular_Female_FullBody.gltf"),
|
||||
("Teen_Male_FullBody.gltf", "child", (0.75, 0.72, 0.75), "Teen_Male_FullBody.gltf"),
|
||||
]
|
||||
|
||||
FORK_README = """\
|
||||
# Fork body type — auto-generated from mesh + armature scaling (T-1090)
|
||||
|
||||
This directory contains **{body_type}** body segments, generated by applying a
|
||||
proportional scale to the source body ({source}) mesh AND its armature rest pose
|
||||
together, then segmenting:
|
||||
|
||||
Scale: ({sx:.2f}, {sy:.2f}, {sz:.2f})
|
||||
|
||||
The scale is applied to the mesh vertices and the embedded armature rest pose
|
||||
with the SAME affine (T-1090 fix). This keeps each segment internally consistent
|
||||
with its own skeleton, which the shared-skeleton compositor
|
||||
(character_visual.gd) requires — a mesh-only scale detaches the head and
|
||||
explodes the limbs on relocation.
|
||||
|
||||
Cross-sectional differentiation (thin = narrow, heavy = wide) survives the
|
||||
composite; global height normalises to the shared skeleton (see T-1090 report /
|
||||
Q-060 for the shared-skeleton scale-normalisation note).
|
||||
|
||||
Status: auto-generated (rebuilt T-1090), 18 segments incl. seg_hips
|
||||
"""
|
||||
|
||||
|
||||
def write_fork_readme(output_dir, body_type, source, scale):
|
||||
sx, sy, sz = scale
|
||||
with open(os.path.join(output_dir, "README.md"), "w") as f:
|
||||
f.write(FORK_README.format(body_type=body_type, source=source, sx=sx, sy=sy, sz=sz))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
argv = sys.argv
|
||||
args = argv[argv.index("--") + 1:] if "--" in argv else []
|
||||
if len(args) < 2:
|
||||
print("Usage: -- <source_dir> <output_base_dir> [body_type ...]")
|
||||
sys.exit(1)
|
||||
|
||||
source_dir = args[0]
|
||||
output_base_dir = args[1]
|
||||
only = set(args[2:]) # optional subset filter
|
||||
|
||||
manifest = [m for m in FORK_MANIFEST if not only or m[1] in only]
|
||||
|
||||
unique_sources = {gltf for (gltf, _, _, _) in manifest}
|
||||
missing = [
|
||||
os.path.join(source_dir, f)
|
||||
for f in sorted(unique_sources)
|
||||
if not os.path.exists(os.path.join(source_dir, f))
|
||||
]
|
||||
if missing:
|
||||
print("\nERROR: Missing source GLTF files:")
|
||||
for p in missing:
|
||||
print(f" {p}")
|
||||
sys.exit(1)
|
||||
|
||||
results = []
|
||||
for (gltf_filename, body_type, scale, source_label) in manifest:
|
||||
gltf_path = os.path.join(source_dir, gltf_filename)
|
||||
output_dir = os.path.join(output_base_dir, body_type)
|
||||
print(f"\n{'='*60}\n Fork: {body_type} scale={scale}\n Source: {gltf_filename}")
|
||||
exported, skipped = segment_body(gltf_path, output_dir, scale)
|
||||
write_fork_readme(output_dir, body_type, source_label, scale)
|
||||
results.append((body_type, exported, skipped))
|
||||
|
||||
print(f"\n{'='*60}\n=== Fork rebuild complete ===")
|
||||
for body_type, exported, skipped in results:
|
||||
print(f" {body_type}: {len(exported)} exported, {len(skipped)} skipped")
|
||||
@@ -125,6 +125,65 @@ def apply_scale(mesh_obj, sx, sy, sz):
|
||||
mesh_obj.data.update()
|
||||
|
||||
|
||||
def apply_fork_scale(body_mesh, special_meshes, armature, sx, sy, sz):
|
||||
"""
|
||||
Scale a fork body (thin/heavy/child) so mesh AND armature stay CONSISTENT.
|
||||
|
||||
This is the load-bearing fix for fork body types (T-1090). The segment GLBs
|
||||
are reparented onto a single SHARED skeleton at runtime (character_visual.gd
|
||||
loads skeleton/armature.glb and drives all segments by bone name). A segment
|
||||
composites coherently ONLY if its mesh matches its own embedded armature's
|
||||
rest pose — the shared skeleton then relocates the whole segment as a rigid
|
||||
unit (this is why the pristine-rig teen body composites fine despite a very
|
||||
different rig; see the T-1090 report).
|
||||
|
||||
The previous behaviour scaled mesh vertices ALONE, leaving the armature at
|
||||
source scale: the head mesh dropped ~0.35 m below the Head bone, limbs flung
|
||||
apart on relocation — the "detached head / spider arms" misrender.
|
||||
|
||||
The scale must be BAKED by Blender via object transform_apply, NOT by poking
|
||||
edit-bone head/tail directly: manual head/tail edits do not recompute bone
|
||||
roll or honour connected-chain constraints, so long chains (arm→hand→fingers,
|
||||
neck→head) accumulate error and still explode. transform_apply rebuilds the
|
||||
bone matrices correctly.
|
||||
|
||||
Method: de-parent meshes (keep transform) so mesh and armature are
|
||||
independent objects sharing the world origin, give each the SAME object
|
||||
scale, then apply. Identical affine about the same origin → mesh verts and
|
||||
bone rest move together; the Armature modifier + vertex groups re-derive a
|
||||
consistent bind, which the glTF exporter bakes into the inverse-bind
|
||||
matrices.
|
||||
"""
|
||||
if sx == 1.0 and sy == 1.0 and sz == 1.0:
|
||||
return
|
||||
print(f" Applying baked fork scale: ({sx:.3f}, {sy:.3f}, {sz:.3f})")
|
||||
meshes = [body_mesh] + [m for m in special_meshes if m is not None]
|
||||
|
||||
if armature.mode != 'OBJECT':
|
||||
bpy.context.view_layer.objects.active = armature
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
# De-parent meshes from the armature (keep world transform). The Armature
|
||||
# MODIFIER and vertex groups are untouched — only the parenting relationship
|
||||
# is cleared, so scaling each object about the origin is not double-applied.
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
for m in meshes:
|
||||
if m.parent is armature:
|
||||
m.select_set(True)
|
||||
if bpy.context.selected_objects:
|
||||
bpy.context.view_layer.objects.active = meshes[0]
|
||||
bpy.ops.object.parent_clear(type='CLEAR_KEEP_TRANSFORM')
|
||||
|
||||
# Scale armature + all meshes by the same object scale, then bake.
|
||||
objs = [armature] + meshes
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
for o in objs:
|
||||
o.select_set(True)
|
||||
o.scale = (sx, sy, sz)
|
||||
bpy.context.view_layer.objects.active = armature
|
||||
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
|
||||
|
||||
|
||||
def export_glb(objects, output_path):
|
||||
"""Select the given objects and export as GLB."""
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
@@ -353,13 +412,13 @@ def segment_body(gltf_path, output_dir, scale=(1.0, 1.0, 1.0)):
|
||||
remaining_names = [o.name for o in bpy.context.scene.objects]
|
||||
print(f" Removed {removed} utility objects. Scene now: {remaining_names}")
|
||||
|
||||
# Apply optional scale transform (for fork body types)
|
||||
# Apply optional scale transform (for fork body types). Mesh AND armature
|
||||
# are scaled together and baked by Blender so the segment stays internally
|
||||
# consistent when reparented onto the shared runtime skeleton (T-1090 fix —
|
||||
# see apply_fork_scale).
|
||||
sx, sy, sz = scale
|
||||
apply_scale(body_mesh, sx, sy, sz)
|
||||
# Also scale Eyes/Eyebrows for child (uniform scale applies to all meshes)
|
||||
if scale != (1.0, 1.0, 1.0):
|
||||
for obj in special.values():
|
||||
apply_scale(obj, sx, sy, sz)
|
||||
apply_fork_scale(body_mesh, list(special.values()), armature, sx, sy, sz)
|
||||
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user