diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json
index 7e21d53..b83e9ea 100644
--- a/.cursor-plugin/plugin.json
+++ b/.cursor-plugin/plugin.json
@@ -67,6 +67,7 @@
"examples/collision-hull-proxy",
"examples/color-attribute-wheel",
"examples/compositor-glare",
+ "examples/cross-version-property-delete",
"examples/curve-bevel-arc",
"examples/custom-normals-shade",
"examples/damped-track-aim",
@@ -99,6 +100,7 @@
"examples/text-version-stamp",
"examples/triangulate-tangents",
"examples/turntable",
+ "examples/usd-export-evaluation-mode",
"examples/uv-layer-grid",
"examples/vertex-color-ao",
"examples/vertex-weight-limit",
diff --git a/AGENTS.md b/AGENTS.md
index aa6fab6..613b3ed 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -20,7 +20,7 @@ a `.cursor-plugin/plugin.json` manifest so the ecosystem drift checker
classifies it as a `cursor-plugin`. This is content the AI loads when the user
asks Blender questions or works on Blender add-ons in Cursor or Claude Code.
-The content base is 13 skills, 6 rules, 2 templates, 17 snippets, and 45
+The content base is 13 skills, 6 rules, 2 templates, 17 snippets, and 47
examples (counts are CI-enforced against README.md and the manifest). The full
inventory tables and per-item purposes live in `CLAUDE.md`. Example anatomy
and authoring rules: copy `examples/bmesh-gear/`; the render look is specified
@@ -35,7 +35,7 @@ Blender-Developer-Tools/
rules/.mdc # 6 rule files
templates// # 2 starter templates
snippets/.py # 17 standalone Python snippets
- examples// # 45 runnable smoke-gated examples (+ gallery.json)
+ examples// # 47 runnable smoke-gated examples (+ gallery.json)
examples/gallery_framing.py # shared Layer 1 framing measurement (render path only)
scripts/build_gallery.py # generates docs/gallery/ (stdlib only)
scripts/site/ # vendored landing-page build (build_site.py + template)
diff --git a/CLAUDE.md b/CLAUDE.md
index 05e289a..9e5386d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -21,7 +21,7 @@ skills//SKILL.md - AI workflow definitions, 13 total
rules/.mdc - Anti-pattern rules, 6 total
templates// - Starter projects, 2 total
snippets/.py - Standalone code patterns, 17 total
-examples// - Runnable smoke-gated examples, 45 total (+ gallery.json)
+examples// - Runnable smoke-gated examples, 47 total (+ gallery.json)
scripts/build_gallery.py - Regenerates docs/gallery/ from gallery.json (stdlib only)
scripts/site/ - Vendored landing-page build (Jinja2)
docs/gallery/ - Committed generated gallery pages + hero renders
@@ -83,7 +83,7 @@ v0.1.0: canonical object creation and deletion, depsgraph evaluated mesh, bmesh
v0.2.0: Principled BSDF material, driver-with-custom-function via `driver_namespace`, application handler registration, shader node group with cross-version `interface` API, `foreach_get` bulk vertex read, version-branch skeleton, and USD export with `evaluation_mode='RENDER'`.
-## Examples (45)
+## Examples (47)
Runnable scripts at `examples//`, each asserting a real API contract with
deterministic checks (exit non-zero on failure) and optionally rendering a still via
diff --git a/README.md b/README.md
index 129d1f6..191720b 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@
- 13 skills • 6 rules • 2 templates • 17 snippets • 45 examples
+ 13 skills • 6 rules • 2 templates • 17 snippets • 47 examples
@@ -36,7 +36,7 @@
## Overview
-This repository ships **13 skills, 6 rules, 2 templates, 17 snippets, and 45 runnable examples** for Blender Python development targeting Blender 5.2 LTS (current stable) with Blender 4.5 LTS fallback support. Blender 5.1 is prior stable.
+This repository ships **13 skills, 6 rules, 2 templates, 17 snippets, and 47 runnable examples** for Blender Python development targeting Blender 5.2 LTS (current stable) with Blender 4.5 LTS fallback support. Blender 5.1 is prior stable.
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.
@@ -203,7 +203,7 @@ Gallery still is a dual-elevation diptych so the contract reads at thumbnail sca
-Mesh, curves & text — 10 examples
+Mesh, curves & text — 11 examples
@@ -308,6 +308,21 @@ A depsgraph-evaluated export — builds a cube with `SUBSURF`, measures the eval
`evaluated_get().to_mesh()` / `to_mesh_clear()`, and asserts `wm.obj_export` ships the
modifier-applied geometry (exported vertex count == evaluated > base).
+
+
+
+
+
+
+
+
+### [usd-export-evaluation-mode](examples/usd-export-evaluation-mode/)
+
+`wm.usd_export` `evaluation_mode='RENDER'` versus `'VIEWPORT'`. TESSELLATE writes
+the closed-form Catmull-Clark counts (26/24 vs 98/96); default BEST_MATCH writes
+the 8-vert cage and the mode is silent. Asserts the USDA point counts, not the
+still.
+
@@ -496,7 +511,7 @@ and every orbit lands on its closed form.
-Context & Grease Pencil — 3 examples
+Context & Grease Pencil — 4 examples
@@ -511,6 +526,20 @@ Three unit cubes joined into a staircase under `bpy.context.temp_override` — t
replacement for the removed `context.copy()` dict-pass form. Asserts one mesh remains,
sources are gone, and local Z spans all three steps.
+
+
+
+
+
+
+
+
+### [cross-version-property-delete](examples/cross-version-property-delete/)
+
+Custom ID properties are removed with `del id_block[key]`, not `property_unset`.
+IDs are built with `bpy.data.objects.new` — the snippet `__main__` keys off
+`active_object` and is dark headless. Same `del` on 4.5 LTS and 5.x.
+
diff --git a/ROADMAP.md b/ROADMAP.md
index c6114cc..b11b713 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -91,6 +91,9 @@ Audit pass on v0.1.0 content: standards-version markers bumped from `1.9.1` to `
Not committed; target list for the next content version. (v0.3.0 shipped the smoke-gated `examples/` track.)
+- ~~Custom ID-property delete witness~~ **SHIPPED** as `examples/cross-version-property-delete/` — IDs built via `bpy.data.objects.new` (not `active_object`); `property_unset` is TypeError and leaves the key; `del` removes it on 4.5 LTS and 5.x
+- ~~USD export evaluation_mode witness~~ **SHIPPED** as `examples/usd-export-evaluation-mode/` — probed `wm.usd_export` on CI Linux portables 5.2.1 (`9e2066aef7ef`) and 4.5.13 (`daeeeca98fb0`); TESSELLATE+VIEWPORT 26/24, TESSELLATE+RENDER 98/96; BEST_MATCH writes the 8-vert catmullClark cage so the mode is silent
+- Tighten inverted smoke canaries: assert the expected `[FAIL]` marker text (`skipped on ... should run` / `missing post-exit sidecar`) alongside wrapper exit 1, so a canary that dies for the wrong reason does not satisfy the gate
- ~~Fleet Pages facelift + examples support~~ **RESOLVED differently (2026-07-03)**: the meta-repo migration was dropped — the fleet template only scaffolds new tools, and each tool's site evolves independently after that. This repo vendored the site build into `scripts/site/`, redesigned landing + gallery as the Blender-viewport system (see `docs/gallery/DESIGN_NOTES.md`), added the examples grid, nav link, and full hero stats locally. `scripts/build_gallery.py` and `examples/gallery.json` are now permanent, not a prototype awaiting lift-and-shift.
- `modal-operators` skill -- `invoke` returning `RUNNING_MODAL`, the `modal()` event handler, modal cancellation patterns
- `usd-pipelines` skill -- USD export options, `evaluation_mode`, instancing, the USD vs glTF tradeoffs
diff --git a/docs/gallery/assets/cross-version-property-delete-hero.webp b/docs/gallery/assets/cross-version-property-delete-hero.webp
new file mode 100644
index 0000000..4f3e33a
Binary files /dev/null and b/docs/gallery/assets/cross-version-property-delete-hero.webp differ
diff --git a/docs/gallery/assets/usd-export-evaluation-mode-hero.webp b/docs/gallery/assets/usd-export-evaluation-mode-hero.webp
new file mode 100644
index 0000000..e12e184
Binary files /dev/null and b/docs/gallery/assets/usd-export-evaluation-mode-hero.webp differ
diff --git a/docs/gallery/contact-sheets/cross-version-property-delete-contact-sheet.webp b/docs/gallery/contact-sheets/cross-version-property-delete-contact-sheet.webp
new file mode 100644
index 0000000..7cadb6f
Binary files /dev/null and b/docs/gallery/contact-sheets/cross-version-property-delete-contact-sheet.webp differ
diff --git a/docs/gallery/contact-sheets/usd-export-evaluation-mode-contact-sheet.webp b/docs/gallery/contact-sheets/usd-export-evaluation-mode-contact-sheet.webp
new file mode 100644
index 0000000..cf7014b
Binary files /dev/null and b/docs/gallery/contact-sheets/usd-export-evaluation-mode-contact-sheet.webp differ
diff --git a/docs/gallery/cross-version-property-delete/index.html b/docs/gallery/cross-version-property-delete/index.html
new file mode 100644
index 0000000..d38ab59
--- /dev/null
+++ b/docs/gallery/cross-version-property-delete/index.html
@@ -0,0 +1,660 @@
+
+
+
+
+
+ cross-version-property-delete — Examples — Blender Developer Tools
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Skip to content
+
+
+
+
+
+
+ Rendered headless by the example itself — click to zoom.
+ witnesses Factory-empty has no active_object; property_unset raises TypeError and leaves the key; del removes it (clear_has=False). --skip-delete and --unset-instead exit 7.
+
+
blender --background --python examples/cross-version-property-delete/cross_version_property_delete.py --
+
Copy
+
+
+A pair of machined specimen tags that witnesses the custom ID-property delete contract from cross-version-property-delete.py and custom-properties — not the snippet's __main__, which keys off context.active_object and prints nothing in --background.
+The IDs are built with bpy.data.objects.new. The Keep tag still carries ["accession"] = 42 (emissive enamel). The Clear tag had del obj["accession"] (empty pocket). Same del on 4.5 LTS and 5.x; there is no version branch.
+What it witnesses: property_unset is a TypeError on a custom ID key and does not remove it. del id_block[key] does. After factory-empty, there is no active_object.
+What failure each check would catch:
+exit 2 — someone set an active object; the snippet __main__ would have been the only path exit 3 — the ID property never landed exit 4 — property_unset removed the key or failed to raise exit 7 — del did not run (--skip-delete / --unset-instead falsify this: measured clear_has=True)
+Run
+blender --background --python cross_version_property_delete.py --
+blender --background --python cross_version_property_delete.py -- --output tags.png
+--skip-delete and --unset-instead are falsification switches: both leave the Clear plate tagged and exit 7.
+The --output render path measures framing against the Layer 1 band via examples/gallery_framing.py (exit 10 on violation) before writing the still.
+
+
+ Source
+
+ """Custom ID-property delete — a runnable example.
+
+Witnesses the contract the snippet ``cross-version-property-delete`` teaches,
+not the snippet's ``__main__`` (which keys off ``context.active_object`` and
+is dark headless). Builds the ID through ``bpy.data.objects.new`` and asserts:
+
+1. ``obj["accession"] = 42`` lands in ``obj.keys()``.
+2. ``property_unset("accession")`` is a TypeError and does **not** remove
+ the ID property — that RNA call resets a registered property to default.
+3. ``del obj["accession"]`` removes it. Same on 4.5 LTS and 5.x; no version
+ branch.
+
+By default it runs only the correctness check (no render) — the CI smoke
+check. Pass --output to also render a still:
+
+ blender --background --python cross_version_property_delete.py --
+ blender --background --python cross_version_property_delete.py -- --output t.png
+"""
+import bpy, bmesh, sys, os, math, argparse
+
+sys.path.insert(0 , os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir))
+sys.dont_write_bytecode = True
+import gallery_framing
+
+KEY = "accession"
+VALUE = 42
+
+
+def beveled_box(name, size, bevel, segments=2 ):
+ me = bpy.data.meshes.new(name)
+ bm = bmesh.new()
+ try :
+ bmesh.ops.create_cube(bm, size=1.0 )
+ for v in bm.verts:
+ v.co.x *= size[0 ]
+ v.co.y *= size[1 ]
+ v.co.z *= size[2 ]
+ bm.normal_update()
+ bmesh.ops.bevel(
+ bm, geom=list(bm.edges), offset=bevel, segments=segments,
+ profile=0.5 , affect="EDGES" , clamp_overlap=True ,
+ )
+ bm.to_mesh(me)
+ finally :
+ bm.free()
+ ob = bpy.data.objects.new(name, me)
+ bpy.context.collection.objects.link(ob)
+ return ob
+
+
+def cylinder(name, radius, depth, loc, segs=24 ):
+ me = bpy.data.meshes.new(name)
+ bm = bmesh.new()
+ try :
+ bmesh.ops.create_cone(
+ bm, cap_ends=True , cap_tris=False , segments=segs,
+ radius1=radius, radius2=radius, depth=depth,
+ )
+ bm.to_mesh(me)
+ finally :
+ bm.free()
+ ob = bpy.data.objects.new(name, me)
+ ob.location = loc
+ bpy.context.collection.objects.link(ob)
+ return ob
+
+
+def build_tag(name, x, yaw):
+ """Machined specimen tag: plate, pocket, hanging ring. No ID property yet."""
+ plate = beveled_box(f" {name}Plate " , (1.70 , 0.10 , 1.05 ), 0.035 )
+ plate.location = (x, 0.0 , 1.15 )
+ plate.rotation_euler = (0.0 , 0.0 , yaw)
+ pocket = beveled_box(f" {name}Pocket " , (0.92 , 0.04 , 0.48 ), 0.02 )
+ pocket.parent = plate
+ pocket.location = (0.18 , -0.05 , -0.03 )
+ ring = cylinder(f" {name}Ring " , 0.11 , 0.05 , (0.0 , 0.0 , 0.0 ), segs=20 )
+ ring.parent = plate
+ ring.location = (-0.68 , 0.0 , 0.33 )
+ ring.rotation_euler = (math.pi / 2 , 0.0 , 0.0 )
+ return plate, pocket, ring
+
+
+def add_inlay(name, plate):
+ """Emissive enamel only when the ID property is still on the plate."""
+ inlay = beveled_box(f" {name}Inlay " , (0.82 , 0.03 , 0.40 ), 0.015 )
+ inlay.parent = plate
+ inlay.location = (0.18 , -0.07 , -0.03 )
+ return inlay
+
+
+def build():
+ bpy.ops.wm.read_factory_settings(use_empty=True )
+ keep_parts = build_tag("Keep" , -1.15 , 0.0 )
+ clear_parts = build_tag("Clear" , 1.15 , 0.0 )
+ post = cylinder("StandPost" , 0.07 , 1.55 , (0.0 , 0.22 , 0.78 ))
+ base = beveled_box("StandBase" , (3.4 , 0.9 , 0.12 ), 0.04 )
+ base.location = (0.0 , 0.15 , 0.06 )
+ bar = beveled_box("StandBar" , (2.6 , 0.08 , 0.08 ), 0.02 )
+ bar.location = (0.0 , 0.22 , 1.52 )
+ return keep_parts, clear_parts, [post, base, bar]
+
+
+def remove_custom_property(id_block, key):
+ if key not in id_block.keys():
+ return False
+ del id_block[key]
+ return True
+
+
+def check(keep_plate, clear_plate, skip_delete=False , unset_instead=False ):
+ if bpy.context.active_object is not None :
+ print(
+ "ERROR: expected no active_object after factory empty + data-API build; "
+ "the snippet __main__ would have been dark for the same reason" ,
+ file=sys.stderr,
+ )
+ return 2
+
+ keep_plate[KEY] = VALUE
+ clear_plate[KEY] = VALUE
+ if KEY not in keep_plate.keys() or keep_plate[KEY] != VALUE:
+ print(f" ERROR: ID property {KEY} did not land on Keep plate " , file=sys.stderr)
+ return 3
+ if KEY not in clear_plate.keys():
+ print(f" ERROR: ID property {KEY} did not land on Clear plate " , file=sys.stderr)
+ return 3
+
+ try :
+ keep_plate.property_unset(KEY)
+ print("ERROR: property_unset on a custom ID key returned instead of TypeError" ,
+ file=sys.stderr)
+ return 4
+ except TypeError as exc:
+ print(f" property_unset TypeError= {exc}" )
+ if KEY not in keep_plate.keys():
+ print("ERROR: property_unset removed the ID property — it must not" , file=sys.stderr)
+ return 4
+
+ if unset_instead:
+ try :
+ clear_plate.property_unset(KEY)
+ except TypeError:
+ pass
+ elif not skip_delete:
+ if not remove_custom_property(clear_plate, KEY):
+ print("ERROR: del did not report removal" , file=sys.stderr)
+ return 5
+
+ keep_has = KEY in keep_plate.keys()
+ clear_has = KEY in clear_plate.keys()
+ print(
+ f" active_object=None keep_has= {keep_has} clear_has= {clear_has} "
+ f" skip_delete= {skip_delete} unset_instead= {unset_instead}"
+ )
+ if not keep_has:
+ print("ERROR: Keep plate lost the ID property" , file=sys.stderr)
+ return 6
+ if clear_has:
+ print(
+ "ERROR: Clear plate still has the ID property — del did not run "
+ "(property_unset is not a delete)" ,
+ file=sys.stderr,
+ )
+ return 7
+ 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=None ):
+ 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 is not None :
+ sock = bsdf.inputs.get("Emission Color" ) or bsdf.inputs["Emission" ]
+ sock.default_value = emission
+ bsdf.inputs["Emission Strength" ].default_value = 4.5
+ return mat
+
+
+def assign(ob, mat):
+ ob.data.materials.clear()
+ ob.data.materials.append(mat)
+
+
+def render_still(keep_parts, clear_parts, stand, path, engine):
+ scene = bpy.context.scene
+ brass = principled("Brass" , (0.78 , 0.50 , 0.18 , 1.0 ), 1.0 , 0.22 )
+ pocket = principled("Pocket" , (0.04 , 0.042 , 0.05 , 1.0 ), 0.2 , 0.55 )
+ enamel = principled(
+ "Enamel" , (0.02 , 0.35 , 0.55 , 1.0 ), 0.0 , 0.35 ,
+ emission=(0.05 , 0.55 , 0.85 , 1.0 ),
+ )
+ steel = principled("Steel" , (0.18 , 0.19 , 0.21 , 1.0 ), 1.0 , 0.38 )
+
+ keep_plate, keep_pocket, keep_ring = keep_parts
+ clear_plate, clear_pocket, clear_ring = clear_parts
+ for ob in (keep_plate, keep_ring, clear_plate, clear_ring):
+ assign(ob, brass)
+ assign(keep_pocket, pocket)
+ assign(clear_pocket, pocket)
+ for ob in stand:
+ assign(ob, steel)
+
+ inlay = None
+ if KEY in keep_plate.keys():
+ inlay = add_inlay("Keep" , keep_plate)
+ assign(inlay, enamel)
+ if KEY in clear_plate.keys():
+ ghost = add_inlay("Clear" , clear_plate)
+ assign(ghost, enamel)
+
+ 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()
+ floor_me.materials.append(principled("Studio" , (0.03 , 0.032 , 0.037 , 1.0 ), 0.0 , 0.7 ))
+ floor = bpy.data.objects.new("Floor" , floor_me)
+ scene.collection.objects.link(floor)
+ wall = bpy.data.objects.new("Wall" , floor_me.copy())
+ wall.data.materials.clear()
+ wall.data.materials.append(principled("Wall" , (0.03 , 0.032 , 0.037 , 1.0 ), 0.0 , 0.7 ))
+ 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
+ world.node_tree.nodes["Background" ].inputs["Color" ].default_value = (0.02 , 0.021 , 0.025 , 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 , -5.0 , 6.0 ), 520.0 , 5.0 , (1.0 , 0.96 , 0.9 ), (46 , 0 , -35 ))
+ light("Fill" , (5.0 , -3.5 , 3.0 ), 110.0 , 9.0 , (0.75 , 0.85 , 1.0 ), (62 , 0 , 50 ))
+ light("Wedge" , (2.5 , 5.5 , 4.0 ), 380.0 , 6.0 , (1.0 , 0.76 , 0.5 ), (-68 , 0 , 190 ))
+ light("Glint" , (1.6 , -5.0 , 6.0 ), 850.0 , 0.9 , (1.0 , 0.9 , 0.7 ), (40 , 0 , 18 ))
+
+ aim = bpy.data.objects.new("Aim" , None )
+ aim.location = (0.0 , 0.0 , 1.15 )
+ aim.hide_render = True
+ scene.collection.objects.link(aim)
+ cam_data = bpy.data.cameras.new("Cam" )
+ cam_data.lens = 50.0
+ cam = bpy.data.objects.new("Cam" , cam_data)
+ cam.location = (3.55 , -6.59 , 3.30 )
+ scene.collection.objects.link(cam)
+ scene.camera = cam
+ track = cam.constraints.new("TRACK_TO" )
+ track.target = aim
+ track.track_axis = "TRACK_NEGATIVE_Z"
+ track.up_axis = "UP_Y"
+
+ scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id()
+ if engine == "cycles" :
+ scene.cycles.samples = 32
+ 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
+ scene.view_settings.view_transform = "Standard"
+
+ hero = [keep_plate, clear_plate, keep_ring, clear_ring] + list(stand)
+ if inlay is not None :
+ hero.append(inlay)
+ fcode = gallery_framing.check_framing(
+ scene, cam, hero=hero, elements=hero, stage=[floor, wall],
+ )
+ if fcode:
+ return fcode
+ bpy.ops.render.render(write_still=True )
+ if not (os.path.exists(path) and os.path.getsize(path) > 0 ):
+ print("ERROR: render produced no file" , file=sys.stderr)
+ return 12
+ return 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" ))
+ p.add_argument(
+ "--skip-delete" , action="store_true" ,
+ help="falsification: leave the Clear plate tagged" ,
+ )
+ p.add_argument(
+ "--unset-instead" , action="store_true" ,
+ help="falsification: call property_unset instead of del" ,
+ )
+ args = p.parse_args(argv)
+
+ keep_parts, clear_parts, stand = build()
+ code = check(
+ keep_parts[0 ], clear_parts[0 ],
+ skip_delete=args.skip_delete, unset_instead=args.unset_instead,
+ )
+ if code:
+ return code
+
+ if args.output:
+ rcode = render_still(
+ keep_parts, clear_parts, stand,
+ os.path.abspath(args.output), args.engine,
+ )
+ if rcode:
+ return rcode
+ print(f" rendered still {args.output}" )
+
+ print("cross-version-property-delete 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/docs/gallery/index.html b/docs/gallery/index.html
index 7cd1d1a..19fae33 100644
--- a/docs/gallery/index.html
+++ b/docs/gallery/index.html
@@ -272,7 +272,7 @@ Examples Gallery
autocomplete="off" spellcheck="false" aria-label="Search examples" />
×
- 45 examples
+ 47 examples
Compact
Detailed
@@ -308,6 +308,7 @@
Examples Gallery
operators
parenting
performance
+
properties
render
rendering
sequencer
@@ -817,6 +818,28 @@
View example →
+
+
+
+
+
+
+
Custom ID properties are removed with del, not property_unset. The IDs are built through bpy.data.objects.new so the check does not depend on active_object.
+
witnesses Factory-empty has no active_object; property_unset raises TypeError and leaves the key; del removes it (clear_has=False). --skip-delete and --unset-instead exit 7.
+
View example →
+
+
+
+
+
+
+
+
+
The USD exporter evaluation_mode chooses viewport versus render modifier quality. TESSELLATE makes the split observable; BEST_MATCH writes the cage and the mode is silent.
+
witnesses TESSELLATE+VIEWPORT writes 26/24; TESSELLATE+RENDER writes 98/96 subdivisionScheme=none; BEST_MATCH writes the 8-vert catmullClark cage. --evaluation-mode VIEWPORT and --subdivision BEST_MATCH exit 4.
+
View example →
+
+
No examples match the current filters.
Clear search and tags
diff --git a/docs/gallery/usd-export-evaluation-mode/index.html b/docs/gallery/usd-export-evaluation-mode/index.html
new file mode 100644
index 0000000..b81ae09
--- /dev/null
+++ b/docs/gallery/usd-export-evaluation-mode/index.html
@@ -0,0 +1,663 @@
+
+
+
+
+
+ usd-export-evaluation-mode — Examples — Blender Developer Tools
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Skip to content
+
+
+
+
+
+
+ Rendered headless by the example itself — click to zoom.
+ witnesses TESSELLATE+VIEWPORT writes 26/24; TESSELLATE+RENDER writes 98/96 subdivisionScheme=none; BEST_MATCH writes the 8-vert catmullClark cage. --evaluation-mode VIEWPORT and --subdivision BEST_MATCH exit 4.
+
+
blender --background --python examples/usd-export-evaluation-mode/usd_export_evaluation_mode.py --
+
Copy
+
+
+A SUBSURF cube exported through wm.usd_export that witnesses evaluation_mode='RENDER' against 'VIEWPORT' — the contract usd-export-evaluation-mode.py names but does not prove.
+Catmull-Clark on a cube is closed form: verts = 2 + 6 × 4^n (n=1 → 26, n=2 → 98). TESSELLATE + VIEWPORT writes 26 points / 24 quads; TESSELLATE + RENDER writes 98 / 96. Default export_subdivision='BEST_MATCH' writes the 8-vert cage plus subdivisionScheme = catmullClark, so evaluation_mode is silent — both files are the cage. TESSELLATE is what makes the mode observable.
+The still is numeric-adjacent: left is viewport tessellation (flat-shaded L1), right is render tessellation (smoothed L2). The USDA point counts are the evidence; the two balls depict the two qualities the exporter chooses between.
+What failure each check would catch:
+exit 3 — VIEWPORT TESSELLATE did not write the L1 closed form exit 4 — RENDER TESSELLATE wrote viewport quality or the BEST_MATCH cage (--evaluation-mode VIEWPORT measured 26/24; --subdivision BEST_MATCH measured 8/6 catmullClark) exit 5 — BEST_MATCH stopped writing the cage exit 6 — RENDER and VIEWPORT files are identical
+The still depicts viewport vs render tessellation of the same SUBSURF cube (flat-shaded L1 vs smoothed L2). The USDA point counts are the evidence.
+Probed on the CI Linux portables Blender 5.2.1 LTS (9e2066aef7ef) and Blender 4.5.13 LTS (daeeeca98fb0): wm.usd_export exists, poll() is true in --background, evaluation_mode is {RENDER, VIEWPORT}. The --output render path measures framing via examples/gallery_framing.py (exit 10 on violation).
+Run
+blender --background --python usd_export_evaluation_mode.py --
+blender --background --python usd_export_evaluation_mode.py -- --output u.png
+
+
+ Source
+
+ """USD evaluation_mode — a runnable example.
+
+Witnesses ``bpy.ops.wm.usd_export`` ``evaluation_mode='RENDER'`` against
+``VIEWPORT``, which the snippet names but does not prove. A SUBSURF cube
+with ``levels=1`` / ``render_levels=2`` is tessellated into USDA:
+
+* Catmull-Clark on a cube is closed form: verts = 2 + 6 × 4^n
+ (n=1 → 26, n=2 → 98). TESSELLATE + VIEWPORT must write 26 points;
+ TESSELLATE + RENDER must write 98.
+* Default ``export_subdivision='BEST_MATCH'`` writes the 8-vert cage plus
+ ``subdivisionScheme = catmullClark``. evaluation_mode is then silent —
+ both files are the cage. TESSELLATE is what makes the mode observable.
+
+By default it runs only the correctness check (no render) — the CI smoke
+check. Pass --output to also render a still:
+
+ blender --background --python usd_export_evaluation_mode.py --
+ blender --background --python usd_export_evaluation_mode.py -- --output u.png
+"""
+import bpy, bmesh, sys, os, math, argparse, tempfile
+
+sys.path.insert(0 , os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir))
+sys.dont_write_bytecode = True
+import gallery_framing
+
+LEVEL_VP = 1
+LEVEL_RD = 2
+
+
+def cube_cc_verts(level):
+ """Catmull-Clark vertex count for a cube after `level` subdivisions."""
+ return 2 + 6 * (4 ** level)
+
+
+def cube_cc_faces(level):
+ return 6 * (4 ** level)
+
+
+def usda_array_body(text, needle):
+ """Return the contents of `needle = [ ... ]`, skipping the type's `[]`."""
+ key = needle + " = ["
+ i = text.find(key)
+ if i < 0 :
+ return None
+ i += len(key)
+ depth = 1
+ j = i
+ while j < len(text) and depth:
+ if text[j] == "[" :
+ depth += 1
+ elif text[j] == "]" :
+ depth -= 1
+ j += 1
+ return text[i:j - 1 ]
+
+
+def usda_point_count(text):
+ body = usda_array_body(text, "point3f[] points" )
+ if body is None :
+ return 0
+ return body.count("(" )
+
+
+def usda_face_count(text):
+ body = usda_array_body(text, "int[] faceVertexCounts" )
+ if body is None :
+ return 0
+ return body.count("," ) + (1 if body.strip() else 0 )
+
+
+def usda_scheme(text):
+ key = 'uniform token subdivisionScheme = "'
+ i = text.find(key)
+ if i < 0 :
+ return None
+ i += len(key)
+ return text[i:text.find('"' , i)]
+
+
+def build():
+ bpy.ops.wm.read_factory_settings(use_empty=True )
+ me = bpy.data.meshes.new("Ingot" )
+ bm = bmesh.new()
+ try :
+ bmesh.ops.create_cube(bm, size=2.0 )
+ bm.to_mesh(me)
+ finally :
+ bm.free()
+ obj = bpy.data.objects.new("Ingot" , me)
+ bpy.context.collection.objects.link(obj)
+ mod = obj.modifiers.new("Subsurf" , "SUBSURF" )
+ mod.levels = LEVEL_VP
+ mod.render_levels = LEVEL_RD
+ return obj, mod
+
+
+def export_usda(path, evaluation_mode, export_subdivision):
+ if os.path.exists(path):
+ os.remove(path)
+ rna = bpy.ops.wm.usd_export.get_rna_type()
+ have = {p.identifier for p in rna.properties}
+ for key in ("filepath" , "evaluation_mode" , "export_subdivision" ):
+ if key not in have:
+ raise RuntimeError(f" wm.usd_export missing RNA property {key}" )
+ result = bpy.ops.wm.usd_export(
+ filepath=path,
+ evaluation_mode=evaluation_mode,
+ export_subdivision=export_subdivision,
+ selected_objects_only=False ,
+ export_animation=False ,
+ export_hair=False ,
+ export_materials=False ,
+ )
+ if result != {"FINISHED" }:
+ raise RuntimeError(f" usd_export {result} for {path}" )
+ if not (os.path.exists(path) and os.path.getsize(path) > 0 ):
+ raise RuntimeError(f" no USDA written to {path}" )
+ return open(path, encoding="utf-8" ).read()
+
+
+def check(obj, mod, out_dir, force_mode=None , force_subdiv=None ):
+ expect_vp = cube_cc_verts(LEVEL_VP)
+ expect_rd = cube_cc_verts(LEVEL_RD)
+ expect_f_vp = cube_cc_faces(LEVEL_VP)
+ expect_f_rd = cube_cc_faces(LEVEL_RD)
+ base = len(obj.data.vertices)
+ if base != 8 :
+ print(f" ERROR: base cube verts {base} != 8 " , file=sys.stderr)
+ return 2
+
+ mode = force_mode or "RENDER"
+ subdiv = force_subdiv or "TESSELLATE"
+ path_rd = os.path.join(out_dir, "render.usda" )
+ path_vp = os.path.join(out_dir, "viewport.usda" )
+ path_cage = os.path.join(out_dir, "cage.usda" )
+
+ text_rd = export_usda(path_rd, mode, subdiv)
+ text_vp = export_usda(path_vp, "VIEWPORT" , "TESSELLATE" )
+ text_cage = export_usda(path_cage, "RENDER" , "BEST_MATCH" )
+
+ pts_rd = usda_point_count(text_rd)
+ pts_vp = usda_point_count(text_vp)
+ pts_cage = usda_point_count(text_cage)
+ faces_rd = usda_face_count(text_rd)
+ faces_vp = usda_face_count(text_vp)
+ scheme_rd = usda_scheme(text_rd)
+ scheme_cage = usda_scheme(text_cage)
+
+ print(
+ f" base= {base} usda_vp= {pts_vp}/ {faces_vp} usda_rd= {pts_rd}/ {faces_rd} "
+ f" usda_cage= {pts_cage} scheme_rd= {scheme_rd} scheme_cage= {scheme_cage} "
+ f" mode= {mode} subdiv= {subdiv}"
+ )
+ print(
+ f" closed_form vp_verts= {expect_vp} rd_verts= {expect_rd} "
+ f" vp_faces= {expect_f_vp} rd_faces= {expect_f_rd}"
+ )
+
+ if pts_vp != expect_vp or faces_vp != expect_f_vp:
+ print(
+ f" ERROR: VIEWPORT TESSELLATE wrote {pts_vp} points / {faces_vp} faces, "
+ f" expected {expect_vp}/ {expect_f_vp}" ,
+ file=sys.stderr,
+ )
+ return 3
+ if pts_rd != expect_rd or faces_rd != expect_f_rd or scheme_rd != "none" :
+ print(
+ f" ERROR: RENDER TESSELLATE wrote {pts_rd} points / {faces_rd} faces "
+ f" scheme= {scheme_rd}, expected {expect_rd}/ {expect_f_rd} scheme=none "
+ "(VIEWPORT quality or BEST_MATCH cage would not match)" ,
+ file=sys.stderr,
+ )
+ return 4
+ if pts_cage != base or scheme_cage != "catmullClark" :
+ print(
+ f" ERROR: BEST_MATCH should write the cage ( {base} points, catmullClark), "
+ f" got {pts_cage} scheme= {scheme_cage}" ,
+ file=sys.stderr,
+ )
+ return 5
+ if pts_rd == pts_vp:
+ print("ERROR: RENDER and VIEWPORT USDA point counts are identical" , file=sys.stderr)
+ return 6
+ 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):
+ 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
+ return mat
+
+
+def render_still(obj, path, engine):
+ """Left: viewport tessellation (levels=1). Right: render tessellation (levels=2)."""
+ scene = bpy.context.scene
+ steel = principled("Steel" , (0.55 , 0.57 , 0.60 , 1.0 ), 1.0 , 0.58 )
+
+ left = obj
+ left.location = (-1.55 , 0.0 , 1.0 )
+ left.rotation_euler = (0.0 , 0.0 , math.radians(28 ))
+ left.data.materials.append(steel)
+ left.modifiers["Subsurf" ].levels = LEVEL_VP
+ for poly in left.data.polygons:
+ poly.use_smooth = False
+
+ right_me = left.data.copy()
+ right = bpy.data.objects.new("IngotRender" , right_me)
+ right.location = (1.55 , 0.0 , 1.0 )
+ right.rotation_euler = (0.0 , 0.0 , math.radians(28 ))
+ right.data.materials.append(steel)
+ bpy.context.collection.objects.link(right)
+ rm = right.modifiers.new("Subsurf" , "SUBSURF" )
+ rm.levels = LEVEL_RD
+ rm.render_levels = LEVEL_RD
+ for poly in right.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()
+ floor_me.materials.append(principled("Studio" , (0.03 , 0.032 , 0.037 , 1.0 ), 0.0 , 0.7 ))
+ floor = bpy.data.objects.new("Floor" , floor_me)
+ scene.collection.objects.link(floor)
+ wall = bpy.data.objects.new("Wall" , floor_me.copy())
+ wall.data.materials.clear()
+ wall.data.materials.append(principled("Wall" , (0.03 , 0.032 , 0.037 , 1.0 ), 0.0 , 0.7 ))
+ 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
+ world.node_tree.nodes["Background" ].inputs["Color" ].default_value = (0.02 , 0.021 , 0.025 , 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 , -5.0 , 6.0 ), 200.0 , 7.0 , (1.0 , 0.96 , 0.9 ), (46 , 0 , -35 ))
+ light("Fill" , (5.0 , -3.5 , 3.0 ), 180.0 , 9.0 , (0.75 , 0.85 , 1.0 ), (62 , 0 , 50 ))
+ light("Wedge" , (2.5 , 5.5 , 4.0 ), 360.0 , 6.0 , (1.0 , 0.76 , 0.5 ), (-68 , 0 , 190 ))
+
+ aim = bpy.data.objects.new("Aim" , None )
+ aim.location = (0.0 , 0.0 , 1.0 )
+ aim.hide_render = True
+ scene.collection.objects.link(aim)
+ cam_data = bpy.data.cameras.new("Cam" )
+ cam_data.lens = 50.0
+ cam = bpy.data.objects.new("Cam" , cam_data)
+ cam.location = (4.16 , -5.85 , 3.00 )
+ scene.collection.objects.link(cam)
+ scene.camera = cam
+ track = cam.constraints.new("TRACK_TO" )
+ track.target = aim
+ track.track_axis = "TRACK_NEGATIVE_Z"
+ track.up_axis = "UP_Y"
+
+ scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id()
+ if engine == "cycles" :
+ scene.cycles.samples = 32
+ 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
+ scene.view_settings.view_transform = "Standard"
+
+ fcode = gallery_framing.check_framing(
+ scene, cam, hero=[left, right], elements=[left, right], stage=[floor, wall],
+ )
+ if fcode:
+ return fcode
+ bpy.ops.render.render(write_still=True )
+ if not (os.path.exists(path) and os.path.getsize(path) > 0 ):
+ print("ERROR: render produced no file" , file=sys.stderr)
+ return 12
+ return 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" ))
+ p.add_argument(
+ "--evaluation-mode" , default="RENDER" , choices=("RENDER" , "VIEWPORT" ),
+ help="falsification: VIEWPORT fails the RENDER closed form" ,
+ )
+ p.add_argument(
+ "--subdivision" , default="TESSELLATE" ,
+ choices=("TESSELLATE" , "BEST_MATCH" , "IGNORE" ),
+ help="falsification: BEST_MATCH writes the cage" ,
+ )
+ args = p.parse_args(argv)
+
+ obj, mod = build()
+ out_dir = tempfile.mkdtemp(prefix="bdt_usd_" )
+ code = check(
+ obj, mod, out_dir,
+ force_mode=args.evaluation_mode, force_subdiv=args.subdivision,
+ )
+ if code:
+ return code
+
+ if args.output:
+ rcode = render_still(obj, os.path.abspath(args.output), args.engine)
+ if rcode:
+ return rcode
+ print(f" rendered still {args.output}" )
+
+ print("usd-export-evaluation-mode 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/cross-version-property-delete/README.md b/examples/cross-version-property-delete/README.md
new file mode 100644
index 0000000..93d0ac4
--- /dev/null
+++ b/examples/cross-version-property-delete/README.md
@@ -0,0 +1,38 @@
+# Cross-version property delete
+
+A pair of machined specimen tags that witnesses the custom **ID-property**
+delete contract from
+[`cross-version-property-delete.py`](../../snippets/cross-version-property-delete.py)
+and [`custom-properties`](../../skills/custom-properties/SKILL.md) — not the
+snippet's `__main__`, which keys off `context.active_object` and prints
+nothing in `--background`.
+
+The IDs are built with `bpy.data.objects.new`. The Keep tag still carries
+`["accession"] = 42` (emissive enamel). The Clear tag had `del obj["accession"]`
+(empty pocket). Same `del` on 4.5 LTS and 5.x; there is no version branch.
+
+**What it witnesses:** `property_unset` is a TypeError on a custom ID key and
+does **not** remove it. `del id_block[key]` does. After factory-empty, there is
+no `active_object`.
+
+**What failure each check would catch:**
+
+- exit 2 — someone set an active object; the snippet `__main__` would have
+ been the only path
+- exit 3 — the ID property never landed
+- exit 4 — `property_unset` removed the key or failed to raise
+- exit 7 — `del` did not run (`--skip-delete` / `--unset-instead` falsify
+ this: measured `clear_has=True`)
+
+## Run
+
+```bash
+blender --background --python cross_version_property_delete.py --
+blender --background --python cross_version_property_delete.py -- --output tags.png
+```
+
+`--skip-delete` and `--unset-instead` are falsification switches: both leave
+the Clear plate tagged and exit 7.
+
+The `--output` render path measures framing against the Layer 1 band via
+`examples/gallery_framing.py` (exit 10 on violation) before writing the still.
diff --git a/examples/cross-version-property-delete/cross_version_property_delete.py b/examples/cross-version-property-delete/cross_version_property_delete.py
new file mode 100644
index 0000000..9e862a3
--- /dev/null
+++ b/examples/cross-version-property-delete/cross_version_property_delete.py
@@ -0,0 +1,342 @@
+"""Custom ID-property delete — a runnable example.
+
+Witnesses the contract the snippet ``cross-version-property-delete`` teaches,
+not the snippet's ``__main__`` (which keys off ``context.active_object`` and
+is dark headless). Builds the ID through ``bpy.data.objects.new`` and asserts:
+
+1. ``obj["accession"] = 42`` lands in ``obj.keys()``.
+2. ``property_unset("accession")`` is a TypeError and does **not** remove
+ the ID property — that RNA call resets a registered property to default.
+3. ``del obj["accession"]`` removes it. Same on 4.5 LTS and 5.x; no version
+ branch.
+
+By default it runs only the correctness check (no render) — the CI smoke
+check. Pass --output to also render a still:
+
+ blender --background --python cross_version_property_delete.py --
+ blender --background --python cross_version_property_delete.py -- --output t.png
+"""
+import bpy, bmesh, sys, os, math, argparse
+
+sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir))
+sys.dont_write_bytecode = True
+import gallery_framing
+
+KEY = "accession"
+VALUE = 42
+
+
+def beveled_box(name, size, bevel, segments=2):
+ me = bpy.data.meshes.new(name)
+ bm = bmesh.new()
+ try:
+ bmesh.ops.create_cube(bm, size=1.0)
+ for v in bm.verts:
+ v.co.x *= size[0]
+ v.co.y *= size[1]
+ v.co.z *= size[2]
+ bm.normal_update()
+ bmesh.ops.bevel(
+ bm, geom=list(bm.edges), offset=bevel, segments=segments,
+ profile=0.5, affect="EDGES", clamp_overlap=True,
+ )
+ bm.to_mesh(me)
+ finally:
+ bm.free()
+ ob = bpy.data.objects.new(name, me)
+ bpy.context.collection.objects.link(ob)
+ return ob
+
+
+def cylinder(name, radius, depth, loc, segs=24):
+ me = bpy.data.meshes.new(name)
+ bm = bmesh.new()
+ try:
+ bmesh.ops.create_cone(
+ bm, cap_ends=True, cap_tris=False, segments=segs,
+ radius1=radius, radius2=radius, depth=depth,
+ )
+ bm.to_mesh(me)
+ finally:
+ bm.free()
+ ob = bpy.data.objects.new(name, me)
+ ob.location = loc
+ bpy.context.collection.objects.link(ob)
+ return ob
+
+
+def build_tag(name, x, yaw):
+ """Machined specimen tag: plate, pocket, hanging ring. No ID property yet."""
+ plate = beveled_box(f"{name}Plate", (1.70, 0.10, 1.05), 0.035)
+ plate.location = (x, 0.0, 1.15)
+ plate.rotation_euler = (0.0, 0.0, yaw)
+ pocket = beveled_box(f"{name}Pocket", (0.92, 0.04, 0.48), 0.02)
+ pocket.parent = plate
+ pocket.location = (0.18, -0.05, -0.03)
+ ring = cylinder(f"{name}Ring", 0.11, 0.05, (0.0, 0.0, 0.0), segs=20)
+ ring.parent = plate
+ ring.location = (-0.68, 0.0, 0.33)
+ ring.rotation_euler = (math.pi / 2, 0.0, 0.0)
+ return plate, pocket, ring
+
+
+def add_inlay(name, plate):
+ """Emissive enamel only when the ID property is still on the plate."""
+ inlay = beveled_box(f"{name}Inlay", (0.82, 0.03, 0.40), 0.015)
+ inlay.parent = plate
+ inlay.location = (0.18, -0.07, -0.03)
+ return inlay
+
+
+def build():
+ bpy.ops.wm.read_factory_settings(use_empty=True)
+ keep_parts = build_tag("Keep", -1.15, 0.0)
+ clear_parts = build_tag("Clear", 1.15, 0.0)
+ post = cylinder("StandPost", 0.07, 1.55, (0.0, 0.22, 0.78))
+ base = beveled_box("StandBase", (3.4, 0.9, 0.12), 0.04)
+ base.location = (0.0, 0.15, 0.06)
+ bar = beveled_box("StandBar", (2.6, 0.08, 0.08), 0.02)
+ bar.location = (0.0, 0.22, 1.52)
+ return keep_parts, clear_parts, [post, base, bar]
+
+
+def remove_custom_property(id_block, key):
+ if key not in id_block.keys():
+ return False
+ del id_block[key]
+ return True
+
+
+def check(keep_plate, clear_plate, skip_delete=False, unset_instead=False):
+ if bpy.context.active_object is not None:
+ print(
+ "ERROR: expected no active_object after factory empty + data-API build; "
+ "the snippet __main__ would have been dark for the same reason",
+ file=sys.stderr,
+ )
+ return 2
+
+ keep_plate[KEY] = VALUE
+ clear_plate[KEY] = VALUE
+ if KEY not in keep_plate.keys() or keep_plate[KEY] != VALUE:
+ print(f"ERROR: ID property {KEY} did not land on Keep plate", file=sys.stderr)
+ return 3
+ if KEY not in clear_plate.keys():
+ print(f"ERROR: ID property {KEY} did not land on Clear plate", file=sys.stderr)
+ return 3
+
+ try:
+ keep_plate.property_unset(KEY)
+ print("ERROR: property_unset on a custom ID key returned instead of TypeError",
+ file=sys.stderr)
+ return 4
+ except TypeError as exc:
+ print(f"property_unset TypeError={exc}")
+ if KEY not in keep_plate.keys():
+ print("ERROR: property_unset removed the ID property — it must not", file=sys.stderr)
+ return 4
+
+ if unset_instead:
+ try:
+ clear_plate.property_unset(KEY)
+ except TypeError:
+ pass
+ elif not skip_delete:
+ if not remove_custom_property(clear_plate, KEY):
+ print("ERROR: del did not report removal", file=sys.stderr)
+ return 5
+
+ keep_has = KEY in keep_plate.keys()
+ clear_has = KEY in clear_plate.keys()
+ print(
+ f"active_object=None keep_has={keep_has} clear_has={clear_has} "
+ f"skip_delete={skip_delete} unset_instead={unset_instead}"
+ )
+ if not keep_has:
+ print("ERROR: Keep plate lost the ID property", file=sys.stderr)
+ return 6
+ if clear_has:
+ print(
+ "ERROR: Clear plate still has the ID property — del did not run "
+ "(property_unset is not a delete)",
+ file=sys.stderr,
+ )
+ return 7
+ 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=None):
+ 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 is not None:
+ sock = bsdf.inputs.get("Emission Color") or bsdf.inputs["Emission"]
+ sock.default_value = emission
+ bsdf.inputs["Emission Strength"].default_value = 4.5
+ return mat
+
+
+def assign(ob, mat):
+ ob.data.materials.clear()
+ ob.data.materials.append(mat)
+
+
+def render_still(keep_parts, clear_parts, stand, path, engine):
+ scene = bpy.context.scene
+ brass = principled("Brass", (0.78, 0.50, 0.18, 1.0), 1.0, 0.22)
+ pocket = principled("Pocket", (0.04, 0.042, 0.05, 1.0), 0.2, 0.55)
+ enamel = principled(
+ "Enamel", (0.02, 0.35, 0.55, 1.0), 0.0, 0.35,
+ emission=(0.05, 0.55, 0.85, 1.0),
+ )
+ steel = principled("Steel", (0.18, 0.19, 0.21, 1.0), 1.0, 0.38)
+
+ keep_plate, keep_pocket, keep_ring = keep_parts
+ clear_plate, clear_pocket, clear_ring = clear_parts
+ for ob in (keep_plate, keep_ring, clear_plate, clear_ring):
+ assign(ob, brass)
+ assign(keep_pocket, pocket)
+ assign(clear_pocket, pocket)
+ for ob in stand:
+ assign(ob, steel)
+
+ inlay = None
+ if KEY in keep_plate.keys():
+ inlay = add_inlay("Keep", keep_plate)
+ assign(inlay, enamel)
+ if KEY in clear_plate.keys():
+ ghost = add_inlay("Clear", clear_plate)
+ assign(ghost, enamel)
+
+ 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()
+ floor_me.materials.append(principled("Studio", (0.03, 0.032, 0.037, 1.0), 0.0, 0.7))
+ floor = bpy.data.objects.new("Floor", floor_me)
+ scene.collection.objects.link(floor)
+ wall = bpy.data.objects.new("Wall", floor_me.copy())
+ wall.data.materials.clear()
+ wall.data.materials.append(principled("Wall", (0.03, 0.032, 0.037, 1.0), 0.0, 0.7))
+ 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
+ world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.02, 0.021, 0.025, 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, -5.0, 6.0), 520.0, 5.0, (1.0, 0.96, 0.9), (46, 0, -35))
+ light("Fill", (5.0, -3.5, 3.0), 110.0, 9.0, (0.75, 0.85, 1.0), (62, 0, 50))
+ light("Wedge", (2.5, 5.5, 4.0), 380.0, 6.0, (1.0, 0.76, 0.5), (-68, 0, 190))
+ light("Glint", (1.6, -5.0, 6.0), 850.0, 0.9, (1.0, 0.9, 0.7), (40, 0, 18))
+
+ aim = bpy.data.objects.new("Aim", None)
+ aim.location = (0.0, 0.0, 1.15)
+ aim.hide_render = True
+ scene.collection.objects.link(aim)
+ cam_data = bpy.data.cameras.new("Cam")
+ cam_data.lens = 50.0
+ cam = bpy.data.objects.new("Cam", cam_data)
+ cam.location = (3.55, -6.59, 3.30)
+ scene.collection.objects.link(cam)
+ scene.camera = cam
+ track = cam.constraints.new("TRACK_TO")
+ track.target = aim
+ track.track_axis = "TRACK_NEGATIVE_Z"
+ track.up_axis = "UP_Y"
+
+ scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id()
+ if engine == "cycles":
+ scene.cycles.samples = 32
+ 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
+ scene.view_settings.view_transform = "Standard"
+
+ hero = [keep_plate, clear_plate, keep_ring, clear_ring] + list(stand)
+ if inlay is not None:
+ hero.append(inlay)
+ fcode = gallery_framing.check_framing(
+ scene, cam, hero=hero, elements=hero, stage=[floor, wall],
+ )
+ if fcode:
+ return fcode
+ bpy.ops.render.render(write_still=True)
+ if not (os.path.exists(path) and os.path.getsize(path) > 0):
+ print("ERROR: render produced no file", file=sys.stderr)
+ return 12
+ return 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"))
+ p.add_argument(
+ "--skip-delete", action="store_true",
+ help="falsification: leave the Clear plate tagged",
+ )
+ p.add_argument(
+ "--unset-instead", action="store_true",
+ help="falsification: call property_unset instead of del",
+ )
+ args = p.parse_args(argv)
+
+ keep_parts, clear_parts, stand = build()
+ code = check(
+ keep_parts[0], clear_parts[0],
+ skip_delete=args.skip_delete, unset_instead=args.unset_instead,
+ )
+ if code:
+ return code
+
+ if args.output:
+ rcode = render_still(
+ keep_parts, clear_parts, stand,
+ os.path.abspath(args.output), args.engine,
+ )
+ if rcode:
+ return rcode
+ print(f"rendered still {args.output}")
+
+ print("cross-version-property-delete 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/cross-version-property-delete/preview.webp b/examples/cross-version-property-delete/preview.webp
new file mode 100644
index 0000000..91899af
Binary files /dev/null and b/examples/cross-version-property-delete/preview.webp differ
diff --git a/examples/gallery.json b/examples/gallery.json
index bd2c145..0ccaf0a 100644
--- a/examples/gallery.json
+++ b/examples/gallery.json
@@ -570,6 +570,30 @@
"game-pipeline",
"render"
]
+ },
+ {
+ "name": "cross-version-property-delete",
+ "dir": "examples/cross-version-property-delete",
+ "teaches": "Custom ID properties are removed with del, not property_unset. The IDs are built through bpy.data.objects.new so the check does not depend on active_object.",
+ "witnessesFix": "Factory-empty has no active_object; property_unset raises TypeError and leaves the key; del removes it (clear_has=False). --skip-delete and --unset-instead exit 7.",
+ "hero": "docs/gallery/assets/cross-version-property-delete-hero.webp",
+ "preview": "examples/cross-version-property-delete/preview.webp",
+ "tags": [
+ "context",
+ "properties"
+ ]
+ },
+ {
+ "name": "usd-export-evaluation-mode",
+ "dir": "examples/usd-export-evaluation-mode",
+ "teaches": "The USD exporter evaluation_mode chooses viewport versus render modifier quality. TESSELLATE makes the split observable; BEST_MATCH writes the cage and the mode is silent.",
+ "witnessesFix": "TESSELLATE+VIEWPORT writes 26/24; TESSELLATE+RENDER writes 98/96 subdivisionScheme=none; BEST_MATCH writes the 8-vert catmullClark cage. --evaluation-mode VIEWPORT and --subdivision BEST_MATCH exit 4.",
+ "hero": "docs/gallery/assets/usd-export-evaluation-mode-hero.webp",
+ "preview": "examples/usd-export-evaluation-mode/preview.webp",
+ "tags": [
+ "depsgraph",
+ "export"
+ ]
}
]
}
diff --git a/examples/usd-export-evaluation-mode/README.md b/examples/usd-export-evaluation-mode/README.md
new file mode 100644
index 0000000..bb2ef91
--- /dev/null
+++ b/examples/usd-export-evaluation-mode/README.md
@@ -0,0 +1,43 @@
+# USD export evaluation_mode
+
+A SUBSURF cube exported through `wm.usd_export` that witnesses
+`evaluation_mode='RENDER'` against `'VIEWPORT'` — the contract
+[`usd-export-evaluation-mode.py`](../../snippets/usd-export-evaluation-mode.py)
+names but does not prove.
+
+Catmull-Clark on a cube is closed form: verts = `2 + 6 × 4^n` (n=1 → 26,
+n=2 → 98). TESSELLATE + VIEWPORT writes 26 points / 24 quads; TESSELLATE +
+RENDER writes 98 / 96. Default `export_subdivision='BEST_MATCH'` writes the
+8-vert cage plus `subdivisionScheme = catmullClark`, so evaluation_mode is
+silent — both files are the cage. TESSELLATE is what makes the mode
+observable.
+
+The still is numeric-adjacent: left is viewport tessellation (flat-shaded
+L1), right is render tessellation (smoothed L2). The USDA point counts are
+the evidence; the two balls depict the two qualities the exporter chooses
+between.
+
+**What failure each check would catch:**
+
+- exit 3 — VIEWPORT TESSELLATE did not write the L1 closed form
+- exit 4 — RENDER TESSELLATE wrote viewport quality or the BEST_MATCH cage
+ (`--evaluation-mode VIEWPORT` measured 26/24; `--subdivision BEST_MATCH`
+ measured 8/6 `catmullClark`)
+- exit 5 — BEST_MATCH stopped writing the cage
+- exit 6 — RENDER and VIEWPORT files are identical
+
+The still depicts viewport vs render tessellation of the same SUBSURF cube
+(flat-shaded L1 vs smoothed L2). The USDA point counts are the evidence.
+
+Probed on the CI Linux portables **Blender 5.2.1 LTS** (`9e2066aef7ef`) and
+**Blender 4.5.13 LTS** (`daeeeca98fb0`): `wm.usd_export` exists, `poll()` is
+true in `--background`, `evaluation_mode` is `{RENDER, VIEWPORT}`. The
+`--output` render path measures framing via `examples/gallery_framing.py`
+(exit 10 on violation).
+
+## Run
+
+```bash
+blender --background --python usd_export_evaluation_mode.py --
+blender --background --python usd_export_evaluation_mode.py -- --output u.png
+```
diff --git a/examples/usd-export-evaluation-mode/preview.webp b/examples/usd-export-evaluation-mode/preview.webp
new file mode 100644
index 0000000..44bae88
Binary files /dev/null and b/examples/usd-export-evaluation-mode/preview.webp differ
diff --git a/examples/usd-export-evaluation-mode/usd_export_evaluation_mode.py b/examples/usd-export-evaluation-mode/usd_export_evaluation_mode.py
new file mode 100644
index 0000000..6e37dbb
--- /dev/null
+++ b/examples/usd-export-evaluation-mode/usd_export_evaluation_mode.py
@@ -0,0 +1,345 @@
+"""USD evaluation_mode — a runnable example.
+
+Witnesses ``bpy.ops.wm.usd_export`` ``evaluation_mode='RENDER'`` against
+``VIEWPORT``, which the snippet names but does not prove. A SUBSURF cube
+with ``levels=1`` / ``render_levels=2`` is tessellated into USDA:
+
+* Catmull-Clark on a cube is closed form: verts = 2 + 6 × 4^n
+ (n=1 → 26, n=2 → 98). TESSELLATE + VIEWPORT must write 26 points;
+ TESSELLATE + RENDER must write 98.
+* Default ``export_subdivision='BEST_MATCH'`` writes the 8-vert cage plus
+ ``subdivisionScheme = catmullClark``. evaluation_mode is then silent —
+ both files are the cage. TESSELLATE is what makes the mode observable.
+
+By default it runs only the correctness check (no render) — the CI smoke
+check. Pass --output to also render a still:
+
+ blender --background --python usd_export_evaluation_mode.py --
+ blender --background --python usd_export_evaluation_mode.py -- --output u.png
+"""
+import bpy, bmesh, sys, os, math, argparse, tempfile
+
+sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir))
+sys.dont_write_bytecode = True
+import gallery_framing
+
+LEVEL_VP = 1
+LEVEL_RD = 2
+
+
+def cube_cc_verts(level):
+ """Catmull-Clark vertex count for a cube after `level` subdivisions."""
+ return 2 + 6 * (4 ** level)
+
+
+def cube_cc_faces(level):
+ return 6 * (4 ** level)
+
+
+def usda_array_body(text, needle):
+ """Return the contents of `needle = [ ... ]`, skipping the type's `[]`."""
+ key = needle + " = ["
+ i = text.find(key)
+ if i < 0:
+ return None
+ i += len(key)
+ depth = 1
+ j = i
+ while j < len(text) and depth:
+ if text[j] == "[":
+ depth += 1
+ elif text[j] == "]":
+ depth -= 1
+ j += 1
+ return text[i:j - 1]
+
+
+def usda_point_count(text):
+ body = usda_array_body(text, "point3f[] points")
+ if body is None:
+ return 0
+ return body.count("(")
+
+
+def usda_face_count(text):
+ body = usda_array_body(text, "int[] faceVertexCounts")
+ if body is None:
+ return 0
+ return body.count(",") + (1 if body.strip() else 0)
+
+
+def usda_scheme(text):
+ key = 'uniform token subdivisionScheme = "'
+ i = text.find(key)
+ if i < 0:
+ return None
+ i += len(key)
+ return text[i:text.find('"', i)]
+
+
+def build():
+ bpy.ops.wm.read_factory_settings(use_empty=True)
+ me = bpy.data.meshes.new("Ingot")
+ bm = bmesh.new()
+ try:
+ bmesh.ops.create_cube(bm, size=2.0)
+ bm.to_mesh(me)
+ finally:
+ bm.free()
+ obj = bpy.data.objects.new("Ingot", me)
+ bpy.context.collection.objects.link(obj)
+ mod = obj.modifiers.new("Subsurf", "SUBSURF")
+ mod.levels = LEVEL_VP
+ mod.render_levels = LEVEL_RD
+ return obj, mod
+
+
+def export_usda(path, evaluation_mode, export_subdivision):
+ if os.path.exists(path):
+ os.remove(path)
+ rna = bpy.ops.wm.usd_export.get_rna_type()
+ have = {p.identifier for p in rna.properties}
+ for key in ("filepath", "evaluation_mode", "export_subdivision"):
+ if key not in have:
+ raise RuntimeError(f"wm.usd_export missing RNA property {key}")
+ result = bpy.ops.wm.usd_export(
+ filepath=path,
+ evaluation_mode=evaluation_mode,
+ export_subdivision=export_subdivision,
+ selected_objects_only=False,
+ export_animation=False,
+ export_hair=False,
+ export_materials=False,
+ )
+ if result != {"FINISHED"}:
+ raise RuntimeError(f"usd_export {result} for {path}")
+ if not (os.path.exists(path) and os.path.getsize(path) > 0):
+ raise RuntimeError(f"no USDA written to {path}")
+ return open(path, encoding="utf-8").read()
+
+
+def check(obj, mod, out_dir, force_mode=None, force_subdiv=None):
+ expect_vp = cube_cc_verts(LEVEL_VP)
+ expect_rd = cube_cc_verts(LEVEL_RD)
+ expect_f_vp = cube_cc_faces(LEVEL_VP)
+ expect_f_rd = cube_cc_faces(LEVEL_RD)
+ base = len(obj.data.vertices)
+ if base != 8:
+ print(f"ERROR: base cube verts {base} != 8", file=sys.stderr)
+ return 2
+
+ mode = force_mode or "RENDER"
+ subdiv = force_subdiv or "TESSELLATE"
+ path_rd = os.path.join(out_dir, "render.usda")
+ path_vp = os.path.join(out_dir, "viewport.usda")
+ path_cage = os.path.join(out_dir, "cage.usda")
+
+ text_rd = export_usda(path_rd, mode, subdiv)
+ text_vp = export_usda(path_vp, "VIEWPORT", "TESSELLATE")
+ text_cage = export_usda(path_cage, "RENDER", "BEST_MATCH")
+
+ pts_rd = usda_point_count(text_rd)
+ pts_vp = usda_point_count(text_vp)
+ pts_cage = usda_point_count(text_cage)
+ faces_rd = usda_face_count(text_rd)
+ faces_vp = usda_face_count(text_vp)
+ scheme_rd = usda_scheme(text_rd)
+ scheme_cage = usda_scheme(text_cage)
+
+ print(
+ f"base={base} usda_vp={pts_vp}/{faces_vp} usda_rd={pts_rd}/{faces_rd} "
+ f"usda_cage={pts_cage} scheme_rd={scheme_rd} scheme_cage={scheme_cage} "
+ f"mode={mode} subdiv={subdiv}"
+ )
+ print(
+ f"closed_form vp_verts={expect_vp} rd_verts={expect_rd} "
+ f"vp_faces={expect_f_vp} rd_faces={expect_f_rd}"
+ )
+
+ if pts_vp != expect_vp or faces_vp != expect_f_vp:
+ print(
+ f"ERROR: VIEWPORT TESSELLATE wrote {pts_vp} points / {faces_vp} faces, "
+ f"expected {expect_vp}/{expect_f_vp}",
+ file=sys.stderr,
+ )
+ return 3
+ if pts_rd != expect_rd or faces_rd != expect_f_rd or scheme_rd != "none":
+ print(
+ f"ERROR: RENDER TESSELLATE wrote {pts_rd} points / {faces_rd} faces "
+ f"scheme={scheme_rd}, expected {expect_rd}/{expect_f_rd} scheme=none "
+ "(VIEWPORT quality or BEST_MATCH cage would not match)",
+ file=sys.stderr,
+ )
+ return 4
+ if pts_cage != base or scheme_cage != "catmullClark":
+ print(
+ f"ERROR: BEST_MATCH should write the cage ({base} points, catmullClark), "
+ f"got {pts_cage} scheme={scheme_cage}",
+ file=sys.stderr,
+ )
+ return 5
+ if pts_rd == pts_vp:
+ print("ERROR: RENDER and VIEWPORT USDA point counts are identical", file=sys.stderr)
+ return 6
+ 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):
+ 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
+ return mat
+
+
+def render_still(obj, path, engine):
+ """Left: viewport tessellation (levels=1). Right: render tessellation (levels=2)."""
+ scene = bpy.context.scene
+ steel = principled("Steel", (0.55, 0.57, 0.60, 1.0), 1.0, 0.58)
+
+ left = obj
+ left.location = (-1.55, 0.0, 1.0)
+ left.rotation_euler = (0.0, 0.0, math.radians(28))
+ left.data.materials.append(steel)
+ left.modifiers["Subsurf"].levels = LEVEL_VP
+ for poly in left.data.polygons:
+ poly.use_smooth = False
+
+ right_me = left.data.copy()
+ right = bpy.data.objects.new("IngotRender", right_me)
+ right.location = (1.55, 0.0, 1.0)
+ right.rotation_euler = (0.0, 0.0, math.radians(28))
+ right.data.materials.append(steel)
+ bpy.context.collection.objects.link(right)
+ rm = right.modifiers.new("Subsurf", "SUBSURF")
+ rm.levels = LEVEL_RD
+ rm.render_levels = LEVEL_RD
+ for poly in right.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()
+ floor_me.materials.append(principled("Studio", (0.03, 0.032, 0.037, 1.0), 0.0, 0.7))
+ floor = bpy.data.objects.new("Floor", floor_me)
+ scene.collection.objects.link(floor)
+ wall = bpy.data.objects.new("Wall", floor_me.copy())
+ wall.data.materials.clear()
+ wall.data.materials.append(principled("Wall", (0.03, 0.032, 0.037, 1.0), 0.0, 0.7))
+ 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
+ world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.02, 0.021, 0.025, 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, -5.0, 6.0), 200.0, 7.0, (1.0, 0.96, 0.9), (46, 0, -35))
+ light("Fill", (5.0, -3.5, 3.0), 180.0, 9.0, (0.75, 0.85, 1.0), (62, 0, 50))
+ light("Wedge", (2.5, 5.5, 4.0), 360.0, 6.0, (1.0, 0.76, 0.5), (-68, 0, 190))
+
+ aim = bpy.data.objects.new("Aim", None)
+ aim.location = (0.0, 0.0, 1.0)
+ aim.hide_render = True
+ scene.collection.objects.link(aim)
+ cam_data = bpy.data.cameras.new("Cam")
+ cam_data.lens = 50.0
+ cam = bpy.data.objects.new("Cam", cam_data)
+ cam.location = (4.16, -5.85, 3.00)
+ scene.collection.objects.link(cam)
+ scene.camera = cam
+ track = cam.constraints.new("TRACK_TO")
+ track.target = aim
+ track.track_axis = "TRACK_NEGATIVE_Z"
+ track.up_axis = "UP_Y"
+
+ scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id()
+ if engine == "cycles":
+ scene.cycles.samples = 32
+ 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
+ scene.view_settings.view_transform = "Standard"
+
+ fcode = gallery_framing.check_framing(
+ scene, cam, hero=[left, right], elements=[left, right], stage=[floor, wall],
+ )
+ if fcode:
+ return fcode
+ bpy.ops.render.render(write_still=True)
+ if not (os.path.exists(path) and os.path.getsize(path) > 0):
+ print("ERROR: render produced no file", file=sys.stderr)
+ return 12
+ return 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"))
+ p.add_argument(
+ "--evaluation-mode", default="RENDER", choices=("RENDER", "VIEWPORT"),
+ help="falsification: VIEWPORT fails the RENDER closed form",
+ )
+ p.add_argument(
+ "--subdivision", default="TESSELLATE",
+ choices=("TESSELLATE", "BEST_MATCH", "IGNORE"),
+ help="falsification: BEST_MATCH writes the cage",
+ )
+ args = p.parse_args(argv)
+
+ obj, mod = build()
+ out_dir = tempfile.mkdtemp(prefix="bdt_usd_")
+ code = check(
+ obj, mod, out_dir,
+ force_mode=args.evaluation_mode, force_subdiv=args.subdivision,
+ )
+ if code:
+ return code
+
+ if args.output:
+ rcode = render_still(obj, os.path.abspath(args.output), args.engine)
+ if rcode:
+ return rcode
+ print(f"rendered still {args.output}")
+
+ print("usd-export-evaluation-mode 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/tests/smoke/catalog.json b/tests/smoke/catalog.json
index 644de31..9ada032 100644
--- a/tests/smoke/catalog.json
+++ b/tests/smoke/catalog.json
@@ -53,5 +53,7 @@
{"name": "modular-kit-snap", "script": "examples/modular-kit-snap/modular_kit_snap.py"},
{"name": "lightmap-uv-channel", "script": "examples/lightmap-uv-channel/lightmap_uv_channel.py"},
{"name": "socket-attach-points", "script": "examples/socket-attach-points/socket_attach_points.py"},
- {"name": "vertex-color-ao", "script": "examples/vertex-color-ao/vertex_color_ao.py"}
+ {"name": "vertex-color-ao", "script": "examples/vertex-color-ao/vertex_color_ao.py"},
+ {"name": "cross-version-property-delete", "script": "examples/cross-version-property-delete/cross_version_property_delete.py"},
+ {"name": "usd-export-evaluation-mode", "script": "examples/usd-export-evaluation-mode/usd_export_evaluation_mode.py"}
]