From 18851bac3bcecaac8124866cd0f7606960ad54d8 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Sun, 4 Oct 2026 19:41:09 -0400 Subject: [PATCH] fix(skills): correct four API claims that make agent code silently wrong - use-foreach-set rule listed vertex "normal" and "index" as foreach_set targets; both are read-only and foreach_set on them is a silent no-op. It now names writable attributes and says how to read and write normals. - geometry-nodes-python told agents to list node types with GeometryNode.__subclasses__(), which returns [] because bpy.types classes are created lazily. Use dir(bpy.types) instead. - engine-export-presets said glTF infers the format from the filepath suffix. It does not: a .gltf path with no export_format writes .glb. The skill now says to always pass export_format, and every glTF call in the snippets, presets and pipeline template passes it. - drivers-and-app-handlers' save-count example keyed an ID property by the full path (KeyError past 63 characters) and counted in save_post, after the file was written. It now keys by basename and uses save_pre. Closes #353 Closes #355 Closes #388 Closes #390 Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- rules/use-foreach-set-for-bulk-data.mdc | 10 ++++++---- skills/drivers-and-app-handlers/SKILL.md | 15 +++++++++------ skills/engine-export-presets/SKILL.md | 4 +++- skills/geometry-nodes-python/SKILL.md | 8 +++++--- snippets/export_preset_godot.py | 1 + snippets/export_preset_unity.py | 1 + snippets/export_preset_unreal.py | 1 + snippets/gltf_draco_export.py | 1 + templates/ai-asset-pipeline-template/pipeline.py | 3 +++ 9 files changed, 30 insertions(+), 14 deletions(-) diff --git a/rules/use-foreach-set-for-bulk-data.mdc b/rules/use-foreach-set-for-bulk-data.mdc index b408e1b4..a0f4a619 100644 --- a/rules/use-foreach-set-for-bulk-data.mdc +++ b/rules/use-foreach-set-for-bulk-data.mdc @@ -66,8 +66,12 @@ Same 100,000 vertices: under a millisecond. The signature is `foreach_set(attribute, sequence)` where: -- `attribute` is the name of the per-element attribute as a string - (`"co"`, `"normal"`, `"index"`, `"use_smooth"`, etc.). +- `attribute` is the name of a **writable** per-element attribute as a + string (`"co"`, `"use_smooth"`, `"material_index"`, etc.). Read-only + attributes such as vertex `"normal"` and `"index"` accept `foreach_set` + without error and change nothing (measured on 5.2.1 LTS): vertex normals + are derived, so read them with `foreach_get` (or `mesh.vertex_normals`) + and write custom normals with `mesh.normals_split_custom_set(...)`. - `sequence` is a flat 1D iterable of `len(elements) * components` values. For vector attributes (3 floats per vertex), the buffer is 3x as long as the element count. @@ -85,8 +89,6 @@ import numpy as np n = len(mesh.vertices) coords = np.empty(n * 3, dtype=np.float32) -mesh.vertices.foreach_get("co", coords) - mesh.vertices.foreach_get("co", coords) xs = coords[0::3] ys = coords[1::3] diff --git a/skills/drivers-and-app-handlers/SKILL.md b/skills/drivers-and-app-handlers/SKILL.md index 8dd56db4..b0b19dc0 100644 --- a/skills/drivers-and-app-handlers/SKILL.md +++ b/skills/drivers-and-app-handlers/SKILL.md @@ -210,21 +210,24 @@ from bpy.app.handlers import persistent @persistent def increment_save_count(filepath): - # save_post passes the saved file path (a string) as its only argument. - # Store the running count on the current scene. + # save_pre runs before the write, so the new count lands in the file being + # saved (a save_post write would only reach the next save). It receives the + # target path. ID-property keys are limited to 63 characters, so key by the + # file name, never the full path, which raises KeyError when longer. scene = bpy.context.scene + key = bpy.path.basename(filepath)[:63] or "untitled" counts = scene.get('save_counts', {}) - counts[filepath] = counts.get(filepath, 0) + 1 + counts[key] = counts.get(key, 0) + 1 scene['save_counts'] = counts def register(): - bpy.app.handlers.save_post.append(increment_save_count) + bpy.app.handlers.save_pre.append(increment_save_count) def unregister(): - if increment_save_count in bpy.app.handlers.save_post: - bpy.app.handlers.save_post.remove(increment_save_count) + if increment_save_count in bpy.app.handlers.save_pre: + bpy.app.handlers.save_pre.remove(increment_save_count) ``` ### Worked example: cleanup on exit (5.1+) diff --git a/skills/engine-export-presets/SKILL.md b/skills/engine-export-presets/SKILL.md index 283ae922..3a43becf 100644 --- a/skills/engine-export-presets/SKILL.md +++ b/skills/engine-export-presets/SKILL.md @@ -51,6 +51,7 @@ def apply_selected_mesh_transforms(): ```python bpy.ops.export_scene.gltf( filepath=path, + export_format="GLB", use_selection=True, export_yup=True, export_apply=True, @@ -68,6 +69,7 @@ Snippet: [`snippets/export_preset_unity.py`](https://github.com/TMHSDigital/Blen ```python bpy.ops.export_scene.gltf( filepath=path, + export_format="GLB", use_selection=True, export_yup=False, export_apply=True, @@ -115,7 +117,7 @@ Snippet: [`snippets/export_preset_unreal.py`](https://github.com/TMHSDigital/Ble Probed on 4.5 LTS, 5.1, and 5.2: `export_yup` on glTF and `axis_forward` / `axis_up` / `global_scale` on FBX are present on all three. No version branch for the axis kwargs. Guard by requiring those names in operator RNA so a future rename fails loudly, as [`examples/gltf-export-roundtrip/`](https://github.com/TMHSDigital/Blender-Developer-Tools/tree/main/examples/gltf-export-roundtrip) does. -`export_format` defaults differ by call site; pass the filepath suffix (`.glb` / `.gltf` / `.fbx`) and let the operator infer, or set `export_format` explicitly on glTF. +Always pass `export_format` on glTF and make the filepath suffix match it. The operator does **not** infer the format from the suffix: `export_scene.gltf(filepath="x.gltf")` with no `export_format` writes a binary `x.glb`, so the requested path never exists (measured on 4.5.11 and 5.2.1). Use `export_format="GLB"` with `.glb`, or `"GLTF_SEPARATE"` with `.gltf`. ## Related diff --git a/skills/geometry-nodes-python/SKILL.md b/skills/geometry-nodes-python/SKILL.md index 0518583d..aee8da1e 100644 --- a/skills/geometry-nodes-python/SKILL.md +++ b/skills/geometry-nodes-python/SKILL.md @@ -164,8 +164,10 @@ To list all available Geometry node types in your Blender version: ```python import bpy -for cls in bpy.types.GeometryNode.__subclasses__(): - print(cls.bl_idname) +# bpy.types registers node classes lazily, so GeometryNode.__subclasses__() +# returns [] until something touches them. dir(bpy.types) lists them all. +for name in sorted(n for n in dir(bpy.types) if n.startswith("GeometryNode")): + print(name) ``` ## Wiring nodes @@ -323,7 +325,7 @@ def has_for_each_element(): tree.nodes.new('GeometryNodeSetPosition') # RIGHT ``` - When in doubt, list `bpy.types.GeometryNode.__subclasses__()` and grep. + When in doubt, list `[n for n in dir(bpy.types) if n.startswith("GeometryNode")]` and grep. (`bpy.types.GeometryNode.__subclasses__()` returns an empty list: the classes are created lazily.) 3. **Linking nodes by name when both inputs share a name** (`Value`, `Vector`, `Geometry`) and getting the wrong one. Use indices or the named inputs of the parent node. diff --git a/snippets/export_preset_godot.py b/snippets/export_preset_godot.py index f624e767..d5a10b1e 100644 --- a/snippets/export_preset_godot.py +++ b/snippets/export_preset_godot.py @@ -31,6 +31,7 @@ def export_preset_godot(filepath, selected_only=True, draco=False): apply_selected_mesh_transforms() bpy.ops.export_scene.gltf( filepath=filepath, + export_format="GLB", use_selection=selected_only, export_yup=False, export_apply=True, diff --git a/snippets/export_preset_unity.py b/snippets/export_preset_unity.py index 712627b8..7dada8be 100644 --- a/snippets/export_preset_unity.py +++ b/snippets/export_preset_unity.py @@ -31,6 +31,7 @@ def export_preset_unity(filepath, selected_only=True, draco=False): apply_selected_mesh_transforms() bpy.ops.export_scene.gltf( filepath=filepath, + export_format="GLB", use_selection=selected_only, export_yup=True, export_apply=True, diff --git a/snippets/export_preset_unreal.py b/snippets/export_preset_unreal.py index b29855a3..ee08f9c6 100644 --- a/snippets/export_preset_unreal.py +++ b/snippets/export_preset_unreal.py @@ -41,6 +41,7 @@ def export_preset_unreal_gltf(filepath, selected_only=True, draco=False): apply_selected_mesh_transforms() bpy.ops.export_scene.gltf( filepath=filepath, + export_format="GLB", use_selection=selected_only, export_yup=True, export_apply=True, diff --git a/snippets/gltf_draco_export.py b/snippets/gltf_draco_export.py index da7a02d5..e9b754cf 100644 --- a/snippets/gltf_draco_export.py +++ b/snippets/gltf_draco_export.py @@ -14,6 +14,7 @@ def export_gltf_draco(filepath, selected_only=True, yup=True): bpy.ops.export_scene.gltf( filepath=filepath, + export_format="GLB", use_selection=selected_only, export_draco_mesh_compression_enable=True, export_draco_mesh_compression_level=6, diff --git a/templates/ai-asset-pipeline-template/pipeline.py b/templates/ai-asset-pipeline-template/pipeline.py index 80b26320..1d372503 100644 --- a/templates/ai-asset-pipeline-template/pipeline.py +++ b/templates/ai-asset-pipeline-template/pipeline.py @@ -260,6 +260,7 @@ def export_preset(filepath, preset, draco): if preset == "unity": bpy.ops.export_scene.gltf( filepath=filepath, + export_format="GLB", use_selection=True, export_yup=True, export_apply=True, @@ -270,6 +271,7 @@ def export_preset(filepath, preset, draco): if preset == "godot": bpy.ops.export_scene.gltf( filepath=filepath, + export_format="GLB", use_selection=True, export_yup=False, export_apply=True, @@ -296,6 +298,7 @@ def export_preset(filepath, preset, draco): apply_selected_mesh_transforms() bpy.ops.export_scene.gltf( filepath=filepath, + export_format="GLB", use_selection=True, export_yup=True, export_apply=True,