Root CauseWhat broke, why, and the fix.

The Blender transform you read back is not the one you just set

· blender, python, automation, debugging
ⓘ Operated by TechAthletes. Every post here is a bug we hit in our own work — symptom, root cause, fix. Nothing is sponsored and we are not paid to mention any tool.

Our headless Blender batch pipeline produced assembled models that flew apart when rotated. A key’s teeth became a giant cube. A rocket drifted out of frame in a turntable render.

Different symptoms. The same place to look: the transform we thought we were using.

The scripts ran on Blender 5.2 LTS through blender -b --factory-startup -P script.py. Setting a rotation or scale looked straightforward. But the transform I read back was not necessarily the transform I had just set. And rotating every part did not mean rotating the assembly.

The first wrong assumption: rotate every part

The first trap was treating a collection of objects as if it were already one object.

A heart assembled from two spheres and a cone needs its parts to move together. Applying rotation_euler.rotate_axis() to each part looked like a way to tilt the whole model:

for o in objs:
    o.rotation_euler.rotate_axis("Z", rad)

That rotates each object around its own origin. It does not rotate the objects’ positions around a shared pivot. The parts receive rotations, but the assembled shape comes apart.

There was another assumption inside that "Z": that it meant the same direction for every part.

It did not. rotate_axis() uses a local axis. A torus already rotated by π/2 has a different local orientation, so the same call can rotate it around a different axis from the other parts.

The code said “rotate each object.” I was asking it to mean “rotate the assembly.” Those are different operations.

The second wrong assumption: the matrix is current

A world matrix gave us the right way to express a shared rotation. But reading it introduced the next trap.

Immediately after creating a cube and assigning its scale, matrix_world could still return the matrix from before that assignment had been evaluated. The scale in that matrix was still 1.

The misleading sequence was:

bpy.ops.mesh.primitive_cube_add()
o = bpy.context.object
o.scale = (sx, sy, sz)

world = o.matrix_world.copy()

The scale assignment was there. The next line was there. Nothing between them made the dependency graph update implicit.

Using that old matrix to build a rotation or a matrix_parent_inverse carried the old transform into the next operation. That was how a key’s teeth became a giant cube, and how the rocket shifted out of frame during the turntable render.

This was not just a question of choosing the right matrix multiplication. The input matrix was already wrong for the state I intended to use.

Root cause

Both traps came from treating transform operations as if their meaning and their evaluated result were obvious from the preceding assignment.

There were two separate requirements.

First, the rotation had to describe motion around a common world-space pivot. Rotating each object around its own origin could not do that.

Second, the world matrices had to include the scale and position changes already made by the script. Before the dependency graph update, reading matrix_world could give us the earlier state.

The update does not turn local rotations into an assembly rotation. It makes the matrices current. The world-space multiplication then applies the shared rotation.

That order matters: assign the transforms, update the view layer, read the matrices, then compose the rotation.

The fix

For the assembled objects, the fix was:

import bpy
from mathutils import Matrix

bpy.context.view_layer.update()

rotation = Matrix.Rotation(rad, 4, "Z")
for o in objs:
    o.matrix_world = rotation @ o.matrix_world

The common pivot here is the world origin. Left-multiplying each world matrix by the same rotation rotates the parts around that origin, including their positions. The assembly moves together.

The update before the loop is part of the fix. Without it, the multiplication can still start from a matrix missing a recent scale or position change.

For a newly created part, the same ordering applies:

bpy.ops.mesh.primitive_cube_add()
o = bpy.context.object
o.scale = (sx, sy, sz)

bpy.context.view_layer.update()
world = o.matrix_world.copy()

That is also the point to respect before reading a matrix for matrix_parent_inverse. Changing what we do with the matrix does not make an earlier read current.

The camera needed evaluated bounds too

There was a separate framing problem: a fixed camera could cut off tall assets.

The framing calculation used every mesh’s bounding-box corners transformed into world coordinates. From those points, we took the center and diagonal length:

from mathutils import Vector

bpy.context.view_layer.update()

points = [
    o.matrix_world @ Vector(corner)
    for o in objs if o.type == "MESH"
    for corner in o.bound_box
]
low = Vector([min(p[i] for p in points) for i in range(3)])
high = Vector([max(p[i] for p in points) for i in range(3)])
center = (low + high) / 2
diag = (high - low).length

We placed the camera at center + direction * 10, aimed it at an empty target using TRACK_TO, and set ortho_scale = diag * 0.95. That removed the need to adjust the orthographic scale separately for each asset.

What I would check first next time

For scattered parts, I would check the pivot and whether the rotation axis is local or world-space.

For an oversized or displaced part, I would check whether the view layer was updated before the matrix read. The scale assignment alone is not evidence that the matrix contains it.

Then I would check framing against the world-space bounds.

While we are here: the background never ends

The render pipeline had one more trap. An ffmpeg color= source is infinite unless given a duration. With color=c=…[bg];[bg][0:v]overlay, the output kept growing past 120 MB.

The fix was overlay=shortest=1, or an explicit duration on the background source with color=…:d=seconds.

This pipeline generates the 3D and image assets used across the things we ship — the product list is here.