Building Paths, Handles, and Selection Systems From Scratch - Complete Beginner's Guide

This chapter teaches you how to build three important systems that make your shapes interactive and editable. Think of it like this:

  • Paths = Drawing complex shapes (not just circles and rectangles)
  • Handles = Little squares/circles you drag to resize/rotate shapes
  • Selection = Visual highlighting that shows which shape you're working with

Together, these three systems turn your static shapes into interactive design tools that users can actually edit.

Why These Systems Matter

Imagine you've built shapes and a renderer. Great! But right now, your shapes are like statues - you can look at them, but you can't change them. Users can't resize a circle by dragging its corner, they can't see which shape they've clicked, and they can't draw custom shapes.

These three systems fix that:

  1. Paths let you draw complex shapes - Want to draw a star? A custom polygon? A curved line? You need paths. Think of paths like connect-the-dots - you give it a list of points, and it draws lines connecting them.

  2. Handles let users edit shapes - Those little squares you see when you select something in Photoshop or Figma? Those are handles. Drag a corner handle to resize. Drag the rotation handle to rotate. This is what handles do.

  3. Selection shows what you're working with - When you click a shape, you need visual feedback. Is it selected? Is your mouse hovering over it? Selection provides that feedback with outlines and highlights.

Without these systems, your shapes are just pictures on a screen. With them, users can create and modify designs interactively - exactly what a design tool needs!

Overview of the Three Systems

Paths are sequences of points that form lines and curves. They're the foundation for drawing complex shapes that can't be represented as simple primitives. The path renderer handles turtle graphics, bezier curves, smooth paths, and more.

Handles are interactive control points on shapes. When a user drags a handle, the shape updates. Handles appear at key points like corners, edges, or centers, depending on the shape type.

Selection provides visual feedback and interaction state. Selected shapes are highlighted, handles appear, and the system knows what the user is working with.

What Paths Are - Explained Simply

Think of a path like connect-the-dots, but for computers. You give the computer a list of points (coordinates), and it draws lines connecting them in order.

Simple Analogy: Imagine you're drawing a square by hand:

  1. Put your pen at the top-left corner (point 1)
  2. Draw a line to the top-right corner (point 2)
  3. Draw a line down to the bottom-right corner (point 3)
  4. Draw a line to the bottom-left corner (point 4)
  5. Draw a line back to the top-left (point 1 again - this "closes" the square)

A path is exactly like this, but you tell the computer where each point is, and it draws the lines automatically.

The Path Data Structure:

const path = {
    points: [
        [0, 0],      // First point: x=0, y=0 (top-left corner)
        [50, 0],     // Second point: x=50, y=0 (top-right corner)
        [50, 50],    // Third point: x=50, y=50 (bottom-right corner)
        [0, 50]      // Fourth point: x=0, y=50 (bottom-left corner)
    ],
    closed: true  // Connect last point back to first (makes it a square instead of 3 lines)
};

Understanding Each Part:

1. points - The List of Points:

  • This is an array (list) of coordinate pairs
  • Each pair [x, y] tells you where one point is
  • [0, 0] means "at x=0, y=0" (top-left corner in our example)
  • [50, 0] means "at x=50, y=0" (50 pixels to the right)
  • The order matters! Points are connected in the order they appear in the array
  • If you put points in the wrong order, you get a weird shape instead of what you want

2. closed: true - Connect the Last Point to the First:

  • When closed: true, the computer automatically draws a line from the last point back to the first
  • This creates a closed shape (like a square, circle, or any polygon)
  • When closed: false or missing, the path is "open" - it's just a line that doesn't loop back
  • Think of it like drawing a square (closed) vs drawing just three sides of a square (open)

This Example Creates a Square:

  • Start at (0, 0) - top-left
  • Draw line to (50, 0) - top-right
  • Draw line to (50, 50) - bottom-right
  • Draw line to (0, 50) - bottom-left
  • Because closed: true, automatically draw line back to (0, 0) - closes the square

Why Do We Need Paths?

Simple shapes (circles, rectangles) can be drawn with special commands. But what if you want:

  • A star shape? (need points at all the tips)
  • A custom polygon? (any shape with any number of corners)
  • A curved line? (bezier curves need control points)
  • A shape someone drew freehand? (turtle graphics or drawing tools)

All of these need paths - a way to say "draw lines connecting these specific points." Paths give you complete flexibility - you can draw literally any shape by specifying the points.

Real-World Example: If you've used drawing tools like Photoshop, Illustrator, or even MS Paint with the polygon tool, you've used paths. When you click to place points and the computer draws lines between them, that's exactly what paths do.

Building Basic Path Rendering From Scratch

This is the simplest path rendering - just connect points with straight lines. Think of it like a connect-the-dots puzzle where you draw straight lines between each dot.

What We're Building: A function that takes a list of points and draws lines connecting them. This is the foundation for everything else - once you understand this, you can build curves, complex shapes, and more.

Real-World Analogy: Imagine you have a map with 4 cities marked. You want to draw roads connecting them. You:

  1. Start at city 1
  2. Draw a road to city 2
  3. Draw a road to city 3
  4. Draw a road to city 4
  5. If it's a closed route, draw a road back to city 1

That's exactly what path rendering does, but with points on a canvas.

How to Build It Step by Step:

Step 1: Understand the Function Signature

Before we write code, let's understand what information the function needs:

renderRegularPath(params, styleContext, isSelected, isHovered)

What Each Parameter Does:

  1. params - This contains the actual path data

    • Has a points array (the list of coordinates to connect)
    • Has a closed property (true/false - should we close the shape?)
    • Think of this as "what to draw"
  2. styleContext - This contains styling information

    • Has colors (fill color, stroke color)
    • Has flags (shouldFill, strokeWidth, etc.)
    • Think of this as "how to draw it" (what color, filled or just outline)
  3. isSelected - Is this path currently selected?

    • true = user clicked on it, should highlight it
    • false = not selected, draw normally
    • Used to show a special outline when selected
  4. isHovered - Is the mouse hovering over this path?

    • true = mouse is over it, show hover effect
    • false = mouse not over it, draw normally
    • Used to show a subtle highlight when mouse is near

Why Four Parameters?

  • params = the data (what to draw)
  • styleContext = the style (colors, fills)
  • isSelected and isHovered = the state (user interaction)

Separating these makes the code cleaner and easier to understand. Each parameter has one job.

Step 2: Build the Function Step by Step

Step 2.1: Extract and Validate Points

We need at least 2 points to draw a line - one point is just a position, two points form a line segment. If we have fewer than 2 points, there's nothing to draw, so we return false to indicate failure. The early return prevents errors and wasted computation.

Why This Validation is Critical:

Validation is the first line of defense against errors. Without it, the function could crash when it tries to access points[0] or points[1] on an empty array. Additionally, attempting to draw with invalid data wastes CPU cycles and could cause rendering glitches. By validating early and returning false, we:

  1. Prevent crashes: Avoid TypeError: Cannot read property '0' of undefined errors
  2. Improve performance: Skip unnecessary computation when data is invalid
  3. Provide clear feedback: Returning false indicates to the caller that rendering failed
  4. Follow defensive programming: Always validate inputs before using them

Understanding the Destructuring Assignment:

The line const { points } = params; uses JavaScript destructuring. This is equivalent to writing const points = params.points; but more concise. Destructuring extracts the points property from the params object and assigns it to a local variable. If params doesn't have a points property, points will be undefined. If params itself is null or undefined, this will throw an error - which is why we check for !points afterwards.

The Validation Logic Explained:

The condition if (!points || points.length < 2) checks two things:

  1. !points - This is true if points is:

    • undefined (property doesn't exist)
    • null (explicitly set to null)
    • false, 0, "", or any other falsy value
  2. points.length < 2 - This checks the array length:

    • Length 0 = empty array (no points)
    • Length 1 = single point (can't draw a line)
    • Length 2+ = valid (can draw lines)

The || (OR) operator means "if either condition is true, return false". We use early return pattern - if validation fails, immediately exit the function without doing any more work.

Common Validation Scenarios:

  • Empty array: points = [] - nothing to draw, length is 0
  • Single point: points = [[0, 0]] - need at least two for a line, length is 1
  • Undefined points: points = undefined - data missing, property doesn't exist
  • Null points: points = null - invalid data, explicitly null
  • Missing property: params = { closed: true } - points property not included
renderRegularPath(params, styleContext, isSelected, isHovered) {
    // Extract points from params using destructuring
    // This creates a local variable 'points' containing params.points
    // If params doesn't have 'points', points will be undefined
    const { points } = params;

    // Validate that points exists and has at least 2 elements
    // !points checks for undefined/null/falsy values
    // points.length < 2 checks for empty array or single point
    // If either condition is true, return false to indicate failure
    // Early return prevents errors and saves computation
    if (!points || points.length < 2) return false;
}

If you see an error at this step:

Error: TypeError: Cannot destructure property 'points' of 'params' as it is undefined

  • What this means: params is undefined or null
  • Common causes:
    1. Method called without params: renderRegularPath() instead of renderRegularPath(params, ...)
    2. params passed as null/undefined: renderRegularPath(null, ...)
    3. Wrong parameter order in calling code
  • Fix: Add check: if (!params) return false; before destructuring, or ensure params is always passed

Error: TypeError: Cannot read property 'length' of undefined

  • What this means: points is undefined after destructuring
  • Common causes:
    1. params object doesn't have points property: { closed: true } missing points
    2. Typo in property name: params.point instead of params.points
    3. Points is null: params.points = null
  • Fix: Check params structure, verify points property exists: console.log('Params:', params);

Error: TypeError: points.length is not a number

  • What this means: points exists but is not an array
  • Common causes:
    1. Points is an object: params.points = {} instead of []
    2. Points is a string: params.points = "0,0,50,50" instead of array
    3. Points is a number: params.points = 4 instead of array of coordinates
  • Fix: Ensure points is always an array: if (!Array.isArray(points)) return false;

Error: Path renders with 1 point or 0 points (nothing appears)

  • What this means: Validation didn't catch edge case
  • Common causes:
    1. Points array has 1 element: points = [[0, 0]] (needs at least 2)
    2. Points array empty: points = []
    3. Validation check wrong: points.length < 1 instead of points.length < 2
  • Fix: Check validation: if (!points || points.length < 2) return false; ensures at least 2 points

Step 2.2: Start a New Path

beginPath() starts a new path, clearing any previous path state. Think of the canvas like a piece of paper - when you draw one shape, then try to draw another, the canvas "remembers" where your pen was. beginPath() is like picking up your pen and starting a fresh drawing.

Understanding Canvas Path State:

The HTML5 Canvas API maintains internal state about the current path. When you call moveTo(), lineTo(), or other path commands, the canvas adds them to the current path. The canvas doesn't automatically "reset" between shapes - it accumulates path commands until you explicitly clear them. This state includes:

  1. The current path's points: All the points that have been added via moveTo(), lineTo(), arc(), etc.
  2. The current position: Where the "pen" last was (where you left off drawing)
  3. Path state flags: Whether the path is open or closed

Without beginPath(), all new drawing commands are added to the existing path, creating unwanted connections between shapes.

Why This is Critical:

Without beginPath(), if you drew a circle, then tried to draw a square, the square would have an unwanted line connecting it to where the circle ended. The canvas would think you're still drawing the same shape. This happens because:

  1. You draw a circle using arc() - the canvas remembers you're at some point on the circle
  2. You start drawing a square with moveTo() to the square's first corner
  3. If beginPath() wasn't called, the canvas still thinks you're continuing the circle's path
  4. It draws a line from the circle's end position to the square's start position - unwanted connection!

Real-World Analogy:

  • Without beginPath(): You draw a circle, then try to draw a square - but the pen leaves a line from the circle to the square (messy!). It's like drawing without lifting your pen - everything connects.
  • With beginPath(): You lift your pen, move to a new spot, put it down, and draw the square cleanly. It's like starting a new drawing on a fresh part of the paper.

What beginPath() Actually Does:

When you call beginPath(), the canvas:

  1. Clears the current path's point list (forgets all previous points)
  2. Resets the current position (forgets where the pen was)
  3. Sets the path to "open" state (not closed)
  4. Prepares for a fresh sequence of path commands

Always call beginPath() at the start of any drawing function to avoid unwanted connections between shapes. This is a fundamental rule of canvas programming - every path drawing function should start with beginPath().

Common Mistakes:

  • Forgetting beginPath() results in shapes connecting unexpectedly
  • Calling beginPath() in the middle of a path (breaks the path into pieces)
  • Calling beginPath() multiple times unnecessarily (harmless but inefficient)
    // Start a new path (clears previous path state)
    // This is critical - without it, new drawing commands would be added
    // to the existing path, creating unwanted connections between shapes
    // beginPath() resets the path state, clearing all previous points
    // and preparing for a fresh sequence of path drawing commands
    this.ctx.beginPath();

Step 2.3: Move to the First Point

moveTo() moves the "pen" to a position without drawing a line. Think of it like lifting your pen off the paper, moving it to a new spot, and putting it down - but not drawing yet.

Understanding the Coordinates:

The points array uses nested arrays to represent coordinate pairs. Understanding how to access these coordinates is crucial:

  • points[0] = the first point in the array (arrays start at 0, so index 0 is the first element)
  • points[0][0] = the X coordinate of the first point (first point's first element)
  • points[0][1] = the Y coordinate of the first point (first point's second element)

Why Nested Arrays?

We use nested arrays [[x, y], [x, y], ...] instead of flat arrays [x, y, x, y, ...] because:

  1. Clarity: Each point is clearly grouped together
  2. Easier iteration: You can loop through points, and each iteration gives you a complete coordinate pair
  3. Common pattern: This matches how many graphics APIs represent coordinates (SVG, DXF, etc.)

Example: If points = [[10, 20], [50, 30]]:

  • points[0] = [10, 20] (the first point - an array containing x and y)
  • points[0][0] = 10 (the X coordinate - first element of first point)
  • points[0][1] = 20 (the Y coordinate - second element of first point)
  • So moveTo(points[0][0], points[0][1]) becomes moveTo(10, 20) after evaluation

Why Use moveTo() for the First Point?

We use moveTo() instead of lineTo() for the first point because:

  • We don't want a line from wherever the pen was before (from a previous shape)
  • We want to START at the first point (position the pen without drawing)
  • moveTo() positions without drawing - perfect for the starting point
  • After beginPath(), the current position is undefined/reset, but using moveTo() explicitly sets it

The Difference Between moveTo() and lineTo():

  • moveTo(x, y): Moves the "pen" to position (x, y) without drawing. Like lifting your pen and placing it down somewhere new.
  • lineTo(x, y): Draws a line from the current position to (x, y). Like dragging your pen from where it is to the new position, leaving a line behind.

Real-World Analogy: Imagine drawing a square:

  1. Lift your pen (don't draw yet) and move to the top-left corner - that's moveTo() - no line drawn, just positioning
  2. Now draw a line to the top-right corner - that's lineTo() - draws a line as you move
  3. Draw a line to the bottom-right - lineTo() - another line drawn
  4. And so on...

If you accidentally use lineTo() for the first point, you'll get an unwanted line from the previous drawing location to your first point, creating messy connections between shapes. This happens because the canvas remembers where you last were, even after beginPath() (though beginPath() clears the path, the current position might still exist from the last drawing operation).

The Coordinate Extraction Process:

When we write points[0][0] and points[0][1]:

  1. JavaScript first evaluates points[0] - this gets the first element of the points array
  2. Then [0] accesses the first element of that coordinate pair (X)
  3. And [1] accesses the second element of that coordinate pair (Y)
  4. These values are then passed as separate arguments to moveTo(x, y)
    // Move to first point without drawing (start position)
    // moveTo() positions the "pen" without leaving a line
    // We use points[0][0] to get X coordinate and points[0][1] to get Y coordinate
    // This sets the starting point for the path without drawing from the previous position
    // After beginPath(), this explicitly establishes where we begin drawing
    this.ctx.moveTo(points[0][0], points[0][1]);

If you see an error at this step:

Error: TypeError: Cannot read property '0' of undefined

  • What this means: points[0] is undefined
  • Common causes:
    1. Points array is empty: validation didn't catch it, points.length === 0
    2. Points array has null/undefined elements: points = [null, [50, 50]]
    3. Wrong array structure: points = [0, 50] (flat array) instead of [[0, 0], [50, 50]]
  • Fix: Add check: if (!points[0]) return false; or ensure all points are valid arrays

Error: TypeError: points[0][0] is undefined or points[0][1] is undefined

  • What this means: First point is not a coordinate pair
  • Common causes:
    1. Point is not an array: points[0] = 0 instead of [0, 0]
    2. Point array has wrong length: points[0] = [0] (only x, missing y)
    3. Point is object: points[0] = { x: 0, y: 0 } instead of [0, 0]
  • Fix: Validate point structure: if (!Array.isArray(points[0]) || points[0].length < 2) return false;

Error: TypeError: this.ctx.moveTo is not a function

  • What this means: ctx is not a 2D canvas context
  • Common causes:
    1. ctx not initialized: this.ctx = null or undefined
    2. Wrong context type: getContext('webgl') instead of getContext('2d')
    3. ctx not passed to constructor
  • Fix: Check ctx is 2D context: if (!this.ctx || typeof this.ctx.moveTo !== 'function') throw new Error('Invalid canvas context');

Error: Path starts with unexpected line segment

  • What this means: Line drawn from previous path to first point
  • Common causes:
    1. Forgot beginPath() before moveTo() - previous path state still active
    2. moveTo() called after lineTo() accidentally
    3. Canvas context not cleared between renders
  • Fix: Always call this.ctx.beginPath() at start of function before moveTo()

Step 2.4: Draw Lines to Each Subsequent Point

lineTo() draws a straight line from wherever the pen currently is to the specified point. Unlike moveTo(), this actually draws a visible line. This step connects all the points together into a continuous path by drawing lines between them sequentially.

Understanding the Loop:

Why Start at i = 1?

  • We already positioned at points[0] (the first point) using moveTo()
  • So we start the loop at i = 1 (the second point) and draw lines from there
  • If we started at i = 0, we'd try to draw a line to the first point (which we're already at - redundant!)

Understanding Loop Fundamentals:

A for loop repeats code for each element in a sequence. In this case, we're iterating through array indices. The loop structure for (let i = 1; i < points.length; i++) consists of:

  1. Initialization (let i = 1): Creates a variable i and sets it to 1. This executes once before the loop starts. We start at 1 because index 0 was already handled by moveTo().

  2. Condition (i < points.length): Before each iteration, checks if i is less than the array length. If true, the loop continues; if false, the loop exits. For example, if points.length = 4, the loop runs for i = 1, 2, 3 and stops when i = 4 (because 4 < 4 is false).

  3. Increment (i++): After each iteration, increases i by 1. This moves to the next array index. The ++ operator is shorthand for i = i + 1.

  4. Body (the code inside {}): The code that executes for each iteration. Here, it's the lineTo() call that draws a line to the current point.

Understanding lineTo() Behavior:

The lineTo(x, y) method does two things:

  1. Draws a line: Creates a visible line from the current pen position to the specified coordinates
  2. Updates position: After drawing, the pen position is updated to (x, y). This means each subsequent lineTo() continues from where the previous one ended, creating a continuous path.

This is different from moveTo(), which only updates position without drawing. The cumulative effect of multiple lineTo() calls is a connected series of line segments - a polyline.

How the Loop Works - Detailed Step-by-Step:

Let's trace through an example with 4 points: [[0, 0], [50, 0], [50, 50], [0, 50]]

  1. Before loop starts: After moveTo(points[0][0], points[0][1]), the pen is positioned at point 0: [0, 0] (the first point). No line has been drawn yet.

  2. Loop iteration 1 (i = 1):

    • Condition check: 1 < 4 is true, so enter the loop body
    • Get point: points[1] = [50, 0] (the second point in the array)
    • Extract coordinates: points[1][0] = 50 (X), points[1][1] = 0 (Y)
    • Call lineTo: this.ctx.lineTo(50, 0)
    • What happens: A line is drawn from current position [0, 0] to [50, 0] (horizontal line moving right)
    • Position update: The pen position is now updated to [50, 0]
    • Increment: i++ changes i from 1 to 2
  3. Loop iteration 2 (i = 2):

    • Condition check: 2 < 4 is true, so continue
    • Get point: points[2] = [50, 50] (the third point)
    • Extract coordinates: points[2][0] = 50 (X), points[2][1] = 50 (Y)
    • Call lineTo: this.ctx.lineTo(50, 50)
    • What happens: A line is drawn from [50, 0] (current position) to [50, 50] (vertical line moving down)
    • Position update: The pen position is now [50, 50]
    • Increment: i++ changes i from 2 to 3
  4. Loop iteration 3 (i = 3):

    • Condition check: 3 < 4 is true, so continue
    • Get point: points[3] = [0, 50] (the fourth and final point)
    • Extract coordinates: points[3][0] = 0 (X), points[3][1] = 50 (Y)
    • Call lineTo: this.ctx.lineTo(0, 50)
    • What happens: A line is drawn from [50, 50] to [0, 50] (horizontal line moving left)
    • Position update: The pen position is now [0, 50]
    • Increment: i++ changes i from 3 to 4
  5. Loop exit (i = 4):

    • Condition check: 4 < 4 is false, so exit the loop

Result: After the loop completes, we have three line segments connecting the four points:

  • Line segment 1: From [0, 0] to [50, 0] (top edge of square)
  • Line segment 2: From [50, 0] to [50, 50] (right edge of square)
  • Line segment 3: From [50, 50] to [0, 50] (bottom edge of square)

The path now goes from [0, 0][50, 0][50, 50][0, 50]. The final edge from [0, 50] back to [0, 0] will be added by closePath() if the path is closed.

What is a Polyline?

A polyline is a series of connected line segments. After this loop, you have a polyline - multiple straight lines connected end-to-end. Think of it like a connect-the-dots drawing where you draw straight lines between each consecutive dot. If the path is closed (we'll do that next), it becomes a polygon (a closed shape).

The key difference:

  • Polyline: Open path, line segments connected end-to-end but not forming a closed shape
  • Polygon: Closed path, line segments form a complete loop (the last point connects back to the first)

Why This Works - The Cumulative Effect:

Each lineTo() call draws from the current pen position to the new point, then the pen stays at that new point. This creates a chain reaction:

  • First lineTo(): Draws from start to point 1, pen now at point 1
  • Second lineTo(): Continues from point 1 to point 2, pen now at point 2
  • Third lineTo(): Continues from point 2 to point 3, pen now at point 3
  • And so on...

The result is a continuous path where each line segment connects seamlessly to the next, forming a single connected path. This is the foundation for drawing complex shapes - any shape can be represented as a series of connected line segments.

Performance Considerations:

For large point arrays (hundreds or thousands of points), this loop executes many times. Each lineTo() call is relatively fast, but the cumulative effect can impact performance:

  • 100 points = 99 lineTo() calls
  • 1000 points = 999 lineTo() calls
  • 10000 points = 9999 lineTo() calls

Some optimization strategies:

  • Use Path2D objects: Can be created once and reused (especially useful for static paths)
  • Reduce point count: Simplify paths when possible (remove redundant points)
  • Use requestAnimationFrame: Already handled by the renderer for smooth animation
  • Batch rendering: Draw multiple paths in a single frame when possible

Array Access Pattern Explained:

The expression points[i][0] and points[i][1] uses nested array access:

  • points[i] gets the point at index i (which is itself an array [x, y])
  • [0] then accesses the first element of that point array (the X coordinate)
  • [1] accesses the second element (the Y coordinate)

This pattern assumes points are stored as nested arrays [[x1, y1], [x2, y2], ...]. If points were stored differently (like objects {x, y} or a flat array), you'd need different access patterns.

    // Draw lines to each subsequent point
    // Loop starts at i=1 because we already moved to points[0] with moveTo()
    // Each iteration draws a line from current position to the next point
    // lineTo() automatically updates the current position after drawing,
    // so each line segment continues seamlessly from where the previous ended
    // This creates a continuous path connecting all points in sequence
    // The loop continues until all points have been connected (i < points.length)
    for (let i = 1; i < points.length; i++) {
        // Extract X and Y coordinates from the point at index i
        // points[i] is an array [x, y], so:
        // points[i][0] extracts the X coordinate (first element)
        // points[i][1] extracts the Y coordinate (second element)
        // lineTo() draws a visible line from current position to these coordinates
        this.ctx.lineTo(points[i][0], points[i][1]);
    }

If you see an error at this step:

Error: TypeError: Cannot read property '0' of undefined inside loop

  • What this means: Some point in array is undefined or not an array
  • Common causes:
    1. Sparse array: points = [[0, 0], , [50, 50]] (missing middle element)
    2. Points array has null elements: points = [[0, 0], null, [50, 50]]
    3. Points array has wrong structure: mixed types
  • Fix: Validate all points: for (let i = 1; i < points.length; i++) { if (!points[i] || !Array.isArray(points[i])) continue; this.ctx.lineTo(points[i][0], points[i][1]); }

Error: TypeError: points[i][0] is undefined

  • What this means: Point exists but doesn't have x/y coordinates
  • Common causes:
    1. Point is empty array: points[i] = []
    2. Point has only one coordinate: points[i] = [50] (missing y)
    3. Point is wrong type: points[i] = "50,50" (string) instead of [50, 50]
  • Fix: Check point length: if (!points[i] || points[i].length < 2) continue; before accessing coordinates

Error: Path has missing segments (some lines don't draw)

  • What this means: Some points are invalid and skipped
  • Common causes:
    1. Points array has invalid elements (null, wrong format)
    2. Validation inside loop returns early instead of continuing
    3. Points have NaN values: points[i] = [NaN, 50]
  • Fix: Filter invalid points before loop, or handle gracefully in loop with continue

Error: TypeError: this.ctx.lineTo is not a function

  • What this means: Canvas context methods not available
  • Common causes:
    1. Context lost: canvas was removed from DOM
    2. Wrong context type: using WebGL or other context
    3. Context not properly initialized
  • Fix: Check context exists: if (typeof this.ctx.lineTo !== 'function') throw new Error('Invalid context');

Step 2.5: Close the Path if Needed

closePath() automatically draws a line from the last point back to the first point. This "closes" the shape, turning an open path (just lines) into a closed polygon (a complete shape).

Understanding What closePath() Does:

When you call closePath(), the canvas automatically:

  1. Calculates the connection: Determines the first point in the current path (where we started with moveTo())
  2. Draws a line: Creates a line segment from the current position (last point) to that first point
  3. Completes the shape: The path is now closed - it forms a complete loop

This is different from manually calling lineTo() with the first point's coordinates. closePath() is specifically designed for this purpose and handles edge cases properly.

Understanding the Check: params.closed !== false

This might look confusing at first, but it's a JavaScript pattern for defaulting to true:

  • If params.closed is true: true !== false is true, so close the path ✓
  • If params.closed is undefined (not set): undefined !== false is true, so close the path ✓ (default behavior)
  • If params.closed is false: false !== false is false, so don't close the path ✗

So closed !== false means "close it unless explicitly told not to." This pattern is useful when you want a sensible default (closed paths are more common than open paths in graphics).

Alternative Patterns:

You could write this differently:

  • if (params.closed === true || params.closed === undefined) - more explicit but verbose
  • if (params.closed !== false) - concise and handles undefined gracefully
  • if (params.closed ?? true) - uses nullish coalescing (modern JavaScript), defaults to true

The !== false pattern is common in JavaScript for "truthy with a default of true" scenarios.

Why Close Before Fill:

fill() fills the inside of a closed shape with color. It needs a closed shape to know what region to fill. Here's why the order matters:

  1. If you call fill() on an open path: The browser automatically tries to close it by drawing a line from the last point to the first point. However, this automatic closure might not match your intent. For example, if the path has gaps or the points are in an unexpected order, the fill might create an unexpected shape.

  2. If you call closePath() first, then fill(): You have explicit control over how the shape closes. You ensure the path is properly closed before filling, which gives predictable results.

The Fill Algorithm:

When fill() is called on a closed path, the browser uses the "even-odd" or "non-zero" winding rule to determine what's inside:

  • It traces the path and determines which areas are enclosed
  • For complex paths (with holes or self-intersections), the winding rule determines what counts as "inside"
  • The filled area uses the current fillStyle (color, gradient, pattern, etc.)

Real-World Analogy: Imagine painting a room:

  1. First, draw the outline of the room (the path) - that's the lineTo() calls creating the walls
  2. Close the outline (make sure all walls connect) - that's closePath() ensuring the outline is complete
  3. Then paint the inside - that's fill() filling the enclosed area

You can't paint the inside until you have a complete outline! Similarly, you can't fill a path until it's closed and forms a complete region.

The shouldFill Check:

We only fill if styleContext.shouldFill is true. Sometimes you want just an outline (stroke only), sometimes you want it filled. This check lets you choose. The styleContext object contains styling information that was processed earlier, and shouldFill is a boolean flag indicating whether this shape should be filled or not.

Why Check Before Filling:

Filling a shape can be computationally expensive, especially for complex paths. By checking shouldFill first, we avoid unnecessary fill operations when only a stroke (outline) is desired. Additionally, some shapes (like turtle graphics paths) are typically stroke-only and shouldn't be filled.

Common Scenarios:

  • Filled shape: shouldFill = true, closed = true → Draws filled shape with outline
  • Outline only: shouldFill = false, closed = true → Draws just the outline
  • Open path: shouldFill = false, closed = false → Draws an open line (doesn't close or fill)
    // Close the path if needed (default to closed unless explicitly false)
    // The condition params.closed !== false means:
    // - If closed is true → close it ✓
    // - If closed is undefined (not set) → close it ✓ (default behavior)
    // - If closed is false → don't close it ✗
    // This pattern provides a sensible default (closed paths are common)
    if (params.closed !== false) {
        // closePath() automatically draws a line from the last point back to the first point
        // This completes the shape, turning an open polyline into a closed polygon
        // After closing, the path forms a complete region that can be filled
        this.ctx.closePath();

        // Fill the closed path, but only if shouldFill is true
        // shouldFill is a flag in styleContext indicating whether to fill or just stroke
        // We check before filling to avoid unnecessary fill operations
        // Filling is computationally expensive, so we skip it if not needed
        if (styleContext.shouldFill) {
            // fill() fills the interior of the closed path using the current fillStyle
            // The filled area is determined by the winding rule (even-odd or non-zero)
            this.ctx.fill();
        }
    }

If you see an error at this step:

Error: TypeError: Cannot read property 'closed' of undefined

  • What this means: params is undefined
  • Common causes:
    1. params not passed to function
    2. params is null
    3. Wrong parameter order
  • Fix: Add check: if (!params) return false; at start, or use optional chaining: params?.closed !== false

Error: Path doesn't close when it should (open shape when expecting closed)

  • What this means: Closed check logic is wrong
  • Common causes:
    1. Wrong comparison: if (params.closed === true) instead of if (params.closed !== false) (fails when undefined)
    2. Closed property is string: params.closed = "true" (string, not boolean)
    3. Logic inverted: checking for false instead of true
  • Fix: Use params.closed !== false to default to closed, or explicitly check: if (params.closed === true || params.closed === undefined)

Error: TypeError: styleContext.shouldFill is undefined

  • What this means: styleContext is undefined or missing shouldFill property
  • Common causes:
    1. styleContext not passed: renderRegularPath(params) missing second parameter
    2. styleContext created incorrectly: missing shouldFill property
    3. styleContext is null
  • Fix: Check styleContext exists: if (!styleContext) return false;, verify shouldFill property exists

Error: Fill doesn't appear even though shouldFill is true

  • What this means: Fill called incorrectly or styles not set
  • Common causes:
    1. fillStyle not set before fill(): ctx.fill() without ctx.fillStyle = 'red'
    2. closePath() not called before fill()
    3. Path has zero area (all points same or collinear)
  • Fix: Set fillStyle before fill: this.ctx.fillStyle = styleContext.fillColor; this.ctx.fill();, ensure closePath() called first

Step 2.6: Draw the Outline

stroke() draws the outline/border of the path. Think of it like drawing with a marker around the edges of a shape. This step renders the path's boundary using the current stroke style settings.

Understanding stroke():

When you call stroke(), the canvas:

  1. Uses the current path: Renders the path that was built with moveTo(), lineTo(), and closePath() calls
  2. Applies stroke style: Uses the current strokeStyle (color, gradient, pattern) and lineWidth settings
  3. Draws the outline: Creates visible lines along all the path segments
  4. Doesn't fill: Only draws the border/outline, not the interior

The stroke is drawn centered on the path - half the line width extends inside the path, half extends outside. For a closed path, this creates a visible border around the shape.

The Order Matters - Fill First, Then Stroke:

The order is critical and follows standard graphics programming practice:

  1. Fill first (ctx.fill()) - Paints the inside of the shape with color (the background). This fills the interior region defined by the closed path.

  2. Stroke second (ctx.stroke()) - Draws the outline on top. Since we do it after filling, the outline appears on top of the fill, making it clearly visible and preventing the fill from covering any part of the stroke.

What Happens If You Do It Backwards (Stroke Then Fill):

If you stroke first, then fill:

  • The stroke (outline) gets drawn first, creating a visible border
  • Then the fill covers part of the stroke (half of the stroke width gets covered because the fill draws over it)
  • Result: You only see half the stroke thickness - the outer half is visible, but the inner half is covered by the fill. This makes the stroke look thinner and inconsistent, and it can appear visually broken or incomplete.

Why This Happens:

The stroke is drawn centered on the path line. So if your path defines the edge of a shape, the stroke extends both inward and outward from that edge:

  • Inner half: Extends into the shape's interior (gets covered by fill if fill comes after)
  • Outer half: Extends outside the shape (always visible)

By filling first, then stroking, we ensure:

  • The fill covers the interior (including the inner half of where the stroke will be)
  • The stroke then draws on top, covering both the fill's edge and extending outward
  • Result: Full stroke thickness is visible

Real-World Analogy: Think of painting a picture:

  1. First, fill in the shapes with paint (the fill) - this is like painting the background/base color
  2. Then, go back and outline them with a pen (the stroke) - this is like drawing the border on top

If you outline first, then paint over it, the paint will cover part of your outline! The outline you drew would be partially obscured by the paint. This is why artists typically paint the base colors first, then add outlines/details on top.

This is Standard Practice:

This is the standard way to draw filled shapes with outlines in graphics programming. Fill provides the base color/background, stroke provides the border/outline on top. Almost all graphics libraries (Canvas API, SVG, OpenGL, etc.) follow this pattern:

  • Fill first (base layer)
  • Stroke second (top layer)

This ensures consistent, predictable rendering where the stroke is always fully visible.

Return Value:

Returning true indicates successful rendering. This is useful for error handling - if validation fails or rendering can't complete, we return false to indicate failure. The caller can check the return value to know if the path was rendered successfully.

What Happens After stroke():

After stroke() is called:

  • The path outline is rendered on the canvas
  • The path data remains in memory (available for further operations if needed)
  • Canvas state (colors, line width, etc.) is preserved for subsequent drawing operations
  • The function completes and returns control to the caller
    // Draw the outline (stroke) on top of the fill
    // stroke() renders the path's boundary using current strokeStyle and lineWidth
    // It's called AFTER fill() so the outline appears on top and is fully visible
    // The stroke is drawn centered on the path - half inside, half outside
    // By stroking after filling, we ensure the full stroke thickness is visible
    // (if we stroked first, the fill would cover the inner half of the stroke)
    this.ctx.stroke();

    // Return true to indicate successful rendering
    // This allows callers to check if path rendering completed successfully
    return true;
}

If you see an error at this step:

Error: TypeError: this.ctx.stroke is not a function

  • What this means: Canvas context doesn't have stroke method
  • Common causes:
    1. Context is null/undefined: this.ctx = null
    2. Wrong context type: not a 2D rendering context
    3. Context was lost: canvas removed from DOM
  • Fix: Check context exists and is 2D: if (!this.ctx || typeof this.ctx.stroke !== 'function') throw new Error('Invalid context');

Error: Path outline doesn't appear (no stroke visible)

  • What this means: Stroke not drawing or styles not set
  • Common causes:
    1. strokeStyle not set: ctx.stroke() without ctx.strokeStyle = 'color'
    2. lineWidth is 0: ctx.lineWidth = 0
    3. strokeStyle is transparent: ctx.strokeStyle = 'rgba(0,0,0,0)'
    4. Path has no segments (all points same location)
  • Fix: Set stroke style before stroke: this.ctx.strokeStyle = styleContext.strokeColor; this.ctx.lineWidth = styleContext.strokeWidth; this.ctx.stroke();

Error: Stroke appears on top but fill covers part of it

  • What this means: Stroke called before fill (wrong order)
  • Common causes:
    1. stroke() called before fill()
    2. closePath() and fill() not called, but stroke() is
  • Fix: Always call fill() first (if needed), then stroke(): fill background, stroke on top

Error: Path renders but stroke is very thick or invisible

  • What this means: lineWidth set incorrectly
  • Common causes:
    1. lineWidth too large: ctx.lineWidth = 100 makes stroke huge
    2. lineWidth is 0 or negative: stroke invisible
    3. lineWidth is NaN: stroke might not render
  • Fix: Check lineWidth value: if (styleContext.strokeWidth > 0) { this.ctx.lineWidth = styleContext.strokeWidth; this.ctx.stroke(); }

The Complete Function:

renderRegularPath(params, styleContext, isSelected, isHovered) {
    // Step 1: Extract points from params and validate
    const { points } = params;
    if (!points || points.length < 2) return false;

    // Step 2: Start a new path on the canvas
    this.ctx.beginPath();

    // Step 3: Move to the first point without drawing
    this.ctx.moveTo(points[0][0], points[0][1]);

    // Step 4: Draw lines to each subsequent point
    for (let i = 1; i < points.length; i++) {
        this.ctx.lineTo(points[i][0], points[i][1]);
    }

    // Step 5: Close the path if needed
    if (params.closed !== false) {
        this.ctx.closePath();

        if (styleContext.shouldFill) {
            this.ctx.fill();
        }
    }

    // Step 6: Draw the outline
    this.ctx.stroke();

    return true;
}

Building This Step by Step:

  1. Create renderRegularPath() method with four parameters
  2. Extract points from params and validate (need at least 2 points)
  3. Call beginPath() to start a new path
  4. Call moveTo() to position at first point
  5. Loop through remaining points, calling lineTo() for each
  6. Check if path should be closed, call closePath() if needed
  7. Fill the path if shouldFill is true
  8. Stroke the path outline
  9. Return true to indicate success

Canvas Path Drawing Sequence Explained: The canvas 2D API uses a state machine for path drawing. Here's what happens:

  1. beginPath() - Starts a new path, clearing any previous path state. This is like getting a fresh sheet of paper. Without it, you'd be drawing on top of previous paths.

  2. moveTo() - Positions the "pen" without drawing. This is like lifting your pen and placing it at a new location. We use it for the first point because we don't want a line from wherever the pen was before.

  3. lineTo() - Draws a line from the current position to the specified point. Each call extends the path. After multiple lineTo() calls, you have a polyline.

  4. closePath() - Connects the last point back to the first point, creating a closed polygon. This is essential for filling. The browser doesn't automatically close paths - you must call this explicitly.

  5. fill() - Fills the interior of the closed path. This only works properly on closed paths. The fill uses the current fillStyle (color, gradient, etc.).

  6. stroke() - Draws the outline of the path. This uses the current strokeStyle and lineWidth. We call it after fill() so the stroke appears on top.

Why This Order Matters:

  • Fill before stroke ensures the stroke is visible on top
  • Close before fill ensures proper shape closure
  • Begin path first to avoid drawing on previous paths
  • Move to first point to avoid unwanted initial line

Point Array Access: Points are stored as [x, y] arrays, so we access them with:

  • points[i][0] - X coordinate of point i
  • points[i][1] - Y coordinate of point i

This is a common pattern in graphics programming - coordinate pairs as arrays.

Turtle Path Rendering

Turtle graphics create paths with multiple sub-paths. This is different from regular paths because the turtle can lift its pen (penup) and move without drawing, then put the pen down (pendown) and draw again. Each continuous drawing segment becomes a separate sub-path.

Understanding Sub-Paths: When a turtle executes commands like:

  • forward(50) - draws a line
  • penup() - lifts the pen
  • forward(30) - moves without drawing
  • pendown() - puts pen down
  • forward(50) - draws another line

This creates two separate sub-paths: the first line and the second line. They're not connected because the pen was lifted between them.

The Rendering Function:

Turtle graphics create paths with multiple sub-paths. This is different from regular paths because the turtle can lift its pen (penup) and move without drawing, then put the pen down (pendown) and draw again. Each continuous drawing segment becomes a separate sub-path.

Understanding Sub-Paths:

When a turtle executes commands like:

  • forward(50) - draws a line
  • penup() - lifts the pen
  • forward(30) - moves without drawing
  • pendown() - puts pen down
  • forward(50) - draws another line

This creates two separate sub-paths: the first line and the second line. They're not connected because the pen was lifted between them.

Key Differences from Regular Paths:

  • Each sub-path needs its own beginPath() call to ensure sub-paths don't connect to each other
  • Turtle paths are always open (never closed) - they represent the path the pen took
  • No fill - turtle graphics are line drawings, not filled shapes
  • Only stroke - we just draw the outline
renderTurtlePath(params, styleContext, isSelected, isHovered) {
    // Validate we have sub-paths to render
    // subPaths is an array of arrays - each inner array is a separate path segment
    if (!params.subPaths || params.subPaths.length === 0) return false;

    // Render each sub-path separately
    for (const path of params.subPaths) {
        // Validate sub-path has enough points (need at least 2 for a line)
        if (path.length >= 2) {
            // Each sub-path needs its own beginPath() to prevent connections between segments
            this.ctx.beginPath();

            // Move to first point of this sub-path (position without drawing)
            this.ctx.moveTo(path[0][0], path[0][1]);

            // Draw lines connecting all points in this sub-path
            for (let i = 1; i < path.length; i++) {
                this.ctx.lineTo(path[i][0], path[i][1]);
            }

            // Draw outline (no fill for turtle paths - they're line drawings)
            this.ctx.stroke();
        }
    }

    return true;
}

Why Sub-Paths for Turtle Graphics: The key difference between turtle paths and regular paths is the concept of sub-paths:

  1. Pen State: When the turtle executes penup, it stops drawing and can move without leaving a trail. When it executes pendown, it starts drawing again from the new position. Each continuous drawing segment (between penup/pendown pairs) becomes a separate sub-path.

  2. Example: If the turtle draws a square, lifts the pen, moves, draws a circle - that's two sub-paths. They're visually separate because the pen was lifted between them.

  3. Rendering Strategy: We loop through each sub-path and render it separately with its own beginPath() and stroke() calls. This ensures sub-paths don't connect to each other visually.

  4. Validation: We check path.length >= 2 because you need at least 2 points to draw a line (start and end). Sub-paths with fewer points are skipped.

  5. No Fill, No Close: Turtle paths are always open (not closed) and always stroked (not filled) because they represent pen movements, not filled shapes. They're like a pen drawing on paper - you can see the line, but there's no fill.

Performance Considerations:

  • Each sub-path requires its own beginPath() call, which has a small performance cost
  • For paths with many sub-paths, this can add up
  • However, the visual correctness (separate sub-paths) is more important than micro-optimizations
  • If you have thousands of sub-paths, consider batching or optimization strategies

Building Bezier Curve Rendering From Scratch

Bezier curves are smooth curves defined by control points. Unlike straight lines, bezier curves create smooth, flowing shapes that are essential for modern graphics. Understanding how to render them is crucial for creating professional-looking designs.

How to Build It Step by Step:

Step 1: Understand Bezier Curves A cubic bezier curve (the most common type) requires exactly 4 points:

  1. Start point - Where the curve begins
  2. Control point 1 - "Pulls" the curve in one direction
  3. Control point 2 - "Pulls" the curve in another direction
  4. End point - Where the curve ends

The curve doesn't pass through the control points - they act like magnets that influence the curve's shape. The curve starts at the start point, is pulled toward control point 1, then toward control point 2, and finally reaches the end point.

Step 2: Build the Rendering Function

Step 2.1: Validate Points

renderBezierPath(params, styleContext, isSelected, isHovered) {
    // Validate we have enough points for a bezier curve
    if (!params.points || params.points.length < 4) return false;

    // Extract points for easier access
    const points = params.points;
}

Why Validate: A cubic bezier curve requires exactly 4 points. If we have fewer, we can't draw a proper bezier curve. Return false to indicate we can't render this. This validation prevents errors and ensures we only render valid bezier curves.

Step 2.2: Start Path and Move to Start Point

    // Start a new path
    this.ctx.beginPath();

    // Move to the start point
    this.ctx.moveTo(points[0][0], points[0][1]);

Why beginPath() and moveTo(): beginPath() clears any previous path state. moveTo() positions the pen at the start of the curve without drawing. points[0] is the start point of the bezier curve.

Step 2.3: Draw the Bezier Curve

    // Draw the bezier curve or fallback to lines
    if (points.length === 4) {
        // Perfect case: exactly 4 points for a cubic bezier
        this.ctx.bezierCurveTo(
            points[1][0], points[1][1],  // Control point 1: influences start of curve
            points[2][0], points[2][1],  // Control point 2: influences end of curve
            points[3][0], points[3][1]    // End point: where the curve ends
        );
    } else {
        // Fallback: if we have more than 4 points, just draw straight lines
        for (let i = 1; i < points.length; i++) {
            this.ctx.lineTo(points[i][0], points[i][1]);
        }
    }

How bezierCurveTo() Works: bezierCurveTo() draws a cubic bezier curve from the current position (the start point we moved to) to the end point, with the curve being influenced by the two control points. The curve starts at the current position, is "pulled" toward control point 1 (strongest influence at the start), then "pulled" toward control point 2 (strongest influence at the end), and finally reaches the end point. The control points don't lie on the curve - they're like handles that shape the curve. Moving a control point changes the curve's shape.

Why Fallback: If we have more than 4 points, we fall back to drawing straight lines. This handles edge cases where the data might be malformed or we're transitioning between different path types. This is a graceful degradation - if we can't draw a bezier curve, at least show something.

Step 2.4: Draw the Outline

    // Draw the curve outline
    this.ctx.stroke();

Why Stroke Only: We don't fill bezier curves by default - they're typically used for outlines. If you want to fill, you'd need to close the path first (but bezier curves are usually open paths, not closed shapes).

Step 2.5: Show Control Points if Selected

    // Show control points if selected (for editing)
    if (isSelected && params.showControlPoints !== false) {
        this.renderBezierControlPoints(points);
    }

    return true;
}

Why Show Control Points: When a bezier curve is selected, show the control points visually. This helps users understand and edit the curve. They can see where the control points are and how they influence the curve. The check params.showControlPoints !== false means "show by default" - only hide if explicitly set to false.

The Complete Function:

Bezier curves require exactly 4 points: a start point, two control points, and an end point. The control points act like "handles" that pull the curve toward them. The closer a control point is to the curve, the stronger its influence.

How Bezier Curves Work Mathematically:

The bezier curve is calculated using a mathematical formula that interpolates between the points:

  • At t=0 (start): The curve is at the start point
  • At t=0.5 (middle): The curve is influenced by both control points
  • At t=1 (end): The curve is at the end point

If both control points are on the same side of the line between start and end, you get a simple curve. If they're on opposite sides, you get an S-curve.

Control Point Visualization:

When a bezier curve is selected, we draw the control points and lines connecting them to help users understand and edit the curve:

  • Control points are typically drawn as small circles or squares
  • Lines connect the start point to control point 1, and the end point to control point 2
  • This visual feedback shows users how the control points influence the curve
  • Users can then drag these control points to modify the curve's shape

Why the Fallback?

If we have more than 4 points, we fall back to drawing straight lines. This handles edge cases where the data might be malformed, we're transitioning between path types, or the system is in an inconsistent state. The fallback ensures we always render something, even if it's not a perfect bezier curve.

renderBezierPath(params, styleContext, isSelected, isHovered) {
    // Validate we have enough points for a bezier curve (need 4: start, control1, control2, end)
    if (!params.points || params.points.length < 4) return false;

    const points = params.points;

    // Start a new path
    this.ctx.beginPath();

    // Move to the start point
    this.ctx.moveTo(points[0][0], points[0][1]);

    // Draw bezier curve if we have exactly 4 points, otherwise fallback to lines
    if (points.length === 4) {
        this.ctx.bezierCurveTo(
            points[1][0], points[1][1],  // Control point 1
            points[2][0], points[2][1],  // Control point 2
            points[3][0], points[3][1]    // End point
        );
    } else {
        // Fallback: draw straight lines if we have more than 4 points
        for (let i = 1; i < points.length; i++) {
            this.ctx.lineTo(points[i][0], points[i][1]);
        }
    }

    // Draw the curve outline
    this.ctx.stroke();

    // Show control points if selected (for editing) - default to showing unless explicitly disabled
    if (isSelected && params.showControlPoints !== false) {
        this.renderBezierControlPoints(points);
    }

    return true;
}

Smooth Path Rendering

Smooth paths use quadratic curves to create smooth transitions:

renderSmoothPath(params, styleContext, isSelected, isHovered) {
    const { points } = params;
    if (!points || points.length < 3) {
        return this.renderRegularPath(params, styleContext, isSelected, isHovered);
    }

    this.ctx.beginPath();
    this.ctx.moveTo(points[0][0], points[0][1]);

    // Use quadratic curves between points
    for (let i = 1; i < points.length - 1; i++) {
        const currentPoint = points[i];
        const nextPoint = points[i + 1];

        // Control point is halfway between current and next
        const controlX = (currentPoint[0] + nextPoint[0]) / 2;
        const controlY = (currentPoint[1] + nextPoint[1]) / 2;

        // Quadratic curve: goes through current point, curves toward control, ends at midpoint
        this.ctx.quadraticCurveTo(
            currentPoint[0], currentPoint[1],  // Control point (the actual point)
            controlX, controlY                 // End point (midpoint to next)
        );
    }

    // Draw final line to last point
    this.ctx.lineTo(points[points.length - 1][0], points[points.length - 1][1]);

    if (params.closed !== false && points.length > 2) {
        this.ctx.closePath();
        if (styleContext.shouldFill) {
            this.ctx.fill();
        }
    }

    this.ctx.stroke();
    return true;
}

How smooth paths work: Instead of sharp corners, use quadratic curves. The curve goes through each point, but curves smoothly between them. The control point is the actual point, the end point is the midpoint to the next point.

Why quadratic? Simpler than cubic bezier (only one control point), but still smooth. Good for approximating smooth curves from point data.

Path with Markers

Paths can have markers at the start and end (arrows, circles, etc.):

renderPathMarkers(params, styleContext) {
    const { points } = params;
    if (!points || points.length < 2) return;

    if (params.startMarker) {
        // Draw marker at first point, pointing toward second point
        this.renderMarker(
            points[0],
            points[1],
            params.startMarker,
            styleContext
        );
    }

    if (params.endMarker) {
        // Draw marker at last point, pointing from second-to-last point
        const lastIndex = points.length - 1;
        this.renderMarker(
            points[lastIndex],
            points[lastIndex - 1],
            params.endMarker,
            styleContext
        );
    }
}

renderMarker(point, direction, markerType, styleContext) {
    if (!point || !direction) return;

    // Calculate angle from direction
    const dx = direction[0] - point[0];
    const dy = direction[1] - point[1];
    const angle = Math.atan2(dy, dx);

    this.ctx.save();
    this.ctx.translate(point[0], point[1]);
    this.ctx.rotate(angle);

    switch (markerType) {
        case 'arrow':
            this.renderArrowMarker(styleContext);
            break;
        case 'circle':
            this.renderCircleMarker(styleContext);
            break;
        // ... etc
    }

    this.ctx.restore();
}

How markers work: Calculate the angle from the point to the direction point. Rotate the canvas, draw the marker, restore. The marker is drawn in local space (pointing right), then rotated to match the path direction.

Arrow marker example:

renderArrowMarker(styleContext) {
    const size = 8;
    this.ctx.beginPath();
    this.ctx.moveTo(0, 0);           // Tip of arrow
    this.ctx.lineTo(-size, -size/2); // Top of arrowhead
    this.ctx.lineTo(-size, size/2);  // Bottom of arrowhead
    this.ctx.closePath();

    if (styleContext.shouldFill) {
        this.ctx.fill();
    }
    this.ctx.stroke();
}

Animated Path Rendering

For drawing animations, you can render only part of a path:

renderAnimatedPath(params, styleContext, isSelected, isHovered, animationProgress = 0) {
    const { points } = params;
    if (!points || points.length < 2) return false;

    // Calculate total path length
    const totalLength = this.calculatePathLength(points);
    const currentLength = totalLength * animationProgress;

    // Find which points are visible
    let accumulatedLength = 0;
    let visiblePoints = [points[0]];

    for (let i = 1; i < points.length; i++) {
        const segmentLength = this.calculateDistance(points[i-1], points[i]);

        if (accumulatedLength + segmentLength <= currentLength) {
            // This entire segment is visible
            visiblePoints.push(points[i]);
            accumulatedLength += segmentLength;
        } else {
            // Partially visible - interpolate
            const remainingLength = currentLength - accumulatedLength;
            const ratio = remainingLength / segmentLength;

            const interpolatedPoint = [
                points[i-1][0] + (points[i][0] - points[i-1][0]) * ratio,
                points[i-1][1] + (points[i][1] - points[i-1][1]) * ratio
            ];

            visiblePoints.push(interpolatedPoint);
            break;
        }
    }

    // Render only the visible portion
    const animatedParams = { ...params, points: visiblePoints, closed: false };
    return this.renderRegularPath(animatedParams, styleContext, isSelected, isHovered);
}

How animation works: Calculate the total path length. Based on progress (0 to 1), calculate how much of the path should be visible. Find which points are in that range, interpolate the last point if needed, then render only that portion.

Path length calculation:

calculatePathLength(points) {
    let totalLength = 0;
    for (let i = 1; i < points.length; i++) {
        totalLength += this.calculateDistance(points[i-1], points[i]);
    }
    return totalLength;
}

calculateDistance(point1, point2) {
    const dx = point2[0] - point1[0];
    const dy = point2[1] - point1[1];
    return Math.sqrt(dx * dx + dy * dy);
}

Path Simplification

For performance, you can reduce the number of points:

simplifyPath(points, tolerance = 1) {
    if (!points || points.length <= 2) return points;

    const simplified = [points[0]];

    for (let i = 1; i < points.length - 1; i++) {
        const prev = points[i - 1];
        const current = points[i];
        const next = points[i + 1];

        // Calculate distance from current point to line between prev and next
        const distance = this.distanceToLine(current, prev, next);

        // If distance is large, keep the point. Otherwise, skip it.
        if (distance > tolerance) {
            simplified.push(current);
        }
    }

    simplified.push(points[points.length - 1]);
    return simplified;
}

How simplification works: For each point, check if it's close to the line between the previous and next points. If it is, you can remove it without changing the shape much. This reduces point count while keeping the path looking similar.

Distance to line: Project the point onto the line, calculate distance from point to projection. If it's small, the point is redundant.

Common Gotchas

Points must be in order: Path points define the outline. They must be sequential. Random order = broken path.

Closed paths: Set closed: true to connect last point to first. Otherwise, the path is open (just a line).

Bezier control points: Control points don't lie on the curve. They "pull" the curve. Users often expect them to be on the curve - that's a common confusion.

Path length: Calculating path length is expensive (iterate through all points). Cache it if you use it often.

Smooth paths: Quadratic curves are simpler than bezier, but still smooth. Good for approximating smooth curves.

The path renderer handles all the complex path drawing. It doesn't know about shapes, selection, or interaction. It just draws paths. This makes it reusable - any system that needs to draw paths can use it.

Building the Handle System From Scratch

Handles are the little squares/circles you see when you select a shape. You drag them to resize or rotate. The handle system manages all of this.

What Handles Are - Explained Simply

What Are Handles? Handles are the little squares or circles you see when you select a shape in design tools. They appear at key points on the shape (corners, edges, etc.) and let you drag them to modify the shape.

Real-World Analogy: Think of handles like the corners of a picture frame. When you want to resize a picture:

  • You grab the corner (the handle)
  • You drag it to make the picture bigger or smaller
  • The picture resizes as you drag

Handles work exactly the same way, but in code!

Types of Handles:

  1. Corner Handles (tl, tr, br, bl):

    • tl = top-left corner
    • tr = top-right corner
    • br = bottom-right corner
    • bl = bottom-left corner
    • These let you resize the shape by dragging any corner
    • Dragging a corner makes the shape bigger or smaller
  2. Rotation Handle:

    • Usually appears outside the shape (often above it)
    • Drag it in a circle to rotate the shape
    • Like spinning a wheel - the handle is where you grab it
  3. Edge Handles (optional):

    • Appear in the middle of each edge (top, right, bottom, left)
    • Let you resize along just one axis (make it wider or taller, but not both)
    • Useful for fine-tuning dimensions

Visual Example:

When a shape is selected, handles appear:

     [rotation handle]
          |
     +----+----+
     |    |    |  ← corner handles at corners
  +--+    |    +--+
  |       |       |  ← edge handles on sides
  +-------+-------+

Why Do We Need Handles?

Without handles, users can't interact with shapes. They can't:

  • Resize shapes by dragging
  • Rotate shapes easily
  • See what they can modify

Handles provide:

  • Visual feedback - "I can drag this to resize"
  • Clear interaction points - Users know where to click/drag
  • Intuitive editing - Like real-world tools (Photoshop, Figma, etc.)

Where Have You Seen Handles?

  • Photoshop: When you select a layer, handles appear
  • Figma/Sketch: Selection shows corner handles for resizing
  • Word/PowerPoint: Images have corner handles for resizing
  • Even this text editor: If you select text, you might see handles for resizing

Handles are everywhere in design tools - they're a standard way to make things editable!

Building Basic Handle Drawing From Scratch

Handles are the interactive control points that appear when a shape is selected. They allow users to resize, rotate, and manipulate shapes directly.

How to Build It Step by Step:

Step 1: Create the drawHandleAtPosition() Method This method draws a single handle at a specified position:

drawHandleAtPosition(x, y, handleType = 'corner', isHovered = false, isActive = false, color = null) {
    // Step 1.1: Determine handle size and color
    // Hovered handles are slightly larger for better visibility
    const radius = isHovered ? this.handleHoverRadius : this.handleRadius;
    const strokeColor = color || this.selectionColor;

    // Step 1.2: Save canvas state and translate to handle position
    // This allows us to draw the handle centered at (0, 0) in local space
    this.ctx.save();
    this.ctx.translate(x, y);
}

Why Save and Translate: By saving the canvas state and translating to the handle position, we can draw the handle centered at (0, 0) in local space. This makes the drawing code simpler - we don't need to add x/y offsets to every coordinate.

Step 2: Draw the Shadow

    // Step 2.1: Draw shadow (offset by 0.5px for depth)
    this.ctx.beginPath();
    this.ctx.arc(0.5, 0.5, radius, 0, Math.PI * 2);
    this.ctx.fillStyle = this.handleShadowColor;
    this.ctx.fill();

Why Shadow: The shadow makes handles pop off the canvas. The 0.5px offset creates a subtle 3D effect, making the handle appear to float above the canvas. This improves visibility and provides visual feedback.

Step 3: Draw the Handle

    // Step 3.1: Draw handle (white fill)
    this.ctx.beginPath();
    this.ctx.arc(0, 0, radius, 0, Math.PI * 2);
    this.ctx.fillStyle = this.handleFillColor;  // White
    this.ctx.fill();

Why White Fill: White fill provides good contrast against most backgrounds. The handle is clearly visible whether the shape is light or dark colored.

Step 4: Draw the Border

    // Step 4.1: Draw border
    // Active handles (being dragged) get thicker, more opaque border
    this.ctx.strokeStyle = isActive ? strokeColor + 'FF' : strokeColor;
    this.ctx.lineWidth = isActive ? 3 : 2;
    this.ctx.stroke();

Why Different Styles for Active: Active handles (being dragged) get a thicker, more opaque border. This provides clear visual feedback that the handle is currently being manipulated. The user can see which handle they're dragging.

Step 5: Draw Hover Ring

    // Step 5.1: Draw hover ring (if hovered)
    if (isHovered) {
        this.ctx.beginPath();
        this.ctx.arc(0, 0, radius + 2, 0, Math.PI * 2);
        this.ctx.strokeStyle = strokeColor + '40';  // Semi-transparent
        this.ctx.lineWidth = 1;
        this.ctx.stroke();
    }

    // Step 5.2: Restore canvas state
    this.ctx.restore();
}

Why Hover Ring: The hover ring provides visual feedback when the mouse is over a handle. It's slightly larger than the handle and semi-transparent, making it clear which handle the mouse is hovering over without being too distracting.

The Complete Function:

drawHandleAtPosition(x, y, handleType = 'corner', isHovered = false, isActive = false, color = null) {
    const radius = isHovered ? this.handleHoverRadius : this.handleRadius;
    const strokeColor = color || this.selectionColor;

    this.ctx.save();
    this.ctx.translate(x, y);

    // Draw shadow (offset by 0.5px for depth)
    this.ctx.beginPath();
    this.ctx.arc(0.5, 0.5, radius, 0, Math.PI * 2);
    this.ctx.fillStyle = this.handleShadowColor;
    this.ctx.fill();

    // Draw handle
    this.ctx.beginPath();
    this.ctx.arc(0, 0, radius, 0, Math.PI * 2);
    this.ctx.fillStyle = this.handleFillColor;  // White
    this.ctx.fill();

    // Draw border
    this.ctx.strokeStyle = isActive ? strokeColor + 'FF' : strokeColor;
    this.ctx.lineWidth = isActive ? 3 : 2;
    this.ctx.stroke();

    // Draw hover ring
    if (isHovered) {
        this.ctx.beginPath();
        this.ctx.arc(0, 0, radius + 2, 0, Math.PI * 2);
        this.ctx.strokeStyle = strokeColor + '40';
        this.ctx.lineWidth = 1;
        this.ctx.stroke();
    }

    this.ctx.restore();
}

The Visual Design: White fill, colored border, shadow for depth. When hovered, show a ring. When active (dragging), thicker border. This creates a clear, professional-looking handle that provides excellent visual feedback.

Building This Step by Step:

  1. Create drawHandleAtPosition() method with parameters for position, type, state, and color
  2. Determine handle size based on hover state
  3. Get stroke color (use provided color or default selection color)
  4. Save canvas state and translate to handle position
  5. Draw shadow circle (offset by 0.5px)
  6. Draw handle circle (white fill)
  7. Draw border (thicker and more opaque if active)
  8. Draw hover ring if hovered (semi-transparent, slightly larger)
  9. Restore canvas state

Building Corner Handle Drawing From Scratch

For a selected shape, draw handles at the corners. These handles allow users to resize the shape by dragging the corners.

How to Build It Step by Step:

Step 1: Calculate Handle Positions Handle positions are calculated based on the shape's bounds in local space:

drawCornerHandles(shape, scaledWidth, scaledHeight) {
    // Step 1.1: Define handle positions in local space
    // Shapes are centered at (0, 0), so corners are at ±width/2, ±height/2
    const handlePositions = [
        { x: -scaledWidth / 2, y: -scaledHeight / 2, handle: 'tl' },  // Top-left
        { x: scaledWidth / 2, y: -scaledHeight / 2, handle: 'tr' },   // Top-right
        { x: scaledWidth / 2, y: scaledHeight / 2, handle: 'br' },     // Bottom-right
        { x: -scaledWidth / 2, y: scaledHeight / 2, handle: 'bl' }     // Bottom-left
    ];

Why Local Space: The canvas is already translated and rotated to the shape's position. So handles are drawn in local space, then the canvas transform applies. This means we calculate positions relative to the shape's center (0, 0), and the canvas transform handles positioning and rotation.

Why These Coordinates: Shapes are centered at (0, 0), so:

  • Top-left corner is at (-width/2, -height/2)
  • Top-right corner is at (width/2, -height/2)
  • Bottom-right corner is at (width/2, height/2)
  • Bottom-left corner is at (-width/2, height/2)

Step 2: Draw Each Handle

    // Step 2.1: Draw each handle
    handlePositions.forEach(pos => {
        // Step 2.2: Check if this handle is hovered or active
        const isHovered = this.hoveredHandle === pos.handle;
        const isActive = this.activeHandle === pos.handle;

        // Step 2.3: Draw the handle at its position
        this.drawHandleAtPosition(pos.x, pos.y, 'corner', isHovered, isActive);
    });
}

Why Check State: We check if each handle is hovered or active so we can pass the correct state to drawHandleAtPosition(). This ensures handles show the correct visual feedback (hover ring, thicker border, etc.).

The Complete Function:

drawCornerHandles(shape, scaledWidth, scaledHeight) {
    const handlePositions = [
        { x: -scaledWidth / 2, y: -scaledHeight / 2, handle: 'tl' },
        { x: scaledWidth / 2, y: -scaledHeight / 2, handle: 'tr' },
        { x: scaledWidth / 2, y: scaledHeight / 2, handle: 'br' },
        { x: -scaledWidth / 2, y: scaledHeight / 2, handle: 'bl' }
    ];

    handlePositions.forEach(pos => {
        const isHovered = this.hoveredHandle === pos.handle;
        const isActive = this.activeHandle === pos.handle;

        this.drawHandleAtPosition(pos.x, pos.y, 'corner', isHovered, isActive);
    });
}

Building This Step by Step:

  1. Create drawCornerHandles() method with shape and dimensions
  2. Calculate handle positions in local space (four corners)
  3. Loop through each handle position
  4. Check if handle is hovered or active
  5. Call drawHandleAtPosition() for each handle
  6. Handles will be drawn with correct visual feedback

Drawing Rotation Handle

The rotation handle is above the shape:

drawRotationHandle(shape, scaledHeight) {
    const rotHandleY = -scaledHeight / 2 - this.rotationHandleDistance;
    const isRotHovered = this.hoveredHandle === 'rotate';
    const isRotActive = this.activeHandle === 'rotate';

    // Draw connection line
    this.ctx.beginPath();
    this.ctx.moveTo(0, -scaledHeight / 2);
    this.ctx.lineTo(0, rotHandleY);
    this.ctx.strokeStyle = this.selectionColor + '60';
    this.ctx.lineWidth = 1;
    this.ctx.stroke();

    // Draw handle
    this.drawHandleAtPosition(0, rotHandleY, 'rotation', isRotHovered, isRotActive);

    // Draw rotation icon (curved arrow)
    this.ctx.beginPath();
    this.ctx.arc(0, rotHandleY, this.handleRadius * 0.4, 0, Math.PI * 1.5);
    this.ctx.strokeStyle = this.selectionColor;
    this.ctx.lineWidth = 1.5;
    this.ctx.stroke();
}

Why above? Rotation handle needs to be separate from resize handles. Putting it above makes it clear it's for rotation.

The icon: A curved arrow indicates rotation. Draw a partial arc, then a small arrow.

Getting Handle Positions

To test if the mouse is over a handle:

getHandlePositions(shape) {
    if (!shape || !shape.transform) return [];

    // Get bounds in local space
    const bounds = this.renderer.transformManager.calculateBounds(shape);
    const scaledWidth = bounds.width * this.coordinateSystem.scale;
    const scaledHeight = bounds.height * this.coordinateSystem.scale;

    // Get shape position in screen space
    const shapeX = this.coordinateSystem.transformX(shape.transform.position[0]);
    const shapeY = this.coordinateSystem.transformY(shape.transform.position[1]);

    const halfWidth = scaledWidth / 2;
    const halfHeight = scaledHeight / 2;

    // Account for rotation
    const angle = -shape.transform.rotation * Math.PI / 180;
    const rotate = (px, py) => {
        const s = Math.sin(angle);
        const c = Math.cos(angle);
        const dx = px - shapeX;
        const dy = py - shapeY;
        return {
            x: shapeX + (dx * c - dy * s),
            y: shapeY + (dx * s + dy * c)
        };
    };

    // Calculate handle positions in screen space
    const positions = [
        { id: 'tl', ...rotate(shapeX - halfWidth, shapeY - halfHeight), type: 'corner' },
        { id: 'tr', ...rotate(shapeX + halfWidth, shapeY - halfHeight), type: 'corner' },
        { id: 'br', ...rotate(shapeX + halfWidth, shapeY + halfHeight), type: 'corner' },
        { id: 'bl', ...rotate(shapeX - halfWidth, shapeY + halfHeight), type: 'corner' },
        { id: 'rotate', ...rotate(shapeX, shapeY - halfHeight - this.rotationHandleDistance), type: 'rotation' }
    ];

    return positions;
}

Why screen space? Mouse coordinates are in screen space. To test if mouse is over a handle, handles need to be in screen space too.

Rotation handling: If the shape is rotated, rotate the handle positions. The rotate function rotates a point around the shape's center.

Testing Handle Hits

When the mouse moves, check if it's over a handle:

getHandleAtPoint(x, y, shape) {
    if (!shape) return null;

    const positions = this.getHandlePositions(shape);

    for (const pos of positions) {
        const dx = x - pos.x;
        const dy = y - pos.y;
        const dist = Math.sqrt(dx * dx + dy * dy);

        // Check if point is within handle radius
        if (dist <= this.handleRadius + 3) {  // +3 for easier clicking
            return {
                type: pos.type === 'rotation' ? 'rotate' : 'scale',
                handle: pos.id,
                position: pos
            };
        }
    }

    return null;
}

Hit testing: Calculate distance from mouse to each handle. If distance < radius, it's a hit. The +3 makes it easier to click (slightly larger hit area).

Return value: Returns handle info (type, id, position) so the interaction handler knows what to do.

Drawing Selection Handles

The main method that draws all handles for a selected shape:

drawSelectionHandles(shape, transformContext) {
    if (!shape || !shape.transform) return;

    const { transform } = shape;
    const screenX = this.coordinateSystem.transformX(transform.position[0]);
    const screenY = this.coordinateSystem.transformY(transform.position[1]);

    this.ctx.save();
    this.ctx.translate(screenX, screenY);
    this.ctx.rotate(-transform.rotation * Math.PI / 180);

    // Get bounds
    const bounds = this.renderer.transformManager.calculateBounds(shape);
    const scaledWidth = bounds.width * this.coordinateSystem.scale;
    const scaledHeight = bounds.height * this.coordinateSystem.scale;

    // Draw handles
    this.drawCornerHandles(shape, scaledWidth, scaledHeight);
    this.drawRotationHandle(shape, scaledHeight);

    this.ctx.restore();
}

The transform: Translate to shape position, rotate by shape rotation. Now handles are drawn in the shape's local space, but rotated correctly.

Why negative rotation? Canvas rotation is counter-clockwise, but we store rotation as clockwise (standard). So negate it.

Handle State Management

Track which handle is active/hovered:

setActiveHandle(handle) {
    this.activeHandle = handle;  // 'tl', 'tr', 'br', 'bl', 'rotate', or null
}

setHoveredHandle(handle) {
    this.hoveredHandle = handle;
}

clearHandleState() {
    this.activeHandle = null;
    this.hoveredHandle = null;
}

State tracking: The interaction handler sets these based on mouse events. The renderer uses them to draw handles with the right visual state.

Common Gotchas

Handle positions must account for rotation: If the shape is rotated, handles need to be rotated too. Calculate in screen space, not local space.

Hit testing needs screen coordinates: Mouse events give screen coordinates. Handles need to be in screen space for hit testing.

Handle size should scale with zoom: At high zoom, handles might be too small. Scale handle radius with zoom level.

Rotation handle distance: Make it far enough that it doesn't overlap with corner handles, but close enough that it's easy to reach.

Visual feedback: Hover and active states need to be obvious. Use color, size, and rings to make it clear.

The handle system provides the UI for shape manipulation. It doesn't know about shape parameters or how resizing works - that's the transform manager's job. The handle system just draws handles and reports which one was clicked.

Building the Selection System From Scratch

Selection is fundamental to any graphics editor. Users need to see which shape is selected, get visual feedback, and see shape information. The selection system handles all of this.

What Selection Is

Selection is just tracking which shape the user has selected:

this.selectedShape = shape;  // The shape object, or null
this.hoveredShape = shapeName;  // The shape name (for hover), or null

Why track both? Selected = clicked and active. Hovered = mouse is over it. Different visual feedback for each.

Building Selection Outline Drawing From Scratch

When a shape is selected, draw an outline to provide visual feedback. This outline should clearly indicate which shape is selected.

How to Build It Step by Step:

Step 1: Validate and Get Transform

drawSelectionOutline(shape, transformContext) {
    // Step 1.1: Validate shape has transform
    if (!shape || !shape.transform) return;

    // Step 1.2: Extract transform for easier access
    const { transform } = shape;

Why Validate: If the shape doesn't have a transform, we can't draw the outline correctly. Return early to avoid errors.

Step 2: Convert Position to Screen Coordinates

    // Step 2.1: Convert shape position to screen coordinates
    const screenX = this.coordinateSystem.transformX(transform.position[0]);
    const screenY = this.coordinateSystem.transformY(transform.position[1]);

Why Convert to Screen: The shape's position is in world coordinates. We need to convert it to screen coordinates (pixels) to draw on the canvas.

Step 3: Transform Canvas to Shape Position and Rotation

    // Step 3.1: Save canvas state
    this.ctx.save();

    // Step 3.2: Translate to shape position
    this.ctx.translate(screenX, screenY);

    // Step 3.3: Rotate to match shape rotation
    this.ctx.rotate(-transform.rotation * Math.PI / 180);

Why Transform Canvas: The shape might be rotated. By transforming the canvas (translate and rotate), we can draw the outline aligned with the shape. The negative rotation is because canvas rotation is clockwise, but our rotation values are typically counter-clockwise.

Step 4: Calculate Scaled Bounds

    // Step 4.1: Get shape bounds
    const bounds = this.renderer.transformManager.calculateBounds(shape);

    // Step 4.2: Scale bounds to screen space
    const scaledWidth = bounds.width * this.coordinateSystem.scale;
    const scaledHeight = bounds.height * this.coordinateSystem.scale;

Why Scale Bounds: Bounds are in world coordinates. We need to scale them by the coordinate system's scale to get screen-space dimensions.

Step 5: Draw Dashed Outline

    // Step 5.1: Set stroke style (semi-transparent selection color)
    this.ctx.strokeStyle = this.selectionColor + '40';
    this.ctx.lineWidth = 1;

    // Step 5.2: Set dash pattern
    this.ctx.setLineDash([4, 4]);

    // Step 5.3: Draw rectangle outline (centered at 0, 0)
    this.ctx.strokeRect(-scaledWidth / 2, -scaledHeight / 2, scaledWidth, scaledHeight);

    // Step 5.4: Reset dash pattern
    this.ctx.setLineDash([]);

    // Step 5.5: Restore canvas state
    this.ctx.restore();
}

Why Dashed Outline: A dashed rectangle around the shape's bounds. Dashed makes it clear it's a selection indicator, not part of the shape. The semi-transparent color ('40' = 25% opacity) ensures it's visible but not too prominent.

Why Centered at (0, 0): Since we translated the canvas to the shape's position, we draw the rectangle centered at (0, 0) in local space. The rectangle extends from -width/2 to +width/2 and -height/2 to +height/2.

The Complete Function:

drawSelectionOutline(shape, transformContext) {
    if (!shape || !shape.transform) return;

    const { transform } = shape;
    const screenX = this.coordinateSystem.transformX(transform.position[0]);
    const screenY = this.coordinateSystem.transformY(transform.position[1]);

    this.ctx.save();
    this.ctx.translate(screenX, screenY);
    this.ctx.rotate(-transform.rotation * Math.PI / 180);

    // Get bounds
    const bounds = this.renderer.transformManager.calculateBounds(shape);
    const scaledWidth = bounds.width * this.coordinateSystem.scale;
    const scaledHeight = bounds.height * this.coordinateSystem.scale;

    // Draw dashed outline
    this.ctx.strokeStyle = this.selectionColor + '40';
    this.ctx.lineWidth = 1;
    this.ctx.setLineDash([4, 4]);
    this.ctx.strokeRect(-scaledWidth / 2, -scaledHeight / 2, scaledWidth, scaledHeight);
    this.ctx.setLineDash([]);

    this.ctx.restore();
}

Building This Step by Step:

  1. Create drawSelectionOutline() method with shape and transform context
  2. Validate shape has transform, return early if not
  3. Extract transform from shape
  4. Convert shape position to screen coordinates
  5. Save canvas state
  6. Translate canvas to shape position
  7. Rotate canvas to match shape rotation
  8. Get shape bounds and scale to screen space
  9. Set stroke style (semi-transparent selection color)
  10. Set dash pattern for dashed line
  11. Draw rectangle outline centered at (0, 0)
  12. Reset dash pattern
  13. Restore canvas state

Building Hover Outline Drawing From Scratch

Hover outline is similar to selection outline, but with different styling to distinguish it. It shows which shape the mouse is hovering over.

How to Build It Step by Step:

Step 1: Build Similar to Selection Outline The hover outline uses the same structure as the selection outline, but with different visual styling:

drawHoverOutline(shape, transformContext) {
    // Step 1.1: Same validation and setup as selection outline
    if (!shape || !shape.transform) return;

    const { transform } = shape;
    const screenX = this.coordinateSystem.transformX(transform.position[0]);
    const screenY = this.coordinateSystem.transformY(transform.position[1]);

    this.ctx.save();
    this.ctx.translate(screenX, screenY);
    this.ctx.rotate(-transform.rotation * Math.PI / 180);

    const bounds = this.renderer.transformManager.calculateBounds(shape);
    const scaledWidth = bounds.width * this.coordinateSystem.scale;
    const scaledHeight = bounds.height * this.coordinateSystem.scale;

    // Step 1.2: Different styling for hover
    this.ctx.strokeStyle = this.hoverColor + '80';  // Different color, more opaque
    this.ctx.lineWidth = 2;  // Thicker
    this.ctx.setLineDash([2, 2]);  // Different dash pattern (shorter dashes)

    this.ctx.strokeRect(-scaledWidth / 2, -scaledHeight / 2, scaledWidth, scaledHeight);
    this.ctx.setLineDash([]);

    this.ctx.restore();
}

Hover vs Selection Styling:

  • Hover: Lighter/more transparent color ('80' = 50% opacity), thicker line (2px), shorter dashes ([2, 2])
  • Selection: More prominent color ('40' = 25% opacity), thinner line (1px), longer dashes ([4, 4])

Why Different Styling: Hover shows "this will be selected if you click", selection shows "this is selected". The hover outline should be noticeable but not as prominent as the selection outline. The thicker line and more opaque color make it clear which shape is being hovered, while the selection outline is more subtle since the shape is already selected.

The Complete Function:

drawHoverOutline(shape, transformContext) {
    if (!shape || !shape.transform) return;

    const { transform } = shape;
    const screenX = this.coordinateSystem.transformX(transform.position[0]);
    const screenY = this.coordinateSystem.transformY(transform.position[1]);

    this.ctx.save();
    this.ctx.translate(screenX, screenY);
    this.ctx.rotate(-transform.rotation * Math.PI / 180);

    const bounds = this.renderer.transformManager.calculateBounds(shape);
    const scaledWidth = bounds.width * this.coordinateSystem.scale;
    const scaledHeight = bounds.height * this.coordinateSystem.scale;

    // Different styling for hover
    this.ctx.strokeStyle = this.hoverColor + '80';  // Different color, more opaque
    this.ctx.lineWidth = 2;  // Thicker
    this.ctx.setLineDash([2, 2]);  // Different dash pattern

    this.ctx.strokeRect(-scaledWidth / 2, -scaledHeight / 2, scaledWidth, scaledHeight);
    this.ctx.setLineDash([]);

    this.ctx.restore();
}

Building This Step by Step:

  1. Create drawHoverOutline() method (same structure as selection outline)
  2. Use same validation and setup as selection outline
  3. Use different stroke style (hover color, more opaque)
  4. Use thicker line width (2px instead of 1px)
  5. Use different dash pattern (shorter dashes)
  6. Draw rectangle outline same way
  7. Restore canvas state

Building Operation Label Drawing From Scratch

For boolean operations (union, difference, intersection), show what operation created the shape. This helps users understand the shape's origin.

How to Build It Step by Step:

Step 1: Validate and Get Position

drawOperationLabel(shape, operation) {
    // Step 1.1: Check if labels should be shown
    // showOperationLabels is a setting that can be toggled
    // If operation is not provided, there's nothing to label
    if (!this.showOperationLabels || !operation) return;

    // Step 1.2: Get shape position in screen coordinates
    const screenX = this.coordinateSystem.transformX(shape.transform.position[0]);
    const screenY = this.coordinateSystem.transformY(shape.transform.position[1]);

Why Validate: Labels are optional - users might not want them cluttering the canvas. If labels are disabled or there's no operation to label, return early.

Step 2: Calculate Label Position

    // Step 2.1: Get shape bounds
    const bounds = this.renderer.transformManager.calculateBounds(shape);

    // Step 2.2: Calculate label Y position (above the shape)
    // Start at shape center Y, move up by half the shape height, then add 25px spacing
    const labelY = screenY - (bounds.height * this.coordinateSystem.scale) / 2 - 25;

Why Above Shape: Positioning the label above the shape keeps it visible and doesn't obscure the shape itself. The 25px spacing provides visual separation.

Step 3: Draw Label Background

    // Step 3.1: Draw label background (semi-transparent black)
    // This provides contrast so the text is readable over any background
    this.ctx.fillStyle = 'rgba(0, 0, 0, 0.8)';
    this.ctx.fillRect(screenX - 40, labelY - 10, 80, 20);

Why Background: The semi-transparent black background provides contrast so the white text is readable over any shape color. The rectangle is 80px wide and 20px tall, centered horizontally on the shape.

Step 4: Draw Label Border

    // Step 4.1: Draw label border (colored by operation type)
    // Different operations get different colors for visual distinction
    this.ctx.strokeStyle = this.getOperationColor(operation);
    this.ctx.lineWidth = 2;
    this.ctx.strokeRect(screenX - 40, labelY - 10, 80, 20);

Why Colored Border: Different operations get different colors (e.g., union = green, difference = red, intersection = blue). This provides quick visual identification of the operation type.

Step 5: Draw Label Text

    // Step 5.1: Set text style
    this.ctx.fillStyle = 'white';
    this.ctx.font = '12px monospace';
    this.ctx.textAlign = 'center';
    this.ctx.textBaseline = 'middle';

    // Step 5.2: Draw the operation name (uppercase)
    this.ctx.fillText(operation.toUpperCase(), screenX, labelY);
}

Why These Text Settings:

  • White fill for contrast against black background
  • Monospace font for consistent width
  • Center alignment so text is centered in the label
  • Middle baseline so text is vertically centered
  • Uppercase for consistency and readability

The Complete Function:

drawOperationLabel(shape, operation) {
    if (!this.showOperationLabels || !operation) return;

    const screenX = this.coordinateSystem.transformX(shape.transform.position[0]);
    const screenY = this.coordinateSystem.transformY(shape.transform.position[1]);

    const bounds = this.renderer.transformManager.calculateBounds(shape);
    const labelY = screenY - (bounds.height * this.coordinateSystem.scale) / 2 - 25;

    // Draw label background
    this.ctx.fillStyle = 'rgba(0, 0, 0, 0.8)';
    this.ctx.fillRect(screenX - 40, labelY - 10, 80, 20);

    // Draw label border
    this.ctx.strokeStyle = this.getOperationColor(operation);
    this.ctx.lineWidth = 2;
    this.ctx.strokeRect(screenX - 40, labelY - 10, 80, 20);

    // Draw label text
    this.ctx.fillStyle = 'white';
    this.ctx.font = '12px monospace';
    this.ctx.textAlign = 'center';
    this.ctx.textBaseline = 'middle';
    this.ctx.fillText(operation.toUpperCase(), screenX, labelY);
}

Building This Step by Step:

  1. Create drawOperationLabel() method with shape and operation
  2. Check if labels should be shown and operation exists
  3. Get shape position in screen coordinates
  4. Calculate label Y position (above shape with spacing)
  5. Draw semi-transparent black background rectangle
  6. Draw colored border rectangle (color based on operation)
  7. Set text style (white, monospace, centered)
  8. Draw operation name in uppercase
  9. This provides clear visual identification of boolean operations

Building Selection Info Panel From Scratch

Show shape information in a panel when a shape is selected. This provides detailed information about the shape's properties and state.

How to Build It Step by Step:

Step 1: Validate and Get Position

drawSelectionInfo(shape, shapeName) {
    // Step 1.1: Validate shape exists
    if (!shape) return;

    // Step 1.2: Get shape position in screen coordinates
    const screenX = this.coordinateSystem.transformX(shape.transform.position[0]);
    const screenY = this.coordinateSystem.transformY(shape.transform.position[1]);

    // Step 1.3: Get shape bounds for positioning
    const bounds = this.renderer.transformManager.calculateBounds(shape);

Why Validate: If there's no shape, there's nothing to show info for. Return early to prevent errors.

Step 2: Calculate Panel Position

    // Step 2.1: Position info panel to the right of the shape
    // Start at shape center X, move right by half the shape width, add 15px spacing
    const infoX = screenX + (bounds.width * this.coordinateSystem.scale) / 2 + 15;

    // Step 2.2: Position panel vertically centered on shape
    const infoY = screenY;

Why Right of Shape: Positioning the panel to the right keeps it visible and doesn't obscure the shape. The 15px spacing provides visual separation.

Step 3: Get Info Text and Calculate Panel Size

    // Step 3.1: Get info text lines
    // This method generates an array of text lines with shape information
    // (name, type, position, rotation, parameters, etc.)
    const info = this.getShapeInfoText(shape, shapeName);

    // Step 3.2: Calculate maximum line width
    // Measure each line's width and find the maximum
    // This ensures the panel is wide enough for all text
    const maxWidth = Math.max(...info.map(line => this.ctx.measureText(line).width));

    // Step 3.3: Calculate panel dimensions
    // Width: max text width + 16px padding (8px on each side)
    const panelWidth = maxWidth + 16;
    // Height: number of lines * line height (16px) + 8px padding (4px top/bottom)
    const panelHeight = info.length * 16 + 8;

Why Calculate Size Dynamically: Different shapes have different amounts of information. Calculating the panel size based on the actual text ensures it fits all information without being too large or too small.

Step 4: Draw Panel Background and Border

    // Step 4.1: Draw panel background (semi-transparent white)
    // White background provides good contrast for dark text
    // 0.95 opacity makes it slightly transparent so it doesn't completely obscure
    // shapes behind it, but opaque enough to be clearly readable
    this.ctx.fillStyle = 'rgba(255, 255, 255, 0.95)';
    this.ctx.fillRect(infoX, infoY - panelHeight/2, panelWidth, panelHeight);

    // Step 4.2: Draw panel border
    // Border uses selection color to visually connect the panel to the selected shape
    this.ctx.strokeStyle = this.selectionColor;
    this.ctx.lineWidth = 1;
    this.ctx.strokeRect(infoX, infoY - panelHeight/2, panelWidth, panelHeight);

Why Border: The border uses the selection color to visually connect the panel to the selected shape. This helps users understand that the panel is showing information about the selected shape.

Step 5: Draw Info Text

    // Step 5.1: Set text style
    // Dark text (#333) for good contrast against white background
    // Monospace font for consistent width and readability
    this.ctx.fillStyle = '#333';
    this.ctx.font = '11px monospace';
    this.ctx.textAlign = 'left';
    this.ctx.textBaseline = 'top';

    // Step 5.2: Draw each info line
    // Loop through info lines and draw each one with proper spacing
    // 8px left padding, 8px top padding, 16px line height
    for (let i = 0; i < info.length; i++) {
        this.ctx.fillText(info[i], infoX + 8, infoY - panelHeight/2 + 8 + i * 16);
    }
}

Why These Text Settings:

  • Dark text (#333) for good contrast against white background
  • Monospace font for consistent width and readability
  • Left-aligned text with padding for comfortable reading
  • 16px line height for clear separation between lines
  • 8px padding on all sides for visual breathing room

The Complete Function:

drawSelectionInfo(shape, shapeName) {
    if (!shape) return;

    const screenX = this.coordinateSystem.transformX(shape.transform.position[0]);
    const screenY = this.coordinateSystem.transformY(shape.transform.position[1]);
    const bounds = this.renderer.transformManager.calculateBounds(shape);

    // Position info panel to the right of the shape
    const infoX = screenX + (bounds.width * this.coordinateSystem.scale) / 2 + 15;
    const infoY = screenY;

    // Get info text
    const info = this.getShapeInfoText(shape, shapeName);
    const maxWidth = Math.max(...info.map(line => this.ctx.measureText(line).width));
    const panelWidth = maxWidth + 16;
    const panelHeight = info.length * 16 + 8;

    // Draw panel background
    this.ctx.fillStyle = 'rgba(255, 255, 255, 0.95)';
    this.ctx.fillRect(infoX, infoY - panelHeight/2, panelWidth, panelHeight);

    // Draw border
    this.ctx.strokeStyle = this.selectionColor;
    this.ctx.lineWidth = 1;
    this.ctx.strokeRect(infoX, infoY - panelHeight/2, panelWidth, panelHeight);

    // Draw text
    this.ctx.fillStyle = '#333';
    this.ctx.font = '11px monospace';
    this.ctx.textAlign = 'left';
    this.ctx.textBaseline = 'top';

    for (let i = 0; i < info.length; i++) {
        this.ctx.fillText(info[i], infoX + 8, infoY - panelHeight/2 + 8 + i * 16);
    }
}

Building This Step by Step:

  1. Create drawSelectionInfo() method with shape and shape name
  2. Validate shape exists, return early if not
  3. Get shape position in screen coordinates
  4. Calculate panel position (right of shape with spacing)
  5. Get info text lines from helper method
  6. Calculate panel size based on text content
  7. Draw semi-transparent white background rectangle
  8. Draw border rectangle with selection color
  9. Set text style (dark, monospace, left-aligned)
  10. Draw each info line with proper spacing
  11. This provides detailed shape information when selected

getShapeInfoText(shape, shapeName) { const info = [${shapeName} (${shape.type})];

if (shape.transform.position) {
    info.push(`pos: ${shape.transform.position[0].toFixed(1)}, ${shape.transform.position[1].toFixed(1)}`);
}

if (shape.transform.rotation) {
    info.push(`rot: ${shape.transform.rotation.toFixed(1)}°`);
}

const params = shape.params;
if (params.width && params.height) {
    info.push(`size: ${params.width.toFixed(1)} × ${params.height.toFixed(1)}`);
} else if (params.radius) {
    info.push(`radius: ${params.radius.toFixed(1)}`);
}

if (params.operation) {
    info.push(`op: ${params.operation}`);
}

return info;

}


**Info panel:** Shows shape name, position, rotation, size, operation. Useful for debugging and precise editing.

**Text measurement:** Use `ctx.measureText()` to calculate text width. Size the panel to fit the text.

### Multi-Selection

Support selecting multiple shapes:

```javascript
drawMultiSelectionOutline(shapes) {
    if (!shapes || shapes.length === 0) return;

    // Calculate bounding box of all selected shapes
    const bounds = this.calculateMultiSelectionBounds(shapes);

    // Draw outline around all shapes
    this.ctx.save();
    this.ctx.strokeStyle = this.selectionColor + '60';
    this.ctx.lineWidth = 2;
    this.ctx.setLineDash([8, 4]);
    this.ctx.strokeRect(bounds.x, bounds.y, bounds.width, bounds.height);
    this.ctx.setLineDash([]);

    // Draw semi-transparent fill
    this.ctx.fillStyle = this.selectionColor + '10';
    this.ctx.fillRect(bounds.x, bounds.y, bounds.width, bounds.height);

    // Draw selection count
    this.drawSelectionCount(shapes.length, bounds);

    this.ctx.restore();
}

calculateMultiSelectionBounds(shapes) {
    let minX = Infinity, maxX = -Infinity;
    let minY = Infinity, maxY = -Infinity;

    for (const shape of shapes) {
        const screenX = this.coordinateSystem.transformX(shape.transform.position[0]);
        const screenY = this.coordinateSystem.transformY(shape.transform.position[1]);
        const bounds = this.renderer.transformManager.calculateBounds(shape);
        const scaledWidth = bounds.width * this.coordinateSystem.scale;
        const scaledHeight = bounds.height * this.coordinateSystem.scale;

        minX = Math.min(minX, screenX - scaledWidth / 2);
        maxX = Math.max(maxX, screenX + scaledWidth / 2);
        minY = Math.min(minY, screenY - scaledHeight / 2);
        maxY = Math.max(maxY, screenY + scaledHeight / 2);
    }

    return {
        x: minX - 10,
        y: minY - 10,
        width: maxX - minX + 20,
        height: maxY - minY + 20
    };
}

Multi-selection bounds: Find the bounding box that contains all selected shapes. Draw an outline around that.

Selection count: Show how many shapes are selected. Useful when you have many shapes.

Selection State Management

Simple state tracking:

setSelectedShape(shape) {
    this.selectedShape = shape;
}

setHoveredShape(shapeName) {
    this.hoveredShape = shapeName;
}

clearSelection() {
    this.selectedShape = null;
    this.hoveredShape = null;
}

isShapeSelected(shape) {
    return this.selectedShape === shape;
}

isShapeHovered(shapeName) {
    return this.hoveredShape === shapeName;
}

State management: The interaction handler sets these based on mouse events. The renderer uses them to draw the right visual feedback.

Why shape object for selected, name for hovered? Selected needs the full object (for manipulation). Hovered just needs the name (for lookup). This is a design choice - you could use names for both.

Common Gotchas

Selection outline must account for rotation: If the shape is rotated, the outline must be rotated too. Transform the canvas before drawing.

Hover vs selection: Different visual styles. Hover = "will select", selection = "is selected". Make them distinct.

Info panel positioning: Position it so it doesn't overlap the shape. Usually to the right or above.

Multi-selection bounds: Calculate correctly. Include all shapes, account for rotation.

Performance: Drawing selection outlines on every frame can be expensive. Only draw when needed (shape is selected/hovered).

The selection system provides visual feedback for user interaction. It doesn't know about shape manipulation or parameters - that's other systems' jobs. The selection system just shows what's selected and provides visual cues.

How to Build Paths, Handles, and Selection Systems - Complete Step-by-Step Guide

This section provides a complete guide for building the path renderer, handle system, and selection system from scratch.

Part 1: Building the Path Renderer

Step 1.1: Create the Path Renderer Class

File: src/renderer/pathRenderer.mjs

What You're Building: A PathRenderer class that draws paths (sequences of connected points) on the canvas. Paths can be open (lines) or closed (polygons), and can have both fill and stroke styles. This class handles coordinate conversion and canvas drawing operations.

Why This Class: Paths are a fundamental drawing primitive. They allow users to create custom shapes by specifying points. The path renderer handles the complexity of converting world coordinates to screen coordinates, applying styles, and using the canvas API correctly.

How to Build It Step by Step:

Step 1.1.1: Create the PathRenderer Class and Constructor Start with the class definition:

export class PathRenderer {
  constructor(ctx) {
    // Step 1.1.1.1: Store canvas context
    // The context provides all drawing methods (beginPath, lineTo, stroke, etc.)
    this.ctx = ctx;
  }

Why Store Context: The canvas context is needed for all drawing operations. We store it as an instance property so all methods can access it.

Step 1.1.2: Implement drawPath() Method - Validation and Setup Start the method with validation and style setup:

  drawPath(shape, style, coordinateSystem) {
    // Step 1.1.2.1: Extract parameters from shape
    // Paths have a 'points' array and optional 'closed' flag
    const params = shape.params || {};
    const points = params.points || [];

    // Step 1.1.2.2: Validate minimum points
    // A path needs at least 2 points (start and end)
    if (points.length < 2) return false;

    // Step 1.1.2.3: Save canvas state
    // This allows us to restore canvas state after drawing
    // Important for nested drawing operations
    this.ctx.save();

Why Save Canvas State: Canvas operations modify global state (fillStyle, strokeStyle, etc.). By saving before and restoring after, we ensure our drawing doesn't affect other drawing operations.

Step 1.1.3: Apply Styles Set up fill and stroke styles:

    // Step 1.1.3.1: Apply stroke style
    // Stroke is the outline of the path
    if (style.stroke) {
      this.ctx.strokeStyle = style.strokeColor || '#000000';
      this.ctx.lineWidth = style.strokeWidth || 1;
    }

    // Step 1.1.3.2: Apply fill style
    // Fill is the interior of closed paths
    if (style.fill) {
      this.ctx.fillStyle = style.fillColor || '#000000';
    }

Why Check Style Flags: We only set styles if they're enabled. This avoids unnecessary style changes and allows paths to be stroke-only or fill-only.

Step 1.1.4: Draw the Path Convert points and draw the path:

    // Step 1.1.4.1: Start a new path
    // beginPath() starts a new path, clearing any previous path
    this.ctx.beginPath();

    // Step 1.1.4.2: Move to first point
    // moveTo() sets the starting point without drawing
    const firstPoint = coordinateSystem.worldToScreen(points[0][0], points[0][1]);
    this.ctx.moveTo(firstPoint.x, firstPoint.y);

    // Step 1.1.4.3: Draw lines to remaining points
    // lineTo() draws a line from current point to specified point
    for (let i = 1; i < points.length; i++) {
      const screen = coordinateSystem.worldToScreen(points[i][0], points[i][1]);
      this.ctx.lineTo(screen.x, screen.y);
    }

Why Convert Coordinates: Points are in world coordinates (millimeters), but canvas uses screen coordinates (pixels). We must convert each point using the coordinate system.

Step 1.1.5: Close Path and Apply Fill/Stroke Finish the path:

    // Step 1.1.5.1: Close path if needed
    // closePath() draws a line from last point back to first point
    // Default is closed (closed !== false means closed by default)
    if (params.closed !== false) {
      this.ctx.closePath();

      // Step 1.1.5.2: Fill closed paths
      // Fill only works on closed paths
      if (style.fill) {
        this.ctx.fill();
      }
    }

    // Step 1.1.5.3: Stroke the path
    // Stroke draws the outline (works for both open and closed paths)
    if (style.stroke) {
      this.ctx.stroke();
    }

    // Step 1.1.5.4: Restore canvas state
    // Restore the canvas state we saved earlier
    this.ctx.restore();
    return true;
  }
}

Why Close Path: Closed paths form polygons that can be filled. Open paths are just lines. The closed parameter controls this. We check closed !== false so the default is closed (undefined = closed).

The Complete Class:

export class PathRenderer {
  constructor(ctx) {
    this.ctx = ctx;
  }

  drawPath(shape, style, coordinateSystem) {
    const params = shape.params || {};
    const points = params.points || [];

    if (points.length < 2) return false;

    this.ctx.save();

    // Apply style
    if (style.stroke) {
      this.ctx.strokeStyle = style.strokeColor || '#000000';
      this.ctx.lineWidth = style.strokeWidth || 1;
    }

    if (style.fill) {
      this.ctx.fillStyle = style.fillColor || '#000000';
    }

    // Convert points to screen coordinates
    this.ctx.beginPath();
    const firstPoint = coordinateSystem.worldToScreen(points[0][0], points[0][1]);
    this.ctx.moveTo(firstPoint.x, firstPoint.y);

    for (let i = 1; i < points.length; i++) {
      const screen = coordinateSystem.worldToScreen(points[i][0], points[i][1]);
      this.ctx.lineTo(screen.x, screen.y);
    }

    // Close path if needed
    if (params.closed !== false) {
      this.ctx.closePath();
      if (style.fill) {
        this.ctx.fill();
      }
    }

    if (style.stroke) {
      this.ctx.stroke();
    }

    this.ctx.restore();
    return true;
  }
}

Building This Step by Step:

  1. Create new file src/renderer/pathRenderer.mjs
  2. Export PathRenderer class
  3. Create constructor that takes ctx parameter
  4. Store context as instance property
  5. Create drawPath() method with shape, style, coordinateSystem parameters
  6. Extract params and points from shape
  7. Validate minimum 2 points, return false if invalid
  8. Call ctx.save() to save canvas state
  9. Check if stroke is enabled, set strokeStyle and lineWidth
  10. Check if fill is enabled, set fillStyle
  11. Call ctx.beginPath() to start new path
  12. Convert first point to screen coordinates
  13. Call ctx.moveTo() with first point
  14. Loop through remaining points
  15. Convert each point to screen coordinates
  16. Call ctx.lineTo() for each point
  17. Check if path should be closed
  18. If closed, call ctx.closePath()
  19. If closed and fill enabled, call ctx.fill()
  20. If stroke enabled, call ctx.stroke()
  21. Call ctx.restore() to restore canvas state
  22. Return true on success
  23. This class provides path rendering with coordinate conversion

Step 1.2: Add Bezier Curve Support

What You're Building: A method that extends path rendering to support Bezier curves. Bezier curves allow smooth, curved paths instead of just straight lines. This method handles both cubic Bezier curves (with two control points) and quadratic Bezier curves (with one control point).

Why This Method: Straight lines are limiting. Bezier curves allow smooth, organic shapes. They're essential for professional graphics. This method adds curve support while maintaining compatibility with straight-line paths.

How to Build It Step by Step:

Step 1.2.1: Method Setup and Validation Start with parameter extraction and validation:

drawBezierPath(shape, style, coordinateSystem) {
  // Step 1.2.1.1: Extract parameters
  // Points are the anchor points (start/end of curves)
  // Control points define the curve shape
  const params = shape.params || {};
  const points = params.points || [];
  const controlPoints = params.controlPoints || [];

  // Step 1.2.1.2: Validate minimum points
  // Need at least 2 anchor points (start and end)
  if (points.length < 2) return false;

  // Step 1.2.1.3: Save canvas state
  this.ctx.save();

  // Step 1.2.1.4: Apply styles (assuming applyStyle method exists)
  this.applyStyle(style);

Why Control Points: Bezier curves use control points to define the curve shape. Each curve segment has anchor points (start/end) and control points (define curvature). More control points = more complex curves.

Step 1.2.2: Start Path and Move to First Point Initialize the path:

  // Step 1.2.2.1: Start new path
  this.ctx.beginPath();

  // Step 1.2.2.2: Move to first anchor point
  // This is the starting point of the path
  const firstPoint = coordinateSystem.worldToScreen(points[0][0], points[0][1]);
  this.ctx.moveTo(firstPoint.x, firstPoint.y);

Why Move to First Point: The first point is special - we use moveTo() instead of lineTo() because there's no previous point to draw from.

Step 1.2.3: Draw Curves or Lines Loop through points, drawing curves or lines:

  // Step 1.2.3.1: Loop through remaining anchor points
  // Each iteration draws a segment (curve or line) to the next anchor point
  for (let i = 1; i < points.length; i++) {
    // Step 1.2.3.2: Check if this segment has control points
    // Control points are indexed by previous anchor point (i - 1)
    if (controlPoints[i - 1]) {
      // Step 1.2.3.3: Bezier curve segment
      // Convert first control point to screen coordinates
      const cp1 = coordinateSystem.worldToScreen(
        controlPoints[i - 1][0], 
        controlPoints[i - 1][1]
      );

      // Step 1.2.3.4: Check for second control point
      // If present, it's a cubic Bezier (2 control points)
      // If absent, use cp1 as second control point (quadratic Bezier)
      const cp2 = controlPoints[i - 1][2] ? 
        coordinateSystem.worldToScreen(controlPoints[i - 1][2], controlPoints[i - 1][3]) :
        cp1;

      // Step 1.2.3.5: Convert end anchor point
      const end = coordinateSystem.worldToScreen(points[i][0], points[i][1]);

      // Step 1.2.3.6: Draw cubic Bezier curve
      // bezierCurveTo(cp1x, cp1y, cp2x, cp2y, endX, endY)
      // Draws curve from current point to end, using cp1 and cp2 to define curvature
      this.ctx.bezierCurveTo(cp1.x, cp1.y, cp2.x, cp2.y, end.x, end.y);
    } else {
      // Step 1.2.3.7: Straight line segment
      // No control points = straight line
      const end = coordinateSystem.worldToScreen(points[i][0], points[i][1]);
      this.ctx.lineTo(end.x, end.y);
    }
  }

Why Check Control Points: Not all segments need curves. If a segment has no control points, we draw a straight line. This allows mixing curves and lines in the same path.

Why Cubic Bezier: Cubic Bezier curves (with 2 control points) provide the most flexibility. Quadratic Bezier (1 control point) is a special case where both control points are the same.

Step 1.2.4: Close Path and Apply Styles Finish the path:

  // Step 1.2.4.1: Close path if needed
  if (params.closed) {
    this.ctx.closePath();
    // Step 1.2.4.2: Fill if enabled
    if (style.fill) this.ctx.fill();
  }

  // Step 1.2.4.3: Stroke the path
  if (style.stroke) this.ctx.stroke();

  // Step 1.2.4.4: Restore canvas state
  this.ctx.restore();
  return true;
}

Why This Order: We close the path first (if needed), then fill (only works on closed paths), then stroke (works on both open and closed paths).

The Complete Method:

drawBezierPath(shape, style, coordinateSystem) {
  const params = shape.params || {};
  const points = params.points || [];
  const controlPoints = params.controlPoints || [];

  if (points.length < 2) return false;

  this.ctx.save();
  this.applyStyle(style);

  this.ctx.beginPath();
  const firstPoint = coordinateSystem.worldToScreen(points[0][0], points[0][1]);
  this.ctx.moveTo(firstPoint.x, firstPoint.y);

  for (let i = 1; i < points.length; i++) {
    if (controlPoints[i - 1]) {
      // Bezier curve
      const cp1 = coordinateSystem.worldToScreen(
        controlPoints[i - 1][0], 
        controlPoints[i - 1][1]
      );
      const cp2 = controlPoints[i - 1][2] ? 
        coordinateSystem.worldToScreen(controlPoints[i - 1][2], controlPoints[i - 1][3]) :
        cp1;
      const end = coordinateSystem.worldToScreen(points[i][0], points[i][1]);

      this.ctx.bezierCurveTo(cp1.x, cp1.y, cp2.x, cp2.y, end.x, end.y);
    } else {
      // Straight line
      const end = coordinateSystem.worldToScreen(points[i][0], points[i][1]);
      this.ctx.lineTo(end.x, end.y);
    }
  }

  if (params.closed) {
    this.ctx.closePath();
    if (style.fill) this.ctx.fill();
  }

  if (style.stroke) this.ctx.stroke();

  this.ctx.restore();
  return true;
}

Building This Step by Step:

  1. Add drawBezierPath() method to PathRenderer class
  2. Extract params, points, and controlPoints from shape
  3. Validate minimum 2 points
  4. Save canvas state
  5. Apply styles
  6. Begin new path
  7. Convert first point to screen coordinates
  8. Move to first point
  9. Loop through remaining points
  10. Check if segment has control points
  11. If yes, convert control points to screen coordinates
  12. Check for second control point (cubic vs quadratic)
  13. Convert end anchor point to screen coordinates
  14. Call bezierCurveTo() with control points and end point
  15. If no control points, convert end point and call lineTo()
  16. After loop, check if path should be closed
  17. If closed, call closePath() and fill if enabled
  18. Stroke the path if enabled
  19. Restore canvas state
  20. Return true on success
  21. This method provides Bezier curve support for smooth paths

Part 2: Building the Handle System

Step 2.1: Create the Handle System Class

File: src/renderer/handleSystem.mjs

What You're Building: A HandleSystem class that manages interactive handles (control points) for shapes. Handles allow users to resize shapes (corner handles) and rotate shapes (rotation handle). This class generates handle positions, draws handles, and detects handle hits for interaction.

Why This Class: Handles provide visual feedback and interaction points for shape manipulation. Users need to see where they can interact (handles) and be able to click/drag them. This class centralizes all handle-related functionality.

How to Build It Step by Step:

Step 2.1.1: Create the HandleSystem Class and Constructor Start with the class definition:

export class HandleSystem {
  constructor(renderer) {
    // Step 2.1.1.1: Store renderer reference
    // We need renderer for context, coordinate system, and shape bounds
    this.renderer = renderer;

    // Step 2.1.1.2: Set handle radius
    // This is the visual size of handles in pixels
    // 5 pixels is a good size - visible but not too large
    this.handleRadius = 5;

    // Step 2.1.1.3: Initialize handles array
    // This stores all handles for the current selection
    this.handles = [];

    // Step 2.1.1.4: Track active handle
    // This is the handle currently being dragged (if any)
    this.activeHandle = null;
  }

Why These Properties:

  • renderer: Provides access to canvas context, coordinate system, and shape information
  • handleRadius: Controls handle size. Larger = easier to click, but more obtrusive
  • handles: Stores handle objects for the selected shape
  • activeHandle: Tracks which handle is being dragged for interaction feedback

Step 2.1.2: Implement getHandlesForShape() Method Generate handles for a shape:

  getHandlesForShape(shape) {
    // Step 2.1.2.1: Initialize handles array
    const handles = [];

    // Step 2.1.2.2: Get shape bounds
    // Bounds define the rectangle that contains the shape
    // We'll place handles at corners and edges
    const bounds = this.getShapeBounds(shape);

    // Step 2.1.2.3: Create corner handles
    // Corner handles allow resizing the shape
    // We create 4 handles, one at each corner
    handles.push({ 
      type: 'corner', 
      position: 'topLeft',
      x: bounds.x, 
      y: bounds.y 
    });
    handles.push({ 
      type: 'corner', 
      position: 'topRight',
      x: bounds.x + bounds.width, 
      y: bounds.y 
    });
    handles.push({ 
      type: 'corner', 
      position: 'bottomRight',
      x: bounds.x + bounds.width, 
      y: bounds.y + bounds.height 
    });
    handles.push({ 
      type: 'corner', 
      position: 'bottomLeft',
      x: bounds.x, 
      y: bounds.y + bounds.height 
    });

Why Corner Handles: Corner handles allow resizing shapes. By dragging corners, users can change width and height simultaneously. The position property ('topLeft', etc.) helps identify which corner is being dragged.

Step 2.1.3: Add Rotation Handle Add a rotation handle above the shape:

    // Step 2.1.3.1: Rotation handle (above shape)
    // Rotation handle is positioned above the shape's center
    // Users drag this to rotate the shape
    handles.push({
      type: 'rotation',
      x: bounds.x + bounds.width / 2,  // Center horizontally
      y: bounds.y - 30                 // 30 units above top edge
    });

    // Step 2.1.3.2: Return all handles
    return handles;
  }

Why Rotation Handle: Rotation requires a separate handle because it's a different operation than resizing. Placing it above the shape makes it visually distinct and easy to access.

Step 2.1.4: Implement drawHandles() Method Draw handles on the canvas:

  drawHandles(handles) {
    // Step 2.1.4.1: Get canvas context from renderer
    this.ctx = this.renderer.ctx;

    // Step 2.1.4.2: Save canvas state
    this.ctx.save();

    // Step 2.1.4.3: Draw each handle
    handles.forEach(handle => {
      // Step 2.1.4.4: Convert handle position to screen coordinates
      // Handles are in world coordinates, canvas needs screen coordinates
      const screen = this.renderer.coordinateSystem.worldToScreen(handle.x, handle.y);

      // Step 2.1.4.5: Draw handle circle
      // Handles are drawn as filled circles
      // Active handle could have different color (currently same)
      this.ctx.fillStyle = handle === this.activeHandle ? '#0066FF' : '#0066FF';
      this.ctx.beginPath();
      this.ctx.arc(screen.x, screen.y, this.handleRadius, 0, Math.PI * 2);
      this.ctx.fill();

      // Step 2.1.4.6: Draw outline
      // White outline makes handles visible on any background
      this.ctx.strokeStyle = '#FFFFFF';
      this.ctx.lineWidth = 2;
      this.ctx.stroke();
    });

    // Step 2.1.4.7: Restore canvas state
    this.ctx.restore();
  }

Why This Drawing Style: Handles need to be visible on any background. A filled circle with a white outline ensures visibility. The blue fill indicates interactivity.

Step 2.1.5: Implement getHandleAtPoint() Method Detect which handle (if any) is at a screen position:

  getHandleAtPoint(screenX, screenY) {
    // Step 2.1.5.1: Convert screen coordinates to world coordinates
    // Handles are stored in world coordinates, so we need to convert
    const worldPos = this.renderer.coordinateSystem.screenToWorld(screenX, screenY);

    // Step 2.1.5.2: Check each handle
    for (const handle of this.handles) {
      // Step 2.1.5.3: Calculate distance from click to handle center
      // Use Euclidean distance formula
      const distance = Math.sqrt(
        Math.pow(worldPos.x - handle.x, 2) + 
        Math.pow(worldPos.y - handle.y, 2)
      );

      // Step 2.1.5.4: Check if click is within handle radius
      // We use radius * 2 for hit testing (larger than visual radius)
      // This makes handles easier to click
      if (distance < this.handleRadius * 2) {
        return handle;
      }
    }

    // Step 2.1.5.5: No handle found
    return null;
  }
}

Why Distance Check: We calculate the distance from the click point to each handle center. If the distance is less than the handle radius (multiplied by 2 for easier clicking), the click hit that handle.

The Complete Class:

export class HandleSystem {
  constructor(renderer) {
    this.renderer = renderer;
    this.handleRadius = 5;
    this.handles = [];
    this.activeHandle = null;
  }

  getHandlesForShape(shape) {
    const handles = [];
    const bounds = this.getShapeBounds(shape);

    // Corner handles
    handles.push({ 
      type: 'corner', 
      position: 'topLeft',
      x: bounds.x, 
      y: bounds.y 
    });
    handles.push({ 
      type: 'corner', 
      position: 'topRight',
      x: bounds.x + bounds.width, 
      y: bounds.y 
    });
    handles.push({ 
      type: 'corner', 
      position: 'bottomRight',
      x: bounds.x + bounds.width, 
      y: bounds.y + bounds.height 
    });
    handles.push({ 
      type: 'corner', 
      position: 'bottomLeft',
      x: bounds.x, 
      y: bounds.y + bounds.height 
    });

    // Rotation handle (above shape)
    handles.push({
      type: 'rotation',
      x: bounds.x + bounds.width / 2,
      y: bounds.y - 30
    });

    return handles;
  }

  drawHandles(handles) {
    this.ctx = this.renderer.ctx;
    this.ctx.save();

    handles.forEach(handle => {
      const screen = this.renderer.coordinateSystem.worldToScreen(handle.x, handle.y);

      // Draw handle circle
      this.ctx.fillStyle = handle === this.activeHandle ? '#0066FF' : '#0066FF';
      this.ctx.beginPath();
      this.ctx.arc(screen.x, screen.y, this.handleRadius, 0, Math.PI * 2);
      this.ctx.fill();

      // Draw outline
      this.ctx.strokeStyle = '#FFFFFF';
      this.ctx.lineWidth = 2;
      this.ctx.stroke();
    });

    this.ctx.restore();
  }

  getHandleAtPoint(screenX, screenY) {
    const worldPos = this.renderer.coordinateSystem.screenToWorld(screenX, screenY);

    for (const handle of this.handles) {
      const distance = Math.sqrt(
        Math.pow(worldPos.x - handle.x, 2) + 
        Math.pow(worldPos.y - handle.y, 2)
      );

      if (distance < this.handleRadius * 2) {
        return handle;
      }
    }

    return null;
  }
}

Building This Step by Step:

  1. Create new file src/renderer/handleSystem.mjs
  2. Export HandleSystem class
  3. Create constructor that takes renderer parameter
  4. Store renderer as instance property
  5. Set handleRadius to 5 pixels
  6. Initialize handles array
  7. Initialize activeHandle to null
  8. Create getHandlesForShape() method
  9. Initialize handles array
  10. Get shape bounds
  11. Create top-left corner handle
  12. Create top-right corner handle
  13. Create bottom-right corner handle
  14. Create bottom-left corner handle
  15. Create rotation handle above shape center
  16. Return handles array
  17. Create drawHandles() method
  18. Get context from renderer
  19. Save canvas state
  20. Loop through handles
  21. Convert each handle to screen coordinates
  22. Set fill style
  23. Draw handle circle using arc()
  24. Set stroke style and line width
  25. Stroke handle outline
  26. Restore canvas state
  27. Create getHandleAtPoint() method
  28. Convert screen coordinates to world coordinates
  29. Loop through handles
  30. Calculate distance from point to handle
  31. Check if distance is within handle radius * 2
  32. Return handle if hit, null if no hit
  33. This class provides interactive handles for shape manipulation

Part 3: Building the Selection System

Step 3.1: Create the Selection System Class

File: src/renderer/selectionSystem.mjs

What You're Building: A SelectionSystem class that manages shape selection and hover states. This class tracks which shape is selected (clicked) and which shape is hovered (mouse over), and provides methods to draw selection outlines and manage selection state.

Why This Class: Users need visual feedback to know which shape they're interacting with. Selection (click) and hover (mouse over) are fundamental interactions. This class centralizes selection management and provides visual feedback through outlines.

How to Build It Step by Step:

Step 3.1.1: Create the SelectionSystem Class and Constructor Start with the class definition:

export class SelectionSystem {
  constructor(renderer) {
    // Step 3.1.1.1: Store renderer reference
    // We need renderer for context, coordinate system, and redraw
    this.renderer = renderer;

    // Step 3.1.1.2: Track selected shape
    // This is the shape currently selected (clicked)
    this.selectedShape = null;

    // Step 3.1.1.3: Track hovered shape
    // This is the shape currently under the mouse cursor
    this.hoveredShape = null;
  }

Why These Properties:

  • renderer: Provides access to canvas context, coordinate system, and redraw functionality
  • selectedShape: The shape the user has clicked/selected. Only one shape can be selected at a time.
  • hoveredShape: The shape currently under the mouse cursor. Provides hover feedback.

Step 3.1.2: Implement setSelected() Method Set the selected shape:

  setSelected(shape) {
    // Step 3.1.2.1: Update selected shape
    // This replaces any previously selected shape
    this.selectedShape = shape;

    // Step 3.1.2.2: Trigger redraw
    // Selection change needs visual update (draw selection outline)
    this.renderer.redraw();
  }

Why Redraw: When selection changes, we need to redraw the canvas to show the new selection outline. The outline is drawn during the redraw process.

Step 3.1.3: Implement setHovered() Method Set the hovered shape:

  setHovered(shape) {
    // Step 3.1.3.1: Update hovered shape
    // This changes as the mouse moves
    this.hoveredShape = shape;

    // Step 3.1.3.2: Trigger redraw
    // Hover change needs visual update (draw hover outline)
    this.renderer.redraw();
  }

Why Separate Hover: Hover provides immediate feedback as the mouse moves, before clicking. It's separate from selection so users can see what they're about to select.

Step 3.1.4: Implement clearSelection() Method Clear both selection and hover:

  clearSelection() {
    // Step 3.1.4.1: Clear selected shape
    this.selectedShape = null;

    // Step 3.1.4.2: Clear hovered shape
    this.hoveredShape = null;
  }

Why This Method: Provides a clean way to deselect everything. Useful when clicking on empty space or pressing Escape.

Step 3.1.5: Implement drawSelectionOutline() Method Draw the selection outline around a shape:

  drawSelectionOutline(shape) {
    // Step 3.1.5.1: Validate shape exists
    if (!shape) return;

    // Step 3.1.5.2: Get shape bounds
    // Bounds define the rectangle that contains the shape
    const bounds = this.getShapeBounds(shape);
    const ctx = this.renderer.ctx;

    // Step 3.1.5.3: Save canvas state
    ctx.save();

    // Step 3.1.5.4: Set selection style
    // Blue dashed outline indicates selection
    ctx.strokeStyle = '#0066FF';
    ctx.lineWidth = 2;
    ctx.setLineDash([5, 5]);  // Dashed line pattern

    // Step 3.1.5.5: Convert bounds to screen coordinates
    // Bounds are in world coordinates, canvas needs screen coordinates
    const topLeft = this.renderer.coordinateSystem.worldToScreen(bounds.x, bounds.y);
    const bottomRight = this.renderer.coordinateSystem.worldToScreen(
      bounds.x + bounds.width, 
      bounds.y + bounds.height
    );

    // Step 3.1.5.6: Draw selection rectangle
    // Draw a rectangle around the shape's bounds
    ctx.strokeRect(
      topLeft.x,
      topLeft.y,
      bottomRight.x - topLeft.x,  // Width
      bottomRight.y - topLeft.y   // Height
    );

    // Step 3.1.5.7: Reset line dash
    // Important: reset dash pattern so it doesn't affect other drawings
    ctx.setLineDash([]);

    // Step 3.1.5.8: Restore canvas state
    ctx.restore();
  }

Why Dashed Outline: A dashed outline is a standard visual convention for selection. It's clearly visible but doesn't obscure the shape. The blue color (#0066FF) is a common selection color.

Why Reset Line Dash: The line dash pattern persists after strokeRect(). We reset it to [] so it doesn't affect subsequent drawings.

The Complete Class:

export class SelectionSystem {
  constructor(renderer) {
    this.renderer = renderer;
    this.selectedShape = null;
    this.hoveredShape = null;
  }

  setSelected(shape) {
    this.selectedShape = shape;
    this.renderer.redraw();
  }

  setHovered(shape) {
    this.hoveredShape = shape;
    this.renderer.redraw();
  }

  clearSelection() {
    this.selectedShape = null;
    this.hoveredShape = null;
  }

  drawSelectionOutline(shape) {
    if (!shape) return;

    const bounds = this.getShapeBounds(shape);
    const ctx = this.renderer.ctx;

    ctx.save();

    // Draw selection rectangle
    ctx.strokeStyle = '#0066FF';
    ctx.lineWidth = 2;
    ctx.setLineDash([5, 5]);

    const topLeft = this.renderer.coordinateSystem.worldToScreen(bounds.x, bounds.y);
    const bottomRight = this.renderer.coordinateSystem.worldToScreen(
      bounds.x + bounds.width, 
      bounds.y + bounds.height
    );

    ctx.strokeRect(
      topLeft.x,
      topLeft.y,
      bottomRight.x - topLeft.x,
      bottomRight.y - topLeft.y
    );

    ctx.setLineDash([]);
    ctx.restore();
  }

  drawHoverOutline(shape) {
    if (!shape) return;

    // Similar to selection outline but different color/style
    const bounds = this.getShapeBounds(shape);
    const ctx = this.renderer.ctx;

    ctx.save();
    ctx.strokeStyle = '#00AAFF';
    ctx.lineWidth = 1;
    ctx.setLineDash([3, 3]);

    // Draw outline...

    ctx.setLineDash([]);
    ctx.restore();
  }

  getShapeBounds(shape) {
    // Calculate bounding box for shape
    // Account for transforms
    const transform = shape.transform || {
      position: [0, 0],
      rotation: 0,
      scale: [1, 1]
    };

    // Get shape points
    const shapeClass = this.renderer.getShapeClass(shape);
    const points = shapeClass.getPoints();

    // Transform points and find bounds
    let minX = Infinity, maxX = -Infinity;
    let minY = Infinity, maxY = -Infinity;

    points.forEach(point => {
      const transformed = this.renderer.transformManager.applyTransform(point, transform);
      minX = Math.min(minX, transformed.x);
      maxX = Math.max(maxX, transformed.x);
      minY = Math.min(minY, transformed.y);
      maxY = Math.max(maxY, transformed.y);
    });

    return {
      x: minX,
      y: minY,
      width: maxX - minX,
      height: maxY - minY
    };
  }
}

Common Issues and Fixes

Issue: Paths don't render

  • Check points array has at least 2 points
  • Verify coordinate conversion (world → screen)
  • Check path is closed correctly

Issue: Handles don't appear

  • Check handles are generated for shape
  • Verify coordinate conversion
  • Check handle radius is visible

Issue: Selection outline wrong size

  • Check bounds calculation includes all points
  • Verify transforms are applied
  • Check coordinate conversion

Issue: Hit testing doesn't work

  • Check coordinate conversion (screen → world)
  • Verify distance calculation
  • Check handle radius is correct

results matching ""

    No results matching ""