# Acceptance probe for the fleet Blender extensions (stacks/blender, README "Extensions"). # One real operator run per add-on. The same code runs on both paths: # headless: scripts/blender-run --extensions -- --python extensions_acceptance.py -- headless # MCP: the body pasted into execute_blender_code, with TAG = "mcp" # so it is written to pass MCP safe mode: bpy, bmesh and mathutils only, no os/open/imports. # Files land in /work/acceptance/extensions/ (= fv-ml1:/tank/blender/acceptance/extensions/), # which must exist first. Every result prints as PASS/FAIL; any FAIL raises at the end, so # blender-run exits 1. import bpy import bmesh import sys from mathutils import Vector TAG = sys.argv[sys.argv.index("--") + 1] if "--" in sys.argv else "mcp" OUT = "/work/acceptance/extensions/" results = [] def report(name, ok, detail): results.append((name, ok, detail)) print(("PASS " if ok else "FAIL ") + name + " - " + detail) def clear(): if bpy.context.object is not None and bpy.context.object.mode != "OBJECT": bpy.ops.object.mode_set(mode="OBJECT") for o in list(bpy.data.objects): bpy.data.objects.remove(o, do_unlink=True) def select_only(*objs): for o in bpy.context.view_layer.objects: o.select_set(False) for o in objs: o.select_set(True) bpy.context.view_layer.objects.active = objs[0] def view3d_override(): """Context for operators that insist on a 3D View area: the GUI window's, or (headless, where there is no window) a VIEW_3D area of a screen datablock.""" for w in bpy.context.window_manager.windows: for a in w.screen.areas: if a.type == "VIEW_3D": region = [r for r in a.regions if r.type == "WINDOW"][0] return {"window": w, "screen": w.screen, "area": a, "region": region} for s in bpy.data.screens: for a in s.areas: if a.type == "VIEW_3D": return {"area": a} return {} def non_manifold_edges(o): bm = bmesh.new() bm.from_mesh(o.data) n = len([e for e in bm.edges if not e.is_manifold]) bm.free() return n def world_extent(o): """World-space bounding-box size. Object.dimensions is in LOCAL axes and ignores rotation, so it cannot show which way an import is facing.""" pts = [o.matrix_world @ Vector(c) for c in o.bound_box] return [max(p[i] for p in pts) - min(p[i] for p in pts) for i in range(3)] def dims(o): return "x".join(str(round(d * 1000, 3)) for d in world_extent(o)) + " mm (world)" def run(name, fn): try: fn(name) except Exception as ex: report(name, False, "raised " + type(ex).__name__ + ": " + str(ex)) # 1. 3D-Print Toolbox: clean non-manifold on a cube with one face deleted. def t_print3d(name): clear() bpy.ops.mesh.primitive_cube_add(size=0.02) cube = bpy.context.active_object bm = bmesh.new() bm.from_mesh(cube.data) bm.faces.ensure_lookup_table() bmesh.ops.delete(bm, geom=[bm.faces[0]], context="FACES_ONLY") bm.to_mesh(cube.data) bm.free() before = non_manifold_edges(cube) r = bpy.ops.mesh.print3d_clean_non_manifold() after = non_manifold_edges(cube) report(name, r == {"FINISHED"} and before > 0 and after == 0, "non-manifold edges " + str(before) + " -> " + str(after)) # 2. Bool Tool: auto difference, a 4 mm hole through a 20 mm cube. def t_booltool(name): clear() bpy.ops.mesh.primitive_cube_add(size=0.02) canvas = bpy.context.active_object bpy.ops.mesh.primitive_cylinder_add(radius=0.002, depth=0.05) cutter = bpy.context.active_object cutter_name = cutter.name select_only(canvas, cutter) v0 = len(canvas.data.vertices) r = bpy.ops.object.boolean_auto_difference() v1 = len(canvas.data.vertices) gone = cutter_name not in bpy.data.objects report(name, r == {"FINISHED"} and gone and v1 > v0 and non_manifold_edges(canvas) == 0, "canvas verts " + str(v0) + " -> " + str(v1) + ", cutter consumed " + str(gone) + ", non-manifold " + str(non_manifold_edges(canvas))) # 3. LoopTools: circle the boundary loop of a 5x5 grid (a square: corners sit further out). def t_looptools(name): clear() bpy.ops.mesh.primitive_grid_add(x_subdivisions=4, y_subdivisions=4, size=0.02) g = bpy.context.active_object edge = max(abs(v.co.x) for v in g.data.vertices) for v in g.data.vertices: v.select = abs(abs(v.co.x) - edge) < 1e-6 or abs(abs(v.co.y) - edge) < 1e-6 ring = [v.index for v in g.data.vertices if v.select] def spread(): radii = [g.data.vertices[i].co.to_2d().length for i in ring] return max(radii) - min(radii) before = spread() bpy.ops.object.mode_set(mode="EDIT") bpy.ops.mesh.select_mode(type="VERT") with bpy.context.temp_override(**view3d_override()): r = bpy.ops.mesh.looptools_circle() bpy.ops.object.mode_set(mode="OBJECT") after = spread() report(name, r == {"FINISHED"} and before > 1e-4 and after < 1e-6, str(len(ring)) + " boundary verts, radius spread " + str(round(before * 1000, 4)) + " -> " + str(round(after * 1000, 6)) + " mm") # 4. MeasureIt: a dimension segment between two vertices. def t_measureit(name): clear() bpy.ops.mesh.primitive_cube_add(size=0.02) c = bpy.context.active_object for elems in (c.data.polygons, c.data.edges, c.data.vertices): for e in elems: e.select = False for v in c.data.vertices: v.select = v.index in (0, 1) bpy.ops.object.mode_set(mode="EDIT") with bpy.context.temp_override(**view3d_override()): r = bpy.ops.measureit.addsegment() bpy.ops.object.mode_set(mode="OBJECT") n = c.MeasureGenerator[0].measureit_num if len(c.MeasureGenerator) else 0 report(name, r == {"FINISHED"} and n == 1, "segments " + str(n)) # 5. SurfacePsycho: one Bezier patch scaled to 20 x 20 mm, exported to STEP through its bundled OCP. def t_surfacepsycho(name): clear() r1 = bpy.ops.object.sp_add_bezier_patch() patch = bpy.context.active_object patch.scale = (0.01, 0.01, 0.01) # the default patch is 2 x 2 m bpy.context.view_layer.update() select_only(patch) path = OUT + "sp-patch-" + TAG + ".step" r2 = bpy.ops.wm.sp_step_export(filepath=path, use_selection=True) report(name, r1 == {"FINISHED"} and r2 == {"FINISHED"}, "patch " + dims(patch) + " -> " + path + " (read back by the STEP import below)") # 6. STEP Importer (Clonephaze, OCCT via cascadio): read the SurfacePsycho STEP back, and check it # lands the same way up (world space) and the same size. def t_step_import(name): clear() path = OUT + "sp-patch-" + TAG + ".step" r = bpy.ops.import_scene.step(filepath=path) meshes = [o for o in bpy.data.objects if o.type == "MESH"] faces = sum(len(o.data.polygons) for o in meshes) d = world_extent(meshes[0]) if meshes else (0, 0, 0) same = abs(d[0] - 0.02) < 1e-5 and abs(d[1] - 0.02) < 1e-5 and d[2] < 1e-6 report(name, r == {"FINISHED"} and len(meshes) == 1 and faces > 0 and same, str(len(meshes)) + " mesh object(s), " + str(faces) + " faces" + ("; " + dims(meshes[0]) if meshes else "") + ", expect 20x20x0") # Control for the orientation check itself: a deliberately wrong source axis must stand # the patch on its edge, and the same check must say so. clear() bpy.ops.import_scene.step(filepath=path, up_axis="Z") wrong = [o for o in bpy.data.objects if o.type == "MESH"] w = world_extent(wrong[0]) if wrong else (0, 0, 0) caught = not (abs(w[0] - 0.02) < 1e-5 and abs(w[1] - 0.02) < 1e-5 and w[2] < 1e-6) report(name + " (control: wrong up-axis is caught)", caught, "up_axis=Z gives " + (dims(wrong[0]) if wrong else "nothing")) # 7. 3MF Import/Export: a 20 mm cube out and back; the size must survive (3MF carries units). def t_threemf(name): clear() bpy.ops.mesh.primitive_cube_add(size=0.02) select_only(bpy.context.active_object) path = OUT + "cube-" + TAG + ".3mf" r1 = bpy.ops.export_mesh.threemf(filepath=path, use_selection=True) clear() r2 = bpy.ops.import_mesh.threemf(filepath=path) meshes = [o for o in bpy.data.objects if o.type == "MESH"] ok_size = len(meshes) == 1 and all(abs(d - 0.02) < 1e-6 for d in world_extent(meshes[0])) report(name, r1 == {"FINISHED"} and r2 == {"FINISHED"} and ok_size, path + ", re-imported " + (dims(meshes[0]) if meshes else "nothing")) # 8. CAD Sketcher: a sketch on the XY origin plane, then a full solve through slvs. def t_cad_sketcher(name): clear() with bpy.context.temp_override(**view3d_override()): r1 = bpy.ops.view3d.slvs_add_sketch_on_plane(plane="XY") r2 = bpy.ops.view3d.slvs_solve(all=True) sketches = [o for o in bpy.data.objects if o.type == "CURVES"] report(name, r1 == {"FINISHED"} and r2 == {"FINISHED"} and len(sketches) == 1, "sketch objects " + str(len(sketches))) for n, f in ( ("print3d_toolbox clean_non_manifold", t_print3d), ("bool_tool boolean_auto_difference", t_booltool), ("looptools circle", t_looptools), ("measureit addsegment", t_measureit), ("surfacepsycho bezier patch + STEP export", t_surfacepsycho), ("step_importer import_scene.step", t_step_import), ("ThreeMF_io export + import", t_threemf), ("CAD_Sketcher sketch + solve", t_cad_sketcher), ): run(n, f) failed = [r[0] for r in results if not r[1]] print("SUMMARY " + TAG + ": " + str(len(results) - len(failed)) + "/" + str(len(results)) + " passed") if failed: raise RuntimeError("failed: " + ", ".join(failed))