Copy the hoodie eye pair onto the Worker head and lift every hat except the firefighter helmet
No files matched your search
@@ -887,14 +887,21 @@ gear) and 5 (head gear on the skull).
|
||||
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.
|
||||
- [x] Worker roles (vest, firefighter, guard, crowd_d) had **no visible eyes**: the Worker's own
|
||||
quads sit inside the head. The hoodie's eye pair is now transplanted with a 4 mm forward
|
||||
offset, so all four roles show two eyes and two brows (owner request).
|
||||
- [x] Owner: every hat/head item moves up. `GEAR_OFFSETS` now lifts the cap, hood and beanie by
|
||||
0.07 m on top of the shared head-gear lift; the firefighter helmet is deliberately unchanged.
|
||||
- [x] Worker faces: the dark "unibrow" band turned out to be the **cap brim** sitting on the brow,
|
||||
not head geometry. `HEAD_GEAR_LIFT` went 0.07 → 0.103 (the gear is authored around the head
|
||||
*centre* at 1.62, the Quaternius head centre is 1.69) and `GEAR_SCALE` grows the cap/hood so
|
||||
they clear the 0.228 m hair shell. The cap now reads as a cap and both eyes *and* brows show.
|
||||
- [x] Hair poking through head gear: fixed by the same lift + cap/hood scale; the firefighter
|
||||
helmet dome covers the hair, and the cap reads as a cap rather than a visor.
|
||||
- [ ] The Worker eye quads are still upstream-sized; they read as two dark rectangles rather than
|
||||
eyes with whites. A light sclera + pupil texture on the `Eye` slot would finish the face.
|
||||
- [ ] The eyes are single-colour quads on every base; a light sclera + pupil texture on the `Eye`
|
||||
slot would finish the face at portrait distance.
|
||||
- [ ] The raised caps now hover slightly above the hair shell with a visible gap; either shrink
|
||||
the hair or fit the cap crown to it.
|
||||
- [-] Arm/torso intersection on `npc_dress`/`npc_jacket`/`npc_crowd_a`/`npc_crowd_d`: the baked
|
||||
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
|
||||
|
||||
@@ -655,11 +655,11 @@
|
||||
"skin",
|
||||
"cloth",
|
||||
"accessory",
|
||||
"hair.001",
|
||||
"Eyebrows",
|
||||
"Moustache",
|
||||
"Eye",
|
||||
"pants",
|
||||
"Brows",
|
||||
"Eye.001",
|
||||
"hair"
|
||||
],
|
||||
"mesh_node": "npc_base",
|
||||
@@ -738,11 +738,11 @@
|
||||
"skin",
|
||||
"cloth",
|
||||
"accessory",
|
||||
"hair.001",
|
||||
"Eyebrows",
|
||||
"Moustache",
|
||||
"Eye",
|
||||
"pants",
|
||||
"Brows",
|
||||
"Eye.001",
|
||||
"hair"
|
||||
],
|
||||
"mesh_node": "npc_base",
|
||||
@@ -780,11 +780,11 @@
|
||||
"skin",
|
||||
"cloth",
|
||||
"accessory",
|
||||
"hair.001",
|
||||
"Eyebrows",
|
||||
"Moustache",
|
||||
"Eye",
|
||||
"pants",
|
||||
"Brows",
|
||||
"Eye.001",
|
||||
"hair"
|
||||
],
|
||||
"mesh_node": "npc_base",
|
||||
@@ -908,11 +908,11 @@
|
||||
"Worker_Yellow",
|
||||
"cloth",
|
||||
"accessory",
|
||||
"hair.001",
|
||||
"Eyebrows",
|
||||
"Moustache",
|
||||
"Eye",
|
||||
"pants",
|
||||
"Brows",
|
||||
"Eye.001",
|
||||
"hair"
|
||||
],
|
||||
"mesh_node": "npc_base",
|
||||
|
||||
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 155 KiB After Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 217 KiB After Width: | Height: | Size: 217 KiB |
@@ -181,8 +181,10 @@ const GEAR_OFFSETS := {
|
||||
# The Quaternius head wears a 0.24 m hair shell that stands ~3.5 cm above the
|
||||
# cap crown, so the cap sinks into the hair and only its brim shows. Lift the
|
||||
# cap and hood onto the hair; sunglasses stay at the eye line.
|
||||
"cap": Vector3(0, 0.05, 0),
|
||||
"hood": Vector3(0, 0.03, 0),
|
||||
# Owner: every hat and head item moves up; the firefighter helmet keeps its fit.
|
||||
"cap": Vector3(0, 0.07, 0),
|
||||
"hood": Vector3(0, 0.07, 0),
|
||||
"beanie": Vector3(0, 0.07, 0),
|
||||
}
|
||||
## The hat geometry is authored around the 0.315 m procedural head and scaled to
|
||||
## 0.64 in the Blender build to match the 0.20 m Quaternius skull. That fits the
|
||||
@@ -191,6 +193,7 @@ const GEAR_OFFSETS := {
|
||||
const GEAR_SCALE := {
|
||||
"cap": 1.22,
|
||||
"hood": 1.12,
|
||||
"beanie": 1.22,
|
||||
}
|
||||
## Hand gear is authored around the wrist proxy. The palm sits ~4 cm in front
|
||||
## of that origin, so without this offset the first phone/flashlight versions
|
||||
|
||||
@@ -1510,7 +1510,7 @@ QUATERNIUS_SOURCES = {
|
||||
"above": "hair", "below": "cloth"},
|
||||
# The bar, the oversized eyes and the hard hat are replaced by
|
||||
# `_transplant_worker_head()`; those materials are gone by here.
|
||||
"Eyebrows": "hair",
|
||||
"Eyebrows": "keep",
|
||||
"Brows": "keep",
|
||||
"Eye": "keep", "Moustache": "keep"},
|
||||
"donor_head": True,
|
||||
@@ -1534,24 +1534,59 @@ QUATERNIUS_SOURCES = {
|
||||
WORKER_HEAD_DONOR = {
|
||||
"source": "Casual_Hoodie.glb",
|
||||
# donor material -> contract slot name.
|
||||
"parts": {"Eyebrows": "Brows", "Hair": "hair"},
|
||||
"parts": {"Eyebrows": "Brows", "Eye": "Eye", "Hair": "hair"},
|
||||
# The three upstream heads carry the same-sized eye quads (x +-0.057), but the
|
||||
# Worker's face is flatter, so unchecked they sit proud of the skin and merge
|
||||
# into one dark bar -- the "unibrow" the owner saw. Set them back into the
|
||||
# socket so the skin crops their edges, and lift the brows clear of them.
|
||||
"offset": {"Brows": (0.0, 0.0, 0.020)},
|
||||
# Brows lift clear of the eye line; the eyes are pushed 4 mm forward so they
|
||||
# clear the Worker's flatter face instead of sinking into it.
|
||||
"offset": {"Brows": (0.0, 0.0, 0.020), "Eye": (0.0, -0.004, 0.0)},
|
||||
# Our material -> the height below which its faces go away. `Eyebrows` is the
|
||||
# bar *and* the forehead shell, so only the bar is cut: removing the whole
|
||||
# material opened the skull and the interior rendered as a black band across
|
||||
# the brows. `Eye` goes entirely (its quads read as one dark mass).
|
||||
# `Eye` is deliberately *not* removed: this head has open sockets and the eye
|
||||
# quads are the only thing filling them, so deleting them showed the inside of
|
||||
# the head as a black band. They stay; the donor brows sit above them.
|
||||
"remove": {"Eyebrows": 1.715, "Worker_Yellow": 1.60},
|
||||
# The Worker's own eye quads sit inside the head and never show (the owner
|
||||
# reported the Worker roles as eyeless), so they are replaced by the hoodie's
|
||||
# pair, which is the same geometry the other eight characters use.
|
||||
"remove": {"Eyebrows": 1.715, "Worker_Yellow": 1.60, "Eye": None},
|
||||
"bone": "Head",
|
||||
}
|
||||
|
||||
|
||||
def _purge_unused_materials(obj):
|
||||
"""Drop material slots no face uses, so a donor can take the freed name.
|
||||
|
||||
`IDMaterials.pop()` repoints the polygons that used the slot, which corrupted
|
||||
the mesh the first time this was tried; rebuilding the list from the per-face
|
||||
names is explicit and leaves no dangling index.
|
||||
"""
|
||||
names = []
|
||||
for poly in obj.data.polygons:
|
||||
material = obj.data.materials[poly.material_index]
|
||||
names.append(material.name if material else "")
|
||||
kept, index_of = [], {}
|
||||
for name in names:
|
||||
if name in index_of:
|
||||
continue
|
||||
source = bpy.data.materials.get(name)
|
||||
if source is None:
|
||||
continue
|
||||
index_of[name] = len(kept)
|
||||
kept.append(source)
|
||||
if len(kept) == len(obj.data.materials):
|
||||
return
|
||||
obj.data.materials.clear()
|
||||
for material in kept:
|
||||
obj.data.materials.append(material)
|
||||
for poly, name in zip(obj.data.polygons, names):
|
||||
poly.material_index = index_of.get(name, 0)
|
||||
# A removed slot leaves its datablock behind with zero users, which still owns
|
||||
# the name and forces the donor's material to `Eye.001`.
|
||||
for material in [m for m in bpy.data.materials if m.users == 0]:
|
||||
bpy.data.materials.remove(material)
|
||||
|
||||
|
||||
def _base_name(name):
|
||||
"""Strip Blender's `.001` duplicate suffix."""
|
||||
head, sep, tail = name.rpartition(".")
|
||||
@@ -1588,6 +1623,8 @@ def _transplant_worker_head(obj):
|
||||
bm.to_mesh(obj.data)
|
||||
bm.free()
|
||||
print(f" removed {len(doomed)} Worker head faces")
|
||||
_purge_unused_materials(obj)
|
||||
|
||||
donor_path = QUATERNIUS_SRC / spec["source"]
|
||||
if not donor_path.is_file():
|
||||
print(f" head donor {donor_path.name} missing, Worker head left bare")
|
||||
@@ -1632,7 +1669,7 @@ def _transplant_worker_head(obj):
|
||||
kept.append(m)
|
||||
for slot_name, rule in spec.get("narrow", {}).items():
|
||||
local = [i for i, m in enumerate(candidate.data.materials)
|
||||
if m is not None and m.name == slot_name]
|
||||
if m is not None and _base_name(m.name) == slot_name]
|
||||
if not local:
|
||||
continue
|
||||
bm = bmesh.new()
|
||||
@@ -1649,7 +1686,7 @@ def _transplant_worker_head(obj):
|
||||
|
||||
for slot_name, offset in spec.get("offset", {}).items():
|
||||
local = [i for i, m in enumerate(candidate.data.materials)
|
||||
if m is not None and m.name == slot_name]
|
||||
if m is not None and _base_name(m.name) == slot_name]
|
||||
if not local:
|
||||
continue
|
||||
bm = bmesh.new()
|
||||
@@ -1684,6 +1721,17 @@ def _transplant_worker_head(obj):
|
||||
donor.select_set(True)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
bpy.ops.object.join()
|
||||
# The join can leave `Eye.001` next to a zero-user `Eye`; the contract slot
|
||||
# names must be exact because the runtime tints by name.
|
||||
slot_names = {m.name for m in obj.data.materials if m is not None}
|
||||
for material in obj.data.materials:
|
||||
if material is None:
|
||||
continue
|
||||
base = _base_name(material.name)
|
||||
if base != material.name and base not in slot_names:
|
||||
slot_names.discard(material.name)
|
||||
material.name = base
|
||||
slot_names.add(base)
|
||||
group = obj.vertex_groups.get(spec["bone"]) or obj.vertex_groups.new(name=spec["bone"])
|
||||
group.add([v.index for v in obj.data.vertices if not v.groups], 1.0, "REPLACE")
|
||||
print(f" transplanted {', '.join(sorted(spec['parts'].values()))} "
|
||||
|
||||