From 0e4e884b8b57a0cf00e01c5630bd2f8c11ac37b2 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Sun, 13 Sep 2026 09:57:30 -0400 Subject: [PATCH] feat: add CLI falsifiers and exit tables to thirteen rigging examples Default smoke stays green. Each flag feeds degenerate input into an existing assertion, including unevaluated OBJ export and join without temp_override. Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-authored-by: Cursor --- examples/armature-bend/README.md | 28 +++++++++++-- examples/armature-bend/armature_bend.py | 9 ++++- examples/attribute-domain-shear/README.md | 27 +++++++++++-- .../attribute_domain_shear.py | 14 ++++--- examples/color-attribute-wheel/README.md | 28 +++++++++++-- .../color_attribute_wheel.py | 29 ++++++++------ examples/damped-track-aim/README.md | 29 ++++++++++++-- examples/damped-track-aim/damped_track_aim.py | 12 +++++- examples/depsgraph-export/README.md | 28 +++++++++++-- examples/depsgraph-export/depsgraph_export.py | 18 +++++++-- examples/driver-wave/README.md | 24 ++++++++++-- examples/driver-wave/driver_wave.py | 12 ++++-- examples/gltf-skin-roundtrip/README.md | 39 +++++++++++++++++-- .../gltf_skin_roundtrip.py | 15 +++++-- examples/parent-inverse-orrery/README.md | 27 +++++++++++-- .../parent_inverse_orrery.py | 26 ++++++++----- examples/prop-origin-transform/README.md | 28 +++++++++++-- .../prop_origin_transform.py | 15 +++++-- examples/shape-key-blend/README.md | 28 ++++++++++++- examples/shape-key-blend/shape_key_blend.py | 12 ++++-- examples/soccer-ball-goldberg/README.md | 32 +++++++++++++-- .../soccer_ball_goldberg.py | 10 +++-- examples/temp-override-join/README.md | 27 +++++++++++-- .../temp-override-join/temp_override_join.py | 17 +++++++- examples/vertex-weight-limit/README.md | 30 ++++++++++++-- .../vertex_weight_limit.py | 32 +++++++++------ 26 files changed, 494 insertions(+), 102 deletions(-) diff --git a/examples/armature-bend/README.md b/examples/armature-bend/README.md index 5c1ab38..0bb6ce9 100644 --- a/examples/armature-bend/README.md +++ b/examples/armature-bend/README.md @@ -35,11 +35,33 @@ joints are the same weights the LBS check asserts. # Cheap correctness check (no render) — the CI check: blender --background --python armature_bend.py -- +# Falsifier: rest pose. Must exit non-zero (tip deflection). +blender --background --python armature_bend.py -- --zero-curl + # Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts): blender --background --python armature_bend.py -- --output bend.png blender --background --python armature_bend.py -- --output bend.png --engine cycles ``` -It exits non-zero on failure (edit-bone lifetime violation, LBS deviation, moved root -ring, or an undeformed tip). The `blender-smoke` workflow runs the check on Blender -5.2 LTS and 4.5 LTS. +## Exit codes + +Per-script sequential checks. `9` is a valid check code; there is no rule +against it. + +| Code | Meaning | +| --- | --- | +| 0 | Success | +| 1 | Uncaught exception (FATAL wrapper) | +| 2 | argparse / usage | +| 3 | `edit_bones` populated in object mode | +| 4 | Edit-mode bone chain off closed form | +| 5 | Evaluated vertex count changed | +| 6 | Evaluated mesh off closed-form LBS | +| 7 | Root ring moved | +| 8 | Tip did not deflect (`--zero-curl` lands here) | +| 9 | `--output` produced no file | + +The `blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS +(5.1 on the weekly cron, the `needs-5.1` PR label, or manual dispatch). +Smoke does not pass `--output` or `--zero-curl`. + diff --git a/examples/armature-bend/armature_bend.py b/examples/armature-bend/armature_bend.py index 9f3be71..12a9c69 100644 --- a/examples/armature-bend/armature_bend.py +++ b/examples/armature-bend/armature_bend.py @@ -19,10 +19,14 @@ The same API works unchanged on Blender 4.5 LTS and 5.1 — no version gate is needed, which this example demonstrates by running identically on both. +``--zero-curl`` leaves every pose bone at rest and still asserts the tip +deflects. That is the falsifier (``--same-axis`` in export-preset-axis). + By default it runs only the correctness check (no render) — the CI smoke check. Pass --output to also render a still: blender --background --python armature_bend.py -- # check only + blender --background --python armature_bend.py -- --zero-curl # must fail blender --background --python armature_bend.py -- --output b.png # + render """ import bpy, bmesh, sys, os, math, argparse @@ -294,10 +298,13 @@ def main(): p.add_argument("--output", default=None, help="optional: render a still PNG here") p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"), help="render engine for --output (cycles for GPU-less hosts)") + p.add_argument("--zero-curl", action="store_true", + help="leave pose bones at rest (must fail)") args = p.parse_args(argv) bpy.ops.wm.read_factory_settings(use_empty=True) - tube, arm = build_rig(CURL_DEG) + curl = 0.0 if args.zero_curl else CURL_DEG + tube, arm = build_rig(curl) code = check(tube, arm) if code: return code diff --git a/examples/attribute-domain-shear/README.md b/examples/attribute-domain-shear/README.md index 89b3fff..53637f1 100644 --- a/examples/attribute-domain-shear/README.md +++ b/examples/attribute-domain-shear/README.md @@ -50,11 +50,30 @@ per `docs/VISUAL-STYLE.md`. ```bash blender --background --python attribute_domain_shear.py -- +blender --background --python attribute_domain_shear.py -- --no-overwrite blender --background --python attribute_domain_shear.py -- --output shear.png blender --background --python attribute_domain_shear.py -- --output shear.png --engine cycles ``` -Exits non-zero on failure. The `blender-smoke` workflow runs the check on -Blender 5.2 LTS and 4.5 LTS. The `--output` render path additionally measures -framing against the Layer 1 band via `examples/gallery_framing.py` (exit 10 -on violation) before writing the still. +## Exit codes + +Per-script sequential checks. `9` is a valid check code; there is no rule +against it. `10` is the shared framing helper. + +| Code | Meaning | +| --- | --- | +| 0 | Success | +| 1 | Uncaught exception (FATAL wrapper) | +| 2 | argparse / usage | +| 3 | CORNER or POINT attribute size wrong | +| 4 | CORNER hub corners off wedge color | +| 5 | POINT hub is not last-write (`--no-overwrite` lands here) | +| 6 | Outer ring verts off last-write order | +| 7 | Measured shear off palette closed form, or ~0 | +| 9 | `--output` produced no file | +| 10 | Gallery framing violation | + +The `blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS +(5.1 on the weekly cron, the `needs-5.1` PR label, or manual dispatch). +Smoke does not pass `--output` or `--no-overwrite`. + diff --git a/examples/attribute-domain-shear/attribute_domain_shear.py b/examples/attribute-domain-shear/attribute_domain_shear.py index f49db2c..3cbcb10 100644 --- a/examples/attribute-domain-shear/attribute_domain_shear.py +++ b/examples/attribute-domain-shear/attribute_domain_shear.py @@ -26,6 +26,7 @@ check. Pass --output to also render a still: blender --background --python attribute_domain_shear.py -- # check only + blender --background --python attribute_domain_shear.py -- --no-overwrite # must fail blender --background --python attribute_domain_shear.py -- --output a.png # + render """ import bpy, bmesh, sys, os, math, argparse, colorsys @@ -98,13 +99,14 @@ def assign_corner(me, pal): return attr -def assign_point_naive(me, pal): +def assign_point_naive(me, pal, overwrite=True): """The AI mistake: author per-wedge colors into a POINT-domain attribute. Every wedge rewrites the shared hub (and its leading ring vert), so the last wedge wins — colors shear across every shared vertex.""" attr = me.color_attributes.new(ATTR_P, type='FLOAT_COLOR', domain='POINT') hub_index = 0 # build_fan creates the hub first - for i in range(K): + last = K if overwrite else 1 + for i in range(last): # naive per-wedge pass: set the hub and both ring verts to palette[i] attr.data[hub_index].color = pal[i] attr.data[1 + i].color = pal[i] @@ -113,7 +115,7 @@ def assign_point_naive(me, pal): return attr -def check(): +def check(overwrite=True): pal = palette() expect_shear = closed_form_shear(pal) print(f"palette K={K} closed_form_shear={expect_shear:.6f}") @@ -138,7 +140,7 @@ def check(): # --- POINT: the shear, measured against the closed form --- me_p = build_fan() - attr_p = assign_point_naive(me_p, pal) + attr_p = assign_point_naive(me_p, pal, overwrite=overwrite) if len(attr_p.data) != len(me_p.vertices) or len(me_p.vertices) != K + 1: print(f"ERROR: POINT attr size {len(attr_p.data)} != verts {len(me_p.vertices)}", file=sys.stderr) @@ -383,11 +385,13 @@ def main(): p.add_argument("--output", default=None, help="optional: render a still PNG here") p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"), help="render engine for --output (cycles for GPU-less hosts)") + p.add_argument("--no-overwrite", action="store_true", + help="write only the first POINT wedge (must fail)") args = p.parse_args(argv) print(f"binary version: {bpy.app.version} ({bpy.app.version_string})") bpy.ops.wm.read_factory_settings(use_empty=True) - code = check() + code = check(overwrite=not args.no_overwrite) if code: return code diff --git a/examples/color-attribute-wheel/README.md b/examples/color-attribute-wheel/README.md index 3d8e4a8..b516145 100644 --- a/examples/color-attribute-wheel/README.md +++ b/examples/color-attribute-wheel/README.md @@ -25,11 +25,33 @@ Color, not just present in the node tree. # Cheap correctness check (no render) — the CI check: blender --background --python color_attribute_wheel.py -- +# Falsifier: POINT-domain attribute. Must exit non-zero. +blender --background --python color_attribute_wheel.py -- --point-domain + # Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts): blender --background --python color_attribute_wheel.py -- --output wheel.png blender --background --python color_attribute_wheel.py -- --output wheel.png --engine cycles ``` -It exits non-zero on failure (missing/mis-sized/mis-domained attribute, wrong -active attribute, a probe color mismatch, or an unlinked Attribute node). The -`blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS. +## Exit codes + +Per-script sequential checks. `9` is a valid check code; there is no rule +against it. + +| Code | Meaning | +| --- | --- | +| 0 | Success | +| 1 | Uncaught exception (FATAL wrapper) | +| 2 | argparse / usage | +| 3 | Topology ≠ closed form | +| 4 | Color attribute missing | +| 5 | Domain/type ≠ CORNER/FLOAT_COLOR (`--point-domain` lands here) | +| 6 | Attribute sized to verts, not loops | +| 7 | `active_color` not set | +| 8 | Probe loop color off HSV closed form | +| 9 | `--output` produced no file | + +The `blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS +(5.1 on the weekly cron, the `needs-5.1` PR label, or manual dispatch). +Smoke does not pass `--output` or `--point-domain`. + diff --git a/examples/color-attribute-wheel/color_attribute_wheel.py b/examples/color-attribute-wheel/color_attribute_wheel.py index accd594..86e01aa 100644 --- a/examples/color-attribute-wheel/color_attribute_wheel.py +++ b/examples/color-attribute-wheel/color_attribute_wheel.py @@ -18,6 +18,7 @@ check. Pass --output to also render a still: blender --background --python color_attribute_wheel.py -- # check only + blender --background --python color_attribute_wheel.py -- --point-domain # must fail blender --background --python color_attribute_wheel.py -- --output w.png # + render """ import bpy, bmesh, sys, os, math, colorsys, argparse @@ -56,7 +57,7 @@ def wheel_geometry(): return coords, hsv -def build_wheel(): +def build_wheel(point_domain=False): bpy.ops.wm.read_factory_settings(use_empty=True) coords, hsv = wheel_geometry() me = bpy.data.meshes.new("ColorWheel") @@ -80,16 +81,18 @@ def build_wheel(): # created via color_attributes (not the deprecated vertex_colors alias), # sized to loops -- then filled by expanding per-vertex HSV across corners # with bulk foreach_get / foreach_set, never a per-loop Python assignment. - attr = me.color_attributes.new(ATTR_NAME, type='FLOAT_COLOR', domain='CORNER') - n_loops = len(me.loops) - loop_vert = array('i', [0]) * n_loops - me.loops.foreach_get("vertex_index", loop_vert) - flat = array('f', [0.0]) * (n_loops * 4) - for i, vi in enumerate(loop_vert): - h, s, v = hsv[vi] - r, g, b = colorsys.hsv_to_rgb(h, s, v) - flat[i * 4], flat[i * 4 + 1], flat[i * 4 + 2], flat[i * 4 + 3] = r, g, b, 1.0 - attr.data.foreach_set("color", flat) + domain = 'POINT' if point_domain else 'CORNER' + attr = me.color_attributes.new(ATTR_NAME, type='FLOAT_COLOR', domain=domain) + if not point_domain: + n_loops = len(me.loops) + loop_vert = array('i', [0]) * n_loops + me.loops.foreach_get("vertex_index", loop_vert) + flat = array('f', [0.0]) * (n_loops * 4) + for i, vi in enumerate(loop_vert): + h, s, v = hsv[vi] + r, g, b = colorsys.hsv_to_rgb(h, s, v) + flat[i * 4], flat[i * 4 + 1], flat[i * 4 + 2], flat[i * 4 + 3] = r, g, b, 1.0 + attr.data.foreach_set("color", flat) me.color_attributes.active_color = attr # the step AI code most often forgets obj = bpy.data.objects.new("ColorWheel", me) @@ -283,9 +286,11 @@ def main(): p.add_argument("--output", default=None, help="optional: render a still PNG here") p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"), help="render engine for --output (cycles for GPU-less hosts)") + p.add_argument("--point-domain", action="store_true", + help="create a POINT-domain color attribute (must fail)") args = p.parse_args(argv) - obj, hsv = build_wheel() + obj, hsv = build_wheel(point_domain=args.point_domain) code = check(obj, hsv) if code: return code diff --git a/examples/damped-track-aim/README.md b/examples/damped-track-aim/README.md index 7f70621..5529afb 100644 --- a/examples/damped-track-aim/README.md +++ b/examples/damped-track-aim/README.md @@ -26,11 +26,34 @@ wired to `examples/gallery_framing.py`, call it with # Cheap correctness check (no render) — the CI check: blender --background --python damped_track_aim.py -- +# Falsifier: mute every constraint. Must exit non-zero. +blender --background --python damped_track_aim.py -- --mute + # Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts): blender --background --python damped_track_aim.py -- --output aim.png blender --background --python damped_track_aim.py -- --output aim.png --engine cycles ``` -It exits non-zero on failure (wrong constraint type/target/axis, or evaluated -aim outside the angular epsilon). The `blender-smoke` workflow runs the check -on Blender 5.2 LTS and 4.5 LTS. +## Exit codes + +Per-script sequential checks. `9` is a valid check code; there is no rule +against it. + +| Code | Meaning | +| --- | --- | +| 0 | Success | +| 1 | Uncaught exception (FATAL wrapper) | +| 2 | argparse / usage | +| 3 | Needle count ≠ 12 | +| 4 | Needle does not carry exactly one DAMPED_TRACK | +| 5 | Constraint target is not Core | +| 6 | `track_axis` is not TRACK_Z | +| 7 | Constraint muted or influence < 1 (`--mute` lands here) | +| 8 | TRACK_TO still present | +| 9 | Evaluated aim dot below 0.998 | +| 10 | `--output` produced no file | + +The `blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS +(5.1 on the weekly cron, the `needs-5.1` PR label, or manual dispatch). +Smoke does not pass `--output` or `--mute`. + diff --git a/examples/damped-track-aim/damped_track_aim.py b/examples/damped-track-aim/damped_track_aim.py index d3c19f1..49c917a 100644 --- a/examples/damped-track-aim/damped_track_aim.py +++ b/examples/damped-track-aim/damped_track_aim.py @@ -12,10 +12,14 @@ constraint is missing, muted, mistyped as TRACK_TO, or the axis is flipped, the dot product fails. +``--mute`` mutes every DAMPED_TRACK and still asserts unmute. That is the +falsifier (``--same-axis`` in export-preset-axis). + By default it runs only the correctness check (no render) — the CI smoke check. Pass --output to also render a still: blender --background --python damped_track_aim.py -- # check only + blender --background --python damped_track_aim.py -- --mute # must fail blender --background --python damped_track_aim.py -- --output aim.png """ import bpy, bmesh, sys, os, math, argparse @@ -161,7 +165,7 @@ def make_dielectric(name, color, roughness=0.35): return mat -def build(): +def build(mute=False): bpy.ops.wm.read_factory_settings(use_empty=True) col = bpy.context.collection @@ -186,6 +190,8 @@ def build(): con.name = "AimCore" con.target = core con.track_axis = "TRACK_Z" + if mute: + con.mute = True needles.append(ob) bpy.context.view_layer.update() @@ -376,9 +382,11 @@ def main(): choices=("eevee", "cycles"), help="render engine when --output is set", ) + p.add_argument("--mute", action="store_true", + help="mute every DAMPED_TRACK (must fail)") args = p.parse_args(argv) - core, needles = build() + core, needles = build(mute=args.mute) code = check(core, needles) if code != 0: return code diff --git a/examples/depsgraph-export/README.md b/examples/depsgraph-export/README.md index 4355819..6ddfaca 100644 --- a/examples/depsgraph-export/README.md +++ b/examples/depsgraph-export/README.md @@ -17,6 +17,9 @@ export) rather than the unmodified base mesh. # Cheap correctness check (writes an OBJ to a temp path, asserts the counts) — the CI check: blender --background --python depsgraph_export.py -- +# Falsifier: apply_modifiers=False. Must exit non-zero (export ≠ evaluated). +blender --background --python depsgraph_export.py -- --unevaluated + # Also render a still of base vs evaluated (EEVEE on a GPU host; cycles on GPU-less hosts): blender --background --python depsgraph_export.py -- --output depsgraph.png blender --background --python depsgraph_export.py -- --output depsgraph.png --engine cycles @@ -25,8 +28,27 @@ blender --background --python depsgraph_export.py -- --output depsgraph.png --en blender --background --python depsgraph_export.py -- --obj exported.obj ``` -It exits non-zero on failure (modifier not applied, or exported count ≠ evaluated count). The -`blender-smoke` workflow runs this check on Blender 5.2 LTS and 4.5 LTS: base 8 → evaluated/exported -98 vertices with a 2-level SUBSURF. +## Exit codes + +Per-script sequential checks. `9` is a valid check code; there is no rule +against it. `10` is the shared framing helper. + +| Code | Meaning | +| --- | --- | +| 0 | Success | +| 1 | Uncaught exception (FATAL wrapper) | +| 2 | argparse / usage | +| 3 | Evaluated mesh did not apply the modifier | +| 4 | No OBJ written | +| 5 | Export vert count ≠ evaluated (`--unevaluated` lands here) | +| 6 | `--output` produced no file | +| 10 | Gallery framing violation | + +`--obj` is a path selector, not a falsifier. + +The `blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS +(5.1 on the weekly cron, the `needs-5.1` PR label, or manual dispatch). +Smoke does not pass `--output`, `--obj`, or `--unevaluated`. + The `--output` render path additionally measures framing against the Layer 1 band via `examples/gallery_framing.py` (exit 10 on violation) before writing the still. diff --git a/examples/depsgraph-export/depsgraph_export.py b/examples/depsgraph-export/depsgraph_export.py index 14d7062..b3cd1eb 100644 --- a/examples/depsgraph-export/depsgraph_export.py +++ b/examples/depsgraph-export/depsgraph_export.py @@ -6,10 +6,16 @@ wm.obj_export, and asserts the exported vertex count equals the EVALUATED (modifier-applied) count and is strictly greater than the base. +``--unevaluated`` exports with ``apply_modifiers=False`` and still asserts +the OBJ vertex count equals the depsgraph-evaluated mesh. That is the +falsifier (``--same-axis`` in export-preset-axis). ``--obj`` is a path +selector, not a falsifier. + By default it runs only the correctness check (no render) — the CI smoke check. Pass --output to also render a still: blender --background --python depsgraph_export.py -- # check only + blender --background --python depsgraph_export.py -- --unevaluated # must fail blender --background --python depsgraph_export.py -- --output d.png # + render """ import bpy, bmesh, sys, os, math, argparse, tempfile @@ -35,7 +41,7 @@ def build(): return obj -def check(obj, obj_path): +def check(obj, obj_path, unevaluated=False): base = len(obj.data.vertices) # depsgraph lifetime contract: evaluate, read, then release with to_mesh_clear @@ -48,7 +54,11 @@ def check(obj, obj_path): out = obj_path or os.path.join(tempfile.gettempdir(), "depsgraph_export.obj") os.makedirs(os.path.dirname(os.path.abspath(out)) or ".", exist_ok=True) # obj_export writes the evaluated (modifier-applied) geometry by default - bpy.ops.wm.obj_export(filepath=out, export_selected_objects=False) + bpy.ops.wm.obj_export( + filepath=out, + export_selected_objects=False, + apply_modifiers=not unevaluated, + ) if not (os.path.exists(out) and os.path.getsize(out) > 0): print("ERROR: no OBJ written", file=sys.stderr) return 4 @@ -207,10 +217,12 @@ def main(): help="render engine for --output (cycles for GPU-less hosts)") p.add_argument("--obj", default=None, help="optional: write the exported OBJ here (else a temp path)") + p.add_argument("--unevaluated", action="store_true", + help="export with apply_modifiers=False (must fail)") args = p.parse_args(argv) obj = build() - code = check(obj, args.obj) + code = check(obj, args.obj, unevaluated=args.unevaluated) if code: return code diff --git a/examples/driver-wave/README.md b/examples/driver-wave/README.md index 9b590a1..b7bc6f1 100644 --- a/examples/driver-wave/README.md +++ b/examples/driver-wave/README.md @@ -21,12 +21,30 @@ open. Headless, registering before driver creation (as here) is enough. # Cheap correctness check (no render) — the CI check: blender --background --python driver_wave.py -- +# Falsifier: constant 1.0 expression. Must exit non-zero. +blender --background --python driver_wave.py -- --flat-expr + # Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts): blender --background --python driver_wave.py -- --output driver.png blender --background --python driver_wave.py -- --output driver.png --engine cycles ``` -It exits non-zero on failure (driven value wrong, or the flush-back disagreed). The -`blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS. +## Exit codes + +Per-script sequential checks. `9` is a valid check code; there is no rule +against it. `10` is the shared framing helper. + +| Code | Meaning | +| --- | --- | +| 0 | Success | +| 1 | Uncaught exception (FATAL wrapper) | +| 2 | argparse / usage | +| 3 | Evaluated Z scale ≠ `wave_scale` (`--flat-expr` lands here) | +| 4 | Original datablock was not flushed | +| 6 | `--output` produced no file | +| 10 | Gallery framing violation | + +The `blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS +(5.1 on the weekly cron, the `needs-5.1` PR label, or manual dispatch). +Smoke does not pass `--output` or `--flat-expr`. -The `--output` render path additionally measures framing against the Layer 1 band via `examples/gallery_framing.py` (exit 10 on violation) before writing the still. diff --git a/examples/driver-wave/driver_wave.py b/examples/driver-wave/driver_wave.py index f1f0b7a..e6b5b7f 100644 --- a/examples/driver-wave/driver_wave.py +++ b/examples/driver-wave/driver_wave.py @@ -8,10 +8,14 @@ for display, so both must agree). Asserts both against the closed-form profile. Exits non-zero on failure. +``--flat-expr`` drives Z scale with ``1.0`` and still asserts ``wave_scale``. +That is the falsifier (``--same-axis`` in export-preset-axis). + By default it runs only the correctness check (no render) — the CI smoke check. Pass --output to also render a still: blender --background --python driver_wave.py -- # check only + blender --background --python driver_wave.py -- --flat-expr # must fail blender --background --python driver_wave.py -- --output d.png # + render """ import bpy, bmesh, sys, os, math, argparse @@ -31,7 +35,7 @@ def wave_scale(i): return 1.4 + math.sin(i * 0.6) -def build_columns(): +def build_columns(flat_expr=False): bpy.ops.wm.read_factory_settings(use_empty=True) # driver_namespace entries do not persist in .blend files; real add-ons # re-register them from a load_post handler. Headless, registering before @@ -54,7 +58,7 @@ def build_columns(): obj.scale = (BASE, BASE, 1.0) fcu = obj.driver_add("scale", 2) fcu.driver.type = 'SCRIPTED' - fcu.driver.expression = f"wave_scale({i})" + fcu.driver.expression = "1.0" if flat_expr else f"wave_scale({i})" bpy.context.collection.objects.link(obj) objs.append(obj) return objs @@ -191,9 +195,11 @@ def main(): p.add_argument("--output", default=None, help="optional: render a still PNG here") p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"), help="render engine for --output (cycles for GPU-less hosts)") + p.add_argument("--flat-expr", action="store_true", + help="drive Z scale with 1.0 (must fail)") args = p.parse_args(argv) - objs = build_columns() + objs = build_columns(flat_expr=args.flat_expr) code = check(objs) if code: return code diff --git a/examples/gltf-skin-roundtrip/README.md b/examples/gltf-skin-roundtrip/README.md index 084315e..a698837 100644 --- a/examples/gltf-skin-roundtrip/README.md +++ b/examples/gltf-skin-roundtrip/README.md @@ -53,11 +53,44 @@ same curl, same glowing stinger — proof the skin rode the format through. # Cheap correctness check (no render) — the CI check: blender --background --python gltf_skin_roundtrip.py -- +# Falsifier: export_skins=False. Must exit non-zero. +blender --background --python gltf_skin_roundtrip.py -- --no-skins + # Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts): blender --background --python gltf_skin_roundtrip.py -- --output scorp.png blender --background --python gltf_skin_roundtrip.py -- --output scorp.png --engine cycles ``` -It exits non-zero on failure (missing skin, joint drift, weight-sum drift, -skeleton drift, weight excursion, or deformation excursion). The -`blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS. +## Exit codes + +Per-script sequential checks. `9` is a valid check code; there is no rule +against it. + +| Code | Meaning | +| --- | --- | +| 0 | Success | +| 1 | Uncaught exception (FATAL wrapper) | +| 2 | argparse / usage | +| 3 | Exporter RNA missing expected kwargs | +| 4 | Vertex group count ≠ bone count | +| 5 | Disk skins ≠ 1 (`--no-skins` lands here) | +| 6 | Skin joints ≠ bone names | +| 7 | Missing JOINTS_0/WEIGHTS_0, or accessor length mismatch | +| 8 | Disk weight sums off 1.0 | +| 9 | Disk verts exceed evaluated loops | +| 10 | Armature count after import ≠ 1 | +| 11 | Bone count drifted | +| 12 | Named bone lost | +| 13 | Bone parent drifted | +| 14 | Rest matrices drifted | +| 15 | Skinned mesh count after import ≠ 1 | +| 16 | Re-import vert count ≠ disk | +| 17 | Vertex group names drifted | +| 18 | Weight round-trip drifted | +| 19 | Deformation round-trip drifted | +| 20 | `--output` produced no file | + +The `blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS +(5.1 on the weekly cron, the `needs-5.1` PR label, or manual dispatch). +Smoke does not pass `--output` or `--no-skins`. + diff --git a/examples/gltf-skin-roundtrip/gltf_skin_roundtrip.py b/examples/gltf-skin-roundtrip/gltf_skin_roundtrip.py index 2b6e903..f8ee5cd 100644 --- a/examples/gltf-skin-roundtrip/gltf_skin_roundtrip.py +++ b/examples/gltf-skin-roundtrip/gltf_skin_roundtrip.py @@ -24,10 +24,14 @@ The skins pipeline is stable between Blender 4.5 LTS and 5.1 (exporter RNA is byte-identical, verified on both) — the example runs identically on both. +``--no-skins`` exports with ``export_skins=False`` and still asserts one +skin on disk. That is the falsifier (``--same-axis`` in export-preset-axis). + By default it runs only the correctness check (no render) — the CI smoke check. Pass --output to also render a still: blender --background --python gltf_skin_roundtrip.py -- # check only + blender --background --python gltf_skin_roundtrip.py -- --no-skins # must fail blender --background --python gltf_skin_roundtrip.py -- --output s.png # + render """ import bpy, bmesh, sys, os, math, json, struct, shutil, tempfile, argparse @@ -247,7 +251,7 @@ def accessor_uints(idx, ncomp, ctype): return g, accessor_floats, accessor_uints -def check(obj, arm, part_of): +def check(obj, arm, part_of, no_skins=False): me = obj.data exp_props = {p.identifier for p in bpy.ops.export_scene.gltf.get_rna_type().properties} missing = [k for k in EXPORT_KWARGS if k not in exp_props] @@ -290,7 +294,10 @@ def weight_map(o): tmp = tempfile.mkdtemp(prefix="gltf_skin_") try: path = os.path.join(tmp, "scorp.gltf").replace("\\", "/") - bpy.ops.export_scene.gltf(filepath=path, **EXPORT_KWARGS) + kw = dict(EXPORT_KWARGS) + if no_skins: + kw["export_skins"] = False + bpy.ops.export_scene.gltf(filepath=path, **kw) # contract 1 (on disk): the skin carries every bone, joints named g, acc_f, acc_u = read_gltf(path) @@ -556,6 +563,8 @@ def main(): p.add_argument("--output", default=None, help="optional: render a still PNG here") p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"), help="render engine for --output (cycles for GPU-less hosts)") + p.add_argument("--no-skins", action="store_true", + help="export with export_skins=False (must fail)") args = p.parse_args(argv) bpy.ops.wm.read_factory_settings(use_empty=True) @@ -565,7 +574,7 @@ def main(): assign_weights(obj, part_of) arm = build_rig(obj) bpy.context.view_layer.update() - code = check(obj, arm, part_of) + code = check(obj, arm, part_of, no_skins=args.no_skins) if code: return code diff --git a/examples/parent-inverse-orrery/README.md b/examples/parent-inverse-orrery/README.md index f4ede38..b2ca463 100644 --- a/examples/parent-inverse-orrery/README.md +++ b/examples/parent-inverse-orrery/README.md @@ -24,11 +24,32 @@ land exactly on their closed-form orbit positions after the pivots spin. # Cheap correctness check (no render) — the CI check: blender --background --python parent_inverse_orrery.py -- +# Falsifier: parent without MPI. Must exit non-zero (orbit closed form). +blender --background --python parent_inverse_orrery.py -- --skip-mpi + # Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts): blender --background --python parent_inverse_orrery.py -- --output orrery.png blender --background --python parent_inverse_orrery.py -- --output orrery.png --engine cycles ``` -It exits non-zero on failure (no jump from the trap, keep-world error, stale-matrix -contract broken, or an orbit off its closed form). The `blender-smoke` workflow runs the -check on Blender 5.2 LTS and 4.5 LTS. +## Exit codes + +Per-script sequential checks. `9` is a valid check code; there is no rule +against it. + +| Code | Meaning | +| --- | --- | +| 0 | Success | +| 1 | Uncaught exception (FATAL wrapper) | +| 2 | argparse / usage | +| 3 | Bare parenting did not jump | +| 4 | Keep-world idiom off | +| 5 | Stale `matrix_world` contract broken | +| 6 | Planet off closed-form orbit (`--skip-mpi` lands here) | +| 7 | Moon off closed-form orbit | +| 8 | `--output` produced no file | + +The `blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS +(5.1 on the weekly cron, the `needs-5.1` PR label, or manual dispatch). +Smoke does not pass `--output` or `--skip-mpi`. + diff --git a/examples/parent-inverse-orrery/parent_inverse_orrery.py b/examples/parent-inverse-orrery/parent_inverse_orrery.py index d24fc64..ed48678 100644 --- a/examples/parent-inverse-orrery/parent_inverse_orrery.py +++ b/examples/parent-inverse-orrery/parent_inverse_orrery.py @@ -15,10 +15,15 @@ every planet and the moon must land on the closed-form orbit position (rotation about the column axis, composed per hierarchy level). +``--skip-mpi`` parents without ``matrix_parent_inverse`` and still asserts +closed-form orbits. That is the falsifier (``--same-axis`` in +export-preset-axis). + By default it runs only the correctness check (no render) — the CI smoke check. Pass --output to also render a still: blender --background --python parent_inverse_orrery.py -- # check only + blender --background --python parent_inverse_orrery.py -- --skip-mpi # must fail blender --background --python parent_inverse_orrery.py -- --output o.png # + render """ import bpy, bmesh, sys, os, math, argparse @@ -72,13 +77,14 @@ def empty(name, location): return obj -def parent_keep_world(child, parent): +def parent_keep_world(child, parent, skip_mpi=False): """The idiom this example witnesses: parent without moving the child.""" child.parent = parent - child.matrix_parent_inverse = parent.matrix_world.inverted() + if not skip_mpi: + child.matrix_parent_inverse = parent.matrix_world.inverted() -def build_orrery(): +def build_orrery(skip_mpi=False): """Author the whole hierarchy with bpy.data (no object-mode operators).""" bpy.ops.wm.read_factory_settings(use_empty=True) @@ -100,8 +106,8 @@ def build_orrery(): # everything is placed at its theta=0 WORLD position first, then # parented with the keep-world idiom -- nothing may move here bpy.context.view_layer.update() - parent_keep_world(arm, pivot) - parent_keep_world(planet, pivot) + parent_keep_world(arm, pivot, skip_mpi=skip_mpi) + parent_keep_world(planet, pivot, skip_mpi=skip_mpi) rig["planets"][name] = { "pivot": pivot, "planet": planet, "angle": math.radians(angle), "p0": Vector((radius, 0.0, height)), @@ -116,9 +122,9 @@ def build_orrery(): moon = sphere("Moon", MOON_R) moon.location = pc0 + Vector((MOON_OFFSET, 0.0, 0.0)) bpy.context.view_layer.update() - parent_keep_world(moon_pivot, host["planet"]) - parent_keep_world(rod, moon_pivot) - parent_keep_world(moon, moon_pivot) + parent_keep_world(moon_pivot, host["planet"], skip_mpi=skip_mpi) + parent_keep_world(rod, moon_pivot, skip_mpi=skip_mpi) + parent_keep_world(moon, moon_pivot, skip_mpi=skip_mpi) rig["moon"] = {"pivot": moon_pivot, "moon": moon, "angle": math.radians(MOON_ANGLE), "pc0": pc0, "m0": pc0 + Vector((MOON_OFFSET, 0.0, 0.0))} @@ -323,9 +329,11 @@ def main(): p.add_argument("--output", default=None, help="optional: render a still PNG here") p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"), help="render engine for --output (cycles for GPU-less hosts)") + p.add_argument("--skip-mpi", action="store_true", + help="parent without matrix_parent_inverse (must fail)") args = p.parse_args(argv) - rig = build_orrery() + rig = build_orrery(skip_mpi=args.skip_mpi) code = check(rig) if code: return code diff --git a/examples/prop-origin-transform/README.md b/examples/prop-origin-transform/README.md index 063514d..ae73d9a 100644 --- a/examples/prop-origin-transform/README.md +++ b/examples/prop-origin-transform/README.md @@ -46,11 +46,31 @@ color — carries the proof. Check closed forms are unchanged. ```bash blender --background --python prop_origin_transform.py -- +blender --background --python prop_origin_transform.py -- --skip-mpi blender --background --python prop_origin_transform.py -- --output origin.png blender --background --python prop_origin_transform.py -- --output origin.png --engine cycles ``` -Exits non-zero on failure. The `blender-smoke` workflow runs the check on -Blender 5.2 LTS and 4.5 LTS. The `--output` render path additionally measures -framing against the Layer 1 band via `examples/gallery_framing.py` (exit 10 -on violation) before writing the still. +## Exit codes + +Per-script sequential checks. `9` is a valid check code; there is no rule +against it. `10` is the shared framing helper. + +| Code | Meaning | +| --- | --- | +| 0 | Success | +| 1 | Uncaught exception (FATAL wrapper) | +| 2 | argparse / usage | +| 3 | Stale `matrix_world` contract broken | +| 4 | World bbox moved across bake | +| 5 | Scale after bake is not (1,1,1) | +| 6 | Origin not at local base center | +| 7 | Bare parenting did not jump | +| 8 | MPI did not restore world location (`--skip-mpi` lands here) | +| 9 | `--output` produced no file | +| 10 | Gallery framing violation | + +The `blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS +(5.1 on the weekly cron, the `needs-5.1` PR label, or manual dispatch). +Smoke does not pass `--output` or `--skip-mpi`. + diff --git a/examples/prop-origin-transform/prop_origin_transform.py b/examples/prop-origin-transform/prop_origin_transform.py index e081179..9d0af98 100644 --- a/examples/prop-origin-transform/prop_origin_transform.py +++ b/examples/prop-origin-transform/prop_origin_transform.py @@ -7,10 +7,14 @@ `parent-inverse-orrery` (MPI idiom + stale `matrix_world`) without retreading orbits — subject is a street utility pedestal with a bolted conduit accessory. +``--skip-mpi`` parents the accessory without MPI and still asserts the restore. +That is the falsifier (``--same-axis`` in export-preset-axis). + By default it runs only the correctness check (no render). Pass --output to also render a still: blender --background --python prop_origin_transform.py -- + blender --background --python prop_origin_transform.py -- --skip-mpi blender --background --python prop_origin_transform.py -- --output o.png """ import bpy, bmesh, sys, os, math, argparse @@ -257,7 +261,7 @@ def bake_prop(prop): return before, after -def check(prop, acc): +def check(prop, acc, skip_mpi=False): """Assert origin/scale bake + MPI accessory contract. Exit 3–8 on failure.""" view_layer = bpy.context.view_layer @@ -341,8 +345,9 @@ def check(prop, acc): ) return 7 - acc.matrix_parent_inverse = prop.matrix_world.inverted() - view_layer.update() + if not skip_mpi: + acc.matrix_parent_inverse = prop.matrix_world.inverted() + view_layer.update() err = (acc.matrix_world.translation - w0).length print(f"mpi_restore_err={err:.3e}") if err > MPI_EPS: @@ -608,11 +613,13 @@ def main(): p = argparse.ArgumentParser() p.add_argument("--output", default=None) p.add_argument("--engine", default="eevee", choices=("eevee", "cycles")) + p.add_argument("--skip-mpi", action="store_true", + help="parent the accessory without MPI (must fail)") args = p.parse_args(argv) print(f"binary version: {bpy.app.version} ({bpy.app.version_string})") sc, prop, acc = build_scene() - code = check(prop, acc) + code = check(prop, acc, skip_mpi=args.skip_mpi) if code: return code diff --git a/examples/shape-key-blend/README.md b/examples/shape-key-blend/README.md index b00191f..729b634 100644 --- a/examples/shape-key-blend/README.md +++ b/examples/shape-key-blend/README.md @@ -16,10 +16,34 @@ mesh stays at Basis; every evaluated vertex matches the closed-form blend # Cheap correctness check (no render) — the CI check: blender --background --python shape_key_blend.py -- +# Falsifier: Tall.value = 0. Must exit non-zero. +blender --background --python shape_key_blend.py -- --zero-blend + # Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts): blender --background --python shape_key_blend.py -- --output blend.png blender --background --python shape_key_blend.py -- --output blend.png --engine cycles ``` -It exits non-zero on failure (missing keys, wrong value, per-vert blend mismatch, or -flare miss). The `blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS. +## Exit codes + +Per-script sequential checks. `9` is a valid check code; there is no rule +against it. + +| Code | Meaning | +| --- | --- | +| 0 | Success | +| 1 | Uncaught exception (FATAL wrapper) | +| 2 | argparse / usage | +| 3 | No shape keys on mesh | +| 4 | Key names ≠ Basis, Tall | +| 5 | Tall.value ≠ 0.5 (`--zero-blend` lands here) | +| 6 | Undeformed `mesh.vertices` not at Basis | +| 7 | Evaluated vert off closed-form blend | +| 8 | Evaluated Z span off closed form | +| 9 | Top flare off closed form | +| 10 | `--output` produced no file | + +The `blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS +(5.1 on the weekly cron, the `needs-5.1` PR label, or manual dispatch). +Smoke does not pass `--output` or `--zero-blend`. + diff --git a/examples/shape-key-blend/shape_key_blend.py b/examples/shape-key-blend/shape_key_blend.py index 5bc58dc..35fdd5c 100644 --- a/examples/shape-key-blend/shape_key_blend.py +++ b/examples/shape-key-blend/shape_key_blend.py @@ -10,10 +10,14 @@ The Tall key both lifts and flares the top face, so the silhouette is a truncated pyramid — clearly a blend, not a uniformly scaled box. +``--zero-blend`` sets Tall.value to 0 and still asserts the 0.5 closed form. +That is the falsifier (``--same-axis`` in export-preset-axis). + By default it runs only the correctness check (no render) — the CI smoke check. Pass --output to also render a still: blender --background --python shape_key_blend.py -- # check only + blender --background --python shape_key_blend.py -- --zero-blend # must fail blender --background --python shape_key_blend.py -- --output s.png # + render """ import bpy, bmesh, sys, os, math, argparse @@ -28,7 +32,7 @@ EXPECT_TOP_HALF = HALF + BLEND * FLARE # |x| and |y| of top verts -def build(): +def build(zero_blend=False): bpy.ops.wm.read_factory_settings(use_empty=True) me = bpy.data.meshes.new("Block") bm = bmesh.new() @@ -51,7 +55,7 @@ def build(): # flare top face outward so the blend reads as a taper, not a box co.x = math.copysign(HALF + FLARE, co.x) co.y = math.copysign(HALF + FLARE, co.y) - tall.value = BLEND + tall.value = 0.0 if zero_blend else BLEND return obj @@ -245,9 +249,11 @@ def main(): p.add_argument("--output", default=None, help="optional: render a still PNG here") p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"), help="render engine for --output (cycles for GPU-less hosts)") + p.add_argument("--zero-blend", action="store_true", + help="set Tall.value to 0 (must fail)") args = p.parse_args(argv) - obj = build() + obj = build(zero_blend=args.zero_blend) code = check(obj) if code: return code diff --git a/examples/soccer-ball-goldberg/README.md b/examples/soccer-ball-goldberg/README.md index 57730ec..e6b815b 100644 --- a/examples/soccer-ball-goldberg/README.md +++ b/examples/soccer-ball-goldberg/README.md @@ -68,11 +68,37 @@ inverts with it: white pentagons on a black ball, wrong on sight. # Cheap correctness check (no render) — the CI check: blender --background --python soccer_ball_goldberg.py -- +# Falsifier: swap pentagon/hexagon slots. Must exit non-zero. +blender --background --python soccer_ball_goldberg.py -- --invert-bind + # Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts): blender --background --python soccer_ball_goldberg.py -- --output ball.png blender --background --python soccer_ball_goldberg.py -- --output ball.png --engine cycles ``` -It exits non-zero on failure (topology, census, degree, edge uniformity, -planarity, circumsphere, or panel binding). The `blender-smoke` workflow runs -the check on Blender 5.2 LTS and 4.5 LTS. +## Exit codes + +Per-script sequential checks. `9` is a valid check code; there is no rule +against it. + +| Code | Meaning | +| --- | --- | +| 0 | Success | +| 1 | Uncaught exception (FATAL wrapper) | +| 2 | argparse / usage | +| 3 | Topology ≠ 60/90/32 | +| 4 | Euler characteristic ≠ 2 | +| 5 | Face census ≠ 12 pentagons + 20 hexagons | +| 6 | Vertex degree not uniform 3; also `--output` produced no file | +| 7 | Non-manifold edges | +| 8 | Edge lengths not uniform | +| 9 | Face planarity off | +| 10 | Centroid off origin | +| 11 | Circumradius not uniform | +| 12 | Panel material count ≠ 2 | +| 13 | Panel binding not by vertex count (`--invert-bind` lands here) | + +The `blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS +(5.1 on the weekly cron, the `needs-5.1` PR label, or manual dispatch). +Smoke does not pass `--output` or `--invert-bind`. + diff --git a/examples/soccer-ball-goldberg/soccer_ball_goldberg.py b/examples/soccer-ball-goldberg/soccer_ball_goldberg.py index c880cbd..16ad3e8 100644 --- a/examples/soccer-ball-goldberg/soccer_ball_goldberg.py +++ b/examples/soccer-ball-goldberg/soccer_ball_goldberg.py @@ -13,6 +13,7 @@ check. Pass --output to also render a still: blender --background --python soccer_ball_goldberg.py -- # check only + blender --background --python soccer_ball_goldberg.py -- --invert-bind # must fail blender --background --python soccer_ball_goldberg.py -- --output b.png # + render """ import bpy, bmesh, sys, os, math, argparse @@ -67,7 +68,7 @@ def _fan_edges(bv): prev_face, current = face, nxt -def build_ball(): +def build_ball(invert_bind=False): """Truncate a bmesh icosphere at 1/3 per edge into the Goldberg ball. The icosphere is the topology source: cut points are computed per edge, @@ -150,7 +151,8 @@ def near(i, j): # builder that assigns "first 12 faces black" passes only by luck of # bmesh face ordering, and the check below must catch it for poly in me.polygons: - poly.material_index = 1 if len(poly.vertices) == 5 else 0 + pent = len(poly.vertices) == 5 + poly.material_index = (0 if pent else 1) if invert_bind else (1 if pent else 0) return obj @@ -392,9 +394,11 @@ def main(): p.add_argument("--output", default=None, help="optional: render a still PNG here") p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"), help="render engine for --output (cycles for GPU-less hosts)") + p.add_argument("--invert-bind", action="store_true", + help="swap pentagon/hexagon material slots (must fail)") args = p.parse_args(argv) - obj = build_ball() + obj = build_ball(invert_bind=args.invert_bind) code = check(obj) if code: return code diff --git a/examples/temp-override-join/README.md b/examples/temp-override-join/README.md index 3910b11..3510424 100644 --- a/examples/temp-override-join/README.md +++ b/examples/temp-override-join/README.md @@ -19,11 +19,32 @@ leaves only step 0 and the Z span fails. # Cheap correctness check (no render) — the CI check: blender --background --python temp_override_join.py -- +# Falsifier: join without temp_override. Must exit non-zero. +blender --background --python temp_override_join.py -- --no-override + # Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts): blender --background --python temp_override_join.py -- --output join.png blender --background --python temp_override_join.py -- --output join.png --engine cycles ``` -It exits non-zero on failure (wrong object count, topology mismatch, sources still alive, -or incomplete Z span). The `blender-smoke` workflow runs the check on Blender 5.2 LTS and -4.5 LTS. +## Exit codes + +Per-script sequential checks. `9` is a valid check code; there is no rule +against it. + +| Code | Meaning | +| --- | --- | +| 0 | Success | +| 1 | Uncaught exception (FATAL wrapper) | +| 2 | argparse / usage | +| 3 | Mesh object count after join ≠ 1 (`--no-override` lands here) | +| 4 | Joined target is not the sole remaining mesh | +| 5 | Topology ≠ 24 verts / 18 faces | +| 6 | Source objects still present | +| 7 | Local Z span did not cover all steps | +| 8 | `--output` produced no file | + +The `blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS +(5.1 on the weekly cron, the `needs-5.1` PR label, or manual dispatch). +Smoke does not pass `--output` or `--no-override`. + diff --git a/examples/temp-override-join/temp_override_join.py b/examples/temp-override-join/temp_override_join.py index eaff520..f178d89 100644 --- a/examples/temp-override-join/temp_override_join.py +++ b/examples/temp-override-join/temp_override_join.py @@ -8,10 +8,15 @@ that only the target remains, and that the local Z span spans all three steps (proving every source contributed geometry). +``--no-override`` calls ``object.join`` without ``temp_override``. If the +operator raises, that is caught and the existing object-count check still +runs. That is the falsifier (``--same-axis`` in export-preset-axis). + By default it runs only the correctness check (no render) — the CI smoke check. Pass --output to also render a still: blender --background --python temp_override_join.py -- # check only + blender --background --python temp_override_join.py -- --no-override # must fail blender --background --python temp_override_join.py -- --output j.png # + render """ import bpy, bmesh, sys, os, math, argparse @@ -198,12 +203,22 @@ def main(): p.add_argument("--output", default=None, help="optional: render a still PNG here") p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"), help="render engine for --output (cycles for GPU-less hosts)") + p.add_argument("--no-override", action="store_true", + help="join without temp_override (must fail)") args = p.parse_args(argv) objs = build_cubes() target, sources = objs[0], objs[1:] source_names = [s.name for s in sources] - joined = join_with_temp_override(target, sources) + if args.no_override: + try: + bpy.ops.object.join() + except RuntimeError as exc: + print(f"join without override: {type(exc).__name__}: {exc}", + file=sys.stderr) + joined = target + else: + joined = join_with_temp_override(target, sources) code = check(joined, source_names) if code: return code diff --git a/examples/vertex-weight-limit/README.md b/examples/vertex-weight-limit/README.md index c8d2ae7..b481037 100644 --- a/examples/vertex-weight-limit/README.md +++ b/examples/vertex-weight-limit/README.md @@ -49,13 +49,35 @@ limited weights still deform as authored. # Cheap correctness check (no render) — the CI check: blender --background --python vertex_weight_limit.py -- +# Falsifier: skip the 4-influence prune. Must exit non-zero. +blender --background --python vertex_weight_limit.py -- --skip-limit + # Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts): blender --background --python vertex_weight_limit.py -- --output arm.png blender --background --python vertex_weight_limit.py -- --output arm.png --engine cycles ``` -It exits non-zero on failure (vacuous authoring, a vertex over the cap, broken -weight sums, pose damaged by pruning, LBS drift, or a moved Root mount). The -`blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS. +## Exit codes + +Per-script sequential checks. `9` is a valid check code; there is no rule +against it. `10` is the shared framing helper; it is also the missing-render +code. + +| Code | Meaning | +| --- | --- | +| 0 | Success | +| 1 | Uncaught exception (FATAL wrapper) | +| 2 | argparse / usage | +| 3 | Pre-limit max influences ≠ 5 | +| 4 | Vertex over the 4-influence cap (`--skip-limit` lands here) | +| 5 | Limit changed nothing | +| 6 | Weight sums off 1.0 after renormalize | +| 7 | Pose damaged by pruning, or evaluated vert count changed | +| 8 | Evaluated mesh off LBS over limited weights | +| 9 | Root-weighted mount moved | +| 10 | Gallery framing violation; also `--output` produced no file | + +The `blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS +(5.1 on the weekly cron, the `needs-5.1` PR label, or manual dispatch). +Smoke does not pass `--output` or `--skip-limit`. -The `--output` render path additionally measures framing against the Layer 1 band via `examples/gallery_framing.py` (exit 10 on violation) before writing the still. diff --git a/examples/vertex-weight-limit/vertex_weight_limit.py b/examples/vertex-weight-limit/vertex_weight_limit.py index e19a778..3a61650 100644 --- a/examples/vertex-weight-limit/vertex_weight_limit.py +++ b/examples/vertex-weight-limit/vertex_weight_limit.py @@ -23,10 +23,15 @@ between Blender 4.5 LTS and 5.1 — the example runs identically on both, which is itself the version witness. +``--skip-limit`` leaves the five-influence flex weights in place and still +asserts the engine cap. That is the falsifier (``--same-axis`` in +export-preset-axis). + By default it runs only the correctness check (no render) — the CI smoke check. Pass --output to also render a still: blender --background --python vertex_weight_limit.py -- # check only + blender --background --python vertex_weight_limit.py -- --skip-limit # must fail blender --background --python vertex_weight_limit.py -- --output a.png # + render """ import bpy, bmesh, sys, os, math, argparse @@ -262,7 +267,7 @@ def eval_positions(obj): ob_eval.to_mesh_clear() -def check(obj, arm, groups, pose_before): +def check(obj, arm, groups, pose_before, skip_limit=False): me = obj.data # pre-limit witness: the flex cuffs really carry five influences @@ -274,16 +279,17 @@ def check(obj, arm, groups, pose_before): # the limit, through the data API: keep top-4, drop the rest, renormalize changed = 0 - for v in me.vertices: - gs = sorted(v.groups, key=lambda g: -g.weight) - if len(gs) > MAX_INFLUENCES: - changed += 1 - for g in gs[MAX_INFLUENCES:]: - groups[BONES[g.group]].remove([v.index]) - kept = [g for g in v.groups] - total = sum(g.weight for g in kept) - for g in kept: - groups[BONES[g.group]].add([v.index], g.weight / total, 'REPLACE') + if not skip_limit: + for v in me.vertices: + gs = sorted(v.groups, key=lambda g: -g.weight) + if len(gs) > MAX_INFLUENCES: + changed += 1 + for g in gs[MAX_INFLUENCES:]: + groups[BONES[g.group]].remove([v.index]) + kept = [g for g in v.groups] + total = sum(g.weight for g in kept) + for g in kept: + groups[BONES[g.group]].add([v.index], g.weight / total, 'REPLACE') # contract 1: no vertex exceeds the engine limit post_max = max(len(v.groups) for v in me.vertices) @@ -481,6 +487,8 @@ def main(): p.add_argument("--output", default=None, help="optional: render a still PNG here") p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"), help="render engine for --output (cycles for GPU-less hosts)") + p.add_argument("--skip-limit", action="store_true", + help="skip the 4-influence prune (must fail)") args = p.parse_args(argv) bpy.ops.wm.read_factory_settings(use_empty=True) @@ -491,7 +499,7 @@ def main(): arm = build_rig(obj) bpy.context.view_layer.update() pose_before = eval_positions(obj) - code = check(obj, arm, groups, pose_before) + code = check(obj, arm, groups, pose_before, skip_limit=args.skip_limit) if code: return code