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:
zwitschi committed 2026-11-01 18:04:51 +01:00
1 parent a478bfbf35
commit 1b319c48b7
5 files changed
+82 -8

No files matched your search

+5
View File
@@ -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
(`<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
+8 -5
View File
@@ -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
<same>`), 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/<date>-gear/`.
- [ ] Consider a second character close-up for a hoodie/winter NPC so headgear is covered too
+1 -1
View File
@@ -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_<category>.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
```
+62
View File
@@ -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
+6 -2
View File
@@ -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"))