Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .github/workflows/blender-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,18 @@ jobs:
[ "$code" -eq 2 ] || { echo "::error::expected exit 2 for no-mesh input, got $code"; exit 1; }
echo "no-mesh exit code = $code (correct)"

- name: Headless template applies the new modifier after the existing stack (#468)
run: |
set -euo pipefail
# stack.blend: a cube with a live SUBSURF (levels 1). SUBSURF then
# TRIANGULATE is 48 tris; the reversed order a non-first
# modifier_apply produces is 72.
xvfb-run -a "$BLENDER" --background "$RUNNER_TEMP/out/stack.blend" \
--python-exit-code 1 --python templates/headless-batch-script-template/script.py -- \
--output "$RUNNER_TEMP/out/stack.glb" --apply-modifier TRIANGULATE
xvfb-run -a "$BLENDER" --background --python-exit-code 1 --python tests/smoke/check_glb_tris.py -- \
"$RUNNER_TEMP/out/stack.glb" 48

- name: Extension template passes Blender's own manifest validator
run: |
set -euo pipefail
Expand Down Expand Up @@ -256,6 +268,39 @@ jobs:
[ "$code" -eq 2 ] || { echo "::error::expected exit 2 for missing input, got $code"; exit 1; }
echo "missing-input exit code = $code (correct)"

- name: Pipeline template keeps every part of a shared-mesh, parented GLB (#462, #467)
run: |
set -euo pipefail
# Root rotated 90 deg on X, scaled 2x, at z=3; Body plus two wheels
# sharing one mesh. lod0 must hold every part (world bbox == source),
# grounded on its world minimum Z, with no node rotation or scale.
mkdir -p "$RUNNER_TEMP/out/pipeline_parented"
xvfb-run -a "$BLENDER" --background --python-exit-code 1 --python tests/smoke/make_pipeline_glb.py -- \
"$RUNNER_TEMP/out/parented_src.glb" --parented
xvfb-run -a "$BLENDER" --background --python-exit-code 1 --python templates/ai-asset-pipeline-template/pipeline.py -- \
--input "$RUNNER_TEMP/out/parented_src.glb" \
--outdir "$RUNNER_TEMP/out/pipeline_parented" \
--preset unity \
--lod-budgets 1024,256,64 \
--collider convex
xvfb-run -a "$BLENDER" --background --python-exit-code 1 --python tests/smoke/check_pipeline_glb.py -- \
"$RUNNER_TEMP/out/parented_src.glb" "$RUNNER_TEMP/out/pipeline_parented/lod0.glb"

- name: Falsifier - pipeline template rejects a 2 km asset with exit 8
run: |
set -euo pipefail
xvfb-run -a "$BLENDER" --background --python-exit-code 1 --python tests/smoke/make_pipeline_glb.py -- \
"$RUNNER_TEMP/out/huge_src.glb" --radius 1000
set +e
xvfb-run -a "$BLENDER" --background --python-exit-code 1 --python templates/ai-asset-pipeline-template/pipeline.py -- \
--input "$RUNNER_TEMP/out/huge_src.glb" \
--outdir "$RUNNER_TEMP/out/pipeline" \
--preset unity
code=$?
set -e
[ "$code" -eq 8 ] || { echo "::error::expected exit 8 for out-of-range extent, got $code"; exit 1; }
echo "out-of-range extent exit code = $code (correct)"

- name: Headless render template runs (exit 0, PNG produced)
run: |
set -euo pipefail
Expand Down
4 changes: 3 additions & 1 deletion examples/export-preset-axis/export_preset_axis.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,9 +307,11 @@ def principled(name, color, metallic, roughness):


def apply_selected_mesh_transforms():
# Same body as snippets/export_preset_unity.py: one operator call for the
# Core of snippets/export_preset_unity.py: one operator call for the
# whole selection. transform_apply acts on selected_editable_objects, so
# that is the key to override; selected_objects alone does not narrow it.
# The snippet's shared-mesh / parent prelude is left out: this scene
# builds single-user, unparented meshes.
meshes = [o for o in bpy.context.selected_objects if o.type == "MESH"]
if not meshes:
return
Expand Down
43 changes: 39 additions & 4 deletions skills/ai-mesh-cleanup/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,34 @@ def scene_units_are_meters(scene):
return abs(units.scale_length - 1.0) < 1e-6


def isolate_mesh_data(objs):
# glTF instancing imports as several objects sharing one Mesh (users > 1).
# transform_apply refuses multi-user data, and origin_to_base() on shared
# data moves every other instance. One copy per object; the last user
# keeps the original.
for obj in objs:
if obj.data.users > 1:
obj.data = obj.data.copy()


def clear_parent_keep_transform(objs):
# glTF node trees import meshes under a root empty that is often rotated
# or scaled. Applying the child's own rotation leaves the root's in
# matrix_world, so local Z is still not world Z.
for obj in objs:
if obj.parent is not None:
world = obj.matrix_world.copy()
obj.parent = None
obj.matrix_world = world
bpy.context.view_layer.update()


def rot_scale_is_identity(obj, tol=1e-6):
# Rotation and scale both: origin_to_base() shifts along local Z, which is
# world Z only once rotation is applied. A GLB node often carries a
# rotation with identity scale.
m = obj.matrix_basis.to_3x3()
# rotation with identity scale. Read matrix_world, not matrix_basis: a
# child of a rotated root has an identity basis.
m = obj.matrix_world.to_3x3()
return all(
abs(m[i][j] - (1.0 if i == j else 0.0)) < tol
for i in range(3)
Expand All @@ -68,7 +91,8 @@ def apply_transforms(objs):


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

apply_transforms([o for o in imported_meshes() if not rot_scale_is_identity(o)])
meshes = imported_meshes()
isolate_mesh_data(meshes)
clear_parent_keep_transform(meshes)
apply_transforms([o for o in meshes if not rot_scale_is_identity(o)])
```

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).

`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).

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

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).

Two import shapes break a naive apply, and both are ordinary glTF:

- **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.
- **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.

### 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`. 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`.
Expand Down Expand Up @@ -182,6 +216,7 @@ Draco, selected-only, explicit `export_yup`, and `export_apply=True` so the deci
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`.
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.

## Version correctness

Expand Down
14 changes: 12 additions & 2 deletions skills/engine-export-presets/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,17 +33,27 @@ Before any preset: meters in the scene (`scale_length == 1.0`), identity object
```python
def apply_selected_mesh_transforms():
# One operator call for the whole selection. transform_apply reads
# selected_editable_objects, so that is the key to override; overriding
# selected_objects alone does not narrow it.
# selected_editable_objects, so that is the key to override. It refuses
# shared (glTF-instanced) mesh data, so copy it per object first, and
# unparent keeping the world placement, or a rotated root stays on the node.
meshes = [o for o in bpy.context.selected_objects if o.type == "MESH"]
if not meshes:
return
for o in meshes:
if o.data.users > 1:
o.data = o.data.copy()
if o.parent is not None:
world = o.matrix_world.copy()
o.parent = None
o.matrix_world = world
with bpy.context.temp_override(
object=meshes[0], active_object=meshes[0], selected_editable_objects=meshes
):
bpy.ops.object.transform_apply(location=False, rotation=True, scale=True)
```

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.

`use_selection=True` on every preset. Draco is opt-in on glTF; do not copy `gltf_draco_export.py` wholesale.

## glTF is the same for every engine
Expand Down
12 changes: 10 additions & 2 deletions snippets/export_preset_godot.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,19 @@

def apply_selected_mesh_transforms():
# One operator call for the whole selection. transform_apply reads
# selected_editable_objects, so that is the key to override; overriding
# selected_objects alone does not narrow it.
# selected_editable_objects, so that is the key to override. It refuses
# shared (glTF-instanced) mesh data, so copy it per object first, and
# unparent keeping the world placement, or a rotated root stays on the node.
meshes = [o for o in bpy.context.selected_objects if o.type == "MESH"]
if not meshes:
return
for o in meshes:
if o.data.users > 1:
o.data = o.data.copy()
if o.parent is not None:
world = o.matrix_world.copy()
o.parent = None
o.matrix_world = world
with bpy.context.temp_override(
object=meshes[0], active_object=meshes[0], selected_editable_objects=meshes
):
Expand Down
12 changes: 10 additions & 2 deletions snippets/export_preset_unity.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,19 @@

def apply_selected_mesh_transforms():
# One operator call for the whole selection. transform_apply reads
# selected_editable_objects, so that is the key to override; overriding
# selected_objects alone does not narrow it.
# selected_editable_objects, so that is the key to override. It refuses
# shared (glTF-instanced) mesh data, so copy it per object first, and
# unparent keeping the world placement, or a rotated root stays on the node.
meshes = [o for o in bpy.context.selected_objects if o.type == "MESH"]
if not meshes:
return
for o in meshes:
if o.data.users > 1:
o.data = o.data.copy()
if o.parent is not None:
world = o.matrix_world.copy()
o.parent = None
o.matrix_world = world
with bpy.context.temp_override(
object=meshes[0], active_object=meshes[0], selected_editable_objects=meshes
):
Expand Down
12 changes: 10 additions & 2 deletions snippets/export_preset_unreal.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,19 @@

def apply_selected_mesh_transforms():
# One operator call for the whole selection. transform_apply reads
# selected_editable_objects, so that is the key to override; overriding
# selected_objects alone does not narrow it.
# selected_editable_objects, so that is the key to override. It refuses
# shared (glTF-instanced) mesh data, so copy it per object first, and
# unparent keeping the world placement, or a rotated root stays on the node.
meshes = [o for o in bpy.context.selected_objects if o.type == "MESH"]
if not meshes:
return
for o in meshes:
if o.data.users > 1:
o.data = o.data.copy()
if o.parent is not None:
world = o.matrix_world.copy()
o.parent = None
o.matrix_world = world
with bpy.context.temp_override(
object=meshes[0], active_object=meshes[0], selected_editable_objects=meshes
):
Expand Down
40 changes: 33 additions & 7 deletions templates/ai-asset-pipeline-template/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,25 @@ same way for every preset.

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

Cleanup order follows `ai-mesh-cleanup`. LOD, collider, and export
helpers are duplicated from the snippets named in `pipeline.py`'s
Expand All @@ -74,6 +86,12 @@ as `export-preset-axis` number their own checks independently.
| 5 | Import produced no mesh |
| 6 | `outdir` is not a directory, or glTF export failed / wrote no file |
| 7 | `--collider convex` produced a hull that is not closed (an edge without exactly two faces), e.g. a flat input |
| 8 | The joined asset's largest extent is outside 0.01 m to 100 m (wrong source units) |
| 9 | An LOD's evaluated triangle count is over its `--lod-budgets` entry |
| 12 | `transform_apply` or `object.join` raised `RuntimeError` (for example multi-user data that was not isolated) |

10 and 11 are skipped on purpose. The repo's shared render gates use them
(framing and asset quality), so a template never reuses them.

## Expected environment

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