diff --git a/docs/gallery/assets/gltf-export-roundtrip-hero.webp b/docs/gallery/assets/gltf-export-roundtrip-hero.webp index e1502f0..d7fbe28 100644 Binary files a/docs/gallery/assets/gltf-export-roundtrip-hero.webp and b/docs/gallery/assets/gltf-export-roundtrip-hero.webp differ diff --git a/docs/gallery/assets/vertex-weight-limit-hero.webp b/docs/gallery/assets/vertex-weight-limit-hero.webp index f8d27da..2ee8f40 100644 Binary files a/docs/gallery/assets/vertex-weight-limit-hero.webp and b/docs/gallery/assets/vertex-weight-limit-hero.webp differ diff --git a/docs/gallery/assets/vse-cut-list-hero.webp b/docs/gallery/assets/vse-cut-list-hero.webp index d0a70f1..5577bab 100644 Binary files a/docs/gallery/assets/vse-cut-list-hero.webp and b/docs/gallery/assets/vse-cut-list-hero.webp differ diff --git a/docs/gallery/contact-sheets/game-pipeline-audit-contact-sheet.webp b/docs/gallery/contact-sheets/game-pipeline-audit-contact-sheet.webp new file mode 100644 index 0000000..c3a0f63 Binary files /dev/null and b/docs/gallery/contact-sheets/game-pipeline-audit-contact-sheet.webp differ diff --git a/docs/gallery/gltf-export-roundtrip/index.html b/docs/gallery/gltf-export-roundtrip/index.html index 08bbdd0..5abba8e 100644 --- a/docs/gallery/gltf-export-roundtrip/index.html +++ b/docs/gallery/gltf-export-roundtrip/index.html @@ -184,6 +184,7 @@

gltf-export-roundtrip

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.

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.

@@ -680,15 +681,17 @@

Source

pm = bpy.data.materials.new("PlaqueMetal") pm.use_nodes = True pb = pm.node_tree.nodes["Principled BSDF"] - pb.inputs["Base Color"].default_value = (0.16, 0.17, 0.19, 1.0) - pb.inputs["Metallic"].default_value = 0.9 - pb.inputs["Roughness"].default_value = 0.3 + # diffuse light-grey, not metal: the AUTHORED/ROUND-TRIP labels are the + # render's argument and must survive thumbnail scale on the dark floor + pb.inputs["Base Color"].default_value = (0.42, 0.44, 0.48, 1.0) + pb.inputs["Metallic"].default_value = 0.2 + pb.inputs["Roughness"].default_value = 0.6 def plaque(text, x): cu = bpy.data.curves.new("Plaque", 'FONT') cu.body = text cu.align_x = 'CENTER' - cu.size = 0.24 + cu.size = 0.30 cu.extrude = 0.008 ob = bpy.data.objects.new("Plaque", cu) ob.location = (x, -1.55, 0.01) diff --git a/docs/gallery/lod-decimate-chain/index.html b/docs/gallery/lod-decimate-chain/index.html index 5f4786b..79b9a66 100644 --- a/docs/gallery/lod-decimate-chain/index.html +++ b/docs/gallery/lod-decimate-chain/index.html @@ -184,6 +184,7 @@

lod-decimate-chain

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).

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).

diff --git a/docs/gallery/vertex-weight-limit/index.html b/docs/gallery/vertex-weight-limit/index.html index 0ffa8d2..1faed7d 100644 --- a/docs/gallery/vertex-weight-limit/index.html +++ b/docs/gallery/vertex-weight-limit/index.html @@ -184,10 +184,11 @@

vertex-weight-limit

A runnable example that rigs a mech arm — pedestal mount and pauldron, orange armor shells, ribbed flex boots, wrist, three claw prongs — with deliberately rich five-bone weight bumps in the boots, then enforces the game-engine maximum of four bone influences per vertex through the data API, following mesh-editing-and-bmesh 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.986 — a mesh that shrinks toward the origin under load (the check's measured failure, 1.438e-02 off unit sum). 2. The armature modifier is still exactly linear blend skinning after the limit: every depsgraph-evaluated vertex equals Σ wᵢ · (pose.matrix @ bone.matrix_local.inverted()) @ rest, with the weights read back from the mesh's own deform layer (v.groups) — the weights on the mesh are the contract, not the weights you meant to write. Measured lbs_err = 3.0e-07. 3. Pruning must not damage the pose. Evaluated positions before and after the limit are held within 0.05 (measured 3.0e-03), the pedestal mount stays exactly pinned (Root is unposed), and the pre-limit authoring really carries five influences in the boots — otherwise the witness would be vacuous.

The vertex-group API (v.groups, VertexGroup.add/remove) is stable between Blender 4.5 LTS and 5.1 — the example runs identically on both, which is itself the version witness (measured values match to the digit).

-

The render shows the pruned arm mid-pose: the elbow bellows and wrist boot flex through the bend, the teal accent ring rides the forearm — proof that the limited weights still deform as authored.

+

The render shows the pruned arm mid-pose: the flex boots carry the teal accent — the five-influence zones the limit prunes glow at the elbow and wrist, sealed by the bright ring on the elbow boot — proof that the limited weights still deform as authored.

Run

# Cheap correctness check (no render) — the CI check:
 blender --background --python vertex_weight_limit.py --
@@ -315,8 +316,9 @@ 

Source

(1.82, 0.26), (2.00, 0.24), (2.10, 0.27), (2.16, 0.27), (2.22, 0.24), (2.45, 0.22)], ORANGE), "Elbow") - # teal accent ring inset on the forearm - tag(lathe_part(bm, [(2.10, 0.275), (2.16, 0.275)], ACCENT), "Elbow") + # teal accent ring sealing the elbow boot — the primary + # five-influence zone the limit prunes + tag(lathe_part(bm, [(1.33, 0.245), (1.39, 0.245)], ACCENT), "Elbow") # wrist flex boot + ball (second >4-influence region) tag(lathe_part(bm, [(2.45, 0.21), (2.52, 0.17), (2.60, 0.20), (2.68, 0.16), (2.76, 0.19), (2.84, 0.16), (2.95, 0.15)], RUBBER), "FLEX") @@ -518,7 +520,8 @@

Source

return [ pbr("Gunmetal", (0.11, 0.12, 0.14), 0.9, 0.32), pbr("HazardOrange", (0.82, 0.30, 0.08), 0.10, 0.45), - pbr("FlexRubber", (0.05, 0.05, 0.06), 0.0, 0.85), + pbr("FlexRubber", (0.04, 0.13, 0.17), 0.0, 0.80, + emission=(0.06, 0.30, 0.34), strength=0.38), pbr("TealAccent", (0.03, 0.22, 0.26), 0.0, 0.35, emission=(0.10, 0.65, 0.72), strength=2.2), ] diff --git a/docs/gallery/vse-cut-list/index.html b/docs/gallery/vse-cut-list/index.html index 6258cef..ab67cf9 100644 --- a/docs/gallery/vse-cut-list/index.html +++ b/docs/gallery/vse-cut-list/index.html @@ -191,7 +191,7 @@

vse-cut-list

Hazards discovered while authoring (all witnessed by the checks above):

  • A GAMMA_CROSS asked to outlast its inputs' overlap is silently clamped to the overlap — request length 9 over a (25, 33) overlap, get (25, 33).
  • A scene strip pointing at its own scene is a feedback loop and renders transparent (alpha 0) — the "stage" is silently absent. Source a separate scene.
  • Effect strips consume their inputs: input strips never composite on their own channel, and the effect's transform applies on top of the inputs' transforms. Consumption requires the effect on a channel above its inputs; below, they keep painting independently.
  • An empty bpy_prop_collection is falsy — se.strips or se.sequences silently falls through to the legacy accessor on an empty timeline. Always branch on hasattr.

Version divergence: the whole example is the divergence — gated on the bpy.app.version tuple (>= (5, 0, 0)), never on version_string ("4.5.11 LTS" is not bare semver). Each side asserts its own canonical contract plus the other side's removal/bridge state. Measured values are identical on 4.5.11 and 5.1.1, including the pixel witness.

-

Render: the program wall *is* the sequencer output at frame 29 (mid cross) — crimson A, teal B, amber long-runner C, and the 50/50 cross blend, over the Stage scene strip showing the dark studio. An off-by-one in end-exclusive span math drops its cell to the dark stage; the caption strip carries the closed form. Rendered locally with EEVEE (GPU host); the checks and --check-pixels need no GPU (Cycles CPU).

+

Render: the program wall *is* the sequencer output at frame 29 (mid cross) — crimson A, teal B, amber long-runner C, and the 50/50 cross blend, over the Stage scene strip showing the dark studio. An off-by-one in end-exclusive span math drops its cell to the dark stage; the caption strip carries the closed form. The hero presents that authentic frame on a reference monitor in a dark-studio editing bay — the pixels on the screen are the genuine sequencer output (evidence); only the bay around them is staged (presentation). Rendered locally with EEVEE (GPU host); the checks and --check-pixels need no GPU (Cycles CPU).

Run

# Cheap correctness check (no render) — the CI check:
 blender --background --python vse_cut_list.py --
@@ -245,16 +245,17 @@ 

Source

The check builds a deterministic cut list, asserts every span against its closed form on each version's canonical accessors, then proves the spans, wiring, colors, and transforms survive a save/reload round-trip. Pass ---output to render the program wall staged inside the studio — the mosaic -IS the sequencer output sampled mid-cross — and --check-pixels to assert -the compositing contract on a tiny render (cell colors and input -consumption), the way CI does: +--output to render the authentic program wall (the sequencer output +sampled mid-cross) presented on a reference monitor in the dark-studio +editing bay, and --check-pixels to assert the compositing contract on a +tiny render (cell colors and input consumption), the way CI does: blender --background --python vse_cut_list.py -- blender --background --python vse_cut_list.py -- --check-pixels blender --background --python vse_cut_list.py -- --output vse.png """ -import bpy, sys, os, math, argparse, tempfile, warnings +import bpy, sys, os, math, argparse, tempfile, warnings, shutil +import mathutils IS_5X = bpy.app.version >= (5, 0, 0) @@ -629,11 +630,144 @@

Source

return os.path.exists(path) and os.path.getsize(path) > 0 +def build_bay(sc, frame_path): + """The editing-bay presentation: a reference monitor on a desk in the + dark studio, its screen showing the AUTHENTIC sequencer frame. The pixels + on the screen are the evidence (rendered by the VSE above); the bay is + only the designed presentation around them.""" + import bmesh + + def box(name, size, loc, mat): + me = bpy.data.meshes.new(name) + bm = bmesh.new() + try: + bmesh.ops.create_cube(bm, size=1.0, + matrix=mathutils.Matrix.Diagonal((*size, 1.0))) + bm.to_mesh(me) + finally: + bm.free() + me.materials.append(mat) + ob = bpy.data.objects.new(name, me) + ob.location = loc + sc.collection.objects.link(ob) + return ob + + def pbr(name, base, metallic, roughness): + mat = bpy.data.materials.new(name) + mat.use_nodes = True + b = mat.node_tree.nodes["Principled BSDF"] + b.inputs["Base Color"].default_value = (*base, 1.0) + b.inputs["Metallic"].default_value = metallic + b.inputs["Roughness"].default_value = roughness + return mat + + gunmetal = pbr("BayMetal", (0.08, 0.09, 0.10), 0.7, 0.40) + deskmat = pbr("DeskTop", (0.05, 0.05, 0.06), 0.0, 0.80) + + # desk slab, stand, and the monitor bezel (screen face toward -Y) + box("Desk", (2.80, 1.10, 0.72), (0.0, 0.0, 0.36), deskmat) + box("StandBase", (0.70, 0.50, 0.06), (0.0, 0.05, 0.75), gunmetal) + box("Stand", (0.18, 0.12, 0.50), (0.0, 0.05, 0.99), gunmetal) + box("Bezel", (2.06, 0.09, 1.30), (0.0, 0.0, 1.89), gunmetal) + + # the screen: one unlit quad sampling the authentic frame end to end — + # emission only, so the VSE pixels read exactly as the sequencer wrote them + img = bpy.data.images.load(frame_path) + smat = bpy.data.materials.new("ProgramScreen") + smat.use_nodes = True + nt = smat.node_tree + nt.nodes.clear() + out = nt.nodes.new("ShaderNodeOutputMaterial") + em = nt.nodes.new("ShaderNodeEmission") + tex = nt.nodes.new("ShaderNodeTexImage") + tex.image = img + tc = nt.nodes.new("ShaderNodeTexCoord") + nt.links.new(tc.outputs["UV"], tex.inputs["Vector"]) + nt.links.new(tex.outputs["Color"], em.inputs["Color"]) + nt.links.new(em.outputs["Emission"], out.inputs["Surface"]) + sme = bpy.data.meshes.new("Screen") + bm = bmesh.new() + try: + q = [bm.verts.new(p) for p in ((-0.96, -0.048, 1.35), (0.96, -0.048, 1.35), + (0.96, -0.048, 2.43), (-0.96, -0.048, 2.43))] + # explicit UVs, not Generated: the quad is flat in Y, so Generated's + # image-V is a constant and the screen samples one strip of the frame + uv_layer = bm.loops.layers.uv.new("UVMap") + f = bm.faces.new(q) + for li, l in enumerate(f.loops): + l[uv_layer].uv = ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0))[li] + bm.to_mesh(sme) + finally: + bm.free() + sme.materials.append(smat) + sob = bpy.data.objects.new("Screen", sme) + sc.collection.objects.link(sob) + + # a small desk-lip caption, the bay's only typography + cmat = pbr("CaptionGrey", (0.42, 0.44, 0.48), 0.2, 0.6) + cu = bpy.data.curves.new("BayCaption", "FONT") + cu.body = "PROGRAM" + cu.align_x = "CENTER" + cu.size = 0.10 + cu.extrude = 0.004 + cob = bpy.data.objects.new("BayCaption", cu) + cob.location = (0.0, -0.40, 0.73) + cob.data.materials.append(cmat) + sc.collection.objects.link(cob) + + +def render_bay(sc, path, engine, w, h): + """Render the editing bay. The camera moves to a three-quarter view of + the monitor; everything else reuses the house stage.""" + cam_data = bpy.data.cameras.new("BayCam") + cam_data.lens = 52.0 + cam = bpy.data.objects.new("BayCam", cam_data) + cam.location = (2.0, -5.2, 1.72) + sc.collection.objects.link(cam) + target = bpy.data.objects.new("BayAim", None) + target.location = (0.0, 0.0, 1.28) + sc.collection.objects.link(target) + con = cam.constraints.new("TRACK_TO") + con.target = target + sc.camera = cam + + sc.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id() + if engine == "cycles": + sc.cycles.samples = 64 + sc.cycles.use_denoising = False + else: + try: + sc.eevee.taa_render_samples = 64 + except AttributeError: + pass + sc.render.resolution_x = w + sc.render.resolution_y = h + sc.render.resolution_percentage = 100 + sc.render.image_settings.file_format = "PNG" + sc.render.filepath = path + # Standard keeps both the stage saturated and the on-screen frame true + sc.view_settings.view_transform = "Standard" + bpy.ops.render.render(write_still=True, scene=sc.name) + return os.path.exists(path) and os.path.getsize(path) > 0 + + def render_still(path, engine): + """Two passes: the authentic sequencer frame (evidence), then the + editing-bay presentation with that exact frame on the monitor screen.""" sc = bpy.context.scene build_cut_list(sc) build_stage(bpy.data.scenes["Stage"]) - return render_frame(sc, path, engine, RENDER_W, RENDER_H) + tmp = tempfile.mkdtemp(prefix="vse_frame_") + try: + frame_path = os.path.join(tmp, "program.png") + if not render_frame(sc, frame_path, engine, RENDER_W, RENDER_H): + return False + bay = bpy.data.scenes.new("Bay") + build_stage(bay) # the house dark studio, lights included + build_bay(bay, frame_path) + return render_bay(bay, path, engine, RENDER_W, RENDER_H) + finally: + shutil.rmtree(tmp, ignore_errors=True) # pixels live on in the blend def near(got, want, tol): diff --git a/examples/gltf-export-roundtrip/README.md b/examples/gltf-export-roundtrip/README.md index 3f2c31c..4789bf0 100644 --- a/examples/gltf-export-roundtrip/README.md +++ b/examples/gltf-export-roundtrip/README.md @@ -7,6 +7,9 @@ the whole round-trip against the depsgraph-evaluated mesh, following [`depsgraph-and-evaluated-data`](../../skills/depsgraph-and-evaluated-data/SKILL.md) and [`headless-batch-scripting`](../../skills/headless-batch-scripting/SKILL.md). +**Pipeline arc:** modeling/LOD in [`lod-decimate-chain`](../lod-decimate-chain/), +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. diff --git a/examples/gltf-export-roundtrip/gltf_export_roundtrip.py b/examples/gltf-export-roundtrip/gltf_export_roundtrip.py index 1d6cf42..a888b38 100644 --- a/examples/gltf-export-roundtrip/gltf_export_roundtrip.py +++ b/examples/gltf-export-roundtrip/gltf_export_roundtrip.py @@ -474,15 +474,17 @@ def render_still(authored, roundtrip, path, engine): pm = bpy.data.materials.new("PlaqueMetal") pm.use_nodes = True pb = pm.node_tree.nodes["Principled BSDF"] - pb.inputs["Base Color"].default_value = (0.16, 0.17, 0.19, 1.0) - pb.inputs["Metallic"].default_value = 0.9 - pb.inputs["Roughness"].default_value = 0.3 + # diffuse light-grey, not metal: the AUTHORED/ROUND-TRIP labels are the + # render's argument and must survive thumbnail scale on the dark floor + pb.inputs["Base Color"].default_value = (0.42, 0.44, 0.48, 1.0) + pb.inputs["Metallic"].default_value = 0.2 + pb.inputs["Roughness"].default_value = 0.6 def plaque(text, x): cu = bpy.data.curves.new("Plaque", 'FONT') cu.body = text cu.align_x = 'CENTER' - cu.size = 0.24 + cu.size = 0.30 cu.extrude = 0.008 ob = bpy.data.objects.new("Plaque", cu) ob.location = (x, -1.55, 0.01) diff --git a/examples/gltf-export-roundtrip/preview.webp b/examples/gltf-export-roundtrip/preview.webp index d1f1d23..b0e84d0 100644 Binary files a/examples/gltf-export-roundtrip/preview.webp and b/examples/gltf-export-roundtrip/preview.webp differ diff --git a/examples/lod-decimate-chain/README.md b/examples/lod-decimate-chain/README.md index e591c41..dfd8dc3 100644 --- a/examples/lod-decimate-chain/README.md +++ b/examples/lod-decimate-chain/README.md @@ -6,6 +6,10 @@ evaluates it at LOD0/1/2 through the Decimate modifier via the depsgraph, following [`depsgraph-and-evaluated-data`](../../skills/depsgraph-and-evaluated-data/SKILL.md) and [`mesh-editing-and-bmesh`](../../skills/mesh-editing-and-bmesh/SKILL.md). +**Pipeline arc:** modeling/LOD here, weighting in +[`vertex-weight-limit`](../vertex-weight-limit/), export in +[`gltf-export-roundtrip`](../gltf-export-roundtrip/). + **What it witnesses:** the modifier-based LOD contract that game asset pipelines rely on. diff --git a/examples/vertex-weight-limit/README.md b/examples/vertex-weight-limit/README.md index 8d080c1..42c0ab3 100644 --- a/examples/vertex-weight-limit/README.md +++ b/examples/vertex-weight-limit/README.md @@ -8,6 +8,9 @@ rich five-bone weight bumps in the boots, then enforces the game-engine building on the linear-blend-skinning precedent of [`armature-bend`](../armature-bend/). +**Pipeline arc:** modeling/LOD in [`lod-decimate-chain`](../lod-decimate-chain/), +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. @@ -32,9 +35,10 @@ The vertex-group API (`v.groups`, `VertexGroup.add`/`remove`) is stable between Blender 4.5 LTS and 5.1 — the example runs identically on both, which is itself the version witness (measured values match to the digit). -The render shows the pruned arm mid-pose: the elbow bellows and wrist boot flex -through the bend, the teal accent ring rides the forearm — proof that the -limited weights still deform as authored. +The render shows the pruned arm mid-pose: the flex boots carry the teal +accent — the five-influence zones the limit prunes glow at the elbow and +wrist, sealed by the bright ring on the elbow boot — proof that the limited +weights still deform as authored. ## Run diff --git a/examples/vertex-weight-limit/preview.webp b/examples/vertex-weight-limit/preview.webp index b07f84b..924144b 100644 Binary files a/examples/vertex-weight-limit/preview.webp and b/examples/vertex-weight-limit/preview.webp differ diff --git a/examples/vertex-weight-limit/vertex_weight_limit.py b/examples/vertex-weight-limit/vertex_weight_limit.py index ff750c6..ccd8d8a 100644 --- a/examples/vertex-weight-limit/vertex_weight_limit.py +++ b/examples/vertex-weight-limit/vertex_weight_limit.py @@ -110,8 +110,9 @@ def tag(rings_verts, bone): (1.82, 0.26), (2.00, 0.24), (2.10, 0.27), (2.16, 0.27), (2.22, 0.24), (2.45, 0.22)], ORANGE), "Elbow") - # teal accent ring inset on the forearm - tag(lathe_part(bm, [(2.10, 0.275), (2.16, 0.275)], ACCENT), "Elbow") + # teal accent ring sealing the elbow boot — the primary + # five-influence zone the limit prunes + tag(lathe_part(bm, [(1.33, 0.245), (1.39, 0.245)], ACCENT), "Elbow") # wrist flex boot + ball (second >4-influence region) tag(lathe_part(bm, [(2.45, 0.21), (2.52, 0.17), (2.60, 0.20), (2.68, 0.16), (2.76, 0.19), (2.84, 0.16), (2.95, 0.15)], RUBBER), "FLEX") @@ -313,7 +314,8 @@ def pbr(name, base, metallic, roughness, emission=None, strength=0.0): return [ pbr("Gunmetal", (0.11, 0.12, 0.14), 0.9, 0.32), pbr("HazardOrange", (0.82, 0.30, 0.08), 0.10, 0.45), - pbr("FlexRubber", (0.05, 0.05, 0.06), 0.0, 0.85), + pbr("FlexRubber", (0.04, 0.13, 0.17), 0.0, 0.80, + emission=(0.06, 0.30, 0.34), strength=0.38), pbr("TealAccent", (0.03, 0.22, 0.26), 0.0, 0.35, emission=(0.10, 0.65, 0.72), strength=2.2), ] diff --git a/examples/vse-cut-list/README.md b/examples/vse-cut-list/README.md index 557f7af..cc0ecbb 100644 --- a/examples/vse-cut-list/README.md +++ b/examples/vse-cut-list/README.md @@ -87,7 +87,10 @@ identical on 4.5.11 and 5.1.1, including the pixel witness. cross) — crimson A, teal B, amber long-runner C, and the 50/50 cross blend, over the Stage scene strip showing the dark studio. An off-by-one in end-exclusive span math drops its cell to the dark stage; the caption strip -carries the closed form. Rendered locally with EEVEE (GPU host); the checks +carries the closed form. The hero presents that authentic frame on a +reference monitor in a dark-studio editing bay — the pixels on the screen +are the genuine sequencer output (evidence); only the bay around them is +staged (presentation). Rendered locally with EEVEE (GPU host); the checks and `--check-pixels` need no GPU (Cycles CPU). ## Run diff --git a/examples/vse-cut-list/preview.webp b/examples/vse-cut-list/preview.webp index 8526202..e03381b 100644 Binary files a/examples/vse-cut-list/preview.webp and b/examples/vse-cut-list/preview.webp differ diff --git a/examples/vse-cut-list/vse_cut_list.py b/examples/vse-cut-list/vse_cut_list.py index 076ccd6..6c72c07 100644 --- a/examples/vse-cut-list/vse_cut_list.py +++ b/examples/vse-cut-list/vse_cut_list.py @@ -34,16 +34,17 @@ The check builds a deterministic cut list, asserts every span against its closed form on each version's canonical accessors, then proves the spans, wiring, colors, and transforms survive a save/reload round-trip. Pass ---output to render the program wall staged inside the studio — the mosaic -IS the sequencer output sampled mid-cross — and --check-pixels to assert -the compositing contract on a tiny render (cell colors and input -consumption), the way CI does: +--output to render the authentic program wall (the sequencer output +sampled mid-cross) presented on a reference monitor in the dark-studio +editing bay, and --check-pixels to assert the compositing contract on a +tiny render (cell colors and input consumption), the way CI does: blender --background --python vse_cut_list.py -- blender --background --python vse_cut_list.py -- --check-pixels blender --background --python vse_cut_list.py -- --output vse.png """ -import bpy, sys, os, math, argparse, tempfile, warnings +import bpy, sys, os, math, argparse, tempfile, warnings, shutil +import mathutils IS_5X = bpy.app.version >= (5, 0, 0) @@ -418,11 +419,144 @@ def render_frame(sc, path, engine, w, h): return os.path.exists(path) and os.path.getsize(path) > 0 +def build_bay(sc, frame_path): + """The editing-bay presentation: a reference monitor on a desk in the + dark studio, its screen showing the AUTHENTIC sequencer frame. The pixels + on the screen are the evidence (rendered by the VSE above); the bay is + only the designed presentation around them.""" + import bmesh + + def box(name, size, loc, mat): + me = bpy.data.meshes.new(name) + bm = bmesh.new() + try: + bmesh.ops.create_cube(bm, size=1.0, + matrix=mathutils.Matrix.Diagonal((*size, 1.0))) + bm.to_mesh(me) + finally: + bm.free() + me.materials.append(mat) + ob = bpy.data.objects.new(name, me) + ob.location = loc + sc.collection.objects.link(ob) + return ob + + def pbr(name, base, metallic, roughness): + mat = bpy.data.materials.new(name) + mat.use_nodes = True + b = mat.node_tree.nodes["Principled BSDF"] + b.inputs["Base Color"].default_value = (*base, 1.0) + b.inputs["Metallic"].default_value = metallic + b.inputs["Roughness"].default_value = roughness + return mat + + gunmetal = pbr("BayMetal", (0.08, 0.09, 0.10), 0.7, 0.40) + deskmat = pbr("DeskTop", (0.05, 0.05, 0.06), 0.0, 0.80) + + # desk slab, stand, and the monitor bezel (screen face toward -Y) + box("Desk", (2.80, 1.10, 0.72), (0.0, 0.0, 0.36), deskmat) + box("StandBase", (0.70, 0.50, 0.06), (0.0, 0.05, 0.75), gunmetal) + box("Stand", (0.18, 0.12, 0.50), (0.0, 0.05, 0.99), gunmetal) + box("Bezel", (2.06, 0.09, 1.30), (0.0, 0.0, 1.89), gunmetal) + + # the screen: one unlit quad sampling the authentic frame end to end — + # emission only, so the VSE pixels read exactly as the sequencer wrote them + img = bpy.data.images.load(frame_path) + smat = bpy.data.materials.new("ProgramScreen") + smat.use_nodes = True + nt = smat.node_tree + nt.nodes.clear() + out = nt.nodes.new("ShaderNodeOutputMaterial") + em = nt.nodes.new("ShaderNodeEmission") + tex = nt.nodes.new("ShaderNodeTexImage") + tex.image = img + tc = nt.nodes.new("ShaderNodeTexCoord") + nt.links.new(tc.outputs["UV"], tex.inputs["Vector"]) + nt.links.new(tex.outputs["Color"], em.inputs["Color"]) + nt.links.new(em.outputs["Emission"], out.inputs["Surface"]) + sme = bpy.data.meshes.new("Screen") + bm = bmesh.new() + try: + q = [bm.verts.new(p) for p in ((-0.96, -0.048, 1.35), (0.96, -0.048, 1.35), + (0.96, -0.048, 2.43), (-0.96, -0.048, 2.43))] + # explicit UVs, not Generated: the quad is flat in Y, so Generated's + # image-V is a constant and the screen samples one strip of the frame + uv_layer = bm.loops.layers.uv.new("UVMap") + f = bm.faces.new(q) + for li, l in enumerate(f.loops): + l[uv_layer].uv = ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0))[li] + bm.to_mesh(sme) + finally: + bm.free() + sme.materials.append(smat) + sob = bpy.data.objects.new("Screen", sme) + sc.collection.objects.link(sob) + + # a small desk-lip caption, the bay's only typography + cmat = pbr("CaptionGrey", (0.42, 0.44, 0.48), 0.2, 0.6) + cu = bpy.data.curves.new("BayCaption", "FONT") + cu.body = "PROGRAM" + cu.align_x = "CENTER" + cu.size = 0.10 + cu.extrude = 0.004 + cob = bpy.data.objects.new("BayCaption", cu) + cob.location = (0.0, -0.40, 0.73) + cob.data.materials.append(cmat) + sc.collection.objects.link(cob) + + +def render_bay(sc, path, engine, w, h): + """Render the editing bay. The camera moves to a three-quarter view of + the monitor; everything else reuses the house stage.""" + cam_data = bpy.data.cameras.new("BayCam") + cam_data.lens = 52.0 + cam = bpy.data.objects.new("BayCam", cam_data) + cam.location = (2.0, -5.2, 1.72) + sc.collection.objects.link(cam) + target = bpy.data.objects.new("BayAim", None) + target.location = (0.0, 0.0, 1.28) + sc.collection.objects.link(target) + con = cam.constraints.new("TRACK_TO") + con.target = target + sc.camera = cam + + sc.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id() + if engine == "cycles": + sc.cycles.samples = 64 + sc.cycles.use_denoising = False + else: + try: + sc.eevee.taa_render_samples = 64 + except AttributeError: + pass + sc.render.resolution_x = w + sc.render.resolution_y = h + sc.render.resolution_percentage = 100 + sc.render.image_settings.file_format = "PNG" + sc.render.filepath = path + # Standard keeps both the stage saturated and the on-screen frame true + sc.view_settings.view_transform = "Standard" + bpy.ops.render.render(write_still=True, scene=sc.name) + return os.path.exists(path) and os.path.getsize(path) > 0 + + def render_still(path, engine): + """Two passes: the authentic sequencer frame (evidence), then the + editing-bay presentation with that exact frame on the monitor screen.""" sc = bpy.context.scene build_cut_list(sc) build_stage(bpy.data.scenes["Stage"]) - return render_frame(sc, path, engine, RENDER_W, RENDER_H) + tmp = tempfile.mkdtemp(prefix="vse_frame_") + try: + frame_path = os.path.join(tmp, "program.png") + if not render_frame(sc, frame_path, engine, RENDER_W, RENDER_H): + return False + bay = bpy.data.scenes.new("Bay") + build_stage(bay) # the house dark studio, lights included + build_bay(bay, frame_path) + return render_bay(bay, path, engine, RENDER_W, RENDER_H) + finally: + shutil.rmtree(tmp, ignore_errors=True) # pixels live on in the blend def near(got, want, tol):