Add Convert Image Empty to Mesh Plane operator

Since import images as mesh planes operator was added recently in core
Blender, it is now easy to also support converting image empties to mesh
planes by reusing the same code.

This results in a fast workflow where you can use Blender's drag & drop
& align feature for reference images, and quickly turn them into meshes
without registering second file handler and clicking anything before
import is finished.

Ref !122546
This commit is contained in:
Nika Kutsniashvili 2024-06-06 10:55:15 +10:00 committed by Campbell Barton
parent 809be0c8e0
commit 1d894aa1a7
2 changed files with 418 additions and 248 deletions

@ -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,
)

@ -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")