diff --git a/.github/workflows/blender-smoke.yml b/.github/workflows/blender-smoke.yml index 52a1b16..1d74e60 100644 --- a/.github/workflows/blender-smoke.yml +++ b/.github/workflows/blender-smoke.yml @@ -130,3 +130,19 @@ jobs: --python examples/swatch-grid/swatch_grid.py -- \ --output "$RUNNER_TEMP/out/swatch.png" --engine cycles --samples 8 --width 640 test -s "$RUNNER_TEMP/out/swatch.png" || { echo "::error::swatch grid output missing/empty"; exit 1; } + + - name: Shipped example - turntable correctness (slotted actions) + run: | + set -euo pipefail + # Frame-independent check only (no render): asserts the rotation keys drive + # playback via the cross-version channelbag path; exits non-zero on failure. + xvfb-run -a "$BLENDER" --background \ + --python examples/turntable/turntable.py -- + + - name: Shipped example - GN SDF remesh correctness (GridToMesh) + run: | + set -euo pipefail + # Frame-independent check only (no render): asserts the SDF->GridToMesh remesh + # yields geometry (eval vcount > 0 and != base); exits non-zero on failure. + xvfb-run -a "$BLENDER" --background \ + --python examples/gn-sdf-remesh/gn_sdf_remesh.py -- diff --git a/ROADMAP.md b/ROADMAP.md index ce095ba..b6eec2e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -9,7 +9,8 @@ | v0.1.0 | Foundation | 8 | 4 | 1 | 10 | Shipped | | v0.2.0 | Materials, drivers, migration | 12 | 6 | 2 | 17 | Shipped | | v0.3.0 | Examples and demos (smoke-gated) | 12 | 6 | 2 | 17 | Shipped | -| v0.4.0 | 5.2 LTS sweep, modal operators, USD | TBD | TBD | TBD | TBD | Planned | +| v0.4.0 | More examples (turntable, SDF remesh) | 12 | 6 | 2 | 17 | Shipped | +| v0.5.0 | 5.2 LTS sweep, modal operators, USD | TBD | TBD | TBD | TBD | Planned | | v1.0.0 | Stable | TBD | TBD | TBD | TBD | Planned | ## v0.1.0 - Foundation @@ -79,7 +80,7 @@ The 7 new snippets: Audit pass on v0.1.0 content: standards-version markers bumped from `1.9.1` to `1.9.4` across all skills, rules, AGENTS.md, CLAUDE.md, and ROADMAP.md. Verified the `bpy_extras.anim_utils.action_ensure_channelbag_for_slot` import path against the current Blender 5.1 API reference and removed the stale "verify before production" caveat in `slotted-actions-animation/SKILL.md`. -## v0.4.0 (candidate pool) +## v0.5.0 (candidate pool) Not committed; target list for the next content version. (v0.3.0 shipped the smoke-gated `examples/` track.) diff --git a/examples/gn-sdf-remesh/README.md b/examples/gn-sdf-remesh/README.md new file mode 100644 index 0000000..1c31377 --- /dev/null +++ b/examples/gn-sdf-remesh/README.md @@ -0,0 +1,28 @@ +# Geometry Nodes SDF Remesh + +A runnable example that remeshes an input mesh through an OpenVDB SDF grid using the +`build_remesh_via_sdf` pattern from the +[`geometry-nodes-python`](../../skills/geometry-nodes-python/SKILL.md) skill: +`GeometryNodeMeshToSDFGrid` → `GeometryNodeGridToMesh` at the SDF zero-level, attached as a +NODES modifier and evaluated via the depsgraph. + +**Which fix it witnesses:** an SDF grid is meshed with **Grid to Mesh**, not Volume to Mesh +(the `Mesh to SDF Grid` output is a grid socket; `Volume to Mesh` takes a volume-geometry +socket, so wiring the grid there is an invalid link that yields no geometry). `Grid to Mesh` +has the matching grid input. + +## Run + +```bash +# Cheap correctness check only (no render) — the CI smoke check: +blender --background --python gn_sdf_remesh.py -- + +# Also render the remeshed result (EEVEE on a GPU host; --engine cycles on GPU-less hosts): +blender --background --python gn_sdf_remesh.py -- --output remesh.png +blender --background --python gn_sdf_remesh.py -- --output remesh.png --engine cycles +``` + +By default it runs only the **frame-independent correctness check**: the depsgraph-evaluated +vertex count must be > 0 AND differ from the base mesh (the remesh produced geometry). It +exits non-zero on failure — the same check the `blender-smoke` workflow runs on Blender 4.5 +LTS and 5.1. diff --git a/examples/gn-sdf-remesh/gn_sdf_remesh.py b/examples/gn-sdf-remesh/gn_sdf_remesh.py new file mode 100644 index 0000000..2769095 --- /dev/null +++ b/examples/gn-sdf-remesh/gn_sdf_remesh.py @@ -0,0 +1,112 @@ +"""Geometry Nodes SDF remesh -- a runnable BDT example. + +Builds the `build_remesh_via_sdf` pattern from the geometry-nodes-python skill +(`GeometryNodeMeshToSDFGrid` -> `GeometryNodeGridToMesh` at the SDF zero-level), attaches it +as a NODES modifier to an input mesh, and evaluates via the depsgraph. It witnesses the F2 +fix: an SDF grid is meshed with **Grid to Mesh**, not Volume to Mesh. + +By default it runs only the cheap, frame-independent correctness check (no render): the +evaluated vertex count must be > 0 AND differ from the base mesh -- proving the remesh +produced geometry. Exits non-zero on failure. This is the check the CI smoke gate runs on +both builds. + + blender --background --python gn_sdf_remesh.py -- # correctness check only + blender --background --python gn_sdf_remesh.py -- --output r.png # also render the result + blender --background --python gn_sdf_remesh.py -- --output r.png --engine cycles # GPU-less +""" +import bpy, sys, os, argparse + +def get_eevee_engine_id(): + return 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT' + +def build_remesh_via_sdf(voxel_size=0.1, threshold=0.0): + tree = bpy.data.node_groups.new("SDFRemesh", 'GeometryNodeTree') + tree.interface.new_socket(name="Geometry", in_out='INPUT', socket_type='NodeSocketGeometry') + tree.interface.new_socket(name="Geometry", in_out='OUTPUT', socket_type='NodeSocketGeometry') + gi = tree.nodes.new('NodeGroupInput'); go = tree.nodes.new('NodeGroupOutput') + mesh_to_sdf = tree.nodes.new('GeometryNodeMeshToSDFGrid') + grid_to_mesh = tree.nodes.new('GeometryNodeGridToMesh') + mesh_to_sdf.inputs["Voxel Size"].default_value = voxel_size + grid_to_mesh.inputs["Threshold"].default_value = threshold + tree.links.new(gi.outputs["Geometry"], mesh_to_sdf.inputs["Mesh"]) + link = tree.links.new(mesh_to_sdf.outputs["SDF Grid"], grid_to_mesh.inputs["Grid"]) + tree.links.new(grid_to_mesh.outputs["Mesh"], go.inputs["Geometry"]) + return tree, link.is_valid + +def build(): + bpy.ops.wm.read_factory_settings(use_empty=True) + bpy.ops.mesh.primitive_torus_add(location=(0, 0, 1.0), major_radius=1.2, minor_radius=0.5) + obj = bpy.context.active_object + for p in obj.data.polygons: + p.use_smooth = True + mat = bpy.data.materials.new("Clay"); mat.use_nodes = True + b = mat.node_tree.nodes.get('Principled BSDF') + b.inputs['Base Color'].default_value = (0.45, 0.55, 0.85, 1) + b.inputs['Roughness'].default_value = 0.45 + obj.data.materials.append(mat) + return obj + +def render_still(obj, path, engine): + import bmesh + sc = bpy.context.scene + fme = bpy.data.meshes.new("Floor"); bm = bmesh.new() + bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=30.0); bm.to_mesh(fme); bm.free() + floor = bpy.data.objects.new("Floor", fme); bpy.context.collection.objects.link(floor) + w = bpy.data.worlds.new("W"); w.use_nodes = True + w.node_tree.nodes["Background"].inputs[0].default_value = (0.05, 0.06, 0.08, 1); sc.world = w + aim = bpy.data.objects.new("Aim", None); aim.location = (0, 0, 1.0); bpy.context.collection.objects.link(aim) + cam = bpy.data.objects.new("cam", bpy.data.cameras.new("cam")); cam.location = (0, -6.5, 3.0) + bpy.context.collection.objects.link(cam); sc.camera = cam + c = cam.constraints.new('TRACK_TO'); c.target = aim; c.track_axis = 'TRACK_NEGATIVE_Z'; c.up_axis = 'UP_Y' + for nm, loc, en in [("K", (-4, -5, 7), 900), ("F2", (5, -4, 2), 350)]: + ld = bpy.data.lights.new(nm, 'AREA'); ld.energy = en; ld.size = 5.0 + lo = bpy.data.objects.new(nm, ld); lo.location = loc; bpy.context.collection.objects.link(lo) + lc = lo.constraints.new('TRACK_TO'); lc.target = aim; lc.track_axis = 'TRACK_NEGATIVE_Z'; lc.up_axis = 'UP_Y' + sc.render.engine = 'CYCLES' if engine == 'cycles' else get_eevee_engine_id() + if sc.render.engine == 'CYCLES': + try: sc.cycles.samples = 16 + except Exception: pass + else: + try: sc.eevee.taa_render_samples = 16 + except Exception: pass + sc.render.resolution_x = 1280; sc.render.resolution_y = 720 + sc.render.image_settings.file_format = 'PNG'; sc.render.filepath = path + bpy.ops.render.render(write_still=True) + return os.path.exists(path) and os.path.getsize(path) > 0 + +def main(): + argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] + p = argparse.ArgumentParser() + p.add_argument("--output", default=None, help="optional: render the remeshed result to this PNG") + p.add_argument("--engine", choices=["auto", "cycles"], default="auto") + args = p.parse_args(argv) + + eid = get_eevee_engine_id() + expected = 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT' + bpy.context.scene.render.engine = eid + if bpy.context.scene.render.engine != expected: + print(f"ERROR: EEVEE id {eid} != expected {expected}", file=sys.stderr); return 5 + + obj = build() + base = len(obj.data.vertices) + tree, link_valid = build_remesh_via_sdf() + obj.modifiers.new("sdf", 'NODES').node_group = tree + dg = bpy.context.evaluated_depsgraph_get(); ev = obj.evaluated_get(dg) + m = ev.to_mesh(); evc = len(m.vertices); ev.to_mesh_clear() + print(f"link_valid={link_valid} base_vcount={base} eval_vcount={evc}") + if not (link_valid and evc > 0 and evc != base): + print("ERROR: SDF remesh produced no/unchanged geometry", file=sys.stderr); return 3 + + if args.output: + if not render_still(obj, args.output, args.engine): + print("ERROR: render produced no file", file=sys.stderr); return 4 + print(f"rendered {args.output} ({os.path.getsize(args.output)} bytes)") + print("gn-sdf-remesh OK") + return 0 + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception as exc: + import traceback; traceback.print_exc() + print(f"FATAL: {type(exc).__name__}: {exc}", file=sys.stderr); sys.exit(1) diff --git a/examples/turntable/README.md b/examples/turntable/README.md new file mode 100644 index 0000000..b9527ca --- /dev/null +++ b/examples/turntable/README.md @@ -0,0 +1,28 @@ +# Turntable (Slotted Actions) + +A runnable example that keyframes a Z-rotation turntable through the **slotted-actions** +cross-version channelbag path from the +[`slotted-actions-animation`](../../skills/slotted-actions-animation/SKILL.md) skill, and +picks the render engine with the version-branch EEVEE-id helper. + +**Which fix it witnesses:** the slotted-actions cross-version helper. On Blender 5.x the +channelbag comes from `action_ensure_channelbag_for_slot`; on 4.4/4.5 from +`strip.channelbag(slot, ensure=True)` (legacy `action.fcurves` still works on 4.5, raises +`AttributeError` on 5.x). + +## Run + +```bash +# Cheap correctness check only (no render) — the CI smoke check: +blender --background --python turntable.py -- + +# Also render one still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts): +blender --background --python turntable.py -- --output turntable.png +blender --background --python turntable.py -- --output turntable.png --engine cycles +``` + +By default it runs only the **frame-independent correctness check**: it inserts the rotation +keys, samples the object's Z rotation at frame 1 vs a later frame, and asserts they **differ** +(the keys drive playback). It exits non-zero on failure — the same check the `blender-smoke` +workflow runs on Blender 4.5 LTS and 5.1. `--output` additionally renders a still; the full +animated loop is a showcase extra, not part of the CI check. diff --git a/examples/turntable/turntable.py b/examples/turntable/turntable.py new file mode 100644 index 0000000..7924dd7 --- /dev/null +++ b/examples/turntable/turntable.py @@ -0,0 +1,129 @@ +"""Slotted-actions turntable -- a runnable BDT example. + +Keyframes a Z-rotation turntable through the slotted-actions cross-version channelbag path +(`get_channelbag_for_slot`) and selects the engine with the version-branch EEVEE-id helper. +It witnesses the slotted-actions fix: on Blender 5.x the channelbag comes from +`action_ensure_channelbag_for_slot`; on 4.4/4.5 from `strip.channelbag(slot, ensure=True)`. + +By default it runs only the cheap, frame-independent correctness check (no render): insert +the rotation keys, sample the object's Z rotation at frame 1 vs a later frame, and assert +they DIFFER -- proving the keys drive playback. Exits non-zero on failure. This is the check +the CI smoke gate runs on both builds. + + blender --background --python turntable.py -- # correctness check only + blender --background --python turntable.py -- --output t.png # also render one still + blender --background --python turntable.py -- --output t.png --engine cycles # GPU-less +""" +import bpy, sys, os, math, argparse + +FRAMES = 36 + +def get_eevee_engine_id(): + return 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT' + +def get_channelbag_for_slot(action, slot): + if bpy.app.version >= (5, 0, 0): + from bpy_extras.anim_utils import action_ensure_channelbag_for_slot + return action_ensure_channelbag_for_slot(action, slot) + layer = action.layers[0] if action.layers else action.layers.new("Layer") + strip = layer.strips[0] if layer.strips else layer.strips.new(type='KEYFRAME') + return strip.channelbag(slot, ensure=True) + +def build(): + bpy.ops.wm.read_factory_settings(use_empty=True) + bpy.ops.mesh.primitive_monkey_add(location=(0, 0, 1.0)) + obj = bpy.context.active_object + for p in obj.data.polygons: + p.use_smooth = True + mat = bpy.data.materials.new("M"); mat.use_nodes = True + b = mat.node_tree.nodes.get('Principled BSDF') + b.inputs['Base Color'].default_value = (0.85, 0.35, 0.10, 1) + b.inputs['Metallic'].default_value = 0.7 + b.inputs['Roughness'].default_value = 0.25 + obj.data.materials.append(mat) + # rotation keyframes via the slotted-actions channelbag path + obj.animation_data_create() + act = bpy.data.actions.new("Turn"); obj.animation_data.action = act + slot = obj.animation_data.action_slot + if slot is None: + slot = act.slots.new(id_type='OBJECT', name=obj.name); obj.animation_data.action_slot = slot + cbag = get_channelbag_for_slot(act, slot) + fc = cbag.fcurves.new("rotation_euler", index=2) + fc.keyframe_points.insert(1, 0.0) + fc.keyframe_points.insert(FRAMES, math.radians(360)) + for kp in fc.keyframe_points: + kp.interpolation = 'LINEAR' + fc.update() + return obj + +def correctness(obj): + sc = bpy.context.scene; sc.frame_start = 1; sc.frame_end = FRAMES + def rz(f): + sc.frame_set(f); dg = bpy.context.evaluated_depsgraph_get() + return round(obj.evaluated_get(dg).rotation_euler.z, 4) + r1, rmid, rend = rz(1), rz(FRAMES // 2), rz(FRAMES) + branch = '5.0+ ensure-helper' if bpy.app.version >= (5, 0, 0) else '4.4/4.5 strip.channelbag' + drives = (r1 != rmid != rend) and abs(rend - r1) > 0.5 + print(f"branch={branch} rot_z f1={r1} fmid={rmid} fend={rend} drives={drives}") + return drives + +def render_still(obj, path, engine): + import bmesh + sc = bpy.context.scene + fme = bpy.data.meshes.new("Floor"); bm = bmesh.new() + bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=30.0); bm.to_mesh(fme); bm.free() + floor = bpy.data.objects.new("Floor", fme); bpy.context.collection.objects.link(floor) + w = bpy.data.worlds.new("W"); w.use_nodes = True + w.node_tree.nodes["Background"].inputs[0].default_value = (0.04, 0.05, 0.07, 1); sc.world = w + aim = bpy.data.objects.new("Aim", None); aim.location = (0, 0, 1.0); bpy.context.collection.objects.link(aim) + cam = bpy.data.objects.new("cam", bpy.data.cameras.new("cam")); cam.location = (0, -7, 3.0) + bpy.context.collection.objects.link(cam); sc.camera = cam + c = cam.constraints.new('TRACK_TO'); c.target = aim; c.track_axis = 'TRACK_NEGATIVE_Z'; c.up_axis = 'UP_Y' + for nm, loc, en in [("K", (-4, -5, 7), 900), ("F2", (5, -4, 2), 350)]: + ld = bpy.data.lights.new(nm, 'AREA'); ld.energy = en; ld.size = 5.0 + lo = bpy.data.objects.new(nm, ld); lo.location = loc; bpy.context.collection.objects.link(lo) + lc = lo.constraints.new('TRACK_TO'); lc.target = aim; lc.track_axis = 'TRACK_NEGATIVE_Z'; lc.up_axis = 'UP_Y' + sc.render.engine = 'CYCLES' if engine == 'cycles' else get_eevee_engine_id() + if sc.render.engine == 'CYCLES': + try: sc.cycles.samples = 16 + except Exception: pass + else: + try: sc.eevee.taa_render_samples = 16 + except Exception: pass + sc.frame_set(FRAMES // 4) + sc.render.resolution_x = 1280; sc.render.resolution_y = 720 + sc.render.image_settings.file_format = 'PNG'; sc.render.filepath = path + bpy.ops.render.render(write_still=True) + return os.path.exists(path) and os.path.getsize(path) > 0 + +def main(): + argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] + p = argparse.ArgumentParser() + p.add_argument("--output", default=None, help="optional: render one still to this PNG") + p.add_argument("--engine", choices=["auto", "cycles"], default="auto") + args = p.parse_args(argv) + + # the EEVEE-id mapping is asserted regardless of whether we render + eid = get_eevee_engine_id() + expected = 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT' + bpy.context.scene.render.engine = eid + if bpy.context.scene.render.engine != expected: + print(f"ERROR: EEVEE id {eid} != expected {expected}", file=sys.stderr); return 5 + + obj = build() + if not correctness(obj): + print("ERROR: rotation keys do not drive playback", file=sys.stderr); return 3 + + if args.output: + if not render_still(obj, args.output, args.engine): + print("ERROR: still render produced no file", file=sys.stderr); return 4 + print(f"rendered still {args.output} ({os.path.getsize(args.output)} bytes)") + print("turntable OK") + return 0 + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception as exc: + import traceback; traceback.print_exc() + print(f"FATAL: {type(exc).__name__}: {exc}", file=sys.stderr); sys.exit(1)