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 --

# Also render a still of base vs evaluated (EEVEE on a GPU host; cycles on GPU-less hosts):
blender --background --python depsgraph_export.py -- --output depsgraph.png
blender --background --python depsgraph_export.py -- --output depsgraph.png --engine cycles

# Write the exported OBJ to a specific path:
blender --background --python depsgraph_export.py -- --obj exported.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.

The --output render path additionally measures framing against the Layer 1 band via examples/gallery_framing.py (exit 10 on violation) before writing the still.

Source

examples/depsgraph-export/depsgraph_export.py View on GitHub →
"""Depsgraph-evaluated export — a runnable example.

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.

By default it runs only the correctness check (no render) — the CI smoke
check. Pass --output to also render a still:

    blender --background --python depsgraph_export.py --                 # check only
    blender --background --python depsgraph_export.py -- --output d.png  # + render
"""
import bpy, bmesh, sys, os, math, argparse, tempfile

# Shared Layer 1 framing measurement (render path only) — see gallery_framing.py
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir))
sys.dont_write_bytecode = True  # keep examples/__pycache__ out of the repo tree
import gallery_framing


def build():
    bpy.ops.wm.read_factory_settings(use_empty=True)
    me = bpy.data.meshes.new("Cube")
    bm = bmesh.new()
    try:
        bmesh.ops.create_cube(bm, size=2.0)
        bm.to_mesh(me)
    finally:
        bm.free()
    obj = bpy.data.objects.new("Cube", me)
    bpy.context.collection.objects.link(obj)
    obj.modifiers.new("ss", 'SUBSURF').levels = 2
    return obj


def check(obj, obj_path):
    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

    out = obj_path 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
    return 0


def eevee_engine_id():
    return 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT'


def principled(name, color, metallic, roughness):
    mat = bpy.data.materials.new(name)
    mat.use_nodes = True
    bsdf = mat.node_tree.nodes["Principled BSDF"]
    bsdf.inputs["Base Color"].default_value = color
    bsdf.inputs["Metallic"].default_value = metallic
    bsdf.inputs["Roughness"].default_value = roughness
    return mat


def render_still(obj, path, engine):
    """Base cube beside its evaluated form — the two counts the check compares."""
    scene = bpy.context.scene

    # one hero material on BOTH objects: the two silhouettes share a color so
    # the only visible difference is the thing the check measures — base-mesh
    # geometry versus the depsgraph-evaluated subsurf
    hero = principled("Teal", (0.012, 0.30, 0.34, 1.0), 0.0, 0.2)

    # left: the base mesh, modifier-free (a plain copy of the datablock)
    base_obj = bpy.data.objects.new("Base", obj.data.copy())
    base_obj.location = (-1.7, 0.0, 1.0)
    # turned off camera-parallel so the key rakes the front face into a
    # gradient instead of one flat tone
    base_obj.rotation_euler = (0.0, 0.0, math.radians(32))
    base_obj.data.materials.append(hero)
    bpy.context.collection.objects.link(base_obj)

    # right: the SUBSURF object — what the depsgraph evaluates and the OBJ ships.
    # Rest it on the floor by measuring its own EVALUATED bounds (the subsurf
    # limit surface shrinks, so the base-mesh -1.0 is not where it ends).
    dg = bpy.context.evaluated_depsgraph_get()
    em = obj.evaluated_get(dg).to_mesh()
    bottom = min(v.co.z for v in em.vertices)
    obj.evaluated_get(dg).to_mesh_clear()
    obj.location = (1.8, 0.0, -bottom)
    obj.data.materials.append(hero)
    for poly in obj.data.polygons:
        poly.use_smooth = True

    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()
    floor_me.materials.append(principled("Studio", (0.03, 0.032, 0.037, 1.0), 0.0, 0.7))
    floor = bpy.data.objects.new("Floor", floor_me)
    scene.collection.objects.link(floor)
    wall = bpy.data.objects.new("Wall", floor_me.copy())
    wall.data.materials.clear()
    wall.data.materials.append(principled("Wall", (0.03, 0.032, 0.037, 1.0), 0.0, 0.7))
    wall.location = (0.0, 9.0, 0.0)
    wall.rotation_euler = (math.pi / 2, 0.0, 0.0)
    scene.collection.objects.link(wall)

    world = bpy.data.worlds.new("World")
    world.use_nodes = True
    world.node_tree.nodes["Background"].inputs["Color"].default_value = \
        (0.02, 0.021, 0.025, 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)

    # warm shaped key, faint cool fill, warm wedge on the back wall
    # (docs/VISUAL-STYLE.md)
    light("Key", (-4.0, -5.0, 6.0), 650.0, 5.0, (1.0, 0.96, 0.9), (46, 0, -35))
    light("Fill", (5.0, -3.5, 3.0), 120.0, 9.0, (0.75, 0.85, 1.0), (62, 0, 50))
    light("Wedge", (2.5, 5.5, 4.0), 380.0, 6.0, (1.0, 0.76, 0.5), (-68, 0, 190))

    cam_data = bpy.data.cameras.new("Cam")
    cam_data.lens = 50.0
    cam = bpy.data.objects.new("Cam", cam_data)
    # Reframed: the rotated base cube spans ~5.9 units with the evaluated
    # sphere, wider than the old (0,-7.4,3.0) frame — the cube was amputated
    # at the left edge. Aim-based framing on the pair's center.
    cam.location = (0.0, -9.6, 2.6)
    scene.collection.objects.link(cam)
    aim = bpy.data.objects.new("Aim", None)
    aim.location = (0.0, 0.0, 0.9)
    scene.collection.objects.link(aim)
    con = cam.constraints.new('TRACK_TO')
    con.target = aim
    con.track_axis = 'TRACK_NEGATIVE_Z'
    con.up_axis = 'UP_Y'
    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
    # AgX would flatten the graphite-vs-green contrast this still hinges on
    # (docs/VISUAL-STYLE.md)
    scene.view_settings.view_transform = 'Standard'
    # Layer 1 framing gate (silhouette matte) — exit 10 on violation, before
    # the beauty render so a defective composition ships no artifact.
    fcode = gallery_framing.check_framing(
        scene, cam,
        hero=[base_obj, obj],
        elements=[base_obj, obj],
        stage=[floor, wall],
    )
    if fcode:
        return fcode
    bpy.ops.render.render(write_still=True)
    if not (os.path.exists(path) and os.path.getsize(path) > 0):
        print("ERROR: render produced no file", file=sys.stderr)
        return 6
    return 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)")
    p.add_argument("--obj", default=None,
                   help="optional: write the exported OBJ here (else a temp path)")
    args = p.parse_args(argv)

    obj = build()
    code = check(obj, args.obj)
    if code:
        return code

    if args.output:
        rcode = render_still(obj, os.path.abspath(args.output), args.engine)
        if rcode:
            return rcode
        print(f"rendered still {args.output}")

    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)