Give every Quaternius head a visible mouth and queue the prop and gear fit pass
Full build / Import + headless tests (push) Successful in 40s
Full build / Full desktop build + optional release (push) Skipped

This commit is contained in:
zwitschi committed 2026-11-06 18:02:55 +01:00
1 parent 30d145079a
commit 9fd48243ad
32 files changed
+146 -36

No files matched your search

+60
View File
@@ -1587,6 +1587,65 @@ def _purge_unused_materials(obj):
bpy.data.materials.remove(material)
def _add_quaternius_mouth(obj):
"""Give every Quaternius head a readable mouth.
None of the three upstream heads carries a mouth material: the face is skin
only, so at gameplay distance the characters read as brow-and-eye masks. A
small flat lip block is placed on the measured skin surface, which is why it
works for all three bases without per-source tuning.
"""
# This runs before the slot map, so the material is still the upstream `Skin`.
skin = next((i for i, m in enumerate(obj.data.materials)
if m is not None and _base_name(m.name).lower() == "skin"), None)
if skin is None:
return
band = []
for poly in obj.data.polygons:
if poly.material_index != skin:
continue
for index in poly.vertices:
co = obj.data.vertices[index].co
if 1.600 <= co.z <= 1.632 and abs(co.x) <= 0.035:
band.append(co.y)
if not band:
return
front = min(band) # Blender -Y is the face; the lip sits on the surface
height = 1.616
material = bpy.data.materials.new("mouth")
material.use_nodes = True
bsdf = material.node_tree.nodes.get("Principled BSDF")
if bsdf is not None:
# Blender node colours are linear; 0.16 linear reads as mauve once
# Tonemapped in Godot, so the lip is set well below the skin value.
bsdf.inputs["Base Color"].default_value = (0.055, 0.018, 0.02, 1.0)
bsdf.inputs["Roughness"].default_value = 0.6
else:
print(" mouth: no Principled node, colour left at default")
material.diffuse_color = (0.055, 0.018, 0.02, 1.0)
bm = bmesh.new()
bmesh.ops.create_cube(bm, size=1.0)
for vert in bm.verts:
vert.co.x *= 0.058
vert.co.y *= 0.012
vert.co.z *= 0.011
vert.co += Vector((0.0, front + 0.003, height))
mouth = bpy.data.objects.new("Mouth", bpy.data.meshes.new("Mouth"))
bm.to_mesh(mouth.data)
bm.free()
mouth.data.materials.append(material)
bpy.context.scene.collection.objects.link(mouth)
for o in bpy.context.selected_objects:
o.select_set(False)
obj.select_set(True)
mouth.select_set(True)
bpy.context.view_layer.objects.active = obj
bpy.ops.object.join()
group = obj.vertex_groups.get("Head") or obj.vertex_groups.new(name="Head")
group.add([v.index for v in obj.data.vertices if not v.groups], 1.0, "REPLACE")
print(f" mouth placed at y {front + 0.003:+.3f} z {height:.3f}")
def _base_name(name):
"""Strip Blender's `.001` duplicate suffix."""
head, sep, tail = name.rpartition(".")
@@ -1998,6 +2057,7 @@ def _quaternius_tpose_glb(base, overrides):
slots.update(overrides)
if source.get("donor_head"):
_transplant_worker_head(obj)
_add_quaternius_mouth(obj)
_quaternius_slot_map(obj, slots)
# Upstream UVs are degenerate (every loop at 0,0), so an AO bake would write