diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json
index 7382844..f24c4b9 100644
--- a/.cursor-plugin/plugin.json
+++ b/.cursor-plugin/plugin.json
@@ -80,6 +80,7 @@
"examples/swatch-grid",
"examples/temp-override-join",
"examples/text-version-stamp",
+ "examples/triangulate-tangents",
"examples/turntable",
"examples/uv-layer-grid",
"examples/vertex-weight-limit",
diff --git a/.github/workflows/blender-smoke.yml b/.github/workflows/blender-smoke.yml
index 86d7bf5..13ad5ca 100644
--- a/.github/workflows/blender-smoke.yml
+++ b/.github/workflows/blender-smoke.yml
@@ -404,3 +404,15 @@ jobs:
# deform layer, and a pinned Root mount. Exits non-zero on failure.
xvfb-run -a "$BLENDER" --background \
--python examples/vertex-weight-limit/vertex_weight_limit.py --
+
+ - name: Shipped example - triangulate + tangents (mikktspace contract)
+ run: |
+ set -euo pipefail
+ # Check only (no render): a lathed buckler with polar/strip/planar
+ # UV charts; asserts closed-form loop-triangle count, unit and
+ # orthogonal tangent frames, bitangent == sign*(n x t), tangents
+ # matching the independent edge/UV-delta formula on smooth-field
+ # triangles, zero flips off seams, and the re-fetched UV layer
+ # surviving calc_tangents. Exits non-zero on failure.
+ xvfb-run -a "$BLENDER" --background \
+ --python examples/triangulate-tangents/triangulate_tangents.py --
diff --git a/README.md b/README.md
index ab4aaac..9fcfdda 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@
- 12 skills • 6 rules • 2 templates • 17 snippets • 26 examples
+ 12 skills • 6 rules • 2 templates • 17 snippets • 27 examples
@@ -36,7 +36,7 @@
## Overview
-This repository ships **12 skills, 6 rules, 2 templates, 17 snippets, and 26 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 27 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.
@@ -472,7 +472,7 @@ round-trips through the raw `POINT` buffer.
-Game asset pipeline — 3 examples
+Game asset pipeline — 4 examples
@@ -525,6 +525,23 @@ boots really carry five influences, no vertex ends over the cap, weights still
sum to one, the pose survives pruning, and the modifier is still exact linear
blend skinning read back from the mesh's own deform layer.
+
+
+
+
+
+
+
+
+### [triangulate-tangents](examples/triangulate-tangents/)
+
+The normal-mapping tangent-space contract: deterministic triangulation
+(`calc_tangents` aborts on any ngon), unit orthogonal tangent frames,
+`bitangent == sign * (n x t)`, and mikktspace matching the independent
+edge/UV-delta formula on smooth fields. Documents the planar-on-cylinder UV
+degeneracy (tangent collapses onto the normal) and the stale layer-handle
+hazard that silently corrupts measurements on 4.5.
+
diff --git a/ROADMAP.md b/ROADMAP.md
index f4ffecd..bc03253 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -106,7 +106,8 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo
- ~~glTF export round-trip witness~~ **SHIPPED** as `examples/gltf-export-roundtrip/` — `export_yup` bakes `(x,y,z)→(x,z,−y)` into vertex data with no node rotation (probed identical on 4.5.11 and 5.1.2), `export_apply` ships the evaluated mesh (one disk vertex per evaluated loop), TEXCOORD_0 is V-flipped on disk, per-triangle material bindings survive; exporter/importer RNA signatures byte-identical between 4.5.11 and 5.1.2 (guarded against future renames); `Mesh.calc_normals()` removal on 5.x surfaced during authoring
- ~~LOD decimate chain witness~~ **SHIPPED** as `examples/lod-decimate-chain/` — Decimate COLLAPSE evaluated through the depsgraph is non-destructive (obj.data keeps closed-form counts), evaluated tris hit `ratio × base` within 5% (measured 0.0–0.44%), silhouette bbox survives within 1e-3 (measured 7.7e-6); a stacked Decimate halves the effective ratio, an aggressive 0.02 ratio collapses the nose tip — both caught failure modes
- ~~Vertex weight limit witness~~ **SHIPPED** as `examples/vertex-weight-limit/` — the 4-influence engine cap enforced via the data API (`v.groups` + `VertexGroup.remove` + renormalize); unit sums (measured 3e-8), pose preserved (4.9e-3), exact LBS from the mesh's own deform layer (2.7e-7), Root mount pinned
-- Triangulate + tangent-space witness: `mesh.calc_loop_triangles` + `calc_tangents` producing the deterministic loop topology and orthonormal tangent basis a game engine's normal mapping depends on — the fourth direction of the game-pipeline arc, identified but not built
+- ~~Triangulate + tangent-space witness~~ **SHIPPED** as `examples/triangulate-tangents/` — `calc_tangents` aborts on any ngon (back cap must be an explicit fan); mikktspace matches the edge/UV-delta formula within welding tolerance on smooth fields (2.3e-6 measured); planar UVs on a cylindrical wall collapse tangents onto normals (dot 0.998); `MeshUVLoopLayer` handle dangles across `calc_tangents()` on 4.5 (471 phantom flips, silent exit 0) while the mikktspace math is byte-identical on 4.5.11 and 5.1.2
+- UV-handle lifetime snippet: re-fetch attribute/UV layers by name after any CustomData-reallocating call (`calc_tangents`, `VertexGroup.add`, modifier edits) — held handles dangle silently on 4.5, survive by luck on 5.1 (found authoring `triangulate-tangents`)
- glTF skinned-mesh export witness: follow-up to `gltf-export-roundtrip` + `vertex-weight-limit` — export a rigged mesh (weights + joints) to glTF and round-trip the skin, asserting influence counts and joint bindings survive the format
- Degenerate-bevel weld hazard (snippet or rule): bevel width ≥ half a box dimension creates zero-area faces whose loops weld on glTF export (found authoring `gltf-export-roundtrip`, where the count check caught a 36-vertex weld)
- GAMMA_CROSS blend-curve witness: the cross blend is not the naive linear mix (mid-cross measured (0.341, 0.349, 0.463) from crimson (0.85, 0.10, 0.22) and teal (0.06, 0.75, 0.80)) — derive and assert the gamma-space closed form per frame; follow-up to `vse-cut-list`
diff --git a/docs/gallery/assets/triangulate-tangents-hero.webp b/docs/gallery/assets/triangulate-tangents-hero.webp
new file mode 100644
index 0000000..58d434e
Binary files /dev/null and b/docs/gallery/assets/triangulate-tangents-hero.webp differ
diff --git a/docs/gallery/contact-sheets/triangulate-tangents-contact-sheet.webp b/docs/gallery/contact-sheets/triangulate-tangents-contact-sheet.webp
new file mode 100644
index 0000000..d1eb493
Binary files /dev/null and b/docs/gallery/contact-sheets/triangulate-tangents-contact-sheet.webp differ
diff --git a/docs/gallery/index.html b/docs/gallery/index.html
index ab02d70..1193f72 100644
--- a/docs/gallery/index.html
+++ b/docs/gallery/index.html
@@ -491,6 +491,17 @@
View example →
+
+
+
+
+
+
+
A machined buckler verifying the tangent-space contract a game engine's normal mapping depends on. Deterministic triangulation, unit orthogonal tangent frames, and the edge/UV-delta formula matching mikktspace within welding tolerance.
+
witnesses calc_tangents aborts on any ngon; planar UVs on a cylindrical wall collapse tangents onto normals; a MeshUVLoopLayer handle held across calc_tangents dangles on 4.5 (471 phantom flips, silent exit 0) while the mikktspace math is byte-identical on both versions.
+
View example →
+
+
diff --git a/docs/gallery/triangulate-tangents/index.html b/docs/gallery/triangulate-tangents/index.html
new file mode 100644
index 0000000..96285ed
--- /dev/null
+++ b/docs/gallery/triangulate-tangents/index.html
@@ -0,0 +1,678 @@
+
+
+
+
+
+ triangulate-tangents — Examples — Blender Developer Tools
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Skip to content
+
+
+ triangulate-tangents
+ A machined buckler verifying the tangent-space contract a game engine's normal mapping depends on. Deterministic triangulation, unit orthogonal tangent frames, and the edge/UV-delta formula matching mikktspace within welding tolerance.
+
+
+
+
+
+ Rendered headless by the example itself — click to zoom.
+ witnesses calc_tangents aborts on any ngon; planar UVs on a cylindrical wall collapse tangents onto normals; a MeshUVLoopLayer handle held across calc_tangents dangles on 4.5 (471 phantom flips, silent exit 0) while the mikktspace math is byte-identical on both versions.
+
+
blender --background --python examples/triangulate-tangents/triangulate_tangents.py --
+
Copy
+
+
+A runnable example that builds a machined buckler — lathed dome with polar UVs, unwrapped rim and underside strips, a fanned back cap — and verifies the tangent-space contract a game engine's normal mapping depends on, following mesh-editing-and-bmesh .
+Pipeline arc neighbor: weighting in vertex-weight-limit , export in gltf-export-roundtrip — tangent space is what the exported normal maps are baked against.
+What it witnesses: the mikktspace contract engines implement, and the hazards around it.
+1. Deterministic triangulation and the engine basis. calc_loop_triangles yields the closed-form count (two tris per quad + the cap fan, 720), and every loop tangent is unit length (err 1.3e-07) and exactly orthogonal to its loop normal (err 6.0e-08), with the bitangent exactly bitangent_sign * (normal x tangent) (err 0.0). One ngon anywhere in the mesh and calc_tangents aborts the whole call — the back cap is an explicit fan for exactly that reason. 2. The tangent frame follows the UVs. On smooth-field triangles the per-loop tangents match the independently derived edge/UV-delta formula within mikktspace's vertex-welding tolerance (measured 2.3e-06, tol 0.15); a flipped frame inside a smooth field is never legal (0 measured). At UV seams the frame orientation is implementation-defined — 42 seam flips, 9 at chart seams, identical on both versions — and planar UVs on a cylindrical wall are degenerate: they collapse the tangent onto the normal (dot 0.998, measured and fixed by unwrapping strips). 3. The reference-lifetime hazard (the real divergence). A MeshUVLoopLayer handle held across calc_tangents() dangles: on Blender 4.5 reads through it return tangent floats, not UVs — measured while authoring as a phantom 471 flipped frames and 385 phantom seam positions, exit 0, a silent wrong answer . On 5.1 the same stale read survives by memory-layout luck. The mikktspace math itself is byte-identical on 4.5.11 and 5.1.2; the apparent version divergence was entirely the corrupt handle. Never hold layer handles across CustomData-reallocating calls — re-fetch by name.
+What each check catches on failure: a remeshed asset breaking the triangulation count (probe: dropped profile ring, exit 3, 624 vs 720); a wrong tangent formula in the independent derivation (probe: swapped du/dv, exit 7, weld 1.459); and the stale-handle read (probe: no re-fetch — corrupt measurements on 4.5, correct values on 5.1 by luck).
+Version witness: mikktspace output is identical on Blender 4.5.11 LTS and 5.1.2 (same weld deviations, same seam flips). The calc_tangents(uvmap=) signature is stable. The lifetime hazard above is the only behavioral split, and it corrupts measurements rather than raising.
+The render shows the buckler on its cradle: brushed grooves circulating the boss with the anisotropic sweep riding them — the circulating tangent field made visible.
+Run
+# Cheap correctness check (no render) — the CI check:
+blender --background --python triangulate_tangents.py --
+
+# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
+blender --background --python triangulate_tangents.py -- --output buckler.png
+blender --background --python triangulate_tangents.py -- --output buckler.png --engine cycles
+It exits non-zero on failure (topology drift, reallocated UV layer, non-orthonormal basis, bitangent-convention drift, formula excursion, or a flip inside a smooth field). The blender-smoke workflow runs the check on Blender 4.5 LTS and 5.1.
+
+
+ Source
+
+ """A buckler whose brushed grooves follow its tangent field — a runnable example.
+
+Witnesses the tangent-space contract a game engine's normal mapping depends
+on, computed through the same mikktspace path engines use:
+
+1. Triangulation is deterministic: ``calc_loop_triangles`` yields the
+ closed-form count (two triangles per quad) and per-loop tangent frames.
+2. Every loop tangent is unit length and exactly orthogonal to its loop
+ normal — the engine's normal-map basis.
+3. The tangent frame follows the UVs: per-triangle tangents match the
+ independently derived edge/UV-delta formula within mikktspace's vertex
+ welding tolerance, and the bitangent is exactly
+ ``bitangent_sign * (normal x tangent)``. Flip the UV island and the
+ tangent field flips with it — a pipeline baking normal maps from stale
+ UVs breaks exactly here.
+4. Safe-read protocol: on Blender 4.5 a ``MeshUVLoopLayer`` handle held
+ across ``calc_tangents()`` dangles — reads return tangent floats, not
+ UVs (measured err ~1.85 while authoring; on 5.1 the same stale read
+ survives by memory-layout luck). Never hold layer handles across
+ CustomData-reallocating calls; re-fetch by name.
+
+The mikktspace tangent field is byte-identical between Blender 4.5 LTS and
+5.1 (verified on both: same weld deviations, same 42 seam flips, same 9
+chart-seam flips). An earlier draft of this very check appeared to measure
+a 471-vs-9 flip divergence between the versions — every one of those
+"flips" was the stale UV handle of point 4 corrupting the seam
+classification. The reference lifetime is the real hazard; the math does
+not diverge.
+
+By default it runs only the correctness check (no render) — the CI smoke
+check. Pass --output to also render a still:
+
+ blender --background --python triangulate_tangents.py -- # check only
+ blender --background --python triangulate_tangents.py -- --output s.png # + render
+"""
+import bpy, bmesh, sys, os, math, argparse
+import mathutils
+
+SIDES = 48 # buckler lathe resolution
+RINGS = 6 # dome profile rings (closed form below)
+BOSS_R = 1.0
+UNIT_TOL = 1e-4 # |length(t) - 1| per loop
+DOT_TOL = 1e-4 # |t . n| per loop
+WELD_TOL = 0.15 # mikktspace vertex welding vs per-triangle formula
+BTN_TOL = 1e-4 # bitangent == sign * (n x t)
+UV_TOL = 1e-6 # re-fetched UV layer vs authored closed form
+
+# dome profile: (r, z) rings from center out, then the raised rim lip
+PROFILE = [(0.02 , 0.34 ), (0.30 , 0.30 ), (0.55 , 0.22 ), (0.78 , 0.12 ),
+ (0.95 , 0.05 ), (1.00 , 0.05 )]
+
+
+def dome_uv(co):
+ """Polar UVs on the dome: u sweeps the angle, v the radius — the tangent
+ field circulates around the boss (closed form)."""
+ theta = math.atan2(co.y, co.x) / (2 * math.pi)
+ r = math.sqrt(co.x * co.x + co.y * co.y)
+ return (theta % 1.0 , min(1.0 , r / BOSS_R))
+
+
+def build_buckler():
+ """A round buckler: lathed dome with raised rim, polar-UV dome face."""
+ me = bpy.data.meshes.new("Buckler" )
+ bm = bmesh.new()
+ try :
+ rings = []
+ for r, z in PROFILE:
+ rings.append([bm.verts.new((r * math.cos(2 * math.pi * s / SIDES),
+ r * math.sin(2 * math.pi * s / SIDES), z))
+ for s in range(SIDES)])
+ # underside: mirror of the outer two rings, then a flat back
+ back = [[bm.verts.new((r * math.cos(2 * math.pi * s / SIDES),
+ r * math.sin(2 * math.pi * s / SIDES), z - 0.10 ))
+ for s in range(SIDES)] for r, z in ((1.00 , 0.05 ), (0.30 , 0.02 ))]
+ for k in range(len(rings) - 1 ):
+ for s in range(SIDES):
+ bm.faces.new((rings[k][s], rings[k][(s + 1 ) % SIDES],
+ rings[k + 1 ][(s + 1 ) % SIDES], rings[k + 1 ][s]))
+ # rim side wall and underside
+ for s in range(SIDES):
+ bm.faces.new((rings[-1 ][s], rings[-1 ][(s + 1 ) % SIDES],
+ back[0 ][(s + 1 ) % SIDES], back[0 ][s]))
+ for s in range(SIDES):
+ bm.faces.new((back[0 ][s], back[0 ][(s + 1 ) % SIDES],
+ back[1 ][(s + 1 ) % SIDES], back[1 ][s]))
+ # the back cap must be an explicit fan: calc_tangents aborts on any
+ # ngon ("only tris/quads") — one n-gon kills the whole call
+ center = bm.verts.new((0.0 , 0.0 , -0.08 ))
+ for s in range(SIDES):
+ bm.faces.new((back[1 ][(s + 1 ) % SIDES], back[1 ][s], center))
+ bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
+ bm.to_mesh(me)
+ finally :
+ bm.free() # the ownership contract, as always
+ for poly in me.polygons:
+ poly.use_smooth = False # faceted boss plates keep the basis unambiguous
+ uv = me.uv_layers.new(name="UVMap" )
+ n_dome = (RINGS - 1 ) * SIDES
+ for poly in me.polygons[:n_dome]:
+ for li in poly.loop_indices:
+ co = me.vertices[me.loops[li].vertex_index].co
+ uv.data[li].uv = dome_uv(co)
+ for poly in me.polygons[n_dome:n_dome + 2 * SIDES]:
+ # rim wall + underside: unwrap as strips (u sweeps the angle, v the
+ # depth) — a planar projection is DEGENERATE on a cylindrical wall
+ # and collapses the tangent onto the normal (dot ~1.0, measured)
+ for li in poly.loop_indices:
+ co = me.vertices[me.loops[li].vertex_index].co
+ theta = math.atan2(co.y, co.x) / (2 * math.pi)
+ uv.data[li].uv = (theta % 1.0 , (0.05 - co.z) / 0.23 )
+ for poly in me.polygons[n_dome + 2 * SIDES:]: # back fan: flat, planar
+ for li in poly.loop_indices:
+ co = me.vertices[me.loops[li].vertex_index].co
+ uv.data[li].uv = (co.x * 0.5 + 0.5 , co.y * 0.5 + 0.5 )
+ obj = bpy.data.objects.new("Buckler" , me)
+ bpy.context.collection.objects.link(obj)
+ return obj, n_dome
+
+
+def formula_tangents(me, uv, tri):
+ """Per-triangle tangent/bitangent from edges and UV deltas — the
+ independent derivation engines implement. UV deltas are unwrapped so a
+ polar seam inside one triangle does not flip the frame."""
+ vs = [me.vertices[me.loops[li].vertex_index].co for li in tri.loops]
+ uvs = [uv.data[li].uv for li in tri.loops]
+ e1 = vs[1 ] - vs[0 ]
+ e2 = vs[2 ] - vs[0 ]
+ du1, dv1 = uvs[1 ].x - uvs[0 ].x, uvs[1 ].y - uvs[0 ].y
+ du2, dv2 = uvs[2 ].x - uvs[0 ].x, uvs[2 ].y - uvs[0 ].y
+ du1, dv1, du2, dv2 = (d - math.copysign(1.0 , d) if abs(d) > 0.5 else d
+ for d in (du1, dv1, du2, dv2))
+ det = du1 * dv2 - du2 * dv1
+ if abs(det) < 1e-12 :
+ return None
+ return ((e1 * dv2 - e2 * dv1) / det, (e2 * du1 - e1 * du2) / det)
+
+
+def check(obj, n_dome):
+ me = obj.data
+ me.calc_loop_triangles()
+
+ # contract 1: closed-form topology: two triangles per quad plus the
+ # explicit SIDES-triangle fan in the back cap
+ want_tris = 2 * ((RINGS - 1 ) * SIDES + 2 * SIDES) + SIDES
+ if len(me.loop_triangles) != want_tris:
+ print(f" ERROR: {len(me.loop_triangles)} loop triangles != closed "
+ f" form {want_tris}" , file=sys.stderr)
+ return 3
+
+ # re-fetch the layer AFTER calc_tangents: on 4.5 a handle held across
+ # that call dangles and reads tangent floats (measured err ~1.85 while
+ # authoring); on 5.1 the stale read survives by luck. Never trust a
+ # held handle — re-fetch by name.
+ uv = me.uv_layers["UVMap" ]
+
+ # contract 4: the re-fetched layer still holds the authored closed form
+ uv_err = 0.0
+ for poly in me.polygons[:n_dome]:
+ for li in poly.loop_indices:
+ co = me.vertices[me.loops[li].vertex_index].co
+ want = dome_uv(co)
+ uv_err = max(uv_err, (uv.data[li].uv - mathutils.Vector(want)).length)
+ if uv_err > UV_TOL:
+ print(f" ERROR: re-fetched UV layer deviates {uv_err:.3e } from the "
+ "authored polar field — layer contents were reallocated" ,
+ file=sys.stderr)
+ return 4
+
+ me.calc_tangents(uvmap="UVMap" )
+ # re-fetch AGAIN: the handle captured before calc_tangents dangles on
+ # 4.5 (reads there return garbage), corrupting the seam classification
+ uv = me.uv_layers["UVMap" ]
+
+ # per-position UV sets: a position carrying more than one UV across the
+ # mesh is a chart seam. mikktspace welds frames there, and the result is
+ # version-sensitive (4.5 welds across and can flip; 5.x splits cleanly).
+ uv_by_pos = {}
+ for li, loop in enumerate(me.loops):
+ key = tuple(round(c, 5 ) for c in me.vertices[loop.vertex_index].co)
+ uv_by_pos.setdefault(key, set()).add(
+ (round(uv.data[li].uv.x, 5 ), round(uv.data[li].uv.y, 5 )))
+ seam_positions = {k for k, s in uv_by_pos.items() if len(s) > 1 }
+
+ def tri_is_seam(tri):
+ """A triangle whose frame is implementation-defined: it touches a
+ chart-seam position, or its own UV deltas cross the polar wrap."""
+ uvs = [uv.data[li].uv for li in tri.loops]
+ for i in range(3 ):
+ key = tuple(round(c, 5 )
+ for c in me.vertices[me.loops[tri.loops[i]].vertex_index].co)
+ if key in seam_positions:
+ return True
+ j = (i + 1 ) % 3
+ if (abs(uvs[j].x - uvs[i].x) > 0.5 or abs(uvs[j].y - uvs[i].y) > 0.5 ):
+ return True
+ return False
+
+ unit_err = 0.0
+ dot_err = 0.0
+ btn_err = 0.0
+ sign_bad = 0
+ weld_err = 0.0
+ clean_flips = 0 # flips inside clean triangles: never allowed
+ seam_flips = 0 # flips inside seam triangles: implementation-defined
+ chart_flips = 0 # seam flips at chart-seam positions (4.5 welds these)
+ for tri in me.loop_triangles:
+ seam = tri_is_seam(tri)
+ ft = None if seam else formula_tangents(me, uv, tri)
+ for li in tri.loops:
+ loop = me.loops[li]
+ t = loop.tangent
+ b = loop.bitangent
+ s = loop.bitangent_sign
+ n = loop.normal
+ # contract 2 (everywhere, both versions): unit + orthogonal basis
+ unit_err = max(unit_err, abs(t.length - 1.0 ))
+ dot_err = max(dot_err, abs(t.dot(n)))
+ # contract 3b (everywhere): bitangent is exactly sign * (n x t)
+ btn_err = max(btn_err, (b - s * n.cross(t)).length)
+ if s not in (-1.0 , 1.0 ):
+ sign_bad += 1
+ # contract 3a (clean triangles): tangents match the independent
+ # derivation within mikktspace's vertex-welding tolerance
+ if ft is not None :
+ fn = ft[0 ].normalized()
+ if t.dot(fn) < -0.5 :
+ clean_flips += 1
+ else :
+ weld_err = max(weld_err, (t - fn).length)
+ elif ft is None :
+ # orientation inside seam triangles is implementation-defined;
+ # count it for the version witness
+ ft2 = formula_tangents(me, uv, tri)
+ if ft2 is not None and t.dot(ft2[0 ].normalized()) < -0.5 :
+ seam_flips += 1
+ key = tuple(round(c, 5 )
+ for c in me.vertices[loop.vertex_index].co)
+ if key in seam_positions:
+ chart_flips += 1
+ if unit_err > UNIT_TOL or dot_err > DOT_TOL:
+ print(f" ERROR: tangent basis not orthonormal: unit err {unit_err:.3e }, "
+ f" t.n err {dot_err:.3e }" , file=sys.stderr)
+ return 5
+ if btn_err > BTN_TOL or sign_bad:
+ print(f" ERROR: bitangent != sign*(n x t): err {btn_err:.3e }, "
+ f" {sign_bad} bad signs " , file=sys.stderr)
+ return 6
+ if weld_err > WELD_TOL:
+ print(f" ERROR: tangents deviate {weld_err:.3e } from the edge/UV "
+ f" closed form (mikktspace weld tol {WELD_TOL}) " , file=sys.stderr)
+ return 7
+ # contract 3c: a flipped frame inside a CLEAN triangle is never legal —
+ # the tangent field must follow the UVs wherever the UVs are smooth
+ if clean_flips:
+ print(f" ERROR: {clean_flips} flipped tangent(s) inside clean "
+ "triangles — the tangent field does not follow the UVs" ,
+ file=sys.stderr)
+ return 8
+ # version witness: none in the math — 4.5.11 and 5.1.2 produce identical
+ # counts here (42 seam flips, 9 at chart seams). The earlier draft that
+ # "measured" a 471-vs-9 divergence was reading the stale UV handle from
+ # point 4; the hazard corrupted the measurement itself.
+
+ print(f" sides= {SIDES} tris= {len(me.loop_triangles)} "
+ f" unit_err= {unit_err:.2e } dot_err= {dot_err:.2e } "
+ f" btn_err= {btn_err:.2e } uv_err= {uv_err:.2e }" )
+ print(f" weld_err= {weld_err:.2e } (tol {WELD_TOL}) sign_bad= {sign_bad} "
+ f" clean_flips= {clean_flips} seam_flips= {seam_flips} "
+ f" chart_flips= {chart_flips} seam_positions= {len(seam_positions)}" )
+ 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
+
+ # brushed steel: the groove rings follow V, so the anisotropic sweep
+ # only reads concentric if the tangent field circulates with the UVs
+ mat = bpy.data.materials.new("BrushedSteel" )
+ mat.use_nodes = True
+ nt = mat.node_tree
+ bsdf = nt.nodes["Principled BSDF" ]
+ bsdf.inputs["Base Color" ].default_value = (0.30 , 0.32 , 0.36 , 1.0 )
+ bsdf.inputs["Metallic" ].default_value = 1.0
+ bsdf.inputs["Roughness" ].default_value = 0.40
+ aniso = bsdf.inputs.get("Anisotropic IOR Level" ) or bsdf.inputs.get("Anisotropic" )
+ if aniso is not None :
+ aniso.default_value = 0.6
+ tangent = nt.nodes.new("ShaderNodeTangent" )
+ tangent.direction_type = 'UV_MAP'
+ tan_in = bsdf.inputs.get("Tangent" )
+ if tan_in is not None :
+ nt.links.new(tangent.outputs["Tangent" ], tan_in)
+ wave = nt.nodes.new("ShaderNodeTexWave" )
+ wave.wave_type = 'RINGS'
+ wave.rings_direction = 'SPHERICAL'
+ wave.inputs["Scale" ].default_value = 9.0
+ wave.inputs["Distortion" ].default_value = 3.0
+ bump = nt.nodes.new("ShaderNodeBump" )
+ bump.inputs["Strength" ].default_value = 0.07
+ bump.inputs["Distance" ].default_value = 0.03
+ nt.links.new(wave.outputs["Fac" ], bump.inputs["Height" ])
+ nt.links.new(bump.outputs["Normal" ], bsdf.inputs["Normal" ])
+ obj.data.materials.append(mat)
+
+ # presentation: the buckler leaning back on a low display stand
+ obj.location = (0.0 , 0.0 , 1.55 )
+ obj.rotation_euler = (math.radians(70 ), 0.0 , math.radians(-12 ))
+ stand_me = bpy.data.meshes.new("Stand" )
+ bm = bmesh.new()
+ try :
+ bmesh.ops.create_cube(bm, size=1.0 ,
+ matrix=mathutils.Matrix.Diagonal((0.9 , 0.5 , 0.12 , 1.0 )))
+ bm.to_mesh(stand_me)
+ finally :
+ bm.free()
+ smat = bpy.data.materials.new("StandMetal" )
+ smat.use_nodes = True
+ sb = smat.node_tree.nodes["Principled BSDF" ]
+ sb.inputs["Base Color" ].default_value = (0.09 , 0.10 , 0.12 , 1.0 )
+ sb.inputs["Metallic" ].default_value = 0.85
+ sb.inputs["Roughness" ].default_value = 0.45
+ stand_me.materials.append(smat)
+ stand = bpy.data.objects.new("Stand" , stand_me)
+ # under the leaning rim's contact patch, so the cradle visibly carries it
+ stand.location = (0.0 , -0.34 , 0.55 )
+ scene.collection.objects.link(stand)
+
+ 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.03 , 0.032 , 0.037 , 1.0 )
+ fb.inputs["Roughness" ].default_value = 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 , 9.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)
+
+ # shaped warm key, faint cool fill, cool rim, warm wedge on the back wall
+ # (docs/VISUAL-STYLE.md); the glint is the anisotropic streak's driver
+ light("Key" , (-4.0 , -5.0 , 6.0 ), 500.0 , 4.5 , (1.0 , 0.96 , 0.9 ), (48 , 0 , -38 ))
+ light("Fill" , (5.0 , -4.0 , 3.0 ), 100.0 , 9.0 , (0.75 , 0.85 , 1.0 ), (62 , 0 , 50 ))
+ light("Rim" , (0.5 , 4.5 , 5.0 ), 340.0 , 4.0 , (0.6 , 0.78 , 1.0 ), (-55 , 0 , 175 ))
+ light("Wedge" , (2.5 , 3.5 , 4.2 ), 480.0 , 6.0 , (1.0 , 0.76 , 0.5 ), (-72 , 0 , 195 ))
+ light("Glint" , (1.5 , -5.5 , 5.5 ), 400.0 , 1.2 , (1.0 , 0.9 , 0.75 ), (42 , 0 , 15 ))
+
+ cam_data = bpy.data.cameras.new("Cam" )
+ cam_data.lens = 55.0
+ cam = bpy.data.objects.new("Cam" , cam_data)
+ cam.location = (0.6 , -8.8 , 2.8 )
+ scene.collection.objects.link(cam)
+ target = bpy.data.objects.new("Aim" , None )
+ target.location = (0.0 , 0.0 , 1.35 )
+ 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 = 32
+ else :
+ try :
+ scene.eevee.taa_render_samples = 64
+ except AttributeError:
+ pass
+ scene.render.resolution_x = 1280
+ scene.render.resolution_y = 720
+ scene.render.image_settings.file_format = 'PNG'
+ scene.render.filepath = path
+ # AgX would wash the steel toward chalk (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 )
+ obj, n_dome = build_buckler()
+ code = check(obj, n_dome)
+ 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 10
+ print(f" rendered still {args.output}" )
+
+ print("triangulate-tangents 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/examples/gallery.json b/examples/gallery.json
index 9465623..9b6247e 100644
--- a/examples/gallery.json
+++ b/examples/gallery.json
@@ -1,15 +1,15 @@
{
"_comment": "FORWARD-COMPATIBLE SOURCE OF TRUTH for the examples gallery. The local page at docs/gallery/index.html is GENERATED from this file by scripts/build_gallery.py -- do not hand-edit the HTML. When the fleet template (Developer-Tools-Directory: site-template/build_site.py + template.html.j2) gains examples support (see ROADMAP: 'Fleet Pages examples support'), it reads this same file and the local page is retired. That migration is a lift-and-shift, not a rewrite: keep this schema stable. Per-entry schema: {name, dir, teaches, witnessesFix, hero, preview, tags?}; hero/preview/dir are repo-root-relative; tags is an optional additive list driving the gallery filter chips. build_gallery.py also emits a detail page per example at docs/gallery//.",
"title": "Examples Gallery",
- "description": "Runnable, smoke-gated Blender Python examples \u2014 each executed headless on Blender 4.5 LTS and 5.1, so every render reflects code that actually runs.",
+ "description": "Runnable, smoke-gated Blender Python examples — each executed headless on Blender 4.5 LTS and 5.1, so every render reflects code that actually runs.",
"repoBaseUrl": "https://github.com/TMHSDigital/Blender-Developer-Tools/tree/main",
"siteBaseUrl": "https://tmhsdigital.github.io/Blender-Developer-Tools",
"examples": [
{
"name": "swatch-grid",
"dir": "examples/swatch-grid",
- "teaches": "Procedural Principled materials \u2014 metal and dielectric, the emission pattern, and the cross-version set_specular shim.",
- "witnessesFix": "EEVEE engine-id mapping: BLENDER_EEVEE on 5.x, BLENDER_EEVEE_NEXT on 4.2\u20134.5.",
+ "teaches": "Procedural Principled materials — metal and dielectric, the emission pattern, and the cross-version set_specular shim.",
+ "witnessesFix": "EEVEE engine-id mapping: BLENDER_EEVEE on 5.x, BLENDER_EEVEE_NEXT on 4.2–4.5.",
"hero": "docs/gallery/assets/swatch-grid-hero.webp",
"preview": "examples/swatch-grid/preview.webp",
"tags": [
@@ -31,7 +31,7 @@
{
"name": "gn-sdf-remesh",
"dir": "examples/gn-sdf-remesh",
- "teaches": "A Geometry Nodes SDF remesh (MeshToSDFGrid \u2192 GridToMesh at the SDF zero-level), with a Set Material node carrying the material through the remesh.",
+ "teaches": "A Geometry Nodes SDF remesh (MeshToSDFGrid → GridToMesh at the SDF zero-level), with a Set Material node carrying the material through the remesh.",
"witnessesFix": "An SDF grid is meshed with Grid to Mesh, not Volume to Mesh; GN geometry needs Set Material or it renders untextured.",
"hero": "docs/gallery/assets/gn-sdf-remesh-hero.webp",
"preview": "examples/gn-sdf-remesh/preview.webp",
@@ -43,7 +43,7 @@
{
"name": "depsgraph-export",
"dir": "examples/depsgraph-export",
- "teaches": "The depsgraph lifetime contract \u2014 evaluated_get().to_mesh() paired with to_mesh_clear() \u2014 measured against an OBJ export of the same object.",
+ "teaches": "The depsgraph lifetime contract — evaluated_get().to_mesh() paired with to_mesh_clear() — measured against an OBJ export of the same object.",
"witnessesFix": "Exports ship evaluated geometry: the exported vertex count equals the subsurf-applied count and is strictly greater than the base mesh.",
"hero": "docs/gallery/assets/depsgraph-export-hero.webp",
"preview": "examples/depsgraph-export/preview.webp",
@@ -55,7 +55,7 @@
{
"name": "wave-displace",
"dir": "examples/wave-displace",
- "teaches": "Bulk vertex IO at real scale \u2014 9,409 vertices displaced into a standing wave with one foreach_get and one foreach_set, no per-vertex access.",
+ "teaches": "Bulk vertex IO at real scale — 9,409 vertices displaced into a standing wave with one foreach_get and one foreach_set, no per-vertex access.",
"witnessesFix": "The bulk path is correct, not just fast: the Z span matches the wave amplitude and every vertex matches the closed-form wave, so a stride bug in the flat buffer cannot hide.",
"hero": "docs/gallery/assets/wave-displace-hero.webp",
"preview": "examples/wave-displace/preview.webp",
@@ -67,7 +67,7 @@
{
"name": "driver-wave",
"dir": "examples/driver-wave",
- "teaches": "A driver_namespace function driving sixteen column heights through SCRIPTED drivers \u2014 the sine skyline is entirely driver-evaluated.",
+ "teaches": "A driver_namespace function driving sixteen column heights through SCRIPTED drivers — the sine skyline is entirely driver-evaluated.",
"witnessesFix": "Driven values appear after a view-layer update in two places that must agree: the evaluated copy and the original datablock the animation system flushes for display.",
"hero": "docs/gallery/assets/driver-wave-hero.webp",
"preview": "examples/driver-wave/preview.webp",
@@ -79,7 +79,7 @@
{
"name": "bmesh-gear",
"dir": "examples/bmesh-gear",
- "teaches": "A 14-tooth gear built entirely with bmesh \u2014 profile ring, face, extrude \u2014 with bm.free() in a try/finally, exactly as the ownership contract demands.",
+ "teaches": "A 14-tooth gear built entirely with bmesh — profile ring, face, extrude — with bm.free() in a try/finally, exactly as the ownership contract demands.",
"witnessesFix": "Parametric bmesh topology is exactly predictable: verts, edges, and faces match their closed forms, and every edge borders exactly two faces (watertight).",
"hero": "docs/gallery/assets/bmesh-gear-hero.webp",
"preview": "examples/bmesh-gear/preview.webp",
@@ -91,7 +91,7 @@
{
"name": "shader-node-group",
"dir": "examples/shader-node-group",
- "teaches": "One reusable shader group declared via tree.interface.new_socket, instanced in two materials with different Tint values \u2014 two spheres, one group, two colors.",
+ "teaches": "One reusable shader group declared via tree.interface.new_socket, instanced in two materials with different Tint values — two spheres, one group, two colors.",
"witnessesFix": "Grouping contract: interface sockets appear on every instance, both materials share one group datablock (users == 2), and per-material parameters live on the group node, not inside the tree.",
"hero": "docs/gallery/assets/shader-node-group-hero.webp",
"preview": "examples/shader-node-group/preview.webp",
@@ -103,8 +103,8 @@
{
"name": "temp-override-join",
"dir": "examples/temp-override-join",
- "teaches": "Join three unit cubes into a staircase under bpy.context.temp_override \u2014 the supported replacement for the removed context.copy() dict-pass form.",
- "witnessesFix": "temp_override actually applies: join consumes the sources, exactly one mesh remains, topology is verts = 8 \u00d7 steps, and local Z spans all three steps.",
+ "teaches": "Join three unit cubes into a staircase under bpy.context.temp_override — the supported replacement for the removed context.copy() dict-pass form.",
+ "witnessesFix": "temp_override actually applies: join consumes the sources, exactly one mesh remains, topology is verts = 8 × steps, and local Z spans all three steps.",
"hero": "docs/gallery/assets/temp-override-join-hero.webp",
"preview": "examples/temp-override-join/preview.webp",
"tags": [
@@ -115,7 +115,7 @@
{
"name": "gn-instance-grid",
"dir": "examples/gn-instance-grid",
- "teaches": "A generative Geometry Nodes tree \u2014 Mesh Grid \u2192 Instance on Points \u2192 Realize Instances \u2192 Set Shade Smooth \u2014 attached as a NODES modifier with no Group Input geometry.",
+ "teaches": "A generative Geometry Nodes tree — Mesh Grid → Instance on Points → Realize Instances → Set Shade Smooth — attached as a NODES modifier with no Group Input geometry.",
"witnessesFix": "Realized instances produce closed-form topology (72 verts, 54 faces), Set Material carries Lime, and the corner instance center sits at its closed-form grid coordinate.",
"hero": "docs/gallery/assets/gn-instance-grid-hero.webp",
"preview": "examples/gn-instance-grid/preview.webp",
@@ -127,8 +127,8 @@
{
"name": "shape-key-blend",
"dir": "examples/shape-key-blend",
- "teaches": "A relative Tall shape key that lifts and flares the top face \u2014 authored via shape_key_add / key_blocks / .value \u2014 read back from the depsgraph-evaluated mesh.",
- "witnessesFix": "Shape keys do not rewrite mesh.vertices: every evaluated vert matches basis + value \u00d7 (key \u2212 basis), including the flared top half-extent.",
+ "teaches": "A relative Tall shape key that lifts and flares the top face — authored via shape_key_add / key_blocks / .value — read back from the depsgraph-evaluated mesh.",
+ "witnessesFix": "Shape keys do not rewrite mesh.vertices: every evaluated vert matches basis + value × (key − basis), including the flared top half-extent.",
"hero": "docs/gallery/assets/shape-key-blend-hero.webp",
"preview": "examples/shape-key-blend/preview.webp",
"tags": [
@@ -139,7 +139,7 @@
{
"name": "curve-bevel-arc",
"dir": "examples/curve-bevel-arc",
- "teaches": "A beveled Bezier semicircle authored on bpy.types.Curve \u2014 splines.new('BEZIER'), bezier_points, bevel_depth, use_fill_caps \u2014 so the curve renders as a solid tube without a prior mesh conversion.",
+ "teaches": "A beveled Bezier semicircle authored on bpy.types.Curve — splines.new('BEZIER'), bezier_points, bevel_depth, use_fill_caps — so the curve renders as a solid tube without a prior mesh conversion.",
"witnessesFix": "Curve tubes are curve datablocks: eight Bezier points, bevel_depth == 0.15, filled caps, and the evaluated mesh has deterministic topology (1044 verts, 1028 faces) resting on the floor.",
"hero": "docs/gallery/assets/curve-bevel-arc-hero.webp",
"preview": "examples/curve-bevel-arc/preview.webp",
@@ -151,7 +151,7 @@
{
"name": "compositor-glare",
"dir": "examples/compositor-glare",
- "teaches": "Bloom where it actually lives \u2014 a compositor Glare (Fog Glow) node fed by Render Layers, wired via scene.compositing_node_group on 5.x and scene.node_tree on 4.x, with the Glare node's legacy properties vs 5.x menu sockets.",
+ "teaches": "Bloom where it actually lives — a compositor Glare (Fog Glow) node fed by Render Layers, wired via scene.compositing_node_group on 5.x and scene.node_tree on 4.x, with the Glare node's legacy properties vs 5.x menu sockets.",
"witnessesFix": "scene.node_tree is gone in 5.x and EEVEE has no use_bloom on either side: the halo beyond the ring silhouette falls off strictly with the compositor on and is exactly zero with it off.",
"hero": "docs/gallery/assets/compositor-glare-hero.webp",
"preview": "examples/compositor-glare/preview.webp",
@@ -163,8 +163,8 @@
{
"name": "damped-track-aim",
"dir": "examples/damped-track-aim",
- "teaches": "Aim constraints via the data API \u2014 Object.constraints.new('DAMPED_TRACK') with target and TRACK_Z, not bpy.ops.object.constraint_add in a headless loop. Gallery still: brass spike cage around an ember core.",
- "witnessesFix": "Every needle has one unmuted DAMPED_TRACK on the core; evaluated local +Z aligns toward the core (dot \u2265 0.998). TRACK_TO stand-ins and flipped axes fail.",
+ "teaches": "Aim constraints via the data API — Object.constraints.new('DAMPED_TRACK') with target and TRACK_Z, not bpy.ops.object.constraint_add in a headless loop. Gallery still: brass spike cage around an ember core.",
+ "witnessesFix": "Every needle has one unmuted DAMPED_TRACK on the core; evaluated local +Z aligns toward the core (dot ≥ 0.998). TRACK_TO stand-ins and flipped axes fail.",
"hero": "docs/gallery/assets/damped-track-aim-hero.webp",
"preview": "examples/damped-track-aim/preview.webp",
"tags": [
@@ -175,7 +175,7 @@
{
"name": "color-attribute-wheel",
"dir": "examples/color-attribute-wheel",
- "teaches": "The modern color-attributes API \u2014 mesh.color_attributes.new() on the CORNER domain, filled by expanding per-vertex HSV across face corners with foreach_get/foreach_set, then wired into a shader Attribute node.",
+ "teaches": "The modern color-attributes API — mesh.color_attributes.new() on the CORNER domain, filled by expanding per-vertex HSV across face corners with foreach_get/foreach_set, then wired into a shader Attribute node.",
"witnessesFix": "CORNER-domain attributes are sized to loop count, not vertex count; the check asserts the sizing, that active_color points at it, and that the Attribute node is actually linked to Base Color, not just present.",
"hero": "docs/gallery/assets/color-attribute-wheel-hero.webp",
"preview": "examples/color-attribute-wheel/preview.webp",
@@ -188,7 +188,7 @@
{
"name": "parent-inverse-orrery",
"dir": "examples/parent-inverse-orrery",
- "teaches": "Data-API parenting for a brass orrery \u2014 the keep-world idiom (child.parent = pivot; child.matrix_parent_inverse = pivot.matrix_world.inverted()) carrying arms, planets, and a two-level moon through spinning pivots.",
+ "teaches": "Data-API parenting for a brass orrery — the keep-world idiom (child.parent = pivot; child.matrix_parent_inverse = pivot.matrix_world.inverted()) carrying arms, planets, and a two-level moon through spinning pivots.",
"witnessesFix": "Bare `.parent =` really does teleport the child; the idiom restores world position exactly; matrix_world stays stale until view_layer.update(); every orbit lands on its closed form.",
"hero": "docs/gallery/assets/parent-inverse-orrery-hero.webp",
"preview": "examples/parent-inverse-orrery/preview.webp",
@@ -200,7 +200,7 @@
{
"name": "grease-pencil-rosette",
"dir": "examples/grease-pencil-rosette",
- "teaches": "Grease Pencil v3's attribute-based API \u2014 layer \u2192 frames.new(1).drawing \u2192 add_strokes \u2192 per-point position/radius/opacity/vertex_color \u2014 drawing five nested neon rose curves.",
+ "teaches": "Grease Pencil v3's attribute-based API — layer → frames.new(1).drawing → add_strokes → per-point position/radius/opacity/vertex_color — drawing five nested neon rose curves.",
"witnessesFix": "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.",
"hero": "docs/gallery/assets/grease-pencil-rosette-hero.webp",
"preview": "examples/grease-pencil-rosette/preview.webp",
@@ -212,8 +212,8 @@
{
"name": "armature-bend",
"dir": "examples/armature-bend",
- "teaches": "Rigging end to end in the data API \u2014 edit_bones chain construction, name-bound vertex groups with smoothstep blend zones, posing, and depsgraph evaluation \u2014 bending a tapered tube through rest, half, and full curl.",
- "witnessesFix": "edit_bones exists only in edit mode (empty in object mode), vertex groups bind to bones strictly by name, and the armature modifier is exactly linear blend skinning: every evaluated vertex must equal \u03a3 w\u1d62\u00b7(pose_bone.matrix @ bone.matrix_local.inverted()) @ rest, root ring pinned, tip deflected \u2014 a straight tube fails.",
+ "teaches": "Rigging end to end in the data API — edit_bones chain construction, name-bound vertex groups with smoothstep blend zones, posing, and depsgraph evaluation — bending a tapered tube through rest, half, and full curl.",
+ "witnessesFix": "edit_bones exists only in edit mode (empty in object mode), vertex groups bind to bones strictly by name, and the armature modifier is exactly linear blend skinning: every evaluated vertex must equal Σ wᵢ·(pose_bone.matrix @ bone.matrix_local.inverted()) @ rest, root ring pinned, tip deflected — a straight tube fails.",
"hero": "docs/gallery/assets/armature-bend-hero.webp",
"preview": "examples/armature-bend/preview.webp",
"tags": [
@@ -224,8 +224,8 @@
{
"name": "text-version-stamp",
"dir": "examples/text-version-stamp",
- "teaches": "The TextCurve data API \u2014 curves.new(type='FONT'), live body text from bpy.app.version_string, extrude and bevel_depth solids, and evaluated-mesh conversion \u2014 so every render self-documents which Blender produced it.",
- "witnessesFix": "TextCurve solids are exactly predictable: evaluated z-extent = 2 \u00d7 (extrude + bevel_depth), bevel widens the outline by 2 \u00d7 bevel_depth, editing body regenerates geometry \u2014 and version_string is not bare semver on LTS (\"4.5.11 LTS\"), so parse bpy.app.version instead.",
+ "teaches": "The TextCurve data API — curves.new(type='FONT'), live body text from bpy.app.version_string, extrude and bevel_depth solids, and evaluated-mesh conversion — so every render self-documents which Blender produced it.",
+ "witnessesFix": "TextCurve solids are exactly predictable: evaluated z-extent = 2 × (extrude + bevel_depth), bevel widens the outline by 2 × bevel_depth, editing body regenerates geometry — and version_string is not bare semver on LTS (\"4.5.11 LTS\"), so parse bpy.app.version instead.",
"hero": "docs/gallery/assets/text-version-stamp-hero.webp",
"preview": "examples/text-version-stamp/preview.webp",
"tags": [
@@ -237,8 +237,8 @@
{
"name": "image-pixels-testcard",
"dir": "examples/image-pixels-testcard",
- "teaches": "The Image pixel-buffer contract \u2014 a procedural broadcast test card written into bpy.data.images.new() with one pixels.foreach_set (589,824 floats), byte vs float_buffer storage, scale() reallocation, and the save() vs save_render() lifecycle.",
- "witnessesFix": "pixels is always flat RGBA (channels == 4 even with alpha=False), byte storage quantizes at exactly \u2264 0.5/255 and strictly > 0, stale-size bulk reads raise after scale() \u2014 and save() silently flips source to FILE and drops the buffer, so later pixels reads come from whatever sits on disk (proven with an imposter file).",
+ "teaches": "The Image pixel-buffer contract — a procedural broadcast test card written into bpy.data.images.new() with one pixels.foreach_set (589,824 floats), byte vs float_buffer storage, scale() reallocation, and the save() vs save_render() lifecycle.",
+ "witnessesFix": "pixels is always flat RGBA (channels == 4 even with alpha=False), byte storage quantizes at exactly ≤ 0.5/255 and strictly > 0, stale-size bulk reads raise after scale() — and save() silently flips source to FILE and drops the buffer, so later pixels reads come from whatever sits on disk (proven with an imposter file).",
"hero": "docs/gallery/assets/image-pixels-testcard-hero.webp",
"preview": "examples/image-pixels-testcard/preview.webp",
"tags": [
@@ -250,8 +250,8 @@
{
"name": "png-exr-alpha",
"dir": "examples/png-exr-alpha",
- "teaches": "Float-image PNG save trap \u2014 float_buffer=True Image.save() writes RGBA16 and unpremultiplies as if associated-alpha, clamping straight-authored dark values at low alpha to white (closed-form err 0.98 at RGB 0.02 / a=1/255).",
- "witnessesFix": "float\u2192PNG IHDR is RGBA16 and matches q16(min(1, c/q16(a))); OpenEXR round-trips within 1e-5; byte\u2192PNG is straight RGBA8; EXR color_mode=RGB drops alpha to opaque.",
+ "teaches": "Float-image PNG save trap — float_buffer=True Image.save() writes RGBA16 and unpremultiplies as if associated-alpha, clamping straight-authored dark values at low alpha to white (closed-form err 0.98 at RGB 0.02 / a=1/255).",
+ "witnessesFix": "float→PNG IHDR is RGBA16 and matches q16(min(1, c/q16(a))); OpenEXR round-trips within 1e-5; byte→PNG is straight RGBA8; EXR color_mode=RGB drops alpha to opaque.",
"hero": "docs/gallery/assets/png-exr-alpha-hero.webp",
"preview": "examples/png-exr-alpha/preview.webp",
"tags": [
@@ -263,7 +263,7 @@
{
"name": "uv-layer-grid",
"dir": "examples/uv-layer-grid",
- "teaches": "The UV-layer authoring hazard \u2014 bmesh.ops.create_grid(..., calc_uvs=True) is a silent no-op unless a UV layer already exists; without one an Image Texture samples texel (0,0) everywhere.",
+ "teaches": "The UV-layer authoring hazard — bmesh.ops.create_grid(..., calc_uvs=True) is a silent no-op unless a UV layer already exists; without one an Image Texture samples texel (0,0) everywhere.",
"witnessesFix": "calc_uvs=True alone leaves 0 UV layers; pre-create + calc_uvs fills loops to the closed-form grid UVs ((x/size+1)/2, (y/size+1)/2) within 1e-6; explicit loop assignment is the calc_uvs-free fallback. The still is self-witnessing: the script re-reads its render and exits non-zero unless the hazard panel is one flat teal and the repaired panel a high-contrast checker.",
"hero": "docs/gallery/assets/uv-layer-grid-hero.webp",
"preview": "examples/uv-layer-grid/preview.webp",
@@ -321,6 +321,18 @@
"armature",
"mesh"
]
+ },
+ {
+ "name": "triangulate-tangents",
+ "dir": "examples/triangulate-tangents",
+ "teaches": "A machined buckler verifying the tangent-space contract a game engine's normal mapping depends on. Deterministic triangulation, unit orthogonal tangent frames, and the edge/UV-delta formula matching mikktspace within welding tolerance.",
+ "witnessesFix": "calc_tangents aborts on any ngon; planar UVs on a cylindrical wall collapse tangents onto normals; a MeshUVLoopLayer handle held across calc_tangents dangles on 4.5 (471 phantom flips, silent exit 0) while the mikktspace math is byte-identical on both versions.",
+ "hero": "docs/gallery/assets/triangulate-tangents-hero.webp",
+ "preview": "examples/triangulate-tangents/preview.webp",
+ "tags": [
+ "mesh",
+ "uv"
+ ]
}
]
}
diff --git a/examples/triangulate-tangents/README.md b/examples/triangulate-tangents/README.md
new file mode 100644
index 0000000..06859c4
--- /dev/null
+++ b/examples/triangulate-tangents/README.md
@@ -0,0 +1,70 @@
+# Triangulate + Tangents
+
+A runnable example that builds a machined buckler — lathed dome with polar
+UVs, unwrapped rim and underside strips, a fanned back cap — and verifies
+the tangent-space contract a game engine's normal mapping depends on,
+following [`mesh-editing-and-bmesh`](../../skills/mesh-editing-and-bmesh/SKILL.md).
+
+**Pipeline arc neighbor:** weighting in
+[`vertex-weight-limit`](../vertex-weight-limit/), export in
+[`gltf-export-roundtrip`](../gltf-export-roundtrip/) — tangent space is what
+the exported normal maps are baked against.
+
+**What it witnesses:** the mikktspace contract engines implement, and the
+hazards around it.
+
+1. **Deterministic triangulation and the engine basis.** `calc_loop_triangles`
+ yields the closed-form count (two tris per quad + the cap fan, 720), and
+ every loop tangent is unit length (err 1.3e-07) and exactly orthogonal to
+ its loop normal (err 6.0e-08), with the bitangent exactly
+ `bitangent_sign * (normal x tangent)` (err 0.0). One ngon anywhere in the
+ mesh and `calc_tangents` **aborts the whole call** — the back cap is an
+ explicit fan for exactly that reason.
+2. **The tangent frame follows the UVs.** On smooth-field triangles the
+ per-loop tangents match the independently derived edge/UV-delta formula
+ within mikktspace's vertex-welding tolerance (measured 2.3e-06, tol 0.15);
+ a flipped frame inside a smooth field is never legal (0 measured). At UV
+ seams the frame orientation is implementation-defined — 42 seam flips, 9
+ at chart seams, identical on both versions — and planar UVs on a
+ cylindrical wall are degenerate: they collapse the tangent onto the
+ normal (dot 0.998, measured and fixed by unwrapping strips).
+3. **The reference-lifetime hazard (the real divergence).** A
+ `MeshUVLoopLayer` handle held across `calc_tangents()` dangles: on
+ Blender 4.5 reads through it return tangent floats, not UVs — measured
+ while authoring as a phantom **471 flipped frames and 385 phantom seam
+ positions, exit 0, a silent wrong answer**. On 5.1 the same stale read
+ survives by memory-layout luck. The mikktspace math itself is
+ byte-identical on 4.5.11 and 5.1.2; the apparent version divergence was
+ entirely the corrupt handle. Never hold layer handles across
+ CustomData-reallocating calls — re-fetch by name.
+
+**What each check catches on failure:** a remeshed asset breaking the
+triangulation count (probe: dropped profile ring, exit 3, 624 vs 720); a
+wrong tangent formula in the independent derivation (probe: swapped du/dv,
+exit 7, weld 1.459); and the stale-handle read (probe: no re-fetch — corrupt
+measurements on 4.5, correct values on 5.1 by luck).
+
+**Version witness:** mikktspace output is identical on Blender 4.5.11 LTS
+and 5.1.2 (same weld deviations, same seam flips). The `calc_tangents(uvmap=)`
+signature is stable. The lifetime hazard above is the only behavioral split,
+and it corrupts measurements rather than raising.
+
+The render shows the buckler on its cradle: brushed grooves circulating the
+boss with the anisotropic sweep riding them — the circulating tangent field
+made visible.
+
+## Run
+
+```bash
+# Cheap correctness check (no render) — the CI check:
+blender --background --python triangulate_tangents.py --
+
+# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
+blender --background --python triangulate_tangents.py -- --output buckler.png
+blender --background --python triangulate_tangents.py -- --output buckler.png --engine cycles
+```
+
+It exits non-zero on failure (topology drift, reallocated UV layer,
+non-orthonormal basis, bitangent-convention drift, formula excursion, or a
+flip inside a smooth field). The `blender-smoke` workflow runs the check on
+Blender 4.5 LTS and 5.1.
diff --git a/examples/triangulate-tangents/preview.webp b/examples/triangulate-tangents/preview.webp
new file mode 100644
index 0000000..6d449a0
Binary files /dev/null and b/examples/triangulate-tangents/preview.webp differ
diff --git a/examples/triangulate-tangents/triangulate_tangents.py b/examples/triangulate-tangents/triangulate_tangents.py
new file mode 100644
index 0000000..907c4e2
--- /dev/null
+++ b/examples/triangulate-tangents/triangulate_tangents.py
@@ -0,0 +1,431 @@
+"""A buckler whose brushed grooves follow its tangent field — a runnable example.
+
+Witnesses the tangent-space contract a game engine's normal mapping depends
+on, computed through the same mikktspace path engines use:
+
+1. Triangulation is deterministic: ``calc_loop_triangles`` yields the
+ closed-form count (two triangles per quad) and per-loop tangent frames.
+2. Every loop tangent is unit length and exactly orthogonal to its loop
+ normal — the engine's normal-map basis.
+3. The tangent frame follows the UVs: per-triangle tangents match the
+ independently derived edge/UV-delta formula within mikktspace's vertex
+ welding tolerance, and the bitangent is exactly
+ ``bitangent_sign * (normal x tangent)``. Flip the UV island and the
+ tangent field flips with it — a pipeline baking normal maps from stale
+ UVs breaks exactly here.
+4. Safe-read protocol: on Blender 4.5 a ``MeshUVLoopLayer`` handle held
+ across ``calc_tangents()`` dangles — reads return tangent floats, not
+ UVs (measured err ~1.85 while authoring; on 5.1 the same stale read
+ survives by memory-layout luck). Never hold layer handles across
+ CustomData-reallocating calls; re-fetch by name.
+
+The mikktspace tangent field is byte-identical between Blender 4.5 LTS and
+5.1 (verified on both: same weld deviations, same 42 seam flips, same 9
+chart-seam flips). An earlier draft of this very check appeared to measure
+a 471-vs-9 flip divergence between the versions — every one of those
+"flips" was the stale UV handle of point 4 corrupting the seam
+classification. The reference lifetime is the real hazard; the math does
+not diverge.
+
+By default it runs only the correctness check (no render) — the CI smoke
+check. Pass --output to also render a still:
+
+ blender --background --python triangulate_tangents.py -- # check only
+ blender --background --python triangulate_tangents.py -- --output s.png # + render
+"""
+import bpy, bmesh, sys, os, math, argparse
+import mathutils
+
+SIDES = 48 # buckler lathe resolution
+RINGS = 6 # dome profile rings (closed form below)
+BOSS_R = 1.0
+UNIT_TOL = 1e-4 # |length(t) - 1| per loop
+DOT_TOL = 1e-4 # |t . n| per loop
+WELD_TOL = 0.15 # mikktspace vertex welding vs per-triangle formula
+BTN_TOL = 1e-4 # bitangent == sign * (n x t)
+UV_TOL = 1e-6 # re-fetched UV layer vs authored closed form
+
+# dome profile: (r, z) rings from center out, then the raised rim lip
+PROFILE = [(0.02, 0.34), (0.30, 0.30), (0.55, 0.22), (0.78, 0.12),
+ (0.95, 0.05), (1.00, 0.05)]
+
+
+def dome_uv(co):
+ """Polar UVs on the dome: u sweeps the angle, v the radius — the tangent
+ field circulates around the boss (closed form)."""
+ theta = math.atan2(co.y, co.x) / (2 * math.pi)
+ r = math.sqrt(co.x * co.x + co.y * co.y)
+ return (theta % 1.0, min(1.0, r / BOSS_R))
+
+
+def build_buckler():
+ """A round buckler: lathed dome with raised rim, polar-UV dome face."""
+ me = bpy.data.meshes.new("Buckler")
+ bm = bmesh.new()
+ try:
+ rings = []
+ for r, z in PROFILE:
+ rings.append([bm.verts.new((r * math.cos(2 * math.pi * s / SIDES),
+ r * math.sin(2 * math.pi * s / SIDES), z))
+ for s in range(SIDES)])
+ # underside: mirror of the outer two rings, then a flat back
+ back = [[bm.verts.new((r * math.cos(2 * math.pi * s / SIDES),
+ r * math.sin(2 * math.pi * s / SIDES), z - 0.10))
+ for s in range(SIDES)] for r, z in ((1.00, 0.05), (0.30, 0.02))]
+ for k in range(len(rings) - 1):
+ for s in range(SIDES):
+ bm.faces.new((rings[k][s], rings[k][(s + 1) % SIDES],
+ rings[k + 1][(s + 1) % SIDES], rings[k + 1][s]))
+ # rim side wall and underside
+ for s in range(SIDES):
+ bm.faces.new((rings[-1][s], rings[-1][(s + 1) % SIDES],
+ back[0][(s + 1) % SIDES], back[0][s]))
+ for s in range(SIDES):
+ bm.faces.new((back[0][s], back[0][(s + 1) % SIDES],
+ back[1][(s + 1) % SIDES], back[1][s]))
+ # the back cap must be an explicit fan: calc_tangents aborts on any
+ # ngon ("only tris/quads") — one n-gon kills the whole call
+ center = bm.verts.new((0.0, 0.0, -0.08))
+ for s in range(SIDES):
+ bm.faces.new((back[1][(s + 1) % SIDES], back[1][s], center))
+ bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
+ bm.to_mesh(me)
+ finally:
+ bm.free() # the ownership contract, as always
+ for poly in me.polygons:
+ poly.use_smooth = False # faceted boss plates keep the basis unambiguous
+ uv = me.uv_layers.new(name="UVMap")
+ n_dome = (RINGS - 1) * SIDES
+ for poly in me.polygons[:n_dome]:
+ for li in poly.loop_indices:
+ co = me.vertices[me.loops[li].vertex_index].co
+ uv.data[li].uv = dome_uv(co)
+ for poly in me.polygons[n_dome:n_dome + 2 * SIDES]:
+ # rim wall + underside: unwrap as strips (u sweeps the angle, v the
+ # depth) — a planar projection is DEGENERATE on a cylindrical wall
+ # and collapses the tangent onto the normal (dot ~1.0, measured)
+ for li in poly.loop_indices:
+ co = me.vertices[me.loops[li].vertex_index].co
+ theta = math.atan2(co.y, co.x) / (2 * math.pi)
+ uv.data[li].uv = (theta % 1.0, (0.05 - co.z) / 0.23)
+ for poly in me.polygons[n_dome + 2 * SIDES:]: # back fan: flat, planar
+ for li in poly.loop_indices:
+ co = me.vertices[me.loops[li].vertex_index].co
+ uv.data[li].uv = (co.x * 0.5 + 0.5, co.y * 0.5 + 0.5)
+ obj = bpy.data.objects.new("Buckler", me)
+ bpy.context.collection.objects.link(obj)
+ return obj, n_dome
+
+
+def formula_tangents(me, uv, tri):
+ """Per-triangle tangent/bitangent from edges and UV deltas — the
+ independent derivation engines implement. UV deltas are unwrapped so a
+ polar seam inside one triangle does not flip the frame."""
+ vs = [me.vertices[me.loops[li].vertex_index].co for li in tri.loops]
+ uvs = [uv.data[li].uv for li in tri.loops]
+ e1 = vs[1] - vs[0]
+ e2 = vs[2] - vs[0]
+ du1, dv1 = uvs[1].x - uvs[0].x, uvs[1].y - uvs[0].y
+ du2, dv2 = uvs[2].x - uvs[0].x, uvs[2].y - uvs[0].y
+ du1, dv1, du2, dv2 = (d - math.copysign(1.0, d) if abs(d) > 0.5 else d
+ for d in (du1, dv1, du2, dv2))
+ det = du1 * dv2 - du2 * dv1
+ if abs(det) < 1e-12:
+ return None
+ return ((e1 * dv2 - e2 * dv1) / det, (e2 * du1 - e1 * du2) / det)
+
+
+def check(obj, n_dome):
+ me = obj.data
+ me.calc_loop_triangles()
+
+ # contract 1: closed-form topology: two triangles per quad plus the
+ # explicit SIDES-triangle fan in the back cap
+ want_tris = 2 * ((RINGS - 1) * SIDES + 2 * SIDES) + SIDES
+ if len(me.loop_triangles) != want_tris:
+ print(f"ERROR: {len(me.loop_triangles)} loop triangles != closed "
+ f"form {want_tris}", file=sys.stderr)
+ return 3
+
+ # re-fetch the layer AFTER calc_tangents: on 4.5 a handle held across
+ # that call dangles and reads tangent floats (measured err ~1.85 while
+ # authoring); on 5.1 the stale read survives by luck. Never trust a
+ # held handle — re-fetch by name.
+ uv = me.uv_layers["UVMap"]
+
+ # contract 4: the re-fetched layer still holds the authored closed form
+ uv_err = 0.0
+ for poly in me.polygons[:n_dome]:
+ for li in poly.loop_indices:
+ co = me.vertices[me.loops[li].vertex_index].co
+ want = dome_uv(co)
+ uv_err = max(uv_err, (uv.data[li].uv - mathutils.Vector(want)).length)
+ if uv_err > UV_TOL:
+ print(f"ERROR: re-fetched UV layer deviates {uv_err:.3e} from the "
+ "authored polar field — layer contents were reallocated",
+ file=sys.stderr)
+ return 4
+
+ me.calc_tangents(uvmap="UVMap")
+ # re-fetch AGAIN: the handle captured before calc_tangents dangles on
+ # 4.5 (reads there return garbage), corrupting the seam classification
+ uv = me.uv_layers["UVMap"]
+
+ # per-position UV sets: a position carrying more than one UV across the
+ # mesh is a chart seam. mikktspace welds frames there, and the result is
+ # version-sensitive (4.5 welds across and can flip; 5.x splits cleanly).
+ uv_by_pos = {}
+ for li, loop in enumerate(me.loops):
+ key = tuple(round(c, 5) for c in me.vertices[loop.vertex_index].co)
+ uv_by_pos.setdefault(key, set()).add(
+ (round(uv.data[li].uv.x, 5), round(uv.data[li].uv.y, 5)))
+ seam_positions = {k for k, s in uv_by_pos.items() if len(s) > 1}
+
+ def tri_is_seam(tri):
+ """A triangle whose frame is implementation-defined: it touches a
+ chart-seam position, or its own UV deltas cross the polar wrap."""
+ uvs = [uv.data[li].uv for li in tri.loops]
+ for i in range(3):
+ key = tuple(round(c, 5)
+ for c in me.vertices[me.loops[tri.loops[i]].vertex_index].co)
+ if key in seam_positions:
+ return True
+ j = (i + 1) % 3
+ if (abs(uvs[j].x - uvs[i].x) > 0.5 or abs(uvs[j].y - uvs[i].y) > 0.5):
+ return True
+ return False
+
+ unit_err = 0.0
+ dot_err = 0.0
+ btn_err = 0.0
+ sign_bad = 0
+ weld_err = 0.0
+ clean_flips = 0 # flips inside clean triangles: never allowed
+ seam_flips = 0 # flips inside seam triangles: implementation-defined
+ chart_flips = 0 # seam flips at chart-seam positions (4.5 welds these)
+ for tri in me.loop_triangles:
+ seam = tri_is_seam(tri)
+ ft = None if seam else formula_tangents(me, uv, tri)
+ for li in tri.loops:
+ loop = me.loops[li]
+ t = loop.tangent
+ b = loop.bitangent
+ s = loop.bitangent_sign
+ n = loop.normal
+ # contract 2 (everywhere, both versions): unit + orthogonal basis
+ unit_err = max(unit_err, abs(t.length - 1.0))
+ dot_err = max(dot_err, abs(t.dot(n)))
+ # contract 3b (everywhere): bitangent is exactly sign * (n x t)
+ btn_err = max(btn_err, (b - s * n.cross(t)).length)
+ if s not in (-1.0, 1.0):
+ sign_bad += 1
+ # contract 3a (clean triangles): tangents match the independent
+ # derivation within mikktspace's vertex-welding tolerance
+ if ft is not None:
+ fn = ft[0].normalized()
+ if t.dot(fn) < -0.5:
+ clean_flips += 1
+ else:
+ weld_err = max(weld_err, (t - fn).length)
+ elif ft is None:
+ # orientation inside seam triangles is implementation-defined;
+ # count it for the version witness
+ ft2 = formula_tangents(me, uv, tri)
+ if ft2 is not None and t.dot(ft2[0].normalized()) < -0.5:
+ seam_flips += 1
+ key = tuple(round(c, 5)
+ for c in me.vertices[loop.vertex_index].co)
+ if key in seam_positions:
+ chart_flips += 1
+ if unit_err > UNIT_TOL or dot_err > DOT_TOL:
+ print(f"ERROR: tangent basis not orthonormal: unit err {unit_err:.3e}, "
+ f"t.n err {dot_err:.3e}", file=sys.stderr)
+ return 5
+ if btn_err > BTN_TOL or sign_bad:
+ print(f"ERROR: bitangent != sign*(n x t): err {btn_err:.3e}, "
+ f"{sign_bad} bad signs", file=sys.stderr)
+ return 6
+ if weld_err > WELD_TOL:
+ print(f"ERROR: tangents deviate {weld_err:.3e} from the edge/UV "
+ f"closed form (mikktspace weld tol {WELD_TOL})", file=sys.stderr)
+ return 7
+ # contract 3c: a flipped frame inside a CLEAN triangle is never legal —
+ # the tangent field must follow the UVs wherever the UVs are smooth
+ if clean_flips:
+ print(f"ERROR: {clean_flips} flipped tangent(s) inside clean "
+ "triangles — the tangent field does not follow the UVs",
+ file=sys.stderr)
+ return 8
+ # version witness: none in the math — 4.5.11 and 5.1.2 produce identical
+ # counts here (42 seam flips, 9 at chart seams). The earlier draft that
+ # "measured" a 471-vs-9 divergence was reading the stale UV handle from
+ # point 4; the hazard corrupted the measurement itself.
+
+ print(f"sides={SIDES} tris={len(me.loop_triangles)} "
+ f"unit_err={unit_err:.2e} dot_err={dot_err:.2e} "
+ f"btn_err={btn_err:.2e} uv_err={uv_err:.2e}")
+ print(f"weld_err={weld_err:.2e} (tol {WELD_TOL}) sign_bad={sign_bad} "
+ f"clean_flips={clean_flips} seam_flips={seam_flips} "
+ f"chart_flips={chart_flips} seam_positions={len(seam_positions)}")
+ 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
+
+ # brushed steel: the groove rings follow V, so the anisotropic sweep
+ # only reads concentric if the tangent field circulates with the UVs
+ mat = bpy.data.materials.new("BrushedSteel")
+ mat.use_nodes = True
+ nt = mat.node_tree
+ bsdf = nt.nodes["Principled BSDF"]
+ bsdf.inputs["Base Color"].default_value = (0.30, 0.32, 0.36, 1.0)
+ bsdf.inputs["Metallic"].default_value = 1.0
+ bsdf.inputs["Roughness"].default_value = 0.40
+ aniso = bsdf.inputs.get("Anisotropic IOR Level") or bsdf.inputs.get("Anisotropic")
+ if aniso is not None:
+ aniso.default_value = 0.6
+ tangent = nt.nodes.new("ShaderNodeTangent")
+ tangent.direction_type = 'UV_MAP'
+ tan_in = bsdf.inputs.get("Tangent")
+ if tan_in is not None:
+ nt.links.new(tangent.outputs["Tangent"], tan_in)
+ wave = nt.nodes.new("ShaderNodeTexWave")
+ wave.wave_type = 'RINGS'
+ wave.rings_direction = 'SPHERICAL'
+ wave.inputs["Scale"].default_value = 9.0
+ wave.inputs["Distortion"].default_value = 3.0
+ bump = nt.nodes.new("ShaderNodeBump")
+ bump.inputs["Strength"].default_value = 0.07
+ bump.inputs["Distance"].default_value = 0.03
+ nt.links.new(wave.outputs["Fac"], bump.inputs["Height"])
+ nt.links.new(bump.outputs["Normal"], bsdf.inputs["Normal"])
+ obj.data.materials.append(mat)
+
+ # presentation: the buckler leaning back on a low display stand
+ obj.location = (0.0, 0.0, 1.55)
+ obj.rotation_euler = (math.radians(70), 0.0, math.radians(-12))
+ stand_me = bpy.data.meshes.new("Stand")
+ bm = bmesh.new()
+ try:
+ bmesh.ops.create_cube(bm, size=1.0,
+ matrix=mathutils.Matrix.Diagonal((0.9, 0.5, 0.12, 1.0)))
+ bm.to_mesh(stand_me)
+ finally:
+ bm.free()
+ smat = bpy.data.materials.new("StandMetal")
+ smat.use_nodes = True
+ sb = smat.node_tree.nodes["Principled BSDF"]
+ sb.inputs["Base Color"].default_value = (0.09, 0.10, 0.12, 1.0)
+ sb.inputs["Metallic"].default_value = 0.85
+ sb.inputs["Roughness"].default_value = 0.45
+ stand_me.materials.append(smat)
+ stand = bpy.data.objects.new("Stand", stand_me)
+ # under the leaning rim's contact patch, so the cradle visibly carries it
+ stand.location = (0.0, -0.34, 0.55)
+ scene.collection.objects.link(stand)
+
+ 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.03, 0.032, 0.037, 1.0)
+ fb.inputs["Roughness"].default_value = 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, 9.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)
+
+ # shaped warm key, faint cool fill, cool rim, warm wedge on the back wall
+ # (docs/VISUAL-STYLE.md); the glint is the anisotropic streak's driver
+ light("Key", (-4.0, -5.0, 6.0), 500.0, 4.5, (1.0, 0.96, 0.9), (48, 0, -38))
+ light("Fill", (5.0, -4.0, 3.0), 100.0, 9.0, (0.75, 0.85, 1.0), (62, 0, 50))
+ light("Rim", (0.5, 4.5, 5.0), 340.0, 4.0, (0.6, 0.78, 1.0), (-55, 0, 175))
+ light("Wedge", (2.5, 3.5, 4.2), 480.0, 6.0, (1.0, 0.76, 0.5), (-72, 0, 195))
+ light("Glint", (1.5, -5.5, 5.5), 400.0, 1.2, (1.0, 0.9, 0.75), (42, 0, 15))
+
+ cam_data = bpy.data.cameras.new("Cam")
+ cam_data.lens = 55.0
+ cam = bpy.data.objects.new("Cam", cam_data)
+ cam.location = (0.6, -8.8, 2.8)
+ scene.collection.objects.link(cam)
+ target = bpy.data.objects.new("Aim", None)
+ target.location = (0.0, 0.0, 1.35)
+ 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 = 32
+ else:
+ try:
+ scene.eevee.taa_render_samples = 64
+ except AttributeError:
+ pass
+ scene.render.resolution_x = 1280
+ scene.render.resolution_y = 720
+ scene.render.image_settings.file_format = 'PNG'
+ scene.render.filepath = path
+ # AgX would wash the steel toward chalk (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)
+ obj, n_dome = build_buckler()
+ code = check(obj, n_dome)
+ 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 10
+ print(f"rendered still {args.output}")
+
+ print("triangulate-tangents 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)