Drag Selecting multiple nodes

I know its an old thread but this draw-select solution is the best working implementation i could find so far. I’m struggling with the single-click handling.
So far I’ve encapsulated the latest version (ynjh_jo, 2009-08-01, first variant) into its own class.
The class also checks each selection for single clicks and reduces the result to only one entry.

Only problem left is the item that gets selected on a single click. In some cases when some smileys overlap, the one in the background gets selected.

I’ve tried

self.selHandler.sortEntries()

but the result was the same…

Help would be appreciated :slight_smile:

BTW:
I guess the following question goes to all contributors:
What about the license for this code snippet? Is it allowed to be used in (may-be) commercial projects or OSS only?

* see below for updated version *

That never cross anyone’s mind, I guess.

In that case, you should :

  1. compare getMouse() on mouse press and release, if they’re exactly the same, then it indicates a single object selection is prefered
  2. sort the entries, as you did
  3. don’t loop over ALL collision entries :
for i in range(self.selHandler.getNumEntries()):
  1. but, use only the first entry instead :
for i in range(1):

but first, check if there is any entry :

if self.selHandler.getNumEntries():

That’s why I’ve added:

    def wasSingleClick(self, curMousePos):
        return curMousePos >= self.click_start - 0.1 and curMousePos <= self.click_start + 0.1

Is there a more elegant solution to this check? I mean something like getMouse().hasClicked() ?
(The +/- 0.1 is for ppl with shaky hands :smiley: )

I tried that already but then simleys that are way off the cursor get selected.

+clicked here
|
o    (o) o
      |
      +- this one gets selected

So I guessed I’d need the set() to filter out all objects that touch less than 4 planes.

EDIT: I thought about it a little… Wouldn’t it be faster using a simple CollisionRay for single clicks? I mean performance-wise.

Now that I think of it, sorting the entries in this case is pointless, because the collision DEPTH is not measured from the camera’s position, but perpendicular from the planes, which are perpendicular to camera view direction.

From the camera’s point of view :

  1. you’d expect the depths along the Y axis
  2. BUT, the depths are actually on the XZ plane

example :
Smiley1 is farther away than Smiley2 from the camera, but when you click on the “x”, Smiley 1 is closer to the collision planes, which is 4 units, while Smiley 2 is 9 units, so Smiley1 is the one will be selected.

(S1)321x12345678(S2)
       |
       --- click

So in this case, you’d be better using CollisionRay, using the wasSingleClick() to switch between Ray or Planes.

Ah that makes sense :slight_smile: Thanks for the explanation ynjh_jo!

Sorry to bother again with the clicking issue but I’m getting frustrated now…
What am I doing wrong? I’ve modified the code to either use the Ray or the Planes. The drag-selection works but the ray never returns any collisions.

When I set the rays IntoCollideMask to bit(1) and enable showCollisions I can see that it pierces the clicked smiley, so there must be something trivial missing.

The forum could use a default-collapsed code block :slight_smile:
I really hate to spam, but anyway… here’s the current version:

* final version below *

First, the smileys’ collision spheres act as FROM objects (with FROM bit 1 on, INTO bit off), while the pyramidal collision planes act as INTO objects.
Then you added a collision ray and set it also as FROM object (with FROM bit 1 on, INTO bit off). They won’t collide, as the manual on collision bitmask clearly stated that :

So, to make it work, I did :

  1. set the smileys’ spheres INTO bit(2) on
  2. change the ray node’s FROM bit to 2

Spheres just can’t collide INTO rays, if you set the ray’s INTO bit to 1.

Second, you forgot the .getParent() after the getIntoNodePath() in handleMouseClick.

:blush: Ok from now on I’ll take a closer look into the manual before posting such silly mistakes. Guess I was fixed to my example scripts and didn’t think of the Bitmask manual page.

I must have overlooked the missing getParent() because the interpreter never reached that part :unamused:

And again: Many thanks for the help ynjh_jo!

I’ve updated the snippet with the fixes and CONTROL + SHIFT key support for adding / removing selected entries. Might be useful for others too

from pandac.PandaModules import * 
from direct.directbase.DirectStart import * 
from direct.showbase.DirectObject import DirectObject 
from direct.interval.IntervalGlobal import * 
from direct.task import Task 
import random, sys 


class ClickSelector(DirectObject):
    def __init__(self): 
        self.collTrav = CollisionTraverser('selector') 
        self.selHandler = CollisionHandlerQueue() 
        self.selection = set()
        self.tmpSelection = set()
        CM = CardMaker('sel') 
        CM.setFrame(0, 1, 0, 1) 
        self.rect = render2d.attachNewNode(CM.generate()) 
        self.rect.setColor(0, 1, 1, .2) 
        self.rect.setTransparency(1) 
        self.rect.hide() 
        LS = LineSegs() 
        LS.moveTo(0, 0, 0) 
        LS.drawTo(1, 0, 0) 
        LS.drawTo(1, 0, 1) 
        LS.drawTo(0, 0, 1) 
        LS.drawTo(0, 0, 0) 
        self.rect.attachNewNode(LS.create()) 
        self.accept("control-mouse1", self.click)
        self.accept("shift-mouse1", self.click)
        self.accept("mouse1", self.click)
        
        # add collision-detection for single clicks
        self.clickColNP = camera.attachNewNode(CollisionNode('sel'))
        self.clickColRay = CollisionRay()
        self.clickColNP.node().addSolid(self.clickColRay)
        # set the FROM mask to 0x10 to match the INTO mask of the balls
        self.clickColNP.node().setFromCollideMask(BitMask32.bit(2))
        self.clickColNP.node().setIntoCollideMask(BitMask32.allOff())
        self.collTrav.addCollider(self.clickColNP, self.selHandler)
        
    def addSelectable(self, obj):
        self.collTrav.addCollider(obj, self.selHandler)

    def click(self): 
        if not base.mouseWatcherNode.hasMouse(): 
            return 
        self.click_start = Point2(base.mouseWatcherNode.getMouse()) 
        self.rect.setPos(self.click_start[0], 1, self.click_start[1]) 
        self.rect.show() 
        t = taskMgr.add(self.update_rect, "update_rect")
        self.acceptOnce("mouse1-up", self.release_click, extraArgs=[t])

    def update_rect(self, task): 
        if not base.mouseWatcherNode.hasMouse():    # check for mouse first, in case the mouse is outside the Panda window 
            return Task.cont 
        d = base.mouseWatcherNode.getMouse() - self.click_start 
        self.rect.setScale(d[0] if d[0] else 1e-3, 1, d[1] if d[1] else 1e-3) 
        return task.cont

    def release_click(self, t): 
        taskMgr.remove(t)
        if not base.mouseWatcherNode.hasMouse(): 
            return
        curMousePos = Point2(base.mouseWatcherNode.getMouse())
        
        # check for single clicks
        if curMousePos >= self.click_start - 0.01 and curMousePos <= self.click_start + 0.01:
            self.rect.hide()
            self.handleMouseClick()
        else:
            self.handleMouseDrag()
            
        # clear collision result
        self.selHandler.clearEntries()
        
        # check for pressed shift or control keys
        shiftKey = base.mouseWatcherNode.isButtonDown(KeyboardButton.shift())
        controlKey = base.mouseWatcherNode.isButtonDown(KeyboardButton.control())
        
        # now update the selection
        # SHIFT: add to the old selection
        # CONTROL: remove from the old selection
        # NO-KEY: set as new selection
        newSet = set()
        for i in self.selection:
            if shiftKey:
                newSet.add(i)
                self.selectObj(i)
            elif controlKey:
                if not i in self.tmpSelection:
                    newSet.add(i)
                    self.selectObj(i)
                else:
                    self.deselectObj(i)
            else:
                self.deselectObj(i)
        if not controlKey:
            for i in self.tmpSelection:
                newSet.add(i)
                self.selectObj(i)
        self.selection = newSet
        self.tmpSelection = set()
        
    def selectObj(self, obj):
        obj.setColorScale(Vec4(1, 0, 0, 1)) 

    def deselectObj(self, obj):
        obj.setColorScale(Vec4(1)) 
    
    def handleMouseClick(self):
        self.clickColRay.setFromLens(base.camNode, self.click_start[0], self.click_start[1])
        # start collision check
        self.collTrav.traverse(render)
        # check for collisions 
        if self.selHandler.getNumEntries() > 0:
            # sort to select the front-most entry
            self.selHandler.sortEntries()
            n = self.selHandler.getEntry(0).getIntoNodePath().getParent()
            self.tmpSelection.add(n)

    def handleMouseDrag(self):
        bmin, bmax = self.rect.getTightBounds()
        clickLL = Point2(bmin[0], bmin[2])    # lower left 
        clickUR = Point2(bmax[0], bmax[2])    # upper right 
        if clickUR == clickLL:    # Fudge the numbers a bit to avoid the degenerate case of no rectangle 
            clickUR = Point2(clickUR[0] + .00001, clickUR[1] + .00001) 
        crap = Point3() 
        llF = Point3() 
        urF = Point3() 
        base.camLens.extrude(clickLL, crap, llF) 
        base.camLens.extrude(clickUR, crap, urF) 

        ulF = Point3(llF[0], llF[1], urF[2]) 
        brF = Point3(urF[0], urF[1], llF[2]) 

        camOrigin = Point3(0) 
        left = CollisionPlane(Plane(camOrigin, ulF, llF))    # Create the 4 sides with planes 
        right = CollisionPlane(Plane(camOrigin, brF, urF))    # They should all 'face' OUTward: i.e. 
        bot = CollisionPlane(Plane(camOrigin, llF, brF))    # Collisions are INSIDE 
        top = CollisionPlane(Plane(camOrigin, urF, ulF)) 
        pyramid = camera.attachNewNode(CollisionNode('pyramid'))
        pyramid.node().addSolid(left) 
        pyramid.node().addSolid(top) 
        pyramid.node().addSolid(right) 
        pyramid.node().addSolid(bot)

        # check for collisions
        self.collTrav.traverse(render)
        if self.selHandler.getNumEntries() > 0:
            hits = [] 
            for i in range(self.selHandler.getNumEntries()): 
                hits.append(self.selHandler.getEntry(i).getFromNodePath().getParent()) 
            # If the object is within the rectangle, it collides with all 4 planes. 
            # Use set to remove duplicates collision entries of the same object. 
            self.tmpSelection = set(filter(lambda i: hits.count(i) == 4, hits))
        pyramid.removeNode()
        self.rect.hide()
        


class World(DirectObject): 
    def __init__(self): 
        self.selector = ClickSelector()
        for i in range(20): 
            ball = loader.loadModel("smiley") 
            ball.reparentTo(render) 
            ball.setPos(random.randrange(-3.0, 3.0) * 2, random.randrange(-3.0, 3.0) * 2, random.randrange(-3.0, 3.0) * 2)
            # set the collision sphere to collide FROM the drag-Planes and INTO the click-Ray
            cnodePath = ball.attachCollisionSphere(
                 'sel', 0, 0, 0, 1, fromCollide=BitMask32.bit(1), intoCollide=BitMask32.bit(2)) 
            self.selector.addSelectable(cnodePath)
            # ~ ball.showCS() 
        self.accept("escape", sys.exit) 
base.disableMouse() 
# animate camera orientation, to see if the pyramid is correct 
camPivot = render.attachNewNode('') 
camera.reparentTo(camPivot) 
camera.setY(-30) 
pos = camera.getPos() 
Sequence(
    camera.posInterval(20, pos * 1.5, pos),
    camera.posInterval(20, pos),
    ).loop() 
camPivot.hprInterval(150, Vec3(360)).loop() 
winst = World() 
run()