diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json
index ba19941..676ee22 100644
--- a/.cursor-plugin/plugin.json
+++ b/.cursor-plugin/plugin.json
@@ -82,6 +82,7 @@
"examples/text-version-stamp",
"examples/turntable",
"examples/uv-layer-grid",
+ "examples/vertex-weight-limit",
"examples/vse-cut-list",
"examples/wave-displace"
]
diff --git a/.github/workflows/blender-smoke.yml b/.github/workflows/blender-smoke.yml
index 97eba6b..86d7bf5 100644
--- a/.github/workflows/blender-smoke.yml
+++ b/.github/workflows/blender-smoke.yml
@@ -393,3 +393,14 @@ jobs:
# (bbox) preservation within 1e-3. Exits non-zero on failure.
xvfb-run -a "$BLENDER" --background \
--python examples/lod-decimate-chain/lod_decimate_chain.py --
+
+ - name: Shipped example - vertex weight limit (4-influence cap + LBS)
+ run: |
+ set -euo pipefail
+ # Check only (no render): a five-bone mech arm with rich 5-influence
+ # boots, pruned to the 4-influence engine cap via the data API;
+ # asserts pre/post-limit influence counts, unit weight sums, pose
+ # preservation (pre vs post pruning), exact LBS from the mesh's own
+ # 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 --
diff --git a/README.md b/README.md
index febd367..f7cce23 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@
- 12 skills • 6 rules • 2 templates • 17 snippets • 25 examples
+ 12 skills • 6 rules • 2 templates • 17 snippets • 26 examples
@@ -36,7 +36,7 @@
## Overview
-This repository ships **12 skills, 6 rules, 2 templates, 17 snippets, and 25 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 26 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.
@@ -466,7 +466,7 @@ round-trips through the raw `POINT` buffer.
-Game asset pipeline — 2 examples
+Game asset pipeline — 3 examples
@@ -502,6 +502,23 @@ keeps its closed-form counts), each LOD's evaluated triangle count lands within
survives within 1e-3 — with the aggressive-ratio nose-tip collapse documented
as the caught failure mode.
+
+
+
+
+
+
+
+
+### [vertex-weight-limit](examples/vertex-weight-limit/)
+
+The game-engine max-four-bone-influences constraint, enforced through the data
+API (`v.groups` + `VertexGroup.remove` + renormalize) rather than the
+`bpy.ops.object.vertex_group_limit_total` context path. Asserts the pre-limit
+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.
+
diff --git a/ROADMAP.md b/ROADMAP.md
index e35ba57..b38560b 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -105,6 +105,10 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo
- ~~VSE sequences-to-strips witness~~ **SHIPPED** as `examples/vse-cut-list/` — `.sequences` removed on 5.x (4.5 bridges to `.strips`), `new_effect` end kwarg `frame_end=` (4.5) vs `length=` (5.x), `frame_final_*` deprecated in favor of `left_handle`/`right_handle`/`duration`, TRANSFORM effect type removed, GAMMA_CROSS clamps to the source overlap, effect strips consume inputs only when stacked above them, same-scene scene strips render transparent; save/reload round-trip + tiny-render pixel witness
- ~~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
+- 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`
- 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`)
diff --git a/docs/gallery/assets/vertex-weight-limit-hero.webp b/docs/gallery/assets/vertex-weight-limit-hero.webp
new file mode 100644
index 0000000..f8d27da
Binary files /dev/null and b/docs/gallery/assets/vertex-weight-limit-hero.webp differ
diff --git a/docs/gallery/contact-sheets/vertex-weight-limit-contact-sheet.webp b/docs/gallery/contact-sheets/vertex-weight-limit-contact-sheet.webp
new file mode 100644
index 0000000..4d6852d
Binary files /dev/null and b/docs/gallery/contact-sheets/vertex-weight-limit-contact-sheet.webp differ
diff --git a/docs/gallery/index.html b/docs/gallery/index.html
index ac03ee7..a66c34e 100644
--- a/docs/gallery/index.html
+++ b/docs/gallery/index.html
@@ -480,6 +480,17 @@
View example →
+
+
+
+
+
+
+
A rigged mech arm pruned to the game-engine cap of four bone influences per vertex, through the data API. The check proves no vertex exceeds the cap, weights still sum to one, the pose survives pruning, and the modifier is still exact linear blend skinning.
+
witnesses Limit-total and normalize without bpy.ops: keep the top four groups, VertexGroup.remove the rest, renormalize. LBS is verified against the weights read back from the mesh's own deform layer, not the authoring function.
+
View example →
+
+
diff --git a/docs/gallery/vertex-weight-limit/index.html b/docs/gallery/vertex-weight-limit/index.html
new file mode 100644
index 0000000..0ffa8d2
--- /dev/null
+++ b/docs/gallery/vertex-weight-limit/index.html
@@ -0,0 +1,679 @@
+
+
+
+
+
+ vertex-weight-limit — Examples — Blender Developer Tools
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Skip to content
+
+
+ vertex-weight-limit
+ A rigged mech arm pruned to the game-engine cap of four bone influences per vertex, through the data API. The check proves no vertex exceeds the cap, weights still sum to one, the pose survives pruning, and the modifier is still exact linear blend skinning.
+
+
+
+
+
+ Rendered headless by the example itself — click to zoom.
+ witnesses Limit-total and normalize without bpy.ops: keep the top four groups, VertexGroup.remove the rest, renormalize. LBS is verified against the weights read back from the mesh's own deform layer, not the authoring function.
+
+
blender --background --python examples/vertex-weight-limit/vertex_weight_limit.py --
+
Copy
+
+
+A runnable example that rigs a mech arm — pedestal mount and pauldron, orange armor shells, ribbed flex boots, wrist, three claw prongs — with deliberately rich five-bone weight bumps in the boots, then enforces the game-engine maximum of four bone influences per vertex through the data API, following mesh-editing-and-bmesh and building on the linear-blend-skinning precedent of armature-bend .
+What it witnesses: the skinning constraint every game engine enforces and AI-generated rigging code most often violates silently.
+1. The limit is a data-API operation, not a context operator. Instead of bpy.ops.object.vertex_group_limit_total, the example reads each vertex's groups, keeps the top four by weight, VertexGroup.removes the rest, and renormalizes the survivors. Dropping without renormalizing leaves sums at 0.986 — a mesh that shrinks toward the origin under load (the check's measured failure, 1.438e-02 off unit sum). 2. The armature modifier is still exactly linear blend skinning after the limit: every depsgraph-evaluated vertex equals Σ wᵢ · (pose.matrix @ bone.matrix_local.inverted()) @ rest, with the weights read back from the mesh's own deform layer (v.groups) — the weights on the mesh are the contract, not the weights you meant to write. Measured lbs_err = 3.0e-07. 3. Pruning must not damage the pose. Evaluated positions before and after the limit are held within 0.05 (measured 3.0e-03), the pedestal mount stays exactly pinned (Root is unposed), and the pre-limit authoring really carries five influences in the boots — otherwise the witness would be vacuous.
+The vertex-group API (v.groups, VertexGroup.add/remove) is stable between Blender 4.5 LTS and 5.1 — the example runs identically on both, which is itself the version witness (measured values match to the digit).
+The render shows the pruned arm mid-pose: the elbow bellows and wrist boot flex through the bend, the teal accent ring rides the forearm — proof that the limited weights still deform as authored.
+Run
+# Cheap correctness check (no render) — the CI check:
+blender --background --python vertex_weight_limit.py --
+
+# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
+blender --background --python vertex_weight_limit.py -- --output arm.png
+blender --background --python vertex_weight_limit.py -- --output arm.png --engine cycles
+It exits non-zero on failure (vacuous authoring, a vertex over the cap, broken weight sums, pose damaged by pruning, LBS drift, or a moved Root mount). The blender-smoke workflow runs the check on Blender 4.5 LTS and 5.1.
+
+
+ Source
+
+ """A rigged mech arm pruned to the 4-influence game-engine limit — a runnable example.
+
+Witnesses the skinning constraint every game engine enforces and AI-generated
+rigging code most often violates silently: **no more than four bone influences
+per vertex, weights summing to one**.
+
+1. The limit is enforced through the data API, not the context-heavy
+ ``bpy.ops.object.vertex_group_limit_total`` path: read each vertex's groups,
+ keep the top four by weight, ``VertexGroup.remove`` the rest, then
+ renormalize the survivors to sum 1. Dropping without renormalizing leaves
+ sums < 1 and the mesh shrinks toward the origin under load.
+2. The armature modifier is still exactly linear blend skinning (the
+ armature-bend precedent — built on, not duplicated): after limiting, every
+ depsgraph-evaluated vertex equals sum_i w_i (pose.matrix @
+ rest_local.inverted()) @ rest with the weights **read back from the mesh's
+ own deform layer** (``v.groups``), not from the authoring function. The
+ weights on the mesh are the contract, not the weights you meant to write.
+3. Pruning must not damage the pose: evaluated positions before and after the
+ limit are compared and held within tolerance. The root stays pinned — the
+ pedestal mount never moves.
+
+The vertex-group API (``v.groups``, ``VertexGroup.add``/``remove``) is stable
+between Blender 4.5 LTS and 5.1 — the example runs identically on both, which
+is itself the version witness.
+
+By default it runs only the correctness check (no render) — the CI smoke
+check. Pass --output to also render a still:
+
+ blender --background --python vertex_weight_limit.py -- # check only
+ blender --background --python vertex_weight_limit.py -- --output a.png # + render
+"""
+import bpy, bmesh, sys, os, math, argparse
+import mathutils
+
+SIDES = 32
+MAX_INFLUENCES = 4 # the engine constraint being witnessed
+BUMP_R = 1.8 # flex-boot blend support; guarantees 5 pre-limit
+LBS_TOL = 5e-4 # float32 mesh coords vs double pose matrices
+SUM_TOL = 1e-5 # per-vertex weight sum after renormalize
+POSE_TOL = 0.05 # pruning must not move the pose beyond this
+BONES = ("Root" , "Shoulder" , "Elbow" , "Wrist" , "Claw" )
+BONE_SPANS = {"Root" : (-0.35 , 0.10 ), "Shoulder" : (0.10 , 1.35 ),
+ "Elbow" : (1.35 , 2.55 ), "Wrist" : (2.55 , 2.90 ), "Claw" : (2.90 , 3.35 )}
+BONE_CENTERS = {b: (s[0 ] + s[1 ]) / 2 for b, s in BONE_SPANS.items()}
+POSE_DEG = {"Shoulder" : -8.0 , "Elbow" : -35.0 , "Wrist" : -18.0 , "Claw" : 12.0 }
+
+# armor palette slots
+GUNMETAL, ORANGE, RUBBER, ACCENT = 0 , 1 , 2 , 3
+
+
+def bump(z, center, radius=BUMP_R):
+ """Smooth, compactly-supported influence: (1 - (d/r)^2)^2 inside r, else 0."""
+ d = abs(z - center) / radius
+ return (1.0 - d * d) ** 2 if d < 1.0 else 0.0
+
+
+def flex_weights(z):
+ """Rich pre-limit weights over all five bones — boots blend broadly."""
+ w = [bump(z, BONE_CENTERS[b]) for b in BONES]
+ total = sum(w)
+ return [x / total for x in w]
+
+
+def lathe_part(bm, rings, mat):
+ """Revolve (z, r) rings around Z; returns the ring vertex lists."""
+ out = []
+ for z, r in rings:
+ out.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)])
+ for k in range(len(out) - 1 ):
+ for s in range(SIDES):
+ f = bm.faces.new((out[k][s], out[k][(s + 1 ) % SIDES],
+ out[k + 1 ][(s + 1 ) % SIDES], out[k + 1 ][s]))
+ f.material_index = mat
+ return out
+
+
+def build_arm():
+ """The mech arm: mount + pauldron, upper arm, elbow boot + ball, forearm,
+ wrist boot + ball, hand plate, three claw prongs — every ring closed form."""
+ me = bpy.data.meshes.new("MechArm" )
+ part_of = [] # creation-order bone name (or 'FLEX') per mesh vertex
+ bm = bmesh.new()
+ try :
+ def tag(rings_verts, bone):
+ for ring in rings_verts:
+ for v in ring:
+ part_of.append(bone)
+
+ # pedestal mount + pauldron cap (rigid on Root)
+ tag(lathe_part(bm, [(-0.35 , 0.32 ), (-0.05 , 0.32 ), (0.0 , 0.48 ),
+ (0.10 , 0.50 ), (0.22 , 0.34 ), (0.28 , 0.14 )],
+ GUNMETAL), "Root" )
+ # upper arm armor with two plate ridges (rigid on Shoulder)
+ tag(lathe_part(bm, [(0.10 , 0.26 ), (0.30 , 0.30 ), (0.38 , 0.33 ),
+ (0.46 , 0.30 ), (0.70 , 0.29 ), (0.78 , 0.32 ),
+ (0.86 , 0.29 ), (1.10 , 0.27 ), (1.15 , 0.26 )],
+ ORANGE), "Shoulder" )
+ # elbow flex boot (broad blend — the >4-influence region)
+ tag(lathe_part(bm, [(1.15 , 0.25 ), (1.22 , 0.20 ), (1.28 , 0.24 ),
+ (1.35 , 0.20 ), (1.42 , 0.24 ), (1.48 , 0.21 ), (1.55 , 0.24 )], RUBBER), "FLEX" )
+ n0 = len(bm.verts)
+ bmesh.ops.create_uvsphere(bm, u_segments=24 , v_segments=12 , radius=0.23 )
+ for v in bm.verts[n0:]:
+ v.co.z += 1.35
+ part_of.append("Elbow" )
+ # forearm armor with ridges + teal accent ring (rigid on Elbow)
+ tag(lathe_part(bm, [(1.55 , 0.24 ), (1.70 , 0.26 ), (1.76 , 0.28 ),
+ (1.82 , 0.26 ), (2.00 , 0.24 ), (2.10 , 0.27 ),
+ (2.16 , 0.27 ), (2.22 , 0.24 ), (2.45 , 0.22 )],
+ ORANGE), "Elbow" )
+ # teal accent ring inset on the forearm
+ tag(lathe_part(bm, [(2.10 , 0.275 ), (2.16 , 0.275 )], ACCENT), "Elbow" )
+ # wrist flex boot + ball (second >4-influence region)
+ tag(lathe_part(bm, [(2.45 , 0.21 ), (2.52 , 0.17 ), (2.60 , 0.20 ),
+ (2.68 , 0.16 ), (2.76 , 0.19 ), (2.84 , 0.16 ), (2.95 , 0.15 )], RUBBER), "FLEX" )
+ n0 = len(bm.verts)
+ bmesh.ops.create_uvsphere(bm, u_segments=24 , v_segments=12 , radius=0.20 )
+ for v in bm.verts[n0:]:
+ v.co.z += 2.72
+ part_of.append("Wrist" )
+ # hand plate (rigid on Wrist)
+ tag(lathe_part(bm, [(2.55 , 0.16 ), (2.90 , 0.17 )], GUNMETAL), "Wrist" )
+ # three claw prongs, splayed at 120 deg (rigid on Claw)
+ for k in range(3 ):
+ a = 2 * math.pi * k / 3
+ dx, dy = math.cos(a), math.sin(a)
+ rings = []
+ for z, r, off in ((2.90 , 0.10 , 0.13 ), (3.12 , 0.08 , 0.19 ),
+ (3.40 , 0.03 , 0.28 )):
+ rings.append([bm.verts.new((off * dx + r * math.cos(2 * math.pi * s / 12 ),
+ off * dy + r * math.sin(2 * math.pi * s / 12 ), z))
+ for s in range(12 )])
+ for j in range(len(rings) - 1 ):
+ for s in range(12 ):
+ f = bm.faces.new((rings[j][s], rings[j][(s + 1 ) % 12 ],
+ rings[j + 1 ][(s + 1 ) % 12 ], rings[j + 1 ][s]))
+ f.material_index = GUNMETAL
+ for ring in rings:
+ for v in ring:
+ part_of.append("Claw" )
+ 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
+ obj = bpy.data.objects.new("MechArm" , me)
+ bpy.context.collection.objects.link(obj)
+ return obj, part_of
+
+
+def assign_weights(obj, part_of):
+ """Author the rich pre-limit weights: hard single-bone on armor, broad
+ five-bone bumps in the flex boots."""
+ groups = {b: obj.vertex_groups.new(name=b) for b in BONES}
+ if len(part_of) != len(obj.data.vertices):
+ raise RuntimeError(f" part tag count {len(part_of)} != vert count "
+ f" {len(obj.data.vertices)}" )
+ for idx, v in enumerate(obj.data.vertices):
+ bone = part_of[idx]
+ if bone == "FLEX" :
+ for b, w in zip(BONES, flex_weights(v.co.z)):
+ if w > 0.0 :
+ groups[b].add([idx], w, 'REPLACE' )
+ else :
+ groups[bone].add([idx], 1.0 , 'REPLACE' )
+ return groups
+
+
+def build_rig(obj):
+ arm_data = bpy.data.armatures.new("ArmRig" )
+ arm = bpy.data.objects.new("ArmRig" , arm_data)
+ bpy.context.collection.objects.link(arm)
+ bpy.context.view_layer.objects.active = arm
+ bpy.ops.object.mode_set(mode='EDIT' )
+ prev = None
+ for name in BONES:
+ eb = arm_data.edit_bones.new(name)
+ z0, z1 = BONE_SPANS[name]
+ eb.head = (0.0 , 0.0 , z0)
+ eb.tail = (0.0 , 0.0 , z1)
+ if prev is not None :
+ eb.parent = prev
+ eb.use_connect = True
+ prev = eb
+ bpy.ops.object.mode_set(mode='OBJECT' )
+ mod = obj.modifiers.new("Armature" , 'ARMATURE' )
+ mod.object = arm
+ mod.use_vertex_groups = True
+ mod.use_bone_envelopes = False
+ for name, deg in POSE_DEG.items():
+ pb = arm.pose.bones[name]
+ pb.rotation_mode = 'XYZ'
+ pb.rotation_euler.x = math.radians(deg)
+ return arm
+
+
+def eval_positions(obj):
+ """Evaluated vertex positions via the depsgraph; reference released by contract."""
+ deps = bpy.context.evaluated_depsgraph_get()
+ ob_eval = obj.evaluated_get(deps)
+ me = ob_eval.to_mesh()
+ try :
+ return [tuple(v.co) for v in me.vertices]
+ finally :
+ ob_eval.to_mesh_clear()
+
+
+def check(obj, arm, groups, pose_before):
+ me = obj.data
+
+ # pre-limit witness: the flex boots really carry five influences
+ pre_max = max(len(v.groups) for v in me.vertices)
+ if pre_max != 5 :
+ print(f" ERROR: pre-limit max influences {pre_max} != 5 — the rich "
+ "authoring drifted; the witness is vacuous" , file=sys.stderr)
+ return 3
+
+ # the limit, through the data API: keep top-4, drop the rest, renormalize
+ changed = 0
+ for v in me.vertices:
+ gs = sorted(v.groups, key=lambda g: -g.weight)
+ if len(gs) > MAX_INFLUENCES:
+ changed += 1
+ for g in gs[MAX_INFLUENCES:]:
+ groups[BONES[g.group]].remove([v.index])
+ kept = [g for g in v.groups]
+ total = sum(g.weight for g in kept)
+ for g in kept:
+ groups[BONES[g.group]].add([v.index], g.weight / total, 'REPLACE' )
+
+ # contract 1: no vertex exceeds the engine limit
+ post_max = max(len(v.groups) for v in me.vertices)
+ if post_max > MAX_INFLUENCES:
+ print(f" ERROR: vertex carries {post_max} groups after the limit — "
+ f" engines cap at {MAX_INFLUENCES}" , file=sys.stderr)
+ return 4
+ if changed == 0 :
+ print("ERROR: the limit changed nothing — no vertex exceeded the cap, "
+ "the witness is vacuous" , file=sys.stderr)
+ return 5
+
+ # contract 2: every vertex's weights sum to 1 (or the vertex is unskinned)
+ sum_err = 0.0
+ for v in me.vertices:
+ if v.groups:
+ sum_err = max(sum_err, abs(sum(g.weight for g in v.groups) - 1.0 ))
+ if sum_err > SUM_TOL:
+ print(f" ERROR: weight sums deviate {sum_err:.3e } from 1.0 after "
+ "renormalize (dropped groups were not re-balanced)" , file=sys.stderr)
+ return 6
+
+ bpy.context.view_layer.update()
+ pose_after = eval_positions(obj)
+
+ # contract 3: pruning did not damage the pose
+ pose_dev = max((mathutils.Vector(a) - mathutils.Vector(b)).length
+ for a, b in zip(pose_before, pose_after))
+ if pose_dev > POSE_TOL:
+ print(f" ERROR: limiting moved the pose by {pose_dev:.4f } "
+ f" (tol {POSE_TOL}) — pruning damaged deformation " , file=sys.stderr)
+ return 7
+
+ # contract 4: the modifier is still exactly LBS, with the weights read
+ # back from the mesh's own deform layer (armature-bend's math, built on)
+ mats = {n: arm.pose.bones[n].matrix @ arm.data.bones[n].matrix_local.inverted()
+ for n in BONES}
+ lbs_err = 0.0
+ for v in me.vertices:
+ predicted = sum((g.weight * (mats[BONES[g.group]] @ v.co)
+ for g in v.groups),
+ start=mathutils.Vector((0.0 , 0.0 , 0.0 )))
+ lbs_err = max(lbs_err, (mathutils.Vector(pose_after[v.index])
+ - predicted).length)
+ if lbs_err > LBS_TOL:
+ print(f" ERROR: evaluated mesh deviates {lbs_err:.6f } from LBS over the "
+ f" limited weights (tol {LBS_TOL}) " , file=sys.stderr)
+ return 8
+
+ # the pedestal mount is rigid on Root and must not move
+ root_move = max((mathutils.Vector(pose_after[i]) - v.co).length
+ for i, v in enumerate(me.vertices)
+ if len(v.groups) == 1 and v.groups[0 ].weight > 0.99
+ and BONES[v.groups[0 ].group] == "Root" )
+ if root_move > 1e-5 :
+ print(f" ERROR: Root-weighted mount moved {root_move:.6f } (Root is unposed) " ,
+ file=sys.stderr)
+ return 9
+
+ print(f" verts= {len(me.vertices)} pre_max= {pre_max} post_max= {post_max} "
+ f" limited_verts= {changed}" )
+ print(f" sum_err= {sum_err:.2e } (tol {SUM_TOL}) pose_dev= {pose_dev:.2e } "
+ f" (tol {POSE_TOL}) lbs_err= {lbs_err:.2e } (tol {LBS_TOL}) "
+ f" root_move= {root_move:.2e }" )
+ return 0
+
+
+def make_materials():
+ def pbr(name, base, metallic, roughness, emission=None , strength=0.0 ):
+ mat = bpy.data.materials.new(name)
+ mat.use_nodes = True
+ b = mat.node_tree.nodes["Principled BSDF" ]
+ b.inputs["Base Color" ].default_value = (*base, 1.0 )
+ b.inputs["Metallic" ].default_value = metallic
+ b.inputs["Roughness" ].default_value = roughness
+ if emission is not None :
+ sock = b.inputs.get("Emission Color" ) or b.inputs["Emission" ]
+ sock.default_value = (*emission, 1.0 )
+ b.inputs["Emission Strength" ].default_value = strength
+ return mat
+ return [
+ pbr("Gunmetal" , (0.11 , 0.12 , 0.14 ), 0.9 , 0.32 ),
+ pbr("HazardOrange" , (0.82 , 0.30 , 0.08 ), 0.10 , 0.45 ),
+ pbr("FlexRubber" , (0.05 , 0.05 , 0.06 ), 0.0 , 0.85 ),
+ pbr("TealAccent" , (0.03 , 0.22 , 0.26 ), 0.0 , 0.35 ,
+ emission=(0.10 , 0.65 , 0.72 ), strength=2.2 ),
+ ]
+
+
+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
+
+ 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)
+ light("Key" , (-4.0 , -5.0 , 6.0 ), 600.0 , 4.5 , (1.0 , 0.96 , 0.9 ), (48 , 0 , -38 ))
+ light("Fill" , (5.0 , -4.0 , 3.0 ), 110.0 , 9.0 , (0.75 , 0.85 , 1.0 ), (62 , 0 , 50 ))
+ light("Rim" , (0.5 , 4.5 , 5.0 ), 350.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 ))
+
+ cam_data = bpy.data.cameras.new("Cam" )
+ cam_data.lens = 52.0
+ cam = bpy.data.objects.new("Cam" , cam_data)
+ cam.location = (5.3 , -6.2 , 2.1 )
+ scene.collection.objects.link(cam)
+ target = bpy.data.objects.new("Aim" , None )
+ target.location = (-0.15 , 0.0 , 1.5 )
+ 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 hazard orange and teal accent toward pastel
+ # (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, part_of = build_arm()
+ for m in make_materials():
+ obj.data.materials.append(m)
+ groups = assign_weights(obj, part_of)
+ arm = build_rig(obj)
+ bpy.context.view_layer.update()
+ pose_before = eval_positions(obj)
+ code = check(obj, arm, groups, pose_before)
+ 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("vertex-weight-limit 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 211eaa6..64f24e1 100644
--- a/examples/gallery.json
+++ b/examples/gallery.json
@@ -309,6 +309,18 @@
"depsgraph",
"mesh"
]
+ },
+ {
+ "name": "vertex-weight-limit",
+ "dir": "examples/vertex-weight-limit",
+ "teaches": "A rigged mech arm pruned to the game-engine cap of four bone influences per vertex, through the data API. The check proves no vertex exceeds the cap, weights still sum to one, the pose survives pruning, and the modifier is still exact linear blend skinning.",
+ "witnessesFix": "Limit-total and normalize without bpy.ops: keep the top four groups, VertexGroup.remove the rest, renormalize. LBS is verified against the weights read back from the mesh's own deform layer, not the authoring function.",
+ "hero": "docs/gallery/assets/vertex-weight-limit-hero.webp",
+ "preview": "examples/vertex-weight-limit/preview.webp",
+ "tags": [
+ "armature",
+ "mesh"
+ ]
}
]
}
diff --git a/examples/vertex-weight-limit/README.md b/examples/vertex-weight-limit/README.md
new file mode 100644
index 0000000..8d080c1
--- /dev/null
+++ b/examples/vertex-weight-limit/README.md
@@ -0,0 +1,52 @@
+# Vertex Weight Limit
+
+A runnable example that rigs a mech arm — pedestal mount and pauldron, orange
+armor shells, ribbed flex boots, wrist, three claw prongs — with deliberately
+rich five-bone weight bumps in the boots, then enforces the game-engine
+**maximum of four bone influences per vertex** through the data API, following
+[`mesh-editing-and-bmesh`](../../skills/mesh-editing-and-bmesh/SKILL.md) and
+building on the linear-blend-skinning precedent of
+[`armature-bend`](../armature-bend/).
+
+**What it witnesses:** the skinning constraint every game engine enforces and
+AI-generated rigging code most often violates silently.
+
+1. **The limit is a data-API operation, not a context operator.** Instead of
+ `bpy.ops.object.vertex_group_limit_total`, the example reads each vertex's
+ groups, keeps the top four by weight, `VertexGroup.remove`s the rest, and
+ renormalizes the survivors. Dropping without renormalizing leaves sums at
+ 0.986 — a mesh that shrinks toward the origin under load (the check's
+ measured failure, 1.438e-02 off unit sum).
+2. **The armature modifier is still exactly linear blend skinning** after the
+ limit: every depsgraph-evaluated vertex equals
+ `Σ wᵢ · (pose.matrix @ bone.matrix_local.inverted()) @ rest`, with the
+ weights **read back from the mesh's own deform layer** (`v.groups`) — the
+ weights on the mesh are the contract, not the weights you meant to write.
+ Measured `lbs_err = 3.0e-07`.
+3. **Pruning must not damage the pose.** Evaluated positions before and after
+ the limit are held within 0.05 (measured 3.0e-03), the pedestal mount stays
+ exactly pinned (Root is unposed), and the pre-limit authoring really carries
+ five influences in the boots — otherwise the witness would be vacuous.
+
+The vertex-group API (`v.groups`, `VertexGroup.add`/`remove`) is stable between
+Blender 4.5 LTS and 5.1 — the example runs identically on both, which is itself
+the version witness (measured values match to the digit).
+
+The render shows the pruned arm mid-pose: the elbow bellows and wrist boot flex
+through the bend, the teal accent ring rides the forearm — proof that the
+limited weights still deform as authored.
+
+## Run
+
+```bash
+# Cheap correctness check (no render) — the CI check:
+blender --background --python vertex_weight_limit.py --
+
+# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
+blender --background --python vertex_weight_limit.py -- --output arm.png
+blender --background --python vertex_weight_limit.py -- --output arm.png --engine cycles
+```
+
+It exits non-zero on failure (vacuous authoring, a vertex over the cap, broken
+weight sums, pose damaged by pruning, LBS drift, or a moved Root mount). The
+`blender-smoke` workflow runs the check on Blender 4.5 LTS and 5.1.
diff --git a/examples/vertex-weight-limit/preview.webp b/examples/vertex-weight-limit/preview.webp
new file mode 100644
index 0000000..b07f84b
Binary files /dev/null and b/examples/vertex-weight-limit/preview.webp differ
diff --git a/examples/vertex-weight-limit/vertex_weight_limit.py b/examples/vertex-weight-limit/vertex_weight_limit.py
new file mode 100644
index 0000000..ff750c6
--- /dev/null
+++ b/examples/vertex-weight-limit/vertex_weight_limit.py
@@ -0,0 +1,434 @@
+"""A rigged mech arm pruned to the 4-influence game-engine limit — a runnable example.
+
+Witnesses the skinning constraint every game engine enforces and AI-generated
+rigging code most often violates silently: **no more than four bone influences
+per vertex, weights summing to one**.
+
+1. The limit is enforced through the data API, not the context-heavy
+ ``bpy.ops.object.vertex_group_limit_total`` path: read each vertex's groups,
+ keep the top four by weight, ``VertexGroup.remove`` the rest, then
+ renormalize the survivors to sum 1. Dropping without renormalizing leaves
+ sums < 1 and the mesh shrinks toward the origin under load.
+2. The armature modifier is still exactly linear blend skinning (the
+ armature-bend precedent — built on, not duplicated): after limiting, every
+ depsgraph-evaluated vertex equals sum_i w_i (pose.matrix @
+ rest_local.inverted()) @ rest with the weights **read back from the mesh's
+ own deform layer** (``v.groups``), not from the authoring function. The
+ weights on the mesh are the contract, not the weights you meant to write.
+3. Pruning must not damage the pose: evaluated positions before and after the
+ limit are compared and held within tolerance. The root stays pinned — the
+ pedestal mount never moves.
+
+The vertex-group API (``v.groups``, ``VertexGroup.add``/``remove``) is stable
+between Blender 4.5 LTS and 5.1 — the example runs identically on both, which
+is itself the version witness.
+
+By default it runs only the correctness check (no render) — the CI smoke
+check. Pass --output to also render a still:
+
+ blender --background --python vertex_weight_limit.py -- # check only
+ blender --background --python vertex_weight_limit.py -- --output a.png # + render
+"""
+import bpy, bmesh, sys, os, math, argparse
+import mathutils
+
+SIDES = 32
+MAX_INFLUENCES = 4 # the engine constraint being witnessed
+BUMP_R = 1.8 # flex-boot blend support; guarantees 5 pre-limit
+LBS_TOL = 5e-4 # float32 mesh coords vs double pose matrices
+SUM_TOL = 1e-5 # per-vertex weight sum after renormalize
+POSE_TOL = 0.05 # pruning must not move the pose beyond this
+BONES = ("Root", "Shoulder", "Elbow", "Wrist", "Claw")
+BONE_SPANS = {"Root": (-0.35, 0.10), "Shoulder": (0.10, 1.35),
+ "Elbow": (1.35, 2.55), "Wrist": (2.55, 2.90), "Claw": (2.90, 3.35)}
+BONE_CENTERS = {b: (s[0] + s[1]) / 2 for b, s in BONE_SPANS.items()}
+POSE_DEG = {"Shoulder": -8.0, "Elbow": -35.0, "Wrist": -18.0, "Claw": 12.0}
+
+# armor palette slots
+GUNMETAL, ORANGE, RUBBER, ACCENT = 0, 1, 2, 3
+
+
+def bump(z, center, radius=BUMP_R):
+ """Smooth, compactly-supported influence: (1 - (d/r)^2)^2 inside r, else 0."""
+ d = abs(z - center) / radius
+ return (1.0 - d * d) ** 2 if d < 1.0 else 0.0
+
+
+def flex_weights(z):
+ """Rich pre-limit weights over all five bones — boots blend broadly."""
+ w = [bump(z, BONE_CENTERS[b]) for b in BONES]
+ total = sum(w)
+ return [x / total for x in w]
+
+
+def lathe_part(bm, rings, mat):
+ """Revolve (z, r) rings around Z; returns the ring vertex lists."""
+ out = []
+ for z, r in rings:
+ out.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)])
+ for k in range(len(out) - 1):
+ for s in range(SIDES):
+ f = bm.faces.new((out[k][s], out[k][(s + 1) % SIDES],
+ out[k + 1][(s + 1) % SIDES], out[k + 1][s]))
+ f.material_index = mat
+ return out
+
+
+def build_arm():
+ """The mech arm: mount + pauldron, upper arm, elbow boot + ball, forearm,
+ wrist boot + ball, hand plate, three claw prongs — every ring closed form."""
+ me = bpy.data.meshes.new("MechArm")
+ part_of = [] # creation-order bone name (or 'FLEX') per mesh vertex
+ bm = bmesh.new()
+ try:
+ def tag(rings_verts, bone):
+ for ring in rings_verts:
+ for v in ring:
+ part_of.append(bone)
+
+ # pedestal mount + pauldron cap (rigid on Root)
+ tag(lathe_part(bm, [(-0.35, 0.32), (-0.05, 0.32), (0.0, 0.48),
+ (0.10, 0.50), (0.22, 0.34), (0.28, 0.14)],
+ GUNMETAL), "Root")
+ # upper arm armor with two plate ridges (rigid on Shoulder)
+ tag(lathe_part(bm, [(0.10, 0.26), (0.30, 0.30), (0.38, 0.33),
+ (0.46, 0.30), (0.70, 0.29), (0.78, 0.32),
+ (0.86, 0.29), (1.10, 0.27), (1.15, 0.26)],
+ ORANGE), "Shoulder")
+ # elbow flex boot (broad blend — the >4-influence region)
+ tag(lathe_part(bm, [(1.15, 0.25), (1.22, 0.20), (1.28, 0.24),
+ (1.35, 0.20), (1.42, 0.24), (1.48, 0.21), (1.55, 0.24)], RUBBER), "FLEX")
+ n0 = len(bm.verts)
+ bmesh.ops.create_uvsphere(bm, u_segments=24, v_segments=12, radius=0.23)
+ for v in bm.verts[n0:]:
+ v.co.z += 1.35
+ part_of.append("Elbow")
+ # forearm armor with ridges + teal accent ring (rigid on Elbow)
+ tag(lathe_part(bm, [(1.55, 0.24), (1.70, 0.26), (1.76, 0.28),
+ (1.82, 0.26), (2.00, 0.24), (2.10, 0.27),
+ (2.16, 0.27), (2.22, 0.24), (2.45, 0.22)],
+ ORANGE), "Elbow")
+ # teal accent ring inset on the forearm
+ tag(lathe_part(bm, [(2.10, 0.275), (2.16, 0.275)], ACCENT), "Elbow")
+ # wrist flex boot + ball (second >4-influence region)
+ tag(lathe_part(bm, [(2.45, 0.21), (2.52, 0.17), (2.60, 0.20),
+ (2.68, 0.16), (2.76, 0.19), (2.84, 0.16), (2.95, 0.15)], RUBBER), "FLEX")
+ n0 = len(bm.verts)
+ bmesh.ops.create_uvsphere(bm, u_segments=24, v_segments=12, radius=0.20)
+ for v in bm.verts[n0:]:
+ v.co.z += 2.72
+ part_of.append("Wrist")
+ # hand plate (rigid on Wrist)
+ tag(lathe_part(bm, [(2.55, 0.16), (2.90, 0.17)], GUNMETAL), "Wrist")
+ # three claw prongs, splayed at 120 deg (rigid on Claw)
+ for k in range(3):
+ a = 2 * math.pi * k / 3
+ dx, dy = math.cos(a), math.sin(a)
+ rings = []
+ for z, r, off in ((2.90, 0.10, 0.13), (3.12, 0.08, 0.19),
+ (3.40, 0.03, 0.28)):
+ rings.append([bm.verts.new((off * dx + r * math.cos(2 * math.pi * s / 12),
+ off * dy + r * math.sin(2 * math.pi * s / 12), z))
+ for s in range(12)])
+ for j in range(len(rings) - 1):
+ for s in range(12):
+ f = bm.faces.new((rings[j][s], rings[j][(s + 1) % 12],
+ rings[j + 1][(s + 1) % 12], rings[j + 1][s]))
+ f.material_index = GUNMETAL
+ for ring in rings:
+ for v in ring:
+ part_of.append("Claw")
+ 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
+ obj = bpy.data.objects.new("MechArm", me)
+ bpy.context.collection.objects.link(obj)
+ return obj, part_of
+
+
+def assign_weights(obj, part_of):
+ """Author the rich pre-limit weights: hard single-bone on armor, broad
+ five-bone bumps in the flex boots."""
+ groups = {b: obj.vertex_groups.new(name=b) for b in BONES}
+ if len(part_of) != len(obj.data.vertices):
+ raise RuntimeError(f"part tag count {len(part_of)} != vert count "
+ f"{len(obj.data.vertices)}")
+ for idx, v in enumerate(obj.data.vertices):
+ bone = part_of[idx]
+ if bone == "FLEX":
+ for b, w in zip(BONES, flex_weights(v.co.z)):
+ if w > 0.0:
+ groups[b].add([idx], w, 'REPLACE')
+ else:
+ groups[bone].add([idx], 1.0, 'REPLACE')
+ return groups
+
+
+def build_rig(obj):
+ arm_data = bpy.data.armatures.new("ArmRig")
+ arm = bpy.data.objects.new("ArmRig", arm_data)
+ bpy.context.collection.objects.link(arm)
+ bpy.context.view_layer.objects.active = arm
+ bpy.ops.object.mode_set(mode='EDIT')
+ prev = None
+ for name in BONES:
+ eb = arm_data.edit_bones.new(name)
+ z0, z1 = BONE_SPANS[name]
+ eb.head = (0.0, 0.0, z0)
+ eb.tail = (0.0, 0.0, z1)
+ if prev is not None:
+ eb.parent = prev
+ eb.use_connect = True
+ prev = eb
+ bpy.ops.object.mode_set(mode='OBJECT')
+ mod = obj.modifiers.new("Armature", 'ARMATURE')
+ mod.object = arm
+ mod.use_vertex_groups = True
+ mod.use_bone_envelopes = False
+ for name, deg in POSE_DEG.items():
+ pb = arm.pose.bones[name]
+ pb.rotation_mode = 'XYZ'
+ pb.rotation_euler.x = math.radians(deg)
+ return arm
+
+
+def eval_positions(obj):
+ """Evaluated vertex positions via the depsgraph; reference released by contract."""
+ deps = bpy.context.evaluated_depsgraph_get()
+ ob_eval = obj.evaluated_get(deps)
+ me = ob_eval.to_mesh()
+ try:
+ return [tuple(v.co) for v in me.vertices]
+ finally:
+ ob_eval.to_mesh_clear()
+
+
+def check(obj, arm, groups, pose_before):
+ me = obj.data
+
+ # pre-limit witness: the flex boots really carry five influences
+ pre_max = max(len(v.groups) for v in me.vertices)
+ if pre_max != 5:
+ print(f"ERROR: pre-limit max influences {pre_max} != 5 — the rich "
+ "authoring drifted; the witness is vacuous", file=sys.stderr)
+ return 3
+
+ # the limit, through the data API: keep top-4, drop the rest, renormalize
+ changed = 0
+ for v in me.vertices:
+ gs = sorted(v.groups, key=lambda g: -g.weight)
+ if len(gs) > MAX_INFLUENCES:
+ changed += 1
+ for g in gs[MAX_INFLUENCES:]:
+ groups[BONES[g.group]].remove([v.index])
+ kept = [g for g in v.groups]
+ total = sum(g.weight for g in kept)
+ for g in kept:
+ groups[BONES[g.group]].add([v.index], g.weight / total, 'REPLACE')
+
+ # contract 1: no vertex exceeds the engine limit
+ post_max = max(len(v.groups) for v in me.vertices)
+ if post_max > MAX_INFLUENCES:
+ print(f"ERROR: vertex carries {post_max} groups after the limit — "
+ f"engines cap at {MAX_INFLUENCES}", file=sys.stderr)
+ return 4
+ if changed == 0:
+ print("ERROR: the limit changed nothing — no vertex exceeded the cap, "
+ "the witness is vacuous", file=sys.stderr)
+ return 5
+
+ # contract 2: every vertex's weights sum to 1 (or the vertex is unskinned)
+ sum_err = 0.0
+ for v in me.vertices:
+ if v.groups:
+ sum_err = max(sum_err, abs(sum(g.weight for g in v.groups) - 1.0))
+ if sum_err > SUM_TOL:
+ print(f"ERROR: weight sums deviate {sum_err:.3e} from 1.0 after "
+ "renormalize (dropped groups were not re-balanced)", file=sys.stderr)
+ return 6
+
+ bpy.context.view_layer.update()
+ pose_after = eval_positions(obj)
+
+ # contract 3: pruning did not damage the pose
+ pose_dev = max((mathutils.Vector(a) - mathutils.Vector(b)).length
+ for a, b in zip(pose_before, pose_after))
+ if pose_dev > POSE_TOL:
+ print(f"ERROR: limiting moved the pose by {pose_dev:.4f} "
+ f"(tol {POSE_TOL}) — pruning damaged deformation", file=sys.stderr)
+ return 7
+
+ # contract 4: the modifier is still exactly LBS, with the weights read
+ # back from the mesh's own deform layer (armature-bend's math, built on)
+ mats = {n: arm.pose.bones[n].matrix @ arm.data.bones[n].matrix_local.inverted()
+ for n in BONES}
+ lbs_err = 0.0
+ for v in me.vertices:
+ predicted = sum((g.weight * (mats[BONES[g.group]] @ v.co)
+ for g in v.groups),
+ start=mathutils.Vector((0.0, 0.0, 0.0)))
+ lbs_err = max(lbs_err, (mathutils.Vector(pose_after[v.index])
+ - predicted).length)
+ if lbs_err > LBS_TOL:
+ print(f"ERROR: evaluated mesh deviates {lbs_err:.6f} from LBS over the "
+ f"limited weights (tol {LBS_TOL})", file=sys.stderr)
+ return 8
+
+ # the pedestal mount is rigid on Root and must not move
+ root_move = max((mathutils.Vector(pose_after[i]) - v.co).length
+ for i, v in enumerate(me.vertices)
+ if len(v.groups) == 1 and v.groups[0].weight > 0.99
+ and BONES[v.groups[0].group] == "Root")
+ if root_move > 1e-5:
+ print(f"ERROR: Root-weighted mount moved {root_move:.6f} (Root is unposed)",
+ file=sys.stderr)
+ return 9
+
+ print(f"verts={len(me.vertices)} pre_max={pre_max} post_max={post_max} "
+ f"limited_verts={changed}")
+ print(f"sum_err={sum_err:.2e} (tol {SUM_TOL}) pose_dev={pose_dev:.2e} "
+ f"(tol {POSE_TOL}) lbs_err={lbs_err:.2e} (tol {LBS_TOL}) "
+ f"root_move={root_move:.2e}")
+ return 0
+
+
+def make_materials():
+ def pbr(name, base, metallic, roughness, emission=None, strength=0.0):
+ mat = bpy.data.materials.new(name)
+ mat.use_nodes = True
+ b = mat.node_tree.nodes["Principled BSDF"]
+ b.inputs["Base Color"].default_value = (*base, 1.0)
+ b.inputs["Metallic"].default_value = metallic
+ b.inputs["Roughness"].default_value = roughness
+ if emission is not None:
+ sock = b.inputs.get("Emission Color") or b.inputs["Emission"]
+ sock.default_value = (*emission, 1.0)
+ b.inputs["Emission Strength"].default_value = strength
+ return mat
+ return [
+ pbr("Gunmetal", (0.11, 0.12, 0.14), 0.9, 0.32),
+ pbr("HazardOrange", (0.82, 0.30, 0.08), 0.10, 0.45),
+ pbr("FlexRubber", (0.05, 0.05, 0.06), 0.0, 0.85),
+ pbr("TealAccent", (0.03, 0.22, 0.26), 0.0, 0.35,
+ emission=(0.10, 0.65, 0.72), strength=2.2),
+ ]
+
+
+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
+
+ 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)
+ light("Key", (-4.0, -5.0, 6.0), 600.0, 4.5, (1.0, 0.96, 0.9), (48, 0, -38))
+ light("Fill", (5.0, -4.0, 3.0), 110.0, 9.0, (0.75, 0.85, 1.0), (62, 0, 50))
+ light("Rim", (0.5, 4.5, 5.0), 350.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))
+
+ cam_data = bpy.data.cameras.new("Cam")
+ cam_data.lens = 52.0
+ cam = bpy.data.objects.new("Cam", cam_data)
+ cam.location = (5.3, -6.2, 2.1)
+ scene.collection.objects.link(cam)
+ target = bpy.data.objects.new("Aim", None)
+ target.location = (-0.15, 0.0, 1.5)
+ 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 hazard orange and teal accent toward pastel
+ # (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, part_of = build_arm()
+ for m in make_materials():
+ obj.data.materials.append(m)
+ groups = assign_weights(obj, part_of)
+ arm = build_rig(obj)
+ bpy.context.view_layer.update()
+ pose_before = eval_positions(obj)
+ code = check(obj, arm, groups, pose_before)
+ 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("vertex-weight-limit 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)