diff --git a/examples/bmesh-gear/README.md b/examples/bmesh-gear/README.md index 7347de1..851c147 100644 --- a/examples/bmesh-gear/README.md +++ b/examples/bmesh-gear/README.md @@ -17,10 +17,28 @@ faces). If an op leaks geometry or a face fails to close, the math catches it. # Cheap correctness check (no render) — the CI check: blender --background --python bmesh_gear.py -- +# Falsifier: skip the extrude. Must exit non-zero (topology). +blender --background --python bmesh_gear.py -- --no-extrude + # Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts): blender --background --python bmesh_gear.py -- --output gear.png blender --background --python bmesh_gear.py -- --output gear.png --engine cycles ``` -It exits non-zero on failure (topology mismatch or non-manifold edges). The `blender-smoke` -workflow runs the check on Blender 5.2 LTS and 4.5 LTS. +## Exit codes + +Per-script sequential checks. `9` is a valid check code; there is no rule +against it. + +| Code | Meaning | +| --- | --- | +| 0 | Success | +| 1 | Uncaught exception (FATAL wrapper) | +| 2 | argparse / usage | +| 3 | Topology ≠ closed form (`--no-extrude` lands here) | +| 4 | Non-manifold edges | +| 6 | `--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-extrude`. diff --git a/examples/bmesh-gear/bmesh_gear.py b/examples/bmesh-gear/bmesh_gear.py index 3c17888..cfcc2da 100644 --- a/examples/bmesh-gear/bmesh_gear.py +++ b/examples/bmesh-gear/bmesh_gear.py @@ -7,10 +7,15 @@ verts = 2 x (4 x teeth), faces = sides + 2 caps, edges = 3 x profile — and that the mesh is watertight (every edge borders exactly 2 faces). +``--no-extrude`` skips the face-region extrude and still runs the closed-form +count check, so verts stay at one ring. 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 bmesh_gear.py -- # check only + blender --background --python bmesh_gear.py -- --no-extrude # must fail blender --background --python bmesh_gear.py -- --output g.png # + render """ import bpy, bmesh, sys, os, math, argparse @@ -39,16 +44,17 @@ def gear_profile(): return [(r * math.cos(a), r * math.sin(a), 0.0) for a, r in coords] -def build_gear(): +def build_gear(no_extrude=False): bpy.ops.wm.read_factory_settings(use_empty=True) me = bpy.data.meshes.new("Gear") bm = bmesh.new() try: verts = [bm.verts.new(co) for co in gear_profile()] face = bm.faces.new(verts) - ext = bmesh.ops.extrude_face_region(bm, geom=[face]) - top_verts = [e for e in ext["geom"] if isinstance(e, bmesh.types.BMVert)] - bmesh.ops.translate(bm, verts=top_verts, vec=(0.0, 0.0, DEPTH)) + if not no_extrude: + ext = bmesh.ops.extrude_face_region(bm, geom=[face]) + top_verts = [e for e in ext["geom"] if isinstance(e, bmesh.types.BMVert)] + bmesh.ops.translate(bm, verts=top_verts, vec=(0.0, 0.0, DEPTH)) bmesh.ops.recalc_face_normals(bm, faces=bm.faces) bm.to_mesh(me) finally: @@ -193,9 +199,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("--no-extrude", action="store_true", + help="skip the face-region extrude (must fail)") args = p.parse_args(argv) - obj = build_gear() + obj = build_gear(no_extrude=args.no_extrude) code = check(obj) if code: return code diff --git a/examples/collision-hull-proxy/README.md b/examples/collision-hull-proxy/README.md index 003af6e..43d7716 100644 --- a/examples/collision-hull-proxy/README.md +++ b/examples/collision-hull-proxy/README.md @@ -61,12 +61,32 @@ through the shell in frame. # Cheap correctness check (no render) — the CI check: blender --background --python collision_hull_proxy.py -- +# Falsifier: shrink hull vertices. Must exit non-zero (containment). +blender --background --python collision_hull_proxy.py -- --shrink-hull + # Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts): blender --background --python collision_hull_proxy.py -- --output hydrant.png blender --background --python collision_hull_proxy.py -- --output hydrant.png --engine cycles ``` -It exits non-zero on failure (render geometry escaping a hull, inverted -winding, a non-watertight or non-convex piece, Euler drift, or a piece over -the 255-face budget). 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 | Render vertex escapes its hull (`--shrink-hull` lands here) | +| 4 | Hull edge does not border exactly two faces | +| 5 | Signed volume ≤ 0 (inverted winding) | +| 6 | Hull vertex off its own face plane | +| 7 | Euler characteristic ≠ 2 | +| 8 | Piece over the 255-face collision budget | +| 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 `--shrink-hull`. diff --git a/examples/collision-hull-proxy/collision_hull_proxy.py b/examples/collision-hull-proxy/collision_hull_proxy.py index 5b27568..f085aff 100644 --- a/examples/collision-hull-proxy/collision_hull_proxy.py +++ b/examples/collision-hull-proxy/collision_hull_proxy.py @@ -24,10 +24,15 @@ details (grooves) never touch the hull; proud details cost hull faces. That trade-off IS the collision-authoring lesson. +``--shrink-hull`` scales each hull's vertices by 0.5 and still runs the +containment plane test, so render verts escape. 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 collision_hull_proxy.py -- # check only + blender --background --python collision_hull_proxy.py -- --shrink-hull # must fail blender --background --python collision_hull_proxy.py -- --output h.png # + render """ import bpy, bmesh, sys, os, math, argparse @@ -485,12 +490,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("--shrink-hull", action="store_true", + help="scale hull vertices by 0.5 (must fail)") args = p.parse_args(argv) groups = build_hydrant() pieces = [] for g in groups: hull = build_hull(f"{g['name']}Hull", collect_points(g["cage"])) + if args.shrink_hull: + for v in hull.data.vertices: + v.co *= 0.5 + hull.data.update() pieces.append((g["name"], hull, collect_points(g["render"]))) code = check(pieces) if code: diff --git a/examples/degenerate-bevel-weld/README.md b/examples/degenerate-bevel-weld/README.md index 398853a..15cd9a8 100644 --- a/examples/degenerate-bevel-weld/README.md +++ b/examples/degenerate-bevel-weld/README.md @@ -49,11 +49,30 @@ broken state is in-frame by design. ```bash blender --background --python degenerate_bevel_weld.py -- +blender --background --python degenerate_bevel_weld.py -- --both-safe blender --background --python degenerate_bevel_weld.py -- --output bevel.png blender --background --python degenerate_bevel_weld.py -- --output bevel.png --engine cycles ``` -Exits non-zero on failure. The `blender-smoke` workflow runs the check on -Blender 5.2 LTS and 4.5 LTS. The `--output` render path additionally measures -framing against the Layer 1 band via `examples/gallery_framing.py` (exit 10 -on violation) before writing the still. +## Exit codes + +Per-script sequential checks. `9` is a valid check code; there is no rule +against it. `10` is the shared framing helper. + +| Code | Meaning | +| --- | --- | +| 0 | Success | +| 1 | Uncaught exception (FATAL wrapper) | +| 2 | argparse / usage | +| 3 | Safe bevel produced zero-area faces | +| 4 | Degenerate bevel zero-area count ≠ closed form (`--both-safe` lands here) | +| 5 | min_area collapse under 1e5× | +| 6 | Coincident-position count off the closed form | +| 7 | Safe GLB carries degenerate triangles | +| 8 | Degenerate GLB triangle or position count drifted | +| 9 | `--output` produced no file | +| 10 | Gallery framing violation | + +The `blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS +(5.1 on the weekly cron, the `needs-5.1` PR label, or manual dispatch). +Smoke does not pass `--output` or `--both-safe`. diff --git a/examples/degenerate-bevel-weld/degenerate_bevel_weld.py b/examples/degenerate-bevel-weld/degenerate_bevel_weld.py index cfd6d78..66288aa 100644 --- a/examples/degenerate-bevel-weld/degenerate_bevel_weld.py +++ b/examples/degenerate-bevel-weld/degenerate_bevel_weld.py @@ -150,10 +150,10 @@ def export_glb(ob, path): return path -def check(): +def check(both_safe=False): tmp = tempfile.mkdtemp(prefix="bevelweld_") safe = beveled_box(DIMS, SAFE_OFFSET) - degen = beveled_box(DIMS, DEGEN_OFFSET) + degen = beveled_box(DIMS, SAFE_OFFSET if both_safe else DEGEN_OFFSET) # --- 1. threshold: zero-area faces flip on at offset == half min dim --- safe_za = zero_area_count(safe) @@ -391,11 +391,13 @@ def main(): p.add_argument("--output", default=None, help="optional: render a still PNG here") p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"), help="render engine for --output (cycles for GPU-less hosts)") + p.add_argument("--both-safe", action="store_true", + help="bevel the degenerate box at the safe offset (must fail)") args = p.parse_args(argv) print(f"binary version: {bpy.app.version} ({bpy.app.version_string})") bpy.ops.wm.read_factory_settings(use_empty=True) - code = check() + code = check(both_safe=args.both_safe) if code: return code diff --git a/examples/gn-instance-grid/README.md b/examples/gn-instance-grid/README.md index 9038413..8e54115 100644 --- a/examples/gn-instance-grid/README.md +++ b/examples/gn-instance-grid/README.md @@ -19,11 +19,31 @@ counts fail. # Cheap correctness check (no render) — the CI check: blender --background --python gn_instance_grid.py -- +# Falsifier: 1×1 grid. Must exit non-zero (corner instance missing). +blender --background --python gn_instance_grid.py -- --one-cell + # Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts): blender --background --python gn_instance_grid.py -- --output grid.png blender --background --python gn_instance_grid.py -- --output grid.png --engine cycles ``` -It exits non-zero on failure (wrong carrier, topology mismatch, missing material, or -misplaced corner). 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 | Carrier vertex count ≠ 1 | +| 4 | Corner instance vert count ≠ 8 (`--one-cell` lands here) | +| 5 | Evaluated topology ≠ 72 verts / 54 faces | +| 6 | Set Material did not carry Lime | +| 7 | Corner instance center off the closed-form grid point | +| 8 | `--output` produced no file | + +The `blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS +(5.1 on the weekly cron, the `needs-5.1` PR label, or manual dispatch). +Smoke does not pass `--output` or `--one-cell`. diff --git a/examples/gn-instance-grid/gn_instance_grid.py b/examples/gn-instance-grid/gn_instance_grid.py index 8897932..ba9ffa2 100644 --- a/examples/gn-instance-grid/gn_instance_grid.py +++ b/examples/gn-instance-grid/gn_instance_grid.py @@ -8,10 +8,15 @@ left as empty instance geometry, and that a corner instance sits at its closed-form grid coordinate. +``--one-cell`` builds a 1x1 grid and still asserts 3x3 realized topology, +so the corner instance is missing. 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 gn_instance_grid.py -- # check only + blender --background --python gn_instance_grid.py -- --one-cell # must fail blender --background --python gn_instance_grid.py -- --output g.png # + render """ import bpy, bmesh, sys, os, math, argparse @@ -31,7 +36,7 @@ CORNER_CENTER = (GRID_HALF, GRID_HALF, CUBE_SIZE / 2) -def build_instance_grid_tree(material=None): +def build_instance_grid_tree(material=None, grid_x=GRID_X, grid_y=GRID_Y): tree = bpy.data.node_groups.new("InstanceGrid", 'GeometryNodeTree') # generative: no Group Input — the tree owns the geometry tree.interface.new_socket( @@ -42,8 +47,8 @@ def build_instance_grid_tree(material=None): grid = tree.nodes.new('GeometryNodeMeshGrid') grid.inputs["Size X"].default_value = GRID_SIZE grid.inputs["Size Y"].default_value = GRID_SIZE - grid.inputs["Vertices X"].default_value = GRID_X - grid.inputs["Vertices Y"].default_value = GRID_Y + grid.inputs["Vertices X"].default_value = grid_x + grid.inputs["Vertices Y"].default_value = grid_y cube = tree.nodes.new('GeometryNodeMeshCube') cube.inputs["Size"].default_value = (CUBE_SIZE, CUBE_SIZE, CUBE_SIZE) @@ -75,7 +80,7 @@ def build_instance_grid_tree(material=None): return tree -def build(): +def build(one_cell=False): bpy.ops.wm.read_factory_settings(use_empty=True) # carrier mesh is unused by the generative tree; one vertex is enough me = bpy.data.meshes.new("Carrier") @@ -89,7 +94,8 @@ def build(): bsdf.inputs["Base Color"].default_value = (0.22, 0.95, 0.06, 1.0) # lime bsdf.inputs["Roughness"].default_value = 0.22 - tree = build_instance_grid_tree(material=mat) + gx = gy = 1 if one_cell else GRID_X + tree = build_instance_grid_tree(material=mat, grid_x=gx, grid_y=gy) mod = obj.modifiers.new("instance_grid", 'NODES') mod.node_group = tree return obj, mat @@ -246,9 +252,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("--one-cell", action="store_true", + help="instance a 1x1 grid (must fail)") args = p.parse_args(argv) - obj, _mat = build() + obj, _mat = build(one_cell=args.one_cell) code = check(obj) if code: return code diff --git a/examples/gn-sdf-remesh/README.md b/examples/gn-sdf-remesh/README.md index df8e6da..98d4b99 100644 --- a/examples/gn-sdf-remesh/README.md +++ b/examples/gn-sdf-remesh/README.md @@ -22,12 +22,29 @@ output — this example does that and asserts the material survives onto the eva # Cheap correctness check only (no render) — the CI smoke check: blender --background --python gn_sdf_remesh.py -- +# Falsifier: no SDF modifier. Must exit non-zero (evaluated == base). +blender --background --python gn_sdf_remesh.py -- --no-sdf + # Also render the remeshed result (EEVEE on a GPU host; --engine cycles on GPU-less hosts): blender --background --python gn_sdf_remesh.py -- --output remesh.png blender --background --python gn_sdf_remesh.py -- --output remesh.png --engine cycles ``` -By default it runs only the **frame-independent correctness check**: the depsgraph-evaluated -vertex count must be > 0 AND differ from the base mesh (the remesh produced geometry). It -exits non-zero on failure — the same check the `blender-smoke` workflow runs 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 | SDF remesh produced no/unchanged geometry (`--no-sdf` lands here) | +| 4 | `--output` produced no file | +| 5 | Wrong-era EEVEE engine id was accepted | +| 6 | Input material dropped by remesh | + +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-sdf`. diff --git a/examples/gn-sdf-remesh/gn_sdf_remesh.py b/examples/gn-sdf-remesh/gn_sdf_remesh.py index 6ab360a..d811da5 100644 --- a/examples/gn-sdf-remesh/gn_sdf_remesh.py +++ b/examples/gn-sdf-remesh/gn_sdf_remesh.py @@ -11,6 +11,7 @@ both builds. blender --background --python gn_sdf_remesh.py -- # correctness check only + blender --background --python gn_sdf_remesh.py -- --no-sdf # must fail blender --background --python gn_sdf_remesh.py -- --output r.png # also render the result blender --background --python gn_sdf_remesh.py -- --output r.png --engine cycles # GPU-less """ @@ -108,6 +109,8 @@ def main(): p = argparse.ArgumentParser() p.add_argument("--output", default=None, help="optional: render the remeshed result to this PNG") p.add_argument("--engine", choices=["auto", "cycles"], default="auto") + p.add_argument("--no-sdf", action="store_true", + help="skip attaching the SDF remesh modifier (must fail)") args = p.parse_args(argv) # EEVEE-id inversion witnessed for real: the OTHER era's id must be @@ -125,7 +128,8 @@ def main(): base = len(obj.data.vertices) src_mat = obj.data.materials[0] if obj.data.materials else None tree, link_valid = build_remesh_via_sdf(material=src_mat) - obj.modifiers.new("sdf", 'NODES').node_group = tree + if not args.no_sdf: + obj.modifiers.new("sdf", 'NODES').node_group = tree dg = bpy.context.evaluated_depsgraph_get(); ev = obj.evaluated_get(dg) m = ev.to_mesh(); evc = len(m.vertices) mat_names = [mm.name for mm in m.materials if mm is not None] diff --git a/examples/mesh-hygiene-audit/README.md b/examples/mesh-hygiene-audit/README.md index 98670f5..2ab4ea2 100644 --- a/examples/mesh-hygiene-audit/README.md +++ b/examples/mesh-hygiene-audit/README.md @@ -49,11 +49,30 @@ thumbnail scale without faking annotation. ```bash blender --background --python mesh_hygiene_audit.py -- +blender --background --python mesh_hygiene_audit.py -- --inject-ngon blender --background --python mesh_hygiene_audit.py -- --output hygiene.png blender --background --python mesh_hygiene_audit.py -- --output hygiene.png --engine cycles ``` -Exits non-zero on failure. The `blender-smoke` workflow runs the check on -Blender 5.2 LTS and 4.5 LTS. The `--output` render path additionally measures -framing against the Layer 1 band via `examples/gallery_framing.py` (exit 10 -on violation) before writing the still. +## Exit codes + +Per-script sequential checks. `9` is a valid check code; there is no rule +against it. `10` is the shared framing helper. + +| Code | Meaning | +| --- | --- | +| 0 | Success | +| 1 | Uncaught exception (FATAL wrapper) | +| 2 | argparse / usage | +| 3 | Ngon present (`--inject-ngon` lands here) | +| 4 | Loose vertices | +| 5 | Non-manifold or boundary edges | +| 6 | Zero-area faces | +| 7 | Signed volume ≤ 0 | +| 8 | Euler characteristic ≠ 2 | +| 9 | `--output` produced no file | +| 10 | Gallery framing violation | + +The `blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS +(5.1 on the weekly cron, the `needs-5.1` PR label, or manual dispatch). +Smoke does not pass `--output` or `--inject-ngon`. diff --git a/examples/mesh-hygiene-audit/mesh_hygiene_audit.py b/examples/mesh-hygiene-audit/mesh_hygiene_audit.py index a31268f..ba9d6e4 100644 --- a/examples/mesh-hygiene-audit/mesh_hygiene_audit.py +++ b/examples/mesh-hygiene-audit/mesh_hygiene_audit.py @@ -15,6 +15,7 @@ to also render a still: blender --background --python mesh_hygiene_audit.py -- + blender --background --python mesh_hygiene_audit.py -- --inject-ngon blender --background --python mesh_hygiene_audit.py -- --output h.png """ import bpy, bmesh, sys, os, math, argparse @@ -577,10 +578,14 @@ def main(): "--engine", default="eevee", choices=("eevee", "cycles"), help="render engine for --output", ) + p.add_argument("--inject-ngon", action="store_true", + help="dissolve one edge into an ngon (must fail)") args = p.parse_args(argv) print(f"binary version: {bpy.app.version} ({bpy.app.version_string})") sc, ob = build_scene() + if args.inject_ngon: + inject_defect(ob.data, "ngon") code = check(ob.data) if code: return code diff --git a/examples/swatch-grid/README.md b/examples/swatch-grid/README.md index 28778c0..c8cba9f 100644 --- a/examples/swatch-grid/README.md +++ b/examples/swatch-grid/README.md @@ -23,6 +23,9 @@ that mapping fails the example, not just the docs. # Cheap correctness check (materials + engine-id witness, no render): blender --background --python swatch_grid.py -- +# Falsifier: same RGB on every swatch. Must exit non-zero. +blender --background --python swatch_grid.py -- --same-base + # Render and pixel-verify with the build's EEVEE engine (needs a GPU/display): blender --background --python swatch_grid.py -- --output swatch.png @@ -31,10 +34,27 @@ blender --background --python swatch_grid.py -- --output swatch.png blender --background --python swatch_grid.py -- --output swatch.png --engine cycles --samples 16 --width 960 ``` -The script is deterministic and dependency-light (fixed camera and layout, no HDRI, no -network). It **exits non-zero** on any failure, including a render that comes out uniformly -black or without the expected six distinct swatch regions — the same honest check the CI -smoke gate runs on Blender 5.2 LTS and 4.5 LTS. +## Exit codes + +Per-script sequential checks. `9` is a valid check code; there is no rule +against it. `10` is the shared framing helper. + +| Code | Meaning | +| --- | --- | +| 0 | Success | +| 1 | Uncaught exception (FATAL wrapper) | +| 2 | argparse / usage | +| 3 | Distinct swatch colors ≠ 6 (`--same-base` lands here); also render not six distinct regions | +| 4 | `--output` produced no file | +| 5 | Wrong-era EEVEE engine id was accepted | +| 10 | Gallery framing violation | + +`--no-verify` was a skip-flag and has been removed. Pixel verification always +runs when `--output` is passed. + +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 `--same-base`. ## Verified diff --git a/examples/swatch-grid/swatch_grid.py b/examples/swatch-grid/swatch_grid.py index a1997d5..80e65d6 100644 --- a/examples/swatch-grid/swatch_grid.py +++ b/examples/swatch-grid/swatch_grid.py @@ -8,9 +8,12 @@ on 4.2-4.5, and the chosen id is asserted against the build before rendering. By default it runs only the correctness check (no render) — the CI smoke check. -Pass --output to also render and pixel-verify a still: +Pass --output to also render and pixel-verify a still. ``--same-base`` writes +the same RGB to every swatch and still asserts six distinct colors, so the +count fails. That is the falsifier (``--same-axis`` in export-preset-axis). blender --background --python swatch_grid.py -- # check only + blender --background --python swatch_grid.py -- --same-base # must fail blender --background --python swatch_grid.py -- --output swatch.png blender --background --python swatch_grid.py -- --output s.png --engine cycles --samples 8 --width 640 @@ -183,6 +186,39 @@ def build_scene(mats): bpy.context.scene.world = world +def swatch_rgb(mat): + for node in mat.node_tree.nodes: + if node.type == "BSDF_PRINCIPLED": + c = node.inputs["Base Color"].default_value + return (round(c[0], 4), round(c[1], 4), round(c[2], 4)) + if node.type == "EMISSION": + c = node.inputs["Color"].default_value + return (round(c[0], 4), round(c[1], 4), round(c[2], 4)) + return None + + +def flatten_swatch_colors(mats): + gray = (0.5, 0.5, 0.5, 1.0) + for mat in mats: + for node in mat.node_tree.nodes: + if node.type == "BSDF_PRINCIPLED": + node.inputs["Base Color"].default_value = gray + elif node.type == "EMISSION": + node.inputs["Color"].default_value = gray + + +def check_distinct_swatches(mats): + colors = [swatch_rgb(m) for m in mats] + if len(set(colors)) != MATERIAL_COUNT: + print( + f"ERROR: distinct swatch colors {len(set(colors))} != " + f"{MATERIAL_COUNT} (got {colors})", + file=sys.stderr, + ) + return 3 + return 0 + + def verify_png(path): """Honest capture: not uniformly black AND distinct swatch regions == MATERIAL_COUNT.""" img = bpy.data.images.load(path) @@ -210,12 +246,18 @@ def main(): help="auto/eevee use the version-correct EEVEE id; cycles for GPU-less hosts") p.add_argument("--samples", type=int, default=32) p.add_argument("--width", type=int, default=1280) - p.add_argument("--no-verify", action="store_true") + p.add_argument("--same-base", action="store_true", + help="write the same RGB to every swatch (must fail)") args = p.parse_args(argv) # Empty the factory file FIRST so the materials we create below survive. bpy.ops.wm.read_factory_settings(use_empty=True) mats, specular_socket = build_materials() + if args.same_base: + flatten_swatch_colors(mats) + dcode = check_distinct_swatches(mats) + if dcode: + return dcode build_scene(mats) sc = bpy.context.scene @@ -271,15 +313,14 @@ def main(): return 4 print(f"rendered {args.output} with {render_engine} ({os.path.getsize(args.output)} bytes)") - if not args.no_verify: - gmax, regions = verify_png(args.output) - non_black = gmax > 0.05 - regions_ok = regions == MATERIAL_COUNT - print(f"verify: max_pixel={gmax:.3f} non_black={non_black} " - f"distinct_regions={regions} materials={MATERIAL_COUNT} ok={regions_ok}") - if not (non_black and regions_ok): - print("ERROR: render failed verification (black or wrong region count)", file=sys.stderr) - return 3 + gmax, regions = verify_png(args.output) + non_black = gmax > 0.05 + regions_ok = regions == MATERIAL_COUNT + print(f"verify: max_pixel={gmax:.3f} non_black={non_black} " + f"distinct_regions={regions} materials={MATERIAL_COUNT} ok={regions_ok}") + if not (non_black and regions_ok): + print("ERROR: render failed verification (black or wrong region count)", file=sys.stderr) + return 3 print("swatch-grid OK") return 0 diff --git a/examples/triangulate-tangents/README.md b/examples/triangulate-tangents/README.md index d03f748..4ef654f 100644 --- a/examples/triangulate-tangents/README.md +++ b/examples/triangulate-tangents/README.md @@ -59,14 +59,32 @@ made visible. # Cheap correctness check (no render) — the CI check: blender --background --python triangulate_tangents.py -- +# Falsifier: every UV at (0, 0). Must exit non-zero (authored UV closed form). +blender --background --python triangulate_tangents.py -- --zero-uv + # Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts): blender --background --python triangulate_tangents.py -- --output buckler.png blender --background --python triangulate_tangents.py -- --output buckler.png --engine cycles ``` -It exits non-zero on failure (topology drift, reallocated UV layer, -non-orthonormal basis, bitangent-convention drift, formula excursion, or a -flip inside a smooth field). The `blender-smoke` workflow runs the check on -Blender 5.2 LTS and 4.5 LTS. +## Exit codes + +Per-script sequential checks. `9` is a valid check code; there is no rule +against it. `10` is the shared framing helper. + +| Code | Meaning | +| --- | --- | +| 0 | Success | +| 1 | Uncaught exception (FATAL wrapper) | +| 2 | argparse / usage | +| 3 | Loop-triangle count ≠ closed form | +| 4 | Re-fetched UV layer drifted from the polar field (`--zero-uv` lands here) | +| 5 | Tangent basis not orthonormal | +| 6 | Bitangent ≠ sign × (n × t) | +| 7 | Tangents deviate from the edge/UV closed form | +| 8 | Flipped tangent inside a clean triangle | +| 10 | Gallery framing violation; also `--output` produced no file | -The `--output` render path additionally measures framing against the Layer 1 band via `examples/gallery_framing.py` (exit 10 on violation) before writing the still. +The `blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS +(5.1 on the weekly cron, the `needs-5.1` PR label, or manual dispatch). +Smoke does not pass `--output` or `--zero-uv`. diff --git a/examples/triangulate-tangents/triangulate_tangents.py b/examples/triangulate-tangents/triangulate_tangents.py index c91af01..6072800 100644 --- a/examples/triangulate-tangents/triangulate_tangents.py +++ b/examples/triangulate-tangents/triangulate_tangents.py @@ -31,6 +31,7 @@ check. Pass --output to also render a still: blender --background --python triangulate_tangents.py -- # check only + blender --background --python triangulate_tangents.py -- --zero-uv # must fail blender --background --python triangulate_tangents.py -- --output s.png # + render """ import bpy, bmesh, sys, os, math, argparse @@ -428,10 +429,16 @@ def main(): p.add_argument("--output", default=None, help="optional: render a still PNG here") p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"), help="render engine for --output (cycles for GPU-less hosts)") + p.add_argument("--zero-uv", action="store_true", + help="write every UV to (0, 0) (must fail)") args = p.parse_args(argv) bpy.ops.wm.read_factory_settings(use_empty=True) obj, n_dome = build_buckler() + if args.zero_uv: + uv = obj.data.uv_layers["UVMap"] + for loop in uv.data: + loop.uv = (0.0, 0.0) code = check(obj, n_dome) if code: return code diff --git a/examples/uv-layer-grid/README.md b/examples/uv-layer-grid/README.md index b9b5741..1200314 100644 --- a/examples/uv-layer-grid/README.md +++ b/examples/uv-layer-grid/README.md @@ -62,11 +62,38 @@ the pixels prove the flat-vs-checker split (measured on 5.1.2: hazard spread # Cheap correctness check (no render) — the CI check: blender --background --python uv_layer_grid.py -- +# Falsifier: pre-create a UV layer on the silent-no-op probe. Must exit non-zero. +blender --background --python uv_layer_grid.py -- --precreate-on-hazard + # Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts): blender --background --python uv_layer_grid.py -- --output uv.png blender --background --python uv_layer_grid.py -- --output uv.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. +## Exit codes + +Per-script sequential checks. `9` is a valid check code; there is no rule +against it. This example does not call the gallery framing helper; `10` +is the missing-render-file check. + +| Code | Meaning | +| --- | --- | +| 0 | Success | +| 1 | Uncaught exception (FATAL wrapper) | +| 2 | argparse / usage | +| 3 | Silent-no-op hazard gone (`--precreate-on-hazard` lands here) | +| 4 | Grid topology drifted | +| 5 | Pre-create + calc_uvs did not persist one UV layer | +| 6 | calc_uvs closed-form error | +| 7 | Mesh UV round-trip error | +| 8 | calc_uvs=False unexpectedly created a UV layer | +| 9 | Explicit UV assignment error | +| 10 | `--output` produced no file | +| 11 | Broken panel is not flat | +| 12 | Broken panel is not texel-(0,0) teal | +| 13 | Repaired panel is not a checker | +| 14 | Broken and repaired panels render identically | + +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 `--precreate-on-hazard`. diff --git a/examples/uv-layer-grid/uv_layer_grid.py b/examples/uv-layer-grid/uv_layer_grid.py index 908b045..25ae49f 100644 --- a/examples/uv-layer-grid/uv_layer_grid.py +++ b/examples/uv-layer-grid/uv_layer_grid.py @@ -8,14 +8,14 @@ The check proves the silent no-op, then the pre-create + `calc_uvs=True` repair path against a closed-form UV grid, and an explicit loop-assignment -fallback that does not depend on `calc_uvs` at all. Pass --output to also -render a still that stages the broken (flat) panel beside the repaired -(checker) panel — and then *witnesses the render itself*: the saved PNG is -read back and probed at each panel's projected center, asserting the broken -panel is one flat teal (texel (0,0)) while the repaired panel carries both -checker colors. If the UV contract failed, the pixels would say so: +fallback that does not depend on `calc_uvs` at all. + +``--precreate-on-hazard`` creates the UV layer on the silent-no-op probe +and still asserts zero layers, so the hazard check fails. That is the +falsifier (``--same-axis`` in export-preset-axis). blender --background --python uv_layer_grid.py -- + blender --background --python uv_layer_grid.py -- --precreate-on-hazard blender --background --python uv_layer_grid.py -- --output uv.png """ import bpy, bmesh, sys, os, math, argparse @@ -46,13 +46,15 @@ def max_uv_err(bm, uv_layer): return err -def check(): +def check(precreate_on_hazard=False): bpy.ops.wm.read_factory_settings(use_empty=True) # --- 1. The hazard: calc_uvs=True is a silent no-op without a UV layer --- me_bad = bpy.data.meshes.new("NoPreUV") bm = bmesh.new() try: + if precreate_on_hazard: + bm.loops.layers.uv.new("UVMap") bmesh.ops.create_grid( bm, x_segments=SEG, y_segments=SEG, size=SIZE, calc_uvs=True, ) @@ -506,9 +508,11 @@ def main(): "--engine", default="eevee", choices=("eevee", "cycles"), help="render engine for --output (cycles for GPU-less hosts)", ) + p.add_argument("--precreate-on-hazard", action="store_true", + help="pre-create a UV layer on the silent-no-op probe (must fail)") args = p.parse_args(argv) - code = check() + code = check(precreate_on_hazard=args.precreate_on_hazard) if code != 0: return code if args.output: diff --git a/examples/wave-displace/README.md b/examples/wave-displace/README.md index aab4b3c..d984e02 100644 --- a/examples/wave-displace/README.md +++ b/examples/wave-displace/README.md @@ -25,10 +25,28 @@ to `examples/gallery_framing.py`, call it with # Cheap correctness check (no render) — the CI check: blender --background --python wave_displace.py -- +# Falsifier: skip the foreach_set displacement. Must exit non-zero (z-span). +blender --background --python wave_displace.py -- --flat + # Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts): blender --background --python wave_displace.py -- --output wave.png blender --background --python wave_displace.py -- --output wave.png --engine cycles ``` -It exits non-zero on failure (span wrong, or any vertex off the closed form). The -`blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS. +## Exit codes + +Per-script sequential checks. `9` is a valid check code; there is no rule +against it. + +| Code | Meaning | +| --- | --- | +| 0 | Success | +| 1 | Uncaught exception (FATAL wrapper) | +| 2 | argparse / usage | +| 4 | Z-span not in the closed-form band (`--flat` lands here) | +| 5 | A vertex is off the closed-form wave | +| 6 | `--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 `--flat`. diff --git a/examples/wave-displace/wave_displace.py b/examples/wave-displace/wave_displace.py index 677918f..8602120 100644 --- a/examples/wave-displace/wave_displace.py +++ b/examples/wave-displace/wave_displace.py @@ -12,6 +12,7 @@ check. Pass --output to also render a still: blender --background --python wave_displace.py -- # check only + blender --background --python wave_displace.py -- --flat # must fail blender --background --python wave_displace.py -- --output w.png # + render """ import bpy, bmesh, sys, os, math, argparse @@ -133,10 +134,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("--flat", action="store_true", + help="skip the foreach_set displacement (must fail)") args = p.parse_args(argv) obj = build_grid() - n = displace(obj.data) + n = len(obj.data.vertices) if args.flat else displace(obj.data) code = check(obj, n) if code: return code