Add a Blender GLB intake inspection command
This commit is contained in:
1 parent
6f3facff43
commit
5dc3ef0f4c
6 files changed
+510
-1
No files matched your search
@@ -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 "
|
||||
|
||||
@@ -95,5 +95,110 @@ class BuiltAssetTests(unittest.TestCase):
|
||||
self.assertTrue((ba.SRC_DIR / ".gdignore").is_file())
|
||||
|
||||
|
||||
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."""
|
||||
|
||||
def stats(self, category="npc", **overrides):
|
||||
base = {
|
||||
"file": "/tmp/synthetic.glb",
|
||||
"category": category,
|
||||
"objects": 1,
|
||||
"mesh_nodes": ["npc_base"] if category == "npc" else ["prop"],
|
||||
"armatures": ["Armature"] if category == "npc" else [],
|
||||
"helpers": [],
|
||||
"bones": list(ba.BONES) + ["Hips", "Spine", "Neck"],
|
||||
"bone_heads": {},
|
||||
"materials": list(ba.SLOTS),
|
||||
"images": 5,
|
||||
"uv_layers": ["UVMap"],
|
||||
"meshes_without_uv": 0,
|
||||
"meshes_without_material": 0,
|
||||
"unskinned": [],
|
||||
"skinned": category == "npc",
|
||||
"animations": 0,
|
||||
"triangles": 4500,
|
||||
"vertices": 4200,
|
||||
"split_vertices": 4400,
|
||||
"local_bounds": {"min": [-0.3, 0.0, -0.2], "max": [0.3, 1.82, 0.2]},
|
||||
"world_bounds": {"min": [-0.3, 0.0, -0.2], "max": [0.3, 1.82, 0.2]},
|
||||
"transform_delta": 0.0,
|
||||
"transforms_applied": True,
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
def test_clean_character_passes(self):
|
||||
result = ba.analyse_inspection(self.stats())
|
||||
self.assertTrue(result["ok"], result["issues"])
|
||||
self.assertEqual(result["bones_missing"], [])
|
||||
self.assertEqual(result["budget"]["max_triangles"], ba.CHAR_MAX_TRIS)
|
||||
|
||||
def test_character_contract_failures_are_listed(self):
|
||||
stats = self.stats(mesh_nodes=["Armature"], skinned=False, unskinned=["Armature"],
|
||||
materials=["cloth"], bones=["Head"], meshes_without_uv=1,
|
||||
triangles=9000, split_vertices=9000,
|
||||
world_bounds={"min": [-0.3, -0.4, -0.2], "max": [0.3, 1.2, 0.2]})
|
||||
issues = "\n".join(ba.analyse_inspection(stats)["issues"])
|
||||
self.assertIn("no mesh node named npc_base", issues)
|
||||
self.assertIn("missing bone HipL", issues)
|
||||
self.assertIn("missing material slot skin", issues)
|
||||
self.assertIn("mesh not skinned", issues)
|
||||
self.assertIn("mesh(es) without UVs", issues)
|
||||
self.assertIn(f"triangles 9000 > {ba.CHAR_MAX_TRIS}", issues)
|
||||
self.assertIn(f"glTF vertices 9000 > {ba.CHAR_MAX_VERTS}", issues)
|
||||
self.assertIn("feet not on the ground", issues)
|
||||
self.assertIn("unexpected height", issues)
|
||||
|
||||
def test_budget_gates_follow_the_category(self):
|
||||
gear = ba.analyse_inspection(self.stats("npc_gear", mesh_nodes=["cap"],
|
||||
triangles=ba.GEAR_MAX_TRIS + 1))
|
||||
self.assertIn(f"triangles {ba.GEAR_MAX_TRIS + 1} > {ba.GEAR_MAX_TRIS}",
|
||||
"\n".join(gear["issues"]))
|
||||
prop = ba.analyse_inspection(self.stats("props", mesh_nodes=["bollard"],
|
||||
triangles=ba.PROP_MAX_TRIS + 1))
|
||||
self.assertIn(f"triangles {ba.PROP_MAX_TRIS + 1} > {ba.PROP_MAX_TRIS}",
|
||||
"\n".join(prop["issues"]))
|
||||
external = ba.analyse_inspection(self.stats("external", mesh_nodes=["body"],
|
||||
materials=[], skinned=False,
|
||||
triangles=999_999,
|
||||
world_bounds={"min": [-3.0, -1.0, -3.0],
|
||||
"max": [3.0, 9.0, 3.0]}))
|
||||
self.assertTrue(external["ok"], external["issues"])
|
||||
self.assertIsNone(external["budget"]["max_triangles"])
|
||||
self.assertTrue(any("no project budget" in w for w in external["warnings"]))
|
||||
|
||||
def test_unapplied_transforms_are_reported_not_failed(self):
|
||||
result = ba.analyse_inspection(self.stats(transform_delta=1.9))
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertTrue(any("node transforms changed the pose" in w
|
||||
for w in result["warnings"]))
|
||||
|
||||
def test_missing_mesh_or_bounds_is_a_failure(self):
|
||||
result = ba.analyse_inspection(self.stats(mesh_nodes=[], objects=0, triangles=0,
|
||||
world_bounds={"min": None, "max": None}))
|
||||
self.assertFalse(result["ok"])
|
||||
self.assertIn("no mesh objects", result["issues"])
|
||||
self.assertIn("no triangles", result["issues"])
|
||||
self.assertIn("empty world bounds", result["issues"])
|
||||
|
||||
def test_category_inference_from_path(self):
|
||||
self.assertEqual(ba.category_for_path("assets/models/blender/npc/npc_casual.glb"), "npc")
|
||||
self.assertEqual(ba.category_for_path("assets/models/blender/npc_gear/cap.glb"),
|
||||
"npc_gear")
|
||||
self.assertEqual(ba.category_for_path("assets/models/blender/props/bollard.glb"),
|
||||
"props")
|
||||
self.assertEqual(ba.category_for_path("assets/models/generated/bollard.glb"), "props")
|
||||
self.assertEqual(ba.category_for_path("tmp/quaternius/Worker.glb"), "external")
|
||||
|
||||
def test_inspect_requires_blender(self):
|
||||
# The stdlib test run has no bpy; inspect must fail cleanly, not crash.
|
||||
if ba.bpy is not None: # pragma: no cover - only inside Blender
|
||||
self.skipTest("running inside Blender")
|
||||
result = ba.inspect_glb("does-not-exist.glb")
|
||||
self.assertFalse(result["ok"])
|
||||
self.assertIn("inside Blender", result["error"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user