Zooming interface?

Random question about Panda’s capability that I don’t have time to investigate for myself right now. I’ve been thinking of implementing a zooming interface – that is, a point-and-click and keyboard-typing interface in which navigation is done by zooming in on interface elements to see and manipulate one element or group of elements in more detail or zooming out to get more of an overview, seeing many elements at once but in less detail.

So for example the interface might display a number of boxes each with an image and some text. Moving the mouse highlights each box as you mouse over it. Clicking on a box zooms in on it so that it fills the screen and the other boxes are out of view, and you can now, for example, click on the text of the box and change it. A ‘zoom out’ button returns you to the view of all the boxes at once.

So the interface elements themselves are pretty basic and the pointing and clicking and typing is basic. At first I thought Panda would be perfect for the zooming – just move the camera. But then I remembered that DirectGUI elements are not in the 3D scene but are on aspect2D.

So, is there a nice, easy way to get Panda to zoom in and out on DirectGUI elements like this?

One consideration is that the interface elements need to be legible at every level of zoom. So for example if a box has lots of text in it that’s readable when zoomed in, it’ll be too small to read when zoomed out, so a summary of the text in a readable size should be shown instead. Similarly with detailed images, a thumbnail should appear in a visible size when zoomed out.

The user doesn’t have to be able to zoom in and out to any degree they want – there is a finite number of zoom levels and the user jumps from level to level by clicking on an object to zoom in on it by one level or clicking on the zoom out button to go out by one level.

Anyone have any hints about this that might be useful when I come to investigating how to implement it?

You could zoom in/out by changing the scale of aspect2d (or of a DirectFrame that you parent to aspect2d).

Oooh, yes I suppose that would work wouldn’t it? In theory. I’d need to setup a task to smoothly scale the DirectFrame from one scale to another independent of framerate, so the user sees an animated zooming in or zooming out. But that shouldn’t be difficult. Thanks!

Hmm… it does get a little more complicated than that. If the target the user clicked is not dead-centre on the screen, then as well as scaling the DirectFrame, you have to move it so that the target is centred at the same time, and the two movements should be timed to start and end at once. That doesn’t sound unsurmountable though.

Can GUI elements be rendered in an off screen buffer?
If so, you could try the following:

Render the text and whatever you want to have displayed in a framebuffer object. Generate a texture from that buffer and apply this texture to a cube in your 3D world.
So during the zoom-in animation you could increase the detail level of that texture, so it won’t look crappy when you’ve closed in.
After the zoom-in is done, you just need to get the GUI elements (like direct input, to change the text) back on your screen at the exact same position as the texture on your cube(remember to give your cube a blank texture).

This works :

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

class World(DirectObject):
  def __init__(self):
      self.aspRatio=base.getAspectRatio()
      self.accept('escape',sys.exit)
      self.accept('mouse1',self.click)
      self.accept('space',self.restorePosScale)
      self.accept('restoreAspect2d',self.restoreA2dSG)

      # normal proportion
      scale=.2
      self.normalScaledSmileys=aspect2d.attachNewNode('')
      for x in range(4):
          for z in range(4):
              lilSmiley=loader.loadModelCopy('misc/lilsmiley')
              lilSmiley.reparentTo(self.normalScaledSmileys)
              lilSmiley.setScale(scale)
              lilSmiley.setPos(x*scale,0,z*scale)
      self.normalScaledSmileys.setCollideMask(BitMask32.bit(3))
      self.normalScaledSmileys.setPos(0,0,.2)

      # Z stretched smileys
      scale=.2
      self.ZstretchedSmileys=aspect2d.attachNewNode('')
      for x in range(4):
          for z in range(4):
              lilSmiley=loader.loadModelCopy('misc/lilsmiley')
              lilSmiley.reparentTo(self.ZstretchedSmileys)
              lilSmiley.setScale(scale)
              lilSmiley.setPos(x*scale,0,z*scale)
      self.ZstretchedSmileys.setCollideMask(BitMask32.bit(3))
      self.ZstretchedSmileys.setScale(1,1,2)
      self.ZstretchedSmileys.setPos(-1,0,-.5)

      # X stretched smileys
      scale=.2
      self.XstretchedSmileys=aspect2d.attachNewNode('')
      for x in range(4):
          for z in range(4):
              lilSmiley=loader.loadModelCopy('misc/lilsmiley')
              lilSmiley.reparentTo(self.XstretchedSmileys)
              lilSmiley.setScale(scale)
              lilSmiley.setPos(x*scale,0,z*scale)
      self.XstretchedSmileys.setCollideMask(BitMask32.bit(3))
      self.XstretchedSmileys.setScale(2,1,1)
      self.XstretchedSmileys.setPos(-.1,0,-.8)


      # setup collision against aspect2d
      A2DeditMouseCol = CollisionNode('mouseRay')
      A2DeditMouseCol.addSolid(CollisionRay(0,-100,0, 0,1,0))
      A2DeditMouseCol.setFromCollideMask(BitMask32.bit(3))
      A2DeditMouseCol.setIntoCollideMask(BitMask32.allOff())
      self.mouseColNP = aspect2d.attachNewNode(A2DeditMouseCol)
      self.mousecTrav=CollisionTraverser()
      self.mousecQueue = CollisionHandlerQueue()
      self.mousecTrav.addCollider(self.mouseColNP, self.mousecQueue)
      self.mousecTrav.showCollisions(aspect2d)

      taskMgr.add(self.followMouse,'followMouse')
      self.selectedNP=None
      self.A2Dtempdummy=render2d.attachNewNode('')

  def followMouse(self,t):
      if not base.mouseWatcherNode.hasMouse():
         return Task.cont
      mpos=base.mouseWatcherNode.getMouse()
      self.mouseColNP.setPos(render2d,mpos[0],0,mpos[1])
      self.mousecTrav.traverse(aspect2d)
      if self.mousecQueue.getNumEntries():
         self.selectedNP=self.mousecQueue.getEntry(0).getIntoNodePath()
      return Task.cont

  def click(self):
      if not self.selectedNP or self.isZooming():
         return
      posR2D=self.selectedNP.getPos(render2d)

      self.A2Dtempdummy.setPos(posR2D)
      aspect2d.wrtReparentTo(self.A2Dtempdummy)
      scale=self.selectedNP.getScale(aspect2d)
      selfRatio=scale[0]/scale[2]
      parentScale=self.selectedNP.getParent().getScale(self.A2Dtempdummy)
      bounds3=self.selectedNP.getTightBounds()
      bounds=(bounds3[1]-bounds3[0])
      scaledBounds=Vec3(bounds[0]*selfRatio,bounds[1],bounds[2])
      print selfRatio,scaledBounds
      if selfRatio<self.aspRatio:
         maxScale=2./scaledBounds[2]
      else:
         maxScale=2*self.aspRatio/scaledBounds[0]
      #print maxScale
      maxSize=Vec3(maxScale/(parentScale[0]*self.aspRatio/selfRatio),
                   maxScale/parentScale[1],
                   maxScale/parentScale[2])
      #print maxSize

      self.ZoomInIval = self.A2Dtempdummy.posHprScaleInterval(.5,
           self.A2Dtempdummy.getPos()-posR2D,
           self.A2Dtempdummy.getHpr(),
           maxSize
           )
      self.ZoomInIval.setDoneEvent('restoreAspect2d')
      self.ZoomInIval.start()

  def restoreA2dSG(self):
      aspect2d.wrtReparentTo(render2d)

  def restorePosScale(self):
      if self.isZooming():
         return
      self.A2Dtempdummy.clearTransform()
      self.ZoomOutIval = aspect2d.posHprScaleInterval(.5,
           Point3(0,0,0),
           aspect2d.getHpr(),
           Vec3(1/self.aspRatio,1,1)
           )
      self.ZoomOutIval.start()

  def isZooming(self):
      zooming=0
      if hasattr(self,'ZoomInIval'):
         zooming|=self.ZoomInIval.isPlaying()
      if hasattr(self,'ZoomOutIval'):
         zooming|=self.ZoomOutIval.isPlaying()
      return zooming

World()
run()

It uses your entire window to zoom in the object, using tight bounds. If the object’s x/z is larger than your window aspect ratio, then the object will be zoomed maximumly along X axis, otherwise along Z axis.
Press space to restore the pos and scale.

:smiley: – that’s perfect! I have a feeling this will be a really valuable example for me in the coming months. Thanks ynjh_jo, and others!

Nice example ynjh_jo! Thanks for sharing.

Quick question: To get rid of the pixelization upon ZOOM IN, would one apply a sort of LOD function such as when a image is clicked and it is zoomed to that location a High res image switches out that low res one when it reaches its destination?

Cool stuff!

Honestly I don’t understand Legion’s point above.
You have some options to choose :

1. using high-res texture since the first place. The loaded textured object mostly used the default trilinear mipmap texture-filter (FTLinearMipmapLinear). For on-the-fly textured object :

tex=loader.loadTexture('whatever.jpg')
tex.setMagfilter(Texture.FTLinearMipmapLinear)
tex.setMinfilter(Texture.FTLinearMipmapLinear)
yourObject.setTexture(tex,1)

The MIPmap texture filter automatically switches the MIPmap level textures with respect to it’s coverage onto the screen.
en.wikipedia.org/wiki/Mipmap

2. if you don’t like any of the offered filters, then you should run a task to monitor the screen coverage of your texture, and switch it yourself when the (texture size/screen size) match your need.
To get the texture’s original size :

tex=yourObject.findTexture('*')
p = PNMImageHeader()
p.readHeader(tex.getFullpath())
print p.getXSize(),p.getYSize()

Thats a creative idea:

Code: 
tex=yourObject.findTexture('*') 
p = PNMImageHeader() 
p.readHeader(tex.getFullpath()) 
print p.getXSize(),p.getYSize() 

Thanks!

Just looking at this code again. ynjh_jo, I’d be really grateful if you could explain one part of it in detail, this section in the click function where the zoom is actually carried out.

posR2D=self.selectedNP.getPos(render2d)
self.A2Dtempdummy.setPos(posR2D)
aspect2d.wrtReparentTo(self.A2Dtempdummy)
scale=self.selectedNP.getScale(aspect2d)
selfRatio=scale[0]/scale[2]
parentScale = self.selectedNP.getParent().getScale(self.A2Dtempdummy)
bounds3=self.selectedNP.getTightBounds()
bounds=(bounds3[1]-bounds3[0])
scaledBounds = Vec3(bounds[0]*selfRatio,bounds[1],bounds[2])
if selfRatio<self.aspRatio:
    maxScale=2./scaledBounds[2]
else:
    maxScale=2*self.aspRatio/scaledBounds[0]
maxSize=Vec3(maxScale/(parentScale[0]*self.aspRatio/selfRatio),
                    maxScale/parentScale[1],
                    maxScale/parentScale[2])

I have to admit, it is indeed complicated. How could I create such bad code all the time ? Obviously there is a cleaner way.

      posR2D=self.selectedNP.getPos(render2d)
      self.A2Dtempdummy.setPos(posR2D)
      aspect2d.wrtReparentTo(self.A2Dtempdummy)
      scale=self.selectedNP.getScale(aspect2d)
      selfRatio=scale[0]/scale[2]
      bounds3=self.selectedNP.getTightBounds()
      bounds=render2d.getRelativeVector(self.selectedNP.getParent(),bounds3[1]-bounds3[0])
      if selfRatio<self.aspRatio:
         maxScale=2./bounds[2]
      else:
         maxScale=2./bounds[0]
      self.ZoomInIval = self.A2Dtempdummy.posHprScaleInterval(.5,
           self.A2Dtempdummy.getPos()-posR2D,
           self.A2Dtempdummy.getHpr(),
           self.A2Dtempdummy.getScale()*maxScale
           )

So, to zoom in, the scale factor we need is the division of the screen size (which is 2x2, relative to render2d) by the desired object’s size (tight bounds), but this time, it must be relative to render2d too, to hide the complicated calc under the hood.
I hope it clears the fog.

Been playing with this a bit more. I’ve got a little demo that does free-form zooming and panning left/right and up/down using intervals. That is, the user holds down a key to zoom or pan the camera as much as they want, and releases it when they want to stop.

The way your version works, clicking on an object and automatically zooming and panning correctly to view that object is both more useful and more difficult to implement.

There’s one more thing I noticed that I don’t understand at all. I discovered that if you scale render2d, or aspect2d, or a child node of aspect2d, it has no effect. Position transforms will work, but scaling doesn’t. So, following your example, I attach a dummy node to render2d then reparent aspect2d to the dummy node, thus inserting a dummy node between render2d and aspect2d, then scale the dummy node, with the result that the whole 2D scene graph beneath aspect2d is scaled.

My question is, why can’t render2d and aspect2d be scaled?

Also, why do you reparent aspect2d to render2d after a zoom operation finishes? That seems unnecessary, you can just leave the dummy node between render2d and aspect2d and reuse it. Not that it matters.

Thanks again ynjh_jo, this little trick is so odd that I’m sure I would never have discovered it myself, and wonder how you came to know such an obscure thing.

Here is my code so far. I’ve tried to integrate ynjh_jo’s zoom-to-node into my module, but I’ve made some mistake I haven’t found yet. Pressing q should cause it to zoom in on one of the OnscreenImages, but it doesn’t, although if you press q a second time it pans over to the correct image. No idea why yet.

To run it you need an image file called house.png in the working directory.

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

wp = WindowProperties()
wp.setSize(1024,768)
base.win.requestProperties(wp) 

# The coordinate space of aspect 2D is (l,r,b,t): 
#     (-aspRatio,aspRatio,-1,1)
# (You can go beyond this coordinate space, but you'll be outside
# the Panda3D window.)
aspRatio=base.getAspectRatio()

# Put a bunch of stuff on aspect2d.
# Objects are placed at (0,0) if no pos is specified.
ost = OnscreenText(text='Hello!')

# Warning! Image files should be a power of 2 in size:
# 1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048 ...
# otherwise Panda3D will scale and possibly stretch them.
# As long as it's a power of 2 and square an image seems to get
# scaled only, not stretched.
# Trying to get an image with OnscreenImage to appear the same
# pixel size as it is in file seems to be impossible.
# To avoid these difficulties with OnscreenImage you should use
# egg-texture-cards instead:
# <https://discourse.panda3d.org/viewtopic.php?t=3467&highlight=egg+texture+card>
#
# When scaling and positioning on aspect2d, the Y value is
# irrelevant, it should always be 1 when scaling and 0 when
# positioning.
osi = OnscreenImage(image='house.png',scale=.3,pos=(-.5,0,.5))

# And some DirectGUI objects...
node = aspect2d.attachNewNode('n')
button = DirectButton(text = ("OK", "click!", "rolling over", "disabled"), scale=.1, pos=(.5,0,.5))
checkButton = DirectCheckButton(text = "CheckButton" ,scale=.05, pos=(0,0,-.5))
entry = DirectEntry(scale=.05,initialText="Write on me!", numLines=2, pos=(.25,0,.75))
label = DirectLabel(scale=.05,text="DirectLabel",pos=(-1,0,0))

class Canvas(DirectObject):

    def __init__(self):

        self.accept("escape",sys.exit)

        # For some reason, although position transforms will work,
        # scale transforms do not have any effect when used on
        # render2d, aspect2d, or any child of aspect2d. But we CAN
        # scale a child node that we attach to render2d. Go
        # figure! So what we do is attach 'dummy' to render2d and
        # reparent aspect2d to dummy, so we have positioned dummy
        # between the default nodes render2d and aspect2d. Now if
        # we scale dummy the whole 2D scene graph scales!
        self.dummy=render2d.attachNewNode('dummy')
        aspect2d.wrtReparentTo(self.dummy)
    
        self.accept("x", self.zoom_out)
        self.accept("x-up",self.stop_zoom_out)
        self.accept("z",self.zoom_in)
        self.accept("z-up",self.stop_zoom_in)
        self.accept("arrow_left",self.pan_left)
        self.accept("arrow_left-up",self.stop_pan_left)
        self.accept("arrow_right",self.pan_right)
        self.accept("arrow_right-up",self.stop_pan_right)
        self.accept("arrow_up",self.pan_up)
        self.accept("arrow_up-up",self.stop_pan_up)
        self.accept("arrow_down",self.pan_down)
        self.accept("arrow_down-up",self.stop_pan_down)

        self.zoom_interval = None
        self.zoom_time = 2 # Time (in seconds) that it takes to
                           # zoom all the way from the minimum
                           # zoom level to the max or vice-versa.
        self.max_zoom = 4.0
        self.min_zoom = .1

        self.pan_x_interval = None
        self.pan_x_time = 2
        self.max_pan_x = 1.33
        self.min_pan_x = -1.33

        # In Panda the Z axis represents up. (Y is forward.)
        self.pan_z_interval = None
        self.pan_z_time = 2
        self.max_pan_z = 1.0
        self.min_pan_z = -1.0

        # Testing zoom to, need to implement picking.
        # Can't say I understand why this isn't working.
        osi = OnscreenImage(image='house.png',scale=.3,pos=(-1,0,-0.5))
        self.accept("q",self.zoom_to_node,[osi])

    def zoom_in(self):

        if self.zoom_interval is not None:
            if self.zoom_interval.isPlaying():
                self.zoom_interval.pause()

        current_zoom = self.dummy.getScale().getY()
        duration = ((self.max_zoom-current_zoom)/self.max_zoom)*self.zoom_time

        self.zoom_interval = self.dummy.scaleInterval(
            duration=duration,
            scale=4,
            name = "zoom_in")
        self.zoom_interval.start()

    def zoom_out(self):

        if self.zoom_interval is not None:
            if self.zoom_interval.isPlaying():
                self.zoom_interval.pause()
    
        current_zoom = self.dummy.getScale().getY()
        duration = ((current_zoom-self.min_zoom)/self.max_zoom)*self.zoom_time
        
        self.zoom_interval = self.dummy.scaleInterval(
            duration=.5,
            scale=.1,
            name = "zoom_out")
        self.zoom_interval.start()

    def stop_zoom_in(self):
    
        if self.zoom_interval is not None:
            if self.zoom_interval.isPlaying() and self.zoom_interval.getName() == "zoom_in":
                self.zoom_interval.pause()

    def stop_zoom_out(self):
    
        if self.zoom_interval is not None:
            if self.zoom_interval.isPlaying() and self.zoom_interval.getName() == "zoom_out":
                self.zoom_interval.pause()

    def pan_left(self):

        if self.pan_x_interval is not None:
            if self.pan_x_interval.isPlaying():
                self.pan_x_interval.pause()

        current_pan = self.dummy.getPos().getX()
        duration = ((self.max_pan_x-current_pan)/self.max_pan_x)*self.pan_x_time

        self.pan_x_interval = self.dummy.posInterval(
            duration=duration,
            pos = Vec3(self.max_pan_x,self.dummy.getPos().getY(),self.dummy.getPos().getZ()),
            name = "pan_left")
        self.pan_x_interval.start()

    def pan_right(self):

        if self.pan_x_interval is not None:
            if self.pan_x_interval.isPlaying():
                self.pan_x_interval.pause()

        current_pan = self.dummy.getPos().getX()
        duration = ((current_pan-self.min_pan_x)/self.max_pan_x)*self.pan_x_time

        self.pan_x_interval = self.dummy.posInterval(
            duration=duration,
            pos = Vec3(self.min_pan_x,self.dummy.getPos().getY(),self.dummy.getPos().getZ()),
            name = "pan_right")
        self.pan_x_interval.start()

    def stop_pan_left(self):
    
        if self.pan_x_interval is not None:
            if self.pan_x_interval.isPlaying() and self.pan_x_interval.getName() == "pan_left":
                self.pan_x_interval.pause()

    def stop_pan_right(self):
    
        if self.pan_x_interval is not None:
            if self.pan_x_interval.isPlaying() and self.pan_x_interval.getName() == "pan_right":
                self.pan_x_interval.pause()

    def pan_down(self):

        if self.pan_z_interval is not None:
            if self.pan_z_interval.isPlaying():
                self.pan_z_interval.pause()

        current_pan = self.dummy.getPos().getZ()
        duration = ((self.max_pan_z-current_pan)/self.max_pan_z)*self.pan_z_time

        self.pan_z_interval = self.dummy.posInterval(
            duration=duration,
            pos = Vec3(self.dummy.getPos().getX(),self.dummy.getPos().getY(),self.max_pan_z),
            name = "pan_down")
        self.pan_z_interval.start()

    def pan_up(self):

        if self.pan_z_interval is not None:
            if self.pan_z_interval.isPlaying():
                self.pan_z_interval.pause()

        current_pan = self.dummy.getPos().getZ()
        duration = ((current_pan-self.min_pan_z)/self.max_pan_z)*self.pan_z_time

        self.pan_z_interval = self.dummy.posInterval(
            duration=duration,
            pos = Vec3(self.dummy.getPos().getX(),self.dummy.getPos().getY(),self.min_pan_z),
            name = "pan_up")
        self.pan_z_interval.start()

    def stop_pan_up(self):
    
        if self.pan_z_interval is not None:
            if self.pan_z_interval.isPlaying() and self.pan_z_interval.getName() == "pan_up":
                self.pan_z_interval.pause()

    def stop_pan_down(self):
    
        if self.pan_z_interval is not None:
            if self.pan_z_interval.isPlaying() and self.pan_z_interval.getName() == "pan_down":
                self.pan_z_interval.pause()

    def zoom_to_node(self,np):
        
        pos = np.getPos(render2d)
        scale = np.getScale(aspect2d)
        aspect_ratio = scale[0]/scale[2]
        bounds3 = np.getTightBounds()
        bounds = render2d.getRelativeVector(np.getParent(),bounds3[1]-bounds3[0])
        if aspect_ratio < base.getAspectRatio():
            maxScale=2./bounds[2]
        else:
            maxScale=2./bounds[0]
        
        print 'Zooming from ',self.dummy.getPos()
        print 'Zooming to ', self.dummy.getPos() - pos
        self.zoom_to_interval = self.dummy.posHprScaleInterval(
            duration = .5,
            pos = self.dummy.getPos() - pos,
            hpr = self.dummy.getHpr(),
            scale =  self.dummy.getScale() * maxScale
            )                                      
        self.zoom_to_interval.start()

c = Canvas()        
run ()

render2d’s transform means nothing since it’s the top scenegraph node. Aspect2d is scalable, but if you change it before run(), it’s overriden by ShowBase.__windowEvent, that’s why it seems to resist. Put some print’s there and get ready to be surprised !

No, the dummy node’s transform is meant to affect aspect2d only during zoom process. Before zooming in, that dummy node must be placed to object’s center, without carrying aspect2d. If you don’t like it that way, sure you can save its last transform, relative to render2d (or something static), and restore it back after placing the dummy node.

Of course your code doesn’t work, since the dummy node is not placed in osi’s center.
This piece shows what I said earlier, plus solving your problem.

    def zoom_to_node(self,np):
        a2dOldTransf=aspect2d.getTransform(render2d)
        self.dummy.setPos(np,0,0,0)
        aspect2d.setTransform(render2d,a2dOldTransf)

Thanks again ynjh_jo. I’ve now got the integration of zoom_to_node working, and I’ve also integrated the zooming back to home and the mouse clicking from your code. I still don’t totally understand the zoom_to_node code, but that’s the only bit, and it’s all working.

Next, I plan to implement some simple layout nodes. For example, a node that lays out all the child nodes you add to it in a neat horizontal or vertical line, or in a grid formation, etc. I know you already implemented a grid layout in this thread with a simple for-loop, I’m basically planning to generalise that a little.

Here’s the code I have so far:

# Todo: implement layouts, and have a demo that uses a bunch of
# objects in a layout.
# Use NodePath.getChildren().asList():
# <https://discourse.panda3d.org/viewtopic.php?t=4120&highlight=getchildren>
# Also this manual page:
# <http://panda3d.org/manual/index.php/Searching_the_Scene_Graph>

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

wp = WindowProperties()
wp.setSize(1024,768)
base.win.requestProperties(wp) 

# The coordinate space of aspect 2D is (l,r,b,t): 
#     (-aspRatio,aspRatio,-1,1)
# (You can go beyond this coordinate space, but you'll be outside
# the Panda3D window.)
aspRatio=base.getAspectRatio()

# Put a bunch of stuff on aspect2d.
# Objects are placed at (0,0) if no pos is specified.
ost = OnscreenText(text='Hello!')

# Warning! Image files should be a power of 2 in size:
# 1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048 ...
# otherwise Panda3D will scale and possibly stretch them.
# As long as it's a power of 2 and square an image seems to get
# scaled only, not stretched.
# Trying to get an image with OnscreenImage to appear the same
# pixel size as it is in file seems to be impossible.
# To avoid these difficulties with OnscreenImage you should use
# egg-texture-cards instead:
# <https://discourse.panda3d.org/viewtopic.php?t=3467&highlight=egg+texture+card>
#
# When scaling and positioning on aspect2d, the Y value is
# irrelevant, it should always be 1 when scaling and 0 when
# positioning.
osi = OnscreenImage(image='house.png',scale=.3,pos=(-.5,0,.5))

# And some DirectGUI objects...
node = aspect2d.attachNewNode('n')
button = DirectButton(text = ("OK", "click!", "rolling over", "disabled"), scale=.1, pos=(.5,0,.5))
checkButton = DirectCheckButton(text = "CheckButton" ,scale=.05, pos=(0,0,-.5))
entry = DirectEntry(scale=.05,initialText="Write on me!", numLines=2, pos=(.25,0,.75))
label = DirectLabel(scale=.05,text="DirectLabel",pos=(-1,0,0))

cm = CardMaker('card')
width,height = 0.088320312, 0.1328125
left,right,bottom,top = 0-0.5*width,0.5*width,0-0.5*height,0.5*height
width = right - left
height = top - bottom
cm.setFrame(left,right,bottom,top)
np = aspect2d.attachNewNode(cm.generate())
tex = loader.loadTexture('house.png')
np.setTexture(tex,1)
np.setPos(-1,0,-1)

# Set the collide mask that will make these objects clickable.
aspect2d.setCollideMask(BitMask32.bit(3))

class Canvas(DirectObject):

    def __init__(self):

        self.accept("escape",sys.exit)

        self.dummy=render2d.attachNewNode('dummy')
        aspect2d.wrtReparentTo(self.dummy)
    
        self.accept("x", self.zoom_out)
        self.accept("x-up",self.stop_zoom_out)
        self.accept("z",self.zoom_in)
        self.accept("z-up",self.stop_zoom_in)
        self.accept("arrow_left",self.pan_left)
        self.accept("arrow_left-up",self.stop_pan_left)
        self.accept("arrow_right",self.pan_right)
        self.accept("arrow_right-up",self.stop_pan_right)
        self.accept("arrow_up",self.pan_up)
        self.accept("arrow_up-up",self.stop_pan_up)
        self.accept("arrow_down",self.pan_down)
        self.accept("arrow_down-up",self.stop_pan_down)
        self.accept('space',self.zoom_to_home)

        self.zoom_interval = None
        self.zoom_time = 2 # Time (in seconds) that it takes to
                           # zoom all the way from the minimum
                           # zoom level to the max or vice-versa.
        self.max_zoom = 4.0
        self.min_zoom = .1

        self.pan_x_interval = None
        self.pan_x_time = 2
        self.max_pan_x = 1.33
        self.min_pan_x = -1.33

        # In Panda the Z axis represents up. (Y is forward.)
        self.pan_z_interval = None
        self.pan_z_time = 2
        self.max_pan_z = 1.0
        self.min_pan_z = -1.0

        # Create a CollisionRay and attach it to aspect2d.
        cn = CollisionNode('mouseRay')
        cn.addSolid(CollisionRay(0,-100,0, 0,1,0))
        # The ray will collide into anything with a matching mask.
        # NOTE: This means that if you want an object to be
        # clickable, you have to give it this collide mask.
        cn.setFromCollideMask(BitMask32.bit(3))
        # But nothing will collide into the ray.
        cn.setIntoCollideMask(BitMask32.allOff())
        # Keep a reference to the NodePath of the CollisionNode.
        self.cnp = aspect2d.attachNewNode(cn)
        # Now we need a collision traverser and collision handler.
        self.ctrav=CollisionTraverser()
        self.queue = CollisionHandlerQueue()
        # Add our collision node to the traverser, and associate
        # it with our handler.
        self.ctrav.addCollider(self.cnp, self.queue)
        # For debugging only.
        self.ctrav.showCollisions(aspect2d)

        # Last, we need a task function to check for any
        # collisions and update selection if the mouse is over
        # a node.
        taskMgr.add(self.mouse,'mouse')
        self.selection = None # Keeps track of which NodePath the
                              # mouse pointer is over.
        
        # When the mouse is clicked, we zoom in on the nodepath
        # that the mouse pointer is over (if any)/                      
        self.accept('mouse1',self.click)                              

    def mouse(self,t):
        """Check for collisions and update self.selection if the
           mouse pointer is over a nodepath."""
        
        # If the mouse pointer is not inside the window do nothing
        if not base.mouseWatcherNode.hasMouse():
            return Task.cont
        
        # Move the collision ray's nodepath to where the mouse
        # pointer is.
        mpos = base.mouseWatcherNode.getMouse()
        self.cnp.setPos(render2d,mpos[0],0,mpos[1])
        
        # Check whether the collision ray hits anything.
        self.ctrav.traverse(aspect2d)
        
        # If the ray collides with anything, point self.selection
        # to the first nodepath that the ray collides with.
        # Otherwise set self.selection to None.
        if self.queue.getNumEntries():
            self.selection = self.queue.getEntry(0).getIntoNodePath()
        else:
            self.selection = None
        
        return Task.cont

    def click(self):
        """
        If self.selection is not None and we are not already
        zooming, initiate a zoom_to_node on self.selected.
        """
       
        if self.selection is not None:
            self.zoom_to_node(self.selection)
        
    def zoom_in(self):

        if self.zoom_interval is not None:
            if self.zoom_interval.isPlaying():
                self.zoom_interval.pause()

        current_zoom = self.dummy.getScale().getY()
        duration = ((self.max_zoom-current_zoom)/self.max_zoom)*self.zoom_time

        self.zoom_interval = self.dummy.scaleInterval(
            duration=duration,
            scale=4,
            name = "zoom_in")
        self.zoom_interval.start()

    def zoom_out(self):

        if self.zoom_interval is not None:
            if self.zoom_interval.isPlaying():
                self.zoom_interval.pause()
    
        current_zoom = self.dummy.getScale().getY()
        duration = ((current_zoom-self.min_zoom)/self.max_zoom)*self.zoom_time
        
        self.zoom_interval = self.dummy.scaleInterval(
            duration=.5,
            scale=.1,
            name = "zoom_out")

        self.zoom_interval.start()

    def stop_zoom_in(self):
    
        if self.zoom_interval is not None:
            if self.zoom_interval.isPlaying() and self.zoom_interval.getName() == "zoom_in":
                self.zoom_interval.pause()

    def stop_zoom_out(self):
    
        if self.zoom_interval is not None:
            if self.zoom_interval.isPlaying() and self.zoom_interval.getName() == "zoom_out":
                self.zoom_interval.pause()

    def pan_left(self):

        if self.pan_x_interval is not None:
            if self.pan_x_interval.isPlaying():
                self.pan_x_interval.pause()

        current_pan = self.dummy.getPos().getX()
        duration = ((self.max_pan_x-current_pan)/self.max_pan_x)*self.pan_x_time

        self.pan_x_interval = self.dummy.posInterval(
            duration=duration,
            pos = Vec3(self.max_pan_x,self.dummy.getPos().getY(),self.dummy.getPos().getZ()),
            name = "pan_left")
        self.pan_x_interval.start()

    def pan_right(self):

        if self.pan_x_interval is not None:
            if self.pan_x_interval.isPlaying():
                self.pan_x_interval.pause()

        current_pan = self.dummy.getPos().getX()
        duration = ((current_pan-self.min_pan_x)/self.max_pan_x)*self.pan_x_time

        self.pan_x_interval = self.dummy.posInterval(
            duration=duration,
            pos = Vec3(self.min_pan_x,self.dummy.getPos().getY(),self.dummy.getPos().getZ()),
            name = "pan_right")
        self.pan_x_interval.start()

    def stop_pan_left(self):
    
        if self.pan_x_interval is not None:
            if self.pan_x_interval.isPlaying() and self.pan_x_interval.getName() == "pan_left":
                self.pan_x_interval.pause()

    def stop_pan_right(self):
    
        if self.pan_x_interval is not None:
            if self.pan_x_interval.isPlaying() and self.pan_x_interval.getName() == "pan_right":
                self.pan_x_interval.pause()

    def pan_down(self):

        if self.pan_z_interval is not None:
            if self.pan_z_interval.isPlaying():
                self.pan_z_interval.pause()

        current_pan = self.dummy.getPos().getZ()
        duration = ((self.max_pan_z-current_pan)/self.max_pan_z)*self.pan_z_time

        self.pan_z_interval = self.dummy.posInterval(
            duration=duration,
            pos = Vec3(self.dummy.getPos().getX(),self.dummy.getPos().getY(),self.max_pan_z),
            name = "pan_down")
        self.pan_z_interval.start()

    def pan_up(self):

        if self.pan_z_interval is not None:
            if self.pan_z_interval.isPlaying():
                self.pan_z_interval.pause()

        current_pan = self.dummy.getPos().getZ()
        duration = ((current_pan-self.min_pan_z)/self.max_pan_z)*self.pan_z_time

        self.pan_z_interval = self.dummy.posInterval(
            duration=duration,
            pos = Vec3(self.dummy.getPos().getX(),self.dummy.getPos().getY(),self.min_pan_z),
            name = "pan_up")
        self.pan_z_interval.start()

    def stop_pan_up(self):
    
        if self.pan_z_interval is not None:
            if self.pan_z_interval.isPlaying() and self.pan_z_interval.getName() == "pan_up":
                self.pan_z_interval.pause()

    def stop_pan_down(self):
    
        if self.pan_z_interval is not None:
            if self.pan_z_interval.isPlaying() and self.pan_z_interval.getName() == "pan_down":
                self.pan_z_interval.pause()

    def zoom_to_node(self,np):

        # ynjh_jo's zoom code. This is why we have the dummy node.
        # We move the dummy node to the center of the object we're
        # zooming in on, and use it as a target for aspect2d.
        a2dOldTransf=aspect2d.getTransform(render2d)
        self.dummy.setPos(np,0,0,0)
        aspect2d.setTransform(render2d,a2dOldTransf)
        
        pos = np.getPos(render2d)
        scale = np.getScale(aspect2d)
        aspect_ratio = scale[0]/scale[2]
        bounds3 = np.getTightBounds()
        bounds = render2d.getRelativeVector(np.getParent(),bounds3[1]-bounds3[0])
        if aspect_ratio < base.getAspectRatio():
            maxScale=2./bounds[2]
        else:
            maxScale=2./bounds[0]
        
        self.zoom_to_interval = self.dummy.posHprScaleInterval(
            duration = .5,
            pos = self.dummy.getPos() - pos,
            hpr = self.dummy.getHpr(),
            scale =  self.dummy.getScale() * maxScale
            )                                      
        self.zoom_to_interval.start()

    def zoom_to_home(self):
        """Zoom back to the original view."""
        
        a2dOldTransf=aspect2d.getTransform(render2d)
        self.dummy.clearTransform()
        aspect2d.setTransform(render2d,a2dOldTransf)
        self.zoom_home_interval = aspect2d.posHprScaleInterval(
            duration = .5,
            pos = Point3(0,0,0),
            hpr = aspect2d.getHpr(),
            scale = Vec3(1/base.getAspectRatio(),1,1)
            )
        self.zoom_home_interval.start()


c = Canvas()        
run ()

ynjh_jo, I found a strange behaviour with the zoom when the node being zoomed in has child nodes. I don’t understand it, but consider this demo:

from zui import *
import direct.directbase.DirectStart

class Frame():
    """
    A visible frame that holds a bunch of cards and that you can 
    zoom in on.
    
    """

    def __init__(self):

        cm = CardMaker('Frame')
        left,right,bottom,top = -1,1,-1,1
        width = right - left
        height = top - bottom
        cm.setFrame(left,right,bottom,top)
        self.np = aspect2d.attachNewNode(cm.generate())
        self.np.setCollideMask(BitMask32.bit(3))
        self.np.setPythonTag("clicked",self.clicked)
        self.np.setScale(.1)
    
    def clicked(self):
        pass
        
frame = Frame()
another_frame = Frame()
frame.np.setPos(-.5,0,0)
another_frame.np.setPos(1.1,0,0)
c = Canvas()        
run ()

This works fine. It creates a couple of plain white cards, if you click on a card it will zoom in on it, right-click and you zoom back out. Now if I add a couple of lines at the end before running Panda I can parent one frame to the other:

frame = Frame()
another_frame = Frame()
frame.np.setPos(-.5,0,0)
another_frame.np.setPos(1.1,0,0)
another_frame.np.wrtReparentTo(frame.np)
c = Canvas()        
run ()

This works fine also, it makes no difference to the zooming. Now lets say I also change the scale of the child node. If I make it smaller things work fine, but if I make the child node bigger than the parent:

frame = Frame()
another_frame = Frame()
frame.np.setPos(-.5,0,0)
another_frame.np.setPos(1.1,0,0)
another_frame.np.wrtReparentTo(frame.np)
another_frame.np.setScale(2)
c = Canvas()        
run ()

Now zooming in on the child node still works fine, but if I click on the parent node it does not zoom in all the way. The zoom stops too soon. You can also get the effect if you have many child nodes all smaller than the parent but bigger when combined:

frame = Frame()
frame.np.setPos(-.5,0,0)
for i in range(10):
    another_frame = Frame()
    another_frame.np.setPos(i*.1,0,i*.1)
    another_frame.np.wrtReparentTo(frame.np)
    another_frame.np.setScale(.2)
c = Canvas()        
run ()

Zooming in on any of the child nodes works fine, but if you click on the parent node it will not zoom in all the way.

Here’s the file zui.py that I’m importing in the above examples. It’s the same zoom_to_node function that’s being used when you click on an object. The code hasn’t changed much since the last time I posted it.

# Todo: implement layouts, and have a demo that uses a bunch of
# objects in a layout.
# Use NodePath.getChildren().asList():
# <https://discourse.panda3d.org/viewtopic.php?t=4120&highlight=getchildren>
# Also this manual page:
# <http://panda3d.org/manual/index.php/Searching_the_Scene_Graph>

from pandac.PandaModules import *
from direct.showbase.DirectObject import DirectObject
from direct.interval.IntervalGlobal import *
from direct.task import Task
import sys

class Canvas(DirectObject):

    def __init__(self):

        self.accept("escape",sys.exit)

        self.dummy=render2d.attachNewNode('dummy')
        aspect2d.wrtReparentTo(self.dummy)
    
        #self.accept("arrow_down", self.zoom_out)
        #self.accept("arrow_down-up",self.stop_zoom_out)
        #self.accept("arrow_up",self.zoom_in)
        #self.accept("arrow_up-up",self.stop_zoom_in)
        #self.accept("arrow_left",self.pan_left)
        #self.accept("arrow_left-up",self.stop_pan_left)
        #self.accept("arrow_right",self.pan_right)
        #self.accept("arrow_right-up",self.stop_pan_right)
        #self.accept("arrow_up",self.pan_up)
        #self.accept("arrow_up-up",self.stop_pan_up)
        #self.accept("arrow_down",self.pan_down)
        #self.accept("arrow_down-up",self.stop_pan_down)
        self.accept('mouse3',self.zoom_to_home)

        self.zoom_interval = None
        self.zoom_time = 2 # Time (in seconds) that it takes to
                           # zoom all the way from the minimum
                           # zoom level to the max or vice-versa.
        self.max_zoom = 4.0
        self.min_zoom = .1

        self.pan_x_interval = None
        self.pan_x_time = 2
        self.max_pan_x = 1.33
        self.min_pan_x = -1.33

        # In Panda the Z axis represents up. (Y is forward.)
        self.pan_z_interval = None
        self.pan_z_time = 2
        self.max_pan_z = 1.0
        self.min_pan_z = -1.0

        # Create a CollisionRay and attach it to aspect2d.
        cn = CollisionNode('mouseRay')
        cn.addSolid(CollisionRay(0,-100,0, 0,1,0))
        # The ray will collide into anything with a matching mask.
        # NOTE: This means that if you want an object to be
        # clickable, you have to give it this collide mask.
        cn.setFromCollideMask(BitMask32.bit(3))
        # But nothing will collide into the ray.
        cn.setIntoCollideMask(BitMask32.allOff())
        # Keep a reference to the NodePath of the CollisionNode.
        self.cnp = aspect2d.attachNewNode(cn)
        # Now we need a collision traverser and collision handler.
        self.ctrav=CollisionTraverser()
        self.queue = CollisionHandlerQueue()
        # Add our collision node to the traverser, and associate
        # it with our handler.
        self.ctrav.addCollider(self.cnp, self.queue)
        # For debugging only.
        # self.ctrav.showCollisions(aspect2d)

        # Last, we need a task function to check for any
        # collisions and update selection if the mouse is over
        # a node.
        taskMgr.add(self.mouse,'mouse')
        self.selection = None # Keeps track of which NodePath the
                              # mouse pointer is over.
        
        # When the mouse is clicked, we zoom in on the nodepath
        # that the mouse pointer is over (if any)/                      
        self.accept('mouse1',self.click)                              

    def mouse(self,t):
        """Check for collisions and update self.selection if the
           mouse pointer is over a nodepath."""
        
        # If the mouse pointer is not inside the window do nothing
        if not base.mouseWatcherNode.hasMouse():
            return Task.cont
        
        # Move the collision ray's nodepath to where the mouse
        # pointer is.
        mpos = base.mouseWatcherNode.getMouse()
        self.cnp.setPos(render2d,mpos[0],0,mpos[1])
        
        # Check whether the collision ray hits anything.
        self.ctrav.traverse(aspect2d)
        
        # If the ray collides with anything, point self.selection
        # to the first nodepath that the ray collides with.
        # Otherwise set self.selection to None.
        if self.queue.getNumEntries():
            self.selection = self.queue.getEntry(0).getIntoNodePath()
        else:
            self.selection = None
        
        return Task.cont

    def click(self):
        """
        If self.selection is not None and we are not already
        zooming, initiate a zoom_to_node on self.selected.
        
        """       
        if self.selection is not None:
            self.zoom_to_node(self.selection)
            # The card class tags its nodepath with a callable Python
            # function.
            self.selection.getPythonTag("clicked")()
        
    def zoom_in(self):

        if self.zoom_interval is not None:
            if self.zoom_interval.isPlaying():
                self.zoom_interval.pause()

        current_zoom = self.dummy.getScale().getY()
        duration = ((self.max_zoom-current_zoom)/self.max_zoom)*self.zoom_time

        self.zoom_interval = self.dummy.scaleInterval(
            duration=duration,
            scale=4,
            name = "zoom_in")
        self.zoom_interval.start()

    def zoom_out(self):

        if self.zoom_interval is not None:
            if self.zoom_interval.isPlaying():
                self.zoom_interval.pause()
    
        current_zoom = self.dummy.getScale().getY()
        duration = ((current_zoom-self.min_zoom)/self.max_zoom)*self.zoom_time
        
        self.zoom_interval = self.dummy.scaleInterval(
            duration=.5,
            scale=.1,
            name = "zoom_out")
        self.zoom_interval.start()

    def stop_zoom_in(self):
    
        if self.zoom_interval is not None:
            if self.zoom_interval.isPlaying() and self.zoom_interval.getName() == "zoom_in":
                self.zoom_interval.pause()

    def stop_zoom_out(self):
    
        if self.zoom_interval is not None:
            if self.zoom_interval.isPlaying() and self.zoom_interval.getName() == "zoom_out":
                self.zoom_interval.pause()

    def pan_left(self):

        if self.pan_x_interval is not None:
            if self.pan_x_interval.isPlaying():
                self.pan_x_interval.pause()

        current_pan = self.dummy.getPos().getX()
        duration = ((self.max_pan_x-current_pan)/self.max_pan_x)*self.pan_x_time

        self.pan_x_interval = self.dummy.posInterval(
            duration=duration,
            pos = Vec3(self.max_pan_x,self.dummy.getPos().getY(),self.dummy.getPos().getZ()),
            name = "pan_left")
        self.pan_x_interval.start()

    def pan_right(self):

        if self.pan_x_interval is not None:
            if self.pan_x_interval.isPlaying():
                self.pan_x_interval.pause()

        current_pan = self.dummy.getPos().getX()
        duration = ((current_pan-self.min_pan_x)/self.max_pan_x)*self.pan_x_time

        self.pan_x_interval = self.dummy.posInterval(
            duration=duration,
            pos = Vec3(self.min_pan_x,self.dummy.getPos().getY(),self.dummy.getPos().getZ()),
            name = "pan_right")
        self.pan_x_interval.start()

    def stop_pan_left(self):
    
        if self.pan_x_interval is not None:
            if self.pan_x_interval.isPlaying() and self.pan_x_interval.getName() == "pan_left":
                self.pan_x_interval.pause()

    def stop_pan_right(self):
    
        if self.pan_x_interval is not None:
            if self.pan_x_interval.isPlaying() and self.pan_x_interval.getName() == "pan_right":
                self.pan_x_interval.pause()

    def pan_down(self):

        if self.pan_z_interval is not None:
            if self.pan_z_interval.isPlaying():
                self.pan_z_interval.pause()

        current_pan = self.dummy.getPos().getZ()
        duration = ((self.max_pan_z-current_pan)/self.max_pan_z)*self.pan_z_time

        self.pan_z_interval = self.dummy.posInterval(
            duration=duration,
            pos = Vec3(self.dummy.getPos().getX(),self.dummy.getPos().getY(),self.max_pan_z),
            name = "pan_down")
        self.pan_z_interval.start()

    def pan_up(self):

        if self.pan_z_interval is not None:
            if self.pan_z_interval.isPlaying():
                self.pan_z_interval.pause()

        current_pan = self.dummy.getPos().getZ()
        duration = ((current_pan-self.min_pan_z)/self.max_pan_z)*self.pan_z_time

        self.pan_z_interval = self.dummy.posInterval(
            duration=duration,
            pos = Vec3(self.dummy.getPos().getX(),self.dummy.getPos().getY(),self.min_pan_z),
            name = "pan_up")
        self.pan_z_interval.start()

    def stop_pan_up(self):
    
        if self.pan_z_interval is not None:
            if self.pan_z_interval.isPlaying() and self.pan_z_interval.getName() == "pan_up":
                self.pan_z_interval.pause()

    def stop_pan_down(self):
    
        if self.pan_z_interval is not None:
            if self.pan_z_interval.isPlaying() and self.pan_z_interval.getName() == "pan_down":
                self.pan_z_interval.pause()

    def zoom_to_node(self,np):

        # ynjh_jo's zoom code. This is why we have the dummy node.
        # We move the dummy node to the center of the object we're
        # zooming in on, and use it as a target for aspect2d.
        a2dOldTransf=aspect2d.getTransform(render2d)
        self.dummy.setPos(np,0,0,0)
        aspect2d.setTransform(render2d,a2dOldTransf)
        
        pos = np.getPos(render2d)
        scale = np.getScale(aspect2d)
        aspect_ratio = scale[0]/scale[2]
        bounds3 = np.getTightBounds()
        bounds = render2d.getRelativeVector(np.getParent(),bounds3[1]-bounds3[0])
        if aspect_ratio < base.getAspectRatio():
            maxScale=2./bounds[2]
        else:
            maxScale=2./bounds[0]
        
        self.zoom_to_interval = self.dummy.posHprScaleInterval(
            duration = .5,
            pos = self.dummy.getPos() - pos,
            hpr = self.dummy.getHpr(),
            scale =  self.dummy.getScale() * maxScale
            )                                      
        self.zoom_to_interval.start()

    def zoom_to_home(self):
        """Zoom back to the original view."""
        
        a2dOldTransf=aspect2d.getTransform(render2d)
        self.dummy.clearTransform()
        aspect2d.setTransform(render2d,a2dOldTransf)
        self.zoom_home_interval = aspect2d.posHprScaleInterval(
            duration = .5,
            pos = Point3(0,0,0),
            hpr = aspect2d.getHpr(),
            scale = Vec3(1/base.getAspectRatio(),1,1)
            )
        self.zoom_home_interval.start()

My first thought was that adding child nodes was changing the tight bounds of the parent node, but that doesn’t seem to be the case.

Edit: Actually I was wrong, adding child nodes does change the tight bounds of parent nodes, I was just printing out the bounds before Panda had a chance to update them, so that’s what’s happning. It seems a bit weird that you can get away with it if the child node is smaller than the parent, even if the pos of the child node places it well outside the bounds of the parent. I would have thought the bounds woud be updated to encompass all child nodes taking their positions into account, but it is as if the bounds of a parent node take into account the bounds of a child node but not its position. I guess that’s how panda works.

It’s not a big deal I guess, I’ll just avoid making sibling nodes whose combined bounds are bigger than their parent node.

I think I know what you want. You want to zoom in and ignore the children, don’t you ?
getTightBounds() returns the bounds all the way down to the children too. So, to get the bounds you want, just discard the children temporarily.

    def zoom_to_node(self,np):

        # ynjh_jo's zoom code. This is why we have the dummy node.
        # We move the dummy node to the center of the object we're
        # zooming in on, and use it as a target for aspect2d.
        a2dOldTransf=aspect2d.getTransform(render2d)
        self.dummy.setPos(np,0,0,0)
        aspect2d.setTransform(render2d,a2dOldTransf)

        pos = np.getPos(render2d)
        scale = np.getScale(aspect2d)
        # temporarily detach its children
        np.getChildren().stash()
        # now the bounds is its own without the children
        bounds3 = np.getTightBounds()
        # restore its children
        np.unstashAll()

        bounds = render2d.getRelativeVector(np.getParent(),bounds3[1]-bounds3[0])
        aspect_ratio = bounds[0]/bounds[2]
        if aspect_ratio < base.getAspectRatio():
            maxScale=2./bounds[2]
        else:
            maxScale=2./bounds[0]

        self.zoom_to_interval = self.dummy.posHprScaleInterval(
            duration = .5,
            pos = self.dummy.getPos() - pos,
            hpr = self.dummy.getHpr(),
            scale =  self.dummy.getScale() * maxScale
            )
        self.zoom_to_interval.start()

I also changed the aspect_ratio to use the bounds directly. I used scale at that moment with an assumption that the node’s geom is always squared. It’d be better to use the bounds, which is the overall size, to avoid another confusion in the future when you want to use rectangles.

Ah, temporarily detach the children! It’s so simple and NodePath even has convenience methods for it. I think I still think of the scene graph as something you build up, haven’t yet gotten into the mode of thinking about its true power when you can programatically manipulate it however you want.

Works a charm here.