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": [
"torso",
"hips",
"arm_upper_l",
"arm_upper_r",
"arm_lower_l",
@@ -1,6 +1,7 @@
{
"hides": [
"torso",
"hips",
"arm_upper_l",
"arm_upper_r",
"arm_lower_l",
@@ -1,5 +1,6 @@
{
"hides": [
"hips",
"leg_upper_l",
"leg_upper_r",
"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": [
"torso",
"hips",
"arm_upper_l",
"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()
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
+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."""
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":
+99 -35
View File
@@ -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()
+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}")