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

+9 -2
View File
@@ -887,14 +887,21 @@ gear) and 5 (head gear on the skull).
head, lifted 18 mm clear of the eye line (`WORKER_HEAD_DONOR`). head, lifted 18 mm clear of the eye line (`WORKER_HEAD_DONOR`).
- [x] Worker crown reads as a hard hat: the hoodie's hair is transplanted onto the Worker head and - [x] Worker crown reads as a hard hat: the hoodie's hair is transplanted onto the Worker head and
the hard-hat faces above the collar are cut away, so guard/responder/crowd_d have hair. the hard-hat faces above the collar are cut away, so guard/responder/crowd_d have hair.
- [x] Worker roles (vest, firefighter, guard, crowd_d) had **no visible eyes**: the Worker's own
quads sit inside the head. The hoodie's eye pair is now transplanted with a 4 mm forward
offset, so all four roles show two eyes and two brows (owner request).
- [x] Owner: every hat/head item moves up. `GEAR_OFFSETS` now lifts the cap, hood and beanie by
0.07 m on top of the shared head-gear lift; the firefighter helmet is deliberately unchanged.
- [x] Worker faces: the dark "unibrow" band turned out to be the **cap brim** sitting on the brow, - [x] Worker faces: the dark "unibrow" band turned out to be the **cap brim** sitting on the brow,
not head geometry. `HEAD_GEAR_LIFT` went 0.07 → 0.103 (the gear is authored around the head not head geometry. `HEAD_GEAR_LIFT` went 0.07 → 0.103 (the gear is authored around the head
*centre* at 1.62, the Quaternius head centre is 1.69) and `GEAR_SCALE` grows the cap/hood so *centre* at 1.62, the Quaternius head centre is 1.69) and `GEAR_SCALE` grows the cap/hood so
they clear the 0.228 m hair shell. The cap now reads as a cap and both eyes *and* brows show. they clear the 0.228 m hair shell. The cap now reads as a cap and both eyes *and* brows show.
- [x] Hair poking through head gear: fixed by the same lift + cap/hood scale; the firefighter - [x] Hair poking through head gear: fixed by the same lift + cap/hood scale; the firefighter
helmet dome covers the hair, and the cap reads as a cap rather than a visor. helmet dome covers the hair, and the cap reads as a cap rather than a visor.
- [ ] The Worker eye quads are still upstream-sized; they read as two dark rectangles rather than - [ ] The eyes are single-colour quads on every base; a light sclera + pupil texture on the `Eye`
eyes with whites. A light sclera + pupil texture on the `Eye` slot would finish the face. slot would finish the face at portrait distance.
- [ ] The raised caps now hover slightly above the hair shell with a visible gap; either shrink
the hair or fit the cap crown to it.
- [-] Arm/torso intersection on `npc_dress`/`npc_jacket`/`npc_crowd_a`/`npc_crowd_d`: the baked - [-] Arm/torso intersection on `npc_dress`/`npc_jacket`/`npc_crowd_a`/`npc_crowd_d`: the baked
rest now aims the upper arm 7° out from vertical. Verified clear on the `casual` base; a rest now aims the upper arm 7° out from vertical. Verified clear on the `casual` base; a
light clip remains on `worker`, so this needs either a wider angle or a per-base shoulder light clip remains on `worker`, so this needs either a wider angle or a per-base shoulder
+8 -8
View File
@@ -655,11 +655,11 @@
"skin", "skin",
"cloth", "cloth",
"accessory", "accessory",
"hair.001", "Eyebrows",
"Moustache", "Moustache",
"Eye",
"pants", "pants",
"Brows", "Brows",
"Eye.001",
"hair" "hair"
], ],
"mesh_node": "npc_base", "mesh_node": "npc_base",
@@ -738,11 +738,11 @@
"skin", "skin",
"cloth", "cloth",
"accessory", "accessory",
"hair.001", "Eyebrows",
"Moustache", "Moustache",
"Eye",
"pants", "pants",
"Brows", "Brows",
"Eye.001",
"hair" "hair"
], ],
"mesh_node": "npc_base", "mesh_node": "npc_base",
@@ -780,11 +780,11 @@
"skin", "skin",
"cloth", "cloth",
"accessory", "accessory",
"hair.001", "Eyebrows",
"Moustache", "Moustache",
"Eye",
"pants", "pants",
"Brows", "Brows",
"Eye.001",
"hair" "hair"
], ],
"mesh_node": "npc_base", "mesh_node": "npc_base",
@@ -908,11 +908,11 @@
"Worker_Yellow", "Worker_Yellow",
"cloth", "cloth",
"accessory", "accessory",
"hair.001", "Eyebrows",
"Moustache", "Moustache",
"Eye",
"pants", "pants",
"Brows", "Brows",
"Eye.001",
"hair" "hair"
], ],
"mesh_node": "npc_base", "mesh_node": "npc_base",
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 217 KiB

After

Width:  |  Height:  |  Size: 217 KiB

+5 -2
View File
@@ -181,8 +181,10 @@ const GEAR_OFFSETS := {
# The Quaternius head wears a 0.24 m hair shell that stands ~3.5 cm above the # The Quaternius head wears a 0.24 m hair shell that stands ~3.5 cm above the
# cap crown, so the cap sinks into the hair and only its brim shows. Lift the # cap crown, so the cap sinks into the hair and only its brim shows. Lift the
# cap and hood onto the hair; sunglasses stay at the eye line. # cap and hood onto the hair; sunglasses stay at the eye line.
"cap": Vector3(0, 0.05, 0), # Owner: every hat and head item moves up; the firefighter helmet keeps its fit.
"hood": Vector3(0, 0.03, 0), "cap": Vector3(0, 0.07, 0),
"hood": Vector3(0, 0.07, 0),
"beanie": Vector3(0, 0.07, 0),
} }
## The hat geometry is authored around the 0.315 m procedural head and scaled to ## The hat geometry is authored around the 0.315 m procedural head and scaled to
## 0.64 in the Blender build to match the 0.20 m Quaternius skull. That fits the ## 0.64 in the Blender build to match the 0.20 m Quaternius skull. That fits the
@@ -191,6 +193,7 @@ const GEAR_OFFSETS := {
const GEAR_SCALE := { const GEAR_SCALE := {
"cap": 1.22, "cap": 1.22,
"hood": 1.12, "hood": 1.12,
"beanie": 1.22,
} }
## Hand gear is authored around the wrist proxy. The palm sits ~4 cm in front ## Hand gear is authored around the wrist proxy. The palm sits ~4 cm in front
## of that origin, so without this offset the first phone/flashlight versions ## of that origin, so without this offset the first phone/flashlight versions
+57 -9
View File
@@ -1510,7 +1510,7 @@ QUATERNIUS_SOURCES = {
"above": "hair", "below": "cloth"}, "above": "hair", "below": "cloth"},
# The bar, the oversized eyes and the hard hat are replaced by # The bar, the oversized eyes and the hard hat are replaced by
# `_transplant_worker_head()`; those materials are gone by here. # `_transplant_worker_head()`; those materials are gone by here.
"Eyebrows": "hair", "Eyebrows": "keep",
"Brows": "keep", "Brows": "keep",
"Eye": "keep", "Moustache": "keep"}, "Eye": "keep", "Moustache": "keep"},
"donor_head": True, "donor_head": True,
@@ -1534,24 +1534,59 @@ QUATERNIUS_SOURCES = {
WORKER_HEAD_DONOR = { WORKER_HEAD_DONOR = {
"source": "Casual_Hoodie.glb", "source": "Casual_Hoodie.glb",
# donor material -> contract slot name. # 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 # 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 # 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 # 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. # 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 # 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 # 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 # 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). # 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 # The Worker's own eye quads sit inside the head and never show (the owner
# quads are the only thing filling them, so deleting them showed the inside of # reported the Worker roles as eyeless), so they are replaced by the hoodie's
# the head as a black band. They stay; the donor brows sit above them. # pair, which is the same geometry the other eight characters use.
"remove": {"Eyebrows": 1.715, "Worker_Yellow": 1.60}, "remove": {"Eyebrows": 1.715, "Worker_Yellow": 1.60, "Eye": None},
"bone": "Head", "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): def _base_name(name):
"""Strip Blender's `.001` duplicate suffix.""" """Strip Blender's `.001` duplicate suffix."""
head, sep, tail = name.rpartition(".") head, sep, tail = name.rpartition(".")
@@ -1588,6 +1623,8 @@ def _transplant_worker_head(obj):
bm.to_mesh(obj.data) bm.to_mesh(obj.data)
bm.free() bm.free()
print(f" removed {len(doomed)} Worker head faces") print(f" removed {len(doomed)} Worker head faces")
_purge_unused_materials(obj)
donor_path = QUATERNIUS_SRC / spec["source"] donor_path = QUATERNIUS_SRC / spec["source"]
if not donor_path.is_file(): if not donor_path.is_file():
print(f" head donor {donor_path.name} missing, Worker head left bare") print(f" head donor {donor_path.name} missing, Worker head left bare")
@@ -1632,7 +1669,7 @@ def _transplant_worker_head(obj):
kept.append(m) kept.append(m)
for slot_name, rule in spec.get("narrow", {}).items(): for slot_name, rule in spec.get("narrow", {}).items():
local = [i for i, m in enumerate(candidate.data.materials) 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: if not local:
continue continue
bm = bmesh.new() bm = bmesh.new()
@@ -1649,7 +1686,7 @@ def _transplant_worker_head(obj):
for slot_name, offset in spec.get("offset", {}).items(): for slot_name, offset in spec.get("offset", {}).items():
local = [i for i, m in enumerate(candidate.data.materials) 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: if not local:
continue continue
bm = bmesh.new() bm = bmesh.new()
@@ -1684,6 +1721,17 @@ def _transplant_worker_head(obj):
donor.select_set(True) donor.select_set(True)
bpy.context.view_layer.objects.active = obj bpy.context.view_layer.objects.active = obj
bpy.ops.object.join() 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 = 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") 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()))} " print(f" transplanted {', '.join(sorted(spec['parts'].values()))} "