From b4f30cfab67fc076dadcb3c4dc6f857a71984e7d Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Sat, 12 Sep 2026 18:04:45 -0400 Subject: [PATCH] feat: add CLI falsifiers and exit tables to six examples Each flag feeds degenerate input into an existing measured check so the assertion can fail on purpose. Default smoke path is unchanged. README tables document every nonzero code these scripts already returned; no codes were renumbered. Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-authored-by: Cursor --- examples/car-mirror-symmetry/README.md | 34 ++++++++++++++-- .../car_mirror_symmetry.py | 17 +++++++- examples/custom-normals-shade/README.md | 28 +++++++++++-- .../custom_normals_shade.py | 16 ++++++-- examples/gltf-export-roundtrip/README.md | 39 +++++++++++++++++-- .../gltf_export_roundtrip.py | 19 ++++++--- examples/gn-modifier-inputs/README.md | 33 +++++++++++++--- .../gn-modifier-inputs/gn_modifier_inputs.py | 19 ++++++--- examples/image-pixels-testcard/README.md | 30 ++++++++++++-- .../image_pixels_testcard.py | 19 ++++++--- examples/lod-decimate-chain/README.md | 28 +++++++++++-- .../lod-decimate-chain/lod_decimate_chain.py | 15 +++++-- 12 files changed, 253 insertions(+), 44 deletions(-) diff --git a/examples/car-mirror-symmetry/README.md b/examples/car-mirror-symmetry/README.md index c5246f4..3590a53 100644 --- a/examples/car-mirror-symmetry/README.md +++ b/examples/car-mirror-symmetry/README.md @@ -58,11 +58,39 @@ windshield as a hot salmon slab. # Cheap correctness check (no render) — the CI check: blender --background --python car_mirror_symmetry.py -- +# Falsifier: Mirror X off. Must exit non-zero (evaluated verts stay at n). +blender --background --python car_mirror_symmetry.py -- --no-mirror + # Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts): blender --background --python car_mirror_symmetry.py -- --output car.png blender --background --python car_mirror_symmetry.py -- --output car.png --engine cycles ``` -It exits non-zero on failure (applied mirror, doubled centerline, unwelded -seam, broken symmetry, or a mirrored part off its plane origin). 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 | Body datablock is not the authored half | +| 4 | Authored centerline vert count ≠ 28 | +| 5 | Evaluated verts ≠ `2n − c` (`--no-mirror` lands here) | +| 6 | Evaluated on-plane verts ≠ centerline; also `--output` produced no file | +| 7 | Evaluated Euler characteristic ≠ 2 | +| 8 | Non-manifold edges in the evaluated shell | +| 9 | Evaluated verts lack a mirrored partner | +| 10 | Mirror partner deviation above tolerance | +| 11 | Evaluated bbox not symmetric about X | +| 12 | Mirrored-part origin off the plane | +| 13 | Mirrored-part datablock is not the authored half | +| 14 | Mirrored-part evaluated counts did not double | +| 15 | Mirrored-part partner check failed | +| 16 | Mirrored-part evaluated mesh stayed on one side | + +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-mirror`. diff --git a/examples/car-mirror-symmetry/car_mirror_symmetry.py b/examples/car-mirror-symmetry/car_mirror_symmetry.py index ab35226..80230c4 100644 --- a/examples/car-mirror-symmetry/car_mirror_symmetry.py +++ b/examples/car-mirror-symmetry/car_mirror_symmetry.py @@ -10,10 +10,15 @@ and the wheels mirror about their object origins sitting ON the symmetry plane. Failure is dramatically visible: a car with one side missing. +``--no-mirror`` turns off the Mirror X axis on every mirrored object and +still runs the evaluated-count check, so the half-car fails ``2n − c``. +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 car_mirror_symmetry.py -- # check only + blender --background --python car_mirror_symmetry.py -- --no-mirror # must fail blender --background --python car_mirror_symmetry.py -- --output c.png # + render """ import bpy, bmesh, sys, os, math, argparse @@ -269,7 +274,13 @@ def _symmetry_dev(verts, tol_plane): return dev, lone -def check(objs): +def check(objs, no_mirror=False): + if no_mirror: + for ob in [objs["body"]] + [w for w, *_ in objs["mirrored"]]: + for mod in ob.modifiers: + if mod.type == 'MIRROR': + mod.use_axis[0] = False + body = objs["body"] me = body.data @@ -502,10 +513,12 @@ 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-mirror", action="store_true", + help="turn off Mirror X (must fail)") args = p.parse_args(argv) objs = build_car() - code = check(objs) + code = check(objs, no_mirror=args.no_mirror) if code: return code diff --git a/examples/custom-normals-shade/README.md b/examples/custom-normals-shade/README.md index bf7173f..4103c49 100644 --- a/examples/custom-normals-shade/README.md +++ b/examples/custom-normals-shade/README.md @@ -73,12 +73,32 @@ strip light whose reflection exposes every normal discontinuity. # Cheap correctness check (no render) — the CI check: blender --background --python custom_normals_shade.py -- +# Falsifier: mark sharp at 20° while auditing 30°. Must exit non-zero. +blender --background --python custom_normals_shade.py -- --mismatch-angle + # Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts): blender --background --python custom_normals_shade.py -- --output cans.png blender --background --python custom_normals_shade.py -- --output cans.png --engine cycles ``` -It exits non-zero on failure (legacy API resurrected, sharp-set/dihedral -mismatch, broken normal welds, custom normals lost or dequantized in -evaluation, or legacy-operator divergence drift). 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 | Legacy shading API present, or modern path missing | +| 4 | Non-manifold edges (dihedral test undefined) | +| 5 | Sharp set ≠ independent dihedral (`--mismatch-angle` lands here) | +| 6 | Evaluated loop normals not welded/split as the sharp set promises | +| 7 | Custom split normals lost or dequantized in evaluation | +| 8 | `shade_auto_smooth` operator behavior drifted from the version split | +| 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 `--mismatch-angle`. diff --git a/examples/custom-normals-shade/custom_normals_shade.py b/examples/custom-normals-shade/custom_normals_shade.py index 6507863..9940056 100644 --- a/examples/custom-normals-shade/custom_normals_shade.py +++ b/examples/custom-normals-shade/custom_normals_shade.py @@ -26,11 +26,16 @@ that ignores the return set; on 5.1 it FINISHES and adds the NODES modifier. The portable path is the data API. +``--mismatch-angle`` marks sharp at 20° and still audits against the 30° +dihedral set, so the sharp-set match fails. 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 (the same can shaded flat / smooth-everywhere / by-angle, so a broken path reads as faceting or smear): blender --background --python custom_normals_shade.py -- # check only + blender --background --python custom_normals_shade.py -- --mismatch-angle blender --background --python custom_normals_shade.py -- --output c.png # + render """ import bpy, bmesh, sys, os, math, argparse @@ -244,15 +249,16 @@ def check_api_surface(me): return 0 -def check_by_angle(objs): +def check_by_angle(objs, mismatch_angle=False): """set_sharp_from_angle must mark exactly the edges whose independently recomputed dihedral crosses the threshold — on every checked mesh.""" + mark = math.radians(20.0) if mismatch_angle else ANGLE total_sharp = total_manifold = 0 for obj in objs: me = obj.data for p in me.polygons: p.use_smooth = True - me.set_sharp_from_angle(angle=ANGLE) + me.set_sharp_from_angle(angle=mark) dih, nonmanifold = manifold_dihedrals(me) if nonmanifold: print(f"ERROR: {obj.name}: {nonmanifold} non-manifold edge(s) — the " @@ -557,13 +563,17 @@ 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("--mismatch-angle", action="store_true", + help="mark sharp at 20° while auditing 30° (must fail)") args = p.parse_args(argv) bpy.ops.wm.read_factory_settings(use_empty=True) can = build_jerry_can() for step in (lambda: check_api_surface(can["shell"].data), - lambda: check_by_angle([can["shell"], can["rib"], can["neck"]]), + lambda: check_by_angle( + [can["shell"], can["rib"], can["neck"]], + mismatch_angle=args.mismatch_angle), lambda: check_normal_welds(can["shell"]), lambda: check_custom_normals_roundtrip(can["shell"]), check_legacy_operator): diff --git a/examples/gltf-export-roundtrip/README.md b/examples/gltf-export-roundtrip/README.md index f6e00c2..8eee190 100644 --- a/examples/gltf-export-roundtrip/README.md +++ b/examples/gltf-export-roundtrip/README.md @@ -57,11 +57,44 @@ silhouette would lose the rounded edges. # Cheap correctness check (no render) — the CI check: blender --background --python gltf_export_roundtrip.py -- +# Falsifier: export_yup=False. Must exit non-zero (bbox is Z-up on disk). +blender --background --python gltf_export_roundtrip.py -- --no-yup + # Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts): blender --background --python gltf_export_roundtrip.py -- --output crate.png blender --background --python gltf_export_roundtrip.py -- --output crate.png --engine cycles ``` -It exits non-zero on failure (RNA kwarg drift, cage drift, missing on-disk -conversion, vertex-split drift, or any round-trip excursion beyond tolerance). -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/importer RNA kwargs drifted | +| 4 | Base cage drifted from its closed form | +| 5 | Authored UVs drifted from the box-map closed form | +| 6 | Bevel produced no evaluated geometry | +| 7 | On-disk node/mesh/generator contract drifted | +| 8 | On-disk primitive/material binding count drifted | +| 9 | On-disk POSITION bounds ≠ axis-converted bbox (`--no-yup` lands here) | +| 10 | On-disk POSITION count ≠ evaluated loop count | +| 11 | On-disk UV V-flip failed | +| 12 | Re-import did not produce exactly one mesh | +| 13 | Re-imported object carries a transform | +| 14 | Material names drifted on re-import | +| 15 | Re-import vert count ≠ evaluated loop count | +| 16 | Round-trip position drift | +| 17 | Round-trip normal drift | +| 18 | Round-trip UV drift | +| 19 | Re-import triangle count drifted | +| 20 | Per-triangle material bindings drifted | +| 21 | `--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-yup`. diff --git a/examples/gltf-export-roundtrip/gltf_export_roundtrip.py b/examples/gltf-export-roundtrip/gltf_export_roundtrip.py index b9e7834..0f2dd17 100644 --- a/examples/gltf-export-roundtrip/gltf_export_roundtrip.py +++ b/examples/gltf-export-roundtrip/gltf_export_roundtrip.py @@ -8,8 +8,11 @@ data itself — (x, y, z) -> (x, z, -y) on disk — with no node rotation. The check parses the exported .gltf JSON and asserts the POSITION accessor bounds equal the axis-converted evaluated bounding box, and that the node - carries neither rotation nor scale. Exporting with ``export_yup=False`` - writes raw Z-up data that every engine will display lying on its back. + carries neither rotation nor scale. ``--no-yup`` exports with + ``export_yup=False`` and still runs that bbox check, so the +Y-up + conversion fails. That is the falsifier (``--same-axis`` in + export-preset-axis). Exporting with ``export_yup=False`` writes raw Z-up + data that every engine will display lying on its back. 2. Modifiers ship evaluated geometry. ``export_apply=True`` applies the crate's bevel modifier: the re-imported mesh matches the depsgraph-evaluated mesh, not the base cage. With ``export_apply=False`` @@ -32,6 +35,7 @@ check. Pass --output to also render a still: blender --background --python gltf_export_roundtrip.py -- # check only + blender --background --python gltf_export_roundtrip.py -- --no-yup # must fail blender --background --python gltf_export_roundtrip.py -- --output c.png # + render """ import bpy, bmesh, sys, os, math, json, struct, shutil, tempfile, argparse @@ -244,7 +248,7 @@ def accessor_floats(idx, ncomp): # --------------------------------------------------------------------------- # The check. Distinct exit codes per contract; measured maxima printed on success. # --------------------------------------------------------------------------- -def check(crate): +def check(crate, export_kwargs): # contract 0 (version guard): every kwarg we rely on still exists. exp_props = {p.identifier for p in bpy.ops.export_scene.gltf.get_rna_type().properties} imp_props = {p.identifier for p in bpy.ops.import_scene.gltf.get_rna_type().properties} @@ -285,7 +289,7 @@ def check(crate): tmp = tempfile.mkdtemp(prefix="gltf_roundtrip_") try: path = os.path.join(tmp, "crate.gltf").replace("\\", "/") - bpy.ops.export_scene.gltf(filepath=path, **EXPORT_KWARGS) + bpy.ops.export_scene.gltf(filepath=path, **export_kwargs) # contract 1 (on disk): +Y-up is baked into vertex data, no node transform g, acc_floats = read_gltf(path) @@ -592,13 +596,18 @@ 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-yup", action="store_true", + help="export with export_yup=False (must fail)") args = p.parse_args(argv) bpy.ops.wm.read_factory_settings(use_empty=True) crate = build_crate() for m in make_materials(): crate.data.materials.append(m) - code = check(crate) + kwargs = dict(EXPORT_KWARGS) + if args.no_yup: + kwargs["export_yup"] = False + code = check(crate, kwargs) if code: return code diff --git a/examples/gn-modifier-inputs/README.md b/examples/gn-modifier-inputs/README.md index 19e5469..39cc465 100644 --- a/examples/gn-modifier-inputs/README.md +++ b/examples/gn-modifier-inputs/README.md @@ -22,7 +22,10 @@ Follows [`geometry-nodes-python`](../../skills/geometry-nodes-python/SKILL.md). # Cheap correctness check (no render) — the CI check: blender --background --python gn_modifier_inputs.py -- -# Force one side of the split (must fail on the other series): +# Portable falsifier: write 1.0 to every modifier. Must exit non-zero. +blender --background --python gn_modifier_inputs.py -- --same-scale + +# Force one side of the split (must fail on the other series, not all three): blender --background --python gn_modifier_inputs.py -- --api dict blender --background --python gn_modifier_inputs.py -- --api rna @@ -31,10 +34,30 @@ blender --background --python gn_modifier_inputs.py -- --output stairs.png blender --background --python gn_modifier_inputs.py -- --output stairs.png --engine cycles ``` -It exits non-zero on failure (missing identifier, write/read raise, readback -mismatch, evaluated Z-extent ≠ scale, or three extents not distinct). The -`blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS -(5.1 on the weekly cron). +## 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 | Scale input identifier missing on the tree interface | +| 4 | Modifiers do not share one node_group | +| 5 | Version-path write raised (`--api dict` on 5.2, `--api rna` on 4.5) | +| 6 | Version-path read raised | +| 7 | Readback ≠ intended scale (`--same-scale` lands here) | +| 8 | Evaluated Z-extent ≠ intended scale | +| 9 | Evaluated mesh not sitting on z=0 | +| 10 | Gallery framing violation | +| 11 | Evaluated extents not distinct | +| 12 | `--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`, `--same-scale`, or `--api dict`/`rna`. ## Falsification diff --git a/examples/gn-modifier-inputs/gn_modifier_inputs.py b/examples/gn-modifier-inputs/gn_modifier_inputs.py index 59ea94c..d8f8d9d 100644 --- a/examples/gn-modifier-inputs/gn_modifier_inputs.py +++ b/examples/gn-modifier-inputs/gn_modifier_inputs.py @@ -10,10 +10,14 @@ 4.5 LTS and 5.1 write ``mod[identifier] = value``. 5.2+ removed ID properties on NodesModifier — that assignment raises TypeError — and the replacement is ``mod.properties.inputs..value``. -``--api dict`` / ``--api rna`` force one side so the witness can fail -on purpose. +``--api dict`` / ``--api rna`` force one side of the 5.1/5.2 split — they +fail on the *other* series, not on every binary. ``--same-scale`` writes +1.0 to every modifier and still asserts 1 / 2 / 3, so the second cube's +readback fails on all three. That is the portable falsifier +(``--same-axis`` in export-preset-axis). blender --background --python gn_modifier_inputs.py -- + blender --background --python gn_modifier_inputs.py -- --same-scale blender --background --python gn_modifier_inputs.py -- --api dict blender --background --python gn_modifier_inputs.py -- --output s.png """ @@ -178,7 +182,7 @@ def evaluated_z_extent(obj): ev.to_mesh_clear() -def check(tree, objs, mods, api): +def check(tree, objs, mods, api, same_scale=False): ident = scale_identifier(tree) if not ident: print("ERROR: Scale input identifier missing on the tree interface", @@ -191,8 +195,9 @@ def check(tree, objs, mods, api): return 4 for obj, mod, scale in zip(objs, mods, SCALES): + written = SCALES[0] if same_scale else scale try: - set_mod_input(mod, ident, scale, api) + set_mod_input(mod, ident, written, api) except Exception as e: print( f"ERROR: {api} write of {scale} on {obj.name} raised " @@ -368,11 +373,15 @@ def main(): "--api", default="auto", choices=("auto", "dict", "rna"), help="force the 5.1 dict path, the 5.2 RNA path, or pick from bpy.app.version", ) + p.add_argument( + "--same-scale", action="store_true", + help="write 1.0 to every modifier (must fail)", + ) args = p.parse_args(argv) tree, objs, mods = build() api = _api_choice(args.api) - code = check(tree, objs, mods, api) + code = check(tree, objs, mods, api, same_scale=args.same_scale) if code: return code diff --git a/examples/image-pixels-testcard/README.md b/examples/image-pixels-testcard/README.md index 80e9218..d031fc4 100644 --- a/examples/image-pixels-testcard/README.md +++ b/examples/image-pixels-testcard/README.md @@ -57,14 +57,38 @@ and the screen renders as one flat color. The render path creates the layer expl # Cheap correctness check (no render) — the CI check: blender --background --python image_pixels_testcard.py -- +# Falsifier: write the card top-down. Must exit non-zero (byte round-trip). +blender --background --python image_pixels_testcard.py -- --wrong-origin + # Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts): blender --background --python image_pixels_testcard.py -- --output card.png blender --background --python image_pixels_testcard.py -- --output card.png --engine cycles ``` -It exits non-zero on failure and prints every measured error and tolerance on success, -so CI logs carry the numbers. The `blender-smoke` workflow runs the check on Blender -5.2 LTS and 4.5 LTS. In the render, `Closest` interpolation keeps the pixel grid honest — +## 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 | Pixel buffer is not always RGBA | +| 4 | Byte round-trip vs closed-form card (`--wrong-origin` lands here) | +| 5 | Float-buffer round-trip failed | +| 6 | `scale()` did not reallocate, or stale-size read succeeded | +| 7 | `save()` source/buffer-drop contract drifted | +| 8 | `save_render()` flipped source or disturbed the buffer | +| 9 | Byte PNG save/reload error | +| 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 `--wrong-origin`. + +In the render, `Closest` interpolation keeps the pixel grid honest — the jagged circle edge is the 512 × 288 buffer itself, and the white marker in the PLUGE row sits at the bottom-left because that is where pixel (0, 0) lives. The monitor is staged as a designed object — beveled dark-polymer case, machined metal diff --git a/examples/image-pixels-testcard/image_pixels_testcard.py b/examples/image-pixels-testcard/image_pixels_testcard.py index 27f1580..6749ae6 100644 --- a/examples/image-pixels-testcard/image_pixels_testcard.py +++ b/examples/image-pixels-testcard/image_pixels_testcard.py @@ -18,10 +18,15 @@ original datablock. `save_render()` writes the same PNG but leaves `source` == 'GENERATED' and the buffer intact and exact. +``--wrong-origin`` writes the card top-down and still compares against +the bottom-left closed form, so the byte round-trip fails. 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 image_pixels_testcard.py -- # check only + blender --background --python image_pixels_testcard.py -- --wrong-origin # must fail blender --background --python image_pixels_testcard.py -- --output t.png # + render """ import bpy, sys, os, math, argparse, tempfile @@ -66,14 +71,15 @@ def pattern(x, y): return r, g, b, 1.0 -def flat_pattern(): +def flat_pattern(flip_origin=False): """The whole card as one flat RGBA buffer in pixel-buffer order: row-major from the BOTTOM row up, 4 floats per pixel.""" buf = [0.0] * (W * H * 4) i = 0 for y in range(H): + y_src = (H - 1 - y) if flip_origin else y for x in range(W): - buf[i:i + 4] = pattern(x, y) + buf[i:i + 4] = pattern(x, y_src) i += 4 return buf @@ -83,9 +89,10 @@ def fail(msg, code): return code -def check(): +def check(wrong_origin=False): bpy.ops.wm.read_factory_settings(use_empty=True) expected = flat_pattern() + written = flat_pattern(flip_origin=True) if wrong_origin else expected # -- buffer geometry: always RGBA, even with alpha=False ---------------- img = bpy.data.images.new("TestCard", W, H, alpha=False) @@ -99,7 +106,7 @@ def check(): pass # -- byte image: one bulk write, quantized round-trip -------------------- - img.pixels.foreach_set(expected) + img.pixels.foreach_set(written) got = [0.0] * (W * H * 4) img.pixels.foreach_get(got) byte_err = max(abs(a - b) for a, b in zip(expected, got)) @@ -370,9 +377,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("--wrong-origin", action="store_true", + help="write the card top-down (must fail)") args = p.parse_args(argv) - code = check() + code = check(wrong_origin=args.wrong_origin) if code: return code diff --git a/examples/lod-decimate-chain/README.md b/examples/lod-decimate-chain/README.md index 2c3934c..250c467 100644 --- a/examples/lod-decimate-chain/README.md +++ b/examples/lod-decimate-chain/README.md @@ -44,11 +44,33 @@ the nose facets coarsen, exactly the geometry the triangle counts assert. # Cheap correctness check (no render) — the CI check: blender --background --python lod_decimate_chain.py -- +# Falsifier: no Decimate on the LOD copies. Must exit non-zero (no reduction). +blender --background --python lod_decimate_chain.py -- --no-decimate + # Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts): blender --background --python lod_decimate_chain.py -- --output rocket.png blender --background --python lod_decimate_chain.py -- --output rocket.png --engine cycles ``` -It exits non-zero on failure (base-topology drift, no reduction, a mutated -original datablock, a ratio-bounds excursion, or silhouette loss). 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 | Base topology ≠ closed form | +| 4 | Base bbox ≠ closed form | +| 5 | LOD0 evaluated counts ≠ base | +| 6 | Evaluated tris ≥ base (`--no-decimate` lands here) | +| 7 | Original datablock mutated after evaluation | +| 8 | LOD tris outside ratio bounds | +| 9 | LOD bbox lost silhouette-critical dimensions | +| 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 `--no-decimate`. diff --git a/examples/lod-decimate-chain/lod_decimate_chain.py b/examples/lod-decimate-chain/lod_decimate_chain.py index aa4ad6c..3909053 100644 --- a/examples/lod-decimate-chain/lod_decimate_chain.py +++ b/examples/lod-decimate-chain/lod_decimate_chain.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. +``--no-decimate`` leaves the LOD copies without a Decimate modifier and +still runs the reduction check, so evaluated tris equal the base. 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 lod_decimate_chain.py -- # check only + blender --background --python lod_decimate_chain.py -- --no-decimate # must fail blender --background --python lod_decimate_chain.py -- --output r.png # + render """ import bpy, bmesh, sys, os, math, argparse @@ -195,7 +200,7 @@ def add_decimate(obj, ratio): return mod -def check(rocket, lod1, lod2): +def check(rocket, lod1, lod2, no_decimate=False): me = rocket.data want_v, want_f, want_t = closed_form_counts() got = (len(me.vertices), len(me.polygons)) @@ -225,7 +230,8 @@ def check(rocket, lod1, lod2): measured = [] for lod, ratio in ((lod1, LODS[0]), (lod2, LODS[1])): - add_decimate(lod, ratio) + if not no_decimate: + add_decimate(lod, ratio) snap = eval_mesh(lod) # contract 2: triangle count lands near ratio * base, within bounds target = ratio * want_t @@ -396,6 +402,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-decimate", action="store_true", + help="skip adding Decimate to the LOD copies (must fail)") args = p.parse_args(argv) bpy.ops.wm.read_factory_settings(use_empty=True) @@ -406,7 +414,8 @@ def main(): for m in mats: r.data.materials.append(m) rockets.append(r) - code = check(rockets[0], rockets[1], rockets[2]) + code = check(rockets[0], rockets[1], rockets[2], + no_decimate=args.no_decimate) if code: return code