diff --git a/scripts/startup/bl_operators/image_as_planes.py b/scripts/startup/bl_operators/image_as_planes.py index f9efb4914b5..563b7f9e023 100644 --- a/scripts/startup/bl_operators/image_as_planes.py +++ b/scripts/startup/bl_operators/image_as_planes.py @@ -237,9 +237,270 @@ def center_in_camera(camera, ob, axis=(1, 1)): ob.location = location + offset +def get_ref_object_space_coord(ob): + size = ob.empty_display_size + x, y = ob.empty_image_offset + img = ob.data + + res_x, res_y = img.size + scaling = 1.0 / max(res_x, res_y) + + corners = [ + (0.0, 0.0), + (res_x, 0.0), + (0.0, res_y), + (res_x, res_y), + ] + + obj_space_corners = [] + for co in corners: + nco_x = ((co[0] + (x * res_x)) * size) * scaling + nco_y = ((co[1] + (y * res_y)) * size) * scaling + obj_space_corners.append((nco_x, nco_y, 0)) + return obj_space_corners + + # ----------------------------------------------------------------------------- # Cycles/EEVEE utils +class MaterialProperties_MixIn: + shader: EnumProperty( + name="Shader", + items=( + ('PRINCIPLED', "Principled", "Principled shader"), + ('SHADELESS', "Shadeless", "Only visible to camera and reflections"), + ('EMISSION', "Emission", "Emission shader"), + ), + default='PRINCIPLED', + description="Node shader to use", + ) + + emit_strength: FloatProperty( + name="Emission Strength", + min=0.0, + default=1.0, + soft_max=10.0, + step=100, + description="Strength of emission", + ) + + use_transparency: BoolProperty( + name="Use Alpha", + default=True, + description="Use alpha channel for transparency", + ) + + blend_method: EnumProperty( + name="Blend Mode", + items=( + ('BLEND', "Blend", "Render polygon transparent, depending on alpha channel of the texture"), + ('CLIP', "Clip", "Use the alpha threshold to clip the visibility (binary visibility)"), + ('HASHED', "Hashed", "Use noise to dither the binary visibility (works well with multi-samples)"), + ('OPAQUE', "Opaque", "Render surface without transparency"), + ), + default='BLEND', + description="Blend Mode for Transparent Faces", + translation_context=i18n_contexts.id_material, + ) + + shadow_method: EnumProperty( + name="Shadow Mode", + items=( + ('CLIP', "Clip", "Use the alpha threshold to clip the visibility (binary visibility)"), + ('HASHED', "Hashed", "Use noise to dither the binary visibility (works well with multi-samples)"), + ('OPAQUE', "Opaque", "Material will cast shadows without transparency"), + ('NONE', "None", "Material will cast no shadow"), + ), + default='CLIP', + description="Shadow mapping method", + translation_context=i18n_contexts.id_material, + ) + + use_backface_culling: BoolProperty( + name="Backface Culling", + default=False, + description="Use backface culling to hide the back side of faces", + ) + + show_transparent_back: BoolProperty( + name="Show Backface", + default=True, + description="Render multiple transparent layers (may introduce transparency sorting problems)", + ) + + overwrite_material: BoolProperty( + name="Overwrite Material", + default=True, + description="Overwrite existing material with the same name", + ) + + def draw_material_config(self, context): + # --- Material / Rendering Properties --- # + layout = self.layout + + header, body = layout.panel("import_image_plane_material", default_closed=False) + header.label(text="Material") + if body: + body.prop(self, 'shader') + if self.shader == 'EMISSION': + body.prop(self, "emit_strength") + + body.prop(self, 'blend_method') + + body.prop(self, 'shadow_method') + if self.blend_method == 'BLEND': + body.prop(self, "show_transparent_back") + + body.prop(self, "use_backface_culling") + + engine = context.scene.render.engine + if engine not in ('CYCLES', 'BLENDER_EEVEE', 'BLENDER_WORKBENCH'): + body.label(text=tip_("{:s} is not supported").format(engine), icon='ERROR') + + body.prop(self, "overwrite_material") + + +class TextureProperties_MixIn: + interpolation: EnumProperty( + name="Interpolation", + items=( + ('Linear', "Linear", "Linear interpolation"), + ('Closest', "Closest", "No interpolation (sample closest texel)"), + ('Cubic', "Cubic", "Cubic interpolation"), + ('Smart', "Smart", "Bicubic when magnifying, else bilinear (OSL only)"), + ), + default='Linear', + description="Texture interpolation", + ) + + extension: EnumProperty( + name="Extension", + items=( + ('CLIP', "Clip", "Clip to image size and set exterior pixels as transparent"), + ('EXTEND', "Extend", "Extend by repeating edge pixels of the image"), + ('REPEAT', "Repeat", "Cause the image to repeat horizontally and vertically"), + ), + default='CLIP', + description="How the image is extrapolated past its original bounds", + ) + + t = bpy.types.Image.bl_rna.properties["alpha_mode"] + alpha_mode: EnumProperty( + name=t.name, + items=tuple((e.identifier, e.name, e.description) for e in t.enum_items), + default=t.default, + description=t.description, + ) + + t = bpy.types.ImageUser.bl_rna.properties["use_auto_refresh"] + use_auto_refresh: BoolProperty( + name=t.name, + default=True, + description=t.description, + ) + + relative: BoolProperty( + name="Relative Paths", + default=True, + description="Use relative file paths", + ) + + def draw_texture_config(self, context): + # --- Texture Properties --- # + layout = self.layout + + header, body = layout.panel("import_image_plane_texture", default_closed=False) + header.label(text="Texture") + if body: + body.prop(self, 'interpolation') + body.prop(self, 'extension') + + row = body.row(align=False, heading="Alpha") + row.prop(self, "use_transparency", text="") + sub = row.row(align=True) + sub.active = self.use_transparency + sub.prop(self, "alpha_mode", text="") + + body.prop(self, "use_auto_refresh") + + +def apply_texture_options(self, texture, img_spec): + # Shared by both Cycles and Blender Internal. + image_user = texture.image_user + image_user.use_auto_refresh = self.use_auto_refresh + image_user.frame_start = img_spec.frame_start + image_user.frame_offset = img_spec.frame_offset + image_user.frame_duration = img_spec.frame_duration + + # Image sequences need auto refresh to display reliably. + if img_spec.image.source == 'SEQUENCE': + image_user.use_auto_refresh = True + + +def create_cycles_texnode(self, node_tree, img_spec): + tex_image = node_tree.nodes.new('ShaderNodeTexImage') + tex_image.image = img_spec.image + tex_image.show_texture = True + tex_image.interpolation = self.interpolation + tex_image.extension = self.extension + apply_texture_options(self, tex_image, img_spec) + return tex_image + + +def create_cycles_material(self, context, img_spec, name): + material = None + if self.overwrite_material: + material = bpy.data.materials.get((name, None)) + if material is None: + material = bpy.data.materials.new(name=name) + + material.use_nodes = True + + material.blend_method = self.blend_method + material.shadow_method = self.shadow_method + + material.use_backface_culling = self.use_backface_culling + material.use_transparency_overlap = self.show_transparent_back + + node_tree = material.node_tree + out_node = clean_node_tree(node_tree) + + tex_image = create_cycles_texnode(self, node_tree, img_spec) + + if self.shader == 'PRINCIPLED': + core_shader = node_tree.nodes.new('ShaderNodeBsdfPrincipled') + elif self.shader == 'SHADELESS': + core_shader = get_shadeless_node(node_tree) + elif self.shader == 'EMISSION': + core_shader = node_tree.nodes.new('ShaderNodeBsdfPrincipled') + core_shader.inputs["Emission Strength"].default_value = self.emit_strength + core_shader.inputs["Base Color"].default_value = (0.0, 0.0, 0.0, 1.0) + core_shader.inputs["Specular IOR Level"].default_value = 0.0 + + # Connect color from texture. + if self.shader in {'PRINCIPLED', 'SHADELESS'}: + node_tree.links.new(core_shader.inputs[0], tex_image.outputs["Color"]) + elif self.shader == 'EMISSION': + node_tree.links.new(core_shader.inputs["Emission Color"], tex_image.outputs["Color"]) + + if self.use_transparency: + if self.shader in {'PRINCIPLED', 'EMISSION'}: + node_tree.links.new(core_shader.inputs["Alpha"], tex_image.outputs["Alpha"]) + else: + bsdf_transparent = node_tree.nodes.new('ShaderNodeBsdfTransparent') + + mix_shader = node_tree.nodes.new('ShaderNodeMixShader') + node_tree.links.new(mix_shader.inputs["Fac"], tex_image.outputs["Alpha"]) + node_tree.links.new(mix_shader.inputs[1], bsdf_transparent.outputs["BSDF"]) + node_tree.links.new(mix_shader.inputs[2], core_shader.outputs[0]) + core_shader = mix_shader + + node_tree.links.new(out_node.inputs["Surface"], core_shader.outputs[0]) + + auto_align_nodes(node_tree) + return material + + def get_input_nodes(node, links): """Get nodes that are a inputs to the given node""" # Get all links going to node. @@ -384,7 +645,8 @@ def get_shadeless_node(dest_node_tree): # ----------------------------------------------------------------------------- # Operator -class IMAGE_OT_import_as_mesh_planes(AddObjectHelper, ImportHelper, Operator): +class IMAGE_OT_import_as_mesh_planes(AddObjectHelper, ImportHelper, MaterialProperties_MixIn, + TextureProperties_MixIn, Operator): """Create mesh plane(s) from image files with the appropriate aspect ratio""" bl_idname = "image.import_as_mesh_planes" @@ -548,124 +810,6 @@ class IMAGE_OT_import_as_mesh_planes(AddObjectHelper, ImportHelper, Operator): description="Number of pixels per inch or Blender Unit", ) - # ------------------------------ - # Properties - Material / Shader - shader: EnumProperty( - name="Shader", - items=( - ('PRINCIPLED', "Principled", "Principled Shader"), - ('SHADELESS', "Shadeless", "Only visible to camera and reflections"), - ('EMISSION', "Emit", "Emission Shader"), - ), - default='PRINCIPLED', - description="Node shader to use", - ) - - emit_strength: FloatProperty( - name="Emission Strength", - min=0.0, - default=1.0, - soft_max=10.0, - step=100, - description="Strength of emission", - ) - - use_transparency: BoolProperty( - name="Use Alpha", - default=True, - description="Use alpha channel for transparency", - ) - - blend_method: EnumProperty( - name="Blend Mode", - items=( - ('BLEND', "Blend", "Render polygon transparent, depending on alpha channel of the texture"), - ('CLIP', "Clip", "Use the alpha threshold to clip the visibility (binary visibility)"), - ('HASHED', "Hashed", "Use noise to dither the binary visibility (works well with multi-samples)"), - ('OPAQUE', "Opaque", "Render surface without transparency"), - ), - default='BLEND', - description="Blend Mode for Transparent Faces", - translation_context=i18n_contexts.id_material, - ) - - shadow_method: EnumProperty( - name="Shadow Mode", - items=( - ('CLIP', "Clip", "Use the alpha threshold to clip the visibility (binary visibility)"), - ('HASHED', "Hashed", "Use noise to dither the binary visibility (works well with multi-samples)"), - ('OPAQUE', "Opaque", "Material will cast shadows without transparency"), - ('NONE', "None", "Material will cast no shadow"), - ), - default='CLIP', - description="Shadow mapping method", - translation_context=i18n_contexts.id_material, - ) - - use_backface_culling: BoolProperty( - name="Backface Culling", - default=False, - description="Use backface culling to hide the back side of faces", - ) - - show_transparent_back: BoolProperty( - name="Show Backface", - default=True, - description="Render multiple transparent layers (may introduce transparency sorting problems)", - ) - - overwrite_material: BoolProperty( - name="Overwrite Material", - default=True, - description="Overwrite existing material with the same name", - ) - - # ------------------ - # Properties - Image - interpolation: EnumProperty( - name="Interpolation", - items=( - ('Linear', "Linear", "Linear interpolation"), - ('Closest', "Closest", "No interpolation (sample closest texel)"), - ('Cubic', "Cubic", "Cubic interpolation"), - ('Smart', "Smart", "Bicubic when magnifying, else bilinear (OSL only)"), - ), - default='Linear', - description="Texture interpolation", - ) - - extension: EnumProperty( - name="Extension", - items=( - ('CLIP', "Clip", "Clip to image size and set exterior pixels as transparent"), - ('EXTEND', "Extend", "Extend by repeating edge pixels of the image"), - ('REPEAT', "Repeat", "Cause the image to repeat horizontally and vertically"), - ), - default='CLIP', - description="How the image is extrapolated past its original bounds", - ) - - t = bpy.types.Image.bl_rna.properties["alpha_mode"] - alpha_mode: EnumProperty( - name=t.name, - items=tuple((e.identifier, e.name, e.description) for e in t.enum_items), - default=t.default, - description=t.description, - ) - - t = bpy.types.ImageUser.bl_rna.properties["use_auto_refresh"] - use_auto_refresh: BoolProperty( - name=t.name, - default=True, - description=t.description, - ) - - relative: BoolProperty( - name="Relative Paths", - default=True, - description="Use relative file paths", - ) - # ------- # Draw UI @@ -682,49 +826,6 @@ class IMAGE_OT_import_as_mesh_planes(AddObjectHelper, ImportHelper, Operator): body.prop(self, "force_reload") body.prop(self, "image_sequence") - def draw_material_config(self, context): - # --- Material / Rendering Properties --- # - layout = self.layout - - header, body = layout.panel("import_image_plane_material", default_closed=False) - header.label(text="Material") - if body: - body.prop(self, 'shader') - if self.shader == 'EMISSION': - body.prop(self, "emit_strength") - - body.prop(self, 'blend_method') - - body.prop(self, 'shadow_method') - if self.blend_method == 'BLEND': - body.prop(self, "show_transparent_back") - - body.prop(self, "use_backface_culling") - - engine = context.scene.render.engine - if engine not in ('CYCLES', 'BLENDER_EEVEE', 'BLENDER_WORKBENCH'): - body.label(text=tip_("{:s} is not supported").format(engine), icon='ERROR') - - body.prop(self, "overwrite_material") - - def draw_texture_config(self, context): - # --- Texture Properties --- # - layout = self.layout - - header, body = layout.panel("import_image_plane_texture", default_closed=False) - header.label(text="Texture") - if body: - body.prop(self, 'interpolation') - body.prop(self, 'extension') - - row = body.row(align=False, heading="Alpha") - row.prop(self, "use_transparency", text="") - sub = row.row(align=True) - sub.active = self.use_transparency - sub.prop(self, "alpha_mode", text="") - - body.prop(self, "use_auto_refresh") - def draw_spatial_config(self, _context): # --- Spatial Properties: Position, Size and Orientation --- # layout = self.layout @@ -758,8 +859,8 @@ class IMAGE_OT_import_as_mesh_planes(AddObjectHelper, ImportHelper, Operator): layout.use_property_split = True self.draw_import_config(context) - self.draw_material_config(context) - self.draw_texture_config(context) + MaterialProperties_MixIn.draw_material_config(self, context) + TextureProperties_MixIn.draw_texture_config(self, context) self.draw_spatial_config(context) # ------------------------------------------------------------------------- @@ -845,7 +946,7 @@ class IMAGE_OT_import_as_mesh_planes(AddObjectHelper, ImportHelper, Operator): # Configure material. # TODO: check `context.scene.render.engine` and support other engines. - material = self.create_cycles_material(img_spec, name) + material = create_cycles_material(self, context, img_spec, name) # Create and position plane object. plane = self.create_image_plane(context, name, img_spec) @@ -867,18 +968,6 @@ class IMAGE_OT_import_as_mesh_planes(AddObjectHelper, ImportHelper, Operator): except ValueError: pass - def apply_texture_options(self, texture, img_spec): - # Shared by both Cycles and Blender Internal. - image_user = texture.image_user - image_user.use_auto_refresh = self.use_auto_refresh - image_user.frame_start = img_spec.frame_start - image_user.frame_offset = img_spec.frame_offset - image_user.frame_duration = img_spec.frame_duration - - # Image sequences need auto refresh to display reliably. - if img_spec.image.source == 'SEQUENCE': - image_user.use_auto_refresh = True - def apply_material_options(self, material, slot): shader = self.shader @@ -899,70 +988,6 @@ class IMAGE_OT_import_as_mesh_planes(AddObjectHelper, ImportHelper, Operator): material.use_transparent_shadows = (shader == 'DIFFUSE') material.emit = self.emit_strength if shader == 'EMISSION' else 0.0 - # ------------------------------------------------------------------------- - # Cycles/EEVEE - def create_cycles_texnode(self, node_tree, img_spec): - tex_image = node_tree.nodes.new('ShaderNodeTexImage') - tex_image.image = img_spec.image - tex_image.show_texture = True - tex_image.interpolation = self.interpolation - tex_image.extension = self.extension - self.apply_texture_options(tex_image, img_spec) - return tex_image - - def create_cycles_material(self, img_spec, name): - material = None - if self.overwrite_material: - material = bpy.data.materials.get((name, None)) - if material is None: - material = bpy.data.materials.new(name=name) - - material.use_nodes = True - - material.blend_method = self.blend_method - material.shadow_method = self.shadow_method - - material.use_backface_culling = self.use_backface_culling - material.use_transparency_overlap = self.show_transparent_back - - node_tree = material.node_tree - out_node = clean_node_tree(node_tree) - - tex_image = self.create_cycles_texnode(node_tree, img_spec) - - if self.shader == 'PRINCIPLED': - core_shader = node_tree.nodes.new('ShaderNodeBsdfPrincipled') - elif self.shader == 'SHADELESS': - core_shader = get_shadeless_node(node_tree) - elif self.shader == 'EMISSION': - core_shader = node_tree.nodes.new('ShaderNodeBsdfPrincipled') - core_shader.inputs["Emission Strength"].default_value = self.emit_strength - core_shader.inputs["Base Color"].default_value = (0.0, 0.0, 0.0, 1.0) - core_shader.inputs["Specular IOR Level"].default_value = 0.0 - - # Connect color from texture. - if self.shader in {'PRINCIPLED', 'SHADELESS'}: - node_tree.links.new(core_shader.inputs[0], tex_image.outputs["Color"]) - elif self.shader == 'EMISSION': - node_tree.links.new(core_shader.inputs["Emission Color"], tex_image.outputs["Color"]) - - if self.use_transparency: - if self.shader in {'PRINCIPLED', 'EMISSION'}: - node_tree.links.new(core_shader.inputs["Alpha"], tex_image.outputs["Alpha"]) - else: - bsdf_transparent = node_tree.nodes.new('ShaderNodeBsdfTransparent') - - mix_shader = node_tree.nodes.new('ShaderNodeMixShader') - node_tree.links.new(mix_shader.inputs["Fac"], tex_image.outputs["Alpha"]) - node_tree.links.new(mix_shader.inputs[1], bsdf_transparent.outputs["BSDF"]) - node_tree.links.new(mix_shader.inputs[2], core_shader.outputs[0]) - core_shader = mix_shader - - node_tree.links.new(out_node.inputs["Surface"], core_shader.outputs[0]) - - auto_align_nodes(node_tree) - return material - # ------------------------------------------------------------------------- # Geometry Creation def create_image_plane(self, context, name, img_spec): @@ -1075,6 +1100,146 @@ class IMAGE_OT_import_as_mesh_planes(AddObjectHelper, ImportHelper, Operator): constraint.lock_axis = 'LOCK_Y' +class IMAGE_OT_convert_to_mesh_plane(MaterialProperties_MixIn, TextureProperties_MixIn, Operator): + """Convert selected reference images to textured mesh plane""" + bl_idname = "image.convert_to_mesh_plane" + bl_label = "Convert Image Empty to Mesh Plane" + bl_options = {'REGISTER', 'PRESET', 'UNDO'} + + name_from: EnumProperty( + name="Name After", + items=[ + ('OBJECT', "Source Object", "Name after object source with a suffix"), + ('IMAGE', "Source Image", "name from laoded image"), + ], + default='OBJECT', + description="Name for new mesh object and material" + ) + + delete_ref: BoolProperty( + name="Delete Reference Object", + default=True, + description="Delete empty image object once mesh plane is created" + ) + + @classmethod + def poll(cls, context): + ob = context.object + return ( + ob is not None and + ob.select_get() and + cls._is_object_empty_image(ob) + ) + + @staticmethod + def _is_object_empty_image(ob): + return ( + (ob.type == 'EMPTY') and + (ob.empty_display_type == 'IMAGE') and + (ob.data is not None) + ) + + def invoke(self, context, _event): + scene = context.scene + engine = scene.render.engine + + if engine not in COMPATIBLE_ENGINES: + self.report({'ERROR'}, tip_("Cannot generate materials for unknown {:s} render engine").format(engine)) + return {'CANCELLED'} + + if engine == 'BLENDER_WORKBENCH': + self.report( + {'WARNING'}, + tip_("Generating Cycles/EEVEE compatible material, but won't be visible with {:s} engine").format( + engine, + )) + + return context.window_manager.invoke_props_dialog(self) + + def execute(self, context): + scene = context.scene + + selected_objects = [ob for ob in context.selected_objects] + converted = 0 + + for ob in selected_objects: + if not self._is_object_empty_image(ob): + continue + + img = ob.data + img_user = ob.image_user + ob_name = ob.name + + # Give Name. + if self.name_from == 'IMAGE': + name = bpy.path.display_name(img.name, title_case=False) + else: + assert self.name_from == 'OBJECT' + name = ob.name + + # Create Mesh Plane. + obj_space_corners = get_ref_object_space_coord(ob) + + face = [(0, 1, 3, 2)] + mesh = bpy.data.meshes.new(name) + mesh.from_pydata(obj_space_corners, [], face) + plane = bpy.data.objects.new(name, mesh) + mesh.uv_layers.new(name="UVMap") + + # Link in the Same Collections. + users_collection = ob.users_collection + for collection in users_collection: + collection.objects.link(plane) + + # Assign Parent. + plane.parent = ob.parent + plane.matrix_local = ob.matrix_local + plane.matrix_parent_inverse = ob.matrix_parent_inverse + + # Create Material. + img_spec = ImageSpec( + img, + (img.size[0], img.size[1]), + img_user.frame_start, + img_user.frame_offset, + img_user.frame_duration, + ) + material = create_cycles_material(self, context, img_spec, name) + plane.data.materials.append(material) + + # Delete Empty. + if self.delete_ref: + for collection in users_collection: + collection.objects.unlink(ob) + mesh.name = ob_name + plane.name = ob_name + + plane.select_set(True) + converted += 1 + + if not converted: + self.report({'ERROR'}, "No images converted") + return {'CANCELLED'} + + self.report({'INFO'}, "{:d} image(s) converted to mesh plane(s)".format(converted)) + return {'FINISHED'} + + def draw(self, context): + layout = self.layout + layout.use_property_split = True + + # General. + col = layout.column(align=False) + col.prop(self, "name_from") + col.prop(self, "delete_ref") + layout.separator() + + # Material. + MaterialProperties_MixIn.draw_material_config(self, context) + TextureProperties_MixIn.draw_texture_config(self, context) + + classes = ( IMAGE_OT_import_as_mesh_planes, + IMAGE_OT_convert_to_mesh_plane, ) diff --git a/scripts/startup/bl_ui/space_view3d.py b/scripts/startup/bl_ui/space_view3d.py index f4db5014393..71a086bf46d 100644 --- a/scripts/startup/bl_ui/space_view3d.py +++ b/scripts/startup/bl_ui/space_view3d.py @@ -3108,6 +3108,7 @@ class VIEW3D_MT_object_context_menu(Menu): layout.separator() if obj.empty_display_type == 'IMAGE': + layout.operator("image.convert_to_mesh_plane", text="Convert to Mesh Plane") layout.operator("gpencil.trace_image") layout.separator() @@ -3500,14 +3501,18 @@ class VIEW3D_MT_object_convert(Menu): layout = self.layout ob = context.active_object - if ob and ob.type == 'GPENCIL' and context.gpencil_data and not context.preferences.experimental.use_grease_pencil_version3: - layout.operator_enum("gpencil.convert", "type") - else: - layout.operator_enum("object.convert", "target") + if ob and ob.type != "EMPTY": + if (ob.type == 'GPENCIL' and context.gpencil_data + and not context.preferences.experimental.use_grease_pencil_version3): + layout.operator_enum("gpencil.convert", "type") + else: + layout.operator_enum("object.convert", "target") - # Potrace lib dependency. - if bpy.app.build_options.potrace: - layout.operator("gpencil.trace_image", icon='OUTLINER_OB_GREASEPENCIL') + else: + # Potrace lib dependency. + if bpy.app.build_options.potrace: + layout.operator("image.convert_to_mesh_plane", text="Convert to Mesh Plane", icon='MESH_PLANE') + layout.operator("gpencil.trace_image", icon='OUTLINER_OB_GREASEPENCIL') if ob and ob.type == 'CURVES': layout.operator("curves.convert_to_particle_system", text="Particle System")