Drag Selecting multiple nodes

: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()