Transplant the hoodie head parts onto the Worker base and put carried props in the hands
This commit is contained in:
1 parent
3ab7577634
commit
529d15ee7a
16 files changed
+179
-84
No files matched your search
+99
-42
@@ -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
|
||||
|
||||
Reference in new issue
Block a user