Fix the worker torso/crown holes, transplant proper brows, widen the rest-arm clearance
This commit is contained in:
1 parent
c8875a3dfa
commit
3ab7577634
32 files changed
+356
-120
No files matched your search
+242
-64
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user