diff --git a/TODO.md b/TODO.md index 28b41a1..857a15a 100644 --- a/TODO.md +++ b/TODO.md @@ -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 diff --git a/assets/models/blender/manifest.json b/assets/models/blender/manifest.json index 04b4b13..b2e2604 100644 --- a/assets/models/blender/manifest.json +++ b/assets/models/blender/manifest.json @@ -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", diff --git a/assets/models/blender/npc/npc_crowd_d.glb b/assets/models/blender/npc/npc_crowd_d.glb index 7e2dfd4..5f93500 100644 Binary files a/assets/models/blender/npc/npc_crowd_d.glb and b/assets/models/blender/npc/npc_crowd_d.glb differ diff --git a/assets/models/blender/npc/npc_crowd_d_ao.png b/assets/models/blender/npc/npc_crowd_d_ao.png index 5bad0ac..f4e3170 100644 Binary files a/assets/models/blender/npc/npc_crowd_d_ao.png and b/assets/models/blender/npc/npc_crowd_d_ao.png differ diff --git a/assets/models/blender/npc/npc_firefighter.glb b/assets/models/blender/npc/npc_firefighter.glb index 7e2dfd4..5f93500 100644 Binary files a/assets/models/blender/npc/npc_firefighter.glb and b/assets/models/blender/npc/npc_firefighter.glb differ diff --git a/assets/models/blender/npc/npc_firefighter_ao.png b/assets/models/blender/npc/npc_firefighter_ao.png index 5bad0ac..f4e3170 100644 Binary files a/assets/models/blender/npc/npc_firefighter_ao.png and b/assets/models/blender/npc/npc_firefighter_ao.png differ diff --git a/assets/models/blender/npc/npc_guard.glb b/assets/models/blender/npc/npc_guard.glb index 7e2dfd4..5f93500 100644 Binary files a/assets/models/blender/npc/npc_guard.glb and b/assets/models/blender/npc/npc_guard.glb differ diff --git a/assets/models/blender/npc/npc_guard_ao.png b/assets/models/blender/npc/npc_guard_ao.png index 5bad0ac..f4e3170 100644 Binary files a/assets/models/blender/npc/npc_guard_ao.png and b/assets/models/blender/npc/npc_guard_ao.png differ diff --git a/assets/models/blender/npc/npc_vest.glb b/assets/models/blender/npc/npc_vest.glb index 1f0ad29..5b81c81 100644 Binary files a/assets/models/blender/npc/npc_vest.glb and b/assets/models/blender/npc/npc_vest.glb differ diff --git a/assets/models/blender/npc/npc_vest_ao.png b/assets/models/blender/npc/npc_vest_ao.png index 5bad0ac..f4e3170 100644 Binary files a/assets/models/blender/npc/npc_vest_ao.png and b/assets/models/blender/npc/npc_vest_ao.png differ diff --git a/docs/blender_previews/sheet_npc_roster_0.9.9.jpg b/docs/blender_previews/sheet_npc_roster_0.9.9.jpg index c5f1327..27408ae 100644 Binary files a/docs/blender_previews/sheet_npc_roster_0.9.9.jpg and b/docs/blender_previews/sheet_npc_roster_0.9.9.jpg differ diff --git a/docs/blender_previews/sheet_npc_roster_crowd_0.9.9.jpg b/docs/blender_previews/sheet_npc_roster_crowd_0.9.9.jpg index f6f52a9..b9376e5 100644 Binary files a/docs/blender_previews/sheet_npc_roster_crowd_0.9.9.jpg and b/docs/blender_previews/sheet_npc_roster_crowd_0.9.9.jpg differ diff --git a/docs/blender_previews/sheet_npc_roster_views_0.9.9.jpg b/docs/blender_previews/sheet_npc_roster_views_0.9.9.jpg index 6a80130..06b86e0 100644 Binary files a/docs/blender_previews/sheet_npc_roster_views_0.9.9.jpg and b/docs/blender_previews/sheet_npc_roster_views_0.9.9.jpg differ diff --git a/src/game/npc_avatar.gd b/src/game/npc_avatar.gd index bd60495..9c8b26d 100644 --- a/src/game/npc_avatar.gd +++ b/src/game/npc_avatar.gd @@ -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 diff --git a/tools/blender_assets.py b/tools/blender_assets.py index 756763d..33c080a 100644 --- a/tools/blender_assets.py +++ b/tools/blender_assets.py @@ -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()))} "