From 093b24e38ee3f88e4a0729f399301897dcf811ed Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 23 Mar 2026 17:43:04 +0100 Subject: [PATCH] feat(assets): face-based segmentation pipeline + solidify for hair/clothing Segmentation: - Face-based exclusive assignment (no overlap, no caps needed) - Hips split from torso (pelvis as own segment) - Torso_upper independent (spine_03 + clavicle, not a variant) - Clavicle moved from arm_upper to torso_upper Hair pipeline: - Solidify modifier (20mm) for scalp hair volume - Normal recalculation before solidify for consistent direction - Eyebrows/facial hair NOT solidified - Fixed mask generation (was outputting black, now white) Clothing pipeline: - Solidify modifier (25mm) for clothing thickness - Peasant shoes added from Fantasy pack Tooling: - convert_outfit.py with solidify support - inspect_glb.py, check_hair_symmetry.py for debugging - Segment reference distribution locked - Q-063 footsteps VFX filed Co-Authored-By: Claude Opus 4.6 --- .../clothing/coveralls_basic/coverage.json | 1 + .../clothing/jacket_utility/coverage.json | 1 + .../clothing/pants_cargo/coverage.json | 1 + .../clothing/peasant_pants/coverage.json | 4 + .../clothing/peasant_shoes/coverage.json | 4 + .../clothing/peasant_tunic/coverage.json | 4 + .../clothing/shirt_henley/coverage.json | 1 + decisions/questions-architecture.md | 9 +- tooling/blender_create_clothing_refs.py | 51 +++++-- tooling/blender_process_hair.py | 77 +++++++--- tooling/blender_segment_body.py | 134 +++++++++++++----- tooling/check_hair_symmetry.py | 31 ++++ tooling/convert_outfit.py | 53 +++++++ tooling/inspect_glb.py | 32 +++++ tooling/render_quaternius_test.py | 75 ++++++++++ tooling/segment_reference_distribution.md | 34 +++++ tooling/test_quaternius_raw.py | 42 ++++++ 17 files changed, 489 insertions(+), 65 deletions(-) create mode 100644 client/assets/characters/clothing/peasant_pants/coverage.json create mode 100644 client/assets/characters/clothing/peasant_shoes/coverage.json create mode 100644 client/assets/characters/clothing/peasant_tunic/coverage.json create mode 100644 tooling/check_hair_symmetry.py create mode 100644 tooling/convert_outfit.py create mode 100644 tooling/inspect_glb.py create mode 100644 tooling/render_quaternius_test.py create mode 100644 tooling/segment_reference_distribution.md create mode 100644 tooling/test_quaternius_raw.py diff --git a/client/assets/characters/clothing/coveralls_basic/coverage.json b/client/assets/characters/clothing/coveralls_basic/coverage.json index 7ab2685fe..5ded2e0e5 100644 --- a/client/assets/characters/clothing/coveralls_basic/coverage.json +++ b/client/assets/characters/clothing/coveralls_basic/coverage.json @@ -1,6 +1,7 @@ { "hides": [ "torso", + "hips", "arm_upper_l", "arm_upper_r", "arm_lower_l", diff --git a/client/assets/characters/clothing/jacket_utility/coverage.json b/client/assets/characters/clothing/jacket_utility/coverage.json index 953c402e8..ada76c9e2 100644 --- a/client/assets/characters/clothing/jacket_utility/coverage.json +++ b/client/assets/characters/clothing/jacket_utility/coverage.json @@ -1,6 +1,7 @@ { "hides": [ "torso", + "hips", "arm_upper_l", "arm_upper_r", "arm_lower_l", diff --git a/client/assets/characters/clothing/pants_cargo/coverage.json b/client/assets/characters/clothing/pants_cargo/coverage.json index d6e40331a..3c1eeadbe 100644 --- a/client/assets/characters/clothing/pants_cargo/coverage.json +++ b/client/assets/characters/clothing/pants_cargo/coverage.json @@ -1,5 +1,6 @@ { "hides": [ + "hips", "leg_upper_l", "leg_upper_r", "leg_lower_l", diff --git a/client/assets/characters/clothing/peasant_pants/coverage.json b/client/assets/characters/clothing/peasant_pants/coverage.json new file mode 100644 index 000000000..d5f67a299 --- /dev/null +++ b/client/assets/characters/clothing/peasant_pants/coverage.json @@ -0,0 +1,4 @@ +{ + "hides": ["hips", "leg_upper_l", "leg_upper_r"], + "torso_variant": "full" +} diff --git a/client/assets/characters/clothing/peasant_shoes/coverage.json b/client/assets/characters/clothing/peasant_shoes/coverage.json new file mode 100644 index 000000000..b8ff4229a --- /dev/null +++ b/client/assets/characters/clothing/peasant_shoes/coverage.json @@ -0,0 +1,4 @@ +{ + "hides": [], + "torso_variant": "full" +} diff --git a/client/assets/characters/clothing/peasant_tunic/coverage.json b/client/assets/characters/clothing/peasant_tunic/coverage.json new file mode 100644 index 000000000..9d171e1c3 --- /dev/null +++ b/client/assets/characters/clothing/peasant_tunic/coverage.json @@ -0,0 +1,4 @@ +{ + "hides": ["torso", "neck", "hips", "arm_upper_l", "arm_upper_r"], + "torso_variant": "full" +} diff --git a/client/assets/characters/clothing/shirt_henley/coverage.json b/client/assets/characters/clothing/shirt_henley/coverage.json index 78b67382d..6f0e90445 100644 --- a/client/assets/characters/clothing/shirt_henley/coverage.json +++ b/client/assets/characters/clothing/shirt_henley/coverage.json @@ -1,6 +1,7 @@ { "hides": [ "torso", + "hips", "arm_upper_l", "arm_upper_r" ], diff --git a/decisions/questions-architecture.md b/decisions/questions-architecture.md index 579f8e570..0cc82db70 100644 --- a/decisions/questions-architecture.md +++ b/decisions/questions-architecture.md @@ -101,4 +101,11 @@ Technical foundation questions: engine, protocols, data structures, performance, --- -*14 questions (7 resolved, 1 partially resolved, 6 open). Last updated: 2026-03-19.* +### Q-063: Footstep VFX system (Godot Asset Library #4122) +- **Status:** Open +- **Question:** Integrate the Footsteps asset (https://godotengine.org/asset-library/asset/4122) into the character visual system. Jeroen wants this in the game. Evaluate: how does it hook into the animation system? Does it work with our toon shader pipeline? Should footstep triggers be animation events or raycast-based? +- **Cross-reference:** D-149 (3D characters rendered live), D-160 (body segments) + +--- + +*15 questions (7 resolved, 1 partially resolved, 7 open). Last updated: 2026-03-22.* diff --git a/tooling/blender_create_clothing_refs.py b/tooling/blender_create_clothing_refs.py index 61c9922e8..f06a8bb9f 100644 --- a/tooling/blender_create_clothing_refs.py +++ b/tooling/blender_create_clothing_refs.py @@ -124,10 +124,12 @@ def offset_vertices_along_normals(obj, thickness): obj.data.update() -def export_glb(obj, output_path): - """Export a single mesh object as GLB with UVs, normals, and materials.""" +def export_glb(obj, output_path, armature=None): + """Export a mesh (and optionally its armature) as GLB with skinning data.""" bpy.ops.object.select_all(action='DESELECT') obj.select_set(True) + if armature: + armature.select_set(True) bpy.context.view_layer.objects.active = obj bpy.ops.export_scene.gltf( @@ -139,7 +141,7 @@ def export_glb(obj, output_path): export_image_format='AUTO', export_texcoords=True, export_normals=True, - export_skins=False, + export_skins=armature is not None, export_materials='EXPORT', ) @@ -147,6 +149,7 @@ def export_glb(obj, output_path): def create_reference_mesh(item_id, config, bodies_dir, output_dir): """ Create the reference.glb for one clothing item on average_m geometry. + Preserves bone weights from body segments so clothing deforms with the skeleton. Returns True on success, False on error. """ print(f"\n{'='*60}") @@ -156,6 +159,7 @@ def create_reference_mesh(item_id, config, bodies_dir, output_dir): average_m_dir = os.path.join(bodies_dir, REFERENCE_BODY) imported_meshes = [] + imported_armatures = [] missing_segments = [] for seg_name in config["segments"]: @@ -165,7 +169,9 @@ def create_reference_mesh(item_id, config, bodies_dir, output_dir): continue objs = import_glb(seg_path) meshes = [o for o in objs if o.type == 'MESH'] + armatures = [o for o in objs if o.type == 'ARMATURE'] imported_meshes.extend(meshes) + imported_armatures.extend(armatures) if missing_segments: print(f" NOTE: Missing segments (skipped): {', '.join(missing_segments)}") @@ -174,13 +180,41 @@ def create_reference_mesh(item_id, config, bodies_dir, output_dir): print(f" ERROR: No segment meshes could be imported for {item_id}") return False - print(f" Imported {len(imported_meshes)} segments " + print(f" Imported {len(imported_meshes)} segments, " + f"{len(imported_armatures)} armatures " f"({len(missing_segments)} missing)") - # Join all segments into one mesh + # Use the first armature as the canonical one — all segments share the same rig + canonical_armature = imported_armatures[0] if imported_armatures else None + + # Re-parent all meshes to the canonical armature (preserving bone weights) + if canonical_armature: + for m in imported_meshes: + # Clear any existing parent + m.parent = None + m.matrix_world = m.matrix_world # preserve world transform + # Set parent to canonical armature with Armature modifier + m.parent = canonical_armature + m.parent_type = 'OBJECT' + # Ensure Armature modifier exists pointing to canonical armature + has_armature_mod = False + for mod in m.modifiers: + if mod.type == 'ARMATURE': + mod.object = canonical_armature + has_armature_mod = True + if not has_armature_mod: + mod = m.modifiers.new(name="Armature", type='ARMATURE') + mod.object = canonical_armature + + # Remove duplicate armatures (keep only canonical) + for arm in imported_armatures[1:]: + bpy.data.objects.remove(arm, do_unlink=True) + + # Join all segments into one mesh (vertex groups / bone weights are preserved by join) bpy.ops.object.select_all(action='DESELECT') for m in imported_meshes: - m.select_set(True) + if m.name in bpy.data.objects: + m.select_set(True) bpy.context.view_layer.objects.active = imported_meshes[0] if len(imported_meshes) > 1: bpy.ops.object.join() @@ -190,16 +224,17 @@ def create_reference_mesh(item_id, config, bodies_dir, output_dir): vertex_count_before = len(clothing_obj.data.vertices) print(f" Mesh vertices: {vertex_count_before}") + print(f" Vertex groups (bone weights): {len(clothing_obj.vertex_groups)}") # Offset vertices outward to simulate clothing thickness thickness = config["thickness"] offset_vertices_along_normals(clothing_obj, thickness) print(f" Applied {thickness*1000:.1f}mm outward offset") - # Export + # Export with armature so clothing is skinned os.makedirs(output_dir, exist_ok=True) output_path = os.path.join(output_dir, "reference.glb") - export_glb(clothing_obj, output_path) + export_glb(clothing_obj, output_path, armature=canonical_armature) print(f" Exported: {output_path}") return True diff --git a/tooling/blender_process_hair.py b/tooling/blender_process_hair.py index 1e2296a2a..5a1dcbc32 100644 --- a/tooling/blender_process_hair.py +++ b/tooling/blender_process_hair.py @@ -72,7 +72,7 @@ HAIR_MANIFEST = [ ] -def convert_gltf_to_glb(gltf_path: str, glb_path: str) -> None: +def convert_gltf_to_glb(gltf_path: str, glb_path: str, solidify: bool = False) -> None: """Import a GLTF file and re-export as GLB with embedded textures.""" bpy.ops.wm.read_factory_settings(use_empty=True) @@ -91,6 +91,33 @@ def convert_gltf_to_glb(gltf_path: str, glb_path: str) -> None: for action in list(bpy.data.actions): bpy.data.actions.remove(action) + # Solidify hair meshes — gives flat hair cards 3D thickness so they don't + # clip through the scalp. Makes hair look like a hair-shaped shell. + # Only applied to scalp hair, not eyebrows or facial hair. + HAIR_THICKNESS = 0.020 # 20mm outward thickness + if solidify: + for m in meshes: + # Skip icospheres and non-hair utility objects + if m.name.startswith("Icosphere") or len(m.data.vertices) < 50: + continue + bpy.context.view_layer.objects.active = m + m.select_set(True) + # Recalculate normals to ensure consistent outward direction + bpy.ops.object.mode_set(mode='EDIT') + bpy.ops.mesh.select_all(action='SELECT') + bpy.ops.mesh.normals_make_consistent(inside=False) + bpy.ops.object.mode_set(mode='OBJECT') + # Apply solidify + sol_mod = m.modifiers.new(name="Solidify", type='SOLIDIFY') + sol_mod.thickness = HAIR_THICKNESS + sol_mod.offset = 1.0 # grow outward only + sol_mod.use_rim = True # fill edges for closed shell + bpy.ops.object.modifier_apply(modifier=sol_mod.name) + m.select_set(False) + print(f" Solidified: {m.name} ({HAIR_THICKNESS*1000:.0f}mm outward)") + + # Keep armature parenting and skin data intact — the Godot compositor + # will load the skinned mesh and add it to the shared skeleton at runtime. bpy.ops.object.select_all(action='SELECT') bpy.ops.export_scene.gltf( @@ -102,33 +129,40 @@ def convert_gltf_to_glb(gltf_path: str, glb_path: str) -> None: export_image_format='AUTO', export_texcoords=True, export_normals=True, + export_skins=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). +def make_mask_png(mask_path: str, width: int = 64, height: int = 64) -> None: + """Generate a solid-white mask PNG (entire mesh = tintable region). - Solid white = fully tintable. This is correct for v0.2 where each hair style - has a single tint color. The mask format supports greyscale bands for multi-region - recoloring (e.g. roots vs tips as separate regions) when needed in future — no - re-export required, just a shader update to sample distinct greyscale values. + Solid white = fully tintable. Small size is fine — the shader just samples + white everywhere. The mask format supports greyscale bands for multi-region + recoloring when needed in future. """ - 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)}") + import struct + import zlib + + # Build a minimal white PNG manually — no Blender image API issues + def make_png(w, h): + def chunk(ctype, data): + c = ctype + data + return struct.pack('>I', len(data)) + c + struct.pack('>I', zlib.crc32(c) & 0xffffffff) + header = b'\x89PNG\r\n\x1a\n' + ihdr = chunk(b'IHDR', struct.pack('>IIBBBBB', w, h, 8, 0, 0, 0, 0)) # 8-bit greyscale + raw = b'' + for _ in range(h): + raw += b'\x00' + b'\xff' * w # filter byte + white pixels + idat = chunk(b'IDAT', zlib.compress(raw)) + iend = chunk(b'IEND', b'') + return header + ihdr + idat + iend + + with open(mask_path, 'wb') as f: + f.write(make_png(width, height)) + print(f" Mask saved: {os.path.basename(mask_path)} ({width}x{height} white)") def make_bald_placeholder(glb_path: str) -> None: @@ -187,7 +221,8 @@ if __name__ == "__main__": print(f"\n[{tag}] {key}") glb_path = os.path.join(out_dir, key + ".glb") - convert_gltf_to_glb(gltf_path, glb_path) + # Only solidify scalp hair — not facial hair or eyebrows + convert_gltf_to_glb(gltf_path, glb_path, solidify=(tag == "hair")) # Eyebrows: no mask (tinted directly by shader, no recolor mask needed) if tag != "eyebrows": diff --git a/tooling/blender_segment_body.py b/tooling/blender_segment_body.py index 8e30086dd..14c356f41 100644 --- a/tooling/blender_segment_body.py +++ b/tooling/blender_segment_body.py @@ -32,10 +32,11 @@ import bpy 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_torso": ["spine_01", "spine_02"], + "seg_torso_upper": ["spine_03", "clavicle_l", "clavicle_r"], + "seg_hips": ["pelvis"], + "seg_arm_upper_l": ["upperarm_l"], + "seg_arm_upper_r": ["upperarm_r"], "seg_arm_lower_l": ["lowerarm_l"], "seg_arm_lower_r": ["lowerarm_r"], "seg_hand_l": [ @@ -71,7 +72,7 @@ OBJECT_SEGMENTS = { # Ordered list for consistent output SEGMENT_ORDER = [ "seg_head", "seg_neck", - "seg_torso", "seg_torso_upper", + "seg_torso_upper", "seg_torso", "seg_hips", "seg_arm_upper_l", "seg_arm_upper_r", "seg_arm_lower_l", "seg_arm_lower_r", "seg_hand_l", "seg_hand_r", @@ -82,7 +83,7 @@ SEGMENT_ORDER = [ ] WEIGHT_THRESHOLD = 0.01 # Minimum weight to count as "belonging" to a bone -EXPAND_RINGS = 1 # 1-ring overlap at segment boundaries +EXPAND_RINGS = 0 # No overlap — clean segment boundaries for hiding/amputation def find_objects(scene): @@ -149,9 +150,11 @@ def export_glb(objects, output_path): 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. + Uses face-based assignment: each face belongs to the segment whose bones + have the highest total weight across the face's vertices. No vertices are + deleted — only faces that don't belong to this segment are removed. + This keeps all boundary vertices intact (shared with neighbors) so there + are no gaps, no holes, and no need for caps. """ import bmesh @@ -162,7 +165,6 @@ def segment_by_bones(body_mesh, armature, bone_names, output_path): 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') @@ -176,43 +178,105 @@ def segment_by_bones(body_mesh, armature, bone_names, output_path): if not vg_indices: print(f" WARNING: No vertex groups found for bones {bone_names}") - # Build BMesh from the duplicated mesh data + # Build BMesh bm = bmesh.new() bm.from_mesh(dup.data) bm.verts.ensure_lookup_table() - bm.edges.ensure_lookup_table() + bm.faces.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) + # Each face belongs to exactly ONE segment — the one whose bones have + # the highest total weight across the face's vertices. This prevents + # any face from appearing in two segments. + # + # We compute per-face the sum of weights for EVERY segment's bone set, + # then assign the face to the segment with the highest sum. We only + # keep faces assigned to THIS segment. + + # Build a map of ALL segments' bone group indices for comparison. + # Exclude swappable variants (torso_upper) — they are subsets of their + # parent segment and should not compete in exclusive face assignment. + # torso_upper gets the same faces as torso, filtered to its bone subset. + VARIANT_SEGMENTS = set() # no variants — all segments are independent + + all_segment_vg_indices = {} + for seg_name_key, seg_bones in SEGMENT_BONES.items(): + if seg_name_key in VARIANT_SEGMENTS: + continue # skip variants in competition + seg_vg = set() + for bname in seg_bones: + vg = dup.vertex_groups.get(bname) + if vg: + seg_vg.add(vg.index) + all_segment_vg_indices[seg_name_key] = seg_vg + + # For the current segment, use the key from SEGMENT_BONES that matches our bone_names + current_seg_key = None + for seg_name_key, seg_bones in SEGMENT_BONES.items(): + if set(seg_bones) == set(bone_names): + current_seg_key = seg_name_key + break + + if current_seg_key is None: + # Fallback: match by vg_indices + for seg_name_key, seg_vg in all_segment_vg_indices.items(): + if seg_vg == vg_indices: + current_seg_key = seg_name_key break - print(f" Primary vertices: {len(primary_set)} / {len(bm.verts)}") + is_variant = current_seg_key in VARIANT_SEGMENTS - # 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) + keep_faces = set() + if is_variant: + # Variant segments (e.g. torso_upper) are subsets of a parent. + # Keep only faces where the dominant bone (highest weight vertex) + # is exclusively in this variant's bone set, not the parent's + # extra bones. For torso_upper (spine_02, spine_03): keep faces + # where spine_02/spine_03 outweigh spine_01. + for face in bm.faces: + variant_w = 0.0 + total_w = 0.0 + for v in face.verts: + weights = v[deform_layer] + for idx, w in weights.items(): + total_w += w + if idx in vg_indices: + variant_w += w + # Face belongs to variant if variant bones are dominant + if total_w > 0 and variant_w / total_w > 0.5: + keep_faces.add(face) + else: + # Primary segments: exclusive assignment via competition + for face in bm.faces: + best_seg = None + best_weight = -1.0 + for seg_name_key, seg_vg in all_segment_vg_indices.items(): + total_w = 0.0 + for v in face.verts: + weights = v[deform_layer] + for idx in seg_vg: + if idx in weights: + total_w += weights[idx] + if total_w > best_weight: + best_weight = total_w + best_seg = seg_name_key + if best_seg == current_seg_key: + keep_faces.add(face) - print(f" Keep set after {EXPAND_RINGS}-ring expansion: {len(keep_set)}") + print(f" Faces to keep: {len(keep_faces)} / {len(bm.faces)}") - # 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') + # Delete faces NOT in keep_faces + faces_to_delete = [f for f in bm.faces if f not in keep_faces] + bmesh.ops.delete(bm, geom=faces_to_delete, context='FACES') - # Write back to mesh + # Clean up: remove vertices that have no faces left + bm.verts.ensure_lookup_table() + orphan_verts = [v for v in bm.verts if not v.link_faces] + if orphan_verts: + bmesh.ops.delete(bm, geom=orphan_verts, context='VERTS') + + # Write back bm.to_mesh(dup.data) bm.free() dup.data.update() diff --git a/tooling/check_hair_symmetry.py b/tooling/check_hair_symmetry.py new file mode 100644 index 000000000..d506120d0 --- /dev/null +++ b/tooling/check_hair_symmetry.py @@ -0,0 +1,31 @@ +"""Check if a hair mesh is symmetric across the X axis.""" +import sys +import bpy + +if __name__ == "__main__": + argv = sys.argv + if "--" not in argv: + sys.exit(1) + path = argv[argv.index("--") + 1] + + bpy.ops.wm.read_factory_settings(use_empty=True) + bpy.ops.import_scene.gltf(filepath=path) + + for obj in bpy.context.scene.objects: + if obj.type != 'MESH' or 'Hair' not in obj.name: + continue + verts = obj.data.vertices + xs = [v.co.x for v in verts] + zs = [v.co.z for v in verts] + print(f"{obj.name}: {len(verts)} verts") + print(f" X range: {min(xs):.4f} to {max(xs):.4f}") + print(f" Z range: {min(zs):.4f} to {max(zs):.4f}") + left = sum(1 for x in xs if x > 0.01) + right = sum(1 for x in xs if x < -0.01) + center = sum(1 for x in xs if abs(x) <= 0.01) + print(f" left(+X): {left}, right(-X): {right}, center: {center}") + + # Check normals for consistency + normals = [v.normal for v in verts] + outward = sum(1 for n in normals if n.length() > 0.5) + print(f" verts with normals: {outward}/{len(verts)}") diff --git a/tooling/convert_outfit.py b/tooling/convert_outfit.py new file mode 100644 index 000000000..8a1a1f931 --- /dev/null +++ b/tooling/convert_outfit.py @@ -0,0 +1,53 @@ +"""Convert a Quaternius outfit gltf to glb with optional solidify.""" +import sys +import bpy + +CLOTHING_THICKNESS = 0.025 # 25mm outward solidify + +if __name__ == "__main__": + argv = sys.argv + if "--" not in argv: + print("Usage: blender --background --python convert_outfit.py -- ") + sys.exit(1) + args = argv[argv.index("--") + 1:] + input_path = args[0] + output_path = args[1] + + bpy.ops.wm.read_factory_settings(use_empty=True) + bpy.ops.import_scene.gltf(filepath=input_path) + + # Solidify clothing meshes for thickness + meshes = [o for o in bpy.context.scene.objects if o.type == 'MESH'] + for m in meshes: + if m.name.startswith("Icosphere") or len(m.data.vertices) < 50: + continue + bpy.context.view_layer.objects.active = m + m.select_set(True) + # Recalculate normals for consistent outward direction + bpy.ops.object.mode_set(mode='EDIT') + bpy.ops.mesh.select_all(action='SELECT') + bpy.ops.mesh.normals_make_consistent(inside=False) + bpy.ops.object.mode_set(mode='OBJECT') + # Solidify + sol = m.modifiers.new(name="Solidify", type='SOLIDIFY') + sol.thickness = CLOTHING_THICKNESS + sol.offset = 1.0 # grow outward only + sol.use_rim = True + bpy.ops.object.modifier_apply(modifier=sol.name) + m.select_set(False) + print(f" Solidified: {m.name} ({CLOTHING_THICKNESS*1000:.0f}mm)") + + bpy.ops.object.select_all(action='SELECT') + + bpy.ops.export_scene.gltf( + filepath=output_path, + use_selection=True, + export_format='GLB', + export_animations=False, + export_yup=True, + export_texcoords=True, + export_normals=True, + export_skins=True, + export_materials='EXPORT', + ) + print(f"Exported: {output_path}") diff --git a/tooling/inspect_glb.py b/tooling/inspect_glb.py new file mode 100644 index 000000000..ae88f5b4f --- /dev/null +++ b/tooling/inspect_glb.py @@ -0,0 +1,32 @@ +"""Inspect a GLB file's objects, vertex groups, and parent hierarchy.""" +import sys +import bpy + +if __name__ == "__main__": + argv = sys.argv + if "--" not in argv: + print("Usage: blender --background --python inspect_glb.py -- ") + sys.exit(1) + args = argv[argv.index("--") + 1:] + input_path = args[0] + + bpy.ops.wm.read_factory_settings(use_empty=True) + bpy.ops.import_scene.gltf(filepath=input_path) + + for obj in bpy.context.scene.objects: + print(f"OBJ: {obj.name} type={obj.type} pos={tuple(round(v,3) for v in obj.location)}") + if obj.type == 'MESH': + vg_names = [vg.name for vg in obj.vertex_groups] + print(f" vertex_groups ({len(vg_names)}): {vg_names[:10]}") + print(f" parent: {obj.parent.name if obj.parent else 'None'}") + print(f" verts: {len(obj.data.vertices)}") + # Check skin modifier + for mod in obj.modifiers: + print(f" modifier: {mod.name} type={mod.type} object={mod.object.name if hasattr(mod, 'object') and mod.object else 'None'}") + if obj.type == 'ARMATURE': + bones = [b.name for b in obj.data.bones] + print(f" bones ({len(bones)}): {bones[:8]}...") + # Print head bone position if exists + for b in obj.data.bones: + if b.name == 'Head': + print(f" Head bone head_local: {tuple(round(v,3) for v in b.head_local)}") diff --git a/tooling/render_quaternius_test.py b/tooling/render_quaternius_test.py new file mode 100644 index 000000000..d2071d5e9 --- /dev/null +++ b/tooling/render_quaternius_test.py @@ -0,0 +1,75 @@ +"""Render the raw Quaternius body + hair from multiple angles to check for clipping.""" +import sys +import os +import math +import bpy + +if __name__ == "__main__": + argv = sys.argv + if "--" not in argv: + sys.exit(1) + out_dir = argv[argv.index("--") + 1] + os.makedirs(out_dir, exist_ok=True) + + bpy.ops.wm.read_factory_settings(use_empty=True) + + # Load body + body_path = "/var/mnt/data/projects/settled-reach/main/docs/assets/downloads/Universal Base Characters[Source]/Base Characters/Exports/Godot - UE/Superhero_Female_FullBody.gltf" + bpy.ops.import_scene.gltf(filepath=body_path) + + # Load bob hair + hair_path = "/var/mnt/data/projects/settled-reach/main/docs/assets/downloads/Universal Base Characters[Source]/Hairstyles/Rigged to Head Bone/glTF (Godot -Unreal)/Female/Hair_Bob.gltf" + bpy.ops.import_scene.gltf(filepath=hair_path) + + # Remove icospheres + for obj in list(bpy.context.scene.objects): + if "Icosphere" in obj.name: + bpy.data.objects.remove(obj, do_unlink=True) + + # Setup camera + cam_data = bpy.data.cameras.new("TestCam") + cam_data.lens = 85 + cam = bpy.data.objects.new("TestCam", cam_data) + bpy.context.scene.collection.objects.link(cam) + bpy.context.scene.camera = cam + + # Light + light_data = bpy.data.lights.new("TestLight", type='SUN') + light_data.energy = 3.0 + light = bpy.data.objects.new("TestLight", light_data) + light.rotation_euler = (math.radians(45), 0, math.radians(30)) + bpy.context.scene.collection.objects.link(light) + + # Render settings + bpy.context.scene.render.engine = 'BLENDER_EEVEE_NEXT' + bpy.context.scene.render.resolution_x = 800 + bpy.context.scene.render.resolution_y = 600 + bpy.context.scene.render.film_transparent = True + + # Head center (approx) + target = (0.0, 0.0, 1.65) + dist = 0.5 + + angles = { + "front": 0, + "right": 90, + "back": 180, + "left": 270, + } + + for name, deg in angles.items(): + rad = math.radians(deg) + cx = target[0] + dist * math.sin(rad) + cy = target[1] - dist * math.cos(rad) + cz = target[2] + cam.location = (cx, cy, cz) + # Point at target + direction = (target[0] - cx, target[1] - cy, target[2] - cz) + rot_z = math.atan2(direction[0], -direction[1]) + rot_x = math.atan2(math.sqrt(direction[0]**2 + direction[1]**2), direction[2]) + cam.rotation_euler = (rot_x, 0, rot_z) + + filepath = os.path.join(out_dir, f"raw_bob_{name}.png") + bpy.context.scene.render.filepath = filepath + bpy.ops.render.render(write_still=True) + print(f"Rendered: {filepath}") diff --git a/tooling/segment_reference_distribution.md b/tooling/segment_reference_distribution.md new file mode 100644 index 000000000..b5426c3fc --- /dev/null +++ b/tooling/segment_reference_distribution.md @@ -0,0 +1,34 @@ +# Segment Face Distribution Reference (average_m) + +Source: Regular_Male_FullBody.gltf (7047 verts, 12128 faces) +Date: 2026-03-23 +Method: face-based exclusive assignment (dominant bone weight per face) +Bone mapping: + torso: spine_01, spine_02 + torso_upper: spine_03, clavicle_l, clavicle_r + hips: pelvis + arm_upper: upperarm_l/r (no clavicle) + +| Segment | Faces | Status | +|---------|-------|--------| +| seg_head | 2552 | LOCKED | +| seg_neck | 200 | LOCKED | +| seg_torso | 631 | LOCKED | +| seg_torso_upper | 762 | LOCKED | +| seg_hips | 314 | LOCKED | +| seg_arm_upper_l | 274 | LOCKED | +| seg_arm_upper_r | 274 | LOCKED | +| seg_arm_lower_l | 293 | LOCKED | +| seg_arm_lower_r | 293 | LOCKED | +| seg_hand_l | 1722 | LOCKED | +| seg_hand_r | 1722 | LOCKED | +| seg_leg_upper_l | 406 | LOCKED | +| seg_leg_upper_r | 409 | LOCKED | +| seg_leg_lower_l | 439 | LOCKED | +| seg_leg_lower_r | 439 | LOCKED | +| seg_foot_l | 699 | LOCKED | +| seg_foot_r | 699 | LOCKED | +| seg_eyes | (sub-object) | LOCKED | +| seg_eyebrows | (sub-object) | LOCKED | + +All segments locked. No overlaps, no variants — all independent. diff --git a/tooling/test_quaternius_raw.py b/tooling/test_quaternius_raw.py new file mode 100644 index 000000000..dcd03af91 --- /dev/null +++ b/tooling/test_quaternius_raw.py @@ -0,0 +1,42 @@ +"""Load original Quaternius body + hair as-is and export a combined GLB for visual inspection.""" +import sys +import os +import bpy + +if __name__ == "__main__": + argv = sys.argv + if "--" not in argv: + print("Usage: blender --background --python test_quaternius_raw.py -- ") + sys.exit(1) + output = argv[argv.index("--") + 1] + + bpy.ops.wm.read_factory_settings(use_empty=True) + + # Load the Superhero Female body (original, unmodified) + body_path = "/var/mnt/data/projects/settled-reach/main/docs/assets/downloads/Universal Base Characters[Source]/Base Characters/Exports/Godot - UE/Superhero_Female_FullBody.gltf" + bpy.ops.import_scene.gltf(filepath=body_path) + + print("=== After body import ===") + for obj in bpy.context.scene.objects: + print(f" {obj.name} type={obj.type}") + + # Load bob hair (Rigged to Head Bone, Female) + hair_path = "/var/mnt/data/projects/settled-reach/main/docs/assets/downloads/Universal Base Characters[Source]/Hairstyles/Rigged to Head Bone/glTF (Godot -Unreal)/Female/Hair_Bob.gltf" + bpy.ops.import_scene.gltf(filepath=hair_path) + + print("=== After hair import ===") + for obj in bpy.context.scene.objects: + print(f" {obj.name} type={obj.type} parent={obj.parent.name if obj.parent else 'None'}") + + # Export everything as one GLB + bpy.ops.object.select_all(action='SELECT') + bpy.ops.export_scene.gltf( + filepath=output, + use_selection=True, + export_format='GLB', + export_animations=False, + export_yup=True, + export_skins=True, + export_materials='EXPORT', + ) + print(f"Exported: {output}")