Transplant the hoodie head parts onto the Worker base and put carried props in the hands
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 156 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 211 KiB After Width: | Height: | Size: 214 KiB |
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||