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