Get world coordinates of viewing area boundaries

I render tooltips (TT) on mouseover of my models and place them with 3d world coordinates. If my models are too close to the edge of the viewing area, sometimes (depending on TT content, as they are dynamically centered over their models, and the text is dynamic) the text placement point is off screen, and the tooltip is not visible. I’d like to detect if any part of the tooltip will be outside of the screen viewing area and shift its coordinates accordingly. I’m playing with extrude and project on the lens and struggling to get results which make sense. Is there a better way to get the world coordinates of the viewable space? Is there a better way to shift the TT to stay visible?

The first thing that came to mind was this approach. The second one you can use is a callback node, but you need to wrap each object with it, which is not practical.

from direct.showbase.ShowBase import ShowBase

class Game(ShowBase):
    def __init__(self):
        ShowBase.__init__(self)

        self.model_1 = loader.load_model("box.bam")
        self.model_1.set_name("model_1 (red)")
        self.model_1.set_tag("check_visibility", "")
        self.model_1.set_color(10, 0, -15)
        self.model_1.set_pos(0, 0, 0)
        self.model_1.reparent_to(render)
        
        self.model_2 = loader.load_model("box.bam")
        self.model_2.set_tag("check_visibility", "")
        self.model_2.set_name("model_2 (green)")
        self.model_2.set_color(0, 1, 0)
        self.model_2.set_pos(10, 0, 0)
        self.model_2.reparent_to(render)

        self.model_3 = loader.load_model("box.bam")
        self.model_3.set_tag("check_visibility", "")
        self.model_3.set_name("model_3 (blue)")
        self.model_3.set_color(0, 0, 1)
        self.model_3.set_pos(0, 45, 0)
        self.model_3.reparent_to(render)

        taskMgr.add(self.check_visibility,"check_visibility")

    def check_visibility(self, task):
        for node in render.get_children():
            if node.has_tag("check_visibility"):
                lens_bounds = base.camLens.make_bounds()

                node_bounds = node.get_bounds()
                node_bounds.xform(node.get_parent().get_mat(base.cam))

                if lens_bounds.contains(node_bounds):
                    print("visible: ", node.get_name())

                else:
                    print("not visible: ", node.get_name())

        return task.cont

app = Game()
app.run()

box.bam (1.8 KB)

I think the way to find the angle, relative to the vector between the camera’s gaze and the models, will give you information on where to move the placemark. Since the viewing angle of the camera lens is a known value.

I would say that “project” (which transforms world-space into view-space) is a pretty good way to do it, honestly.

(Although, instead of getting the world-coordinates of the viewable space, I might project the extents of the object and see whether it falls within the appropriate ranges.)

However, it does have one easy-to-miss pitfall: it expects to operate within the space of the camera, not “raw” world-space.

Which means that, to get sensible results, one has to first transform the desired point into camera-space, and only then call “project”.

Here’s a quick sample to demonstrate:

from direct.showbase.ShowBase import ShowBase

from panda3d.core import Vec3, Point2 

import random

class Game(ShowBase):
    def __init__(self):
        ShowBase.__init__(self)
        
        # A set up a simple callback which will do
        # our projection when we press "space"
        self.accept("space", self.getProjection)
        
        # A marker to show the world-space position
        # being projected
        self.marker = loader.loadModel("smiley")
        self.marker.reparentTo(self.render)

    def getProjection(self):
        # This method first creates a random point,
        # then sets the marker to be located at that
        # point (so that we can see where it is),
        # and finally projects that point and
        # tells us the result
        
        # First, a point:
        randomPoint = Vec3(random.uniform(-15, 15), 50, random.uniform(-10, 10))
        
        # Then, set the marker to be located there:
        self.marker.setPos(randomPoint)
        
        # Third, perform the projection...
        
        ### Get the camera-lens, which is responsible
        ### for doing the projection
        lens = base.cam.node().getLens()
        ### Create a point in which the "project" method
        ### will store the result of the projection
        resultPoint = Point2()
        
        ### Now, "project" expects that it will be given
        ### a point relative to the camera. So, we transform
        ### our random point from a world-space point to
        ### a camera-relative point
        relativePoint = base.cam.getRelativePoint(render, randomPoint)
        
        ### Finally, we perform the projection, and print the result!
        success = lens.project(relativePoint, resultPoint)
        if success:
            print (resultPoint)
        else:
            print ("No projection found!")

app = Game()
app.run()

This idea may fail because 3d geometry has volume. Your method will not know whether the object is fully or partially visible, because with a point it is enough for the center to be in the area of the screen coordinates.

I think if the text label is always facing the camera, then along the edges (left and right)you can create dummy nodes, they can be reliably verified.

This is why I said to project the extents of the object–not just its origin.

So, one would project four points per object: one for each of the top-left, top-right, bottom-left, and bottom-right.

Now, this can be done with dummy-nodes–but one doesn’t need to do that. Just store or calculate the size of the object, then add and subtract that from the origin-point. Something like this:

# "myObjectWidth" and "myObjectHeight" can be either
# calculated when the object is made, or calculated
# just before this point
leftX = relativePoint.x - myObjectWidth
rightX = relativePoint.x + myObjectWidth
bottomZ = relativePoint.z - myObjectHeight
topZ = relativePoint.z + myObjectHeight

testPt = Vec3(0, relativePoint.y, 0)

# Top-left
testPt.setX(leftX)
testPt.setZ(topZ)

success = lens.project(testPt, resultPt)

# And so on ...

Note, by the way, that if I’m not much mistaken and if one is only interested in whether the point in question is visible, one doesn’t really need to do anything with the result-point: the return-value of “project” will be “False” if the point is outside of the camera-frustum.

Thank you both. I am still struggling with mapping the width of the drawn text to world coordinates. When I divide by 2 it seems to be about the correct width… any hints on Getting world units for the width of the text field?

I’m presently using TextNode.getUpperLeft3d() - TextNode.getLowerRight3d() to get the dimensions of the text node. When I divide the W and H roughly by 2, and then shift the placement position by that amount, I’m able to keep the text node onscreen when it tries to render too close to the right or bottom of the screen.

But, this seems incorrect, at best … Getting tight bounds on the TextNode’s NodePath (that is the np to which it is attached) also yields nonsensical numbers …

1 Like

Ah, I didn’t actually know about “getLowerRight3d” and “getUpperLeft3d”! Panda continues to surprise me with its myriad tools that “just do” some useful but complex-seeming task! :slight_smile:

So, based on a little testing, it seems like those methods produce results in the space of the TextNode. As a result, it may be that, in order to get results that are useful to you, a transformation into world-space is called for.

Here below is a short test-program that demonstrates this. Specifically, it creates a TextNode, places it in the world, and then places two Smiley-models at the upper-left and lower-right corners of the TextNode.

Note in particular the calls to “getRelativePoint”, which transform the results of “getLowerRight3d” and “getUpperLeft3d” from the space of the TextNode to the space of “render” (i.e. “the world”).

from direct.showbase.ShowBase import ShowBase

from panda3d.core import TextNode

import random

class Game(ShowBase):
    def __init__(self):
        ShowBase.__init__(self)
        
        self.marker1 = loader.loadModel("smiley")
        self.marker1.reparentTo(self.render)
        self.marker1.setScale(0.5)
        
        self.marker2 = loader.loadModel("smiley")
        self.marker2.reparentTo(self.render)
        self.marker2.setScale(0.5)
        
        textNode = TextNode("mew")
        textNode.setText("Kittens")
        textNP = render.attachNewNode(textNode)
        textNP.setPos(-1, 25, -2)
        
        lr = textNode.getLowerRight3d()
        ul = textNode.getUpperLeft3d()
        
        lr = render.getRelativePoint(textNP, lr)
        ul = render.getRelativePoint(textNP, ul)
        
        self.marker1.setPos(lr)
        self.marker2.setPos(ul)


app = Game()
app.run()

Thanks again! I’ve found that calling setScale on the textNP does not affect the results of getUpperLeft - getLowerRight. That is to say using those methods to compute width and height when the scale is not 1.0, seems to still return the width and height as if the scale were 1.0 … Similarly when using setBillboardPointEye(-90, fixed_depth=True) … I was using these two methods for some reason to manage the size of the text … I’m now researching a means to change the text size which is compatible with the getUpper/LowerLeft/Right methods.

1 Like

It looks like setGlyphScale plays nicely with the upper and lower bound getters. So, I’m back in business. Thanks again for all the help!

Oh, but, now I am reminded why I was using the billboard and node scale … I was trying to keep the text the same size regardless of distance of the TT having object from the camera. It seems I can either readily get the width and height of the text, if I do not use any helpers to play tricks with the text. So, maybe the bounds getting methods reference data from before the billboard and scale modifiers are applied.

Hmm… Are you sure? I just tried it, and it seemed to work on my end.

Here’s a modified version of the program that I posted above:

from direct.showbase.ShowBase import ShowBase

from panda3d.core import TextNode

import random

class Game(ShowBase):
    def __init__(self):
        ShowBase.__init__(self)
        
        self.marker1 = loader.loadModel("smiley")
        self.marker1.reparentTo(self.render)
        self.marker1.setScale(0.5)
        
        self.marker2 = loader.loadModel("smiley")
        self.marker2.reparentTo(self.render)
        self.marker2.setScale(0.5)
        
        textNode = TextNode("mew")
        textNode.setText("Kittens")
        textNP = render.attachNewNode(textNode)
        textNP.setPos(-1, 25, -2)
        
        lr = textNode.getLowerRight3d()
        ul = textNode.getUpperLeft3d()
        
        lr = render.getRelativePoint(textNP, lr)
        ul = render.getRelativePoint(textNP, ul)
        
        w = lr.x - ul.x
        print (w)
        
        textNP.setScale(5)
        
        lr = textNode.getLowerRight3d()
        ul = textNode.getUpperLeft3d()
        
        lr = render.getRelativePoint(textNP, lr)
        ul = render.getRelativePoint(textNP, ul) 
        
        w = lr.x - ul.x
        print (w)
        
        self.marker1.setPos(lr)
        self.marker2.setPos(ul)


app = Game()
app.run()

(In short, it does much as before, but now it calculates and prints out the width of the object–then scales the object up and repeats.)

On my machine, the above prints out first 3.1999998092651367, then 15.999999046325684–the latter being five times the former, and thus in line with the text NodePath being scaled up by 5.

Are you seeing a different effect? Or do you perhaps have a program context that is causing different results…?

Interesting. I’ve been using the debugger in VS-Code to watch my behavior. So, Here is the relevant constructor and code which renders the tooltip. First the constructor (using setScale on the NP):

def __init__(self, parent):
        """The tooltip constructor."""
        self.visible = False
        self.text = ""
        self.parent = parent
        self.vis_secs = DEFAULTTTVISSECS
        self.updaters = list()
        self.onsrctxt = TextNode(namegen.get())
        self.onsrctxt.setShadow(DEFAULTTIPSHADOWPOS)
        self.onsrctxt.setShadowColor(DEFAULTTIPSHADOWCOL)
        self.onsrctxt.setCardColor(DEFAULTTIPBGCOL)
        self.onsrctxt.setTextColor(DEFAULTTIPFGCOL)
        self.onsrctxt.setCardAsMargin(*DEFAULTTIPMARGIN)
        self.onsrctxt.setCardDecal(True)
        # self.onsrctxt.setGlyphScale(DEFAULTTIPSCALE)
        self.text_np = cm.cwc().attachNewNode(self.onsrctxt)
        self.text_np.setScale(DEFAULTTIPSCALE)
        # self.text_np.setBillboardPointEye(-90, fixed_depth=True)

Then the draw code:

    def show(self, where, pos3d):
        """Relative to the provided 3d position, show the tooltip."""
        global base
        self.visible = True
        for upd in self.updaters:
            upd(self)

        self.text_np.reparentTo(cm.cwc())

        ostul = self.onsrctxt.getUpperLeft3d()
        ostbr = self.onsrctxt.getLowerRight3d()

        twidth = abs(ostul.x - ostbr.x)
        theight = abs(ostul.z - ostbr.z)

        # markPt(ostul, Green)
        # markPt(ostbr, Red)

        # markPt(ul, Blue)
        # markPt(br, White)

        pos3d.x += twidth / DEFAULTTIPTWIDTHDIVISOR
        pos3d.z += theight

        # deal with TopLeft corner going off screen
        relativePt = base.camera.getRelativePoint(cm.cwc(), pos3d)
        ul_resPt = Point3()
        ul_success = base.camLens.project(relativePt, ul_resPt)

        brcPt = Point3(pos3d.x + twidth,
                       pos3d.y,
                       pos3d.z + theight)
        relativePt = base.camera.getRelativePoint(cm.cwc(), brcPt)
        br_resPt = Point3()
        br_success = base.camLens.project(relativePt, br_resPt)

        screenTL = Point3()
        base.camLens.extrudeDepth(Point3(-1.0, -1.0, ul_resPt.z), screenTL)
        screenTL = cm.cwc().getRelativePoint(base.camera, screenTL)
        screenBR = Point3()
        base.camLens.extrudeDepth(Point3(1.0, 1.0, br_resPt.z), screenBR)
        screenBR = cm.cwc().getRelativePoint(base.camera, screenBR)

        if pos3d.x < screenTL.x:
            pos3d.x = screenTL.x
        if pos3d.z < screenTL.z:
            pos3d.z = screenTL.z
        if pos3d.x + twidth > screenBR.x:
            pos3d.x = screenBR.x - twidth
        if pos3d.z + theight > screenBR.z:
            pos3d.z = screenBR.z - theight

        self.text_np.setPos(pos3d)

        self.text_np.show()

cm.cwc() returns the render node for the “room” we are in. DEFAULTTIPSCALE is 0.4

When I run using setGlyphScale, the twidth == 7.625000476837158, when I switch to text_np.setScale, my twidth == 19.0625.

The updaters, just call self.onsrctxt.setText with the relevant text, given the context the rest of the program is in. I’m tracing through now looking for possible changes to the text_np, onsrctxt camera or other vars…

Looking at that code, I note that after your calls to “getUpperLeft3d” and “getLowerRight3d”, you don’t seem to call “getRelativePoint” to transform the those upper-left and lower-right points into “world”-space. Perhaps this is the source of the discrepancy?

(If you look at my example above, note that I essentially do the following:

  • Get lower-right and upper-left
  • Transform lower-right and upper-left into world-space
  • Use lower-right and upper-left to calculate width

It’s that middle point that you don’t seem to have in your code.)

Ah, thank you. That was lost in the edits and troubleshooting. With those back in place, the width behavior is consistent, like your example. It seems I’m now just at the point that setBillboardPointEye is the thing that is breaking my width and height based adjustments, as it seems that the UL and LR calls return values from the textnode before the billboardpointeye adjustments are made.

1 Like

Hmm… That is tricky!

If you don’t mind a one-frame lag in the repositioning, you could always calculate and apply the logic for it on the next frame after showing the tooltip. Essentially, store a variable that indicates that a tooltip should be repositioned, or set a “doMethodLater” call, and either way on the next frame respond to that with the repositioning logic.

If that’s no good, you could perhaps replace the automatic billboarding with your own implementation, allowing you to place its logic wherever in the program-flow best suits you.