Guard gear fit with headless and Blender regression checks
The Python fit test now caps the glasses at 0.70x head width and requires a positive bag clearance on both the coat and dress. The Godot character suite loads head/back gear on six roles and checks attach parents, head width and brow bounds, and that back gear reaches beyond the 0.26 m body silhouette. Rebuilt and re-baked all 18 gear assets; no-op GLB export churn was reverted. Headless 229/229, Python 87/87.
This commit is contained in:
1 parent
a478bfbf35
commit
1b319c48b7
5 files changed
+82
-8
No files matched your search
@@ -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()`.
|
same colliders/obstacles, and collects library emissives for `set_night_mode()`.
|
||||||
`LIBRARY_TRIANGLE_BUDGET` = 60k (current ~27,986). Library props are excluded from
|
`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).
|
`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
|
- **v0.6.0 environment pass:** `tools/bake_ao.py` bakes 256² AO maps per library asset
|
||||||
(`<name>_ao.png`, red channel) and `Materials.apply_ao()`/`SurfaceMaterials` wire them in;
|
(`<name>_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
|
`tools/make_normal_maps.py` derives tangent normals for the 1024² brick/concrete/asphalt
|
||||||
|
|||||||
@@ -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
|
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
|
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.
|
currently unused; it is covered by the upcoming headgear regression check.
|
||||||
- [ ] Rebuild the gear (`build`) and re-bake AO (`tools/bake_ao.py`), then
|
- [x] Rebuild the gear (`build --only <18 gear names>`) and re-bake AO (`tools/bake_ao.py --only
|
||||||
`godot --headless --path . --import`.
|
<same>`), then `godot --headless --path . --import` (clean). Blender's GLB export is
|
||||||
- [ ] Add a regression test: `tools/test_blender_assets.py` checks the glasses width against the
|
byte-nondeterministic for unchanged assets (padding/index order changes), so the no-op churn
|
||||||
head width, and `src/test/tests/test_npc_character.gd` checks the head/back attach nodes
|
was reverted; only the intended sunglasses, bag, and helmet assets are committed.
|
||||||
stay outside the body silhouette radius.
|
- [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
|
- [ ] Re-capture the portrait (`--stages=day,portrait`) and the gallery shots; record the
|
||||||
before/after in `docs/screens/realism/<date>-gear/`.
|
before/after in `docs/screens/realism/<date>-gear/`.
|
||||||
- [ ] Consider a second character close-up for a hoodie/winter NPC so headgear is covered too
|
- [ ] Consider a second character close-up for a hoodie/winter NPC so headgear is covered too
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ flatpak run org.blender.Blender --background \
|
|||||||
--python "$PWD/tools/blender_assets.py" -- render
|
--python "$PWD/tools/blender_assets.py" -- render
|
||||||
python3 tools/blender_assets.py sheet # -> docs/blender_previews/sheet_<category>.jpg
|
python3 tools/blender_assets.py sheet # -> docs/blender_previews/sheet_<category>.jpg
|
||||||
python3 tools/blender_assets.py audit # stdlib only; exit 1 on any issue
|
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
|
python3 tools/blender_assets.py list
|
||||||
godot --headless --path . --import # after any rebuild
|
godot --headless --path . --import # after any rebuild
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -191,6 +191,68 @@ func test_library_gear_attaches_to_the_contract_points() -> bool:
|
|||||||
responder.free()
|
responder.free()
|
||||||
return a and b and c and d
|
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:
|
func test_fixed_detail_materials_are_not_tinted() -> bool:
|
||||||
var avatar := _avatar(Rules.NpcType.RESPONDER, 3)
|
var avatar := _avatar(Rules.NpcType.RESPONDER, 3)
|
||||||
var mi := avatar.find_child("npc_base", true, false) as MeshInstance3D
|
var mi := avatar.find_child("npc_base", true, false) as MeshInstance3D
|
||||||
|
|||||||
@@ -53,7 +53,8 @@ class BuiltAssetTests(unittest.TestCase):
|
|||||||
self.assertAlmostEqual(coat["reference"]["clothing_radius"], 0.225, places=3)
|
self.assertAlmostEqual(coat["reference"]["clothing_radius"], 0.225, places=3)
|
||||||
sunglasses = next(row for row in coat["gear"] if row["name"] == "sunglasses")
|
sunglasses = next(row for row in coat["gear"] if row["name"] == "sunglasses")
|
||||||
self.assertEqual(sunglasses["attach"], "head")
|
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")
|
bag = next(row for row in coat["gear"] if row["name"] == "shoulder_bag")
|
||||||
self.assertEqual(bag["attach"], "back")
|
self.assertEqual(bag["attach"], "back")
|
||||||
self.assertGreaterEqual(bag["x_extent"], 0.2)
|
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["min"]), 3)
|
||||||
self.assertEqual(len(row["max"]), 3)
|
self.assertEqual(len(row["max"]), 3)
|
||||||
self.assertIn("npc_dress", report)
|
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):
|
def test_manifest_covers_catalogue(self):
|
||||||
manifest = json.loads(ba.MANIFEST.read_text(encoding="utf-8"))
|
manifest = json.loads(ba.MANIFEST.read_text(encoding="utf-8"))
|
||||||
|
|||||||
Reference in new issue
Block a user