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