diff --git a/MEMORY.md b/MEMORY.md index 360eb17..2f9dd58 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -241,6 +241,11 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o same colliders/obstacles, and collects library emissives for `set_night_mode()`. `LIBRARY_TRIANGLE_BUDGET` = 60k (current ~27,986). Library props are excluded from `PROP_PLACEMENTS` only when they need dedicated slots (door/marquee/sedan/handrail/lantern). + **Gear fit (v0.6.1):** `fit` measures head width (0.315 m), skirt/hip radius and gear bounds; + sunglasses are 0.202 m, the bag's body inner face is 0.262 m (coat 0.225, dress 0.260), and the + helmet is lifted 2 cm above the brow. `test_npc_character` guards head/back gear in Godot. + Blender's GLB export is byte-nondeterministic for unchanged assets (padding/index order), so do + not commit a full no-op rebuild; rebuild only the assets whose geometry changed. - **v0.6.0 environment pass:** `tools/bake_ao.py` bakes 256² AO maps per library asset (`_ao.png`, red channel) and `Materials.apply_ao()`/`SurfaceMaterials` wire them in; `tools/make_normal_maps.py` derives tangent normals for the 1024² brick/concrete/asphalt diff --git a/TODO.md b/TODO.md index 6b3d6c3..4337a49 100644 --- a/TODO.md +++ b/TODO.md @@ -30,11 +30,14 @@ live in `tools/blender_assets.py` (`_build_gear()`), the contract in `docs/blend the hood leaves the face readable; the helmet brim sat on the brow, so the whole helmet was lifted 2 cm and rebuilt/re-baked. Beanie uses the same capped-shell sizing as the cap and is currently unused; it is covered by the upcoming headgear regression check. -- [ ] Rebuild the gear (`build`) and re-bake AO (`tools/bake_ao.py`), then - `godot --headless --path . --import`. -- [ ] Add a regression test: `tools/test_blender_assets.py` checks the glasses width against the - head width, and `src/test/tests/test_npc_character.gd` checks the head/back attach nodes - stay outside the body silhouette radius. +- [x] Rebuild the gear (`build --only <18 gear names>`) and re-bake AO (`tools/bake_ao.py --only + `), then `godot --headless --path . --import` (clean). Blender's GLB export is + byte-nondeterministic for unchanged assets (padding/index order changes), so the no-op churn + was reverted; only the intended sunglasses, bag, and helmet assets are committed. +- [x] Add a regression test: `tools/test_blender_assets.py` limits the glasses to 0.70x head + width and requires positive bag clearance on both coat and dress; + `test_npc_character.test_head_and_back_gear_clear_the_body_silhouette` loads the gear on + six roles and checks head-parent/width/brow bounds plus back-parent/0.26 m outer radius. - [ ] Re-capture the portrait (`--stages=day,portrait`) and the gallery shots; record the before/after in `docs/screens/realism/-gear/`. - [ ] Consider a second character close-up for a hoodie/winter NPC so headgear is covered too diff --git a/docs/blender-assets.md b/docs/blender-assets.md index 3aa8315..41bdf02 100644 --- a/docs/blender-assets.md +++ b/docs/blender-assets.md @@ -41,7 +41,7 @@ flatpak run org.blender.Blender --background \ --python "$PWD/tools/blender_assets.py" -- render python3 tools/blender_assets.py sheet # -> docs/blender_previews/sheet_.jpg python3 tools/blender_assets.py audit # stdlib only; exit 1 on any issue -python3 tools/blender_assets.py fit # head/clothing fit, per character +python3 tools/blender_assets.py fit # head/clothing fit + bag-body clearance, per character python3 tools/blender_assets.py list godot --headless --path . --import # after any rebuild ``` diff --git a/src/test/tests/test_npc_character.gd b/src/test/tests/test_npc_character.gd index 4c6ae5b..c1d275f 100644 --- a/src/test/tests/test_npc_character.gd +++ b/src/test/tests/test_npc_character.gd @@ -191,6 +191,68 @@ func test_library_gear_attaches_to_the_contract_points() -> bool: responder.free() return a and b and c and d +## Horizontal bounds of a gear GLB in its attach-node space. The head/back +## contract is measured against the Blender fit report (head width 0.315 m, +## widest body silhouette 0.26 m); this catches a gear shrinking back inside +## the body or a cap sinking below the brow. +func _gear_bounds(node: Node3D) -> AABB: + var box := AABB() + var first := true + for child in node.find_children("*", "MeshInstance3D", true, false): + var mi := child as MeshInstance3D + var b: AABB = mi.get_aabb() + # Gear meshes have no extra transform below the attach node. + if first: + box = b + first = false + else: + box = box.merge(b) + return box + +func test_head_and_back_gear_clear_the_body_silhouette() -> bool: + const HEAD_WIDTH := 0.315 + const WIDEST_BODY_RADIUS := 0.26 + var problems: Array[String] = [] + var seen := {} + for npc_type in [Rules.NpcType.TOURIST, Rules.NpcType.INFLUENCER, + Rules.NpcType.RESPONDER, Rules.NpcType.PERFORMER, + Rules.NpcType.DELIVERY, Rules.NpcType.TRESPASSER]: + var avatar := _avatar(npc_type, 5) + for child in avatar.find_children("Gear_*", "Node3D", true, false): + var gear := child as Node3D + var name := gear.name.trim_prefix("Gear_") + var attach := str(NpcAvatar.GEAR_ATTACH.get(name, "")) + if attach != "head" and attach != "back": + continue + seen[attach + ":" + name] = true + var box := _gear_bounds(gear) + if attach == "head": + var parent_name := gear.get_parent().name + if parent_name != "Head": + problems.append("%s is not under Head" % name) + if box.size.x > HEAD_WIDTH * 1.70: + problems.append("%s is %.3f m wide (head %.3f)" % [ + name, box.size.x, HEAD_WIDTH]) + # The hood wraps the neck; all other headgear must start on the skull. + if name != "hood" and box.position.y < 0.0: + problems.append("%s starts %.3f m below the head origin" % [ + name, box.position.y]) + else: + if gear.get_parent().name != "Rig": + problems.append("%s is not under Rig" % name) + var outer_x := maxf(absf(box.position.x), absf(box.end.x)) + var outer_z := maxf(absf(box.position.z), absf(box.end.z)) + if maxf(outer_x, outer_z) < WIDEST_BODY_RADIUS: + problems.append("%s only reaches %.3f m (body %.3f)" % [ + name, maxf(outer_x, outer_z), WIDEST_BODY_RADIUS]) + avatar.free() + for required in ["head:sunglasses", "head:firefighter_helmet", "head:cap", + "back:shoulder_bag", "back:backpack", "back:scba_tank"]: + if not seen.has(required): + problems.append("missing %s" % required) + return TestUtils.check(problems.is_empty(), + "head/back gear clears the body silhouette (%s)" % str(problems)) + func test_fixed_detail_materials_are_not_tinted() -> bool: var avatar := _avatar(Rules.NpcType.RESPONDER, 3) var mi := avatar.find_child("npc_base", true, false) as MeshInstance3D diff --git a/tools/test_blender_assets.py b/tools/test_blender_assets.py index e87499f..2cce1ab 100644 --- a/tools/test_blender_assets.py +++ b/tools/test_blender_assets.py @@ -53,7 +53,8 @@ class BuiltAssetTests(unittest.TestCase): self.assertAlmostEqual(coat["reference"]["clothing_radius"], 0.225, places=3) sunglasses = next(row for row in coat["gear"] if row["name"] == "sunglasses") self.assertEqual(sunglasses["attach"], "head") - self.assertLess(sunglasses["width"], coat["reference"]["head_width"]) + self.assertLessEqual(sunglasses["width"], + 0.70 * coat["reference"]["head_width"]) bag = next(row for row in coat["gear"] if row["name"] == "shoulder_bag") self.assertEqual(bag["attach"], "back") self.assertGreaterEqual(bag["x_extent"], 0.2) @@ -64,7 +65,10 @@ class BuiltAssetTests(unittest.TestCase): self.assertEqual(len(row["min"]), 3) self.assertEqual(len(row["max"]), 3) self.assertIn("npc_dress", report) - self.assertAlmostEqual(report["npc_dress"]["reference"]["clothing_radius"], 0.26, places=3) + dress = report["npc_dress"] + self.assertAlmostEqual(dress["reference"]["clothing_radius"], 0.26, places=3) + dress_bag = next(row for row in dress["gear"] if row["name"] == "shoulder_bag") + self.assertGreater(dress_bag["x_clearance"], 0) def test_manifest_covers_catalogue(self): manifest = json.loads(ba.MANIFEST.read_text(encoding="utf-8"))