Rendered headless by the example itself — click to zoom.
blender --background --python examples/grease-pencil-rosette/grease_pencil_rosette.py --
A runnable example that draws five nested neon rose curves as Grease Pencil v3 strokes — layer → frames.new(1).drawing → add_strokes([counts]) → per-point position, radius, opacity, vertex_color — the attribute-based API that replaced legacy GPencil across the 4.x-to-5.x window, the largest bpy API break the gallery witnesses.
What it witnesses: GPv3 lives at *different addresses* on the two supported versions, and the check asserts each side's actual contract:
- Blender 4.5 LTS — GPv3 is
bpy.data.grease_pencils_v3(GreasePencilv3), whilebpy.data.grease_pencilsstill holds legacy GPencil whose frames carry.strokesdirectly and have no.drawing. Same collection name, incompatible API — the trap is asserted, not just avoided. - Blender 5.x — legacy is gone: GPv3 took over the
bpy.data.grease_pencilsname, andgrease_pencils_v3/bpy.types.GPencilStrokeno longer exist.
On the shared GPv3 surface it then asserts that stroke points are *views over attribute layers*: writing pt.radius / pt.opacity / pt.vertex_color lazily materializes radius, opacity, vertex_color (POINT) and cyclic (CURVE) layers in drawing.attributes, and every closed-form rose position round-trips exactly through the raw position attribute buffer via foreach_get.
Run
# Cheap correctness check (no render) — the CI check:
blender --background --python grease_pencil_rosette.py --
# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
blender --background --python grease_pencil_rosette.py -- --output rosette.png
blender --background --python grease_pencil_rosette.py -- --output rosette.png --engine cycles
It exits non-zero on failure (wrong version gate, structural mismatch, missing attribute layers, or attribute-buffer deviation from the closed form). The blender-smoke workflow runs the check on Blender 4.5 LTS and 5.1.
Source
"""A neon rose-curve set drawn with GPv3 strokes — a runnable example. Witnesses the Grease Pencil v3 rewrite, the largest bpy API break in the 4.x-to-5.x window. GPv3 is present on both supported versions but lives at DIFFERENT addresses: - Blender 4.5 LTS: GPv3 is `bpy.data.grease_pencils_v3` (`GreasePencilv3`); `bpy.data.grease_pencils` still holds LEGACY GPencil datablocks whose frames carry `.strokes` directly — same collection name, incompatible API. - Blender 5.x: legacy is gone, GPv3 took over the `bpy.data.grease_pencils` name (`GreasePencil`), and `grease_pencils_v3` / `GPencilStroke` no longer exist. The shared GPv3 surface is attribute-based: layer -> frames.new(n).drawing -> add_strokes([counts]) -> point.position/radius/opacity/vertex_color, where stroke points are views over attribute layers that materialize lazily in `drawing.attributes`. The check asserts the address divergence on each side, the legacy trap on 4.5, the structural contract, lazy attribute materialization, and a closed-form round-trip of every position through the raw POINT attribute buffer. By default it runs only the correctness check (no render) — the CI smoke check. Pass --output to also render a still: blender --background --python grease_pencil_rosette.py -- # check only blender --background --python grease_pencil_rosette.py -- --output r.png # + render """ import bpy, sys, os, math, argparse, colorsys RINGS = 5 # nested rose curves, one stroke each POINTS = 192 # samples per stroke R_OUTER = 1.55 # radius of the outermost rose BASE_RADIUS = 0.02 # base line half-width in world units TOL = 1e-4 def rose_point(ring, i): """Closed-form sample i of ring's rose curve r = a*(0.72 + 0.28*cos(k*t)), laid out upright in the XZ plane. Single source of truth for build & check.""" k = 3 + ring # petal frequency a = R_OUTER * (1.0 - ring / (RINGS + 1.5)) phase = ring * math.pi / 7.0 t = 2.0 * math.pi * i / POINTS r = a * (0.72 + 0.28 * math.cos(k * t)) return (r * math.cos(t + phase), 0.0, r * math.sin(t + phase)) def point_radius(ring, i): """Calligraphic taper: width swells on the petal tips.""" k = 3 + ring t = 2.0 * math.pi * i / POINTS return BASE_RADIUS * (0.55 + 1.45 * (0.5 + 0.5 * math.cos(k * t))) def ring_color(ring, i): """Neon hue per ring, drifting slightly along the stroke.""" h = (0.52 + ring / RINGS * 0.55 + 0.04 * math.sin(2 * math.pi * i / POINTS)) % 1.0 r, g, b = colorsys.hsv_to_rgb(h, 0.96, 1.0) return (r, g, b, 1.0) def gp_data_new(name): """THE version gate this example exists for: GPv3 datablock creation.""" if bpy.app.version >= (5, 0, 0): return bpy.data.grease_pencils.new(name) # GPv3 owns the name in 5.x return bpy.data.grease_pencils_v3.new(name) # 4.5 LTS: GPv3 lives at _v3 def build_rosette(): bpy.ops.wm.read_factory_settings(use_empty=True) gp = gp_data_new("Rosette") layer = gp.layers.new("Ink") frame = layer.frames.new(1) drawing = frame.drawing drawing.add_strokes([POINTS] * RINGS) for ring, stroke in enumerate(drawing.strokes): stroke.cyclic = True for i, pt in enumerate(stroke.points): pt.position = rose_point(ring, i) pt.radius = point_radius(ring, i) pt.opacity = 1.0 pt.vertex_color = ring_color(ring, i) mat = bpy.data.materials.new("Neon Ink") bpy.data.materials.create_gpencil_data(mat) # same helper on 4.5 and 5.x mat.grease_pencil.color = (1.0, 1.0, 1.0, 1.0) # vertex colors carry the hue gp.materials.append(mat) obj = bpy.data.objects.new("Rosette", gp) bpy.context.collection.objects.link(obj) return obj def check_version_gate(): """Assert the API break each side actually exposes.""" if bpy.app.version >= (5, 0, 0): if hasattr(bpy.data, "grease_pencils_v3") or hasattr(bpy.types, "GPencilStroke"): print("ERROR: 5.x still exposes legacy GP names — gate is wrong", file=sys.stderr) return 3 print("5.x contract: grease_pencils is GPv3; _v3 alias and GPencilStroke are gone") else: if not hasattr(bpy.data, "grease_pencils_v3") or not hasattr(bpy.types, "GPencilStroke"): print("ERROR: 4.5 is missing grease_pencils_v3 or legacy GPencilStroke", file=sys.stderr) return 3 # The trap: on 4.5 `bpy.data.grease_pencils` is LEGACY GPencil. Its frames # carry `.strokes` directly and have no `.drawing` — code written for one # API fails on the other despite the identical collection name. legacy = bpy.data.grease_pencils.new("_legacy_probe") try: lframe = legacy.layers.new("L").frames.new(1) if hasattr(lframe, "drawing") or not hasattr(lframe, "strokes"): print("ERROR: 4.5 grease_pencils did not behave as legacy GPencil", file=sys.stderr) return 3 finally: bpy.data.grease_pencils.remove(legacy) print("4.5 contract: grease_pencils is legacy (frame.strokes); GPv3 lives at _v3") return 0 def check(obj): code = check_version_gate() if code: return code if obj.type != 'GREASEPENCIL': print(f"ERROR: object type {obj.type!r} != 'GREASEPENCIL'", file=sys.stderr) return 4 gp = obj.data if len(gp.layers) != 1: print(f"ERROR: {len(gp.layers)} layers != 1", file=sys.stderr) return 4 frame = gp.layers[0].frames[0] if frame.frame_number != 1: print(f"ERROR: frame_number {frame.frame_number} != 1", file=sys.stderr) return 4 drawing = frame.drawing strokes = drawing.strokes if len(strokes) != RINGS or any(len(s.points) != POINTS for s in strokes): print(f"ERROR: stroke topology != {RINGS} x {POINTS}", file=sys.stderr) return 5 if not all(s.cyclic for s in strokes): print("ERROR: not every stroke is cyclic", file=sys.stderr) return 5 # Lazy materialization: writing through the point view must have created # these attribute layers on the drawing (they are absent on a fresh drawing). attrs = {a.name: (a.domain, a.data_type) for a in drawing.attributes} expected = { "position": ('POINT', 'FLOAT_VECTOR'), "radius": ('POINT', 'FLOAT'), "opacity": ('POINT', 'FLOAT'), "vertex_color": ('POINT', 'FLOAT_COLOR'), "cyclic": ('CURVE', 'BOOLEAN'), } for name, sig in expected.items(): if attrs.get(name) != sig: print(f"ERROR: attribute {name!r} is {attrs.get(name)} != {sig}", file=sys.stderr) return 6 # Round-trip: the raw POINT attribute buffer must hold every closed-form # position — stroke points are views over this buffer, not copies. pos = drawing.attributes["position"] n = len(pos.data) if n != RINGS * POINTS: print(f"ERROR: position buffer {n} points != {RINGS * POINTS}", file=sys.stderr) return 7 buf = [0.0] * (3 * n) pos.data.foreach_get("vector", buf) worst = 0.0 for ring in range(RINGS): for i in range(POINTS): j = 3 * (ring * POINTS + i) ex, ey, ez = rose_point(ring, i) worst = max(worst, abs(buf[j] - ex), abs(buf[j + 1] - ey), abs(buf[j + 2] - ez)) if worst > TOL: print(f"ERROR: attribute buffer deviates {worst} > {TOL} from closed form", file=sys.stderr) return 7 if len(gp.materials) != 1 or gp.materials[0].grease_pencil is None: print("ERROR: grease pencil material missing its gpencil settings", file=sys.stderr) return 8 print(f"rings={RINGS} points/stroke={POINTS} attrs=lazy-materialized " f"round-trip worst={worst:.2e} object=GREASEPENCIL") 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 obj.data.layers[0].use_lights = False # neon ink stays unlit and vivid obj.location = (0.0, 0.0, 1.9) obj.rotation_euler = (math.radians(4), 0.0, 0.0) # unlit saturated ink wants the graphic transform, not AgX's filmic desaturation scene.view_settings.view_transform = 'Standard' import bmesh 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.045, 0.05, 0.065, 1.0) fb.inputs["Roughness"].default_value = 0.35 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, 6.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 world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.01, 0.012, 0.02, 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) # the strokes are unlit; this only paints a soft halo on the wall behind them light("Halo", (0.0, 3.2, 1.9), 260.0, 7.0, (0.4, 0.45, 1.0), (90, 0, 0)) cam_data = bpy.data.cameras.new("Cam") cam_data.lens = 50.0 cam = bpy.data.objects.new("Cam", cam_data) cam.location = (0.0, -9.5, 1.9) cam.rotation_euler = (math.radians(90), 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_rosette() 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 9 print(f"rendered still {args.output}") print("grease-pencil-rosette 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)