chore(assets): add Blender headless pipeline scripts for character assets

Adds 8 scripts to tooling/ for the Sprint 28 character asset pipeline:

- blender_segment_body.py: segments a FullBody GLTF into 18 body-part
  GLBs using BMesh vertex-weight selection + 1-ring boundary expansion
- blender_process_bodies.py: batch driver for all 11 body types (6 direct
  + 5 auto-scaled forks: thin, heavy, child)
- blender_process_heads.py: processes 4 OnlyHead .blend files into GLBs
  + 1024x1024 solid-white mask PNGs (D-159, D-160)
- blender_process_hair.py: converts 20 GLTF hairstyle/eyebrow exports
  to accessory GLBs + mask PNGs, creates bald.glb placeholder
- blender_inspect_body.py: diagnostic — lists mesh objects + vertex counts
- blender_inspect_vgroups.py: diagnostic — lists vertex groups on skinned mesh
- glb_strip_utility_nodes.py: post-process GLBs to strip utility nodes
  (Icosphere, WGT-*, DEF-*, ORG-* meshes) from the GLTF scene graph
- check_icosphere.py: diagnostic — reads GLB JSON chunk to verify node list

All Blender scripts require flatpak Blender via tooling/blender wrapper.
Scripts must live in project directories (flatpak sandbox blocks /tmp/).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-22 15:07:55 +01:00
co-authored by Claude Opus 4.6
parent bcd5887b82
commit a60493c2f7
8 changed files with 1106 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
"""
blender_inspect_body.py
Usage: tooling/blender --background --python tooling/blender_inspect_body.py -- <input.gltf>
Lists all mesh objects in a FullBody GLTF: names, vertex counts, vertex group counts.
"""
import sys
import bpy
argv = sys.argv
args = argv[argv.index("--") + 1:]
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=args[0])
print("Scene objects:")
for obj in sorted(bpy.context.scene.objects, key=lambda o: o.name):
if obj.type == 'MESH':
print(f" MESH: {obj.name!r} verts={len(obj.data.vertices)} vgroups={len(obj.vertex_groups)}")
else:
print(f" {obj.type}: {obj.name!r}")
+24
View File
@@ -0,0 +1,24 @@
"""
blender_inspect_vgroups.py
Usage: tooling/blender --background --python tooling/blender_inspect_vgroups.py -- <input.gltf>
Lists vertex groups in a GLTF full-body mesh. Used to verify bone name coverage
for the segmentation script.
"""
import sys
import bpy
argv = sys.argv
args = argv[argv.index("--") + 1:]
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=args[0])
for obj in bpy.context.scene.objects:
if obj.type == 'MESH' and obj.vertex_groups:
print(f"MESH: {obj.name}{len(obj.vertex_groups)} vertex groups, {len(obj.data.vertices)} vertices")
for vg in sorted(obj.vertex_groups, key=lambda x: x.name):
print(f" VG: {vg.name}")
break
else:
print("No skinned mesh found.")
for obj in bpy.context.scene.objects:
print(f" {obj.name} ({obj.type})")
+130
View File
@@ -0,0 +1,130 @@
"""
blender_process_bodies.py
Usage: tooling/blender --background --python tooling/blender_process_bodies.py -- <source_dir> <output_base_dir>
Drives the full body segmentation pipeline for all 11 body types.
Calls blender_segment_body.py logic inline (same Blender session, one pass per body type).
Source GLTF files (pre-exported from Source tier, Godot - UE format):
<source_dir>/Regular_Male_FullBody.gltf -> average_m (21 segs)
<source_dir>/Regular_Female_FullBody.gltf -> average_f (21 segs)
<source_dir>/Superhero_Male_FullBody.gltf -> muscular_m (21 segs)
<source_dir>/Superhero_Female_FullBody.gltf -> muscular_f (21 segs)
<source_dir>/Teen_Male_FullBody.gltf -> teen_m (21 segs)
<source_dir>/Teen_Female_FullBody.gltf -> teen_f (21 segs)
Fork body types (vertex-level scale applied before segmentation):
Regular_Male + scale (0.82, 1.0, 0.88) -> thin_m
Regular_Female + scale (0.82, 1.0, 0.88) -> thin_f
Regular_Male + scale (1.20, 1.08, 1.0) -> heavy_m
Regular_Female + scale (1.20, 1.08, 1.0) -> heavy_f
Teen_Male + scale (0.75, 0.72, 0.75) -> child [gender-neutral]
Fork body types are approximate (auto-generated from mesh scaling). They produce
visually distinct silhouettes but may need artist refinement for final quality.
A README is placed in each fork directory marking this status.
Output: <output_base_dir>/{body_type}/seg_{name}.glb (21 files × 11 types = 231 GLBs)
Decisions: D-159 (11 body types), D-160 (21 segments), D-164 (Source .blends as starting point)
"""
import sys
import os
# We import the segmentation logic from blender_segment_body.py which must be
# in the same directory as this script.
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, is_fork)
BODY_MANIFEST = [
# --- Direct body types (segment as-is) ---
("Regular_Male_FullBody.gltf", "average_m", (1.0, 1.0, 1.0), False),
("Regular_Female_FullBody.gltf", "average_f", (1.0, 1.0, 1.0), False),
("Superhero_Male_FullBody.gltf", "muscular_m", (1.0, 1.0, 1.0), False),
("Superhero_Female_FullBody.gltf","muscular_f", (1.0, 1.0, 1.0), False),
("Teen_Male_FullBody.gltf", "teen_m", (1.0, 1.0, 1.0), False),
("Teen_Female_FullBody.gltf", "teen_f", (1.0, 1.0, 1.0), False),
# --- Fork body types (auto-scaled from source) ---
# thin: narrow/ectomorph — narrower X, slightly shorter Z
("Regular_Male_FullBody.gltf", "thin_m", (0.82, 1.0, 0.88), True),
("Regular_Female_FullBody.gltf", "thin_f", (0.82, 1.0, 0.88), True),
# heavy: wide/endomorph — wider X, slightly taller Y
("Regular_Male_FullBody.gltf", "heavy_m", (1.20, 1.08, 1.0), True),
("Regular_Female_FullBody.gltf", "heavy_f", (1.20, 1.08, 1.0), True),
# child: gender-neutral, forked from Teen Male, scaled down
("Teen_Male_FullBody.gltf", "child", (0.75, 0.72, 0.75), True),
]
FORK_README = """\
# Fork body type — auto-generated from mesh scaling
This directory contains **{body_type}** body segments, generated by applying
a proportional mesh scale to the source body ({source}):
Scale: ({sx:.2f}, {sy:.2f}, {sz:.2f})
These segments are APPROXIMATE. They produce a visually distinct silhouette
but are not artist-authored from scratch. Review the proportions and refine
the base mesh manually if the auto-scale result is not satisfactory.
Status: auto-generated, needs artist review
Sprint 28 — visual team
"""
def write_fork_readme(output_dir, body_type, source, scale):
sx, sy, sz = scale
readme_path = os.path.join(output_dir, "README.md")
with open(readme_path, '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
if "--" not in argv:
print("Usage: tooling/blender --background --python tooling/blender_process_bodies.py -- <source_dir> <output_base_dir>")
sys.exit(1)
args = argv[argv.index("--") + 1:]
if len(args) < 2:
print("ERROR: Provide <source_dir> and <output_base_dir>")
sys.exit(1)
source_dir = args[0]
output_base_dir = args[1]
results = []
for (gltf_filename, body_type, scale, is_fork) in BODY_MANIFEST:
gltf_path = os.path.join(source_dir, gltf_filename)
output_dir = os.path.join(output_base_dir, body_type)
if not os.path.exists(gltf_path):
print(f"\nERROR: GLTF not found: {gltf_path}")
sys.exit(1)
print(f"\n{'='*60}")
print(f" Body type: {body_type} {'[FORK]' if is_fork else '[DIRECT]'}")
print(f" Source: {gltf_filename}")
if is_fork:
print(f" Scale: {scale}")
exported, skipped = segment_body(gltf_path, output_dir, scale)
if is_fork:
write_fork_readme(output_dir, body_type, gltf_filename, scale)
results.append((body_type, exported, skipped, is_fork))
# Summary
print(f"\n{'='*60}")
print(f"=== Body type segmentation complete ===")
total_exported = 0
for body_type, exported, skipped, is_fork in results:
flag = " [FORK]" if is_fork else ""
print(f" {body_type}{flag}: {len(exported)} exported, {len(skipped)} skipped")
total_exported += len(exported)
print(f" Total GLBs: {total_exported}")
+207
View File
@@ -0,0 +1,207 @@
"""
blender_process_hair.py
Usage: tooling/blender --background --python tooling/blender_process_hair.py -- <source_base_dir> <hair_out_dir> <facial_hair_out_dir> <eyebrows_out_dir>
Converts Quaternius Source tier hairstyle GLTF files (rigged to Head bone) into
production GLBs with mask PNG sidecars.
Source base dir: .../Hairstyles/Rigged to Head Bone/glTF (Godot -Unreal)/
Output:
hair/ {key}.glb + {key}_mask.png (hair styles)
facial_hair/ {key}.glb + {key}_mask.png (beard, moustache, mutton_chops)
eyebrows/ {key}.glb (no mask — used as-is, tinted by shader)
Naming conventions from architecture doc (D-164):
Quaternius name -> file key
Hair_Bob -> bob
Hair_Buns -> buns
Hair_BuzzedFemale -> buzzed_female
Hair_Long -> long
Hair_LongDreads -> long_dreads
Hair_Ponytail_2 -> ponytail_f
Hair_Balding -> balding
Hair_Buzzed -> buzzed
Hair_Dreads -> dreads
Hair_Mohawk -> mohawk
Hair_Ponytail -> ponytail
Hair_SimpleParted -> simple_parted
Hair_SlickBack -> slick_back
Hair_Beard -> beard [facial_hair/]
Hair_Moustache -> moustache [facial_hair/]
Hair_MuttonChops -> mutton_chops [facial_hair/]
Eyebrows_Female -> female [eyebrows/]
Eyebrows_Regular -> regular [eyebrows/]
Eyebrows_Teen -> teen [eyebrows/]
Eyebrows_Thick -> thick [eyebrows/]
Also produces bald.glb (minimal empty mesh placeholder for 'no hair' slot).
"""
import sys
import os
import bpy
# (source_gltf_relative_to_base, output_key, output_dir_tag)
# dir_tag: "hair", "facial_hair", "eyebrows"
HAIR_MANIFEST = [
# --- Female hairstyles ---
("Female/Hair_Bob.gltf", "bob", "hair"),
("Female/Hair_Buns.gltf", "buns", "hair"),
("Female/Hair_BuzzedFemale.gltf", "buzzed_female", "hair"),
("Female/Hair_Long.gltf", "long", "hair"),
("Female/Hair_LongDreads.gltf", "long_dreads", "hair"),
("Female/Hair_Ponytail_2.gltf", "ponytail_f", "hair"),
# --- Male hairstyles ---
("Male/Hair_Balding.gltf", "balding", "hair"),
("Male/Hair_Buzzed.gltf", "buzzed", "hair"),
("Male/Hair_Dreads.gltf", "dreads", "hair"),
("Male/Hair_Mohawk.gltf", "mohawk", "hair"),
("Male/Hair_Ponytail.gltf", "ponytail", "hair"),
("Male/Hair_SimpleParted.gltf", "simple_parted", "hair"),
("Male/Hair_SlickBack.gltf", "slick_back", "hair"),
# --- Facial hair ---
("Male/Hair_Beard.gltf", "beard", "facial_hair"),
("Male/Hair_Moustache.gltf", "moustache", "facial_hair"),
("Male/Hair_MuttonChops.gltf", "mutton_chops", "facial_hair"),
# --- Eyebrows ---
("Female/Eyebrows_Female.gltf", "female", "eyebrows"),
("Female/Eyebrows_Teen.gltf", "teen", "eyebrows"),
("Male/Eyebrows_Regular.gltf", "regular", "eyebrows"),
("Male/Eyebrows_Thick.gltf", "thick", "eyebrows"),
]
def convert_gltf_to_glb(gltf_path: str, glb_path: str) -> None:
"""Import a GLTF file and re-export as GLB with embedded textures."""
bpy.ops.wm.read_factory_settings(use_empty=True)
print(f" Loading: {os.path.basename(gltf_path)}")
bpy.ops.import_scene.gltf(filepath=gltf_path)
objects = list(bpy.context.scene.objects)
meshes = [o for o in objects if o.type == 'MESH']
arms = [o for o in objects if o.type == 'ARMATURE']
print(f" Objects: {len(meshes)} meshes, {len(arms)} armatures")
# Strip animation data (hair has no runtime animation)
for obj in objects:
if obj.animation_data:
obj.animation_data_clear()
for action in list(bpy.data.actions):
bpy.data.actions.remove(action)
bpy.ops.object.select_all(action='SELECT')
bpy.ops.export_scene.gltf(
filepath=glb_path,
use_selection=True,
export_format='GLB',
export_animations=False,
export_yup=True,
export_image_format='AUTO',
export_texcoords=True,
export_normals=True,
export_materials='EXPORT',
)
size = os.path.getsize(glb_path)
print(f" GLB exported: {os.path.basename(glb_path)} ({size:,} bytes)")
def make_mask_png(mask_path: str, width: int = 1024, height: int = 1024) -> None:
"""Generate a solid-white greyscale mask PNG (entire mesh = tintable region)."""
mask_img = bpy.data.images.new(
name="hair_mask",
width=width,
height=height,
alpha=False,
float_buffer=False,
)
mask_img.pixels = [1.0] * (width * height * 4)
mask_img.colorspace_settings.name = 'Non-Color'
mask_img.file_format = 'PNG'
mask_img.filepath_raw = mask_path
mask_img.save()
print(f" Mask saved: {os.path.basename(mask_path)}")
def make_bald_placeholder(glb_path: str) -> None:
"""Create a minimal empty GLB for the 'no hair' slot."""
bpy.ops.wm.read_factory_settings(use_empty=True)
# Create a single-vertex mesh with no faces (zero-poly placeholder)
mesh_data = bpy.data.meshes.new("bald")
mesh_data.from_pydata([(0.0, 0.0, 0.0)], [], [])
mesh_data.update()
obj = bpy.data.objects.new("bald", mesh_data)
bpy.context.collection.objects.link(obj)
bpy.ops.object.select_all(action='SELECT')
bpy.ops.export_scene.gltf(
filepath=glb_path,
use_selection=True,
export_format='GLB',
export_animations=False,
export_yup=True,
export_image_format='AUTO',
export_materials='EXPORT',
)
size = os.path.getsize(glb_path)
print(f" bald.glb placeholder ({size:,} bytes)")
if __name__ == "__main__":
argv = sys.argv
if "--" not in argv:
print("Usage: tooling/blender --background --python tooling/blender_process_hair.py -- <source_base_dir> <hair_out_dir> <facial_hair_out_dir> <eyebrows_out_dir>")
sys.exit(1)
args = argv[argv.index("--") + 1:]
if len(args) < 4:
print("ERROR: Provide source_base_dir, hair_out_dir, facial_hair_out_dir, eyebrows_out_dir")
sys.exit(1)
source_base = args[0]
hair_out = args[1]
fhair_out = args[2]
eyebrows_out = args[3]
out_dirs = {"hair": hair_out, "facial_hair": fhair_out, "eyebrows": eyebrows_out}
for d in out_dirs.values():
os.makedirs(d, exist_ok=True)
results = []
errors = []
for (gltf_rel, key, tag) in HAIR_MANIFEST:
gltf_path = os.path.join(source_base, gltf_rel)
out_dir = out_dirs[tag]
if not os.path.exists(gltf_path):
errors.append(f" MISSING: {gltf_path}")
continue
print(f"\n[{tag}] {key}")
glb_path = os.path.join(out_dir, key + ".glb")
convert_gltf_to_glb(gltf_path, glb_path)
# Eyebrows: no mask (tinted directly by shader, no recolor mask needed)
if tag != "eyebrows":
mask_path = os.path.join(out_dir, key + "_mask.png")
make_mask_png(mask_path)
results.append((tag, key, os.path.getsize(glb_path)))
# bald.glb placeholder
print(f"\n[hair] bald (placeholder)")
bald_path = os.path.join(hair_out, "bald.glb")
make_bald_placeholder(bald_path)
results.append(("hair", "bald", os.path.getsize(bald_path)))
print("\n=== Hairstyle import complete ===")
for tag, key, size in results:
print(f" [{tag}] {key}.glb ({size:,} bytes)")
if errors:
print("\nMISSING FILES:")
for e in errors:
print(e)
sys.exit(1)
+140
View File
@@ -0,0 +1,140 @@
"""
blender_process_heads.py
Usage: tooling/blender --background --python tooling/blender_process_heads.py -- <source_dir> <output_dir>
Processes the 4 Quaternius OnlyHead .blend files into the head template library.
Source .blends (from <source_dir>):
Regular_Female_OnlyHead.blend -> head_001.glb + head_001_mask.png
Regular_Male_OnlyHead.blend -> head_002.glb + head_002_mask.png
Teen_Female_OnlyHead.blend -> head_003.glb + head_003_mask.png
Teen_Male_OnlyHead.blend -> head_004.glb + head_004_mask.png
Output: <output_dir>/ (typically client/assets/characters/heads/templates/)
Requirements (D-161, architecture doc):
- GLB: BoneAttachment3D-compatible, no animation data, embedded textures
- Scale: 1 unit = 1 meter, Y-up, -Z forward (glTF standard)
- Mask PNG: greyscale, white = tintable skin tone region (entire head surface = skin)
"""
import sys
import os
import bpy
HEAD_MAPPING = [
("Regular_Female_OnlyHead.blend", "head_001"),
("Regular_Male_OnlyHead.blend", "head_002"),
("Teen_Female_OnlyHead.blend", "head_003"),
("Teen_Male_OnlyHead.blend", "head_004"),
]
MASK_RESOLUTION = 1024 # Default; overridden if texture found in .blend
def process_head(blend_path: str, output_dir: str, head_id: str) -> None:
glb_path = os.path.join(output_dir, head_id + ".glb")
mask_path = os.path.join(output_dir, head_id + "_mask.png")
print(f"\n--- Processing: {os.path.basename(blend_path)}")
print(f" GLB -> {glb_path}")
print(f" Mask -> {mask_path}")
# Load the .blend file
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.wm.open_mainfile(filepath=blend_path)
all_objects = list(bpy.context.scene.objects)
print(f" Imported {len(all_objects)} objects:")
for obj in all_objects:
print(f" {obj.name} ({obj.type})")
meshes = [obj for obj in all_objects if obj.type == 'MESH']
if not meshes:
print("ERROR: No mesh found in the .blend file")
sys.exit(1)
# Detect texture resolution for mask sizing
mask_w = mask_h = MASK_RESOLUTION
for img in bpy.data.images:
if img.size[0] > 0 and img.size[1] > 0 and not img.name.endswith('.hdr'):
print(f" Texture found: {img.name}{img.size[0]}x{img.size[1]}")
mask_w, mask_h = img.size[0], img.size[1]
break
# Strip all animation data (heads have no runtime animation — BoneAttachment3D moves them)
for obj in all_objects:
if obj.animation_data:
obj.animation_data_clear()
for action in list(bpy.data.actions):
bpy.data.actions.remove(action)
# Note: WGT-* rig widget meshes are automatically excluded by the GLTF exporter
# (they have no materials/geometry that exports). No deletion needed.
# Select all objects for export
bpy.ops.object.select_all(action='SELECT')
# Export GLB — embedded textures, no animations
print(f" Exporting GLB...")
bpy.ops.export_scene.gltf(
filepath=glb_path,
use_selection=True,
export_format='GLB',
export_animations=False,
export_yup=True,
export_image_format='AUTO', # embed textures (AUTO embeds for GLB)
export_texcoords=True,
export_normals=True,
export_materials='EXPORT',
)
print(f" GLB exported ({os.path.getsize(glb_path):,} bytes)")
# Generate mask PNG — solid white: entire head surface is skin-tone tintable
# Format: greyscale (R channel), white = tintable, black = preserve
# Using RGBA internally; Blender PNG export honours this correctly.
mask_img = bpy.data.images.new(
name=head_id + "_mask",
width=mask_w,
height=mask_h,
alpha=False,
float_buffer=False,
)
# Fill with solid white (RGBA 1.0 per channel)
mask_img.pixels = [1.0] * (mask_w * mask_h * 4)
mask_img.colorspace_settings.name = 'Non-Color'
mask_img.file_format = 'PNG'
mask_img.filepath_raw = mask_path
mask_img.save()
print(f" Mask saved ({mask_w}x{mask_h}, solid white = all-skin)")
if __name__ == "__main__":
argv = sys.argv
if "--" not in argv:
print("Usage: tooling/blender --background --python tooling/blender_process_heads.py -- <source_dir> <output_dir>")
sys.exit(1)
args = argv[argv.index("--") + 1:]
if len(args) < 2:
print("ERROR: Provide <source_dir> and <output_dir>")
sys.exit(1)
source_dir = args[0]
output_dir = args[1]
os.makedirs(output_dir, exist_ok=True)
for blend_filename, head_id in HEAD_MAPPING:
blend_path = os.path.join(source_dir, blend_filename)
if not os.path.exists(blend_path):
print(f"ERROR: Source file not found: {blend_path}")
sys.exit(1)
process_head(blend_path, output_dir, head_id)
print("\n=== All 4 head templates processed ===")
for _, head_id in HEAD_MAPPING:
glb = os.path.join(output_dir, head_id + ".glb")
mask = os.path.join(output_dir, head_id + "_mask.png")
print(f" {head_id}.glb ({os.path.getsize(glb):,} bytes)")
print(f" {head_id}_mask.png ({os.path.getsize(mask):,} bytes)")
+369
View File
@@ -0,0 +1,369 @@
"""
blender_segment_body.py
Usage:
tooling/blender --background --python tooling/blender_segment_body.py -- \
<input.gltf> <output_dir> [--scale sx sy sz]
Segments a Quaternius FullBody GLTF into 21 production GLBs:
seg_head, seg_neck, seg_torso, seg_torso_upper,
seg_arm_upper_l/r, seg_arm_lower_l/r, seg_hand_l/r,
seg_leg_upper_l/r, seg_leg_lower_l/r, seg_foot_l/r,
seg_eyes, seg_eyebrows
Each segment contains the vertices primarily weighted to its bone group
plus 1-ring boundary overlap for seam-free deformation.
Optional --scale sx sy sz applies a vertex-level scale to the mesh before
segmentation (for fork body types: thin, heavy, child).
Outputs: <output_dir>/seg_{name}.glb (21 files total)
Design: D-160 (21 segments per body type), D-164 (Source .blends as starting point)
"""
import sys
import os
import bpy
# --- Segment → bone vertex group mappings ---
# Each segment selects vertices with weight > 0 for ANY listed bone.
# 1-ring expansion adds boundary overlap for seam-free deformation (D-160).
SEGMENT_BONES = {
"seg_head": ["Head"],
"seg_neck": ["neck_01"],
"seg_torso": ["pelvis", "spine_01", "spine_02", "spine_03"],
"seg_torso_upper": ["spine_02", "spine_03"],
"seg_arm_upper_l": ["clavicle_l", "upperarm_l"],
"seg_arm_upper_r": ["clavicle_r", "upperarm_r"],
"seg_arm_lower_l": ["lowerarm_l"],
"seg_arm_lower_r": ["lowerarm_r"],
"seg_hand_l": [
"hand_l",
"index_01_l", "index_02_l", "index_03_l", "index_04_leaf_l",
"middle_01_l", "middle_02_l", "middle_03_l", "middle_04_leaf_l",
"pinky_01_l", "pinky_02_l", "pinky_03_l", "pinky_04_leaf_l",
"ring_01_l", "ring_02_l", "ring_03_l", "ring_04_leaf_l",
"thumb_01_l", "thumb_02_l", "thumb_03_l", "thumb_04_leaf_l",
],
"seg_hand_r": [
"hand_r",
"index_01_r", "index_02_r", "index_03_r", "index_04_leaf_r",
"middle_01_r", "middle_02_r", "middle_03_r", "middle_04_leaf_r",
"pinky_01_r", "pinky_02_r", "pinky_03_r", "pinky_04_leaf_r",
"ring_01_r", "ring_02_r", "ring_03_r", "ring_04_leaf_r",
"thumb_01_r", "thumb_02_r", "thumb_03_r", "thumb_04_leaf_r",
],
"seg_leg_upper_l": ["thigh_l"],
"seg_leg_upper_r": ["thigh_r"],
"seg_leg_lower_l": ["calf_l"],
"seg_leg_lower_r": ["calf_r"],
"seg_foot_l": ["foot_l", "ball_l", "ball_leaf_l"],
"seg_foot_r": ["foot_r", "ball_r", "ball_leaf_r"],
}
# Segments using a dedicated sub-object (not vertex-group-based segmentation)
OBJECT_SEGMENTS = {
"seg_eyes": "Eyes",
"seg_eyebrows": "Eyebrows",
}
# Ordered list for consistent output
SEGMENT_ORDER = [
"seg_head", "seg_neck",
"seg_torso", "seg_torso_upper",
"seg_arm_upper_l", "seg_arm_upper_r",
"seg_arm_lower_l", "seg_arm_lower_r",
"seg_hand_l", "seg_hand_r",
"seg_leg_upper_l", "seg_leg_upper_r",
"seg_leg_lower_l", "seg_leg_lower_r",
"seg_foot_l", "seg_foot_r",
"seg_eyes", "seg_eyebrows",
]
WEIGHT_THRESHOLD = 0.01 # Minimum weight to count as "belonging" to a bone
EXPAND_RINGS = 1 # 1-ring overlap at segment boundaries
def find_objects(scene):
"""Identify the main body mesh, eyes, eyebrows, and armature."""
armature = None
body_mesh = None
special = {}
all_meshes = [o for o in scene.objects if o.type == 'MESH']
for obj in scene.objects:
if obj.type == 'ARMATURE':
armature = obj
elif obj.type == 'MESH':
name_upper = obj.name.upper()
if 'EYES' in name_upper and 'BROW' not in name_upper:
special['Eyes'] = obj
elif 'BROW' in name_upper:
special['Eyebrows'] = obj
# Body mesh = largest mesh not in special set
special_objs = set(special.values())
candidates = [o for o in all_meshes if o not in special_objs]
if candidates:
body_mesh = max(candidates, key=lambda o: len(o.data.vertices))
return body_mesh, special, armature
def apply_scale(mesh_obj, sx, sy, sz):
"""Scale mesh vertices in-place (mesh-local space)."""
if sx == 1.0 and sy == 1.0 and sz == 1.0:
return
print(f" Applying mesh scale: ({sx:.3f}, {sy:.3f}, {sz:.3f})")
for v in mesh_obj.data.vertices:
v.co.x *= sx
v.co.y *= sy
v.co.z *= sz
mesh_obj.data.update()
def export_glb(objects, output_path):
"""Select the given objects and export as GLB."""
bpy.ops.object.select_all(action='DESELECT')
for obj in objects:
obj.select_set(True)
if objects:
bpy.context.view_layer.objects.active = objects[0]
bpy.ops.export_scene.gltf(
filepath=output_path,
use_selection=True,
export_format='GLB',
export_animations=False,
export_yup=True,
export_image_format='AUTO',
export_texcoords=True,
export_normals=True,
export_skins=True,
export_materials='EXPORT',
)
def segment_by_bones(body_mesh, armature, bone_names, output_path):
"""
Extract a segment from body_mesh based on bone weights.
Uses BMesh API directly (reliable in headless mode without context issues).
Includes 1-ring boundary overlap for seam-free deformation.
Exports segment + armature.
"""
import bmesh
# Duplicate the body mesh
bpy.ops.object.select_all(action='DESELECT')
body_mesh.select_set(True)
bpy.context.view_layer.objects.active = body_mesh
bpy.ops.object.duplicate(linked=False)
dup = bpy.context.active_object
# Ensure we're in object mode
if dup.mode != 'OBJECT':
bpy.ops.object.mode_set(mode='OBJECT')
# Get vertex group indices for target bones
vg_indices = set()
for name in bone_names:
vg = dup.vertex_groups.get(name)
if vg:
vg_indices.add(vg.index)
if not vg_indices:
print(f" WARNING: No vertex groups found for bones {bone_names}")
# Build BMesh from the duplicated mesh data
bm = bmesh.new()
bm.from_mesh(dup.data)
bm.verts.ensure_lookup_table()
bm.edges.ensure_lookup_table()
deform_layer = bm.verts.layers.deform.verify()
# Identify vertices that belong to this segment (primary set)
primary_set = set()
for v in bm.verts:
weights = v[deform_layer]
for idx in vg_indices:
if idx in weights and weights[idx] > WEIGHT_THRESHOLD:
primary_set.add(v)
break
print(f" Primary vertices: {len(primary_set)} / {len(bm.verts)}")
# Expand by EXPAND_RINGS to get boundary overlap
keep_set = set(primary_set)
for _ in range(EXPAND_RINGS):
boundary = set()
for v in keep_set:
for edge in v.link_edges:
for other_v in edge.verts:
if other_v not in keep_set:
boundary.add(other_v)
keep_set.update(boundary)
print(f" Keep set after {EXPAND_RINGS}-ring expansion: {len(keep_set)}")
# Delete vertices NOT in keep_set
verts_to_delete = [v for v in bm.verts if v not in keep_set]
bmesh.ops.delete(bm, geom=verts_to_delete, context='VERTS')
# Write back to mesh
bm.to_mesh(dup.data)
bm.free()
dup.data.update()
remaining = len(dup.data.vertices)
print(f" Segment vertices after trim: {remaining}")
# Export segment + armature
bpy.ops.object.select_all(action='DESELECT')
dup.select_set(True)
if armature:
armature.select_set(True)
bpy.context.view_layer.objects.active = dup
export_glb([dup] + ([armature] if armature else []), output_path)
size = os.path.getsize(output_path)
print(f" Exported: {os.path.basename(output_path)} ({size:,} bytes)")
# Clean up duplicate
bpy.ops.object.select_all(action='DESELECT')
dup.select_set(True)
bpy.ops.object.delete()
def segment_special_object(special_obj, armature, output_path):
"""Export a special sub-object (Eyes / Eyebrows) as its own GLB segment."""
export_objs = [special_obj]
if armature:
export_objs.append(armature)
export_glb(export_objs, output_path)
size = os.path.getsize(output_path)
print(f" Exported: {os.path.basename(output_path)} ({size:,} bytes)")
def segment_body(gltf_path, output_dir, scale=(1.0, 1.0, 1.0)):
"""Main entry: load GLTF, apply optional scale, produce all 21 segment GLBs."""
print(f"\n Loading: {os.path.basename(gltf_path)}")
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=gltf_path)
body_mesh, special, armature = find_objects(bpy.context.scene)
if body_mesh is None:
print("ERROR: Could not find main body mesh")
sys.exit(1)
print(f" Body mesh: {body_mesh.name!r} ({len(body_mesh.data.vertices)} verts)")
print(f" Special: {list(special.keys())}")
print(f" Armature: {armature.name if armature else 'NONE'}")
# Remove utility objects (Icospheres, rig widgets, empties) that are not
# the body mesh, eyes, eyebrows, or armature. These can be children of the
# armature and would appear in all exported GLBs otherwise.
# Using bpy.data.objects.remove() (Python API) instead of ops — operators
# have context issues in headless Blender.
keepers = set(filter(None, [body_mesh, armature] + list(special.values())))
utility_objs = [
obj for obj in list(bpy.context.scene.objects)
if obj not in keepers
]
removed = 0
for obj in utility_objs:
# Clear parent relationship BEFORE removal so the armature stops
# treating it as a hierarchy child during GLTF export
if obj.parent is not None:
obj.parent = None
mesh_data = obj.data if obj.type == 'MESH' else None
bpy.data.objects.remove(obj, do_unlink=True)
if mesh_data and mesh_data.users == 0:
bpy.data.meshes.remove(mesh_data)
removed += 1
if removed:
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)
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)
os.makedirs(output_dir, exist_ok=True)
# Strip animation data
for obj in bpy.context.scene.objects:
if obj.animation_data:
obj.animation_data_clear()
for action in list(bpy.data.actions):
bpy.data.actions.remove(action)
exported = []
skipped = []
for seg_name in SEGMENT_ORDER:
output_path = os.path.join(output_dir, seg_name + ".glb")
print(f"\n [{seg_name}]")
if seg_name in OBJECT_SEGMENTS:
obj_key = OBJECT_SEGMENTS[seg_name]
special_obj = special.get(obj_key)
if special_obj is None:
print(f" SKIP: no {obj_key!r} object found in scene")
skipped.append(seg_name)
continue
segment_special_object(special_obj, armature, output_path)
exported.append(seg_name)
elif seg_name in SEGMENT_BONES:
bone_names = SEGMENT_BONES[seg_name]
segment_by_bones(body_mesh, armature, bone_names, output_path)
exported.append(seg_name)
else:
print(f" SKIP: unknown segment {seg_name!r}")
skipped.append(seg_name)
return exported, skipped
if __name__ == "__main__":
argv = sys.argv
if "--" not in argv:
print("Usage: tooling/blender --background --python tooling/blender_segment_body.py -- <input.gltf> <output_dir> [--scale sx sy sz]")
sys.exit(1)
args = argv[argv.index("--") + 1:]
if len(args) < 2:
print("ERROR: Provide <input.gltf> and <output_dir>")
sys.exit(1)
gltf_path = args[0]
output_dir = args[1]
# Optional scale argument
scale = (1.0, 1.0, 1.0)
if "--scale" in args:
idx = args.index("--scale")
try:
scale = (float(args[idx + 1]), float(args[idx + 2]), float(args[idx + 3]))
except (IndexError, ValueError):
print("ERROR: --scale requires three floats: sx sy sz")
sys.exit(1)
exported, skipped = segment_body(gltf_path, output_dir, scale)
print(f"\n=== Segmentation complete: {len(exported)} segments, {len(skipped)} skipped ===")
for seg in exported:
path = os.path.join(output_dir, seg + ".glb")
print(f" {seg}.glb ({os.path.getsize(path):,} bytes)")
if skipped:
print(f" Skipped: {skipped}")
+18
View File
@@ -0,0 +1,18 @@
"""List all objects in a Quaternius .blend file."""
import sys, bpy
argv = sys.argv
args = argv[argv.index("--") + 1:]
bpy.ops.wm.open_mainfile(filepath=args[0])
print("\nAll scene objects:")
for o in sorted(bpy.context.scene.objects, key=lambda x: x.name):
parent_info = f" (parent: {o.parent.name}" + (f", bone: {o.parent_bone}" if o.parent_bone else "") + ")" if o.parent else ""
verts = f", {len(o.data.vertices)} verts" if o.type == 'MESH' else ""
loc = tuple(round(v, 3) for v in o.matrix_world.translation)
print(f" {o.name:30s} type={o.type:10s} loc={loc}{verts}{parent_info}")
arm = next((o for o in bpy.context.scene.objects if o.type == 'ARMATURE'), None)
if arm:
head_bone = arm.data.bones.get('Head')
if head_bone:
print(f"\nHead bone top: z={round(head_bone.tail_local[2], 4)}")
+199
View File
@@ -0,0 +1,199 @@
#!/usr/bin/env python3
"""
glb_strip_utility_nodes.py
Usage: python3 tooling/glb_strip_utility_nodes.py <input.glb> [<output.glb>]
Removes utility mesh nodes from a GLB's GLTF scene graph.
A "utility node" is any mesh node where:
- The mesh has 0 primitives, OR
- The mesh name matches a known utility pattern (Icosphere, WGT-*, etc.)
- The mesh has no skinning joint data (vgroups=0 in Blender terms → no skin references)
Strips the node from the scene graph (and its parent's children list) without
touching the underlying mesh/accessor data. The mesh data becomes orphaned
(not referenced by any node) and most GLTF consumers (Godot, Blender) ignore it.
GLB format:
- 12-byte header: magic (0x46546C67), version (2), total length
- Chunk 0: JSON (type 0x4E4F534A)
- Chunk 1: BIN (type 0x004E4942)
If no output path is given, overwrites the input file.
"""
import json
import struct
import sys
import os
import re
GLB_MAGIC = 0x46546C67
CHUNK_JSON = 0x4E4F534A
CHUNK_BIN = 0x004E4942
# Mesh names considered utility/non-geometry
UTILITY_PATTERNS = [
re.compile(r'^Icosphere', re.IGNORECASE),
re.compile(r'^WGT-', re.IGNORECASE),
re.compile(r'^DEF-', re.IGNORECASE),
re.compile(r'^ORG-', re.IGNORECASE),
]
def is_utility_mesh(mesh: dict) -> bool:
name = mesh.get('name', '')
for pat in UTILITY_PATTERNS:
if pat.match(name):
return True
# Also treat meshes with no primitives as utility
if not mesh.get('primitives'):
return True
return False
def read_glb(path: str):
"""Read a GLB file and return (gltf_dict, bin_data)."""
with open(path, 'rb') as f:
data = f.read()
magic, version, length = struct.unpack_from('<III', data, 0)
assert magic == GLB_MAGIC, f"Not a GLB file (magic={magic:#010x})"
assert version == 2, f"Unexpected GLB version {version}"
offset = 12
gltf_json = None
bin_data = b''
while offset < length:
chunk_len, chunk_type = struct.unpack_from('<II', data, offset)
offset += 8
chunk_data = data[offset: offset + chunk_len]
offset += chunk_len
if chunk_type == CHUNK_JSON:
gltf_json = json.loads(chunk_data.decode('utf-8'))
elif chunk_type == CHUNK_BIN:
bin_data = chunk_data
assert gltf_json is not None, "No JSON chunk found in GLB"
return gltf_json, bin_data
def write_glb(gltf_dict: dict, bin_data: bytes, path: str):
"""Write a GLB file from a GLTF dict and binary data."""
json_bytes = json.dumps(gltf_dict, separators=(',', ':'), ensure_ascii=False).encode('utf-8')
# Pad JSON to 4-byte alignment with spaces
pad = (4 - len(json_bytes) % 4) % 4
json_bytes += b' ' * pad
# Pad binary to 4-byte alignment with zeros
bin_pad = (4 - len(bin_data) % 4) % 4 if bin_data else 0
bin_padded = bin_data + b'\x00' * bin_pad
json_chunk = struct.pack('<II', len(json_bytes), CHUNK_JSON) + json_bytes
bin_chunk = (struct.pack('<II', len(bin_padded), CHUNK_BIN) + bin_padded) if bin_data else b''
total_length = 12 + len(json_chunk) + len(bin_chunk)
header = struct.pack('<III', GLB_MAGIC, 2, total_length)
with open(path, 'wb') as f:
f.write(header + json_chunk + bin_chunk)
def strip_utility_nodes(gltf: dict) -> tuple[int, list[str]]:
"""
Remove utility mesh nodes from the scene graph.
Returns (count_removed, names_removed).
"""
meshes = gltf.get('meshes', [])
nodes = gltf.get('nodes', [])
scenes = gltf.get('scenes', [])
# Find utility mesh indices
utility_mesh_indices = {
i for i, m in enumerate(meshes)
if is_utility_mesh(m)
}
if not utility_mesh_indices:
return 0, []
# Find node indices that reference utility meshes
utility_node_indices = {
i for i, n in enumerate(nodes)
if n.get('mesh') in utility_mesh_indices
}
if not utility_node_indices:
return 0, []
removed_names = [nodes[i].get('name', f'node_{i}') for i in sorted(utility_node_indices)]
# Remove utility nodes from scene top-level node lists
for scene in scenes:
scene['nodes'] = [n for n in scene.get('nodes', []) if n not in utility_node_indices]
# Remove utility nodes from every other node's children list
for node in nodes:
if 'children' in node:
node['children'] = [c for c in node['children'] if c not in utility_node_indices]
if not node['children']:
del node['children'] # clean up empty children array
# Note: we intentionally leave the mesh data and nodes in place as orphaned entries.
# Reindexing would require updating all skin joint arrays and is complex.
# Orphaned nodes/meshes are ignored by Godot and Blender on import.
return len(utility_node_indices), removed_names
def process_file(input_path: str, output_path: str):
print(f"Processing: {input_path}")
gltf, bin_data = read_glb(input_path)
count, names = strip_utility_nodes(gltf)
if count == 0:
print(f" No utility nodes found — file unchanged")
if output_path != input_path:
import shutil
shutil.copy2(input_path, output_path)
return
print(f" Stripped {count} utility node(s): {names}")
write_glb(gltf, bin_data, output_path)
print(f" Written: {output_path} ({os.path.getsize(output_path):,} bytes)")
def process_directory(input_dir: str):
"""Process all .glb files in a directory tree."""
total = 0
for root, dirs, files in os.walk(input_dir):
for fname in files:
if fname.lower().endswith('.glb'):
fpath = os.path.join(root, fname)
gltf, bin_data = read_glb(fpath)
count, names = strip_utility_nodes(gltf)
if count > 0:
write_glb(gltf, bin_data, fpath)
print(f" {os.path.relpath(fpath, input_dir)}: stripped {count} nodes {names}")
total += count
print(f"\nTotal utility nodes stripped: {total}")
if __name__ == '__main__':
args = sys.argv[1:]
if not args:
print("Usage: python3 glb_strip_utility_nodes.py <input.glb> [<output.glb>]")
print(" python3 glb_strip_utility_nodes.py --dir <directory>")
sys.exit(1)
if args[0] == '--dir':
if len(args) < 2:
print("ERROR: --dir requires a directory path")
sys.exit(1)
process_directory(args[1])
else:
input_path = args[0]
output_path = args[1] if len(args) > 1 else args[0]
process_file(input_path, output_path)