Fix the worker torso/crown holes, transplant proper brows, widen the rest-arm clearance
Full build / Import + headless tests (push) Successful in 42s
Full build / Full desktop build + optional release (push) Skipped

This commit is contained in:
zwitschi committed 2026-11-06 14:28:57 +01:00
1 parent c8875a3dfa
commit 3ab7577634
32 files changed
+356 -120

No files matched your search

+242 -64
View File
@@ -86,8 +86,9 @@ except ImportError: # pragma: no cover - audit/sheet/list run outside Blender
# pick one per avatar later. `tint` lists the slots the runtime should tint;
# every other material keeps its authored colour.
# `base` selects the CC0 Quaternius source the character is baked from
# (ADR-0017/ADR-0018, owner chose the full roster swap); `drop` removes upstream
# materials that do not belong to the role (the Worker's hivis vest and hard hat).
# (ADR-0017/ADR-0018, owner chose the full roster swap); `slots` overrides the
# source's material -> tint-slot map for that role (nothing is deleted: the
# modular Worker's vest is its torso and its hard hat is its crown).
# Rebuild one procedurally with `build --source procedural --head mh`.
CHARACTERS = {
"npc_casual": {"layer": "none", "hair": "short", "gear": ["backpack", "camera"],
@@ -101,6 +102,10 @@ CHARACTERS = {
"roles": ["influencer", "trespasser", "violent_threat"]},
"npc_vest": {"layer": "vest", "hair": "buzz", "gear": ["cap", "clipboard"],
"base": "worker",
# Delivery keeps the hi-vis stripe bands as authored; every
# other worker role tints them with the shirt.
"slots": {"Worker_Yellow": {"split": True, "z": 1.65,
"above": "hair", "below": "keep"}},
"roles": ["delivery"]},
"npc_dress": {"layer": "dress", "hair": "bun", "build": "slim", "base": "casual",
"gear": ["shoulder_bag"],
@@ -108,11 +113,11 @@ CHARACTERS = {
"npc_hoodie": {"layer": "hood", "hair": "short", "gear": ["cup"], "base": "hoodie",
"roles": ["trespasser", "mental_crisis", "violent_threat"]},
"npc_firefighter": {"layer": "responder", "hair": "buzz", "build": "broad",
"base": "worker", "drop": ["Worker_Vest", "Worker_Yellow"],
"base": "worker",
"gear": ["firefighter_helmet", "scba_tank", "flashlight"],
"roles": ["responder"]},
"npc_guard": {"layer": "guard", "hair": "buzz", "build": "broad",
"base": "worker", "drop": ["Worker_Vest", "Worker_Yellow"],
"base": "worker",
"gear": ["cap", "wand"],
"roles": ["player_guard", "venue_security"]},
# Crowd-variety prototypes (character development): the three Quaternius
@@ -125,7 +130,7 @@ CHARACTERS = {
"npc_crowd_c": {"layer": "coat", "hair": "short", "base": "hoodie",
"gear": ["umbrella"], "roles": ["intoxicated", "guest"]},
"npc_crowd_d": {"layer": "hood", "hair": "short", "build": "slim",
"base": "worker", "drop": ["Worker_Vest", "Worker_Yellow"],
"base": "worker",
"gear": ["cup"], "roles": ["tourist", "guest"]},
}
@@ -1491,72 +1496,234 @@ QUATERNIUS_SOURCES = {
"file": "Worker.glb",
"slots": {"Skin": "skin", "LightBrown": "cloth", "Brown": "pants",
"Brown2": "pants", "Grey": "accessory", "Black": "accessory",
# The Worker's head is bare apart from a hard hat, so the
# brows carry the hair tint and the slot keeps a surface.
# The modular Worker has no torso under the vest and no crown
# under the hard hat, so both must stay in the mesh: the vest
# becomes the role's shirt and only the hat/crown part of the
# yellow material stays, tinted as hair. The stripes below the
# collar follow the shirt.
"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.
"Eyebrows": "hair",
"Eye": "keep", "Moustache": "keep",
"Worker_Vest": "keep", "Worker_Yellow": "keep"},
"notes": "work shirt + trousers + hivis vest + hard hat",
"Brows": "keep",
"Eye": "keep", "Moustache": "keep"},
"donor_brows": True,
"notes": "work shirt (vest geometry) + trousers + hard hat/crown",
},
}
def _quaternius_slot_map(obj, slot_map, drop):
"""Rename the upstream materials onto the contract slots.
## 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)}
def _base_name(name):
"""Strip Blender's `.001` duplicate suffix."""
head, sep, tail = name.rpartition(".")
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.
doomed = []
for poly in obj.data.polygons:
material = obj.data.materials[poly.material_index]
if material is None or material.name != spec["material"]:
continue
if _face_centroid(obj, poly)[2] < 1.70:
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")
bm.to_mesh(obj.data)
bm.free()
donor_path = QUATERNIUS_SRC / spec["source"]
if not donor_path.is_file():
print(f" brows: {donor_path.name} missing, keeping the bar")
return
before = set(o.name for o in bpy.context.scene.objects)
bpy.ops.import_scene.gltf(filepath=str(donor_path))
added = [o for o in bpy.context.scene.objects if o.name not in before]
donor = None
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:
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]
if drop:
bmesh.ops.delete(bm, geom=drop, context="FACES")
loose = [v for v in bm.verts if not v.link_faces]
if loose:
bmesh.ops.delete(bm, geom=loose, context="VERTS")
if not bm.faces:
bm.free()
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"]
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")
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.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']}")
def _face_centroid(obj, poly):
co = [obj.data.vertices[v].co for v in poly.vertices]
n = max(len(co), 1)
return (sum(c.x for c in co) / n, sum(c.y for c in co) / n,
sum(c.z for c in co) / n)
def _face_centroid(obj, poly):
co = [obj.data.vertices[v].co for v in poly.vertices]
n = max(len(co), 1)
return (sum(c.x for c in co) / n, sum(c.y for c in co) / n,
sum(c.z for c in co) / n)
def _carve_quaternius_faces(obj, slot_map, by_name):
"""Delete the faces a rule marks as `carve`.
The modular Worker's `Eyebrows` material is one continuous bar across the
face, which reads as a unibrow, and no other upstream head has brows that fit
this head (the three heads are different meshes). Cutting the middle out of
the bar is the cheapest honest fix. This is the only deletion in the
pipeline: everything else is remapped, because the Worker's vest *is* its
torso and its hard hat *is* the crown of its skull.
"""
doomed = []
for poly in obj.data.polygons:
material = obj.data.materials[poly.material_index]
rule = slot_map.get(material.name) if material else None
if not isinstance(rule, dict) or "carve" not in rule:
continue
x, _y, z = _face_centroid(obj, poly)
if "z" in rule and z >= float(rule["z"]):
continue
if abs(x) < float(rule["carve"].get("abs_x_below", 0.0)):
doomed.append(poly.index)
if not doomed:
return
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" carved {len(doomed)} brow faces")
def _quaternius_slot_map(obj, slot_map):
"""Repoint the upstream materials onto the contract slots.
Nothing is deleted. The upstream Worker is modular: `Worker_Vest` *is* the
torso and `Worker_Yellow` above z 1.65 *is* the crown of the skull, so
dropping either leaves real holes in the character (that is what shipped in
the first 0.9.9 build). Instead every polygon is repointed at a slot, and a
rule can split one upstream material by height:
{"split": True, "z": 1.65, "above": "hair", "below": "keep"}
Several upstream materials can share a slot (trousers + patches), so the
polygons are repointed at one Blender material instead of relying on a
rename that Blender would de-duplicate to `pants.001`.
"""
materials = [m for m in obj.data.materials if m is not None]
dropped = {i for i, m in enumerate(materials) if m.name in drop}
kept = []
target = {}
remap = {}
for index, m in enumerate(materials):
slot = slot_map.get(m.name, "keep")
if slot == "keep":
if m.name not in target:
target[m.name] = len(kept)
kept.append(m)
remap[index] = target[m.name]
continue
if slot in target:
remap[index] = target[slot]
continue
m.name = slot
target[slot] = len(kept)
kept.append(m)
remap[index] = target[slot]
# Faces that only exist for a dropped material go away with it.
if dropped:
doomed = [p.index for p in obj.data.polygons
if p.material_index in dropped]
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")
bm.to_mesh(obj.data)
bm.free()
# Loose vertices left behind by the helmet/vest would export as an
# unreferenced blob, so drop them before the UV pass.
bm = bmesh.new()
bm.from_mesh(obj.data)
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()
# `materials.clear()` resets every polygon to slot 0, so record the source
# indices first or the whole mesh collapses onto the first slot.
source_index = [poly.material_index for poly in obj.data.polygons]
by_name = {m.name: m for m in materials}
# Read the source state before touching the material list: clearing it resets
# every polygon to slot 0.
source_names = []
centroids = []
for poly in obj.data.polygons:
src = materials[poly.material_index] if poly.material_index < len(materials) else None
source_names.append(src.name if src else "")
co = [obj.data.vertices[v].co for v in poly.vertices]
centroids.append(sum(c.z for c in co) / max(len(co), 1))
targets = []
def target_index(key, source):
if key in index_of_slot:
return index_of_slot[key]
if key[0] == "keep":
mat = source
else:
# A copy carries the authored colour into the slot, and keeps the
# source material untouched for the other slots that share it.
mat = source.copy()
mat.name = key[1]
index_of_slot[key] = len(targets)
targets.append(mat)
return index_of_slot[key]
index_of_slot = {}
resolved = []
for name, z in zip(source_names, centroids):
rule = slot_map.get(name, "keep")
if isinstance(rule, dict) and rule.get("split"):
slot = rule["above"] if z >= float(rule["z"]) else rule["below"]
key = ("keep", name) if slot == "keep" else ("slot", slot)
elif rule == "keep" or rule is None:
key = ("keep", name)
else:
key = ("slot", rule)
resolved.append(target_index(key, by_name[name]) if name in by_name else 0)
obj.data.materials.clear()
for m in kept:
for m in targets:
obj.data.materials.append(m)
for poly, index in zip(obj.data.polygons, source_index):
poly.material_index = remap.get(index, 0)
for poly, index in zip(obj.data.polygons, resolved):
poly.material_index = index
def _bake_quaternius_rest(arm, mesh):
@@ -1579,7 +1746,10 @@ def _bake_quaternius_rest(arm, mesh):
# Local X swings the arm in the frontal plane. This is only symmetric on
# *our* exported rest: the raw upstream file mirrors the bone rolls, so
# the build always round-trips through `_quaternius_tpose_glb()` first.
pose_bone.rotation_euler = (-math.pi / 2, 0, 0)
# 83 instead of 90 degrees leaves the arm 7 degrees out from vertical: on
# the narrow `casual` base a perfectly vertical arm intersects the hip
# and torso (owner report: dress, jacket, crowd_a).
pose_bone.rotation_euler = (math.radians(-83.0), 0, 0)
bpy.ops.object.mode_set(mode="OBJECT")
bpy.context.view_layer.update()
for side in ("L", "R"):
@@ -1655,15 +1825,19 @@ def _quaternius_work_dir():
return path
def _quaternius_tpose_glb(base, drop):
def _quaternius_tpose_glb(base, overrides):
"""Export (and cache) the joined, slot-mapped, unwrapped T-pose base.
The arm-down bake only behaves symmetrically on our own export, because the
upstream file mirrors the bone rolls; this round trip normalises them. The
result depends only on the source and the dropped materials, so it is cached
result depends only on the source and the slot overrides, so it is cached
for the run.
"""
key = base + ("-" + "-".join(sorted(drop)) if drop else "")
# The cache key has to cover the slot overrides: two roles can share a source
# and still need different slot maps (the delivery vest keeps its hi-vis).
key = base
if overrides:
key += "-" + "-".join(f"{k}:{v}" for k, v in sorted(overrides.items()))
path = _quaternius_work_dir() / f"tpose_{key}.glb"
if path.is_file():
return path
@@ -1696,7 +1870,11 @@ def _quaternius_tpose_glb(base, drop):
obj.name = "npc_base"
obj.data.name = "npc_base"
_quaternius_slot_map(obj, source["slots"], drop)
slots = dict(source["slots"])
slots.update(overrides)
if source.get("donor_brows"):
_transplant_brows(obj)
_quaternius_slot_map(obj, slots)
# Upstream UVs are degenerate (every loop at 0,0), so an AO bake would write
# an all-black map. Repack a real layout at a tight island margin (the
@@ -1749,7 +1927,7 @@ def _quaternius_tpose_glb(base, drop):
def build_quaternius_character(name):
"""Bake one roster character from its CC0 Quaternius source."""
spec = CHARACTERS[name]
tpose = _quaternius_tpose_glb(spec["base"], spec.get("drop", []))
tpose = _quaternius_tpose_glb(spec["base"], spec.get("slots", {}))
reset_scene()
bpy.ops.import_scene.gltf(filepath=str(tpose))
arm = next(o for o in bpy.context.scene.objects if o.type == "ARMATURE")