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).
The fundamental challenge in 2D graphics is mapping between two coordinate systems:
-
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. -
Screen Coordinates (
$X_s, Y_s$ ): The actual pixels on the user's monitor. These change constantly as the user zooms and pans.
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.
| Operation | Formula | Purpose |
|---|---|---|
| World |
Rendering: Determining where to draw a line on the screen. | |
| Screen |
Interaction: Determining which object is under the mouse cursor. | |
| Zoom Compensation |
Navigation: Calculating the new Pan ( |
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();
}Goal: Keep the specific point under the mouse stationary while the scale changes.
Sequence: Capture Anchor (World point) Apply Zoom (Change Scale) 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
}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;
}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);
}
}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));
}
}