Smooth zooming in and out on your mouse tip

My dear friend John, a.k.a introscopia has this amazing smooth zoom and pan code I really like, but he uses mostly SDL nowadays, so I asked him for help and made these two examples. One in Processing Java and the other for Python + py5. I hope you enjoy it as much as I did (I have many sketches that would benefit from this).

out2

// A zoom strategy from John @introscopia https://introscopia.itch.io/

int zptX = 0;
int zptY = 0;
float zptScale = 1;
float zptAmount = 0;

int seed = 1;

void setup() {
  size(500, 500);
  noStroke();
}

void draw() {
  pushMatrix();
  translate(zptX, zptY);
  scale(zptScale);
  // Your drawing goes here
  background(0);
  randomSeed(seed);
  int w = 500;
  for (int i=0; i<10; i++) {
    float x= random(-w/2, w + w/2);
    float y = random(-w/2, w + w/2);
    for (int d=w*2; d>0; d=d-10) {
      fill(random(255), random(255), random(255), 50);
      circle(x, y, d);
    }
  }
  // end of the main drawing part
  popMatrix();
  // HUD (Head-Up Display, like if projected on the windscreen, unaffected by zoom & pan)
  pushStyle();
  int margin = 40;
  noFill();
  stroke(255);
  rect(margin, margin, width - margin * 2, height - margin * 2);
  fill(255);
  textSize(margin / 3);
  textAlign(LEFT, TOP);
  text(String.format("{'x': %s,  'y': %s , 'scale': %s, 'amount': %s}", zptX, zptY, zptScale, zptAmount),
       margin, height - margin / 2);
  popStyle();
}

void mouseWheel(MouseEvent e) {
  float xrd = (mouseX - zptX) / zptScale;
  float   yrd = (mouseY - zptY) / zptScale;
  zptAmount -= e.getCount();
  zptScale = pow(1.1, zptAmount);
  zptX = int(mouseX - xrd * zptScale);
  zptY = int(mouseY - yrd * zptScale);
}

void mouseDragged() {
  zptX += mouseX - pmouseX;
  zptY += mouseY - pmouseY;
}

void keyPressed() {
  seed += 1;
}

And with py5:

# A zoom strategy from John @introscopia https://introscopia.itch.io/

import py5

zpt = {'x': 0, 'y': 0, 'scale': 1, 'amount': 0}  # zoom & pan transformation values
seed = 1

def setup():
    py5.size(500, 500)
    py5.no_stroke()
    
def draw():
    with py5.push_matrix():
        py5.translate(zpt['x'], zpt['y'])
        py5.scale(zpt['scale'])
        # Your drawing goes here
        py5.background(0)
        py5.random_seed(seed)
        w = 500
        for _ in range(10):
            x, y = py5.random(-w/2, w + w/2), py5.random(-w/2, w + w/2)
            for d in range(w * 2, 0, -10):
                py5.fill(py5.random(255),py5.random(255),py5.random(255), 50)
                py5.circle(x, y, d)
    # HUD (Head-Up Display, like if projected on the windscreen, unaffected by zoom & pan)
    with py5.push_style():
        margin = 40
        py5.no_fill()
        py5.stroke(255)
        py5.rect(margin, margin, py5.width - margin * 2, py5.height - margin * 2)
        py5.fill(255)
        py5.text_size(margin / 3)
        py5.text_align(py5.LEFT, py5.TOP)
        py5.text(str(zpt), margin, py5.height - margin / 2)

def mouse_wheel(e):
    xrd = (py5.mouse_x - zpt['x']) / zpt['scale']
    yrd = (py5.mouse_y - zpt['y']) / zpt['scale']
    zpt['amount'] -= e.get_count()
    zpt['scale'] = 1.1 ** zpt['amount']
    zpt['x'] = int(py5.mouse_x - xrd * zpt['scale'])
    zpt['y'] = int(py5.mouse_y - yrd * zpt['scale'])

def mouse_dragged():
    zpt['x'] += py5.mouse_x - py5.pmouse_x
    zpt['y'] += py5.mouse_y - py5.pmouse_y

def key_pressed():
    global seed
    seed += 1

py5.run_sketch()
5 Likes

Random question:

What OS is that?

Windows?
Some GNU/Linux distro?

Cool!

You probably shouldn’t use the word “interface” to avoid confusion.

interface has a meaning in Java (the language Processing is based upon).

1 Like

In general, declaring variables in the draw method is a bad idea.

You are declaring a new variable 60x a second!

Perhaps this method could be simplified:

void mouseWheel(MouseEvent e) {
  float xrd = (mouseX - zptX) / zptScale;
  float   yrd = (mouseY - zptY) / zptScale;
  zptAmount -= e.getCount();
  zptScale = pow(1.1, zptAmount);
  zptX = int(mouseX - xrd * zptScale);
  zptY = int(mouseY - yrd * zptScale);
}

Perhaps int(mouseX - (mouseX - zptX) / zptScale * zptScale) could be simplified to int(zptX)

NOTE: Math.round() is probably better than int(); typecasting (int) zptX is probably superior.

void mouseWheel(MouseEvent e) {
  final float zptScale_old = zptScale;
  //zptAmount is useless and unnecessary
  zptScale = pow(1.1, -e.getCount());
  zptX = Math.round(mouseX - (((mouseX - zptX)/zptScale_old) * zptScale));
  zptY = Math.round(mouseY - (((mouseY - zptY)/zptScale_old) * zptScale));
}
2 Likes

Thanks for the tips @LangdonS !

Java isn’t my thing for a long time now… good idea to not mislead people with the “interface” jargon. I have edited it out. I’m on Manjaro XFCE by the way.

You shouldn’t simplify it because zptScale changes in the middle of the operation, thus changing zptX and zptY, the zoom adjusts the pan, that’s the whole point! :smile:

I could adopt your (int) casting idea but I don’t think that in the time scale of the mouse wheel events it will make any difference. It was there just because I decided it would be prettier to have zptX and zptY as ints, but they could be floats as well.

I should move margin out…

You don’t need the variable zptAmount.

Oh it is essential, it keeps the state of the “human intuition readable zoom amount”. zptScale is the value used by the scale transformation, but it is calculated from zptAmount, which is increased or decreased by one with the scroll wheel, so it needs to be kept between scrolling events. This is the whole point of the thing, makes zooming in and out much more consistent than if you try changing the scale directly.

One could arguably reconstruct zptAmount from zptScale, or vice-versa, on the spot, but I find it much more sensible to keep both.

zptAmount -= e.getCount();
zptScale = pow(1.1, zptAmount);

Can probably be simplified to:

zptScale *= pow(1.1, -e.getCount())

Then you can get rid of zptAmount.

Perhaps even better(uses double to maximize accuracy):
zptScale *= Math.sqrt(Math.pow(2,-e.getCount()));

Try this out:
zptScale = Math.sqrt(Math.pow(2, zptAmount));
OR:
zptScale = Math.pow(2, zptAmount/2d);

This could provide “nicer” results.

2x zoom in = 200%
2x zoom out = 50%

Have you tried it yourself? :smiley:

Answering the previous post, you really can’t get rid of zptAmount, because e.getCount() gets you -1 or 1, you need somewhere to keep the zoom amount state.

Also the whole point, John’s great insight, in my view, is that pow(1.1, zptAmount) gives a much nicer continuous “zoom experience” both inwards and outwards. But you are free to disagree and use whatever constants you like!

1.1 might give visually “nice” reults, but √2 gives numerically “nice” results try out both, see which you like.

The nice thing about √2 is the resulting zoom ratios are like 50%, 71%, 100%, 142%, 200%, 283%, 400%

Note the *= not =.

1 Like

Oh! I see… but then, just because you want to remove a variable, you loose what you described yourself as a “numerically nice” state variable! I’ll stand behind John’s choices: nice zoom experience plus a state variable with a very intuitive value. :laughing:

Try out the √2 and see what you think.

The problem with 1.1 is the resulting scaling ratios are kinda weird: 91%, 100%, 110%, 121%

√2 is too fast. If you want smooth and rounding to nice values use 1.09051 which is just over 2 ^ 1/8. Every 8 tics of the mouse wheel will give you an even power of 2 scaling.

@villares If I tell you that the view scaling is 1x or 2x or 10x, I think you’d have a pretty clear idea what that means. If I say that zptAmount is 1 or 4 or 7, is that going to have some intuitive meaning to anyone? Stick with zptScale *= pow( 1.09051, -e.getCount() ); and I suspect people would find the zptScale value much more meaningful.