From 8992fe7fe4cb390ad075f938b2f29d6acaa1826b Mon Sep 17 00:00:00 2001 From: fOuttaMyPaint Date: Fri, 3 Jul 2026 16:19:03 -0400 Subject: [PATCH] docs: per-example detail pages in the gallery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each example now gets docs/gallery//index.html: the hero render with a click-to-zoom lightbox, the witnesses callout, a copyable run-it-yourself command, the README rendered inline (relative links resolve to GitHub), and the full Python source syntax-highlighted at build time via stdlib tokenize — no new dependencies, the stdlib-only rule for build_gallery.py holds. The gallery index gains topic filter chips driven by an additive tags field in gallery.json, and cards now link to the detail pages instead of straight to GitHub. pages.yml rebuilds on any examples/** change so embedded source and READMEs can never drift. Co-Authored-By: Claude Fable 5 --- .github/workflows/pages.yml | 2 +- docs/gallery/depsgraph-export/index.html | 313 ++++++++++++++ docs/gallery/gn-sdf-remesh/index.html | 387 +++++++++++++++++ docs/gallery/index.html | 128 +++++- docs/gallery/swatch-grid/index.html | 485 +++++++++++++++++++++ docs/gallery/turntable/index.html | 389 +++++++++++++++++ examples/gallery.json | 14 +- scripts/build_gallery.py | 522 ++++++++++++++++++++--- 8 files changed, 2148 insertions(+), 92 deletions(-) create mode 100644 docs/gallery/depsgraph-export/index.html create mode 100644 docs/gallery/gn-sdf-remesh/index.html create mode 100644 docs/gallery/swatch-grid/index.html create mode 100644 docs/gallery/turntable/index.html 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)
+
+
+
+ +
+ Generated from examples/gallery.json by scripts/build_gallery.py. +  •  CC-BY-NC-ND-4.0 +
+ + + 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)
+
+
+
+ +
+ Generated from examples/gallery.json by scripts/build_gallery.py. +  •  CC-BY-NC-ND-4.0 +
+ + + 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:

+
  • Principled BSDF metals (gold, copper: high metallic, low roughness) and dielectrics (red/blue plastic, white rough), configured with string socket lookups and 4-tuple colors.
  • The emission pattern (an emissive orange swatch).
  • The cross-version set_specular shim (SpecularSpecular IOR Level, renamed in Blender 4.0).
+

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)
+
+
+
+ +
+ Generated from examples/gallery.json by scripts/build_gallery.py. +  •  CC-BY-NC-ND-4.0 +
+ + + 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)
+
+
+
+ +
+ Generated from examples/gallery.json by scripts/build_gallery.py. +  •  CC-BY-NC-ND-4.0 +
+ + + 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. -->