diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index d37c3e6..514bb12 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -17,7 +17,7 @@ on: - "site.json" - ".cursor-plugin/plugin.json" - "assets/**" - - "examples/gallery.json" + - "examples/**" - "docs/gallery/**" - "scripts/build_gallery.py" - "scripts/site/**" diff --git a/docs/gallery/depsgraph-export/index.html b/docs/gallery/depsgraph-export/index.html new file mode 100644 index 0000000..12dfcc6 --- /dev/null +++ b/docs/gallery/depsgraph-export/index.html @@ -0,0 +1,313 @@ + + + + + + depsgraph-export — Examples — Blender Developer Tools + + + + + + + + + + + + + + + + + + + + +
+ Examples Gallery +
+ GitHub + +
+
+
+

depsgraph-export

+

The depsgraph lifetime contract — evaluated_get().to_mesh() paired with to_mesh_clear() — measured against an OBJ export of the same object.

+
+
+ +

Rendered headless by the example itself — click to zoom.

+
witnesses Exports ship evaluated geometry: the exported vertex count equals the subsurf-applied count and is strictly greater than the base mesh.
+
+
blender --background --python examples/depsgraph-export/depsgraph_export.py --
+ +
+
+

A runnable example that proves modifiers actually ship in exports and demonstrates the depsgraph-and-evaluated-data lifetime contract. It builds a cube with a SUBSURF modifier, measures the evaluated mesh via evaluated_get().to_mesh() (paired with to_mesh_clear()), exports through wm.obj_export, and asserts the exported vertex count equals the evaluated (modifier-applied) count and is strictly greater than the base mesh.

+

What it witnesses: the evaluated_getto_meshto_mesh_clear contract, and that wm.obj_export writes the depsgraph-evaluated geometry (so modifiers are baked into the export) rather than the unmodified base mesh.

+

Run

+
# Cheap correctness check (writes an OBJ to a temp path, asserts the counts) — the CI check:
+blender --background --python depsgraph_export.py --
+
+# Write the exported OBJ to a specific path:
+blender --background --python depsgraph_export.py -- --output remeshed.obj
+

It exits non-zero on failure (modifier not applied, or exported count ≠ evaluated count). The blender-smoke workflow runs this check on Blender 4.5 LTS and 5.1: base 8 → evaluated/exported 98 vertices with a 2-level SUBSURF.

+
+
+

Source

+
+ examples/depsgraph-export/depsgraph_export.py + View on GitHub → +
+
"""Candidate B: depsgraph-evaluated export (SCRATCH).
+
+Witnesses the depsgraph lifetime contract AND that modifiers actually ship in exports. Builds
+a cube with a SUBSURF modifier, measures the evaluated mesh via evaluated_get().to_mesh()
+(paired with to_mesh_clear()), exports through wm.obj_export, and asserts the exported vertex
+count equals the EVALUATED (modifier-applied) count and is strictly greater than the base.
+"""
+import bpy, bmesh, sys, os, argparse
+
+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: write the exported OBJ here (else a temp path)")
+    args = p.parse_args(argv)
+
+    bpy.ops.wm.read_factory_settings(use_empty=True)
+    me = bpy.data.meshes.new("Cube"); bm = bmesh.new(); bmesh.ops.create_cube(bm, size=2.0); bm.to_mesh(me); bm.free()
+    obj = bpy.data.objects.new("Cube", me); bpy.context.collection.objects.link(obj)
+    obj.modifiers.new("ss", 'SUBSURF').levels = 2
+    base = len(obj.data.vertices)
+
+    # depsgraph lifetime contract: evaluate, read, then release with to_mesh_clear
+    dg = bpy.context.evaluated_depsgraph_get()
+    ev = obj.evaluated_get(dg)
+    em = ev.to_mesh()
+    eval_vcount = len(em.vertices)
+    ev.to_mesh_clear()  # must be paired; releases the temporary mesh
+
+    import tempfile
+    out = args.output or os.path.join(tempfile.gettempdir(), "depsgraph_export.obj")
+    os.makedirs(os.path.dirname(os.path.abspath(out)) or ".", exist_ok=True)
+    # obj_export writes the evaluated (modifier-applied) geometry by default
+    bpy.ops.wm.obj_export(filepath=out, export_selected_objects=False)
+    if not (os.path.exists(out) and os.path.getsize(out) > 0):
+        print("ERROR: no OBJ written", file=sys.stderr); return 4
+    exported = 0
+    with open(out) as f:
+        for line in f:
+            if line.startswith("v "): exported += 1
+
+    print(f"base_vcount={base} eval_vcount={eval_vcount} exported_vcount={exported}")
+    if not (eval_vcount > base):
+        print("ERROR: evaluated mesh did not apply the modifier", file=sys.stderr); return 3
+    if exported != eval_vcount:
+        print(f"ERROR: export ({exported}) != evaluated ({eval_vcount}); modifier did not ship",
+              file=sys.stderr); return 5
+    print("depsgraph-export 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/gn-sdf-remesh/index.html b/docs/gallery/gn-sdf-remesh/index.html new file mode 100644 index 0000000..2ee0c54 --- /dev/null +++ b/docs/gallery/gn-sdf-remesh/index.html @@ -0,0 +1,387 @@ + + + + + + gn-sdf-remesh — Examples — Blender Developer Tools + + + + + + + + + + + + + + + + + + + + +
+ Examples Gallery +
+ GitHub + +
+
+
+

gn-sdf-remesh

+

A Geometry Nodes SDF remesh (MeshToSDFGrid → GridToMesh at the SDF zero-level), with a Set Material node carrying the material through the remesh.

+
+
+ +

Rendered headless by the example itself — click to zoom.

+
witnesses An SDF grid is meshed with Grid to Mesh, not Volume to Mesh; GN geometry needs Set Material or it renders untextured.
+
+
blender --background --python examples/gn-sdf-remesh/gn_sdf_remesh.py --
+ +
+
+

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 skill: GeometryNodeMeshToSDFGridGeometryNodeGridToMesh 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.

+

Materials gotcha: geometry generated by Geometry Nodes carries no material, so the input mesh's material is dropped on remesh (the result renders with the default white). Re-apply it inside the tree with a Set Material node (GeometryNodeSetMaterial) on the remeshed output — this example does that and asserts the material survives onto the evaluated mesh.

+

Run

+
# 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.

+
+
+

Source

+
+ examples/gn-sdf-remesh/gn_sdf_remesh.py + View on GitHub → +
+
"""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, material=None):
+    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"])
+    # GN-generated geometry carries no material, so the input mesh's material is dropped on
+    # remesh. Re-apply it inside the tree with a Set Material node (the GN-native fix).
+    out_socket = grid_to_mesh.outputs["Mesh"]
+    if material is not None:
+        set_mat = tree.nodes.new('GeometryNodeSetMaterial')
+        set_mat.inputs["Material"].default_value = material
+        tree.links.new(out_socket, set_mat.inputs["Geometry"])
+        out_socket = set_mat.outputs["Geometry"]
+    tree.links.new(out_socket, 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)
+    src_mat = obj.data.materials[0] if obj.data.materials else None
+    tree, link_valid = build_remesh_via_sdf(material=src_mat)
+    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)
+    mat_names = [mm.name for mm in m.materials if mm is not None]
+    ev.to_mesh_clear()
+    print(f"link_valid={link_valid} base_vcount={base} eval_vcount={evc} materials={mat_names}")
+    if not (link_valid and evc > 0 and evc != base):
+        print("ERROR: SDF remesh produced no/unchanged geometry", file=sys.stderr); return 3
+    # the Set Material node must carry the input material onto the remeshed result
+    if src_mat is not None and src_mat.name not in mat_names:
+        print(f"ERROR: material '{src_mat.name}' dropped by remesh", file=sys.stderr); return 6
+
+    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/docs/gallery/index.html b/docs/gallery/index.html index 43ec4eb..6fca1c3 100644 --- a/docs/gallery/index.html +++ b/docs/gallery/index.html @@ -19,7 +19,7 @@ + key with the landing page, so a visitor's choice carries across. --> + + + +
+ Examples Gallery +
+ GitHub + +
+
+
+

swatch-grid

+

Procedural Principled materials — metal and dielectric, the emission pattern, and the cross-version set_specular shim.

+
+
+ +

Rendered headless by the example itself — click to zoom.

+
witnesses EEVEE engine-id mapping: BLENDER_EEVEE on 5.x, BLENDER_EEVEE_NEXT on 4.2–4.5.
+
+
blender --background --python examples/swatch-grid/swatch_grid.py --
+ +
+
+

A runnable example that renders a 3×2 grid of spheres — one material per cell — to a single PNG. It demonstrates the procedural-materials-and-shaders patterns end to end:

+ +

It doubles as a live proof of the EEVEE engine-id behavior: the version-branch helper resolves BLENDER_EEVEE on Blender 5.x and BLENDER_EEVEE_NEXT on 4.2–4.5, and the chosen id is asserted against the running build before rendering — so a regression in that mapping fails the example, not just the docs.

+

Run

+
# Default: render with the build's EEVEE engine (needs a GPU/display)
+blender --background --python swatch_grid.py -- --output swatch.png
+
+# GPU-less / CI hosts: render the pixels with Cycles (CPU). The EEVEE id is still
+# asserted; only the final pixels use Cycles.
+blender --background --python swatch_grid.py -- --output swatch.png --engine cycles --samples 16 --width 960
+

The script is deterministic and dependency-light (fixed camera and layout, no HDRI, no network). It exits non-zero on any failure, including a render that comes out uniformly black or without the expected six distinct swatch regions — the same honest check the CI smoke gate runs on both Blender 4.5 LTS and 5.1.

+

Verified

+

Runs headless on Blender 4.5.10 LTS and 5.1.1; exercised on both by the blender-smoke workflow on every PR and weekly schedule.

+
+
+

Source

+
+ examples/swatch-grid/swatch_grid.py + View on GitHub → +
+
"""Procedural-materials swatch grid -- a runnable BDT example.
+
+Renders a 3x2 grid of spheres, one per material, demonstrating the
+`procedural-materials-and-shaders` patterns end to end: Principled BSDF (metal +
+dielectric), the emission pattern, the cross-version `set_specular` shim, string socket
+lookups, and 4-tuple colors. It also doubles as a live proof of the EEVEE engine-id fix:
+the version-branch helper resolves `BLENDER_EEVEE` on Blender 5.x and `BLENDER_EEVEE_NEXT`
+on 4.2-4.5, and the chosen id is asserted against the build before rendering.
+
+Run headless:
+    blender --background --python swatch_grid.py -- --output swatch.png
+    blender --background --python swatch_grid.py -- --output s.png --engine cycles --samples 8 --width 640
+
+Dependency-light and deterministic (fixed camera/layout, no HDRI, no network). Exits
+non-zero on any failure, including a render that comes out black or without the expected
+number of distinct swatch regions.
+"""
+import bpy
+import bmesh
+import sys
+import os
+import math
+import argparse
+import numpy as np
+
+GRID_COLS, GRID_ROWS = 3, 2
+MATERIAL_COUNT = GRID_COLS * GRID_ROWS  # 6
+
+
+# --- patterns copied from the procedural-materials-and-shaders skill ---
+def get_eevee_engine_id():
+    """EEVEE id: 'BLENDER_EEVEE' on 5.0+, 'BLENDER_EEVEE_NEXT' on 4.2-4.5."""
+    return 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT'
+
+
+def set_specular(bsdf, value):
+    """'Specular' was renamed to 'Specular IOR Level' in Blender 4.0; support both."""
+    if 'Specular IOR Level' in bsdf.inputs:
+        bsdf.inputs['Specular IOR Level'].default_value = value
+        return 'Specular IOR Level'
+    if 'Specular' in bsdf.inputs:
+        bsdf.inputs['Specular'].default_value = value
+        return 'Specular'
+    return None
+
+
+def make_principled(name, base_color, metallic, roughness, specular=None):
+    mat = bpy.data.materials.new(name)
+    mat.use_nodes = True
+    nt = mat.node_tree
+    nt.nodes.clear()
+    bsdf = nt.nodes.new('ShaderNodeBsdfPrincipled')
+    bsdf.inputs['Base Color'].default_value = base_color
+    bsdf.inputs['Metallic'].default_value = metallic
+    bsdf.inputs['Roughness'].default_value = roughness
+    resolved = set_specular(bsdf, specular) if specular is not None else None
+    out = nt.nodes.new('ShaderNodeOutputMaterial')
+    nt.links.new(bsdf.outputs['BSDF'], out.inputs['Surface'])
+    return mat, resolved
+
+
+def make_emissive(name, color, strength):
+    mat = bpy.data.materials.new(name)
+    mat.use_nodes = True
+    nt = mat.node_tree
+    nt.nodes.clear()
+    emis = nt.nodes.new('ShaderNodeEmission')
+    emis.inputs['Color'].default_value = color
+    emis.inputs['Strength'].default_value = strength
+    out = nt.nodes.new('ShaderNodeOutputMaterial')
+    nt.links.new(emis.outputs['Emission'], out.inputs['Surface'])
+    return mat
+
+
+def build_materials():
+    """Return a list of (material, label) covering metal, dielectric, emissive, and the
+    set_specular shim. The list order maps left-to-right, top-to-bottom across the grid."""
+    mats, specular_socket = [], None
+    m, specular_socket = make_principled("Gold", (1.00, 0.77, 0.34, 1), 1.0, 0.15)
+    mats.append(m)
+    m, _ = make_principled("Copper", (0.95, 0.64, 0.54, 1), 1.0, 0.28)
+    mats.append(m)
+    m, sr = make_principled("RedPlastic", (0.80, 0.05, 0.05, 1), 0.0, 0.40, specular=0.5)
+    mats.append(m)
+    specular_socket = specular_socket or sr
+    m, _ = make_principled("BluePlastic", (0.05, 0.20, 0.80, 1), 0.0, 0.30, specular=0.5)
+    mats.append(m)
+    mats.append(make_emissive("EmissiveOrange", (1.0, 0.35, 0.05, 1), 6.0))
+    m, _ = make_principled("WhiteRough", (0.90, 0.90, 0.92, 1), 0.0, 0.70, specular=0.3)
+    mats.append(m)
+    return mats, specular_socket
+
+
+def build_scene(mats):
+    xs = [-2.2, 0.0, 2.2]
+    zs = [1.1, -1.1]
+    i = 0
+    for r in range(GRID_ROWS):
+        for c in range(GRID_COLS):
+            me = bpy.data.meshes.new(f"S{i}")
+            bm = bmesh.new()
+            bmesh.ops.create_uvsphere(bm, u_segments=48, v_segments=24, radius=0.92)
+            bm.to_mesh(me)
+            bm.free()
+            for poly in me.polygons:
+                poly.use_smooth = True
+            ob = bpy.data.objects.new(f"S{i}", me)
+            ob.location = (xs[c], 0.0, zs[r])
+            bpy.context.collection.objects.link(ob)
+            ob.data.materials.append(mats[i])
+            i += 1
+    # ortho camera framed exactly on the grid cells
+    cam_d = bpy.data.cameras.new("cam")
+    cam_d.type = 'ORTHO'
+    cam_d.ortho_scale = 6.6
+    cam = bpy.data.objects.new("cam", cam_d)
+    cam.location = (0.0, -10.0, 0.0)
+    cam.rotation_euler = (math.radians(90), 0, 0)
+    bpy.context.collection.objects.link(cam)
+    bpy.context.scene.camera = cam
+    aim = bpy.data.objects.new("Aim", None)
+    bpy.context.collection.objects.link(aim)
+    for lname, loc, energy in [("KeyL", (-5, -6, 4), 1500), ("FillL", (5, -6, -2), 700)]:
+        ld = bpy.data.lights.new(lname, 'AREA')
+        ld.energy = energy
+        ld.size = 5.0
+        lo = bpy.data.objects.new(lname, ld)
+        lo.location = loc
+        bpy.context.collection.objects.link(lo)
+        con = lo.constraints.new('TRACK_TO')
+        con.target = aim
+        con.track_axis = 'TRACK_NEGATIVE_Z'
+        con.up_axis = 'UP_Y'
+    world = bpy.data.worlds.new("W")
+    world.use_nodes = True
+    world.node_tree.nodes["Background"].inputs[0].default_value = (0.03, 0.03, 0.035, 1)
+    bpy.context.scene.world = world
+
+
+def verify_png(path):
+    """Honest capture: not uniformly black AND distinct swatch regions == MATERIAL_COUNT."""
+    img = bpy.data.images.load(path)
+    w, h = img.size
+    arr = np.array(img.pixels[:], dtype=np.float32).reshape(h, w, 4)[..., :3]
+    gmax = float(arr.max())
+    cw, ch, ph = w // GRID_COLS, h // GRID_ROWS, 24
+    means = []
+    for r in range(GRID_ROWS):
+        for c in range(GRID_COLS):
+            cx, cy = c * cw + cw // 2, r * ch + ch // 2
+            means.append(arr[cy - ph:cy + ph, cx - ph:cx + ph, :].reshape(-1, 3).mean(axis=0))
+    kept = []
+    for cm in means:
+        if all(np.linalg.norm(cm - k) > 0.10 for k in kept):
+            kept.append(cm)
+    return gmax, len(kept)
+
+
+def main():
+    argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
+    p = argparse.ArgumentParser(description="Render a procedural-materials swatch grid.")
+    p.add_argument("--output", required=True, help="Output PNG path")
+    p.add_argument("--engine", choices=["auto", "eevee", "cycles"], default="auto",
+                   help="auto/eevee use the version-correct EEVEE id; cycles for GPU-less hosts")
+    p.add_argument("--samples", type=int, default=32)
+    p.add_argument("--width", type=int, default=1280)
+    p.add_argument("--no-verify", action="store_true")
+    args = p.parse_args(argv)
+
+    # Empty the factory file FIRST so the materials we create below survive.
+    bpy.ops.wm.read_factory_settings(use_empty=True)
+    mats, specular_socket = build_materials()
+    build_scene(mats)
+
+    sc = bpy.context.scene
+    # EEVEE engine-id proof: frame-independent, must hold even when we render with Cycles.
+    eid = get_eevee_engine_id()
+    expected = 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT'
+    sc.render.engine = eid
+    if sc.render.engine != expected:
+        print(f"ERROR: EEVEE id helper returned '{eid}', engine is '{sc.render.engine}', "
+              f"expected '{expected}'", file=sys.stderr)
+        return 5
+    print(f"eevee_engine_id={eid} (expected {expected}) OK; set_specular resolved '{specular_socket}'")
+
+    render_engine = 'CYCLES' if args.engine == 'cycles' else eid
+    sc.render.engine = render_engine
+    if render_engine == 'CYCLES':
+        sc.cycles.samples = args.samples
+    else:
+        sc.eevee.taa_render_samples = args.samples
+    sc.render.resolution_x = args.width
+    sc.render.resolution_y = int(args.width * 9 / 16)
+    sc.render.image_settings.file_format = 'PNG'
+    sc.render.filepath = args.output
+    os.makedirs(os.path.dirname(os.path.abspath(args.output)) or ".", exist_ok=True)
+    bpy.ops.render.render(write_still=True)
+    if not (os.path.exists(args.output) and os.path.getsize(args.output) > 0):
+        print("ERROR: no output written", file=sys.stderr)
+        return 4
+    print(f"rendered {args.output} with {render_engine} ({os.path.getsize(args.output)} bytes)")
+
+    if not args.no_verify:
+        gmax, regions = verify_png(args.output)
+        non_black = gmax > 0.05
+        regions_ok = regions == MATERIAL_COUNT
+        print(f"verify: max_pixel={gmax:.3f} non_black={non_black} "
+              f"distinct_regions={regions} materials={MATERIAL_COUNT} ok={regions_ok}")
+        if not (non_black and regions_ok):
+            print("ERROR: render failed verification (black or wrong region count)", file=sys.stderr)
+            return 3
+    return 0
+
+
+if __name__ == "__main__":
+    try:
+        sys.exit(main())
+    except Exception as exc:  # blender exits 0 on an uncaught traceback; force non-zero
+        import traceback
+        traceback.print_exc()
+        print(f"FATAL: {type(exc).__name__}: {exc}", file=sys.stderr)
+        sys.exit(1)
+
+
+
+ + + + + diff --git a/docs/gallery/turntable/index.html b/docs/gallery/turntable/index.html new file mode 100644 index 0000000..816e878 --- /dev/null +++ b/docs/gallery/turntable/index.html @@ -0,0 +1,389 @@ + + + + + + turntable — Examples — Blender Developer Tools + + + + + + + + + + + + + + + + + + + + +
+ Examples Gallery +
+ GitHub + +
+
+
+

turntable

+

A slotted-actions Z-rotation turntable keyed through the cross-version channelbag path (get_channelbag_for_slot).

+
+
+ +

Rendered headless by the example itself — click to zoom.

+
witnesses Slotted-actions boundary: ensure-helper channelbag on 5.x, strip.channelbag on 4.4/4.5.
+
+
blender --background --python examples/turntable/turntable.py --
+ +
+
+

A runnable example that keyframes a Z-rotation turntable through the slotted-actions cross-version channelbag path from the slotted-actions-animation 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

+
# 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.

+
+
+

Source

+
+ examples/turntable/turntable.py + View on GitHub → +
+
"""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)
+
+
+
+ + + + + diff --git a/examples/gallery.json b/examples/gallery.json index 4fff8d9..7954a16 100644 --- a/examples/gallery.json +++ b/examples/gallery.json @@ -1,5 +1,5 @@ { - "_comment": "FORWARD-COMPATIBLE SOURCE OF TRUTH for the examples gallery. The local page at docs/gallery/index.html is GENERATED from this file by scripts/build_gallery.py -- do not hand-edit the HTML. When the fleet template (Developer-Tools-Directory: site-template/build_site.py + template.html.j2) gains examples support (see ROADMAP: 'Fleet Pages examples support'), it reads this same file and the local page is retired. That migration is a lift-and-shift, not a rewrite: keep this schema stable. Per-entry schema: {name, dir, teaches, witnessesFix, hero, preview}; hero/preview/dir are repo-root-relative.", + "_comment": "FORWARD-COMPATIBLE SOURCE OF TRUTH for the examples gallery. The local page at docs/gallery/index.html is GENERATED from this file by scripts/build_gallery.py -- do not hand-edit the HTML. When the fleet template (Developer-Tools-Directory: site-template/build_site.py + template.html.j2) gains examples support (see ROADMAP: 'Fleet Pages examples support'), it reads this same file and the local page is retired. That migration is a lift-and-shift, not a rewrite: keep this schema stable. Per-entry schema: {name, dir, teaches, witnessesFix, hero, preview, tags?}; hero/preview/dir are repo-root-relative; tags is an optional additive list driving the gallery filter chips. build_gallery.py also emits a detail page per example at docs/gallery//.", "title": "Examples Gallery", "description": "Runnable, smoke-gated Blender Python examples — each executed headless on Blender 4.5 LTS and 5.1, so every render reflects code that actually runs.", "repoBaseUrl": "https://github.com/TMHSDigital/Blender-Developer-Tools/tree/main", @@ -11,7 +11,8 @@ "teaches": "Procedural Principled materials — metal and dielectric, the emission pattern, and the cross-version set_specular shim.", "witnessesFix": "EEVEE engine-id mapping: BLENDER_EEVEE on 5.x, BLENDER_EEVEE_NEXT on 4.2–4.5.", "hero": "docs/gallery/assets/swatch-grid-hero.webp", - "preview": "examples/swatch-grid/preview.webp" + "preview": "examples/swatch-grid/preview.webp", + "tags": ["materials", "rendering"] }, { "name": "turntable", @@ -19,7 +20,8 @@ "teaches": "A slotted-actions Z-rotation turntable keyed through the cross-version channelbag path (get_channelbag_for_slot).", "witnessesFix": "Slotted-actions boundary: ensure-helper channelbag on 5.x, strip.channelbag on 4.4/4.5.", "hero": "docs/gallery/assets/turntable-hero.webp", - "preview": "examples/turntable/preview.webp" + "preview": "examples/turntable/preview.webp", + "tags": ["animation"] }, { "name": "gn-sdf-remesh", @@ -27,7 +29,8 @@ "teaches": "A Geometry Nodes SDF remesh (MeshToSDFGrid → GridToMesh at the SDF zero-level), with a Set Material node carrying the material through the remesh.", "witnessesFix": "An SDF grid is meshed with Grid to Mesh, not Volume to Mesh; GN geometry needs Set Material or it renders untextured.", "hero": "docs/gallery/assets/gn-sdf-remesh-hero.webp", - "preview": "examples/gn-sdf-remesh/preview.webp" + "preview": "examples/gn-sdf-remesh/preview.webp", + "tags": ["geometry-nodes", "materials"] }, { "name": "depsgraph-export", @@ -35,7 +38,8 @@ "teaches": "The depsgraph lifetime contract — evaluated_get().to_mesh() paired with to_mesh_clear() — measured against an OBJ export of the same object.", "witnessesFix": "Exports ship evaluated geometry: the exported vertex count equals the subsurf-applied count and is strictly greater than the base mesh.", "hero": "docs/gallery/assets/depsgraph-export-hero.webp", - "preview": "examples/depsgraph-export/preview.webp" + "preview": "examples/depsgraph-export/preview.webp", + "tags": ["depsgraph", "export"] } ] } diff --git a/scripts/build_gallery.py b/scripts/build_gallery.py index 3d62891..af84ccd 100644 --- a/scripts/build_gallery.py +++ b/scripts/build_gallery.py @@ -1,64 +1,67 @@ #!/usr/bin/env python3 -"""Generate the standalone examples gallery page from examples/gallery.json. +"""Generate the examples gallery from examples/gallery.json. -This page is a LOCAL, this-repo gallery that rides alongside the fleet-generated -docs/index.html (which build_site.py owns and overwrites). It writes ONLY to -docs/gallery/ so it never collides with the fleet build's docs/index.html, -docs/fonts/, or docs/assets/. +Emits the gallery index (docs/gallery/index.html) AND one detail page per +example (docs/gallery//index.html) with the hero render (click to +zoom), a run-it-yourself command, the example's README rendered inline, and +the full Python source syntax-highlighted at build time. -examples/gallery.json is the forward-compatible source of truth: when the fleet -template gains examples support, it consumes the same data and this script and -page are retired. The visual direction here is the deliberate PROTOTYPE for that -fleet facelift -- see docs/gallery/DESIGN_NOTES.md. Run after editing gallery.json: +This is a LOCAL, this-repo gallery that rides alongside the generated +docs/index.html (which scripts/site/build_site.py owns and overwrites). It +writes ONLY under docs/gallery/ so it never collides with the landing build's +docs/index.html, docs/fonts/, or docs/assets/. + +examples/gallery.json is the source of truth. Run after editing gallery.json, +an example script, or an example README: python scripts/build_gallery.py -Stdlib only (no Jinja2), so the Pages workflow can regenerate it without extra deps. -Uses token replacement (not str.format) so CSS braces need no escaping. +Stdlib only (no Jinja2, no Pygments), so the Pages workflow can regenerate it +without extra deps. Uses token replacement (not str.format) so CSS braces +need no escaping. """ import html +import io import json +import keyword +import posixpath +import re import sys +import tokenize from pathlib import Path REPO = Path(__file__).resolve().parent.parent DATA = REPO / "examples" / "gallery.json" -OUT = REPO / "docs" / "gallery" / "index.html" +OUT_DIR = REPO / "docs" / "gallery" -CARD = """ """ +# --------------------------------------------------------------------------- +# Shared page shell. __ROOT__ is the relative prefix from the page to the +# gallery root ("" for the index, "../" for detail pages); __SITEROOT__ is the +# prefix to the site root ("../" for the index, "../../" for detail pages). +# --------------------------------------------------------------------------- -PAGE = """ +SHELL = """ - __TITLE__ — Blender Developer Tools + __TITLE__ - + - + - + + key with the landing page, so a visitor's choice carries across. -->