Add a Blender GLB intake inspection command
Full build / Import + headless tests (push) Successful in 44s
Full build / Full desktop build + optional release (push) Skipped

This commit is contained in:
zwitschi committed 2026-11-03 10:08:45 +01:00
1 parent 6f3facff43
commit 5dc3ef0f4c
6 files changed
+510 -1

No files matched your search

+343
View File
@@ -12,6 +12,9 @@ integration: nothing in the game loads them yet (see
# Cycles preview renders -> docs/blender_previews/<asset>.png (local only)
flatpak run org.blender.Blender --background \
--python "$PWD/tools/blender_assets.py" -- render
# inspect an exported or external GLB exactly as Blender imports it
flatpak run org.blender.Blender --background \
--python "$PWD/tools/blender_assets.py" -- inspect --file some.glb
# outside Blender (stdlib + Pillow)
python3 tools/blender_assets.py audit # GLB contract checks
python3 tools/blender_assets.py fit # gear-vs-body fit measurements
@@ -1788,6 +1791,324 @@ def audit_asset(category, name):
return res
# ---------------------------------------------------------------------------
# Intake inspection (import a GLB the way Blender sees it)
# ---------------------------------------------------------------------------
# `audit` reads accessor-local bounds and ignores node transforms, so an
# external GLB can pass locally and sit metres away in the scene. `inspect`
# imports the file, applies node transforms and measures world space.
INSPECT_TRANSFORM_TOLERANCE = 0.005 # metres of local/world drift before reporting
INSPECT_CATEGORY_DIRS = {"npc": "npc", "npc_gear": "npc_gear", "props": "props",
"generated": "props", "blender": "external"}
def category_for_path(path) -> str:
"""Guess the contract family from a GLB path (npc, npc_gear, props, external)."""
p = Path(path)
stem = p.stem
parent = p.parent.name
if parent in INSPECT_CATEGORY_DIRS:
return INSPECT_CATEGORY_DIRS[parent]
if stem in CHARACTERS:
return "npc"
if stem in GEAR:
return "npc_gear"
if stem in PROPS:
return "props"
return "external"
def analyse_inspection(stats: dict) -> dict:
"""Pure contract analysis of `inspect_glb()` measurements (no Blender).
Hard contract failures land in `issues`; advisory findings in `warnings`.
The category gates the budget/rig checks; `external` assets only get the
generic checks so an unadapted download can be measured before intake.
"""
category = str(stats.get("category", "external"))
issues: list[str] = []
warnings: list[str] = []
tris = int(stats.get("triangles", 0))
world_verts = int(stats.get("split_vertices", 0))
meshes = list(stats.get("mesh_nodes", []))
bones = list(stats.get("bones", []))
materials = list(stats.get("materials", []))
bones_ok = {b for b in BONES if b in bones}
needed = set(BONES) | {"Hips", "Spine", "Neck"}
if not meshes:
issues.append("no mesh objects")
if tris <= 0:
issues.append("no triangles")
bounds = stats.get("world_bounds") or {}
lo, hi = bounds.get("min"), bounds.get("max")
if not lo or not hi:
issues.append("empty world bounds")
for value in (tris, world_verts, *(lo or []), *(hi or [])):
if isinstance(value, float) and not math.isfinite(value):
issues.append("non-finite measurement")
break
if category == "npc":
if "npc_base" not in meshes:
issues.append("no mesh node named npc_base")
for bone in sorted(needed - set(bones)):
issues.append(f"missing bone {bone}")
for slot in SLOTS:
if slot not in materials:
issues.append(f"missing material slot {slot}")
unskinned = list(stats.get("unskinned", []))
if not stats.get("skinned", False) or unskinned:
issues.append("mesh not skinned" + (f" ({', '.join(unskinned)})" if unskinned else ""))
if tris > CHAR_MAX_TRIS:
issues.append(f"triangles {tris} > {CHAR_MAX_TRIS}")
if world_verts > CHAR_MAX_VERTS:
issues.append(f"glTF vertices {world_verts} > {CHAR_MAX_VERTS}")
if lo and abs(float(lo[1])) > 0.03:
issues.append(f"feet not on the ground (min y {float(lo[1]):.3f})")
if hi and not (1.6 < float(hi[1]) < 2.1):
issues.append(f"unexpected height {float(hi[1]):.2f} m")
elif category == "npc_gear":
if tris > GEAR_MAX_TRIS:
issues.append(f"triangles {tris} > {GEAR_MAX_TRIS}")
elif category == "props":
if tris > PROP_MAX_TRIS:
issues.append(f"triangles {tris} > {PROP_MAX_TRIS}")
else:
warnings.append("external asset: no project budget or rig contract applied")
if int(stats.get("meshes_without_uv", 0)) > 0:
issues.append(f"{int(stats['meshes_without_uv'])} mesh(es) without UVs")
if int(stats.get("meshes_without_material", 0)) > 0:
warnings.append(f"{int(stats['meshes_without_material'])} mesh(es) without a material")
if int(stats.get("objects", 0)) > max(4, tris // 2000):
warnings.append(f"{stats['objects']} objects: merge before runtime use (draw calls)")
if float(stats.get("transform_delta", 0.0)) > INSPECT_TRANSFORM_TOLERANCE:
warnings.append(
"node transforms changed the pose by "
f"{float(stats['transform_delta']):.3f} m; apply transforms before export "
"(`audit` reads local bounds)")
if int(stats.get("animations", 0)) > 0:
warnings.append(f"{int(stats['animations'])} animation(s) imported; the runtime has "
"no clip for them")
if int(stats.get("images", 0)) == 0 and materials and category in ("npc", "external"):
# Library gear/props are intentionally solid-colour palette materials.
warnings.append("no images: materials are untextured")
return {
"ok": not issues,
"category": category,
"issues": issues,
"warnings": warnings,
"budget": {
"max_triangles": {"npc": CHAR_MAX_TRIS, "npc_gear": GEAR_MAX_TRIS,
"props": PROP_MAX_TRIS}.get(category),
"max_vertices": CHAR_MAX_VERTS if category == "npc" else None,
},
"bones_found": sorted(bones_ok),
"bones_missing": sorted(needed - set(bones)) if category == "npc" else [],
}
def inspect_glb(path, category=None) -> dict:
"""Import `path` into the current Blender session and measure it.
Node transforms are applied first, so bounds and bone rests match what the
scene would export rather than the accessor-local values `audit` reads.
"""
if bpy is None:
return {"ok": False, "error": "inspect must run inside Blender", "issues": [],
"warnings": [], "category": category or "external"}
file_path = Path(path).expanduser()
if not file_path.is_file():
return {"ok": False, "error": f"missing file {file_path}", "issues": [],
"warnings": [], "category": category or "external"}
reset_scene()
try:
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"}
armatures = [o for o in bpy.context.scene.objects if o.type == "ARMATURE"]
# The glTF importer adds a bone-display helper (an Icosphere used as
# pose_bone.custom_shape). It is not part of the asset, so never measure it.
helpers = {pb.custom_shape.name for arm in armatures for pb in arm.pose.bones
if pb.custom_shape is not None}
meshes = [o for o in bpy.context.scene.objects
if o.type == "MESH" and o.name not in helpers]
local = _bounds_of(meshes, world=False)
world = _bounds_of(meshes, world=True)
delta = _bounds_delta(local, world)
applied = _apply_imported_transforms(meshes, armatures)
if applied:
world = _bounds_of(meshes, world=True)
bones = [b.name for arm in armatures for b in arm.data.bones]
bone_heads = {b.name: _to_godot(arm.matrix_world @ b.head_local, digits=3)
for arm in armatures for b in arm.data.bones}
uv_layers = sorted({uv.name for o in meshes for uv in o.data.uv_layers})
objects = [{
"name": o.name,
"triangles": _triangle_count(o),
"vertices": len(o.data.vertices),
"split_vertices": _split_vertex_count(o),
"uv_layers": [uv.name for uv in o.data.uv_layers],
"materials": [s.material.name for s in o.material_slots if s.material],
"vertex_groups": len(o.vertex_groups),
"skinned": any(m.type == "ARMATURE" for m in o.modifiers),
} for o in meshes]
stats = {
"file": str(file_path),
"category": category or category_for_path(file_path),
"objects": len(meshes),
"mesh_nodes": [o.name for o in meshes],
"armatures": [o.name for o in armatures],
"helpers": sorted(helpers),
"bones": bones,
"bone_heads": bone_heads,
"materials": sorted({m for row in objects for m in row["materials"]}),
"images": len(bpy.data.images),
"uv_layers": uv_layers,
"meshes_without_uv": sum(1 for row in objects if not row["uv_layers"]),
"meshes_without_material": sum(1 for row in objects if not row["materials"]),
"unskinned": [row["name"] for row in objects if not row["skinned"]],
"skinned": any(row["skinned"] for row in objects),
"animations": len(bpy.data.actions),
"triangles": sum(row["triangles"] for row in objects),
"vertices": sum(row["vertices"] for row in objects),
"split_vertices": sum(row["split_vertices"] for row in objects),
"local_bounds": local,
"world_bounds": world,
"transform_delta": delta,
"transforms_applied": applied,
"detail": objects,
}
result = analyse_inspection(stats)
result.update({"stats": stats})
return result
def _to_godot(co, digits: int = 4) -> list:
"""Blender Z-up (x, y, z) -> Godot/glTF Y-up (x, z, -y) so reports match
the contract bounds, HEAD_BAND and the audit's coordinate space."""
return [round(float(co[0]), digits), round(float(co[2]), digits),
round(-float(co[1]), digits)]
def _bounds_of(objects, world: bool) -> dict:
"""Bounds in Godot coordinates (Y up, +Z front) for the given meshes."""
lo = [1e18] * 3
hi = [-1e18] * 3
found = False
for obj in objects:
matrix = obj.matrix_world if world else None
for vert in obj.data.vertices:
co = (matrix @ vert.co) if matrix is not None else vert.co
found = True
for k in range(3):
lo[k] = min(lo[k], float(co[k]))
hi[k] = max(hi[k], float(co[k]))
if not found:
return {"min": None, "max": None}
# Blender is Z-up; Godot is Y-up with +Z forward: (x, y, z) -> (x, z, -y).
return {
"min": [round(lo[0], 4), round(lo[2], 4), round(-hi[1], 4)],
"max": [round(hi[0], 4), round(hi[2], 4), round(-lo[1], 4)],
}
def _bounds_delta(a: dict, b: dict) -> float:
if not a.get("min") or not b.get("min"):
return 0.0
return round(max(abs(x - y) for pair in ((a["min"], b["min"]), (a["max"], b["max"]))
for x, y in zip(*pair)), 4)
def _triangle_count(obj) -> int:
mesh = obj.data
if hasattr(mesh, "calc_loop_triangles"):
mesh.calc_loop_triangles()
return len(mesh.loop_triangles)
return sum(len(poly.vertices) - 2 for poly in mesh.polygons)
def _split_vertex_count(obj) -> int:
"""Approximate the glTF vertex count: unique (vertex, uv, normal) per loop."""
mesh = obj.data
uv_layer = mesh.uv_layers.active
seen = set()
for poly in mesh.polygons:
for loop_index in poly.loop_indices:
loop = mesh.loops[loop_index]
uv = tuple(round(float(c), 4) for c in (uv_layer.data[loop_index].uv if uv_layer
else (0.0, 0.0)))
normal = tuple(round(float(c), 3) for c in loop.normal)
seen.add((loop.vertex_index, uv, normal))
return len(seen)
def _apply_imported_transforms(meshes, armatures) -> bool:
"""Clear parents and apply object transforms so measurements match export."""
if not meshes and not armatures:
return False
try:
bpy.ops.object.select_all(action="DESELECT")
for obj in meshes + armatures:
obj.select_set(True)
bpy.context.view_layer.objects.active = meshes[0] if meshes else armatures[0]
bpy.ops.object.parent_clear(type="CLEAR_KEEP_TRANSFORM")
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
return True
except (RuntimeError, AttributeError): # pragma: no cover - Blender only
return False
def print_inspection(result: dict) -> None:
stats = result.get("stats", {})
if result.get("error"):
print(f"INSPECT FAILED {stats.get('file', '')}\n - {result['error']}", file=sys.stderr)
return
world = stats.get("world_bounds") or {}
lo, hi = world.get("min"), world.get("max")
print(f"INSPECT {Path(stats['file']).name}")
print(f" category {result['category']}")
budget = result.get("budget", {})
print(f" triangles {stats.get('triangles', 0)}" +
(f" / {budget['max_triangles']}" if budget.get("max_triangles") else ""))
print(f" vertices {stats.get('vertices', 0)} raw, "
f"{stats.get('split_vertices', 0)} seam-split" +
(f" / {budget['max_vertices']}" if budget.get("max_vertices") else ""))
if lo and hi:
print(f" bounds (m) x=[{lo[0]:+.3f},{hi[0]:+.3f}] "
f"y=[{lo[1]:+.3f},{hi[1]:+.3f}] z=[{lo[2]:+.3f},{hi[2]:+.3f}]")
if result["category"] == "npc":
print(f" height / feet {hi[1]:.3f} m / {lo[1]:+.3f} m")
print(f" transforms {'applied' if stats.get('transforms_applied') else 'NOT applied'}"
f" (local/world delta {stats.get('transform_delta', 0.0):.3f} m)")
print(f" objects {stats.get('objects', 0)} mesh(es) "
f"[{', '.join(stats.get('mesh_nodes', [])) or 'none'}], "
f"{len(stats.get('armatures', []))} armature(s)"
+ (f", helpers ignored [{', '.join(stats['helpers'])}]" if stats.get("helpers") else ""))
print(f" bones {len(stats.get('bones', []))}"
+ (f": {', '.join(stats.get('bones', []))}" if stats.get("bones") else ""))
print(f" materials {len(stats.get('materials', []))}"
+ (f": {', '.join(stats.get('materials', []))}" if stats.get("materials") else ""))
print(f" uv layers {len(stats.get('uv_layers', []))}"
+ (f": {', '.join(stats.get('uv_layers', []))}" if stats.get("uv_layers") else ""))
print(f" skinning {'yes' if stats.get('skinned') else 'no'}"
f" (unskinned: {', '.join(stats.get('unskinned', [])) or 'none'})")
print(f" animations {stats.get('animations', 0)}")
print(f" issues {'none' if result['ok'] else str(len(result['issues']))}")
for issue in result["issues"]:
print(" - " + issue)
if result["warnings"]:
print(" warnings")
for warning in result["warnings"]:
print(" ! " + warning)
def character_fit_reference(name):
"""Measure a character's head width and hip/skirt radius from its GLB.
@@ -1990,10 +2311,32 @@ def main(argv=None):
a.add_argument("--json", action="store_true")
fit = sub.add_parser("fit")
fit.add_argument("--json", action="store_true")
insp = sub.add_parser("inspect", help="import a GLB and report its contract state")
insp.add_argument("--file", action="append", required=True,
help="GLB to inspect (repeatable; absolute path, it runs in Blender)")
insp.add_argument("--category", default="",
choices=["", "npc", "npc_gear", "props", "external"],
help="override the path-derived contract category")
insp.add_argument("--json", action="store_true")
sub.add_parser("sheet")
sub.add_parser("list")
args = ap.parse_args(raw)
if args.cmd == "inspect":
if bpy is None:
print("inspect must run inside Blender:\n flatpak run org.blender.Blender "
f"--background --python {Path(__file__).resolve()} -- inspect "
"--file <path.glb>", file=sys.stderr)
return 2
results = [inspect_glb(f, args.category or None) for f in args.file]
if args.json:
print(json.dumps(results, indent=2))
else:
for result in results:
print_inspection(result)
ok = all(r.get("ok", False) for r in results)
return 0 if ok else 1
if args.cmd in ("build", "render"):
if bpy is None:
print(f"{args.cmd} must run inside Blender:\n flatpak run org.blender.Blender "