Skip to content

Commit fd3493d

Browse files
feat: add CLI falsifiers and exit tables to six examples (#141)
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 <cursoragent@cursor.com>
1 parent 9edc150 commit fd3493d

12 files changed

Lines changed: 253 additions & 44 deletions

File tree

examples/car-mirror-symmetry/README.md

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,39 @@ windshield as a hot salmon slab.
5858
# Cheap correctness check (no render) — the CI check:
5959
blender --background --python car_mirror_symmetry.py --
6060

61+
# Falsifier: Mirror X off. Must exit non-zero (evaluated verts stay at n).
62+
blender --background --python car_mirror_symmetry.py -- --no-mirror
63+
6164
# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
6265
blender --background --python car_mirror_symmetry.py -- --output car.png
6366
blender --background --python car_mirror_symmetry.py -- --output car.png --engine cycles
6467
```
6568

66-
It exits non-zero on failure (applied mirror, doubled centerline, unwelded
67-
seam, broken symmetry, or a mirrored part off its plane origin). The
68-
`blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS.
69+
## Exit codes
70+
71+
Per-script sequential checks. `9` is a valid check code; there is no rule
72+
against it.
73+
74+
| Code | Meaning |
75+
| --- | --- |
76+
| 0 | Success |
77+
| 1 | Uncaught exception (FATAL wrapper) |
78+
| 2 | argparse / usage |
79+
| 3 | Body datablock is not the authored half |
80+
| 4 | Authored centerline vert count ≠ 28 |
81+
| 5 | Evaluated verts ≠ `2n − c` (`--no-mirror` lands here) |
82+
| 6 | Evaluated on-plane verts ≠ centerline; also `--output` produced no file |
83+
| 7 | Evaluated Euler characteristic ≠ 2 |
84+
| 8 | Non-manifold edges in the evaluated shell |
85+
| 9 | Evaluated verts lack a mirrored partner |
86+
| 10 | Mirror partner deviation above tolerance |
87+
| 11 | Evaluated bbox not symmetric about X |
88+
| 12 | Mirrored-part origin off the plane |
89+
| 13 | Mirrored-part datablock is not the authored half |
90+
| 14 | Mirrored-part evaluated counts did not double |
91+
| 15 | Mirrored-part partner check failed |
92+
| 16 | Mirrored-part evaluated mesh stayed on one side |
93+
94+
The `blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS
95+
(5.1 on the weekly cron, the `needs-5.1` PR label, or manual dispatch).
96+
Smoke does not pass `--output` or `--no-mirror`.

examples/car-mirror-symmetry/car_mirror_symmetry.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,15 @@
1010
and the wheels mirror about their object origins sitting ON the symmetry
1111
plane. Failure is dramatically visible: a car with one side missing.
1212
13+
``--no-mirror`` turns off the Mirror X axis on every mirrored object and
14+
still runs the evaluated-count check, so the half-car fails ``2n − c``.
15+
That is the falsifier (``--same-axis`` in export-preset-axis).
16+
1317
By default it runs only the correctness check (no render) — the CI smoke
1418
check. Pass --output to also render a still:
1519
1620
blender --background --python car_mirror_symmetry.py -- # check only
21+
blender --background --python car_mirror_symmetry.py -- --no-mirror # must fail
1722
blender --background --python car_mirror_symmetry.py -- --output c.png # + render
1823
"""
1924
import bpy, bmesh, sys, os, math, argparse
@@ -269,7 +274,13 @@ def _symmetry_dev(verts, tol_plane):
269274
return dev, lone
270275

271276

272-
def check(objs):
277+
def check(objs, no_mirror=False):
278+
if no_mirror:
279+
for ob in [objs["body"]] + [w for w, *_ in objs["mirrored"]]:
280+
for mod in ob.modifiers:
281+
if mod.type == 'MIRROR':
282+
mod.use_axis[0] = False
283+
273284
body = objs["body"]
274285
me = body.data
275286

@@ -502,10 +513,12 @@ def main():
502513
p.add_argument("--output", default=None, help="optional: render a still PNG here")
503514
p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"),
504515
help="render engine for --output (cycles for GPU-less hosts)")
516+
p.add_argument("--no-mirror", action="store_true",
517+
help="turn off Mirror X (must fail)")
505518
args = p.parse_args(argv)
506519

507520
objs = build_car()
508-
code = check(objs)
521+
code = check(objs, no_mirror=args.no_mirror)
509522
if code:
510523
return code
511524

examples/custom-normals-shade/README.md

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,12 +73,32 @@ strip light whose reflection exposes every normal discontinuity.
7373
# Cheap correctness check (no render) — the CI check:
7474
blender --background --python custom_normals_shade.py --
7575

76+
# Falsifier: mark sharp at 20° while auditing 30°. Must exit non-zero.
77+
blender --background --python custom_normals_shade.py -- --mismatch-angle
78+
7679
# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
7780
blender --background --python custom_normals_shade.py -- --output cans.png
7881
blender --background --python custom_normals_shade.py -- --output cans.png --engine cycles
7982
```
8083

81-
It exits non-zero on failure (legacy API resurrected, sharp-set/dihedral
82-
mismatch, broken normal welds, custom normals lost or dequantized in
83-
evaluation, or legacy-operator divergence drift). The `blender-smoke`
84-
workflow runs the check on Blender 5.2 LTS and 4.5 LTS.
84+
## Exit codes
85+
86+
Per-script sequential checks. `9` is a valid check code; there is no rule
87+
against it.
88+
89+
| Code | Meaning |
90+
| --- | --- |
91+
| 0 | Success |
92+
| 1 | Uncaught exception (FATAL wrapper) |
93+
| 2 | argparse / usage |
94+
| 3 | Legacy shading API present, or modern path missing |
95+
| 4 | Non-manifold edges (dihedral test undefined) |
96+
| 5 | Sharp set ≠ independent dihedral (`--mismatch-angle` lands here) |
97+
| 6 | Evaluated loop normals not welded/split as the sharp set promises |
98+
| 7 | Custom split normals lost or dequantized in evaluation |
99+
| 8 | `shade_auto_smooth` operator behavior drifted from the version split |
100+
| 9 | `--output` produced no file |
101+
102+
The `blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS
103+
(5.1 on the weekly cron, the `needs-5.1` PR label, or manual dispatch).
104+
Smoke does not pass `--output` or `--mismatch-angle`.

examples/custom-normals-shade/custom_normals_shade.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,16 @@
2626
that ignores the return set; on 5.1 it FINISHES and adds
2727
the NODES modifier. The portable path is the data API.
2828
29+
``--mismatch-angle`` marks sharp at 20° and still audits against the 30°
30+
dihedral set, so the sharp-set match fails. That is the falsifier
31+
(``--same-axis`` in export-preset-axis).
32+
2933
By default it runs only the correctness check (no render) — the CI smoke
3034
check. Pass --output to also render a still (the same can shaded flat /
3135
smooth-everywhere / by-angle, so a broken path reads as faceting or smear):
3236
3337
blender --background --python custom_normals_shade.py -- # check only
38+
blender --background --python custom_normals_shade.py -- --mismatch-angle
3439
blender --background --python custom_normals_shade.py -- --output c.png # + render
3540
"""
3641
import bpy, bmesh, sys, os, math, argparse
@@ -244,15 +249,16 @@ def check_api_surface(me):
244249
return 0
245250

246251

247-
def check_by_angle(objs):
252+
def check_by_angle(objs, mismatch_angle=False):
248253
"""set_sharp_from_angle must mark exactly the edges whose independently
249254
recomputed dihedral crosses the threshold — on every checked mesh."""
255+
mark = math.radians(20.0) if mismatch_angle else ANGLE
250256
total_sharp = total_manifold = 0
251257
for obj in objs:
252258
me = obj.data
253259
for p in me.polygons:
254260
p.use_smooth = True
255-
me.set_sharp_from_angle(angle=ANGLE)
261+
me.set_sharp_from_angle(angle=mark)
256262
dih, nonmanifold = manifold_dihedrals(me)
257263
if nonmanifold:
258264
print(f"ERROR: {obj.name}: {nonmanifold} non-manifold edge(s) — the "
@@ -557,13 +563,17 @@ def main():
557563
p.add_argument("--output", default=None, help="optional: render a still PNG here")
558564
p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"),
559565
help="render engine for --output (cycles for GPU-less hosts)")
566+
p.add_argument("--mismatch-angle", action="store_true",
567+
help="mark sharp at 20° while auditing 30° (must fail)")
560568
args = p.parse_args(argv)
561569

562570
bpy.ops.wm.read_factory_settings(use_empty=True)
563571
can = build_jerry_can()
564572

565573
for step in (lambda: check_api_surface(can["shell"].data),
566-
lambda: check_by_angle([can["shell"], can["rib"], can["neck"]]),
574+
lambda: check_by_angle(
575+
[can["shell"], can["rib"], can["neck"]],
576+
mismatch_angle=args.mismatch_angle),
567577
lambda: check_normal_welds(can["shell"]),
568578
lambda: check_custom_normals_roundtrip(can["shell"]),
569579
check_legacy_operator):

examples/gltf-export-roundtrip/README.md

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,44 @@ silhouette would lose the rounded edges.
5757
# Cheap correctness check (no render) — the CI check:
5858
blender --background --python gltf_export_roundtrip.py --
5959

60+
# Falsifier: export_yup=False. Must exit non-zero (bbox is Z-up on disk).
61+
blender --background --python gltf_export_roundtrip.py -- --no-yup
62+
6063
# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
6164
blender --background --python gltf_export_roundtrip.py -- --output crate.png
6265
blender --background --python gltf_export_roundtrip.py -- --output crate.png --engine cycles
6366
```
6467

65-
It exits non-zero on failure (RNA kwarg drift, cage drift, missing on-disk
66-
conversion, vertex-split drift, or any round-trip excursion beyond tolerance).
67-
The `blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS.
68+
## Exit codes
69+
70+
Per-script sequential checks. `9` is a valid check code; there is no rule
71+
against it.
72+
73+
| Code | Meaning |
74+
| --- | --- |
75+
| 0 | Success |
76+
| 1 | Uncaught exception (FATAL wrapper) |
77+
| 2 | argparse / usage |
78+
| 3 | Exporter/importer RNA kwargs drifted |
79+
| 4 | Base cage drifted from its closed form |
80+
| 5 | Authored UVs drifted from the box-map closed form |
81+
| 6 | Bevel produced no evaluated geometry |
82+
| 7 | On-disk node/mesh/generator contract drifted |
83+
| 8 | On-disk primitive/material binding count drifted |
84+
| 9 | On-disk POSITION bounds ≠ axis-converted bbox (`--no-yup` lands here) |
85+
| 10 | On-disk POSITION count ≠ evaluated loop count |
86+
| 11 | On-disk UV V-flip failed |
87+
| 12 | Re-import did not produce exactly one mesh |
88+
| 13 | Re-imported object carries a transform |
89+
| 14 | Material names drifted on re-import |
90+
| 15 | Re-import vert count ≠ evaluated loop count |
91+
| 16 | Round-trip position drift |
92+
| 17 | Round-trip normal drift |
93+
| 18 | Round-trip UV drift |
94+
| 19 | Re-import triangle count drifted |
95+
| 20 | Per-triangle material bindings drifted |
96+
| 21 | `--output` produced no file |
97+
98+
The `blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS
99+
(5.1 on the weekly cron, the `needs-5.1` PR label, or manual dispatch).
100+
Smoke does not pass `--output` or `--no-yup`.

examples/gltf-export-roundtrip/gltf_export_roundtrip.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@
88
data itself — (x, y, z) -> (x, z, -y) on disk — with no node rotation.
99
The check parses the exported .gltf JSON and asserts the POSITION accessor
1010
bounds equal the axis-converted evaluated bounding box, and that the node
11-
carries neither rotation nor scale. Exporting with ``export_yup=False``
12-
writes raw Z-up data that every engine will display lying on its back.
11+
carries neither rotation nor scale. ``--no-yup`` exports with
12+
``export_yup=False`` and still runs that bbox check, so the +Y-up
13+
conversion fails. That is the falsifier (``--same-axis`` in
14+
export-preset-axis). Exporting with ``export_yup=False`` writes raw Z-up
15+
data that every engine will display lying on its back.
1316
2. Modifiers ship evaluated geometry. ``export_apply=True`` applies the
1417
crate's bevel modifier: the re-imported mesh matches the
1518
depsgraph-evaluated mesh, not the base cage. With ``export_apply=False``
@@ -32,6 +35,7 @@
3235
check. Pass --output to also render a still:
3336
3437
blender --background --python gltf_export_roundtrip.py -- # check only
38+
blender --background --python gltf_export_roundtrip.py -- --no-yup # must fail
3539
blender --background --python gltf_export_roundtrip.py -- --output c.png # + render
3640
"""
3741
import bpy, bmesh, sys, os, math, json, struct, shutil, tempfile, argparse
@@ -244,7 +248,7 @@ def accessor_floats(idx, ncomp):
244248
# ---------------------------------------------------------------------------
245249
# The check. Distinct exit codes per contract; measured maxima printed on success.
246250
# ---------------------------------------------------------------------------
247-
def check(crate):
251+
def check(crate, export_kwargs):
248252
# contract 0 (version guard): every kwarg we rely on still exists.
249253
exp_props = {p.identifier for p in bpy.ops.export_scene.gltf.get_rna_type().properties}
250254
imp_props = {p.identifier for p in bpy.ops.import_scene.gltf.get_rna_type().properties}
@@ -285,7 +289,7 @@ def check(crate):
285289
tmp = tempfile.mkdtemp(prefix="gltf_roundtrip_")
286290
try:
287291
path = os.path.join(tmp, "crate.gltf").replace("\\", "/")
288-
bpy.ops.export_scene.gltf(filepath=path, **EXPORT_KWARGS)
292+
bpy.ops.export_scene.gltf(filepath=path, **export_kwargs)
289293

290294
# contract 1 (on disk): +Y-up is baked into vertex data, no node transform
291295
g, acc_floats = read_gltf(path)
@@ -592,13 +596,18 @@ def main():
592596
p.add_argument("--output", default=None, help="optional: render a still PNG here")
593597
p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"),
594598
help="render engine for --output (cycles for GPU-less hosts)")
599+
p.add_argument("--no-yup", action="store_true",
600+
help="export with export_yup=False (must fail)")
595601
args = p.parse_args(argv)
596602

597603
bpy.ops.wm.read_factory_settings(use_empty=True)
598604
crate = build_crate()
599605
for m in make_materials():
600606
crate.data.materials.append(m)
601-
code = check(crate)
607+
kwargs = dict(EXPORT_KWARGS)
608+
if args.no_yup:
609+
kwargs["export_yup"] = False
610+
code = check(crate, kwargs)
602611
if code:
603612
return code
604613

examples/gn-modifier-inputs/README.md

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@ Follows [`geometry-nodes-python`](../../skills/geometry-nodes-python/SKILL.md).
2222
# Cheap correctness check (no render) — the CI check:
2323
blender --background --python gn_modifier_inputs.py --
2424

25-
# Force one side of the split (must fail on the other series):
25+
# Portable falsifier: write 1.0 to every modifier. Must exit non-zero.
26+
blender --background --python gn_modifier_inputs.py -- --same-scale
27+
28+
# Force one side of the split (must fail on the other series, not all three):
2629
blender --background --python gn_modifier_inputs.py -- --api dict
2730
blender --background --python gn_modifier_inputs.py -- --api rna
2831

@@ -31,10 +34,30 @@ blender --background --python gn_modifier_inputs.py -- --output stairs.png
3134
blender --background --python gn_modifier_inputs.py -- --output stairs.png --engine cycles
3235
```
3336

34-
It exits non-zero on failure (missing identifier, write/read raise, readback
35-
mismatch, evaluated Z-extent ≠ scale, or three extents not distinct). The
36-
`blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS
37-
(5.1 on the weekly cron).
37+
## Exit codes
38+
39+
Per-script sequential checks. `9` is a valid check code; there is no rule
40+
against it. `10` is the shared framing helper.
41+
42+
| Code | Meaning |
43+
| --- | --- |
44+
| 0 | Success |
45+
| 1 | Uncaught exception (FATAL wrapper) |
46+
| 2 | argparse / usage |
47+
| 3 | Scale input identifier missing on the tree interface |
48+
| 4 | Modifiers do not share one node_group |
49+
| 5 | Version-path write raised (`--api dict` on 5.2, `--api rna` on 4.5) |
50+
| 6 | Version-path read raised |
51+
| 7 | Readback ≠ intended scale (`--same-scale` lands here) |
52+
| 8 | Evaluated Z-extent ≠ intended scale |
53+
| 9 | Evaluated mesh not sitting on z=0 |
54+
| 10 | Gallery framing violation |
55+
| 11 | Evaluated extents not distinct |
56+
| 12 | `--output` produced no file |
57+
58+
The `blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS
59+
(5.1 on the weekly cron, the `needs-5.1` PR label, or manual dispatch).
60+
Smoke does not pass `--output`, `--same-scale`, or `--api dict`/`rna`.
3861

3962
## Falsification
4063

examples/gn-modifier-inputs/gn_modifier_inputs.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,14 @@
1010
4.5 LTS and 5.1 write ``mod[identifier] = value``. 5.2+ removed ID
1111
properties on NodesModifier — that assignment raises TypeError — and
1212
the replacement is ``mod.properties.inputs.<identifier>.value``.
13-
``--api dict`` / ``--api rna`` force one side so the witness can fail
14-
on purpose.
13+
``--api dict`` / ``--api rna`` force one side of the 5.1/5.2 split — they
14+
fail on the *other* series, not on every binary. ``--same-scale`` writes
15+
1.0 to every modifier and still asserts 1 / 2 / 3, so the second cube's
16+
readback fails on all three. That is the portable falsifier
17+
(``--same-axis`` in export-preset-axis).
1518
1619
blender --background --python gn_modifier_inputs.py --
20+
blender --background --python gn_modifier_inputs.py -- --same-scale
1721
blender --background --python gn_modifier_inputs.py -- --api dict
1822
blender --background --python gn_modifier_inputs.py -- --output s.png
1923
"""
@@ -178,7 +182,7 @@ def evaluated_z_extent(obj):
178182
ev.to_mesh_clear()
179183

180184

181-
def check(tree, objs, mods, api):
185+
def check(tree, objs, mods, api, same_scale=False):
182186
ident = scale_identifier(tree)
183187
if not ident:
184188
print("ERROR: Scale input identifier missing on the tree interface",
@@ -191,8 +195,9 @@ def check(tree, objs, mods, api):
191195
return 4
192196

193197
for obj, mod, scale in zip(objs, mods, SCALES):
198+
written = SCALES[0] if same_scale else scale
194199
try:
195-
set_mod_input(mod, ident, scale, api)
200+
set_mod_input(mod, ident, written, api)
196201
except Exception as e:
197202
print(
198203
f"ERROR: {api} write of {scale} on {obj.name} raised "
@@ -368,11 +373,15 @@ def main():
368373
"--api", default="auto", choices=("auto", "dict", "rna"),
369374
help="force the 5.1 dict path, the 5.2 RNA path, or pick from bpy.app.version",
370375
)
376+
p.add_argument(
377+
"--same-scale", action="store_true",
378+
help="write 1.0 to every modifier (must fail)",
379+
)
371380
args = p.parse_args(argv)
372381

373382
tree, objs, mods = build()
374383
api = _api_choice(args.api)
375-
code = check(tree, objs, mods, api)
384+
code = check(tree, objs, mods, api, same_scale=args.same_scale)
376385
if code:
377386
return code
378387

0 commit comments

Comments
 (0)