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 @@
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.
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).
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.
# 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):