Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 31 additions & 3 deletions examples/car-mirror-symmetry/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
17 changes: 15 additions & 2 deletions examples/car-mirror-symmetry/car_mirror_symmetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
28 changes: 24 additions & 4 deletions examples/custom-normals-shade/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
16 changes: 13 additions & 3 deletions examples/custom-normals-shade/custom_normals_shade.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 "
Expand Down Expand Up @@ -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):
Expand Down
39 changes: 36 additions & 3 deletions examples/gltf-export-roundtrip/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
19 changes: 14 additions & 5 deletions examples/gltf-export-roundtrip/gltf_export_roundtrip.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``
Expand All @@ -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
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down
33 changes: 28 additions & 5 deletions examples/gn-modifier-inputs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
19 changes: 14 additions & 5 deletions examples/gn-modifier-inputs/gn_modifier_inputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.<identifier>.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
"""
Expand Down Expand Up @@ -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",
Expand All @@ -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 "
Expand Down Expand Up @@ -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

Expand Down
Loading
Loading