@@ -36,7 +36,7 @@
## Overview
-This repository ships **12 skills, 6 rules, 2 templates, 17 snippets, and 31 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 32 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.
@@ -504,7 +504,7 @@ round-trips through the raw `POINT` buffer.
-Game asset pipeline — 6 examples
+Game asset pipeline — 7 examples
@@ -610,6 +610,27 @@ tests prove containment (5.9e-08), convexity, watertightness, outward
winding, and Euler characteristic 2 per piece. Proud details cost cage rows;
concave grooves are free.
+
+
+
+
+
+
+
+
+### [custom-normals-shade](examples/custom-normals-shade/)
+
+The shading contract a prop's silhouette depends on: since Blender 4.1,
+hard edges are mesh data (face smooth flags + `sharp_edge` attribute), and
+`use_auto_smooth` / `use_custom_normals` / `calc_normals` are AttributeError
+on **both** 4.5 LTS and 5.1. `set_sharp_from_angle` marks sharp exactly the
+edges an independent dihedral recompute predicts; evaluated loop normals
+weld across smooth edges and split by the dihedral across sharp ones;
+custom split normals survive depsgraph evaluation within their int16
+quantization (1.407e-04, not float-exact). Documents the legacy
+`shade_auto_smooth` operator trap: CANCELLED headless on 4.5, FINISHED
+with the Smooth-by-Angle modifier on 5.1.
+
diff --git a/ROADMAP.md b/ROADMAP.md
index 2b3e782..666dca0 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -113,7 +113,7 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo
- ~~GAMMA_CROSS blend-curve witness~~ **SHIPPED** as `examples/vse-gamma-cross/` — the cross blends in a gamma-0.5 space: `((1-t)·√A + t·√B)²` with `t = (frame − start)/duration`, never 1 inside the effect; mid-cross dips 0.115 below the sRGB lerp from crimson/teal (closed form (0.341, 0.349, 0.463) confirmed per frame); AgX-default sampling poisons the fit (0.146 red-channel error, `view_transform='Standard'` mandatory); deleting a consumed input orphans-and-deletes the effect — follow-up to `vse-cut-list`
- Falsy `bpy_prop_collection` trap snippet: an empty collection is falsy, so `editor.strips or editor.sequences` silently falls through to the legacy accessor on an empty timeline — always branch on `hasattr`; likely generalizes across the API (found authoring `vse-cut-list`)
- ~~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 5.9e-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 374 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 (game prop shading) — `use_auto_smooth` removed in 4.1; assert what 4.5 vs 5.1 actually expose (`shade_smooth_by_angle` operator / Smooth-by-Angle node group), per-loop custom normals surviving depsgraph evaluation, unit-length basis, hard edges landing exactly where an independently recomputed dihedral angle crosses the threshold
+- ~~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
- prop-origin-transform witness — origin to base center, `transform_apply` through the data API, delta transforms, `matrix_parent_inverse` so parented children do not teleport; closed forms: post-apply scale exactly (1,1,1), local bbox min Z == 0, world bbox unchanged (builds on `parent-inverse-orrery`, does not duplicate it)
- mesh-hygiene-audit witness — the engine-ingest checklist as executable contract: no ngons, no loose vertices, no non-manifold edges, no zero-area faces, consistent outward winding; closed forms via Euler characteristic and exact edge-face incidence counts
diff --git a/docs/gallery/assets/custom-normals-shade-hero.webp b/docs/gallery/assets/custom-normals-shade-hero.webp
new file mode 100644
index 0000000..93500a8
Binary files /dev/null and b/docs/gallery/assets/custom-normals-shade-hero.webp differ
diff --git a/docs/gallery/contact-sheets/custom-normals-shade-contact-sheet.webp b/docs/gallery/contact-sheets/custom-normals-shade-contact-sheet.webp
new file mode 100644
index 0000000..de7f469
Binary files /dev/null and b/docs/gallery/contact-sheets/custom-normals-shade-contact-sheet.webp differ
diff --git a/docs/gallery/custom-normals-shade/index.html b/docs/gallery/custom-normals-shade/index.html
new file mode 100644
index 0000000..bbab0b9
--- /dev/null
+++ b/docs/gallery/custom-normals-shade/index.html
@@ -0,0 +1,836 @@
+
+
+
+
+
+ custom-normals-shade — Examples — Blender Developer Tools
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Skip to content
+
A jerry can prop shaded three ways to prove the post-4.1 shading contract: hard edges are mesh data, landing exactly where the dihedral crosses. Face smooth flags plus a sharp_edge attribute, verified against an independently recomputed dihedral test, and per-loop custom normals surviving depsgraph evaluation within their int16 storage quantization (1.407e-04, not float-exact).
+
+
+
+
Rendered headless by the example itself — click to zoom.
+
witnesses use_auto_smooth, use_custom_normals and calc_normals are AttributeError on BOTH 4.5 LTS and 5.1 - AI code still emits them. The legacy shade_auto_smooth operator CANCELS headless on 4.5 (asset load never finishes; mesh untouched; no exception) while 5.1 FINISHES with the Smooth by Angle NODES modifier. By-angle sharp sets match the independent dihedral recompute exactly (188 of 388 edges over 3 meshes).
A runnable example that builds a jerry can prop — rounded-slab shell, pressed X ribs, spout and cap, three-post handle — and verifies the shading contract a game prop's silhouette depends on: which edges read hard and which read smooth is mesh DATA, carried since Blender 4.1 by face smooth flags plus a sharp_edge attribute, following mesh-editing-and-bmesh.
+
Pipeline arc neighbor: collision in collision-hull-proxy (the pair partner — same prop-pipeline audience), tangent space in triangulate-tangents — normal maps are baked against exactly this shading, and engines harden or soften the same edges at ingest.
+
Scope: this witnesses the bpy-level contract a prop pipeline relies on. It is not an engine exporter and does not claim engine/FiveM compatibility — it proves the mesh-data properties such an asset's shading must have.
+
What it witnesses:
+
The legacy shading API is gone — on both supported versions.use_auto_smooth, use_custom_normals, and calc_normals are AttributeError on 4.5 LTS *and* 5.1. AI-generated Blender code still emits mesh.use_auto_smooth = True constantly; any script carrying that habit dies immediately, and this check keeps it dead.
Shade-by-angle is exact.mesh.set_sharp_from_angle(30°) (which also sets the face smooth flags — probed on both versions) marks sharp exactly the edges whose *independently recomputed* dihedral angle crosses the threshold: Shell 48 of 72 edges, rib 12 of 12, neck 128 of 304 — an exact set match, not a count approximation.
The evaluated shading matches the attribute's promise. Through depsgraph evaluation, loop normals across a smooth edge are welded (deviation 0.0, tol 1e-3) and loop normals across a sharp edge carry their face normals split by exactly the dihedral (angle error 0.0 rad, tol 5e-3), all unit length (err 5.6e-08).
Custom split normals survive depsgraph evaluation — with a quantization budget, not float precision: normals_split_custom_set stores per-loop normals in int16, so a 144-loop round-trip reads back within 1.407e-04 (tol 2e-4), unit length within 1.1e-07. Asserting float-exact custom normals is a real bug this check catches.
The divergence: the legacy shade_auto_smooth OPERATOR is a version-split trap. It builds the Smooth-by-Angle node-group modifier from a bundled asset. Headless on 4.5 LTS the asset load never finishes: the op returns {'CANCELLED'} — no exception — and the mesh stays untouched (measured: 0 smooth faces, 0 modifiers). Any script that ignores the return set ships flat shading and never knows. On 5.1 it FINISHES and adds the Smooth by Angle NODES modifier. The portable path is the data API above, version-gated here explicitly.
+
What each check catches on failure: author/audit threshold drift (probe: sharp marks applied at 20° but audited at 30°, exit 5, 4 extra edges); the two halves of the contract out of sync (probe: sharp_edge set but face smooth flags lost, exit 6, smooth-edge loops split by 3.83e-01); float-exactness assumed of custom normals (probe: tolerance 1e-6, exit 7, measured 1.407e-04); and any future version that resurrects the legacy API or changes the operator's headless behavior (exit 3/8).
+
Version witness: every value above is identical on Blender 4.5.11 LTS and 5.1.2 except the shade_auto_smooth operator behavior, which is asserted per version (CANCELLED + untouched on 4.5, FINISHED + NODES modifier on 5.1).
+
The render shows the same can shaded three ways — flat (faceted corners), smooth-everywhere (the smeared AI bug: ribs melt, highlights warp at the rim), and by-angle (the contract: smooth walls, crisp edges) — under a strip light whose reflection exposes every normal discontinuity.
+
Run
+
# Cheap correctness check (no render) — the CI check:
+blender --background --python custom_normals_shade.py --
+
+# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
+blender --background --python custom_normals_shade.py -- --output cans.png
+blender --background --python custom_normals_shade.py -- --output cans.png --engine cycles
+
It exits non-zero on failure (legacy API resurrected, sharp-set/dihedral mismatch, broken normal welds, custom normals lost or dequantized in evaluation, or legacy-operator divergence drift). The blender-smoke workflow runs the check on Blender 4.5 LTS and 5.1.
+
+
+
Source
+
+ examples/custom-normals-shade/custom_normals_shade.py
+ View on GitHub →
+
+
"""A jerry can prop shaded three ways — a runnable example.
+
+Witnesses the shading contract a game prop's silhouette depends on (engines
+generally, FiveM/GTA-style prop workflows specifically): which edges read
+hard and which read smooth is mesh DATA, and since Blender 4.1 it is carried
+by face smooth flags plus a `sharp_edge` attribute — `use_auto_smooth` is
+gone. AI-generated Blender code still emits the pre-4.1 API
+(`mesh.use_auto_smooth = True`, `bpy.ops.object.shade_auto_smooth()`), so
+this example asserts what the supported versions actually expose:
+
+ legacy API — use_auto_smooth, use_custom_normals, calc_normals are
+ AttributeError on BOTH 4.5 LTS and 5.1
+ by-angle data — `mesh.set_sharp_from_angle(angle)` + face smooth flags:
+ the sharp set lands EXACTLY where an independently
+ recomputed dihedral angle crosses the threshold
+ normal welds — through depsgraph evaluation, loops across a smooth edge
+ share one normal (welded) and loops across a sharp edge
+ carry their face normals (split by the dihedral)
+ custom normals— per-loop normals set with `normals_split_custom_set`
+ survive depsgraph evaluation within the int16 storage
+ quantization (~7.5e-05 measured, tol 2e-4), unit length
+ divergence — the legacy `shade_auto_smooth` OPERATOR needs the bundled
+ Smooth-by-Angle node-group asset: headless on 4.5 LTS it
+ returns {'CANCELLED'} ("Asset loading is unfinished") and
+ the mesh is UNTOUCHED — silent flat shading for any script
+ that ignores the return set; on 5.1 it FINISHES and adds
+ the NODES modifier. The portable path is the data API.
+
+By default it runs only the correctness check (no render) — the CI smoke
+check. Pass --output to also render a still (the same can shaded flat /
+smooth-everywhere / by-angle, so a broken path reads as faceting or smear):
+
+ blender --background --python custom_normals_shade.py -- # check only
+ blender --background --python custom_normals_shade.py -- --output c.png # + render
+"""
+import bpy, bmesh, sys, os, math, argparse
+from mathutils import Vector
+
+ANGLE_DEG = 30.0# shade-by-angle threshold
+ANGLE = math.radians(ANGLE_DEG)
+TOL_NORMAL = 2e-4# custom-normal readback: int16 storage quantizes to ~7.5e-05
+TOL_UNIT = 1e-6# unit-length tolerance for evaluated normals (measured 4.5e-08)
+TOL_SMOOTH = 1e-3# loop-normal equality across a welded (smooth) edge
+TOL_SHARP = 5e-3# radians: split-normal angle vs dihedral across a sharp edge
+
+# ---------------------------------------------------------------------------
+# Prop construction. A 20-unit jerry can: rounded-slab shell, pressed X ribs
+# floating on both faces, spout neck + cap, three-post carry handle. All
+# dimensions invented for this prop; ribs are floater prisms, the game-prop
+# norm — every checked mesh is itself closed and manifold.
+# ---------------------------------------------------------------------------
+
+SHELL_W, SHELL_H, SHELL_D, SHELL_R = 1.4, 1.8, 0.62, 0.28
+
+
+def signed_volume(me):
+ """Divergence-theorem volume; positive when face winding points outward."""
+ vol = 0.0
+ for p in me.polygons:
+ vs = [me.vertices[i].co for i in p.vertices]
+ v0 = vs[0]
+ for i in range(1, len(vs) - 1):
+ vol += v0.dot(vs[i].cross(vs[i + 1])) / 6.0
+ return vol
+
+
+def finish_mesh(name, bm):
+ me = bpy.data.meshes.new(name)
+ try:
+ bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
+ bm.to_mesh(me)
+ finally:
+ bm.free()
+ obj = bpy.data.objects.new(name, me)
+ bpy.context.collection.objects.link(obj)
+ if signed_volume(me) < 0.0: # pin outward winding by the closed form
+ for p in me.polygons:
+ p.flip()
+ return obj
+
+
+def rounded_rect_profile(w, h, r, segs):
+ """(x, z) loop of a rounded rectangle, z from 0 to h, CCW seen from -Y."""
+ pts = []
+ for cx, cz, a0 in ((w / 2 - r, r, -90), (w / 2 - r, h - r, 0),
+ (-(w / 2 - r), h - r, 90), (-(w / 2 - r), r, 180)):
+ for i in range(segs):
+ a = math.radians(a0 + 90.0 * i / segs)
+ pts.append((cx + r * math.cos(a), cz + r * math.sin(a)))
+ return pts
+
+
+def build_shell():
+ """Loft the rounded-rect profile along Y; caps are the end ngons."""
+ prof = rounded_rect_profile(SHELL_W, SHELL_H, SHELL_R, 6)
+ n = len(prof)
+ y0, y1 = -SHELL_D / 2, SHELL_D / 2
+ bm = bmesh.new()
+ front = [bm.verts.new((x, y0, z)) for x, z in prof]
+ back = [bm.verts.new((x, y1, z)) for x, z in prof]
+ for i in range(n):
+ j = (i + 1) % n
+ bm.faces.new((front[i], back[i], back[j], front[j]))
+ bm.faces.new(front)
+ bm.faces.new(back)
+ return finish_mesh("Shell", bm)
+
+
+def build_rib(name, p0, p1, face_y, outward):
+ """Trapezoid-section pressed rib from p0 to p1 (x, z), raised off the
+ face plane by `outward` (-1 front, +1 back)."""
+ wb, wt, hgt = 0.09, 0.06, 0.045
+ d = Vector((p1[0] - p0[0], 0.0, p1[1] - p0[1])).normalized()
+ side = Vector((-d.z, 0.0, d.x))
+ up = Vector((0.0, outward, 0.0))
+ bm = bmesh.new()
+ ends = []
+ for px, pz in (p0, p1):
+ c = Vector((px, face_y, pz))
+ ends.append([bm.verts.new(c - side * wb), bm.verts.new(c + side * wb),
+ bm.verts.new(c + side * wt + up * hgt),
+ bm.verts.new(c - side * wt + up * hgt)])
+ a, b = ends
+ bm.faces.new((a[0], a[1], a[2], a[3]))
+ bm.faces.new((b[1], b[0], b[3], b[2]))
+ bm.faces.new((a[1], b[1], b[2], a[2]))
+ bm.faces.new((a[3], b[3], b[0], a[0]))
+ bm.faces.new((a[0], b[0], b[1], a[1]))
+ bm.faces.new((a[2], b[2], b[3], a[3]))
+ return finish_mesh(name, bm)
+
+
+def lathe(name, profile, segments):
+ """Spin an (r, z) profile around Z; r == 0 ends become poles."""
+ bm = bmesh.new()
+ bot = top = None
+ if profile[0][0] == 0.0:
+ bot = bm.verts.new((0.0, 0.0, profile[0][1]))
+ profile = profile[1:]
+ if profile[-1][0] == 0.0:
+ top = bm.verts.new((0.0, 0.0, profile[-1][1]))
+ profile = profile[:-1]
+ rings = []
+ for i in range(segments):
+ a = 2.0 * math.pi * i / segments
+ rings.append([bm.verts.new((r * math.cos(a), r * math.sin(a), z))
+ for r, z in profile])
+ for i in range(segments):
+ j = (i + 1) % segments
+ for k in range(len(profile) - 1):
+ bm.faces.new((rings[i][k], rings[j][k], rings[j][k + 1], rings[i][k + 1]))
+ if bot isnotNone:
+ bm.faces.new((rings[j][0], rings[i][0], bot))
+ if top isnotNone:
+ bm.faces.new((rings[i][-1], rings[j][-1], top))
+ return finish_mesh(name, bm)
+
+
+def build_jerry_can():
+ """All parts at world placement; returns {"shell", "rib", "neck", "parts"}."""
+ shell = build_shell()
+ parts = [shell]
+
+ ribs = []
+ inset = [(0.46, 0.22), (0.46, 1.58)]
+ specs = [((inset[0], ( -inset[0][0], inset[1][1])), "RibDiagA"),
+ (((-inset[0][0], inset[0][1]), inset[1]), "RibDiagB"),
+ (((0.0, 0.18), (0.0, 1.62)), "RibVert")]
+ for fy, out, tag in ((-SHELL_D / 2, -1.0, "F"), (SHELL_D / 2, 1.0, "B")):
+ for (p0, p1), rn in specs:
+ ribs.append(build_rib(f"{rn}{tag}", p0, p1, fy, out))
+ parts.extend(ribs)
+
+ neck = lathe("Neck", [(0.0, 0.0), (0.21, 0.0), (0.21, 0.05), (0.175, 0.07),
+ (0.165, 0.10), (0.165, 0.16), (0.185, 0.17),
+ (0.185, 0.20), (0.165, 0.21), (0.165, 0.24), (0.0, 0.24)], 16)
+ neck.location = (0.42, -0.12, 1.75)
+ neck.rotation_euler = (math.radians(-8.0), 0.0, 0.0)
+ cap = lathe("Cap", [(0.0, 0.0), (0.205, 0.0), (0.205, 0.04), (0.19, 0.05),
+ (0.19, 0.11), (0.14, 0.14), (0.0, 0.145)], 16)
+ cap.location = (0.42, -0.0866, 1.9877)
+ cap.rotation_euler = (math.radians(-8.0), 0.0, 0.0)
+ parts.extend((neck, cap))
+
+ for i, x in enumerate((-0.28, 0.0, 0.28)):
+ post = lathe(f"Post{i}", [(0.0, 0.0), (0.055, 0.0), (0.055, 0.17), (0.0, 0.17)], 16)
+ post.location = (x, 0.08, 1.79)
+ parts.append(post)
+ grip = lathe("Grip", [(0.0, 0.0), (0.06, 0.0), (0.06, 0.68), (0.0, 0.68)], 16)
+ grip.rotation_euler = (0.0, math.radians(90.0), 0.0)
+ grip.location = (-0.34, 0.08, 1.96)
+ parts.append(grip)
+
+ return {"shell": shell, "rib": ribs[0], "neck": neck, "parts": parts}
+
+
+# ---------------------------------------------------------------------------
+# The contract checks.
+# ---------------------------------------------------------------------------
+
+def manifold_dihedrals(me):
+ """Independent recompute: {vertex-pair key: (degrees, v1, v2)} for edges
+ with exactly two link faces, plus the count of non-manifold edges."""
+ bm = bmesh.new()
+ out = {}
+ nonmanifold = 0
+ try:
+ bm.from_mesh(me)
+ for e in bm.edges:
+ if len(e.link_faces) != 2:
+ nonmanifold += 1
+ continue
+ deg = math.degrees(e.link_faces[0].normal.angle(e.link_faces[1].normal))
+ v1, v2 = e.verts[0].index, e.verts[1].index
+ out[frozenset((v1, v2))] = (deg, v1, v2)
+ finally:
+ bm.free()
+ return out, nonmanifold
+
+
+def sharp_edge_keys(me):
+ attr = me.attributes.get("sharp_edge")
+ if attr isNone:
+ return set()
+ return {frozenset((me.edges[i].vertices[0], me.edges[i].vertices[1]))
+ for i, d in enumerate(attr.data) if d.value}
+
+
+def check_api_surface(me):
+ """The legacy shading API is gone on every supported version."""
+ for gone in ("use_auto_smooth", "use_custom_normals", "calc_normals"):
+ if hasattr(me, gone):
+ print(f"ERROR: mesh still exposes {gone} on {bpy.app.version_string} — "
+ f"the pre-4.1 shading API must stay removed", file=sys.stderr)
+ return3
+ for needed in ("normals_split_custom_set", "normals_split_custom_set_from_vertices",
+ "set_sharp_from_angle", "corner_normals", "has_custom_normals"):
+ ifnot hasattr(me, needed):
+ print(f"ERROR: mesh lacks {needed} on {bpy.app.version_string}",
+ file=sys.stderr)
+ return3
+ print(f"api-surface: use_auto_smooth/use_custom_normals/calc_normals absent, "
+ f"modern path present ({bpy.app.version_string})")
+ return0
+
+
+def check_by_angle(objs):
+ """set_sharp_from_angle must mark exactly the edges whose independently
+ recomputed dihedral crosses the threshold — on every checked mesh."""
+ total_sharp = total_manifold = 0
+ for obj in objs:
+ me = obj.data
+ for p in me.polygons:
+ p.use_smooth = True
+ me.set_sharp_from_angle(angle=ANGLE)
+ dih, nonmanifold = manifold_dihedrals(me)
+ if nonmanifold:
+ print(f"ERROR: {obj.name}: {nonmanifold} non-manifold edge(s) — the "
+ f"dihedral test is undefined there", file=sys.stderr)
+ return4
+ expect = {k for k, (deg, _, _) in dih.items() if deg > ANGLE_DEG}
+ got = sharp_edge_keys(me)
+ if got != expect:
+ only_got = len(got - expect)
+ only_exp = len(expect - got)
+ print(f"ERROR: {obj.name}: sharp set mismatch vs independent dihedral "
+ f"test ({only_got} extra, {only_exp} missing of {len(expect)}"
+ f"expected)", file=sys.stderr)
+ return5
+ total_sharp += len(got)
+ total_manifold += len(dih)
+ print(f"by-angle {obj.name}: edges={len(dih)} sharp={len(got)}"
+ f"matches independent dihedral recompute (>{ANGLE_DEG:.0f}deg)")
+ print(f"by-angle: {len(objs)} meshes, {total_manifold} manifold edges, "
+ f"{total_sharp} sharp, exact set match")
+ return0
+
+
+def check_normal_welds(obj):
+ """Through depsgraph evaluation: loops across a smooth edge share one
+ normal; loops across a sharp edge carry their face normals (split by the
+ dihedral). The rendered shading, verified — not the attribute's say-so."""
+ me = obj.data
+ dih, _ = manifold_dihedrals(me)
+ sharp = sharp_edge_keys(me)
+ dg = bpy.context.evaluated_depsgraph_get()
+ ev = obj.evaluated_get(dg).to_mesh()
+ try:
+ # per manifold edge, per endpoint vertex, per polygon: the loop normal
+ loop_normal = [tuple(l.normal) for l in ev.loops]
+ poly_of_loop = [0] * len(ev.loops)
+ for p in ev.polygons:
+ for li in range(p.loop_start, p.loop_start + p.loop_total):
+ poly_of_loop[li] = p.index
+ by_edge = {}
+ for li, l in enumerate(ev.loops):
+ pi, vi = poly_of_loop[li], l.vertex_index
+ for vj in ev.polygons[pi].vertices:
+ if vj == vi:
+ continue
+ k = frozenset((vi, vj))
+ if k in dih:
+ by_edge.setdefault(k, {}).setdefault(vi, {})[pi] = loop_normal[li]
+ max_smooth = 0.0
+ max_sharp = 0.0
+ unit_worst = 0.0
+ for k, (deg, v1, v2) in dih.items():
+ for v in (v1, v2):
+ sides = by_edge.get(k, {}).get(v, {})
+ if len(sides) != 2:
+ print(f"ERROR: edge {tuple(sorted(k))} endpoint v{v}: expected "
+ f"loop normals on both sides, got {len(sides)}", file=sys.stderr)
+ return6
+ n1, n2 = (Vector(s) for s in sides.values())
+ unit_worst = max(unit_worst, abs(n1.length - 1.0), abs(n2.length - 1.0))
+ if k in sharp:
+ err = abs(n1.angle(n2) - math.radians(deg))
+ max_sharp = max(max_sharp, err)
+ else:
+ max_smooth = max(max_smooth, (n1 - n2).length)
+ if unit_worst > TOL_UNIT:
+ print(f"ERROR: evaluated normal off unit length by {unit_worst:.3e}"
+ f"(tol {TOL_UNIT})", file=sys.stderr)
+ return6
+ if max_smooth > TOL_SMOOTH:
+ print(f"ERROR: smooth edge not welded: loop normals differ by "
+ f"{max_smooth:.3e} (tol {TOL_SMOOTH})", file=sys.stderr)
+ return6
+ if max_sharp > TOL_SHARP:
+ print(f"ERROR: sharp edge split {max_sharp:.6f} rad off its dihedral "
+ f"(tol {TOL_SHARP})", file=sys.stderr)
+ return6
+ print(f"normal-welds {obj.name}: smooth max deviation {max_smooth:.3e}"
+ f"(tol {TOL_SMOOTH}), sharp max angle err {max_sharp:.3e} rad "
+ f"(tol {TOL_SHARP}), unit err {unit_worst:.3e}")
+ finally:
+ obj.evaluated_get(dg).to_mesh_clear()
+ return0
+
+
+def check_custom_normals_roundtrip(obj):
+ """Per-loop custom normals survive depsgraph evaluation. Tolerance is the
+ int16 storage quantization, measured 7.5e-05 on both versions."""
+ me = obj.data
+ n = len(me.loops)
+ custom = []
+ for i in range(n):
+ a = 2.0 * math.pi * i / n
+ custom.append(Vector((0.5 * math.cos(a), 0.5 * math.sin(a), 0.85)).normalized())
+ me.normals_split_custom_set(custom)
+ ifnot me.has_custom_normals:
+ print("ERROR: has_custom_normals False after normals_split_custom_set — "
+ "no use_custom_normals flag exists to flip anymore", file=sys.stderr)
+ return7
+ bpy.context.view_layer.update()
+ dg = bpy.context.evaluated_depsgraph_get()
+ ev = obj.evaluated_get(dg).to_mesh()
+ try:
+ err = max((Vector(tuple(l.normal)) - custom[i]).length
+ for i, l in enumerate(ev.loops))
+ unit = max(abs(Vector(tuple(l.normal)).length - 1.0) for l in ev.loops)
+ finally:
+ obj.evaluated_get(dg).to_mesh_clear()
+ if err > TOL_NORMAL or unit > TOL_UNIT:
+ print(f"ERROR: custom normals lost in evaluation: max_err {err:.3e}"
+ f"(tol {TOL_NORMAL}), unit err {unit:.3e}", file=sys.stderr)
+ return7
+ print(f"custom-normals: {n} loops survive depsgraph evaluation, "
+ f"max_err {err:.3e} (tol {TOL_NORMAL}), unit err {unit:.3e}")
+ return0
+
+
+def check_legacy_operator():
+ """The shade_auto_smooth OPERATOR is a version-split trap: it builds the
+ Smooth-by-Angle node-group modifier from a bundled asset. Headless on
+ 4.5 LTS the asset load never finishes and the op CANCELS — silently, no
+ exception — leaving flat shading. On 5.1 it FINISHES with the modifier."""
+ me = bpy.data.meshes.new("LegacyProbe")
+ bm = bmesh.new()
+ try:
+ bmesh.ops.create_cube(bm, size=1.0)
+ bm.to_mesh(me)
+ finally:
+ bm.free()
+ obj = bpy.data.objects.new("LegacyProbe", me)
+ bpy.context.collection.objects.link(obj)
+ bpy.context.view_layer.objects.active = obj
+ obj.select_set(True)
+ try:
+ result = bpy.ops.object.shade_auto_smooth(angle=ANGLE)
+ except Exception as e:
+ print(f"ERROR: shade_auto_smooth raised {type(e).__name__}: {e}",
+ file=sys.stderr)
+ return8
+ mods = [(m.name, m.type) for m in obj.modifiers]
+ smooth = sum(1for p in me.polygons if p.use_smooth)
+ bpy.data.objects.remove(obj)
+ bpy.data.meshes.remove(me)
+ if bpy.app.version >= (5, 0, 0):
+ ok = result == {'FINISHED'} and any(t == 'NODES'for _, t in mods) and smooth > 0
+ detail = f"expect FINISHED + Smooth-by-Angle NODES modifier, got {result} mods={mods} smooth={smooth}"
+ else:
+ ok = result == {'CANCELLED'} andnot mods and smooth == 0
+ detail = f"expect CANCELLED headless (asset load never finishes) + untouched mesh, got {result} mods={mods} smooth={smooth}"
+ ifnot ok:
+ print(f"ERROR: legacy-operator divergence drifted: {detail}", file=sys.stderr)
+ return8
+ print(f"legacy-op ({bpy.app.version_string}): {detail} — as asserted")
+ return0
+
+
+# ---------------------------------------------------------------------------
+# Render: the same can three ways — flat (faceting), smooth-everywhere (the
+# smeared AI bug), by-angle (the contract). Failure modes flank the truth.
+# ---------------------------------------------------------------------------
+
+def eevee_engine_id():
+ return'BLENDER_EEVEE'if bpy.app.version >= (5, 0, 0) else'BLENDER_EEVEE_NEXT'
+
+
+def make_material(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, 1.0)
+ bsdf.inputs["Metallic"].default_value = metallic
+ bsdf.inputs["Roughness"].default_value = roughness
+ return mat
+
+
+def variant_meshes(parts, mode):
+ """Fresh mesh copies with one shading treatment applied."""
+ out = []
+ for o in parts:
+ me = o.data.copy()
+ me.name = f"{o.name}_{mode}"
+ for p in me.polygons:
+ p.use_smooth = mode != "flat"
+ if mode == "byangle":
+ me.set_sharp_from_angle(angle=ANGLE)
+ dup = bpy.data.objects.new(f"{o.name}_{mode}", me)
+ dup.location = o.location
+ dup.rotation_euler = o.rotation_euler
+ bpy.context.collection.objects.link(dup)
+ out.append(dup)
+ return out
+
+
+def render_still(can, path, engine):
+ scene = bpy.context.scene
+ # semi-gloss painted steel: rough enough to read as paint, glossy enough
+ # that a highlight exposes every normal discontinuity — matte would hide
+ # the very shading differences this render exists to show
+ steel = make_material("OliveDrab", (0.31, 0.35, 0.15), 0.8, 0.26)
+ accent = make_material("FuelMarker", (0.55, 0.06, 0.03), 0.3, 0.30)
+
+ # the checked base can IS the by-angle variant; the two failure modes are
+ # mesh copies with their own shading, no materials yet
+ variants = []
+ for mode, x in (("flat", -1.5), ("smooth", 0.0), ("byangle", 1.5)):
+ objs = variant_meshes(can["parts"], mode)
+ for o in objs:
+ o.location.x += x
+ o.rotation_euler.z += math.radians(-10.0) # uniform yaw, fronts to camera
+ o.data.materials.append(steel)
+ if o.name.startswith("Cap"):
+ o.data.materials.append(accent)
+ for p in o.data.polygons: # cap rim ring in the marker red
+ if p.center.z < 0.045:
+ p.material_index = 1
+ variants.append(objs)
+ # hide the checked originals: the byangle variant re-shows the same data
+ for o in can["parts"]:
+ o.hide_render = 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()
+ fmat = make_material("Studio", (0.03, 0.032, 0.037), 0.0, 0.7)
+ 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, 11.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.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)
+
+ # key/fill/rim/wedge per docs/VISUAL-STYLE.md
+ light("Key", (-4.0, -5.0, 6.0), 520.0, 4.5, (1.0, 0.96, 0.9), (50, 0, -38))
+ light("Fill", (5.5, -3.5, 2.5), 120.0, 9.0, (0.75, 0.85, 1.0), (65, 0, 55))
+ light("Rim", (1.5, 4.5, 4.0), 340.0, 3.0, (0.6, 0.78, 1.0), (-58, 0, 170))
+ light("Wedge", (2.5, 5.5, 4.0), 400.0, 6.0, (1.0, 0.76, 0.5), (-68, 0, 190))
+ # the shading audit light: a tall strip whose reflection runs down each
+ # can face — it stair-steps on flat shading, warps at the rim on
+ # smooth-everything, and stays straight with crisp edges on by-angle
+ ld = bpy.data.lights.new("Strip", 'AREA')
+ ld.energy = 460.0
+ ld.shape = 'RECTANGLE'
+ ld.size = 1.0
+ ld.size_y = 8.0
+ ld.color = (0.9, 0.95, 1.0)
+ strip = bpy.data.objects.new("Strip", ld)
+ strip.location = (-3.5, -4.5, 3.2)
+ strip.rotation_euler = (math.radians(60), 0.0, math.radians(-30))
+ scene.collection.objects.link(strip)
+
+ cam_data = bpy.data.cameras.new("Cam")
+ cam_data.lens = 50.0
+ cam = bpy.data.objects.new("Cam", cam_data)
+ cam.location = (1.9, -7.2, 2.6)
+ scene.collection.objects.link(cam)
+ target = bpy.data.objects.new("Aim", None)
+ target.location = (0.0, 0.0, 1.05)
+ scene.collection.objects.link(target)
+ con = cam.constraints.new('TRACK_TO')
+ con.target = target
+ scene.camera = cam
+
+ scene.render.engine = 'CYCLES'if engine == 'cycles'else eevee_engine_id()
+ if engine == 'cycles':
+ scene.cycles.samples = 48
+ 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 olive drab toward mud (docs/VISUAL-STYLE.md)
+ scene.view_settings.view_transform = 'Standard'
+ 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)
+
+ bpy.ops.wm.read_factory_settings(use_empty=True)
+ can = build_jerry_can()
+
+ for step in (lambda: check_api_surface(can["shell"].data),
+ lambda: check_by_angle([can["shell"], can["rib"], can["neck"]]),
+ lambda: check_normal_welds(can["shell"]),
+ lambda: check_custom_normals_roundtrip(can["shell"]),
+ check_legacy_operator):
+ code = step()
+ if code:
+ return code
+
+ if args.output:
+ ifnot render_still(can, os.path.abspath(args.output), args.engine):
+ print("ERROR: render produced no file", file=sys.stderr)
+ return9
+ print(f"rendered still {args.output}")
+
+ print("custom-normals-shade OK")
+ return0
+
+
+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)
+
A jerry can prop shaded three ways to prove the post-4.1 shading contract: hard edges are mesh data, landing exactly where the dihedral crosses. Face smooth flags plus a sharp_edge attribute, verified against an independently recomputed dihedral test, and per-loop custom normals surviving depsgraph evaluation within their int16 storage quantization (1.407e-04, not float-exact).
+
witnesses use_auto_smooth, use_custom_normals and calc_normals are AttributeError on BOTH 4.5 LTS and 5.1 - AI code still emits them. The legacy shade_auto_smooth operator CANCELS headless on 4.5 (asset load never finishes; mesh untouched; no exception) while 5.1 FINISHES with the Smooth by Angle NODES modifier. By-angle sharp sets match the independent dihedral recompute exactly (188 of 388 edges over 3 meshes).