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`).
|
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
|
- [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.
|
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,
|
- [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
|
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
|
*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.
|
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
|
- [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.
|
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
|
- [ ] The eyes are single-colour quads on every base; a light sclera + pupil texture on the `Eye`
|
||||||
eyes with whites. A light sclera + pupil texture on the `Eye` slot would finish the face.
|
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
|
- [-] 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
|
||||||
|
|||||||
@@ -655,11 +655,11 @@
|
|||||||
"skin",
|
"skin",
|
||||||
"cloth",
|
"cloth",
|
||||||
"accessory",
|
"accessory",
|
||||||
"hair.001",
|
"Eyebrows",
|
||||||
"Moustache",
|
"Moustache",
|
||||||
"Eye",
|
|
||||||
"pants",
|
"pants",
|
||||||
"Brows",
|
"Brows",
|
||||||
|
"Eye.001",
|
||||||
"hair"
|
"hair"
|
||||||
],
|
],
|
||||||
"mesh_node": "npc_base",
|
"mesh_node": "npc_base",
|
||||||
@@ -738,11 +738,11 @@
|
|||||||
"skin",
|
"skin",
|
||||||
"cloth",
|
"cloth",
|
||||||
"accessory",
|
"accessory",
|
||||||
"hair.001",
|
"Eyebrows",
|
||||||
"Moustache",
|
"Moustache",
|
||||||
"Eye",
|
|
||||||
"pants",
|
"pants",
|
||||||
"Brows",
|
"Brows",
|
||||||
|
"Eye.001",
|
||||||
"hair"
|
"hair"
|
||||||
],
|
],
|
||||||
"mesh_node": "npc_base",
|
"mesh_node": "npc_base",
|
||||||
@@ -780,11 +780,11 @@
|
|||||||
"skin",
|
"skin",
|
||||||
"cloth",
|
"cloth",
|
||||||
"accessory",
|
"accessory",
|
||||||
"hair.001",
|
"Eyebrows",
|
||||||
"Moustache",
|
"Moustache",
|
||||||
"Eye",
|
|
||||||
"pants",
|
"pants",
|
||||||
"Brows",
|
"Brows",
|
||||||
|
"Eye.001",
|
||||||
"hair"
|
"hair"
|
||||||
],
|
],
|
||||||
"mesh_node": "npc_base",
|
"mesh_node": "npc_base",
|
||||||
@@ -908,11 +908,11 @@
|
|||||||
"Worker_Yellow",
|
"Worker_Yellow",
|
||||||
"cloth",
|
"cloth",
|
||||||
"accessory",
|
"accessory",
|
||||||
"hair.001",
|
"Eyebrows",
|
||||||
"Moustache",
|
"Moustache",
|
||||||
"Eye",
|
|
||||||
"pants",
|
"pants",
|
||||||
"Brows",
|
"Brows",
|
||||||
|
"Eye.001",
|
||||||
"hair"
|
"hair"
|
||||||
],
|
],
|
||||||
"mesh_node": "npc_base",
|
"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
|
# 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 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 and hood onto the hair; sunglasses stay at the eye line.
|
||||||
"cap": Vector3(0, 0.05, 0),
|
# Owner: every hat and head item moves up; the firefighter helmet keeps its fit.
|
||||||
"hood": Vector3(0, 0.03, 0),
|
"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
|
## 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
|
## 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 := {
|
const GEAR_SCALE := {
|
||||||
"cap": 1.22,
|
"cap": 1.22,
|
||||||
"hood": 1.12,
|
"hood": 1.12,
|
||||||
|
"beanie": 1.22,
|
||||||
}
|
}
|
||||||
## Hand gear is authored around the wrist proxy. The palm sits ~4 cm in front
|
## 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
|
## of that origin, so without this offset the first phone/flashlight versions
|
||||||
|
|||||||
@@ -1510,7 +1510,7 @@ QUATERNIUS_SOURCES = {
|
|||||||
"above": "hair", "below": "cloth"},
|
"above": "hair", "below": "cloth"},
|
||||||
# The bar, the oversized eyes and the hard hat are replaced by
|
# The bar, the oversized eyes and the hard hat are replaced by
|
||||||
# `_transplant_worker_head()`; those materials are gone by here.
|
# `_transplant_worker_head()`; those materials are gone by here.
|
||||||
"Eyebrows": "hair",
|
"Eyebrows": "keep",
|
||||||
"Brows": "keep",
|
"Brows": "keep",
|
||||||
"Eye": "keep", "Moustache": "keep"},
|
"Eye": "keep", "Moustache": "keep"},
|
||||||
"donor_head": True,
|
"donor_head": True,
|
||||||
@@ -1534,24 +1534,59 @@ QUATERNIUS_SOURCES = {
|
|||||||
WORKER_HEAD_DONOR = {
|
WORKER_HEAD_DONOR = {
|
||||||
"source": "Casual_Hoodie.glb",
|
"source": "Casual_Hoodie.glb",
|
||||||
# donor material -> contract slot name.
|
# 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
|
# 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
|
# 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
|
# 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.
|
# 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
|
# 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
|
# 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
|
# 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).
|
# 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
|
# The Worker's own eye quads sit inside the head and never show (the owner
|
||||||
# quads are the only thing filling them, so deleting them showed the inside of
|
# reported the Worker roles as eyeless), so they are replaced by the hoodie's
|
||||||
# the head as a black band. They stay; the donor brows sit above them.
|
# pair, which is the same geometry the other eight characters use.
|
||||||
"remove": {"Eyebrows": 1.715, "Worker_Yellow": 1.60},
|
"remove": {"Eyebrows": 1.715, "Worker_Yellow": 1.60, "Eye": None},
|
||||||
"bone": "Head",
|
"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):
|
def _base_name(name):
|
||||||
"""Strip Blender's `.001` duplicate suffix."""
|
"""Strip Blender's `.001` duplicate suffix."""
|
||||||
head, sep, tail = name.rpartition(".")
|
head, sep, tail = name.rpartition(".")
|
||||||
@@ -1588,6 +1623,8 @@ def _transplant_worker_head(obj):
|
|||||||
bm.to_mesh(obj.data)
|
bm.to_mesh(obj.data)
|
||||||
bm.free()
|
bm.free()
|
||||||
print(f" removed {len(doomed)} Worker head faces")
|
print(f" removed {len(doomed)} Worker head faces")
|
||||||
|
_purge_unused_materials(obj)
|
||||||
|
|
||||||
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" head donor {donor_path.name} missing, Worker head left bare")
|
print(f" head donor {donor_path.name} missing, Worker head left bare")
|
||||||
@@ -1632,7 +1669,7 @@ def _transplant_worker_head(obj):
|
|||||||
kept.append(m)
|
kept.append(m)
|
||||||
for slot_name, rule in spec.get("narrow", {}).items():
|
for slot_name, rule in spec.get("narrow", {}).items():
|
||||||
local = [i for i, m in enumerate(candidate.data.materials)
|
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:
|
if not local:
|
||||||
continue
|
continue
|
||||||
bm = bmesh.new()
|
bm = bmesh.new()
|
||||||
@@ -1649,7 +1686,7 @@ def _transplant_worker_head(obj):
|
|||||||
|
|
||||||
for slot_name, offset in spec.get("offset", {}).items():
|
for slot_name, offset in spec.get("offset", {}).items():
|
||||||
local = [i for i, m in enumerate(candidate.data.materials)
|
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:
|
if not local:
|
||||||
continue
|
continue
|
||||||
bm = bmesh.new()
|
bm = bmesh.new()
|
||||||
@@ -1684,6 +1721,17 @@ def _transplant_worker_head(obj):
|
|||||||
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 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 = 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 {', '.join(sorted(spec['parts'].values()))} "
|
print(f" transplanted {', '.join(sorted(spec['parts'].values()))} "
|
||||||
|
|||||||