Transplant the hoodie head parts onto the Worker base and put carried props in the hands
Full build / Import + headless tests (push) Successful in 45s
Full build / Full desktop build + optional release (push) Skipped

This commit is contained in:
zwitschi committed 2026-11-06 14:40:35 +01:00
1 parent 3ab7577634
commit 529d15ee7a
16 files changed
+179 -84

No files matched your search

+99 -42
View File
@@ -1504,32 +1504,41 @@ QUATERNIUS_SOURCES = {
"Worker_Vest": "cloth",
"Worker_Yellow": {"split": True, "z": 1.65,
"above": "hair", "below": "cloth"},
# The bar is deleted in `_transplant_brows()` and replaced by
# the hoodie's two brows; what remains of this material is the
# dark shell over the crown, which follows the hair.
# The bar, the oversized eyes and the hard hat are replaced by
# `_transplant_worker_head()`; those materials are gone by here.
"Eyebrows": "hair",
"Brows": "keep",
"Eye": "keep", "Moustache": "keep"},
"donor_brows": True,
"donor_head": True,
"notes": "work shirt (vest geometry) + trousers + hard hat/crown",
},
}
## The modular Worker's `Eyebrows` material is one continuous bar across the
## face (the owner reads it as a unibrow) and no other upstream head has brows
## that fit this head, because the three heads are different meshes. `casual` is
## the closest sibling, so its two brows are transplanted onto the Worker head
## and the Worker bar is deleted. Everything else in the pipeline is remapped,
## never deleted: the Worker's vest *is* its torso and its hard hat *is* the
## crown of its skull.
DONOR_BROWS = {"source": "Casual_Hoodie.glb", "material": "Eyebrows",
"slot_name": "Brows", "bone": "Head",
# The two heads are different meshes: the Worker's eye quads top
# out at y 1.709, so the hoodie's brows (1.702-1.717) landed on the
# eyes and read as one dark mass. Lift them above the eyes and sink
# them into the brow ridge. Blender z = Godot y.
"offset": (0.0, 0.0, 0.018)}
## The modular Worker head has three defects the roster cannot live with, and no
## other upstream head has parts that fit it directly, because the three heads are
## different meshes at different detail levels. `casual_hoodie` is the closest
## sibling, so its brows, eyes and hair are transplanted onto the Worker head:
##
## - `Eyebrows` is one continuous bar across the face (reads as a unibrow);
## - `Eye` is two oversized quads that nearly touch (the same dark mass);
## - `Worker_Yellow` above the collar is a hard hat that doubles as the skull
## crown, so a guard or responder looks like it is wearing a helmet to bed.
##
## This is the only place the build removes geometry. Everything else is
## remapped, because the Worker's vest *is* its torso and its hat *is* its crown.
WORKER_HEAD_DONOR = {
"source": "Casual_Hoodie.glb",
# donor material -> contract slot name. The hoodie's eyes are not used: this
# head's eye quads are large enough to read as one dark bar, which is the
# "unibrow" the owner saw. The brows and hair carry the face instead.
"parts": {"Eyebrows": "Brows", "Hair": "hair"},
# Lift the brows clear of the eye line (the two heads differ in mesh density).
"lift": {"Brows": 0.018},
# our material -> height rule for the faces that go away
"remove": {"Eyebrows": None, "Eye": None, "Worker_Yellow": 1.60},
"bone": "Head",
}
def _base_name(name):
@@ -1538,28 +1547,49 @@ def _base_name(name):
return head if sep and tail.isdigit() else name
def _transplant_brows(obj):
"""Replace the Worker eyebrow bar with the two brows from the hoodie head."""
spec = DONOR_BROWS
# Cut our own bar: every `Eyebrows` face below the crown shell.
def _transplant_worker_head(obj):
"""Replace the Worker brow bar, eyes and hard hat with the hoodie's head gear."""
spec = WORKER_HEAD_DONOR
# Resolve the targets before anything is deleted or imported: the material
# objects are stable through the join, names are not.
targets = {}
for name in spec["remove"]:
material = next((m for m in obj.data.materials if m and m.name == name), None)
if material is not None:
targets[material.name] = material
doomed = []
for poly in obj.data.polygons:
material = obj.data.materials[poly.material_index]
if material is None or material.name != spec["material"]:
if material is None or material.name not in targets:
continue
if _face_centroid(obj, poly)[2] < 1.70:
doomed.append(poly.index)
limit = spec["remove"].get(material.name)
if limit is not None and _face_centroid(obj, poly)[2] <= float(limit):
continue
doomed.append(poly.index)
if doomed:
bm = bmesh.new()
bm.from_mesh(obj.data)
bm.faces.ensure_lookup_table()
bmesh.ops.delete(bm, geom=[bm.faces[i] for i in doomed], context="FACES")
loose = [v for v in bm.verts if not v.link_faces]
if loose:
bmesh.ops.delete(bm, geom=loose, context="VERTS")
bm.to_mesh(obj.data)
bm.free()
print(f" removed {len(doomed)} Worker head faces")
# Free the slot names so the donor's materials can take them. `pop` removes a
# slot by index and repoints the polygons that used it.
for index in range(len(obj.data.materials) - 1, -1, -1):
slot = obj.data.materials[index]
if slot is None or slot.name not in spec["remove"]:
continue
if any(poly.material_index == index for poly in obj.data.polygons):
continue
obj.data.materials.pop(index=index)
donor_path = QUATERNIUS_SRC / spec["source"]
if not donor_path.is_file():
print(f" brows: {donor_path.name} missing, keeping the bar")
print(f" head donor {donor_path.name} missing, Worker head left bare")
return
before = set(o.name for o in bpy.context.scene.objects)
bpy.ops.import_scene.gltf(filepath=str(donor_path))
@@ -1568,16 +1598,14 @@ def _transplant_brows(obj):
for candidate in added:
if candidate.type != "MESH":
continue
# A second import of the same pack reuses the material names, so Blender
# suffixes them (`Eyebrows.001`); match the base name.
keep = [i for i, m in enumerate(candidate.data.materials)
if m is not None and _base_name(m.name) == spec["material"]]
if not keep:
wanted = [i for i, m in enumerate(candidate.data.materials)
if m is not None and _base_name(m.name) in spec["parts"]]
if not wanted:
continue
bm = bmesh.new()
bm.from_mesh(candidate.data)
bm.faces.ensure_lookup_table()
drop = [f for f in bm.faces if f.material_index not in keep]
drop = [f for f in bm.faces if f.material_index not in wanted]
if drop:
bmesh.ops.delete(bm, geom=drop, context="FACES")
loose = [v for v in bm.verts if not v.link_faces]
@@ -1588,30 +1616,59 @@ def _transplant_brows(obj):
continue
bm.to_mesh(candidate.data)
bm.free()
for m in candidate.data.materials:
if m is not None and _base_name(m.name) == spec["material"]:
m.name = spec["slot_name"]
# Collect the kept materials, then remap the faces: clearing the material
# list resets every polygon to slot 0.
kept = []
remap = {}
for index, m in enumerate(candidate.data.materials):
if m is None:
continue
slot = spec["parts"].get(_base_name(m.name))
if slot is None:
continue
m.name = slot
remap[index] = len(kept)
kept.append(m)
for slot_name, lift in spec.get("lift", {}).items():
local = [i for i, m in enumerate(candidate.data.materials)
if m is not None and m.name == slot_name]
if not local:
continue
bm = bmesh.new()
bm.from_mesh(candidate.data)
bm.faces.ensure_lookup_table()
moved = {v for f in bm.faces if f.material_index in local for v in f.verts}
for v in moved:
v.co.z += float(lift)
bm.to_mesh(candidate.data)
bm.free()
face_slots = [remap.get(poly.material_index, 0)
for poly in candidate.data.polygons]
candidate.data.materials.clear()
for m in kept:
candidate.data.materials.append(m)
for poly, slot in zip(candidate.data.polygons, face_slots):
poly.material_index = slot
donor = candidate
break
for extra in added:
if extra is not donor:
bpy.data.objects.remove(extra, do_unlink=True)
if donor is None:
print(" brows: donor head not found, keeping the bar")
print(" head donor not found, Worker head left bare")
return
donor.parent = None
donor.matrix_world = Matrix.Identity(4)
donor.data.transform(Matrix.Translation(spec["offset"]))
group = obj.vertex_groups.get(spec["bone"]) or obj.vertex_groups.new(name=spec["bone"])
for o in bpy.context.selected_objects:
o.select_set(False)
obj.select_set(True)
donor.select_set(True)
bpy.context.view_layer.objects.active = obj
bpy.ops.object.join()
# The donor's vertices carry no weights; the brows ride the head bone.
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 {spec['slot_name']} from {spec['source']}")
print(f" transplanted {', '.join(sorted(spec['parts'].values()))} "
f"from {spec['source']}")
def _face_centroid(obj, poly):
@@ -1872,8 +1929,8 @@ def _quaternius_tpose_glb(base, overrides):
slots = dict(source["slots"])
slots.update(overrides)
if source.get("donor_brows"):
_transplant_brows(obj)
if source.get("donor_head"):
_transplant_worker_head(obj)
_quaternius_slot_map(obj, slots)
# Upstream UVs are degenerate (every loop at 0,0), so an AO bake would write