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