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 <noreply@anthropic.com>
This commit is contained in:
2026-03-23 17:43:04 +01:00
co-authored by Claude Opus 4.6
parent 4d9840b68f
commit 093b24e38e
17 changed files with 489 additions and 65 deletions
@@ -1,6 +1,7 @@
{ {
"hides": [ "hides": [
"torso", "torso",
"hips",
"arm_upper_l", "arm_upper_l",
"arm_upper_r", "arm_upper_r",
"arm_lower_l", "arm_lower_l",
@@ -1,6 +1,7 @@
{ {
"hides": [ "hides": [
"torso", "torso",
"hips",
"arm_upper_l", "arm_upper_l",
"arm_upper_r", "arm_upper_r",
"arm_lower_l", "arm_lower_l",
@@ -1,5 +1,6 @@
{ {
"hides": [ "hides": [
"hips",
"leg_upper_l", "leg_upper_l",
"leg_upper_r", "leg_upper_r",
"leg_lower_l", "leg_lower_l",
@@ -0,0 +1,4 @@
{
"hides": ["hips", "leg_upper_l", "leg_upper_r"],
"torso_variant": "full"
}
@@ -0,0 +1,4 @@
{
"hides": [],
"torso_variant": "full"
}
@@ -0,0 +1,4 @@
{
"hides": ["torso", "neck", "hips", "arm_upper_l", "arm_upper_r"],
"torso_variant": "full"
}
@@ -1,6 +1,7 @@
{ {
"hides": [ "hides": [
"torso", "torso",
"hips",
"arm_upper_l", "arm_upper_l",
"arm_upper_r" "arm_upper_r"
], ],
+8 -1
View File
@@ -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.*
+43 -8
View File
@@ -124,10 +124,12 @@ def offset_vertices_along_normals(obj, thickness):
obj.data.update() obj.data.update()
def export_glb(obj, output_path): def export_glb(obj, output_path, armature=None):
"""Export a single mesh object as GLB with UVs, normals, and materials.""" """Export a mesh (and optionally its armature) as GLB with skinning data."""
bpy.ops.object.select_all(action='DESELECT') bpy.ops.object.select_all(action='DESELECT')
obj.select_set(True) obj.select_set(True)
if armature:
armature.select_set(True)
bpy.context.view_layer.objects.active = obj bpy.context.view_layer.objects.active = obj
bpy.ops.export_scene.gltf( bpy.ops.export_scene.gltf(
@@ -139,7 +141,7 @@ def export_glb(obj, output_path):
export_image_format='AUTO', export_image_format='AUTO',
export_texcoords=True, export_texcoords=True,
export_normals=True, export_normals=True,
export_skins=False, export_skins=armature is not None,
export_materials='EXPORT', export_materials='EXPORT',
) )
@@ -147,6 +149,7 @@ def export_glb(obj, output_path):
def create_reference_mesh(item_id, config, bodies_dir, output_dir): def create_reference_mesh(item_id, config, bodies_dir, output_dir):
""" """
Create the reference.glb for one clothing item on average_m geometry. 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. Returns True on success, False on error.
""" """
print(f"\n{'='*60}") 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) average_m_dir = os.path.join(bodies_dir, REFERENCE_BODY)
imported_meshes = [] imported_meshes = []
imported_armatures = []
missing_segments = [] missing_segments = []
for seg_name in config["segments"]: for seg_name in config["segments"]:
@@ -165,7 +169,9 @@ def create_reference_mesh(item_id, config, bodies_dir, output_dir):
continue continue
objs = import_glb(seg_path) objs = import_glb(seg_path)
meshes = [o for o in objs if o.type == 'MESH'] 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_meshes.extend(meshes)
imported_armatures.extend(armatures)
if missing_segments: if missing_segments:
print(f" NOTE: Missing segments (skipped): {', '.join(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}") print(f" ERROR: No segment meshes could be imported for {item_id}")
return False 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)") 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') bpy.ops.object.select_all(action='DESELECT')
for m in imported_meshes: 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] bpy.context.view_layer.objects.active = imported_meshes[0]
if len(imported_meshes) > 1: if len(imported_meshes) > 1:
bpy.ops.object.join() 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) vertex_count_before = len(clothing_obj.data.vertices)
print(f" Mesh vertices: {vertex_count_before}") 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 # Offset vertices outward to simulate clothing thickness
thickness = config["thickness"] thickness = config["thickness"]
offset_vertices_along_normals(clothing_obj, thickness) offset_vertices_along_normals(clothing_obj, thickness)
print(f" Applied {thickness*1000:.1f}mm outward offset") print(f" Applied {thickness*1000:.1f}mm outward offset")
# Export # Export with armature so clothing is skinned
os.makedirs(output_dir, exist_ok=True) os.makedirs(output_dir, exist_ok=True)
output_path = os.path.join(output_dir, "reference.glb") 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}") print(f" Exported: {output_path}")
return True return True
+56 -21
View File
@@ -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.""" """Import a GLTF file and re-export as GLB with embedded textures."""
bpy.ops.wm.read_factory_settings(use_empty=True) 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): for action in list(bpy.data.actions):
bpy.data.actions.remove(action) 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.object.select_all(action='SELECT')
bpy.ops.export_scene.gltf( 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_image_format='AUTO',
export_texcoords=True, export_texcoords=True,
export_normals=True, export_normals=True,
export_skins=True,
export_materials='EXPORT', export_materials='EXPORT',
) )
size = os.path.getsize(glb_path) size = os.path.getsize(glb_path)
print(f" GLB exported: {os.path.basename(glb_path)} ({size:,} bytes)") 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: def make_mask_png(mask_path: str, width: int = 64, height: int = 64) -> None:
"""Generate a solid-white greyscale mask PNG (entire mesh = tintable region). """Generate a solid-white mask PNG (entire mesh = tintable region).
Solid white = fully tintable. This is correct for v0.2 where each hair style Solid white = fully tintable. Small size is fine — the shader just samples
has a single tint color. The mask format supports greyscale bands for multi-region white everywhere. The mask format supports greyscale bands for multi-region
recoloring (e.g. roots vs tips as separate regions) when needed in future — no recoloring when needed in future.
re-export required, just a shader update to sample distinct greyscale values.
""" """
mask_img = bpy.data.images.new( import struct
name="hair_mask", import zlib
width=width,
height=height, # Build a minimal white PNG manually — no Blender image API issues
alpha=False, def make_png(w, h):
float_buffer=False, def chunk(ctype, data):
) c = ctype + data
mask_img.pixels = [1.0] * (width * height * 4) return struct.pack('>I', len(data)) + c + struct.pack('>I', zlib.crc32(c) & 0xffffffff)
mask_img.colorspace_settings.name = 'Non-Color' header = b'\x89PNG\r\n\x1a\n'
mask_img.file_format = 'PNG' ihdr = chunk(b'IHDR', struct.pack('>IIBBBBB', w, h, 8, 0, 0, 0, 0)) # 8-bit greyscale
mask_img.filepath_raw = mask_path raw = b''
mask_img.save() for _ in range(h):
print(f" Mask saved: {os.path.basename(mask_path)}") 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: def make_bald_placeholder(glb_path: str) -> None:
@@ -187,7 +221,8 @@ if __name__ == "__main__":
print(f"\n[{tag}] {key}") print(f"\n[{tag}] {key}")
glb_path = os.path.join(out_dir, key + ".glb") 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) # Eyebrows: no mask (tinted directly by shader, no recolor mask needed)
if tag != "eyebrows": if tag != "eyebrows":
+99 -35
View File
@@ -32,10 +32,11 @@ import bpy
SEGMENT_BONES = { SEGMENT_BONES = {
"seg_head": ["Head"], "seg_head": ["Head"],
"seg_neck": ["neck_01"], "seg_neck": ["neck_01"],
"seg_torso": ["pelvis", "spine_01", "spine_02", "spine_03"], "seg_torso": ["spine_01", "spine_02"],
"seg_torso_upper": ["spine_02", "spine_03"], "seg_torso_upper": ["spine_03", "clavicle_l", "clavicle_r"],
"seg_arm_upper_l": ["clavicle_l", "upperarm_l"], "seg_hips": ["pelvis"],
"seg_arm_upper_r": ["clavicle_r", "upperarm_r"], "seg_arm_upper_l": ["upperarm_l"],
"seg_arm_upper_r": ["upperarm_r"],
"seg_arm_lower_l": ["lowerarm_l"], "seg_arm_lower_l": ["lowerarm_l"],
"seg_arm_lower_r": ["lowerarm_r"], "seg_arm_lower_r": ["lowerarm_r"],
"seg_hand_l": [ "seg_hand_l": [
@@ -71,7 +72,7 @@ OBJECT_SEGMENTS = {
# Ordered list for consistent output # Ordered list for consistent output
SEGMENT_ORDER = [ SEGMENT_ORDER = [
"seg_head", "seg_neck", "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_upper_l", "seg_arm_upper_r",
"seg_arm_lower_l", "seg_arm_lower_r", "seg_arm_lower_l", "seg_arm_lower_r",
"seg_hand_l", "seg_hand_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 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): 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): def segment_by_bones(body_mesh, armature, bone_names, output_path):
""" """
Extract a segment from body_mesh based on bone weights. Extract a segment from body_mesh based on bone weights.
Uses BMesh API directly (reliable in headless mode without context issues). Uses face-based assignment: each face belongs to the segment whose bones
Includes 1-ring boundary overlap for seam-free deformation. have the highest total weight across the face's vertices. No vertices are
Exports segment + armature. 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 import bmesh
@@ -162,7 +165,6 @@ def segment_by_bones(body_mesh, armature, bone_names, output_path):
bpy.ops.object.duplicate(linked=False) bpy.ops.object.duplicate(linked=False)
dup = bpy.context.active_object dup = bpy.context.active_object
# Ensure we're in object mode
if dup.mode != 'OBJECT': if dup.mode != 'OBJECT':
bpy.ops.object.mode_set(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: if not vg_indices:
print(f" WARNING: No vertex groups found for bones {bone_names}") print(f" WARNING: No vertex groups found for bones {bone_names}")
# Build BMesh from the duplicated mesh data # Build BMesh
bm = bmesh.new() bm = bmesh.new()
bm.from_mesh(dup.data) bm.from_mesh(dup.data)
bm.verts.ensure_lookup_table() bm.verts.ensure_lookup_table()
bm.edges.ensure_lookup_table() bm.faces.ensure_lookup_table()
deform_layer = bm.verts.layers.deform.verify() deform_layer = bm.verts.layers.deform.verify()
# Identify vertices that belong to this segment (primary set) # Each face belongs to exactly ONE segment — the one whose bones have
primary_set = set() # the highest total weight across the face's vertices. This prevents
for v in bm.verts: # any face from appearing in two segments.
weights = v[deform_layer] #
for idx in vg_indices: # We compute per-face the sum of weights for EVERY segment's bone set,
if idx in weights and weights[idx] > WEIGHT_THRESHOLD: # then assign the face to the segment with the highest sum. We only
primary_set.add(v) # 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 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_faces = set()
keep_set = set(primary_set) if is_variant:
for _ in range(EXPAND_RINGS): # Variant segments (e.g. torso_upper) are subsets of a parent.
boundary = set() # Keep only faces where the dominant bone (highest weight vertex)
for v in keep_set: # is exclusively in this variant's bone set, not the parent's
for edge in v.link_edges: # extra bones. For torso_upper (spine_02, spine_03): keep faces
for other_v in edge.verts: # where spine_02/spine_03 outweigh spine_01.
if other_v not in keep_set: for face in bm.faces:
boundary.add(other_v) variant_w = 0.0
keep_set.update(boundary) 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 # Delete faces NOT in keep_faces
verts_to_delete = [v for v in bm.verts if v not in keep_set] faces_to_delete = [f for f in bm.faces if f not in keep_faces]
bmesh.ops.delete(bm, geom=verts_to_delete, context='VERTS') 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.to_mesh(dup.data)
bm.free() bm.free()
dup.data.update() dup.data.update()
+31
View File
@@ -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)}")
+53
View File
@@ -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 -- <input.gltf> <output.glb>")
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}")
+32
View File
@@ -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 -- <input.glb>")
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)}")
+75
View File
@@ -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}")
+34
View File
@@ -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.
+42
View File
@@ -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 -- <output.glb>")
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}")