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
@@ -4,6 +4,16 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
|||||||
|
|
||||||
## Decisions
|
## 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
|
- **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
|
(v1.3.0, 94 skills + a shared `references/` folder) is installed at
|
||||||
`.agents/skills/blender-skills/` for Blender asset work. `.agents/` is gitignored and
|
`.agents/skills/blender-skills/` for Blender asset work. `.agents/` is gitignored and
|
||||||
|
|||||||
@@ -36,8 +36,14 @@ development pipeline. This is the current development priority; keep it out of t
|
|||||||
|
|
||||||
### Intake and validation
|
### 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.
|
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
|
- [ ] 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
|
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,
|
contract. The Quaternius prototype showed that a contract rebuild needs a proportion warp,
|
||||||
|
|||||||
@@ -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
|
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 sheet # docs/blender_previews/sheet_*.jpg
|
||||||
python3 tools/blender_assets.py audit # no Blender needed
|
python3 tools/blender_assets.py audit # no Blender needed
|
||||||
|
python3 tools/blender_assets.py fit # gear-vs-body fit report
|
||||||
godot --headless --path . --import
|
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)
|
## Local map development (planned)
|
||||||
|
|
||||||
The **Local map development** track in [`TODO.md`](../TODO.md#local-map-development)
|
The **Local map development** track in [`TODO.md`](../TODO.md#local-map-development)
|
||||||
|
|||||||
@@ -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 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 fit # head/clothing fit + bag-body clearance, per character
|
||||||
python3 tools/blender_assets.py list
|
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
|
godot --headless --path . --import # after any rebuild
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ integration: nothing in the game loads them yet (see
|
|||||||
# Cycles preview renders -> docs/blender_previews/<asset>.png (local only)
|
# Cycles preview renders -> docs/blender_previews/<asset>.png (local only)
|
||||||
flatpak run org.blender.Blender --background \
|
flatpak run org.blender.Blender --background \
|
||||||
--python "$PWD/tools/blender_assets.py" -- render
|
--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)
|
# outside Blender (stdlib + Pillow)
|
||||||
python3 tools/blender_assets.py audit # GLB contract checks
|
python3 tools/blender_assets.py audit # GLB contract checks
|
||||||
python3 tools/blender_assets.py fit # gear-vs-body fit measurements
|
python3 tools/blender_assets.py fit # gear-vs-body fit measurements
|
||||||
@@ -1788,6 +1791,324 @@ def audit_asset(category, name):
|
|||||||
return res
|
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):
|
def character_fit_reference(name):
|
||||||
"""Measure a character's head width and hip/skirt radius from its GLB.
|
"""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")
|
a.add_argument("--json", action="store_true")
|
||||||
fit = sub.add_parser("fit")
|
fit = sub.add_parser("fit")
|
||||||
fit.add_argument("--json", action="store_true")
|
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("sheet")
|
||||||
sub.add_parser("list")
|
sub.add_parser("list")
|
||||||
args = ap.parse_args(raw)
|
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 args.cmd in ("build", "render"):
|
||||||
if bpy is None:
|
if bpy is None:
|
||||||
print(f"{args.cmd} must run inside Blender:\n flatpak run org.blender.Blender "
|
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())
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
Reference in new issue
Block a user