|
| 1 | +"""In-Blender smoke test for the BDT example patterns. |
| 2 | +
|
| 3 | +Run: blender --background --python tests/smoke/run_smoke.py -- <outdir> |
| 4 | +
|
| 5 | +Executes the snippets' / skills' headline examples and asserts CONTENT, not just |
| 6 | +"no exception". Exits non-zero on the FIRST failed assertion, naming the example. |
| 7 | +Self-contained: example code is copied here, NOT imported from skills/ or snippets/, |
| 8 | +so the test catches drift in the shipped content rather than masking it. |
| 9 | +""" |
| 10 | +import bpy, sys, os, tempfile |
| 11 | + |
| 12 | +ARGS = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] |
| 13 | +OUT = ARGS[0] if ARGS else tempfile.mkdtemp() |
| 14 | +os.makedirs(OUT, exist_ok=True) |
| 15 | +V = bpy.app.version |
| 16 | +print(f"=== smoke on Blender {V[0]}.{V[1]}.{V[2]} -> {OUT} ===") |
| 17 | + |
| 18 | +def require(example, cond, evidence): |
| 19 | + """Pass-or-die assertion. On failure print which example failed and exit 1.""" |
| 20 | + status = "ok" if cond else "FAIL" |
| 21 | + print(f"[{status}] {example}: {evidence}") |
| 22 | + if not cond: |
| 23 | + print(f"SMOKE FAILED at example '{example}' on Blender {V[0]}.{V[1]}.{V[2]}") |
| 24 | + sys.exit(1) |
| 25 | + |
| 26 | +def reset(): |
| 27 | + bpy.ops.wm.read_factory_settings(use_empty=True) |
| 28 | + |
| 29 | +# ---- helpers copied from fixed content ---- |
| 30 | +def get_eevee_engine_id(): |
| 31 | + return 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT' |
| 32 | + |
| 33 | +def get_channelbag_for_slot(action, slot): |
| 34 | + if bpy.app.version >= (5, 0, 0): |
| 35 | + from bpy_extras.anim_utils import action_ensure_channelbag_for_slot |
| 36 | + return action_ensure_channelbag_for_slot(action, slot) |
| 37 | + layer = action.layers[0] if action.layers else action.layers.new("Layer") |
| 38 | + strip = layer.strips[0] if layer.strips else layer.strips.new(type='KEYFRAME') |
| 39 | + return strip.channelbag(slot, ensure=True) |
| 40 | + |
| 41 | +# ---------- mesh: foreach roundtrip + SUBSURF eval > base ---------- |
| 42 | +def smoke_mesh(): |
| 43 | + import bmesh |
| 44 | + reset() |
| 45 | + me = bpy.data.meshes.new("m"); me.from_pydata([(0,0,0),(1,0,0),(1,1,0),(0,1,0)], [], [(0,1,2,3)]); me.update() |
| 46 | + obj = bpy.data.objects.new("M", me); bpy.context.collection.objects.link(obj) |
| 47 | + base = len(me.vertices) |
| 48 | + n = len(me.vertices); src = [float(i) for i in range(n*3)] |
| 49 | + me.vertices.foreach_set("co", src); dst = [0.0]*(n*3); me.vertices.foreach_get("co", dst) |
| 50 | + require("mesh-foreach-roundtrip", all(abs(a-b) < 1e-5 for a,b in zip(src,dst)), "foreach_set/get arrays equal") |
| 51 | + obj.modifiers.new("ss", 'SUBSURF').levels = 2 |
| 52 | + dg = bpy.context.evaluated_depsgraph_get(); ev = obj.evaluated_get(dg); m = ev.to_mesh() |
| 53 | + evc = len(m.vertices); ev.to_mesh_clear() |
| 54 | + require("mesh-subsurf-eval", evc > base, f"eval vcount {evc} > base {base}") |
| 55 | + |
| 56 | +# ---------- F1: driver_namespace example (skill, fixed) ---------- |
| 57 | +def smoke_driver(): |
| 58 | + reset() |
| 59 | + me = bpy.data.meshes.new("d"); me.from_pydata([(0,0,0)],[],[]) |
| 60 | + obj = bpy.data.objects.new("D", me); bpy.context.collection.objects.link(obj) |
| 61 | + def smooth_step(t): |
| 62 | + t = max(0.0, min(1.0, t)); return t*t*(3.0-2.0*t) |
| 63 | + bpy.app.driver_namespace['smooth_step'] = smooth_step |
| 64 | + fcurve = obj.driver_add("location", 2); fcurve.driver.type = 'SCRIPTED' |
| 65 | + var = fcurve.driver.variables.new(); var.name = 't'; var.type = 'SINGLE_PROP' |
| 66 | + var.targets[0].id_type = 'SCENE' # the F1 fix under test |
| 67 | + var.targets[0].id = bpy.context.scene |
| 68 | + var.targets[0].data_path = 'frame_current' |
| 69 | + fcurve.driver.expression = 'smooth_step((t - 1.0) / 100.0) * 5.0' |
| 70 | + vals = [] |
| 71 | + for f in (1, 50, 100): |
| 72 | + bpy.context.scene.frame_set(f); dg = bpy.context.evaluated_depsgraph_get() |
| 73 | + vals.append(round(obj.evaluated_get(dg).location.z, 4)) |
| 74 | + require("F1-driver-namespace", vals[0] < vals[1] < vals[2], f"location.z across frames = {vals} (strictly increasing)") |
| 75 | + |
| 76 | +# ---------- F2: SDF remesh via GridToMesh (skill, fixed) ---------- |
| 77 | +def smoke_sdf(): |
| 78 | + import bmesh |
| 79 | + reset() |
| 80 | + me = bpy.data.meshes.new("c"); bm = bmesh.new(); bmesh.ops.create_cube(bm, size=2.0); bm.to_mesh(me); bm.free() |
| 81 | + obj = bpy.data.objects.new("C", me); bpy.context.collection.objects.link(obj) |
| 82 | + tree = bpy.data.node_groups.new("SDFRemesh", 'GeometryNodeTree') |
| 83 | + tree.interface.new_socket(name="Geometry", in_out='INPUT', socket_type='NodeSocketGeometry') |
| 84 | + tree.interface.new_socket(name="Geometry", in_out='OUTPUT', socket_type='NodeSocketGeometry') |
| 85 | + gi = tree.nodes.new('NodeGroupInput'); go = tree.nodes.new('NodeGroupOutput') |
| 86 | + m2s = tree.nodes.new('GeometryNodeMeshToSDFGrid'); g2m = tree.nodes.new('GeometryNodeGridToMesh') |
| 87 | + m2s.inputs["Voxel Size"].default_value = 0.1 |
| 88 | + g2m.inputs["Threshold"].default_value = 0.0 |
| 89 | + tree.links.new(gi.outputs["Geometry"], m2s.inputs["Mesh"]) |
| 90 | + link = tree.links.new(m2s.outputs["SDF Grid"], g2m.inputs["Grid"]) # the F2 fix under test |
| 91 | + tree.links.new(g2m.outputs["Mesh"], go.inputs["Geometry"]) |
| 92 | + require("F2-sdf-link-valid", link.is_valid, "SDF Grid -> Grid link is_valid") |
| 93 | + obj.modifiers.new("gn", 'NODES').node_group = tree |
| 94 | + dg = bpy.context.evaluated_depsgraph_get(); ev = obj.evaluated_get(dg); m = ev.to_mesh() |
| 95 | + n = len(m.vertices); ev.to_mesh_clear() |
| 96 | + require("F2-sdf-remesh", n > 0, f"GridToMesh remesh produced {n} vertices (>0)") |
| 97 | + |
| 98 | +# ---------- EEVEE: id assignment + non-black render ---------- |
| 99 | +def smoke_eevee(): |
| 100 | + import bmesh |
| 101 | + reset() |
| 102 | + me = bpy.data.meshes.new("p"); bm = bmesh.new(); bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=10.0); bm.to_mesh(me); bm.free() |
| 103 | + obj = bpy.data.objects.new("P", me); bpy.context.collection.objects.link(obj) |
| 104 | + mat = bpy.data.materials.new("M"); mat.use_nodes = True; nt = mat.node_tree; nt.nodes.clear() |
| 105 | + emis = nt.nodes.new('ShaderNodeEmission'); emis.inputs['Color'].default_value = (0.9,0.4,0.1,1.0); emis.inputs['Strength'].default_value = 5.0 |
| 106 | + out = nt.nodes.new('ShaderNodeOutputMaterial'); nt.links.new(emis.outputs['Emission'], out.inputs['Surface']) |
| 107 | + obj.data.materials.append(mat) |
| 108 | + cam = bpy.data.objects.new("cam", bpy.data.cameras.new("cam")); bpy.context.collection.objects.link(cam) |
| 109 | + cam.location = (0,0,10); bpy.context.scene.camera = cam |
| 110 | + sc = bpy.context.scene |
| 111 | + # the EEVEE-id regression guard: assigning the version-correct id must succeed |
| 112 | + eid = get_eevee_engine_id() |
| 113 | + try: |
| 114 | + sc.render.engine = eid |
| 115 | + except Exception as e: |
| 116 | + require("eevee-id-assign", False, f"assigning '{eid}' raised {type(e).__name__}: {e}") |
| 117 | + require("eevee-id-assign", sc.render.engine == eid, f"engine id '{eid}' valid on this build") |
| 118 | + sc.render.resolution_x = 16; sc.render.resolution_y = 16; sc.render.image_settings.file_format = 'PNG' |
| 119 | + png = os.path.join(OUT, f"smoke_render_{V[0]}{V[1]}.png"); sc.render.filepath = png |
| 120 | + # try EEVEE render; if the headless runner has no GPU and it raises, fall back to Cycles (CPU) |
| 121 | + used = sc.render.engine |
| 122 | + try: |
| 123 | + try: sc.eevee.taa_render_samples = 4 |
| 124 | + except Exception: pass |
| 125 | + bpy.ops.render.render(write_still=True) |
| 126 | + if not os.path.exists(png): raise RuntimeError("no PNG written") |
| 127 | + except Exception as e: |
| 128 | + print(f" (EEVEE render unavailable headless: {type(e).__name__}; falling back to CYCLES)") |
| 129 | + sc.render.engine = 'CYCLES'; used = 'CYCLES' |
| 130 | + try: sc.cycles.samples = 4 |
| 131 | + except Exception: pass |
| 132 | + bpy.ops.render.render(write_still=True) |
| 133 | + require("eevee-render-file", os.path.exists(png) and os.path.getsize(png) > 0, f"render PNG written by {used}") |
| 134 | + img = bpy.data.images.load(png); px = list(img.pixels); mx = max(px) if px else 0.0 |
| 135 | + require("eevee-render-nonblack", mx > 0.05, f"max pixel {round(mx,3)} > 0.05 (engine used: {used})") |
| 136 | + |
| 137 | +# ---------- slotted actions: correct branch + legacy behaviour ---------- |
| 138 | +def smoke_slotted(): |
| 139 | + reset() |
| 140 | + me = bpy.data.meshes.new("a"); me.from_pydata([(0,0,0)],[],[]) |
| 141 | + obj = bpy.data.objects.new("A", me); bpy.context.collection.objects.link(obj) |
| 142 | + obj.location = (0,0,0.0); obj.keyframe_insert("location", frame=1) |
| 143 | + obj.location = (0,0,5.0); obj.keyframe_insert("location", frame=24) |
| 144 | + bpy.context.scene.frame_set(1); z1 = round(obj.location.z,3) |
| 145 | + bpy.context.scene.frame_set(24); z24 = round(obj.location.z,3) |
| 146 | + require("slotted-keyframe-drives", abs(z24-z1) > 1.0, f"sampled z f1={z1} f24={z24} differ") |
| 147 | + o2 = bpy.data.objects.new("A2", bpy.data.meshes.new("a2")); bpy.context.collection.objects.link(o2) |
| 148 | + o2.animation_data_create(); act = bpy.data.actions.new("Act"); o2.animation_data.action = act |
| 149 | + slot = o2.animation_data.action_slot |
| 150 | + if slot is None: |
| 151 | + slot = act.slots.new(id_type='OBJECT', name=o2.name); o2.animation_data.action_slot = slot |
| 152 | + cbag = get_channelbag_for_slot(act, slot) |
| 153 | + require("slotted-channelbag", type(cbag).__name__ == "ActionChannelbag", f"channelbag = {type(cbag).__name__}") |
| 154 | + act3 = bpy.data.actions.new("Act3") |
| 155 | + try: |
| 156 | + act3.fcurves.new("location", index=0); legacy = 'WORKS' |
| 157 | + except AttributeError: |
| 158 | + legacy = 'AttributeError' |
| 159 | + expected = 'AttributeError' if bpy.app.version >= (5,0,0) else 'WORKS' |
| 160 | + require("slotted-legacy-branch", legacy == expected, f"legacy action.fcurves = {legacy} (expected {expected} for this version)") |
| 161 | + |
| 162 | +# ---------- save_pre handler arg is a filepath string ---------- |
| 163 | +def smoke_save_pre(): |
| 164 | + reset() |
| 165 | + captured = {} |
| 166 | + def on_save_pre(arg, *a): captured['t'] = type(arg).__name__ |
| 167 | + bpy.app.handlers.save_pre.append(on_save_pre) |
| 168 | + p = os.path.join(OUT, "smoke_sp.blend"); bpy.ops.wm.save_as_mainfile(filepath=p) |
| 169 | + bpy.app.handlers.save_pre.remove(on_save_pre) |
| 170 | + require("save_pre-arg-type", captured.get('t') == 'str', f"save_pre arg type = {captured.get('t')} (filepath string, not Scene)") |
| 171 | + |
| 172 | +# Blender runs --python scripts but exits 0 even on an uncaught exception, so wrap every |
| 173 | +# example: a raised exception is a failure just like a failed content assertion. |
| 174 | +for fn in (smoke_mesh, smoke_driver, smoke_sdf, smoke_eevee, smoke_slotted, smoke_save_pre): |
| 175 | + try: |
| 176 | + fn() |
| 177 | + except SystemExit: |
| 178 | + raise |
| 179 | + except Exception as e: |
| 180 | + import traceback; traceback.print_exc() |
| 181 | + print(f"SMOKE FAILED: unhandled {type(e).__name__} in '{fn.__name__}' on " |
| 182 | + f"Blender {V[0]}.{V[1]}.{V[2]}: {e}") |
| 183 | + sys.exit(1) |
| 184 | + |
| 185 | +print(f"=== ALL SMOKE CHECKS PASSED on Blender {V[0]}.{V[1]}.{V[2]} ===") |
0 commit comments