diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index dd677e5..e292ffe 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -93,6 +93,7 @@ "examples/degenerate-bevel-weld", "examples/depsgraph-export", "examples/driver-wave", + "examples/eval-mesh-datablock-name", "examples/exit-pre-sidecar", "examples/export-preset-axis", "examples/gltf-export-roundtrip", @@ -109,6 +110,7 @@ "examples/light-link-studio", "examples/lightmap-uv-channel", "examples/lod-decimate-chain", + "examples/mesh-automasking-settings", "examples/mesh-hygiene-audit", "examples/modular-kit-snap", "examples/ngon-triangulate", diff --git a/AGENTS.md b/AGENTS.md index 5fbc6bb..9616f3e 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 16 skills, 9 rules, 3 templates, 27 snippets, and 57 +The content base is 16 skills, 9 rules, 3 templates, 27 snippets, and 59 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 # 9 rule files templates// # 3 starter templates snippets/.py # 27 standalone Python snippets - examples// # 55 runnable smoke-gated examples (+ gallery.json) + examples// # 59 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 4513d19..b02b56d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,7 @@ skills//SKILL.md - AI workflow definitions, 16 total rules/.mdc - Anti-pattern rules, 9 total templates// - Starter projects, 3 total snippets/.py - Standalone code patterns, 27 total -examples// - Runnable smoke-gated examples, 57 total (+ gallery.json) +examples// - Runnable smoke-gated examples, 59 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 @@ -99,7 +99,7 @@ v0.2.0: Principled BSDF material, driver-with-custom-function via `driver_namesp AI asset pipeline track: `decimate_to_budget.py`, `convex_hull_collider.py`, `lod_chain.py` (helper duplicated, not imported), `gltf_draco_export.py`, `export_preset_unity.py`, `export_preset_godot.py`, `export_preset_unreal.py`, `setup_bake_target_image.py`, `bake_normal_high_to_low.py`, `save_baked_image.py`. -## Examples (57) +## Examples (59) 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/CONTRIBUTING.md b/CONTRIBUTING.md index f2910c7..51230e2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -143,6 +143,21 @@ and the product scripts that self-skip: `examples/gn-bundle-roundtrip/`, `examples/exit-pre-sidecar/`). A script under test prints `SMOKE_SKIP:` and exits 77; the runner records SKIP and returns 0 so the YAML step stays green. +**Version-gated falsifiers.** A falsifier for a cross-version removal or +rename is version-gated by nature. It exits its documented code on versions +where the API changed and exits 0 on versions where the old API still works. +That is the correct witness: the naive script is still valid on the older +binaries. It differs from every other falsifier in the tree (for example +`--same-axis`), which is red on all three versions. + +Do not "fix" these flags to fail unconditionally. The examples that behave +this way: + +- `examples/vse-linear-modifiers/` (`--assume-present`) +- `examples/gn-socket-rename/` (`--legacy-ids`) +- `examples/eval-mesh-datablock-name/` (`--assume-distinct-names`) +- `examples/mesh-automasking-settings/` (`--assume-brush-attrs`) + ## Standards-version Markers Files that participate in ecosystem drift checking must carry a `standards-version` marker matching the current meta-repo `STANDARDS_VERSION` (which is decoupled from this repo's `VERSION`): diff --git a/README.md b/README.md index 153f3a0..d0b5246 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

- 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  57 examples + 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  59 examples

@@ -36,7 +36,7 @@ ## Overview -This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, and 57 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 **16 skills, 9 rules, 3 templates, 27 snippets, and 59 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 — 12 examples +Mesh, curves & text — 13 examples @@ -308,6 +308,20 @@ 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). + + + + + @@ -587,7 +601,7 @@ and every orbit lands on its closed form.
-Context & Grease Pencil — 4 examples +Context & Grease Pencil — 5 examples
+check-only, no gallery still — no geometry + + +### [eval-mesh-datablock-name](examples/eval-mesh-datablock-name/) + +`evaluated_get().data.name` is generic `Mesh` on 4.5.11 and 5.1.2 and equals +the source name on 5.2.1. `to_mesh().name` stays the source name on all +three. `--assume-distinct-names` is red only on 5.2. +
@@ -616,6 +630,21 @@ 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 f2165a5..31cdb9d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -142,8 +142,8 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - UV atlas **utilization** witness — coverage/wasted-texel closed forms for a packed lightmap atlas (the non-overlap, unit-square, margin, and active/active_render contracts shipped in `lightmap-uv-channel`; utilization is the remaining unbuilt slice of the old "UV atlas pack" candidate) - ~~VSE `use_linear_modifiers` removal~~ **SHIPPED** as `examples/vse-linear-modifiers/` — bool on 4.5.11 and 5.1.2, `AttributeError` on 5.2.1; `--assume-present` red only on 5.2; pathology, no gallery still - ~~GN Compare / Random Value socket identifier rename~~ **SHIPPED** as `examples/gn-socket-rename/` — `A_INT` / `Min_001` present on 4.5.11 and 5.1.2, absent on 5.2.1; enabled-name lookup wires all three; `--legacy-ids` red only on 5.2 -- GN evaluated `to_mesh().name` no longer equals the original mesh name (5.2 contract change) -- `MeshAutomaskingSettings` move: old `Brush` automasking attributes gone on 5.2 +- ~~Evaluated mesh datablock `evaluated_get().data.name` is generic `Mesh` on 4.5.11 and 5.1.2 and equals the source name on 5.2.1 (`to_mesh().name` stays the source name on all three)~~ **SHIPPED** as `examples/eval-mesh-datablock-name/` — `evaluated_get().data.name` is `Mesh` on 4.5.11 and 5.1.2, `SourceMesh` on 5.2.1; `to_mesh().name` stays `SourceMesh` on all three; `--assume-distinct-names` red only on 5.2; pathology, no gallery still +- ~~`MeshAutomaskingSettings` move: old `Brush` automasking attributes gone on 5.2~~ **SHIPPED** as `examples/mesh-automasking-settings/` — type absent on 4.5.11 and 5.1.2, present on 5.2.1; old `Brush` attrs gone; `--assume-brush-attrs` red only on 5.2; pathology, no gallery still - ~~GAMMA_CROSS blend-curve witness~~ **SHIPPED** as `examples/vse-gamma-cross/` — the cross blends in a gamma-0.5 space: `((1-t)·√A + t·√B)²` with `t = (frame − start)/duration`, never 1 inside the effect; mid-cross dips 0.115 below the sRGB lerp from crimson/teal (closed form (0.341, 0.349, 0.463) confirmed per frame); AgX-default sampling poisons the fit (0.146 red-channel error, `view_transform='Standard'` mandatory); deleting a consumed input orphans-and-deletes the effect — follow-up to `vse-cut-list` - Falsy `bpy_prop_collection` trap snippet: an empty collection is falsy, so `editor.strips or editor.sequences` silently falls through to the legacy accessor on an empty timeline — always branch on `hasattr`; likely generalizes across the API (found authoring `vse-cut-list`) - ~~Collision compound witness~~ **SHIPPED** as `examples/collision-hull-proxy/` — game-prop collision as a compound of convex pieces, each a `bmesh.ops.convex_hull` of a coarse `sec(π/n)`-inflated cage (containment 4.4e-08, watertight, positive signed volume, Euler 2, per-piece 255-face budget: body 70, caps 60×3, compound 250); a hull of the dense render mesh measures 380 faces — over budget — which is why pipelines hull cages; proud details cost cage rows, concave grooves are free; byte-identical on 4.5.11 and 5.1.2 diff --git a/examples/eval-mesh-datablock-name/README.md b/examples/eval-mesh-datablock-name/README.md new file mode 100644 index 0000000..a3063ec --- /dev/null +++ b/examples/eval-mesh-datablock-name/README.md @@ -0,0 +1,67 @@ +# Evaluated mesh datablock name + +Pathology witness for the 5.2 change in +`Object.evaluated_get(depsgraph).data.name`. A cube plus SUBSURF is +enough; there is no gallery still (same class as +[`vse-linear-modifiers`](../vse-linear-modifiers/), +[`ngon-triangulate`](../ngon-triangulate/)). + +Follows [`depsgraph-and-evaluated-data`](../../skills/depsgraph-and-evaluated-data/SKILL.md) +and the version-gated assertion shape of +[`gn-modifier-inputs`](../gn-modifier-inputs/) (per-version contract, exit +0 on every matrix leg). Scaffolding matches +[`cross-version-property-delete`](../cross-version-property-delete/) +(`check()` returns, argparse naive-API flag, FATAL wrapper). That example +does **not** version-branch, so the gate itself is copied from +`gn-modifier-inputs`, not from `del`. + +**What it witnesses:** with source mesh named `SourceMesh`, +`evaluated_get().data.name` is generic `Mesh` on 4.5.11 and 5.1.2, and +`SourceMesh` on 5.2.1. `to_mesh().name` is `SourceMesh` on all three — +that accessor is not the witness. SUBSURF `levels=1` on a cube is +load-bearing: 8 source verts vs 26 evaluated (Catmull-Clark). Name +comparison that treats inequality as "this is evaluated" is silently +wrong on 5.2; nothing raises. + +**What failure each check would catch:** + +- exit 3 — source cube was not 8 verts, or evaluated data is missing +- exit 4 — SUBSURF did not produce a distinct evaluated mesh (not 26 verts) +- exit 5 — source datablock name is not `SourceMesh` +- exit 6 — evaluated datablock name is wrong for this Blender + (`--assume-distinct-names` on 5.2 lands here: names match) +- exit 7 — `to_mesh().name` is not `SourceMesh` + +`--assume-distinct-names` is the falsifier: skip the version gate and +demand the 4.5/5.1 inequality. It exits **0 on 4.5.11 and 5.1.2** (the +names still differ) and **6 on 5.2.1**. That is unlike `--same-axis`, +which is red on every binary. + +No `SMOKE_SKIP`. Every matrix leg exercises the contract. + +## Run + +```bash +blender --background --python eval_mesh_datablock_name.py -- +blender --background --python eval_mesh_datablock_name.py -- --assume-distinct-names +``` + +## Exit codes + +Per-script sequential checks. `9` is a valid check code; there is no rule +against it. + +| Code | Meaning | +| --- | --- | +| 0 | Success | +| 1 | Uncaught exception (FATAL wrapper) | +| 2 | argparse / usage | +| 3 | Source cube missing or not 8 verts | +| 4 | SUBSURF did not produce a distinct evaluated mesh | +| 5 | Source datablock name is not `SourceMesh` | +| 6 | Evaluated datablock name wrong for this version (`--assume-distinct-names` on 5.2) | +| 7 | `to_mesh().name` is not `SourceMesh` | + +The `blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS +(5.1 on the weekly cron, the `needs-5.1` PR label, or manual dispatch). +Smoke does not pass `--assume-distinct-names`. diff --git a/examples/eval-mesh-datablock-name/eval_mesh_datablock_name.py b/examples/eval-mesh-datablock-name/eval_mesh_datablock_name.py new file mode 100644 index 0000000..f9b512e --- /dev/null +++ b/examples/eval-mesh-datablock-name/eval_mesh_datablock_name.py @@ -0,0 +1,172 @@ +"""Evaluated mesh datablock naming — a runnable example. + +Witnesses that ``Object.evaluated_get(depsgraph).data.name`` is a generic +``Mesh`` on 4.5 LTS and 5.1, and equals the source mesh name on 5.2. +``to_mesh().name`` equals the source name on all three — it is not the +witness. Code that treats name inequality as "this is evaluated geometry" +is silently wrong on 5.2: nothing raises. + +Pathology: no gallery still. A SUBSURF cube is enough to produce a +distinct evaluated datablock (8 source verts vs 26 Catmull-Clark). + +The default path asserts the names that are correct for *this* Blender +and still exits 0 on all three. ``--assume-distinct-names`` is the naive +script: fail if the names match. That is red only on 5.2. + + blender --background --python eval_mesh_datablock_name.py -- + blender --background --python eval_mesh_datablock_name.py -- --assume-distinct-names +""" +import argparse +import sys + +import bpy + +SOURCE_NAME = "SourceMesh" +GENERIC_EVAL_NAME = "Mesh" +SOURCE_VERTS = 8 +EVAL_VERTS = 26 # Catmull-Clark SUBSURF levels=1 on a cube +CHANGED_AT = (5, 2, 0) + + +def fail(msg, code): + print(f"ERROR: {msg}", file=sys.stderr) + return code + + +def cube_mesh(name): + me = bpy.data.meshes.new(name) + me.from_pydata( + [(-1, -1, -1), (1, -1, -1), (1, 1, -1), (-1, 1, -1), + (-1, -1, 1), (1, -1, 1), (1, 1, 1), (-1, 1, 1)], + [], + [(0, 1, 2, 3), (4, 5, 6, 7), (0, 1, 5, 4), + (2, 3, 7, 6), (0, 3, 7, 4), (1, 2, 6, 5)], + ) + me.update() + return me + + +def build(): + bpy.ops.wm.read_factory_settings(use_empty=True) + me = cube_mesh(SOURCE_NAME) + obj = bpy.data.objects.new("SourceObj", me) + bpy.context.collection.objects.link(obj) + mod = obj.modifiers.new("ss", "SUBSURF") + mod.levels = 1 + bpy.context.view_layer.update() + return obj + + +def check(obj, assume_distinct_names=False): + ver = tuple(bpy.app.version) + legacy_distinct = ver < CHANGED_AT + require_distinct = assume_distinct_names or legacy_distinct + print( + f"blender={ver} assume_distinct_names={assume_distinct_names} " + f"legacy_distinct={legacy_distinct} require_distinct={require_distinct}" + ) + + if obj is None or obj.data is None: + return fail("source object/mesh missing", 3) + + dg = bpy.context.evaluated_depsgraph_get() + ev = obj.evaluated_get(dg) + if ev.data is None: + return fail("evaluated_get().data is None", 3) + + src_name = obj.data.name + eval_name = ev.data.name + src_verts = len(obj.data.vertices) + eval_verts = len(ev.data.vertices) + tm = ev.to_mesh() + try: + tm_name = tm.name + tm_verts = len(tm.vertices) + finally: + ev.to_mesh_clear() + + print( + f"src_name={src_name!r} eval_data_name={eval_name!r} " + f"to_mesh_name={tm_name!r}" + ) + print( + f"src_verts={src_verts} eval_verts={eval_verts} " + f"to_mesh_verts={tm_verts} eval_is_src={ev.data is obj.data}" + ) + + if src_verts != SOURCE_VERTS: + return fail( + f"source verts {src_verts} != {SOURCE_VERTS} (cube build)", + 3, + ) + if eval_verts != EVAL_VERTS or ev.data is obj.data: + return fail( + f"SUBSURF did not produce a distinct evaluated mesh " + f"(eval_verts={eval_verts}, same_datablock={ev.data is obj.data})", + 4, + ) + if src_name != SOURCE_NAME: + return fail(f"source name {src_name!r} != {SOURCE_NAME!r}", 5) + + if require_distinct: + if eval_name == src_name: + return fail( + f"evaluated datablock name {eval_name!r} equals source " + f"{src_name!r} — naive name-inequality distinguisher is wrong", + 6, + ) + if eval_name != GENERIC_EVAL_NAME: + return fail( + f"expected generic {GENERIC_EVAL_NAME!r} on {ver}, " + f"got {eval_name!r}", + 6, + ) + else: + if eval_name != src_name: + return fail( + f"5.2+ evaluated datablock name {eval_name!r} != " + f"source {src_name!r}", + 6, + ) + if eval_name != SOURCE_NAME: + return fail( + f"expected source name {SOURCE_NAME!r} on {ver}, " + f"got {eval_name!r}", + 6, + ) + + if tm_name != SOURCE_NAME: + return fail( + f"to_mesh().name {tm_name!r} != {SOURCE_NAME!r} " + "(this accessor is not the witness; it must stay the source)", + 7, + ) + return 0 + + +def main(): + argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] + p = argparse.ArgumentParser() + p.add_argument( + "--assume-distinct-names", + action="store_true", + help="falsification: treat name inequality as the evaluated-mesh test", + ) + args = p.parse_args(argv) + + obj = build() + code = check(obj, assume_distinct_names=args.assume_distinct_names) + if code: + return code + print("eval-mesh-datablock-name 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/mesh-automasking-settings/README.md b/examples/mesh-automasking-settings/README.md new file mode 100644 index 0000000..d804865 --- /dev/null +++ b/examples/mesh-automasking-settings/README.md @@ -0,0 +1,74 @@ +# Mesh automasking settings move + +Pathology witness for the 5.2 move of sculpt automasking RNA off `Brush` +into `MeshAutomaskingSettings`. There is no geometry and **no gallery +still** (same class as [`vse-linear-modifiers`](../vse-linear-modifiers/), +[`ngon-triangulate`](../ngon-triangulate/)). + +Follows the version-gated assertion shape of +[`gn-modifier-inputs`](../gn-modifier-inputs/) (per-version contract, exit +0 on every matrix leg). Scaffolding matches +[`cross-version-property-delete`](../cross-version-property-delete/) +(`check()` returns, argparse naive-API flag, FATAL wrapper). That example +does **not** version-branch, so the gate itself is copied from +`gn-modifier-inputs`, not from `del`. + +**Headless:** factory-empty has **zero** brushes. The script creates one +with `bpy.data.brushes.new("ProbeBrush", mode="SCULPT")`. `mode="SCULPT"` +is load-bearing on 5.2: a default-mode `new(name)` leaves +`mesh_automasking_settings is None`. + +**Subset** (not all 18+ attributes): `use_automasking_topology` and +`use_automasking_cavity` keep their identifiers after the move; cavity +factor does not (`Brush.automasking_cavity_factor` vs +`MeshAutomaskingSettings.cavity_factor`). That pair covers the location +move and the nested identifier shortening. + +**What it witnesses:** `bpy.types.MeshAutomaskingSettings` is absent on +4.5.11 and 5.1.2 and present on 5.2.1. Old `Brush.use_automasking_*` / +`Brush.automasking_*` getattr works on 4.5/5.1 and is `AttributeError` on +5.2. Reading `.mesh_automasking_settings` (with a Brush fallback) works +on all three. + +**What failure each check would catch:** + +- exit 3 — SCULPT brush never landed +- exit 4 — `MeshAutomaskingSettings` type presence wrong for this Blender +- exit 5 — old Brush attributes missing when required + (`--assume-brush-attrs` on 5.2 lands here) +- exit 6 — old Brush attributes still present, or mas pointer is None, on 5.2+ +- exit 7 — current-location read raised or returned the wrong types + +`--assume-brush-attrs` is the falsifier: skip the version gate and demand +the 4.5 Brush RNA. It exits **0 on 4.5.11 and 5.1.2** (the old API still +works) and **5 on 5.2.1**. That is unlike `--same-axis`, which is red on +every binary. + +No `SMOKE_SKIP`. Every matrix leg exercises the contract. + +## Run + +```bash +blender --background --python mesh_automasking_settings.py -- +blender --background --python mesh_automasking_settings.py -- --assume-brush-attrs +``` + +## Exit codes + +Per-script sequential checks. `9` is a valid check code; there is no rule +against it. + +| Code | Meaning | +| --- | --- | +| 0 | Success | +| 1 | Uncaught exception (FATAL wrapper) | +| 2 | argparse / usage | +| 3 | SCULPT brush was not created | +| 4 | `MeshAutomaskingSettings` type presence wrong for this version | +| 5 | Old Brush automasking attributes missing when required (`--assume-brush-attrs` on 5.2) | +| 6 | Old Brush attributes still present, or mas is None, on 5.2+ | +| 7 | Current-location read raised or returned the wrong types | + +The `blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS +(5.1 on the weekly cron, the `needs-5.1` PR label, or manual dispatch). +Smoke does not pass `--assume-brush-attrs`. diff --git a/examples/mesh-automasking-settings/mesh_automasking_settings.py b/examples/mesh-automasking-settings/mesh_automasking_settings.py new file mode 100644 index 0000000..6d8e49b --- /dev/null +++ b/examples/mesh-automasking-settings/mesh_automasking_settings.py @@ -0,0 +1,193 @@ +"""MeshAutomaskingSettings move — a runnable example. + +Witnesses the 5.2 move of sculpt automasking RNA off ``Brush`` into +``MeshAutomaskingSettings``, reached via ``.mesh_automasking_settings``. +The old ``Brush.use_automasking_*`` / ``Brush.automasking_*`` attributes +are a bool/float you can read on 4.5 LTS and 5.1; the same getattr raises +AttributeError on 5.2. Version-guarded code (current location, with a +Brush fallback) reads on all three. + +Pathology: no geometry, no gallery still. Factory-empty has zero brushes; +the script creates one with ``bpy.data.brushes.new(..., mode="SCULPT")``. +``mode="SCULPT"`` is load-bearing on 5.2: a default-mode ``new(name)`` +leaves ``mesh_automasking_settings is None``. + +Subset (location move + nested identifier shortening, not all 18+): + +- ``use_automasking_topology`` — bool, same identifier in both places +- ``use_automasking_cavity`` — bool, same identifier in both places +- cavity factor — ``Brush.automasking_cavity_factor`` vs + ``MeshAutomaskingSettings.cavity_factor`` + + blender --background --python mesh_automasking_settings.py -- + blender --background --python mesh_automasking_settings.py -- --assume-brush-attrs +""" +import argparse +import sys + +import bpy + +MOVED_AT = (5, 2, 0) +BRUSH_NAME = "ProbeBrush" + + +def fail(msg, code): + print(f"ERROR: {msg}", file=sys.stderr) + return code + + +def type_present(): + return hasattr(bpy.types, "MeshAutomaskingSettings") + + +def read_current(brush): + """Version-safe reader. None mas falls through to the Brush attrs.""" + mas = getattr(brush, "mesh_automasking_settings", None) + if mas is not None: + return ( + mas.use_automasking_topology, + mas.use_automasking_cavity, + mas.cavity_factor, + ) + return ( + brush.use_automasking_topology, + brush.use_automasking_cavity, + brush.automasking_cavity_factor, + ) + + +def build(): + bpy.ops.wm.read_factory_settings(use_empty=True) + n_before = len(bpy.data.brushes) + brush = bpy.data.brushes.new(BRUSH_NAME, mode="SCULPT") + return brush, n_before + + +def check(brush, n_before, assume_brush_attrs=False): + ver = tuple(bpy.app.version) + legacy = ver < MOVED_AT + require_brush_attrs = assume_brush_attrs or legacy + print( + f"blender={ver} assume_brush_attrs={assume_brush_attrs} " + f"legacy={legacy} require_brush_attrs={require_brush_attrs} " + f"n_brushes_before={n_before} created={BRUSH_NAME!r}" + ) + + if brush is None or brush.bl_rna.identifier != "Brush": + return fail("SCULPT brush was not created", 3) + + present = type_present() + print(f"MeshAutomaskingSettings_type={present}") + if legacy and present: + return fail( + f"MeshAutomaskingSettings exists on {ver}; it must not before 5.2", + 4, + ) + if not legacy and not present: + return fail( + f"MeshAutomaskingSettings missing on {ver}; 5.2+ must define it", + 4, + ) + + has_topo = hasattr(brush, "use_automasking_topology") + has_cavity = hasattr(brush, "use_automasking_cavity") + has_factor = hasattr(brush, "automasking_cavity_factor") + mas = getattr(brush, "mesh_automasking_settings", None) + print( + f"brush.use_automasking_topology={has_topo} " + f"use_automasking_cavity={has_cavity} " + f"automasking_cavity_factor={has_factor} " + f"mesh_automasking_settings={mas!r}" + ) + + if require_brush_attrs: + if not (has_topo and has_cavity and has_factor): + return fail( + "old Brush automasking attributes missing when required " + f"(topo={has_topo} cavity={has_cavity} factor={has_factor})", + 5, + ) + try: + topo = brush.use_automasking_topology + cavity = brush.use_automasking_cavity + factor = brush.automasking_cavity_factor + except AttributeError as exc: + return fail(f"old Brush getattr raised on {ver}: {exc}", 5) + print( + f"legacy_read topology={topo!r} cavity={cavity!r} " + f"automasking_cavity_factor={factor!r}" + ) + if type(topo) is not bool or type(cavity) is not bool: + return fail( + f"legacy topology/cavity not bool: {type(topo).__name__}/" + f"{type(cavity).__name__}", + 5, + ) + if type(factor) is not float: + return fail( + f"legacy automasking_cavity_factor is " + f"{type(factor).__name__}={factor!r}, not float", + 5, + ) + else: + if has_topo or has_cavity or has_factor: + return fail( + "old Brush automasking attributes still present on 5.2+", + 6, + ) + if mas is None: + return fail( + "mesh_automasking_settings is None on 5.2+ " + "(create with mode='SCULPT')", + 6, + ) + + try: + topo, cavity, factor = read_current(brush) + except AttributeError as exc: + return fail(f"current-location read raised: {exc}", 7) + print( + f"current_read topology={topo!r} cavity={cavity!r} " + f"cavity_factor={factor!r}" + ) + if type(topo) is not bool or type(cavity) is not bool: + return fail( + f"current topology/cavity not bool: {type(topo).__name__}/" + f"{type(cavity).__name__}", + 7, + ) + if type(factor) is not float: + return fail( + f"current cavity_factor is {type(factor).__name__}={factor!r}, " + "not float", + 7, + ) + return 0 + + +def main(): + argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] + p = argparse.ArgumentParser() + p.add_argument( + "--assume-brush-attrs", + action="store_true", + help="falsification: read automasking off Brush as if it never moved", + ) + args = p.parse_args(argv) + + brush, n_before = build() + code = check(brush, n_before, assume_brush_attrs=args.assume_brush_attrs) + if code: + return code + print("mesh-automasking-settings 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 98e42b7..c0cde53 100644 --- a/tests/smoke/catalog.json +++ b/tests/smoke/catalog.json @@ -71,5 +71,7 @@ {"name": "coincident-vert-weld", "script": "examples/coincident-vert-weld/coincident_vert_weld.py"}, {"name": "bake-normal-high-to-low", "script": "examples/bake-normal-high-to-low/bake_normal_high_to_low.py"}, {"name": "vse-linear-modifiers", "script": "examples/vse-linear-modifiers/vse_linear_modifiers.py"}, - {"name": "gn-socket-rename", "script": "examples/gn-socket-rename/gn_socket_rename.py"} + {"name": "gn-socket-rename", "script": "examples/gn-socket-rename/gn_socket_rename.py"}, + {"name": "eval-mesh-datablock-name", "script": "examples/eval-mesh-datablock-name/eval_mesh_datablock_name.py"}, + {"name": "mesh-automasking-settings", "script": "examples/mesh-automasking-settings/mesh_automasking_settings.py"} ]
+check-only, no gallery still — no geometry + + +### [mesh-automasking-settings](examples/mesh-automasking-settings/) + +`MeshAutomaskingSettings` is absent on 4.5.11 and 5.1.2, present on 5.2.1. +Old `Brush` automasking attributes are gone on 5.2; read +`.mesh_automasking_settings` instead. `--assume-brush-attrs` is red only +on 5.2. +