Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/blender-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 --
5 changes: 3 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.)

Expand Down
28 changes: 28 additions & 0 deletions examples/gn-sdf-remesh/README.md
Original file line number Diff line number Diff line change
@@ -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.
112 changes: 112 additions & 0 deletions examples/gn-sdf-remesh/gn_sdf_remesh.py
Original file line number Diff line number Diff line change
@@ -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)
28 changes: 28 additions & 0 deletions examples/turntable/README.md
Original file line number Diff line number Diff line change
@@ -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.
129 changes: 129 additions & 0 deletions examples/turntable/turntable.py
Original file line number Diff line number Diff line change
@@ -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)
Loading