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

+16 -5
View File
@@ -882,8 +882,16 @@ gear) and 5 (head gear on the skull).
- [x] Worker crown hole on `npc_firefighter`/`npc_crowd_d`: the deleted `Worker_Yellow` hat was
the crown of the skull. Fixed by keeping it and splitting the material at z 1.65 (crown →
`hair`, vest stripes → `cloth`).
- [x] Unibrow on `npc_guard`/`npc_vest`/`npc_crowd_d`: the Worker `Eyebrows` bar is cut out and
the hoodie's two brows are transplanted onto the Worker head (`DONOR_BROWS`).
- [x] Unibrow on `npc_guard`/`npc_vest`/`npc_crowd_d`: the Worker `Eyebrows` bar *and* its
oversized eye quads are cut out, and the hoodie's two brows are transplanted onto the Worker
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
the hard-hat faces above the collar are cut away, so guard/responder/crowd_d have hair.
- [ ] Worker roles now have **no eyes** (the eye quads were the dark bar that read as a unibrow).
Add the `casual` base's smaller eye quads, or a light sclera + pupil, so the face is not
brow-and-moustache only.
- [ ] Hair pokes through the `firefighter_helmet` dome, which now sits under a taller hair shell;
re-fit the helmet like the head gear below.
- [-] 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
light clip remains on `worker`, so this needs either a wider angle or a per-base shoulder
@@ -893,9 +901,12 @@ gear) and 5 (head gear on the skull).
- [ ] Eyes and brows still read as one heavy dark mass at portrait distance (checklist row 3).
- [ ] Head gear still does not sit on the skull convincingly (row 5): the firefighter helmet brim
is 1.55× the head width and the cap sits high.
- [ ] Hand-held gear is still authored for the old hand (row 4): the `npc_vest` clipboard and the
`npc_dress` performer sign are oversized, and the sign floats beside the body because
`_add_props()` places it at a fixed rig offset instead of on the hand proxy.
- [x] Carried role props floated beside the body: `_add_props()` placed the performer hat/board
and the protester pole/sign at fixed rig offsets. They now ride the hand proxies
(`_hand_for()`), and the performer cone was resized from a 0.44 m base to 0.25 m so it sits
on the 0.20 m head instead of swallowing it.
- [ ] Hand-held gear is still authored for the old hand (row 4): the `npc_vest` clipboard is
oversized on the shorter Quaternius arm.
- [-] Build one asset family per commit, AO-baked and re-imported; never run a no-op full-library
rebuild. Use the concept + vision-judge loop for head/outfit decisions and record model,
prompt and spend. (Roster family landed in one commit because the three sources share one
+24 -28
View File
@@ -640,13 +640,13 @@
"bounds_godot": {
"max": [
0.262,
1.863,
1.866,
0.287
],
"min": [
-0.262,
-0.004,
-0.103
-0.087
]
},
"category": "npc",
@@ -655,11 +655,10 @@
"skin",
"cloth",
"accessory",
"hair",
"Moustache",
"Eye",
"pants",
"Brows"
"Brows",
"hair"
],
"mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_crowd_d.glb",
@@ -674,8 +673,8 @@
"accessory",
"hair"
],
"triangles": 5258,
"vertices": 10980
"triangles": 6176,
"vertices": 13074
},
"npc_dress": {
"base": "casual",
@@ -722,13 +721,13 @@
"bounds_godot": {
"max": [
0.262,
1.863,
1.866,
0.287
],
"min": [
-0.262,
-0.004,
-0.103
-0.087
]
},
"category": "npc",
@@ -737,11 +736,10 @@
"skin",
"cloth",
"accessory",
"hair",
"Moustache",
"Eye",
"pants",
"Brows"
"Brows",
"hair"
],
"mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_firefighter.glb",
@@ -755,21 +753,21 @@
"accessory",
"hair"
],
"triangles": 5258,
"vertices": 10980
"triangles": 6176,
"vertices": 13074
},
"npc_guard": {
"base": "worker",
"bounds_godot": {
"max": [
0.262,
1.863,
1.866,
0.287
],
"min": [
-0.262,
-0.004,
-0.103
-0.087
]
},
"category": "npc",
@@ -778,11 +776,10 @@
"skin",
"cloth",
"accessory",
"hair",
"Moustache",
"Eye",
"pants",
"Brows"
"Brows",
"hair"
],
"mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_guard.glb",
@@ -797,8 +794,8 @@
"accessory",
"hair"
],
"triangles": 5258,
"vertices": 10980
"triangles": 6176,
"vertices": 13074
},
"npc_hoodie": {
"base": "hoodie",
@@ -889,13 +886,13 @@
"bounds_godot": {
"max": [
0.262,
1.863,
1.866,
0.287
],
"min": [
-0.262,
-0.004,
-0.103
-0.087
]
},
"category": "npc",
@@ -905,11 +902,10 @@
"Worker_Yellow",
"cloth",
"accessory",
"hair",
"Moustache",
"Eye",
"pants",
"Brows"
"Brows",
"hair"
],
"mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_vest.glb",
@@ -923,8 +919,8 @@
"accessory",
"hair"
],
"triangles": 5258,
"vertices": 10980
"triangles": 6176,
"vertices": 13074
},
"palm_tree": {
"bounds_godot": {
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

After

Width:  |  Height:  |  Size: 154 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: 211 KiB

After

Width:  |  Height:  |  Size: 214 KiB

+9
View File
@@ -68,6 +68,15 @@ not the art, and all three are addressed in the second pass (2026-11-06):
| `npc_guard`, `npc_vest`, `npc_crowd_d` have one big unibrow | the Worker's `Eyebrows` material is one continuous bar, and no other upstream head has brows that fit this mesh | **replaced** — the bar is cut out and the hoodie's two brows are transplanted onto the Worker head, lifted above the eye quads |
| `npc_dress`, `npc_jacket`, `npc_crowd_a`, `npc_crowd_d` arms intersect the body | the baked rest aimed the upper arm straight down, which works on the wide bases but not the narrow ones | **reduced** — the arm now rests 7° out from vertical (mesh width 0.180 → 0.220 on `casual`, 0.228 → 0.262 on `hoodie`); a light clip remains on the `worker` bases |
Follow-up pass (same day) went further on the Worker head, which still read as a hard hat under
the bare tint:
| Finding | Fix |
| --- | --- |
| crown still shaped like a hard hat | the hoodie's **hair** is transplanted onto the Worker head and the hat faces above the collar are cut away, so guard/responder/crowd_d have hair |
| the dark bar was the Worker's *eye* quads, not only the brow bar | the eye quads are removed too; the face now reads brows + moustache |
| performer cone swallowed the head, and the performer/protester props floated beside the body | `_add_props()` now parents carried props to the hand proxies and the cone shrank from a 0.44 m to a 0.25 m base |
Re-verified after the fix with the sheets below and the vision judge: no holes or missing geometry
on any of the three Worker-based roles.
+31 -9
View File
@@ -962,24 +962,33 @@ func _add_props(npc_type: int) -> void:
Rules.NpcType.DELIVERY:
_add_box_prop(_rig, Vector3(0.34, 0.26, 0.28), Vector3(0.0, 1.02, 0.3), Color(0.55, 0.4, 0.25))
Rules.NpcType.PERFORMER:
# The cone is sized to the shipped head (0.20 m wide) and sits on the
# crown; at the old 0.44 m base it swallowed the whole head.
var hat := _material(Color(0.85, 0.2, 0.25))
var cone := MeshInstance3D.new()
var cm := CylinderMesh.new()
cm.top_radius = 0.02
cm.bottom_radius = 0.22
cm.height = 0.4
cm.top_radius = 0.012
cm.bottom_radius = 0.125
cm.height = 0.24
cone.mesh = cm
cone.material_override = hat
cone.position = Vector3(0, 0.26, 0)
cone.position = Vector3(0, 0.15, 0)
_head.add_child(cone)
# Hat in one hand and a small board in the other: the performer plays
# to the sidewalk once at the waypoint.
_add_box_prop(_rig, Vector3(0.06, 0.5, 0.06), Vector3(-0.42, 1.3, 0.14), Color(0.45, 0.35, 0.25))
_sign_prop = _add_box_prop(_rig, Vector3(0.42, 0.32, 0.03), Vector3(-0.46, 1.6, 0.14), Color(0.95, 0.9, 0.75))
# to the sidewalk once at the waypoint. Both ride the hand proxies, so
# they follow the arm instead of floating beside the body at a fixed
# rig offset (which is what the old -0.46 m placement did).
_add_box_prop(_hand_for(0), Vector3(0.05, 0.34, 0.05),
HAND_PROP_OFFSET, Color(0.45, 0.35, 0.25))
_sign_prop = _add_box_prop(_hand_for(1), Vector3(0.30, 0.24, 0.025),
HAND_PROP_OFFSET, Color(0.95, 0.9, 0.75))
_sign_prop_base_y = _sign_prop.position.y
Rules.NpcType.PROTESTER:
_add_box_prop(_rig, Vector3(0.05, 1.2, 0.05), Vector3(0.28, 1.45, 0.18), Color(0.4, 0.36, 0.32))
_sign_prop = _add_box_prop(_rig, Vector3(0.5, 0.42, 0.04), Vector3(0.48, 1.55, 0.18), Color(0.93, 0.93, 0.95))
# Pole and board ride the carrying hand for the same reason.
_add_box_prop(_hand_for(1), Vector3(0.045, 0.95, 0.045),
HAND_PROP_OFFSET, Color(0.4, 0.36, 0.32))
_sign_prop = _add_box_prop(_hand_for(1), Vector3(0.42, 0.34, 0.035),
HAND_PROP_OFFSET + Vector3(0, 0.5, 0), Color(0.93, 0.93, 0.95))
_sign_prop_base_y = _sign_prop.position.y
Rules.NpcType.TOURIST:
_photo_prop = _add_box_prop(_rig, Vector3(0.3, 0.36, 0.14), Vector3(-0.34, 1.15, -0.18), Color(0.25, 0.3, 0.45))
@@ -989,6 +998,19 @@ func _add_props(npc_type: int) -> void:
Rules.NpcType.CONFUSED_VISITOR:
_add_box_prop(_rig, Vector3(0.3, 0.05, 0.34), Vector3(0, 0.92, 0.06), Color(0.9, 0.88, 0.8))
## Carried role props ride the hand proxy so they follow the arm. The offset is
## in the hand's local space: the grip sits ~4 cm in front of the wrist origin,
## the same convention the Blender hand gear uses (`HAND_ITEM_FORWARD`).
const HAND_PROP_OFFSET := Vector3(0, -0.02, 0.05)
## Hand proxy for a side (0 = the avatar's left, 1 = its right), falling back to
## the rig when the fallback body has no proxies.
func _hand_for(side: int) -> Node3D:
if side < _hands.size() and _hands[side] != null:
return _hands[side]
return _rig
func _add_box_prop(parent: Node3D, size: Vector3, pos: Vector3, color: Color) -> MeshInstance3D:
var mi := MeshInstance3D.new()
var mesh := BoxMesh.new()
+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