Building the Shapes Library and Transform Manager From Scratch

So you want to understand how shapes are actually created. The shapes library (Shapes.mjs) is where all the geometric primitives live. It's not used directly by the interpreter - instead, the interpreter creates shape objects, and the renderer uses the shapes library to get the actual geometry.

What the Shapes Library Does

The shapes library provides classes for different geometric shapes. Each shape class has a getPoints() method that returns an array of {x, y} points that define the shape's outline.

Why points? The renderer needs to draw shapes. Some shapes (like circles) can be drawn with canvas primitives (ctx.arc()), but complex shapes (like stars, gears, joints) need to be drawn as paths. The shapes library converts everything to points, which can then be drawn as paths.

Building the Base Shape Class From Scratch

All shapes inherit from a base Shape class. This class provides the foundation for position, rotation, scale, and point transformation. Here's how to build it step by step.

How to Build It Step by Step:

Step 1: Create the Base Class Structure Start with a class that stores transform properties:

class Shape {
    constructor() {
        // Step 1.1: Initialize position (where the shape is in world space)
        // Position is in world coordinates (millimeters)
        // Start at (0, 0) - the origin
        this.position = { x: 0, y: 0 };

        // Step 1.2: Initialize rotation (angle in degrees)
        // Rotation is in degrees, 0 means no rotation
        // Positive values rotate counter-clockwise
        this.rotation = 0;

        // Step 1.3: Initialize scale (size multiplier)
        // Scale of { x: 1, y: 1 } means normal size
        // { x: 2, y: 2 } means twice as large
        // { x: 0.5, y: 0.5 } means half size
        this.scale = { x: 1, y: 1 };
    }
}

Why These Properties:

  • position: Where the shape is located in world space
  • rotation: How much the shape is rotated (in degrees)
  • scale: How much the shape is scaled (1.0 = normal, 2.0 = double size, etc.)

If you see an error at this step:

Error: SyntaxError: Unexpected token or SyntaxError: Unexpected identifier

  • What this means: Syntax error in the class definition
  • Common causes:
    1. Missing class keyword: Shape { instead of class Shape {
    2. Missing curly braces: class Shape constructor() instead of class Shape { constructor() { } }
    3. Wrong constructor syntax: constructor instead of constructor()
  • Fix: Check your syntax matches exactly: class Shape { constructor() { ... } }

Error: ReferenceError: Cannot access 'Shape' before initialization

  • What this means: Trying to use Shape before it's defined, or circular dependency
  • Common causes:
    1. Using Shape before the class is defined
    2. Export statement before class definition: export class Shape is fine, but using it elsewhere before it loads
  • Fix: Make sure class is defined before you try to extend it or use it

Error: TypeError: Cannot set property 'position' of undefined

  • What this means: this is undefined when trying to set properties
  • Common causes:
    1. Forgot this. keyword: position = { x: 0, y: 0 } instead of this.position = { x: 0, y: 0 }
    2. Constructor not actually a method: missing constructor() or wrong syntax
    3. Using arrow function: constructor = () => { } instead of constructor() { }
  • Fix: Always use this.position not position, and make sure constructor is a proper method

Error: SyntaxError: Unexpected token '{' when defining object

  • What this means: Syntax error in object literal
  • Common causes:
    1. Using = instead of :: { x = 0, y = 0 } instead of { x: 0, y: 0 }
    2. Missing comma: { x: 0 y: 0 } instead of { x: 0, y: 0 }
    3. Trailing comma issues (in some contexts)
  • Fix: Use colons for object properties, commas between properties: { x: 0, y: 0 }

Step 2: Build the transformPoint() Method This method transforms a point from local space to world space. It applies scale, rotation, and translation in that order:

transformPoint(point) {
    // Step 2.1: Apply scale first
    // Scale multiplies the point coordinates
    // This makes the shape bigger or smaller
    const scaledX = point.x * this.scale.x;
    const scaledY = point.y * this.scale.y;

    // Step 2.2: Apply rotation
    // Rotation rotates the point around the origin (0, 0)
    // First convert degrees to radians (JavaScript Math functions use radians)
    //
    // DETAILED EXPLANATION OF ROTATION APPLICATION:
    // Rotation transforms point coordinates by rotating them around the origin using trigonometry.
    // This happens after scaling, so the shape rotates in its scaled size.
    //
    // Step 2.2.1: Convert degrees to radians
    // - JavaScript Math.cos() and Math.sin() require angles in radians
    // - Radians are the "natural" unit for trigonometry (based on π)
    // - Conversion formula: radians = degrees × (π / 180)
    // - Example: 90° = 90 × (π / 180) = π/2 ≈ 1.5708 radians
    // - Example: 45° = 45 × (π / 180) = π/4 ≈ 0.7854 radians
    // - Example: 180° = 180 × (π / 180) = π ≈ 3.1416 radians
    //
    // Why convert to radians?
    // - Math.cos() and Math.sin() expect radians, not degrees
    // - Radians are standard in mathematics and programming
    // - Conversion is necessary because we store rotation in degrees (user-friendly)
    //
    // Math.PI constant:
    // - Math.PI is JavaScript's built-in constant for π (pi) ≈ 3.141592653589793
    // - It's capitalized because it's a constant (convention in JavaScript)
    // - Using Math.PI is more accurate than typing 3.14159 manually
    //
    const rad = (this.rotation * Math.PI) / 180;

    // Step 2.2.2: Calculate cosine and sine once (for efficiency)
    // - Math.cos(angle) returns the cosine of the angle (adjacent/hypotenuse)
    // - Math.sin(angle) returns the sine of the angle (opposite/hypotenuse)
    // - We calculate these once and reuse them (more efficient than calculating twice)
    //
    // What are cosine and sine?
    // - They're trigonometric functions that describe the relationship between angles and
    //   coordinates on a circle
    // - For a point on a unit circle at angle θ:
    //   → x = cos(θ)
    //   → y = sin(θ)
    // - Cosine tells us the X coordinate
    // - Sine tells us the Y coordinate
    //
    // Common values (for reference):
    // - cos(0°) = 1, sin(0°) = 0 → point at (1, 0) = right side of circle
    // - cos(90°) = 0, sin(90°) = 1 → point at (0, 1) = top of circle
    // - cos(180°) = -1, sin(180°) = 0 → point at (-1, 0) = left side of circle
    // - cos(270°) = 0, sin(270°) = -1 → point at (0, -1) = bottom of circle
    // - cos(45°) = sin(45°) ≈ 0.7071 → point at (0.707, 0.707) = diagonal
    //
    // Why calculate once?
    // - These values are used twice in the rotation formula (for both x' and y')
    // - Calculating once and storing in variables is more efficient
    // - Also makes the code more readable (clearer what we're doing)
    //
    const cos = Math.cos(rad);
    const sin = Math.sin(rad);

    // Step 2.2.3: Apply rotation matrix
    // The rotation matrix is a mathematical formula that rotates a point around the origin.
    // 
    // Rotation matrix formula:
    // [x']   [cos(θ)  -sin(θ)] [x]
    // [y'] = [sin(θ)   cos(θ)] [y]
    //
    // Expanded:
    // x' = x*cos(θ) - y*sin(θ)
    // y' = x*sin(θ) + y*cos(θ)
    //
    // This formula rotates point (x, y) by angle θ around the origin (0, 0).
    //
    // Why this formula works:
    // - If you have a point at angle α from origin, and rotate by θ:
    //   → New angle = α + θ
    // - Using trigonometry:
    //   → Original: x = r*cos(α), y = r*sin(α)
    //   → Rotated: x' = r*cos(α+θ), y' = r*sin(α+θ)
    // - Using angle addition formulas:
    //   → cos(α+θ) = cos(α)cos(θ) - sin(α)sin(θ)
    //   → sin(α+θ) = sin(α)cos(θ) + cos(α)sin(θ)
    // - Substituting x and y:
    //   → x' = x*cos(θ) - y*sin(θ)
    //   → y' = x*sin(θ) + y*cos(θ)
    //
    // Example walkthrough:
    // Starting point (after scale): scaledX = 10, scaledY = 0 (point at right side)
    // Rotation: 90° counter-clockwise
    // rad = 90 × (π/180) = π/2 ≈ 1.5708
    // cos(90°) = 0
    // sin(90°) = 1
    // x' = 10*0 - 0*1 = 0
    // y' = 10*1 + 0*0 = 10
    // Result: (0, 10) = point moved to top (rotated 90° counter-clockwise) ✓
    //
    // Why use scaledX and scaledY?
    // - These are the points after scaling has been applied
    // - Rotation happens after scaling in the transform chain
    // - This ensures the shape rotates in its scaled size
    //
    // Rotation direction:
    // - Positive rotation = counter-clockwise (standard in mathematics)
    // - Negative rotation = clockwise (multiply by -1)
    // - Example: rotation = -90° rotates clockwise
    //
    // Why rotate around origin?
    // - Shapes are centered at (0, 0) in local space
    // - Rotating around origin rotates the shape around its center
    // - This is the expected behavior (shape spins in place, not orbits)
    //
    const rotatedX = scaledX * cos - scaledY * sin;
    const rotatedY = scaledX * sin + scaledY * cos;

    // Step 2.3: Apply translation (move to position)
    // Translation adds the position offset
    // This moves the shape to its final location
    //
    // DETAILED EXPLANATION OF TRANSLATION APPLICATION:
    // Translation is the final step - it moves the rotated point to its final world position.
    // This happens after scaling and rotation, so the entire transformed shape is moved.
    //
    // Translation operation:
    // - Simply add the position offset to the rotated coordinates
    // - x' = rotatedX + position.x
    // - y' = rotatedY + position.y
    // - This moves the point from its rotated position to the final world position
    //
    // Why apply translation last?
    // - Translation moves the shape in world space (after all local transforms)
    // - If we translated first, then rotated, rotation would happen around origin, not shape center
    // - By translating last, we move the already-scaled-and-rotated shape to its position
    // - This is the standard order: Scale → Rotate → Translate
    //
    // What translation does:
    // - Moves the shape from local space (centered at 0,0) to world space (at position)
    // - All points move by the same amount (preserves shape, just changes location)
    // - Translation doesn't change size or orientation, just location
    //
    // Example walkthrough:
    // After rotation: rotatedX = 0, rotatedY = 10 (point at top)
    // Position: { x: 100, y: 50 }
    // Final: x = 0 + 100 = 100, y = 10 + 50 = 60
    // Result: Point moved to (100, 60) in world coordinates
    //
    // Complete transform example:
    // Starting point (local): { x: 10, y: 0 }
    // Transform: position { x: 100, y: 50 }, rotation 90°, scale { x: 2, y: 2 }
    //
    // Step 1 - Scale:
    //   scaledX = 10 * 2 = 20
    //   scaledY = 0 * 2 = 0
    //
    // Step 2 - Rotate:
    //   rad = 90 × (π/180) = π/2
    //   cos = 0, sin = 1
    //   rotatedX = 20*0 - 0*1 = 0
    //   rotatedY = 20*1 + 0*0 = 20
    //
    // Step 3 - Translate:
    //   finalX = 0 + 100 = 100
    //   finalY = 20 + 50 = 70
    //
    // Final result: { x: 100, y: 70 }
    // The point that started at (10, 0) in local space is now at (100, 70) in world space
    //
    // Why return an object with x and y?
    // - Matches the input format (point is object with x and y)
    // - Consistent interface: input and output are both { x, y } objects
    // - Easy to use: result.x and result.y are clear
    //
    // Coordinate system:
    // - Input point is in local space (relative to shape center at 0,0)
    // - Output point is in world space (absolute position in the world)
    // - World space is where all shapes are positioned relative to each other
    //
    return {
        x: rotatedX + this.position.x,
        y: rotatedY + this.position.y
    };
}

Why This Transform Order: The transform order is critical: Scale → Rotate → Translate.

  • If you translate first, then rotate, the shape rotates around the origin (0, 0), not its center. This would make shapes orbit the origin when rotated.
  • By scaling and rotating first (around the shape's local origin at 0, 0), then translating, the shape rotates around its own center. This is the expected behavior.

DETAILED EXPLANATION OF TRANSFORM ORDER - Step-by-Step Breakdown:

The order in which you apply transformations is absolutely critical. Changing the order produces completely different results. This is one of the most important concepts in 2D graphics programming.

Understanding Why Order Matters:

Transformations are not commutative - meaning the order changes the result. In mathematics, commutative operations can be done in any order (e.g., 2 + 3 = 3 + 2). But transformations are not commutative: Scale then Rotate produces a different result than Rotate then Scale.

Visual Example of Order Importance:

Let's trace through what happens to a point at (10, 0) with different transform orders:

Order 1: Scale → Rotate → Translate (CORRECT - What We Use)

Starting point: (10, 0) - a point 10 units to the right of the shape's center

Step 1: Scale by 2x

  • Point (10, 0) becomes (20, 0)
  • The point is now 20 units from center (doubled in size)
  • Shape is twice as large, still centered at origin

Step 2: Rotate 90° counter-clockwise

  • Point (20, 0) rotates around origin (0, 0)
  • Using rotation matrix:
    • x' = 20 cos(90°) - 0 sin(90°) = 20 0 - 0 1 = 0
    • y' = 20 sin(90°) + 0 cos(90°) = 20 1 + 0 0 = 20
  • Point becomes (0, 20) - now 20 units above center
  • Shape is rotated 90°, still centered at origin

Step 3: Translate to position (100, 50)

  • Point (0, 20) moves to (0 + 100, 20 + 50) = (100, 70)
  • The entire rotated and scaled shape moves to its final position
  • Shape center is at (100, 50), point is at (100, 70) - 20 units above center

Result: Shape appears at position (100, 50), rotated 90°, twice as large, and rotates around its own center. ✓ CORRECT!

Order 2: Translate → Rotate → Scale (WRONG - What Happens If Order is Wrong)

Starting point: (10, 0) - same point

Step 1: Translate to position (100, 50)

  • Point (10, 0) moves to (10 + 100, 0 + 50) = (110, 50)
  • Shape center moves to (100, 50), point is 10 units to the right

Step 2: Rotate 90° counter-clockwise

  • Point (110, 50) rotates around origin (0, 0), NOT around shape center (100, 50)!
  • This is the problem - rotation happens around the wrong point!
  • Using rotation around origin:
    • x' = 110 cos(90°) - 50 sin(90°) = 110 0 - 50 1 = -50
    • y' = 110 sin(90°) + 50 cos(90°) = 110 1 + 50 0 = 110
  • Point becomes (-50, 110)
  • The entire shape has rotated around the origin, not its center!

Step 3: Scale by 2x

  • Point (-50, 110) becomes (-100, 220)
  • Shape is now at completely wrong position, wrong rotation point

Result: Shape is in the wrong place, rotated around the origin instead of its center. ✗ WRONG!

Why the Correct Order Works:

The key insight is that we want shapes to rotate around their own center, not around the world origin. By applying Scale and Rotate first (when the shape is still centered at origin), we ensure all transformations happen around the shape's center. Then Translation moves the already-transformed shape to its final position.

Mathematical Explanation:

In matrix math, transformations are represented as matrices, and the order is determined by matrix multiplication:

Correct Order (Scale → Rotate → Translate):

Final = T × R × S × Point

Where:

  • S = Scale matrix (scales around origin)
  • R = Rotation matrix (rotates around origin)
  • T = Translation matrix (moves to final position)

When we multiply matrices in this order: T × R × S, we first apply S (scale), then R (rotate around origin), then T (translate). This means rotation happens around the origin before translation.

Why This Is Correct:

Shapes are defined in "local space" where they're centered at (0, 0). When we scale and rotate in local space, we transform the shape around its own center (the origin). Then translation moves the entire transformed shape to its world position. This is exactly what we want - shapes rotate around themselves, not around the world origin.

Incorrect Order (Translate → Rotate → Scale):

Final = S × R × T × Point

This applies translation first, moving the shape away from origin, then rotates around origin (wrong!), then scales. The shape ends up rotating around the world origin, not its own center.

Real-World Analogy:

Think of a toy car on a table:

Correct Order (Scale → Rotate → Translate):

  1. Scale: Make the car bigger while it's on the table (still at origin)
  2. Rotate: Spin the car around its center while it's on the table
  3. Translate: Move the car to a different location on the table

Result: The car spins in place, then moves to the new location. ✓

Incorrect Order (Translate → Rotate → Scale):

  1. Translate: Move the car to the other side of the table
  2. Rotate: Try to spin it, but the rotation happens around the table's center (not the car's center)
  3. Scale: Make it bigger at its wrong position

Result: The car spins around the table's center, orbiting it like a satellite. ✗

Why We Start With Scale:

Scaling first ensures the shape is sized correctly before any other transformations. If we rotated first, then scaled, the rotation would apply to the original size, then scaling would affect the rotated shape differently. By scaling first, we establish the final size, then rotate and translate the already-sized shape.

Why Rotation Comes Before Translation:

Rotation must happen before translation because we want to rotate around the shape's local center (0, 0). If we translate first, the shape moves away from the origin, and rotation would happen around the origin (not the shape center). By rotating first (while shape is at origin), then translating, we ensure rotation happens around the shape's center.

Complete Mathematical Proof:

For a point P in local space, with transform T = {position, rotation, scale}:

Correct Order: Scale → Rotate → Translate

P_local = (x, y)  // Point in local space (relative to shape center)

// Step 1: Scale
P_scaled = (x * scale.x, y * scale.y)

// Step 2: Rotate (around origin)
cos_θ = cos(rotation)
sin_θ = sin(rotation)
P_rotated = (
    P_scaled.x * cos_θ - P_scaled.y * sin_θ,
    P_scaled.x * sin_θ + P_scaled.y * cos_θ
)

// Step 3: Translate
P_world = (
    P_rotated.x + position.x,
    P_rotated.y + position.y
)

Matrix Form (More Compact):

[x']   [1  0  px] [cos(θ)  -sin(θ)  0] [sx  0   0] [x]
[y'] = [0  1  py] [sin(θ)   cos(θ)  0] [0   sy  0] [y]
[1 ]   [0  0  1 ] [0        0       1] [0   0   1] [1]

This matrix multiplication applies transformations right-to-left: Scale first, then Rotate, then Translate.

Performance Considerations:

The order also affects performance optimizations:

  • Scale first: Can optimize by skipping rotation if scale is 0 (shape disappears)
  • Rotate before translate: Can cache rotation matrix calculations if rotation doesn't change
  • Translate last: Simplest operation, applied to already-transformed coordinates

Common Mistakes:

Mistake 1: Translating Before Rotating

// WRONG:
translatedX = x + position.x;
rotatedX = translatedX * cos - translatedY * sin;  // Rotates around origin, not shape center!

Result: Shape orbits the origin when rotated.

Mistake 2: Rotating Before Scaling

// Less optimal:
rotatedX = x * cos - y * sin;
scaledX = rotatedX * scale.x;  // Works, but less intuitive

Result: Works but is less clear conceptually (scale affects already-rotated coordinates).

Mistake 3: Mixing Order in Different Functions

// In renderer: Scale → Rotate → Translate (correct)
// In hit testing: Translate → Rotate → Scale (wrong!)

Result: Shapes appear in different places in renderer vs hit testing (bugs!).

The Golden Rule:

Always apply transformations in this order:

  1. Scale (around local origin)
  2. Rotate (around local origin)
  3. Translate (move to world position)

This ensures shapes rotate around their own center, not around the world origin.

The Correct Order: Scale → Rotate → Translate

Step-by-step explanation:

1. Scale First (in local space):

  • Scale multiplies coordinates relative to the origin (0, 0)
  • Since shapes are centered at origin, scaling happens around the center
  • Example: Scale {x: 2, y: 2} doubles the distance of all points from center
  • Result: Shape becomes twice as large, still centered at (0, 0)

2. Rotate Second (after scaling, still in local space):

  • Rotation rotates points around the origin (0, 0)
  • Since shape is still centered at origin, rotation happens around shape's center
  • Example: Rotate 90° spins the scaled shape around its center
  • Result: Shape is rotated, still centered at (0, 0)

3. Translate Last (moves to world space):

  • Translation moves all points by the same amount
  • This happens after scaling and rotation
  • Example: Translate by (100, 50) moves the entire transformed shape
  • Result: Shape is now at its final world position

Visual demonstration of correct order:

Starting shape (local space, centered at 0,0):
     ┌─────┐
     │  ●  │  ← Center at (0,0)
     └─────┘

After Scale {x: 2, y: 2} (still at 0,0):
     ┌─────────────┐
     │             │
     │      ●      │  ← Still centered, but bigger
     │             │
     └─────────────┘

After Rotate 90° (still at 0,0):
     ┌─────┐
     │     │
     │     │
     │  ●  │  ← Rotated around center, still at (0,0)
     │     │
     │     │
     └─────┘

After Translate (100, 50) (now in world space):
     ┌─────┐
     │     │
     │     │         ● ← World origin (0,0)
     │  ●  │        ↑
     │     │        │
     │     │        │ 100 units right
     └─────┘        50 units up
    ↑
    │ Shape at (100, 50) in world space

What happens with WRONG order (Translate → Rotate → Scale):

1. Translate First:

  • Shape moves to world position
  • Example: Move rectangle from (0,0) to (100, 50)
  • Now shape center is at (100, 50), not at origin

2. Rotate Second (WRONG!):

  • Rotation happens around origin (0, 0), NOT around shape center
  • Shape center is at (100, 50), but rotation is around (0, 0)
  • Result: Shape ORBITS around origin like a planet, not rotating in place

3. Scale Third (WRONG!):

  • Scaling happens around origin (0, 0), not shape center
  • Shape gets bigger/smaller, but grows from origin, not from its center
  • Result: Shape appears to "move away" from origin as it scales

Visual demonstration of WRONG order:

Starting shape (local space):
     ┌─────┐
     │  ●  │  ← Center at (0,0)
     └─────┘

After Translate (100, 50) FIRST:
     ┌─────┐
     │  ●  │  ← Center now at (100,50)
     └─────┘
     ↑
     │ 100 units right, 50 units up from origin

After Rotate 90° (rotates around 0,0, NOT around shape center):
         ┌─────┐
         │  ●  │
         └─────┘
     ● ← Origin (0,0)
     ↑
     Shape ORBITS around origin! ✗ WRONG!

After Scale (scales from origin, not shape center):
         ┌─────────────┐
         │             │
         │      ●      │
         │             │
         └─────────────┘
     ● ← Origin
     ↑
     Shape grew away from origin, not from its center! ✗ WRONG!

Mathematical proof:

CORRECT ORDER: Scale → Rotate → Translate
Point: (10, 0) in local space
Scale {x: 2, y: 2}: (20, 0) [doubled, still at origin]
Rotate 90°: (0, 20) [rotated around origin, still at origin]
Translate (100, 50): (100, 70) [moved to final position]
Final: Point is 20 units up from shape center (100, 50) ✓

WRONG ORDER: Translate → Rotate → Scale
Point: (10, 0) in local space
Translate (100, 50): (110, 50) [moved to world position]
Rotate 90° around origin: (50, 110) [orbits around origin!]
Scale {x: 2, y: 2}: (100, 220) [scales from origin, not shape center]
Final: Point is at (100, 220) - completely wrong position! ✗

Why the order matters:

  • Each transformation happens in a specific coordinate space
  • Scale and rotate work in "local space" (relative to shape center)
  • Translate works in "world space" (absolute positions)
  • Applying them in wrong order mixes coordinate spaces = wrong results

Real-world analogy: Think of a toy on a table:

  • Correct order: Make toy bigger (scale) → Spin toy in place (rotate) → Move table to different room (translate) → Toy is bigger, rotated, and in new location ✓
  • Wrong order: Move table first → Try to spin toy → Make toy bigger → Toy orbits around where table used to be, not where it is now ✗

This order is standard in graphics:

  • OpenGL, DirectX, Canvas, SVG all use this order
  • It's the "standard transformation pipeline"
  • Any graphics textbook teaches this order
  • Deviating from it causes bugs that are hard to debug

Key takeaway: ALWAYS apply transforms in this order: Scale → Rotate → Translate NEVER change the order unless you have a very specific reason and understand the math

If you see an error at this step:

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

  • What this means: point parameter is undefined or null
  • Common causes:
    1. Calling method without argument: transformPoint() instead of transformPoint({ x: 10, y: 20 })
    2. Passing wrong type: transformPoint([10, 20]) instead of transformPoint({ x: 10, y: 20 })
    3. Point object doesn't have x property: transformPoint({ y: 20 }) missing x
  • Fix: Always pass object with x and y: transformPoint({ x: 10, y: 20 })

Error: TypeError: Cannot read property 'x' of undefined (on this.scale.x)

  • What this means: this.scale is undefined
  • Common causes:
    1. Forgot to initialize scale in constructor
    2. Constructor didn't run (class not instantiated properly)
    3. Using arrow function in constructor (loses this binding)
  • Fix: Make sure constructor sets this.scale = { x: 1, y: 1 };

Error: NaN returned from transformPoint

  • What this means: Math calculation resulted in NaN (Not a Number)
  • Common causes:
    1. this.rotation is undefined → undefined * Math.PI = NaN
    2. Forgot to convert degrees to radians and passed degrees directly to Math.cos()
    3. this.scale.x is undefined → point.x * undefined = NaN
  • Fix: Check that all properties are initialized in constructor, verify degrees→radians conversion

Error: SyntaxError: Unexpected token when using Math.PI

  • What this means: Syntax error with Math constant
  • Common causes:
    1. Typo: Math.pi (lowercase) instead of Math.PI (uppercase)
    2. Missing Math prefix: PI instead of Math.PI
    3. Wrong multiplication syntax
  • Fix: Use Math.PI (capital PI), JavaScript is case-sensitive

Error: Rotation gives completely wrong results (e.g., 90° doesn't rotate correctly)

  • What this means: Using degrees directly instead of converting to radians
  • Common causes:
    1. Forgot conversion: Math.cos(this.rotation) instead of Math.cos((this.rotation * Math.PI) / 180)
    2. Wrong conversion formula
  • Fix: Always convert: const rad = (this.rotation * Math.PI) / 180; then use rad with Math.cos/sin

The Complete Base Class:

class Shape {
    constructor() {
        this.position = { x: 0, y: 0 };
        this.rotation = 0;
        this.scale = { x: 1, y: 1 };
    }

    transformPoint(point) {
        // Apply scale
        const scaledX = point.x * this.scale.x;
        const scaledY = point.y * this.scale.y;

        // Apply rotation
        const rad = (this.rotation * Math.PI) / 180;
        const cos = Math.cos(rad);
        const sin = Math.sin(rad);
        const rotatedX = scaledX * cos - scaledY * sin;
        const rotatedY = scaledX * sin + scaledY * cos;

        // Apply translation
        return {
            x: rotatedX + this.position.x,
            y: rotatedY + this.position.y
        };
    }
}

How It Works:

  1. Scale: Multiplies point coordinates by scale factors
  2. Rotate: Rotates the scaled point around origin using rotation matrix
  3. Translate: Moves the rotated point to final position

Why This Order: It's the standard transformation order in graphics. Scale and rotate are applied in local space (relative to the shape's center at 0, 0), then translate moves it to world space. This ensures shapes rotate around their center, not the world origin.

Building This Step by Step:

  1. Create Shape class
  2. Add constructor with position, rotation, and scale properties
  3. Add transformPoint() method
  4. Implement scale application (multiply coordinates)
  5. Implement rotation (convert degrees to radians, apply rotation matrix)
  6. Implement translation (add position offset)
  7. Return transformed point

Building Simple Shapes From Scratch

Let's start with a rectangle. This is the simplest shape and demonstrates the pattern for all shapes.

Understanding Shape Hierarchy:

All shapes in the system extend the base Shape class. This provides:

  • Transform properties (position, rotation, scale) - inherited from base class
  • transformPoint() method - inherited for coordinate transformation
  • Common interface - all shapes can be treated the same way

Why Start With Rectangle:

Rectangle is the simplest geometric shape:

  • Four corners: Easy to understand and calculate
  • Straight edges: No curves to worry about
  • Clear parameters: Width and height are intuitive
  • Foundation: Once you understand rectangle, other shapes follow similar patterns

The Rectangle Pattern (Used for All Shapes):

Every shape follows this pattern:

  1. Extend base Shape class: Gets transform properties and methods
  2. Store shape-specific parameters: Width, height, radius, etc.
  3. Implement getPoints() method: Returns array of points defining shape outline
  4. Points in local space: All points relative to shape center (0, 0)

Understanding Local Space:

Shapes are defined in "local space" where:

  • Origin (0, 0) is at the shape's center
  • Points are relative to the center
  • No transforms applied yet (raw geometry)

Example for rectangle:

  • If rectangle is 100mm wide × 50mm tall:
    • Top-left corner: (-50, 25) in local space (50mm left, 25mm up from center)
    • Top-right corner: (50, 25) in local space (50mm right, 25mm up from center)
    • Bottom-right corner: (50, -25) in local space (50mm right, 25mm down from center)
    • Bottom-left corner: (-50, -25) in local space (50mm left, 25mm down from center)

The transformPoint() method (from base class) converts these local points to world coordinates.

How to Build a Rectangle Step by Step:

Step 1: Create the Rectangle Class Extend the base Shape class and store rectangle dimensions:

class Rectangle extends Shape {
    constructor(width, height) {
        // Step 1.1: Call parent constructor
        // This initializes position, rotation, and scale
        super();

        // Step 1.2: Store rectangle dimensions
        // Width and height define the rectangle size
        this.width = width;
        this.height = height;
    }
}

Why Extend Shape: By extending Shape, the rectangle inherits position, rotation, scale, and transformPoint(). This means we get all transform functionality for free.

If you see an error at this step:

Error: ReferenceError: Must call super constructor in derived class before accessing 'this'

  • What this means: You're using this before calling super()
  • Common causes:
    1. Setting properties before super(): constructor(w, h) { this.width = w; super(); }
    2. Using this in any way before super() call
  • Fix: super() MUST be the first line: constructor(w, h) { super(); this.width = w; }

Error: TypeError: Class extends value is not a constructor or null

  • What this means: Shape class doesn't exist or wasn't imported properly
  • Common causes:
    1. Shape class not defined: class Rectangle extends Shape but Shape doesn't exist
    2. Wrong import: import { Shape } from './Shapes.mjs' but Shape not exported
    3. Typo in class name: extends Shap instead of extends Shape
  • Fix: Make sure Shape class exists and is accessible, check import/export statements

Error: TypeError: Rectangle is not a constructor

  • What this means: Rectangle class not exported or syntax error
  • Common causes:
    1. Forgot to export: class Rectangle instead of export class Rectangle
    2. Syntax error preventing class from being created
    3. Circular dependency issue
  • Fix: Add export keyword: export class Rectangle extends Shape { ... }

Error: TypeError: Cannot read property 'width' of undefined when using rectangle

  • What this means: Rectangle instance doesn't have width property
  • Common causes:
    1. Forgot to set in constructor: constructor(w, h) { super(); } missing this.width = w;
    2. Constructor didn't run (instantiation error)
    3. Using rectangle before it's constructed
  • Fix: Make sure constructor sets this.width = width; and this.height = height;

Step 2: Implement the getPoints() Method This method returns the rectangle's corner points. The key is defining points centered at (0, 0):

getPoints() {
    // Step 2.1: Define points in local space (centered at 0,0)
    // Local space means relative to the shape's center
    // We define points from -width/2 to +width/2, so center is at (0,0)
    const points = [
        { x: -this.width / 2, y: -this.height / 2 },  // Top-left corner
        { x: this.width / 2, y: -this.height / 2 },   // Top-right corner
        { x: this.width / 2, y: this.height / 2 },    // Bottom-right corner
        { x: -this.width / 2, y: this.height / 2 }    // Bottom-left corner
    ];

    // Step 2.2: Transform each point to world space
    // transformPoint() applies scale, rotation, and translation
    // map() applies transformPoint() to each point in the array
    return points.map(p => this.transformPoint(p));
}

Why Center at (0, 0): By defining the rectangle relative to (0, 0), rotation happens around its center, not a corner. The points are at -width/2 to +width/2, so the center is at (0, 0). When you rotate around (0, 0), the shape spins in place. If you defined it from (0, 0) to (width, height), rotation would orbit the origin, not the shape's center. The renderer expects shapes centered at origin - position is applied as a transform, not baked into the points.

DETAILED EXPLANATION OF CENTERING: This is a critical concept that affects how shapes behave when transformed. Understanding this is essential for getting transforms right.

What "centered at origin" means:

  • The shape's geometric center (center point) is located at (0, 0) in local coordinate space
  • All points are defined relative to this center point
  • For a rectangle, this means:
    • Left edge is at -width/2 (half the width to the left)
    • Right edge is at +width/2 (half the width to the right)
    • Top edge is at -height/2 (half the height upward)
    • Bottom edge is at +height/2 (half the height downward)
    • Center is exactly at (0, 0)

Visual representation:

Rectangle with width=100, height=50, centered at (0,0):

         -50           0           +50  (X axis)
-25 ────────────────────────────────────
    │                                   │
    │                                   │
  0 │────────────●─────────────────────│ (0,0) is center
    │                                   │
+25 ────────────────────────────────────

The four corners are:
- Top-left: (-50, -25)     [x: -width/2, y: -height/2]
- Top-right: (+50, -25)    [x: +width/2, y: -height/2]
- Bottom-right: (+50, +25) [x: +width/2, y: +height/2]
- Bottom-left: (-50, +25)  [x: -width/2, y: +height/2]

Why this matters for rotation:

  • When you rotate a point around (0, 0), it moves in a circle centered at (0, 0)
  • If the shape is centered at (0, 0), all points rotate around the shape's center
  • Result: Shape spins in place (expected behavior)
  • If the shape was at (0, 0) to (width, height), rotation would orbit around the world origin
  • Result: Shape would swing around the origin like a pendulum (unexpected behavior)

Example: Rotating a centered rectangle:

Before rotation (centered at 0,0):
    ┌─────────┐
    │    ●    │  ← Center at (0,0)
    └─────────┘

After 90° rotation (still centered at 0,0):
    │
    │
    ●
    │
    │

The rectangle rotates around its center, staying in the same general area.

Example: Rotating a non-centered rectangle (WRONG):

If rectangle was defined from (0,0) to (100, 50):
Before rotation:
    ┌─────────────┐
    │             │
    └─────────────┘
    ●             ← World origin (0,0) at corner

After 90° rotation (rotates around origin):
         │
         │
         └───┐
             │
             ●

The rectangle orbits around the origin, moving to a completely different location!

Why the renderer expects centered shapes:

  • Position is applied as a transform, not baked into the geometry
  • Shape geometry is defined in local space (centered at 0,0)
  • Transform (position, rotation, scale) moves it to world space
  • This separation allows: same geometry, different transforms = different instances
  • Example: Same rectangle class, positioned at (100, 50) or (200, 75) = different rectangles

Comparison: Centered vs Non-Centered:

CENTERED (CORRECT):
Local space: Shape centered at (0, 0)
World space: Transform moves it to (100, 50)
Rotation: Happens around shape center ✓

NON-CENTERED (WRONG):
Local space: Shape at (0, 0) to (100, 50)
World space: Would need to add position to all points
Rotation: Would orbit around (0, 0) ✗

Mathematical proof:

  • Centered rectangle: Points at (-w/2, -h/2), (w/2, -h/2), etc.
  • Rotate 90°: All points rotate around (0, 0), shape stays centered
  • Non-centered: Points at (0, 0), (w, 0), (w, h), (0, h)
  • Rotate 90°: Point at (0, 0) stays at (0, 0), other points orbit around it
  • Result: Shape appears to swing around, not rotate in place

This principle applies to ALL shapes:

  • Circles: Center at (0, 0), radius extends outward
  • Rectangles: Center at (0, 0), corners at ±width/2, ±height/2
  • Polygons: Center at (0, 0), vertices arranged around center
  • Stars: Center at (0, 0), tips extend outward
  • All shapes follow this pattern for consistent transform behavior

If you see an error at this step:

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

  • What this means: this.width doesn't exist
  • Common causes:
    1. Forgot to set in constructor: constructor(w, h) { super(); } missing width/height assignment
    2. Using width instead of this.width: -width / 2 instead of -this.width / 2
    3. Constructor didn't run (instantiation error)
  • Fix: Use this.width and this.height, make sure they're set in constructor

Error: TypeError: points.map is not a function

  • What this means: points is not an array
  • Common causes:
    1. Forgot to create array: const points = ... without square brackets
    2. Typo: const point = [...] (singular) instead of const points = [...]
    3. Variable name mismatch: created pointsArray but using points
  • Fix: Make sure you have const points = [ ... ]; with square brackets

Error: TypeError: transformPoint is not a function

  • What this means: transformPoint method doesn't exist or isn't accessible
  • Common causes:
    1. Rectangle doesn't extend Shape: class Rectangle { } instead of class Rectangle extends Shape { }
    2. Forgot to call super() in constructor (inheritance not working)
    3. Typo in method name: this.transformpoint() instead of this.transformPoint()
  • Fix: Make sure Rectangle extends Shape and calls super() in constructor

Error: SyntaxError: Unexpected token in array

  • What this means: Syntax error in points array definition
  • Common causes:
    1. Missing comma: { x: -this.width / 2, y: -this.height / 2 } { x: this.width / 2, ... }
    2. Wrong brackets: points = { ... } instead of points = [ ... ]
    3. Missing closing bracket or brace
  • Fix: Check commas between array elements, make sure you use square brackets for array

Error: Rectangle appears twice the size or half the size

  • What this means: Math error in point calculation
  • Common causes:
    1. Forgot to divide by 2: -this.width instead of -this.width / 2
    2. Wrong division: this.width / 2 calculated incorrectly
    3. Using width where height should be or vice versa
  • Fix: Double-check math: -this.width / 2 for left edge, +this.width / 2 for right edge

How transformPoint Works: Each point goes through scale → rotate → translate. For a rectangle at position (100, 50) rotated 45°: first the local points are defined centered at (0, 0), then transformPoint() scales them (if scale is not 1), rotates them 45° around (0, 0), then translates them to (100, 50). The result: all four corners are correctly positioned and rotated. This is efficient because we define geometry once, then transform it - we don't need separate geometry for every position/rotation combination.

The Complete Rectangle Class:

class Rectangle extends Shape {
    constructor(width, height) {
        super();
        this.width = width;
        this.height = height;
    }

    getPoints() {
        // Define points in local space (centered at 0,0)
        const points = [
            { x: -this.width / 2, y: -this.height / 2 },  // Top-left
            { x: this.width / 2, y: -this.height / 2 },   // Top-right
            { x: this.width / 2, y: this.height / 2 },    // Bottom-right
            { x: -this.width / 2, y: this.height / 2 }    // Bottom-left
        ];

        // Transform each point (applies scale, rotation, translation)
        return points.map(p => this.transformPoint(p));
    }
}

Building This Step by Step:

  1. Create Rectangle class extending Shape
  2. Add constructor that calls super() and stores width and height
  3. Implement getPoints() method
  4. Define four corner points centered at (0, 0)
  5. Use map() to transform each point with transformPoint()
  6. Return the transformed points array

Building Circles From Scratch

Circles are approximated as polygons (lots of points). This allows them to work with the same point-based system as other shapes.

How to Build a Circle Step by Step:

Step 1: Create the Circle Class Extend the base Shape class and store the radius:

class Circle extends Shape {
    constructor(radius) {
        // Step 1.1: Call parent constructor
        super();

        // Step 1.2: Store the radius
        // Radius is the distance from center to edge
        this.radius = radius;
    }
}

Step 2: Implement the getPoints() Method Generate points around the circle using trigonometry:

getPoints(segments = 32) {
    // Step 2.1: Initialize empty points array
    // We'll build this array by generating points around the circle
    const points = [];

    // Step 2.2: Generate points around the circle
    // segments determines how many points to generate
    // More segments = smoother circle, but more computation
    // Default of 32 is usually smooth enough
    for (let i = 0; i < segments; i++) {
        // Step 2.3: Calculate angle for this point
        // (i / segments) gives fraction around circle (0 to 1)
        // Multiply by 2π to get angle in radians (0 to 2π)
        // This evenly distributes points around 360 degrees
        const angle = (i / segments) * Math.PI * 2;

        // Step 2.4: Calculate point position using trigonometry
        // Math.cos(angle) * radius gives X offset from center
        // Math.sin(angle) * radius gives Y offset from center
        // These formulas trace a circle mathematically
        points.push({
            x: Math.cos(angle) * this.radius,
            y: Math.sin(angle) * this.radius
        });
    }

    // Step 2.5: Transform each point to world space
    // Apply scale, rotation, and translation
    return points.map(p => this.transformPoint(p));
}

Why Approximate Circles as Polygons: Canvas can draw circles with arc(), but for consistency, boolean operations, and path manipulation, we convert everything to points. A circle becomes a polygon with many sides. 32 segments means dividing 360° by 32 = 11.25° per segment, which is smooth enough for most cases. For large circles or high zoom, you might see the edges, so you could increase segments dynamically based on screen size.

DETAILED EXPLANATION OF POLYGON APPROXIMATION: This is a fundamental design decision that affects how all shapes are represented and processed in the system.

The problem:

  • Circles are mathematically smooth curves (infinite points)
  • Computers can only work with discrete points (finite set)
  • We need to represent smooth circles using a finite number of points

The solution:

  • Approximate circles as polygons with many sides
  • More sides = smoother appearance, but more computation
  • Fewer sides = faster, but visible edges (polygon shape visible)

Why 32 segments?

  • 360° / 32 = 11.25° per segment
  • At normal viewing distance, 11.25° is small enough that the curve looks smooth
  • 32 points is a good balance: smooth enough, not too many points
  • Visual test: Most people can't see the edges at normal zoom levels
  • Performance: 32 points is fast to process, not too slow

Comparison of segment counts:

Segments = 4:  Square (90° per segment)      - Clearly visible edges
Segments = 8:  Octagon (45° per segment)     - Still visible edges
Segments = 16: 16-gon (22.5° per segment)    - Slight edges visible
Segments = 32: 32-gon (11.25° per segment)   - Smooth for most cases ✓
Segments = 64: 64-gon (5.625° per segment)   - Very smooth, slower
Segments = 128: 128-gon (2.8125° per segment) - Extremely smooth, slow

Why not use canvas arc() for circles?

  • Canvas has ctx.arc() which draws perfect circles
  • BUT: We need consistency across all shapes
  • Other shapes (stars, polygons, joints) don't have canvas primitives
  • Boolean operations need points (can't boolean arc() easily)
  • Path manipulation needs points (editing, simplification, etc.)
  • Unified representation: All shapes become points → same code path

Benefits of point-based approach:

  1. Consistency: All shapes use the same representation (array of points)
  2. Boolean operations: Can use algorithms like ClipperLib that work with polygons
  3. Editing: Can manipulate individual points (drag corners, etc.)
  4. Export: Easy to export to formats that need polygons (DXF, some SVG)
  5. Simplification: Can reduce point count for performance
  6. Debugging: Can visualize exact points being used

Trade-offs:

  • Pros: Unified system, works with boolean ops, editable
  • Cons: Not perfectly smooth, more points = slower

Dynamic segment count (advanced): For better quality, you could adjust segments based on:

  • Circle size: Larger circles need more segments (visible at high zoom)
  • Zoom level: Higher zoom = more visible edges = need more segments
  • Performance requirements: Lower-end devices = fewer segments

Example calculation for dynamic segments:

// Calculate segments based on circle size and zoom
const minSegments = 16;  // Minimum for small circles
const maxSegments = 128; // Maximum for very large circles
const baseRadius = 50;   // Base size for 32 segments

// Scale segments with radius (larger = more segments)
const segments = Math.min(
    maxSegments,
    Math.max(minSegments, Math.ceil(32 * (radius / baseRadius)))
);

// Or based on screen size (zoom level)
const screenRadius = radius * zoomLevel;
const segments = Math.min(
    maxSegments,
    Math.max(minSegments, Math.ceil(screenRadius / 5)) // ~5 pixels per segment
);

Visual quality comparison:

32 segments at normal zoom:   ○ Looks perfectly smooth
32 segments at 10x zoom:      ⬡ Slightly visible edges (still acceptable)
32 segments at 100x zoom:     ⬡ Clearly visible polygon edges
64 segments at 100x zoom:     ○ Still looks smooth

Performance impact:

  • 32 points: Fast rendering, fast boolean ops, fast hit testing
  • 64 points: 2x more points = ~2x slower (but still very fast)
  • 128 points: 4x more points = ~4x slower (noticeable on many shapes)

Best practice:

  • Start with 32 segments (good default)
  • Increase to 64 if edges are visible at normal zoom
  • Use dynamic segments only if needed (adds complexity)
  • Profile performance if using high segment counts

The Math Breakdown: We iterate from 0 to segments-1. Each iteration calculates an angle: (i / segments) * 2π evenly distributes angles around the circle. Math.cos(angle) * radius gives the X offset from center (cos(0°) = 1, so point at 0° is at (radius, 0)). Math.sin(angle) * radius gives the Y offset (sin(90°) = 1, so point at 90° is at (0, radius)). These formulas trace a circle mathematically. After generating all points, transformPoint() applies position/rotation/scale, so the circle can be placed and rotated like any other shape.

The Complete Circle Class:

class Circle extends Shape {
    constructor(radius) {
        super();
        this.radius = radius;
    }

    getPoints(segments = 32) {
        const points = [];

        // Generate points around the circle
        for (let i = 0; i < segments; i++) {
            const angle = (i / segments) * Math.PI * 2;
            points.push({
                x: Math.cos(angle) * this.radius,
                y: Math.sin(angle) * this.radius
            });
        }

        return points.map(p => this.transformPoint(p));
    }
}

Building This Step by Step:

  1. Create Circle class extending Shape
  2. Add constructor that calls super() and stores radius
  3. Implement getPoints() method with segments parameter (default 32)
  4. Create empty points array
  5. Loop from 0 to segments-1
  6. Calculate angle for each point: (i / segments) * 2π
  7. Calculate point position using Math.cos(angle) * radius and Math.sin(angle) * radius
  8. Add point to array
  9. Transform all points with map() and transformPoint()
  10. Return transformed points

Building Complex Shapes From Scratch - Stars

Stars are more interesting than simple shapes. They alternate between outer and inner radii to create the star shape.

How to Build a Star Step by Step:

Step 1: Create the Star Class Extend the base Shape class and store star properties:

class Star extends Shape {
    constructor(outerRadius, innerRadius, points) {
        // Step 1.1: Call parent constructor
        super();

        // Step 1.2: Store star properties
        // outerRadius: distance from center to star tips
        // innerRadius: distance from center to star valleys
        // points: number of star arms (tips)
        this.outerRadius = outerRadius;
        this.innerRadius = innerRadius;
        this.points = points;  // Number of star points
    }
}

Step 2: Implement the getPoints() Method Generate points by alternating between outer and inner radii:

getPoints() {
    // Step 2.1: Initialize empty points array
    const points = [];

    // Step 2.2: Generate alternating outer and inner points
    // A star has twice as many points as it has arms
    // For a 5-pointed star, we need 10 points total
    // Point 0 is outer (tip), point 1 is inner (valley), point 2 is outer (next tip), etc.
    for (let i = 0; i < this.points * 2; i++) {
        // Step 2.3: Calculate angle for this point
        // i / (points * 2) gives fraction around circle (0 to 1)
        // Multiply by 2π to get angle in radians
        const angle = (i / (this.points * 2)) * Math.PI * 2;

        // Step 2.4: Alternate between outer and inner radius
        // If i is even (0, 2, 4, ...), use outer radius (star tip)
        // If i is odd (1, 3, 5, ...), use inner radius (star valley)
        const radius = i % 2 === 0 ? this.outerRadius : this.innerRadius;

        // Step 2.5: Calculate point position using trigonometry
        // Same as circle, but radius alternates
        points.push({
            x: Math.cos(angle) * radius,
            y: Math.sin(angle) * radius
        });
    }

    // Step 2.6: Transform each point to world space
    return points.map(p => this.transformPoint(p));
}

How It Works: A star has twice as many points as it has arms. Point 0 is outer (tip), point 1 is inner (valley), point 2 is outer (next tip), etc. By alternating between outer and inner radii, you get the star shape. The angle calculation i / (points * 2) gives you the fraction around the circle. Multiply by to get the angle in radians.

DETAILED EXPLANATION OF STAR GENERATION: Stars are more complex than circles because they alternate between two radii. Understanding this pattern is key to generating correct star shapes.

Star structure:

  • A star with N arms (points) has 2N total vertices (points in the points array)
  • Vertices alternate: outer radius (tip) → inner radius (valley) → outer radius (next tip) → etc.
  • Each "arm" consists of two vertices: one at outer radius (tip), one at inner radius (valley)
  • Example 5-pointed star: 10 vertices total (5 tips + 5 valleys)

Visual representation of 5-pointed star:

            Tip (outer radius)
                *
               / \
              /   \
    Valley  /       \  Valley (inner radius)
          *           *
          |           |
          |     ●     |  ← Center (0,0)
          |           |
          *           *
    Valley  \       /  Valley
              \   /
               \ /
                *
            Tip (outer radius)

Point sequence for 5-pointed star:

Point 0: angle = 0° (right), radius = outer → Tip pointing right
Point 1: angle = 18° (slight up), radius = inner → Valley
Point 2: angle = 36° (more up), radius = outer → Tip pointing up-right
Point 3: angle = 54° (higher), radius = inner → Valley
Point 4: angle = 72° (almost up), radius = outer → Tip pointing up-left
Point 5: angle = 90° (up), radius = inner → Valley at top
... (continues for all 10 points)

Angle calculation:

  • Total points = points * 2 (5 arms × 2 = 10 points)
  • Angle step = 2π / (points * 2) = 2π / 10 = 36° per step
  • For point i: angle = (i / (points * 2)) * 2π
  • Example: i=0 → angle=0°, i=1 → angle=36°, i=2 → angle=72°, etc.

Radius alternation:

  • Even indices (0, 2, 4, ...): Use outer radius (tips of the star)
  • Odd indices (1, 3, 5, ...): Use inner radius (valleys between tips)
  • Code: i % 2 === 0 ? outerRadius : innerRadius
  • Modulo operator % gives remainder: even numbers % 2 = 0, odd numbers % 2 = 1

Why this creates a star:

  • Outer radius points create the "spikes" or "tips" of the star
  • Inner radius points create the "valleys" or "indentations"
  • Alternating between them creates the classic star silhouette
  • The ratio of outerRadius to innerRadius controls star "sharpness":
    • innerRadius close to outerRadius → shallow, rounded star
    • innerRadius much smaller → sharp, pointy star
    • innerRadius = 0 → degenerate case (all tips meet at center)

Example walkthrough for 5-pointed star:

Given: outerRadius = 50, innerRadius = 25, points = 5

Point 0 (i=0, even):
  angle = (0/10) * 2π = 0° → pointing right
  radius = outerRadius = 50 (because 0 % 2 === 0)
  position: x = cos(0°) * 50 = 50, y = sin(0°) * 50 = 0
  Result: (50, 0) = tip pointing right ✓

Point 1 (i=1, odd):
  angle = (1/10) * 2π = 36° → slightly up from right
  radius = innerRadius = 25 (because 1 % 2 === 1)
  position: x = cos(36°) * 25 ≈ 20.2, y = sin(36°) * 25 ≈ 14.7
  Result: (20.2, 14.7) = valley between tips ✓

Point 2 (i=2, even):
  angle = (2/10) * 2π = 72° → more up, between right and up
  radius = outerRadius = 50 (because 2 % 2 === 0)
  position: x = cos(72°) * 50 ≈ 15.5, y = sin(72°) * 50 ≈ 47.6
  Result: (15.5, 47.6) = tip pointing up-right ✓

Common star ratios:

  • Standard 5-pointed star: innerRadius ≈ outerRadius * 0.38 (golden ratio)
  • Sharp star: innerRadius ≈ outerRadius * 0.25 (very pointy)
  • Rounded star: innerRadius ≈ outerRadius * 0.5 (gentle curves)

Troubleshooting star generation:

  • Star looks wrong or malformed: Check radius alternation logic (i % 2)
  • Star tips are in wrong positions: Check angle calculation (should be i / (points * 2))
  • Star has wrong number of arms: Check loop condition (should be i < points * 2)
  • Star is inside-out: Swap outerRadius and innerRadius

The Complete Star Class:

class Star extends Shape {
    constructor(outerRadius, innerRadius, points) {
        super();
        this.outerRadius = outerRadius;
        this.innerRadius = innerRadius;
        this.points = points;  // Number of star points
    }

    getPoints() {
        const points = [];

        // Generate alternating outer and inner points
        for (let i = 0; i < this.points * 2; i++) {
            const angle = (i / (this.points * 2)) * Math.PI * 2;
            // Alternate between outer and inner radius
            const radius = i % 2 === 0 ? this.outerRadius : this.innerRadius;

            points.push({
                x: Math.cos(angle) * radius,
                y: Math.sin(angle) * radius
            });
        }

        return points.map(p => this.transformPoint(p));
    }
}

Building This Step by Step:

  1. Create Star class extending Shape
  2. Add constructor with outerRadius, innerRadius, and points parameters
  3. Implement getPoints() method
  4. Create empty points array
  5. Loop from 0 to points * 2 - 1 (twice as many points as arms)
  6. Calculate angle: (i / (points * 2)) * 2π
  7. Alternate radius: use outerRadius if i is even, innerRadius if odd
  8. Calculate point position using trigonometry
  9. Add point to array
  10. Transform all points and return

Building Rounded Rectangles From Scratch

Rounded rectangles need corner arcs. This is more complex than regular rectangles because you need to generate curved corners.

How to Build a Rounded Rectangle Step by Step:

Step 1: Create the RoundedRectangle Class Extend the base Shape class and store dimensions with radius validation:

class RoundedRectangle extends Shape {
    constructor(width, height, radius) {
        // Step 1.1: Call parent constructor
        super();

        // Step 1.2: Store dimensions
        this.width = width;
        this.height = height;

        // Step 1.3: Validate and clamp radius
        // Radius can't be bigger than half the width or height
        // If it is, clamp it to the smaller of the two
        // This prevents invalid shapes where corners would overlap
        this.radius = Math.min(radius, width / 2, height / 2);
    }
}

Why Clamp Radius: If the radius is too large, the rounded corners would overlap or extend beyond the rectangle bounds. By clamping it to half the width or height (whichever is smaller), we ensure the shape is always valid.

Step 2: Implement the getPoints() Method Generate points for straight edges and curved corners:

getPoints(segmentsPerCorner = 8) {
    // Step 2.1: Initialize points array and calculate half-dimensions
    const points = [];
    const w = this.width / 2;   // Half width (distance from center to edge)
    const h = this.height / 2;  // Half height
    const r = this.radius;      // Corner radius

    // Step 2.2: Add top edge points (straight line)
    // Start from left edge (accounting for corner radius)
    // End at right edge (accounting for corner radius)
    points.push({ x: -w + r, y: -h });  // Left end of top edge
    points.push({ x: w - r, y: -h });    // Right end of top edge

    // Step 2.3: Generate top-right corner arc
    // Each corner is a quarter circle (90 degrees)
    // We generate multiple points to approximate the curve
    for (let i = 0; i <= segmentsPerCorner; i++) {
        // Calculate angle for this point on the arc
        // Start at -π/2 (pointing up), end at 0 (pointing right)
        // This traces a quarter circle from top to right
        const angle = -Math.PI / 2 + (i / segmentsPerCorner) * (Math.PI / 2);

        // Calculate point position
        // Arc center is at (w - r, -h + r) - corner position minus radius
        // Add the arc offset using trigonometry
        points.push({
            x: w - r + Math.cos(angle) * r,
            y: -h + r + Math.sin(angle) * r
        });
    }

    // Step 2.4: Repeat for other edges and corners
    // Right edge, bottom-right corner, bottom edge, bottom-left corner, left edge, top-left corner
    // ... (similar pattern for each)

    // Step 2.5: Transform all points to world space
    return points.map(p => this.transformPoint(p));
}

Corner Arcs Explained: Each corner is a quarter circle. The angle goes from -π/2 (pointing up) to 0 (pointing right) for the top-right corner. The center of the arc is at (w - r, -h + r) - that's the corner position minus the radius. This positions the arc so it smoothly connects the edges.

DETAILED EXPLANATION OF ROUNDED RECTANGLE CORNERS: Rounded rectangles are more complex than regular rectangles because they combine straight edges with curved corners. Understanding how to generate corner arcs is key to creating smooth rounded rectangles.

What is a rounded corner?

  • A rounded corner is a quarter-circle (90° arc) that smoothly connects two edges
  • Instead of a sharp corner, the corner is "cut off" with a curved arc
  • The arc has a radius (r) - larger radius = more rounded corner

Visual representation of rounded rectangle:

Regular rectangle corner (sharp):
┌───┐
│   │
│   │  ← Sharp 90° corner
└───┘

Rounded rectangle corner:
     ┌───
    ╱    │  ← Curved arc replaces sharp corner
   ╱     │
  ╱      │
 └───────┘

Corner arc positioning:

Rectangle: width=100, height=50, radius=10
Half dimensions: w=50, h=25

Top-right corner (before rounding):
  Corner would be at: (50, -25)  [right edge, top edge]

Arc center (top-right corner):
  Arc center = corner position - radius offset
  X: 50 - 10 = 40  [move left by radius]
  Y: -25 + 10 = -15  [move down by radius]
  Center at: (40, -15)

Why this position?
  - Arc needs to smoothly connect two edges
  - Top edge ends at: (40, -25)  [50 - 10 = 40]
  - Right edge starts at: (50, -15)  [-25 + 10 = -15]
  - Arc connects these two points

Arc angle calculation:

Top-right corner arc:
  - Starts at top edge (pointing left): angle = -π/2 (270° or pointing up)
  - Ends at right edge (pointing down): angle = 0 (0° or pointing right)
  - Arc spans: 0 - (-π/2) = π/2 radians = 90°

The loop generates points along this 90° arc:
  for (let i = 0; i <= segmentsPerCorner; i++) {
      // Angle goes from -π/2 to 0
      // i=0: angle = -π/2 (start of arc, pointing up)
      // i=segmentsPerCorner: angle = 0 (end of arc, pointing right)
      // i in between: angles evenly distributed
  }

Point generation for arc:

For top-right corner with 8 segments:

Segment 0: angle = -π/2 + (0/8) * (π/2) = -π/2 = -90° → pointing up
  Point: x = 40 + cos(-90°)*10 = 40 + 0 = 40
         y = -15 + sin(-90°)*10 = -15 + (-1)*10 = -25
  Result: (40, -25) = connects to top edge ✓

Segment 4: angle = -π/2 + (4/8) * (π/2) = -π/2 + π/4 = -45°
  Point: x = 40 + cos(-45°)*10 ≈ 40 + 7.07 = 47.07
         y = -15 + sin(-45°)*10 ≈ -15 + (-7.07) = -22.07
  Result: (47.07, -22.07) = middle of arc

Segment 8: angle = -π/2 + (8/8) * (π/2) = -π/2 + π/2 = 0° → pointing right
  Point: x = 40 + cos(0°)*10 = 40 + 10 = 50
         y = -15 + sin(0°)*10 = -15 + 0 = -15
  Result: (50, -15) = connects to right edge ✓

Why Segments Per Corner: More segments = smoother corners. 8 is usually enough for most cases. You can increase it for very large shapes or when you need higher quality curves.

DETAILED EXPLANATION:

  • Each corner arc needs multiple points to approximate the curve
  • Fewer segments = visible polygon edges (not smooth)
  • More segments = smoother curve, but more computation
  • 8 segments = 8 points along the 90° arc = ~11.25° per segment
  • This is usually smooth enough at normal viewing distances

Segment count comparison:

4 segments:  Corner looks like a quarter-polygon (visible edges)
8 segments:  Corner looks smooth in most cases ✓ (recommended)
16 segments: Very smooth, rarely needed
32 segments: Extremely smooth, only for very large shapes or high zoom

Complete rounded rectangle point sequence:

1. Top-left corner of top edge
2. Top-right corner of top edge (before arc)
3. Top-right corner arc points (8 points)
4. Top of right edge (after arc)
5. Bottom of right edge (before arc)
6. Bottom-right corner arc points (8 points)
7. Right side of bottom edge (after arc)
8. Left side of bottom edge (before arc)
9. Bottom-left corner arc points (8 points)
10. Bottom of left edge (after arc)
11. Top of left edge (before arc)
12. Top-left corner arc points (8 points)
13. Back to start (closes the path)

Total points: 4 edges + 4 corners × 8 segments = ~36 points

Why clamp radius:

this.radius = Math.min(radius, width / 2, height / 2);
  • If radius is too large, corners would overlap
  • Example: width=100, radius=60 → corner would extend beyond rectangle bounds
  • Clamping ensures radius ≤ half of smallest dimension
  • Math.min finds smallest of: radius, width/2, height/2
  • This prevents invalid shapes

The Complete RoundedRectangle Class:

class RoundedRectangle extends Shape {
    constructor(width, height, radius) {
        super();
        this.width = width;
        this.height = height;
        this.radius = Math.min(radius, width / 2, height / 2);  // Can't be bigger than half
    }

    getPoints(segmentsPerCorner = 8) {
        const points = [];
        const w = this.width / 2;
        const h = this.height / 2;
        const r = this.radius;

        // Top edge (left to right)
        points.push({ x: -w + r, y: -h });
        points.push({ x: w - r, y: -h });

        // Top-right corner arc
        for (let i = 0; i <= segmentsPerCorner; i++) {
            const angle = -Math.PI / 2 + (i / segmentsPerCorner) * (Math.PI / 2);
            points.push({
                x: w - r + Math.cos(angle) * r,
                y: -h + r + Math.sin(angle) * r
            });
        }

        // Right edge, bottom-right corner, bottom edge, bottom-left corner, left edge, top-left corner
        // ... (similar pattern)

        return points.map(p => this.transformPoint(p));
    }
}

Building This Step by Step:

  1. Create RoundedRectangle class extending Shape
  2. Add constructor with width, height, and radius parameters
  3. Clamp radius to prevent invalid shapes
  4. Implement getPoints() method with segmentsPerCorner parameter
  5. Calculate half-dimensions (w, h) and radius (r)
  6. Add top edge points (accounting for corner radius)
  7. Generate top-right corner arc using loop and trigonometry
  8. Repeat for other three corners and edges
  9. Transform all points and return

Building Joint Shapes

Woodworking joints are complex. Let's look at a finger joint:

class FingerJointPin extends Shape {
    constructor(width, fingerCount, fingerWidth, depth, thickness = 20) {
        super();
        this.width = width;
        this.fingerCount = fingerCount;
        this.fingerWidth = fingerWidth || width / fingerCount;
        this.depth = depth;
        this.thickness = thickness;
    }

    getPoints() {
        const points = [];
        const startX = -this.width / 2;
        const startY = -this.depth / 2;

        // Start at top-left
        points.push({ x: startX, y: startY - this.thickness });

        // Top edge (straight)
        points.push({ x: startX + this.width, y: startY - this.thickness });

        // Right edge down to where fingers start
        points.push({ x: startX + this.width, y: startY });

        // Draw finger pattern along bottom edge
        for (let i = this.fingerCount - 1; i >= 0; i--) {
            const fingerLeft = startX + i * this.fingerWidth;
            const fingerRight = fingerLeft + this.fingerWidth;

            if (i % 2 === 0) {
                // This is a finger (extends outward)
                points.push({ x: fingerRight, y: startY });
                points.push({ x: fingerRight, y: startY + this.depth });
                points.push({ x: fingerLeft, y: startY + this.depth });
                points.push({ x: fingerLeft, y: startY });
            } else {
                // This is a gap (stays at base level)
                points.push({ x: fingerRight, y: startY });
            }
        }

        // Complete the outline
        points.push({ x: startX, y: startY });
        points.push({ x: startX, y: startY - this.thickness });

        return points.map(p => this.transformPoint(p));
    }
}

How finger joints work: Fingers alternate - even indices are fingers (extend outward), odd indices are gaps (stay at base). This creates the interlocking pattern.

The pattern: Start from the right, work left. For each finger, draw: right edge down, bottom edge left, left edge up, top edge right. For gaps, just continue along the base.

DETAILED EXPLANATION OF FINGER JOINT GENERATION: Finger joints are woodworking joints where interlocking "fingers" create a strong connection. Understanding the pattern is essential for generating correct joint geometry.

What is a finger joint?

  • A series of alternating fingers and gaps that interlock
  • Two pieces can be connected by sliding fingers into gaps
  • Used in woodworking for box corners, drawers, etc.
  • Pattern must match exactly between male and female pieces

Visual representation:

Finger Joint Pin (side view):
┌────────────────────────────┐ ← Top edge
│                            │
│    ┌───┐     ┌───┐        │ ← Fingers extend outward
│    │   │     │   │        │
│    │   └───┐ │   └───┐    │ ← Gaps between fingers
│    │       │ │       │    │
│    └───────┘ └───────┘    │ ← Base level
└────────────────────────────┘

Finger Joint Tail (female, fits around pin):
┌────────────────────────────┐
│                            │
│  ┌───┐     ┌───┐          │ ← Gaps that fingers fit into
│  │   │     │   │          │
│  │   └───┐ │   └───┐      │ ← Material between gaps
│  │       │ │       │      │
│  └───────┘ └───────┘      │
└────────────────────────────┘

Finger joint pattern:

  • Pattern alternates: finger → gap → finger → gap → ...
  • Even indices (0, 2, 4, ...): Fingers (extend outward from base)
  • Odd indices (1, 3, 5, ...): Gaps (stay at base level)
  • For a joint with N fingers, you need N fingers and N-1 or N+1 gaps

Parameter explanation:

constructor(width, fingerCount, fingerWidth, depth, thickness)
  • width: Total width of the joint (spans all fingers and gaps)
  • fingerCount: Number of fingers (e.g., 5 fingers = 5 protrusions)
  • fingerWidth: Width of each finger (should divide evenly into total width)
  • depth: How far fingers extend (penetration depth)
  • thickness: Thickness of the material (affects top edge position)

Point generation walkthrough:

Example: width=100, fingerCount=4, fingerWidth=20, depth=15, thickness=10

Calculate positions:
  startX = -50 (left edge, half width to left)
  startY = -7.5 (base level, half depth up from center)

1. Start at top-left (before fingers):
   Point: (-50, -17.5)  [startY - thickness = -7.5 - 10]

2. Draw top edge (straight line):
   Point: (50, -17.5)  [right edge, still at top]

3. Draw right edge down to base:
   Point: (50, -7.5)  [right edge, at base level]

4. Loop through fingers from right to left:
   i=3 (rightmost finger, index 3, which is odd = gap):
     Gap from x=30 to x=50 (stays at base level)
     Point: (30, -7.5)

   i=2 (finger, index 2, which is even = finger):
     Finger from x=10 to x=30
     Draw finger shape:
       Point: (30, -7.5)  [right edge at base]
       Point: (30, 7.5)   [right edge extended down]
       Point: (10, 7.5)   [bottom edge left]
       Point: (10, -7.5)  [left edge up, back to base]

   i=1 (gap, index 1, which is odd = gap):
     Gap from x=-10 to x=10 (stays at base)
     Point: (-10, -7.5)

   i=0 (finger, index 0, which is even = finger):
     Finger from x=-30 to x=-10
     Draw finger shape: (similar to above)

5. Complete the outline:
   Point: (-50, -7.5)  [back to left edge at base]
   Point: (-50, -17.5) [back to start]

Why loop from right to left:

  • Starting from right (highest index) and working left (to index 0)
  • This makes it easier to calculate positions: startX + i * fingerWidth
  • As i decreases, we move left across the joint
  • Can also loop left-to-right, but the math is slightly different

Finger vs Gap logic:

if (i % 2 === 0) {
    // Even index = Finger
    // Draw: down, left, up, right (creates protruding finger)
} else {
    // Odd index = Gap
    // Just continue along base (no protrusion)
}

Why modulo operator (%):

  • i % 2 gives remainder when dividing i by 2
  • Even numbers: 0 % 2 = 0, 2 % 2 = 0, 4 % 2 = 0, ...
  • Odd numbers: 1 % 2 = 1, 3 % 2 = 1, 5 % 2 = 1, ...
  • This creates the alternating pattern automatically

Creating matching male/female joints:

  • Male (pin): Fingers extend outward (even indices)
  • Female (tail): Pattern is inverted (fingers become gaps, gaps become fingers)
  • They must have same fingerCount and fingerWidth to fit
  • Depth should match (or slightly less for tolerance)

Building Paths with Holes

Some shapes have holes (like donuts). These need special handling:

class Donut extends Shape {
    constructor(outerRadius, innerRadius) {
        super();
        this.outerRadius = outerRadius;
        this.innerRadius = innerRadius;
    }

    getPoints(segments = 64) {
        const points = [];

        // Outer circle
        for (let i = 0; i <= segments; i++) {
            const angle = (i / segments) * Math.PI * 2;
            points.push({
                x: Math.cos(angle) * this.outerRadius,
                y: Math.sin(angle) * this.outerRadius
            });
        }

        // Bridge to inner circle
        points.push({
            x: Math.cos(0) * this.innerRadius,
            y: Math.sin(0) * this.innerRadius
        });

        // Inner circle (in reverse to create hole)
        for (let i = segments; i >= 0; i--) {
            const angle = (i / segments) * Math.PI * 2;
            points.push({
                x: Math.cos(angle) * this.innerRadius,
                y: Math.sin(angle) * this.innerRadius
            });
        }

        // Bridge back to outer circle
        points.push({
            x: Math.cos(0) * this.outerRadius,
            y: Math.sin(0) * this.outerRadius
        });

        return points.map(p => this.transformPoint(p));
    }
}

How holes work: Draw the outer path, then draw the inner path in reverse. The reverse direction tells the renderer this is a hole. The renderer uses the even-odd fill rule - areas with odd winding count are filled, even count are holes.

The bridge: You need to connect the outer and inner paths. Draw a line from the outer circle to the inner circle, then back. This creates a single continuous path.

DETAILED EXPLANATION OF HOLES IN PATHS: Holes (like in donuts) require special handling because a single path needs to define both an outer boundary and an inner cutout. This uses the "winding rule" to determine what gets filled.

What is a hole?

  • A hole is a cutout in a shape (like a donut hole)
  • The outer path defines the shape boundary
  • The inner path defines what gets "cut out"
  • Both are part of the same continuous path

Visual representation:

Donut shape:
    ┌─────────────┐
    │             │
    │   ┌─────┐   │
    │   │     │   │  ← Outer circle (filled area)
    │   │  ●  │   │  ← Inner circle (hole, not filled)
    │   │     │   │
    │   └─────┘   │
    │             │
    └─────────────┘

The filled area is: outer area - inner area

Winding rule (even-odd fill rule):

How it works:
1. Draw a ray from a point outward (any direction)
2. Count how many times the path crosses the ray
3. Odd count = point is inside (fill it)
4. Even count = point is outside (don't fill it)

Example for donut:
  Point in outer area (not in hole):
    Ray crosses outer path once → odd → fill ✓

  Point in hole (inner area):
    Ray crosses outer path once, then inner path once → even → don't fill ✓
    (Hole is "subtracted" from fill)

  Point outside donut:
    Ray crosses nothing → even (0) → don't fill ✓

Why reverse direction matters:

  • Winding rule depends on path direction (clockwise vs counter-clockwise)
  • If outer path goes clockwise, inner path should go counter-clockwise (or vice versa)
  • This ensures the winding count works correctly
  • Reversing the inner path changes its "direction" relative to outer path

Point generation for donut:

Step 1: Generate outer circle (clockwise):
  for (let i = 0; i <= segments; i++) {
      angle = (i / segments) * 2π
      // Generates points going around circle clockwise
      // Point 0: angle 0° (right)
      // Point segments/4: angle 90° (top)
      // Point segments/2: angle 180° (left)
      // Point 3*segments/4: angle 270° (bottom)
  }

Step 2: Bridge to inner circle:
  // Need to connect outer to inner
  // Draw line from outer circle (angle 0) to inner circle (angle 0)
  Point: inner circle point at angle 0

Step 3: Generate inner circle (counter-clockwise - REVERSE):
  for (let i = segments; i >= 0; i--) {  // Note: counting DOWN
      angle = (i / segments) * 2π
      // Generates points going around circle counter-clockwise
      // Point segments: angle 0° (right) - same as start
      // Point 3*segments/4: angle 270° (bottom)
      // Point segments/2: angle 180° (left)
      // Point segments/4: angle 90° (top)
      // Point 0: angle 0° (right) - back to start
  }

Step 4: Bridge back to outer circle:
  // Connect inner circle back to outer
  // Draw line from inner circle (angle 0) back to outer circle (angle 0)
  Point: outer circle point at angle 0

Why bridges are needed:

  • Canvas paths must be continuous (no gaps)
  • Outer and inner circles are separate, need connection
  • Bridge connects them, creating one continuous path
  • Without bridge, you'd have two separate paths (won't work as hole)

Complete path structure:

Path for donut:
1. Outer circle points (0° to 360°, clockwise) - 64 points
2. Bridge point: Inner circle at 0°
3. Inner circle points (360° to 0°, counter-clockwise) - 64 points  
4. Bridge point: Outer circle at 0° (back to start)

Total: One continuous path with outer boundary and inner hole

Why this works:

  • Single continuous path (required by canvas/SVG)
  • Winding rule determines fill: outer area filled, inner area not filled
  • Reverse direction on inner path ensures correct winding count
  • Bridges create the connection without affecting the visual result

Alternative: Multiple paths (not used here):

  • Some systems support multiple paths (outer + inner as separate)
  • Canvas requires single path for holes (uses winding rule)
  • SVG supports both: single path with winding, or separate paths with CSS fill-rule

Example calculation:

Given: outerRadius = 50, innerRadius = 25, segments = 64

Outer circle (clockwise, i from 0 to 64):
  Point 0: angle = 0°, x = 50*cos(0°) = 50, y = 50*sin(0°) = 0
  Point 16: angle = 90°, x = 0, y = 50
  Point 32: angle = 180°, x = -50, y = 0
  Point 48: angle = 270°, x = 0, y = -50
  Point 64: angle = 360° = 0°, back to start

Bridge to inner:
  Point: inner at angle 0°: x = 25*cos(0°) = 25, y = 25*sin(0°) = 0

Inner circle (counter-clockwise, i from 64 to 0):
  Point 64: angle = 0°, x = 25, y = 0 (already added as bridge)
  Point 48: angle = 270° (going backwards), x = 0, y = -25
  Point 32: angle = 180°, x = -25, y = 0
  Point 16: angle = 90°, x = 0, y = 25
  Point 0: angle = 0°, x = 25, y = 0 (back to bridge point)

Bridge back to outer:
  Point: outer at angle 0°: x = 50, y = 0 (back to start)

Visual result:

  • Outer circle drawn first (clockwise) - creates filled boundary
  • Bridge connects to inner
  • Inner circle drawn second (counter-clockwise) - creates "subtraction"
  • Winding rule: Outer area has winding count 1 (odd) → filled
  • Winding rule: Inner area has winding count 2 (even) → not filled (hole)
  • Result: Donut shape with hole in center ✓

How Shapes Connect to the Interpreter

The interpreter doesn't use the shapes library directly. Instead:

  1. Interpreter creates shape objects:

    const shape = {
     type: 'circle',
     params: { radius: 50 },
     transform: { position: [100, 50], rotation: 0, scale: [1, 1] }
    };
    
  2. Renderer uses shape type to draw:

    switch (shape.type) {
     case 'circle':
         // Use canvas arc() or get points from shapes library
         break;
    }
    

Why not use shapes library directly? The interpreter creates shape objects with parameters. The renderer decides how to draw them. For simple shapes (circles, rectangles), the renderer uses canvas primitives. For complex shapes (stars, joints), it might use the shapes library to get points, then draw as a path.

DETAILED EXPLANATION OF SHAPE LIBRARY INTEGRATION: Understanding how the shapes library connects to the interpreter and renderer is crucial for the overall architecture. This separation of concerns makes the system flexible and maintainable.

The three-layer architecture:

Layer 1: Interpreter (creates shape data)

Interpreter's job:
- Parses code: "shape circle c1 { radius: 50 }"
- Creates shape object with parameters
- Stores in environment
- Doesn't know about rendering or geometry generation

Shape object created:
{
    type: 'circle',
    name: 'c1',
    params: { radius: 50, x: 0, y: 0 },
    transform: { position: [0, 0], rotation: 0, scale: [1, 1] }
}

Layer 2: Shapes Library (generates geometry)

Shapes library's job:
- Takes shape type and parameters
- Generates geometric points that define the shape
- Provides geometry, not rendering

Example:
const circle = new Circle(50);  // Create circle with radius 50
const points = circle.getPoints();  // Get array of 32 points around circle
// Returns: [{x: 50, y: 0}, {x: 49.24, y: 9.75}, ...]

Layer 3: Renderer (draws shapes)

Renderer's job:
- Takes shape objects from interpreter
- Uses shapes library to get geometry (if needed)
- Draws shapes on canvas
- Handles styling, transforms, selection, etc.

Example:
const shapeClass = new Circle(shape.params.radius);
const points = shapeClass.getPoints();
// Draw points as path on canvas

Why this separation?

1. Interpreter doesn't know about rendering:

  • Interpreter just creates data structures
  • Doesn't care how shapes are drawn (canvas, SVG, WebGL, etc.)
  • Can change rendering system without touching interpreter
  • Interpreter is pure logic, no visual dependencies

2. Shapes library doesn't know about rendering:

  • Shapes library just generates geometry (points)
  • Doesn't care about canvas, colors, styling, etc.
  • Pure geometry library - can be used for any purpose
  • Could be used for 3D rendering, path calculations, etc.

3. Renderer decides how to draw:

  • Renderer can optimize drawing per shape type
  • Simple shapes (circles, rectangles) → use canvas primitives (faster)
  • Complex shapes (stars, joints) → use points from library
  • Renderer controls quality vs performance trade-offs

Simple shape optimization:

// Renderer can optimize circles
if (shape.type === 'circle') {
    // Use canvas arc() - faster than drawing 32 points
    ctx.beginPath();
    ctx.arc(worldX, worldY, radius * scale, 0, Math.PI * 2);
    ctx.fill();
} else {
    // Use shapes library for complex shapes
    const shapeClass = getShapeClass(shape.type, shape.params);
    const points = shapeClass.getPoints();
    // Draw as path
}

Shape object format (from interpreter):

{
    type: 'circle',           // Shape type identifier
    name: 'c1',              // Shape name (for referencing)
    params: {                 // Shape-specific parameters
        radius: 50,          // Circle: radius
        x: 100,              // Optional: position override
        y: 50                // Optional: position override
    },
    transform: {              // Transform properties
        position: [100, 50], // World position
        rotation: 45,        // Rotation in degrees
        scale: [1, 1]        // Scale factors
    }
}

Converting shape object to shape class:

function getShapeClass(shape) {
    const params = shape.params;

    switch (shape.type) {
        case 'circle':
            return new Circle(params.radius || 50);

        case 'rectangle':
            return new Rectangle(
                params.width || 100,
                params.height || 100
            );

        case 'star':
            return new Star(
                params.outerRadius || 50,
                params.innerRadius || 25,
                params.points || 5
            );

        // ... etc for other shape types

        default:
            console.warn(`Unknown shape type: ${shape.type}`);
            return null;
    }
}

Complete rendering flow:

1. Interpreter creates shape object:
   { type: 'circle', params: { radius: 50 }, transform: {...} }

2. Renderer receives shape object:
   renderer.drawShape(shapeObject)

3. Renderer converts to shape class:
   const circle = new Circle(shapeObject.params.radius)

4. Renderer gets points:
   const points = circle.getPoints()  // Array of 32 points

5. Renderer applies transform:
   const worldPoints = points.map(p => 
       transformManager.applyTransform(p, shapeObject.transform)
   )

6. Renderer converts to screen coordinates:
   const screenPoints = worldPoints.map(p =>
       coordinateSystem.worldToScreen(p.x, p.y)
   )

7. Renderer draws on canvas:
   ctx.beginPath();
   screenPoints.forEach((p, i) => {
       if (i === 0) ctx.moveTo(p.x, p.y);
       else ctx.lineTo(p.x, p.y);
   });
   ctx.closePath();
   ctx.fill();

Benefits of this architecture:

1. Flexibility:

  • Can add new shape types without changing renderer
  • Can change rendering method without changing shapes
  • Can use shapes for non-rendering purposes (calculations, export, etc.)

2. Performance:

  • Renderer can optimize per shape type
  • Simple shapes use fast canvas primitives
  • Complex shapes use point-based rendering when needed

3. Testability:

  • Each layer can be tested independently
  • Shape library can be tested without renderer
  • Interpreter can be tested without visual output

4. Maintainability:

  • Clear separation of concerns
  • Changes in one layer don't affect others
  • Easy to understand and debug

Alternative approaches (and why we don't use them):

Alternative 1: Interpreter directly uses shape classes

Problems:
- Interpreter would need to import shapes library
- Couples interpreter to rendering concerns
- Can't test interpreter without shape classes
- Harder to optimize rendering later

Alternative 2: Renderer directly creates shape classes

Problems:
- Renderer needs to know all shape types
- Harder to add new shape types
- Shape creation logic scattered
- Less flexible

Why current approach is best:

  • Interpreter = Data creation (pure logic)
  • Shapes library = Geometry generation (pure math)
  • Renderer = Visualization (handles display)
  • Each has single responsibility, clean interfaces

Adding a New Shape Type

To add a new shape:

  1. Create the shape class:

    class MyNewShape extends Shape {
     constructor(param1, param2) {
         super();
         this.param1 = param1;
         this.param2 = param2;
     }
    
     getPoints() {
         const points = [];
         // Generate points
         return points.map(p => this.transformPoint(p));
     }
    }
    
  2. Export it:

    export { MyNewShape };
    
  3. Add interpreter support (in interpreter.mjs):

    case 'myNewShape':
     // Create shape object with params
     break;
    
  4. Add renderer support (in shapeRenderer.mjs):

    case 'myNewShape':
     // Draw the shape
     break;
    
  5. Add Blockly block (in blocks-umd.js):

    Blockly.defineBlocksWithJsonArray([{
     type: 'aqui_shape_myNewShape',
     // ... block definition
    }]);
    

The pattern: Shape class → Interpreter creates shape object → Renderer draws it → Blockly block for UI.

DETAILED STEP-BY-STEP GUIDE FOR ADDING A NEW SHAPE:

Step 1: Create the Shape Class

// File: src/Shapes.mjs

export class MyNewShape extends Shape {
    constructor(param1, param2) {
        // Step 1.1: Call parent constructor
        // This initializes position, rotation, and scale
        super();

        // Step 1.2: Store shape-specific parameters
        // These define the shape's geometry
        this.param1 = param1;
        this.param2 = param2;

        // Step 1.3: Validate parameters (optional but recommended)
        // Ensure parameters are valid to prevent errors later
        if (param1 <= 0) {
            throw new Error('param1 must be positive');
        }
        if (param2 <= 0) {
            throw new Error('param2 must be positive');
        }
    }

    // Step 1.4: Implement getPoints() method
    // This is the core method that defines the shape's geometry
    getPoints() {
        // Step 1.4.1: Initialize points array
        const points = [];

        // Step 1.4.2: Generate points based on shape's geometry
        // This is where you define how the shape looks
        // Example: Generate points in a pattern

        for (let i = 0; i < this.param1; i++) {
            const angle = (i / this.param1) * Math.PI * 2;
            const radius = this.param2;

            points.push({
                x: Math.cos(angle) * radius,
                y: Math.sin(angle) * radius
            });
        }

        // Step 1.4.3: Return points (will be transformed by parent class)
        // DON'T call transformPoint() here - parent class handles that
        return points;
    }

    // Step 1.5: Override getBounds() for efficiency (optional)
    // If you can calculate bounds faster than generic method, override it
    getBounds() {
        // Example: If shape is always circular, bounds are simple
        return {
            x: this.position.x - this.param2,
            y: this.position.y - this.param2,
            width: this.param2 * 2,
            height: this.param2 * 2
        };
    }
}

Step 2: Export the Shape Class

// File: src/Shapes.mjs (at the end)

// Add to exports
export { MyNewShape };

// Or if using default export pattern:
export default {
    Shape,
    Circle,
    Rectangle,
    Polygon,
    Star,
    MyNewShape  // Add your new shape here
};

Step 3: Add Interpreter Support

// File: src/interpreter.mjs

// In evaluateShape() method or similar:
case 'myNewShape':  // Shape type name in code
    // Step 3.1: Extract parameters from AST
    const param1 = this.evaluateExpression(node.params.param1 || {type: 'number', value: 50});
    const param2 = this.evaluateExpression(node.params.param2 || {type: 'number', value: 25});

    // Step 3.2: Create shape object
    // This is the data structure, not the shape class instance
    const shape = {
        type: 'myNewShape',
        id: `myNewShape_${node.name}_${Date.now()}`,
        params: {
            param1: param1,  // Store evaluated values
            param2: param2,
            // Include any other parameters
            x: this.evaluateExpression(node.params.x || {type: 'number', value: 0}),
            y: this.evaluateExpression(node.params.y || {type: 'number', value: 0})
        },
        transform: {
            position: [
                this.evaluateExpression(node.params.x || {type: 'number', value: 0}),
                this.evaluateExpression(node.params.y || {type: 'number', value: 0})
            ],
            rotation: this.evaluateExpression(node.params.rotation || {type: 'number', value: 0}),
            scale: [
                this.evaluateExpression(node.params.scaleX || node.params.scale?.[0] || {type: 'number', value: 1}),
                this.evaluateExpression(node.params.scaleY || node.params.scale?.[1] || {type: 'number', value: 1})
            ]
        },
        layerName: null
    };

    // Step 3.3: Add shape to environment
    this.env.addShape(node.name, shape);

    // Step 3.4: Return shape (for chaining or reference)
    return shape;

Step 4: Add Renderer Support

// File: src/renderer/shapeRenderer.mjs

// In drawShape() method or getShapeClass() method:

// Option A: Use shapes library (for complex shapes)
import { MyNewShape } from '../Shapes.mjs';

function getShapeClass(shape) {
    // ... existing cases ...

    case 'myNewShape':
        // Create shape class instance from shape object parameters
        return new MyNewShape(
            shape.params.param1 || 50,
            shape.params.param2 || 25
        );

    // ... other cases ...
}

// Option B: Optimize with canvas primitives (for simple shapes)
function drawShapeOptimized(shape, ctx, coordinateSystem) {
    switch (shape.type) {
        // ... existing cases ...

        case 'myNewShape':
            // If shape can be drawn with canvas primitives, do it here
            // Otherwise, use getShapeClass() and draw as path
            const shapeClass = getShapeClass(shape);
            const points = shapeClass.getPoints();
            // Draw points as path...
            break;
    }
}

Step 5: Add Parser Support

// File: src/parser.mjs

// In parseShape() method:
// Parser should already handle generic shape syntax:
//   shape myNewShape name { param1: value1, param2: value2 }

// But you may need to add validation:
parseShape() {
    // ... existing code ...

    // After parsing shape type:
    if (shapeType === 'myNewShape') {
        // Validate required parameters
        if (!params.param1) {
            this.error('myNewShape requires param1 parameter');
        }
        if (!params.param2) {
            this.error('myNewShape requires param2 parameter');
        }
    }

    // ... rest of parsing ...
}

Step 6: Add Blockly Block (Optional but Recommended)

// File: src/blocks-umd.js or src/blocks/shapes.js

// Step 6.1: Define the block
Blockly.defineBlocksWithJsonArray([
    {
        "type": "aqui_shape_myNewShape",
        "message0": "myNewShape %1 param1: %2 param2: %3",
        "args0": [
            {
                "type": "field_input",
                "name": "NAME",
                "text": "shape1"
            },
            {
                "type": "field_number",
                "name": "PARAM1",
                "value": 50,
                "min": 1
            },
            {
                "type": "field_number",
                "name": "PARAM2",
                "value": 25,
                "min": 1
            }
        ],
        "previousStatement": null,
        "nextStatement": null,
        "colour": 160,
        "tooltip": "Create a myNewShape shape"
    }
]);

// Step 6.2: Define code generator
Blockly.JavaScript['aqui_shape_myNewShape'] = function(block) {
    const name = block.getFieldValue('NAME');
    const param1 = block.getFieldValue('PARAM1');
    const param2 = block.getFieldValue('PARAM2');

    return `shape myNewShape ${name} {
  param1: ${param1}
  param2: ${param2}
  x: 0
  y: 0
}\n`;
};

// Step 6.3: Add to toolbox
// In app.js or where toolbox is defined:
{
    kind: 'category',
    name: 'Shapes',
    contents: [
        // ... existing blocks ...
        { kind: 'block', type: 'aqui_shape_myNewShape' }
    ]
}

Step 7: Test Your New Shape

// Test shape class directly:
const shape = new MyNewShape(50, 25);
const points = shape.getPoints();
console.log('Points:', points);  // Should generate points
console.log('Bounds:', shape.getBounds());  // Should calculate bounds

// Test with transform:
shape.position = { x: 100, y: 50 };
shape.rotation = 45;
const transformedPoints = shape.getPoints();
console.log('Transformed points:', transformedPoints);

// Test interpreter:
const code = `shape myNewShape test { param1: 50, param2: 25, x: 100, y: 50 }`;
const lexer = new Lexer(code);
const parser = new Parser(lexer);
const ast = parser.parse();
const interpreter = new Interpreter();
const result = interpreter.interpret(ast);
console.log('Shape created:', result.shapes.get('test'));

// Test rendering:
renderer.setShapes(result.shapes);
renderer.redraw();
// Shape should appear on canvas

Step 8: Add Documentation

// Add to shape documentation:
/**
 * MyNewShape - Creates a [description of shape]
 * 
 * Parameters:
 * @param {number} param1 - Description of param1 (e.g., number of points)
 * @param {number} param2 - Description of param2 (e.g., radius)
 * 
 * Example:
 * shape myNewShape example {
 *     param1: 8
 *     param2: 50
 *     x: 100
 *     y: 100
 * }
 */

Common mistakes when adding shapes:

1. Forgetting to export:

  • Shape class created but not exported → import fails
  • Fix: Add export keyword before class definition

2. Wrong parameter names:

  • Parser expects param1, but shape uses parameter1
  • Fix: Use consistent naming across all layers

3. Missing transform handling:

  • Shape points not transformed → shape appears at wrong position
  • Fix: Return untransformed points, let base class handle transform

4. Bounds calculation wrong:

  • Bounds don't account for rotation → selection/hit-test fails
  • Fix: Either override getBounds() correctly or use base class method

5. Interpreter parameter evaluation:

  • Parameters not evaluated → shape uses AST nodes instead of values
  • Fix: Call evaluateExpression() for all parameter values

6. Blockly block mismatch:

  • Block generates wrong code → parser can't parse it
  • Fix: Ensure generated code matches parser syntax exactly

Verification checklist:

  • [ ] Shape class extends Shape
  • [ ] getPoints() returns array of {x, y} objects
  • [ ] Shape class exported from Shapes.mjs
  • [ ] Interpreter case added for shape type
  • [ ] Renderer can create shape class instance
  • [ ] Renderer can draw shape (or uses library)
  • [ ] Parser can parse shape syntax
  • [ ] Blockly block defined (if using Blockly)
  • [ ] Blockly code generator matches parser syntax
  • [ ] Shape appears correctly on canvas
  • [ ] Shape can be transformed (moved, rotated, scaled)
  • [ ] Shape can be selected and manipulated
  • [ ] Shape works with boolean operations (if applicable)
  • [ ] Shape exports correctly (SVG/DXF)

Common Gotchas

Points must be in order: The points array defines the outline. They must be in order (clockwise or counter-clockwise). If they're random, you get a mess.

Holes need reverse winding: Inner paths (holes) should be in the opposite direction from outer paths. This makes the even-odd fill rule work.

Transform order matters: Scale → Rotate → Translate. Don't change the order.

Centering: Shapes should be centered at (0, 0) in local space. The transform moves them to world space.

Point density: More points = smoother curves but slower rendering. Find a balance. 32 segments for circles is usually good.

DETAILED EXPLANATION OF COMMON GOTCHAS AND TROUBLESHOOTING:

Gotcha 1: Points Must Be In Order

The Problem: If points are not in sequential order around the shape, the rendered path will be a jumbled mess of lines connecting random points.

What "in order" means:

  • Points must form a continuous path around the shape's perimeter
  • Each point should connect to the next point (and last to first)
  • Can go clockwise or counter-clockwise, but must be consistent

Visual example:

CORRECT ORDER (clockwise):
Point 0 ──→ Point 1 ──→ Point 2 ──→ Point 3 ──→ Point 0
  ↑                                            │
  └────────────────────────────────────────────┘
Forms a proper rectangle ✓

WRONG ORDER (random):
Point 0 ──→ Point 3 ──→ Point 1 ──→ Point 2 ──→ Point 0
  ↑                                            │
  └────────────────────────────────────────────┘
Forms a crossed/bow-tie shape ✗

Why order matters:

  • Canvas lineTo() draws lines between consecutive points
  • If points are out of order, lines cross and create incorrect shapes
  • Path fill uses the order to determine what's "inside" vs "outside"

How to ensure correct order:

  • Generate points sequentially as you trace the shape
  • For circles: Generate points in angular order (0° to 360°)
  • For rectangles: Generate corners in order (top-left → top-right → bottom-right → bottom-left)
  • For complex shapes: Trace the perimeter systematically

Testing point order:

// Visual test: Draw lines between consecutive points
const points = shape.getPoints();
ctx.strokeStyle = 'red';
ctx.beginPath();
points.forEach((p, i) => {
    if (i === 0) ctx.moveTo(p.x, p.y);
    else ctx.lineTo(p.x, p.y);
});
ctx.closePath();
ctx.stroke();
// Should see a clean outline, not crossed lines

Gotcha 2: Holes Need Reverse Winding

The Problem: For shapes with holes (like donuts), the inner path must go in the opposite direction from the outer path, or the hole won't be recognized as a hole.

Winding rule recap:

  • Even-odd fill rule: Count how many times a ray crosses the path
  • Odd count = filled, even count = not filled (hole)
  • Path direction affects which side counts as "inside"

Visual example:

OUTER PATH (clockwise):
    ┌─────────┐
    │         │
    │    ●    │  ← Ray crosses once (odd) → filled ✓
    │         │
    └─────────┘

INNER PATH (should be counter-clockwise):
    ┌─────┐
    │     │
    │  ●  │  ← Ray crosses outer (1) then inner (2) → even → hole ✓
    │     │
    └─────┘

If inner path also clockwise (WRONG):
    ┌─────┐
    │     │
    │  ●  │  ← Ray crosses outer (1) then inner (2, but wrong direction) → still filled ✗
    │     │
    └─────┘

How to ensure reverse winding:

  • Generate outer path in one direction (e.g., clockwise)
  • Generate inner path in opposite direction (e.g., counter-clockwise)
  • For circles: Outer goes 0° to 360°, inner goes 360° to 0°

Code example:

// Outer circle (clockwise)
for (let i = 0; i <= segments; i++) {
    angle = (i / segments) * 2π;  // 0 to 2π
    // Add points...
}

// Inner circle (counter-clockwise - REVERSE)
for (let i = segments; i >= 0; i--) {  // Note: counting DOWN
    angle = (i / segments) * 2π;  // 2π to 0 (reverse)
    // Add points...
}

Testing holes:

// Visual test: Fill should show hole
ctx.fillStyle = 'blue';
ctx.fill();  // Should fill outer area but not inner area
// If inner area also fills, winding is wrong

Gotcha 3: Transform Order Matters

The Problem: Changing the transform order (e.g., Translate → Rotate → Scale instead of Scale → Rotate → Translate) produces completely wrong results.

Why order matters (detailed): Each transform operation happens in a specific coordinate space. Changing the order changes which coordinate space each operation uses.

Example of wrong order:

CORRECT: Scale → Rotate → Translate
Point (10, 0):
  1. Scale 2x: (20, 0)
  2. Rotate 90°: (0, 20)
  3. Translate (100, 50): (100, 70)
Result: Point is 20 units above shape center ✓

WRONG: Translate → Rotate → Scale
Point (10, 0):
  1. Translate (100, 50): (110, 50)
  2. Rotate 90° around origin: (-50, 110)  ← Rotated around (0,0), not shape center!
  3. Scale 2x: (-100, 220)
Result: Point is in completely wrong location ✗

The fix:

  • ALWAYS use: Scale → Rotate → Translate
  • NEVER change this order
  • If you need different behavior, adjust the transforms, not the order

Gotcha 4: Centering Must Be Correct

The Problem: If shapes aren't centered at (0, 0) in local space, transforms (especially rotation) will behave incorrectly.

What centering means:

  • Shape's geometric center = (0, 0) in local coordinate space
  • All points defined relative to this center
  • Position transform moves this center to world position

How to check centering:

// Get bounds of shape in local space (before transform)
const bounds = shape.getBounds();
// Center should be at (0, 0) in local space
const centerX = bounds.x + bounds.width / 2;
const centerY = bounds.y + bounds.height / 2;
console.log('Center:', centerX, centerY);  // Should be close to (0, 0)

Common centering mistakes:

Mistake 1: Rectangle from (0,0) to (width, height)

// WRONG:
points = [
    {x: 0, y: 0},
    {x: width, y: 0},
    {x: width, y: height},
    {x: 0, y: height}
];
// Center is at (width/2, height/2), not (0, 0) ✗

// CORRECT:
points = [
    {x: -width/2, y: -height/2},
    {x: width/2, y: -height/2},
    {x: width/2, y: height/2},
    {x: -width/2, y: height/2}
];
// Center is at (0, 0) ✓

Mistake 2: Circle starting at (radius, 0) instead of centered

// Not really a mistake, but ensure points are around origin:
for (let i = 0; i < segments; i++) {
    angle = (i / segments) * 2π;
    // CORRECT: Points go from -radius to +radius in both directions
    points.push({
        x: Math.cos(angle) * radius,  // -radius to +radius
        y: Math.sin(angle) * radius   // -radius to +radius
    });
}
// Center is at (0, 0) ✓

Gotcha 5: Point Density Trade-offs

The Problem: Too few points = visible edges/polygon shape. Too many points = slow performance.

Point density guidelines:

Circles:

  • 8 segments: Visible polygon (octagon shape)
  • 16 segments: Slight edges visible at high zoom
  • 32 segments: Smooth for most cases (recommended) ✓
  • 64 segments: Very smooth, slower
  • 128+ segments: Only for very large shapes or high zoom

Rounded rectangles:

  • 4 segments per corner: Visible edges
  • 8 segments per corner: Smooth for most cases (recommended) ✓
  • 16 segments per corner: Very smooth corners

Stars/Polygons:

  • Points = number of vertices (can't change this)
  • But can add interpolation for smoother curves (advanced)

Performance impact:

32-point circle: ~0.01ms to generate points
64-point circle: ~0.02ms (2x slower)
128-point circle: ~0.04ms (4x slower)

With 100 shapes:
32 points each = 3,200 points total = fast
128 points each = 12,800 points total = 4x slower

Dynamic point density (advanced optimization):

// Adjust segments based on size and zoom
function getSegmentCount(radius, zoomLevel) {
    const screenRadius = radius * zoomLevel;
    // More segments for larger screen size
    if (screenRadius < 10) return 16;      // Small on screen
    if (screenRadius < 50) return 32;      // Medium (default)
    if (screenRadius < 200) return 64;     // Large
    return 128;                            // Very large
}

Balance recommendation:

  • Default: 32 segments for circles (good balance)
  • Increase to 64 if edges are visible at normal zoom
  • Decrease to 16 only for performance-critical scenarios
  • Use dynamic density for advanced optimization

Additional gotchas:

Gotcha 6: Transform Point Must Not Call Itself

// WRONG - infinite recursion:
transformPoint(point) {
    return this.transformPoint(this.transformPoint(point));  // Calls itself forever!
}

// CORRECT:
transformPoint(point) {
    // Apply transforms directly, don't call transformPoint recursively
    let x = point.x * this.scale.x;
    // ... etc
}

Gotcha 7: getPoints() Should Return Untransformed Points

// WRONG:
getPoints() {
    const points = [...];
    return points.map(p => this.transformPoint(p));  // Transforms here
}

// CORRECT:
getPoints() {
    const points = [...];  // Local space points
    return points;  // Return untransformed, base class handles transform
}

Gotcha 8: Bounds Must Account for Rotation

// WRONG (for rotated shapes):
getBounds() {
    return {
        x: this.position.x - this.radius,
        y: this.position.y - this.radius,
        width: this.radius * 2,
        height: this.radius * 2
    };
    // This doesn't account for rotation!
}

// CORRECT (generic method):
getBounds() {
    const points = this.getPoints();
    // Transform all points, then find min/max
    // This accounts for rotation correctly
}

Gotcha 9: NaN Values Break Everything

// Check for NaN in calculations:
if (isNaN(point.x) || isNaN(point.y)) {
    console.error('NaN in point calculation!');
    return {x: 0, y: 0};  // Fallback value
}

Gotcha 10: Negative Scale Can Mirror Shapes

// Scale of -1 mirrors the shape:
scale: {x: -1, y: 1}  // Mirrors horizontally (flips left-right)
scale: {x: 1, y: -1}  // Mirrors vertically (flips top-bottom)

// If you don't want mirroring, ensure scale is always positive:
this.scale = {
    x: Math.abs(scale.x),
    y: Math.abs(scale.y)
};

The shapes library is the geometry engine. It doesn't know about rendering, selection, or interaction. It just generates points. This separation makes it easy to add new shapes without touching the renderer.

Building the Transform Manager From Scratch

Transformations are everywhere in graphics. Position, rotation, scale - these are the three basic transforms. The transform manager handles all the math.

What Transformations Are

A transformation changes where and how a shape appears:

  • Position (translation): Moves the shape
  • Rotation: Rotates the shape around a point
  • Scale: Makes the shape bigger or smaller

Why a transform manager? The math is complex. Converting between coordinate systems, combining transforms, inverting transforms - it's easy to get wrong. The transform manager does it all correctly.

DETAILED EXPLANATION OF WHY WE NEED A TRANSFORM MANAGER: Transformation mathematics is non-trivial. Having a dedicated manager class centralizes this logic, ensures correctness, and makes the codebase maintainable.

Problems solved by transform manager:

1. Consistency:

  • All transform operations use the same code (no duplication)
  • Same order, same formulas, same edge case handling everywhere
  • If you fix a bug once, it's fixed everywhere
  • If you optimize once, everything benefits

2. Correctness:

  • Transform math is easy to get wrong (order matters, sign matters, etc.)
  • One place to test and verify correctness
  • Reduces bugs from copy-pasted transform code
  • Mathematical formulas are complex - easier to get right once

3. Maintainability:

  • Changes to transform logic happen in one place
  • Easy to find and fix bugs
  • Easy to add new transform operations
  • Easy to optimize (cache calculations, etc.)

4. Reusability:

  • Same transform code works for all shapes
  • Same code works for hit testing, rendering, constraints, etc.
  • Don't need to rewrite transform logic for each feature

What transform manager handles:

  1. Forward transforms: Local space → World space
  2. Inverse transforms: World space → Local space
  3. Transform combination: Applying multiple transforms in sequence
  4. Bounds transformation: Converting shape bounds to world space
  5. Coordinate system conversion: World ↔ Screen coordinates (with zoom/pan)
  6. Transform validation: Checking for invalid values (zero scale, etc.)

Without transform manager (problems):

// Code duplication everywhere:
// In renderer:
function renderShape(shape) {
    const rad = shape.rotation * Math.PI / 180;
    const cos = Math.cos(rad);
    // ... transform code duplicated ...
}

// In hit tester:
function hitTest(point, shape) {
    const rad = shape.rotation * Math.PI / 180;
    const cos = Math.cos(rad);
    // ... same transform code duplicated, might have bugs ...
}

// In constraint engine:
function getAnchorPosition(shape, anchor) {
    const rad = shape.rotation * Math.PI / 180;
    // ... same code again, might have different bugs ...
}

Problems:
- Transform code duplicated 3+ times
- Bugs in one place don't get fixed in others
- Changes need to be made in multiple places
- Hard to maintain and test

With transform manager (solution):

// One place for all transform code:
class TransformManager {
    applyTransform(point, transform) { /* correct implementation */ }
    applyInverseTransform(point, transform) { /* correct implementation */ }
    // ...
}

// Used everywhere:
// In renderer:
const transformed = transformManager.applyTransform(point, shape.transform);

// In hit tester:
const localPoint = transformManager.applyInverseTransform(worldPoint, shape.transform);

// In constraint engine:
const worldAnchor = transformManager.applyTransform(localAnchor, shape.transform);

Benefits:
- Single implementation (tested once, works everywhere)
- Fix bugs in one place, fixed everywhere
- Easy to optimize (cache calculations, etc.)
- Consistent behavior across all systems

Transform manager as abstraction layer:

  • Hides complexity: Other code doesn't need to know transform math
  • Provides simple API: applyTransform(point, transform) instead of manual math
  • Encapsulates details: Transform format, order, edge cases all hidden
  • Makes code readable: transform(point) is clearer than manual rotation matrix

Transform manager responsibilities:

  1. Validate inputs: Check for invalid values (null, undefined, zero scale)
  2. Handle edge cases: Zero rotation, zero scale, missing properties
  3. Optimize calculations: Cache sin/cos values, avoid redundant math
  4. Support multiple formats: Handle both {x,y} objects and [x,y] arrays
  5. Document behavior: Code comments explain the math and order

Transform manager is pure functions:

  • No state: Transform operations are stateless (same input = same output)
  • No side effects: Doesn't modify inputs, returns new values
  • Testable: Easy to write unit tests (input → expected output)
  • Thread-safe: Can be used in parallel (no shared state)

Benefits summary:

  • DRY principle: Don't Repeat Yourself - transform code in one place
  • Single Responsibility: Transform manager only handles transforms
  • Separation of Concerns: Transform logic separate from rendering/hit-testing
  • Testability: Can test transform logic independently
  • Performance: Can optimize transform operations in one place
  • Maintainability: Changes happen in one place, affect everything

Building the Basic Transform Structure From Scratch

A transform is just an object that stores position, rotation, and scale. Here's how to build it.

How to Build It Step by Step:

Step 1: Create the Transform Object Structure A transform is a simple object with three properties:

const transform = {
    // Step 1.1: Position (translation)
    // This moves the shape in world space
    // Array format [x, y] represents a 2D vector
    position: [x, y],      // Translation in world units

    // Step 1.2: Rotation
    // This rotates the shape around its center
    // Stored in degrees (users think in degrees, not radians)
    rotation: angle,       // Rotation in degrees

    // Step 1.3: Scale
    // This makes the shape bigger or smaller
    // Array format [sx, sy] allows different scaling in X and Y
    // [1, 1] = normal size, [2, 2] = double size, [0.5, 0.5] = half size
    scale: [sx, sy]        // Scale factors (1 = normal, 2 = double size)
};

Why Arrays for Position and Scale: They're 2D vectors. [x, y] is easier to work with than separate x and y properties. You can easily pass them to functions, iterate over them, and perform vector operations.

Why Rotation in Degrees: Canvas uses radians, but users think in degrees. We store degrees for user-friendliness, then convert to radians when needed for calculations.

Building This Step by Step:

  1. Create transform object with position array [x, y]
  2. Add rotation property in degrees
  3. Add scale array [sx, sy]
  4. This structure is used throughout the transform system

Applying a Transform

To transform a point:

applyTransform(transform, point) {
    const { position = [0, 0], rotation = 0, scale = [1, 1] } = transform;

    // Start with the point
    let x = point.x;
    let y = point.y;

    // Apply scale
    x *= scale[0];
    y *= scale[1];

    // Apply rotation
    if (rotation !== 0) {
        const rad = rotation * Math.PI / 180;
        const cos = Math.cos(rad);
        const sin = Math.sin(rad);
        const rotatedX = x * cos - y * sin;
        const rotatedY = x * sin + y * cos;
        x = rotatedX;
        y = rotatedY;
    }

    // Apply translation
    x += position[0];
    y += position[1];

    return { x, y };
}

The order: Scale → Rotate → Translate. This is standard. Scale and rotate in local space (around origin), then translate to world position.

Rotation math: x' = x*cos(θ) - y*sin(θ), y' = x*sin(θ) + y*cos(θ). This rotates a point around the origin.

Building the Inverse Transform Method From Scratch

Sometimes you need to go backwards - from world space to local space. This is the inverse of applying a transform.

Understanding Inverse Transforms:

An inverse transform reverses the forward transform. If forward transform converts local space → world space, then inverse transform converts world space → local space. This is essential for operations like:

  • Hit testing (convert world click position to local shape coordinates)
  • Constraint solving (work with shapes in their local coordinate space)
  • Editing handles (convert world handle position to local coordinates)

Why We Need Inverse Transforms:

When a user clicks on the canvas, we get a world coordinate (e.g., 100mm, 50mm). But to check if the click is inside a shape, we need to know the position relative to the shape's local coordinate system. We can't directly compare world coordinates to local shape coordinates - we need to convert the world coordinate to the shape's local space first.

Mathematical Relationship:

If forward transform is: P_world = T(P_local) Then inverse transform is: P_local = T⁻¹(P_world)

Where T⁻¹ is the inverse transformation that undoes T.

Key Insight: To reverse a transform, we must undo each operation in reverse order:

  • Forward: Scale → Rotate → Translate
  • Inverse: Undo Translate → Undo Rotate → Undo Scale

How to Build It Step by Step:

Step 1: Extract Transform Properties Start by extracting the transform properties with default values:

applyInverseTransform(transform, point) {
    // Step 1.1: Extract transform components with defaults
    // Default values ensure the function works even if transform is incomplete
    const { 
        position = [0, 0],    // Default: no translation
        rotation = 0,         // Default: no rotation
        scale = [1, 1]        // Default: normal scale (no scaling)
    } = transform;
    // Step 1.1: Extract transform properties with defaults
    const { position = [0, 0], rotation = 0, scale = [1, 1] } = transform;

    // Step 1.2: Start with world space point
    // This is the point we want to convert back to local space
    let x = point.x;
    let y = point.y;
}

Step 2: Remove Translation (Reverse Step 4) Remove the position offset first (opposite of adding it):

applyInverseTransform(transform, point) {
    const { position = [0, 0], rotation = 0, scale = [1, 1] } = transform;
    let x = point.x;
    let y = point.y;

    // Step 2.1: Remove translation
    // Subtract position offset (opposite of adding in applyTransform)
    x -= position[0];
    y -= position[1];
    // After this, point is back to rotated/scaled position
}

Step 3: Remove Rotation (Reverse Step 3) Rotate by the negative angle (opposite direction):

applyInverseTransform(transform, point) {
    const { position = [0, 0], rotation = 0, scale = [1, 1] } = transform;
    let x = point.x;
    let y = point.y;

    // Remove translation (step 2)
    x -= position[0];
    y -= position[1];

    // Step 3.1: Remove rotation (only if rotation is not zero)
    if (rotation !== 0) {
        // Step 3.2: Convert degrees to radians, but NEGATIVE
        // Rotating by negative angle undoes the original rotation
        const rad = -rotation * Math.PI / 180;  // Negative!

        // Step 3.3: Calculate cosine and sine
        const cos = Math.cos(rad);
        const sin = Math.sin(rad);

        // Step 3.4: Apply rotation matrix (same as forward, but with negative angle)
        const rotatedX = x * cos - y * sin;
        const rotatedY = x * sin + y * cos;
        x = rotatedX;
        y = rotatedY;
    }
}

Step 4: Remove Scale (Reverse Step 2) Divide by scale factors (opposite of multiplying):

applyInverseTransform(transform, point) {
    const { position = [0, 0], rotation = 0, scale = [1, 1] } = transform;
    let x = point.x;
    let y = point.y;

    // Remove translation (step 2)
    x -= position[0];
    y -= position[1];

    // Remove rotation (step 3)
    if (rotation !== 0) {
        const rad = -rotation * Math.PI / 180;
        const cos = Math.cos(rad);
        const sin = Math.sin(rad);
        const rotatedX = x * cos - y * sin;
        const rotatedY = x * sin + y * cos;
        x = rotatedX;
        y = rotatedY;
    }

    // Step 4.1: Remove scale
    // Divide by scale factors (opposite of multiplying in applyTransform)
    x /= scale[0];
    y /= scale[1];

    // Step 4.2: Return point in local space
    return { x, y };
}

The Complete Method:

applyInverseTransform(transform, point) {
    const { position = [0, 0], rotation = 0, scale = [1, 1] } = transform;

    // Start with world point
    let x = point.x;
    let y = point.y;

    // Remove translation
    x -= position[0];
    y -= position[1];

    // Remove rotation (rotate by negative angle)
    if (rotation !== 0) {
        const rad = -rotation * Math.PI / 180;  // Negative!
        const cos = Math.cos(rad);
        const sin = Math.sin(rad);
        const rotatedX = x * cos - y * sin;
        const rotatedY = x * sin + y * cos;
        x = rotatedX;
        y = rotatedY;
    }

    // Remove scale
    x /= scale[0];
    y /= scale[1];

    return { x, y };
}

Why Inverse Transform: When you click on a shape, you get a world coordinate. To test if the click is inside the shape, you need to convert to the shape's local space. That's where inverse transform comes in. It undoes the transform to get back to local coordinates.

The Reverse Order: The inverse applies operations in reverse order: Remove Translation → Remove Rotation → Remove Scale. This undoes the forward transform: Scale → Rotate → Translate.

DETAILED EXPLANATION OF INVERSE TRANSFORMS: Inverse transforms are used whenever you need to convert FROM world space back TO local space. This is essential for hit testing, selection, and any interaction that needs to work with the shape's original geometry.

What is an inverse transform?

  • Forward transform: Converts local space → world space (Scale → Rotate → Translate)
  • Inverse transform: Converts world space → local space (undoes the forward transform)
  • It's like running the forward transform in reverse

Why do we need inverse transforms?

Example 1: Hit Testing (click detection)

User clicks at world coordinate (150, 100)
Shape is at world position (100, 50) with rotation 45°
Question: Is the click inside the shape?

Solution:
1. Convert click point to shape's local space using inverse transform
2. Check if transformed point is inside the shape's local geometry
3. Shape's local geometry is defined at origin (0,0), so this check is simple

Example 2: Selection Handles

User drags a corner handle
Handle position is in world coordinates
But shape geometry is defined in local space
Need to convert handle position to local space to update shape correctly

Example 3: Constraint Anchors

Constraint system needs to know where anchor points are
Anchors are defined in local space (e.g., "center" = 0,0)
But to display them, need world coordinates
To calculate with them, need local coordinates
Constant conversion back and forth needed

The inverse transform process:

Forward transform order: Scale → Rotate → Translate Inverse transform order: Remove Translation → Remove Rotation → Remove Scale

Why reverse order?

  • Math principle: To undo operations, you must undo them in reverse order
  • Like unwrapping a gift: You remove the ribbon last (it was added last)
  • Forward: Scale first, Rotate second, Translate third
  • Inverse: Remove Translate first, Remove Rotate second, Remove Scale third

Step-by-step inverse transform:

Step 1: Remove Translation (undoes forward Step 3)

Forward transform added position: x += position.x, y += position.y
Inverse removes it: x -= position.x, y -= position.y

Example:
  World point: (150, 100)
  Shape position: (100, 50)
  After removing translation: (150-100, 100-50) = (50, 50)
  Point is now relative to shape's local origin

Step 2: Remove Rotation (undoes forward Step 2)

Forward transform rotated by +θ (counter-clockwise)
Inverse rotates by -θ (clockwise, opposite direction)

Key insight: Rotating by -θ undoes rotation by +θ
  Forward: Rotate 90° counter-clockwise
  Inverse: Rotate 90° clockwise (which is -90° counter-clockwise)

Example:
  After removing translation: (50, 50)
  Shape rotation: 90°
  Inverse rotation: -90° (or 270°)
  After removing rotation: Point rotated back to original orientation

Step 3: Remove Scale (undoes forward Step 1)

Forward transform multiplied by scale: x *= scale.x, y *= scale.y
Inverse divides by scale: x /= scale.x, y /= scale.y

Important: Must check for zero scale (division by zero error)
  If scale is 0, division is undefined
  Should return error or handle specially

Example:
  After removing rotation: (10, 20)
  Shape scale: {x: 2, y: 2}
  After removing scale: (10/2, 20/2) = (5, 10)
  Point is now back in original local space

Complete inverse transform example:

World point: (150, 100)
Shape transform: position (100, 50), rotation 90°, scale (2, 2)

Step 1 - Remove translation:
  (150, 100) - (100, 50) = (50, 50)

Step 2 - Remove rotation (rotate by -90°):
  Convert -90° to radians: -90 × (π/180) = -π/2
  cos(-90°) = 0, sin(-90°) = -1
  x' = 50×0 - 50×(-1) = 50
  y' = 50×(-1) + 50×0 = -50
  Result: (50, -50)

Step 3 - Remove scale (divide by 2):
  (50/2, -50/2) = (25, -25)

Final result: (25, -25) in local space

Verification: Apply forward transform to verify:

Local point: (25, -25)

Forward transform:
1. Scale: (25×2, -25×2) = (50, -50)
2. Rotate 90°: (50, 50) [rotated]
3. Translate: (50+100, 50+50) = (150, 100)

Result: (150, 100) ✓ Matches original world point!

Common use cases:

1. Hit testing (point-in-shape test):

function isPointInShape(worldPoint, shape) {
    // Convert world point to shape's local space
    const localPoint = applyInverseTransform(shape.transform, worldPoint);

    // Test if point is in shape's local geometry
    // (easier because shape is centered at origin)
    return isPointInLocalGeometry(localPoint, shape);
}

2. Converting handle positions:

function updateShapeFromHandle(worldHandlePos, shape) {
    // Convert handle to local space
    const localHandlePos = applyInverseTransform(shape.transform, worldHandlePos);

    // Update shape geometry using local coordinates
    shape.updateFromLocalHandle(localHandlePos);
}

3. Constraint calculations:

function getAnchorWorldPosition(shape, anchorName) {
    // Anchor is defined in local space (e.g., {x: 0, y: 0} for center)
    const localAnchor = shape.getLocalAnchor(anchorName);

    // Convert to world space
    return applyTransform(shape.transform, localAnchor);
}

function checkConstraint(worldPoint, shape, anchorName) {
    // Need both in same space - convert world point to local
    const localPoint = applyInverseTransform(shape.transform, worldPoint);
    const localAnchor = shape.getLocalAnchor(anchorName);

    // Now both in local space, can compare
    return distance(localPoint, localAnchor);
}

Edge cases and gotchas:

1. Scale of zero:

  • Forward: Multiplying by 0 collapses shape to point
  • Inverse: Dividing by 0 is undefined (error!)
  • Solution: Check for zero scale before dividing, return error or special value

2. Rotation wrapping:

  • Forward: 370° = 10° (wraps around)
  • Inverse: -10° = 350° (also wraps, but in opposite direction)
  • Both should work correctly if handled properly

3. Numerical precision:

  • Repeated forward/inverse transforms may accumulate errors
  • Floating-point math is not perfectly precise
  • May need to round results in some cases

Why inverse is necessary:

  • Without inverse transforms, you'd need separate geometry for every position/rotation
  • With inverse, one geometry definition works for all transforms
  • Makes the system efficient and maintainable

Building This Step by Step:

  1. Create applyInverseTransform() function
  2. Extract transform properties
  3. Start with world space point
  4. Remove translation (subtract position)
  5. Remove rotation (rotate by negative angle)
  6. Remove scale (divide by scale factors)
  7. Return point in local space

The order is reversed: Translate → Rotate → Scale, but in reverse. Undo translation first, then rotation, then scale.

Calculating Bounds

You need to know a shape's bounding box (for selection, handles, etc.):

calculateBounds(shape) {
    const { type, params } = shape;

    switch (type) {
        case 'circle':
            const radius = params.radius || 50;
            return {
                x: -radius,
                y: -radius,
                width: radius * 2,
                height: radius * 2
            };

        case 'rectangle':
            return {
                x: -(params.width || 100) / 2,
                y: -(params.height || 100) / 2,
                width: params.width || 100,
                height: params.height || 100
            };

        // ... etc for other shapes
    }
}

Bounds are in local space: The bounds are relative to the shape's center (0, 0). To get screen bounds, you transform the corners.

Why local space? It's simpler. The shape's size doesn't change when you move it. Only the transform changes.

DETAILED EXPLANATION OF BOUNDS CALCULATION: Bounds (bounding box) represent the smallest rectangle that completely contains a shape. This is essential for selection, collision detection, culling (skipping off-screen shapes), and rendering optimization.

What are bounds?

  • Bounds = Bounding Box = Axis-Aligned Bounding Box (AABB)
  • The smallest rectangle that completely contains the shape
  • Defined by: x (left edge), y (top edge), width, height
  • Always axis-aligned (edges parallel to X and Y axes)

Visual representation:

Shape (rotated rectangle):
        ╱───╲
       ╱     ╲
      ╱       ╲
     ╱    ●    ╲  ← Shape
    ╱           ╲
   ╱             ╲
  ╲             ╱
   ╲           ╱
    ╲─────────╱

Bounding box (bounds):
┌─────────────────┐
│                 │
│        ╱───╲    │
│       ╱     ╲   │
│      ╱       ╲  │
│     ╱    ●    ╲ │  ← Smallest rectangle containing shape
│    ╱           ╲│
│   ╱             ╲│
│  ╲             ╱│
│   ╲           ╱ │
│    ╲─────────╱  │
│                 │
└─────────────────┘

Why bounds matter:

  1. Selection: Quick test - if click is outside bounds, definitely not in shape
  2. Culling: Skip rendering shapes outside viewport (bounds outside screen)
  3. Collision detection: Fast initial test (if bounds don't overlap, shapes don't collide)
  4. Layout: Calculate where shapes are for UI layout
  5. Zoom to fit: Find bounds of all shapes to zoom to show everything

Local space vs world space bounds:

  • Local space bounds: Bounds relative to shape's center (0, 0)

    • Example: Rectangle width 100, height 50 → bounds: {x: -50, y: -25, width: 100, height: 50}
    • These bounds don't change when you move/rotate the shape
    • Only depend on shape's geometry (size, type)
  • World space bounds: Bounds in absolute world coordinates

    • Example: Shape at (100, 50) → world bounds: {x: 50, y: 25, width: 100, height: 50}
    • These bounds change when you move/rotate the shape
    • Need to transform local bounds or all shape points

Why calculate bounds in local space first?

  • Simpler: Shape geometry doesn't change, only transform changes
  • Efficient: Calculate once, transform when needed
  • Reusable: Same bounds calculation works for all instances of same shape type
  • Clean separation: Geometry (local) vs positioning (transform)

How to calculate bounds for different shapes:

1. Rectangle (simple):

Local bounds (no rotation):
  x = -width/2     (left edge)
  y = -height/2    (top edge)
  width = width    (full width)
  height = height  (full height)

Example: width=100, height=50
  → bounds: {x: -50, y: -25, width: 100, height: 50}

2. Circle (simple):

Local bounds:
  x = -radius      (left edge)
  y = -radius      (top edge)
  width = 2*radius (diameter)
  height = 2*radius (diameter)

Example: radius=50
  → bounds: {x: -50, y: -50, width: 100, height: 100}
  Note: Circle's bounds are always square (width = height)

3. Rotated rectangle (complex):

When rotated, bounds get bigger:
  - Transform all four corners to world space
  - Find min/max X and Y of transformed corners
  - That gives the bounding box

Example: 100×50 rectangle rotated 45°
  Original bounds: 100×50
  Rotated bounds: ~106×106 (larger to contain rotated shape)

4. Complex shapes (star, polygon, etc.):

Must check all points:
  - Get all points of the shape
  - Transform each point to world space
  - Find min X, max X, min Y, max Y
  - Bounds = {x: minX, y: minY, width: maxX-minX, height: maxY-minY}

This is the generic method that works for any shape

Transforming bounds to world space:

Method 1: Transform corners (accurate but more work):

function transformBounds(localBounds, transform) {
    // Get four corners of local bounds
    const corners = [
        {x: localBounds.x, y: localBounds.y},                    // Top-left
        {x: localBounds.x + localBounds.width, y: localBounds.y}, // Top-right
        {x: localBounds.x + localBounds.width, y: localBounds.y + localBounds.height}, // Bottom-right
        {x: localBounds.x, y: localBounds.y + localBounds.height} // Bottom-left
    ];

    // Transform each corner
    const transformedCorners = corners.map(c => applyTransform(transform, c));

    // Find bounding box of transformed corners
    let minX = Infinity, maxX = -Infinity;
    let minY = Infinity, maxY = -Infinity;

    for (const corner of transformedCorners) {
        minX = Math.min(minX, corner.x);
        maxX = Math.max(maxX, corner.x);
        minY = Math.min(minY, corner.y);
        maxY = Math.max(maxY, corner.y);
    }

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

Method 2: Approximate (faster but less accurate for rotated shapes):

function approximateWorldBounds(localBounds, transform) {
    // Simple approximation: transform center and scale size
    const center = {
        x: localBounds.x + localBounds.width / 2,
        y: localBounds.y + localBounds.height / 2
    };

    const worldCenter = applyTransform(transform, center);

    return {
        x: worldCenter.x - (localBounds.width * transform.scale[0]) / 2,
        y: worldCenter.y - (localBounds.height * transform.scale[1]) / 2,
        width: localBounds.width * transform.scale[0],
        height: localBounds.height * transform.scale[1]
    };
}
// Note: This only works if shape is not rotated!

Why transform all points for rotated shapes:

  • When a rectangle rotates 45°, its bounding box becomes larger
  • A 100×50 rectangle rotated 45° needs a ~106×106 box to contain it
  • The only way to know the exact size is to transform all corners and find min/max
  • Approximation methods don't account for rotation

Bounds optimization tips:

  1. Cache bounds: Calculate once, reuse until shape changes
  2. Invalidate on change: Mark bounds dirty when shape transforms change
  3. Lazy calculation: Only calculate when needed (don't calculate every frame)
  4. Shape-specific optimizations: Circles and axis-aligned rectangles have simpler bounds

Common bounds operations:

// Check if point is in bounds (fast pre-check before detailed hit test)
function isPointInBounds(point, bounds) {
    return point.x >= bounds.x &&
           point.x <= bounds.x + bounds.width &&
           point.y >= bounds.y &&
           point.y <= bounds.y + bounds.height;
}

// Check if two bounds overlap (for collision detection)
function boundsOverlap(bounds1, bounds2) {
    return !(bounds1.x + bounds1.width < bounds2.x ||
             bounds2.x + bounds2.width < bounds1.x ||
             bounds1.y + bounds1.height < bounds2.y ||
             bounds2.y + bounds2.height < bounds1.y);
}

// Combine bounds (for "zoom to fit" calculations)
function unionBounds(bounds1, bounds2) {
    const minX = Math.min(bounds1.x, bounds2.x);
    const minY = Math.min(bounds1.y, bounds2.y);
    const maxX = Math.max(bounds1.x + bounds1.width, bounds2.x + bounds2.width);
    const maxY = Math.max(bounds1.y + bounds1.height, bounds2.y + bounds2.height);

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

Bounds are your friend:

  • Use them for fast rejection (point outside bounds = definitely not in shape)
  • Use them for optimization (don't render shapes outside viewport)
  • Use them for UI (selection rectangles, handles, etc.)
  • They're simple rectangles, easy to work with mathematically

Transforming Bounds

When a shape is rotated, its bounding box changes:

transformBounds(bounds, transform) {
    // Get the four corners
    const corners = [
        { x: bounds.x, y: bounds.y },
        { x: bounds.x + bounds.width, y: bounds.y },
        { x: bounds.x + bounds.width, y: bounds.y + bounds.height },
        { x: bounds.x, y: bounds.y + bounds.height }
    ];

    // Transform each corner
    const transformedCorners = corners.map(corner => 
        this.applyTransform(transform, corner)
    );

    // Find the bounding box of transformed corners
    let minX = Infinity, maxX = -Infinity;
    let minY = Infinity, maxY = -Infinity;

    transformedCorners.forEach(corner => {
        minX = Math.min(minX, corner.x);
        maxX = Math.max(maxX, corner.x);
        minY = Math.min(minY, corner.y);
        maxY = Math.max(maxY, corner.y);
    });

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

Why transform corners? When you rotate a rectangle, it's no longer axis-aligned. The bounding box becomes bigger. By transforming the corners and finding the new min/max, you get the correct bounds.

Combining Transforms

Sometimes you need to apply one transform after another:

combineTransforms(transform1, transform2) {
    // Apply transform2 to transform1's position
    const combinedPosition = this.applyTransform(transform2, {
        x: transform1.position[0],
        y: transform1.position[1]
    });

    return {
        position: [combinedPosition.x, combinedPosition.y],
        rotation: transform1.rotation + transform2.rotation,
        scale: [
            transform1.scale[0] * transform2.scale[0],
            transform1.scale[1] * transform2.scale[1]
        ]
    };
}

Why combine? If you have a shape inside a group, the group has a transform, and the shape has a transform. To get the shape's world position, you combine them.

The math: Position gets transformed (translation + rotation + scale), rotation adds, scale multiplies.

DETAILED EXPLANATION OF COMBINING TRANSFORMS: Combining transforms is essential when you have nested transformations (like shapes in groups, or applying multiple transforms in sequence). Understanding how to combine them correctly is crucial.

When do you need to combine transforms?

  1. Grouped shapes: Group has a transform, shape inside group has its own transform
  2. Nested hierarchies: Parent object transforms, child objects have relative transforms
  3. Animation: Applying multiple transforms in sequence (move, then rotate, then scale)
  4. Transform chaining: User applies multiple transforms one after another

Example: Shape in a group:

Group transform:
  position: [100, 50]
  rotation: 45°
  scale: [1.5, 1.5]

Shape transform (relative to group):
  position: [20, 10]  (20mm right, 10mm up from group center)
  rotation: 30°
  scale: [0.8, 0.8]

Question: Where is the shape in world space?
Answer: Combine group transform + shape transform

How combining works:

Step 1: Combine rotations

Rotation is additive:
  combinedRotation = transform1.rotation + transform2.rotation

Example:
  Group rotation: 45°
  Shape rotation: 30°
  Combined: 45° + 30° = 75°

Why additive?
  - Rotating by 45°, then rotating by 30° = total rotation of 75°
  - Rotations are cumulative

Step 2: Combine scales

Scale is multiplicative:
  combinedScale.x = transform1.scale[0] * transform2.scale[0]
  combinedScale.y = transform1.scale[1] * transform2.scale[1]

Example:
  Group scale: [1.5, 1.5]
  Shape scale: [0.8, 0.8]
  Combined: [1.5*0.8, 1.5*0.8] = [1.2, 1.2]

Why multiplicative?
  - Scaling by 1.5x, then scaling by 0.8x = total scale of 1.2x
  - Scales compound (multiply together)

Visual:
  Original size: 100
  After group scale (1.5x): 150
  After shape scale (0.8x): 120
  Total: 100 * 1.5 * 0.8 = 120 ✓

Step 3: Combine positions (most complex)

Position requires full transform application:
  1. Start with transform1's position
  2. Apply transform2's full transform to it (scale, rotate, translate)
  3. Result is the combined position

Why this complexity?
  - Position is affected by rotation and scale
  - If group is rotated, shape's relative position rotates too
  - If group is scaled, shape's relative position scales too

Example:
  Group: position (100, 50), rotation 45°, scale (1.5, 1.5)
  Shape: position (20, 10) relative to group

  Step 1: Transform shape's position by group transform:
    - Scale: (20, 10) * (1.5, 1.5) = (30, 15)
    - Rotate 45°: (30, 15) rotated → (~21.2, ~31.8)
    - Translate: (~21.2, ~31.8) + (100, 50) = (~121.2, ~81.8)

  Combined position: (~121.2, ~81.8)

Complete combining algorithm:

function combineTransforms(transform1, transform2) {
    // Step 1: Combine rotations (additive)
    const combinedRotation = (transform1.rotation || 0) + (transform2.rotation || 0);

    // Step 2: Combine scales (multiplicative)
    const scale1 = transform1.scale || [1, 1];
    const scale2 = transform2.scale || [1, 1];
    const combinedScale = [
        scale1[0] * scale2[0],
        scale1[1] * scale2[1]
    ];

    // Step 3: Combine positions (apply transform2 to transform1's position)
    const pos1 = transform1.position || [0, 0];
    const combinedPosition = applyTransform(transform2, { x: pos1[0], y: pos1[1] });

    return {
        position: [combinedPosition.x, combinedPosition.y],
        rotation: combinedRotation,
        scale: combinedScale
    };
}

Order of application:

  • When combining transforms, apply transform2 AFTER transform1
  • This means: First apply transform1, then apply transform2 to the result
  • Combined transform = transform2(transform1(point))

Verification example:

Transform1: position (10, 20), rotation 30°, scale (2, 2)
Transform2: position (5, 5), rotation 15°, scale (0.5, 0.5)
Point: (1, 0) in local space

Method 1: Apply transforms separately:
  1. Apply transform1 to point:
     - Scale: (2, 0)
     - Rotate 30°: (~1.73, 1.0)
     - Translate: (~11.73, 21.0)

  2. Apply transform2 to result:
     - Scale: (~0.87, 0.5)
     - Rotate 15°: (~0.76, 0.58)
     - Translate: (~5.76, 5.58)

  Final: (~5.76, 5.58)

Method 2: Combine transforms first:
  Combined = combine(transform1, transform2)
  Apply combined to point: Should get same result (~5.76, 5.58) ✓

Common use cases:

1. Nested groups:

// Group A contains Group B, which contains Shape
const groupATransform = { position: [100, 100], rotation: 45°, scale: [2, 2] };
const groupBTransform = { position: [50, 0], rotation: 30°, scale: [0.5, 0.5] };
const shapeTransform = { position: [10, 10], rotation: 0°, scale: [1, 1] };

// Combine all three:
const combinedAB = combineTransforms(groupATransform, groupBTransform);
const finalTransform = combineTransforms(combinedAB, shapeTransform);

// Now can get shape's world position directly

2. Animation sequences:

// User applies transforms in sequence
let currentTransform = { position: [0, 0], rotation: 0°, scale: [1, 1] };

// User moves shape
const moveTransform = { position: [100, 50], rotation: 0°, scale: [1, 1] };
currentTransform = combineTransforms(currentTransform, moveTransform);

// User rotates shape
const rotateTransform = { position: [0, 0], rotation: 45°, scale: [1, 1] };
currentTransform = combineTransforms(currentTransform, rotateTransform);

// Final transform includes both move and rotate

3. Relative transforms:

// Shape's transform is relative to its parent
// To get absolute position, combine parent + child
const absoluteTransform = combineTransforms(parentTransform, childTransform);

Important properties:

  • Not commutative: combine(A, B) ≠ combine(B, A)

    • Transform order matters!
    • combine(move, rotate) ≠ combine(rotate, move)
  • Associative: combine(combine(A, B), C) = combine(A, combine(B, C))

    • Can combine in any order, result is same
  • Identity: combine(identity, T) = T

    • Combining with identity transform does nothing
    • Identity = {position: [0,0], rotation: 0, scale: [1,1]}

Performance considerations:

  • Combining transforms is computationally expensive (especially position combining)
  • Cache combined transforms if possible
  • Only combine when necessary (don't combine every frame if unchanged)
  • Pre-compute combined transforms for static hierarchies

Handling Resize with Handles

When you drag a corner handle, you need to resize the shape:

handleParameterScaling(shape, activeHandle, dx, dy, scaleFactor, shapeName, shapeManager) {
    // Convert screen delta to world delta
    const worldDX = dx * (1 / scaleFactor);
    const worldDY = dy * (1 / scaleFactor);

    // Get shape bounds
    const bounds = this.calculateBounds(shape);

    // Determine which corner was dragged
    let handleX, handleY;
    switch (activeHandle) {
        case 'tl': handleX = -bounds.width / 2; handleY = -bounds.height / 2; break;
        case 'tr': handleX = bounds.width / 2; handleY = -bounds.height / 2; break;
        case 'br': handleX = bounds.width / 2; handleY = bounds.height / 2; break;
        case 'bl': handleX = -bounds.width / 2; handleY = bounds.height / 2; break;
    }

    // Calculate new handle position
    const newHandleX = handleX + worldDX;
    const newHandleY = handleY + worldDY;

    // For rectangles: calculate new width/height
    const newWidth = Math.abs(newHandleX - (-bounds.width / 2)) * 2;
    const newHeight = Math.abs(newHandleY - (-bounds.height / 2)) * 2;

    // Update shape
    shapeManager.onCanvasShapeChange(shapeName, 'width', newWidth);
    shapeManager.onCanvasShapeChange(shapeName, 'height', newHeight);
}

How it works: The handle position tells you which corner. The delta (how far the mouse moved) tells you how much to resize. Calculate the new size, update the shape.

For circles: Calculate distance from center to new handle position. That's the new radius.

For rectangles: Calculate distance from opposite corner. That gives width and height.

DETAILED EXPLANATION OF RESIZE WITH HANDLES: Resizing shapes by dragging corner handles is a fundamental interaction. Understanding how to calculate new dimensions from handle movement is essential for implementing this feature.

What is a resize handle?

  • Small interactive markers at shape corners/edges
  • User drags handle to resize the shape
  • Handle position in screen coordinates, but resize affects world-space dimensions
  • Need to convert screen movement → world movement → new shape size

Resize process overview:

  1. User clicks and drags a corner handle
  2. Handle moves from old position to new position (delta)
  3. Convert screen delta to world delta (accounting for zoom)
  4. Determine which corner was dragged
  5. Calculate new shape dimensions based on handle movement
  6. Update shape with new dimensions

Step-by-step resize algorithm:

Step 1: Convert screen coordinates to world coordinates

User drags handle from screen position (200, 150) to (250, 180)
Screen delta: dx = 50 pixels, dy = 30 pixels

But shape dimensions are in world units (millimeters), not pixels!
Need to convert screen pixels to world millimeters.

Conversion requires zoom level:
  worldDeltaX = screenDeltaX / zoomLevel
  worldDeltaY = screenDeltaY / zoomLevel

Example with zoom = 2 (zoomed in 2x):
  worldDeltaX = 50 / 2 = 25mm
  worldDeltaY = 30 / 2 = 15mm

User moved handle 25mm right, 15mm down in world space

Step 2: Determine which corner was dragged

Rectangle has 4 corners:
  - Top-left (tl): (-width/2, -height/2)
  - Top-right (tr): (+width/2, -height/2)
  - Bottom-right (br): (+width/2, +height/2)
  - Bottom-left (bl): (-width/2, +height/2)

Handle identifier tells us which corner:
  activeHandle = 'br' → bottom-right corner

For bottom-right corner:
  handleX = width/2   (right edge)
  handleY = height/2  (bottom edge)

Step 3: Calculate new handle position

Original handle position (in local space):
  handleX = width/2 = 50
  handleY = height/2 = 25

User dragged handle by world delta:
  worldDeltaX = 25mm (moved right)
  worldDeltaY = 15mm (moved down)

New handle position:
  newHandleX = handleX + worldDeltaX = 50 + 25 = 75
  newHandleY = handleY + worldDeltaY = 25 + 15 = 40

Step 4: Calculate new dimensions

For rectangle, dimensions depend on opposite corner:

Bottom-right corner dragged:
  Opposite corner is top-left at: (-width/2, -height/2)

New width = distance from opposite corner to new handle X
  newWidth = newHandleX - (-width/2) = newHandleX + width/2
  But wait - that's not quite right!

Actually, for centered rectangle:
  Left edge is at: -width/2
  Right edge is at: +width/2
  Width = rightEdge - leftEdge = 2 * rightEdge (if centered)

If bottom-right handle moved to (newHandleX, newHandleY):
  newWidth = 2 * newHandleX
  newHeight = 2 * newHandleY

Example:
  Original: width=100, height=50
  Bottom-right handle at: (50, 25)

  User drags to: (75, 40)
  newWidth = 2 * 75 = 150
  newHeight = 2 * 40 = 80

  Shape grew from 100×50 to 150×80 ✓

Different corners, different calculations:

Top-left corner dragged:
  newWidth = 2 * abs(newHandleX) = 2 * abs(-width/2 + deltaX)
  newHeight = 2 * abs(newHandleY) = 2 * abs(-height/2 + deltaY)

Top-right corner dragged:
  newWidth = 2 * abs(newHandleX) = 2 * abs(width/2 + deltaX)
  newHeight = 2 * abs(newHandleY) = 2 * abs(-height/2 + deltaY)

Bottom-right corner dragged:
  newWidth = 2 * abs(newHandleX) = 2 * abs(width/2 + deltaX)
  newHeight = 2 * abs(newHandleY) = 2 * abs(height/2 + deltaY)

Bottom-left corner dragged:
  newWidth = 2 * abs(newHandleX) = 2 * abs(-width/2 + deltaX)
  newHeight = 2 * abs(newHandleY) = 2 * abs(height/2 + deltaY)

For circles (different approach):

Circle has radius, not width/height.
Handles are at the edge of the circle.

Calculate distance from center to new handle position:
  originalHandleDistance = radius (handle is on circle edge)
  newHandlePosition = handle position after drag
  newRadius = distance(center, newHandlePosition)

Formula:
  center = (0, 0) in local space
  handleDelta = (deltaX, deltaY) in world space
  originalHandlePos = (radius, 0) [handle at right edge]
  newHandlePos = (radius + deltaX, 0 + deltaY)

  newRadius = sqrt(newHandlePos.x² + newHandlePos.y²)
            = sqrt((radius + deltaX)² + deltaY²)

Example:
  Original radius: 50
  User drags handle: deltaX = 20, deltaY = 10
  newHandlePos = (70, 10)
  newRadius = sqrt(70² + 10²) = sqrt(4900 + 100) = sqrt(5000) ≈ 70.7

  Circle grew from radius 50 to radius 70.7 ✓

Important considerations:

1. Minimum size:

// Prevent shapes from becoming too small or negative
const MIN_SIZE = 1; // Minimum 1mm

newWidth = Math.max(MIN_SIZE, newWidth);
newHeight = Math.max(MIN_SIZE, newHeight);

2. Constrain aspect ratio (optional):

// If Shift key held, maintain aspect ratio
if (event.shiftKey) {
    // Use the larger delta to maintain square/equal scaling
    const maxDelta = Math.max(Math.abs(deltaX), Math.abs(deltaY));
    newWidth = width + (maxDelta * Math.sign(deltaX)) * 2;
    newHeight = height + (maxDelta * Math.sign(deltaY)) * 2;
}

3. Coordinate system conversion:

// Handle is in screen coordinates
// Shape dimensions are in world coordinates
// Must convert properly!

const screenDelta = { x: newScreenX - oldScreenX, y: newScreenY - oldScreenY };
const worldDelta = coordinateSystem.screenToWorldDelta(screenDelta.x, screenDelta.y);

4. Handle snapping (optional):

// Snap to grid or other shapes
const snappedDelta = snapToGrid(worldDelta, gridSize);
const snappedHandlePos = calculateHandlePosWithSnap(handlePos, snappedDelta);

Complete resize handler example:

function handleResize(shape, activeHandle, screenDeltaX, screenDeltaY, zoomLevel) {
    // Step 1: Convert to world coordinates
    const worldDeltaX = screenDeltaX / zoomLevel;
    const worldDeltaY = screenDeltaY / zoomLevel;

    // Step 2: Get current handle position (local space)
    const bounds = calculateBounds(shape);
    let handleX, handleY;

    switch (activeHandle) {
        case 'tl': handleX = -bounds.width/2; handleY = -bounds.height/2; break;
        case 'tr': handleX = bounds.width/2; handleY = -bounds.height/2; break;
        case 'br': handleX = bounds.width/2; handleY = bounds.height/2; break;
        case 'bl': handleX = -bounds.width/2; handleY = bounds.height/2; break;
    }

    // Step 3: Calculate new handle position
    const newHandleX = handleX + worldDeltaX;
    const newHandleY = handleY + worldDeltaY;

    // Step 4: Calculate new dimensions
    let newWidth, newHeight;

    if (activeHandle === 'br' || activeHandle === 'tl') {
        // Opposite corners: calculate distance
        const oppositeX = activeHandle === 'br' ? -bounds.width/2 : bounds.width/2;
        const oppositeY = activeHandle === 'br' ? -bounds.height/2 : bounds.height/2;

        newWidth = Math.abs(newHandleX - oppositeX);
        newHeight = Math.abs(newHandleY - oppositeY);
    } else {
        // Adjacent corners: similar but different math
        // (handled similarly)
    }

    // Step 5: Enforce minimum size
    newWidth = Math.max(1, newWidth);
    newHeight = Math.max(1, newHeight);

    // Step 6: Update shape
    shapeManager.updateShapeParameter(shapeName, 'width', newWidth);
    shapeManager.updateShapeParameter(shapeName, 'height', newHeight);
}

Why this works:

  • Handles represent the shape's bounds corners
  • Moving a handle changes the distance to opposite corner
  • That distance is the new dimension
  • Updating dimensions updates the shape geometry
  • Transform system handles repositioning automatically

Common Gotchas

Transform order matters: Always Scale → Rotate → Translate. Changing the order gives wrong results.

Degrees vs radians: Store degrees, convert to radians for math. rotation * Math.PI / 180.

Bounds are local: Bounds are in the shape's local space. Transform them to get screen bounds.

Scale can't be zero: Division by zero breaks things. Always check scale > 0.

Rotation wraps: rotation % 360 to keep it in range. Negative rotations need + 360.

The transform manager is pure math. It doesn't know about rendering or interaction. It just does transformations correctly. This makes it reusable - any system that needs transforms can use it.

How to Build the Shapes Library and Transform Manager - Complete Step-by-Step Guide

This section provides a complete guide for building the shapes library and transform manager from scratch.

Prerequisites

Before building shapes and transforms, you need:

  • Understanding of 2D geometry (points, vectors, transformations)
  • Basic trigonometry (sin, cos, rotation)
  • Understanding of coordinate systems

Part 1: Building the Base Shape Class

Step 1.1: Create the Base Shape Class

File: src/Shapes.mjs

What You're Building: The base Shape class that all shape types inherit from. This class provides the foundation for position, rotation, scale, and point transformation. It defines the common interface that all shapes must implement.

Why This Class: All shapes share common properties (position, rotation, scale) and need to transform points. By creating a base class, we avoid code duplication and ensure all shapes work consistently. The base class provides default implementations that can be overridden by specific shape types.

How to Build It Step by Step:

Step 1.1.1: Create the Class and Constructor Start with the class definition and initialize transform properties:

export class Shape {
  constructor() {
    // Step 1.1.1.1: Initialize position
    // Position is the shape's location in world coordinates
    // Default is origin (0, 0)
    this.position = { x: 0, y: 0 };

    // Step 1.1.1.2: Initialize rotation
    // Rotation is in degrees (0-360)
    // Default is 0 (no rotation)
    this.rotation = 0;  // Degrees

    // Step 1.1.1.3: Initialize scale
    // Scale affects the size of the shape
    // Default is 1,1 (no scaling)
    this.scale = { x: 1, y: 1 };
  }

Why These Properties:

  • position: Every shape needs a location. This is the center point of the shape in world coordinates.
  • rotation: Shapes can be rotated. Stored in degrees for human readability, converted to radians when needed.
  • scale: Shapes can be scaled independently on X and Y axes. Default of 1,1 means no scaling.

Step 1.1.2: Implement getPoints() Method This method returns the points that define the shape's geometry:

  // Step 1.1.2.1: Get points that define the shape
  // This is a template method - subclasses override it
  // Returns points in local coordinate space (before transforms)
  getPoints() {
    return [];
  }

Why Template Method: Each shape type (circle, rectangle, etc.) has different geometry. The base class provides an empty implementation that subclasses override. Points are returned in local space (relative to shape center) before transforms are applied.

Step 1.1.3: Implement transformPoint() Method This method applies all transforms to a point in the correct order:

  // Step 1.1.3.1: Transform a point by this shape's transform
  // Order matters: scale → rotate → translate
  transformPoint(point) {
    // Step 1.1.3.2: Apply scale first
    // Scale happens in local space (before rotation)
    let x = point.x * this.scale.x;
    let y = point.y * this.scale.y;

    // Step 1.1.3.3: Apply rotation
    // Convert degrees to radians for Math functions
    const rad = (this.rotation * Math.PI) / 180;
    const cos = Math.cos(rad);
    const sin = Math.sin(rad);

    // Step 1.1.3.4: Rotate point using rotation matrix
    // Rotation matrix: [cos -sin] [x]
    //                  [sin  cos] [y]
    const rotatedX = x * cos - y * sin;
    const rotatedY = x * sin + y * cos;

    // Step 1.1.3.5: Apply translation last
    // Translation moves the point to world coordinates
    return {
      x: rotatedX + this.position.x,
      y: rotatedY + this.position.y
    };
  }

Why This Transform Order: The order is critical: scale → rotate → translate. This ensures:

  1. Scale happens in local space (shape scales around its center)
  2. Rotation happens after scaling (shape rotates around its center)
  3. Translation happens last (moves the entire transformed shape)

If we did translation first, rotation would happen around the origin, not the shape's center.

Step 1.1.4: Implement getBounds() Method This method calculates the bounding box of the shape:

  // Step 1.1.4.1: Get bounding box
  // Returns the smallest rectangle that contains the shape
  getBounds() {
    // Step 1.1.4.2: Get all points of the shape
    const points = this.getPoints();

    // Step 1.1.4.3: Handle empty shapes
    if (points.length === 0) {
      return { x: 0, y: 0, width: 0, height: 0 };
    }

    // Step 1.1.4.4: Initialize min/max with first point
    // We'll find the bounding box by tracking extremes
    let minX = points[0].x;
    let maxX = points[0].x;
    let minY = points[0].y;
    let maxY = points[0].y;

    // Step 1.1.4.5: Find min/max by checking all transformed points
    // We must transform points first to account for rotation/scale
    for (const point of points) {
      const transformed = this.transformPoint(point);
      minX = Math.min(minX, transformed.x);
      maxX = Math.max(maxX, transformed.x);
      minY = Math.min(minY, transformed.y);
      maxY = Math.max(maxY, transformed.y);
    }

    // Step 1.1.4.6: Return bounding box
    return {
      x: minX,
      y: minY,
      width: maxX - minX,
      height: maxY - minY
    };
  }
}

Why Transform Points First: We must transform points before calculating bounds. A rotated rectangle has different bounds than an unrotated one. By transforming each point first, we get accurate bounds that account for all transforms.

The Complete Class:

export class Shape {
  constructor() {
    this.position = { x: 0, y: 0 };
    this.rotation = 0;  // Degrees
    this.scale = { x: 1, y: 1 };
  }

  // Get points that define the shape (to be overridden)
  getPoints() {
    return [];
  }

  // Transform a point by this shape's transform
  transformPoint(point) {
    // Apply scale
    let x = point.x * this.scale.x;
    let y = point.y * this.scale.y;

    // Apply rotation
    const rad = (this.rotation * Math.PI) / 180;
    const cos = Math.cos(rad);
    const sin = Math.sin(rad);
    const rotatedX = x * cos - y * sin;
    const rotatedY = x * sin + y * cos;

    // Apply translation
    return {
      x: rotatedX + this.position.x,
      y: rotatedY + this.position.y
    };
  }

  // Get bounding box
  getBounds() {
    const points = this.getPoints();
    if (points.length === 0) {
      return { x: 0, y: 0, width: 0, height: 0 };
    }

    let minX = points[0].x;
    let maxX = points[0].x;
    let minY = points[0].y;
    let maxY = points[0].y;

    for (const point of points) {
      const transformed = this.transformPoint(point);
      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
    };
  }
}

Building This Step by Step:

  1. Create new file src/Shapes.mjs
  2. Export Shape class
  3. Create constructor that initializes position, rotation, and scale
  4. Set default position to {x: 0, y: 0}
  5. Set default rotation to 0 degrees
  6. Set default scale to {x: 1, y: 1}
  7. Create getPoints() method that returns empty array (template method)
  8. Create transformPoint() method
  9. Apply scale first (multiply by scale.x and scale.y)
  10. Convert rotation to radians
  11. Calculate cos and sin of rotation angle
  12. Apply rotation matrix to get rotated coordinates
  13. Apply translation by adding position
  14. Create getBounds() method
  15. Get points from getPoints()
  16. Handle empty points case
  17. Initialize min/max with first point
  18. Loop through all points, transform each, track min/max
  19. Return bounding box with x, y, width, height
  20. This base class provides the foundation for all shapes

Test:

const shape = new Shape();
shape.position = { x: 10, y: 20 };
const point = shape.transformPoint({ x: 5, y: 5 });
console.log(point); // Should be transformed point

Part 2: Building Simple Shapes

Step 2.1: Build the Circle Class

What You're Building: A Circle class that extends the base Shape class. Circles are defined by a radius and generate points around their circumference. This class implements the circle-specific geometry while inheriting transform functionality from the base class.

Why This Class: Circles are a fundamental shape type. They need special handling because they're defined by radius (not width/height) and can be efficiently represented with a simpler bounds calculation than the generic getBounds() method.

How to Build It Step by Step:

Step 2.1.1: Create the Circle Class and Constructor Start by extending the base Shape class:

export class Circle extends Shape {
  constructor(radius = 50) {
    // Step 2.1.1.1: Call parent constructor
    // This initializes position, rotation, and scale
    super();

    // Step 2.1.1.2: Store radius
    // Radius is the distance from center to edge
    // Default is 50 units
    this.radius = radius;
  }

Why Extend Shape: By extending Shape, the Circle class automatically gets position, rotation, scale, and transformPoint() functionality. We only need to implement circle-specific geometry.

Step 2.1.2: Implement getPoints() Method Generate points around the circle's circumference:

  getPoints() {
    // Step 2.1.2.1: Initialize points array
    // We'll generate points around the circumference
    const points = [];

    // Step 2.1.2.2: Set number of segments
    // More segments = smoother circle, but more points
    // 32 segments is a good balance (smooth enough, not too many points)
    const segments = 32;  // Number of points

    // Step 2.1.2.3: Generate points around circle
    // Loop through each segment
    for (let i = 0; i < segments; i++) {
      // Step 2.1.2.4: Calculate angle for this segment
      // Divide full circle (2π) by number of segments
      // Each point is evenly spaced around the circle
      const angle = (i / segments) * Math.PI * 2;

      // Step 2.1.2.5: Calculate point position using trigonometry
      // x = radius * cos(angle)
      // y = radius * sin(angle)
      // This gives us a point on the circle's circumference
      points.push({
        x: Math.cos(angle) * this.radius,
        y: Math.sin(angle) * this.radius
      });
    }

    // Step 2.1.2.6: Return all points
    return points;
  }

Why Generate Points: Even though circles are smooth curves, we need discrete points for rendering and bounds calculation. We generate evenly-spaced points around the circumference. The number of segments (32) provides a good balance between smoothness and performance.

Step 2.1.3: Override getBounds() for Efficiency Circles have a simpler bounds calculation:

  // Step 2.1.3.1: Override getBounds for efficiency
  // For circles, we can calculate bounds directly without transforming all points
  // A circle's bounds is always a square centered at position
  getBounds() {
    return {
      // Step 2.1.3.2: Calculate bounds
      // Top-left corner is position minus radius
      x: this.position.x - this.radius,
      y: this.position.y - this.radius,
      // Width and height are both diameter (radius * 2)
      width: this.radius * 2,
      height: this.radius * 2
    };
  }
}

Why Override getBounds: For circles, we can calculate bounds directly without transforming all points. A circle's bounding box is always a square (width = height = diameter), regardless of rotation. This is much more efficient than the generic method that transforms all points.

Important Note: This simple bounds calculation doesn't account for scale. If you need scaled circles, you'd multiply radius by scale factors. For now, this assumes scale is handled elsewhere.

The Complete Class:

export class Circle extends Shape {
  constructor(radius = 50) {
    super();
    this.radius = radius;
  }

  getPoints() {
    // For circles, return points around the circumference
    const points = [];
    const segments = 32;  // Number of points

    for (let i = 0; i < segments; i++) {
      const angle = (i / segments) * Math.PI * 2;
      points.push({
        x: Math.cos(angle) * this.radius,
        y: Math.sin(angle) * this.radius
      });
    }

    return points;
  }

  // Override getBounds for efficiency
  getBounds() {
    return {
      x: this.position.x - this.radius,
      y: this.position.y - this.radius,
      width: this.radius * 2,
      height: this.radius * 2
    };
  }
}

Building This Step by Step:

  1. Add Circle class to src/Shapes.mjs (same file as Shape)
  2. Extend Shape class using extends Shape
  3. Create constructor with radius parameter (default 50)
  4. Call super() to initialize parent class
  5. Store radius as instance property
  6. Override getPoints() method
  7. Initialize empty points array
  8. Set segments to 32 (good balance of smoothness/performance)
  9. Add for loop from 0 to segments
  10. Calculate angle for each segment (i / segments * 2π)
  11. Calculate x using Math.cos(angle) * radius
  12. Calculate y using Math.sin(angle) * radius
  13. Push point to array
  14. Return points array
  15. Override getBounds() method
  16. Calculate x as position.x - radius
  17. Calculate y as position.y - radius
  18. Calculate width as radius * 2
  19. Calculate height as radius * 2
  20. Return bounds object
  21. This class provides circle geometry with efficient bounds calculation

Step 2.2: Build the Rectangle Class

What You're Building: A Rectangle class that extends the base Shape class. Rectangles are defined by width and height. The rectangle is centered at the origin in local space, with corners at ±halfWidth and ±halfHeight.

Why This Class: Rectangles are fundamental shapes. They're defined by width and height (not radius like circles). The rectangle is centered at the origin, so transforms (rotation, scale) happen around the center, not a corner.

How to Build It Step by Step:

Step 2.2.1: Create the Rectangle Class and Constructor Start by extending the base Shape class:

export class Rectangle extends Shape {
  constructor(width = 100, height = 100) {
    // Step 2.2.1.1: Call parent constructor
    // This initializes position, rotation, and scale
    super();

    // Step 2.2.1.2: Store width and height
    // These define the rectangle's dimensions
    // Default is 100x100 (square)
    this.width = width;
    this.height = height;
  }

Why Width and Height: Rectangles are defined by their dimensions. Unlike circles (which use radius), rectangles need both width and height. The default of 100x100 creates a square.

Step 2.2.2: Implement getPoints() Method Return the four corners of the rectangle:

  getPoints() {
    // Step 2.2.2.1: Calculate half dimensions
    // We center the rectangle at origin, so corners are at ±halfWidth/Height
    const halfWidth = this.width / 2;
    const halfHeight = this.height / 2;

    // Step 2.2.2.2: Return four corner points
    // Rectangle is centered at origin, so:
    // - Top-left: (-halfWidth, -halfHeight)
    // - Top-right: (halfWidth, -halfHeight)
    // - Bottom-right: (halfWidth, halfHeight)
    // - Bottom-left: (-halfWidth, halfHeight)
    return [
      { x: -halfWidth, y: -halfHeight },  // Top-left
      { x: halfWidth, y: -halfHeight },   // Top-right
      { x: halfWidth, y: halfHeight },    // Bottom-right
      { x: -halfWidth, y: halfHeight }    // Bottom-left
    ];
  }

Why Center at Origin: By centering the rectangle at the origin, rotation and scaling happen around the center, not a corner. This is the expected behavior - when you rotate a rectangle, it rotates around its center.

Step 2.2.3: Override getBounds() Method Calculate bounds accounting for transforms:

  getBounds() {
    // Step 2.2.3.1: Calculate half dimensions
    const halfWidth = this.width / 2;
    const halfHeight = this.height / 2;

    // Step 2.2.3.2: Transform one corner
    // We transform the top-left corner to see where it ends up
    // (We could transform all corners, but this gives us a starting point)
    const transformed = this.transformPoint({ x: -halfWidth, y: -halfHeight });

    // Step 2.2.3.3: Return bounds
    // For simplicity, we use the transformed corner as the top-left
    // and apply scale to width/height
    // Note: This is approximate - for rotated rectangles, we'd need all corners
    return {
      x: transformed.x,
      y: transformed.y,
      width: this.width * this.scale.x,
      height: this.height * this.scale.y
    };
  }
}

Why This Bounds Calculation: This is a simplified bounds calculation. For non-rotated rectangles, it's accurate. For rotated rectangles, we'd need to transform all four corners and find the min/max. This version assumes minimal rotation or uses the base class's getBounds() for accuracy.

The Complete Class:

export class Rectangle extends Shape {
  constructor(width = 100, height = 100) {
    super();
    this.width = width;
    this.height = height;
  }

  getPoints() {
    const halfWidth = this.width / 2;
    const halfHeight = this.height / 2;

    return [
      { x: -halfWidth, y: -halfHeight },  // Top-left
      { x: halfWidth, y: -halfHeight },   // Top-right
      { x: halfWidth, y: halfHeight },    // Bottom-right
      { x: -halfWidth, y: halfHeight }    // Bottom-left
    ];
  }

  getBounds() {
    const halfWidth = this.width / 2;
    const halfHeight = this.height / 2;
    const transformed = this.transformPoint({ x: -halfWidth, y: -halfHeight });

    return {
      x: transformed.x,
      y: transformed.y,
      width: this.width * this.scale.x,
      height: this.height * this.scale.y
    };
  }
}

Building This Step by Step:

  1. Add Rectangle class to src/Shapes.mjs
  2. Extend Shape class using extends Shape
  3. Create constructor with width and height parameters (default 100)
  4. Call super() to initialize parent class
  5. Store width and height as instance properties
  6. Override getPoints() method
  7. Calculate halfWidth as width / 2
  8. Calculate halfHeight as height / 2
  9. Return array of four corner points
  10. Top-left: (-halfWidth, -halfHeight)
  11. Top-right: (halfWidth, -halfHeight)
  12. Bottom-right: (halfWidth, halfHeight)
  13. Bottom-left: (-halfWidth, halfHeight)
  14. Override getBounds() method
  15. Calculate half dimensions again
  16. Transform top-left corner using transformPoint()
  17. Return bounds with transformed x, y
  18. Calculate width as width * scale.x
  19. Calculate height as height * scale.y
  20. This class provides rectangle geometry with centered origin

Step 2.3: Build the Polygon Class

What You're Building: A Polygon class that extends the base Shape class. Polygons are regular shapes with a specified number of sides (triangle, pentagon, hexagon, etc.). They're defined by a radius (distance from center to vertex) and number of sides.

Why This Class: Polygons are useful for creating regular geometric shapes. By varying the number of sides, you can create triangles (3), squares (4), pentagons (5), hexagons (6), etc. The polygon is generated by placing vertices evenly around a circle.

How to Build It Step by Step:

Step 2.3.1: Create the Polygon Class and Constructor Start by extending the base Shape class:

export class Polygon extends Shape {
  constructor(radius = 50, sides = 5) {
    // Step 2.3.1.1: Call parent constructor
    // This initializes position, rotation, and scale
    super();

    // Step 2.3.1.2: Store radius
    // Radius is the distance from center to each vertex
    // Default is 50 units
    this.radius = radius;

    // Step 2.3.1.3: Store number of sides
    // This determines the polygon type (3=triangle, 4=square, 5=pentagon, etc.)
    // Default is 5 (pentagon)
    this.sides = sides;
  }

Why Radius and Sides:

  • radius: Defines the size of the polygon (distance from center to vertices)
  • sides: Defines the shape type. More sides = more circular shape. 3 = triangle, 4 = square, 5 = pentagon, etc.

Step 2.3.2: Implement getPoints() Method Generate vertices evenly spaced around a circle:

  getPoints() {
    // Step 2.3.2.1: Initialize points array
    const points = [];

    // Step 2.3.2.2: Calculate angle step
    // Divide full circle (2π) by number of sides
    // This gives us the angle between adjacent vertices
    const angleStep = (Math.PI * 2) / this.sides;

    // Step 2.3.2.3: Generate vertices
    // Loop through each side/vertex
    for (let i = 0; i < this.sides; i++) {
      // Step 2.3.2.4: Calculate angle for this vertex
      // Start at -π/2 (top) so polygon is "upright"
      // Then add angle step for each vertex
      const angle = (i * angleStep) - (Math.PI / 2);  // Start at top

      // Step 2.3.2.5: Calculate vertex position using trigonometry
      // Same as circle, but only at specific angles (vertices)
      points.push({
        x: Math.cos(angle) * this.radius,
        y: Math.sin(angle) * this.radius
      });
    }

    // Step 2.3.2.6: Return all vertices
    return points;
  }
}

Why Start at -π/2: Starting at -π/2 (top of circle) makes the polygon "upright" - the first vertex is at the top. This is more intuitive than starting at angle 0 (right side). The - (Math.PI / 2) offset rotates the polygon so it's oriented correctly.

Why This Approach: Polygons are essentially circles with vertices at specific angles. We calculate evenly-spaced angles around the circle, then place vertices at those angles. The more sides, the closer it gets to a circle.

The Complete Class:

export class Polygon extends Shape {
  constructor(radius = 50, sides = 5) {
    super();
    this.radius = radius;
    this.sides = sides;
  }

  getPoints() {
    const points = [];
    const angleStep = (Math.PI * 2) / this.sides;

    for (let i = 0; i < this.sides; i++) {
      const angle = (i * angleStep) - (Math.PI / 2);  // Start at top
      points.push({
        x: Math.cos(angle) * this.radius,
        y: Math.sin(angle) * this.radius
      });
    }

    return points;
  }
}

Building This Step by Step:

  1. Add Polygon class to src/Shapes.mjs
  2. Extend Shape class using extends Shape
  3. Create constructor with radius (default 50) and sides (default 5) parameters
  4. Call super() to initialize parent class
  5. Store radius as instance property
  6. Store sides as instance property
  7. Override getPoints() method
  8. Initialize empty points array
  9. Calculate angleStep as (2π) / sides
  10. Add for loop from 0 to sides
  11. Calculate angle as (i * angleStep) - (π/2) (start at top)
  12. Calculate x using Math.cos(angle) * radius
  13. Calculate y using Math.sin(angle) * radius
  14. Push point to array
  15. Return points array
  16. This class provides polygon geometry with configurable sides

Test shapes:

const circle = new Circle(50);
const points = circle.getPoints();
console.log(points.length); // Should be 32

const rect = new Rectangle(100, 50);
const bounds = rect.getBounds();
console.log(bounds); // Should show bounds

Part 3: Building the Transform Manager

Step 3.1: Create the Transform Manager Class

File: src/renderer/transformManager.mjs

What You're Building: A TransformManager class that handles all transformation mathematics. This class provides pure functions (no state) for transforming points between coordinate spaces. It's the central place for all transform calculations, ensuring consistency across the entire system.

Why This Class: Transform math is complex and easy to get wrong. By centralizing it in one class, we ensure:

  • All transform code uses the same formulas
  • Bugs are fixed in one place
  • Performance optimizations benefit everything
  • Code is testable and maintainable

How to Build It Step by Step:

Step 3.1.1: Create the Class and Constructor

// File: src/renderer/transformManager.mjs

// Step 3.1.1.1: Export the class for use in other modules
export class TransformManager {

  // Step 3.1.1.2: Create constructor
  // TransformManager has no state - it's a collection of pure functions
  // Pure functions: same input always produces same output, no side effects
  constructor() {
    // No instance variables needed
    // All methods are stateless - they don't remember anything between calls
    // This makes the class thread-safe and easy to test

    // Why no state?
    // - Transform operations don't need to remember previous calculations
    // - Each transform is independent
    // - Can create one instance and reuse it everywhere
    // - No initialization needed, no cleanup needed
  }
}

DETAILED EXPLANATION OF CONSTRUCTOR: The constructor is empty because TransformManager is a utility class with only static-like methods. Each method takes inputs and returns outputs without modifying internal state.

Why no state?

  • Transform operations are mathematical functions: f(input) → output
  • No need to track previous operations or cache results
  • Multiple parts of code can use the same instance safely
  • Easier to test (no state to set up or tear down)

Step 3.1.2: Implement applyTransform() Method - Step by Step

This is the core method that applies a transform to a point. It converts a point from local space to world space by applying scale, rotation, and translation in that exact order.

  // Step 3.1.2.1: Define the method signature
  // applyTransform takes a point and a transform object
  // Returns a new point (doesn't modify the input)
  applyTransform(point, transform) {

    // Step 3.1.2.2: Extract starting point coordinates
    // We work with local copies to avoid modifying the input point
    // This ensures the function is pure (no side effects)
    let x = point.x;  // Start with X coordinate
    let y = point.y;  // Start with Y coordinate

    // DETAILED EXPLANATION:
    // We create local variables `x` and `y` that we'll modify through the transform steps.
    // This preserves the original `point` object unchanged (important for pure functions).
    // Starting values: x and y are the point's coordinates in local space.

    // Step 3.1.2.3: Apply Scale (FIRST in the transform chain)
    // Scale multiplies the point coordinates by scale factors
    // This happens in local space, before rotation
    if (transform.scale) {
      // Step 3.1.2.3.1: Extract scale values
      // Handle both array format [sx, sy] and object format {x: sx, y: sy}
      // Use logical OR (||) to provide defaults if format differs
      const scaleX = transform.scale[0] || transform.scale.x || 1;
      const scaleY = transform.scale[1] || transform.scale.y || 1;

      // Step 3.1.2.3.2: Apply scale multiplication
      // Multiply each coordinate by its corresponding scale factor
      x *= scaleX;  // x' = x * scaleX
      y *= scaleY;  // y' = y * scaleY

      // DETAILED EXPLANATION:
      // Scale operation: (x, y) → (x * scaleX, y * scaleY)
      // Example: point (10, 5) with scale [2, 2] → (20, 10)
      // - X coordinate doubled: 10 * 2 = 20
      // - Y coordinate doubled: 5 * 2 = 10
      // - Result: Point moves twice as far from origin (shape becomes 2x larger)

      // Why check for transform.scale?
      // - If scale is not provided, skip scaling (assume scale of 1)
      // - Prevents errors if transform object is incomplete
      // - Allows partial transforms (e.g., only translation, no scale)
    }

    // Step 3.1.2.4: Apply Rotation (SECOND in the transform chain)
    // Rotation rotates the point around the origin using trigonometry
    // This happens after scaling, but before translation
    if (transform.rotation) {

      // Step 3.1.2.4.1: Convert rotation from degrees to radians
      // JavaScript Math.cos() and Math.sin() require radians, not degrees
      // Formula: radians = degrees × (π / 180)
      const rad = (transform.rotation * Math.PI) / 180;

      // DETAILED EXPLANATION:
      // Rotation is stored in degrees (user-friendly: 90°, 180°, etc.)
      // But trigonometric functions use radians (0 to 2π)
      // Conversion: 90° = 90 × (π/180) = π/2 ≈ 1.5708 radians
      // Example: rotation = 45° → rad = 45 × (π/180) = π/4 ≈ 0.7854

      // Step 3.1.2.4.2: Calculate cosine and sine of rotation angle
      // These values are used in the rotation matrix formula
      // We calculate once and reuse (more efficient than calculating twice)
      const cos = Math.cos(rad);  // Cosine of rotation angle
      const sin = Math.sin(rad);  // Sine of rotation angle

      // DETAILED EXPLANATION:
      // Math.cos() returns the cosine: how far right/left at this angle
      // Math.sin() returns the sine: how far up/down at this angle
      // These are pre-calculated once for efficiency (used in both x' and y' calculations)
      // Common values:
      //   cos(0°) = 1, sin(0°) = 0
      //   cos(90°) = 0, sin(90°) = 1
      //   cos(180°) = -1, sin(180°) = 0
      //   cos(45°) ≈ 0.707, sin(45°) ≈ 0.707

      // Step 3.1.2.4.3: Apply rotation matrix
      // Rotation matrix formula rotates point around origin (0, 0)
      // Formula: 
      //   x' = x*cos(θ) - y*sin(θ)
      //   y' = x*sin(θ) + y*cos(θ)
      const rotatedX = x * cos - y * sin;  // Calculate new X after rotation
      const rotatedY = x * sin + y * cos;  // Calculate new Y after rotation

      // DETAILED EXPLANATION:
      // This is the standard 2D rotation matrix formula.
      // It rotates point (x, y) by angle θ around the origin.
      // 
      // Mathematical derivation:
      // - Original point at angle α: x = r*cos(α), y = r*sin(α)
      // - After rotation by θ: new angle = α + θ
      // - New coordinates: x' = r*cos(α+θ), y' = r*sin(α+θ)
      // - Using angle addition formulas:
      //     cos(α+θ) = cos(α)cos(θ) - sin(α)sin(θ)
      //     sin(α+θ) = sin(α)cos(θ) + cos(α)sin(θ)
      // - Substituting x and y:
      //     x' = x*cos(θ) - y*sin(θ)
      //     y' = x*sin(θ) + y*cos(θ)
      //
      // Example walkthrough:
      //   Starting point (after scale): x = 20, y = 0 (pointing right)
      //   Rotation: 90° counter-clockwise
      //   cos(90°) = 0, sin(90°) = 1
      //   x' = 20*0 - 0*1 = 0
      //   y' = 20*1 + 0*0 = 20
      //   Result: (0, 20) - point moved to top (rotated 90°) ✓

      // Step 3.1.2.4.4: Update coordinates with rotated values
      x = rotatedX;  // Replace x with rotated x
      y = rotatedY;  // Replace y with rotated y

      // After this step, point has been scaled and rotated, but still at origin
    }

    // Step 3.1.2.5: Apply Translation (THIRD and FINAL in the transform chain)
    // Translation moves the point to its final world position
    // This happens last, after scaling and rotation
    if (transform.position) {

      // Step 3.1.2.5.1: Extract position values
      // Handle both array format [x, y] and object format {x: x, y: y}
      const posX = transform.position[0] || transform.position.x || 0;
      const posY = transform.position[1] || transform.position.y || 0;

      // DETAILED EXPLANATION:
      // Position is where the shape's center is located in world space.
      // We extract X and Y coordinates, with defaults of 0 if not provided.
      // This allows transforms with only rotation/scale, no translation.

      // Step 3.1.2.5.2: Add position offset to coordinates
      // Translation is simple addition: move point by position offset
      x += posX;  // x' = x + positionX
      y += posY;  // y' = y + positionY

      // DETAILED EXPLANATION:
      // Translation operation: (x, y) → (x + posX, y + posY)
      // This moves the point from its rotated/scaled position to world space.
      // 
      // Example:
      //   After rotation: x = 0, y = 20 (point at top, relative to origin)
      //   Position: (100, 50) (shape center at world position 100, 50)
      //   Final: x = 0 + 100 = 100, y = 20 + 50 = 70
      //   Result: Point is at (100, 70) in world space ✓

      // Why translation is last:
      // - If we translated first, rotation would happen around world origin (0, 0)
      // - By translating last, rotation happens around shape's local origin
      // - This ensures shape rotates around its own center, not world origin
    }

    // Step 3.1.2.6: Return transformed point
    // Return a new object with transformed coordinates
    // Don't modify the original point object (pure function)
    return { x, y };

    // DETAILED EXPLANATION:
    // We return a new object containing the transformed coordinates.
    // The original `point` parameter is unchanged (important for pure functions).
    // Return format: { x: number, y: number } - matches input format.
  }

Complete Example Walkthrough of applyTransform():

Let's trace through a complete example step-by-step:

// Input:
const point = { x: 10, y: 0 };  // Point 10 units to the right of origin
const transform = {
  position: [100, 50],  // Move shape to (100, 50)
  rotation: 90,         // Rotate 90° counter-clockwise
  scale: [2, 2]         // Double the size
};

// Step 1: Start with point coordinates
x = 10
y = 0

// Step 2: Apply scale
scaleX = 2, scaleY = 2
x = 10 * 2 = 20
y = 0 * 2 = 0
// After scale: (20, 0) - point is now 20 units to the right

// Step 3: Apply rotation
rotation = 90°
rad = 90 × (π/180) = π/21.5708
cos(90°) = 0
sin(90°) = 1
rotatedX = 20*0 - 0*1 = 0
rotatedY = 20*1 + 0*0 = 20
x = 0
y = 20
// After rotation: (0, 20) - point is now 20 units up

// Step 4: Apply translation
posX = 100, posY = 50
x = 0 + 100 = 100
y = 20 + 50 = 70
// After translation: (100, 70) - point is at final world position

// Final result: { x: 100, y: 70 }
// The point that started at (10, 0) is now at (100, 70) in world space

Step 3.1.3: Implement invertTransform() Method - Step by Step

This method does the opposite of applyTransform - it converts a point from world space back to local space. This is essential for hit testing (determining if a click is inside a shape).

  // Step 3.1.3.1: Define the method signature
  // invertTransform takes a world-space point and a transform
  // Returns the point in local space (undoes the transform)
  invertTransform(point, transform) {

    // Step 3.1.3.2: Extract starting point coordinates (world space)
    let x = point.x;  // World X coordinate
    let y = point.y;  // World Y coordinate

    // DETAILED EXPLANATION:
    // We start with a point in world space (absolute position).
    // Our goal: convert it back to local space (relative to shape center).
    // We'll undo each transform operation in REVERSE order.

    // Step 3.1.3.3: Remove Translation (FIRST - undoes the last step of applyTransform)
    // Subtract the position offset to move point back toward origin
    if (transform.position) {
      const posX = transform.position[0] || transform.position.x || 0;
      const posY = transform.position[1] || transform.position.y || 0;

      // Subtract position (opposite of adding in applyTransform)
      x -= posX;  // x' = x - positionX
      y -= posY;  // y' = y - positionY

      // DETAILED EXPLANATION:
      // Forward transform added position: x += posX
      // Inverse transform subtracts position: x -= posX
      // This moves the point back toward the origin.
      //
      // Example:
      //   World point: (150, 100)
      //   Shape position: (100, 50)
      //   After removing translation: (150-100, 100-50) = (50, 50)
      //   Point is now relative to shape's local origin
    }

    // Step 3.1.3.4: Remove Rotation (SECOND - undoes rotation)
    // Rotate by negative angle (opposite direction)
    if (transform.rotation) {

      // Step 3.1.3.4.1: Convert rotation to radians, but NEGATIVE
      // Rotating by -θ undoes rotation by +θ
      const rad = (-transform.rotation * Math.PI) / 180;  // Note: NEGATIVE!

      // DETAILED EXPLANATION:
      // Forward transform rotated by +θ (e.g., 90° counter-clockwise)
      // Inverse transform rotates by -θ (e.g., 90° clockwise, which is -90°)
      // Rotating by negative angle undoes the original rotation.
      //
      // Example:
      //   Forward: rotated 90° counter-clockwise
      //   Inverse: rotate -90° (which is 90° clockwise)
      //   Result: Point rotated back to original orientation ✓

      // Step 3.1.3.4.2: Calculate cosine and sine of negative angle
      const cos = Math.cos(rad);  // cos(-θ) = cos(θ) [cosine is even]
      const sin = Math.sin(rad);  // sin(-θ) = -sin(θ) [sine is odd]

      // DETAILED EXPLANATION:
      // Trigonometric identities:
      //   cos(-θ) = cos(θ)  (cosine is an even function)
      //   sin(-θ) = -sin(θ) (sine is an odd function)
      // Example: cos(-90°) = cos(90°) = 0, sin(-90°) = -sin(90°) = -1

      // Step 3.1.3.4.3: Apply rotation matrix with negative angle
      // Same formula as forward, but with negative angle
      const rotatedX = x * cos - y * sin;
      const rotatedY = x * sin + y * cos;

      // DETAILED EXPLANATION:
      // This is the same rotation matrix formula, but with negative angle.
      // It rotates the point back to its original orientation.
      //
      // Example walkthrough:
      //   After removing translation: x = 0, y = 20
      //   Original rotation was 90° counter-clockwise
      //   Inverse rotation: -90° (clockwise)
      //   cos(-90°) = 0, sin(-90°) = -1
      //   rotatedX = 0*0 - 20*(-1) = 20
      //   rotatedY = 0*(-1) + 20*0 = 0
      //   Result: (20, 0) - point back to pointing right ✓

      x = rotatedX;
      y = rotatedY;
    }

    // Step 3.1.3.5: Remove Scale (THIRD and FINAL - undoes scaling)
    // Divide by scale factors (opposite of multiplying)
    if (transform.scale) {
      const scaleX = transform.scale[0] || transform.scale.x || 1;
      const scaleY = transform.scale[1] || transform.scale.y || 1;

      // Step 3.1.3.5.1: Check for zero scale (prevent division by zero)
      if (scaleX !== 0 && scaleY !== 0) {
        // Divide by scale (opposite of multiplying in applyTransform)
        x /= scaleX;  // x' = x / scaleX
        y /= scaleY;  // y' = y / scaleY

        // DETAILED EXPLANATION:
        // Forward transform multiplied: x *= scaleX
        // Inverse transform divides: x /= scaleX
        // This undoes the scaling.
        //
        // Example:
        //   After removing rotation: x = 20, y = 0
        //   Original scale was [2, 2]
        //   After removing scale: (20/2, 0/2) = (10, 0)
        //   Result: Point back to original size ✓

      } else {
        // Edge case: scale is zero
        // Division by zero is undefined - handle as error or special case
        console.warn('Cannot invert transform: scale is zero');
        // Could return NaN or throw error, depending on requirements
      }
    }

    // Step 3.1.3.6: Return point in local space
    return { x, y };

    // DETAILED EXPLANATION:
    // The point is now back in local space (relative to shape center at 0,0).
    // This can be used for hit testing: check if local point is inside shape's geometry.
  }

Complete Example Walkthrough of invertTransform():

Let's verify it works by inverting the previous example:

// Input (result from applyTransform example):
const worldPoint = { x: 100, y: 70 };  // World space point
const transform = {
  position: [100, 50],
  rotation: 90,
  scale: [2, 2]
};

// Step 1: Start with world point
x = 100
y = 70

// Step 2: Remove translation
x = 100 - 100 = 0
y = 70 - 50 = 20
// After removing translation: (0, 20)

// Step 3: Remove rotation
rotation = -90° (negative of original 90°)
rad = -90 × (π/180) = -π/2
cos(-90°) = 0
sin(-90°) = -1
rotatedX = 0*0 - 20*(-1) = 20
rotatedY = 0*(-1) + 20*0 = 0
x = 20
y = 0
// After removing rotation: (20, 0)

// Step 4: Remove scale
scaleX = 2, scaleY = 2
x = 20 / 2 = 10
y = 0 / 2 = 0
// After removing scale: (10, 0)

// Final result: { x: 10, y: 0 }
// Matches original point! ✓ Inverse transform works correctly

Step 3.1.4: Implement calculateBounds() Method - Step by Step

This method calculates the bounding box of a shape after applying transforms. The bounding box is the smallest rectangle that contains the entire shape.

  // Step 3.1.4.1: Define the method signature
  // calculateBounds takes a shape object and a transform
  // Returns a bounding box: {x, y, width, height}
  calculateBounds(shape, transform) {

    // Step 3.1.4.2: Get all points of the shape
    // Shape must have a getPoints() method that returns array of {x, y} points
    const points = shape.getPoints();

    // DETAILED EXPLANATION:
    // We need all the points that define the shape's outline.
    // getPoints() returns points in local space (relative to shape center).
    // These points haven't been transformed yet.

    // Step 3.1.4.3: Handle edge case - empty shape
    if (points.length === 0) {
      // No points means no shape - return empty bounds
      return { x: 0, y: 0, width: 0, height: 0 };
    }

    // DETAILED EXPLANATION:
    // Some shapes might return empty point arrays (edge case).
    // We handle this gracefully by returning empty bounds.
    // This prevents errors in the min/max calculations below.

    // Step 3.1.4.4: Initialize min/max variables
    // We'll track the extreme values to find the bounding box
    let minX = Infinity;  // Start with largest possible value
    let maxX = -Infinity; // Start with smallest possible value
    let minY = Infinity;  // Start with largest possible value
    let maxY = -Infinity; // Start with smallest possible value

    // DETAILED EXPLANATION:
    // We use Infinity and -Infinity so any real number will be smaller/larger.
    // This ensures the first point's coordinates will replace these initial values.
    // Example: minX = Infinity, first point x = 10 → minX becomes 10

    // Step 3.1.4.5: Loop through all points and find extremes
    for (const point of points) {

      // Step 3.1.4.5.1: Transform point to world space
      // Use applyTransform to get point's world position
      const transformed = this.applyTransform(point, transform);

      // DETAILED EXPLANATION:
      // Each point needs to be transformed before calculating bounds.
      // A rotated shape has different bounds than an unrotated one.
      // Example: 100×50 rectangle rotated 45° needs larger bounding box.

      // Step 3.1.4.5.2: Update min/max values
      // Track the extreme X and Y coordinates
      minX = Math.min(minX, transformed.x);  // Find leftmost X
      maxX = Math.max(maxX, transformed.x);  // Find rightmost X
      minY = Math.min(minY, transformed.y);  // Find topmost Y
      maxY = Math.max(maxY, transformed.y);  // Find bottommost Y

      // DETAILED EXPLANATION:
      // Math.min() and Math.max() compare values and keep the extreme.
      // After looping through all points, we'll have:
      //   minX = leftmost X coordinate
      //   maxX = rightmost X coordinate
      //   minY = topmost Y coordinate
      //   maxY = bottommost Y coordinate
      //
      // Example:
      //   Points: (10, 5), (20, 15), (5, 10)
      //   After loop: minX = 5, maxX = 20, minY = 5, maxY = 15
    }

    // Step 3.1.4.6: Calculate and return bounding box
    return {
      x: minX,                    // Left edge of bounding box
      y: minY,                    // Top edge of bounding box
      width: maxX - minX,         // Width = right edge - left edge
      height: maxY - minY         // Height = bottom edge - top edge
    };

    // DETAILED EXPLANATION:
    // Bounding box is defined by:
    //   x, y: Top-left corner position
    //   width: Distance from left to right edge
    //   height: Distance from top to bottom edge
    //
    // Example:
    //   minX = 10, maxX = 50 → width = 50 - 10 = 40
    //   minY = 20, maxY = 60 → height = 60 - 20 = 40
    //   Bounds: {x: 10, y: 20, width: 40, height: 40}
  }

Step 3.1.5: Implement combineTransforms() Method - Step by Step

This method combines two transforms into one. Useful for nested transforms (e.g., shape inside a group).

  // Step 3.1.5.1: Define the method signature
  // combineTransforms takes two transform objects
  // Returns a new transform that is equivalent to applying transform2 after transform1
  combineTransforms(transform1, transform2) {

    // Step 3.1.5.2: Extract positions from both transforms
    // Get position arrays, with defaults if not provided
    const pos1 = transform1.position || [0, 0];
    const pos2 = transform2.position || [0, 0];

    // DETAILED EXPLANATION:
    // We need both position values to combine them.
    // Default to [0, 0] if position is missing (no translation).

    // Step 3.1.5.3: Transform transform2's position by transform1
    // This is the most complex part - position combining requires full transform
    const transformedPos2 = this.applyTransform(
      { x: pos2[0] || pos2.x || 0, y: pos2[1] || pos2.y || 0 },
      transform1
    );

    // DETAILED EXPLANATION:
    // When combining transforms, transform2's position is affected by transform1.
    // Example: If transform1 rotates 90°, transform2's position also rotates.
    // We use applyTransform to apply transform1 to transform2's position.
    //
    // Why this is necessary:
    //   Transform1: position (100, 0), rotation 90°
    //   Transform2: position (0, 50) relative to transform1
    //   Combined: position should be (100, 0) rotated 90° then moved (0, 50)
    //   Result: position at world location after both transforms

    // Step 3.1.5.4: Combine rotations (simple addition)
    const rot1 = transform1.rotation || 0;
    const rot2 = transform2.rotation || 0;
    const combinedRotation = rot1 + rot2;

    // DETAILED EXPLANATION:
    // Rotations are additive: rotating by 30° then 45° = 75° total.
    // Example: rot1 = 45°, rot2 = 30° → combined = 75°

    // Step 3.1.5.5: Combine scales (multiplicative)
    // Extract scale values, handling both array and object formats
    const scale1 = transform1.scale || [1, 1];
    const scale2 = transform2.scale || [1, 1];

    // Extract individual scale components with defaults
    const scale1X = scale1[0] || scale1.x || 1;
    const scale1Y = scale1[1] || scale1.y || 1;
    const scale2X = scale2[0] || scale2.x || 1;
    const scale2Y = scale2[1] || scale2.y || 1;

    // Multiply scales together (scales compound)
    const combinedScaleX = scale1X * scale2X;
    const combinedScaleY = scale1Y * scale2Y;

    // DETAILED EXPLANATION:
    // Scales are multiplicative: scaling by 2x then 1.5x = 3x total.
    // Example: scale1 = [2, 2], scale2 = [1.5, 1.5] → combined = [3, 3]
    //   X: 2 × 1.5 = 3
    //   Y: 2 × 1.5 = 3

    // Step 3.1.5.6: Return combined transform
    return {
      position: [transformedPos2.x, transformedPos2.y],  // Combined position
      rotation: combinedRotation,                         // Combined rotation
      scale: [combinedScaleX, combinedScaleY]            // Combined scale
    };

    // DETAILED EXPLANATION:
    // The returned transform is equivalent to applying transform1, then transform2.
    // This can be used instead of applying two transforms separately (more efficient).
  }
}

Step 3.1.6: Testing the Transform Manager

Test 1: Basic Transform Application

// Create transform manager instance
const tm = new TransformManager();

// Test point
const point = { x: 10, y: 10 };

// Test transform
const transform = {
  position: [5, 5],    // Move to (5, 5)
  rotation: 45,        // Rotate 45°
  scale: [2, 2]        // Double size
};

// Apply transform
const transformed = tm.applyTransform(point, transform);
console.log('Original point:', point);
console.log('Transform:', transform);
console.log('Transformed point:', transformed);
// Expected: Transformed point with scale, rotation, and translation applied

// Verify step by step:
// Step 1: Scale (10, 10) * [2, 2] = (20, 20)
// Step 2: Rotate 45°: (20*cos(45°) - 20*sin(45°), 20*sin(45°) + 20*cos(45°))
//         = (20*0.707 - 20*0.707, 20*0.707 + 20*0.707) ≈ (0, 28.28)
// Step 3: Translate: (0+5, 28.28+5) ≈ (5, 33.28)

Test 2: Inverse Transform (Round Trip)

const tm = new TransformManager();

// Original point in local space
const localPoint = { x: 10, y: 5 };
const transform = {
  position: [100, 50],
  rotation: 90,
  scale: [2, 2]
};

// Forward transform: local → world
const worldPoint = tm.applyTransform(localPoint, transform);
console.log('Local point:', localPoint);
console.log('World point:', worldPoint);

// Inverse transform: world → local
const backToLocal = tm.invertTransform(worldPoint, transform);
console.log('Back to local:', backToLocal);
console.log('Round trip successful:', 
  Math.abs(backToLocal.x - localPoint.x) < 0.001 &&
  Math.abs(backToLocal.y - localPoint.y) < 0.001
);
// Expected: true (point should return to original position)

Test 3: Bounds Calculation

const tm = new TransformManager();

// Create a simple rectangle shape (mock)
const rectangle = {
  getPoints() {
    // Rectangle 100×50, centered at origin
    return [
      { x: -50, y: -25 },  // Top-left
      { x: 50, y: -25 },   // Top-right
      { x: 50, y: 25 },    // Bottom-right
      { x: -50, y: 25 }    // Bottom-left
    ];
  }
};

// Transform: position (100, 50), rotation 0°, scale [1, 1]
const transform = {
  position: [100, 50],
  rotation: 0,
  scale: [1, 1]
};

const bounds = tm.calculateBounds(rectangle, transform);
console.log('Bounds:', bounds);
// Expected: { x: 50, y: 25, width: 100, height: 50 }
// (Rectangle at (100, 50), so bounds are 50-150 X, 25-75 Y)

// Test with rotation
const rotatedTransform = {
  position: [100, 50],
  rotation: 45,
  scale: [1, 1]
};

const rotatedBounds = tm.calculateBounds(rectangle, rotatedTransform);
console.log('Rotated bounds:', rotatedBounds);
// Expected: Larger bounds (rotated rectangle needs bigger box)

Test 4: Combining Transforms

const tm = new TransformManager();

// Transform 1: Move and scale
const transform1 = {
  position: [100, 50],
  rotation: 0,
  scale: [2, 2]
};

// Transform 2: Rotate (relative to transform1)
const transform2 = {
  position: [0, 0],
  rotation: 90,
  scale: [1, 1]
};

// Combine them
const combined = tm.combineTransforms(transform1, transform2);
console.log('Transform 1:', transform1);
console.log('Transform 2:', transform2);
console.log('Combined:', combined);
// Expected: Combined transform with position affected by rotation,
//           rotation = 90°, scale = [2, 2]

// Test: Applying combined should equal applying both separately
const point = { x: 10, y: 5 };

const result1 = tm.applyTransform(point, transform1);
const result2 = tm.applyTransform(result1, transform2);

const resultCombined = tm.applyTransform(point, combined);

console.log('Apply separately:', result2);
console.log('Apply combined:', resultCombined);
console.log('Results match:', 
  Math.abs(result2.x - resultCombined.x) < 0.001 &&
  Math.abs(result2.y - resultCombined.y) < 0.001
);
// Expected: true (combined transform should produce same result)

Test 5: Edge Cases

const tm = new TransformManager();

// Edge case 1: Empty transform (identity)
const identityTransform = {
  position: [0, 0],
  rotation: 0,
  scale: [1, 1]
};
const point = { x: 10, y: 20 };
const transformed = tm.applyTransform(point, identityTransform);
console.log('Identity transform:', transformed);
// Expected: { x: 10, y: 20 } (point unchanged)

// Edge case 2: Zero scale (should be handled)
const zeroScaleTransform = {
  position: [0, 0],
  rotation: 0,
  scale: [0, 0]
};
try {
  const zeroResult = tm.applyTransform(point, zeroScaleTransform);
  console.log('Zero scale result:', zeroResult);
  // Expected: { x: 0, y: 0 } (point collapsed to origin)
} catch (e) {
  console.log('Zero scale error handled');
}

// Edge case 3: Missing transform properties
const partialTransform = {
  position: [10, 10]
  // No rotation or scale
};
const partialResult = tm.applyTransform(point, partialTransform);
console.log('Partial transform:', partialResult);
// Expected: { x: 20, y: 30 } (only translation applied)

// Edge case 4: Negative scale (mirroring)
const mirrorTransform = {
  position: [0, 0],
  rotation: 0,
  scale: [-1, 1]  // Mirror horizontally
};
const mirrorResult = tm.applyTransform(point, mirrorTransform);
console.log('Mirror transform:', mirrorResult);
// Expected: { x: -10, y: 20 } (flipped horizontally)

Step 3.1.7: Common Errors and How to Fix Them

Error 1: Transform order wrong

// WRONG: Applying transforms in wrong order
let x = point.x;
x += transform.position[0];  // Translation first (WRONG!)
x *= transform.scale[0];     // Scale second (WRONG!)
// Rotation...

// CORRECT: Scale → Rotate → Translate
let x = point.x;
x *= transform.scale[0];     // Scale first
// Rotation...
x += transform.position[0];  // Translate last

Error 2: Forgetting to convert degrees to radians

// WRONG: Using degrees directly
const cos = Math.cos(transform.rotation);  // Wrong! rotation is in degrees

// CORRECT: Convert to radians first
const rad = (transform.rotation * Math.PI) / 180;
const cos = Math.cos(rad);

Error 3: Division by zero in inverse transform

// WRONG: Not checking for zero scale
x /= transform.scale[0];  // Crashes if scale is 0!

// CORRECT: Check for zero before dividing
if (transform.scale[0] !== 0) {
  x /= transform.scale[0];
} else {
  // Handle error: cannot invert zero scale
  return { x: NaN, y: NaN };  // Or throw error
}

Error 4: Modifying input point

// WRONG: Modifying the input point
point.x *= scale;  // Modifies original point (side effect)

// CORRECT: Work with copies
let x = point.x;  // Copy to local variable
x *= scale;       // Modify copy, not original

Error 5: Not handling both transform formats

// WRONG: Only handles array format
const posX = transform.position[0];  // Breaks if position is {x: 10, y: 20}

// CORRECT: Handle both formats
const posX = transform.position[0] || transform.position.x || 0;

Step 3.1.8: Performance Optimizations

Optimization 1: Skip unnecessary calculations

// Check if rotation is zero before calculating sin/cos
if (transform.rotation !== 0) {
  // Only do expensive trigonometry if rotation needed
  const rad = (transform.rotation * Math.PI) / 180;
  const cos = Math.cos(rad);
  const sin = Math.sin(rad);
  // ... rotation math
}

Optimization 2: Cache trigonometric values (for repeated transforms)

// If applying same rotation to many points, cache sin/cos
class TransformManager {
  constructor() {
    this.cosCache = new Map();
    this.sinCache = new Map();
  }

  applyTransform(point, transform) {
    // ... scale ...

    if (transform.rotation !== 0) {
      // Check cache first
      let cos = this.cosCache.get(transform.rotation);
      let sin = this.sinCache.get(transform.rotation);

      if (cos === undefined) {
        // Calculate and cache
        const rad = (transform.rotation * Math.PI) / 180;
        cos = Math.cos(rad);
        sin = Math.sin(rad);
        this.cosCache.set(transform.rotation, cos);
        this.sinCache.set(transform.rotation, sin);
      }

      // Use cached values
      // ... rotation math ...
    }
  }
}

Optimization 3: Early bounds calculation for simple shapes

// For axis-aligned rectangles (no rotation), bounds are simpler
calculateBounds(shape, transform) {
  if (transform.rotation === 0 && shape.type === 'rectangle') {
    // Fast path: no rotation, use simple calculation
    const width = shape.params.width * (transform.scale[0] || 1);
    const height = shape.params.height * (transform.scale[1] || 1);
    return {
      x: transform.position[0] - width/2,
      y: transform.position[1] - height/2,
      width: width,
      height: height
    };
  }

  // Slow path: generic calculation for rotated shapes
  // ... existing code ...
}

Part 4: Building More Complex Shapes

Step 4.1: Build the Star Class

export class Star extends Shape {
  constructor(outerRadius = 50, innerRadius = 25, points = 5) {
    super();
    this.outerRadius = outerRadius;
    this.innerRadius = innerRadius;
    this.points = points;
  }

  getPoints() {
    const pointArray = [];
    const angleStep = (Math.PI * 2) / (this.points * 2);

    for (let i = 0; i < this.points * 2; i++) {
      const angle = (i * angleStep) - (Math.PI / 2);
      const radius = i % 2 === 0 ? this.outerRadius : this.innerRadius;
      pointArray.push({
        x: Math.cos(angle) * radius,
        y: Math.sin(angle) * radius
      });
    }

    return pointArray;
  }
}

Step 4.2: Build the Ellipse Class

export class Ellipse extends Shape {
  constructor(width = 100, height = 50) {
    super();
    this.width = width;
    this.height = height;
  }

  getPoints() {
    const points = [];
    const segments = 32;
    const halfWidth = this.width / 2;
    const halfHeight = this.height / 2;

    for (let i = 0; i < segments; i++) {
      const angle = (i / segments) * Math.PI * 2;
      points.push({
        x: Math.cos(angle) * halfWidth,
        y: Math.sin(angle) * halfHeight
      });
    }

    return points;
  }
}

Part 5: Using Shapes with the Renderer

Step 5.1: Integrate Shapes with Renderer

// In renderer.mjs
import { Circle, Rectangle, Polygon } from './Shapes.mjs';
import { TransformManager } from './renderer/transformManager.mjs';

export class Renderer {
  constructor(canvas) {
    // ... existing code ...
    this.transformManager = new TransformManager();
  }

  drawShape(shape) {
    // Get shape class instance
    const shapeClass = this.getShapeClass(shape);
    if (!shapeClass) return;

    // Get points
    const points = shapeClass.getPoints();

    // Apply transform
    const transform = shape.transform || {
      position: [shape.params.x || 0, shape.params.y || 0],
      rotation: shape.params.rotation || 0,
      scale: [1, 1]
    };

    // Convert to screen coordinates and draw
    this.ctx.beginPath();
    for (let i = 0; i < points.length; i++) {
      const transformed = this.transformManager.applyTransform(points[i], transform);
      const screen = this.coordinateSystem.worldToScreen(transformed.x, transformed.y);

      if (i === 0) {
        this.ctx.moveTo(screen.x, screen.y);
      } else {
        this.ctx.lineTo(screen.x, screen.y);
      }
    }
    this.ctx.closePath();

    // Apply style and draw
    const style = this.styleManager.getStyleContext(shape);
    if (style.fill) {
      this.ctx.fillStyle = style.fillColor;
      this.ctx.fill();
    }
    if (style.stroke) {
      this.ctx.strokeStyle = style.strokeColor;
      this.ctx.stroke();
    }
  }

  getShapeClass(shape) {
    const params = shape.params;

    switch (shape.type) {
      case 'circle':
        return new Circle(params.radius || 50);
      case 'rectangle':
        return new Rectangle(params.width || 100, params.height || 100);
      case 'polygon':
        return new Polygon(params.radius || 50, params.sides || 5);
      case 'star':
        return new Star(
          params.outerRadius || 50,
          params.innerRadius || 25,
          params.points || 5
        );
      case 'ellipse':
        return new Ellipse(params.width || 100, params.height || 50);
      default:
        return null;
    }
  }
}

Common Issues and Fixes

Issue: Points in wrong position

  • Check transform order (scale → rotate → translate)
  • Check coordinate system (world vs screen)
  • Verify transform values are correct

Issue: Rotation around wrong point

  • Ensure rotation happens before translation
  • Check rotation center (should be shape origin)

Issue: Scale doesn't work

  • Check scale values (should be positive)
  • Verify scale is applied before rotation
  • Check for division by zero

Issue: Bounds incorrect

  • Verify all points are included in bounds calculation
  • Check transform is applied to all points
  • Verify bounds account for rotation

results matching ""

    No results matching ""