diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index f70c3c1..f937c4e 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -73,6 +73,7 @@ "examples/gltf-skin-roundtrip", "examples/gn-instance-grid", "examples/gn-sdf-remesh", + "examples/gp-lineart-contour", "examples/grease-pencil-rosette", "examples/image-pixels-testcard", "examples/light-link-studio", diff --git a/.github/workflows/blender-smoke.yml b/.github/workflows/blender-smoke.yml index 9cb33f8..e8f38bf 100644 --- a/.github/workflows/blender-smoke.yml +++ b/.github/workflows/blender-smoke.yml @@ -287,6 +287,17 @@ jobs: xvfb-run -a "$BLENDER" --background \ --python examples/grease-pencil-rosette/grease_pencil_rosette.py -- + - name: Shipped example - gp line art contour (LINEART modifier + thickness trap) + run: | + set -euo pipefail + # Check only (no render): GPv3 object + LINEART modifier; asserts + # source_object is load-bearing (clear → 0 strokes), contour+crease + # off → 0, restore recovers silhouette strokes, and thickness is + # 4.5-only (AttributeError on 5.1; portable path is radius). + # Exits non-zero on failure. + xvfb-run -a "$BLENDER" --background \ + --python examples/gp-lineart-contour/gp_lineart_contour.py -- + - name: Shipped example - armature bend (edit_bones + LBS closed form) run: | set -euo pipefail diff --git a/README.md b/README.md index 7551db6..1b5888c 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

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

@@ -36,7 +36,7 @@ ## Overview -This repository ships **12 skills, 6 rules, 2 templates, 17 snippets, and 33 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 34 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. @@ -482,7 +482,7 @@ and every orbit lands on its closed form.

-Context & Grease Pencil — 2 examples +Context & Grease Pencil — 3 examples @@ -514,6 +514,22 @@ 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. + + + + +
+GP Line Art contour: a faceted teal crystal on a dark studio floor outlined in neon cyan Grease Pencil Line Art strokes, proving modifiers.new LINEART with source_object silhouette evaluation + + +### [gp-lineart-contour](examples/gp-lineart-contour/) + +Grease Pencil `LINEART` modifier contours via the depsgraph — not Freestyle and +not hand-drawn strokes. `source_object` is load-bearing (clear → 0 strokes); +contour+crease off → 0; restore recovers **10** strokes / **34** points on both +binaries. Stroke width: `thickness` exists on 4.5, `AttributeError` on 5.1 — +portable path is `radius`. +
diff --git a/ROADMAP.md b/ROADMAP.md index 1a670f7..a37b23e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -115,7 +115,7 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - ~~Collision compound witness~~ **SHIPPED** as `examples/collision-hull-proxy/` — game-prop collision as a compound of convex pieces, each a `bmesh.ops.convex_hull` of a coarse `sec(π/n)`-inflated cage (containment 4.4e-08, watertight, positive signed volume, Euler 2, per-piece 255-face budget: body 70, caps 60×3, compound 250); a hull of the dense render mesh measures 380 faces — over budget — which is why pipelines hull cages; proud details cost cage rows, concave grooves are free; byte-identical on 4.5.11 and 5.1.2 - ~~Custom-normals / shade-by-angle witness~~ **SHIPPED** as `examples/custom-normals-shade/` — the post-4.1 shading contract: `use_auto_smooth`/`use_custom_normals`/`calc_normals` are AttributeError on BOTH 4.5.11 and 5.1.2; `set_sharp_from_angle` sharp sets match an independent dihedral recompute exactly (188/388 edges, 3 meshes); evaluated normal welds/splits exact (0.0 dev); custom split normals survive depsgraph evaluation within int16 quantization (1.407e-04, not float-exact); **divergence**: the legacy `shade_auto_smooth` operator CANCELS headless on 4.5 ("Asset loading is unfinished", mesh untouched, no exception) while 5.1 FINISHES with the Smooth-by-Angle NODES modifier — the data API is the portable path - ~~Sky Texture / sun_elevation witness~~ **SHIPPED** as `examples/sky-texture-sun-elevation/` — World `ShaderNodeTexSky` → Background; `sky_type` is `NISHITA` on 4.5 and `MULTIPLE_SCATTERING` on 5.1 (`NISHITA` gone); `dust_density` is 4.5-only (`aerosol_density` on 5.1); zenith EXR rise 8°→55° measured **2.25x** (5.1.2) / **1.50x** (4.5.11), gate ≥ 1.25; dual-elevation gallery diptych -- Grease Pencil Line Art contour witness: empty GPv3 object + `modifiers.new(..., "LINEART")`; `thickness` on 4.5 vs `radius` on 5.1; evaluated contour stroke counts for a known source mesh +- ~~Grease Pencil Line Art contour witness~~ **SHIPPED** as `examples/gp-lineart-contour/` — GPv3 + `modifiers.new(..., "LINEART")`; `source_object` clear → 0 strokes; contour+crease off → 0; restore recovers **10** strokes / **34** points on 4.5.11 and 5.1.2; `thickness` is 4.5-only (`AttributeError` on 5.1; portable `radius`) - Camera DOF + focus_distance witness: `cam.dof.use_dof`, focus plane vs defocus variance on high-freq cards (Stage deviation — depth needs background content) - Volumetric scatter optical-depth witness: Volume Scatter density → Beer–Lambert transmittance along a known path (Cycles; Stage deviation) - Freestyle SVG / line-set witness: Freestyle line set on a silhouette (full render pass; prefer Line Art first) diff --git a/docs/gallery/assets/gp-lineart-contour-hero.webp b/docs/gallery/assets/gp-lineart-contour-hero.webp new file mode 100644 index 0000000..92a46d6 Binary files /dev/null and b/docs/gallery/assets/gp-lineart-contour-hero.webp differ diff --git a/docs/gallery/contact-sheets/gp-lineart-contour-contact-sheet.webp b/docs/gallery/contact-sheets/gp-lineart-contour-contact-sheet.webp new file mode 100644 index 0000000..16cf01e Binary files /dev/null and b/docs/gallery/contact-sheets/gp-lineart-contour-contact-sheet.webp differ diff --git a/docs/gallery/gp-lineart-contour/index.html b/docs/gallery/gp-lineart-contour/index.html new file mode 100644 index 0000000..5588aa5 --- /dev/null +++ b/docs/gallery/gp-lineart-contour/index.html @@ -0,0 +1,664 @@ + + + + + + gp-lineart-contour — Examples — Blender Developer Tools + + + + + + + + + + + + + + + + + +
+ Examples Gallery +
+ GitHub +
+
+
+

gp-lineart-contour

+

Grease Pencil LINEART modifier contours via the depsgraph on a faceted crystal. source_object is load-bearing (clear yields 0 strokes); contour+crease off yields 0; restore recovers 10 strokes / 34 points on both 4.5.11 and 5.1.2. Stroke width: thickness exists on 4.5, AttributeError on 5.1 — portable path is radius.

+
+
+ +

Rendered headless by the example itself — click to zoom.

+
witnesses AI code still sets LINEART.thickness on 5.1 (AttributeError) or hand-draws strokes / enables Freestyle instead of modifiers.new(..., 'LINEART'). Clearing source_object or leaving contour+crease off silently yields an empty drawing — the check proves both failure modes and recovery.
+
+
blender --background --python examples/gp-lineart-contour/gp_lineart_contour.py --
+ +
+
+

A runnable example that builds a faceted crystal, attaches a Grease Pencil LINEART modifier with source_type='OBJECT', and proves the contour contract through the depsgraph — including the 4.5 → 5.1 stroke-width trap.

+

What it witnesses: the Line Art modifier contract AI-generated NPR code most often gets wrong.

+
  • Contours are a modifier, not Freestyle and not hand-authored strokes. modifiers.new(..., 'LINEART') on a GPv3 object, with target_layer / target_material set, evaluates silhouette edges into drawing strokes.
  • source_object is load-bearing. Clearing it yields 0 evaluated strokes (proven in the same check pass). Restoring it recovers the contour.
  • Edge-type flags matter. A freshly added LINEART with use_contour and use_crease both off emits 0 strokes; turning contour (+ crease) back on recovers the silhouette (10 strokes / 34 points on both binaries for this crystal + camera).
  • Stroke width renamed. 4.5 exposes both thickness (legacy px, set to 45 here) and radius; 5.1 removes thickness (AttributeError) and keeps radius only (portable path: mod.radius = 0.028).
  • GPv3 address matches grease-pencil-rosette: grease_pencils_v3 on 4.5, grease_pencils on 5.x.
+

What each check catches on failure: wrong GPv3 collection (exit 2), thickness present/absent on the wrong side of 5.0 (exit 3), lost source_object assignment (exit 4), contour too thin (exit 5), source clear not zeroing strokes (exit 6), flags-off or restore failure (exit 7).

+

Version witness: stroke counts match on 4.5.11 LTS and 5.1.2 (10 strokes / 34 points). The divergence is thickness vs radius.

+

Run

+
# Depsgraph contour check — the CI check:
+blender --background --python gp_lineart_contour.py --
+
+# Also render the gallery still:
+blender --background --python gp_lineart_contour.py -- --output lineart.png
+

It exits non-zero on failure. The blender-smoke workflow runs the check on Blender 4.5 LTS and 5.1.

+
+
+

Source

+
+ examples/gp-lineart-contour/gp_lineart_contour.py + View on GitHub → +
+
"""Grease Pencil Line Art contours — a runnable example.
+
+Witnesses the Line Art modifier contract AI-generated NPR code most often
+gets wrong across the 4.5 LTS → 5.1 window:
+
+1. Contours come from ``modifiers.new(..., 'LINEART')`` on a GPv3 object,
+   not from freestyle or hand-drawn strokes.
+2. ``source_type='OBJECT'`` + ``source_object`` is load-bearing — clearing
+   the source yields zero evaluated strokes.
+3. ``use_contour`` must be on for silhouette edges; with contour and crease
+   both off the evaluator emits nothing.
+4. Stroke width: 4.5 exposes both ``thickness`` (legacy px) and ``radius``;
+   5.1 removes ``thickness`` (AttributeError) and keeps ``radius`` only.
+5. GPv3 datablock address matches grease-pencil-rosette: ``grease_pencils_v3``
+   on 4.5, ``grease_pencils`` on 5.x.
+
+The check evaluates the modifier through the depsgraph (no bake required)
+and asserts stroke/point lower bounds against the known failure modes above.
+
+By default it runs the correctness check only. Pass --output to render:
+
+    blender --background --python gp_lineart_contour.py --
+    blender --background --python gp_lineart_contour.py -- --output l.png
+"""
+import bpy, bmesh, sys, os, math, argparse
+from mathutils import Matrix
+
+# Faceted crystal (octahedron-ish) — enough silhouette edges to read at thumbnail
+CRYSTAL_SCALE = 1.15
+STROKE_MIN = 1
+POINT_MIN = 4
+RADIUS = 0.028
+
+
+def eevee_engine_id():
+    return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT"
+
+
+def gp_data_new(name):
+    """Version-gated GPv3 datablock creation (same bridge as grease-pencil-rosette)."""
+    if bpy.app.version >= (5, 0, 0):
+        return bpy.data.grease_pencils.new(name)
+    return bpy.data.grease_pencils_v3.new(name)
+
+
+def check_gp_version_gate():
+    if bpy.app.version >= (5, 0, 0):
+        if not hasattr(bpy.data, "grease_pencils"):
+            print("ERROR: grease_pencils missing on 5.x", file=sys.stderr)
+            return 2
+        if hasattr(bpy.data, "grease_pencils_v3"):
+            print("ERROR: grease_pencils_v3 should be gone on 5.x", file=sys.stderr)
+            return 2
+        print("5.x contract: grease_pencils is GPv3; _v3 alias gone")
+    else:
+        if not hasattr(bpy.data, "grease_pencils_v3"):
+            print("ERROR: grease_pencils_v3 missing on 4.5", file=sys.stderr)
+            return 2
+        legacy = bpy.data.grease_pencils.new("LegacyProbe")
+        lframe = legacy.layers.new("L").frames.new(1)
+        if hasattr(lframe, "drawing") or not hasattr(lframe, "strokes"):
+            print("ERROR: 4.5 grease_pencils is not legacy GPencil", file=sys.stderr)
+            return 2
+        bpy.data.grease_pencils.remove(legacy)
+        print("4.5 contract: grease_pencils is legacy; GPv3 lives at _v3")
+    return 0
+
+
+def build_crystal(sc):
+    """A designed faceted crystal — not a raw cube."""
+    me = bpy.data.meshes.new("Crystal")
+    bm = bmesh.new()
+    try:
+        # Dual cone = octahedron-like crystal
+        bmesh.ops.create_cone(
+            bm, cap_ends=False, cap_tris=True,
+            segments=6, radius1=0.85, radius2=0.0, depth=1.4,
+        )
+        # second tip downward
+        geom = bmesh.ops.duplicate(bm, geom=list(bm.verts) + list(bm.edges) + list(bm.faces))
+        verts = [e for e in geom["geom"] if isinstance(e, bmesh.types.BMVert)]
+        bmesh.ops.rotate(
+            bm, verts=verts, cent=(0, 0, 0),
+            matrix=Matrix.Rotation(math.pi, 3, "X"),
+        )
+        bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-4)
+        bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
+        bmesh.ops.scale(bm, vec=(CRYSTAL_SCALE,) * 3, verts=bm.verts)
+        bm.to_mesh(me)
+    finally:
+        bm.free()
+    for p in me.polygons:
+        p.use_smooth = False
+    mat = bpy.data.materials.new("CrystalMat")
+    mat.use_nodes = True
+    b = mat.node_tree.nodes["Principled BSDF"]
+    b.inputs["Base Color"].default_value = (0.12, 0.38, 0.48, 1.0)
+    b.inputs["Roughness"].default_value = 0.42
+    b.inputs["Metallic"].default_value = 0.2
+    emit = b.inputs.get("Emission Color") or b.inputs.get("Emission")
+    if emit is not None:
+        emit.default_value = (0.05, 0.2, 0.28, 1.0)
+        b.inputs["Emission Strength"].default_value = 0.15
+    me.materials.append(mat)
+    ob = bpy.data.objects.new("Crystal", me)
+    ob.location = (0.0, 0.0, 1.15)
+    ob.rotation_euler = (math.radians(8), math.radians(12), math.radians(22))
+    sc.collection.objects.link(ob)
+    return ob
+
+
+def build_lineart(sc, source):
+    gp = gp_data_new("LineArtGP")
+    ob = bpy.data.objects.new("LineArt", gp)
+    sc.collection.objects.link(ob)
+
+    layer = gp.layers.new("Contours")
+    layer.frames.new(1)
+
+    mat = bpy.data.materials.new("NeonContour")
+    bpy.data.materials.create_gpencil_data(mat)
+    # Neon cyan ink
+    gp_set = mat.grease_pencil
+    if hasattr(gp_set, "color"):
+        gp_set.color = (0.05, 0.95, 1.0, 1.0)
+    gp.materials.append(mat)
+
+    mod = ob.modifiers.new("LineArt", "LINEART")
+    mod.source_type = "OBJECT"
+    mod.source_object = source
+    mod.use_contour = True
+    mod.use_crease = True
+    if hasattr(mod, "use_material"):
+        mod.use_material = False
+    mod.target_layer = "Contours"
+    mod.target_material = mat
+    # Width: radius is portable; thickness is 4.5-only
+    if hasattr(mod, "thickness"):
+        mod.thickness = 45
+    mod.radius = RADIUS
+
+    # Disable lights on the GP layer so neon stays vivid
+    if hasattr(layer, "use_lights"):
+        layer.use_lights = False
+    return ob, mod, gp
+
+
+def setup_camera(sc):
+    cam_data = bpy.data.cameras.new("Cam")
+    cam_data.lens = 50.0
+    cam = bpy.data.objects.new("Cam", cam_data)
+    cam.location = (3.4, -4.2, 2.4)
+    sc.collection.objects.link(cam)
+    sc.camera = cam
+    aim = bpy.data.objects.new("Aim", None)
+    aim.location = (0.0, 0.0, 1.15)
+    sc.collection.objects.link(aim)
+    tr = cam.constraints.new("TRACK_TO")
+    tr.target = aim
+    tr.track_axis = "TRACK_NEGATIVE_Z"
+    tr.up_axis = "UP_Y"
+    return cam
+
+
+def eval_stroke_counts(ob):
+    dg = bpy.context.evaluated_depsgraph_get()
+    dg.update()
+    eob = ob.evaluated_get(dg)
+    layer = eob.data.layers[0]
+    frame = layer.frames[0]
+    drawing = frame.drawing
+    strokes = list(drawing.strokes)
+    n_strokes = len(strokes)
+    n_points = sum(len(s.points) for s in strokes)
+    return n_strokes, n_points
+
+
+def check_thickness_trap(mod):
+    if bpy.app.version >= (5, 0, 0):
+        if hasattr(mod, "thickness"):
+            print("ERROR: thickness still present on 5.x LINEART", file=sys.stderr)
+            return 3
+        try:
+            _ = mod.thickness
+            print("ERROR: thickness read did not raise on 5.x", file=sys.stderr)
+            return 3
+        except AttributeError:
+            pass
+        if not hasattr(mod, "radius"):
+            print("ERROR: radius missing on 5.x LINEART", file=sys.stderr)
+            return 3
+        print(f"5.x contract: LINEART.radius={mod.radius} thickness=AttributeError")
+    else:
+        if not hasattr(mod, "thickness"):
+            print("ERROR: thickness missing on 4.5 LINEART", file=sys.stderr)
+            return 3
+        if not hasattr(mod, "radius"):
+            print("ERROR: radius missing on 4.5 LINEART", file=sys.stderr)
+            return 3
+        print(
+            f"4.5 contract: LINEART.thickness={mod.thickness} radius={mod.radius}"
+        )
+    return 0
+
+
+def check(sc, source, la_ob, mod):
+    code = check_gp_version_gate()
+    if code:
+        return code
+    code = check_thickness_trap(mod)
+    if code:
+        return code
+
+    if mod.type != "LINEART":
+        print(f"ERROR: modifier type {mod.type!r} != 'LINEART'", file=sys.stderr)
+        return 4
+    if mod.source_type != "OBJECT":
+        print(f"ERROR: source_type {mod.source_type!r} != 'OBJECT'", file=sys.stderr)
+        return 4
+    if mod.source_object != source:
+        print("ERROR: source_object round-trip failed", file=sys.stderr)
+        return 4
+    if not mod.use_contour:
+        print("ERROR: use_contour should be True", file=sys.stderr)
+        return 4
+
+    # Happy path: contours present
+    n_s, n_p = eval_stroke_counts(la_ob)
+    if n_s < STROKE_MIN or n_p < POINT_MIN:
+        print(
+            f"ERROR: evaluated Line Art too thin: strokes={n_s} points={n_p} "
+            f"(need >= {STROKE_MIN} strokes, >= {POINT_MIN} points)",
+            file=sys.stderr,
+        )
+        return 5
+    print(f"contour_ok strokes={n_s} points={n_p} (gates>={STROKE_MIN},>={POINT_MIN})")
+
+    # Failure mode A: clear source_object → zero strokes
+    saved = mod.source_object
+    mod.source_object = None
+    z_s, z_p = eval_stroke_counts(la_ob)
+    mod.source_object = saved
+    if z_s != 0 or z_p != 0:
+        print(
+            f"ERROR: cleared source_object still produced strokes={z_s} points={z_p}",
+            file=sys.stderr,
+        )
+        return 6
+    print("nosource_ok strokes=0 points=0")
+
+    # Failure mode B: rebuild modifier with contour+crease off → zero strokes
+    while la_ob.modifiers:
+        la_ob.modifiers.remove(la_ob.modifiers[0])
+    mod_off = la_ob.modifiers.new("LineArtOff", "LINEART")
+    mod_off.source_type = "OBJECT"
+    mod_off.source_object = source
+    mod_off.use_contour = False
+    mod_off.use_crease = False
+    if hasattr(mod_off, "use_loose"):
+        mod_off.use_loose = False
+    if hasattr(mod_off, "use_intersection"):
+        mod_off.use_intersection = False
+    if hasattr(mod_off, "use_material"):
+        mod_off.use_material = False
+    if hasattr(mod_off, "use_edge_mark"):
+        mod_off.use_edge_mark = False
+    mod_off.target_layer = "Contours"
+    gp_mat = la_ob.data.materials[0]
+    mod_off.target_material = gp_mat
+    mod_off.radius = RADIUS
+    z_s, z_p = eval_stroke_counts(la_ob)
+    if z_s != 0 or z_p != 0:
+        print(
+            f"ERROR: contour+crease off still produced strokes={z_s} points={z_p}",
+            file=sys.stderr,
+        )
+        return 7
+    print("noflags_ok strokes=0 points=0")
+
+    # Restore working Line Art modifier for optional still
+    while la_ob.modifiers:
+        la_ob.modifiers.remove(la_ob.modifiers[0])
+    mod_on = la_ob.modifiers.new("LineArt", "LINEART")
+    mod_on.source_type = "OBJECT"
+    mod_on.source_object = source
+    mod_on.use_contour = True
+    mod_on.use_crease = True
+    mod_on.target_layer = "Contours"
+    mod_on.target_material = gp_mat
+    if hasattr(mod_on, "thickness"):
+        mod_on.thickness = 45
+    mod_on.radius = RADIUS
+    n_s2, n_p2 = eval_stroke_counts(la_ob)
+    if n_s2 < STROKE_MIN or n_p2 < POINT_MIN:
+        print(
+            f"ERROR: contours did not recover after restore: "
+            f"strokes={n_s2} points={n_p2}",
+            file=sys.stderr,
+        )
+        return 7
+    print(
+        f"restored_ok strokes={n_s2} points={n_p2} radius={mod_on.radius}"
+    )
+    return 0
+
+
+def build_studio(sc):
+    # Default-stage-ish dark floor + wall — Line Art neon is the proof
+    def plane(name, size, loc, rot):
+        me = bpy.data.meshes.new(name)
+        bm = bmesh.new()
+        try:
+            bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=size)
+            bm.to_mesh(me)
+        finally:
+            bm.free()
+        mat = bpy.data.materials.new(name + "M")
+        mat.use_nodes = True
+        b = mat.node_tree.nodes["Principled BSDF"]
+        b.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0)
+        b.inputs["Roughness"].default_value = 0.7
+        me.materials.append(mat)
+        ob = bpy.data.objects.new(name, me)
+        ob.location = loc
+        ob.rotation_euler = rot
+        sc.collection.objects.link(ob)
+        return ob
+
+    plane("Floor", 30.0, (0, 0, 0), (0, 0, 0))
+    plane("Wall", 30.0, (0, 9, 0), (math.radians(90), 0, 0))
+
+    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,
+    )
+    sc.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)
+        sc.collection.objects.link(ob)
+
+    light("Key", (-4, -5, 6), 420.0, 5.0, (1.0, 0.96, 0.9), (48, 0, -35))
+    light("Fill", (5, -3.5, 2.5), 90.0, 9.0, (0.75, 0.85, 1.0), (65, 0, 50))
+    light("Wedge", (2.5, 5.5, 4.0), 280.0, 6.0, (1.0, 0.76, 0.5), (-68, 0, 190))
+
+
+def render_still(sc, path, engine):
+    sc.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id()
+    if engine == "cycles":
+        sc.cycles.device = "CPU"
+        sc.cycles.samples = 64
+        sc.cycles.use_denoising = True
+    else:
+        try:
+            sc.eevee.taa_render_samples = 64
+        except AttributeError:
+            pass
+    sc.render.resolution_x = 1280
+    sc.render.resolution_y = 720
+    sc.render.image_settings.file_format = "PNG"
+    sc.render.filepath = path
+    sc.view_settings.view_transform = "Standard"
+    bpy.ops.render.render(write_still=True)
+    return os.path.exists(path) and os.path.getsize(path) > 0
+
+
+def build_scene():
+    bpy.ops.wm.read_factory_settings(use_empty=True)
+    sc = bpy.context.scene
+    build_studio(sc)
+    crystal = build_crystal(sc)
+    la_ob, mod, gp = build_lineart(sc, crystal)
+    setup_camera(sc)
+    return sc, crystal, la_ob, mod
+
+
+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",
+    )
+    args = p.parse_args(argv)
+
+    print(f"binary version: {bpy.app.version} ({bpy.app.version_string})")
+    sc, crystal, la_ob, mod = build_scene()
+    code = check(sc, crystal, la_ob, mod)
+    if code:
+        return code
+
+    if args.output:
+        if not render_still(sc, os.path.abspath(args.output), args.engine):
+            print("ERROR: render produced no file", file=sys.stderr)
+            return 8
+        print(f"rendered still {args.output}")
+
+    print("gp-lineart-contour 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 4d3a5a5..42f69f3 100644 --- a/docs/gallery/index.html +++ b/docs/gallery/index.html @@ -191,8 +191,10 @@

Examples Gallery

+ + @@ -571,6 +573,17 @@

sky-texture-sun-elevation

View example +
+ + gp-lineart-contour — Grease Pencil LINEART modifier contours via the depsgraph on a faceted crystal. + +
+

gp-lineart-contour

+

Grease Pencil LINEART modifier contours via the depsgraph on a faceted crystal. source_object is load-bearing (clear yields 0 strokes); contour+crease off yields 0; restore recovers 10 strokes / 34 points on both 4.5.11 and 5.1.2. Stroke width: thickness exists on 4.5, AttributeError on 5.1 — portable path is radius.

+

witnesses AI code still sets LINEART.thickness on 5.1 (AttributeError) or hand-draws strokes / enables Freestyle instead of modifiers.new(..., 'LINEART'). Clearing source_object or leaving contour+crease off silently yields an empty drawing — the check proves both failure modes and recovery.

+ View example +
+