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

+10
View File
@@ -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
+7 -1
View File
@@ -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,
+42
View File
@@ -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)
+3
View File
@@ -48,6 +48,9 @@ python3 tools/blender_assets.py sheet # -> docs/blender_previews/sheet_<catego
python3 tools/blender_assets.py audit # stdlib only; exit 1 on any issue
python3 tools/blender_assets.py fit # head/clothing fit + bag-body clearance, per character
python3 tools/blender_assets.py list
# import a GLB, apply node transforms, report geometry/bones/UVs/skinning + contract failures
flatpak run org.blender.Blender --background \
--python "$PWD/tools/blender_assets.py" -- inspect --file "$PWD/tmp/downloads/x.glb"
godot --headless --path . --import # after any rebuild
```
+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 "
+105
View File
@@ -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()