Add a gear-vs-body fit report to the Blender asset tool
`blender_assets.py fit` measures each character head width (skin vertices weighted to Head in y 1.55-1.81) and clothing radius (cloth skirt in y 0.30-0.52, pants hip fallback), then prints every attached head/back/chest gear bounding box with head-band status and x/radial skirt clearance. The report confirms the glasses width and shoulder-bag coat clip, giving the next fit fixes numbers. Python tooling 87/87; asset audit clean.
This commit is contained in:
1 parent
1ee32215b0
commit
da876e3545
5 files changed
+180
-4
No files matched your search
@@ -13,6 +13,10 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
|||||||
(46 `.blend` files removed from the index on 2026-11-01); the `.gdignore` marker stays tracked.
|
(46 `.blend` files removed from the index on 2026-11-01); the `.gdignore` marker stays tracked.
|
||||||
- `screen_designs.py judge` default `google/gemini-2.5-flash-lite` worked on 2026-11-01
|
- `screen_designs.py judge` default `google/gemini-2.5-flash-lite` worked on 2026-11-01
|
||||||
(7 match / 4 review / 0 diff); `--model` remains available.
|
(7 match / 4 review / 0 diff); `--model` remains available.
|
||||||
|
- Gear fit is measured with `python3 tools/blender_assets.py fit` (stdlib, reads the GLBs):
|
||||||
|
head width uses skin vertices weighted to Head in y 1.55–1.81; clothing radius is the cloth
|
||||||
|
skirt in y 0.30–0.52 with a pants hip fallback. The command reports per-character gear boxes,
|
||||||
|
head-band status and x/radial skirt clearance; `test_blender_assets` covers its shape.
|
||||||
- Post-realism work is split by theme, not by date: the finished lighting/flashlight/NPC items
|
- Post-realism work is split by theme, not by date: the finished lighting/flashlight/NPC items
|
||||||
are archived in `DONE.md`, and `TODO.md` carries three planned workstreams (NPC gear fit,
|
are archived in `DONE.md`, and `TODO.md` carries three planned workstreams (NPC gear fit,
|
||||||
venue geometry + committed gallery, Forward+ desktop preset). New visual work should be added
|
venue geometry + committed gallery, Forward+ desktop preset). New visual work should be added
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ sunglasses span the whole upper face and the shoulder bag cuts through `npc_coat
|
|||||||
live in `tools/blender_assets.py` (`_build_gear()`), the contract in `docs/blender-assets.md`
|
live in `tools/blender_assets.py` (`_build_gear()`), the contract in `docs/blender-assets.md`
|
||||||
(gear budget 1500 tris, `GEAR_ATTACH` points in `NpcAvatar`).
|
(gear budget 1500 tris, `GEAR_ATTACH` points in `NpcAvatar`).
|
||||||
|
|
||||||
- [ ] Measure the fit in Blender, not by eye: extend `blender_assets.py audit` (or add a `fit`
|
- [x] Measure the fit in Blender, not by eye: `python3 tools/blender_assets.py fit` prints each
|
||||||
subcommand) to print, per head/back/chest gear piece, its bounding box against the avatar
|
character's measured head width and hip/skirt radius, then every head/back/chest gear
|
||||||
head width (measured 0.314 m on `npc_coat`), the head height band (y 1.55–1.81) and the
|
bounding box with head-band status or skirt clearance. Current measurements: head width
|
||||||
clothing radius (coat/dress skirt flare 0.225–0.26 m), for every character variant.
|
0.315 m; coat skirt 0.225 m, dress 0.260 m; sunglasses 0.282 m (0.90x head); shoulder-bag
|
||||||
|
x-clearance -0.025 m on the coat (radial -0.031 m on the dress).
|
||||||
- [ ] Narrow `sunglasses` from 0.28 m to ~0.20 m. The width is driven by the arms/hinges
|
- [ ] Narrow `sunglasses` from 0.28 m to ~0.20 m. The width is driven by the arms/hinges
|
||||||
(`Hinge` at `x = ±0.11` with a 0.06 box, `Arm` at `±0.138`), not by the 0.03 m lenses, so
|
(`Hinge` at `x = ±0.11` with a 0.06 box, `Arm` at `±0.138`), not by the 0.03 m lenses, so
|
||||||
pull those in first and shrink the lenses to ~0.024 m radius; keep the lens plane at
|
pull those in first and shrink the lenses to ~0.024 m radius; keep the lens plane at
|
||||||
|
|||||||
@@ -41,6 +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 list
|
python3 tools/blender_assets.py list
|
||||||
godot --headless --path . --import # after any rebuild
|
godot --headless --path . --import # after any rebuild
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ integration: nothing in the game loads them yet (see
|
|||||||
--python "$PWD/tools/blender_assets.py" -- render
|
--python "$PWD/tools/blender_assets.py" -- render
|
||||||
# outside Blender (stdlib + Pillow)
|
# outside Blender (stdlib + Pillow)
|
||||||
python3 tools/blender_assets.py audit # GLB contract checks
|
python3 tools/blender_assets.py audit # GLB contract checks
|
||||||
|
python3 tools/blender_assets.py fit # gear-vs-body fit measurements
|
||||||
python3 tools/blender_assets.py sheet # sheet_<category>.jpg (committed)
|
python3 tools/blender_assets.py sheet # sheet_<category>.jpg (committed)
|
||||||
python3 tools/blender_assets.py list
|
python3 tools/blender_assets.py list
|
||||||
|
|
||||||
@@ -104,6 +105,14 @@ GEAR_ATTACH = {
|
|||||||
# Same attach points in Blender coordinates (Godot (x, y, z) -> Blender (x, -z, y)).
|
# Same attach points in Blender coordinates (Godot (x, y, z) -> Blender (x, -z, y)).
|
||||||
ATTACH_BLENDER = {"head": (0.0, 0.0, 1.62), "hand": (0.27, -0.02, 0.78),
|
ATTACH_BLENDER = {"head": (0.0, 0.0, 1.62), "hand": (0.27, -0.02, 0.78),
|
||||||
"back": (0.0, 0.0, 1.10), "chest": (0.0, 0.0, 1.30)}
|
"back": (0.0, 0.0, 1.10), "chest": (0.0, 0.0, 1.30)}
|
||||||
|
# Attach points in the authored GLB space (Godot coordinates), used by `fit`.
|
||||||
|
ATTACH_GODOT = {"head": (0.0, 1.62, 0.0), "hand": (0.27, 0.78, 0.02),
|
||||||
|
"back": (0.0, 1.10, 0.0), "chest": (0.0, 1.30, 0.0)}
|
||||||
|
HEAD_BAND = (1.55, 1.81) # face/headgear height band in Godot metres
|
||||||
|
FIT_HEAD_MATERIALS = ("skin",)
|
||||||
|
FIT_CLOTHING_MATERIAL = "cloth"
|
||||||
|
FIT_CLOTHING_BAND = (0.30, 0.52) # hip/skirt band; captures coat/dress flare
|
||||||
|
FIT_HIP_BAND = (0.55, 0.95) # fallback body radius for trousers/vests
|
||||||
GEAR = {
|
GEAR = {
|
||||||
"cap": "head", "beanie": "head", "hood": "head", "sunglasses": "head",
|
"cap": "head", "beanie": "head", "hood": "head", "sunglasses": "head",
|
||||||
"firefighter_helmet": "head",
|
"firefighter_helmet": "head",
|
||||||
@@ -1672,6 +1681,117 @@ def audit_asset(category, name):
|
|||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
def character_fit_reference(name):
|
||||||
|
"""Measure a character's head width and hip/skirt radius from its GLB.
|
||||||
|
|
||||||
|
Head width uses skin/hair vertices whose strongest joint is Head inside the
|
||||||
|
fixed head band. Clothing radius uses the cloth vertices in the hip/skirt
|
||||||
|
band. Both are reported in metres and rounded to millimetres.
|
||||||
|
"""
|
||||||
|
path = asset_path("npc", name)
|
||||||
|
if not path.is_file():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
_magic, _version, _length, _size, gltf, binary = _read_glb(path)
|
||||||
|
except (struct.error, ValueError, KeyError):
|
||||||
|
return None
|
||||||
|
nodes = gltf.get("nodes", [])
|
||||||
|
joints = [nodes[j].get("name", "") for j in gltf.get("skins", [{}])[0].get("joints", [])]
|
||||||
|
materials = [m.get("name", "") for m in gltf.get("materials", [])]
|
||||||
|
head_x: list[float] = []
|
||||||
|
head_y: list[float] = []
|
||||||
|
cloth_x: list[float] = []
|
||||||
|
hip_x: list[float] = []
|
||||||
|
for mesh in gltf.get("meshes", []):
|
||||||
|
for prim in mesh.get("primitives", []):
|
||||||
|
attrs = prim.get("attributes", {})
|
||||||
|
if "POSITION" not in attrs or "JOINTS_0" not in attrs or "WEIGHTS_0" not in attrs:
|
||||||
|
continue
|
||||||
|
mat_index = prim.get("material")
|
||||||
|
mat_name = materials[mat_index] if mat_index is not None and mat_index < len(materials) else ""
|
||||||
|
positions = _accessor(gltf, binary, attrs["POSITION"])
|
||||||
|
joint_rows = _accessor(gltf, binary, attrs["JOINTS_0"])
|
||||||
|
weight_rows = _accessor(gltf, binary, attrs["WEIGHTS_0"])
|
||||||
|
for pos, joint_row, weight_row in zip(positions, joint_rows, weight_rows):
|
||||||
|
strongest = max(range(4), key=lambda i: weight_row[i])
|
||||||
|
bone = joints[joint_row[strongest]] if joint_row[strongest] < len(joints) else ""
|
||||||
|
if (mat_name in FIT_HEAD_MATERIALS and bone == "Head"
|
||||||
|
and weight_row[strongest] > 0.5 and HEAD_BAND[0] <= pos[1] <= HEAD_BAND[1]):
|
||||||
|
head_x.append(pos[0])
|
||||||
|
head_y.append(pos[1])
|
||||||
|
if (mat_name == FIT_CLOTHING_MATERIAL
|
||||||
|
and FIT_CLOTHING_BAND[0] <= pos[1] <= FIT_CLOTHING_BAND[1]):
|
||||||
|
cloth_x.append(pos[0])
|
||||||
|
if (mat_name == "pants"
|
||||||
|
and FIT_HIP_BAND[0] <= pos[1] <= FIT_HIP_BAND[1]):
|
||||||
|
hip_x.append(pos[0])
|
||||||
|
skirt_radius = max(abs(x) for x in cloth_x) if cloth_x else 0.0
|
||||||
|
hip_radius = max(abs(x) for x in hip_x) if hip_x else 0.0
|
||||||
|
clothing_radius = max(skirt_radius, hip_radius)
|
||||||
|
return {
|
||||||
|
"head_width": round(max(head_x) - min(head_x), 3) if head_x else 0.0,
|
||||||
|
"head_y_min": round(min(head_y), 3) if head_y else 0.0,
|
||||||
|
"head_y_max": round(max(head_y), 3) if head_y else 0.0,
|
||||||
|
"clothing_radius": round(clothing_radius, 3),
|
||||||
|
"clothing_source": "skirt" if skirt_radius >= hip_radius and skirt_radius > 0 else "hip",
|
||||||
|
"head_band": HEAD_BAND,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _gear_fit(name, reference):
|
||||||
|
attach = GEAR.get(name)
|
||||||
|
if attach not in ("head", "back", "chest"):
|
||||||
|
return None
|
||||||
|
audit = audit_asset("npc_gear", name)
|
||||||
|
bounds = audit.get("bounds")
|
||||||
|
if not bounds:
|
||||||
|
return None
|
||||||
|
offset = ATTACH_GODOT[attach]
|
||||||
|
lo = [bounds["min"][i] + offset[i] for i in range(3)]
|
||||||
|
hi = [bounds["max"][i] + offset[i] for i in range(3)]
|
||||||
|
result = {
|
||||||
|
"name": name,
|
||||||
|
"attach": attach,
|
||||||
|
"min": [round(v, 3) for v in lo],
|
||||||
|
"max": [round(v, 3) for v in hi],
|
||||||
|
}
|
||||||
|
if attach == "head":
|
||||||
|
result.update({
|
||||||
|
"width": round(hi[0] - lo[0], 3),
|
||||||
|
"width_ratio": round((hi[0] - lo[0]) / reference["head_width"], 3)
|
||||||
|
if reference["head_width"] else 0.0,
|
||||||
|
"inside_band": lo[1] >= HEAD_BAND[0] - 0.005 and hi[1] <= HEAD_BAND[1] + 0.005,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# Compare the widest point (|x|) and the corner radial extent with the
|
||||||
|
# skirt flare. The radial value catches bags that sit diagonally out.
|
||||||
|
x_extent = max(abs(lo[0]), abs(hi[0]))
|
||||||
|
radial = max(math.hypot(x, z) for x in (lo[0], hi[0]) for z in (lo[2], hi[2]))
|
||||||
|
result.update({
|
||||||
|
"x_extent": round(x_extent, 3),
|
||||||
|
"radial": round(radial, 3),
|
||||||
|
"x_clearance": round(reference["clothing_radius"] - x_extent, 3),
|
||||||
|
"radial_clearance": round(reference["clothing_radius"] - radial, 3),
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def fit_report():
|
||||||
|
"""Return per-character gear fit measurements for the `fit` command/tests."""
|
||||||
|
report = []
|
||||||
|
for character, spec in CHARACTERS.items():
|
||||||
|
reference = character_fit_reference(character)
|
||||||
|
if reference is None:
|
||||||
|
continue
|
||||||
|
rows = []
|
||||||
|
for gear_name in spec.get("gear", []):
|
||||||
|
row = _gear_fit(gear_name, reference)
|
||||||
|
if row:
|
||||||
|
rows.append(row)
|
||||||
|
report.append({"character": character, "reference": reference, "gear": rows})
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
def write_manifest(results):
|
def write_manifest(results):
|
||||||
out = {"generator": "tools/blender_assets.py", "attach_points": GEAR_ATTACH,
|
out = {"generator": "tools/blender_assets.py", "attach_points": GEAR_ATTACH,
|
||||||
"assets": {}}
|
"assets": {}}
|
||||||
@@ -1735,6 +1855,8 @@ def main(argv=None):
|
|||||||
s.add_argument("--only", default="")
|
s.add_argument("--only", default="")
|
||||||
a = sub.add_parser("audit")
|
a = sub.add_parser("audit")
|
||||||
a.add_argument("--json", action="store_true")
|
a.add_argument("--json", action="store_true")
|
||||||
|
fit = sub.add_parser("fit")
|
||||||
|
fit.add_argument("--json", action="store_true")
|
||||||
sub.add_parser("sheet")
|
sub.add_parser("sheet")
|
||||||
sub.add_parser("list")
|
sub.add_parser("list")
|
||||||
args = ap.parse_args(raw)
|
args = ap.parse_args(raw)
|
||||||
@@ -1766,6 +1888,35 @@ def main(argv=None):
|
|||||||
if args.cmd == "sheet":
|
if args.cmd == "sheet":
|
||||||
contact_sheets()
|
contact_sheets()
|
||||||
return 0
|
return 0
|
||||||
|
if args.cmd == "fit":
|
||||||
|
report = fit_report()
|
||||||
|
if not report:
|
||||||
|
print("no built character assets; run build first", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(report, indent=2))
|
||||||
|
else:
|
||||||
|
for entry in report:
|
||||||
|
ref = entry["reference"]
|
||||||
|
print(f"{entry['character']:16s} head_width={ref['head_width']:.3f} m "
|
||||||
|
f"clothing_radius={ref['clothing_radius']:.3f} m "
|
||||||
|
f"({ref['clothing_source']})")
|
||||||
|
for row in entry["gear"]:
|
||||||
|
lo, hi = row["min"], row["max"]
|
||||||
|
box = (f"x=[{lo[0]:+.3f},{hi[0]:+.3f}] "
|
||||||
|
f"y=[{lo[1]:+.3f},{hi[1]:+.3f}] "
|
||||||
|
f"z=[{lo[2]:+.3f},{hi[2]:+.3f}]")
|
||||||
|
if row["attach"] == "head":
|
||||||
|
flag = "inside" if row["inside_band"] else "OUTSIDE"
|
||||||
|
print(f" {row['name']:20s} head {box} "
|
||||||
|
f"width={row['width']:.3f} ({row['width_ratio']:.2f}x head) "
|
||||||
|
f"band={flag}")
|
||||||
|
else:
|
||||||
|
clip = "CLIP" if row["x_clearance"] < 0 else "clear"
|
||||||
|
print(f" {row['name']:20s} {row['attach']:5s} {box} "
|
||||||
|
f"clearance x={row['x_clearance']:+.3f} "
|
||||||
|
f"r={row['radial_clearance']:+.3f} {clip}")
|
||||||
|
return 0
|
||||||
results = [audit_asset(c, n) for c, n in catalogue()]
|
results = [audit_asset(c, n) for c, n in catalogue()]
|
||||||
if args.json:
|
if args.json:
|
||||||
print(json.dumps(results, indent=2))
|
print(json.dumps(results, indent=2))
|
||||||
|
|||||||
@@ -45,6 +45,25 @@ class BuiltAssetTests(unittest.TestCase):
|
|||||||
bad[name] = result["issues"]
|
bad[name] = result["issues"]
|
||||||
self.assertEqual(bad, {})
|
self.assertEqual(bad, {})
|
||||||
|
|
||||||
|
def test_fit_report_measures_gear_against_body(self):
|
||||||
|
report = {entry["character"]: entry for entry in ba.fit_report()}
|
||||||
|
self.assertIn("npc_coat", report)
|
||||||
|
coat = report["npc_coat"]
|
||||||
|
self.assertAlmostEqual(coat["reference"]["head_width"], 0.315, places=2)
|
||||||
|
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"])
|
||||||
|
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)
|
||||||
|
for character in report.values():
|
||||||
|
for row in character["gear"]:
|
||||||
|
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)
|
||||||
|
|
||||||
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"))
|
||||||
self.assertEqual(set(manifest["assets"]), {n for _, n in ba.catalogue()})
|
self.assertEqual(set(manifest["assets"]), {n for _, n in ba.catalogue()})
|
||||||
|
|||||||
Reference in new issue
Block a user