diff --git a/tooling/blender_inspect_body.py b/tooling/blender_inspect_body.py new file mode 100644 index 000000000..e54c170f9 --- /dev/null +++ b/tooling/blender_inspect_body.py @@ -0,0 +1,19 @@ +""" +blender_inspect_body.py +Usage: tooling/blender --background --python tooling/blender_inspect_body.py -- + +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}") diff --git a/tooling/blender_inspect_vgroups.py b/tooling/blender_inspect_vgroups.py new file mode 100644 index 000000000..f9668ab49 --- /dev/null +++ b/tooling/blender_inspect_vgroups.py @@ -0,0 +1,24 @@ +""" +blender_inspect_vgroups.py +Usage: tooling/blender --background --python tooling/blender_inspect_vgroups.py -- + +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})") diff --git a/tooling/blender_process_bodies.py b/tooling/blender_process_bodies.py new file mode 100644 index 000000000..8f7e6f3b3 --- /dev/null +++ b/tooling/blender_process_bodies.py @@ -0,0 +1,130 @@ +""" +blender_process_bodies.py +Usage: tooling/blender --background --python tooling/blender_process_bodies.py -- + +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): + /Regular_Male_FullBody.gltf -> average_m (21 segs) + /Regular_Female_FullBody.gltf -> average_f (21 segs) + /Superhero_Male_FullBody.gltf -> muscular_m (21 segs) + /Superhero_Female_FullBody.gltf -> muscular_f (21 segs) + /Teen_Male_FullBody.gltf -> teen_m (21 segs) + /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: /{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 -- ") + sys.exit(1) + + args = argv[argv.index("--") + 1:] + if len(args) < 2: + print("ERROR: Provide and ") + 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}") diff --git a/tooling/blender_process_hair.py b/tooling/blender_process_hair.py new file mode 100644 index 000000000..fee9bfa6b --- /dev/null +++ b/tooling/blender_process_hair.py @@ -0,0 +1,207 @@ +""" +blender_process_hair.py +Usage: tooling/blender --background --python tooling/blender_process_hair.py -- + +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 -- ") + 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) diff --git a/tooling/blender_process_heads.py b/tooling/blender_process_heads.py new file mode 100644 index 000000000..ccbdb224d --- /dev/null +++ b/tooling/blender_process_heads.py @@ -0,0 +1,140 @@ +""" +blender_process_heads.py +Usage: tooling/blender --background --python tooling/blender_process_heads.py -- + +Processes the 4 Quaternius OnlyHead .blend files into the head template library. + +Source .blends (from ): + 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: / (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 -- ") + sys.exit(1) + + args = argv[argv.index("--") + 1:] + if len(args) < 2: + print("ERROR: Provide and ") + 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)") diff --git a/tooling/blender_segment_body.py b/tooling/blender_segment_body.py new file mode 100644 index 000000000..3af24e74a --- /dev/null +++ b/tooling/blender_segment_body.py @@ -0,0 +1,369 @@ +""" +blender_segment_body.py +Usage: + tooling/blender --background --python tooling/blender_segment_body.py -- \ + [--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: /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 -- [--scale sx sy sz]") + sys.exit(1) + + args = argv[argv.index("--") + 1:] + if len(args) < 2: + print("ERROR: Provide and ") + 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}") diff --git a/tooling/check_icosphere.py b/tooling/check_icosphere.py new file mode 100644 index 000000000..c424599cf --- /dev/null +++ b/tooling/check_icosphere.py @@ -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)}") diff --git a/tooling/glb_strip_utility_nodes.py b/tooling/glb_strip_utility_nodes.py new file mode 100644 index 000000000..fc2a689bb --- /dev/null +++ b/tooling/glb_strip_utility_nodes.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +""" +glb_strip_utility_nodes.py +Usage: python3 tooling/glb_strip_utility_nodes.py [] + +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(' 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 []") + print(" python3 glb_strip_utility_nodes.py --dir ") + 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)