diff --git a/README.md b/README.md index 071920e..3da8beb 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ shader `Attribute` node is actually linked to Base Color.
+
+
A runnable example that builds a sci-fi supply crate — 35 beveled box shells with three material slots and box-mapped UVs — exports it with bpy.ops.export_scene.gltf, parses the file on disk, re-imports it, and verifies the whole round-trip against the depsgraph-evaluated mesh, following depsgraph-and-evaluated-data and headless-batch-scripting.
Pipeline arc: modeling/LOD in lod-decimate-chain, weighting in vertex-weight-limit, export here.
What it witnesses: the interchange contracts AI-generated export code most often gets silently wrong.
-1. The +Y-up convention is baked into vertex data. glTF is +Y-up, Blender is +Z-up, and export_yup=True (the default) writes (x, y, z) -> (x, z, -y) directly into the POSITION buffer — the node carries no rotation or scale. The check parses the .gltf JSON and asserts the accessor bounds equal the axis-converted evaluated bounding box, and that the node transform is absent. Exporting with export_yup=False ships raw Z-up data every engine displays lying on its back. 2. export_apply=True ships the evaluated mesh, not the base cage. The crate's bevel modifier lives only in the depsgraph; with flat shading and UV seams the exporter splits exactly one vertex per evaluated loop (7,560), so the on-disk POSITION count is an exact witness. export_apply=False silently writes the 624-vertex cage. 3. The round-trip is faithful. Re-imported positions (bit-exact here), loop normals (≤2e-4), box-mapped UVs (≤3e-5), and per-triangle material bindings all match the evaluated mesh. UVs are V-flipped on disk (glTF texture origin is top-left) and flipped back on import — both flips are proven by reading the .bin buffer directly.
export_yup=True (the default) writes (x, y, z) -> (x, z, -y) directly into the POSITION buffer — the node carries no rotation or scale. The check parses the .gltf JSON and asserts the accessor bounds equal the axis-converted evaluated bounding box, and that the node transform is absent. Exporting with export_yup=False ships raw Z-up data every engine displays lying on its back.export_apply=True ships the evaluated mesh, not the base cage. The crate's bevel modifier lives only in the depsgraph; with flat shading and UV seams the exporter splits exactly one vertex per evaluated loop (7,560), so the on-disk POSITION count is an exact witness. export_apply=False silently writes the 624-vertex cage..bin buffer directly.Version witness (probed on Blender 4.5.11 LTS and 5.1.2): the operator signatures are byte-identical — 109 exporter properties, 20 importer properties, same defaults — and the exported JSON differs only in asset.generator ("Khronos glTF Blender I/O v4.5.51" vs "v5.1.20"). The example therefore runs identical kwargs on both versions and guards forward drift explicitly: every kwarg it passes must still exist in the operator's RNA, so a future rename fails loudly instead of drifting silently. One genuine 5.x removal surfaced during authoring: Mesh.calc_normals() is gone (loop normals auto-compute on read) — calling it is itself a cross-version hazard, noted in the code.
Two more authoring hazards are pinned in comments: exact face-plane coincidences between kit-bashed shells weld loops on export (the count check catches it), and read_factory_settings mid-check frees the original mesh — touching a freed RNA raises ReferenceError, so counts are captured before the wipe.
The render stages the authored crate beside the actual re-imported one — same bevels, same materials carried through the file itself. If the axis conversion broke, the right twin would lie on its side; if the modifier contract broke, its silhouette would lose the rounded edges.
diff --git a/docs/gallery/gltf-skin-roundtrip/index.html b/docs/gallery/gltf-skin-roundtrip/index.html index 65f9f66..51296e0 100644 --- a/docs/gallery/gltf-skin-roundtrip/index.html +++ b/docs/gallery/gltf-skin-roundtrip/index.html @@ -186,7 +186,7 @@A runnable example that rigs a mech scorpion — seven-bone chain from pedestal root to stinger, blend rings at every joint seam — exports it with bpy.ops.export_scene.gltf (export_skins=True), parses the file, re-imports it, and verifies the whole skinning contract against the authored rig, following depsgraph-and-evaluated-data.
Pipeline arc: modeling/LOD in lod-decimate-chain, weighting in vertex-weight-limit, export in gltf-export-roundtrip — this is the skinning counterpart to the crate's geometry round-trip. Tangent frames for the normal maps are in triangulate-tangents.
What it witnesses: the skinned-mesh export contract the geometry round-trip left uncovered.
-1. The skeleton survives. skins[0].joints names every bone; the re-imported armature carries the same 7 bones, the same parent chain, and rest matrices within 2.4e-07 — the +Y-up conversion applies to bone nodes exactly as it does to meshes (their translations convert, no rotation is written). 2. The weights survive. Every primitive carries JOINTS_0/WEIGHTS_0; per-vertex weights on disk sum to 1 (err 3.0e-08); the re-imported vertex groups match the authored groups bit-exactly (w_err 0.0), compared as straddle-safe position keys, the same protocol as the crate example. 3. The deformation survives. Posed identically, the re-imported rig's evaluated mesh matches the original's within 4.8e-07 — linear blend skinning through the file format. The comparison is by rest-position key, never sorted multisets: the exporter welds duplicate loops (32 here), so cardinalities differ and a naive sorted zip mispairs vertices (a phantom 2.29 "deviation" measured and fixed during authoring). 4. The mesh must be parented to the armature. The exporter warns "Armature must be the parent of skinned mesh" and picks an armature by name otherwise — with two rigs in the file it can bind the wrong one.
skins[0].joints names every bone; the re-imported armature carries the same 7 bones, the same parent chain, and rest matrices within 2.4e-07 — the +Y-up conversion applies to bone nodes exactly as it does to meshes (their translations convert, no rotation is written).What each check catches on failure: exporting with export_skins=False (exit 5 — no skin on disk), stripping the weights (exit 4 — no vertex groups), and posing the re-imported rig differently (exit 19 — deformation deviates 0.90).
Version witness: the skins pipeline is stable between Blender 4.5 LTS and 5.1 — the exporter/importer RNA is byte-identical (probed with gltf-export-roundtrip), and every measured value matches to the digit on 4.5.11 and 5.1.2.
The render stages the authored scorpion beside the actual re-imported one — same curl, same glowing stinger — proof the skin rode the format through.
diff --git a/docs/gallery/image-pixels-testcard/index.html b/docs/gallery/image-pixels-testcard/index.html index a4f5ba7..05c1f80 100644 --- a/docs/gallery/image-pixels-testcard/index.html +++ b/docs/gallery/image-pixels-testcard/index.html @@ -197,7 +197,7 @@It exits non-zero on failure and prints every measured error and tolerance on success, so CI logs carry the numbers. The blender-smoke workflow runs the check on Blender 4.5 LTS and 5.1. In the render, Closest interpolation keeps the pixel grid honest — the jagged circle edge is the 512 × 288 buffer itself, and the white marker in the PLUGE row sits at the bottom-left because that is where pixel (0, 0) lives.
It exits non-zero on failure and prints every measured error and tolerance on success, so CI logs carry the numbers. The blender-smoke workflow runs the check on Blender 4.5 LTS and 5.1. In the render, Closest interpolation keeps the pixel grid honest — the jagged circle edge is the 512 × 288 buffer itself, and the white marker in the PLUGE row sits at the bottom-left because that is where pixel (0, 0) lives. The monitor is staged as a designed object — beveled dark-polymer case, machined metal stand, teal power LED — on the dark studio stage from docs/VISUAL-STYLE.md (Standard view transform; warm key, cool fill and rim; a warm pool raking the back wall). The screen stays emissive and matte — specular off, emission strength 1.0 — so the card's values read exactly.
A runnable example that builds a retro toy rocket — a lathed body with an ogive nose, three swept fin plates, a porthole, every dimension a closed form — and evaluates it at LOD0/1/2 through the Decimate modifier via the depsgraph, following depsgraph-and-evaluated-data and mesh-editing-and-bmesh.
Pipeline arc: modeling/LOD here, weighting in vertex-weight-limit, export in gltf-export-roundtrip.
What it witnesses: the modifier-based LOD contract that game asset pipelines rely on.
-1. Decimate is non-destructive and lives in the depsgraph. The evaluated mesh carries the reduction; the original datablock is byte-identical before and after evaluation. The check proves both — evaluated counts drop while obj.data keeps the closed-form counts (1,147 verts / 2,260 tris) — and every evaluated reference is released with to_mesh_clear(). An LOD chain that bakes the reduction into obj.data destroys the asset's LOD0. 2. The COLLAPSE ratio is a target, not a guarantee. Each LOD's evaluated triangle count must land within 5% of ratio x base_tris — measured 0.00% at ratio 0.5 (1,130 tris) and 0.44% at ratio 0.18 (405 tris). A stacked second Decimate halves the effective ratio (50% excursion, caught); a dropped modifier leaves evaluated == original (caught). 3. LODs preserve silhouette-critical dimensions. Height (3.12) and fin span are the rocket's readability; the evaluated bbox holds the base bbox within 1e-3 at every level (measured 7.7e-6). Decimate has no vertex pinning, so this is a real risk, not a formality: at an aggressive ratio 0.02 the nose tip collapses (bbox drift 0.0206, caught).
obj.data keeps the closed-form counts (1,147 verts / 2,260 tris) — and every evaluated reference is released with to_mesh_clear(). An LOD chain that bakes the reduction into obj.data destroys the asset's LOD0.ratio x base_tris — measured 0.00% at ratio 0.5 (1,130 tris) and 0.44% at ratio 0.18 (405 tris). A stacked second Decimate halves the effective ratio (50% excursion, caught); a dropped modifier leaves evaluated == original (caught).The Decimate modifier API (decimate_type='COLLAPSE', ratio) 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 stages the three LODs in a row: LOD0's smooth lathe, LOD1's nearly free halving, LOD2's visible crunch — the porthole degrades to a hexagon and the nose facets coarsen, exactly the geometry the triangle counts assert.
A runnable example that witnesses the float-image PNG save trap: a float_buffer=True image saved via Image.save() to PNG is written as 16-bit RGBA and is unpremultiplied as if the buffer were associated-alpha before storage. The normal pixels API authors *straight* RGBA, so any channel where c > a blows past 1.0 and clamps to white — closed-form RGB error reaches 0.98 for authored (0.02, 0.02, 0.02) at alpha 1/255. The same buffer saved as OpenEXR round-trips at float precision. A byte image (float_buffer=False) writes 8-bit PNG with straight alpha and only pays ordinary quantization.
Found while probing the ROADMAP "image save-format" candidate against live Blender 5.1 — the hazard is not classic 8-bit premul quantization (float PNG is 16-bit); it is the false associated-alpha unpremultiply on a straight buffer. Documented here so the contract cannot quietly drift.
What it witnesses:
-1. Float → PNG false unpremul — Image.save() on a Non-Color float image writes RGBA16 (IHDR bit_depth=16, color_type=6). Reloaded pixels match the closed form q16(min(1, c / q16(a))) within 2/65535. Max RGB error vs authored on the probe palette is >= 0.90 (measured 0.98). 2. Float → OpenEXR fidelity — same authored buffer round-trips within 1e-5 (measured ~3e-8). 3. Byte → PNG straight alpha — float_buffer=False writes RGBA8; pixels match independent per-channel q8 within 0.5/255. The stress cell at (0.02, a=1/255) stays near 0.02, not clamped white. 4. EXR color_mode='RGB' drops alpha — save_render with color_mode='RGB' reloads opaque alpha (≈ 1.0) even when authored alpha was 1/255.
Image.save() on a Non-Color float image writes RGBA16 (IHDR bit_depth=16, color_type=6). Reloaded pixels match the closed form q16(min(1, c / q16(a))) within 2/65535. Max RGB error vs authored on the probe palette is >= 0.90 (measured 0.98).1e-5 (measured ~3e-8).float_buffer=False writes RGBA8; pixels match independent per-channel q8 within 0.5/255. The stress cell at (0.02, a=1/255) stays near 0.02, not clamped white.color_mode='RGB' drops alpha — save_render with color_mode='RGB' reloads opaque alpha (≈ 1.0) even when authored alpha was 1/255.What each check catches on failure:
color_mode='RGB' preserving authored alpha (exit 12).Version divergence: none probed on the save/reload contracts above — they assert on Blender 5.1.1 locally. Blender 4.5 LTS is exercised by the blender-smoke CI job (4.5 is not installed on this authoring host; do not treat a 4.4 run as a 4.5 substitute). The only version gate in the file is the EEVEE engine id for the optional render (BLENDER_EEVEE_NEXT on 4.x, BLENDER_EEVEE on 5.x).
Render: two easel panels. Left bakes the closed-form PNG mangling (dark rows flash to white at low-alpha columns). Right shows the authored straight buffer (EXR-clean). If the contract failed, both panels would read the same.
+Render: two framed verification displays on a shared plinth — the left face bakes the closed-form PNG mangling (dark rows flash to white at low-alpha columns), the right face shows the authored straight buffer (EXR-clean); nameplates read FLOAT → PNG and FLOAT → EXR. If the contract failed, both panels would read the same.
# Cheap correctness check (no render) — the CI check:
blender --background --python png_exr_alpha.py --
@@ -219,8 +219,8 @@ Source
AI-generated Blender code commonly trusts `Image.save()` to PNG for float
RGBA scratch buffers (masks, ID mattes, AOVs). Pass --output to also render
-a dual-easel still: left is the PNG-mangled reload, right is the EXR-clean
-reload.
+a staged still of two framed verification displays: left is the PNG-mangled
+reload, right is the EXR-clean reload.
blender --background --python png_exr_alpha.py --
blender --background --python png_exr_alpha.py -- --output alpha.png
@@ -539,22 +539,16 @@ Source
return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT"
-def specular_off(bsdf):
- if "Specular IOR Level" in bsdf.inputs:
- bsdf.inputs["Specular IOR Level"].default_value = 0.0
- elif "Specular" in bsdf.inputs:
- bsdf.inputs["Specular"].default_value = 0.0
-
-
def make_gallery_card(name, mangled):
- """Visual card of the false-unpremul contract for the dual-easel still.
+ """Visual card of the false-unpremul contract for the staged still.
Columns = rising alpha; top rows = dark mid-tones that PNG clamps to
white; lower rows = primaries that survive. Left panel bakes the
closed-form mangling; right panel shows authored straight RGBA.
- A dark bezel is painted into the texture so panel edges read as framed
- objects in the studio still (render-only — checks use fill_pattern).
+ A dark trim is painted into the texture edge so the emissive face reads
+ as a recessed screen inside its physical bezel (render-only — checks
+ use fill_pattern).
"""
gw, gh = 256, 192
border = 10
@@ -595,8 +589,46 @@ Source
return img
-def easel_plane(name, image, loc, rot_z_deg):
- """Single-plane easel + short stand — no dual-surface z-fight."""
+def fixture_mats():
+ """Designed non-data surfaces: dark polymer bezels, rail-steel struts,
+ machined nameplates, plinth — no Principled defaults."""
+ def mat_principled(name, color, metallic, rough):
+ mat = bpy.data.materials.new(name)
+ mat.use_nodes = True
+ bsdf = mat.node_tree.nodes["Principled BSDF"]
+ bsdf.inputs["Base Color"].default_value = (*color, 1.0)
+ bsdf.inputs["Metallic"].default_value = metallic
+ bsdf.inputs["Roughness"].default_value = rough
+ return mat
+
+ return {
+ "bezel": mat_principled("BezelPolymer", (0.045, 0.048, 0.055), 0.1, 0.5),
+ "rail": mat_principled("RailSteel", (0.075, 0.08, 0.09), 0.6, 0.45),
+ "plinth": mat_principled("PlinthSteel", (0.10, 0.11, 0.12), 0.6, 0.5),
+ "plate": mat_principled("PlateSteel", (0.14, 0.15, 0.17), 0.7, 0.4),
+ }
+
+
+def box_obj(name, dims, loc, mat, rot_x=0.0):
+ me = bpy.data.meshes.new(name)
+ bm = bmesh.new()
+ try:
+ bmesh.ops.create_cube(bm, size=1.0)
+ bm.to_mesh(me)
+ finally:
+ bm.free()
+ me.materials.append(mat)
+ ob = bpy.data.objects.new(name, me)
+ ob.scale = dims
+ ob.location = loc
+ ob.rotation_euler = (rot_x, 0.0, 0.0)
+ bpy.context.collection.objects.link(ob)
+ return ob
+
+
+def face_mesh(name):
+ """UV-mapped unit plane (create_grid lies in XY facing +Z; the display
+ unit rotates it vertical)."""
me = bpy.data.meshes.new(name)
bm = bmesh.new()
try:
@@ -612,84 +644,101 @@ Source
bm.to_mesh(me)
finally:
bm.free()
+ return me
- mat = bpy.data.materials.new(name + "Mat")
+
+def face_mat(name, image):
+ """Data faces stay pure emission: exact card values, no specular, no
+ shading — the panel is a backlit data display."""
+ mat = bpy.data.materials.new(name)
mat.use_nodes = True
nt = mat.node_tree
- nodes, links = nt.nodes, nt.links
- bsdf = nodes["Principled BSDF"]
- tex = nodes.new("ShaderNodeTexImage")
+ nt.nodes.clear()
+ out = nt.nodes.new("ShaderNodeOutputMaterial")
+ em = nt.nodes.new("ShaderNodeEmission")
+ tex = nt.nodes.new("ShaderNodeTexImage")
tex.image = image
tex.interpolation = "Closest"
- links.new(tex.outputs["Color"], bsdf.inputs["Base Color"])
- links.new(tex.outputs["Color"], bsdf.inputs["Emission Color"])
- bsdf.inputs["Roughness"].default_value = 1.0
- specular_off(bsdf)
- # Faint emission so clamps read under a dim stage without washing the wall.
- bsdf.inputs["Emission Strength"].default_value = 0.22
- me.materials.append(mat)
+ nt.links.new(tex.outputs["Color"], em.inputs["Color"])
+ nt.links.new(em.outputs["Emission"], out.inputs["Surface"])
+ em.inputs["Strength"].default_value = 1.0
+ return mat
+
+def caption_mat():
+ mat = bpy.data.materials.new("CaptionGrey")
+ mat.use_nodes = True
+ cb = mat.node_tree.nodes["Principled BSDF"]
+ cb.inputs["Base Color"].default_value = (0.42, 0.44, 0.48, 1.0)
+ cb.inputs["Metallic"].default_value = 0.2
+ cb.inputs["Roughness"].default_value = 0.6
+ sock = cb.inputs.get("Emission Color") or cb.inputs["Emission"]
+ sock.default_value = (0.38, 0.40, 0.45, 1.0)
+ cb.inputs["Emission Strength"].default_value = 0.5
+ return mat
+
+
+def display_unit(name, image, caption, x, mats, capmat):
+ """One verification display: the emissive data face seated proud of a
+ thick dark bezel, carried by a rear strut and foot, with a machined
+ nameplate under the bezel — a physical instrument standing on the
+ shared plinth, not a floating texture."""
root = bpy.data.objects.new(name, None)
- root.location = loc
- root.rotation_euler = (math.radians(62), 0.0, math.radians(rot_z_deg))
- root.scale = (0.88, 0.88, 0.88)
+ root.location = (x, 0.0, 0.0)
bpy.context.collection.objects.link(root)
- face = bpy.data.objects.new(name + "Face", me)
- bpy.context.collection.objects.link(face)
- face.parent = root
+ def adopt(ob):
+ ob.parent = root
+ return ob
- stand_me = bpy.data.meshes.new(name + "Stand")
- bm = bmesh.new()
- try:
- bmesh.ops.create_cube(bm, size=1.0)
- bm.to_mesh(stand_me)
- finally:
- bm.free()
- smat = bpy.data.materials.new(name + "StandMat")
- smat.use_nodes = True
- sb = smat.node_tree.nodes["Principled BSDF"]
- sb.inputs["Base Color"].default_value = (0.045, 0.048, 0.055, 1.0)
- sb.inputs["Roughness"].default_value = 0.45
- if "Metallic" in sb.inputs:
- sb.inputs["Metallic"].default_value = 0.3
- stand_me.materials.append(smat)
- stand = bpy.data.objects.new(name + "Stand", stand_me)
- # Fully below the card face — never intersects the evidence plane.
- stand.scale = (0.06, 0.06, 0.28)
- stand.location = (0.0, 0.04, -0.88)
- bpy.context.collection.objects.link(stand)
- stand.parent = root
+ # bezel with real thickness; the face sits proud of its front surface
+ adopt(box_obj(name + "Bezel", (2.64, 0.16, 2.04), (0.0, 0.0, 1.80),
+ mats["bezel"]))
+ face = bpy.data.objects.new(name + "Face", face_mesh(name + "Face"))
+ bpy.context.collection.objects.link(face)
+ face.data.materials.append(face_mat(name + "FaceMat", image))
+ face.location = (0.0, -0.085, 1.80)
+ face.rotation_euler = (math.radians(90), 0.0, 0.0)
+ face.scale = (1.20, 0.90, 1.0)
+ adopt(face)
+ # rear strut leaning onto the bezel back, foot on the plinth top
+ adopt(box_obj(name + "Strut", (0.26, 0.30, 1.30), (0.0, 0.42, 1.10),
+ mats["rail"], rot_x=math.radians(18)))
+ adopt(box_obj(name + "Foot", (0.60, 0.80, 0.10), (0.0, 0.72, 0.50),
+ mats["rail"]))
+ # nameplate bridging bezel bottom and plinth top
+ adopt(box_obj(name + "Plate", (1.60, 0.06, 0.26), (0.0, -0.03, 0.63),
+ mats["plate"]))
+ cu = bpy.data.curves.new(name + "Caption", "FONT")
+ cu.body = caption
+ cu.align_x = "CENTER"
+ cu.align_y = "CENTER"
+ cu.size = 0.15
+ cu.extrude = 0.004
+ cu.materials.append(capmat)
+ cap = bpy.data.objects.new(name + "Caption", cu)
+ cap.location = (0.0, -0.065, 0.63)
+ cap.rotation_euler = (math.radians(90), 0.0, 0.0)
+ bpy.context.collection.objects.link(cap)
+ adopt(cap)
return root
def render_still(path, engine):
+ import mathutils
+
scene = bpy.context.scene
png_card = make_gallery_card("PngMangled", mangled=True)
exr_card = make_gallery_card("ExrClean", mangled=False)
- # Mounted easels on a shared dark table — continuous base, no V-tear shadow.
- easel_plane("PngPanel", png_card, (-1.15, 0.1, 1.28), 5)
- easel_plane("ExrPanel", exr_card, (1.15, 0.1, 1.28), -5)
-
- table_me = bpy.data.meshes.new("Table")
- bm = bmesh.new()
- try:
- bmesh.ops.create_cube(bm, size=1.0)
- bm.to_mesh(table_me)
- finally:
- bm.free()
- tmat = bpy.data.materials.new("TableMat")
- tmat.use_nodes = True
- tb = tmat.node_tree.nodes["Principled BSDF"]
- tb.inputs["Base Color"].default_value = (0.022, 0.024, 0.028, 1.0)
- tb.inputs["Roughness"].default_value = 0.6
- table_me.materials.append(tmat)
- table = bpy.data.objects.new("Table", table_me)
- table.scale = (1.5, 0.55, 0.08)
- table.location = (0.0, 0.25, 0.28)
- scene.collection.objects.link(table)
+ # The verification station: two framed displays on a shared plinth —
+ # left bakes the closed-form PNG mangling, right is EXR-clean.
+ mats = fixture_mats()
+ capmat = caption_mat()
+ display_unit("PngDisplay", png_card, "FLOAT → PNG", -1.45, mats, capmat)
+ display_unit("ExrDisplay", exr_card, "FLOAT → EXR", 1.45, mats, capmat)
+ box_obj("Plinth", (6.10, 1.80, 0.45), (0.0, 0.35, 0.225), mats["plinth"])
floor_me = bpy.data.meshes.new("Floor")
bm = bmesh.new()
@@ -701,46 +750,57 @@ Source
fmat = bpy.data.materials.new("Studio")
fmat.use_nodes = True
fb = fmat.node_tree.nodes["Principled BSDF"]
- fb.inputs["Base Color"].default_value = (0.02, 0.021, 0.025, 1.0)
- fb.inputs["Roughness"].default_value = 0.8
+ 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, 8.8, 0.0)
+ 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.012, 0.013, 0.015, 1.0,
+ 0.02, 0.021, 0.025, 1.0,
)
scene.world = world
- def light(name, loc, energy, size, col, rot):
+ def light(name, loc, energy, size, col, target):
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)
+ d = mathutils.Vector(target) - ob.location
+ ob.rotation_euler = d.to_track_quat("-Z", "Y").to_euler()
scene.collection.objects.link(ob)
- light("Key", (-3.8, -4.8, 5.8), 220.0, 4.5, (1.0, 0.96, 0.9), (52, 0, -32))
- light("Fill", (4.8, -2.8, 1.6), 40.0, 9.0, (0.75, 0.85, 1.0), (72, 0, 52))
- light("Rim", (0.2, 4.8, 2.8), 120.0, 3.2, (0.6, 0.78, 1.0), (-62, 0, 178))
- light("Wedge", (0.5, 6.5, 2.5), 450.0, 7.0, (1.0, 0.72, 0.42), (-78, 0, 180))
+ # Shaped warm key, faint cool fill, cool rim, and the signature warm
+ # wedge between station and wall aimed at the wall point behind the
+ # subject — every light has an explicit target so nothing grazes a
+ # flat surface (grazing area lights draw stray bands).
+ light("Key", (-4.0, -4.5, 7.0), 460.0, 4.5, (1.0, 0.96, 0.9),
+ (0.0, 0.2, 1.8))
+ light("Fill", (6.0, -3.0, 3.0), 90.0, 9.0, (0.75, 0.85, 1.0),
+ (0.5, 0.0, 1.8))
+ light("Rim", (0.5, 4.5, 7.0), 320.0, 4.0, (0.6, 0.78, 1.0),
+ (0.0, 0.3, 2.0))
+ light("Wedge", (-0.5, 5.5, 6.0), 520.0, 4.5, (1.0, 0.76, 0.5),
+ (0.0, 9.0, 2.6))
aim = bpy.data.objects.new("Aim", None)
- aim.location = (0.0, 0.1, 1.15)
+ aim.location = (-0.10, 0.2, 1.65)
scene.collection.objects.link(aim)
+ # Gentle 3/4 from the right: both faces stay fully readable while the
+ # bezels, struts, and plinth show their thickness.
cam_data = bpy.data.cameras.new("Cam")
cam_data.lens = 50.0
cam = bpy.data.objects.new("Cam", cam_data)
- cam.location = (0.0, -6.0, 2.15)
+ cam.location = (3.9, -9.3, 3.05)
con = cam.constraints.new("TRACK_TO")
con.target = aim
con.track_axis = "TRACK_NEGATIVE_Z"
diff --git a/docs/gallery/triangulate-tangents/index.html b/docs/gallery/triangulate-tangents/index.html
index 96285ed..84f34ab 100644
--- a/docs/gallery/triangulate-tangents/index.html
+++ b/docs/gallery/triangulate-tangents/index.html
@@ -186,7 +186,7 @@ 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.
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.
+- 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. - 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).
- 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.
diff --git a/docs/gallery/uv-layer-grid/index.html b/docs/gallery/uv-layer-grid/index.html
index e4a6058..5ade809 100644
--- a/docs/gallery/uv-layer-grid/index.html
+++ b/docs/gallery/uv-layer-grid/index.html
@@ -186,11 +186,11 @@ uv-layer-grid
A runnable example that witnesses the UV-layer authoring hazard behind bmesh.ops.create_grid(..., calc_uvs=True): the flag is a silent no-op unless a UV layer already exists on the BMesh. AI-generated Blender code commonly trusts calc_uvs=True, wires an Image Texture, and ships a mesh that renders as one flat color — every fragment samples texel (0, 0).
Found while authoring image-pixels-testcard; lifted into its own smoke-gated witness so the contract cannot quietly drift.
What it witnesses: three related contracts on the same grid topology (SEG×SEG faces, (SEG+1)² verts, size 1.0 → coords in [-1, 1]):
-1. Silent no-op — create_grid(..., calc_uvs=True) with no prior bm.loops.layers.uv.new(...) leaves len(bm.loops.layers.uv) == 0 and mesh.uv_layers empty after to_mesh. 2. Pre-create repair — create the UV layer first, then calc_uvs=True fills every loop. UVs must match the closed form u = (x/size + 1)/2, v = (y/size + 1)/2 within 1e-6, both on the BMesh and after persisting to mesh.uv_layers.active.data. 3. Explicit assignment fallback — calc_uvs=False, then uv.new("UVMap") and a loop write of the same closed form. Does not depend on calc_uvs at all; same tolerance.
+- Silent no-op —
create_grid(..., calc_uvs=True) with no prior bm.loops.layers.uv.new(...) leaves len(bm.loops.layers.uv) == 0 and mesh.uv_layers empty after to_mesh. - Pre-create repair — create the UV layer first, then
calc_uvs=True fills every loop. UVs must match the closed form u = (x/size + 1)/2, v = (y/size + 1)/2 within 1e-6, both on the BMesh and after persisting to mesh.uv_layers.active.data. - Explicit assignment fallback —
calc_uvs=False, then uv.new("UVMap") and a loop write of the same closed form. Does not depend on calc_uvs at all; same tolerance.
What each check catches on failure:
- *Silent no-op* —
calc_uvs=True starts creating a layer on its own (falsified: expecting n_layers == 0 fails if a layer appears; temporarily pre-creating a layer before the hazard probe exits 3). - *Topology* —
create_grid segment/size contract drift (wrong face/vert counts). - *Closed-form UVs* —
calc_uvs filling a different parameterization, or a one-texel shift (falsified: adding 0.1 to every U exited 6 with measured error 0.1). - *Mesh persistence* — UV layer or loop data lost across
to_mesh. - *Explicit path* — loop assignment or the closed form itself regressing.
- *Pixel witness* (with
--output) — the saved PNG is read back and probed at each panel's projected center: the hazard panel must be one flat teal (per-channel spread ≤ 0.02, ordering b>g>r), the repaired panel a high-contrast checker (spread ≥ 0.25). Falsified twice: secretly giving the hazard panel UVs exited 11 with measured spread 0.7333; repainting texel (0,0) magenta exited 12 with measured rgb (0.927, 0.118, 0.536).
Version divergence: none probed — the silent no-op and closed-form UV fill assert identically on Blender 4.5 LTS and 5.1. The only gate in the file is the EEVEE engine id for the optional render (BLENDER_EEVEE_NEXT on 4.x, BLENDER_EEVEE on 5.x).
-Render: two easel panels sharing one neon checker image. Left is the hazard (no UV layer) — flat teal of texel (0, 0). Right is the repair (pre-create + calc_uvs) — full magenta/cyan checker. If the UV contract failed, both panels would read the same — and the still is not just an illustration: the script re-reads its own render and exits non-zero unless the pixels prove the flat-vs-checker split (measured on 5.1.2: hazard spread 0.0078, repair spread 0.7294).
+Render: two framed lightbox displays on floor trays (rear kick legs, status LED) sharing one neon checker image, shot at a slight 3/4. Left is the hazard (no UV layer) — flat teal of texel (0, 0). Right is the repair (pre-create + calc_uvs) — full magenta/cyan checker. If the UV contract failed, both panels would read the same — and the still is not just an illustration: the script re-reads its own render and exits non-zero unless the pixels prove the flat-vs-checker split (measured on 5.1.2: hazard spread 0.0078, repair spread 0.7412; identical on 4.5.11).
Run
# Cheap correctness check (no render) — the CI check:
blender --background --python uv_layer_grid.py --
@@ -227,6 +227,7 @@ Source
blender --background --python uv_layer_grid.py -- --output uv.png
"""
import bpy, bmesh, sys, os, math, argparse
+from mathutils import Vector
SEG = 8
SIZE = 1.0
@@ -452,15 +453,108 @@ Source
scene = bpy.context.scene
card = make_uv_testcard("UVCard")
- # Left: the hazard — calc_uvs alone, no UV layer → flat teal of texel (0,0).
- broken = textured_plane("Broken", card, with_uvs=False)
- broken.location = (-1.18, 0.0, 0.94)
- broken.rotation_euler = (math.radians(62), 0.0, math.radians(8))
-
- # Right: the repair — pre-create UV layer, then calc_uvs fills it.
- fixed = textured_plane("Fixed", card, with_uvs=True)
- fixed.location = (1.18, 0.0, 0.94)
- fixed.rotation_euler = (math.radians(62), 0.0, math.radians(-8))
+ # --- Staging materials: quiet and dark, the panel faces carry the color --
+ def pbr(name, base, rough, metal=0.0):
+ m = bpy.data.materials.new(name)
+ m.use_nodes = True
+ b = m.node_tree.nodes["Principled BSDF"]
+ b.inputs["Base Color"].default_value = (*base, 1.0)
+ b.inputs["Roughness"].default_value = rough
+ b.inputs["Metallic"].default_value = metal
+ return m
+
+ frame_mat = pbr("Frame", (0.05, 0.053, 0.06), 0.32, 0.65)
+ stand_mat = pbr("Stand", (0.032, 0.035, 0.042), 0.45, 0.4)
+ led_mat = pbr("Led", (0.0, 0.0, 0.0), 0.6)
+ led_b = led_mat.node_tree.nodes["Principled BSDF"]
+ led_b.inputs["Emission Color"].default_value = (1.0, 0.45, 0.12, 1.0)
+ led_b.inputs["Emission Strength"].default_value = 4.0
+
+ def box(name, dims, loc, rot, mat, bevel=0.0):
+ dx, dy, dz = (d * 0.5 for d in dims)
+ verts = [
+ (-dx, -dy, -dz), (dx, -dy, -dz), (dx, dy, -dz), (-dx, dy, -dz),
+ (-dx, -dy, dz), (dx, -dy, dz), (dx, dy, dz), (-dx, dy, dz),
+ ]
+ faces = [
+ (0, 1, 2, 3), (4, 7, 6, 5), (0, 4, 5, 1),
+ (1, 5, 6, 2), (2, 6, 7, 3), (4, 0, 3, 7),
+ ]
+ me = bpy.data.meshes.new(name)
+ me.from_pydata(verts, [], faces)
+ me.materials.append(mat)
+ ob = bpy.data.objects.new(name, me)
+ ob.location = loc
+ ob.rotation_euler = rot
+ scene.collection.objects.link(ob)
+ if bevel > 0.0:
+ mod = ob.modifiers.new("Edge", "BEVEL")
+ mod.width = bevel
+ mod.segments = 2
+ return ob
+
+ def bar_between(name, p1, p2, width, mat):
+ a, b = Vector(p1), Vector(p2)
+ d = b - a
+ ob = box(name, (width, width, d.length), (a + b) * 0.5, (0, 0, 0), mat, 0.01)
+ ob.rotation_mode = "QUATERNION"
+ ob.rotation_quaternion = d.to_track_quat("Z", "Y")
+ return ob
+
+ # --- Two framed lightbox displays, each on a floor tray with a rear
+ # kick leg. The face planes keep their origins at the face centers (the
+ # pixel witness probes the projected centers), with a ~0.2-unit bezel
+ # between the face edge and the frame so the probed patch stays on data.
+ LEAN = math.radians(75.0) # 15° back off vertical, easel-like
+
+ def display(name, with_uvs, cx, cy, yaw_deg):
+ yaw = math.radians(yaw_deg)
+ asm = bpy.data.objects.new(name + "Asm", None)
+ asm.rotation_euler = (LEAN, 0.0, yaw)
+ rot = asm.rotation_euler.to_matrix()
+ # Face-center height such that the frame's bottom edge rests in tray.
+ contact = rot @ Vector((0.0, -1.17, 0.02))
+ asm.location = (cx, cy, 0.07 - contact.z)
+ scene.collection.objects.link(asm)
+
+ # Left: the hazard — calc_uvs alone, no UV layer → flat teal of
+ # texel (0,0). Right: the repair — pre-create, then calc_uvs fills.
+ face = textured_plane(name, card, with_uvs)
+ face.parent = asm
+ face.location = (0.0, 0.0, 0.075)
+
+ plate = box(name + "Back", (2.34, 2.34, 0.14), (0, 0, 0), (0, 0, 0),
+ frame_mat, 0.02)
+ plate.parent = asm
+ bezels = (
+ ((2.34, 0.17, 0.20), (0.0, 1.085, 0.02)),
+ ((2.34, 0.17, 0.20), (0.0, -1.085, 0.02)),
+ ((0.17, 2.0, 0.20), (1.085, 0.0, 0.02)),
+ ((0.17, 2.0, 0.20), (-1.085, 0.0, 0.02)),
+ )
+ for i, (dims, loc) in enumerate(bezels):
+ bez = box(f"{name}Bez{i}", dims, loc, (0, 0, 0), frame_mat, 0.02)
+ bez.parent = asm
+ led = box(name + "Led", (0.06, 0.03, 0.035), (0.92, -1.085, 0.128),
+ (0, 0, 0), led_mat, 0.008)
+ led.parent = asm
+
+ base = asm.location
+ box(name + "Tray", (2.0, 0.32, 0.10),
+ (base.x + contact.x, base.y + contact.y, 0.05),
+ (0, 0, yaw), stand_mat, 0.02)
+ back = rot @ Vector((0.0, 0.0, -1.0))
+ back.z = 0.0
+ back.normalize()
+ for side in (-0.75, 0.75):
+ attach = base + rot @ Vector((side, 0.55, -0.12))
+ foot = (base.x + contact.x + side * math.cos(yaw) + back.x * 0.85,
+ base.y + contact.y - side * math.sin(yaw) + back.y * 0.85,
+ 0.02)
+ bar_between(f"{name}Leg{side:+.2f}", attach, foot, 0.06, stand_mat)
+
+ display("Broken", False, -1.38, 0.30, 6.0)
+ display("Fixed", True, 1.45, -0.10, 9.0)
floor_me = bpy.data.meshes.new("Floor")
bm = bmesh.new()
@@ -499,21 +593,23 @@ Source
ob.rotation_euler = tuple(math.radians(a) for a in rot)
scene.collection.objects.link(ob)
- light("Key", (-3.8, -4.8, 5.8), 300.0, 4.5, (1.0, 0.96, 0.9), (52, 0, -32))
- light("Fill", (4.8, -2.8, 1.6), 55.0, 9.0, (0.75, 0.85, 1.0), (72, 0, 52))
- light("Rim", (0.2, 4.0, 2.8), 90.0, 3.2, (0.6, 0.78, 1.0), (-62, 0, 178))
- # Between the panels and the wall so it only rakes the backdrop: a
- # contained warm pool, corners falling off to dark.
- light("Wedge", (0.3, 5.4, 1.9), 380.0, 3.6, (1.0, 0.68, 0.38), (-72, 0, 180))
+ # Aimed steeply down so its spill dies on the floor, not on the wall.
+ light("Key", (-4.4, -4.2, 6.4), 340.0, 5.0, (1.0, 0.96, 0.9), (60, 0, -32))
+ light("Fill", (4.8, -2.8, 1.6), 80.0, 9.0, (0.75, 0.85, 1.0), (72, 0, 52))
+ light("Rim", (0.2, 4.6, 3.0), 250.0, 3.5, (0.6, 0.78, 1.0), (-42, 0, 178))
+ # Uplight between the displays and the wall, raking UP the backdrop: the
+ # visible wall band above the panel tops only spans z≈2.8–3.4, so the
+ # warm pool is aimed to live there instead of hiding behind the panels.
+ light("Wedge", (-2.2, 5.6, 2.3), 480.0, 3.0, (1.0, 0.76, 0.5), (-117, 0, 180))
aim = bpy.data.objects.new("Aim", None)
- aim.location = (0.0, 0.0, 0.95)
+ aim.location = (0.1, 0.05, 1.0)
scene.collection.objects.link(aim)
cam_data = bpy.data.cameras.new("Cam")
- cam_data.lens = 45.0
+ cam_data.lens = 48.0
cam = bpy.data.objects.new("Cam", cam_data)
- cam.location = (0.0, -6.6, 2.1)
+ cam.location = (3.1, -8.35, 2.2)
con = cam.constraints.new("TRACK_TO")
con.target = aim
con.track_axis = "TRACK_NEGATIVE_Z"
diff --git a/docs/gallery/vertex-weight-limit/index.html b/docs/gallery/vertex-weight-limit/index.html
index 0a47222..c19bc08 100644
--- a/docs/gallery/vertex-weight-limit/index.html
+++ b/docs/gallery/vertex-weight-limit/index.html
@@ -186,7 +186,7 @@ vertex-weight-limit
A runnable example that rigs a mech arm — flanged bolted pedestal and shoulder fairing, a panel-seamed upper arm ending in clevis cheeks, the elbow hinge pin and knuckle barrel capped with hex bolts through a ribbed flex cuff, a long seam-grooved forearm, wrist cuff and collar, and a three-finger gripper — with deliberately rich five-bone weight bumps in the cuffs, 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.
Pipeline arc: modeling/LOD in lod-decimate-chain, weighting here, export in gltf-export-roundtrip.
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.984 — a mesh that shrinks toward the origin under load (the check's measured failure, 1.616e-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 = 2.7e-07. 3. Pruning must not damage the pose. Evaluated positions before and after the limit are held within 0.05 (measured 2.8e-03), the pedestal mount stays exactly pinned (Root is unposed), and the pre-limit authoring really carries five influences in the cuffs — otherwise the witness would be vacuous.
+- 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.984 — a mesh that shrinks toward the origin under load (the check's measured failure, 1.616e-02 off unit sum). - 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 = 2.7e-07. - Pruning must not damage the pose. Evaluated positions before and after the limit are held within 0.05 (measured 2.8e-03), the pedestal mount stays exactly pinned (Root is unposed), and the pre-limit authoring really carries five influences in the cuffs — 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 flex cuffs carry the teal accent — the five-influence zones the limit prunes glow at the elbow hinge and wrist, sealed by the bright hoop on the elbow cuff — proof that the limited weights still deform as authored.
Run
diff --git a/docs/gallery/vse-cut-list/index.html b/docs/gallery/vse-cut-list/index.html
index ab67cf9..18804b4 100644
--- a/docs/gallery/vse-cut-list/index.html
+++ b/docs/gallery/vse-cut-list/index.html
@@ -185,7 +185,7 @@ vse-cut-list
A runnable example that witnesses the sequencer API rename between Blender 4.5 LTS and 5.x — the single most common way AI-generated VSE code dies on a modern Blender. Every tutorial-era snippet does scene.sequence_editor.sequences.new_effect(..., frame_end=...); on 5.x that path is gone twice over (sequences removed, frame_end kwarg rejected), and on 4.5 the modern spelling (strips, length=) is already the only one the new collection accepts.
What it witnesses: a deterministic cut list — three color programs A/B/C, a GAMMA_CROSS fed by a dedicated source pair T1/T2, a scene strip, and a text strip — asserted against closed-form spans on each version's canonical accessors, then re-asserted after a save/reload round-trip:
-1. Accessor rename — 5.x: sequence_editor.sequences raises AttributeError; only .strips exists. 4.5: both accessors exist and see the same strips (the transition bridge). 2. Creation signature — strips.new_effect(...) ends a strip with frame_end= on 4.5 but length= on 5.x; the wrong kwarg raises TypeError on each side (asserted, not assumed). 5.x also rejects the removed TRANSFORM effect type — per-strip strip.transform (StripTransform) places strips in the frame on both versions. 3. Frame-range closed form — every strip's (start, end, duration) matches the authored end-exclusive span: frame_final_* on 4.5; left_handle/right_handle/duration on 5.x, where the frame_final_* names are deprecated aliases (removal announced for 6.0) that must still read equal — the bridge is asserted lossless, with the RNA is_deprecated flag checked on 5.x and its absence checked on 4.5. 4. Effect wiring — input_1/input_2 (not input1) on both versions, in authored order, and the cross clamped to exactly the T1/T2 overlap. 5. Scene strip — 4-arg new_scene(name, scene, channel, frame_start) on both versions (no length/frame_end kwarg); default span is the source scene's frame range; the strip sources a *separate* Stage scene. 6. Round-trip — spans, channels, colors, GC wiring, text, and mosaic transforms (pixel-unit offsets, tol 1e-3) all survive save_as_mainfile → open_mainfile. 7. Compositing (--check-pixels) — a 96×54 render asserts each mosaic cell center carries its strip color (tol 0.1), the cross cell is a true mid blend (strictly between the sources on every channel), and a margin point matches neither cross source — because a consumed input compositing independently would paint it full-frame.
+- Accessor rename — 5.x:
sequence_editor.sequences raises AttributeError; only .strips exists. 4.5: both accessors exist and see the same strips (the transition bridge). - Creation signature —
strips.new_effect(...) ends a strip with frame_end= on 4.5 but length= on 5.x; the wrong kwarg raises TypeError on each side (asserted, not assumed). 5.x also rejects the removed TRANSFORM effect type — per-strip strip.transform (StripTransform) places strips in the frame on both versions. - Frame-range closed form — every strip's (start, end, duration) matches the authored end-exclusive span:
frame_final_* on 4.5; left_handle/right_handle/duration on 5.x, where the frame_final_* names are deprecated aliases (removal announced for 6.0) that must still read equal — the bridge is asserted lossless, with the RNA is_deprecated flag checked on 5.x and its absence checked on 4.5. - Effect wiring —
input_1/input_2 (not input1) on both versions, in authored order, and the cross clamped to exactly the T1/T2 overlap. - Scene strip — 4-arg
new_scene(name, scene, channel, frame_start) on both versions (no length/frame_end kwarg); default span is the source scene's frame range; the strip sources a *separate* Stage scene. - Round-trip — spans, channels, colors, GC wiring, text, and mosaic transforms (pixel-unit offsets, tol 1e-3) all survive
save_as_mainfile → open_mainfile. - Compositing (
--check-pixels) — a 96×54 render asserts each mosaic cell center carries its strip color (tol 0.1), the cross cell is a true mid blend (strictly between the sources on every channel), and a margin point matches neither cross source — because a consumed input compositing independently would paint it full-frame.
What each check catches on failure:
- *Accessor* —
.sequences returning on 5.x, or the 4.5 bridge seeing different contents (falsified: the legacy path on 5.1.1 raises AttributeError: 'SequenceEditor' object has no attribute 'sequences'). - *Creation* — Blender re-accepting a removed kwarg silently, or the TRANSFORM enum returning (falsified: calling
new_effect(frame_end=...) on 5.1.1 raises TypeError: ... expected (name, type, channel, frame_start, length, input1, input2)). - *Spans* — end-inclusive vs end-exclusive off-by-one, retiming drift (falsified: expecting GC span (25, 34) exited 5 with measured
GC span (25, 33, 8) != closed form (25, 34, 9) — note the cross was *clamped* to the 8-frame overlap, itself a witnessed behavior). - *Wiring* — swapped or dropped cross inputs (falsified:
input1=t2, input2=t1 exited 7 with measured inputs=(T2, T1), expected T1 -> T2). - *Round-trip* — serialization dropping strip data (falsified: corrupting the text strip before save exited 10 via the reloaded re-assert).
- *Pixels* — a broken span or cell transform dropping a cell, a frozen cross, or a consumed input leaking back into the composite (found by authoring: GC below T1/T2 let T2 paint the whole wall teal).
Hazards discovered while authoring (all witnessed by the checks above):
diff --git a/examples/gltf-export-roundtrip/README.md b/examples/gltf-export-roundtrip/README.md
index 4789bf0..31418b6 100644
--- a/examples/gltf-export-roundtrip/README.md
+++ b/examples/gltf-export-roundtrip/README.md
@@ -13,23 +13,23 @@ weighting in [`vertex-weight-limit`](../vertex-weight-limit/), export here.
**What it witnesses:** the interchange contracts AI-generated export code most
often gets silently wrong.
-1. **The +Y-up convention is baked into vertex data.** glTF is +Y-up, Blender is
- +Z-up, and `export_yup=True` (the default) writes `(x, y, z) -> (x, z, -y)`
- directly into the POSITION buffer — the node carries **no** rotation or scale.
- The check parses the `.gltf` JSON and asserts the accessor bounds equal the
- axis-converted evaluated bounding box, and that the node transform is absent.
- Exporting with `export_yup=False` ships raw Z-up data every engine displays
- lying on its back.
-2. **`export_apply=True` ships the evaluated mesh, not the base cage.** The
- crate's bevel modifier lives only in the depsgraph; with flat shading and UV
- seams the exporter splits exactly one vertex per evaluated loop (7,560), so
- the on-disk POSITION count is an exact witness. `export_apply=False` silently
- writes the 624-vertex cage.
-3. **The round-trip is faithful.** Re-imported positions (bit-exact here), loop
- normals (≤2e-4), box-mapped UVs (≤3e-5), and per-triangle material bindings
- all match the evaluated mesh. UVs are V-flipped on disk (glTF texture origin
- is top-left) and flipped back on import — both flips are proven by reading
- the `.bin` buffer directly.
+- **The +Y-up convention is baked into vertex data.** glTF is +Y-up, Blender is
+ +Z-up, and `export_yup=True` (the default) writes `(x, y, z) -> (x, z, -y)`
+ directly into the POSITION buffer — the node carries **no** rotation or scale.
+ The check parses the `.gltf` JSON and asserts the accessor bounds equal the
+ axis-converted evaluated bounding box, and that the node transform is absent.
+ Exporting with `export_yup=False` ships raw Z-up data every engine displays
+ lying on its back.
+- **`export_apply=True` ships the evaluated mesh, not the base cage.** The
+ crate's bevel modifier lives only in the depsgraph; with flat shading and UV
+ seams the exporter splits exactly one vertex per evaluated loop (7,560), so
+ the on-disk POSITION count is an exact witness. `export_apply=False` silently
+ writes the 624-vertex cage.
+- **The round-trip is faithful.** Re-imported positions (bit-exact here), loop
+ normals (≤2e-4), box-mapped UVs (≤3e-5), and per-triangle material bindings
+ all match the evaluated mesh. UVs are V-flipped on disk (glTF texture origin
+ is top-left) and flipped back on import — both flips are proven by reading
+ the `.bin` buffer directly.
**Version witness (probed on Blender 4.5.11 LTS and 5.1.2):** the operator
signatures are byte-identical — 109 exporter properties, 20 importer properties,
diff --git a/examples/gltf-skin-roundtrip/README.md b/examples/gltf-skin-roundtrip/README.md
index 93fe7d5..7c09988 100644
--- a/examples/gltf-skin-roundtrip/README.md
+++ b/examples/gltf-skin-roundtrip/README.md
@@ -15,24 +15,24 @@ maps are in [`triangulate-tangents`](../triangulate-tangents/).
**What it witnesses:** the skinned-mesh export contract the geometry
round-trip left uncovered.
-1. **The skeleton survives.** `skins[0].joints` names every bone; the
- re-imported armature carries the same 7 bones, the same parent chain, and
- rest matrices within 2.4e-07 — the +Y-up conversion applies to bone nodes
- exactly as it does to meshes (their translations convert, no rotation is
- written).
-2. **The weights survive.** Every primitive carries JOINTS_0/WEIGHTS_0;
- per-vertex weights on disk sum to 1 (err 3.0e-08); the re-imported vertex
- groups match the authored groups **bit-exactly** (w_err 0.0), compared as
- straddle-safe position keys, the same protocol as the crate example.
-3. **The deformation survives.** Posed identically, the re-imported rig's
- evaluated mesh matches the original's within 4.8e-07 — linear blend
- skinning through the file format. The comparison is by rest-position key,
- never sorted multisets: the exporter welds duplicate loops (32 here), so
- cardinalities differ and a naive sorted zip mispairs vertices (a phantom
- 2.29 "deviation" measured and fixed during authoring).
-4. **The mesh must be parented to the armature.** The exporter warns
- "Armature must be the parent of skinned mesh" and picks an armature by
- name otherwise — with two rigs in the file it can bind the wrong one.
+- **The skeleton survives.** `skins[0].joints` names every bone; the
+ re-imported armature carries the same 7 bones, the same parent chain, and
+ rest matrices within 2.4e-07 — the +Y-up conversion applies to bone nodes
+ exactly as it does to meshes (their translations convert, no rotation is
+ written).
+- **The weights survive.** Every primitive carries JOINTS_0/WEIGHTS_0;
+ per-vertex weights on disk sum to 1 (err 3.0e-08); the re-imported vertex
+ groups match the authored groups **bit-exactly** (w_err 0.0), compared as
+ straddle-safe position keys, the same protocol as the crate example.
+- **The deformation survives.** Posed identically, the re-imported rig's
+ evaluated mesh matches the original's within 4.8e-07 — linear blend
+ skinning through the file format. The comparison is by rest-position key,
+ never sorted multisets: the exporter welds duplicate loops (32 here), so
+ cardinalities differ and a naive sorted zip mispairs vertices (a phantom
+ 2.29 "deviation" measured and fixed during authoring).
+- **The mesh must be parented to the armature.** The exporter warns
+ "Armature must be the parent of skinned mesh" and picks an armature by
+ name otherwise — with two rigs in the file it can bind the wrong one.
**What each check catches on failure:** exporting with `export_skins=False`
(exit 5 — no skin on disk), stripping the weights (exit 4 — no vertex
diff --git a/examples/image-pixels-testcard/README.md b/examples/image-pixels-testcard/README.md
index becb511..71c7e6c 100644
--- a/examples/image-pixels-testcard/README.md
+++ b/examples/image-pixels-testcard/README.md
@@ -66,4 +66,9 @@ It exits non-zero on failure and prints every measured error and tolerance on su
so CI logs carry the numbers. The `blender-smoke` workflow runs the check on Blender
4.5 LTS and 5.1. In the render, `Closest` interpolation keeps the pixel grid honest —
the jagged circle edge is the 512 × 288 buffer itself, and the white marker in the
-PLUGE row sits at the bottom-left because that is where pixel (0, 0) lives.
+PLUGE row sits at the bottom-left because that is where pixel (0, 0) lives. The
+monitor is staged as a designed object — beveled dark-polymer case, machined metal
+stand, teal power LED — on the dark studio stage from `docs/VISUAL-STYLE.md`
+(Standard view transform; warm key, cool fill and rim; a warm pool raking the back
+wall). The screen stays emissive and matte — specular off, emission strength 1.0 —
+so the card's values read exactly.
diff --git a/examples/image-pixels-testcard/image_pixels_testcard.py b/examples/image-pixels-testcard/image_pixels_testcard.py
index e9016d2..27f1580 100644
--- a/examples/image-pixels-testcard/image_pixels_testcard.py
+++ b/examples/image-pixels-testcard/image_pixels_testcard.py
@@ -198,14 +198,56 @@ def eevee_engine_id():
def render_still(path, engine):
import bmesh
+ from mathutils import Vector
scene = bpy.context.scene
+ # Standard view transform, always: AgX lifts the stage toward grey and
+ # washes the bars pastel (docs/VISUAL-STYLE.md)
+ scene.view_settings.view_transform = 'Standard'
# the card itself: a byte sRGB image, written with the one bulk call
card = bpy.data.images.new("TestCard", W, H, alpha=False)
card.pixels.foreach_set(flat_pattern())
- # screen: emissive plane textured with the card (16:9, 3.2 wide)
+ def principled(name, base, rough, metallic=0.0, emit=None, estr=0.0):
+ m = bpy.data.materials.new(name)
+ m.use_nodes = True
+ b = m.node_tree.nodes["Principled BSDF"]
+ b.inputs["Base Color"].default_value = (*base, 1.0)
+ b.inputs["Roughness"].default_value = rough
+ b.inputs["Metallic"].default_value = metallic
+ if emit is not None:
+ b.inputs["Emission Color"].default_value = (*emit, 1.0)
+ b.inputs["Emission Strength"].default_value = estr
+ return m
+
+ def box(name, dims, mat, loc, bevel=0.0):
+ """A world-dimensioned box; a small bevel catches a machined edge line."""
+ me = bpy.data.meshes.new(name)
+ bm = bmesh.new()
+ try:
+ bmesh.ops.create_cube(bm, size=1.0)
+ bmesh.ops.scale(bm, vec=Vector(dims), verts=bm.verts)
+ for f in bm.faces:
+ f.smooth = bevel > 0.0
+ bm.to_mesh(me)
+ finally:
+ bm.free()
+ me.materials.append(mat)
+ ob = bpy.data.objects.new(name, me)
+ ob.location = loc
+ scene.collection.objects.link(ob)
+ if bevel > 0.0:
+ mod = ob.modifiers.new("EdgeBevel", 'BEVEL')
+ mod.width = bevel
+ mod.segments = 3
+ mod.limit_method = 'ANGLE'
+ return ob
+
+ # -- the monitor: designed object, not a black slab ----------------------
+ # screen: emissive plane textured with the card (16:9, 3.2 wide), centered
+ # at standing height on its stand
sw, sh = 3.2, 1.8
+ scr_z = 1.61
screen_me = bpy.data.meshes.new("Screen")
bm = bmesh.new()
try:
@@ -228,46 +270,31 @@ def render_still(path, engine):
bsdf = nodes["Principled BSDF"]
bsdf.inputs["Base Color"].default_value = (0.0, 0.0, 0.0, 1.0)
bsdf.inputs["Roughness"].default_value = 0.4
+ # matte: specular off so the wall/floor horizon never reflects as a line
+ # across the witness data; emission 1.0 so the card reads exactly
+ bsdf.inputs["Specular IOR Level"].default_value = 0.0
links.new(tex.outputs["Color"], bsdf.inputs["Emission Color"])
- bsdf.inputs["Emission Strength"].default_value = 1.35 # hotter washes the bars pastel
+ bsdf.inputs["Emission Strength"].default_value = 1.0
screen_me.materials.append(smat)
screen = bpy.data.objects.new("Screen", screen_me)
screen.scale = (sw, sh, 1.0)
- screen.rotation_euler = (math.radians(90), 0.0, math.radians(-16))
- screen.location = (0.0, 0.0, sh / 2 + 0.14)
+ screen.rotation_euler = (math.radians(90), 0.0, 0.0)
+ screen.location = (0.0, 0.0, scr_z)
scene.collection.objects.link(screen)
- # bezel: a slightly larger dark slab just behind the screen
- bezel_me = bpy.data.meshes.new("Bezel")
- bm = bmesh.new()
- try:
- bmesh.ops.create_cube(bm, size=1.0)
- bm.to_mesh(bezel_me)
- finally:
- bm.free()
- bmat = bpy.data.materials.new("Bezel")
- bmat.use_nodes = True
- bb = bmat.node_tree.nodes["Principled BSDF"]
- bb.inputs["Base Color"].default_value = (0.02, 0.022, 0.025, 1.0)
- bb.inputs["Roughness"].default_value = 0.28
- bb.inputs["Metallic"].default_value = 0.6
- bezel_me.materials.append(bmat)
- bezel = bpy.data.objects.new("Bezel", bezel_me)
- bezel.scale = (sw + 0.14, 0.09, sh + 0.14)
- bezel.rotation_euler = (0.0, 0.0, math.radians(-16))
- # offset along the yawed screen normal so the slab stays behind the plane
- bezel.location = (0.052 * math.sin(math.radians(16)),
- 0.052 * math.cos(math.radians(16)), sh / 2 + 0.14)
- scene.collection.objects.link(bezel)
-
- # plinth foot under the monitor
- foot = bpy.data.objects.new("Foot", bezel_me.copy())
- foot.data.materials.clear(); foot.data.materials.append(bmat)
- foot.scale = (1.1, 0.5, 0.14)
- foot.location = (0.0, 0.05, 0.07)
- scene.collection.objects.link(foot)
-
- # glossy dark floor + back wall
+ # case: dark polymer shell with a beveled edge the rim light can draw
+ case_mat = principled("CasePolymer", (0.030, 0.034, 0.042), 0.38)
+ metal_mat = principled("StandMetal", (0.055, 0.060, 0.070), 0.36, metallic=0.7)
+ box("Case", (sw + 0.18, 0.11, sh + 0.18), case_mat, (0.0, 0.057, scr_z), bevel=0.03)
+ # machined stand: neck + base plate
+ box("Neck", (0.30, 0.14, 0.64), metal_mat, (0.0, 0.10, 0.37), bevel=0.025)
+ box("Base", (1.45, 0.85, 0.07), metal_mat, (0.0, 0.12, 0.035), bevel=0.03)
+ # power LED on the bottom bezel, a small designed detail
+ glow_mat = principled("Glow", (0.0, 0.0, 0.0), 0.5, emit=(0.10, 0.85, 0.75), estr=6.0)
+ box("LED", (0.05, 0.02, 0.02), glow_mat, (1.30, -0.006, scr_z - sh / 2 - 0.045))
+
+ # -- stage: near-black floor + back wall, matte --------------------------
+ stage_mat = principled("Studio", (0.03, 0.032, 0.037), 0.7)
floor_me = bpy.data.meshes.new("Floor")
bm = bmesh.new()
try:
@@ -275,54 +302,49 @@ def render_still(path, engine):
bm.to_mesh(floor_me)
finally:
bm.free()
- fmat = bpy.data.materials.new("Studio")
- fmat.use_nodes = True
- fb = fmat.node_tree.nodes["Principled BSDF"]
- fb.inputs["Base Color"].default_value = (0.045, 0.05, 0.06, 1.0)
- fb.inputs["Roughness"].default_value = 0.18
- floor_me.materials.append(fmat)
+ floor_me.materials.append(stage_mat)
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.location = (0.0, 8.5, 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.025, 0.03, 0.04, 1.0)
+ 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):
+ # -- lighting: shaped warm key, cool fill/rim, warm pool on the wall -----
+ def light(name, loc, target, energy, size, col):
ld = bpy.data.lights.new(name, 'AREA')
- ld.energy = energy; ld.size = size; ld.color = col
+ 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)
+ # aim exactly at the target — a guessed euler is how stray bands happen
+ d = Vector(target) - Vector(loc)
+ ob.rotation_euler = d.to_track_quat('-Z', 'Y').to_euler()
scene.collection.objects.link(ob)
- # teal underglow bar tucked beneath the monitor, spilling onto the floor
- strip = bpy.data.objects.new("GlowStrip", bezel_me.copy())
- gmat = bpy.data.materials.new("Glow")
- gmat.use_nodes = True
- gb = gmat.node_tree.nodes["Principled BSDF"]
- gb.inputs["Emission Color"].default_value = (0.1, 0.9, 0.8, 1.0)
- gb.inputs["Emission Strength"].default_value = 12.0
- strip.data.materials.clear(); strip.data.materials.append(gmat)
- strip.scale = (2.7, 0.045, 0.015)
- strip.rotation_euler = (0.0, 0.0, math.radians(-16))
- strip.location = (0.0, -0.10, 0.015)
- scene.collection.objects.link(strip)
-
- # the card is its own key light; cool fill and a warm rim shape the bezel
- light("Fill", (-5.2, -3.0, 3.6), 80.0, 7.0, (0.7, 0.8, 1.0), (58, 0, -60))
- light("Rim", (4.2, 2.8, 2.2), 260.0, 2.5, (1.0, 0.72, 0.45), (-70, 0, 125))
-
+ light("Key", (-4.0, -5.0, 6.0), (0.0, 0.0, 1.3), 420.0, 5.0, (1.0, 0.96, 0.9))
+ light("Fill", (4.2, -1.8, 2.4), (0.0, 0.0, 1.5), 55.0, 9.0, (0.75, 0.85, 1.0))
+ light("Rim", (3.4, 2.8, 3.4), (0.3, 0.0, 1.6), 320.0, 3.0, (0.6, 0.78, 1.0))
+ # the signature: a warm pool raking the back wall behind the subject;
+ # aimed at the wall well above the floor seam so the seam stays in shadow
+ light("Wedge", (3.2, 5.2, 6.2), (2.4, 8.5, 4.2), 500.0, 5.0, (1.0, 0.76, 0.5))
+
+ # camera: 50 mm, slightly above the subject, tracking an empty on it
+ target = bpy.data.objects.new("AimTarget", None)
+ target.location = (0.0, 0.0, 1.42)
+ scene.collection.objects.link(target)
cam_data = bpy.data.cameras.new("Cam")
- cam_data.lens = 44.0
+ cam_data.lens = 50.0
cam = bpy.data.objects.new("Cam", cam_data)
- cam.location = (-2.45, -5.7, 1.62)
- cam.rotation_euler = (math.radians(84.5), 0.0, math.radians(-23.0))
+ cam.location = (-4.2, -7.7, 2.85)
+ con = cam.constraints.new('TRACK_TO')
+ con.target = target
scene.collection.objects.link(cam)
scene.camera = cam
diff --git a/examples/image-pixels-testcard/preview.webp b/examples/image-pixels-testcard/preview.webp
index 7ff5549..acc1518 100644
Binary files a/examples/image-pixels-testcard/preview.webp and b/examples/image-pixels-testcard/preview.webp differ
diff --git a/examples/lod-decimate-chain/README.md b/examples/lod-decimate-chain/README.md
index dfd8dc3..02578ba 100644
--- a/examples/lod-decimate-chain/README.md
+++ b/examples/lod-decimate-chain/README.md
@@ -13,22 +13,22 @@ and [`mesh-editing-and-bmesh`](../../skills/mesh-editing-and-bmesh/SKILL.md).
**What it witnesses:** the modifier-based LOD contract that game asset pipelines
rely on.
-1. **Decimate is non-destructive and lives in the depsgraph.** The evaluated
- mesh carries the reduction; the original datablock is byte-identical before
- and after evaluation. The check proves both — evaluated counts drop while
- `obj.data` keeps the closed-form counts (1,147 verts / 2,260 tris) — and
- every evaluated reference is released with `to_mesh_clear()`. An LOD chain
- that bakes the reduction into `obj.data` destroys the asset's LOD0.
-2. **The COLLAPSE ratio is a target, not a guarantee.** Each LOD's evaluated
- triangle count must land within 5% of `ratio x base_tris` — measured 0.00%
- at ratio 0.5 (1,130 tris) and 0.44% at ratio 0.18 (405 tris). A stacked
- second Decimate halves the effective ratio (50% excursion, caught); a
- dropped modifier leaves evaluated == original (caught).
-3. **LODs preserve silhouette-critical dimensions.** Height (3.12) and fin
- span are the rocket's readability; the evaluated bbox holds the base bbox
- within 1e-3 at every level (measured 7.7e-6). Decimate has no vertex
- pinning, so this is a real risk, not a formality: at an aggressive ratio
- 0.02 the nose tip collapses (bbox drift 0.0206, caught).
+- **Decimate is non-destructive and lives in the depsgraph.** The evaluated
+ mesh carries the reduction; the original datablock is byte-identical before
+ and after evaluation. The check proves both — evaluated counts drop while
+ `obj.data` keeps the closed-form counts (1,147 verts / 2,260 tris) — and
+ every evaluated reference is released with `to_mesh_clear()`. An LOD chain
+ that bakes the reduction into `obj.data` destroys the asset's LOD0.
+- **The COLLAPSE ratio is a target, not a guarantee.** Each LOD's evaluated
+ triangle count must land within 5% of `ratio x base_tris` — measured 0.00%
+ at ratio 0.5 (1,130 tris) and 0.44% at ratio 0.18 (405 tris). A stacked
+ second Decimate halves the effective ratio (50% excursion, caught); a
+ dropped modifier leaves evaluated == original (caught).
+- **LODs preserve silhouette-critical dimensions.** Height (3.12) and fin
+ span are the rocket's readability; the evaluated bbox holds the base bbox
+ within 1e-3 at every level (measured 7.7e-6). Decimate has no vertex
+ pinning, so this is a real risk, not a formality: at an aggressive ratio
+ 0.02 the nose tip collapses (bbox drift 0.0206, caught).
The Decimate modifier API (`decimate_type='COLLAPSE'`, `ratio`) is stable
between Blender 4.5 LTS and 5.1 — the example runs identically on both, which
diff --git a/examples/png-exr-alpha/README.md b/examples/png-exr-alpha/README.md
index 380e546..0fcc875 100644
--- a/examples/png-exr-alpha/README.md
+++ b/examples/png-exr-alpha/README.md
@@ -17,18 +17,18 @@ buffer. Documented here so the contract cannot quietly drift.
**What it witnesses:**
-1. **Float → PNG false unpremul** — `Image.save()` on a Non-Color float image
- writes RGBA16 (`IHDR` bit_depth=16, color_type=6). Reloaded pixels match
- the closed form `q16(min(1, c / q16(a)))` within `2/65535`. Max RGB error
- vs authored on the probe palette is `>= 0.90` (measured **0.98**).
-2. **Float → OpenEXR fidelity** — same authored buffer round-trips within
- `1e-5` (measured ~`3e-8`).
-3. **Byte → PNG straight alpha** — `float_buffer=False` writes RGBA8; pixels
- match independent per-channel `q8` within `0.5/255`. The stress cell at
- `(0.02, a=1/255)` stays near 0.02, not clamped white.
-4. **EXR `color_mode='RGB'` drops alpha** — `save_render` with
- `color_mode='RGB'` reloads opaque alpha (`≈ 1.0`) even when authored
- alpha was `1/255`.
+- **Float → PNG false unpremul** — `Image.save()` on a Non-Color float image
+ writes RGBA16 (`IHDR` bit_depth=16, color_type=6). Reloaded pixels match
+ the closed form `q16(min(1, c / q16(a)))` within `2/65535`. Max RGB error
+ vs authored on the probe palette is `>= 0.90` (measured **0.98**).
+- **Float → OpenEXR fidelity** — same authored buffer round-trips within
+ `1e-5` (measured ~`3e-8`).
+- **Byte → PNG straight alpha** — `float_buffer=False` writes RGBA8; pixels
+ match independent per-channel `q8` within `0.5/255`. The stress cell at
+ `(0.02, a=1/255)` stays near 0.02, not clamped white.
+- **EXR `color_mode='RGB'` drops alpha** — `save_render` with
+ `color_mode='RGB'` reloads opaque alpha (`≈ 1.0`) even when authored
+ alpha was `1/255`.
**What each check catches on failure:**
@@ -51,9 +51,11 @@ treat a 4.4 run as a 4.5 substitute). The only version gate in the file is
the EEVEE engine id for the optional render (`BLENDER_EEVEE_NEXT` on 4.x,
`BLENDER_EEVEE` on 5.x).
-**Render:** two easel panels. Left bakes the closed-form PNG mangling (dark
-rows flash to white at low-alpha columns). Right shows the authored straight
-buffer (EXR-clean). If the contract failed, both panels would read the same.
+**Render:** two framed verification displays on a shared plinth — the
+left face bakes the closed-form PNG mangling (dark rows flash to white at
+low-alpha columns), the right face shows the authored straight buffer
+(EXR-clean); nameplates read `FLOAT → PNG` and `FLOAT → EXR`. If the
+contract failed, both panels would read the same.
## Run
diff --git a/examples/png-exr-alpha/png_exr_alpha.py b/examples/png-exr-alpha/png_exr_alpha.py
index 7a6df8c..baa1a1a 100644
--- a/examples/png-exr-alpha/png_exr_alpha.py
+++ b/examples/png-exr-alpha/png_exr_alpha.py
@@ -11,8 +11,8 @@
AI-generated Blender code commonly trusts `Image.save()` to PNG for float
RGBA scratch buffers (masks, ID mattes, AOVs). Pass --output to also render
-a dual-easel still: left is the PNG-mangled reload, right is the EXR-clean
-reload.
+a staged still of two framed verification displays: left is the PNG-mangled
+reload, right is the EXR-clean reload.
blender --background --python png_exr_alpha.py --
blender --background --python png_exr_alpha.py -- --output alpha.png
@@ -331,22 +331,16 @@ def eevee_engine_id():
return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT"
-def specular_off(bsdf):
- if "Specular IOR Level" in bsdf.inputs:
- bsdf.inputs["Specular IOR Level"].default_value = 0.0
- elif "Specular" in bsdf.inputs:
- bsdf.inputs["Specular"].default_value = 0.0
-
-
def make_gallery_card(name, mangled):
- """Visual card of the false-unpremul contract for the dual-easel still.
+ """Visual card of the false-unpremul contract for the staged still.
Columns = rising alpha; top rows = dark mid-tones that PNG clamps to
white; lower rows = primaries that survive. Left panel bakes the
closed-form mangling; right panel shows authored straight RGBA.
- A dark bezel is painted into the texture so panel edges read as framed
- objects in the studio still (render-only — checks use fill_pattern).
+ A dark trim is painted into the texture edge so the emissive face reads
+ as a recessed screen inside its physical bezel (render-only — checks
+ use fill_pattern).
"""
gw, gh = 256, 192
border = 10
@@ -387,8 +381,46 @@ def make_gallery_card(name, mangled):
return img
-def easel_plane(name, image, loc, rot_z_deg):
- """Single-plane easel + short stand — no dual-surface z-fight."""
+def fixture_mats():
+ """Designed non-data surfaces: dark polymer bezels, rail-steel struts,
+ machined nameplates, plinth — no Principled defaults."""
+ def mat_principled(name, color, metallic, rough):
+ mat = bpy.data.materials.new(name)
+ mat.use_nodes = True
+ bsdf = mat.node_tree.nodes["Principled BSDF"]
+ bsdf.inputs["Base Color"].default_value = (*color, 1.0)
+ bsdf.inputs["Metallic"].default_value = metallic
+ bsdf.inputs["Roughness"].default_value = rough
+ return mat
+
+ return {
+ "bezel": mat_principled("BezelPolymer", (0.045, 0.048, 0.055), 0.1, 0.5),
+ "rail": mat_principled("RailSteel", (0.075, 0.08, 0.09), 0.6, 0.45),
+ "plinth": mat_principled("PlinthSteel", (0.10, 0.11, 0.12), 0.6, 0.5),
+ "plate": mat_principled("PlateSteel", (0.14, 0.15, 0.17), 0.7, 0.4),
+ }
+
+
+def box_obj(name, dims, loc, mat, rot_x=0.0):
+ me = bpy.data.meshes.new(name)
+ bm = bmesh.new()
+ try:
+ bmesh.ops.create_cube(bm, size=1.0)
+ bm.to_mesh(me)
+ finally:
+ bm.free()
+ me.materials.append(mat)
+ ob = bpy.data.objects.new(name, me)
+ ob.scale = dims
+ ob.location = loc
+ ob.rotation_euler = (rot_x, 0.0, 0.0)
+ bpy.context.collection.objects.link(ob)
+ return ob
+
+
+def face_mesh(name):
+ """UV-mapped unit plane (create_grid lies in XY facing +Z; the display
+ unit rotates it vertical)."""
me = bpy.data.meshes.new(name)
bm = bmesh.new()
try:
@@ -404,84 +436,101 @@ def easel_plane(name, image, loc, rot_z_deg):
bm.to_mesh(me)
finally:
bm.free()
+ return me
- mat = bpy.data.materials.new(name + "Mat")
+
+def face_mat(name, image):
+ """Data faces stay pure emission: exact card values, no specular, no
+ shading — the panel is a backlit data display."""
+ mat = bpy.data.materials.new(name)
mat.use_nodes = True
nt = mat.node_tree
- nodes, links = nt.nodes, nt.links
- bsdf = nodes["Principled BSDF"]
- tex = nodes.new("ShaderNodeTexImage")
+ nt.nodes.clear()
+ out = nt.nodes.new("ShaderNodeOutputMaterial")
+ em = nt.nodes.new("ShaderNodeEmission")
+ tex = nt.nodes.new("ShaderNodeTexImage")
tex.image = image
tex.interpolation = "Closest"
- links.new(tex.outputs["Color"], bsdf.inputs["Base Color"])
- links.new(tex.outputs["Color"], bsdf.inputs["Emission Color"])
- bsdf.inputs["Roughness"].default_value = 1.0
- specular_off(bsdf)
- # Faint emission so clamps read under a dim stage without washing the wall.
- bsdf.inputs["Emission Strength"].default_value = 0.22
- me.materials.append(mat)
+ nt.links.new(tex.outputs["Color"], em.inputs["Color"])
+ nt.links.new(em.outputs["Emission"], out.inputs["Surface"])
+ em.inputs["Strength"].default_value = 1.0
+ return mat
+
+def caption_mat():
+ mat = bpy.data.materials.new("CaptionGrey")
+ mat.use_nodes = True
+ cb = mat.node_tree.nodes["Principled BSDF"]
+ cb.inputs["Base Color"].default_value = (0.42, 0.44, 0.48, 1.0)
+ cb.inputs["Metallic"].default_value = 0.2
+ cb.inputs["Roughness"].default_value = 0.6
+ sock = cb.inputs.get("Emission Color") or cb.inputs["Emission"]
+ sock.default_value = (0.38, 0.40, 0.45, 1.0)
+ cb.inputs["Emission Strength"].default_value = 0.5
+ return mat
+
+
+def display_unit(name, image, caption, x, mats, capmat):
+ """One verification display: the emissive data face seated proud of a
+ thick dark bezel, carried by a rear strut and foot, with a machined
+ nameplate under the bezel — a physical instrument standing on the
+ shared plinth, not a floating texture."""
root = bpy.data.objects.new(name, None)
- root.location = loc
- root.rotation_euler = (math.radians(62), 0.0, math.radians(rot_z_deg))
- root.scale = (0.88, 0.88, 0.88)
+ root.location = (x, 0.0, 0.0)
bpy.context.collection.objects.link(root)
- face = bpy.data.objects.new(name + "Face", me)
- bpy.context.collection.objects.link(face)
- face.parent = root
+ def adopt(ob):
+ ob.parent = root
+ return ob
- stand_me = bpy.data.meshes.new(name + "Stand")
- bm = bmesh.new()
- try:
- bmesh.ops.create_cube(bm, size=1.0)
- bm.to_mesh(stand_me)
- finally:
- bm.free()
- smat = bpy.data.materials.new(name + "StandMat")
- smat.use_nodes = True
- sb = smat.node_tree.nodes["Principled BSDF"]
- sb.inputs["Base Color"].default_value = (0.045, 0.048, 0.055, 1.0)
- sb.inputs["Roughness"].default_value = 0.45
- if "Metallic" in sb.inputs:
- sb.inputs["Metallic"].default_value = 0.3
- stand_me.materials.append(smat)
- stand = bpy.data.objects.new(name + "Stand", stand_me)
- # Fully below the card face — never intersects the evidence plane.
- stand.scale = (0.06, 0.06, 0.28)
- stand.location = (0.0, 0.04, -0.88)
- bpy.context.collection.objects.link(stand)
- stand.parent = root
+ # bezel with real thickness; the face sits proud of its front surface
+ adopt(box_obj(name + "Bezel", (2.64, 0.16, 2.04), (0.0, 0.0, 1.80),
+ mats["bezel"]))
+ face = bpy.data.objects.new(name + "Face", face_mesh(name + "Face"))
+ bpy.context.collection.objects.link(face)
+ face.data.materials.append(face_mat(name + "FaceMat", image))
+ face.location = (0.0, -0.085, 1.80)
+ face.rotation_euler = (math.radians(90), 0.0, 0.0)
+ face.scale = (1.20, 0.90, 1.0)
+ adopt(face)
+ # rear strut leaning onto the bezel back, foot on the plinth top
+ adopt(box_obj(name + "Strut", (0.26, 0.30, 1.30), (0.0, 0.42, 1.10),
+ mats["rail"], rot_x=math.radians(18)))
+ adopt(box_obj(name + "Foot", (0.60, 0.80, 0.10), (0.0, 0.72, 0.50),
+ mats["rail"]))
+ # nameplate bridging bezel bottom and plinth top
+ adopt(box_obj(name + "Plate", (1.60, 0.06, 0.26), (0.0, -0.03, 0.63),
+ mats["plate"]))
+ cu = bpy.data.curves.new(name + "Caption", "FONT")
+ cu.body = caption
+ cu.align_x = "CENTER"
+ cu.align_y = "CENTER"
+ cu.size = 0.15
+ cu.extrude = 0.004
+ cu.materials.append(capmat)
+ cap = bpy.data.objects.new(name + "Caption", cu)
+ cap.location = (0.0, -0.065, 0.63)
+ cap.rotation_euler = (math.radians(90), 0.0, 0.0)
+ bpy.context.collection.objects.link(cap)
+ adopt(cap)
return root
def render_still(path, engine):
+ import mathutils
+
scene = bpy.context.scene
png_card = make_gallery_card("PngMangled", mangled=True)
exr_card = make_gallery_card("ExrClean", mangled=False)
- # Mounted easels on a shared dark table — continuous base, no V-tear shadow.
- easel_plane("PngPanel", png_card, (-1.15, 0.1, 1.28), 5)
- easel_plane("ExrPanel", exr_card, (1.15, 0.1, 1.28), -5)
-
- table_me = bpy.data.meshes.new("Table")
- bm = bmesh.new()
- try:
- bmesh.ops.create_cube(bm, size=1.0)
- bm.to_mesh(table_me)
- finally:
- bm.free()
- tmat = bpy.data.materials.new("TableMat")
- tmat.use_nodes = True
- tb = tmat.node_tree.nodes["Principled BSDF"]
- tb.inputs["Base Color"].default_value = (0.022, 0.024, 0.028, 1.0)
- tb.inputs["Roughness"].default_value = 0.6
- table_me.materials.append(tmat)
- table = bpy.data.objects.new("Table", table_me)
- table.scale = (1.5, 0.55, 0.08)
- table.location = (0.0, 0.25, 0.28)
- scene.collection.objects.link(table)
+ # The verification station: two framed displays on a shared plinth —
+ # left bakes the closed-form PNG mangling, right is EXR-clean.
+ mats = fixture_mats()
+ capmat = caption_mat()
+ display_unit("PngDisplay", png_card, "FLOAT → PNG", -1.45, mats, capmat)
+ display_unit("ExrDisplay", exr_card, "FLOAT → EXR", 1.45, mats, capmat)
+ box_obj("Plinth", (6.10, 1.80, 0.45), (0.0, 0.35, 0.225), mats["plinth"])
floor_me = bpy.data.meshes.new("Floor")
bm = bmesh.new()
@@ -493,46 +542,57 @@ def render_still(path, engine):
fmat = bpy.data.materials.new("Studio")
fmat.use_nodes = True
fb = fmat.node_tree.nodes["Principled BSDF"]
- fb.inputs["Base Color"].default_value = (0.02, 0.021, 0.025, 1.0)
- fb.inputs["Roughness"].default_value = 0.8
+ 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, 8.8, 0.0)
+ 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.012, 0.013, 0.015, 1.0,
+ 0.02, 0.021, 0.025, 1.0,
)
scene.world = world
- def light(name, loc, energy, size, col, rot):
+ def light(name, loc, energy, size, col, target):
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)
+ d = mathutils.Vector(target) - ob.location
+ ob.rotation_euler = d.to_track_quat("-Z", "Y").to_euler()
scene.collection.objects.link(ob)
- light("Key", (-3.8, -4.8, 5.8), 220.0, 4.5, (1.0, 0.96, 0.9), (52, 0, -32))
- light("Fill", (4.8, -2.8, 1.6), 40.0, 9.0, (0.75, 0.85, 1.0), (72, 0, 52))
- light("Rim", (0.2, 4.8, 2.8), 120.0, 3.2, (0.6, 0.78, 1.0), (-62, 0, 178))
- light("Wedge", (0.5, 6.5, 2.5), 450.0, 7.0, (1.0, 0.72, 0.42), (-78, 0, 180))
+ # Shaped warm key, faint cool fill, cool rim, and the signature warm
+ # wedge between station and wall aimed at the wall point behind the
+ # subject — every light has an explicit target so nothing grazes a
+ # flat surface (grazing area lights draw stray bands).
+ light("Key", (-4.0, -4.5, 7.0), 460.0, 4.5, (1.0, 0.96, 0.9),
+ (0.0, 0.2, 1.8))
+ light("Fill", (6.0, -3.0, 3.0), 90.0, 9.0, (0.75, 0.85, 1.0),
+ (0.5, 0.0, 1.8))
+ light("Rim", (0.5, 4.5, 7.0), 320.0, 4.0, (0.6, 0.78, 1.0),
+ (0.0, 0.3, 2.0))
+ light("Wedge", (-0.5, 5.5, 6.0), 520.0, 4.5, (1.0, 0.76, 0.5),
+ (0.0, 9.0, 2.6))
aim = bpy.data.objects.new("Aim", None)
- aim.location = (0.0, 0.1, 1.15)
+ aim.location = (-0.10, 0.2, 1.65)
scene.collection.objects.link(aim)
+ # Gentle 3/4 from the right: both faces stay fully readable while the
+ # bezels, struts, and plinth show their thickness.
cam_data = bpy.data.cameras.new("Cam")
cam_data.lens = 50.0
cam = bpy.data.objects.new("Cam", cam_data)
- cam.location = (0.0, -6.0, 2.15)
+ cam.location = (3.9, -9.3, 3.05)
con = cam.constraints.new("TRACK_TO")
con.target = aim
con.track_axis = "TRACK_NEGATIVE_Z"
diff --git a/examples/png-exr-alpha/preview.webp b/examples/png-exr-alpha/preview.webp
index b419ced..ddfbe10 100644
Binary files a/examples/png-exr-alpha/preview.webp and b/examples/png-exr-alpha/preview.webp differ
diff --git a/examples/triangulate-tangents/README.md b/examples/triangulate-tangents/README.md
index 06859c4..09bce5f 100644
--- a/examples/triangulate-tangents/README.md
+++ b/examples/triangulate-tangents/README.md
@@ -13,30 +13,30 @@ 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.
+- **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.
+- **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).
+- **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
diff --git a/examples/uv-layer-grid/README.md b/examples/uv-layer-grid/README.md
index 1c05d18..9496e6c 100644
--- a/examples/uv-layer-grid/README.md
+++ b/examples/uv-layer-grid/README.md
@@ -12,16 +12,16 @@ lifted into its own smoke-gated witness so the contract cannot quietly drift.
**What it witnesses:** three related contracts on the same grid topology
(`SEG×SEG` faces, `(SEG+1)²` verts, size `1.0` → coords in `[-1, 1]`):
-1. **Silent no-op** — `create_grid(..., calc_uvs=True)` with no prior
- `bm.loops.layers.uv.new(...)` leaves `len(bm.loops.layers.uv) == 0` and
- `mesh.uv_layers` empty after `to_mesh`.
-2. **Pre-create repair** — create the UV layer first, then `calc_uvs=True`
- fills every loop. UVs must match the closed form
- `u = (x/size + 1)/2`, `v = (y/size + 1)/2` within `1e-6`, both on the
- BMesh and after persisting to `mesh.uv_layers.active.data`.
-3. **Explicit assignment fallback** — `calc_uvs=False`, then
- `uv.new("UVMap")` and a loop write of the same closed form. Does not
- depend on `calc_uvs` at all; same tolerance.
+- **Silent no-op** — `create_grid(..., calc_uvs=True)` with no prior
+ `bm.loops.layers.uv.new(...)` leaves `len(bm.loops.layers.uv) == 0` and
+ `mesh.uv_layers` empty after `to_mesh`.
+- **Pre-create repair** — create the UV layer first, then `calc_uvs=True`
+ fills every loop. UVs must match the closed form
+ `u = (x/size + 1)/2`, `v = (y/size + 1)/2` within `1e-6`, both on the
+ BMesh and after persisting to `mesh.uv_layers.active.data`.
+- **Explicit assignment fallback** — `calc_uvs=False`, then
+ `uv.new("UVMap")` and a loop write of the same closed form. Does not
+ depend on `calc_uvs` at all; same tolerance.
**What each check catches on failure:**
@@ -47,13 +47,14 @@ fill assert identically on Blender 4.5 LTS and 5.1. The only gate in the file
is the EEVEE engine id for the optional render (`BLENDER_EEVEE_NEXT` on 4.x,
`BLENDER_EEVEE` on 5.x).
-**Render:** two easel panels sharing one neon checker image. Left is the
+**Render:** two framed lightbox displays on floor trays (rear kick legs,
+status LED) sharing one neon checker image, shot at a slight 3/4. Left is the
hazard (no UV layer) — flat teal of texel (0, 0). Right is the repair
(pre-create + `calc_uvs`) — full magenta/cyan checker. If the UV contract
failed, both panels would read the same — and the still is not just an
illustration: the script re-reads its own render and exits non-zero unless
the pixels prove the flat-vs-checker split (measured on 5.1.2: hazard spread
-`0.0078`, repair spread `0.7294`).
+`0.0078`, repair spread `0.7412`; identical on 4.5.11).
## Run
diff --git a/examples/uv-layer-grid/preview.webp b/examples/uv-layer-grid/preview.webp
index 943731b..e58b2f3 100644
Binary files a/examples/uv-layer-grid/preview.webp and b/examples/uv-layer-grid/preview.webp differ
diff --git a/examples/uv-layer-grid/uv_layer_grid.py b/examples/uv-layer-grid/uv_layer_grid.py
index 019e725..908b045 100644
--- a/examples/uv-layer-grid/uv_layer_grid.py
+++ b/examples/uv-layer-grid/uv_layer_grid.py
@@ -19,6 +19,7 @@
blender --background --python uv_layer_grid.py -- --output uv.png
"""
import bpy, bmesh, sys, os, math, argparse
+from mathutils import Vector
SEG = 8
SIZE = 1.0
@@ -244,15 +245,108 @@ def render_still(path, engine):
scene = bpy.context.scene
card = make_uv_testcard("UVCard")
- # Left: the hazard — calc_uvs alone, no UV layer → flat teal of texel (0,0).
- broken = textured_plane("Broken", card, with_uvs=False)
- broken.location = (-1.18, 0.0, 0.94)
- broken.rotation_euler = (math.radians(62), 0.0, math.radians(8))
-
- # Right: the repair — pre-create UV layer, then calc_uvs fills it.
- fixed = textured_plane("Fixed", card, with_uvs=True)
- fixed.location = (1.18, 0.0, 0.94)
- fixed.rotation_euler = (math.radians(62), 0.0, math.radians(-8))
+ # --- Staging materials: quiet and dark, the panel faces carry the color --
+ def pbr(name, base, rough, metal=0.0):
+ m = bpy.data.materials.new(name)
+ m.use_nodes = True
+ b = m.node_tree.nodes["Principled BSDF"]
+ b.inputs["Base Color"].default_value = (*base, 1.0)
+ b.inputs["Roughness"].default_value = rough
+ b.inputs["Metallic"].default_value = metal
+ return m
+
+ frame_mat = pbr("Frame", (0.05, 0.053, 0.06), 0.32, 0.65)
+ stand_mat = pbr("Stand", (0.032, 0.035, 0.042), 0.45, 0.4)
+ led_mat = pbr("Led", (0.0, 0.0, 0.0), 0.6)
+ led_b = led_mat.node_tree.nodes["Principled BSDF"]
+ led_b.inputs["Emission Color"].default_value = (1.0, 0.45, 0.12, 1.0)
+ led_b.inputs["Emission Strength"].default_value = 4.0
+
+ def box(name, dims, loc, rot, mat, bevel=0.0):
+ dx, dy, dz = (d * 0.5 for d in dims)
+ verts = [
+ (-dx, -dy, -dz), (dx, -dy, -dz), (dx, dy, -dz), (-dx, dy, -dz),
+ (-dx, -dy, dz), (dx, -dy, dz), (dx, dy, dz), (-dx, dy, dz),
+ ]
+ faces = [
+ (0, 1, 2, 3), (4, 7, 6, 5), (0, 4, 5, 1),
+ (1, 5, 6, 2), (2, 6, 7, 3), (4, 0, 3, 7),
+ ]
+ me = bpy.data.meshes.new(name)
+ me.from_pydata(verts, [], faces)
+ me.materials.append(mat)
+ ob = bpy.data.objects.new(name, me)
+ ob.location = loc
+ ob.rotation_euler = rot
+ scene.collection.objects.link(ob)
+ if bevel > 0.0:
+ mod = ob.modifiers.new("Edge", "BEVEL")
+ mod.width = bevel
+ mod.segments = 2
+ return ob
+
+ def bar_between(name, p1, p2, width, mat):
+ a, b = Vector(p1), Vector(p2)
+ d = b - a
+ ob = box(name, (width, width, d.length), (a + b) * 0.5, (0, 0, 0), mat, 0.01)
+ ob.rotation_mode = "QUATERNION"
+ ob.rotation_quaternion = d.to_track_quat("Z", "Y")
+ return ob
+
+ # --- Two framed lightbox displays, each on a floor tray with a rear
+ # kick leg. The face planes keep their origins at the face centers (the
+ # pixel witness probes the projected centers), with a ~0.2-unit bezel
+ # between the face edge and the frame so the probed patch stays on data.
+ LEAN = math.radians(75.0) # 15° back off vertical, easel-like
+
+ def display(name, with_uvs, cx, cy, yaw_deg):
+ yaw = math.radians(yaw_deg)
+ asm = bpy.data.objects.new(name + "Asm", None)
+ asm.rotation_euler = (LEAN, 0.0, yaw)
+ rot = asm.rotation_euler.to_matrix()
+ # Face-center height such that the frame's bottom edge rests in tray.
+ contact = rot @ Vector((0.0, -1.17, 0.02))
+ asm.location = (cx, cy, 0.07 - contact.z)
+ scene.collection.objects.link(asm)
+
+ # Left: the hazard — calc_uvs alone, no UV layer → flat teal of
+ # texel (0,0). Right: the repair — pre-create, then calc_uvs fills.
+ face = textured_plane(name, card, with_uvs)
+ face.parent = asm
+ face.location = (0.0, 0.0, 0.075)
+
+ plate = box(name + "Back", (2.34, 2.34, 0.14), (0, 0, 0), (0, 0, 0),
+ frame_mat, 0.02)
+ plate.parent = asm
+ bezels = (
+ ((2.34, 0.17, 0.20), (0.0, 1.085, 0.02)),
+ ((2.34, 0.17, 0.20), (0.0, -1.085, 0.02)),
+ ((0.17, 2.0, 0.20), (1.085, 0.0, 0.02)),
+ ((0.17, 2.0, 0.20), (-1.085, 0.0, 0.02)),
+ )
+ for i, (dims, loc) in enumerate(bezels):
+ bez = box(f"{name}Bez{i}", dims, loc, (0, 0, 0), frame_mat, 0.02)
+ bez.parent = asm
+ led = box(name + "Led", (0.06, 0.03, 0.035), (0.92, -1.085, 0.128),
+ (0, 0, 0), led_mat, 0.008)
+ led.parent = asm
+
+ base = asm.location
+ box(name + "Tray", (2.0, 0.32, 0.10),
+ (base.x + contact.x, base.y + contact.y, 0.05),
+ (0, 0, yaw), stand_mat, 0.02)
+ back = rot @ Vector((0.0, 0.0, -1.0))
+ back.z = 0.0
+ back.normalize()
+ for side in (-0.75, 0.75):
+ attach = base + rot @ Vector((side, 0.55, -0.12))
+ foot = (base.x + contact.x + side * math.cos(yaw) + back.x * 0.85,
+ base.y + contact.y - side * math.sin(yaw) + back.y * 0.85,
+ 0.02)
+ bar_between(f"{name}Leg{side:+.2f}", attach, foot, 0.06, stand_mat)
+
+ display("Broken", False, -1.38, 0.30, 6.0)
+ display("Fixed", True, 1.45, -0.10, 9.0)
floor_me = bpy.data.meshes.new("Floor")
bm = bmesh.new()
@@ -291,21 +385,23 @@ def light(name, loc, energy, size, col, rot):
ob.rotation_euler = tuple(math.radians(a) for a in rot)
scene.collection.objects.link(ob)
- light("Key", (-3.8, -4.8, 5.8), 300.0, 4.5, (1.0, 0.96, 0.9), (52, 0, -32))
- light("Fill", (4.8, -2.8, 1.6), 55.0, 9.0, (0.75, 0.85, 1.0), (72, 0, 52))
- light("Rim", (0.2, 4.0, 2.8), 90.0, 3.2, (0.6, 0.78, 1.0), (-62, 0, 178))
- # Between the panels and the wall so it only rakes the backdrop: a
- # contained warm pool, corners falling off to dark.
- light("Wedge", (0.3, 5.4, 1.9), 380.0, 3.6, (1.0, 0.68, 0.38), (-72, 0, 180))
+ # Aimed steeply down so its spill dies on the floor, not on the wall.
+ light("Key", (-4.4, -4.2, 6.4), 340.0, 5.0, (1.0, 0.96, 0.9), (60, 0, -32))
+ light("Fill", (4.8, -2.8, 1.6), 80.0, 9.0, (0.75, 0.85, 1.0), (72, 0, 52))
+ light("Rim", (0.2, 4.6, 3.0), 250.0, 3.5, (0.6, 0.78, 1.0), (-42, 0, 178))
+ # Uplight between the displays and the wall, raking UP the backdrop: the
+ # visible wall band above the panel tops only spans z≈2.8–3.4, so the
+ # warm pool is aimed to live there instead of hiding behind the panels.
+ light("Wedge", (-2.2, 5.6, 2.3), 480.0, 3.0, (1.0, 0.76, 0.5), (-117, 0, 180))
aim = bpy.data.objects.new("Aim", None)
- aim.location = (0.0, 0.0, 0.95)
+ aim.location = (0.1, 0.05, 1.0)
scene.collection.objects.link(aim)
cam_data = bpy.data.cameras.new("Cam")
- cam_data.lens = 45.0
+ cam_data.lens = 48.0
cam = bpy.data.objects.new("Cam", cam_data)
- cam.location = (0.0, -6.6, 2.1)
+ cam.location = (3.1, -8.35, 2.2)
con = cam.constraints.new("TRACK_TO")
con.target = aim
con.track_axis = "TRACK_NEGATIVE_Z"
diff --git a/examples/vertex-weight-limit/README.md b/examples/vertex-weight-limit/README.md
index 0e5d23a..7fb7ea1 100644
--- a/examples/vertex-weight-limit/README.md
+++ b/examples/vertex-weight-limit/README.md
@@ -17,22 +17,22 @@ weighting here, export in [`gltf-export-roundtrip`](../gltf-export-roundtrip/).
**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.984 — a mesh that shrinks toward the origin under load (the check's
- measured failure, 1.616e-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 = 2.7e-07`.
-3. **Pruning must not damage the pose.** Evaluated positions before and after
- the limit are held within 0.05 (measured 2.8e-03), the pedestal mount stays
- exactly pinned (Root is unposed), and the pre-limit authoring really carries
- five influences in the cuffs — otherwise the witness would be vacuous.
+- **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.984 — a mesh that shrinks toward the origin under load (the check's
+ measured failure, 1.616e-02 off unit sum).
+- **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 = 2.7e-07`.
+- **Pruning must not damage the pose.** Evaluated positions before and after
+ the limit are held within 0.05 (measured 2.8e-03), the pedestal mount stays
+ exactly pinned (Root is unposed), and the pre-limit authoring really carries
+ five influences in the cuffs — 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
diff --git a/examples/vse-cut-list/README.md b/examples/vse-cut-list/README.md
index cc0ecbb..547bdd1 100644
--- a/examples/vse-cut-list/README.md
+++ b/examples/vse-cut-list/README.md
@@ -13,33 +13,33 @@ a GAMMA_CROSS fed by a dedicated source pair T1/T2, a scene strip, and a text
strip — asserted against closed-form spans on each version's canonical
accessors, then re-asserted after a save/reload round-trip:
-1. **Accessor rename** — 5.x: `sequence_editor.sequences` raises
- AttributeError; only `.strips` exists. 4.5: both accessors exist and see
- the same strips (the transition bridge).
-2. **Creation signature** — `strips.new_effect(...)` ends a strip with
- `frame_end=` on 4.5 but `length=` on 5.x; the wrong kwarg raises TypeError
- on each side (asserted, not assumed). 5.x also rejects the removed
- `TRANSFORM` effect type — per-strip `strip.transform` (StripTransform)
- places strips in the frame on both versions.
-3. **Frame-range closed form** — every strip's (start, end, duration) matches
- the authored end-exclusive span: `frame_final_*` on 4.5;
- `left_handle`/`right_handle`/`duration` on 5.x, where the `frame_final_*`
- names are deprecated aliases (removal announced for 6.0) that must still
- read equal — the bridge is asserted lossless, with the RNA
- `is_deprecated` flag checked on 5.x and its absence checked on 4.5.
-4. **Effect wiring** — `input_1`/`input_2` (not `input1`) on both versions,
- in authored order, and the cross clamped to exactly the T1/T2 overlap.
-5. **Scene strip** — 4-arg `new_scene(name, scene, channel, frame_start)` on
- both versions (no `length`/`frame_end` kwarg); default span is the source
- scene's frame range; the strip sources a *separate* Stage scene.
-6. **Round-trip** — spans, channels, colors, GC wiring, text, and mosaic
- transforms (pixel-unit offsets, tol 1e-3) all survive
- `save_as_mainfile` → `open_mainfile`.
-7. **Compositing (`--check-pixels`)** — a 96×54 render asserts each mosaic
- cell center carries its strip color (tol 0.1), the cross cell is a true
- mid blend (strictly between the sources on every channel), and a margin
- point matches neither cross source — because a consumed input compositing
- independently would paint it full-frame.
+- **Accessor rename** — 5.x: `sequence_editor.sequences` raises
+ AttributeError; only `.strips` exists. 4.5: both accessors exist and see
+ the same strips (the transition bridge).
+- **Creation signature** — `strips.new_effect(...)` ends a strip with
+ `frame_end=` on 4.5 but `length=` on 5.x; the wrong kwarg raises TypeError
+ on each side (asserted, not assumed). 5.x also rejects the removed
+ `TRANSFORM` effect type — per-strip `strip.transform` (StripTransform)
+ places strips in the frame on both versions.
+- **Frame-range closed form** — every strip's (start, end, duration) matches
+ the authored end-exclusive span: `frame_final_*` on 4.5;
+ `left_handle`/`right_handle`/`duration` on 5.x, where the `frame_final_*`
+ names are deprecated aliases (removal announced for 6.0) that must still
+ read equal — the bridge is asserted lossless, with the RNA
+ `is_deprecated` flag checked on 5.x and its absence checked on 4.5.
+- **Effect wiring** — `input_1`/`input_2` (not `input1`) on both versions,
+ in authored order, and the cross clamped to exactly the T1/T2 overlap.
+- **Scene strip** — 4-arg `new_scene(name, scene, channel, frame_start)` on
+ both versions (no `length`/`frame_end` kwarg); default span is the source
+ scene's frame range; the strip sources a *separate* Stage scene.
+- **Round-trip** — spans, channels, colors, GC wiring, text, and mosaic
+ transforms (pixel-unit offsets, tol 1e-3) all survive
+ `save_as_mainfile` → `open_mainfile`.
+- **Compositing (`--check-pixels`)** — a 96×54 render asserts each mosaic
+ cell center carries its strip color (tol 0.1), the cross cell is a true
+ mid blend (strictly between the sources on every channel), and a margin
+ point matches neither cross source — because a consumed input compositing
+ independently would paint it full-frame.
**What each check catches on failure:**