diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index d6240f9..79ed2c0 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -59,9 +59,11 @@ "templates/headless-batch-script-template" ], "examples": [ + "examples/bmesh-gear", "examples/depsgraph-export", "examples/driver-wave", "examples/gn-sdf-remesh", + "examples/shader-node-group", "examples/swatch-grid", "examples/turntable", "examples/wave-displace" diff --git a/.github/workflows/blender-smoke.yml b/.github/workflows/blender-smoke.yml index 7e78cb8..daf04a8 100644 --- a/.github/workflows/blender-smoke.yml +++ b/.github/workflows/blender-smoke.yml @@ -175,3 +175,22 @@ jobs: # on failure. xvfb-run -a "$BLENDER" --background \ --python examples/driver-wave/driver_wave.py -- + + - name: Shipped example - bmesh gear (ownership + watertight topology) + run: | + set -euo pipefail + # Frame-independent check only (no render): parametric gear via bmesh with + # bm.free() in try/finally; asserts closed-form vert/edge/face counts and + # that every edge borders exactly two faces. Exits non-zero on failure. + xvfb-run -a "$BLENDER" --background \ + --python examples/bmesh-gear/bmesh_gear.py -- + + - name: Shipped example - shader node group (interface sockets + sharing) + run: | + set -euo pipefail + # Frame-independent check only (no render): TintedGloss group declared via + # tree.interface.new_socket, instanced by two materials; asserts the sockets + # exist, the group datablock is shared (users == 2), and the per-instance + # Tint values differ. Exits non-zero on failure. + xvfb-run -a "$BLENDER" --background \ + --python examples/shader-node-group/shader_node_group.py -- diff --git a/README.md b/README.md index c3d1ce9..67f1f87 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,34 @@ A `driver_namespace` function driving sixteen column heights through SCRIPTED dr Witnesses the evaluation contract: driven values appear after a view-layer update on the evaluated copy **and** the flushed-back original, and both must match the closed form. + + + + +Bmesh gear: a machined steel 14-tooth gear at a three-quarter angle on a dark studio floor + + + +### [bmesh-gear](examples/bmesh-gear/) + +A 14-tooth gear built entirely with bmesh — with `bm.free()` in a `try`/`finally`, as the +ownership contract demands. Asserts the closed-form vert/edge/face counts and that the +result is watertight (every edge borders exactly two faces). + + + + + +Shader node group: a teal sphere and a magenta sphere sharing one TintedGloss node group with different Tint parameters + + + +### [shader-node-group](examples/shader-node-group/) + +One reusable `TintedGloss` group declared via `tree.interface.new_socket`, instanced in two +materials with different Tint values. Witnesses the grouping contract: shared datablock +(`users == 2`), parameters on the group **node** — two spheres, one group, two colors. + diff --git a/docs/gallery/assets/bmesh-gear-hero.webp b/docs/gallery/assets/bmesh-gear-hero.webp new file mode 100644 index 0000000..3550b1c Binary files /dev/null and b/docs/gallery/assets/bmesh-gear-hero.webp differ diff --git a/docs/gallery/assets/shader-node-group-hero.webp b/docs/gallery/assets/shader-node-group-hero.webp new file mode 100644 index 0000000..c6b802c Binary files /dev/null and b/docs/gallery/assets/shader-node-group-hero.webp differ diff --git a/docs/gallery/bmesh-gear/index.html b/docs/gallery/bmesh-gear/index.html new file mode 100644 index 0000000..028cc4a --- /dev/null +++ b/docs/gallery/bmesh-gear/index.html @@ -0,0 +1,438 @@ + + + + + + bmesh-gear — Examples — Blender Developer Tools + + + + + + + + + + + + + + + + + +
+ Examples Gallery +
+ GitHub +
+
+
+

bmesh-gear

+

A 14-tooth gear built entirely with bmesh — profile ring, face, extrude — with bm.free() in a try/finally, exactly as the ownership contract demands.

+
+
+ +

Rendered headless by the example itself — click to zoom.

+
witnesses Parametric bmesh topology is exactly predictable: verts, edges, and faces match their closed forms, and every edge borders exactly two faces (watertight).
+
+
blender --background --python examples/bmesh-gear/bmesh_gear.py --
+ +
+
+

A runnable example that builds a 14-tooth gear entirely with bmesh — profile ring, face, extrude_face_region, translate — following the ownership contract from mesh-editing-and-bmesh and the always-free-bmesh rule: every bmesh.new() is paired with bm.free() in a try/finally.

+

What it witnesses: parametric bmesh construction has exactly predictable topology. The check asserts the closed-form counts — verts = 2 × (4 × teeth), faces = sides + 2 caps, edges = 3 × profile — and that the result is watertight (every edge borders exactly two faces). If an op leaks geometry or a face fails to close, the math catches it.

+

Run

+
# Cheap correctness check (no render) — the CI check:
+blender --background --python bmesh_gear.py --
+
+# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
+blender --background --python bmesh_gear.py -- --output gear.png
+blender --background --python bmesh_gear.py -- --output gear.png --engine cycles
+

It exits non-zero on failure (topology mismatch or non-manifold edges). The blender-smoke workflow runs the check on Blender 4.5 LTS and 5.1.

+
+
+

Source

+
+ examples/bmesh-gear/bmesh_gear.py + View on GitHub → +
+
"""A parametric gear built entirely with bmesh — a runnable example.
+
+Witnesses the bmesh ownership contract from mesh-editing-and-bmesh and the
+always-free-bmesh rule: every `bmesh.new()` is paired with `bm.free()` in a
+`try`/`finally`, and because the construction is parametric the resulting
+topology is exactly predictable. The check asserts the closed-form counts —
+verts = 2 x (4 x teeth), faces = sides + 2 caps, edges = 3 x profile — and
+that the mesh is watertight (every edge borders exactly 2 faces).
+
+By default it runs only the correctness check (no render) — the CI smoke
+check. Pass --output to also render a still:
+
+    blender --background --python bmesh_gear.py --                 # check only
+    blender --background --python bmesh_gear.py -- --output g.png  # + render
+"""
+import bpy, bmesh, sys, os, math, argparse
+
+TEETH = 14
+R_ROOT = 1.0
+R_TIP = 1.25
+DEPTH = 0.6
+# fraction of a tooth period spent at the tip vs the root
+TOOTH_DUTY = 0.45
+
+
+def gear_profile():
+    """Vertex ring for the gear silhouette: 4 verts per tooth (root-root-tip-tip)."""
+    coords = []
+    step = 2 * math.pi / TEETH
+    for i in range(TEETH):
+        a0 = i * step
+        half = step * TOOTH_DUTY / 2
+        flank = step * (0.5 - TOOTH_DUTY / 2) / 2
+        mid = a0 + step / 2
+        coords.append((a0 + flank, R_ROOT))
+        coords.append((mid - half, R_TIP))
+        coords.append((mid + half, R_TIP))
+        coords.append((a0 + step - flank, R_ROOT))
+    return [(r * math.cos(a), r * math.sin(a), 0.0) for a, r in coords]
+
+
+def build_gear():
+    bpy.ops.wm.read_factory_settings(use_empty=True)
+    me = bpy.data.meshes.new("Gear")
+    bm = bmesh.new()
+    try:
+        verts = [bm.verts.new(co) for co in gear_profile()]
+        face = bm.faces.new(verts)
+        ext = bmesh.ops.extrude_face_region(bm, geom=[face])
+        top_verts = [e for e in ext["geom"] if isinstance(e, bmesh.types.BMVert)]
+        bmesh.ops.translate(bm, verts=top_verts, vec=(0.0, 0.0, DEPTH))
+        bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
+        bm.to_mesh(me)
+    finally:
+        bm.free()  # the contract this example witnesses
+    obj = bpy.data.objects.new("Gear", me)
+    bpy.context.collection.objects.link(obj)
+    return obj
+
+
+def check(obj):
+    me = obj.data
+    profile = 4 * TEETH
+    expect_v = 2 * profile          # bottom ring + extruded top ring
+    expect_f = profile + 2          # side quads + two caps
+    expect_e = 3 * profile          # two rings + verticals
+    got = (len(me.vertices), len(me.edges), len(me.polygons))
+    if got != (expect_v, expect_e, expect_f):
+        print(f"ERROR: topology {got} != expected {(expect_v, expect_e, expect_f)}",
+              file=sys.stderr)
+        return 3
+
+    # watertight: every edge borders exactly two faces
+    bm = bmesh.new()
+    try:
+        bm.from_mesh(me)
+        bad = sum(1 for e in bm.edges if len(e.link_faces) != 2)
+    finally:
+        bm.free()
+    if bad:
+        print(f"ERROR: {bad} non-manifold edge(s) — gear is not watertight", file=sys.stderr)
+        return 4
+
+    print(f"teeth={TEETH} verts={got[0]} edges={got[1]} faces={got[2]} watertight=True")
+    return 0
+
+
+def eevee_engine_id():
+    return 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT'
+
+
+def render_still(obj, path, engine):
+    scene = bpy.context.scene
+    for poly in obj.data.polygons:
+        poly.use_smooth = False  # crisp machined facets
+    mat = bpy.data.materials.new("Steel")
+    mat.use_nodes = True
+    bsdf = mat.node_tree.nodes["Principled BSDF"]
+    bsdf.inputs["Base Color"].default_value = (0.75, 0.77, 0.8, 1.0)
+    bsdf.inputs["Metallic"].default_value = 1.0
+    bsdf.inputs["Roughness"].default_value = 0.45
+    obj.data.materials.append(mat)
+    obj.location = (0.0, 0.0, 0.85)
+    obj.rotation_euler = (math.radians(38), 0.0, math.radians(22))
+
+    floor_me = bpy.data.meshes.new("Floor")
+    bm = bmesh.new()
+    try:
+        bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=30.0)
+        bm.to_mesh(floor_me)
+    finally:
+        bm.free()
+    fmat = bpy.data.materials.new("Studio")
+    fmat.use_nodes = True
+    fb = fmat.node_tree.nodes["Principled BSDF"]
+    fb.inputs["Base Color"].default_value = (0.055, 0.06, 0.07, 1.0)
+    fb.inputs["Roughness"].default_value = 0.5
+    floor_me.materials.append(fmat)
+    floor = bpy.data.objects.new("Floor", floor_me)
+    scene.collection.objects.link(floor)
+    wall = bpy.data.objects.new("Wall", floor_me.copy())
+    wall.location = (0.0, 9.0, 0.0)
+    wall.rotation_euler = (math.radians(90), 0.0, 0.0)
+    scene.collection.objects.link(wall)
+
+    world = bpy.data.worlds.new("World")
+    world.use_nodes = True
+    # metals reflect the environment: keep a faint cool ambient so flanks never go black
+    world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.035, 0.04, 0.05, 1.0)
+    scene.world = world
+
+    def light(name, loc, energy, size, col, rot):
+        ld = bpy.data.lights.new(name, 'AREA')
+        ld.energy = energy; ld.size = size; ld.color = col
+        ob = bpy.data.objects.new(name, ld)
+        ob.location = loc
+        ob.rotation_euler = tuple(math.radians(a) for a in rot)
+        scene.collection.objects.link(ob)
+
+    # metals live on reflections: big soft key, strong cool fill, warm rim kept low
+    light("Key", (-3.5, -4.5, 5.5), 1400.0, 7.0, (1.0, 0.98, 0.94), (48, 0, -35))
+    light("Fill", (5.0, -3.5, 2.5), 600.0, 9.0, (0.8, 0.87, 1.0), (65, 0, 50))
+    light("Rim", (1.5, 4.5, 2.2), 700.0, 4.0, (1.0, 0.7, 0.4), (-82, 0, 165))
+
+    cam_data = bpy.data.cameras.new("Cam")
+    cam_data.lens = 55.0
+    cam = bpy.data.objects.new("Cam", cam_data)
+    cam.location = (0.0, -7.6, 4.2)
+    cam.rotation_euler = (math.radians(66), 0.0, 0.0)
+    scene.collection.objects.link(cam)
+    scene.camera = cam
+
+    scene.render.engine = 'CYCLES' if engine == 'cycles' else eevee_engine_id()
+    if engine == 'cycles':
+        scene.cycles.samples = 32
+    else:
+        try:
+            scene.eevee.taa_render_samples = 64
+        except AttributeError:
+            pass
+    scene.render.resolution_x = 1280
+    scene.render.resolution_y = 720
+    scene.render.image_settings.file_format = 'PNG'
+    scene.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 a still PNG here")
+    p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"),
+                   help="render engine for --output (cycles for GPU-less hosts)")
+    args = p.parse_args(argv)
+
+    obj = build_gear()
+    code = check(obj)
+    if code:
+        return code
+
+    if args.output:
+        if not render_still(obj, os.path.abspath(args.output), args.engine):
+            print("ERROR: render produced no file", file=sys.stderr)
+            return 6
+        print(f"rendered still {args.output}")
+
+    print("bmesh-gear 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/index.html b/docs/gallery/index.html index 5d4b39e..f171e6c 100644 --- a/docs/gallery/index.html +++ b/docs/gallery/index.html @@ -175,12 +175,14 @@

Examples Gallery

@@ -252,6 +254,28 @@

driver-wave

View example +
+ + bmesh-gear — A 14-tooth gear built entirely with bmesh — profile ring, face, extrude — with bm + +
+

bmesh-gear

+

A 14-tooth gear built entirely with bmesh — profile ring, face, extrude — with bm.free() in a try/finally, exactly as the ownership contract demands.

+

witnesses Parametric bmesh topology is exactly predictable: verts, edges, and faces match their closed forms, and every edge borders exactly two faces (watertight).

+ View example +
+
+
+ + shader-node-group — One reusable shader group declared via tree + +
+

shader-node-group

+

One reusable shader group declared via tree.interface.new_socket, instanced in two materials with different Tint values — two spheres, one group, two colors.

+

witnesses Grouping contract: interface sockets appear on every instance, both materials share one group datablock (users == 2), and per-material parameters live on the group node, not inside the tree.

+ View example +
+