Drag to select multiple nodes

Hey guys, I’m struggling to implement a rectangular selection of nodes on my app, I found this solution but I think this is too much code nowadays. Anyone does know a simpler solution?

Thanks in advance :smiley:

… What makes you think that it’s too much code? (I would say that it’s a fairly small amount.)

Looking over it, you might be able to reduce the calculations of the largest- and smallest- points down to a few lines via the “max” and “min” methods, but it doesn’t seem all that worth the bother, I would say.

I thought since is an old post that would exist a leaner solution.

Not necessarily at all.

In any case, it’s a fairly short solution (I’d say), so I’d suggest going with it, unless someone else posts to provide another.

In fact it could be argued that the referenced code is too simple. :wink:

What I mean is that region-selection of objects is not an easy problem to solve, especially when you want it to be fairly precise.
Consider a window frame model that has its origin (i.e. the position of its node) at its centre. If you drag a rectangle over a corner, that code will not select the frame, since the origin is outside of the rectangle. Conversely, if you had a selected window frame and wanted to deselect it by region-selecting another object that you see through the window, your new selection might still include that frame (because its centre would be included in the rectangle).
It can be very frustrating to draw a big rectangle around a model, only to find that you can’t select it, simply because its origin is currently outside of the camera view.

If you’re interested, below is some code that allows pixel-perfect region-selection. Yes, it’s very complicated indeed, but you don’t need to worry about that; all you have to do is import RegionSelector from the region_selection module and add all of your selectable objects to it. You can then draw a rectangle using the RegionSelector.start_region_draw and RegionSelector.end_region_draw methods. The latter will then call the functions you provided to handle the logic for selection and deselection. That’s all there’s to it. :slight_smile:
You do need support for GLSL version 4.20, though (the code uses a shader).

region_selection.py

from panda3d.core import *


VERT_SHADER = """
    #version 420

    uniform mat4 p3d_ModelViewProjectionMatrix;
    uniform int region_sel_index;
    in vec4 p3d_Vertex;
    flat out int oindex;

    void main() {
        gl_Position = p3d_ModelViewProjectionMatrix * p3d_Vertex;
        oindex = region_sel_index;
    }
"""

FRAG_SHADER = """
    #version 420

    layout(r32i) uniform iimageBuffer selections;
    flat in int oindex;

    void main() {
        // Write 1 to the location corresponding to the custom index
        imageAtomicOr(selections, (oindex >> 5), 1 << (oindex & 31));
    }
"""


def create_rectangle():

    vertex_format = GeomVertexFormat.get_v3()
    vertex_data = GeomVertexData("rectangle_data", vertex_format, Geom.UH_static)
    vertex_data.set_num_rows(4)

    prim = GeomLines(Geom.UH_static)
    prim.add_vertices(0, 1)
    prim.add_vertices(1, 2)
    prim.add_vertices(2, 3)
    prim.add_vertices(3, 0)
    geom = Geom(vertex_data)
    geom.add_primitive(prim)
    node = GeomNode("rectangle_geom_node")
    node.add_geom(geom)

    return node


class RegionSelectables:

    used_indices = BitArray()
    obj_indices = {}
    objs = {}

    @classmethod
    def add(cls, obj):

        if obj in cls.objs.values():
            return

        index = cls.used_indices.get_lowest_off_bit()
        cls.used_indices.set_bit(index)
        cls.obj_indices[obj] = index
        cls.objs[index] = obj
        obj.set_shader_input("region_sel_index", index)

    @classmethod
    def remove(cls, obj):

        if obj not in cls.objs.values():
            return

        index = cls.obj_indices[obj]
        del cls.obj_indices[obj]
        del cls.objs[index]
        cls.used_indices.clear_bit(index)
        obj.clear_shader_input("region_sel_index")

    @classmethod
    def clear(cls):

        for obj in cls.objs.values():
            obj.clear_shader_input("region_sel_index")

        cls.used_indices.clear()
        cls.obj_indices.clear()
        cls.objs.clear()


class RegionSelector:

    def __init__(self, showbase, deselect_func, select_func):

        self.showbase = showbase

        self.deselect_func = deselect_func
        self.select_func = select_func

        cam = Camera("region_selection_cam")
        cam.active = False
        self.region_sel_cam = showbase.camera.attach_new_node(cam)
        self.selection_cam_mask = BitMask32.bit(10)
        self.selection_rectangle = showbase.render2d.attach_new_node(create_rectangle())
        self.selection_rectangle.set_color((1., 1., 0., 1.))
        self.selection_rectangle.hide()
        self.mouse_start_pos = (0., 0.)
        self.mouse_end_pos = (0., 0.)

    def add(self, obj):

        RegionSelectables.add(obj)

    def remove(self, obj):

        RegionSelectables.remove(obj)

    def clear(self):

        RegionSelectables.clear()

    def start_region_draw(self):

        if not self.showbase.mouseWatcherNode.has_mouse():
            return

        screen_pos = self.showbase.mouseWatcherNode.get_mouse()
        self.mouse_start_pos = (screen_pos.x, screen_pos.y)
        self.selection_rectangle.show()
        self.showbase.task_mgr.add(self.__draw_region, "draw_region")

    def __draw_region(self, task):

        if not self.showbase.mouseWatcherNode.has_mouse():
            return task.cont

        screen_pos = self.showbase.mouseWatcherNode.get_mouse()
        x1, z1 = self.mouse_start_pos
        x2, z2 = self.mouse_end_pos = (screen_pos.x, screen_pos.y)
        geom = self.selection_rectangle.node().modify_geom(0)
        vertex_data = geom.modify_vertex_data()
        pos_writer = GeomVertexWriter(vertex_data, "vertex")
        pos_writer.set_row(0)
        pos_writer.set_data3(x1, 0., z1)
        pos_writer.set_row(1)
        pos_writer.set_data3(x1, 0., z2)
        pos_writer.set_row(2)
        pos_writer.set_data3(x2, 0., z2)
        pos_writer.set_row(3)
        pos_writer.set_data3(x2, 0., z1)

        return task.cont

    def end_region_draw(self):

        self.showbase.task_mgr.remove("draw_region")
        self.selection_rectangle.hide()
        x1, y1 = self.mouse_start_pos
        x2, y2 = self.mouse_end_pos
        x1 = max(0., min(1., .5 + x1 * .5))
        y1 = max(0., min(1., .5 + y1 * .5))
        x2 = max(0., min(1., .5 + x2 * .5))
        y2 = max(0., min(1., .5 + y2 * .5))
        l, r = min(x1, x2), max(x1, x2)
        b, t = min(y1, y2), max(y1, y2)
        self.__region_select((l, r, b, t))

    def __update_selection(self, obj_indices):

        selected_objs = {RegionSelectables.objs[i] for i in obj_indices}
        deselected_objs = set(RegionSelectables.objs.values()) - selected_objs

        self.deselect_func(deselected_objs)
        self.select_func(selected_objs)

    def __region_select(self, frame):

        lens = self.showbase.camLens
        w, h = lens.film_size
        l, r, b, t = frame
        # compute film size and offset
        w_f = (r - l) * w
        h_f = (t - b) * h
        x_f = ((r + l) * .5 - .5) * w
        y_f = ((t + b) * .5 - .5) * h
        win_props = self.showbase.win.properties
        w, h = win_props.size  # window resolution in pixels
        # compute buffer size
        w_b = int(round((r - l) * w))
        h_b = int(round((t - b) * h))
        bfr_size = (w_b, h_b)

        if min(bfr_size) < 2:
            self.__update_selection([])
            return

        def get_off_axis_lens(film_size):

            lens = self.showbase.camLens
            focal_len = lens.focal_length
            lens = lens.make_copy()
            lens.film_size = film_size
            lens.film_offset = (x_f, y_f)
            lens.focal_length = focal_len

            return lens

        lens = get_off_axis_lens((w_f, h_f))
        cam_np = self.region_sel_cam
        cam = cam_np.node()
        cam.set_lens(lens)
        cam.camera_mask = self.selection_cam_mask
        tex_buffer = self.showbase.win.make_texture_buffer("tex_buffer", w_b, h_b)
        cam.active = True
        self.showbase.make_camera(tex_buffer, useCamera=cam_np)

        obj_count = len(RegionSelectables.objs)

        tex = Texture()
        tex.setup_1d_texture(obj_count, Texture.T_int, Texture.F_r32i)
        tex.clear_color = (0., 0., 0., 0.)
        shader = Shader.make(Shader.SL_GLSL, VERT_SHADER, FRAG_SHADER)

        state_np = NodePath("state_np")
        state_np.set_shader(shader, 1)
        state_np.set_shader_input("selections", tex, read=False, write=True)
        state = state_np.get_state()
        self.region_sel_cam.node().initial_state = state

        self.showbase.graphics_engine.render_frame()
        gsg = self.showbase.win.get_gsg()

        if self.showbase.graphics_engine.extract_texture_data(tex, gsg):

            texels = memoryview(tex.get_ram_image()).cast("I")
            visible_obj_indices = []

            for i, mask in enumerate(texels):
                for j in range(32):
                    if mask & (1 << j):
                        index = 32 * i + j
                        visible_obj_indices.append(index)

#            print("\nVisible objects:", visible_obj_indices)
            self.__update_selection(visible_obj_indices)

        else:

#            print("\nNo objects are in view.")
            self.__update_selection([])

        state_np.clear_attrib(ShaderAttrib)
        self.showbase.graphics_engine.remove_window(tex_buffer)
        cam.active = False

Main script example:

from panda3d.core import *
from direct.showbase.ShowBase import ShowBase
from region_selection import RegionSelector


class MyApp(ShowBase):

    def __init__(self):

        ShowBase.__init__(self)

        self.disable_mouse()
        self.camera.set_pos(0., -50., 2.)
        self.camera.look_at(0., 0., 0.)

        self.selection_color = (1., 0., 0., 1.)
        self.selector = RegionSelector(self, self.deselect_objects, self.select_objects)

        for i in range(10):
            smiley = self.loader.load_model("smiley")
            smiley.reparent_to(self.render)
            smiley.set_x(2. * i - 10.)
            self.selector.add(smiley)

        self.accept("mouse1", self.selector.start_region_draw)
        self.accept("mouse1-up", self.selector.end_region_draw)

    def deselect_objects(self, objs):

        # remove tint from deselected objects
        for obj in objs:
            obj.clear_color_scale()

    def select_objects(self, objs):

        # give the selected objects a specific tint
        for obj in objs:
            obj.set_color_scale(self.selection_color)


app = MyApp()
app.run()

This code could actually be extended to allow circle/ellipse-selection, fence-selection, lasso-selection, paint-selection and even include the option to limit selection to objects completely enclosed by the selection shape. You can already try these things out in my Panda3D Studio project if you’re interested. Perhaps I will add the relevant code to this example and create a GitHub repository for it – if and when I find the time, that is.

2 Likes

In case you’re interested, I’ve extracted the needed code from my project into a stand-alone module, which you can find out more about here.

Thank you, I’ll take a look. :smiley: