From 5dc3ef0f4c0a95fadcf0da1d8b24e6fa8973a934 Mon Sep 17 00:00:00 2001 From: Zwitschi Date: Tue, 3 Nov 2026 10:08:45 +0100 Subject: [PATCH] Add a Blender GLB intake inspection command --- MEMORY.md | 10 + TODO.md | 8 +- docs/asset-tooling.md | 42 +++++ docs/blender-assets.md | 3 + tools/blender_assets.py | 343 +++++++++++++++++++++++++++++++++++ tools/test_blender_assets.py | 105 +++++++++++ 6 files changed, 510 insertions(+), 1 deletion(-) diff --git a/MEMORY.md b/MEMORY.md index 2eecad9..28ec2d8 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -4,6 +4,16 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o ## Decisions +- **GLB intake inspection (`blender_assets.py inspect`, 2026-11-03):** runs inside Blender, clears + parents and applies object transforms, then reports tris/budget, raw and seam-split verts, + Godot-space bounds (height/feet for characters), bones, materials, UVs, skinning and + animations as `issues` (contract failures, exit 1) or `warnings`. `--json`/`--category` exist + for intake scripts. Gotchas: the glTF importer creates an `Icosphere` mesh used as + `pose_bone.custom_shape`, which must be excluded or it dominates bounds; a parented import + needs `parent_clear(CLEAR_KEEP_TRANSFORM)` before `transform_apply`; Blender is Z-up so bounds + and bone rests are converted to `(x, z, -y)`. On project assets its counts match `audit` + exactly, but `audit` stays the stdlib/CI release gate and `inspect` never adapts a file. + - **Blender Skills Pack installed locally (2026-11-03):** the MIT `blender-skills` pack (v1.3.0, 94 skills + a shared `references/` folder) is installed at `.agents/skills/blender-skills/` for Blender asset work. `.agents/` is gitignored and diff --git a/TODO.md b/TODO.md index ba87985..b14919e 100644 --- a/TODO.md +++ b/TODO.md @@ -36,8 +36,14 @@ development pipeline. This is the current development priority; keep it out of t ### Intake and validation -- [ ] Add a Blender `inspect` path that imports a GLB, applies node transforms, reports +- [x] Add a Blender `inspect` path that imports a GLB, applies node transforms, reports tris/verts, bounds, bones, materials, UVs and skinning, and lists contract failures. + (`blender_assets.py inspect --file ...` applies parent/object transforms, converts to + Godot Y-up, ignores the importer's bone-shape helper and prints budgets, bones, slots, + UVs, skinning and animations with issues/warnings; `--json` and `--category` supported. + Seven synthetic contract tests added (Python 108/108); all 46 library GLBs inspect clean, + and a wrapped 2x fixture correctly reported a 0.975 m local/world delta. Blender-side only; + `audit` remains the final gate.) - [ ] Add an `adapt` path for future external assets: scale/ground, weld seams, decimate, map the five tint slots, and either rebuild the contract armature or record an explicit second-rig contract. The Quaternius prototype showed that a contract rebuild needs a proportion warp, diff --git a/docs/asset-tooling.md b/docs/asset-tooling.md index a00d6a4..4379861 100644 --- a/docs/asset-tooling.md +++ b/docs/asset-tooling.md @@ -143,9 +143,51 @@ flatpak run org.blender.Blender --background --python "$PWD/tools/blender_assets flatpak run org.blender.Blender --background --python "$PWD/tools/blender_assets.py" -- render python3 tools/blender_assets.py sheet # docs/blender_previews/sheet_*.jpg python3 tools/blender_assets.py audit # no Blender needed +python3 tools/blender_assets.py fit # gear-vs-body fit report godot --headless --path . --import ``` +### Intake inspection (`inspect`) + +`audit` reads accessor-local bounds and ignores node transforms, so it is the right +release gate but a poor first look at a downloaded or newly authored GLB. `inspect` +imports the file into Blender, clears parents and applies object transforms, then +reports what the scene actually contains: + +```bash +# one asset, a batch, or an external download; repeat --file as needed +flatpak run org.blender.Blender --background \ + --python "$PWD/tools/blender_assets.py" -- inspect \ + --file "$PWD/assets/models/blender/npc/npc_casual.glb" \ + --file "$PWD/tmp/downloads/SomeCharacter.glb" +# machine-readable, for intake scripts +... -- inspect --file "$PWD/tmp/x.glb" --json +# force a contract family instead of inferring it from the path +... -- inspect --file "$PWD/tmp/x.glb" --category npc +``` + +It prints triangles (with the category budget), raw and seam-split vertex counts, +Godot-space bounds with height/feet for characters, whether transforms had to be +applied, mesh nodes and armatures, bone names, materials, UV layers, skinning and +animation count. Hard contract failures (`issues`) fail the command; advisory +findings (`warnings`) do not. The category is inferred from the path: +`blender/npc` → `npc`, `blender/npc_gear` → `npc_gear`, `blender/props` and +`generated` → `props`, anything else → `external` (measured but with no project +budget or rig contract). + +Caveats: + +- The glTF importer adds a bone-display mesh (`Icosphere`, used as + `pose_bone.custom_shape`). `inspect` excludes custom-shape helpers so they never + pollute bounds or the unskinned list. +- Bounds and bone rests are converted from Blender Z-up to Godot Y-up (`x, z, -y`), + matching the contract bands and the audit's coordinate space. +- Numbers are Blender-side measurements. They agree with `audit` on project assets + (same triangle and glTF vertex counts), but `audit` stays the single final gate: + it is stdlib-only, runs in CI, and matches what the engine imports. +- `inspect` never writes or adapts the file; scale/grounding/weld/retarget work is the + separate (not yet built) `adapt` step. + ## Local map development (planned) The **Local map development** track in [`TODO.md`](../TODO.md#local-map-development) diff --git a/docs/blender-assets.md b/docs/blender-assets.md index 963677a..c329421 100644 --- a/docs/blender-assets.md +++ b/docs/blender-assets.md @@ -48,6 +48,9 @@ python3 tools/blender_assets.py sheet # -> docs/blender_previews/sheet_ docs/blender_previews/.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 ", 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 " diff --git a/tools/test_blender_assets.py b/tools/test_blender_assets.py index 019f90c..d90cbe2 100644 --- a/tools/test_blender_assets.py +++ b/tools/test_blender_assets.py @@ -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()