Skip to content

fix(templates): keep every mesh, handle shared and parented imports, bake modifier stacks in order - #477

Merged
TMHSDigital merged 1 commit into
mainfrom
fix/templates-multi-mesh-shared-parented
Oct 7, 2026
Merged

TMHSDigital merged 1 commit into
mainfrom
fix/templates-multi-mesh-shared-parented

Conversation

@TMHSDigital

Copy link
Copy Markdown
Owner

Closes #467
Closes #468
Closes #462

What changed

#467: templates/ai-asset-pipeline-template/pipeline.py

  • Every mesh is kept. The template no longer picks max(meshes, key=tris). It gives shared mesh data one copy per object, unparents keeping the world matrix, applies rotation and scale in one transform_apply, then joins every part with object.join under temp_override. The README states this behavior.
  • The dead unit check is gone. read_factory_settings always leaves the scene metric at scale 1.0, so the old check could never fire. A geometry check replaces it: the largest world extent must be within 0.01 m to 100 m (× scale_length), or the script exits 8.
  • LOD budgets are asserted. An LOD whose evaluated triangle count is over its budget exits 9.
  • A RuntimeError from transform_apply or join exits 12. I skipped 10 and 11 because the shared render gates use them, and the README says so.
  • The redundant apply_selected_mesh_transforms() call inside export_preset is removed: every part is already applied by then. This also removes the object-op-in-a-loop pattern from the export loop.

#468: templates/headless-batch-script-template/

  • bpy.ops.object.modifier_apply per object in a loop is replaced by a stack bake. bpy.data.meshes.new_from_object(obj.evaluated_get(dg), preserve_all_data_layers=True, depsgraph=dg) bakes the whole stack in order, with one depsgraph evaluation for all objects.
  • The README now matches this: the new modifier runs last and the whole stack is applied. The gotcha section explains why the operator path reverses the order, with measured numbers.
  • tests/check_example_rules.py now scans templates/ for the existing two rules.
  • It also adds a templates-only prefer-data-over-ops-in-loops check. A for loop that reaches bpy.ops.object.* fails the check, whether the call is direct or goes through same-file functions. An # ops-loop-exempt: <why> comment on the loop exempts it.
  • This lives in its own section and functions (template_scripts, check_ops_in_loops), so the parallel bulk-write lint work in examples: png-exr-alpha teaches scene image_settings for Image.save(); missing falsifier and per-element writes elsewhere #474 rebases cleanly. Examples are not held to this check, because some loop per part over operators that have no data-API equivalent (uv.smart_project in lightmap-uv-channel).

#462: shared and parented imports

  • snippets/export_preset_{unity,godot,unreal}.py and the prelude in skills/engine-export-presets/SKILL.md now do two things before the single transform_apply: copy multi-user data per object, and unparent keeping the world matrix. The Unreal snippet is now exactly 75 lines, the documented maximum.
  • skills/ai-mesh-cleanup/SKILL.md gains isolate_mesh_data and clear_parent_keep_transform, and rot_scale_is_identity now reads matrix_world. The skill also notes that origin_to_base needs single-user data, that a scene-units check cannot catch centimeters written as meters (measure the geometry instead), and adds common mistake fix: remove stray context-mode block from CLAUDE.md and substantiate verification claims #8.
  • transform_apply(isolate_users=True) exists on both 4.5.11 and 5.2.1 (measured). The skill mentions it but uses the data copy instead, because origin_to_base also needs single-user data, not just the apply.
  • In examples/export-preset-axis, only a comment changed: it said "same body as the snippet", which is no longer true.

Smoke, in blender-smoke.yml (both matrix series)

  • tests/smoke/make_pipeline_glb.py now takes --parented and --radius. The parented fixture is a Root empty rotated 90° on X, scaled 2x, at z=3. Under it are a Body box and two wheels that share one mesh (users == 2) and have different rotations.
  • New tests/smoke/check_pipeline_glb.py re-imports the source and lod0.glb. Exit codes: 3 when lod0.glb has more than one mesh object, 4 when its world bbox differs from the source's, 5 when the origin is not on the world minimum Z, 6 when the node has rotation or scale.
  • tests/smoke/make_input.py also writes stack.blend, a cube with a live SUBSURF at levels 1. New tests/smoke/check_glb_tris.py asserts the GLB's triangle count.
  • New CI steps:
    • The headless template runs on stack.blend with --apply-modifier TRIANGULATE, and the GLB must have 48 tris.
    • The pipeline runs on the parented fixture, and its output must pass check_pipeline_glb.py.
    • A falsifier runs the pipeline on a radius-1000 sphere and passes only on exit 8.

Evidence

Live run (Blender 5.2.1 LTS .scratch\blender-5.2.1-windows-x64\blender.exe and 4.5.11 LTS .scratch\blender-4.5.11-windows-x64\blender.exe; both binaries report those versions, and the two gave identical results)

Case Result
headless template, stack.blend + TRIANGULATE exit 0; 26 verts / 48 faces; check_glb_tris 48 = 48, exit 0
headless template, input.blend + SUBSURF (existing CI case) exit 0
headless template, empty.blend exit 2 (unchanged)
pipeline, default sphere 1024,256,64 exit 0; LOD tris 1024 / 256 / 64
pipeline, parented shared-mesh fixture exit 0; "Joined 3 part(s)"; check_pipeline_glb exit 0, bbox [-2.2179,-1.0,2.0,2.0,1.2,4.0] matches the source, origin z = 2.0000 = min z
pipeline, radius-1000 sphere exit 8 ("largest extent 2000.0014 m is outside [0.01, 100.0] m")

Falsification probes (live, both versions, scratch copies of the script with one step removed)

Probe Measured
isolate_mesh_data removed, parented fixture exit 12: Cannot apply to a multi user: Object "WheelL", Mesh "WheelMesh"
clear_parent_keep_transform removed pipeline exit 0, check_pipeline_glb exit 5: origin z = 3.0000 against min z = 2.0000
join_meshes replaced by the old max(meshes, key=tris) pipeline exit 0, check_pipeline_glb exit 4: bbox drift 3.8179 (only WheelL shipped)
decimate_to_budget removed, sphere exit 9: Fixture_LOD0 has 2208 triangles, over its budget of 1024
old pipeline.py from main, parented fixture exit 1, uncaught RuntimeError: Cannot apply to a multi user
old script.py from main, stack.blend + TRIANGULATE check_glb_tris exit 3: 72 triangles against 48 expected (4.5.11 also printed Info: Applied modifier was not first)
check_ops_in_loops on the old script.py and the old pipeline.py flags script.py:84 (add_and_apply_modifier() -> bpy.ops.object.modifier_apply) and pipeline.py:376 (export_preset() -> bpy.ops.object.transform_apply); the current tree is clean

Two things have no CI falsifier:

  • The LOD-budget check (exit 9). A budget of 1 turned out to be reachable: collapse decimated the sphere to exactly 1 triangle on both versions, so that is not a natural failing input. The check's falsifier is the local probe above.
  • The matrix_world identity check. Switching it back to matrix_basis alone fails nothing, because the unparent step already makes the two matrices equal. The matrix_world read protects the skill's pattern when someone keeps the parents.

Inspection only

  • The snippet and skill copies of apply_selected_mesh_transforms are the same code as the template path proven live above. I did not run them separately.
  • The new CI steps were checked against the local commands they mirror. They are proven in CI on this PR, not locally on Linux.

Local validators

python tests/run_all.py: 28 passed. The 3 failures were test_bump_kind, test_plugin_content_changed and test_release_gate. They fail only under PowerShell, where bash is missing (exit 127), and pass under Git Bash. tests/check_example_rules.py: 145 scripts plus 3 templates, clean.

🤖 Generated with Claude Code

…bake modifier stacks in order

The pipeline template exported only the largest mesh with exit 0. Its unit
check could never fire, and LOD budgets were printed but never checked.
Shared (instanced) mesh data made transform_apply raise. A child of a rotated
glTF root passed the matrix_basis identity test and was grounded along a local
axis. The headless template's non-first modifier_apply reversed the stack
order, contrary to its README.

The pipeline now isolates mesh data, unparents keeping world placement,
applies transforms and joins every part. It checks the asset's extent (exit 8)
and each LOD budget (exit 9), and exits 12 when the apply or join raises.
The cleanup and export-preset skills and the three preset snippets get the
same isolation and unparent guards. The headless template bakes the whole
stack through new_from_object. check_example_rules now scans templates/ and
flags object operators reached from a loop. Smoke covers the shared-mesh
parented GLB, the modifier order, and the out-of-range extent.

Signed-off-by: TMHSDigital <tmhospitalitystrategies@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot added skills snippets templates examples Runnable smoke-gated examples under examples/ documentation Improvements or additions to documentation ci labels Oct 7, 2026
@TMHSDigital
TMHSDigital merged commit f59ff3f into main Oct 7, 2026
13 checks passed
@TMHSDigital
TMHSDigital deleted the fix/templates-multi-mesh-shared-parented branch October 7, 2026 17:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment