The Click Problem

User clicks at (347, 219). Which object did they click?

With 10 objects, loop through all of them. With 10,000 objects, that loop takes too long.

The Naive Approach

First attempt: check every object, every click.

function findClickedObject(mousePos, objects) {
  for (const obj of objects) {
    if (isPointInside(mousePos, obj)) {
      return obj;
    }
  }
  return null;
}

Performance: O(n). Fine for small canvases. Unusable for complex designs.

Problem: We tested every object even when the click was nowhere near most of them.

Bounding Box Optimization

Second attempt: quick rejection with bounding boxes.

function findClickedObject(mousePos, objects) {
  for (const obj of objects) {
    if (!isPointInBounds(mousePos, obj.bounds)) {
      continue;  // Skip expensive test
    }
    if (isPointInside(mousePos, obj)) {
      return obj;
    }
  }
  return null;
}

Better. Bounding box check is fast (4 comparisons). But we still iterate every object.

Enter the Quad-Tree

A quad-tree divides space into quadrants. Each quadrant divides further until reaching a size limit or object count threshold.

var cfg = {
  "id": "qt1",
  "type": "quadtree-demo",
  "bindings": {
    "objectCount": "quadtree.objectCount",
    "depth": "quadtree.depth"
  },
  "width": 500,
  "height": 300
}

Objects: {{quadtree.objectCount}}, Max depth: {{quadtree.depth}}

Click to query. Watch which cells get checked.

Building the Quad-Tree

class QuadTree {
  constructor(bounds, maxObjects = 4, maxDepth = 8) {
    this.bounds = bounds;
    this.maxObjects = maxObjects;
    this.maxDepth = maxDepth;
    this.objects = [];
    this.children = null;
    this.depth = 0;
  }

  insert(obj) {
    if (this.children) {
      const index = this.getChildIndex(obj.bounds);
      if (index !== -1) {
        this.children[index].insert(obj);
        return;
      }
    }

    this.objects.push(obj);

    if (this.objects.length > this.maxObjects &&
        this.depth < this.maxDepth) {
      this.subdivide();
    }
  }
}

Objects that span multiple quadrants stay at the parent level. Objects fully contained in a quadrant descend to that child.

Querying

function query(point) {
  const results = [];

  // Check objects at this level
  for (const obj of this.objects) {
    if (isPointInBounds(point, obj.bounds)) {
      results.push(obj);
    }
  }

  // Recurse into relevant child
  if (this.children) {
    const index = this.getChildIndex({
      x: point.x, y: point.y,
      width: 0, height: 0
    });
    if (index !== -1) {
      results.push(...this.children[index].query(point));
    }
  }

  return results;
}

Performance: O(log n) average case. We only check objects in quadrants the point actually touches.

Point-in-Polygon: The Real Test

The quad-tree gives us candidates. Now we need to test each one precisely.

For polygons, use ray casting:

function isPointInPolygon(point, vertices) {
  let inside = false;
  const n = vertices.length;

  for (let i = 0, j = n - 1; i < n; j = i++) {
    const vi = vertices[i];
    const vj = vertices[j];

    if ((vi.y > point.y) !== (vj.y > point.y) &&
        point.x < (vj.x - vi.x) * (point.y - vi.y) /
                  (vj.y - vi.y) + vi.x) {
      inside = !inside;
    }
  }

  return inside;
}

Cast a ray from the point. Count edge crossings. Odd = inside, even = outside.

Bezier Curves: Approximate and Test

Curved segments complicate things. You can't ray-cast against a bezier directly.

Solution: Flatten curves to line segments for hit testing.

function flattenBezier(p0, p1, p2, p3, tolerance = 1) {
  const points = [p0];

  function subdivide(t0, t1, depth) {
    const mid = (t0 + t1) / 2;
    const pMid = evaluateBezier(mid, p0, p1, p2, p3);
    const pLine = lerp(
      evaluateBezier(t0, p0, p1, p2, p3),
      evaluateBezier(t1, p0, p1, p2, p3),
      0.5
    );

    if (distance(pMid, pLine) > tolerance || depth < 2) {
      subdivide(t0, mid, depth + 1);
      points.push(pMid);
      subdivide(mid, t1, depth + 1);
    }
  }

  subdivide(0, 1, 0);
  points.push(p3);
  return points;
}

The tolerance parameter controls accuracy. Lower = more segments = slower but more precise.

Transform-Aware Hit Testing

Objects have transforms. The quad-tree stores world-space bounds. But point-in-polygon needs local coordinates.

function hitTestObject(worldPoint, obj) {
  // Convert to object's local space
  const localPoint = transformPoint(
    invert(obj.worldMatrix),
    worldPoint
  );

  // Test against local geometry
  return isPointInPolygon(localPoint, obj.localVertices);
}

The inverse matrix converts screen coordinates to object coordinates. Now the polygon test works regardless of rotation or scale.

Z-Order: Front to Back

Multiple objects can overlap. Which one gets the click?

Test in reverse z-order (front to back). First hit wins.

function findTopObject(point, objects) {
  // Assume objects sorted back-to-front
  for (let i = objects.length - 1; i >= 0; i--) {
    if (hitTestObject(point, objects[i])) {
      return objects[i];
    }
  }
  return null;
}

Stroke Hit Testing

Filled shapes: test if point is inside. Stroked shapes: test if point is within stroke width of the path.

function isPointNearPath(point, path, strokeWidth) {
  const threshold = strokeWidth / 2 + 2; // +2 for touch tolerance

  for (const segment of path.segments) {
    const dist = pointToSegmentDistance(point, segment);
    if (dist < threshold) return true;
  }

  return false;
}

The extra pixels make selection feel responsive. Users don't click with pixel precision.

Performance Summary

Approach 100 objects 10,000 objects
Naive loop 0.1ms 10ms
Bounding boxes 0.05ms 5ms
Quad-tree 0.02ms 0.05ms

The quad-tree scales. The naive approach doesn't.

Summary

Hit testing is invisible when it works. When it's slow, users notice immediately—every mouse move triggers a hover test.

Fast hit testing is the difference between a fluid editor and a laggy one.

Next: Editor Modes →