Copy the hoodie eye pair onto the Worker head and lift every hat except the firefighter helmet
Full build / Import + headless tests (push) Successful in 46s
Full build / Full desktop build + optional release (push) Skipped

This commit is contained in:
zwitschi committed 2026-11-06 15:11:57 +01:00
1 parent a290fe83c8
commit 98f618c501
15 files changed
+79 -21

No files matched your search

+57 -9
View File
@@ -1510,7 +1510,7 @@ QUATERNIUS_SOURCES = {
"above": "hair", "below": "cloth"},
# The bar, the oversized eyes and the hard hat are replaced by
# `_transplant_worker_head()`; those materials are gone by here.
"Eyebrows": "hair",
"Eyebrows": "keep",
"Brows": "keep",
"Eye": "keep", "Moustache": "keep"},
"donor_head": True,
@@ -1534,24 +1534,59 @@ QUATERNIUS_SOURCES = {
WORKER_HEAD_DONOR = {
"source": "Casual_Hoodie.glb",
# donor material -> contract slot name.
"parts": {"Eyebrows": "Brows", "Hair": "hair"},
"parts": {"Eyebrows": "Brows", "Eye": "Eye", "Hair": "hair"},
# The three upstream heads carry the same-sized eye quads (x +-0.057), but the
# Worker's face is flatter, so unchecked they sit proud of the skin and merge
# into one dark bar -- the "unibrow" the owner saw. Set them back into the
# socket so the skin crops their edges, and lift the brows clear of them.
"offset": {"Brows": (0.0, 0.0, 0.020)},
# Brows lift clear of the eye line; the eyes are pushed 4 mm forward so they
# clear the Worker's flatter face instead of sinking into it.
"offset": {"Brows": (0.0, 0.0, 0.020), "Eye": (0.0, -0.004, 0.0)},
# Our material -> the height below which its faces go away. `Eyebrows` is the
# bar *and* the forehead shell, so only the bar is cut: removing the whole
# material opened the skull and the interior rendered as a black band across
# the brows. `Eye` goes entirely (its quads read as one dark mass).
# `Eye` is deliberately *not* removed: this head has open sockets and the eye
# quads are the only thing filling them, so deleting them showed the inside of
# the head as a black band. They stay; the donor brows sit above them.
"remove": {"Eyebrows": 1.715, "Worker_Yellow": 1.60},
# The Worker's own eye quads sit inside the head and never show (the owner
# reported the Worker roles as eyeless), so they are replaced by the hoodie's
# pair, which is the same geometry the other eight characters use.
"remove": {"Eyebrows": 1.715, "Worker_Yellow": 1.60, "Eye": None},
"bone": "Head",
}
def _purge_unused_materials(obj):
"""Drop material slots no face uses, so a donor can take the freed name.
`IDMaterials.pop()` repoints the polygons that used the slot, which corrupted
the mesh the first time this was tried; rebuilding the list from the per-face
names is explicit and leaves no dangling index.
"""
names = []
for poly in obj.data.polygons:
material = obj.data.materials[poly.material_index]
names.append(material.name if material else "")
kept, index_of = [], {}
for name in names:
if name in index_of:
continue
source = bpy.data.materials.get(name)
if source is None:
continue
index_of[name] = len(kept)
kept.append(source)
if len(kept) == len(obj.data.materials):
return
obj.data.materials.clear()
for material in kept:
obj.data.materials.append(material)
for poly, name in zip(obj.data.polygons, names):
poly.material_index = index_of.get(name, 0)
# A removed slot leaves its datablock behind with zero users, which still owns
# the name and forces the donor's material to `Eye.001`.
for material in [m for m in bpy.data.materials if m.users == 0]:
bpy.data.materials.remove(material)
def _base_name(name):
"""Strip Blender's `.001` duplicate suffix."""
head, sep, tail = name.rpartition(".")
@@ -1588,6 +1623,8 @@ def _transplant_worker_head(obj):
bm.to_mesh(obj.data)
bm.free()
print(f" removed {len(doomed)} Worker head faces")
_purge_unused_materials(obj)
donor_path = QUATERNIUS_SRC / spec["source"]
if not donor_path.is_file():
print(f" head donor {donor_path.name} missing, Worker head left bare")
@@ -1632,7 +1669,7 @@ def _transplant_worker_head(obj):
kept.append(m)
for slot_name, rule in spec.get("narrow", {}).items():
local = [i for i, m in enumerate(candidate.data.materials)
if m is not None and m.name == slot_name]
if m is not None and _base_name(m.name) == slot_name]
if not local:
continue
bm = bmesh.new()
@@ -1649,7 +1686,7 @@ def _transplant_worker_head(obj):
for slot_name, offset in spec.get("offset", {}).items():
local = [i for i, m in enumerate(candidate.data.materials)
if m is not None and m.name == slot_name]
if m is not None and _base_name(m.name) == slot_name]
if not local:
continue
bm = bmesh.new()
@@ -1684,6 +1721,17 @@ def _transplant_worker_head(obj):
donor.select_set(True)
bpy.context.view_layer.objects.active = obj
bpy.ops.object.join()
# The join can leave `Eye.001` next to a zero-user `Eye`; the contract slot
# names must be exact because the runtime tints by name.
slot_names = {m.name for m in obj.data.materials if m is not None}
for material in obj.data.materials:
if material is None:
continue
base = _base_name(material.name)
if base != material.name and base not in slot_names:
slot_names.discard(material.name)
material.name = base
slot_names.add(base)
group = obj.vertex_groups.get(spec["bone"]) or obj.vertex_groups.new(name=spec["bone"])
group.add([v.index for v in obj.data.vertices if not v.groups], 1.0, "REPLACE")
print(f" transplanted {', '.join(sorted(spec['parts'].values()))} "