Rendered headless by the example itself — click to zoom.
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
"""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)