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

+26
View File
@@ -870,6 +870,32 @@ regressing gear fit, budget or readability.
upstream hivis vest and hard hat. All twelve built, AO-baked and committed (`a6ccd86`).
- [ ] Add the modular part library (hair styles, outfits) so the roster regains the silhouette
variety the twelve procedural meshes had; three shared meshes is the current ceiling.
### Owner review findings (2026-11-06)
Review page: [`docs/npc-visual-review.md`](docs/npc-visual-review.md). The owner signed the roster
off **with follow-ups** and rejected checklist rows 3 (faces at portrait), 4 (hands and hand-held
gear) and 5 (head gear on the skull).
- [x] Worker torso holes on `npc_guard`: the build deleted `Worker_Vest`, which on the modular
Worker *is* the torso. Fixed by mapping it to `cloth` instead of deleting it.
- [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`).
- [-] 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
offset.
- [ ] Worker crown still reads as a hard hat: the shape is upstream geometry, so it needs the
modular hair/cap library (or a carved brim) instead of a tint.
- [ ] 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.
- [-] 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
+50 -47
View File
@@ -431,12 +431,12 @@
"base": "hoodie",
"bounds_godot": {
"max": [
0.228,
0.262,
1.866,
0.287
],
"min": [
-0.228,
-0.262,
-0.004,
-0.091
]
@@ -474,12 +474,12 @@
"base": "hoodie",
"bounds_godot": {
"max": [
0.228,
0.262,
1.866,
0.287
],
"min": [
-0.228,
-0.262,
-0.004,
-0.091
]
@@ -516,12 +516,12 @@
"base": "casual",
"bounds_godot": {
"max": [
0.18,
0.22,
1.843,
0.271
],
"min": [
-0.18,
-0.22,
-0.009,
-0.099
]
@@ -551,18 +551,18 @@
"hair"
],
"triangles": 6424,
"vertices": 13440
"vertices": 13439
},
"npc_crowd_b": {
"base": "casual",
"bounds_godot": {
"max": [
0.18,
0.22,
1.843,
0.271
],
"min": [
-0.18,
-0.22,
-0.009,
-0.099
]
@@ -592,18 +592,18 @@
"hair"
],
"triangles": 6424,
"vertices": 13440
"vertices": 13439
},
"npc_crowd_c": {
"base": "hoodie",
"bounds_godot": {
"max": [
0.228,
0.262,
1.866,
0.287
],
"min": [
-0.228,
-0.262,
-0.004,
-0.091
]
@@ -639,14 +639,14 @@
"base": "worker",
"bounds_godot": {
"max": [
0.217,
1.826,
0.262,
1.863,
0.287
],
"min": [
-0.217,
-0.262,
-0.004,
-0.065
-0.103
]
},
"category": "npc",
@@ -658,7 +658,8 @@
"hair",
"Moustache",
"Eye",
"pants"
"pants",
"Brows"
],
"mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_crowd_d.glb",
@@ -673,19 +674,19 @@
"accessory",
"hair"
],
"triangles": 4458,
"vertices": 9370
"triangles": 5258,
"vertices": 10980
},
"npc_dress": {
"base": "casual",
"bounds_godot": {
"max": [
0.18,
0.22,
1.843,
0.271
],
"min": [
-0.18,
-0.22,
-0.009,
-0.099
]
@@ -714,20 +715,20 @@
"hair"
],
"triangles": 6424,
"vertices": 13440
"vertices": 13439
},
"npc_firefighter": {
"base": "worker",
"bounds_godot": {
"max": [
0.217,
1.826,
0.262,
1.863,
0.287
],
"min": [
-0.217,
-0.262,
-0.004,
-0.065
-0.103
]
},
"category": "npc",
@@ -739,7 +740,8 @@
"hair",
"Moustache",
"Eye",
"pants"
"pants",
"Brows"
],
"mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_firefighter.glb",
@@ -753,21 +755,21 @@
"accessory",
"hair"
],
"triangles": 4458,
"vertices": 9370
"triangles": 5258,
"vertices": 10980
},
"npc_guard": {
"base": "worker",
"bounds_godot": {
"max": [
0.217,
1.826,
0.262,
1.863,
0.287
],
"min": [
-0.217,
-0.262,
-0.004,
-0.065
-0.103
]
},
"category": "npc",
@@ -779,7 +781,8 @@
"hair",
"Moustache",
"Eye",
"pants"
"pants",
"Brows"
],
"mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_guard.glb",
@@ -794,19 +797,19 @@
"accessory",
"hair"
],
"triangles": 4458,
"vertices": 9370
"triangles": 5258,
"vertices": 10980
},
"npc_hoodie": {
"base": "hoodie",
"bounds_godot": {
"max": [
0.228,
0.262,
1.866,
0.287
],
"min": [
-0.228,
-0.262,
-0.004,
-0.091
]
@@ -843,12 +846,12 @@
"base": "casual",
"bounds_godot": {
"max": [
0.18,
0.22,
1.843,
0.271
],
"min": [
-0.18,
-0.22,
-0.009,
-0.099
]
@@ -879,18 +882,18 @@
"hair"
],
"triangles": 6424,
"vertices": 13440
"vertices": 13439
},
"npc_vest": {
"base": "worker",
"bounds_godot": {
"max": [
0.217,
0.262,
1.863,
0.287
],
"min": [
-0.217,
-0.262,
-0.004,
-0.103
]
@@ -900,13 +903,13 @@
"materials": [
"skin",
"Worker_Yellow",
"Worker_Vest",
"cloth",
"accessory",
"hair",
"Moustache",
"Eye",
"pants"
"pants",
"Brows"
],
"mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_vest.glb",
@@ -920,8 +923,8 @@
"accessory",
"hair"
],
"triangles": 5240,
"vertices": 10936
"triangles": 5258,
"vertices": 10980
},
"palm_tree": {
"bounds_godot": {
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 70 KiB

+4
View File
@@ -18,6 +18,10 @@ capture harnesses.
| [![sheet_npc_baseline_compare-tex256.jpg](sheet_npc_baseline_compare-tex256.jpg)](sheet_npc_baseline_compare-tex256.jpg) <br> `sheet_npc_baseline_compare-tex256.jpg` | 128 -> 256 px NPC slot maps on the same camera. |
| [![sheet_npc_baseline_compare-texuv.jpg](sheet_npc_baseline_compare-texuv.jpg)](sheet_npc_baseline_compare-texuv.jpg) <br> `sheet_npc_baseline_compare-texuv.jpg` | 4x fabric weave with the baked per-character AO removed. |
| [![sheet_npc_baseline_compare-torso.jpg](sheet_npc_baseline_compare-torso.jpg)](sheet_npc_baseline_compare-torso.jpg) <br> `sheet_npc_baseline_compare-torso.jpg` | Torso/hip taper without changing the skeleton or build multipliers. |
| [![sheet_npc_roster_0.9.9.jpg](sheet_npc_roster_0.9.9.jpg)](sheet_npc_roster_0.9.9.jpg) <br> `sheet_npc_roster_0.9.9.jpg` | 0.9.9 shipped roster: all twelve roles on the CC0 Quaternius bases, full body. |
| [![sheet_npc_roster_views_0.9.9.jpg](sheet_npc_roster_views_0.9.9.jpg)](sheet_npc_roster_views_0.9.9.jpg) <br> `sheet_npc_roster_views_0.9.9.jpg` | 0.9.9 roster: front and profile per role, for silhouette and gear placement. |
| [![sheet_npc_roster_poses_0.9.9.jpg](sheet_npc_roster_poses_0.9.9.jpg)](sheet_npc_roster_poses_0.9.9.jpg) <br> `sheet_npc_roster_poses_0.9.9.jpg` | 0.9.9 roster: idle, seated and walk-contact poses on six representative roles. |
| [![sheet_npc_roster_crowd_0.9.9.jpg](sheet_npc_roster_crowd_0.9.9.jpg)](sheet_npc_roster_crowd_0.9.9.jpg) <br> `sheet_npc_roster_crowd_0.9.9.jpg` | 0.9.9 roster at the 8 m crowd distance a player actually sees. |
| [![sheet_npc_crowd_prototypes.jpg](sheet_npc_crowd_prototypes.jpg)](sheet_npc_crowd_prototypes.jpg) <br> `sheet_npc_crowd_prototypes.jpg` | Four approved crowd-variety looks queued for the 12-character roster. |
| [![sheet_npc_gear.jpg](sheet_npc_gear.jpg)](sheet_npc_gear.jpg) <br> `sheet_npc_gear.jpg` | Eighteen gear pieces authored in attach space: caps, hood, helmet, bags, tanks, lanyards and handheld items. |
| [![sheet_npc_hands_compare.jpg](sheet_npc_hands_compare.jpg)](sheet_npc_hands_compare.jpg) <br> `sheet_npc_hands_compare.jpg` | Hand pass: wider palms, segmented fingers and a separate thumb. |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

After

Width:  |  Height:  |  Size: 211 KiB

+34 -9
View File
@@ -45,19 +45,44 @@ straight into `TODO.md`.
| # | Question | Verdict |
| --- | --- | --- |
| 1 | Do the twelve characters read as distinct people at 8 m? | ☐ yes ☐ no |
| 2 | Is the head size right for the body (no bobblehead, no pinhead)? | ☐ yes ☐ no |
| 3 | Do the faces hold up at portrait distance? | ☐ yes ☐ no |
| 4 | Do the hands and the hand-held gear (camera, cup, clipboard, sign) read correctly? | ☐ yes ☐ no |
| 5 | Does the head gear (cap, hood, sunglasses, firefighter helmet) sit on the skull? | ☐ yes ☐ no |
| 6 | Are the role outfits believable for a Hollywood-boulevard shift (tourist, delivery, guard, responder)? | ☐ yes ☐ no |
| 7 | Are the seeded tint palettes acceptable, or too pastel/saturated? | ☐ yes ☐ no |
| 8 | Do the seated and walking poses hold up? | ☐ yes ☐ no |
| 1 | Do the twelve characters read as distinct people at 8 m? | [x] yes [ ] no |
| 2 | Is the head size right for the body (no bobblehead, no pinhead)? | [x] yes [ ] no |
| 3 | Do the faces hold up at portrait distance? | [ ] yes [x] no |
| 4 | Do the hands and the hand-held gear (camera, cup, clipboard, sign) read correctly? | [ ] yes [x] no |
| 5 | Does the head gear (cap, hood, sunglasses, firefighter helmet) sit on the skull? | [ ] yes [x] no |
| 6 | Are the role outfits believable for a Hollywood-boulevard shift (tourist, delivery, guard, responder)? | [x] yes [ ] no |
| 7 | Are the seeded tint palettes acceptable, or too pastel/saturated? | [x] yes [ ] no |
| 8 | Do the seated and walking poses hold up? | [x] yes [ ] no |
**Signoff:** ☐ approved as shipped ☐ approved with the follow-ups below ☐ rejected
**Signoff:** [ ] approved as shipped [x] approved with the follow-ups below [ ] rejected
## Issues visible in the captures
The first review pass found three body defects. All three came from the build pipeline,
not the art, and all three are addressed in the second pass (2026-11-06):
| Finding | Cause | Status |
| --- | --- | --- |
| `npc_guard` has holes in the upper body | the build **deleted** the `Worker_Vest` material, and on the modular Worker the vest *is* the torso | **fixed** — the vest is now the `cloth` slot, tinted per role; nothing is deleted |
| `npc_firefighter`, `npc_crowd_d` have a broken shoulder/upper-body area | same cause, plus the deleted `Worker_Yellow` hat was the crown of the skull, leaving the head open | **fixed** — the hat/crown stays in the mesh and only the part above z 1.65 takes the `hair` tint |
| `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 |
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.
**Still open** — checklist rows 3, 4 and 5 plus the list below: the Worker crown still reads as a
hard hat because that is upstream geometry, the eye/brow mass still reads heavy at portrait
distance, and the arms still graze the torso on the `worker` base.
Found by user:
1. npc_firefighter, npc_guard and npc_crowd_d have errors in the body:
- npc_guard has "holes" in upper body
- npc_firefighter and npc_crowd_d have buggy shoulder and upper body area
2. npc_guard, npc_vest and npc_crowd_d have one large unibrow: use other character's eyebrow geometry instead.
3. npc_dress, npc_jacket, npc_crowd_a and npc_crowd_d arms are too close and intersecting the body
Found while building this page; none blocks the swap, all are queued in `TODO.md`:
1. **`npc_dress` wears the performer cone hat plus the protest sign** because the performer props
+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")