Examples Gallery
GitHub

parent-inverse-orrery

Data-API parenting for a brass orrery — the keep-world idiom (child.parent = pivot; child.matrix_parent_inverse = pivot.matrix_world.inverted()) carrying arms, planets, and a two-level moon through spinning pivots.

Rendered headless by the example itself — click to zoom.

witnesses Bare `.parent =` really does teleport the child; the idiom restores world position exactly; matrix_world stays stale until view_layer.update(); every orbit lands on its closed form.
blender --background --python examples/parent-inverse-orrery/parent_inverse_orrery.py --

A runnable example that assembles a brass orrery — sun, three planets on pivot arms, one moon — entirely through the data API, witnessing the parenting contract that generated Blender code gets wrong most often. child.parent = pivot alone re-interprets the child's local matrix in the pivot's space and the child visibly teleports; keeping the world transform takes the two-line idiom:

child.parent = pivot
child.matrix_parent_inverse = pivot.matrix_world.inverted()

What it witnesses: the check first proves the trap is real (a bare-parented probe jumps by more than half a unit), then that the idiom restores the probe's world position to within 1e-5. It also asserts the second half of the contract — matrix_world is the *last-evaluated* matrix, stale after any transform edit until bpy.context.view_layer.update() — and finally that every planet and the two-level moon land exactly on their closed-form orbit positions after the pivots spin.

Run

# Cheap correctness check (no render) — the CI check:
blender --background --python parent_inverse_orrery.py --

# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
blender --background --python parent_inverse_orrery.py -- --output orrery.png
blender --background --python parent_inverse_orrery.py -- --output orrery.png --engine cycles

It exits non-zero on failure (no jump from the trap, keep-world error, stale-matrix contract broken, or an orbit off its closed form). The blender-smoke workflow runs the check on Blender 4.5 LTS and 5.1.

Source

examples/parent-inverse-orrery/parent_inverse_orrery.py View on GitHub →
"""A brass orrery parented through the data API — a runnable example.

Witnesses the object-parenting contract that generated code gets wrong most
often. Assigning `child.parent = pivot` alone re-interprets the child's local
matrix in the pivot's space, so the child visibly teleports; keeping the world
transform requires the two-line idiom:

    child.parent = pivot
    child.matrix_parent_inverse = pivot.matrix_world.inverted()

The check demonstrates the trap on a probe (it really does jump), proves the
idiom restores the world position exactly, and asserts the second contract AI
code trips over: `matrix_world` is the *last-evaluated* matrix — after any
transform edit it is stale until `bpy.context.view_layer.update()`. Finally
every planet and the moon must land on the closed-form orbit position
(rotation about the column axis, composed per hierarchy level).

By default it runs only the correctness check (no render) — the CI smoke
check. Pass --output to also render a still:

    blender --background --python parent_inverse_orrery.py --                   # check only
    blender --background --python parent_inverse_orrery.py -- --output o.png    # + render
"""
import bpy, bmesh, sys, os, math, argparse
from mathutils import Vector, Matrix

# (name, orbit radius, arm height, orbit angle deg, sphere radius, color RGBA)
PLANETS = [
    ("Lapis", 2.55, 1.02, 152.0, 0.34, (0.04, 0.10, 0.42, 1.0)),
    ("Terra", 1.85, 1.58, 336.0, 0.26, (0.48, 0.16, 0.07, 1.0)),
    ("Jade", 1.20, 2.12, 38.0, 0.20, (0.05, 0.33, 0.20, 1.0)),
]
MOON_HOST = "Lapis"           # the moon orbits the outer planet
MOON_OFFSET = 0.62            # distance from its planet, along local +X
MOON_ANGLE = 163.0            # moon-pivot spin, degrees
MOON_R = 0.12
PEDESTAL_TOP = 0.22
COLUMN_TOP = 2.45
SUN_Z = 2.62
EPS = 1e-5


def new_mesh_obj(name, build):
    """Mesh object via bmesh with bm.free() in try/finally (always-free-bmesh)."""
    me = bpy.data.meshes.new(name)
    bm = bmesh.new()
    try:
        build(bm)
        bm.to_mesh(me)
    finally:
        bm.free()
    obj = bpy.data.objects.new(name, me)
    bpy.context.collection.objects.link(obj)
    return obj


def cylinder(name, radius, depth, segments=24):
    return new_mesh_obj(name, lambda bm: bmesh.ops.create_cone(
        bm, cap_ends=True, segments=segments,
        radius1=radius, radius2=radius, depth=depth))


def sphere(name, radius):
    return new_mesh_obj(name, lambda bm: bmesh.ops.create_uvsphere(
        bm, u_segments=32, v_segments=16, radius=radius))


def empty(name, location):
    obj = bpy.data.objects.new(name, None)  # object_data=None -> EMPTY
    obj.location = location
    bpy.context.collection.objects.link(obj)
    return obj


def parent_keep_world(child, parent):
    """The idiom this example witnesses: parent without moving the child."""
    child.parent = parent
    child.matrix_parent_inverse = parent.matrix_world.inverted()


def build_orrery():
    """Author the whole hierarchy with bpy.data (no object-mode operators)."""
    bpy.ops.wm.read_factory_settings(use_empty=True)

    pedestal = cylinder("Pedestal", 1.15, PEDESTAL_TOP, segments=48)
    pedestal.location = (0.0, 0.0, PEDESTAL_TOP / 2)
    column = cylinder("Column", 0.07, COLUMN_TOP - PEDESTAL_TOP, segments=24)
    column.location = (0.0, 0.0, (PEDESTAL_TOP + COLUMN_TOP) / 2)
    sun = sphere("Sun", 0.28)
    sun.location = (0.0, 0.0, SUN_Z)

    rig = {"sun": sun, "pedestal": pedestal, "planets": {}}
    for name, radius, height, angle, size, color in PLANETS:
        pivot = empty(f"Pivot.{name}", (0.0, 0.0, height))
        arm = cylinder(f"Arm.{name}", 0.035, radius, segments=12)
        arm.rotation_euler = (0.0, math.pi / 2, 0.0)
        arm.location = (radius / 2, 0.0, height)
        planet = sphere(name, size)
        planet.location = (radius, 0.0, height)
        # everything is placed at its theta=0 WORLD position first, then
        # parented with the keep-world idiom -- nothing may move here
        bpy.context.view_layer.update()
        parent_keep_world(arm, pivot)
        parent_keep_world(planet, pivot)
        rig["planets"][name] = {
            "pivot": pivot, "planet": planet, "angle": math.radians(angle),
            "p0": Vector((radius, 0.0, height)),
        }

    host = rig["planets"][MOON_HOST]
    pc0 = host["p0"].copy()
    moon_pivot = empty("Pivot.Moon", pc0)
    rod = cylinder("Arm.Moon", 0.02, MOON_OFFSET, segments=12)
    rod.rotation_euler = (0.0, math.pi / 2, 0.0)
    rod.location = pc0 + Vector((MOON_OFFSET / 2, 0.0, 0.0))
    moon = sphere("Moon", MOON_R)
    moon.location = pc0 + Vector((MOON_OFFSET, 0.0, 0.0))
    bpy.context.view_layer.update()
    parent_keep_world(moon_pivot, host["planet"])
    parent_keep_world(rod, moon_pivot)
    parent_keep_world(moon, moon_pivot)
    rig["moon"] = {"pivot": moon_pivot, "moon": moon,
                   "angle": math.radians(MOON_ANGLE), "pc0": pc0,
                   "m0": pc0 + Vector((MOON_OFFSET, 0.0, 0.0))}

    # spin every orbit to its display angle -- the parenting must carry
    # arms, planets, and the moon assembly along
    for entry in rig["planets"].values():
        entry["pivot"].rotation_euler = (0.0, 0.0, entry["angle"])
    rig["moon"]["pivot"].rotation_euler = (0.0, 0.0, rig["moon"]["angle"])
    bpy.context.view_layer.update()
    return rig


def rot_z(theta, v):
    """Closed form: rotate v about the column (Z) axis, z untouched."""
    c, s = math.cos(theta), math.sin(theta)
    return Vector((c * v.x - s * v.y, s * v.x + c * v.y, v.z))


def check(rig):
    view_layer = bpy.context.view_layer
    outer = rig["planets"][MOON_HOST]

    # --- 1. the trap is real: bare `.parent =` teleports the child ---------
    probe = empty("Probe", (1.618, 0.0, 1.0))
    view_layer.update()
    w0 = probe.matrix_world.translation.copy()
    probe.parent = outer["pivot"]  # pivot has a rotation + Z offset
    view_layer.update()
    jumped = (probe.matrix_world.translation - w0).length
    if jumped < 0.5:
        print(f"ERROR: bare parenting moved the probe only {jumped:.6f}"
              "expected a visible jump", file=sys.stderr)
        return 3

    # --- 2. the fix: matrix_parent_inverse restores the world transform ----
    probe.matrix_parent_inverse = outer["pivot"].matrix_world.inverted()
    view_layer.update()
    err = (probe.matrix_world.translation - w0).length
    if err > EPS:
        print(f"ERROR: keep-world idiom off by {err:.8f}", file=sys.stderr)
        return 4

    # --- 3. matrix_world is stale until view_layer.update() ----------------
    before = probe.matrix_world.translation.copy()
    probe.location.x += 1.0
    stale = (probe.matrix_world.translation - before).length
    view_layer.update()
    fresh = (probe.matrix_world.translation - before).length
    if stale > EPS or fresh < 0.5:
        print(f"ERROR: stale-matrix contract broken (stale moved {stale:.8f}, "
              f"updated moved {fresh:.6f})", file=sys.stderr)
        return 5
    bpy.data.objects.remove(probe)
    view_layer.update()

    # --- 4. every orbit lands on its closed form ----------------------------
    for name, entry in rig["planets"].items():
        expect = rot_z(entry["angle"], entry["p0"])
        got = entry["planet"].matrix_world.translation
        if (got - expect).length > EPS:
            print(f"ERROR: {name} at {tuple(got)}, closed form {tuple(expect)}",
                  file=sys.stderr)
            return 6

    # moon: rotation about the column, then about its planet
    m = rig["moon"]
    theta1 = outer["angle"]
    pc = rot_z(theta1, m["pc0"])
    expect = pc + rot_z(theta1 + m["angle"], m["m0"] - m["pc0"])
    got = m["moon"].matrix_world.translation
    if (got - expect).length > EPS:
        print(f"ERROR: Moon at {tuple(got)}, closed form {tuple(expect)}",
              file=sys.stderr)
        return 7

    print(f"planets={len(rig['planets'])} moon=1 keep-world err={err:.2e} "
          f"orbit closed-form OK")
    return 0


def eevee_engine_id():
    return 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT'


def principled(name, color, metallic, roughness, emission=0.0):
    mat = bpy.data.materials.new(name)
    mat.use_nodes = True
    bsdf = mat.node_tree.nodes["Principled BSDF"]
    bsdf.inputs["Base Color"].default_value = color
    bsdf.inputs["Metallic"].default_value = metallic
    bsdf.inputs["Roughness"].default_value = roughness
    if emission:
        bsdf.inputs["Emission Color"].default_value = color
        bsdf.inputs["Emission Strength"].default_value = emission
    return mat


def orbit_ring(name, radius, height, mat):
    """Decorative brass orbit line: a bevelled circle curve (data API)."""
    cu = bpy.data.curves.new(name, type='CURVE')
    cu.dimensions = '3D'
    spline = cu.splines.new('POLY')
    spline.points.add(63)
    for i, pt in enumerate(spline.points):
        a = i * 2 * math.pi / 64
        pt.co = (radius * math.cos(a), radius * math.sin(a), 0.0, 1.0)
    spline.use_cyclic_u = True
    cu.bevel_depth = 0.012
    cu.materials.append(mat)
    obj = bpy.data.objects.new(name, cu)
    obj.location = (0.0, 0.0, height)
    bpy.context.collection.objects.link(obj)
    return obj


def render_still(rig, path, engine):
    scene = bpy.context.scene
    brass = principled("Brass", (0.62, 0.40, 0.16, 1.0), 1.0, 0.32)
    dark_bronze = principled("Bronze", (0.16, 0.11, 0.07, 1.0), 1.0, 0.45)
    sun_mat = principled("SunGlow", (1.0, 0.48, 0.10, 1.0), 0.0, 0.4, emission=3.2)
    moon_mat = principled("MoonSilver", (0.82, 0.84, 0.88, 1.0), 1.0, 0.25)

    rig["sun"].data.materials.append(sun_mat)
    rig["pedestal"].data.materials.append(dark_bronze)
    bpy.data.objects["Column"].data.materials.append(brass)
    rig["moon"]["moon"].data.materials.append(moon_mat)
    bpy.data.objects["Arm.Moon"].data.materials.append(brass)
    for name, radius, height, angle, size, color in PLANETS:
        bpy.data.objects[f"Arm.{name}"].data.materials.append(brass)
        planet = bpy.data.objects[name]
        planet.data.materials.append(principled(f"M.{name}", color, 0.0, 0.22))
        for poly in planet.data.polygons:
            poly.use_smooth = True
        orbit_ring(f"Ring.{name}", radius, height, brass)
    for obj_name in ("Sun", "Moon", "Pedestal", "Column"):
        for poly in bpy.data.objects[obj_name].data.polygons:
            poly.use_smooth = True

    floor_me = bpy.data.meshes.new("Floor")
    bm = bmesh.new()
    try:
        bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=30.0)
        bm.to_mesh(floor_me)
    finally:
        bm.free()
    fmat = principled("Studio", (0.045, 0.05, 0.06, 1.0), 0.0, 0.42)
    floor_me.materials.append(fmat)
    floor = bpy.data.objects.new("Floor", floor_me)
    scene.collection.objects.link(floor)
    wall = bpy.data.objects.new("Wall", floor_me.copy())
    wall.location = (0.0, 9.0, 0.0)
    wall.rotation_euler = (math.pi / 2, 0.0, 0.0)
    scene.collection.objects.link(wall)

    world = bpy.data.worlds.new("World")
    world.use_nodes = True
    # brass lives on reflections: faint warm ambient so flanks never go black
    world.node_tree.nodes["Background"].inputs["Color"].default_value = \
        (0.030, 0.026, 0.022, 1.0)
    scene.world = world

    def light(name, loc, energy, size, col, rot):
        ld = bpy.data.lights.new(name, 'AREA')
        ld.energy = energy; ld.size = size; ld.color = col
        ob = bpy.data.objects.new(name, ld)
        ob.location = loc
        ob.rotation_euler = tuple(math.radians(a) for a in rot)
        scene.collection.objects.link(ob)

    light("Key", (-4.0, -4.5, 5.0), 1500.0, 6.5, (1.0, 0.94, 0.86), (46, 0, -40))
    light("Fill", (4.8, -3.8, 2.6), 500.0, 8.0, (0.75, 0.83, 1.0), (62, 0, 48))
    light("Rim", (1.0, 4.5, 3.4), 900.0, 4.0, (1.0, 0.68, 0.38), (-70, 0, 170))

    cam_data = bpy.data.cameras.new("Cam")
    cam_data.lens = 40.0
    cam = bpy.data.objects.new("Cam", cam_data)
    cam.location = (0.0, -7.8, 2.7)
    cam.rotation_euler = (math.radians(81.0), 0.0, 0.0)
    scene.collection.objects.link(cam)
    scene.camera = cam

    scene.render.engine = 'CYCLES' if engine == 'cycles' else eevee_engine_id()
    if engine == 'cycles':
        scene.cycles.samples = 48
    else:
        try:
            scene.eevee.taa_render_samples = 64
        except AttributeError:
            pass
    scene.render.resolution_x = 1280
    scene.render.resolution_y = 720
    scene.render.image_settings.file_format = 'PNG'
    scene.render.filepath = path
    bpy.ops.render.render(write_still=True)
    return os.path.exists(path) and os.path.getsize(path) > 0


def main():
    argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
    p = argparse.ArgumentParser()
    p.add_argument("--output", default=None, help="optional: render a still PNG here")
    p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"),
                   help="render engine for --output (cycles for GPU-less hosts)")
    args = p.parse_args(argv)

    rig = build_orrery()
    code = check(rig)
    if code:
        return code

    if args.output:
        if not render_still(rig, os.path.abspath(args.output), args.engine):
            print("ERROR: render produced no file", file=sys.stderr)
            return 8
        print(f"rendered still {args.output}")

    print("parent-inverse-orrery OK")
    return 0


if __name__ == "__main__":
    try:
        sys.exit(main())
    except Exception as e:
        import traceback; traceback.print_exc(); print(f"FATAL: {e}", file=sys.stderr); sys.exit(1)