From c4369ae30085aa5e3076913062424595a24e9b34 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Thu, 10 Sep 2026 22:45:49 -0400 Subject: [PATCH 1/6] docs: add the AI asset pipeline as a multi-version track Four sub-tracks (cleanup skills, engine export presets, headless template, live-session spike) listed as upcoming and unpinned, matching existing cadence. Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-authored-by: Cursor --- ROADMAP.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 5e1497f..8393d73 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -18,6 +18,10 @@ derives the actual version from conventional-commit types. | 5.2 LTS targeting, GN modifier inputs | 12 | 6 | 2 | 17 | Shipped | | VSE COLOR strip intrinsic size (undocumented 5.2) | 13 | 6 | 2 | 17 | Shipped | | Modal operators, USD, mathutils | — | — | — | — | Upcoming | +| AI asset pipeline: post-generation cleanup | - | - | - | - | Upcoming | +| AI asset pipeline: engine export presets | - | - | - | - | Upcoming | +| AI asset pipeline: headless template | - | - | - | - | Upcoming | +| AI asset pipeline: live-session bridge (spike) | - | - | - | - | Upcoming | | Stable | — | — | — | — | Upcoming | ## v0.1.0 - Foundation @@ -87,6 +91,15 @@ The 7 new snippets: Audit pass on v0.1.0 content: standards-version markers bumped from `1.9.1` to `1.9.4` across all skills, rules, AGENTS.md, CLAUDE.md, and ROADMAP.md. Verified the `bpy_extras.anim_utils.action_ensure_channelbag_for_slot` import path against the current Blender 5.1 API reference and removed the stale "verify before production" caveat in `slotted-actions-animation/SKILL.md`. +## AI asset pipeline track + +Provider-agnostic GLB-in / engine-ready-out. This repo does not generate meshes. + +- **Post-generation cleanup skills** (this phase starts the family; bake/UV/atlas follow on): import and unit-scale normalization, transform apply and origin, poly-budget decimate, LOD chain, collision mesh, high-to-low bake, UV transfer and atlas packing. Phase 1: `ai-mesh-cleanup`, four snippets, two rules. +- **Engine export presets.** Unity (Y-up), Godot, and Unreal (centimeter scale) glTF and FBX paths with Draco. One skill, one snippet set. +- **`ai-asset-pipeline-template/`.** Third template. Headless: GLB path in; LOD set, convex collider, engine-preset export; explicit CI exit codes. Pattern: `templates/headless-batch-script-template/`. Phase 2. +- **Live-session agent bridge.** Research spike, not a committed deliverable. MCP server or socket listener so an agent can execute against a running Blender instance instead of blind `--background` scripts. Built on `templates/extension-addon-template/`. Needs its own design pass. + ## Candidate pool (next content) Not committed; target list for the next content version. (v0.3.0 shipped the smoke-gated `examples/` track.) From 2c243dc45baf228bc25c26ee3f24fe3099683625 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Thu, 10 Sep 2026 22:46:07 -0400 Subject: [PATCH 2/6] feat: add decimate, collider, LOD, and Draco glTF snippets Standalone helpers for evaluated triangle budgets, convex hull colliders, LOD chains, and Draco-compressed glTF export. LOD duplicates the decimate helper because snippets are not a package. calc_loop_triangles is always called; tessellation is still explicit on 4.5 LTS and 5.x. Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-authored-by: Cursor --- snippets/convex_hull_collider.py | 43 +++++++++++++++++++++++++ snippets/decimate_to_budget.py | 44 +++++++++++++++++++++++++ snippets/gltf_draco_export.py | 31 ++++++++++++++++++ snippets/lod_chain.py | 55 ++++++++++++++++++++++++++++++++ 4 files changed, 173 insertions(+) create mode 100644 snippets/convex_hull_collider.py create mode 100644 snippets/decimate_to_budget.py create mode 100644 snippets/gltf_draco_export.py create mode 100644 snippets/lod_chain.py diff --git a/snippets/convex_hull_collider.py b/snippets/convex_hull_collider.py new file mode 100644 index 0000000..ab89ea4 --- /dev/null +++ b/snippets/convex_hull_collider.py @@ -0,0 +1,43 @@ +# Build a convex hull collision mesh via bmesh.ops.convex_hull. +# Delete geom_interior and geom_unused from the result dict so leftover +# input verts do not remain inside the hull. Copy matrix_world onto +# the collider so it sits on the source object. +# +# bmesh.new() must be paired with bm.free() in try/finally. +# +# Reference: +# https://docs.blender.org/api/current/bmesh.ops.html#bmesh.ops.convex_hull +# https://docs.blender.org/api/current/bmesh.html + +import bmesh +import bpy + + +def convex_hull_collider(obj, name=None): + mesh = bpy.data.meshes.new(name or f"{obj.name}_Collider") + bm = bmesh.new() + try: + bm.from_mesh(obj.data) + result = bmesh.ops.convex_hull(bm, input=bm.verts) + interior = result.get("geom_interior") or [] + unused = result.get("geom_unused") or [] + if interior: + bmesh.ops.delete(bm, geom=interior, context="VERTS") + if unused: + bmesh.ops.delete(bm, geom=unused, context="VERTS") + bm.to_mesh(mesh) + mesh.update() + finally: + bm.free() + + collider = bpy.data.objects.new(name or f"{obj.name}_Collider", mesh) + bpy.context.scene.collection.objects.link(collider) + collider.matrix_world = obj.matrix_world.copy() + return collider + + +if __name__ == "__main__": + obj = bpy.context.active_object + if obj is not None and obj.type == "MESH": + hull = convex_hull_collider(obj) + print(f"collider: {hull.name} verts={len(hull.data.vertices)}") diff --git a/snippets/decimate_to_budget.py b/snippets/decimate_to_budget.py new file mode 100644 index 0000000..403a75f --- /dev/null +++ b/snippets/decimate_to_budget.py @@ -0,0 +1,44 @@ +# Add a DECIMATE COLLAPSE modifier so evaluated triangles land at a budget. +# Triangle count is measured on the evaluated mesh (modifiers applied), +# not obj.data. ratio = target_tris / current, clamped to 1.0. +# Returns None when the object is already at or under budget. +# +# Mesh.calc_loop_triangles() is required before reading loop_triangles on +# 4.5 LTS and on 5.x; tessellation is not implicit. Always call it. +# Do not use a hasattr guard. +# +# Reference: +# https://docs.blender.org/api/5.1/bpy.types.Mesh.html#bpy.types.Mesh.calc_loop_triangles +# https://docs.blender.org/api/current/bpy.types.DecimateModifier.html +# https://docs.blender.org/api/current/bpy.types.Object.html#bpy.types.Object.evaluated_get + +import bpy + + +def evaluated_triangle_count(obj): + depsgraph = bpy.context.evaluated_depsgraph_get() + eval_obj = obj.evaluated_get(depsgraph) + eval_mesh = eval_obj.to_mesh() + try: + eval_mesh.calc_loop_triangles() + return len(eval_mesh.loop_triangles) + finally: + eval_obj.to_mesh_clear() + + +def decimate_to_budget(obj, target_tris): + current = evaluated_triangle_count(obj) + if current == 0 or current <= target_tris: + return None + ratio = min(1.0, target_tris / current) + mod = obj.modifiers.new("DecimateBudget", "DECIMATE") + mod.decimate_type = "COLLAPSE" + mod.ratio = ratio + return mod + + +if __name__ == "__main__": + obj = bpy.context.active_object + if obj is not None and obj.type == "MESH": + print(f"evaluated tris: {evaluated_triangle_count(obj)}") + print(f"modifier: {decimate_to_budget(obj, target_tris=4)}") diff --git a/snippets/gltf_draco_export.py b/snippets/gltf_draco_export.py new file mode 100644 index 0000000..da7a02d --- /dev/null +++ b/snippets/gltf_draco_export.py @@ -0,0 +1,31 @@ +# glTF export with Draco compression, selected-objects-only, and an +# explicit axis flag. glTF RNA exposes axis as export_yup (boolean), +# not FBX-style axis_forward / axis_up. Pass export_yup explicitly. +# export_apply=True ships evaluated (modifier-applied) mesh data. +# +# Reference: +# https://docs.blender.org/api/current/bpy.ops.export_scene.html#bpy.ops.export_scene.gltf + +import tempfile + +import bpy + + +def export_gltf_draco(filepath, selected_only=True, yup=True): + bpy.ops.export_scene.gltf( + filepath=filepath, + use_selection=selected_only, + export_draco_mesh_compression_enable=True, + export_draco_mesh_compression_level=6, + export_yup=yup, + export_apply=True, + ) + + +if __name__ == "__main__": + obj = bpy.context.active_object + if obj is not None and obj.type == "MESH": + obj.select_set(True) + path = tempfile.NamedTemporaryFile(suffix=".glb", delete=False).name + export_gltf_draco(path) + print(f"wrote {path}") diff --git a/snippets/lod_chain.py b/snippets/lod_chain.py new file mode 100644 index 0000000..10c381b --- /dev/null +++ b/snippets/lod_chain.py @@ -0,0 +1,55 @@ +# Ordered LOD set from one source object via successive triangle budgets. +# Snippets are standalone and not a package; the evaluated-triangle-count +# and DECIMATE helper is duplicated from snippets/decimate_to_budget.py +# rather than imported across files. +# +# Each LOD is a new object (source datablock is not mutated). A DECIMATE +# COLLAPSE modifier is added only when that copy is over budget. +# +# Reference: +# https://docs.blender.org/api/5.1/bpy.types.Mesh.html#bpy.types.Mesh.calc_loop_triangles +# https://docs.blender.org/api/current/bpy.types.DecimateModifier.html +# https://docs.blender.org/api/current/bpy.types.Object.html#bpy.types.Object.evaluated_get + +import bpy + + +def evaluated_triangle_count(obj): + depsgraph = bpy.context.evaluated_depsgraph_get() + eval_obj = obj.evaluated_get(depsgraph) + eval_mesh = eval_obj.to_mesh() + try: + eval_mesh.calc_loop_triangles() + return len(eval_mesh.loop_triangles) + finally: + eval_obj.to_mesh_clear() + + +def decimate_to_budget(obj, target_tris): + current = evaluated_triangle_count(obj) + if current == 0 or current <= target_tris: + return None + ratio = min(1.0, target_tris / current) + mod = obj.modifiers.new("DecimateBudget", "DECIMATE") + mod.decimate_type = "COLLAPSE" + mod.ratio = ratio + return mod + + +def make_lod_chain(obj, budgets): + lods = [] + for i, budget in enumerate(budgets): + mesh = obj.data.copy() + lod = bpy.data.objects.new(f"{obj.name}_LOD{i}", mesh) + lod.matrix_world = obj.matrix_world.copy() + bpy.context.scene.collection.objects.link(lod) + decimate_to_budget(lod, budget) + lods.append(lod) + return lods + + +if __name__ == "__main__": + obj = bpy.context.active_object + if obj is not None and obj.type == "MESH": + chain = make_lod_chain(obj, budgets=(8, 4)) + print(f"lods: {[o.name for o in chain]}") From 5dba10b9a04e98ba2f02cd78effdfb7ed45888be Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Thu, 10 Sep 2026 22:46:07 -0400 Subject: [PATCH 3/6] feat: add ai-mesh-cleanup skill Canonical ordered cleanup for an imported generated mesh: units, transform apply, origin, normals, evaluated triangle count, decimate, collider. Composes depsgraph and bmesh skills; does not generate meshes. Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-authored-by: Cursor --- skills/ai-mesh-cleanup/SKILL.md | 209 ++++++++++++++++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 skills/ai-mesh-cleanup/SKILL.md diff --git a/skills/ai-mesh-cleanup/SKILL.md b/skills/ai-mesh-cleanup/SKILL.md new file mode 100644 index 0000000..e7c85bb --- /dev/null +++ b/skills/ai-mesh-cleanup/SKILL.md @@ -0,0 +1,209 @@ +--- +name: ai-mesh-cleanup +description: Ordered cleanup for an imported generated mesh. Unit scale, transform apply, origin, normals, evaluated triangle count, decimate to budget, convex collider. Targets 5.2 LTS with 4.5 LTS fallback. +standards-version: 1.10.0 +--- + +# AI Mesh Cleanup + +## Trigger + +Use this skill when the user: + +- Has a generated or scanned GLB/glTF/FBX that is not engine-ready +- Mentions unit scale, unapplied transforms, triangle budget, LOD, or a collision hull +- Wants a headless cleanup pass (import in, cleaned mesh out) +- Is about to run mesh operators on an import without checking scale + +This skill is the ordered pipeline. It composes `depsgraph-and-evaluated-data` and `mesh-editing-and-bmesh`; it does not replace them. It does not generate meshes and does not call any generation vendor. + +## The core misunderstanding + +An imported generated mesh is not a game asset. Typical residue: object scale not identity, scene units not meters, origin in the wrong place, inverted normals, triangle soup over budget, no collider. Running decimate or export on that state bakes the pathology in. + +Do the cleanup in this order. Skipping a step makes later measurements lie. + +## The canonical pattern + +```python +import bmesh +import bpy + + +def imported_meshes(): + return [o for o in bpy.context.selected_objects if o.type == "MESH"] + + +def scene_units_are_meters(scene): + units = scene.unit_settings + if units.system not in {"METRIC", "NONE"}: + return False + return abs(units.scale_length - 1.0) < 1e-6 + + +def scale_is_identity(obj, tol=1e-6): + sx, sy, sz = obj.scale + return abs(sx - 1.0) < tol and abs(sy - 1.0) < tol and abs(sz - 1.0) < tol + + +def apply_object_transform(obj): + with bpy.context.temp_override( + object=obj, + active_object=obj, + selected_objects=[obj], + ): + bpy.ops.object.transform_apply(location=False, rotation=True, scale=True) + + +def origin_to_base(obj): + mesh = obj.data + n = len(mesh.vertices) + flat = [0.0] * (n * 3) + mesh.vertices.foreach_get("co", flat) + min_z = min(flat[2::3]) + for i in range(n): + flat[i * 3 + 2] -= min_z + mesh.vertices.foreach_set("co", flat) + mesh.update() + obj.location.z += min_z + + +def recalc_normals(obj): + bm = bmesh.new() + try: + bm.from_mesh(obj.data) + bmesh.ops.recalc_face_normals(bm, faces=bm.faces) + bm.to_mesh(obj.data) + obj.data.update() + finally: + bm.free() +``` + +Import, then those helpers, then budget and collider (snippets below). + +### 1. Import + +```python +bpy.ops.import_scene.gltf(filepath=path) +``` + +glTF import RNA has no `global_scale` on 4.5 LTS or 5.x. Scale after import lives on the object (`obj.scale`) and in `scene.unit_settings.scale_length`. + +FBX does take a scale kwarg: + +```python +bpy.ops.import_scene.fbx(filepath=path, global_scale=1.0) +``` + +Default `import_select_created_objects=True` leaves the new meshes selected. Iterate `imported_meshes()`; do not assume `context.active_object` is the only import. + +### 2. Verify and correct unit scale + +Blender's default is metric, `scale_length == 1.0` (meters). Generated files often arrive with `obj.scale` of 0.01 (centimeters written as meters) or a non-1.0 scene scale. + +```python +scene = bpy.context.scene +if not scene_units_are_meters(scene): + scene.unit_settings.system = "METRIC" + scene.unit_settings.scale_length = 1.0 + +for obj in imported_meshes(): + if not scale_is_identity(obj): + apply_object_transform(obj) +``` + +`export_apply=True` on glTF applies **modifiers**, not object scale. Unapplied object scale lands on the glTF node. Witness: `examples/unapplied-scale-gltf/`. + +### 3. Apply transforms + +`transform_apply` needs a real object in context. Use `temp_override`, not `bpy.context.copy()`. After apply, `obj.scale` is `(1, 1, 1)` and `obj.data` vertex positions hold the world size. + +### 4. Set origin + +Origin at the lowest Z of the mesh (sit-on-ground) via `foreach_get` / `foreach_set`, not a Python loop on `mesh.vertices`. See `examples/prop-origin-transform/` for origin-to-base plus `matrix_parent_inverse`. + +### 5. Recalculate normals + +`Mesh.calc_normals()` was removed in Blender 4.0. On 4.5 LTS and 5.x, use `bmesh.ops.recalc_face_normals`. `bm.normal_update()` does not fix inward winding. + +### 6. Measure evaluated triangle count + +`obj.data` is the authored mesh. A DECIMATE modifier does not change `obj.data`. Count triangles on the evaluated mesh, and call `calc_loop_triangles()` first. Tessellation is not implicit on 4.5 or 5.x. + +```python +def evaluated_triangle_count(obj): + depsgraph = bpy.context.evaluated_depsgraph_get() + eval_obj = obj.evaluated_get(depsgraph) + eval_mesh = eval_obj.to_mesh() + try: + eval_mesh.calc_loop_triangles() + return len(eval_mesh.loop_triangles) + finally: + eval_obj.to_mesh_clear() +``` + +Always pair `to_mesh()` with `to_mesh_clear()`. + +### 7. Decimate to budget + +Add `DECIMATE` with `decimate_type='COLLAPSE'` and `ratio = min(1.0, target_tris / current)`. Return `None` when already under budget. The modifier is non-destructive; `obj.data` keeps the dense mesh. Witness: `examples/lod-decimate-chain/`. + +Snippet: `snippets/decimate_to_budget.py`. LOD set from successive budgets: `snippets/lod_chain.py` (helper duplicated; snippets are not a package). + +### 8. Generate collider + +Convex hull via `bmesh.ops.convex_hull`, then delete `geom_interior` and `geom_unused`. Copy `matrix_world` onto the collider. Hull a coarse cage when the render mesh would blow a per-piece face budget. Witness: `examples/collision-hull-proxy/`. + +Snippet: `snippets/convex_hull_collider.py`. + +### Export (when shipping) + +Draco, selected-only, explicit `export_yup`, and `export_apply=True` so the decimate modifier ships. Snippet: `snippets/gltf_draco_export.py`. glTF RNA has `export_yup`, not FBX `axis_forward` / `axis_up`. + +## Common AI mistakes + +1. **Decimate ratio against `len(obj.data.polygons)`**. That ignores modifiers already on the stack and counts n-gons as one. Use evaluated `loop_triangles`. +2. **Skipping `calc_loop_triangles()`**. `loop_triangles` can be empty or stale. Required on 4.5 LTS and 5.x. +3. **`export_apply=True` as "apply object transforms".** It applies modifiers excluding armatures. Apply object scale first. +4. **Hulling the dense render mesh.** Over budget. Hull a coarse cage. +5. **`bm.normal_update()` for flipped faces.** Use `recalc_face_normals`. +6. **Import then `bpy.ops.mesh.*` with no scale check.** Rule `validate-imported-mesh-scale`. +7. **Export with a live DECIMATE and `export_apply=False`.** The engine gets the dense mesh. Rule `no-unapplied-modifiers-on-export`. + +## Version correctness + +The cleanup sequence is the same on 4.5 LTS and 5.x: + +- `Mesh.calc_loop_triangles()` is still required before `mesh.loop_triangles` on 4.5 LTS, 5.1, and 5.2. Not implicit. Verified: https://docs.blender.org/api/5.1/bpy.types.Mesh.html#bpy.types.Mesh.calc_loop_triangles and https://docs.blender.org/api/4.5/bpy.types.Mesh.html#bpy.types.Mesh.calc_loop_triangles. The 5.1 bmesh module still says tessellation "needs to be called explicitly": https://docs.blender.org/api/5.1/bmesh.html +- `Mesh.calc_normals()` is gone since 4.0. `bmesh.ops.recalc_face_normals` on both LTS lines. +- glTF import has no `global_scale` on either line. FBX does. +- `DecimateModifier.decimate_type='COLLAPSE'` and `ratio` are stable across 4.5 LTS and 5.x. + +Branch on `bpy.app.version` only when an API actually diverges. Do not use `hasattr` as a substitute for checking the docs. + +## Related + +- Skill `depsgraph-and-evaluated-data` for `evaluated_get` / `to_mesh` / `to_mesh_clear` +- Skill `mesh-editing-and-bmesh` for bmesh load-edit-free +- Skill `headless-batch-scripting` for `temp_override` and argparse after `--` +- Rule `validate-imported-mesh-scale` +- Rule `no-unapplied-modifiers-on-export` +- Rule `always-free-bmesh` +- Snippet `snippets/decimate_to_budget.py` +- Snippet `snippets/convex_hull_collider.py` +- Snippet `snippets/lod_chain.py` +- Snippet `snippets/gltf_draco_export.py` +- Example `unapplied-scale-gltf` for object scale vs `export_apply` +- Example `lod-decimate-chain` for COLLAPSE ratio vs evaluated tris +- Example `collision-hull-proxy` for hull-from-cage vs hull-from-render +- Example `prop-origin-transform` for origin-to-base +- Example `mesh-hygiene-audit` for engine-ingest topology checks + +## References + +- `bpy.ops.import_scene.gltf`: https://docs.blender.org/api/current/bpy.ops.import_scene.html#bpy.ops.import_scene.gltf +- `bpy.ops.export_scene.gltf`: https://docs.blender.org/api/current/bpy.ops.export_scene.html#bpy.ops.export_scene.gltf +- `Mesh.calc_loop_triangles` (5.1): https://docs.blender.org/api/5.1/bpy.types.Mesh.html#bpy.types.Mesh.calc_loop_triangles +- `DecimateModifier`: https://docs.blender.org/api/current/bpy.types.DecimateModifier.html +- `bmesh.ops.convex_hull`: https://docs.blender.org/api/current/bmesh.ops.html#bmesh.ops.convex_hull +- `Object.evaluated_get`: https://docs.blender.org/api/current/bpy.types.Object.html#bpy.types.Object.evaluated_get From 190d2b284544504009ddf578eaf8b1a5d114aebb Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Thu, 10 Sep 2026 22:46:08 -0400 Subject: [PATCH 4/6] feat: add imported-scale and unevaluated-export rules Catch glTF/FBX import plus mesh work with no transform_apply and no unit check, and export of live modifiers without export_apply or evaluation_mode. Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-authored-by: Cursor --- rules/no-unapplied-modifiers-on-export.mdc | 89 ++++++++++++++++++++ rules/validate-imported-mesh-scale.mdc | 94 ++++++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 rules/no-unapplied-modifiers-on-export.mdc create mode 100644 rules/validate-imported-mesh-scale.mdc diff --git a/rules/no-unapplied-modifiers-on-export.mdc b/rules/no-unapplied-modifiers-on-export.mdc new file mode 100644 index 0000000..db41f59 --- /dev/null +++ b/rules/no-unapplied-modifiers-on-export.mdc @@ -0,0 +1,89 @@ +--- +description: Flag an export call on objects that still carry unapplied modifiers when the export arguments do not request evaluated geometry. The engine then receives the authored cage, not the modifier result. +alwaysApply: true +globs: + - "**/*.py" +standards-version: 1.10.0 +--- + +# No unapplied modifiers on export + +`obj.data` is the authored mesh. DECIMATE, subdivision, and geometry nodes +live in the depsgraph. An export that does not ask for evaluated geometry +writes the cage: the LOD modifier is dropped, the engine gets the dense +mesh, and the script still exits 0. + +glTF: `export_apply=True` applies modifiers excluding armatures. +USD: `evaluation_mode='RENDER'` or `'VIEWPORT'`. +FBX: `use_mesh_modifiers=True`. + +`export_apply` is not "apply object transforms". Apply object scale first. +See rule `validate-imported-mesh-scale`. + +## What this rule flags + +A `bpy.ops.export_scene.gltf`, `bpy.ops.export_scene.fbx`, or +`bpy.ops.wm.usd_export` call in a file that adds modifiers (`modifiers.new`) +and never requests evaluated geometry (`export_apply=True` or +`evaluation_mode=`), and never applies those modifiers before export. + +```python +# WRONG: DECIMATE on the object, glTF without export_apply +mod = obj.modifiers.new("Lod", "DECIMATE") +mod.decimate_type = "COLLAPSE" +mod.ratio = 0.25 +bpy.ops.export_scene.gltf(filepath=path, use_selection=True) +``` + +```python +# WRONG: USD viewport/render mode omitted; default BEST_MATCH writes the cage +obj.modifiers.new("ss", "SUBSURF").levels = 2 +bpy.ops.wm.usd_export(filepath=path) +``` + +## The required pattern + +```python +import bpy + +mod = obj.modifiers.new("DecimateBudget", "DECIMATE") +mod.decimate_type = "COLLAPSE" +mod.ratio = 0.25 + +bpy.ops.export_scene.gltf( + filepath=path, + use_selection=True, + export_apply=True, + export_yup=True, +) +``` + +USD: + +```python +bpy.ops.wm.usd_export( + filepath=path, + evaluation_mode="RENDER", + export_subdivision="TESSELLATE", +) +``` + +Alternatively apply the modifier before export with `temp_override` and +`bpy.ops.object.modifier_apply`. Either path is valid; omitting both is not. + +## Why it matters + +A live DECIMATE that never ships is the usual LOD bug: Blender's viewport +shows the reduced mesh, the glTF still has the source triangle count, and +the engine budget check fails in production. `examples/lod-decimate-chain/` +shows the modifier is non-destructive on `obj.data`; export must opt in to +the evaluated result. + +## Related + +- Skill `ai-mesh-cleanup` +- Skill `depsgraph-and-evaluated-data` +- Snippet `gltf_draco_export.py` +- Snippet `usd-export-evaluation-mode.py` +- Example `lod-decimate-chain` +- `bpy.ops.export_scene.gltf`: https://docs.blender.org/api/current/bpy.ops.export_scene.html#bpy.ops.export_scene.gltf diff --git a/rules/validate-imported-mesh-scale.mdc b/rules/validate-imported-mesh-scale.mdc new file mode 100644 index 0000000..ba6d442 --- /dev/null +++ b/rules/validate-imported-mesh-scale.mdc @@ -0,0 +1,94 @@ +--- +description: Flag a glTF or FBX import followed by mesh operations with no transform_apply and no unit-scale check. Generated files often arrive with non-identity object scale or a non-meter scene scale; mesh edits then bake the wrong size. +alwaysApply: true +globs: + - "**/*.py" +standards-version: 1.10.0 +--- + +# Validate imported mesh scale + +`bpy.ops.import_scene.gltf` and `bpy.ops.import_scene.fbx` leave object +scale and scene units as the file authored them. Generated assets commonly +arrive at 0.01 object scale (centimeters stored as meters) or with +`scene.unit_settings.scale_length != 1.0`. Mesh operators, decimate ratios, +and collision hulls then run in the wrong space. + +glTF import RNA has no `global_scale` on 4.5 LTS or 5.x. The check is on +`scene.unit_settings` and `obj.scale` after import. FBX does take +`global_scale`. + +`export_apply=True` does not apply object scale. It applies modifiers. + +## What this rule flags + +A `bpy.ops.import_scene.gltf` or `bpy.ops.import_scene.fbx` call in the same +file as later mesh work (`bmesh`, `modifiers.new`, `from_pydata`, vertex +writes) when the file never calls `transform_apply` and never reads +`unit_settings` / `scale_length` / `global_scale`. + +```python +# WRONG: import then edit with no scale check +import bmesh +import bpy + +bpy.ops.import_scene.gltf(filepath=path) +obj = bpy.context.selected_objects[0] +bm = bmesh.new() +try: + bm.from_mesh(obj.data) + bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.001) + bm.to_mesh(obj.data) +finally: + bm.free() +``` + +```python +# WRONG: FBX import, then DECIMATE, no global_scale and no apply +bpy.ops.import_scene.fbx(filepath=path) +obj = bpy.context.selected_objects[0] +mod = obj.modifiers.new("Lod", "DECIMATE") +mod.ratio = 0.25 +``` + +## The required pattern + +```python +import bpy + +bpy.ops.import_scene.gltf(filepath=path) + +scene = bpy.context.scene +if scene.unit_settings.system not in {"METRIC", "NONE"}: + scene.unit_settings.system = "METRIC" +scene.unit_settings.scale_length = 1.0 + +for obj in bpy.context.selected_objects: + if obj.type != "MESH": + continue + sx, sy, sz = obj.scale + if abs(sx - 1.0) > 1e-6 or abs(sy - 1.0) > 1e-6 or abs(sz - 1.0) > 1e-6: + with bpy.context.temp_override( + object=obj, active_object=obj, selected_objects=[obj] + ): + bpy.ops.object.transform_apply( + location=False, rotation=True, scale=True + ) +``` + +For FBX, pass `global_scale=1.0` on import as well, then still check +`obj.scale` and apply if needed. + +## Why it matters + +A 0.01 leftover scale makes a 1 m prop 1 cm in the engine, or a decimate +`dist` / weld threshold 100x too tight. The failure is silent: the script +exits 0 and the asset is wrong. `examples/unapplied-scale-gltf/` is the +witness that `export_apply` does not bake object scale into POSITION. + +## Related + +- Skill `ai-mesh-cleanup` +- Skill `headless-batch-scripting` for `temp_override` +- Example `unapplied-scale-gltf` +- `bpy.ops.import_scene.gltf`: https://docs.blender.org/api/current/bpy.ops.import_scene.html#bpy.ops.import_scene.gltf From 49bf2dbc84409f100afbcca65ffe00478a2c82ea Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Thu, 10 Sep 2026 22:46:08 -0400 Subject: [PATCH 5/6] ci: gate import-scale and unevaluated-export anti-patterns Static scan of snippets and templates so the two new rules fail on a deliberate canary, not only on good input. examples/ is excluded because pathology witnesses are intentional. Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-authored-by: Cursor --- .github/workflows/validate.yml | 3 ++ tests/check_import_export_rules.py | 86 ++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 tests/check_import_export_rules.py diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 1f9a6f4..165d376 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -130,6 +130,9 @@ jobs: fi echo "All snippets have valid Python syntax." + - name: Check import-scale and unevaluated-export anti-patterns + run: python3 tests/check_import_export_rules.py + - name: Validate template Python syntax run: | echo "Checking template Python syntax..." diff --git a/tests/check_import_export_rules.py b/tests/check_import_export_rules.py new file mode 100644 index 0000000..0f8382c --- /dev/null +++ b/tests/check_import_export_rules.py @@ -0,0 +1,86 @@ +"""Static checks for validate-imported-mesh-scale and +no-unapplied-modifiers-on-export. + +Scans snippets/ and templates/**/*.py. examples/ is excluded because several +examples are intentional pathology witnesses (unapplied-scale-gltf). +""" +import glob +import os +import re +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +REQUIRED_RULES = ( + "rules/validate-imported-mesh-scale.mdc", + "rules/no-unapplied-modifiers-on-export.mdc", +) + +IMPORT_RE = re.compile(r"bpy\.ops\.import_scene\.(gltf|fbx)\s*\(") +EXPORT_RE = re.compile( + r"bpy\.ops\.(export_scene\.gltf|export_scene\.fbx|wm\.usd_export)\s*\(" +) +MESH_WORK_RE = re.compile(r"bmesh\.|modifiers\.new|from_pydata|foreach_set") +UNIT_SCALE_RE = re.compile(r"unit_settings|scale_length|global_scale") +EXPORT_EVAL_RE = re.compile( + r"export_apply\s*=\s*True|evaluation_mode\s*=|use_mesh_modifiers\s*=\s*True" +) +MODIFIER_NEW_RE = re.compile(r"modifiers\.new") +MODIFIER_APPLY_RE = re.compile(r"modifier_apply") + + +def scan_paths(extra): + paths = [] + paths.extend(glob.glob(os.path.join(ROOT, "snippets", "*.py"))) + paths.extend( + glob.glob(os.path.join(ROOT, "templates", "**", "*.py"), recursive=True) + ) + for item in extra: + paths.append(item if os.path.isabs(item) else os.path.join(ROOT, item)) + return paths + + +def check_text(rel, text): + errors = [] + if IMPORT_RE.search(text) and MESH_WORK_RE.search(text): + if "transform_apply" not in text or not UNIT_SCALE_RE.search(text): + errors.append( + f"{rel}: import_scene gltf/fbx plus mesh work without " + "transform_apply and a unit-scale check " + "(unit_settings, scale_length, or global_scale)" + ) + if EXPORT_RE.search(text) and MODIFIER_NEW_RE.search(text): + if not EXPORT_EVAL_RE.search(text) and not MODIFIER_APPLY_RE.search(text): + errors.append( + f"{rel}: export with modifiers.new but no export_apply=True, " + "evaluation_mode, or modifier_apply" + ) + return errors + + +def main(argv): + errors = [] + for rule in REQUIRED_RULES: + path = os.path.join(ROOT, rule) + if not os.path.isfile(path): + errors.append(f"missing rule file {rule}") + + extra = argv[1:] + for path in scan_paths(extra): + if not os.path.isfile(path): + errors.append(f"missing scan path {path}") + continue + rel = os.path.relpath(path, ROOT).replace("\\", "/") + text = open(path, encoding="utf-8").read() + errors.extend(check_text(rel, text)) + + if errors: + for err in errors: + print(f"ERROR: {err}", file=sys.stderr) + return 1 + print("import/export anti-pattern checks passed.") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) From dcb7a77ad36a297c8eeecd7b4b9a2ee1b1c93247 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Thu, 10 Sep 2026 22:46:09 -0400 Subject: [PATCH 6/6] docs: update inventory counts for the cleanup track README, CLAUDE.md, AGENTS.md, and plugin.json now list 14 skills, 8 rules, and 21 snippets so validate-counts and validate-manifest stay aligned. Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-authored-by: Cursor --- .cursor-plugin/plugin.json | 9 ++++++++- AGENTS.md | 8 ++++---- CLAUDE.md | 17 +++++++++++------ README.md | 20 +++++++++++--------- 4 files changed, 34 insertions(+), 20 deletions(-) diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 0849d77..d7a5ecc 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -15,6 +15,7 @@ ], "skills": [ "skills/addon-scaffolding/SKILL.md", + "skills/ai-mesh-cleanup/SKILL.md", "skills/operators/SKILL.md", "skills/ui-panels/SKILL.md", "skills/custom-properties/SKILL.md", @@ -34,7 +35,9 @@ "rules/target-extensions-platform-format.mdc", "rules/type-annotate-props-and-defend-context.mdc", "rules/prefer-temp-override-over-context-copy.mdc", - "rules/use-foreach-set-for-bulk-data.mdc" + "rules/use-foreach-set-for-bulk-data.mdc", + "rules/validate-imported-mesh-scale.mdc", + "rules/no-unapplied-modifiers-on-export.mdc" ], "snippets": [ "snippets/action-ensure-channelbag-for-slot.py", @@ -42,11 +45,15 @@ "snippets/bmesh-load-edit-free.py", "snippets/canonical-object-creation.py", "snippets/canonical-object-deletion.py", + "snippets/convex_hull_collider.py", "snippets/cross-version-property-delete.py", + "snippets/decimate_to_budget.py", "snippets/depsgraph-evaluated-mesh.py", "snippets/driver-with-custom-function.py", "snippets/foreach-get-vertices.py", "snippets/foreach-set-vertices.py", + "snippets/gltf_draco_export.py", + "snippets/lod_chain.py", "snippets/pointerproperty-binding.py", "snippets/principled-bsdf-material.py", "snippets/register-classes-factory.py", diff --git a/AGENTS.md b/AGENTS.md index 0baf40c..6394fbd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ a `.cursor-plugin/plugin.json` manifest so the ecosystem drift checker classifies it as a `cursor-plugin`. This is content the AI loads when the user asks Blender questions or works on Blender add-ons in Cursor or Claude Code. -The content base is 13 skills, 6 rules, 2 templates, 17 snippets, and 53 +The content base is 14 skills, 8 rules, 2 templates, 21 snippets, and 53 examples (counts are CI-enforced against README.md and the manifest). The full inventory tables and per-item purposes live in `CLAUDE.md`. Example anatomy and authoring rules: copy `examples/bmesh-gear/`; the render look is specified @@ -31,10 +31,10 @@ in `docs/VISUAL-STYLE.md`; the canonical run prompt is ``` Blender-Developer-Tools/ - skills//SKILL.md # 13 skill files - rules/.mdc # 6 rule files + skills//SKILL.md # 14 skill files + rules/.mdc # 8 rule files templates// # 2 starter templates - snippets/.py # 17 standalone Python snippets + snippets/.py # 21 standalone Python snippets examples// # 53 runnable smoke-gated examples (+ gallery.json) examples/gallery_framing.py # shared Layer 1 framing measurement (render path only) scripts/build_gallery.py # generates docs/gallery/ (stdlib only) diff --git a/CLAUDE.md b/CLAUDE.md index aa51117..4ab4c47 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,10 +17,10 @@ The **Blender Developer Tools** repository is at **v0.53.0**. It packages skills ## Repository Architecture ``` -skills//SKILL.md - AI workflow definitions, 13 total -rules/.mdc - Anti-pattern rules, 6 total +skills//SKILL.md - AI workflow definitions, 14 total +rules/.mdc - Anti-pattern rules, 8 total templates// - Starter projects, 2 total -snippets/.py - Standalone code patterns, 17 total +snippets/.py - Standalone code patterns, 21 total examples// - Runnable smoke-gated examples, 53 total (+ gallery.json) scripts/build_gallery.py - Regenerates docs/gallery/ from gallery.json (stdlib only) scripts/site/ - Vendored landing-page build (Jinja2) @@ -28,11 +28,12 @@ docs/gallery/ - Committed generated gallery pages + hero render VERSION - Source of truth for the repo version ``` -## Skills (13) +## Skills (14) | Skill | Purpose | | --- | --- | | addon-scaffolding | Extensions Platform manifest, file layout, register/unregister symmetry | +| ai-mesh-cleanup | Ordered cleanup for imported generated meshes: units, transform apply, origin, normals, budget, collider | | operators | `bpy.types.Operator` lifecycle, `bl_idname`, redo, defensive context handling | | ui-panels | `bpy.types.Panel` declarative `draw()`, layout primitives, conditional UI | | custom-properties | `bpy.props` annotations, PropertyGroup, PointerProperty, storage tradeoffs | @@ -46,7 +47,7 @@ VERSION - Source of truth for the repo version | bl-info-migration | Three-step migration from legacy `bl_info` to Extensions Platform, dual-format pattern | | vse-python | VSE timeline from Python: `.strips` vs `.sequences`, `new_effect` kwargs, 5.2 COLOR `width`/`height` bake | -## Rules (6) +## Rules (8) | Rule | Scope | What it flags | | --- | --- | --- | @@ -56,6 +57,8 @@ VERSION - Source of truth for the repo version | type-annotate-props-and-defend-context | `*.py` | `bpy.props` defined as assignments, unguarded `context.active_object` | | prefer-temp-override-over-context-copy | `*.py` | `bpy.context.copy()` passed to operators (deprecated 4.x, removed 5.x) | | use-foreach-set-for-bulk-data | `*.py` | Python loops over `mesh.vertices` setting bulk attributes one at a time | +| validate-imported-mesh-scale | `*.py` | glTF/FBX import then mesh work with no `transform_apply` and no unit-scale check | +| no-unapplied-modifiers-on-export | `*.py` | Export with live modifiers when the export does not request evaluated geometry | ## Templates (2) @@ -75,7 +78,7 @@ VERSION - Source of truth for the repo version - glTF export via `bpy.ops.export_scene.gltf` - Explicit exit codes for CI integration -## Snippets (17) +## Snippets (21) Small standalone `.py` files at `snippets/.py`, each 5 to 50 lines. @@ -83,6 +86,8 @@ v0.1.0: canonical object creation and deletion, depsgraph evaluated mesh, bmesh v0.2.0: Principled BSDF material, driver-with-custom-function via `driver_namespace`, application handler registration, shader node group with cross-version `interface` API, `foreach_get` bulk vertex read, version-branch skeleton, and USD export with `evaluation_mode='RENDER'`. +AI asset pipeline track: `decimate_to_budget.py`, `convex_hull_collider.py`, `lod_chain.py` (helper duplicated, not imported), `gltf_draco_export.py`. + ## Examples (53) Runnable scripts at `examples//`, each asserting a real API contract with diff --git a/README.md b/README.md index 70cec78..c4126c0 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

- 13 skills  •  6 rules  •  2 templates  •  17 snippets  •  53 examples + 14 skills  •  8 rules  •  2 templates  •  21 snippets  •  53 examples

@@ -36,16 +36,16 @@ ## Overview -This repository ships **13 skills, 6 rules, 2 templates, 17 snippets, and 48 runnable examples** for Blender Python development targeting Blender 5.2 LTS (current stable) with Blender 4.5 LTS fallback support. Blender 5.1 is prior stable. +This repository ships **14 skills, 8 rules, 2 templates, 21 snippets, and 53 examples** for Blender Python development targeting Blender 5.2 LTS (current stable) with Blender 4.5 LTS fallback support. Blender 5.1 is prior stable. The content is consumed by AI coding agents (Cursor, Claude Code, any MCP-capable client) when working on Blender add-ons, geometry nodes scripts, batch pipelines, or animation tooling. There is no build step. Edit the markdown and Python files directly. | Layer | Role | | --- | --- | -| **Skills** | Guided workflows: scaffolding, operators, panels, properties, mesh and bmesh, headless batch, slotted actions, geometry nodes, procedural materials, depsgraph queries, drivers and handlers, `bl_info` migration, video sequencer | -| **Rules** | Guardrails for the most common AI mistakes: ops-in-loops, bmesh leaks, legacy `bl_info` only, prop assignments, deprecated context-copy override, per-element loops over bulk mesh data | +| **Skills** | Guided workflows: scaffolding, operators, panels, properties, mesh and bmesh, headless batch, slotted actions, geometry nodes, procedural materials, depsgraph queries, drivers and handlers, `bl_info` migration, video sequencer, imported-mesh cleanup | +| **Rules** | Guardrails for the most common AI mistakes: ops-in-loops, bmesh leaks, legacy `bl_info` only, prop assignments, deprecated context-copy override, per-element loops over bulk mesh data, import without scale check, export without evaluated geometry | | **Templates** | A working Extensions Platform add-on starter and a headless batch script starter | -| **Snippets** | 17 small standalone Python files demonstrating canonical patterns | +| **Snippets** | 21 small standalone Python files demonstrating canonical patterns | ## Quick start @@ -1008,15 +1008,15 @@ the duplicates, then glTF ships 24 tris / 48 positions / 8 unique. ## How content is organized ``` -skills//SKILL.md - 13 skill files, YAML frontmatter, one canonical pattern each -rules/.mdc - 6 rule files, anti-pattern + correction +skills//SKILL.md - 14 skill files, YAML frontmatter, one canonical pattern each +rules/.mdc - 8 rule files, anti-pattern + correction templates// - 2 template directories (extension-addon-template, headless-batch-script-template) -snippets/.py - 17 standalone Python snippets, 5 to 50 lines each +snippets/.py - 21 standalone Python snippets, 5 to 50 lines each ``` ## Using rules in Cursor -The `.mdc` files in `rules/` apply automatically when Cursor opens a Blender Python project, scoped by the `globs` in each rule's frontmatter. The six rules are: +The `.mdc` files in `rules/` apply automatically when Cursor opens a Blender Python project, scoped by the `globs` in each rule's frontmatter. The eight rules are: - `prefer-data-over-ops-in-loops`: flags `bpy.ops.*` calls inside object iteration - `always-free-bmesh`: flags `bmesh.new()` without paired `bm.free()` in `try`/`finally` @@ -1024,6 +1024,8 @@ The `.mdc` files in `rules/` apply automatically when Cursor opens a Blender Pyt - `type-annotate-props-and-defend-context`: flags `bpy.props` assignment form and unguarded `context.active_object` - `prefer-temp-override-over-context-copy`: flags `bpy.context.copy()` passed to operators (deprecated 4.x, removed 5.x) - `use-foreach-set-for-bulk-data`: flags Python loops over `mesh.vertices` setting `co`, normals, or other per-element bulk data +- `validate-imported-mesh-scale`: flags glTF/FBX import then mesh work with no `transform_apply` and no unit-scale check +- `no-unapplied-modifiers-on-export`: flags export of objects with live modifiers when the export does not request evaluated geometry Symlink or clone this repo, then point Cursor at it as a skills/rules source.