c`pNbQd*A%2r>OU29UeFrA3v+!1Bo(>bC6*0
z(#CmVzu6P)%OXL;odteVR;Ci4YwE^KuC_bLD5*Rs5ys{xV*DQ
zJ1{C^i#x<$1faWMW!d$;AV6~>&8ucg0JeA}AHWa&^5hR=9y|LJG4BAfJ?{&n3rT|C
zE`9u}dh(cL8KD{#ZCyo;hTR5J>eXle;)4VP|X))BdjT@_MiF
zfnLlXsKX%zhsp4)0YSF;%{$i|MIIyDEAaFZn)D+w3mBw7D(a3(@?-ck#}f_AnKklm
zn2D!pBr5g3XC?CEk!UOD$N%+0Ax&*tv82=TAiToF*rDBvt>QK^NZ8{vWwba#{mD8W
z2k{*dB8Uj}(ydF?)MCkBtgZW-RI`gwJv#seSjPA^3?FwES0^@w%;USV7oE0Examples Gallery
materials
mesh
node-groups
+ objects
operators
performance
rendering
shape-keys
+ transforms
+
+
+
+
+
+
+
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.
+
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.
+
View example →
+
+
diff --git a/docs/gallery/parent-inverse-orrery/index.html b/docs/gallery/parent-inverse-orrery/index.html
new file mode 100644
index 0000000..b2409a6
--- /dev/null
+++ b/docs/gallery/parent-inverse-orrery/index.html
@@ -0,0 +1,591 @@
+
+
+
+
+
+ parent-inverse-orrery — Examples — Blender Developer Tools
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Skip to content
+
+
+ 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 --
+
Copy
+
+
+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 )
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/gallery.json b/examples/gallery.json
index e20a451..faf0f57 100644
--- a/examples/gallery.json
+++ b/examples/gallery.json
@@ -184,6 +184,18 @@
"materials",
"attributes"
]
+ },
+ {
+ "name": "parent-inverse-orrery",
+ "dir": "examples/parent-inverse-orrery",
+ "teaches": "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.",
+ "witnessesFix": "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.",
+ "hero": "docs/gallery/assets/parent-inverse-orrery-hero.webp",
+ "preview": "examples/parent-inverse-orrery/preview.webp",
+ "tags": [
+ "objects",
+ "transforms"
+ ]
}
]
}
diff --git a/examples/parent-inverse-orrery/README.md b/examples/parent-inverse-orrery/README.md
new file mode 100644
index 0000000..6608f70
--- /dev/null
+++ b/examples/parent-inverse-orrery/README.md
@@ -0,0 +1,34 @@
+# Parent Inverse Orrery
+
+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:
+
+```python
+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
+
+```bash
+# 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.
diff --git a/examples/parent-inverse-orrery/parent_inverse_orrery.py b/examples/parent-inverse-orrery/parent_inverse_orrery.py
new file mode 100644
index 0000000..d24fc64
--- /dev/null
+++ b/examples/parent-inverse-orrery/parent_inverse_orrery.py
@@ -0,0 +1,347 @@
+"""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)
diff --git a/examples/parent-inverse-orrery/preview.webp b/examples/parent-inverse-orrery/preview.webp
new file mode 100644
index 0000000000000000000000000000000000000000..2f6dfcfcf6f9961f16eda091ebeb43b7af64cef9
GIT binary patch
literal 23758
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