Measure the 3D library and open the model review scorecard
This commit is contained in:
1 parent
2443343f2b
commit
2d67e14e52
8 files changed
+413
-13
No files matched your search
+124
-2
@@ -35,6 +35,8 @@ Conventions (Blender is Z-up; the glTF exporter converts to Godot Y-up):
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
import struct
|
||||
@@ -1791,6 +1793,89 @@ def audit_asset(category, name):
|
||||
return res
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Texel density (UV area vs world area, per material slot)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# The NPC slot maps are 256² (`npc_pipeline.TEX_SIZE`); cloth and pants repeat
|
||||
# the authored UVs 4× at runtime via `Materials.NPC_FABRIC_UV_SCALE`, so their
|
||||
# effective density is four times the authored number.
|
||||
TEXEL_RESOLUTION = 256
|
||||
TEXEL_RUNTIME_UV_SCALE = {"cloth": 4.0, "pants": 4.0}
|
||||
|
||||
|
||||
def _tri_area3(a, b, c) -> float:
|
||||
ux, uy, uz = b[0] - a[0], b[1] - a[1], b[2] - a[2]
|
||||
vx, vy, vz = c[0] - a[0], c[1] - a[1], c[2] - a[2]
|
||||
cx = uy * vz - uz * vy
|
||||
cy = uz * vx - ux * vz
|
||||
cz = ux * vy - uy * vx
|
||||
return 0.5 * math.sqrt(cx * cx + cy * cy + cz * cz)
|
||||
|
||||
|
||||
def _tri_area2(a, b, c) -> float:
|
||||
return 0.5 * abs((b[0] - a[0]) * (c[1] - a[1]) - (c[0] - a[0]) * (b[1] - a[1]))
|
||||
|
||||
|
||||
def texel_density(name: str, path: Path | None = None) -> dict | None:
|
||||
"""Per-slot texel density (px/m) for a character GLB.
|
||||
|
||||
texel density = resolution * sqrt(UV area / world surface area), so the
|
||||
number is independent of how the island is packed. Effective density also
|
||||
applies the runtime UV repeat for cloth/pants.
|
||||
"""
|
||||
path = path or 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
|
||||
materials = [m.get("name", "") for m in gltf.get("materials", [])]
|
||||
slots: dict[str, dict] = {}
|
||||
for mesh in gltf.get("meshes", []):
|
||||
for prim in mesh.get("primitives", []):
|
||||
attrs = prim.get("attributes", {})
|
||||
if ("POSITION" not in attrs or "TEXCOORD_0" not in attrs
|
||||
or prim.get("indices") is None):
|
||||
continue
|
||||
mat_index = prim.get("material")
|
||||
slot = materials[mat_index] if mat_index is not None and mat_index < len(materials) else ""
|
||||
positions = _accessor(gltf, binary, attrs["POSITION"])
|
||||
uvs = _accessor(gltf, binary, attrs["TEXCOORD_0"])
|
||||
indices = [row[0] for row in _accessor(gltf, binary, prim["indices"])]
|
||||
row = slots.setdefault(slot or "unassigned",
|
||||
{"triangles": 0, "world_area_m2": 0.0, "uv_area": 0.0})
|
||||
for k in range(0, len(indices) - 2, 3):
|
||||
i0, i1, i2 = indices[k], indices[k + 1], indices[k + 2]
|
||||
if max(i0, i1, i2) >= len(positions) or max(i0, i1, i2) >= len(uvs):
|
||||
continue
|
||||
row["world_area_m2"] += _tri_area3(positions[i0], positions[i1], positions[i2])
|
||||
row["uv_area"] += _tri_area2(uvs[i0], uvs[i1], uvs[i2])
|
||||
row["triangles"] += 1
|
||||
for slot, row in slots.items():
|
||||
world = row["world_area_m2"]
|
||||
uv = row["uv_area"]
|
||||
authored = TEXEL_RESOLUTION * math.sqrt(uv / world) if world > 0.0 else 0.0
|
||||
row["td_px_per_m"] = round(authored, 1)
|
||||
row["td_runtime_px_per_m"] = round(authored * TEXEL_RUNTIME_UV_SCALE.get(slot, 1.0), 1)
|
||||
row["world_area_m2"] = round(world, 3)
|
||||
row["uv_area"] = round(uv, 4)
|
||||
values = [row["td_px_per_m"] for row in slots.values() if row["td_px_per_m"] > 0.0]
|
||||
return {
|
||||
"name": name,
|
||||
"resolution": TEXEL_RESOLUTION,
|
||||
"slots": slots,
|
||||
"min_td": min(values) if values else 0.0,
|
||||
"max_td": max(values) if values else 0.0,
|
||||
"total_world_area_m2": round(sum(r["world_area_m2"] for r in slots.values()), 3),
|
||||
}
|
||||
|
||||
|
||||
def texel_report() -> list[dict]:
|
||||
return [row for row in (texel_density(name) for name in CHARACTERS) if row]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Intake inspection (import a GLB the way Blender sees it)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1925,8 +2010,12 @@ def inspect_glb(path, category=None) -> dict:
|
||||
return {"ok": False, "error": f"missing file {file_path}", "issues": [],
|
||||
"warnings": [], "category": category or "external"}
|
||||
reset_scene()
|
||||
# The glTF importer logs per-mesh INFO lines to stdout, which would corrupt
|
||||
# `--json` output; keep the log in stats instead of printing it.
|
||||
import_log = io.StringIO()
|
||||
try:
|
||||
bpy.ops.import_scene.gltf(filepath=str(file_path))
|
||||
with contextlib.redirect_stdout(import_log), contextlib.redirect_stderr(import_log):
|
||||
bpy.ops.import_scene.gltf(filepath=str(file_path))
|
||||
except RuntimeError as exc:
|
||||
return {"ok": False, "error": f"import failed: {exc}", "issues": [],
|
||||
"warnings": [], "category": category or "external"}
|
||||
@@ -1983,6 +2072,7 @@ def inspect_glb(path, category=None) -> dict:
|
||||
"world_bounds": world,
|
||||
"transform_delta": delta,
|
||||
"transforms_applied": applied,
|
||||
"import_log": import_log.getvalue().strip().splitlines()[-3:],
|
||||
"detail": objects,
|
||||
}
|
||||
result = analyse_inspection(stats)
|
||||
@@ -2318,10 +2408,35 @@ def main(argv=None):
|
||||
choices=["", "npc", "npc_gear", "props", "external"],
|
||||
help="override the path-derived contract category")
|
||||
insp.add_argument("--json", action="store_true")
|
||||
insp.add_argument("--out", default="",
|
||||
help="with --json, write to this file (Blender prints a startup banner)")
|
||||
tex = sub.add_parser("texels", help="per-slot texel density of the character GLBs")
|
||||
tex.add_argument("--only", default="")
|
||||
tex.add_argument("--json", action="store_true")
|
||||
sub.add_parser("sheet")
|
||||
sub.add_parser("list")
|
||||
args = ap.parse_args(raw)
|
||||
|
||||
if args.cmd == "texels":
|
||||
names = [n.strip() for n in args.only.split(",") if n.strip()] or list(CHARACTERS)
|
||||
report = [row for row in (texel_density(n) for n in names) if row]
|
||||
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:
|
||||
print(f"{entry['name']:16s} {entry['resolution']}px maps "
|
||||
f"area={entry['total_world_area_m2']:.2f} m2 "
|
||||
f"td={entry['min_td']:.0f}-{entry['max_td']:.0f} px/m")
|
||||
for slot, row in sorted(entry["slots"].items()):
|
||||
note = " (runtime x%.0f)" % TEXEL_RUNTIME_UV_SCALE[slot] \
|
||||
if slot in TEXEL_RUNTIME_UV_SCALE else ""
|
||||
print(f" {slot:12s} tris={row['triangles']:5d} area={row['world_area_m2']:5.2f} m2 "
|
||||
f"uv={row['uv_area']:.3f} td={row['td_px_per_m']:6.0f} "
|
||||
f"effective={row['td_runtime_px_per_m']:6.0f} px/m{note}")
|
||||
return 0
|
||||
if args.cmd == "inspect":
|
||||
if bpy is None:
|
||||
print("inspect must run inside Blender:\n flatpak run org.blender.Blender "
|
||||
@@ -2330,7 +2445,14 @@ def main(argv=None):
|
||||
return 2
|
||||
results = [inspect_glb(f, args.category or None) for f in args.file]
|
||||
if args.json:
|
||||
print(json.dumps(results, indent=2))
|
||||
payload = json.dumps(results, indent=2)
|
||||
if args.out:
|
||||
Path(args.out).write_text(payload + "\n", encoding="utf-8")
|
||||
print(f"wrote {args.out}")
|
||||
else:
|
||||
# Blender prints its version banner before this script runs, so
|
||||
# scripts should prefer --out for machine-readable output.
|
||||
print(payload)
|
||||
else:
|
||||
for result in results:
|
||||
print_inspection(result)
|
||||
|
||||
@@ -95,6 +95,37 @@ class BuiltAssetTests(unittest.TestCase):
|
||||
self.assertTrue((ba.SRC_DIR / ".gdignore").is_file())
|
||||
|
||||
|
||||
@unittest.skipUnless(ba.MANIFEST.is_file(), "Blender assets not built")
|
||||
class TexelDensityTests(unittest.TestCase):
|
||||
def test_character_slots_report_density_and_area(self):
|
||||
report = ba.texel_density("npc_casual")
|
||||
self.assertEqual(report["resolution"], 256)
|
||||
self.assertEqual(set(report["slots"]), set(ba.SLOTS) | {"eye_white", "rubber"})
|
||||
for slot in ba.SLOTS:
|
||||
row = report["slots"][slot]
|
||||
self.assertGreater(row["triangles"], 0, slot)
|
||||
self.assertGreater(row["world_area_m2"], 0.0, slot)
|
||||
self.assertGreater(row["td_px_per_m"], 30.0, slot)
|
||||
self.assertGreater(report["total_world_area_m2"], 1.0)
|
||||
self.assertLess(report["total_world_area_m2"], 6.0)
|
||||
|
||||
def test_fabric_slots_apply_the_runtime_uv_repeat(self):
|
||||
report = ba.texel_density("npc_casual")
|
||||
for slot, factor in ba.TEXEL_RUNTIME_UV_SCALE.items():
|
||||
row = report["slots"][slot]
|
||||
self.assertAlmostEqual(row["td_runtime_px_per_m"],
|
||||
row["td_px_per_m"] * factor, places=0)
|
||||
skin = report["slots"]["skin"]
|
||||
self.assertEqual(skin["td_px_per_m"], skin["td_runtime_px_per_m"])
|
||||
|
||||
def test_base_fallback_is_measurable_from_its_own_path(self):
|
||||
from pathlib import Path
|
||||
path = ba.ROOT / "assets" / "models" / "generated" / "npc_base.glb"
|
||||
report = ba.texel_density("npc_base", path)
|
||||
self.assertIsNotNone(report)
|
||||
self.assertGreater(report["min_td"], 30.0)
|
||||
|
||||
|
||||
class InspectionTests(unittest.TestCase):
|
||||
"""`inspect` reports on imported GLBs; the contract logic is pure and tested
|
||||
here with synthetic measurements so Blender is not a test dependency."""
|
||||
|
||||
Reference in new issue
Block a user