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:
@@ -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}")
|
||||
@@ -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})")
|
||||
@@ -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}")
|
||||
@@ -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)
|
||||
@@ -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)")
|
||||
@@ -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}")
|
||||
@@ -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)}")
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user