Skip to content

Commit f59ff3f

Browse files
TMHSDigitalclaude
andauthored
fix(templates): keep every mesh, handle shared and parented imports, bake modifier stacks in order (#477)
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>
1 parent 89ec30a commit f59ff3f

16 files changed

Lines changed: 580 additions & 104 deletions

File tree

‎.github/workflows/blender-smoke.yml‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,18 @@ jobs:
218218
[ "$code" -eq 2 ] || { echo "::error::expected exit 2 for no-mesh input, got $code"; exit 1; }
219219
echo "no-mesh exit code = $code (correct)"
220220
221+
- name: Headless template applies the new modifier after the existing stack (#468)
222+
run: |
223+
set -euo pipefail
224+
# stack.blend: a cube with a live SUBSURF (levels 1). SUBSURF then
225+
# TRIANGULATE is 48 tris; the reversed order a non-first
226+
# modifier_apply produces is 72.
227+
xvfb-run -a "$BLENDER" --background "$RUNNER_TEMP/out/stack.blend" \
228+
--python-exit-code 1 --python templates/headless-batch-script-template/script.py -- \
229+
--output "$RUNNER_TEMP/out/stack.glb" --apply-modifier TRIANGULATE
230+
xvfb-run -a "$BLENDER" --background --python-exit-code 1 --python tests/smoke/check_glb_tris.py -- \
231+
"$RUNNER_TEMP/out/stack.glb" 48
232+
221233
- name: Extension template passes Blender's own manifest validator
222234
run: |
223235
set -euo pipefail
@@ -256,6 +268,39 @@ jobs:
256268
[ "$code" -eq 2 ] || { echo "::error::expected exit 2 for missing input, got $code"; exit 1; }
257269
echo "missing-input exit code = $code (correct)"
258270
271+
- name: Pipeline template keeps every part of a shared-mesh, parented GLB (#462, #467)
272+
run: |
273+
set -euo pipefail
274+
# Root rotated 90 deg on X, scaled 2x, at z=3; Body plus two wheels
275+
# sharing one mesh. lod0 must hold every part (world bbox == source),
276+
# grounded on its world minimum Z, with no node rotation or scale.
277+
mkdir -p "$RUNNER_TEMP/out/pipeline_parented"
278+
xvfb-run -a "$BLENDER" --background --python-exit-code 1 --python tests/smoke/make_pipeline_glb.py -- \
279+
"$RUNNER_TEMP/out/parented_src.glb" --parented
280+
xvfb-run -a "$BLENDER" --background --python-exit-code 1 --python templates/ai-asset-pipeline-template/pipeline.py -- \
281+
--input "$RUNNER_TEMP/out/parented_src.glb" \
282+
--outdir "$RUNNER_TEMP/out/pipeline_parented" \
283+
--preset unity \
284+
--lod-budgets 1024,256,64 \
285+
--collider convex
286+
xvfb-run -a "$BLENDER" --background --python-exit-code 1 --python tests/smoke/check_pipeline_glb.py -- \
287+
"$RUNNER_TEMP/out/parented_src.glb" "$RUNNER_TEMP/out/pipeline_parented/lod0.glb"
288+
289+
- name: Falsifier - pipeline template rejects a 2 km asset with exit 8
290+
run: |
291+
set -euo pipefail
292+
xvfb-run -a "$BLENDER" --background --python-exit-code 1 --python tests/smoke/make_pipeline_glb.py -- \
293+
"$RUNNER_TEMP/out/huge_src.glb" --radius 1000
294+
set +e
295+
xvfb-run -a "$BLENDER" --background --python-exit-code 1 --python templates/ai-asset-pipeline-template/pipeline.py -- \
296+
--input "$RUNNER_TEMP/out/huge_src.glb" \
297+
--outdir "$RUNNER_TEMP/out/pipeline" \
298+
--preset unity
299+
code=$?
300+
set -e
301+
[ "$code" -eq 8 ] || { echo "::error::expected exit 8 for out-of-range extent, got $code"; exit 1; }
302+
echo "out-of-range extent exit code = $code (correct)"
303+
259304
- name: Headless render template runs (exit 0, PNG produced)
260305
run: |
261306
set -euo pipefail

‎examples/export-preset-axis/export_preset_axis.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -307,9 +307,11 @@ def principled(name, color, metallic, roughness):
307307

308308

309309
def apply_selected_mesh_transforms():
310-
# Same body as snippets/export_preset_unity.py: one operator call for the
310+
# Core of snippets/export_preset_unity.py: one operator call for the
311311
# whole selection. transform_apply acts on selected_editable_objects, so
312312
# that is the key to override; selected_objects alone does not narrow it.
313+
# The snippet's shared-mesh / parent prelude is left out: this scene
314+
# builds single-user, unparented meshes.
313315
meshes = [o for o in bpy.context.selected_objects if o.type == "MESH"]
314316
if not meshes:
315317
return

‎skills/ai-mesh-cleanup/SKILL.md‎

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,34 @@ def scene_units_are_meters(scene):
4141
return abs(units.scale_length - 1.0) < 1e-6
4242

4343

44+
def isolate_mesh_data(objs):
45+
# glTF instancing imports as several objects sharing one Mesh (users > 1).
46+
# transform_apply refuses multi-user data, and origin_to_base() on shared
47+
# data moves every other instance. One copy per object; the last user
48+
# keeps the original.
49+
for obj in objs:
50+
if obj.data.users > 1:
51+
obj.data = obj.data.copy()
52+
53+
54+
def clear_parent_keep_transform(objs):
55+
# glTF node trees import meshes under a root empty that is often rotated
56+
# or scaled. Applying the child's own rotation leaves the root's in
57+
# matrix_world, so local Z is still not world Z.
58+
for obj in objs:
59+
if obj.parent is not None:
60+
world = obj.matrix_world.copy()
61+
obj.parent = None
62+
obj.matrix_world = world
63+
bpy.context.view_layer.update()
64+
65+
4466
def rot_scale_is_identity(obj, tol=1e-6):
4567
# Rotation and scale both: origin_to_base() shifts along local Z, which is
4668
# world Z only once rotation is applied. A GLB node often carries a
47-
# rotation with identity scale.
48-
m = obj.matrix_basis.to_3x3()
69+
# rotation with identity scale. Read matrix_world, not matrix_basis: a
70+
# child of a rotated root has an identity basis.
71+
m = obj.matrix_world.to_3x3()
4972
return all(
5073
abs(m[i][j] - (1.0 if i == j else 0.0)) < tol
5174
for i in range(3)
@@ -68,7 +91,8 @@ def apply_transforms(objs):
6891

6992

7093
def origin_to_base(obj):
71-
# Precondition: rotation and scale applied (local Z == world Z).
94+
# Precondition: unparented, rotation and scale applied (local Z == world
95+
# Z), and single-user data (isolate_mesh_data), or other instances move.
7296
mesh = obj.data
7397
n = len(mesh.vertices)
7498
flat = [0.0] * (n * 3)
@@ -120,9 +144,14 @@ if not scene_units_are_meters(scene):
120144
scene.unit_settings.system = "METRIC"
121145
scene.unit_settings.scale_length = 1.0
122146

123-
apply_transforms([o for o in imported_meshes() if not rot_scale_is_identity(o)])
147+
meshes = imported_meshes()
148+
isolate_mesh_data(meshes)
149+
clear_parent_keep_transform(meshes)
150+
apply_transforms([o for o in meshes if not rot_scale_is_identity(o)])
124151
```
125152

153+
The scene check above only catches a scene someone changed. A fresh or factory scene is always metric at 1.0, so it cannot see a prop written in centimeters as meters. Also check the geometry: after the apply, the largest bounding-box extent of a prop should be within a plausible range (the pipeline template uses 0.01 m to 100 m and exits non-zero outside it).
154+
126155
`export_apply=True` on glTF applies **modifiers**, not object scale. Unapplied object scale lands on the glTF node. Witness: [`examples/unapplied-scale-gltf/`](https://github.com/TMHSDigital/Blender-Developer-Tools/tree/main/examples/unapplied-scale-gltf).
127156

128157
### 3. Apply transforms
@@ -131,6 +160,11 @@ apply_transforms([o for o in imported_meshes() if not rot_scale_is_identity(o)])
131160

132161
Gate the apply on rotation **and** scale (`rot_scale_is_identity`). Imported glTF nodes often carry a rotation with identity scale; skipping the apply then makes step 4 ground the mesh along its local Z, which moves it in world space (a cube rotated 90° on X at z=5 shifted by −1 in Y and Z).
133162

163+
Two import shapes break a naive apply, and both are ordinary glTF:
164+
165+
- **Shared mesh data.** Instanced nodes import as several objects on one Mesh (`users == 2` after a round trip). `transform_apply` raises `RuntimeError: Cannot apply to a multi user` on both 4.5.11 and 5.2.1. Run `isolate_mesh_data` first. `transform_apply(isolate_users=True)` also exists on both lines (measured on 4.5.11 and 5.2.1), but it only helps the apply. `origin_to_base` rewrites vertices and still needs single-user data.
166+
- **Parented meshes.** A child of a rotated or scaled root has an identity `matrix_basis`, so a basis check skips it and the root's rotation stays in `matrix_world`. Unparent with the world matrix kept, then test `matrix_world`. Measured with the pipeline template's parented smoke fixture on 4.5.11 and 5.2.1 (a root rotated 90° on X and scaled 2x at z=3). With the unparent step removed, LOD0 exported with its origin at z=3.0 while the geometry's minimum was z=2.0.
167+
134168
### 4. Set origin
135169

136170
Origin at the lowest Z of the mesh (sit-on-ground) via `foreach_get` / `foreach_set`, not a Python loop on `mesh.vertices`. This works in local space, so run it only after step 3. See [`examples/prop-origin-transform/`](https://github.com/TMHSDigital/Blender-Developer-Tools/tree/main/examples/prop-origin-transform) for origin-to-base plus `matrix_parent_inverse`.
@@ -182,6 +216,7 @@ Draco, selected-only, explicit `export_yup`, and `export_apply=True` so the deci
182216
5. **`bm.normal_update()` for flipped faces.** Use `recalc_face_normals`.
183217
6. **Import then `bpy.ops.mesh.*` with no scale check.** Rule `validate-imported-mesh-scale`.
184218
7. **Export with a live DECIMATE and `export_apply=False`.** The engine gets the dense mesh. Rule `no-unapplied-modifiers-on-export`.
219+
8. **Assuming one imported object, single-user and unparented.** Picking the largest mesh drops every other part. Applying shared data raises, and testing `matrix_basis` misses a rotated parent. Isolate the data, unparent, apply, then join if the engine wants one asset.
185220

186221
## Version correctness
187222

‎skills/engine-export-presets/SKILL.md‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,17 +33,27 @@ Before any preset: meters in the scene (`scale_length == 1.0`), identity object
3333
```python
3434
def apply_selected_mesh_transforms():
3535
# One operator call for the whole selection. transform_apply reads
36-
# selected_editable_objects, so that is the key to override; overriding
37-
# selected_objects alone does not narrow it.
36+
# selected_editable_objects, so that is the key to override. It refuses
37+
# shared (glTF-instanced) mesh data, so copy it per object first, and
38+
# unparent keeping the world placement, or a rotated root stays on the node.
3839
meshes = [o for o in bpy.context.selected_objects if o.type == "MESH"]
3940
if not meshes:
4041
return
42+
for o in meshes:
43+
if o.data.users > 1:
44+
o.data = o.data.copy()
45+
if o.parent is not None:
46+
world = o.matrix_world.copy()
47+
o.parent = None
48+
o.matrix_world = world
4149
with bpy.context.temp_override(
4250
object=meshes[0], active_object=meshes[0], selected_editable_objects=meshes
4351
):
4452
bpy.ops.object.transform_apply(location=False, rotation=True, scale=True)
4553
```
4654

55+
The two guards are for ordinary imports. Instanced glTF nodes come back as several objects sharing one Mesh, and `transform_apply` then raises `Cannot apply to a multi user` (4.5.11 and 5.2.1). A mesh under a rotated root has an identity `matrix_basis`. Applying it leaves the root's rotation in `matrix_world`, and `use_selection=True` writes that rotation onto the exported node. Copying the data means instances no longer share one mesh. That is the cost of baking transforms into vertices.
56+
4757
`use_selection=True` on every preset. Draco is opt-in on glTF; do not copy `gltf_draco_export.py` wholesale.
4858

4959
## glTF is the same for every engine

‎snippets/export_preset_godot.py‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,19 @@
1818

1919
def apply_selected_mesh_transforms():
2020
# One operator call for the whole selection. transform_apply reads
21-
# selected_editable_objects, so that is the key to override; overriding
22-
# selected_objects alone does not narrow it.
21+
# selected_editable_objects, so that is the key to override. It refuses
22+
# shared (glTF-instanced) mesh data, so copy it per object first, and
23+
# unparent keeping the world placement, or a rotated root stays on the node.
2324
meshes = [o for o in bpy.context.selected_objects if o.type == "MESH"]
2425
if not meshes:
2526
return
27+
for o in meshes:
28+
if o.data.users > 1:
29+
o.data = o.data.copy()
30+
if o.parent is not None:
31+
world = o.matrix_world.copy()
32+
o.parent = None
33+
o.matrix_world = world
2634
with bpy.context.temp_override(
2735
object=meshes[0], active_object=meshes[0], selected_editable_objects=meshes
2836
):

‎snippets/export_preset_unity.py‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,19 @@
1616

1717
def apply_selected_mesh_transforms():
1818
# One operator call for the whole selection. transform_apply reads
19-
# selected_editable_objects, so that is the key to override; overriding
20-
# selected_objects alone does not narrow it.
19+
# selected_editable_objects, so that is the key to override. It refuses
20+
# shared (glTF-instanced) mesh data, so copy it per object first, and
21+
# unparent keeping the world placement, or a rotated root stays on the node.
2122
meshes = [o for o in bpy.context.selected_objects if o.type == "MESH"]
2223
if not meshes:
2324
return
25+
for o in meshes:
26+
if o.data.users > 1:
27+
o.data = o.data.copy()
28+
if o.parent is not None:
29+
world = o.matrix_world.copy()
30+
o.parent = None
31+
o.matrix_world = world
2432
with bpy.context.temp_override(
2533
object=meshes[0], active_object=meshes[0], selected_editable_objects=meshes
2634
):

‎snippets/export_preset_unreal.py‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,19 @@
1919

2020
def apply_selected_mesh_transforms():
2121
# One operator call for the whole selection. transform_apply reads
22-
# selected_editable_objects, so that is the key to override; overriding
23-
# selected_objects alone does not narrow it.
22+
# selected_editable_objects, so that is the key to override. It refuses
23+
# shared (glTF-instanced) mesh data, so copy it per object first, and
24+
# unparent keeping the world placement, or a rotated root stays on the node.
2425
meshes = [o for o in bpy.context.selected_objects if o.type == "MESH"]
2526
if not meshes:
2627
return
28+
for o in meshes:
29+
if o.data.users > 1:
30+
o.data = o.data.copy()
31+
if o.parent is not None:
32+
world = o.matrix_world.copy()
33+
o.parent = None
34+
o.matrix_world = world
2735
with bpy.context.temp_override(
2836
object=meshes[0], active_object=meshes[0], selected_editable_objects=meshes
2937
):

‎templates/ai-asset-pipeline-template/README.md‎

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,25 @@ same way for every preset.
4646

4747
1. Parses script-side args after `--`.
4848
2. Imports the GLB into an empty scene.
49-
3. Checks scene units (metric meters), applies object rotation/scale,
50-
sits the origin on the lowest Z, recalculates face normals, and
51-
prints the evaluated triangle count.
52-
4. Builds an LOD chain from `--lod-budgets`.
53-
5. Optionally builds a convex hull or AABB box collider.
54-
6. Exports each LOD (and the collider) under the chosen engine preset.
55-
7. Returns explicit exit codes so a CI pipeline can detect failures.
49+
3. Keeps **every** mesh in the file. Shared (instanced) mesh data gets one
50+
copy per object, parents are cleared with the world placement kept,
51+
rotation and scale are applied, and all parts are joined into one
52+
object (`object.join`, which keeps material slots). A body plus
53+
separate wheels ships as one asset with the wheels in place; nothing
54+
is dropped. Split the parts in your own code before this step if your
55+
engine wants them as separate assets.
56+
4. Checks the joined asset's largest bounding-box extent against
57+
0.01 m to 100 m (`MIN_EXTENT_M` / `MAX_EXTENT_M`). A fresh scene is
58+
always metric at scale 1.0, so this measures the imported geometry,
59+
not the scene settings. A prop written in centimeters as meters
60+
fails here instead of shipping 100x too large.
61+
5. Sits the origin on the lowest world Z, recalculates face normals,
62+
and prints the evaluated triangle count.
63+
6. Builds an LOD chain from `--lod-budgets` and asserts that each LOD's
64+
evaluated triangle count is at or under its budget.
65+
7. Optionally builds a convex hull or AABB box collider.
66+
8. Exports each LOD (and the collider) under the chosen engine preset.
67+
9. Returns explicit exit codes so a CI pipeline can detect failures.
5668

5769
Cleanup order follows `ai-mesh-cleanup`. LOD, collider, and export
5870
helpers are duplicated from the snippets named in `pipeline.py`'s
@@ -74,6 +86,12 @@ as `export-preset-axis` number their own checks independently.
7486
| 5 | Import produced no mesh |
7587
| 6 | `outdir` is not a directory, or glTF export failed / wrote no file |
7688
| 7 | `--collider convex` produced a hull that is not closed (an edge without exactly two faces), e.g. a flat input |
89+
| 8 | The joined asset's largest extent is outside 0.01 m to 100 m (wrong source units) |
90+
| 9 | An LOD's evaluated triangle count is over its `--lod-budgets` entry |
91+
| 12 | `transform_apply` or `object.join` raised `RuntimeError` (for example multi-user data that was not isolated) |
92+
93+
10 and 11 are skipped on purpose. The repo's shared render gates use them
94+
(framing and asset quality), so a template never reuses them.
7795

7896
## Expected environment
7997

@@ -91,6 +109,14 @@ as `export-preset-axis` number their own checks independently.
91109
- **Running without `--background`**. The script still works, but
92110
Blender opens a UI window and stays open after the script finishes.
93111
Use `--background` for unattended runs.
112+
- **Instanced and parented glTF nodes**. Two nodes on one mesh import as
113+
two objects sharing one Mesh (`users == 2`), and `transform_apply`
114+
refuses that data. A mesh under a rotated root has an identity
115+
`matrix_basis`, so the rotation lives only in `matrix_world`. The
116+
script isolates and unparents before applying. If you remove that
117+
step, a shared-mesh input exits 12. A parented input exits 0 but ships
118+
with its origin off the ground: on the smoke fixture, z=3.0 against a
119+
geometry minimum of z=2.0.
94120
- **Operators that need a 3D Viewport context**. Some operators only
95121
work when a `VIEW_3D` area exists. In headless mode, none does.
96122
Either rewrite using `bpy.data.*`, or fabricate a window+area via

0 commit comments

Comments
 (0)