Joining Objects In Edit Mode

Hi folks. A while ago I decided to start learning to use Python in Blender to make a basic modal operator. Since Blender has the newly added feature of being able to use multiple objects in edit mode, I thought I’d try to make use of this new feature at the same time as learning about Python in Blender. So I started putting together the basics of an operator that would allow a user to choose which objects to join whilst tweaking the geometry. Iceythe gave me some very helpful advice with regards to using Python in blender, including the advice that I should not try to use bpy.ops to join the objects (a recommendation that I’ve seen re-enforced in almost every single search result relating to this topic). Iceythe advised me to attempt to use lower level calls to connect meshes that the user chooses to join and, according to the documentation, the bmesh module makes calls directly to the underlying C, so I guess that’s about as low level as I can hope to get (which is good because it’s what I was intending to use anyway).

I’m trying to put together a few lines of Python that I can use as the basis of an object joining method. To set it up, all I do is have two objects in an otherwise empty blend file and put a basic material on each of them, then I just move each object a couple of BUs from the origin in different directions. Based on what was in the bmesh documentation, I managed to put together a few lines that does take the mesh of each object that’s in edit mode and puts them into into a single object that gets linked back to each collection that any of the objects came from, but it doesn’t copy over any of the non-mesh information: materials, translations, etc are all missing. It took me a while to realise that it was actually copying the data of both meshes because, when the translation information is missing, all the meshes are sitting in exactly the same place at the origin. I guess I shouldn’t really have been surprised that only the mesh data was being used, given that the methods I could get to work were called .from_edit_mesh() and .from_mesh(), but the method I pinned my hopes on when I went back to the documentation .from_object() demands an argument I’ve never heard of, have no idea how to get, and google doesn’t look like it brings up anything useful when I ask it.

Here’s the Python I’ve been using that gets me an object containing just the bare bones meshes without copying any of the other data I want.

import bmesh
m = bmesh.from_edit_mesh(C.objects_in_mode[0].data)
m.from_mesh(C.objects_in_mode[1].data)
dm = bpy.data.meshes.new('z')
m.to_mesh(dm)
mo = bpy.data.objects.new('s', dm)
cs = set()
for o in C.objects_in_mode:
    cs.update(o.users_collection)

for c in cs:
    c.objects.link(mo)

Am I right to expect that from_object() is the method that will copy over all the other data? If so, then what is a depsgraph and how do I get a reference to the correct one? I’ve been using Blender for well over a year now and I’ve never even heard of it before.

bm.from_object() requires a depsgraph argument. Usually bpy.context.depsgraph is good enough. The only difference between from_object() and from_mesh() is the first argument (object data block vs mesh data block). They are otherwise the same.

This is an example for joining objects in edit-mode using low-level functions. Performance wise it’s similar to the bpy.ops.object.join()

The main steps are:

  • Store a hard reference of material per face from the source objects.
  • If the source object uses custom normals, the target object needs that as well
  • Use a temporary mesh to transfer to the target bmesh
  • Unlink the original object (don’t use bpy.data.objects.remove(), it crashes in multi-edit)

Usage:
Enter edit-mode with multiple objects.
Select any geometry (face, vert, etc.) from objects you want to join.
Lastly, select the object you want them joined to.
Run script

import bpy
import bmesh
import mathutils

def update_materials(source, tmp, target):

    source_slots = source.material_slots
    target_slots = target.material_slots
    polys = source.data.polygons

    if not source_slots:
         # means no material on source to sync
         return

    matlist = [source_slots[face.material_index].material for face in polys]

    # eliminates namespace lookups == faster loops
    append = target.data.materials.append
    find = target.material_slots.find

    # add source material to target if it doesn't exist
    for slot in source_slots:
        mat = slot.material
        if mat is not None:
            if mat.name not in target_slots:
                append(mat)

    # assign the correct material index to faces in temp mesh
    for p, mat in zip(tmp.polygons, matlist):
        p.material_index = find(mat.name)

# make sure all objects use custom normals if any other object
# does, otherwise the custom normals are lost when joining
def ensure_custom_normals(source, target):

    def add_split_normals(obj):
        if not obj.data.has_custom_normals:
            auto = [mathutils.Vector() for l in obj.data.loops]
            obj.data.normals_split_custom_set(auto)
            obj.data.use_auto_smooth = True
            obj.update_from_editmode()

    target_custom = target.data.has_custom_normals
    source_custom = any([s.data.has_custom_normals for s in source])

    # if any of these return true, add custom normals to each
    if target_custom or source_custom:
        add_split_normals(target)
        for so in source:
            add_split_normals(so)


def join():

    source_objects = []  # source objects
    target = None  # destination object

    for obj in bpy.context.objects_in_mode_unique_data:
        if obj != bpy.context.object:
            if obj.data.total_vert_sel:
                source_objects.append(obj)
        else:
            target = obj

    if source_objects and target:

        # sync custom normals
        ensure_custom_normals(source_objects, target)
        bm = bmesh.from_edit_mesh(target.data)

        for source in source_objects:
            # create a temp mesh and transform it to target's local space
            source_copy = source.data.copy()
            m = target.matrix_world.inverted()
            source_copy.transform(source.matrix_world @ m)

            # fix materials, add temp mesh to bmesh
            update_materials(source, source_copy, target)
            bm.from_mesh(source_copy)

            # clean up
            bpy.data.meshes.remove(source_copy)
            bpy.context.collection.objects.unlink(source)

        bmesh.update_edit_mesh(target.data)

if __name__ == '__main__':
    join()
1 Like

Awesome. Thanks man. I’t’s taken me until now to be able to run the thing without the python interpreter complaining about an indentation error because Python loves to drive me up the wall with this indentation instead of curly braces {} thing.

I’ll be taking a while to pick apart what you’ve posted here because it looks like there’s enough to be able to turn it into a few lessons about using Python in Blender (assuming I can find good information, of course). I still feel like I’m stumbling around in the dark most of the time, like in the case of objects_in_mode_unique_data(). I noticed it when I was using the autocomplete of the CLI and wodered how it might differ from objects_in_mode(), but couldn’t find any useful explanations online and couldn’t get past thinking “well isn’t even a copy of another object still a unique instance of itself?”. I didn’t find an answer to that question, but when I saw it again in the code you posted, it prompted me to have another look for information about it. I still haven’t found a useful explanation, but trying to find one did cause me to stumble upon some important tidbits of conceptual information regarding the different ways of accessing blender’s data through Python, and why one mothod might be better than another, depending on the context.

Thanks again man.

No problem. I’ve updated the above post with some comments to make it more clear what the functions do.

I don’t think objects_in_mode is working as intended atm, because it’s supposed to list even instanced objects. objects_in_mode_unique_data is never wrong when iterating over meshes, but objects_in_mode can be, since its intended to include even instanced objects.

The idea with unique_data is to get a list containing objects that don’t share data with each other, since this would otherwise duplicate an operation when iterating over each object’s data. See more here.

As for how the script is written, joining objects requires a few steps because of how materials are assigned to polygons (by material slot index) and how custom normals are stored.

Since a mesh uses material index, joining it with an object that uses a different set of materials would cause the joined part to receive whatever material is on the target’s material slots. So to transfer materials correctly we need to store a hard reference.

So in update_materials() we:

  • store a hard material reference per polygon from the source object in matlist
  • sync the material slots on the target object so it contains the necessary materials
  • using matlist find the new index and apply it back to the temp mesh

ensure_custom_normals() is needed if any of the objects use a custom normals layer. If the source mesh uses custom normals and the target mesh doesn’t, the joined data will lose the custom normals. So, we implement a check that looks through the source and target objects and enable this before joining the objects. This preserves custom normals. If no object uses custom normals, the function does nothing.

1 Like

Awesome. Thanks man. I started working my way through it after you posted it, and then got sick, so I’m starting to pick my way through it again. Thanks for going to the extra trouble of adding in more detailed comments and explanation too. It’s appreciated.