Skip to content

Commit 3775e1d

Browse files
authored
feat: add turntable and GN SDF-remesh examples with smoke gates (#17)
Two runnable, dependency-light, self-verifying examples promoted from verified scratch candidates: - examples/turntable/ -- slotted-actions Z-rotation via the cross-version channelbag path (get_channelbag_for_slot); witnesses the slotted-actions fix. Cheap CI check (no render): rotation keys drive playback (Z differs across frames). - examples/gn-sdf-remesh/ -- the fixed build_remesh_via_sdf (MeshToSDFGrid -> GridToMesh at the SDF zero-level); witnesses the F2 fix. Cheap CI check (no render): evaluated vertex count > 0 and != base. Each runs correctness-only by default (frame-independent, exits non-zero on failure) and renders a still only when --output is given. blender-smoke.yml runs each SHIPPED file on both matrix builds in check-only mode. Verified on Blender 4.5.10 LTS and 5.1.1 (turntable drives=True with the right branch per version; remesh 576 -> 3256/3400 verts). examples/ stays out of validate-counts (12/6/2/17 unchanged). ROADMAP renumbered: v0.4.0 = more examples, 5.2 sweep -> v0.5.0. Signed-off-by: fOuttaMyPaint <TMhospitalitystrategies@gmail.com>
1 parent d27a4ef commit 3775e1d

6 files changed

Lines changed: 316 additions & 2 deletions

File tree

‎.github/workflows/blender-smoke.yml‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,3 +130,19 @@ jobs:
130130
--python examples/swatch-grid/swatch_grid.py -- \
131131
--output "$RUNNER_TEMP/out/swatch.png" --engine cycles --samples 8 --width 640
132132
test -s "$RUNNER_TEMP/out/swatch.png" || { echo "::error::swatch grid output missing/empty"; exit 1; }
133+
134+
- name: Shipped example - turntable correctness (slotted actions)
135+
run: |
136+
set -euo pipefail
137+
# Frame-independent check only (no render): asserts the rotation keys drive
138+
# playback via the cross-version channelbag path; exits non-zero on failure.
139+
xvfb-run -a "$BLENDER" --background \
140+
--python examples/turntable/turntable.py --
141+
142+
- name: Shipped example - GN SDF remesh correctness (GridToMesh)
143+
run: |
144+
set -euo pipefail
145+
# Frame-independent check only (no render): asserts the SDF->GridToMesh remesh
146+
# yields geometry (eval vcount > 0 and != base); exits non-zero on failure.
147+
xvfb-run -a "$BLENDER" --background \
148+
--python examples/gn-sdf-remesh/gn_sdf_remesh.py --

‎ROADMAP.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
| v0.1.0 | Foundation | 8 | 4 | 1 | 10 | Shipped |
1010
| v0.2.0 | Materials, drivers, migration | 12 | 6 | 2 | 17 | Shipped |
1111
| v0.3.0 | Examples and demos (smoke-gated) | 12 | 6 | 2 | 17 | Shipped |
12-
| v0.4.0 | 5.2 LTS sweep, modal operators, USD | TBD | TBD | TBD | TBD | Planned |
12+
| v0.4.0 | More examples (turntable, SDF remesh) | 12 | 6 | 2 | 17 | Shipped |
13+
| v0.5.0 | 5.2 LTS sweep, modal operators, USD | TBD | TBD | TBD | TBD | Planned |
1314
| v1.0.0 | Stable | TBD | TBD | TBD | TBD | Planned |
1415

1516
## v0.1.0 - Foundation
@@ -79,7 +80,7 @@ The 7 new snippets:
7980

8081
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`.
8182

82-
## v0.4.0 (candidate pool)
83+
## v0.5.0 (candidate pool)
8384

8485
Not committed; target list for the next content version. (v0.3.0 shipped the smoke-gated `examples/` track.)
8586

‎examples/gn-sdf-remesh/README.md‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Geometry Nodes SDF Remesh
2+
3+
A runnable example that remeshes an input mesh through an OpenVDB SDF grid using the
4+
`build_remesh_via_sdf` pattern from the
5+
[`geometry-nodes-python`](../../skills/geometry-nodes-python/SKILL.md) skill:
6+
`GeometryNodeMeshToSDFGrid` → `GeometryNodeGridToMesh` at the SDF zero-level, attached as a
7+
NODES modifier and evaluated via the depsgraph.
8+
9+
**Which fix it witnesses:** an SDF grid is meshed with **Grid to Mesh**, not Volume to Mesh
10+
(the `Mesh to SDF Grid` output is a grid socket; `Volume to Mesh` takes a volume-geometry
11+
socket, so wiring the grid there is an invalid link that yields no geometry). `Grid to Mesh`
12+
has the matching grid input.
13+
14+
## Run
15+
16+
```bash
17+
# Cheap correctness check only (no render) — the CI smoke check:
18+
blender --background --python gn_sdf_remesh.py --
19+
20+
# Also render the remeshed result (EEVEE on a GPU host; --engine cycles on GPU-less hosts):
21+
blender --background --python gn_sdf_remesh.py -- --output remesh.png
22+
blender --background --python gn_sdf_remesh.py -- --output remesh.png --engine cycles
23+
```
24+
25+
By default it runs only the **frame-independent correctness check**: the depsgraph-evaluated
26+
vertex count must be > 0 AND differ from the base mesh (the remesh produced geometry). It
27+
exits non-zero on failure — the same check the `blender-smoke` workflow runs on Blender 4.5
28+
LTS and 5.1.
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"""Geometry Nodes SDF remesh -- a runnable BDT example.
2+
3+
Builds the `build_remesh_via_sdf` pattern from the geometry-nodes-python skill
4+
(`GeometryNodeMeshToSDFGrid` -> `GeometryNodeGridToMesh` at the SDF zero-level), attaches it
5+
as a NODES modifier to an input mesh, and evaluates via the depsgraph. It witnesses the F2
6+
fix: an SDF grid is meshed with **Grid to Mesh**, not Volume to Mesh.
7+
8+
By default it runs only the cheap, frame-independent correctness check (no render): the
9+
evaluated vertex count must be > 0 AND differ from the base mesh -- proving the remesh
10+
produced geometry. Exits non-zero on failure. This is the check the CI smoke gate runs on
11+
both builds.
12+
13+
blender --background --python gn_sdf_remesh.py -- # correctness check only
14+
blender --background --python gn_sdf_remesh.py -- --output r.png # also render the result
15+
blender --background --python gn_sdf_remesh.py -- --output r.png --engine cycles # GPU-less
16+
"""
17+
import bpy, sys, os, argparse
18+
19+
def get_eevee_engine_id():
20+
return 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT'
21+
22+
def build_remesh_via_sdf(voxel_size=0.1, threshold=0.0):
23+
tree = bpy.data.node_groups.new("SDFRemesh", 'GeometryNodeTree')
24+
tree.interface.new_socket(name="Geometry", in_out='INPUT', socket_type='NodeSocketGeometry')
25+
tree.interface.new_socket(name="Geometry", in_out='OUTPUT', socket_type='NodeSocketGeometry')
26+
gi = tree.nodes.new('NodeGroupInput'); go = tree.nodes.new('NodeGroupOutput')
27+
mesh_to_sdf = tree.nodes.new('GeometryNodeMeshToSDFGrid')
28+
grid_to_mesh = tree.nodes.new('GeometryNodeGridToMesh')
29+
mesh_to_sdf.inputs["Voxel Size"].default_value = voxel_size
30+
grid_to_mesh.inputs["Threshold"].default_value = threshold
31+
tree.links.new(gi.outputs["Geometry"], mesh_to_sdf.inputs["Mesh"])
32+
link = tree.links.new(mesh_to_sdf.outputs["SDF Grid"], grid_to_mesh.inputs["Grid"])
33+
tree.links.new(grid_to_mesh.outputs["Mesh"], go.inputs["Geometry"])
34+
return tree, link.is_valid
35+
36+
def build():
37+
bpy.ops.wm.read_factory_settings(use_empty=True)
38+
bpy.ops.mesh.primitive_torus_add(location=(0, 0, 1.0), major_radius=1.2, minor_radius=0.5)
39+
obj = bpy.context.active_object
40+
for p in obj.data.polygons:
41+
p.use_smooth = True
42+
mat = bpy.data.materials.new("Clay"); mat.use_nodes = True
43+
b = mat.node_tree.nodes.get('Principled BSDF')
44+
b.inputs['Base Color'].default_value = (0.45, 0.55, 0.85, 1)
45+
b.inputs['Roughness'].default_value = 0.45
46+
obj.data.materials.append(mat)
47+
return obj
48+
49+
def render_still(obj, path, engine):
50+
import bmesh
51+
sc = bpy.context.scene
52+
fme = bpy.data.meshes.new("Floor"); bm = bmesh.new()
53+
bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=30.0); bm.to_mesh(fme); bm.free()
54+
floor = bpy.data.objects.new("Floor", fme); bpy.context.collection.objects.link(floor)
55+
w = bpy.data.worlds.new("W"); w.use_nodes = True
56+
w.node_tree.nodes["Background"].inputs[0].default_value = (0.05, 0.06, 0.08, 1); sc.world = w
57+
aim = bpy.data.objects.new("Aim", None); aim.location = (0, 0, 1.0); bpy.context.collection.objects.link(aim)
58+
cam = bpy.data.objects.new("cam", bpy.data.cameras.new("cam")); cam.location = (0, -6.5, 3.0)
59+
bpy.context.collection.objects.link(cam); sc.camera = cam
60+
c = cam.constraints.new('TRACK_TO'); c.target = aim; c.track_axis = 'TRACK_NEGATIVE_Z'; c.up_axis = 'UP_Y'
61+
for nm, loc, en in [("K", (-4, -5, 7), 900), ("F2", (5, -4, 2), 350)]:
62+
ld = bpy.data.lights.new(nm, 'AREA'); ld.energy = en; ld.size = 5.0
63+
lo = bpy.data.objects.new(nm, ld); lo.location = loc; bpy.context.collection.objects.link(lo)
64+
lc = lo.constraints.new('TRACK_TO'); lc.target = aim; lc.track_axis = 'TRACK_NEGATIVE_Z'; lc.up_axis = 'UP_Y'
65+
sc.render.engine = 'CYCLES' if engine == 'cycles' else get_eevee_engine_id()
66+
if sc.render.engine == 'CYCLES':
67+
try: sc.cycles.samples = 16
68+
except Exception: pass
69+
else:
70+
try: sc.eevee.taa_render_samples = 16
71+
except Exception: pass
72+
sc.render.resolution_x = 1280; sc.render.resolution_y = 720
73+
sc.render.image_settings.file_format = 'PNG'; sc.render.filepath = path
74+
bpy.ops.render.render(write_still=True)
75+
return os.path.exists(path) and os.path.getsize(path) > 0
76+
77+
def main():
78+
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
79+
p = argparse.ArgumentParser()
80+
p.add_argument("--output", default=None, help="optional: render the remeshed result to this PNG")
81+
p.add_argument("--engine", choices=["auto", "cycles"], default="auto")
82+
args = p.parse_args(argv)
83+
84+
eid = get_eevee_engine_id()
85+
expected = 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT'
86+
bpy.context.scene.render.engine = eid
87+
if bpy.context.scene.render.engine != expected:
88+
print(f"ERROR: EEVEE id {eid} != expected {expected}", file=sys.stderr); return 5
89+
90+
obj = build()
91+
base = len(obj.data.vertices)
92+
tree, link_valid = build_remesh_via_sdf()
93+
obj.modifiers.new("sdf", 'NODES').node_group = tree
94+
dg = bpy.context.evaluated_depsgraph_get(); ev = obj.evaluated_get(dg)
95+
m = ev.to_mesh(); evc = len(m.vertices); ev.to_mesh_clear()
96+
print(f"link_valid={link_valid} base_vcount={base} eval_vcount={evc}")
97+
if not (link_valid and evc > 0 and evc != base):
98+
print("ERROR: SDF remesh produced no/unchanged geometry", file=sys.stderr); return 3
99+
100+
if args.output:
101+
if not render_still(obj, args.output, args.engine):
102+
print("ERROR: render produced no file", file=sys.stderr); return 4
103+
print(f"rendered {args.output} ({os.path.getsize(args.output)} bytes)")
104+
print("gn-sdf-remesh OK")
105+
return 0
106+
107+
if __name__ == "__main__":
108+
try:
109+
sys.exit(main())
110+
except Exception as exc:
111+
import traceback; traceback.print_exc()
112+
print(f"FATAL: {type(exc).__name__}: {exc}", file=sys.stderr); sys.exit(1)

‎examples/turntable/README.md‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Turntable (Slotted Actions)
2+
3+
A runnable example that keyframes a Z-rotation turntable through the **slotted-actions**
4+
cross-version channelbag path from the
5+
[`slotted-actions-animation`](../../skills/slotted-actions-animation/SKILL.md) skill, and
6+
picks the render engine with the version-branch EEVEE-id helper.
7+
8+
**Which fix it witnesses:** the slotted-actions cross-version helper. On Blender 5.x the
9+
channelbag comes from `action_ensure_channelbag_for_slot`; on 4.4/4.5 from
10+
`strip.channelbag(slot, ensure=True)` (legacy `action.fcurves` still works on 4.5, raises
11+
`AttributeError` on 5.x).
12+
13+
## Run
14+
15+
```bash
16+
# Cheap correctness check only (no render) — the CI smoke check:
17+
blender --background --python turntable.py --
18+
19+
# Also render one still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
20+
blender --background --python turntable.py -- --output turntable.png
21+
blender --background --python turntable.py -- --output turntable.png --engine cycles
22+
```
23+
24+
By default it runs only the **frame-independent correctness check**: it inserts the rotation
25+
keys, samples the object's Z rotation at frame 1 vs a later frame, and asserts they **differ**
26+
(the keys drive playback). It exits non-zero on failure — the same check the `blender-smoke`
27+
workflow runs on Blender 4.5 LTS and 5.1. `--output` additionally renders a still; the full
28+
animated loop is a showcase extra, not part of the CI check.

‎examples/turntable/turntable.py‎

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
"""Slotted-actions turntable -- a runnable BDT example.
2+
3+
Keyframes a Z-rotation turntable through the slotted-actions cross-version channelbag path
4+
(`get_channelbag_for_slot`) and selects the engine with the version-branch EEVEE-id helper.
5+
It witnesses the slotted-actions fix: on Blender 5.x the channelbag comes from
6+
`action_ensure_channelbag_for_slot`; on 4.4/4.5 from `strip.channelbag(slot, ensure=True)`.
7+
8+
By default it runs only the cheap, frame-independent correctness check (no render): insert
9+
the rotation keys, sample the object's Z rotation at frame 1 vs a later frame, and assert
10+
they DIFFER -- proving the keys drive playback. Exits non-zero on failure. This is the check
11+
the CI smoke gate runs on both builds.
12+
13+
blender --background --python turntable.py -- # correctness check only
14+
blender --background --python turntable.py -- --output t.png # also render one still
15+
blender --background --python turntable.py -- --output t.png --engine cycles # GPU-less
16+
"""
17+
import bpy, sys, os, math, argparse
18+
19+
FRAMES = 36
20+
21+
def get_eevee_engine_id():
22+
return 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT'
23+
24+
def get_channelbag_for_slot(action, slot):
25+
if bpy.app.version >= (5, 0, 0):
26+
from bpy_extras.anim_utils import action_ensure_channelbag_for_slot
27+
return action_ensure_channelbag_for_slot(action, slot)
28+
layer = action.layers[0] if action.layers else action.layers.new("Layer")
29+
strip = layer.strips[0] if layer.strips else layer.strips.new(type='KEYFRAME')
30+
return strip.channelbag(slot, ensure=True)
31+
32+
def build():
33+
bpy.ops.wm.read_factory_settings(use_empty=True)
34+
bpy.ops.mesh.primitive_monkey_add(location=(0, 0, 1.0))
35+
obj = bpy.context.active_object
36+
for p in obj.data.polygons:
37+
p.use_smooth = True
38+
mat = bpy.data.materials.new("M"); mat.use_nodes = True
39+
b = mat.node_tree.nodes.get('Principled BSDF')
40+
b.inputs['Base Color'].default_value = (0.85, 0.35, 0.10, 1)
41+
b.inputs['Metallic'].default_value = 0.7
42+
b.inputs['Roughness'].default_value = 0.25
43+
obj.data.materials.append(mat)
44+
# rotation keyframes via the slotted-actions channelbag path
45+
obj.animation_data_create()
46+
act = bpy.data.actions.new("Turn"); obj.animation_data.action = act
47+
slot = obj.animation_data.action_slot
48+
if slot is None:
49+
slot = act.slots.new(id_type='OBJECT', name=obj.name); obj.animation_data.action_slot = slot
50+
cbag = get_channelbag_for_slot(act, slot)
51+
fc = cbag.fcurves.new("rotation_euler", index=2)
52+
fc.keyframe_points.insert(1, 0.0)
53+
fc.keyframe_points.insert(FRAMES, math.radians(360))
54+
for kp in fc.keyframe_points:
55+
kp.interpolation = 'LINEAR'
56+
fc.update()
57+
return obj
58+
59+
def correctness(obj):
60+
sc = bpy.context.scene; sc.frame_start = 1; sc.frame_end = FRAMES
61+
def rz(f):
62+
sc.frame_set(f); dg = bpy.context.evaluated_depsgraph_get()
63+
return round(obj.evaluated_get(dg).rotation_euler.z, 4)
64+
r1, rmid, rend = rz(1), rz(FRAMES // 2), rz(FRAMES)
65+
branch = '5.0+ ensure-helper' if bpy.app.version >= (5, 0, 0) else '4.4/4.5 strip.channelbag'
66+
drives = (r1 != rmid != rend) and abs(rend - r1) > 0.5
67+
print(f"branch={branch} rot_z f1={r1} fmid={rmid} fend={rend} drives={drives}")
68+
return drives
69+
70+
def render_still(obj, path, engine):
71+
import bmesh
72+
sc = bpy.context.scene
73+
fme = bpy.data.meshes.new("Floor"); bm = bmesh.new()
74+
bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=30.0); bm.to_mesh(fme); bm.free()
75+
floor = bpy.data.objects.new("Floor", fme); bpy.context.collection.objects.link(floor)
76+
w = bpy.data.worlds.new("W"); w.use_nodes = True
77+
w.node_tree.nodes["Background"].inputs[0].default_value = (0.04, 0.05, 0.07, 1); sc.world = w
78+
aim = bpy.data.objects.new("Aim", None); aim.location = (0, 0, 1.0); bpy.context.collection.objects.link(aim)
79+
cam = bpy.data.objects.new("cam", bpy.data.cameras.new("cam")); cam.location = (0, -7, 3.0)
80+
bpy.context.collection.objects.link(cam); sc.camera = cam
81+
c = cam.constraints.new('TRACK_TO'); c.target = aim; c.track_axis = 'TRACK_NEGATIVE_Z'; c.up_axis = 'UP_Y'
82+
for nm, loc, en in [("K", (-4, -5, 7), 900), ("F2", (5, -4, 2), 350)]:
83+
ld = bpy.data.lights.new(nm, 'AREA'); ld.energy = en; ld.size = 5.0
84+
lo = bpy.data.objects.new(nm, ld); lo.location = loc; bpy.context.collection.objects.link(lo)
85+
lc = lo.constraints.new('TRACK_TO'); lc.target = aim; lc.track_axis = 'TRACK_NEGATIVE_Z'; lc.up_axis = 'UP_Y'
86+
sc.render.engine = 'CYCLES' if engine == 'cycles' else get_eevee_engine_id()
87+
if sc.render.engine == 'CYCLES':
88+
try: sc.cycles.samples = 16
89+
except Exception: pass
90+
else:
91+
try: sc.eevee.taa_render_samples = 16
92+
except Exception: pass
93+
sc.frame_set(FRAMES // 4)
94+
sc.render.resolution_x = 1280; sc.render.resolution_y = 720
95+
sc.render.image_settings.file_format = 'PNG'; sc.render.filepath = path
96+
bpy.ops.render.render(write_still=True)
97+
return os.path.exists(path) and os.path.getsize(path) > 0
98+
99+
def main():
100+
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
101+
p = argparse.ArgumentParser()
102+
p.add_argument("--output", default=None, help="optional: render one still to this PNG")
103+
p.add_argument("--engine", choices=["auto", "cycles"], default="auto")
104+
args = p.parse_args(argv)
105+
106+
# the EEVEE-id mapping is asserted regardless of whether we render
107+
eid = get_eevee_engine_id()
108+
expected = 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT'
109+
bpy.context.scene.render.engine = eid
110+
if bpy.context.scene.render.engine != expected:
111+
print(f"ERROR: EEVEE id {eid} != expected {expected}", file=sys.stderr); return 5
112+
113+
obj = build()
114+
if not correctness(obj):
115+
print("ERROR: rotation keys do not drive playback", file=sys.stderr); return 3
116+
117+
if args.output:
118+
if not render_still(obj, args.output, args.engine):
119+
print("ERROR: still render produced no file", file=sys.stderr); return 4
120+
print(f"rendered still {args.output} ({os.path.getsize(args.output)} bytes)")
121+
print("turntable OK")
122+
return 0
123+
124+
if __name__ == "__main__":
125+
try:
126+
sys.exit(main())
127+
except Exception as exc:
128+
import traceback; traceback.print_exc()
129+
print(f"FATAL: {type(exc).__name__}: {exc}", file=sys.stderr); sys.exit(1)

0 commit comments

Comments
 (0)