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 - [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 → the crown of the skull. Fixed by keeping it and splitting the material at z 1.65 (crown →
`hair`, vest stripes → `cloth`). `hair`, vest stripes → `cloth`).
- [x] Unibrow on `npc_guard`/`npc_vest`/`npc_crowd_d`: the Worker `Eyebrows` bar is cut out and - [x] Unibrow on `npc_guard`/`npc_vest`/`npc_crowd_d`: the Worker `Eyebrows` bar *and* its
the hoodie's two brows are transplanted onto the Worker head (`DONOR_BROWS`). 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 - [-] 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
@@ -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). - [ ] 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 - [ ] 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. 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 - [x] Carried role props floated beside the body: `_add_props()` placed the performer hat/board
`npc_dress` performer sign are oversized, and the sign floats beside the body because and the protester pole/sign at fixed rig offsets. They now ride the hand proxies
`_add_props()` places it at a fixed rig offset instead of on the hand proxy. (`_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 - [-] 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, 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 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": { "bounds_godot": {
"max": [ "max": [
0.262, 0.262,
1.863, 1.866,
0.287 0.287
], ],
"min": [ "min": [
-0.262, -0.262,
-0.004, -0.004,
-0.103 -0.087
] ]
}, },
"category": "npc", "category": "npc",
@@ -655,11 +655,10 @@
"skin", "skin",
"cloth", "cloth",
"accessory", "accessory",
"hair",
"Moustache", "Moustache",
"Eye",
"pants", "pants",
"Brows" "Brows",
"hair"
], ],
"mesh_node": "npc_base", "mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_crowd_d.glb", "path": "assets/models/blender/npc/npc_crowd_d.glb",
@@ -674,8 +673,8 @@
"accessory", "accessory",
"hair" "hair"
], ],
"triangles": 5258, "triangles": 6176,
"vertices": 10980 "vertices": 13074
}, },
"npc_dress": { "npc_dress": {
"base": "casual", "base": "casual",
@@ -722,13 +721,13 @@
"bounds_godot": { "bounds_godot": {
"max": [ "max": [
0.262, 0.262,
1.863, 1.866,
0.287 0.287
], ],
"min": [ "min": [
-0.262, -0.262,
-0.004, -0.004,
-0.103 -0.087
] ]
}, },
"category": "npc", "category": "npc",
@@ -737,11 +736,10 @@
"skin", "skin",
"cloth", "cloth",
"accessory", "accessory",
"hair",
"Moustache", "Moustache",
"Eye",
"pants", "pants",
"Brows" "Brows",
"hair"
], ],
"mesh_node": "npc_base", "mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_firefighter.glb", "path": "assets/models/blender/npc/npc_firefighter.glb",
@@ -755,21 +753,21 @@
"accessory", "accessory",
"hair" "hair"
], ],
"triangles": 5258, "triangles": 6176,
"vertices": 10980 "vertices": 13074
}, },
"npc_guard": { "npc_guard": {
"base": "worker", "base": "worker",
"bounds_godot": { "bounds_godot": {
"max": [ "max": [
0.262, 0.262,
1.863, 1.866,
0.287 0.287
], ],
"min": [ "min": [
-0.262, -0.262,
-0.004, -0.004,
-0.103 -0.087
] ]
}, },
"category": "npc", "category": "npc",
@@ -778,11 +776,10 @@
"skin", "skin",
"cloth", "cloth",
"accessory", "accessory",
"hair",
"Moustache", "Moustache",
"Eye",
"pants", "pants",
"Brows" "Brows",
"hair"
], ],
"mesh_node": "npc_base", "mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_guard.glb", "path": "assets/models/blender/npc/npc_guard.glb",
@@ -797,8 +794,8 @@
"accessory", "accessory",
"hair" "hair"
], ],
"triangles": 5258, "triangles": 6176,
"vertices": 10980 "vertices": 13074
}, },
"npc_hoodie": { "npc_hoodie": {
"base": "hoodie", "base": "hoodie",
@@ -889,13 +886,13 @@
"bounds_godot": { "bounds_godot": {
"max": [ "max": [
0.262, 0.262,
1.863, 1.866,
0.287 0.287
], ],
"min": [ "min": [
-0.262, -0.262,
-0.004, -0.004,
-0.103 -0.087
] ]
}, },
"category": "npc", "category": "npc",
@@ -905,11 +902,10 @@
"Worker_Yellow", "Worker_Yellow",
"cloth", "cloth",
"accessory", "accessory",
"hair",
"Moustache", "Moustache",
"Eye",
"pants", "pants",
"Brows" "Brows",
"hair"
], ],
"mesh_node": "npc_base", "mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_vest.glb", "path": "assets/models/blender/npc/npc_vest.glb",
@@ -923,8 +919,8 @@
"accessory", "accessory",
"hair" "hair"
], ],
"triangles": 5258, "triangles": 6176,
"vertices": 10980 "vertices": 13074
}, },
"palm_tree": { "palm_tree": {
"bounds_godot": { "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_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 | | `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 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. 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: 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)) _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: 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 hat := _material(Color(0.85, 0.2, 0.25))
var cone := MeshInstance3D.new() var cone := MeshInstance3D.new()
var cm := CylinderMesh.new() var cm := CylinderMesh.new()
cm.top_radius = 0.02 cm.top_radius = 0.012
cm.bottom_radius = 0.22 cm.bottom_radius = 0.125
cm.height = 0.4 cm.height = 0.24
cone.mesh = cm cone.mesh = cm
cone.material_override = hat cone.material_override = hat
cone.position = Vector3(0, 0.26, 0) cone.position = Vector3(0, 0.15, 0)
_head.add_child(cone) _head.add_child(cone)
# Hat in one hand and a small board in the other: the performer plays # Hat in one hand and a small board in the other: the performer plays
# to the sidewalk once at the waypoint. # to the sidewalk once at the waypoint. Both ride the hand proxies, so
_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)) # they follow the arm instead of floating beside the body at a fixed
_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)) # 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 _sign_prop_base_y = _sign_prop.position.y
Rules.NpcType.PROTESTER: 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)) # Pole and board ride the carrying hand for the same reason.
_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)) _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 _sign_prop_base_y = _sign_prop.position.y
Rules.NpcType.TOURIST: 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)) _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: 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)) _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: func _add_box_prop(parent: Node3D, size: Vector3, pos: Vector3, color: Color) -> MeshInstance3D:
var mi := MeshInstance3D.new() var mi := MeshInstance3D.new()
var mesh := BoxMesh.new() var mesh := BoxMesh.new()
+99 -42
View File
@@ -1504,32 +1504,41 @@ QUATERNIUS_SOURCES = {
"Worker_Vest": "cloth", "Worker_Vest": "cloth",
"Worker_Yellow": {"split": True, "z": 1.65, "Worker_Yellow": {"split": True, "z": 1.65,
"above": "hair", "below": "cloth"}, "above": "hair", "below": "cloth"},
# The bar is deleted in `_transplant_brows()` and replaced by # The bar, the oversized eyes and the hard hat are replaced by
# the hoodie's two brows; what remains of this material is the # `_transplant_worker_head()`; those materials are gone by here.
# dark shell over the crown, which follows the hair.
"Eyebrows": "hair", "Eyebrows": "hair",
"Brows": "keep", "Brows": "keep",
"Eye": "keep", "Moustache": "keep"}, "Eye": "keep", "Moustache": "keep"},
"donor_brows": True, "donor_head": True,
"notes": "work shirt (vest geometry) + trousers + hard hat/crown", "notes": "work shirt (vest geometry) + trousers + hard hat/crown",
}, },
} }
## The modular Worker's `Eyebrows` material is one continuous bar across the ## The modular Worker head has three defects the roster cannot live with, and no
## face (the owner reads it as a unibrow) and no other upstream head has brows ## other upstream head has parts that fit it directly, because the three heads are
## that fit this head, because the three heads are different meshes. `casual` is ## different meshes at different detail levels. `casual_hoodie` is the closest
## the closest sibling, so its two brows are transplanted onto the Worker head ## sibling, so its brows, eyes and hair 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 ## - `Eyebrows` is one continuous bar across the face (reads as a unibrow);
## crown of its skull. ## - `Eye` is two oversized quads that nearly touch (the same dark mass);
DONOR_BROWS = {"source": "Casual_Hoodie.glb", "material": "Eyebrows", ## - `Worker_Yellow` above the collar is a hard hat that doubles as the skull
"slot_name": "Brows", "bone": "Head", ## crown, so a guard or responder looks like it is wearing a helmet to bed.
# 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 ## This is the only place the build removes geometry. Everything else is
# eyes and read as one dark mass. Lift them above the eyes and sink ## remapped, because the Worker's vest *is* its torso and its hat *is* its crown.
# them into the brow ridge. Blender z = Godot y. WORKER_HEAD_DONOR = {
"offset": (0.0, 0.0, 0.018)} "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): def _base_name(name):
@@ -1538,28 +1547,49 @@ def _base_name(name):
return head if sep and tail.isdigit() else name return head if sep and tail.isdigit() else name
def _transplant_brows(obj): def _transplant_worker_head(obj):
"""Replace the Worker eyebrow bar with the two brows from the hoodie head.""" """Replace the Worker brow bar, eyes and hard hat with the hoodie's head gear."""
spec = DONOR_BROWS spec = WORKER_HEAD_DONOR
# Cut our own bar: every `Eyebrows` face below the crown shell. # 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 = [] doomed = []
for poly in obj.data.polygons: for poly in obj.data.polygons:
material = obj.data.materials[poly.material_index] 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 continue
if _face_centroid(obj, poly)[2] < 1.70: limit = spec["remove"].get(material.name)
doomed.append(poly.index) if limit is not None and _face_centroid(obj, poly)[2] <= float(limit):
continue
doomed.append(poly.index)
if doomed: if doomed:
bm = bmesh.new() bm = bmesh.new()
bm.from_mesh(obj.data) bm.from_mesh(obj.data)
bm.faces.ensure_lookup_table() bm.faces.ensure_lookup_table()
bmesh.ops.delete(bm, geom=[bm.faces[i] for i in doomed], context="FACES") 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.to_mesh(obj.data)
bm.free() 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"] donor_path = QUATERNIUS_SRC / spec["source"]
if not donor_path.is_file(): 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 return
before = set(o.name for o in bpy.context.scene.objects) before = set(o.name for o in bpy.context.scene.objects)
bpy.ops.import_scene.gltf(filepath=str(donor_path)) bpy.ops.import_scene.gltf(filepath=str(donor_path))
@@ -1568,16 +1598,14 @@ def _transplant_brows(obj):
for candidate in added: for candidate in added:
if candidate.type != "MESH": if candidate.type != "MESH":
continue continue
# A second import of the same pack reuses the material names, so Blender wanted = [i for i, m in enumerate(candidate.data.materials)
# suffixes them (`Eyebrows.001`); match the base name. if m is not None and _base_name(m.name) in spec["parts"]]
keep = [i for i, m in enumerate(candidate.data.materials) if not wanted:
if m is not None and _base_name(m.name) == spec["material"]]
if not keep:
continue continue
bm = bmesh.new() bm = bmesh.new()
bm.from_mesh(candidate.data) bm.from_mesh(candidate.data)
bm.faces.ensure_lookup_table() 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: if drop:
bmesh.ops.delete(bm, geom=drop, context="FACES") bmesh.ops.delete(bm, geom=drop, context="FACES")
loose = [v for v in bm.verts if not v.link_faces] loose = [v for v in bm.verts if not v.link_faces]
@@ -1588,30 +1616,59 @@ def _transplant_brows(obj):
continue continue
bm.to_mesh(candidate.data) bm.to_mesh(candidate.data)
bm.free() bm.free()
for m in candidate.data.materials: # Collect the kept materials, then remap the faces: clearing the material
if m is not None and _base_name(m.name) == spec["material"]: # list resets every polygon to slot 0.
m.name = spec["slot_name"] 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 donor = candidate
break break
for extra in added: for extra in added:
if extra is not donor: if extra is not donor:
bpy.data.objects.remove(extra, do_unlink=True) bpy.data.objects.remove(extra, do_unlink=True)
if donor is None: if donor is None:
print(" brows: donor head not found, keeping the bar") print(" head donor not found, Worker head left bare")
return return
donor.parent = None donor.parent = None
donor.matrix_world = Matrix.Identity(4) 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: for o in bpy.context.selected_objects:
o.select_set(False) o.select_set(False)
obj.select_set(True) obj.select_set(True)
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 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") 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): def _face_centroid(obj, poly):
@@ -1872,8 +1929,8 @@ def _quaternius_tpose_glb(base, overrides):
slots = dict(source["slots"]) slots = dict(source["slots"])
slots.update(overrides) slots.update(overrides)
if source.get("donor_brows"): if source.get("donor_head"):
_transplant_brows(obj) _transplant_worker_head(obj)
_quaternius_slot_map(obj, slots) _quaternius_slot_map(obj, slots)
# Upstream UVs are degenerate (every loop at 0,0), so an AO bake would write # Upstream UVs are degenerate (every loop at 0,0), so an AO bake would write