diff --git a/TODO.md b/TODO.md index 2a54301..8d39477 100644 --- a/TODO.md +++ b/TODO.md @@ -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 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`). +- [x] Unibrow on `npc_guard`/`npc_vest`/`npc_crowd_d`: the Worker `Eyebrows` bar *and* its + 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 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 @@ -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). - [ ] 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. +- [x] Carried role props floated beside the body: `_add_props()` placed the performer hat/board + and the protester pole/sign at fixed rig offsets. They now ride the hand proxies + (`_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 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 diff --git a/assets/models/blender/manifest.json b/assets/models/blender/manifest.json index be9ed90..173912d 100644 --- a/assets/models/blender/manifest.json +++ b/assets/models/blender/manifest.json @@ -640,13 +640,13 @@ "bounds_godot": { "max": [ 0.262, - 1.863, + 1.866, 0.287 ], "min": [ -0.262, -0.004, - -0.103 + -0.087 ] }, "category": "npc", @@ -655,11 +655,10 @@ "skin", "cloth", "accessory", - "hair", "Moustache", - "Eye", "pants", - "Brows" + "Brows", + "hair" ], "mesh_node": "npc_base", "path": "assets/models/blender/npc/npc_crowd_d.glb", @@ -674,8 +673,8 @@ "accessory", "hair" ], - "triangles": 5258, - "vertices": 10980 + "triangles": 6176, + "vertices": 13074 }, "npc_dress": { "base": "casual", @@ -722,13 +721,13 @@ "bounds_godot": { "max": [ 0.262, - 1.863, + 1.866, 0.287 ], "min": [ -0.262, -0.004, - -0.103 + -0.087 ] }, "category": "npc", @@ -737,11 +736,10 @@ "skin", "cloth", "accessory", - "hair", "Moustache", - "Eye", "pants", - "Brows" + "Brows", + "hair" ], "mesh_node": "npc_base", "path": "assets/models/blender/npc/npc_firefighter.glb", @@ -755,21 +753,21 @@ "accessory", "hair" ], - "triangles": 5258, - "vertices": 10980 + "triangles": 6176, + "vertices": 13074 }, "npc_guard": { "base": "worker", "bounds_godot": { "max": [ 0.262, - 1.863, + 1.866, 0.287 ], "min": [ -0.262, -0.004, - -0.103 + -0.087 ] }, "category": "npc", @@ -778,11 +776,10 @@ "skin", "cloth", "accessory", - "hair", "Moustache", - "Eye", "pants", - "Brows" + "Brows", + "hair" ], "mesh_node": "npc_base", "path": "assets/models/blender/npc/npc_guard.glb", @@ -797,8 +794,8 @@ "accessory", "hair" ], - "triangles": 5258, - "vertices": 10980 + "triangles": 6176, + "vertices": 13074 }, "npc_hoodie": { "base": "hoodie", @@ -889,13 +886,13 @@ "bounds_godot": { "max": [ 0.262, - 1.863, + 1.866, 0.287 ], "min": [ -0.262, -0.004, - -0.103 + -0.087 ] }, "category": "npc", @@ -905,11 +902,10 @@ "Worker_Yellow", "cloth", "accessory", - "hair", "Moustache", - "Eye", "pants", - "Brows" + "Brows", + "hair" ], "mesh_node": "npc_base", "path": "assets/models/blender/npc/npc_vest.glb", @@ -923,8 +919,8 @@ "accessory", "hair" ], - "triangles": 5258, - "vertices": 10980 + "triangles": 6176, + "vertices": 13074 }, "palm_tree": { "bounds_godot": { diff --git a/assets/models/blender/npc/npc_crowd_d.glb b/assets/models/blender/npc/npc_crowd_d.glb index 5e68461..faf7ded 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 22e4cbc..f94533c 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 5e68461..faf7ded 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 22e4cbc..f94533c 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 85b492c..faf7ded 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 db0129e..f94533c 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 37358ea..095d959 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 22e4cbc..f94533c 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 2e420bb..49ec2d6 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 139bcd1..9824af9 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 9d1f16f..65dde9b 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/docs/npc-visual-review.md b/docs/npc-visual-review.md index 92d1bc4..8514fe2 100644 --- a/docs/npc-visual-review.md +++ b/docs/npc-visual-review.md @@ -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_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 on any of the three Worker-based roles. diff --git a/src/game/npc_avatar.gd b/src/game/npc_avatar.gd index 9100b24..e909081 100644 --- a/src/game/npc_avatar.gd +++ b/src/game/npc_avatar.gd @@ -962,24 +962,33 @@ func _add_props(npc_type: int) -> void: 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)) 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 cone := MeshInstance3D.new() var cm := CylinderMesh.new() - cm.top_radius = 0.02 - cm.bottom_radius = 0.22 - cm.height = 0.4 + cm.top_radius = 0.012 + cm.bottom_radius = 0.125 + cm.height = 0.24 cone.mesh = cm cone.material_override = hat - cone.position = Vector3(0, 0.26, 0) + cone.position = Vector3(0, 0.15, 0) _head.add_child(cone) # Hat in one hand and a small board in the other: the performer plays - # to the sidewalk once at the waypoint. - _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)) - _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)) + # to the sidewalk once at the waypoint. Both ride the hand proxies, so + # they follow the arm instead of floating beside the body at a fixed + # 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 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)) - _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)) + # Pole and board ride the carrying hand for the same reason. + _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 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)) @@ -989,6 +998,19 @@ func _add_props(npc_type: int) -> void: 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)) +## 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: var mi := MeshInstance3D.new() var mesh := BoxMesh.new() diff --git a/tools/blender_assets.py b/tools/blender_assets.py index d06bb0c..f707500 100644 --- a/tools/blender_assets.py +++ b/tools/blender_assets.py @@ -1504,32 +1504,41 @@ QUATERNIUS_SOURCES = { "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. + # The bar, the oversized eyes and the hard hat are replaced by + # `_transplant_worker_head()`; those materials are gone by here. "Eyebrows": "hair", "Brows": "keep", "Eye": "keep", "Moustache": "keep"}, - "donor_brows": True, + "donor_head": True, "notes": "work shirt (vest geometry) + trousers + hard hat/crown", }, } -## 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)} +## The modular Worker head has three defects the roster cannot live with, and no +## other upstream head has parts that fit it directly, because the three heads are +## different meshes at different detail levels. `casual_hoodie` is the closest +## sibling, so its brows, eyes and hair are transplanted onto the Worker head: +## +## - `Eyebrows` is one continuous bar across the face (reads as a unibrow); +## - `Eye` is two oversized quads that nearly touch (the same dark mass); +## - `Worker_Yellow` above the collar is a hard hat that doubles as the skull +## crown, so a guard or responder looks like it is wearing a helmet to bed. +## +## This is the only place the build removes geometry. Everything else is +## remapped, because the Worker's vest *is* its torso and its hat *is* its crown. +WORKER_HEAD_DONOR = { + "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): @@ -1538,28 +1547,49 @@ def _base_name(name): 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. +def _transplant_worker_head(obj): + """Replace the Worker brow bar, eyes and hard hat with the hoodie's head gear.""" + spec = WORKER_HEAD_DONOR + # 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 = [] for poly in obj.data.polygons: 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 - if _face_centroid(obj, poly)[2] < 1.70: - doomed.append(poly.index) + limit = spec["remove"].get(material.name) + if limit is not None and _face_centroid(obj, poly)[2] <= float(limit): + continue + 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") + 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" 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"] 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 before = set(o.name for o in bpy.context.scene.objects) bpy.ops.import_scene.gltf(filepath=str(donor_path)) @@ -1568,16 +1598,14 @@ def _transplant_brows(obj): 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: + wanted = [i for i, m in enumerate(candidate.data.materials) + if m is not None and _base_name(m.name) in spec["parts"]] + if not wanted: 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] + drop = [f for f in bm.faces if f.material_index not in wanted] if drop: bmesh.ops.delete(bm, geom=drop, context="FACES") loose = [v for v in bm.verts if not v.link_faces] @@ -1588,30 +1616,59 @@ def _transplant_brows(obj): 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"] + # Collect the kept materials, then remap the faces: clearing the material + # list resets every polygon to slot 0. + 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 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") + print(" head donor not found, Worker head left bare") 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 = 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 {spec['slot_name']} from {spec['source']}") + print(f" transplanted {', '.join(sorted(spec['parts'].values()))} " + f"from {spec['source']}") def _face_centroid(obj, poly): @@ -1872,8 +1929,8 @@ def _quaternius_tpose_glb(base, overrides): slots = dict(source["slots"]) slots.update(overrides) - if source.get("donor_brows"): - _transplant_brows(obj) + if source.get("donor_head"): + _transplant_worker_head(obj) _quaternius_slot_map(obj, slots) # Upstream UVs are degenerate (every loop at 0,0), so an AO bake would write