From a1f76ab3441a5a528c078294f0ff8aa3ea822a20 Mon Sep 17 00:00:00 2001 From: fOuttaMyPaint Date: Sat, 18 Jul 2026 17:59:13 -0400 Subject: [PATCH] feat: add grease-pencil-rosette example (GPv3 attribute API drift witness) Grease Pencil v3 is the largest bpy API break in the 4.x-to-5.x window and nothing in the gallery touched it. This example draws five nested neon rose curves through the attribute-based GPv3 surface and asserts the divergence each version actually exposes: on 4.5 LTS GPv3 lives at grease_pencils_v3 while bpy.data.grease_pencils is still legacy GPencil (frame.strokes, no .drawing); on 5.x legacy is deleted and GPv3 owns the grease_pencils name. It also witnesses that stroke points are views over lazily materialized attribute layers, round-tripping every closed-form position through the raw POINT buffer. Co-Authored-By: Claude Fable 5 --- .cursor-plugin/plugin.json | 1 + .github/workflows/blender-smoke.yml | 12 + AGENTS.md | 4 +- CLAUDE.md | 6 +- README.md | 21 +- ROADMAP.md | 1 + .../assets/grease-pencil-rosette-hero.webp | Bin 0 -> 26942 bytes docs/gallery/grease-pencil-rosette/index.html | 535 ++++++++++++++++++ docs/gallery/index.html | 12 + examples/gallery.json | 12 + examples/grease-pencil-rosette/README.md | 37 ++ .../grease_pencil_rosette.py | 291 ++++++++++ examples/grease-pencil-rosette/preview.webp | Bin 0 -> 25306 bytes 13 files changed, 925 insertions(+), 7 deletions(-) create mode 100644 docs/gallery/assets/grease-pencil-rosette-hero.webp create mode 100644 docs/gallery/grease-pencil-rosette/index.html create mode 100644 examples/grease-pencil-rosette/README.md create mode 100644 examples/grease-pencil-rosette/grease_pencil_rosette.py create mode 100644 examples/grease-pencil-rosette/preview.webp diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 7900614..6423226 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -68,6 +68,7 @@ "examples/driver-wave", "examples/gn-instance-grid", "examples/gn-sdf-remesh", + "examples/grease-pencil-rosette", "examples/parent-inverse-orrery", "examples/shader-node-group", "examples/shape-key-blend", diff --git a/.github/workflows/blender-smoke.yml b/.github/workflows/blender-smoke.yml index e7f02c5..92d5733 100644 --- a/.github/workflows/blender-smoke.yml +++ b/.github/workflows/blender-smoke.yml @@ -274,3 +274,15 @@ jobs: # on its closed form. Exits non-zero on failure. xvfb-run -a "$BLENDER" --background \ --python examples/parent-inverse-orrery/parent_inverse_orrery.py -- + + - name: Shipped example - grease pencil rosette (GPv3 attribute API) + run: | + set -euo pipefail + # Frame-independent check only (no render): five nested rose curves drawn + # as GPv3 strokes; asserts the version-gated datablock address (4.5: + # grease_pencils_v3 with grease_pencils still legacy; 5.x: grease_pencils + # is GPv3, legacy names gone), lazy attribute-layer materialization from + # point writes, and a closed-form round-trip of every position through + # the raw POINT attribute buffer. Exits non-zero on failure. + xvfb-run -a "$BLENDER" --background \ + --python examples/grease-pencil-rosette/grease_pencil_rosette.py -- diff --git a/AGENTS.md b/AGENTS.md index c659622..9f55e65 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,7 @@ The content base (counts are CI-enforced against README.md and the manifest): - 2 templates: `extension-addon-template` for Extensions Platform add-ons, and `headless-batch-script-template` for unattended batch jobs. - 17 snippets covering canonical patterns. -- 16 examples under `examples//`: runnable scripts that assert a real +- 17 examples under `examples//`: runnable scripts that assert a real API contract with deterministic checks, exit non-zero on failure, and optionally render a still via `--output`. Each is executed headless on Blender 4.5 LTS and 5.1 by `blender-smoke.yml`; its render ships in the @@ -41,7 +41,7 @@ Blender-Developer-Tools/ rules/.mdc # 6 rule files templates// # 2 starter templates snippets/.py # 17 standalone Python snippets - examples// # 16 runnable smoke-gated examples (+ gallery.json) + examples// # 17 runnable smoke-gated examples (+ gallery.json) scripts/build_gallery.py # generates docs/gallery/ (stdlib only) scripts/site/ # vendored landing-page build (build_site.py + template) docs/gallery/ # committed generated gallery pages + hero assets diff --git a/CLAUDE.md b/CLAUDE.md index f01b0c1..0564b28 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,7 @@ skills//SKILL.md - AI workflow definitions, 12 total rules/.mdc - Anti-pattern rules, 6 total templates// - Starter projects, 2 total snippets/.py - Standalone code patterns, 17 total -examples// - Runnable smoke-gated examples, 16 total (+ gallery.json) +examples// - Runnable smoke-gated examples, 17 total (+ gallery.json) scripts/build_gallery.py - Regenerates docs/gallery/ from gallery.json (stdlib only) scripts/site/ - Vendored landing-page build (Jinja2) docs/gallery/ - Committed generated gallery pages + hero renders @@ -80,11 +80,11 @@ v0.1.0: canonical object creation and deletion, depsgraph evaluated mesh, bmesh v0.2.0: Principled BSDF material, driver-with-custom-function via `driver_namespace`, application handler registration, shader node group with cross-version `interface` API, `foreach_get` bulk vertex read, version-branch skeleton, and USD export with `evaluation_mode='RENDER'`. -## Examples (16) +## Examples (17) Runnable scripts at `examples//`, each asserting a real API contract with deterministic checks (exit non-zero on failure) and optionally rendering a still via -`--output`. All sixteen run headless on Blender 4.5 LTS and 5.1 in `blender-smoke.yml`; +`--output`. All seventeen run headless on Blender 4.5 LTS and 5.1 in `blender-smoke.yml`; their renders ship in the site gallery at `docs/gallery/`. `examples/gallery.json` is the gallery's source of truth. When authoring a new one, copy the anatomy of `examples/bmesh-gear/` (script structure, README shape, dark-studio render recipe) and diff --git a/README.md b/README.md index 6512c6a..bc0d673 100644 --- a/README.md +++ b/README.md @@ -17,14 +17,14 @@

- 12 skills  •  6 rules  •  2 templates  •  17 snippets  •  16 examples + 12 skills  •  6 rules  •  2 templates  •  17 snippets  •  17 examples

--- ## Overview -This repository ships **12 skills, 6 rules, 2 templates, 17 snippets, and 16 runnable examples** for Blender Python development targeting Blender 5.1 (current stable) with Blender 4.5 LTS fallback support. +This repository ships **12 skills, 6 rules, 2 templates, 17 snippets, and 17 runnable examples** for Blender Python development targeting Blender 5.1 (current stable) with Blender 4.5 LTS fallback support. The content is consumed by AI coding agents (Cursor, Claude Code, any MCP-capable client) when working on Blender add-ons, geometry nodes scripts, batch pipelines, or animation tooling. There is no build step. Edit the markdown and Python files directly. @@ -280,6 +280,23 @@ carries arms, planets, and a two-level moon through spinning pivots. Asserts bar `.parent =` really teleports, `matrix_world` is stale until `view_layer.update()`, and every orbit lands on its closed form. + + + + +Grease pencil rosette: five nested neon rose curves drawn as tapered Grease Pencil v3 strokes, cyan through magenta to red, glowing against a dark studio wall with a soft blue halo + + + +### [grease-pencil-rosette](examples/grease-pencil-rosette/) + +Five nested rose curves drawn with the Grease Pencil v3 attribute API — layer → +`frames.new().drawing` → `add_strokes` → per-point position, radius, opacity, and +vertex color. Asserts the GPv3 address break: on 4.5 GPv3 is `grease_pencils_v3` +while `grease_pencils` is still legacy; on 5.x legacy is gone and GPv3 owns the +name. Point writes lazily materialize attribute layers, and every position +round-trips through the raw `POINT` buffer. + diff --git a/ROADMAP.md b/ROADMAP.md index 86dcfe3..11ead1b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -97,6 +97,7 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - Refresh the `slotted-actions-animation` skill against any 5.2 changes - Bump `blender_version_min` in the templates if 5.2 APIs are used - Additional snippets for asset library scripting, EXR baking, multi-file extensions +- Gallery coverage follow-ups from the GPv3 review: `armature-bend` (edit_bones + vertex groups + pose evaluation) and `text-version-stamp` (TextCurve self-labeling renders); lower priority: light-linking, bulk `pixels.foreach_set`, VSE sequences-to-strips witness (`grease-pencil-rosette` shipped first as the strongest 4.x-to-5.x drift witness) ## Future (uncommitted) diff --git a/docs/gallery/assets/grease-pencil-rosette-hero.webp b/docs/gallery/assets/grease-pencil-rosette-hero.webp new file mode 100644 index 0000000000000000000000000000000000000000..843ee32ae537c802aa87cee0426b2abc571c631e GIT binary patch literal 26942 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 literal 0 HcmV?d00001 diff --git a/docs/gallery/grease-pencil-rosette/index.html b/docs/gallery/grease-pencil-rosette/index.html new file mode 100644 index 0000000..6fb8d4a --- /dev/null +++ b/docs/gallery/grease-pencil-rosette/index.html @@ -0,0 +1,535 @@ + + + + + + grease-pencil-rosette — Examples — Blender Developer Tools + + + + + + + + + + + + + + + + + + +
+

grease-pencil-rosette

+

Grease Pencil v3's attribute-based API — layer → frames.new().drawing → add_strokes → per-point position/radius/opacity/vertex_color — drawing five nested neon rose curves.

+
+
+ +

Rendered headless by the example itself — click to zoom.

+
witnesses The GPv3 address break: 4.5 keeps GPv3 at grease_pencils_v3 while grease_pencils is still legacy GPencil (frame.strokes, no .drawing); 5.x deletes legacy and GPv3 takes over the grease_pencils name. Point writes lazily materialize attribute layers, and every position round-trips through the raw POINT buffer.
+
+
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).drawingadd_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), while bpy.data.grease_pencils still holds legacy GPencil whose frames carry .strokes directly 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_pencils name, and grease_pencils_v3 / bpy.types.GPencilStroke no 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

+
+ examples/grease-pencil-rosette/grease_pencil_rosette.py + View on GitHub → +
+
"""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)
+
+
+
+ +
+
+ generated from examples/gallery.json + CC-BY-NC-ND-4.0 + exit 0 +
+
+ + + diff --git a/docs/gallery/index.html b/docs/gallery/index.html index a24b72a..2ffc648 100644 --- a/docs/gallery/index.html +++ b/docs/gallery/index.html @@ -186,6 +186,7 @@

Examples Gallery

+ @@ -375,6 +376,17 @@

parent-inverse-orrery

View example +
+ + grease-pencil-rosette — Grease Pencil v3's attribute-based API — layer → frames + +
+

grease-pencil-rosette

+

Grease Pencil v3's attribute-based API — layer → frames.new().drawing → add_strokes → per-point position/radius/opacity/vertex_color — drawing five nested neon rose curves.

+

witnesses The GPv3 address break: 4.5 keeps GPv3 at grease_pencils_v3 while grease_pencils is still legacy GPencil (frame.strokes, no .drawing); 5.x deletes legacy and GPv3 takes over the grease_pencils name. Point writes lazily materialize attribute layers, and every position round-trips through the raw POINT buffer.

+ View example +
+