Skip to content

SoftwelSanjog/2DGraphics

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

8 Commits
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Interactive 2D Graphics Engine (Zoom & Pan)

A reference guide for building interactive 2D engineering/CAD tools. This document covers the mathematical foundations of coordinate systems and provides identical implementation patterns in C# (Windows Forms) and JavaScript (p5.js).


1. Core Concepts

The fundamental challenge in 2D graphics is mapping between two coordinate systems:

  1. World Coordinates ($X_w, Y_w$): The infinite "virtual" space where your design lives (e.g., a 50m settling basin). These coordinates never change regardless of how you view them.
  2. Screen Coordinates ($X_s, Y_s$): The actual pixels on the user's monitor. These change constantly as the user zooms and pans.

The "Screen Shift" Model

We use the Translate-then-Scale approach. This is the standard industry pattern for infinite canvas tools (like AutoCAD or Google Maps).

  • Scale ($S$): The zoom level ($1.0 = 100%$, $2.0 = 200%$).
  • Pan ($T_x, T_y$): The shift of the origin in Screen Pixels.

The Golden Formulas

Operation Formula Purpose
World $\rightarrow$ Screen $Screen = (World \cdot S) + T$ Rendering: Determining where to draw a line on the screen.
Screen $\rightarrow$ World $World = \frac{Screen - T}{S}$ Interaction: Determining which object is under the mouse cursor.
Zoom Compensation $T_{new} = Mouse - (Anchor \cdot S_{new})$ Navigation: Calculating the new Pan ($T$) to keep the view focused on the cursor during zoom.

2. Implementation Logic (Side-by-Side)

A. Rendering (The Loop)

Rule: Always Translate first, then Scale.

C# (Windows Forms GDI+)

// Variables: float zoom, panX, panY;
private void OnPaint(object sender, PaintEventArgs e) {
    Graphics g = e.Graphics;
    g.Clear(Color.White);

    // 1. Translate (Move the origin)
    g.TranslateTransform(panX, panY);

    // 2. Scale (Magnify the grid)
    g.ScaleTransform(zoom, zoom);

    // 3. Draw World Objects (using real-world units)
    g.DrawRectangle(Pens.Blue, 50, 50, 100, 100);
}

JavaScript (p5.js)

// Variables: let zoom, panX, panY;
function draw() {
  background(240);
  push();
  
  // 1. Translate (Move the origin)
  translate(panX, panY);
  
  // 2. Scale (Magnify the grid)
  scale(zoom);
  
  // 3. Draw World Objects
  // Note: Line width scales too! Use strokeWeight(1/zoom) to fix.
  strokeWeight(1 / zoom); 
  rect(50, 50, 100, 100);
  
  pop();
}

B. Zoom Logic (The "Anchor" Method)

Goal: Keep the specific point under the mouse stationary while the scale changes. Sequence: Capture Anchor (World point) $\rightarrow$ Apply Zoom (Change Scale) $\rightarrow$ Compensate Pan (Recalculate Translation).

C# (Windows Forms)

private void OnMouseWheel(object sender, MouseEventArgs e)
{
    // 1. Capture Anchor: World point under mouse before zoom
    float worldX = (e.X - panX) / zoom;
    float worldY = (e.Y - panY) / zoom;

    // 2. Apply Zoom
    // Delta > 0 is Zoom IN (Scroll Up)
    float zoomFactor = 1.1f;
    if (e.Delta > 0) zoom *= zoomFactor;
    else             zoom /= zoomFactor;

    // 3. Compensate Pan: Force anchor back under mouse
    panX = e.X - (worldX * zoom);
    panY = e.Y - (worldY * zoom);

    this.Invalidate(); // Redraw
}

JavaScript (p5.js)

function mouseWheel(event) {
  // 1. Capture Anchor
  let worldX = (mouseX - panX) / zoom;
  let worldY = (mouseY - panY) / zoom;
  
  // 2. Apply Zoom
  // p5.js: Delta > 0 is Zoom OUT (Scroll Down) -> Opposite of C#
  let zoomFactor = 1.1;
  if (event.delta > 0) zoom /= zoomFactor;
  else                 zoom *= zoomFactor;
  
  // 3. Compensate Pan
  panX = mouseX - (worldX * zoom);
  panY = mouseY - (worldY * zoom);
  
  return false; // Prevent browser scroll
}

C. Pan Logic (The "Delta" Method)

Goal: Move the view by the exact distance the mouse moved. Logic: Pan += (CurrentMouse - LastMouse).

C# (Windows Forms)

private void OnMouseMove(object sender, MouseEventArgs e) {
    if (e.Button == MouseButtons.Left) {
        // Calculate Delta
        float deltaX = e.X - lastMousePos.X;
        float deltaY = e.Y - lastMousePos.Y;

        // Apply Delta
        panX += deltaX;
        panY += deltaY;

        lastMousePos = e.Location;
        this.Invalidate();
    }
}

JavaScript (p5.js)

function mouseDragged() {
  // p5.js automatically calculates 'movedX' and 'movedY'
  panX += movedX;
  panY += movedY;
}

3. Advanced Technique: The Engineering Grid

For professional tools, text size and line thickness must remain constant (e.g., 12px font) regardless of whether the user is zoomed in 100x or 0.1x.

Technique: Inverse Scaling. Formula: DrawSize = TargetPixelSize / Zoom

// p5.js Implementation
function drawGrid() {
  let gridSize = 50; 
  let range = 2000;

  // Constant Text Size logic
  let labelSize = 12 / zoom; 
  textSize(labelSize);

  // Constant Line Width logic
  strokeWeight(1 / zoom); 
  
  for (let x = -range; x <= range; x += gridSize) {
    // Draw Line
    stroke(200);
    line(x, -range, x, range);
    
    // Draw Value (Offset slightly using inverse scale)
    noStroke();
    fill(100);
    text(x, x, 2 / zoom); 
  }
}

4. Full Source Code: p5.js Visual Debugger

This is the complete script containing the visual debugging tools (Red Crosshair, HUD, Grid) to visualize the math in action.

// --- STATE VARIABLES ---
let zoom = 1.0;
let panX = 0;
let panY = 0;
let currentGridSize = 50;
let basins =[]; //list of all objects
let selectedBasin = null; // the currently selected object

// Debug visualization variables
let anchorWorldX = 0;
let anchorWorldY = 0;
let showAnchor = false; // Only show when zooming

function setup() {
  createCanvas(windowWidth, windowHeight);
  
  // Create some dummy data
  basins.push(new Basin(500, 50, 100, 100, "Basin A"));
  basins.push(new Basin(200, 80, 120, 150, "Basin B"));
  basins.push(new Basin(400, 200, 80, 80, "Tank C"));
}

function draw() {
  background(240);
  
  // --- LAYER 1: THE WORLD (Transformed) ---
  push();
  
    // 1. Apply Transformations
    translate(panX, panY);
    scale(zoom);
    // 2. Draw Infinite Grid (Visualizing the Coordinate System)
    drawGrid();

    // 3. Draw World Objects
    noFill();
    stroke(0, 0, 255); // Blue
    strokeWeight(2/zoom);
    rect(50, 50, 100, 100);
    
    noStroke();
    fill(0, 0, 255);
    textSize(14);
    text("Object at (50,50)", 50, 40);

    // 4. Draw the "Anchor" (The point we are zooming towards)
    // This visualizes the math: Tnew = Mouse - (Anchor * Snew)
    if (showAnchor) {
      stroke(255, 0, 0); // Red
      strokeWeight(2 / zoom); // Keep line thin regardless of zoom
      
      // Draw a crosshair at the locked world coordinates
      let len = 15 / zoom; 
      line(anchorWorldX - len, anchorWorldY, anchorWorldX + len, anchorWorldY);
      line(anchorWorldX, anchorWorldY - len, anchorWorldX, anchorWorldY + len);
    }
    
  //calculate the world mouse position
  let worldMouseX = (mouseX - panX)/zoom;
  let worldMouseY = (mouseY -panY)/zoom;
  
  // 2. Draw Loop
  for (let b of basins) {
    // Check States
    let isHovering = b.contains(worldMouseX, worldMouseY);
    let isSelected = (b === selectedBasin);
    
    b.draw(isHovering, isSelected);
  }
   pop();
  
  let snappedPoint = getSnappedMouse();
  
  //convert back to screen coordinates
  let snappedX = (snappedPoint.x * zoom) +panX;
  let snappedY = (snappedPoint.y * zoom) + panY;
  
  
  //rectMode(CENTER);
  stroke(255,200,0);
  noFill();
  strokeWeight(2);
  rect(snappedX-5,snappedY-5,10,10);
  
  // --- LAYER 2: THE HUD (Static Screen Coordinates) ---
  // These draws happen AFTER pop(), so they stick to the screen glass
  drawHUD();
}

function mouseWheel(event) {
  // Step 1: Capture Anchor (World Coordinates before zoom)
  // This is the specific point on the map we want to hold still
  let worldX = (mouseX - panX) / zoom;
  let worldY = (mouseY - panY) / zoom;
  
  // Store for visualization
  anchorWorldX = worldX;
  anchorWorldY = worldY;
  showAnchor = true;

  // Step 2: Adjust Scale
  let zoomFactor = 1.05; // Slower zoom for better visibility
  if (event.delta > 0) zoom /= zoomFactor;
  else                 zoom *= zoomFactor;

  zoom = constrain(zoom,0.01,100);
  // Step 3: Compensate Pan
  // "Move the world so 'worldX' is back under 'mouseX'"
  panX = mouseX - (worldX * zoom);
  panY = mouseY - (worldY * zoom);
  
  // Hide anchor visual after a short delay
  setTimeout(() => showAnchor = false, 500);
  
  return false; 
}

function mouseDragged() {
  panX += movedX;
  panY += movedY;
}

function mousePressed(){
  //1. calculate world mouse
  let worldMouseX = (mouseX - panX)/zoom;
  let worldMouseY = (mouseY - panY)/zoom;
  
  //hit test
  //we loop backwards so if obects overlap, we click the top one first
  let hitFound = false;
  for(let i = basins.length -1; i>=0;i--){
    let b=basins[i];
    if(b.contains(worldMouseX,worldMouseY)){
      selectedBasin = b;
      hitFound = true;
      break;
    }
  }
  //3. Deselect if clicking empth space
  if(!hitFound){
    selectedBasin = null;
  }
}
function getSnappedMouse(){
  //1. convert screen mouse --> World 
  let rawWorldX = (mouseX - panX) /zoom;
  let rawWorldY = (mouseY - panY)/zoom;
  
  let snapX = Math.round(rawWorldX/currentGridSize) * currentGridSize;
  let snapY = Math.round(rawWorldY/currentGridSize) * currentGridSize;
  
  return {x: snapX, y: snapY};
}
function formatGridView(val){
  // 1. Fix to 10 decimal places to remove binary artifacts like .00000004
  // 2. ParseFloat strips the trailing zeros (so "1.500" becomes "1.5")
  return parseFloat(val.toFixed(10));
}
function drawGrid() {
  // --- ADAPTIVE GRID LOGIC ---
  // 1. Determine the best grid step
  // Start with a base of 1 unit
  let gridSize = 1; 
  let minPixelSpacing = 25; // We want lines at least 50px apart visually
  
  // 2. Keep increasing the step by 10 until it looks wide enough on screen
  while ((gridSize * zoom) < minPixelSpacing) {
    gridSize *= 10;
  }
  // Optional: Handle zooming in very deep (fractions)
  while ((gridSize * zoom) > (minPixelSpacing * 10)) {
     gridSize /= 10;
  }

//   if(gridSize <1) gridSize =1;
  currentGridSize = gridSize;
  
  // --- FRUSTUM CULLING (Infinite View) ---
  let worldLeft = -panX / zoom;
  let worldRight = (width - panX) / zoom;
  let worldTop = -panY / zoom;
  let worldBottom = (height - panY) / zoom;

  // Snap bounds to our new dynamic gridSize
  let startX = floor(worldLeft / gridSize) * gridSize;
  let endX = ceil(worldRight / gridSize) * gridSize;
  let startY = floor(worldTop / gridSize) * gridSize;
  let endY = ceil(worldBottom / gridSize) * gridSize;

  // --- DRAWING ---
  // Inverse scaling for text/lines
  let labelSize = 12 / zoom; 
  textSize(labelSize);
  strokeWeight(1 / zoom); 
  
  // Vertical Lines
  textAlign(CENTER, TOP);
  for (let x = startX; x <= endX; x += gridSize) {
    // Make the main axis (0) darker
    if (x === 0) stroke(0); else stroke(200);
    
    line(x, worldTop, x, worldBottom);
    
    // Draw numbers
    if (x !== 0) {
      noStroke();
      fill(100);
      text(formatGridView(x), x, worldTop + (5 / zoom)); 
    }
  }

// 5. Draw Horizontal Lines (Y-Axis)
  // CHANGE: Use LEFT alignment so text draws onto the screen
  textAlign(LEFT, CENTER); 
  
  for (let y = startY; y <= endY; y += gridSize) {
    if (y === 0) stroke(0); else stroke(200);
    
    // Draw the line across the whole view
    line(worldLeft, y, worldRight, y);
    
    // Draw the Text
    if (y !== 0) {
      noStroke();
      fill(100);
      
      // CHANGE: Draw text slightly to the right of the left edge (+10/zoom)
      // This creates a "Sticky" Y-axis ruler on the left side of your screen
      text(formatGridView(y), worldLeft + (10 / zoom), y - (2 / zoom)); 
    }
  }
  
  // Draw (0,0) Origin Label
  fill(0);
  text("0", -5/zoom, 5/zoom);
}
function drawHUD() {
  fill(0, 0, 0, 50); // Semi-transparent background
  noStroke();
  rect(10, 10, 220, 110, 5);
  
  fill(255);
  textSize(12);
  textStyle(BOLD);
  text("CAMERA STATE", 20, 30);
  textStyle(NORMAL);
  
  // Live calculations
  let mouseWorldX = (mouseX - panX) / zoom;
  let mouseWorldY = (mouseY - panY) / zoom;

  text(`Pan X:  ${panX.toFixed(1)} px`, 20, 50);
  text(`Pan Y:  ${panY.toFixed(1)} px`, 20, 65);
  text(`Zoom:   ${zoom.toFixed(2)} x`, 20, 80);
  text(`--------------------------`, 20, 95);
  text(`Mouse World X: ${mouseWorldX.toFixed(1)}`, 20, 110);
}

class Basin{
  constructor(x,y,w,h,name){
    this.x = x;
    this.y =y;
    this.w=w;
    this.h = h;
    this.name = name;
  }
  //HIT test algo: point in rectangle
  contains(px,py){
    return (px> this.x &&
           px<this.x + this.w  &&
           py>this.y &&
           py<this.y + this.h);
  }
  draw(isHovered, isSelected) {
    // 1. Determine Style based on State
    if (isSelected) {
      fill(100, 100, 255);   // Dark Blue (Active)
      stroke(0, 0, 255);
      strokeWeight(3 / zoom); // Thick border
    } 
    else if (isHovered) {
      fill(200, 200, 255);   // Light Blue (Highlight)
      stroke(0, 0, 255);
      strokeWeight(2 / zoom);
    } 
    else {
      noFill();              // Transparent (Inactive)
      stroke(100);
      strokeWeight(1 / zoom);
    }
    
    // 2. Draw Shape
    rect(this.x, this.y, this.w, this.h);
    
    // 3. Draw Label
    noStroke();
    fill(0);
    textSize(12 / zoom);
    text(this.name, this.x, this.y - (5/zoom));
  }
  
}

About

Interactive 2D Graphics: Coordinate Systems

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published