diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json
index 6a90944..5086723 100644
--- a/.cursor-plugin/plugin.json
+++ b/.cursor-plugin/plugin.json
@@ -81,6 +81,7 @@
"examples/mesh-hygiene-audit",
"examples/parent-inverse-orrery",
"examples/png-exr-alpha",
+ "examples/prop-origin-transform",
"examples/shader-node-group",
"examples/shape-key-blend",
"examples/sky-texture-sun-elevation",
diff --git a/.github/workflows/blender-smoke.yml b/.github/workflows/blender-smoke.yml
index 366473e..c4655eb 100644
--- a/.github/workflows/blender-smoke.yml
+++ b/.github/workflows/blender-smoke.yml
@@ -505,3 +505,14 @@ jobs:
# positive signed volume, Euler V-E+F == 2. Exits non-zero on failure.
xvfb-run -a "$BLENDER" --background \
--python examples/mesh-hygiene-audit/mesh_hygiene_audit.py --
+
+ - name: Shipped example - prop origin transform (base origin + scale + MPI)
+ run: |
+ set -euo pipefail
+ # Check only (no render): street pedestal; asserts scale (1,1,1) after
+ # data-API apply, local bbox min.z == 0, world AABB unchanged, bare
+ # parent jumps accessory, matrix_parent_inverse restores world location,
+ # and matrix_world stays stale until view_layer.update(). Exits non-zero
+ # on failure.
+ xvfb-run -a "$BLENDER" --background \
+ --python examples/prop-origin-transform/prop_origin_transform.py --
diff --git a/README.md b/README.md
index 8c425f5..692088a 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@
- 12 skills • 6 rules • 2 templates • 17 snippets • 35 examples
+ 12 skills • 6 rules • 2 templates • 17 snippets • 36 examples
@@ -36,7 +36,7 @@
## Overview
-This repository ships **12 skills, 6 rules, 2 templates, 17 snippets, and 35 runnable examples** for Blender Python development targeting Blender 5.1 (current stable) with Blender 4.5 LTS fallback support.
+This repository ships **12 skills, 6 rules, 2 templates, 17 snippets, and 36 runnable examples** for Blender Python development targeting Blender 5.1 (current stable) with Blender 4.5 LTS fallback support.
The content is consumed by AI coding agents (Cursor, Claude Code, any MCP-capable client) when working on Blender add-ons, geometry nodes scripts, batch pipelines, or animation tooling. There is no build step. Edit the markdown and Python files directly.
@@ -537,7 +537,7 @@ portable path is `radius`.
-Game asset pipeline — 8 examples
+Game asset pipeline — 9 examples
@@ -682,6 +682,23 @@ closed solid (measured 24/44/22, volume 0.989248). Companion to
and [`bmesh-gear`](examples/bmesh-gear/) (parametric closed solids). Dual-panel
still: DIRTY (injected boundary + loose vert) vs CLEAN.
+
+
+
+
+
+
+
+
+### [prop-origin-transform](examples/prop-origin-transform/)
+
+The origin / scale-apply / MPI contract a prop pipeline relies on before
+engine ingest: data-API scale bake to exactly `(1,1,1)`, local bbox
+`min.z == 0` (origin at base), world AABB unchanged across the bake, and
+`matrix_parent_inverse` so a bolted accessory does not teleport. Extends
+[`parent-inverse-orrery`](examples/parent-inverse-orrery/) without retreading
+orbits. Dual-panel still: TRAP (bare parent) vs MPI KEEP.
+
diff --git a/ROADMAP.md b/ROADMAP.md
index 2a412a7..1790433 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -119,9 +119,10 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo
- Camera DOF + focus_distance witness: `cam.dof.use_dof`, focus plane vs defocus variance on high-freq cards (Stage deviation — depth needs background content)
- Volumetric scatter optical-depth witness: Volume Scatter density → Beer–Lambert transmittance along a known path (Cycles; Stage deviation)
- Freestyle SVG / line-set witness: Freestyle line set on a silhouette (full render pass; prefer Line Art first)
-- prop-origin-transform witness — origin to base center, `transform_apply` through the data API, delta transforms, `matrix_parent_inverse` so parented children do not teleport; closed forms: post-apply scale exactly (1,1,1), local bbox min Z == 0, world bbox unchanged (builds on `parent-inverse-orrery`, does not duplicate it)
+- ~~prop-origin-transform witness~~ **SHIPPED** as `examples/prop-origin-transform/` — origin to base center + data-API scale apply + `matrix_parent_inverse` for a bolted accessory; scale `(1,1,1)`, local `min.z==0`, world AABB delta 0, bare jump ~0.43 m, MPI err ~3e-8; stale `matrix_world` until `view_layer.update()`; byte-identical on 4.5.11 and 5.1.2
- ~~mesh-hygiene-audit witness~~ **SHIPPED** as `examples/mesh-hygiene-audit/` — engine-ingest topology checklist on a stepped street electrical pedestal: no ngons, no loose verts, manifold edges (exactly 2 faces), no zero-area faces (`min_area` printed), positive signed volume, Euler `V−E+F==2` (24/44/22, volume 0.989248); byte-identical on 4.5.11 and 5.1.2; dual-panel dirty vs clean still
- UV atlas pack witness — lightmap/unique UV island packing with non-overlap + utilization closed forms (deferred behind hygiene/origin; overlaps `uv-layer-grid` / `triangulate-tangents` / `gltf-export-roundtrip` UV arc)
+- Attribute domain witness: writing a POINT-domain color attribute and reading it as if it were CORNER (or vice versa) silently shears colors across shared verts — companion to `color-attribute-wheel`
## Future (uncommitted)
diff --git a/docs/gallery/assets/prop-origin-transform-hero.webp b/docs/gallery/assets/prop-origin-transform-hero.webp
new file mode 100644
index 0000000..df0116d
Binary files /dev/null and b/docs/gallery/assets/prop-origin-transform-hero.webp differ
diff --git a/docs/gallery/contact-sheets/prop-origin-transform-contact-sheet.webp b/docs/gallery/contact-sheets/prop-origin-transform-contact-sheet.webp
new file mode 100644
index 0000000..d32c754
Binary files /dev/null and b/docs/gallery/contact-sheets/prop-origin-transform-contact-sheet.webp differ
diff --git a/docs/gallery/index.html b/docs/gallery/index.html
index 4689182..828b266 100644
--- a/docs/gallery/index.html
+++ b/docs/gallery/index.html
@@ -199,12 +199,14 @@ Examples Gallery
node-groups
objects
operators
+ parenting
performance
render
rendering
sequencer
shape-keys
sky
+ transform
transforms
uv
world
@@ -596,6 +598,17 @@
View example →
+
+
+
+
+
+
+
Street pedestal origin-to-base-center + data-API scale apply + matrix_parent_inverse for a bolted accessory. After bake: scale exactly (1,1,1), local min.z==0, world AABB delta 0. Bare parent jumps the accessory (~0.43 m); MPI restores world location (~3e-8). view_layer.update() before reading matrix_world.
+
witnesses AI code parents with child.parent = parent alone (accessory teleports), skips view_layer.update() before matrix_world, or leaves non-uniform scale / centered origin for engine ingest. Skipping apply, origin bake, or MPI each fails a closed-form gate.
+
View example →
+
+
diff --git a/docs/gallery/prop-origin-transform/index.html b/docs/gallery/prop-origin-transform/index.html
new file mode 100644
index 0000000..2201fdf
--- /dev/null
+++ b/docs/gallery/prop-origin-transform/index.html
@@ -0,0 +1,801 @@
+
+
+
+
+
+ prop-origin-transform — Examples — Blender Developer Tools
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Skip to content
+
+
+ prop-origin-transform
+ Street pedestal origin-to-base-center + data-API scale apply + matrix_parent_inverse for a bolted accessory. After bake: scale exactly (1,1,1), local min.z==0, world AABB delta 0. Bare parent jumps the accessory (~0.43 m); MPI restores world location (~3e-8). view_layer.update() before reading matrix_world.
+
+
+
+
+
+ Rendered headless by the example itself — click to zoom.
+ witnesses AI code parents with child.parent = parent alone (accessory teleports), skips view_layer.update() before matrix_world, or leaves non-uniform scale / centered origin for engine ingest. Skipping apply, origin bake, or MPI each fails a closed-form gate.
+
+
blender --background --python examples/prop-origin-transform/prop_origin_transform.py --
+
Copy
+
+
+A runnable example that builds a street utility pedestal with a bolted conduit accessory and proves the origin / scale-apply / MPI contract a prop pipeline relies on before engine ingest — following operators and the data-API parenting idiom from parent-inverse-orrery .
+Pipeline arc neighbors: MPI + stale matrix_world in parent-inverse-orrery , export origin sensitivity in gltf-export-roundtrip , collision bounds in collision-hull-proxy , and mesh hygiene in mesh-hygiene-audit . Origin at the base center with applied scale is what placement and physics ingest assume.
+Scope: this witnesses the bpy-level contract a prop pipeline relies on (post-bake scale exactly (1,1,1), local bbox min.z == 0, world bbox unchanged, MPI so a parented accessory does not teleport). It is not an engine exporter — it proves the transform properties such an asset must have.
+What it witnesses:
+Stale matrix_world. After a location edit, matrix_world is unchanged until view_layer.update() (same half of the orrery contract).Scale apply via data API. Non-uniform scale baked into verts; obj.scale == (1,1,1) exactly afterward.Origin at base center. Local bbox min.z == 0, XY centered; world AABB delta across the bake is 0 (gate 1e-5).Bare-parent trap. Parenting the accessory without MPI jumps it (~0.43 m measured); setting matrix_parent_inverse restores world location (err ~3e-8).
+What each check catches on failure: skipping scale apply leaves non-identity scale; skipping origin bake leaves min.z ≈ -0.756; skipping MPI leaves the accessory teleported.
+Version witness: byte-identical numbers on Blender 4.5.11 LTS and 5.1.2.
+The render is a dual panel: left TRAP (bare parent — accessory teleports, hazard material) vs right MPI KEEP (accessory stays put), with emissive origin markers at each pedestal's base.
+Run
+blender --background --python prop_origin_transform.py --
+blender --background --python prop_origin_transform.py -- --output origin.png
+blender --background --python prop_origin_transform.py -- --output origin.png --engine cycles
+Exits non-zero on failure. The blender-smoke workflow runs the check on Blender 4.5 LTS and 5.1.
+
+
+ Source
+
+ """Prop origin transform — base-center origin + scale apply + MPI accessory.
+
+Witnesses the object-level contract a prop pipeline relies on before engine
+ingest: origin at the local base center, scale applied through the data API
+to exactly (1,1,1), world bbox unchanged across the bake, and
+`matrix_parent_inverse` so a parented accessory does not teleport. Extends
+`parent-inverse-orrery` (MPI idiom + stale `matrix_world`) without retreading
+orbits — subject is a street utility pedestal with a bolted conduit accessory.
+
+By default it runs only the correctness check (no render). Pass --output
+to also render a still:
+
+ blender --background --python prop_origin_transform.py --
+ blender --background --python prop_origin_transform.py -- --output o.png
+"""
+import bpy, bmesh, sys, os, math, argparse
+from mathutils import Matrix, Vector
+
+BBOX_EPS = 1e-6
+WORLD_EPS = 1e-5
+MPI_EPS = 1e-6
+STALE_EPS = 1e-9
+JUMP_MIN = 0.05
+
+
+def eevee_engine_id():
+ return "BLENDER_EEVEE" if bpy.app.version >= (5 , 0 , 0 ) else "BLENDER_EEVEE_NEXT"
+
+
+def make_material(name, rgb, rough=0.45 , metallic=0.35 , emit=None , estr=0.0 ):
+ mat = bpy.data.materials.new(name)
+ mat.use_nodes = True
+ b = mat.node_tree.nodes["Principled BSDF" ]
+ b.inputs["Base Color" ].default_value = (*rgb, 1.0 )
+ b.inputs["Roughness" ].default_value = rough
+ b.inputs["Metallic" ].default_value = metallic
+ if emit is not None :
+ sock = b.inputs.get("Emission Color" ) or b.inputs["Emission" ]
+ sock.default_value = (*emit, 1.0 )
+ b.inputs["Emission Strength" ].default_value = estr
+ return mat
+
+
+def local_bbox(me):
+ xs = [v.co.x for v in me.vertices]
+ ys = [v.co.y for v in me.vertices]
+ zs = [v.co.z for v in me.vertices]
+ return (
+ Vector((min(xs), min(ys), min(zs))),
+ Vector((max(xs), max(ys), max(zs))),
+ )
+
+
+def world_bbox(obj):
+ """Axis-aligned world bbox from object bound_box (needs fresh depsgraph)."""
+ corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
+ xs = [c.x for c in corners]
+ ys = [c.y for c in corners]
+ zs = [c.z for c in corners]
+ return (
+ Vector((min(xs), min(ys), min(zs))),
+ Vector((max(xs), max(ys), max(zs))),
+ )
+
+
+def bbox_delta(a, b):
+ (amin, amax), (bmin, bmax) = a, b
+ return max(
+ (amin - bmin).length,
+ (amax - bmax).length,
+ abs(amin.x - bmin.x), abs(amin.y - bmin.y), abs(amin.z - bmin.z),
+ abs(amax.x - bmax.x), abs(amax.y - bmax.y), abs(amax.z - bmax.z),
+ )
+
+
+def build_pedestal_mesh():
+ """Stepped street pedestal mesh in local space (origin roughly at center)."""
+ rings = [
+ (0.55 , 0.45 , -0.70 ),
+ (0.55 , 0.45 , -0.52 ),
+ (0.42 , 0.34 , -0.52 ),
+ (0.42 , 0.34 , 0.55 ),
+ (0.48 , 0.38 , 0.55 ),
+ (0.48 , 0.38 , 0.70 ),
+ ]
+ me = bpy.data.meshes.new("Pedestal" )
+ bm = bmesh.new()
+ try :
+ ring_verts = []
+ for hx, hy, z in rings:
+ ring_verts.append([
+ bm.verts.new((-hx, -hy, z)),
+ bm.verts.new((hx, -hy, z)),
+ bm.verts.new((hx, hy, z)),
+ bm.verts.new((-hx, hy, z)),
+ ])
+ for i in range(len(ring_verts) - 1 ):
+ a, b = ring_verts[i], ring_verts[i + 1 ]
+ for k in range(4 ):
+ n = (k + 1 ) % 4
+ bm.faces.new((a[k], a[n], b[n], b[k]))
+ bot, top = ring_verts[0 ], ring_verts[-1 ]
+ bm.faces.new((bot[0 ], bot[3 ], bot[2 ], bot[1 ]))
+ bm.faces.new((top[0 ], top[1 ], top[2 ], top[3 ]))
+ bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
+ bm.to_mesh(me)
+ finally :
+ bm.free()
+ return me
+
+
+def build_accessory_mesh():
+ """Small conduit elbow — bolted accessory for the MPI trap."""
+ me = bpy.data.meshes.new("Conduit" )
+ bm = bmesh.new()
+ try :
+ bmesh.ops.create_cone(
+ bm, cap_ends=True , segments=12 , radius1=0.07 , radius2=0.07 , depth=0.35 ,
+ )
+ bmesh.ops.rotate(
+ bm, verts=bm.verts, cent=(0 , 0 , 0 ),
+ matrix=Matrix.Rotation(math.radians(90 ), 4 , "X" ),
+ )
+ bm.to_mesh(me)
+ finally :
+ bm.free()
+ return me
+
+
+def apply_scale_data_api(obj):
+ """Bake non-uniform scale into mesh verts; leave obj.scale == (1,1,1)."""
+ sx, sy, sz = obj.scale
+ for v in obj.data.vertices:
+ v.co.x *= sx
+ v.co.y *= sy
+ v.co.z *= sz
+ obj.scale = (1.0 , 1.0 , 1.0 )
+ obj.data.update()
+
+
+def origin_to_base_center(obj):
+ """Shift mesh so local min Z == 0 and XY centered; keep world geometry."""
+ mn, mx = local_bbox(obj.data)
+ ox = 0.5 * (mn.x + mx.x)
+ oy = 0.5 * (mn.y + mx.y)
+ oz = mn.z
+ offset = Vector((ox, oy, oz))
+ for v in obj.data.vertices:
+ v.co -= offset
+ obj.data.update()
+ # World translation that compensates the mesh shift under current transform
+ bpy.context.view_layer.update()
+ offset_world = obj.matrix_world.to_3x3() @ offset
+ obj.location = obj.location + offset_world
+
+
+def parent_keep_world(child, parent):
+ child.parent = parent
+ child.matrix_parent_inverse = parent.matrix_world.inverted()
+
+
+def build_scene():
+ bpy.ops.wm.read_factory_settings(use_empty=True )
+ sc = bpy.context.scene
+
+ body_m = make_material("Body" , (0.20 , 0.38 , 0.34 ), rough=0.42 , metallic=0.55 )
+ acc_m = make_material("Conduit" , (0.45 , 0.42 , 0.28 ), rough=0.4 , metallic=0.7 )
+
+ me = build_pedestal_mesh()
+ me.materials.append(body_m)
+ for p in me.polygons:
+ p.use_smooth = False
+ prop = bpy.data.objects.new("StreetPedestal" , me)
+ # Intentionally wrong for ingest: non-uniform scale, origin at geometry center
+ prop.scale = (1.15 , 0.92 , 1.08 )
+ prop.location = (0.0 , 0.0 , 0.0 )
+ sc.collection.objects.link(prop)
+
+ ame = build_accessory_mesh()
+ ame.materials.append(acc_m)
+ acc = bpy.data.objects.new("Accessory" , ame)
+ # World pose before parenting — bolted to the front face mid-height
+ acc.location = (0.0 , -0.55 , 0.15 )
+ acc.rotation_euler = (0.0 , 0.0 , 0.0 )
+ sc.collection.objects.link(acc)
+
+ bpy.context.view_layer.update()
+ return sc, prop, acc
+
+
+def bake_prop(prop):
+ """Apply scale then origin-to-base-center; return world bbox before/after."""
+ bpy.context.view_layer.update()
+ before = world_bbox(prop)
+ apply_scale_data_api(prop)
+ bpy.context.view_layer.update()
+ origin_to_base_center(prop)
+ bpy.context.view_layer.update()
+ after = world_bbox(prop)
+ return before, after
+
+
+def check(prop, acc):
+ """Assert origin/scale bake + MPI accessory contract. Exit 3–8 on failure."""
+ view_layer = bpy.context.view_layer
+
+ # --- 0. Stale matrix_world without update (orrery half of the contract) ---
+ # Nudge location; matrix_world must stay stale until update().
+ view_layer.update()
+ before = prop.matrix_world.translation.copy()
+ prop.location.x += 0.25
+ stale = (prop.matrix_world.translation - before).length
+ if stale > STALE_EPS:
+ print(
+ f" ERROR: matrix_world updated without view_layer.update() "
+ f" (delta= {stale:.3e }) — cannot witness the stale-matrix trap " ,
+ file=sys.stderr,
+ )
+ return 3
+ view_layer.update()
+ fresh = (prop.matrix_world.translation - before).length
+ if abs(fresh - 0.25 ) > WORLD_EPS:
+ print(
+ f" ERROR: after update, location delta {fresh:.6f } != 0.25 " ,
+ file=sys.stderr,
+ )
+ return 3
+ prop.location.x -= 0.25
+ view_layer.update()
+ print(f" stale_matrix_ok stale= {stale:.3e } fresh= {fresh:.6f }" )
+
+ # --- 1. Bake scale + origin; world bbox must hold ---
+ before_bb, after_bb = bake_prop(prop)
+ delta = bbox_delta(before_bb, after_bb)
+ print(
+ f" world_bbox_delta= {delta:.3e } "
+ f" before_min= {tuple(round(c, 6 ) for c in before_bb[0 ])} "
+ f" after_min= {tuple(round(c, 6 ) for c in after_bb[0 ])}"
+ )
+ if delta > WORLD_EPS:
+ print(
+ f" ERROR: world bbox moved across bake (delta= {delta:.3e } > {WORLD_EPS}) " ,
+ file=sys.stderr,
+ )
+ return 4
+
+ sx, sy, sz = prop.scale
+ if abs(sx - 1.0 ) > 0 or abs(sy - 1.0 ) > 0 or abs(sz - 1.0 ) > 0 :
+ print(f" ERROR: scale after bake is {(sx, sy, sz)}, want (1,1,1) " , file=sys.stderr)
+ return 5
+ print(f" scale_ok scale= {(sx, sy, sz)}" )
+
+ mn, mx = local_bbox(prop.data)
+ print(f" local_bbox min= {tuple(round(c, 6 ) for c in mn)} max= {tuple(round(c, 6 ) for c in mx)}" )
+ if abs(mn.z) > BBOX_EPS:
+ print(f" ERROR: local min.z= {mn.z:.6f } != 0 (origin not at base) " , file=sys.stderr)
+ return 6
+ if abs(0.5 * (mn.x + mx.x)) > BBOX_EPS or abs(0.5 * (mn.y + mx.y)) > BBOX_EPS:
+ print(
+ f" ERROR: local XY center not at origin "
+ f" (cx= {0.5 *(mn.x+mx.x):.6f } cy= {0.5 *(mn.y+mx.y):.6f }) " ,
+ file=sys.stderr,
+ )
+ return 6
+ print(f" origin_base_ok min_z= {mn.z:.3e }" )
+
+ # --- 2. Bare-parent trap on the accessory, then MPI fix ---
+ view_layer.update()
+ w0 = acc.matrix_world.translation.copy()
+ # Move parent so bare parenting has something to fight against
+ prop.location.z += 0.4
+ prop.rotation_euler.z = math.radians(25 )
+ view_layer.update()
+
+ acc.parent = prop # bare — must jump
+ view_layer.update()
+ jumped = (acc.matrix_world.translation - w0).length
+ print(f" bare_parent_jump= {jumped:.6f }" )
+ if jumped < JUMP_MIN:
+ print(
+ f" ERROR: bare parenting moved accessory only {jumped:.6f } "
+ f" (expected jump >= {JUMP_MIN}) — trap not witnessed " ,
+ file=sys.stderr,
+ )
+ return 7
+
+ acc.matrix_parent_inverse = prop.matrix_world.inverted()
+ view_layer.update()
+ err = (acc.matrix_world.translation - w0).length
+ print(f" mpi_restore_err= {err:.3e }" )
+ if err > MPI_EPS:
+ print(
+ f" ERROR: matrix_parent_inverse failed to restore world location "
+ f" (err= {err:.3e } > {MPI_EPS}) " ,
+ file=sys.stderr,
+ )
+ return 8
+
+ print(
+ f" prop-origin-transform OK scale=(1,1,1) min_z= {mn.z:.3e } "
+ f" world_bbox_delta= {delta:.3e } bare_jump= {jumped:.6f } mpi_err= {err:.3e }"
+ )
+ return 0
+
+
+def origin_marker(sc, loc):
+ """Small emissive triad at the object origin — scene evidence, not a light."""
+ mat = make_material(
+ "OriginMark" , (1.0 , 0.55 , 0.1 ), rough=0.35 , metallic=0.0 ,
+ emit=(1.0 , 0.6 , 0.1 ), estr=2.2 ,
+ )
+ me = bpy.data.meshes.new("OriginBead" )
+ bm = bmesh.new()
+ try :
+ bmesh.ops.create_uvsphere(bm, u_segments=10 , v_segments=8 , radius=0.09 )
+ bm.to_mesh(me)
+ finally :
+ bm.free()
+ me.materials.append(mat)
+ ob = bpy.data.objects.new("OriginBead" , me)
+ ob.location = loc
+ sc.collection.objects.link(ob)
+ # Axis stubs
+ for axis, rgb, rot in (
+ ("X" , (1.0 , 0.2 , 0.15 ), (0 , math.radians(90 ), 0 )),
+ ("Y" , (0.2 , 1.0 , 0.25 ), (math.radians(-90 ), 0 , 0 )),
+ ("Z" , (0.25 , 0.45 , 1.0 ), (0 , 0 , 0 )),
+ ):
+ ame = bpy.data.meshes.new(f" Axis {axis}" )
+ abm = bmesh.new()
+ try :
+ bmesh.ops.create_cone(
+ abm, cap_ends=True , segments=8 , radius1=0.015 , radius2=0.015 , depth=0.28 ,
+ )
+ bmesh.ops.translate(abm, vec=(0 , 0 , 0.14 ), verts=abm.verts)
+ abm.to_mesh(ame)
+ finally :
+ abm.free()
+ am = make_material(
+ f" Axis {axis}" , rgb, rough=0.4 , metallic=0.0 , emit=rgb, estr=1.5 ,
+ )
+ ame.materials.append(am)
+ aob = bpy.data.objects.new(f" Axis {axis}" , ame)
+ aob.location = loc
+ aob.rotation_euler = rot
+ sc.collection.objects.link(aob)
+
+
+def placard(sc, text, loc, size=0.18 ):
+ cu = bpy.data.curves.new(text, "FONT" )
+ cu.body = text
+ cu.size = size
+ cu.align_x = "CENTER"
+ ob = bpy.data.objects.new(text, cu)
+ ob.location = loc
+ sc.collection.objects.link(ob)
+ mat = make_material("Label" , (0.92 , 0.92 , 0.94 ), rough=0.6 , metallic=0.0 )
+ ob.data.materials.append(mat)
+ return ob
+
+
+def build_studio(sc):
+ 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 = make_material("Studio" , (0.03 , 0.032 , 0.037 ), rough=0.7 , metallic=0.0 )
+ floor_me.materials.append(fmat)
+ floor = bpy.data.objects.new("Floor" , floor_me)
+ sc.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.radians(90 ), 0.0 , 0.0 )
+ sc.collection.objects.link(wall)
+
+ world = bpy.data.worlds.new("World" )
+ world.use_nodes = True
+ world.node_tree.nodes["Background" ].inputs["Color" ].default_value = (
+ 0.02 , 0.021 , 0.025 , 1.0 ,
+ )
+ sc.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)
+ sc.collection.objects.link(ob)
+
+ light("Key" , (-3.5 , -4.5 , 5.5 ), 480.0 , 4.5 , (1.0 , 0.96 , 0.9 ), (48 , 0 , -35 ))
+ light("Fill" , (5.0 , -3.5 , 2.5 ), 120.0 , 9.0 , (0.75 , 0.85 , 1.0 ), (65 , 0 , 50 ))
+ light("Rim" , (1.5 , 4.5 , 3.5 ), 280.0 , 3.0 , (0.6 , 0.78 , 1.0 ), (-55 , 0 , 170 ))
+ light("Wedge" , (2.5 , 5.5 , 4.0 ), 400.0 , 6.0 , (1.0 , 0.72 , 0.42 ), (-68 , 0 , 190 ))
+
+
+def render_still(path, engine):
+ """Dual panel: bare-parent TRAP (left) vs MPI KEEP (right) + origin at base."""
+ # Fresh scene for the still so check mutations don't poison framing
+ bpy.ops.wm.read_factory_settings(use_empty=True )
+ sc = bpy.context.scene
+
+ body_m = make_material("BodyR" , (0.18 , 0.42 , 0.38 ), rough=0.42 , metallic=0.55 )
+ acc_m = make_material("AccR" , (0.55 , 0.45 , 0.22 ), rough=0.35 , metallic=0.8 )
+ trap_m = make_material(
+ "TrapAcc" , (0.9 , 0.2 , 0.1 ), rough=0.4 , metallic=0.3 ,
+ emit=(1.0 , 0.25 , 0.05 ), estr=1.0 ,
+ )
+
+ def make_prop(name, loc, rot_z=0.0 ):
+ me = build_pedestal_mesh()
+ me.materials.append(body_m)
+ for p in me.polygons:
+ p.use_smooth = False
+ ob = bpy.data.objects.new(name, me)
+ ob.scale = (1.15 , 0.92 , 1.08 )
+ ob.location = loc
+ sc.collection.objects.link(ob)
+ bpy.context.view_layer.update()
+ apply_scale_data_api(ob)
+ bpy.context.view_layer.update()
+ origin_to_base_center(ob)
+ bpy.context.view_layer.update()
+ # Sit on floor after bake
+ bpy.context.view_layer.update()
+ wb = world_bbox(ob)
+ ob.location.z -= wb[0 ].z
+ ob.rotation_euler.z = rot_z
+ bpy.context.view_layer.update()
+ return ob
+
+ def make_acc(name, world_loc, mat):
+ me = build_accessory_mesh()
+ me.materials.append(mat)
+ ob = bpy.data.objects.new(name, me)
+ ob.location = world_loc
+ sc.collection.objects.link(ob)
+ return ob
+
+ left = make_prop("TrapProp" , (-1.55 , 0.0 , 0.0 ), rot_z=math.radians(12 ))
+ right = make_prop("KeepProp" , (1.55 , 0.0 , 0.0 ), rot_z=math.radians(-8 ))
+
+ # Accessory intended world pose (front of each pedestal mid-height)
+ bpy.context.view_layer.update()
+ left_front = left.matrix_world @ Vector((0.0 , -0.48 , 0.85 ))
+ right_front = right.matrix_world @ Vector((0.0 , -0.48 , 0.85 ))
+
+ trap_acc = make_acc("TrapAcc" , left_front, trap_m)
+ keep_acc = make_acc("KeepAcc" , right_front, acc_m)
+ trap_acc.scale = (1.8 , 1.8 , 1.8 )
+ keep_acc.scale = (1.8 , 1.8 , 1.8 )
+ bpy.context.view_layer.update()
+
+ # Ghost at the intended pose on the TRAP side — the "where it should stay"
+ ghost_m = make_material(
+ "Ghost" , (0.7 , 0.75 , 0.8 ), rough=0.6 , metallic=0.0 ,
+ emit=(0.5 , 0.55 , 0.6 ), estr=0.35 ,
+ )
+ ghost = make_acc("ExpectedGhost" , left_front, ghost_m)
+ ghost.scale = (1.8 , 1.8 , 1.8 )
+ # slight transparency via alpha isn't reliable in EEVEE without setup;
+ # keep emissive pale so it reads as "expected seat"
+
+ # Left: bare parent after translating+yawing parent → accessory teleports
+ left.location += Vector((0.55 , 0.35 , 0.0 ))
+ left.rotation_euler.z += math.radians(55 )
+ bpy.context.view_layer.update()
+ w_before = trap_acc.matrix_world.translation.copy()
+ trap_acc.parent = left
+ bpy.context.view_layer.update()
+ jumped = (trap_acc.matrix_world.translation - w_before).length
+ print(f" render_trap_jump= {jumped:.4f } acc_at= {tuple(round(c,3 ) for c in trap_acc.matrix_world.translation)}" )
+
+ # Right: MPI keep-world (accessory stays on the front face)
+ parent_keep_world(keep_acc, right)
+ bpy.context.view_layer.update()
+
+ # Origin markers at each prop's object origin (base center after bake)
+ origin_marker(sc, Vector(left.matrix_world.translation))
+ origin_marker(sc, Vector(right.matrix_world.translation))
+
+ placard(sc, "TRAP" , (-1.2 , -1.25 , 0.02 ), size=0.22 )
+ placard(sc, "MPI KEEP" , (1.55 , -1.25 , 0.02 ), size=0.22 )
+
+ build_studio(sc)
+
+ cam_data = bpy.data.cameras.new("Cam" )
+ cam_data.lens = 38.0
+ cam = bpy.data.objects.new("Cam" , cam_data)
+ cam.location = (0.15 , -7.2 , 2.15 )
+ sc.collection.objects.link(cam)
+ aim = bpy.data.objects.new("Aim" , None )
+ aim.location = (0.15 , 0.0 , 0.9 )
+ sc.collection.objects.link(aim)
+ tr = cam.constraints.new("TRACK_TO" )
+ tr.target = aim
+ tr.track_axis = "TRACK_NEGATIVE_Z"
+ tr.up_axis = "UP_Y"
+ sc.camera = cam
+
+ sc.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id()
+ if engine == "cycles" :
+ sc.cycles.device = "CPU"
+ sc.cycles.samples = 64
+ sc.cycles.use_denoising = True
+ else :
+ try :
+ sc.eevee.taa_render_samples = 64
+ except AttributeError:
+ pass
+ sc.render.resolution_x = 1280
+ sc.render.resolution_y = 720
+ sc.render.image_settings.file_format = "PNG"
+ sc.render.filepath = path
+ sc.view_settings.view_transform = "Standard"
+ 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 )
+ p.add_argument("--engine" , default="eevee" , choices=("eevee" , "cycles" ))
+ args = p.parse_args(argv)
+
+ print(f" binary version: {bpy.app.version} ( {bpy.app.version_string}) " )
+ sc, prop, acc = build_scene()
+ code = check(prop, acc)
+ if code:
+ return code
+
+ if args.output:
+ if not render_still(os.path.abspath(args.output), args.engine):
+ print("ERROR: render produced no file" , file=sys.stderr)
+ return 9
+ print(f" rendered still {args.output}" )
+
+ print("prop-origin-transform 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 cd0c8b3..cfd54fd 100644
--- a/examples/gallery.json
+++ b/examples/gallery.json
@@ -432,6 +432,19 @@
"mesh",
"game-pipeline"
]
+ },
+ {
+ "name": "prop-origin-transform",
+ "dir": "examples/prop-origin-transform",
+ "teaches": "Street pedestal origin-to-base-center + data-API scale apply + matrix_parent_inverse for a bolted accessory. After bake: scale exactly (1,1,1), local min.z==0, world AABB delta 0. Bare parent jumps the accessory (~0.43 m); MPI restores world location (~3e-8). view_layer.update() before reading matrix_world.",
+ "witnessesFix": "AI code parents with child.parent = parent alone (accessory teleports), skips view_layer.update() before matrix_world, or leaves non-uniform scale / centered origin for engine ingest. Skipping apply, origin bake, or MPI each fails a closed-form gate.",
+ "hero": "docs/gallery/assets/prop-origin-transform-hero.webp",
+ "preview": "examples/prop-origin-transform/preview.webp",
+ "tags": [
+ "transform",
+ "parenting",
+ "game-pipeline"
+ ]
}
]
}
diff --git a/examples/prop-origin-transform/README.md b/examples/prop-origin-transform/README.md
new file mode 100644
index 0000000..5536794
--- /dev/null
+++ b/examples/prop-origin-transform/README.md
@@ -0,0 +1,52 @@
+# Prop Origin Transform
+
+A runnable example that builds a street utility pedestal with a bolted
+conduit accessory and proves the **origin / scale-apply / MPI** contract a
+prop pipeline relies on before engine ingest — following
+[`operators`](../../skills/operators/SKILL.md) and the data-API parenting
+idiom from [`parent-inverse-orrery`](../parent-inverse-orrery/).
+
+**Pipeline arc neighbors:** MPI + stale `matrix_world` in
+[`parent-inverse-orrery`](../parent-inverse-orrery/), export origin
+sensitivity in [`gltf-export-roundtrip`](../gltf-export-roundtrip/), collision
+bounds in [`collision-hull-proxy`](../collision-hull-proxy/), and mesh hygiene
+in [`mesh-hygiene-audit`](../mesh-hygiene-audit/). Origin at the base center
+with applied scale is what placement and physics ingest assume.
+
+**Scope:** this witnesses the bpy-level contract a prop pipeline relies on
+(post-bake scale exactly `(1,1,1)`, local bbox `min.z == 0`, world bbox
+unchanged, MPI so a parented accessory does not teleport). It is not an
+engine exporter — it proves the transform properties such an asset must have.
+
+**What it witnesses:**
+
+- **Stale `matrix_world`.** After a location edit, `matrix_world` is unchanged
+ until `view_layer.update()` (same half of the orrery contract).
+- **Scale apply via data API.** Non-uniform scale baked into verts;
+ `obj.scale == (1,1,1)` exactly afterward.
+- **Origin at base center.** Local bbox `min.z == 0`, XY centered; world AABB
+ delta across the bake is **0** (gate `1e-5`).
+- **Bare-parent trap.** Parenting the accessory without MPI jumps it
+ (~0.43 m measured); setting `matrix_parent_inverse` restores world location
+ (err ~3e-8).
+
+**What each check catches on failure:** skipping scale apply leaves
+non-identity scale; skipping origin bake leaves `min.z ≈ -0.756`; skipping
+MPI leaves the accessory teleported.
+
+**Version witness:** byte-identical numbers on Blender 4.5.11 LTS and 5.1.2.
+
+The render is a dual panel: left **TRAP** (bare parent — accessory teleports,
+hazard material) vs right **MPI KEEP** (accessory stays put), with emissive
+origin markers at each pedestal's base.
+
+## Run
+
+```bash
+blender --background --python prop_origin_transform.py --
+blender --background --python prop_origin_transform.py -- --output origin.png
+blender --background --python prop_origin_transform.py -- --output origin.png --engine cycles
+```
+
+Exits non-zero on failure. The `blender-smoke` workflow runs the check on
+Blender 4.5 LTS and 5.1.
diff --git a/examples/prop-origin-transform/preview.webp b/examples/prop-origin-transform/preview.webp
new file mode 100644
index 0000000..532c899
Binary files /dev/null and b/examples/prop-origin-transform/preview.webp differ
diff --git a/examples/prop-origin-transform/prop_origin_transform.py b/examples/prop-origin-transform/prop_origin_transform.py
new file mode 100644
index 0000000..4470ec8
--- /dev/null
+++ b/examples/prop-origin-transform/prop_origin_transform.py
@@ -0,0 +1,556 @@
+"""Prop origin transform — base-center origin + scale apply + MPI accessory.
+
+Witnesses the object-level contract a prop pipeline relies on before engine
+ingest: origin at the local base center, scale applied through the data API
+to exactly (1,1,1), world bbox unchanged across the bake, and
+`matrix_parent_inverse` so a parented accessory does not teleport. Extends
+`parent-inverse-orrery` (MPI idiom + stale `matrix_world`) without retreading
+orbits — subject is a street utility pedestal with a bolted conduit accessory.
+
+By default it runs only the correctness check (no render). Pass --output
+to also render a still:
+
+ blender --background --python prop_origin_transform.py --
+ blender --background --python prop_origin_transform.py -- --output o.png
+"""
+import bpy, bmesh, sys, os, math, argparse
+from mathutils import Matrix, Vector
+
+BBOX_EPS = 1e-6
+WORLD_EPS = 1e-5
+MPI_EPS = 1e-6
+STALE_EPS = 1e-9
+JUMP_MIN = 0.05
+
+
+def eevee_engine_id():
+ return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT"
+
+
+def make_material(name, rgb, rough=0.45, metallic=0.35, emit=None, estr=0.0):
+ mat = bpy.data.materials.new(name)
+ mat.use_nodes = True
+ b = mat.node_tree.nodes["Principled BSDF"]
+ b.inputs["Base Color"].default_value = (*rgb, 1.0)
+ b.inputs["Roughness"].default_value = rough
+ b.inputs["Metallic"].default_value = metallic
+ if emit is not None:
+ sock = b.inputs.get("Emission Color") or b.inputs["Emission"]
+ sock.default_value = (*emit, 1.0)
+ b.inputs["Emission Strength"].default_value = estr
+ return mat
+
+
+def local_bbox(me):
+ xs = [v.co.x for v in me.vertices]
+ ys = [v.co.y for v in me.vertices]
+ zs = [v.co.z for v in me.vertices]
+ return (
+ Vector((min(xs), min(ys), min(zs))),
+ Vector((max(xs), max(ys), max(zs))),
+ )
+
+
+def world_bbox(obj):
+ """Axis-aligned world bbox from object bound_box (needs fresh depsgraph)."""
+ corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
+ xs = [c.x for c in corners]
+ ys = [c.y for c in corners]
+ zs = [c.z for c in corners]
+ return (
+ Vector((min(xs), min(ys), min(zs))),
+ Vector((max(xs), max(ys), max(zs))),
+ )
+
+
+def bbox_delta(a, b):
+ (amin, amax), (bmin, bmax) = a, b
+ return max(
+ (amin - bmin).length,
+ (amax - bmax).length,
+ abs(amin.x - bmin.x), abs(amin.y - bmin.y), abs(amin.z - bmin.z),
+ abs(amax.x - bmax.x), abs(amax.y - bmax.y), abs(amax.z - bmax.z),
+ )
+
+
+def build_pedestal_mesh():
+ """Stepped street pedestal mesh in local space (origin roughly at center)."""
+ rings = [
+ (0.55, 0.45, -0.70),
+ (0.55, 0.45, -0.52),
+ (0.42, 0.34, -0.52),
+ (0.42, 0.34, 0.55),
+ (0.48, 0.38, 0.55),
+ (0.48, 0.38, 0.70),
+ ]
+ me = bpy.data.meshes.new("Pedestal")
+ bm = bmesh.new()
+ try:
+ ring_verts = []
+ for hx, hy, z in rings:
+ ring_verts.append([
+ bm.verts.new((-hx, -hy, z)),
+ bm.verts.new((hx, -hy, z)),
+ bm.verts.new((hx, hy, z)),
+ bm.verts.new((-hx, hy, z)),
+ ])
+ for i in range(len(ring_verts) - 1):
+ a, b = ring_verts[i], ring_verts[i + 1]
+ for k in range(4):
+ n = (k + 1) % 4
+ bm.faces.new((a[k], a[n], b[n], b[k]))
+ bot, top = ring_verts[0], ring_verts[-1]
+ bm.faces.new((bot[0], bot[3], bot[2], bot[1]))
+ bm.faces.new((top[0], top[1], top[2], top[3]))
+ bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
+ bm.to_mesh(me)
+ finally:
+ bm.free()
+ return me
+
+
+def build_accessory_mesh():
+ """Small conduit elbow — bolted accessory for the MPI trap."""
+ me = bpy.data.meshes.new("Conduit")
+ bm = bmesh.new()
+ try:
+ bmesh.ops.create_cone(
+ bm, cap_ends=True, segments=12, radius1=0.07, radius2=0.07, depth=0.35,
+ )
+ bmesh.ops.rotate(
+ bm, verts=bm.verts, cent=(0, 0, 0),
+ matrix=Matrix.Rotation(math.radians(90), 4, "X"),
+ )
+ bm.to_mesh(me)
+ finally:
+ bm.free()
+ return me
+
+
+def apply_scale_data_api(obj):
+ """Bake non-uniform scale into mesh verts; leave obj.scale == (1,1,1)."""
+ sx, sy, sz = obj.scale
+ for v in obj.data.vertices:
+ v.co.x *= sx
+ v.co.y *= sy
+ v.co.z *= sz
+ obj.scale = (1.0, 1.0, 1.0)
+ obj.data.update()
+
+
+def origin_to_base_center(obj):
+ """Shift mesh so local min Z == 0 and XY centered; keep world geometry."""
+ mn, mx = local_bbox(obj.data)
+ ox = 0.5 * (mn.x + mx.x)
+ oy = 0.5 * (mn.y + mx.y)
+ oz = mn.z
+ offset = Vector((ox, oy, oz))
+ for v in obj.data.vertices:
+ v.co -= offset
+ obj.data.update()
+ # World translation that compensates the mesh shift under current transform
+ bpy.context.view_layer.update()
+ offset_world = obj.matrix_world.to_3x3() @ offset
+ obj.location = obj.location + offset_world
+
+
+def parent_keep_world(child, parent):
+ child.parent = parent
+ child.matrix_parent_inverse = parent.matrix_world.inverted()
+
+
+def build_scene():
+ bpy.ops.wm.read_factory_settings(use_empty=True)
+ sc = bpy.context.scene
+
+ body_m = make_material("Body", (0.20, 0.38, 0.34), rough=0.42, metallic=0.55)
+ acc_m = make_material("Conduit", (0.45, 0.42, 0.28), rough=0.4, metallic=0.7)
+
+ me = build_pedestal_mesh()
+ me.materials.append(body_m)
+ for p in me.polygons:
+ p.use_smooth = False
+ prop = bpy.data.objects.new("StreetPedestal", me)
+ # Intentionally wrong for ingest: non-uniform scale, origin at geometry center
+ prop.scale = (1.15, 0.92, 1.08)
+ prop.location = (0.0, 0.0, 0.0)
+ sc.collection.objects.link(prop)
+
+ ame = build_accessory_mesh()
+ ame.materials.append(acc_m)
+ acc = bpy.data.objects.new("Accessory", ame)
+ # World pose before parenting — bolted to the front face mid-height
+ acc.location = (0.0, -0.55, 0.15)
+ acc.rotation_euler = (0.0, 0.0, 0.0)
+ sc.collection.objects.link(acc)
+
+ bpy.context.view_layer.update()
+ return sc, prop, acc
+
+
+def bake_prop(prop):
+ """Apply scale then origin-to-base-center; return world bbox before/after."""
+ bpy.context.view_layer.update()
+ before = world_bbox(prop)
+ apply_scale_data_api(prop)
+ bpy.context.view_layer.update()
+ origin_to_base_center(prop)
+ bpy.context.view_layer.update()
+ after = world_bbox(prop)
+ return before, after
+
+
+def check(prop, acc):
+ """Assert origin/scale bake + MPI accessory contract. Exit 3–8 on failure."""
+ view_layer = bpy.context.view_layer
+
+ # --- 0. Stale matrix_world without update (orrery half of the contract) ---
+ # Nudge location; matrix_world must stay stale until update().
+ view_layer.update()
+ before = prop.matrix_world.translation.copy()
+ prop.location.x += 0.25
+ stale = (prop.matrix_world.translation - before).length
+ if stale > STALE_EPS:
+ print(
+ f"ERROR: matrix_world updated without view_layer.update() "
+ f"(delta={stale:.3e}) — cannot witness the stale-matrix trap",
+ file=sys.stderr,
+ )
+ return 3
+ view_layer.update()
+ fresh = (prop.matrix_world.translation - before).length
+ if abs(fresh - 0.25) > WORLD_EPS:
+ print(
+ f"ERROR: after update, location delta {fresh:.6f} != 0.25",
+ file=sys.stderr,
+ )
+ return 3
+ prop.location.x -= 0.25
+ view_layer.update()
+ print(f"stale_matrix_ok stale={stale:.3e} fresh={fresh:.6f}")
+
+ # --- 1. Bake scale + origin; world bbox must hold ---
+ before_bb, after_bb = bake_prop(prop)
+ delta = bbox_delta(before_bb, after_bb)
+ print(
+ f"world_bbox_delta={delta:.3e} "
+ f"before_min={tuple(round(c, 6) for c in before_bb[0])} "
+ f"after_min={tuple(round(c, 6) for c in after_bb[0])}"
+ )
+ if delta > WORLD_EPS:
+ print(
+ f"ERROR: world bbox moved across bake (delta={delta:.3e} > {WORLD_EPS})",
+ file=sys.stderr,
+ )
+ return 4
+
+ sx, sy, sz = prop.scale
+ if abs(sx - 1.0) > 0 or abs(sy - 1.0) > 0 or abs(sz - 1.0) > 0:
+ print(f"ERROR: scale after bake is {(sx, sy, sz)}, want (1,1,1)", file=sys.stderr)
+ return 5
+ print(f"scale_ok scale={(sx, sy, sz)}")
+
+ mn, mx = local_bbox(prop.data)
+ print(f"local_bbox min={tuple(round(c, 6) for c in mn)} max={tuple(round(c, 6) for c in mx)}")
+ if abs(mn.z) > BBOX_EPS:
+ print(f"ERROR: local min.z={mn.z:.6f} != 0 (origin not at base)", file=sys.stderr)
+ return 6
+ if abs(0.5 * (mn.x + mx.x)) > BBOX_EPS or abs(0.5 * (mn.y + mx.y)) > BBOX_EPS:
+ print(
+ f"ERROR: local XY center not at origin "
+ f"(cx={0.5*(mn.x+mx.x):.6f} cy={0.5*(mn.y+mx.y):.6f})",
+ file=sys.stderr,
+ )
+ return 6
+ print(f"origin_base_ok min_z={mn.z:.3e}")
+
+ # --- 2. Bare-parent trap on the accessory, then MPI fix ---
+ view_layer.update()
+ w0 = acc.matrix_world.translation.copy()
+ # Move parent so bare parenting has something to fight against
+ prop.location.z += 0.4
+ prop.rotation_euler.z = math.radians(25)
+ view_layer.update()
+
+ acc.parent = prop # bare — must jump
+ view_layer.update()
+ jumped = (acc.matrix_world.translation - w0).length
+ print(f"bare_parent_jump={jumped:.6f}")
+ if jumped < JUMP_MIN:
+ print(
+ f"ERROR: bare parenting moved accessory only {jumped:.6f} "
+ f"(expected jump >= {JUMP_MIN}) — trap not witnessed",
+ file=sys.stderr,
+ )
+ return 7
+
+ acc.matrix_parent_inverse = prop.matrix_world.inverted()
+ view_layer.update()
+ err = (acc.matrix_world.translation - w0).length
+ print(f"mpi_restore_err={err:.3e}")
+ if err > MPI_EPS:
+ print(
+ f"ERROR: matrix_parent_inverse failed to restore world location "
+ f"(err={err:.3e} > {MPI_EPS})",
+ file=sys.stderr,
+ )
+ return 8
+
+ print(
+ f"prop-origin-transform OK scale=(1,1,1) min_z={mn.z:.3e} "
+ f"world_bbox_delta={delta:.3e} bare_jump={jumped:.6f} mpi_err={err:.3e}"
+ )
+ return 0
+
+
+def origin_marker(sc, loc):
+ """Small emissive triad at the object origin — scene evidence, not a light."""
+ mat = make_material(
+ "OriginMark", (1.0, 0.55, 0.1), rough=0.35, metallic=0.0,
+ emit=(1.0, 0.6, 0.1), estr=2.2,
+ )
+ me = bpy.data.meshes.new("OriginBead")
+ bm = bmesh.new()
+ try:
+ bmesh.ops.create_uvsphere(bm, u_segments=10, v_segments=8, radius=0.09)
+ bm.to_mesh(me)
+ finally:
+ bm.free()
+ me.materials.append(mat)
+ ob = bpy.data.objects.new("OriginBead", me)
+ ob.location = loc
+ sc.collection.objects.link(ob)
+ # Axis stubs
+ for axis, rgb, rot in (
+ ("X", (1.0, 0.2, 0.15), (0, math.radians(90), 0)),
+ ("Y", (0.2, 1.0, 0.25), (math.radians(-90), 0, 0)),
+ ("Z", (0.25, 0.45, 1.0), (0, 0, 0)),
+ ):
+ ame = bpy.data.meshes.new(f"Axis{axis}")
+ abm = bmesh.new()
+ try:
+ bmesh.ops.create_cone(
+ abm, cap_ends=True, segments=8, radius1=0.015, radius2=0.015, depth=0.28,
+ )
+ bmesh.ops.translate(abm, vec=(0, 0, 0.14), verts=abm.verts)
+ abm.to_mesh(ame)
+ finally:
+ abm.free()
+ am = make_material(
+ f"Axis{axis}", rgb, rough=0.4, metallic=0.0, emit=rgb, estr=1.5,
+ )
+ ame.materials.append(am)
+ aob = bpy.data.objects.new(f"Axis{axis}", ame)
+ aob.location = loc
+ aob.rotation_euler = rot
+ sc.collection.objects.link(aob)
+
+
+def placard(sc, text, loc, size=0.18):
+ cu = bpy.data.curves.new(text, "FONT")
+ cu.body = text
+ cu.size = size
+ cu.align_x = "CENTER"
+ ob = bpy.data.objects.new(text, cu)
+ ob.location = loc
+ sc.collection.objects.link(ob)
+ mat = make_material("Label", (0.92, 0.92, 0.94), rough=0.6, metallic=0.0)
+ ob.data.materials.append(mat)
+ return ob
+
+
+def build_studio(sc):
+ 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 = make_material("Studio", (0.03, 0.032, 0.037), rough=0.7, metallic=0.0)
+ floor_me.materials.append(fmat)
+ floor = bpy.data.objects.new("Floor", floor_me)
+ sc.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.radians(90), 0.0, 0.0)
+ sc.collection.objects.link(wall)
+
+ world = bpy.data.worlds.new("World")
+ world.use_nodes = True
+ world.node_tree.nodes["Background"].inputs["Color"].default_value = (
+ 0.02, 0.021, 0.025, 1.0,
+ )
+ sc.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)
+ sc.collection.objects.link(ob)
+
+ light("Key", (-3.5, -4.5, 5.5), 480.0, 4.5, (1.0, 0.96, 0.9), (48, 0, -35))
+ light("Fill", (5.0, -3.5, 2.5), 120.0, 9.0, (0.75, 0.85, 1.0), (65, 0, 50))
+ light("Rim", (1.5, 4.5, 3.5), 280.0, 3.0, (0.6, 0.78, 1.0), (-55, 0, 170))
+ light("Wedge", (2.5, 5.5, 4.0), 400.0, 6.0, (1.0, 0.72, 0.42), (-68, 0, 190))
+
+
+def render_still(path, engine):
+ """Dual panel: bare-parent TRAP (left) vs MPI KEEP (right) + origin at base."""
+ # Fresh scene for the still so check mutations don't poison framing
+ bpy.ops.wm.read_factory_settings(use_empty=True)
+ sc = bpy.context.scene
+
+ body_m = make_material("BodyR", (0.18, 0.42, 0.38), rough=0.42, metallic=0.55)
+ acc_m = make_material("AccR", (0.55, 0.45, 0.22), rough=0.35, metallic=0.8)
+ trap_m = make_material(
+ "TrapAcc", (0.9, 0.2, 0.1), rough=0.4, metallic=0.3,
+ emit=(1.0, 0.25, 0.05), estr=1.0,
+ )
+
+ def make_prop(name, loc, rot_z=0.0):
+ me = build_pedestal_mesh()
+ me.materials.append(body_m)
+ for p in me.polygons:
+ p.use_smooth = False
+ ob = bpy.data.objects.new(name, me)
+ ob.scale = (1.15, 0.92, 1.08)
+ ob.location = loc
+ sc.collection.objects.link(ob)
+ bpy.context.view_layer.update()
+ apply_scale_data_api(ob)
+ bpy.context.view_layer.update()
+ origin_to_base_center(ob)
+ bpy.context.view_layer.update()
+ # Sit on floor after bake
+ bpy.context.view_layer.update()
+ wb = world_bbox(ob)
+ ob.location.z -= wb[0].z
+ ob.rotation_euler.z = rot_z
+ bpy.context.view_layer.update()
+ return ob
+
+ def make_acc(name, world_loc, mat):
+ me = build_accessory_mesh()
+ me.materials.append(mat)
+ ob = bpy.data.objects.new(name, me)
+ ob.location = world_loc
+ sc.collection.objects.link(ob)
+ return ob
+
+ left = make_prop("TrapProp", (-1.55, 0.0, 0.0), rot_z=math.radians(12))
+ right = make_prop("KeepProp", (1.55, 0.0, 0.0), rot_z=math.radians(-8))
+
+ # Accessory intended world pose (front of each pedestal mid-height)
+ bpy.context.view_layer.update()
+ left_front = left.matrix_world @ Vector((0.0, -0.48, 0.85))
+ right_front = right.matrix_world @ Vector((0.0, -0.48, 0.85))
+
+ trap_acc = make_acc("TrapAcc", left_front, trap_m)
+ keep_acc = make_acc("KeepAcc", right_front, acc_m)
+ trap_acc.scale = (1.8, 1.8, 1.8)
+ keep_acc.scale = (1.8, 1.8, 1.8)
+ bpy.context.view_layer.update()
+
+ # Ghost at the intended pose on the TRAP side — the "where it should stay"
+ ghost_m = make_material(
+ "Ghost", (0.7, 0.75, 0.8), rough=0.6, metallic=0.0,
+ emit=(0.5, 0.55, 0.6), estr=0.35,
+ )
+ ghost = make_acc("ExpectedGhost", left_front, ghost_m)
+ ghost.scale = (1.8, 1.8, 1.8)
+ # slight transparency via alpha isn't reliable in EEVEE without setup;
+ # keep emissive pale so it reads as "expected seat"
+
+ # Left: bare parent after translating+yawing parent → accessory teleports
+ left.location += Vector((0.55, 0.35, 0.0))
+ left.rotation_euler.z += math.radians(55)
+ bpy.context.view_layer.update()
+ w_before = trap_acc.matrix_world.translation.copy()
+ trap_acc.parent = left
+ bpy.context.view_layer.update()
+ jumped = (trap_acc.matrix_world.translation - w_before).length
+ print(f"render_trap_jump={jumped:.4f} acc_at={tuple(round(c,3) for c in trap_acc.matrix_world.translation)}")
+
+ # Right: MPI keep-world (accessory stays on the front face)
+ parent_keep_world(keep_acc, right)
+ bpy.context.view_layer.update()
+
+ # Origin markers at each prop's object origin (base center after bake)
+ origin_marker(sc, Vector(left.matrix_world.translation))
+ origin_marker(sc, Vector(right.matrix_world.translation))
+
+ placard(sc, "TRAP", (-1.2, -1.25, 0.02), size=0.22)
+ placard(sc, "MPI KEEP", (1.55, -1.25, 0.02), size=0.22)
+
+ build_studio(sc)
+
+ cam_data = bpy.data.cameras.new("Cam")
+ cam_data.lens = 38.0
+ cam = bpy.data.objects.new("Cam", cam_data)
+ cam.location = (0.15, -7.2, 2.15)
+ sc.collection.objects.link(cam)
+ aim = bpy.data.objects.new("Aim", None)
+ aim.location = (0.15, 0.0, 0.9)
+ sc.collection.objects.link(aim)
+ tr = cam.constraints.new("TRACK_TO")
+ tr.target = aim
+ tr.track_axis = "TRACK_NEGATIVE_Z"
+ tr.up_axis = "UP_Y"
+ sc.camera = cam
+
+ sc.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id()
+ if engine == "cycles":
+ sc.cycles.device = "CPU"
+ sc.cycles.samples = 64
+ sc.cycles.use_denoising = True
+ else:
+ try:
+ sc.eevee.taa_render_samples = 64
+ except AttributeError:
+ pass
+ sc.render.resolution_x = 1280
+ sc.render.resolution_y = 720
+ sc.render.image_settings.file_format = "PNG"
+ sc.render.filepath = path
+ sc.view_settings.view_transform = "Standard"
+ 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)
+ p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"))
+ args = p.parse_args(argv)
+
+ print(f"binary version: {bpy.app.version} ({bpy.app.version_string})")
+ sc, prop, acc = build_scene()
+ code = check(prop, acc)
+ if code:
+ return code
+
+ if args.output:
+ if not render_still(os.path.abspath(args.output), args.engine):
+ print("ERROR: render produced no file", file=sys.stderr)
+ return 9
+ print(f"rendered still {args.output}")
+
+ print("prop-origin-transform 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)