Skip to content

Commit fdaf467

Browse files
feat: add CLI falsifiers and exit tables to ten mesh examples (#142)
Default smoke stays green. Each flag feeds degenerate input into an existing assertion. swatch-grid --no-verify was a skip-flag; it is gone. Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent cfc869f commit fdaf467

20 files changed

Lines changed: 359 additions & 70 deletions

File tree

‎examples/bmesh-gear/README.md‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,28 @@ faces). If an op leaks geometry or a face fails to close, the math catches it.
1717
# Cheap correctness check (no render) — the CI check:
1818
blender --background --python bmesh_gear.py --
1919

20+
# Falsifier: skip the extrude. Must exit non-zero (topology).
21+
blender --background --python bmesh_gear.py -- --no-extrude
22+
2023
# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
2124
blender --background --python bmesh_gear.py -- --output gear.png
2225
blender --background --python bmesh_gear.py -- --output gear.png --engine cycles
2326
```
2427

25-
It exits non-zero on failure (topology mismatch or non-manifold edges). The `blender-smoke`
26-
workflow runs the check on Blender 5.2 LTS and 4.5 LTS.
28+
## Exit codes
29+
30+
Per-script sequential checks. `9` is a valid check code; there is no rule
31+
against it.
32+
33+
| Code | Meaning |
34+
| --- | --- |
35+
| 0 | Success |
36+
| 1 | Uncaught exception (FATAL wrapper) |
37+
| 2 | argparse / usage |
38+
| 3 | Topology ≠ closed form (`--no-extrude` lands here) |
39+
| 4 | Non-manifold edges |
40+
| 6 | `--output` produced no file |
41+
42+
The `blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS
43+
(5.1 on the weekly cron, the `needs-5.1` PR label, or manual dispatch).
44+
Smoke does not pass `--output` or `--no-extrude`.

‎examples/bmesh-gear/bmesh_gear.py‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,15 @@
77
verts = 2 x (4 x teeth), faces = sides + 2 caps, edges = 3 x profile — and
88
that the mesh is watertight (every edge borders exactly 2 faces).
99
10+
``--no-extrude`` skips the face-region extrude and still runs the closed-form
11+
count check, so verts stay at one ring. That is the falsifier
12+
(``--same-axis`` in export-preset-axis).
13+
1014
By default it runs only the correctness check (no render) — the CI smoke
1115
check. Pass --output to also render a still:
1216
1317
blender --background --python bmesh_gear.py -- # check only
18+
blender --background --python bmesh_gear.py -- --no-extrude # must fail
1419
blender --background --python bmesh_gear.py -- --output g.png # + render
1520
"""
1621
import bpy, bmesh, sys, os, math, argparse
@@ -39,16 +44,17 @@ def gear_profile():
3944
return [(r * math.cos(a), r * math.sin(a), 0.0) for a, r in coords]
4045

4146

42-
def build_gear():
47+
def build_gear(no_extrude=False):
4348
bpy.ops.wm.read_factory_settings(use_empty=True)
4449
me = bpy.data.meshes.new("Gear")
4550
bm = bmesh.new()
4651
try:
4752
verts = [bm.verts.new(co) for co in gear_profile()]
4853
face = bm.faces.new(verts)
49-
ext = bmesh.ops.extrude_face_region(bm, geom=[face])
50-
top_verts = [e for e in ext["geom"] if isinstance(e, bmesh.types.BMVert)]
51-
bmesh.ops.translate(bm, verts=top_verts, vec=(0.0, 0.0, DEPTH))
54+
if not no_extrude:
55+
ext = bmesh.ops.extrude_face_region(bm, geom=[face])
56+
top_verts = [e for e in ext["geom"] if isinstance(e, bmesh.types.BMVert)]
57+
bmesh.ops.translate(bm, verts=top_verts, vec=(0.0, 0.0, DEPTH))
5258
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
5359
bm.to_mesh(me)
5460
finally:
@@ -193,9 +199,11 @@ def main():
193199
p.add_argument("--output", default=None, help="optional: render a still PNG here")
194200
p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"),
195201
help="render engine for --output (cycles for GPU-less hosts)")
202+
p.add_argument("--no-extrude", action="store_true",
203+
help="skip the face-region extrude (must fail)")
196204
args = p.parse_args(argv)
197205

198-
obj = build_gear()
206+
obj = build_gear(no_extrude=args.no_extrude)
199207
code = check(obj)
200208
if code:
201209
return code

‎examples/collision-hull-proxy/README.md‎

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,32 @@ through the shell in frame.
6161
# Cheap correctness check (no render) — the CI check:
6262
blender --background --python collision_hull_proxy.py --
6363

64+
# Falsifier: shrink hull vertices. Must exit non-zero (containment).
65+
blender --background --python collision_hull_proxy.py -- --shrink-hull
66+
6467
# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
6568
blender --background --python collision_hull_proxy.py -- --output hydrant.png
6669
blender --background --python collision_hull_proxy.py -- --output hydrant.png --engine cycles
6770
```
6871

69-
It exits non-zero on failure (render geometry escaping a hull, inverted
70-
winding, a non-watertight or non-convex piece, Euler drift, or a piece over
71-
the 255-face budget). The `blender-smoke` workflow runs the check on
72-
Blender 5.2 LTS and 4.5 LTS.
72+
## Exit codes
73+
74+
Per-script sequential checks. `9` is a valid check code; there is no rule
75+
against it.
76+
77+
| Code | Meaning |
78+
| --- | --- |
79+
| 0 | Success |
80+
| 1 | Uncaught exception (FATAL wrapper) |
81+
| 2 | argparse / usage |
82+
| 3 | Render vertex escapes its hull (`--shrink-hull` lands here) |
83+
| 4 | Hull edge does not border exactly two faces |
84+
| 5 | Signed volume ≤ 0 (inverted winding) |
85+
| 6 | Hull vertex off its own face plane |
86+
| 7 | Euler characteristic ≠ 2 |
87+
| 8 | Piece over the 255-face collision budget |
88+
| 9 | `--output` produced no file |
89+
90+
The `blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS
91+
(5.1 on the weekly cron, the `needs-5.1` PR label, or manual dispatch).
92+
Smoke does not pass `--output` or `--shrink-hull`.

‎examples/collision-hull-proxy/collision_hull_proxy.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,15 @@
2424
details (grooves) never touch the hull; proud details cost hull faces. That
2525
trade-off IS the collision-authoring lesson.
2626
27+
``--shrink-hull`` scales each hull's vertices by 0.5 and still runs the
28+
containment plane test, so render verts escape. That is the falsifier
29+
(``--same-axis`` in export-preset-axis).
30+
2731
By default it runs only the correctness check (no render) — the CI smoke
2832
check. Pass --output to also render a still:
2933
3034
blender --background --python collision_hull_proxy.py -- # check only
35+
blender --background --python collision_hull_proxy.py -- --shrink-hull # must fail
3136
blender --background --python collision_hull_proxy.py -- --output h.png # + render
3237
"""
3338
import bpy, bmesh, sys, os, math, argparse
@@ -485,12 +490,18 @@ def main():
485490
p.add_argument("--output", default=None, help="optional: render a still PNG here")
486491
p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"),
487492
help="render engine for --output (cycles for GPU-less hosts)")
493+
p.add_argument("--shrink-hull", action="store_true",
494+
help="scale hull vertices by 0.5 (must fail)")
488495
args = p.parse_args(argv)
489496

490497
groups = build_hydrant()
491498
pieces = []
492499
for g in groups:
493500
hull = build_hull(f"{g['name']}Hull", collect_points(g["cage"]))
501+
if args.shrink_hull:
502+
for v in hull.data.vertices:
503+
v.co *= 0.5
504+
hull.data.update()
494505
pieces.append((g["name"], hull, collect_points(g["render"])))
495506
code = check(pieces)
496507
if code:

‎examples/degenerate-bevel-weld/README.md‎

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,30 @@ broken state is in-frame by design.
4949

5050
```bash
5151
blender --background --python degenerate_bevel_weld.py --
52+
blender --background --python degenerate_bevel_weld.py -- --both-safe
5253
blender --background --python degenerate_bevel_weld.py -- --output bevel.png
5354
blender --background --python degenerate_bevel_weld.py -- --output bevel.png --engine cycles
5455
```
5556

56-
Exits non-zero on failure. The `blender-smoke` workflow runs the check on
57-
Blender 5.2 LTS and 4.5 LTS. The `--output` render path additionally measures
58-
framing against the Layer 1 band via `examples/gallery_framing.py` (exit 10
59-
on violation) before writing the still.
57+
## Exit codes
58+
59+
Per-script sequential checks. `9` is a valid check code; there is no rule
60+
against it. `10` is the shared framing helper.
61+
62+
| Code | Meaning |
63+
| --- | --- |
64+
| 0 | Success |
65+
| 1 | Uncaught exception (FATAL wrapper) |
66+
| 2 | argparse / usage |
67+
| 3 | Safe bevel produced zero-area faces |
68+
| 4 | Degenerate bevel zero-area count ≠ closed form (`--both-safe` lands here) |
69+
| 5 | min_area collapse under 1e5× |
70+
| 6 | Coincident-position count off the closed form |
71+
| 7 | Safe GLB carries degenerate triangles |
72+
| 8 | Degenerate GLB triangle or position count drifted |
73+
| 9 | `--output` produced no file |
74+
| 10 | Gallery framing violation |
75+
76+
The `blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS
77+
(5.1 on the weekly cron, the `needs-5.1` PR label, or manual dispatch).
78+
Smoke does not pass `--output` or `--both-safe`.

‎examples/degenerate-bevel-weld/degenerate_bevel_weld.py‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -150,10 +150,10 @@ def export_glb(ob, path):
150150
return path
151151

152152

153-
def check():
153+
def check(both_safe=False):
154154
tmp = tempfile.mkdtemp(prefix="bevelweld_")
155155
safe = beveled_box(DIMS, SAFE_OFFSET)
156-
degen = beveled_box(DIMS, DEGEN_OFFSET)
156+
degen = beveled_box(DIMS, SAFE_OFFSET if both_safe else DEGEN_OFFSET)
157157

158158
# --- 1. threshold: zero-area faces flip on at offset == half min dim ---
159159
safe_za = zero_area_count(safe)
@@ -391,11 +391,13 @@ def main():
391391
p.add_argument("--output", default=None, help="optional: render a still PNG here")
392392
p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"),
393393
help="render engine for --output (cycles for GPU-less hosts)")
394+
p.add_argument("--both-safe", action="store_true",
395+
help="bevel the degenerate box at the safe offset (must fail)")
394396
args = p.parse_args(argv)
395397

396398
print(f"binary version: {bpy.app.version} ({bpy.app.version_string})")
397399
bpy.ops.wm.read_factory_settings(use_empty=True)
398-
code = check()
400+
code = check(both_safe=args.both_safe)
399401
if code:
400402
return code
401403

‎examples/gn-instance-grid/README.md‎

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,31 @@ counts fail.
1919
# Cheap correctness check (no render) — the CI check:
2020
blender --background --python gn_instance_grid.py --
2121

22+
# Falsifier: 1×1 grid. Must exit non-zero (corner instance missing).
23+
blender --background --python gn_instance_grid.py -- --one-cell
24+
2225
# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
2326
blender --background --python gn_instance_grid.py -- --output grid.png
2427
blender --background --python gn_instance_grid.py -- --output grid.png --engine cycles
2528
```
2629

27-
It exits non-zero on failure (wrong carrier, topology mismatch, missing material, or
28-
misplaced corner). The `blender-smoke` workflow runs the check on Blender 5.2 LTS and
29-
4.5 LTS.
30+
## Exit codes
31+
32+
Per-script sequential checks. `9` is a valid check code; there is no rule
33+
against it.
34+
35+
| Code | Meaning |
36+
| --- | --- |
37+
| 0 | Success |
38+
| 1 | Uncaught exception (FATAL wrapper) |
39+
| 2 | argparse / usage |
40+
| 3 | Carrier vertex count ≠ 1 |
41+
| 4 | Corner instance vert count ≠ 8 (`--one-cell` lands here) |
42+
| 5 | Evaluated topology ≠ 72 verts / 54 faces |
43+
| 6 | Set Material did not carry Lime |
44+
| 7 | Corner instance center off the closed-form grid point |
45+
| 8 | `--output` produced no file |
46+
47+
The `blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS
48+
(5.1 on the weekly cron, the `needs-5.1` PR label, or manual dispatch).
49+
Smoke does not pass `--output` or `--one-cell`.

‎examples/gn-instance-grid/gn_instance_grid.py‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,15 @@
88
left as empty instance geometry, and that a corner instance sits at its
99
closed-form grid coordinate.
1010
11+
``--one-cell`` builds a 1x1 grid and still asserts 3x3 realized topology,
12+
so the corner instance is missing. That is the falsifier
13+
(``--same-axis`` in export-preset-axis).
14+
1115
By default it runs only the correctness check (no render) — the CI smoke
1216
check. Pass --output to also render a still:
1317
1418
blender --background --python gn_instance_grid.py -- # check only
19+
blender --background --python gn_instance_grid.py -- --one-cell # must fail
1520
blender --background --python gn_instance_grid.py -- --output g.png # + render
1621
"""
1722
import bpy, bmesh, sys, os, math, argparse
@@ -31,7 +36,7 @@
3136
CORNER_CENTER = (GRID_HALF, GRID_HALF, CUBE_SIZE / 2)
3237

3338

34-
def build_instance_grid_tree(material=None):
39+
def build_instance_grid_tree(material=None, grid_x=GRID_X, grid_y=GRID_Y):
3540
tree = bpy.data.node_groups.new("InstanceGrid", 'GeometryNodeTree')
3641
# generative: no Group Input — the tree owns the geometry
3742
tree.interface.new_socket(
@@ -42,8 +47,8 @@ def build_instance_grid_tree(material=None):
4247
grid = tree.nodes.new('GeometryNodeMeshGrid')
4348
grid.inputs["Size X"].default_value = GRID_SIZE
4449
grid.inputs["Size Y"].default_value = GRID_SIZE
45-
grid.inputs["Vertices X"].default_value = GRID_X
46-
grid.inputs["Vertices Y"].default_value = GRID_Y
50+
grid.inputs["Vertices X"].default_value = grid_x
51+
grid.inputs["Vertices Y"].default_value = grid_y
4752

4853
cube = tree.nodes.new('GeometryNodeMeshCube')
4954
cube.inputs["Size"].default_value = (CUBE_SIZE, CUBE_SIZE, CUBE_SIZE)
@@ -75,7 +80,7 @@ def build_instance_grid_tree(material=None):
7580
return tree
7681

7782

78-
def build():
83+
def build(one_cell=False):
7984
bpy.ops.wm.read_factory_settings(use_empty=True)
8085
# carrier mesh is unused by the generative tree; one vertex is enough
8186
me = bpy.data.meshes.new("Carrier")
@@ -89,7 +94,8 @@ def build():
8994
bsdf.inputs["Base Color"].default_value = (0.22, 0.95, 0.06, 1.0) # lime
9095
bsdf.inputs["Roughness"].default_value = 0.22
9196

92-
tree = build_instance_grid_tree(material=mat)
97+
gx = gy = 1 if one_cell else GRID_X
98+
tree = build_instance_grid_tree(material=mat, grid_x=gx, grid_y=gy)
9399
mod = obj.modifiers.new("instance_grid", 'NODES')
94100
mod.node_group = tree
95101
return obj, mat
@@ -246,9 +252,11 @@ def main():
246252
p.add_argument("--output", default=None, help="optional: render a still PNG here")
247253
p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"),
248254
help="render engine for --output (cycles for GPU-less hosts)")
255+
p.add_argument("--one-cell", action="store_true",
256+
help="instance a 1x1 grid (must fail)")
249257
args = p.parse_args(argv)
250258

251-
obj, _mat = build()
259+
obj, _mat = build(one_cell=args.one_cell)
252260
code = check(obj)
253261
if code:
254262
return code

‎examples/gn-sdf-remesh/README.md‎

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,29 @@ output — this example does that and asserts the material survives onto the eva
2222
# Cheap correctness check only (no render) — the CI smoke check:
2323
blender --background --python gn_sdf_remesh.py --
2424

25+
# Falsifier: no SDF modifier. Must exit non-zero (evaluated == base).
26+
blender --background --python gn_sdf_remesh.py -- --no-sdf
27+
2528
# Also render the remeshed result (EEVEE on a GPU host; --engine cycles on GPU-less hosts):
2629
blender --background --python gn_sdf_remesh.py -- --output remesh.png
2730
blender --background --python gn_sdf_remesh.py -- --output remesh.png --engine cycles
2831
```
2932

30-
By default it runs only the **frame-independent correctness check**: the depsgraph-evaluated
31-
vertex count must be > 0 AND differ from the base mesh (the remesh produced geometry). It
32-
exits non-zero on failure — the same check the `blender-smoke` workflow runs on Blender 5.2 LTS and
33-
4.5 LTS.
33+
## Exit codes
34+
35+
Per-script sequential checks. `9` is a valid check code; there is no rule
36+
against it.
37+
38+
| Code | Meaning |
39+
| --- | --- |
40+
| 0 | Success |
41+
| 1 | Uncaught exception (FATAL wrapper) |
42+
| 2 | argparse / usage |
43+
| 3 | SDF remesh produced no/unchanged geometry (`--no-sdf` lands here) |
44+
| 4 | `--output` produced no file |
45+
| 5 | Wrong-era EEVEE engine id was accepted |
46+
| 6 | Input material dropped by remesh |
47+
48+
The `blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS
49+
(5.1 on the weekly cron, the `needs-5.1` PR label, or manual dispatch).
50+
Smoke does not pass `--output` or `--no-sdf`.

‎examples/gn-sdf-remesh/gn_sdf_remesh.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
both builds.
1212
1313
blender --background --python gn_sdf_remesh.py -- # correctness check only
14+
blender --background --python gn_sdf_remesh.py -- --no-sdf # must fail
1415
blender --background --python gn_sdf_remesh.py -- --output r.png # also render the result
1516
blender --background --python gn_sdf_remesh.py -- --output r.png --engine cycles # GPU-less
1617
"""
@@ -108,6 +109,8 @@ def main():
108109
p = argparse.ArgumentParser()
109110
p.add_argument("--output", default=None, help="optional: render the remeshed result to this PNG")
110111
p.add_argument("--engine", choices=["auto", "cycles"], default="auto")
112+
p.add_argument("--no-sdf", action="store_true",
113+
help="skip attaching the SDF remesh modifier (must fail)")
111114
args = p.parse_args(argv)
112115

113116
# EEVEE-id inversion witnessed for real: the OTHER era's id must be
@@ -125,7 +128,8 @@ def main():
125128
base = len(obj.data.vertices)
126129
src_mat = obj.data.materials[0] if obj.data.materials else None
127130
tree, link_valid = build_remesh_via_sdf(material=src_mat)
128-
obj.modifiers.new("sdf", 'NODES').node_group = tree
131+
if not args.no_sdf:
132+
obj.modifiers.new("sdf", 'NODES').node_group = tree
129133
dg = bpy.context.evaluated_depsgraph_get(); ev = obj.evaluated_get(dg)
130134
m = ev.to_mesh(); evc = len(m.vertices)
131135
mat_names = [mm.name for mm in m.materials if mm is not None]

0 commit comments

Comments
 (0)